@foldrun/core 0.2.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 (166) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +239 -0
  3. package/dist/index.d.ts +32 -0
  4. package/dist/index.js +43 -0
  5. package/dist/src/agent-tools.d.ts +31 -0
  6. package/dist/src/agent-tools.js +99 -0
  7. package/dist/src/agents-md.d.ts +9 -0
  8. package/dist/src/agents-md.js +83 -0
  9. package/dist/src/api-tools.d.ts +38 -0
  10. package/dist/src/api-tools.js +313 -0
  11. package/dist/src/approvals.d.ts +39 -0
  12. package/dist/src/approvals.js +103 -0
  13. package/dist/src/arrange.d.ts +8 -0
  14. package/dist/src/arrange.js +40 -0
  15. package/dist/src/catalog.d.ts +108 -0
  16. package/dist/src/catalog.js +255 -0
  17. package/dist/src/completions.d.ts +61 -0
  18. package/dist/src/completions.js +578 -0
  19. package/dist/src/confine.d.ts +31 -0
  20. package/dist/src/confine.js +298 -0
  21. package/dist/src/container.d.ts +30 -0
  22. package/dist/src/container.js +172 -0
  23. package/dist/src/context-tools.d.ts +81 -0
  24. package/dist/src/context-tools.js +347 -0
  25. package/dist/src/deploy.d.ts +100 -0
  26. package/dist/src/deploy.js +377 -0
  27. package/dist/src/egress.d.ts +75 -0
  28. package/dist/src/egress.js +133 -0
  29. package/dist/src/evals.d.ts +150 -0
  30. package/dist/src/evals.js +651 -0
  31. package/dist/src/flow-lint.d.ts +16 -0
  32. package/dist/src/flow-lint.js +177 -0
  33. package/dist/src/git-hook.d.ts +1 -0
  34. package/dist/src/git-hook.js +70 -0
  35. package/dist/src/git.d.ts +37 -0
  36. package/dist/src/git.js +101 -0
  37. package/dist/src/gitrepo.d.ts +95 -0
  38. package/dist/src/gitrepo.js +355 -0
  39. package/dist/src/history.d.ts +82 -0
  40. package/dist/src/history.js +259 -0
  41. package/dist/src/kinds.d.ts +95 -0
  42. package/dist/src/kinds.js +421 -0
  43. package/dist/src/library.d.ts +94 -0
  44. package/dist/src/library.js +341 -0
  45. package/dist/src/linkable.d.ts +20 -0
  46. package/dist/src/linkable.js +113 -0
  47. package/dist/src/model-probe.d.ts +21 -0
  48. package/dist/src/model-probe.js +81 -0
  49. package/dist/src/notify.d.ts +61 -0
  50. package/dist/src/notify.js +252 -0
  51. package/dist/src/oauth-presets.d.ts +13 -0
  52. package/dist/src/oauth-presets.js +39 -0
  53. package/dist/src/okf.d.ts +220 -0
  54. package/dist/src/okf.js +656 -0
  55. package/dist/src/openapi.d.ts +66 -0
  56. package/dist/src/openapi.js +342 -0
  57. package/dist/src/paths.d.ts +33 -0
  58. package/dist/src/paths.js +78 -0
  59. package/dist/src/platform.d.ts +32 -0
  60. package/dist/src/platform.js +48 -0
  61. package/dist/src/providers.d.ts +47 -0
  62. package/dist/src/providers.js +114 -0
  63. package/dist/src/refs.d.ts +20 -0
  64. package/dist/src/refs.js +78 -0
  65. package/dist/src/run-container.d.ts +227 -0
  66. package/dist/src/run-container.js +760 -0
  67. package/dist/src/runner.d.ts +241 -0
  68. package/dist/src/runner.js +3215 -0
  69. package/dist/src/runtime.d.ts +41 -0
  70. package/dist/src/runtime.js +357 -0
  71. package/dist/src/script-tools.d.ts +62 -0
  72. package/dist/src/script-tools.js +296 -0
  73. package/dist/src/secret-files.d.ts +12 -0
  74. package/dist/src/secret-files.js +92 -0
  75. package/dist/src/secrets.d.ts +147 -0
  76. package/dist/src/secrets.js +626 -0
  77. package/dist/src/starter.d.ts +21 -0
  78. package/dist/src/starter.js +399 -0
  79. package/dist/src/step-exec.d.ts +136 -0
  80. package/dist/src/step-exec.js +391 -0
  81. package/dist/src/storage.d.ts +123 -0
  82. package/dist/src/storage.js +640 -0
  83. package/dist/src/store.d.ts +1052 -0
  84. package/dist/src/store.js +2378 -0
  85. package/dist/src/tar.d.ts +30 -0
  86. package/dist/src/tar.js +111 -0
  87. package/dist/src/tool-names.d.ts +39 -0
  88. package/dist/src/tool-names.js +79 -0
  89. package/dist/src/tool-programs.d.ts +18 -0
  90. package/dist/src/tool-programs.js +52 -0
  91. package/dist/src/tool-test.d.ts +19 -0
  92. package/dist/src/tool-test.js +335 -0
  93. package/dist/src/translator.d.ts +159 -0
  94. package/dist/src/translator.js +986 -0
  95. package/dist/src/triggers.d.ts +60 -0
  96. package/dist/src/triggers.js +270 -0
  97. package/dist/src/webhook.d.ts +47 -0
  98. package/dist/src/webhook.js +148 -0
  99. package/index.ts +44 -0
  100. package/package.json +64 -0
  101. package/src/agent-tools.ts +138 -0
  102. package/src/agents-md.ts +81 -0
  103. package/src/api-tools.ts +389 -0
  104. package/src/approvals.ts +129 -0
  105. package/src/arrange.ts +42 -0
  106. package/src/catalog.ts +321 -0
  107. package/src/completions.ts +652 -0
  108. package/src/confine.ts +350 -0
  109. package/src/container.ts +220 -0
  110. package/src/context-tools.ts +435 -0
  111. package/src/deploy.ts +458 -0
  112. package/src/egress.ts +176 -0
  113. package/src/evals.ts +812 -0
  114. package/src/flow-lint.ts +215 -0
  115. package/src/git-hook.ts +73 -0
  116. package/src/git.ts +117 -0
  117. package/src/gitrepo.ts +391 -0
  118. package/src/history.ts +329 -0
  119. package/src/kinds.ts +507 -0
  120. package/src/library.ts +419 -0
  121. package/src/linkable.ts +123 -0
  122. package/src/model-probe.ts +112 -0
  123. package/src/notify.ts +281 -0
  124. package/src/oauth-presets.ts +54 -0
  125. package/src/okf.ts +768 -0
  126. package/src/openapi.ts +383 -0
  127. package/src/paths.ts +79 -0
  128. package/src/platform.ts +87 -0
  129. package/src/providers.ts +147 -0
  130. package/src/refs.ts +83 -0
  131. package/src/run-container.ts +883 -0
  132. package/src/runner.ts +3446 -0
  133. package/src/runtime.ts +404 -0
  134. package/src/script-tools.ts +383 -0
  135. package/src/secret-files.ts +101 -0
  136. package/src/secrets.ts +799 -0
  137. package/src/starter.ts +415 -0
  138. package/src/step-exec.ts +498 -0
  139. package/src/storage.ts +771 -0
  140. package/src/store.ts +2985 -0
  141. package/src/tar.ts +124 -0
  142. package/src/tool-names.ts +91 -0
  143. package/src/tool-programs.ts +65 -0
  144. package/src/tool-test.ts +401 -0
  145. package/src/translator.ts +1029 -0
  146. package/src/triggers.ts +287 -0
  147. package/src/webhook.ts +183 -0
  148. package/templates/blog-desk/AGENTS.md +13 -0
  149. package/templates/blog-desk/agents/researcher/agent.md +11 -0
  150. package/templates/blog-desk/agents/writer/agent.md +8 -0
  151. package/templates/blog-desk/evals/writer-quality.md +10 -0
  152. package/templates/blog-desk/flows/publish.md +10 -0
  153. package/templates/blog-desk/knowledge/house-style.md +9 -0
  154. package/templates/blog-desk/knowledge/index.md +9 -0
  155. package/templates/hello/AGENTS.md +18 -0
  156. package/templates/hello/agents/curator/agent.md +32 -0
  157. package/templates/hello/agents/notetaker/agent.md +24 -0
  158. package/templates/hello/evals/house-style.md +16 -0
  159. package/templates/hello/flows/memory-review.md +7 -0
  160. package/templates/hello/flows/note.md +7 -0
  161. package/templates/hello/knowledge/house-style.md +15 -0
  162. package/templates/hello/knowledge/index.md +9 -0
  163. package/templates/hello/skills/plain-english/SKILL.md +11 -0
  164. package/templates/hello/state/publishing.md +7 -0
  165. package/templates/hello/tools/wordcount/run.py +13 -0
  166. package/templates/hello/tools/wordcount/tool.md +22 -0
@@ -0,0 +1,298 @@
1
+ // Filesystem confinement for an agent's own tools.
2
+ //
3
+ // Scripts already run in containers. The agent's *built-in* tools —
4
+ // Read/Write/Edit/Glob/Grep and friends — do not: they execute in the server
5
+ // process with its full permissions. A run proved this is not theoretical: an
6
+ // agent told to write `outputs/SHIPPED.txt` created it two directories up and
7
+ // invented a whole phantom workspace on the way.
8
+ //
9
+ // The boundary is the WORKSPACE, not the agent. Agents in one workspace are a
10
+ // team — a reviewer legitimately reads what the writer produced — while the
11
+ // workspace is the isolation unit for everything else in the platform, so it
12
+ // is the honest place to draw the line.
13
+ //
14
+ // Inside that boundary, three things are still off limits:
15
+ // · secrets.json — the encrypted store; an agent gets secrets as env vars,
16
+ // never as a file it can exfiltrate wholesale
17
+ // · runs/ — the run journal is the audit trail, and a step that can rewrite
18
+ // its own history makes every other guarantee unverifiable
19
+ // · index.md and log.md inside a knowledge/ or memory/ bundle — generated by
20
+ // the platform on every write. An agent scribbling on one would be undone
21
+ // by the next syncBundleFor, but until then the index says whatever it was
22
+ // told to say, and log.md is provenance for the same reason runs/ is.
23
+ //
24
+ // There is no curated index beside the generated one. A bundle has exactly the
25
+ // two reserved files OKF defines, and both are generated.
26
+ //
27
+ // The bundle scoping matters. An earlier draft denied any index.md anywhere,
28
+ // which would have blocked an agent writing outputs/index.md — a perfectly
29
+ // ordinary deliverable — so the rule names the two directories it applies to.
30
+ import fs from "node:fs";
31
+ import path from "node:path";
32
+ /**
33
+ * A path with its symlinks resolved, as far as it exists.
34
+ *
35
+ * Both sides of the containment check have to be resolved the same way or the
36
+ * comparison is between two spellings of one place. On macOS `/var` is a
37
+ * symlink to `/private/var`, so a workspace under a system temp directory was
38
+ * handed to the runtime as `/var/folders/…` while every path the agent
39
+ * produced came back as `/private/var/folders/…`. `path.relative` then said
40
+ * `..`, and the agent was told its own workspace was outside the workspace —
41
+ * on every write, for the whole run, while the run still reported success
42
+ * because the agent gave up politely.
43
+ *
44
+ * Falls back a level at a time because the path may not exist yet: a Write
45
+ * creating a file needs the directory resolved, not the file.
46
+ */
47
+ function real(p) {
48
+ try {
49
+ return fs.realpathSync.native(p);
50
+ }
51
+ catch {
52
+ const parent = path.dirname(p);
53
+ return parent === p ? p : path.join(real(parent), path.basename(p));
54
+ }
55
+ }
56
+ /** Tool argument names that carry a filesystem path. */
57
+ const PATH_KEYS = [
58
+ "file_path",
59
+ "path",
60
+ "notebook_path",
61
+ "filePath",
62
+ "edits", // NotebookEdit-style payloads are checked via file_path too
63
+ ];
64
+ const DENIED_WITHIN = [
65
+ /(^|\/)secrets\.json$/,
66
+ /(^|\/)runs(\/|$)/,
67
+ ];
68
+ /**
69
+ * Generated, and therefore not an agent's to edit — but very much an agent's
70
+ * to read.
71
+ *
72
+ * These were denied outright, and a run proved what that costs: an agent told
73
+ * to start from the index asked for `knowledge/index.md`, was refused, and
74
+ * spent a turn recovering. The index is the navigational artifact the whole
75
+ * progressive-disclosure design rests on; refusing to let a reader open it is
76
+ * the opposite of the intent. The reason they are protected is that a
77
+ * generated file an agent can edit is one that can lie — which is an argument
78
+ * about writing, and only about writing.
79
+ */
80
+ const READ_ONLY_WITHIN = [/(^|\/)(knowledge|memory)\/(.*\/)?(index|log)\.md$/];
81
+ // Skills are announced to the agent with virtual prefixes — `workspace/skills/…`
82
+ // for the workspace's, `library/skills/…` for the account's — because a skill
83
+ // at either scope lives outside the agent's own folder. Symlinking them in
84
+ // was tried and reverted: it recursed into the workspace's own file listing.
85
+ // Expanding the prefix here instead keeps one honest path per skill, with no
86
+ // duplication on disk.
87
+ // Two prefixes, named after the two scopes above an agent. There used to be
88
+ // three — `workspace/` meant the workspace root while `shared/` meant the
89
+ // workspace's *scripts*, which is exactly the kind of near-synonym that gets
90
+ // mis-typed. The old spellings still resolve so nothing existing breaks.
91
+ const VIRTUAL = [
92
+ { prefix: "account/", root: (r) => r.libraryRoot, readOnly: true },
93
+ { prefix: "workspace/", root: (r) => r.workspaceRoot, readOnly: false },
94
+ // legacy, still accepted
95
+ { prefix: "library/", root: (r) => r.libraryRoot, readOnly: true },
96
+ { prefix: "workspace/", root: (r) => r.workspaceRoot, readOnly: false },
97
+ ];
98
+ const WRITE_TOOLS = new Set(["Write", "Edit", "MultiEdit", "NotebookEdit"]);
99
+ // Only the built-in filesystem tools get path checks. foldrun's own MCP tools
100
+ // are already confined at their own layer — script paths by resolveRunPath,
101
+ // APIs by their declared base URL — and their arguments are not filesystem
102
+ // paths at all: `call_<api>` takes a *URL* path like "/uuid", which this
103
+ // check would otherwise reject as an escape.
104
+ const FS_TOOLS = new Set([
105
+ "Read",
106
+ "Write",
107
+ "Edit",
108
+ "MultiEdit",
109
+ "NotebookEdit",
110
+ "NotebookRead",
111
+ "Glob",
112
+ "Grep",
113
+ "LS",
114
+ ]);
115
+ export const isFilesystemTool = (toolName) => FS_TOOLS.has(toolName);
116
+ /**
117
+ * The path the agent probably meant. Most escapes are one of three guesses —
118
+ * an invented `/tmp/outputs/…`, an absolute spelling of a workspace
119
+ * directory, or a directory a tool made outside the workspace — and a
120
+ * refusal that names the correction is taken on the next turn, where a
121
+ * generic one is followed by two more wrong guesses. Nothing here is
122
+ * enforced; it is advice in the error text.
123
+ */
124
+ export function suggestPath(raw) {
125
+ const posix = raw.split(path.sep).join("/");
126
+ const m = posix.match(/(?:^|\/)(outputs|storage|state|memory|knowledge)\/(.+)$/);
127
+ if (m) {
128
+ const [, dir, rest] = m;
129
+ return dir === "outputs"
130
+ ? `Did you mean outputs/${rest} (relative to your agent directory)?`
131
+ : `Did you mean workspace/${dir}/${rest}?`;
132
+ }
133
+ if (/^\/tmp\//.test(posix)) {
134
+ return (`There is no /tmp for you. If a tool cloned or built something there, reach it through that tool ` +
135
+ `(for example its read or find action); scratch files go to outputs/.`);
136
+ }
137
+ return `Use a path relative to your agent directory, or workspace/<dir>/… for the workspace root (workspace/storage/, workspace/state/).`;
138
+ }
139
+ /** Expand a virtual prefix to a real absolute path, or null if it has none. */
140
+ export function expandVirtual(raw, roots) {
141
+ 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 };
144
+ }
145
+ }
146
+ return null;
147
+ }
148
+ export function checkPaths(toolName, input, roots) {
149
+ const workspace = real(path.resolve(roots.workspaceRoot));
150
+ const library = real(path.resolve(roots.libraryRoot));
151
+ const isWrite = WRITE_TOOLS.has(toolName);
152
+ let rewritten;
153
+ // Grep's `pattern` is a REGEX, not a location — Grep says where to look
154
+ // with `path`. Checking it as a path denied an agent searching for the
155
+ // literal text "/dev/null", reporting a workspace escape for a string it
156
+ // wanted to find inside the workspace. Glob's `pattern` really is a path
157
+ // shape, so it stays checked.
158
+ const keys = [...PATH_KEYS, "glob", ...(toolName === "Grep" ? [] : ["pattern"])];
159
+ for (const key of keys) {
160
+ const raw = input[key];
161
+ if (typeof raw !== "string" || !raw.trim())
162
+ continue;
163
+ // Glob patterns and grep's file filter are only paths when anchored.
164
+ if ((key === "glob" || key === "pattern") && !raw.startsWith("/") && !expandVirtual(raw, roots)) {
165
+ continue;
166
+ }
167
+ const virtual = expandVirtual(raw, roots);
168
+ const abs = real(virtual ? virtual.abs : path.resolve(roots.agentDir, raw));
169
+ // Knowledge is given, not learned: an agent that rewrites the price list
170
+ // it was handed has corrupted its own source of truth. What it discovers
171
+ // goes to memory/, which it owns.
172
+ if (isWrite && /(^|\/)knowledge\//.test(abs.split(path.sep).join("/"))) {
173
+ return {
174
+ ok: false,
175
+ reason: `${toolName} was denied: knowledge/ is reference material maintained by people, not by ` +
176
+ `you. If you learned something new, write it to memory/ instead.`,
177
+ };
178
+ }
179
+ if (virtual?.readOnly && isWrite) {
180
+ return {
181
+ ok: false,
182
+ reason: `${toolName} was denied: "${raw}" is in the shared library, which is read-only from a ` +
183
+ `run. Change it in the dashboard under Shared, or write your own copy in this workspace.`,
184
+ };
185
+ }
186
+ const inWorkspace = !path.relative(workspace, abs).startsWith("..");
187
+ const inLibrary = !path.relative(library, abs).startsWith("..");
188
+ if (!inWorkspace && !inLibrary) {
189
+ return {
190
+ ok: false,
191
+ reason: `${toolName} was denied: "${raw}" is outside this workspace. Agents may only read and ` +
192
+ `write inside their own workspace. ${suggestPath(raw)}`,
193
+ };
194
+ }
195
+ const within = path.relative(inLibrary ? library : workspace, abs).split(path.sep).join("/");
196
+ if (DENIED_WITHIN.some((re) => re.test(within))) {
197
+ return {
198
+ ok: false,
199
+ reason: `${toolName} was denied: "${raw}" is a protected platform file. Secrets reach you as ` +
200
+ `environment variables, and the run journal is not yours to read or rewrite.`,
201
+ };
202
+ }
203
+ if (isWrite && READ_ONLY_WITHIN.some((re) => re.test(within))) {
204
+ return {
205
+ ok: false,
206
+ reason: `${toolName} was denied: "${raw}" is generated from the files around it and will be ` +
207
+ `rewritten on the next write. Read it freely — to change what it says, change a ` +
208
+ `concept file instead.`,
209
+ };
210
+ }
211
+ // Hand the real path back to the tool, so the virtual prefix the prompt
212
+ // advertises is one the agent can actually open.
213
+ if (virtual)
214
+ rewritten = { ...(rewritten ?? input), [key]: abs };
215
+ }
216
+ return { ok: true, updatedInput: rewritten };
217
+ }
218
+ // A shell can defeat any argument-level path check, so Bash is handled
219
+ // separately: it runs under the SDK sandbox, and these patterns are a second
220
+ // layer that catches the obvious escapes with a clear message rather than a
221
+ // confusing sandbox error.
222
+ const BASH_ESCAPES = [
223
+ /\.\.\/\.\.\/\.\.\/\.\.\/\.\.\//, // five levels up leaves even the account
224
+ /(^|[\s;|&(<>])cd\s+(\/|~)/, // cd to an absolute path or home
225
+ // `~` was guarded only for `cd`, so `cat ~/.ssh/id_rsa` and
226
+ // `cp ~/.aws/credentials .` walked straight past — the exact "obvious
227
+ // escape" this layer exists to name before the sandbox does.
228
+ /(^|[\s;|&(<>=:"'])~\//, // a home-relative path, whatever the command
229
+ /(^|[\s;|&(<>])[a-z_]+\s+\/(etc|root|home|Users|var|proc|dev)\b/, // absolute system paths
230
+ /secrets\.json/,
231
+ /\.secret-key/,
232
+ // Don't let a command *print* a credential out — but only that. This used
233
+ // to match any reference to a secret env var, which denied the entire
234
+ // point of secrets: `ssh -i "$SSH_KEY"`, `curl -H "Authorization: $TOKEN"`
235
+ // and `sshpass -e ssh …` all use a credential without emitting it, and all
236
+ // were being refused. Now only an output builtin fronting the variable is
237
+ // blocked; the journal redaction layer is the backstop for the rest.
238
+ /\b(echo|printf|print|cat|head|tail|env|printenv)\b[^\n|;&]*\$\{?[A-Z_]*(TOKEN|KEY|SECRET|PASSWORD)/,
239
+ ];
240
+ /**
241
+ * Shapes that modify a file, as opposed to reading one.
242
+ *
243
+ * Bash cannot be checked the way a tool call can — there is no path argument,
244
+ * only a string — so the protections checkPaths applies to Write and Edit have
245
+ * to be recognised here from the command's shape. That is why `knowledge/` is
246
+ * matched only alongside one of these: reading the price list is the entire
247
+ * reason it exists, and a blanket rule would have banned `cat`.
248
+ */
249
+ const WRITES_TO_A_FILE = /(>>?|\btee\b|\bsed\b[^]*?\s-i\b|\b(rm|mv|cp|truncate|dd|install|ln|chmod|chown|mkdir|rmdir|touch)\b)/;
250
+ /** Protections that mirror checkPaths, per command segment. */
251
+ const PROTECTED_BASH = [
252
+ {
253
+ re: /(^|[\s'"(/])knowledge\//,
254
+ writeOnly: true,
255
+ reason: "knowledge/ is reference material maintained by people, not by you. If you learned " +
256
+ "something new, write it to memory/ instead.",
257
+ },
258
+ {
259
+ // Not write-only: checkPaths denies reading the journal too, and a rule
260
+ // that differs by tool is a rule an agent can shop around for.
261
+ re: /(^|[\s'"(/])runs(\/|$)/,
262
+ writeOnly: false,
263
+ reason: "the run journal is the audit trail for this run, and is not yours to read or rewrite.",
264
+ },
265
+ {
266
+ re: /(^|[\s'"(/])(knowledge|memory)\/(\S*\/)?(index|log)\.md/,
267
+ // Write-only, matching checkPaths. A rule that differs by tool is a rule
268
+ // an agent can shop around for — and reading the index is the point of it.
269
+ writeOnly: true,
270
+ reason: "index.md and log.md are generated from the files around them — write the concept file " +
271
+ "instead.",
272
+ },
273
+ ];
274
+ export function checkBash(command) {
275
+ for (const re of BASH_ESCAPES) {
276
+ if (re.test(command)) {
277
+ return {
278
+ ok: false,
279
+ reason: `Bash was denied: this command reaches outside the workspace. Work with paths relative ` +
280
+ `to your agent directory.`,
281
+ };
282
+ }
283
+ }
284
+ // Per segment, so `cat knowledge/prices.md && echo done` is judged on the
285
+ // part that touches the path rather than on the whole line.
286
+ for (const segment of command.split(/[;|&]+|\n/)) {
287
+ if (!segment.trim())
288
+ continue;
289
+ const writes = WRITES_TO_A_FILE.test(segment);
290
+ for (const rule of PROTECTED_BASH) {
291
+ if (rule.writeOnly && !writes)
292
+ continue;
293
+ if (rule.re.test(segment))
294
+ return { ok: false, reason: `Bash was denied: ${rule.reason}` };
295
+ }
296
+ }
297
+ return { ok: true };
298
+ }
@@ -0,0 +1,30 @@
1
+ import type { RuntimeSpec } from "./runtime.ts";
2
+ export type Executor = "docker" | "sandbox";
3
+ export declare function dockerAvailable(): boolean;
4
+ export declare function chooseExecutor(): Executor;
5
+ export declare function imageTag(spec: RuntimeSpec | null): string;
6
+ export interface ImageResult {
7
+ tag: string;
8
+ built: boolean;
9
+ error: string | null;
10
+ log: string[];
11
+ }
12
+ export declare function ensureImage(spec: RuntimeSpec | null): ImageResult;
13
+ export interface ContainerRunOptions {
14
+ /** Host directory mounted at /workspace — the agent's own folder. */
15
+ agentDir: string;
16
+ /** Extra read-only mounts: host path → container path. */
17
+ readOnly?: Record<string, string>;
18
+ image: string;
19
+ argv: string[];
20
+ env: Record<string, string>;
21
+ /** Only when the tool.md set one — the platform has no clock of its own. */
22
+ timeoutMs?: number;
23
+ /** Allow outbound network (agents that declared APIs need it). */
24
+ network: boolean;
25
+ maxOutput: number;
26
+ }
27
+ export declare function runInContainer(opts: ContainerRunOptions): Promise<{
28
+ code: number | null;
29
+ out: string;
30
+ }>;
@@ -0,0 +1,172 @@
1
+ // Container execution for scripts.
2
+ //
3
+ // The isolation boundary is the run: one container image per runtime
4
+ // declaration (keyed by the same fingerprint the host path uses), and every
5
+ // script call in a run executes inside a container from that image with the
6
+ // agent's directory mounted. Scripts therefore cannot read other tenants'
7
+ // data, the key file, or anything else on the host.
8
+ //
9
+ // Falls back to host execution when Docker isn't available, so local
10
+ // development keeps working with zero configuration.
11
+ import fs from "node:fs";
12
+ import path from "node:path";
13
+ import { dataRoot } from "./paths.js";
14
+ import { spawnSync, spawn } from "node:child_process";
15
+ import { fingerprint } from "./runtime.js";
16
+ const BUILD_TIMEOUT_MS = 600_000;
17
+ const IMAGE_PREFIX = "foldrun-runtime";
18
+ // Any Docker-compatible CLI works — Docker Engine, colima, Podman, nerdctl,
19
+ // OrbStack. Copying files in and out (rather than bind-mounting) is what
20
+ // makes that portability real: no host path has to be shared with a VM.
21
+ const CLI = process.env.FOLDRUN_CONTAINER_CLI ?? "docker";
22
+ let cachedAvailability = null;
23
+ export function dockerAvailable() {
24
+ if (process.env.FOLDRUN_EXECUTOR === "host" || process.env.FOLDRUN_EXECUTOR === "sandbox")
25
+ return false;
26
+ if (cachedAvailability !== null)
27
+ return cachedAvailability;
28
+ const res = spawnSync(CLI, ["info", "--format", "{{.ServerVersion}}"], {
29
+ encoding: "utf8",
30
+ timeout: 15_000,
31
+ });
32
+ cachedAvailability = res.status === 0;
33
+ return cachedAvailability;
34
+ }
35
+ export function chooseExecutor() {
36
+ return dockerAvailable() ? "docker" : "sandbox";
37
+ }
38
+ export function imageTag(spec) {
39
+ return `${IMAGE_PREFIX}:${spec ? fingerprint(spec) : "base"}`;
40
+ }
41
+ function imageExists(tag) {
42
+ return spawnSync(CLI, ["image", "inspect", tag], { timeout: 20_000 }).status === 0;
43
+ }
44
+ // A minimal image carrying just the declared runtimes and packages.
45
+ function dockerfileFor(spec) {
46
+ const wantsNode = spec ? spec.node !== undefined || spec.npm.length > 0 : false;
47
+ const pyVersion = typeof spec?.python === "string" ? spec.python : "3.12";
48
+ const lines = [];
49
+ if (wantsNode && !spec?.packages.length && spec?.python === undefined) {
50
+ lines.push("FROM node:22-slim");
51
+ }
52
+ else {
53
+ lines.push(`FROM python:${pyVersion}-slim`);
54
+ if (wantsNode) {
55
+ lines.push("RUN apt-get update && apt-get install -y --no-install-recommends nodejs npm && rm -rf /var/lib/apt/lists/*");
56
+ }
57
+ }
58
+ if (spec?.packages.length) {
59
+ lines.push(`RUN pip install --no-cache-dir ${spec.packages.map((p) => `'${p}'`).join(" ")}`);
60
+ }
61
+ if (spec?.npm.length) {
62
+ lines.push("WORKDIR /opt/npm");
63
+ lines.push(`RUN npm install --no-fund --no-audit ${spec.npm.map((p) => `'${p}'`).join(" ")}`);
64
+ lines.push("ENV NODE_PATH=/opt/npm/node_modules");
65
+ }
66
+ // Scripts run as a non-root user with no write access outside the mount.
67
+ lines.push("RUN useradd -m -u 10001 agent");
68
+ lines.push("USER agent");
69
+ lines.push("WORKDIR /workspace");
70
+ return lines.join("\n") + "\n";
71
+ }
72
+ export function ensureImage(spec) {
73
+ const tag = imageTag(spec);
74
+ if (imageExists(tag))
75
+ return { tag, built: false, error: null, log: [`image ${tag}: cached`] };
76
+ // dataRoot() may not exist yet on a brand-new install or a fresh workspace
77
+ // whose first-ever step is a script tool — mkdtemp does not create parents,
78
+ // so without this the first script build dies with ENOENT and the whole run
79
+ // fails on nothing the author did wrong.
80
+ fs.mkdirSync(dataRoot(), { recursive: true });
81
+ const dir = fs.mkdtempSync(path.join(dataRoot(), ".build-"));
82
+ try {
83
+ fs.writeFileSync(path.join(dir, "Dockerfile"), dockerfileFor(spec));
84
+ const res = spawnSync(CLI, ["build", "-q", "-t", tag, dir], {
85
+ encoding: "utf8",
86
+ timeout: BUILD_TIMEOUT_MS,
87
+ });
88
+ if (res.status !== 0) {
89
+ return {
90
+ tag,
91
+ built: false,
92
+ error: `image build failed: ${`${res.stderr ?? ""}`.slice(-400)}`,
93
+ log: [],
94
+ };
95
+ }
96
+ return { tag, built: true, error: null, log: [`image ${tag}: built`] };
97
+ }
98
+ finally {
99
+ fs.rmSync(dir, { recursive: true, force: true });
100
+ }
101
+ }
102
+ // Files are copied in and out rather than bind-mounted. Bind mounts need the
103
+ // host path to be inside Docker's shared-paths configuration, which fails
104
+ // silently (the mount appears empty) on Docker Desktop for directories it
105
+ // isn't allowed to share. Copying is what CI systems do, needs no host
106
+ // configuration, and has the side benefit that a script cannot corrupt the
107
+ // source tree — only the copy it was given.
108
+ export async function runInContainer(opts) {
109
+ const docker = (args, timeout = 60_000) => spawnSync(CLI, args, { encoding: "utf8", timeout });
110
+ const createArgs = [
111
+ "create",
112
+ "-i",
113
+ "--workdir",
114
+ "/workspace",
115
+ // Guard rails: no privilege escalation, bounded CPU/memory/processes.
116
+ "--security-opt",
117
+ "no-new-privileges",
118
+ "--cap-drop",
119
+ "ALL",
120
+ "--pids-limit",
121
+ "256",
122
+ "--memory",
123
+ "1g",
124
+ "--cpus",
125
+ "1",
126
+ ];
127
+ if (!opts.network)
128
+ createArgs.push("--network", "none");
129
+ for (const [k, v] of Object.entries(opts.env))
130
+ createArgs.push("-e", `${k}=${v}`);
131
+ createArgs.push(opts.image, ...opts.argv);
132
+ const created = docker(createArgs);
133
+ if (created.status !== 0) {
134
+ return { code: null, out: `container create failed: ${(created.stderr ?? "").slice(-300)}` };
135
+ }
136
+ const cid = (created.stdout ?? "").trim();
137
+ 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
+ }
143
+ // …and any shared directories land at their declared paths.
144
+ for (const [host, mount] of Object.entries(opts.readOnly ?? {})) {
145
+ if (fs.existsSync(host))
146
+ docker(["cp", `${host}/.`, `${cid}:${mount}`], 120_000);
147
+ }
148
+ const result = await new Promise((resolve) => {
149
+ const child = spawn(CLI, ["start", "-a", cid], { timeout: opts.timeoutMs });
150
+ let out = "";
151
+ const append = (chunk) => {
152
+ if (out.length < opts.maxOutput)
153
+ out += chunk.toString();
154
+ };
155
+ child.stdout.on("data", append);
156
+ child.stderr.on("data", append);
157
+ child.on("error", (err) => resolve({ code: null, out: `container failed: ${err.message}` }));
158
+ child.on("close", (code) => resolve({
159
+ code,
160
+ out: out.length > opts.maxOutput
161
+ ? `${out.slice(0, opts.maxOutput)}\n…[truncated]`
162
+ : out || "(no output)",
163
+ }));
164
+ });
165
+ // Bring deliverables back so outputs/ behaves the same either way.
166
+ docker(["cp", `${cid}:/workspace/outputs/.`, path.join(opts.agentDir, "outputs")], 120_000);
167
+ return result;
168
+ }
169
+ finally {
170
+ docker(["rm", "-f", cid], 30_000);
171
+ }
172
+ }
@@ -0,0 +1,81 @@
1
+ import { createSdkMcpServer } from "@anthropic-ai/claude-agent-sdk";
2
+ import type { RunRecord } from "./store.ts";
3
+ export interface SearchRoot {
4
+ /** How the agent should name the place: "knowledge/", "../../memory/". */
5
+ label: string;
6
+ /** The directory, absolute. */
7
+ dir: string;
8
+ }
9
+ /**
10
+ * The inverted index over a set of roots, scored BM25's way — term
11
+ * frequency saturating, rare terms weighing more, long documents not
12
+ * winning by bulk — with the frontmatter head counted three times, because
13
+ * a memory file's name and description are what its author thought it was
14
+ * about. `refresh()` walks the tree (a
15
+ * stat per file — cheap), re-reads only files whose size or mtime moved,
16
+ * drops the ones that went, and rebuilds the postings from the per-file
17
+ * token maps it already holds. `search()` touches only the query terms'
18
+ * postings.
19
+ */
20
+ export declare class SearchIndex {
21
+ private docs;
22
+ private postings;
23
+ private avg;
24
+ /** How many files the last refresh read from disk — what a test watches. */
25
+ lastRead: number;
26
+ private roots;
27
+ constructor(roots: SearchRoot[]);
28
+ refresh(): this;
29
+ private rebuild;
30
+ get size(): number;
31
+ search(query: string, limit?: number): SearchHit[];
32
+ }
33
+ export interface SearchHit {
34
+ path: string;
35
+ score: number;
36
+ snippet: string;
37
+ }
38
+ export declare function searchRoots(roots: SearchRoot[], query: string, limit?: number): SearchHit[];
39
+ export interface ContextToolResult {
40
+ server: ReturnType<typeof createSdkMcpServer> | null;
41
+ toolNames: string[];
42
+ promptLines: string[];
43
+ }
44
+ export declare function buildSearchTools(roots: SearchRoot[]): ContextToolResult;
45
+ export interface RunDigest {
46
+ /** Which workspace it ran in. Absent for this workspace's own history,
47
+ * where naming it every line would be noise. */
48
+ workspace?: string;
49
+ id: string;
50
+ flow: string;
51
+ status: string;
52
+ startedAt: string;
53
+ finishedAt: string | null;
54
+ summary: string | null;
55
+ costUsd: number;
56
+ tags: string[];
57
+ steps: {
58
+ agent: string;
59
+ status: string;
60
+ result: string | null;
61
+ }[];
62
+ }
63
+ /** The digest of a workspace's recent runs — host-side, from the records. */
64
+ export declare function digestRuns(runs: RunRecord[], limit?: number, exceptRunId?: string): RunDigest[];
65
+ export declare function buildHistoryTools(digest: RunDigest[]): ContextToolResult;
66
+ /**
67
+ * The same two verbs across the account's OTHER workspaces.
68
+ *
69
+ * A digest agent has to read what six desks concluded this week, and it
70
+ * cannot: a run pod's network policy denies the platform's own API, and the
71
+ * account library is mounted read-only, so there is no path from inside a
72
+ * sandbox to another workspace's files. This is that path, built the way
73
+ * `history` already is — the host gathers the records, the values cross the
74
+ * boundary as JSON, and the container serves them. Nothing new is reachable
75
+ * from a run; what crosses is what the host decided to send.
76
+ *
77
+ * Read-only and summary-shaped on purpose: run records, not files. An agent
78
+ * that needs another desk's *file* is asking for something this platform
79
+ * deliberately does not have.
80
+ */
81
+ export declare function buildDeskTools(digest: RunDigest[]): ContextToolResult;