@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,383 @@
1
+ // Scripts declared as tools. An agent that writes
2
+ //
3
+ // scripts:
4
+ // - name: ads_summary
5
+ // run: scripts/summary.py
6
+ // description: Pull campaign performance for the account.
7
+ // args:
8
+ // customer_id: The Google Ads customer id
9
+ //
10
+ // gets a real tool called `ads_summary(customer_id)` — no bash needed. The
11
+ // platform spawns the script with the declared args as CLI flags, injects the
12
+ // agent's secrets as environment variables, and returns stdout.
13
+ //
14
+ // This is safer than granting `bash`: the agent can only run the scripts you
15
+ // declared, with the arguments you declared, and never composes a shell line.
16
+
17
+ import path from "node:path";
18
+ import fs from "node:fs";
19
+ import { spawn } from "node:child_process";
20
+ import { z } from "zod";
21
+ import { createSdkMcpServer, tool } from "@anthropic-ai/claude-agent-sdk";
22
+ import { runInContainer } from "./container.ts";
23
+
24
+ // A script's only clock is the `timeout:` its own tool.md declares. There
25
+ // was a two-minute default and a ten-minute cap here; both were the
26
+ // platform's opinion about how long a person's script may take, and the
27
+ // deploy watcher had to be taught to call its tool four times to outlast the
28
+ // cap. The platform orchestrates; it does not decide that.
29
+ const timeoutFor = (spec: ScriptSpec) => (spec.timeout ? spec.timeout * 1000 : undefined);
30
+ const MAX_OUTPUT = 20_000;
31
+
32
+ export interface ScriptSpec {
33
+ name: string;
34
+ /** Path relative to the agent dir — or empty when `code` carries the
35
+ * script inline. Exactly one of the two is the program. */
36
+ run: string;
37
+ description: string;
38
+ args: Record<string, string>; // arg name → description
39
+ interpreter?: string; // optional override, e.g. "python3", "bash"
40
+ /** Seconds this script may run — for the crawl that legitimately takes
41
+ * five minutes. Defaults to 120, capped at 600: a limit an author can
42
+ * raise is a budget; one they can remove is a hang. */
43
+ timeout?: number;
44
+ /** The script itself, when the tool is a single markdown file with its
45
+ * code in a fenced block. Materialised to a file at call time — the
46
+ * execution path is the same either way, only where the bytes start
47
+ * differs. */
48
+ code?: string;
49
+ /** Extension for the materialised file, from the fence's language tag —
50
+ * it picks the interpreter the same way a run: path's extension does. */
51
+ codeExt?: string;
52
+ /** `secrets: proxied` — this program sends its secrets through the egress
53
+ * proxy (it reads FOLDRUN_EGRESS and sends ${NAME} placeholders), so the
54
+ * step need not materialise real values into the sandbox for it. The
55
+ * default, `materialised`, is a program that reads values from its
56
+ * environment and needs them real — a browser seeding cookies. */
57
+ secrets?: "proxied" | "materialised";
58
+ }
59
+
60
+ export function parseScripts(raw: unknown): ScriptSpec[] {
61
+ if (!Array.isArray(raw)) return [];
62
+ const out: ScriptSpec[] = [];
63
+ for (const entry of raw) {
64
+ if (!entry || typeof entry !== "object") continue;
65
+ const e = entry as Record<string, unknown>;
66
+ const name = typeof e.name === "string" ? e.name : null;
67
+ const run = typeof e.run === "string" ? e.run : null;
68
+ const code = typeof e.code === "string" ? e.code : null;
69
+ if (!name || (!run && !code)) continue;
70
+ const args: Record<string, string> = {};
71
+ if (e.args && typeof e.args === "object" && !Array.isArray(e.args)) {
72
+ for (const [k, v] of Object.entries(e.args as Record<string, unknown>)) {
73
+ if (/^[a-z][a-z0-9_]*$/i.test(k)) args[k] = String(v);
74
+ }
75
+ }
76
+ const timeout = Number(e.timeout);
77
+ out.push({
78
+ name: name.replace(/[^a-zA-Z0-9_]/g, "_"),
79
+ run: run ?? "",
80
+ ...(code ? { code, codeExt: typeof e.codeExt === "string" ? e.codeExt : ".mjs" } : {}),
81
+ description: typeof e.description === "string" ? e.description : "",
82
+ args,
83
+ interpreter: typeof e.interpreter === "string" ? e.interpreter : undefined,
84
+ secrets: e.secrets === "proxied" ? "proxied" : undefined,
85
+ timeout: Number.isFinite(timeout) && timeout > 0 ? timeout : undefined,
86
+ });
87
+ }
88
+ return out;
89
+ }
90
+
91
+ // Pick how to execute a file when no interpreter is declared.
92
+ //
93
+ // Exported because the tool tester has to make the same choice, and used to
94
+ // make it from its own shorter table — which knew .js but not .mjs, so the
95
+ // Test button spawned every .mjs tool as a program and reported EACCES for a
96
+ // tool that runs perfectly. Two tables, one of them wrong.
97
+ export function commandFor(
98
+ spec: ScriptSpec,
99
+ abs: string,
100
+ overrides: Record<string, string> = {},
101
+ ): { cmd: string; args: string[] } {
102
+ if (spec.interpreter) return { cmd: spec.interpreter, args: [abs] };
103
+ const ext = path.extname(abs).toLowerCase();
104
+ // A prepared runtime (venv, npm prefix) wins over the host interpreter.
105
+ if (overrides[ext]) return { cmd: overrides[ext], args: [abs] };
106
+ const byExt: Record<string, string> = {
107
+ ".py": "python3",
108
+ ".sh": "bash",
109
+ ".js": "node",
110
+ ".mjs": "node",
111
+ ".ts": "node",
112
+ ".rb": "ruby",
113
+ };
114
+ const interp = byExt[ext];
115
+ return interp ? { cmd: interp, args: [abs] } : { cmd: abs, args: [] };
116
+ }
117
+
118
+ // Inside the image the interpreter is the distro's, not a host venv path.
119
+ function containerInterpreter(abs: string): string {
120
+ const byExt: Record<string, string> = {
121
+ ".py": "python3",
122
+ ".sh": "bash",
123
+ ".js": "node",
124
+ ".mjs": "node",
125
+ ".ts": "node",
126
+ ".rb": "ruby",
127
+ };
128
+ return byExt[path.extname(abs).toLowerCase()] ?? abs;
129
+ }
130
+
131
+ function runScript(
132
+ cwd: string,
133
+ spec: ScriptSpec,
134
+ values: Record<string, string>,
135
+ env: Record<string, string>,
136
+ libraryScripts: string,
137
+ interpreters: Record<string, string>,
138
+ exec: ExecutionContext | null,
139
+ ): Promise<{ code: number | null; out: string }> {
140
+ // An inline-code tool has no file yet: write it inside the agent's own
141
+ // directory — already an allowed root — and run it like any other script.
142
+ // Materialised per call rather than per build, so an edit to the tool's
143
+ // markdown is live on the next call with nothing to clean up but one file.
144
+ if (!spec.run && spec.code) {
145
+ const dir = path.join(cwd, ".tool-code");
146
+ fs.mkdirSync(dir, { recursive: true });
147
+ const file = path.join(dir, `${spec.name}${spec.codeExt ?? ".mjs"}`);
148
+ fs.writeFileSync(file, spec.code, { mode: 0o755 });
149
+ spec = { ...spec, run: `.tool-code/${spec.name}${spec.codeExt ?? ".mjs"}` };
150
+ }
151
+
152
+ return new Promise((resolve) => {
153
+ // Confine to the agent's own directory plus the workspace it belongs to.
154
+ // The workspace root is allowed because `shared/` resolves to
155
+ // <workspace>/scripts — a virtual prefix expanded at resolve time, not a
156
+ // symlink on disk. (Symlinking it was tried and reverted: the link
157
+ // recursed into the workspace's own file listing.)
158
+ const abs = resolveRunPath(cwd, spec.run, libraryScripts);
159
+ const workspaceRoot = path.resolve(cwd, "..", "..");
160
+ // The library's tools/ as well as its scripts/: a folder tool installed at
161
+ // the account keeps its code beside its definition, which is one directory
162
+ // over from where shared scripts live. Without it the containment check
163
+ // refuses an account tool the resolver just found.
164
+ const libraryTools = libraryScripts ? path.resolve(libraryScripts, "..", "tools") : "";
165
+ const allowedRoots = [path.resolve(cwd), workspaceRoot, libraryScripts, libraryTools]
166
+ .filter(Boolean)
167
+ .map((p) => {
168
+ try {
169
+ return fs.realpathSync(p);
170
+ } catch {
171
+ return p;
172
+ }
173
+ });
174
+ let real: string;
175
+ try {
176
+ real = fs.realpathSync(abs);
177
+ } catch {
178
+ return resolve({ code: null, out: `script not found: ${spec.run}` });
179
+ }
180
+ // Containment, not a string prefix. `startsWith` called
181
+ // `<workspace>-attacker/steal.py` a path inside `<workspace>`, because it
182
+ // is — as text. The question is whether one path is under another, and
183
+ // only path.relative answers that.
184
+ const within = (root: string) => {
185
+ const rel = path.relative(root, real);
186
+ return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
187
+ };
188
+ if (!allowedRoots.some(within)) {
189
+ return resolve({ code: null, out: `script path escapes the workspace: ${spec.run}` });
190
+ }
191
+
192
+ const { cmd, args } = commandFor(spec, abs, interpreters);
193
+ // Declared args become long flags: --customer_id 123
194
+ for (const key of Object.keys(spec.args)) {
195
+ if (values[key] !== undefined && values[key] !== "") {
196
+ args.push(`--${key}`, values[key]);
197
+ }
198
+ }
199
+
200
+ // Container path: same command, executed inside an isolated container
201
+ // with only the agent's directory (plus declared shared dirs) mounted.
202
+ if (exec && exec.executor === "docker") {
203
+ const inContainer = (p: string) => {
204
+ if (p.startsWith(path.resolve(cwd))) return p.replace(path.resolve(cwd), "/workspace");
205
+ for (const [host, mount] of Object.entries(exec.mounts)) {
206
+ if (p.startsWith(host)) return p.replace(host, mount);
207
+ }
208
+ return p;
209
+ };
210
+ const argv = [
211
+ // The interpreter inside the image, not the host's venv path.
212
+ spec.interpreter ?? containerInterpreter(abs),
213
+ ...args.map(inContainer),
214
+ ].filter(Boolean) as string[];
215
+ // Drop the host interpreter that commandFor prepended, if any.
216
+ if (argv[1] === argv[0]) argv.splice(1, 1);
217
+ runInContainer({
218
+ agentDir: path.resolve(cwd),
219
+ readOnly: exec.mounts,
220
+ image: exec.image,
221
+ argv,
222
+ env,
223
+ timeoutMs: timeoutFor(spec),
224
+ network: exec.network,
225
+ maxOutput: MAX_OUTPUT,
226
+ }).then(resolve, (err) =>
227
+ resolve({ code: null, out: `container error: ${err instanceof Error ? err.message : String(err)}` }),
228
+ );
229
+ return;
230
+ }
231
+
232
+ const child = spawn(cmd, args, {
233
+ cwd,
234
+ env: { ...process.env, ...env },
235
+ timeout: timeoutFor(spec),
236
+ });
237
+ let out = "";
238
+ const append = (chunk: Buffer) => {
239
+ if (out.length < MAX_OUTPUT) out += chunk.toString();
240
+ };
241
+ child.stdout.on("data", append);
242
+ child.stderr.on("data", append);
243
+ child.on("error", (err) => resolve({ code: null, out: `failed to start: ${err.message}` }));
244
+ child.on("close", (code) =>
245
+ resolve({
246
+ code,
247
+ out:
248
+ out.length > MAX_OUTPUT
249
+ ? `${out.slice(0, MAX_OUTPUT)}\n…[truncated]`
250
+ : out || "(no output)",
251
+ }),
252
+ );
253
+ });
254
+ }
255
+
256
+ export interface ExecutionContext {
257
+ /** "docker" nests each script in its own container; "sandbox" spawns it
258
+ * directly inside the isolation the step already has. */
259
+ executor: "docker" | "sandbox";
260
+ image: string;
261
+ /** Extra read-only mounts for shared/library scripts. */
262
+ mounts: Record<string, string>;
263
+ /** Whether scripts may reach the network. */
264
+ network: boolean;
265
+ }
266
+
267
+ export interface ScriptToolResult {
268
+ server: ReturnType<typeof createSdkMcpServer> | null;
269
+ toolNames: string[];
270
+ promptLines: string[];
271
+ drainLog: () => string[];
272
+ }
273
+
274
+ // `run:` accepts:
275
+ // scripts/x.py this agent's own script
276
+ // workspace/tools/<t>/x.py a folder tool's own code, in this workspace
277
+ // account/tools/<t>/x.py a folder tool's own code, in the library
278
+ // shared/x.py the workspace's scripts/
279
+ // library/x.py the workspace library's scripts/
280
+ //
281
+ // The tools/ forms are written by readToolDir rather than by hand: a folder
282
+ // tool's definition says `run: run.mjs` and never names its own scope, which
283
+ // is what lets the same folder be copied into a workspace or installed at the
284
+ // account without an edit.
285
+ export function resolveRunPath(agentDir: string, run: string, libraryScripts: string): string {
286
+ const workspaceRoot = path.resolve(agentDir, "..", "..");
287
+ // The library's tools/ sits beside its scripts/ — one mount, two shelves.
288
+ const libraryRoot = path.resolve(libraryScripts, "..");
289
+
290
+ if (run.startsWith("workspace/tools/")) {
291
+ return path.resolve(workspaceRoot, "tools", run.slice("workspace/tools/".length));
292
+ }
293
+ if (run.startsWith("account/tools/")) {
294
+ return path.resolve(libraryRoot, "tools", run.slice("account/tools/".length));
295
+ }
296
+ // Canonical prefixes, then the legacy spellings they replaced.
297
+ for (const p of ["workspace/scripts/", "shared/"]) {
298
+ if (run.startsWith(p)) {
299
+ return path.resolve(workspaceRoot, "scripts", run.slice(p.length));
300
+ }
301
+ }
302
+ for (const p of ["account/scripts/", "library/"]) {
303
+ if (run.startsWith(p)) return path.resolve(libraryScripts, run.slice(p.length));
304
+ }
305
+ return path.resolve(agentDir, run);
306
+ }
307
+
308
+ /**
309
+ * The schema a script's arguments are offered to the model under. Every
310
+ * argument becomes a command-line flag, so it is text on the way out — but a
311
+ * model reading "depth: how many results (default 20)" sends 10, the number,
312
+ * and the SDK refuses the call before the handler's String() ever runs; on
313
+ * the fast tier the model then announced a retry and ended its turn. A
314
+ * number or a boolean is accepted and written out as the text it would
315
+ * have been.
316
+ */
317
+ export function scriptArgShape(args: Record<string, string>): Record<string, z.ZodTypeAny> {
318
+ const shape: Record<string, z.ZodTypeAny> = {};
319
+ for (const [arg, desc] of Object.entries(args)) {
320
+ shape[arg] = z.union([z.string(), z.number(), z.boolean()]).optional().describe(desc);
321
+ }
322
+ return shape;
323
+ }
324
+
325
+ export function buildScriptTools(
326
+ agentDir: string,
327
+ scripts: ScriptSpec[],
328
+ env: Record<string, string>,
329
+ libraryScripts = "",
330
+ interpreters: Record<string, string> = {},
331
+ exec: ExecutionContext | null = null,
332
+ ): ScriptToolResult {
333
+ if (scripts.length === 0) {
334
+ return { server: null, toolNames: [], promptLines: [], drainLog: () => [] };
335
+ }
336
+ const log: string[] = [];
337
+
338
+ const tools = scripts.map((spec) => {
339
+ const shape = scriptArgShape(spec.args);
340
+ return tool(
341
+ spec.name,
342
+ `${spec.description || `Run ${spec.run}.`} Runs the workspace script ${spec.run}; returns its output. ` +
343
+ `Credentials it needs are already in its environment.`,
344
+ shape,
345
+ async (args) => {
346
+ const started = Date.now();
347
+ const values = Object.fromEntries(
348
+ Object.entries(args ?? {}).map(([k, v]) => [k, v === undefined ? "" : String(v)]),
349
+ );
350
+ const { code, out } = await runScript(agentDir, spec, values, env, libraryScripts, interpreters, exec);
351
+ // The arguments, on the record. Without them a run says a script was
352
+ // called and not what it was asked to do — and for a script that
353
+ // writes to someone else's system, "it ran" is not an account of what
354
+ // happened. Model-supplied strings only: secrets reach a script
355
+ // through its environment, never through these.
356
+ const shown = Object.entries(values)
357
+ .filter(([, v]) => v !== "")
358
+ .map(([k, v]) => `${k}=${v.length > 80 ? `${v.slice(0, 77)}…` : v}`)
359
+ .join(", ");
360
+ log.push(
361
+ `${spec.name}(${shown}) → exit ${code ?? "error"} ` +
362
+ `(${Date.now() - started}ms, ${exec?.executor ?? "sandbox"})`,
363
+ );
364
+ return {
365
+ content: [{ type: "text" as const, text: `exit ${code ?? "error"}\n\n${out}` }],
366
+ isError: code !== 0,
367
+ };
368
+ },
369
+ );
370
+ });
371
+
372
+ return {
373
+ server: createSdkMcpServer({ name: "foldrun_scripts", version: "1.0.0", tools }),
374
+ toolNames: scripts.map((s) => `mcp__foldrun_scripts__${s.name}`),
375
+ promptLines: scripts.map(
376
+ (s) =>
377
+ `- **${s.name}** — ${s.description || `runs ${s.run}`}${
378
+ Object.keys(s.args).length ? ` Arguments: ${Object.keys(s.args).join(", ")}.` : ""
379
+ }`,
380
+ ),
381
+ drainLog: () => log.splice(0, log.length),
382
+ };
383
+ }
@@ -0,0 +1,101 @@
1
+ // Materialising config-shaped secrets (@file, @ssh, @api) into a run.
2
+ //
3
+ // An SSH private key and a client certificate are credentials tools read
4
+ // from a path — ssh flatly refuses a key that isn't 0600, curl wants
5
+ // --cert a file. And an SSH or API *connection* is more than a value: host,
6
+ // port, user, headers. The user describes the connection; this layer turns
7
+ // it into something directly runnable — a 0600 file, or a 0700 wrapper
8
+ // script — and the env var carries the *path*. The agent runs
9
+ // `"$PROD_VM" 'uptime'` or `"$STRIPE" /v1/charges` and never learns whether
10
+ // it was key or password, one header or five.
11
+ //
12
+ // Everything lands in a per-run scratch directory inside the agent's own
13
+ // tree (so it crosses into a container with the workspace) but dot-prefixed
14
+ // and never archived or copied back — it holds live credentials.
15
+
16
+ import fs from "node:fs";
17
+ import path from "node:path";
18
+ import {
19
+ isFileValue, fileContent,
20
+ isSshValue, sshConfigOf,
21
+ isApiValue, apiConfigOf,
22
+ } from "./secrets.ts";
23
+
24
+ // POSIX single-quoting: the one escape sh actually honours inside ' is none,
25
+ // so close, backslash-quote, reopen. Wrapper scripts embed hosts, users and
26
+ // header values through this and nothing else.
27
+ const q = (s: string) => `'${s.replaceAll("'", `'\\''`)}'`;
28
+
29
+ function writeWrapper(dir: string, name: string, body: string): string {
30
+ const file = path.join(dir, name.toLowerCase());
31
+ fs.writeFileSync(file, `#!/bin/sh\n${body}\n`, { mode: 0o700 });
32
+ return file;
33
+ }
34
+
35
+ /**
36
+ * Turn any @file/@ssh/@api values in `env` into files under
37
+ * `<agentDir>/.secret-files`, rewriting each to its path. Returns the
38
+ * directory written (for cleanup) and the transformed env. Plain values
39
+ * pass through untouched.
40
+ */
41
+ export function materializeFileSecrets(
42
+ agentDir: string,
43
+ env: Record<string, string>,
44
+ ): { env: Record<string, string>; dir: string | null } {
45
+ const entries = Object.entries(env).filter(
46
+ ([, v]) => typeof v === "string" && (isFileValue(v) || isSshValue(v) || isApiValue(v)),
47
+ );
48
+ if (entries.length === 0) return { env, dir: null };
49
+
50
+ const dir = path.join(agentDir, ".secret-files");
51
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
52
+ const out = { ...env };
53
+ for (const [name, value] of entries) {
54
+ // Lowercased name as the filename keeps it predictable without leaking
55
+ // the value; the env var still points at it by absolute path.
56
+ if (isFileValue(value)) {
57
+ const file = path.join(dir, name.toLowerCase());
58
+ fs.writeFileSync(file, fileContent(value), { mode: 0o600 });
59
+ out[name] = file;
60
+ } else if (isSshValue(value)) {
61
+ const ssh = sshConfigOf(value);
62
+ const dest = `${ssh.user}@${ssh.host}`;
63
+ const opts = `-p ${ssh.port ?? 22} -o StrictHostKeyChecking=accept-new`;
64
+ let cmd: string;
65
+ if (ssh.private_key) {
66
+ const key = path.join(dir, `${name.toLowerCase()}.key`);
67
+ // ssh wants a trailing newline on OpenSSH keys and 0600 or it balks.
68
+ fs.writeFileSync(key, ssh.private_key.replace(/\n?$/, "\n"), { mode: 0o600 });
69
+ cmd = `exec ssh -i ${q(key)} ${opts} ${q(dest)} "$@"`;
70
+ out[`${name}_KEY`] = key;
71
+ } else {
72
+ const pw = path.join(dir, `${name.toLowerCase()}.pw`);
73
+ fs.writeFileSync(pw, ssh.password ?? "", { mode: 0o600 });
74
+ cmd = `exec sshpass -f ${q(pw)} ssh ${opts} ${q(dest)} "$@"`;
75
+ }
76
+ out[name] = writeWrapper(dir, name, cmd);
77
+ // The parts, for scp/rsync/anything the wrapper doesn't cover.
78
+ out[`${name}_HOST`] = ssh.host;
79
+ out[`${name}_PORT`] = String(ssh.port ?? 22);
80
+ out[`${name}_USER`] = ssh.user;
81
+ } else {
82
+ const api = apiConfigOf(value);
83
+ const headers = Object.entries(api.headers)
84
+ .map(([h, v]) => `-H ${q(`${h}: ${v}`)}`)
85
+ .join(" ");
86
+ // With a base URL, a path-shaped first argument gets prefixed — so
87
+ // `"$STRIPE" /v1/charges` and `"$STRIPE" https://elsewhere` both work.
88
+ const prefix = api.base_url
89
+ ? `case "$1" in /*) _u=${q(api.base_url)}"$1"; shift; set -- "$_u" "$@";; esac\n`
90
+ : "";
91
+ out[name] = writeWrapper(dir, name, `${prefix}exec curl -sS ${headers} "$@"`);
92
+ if (api.base_url) out[`${name}_URL`] = api.base_url;
93
+ }
94
+ }
95
+ return { env: out, dir };
96
+ }
97
+
98
+ /** Best-effort removal of a materialised secret-files directory. */
99
+ export function cleanupFileSecrets(dir: string | null) {
100
+ if (dir) fs.rmSync(dir, { recursive: true, force: true });
101
+ }