@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
@@ -62,7 +62,7 @@ const PATH_KEYS = [
62
62
  "edits", // NotebookEdit-style payloads are checked via file_path too
63
63
  ];
64
64
  const DENIED_WITHIN = [
65
- /(^|\/)secrets\.json$/,
65
+ /(^|\/)secrets\.json(\.[^/]+)?$/, // and its lock and temp files
66
66
  /(^|\/)runs(\/|$)/,
67
67
  ];
68
68
  /**
@@ -96,6 +96,8 @@ const VIRTUAL = [
96
96
  { prefix: "workspace/", root: (r) => r.workspaceRoot, readOnly: false },
97
97
  ];
98
98
  const WRITE_TOOLS = new Set(["Write", "Edit", "MultiEdit", "NotebookEdit"]);
99
+ /** Workspace-level directories an agent's own folder must never grow. */
100
+ const SHARED_DIRS = new Set(["storage", "state", "workspace"]);
99
101
  // Only the built-in filesystem tools get path checks. foldrun's own MCP tools
100
102
  // are already confined at their own layer — script paths by resolveRunPath,
101
103
  // APIs by their declared base URL — and their arguments are not filesystem
@@ -123,7 +125,12 @@ export const isFilesystemTool = (toolName) => FS_TOOLS.has(toolName);
123
125
  */
124
126
  export function suggestPath(raw) {
125
127
  const posix = raw.split(path.sep).join("/");
126
- const m = posix.match(/(?:^|\/)(outputs|storage|state|memory|knowledge)\/(.+)$/);
128
+ // By segments, not by regex: an unanchored `(.+)$` re-scans from every
129
+ // slash, and this runs on a path an agent chose.
130
+ const KNOWN = ["outputs", "storage", "state", "memory", "knowledge"];
131
+ const segs = posix.split("/");
132
+ const at = segs.findIndex((seg, i) => KNOWN.includes(seg) && segs.slice(i + 1).join("/") !== "");
133
+ const m = at === -1 ? null : [null, segs[at], segs.slice(at + 1).join("/")];
127
134
  if (m) {
128
135
  const [, dir, rest] = m;
129
136
  return dir === "outputs"
@@ -136,11 +143,40 @@ export function suggestPath(raw) {
136
143
  }
137
144
  return `Use a path relative to your agent directory, or workspace/<dir>/… for the workspace root (workspace/storage/, workspace/state/).`;
138
145
  }
146
+ /**
147
+ * A path an author wrote in a flow or an agent wrote in a tool call, as an
148
+ * absolute path: `workspace/…` from the workspace root, anything else from
149
+ * the agent's own folder (so the older `../../storage/x` lands in the same
150
+ * place as `workspace/storage/x`). Confinement is the caller's: this only
151
+ * says where the path points.
152
+ */
153
+ export function resolveAgentPath(workspaceRoot, agentDir, raw) {
154
+ const p = raw.trim();
155
+ if (p === "workspace" || p === "workspace/")
156
+ return path.resolve(workspaceRoot);
157
+ if (p.startsWith("workspace/"))
158
+ return path.resolve(workspaceRoot, p.slice("workspace/".length));
159
+ return path.resolve(agentDir, p);
160
+ }
161
+ /** True when `abs` is `root` or under it. */
162
+ export function isWithin(root, abs) {
163
+ const rel = path.relative(path.resolve(root), path.resolve(abs));
164
+ return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
165
+ }
139
166
  /** Expand a virtual prefix to a real absolute path, or null if it has none. */
140
167
  export function expandVirtual(raw, roots) {
168
+ // The SDK resolves a relative path against the cwd BEFORE canUseTool sees
169
+ // it, so `workspace/storage/x.json` arrives as
170
+ // `<agentDir>/workspace/storage/x.json` and never matched a prefix. The
171
+ // write then made a literal `workspace/` folder inside the agent's own
172
+ // directory and reported success: gbp-desk's reply-sheet, 2026-09-24, whose
173
+ // approved replies never reached the poster. Read the prefix off the path
174
+ // relative to the agent directory as well as off the raw one.
175
+ const rel = path.isAbsolute(raw) ? path.relative(roots.agentDir, raw).split(path.sep).join("/") : null;
176
+ const candidate = rel !== null && !rel.startsWith("..") && !path.isAbsolute(rel) ? rel : raw;
141
177
  for (const v of VIRTUAL) {
142
- if (raw.startsWith(v.prefix)) {
143
- return { abs: path.resolve(v.root(roots), raw.slice(v.prefix.length)), readOnly: v.readOnly };
178
+ if (candidate.startsWith(v.prefix)) {
179
+ return { abs: path.resolve(v.root(roots), candidate.slice(v.prefix.length)), readOnly: v.readOnly };
144
180
  }
145
181
  }
146
182
  return null;
@@ -200,6 +236,25 @@ export function checkPaths(toolName, input, roots) {
200
236
  `environment variables, and the run journal is not yours to read or rewrite.`,
201
237
  };
202
238
  }
239
+ // storage/, state/ and workspace/ belong to the workspace. The same name
240
+ // under an agent's own folder is always a missing prefix — `storage/x`
241
+ // written from agents/<name>/ — and the write used to succeed there,
242
+ // where no other agent looks: gbp-desk's approved replies, lawyer-desk's
243
+ // batch, five files in all by 2026-09-24. Refuse it with the path that
244
+ // was meant; the model takes the correction on its next turn.
245
+ if (isWrite && !virtual) {
246
+ const own = path.relative(real(path.resolve(roots.agentDir)), abs).split(path.sep).join("/");
247
+ const [top, ...rest] = own.split("/");
248
+ if (!own.startsWith("..") && rest.length && SHARED_DIRS.has(top)) {
249
+ const meant = top === "workspace" ? `workspace/${rest.join("/")}` : `workspace/${top}/${rest.join("/")}`;
250
+ return {
251
+ ok: false,
252
+ reason: `${toolName} was denied: "${raw}" would create ${top}/ inside your own agent folder, where no ` +
253
+ `other agent or run looks. Did you mean ${meant}? Shared files go under workspace/ ` +
254
+ `(workspace/storage/, workspace/state/, workspace/memory/); your own scratch goes to outputs/.`,
255
+ };
256
+ }
257
+ }
203
258
  if (isWrite && READ_ONLY_WITHIN.some((re) => re.test(within))) {
204
259
  return {
205
260
  ok: false,
@@ -213,6 +268,30 @@ export function checkPaths(toolName, input, roots) {
213
268
  if (virtual)
214
269
  rewritten = { ...(rewritten ?? input), [key]: abs };
215
270
  }
271
+ // Glob searches from `path` (the cwd when absent) and its PATTERN never
272
+ // climbs: `../../storage/draft/*.md` answered "No files found" with the
273
+ // files there (run-munef2am-439a), and an expanded `workspace/storage/*.md`
274
+ // is an absolute pattern the tool does not search by either. Split such a
275
+ // pattern at its first wildcard: the fixed directories become `path`, the
276
+ // rest stays the pattern — so both spellings find what is there.
277
+ if (toolName === "Glob") {
278
+ const cur = { ...input, ...(rewritten ?? {}) };
279
+ const pattern = typeof cur.pattern === "string" ? cur.pattern : "";
280
+ const climbs = pattern.startsWith("../");
281
+ if (pattern && (climbs || path.isAbsolute(pattern)) && (cur.path === undefined || cur.path === "")) {
282
+ const absPattern = climbs ? path.resolve(roots.agentDir, pattern) : pattern;
283
+ const segs = absPattern.split("/");
284
+ const wild = segs.findIndex((seg) => /[*?[{]/.test(seg));
285
+ if (wild > 0 && wild < segs.length) {
286
+ const base = segs.slice(0, wild).join("/") || "/";
287
+ const inWorkspace = !path.relative(workspace, real(base)).startsWith("..");
288
+ const inLibrary = !path.relative(library, real(base)).startsWith("..");
289
+ if (inWorkspace || inLibrary) {
290
+ rewritten = { ...(rewritten ?? input), path: base, pattern: segs.slice(wild).join("/") };
291
+ }
292
+ }
293
+ }
294
+ }
216
295
  return { ok: true, updatedInput: rewritten };
217
296
  }
218
297
  // A shell can defeat any argument-level path check, so Bash is handled
@@ -296,3 +375,144 @@ export function checkBash(command) {
296
375
  }
297
376
  return { ok: true };
298
377
  }
378
+ // ------------------------------------------------------------ workspace link
379
+ /**
380
+ * `workspace/…` for a shell. The file tools expand the prefix themselves
381
+ * (expandVirtual above), but Bash, a script an agent runs, a scripts: tool
382
+ * and a shell `verify:` see only the filesystem — and there `workspace/` was
383
+ * a folder that did not exist, so the one spelling the docs teach worked in
384
+ * Read and failed in `cat`. For the length of a step the agent's folder holds
385
+ * a `workspace` link to the workspace root (`../..`), and nothing else.
386
+ *
387
+ * Why only for a step: a permanent link inside the tree it points at is a
388
+ * cycle, and anything that walks the tree following links walks it forever
389
+ * (a `shared` link was tried that way and reverted). Every walker this
390
+ * platform runs over a workspace skips links (storage harvest, the source
391
+ * listing, tar, the container copy-back), and the link is gone before the
392
+ * step's files are read back.
393
+ *
394
+ * Counted, because the parallel instances of one agent (`each:`, a fan-out)
395
+ * share its folder in one process: the last one out removes it. A real
396
+ * folder already called `workspace` (a stray from before the prefix was
397
+ * expanded) is left alone and reported — linking over it would hide files.
398
+ */
399
+ const workspaceLinks = new Map();
400
+ export function linkWorkspace(agentDir, workspaceRoot) {
401
+ const link = path.join(agentDir, "workspace");
402
+ const target = path.relative(path.resolve(agentDir), path.resolve(workspaceRoot)) || ".";
403
+ const held = workspaceLinks.get(link) ?? 0;
404
+ let note = null;
405
+ let own = false;
406
+ if (held > 0) {
407
+ own = true;
408
+ }
409
+ else {
410
+ try {
411
+ const st = fs.lstatSync(link);
412
+ if (st.isSymbolicLink()) {
413
+ // Left by a step that ended without its cleanup — adopt it when it
414
+ // points where ours would, replace it when it does not.
415
+ if (fs.readlinkSync(link) !== target) {
416
+ fs.unlinkSync(link);
417
+ fs.symlinkSync(target, link, "dir");
418
+ }
419
+ own = true;
420
+ }
421
+ else {
422
+ note =
423
+ `agents/${path.basename(agentDir)}/workspace/ is a real folder, so a shell command's ` +
424
+ `workspace/… reaches it rather than the workspace root — move what it holds to the ` +
425
+ `workspace's own storage/ or state/ and delete it`;
426
+ }
427
+ }
428
+ catch {
429
+ try {
430
+ fs.symlinkSync(target, link, "dir");
431
+ own = true;
432
+ }
433
+ catch (err) {
434
+ note = `could not link workspace/ for the shell: ${err instanceof Error ? err.message : String(err)}`;
435
+ }
436
+ }
437
+ }
438
+ if (own)
439
+ workspaceLinks.set(link, held + 1);
440
+ let released = false;
441
+ return {
442
+ note,
443
+ release: () => {
444
+ if (!own || released)
445
+ return;
446
+ released = true;
447
+ const left = (workspaceLinks.get(link) ?? 1) - 1;
448
+ if (left > 0) {
449
+ workspaceLinks.set(link, left);
450
+ return;
451
+ }
452
+ workspaceLinks.delete(link);
453
+ try {
454
+ if (fs.lstatSync(link).isSymbolicLink())
455
+ fs.unlinkSync(link);
456
+ }
457
+ catch {
458
+ // already gone
459
+ }
460
+ },
461
+ };
462
+ }
463
+ /**
464
+ * A Node program started THROUGH the link is still its own main module.
465
+ *
466
+ * `node workspace/tools/x/run.mjs` loads the file by its real path —
467
+ * import.meta.url says `<ws>/tools/x/run.mjs` — while process.argv[1] keeps
468
+ * the path as typed, `<ws>/agents/a/workspace/tools/x/run.mjs`. The usual
469
+ * entry-point check, `import.meta.url === pathToFileURL(process.argv[1]).href`,
470
+ * was then false: the program did nothing, exited 0, and a `verify:` that
471
+ * ran it passed on every input.
472
+ *
473
+ * The fix rides NODE_OPTIONS into every child a step starts (the model's
474
+ * Bash, a shell `verify:`, a scripts: tool, the Test button): a preload that
475
+ * rewrites argv[1] to its real path, and only when it goes through this
476
+ * link. `--preserve-symlinks-main` was the obvious flag and the wrong one:
477
+ * it applies to EVERY linked entry point, and a `node_modules/.bin` shim
478
+ * then resolves `require("../lib/x.js")` beside the shim and fails.
479
+ *
480
+ * A data: URL rather than a file, so nothing has to be shipped to wherever
481
+ * the step runs (host, run container, pod, a runtime: venv). Node ≥ 20.6
482
+ * reads `--import` from NODE_OPTIONS; the runner image is Node 22. Python
483
+ * needs nothing: `python3 workspace/x.py` is `__main__` however it is
484
+ * reached.
485
+ */
486
+ const MAIN_PRELOAD = [
487
+ `import{realpathSync}from"node:fs";`,
488
+ `import{resolve,sep,delimiter}from"node:path";`,
489
+ `const l=process.env.FOLDRUN_WORKSPACE_LINK,a=process.argv[1];`,
490
+ `if(l&&a){const f=resolve(a);`,
491
+ `if(l.split(delimiter).some(k=>k&&f.startsWith(k+sep))){`,
492
+ `try{process.argv[1]=realpathSync(f)}catch{}}}`,
493
+ ].join("");
494
+ const MAIN_IMPORT = `--import=data:text/javascript,${encodeURIComponent(MAIN_PRELOAD)}`;
495
+ /**
496
+ * `env` plus what makes `node workspace/…` its own main module (above):
497
+ * the link's path(s) in FOLDRUN_WORKSPACE_LINK, the preload appended to
498
+ * NODE_OPTIONS — after what it held, or the host's when it held nothing.
499
+ * Idempotent, and harmless where no link exists: the preload matches only
500
+ * a path under it.
501
+ */
502
+ export function workspaceLinkEnv(env, agentDir) {
503
+ const link = path.join(path.resolve(agentDir), "workspace");
504
+ let real = link;
505
+ try {
506
+ real = path.join(fs.realpathSync(agentDir), "workspace");
507
+ }
508
+ catch {
509
+ // not there yet — the lexical path is the one a shell would type
510
+ }
511
+ const held = env.NODE_OPTIONS ?? process.env.NODE_OPTIONS ?? "";
512
+ const options = held.includes(MAIN_IMPORT) ? held : [held.trim(), MAIN_IMPORT].filter(Boolean).join(" ");
513
+ return {
514
+ ...env,
515
+ FOLDRUN_WORKSPACE_LINK: [...new Set([link, real])].join(path.delimiter),
516
+ NODE_OPTIONS: options,
517
+ };
518
+ }
@@ -1,4 +1,14 @@
1
1
  import { type DeployFile } from "./store.ts";
2
+ /**
3
+ * Is this path, relative to a workspace root, one `readTree` would carry?
4
+ *
5
+ * The same question the walk above asks, asked of a path that is not on this
6
+ * machine — what the platform lists for a workspace, so `foldrun status` and
7
+ * `foldrun pull` compare like with like. Without it the comparison counted
8
+ * every run artefact and bookkeeping file the platform keeps as a local
9
+ * deletion, and every status was a wall of red.
10
+ */
11
+ export declare function isSourcePath(rel: string): boolean;
2
12
  export interface DeployIssue {
3
13
  where: string;
4
14
  message: string;
@@ -11,11 +21,18 @@ export interface DeployPlan {
11
21
  updated: string[];
12
22
  /** Empty when the deploy is safe to apply. */
13
23
  issues: DeployIssue[];
24
+ /** Advisory, non-blocking: a deploy proceeds with these. Today this is
25
+ * the outward-without-a-gate lint — the same one `foldrun check` fails
26
+ * on, surfaced here so a push over the API is not the last to hear it. */
27
+ warnings: DeployIssue[];
14
28
  /** Set when a run is in flight, which blocks the swap. */
15
29
  blockedBy: string[];
16
30
  }
17
31
  export interface DeployResult extends DeployPlan {
18
32
  applied: boolean;
33
+ /** Set when `expectRemoved` was given and the deploy would have removed
34
+ * paths outside it: those paths. The deploy was refused, nothing changed. */
35
+ unexpectedRemovals?: string[];
19
36
  /** The commit this workspace is now running, when the caller knows it. */
20
37
  commit: string | null;
21
38
  preserved: number;
@@ -37,6 +54,13 @@ export declare function readTree(dir: string): DeployFile[];
37
54
  * not on disk yet. A deploy that would leave the workspace failing its own
38
55
  * checker should not be a deploy.
39
56
  */
57
+ /** Best-effort warnings for a deploy: an agent's `web:` warnings (see
58
+ * webWarnings), and the outward gate — a step whose agent grants
59
+ * a workspace tool marked `outward: true` and has no `verify:`/gate. Reads
60
+ * only the deployed files, so a library or gallery outward tool is not seen
61
+ * here — `foldrun check`, which resolves every scope, remains the real gate.
62
+ * Non-blocking by decision: the deploy proceeds and says so. */
63
+ export declare function deployWarnings(files: DeployFile[]): DeployIssue[];
40
64
  export declare function deployIssues(files: DeployFile[]): DeployIssue[];
41
65
  /**
42
66
  * Runs that make this a bad moment to swap the files under them.
@@ -57,6 +81,15 @@ export interface DeployOptions {
57
81
  message?: string;
58
82
  /** Apply even while runs are in flight. */
59
83
  force?: boolean;
84
+ /**
85
+ * The removals a person confirmed from a dry run. When given, the deploy
86
+ * is refused (applied: false, `unexpectedRemovals` set) if it would remove
87
+ * anything not in this list — a run can write new storage/ outputs between
88
+ * the question and the answer, and a yes to one list is not a yes to
89
+ * another. Checked in the same synchronous call that writes, so there is
90
+ * no window between the check and the swap. Absent: no check, as before.
91
+ */
92
+ expectRemoved?: string[];
60
93
  }
61
94
  /**
62
95
  * Deploy a file list to a workspace, if it checks out.
@@ -20,14 +20,42 @@
20
20
  import fs from "node:fs";
21
21
  import os from "node:os";
22
22
  import path from "node:path";
23
- import { saveWorkspace, workspaceDir, listRuns, parseFlow, WORKSPACE_DIRS, assertSafeName, notifyWorkspaceChanged, } from "./store.js";
23
+ import { saveWorkspace, keptOnDeploy, workspaceDir, listRuns, parseFlow, parseToolDef, WORKSPACE_DIRS, assertSafeName, notifyWorkspaceChanged, } from "./store.js";
24
+ import { lintFlow } from "./flow-lint.js";
25
+ import { refNames } from "./refs.js";
26
+ import matter from "gray-matter";
24
27
  import { conformanceIssues } from "./okf.js";
28
+ import { timezoneProblem } from "./clock.js";
29
+ import { webProblems, webWarnings, runnerEngines } from "./providers.js";
30
+ import { readLimits } from "./limits.js";
31
+ import { actionProblems } from "./web-actions.js";
32
+ import { languageProblem } from "./language.js";
33
+ import { localeProblems } from "./locale.js";
25
34
  import { repoDir } from "./gitrepo.js";
35
+ import { entriesNoFollow } from "./paths.js";
26
36
  /** Directories a deploy never reads out of a source tree. */
27
37
  const NOT_SOURCE = new Set([".git", "node_modules", "runs", "outputs", ".foldrun", ".results"]);
28
38
  /** Files that are never part of a workspace, whatever directory they sit in. */
29
39
  const NOT_SOURCE_FILE = new Set([".DS_Store", "secrets.json"]);
30
40
  const IN_WORKSPACE_DIR = new RegExp(`^(${WORKSPACE_DIRS.join("|")})/`);
41
+ /**
42
+ * Is this path, relative to a workspace root, one `readTree` would carry?
43
+ *
44
+ * The same question the walk above asks, asked of a path that is not on this
45
+ * machine — what the platform lists for a workspace, so `foldrun status` and
46
+ * `foldrun pull` compare like with like. Without it the comparison counted
47
+ * every run artefact and bookkeeping file the platform keeps as a local
48
+ * deletion, and every status was a wall of red.
49
+ */
50
+ export function isSourcePath(rel) {
51
+ const norm = rel.replaceAll("\\", "/");
52
+ const parts = norm.split("/");
53
+ if (parts.some((p) => NOT_SOURCE.has(p)))
54
+ return false;
55
+ if (NOT_SOURCE_FILE.has(parts[parts.length - 1]))
56
+ return false;
57
+ return norm === "AGENTS.md" || norm === "project.md" || IN_WORKSPACE_DIR.test(norm);
58
+ }
31
59
  /**
32
60
  * Read a directory into the file list a deploy ships.
33
61
  *
@@ -130,6 +158,72 @@ export function readTree(dir) {
130
158
  * not on disk yet. A deploy that would leave the workspace failing its own
131
159
  * checker should not be a deploy.
132
160
  */
161
+ /** Best-effort warnings for a deploy: an agent's `web:` warnings (see
162
+ * webWarnings), and the outward gate — a step whose agent grants
163
+ * a workspace tool marked `outward: true` and has no `verify:`/gate. Reads
164
+ * only the deployed files, so a library or gallery outward tool is not seen
165
+ * here — `foldrun check`, which resolves every scope, remains the real gate.
166
+ * Non-blocking by decision: the deploy proceeds and says so. */
167
+ export function deployWarnings(files) {
168
+ const front = (content) => {
169
+ try {
170
+ return matter(content).data;
171
+ }
172
+ catch {
173
+ return {};
174
+ }
175
+ };
176
+ // Workspace tools that change something outside the workspace.
177
+ const outwardTools = new Set();
178
+ for (const f of files) {
179
+ const m = f.path.match(/^tools\/([^/]+?)(?:\/tool)?\.md$/);
180
+ if (!m)
181
+ continue;
182
+ const data = front(f.content);
183
+ const def = parseToolDef(data, m[1], f.content);
184
+ // `outward: true` is read off the frontmatter, the same test the runtime
185
+ // and the CLI use; parseToolDef does not fold it onto the def.
186
+ if (def && data.outward === true)
187
+ outwardTools.add(def.name ?? m[1]);
188
+ }
189
+ // Agents that grant one of them.
190
+ const outwardAgents = [];
191
+ const agentNames = [];
192
+ const out = [];
193
+ // The web: warnings check prints — the old session: key, and a pinned
194
+ // user agent behind the runner image's Chrome, which the platform knows
195
+ // from FOLDRUN_RUNNER_ENGINES (the deploy read it from the image).
196
+ const { chrome, chromium } = runnerEngines() ?? {};
197
+ for (const f of files) {
198
+ const m = f.path.match(/^agents\/([^/]+)\/agent\.md$/);
199
+ if (!m)
200
+ continue;
201
+ agentNames.push(m[1]);
202
+ const data = front(f.content);
203
+ if (refNames(data.tools).some((t) => outwardTools.has(t)))
204
+ outwardAgents.push(m[1]);
205
+ for (const w of webWarnings(data, { chrome, chromium }))
206
+ out.push({ where: f.path, message: w });
207
+ }
208
+ if (!outwardAgents.length)
209
+ return out;
210
+ for (const f of files) {
211
+ if (!/^flows\/[^/]+\.md$/.test(f.path))
212
+ continue;
213
+ let flow;
214
+ try {
215
+ flow = parseFlow(path.basename(f.path), f.content);
216
+ }
217
+ catch {
218
+ continue;
219
+ }
220
+ for (const w of lintFlow(flow, { agents: agentNames, outwardAgents })) {
221
+ if (w.kind === "outward")
222
+ out.push({ where: `${f.path}${w.line ? `:${w.line}` : ""}`, message: w.message });
223
+ }
224
+ }
225
+ return out;
226
+ }
133
227
  export function deployIssues(files) {
134
228
  const issues = [];
135
229
  const at = (where, message) => issues.push({ where, message });
@@ -157,6 +251,40 @@ export function deployIssues(files) {
157
251
  if (m)
158
252
  agents.add(m[1]);
159
253
  }
254
+ // `timezone:` is read at four levels and a value nothing can parse falls
255
+ // silently through to the level above — so it is refused where it was
256
+ // written, not discovered as a date that is a day out.
257
+ for (const f of files) {
258
+ if (!/^(AGENTS\.md|agents\/[^/]+\/agent\.md)$/.test(f.path))
259
+ continue;
260
+ let front = {};
261
+ try {
262
+ front = matter(f.content).data;
263
+ }
264
+ catch {
265
+ continue; // unparseable frontmatter is its own report
266
+ }
267
+ const problem = timezoneProblem(front.timezone);
268
+ if (problem)
269
+ at(f.path, problem);
270
+ // `schedule:` on an agent or AGENTS.md runs nothing — only a flow has a
271
+ // clock. Refuse it where it was written rather than deploy it dead.
272
+ if (front.schedule !== undefined) {
273
+ at(f.path, "schedule: an agent has no schedule of its own — only a flow runs on a clock. Put schedule: on the flow that runs this agent, or remove it.");
274
+ }
275
+ // The same refusal check gives: a web key that cannot work never ships.
276
+ for (const w of [...webProblems(front), ...actionProblems(front)])
277
+ at(f.path, w);
278
+ // `limits:` that cannot be read — a count of 0, a word, web.<not an
279
+ // action> — would run uncapped where the author meant a cap.
280
+ for (const w of readLimits(front.limits).problems)
281
+ at(f.path, w);
282
+ const lang = languageProblem(front.language);
283
+ if (lang)
284
+ at(f.path, lang);
285
+ for (const w of localeProblems(front))
286
+ at(f.path, w);
287
+ }
160
288
  if (agents.size === 0) {
161
289
  at("agents/", "no agents — a workspace needs at least one agents/<name>/agent.md");
162
290
  }
@@ -184,6 +312,8 @@ export function deployIssues(files) {
184
312
  }
185
313
  if (parsed.steps.length === 0)
186
314
  at(f.path, "no steps");
315
+ for (const fp of parsed.frontProblems)
316
+ at(f.path, fp);
187
317
  for (const s of parsed.steps) {
188
318
  const target = s.subflow ?? s.agent;
189
319
  const known = s.subflow ? flowNames.has(target) : agents.has(target);
@@ -245,7 +375,7 @@ export function planDeploy(tenant, workspace, files) {
245
375
  const shipped = new Map(files.map((f) => [path.normalize(f.path), f.content]));
246
376
  const present = new Set();
247
377
  if (fs.existsSync(dir)) {
248
- for (const entry of fs.readdirSync(dir, { recursive: true })) {
378
+ for (const entry of entriesNoFollow(dir)) {
249
379
  const rel = String(entry).split(path.sep).join("/");
250
380
  if (NOT_SOURCE.has(rel.split("/")[0]))
251
381
  continue;
@@ -278,16 +408,18 @@ export function planDeploy(tenant, workspace, files) {
278
408
  updated.push(rel);
279
409
  }
280
410
  }
281
- // What saveWorkspace will keep regardless — reporting these as removals
282
- // would be a lie, and the alarming kind.
283
- const kept = (rel) => /(^|\/)state\//.test(rel) || /(^|\/)memory\/[^/]+\.md$/.test(rel);
284
- const removed = [...present].filter((rel) => !shipped.has(rel) && !kept(rel));
411
+ // What saveWorkspace will keep regardless — storage/, state/, agent
412
+ // memory, the platform's own files. Reporting these as removals would be
413
+ // a lie, and the alarming kind; one predicate serves both.
414
+ const shippedPaths = new Set(shipped.keys());
415
+ const removed = [...present].filter((rel) => !shipped.has(rel) && !keptOnDeploy(rel, shippedPaths));
285
416
  return {
286
417
  files,
287
418
  added: added.sort(),
288
419
  updated: updated.sort(),
289
420
  removed: removed.sort(),
290
421
  issues: deployIssues(files),
422
+ warnings: deployWarnings(files),
291
423
  blockedBy: runsInFlight(tenant, workspace),
292
424
  };
293
425
  }
@@ -351,6 +483,13 @@ export function deployWorkspace(tenant, workspace, files, opts = {}) {
351
483
  if (plan.issues.length > 0 || blocked) {
352
484
  return { ...plan, applied: false, commit: null, preserved: 0 };
353
485
  }
486
+ if (opts.expectRemoved) {
487
+ const ok = new Set(opts.expectRemoved.map((p) => path.normalize(p).split(path.sep).join("/")));
488
+ const unexpectedRemovals = plan.removed.filter((p) => !ok.has(p));
489
+ if (unexpectedRemovals.length) {
490
+ return { ...plan, applied: false, commit: null, preserved: 0, unexpectedRemovals };
491
+ }
492
+ }
354
493
  const { preserved } = saveWorkspace(tenant, workspace, files, { commit: opts.commit ?? null, by: opts.by ?? "deploy", message: opts.message });
355
494
  const commit = opts.commit ?? null;
356
495
  if (commit)
@@ -1,3 +1,4 @@
1
+ import type { TestEffect } from "./test-mode.ts";
1
2
  /** `${NAME}` — the shape a secret reference takes everywhere in foldrun. */
2
3
  export declare const PLACEHOLDER: RegExp;
3
4
  /** The env var the pod reads to know it should send through a proxy. */
@@ -30,6 +31,16 @@ export interface EgressGrant {
30
31
  value: string;
31
32
  hosts: string[];
32
33
  }>;
34
+ /** Secret name → the vault entry it came from, as secret-health keys it:
35
+ * "account:NAME" or "workspace:<ws>:NAME". Two workspaces each holding an
36
+ * API_KEY are two credentials, and one's refusal must not paint the
37
+ * other red. Absent for a secret whose origin the runner did not know. */
38
+ healthKeys?: Record<string, string>;
39
+ /** The step's own timezone, from the clock cascade. The proxy is the only
40
+ * party that sees an upstream response's headers, so it is the party
41
+ * that has to put a rate-limit reset into words — and a reset time is
42
+ * only useful in the calendar the step works to. See refusal.ts. */
43
+ timezone?: string;
33
44
  }
34
45
  export interface EgressLease {
35
46
  /** The base the pod sends through: `http://…/e/<token>`. */
@@ -39,21 +50,34 @@ export interface EgressLease {
39
50
  * lease URL first), so a store that is not this process's memory has
40
51
  * to be told when the grant is complete. Call before every attempt. */
41
52
  commit(): Promise<void>;
42
- /** One line per request, for the run trace. Drained by the runner. */
43
- drainLog(): Promise<string[]>;
53
+ /** One line per request, for the run trace. Drained by the runner. A
54
+ * line may carry a test-mode effect beside its text — what the proxy
55
+ * refused or redirected on a test lease — for the run page to list. */
56
+ drainLog(): Promise<EgressLogLine[]>;
44
57
  /** The step is over: the lease is void and its values are dropped. */
45
58
  release(): Promise<void>;
46
59
  }
60
+ export type EgressLogLine = string | {
61
+ text: string;
62
+ effect: TestEffect;
63
+ };
47
64
  /**
48
65
  * The seam the platform fills. `null` from `lease` (or no `egress` at all)
49
66
  * means "no proxy here" and the runner falls back to materialising, exactly
50
67
  * as it always did.
51
68
  */
52
69
  export interface EgressHooks {
70
+ /** `test` — the run is a test run: the proxy applies the outward-write
71
+ * policy in test-mode.ts to every request on this lease. Carried on the
72
+ * lease, not the request, because the pod is the party not trusted to
73
+ * say so. */
74
+ /** `step` — the step's index in run.steps; keys that step's operator inbox. */
53
75
  lease(args: {
54
76
  tenant: string;
55
77
  runId: string;
56
78
  grant: EgressGrant;
79
+ test?: boolean;
80
+ step?: number;
57
81
  }): Promise<EgressLease | null>;
58
82
  }
59
83
  /** Strip a header set of the proxy's own and hop-by-hop headers. */
@@ -34,6 +34,7 @@
34
34
  // This file is the vocabulary both sides share. The server is the
35
35
  // platform's (it needs a long-lived process and a Service); the pod side is
36
36
  // here in core, because it is what the runner image runs.
37
+ import { trimSlashes } from "./paths.js";
37
38
  /** `${NAME}` — the shape a secret reference takes everywhere in foldrun. */
38
39
  export const PLACEHOLDER = /\$\{([A-Z][A-Z0-9_]*)\}/g;
39
40
  /** The env var the pod reads to know it should send through a proxy. */
@@ -48,7 +49,7 @@ export const MODEL_KEY_NAME = "FOLDRUN_MODEL_KEY";
48
49
  export function viaEgress(egress, target) {
49
50
  if (!egress)
50
51
  return target;
51
- return `${egress.replace(/\/+$/, "")}/${target}`;
52
+ return `${trimSlashes(egress)}/${target}`;
52
53
  }
53
54
  /** The names referenced by placeholders in a string. */
54
55
  export function placeholderNames(text) {
@@ -127,6 +128,12 @@ export function addGrant(grant, name, value, host) {
127
128
  if (existing) {
128
129
  if (!existing.hosts.includes(h))
129
130
  existing.hosts.push(h);
131
+ // A name granted again with a different value is a rotation: the
132
+ // runner re-grants the model key after awaitRotatedCredential, on the
133
+ // same grant object the lease holds by reference. Keeping the first
134
+ // value here meant the "retry on the new one" sent the revoked token —
135
+ // every 4-hourly refresh on 2026-09-18..22 failed the step it landed on.
136
+ existing.value = value;
130
137
  }
131
138
  else
132
139
  grant.secrets[name] = { value, hosts: [h] };
@@ -0,0 +1,2 @@
1
+ export declare const toLF: (raw: string) => string;
2
+ export declare const asRaw: (raw: string, out: string) => string;
@@ -0,0 +1,7 @@
1
+ // Flow (and other markdown) rewrites work on LF text: the parser and the
2
+ // line rules read "\n"-separated lines, and a CRLF line's trailing "\r"
3
+ // defeats them (an option line never matched, so setting it again added a
4
+ // second copy; a CRLF step line was no step at all). So each rewrite takes a
5
+ // CRLF file as LF and gives it back as CRLF.
6
+ export const toLF = (raw) => raw.replace(/\r\n/g, "\n");
7
+ export const asRaw = (raw, out) => (raw.includes("\r\n") ? out.replace(/\r?\n/g, "\r\n") : out);
@@ -38,6 +38,15 @@ export interface EvalInfo {
38
38
  * `manual`: only when a person presses Run or the CLI asks — for a flow
39
39
  * eval that costs a whole run and touches real systems every time. */
40
40
  trigger: "deploy" | "manual";
41
+ /** An eval's runs are test runs — nothing outward, state untouched —
42
+ * unless the file says `live: true`. A flow eval that must really send
43
+ * to prove itself opts in; nothing opts in by accident. */
44
+ live: boolean;
45
+ /** A file of saved inputs for a flow, not a test: `inputs: true`. Its
46
+ * cases are named tasks the dashboard's "Run with…" and `foldrun invoke
47
+ * --inputs` start the flow with; nothing is asserted, so no eval run,
48
+ * deploy or `foldrun eval` ever runs them. */
49
+ inputs: boolean;
41
50
  cases: EvalCase[];
42
51
  }
43
52
  export declare function parseEval(file: string, raw: string): EvalInfo;