@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
package/src/tar.ts ADDED
@@ -0,0 +1,124 @@
1
+ // Reading a .tar.gz, so a deploy can come from a GitHub tarball.
2
+ //
3
+ // `GET /repos/{owner}/{repo}/tarball/{sha}` is one HTTP call and needs no git
4
+ // binary, no clone, and no working copy on the server — which is the whole
5
+ // reason a markdown workspace can deploy from a webhook in a few hundred
6
+ // milliseconds. What it returns is a tar.gz, and Node has gzip but not tar.
7
+ //
8
+ // Deliberately dependency-free, for the same reason the cron parser is: tar is
9
+ // 512-byte blocks with an octal size field, and a reader is shorter than the
10
+ // argument about which library to add.
11
+
12
+ import zlib from "node:zlib";
13
+
14
+ const BLOCK = 512;
15
+
16
+ export interface TarEntry {
17
+ path: string;
18
+ content: Buffer;
19
+ }
20
+
21
+ const str = (b: Buffer, off: number, len: number) => {
22
+ const raw = b.subarray(off, off + len);
23
+ const end = raw.indexOf(0);
24
+ return raw.subarray(0, end === -1 ? raw.length : end).toString("utf8");
25
+ };
26
+
27
+ // Sizes are octal, space- or NUL-padded. GNU also writes base-256 for large
28
+ // files, flagged by the high bit — irrelevant for markdown, but a silent
29
+ // misparse would be worse than a clear refusal.
30
+ function size(b: Buffer, off: number): number {
31
+ if (b[off] & 0x80) throw new Error("tar: base-256 size field is not supported");
32
+ const raw = str(b, off, 12).trim();
33
+ return raw ? parseInt(raw, 8) : 0;
34
+ }
35
+
36
+ /**
37
+ * Every regular file in a gzipped tarball.
38
+ *
39
+ * Directories, symlinks, and pax/global headers are skipped: a workspace is
40
+ * files, and following a link out of the archive is exactly the thing a deploy
41
+ * from an untrusted repository must not do.
42
+ */
43
+ export function readTarGz(gz: Buffer): TarEntry[] {
44
+ const buf = zlib.gunzipSync(gz);
45
+ const out: TarEntry[] = [];
46
+ let longName: string | null = null;
47
+
48
+ for (let off = 0; off + BLOCK <= buf.length; ) {
49
+ const header = buf.subarray(off, off + BLOCK);
50
+ // Two consecutive zero blocks end the archive; one is enough to stop.
51
+ if (header.every((b) => b === 0)) break;
52
+
53
+ const name = str(header, 0, 100);
54
+ const bytes = size(header, 124);
55
+ const type = String.fromCharCode(header[156]) || "0";
56
+ const prefix = str(header, 345, 155);
57
+ off += BLOCK;
58
+
59
+ const body = buf.subarray(off, off + bytes);
60
+ off += Math.ceil(bytes / BLOCK) * BLOCK;
61
+
62
+ // GNU long name: the next header's name lives in this entry's body.
63
+ if (type === "L") {
64
+ longName = body.toString("utf8").replace(/\0+$/, "");
65
+ continue;
66
+ }
67
+ if (type !== "0" && type !== "\0") {
68
+ longName = null;
69
+ continue;
70
+ }
71
+
72
+ const full = longName ?? (prefix ? `${prefix}/${name}` : name);
73
+ longName = null;
74
+ if (full) out.push({ path: full, content: Buffer.from(body) });
75
+ }
76
+
77
+ return out;
78
+ }
79
+
80
+ /**
81
+ * Drop the single wrapper directory an archive is rooted at.
82
+ *
83
+ * GitHub tarballs are rooted at `owner-repo-<sha>/`, which is not part of
84
+ * anyone's workspace. Stripped only when *every* entry shares one first
85
+ * segment, so an archive that is already flat is left alone.
86
+ */
87
+ export function stripRoot(entries: TarEntry[]): TarEntry[] {
88
+ if (entries.length === 0) return entries;
89
+ const first = entries[0].path.split("/")[0];
90
+ if (!entries.every((e) => e.path.split("/")[0] === first && e.path.includes("/"))) {
91
+ return entries;
92
+ }
93
+ return entries.map((e) => ({ ...e, path: e.path.slice(first.length + 1) }));
94
+ }
95
+
96
+ /**
97
+ * Read a tarball as the file list a deploy ships.
98
+ *
99
+ * `subdir` picks a workspace living inside a larger repository — the common
100
+ * case, since a repo usually holds more than one thing.
101
+ */
102
+ export function filesFromTarball(gz: Buffer, subdir = ""): { path: string; content: string }[] {
103
+ const prefix = subdir.replace(/^\/+|\/+$/g, "");
104
+ const out: { path: string; content: string }[] = [];
105
+
106
+ for (const entry of stripRoot(readTarGz(gz))) {
107
+ let rel = entry.path;
108
+ if (prefix) {
109
+ if (!rel.startsWith(`${prefix}/`)) continue;
110
+ rel = rel.slice(prefix.length + 1);
111
+ }
112
+ // A tarball is untrusted input: an entry can name anything at all, and
113
+ // `../` in an archive is the oldest way to write outside the destination.
114
+ if (rel.split("/").includes("..") || rel.startsWith("/")) continue;
115
+ // AppleDouble sidecars. macOS `tar` writes a `._name` beside every file to
116
+ // carry metadata no other system wants; they are not part of anyone's
117
+ // workspace, and letting one through means a deploy rejected for shipping
118
+ // a file the author never wrote.
119
+ if ((rel.split("/").pop() ?? "").startsWith("._")) continue;
120
+ out.push({ path: rel, content: entry.content.toString("utf8") });
121
+ }
122
+
123
+ return out;
124
+ }
@@ -0,0 +1,91 @@
1
+ // The one place that knows which names are the runtime's and which are the
2
+ // author's.
3
+ //
4
+ // `tools:` is Claude Code's field, kept compatible, and grants anything: a
5
+ // built-in group, an exact SDK tool name, or a tool this workspace or account
6
+ // defines. There used to be a second spelling, `use:`, that meant only the
7
+ // author's own tools. It is gone: two keys for one grant meant two places to
8
+ // look for an agent's blast radius, and three readers — the container's
9
+ // egress flag, the library's used-by list, Tool Test's agent picker — read
10
+ // one and forgot the other. A file that still says `use:` gets an error
11
+ // naming the rewrite, and scripts/migrate-use-to-tools.mjs makes it.
12
+
13
+ import { refList, refNames, type Ref } from "./refs.ts";
14
+
15
+ /** Groups: one word an author can hold in their head, expanded to SDK names. */
16
+ export const TOOL_MAP: Record<string, string[]> = {
17
+ // `web` includes Anthropic's server-side WebSearch, the one built-in tool
18
+ // that runs off the box and bills per call. `fetch` is the local half only:
19
+ // an agent that searches through the account's own `websearch` tool and
20
+ // reads pages with WebFetch never leaves the box for anything but tokens.
21
+ web: ["WebSearch", "WebFetch"],
22
+ fetch: ["WebFetch"],
23
+ // `read` is deliberately separate from `files`: an agent that may inspect a
24
+ // repository but must never modify it is a real and common design.
25
+ read: ["Read", "Glob", "Grep"],
26
+ files: ["Read", "Write", "Edit", "Glob", "Grep"],
27
+ bash: ["Bash"],
28
+ };
29
+
30
+ /** Exact SDK tool names, accepted alongside the group aliases so a Claude Code
31
+ * subagent's `tools: Read, Grep` works unchanged. The aliases exist because
32
+ * vendors rename tools; `web` survives a rename that `WebSearch` would not. */
33
+ export const BUILTIN_TOOLS = new Set([
34
+ "Read", "Write", "Edit", "MultiEdit", "Glob", "Grep", "Bash",
35
+ "WebSearch", "WebFetch", "NotebookEdit", "TodoWrite",
36
+ ]);
37
+
38
+ /** The platform's own groups, served in-process rather than by the SDK. */
39
+ export const PLATFORM_GROUPS = new Set(["search", "history", "desks"]);
40
+
41
+ /** Would this name resolve to something the runtime provides? Built-ins win a
42
+ * clash, so a tool file of the same name is shadowed rather than granted. */
43
+ export function isRuntimeTool(name: string): boolean {
44
+ return Boolean(TOOL_MAP[name]) || PLATFORM_GROUPS.has(name) || BUILTIN_TOOLS.has(name);
45
+ }
46
+
47
+ /** The name an entry grants. `tools:` also takes the map form for approval
48
+ * mode — `{Bash: ask}` — where the key is the tool and the value the mode. */
49
+ export function toolEntryName(entry: unknown): string {
50
+ return refList([entry])[0]?.name ?? "";
51
+ }
52
+
53
+ type ToolFrontmatter = { tools?: unknown; use?: unknown };
54
+
55
+ /** Every entry of `tools:`, with whether it was written as a `[[link]]`. */
56
+ export function toolRefs(front: ToolFrontmatter): Ref[] {
57
+ return refList(front.tools);
58
+ }
59
+
60
+ /** Is this entry one of the author's own tools? A `[[link]]` always is —
61
+ * the brackets say "my file", so `[[search]]` is yours even though `search`
62
+ * is a runtime group. A bare name is the author's only when no built-in
63
+ * claims it. */
64
+ export function isOwnToolRef(ref: Ref): boolean {
65
+ return ref.linked || !isRuntimeTool(ref.name);
66
+ }
67
+
68
+ /**
69
+ * Every name in `tools:` that can only mean one of the author's own tools.
70
+ * Deduped, order preserved. Names that resolve to nothing are still
71
+ * returned: the caller reports them, and "you asked for a tool that isn't
72
+ * there" is a better error than silence.
73
+ */
74
+ export function ownToolNames(front: ToolFrontmatter): string[] {
75
+ const out: string[] = [];
76
+ for (const ref of toolRefs(front)) {
77
+ if (isOwnToolRef(ref) && !out.includes(ref.name)) out.push(ref.name);
78
+ }
79
+ return out;
80
+ }
81
+
82
+ /** What a file still says under the removed `use:` key — nothing is granted
83
+ * for these; they exist so the error can quote the exact `tools:` line. */
84
+ export function legacyUseNames(front: ToolFrontmatter): string[] {
85
+ return refNames(front.use);
86
+ }
87
+
88
+ /** The one sentence every reader of a `use:` key says. */
89
+ export function legacyUseError(names: string[]): string {
90
+ return `\`use:\` is no longer read — write \`tools: [${names.join(", ")}]\` instead (scripts/migrate-use-to-tools.mjs rewrites every agent). Nothing was granted for it.`;
91
+ }
@@ -0,0 +1,65 @@
1
+ // Does a script tool's `run:` actually point at a program?
2
+ //
3
+ // The failure this exists for is the quietest one the format has. A script
4
+ // tool whose `run:` resolves to nothing does not raise an error at load: the
5
+ // definition parses, the tool is counted, the agent is told it has the
6
+ // capability — and then the call fails inside a turn as a tool error the
7
+ // model paraphrases into something plausible. `foldrun check` counted such a
8
+ // tool among the workspace's tools and printed "no problems", which is the
9
+ // one report that makes a typo invisible.
10
+ //
11
+ // The resolution here is the runtime's, not a second copy of it. That matters
12
+ // more than it looks: `run:` has six accepted spellings across three scopes,
13
+ // and a checker that reimplemented them would eventually disagree with the
14
+ // runner — passing a tool that cannot run, or failing one that can.
15
+
16
+ import fs from "node:fs";
17
+ import path from "node:path";
18
+
19
+ import { workspaceDir, workspaceTools, type ToolDef } from "./store.ts";
20
+ import { libraryDir, libraryTools } from "./library.ts";
21
+ import { resolveRunPath } from "./script-tools.ts";
22
+
23
+ export interface MissingProgram {
24
+ /** The tool's name, as an agent would write it in `tools:`. */
25
+ name: string;
26
+ /** Which shelf it came off — the two resolve differently. */
27
+ scope: "workspace" | "account";
28
+ /** The `run:` as the definition spells it, after folder qualification. */
29
+ run: string;
30
+ /** The absolute path that was looked for, so the message can be acted on. */
31
+ looked: string;
32
+ }
33
+
34
+ /**
35
+ * Every script tool an agent here could call whose program is not on disk.
36
+ *
37
+ * Both shelves, because a grant reaches both and the runtime resolves
38
+ * nearest-wins across them — a checker that only read the workspace would
39
+ * call a working account tool broken.
40
+ */
41
+ export function missingToolPrograms(tenant: string, workspace: string): MissingProgram[] {
42
+ const dir = workspaceDir(tenant, workspace);
43
+ // Tools are workspace- or account-scoped, so resolve as if from an agent
44
+ // directory one level down — the same base the runtime and the tool tester
45
+ // both use.
46
+ const from = path.join(dir, "agents", "_probe");
47
+ const libScripts = libraryDir(tenant, "scripts");
48
+
49
+ const out: MissingProgram[] = [];
50
+ const scan = (defs: Record<string, ToolDef>, scope: "workspace" | "account") => {
51
+ for (const [name, def] of Object.entries(defs)) {
52
+ if (def.kind !== "script") continue;
53
+ const run = String((def.spec as { run?: unknown }).run ?? "");
54
+ // A single-file tool carries its program in the body and has no path to
55
+ // resolve. It is a shape we still accept, not a missing program.
56
+ if (!run) continue;
57
+ const looked = resolveRunPath(from, run, libScripts);
58
+ if (!fs.existsSync(looked)) out.push({ name, scope, run, looked });
59
+ }
60
+ };
61
+
62
+ scan(libraryTools(tenant), "account");
63
+ scan(workspaceTools(tenant, workspace), "workspace");
64
+ return out;
65
+ }
@@ -0,0 +1,401 @@
1
+ // Call a tool once, now, and say what happened.
2
+ //
3
+ // Without this, the first time anyone finds out a tool is misconfigured is
4
+ // mid-flow, inside an agent's turn, as a tool error the model paraphrases into
5
+ // something vague. A wrong `base:`, a secret that was never set, a script path
6
+ // with a typo — all of them look identical from the outside: the agent just
7
+ // doesn't use the tool and says something plausible instead.
8
+ //
9
+ // So each transport gets a real exercise, not a syntax check:
10
+ //
11
+ // http an actual request to the declared base URL
12
+ // script actually runs it, with the workspace's secrets in the environment
13
+ // mcp actually starts the server and completes an MCP handshake
14
+ //
15
+ // Nothing here returns a secret value. Missing secrets are reported by *name*,
16
+ // because "which one didn't you set" is the useful half and the value is not.
17
+
18
+ import { spawn } from "node:child_process";
19
+ import fs from "node:fs";
20
+ import path from "node:path";
21
+
22
+ import { resolveSecrets } from "./secrets.ts";
23
+ import { listAgents, workspaceDir, type ToolDef } from "./store.ts";
24
+ import { libraryDir } from "./library.ts";
25
+ import { secretsUsedByApi } from "./api-tools.ts";
26
+ import { commandFor, resolveRunPath } from "./script-tools.ts";
27
+ import { parseRuntime, prepareRuntime } from "./runtime.ts";
28
+
29
+ export interface ToolTestResult {
30
+ ok: boolean;
31
+ transport: "http" | "script" | "mcp";
32
+ /** One line, safe to show next to the button. */
33
+ summary: string;
34
+ /** The response body, stdout, or the server's tool list. Truncated. */
35
+ detail: string;
36
+ ms: number;
37
+ /** Names only — never values. */
38
+ missingSecrets: string[];
39
+ }
40
+
41
+ const MAX_DETAIL = 4000;
42
+ const TIMEOUT_MS = 15_000;
43
+
44
+ // A tool test runs tenant-authored code directly on the host — real runs are
45
+ // containerized, this is not — so it must NOT inherit the dashboard's full
46
+ // process.env. That env holds FOLDRUN_SECRET_KEY (the global install key that
47
+ // signs every tenant's webhook tokens and git-push secrets), plus the S3/DB/
48
+ // Stripe/GitHub credentials: handing them to a `console.log(process.env)`
49
+ // script tool is cross-tenant compromise. The child instead starts from a
50
+ // minimal host base — only enough for an interpreter to be found and run —
51
+ // and gets the tool's own declared secrets layered on top by the callers.
52
+ const HOST_BASE_ENV_KEYS = [
53
+ "PATH", "HOME", "TMPDIR", "TMP", "TEMP", "LANG", "LANGUAGE",
54
+ "LC_ALL", "LC_CTYPE", "TZ", "TERM", "SHELL", "USER", "LOGNAME",
55
+ "NODE_ENV", // not a secret; some interpreters/tools read it.
56
+ // Windows equivalents, harmless elsewhere.
57
+ "SYSTEMROOT", "COMSPEC", "PATHEXT", "WINDIR",
58
+ ];
59
+ function hostSafeBaseEnv(): NodeJS.ProcessEnv {
60
+ const base: Record<string, string> = {};
61
+ for (const k of HOST_BASE_ENV_KEYS) {
62
+ const v = process.env[k];
63
+ if (v !== undefined) base[k] = v;
64
+ }
65
+ return base as NodeJS.ProcessEnv;
66
+ }
67
+
68
+ const clip = (s: string) =>
69
+ s.length > MAX_DETAIL ? `${s.slice(0, MAX_DETAIL)}\n… truncated` : s;
70
+
71
+ /** `${NAME}` → value, for headers and query. Same rule the runtime uses. */
72
+ const substitute = (v: string, env: Record<string, string>) =>
73
+ v.replace(/\$\{([A-Z0-9_]+)\}/g, (_, k) => env[k] ?? "");
74
+
75
+ export interface ToolTestInput {
76
+ /** http: the path to try, appended to `base:`. */
77
+ path?: string;
78
+ /** script: values for the tool's declared `args:`. */
79
+ args?: Record<string, string>;
80
+ }
81
+
82
+ export async function testTool(
83
+ tenant: string,
84
+ workspace: string,
85
+ def: ToolDef,
86
+ input: ToolTestInput = {},
87
+ ): Promise<ToolTestResult> {
88
+ const probePath = input.path ?? "";
89
+ const started = Date.now();
90
+ const done = (r: Omit<ToolTestResult, "ms">): ToolTestResult => ({ ...r, ms: Date.now() - started });
91
+
92
+ if (def.kind === "http") {
93
+ const api = def.spec;
94
+ const needed = secretsUsedByApi(api);
95
+ const { env, missing } = resolveSecrets(tenant, needed, workspace);
96
+
97
+ try {
98
+ const rel = probePath.startsWith("/") || probePath === "" ? probePath : `/${probePath}`;
99
+ const url = new URL(api.base + rel);
100
+ // Same confinement the agent gets: a probe can't reach another host.
101
+ if (!url.href.startsWith(api.base)) {
102
+ return done({
103
+ ok: false, transport: "http", missingSecrets: missing,
104
+ summary: "that path escapes the declared base URL",
105
+ detail: `base: ${api.base}\npath: ${probePath}`,
106
+ });
107
+ }
108
+ for (const [k, v] of Object.entries(api.query)) url.searchParams.set(k, substitute(v, env));
109
+
110
+ const headers: Record<string, string> = {};
111
+ for (const [k, v] of Object.entries(api.headers)) headers[k] = substitute(v, env);
112
+
113
+ const method = api.methods.includes("GET") ? "GET" : api.methods[0];
114
+ const res = await fetch(url, {
115
+ method,
116
+ headers,
117
+ signal: AbortSignal.timeout(TIMEOUT_MS),
118
+ });
119
+ const body = await res.text();
120
+
121
+ // An unset secret means the tool cannot authenticate, so it is not
122
+ // working — whatever a permissive endpoint chose to return. Reporting
123
+ // ok on a request that went out with no credentials is how you ship a
124
+ // tool that 401s the first time it meets the real API.
125
+ return done({
126
+ ok: res.ok && missing.length === 0,
127
+ transport: "http",
128
+ missingSecrets: missing,
129
+ summary:
130
+ missing.length > 0
131
+ ? `${method} ${url.pathname} → ${res.status}, but sent without ${missing.join(", ")}`
132
+ : `${method} ${url.pathname} → ${res.status} ${res.statusText}`,
133
+ detail: clip(body),
134
+ });
135
+ } catch (err) {
136
+ return done({
137
+ ok: false, transport: "http", missingSecrets: missing,
138
+ summary: err instanceof Error ? err.message : String(err),
139
+ // A missing secret usually surfaces as a 401 rather than an error, so
140
+ // say it here too — it is the likeliest cause of both.
141
+ detail: missing.length ? `unset secrets: ${missing.join(", ")}` : "",
142
+ });
143
+ }
144
+ }
145
+
146
+ if (def.kind === "script") {
147
+ const run = String(def.spec.run ?? "");
148
+ // A run's working directory is the calling AGENT's folder, not the
149
+ // workspace root — and scripts rely on that. `path.resolve(cwd, "..",
150
+ // "..", "state", f)` is the ordinary way to reach workspace state, and
151
+ // testing one directory too high sent it to <data>/<tenant>/state: a
152
+ // real file read from the wrong place, reported as an empty result
153
+ // rather than an error. The tester now stands where a run stands.
154
+ //
155
+ // Which agent: one that granted this tool, so the test is the call that
156
+ // would actually happen; any agent otherwise, since only the DEPTH
157
+ // changes what a relative path resolves to.
158
+ const dir = workspaceDir(tenant, workspace);
159
+ const agents = listAgents(tenant, workspace);
160
+ const caller =
161
+ agents.find((a) => a.ownTools.includes(def.name)) ?? agents[0] ?? null;
162
+ // With no agents at all there is nothing at that depth to stand in; the
163
+ // workspace root is wrong but it exists, and the note says so rather
164
+ // than failing a test over a workspace that cannot run anything yet.
165
+ const cwd = caller ? path.join(dir, "agents", caller.name) : dir;
166
+ const file = resolveRunPath(path.join(dir, "agents", "_probe"), run, libraryDir(tenant, "scripts"));
167
+
168
+ if (!fs.existsSync(file)) {
169
+ return done({
170
+ ok: false, transport: "script", missingSecrets: [],
171
+ summary: `no such script: ${run}`,
172
+ detail: `looked for ${file}\n\nThis is the commonest way to get a tool that silently never loads.`,
173
+ });
174
+ }
175
+
176
+ const names = Array.isArray(def.spec.secrets) ? def.spec.secrets.map(String) : [];
177
+ const { env, missing } = resolveSecrets(tenant, names, workspace);
178
+ // The runtime's own choice of interpreter, not a second table beside it.
179
+ const interpreter =
180
+ typeof def.spec.interpreter === "string" ? def.spec.interpreter : undefined;
181
+
182
+ // Declared args become long flags, exactly as the runtime passes them.
183
+ const declared = def.spec.args && typeof def.spec.args === "object"
184
+ ? Object.keys(def.spec.args as Record<string, unknown>)
185
+ : [];
186
+ const flags: string[] = [];
187
+ for (const [k, v] of Object.entries(input.args ?? {})) {
188
+ if (v !== "" && /^[a-z][a-z0-9_]*$/i.test(k)) flags.push(`--${k}`, v);
189
+ }
190
+
191
+ // The tool's own dependencies, built (or reused) the way a run builds
192
+ // them. Without this a tool declaring `runtime: { packages: [requests] }`
193
+ // ran fine in a flow and failed the Test button with "no module named
194
+ // requests" — the one place a developer goes to find out if it works.
195
+ const toolRuntime = prepareRuntime(tenant, parseRuntime(def.spec.runtime));
196
+ if (toolRuntime.error) {
197
+ return done({
198
+ ok: false, transport: "script", missingSecrets: missing,
199
+ summary: "its runtime could not be built",
200
+ detail: [toolRuntime.error, ...toolRuntime.log].join("\n"),
201
+ });
202
+ }
203
+ const { cmd, args } = commandFor(
204
+ { interpreter, run: run, name: "", description: "", args: {} },
205
+ file,
206
+ toolRuntime.interpreters,
207
+ );
208
+ const { code, out } = await runOnce(cmd, [...args, ...flags], cwd, {
209
+ ...hostSafeBaseEnv(),
210
+ ...toolRuntime.env,
211
+ ...env,
212
+ });
213
+
214
+ // A script that needs arguments and got none has not failed — it has not
215
+ // been tested. Saying "exited 2" would send someone looking for a bug.
216
+ const untested = code !== 0 && declared.length > 0 && flags.length === 0;
217
+
218
+ return done({
219
+ ok: code === 0,
220
+ transport: "script",
221
+ missingSecrets: missing,
222
+ summary: untested
223
+ ? `needs ${declared.map((a) => `--${a}`).join(", ")} — fill them in and test again`
224
+ : code === 0 ? "exited 0" : `exited ${code ?? "with an error"}`,
225
+ detail: clip(
226
+ (caller
227
+ ? `ran from agents/${caller.name}/, where a run runs\n\n`
228
+ : `ran from the workspace root — there is no agent to stand in for, so a script that reaches ../../ will look one level too high\n\n`) +
229
+ (out || "(no output)"),
230
+ ),
231
+ });
232
+ }
233
+
234
+ // ---- mcp: start the server and complete a handshake ----
235
+ const spec = def.spec;
236
+ if (spec.url) {
237
+ // A remote server: the honest check we can make without a full client is
238
+ // that the endpoint answers.
239
+ try {
240
+ const res = await fetch(spec.url, { signal: AbortSignal.timeout(TIMEOUT_MS) });
241
+ return done({
242
+ ok: res.ok,
243
+ transport: "mcp",
244
+ missingSecrets: [],
245
+ summary: `${spec.url} → ${res.status} ${res.statusText}`,
246
+ detail: "Endpoint reachable. This does not verify the MCP handshake — only a local (command) server is started and spoken to.",
247
+ });
248
+ } catch (err) {
249
+ return done({
250
+ ok: false, transport: "mcp", missingSecrets: [],
251
+ summary: err instanceof Error ? err.message : String(err),
252
+ detail: "",
253
+ });
254
+ }
255
+ }
256
+
257
+ if (!spec.command) {
258
+ return done({
259
+ ok: false, transport: "mcp", missingSecrets: [],
260
+ summary: "no command: or url: — this tool cannot load",
261
+ detail: "",
262
+ });
263
+ }
264
+
265
+ const named = Object.entries(spec.env ?? {})
266
+ .map(([, v]) => String(v).match(/^\$\{([A-Z0-9_]+)\}$/)?.[1])
267
+ .filter((n): n is string => Boolean(n));
268
+ const { env, missing } = resolveSecrets(tenant, named, workspace);
269
+ const childEnv: NodeJS.ProcessEnv = { ...hostSafeBaseEnv() };
270
+ for (const [k, v] of Object.entries(spec.env ?? {})) childEnv[k] = substitute(String(v), env);
271
+
272
+ const handshake = await mcpHandshake(spec.command, spec.args ?? [], childEnv);
273
+ return done({
274
+ ok: handshake.ok,
275
+ transport: "mcp",
276
+ missingSecrets: missing,
277
+ summary: handshake.summary,
278
+ detail: clip(handshake.detail),
279
+ });
280
+ }
281
+
282
+ /** Run a command to completion, capturing both streams together. */
283
+ function runOnce(
284
+ cmd: string,
285
+ args: string[],
286
+ cwd: string,
287
+ env: NodeJS.ProcessEnv,
288
+ ): Promise<{ code: number | null; out: string }> {
289
+ return new Promise((resolve) => {
290
+ let out = "";
291
+ let child: ReturnType<typeof spawn>;
292
+ try {
293
+ child = spawn(cmd, args, { cwd, env, stdio: ["ignore", "pipe", "pipe"] });
294
+ } catch (err) {
295
+ resolve({ code: null, out: err instanceof Error ? err.message : String(err) });
296
+ return;
297
+ }
298
+ const timer = setTimeout(() => child.kill("SIGKILL"), TIMEOUT_MS);
299
+ child.stdout?.on("data", (d) => (out += d));
300
+ child.stderr?.on("data", (d) => (out += d));
301
+ child.on("error", (err) => {
302
+ clearTimeout(timer);
303
+ resolve({ code: null, out: `${out}${err.message}` });
304
+ });
305
+ child.on("close", (code) => {
306
+ clearTimeout(timer);
307
+ resolve({ code, out });
308
+ });
309
+ });
310
+ }
311
+
312
+ /**
313
+ * Start a stdio MCP server and complete `initialize` + `tools/list`.
314
+ *
315
+ * This is the whole point of testing an MCP tool: "the binary exists" tells you
316
+ * nothing, and the failure people actually hit is a server that starts and then
317
+ * exposes nothing useful.
318
+ */
319
+ function mcpHandshake(
320
+ command: string,
321
+ args: string[],
322
+ env: NodeJS.ProcessEnv,
323
+ ): Promise<{ ok: boolean; summary: string; detail: string }> {
324
+ return new Promise((resolve) => {
325
+ let child: ReturnType<typeof spawn>;
326
+ try {
327
+ child = spawn(command, args, { env, stdio: ["pipe", "pipe", "pipe"] });
328
+ } catch (err) {
329
+ resolve({ ok: false, summary: `could not start ${command}`, detail: String(err), });
330
+ return;
331
+ }
332
+
333
+ let buf = "";
334
+ let stderr = "";
335
+ let settled = false;
336
+ const finish = (r: { ok: boolean; summary: string; detail: string }) => {
337
+ if (settled) return;
338
+ settled = true;
339
+ clearTimeout(timer);
340
+ child.kill("SIGKILL");
341
+ resolve(r);
342
+ };
343
+
344
+ const timer = setTimeout(
345
+ () => finish({
346
+ ok: false,
347
+ summary: "the server did not answer in time",
348
+ detail: stderr || "No response to initialize within 15s.",
349
+ }),
350
+ TIMEOUT_MS,
351
+ );
352
+
353
+ const send = (msg: unknown) => child.stdin?.write(`${JSON.stringify(msg)}\n`);
354
+
355
+ child.on("error", (err) =>
356
+ finish({ ok: false, summary: `could not start ${command}`, detail: err.message }));
357
+ child.stderr?.on("data", (d) => (stderr += d));
358
+
359
+ child.stdout?.on("data", (d) => {
360
+ buf += d;
361
+ // One JSON-RPC message per line.
362
+ for (const line of buf.split("\n").slice(0, -1)) {
363
+ if (!line.trim()) continue;
364
+ let msg: { id?: number; result?: { tools?: { name: string }[] }; error?: { message?: string } };
365
+ try {
366
+ msg = JSON.parse(line);
367
+ } catch {
368
+ continue;
369
+ }
370
+ if (msg.error) {
371
+ finish({ ok: false, summary: msg.error.message ?? "the server returned an error", detail: line });
372
+ return;
373
+ }
374
+ if (msg.id === 1) {
375
+ send({ jsonrpc: "2.0", method: "notifications/initialized" });
376
+ send({ jsonrpc: "2.0", id: 2, method: "tools/list", params: {} });
377
+ } else if (msg.id === 2) {
378
+ const tools = msg.result?.tools ?? [];
379
+ finish({
380
+ ok: true,
381
+ summary: `connected — ${tools.length} tool${tools.length === 1 ? "" : "s"}`,
382
+ detail: tools.map((t) => `• ${t.name}`).join("\n") || "The server exposes no tools.",
383
+ });
384
+ return;
385
+ }
386
+ }
387
+ buf = buf.slice(buf.lastIndexOf("\n") + 1);
388
+ });
389
+
390
+ send({
391
+ jsonrpc: "2.0",
392
+ id: 1,
393
+ method: "initialize",
394
+ params: {
395
+ protocolVersion: "2024-11-05",
396
+ capabilities: {},
397
+ clientInfo: { name: "foldrun-tool-test", version: "0.1.0" },
398
+ },
399
+ });
400
+ });
401
+ }