@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.
- package/LICENSE +202 -0
- package/README.md +239 -0
- package/dist/index.d.ts +32 -0
- package/dist/index.js +43 -0
- package/dist/src/agent-tools.d.ts +31 -0
- package/dist/src/agent-tools.js +99 -0
- package/dist/src/agents-md.d.ts +9 -0
- package/dist/src/agents-md.js +83 -0
- package/dist/src/api-tools.d.ts +38 -0
- package/dist/src/api-tools.js +313 -0
- package/dist/src/approvals.d.ts +39 -0
- package/dist/src/approvals.js +103 -0
- package/dist/src/arrange.d.ts +8 -0
- package/dist/src/arrange.js +40 -0
- package/dist/src/catalog.d.ts +108 -0
- package/dist/src/catalog.js +255 -0
- package/dist/src/completions.d.ts +61 -0
- package/dist/src/completions.js +578 -0
- package/dist/src/confine.d.ts +31 -0
- package/dist/src/confine.js +298 -0
- package/dist/src/container.d.ts +30 -0
- package/dist/src/container.js +172 -0
- package/dist/src/context-tools.d.ts +81 -0
- package/dist/src/context-tools.js +347 -0
- package/dist/src/deploy.d.ts +100 -0
- package/dist/src/deploy.js +377 -0
- package/dist/src/egress.d.ts +75 -0
- package/dist/src/egress.js +133 -0
- package/dist/src/evals.d.ts +150 -0
- package/dist/src/evals.js +651 -0
- package/dist/src/flow-lint.d.ts +16 -0
- package/dist/src/flow-lint.js +177 -0
- package/dist/src/git-hook.d.ts +1 -0
- package/dist/src/git-hook.js +70 -0
- package/dist/src/git.d.ts +37 -0
- package/dist/src/git.js +101 -0
- package/dist/src/gitrepo.d.ts +95 -0
- package/dist/src/gitrepo.js +355 -0
- package/dist/src/history.d.ts +82 -0
- package/dist/src/history.js +259 -0
- package/dist/src/kinds.d.ts +95 -0
- package/dist/src/kinds.js +421 -0
- package/dist/src/library.d.ts +94 -0
- package/dist/src/library.js +341 -0
- package/dist/src/linkable.d.ts +20 -0
- package/dist/src/linkable.js +113 -0
- package/dist/src/model-probe.d.ts +21 -0
- package/dist/src/model-probe.js +81 -0
- package/dist/src/notify.d.ts +61 -0
- package/dist/src/notify.js +252 -0
- package/dist/src/oauth-presets.d.ts +13 -0
- package/dist/src/oauth-presets.js +39 -0
- package/dist/src/okf.d.ts +220 -0
- package/dist/src/okf.js +656 -0
- package/dist/src/openapi.d.ts +66 -0
- package/dist/src/openapi.js +342 -0
- package/dist/src/paths.d.ts +33 -0
- package/dist/src/paths.js +78 -0
- package/dist/src/platform.d.ts +32 -0
- package/dist/src/platform.js +48 -0
- package/dist/src/providers.d.ts +47 -0
- package/dist/src/providers.js +114 -0
- package/dist/src/refs.d.ts +20 -0
- package/dist/src/refs.js +78 -0
- package/dist/src/run-container.d.ts +227 -0
- package/dist/src/run-container.js +760 -0
- package/dist/src/runner.d.ts +241 -0
- package/dist/src/runner.js +3215 -0
- package/dist/src/runtime.d.ts +41 -0
- package/dist/src/runtime.js +357 -0
- package/dist/src/script-tools.d.ts +62 -0
- package/dist/src/script-tools.js +296 -0
- package/dist/src/secret-files.d.ts +12 -0
- package/dist/src/secret-files.js +92 -0
- package/dist/src/secrets.d.ts +147 -0
- package/dist/src/secrets.js +626 -0
- package/dist/src/starter.d.ts +21 -0
- package/dist/src/starter.js +399 -0
- package/dist/src/step-exec.d.ts +136 -0
- package/dist/src/step-exec.js +391 -0
- package/dist/src/storage.d.ts +123 -0
- package/dist/src/storage.js +640 -0
- package/dist/src/store.d.ts +1052 -0
- package/dist/src/store.js +2378 -0
- package/dist/src/tar.d.ts +30 -0
- package/dist/src/tar.js +111 -0
- package/dist/src/tool-names.d.ts +39 -0
- package/dist/src/tool-names.js +79 -0
- package/dist/src/tool-programs.d.ts +18 -0
- package/dist/src/tool-programs.js +52 -0
- package/dist/src/tool-test.d.ts +19 -0
- package/dist/src/tool-test.js +335 -0
- package/dist/src/translator.d.ts +159 -0
- package/dist/src/translator.js +986 -0
- package/dist/src/triggers.d.ts +60 -0
- package/dist/src/triggers.js +270 -0
- package/dist/src/webhook.d.ts +47 -0
- package/dist/src/webhook.js +148 -0
- package/index.ts +44 -0
- package/package.json +64 -0
- package/src/agent-tools.ts +138 -0
- package/src/agents-md.ts +81 -0
- package/src/api-tools.ts +389 -0
- package/src/approvals.ts +129 -0
- package/src/arrange.ts +42 -0
- package/src/catalog.ts +321 -0
- package/src/completions.ts +652 -0
- package/src/confine.ts +350 -0
- package/src/container.ts +220 -0
- package/src/context-tools.ts +435 -0
- package/src/deploy.ts +458 -0
- package/src/egress.ts +176 -0
- package/src/evals.ts +812 -0
- package/src/flow-lint.ts +215 -0
- package/src/git-hook.ts +73 -0
- package/src/git.ts +117 -0
- package/src/gitrepo.ts +391 -0
- package/src/history.ts +329 -0
- package/src/kinds.ts +507 -0
- package/src/library.ts +419 -0
- package/src/linkable.ts +123 -0
- package/src/model-probe.ts +112 -0
- package/src/notify.ts +281 -0
- package/src/oauth-presets.ts +54 -0
- package/src/okf.ts +768 -0
- package/src/openapi.ts +383 -0
- package/src/paths.ts +79 -0
- package/src/platform.ts +87 -0
- package/src/providers.ts +147 -0
- package/src/refs.ts +83 -0
- package/src/run-container.ts +883 -0
- package/src/runner.ts +3446 -0
- package/src/runtime.ts +404 -0
- package/src/script-tools.ts +383 -0
- package/src/secret-files.ts +101 -0
- package/src/secrets.ts +799 -0
- package/src/starter.ts +415 -0
- package/src/step-exec.ts +498 -0
- package/src/storage.ts +771 -0
- package/src/store.ts +2985 -0
- package/src/tar.ts +124 -0
- package/src/tool-names.ts +91 -0
- package/src/tool-programs.ts +65 -0
- package/src/tool-test.ts +401 -0
- package/src/translator.ts +1029 -0
- package/src/triggers.ts +287 -0
- package/src/webhook.ts +183 -0
- package/templates/blog-desk/AGENTS.md +13 -0
- package/templates/blog-desk/agents/researcher/agent.md +11 -0
- package/templates/blog-desk/agents/writer/agent.md +8 -0
- package/templates/blog-desk/evals/writer-quality.md +10 -0
- package/templates/blog-desk/flows/publish.md +10 -0
- package/templates/blog-desk/knowledge/house-style.md +9 -0
- package/templates/blog-desk/knowledge/index.md +9 -0
- package/templates/hello/AGENTS.md +18 -0
- package/templates/hello/agents/curator/agent.md +32 -0
- package/templates/hello/agents/notetaker/agent.md +24 -0
- package/templates/hello/evals/house-style.md +16 -0
- package/templates/hello/flows/memory-review.md +7 -0
- package/templates/hello/flows/note.md +7 -0
- package/templates/hello/knowledge/house-style.md +15 -0
- package/templates/hello/knowledge/index.md +9 -0
- package/templates/hello/skills/plain-english/SKILL.md +11 -0
- package/templates/hello/state/publishing.md +7 -0
- package/templates/hello/tools/wordcount/run.py +13 -0
- 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
|
+
}
|