@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
@@ -68,11 +68,14 @@ const EFFORTS: Completion[] = [
68
68
  // survive vendor renames — then the exact SDK names, accepted so a Claude Code
69
69
  // subagent runs here unchanged.
70
70
  const TOOL_GROUPS: Completion[] = [
71
+ // `files`, `bash` and `fetch` are retired (tool-names.ts) and not offered.
72
+ // `web` is our gallery tool, offered here because it is the one way onto
73
+ // the web.
71
74
  { label: "read", hint: "Read, Glob, Grep — may look, never write" },
72
- { label: "files", hint: "Read, Write, Edit, Glob, Grep" },
73
- { label: "web", hint: "WebSearch (Anthropic's servers, billed per search), WebFetch" },
74
- { label: "fetch", hint: "WebFetch only — local; pair with the websearch tool" },
75
- { label: "bash", hint: "Bash — arbitrary commands" },
75
+ { label: "write", hint: "Read, Write, Edit, Glob, Grep" },
76
+ { label: "code", hint: "Bash — runs anything in the sandbox" },
77
+ { label: "web", hint: "search, fetch, browse, crawl, map, extract, answer, monitor — providers under web:" },
78
+ { label: "ask", hint: "ask_person — ask the person running the desk mid-step, and wait for the answer" },
76
79
  ];
77
80
 
78
81
  const SDK_TOOL_NAMES: Completion[] = [
@@ -82,8 +85,6 @@ const SDK_TOOL_NAMES: Completion[] = [
82
85
  { label: "Glob", hint: "exact SDK name" },
83
86
  { label: "Grep", hint: "exact SDK name" },
84
87
  { label: "Bash", hint: "exact SDK name" },
85
- { label: "WebSearch", hint: "exact SDK name" },
86
- { label: "WebFetch", hint: "exact SDK name" },
87
88
  ];
88
89
 
89
90
  const METHODS: Completion[] = ["GET", "POST", "PUT", "PATCH", "DELETE"].map((m) => ({ label: m }));
@@ -134,14 +135,17 @@ const FIELDS: Record<string, Completion[]> = {
134
135
  { label: "description", insert: "description: ", hint: "required — what it does, and when" },
135
136
  { label: "model", insert: "model: default" },
136
137
  { label: "effort", insert: "effort: high", hint: "how hard it thinks" },
137
- { label: "tools", insert: "tools:\n - files", hint: "what the runtime gives it" },
138
+ { label: "tools", insert: "tools:\n - write", hint: "what the runtime gives it" },
138
139
  { label: "secrets", insert: "secrets:\n - ", hint: "credentials it may spend" },
139
140
  { label: "skills", insert: "skills:\n - ", hint: "allowlist; omit to inherit all" },
140
141
  { label: "scripts", insert: "scripts:\n - name: \n run: \n description: " },
141
142
  { label: "runtime", insert: "runtime:\n python: \"3\"\n packages: []" },
142
143
  { label: "mcpServers", insert: "mcpServers:\n " },
144
+ { label: "ask", insert: "ask:\n timeout: 30m", hint: "tools: [ask] — how long ask_person waits for a person (max 24h)" },
143
145
  { label: "permissionMode", insert: "permissionMode: plan", hint: "plan = read-only" },
144
146
  { label: "disallowedTools", insert: "disallowedTools:\n - " },
147
+ { label: "subagents", insert: "subagents:\n - ", hint: "colleagues it may delegate a job to — own context, own tools, no wider than its own" },
148
+ { label: "limits", insert: "limits:\n web.search: 40\n calls: 300", hint: "per step: a call past a limit is refused, never run" },
145
149
  {
146
150
  label: "provider",
147
151
  insert: "provider:\n name: \n token: ${PROVIDER_TOKEN}",
@@ -201,6 +205,8 @@ const FIELDS: Record<string, Completion[]> = {
201
205
  { label: "trigger", insert: "trigger: manual" },
202
206
  { label: "schedule", insert: "schedule: \"0 6 * * MON\"" },
203
207
  { label: "timezone", insert: "timezone: Australia/Sydney" },
208
+ { label: "language", insert: "language: en-AU" },
209
+ { label: "region", insert: "region: au", hint: "currency, units, calendar, weekend and text direction follow" },
204
210
  { label: "model", insert: "model: fast", hint: "every step, unless the step says" },
205
211
  { label: "effort", insert: "effort: high", hint: "every step, unless the step says" },
206
212
  ],
@@ -544,7 +550,7 @@ export function completionsAt(
544
550
  each: [
545
551
  { label: "lines", hint: "one instance per line of the previous result" },
546
552
  { label: "items", hint: "one per element of the previous output: json array" },
547
- { label: "rows of ../../storage/x.csv", hint: "one per CSV row" },
553
+ { label: "rows of workspace/storage/x.csv", hint: "one per CSV row" },
548
554
  ],
549
555
  };
550
556
  const items = byKey[key];
@@ -569,6 +575,9 @@ export function completionsAt(
569
575
  ],
570
576
  secrets: vocab.secrets.map((s) => ({ label: s, hint: "secret" })),
571
577
  skills: vocab.skills.map((s) => ({ label: s, hint: "skill" })),
578
+ // Colleagues: a consult (agents:) or a delegate (subagents:).
579
+ agents: vocab.agents.map((a) => ({ label: a, hint: "consult — answers, no tools" })),
580
+ subagents: vocab.agents.map((a) => ({ label: a, hint: "delegate — own context and tools" })),
572
581
  disallowedTools: [...TOOL_GROUPS, { label: "Bash" }, { label: "Write" }],
573
582
  when: [{ label: "schema" }, { label: "urgent" }],
574
583
  expect: ASSERTIONS.map((a) => ({ ...a, insert: `${a.label}: ` })),
package/src/confine.ts CHANGED
@@ -65,7 +65,7 @@ const PATH_KEYS = [
65
65
  ];
66
66
 
67
67
  const DENIED_WITHIN = [
68
- /(^|\/)secrets\.json$/,
68
+ /(^|\/)secrets\.json(\.[^/]+)?$/, // and its lock and temp files
69
69
  /(^|\/)runs(\/|$)/,
70
70
  ];
71
71
 
@@ -119,6 +119,9 @@ const VIRTUAL = [
119
119
 
120
120
  const WRITE_TOOLS = new Set(["Write", "Edit", "MultiEdit", "NotebookEdit"]);
121
121
 
122
+ /** Workspace-level directories an agent's own folder must never grow. */
123
+ const SHARED_DIRS = new Set(["storage", "state", "workspace"]);
124
+
122
125
  // Only the built-in filesystem tools get path checks. foldrun's own MCP tools
123
126
  // are already confined at their own layer — script paths by resolveRunPath,
124
127
  // APIs by their declared base URL — and their arguments are not filesystem
@@ -148,7 +151,12 @@ export const isFilesystemTool = (toolName: string) => FS_TOOLS.has(toolName);
148
151
  */
149
152
  export function suggestPath(raw: string): string {
150
153
  const posix = raw.split(path.sep).join("/");
151
- const m = posix.match(/(?:^|\/)(outputs|storage|state|memory|knowledge)\/(.+)$/);
154
+ // By segments, not by regex: an unanchored `(.+)$` re-scans from every
155
+ // slash, and this runs on a path an agent chose.
156
+ const KNOWN = ["outputs", "storage", "state", "memory", "knowledge"];
157
+ const segs = posix.split("/");
158
+ const at = segs.findIndex((seg, i) => KNOWN.includes(seg) && segs.slice(i + 1).join("/") !== "");
159
+ const m = at === -1 ? null : ([null, segs[at], segs.slice(at + 1).join("/")] as const);
152
160
  if (m) {
153
161
  const [, dir, rest] = m;
154
162
  return dir === "outputs"
@@ -164,11 +172,40 @@ export function suggestPath(raw: string): string {
164
172
  return `Use a path relative to your agent directory, or workspace/<dir>/… for the workspace root (workspace/storage/, workspace/state/).`;
165
173
  }
166
174
 
175
+ /**
176
+ * A path an author wrote in a flow or an agent wrote in a tool call, as an
177
+ * absolute path: `workspace/…` from the workspace root, anything else from
178
+ * the agent's own folder (so the older `../../storage/x` lands in the same
179
+ * place as `workspace/storage/x`). Confinement is the caller's: this only
180
+ * says where the path points.
181
+ */
182
+ export function resolveAgentPath(workspaceRoot: string, agentDir: string, raw: string): string {
183
+ const p = raw.trim();
184
+ if (p === "workspace" || p === "workspace/") return path.resolve(workspaceRoot);
185
+ if (p.startsWith("workspace/")) return path.resolve(workspaceRoot, p.slice("workspace/".length));
186
+ return path.resolve(agentDir, p);
187
+ }
188
+
189
+ /** True when `abs` is `root` or under it. */
190
+ export function isWithin(root: string, abs: string): boolean {
191
+ const rel = path.relative(path.resolve(root), path.resolve(abs));
192
+ return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
193
+ }
194
+
167
195
  /** Expand a virtual prefix to a real absolute path, or null if it has none. */
168
196
  export function expandVirtual(raw: string, roots: Roots): { abs: string; readOnly: boolean } | null {
197
+ // The SDK resolves a relative path against the cwd BEFORE canUseTool sees
198
+ // it, so `workspace/storage/x.json` arrives as
199
+ // `<agentDir>/workspace/storage/x.json` and never matched a prefix. The
200
+ // write then made a literal `workspace/` folder inside the agent's own
201
+ // directory and reported success: gbp-desk's reply-sheet, 2026-09-24, whose
202
+ // approved replies never reached the poster. Read the prefix off the path
203
+ // relative to the agent directory as well as off the raw one.
204
+ const rel = path.isAbsolute(raw) ? path.relative(roots.agentDir, raw).split(path.sep).join("/") : null;
205
+ const candidate = rel !== null && !rel.startsWith("..") && !path.isAbsolute(rel) ? rel : raw;
169
206
  for (const v of VIRTUAL) {
170
- if (raw.startsWith(v.prefix)) {
171
- return { abs: path.resolve(v.root(roots), raw.slice(v.prefix.length)), readOnly: v.readOnly };
207
+ if (candidate.startsWith(v.prefix)) {
208
+ return { abs: path.resolve(v.root(roots), candidate.slice(v.prefix.length)), readOnly: v.readOnly };
172
209
  }
173
210
  }
174
211
  return null;
@@ -243,6 +280,27 @@ export function checkPaths(
243
280
  };
244
281
  }
245
282
 
283
+ // storage/, state/ and workspace/ belong to the workspace. The same name
284
+ // under an agent's own folder is always a missing prefix — `storage/x`
285
+ // written from agents/<name>/ — and the write used to succeed there,
286
+ // where no other agent looks: gbp-desk's approved replies, lawyer-desk's
287
+ // batch, five files in all by 2026-09-24. Refuse it with the path that
288
+ // was meant; the model takes the correction on its next turn.
289
+ if (isWrite && !virtual) {
290
+ const own = path.relative(real(path.resolve(roots.agentDir)), abs).split(path.sep).join("/");
291
+ const [top, ...rest] = own.split("/");
292
+ if (!own.startsWith("..") && rest.length && SHARED_DIRS.has(top)) {
293
+ const meant = top === "workspace" ? `workspace/${rest.join("/")}` : `workspace/${top}/${rest.join("/")}`;
294
+ return {
295
+ ok: false,
296
+ reason:
297
+ `${toolName} was denied: "${raw}" would create ${top}/ inside your own agent folder, where no ` +
298
+ `other agent or run looks. Did you mean ${meant}? Shared files go under workspace/ ` +
299
+ `(workspace/storage/, workspace/state/, workspace/memory/); your own scratch goes to outputs/.`,
300
+ };
301
+ }
302
+ }
303
+
246
304
  if (isWrite && READ_ONLY_WITHIN.some((re) => re.test(within))) {
247
305
  return {
248
306
  ok: false,
@@ -258,6 +316,31 @@ export function checkPaths(
258
316
  if (virtual) rewritten = { ...(rewritten ?? input), [key]: abs };
259
317
  }
260
318
 
319
+ // Glob searches from `path` (the cwd when absent) and its PATTERN never
320
+ // climbs: `../../storage/draft/*.md` answered "No files found" with the
321
+ // files there (run-munef2am-439a), and an expanded `workspace/storage/*.md`
322
+ // is an absolute pattern the tool does not search by either. Split such a
323
+ // pattern at its first wildcard: the fixed directories become `path`, the
324
+ // rest stays the pattern — so both spellings find what is there.
325
+ if (toolName === "Glob") {
326
+ const cur = { ...input, ...(rewritten ?? {}) };
327
+ const pattern = typeof cur.pattern === "string" ? cur.pattern : "";
328
+ const climbs = pattern.startsWith("../");
329
+ if (pattern && (climbs || path.isAbsolute(pattern)) && (cur.path === undefined || cur.path === "")) {
330
+ const absPattern = climbs ? path.resolve(roots.agentDir, pattern) : pattern;
331
+ const segs = absPattern.split("/");
332
+ const wild = segs.findIndex((seg) => /[*?[{]/.test(seg));
333
+ if (wild > 0 && wild < segs.length) {
334
+ const base = segs.slice(0, wild).join("/") || "/";
335
+ const inWorkspace = !path.relative(workspace, real(base)).startsWith("..");
336
+ const inLibrary = !path.relative(library, real(base)).startsWith("..");
337
+ if (inWorkspace || inLibrary) {
338
+ rewritten = { ...(rewritten ?? input), path: base, pattern: segs.slice(wild).join("/") };
339
+ }
340
+ }
341
+ }
342
+ }
343
+
261
344
  return { ok: true, updatedInput: rewritten };
262
345
  }
263
346
 
@@ -348,3 +431,140 @@ export function checkBash(command: string): ConfineVerdict {
348
431
  }
349
432
  return { ok: true };
350
433
  }
434
+
435
+ // ------------------------------------------------------------ workspace link
436
+
437
+ /**
438
+ * `workspace/…` for a shell. The file tools expand the prefix themselves
439
+ * (expandVirtual above), but Bash, a script an agent runs, a scripts: tool
440
+ * and a shell `verify:` see only the filesystem — and there `workspace/` was
441
+ * a folder that did not exist, so the one spelling the docs teach worked in
442
+ * Read and failed in `cat`. For the length of a step the agent's folder holds
443
+ * a `workspace` link to the workspace root (`../..`), and nothing else.
444
+ *
445
+ * Why only for a step: a permanent link inside the tree it points at is a
446
+ * cycle, and anything that walks the tree following links walks it forever
447
+ * (a `shared` link was tried that way and reverted). Every walker this
448
+ * platform runs over a workspace skips links (storage harvest, the source
449
+ * listing, tar, the container copy-back), and the link is gone before the
450
+ * step's files are read back.
451
+ *
452
+ * Counted, because the parallel instances of one agent (`each:`, a fan-out)
453
+ * share its folder in one process: the last one out removes it. A real
454
+ * folder already called `workspace` (a stray from before the prefix was
455
+ * expanded) is left alone and reported — linking over it would hide files.
456
+ */
457
+ const workspaceLinks = new Map<string, number>();
458
+
459
+ export function linkWorkspace(agentDir: string, workspaceRoot: string): { release: () => void; note: string | null } {
460
+ const link = path.join(agentDir, "workspace");
461
+ const target = path.relative(path.resolve(agentDir), path.resolve(workspaceRoot)) || ".";
462
+ const held = workspaceLinks.get(link) ?? 0;
463
+ let note: string | null = null;
464
+ let own = false;
465
+ if (held > 0) {
466
+ own = true;
467
+ } else {
468
+ try {
469
+ const st = fs.lstatSync(link);
470
+ if (st.isSymbolicLink()) {
471
+ // Left by a step that ended without its cleanup — adopt it when it
472
+ // points where ours would, replace it when it does not.
473
+ if (fs.readlinkSync(link) !== target) {
474
+ fs.unlinkSync(link);
475
+ fs.symlinkSync(target, link, "dir");
476
+ }
477
+ own = true;
478
+ } else {
479
+ note =
480
+ `agents/${path.basename(agentDir)}/workspace/ is a real folder, so a shell command's ` +
481
+ `workspace/… reaches it rather than the workspace root — move what it holds to the ` +
482
+ `workspace's own storage/ or state/ and delete it`;
483
+ }
484
+ } catch {
485
+ try {
486
+ fs.symlinkSync(target, link, "dir");
487
+ own = true;
488
+ } catch (err) {
489
+ note = `could not link workspace/ for the shell: ${err instanceof Error ? err.message : String(err)}`;
490
+ }
491
+ }
492
+ }
493
+ if (own) workspaceLinks.set(link, held + 1);
494
+ let released = false;
495
+ return {
496
+ note,
497
+ release: () => {
498
+ if (!own || released) return;
499
+ released = true;
500
+ const left = (workspaceLinks.get(link) ?? 1) - 1;
501
+ if (left > 0) {
502
+ workspaceLinks.set(link, left);
503
+ return;
504
+ }
505
+ workspaceLinks.delete(link);
506
+ try {
507
+ if (fs.lstatSync(link).isSymbolicLink()) fs.unlinkSync(link);
508
+ } catch {
509
+ // already gone
510
+ }
511
+ },
512
+ };
513
+ }
514
+
515
+ /**
516
+ * A Node program started THROUGH the link is still its own main module.
517
+ *
518
+ * `node workspace/tools/x/run.mjs` loads the file by its real path —
519
+ * import.meta.url says `<ws>/tools/x/run.mjs` — while process.argv[1] keeps
520
+ * the path as typed, `<ws>/agents/a/workspace/tools/x/run.mjs`. The usual
521
+ * entry-point check, `import.meta.url === pathToFileURL(process.argv[1]).href`,
522
+ * was then false: the program did nothing, exited 0, and a `verify:` that
523
+ * ran it passed on every input.
524
+ *
525
+ * The fix rides NODE_OPTIONS into every child a step starts (the model's
526
+ * Bash, a shell `verify:`, a scripts: tool, the Test button): a preload that
527
+ * rewrites argv[1] to its real path, and only when it goes through this
528
+ * link. `--preserve-symlinks-main` was the obvious flag and the wrong one:
529
+ * it applies to EVERY linked entry point, and a `node_modules/.bin` shim
530
+ * then resolves `require("../lib/x.js")` beside the shim and fails.
531
+ *
532
+ * A data: URL rather than a file, so nothing has to be shipped to wherever
533
+ * the step runs (host, run container, pod, a runtime: venv). Node ≥ 20.6
534
+ * reads `--import` from NODE_OPTIONS; the runner image is Node 22. Python
535
+ * needs nothing: `python3 workspace/x.py` is `__main__` however it is
536
+ * reached.
537
+ */
538
+ const MAIN_PRELOAD = [
539
+ `import{realpathSync}from"node:fs";`,
540
+ `import{resolve,sep,delimiter}from"node:path";`,
541
+ `const l=process.env.FOLDRUN_WORKSPACE_LINK,a=process.argv[1];`,
542
+ `if(l&&a){const f=resolve(a);`,
543
+ `if(l.split(delimiter).some(k=>k&&f.startsWith(k+sep))){`,
544
+ `try{process.argv[1]=realpathSync(f)}catch{}}}`,
545
+ ].join("");
546
+ const MAIN_IMPORT = `--import=data:text/javascript,${encodeURIComponent(MAIN_PRELOAD)}`;
547
+
548
+ /**
549
+ * `env` plus what makes `node workspace/…` its own main module (above):
550
+ * the link's path(s) in FOLDRUN_WORKSPACE_LINK, the preload appended to
551
+ * NODE_OPTIONS — after what it held, or the host's when it held nothing.
552
+ * Idempotent, and harmless where no link exists: the preload matches only
553
+ * a path under it.
554
+ */
555
+ export function workspaceLinkEnv<T extends Record<string, string | undefined>>(env: T, agentDir: string): T {
556
+ const link = path.join(path.resolve(agentDir), "workspace");
557
+ let real = link;
558
+ try {
559
+ real = path.join(fs.realpathSync(agentDir), "workspace");
560
+ } catch {
561
+ // not there yet — the lexical path is the one a shell would type
562
+ }
563
+ const held = env.NODE_OPTIONS ?? process.env.NODE_OPTIONS ?? "";
564
+ const options = held.includes(MAIN_IMPORT) ? held : [held.trim(), MAIN_IMPORT].filter(Boolean).join(" ");
565
+ return {
566
+ ...env,
567
+ FOLDRUN_WORKSPACE_LINK: [...new Set([link, real])].join(path.delimiter),
568
+ NODE_OPTIONS: options,
569
+ };
570
+ }
package/src/deploy.ts CHANGED
@@ -23,16 +23,28 @@ import os from "node:os";
23
23
  import path from "node:path";
24
24
  import {
25
25
  saveWorkspace,
26
+ keptOnDeploy,
26
27
  workspaceDir,
27
28
  listRuns,
28
29
  parseFlow,
30
+ parseToolDef,
29
31
  WORKSPACE_DIRS,
30
32
  assertSafeName,
31
33
  type DeployFile,
32
34
  notifyWorkspaceChanged,
33
35
  } from "./store.ts";
36
+ import { lintFlow } from "./flow-lint.ts";
37
+ import { refNames } from "./refs.ts";
38
+ import matter from "gray-matter";
34
39
  import { conformanceIssues } from "./okf.ts";
40
+ import { timezoneProblem } from "./clock.ts";
41
+ import { webProblems, webWarnings, runnerEngines } from "./providers.ts";
42
+ import { readLimits } from "./limits.ts";
43
+ import { actionProblems } from "./web-actions.ts";
44
+ import { languageProblem } from "./language.ts";
45
+ import { localeProblems } from "./locale.ts";
35
46
  import { repoDir } from "./gitrepo.ts";
47
+ import { entriesNoFollow } from "./paths.ts";
36
48
 
37
49
  /** Directories a deploy never reads out of a source tree. */
38
50
  const NOT_SOURCE = new Set([".git", "node_modules", "runs", "outputs", ".foldrun", ".results"]);
@@ -42,6 +54,23 @@ const NOT_SOURCE_FILE = new Set([".DS_Store", "secrets.json"]);
42
54
 
43
55
  const IN_WORKSPACE_DIR = new RegExp(`^(${WORKSPACE_DIRS.join("|")})/`);
44
56
 
57
+ /**
58
+ * Is this path, relative to a workspace root, one `readTree` would carry?
59
+ *
60
+ * The same question the walk above asks, asked of a path that is not on this
61
+ * machine — what the platform lists for a workspace, so `foldrun status` and
62
+ * `foldrun pull` compare like with like. Without it the comparison counted
63
+ * every run artefact and bookkeeping file the platform keeps as a local
64
+ * deletion, and every status was a wall of red.
65
+ */
66
+ export function isSourcePath(rel: string): boolean {
67
+ const norm = rel.replaceAll("\\", "/");
68
+ const parts = norm.split("/");
69
+ if (parts.some((p) => NOT_SOURCE.has(p))) return false;
70
+ if (NOT_SOURCE_FILE.has(parts[parts.length - 1])) return false;
71
+ return norm === "AGENTS.md" || norm === "project.md" || IN_WORKSPACE_DIR.test(norm);
72
+ }
73
+
45
74
  export interface DeployIssue {
46
75
  where: string;
47
76
  message: string;
@@ -55,12 +84,19 @@ export interface DeployPlan {
55
84
  updated: string[];
56
85
  /** Empty when the deploy is safe to apply. */
57
86
  issues: DeployIssue[];
87
+ /** Advisory, non-blocking: a deploy proceeds with these. Today this is
88
+ * the outward-without-a-gate lint — the same one `foldrun check` fails
89
+ * on, surfaced here so a push over the API is not the last to hear it. */
90
+ warnings: DeployIssue[];
58
91
  /** Set when a run is in flight, which blocks the swap. */
59
92
  blockedBy: string[];
60
93
  }
61
94
 
62
95
  export interface DeployResult extends DeployPlan {
63
96
  applied: boolean;
97
+ /** Set when `expectRemoved` was given and the deploy would have removed
98
+ * paths outside it: those paths. The deploy was refused, nothing changed. */
99
+ unexpectedRemovals?: string[];
64
100
  /** The commit this workspace is now running, when the caller knows it. */
65
101
  commit: string | null;
66
102
  preserved: number;
@@ -166,6 +202,55 @@ export function readTree(dir: string): DeployFile[] {
166
202
  * not on disk yet. A deploy that would leave the workspace failing its own
167
203
  * checker should not be a deploy.
168
204
  */
205
+ /** Best-effort warnings for a deploy: an agent's `web:` warnings (see
206
+ * webWarnings), and the outward gate — a step whose agent grants
207
+ * a workspace tool marked `outward: true` and has no `verify:`/gate. Reads
208
+ * only the deployed files, so a library or gallery outward tool is not seen
209
+ * here — `foldrun check`, which resolves every scope, remains the real gate.
210
+ * Non-blocking by decision: the deploy proceeds and says so. */
211
+ export function deployWarnings(files: DeployFile[]): DeployIssue[] {
212
+ const front = (content: string): Record<string, unknown> => {
213
+ try { return matter(content).data as Record<string, unknown>; } catch { return {}; }
214
+ };
215
+ // Workspace tools that change something outside the workspace.
216
+ const outwardTools = new Set<string>();
217
+ for (const f of files) {
218
+ const m = f.path.match(/^tools\/([^/]+?)(?:\/tool)?\.md$/);
219
+ if (!m) continue;
220
+ const data = front(f.content);
221
+ const def = parseToolDef(data, m[1], f.content);
222
+ // `outward: true` is read off the frontmatter, the same test the runtime
223
+ // and the CLI use; parseToolDef does not fold it onto the def.
224
+ if (def && data.outward === true) outwardTools.add(def.name ?? m[1]);
225
+ }
226
+ // Agents that grant one of them.
227
+ const outwardAgents: string[] = [];
228
+ const agentNames: string[] = [];
229
+ const out: DeployIssue[] = [];
230
+ // The web: warnings check prints — the old session: key, and a pinned
231
+ // user agent behind the runner image's Chrome, which the platform knows
232
+ // from FOLDRUN_RUNNER_ENGINES (the deploy read it from the image).
233
+ const { chrome, chromium } = runnerEngines() ?? {};
234
+ for (const f of files) {
235
+ const m = f.path.match(/^agents\/([^/]+)\/agent\.md$/);
236
+ if (!m) continue;
237
+ agentNames.push(m[1]);
238
+ const data = front(f.content);
239
+ if (refNames(data.tools).some((t: string) => outwardTools.has(t))) outwardAgents.push(m[1]);
240
+ for (const w of webWarnings(data, { chrome, chromium })) out.push({ where: f.path, message: w });
241
+ }
242
+ if (!outwardAgents.length) return out;
243
+ for (const f of files) {
244
+ if (!/^flows\/[^/]+\.md$/.test(f.path)) continue;
245
+ let flow;
246
+ try { flow = parseFlow(path.basename(f.path), f.content); } catch { continue; }
247
+ for (const w of lintFlow(flow, { agents: agentNames, outwardAgents })) {
248
+ if (w.kind === "outward") out.push({ where: `${f.path}${w.line ? `:${w.line}` : ""}`, message: w.message });
249
+ }
250
+ }
251
+ return out;
252
+ }
253
+
169
254
  export function deployIssues(files: DeployFile[]): DeployIssue[] {
170
255
  const issues: DeployIssue[] = [];
171
256
  const at = (where: string, message: string) => issues.push({ where, message });
@@ -193,6 +278,34 @@ export function deployIssues(files: DeployFile[]): DeployIssue[] {
193
278
  const m = f.path.match(/^agents\/([^/]+)\/agent\.md$/);
194
279
  if (m) agents.add(m[1]);
195
280
  }
281
+
282
+ // `timezone:` is read at four levels and a value nothing can parse falls
283
+ // silently through to the level above — so it is refused where it was
284
+ // written, not discovered as a date that is a day out.
285
+ for (const f of files) {
286
+ if (!/^(AGENTS\.md|agents\/[^/]+\/agent\.md)$/.test(f.path)) continue;
287
+ let front: Record<string, unknown> = {};
288
+ try {
289
+ front = matter(f.content).data as Record<string, unknown>;
290
+ } catch {
291
+ continue; // unparseable frontmatter is its own report
292
+ }
293
+ const problem = timezoneProblem(front.timezone);
294
+ if (problem) at(f.path, problem);
295
+ // `schedule:` on an agent or AGENTS.md runs nothing — only a flow has a
296
+ // clock. Refuse it where it was written rather than deploy it dead.
297
+ if (front.schedule !== undefined) {
298
+ 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.");
299
+ }
300
+ // The same refusal check gives: a web key that cannot work never ships.
301
+ for (const w of [...webProblems(front), ...actionProblems(front)]) at(f.path, w);
302
+ // `limits:` that cannot be read — a count of 0, a word, web.<not an
303
+ // action> — would run uncapped where the author meant a cap.
304
+ for (const w of readLimits(front.limits).problems) at(f.path, w);
305
+ const lang = languageProblem(front.language);
306
+ if (lang) at(f.path, lang);
307
+ for (const w of localeProblems(front)) at(f.path, w);
308
+ }
196
309
  if (agents.size === 0) {
197
310
  at("agents/", "no agents — a workspace needs at least one agents/<name>/agent.md");
198
311
  }
@@ -218,6 +331,7 @@ export function deployIssues(files: DeployFile[]): DeployIssue[] {
218
331
  continue;
219
332
  }
220
333
  if (parsed.steps.length === 0) at(f.path, "no steps");
334
+ for (const fp of parsed.frontProblems) at(f.path, fp);
221
335
  for (const s of parsed.steps) {
222
336
  const target = s.subflow ?? s.agent;
223
337
  const known = s.subflow ? flowNames.has(target) : agents.has(target);
@@ -284,7 +398,7 @@ export function planDeploy(tenant: string, workspace: string, files: DeployFile[
284
398
 
285
399
  const present = new Set<string>();
286
400
  if (fs.existsSync(dir)) {
287
- for (const entry of fs.readdirSync(dir, { recursive: true })) {
401
+ for (const entry of entriesNoFollow(dir)) {
288
402
  const rel = String(entry).split(path.sep).join("/");
289
403
  if (NOT_SOURCE.has(rel.split("/")[0])) continue;
290
404
  if (/(^|\/)(runs|outputs|\.results)\//.test(rel)) continue;
@@ -312,11 +426,11 @@ export function planDeploy(tenant: string, workspace: string, files: DeployFile[
312
426
  }
313
427
  }
314
428
 
315
- // What saveWorkspace will keep regardless — reporting these as removals
316
- // would be a lie, and the alarming kind.
317
- const kept = (rel: string) =>
318
- /(^|\/)state\//.test(rel) || /(^|\/)memory\/[^/]+\.md$/.test(rel);
319
- const removed = [...present].filter((rel) => !shipped.has(rel) && !kept(rel));
429
+ // What saveWorkspace will keep regardless — storage/, state/, agent
430
+ // memory, the platform's own files. Reporting these as removals would be
431
+ // a lie, and the alarming kind; one predicate serves both.
432
+ const shippedPaths = new Set(shipped.keys());
433
+ const removed = [...present].filter((rel) => !shipped.has(rel) && !keptOnDeploy(rel, shippedPaths));
320
434
 
321
435
  return {
322
436
  files,
@@ -324,6 +438,7 @@ export function planDeploy(tenant: string, workspace: string, files: DeployFile[
324
438
  updated: updated.sort(),
325
439
  removed: removed.sort(),
326
440
  issues: deployIssues(files),
441
+ warnings: deployWarnings(files),
327
442
  blockedBy: runsInFlight(tenant, workspace),
328
443
  };
329
444
  }
@@ -336,6 +451,15 @@ export interface DeployOptions {
336
451
  message?: string;
337
452
  /** Apply even while runs are in flight. */
338
453
  force?: boolean;
454
+ /**
455
+ * The removals a person confirmed from a dry run. When given, the deploy
456
+ * is refused (applied: false, `unexpectedRemovals` set) if it would remove
457
+ * anything not in this list — a run can write new storage/ outputs between
458
+ * the question and the answer, and a yes to one list is not a yes to
459
+ * another. Checked in the same synchronous call that writes, so there is
460
+ * no window between the check and the swap. Absent: no check, as before.
461
+ */
462
+ expectRemoved?: string[];
339
463
  }
340
464
 
341
465
  /**
@@ -427,6 +551,13 @@ export function deployWorkspace(
427
551
  if (plan.issues.length > 0 || blocked) {
428
552
  return { ...plan, applied: false, commit: null, preserved: 0 };
429
553
  }
554
+ if (opts.expectRemoved) {
555
+ const ok = new Set(opts.expectRemoved.map((p) => path.normalize(p).split(path.sep).join("/")));
556
+ const unexpectedRemovals = plan.removed.filter((p) => !ok.has(p));
557
+ if (unexpectedRemovals.length) {
558
+ return { ...plan, applied: false, commit: null, preserved: 0, unexpectedRemovals };
559
+ }
560
+ }
430
561
 
431
562
  const { preserved } = saveWorkspace(tenant, workspace, files, { commit: opts.commit ?? null, by: opts.by ?? "deploy", message: opts.message });
432
563
  const commit = opts.commit ?? null;
package/src/egress.ts CHANGED
@@ -35,6 +35,9 @@
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
37
 
38
+ import { trimSlashes } from "./paths.ts";
39
+ import type { TestEffect } from "./test-mode.ts";
40
+
38
41
  /** `${NAME}` — the shape a secret reference takes everywhere in foldrun. */
39
42
  export const PLACEHOLDER = /\$\{([A-Z][A-Z0-9_]*)\}/g;
40
43
 
@@ -51,7 +54,7 @@ export const MODEL_KEY_NAME = "FOLDRUN_MODEL_KEY";
51
54
  */
52
55
  export function viaEgress(egress: string | undefined | null, target: string): string {
53
56
  if (!egress) return target;
54
- return `${egress.replace(/\/+$/, "")}/${target}`;
57
+ return `${trimSlashes(egress)}/${target}`;
55
58
  }
56
59
 
57
60
  /** The names referenced by placeholders in a string. */
@@ -86,6 +89,16 @@ export function hostOf(url: string): string | null {
86
89
  export interface EgressGrant {
87
90
  /** Secret name → the hosts that secret may be substituted for. */
88
91
  secrets: Record<string, { value: string; hosts: string[] }>;
92
+ /** Secret name → the vault entry it came from, as secret-health keys it:
93
+ * "account:NAME" or "workspace:<ws>:NAME". Two workspaces each holding an
94
+ * API_KEY are two credentials, and one's refusal must not paint the
95
+ * other red. Absent for a secret whose origin the runner did not know. */
96
+ healthKeys?: Record<string, string>;
97
+ /** The step's own timezone, from the clock cascade. The proxy is the only
98
+ * party that sees an upstream response's headers, so it is the party
99
+ * that has to put a rate-limit reset into words — and a reset time is
100
+ * only useful in the calendar the step works to. See refusal.ts. */
101
+ timezone?: string;
89
102
  }
90
103
 
91
104
  export interface EgressLease {
@@ -96,19 +109,28 @@ export interface EgressLease {
96
109
  * lease URL first), so a store that is not this process's memory has
97
110
  * to be told when the grant is complete. Call before every attempt. */
98
111
  commit(): Promise<void>;
99
- /** One line per request, for the run trace. Drained by the runner. */
100
- drainLog(): Promise<string[]>;
112
+ /** One line per request, for the run trace. Drained by the runner. A
113
+ * line may carry a test-mode effect beside its text — what the proxy
114
+ * refused or redirected on a test lease — for the run page to list. */
115
+ drainLog(): Promise<EgressLogLine[]>;
101
116
  /** The step is over: the lease is void and its values are dropped. */
102
117
  release(): Promise<void>;
103
118
  }
104
119
 
120
+ export type EgressLogLine = string | { text: string; effect: TestEffect };
121
+
105
122
  /**
106
123
  * The seam the platform fills. `null` from `lease` (or no `egress` at all)
107
124
  * means "no proxy here" and the runner falls back to materialising, exactly
108
125
  * as it always did.
109
126
  */
110
127
  export interface EgressHooks {
111
- lease(args: { tenant: string; runId: string; grant: EgressGrant }): Promise<EgressLease | null>;
128
+ /** `test` — the run is a test run: the proxy applies the outward-write
129
+ * policy in test-mode.ts to every request on this lease. Carried on the
130
+ * lease, not the request, because the pod is the party not trusted to
131
+ * say so. */
132
+ /** `step` — the step's index in run.steps; keys that step's operator inbox. */
133
+ lease(args: { tenant: string; runId: string; grant: EgressGrant; test?: boolean; step?: number }): Promise<EgressLease | null>;
112
134
  }
113
135
 
114
136
  /** Strip a header set of the proxy's own and hop-by-hop headers. */
@@ -172,5 +194,11 @@ export function addGrant(grant: EgressGrant, name: string, value: string, host:
172
194
  const existing = grant.secrets[name];
173
195
  if (existing) {
174
196
  if (!existing.hosts.includes(h)) existing.hosts.push(h);
197
+ // A name granted again with a different value is a rotation: the
198
+ // runner re-grants the model key after awaitRotatedCredential, on the
199
+ // same grant object the lease holds by reference. Keeping the first
200
+ // value here meant the "retry on the new one" sent the revoked token —
201
+ // every 4-hourly refresh on 2026-09-18..22 failed the step it landed on.
202
+ existing.value = value;
175
203
  } else grant.secrets[name] = { value, hosts: [h] };
176
204
  }