@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
@@ -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
@@ -141,11 +143,40 @@ export function suggestPath(raw) {
141
143
  }
142
144
  return `Use a path relative to your agent directory, or workspace/<dir>/… for the workspace root (workspace/storage/, workspace/state/).`;
143
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
+ }
144
166
  /** Expand a virtual prefix to a real absolute path, or null if it has none. */
145
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;
146
177
  for (const v of VIRTUAL) {
147
- if (raw.startsWith(v.prefix)) {
148
- 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 };
149
180
  }
150
181
  }
151
182
  return null;
@@ -205,6 +236,25 @@ export function checkPaths(toolName, input, roots) {
205
236
  `environment variables, and the run journal is not yours to read or rewrite.`,
206
237
  };
207
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
+ }
208
258
  if (isWrite && READ_ONLY_WITHIN.some((re) => re.test(within))) {
209
259
  return {
210
260
  ok: false,
@@ -218,6 +268,30 @@ export function checkPaths(toolName, input, roots) {
218
268
  if (virtual)
219
269
  rewritten = { ...(rewritten ?? input), [key]: abs };
220
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
+ }
221
295
  return { ok: true, updatedInput: rewritten };
222
296
  }
223
297
  // A shell can defeat any argument-level path check, so Bash is handled
@@ -301,3 +375,144 @@ export function checkBash(command) {
301
375
  }
302
376
  return { ok: true };
303
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
+ }
@@ -15,6 +15,13 @@ export interface ContainerRunOptions {
15
15
  agentDir: string;
16
16
  /** Extra read-only mounts: host path → container path. */
17
17
  readOnly?: Record<string, string>;
18
+ /** Workspace paths a script was handed as `workspace/…`: copied in at
19
+ * `container` before it runs, and what it created or changed under them
20
+ * copied back to `host` after — the host's workspace link, as a copy. */
21
+ staged?: {
22
+ host: string;
23
+ container: string;
24
+ }[];
18
25
  image: string;
19
26
  argv: string[];
20
27
  env: Record<string, string>;
@@ -28,3 +35,5 @@ export declare function runInContainer(opts: ContainerRunOptions): Promise<{
28
35
  code: number | null;
29
36
  out: string;
30
37
  }>;
38
+ /** Write `from` over `to` where the bytes differ; recurse into folders. */
39
+ export declare function syncChanged(from: string, to: string): void;
@@ -9,6 +9,7 @@
9
9
  // Falls back to host execution when Docker isn't available, so local
10
10
  // development keeps working with zero configuration.
11
11
  import fs from "node:fs";
12
+ import os from "node:os";
12
13
  import path from "node:path";
13
14
  import { dataRoot } from "./paths.js";
14
15
  import { spawnSync, spawn } from "node:child_process";
@@ -135,16 +136,41 @@ export async function runInContainer(opts) {
135
136
  }
136
137
  const cid = (created.stdout ?? "").trim();
137
138
  try {
138
- // The agent's own directory becomes /workspace…
139
- const copied = docker(["cp", `${opts.agentDir}/.`, `${cid}:/workspace`], 120_000);
140
- if (copied.status !== 0) {
141
- return { code: null, out: `copy in failed: ${(copied.stderr ?? "").slice(-300)}` };
142
- }
139
+ // The agent's own directory becomes /workspace… — its entries staged
140
+ // like the workspace paths below (a scratch copy, opened up for the
141
+ // script's user, one tar), minus the step's `workspace` link. A step
142
+ // holds that link (→ ../..) so the shell can say workspace/…, and
143
+ // `docker cp` refuses a link that climbs out of what it copies
144
+ // ("invalid symlink"), so every Docker-run script failed before it
145
+ // started. And `docker cp` lands files as root while scripts run as
146
+ // `agent`, so a script could never write its own outputs/.
147
+ const own = fs
148
+ .readdirSync(opts.agentDir)
149
+ .filter((name) => {
150
+ if (name !== "workspace")
151
+ return true;
152
+ try {
153
+ return !fs.lstatSync(path.join(opts.agentDir, name)).isSymbolicLink();
154
+ }
155
+ catch {
156
+ return false;
157
+ }
158
+ })
159
+ .map((name) => ({ host: path.join(opts.agentDir, name), container: `/workspace/${name}` }));
160
+ const copiedIn = stageIn(CLI, cid, own, ["/workspace"]);
161
+ if (copiedIn)
162
+ return { code: null, out: `copy in failed: ${copiedIn}` };
143
163
  // …and any shared directories land at their declared paths.
144
164
  for (const [host, mount] of Object.entries(opts.readOnly ?? {})) {
145
165
  if (fs.existsSync(host))
146
166
  docker(["cp", `${host}/.`, `${cid}:${mount}`], 120_000);
147
167
  }
168
+ const staged = opts.staged ?? [];
169
+ if (staged.length) {
170
+ const failed = stageIn(CLI, cid, staged);
171
+ if (failed)
172
+ return { code: null, out: `copy in failed: ${failed}` };
173
+ }
148
174
  const result = await new Promise((resolve) => {
149
175
  const child = spawn(CLI, ["start", "-a", cid], { timeout: opts.timeoutMs });
150
176
  let out = "";
@@ -164,9 +190,93 @@ export async function runInContainer(opts) {
164
190
  });
165
191
  // Bring deliverables back so outputs/ behaves the same either way.
166
192
  docker(["cp", `${cid}:/workspace/outputs/.`, path.join(opts.agentDir, "outputs")], 120_000);
193
+ for (const s of staged)
194
+ stageBack(CLI, cid, s);
167
195
  return result;
168
196
  }
169
197
  finally {
170
198
  docker(["rm", "-f", cid], 30_000);
171
199
  }
172
200
  }
201
+ /**
202
+ * Copy the staged workspace paths into a created container: one tar, built
203
+ * in a scratch directory that mirrors the container paths, extracted at /.
204
+ * A path that does not exist yet (an output the script is told to write)
205
+ * gets its parent directory, so the write has somewhere to land.
206
+ */
207
+ function stageIn(cli, cid, staged, dirs = []) {
208
+ const scratch = fs.mkdtempSync(path.join(os.tmpdir(), "foldrun-stage-"));
209
+ try {
210
+ for (const d of dirs)
211
+ fs.mkdirSync(path.join(scratch, d), { recursive: true });
212
+ for (const { host, container } of staged) {
213
+ const dest = path.join(scratch, container);
214
+ if (fs.existsSync(host)) {
215
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
216
+ fs.cpSync(host, dest, { recursive: true, dereference: false });
217
+ }
218
+ else {
219
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
220
+ }
221
+ }
222
+ // `docker cp` lands files as root and scripts run as `agent`: these are
223
+ // scratch copies, so open them up for the script to write into.
224
+ openUp(scratch);
225
+ const top = fs.readdirSync(scratch);
226
+ const r = spawnSync("sh", ["-c", 'dir="$1"; cli="$2"; dest="$3"; shift 3; tar --no-xattrs -C "$dir" -cf - -- "$@" | "$cli" cp - "$dest"', "sh", scratch, cli, `${cid}:/`, ...top], { encoding: "utf8", timeout: 120_000, env: { ...process.env, COPYFILE_DISABLE: "1" } });
227
+ return r.status === 0 ? null : (r.stderr ?? "").slice(-300);
228
+ }
229
+ finally {
230
+ fs.rmSync(scratch, { recursive: true, force: true });
231
+ }
232
+ }
233
+ /**
234
+ * Copy back what the script created or changed under one staged path. Files
235
+ * whose bytes did not change are left alone, so an input keeps its mtime —
236
+ * freshness checks read it.
237
+ */
238
+ function stageBack(cli, cid, s) {
239
+ const scratch = fs.mkdtempSync(path.join(os.tmpdir(), "foldrun-stage-"));
240
+ try {
241
+ const out = path.join(scratch, "x");
242
+ const got = spawnSync(cli, ["cp", `${cid}:${s.container}`, out], { encoding: "utf8", timeout: 120_000 });
243
+ if (got.status !== 0 || !fs.existsSync(out))
244
+ return;
245
+ syncChanged(out, s.host);
246
+ }
247
+ finally {
248
+ fs.rmSync(scratch, { recursive: true, force: true });
249
+ }
250
+ }
251
+ /** Write `from` over `to` where the bytes differ; recurse into folders. */
252
+ export function syncChanged(from, to) {
253
+ const st = fs.lstatSync(from);
254
+ if (st.isSymbolicLink())
255
+ return;
256
+ if (st.isDirectory()) {
257
+ fs.mkdirSync(to, { recursive: true });
258
+ for (const name of fs.readdirSync(from))
259
+ syncChanged(path.join(from, name), path.join(to, name));
260
+ return;
261
+ }
262
+ const next = fs.readFileSync(from);
263
+ if (fs.existsSync(to) && fs.statSync(to).isFile() && fs.readFileSync(to).equals(next))
264
+ return;
265
+ fs.mkdirSync(path.dirname(to), { recursive: true });
266
+ fs.writeFileSync(to, next);
267
+ }
268
+ /** Directories 0777, files writable by all (exec bits kept): a scratch tree only. */
269
+ function openUp(dir) {
270
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
271
+ const p = path.join(dir, entry.name);
272
+ if (entry.isSymbolicLink())
273
+ continue;
274
+ if (entry.isDirectory()) {
275
+ fs.chmodSync(p, 0o777);
276
+ openUp(p);
277
+ }
278
+ else {
279
+ fs.chmodSync(p, fs.statSync(p).mode | 0o666);
280
+ }
281
+ }
282
+ }
@@ -1,7 +1,7 @@
1
1
  import { createSdkMcpServer } from "@anthropic-ai/claude-agent-sdk";
2
2
  import type { RunRecord } from "./store.ts";
3
3
  export interface SearchRoot {
4
- /** How the agent should name the place: "knowledge/", "../../memory/". */
4
+ /** How the agent should name the place: "knowledge/", "workspace/memory/". */
5
5
  label: string;
6
6
  /** The directory, absolute. */
7
7
  dir: string;
@@ -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.