@foldrun/core 0.4.0 → 0.5.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 (155) hide show
  1. package/dist/index.d.ts +3 -0
  2. package/dist/index.js +3 -0
  3. package/dist/src/account.d.ts +45 -0
  4. package/dist/src/account.js +130 -0
  5. package/dist/src/api-tools.d.ts +8 -0
  6. package/dist/src/api-tools.js +22 -4
  7. package/dist/src/browser-pod.d.ts +129 -0
  8. package/dist/src/browser-pod.js +284 -0
  9. package/dist/src/catalog.js +2 -1
  10. package/dist/src/clock.d.ts +49 -0
  11. package/dist/src/clock.js +121 -0
  12. package/dist/src/completions.js +17 -8
  13. package/dist/src/confine.d.ts +22 -0
  14. package/dist/src/confine.js +218 -3
  15. package/dist/src/deploy.d.ts +33 -0
  16. package/dist/src/deploy.js +145 -6
  17. package/dist/src/egress.d.ts +7 -0
  18. package/dist/src/egress.js +6 -0
  19. package/dist/src/eol.d.ts +2 -0
  20. package/dist/src/eol.js +7 -0
  21. package/dist/src/evals.d.ts +5 -0
  22. package/dist/src/evals.js +6 -2
  23. package/dist/src/flow-lint.d.ts +8 -0
  24. package/dist/src/flow-lint.js +223 -1
  25. package/dist/src/flow-patterns.d.ts +141 -0
  26. package/dist/src/flow-patterns.js +568 -0
  27. package/dist/src/gitrepo.d.ts +18 -0
  28. package/dist/src/gitrepo.js +49 -0
  29. package/dist/src/kinds.js +1 -1
  30. package/dist/src/language.d.ts +17 -0
  31. package/dist/src/language.js +55 -0
  32. package/dist/src/layout.d.ts +69 -0
  33. package/dist/src/layout.js +200 -0
  34. package/dist/src/library.d.ts +9 -0
  35. package/dist/src/library.js +30 -3
  36. package/dist/src/limits.d.ts +84 -0
  37. package/dist/src/limits.js +287 -0
  38. package/dist/src/locale.d.ts +47 -0
  39. package/dist/src/locale.js +177 -0
  40. package/dist/src/mail.d.ts +88 -0
  41. package/dist/src/mail.js +245 -0
  42. package/dist/src/model-probe.d.ts +3 -0
  43. package/dist/src/model-probe.js +1 -0
  44. package/dist/src/notify.d.ts +53 -7
  45. package/dist/src/notify.js +194 -126
  46. package/dist/src/openapi.js +5 -3
  47. package/dist/src/operator.d.ts +201 -0
  48. package/dist/src/operator.js +251 -0
  49. package/dist/src/paths.d.ts +28 -0
  50. package/dist/src/paths.js +55 -0
  51. package/dist/src/platform.d.ts +43 -3
  52. package/dist/src/platform.js +9 -1
  53. package/dist/src/provider-health.js +3 -2
  54. package/dist/src/providers.d.ts +481 -0
  55. package/dist/src/providers.js +1105 -6
  56. package/dist/src/refusal.d.ts +83 -0
  57. package/dist/src/refusal.js +281 -0
  58. package/dist/src/run-container.d.ts +149 -13
  59. package/dist/src/run-container.js +403 -42
  60. package/dist/src/runner.d.ts +142 -9
  61. package/dist/src/runner.js +1027 -122
  62. package/dist/src/runtime.d.ts +52 -0
  63. package/dist/src/runtime.js +493 -58
  64. package/dist/src/script-tools.d.ts +3 -2
  65. package/dist/src/script-tools.js +35 -3
  66. package/dist/src/secret-files.d.ts +6 -5
  67. package/dist/src/secret-files.js +24 -11
  68. package/dist/src/secret-health.d.ts +30 -6
  69. package/dist/src/secret-health.js +42 -20
  70. package/dist/src/secrets.d.ts +19 -0
  71. package/dist/src/secrets.js +155 -86
  72. package/dist/src/starter.d.ts +9 -0
  73. package/dist/src/starter.js +92 -11
  74. package/dist/src/step-exec.d.ts +61 -2
  75. package/dist/src/step-exec.js +414 -36
  76. package/dist/src/step-removal.d.ts +15 -0
  77. package/dist/src/step-removal.js +49 -0
  78. package/dist/src/storage.d.ts +23 -4
  79. package/dist/src/storage.js +125 -23
  80. package/dist/src/store.d.ts +218 -37
  81. package/dist/src/store.js +347 -60
  82. package/dist/src/subagents.d.ts +75 -0
  83. package/dist/src/subagents.js +149 -0
  84. package/dist/src/tool-names.d.ts +13 -1
  85. package/dist/src/tool-names.js +38 -11
  86. package/dist/src/tool-programs.d.ts +26 -0
  87. package/dist/src/tool-programs.js +172 -0
  88. package/dist/src/tool-test.js +104 -12
  89. package/dist/src/translator.d.ts +8 -2
  90. package/dist/src/translator.js +69 -23
  91. package/dist/src/trigger-log.d.ts +1 -1
  92. package/dist/src/triggers.d.ts +3 -0
  93. package/dist/src/triggers.js +10 -2
  94. package/dist/src/untrusted-fetch.d.ts +11 -0
  95. package/dist/src/untrusted-fetch.js +77 -0
  96. package/dist/src/web-actions.d.ts +91 -0
  97. package/dist/src/web-actions.js +199 -0
  98. package/dist/src/webhook.d.ts +4 -0
  99. package/dist/src/webhook.js +39 -0
  100. package/index.ts +3 -0
  101. package/package.json +3 -3
  102. package/src/account.ts +149 -0
  103. package/src/api-tools.ts +32 -4
  104. package/src/browser-pod.ts +313 -0
  105. package/src/catalog.ts +2 -1
  106. package/src/clock.ts +149 -0
  107. package/src/completions.ts +17 -8
  108. package/src/confine.ts +218 -3
  109. package/src/deploy.ts +137 -6
  110. package/src/egress.ts +13 -1
  111. package/src/eol.ts +7 -0
  112. package/src/evals.ts +11 -2
  113. package/src/flow-lint.ts +253 -1
  114. package/src/flow-patterns.ts +597 -0
  115. package/src/gitrepo.ts +48 -0
  116. package/src/kinds.ts +1 -1
  117. package/src/language.ts +62 -0
  118. package/src/layout.ts +228 -0
  119. package/src/library.ts +37 -3
  120. package/src/limits.ts +290 -0
  121. package/src/locale.ts +217 -0
  122. package/src/mail.ts +316 -0
  123. package/src/model-probe.ts +4 -0
  124. package/src/notify.ts +189 -103
  125. package/src/openapi.ts +5 -3
  126. package/src/operator.ts +300 -0
  127. package/src/paths.ts +54 -0
  128. package/src/platform.ts +52 -3
  129. package/src/provider-health.ts +3 -2
  130. package/src/providers.ts +1368 -6
  131. package/src/refusal.ts +320 -0
  132. package/src/run-container.ts +439 -43
  133. package/src/runner.ts +1050 -122
  134. package/src/runtime.ts +487 -55
  135. package/src/script-tools.ts +38 -5
  136. package/src/secret-files.ts +22 -10
  137. package/src/secret-health.ts +64 -19
  138. package/src/secrets.ts +149 -87
  139. package/src/starter.ts +93 -11
  140. package/src/step-exec.ts +429 -37
  141. package/src/step-removal.ts +59 -0
  142. package/src/storage.ts +141 -22
  143. package/src/store.ts +500 -68
  144. package/src/subagents.ts +189 -0
  145. package/src/tool-names.ts +40 -11
  146. package/src/tool-programs.ts +179 -0
  147. package/src/tool-test.ts +110 -12
  148. package/src/translator.ts +72 -22
  149. package/src/trigger-log.ts +1 -0
  150. package/src/triggers.ts +10 -2
  151. package/src/untrusted-fetch.ts +88 -0
  152. package/src/web-actions.ts +237 -0
  153. package/src/webhook.ts +41 -0
  154. package/templates/hello/agents/curator/agent.md +1 -1
  155. package/templates/hello/agents/notetaker/agent.md +1 -1
@@ -0,0 +1,189 @@
1
+ // Sub-agents: delegate a job to a colleague with its OWN context and its own
2
+ // tools, inside the step that asked.
3
+ //
4
+ // agents: [editor] # consult: a quick opinion, no tools (agent-tools.ts)
5
+ // subagents: [researcher] # delegate: own context, own tools
6
+ //
7
+ // Built on the Agent SDK's native sub-agents (query()'s `agents` option and
8
+ // its Agent tool), so a delegation behaves as it does in Claude Code: a fresh
9
+ // context does the work — reads forty pages, runs the checks — and hands back
10
+ // a summary, keeping the parent's context on its own job.
11
+ //
12
+ // What keeps it the platform's and not a second, wider agent:
13
+ // - never wider than the parent: a sub-agent's tools are its own `tools:`
14
+ // resolved exactly as a step's, INTERSECTED with what the parent step
15
+ // holds — and always an explicit list: the SDK gives a definition with
16
+ // no `tools` every tool the parent has
17
+ // - depth one: the Agent/Task tool is never in a sub-agent's list, so it
18
+ // cannot delegate again
19
+ // - the same step: same sandbox, files, secrets, egress lease, and the
20
+ // parent's hooks — the SDK runs a sub-agent's tool calls through the
21
+ // session's PreToolUse and canUseTool (tagged agent_id/agent_type), so
22
+ // confinement applies unchanged; subagentGuard() below refuses anything
23
+ // outside the sub-agent's own list there too, belt and braces
24
+ // - cost and trace land on the parent step; flows still decide what runs
25
+ // next — a sub-agent is a helper inside one step, never a step.
26
+
27
+ import fs from "node:fs";
28
+ import path from "node:path";
29
+ import { refNames } from "./refs.ts";
30
+ import { TOOL_MAP } from "./tool-names.ts";
31
+
32
+ /** Plain JSON: this crosses into the run container. */
33
+ export interface SubagentSpec {
34
+ name: string;
35
+ description: string;
36
+ prompt: string;
37
+ /** Always explicit — never absent, never inherited. */
38
+ tools: string[];
39
+ disallowedTools: string[];
40
+ /** A model alias (haiku/sonnet/opus, remapped by the step's provider like
41
+ * the step's own) or "inherit". */
42
+ model: string;
43
+ }
44
+
45
+ /** The SDK's delegation tool, under both names it has had. Never granted to a
46
+ * sub-agent: that is what makes the depth one. */
47
+ export const DELEGATE_TOOLS = ["Agent", "Task"] as const;
48
+ export const DELEGATE_TOOL = "Agent";
49
+
50
+ const NAME = /^[a-z0-9]+(-[a-z0-9]+)*$/;
51
+
52
+ /** The named sub-agents' folders, host-side. Unknown names are reported. */
53
+ export function gatherSubagents(
54
+ workspaceRoot: string,
55
+ names: unknown,
56
+ ): { found: { name: string; dir: string }[]; missing: string[] } {
57
+ const found: { name: string; dir: string }[] = [];
58
+ const missing: string[] = [];
59
+ for (const name of refNames(names)) {
60
+ const dir = path.join(workspaceRoot, "agents", name);
61
+ if (!NAME.test(name) || !fs.existsSync(path.join(dir, "agent.md"))) missing.push(name);
62
+ else if (!found.some((f) => f.name === name)) found.push({ name, dir });
63
+ }
64
+ return { found, missing };
65
+ }
66
+
67
+ /**
68
+ * A sub-agent's tools: its own, only where the parent holds them too. An MCP
69
+ * tool is held when the parent holds that exact name or its whole server
70
+ * (`mcp__<server>` / `mcp__<server>__*`). The delegate tool never passes.
71
+ */
72
+ export function subagentTools(own: string[], parent: string[]): string[] {
73
+ const held = new Set(parent);
74
+ const servers = new Set(
75
+ parent.filter((t) => /^mcp__[^_]/.test(t)).map((t) => t.replace(/__\*$/, "")).filter((t) => !t.slice(5).includes("__")),
76
+ );
77
+ const out: string[] = [];
78
+ for (const t of own) {
79
+ if ((DELEGATE_TOOLS as readonly string[]).includes(t) || out.includes(t)) continue;
80
+ const server = /^mcp__([^_].*?)__/.exec(t)?.[1];
81
+ if (held.has(t) || (server && servers.has(`mcp__${server}`))) out.push(t);
82
+ }
83
+ return out;
84
+ }
85
+
86
+ /** The sub-agent's system prompt: its own prose, told where it stands. It
87
+ * works in the PARENT's directory — the SDK shares the session's cwd. */
88
+ export function subagentPrompt(o: {
89
+ body: string;
90
+ name: string;
91
+ parent: string;
92
+ workspace: string;
93
+ shared?: string | null;
94
+ }): string {
95
+ return [
96
+ o.body.trim(),
97
+ `# Where you are\n\n` +
98
+ `You are ${o.name}, a sub-agent started by ${o.parent} to do one job and report back. ` +
99
+ `You work in ${o.parent}'s directory, \`agents/${o.parent}/\` in the \`${o.workspace}\` workspace: ` +
100
+ `\`../../\` is the workspace root, its knowledge is at \`../../knowledge/\`, and absolute paths are refused. ` +
101
+ `You have only the tools listed for you. When the job is done, reply with what you found or made — ` +
102
+ `your reply is all ${o.parent} sees of your work, so put the facts and file paths in it.`,
103
+ ...(o.shared?.trim() ? [o.shared.trim()] : []),
104
+ ].join("\n\n");
105
+ }
106
+
107
+ /** SDK definitions. `tools` is always set — an absent list inherits every
108
+ * tool the parent has, which is the one thing this module exists to stop. */
109
+ export function toAgentDefinitions(specs: SubagentSpec[]): Record<string, {
110
+ description: string;
111
+ prompt: string;
112
+ tools: string[];
113
+ disallowedTools: string[];
114
+ model: string;
115
+ }> {
116
+ const out: Record<string, { description: string; prompt: string; tools: string[]; disallowedTools: string[]; model: string }> = {};
117
+ for (const s of specs) {
118
+ out[s.name] = {
119
+ description: s.description,
120
+ prompt: s.prompt,
121
+ tools: [...s.tools],
122
+ disallowedTools: [...new Set([...s.disallowedTools, ...DELEGATE_TOOLS])],
123
+ model: s.model || "inherit",
124
+ };
125
+ }
126
+ return out;
127
+ }
128
+
129
+ /** One tool call as the parent's PreToolUse hook sees it. */
130
+ export interface GuardedCall {
131
+ /** The SDK's agent_id: set when the call comes from inside a sub-agent. */
132
+ agentId?: string;
133
+ /** The SDK's agent_type: which sub-agent. */
134
+ agentType?: string;
135
+ tool: string;
136
+ input: Record<string, unknown>;
137
+ }
138
+
139
+ /**
140
+ * For the parent's PreToolUse hook, on a step that has sub-agents: a reason
141
+ * to refuse the call — or null.
142
+ *
143
+ * Only DECLARED sub-agents may run. The SDK also offers its built-ins
144
+ * (general-purpose and the rest, with every tool the session has); they are
145
+ * switched off in executeStep, and refused here too. A call from inside a
146
+ * sub-agent whose type is missing or not declared used to pass unjudged —
147
+ * `general-purpose` was a way round every sub-agent's own list.
148
+ */
149
+ export function subagentGuard(specs: SubagentSpec[]): (call: GuardedCall) => string | null {
150
+ const by = new Map(specs.map((s) => [s.name, s]));
151
+ const declared = specs.map((s) => s.name).join(", ");
152
+ const isDelegate = (tool: string) => (DELEGATE_TOOLS as readonly string[]).includes(tool);
153
+ return ({ agentId, agentType, tool, input }) => {
154
+ if (!agentId) {
155
+ // The main thread: its tools are canUseTool's business — except the
156
+ // delegation itself, which may name only a declared sub-agent.
157
+ if (!isDelegate(tool)) return null;
158
+ const to = typeof input.subagent_type === "string" ? input.subagent_type : "";
159
+ if (by.has(to)) return null;
160
+ return `${to ? `"${to}" is not a sub-agent this step declared` : "name the sub-agent to delegate to"} — subagent_type must be one of: ${declared}`;
161
+ }
162
+ const s = agentType ? by.get(agentType) : undefined;
163
+ if (!s) return `${agentType ? `"${agentType}"` : "an unnamed sub-agent"} is not declared in subagents: for this step (only ${declared}) — its calls are refused`;
164
+ if (isDelegate(tool)) {
165
+ return `${s.name} is a sub-agent and cannot start another — do the work with your own tools`;
166
+ }
167
+ if (s.disallowedTools.includes(tool)) return `${tool} is disallowed for ${s.name}`;
168
+ if (subagentTools([tool], s.tools).length === 0) return `${tool} is not one of ${s.name}'s tools`;
169
+ return null;
170
+ };
171
+ }
172
+
173
+ /**
174
+ * What `foldrun check` can say without running anything: the SDK and built-in
175
+ * tool names two `tools:` lists grant, and their overlap. Own tools compare by
176
+ * name. Used to warn when a sub-agent would be left with nothing.
177
+ */
178
+ export function grantedNames(tools: string[]): Set<string> {
179
+ const out = new Set<string>();
180
+ for (const t of tools) {
181
+ for (const n of TOOL_MAP[t] ?? [t]) out.add(n);
182
+ }
183
+ return out;
184
+ }
185
+
186
+ export function checkOverlap(parentTools: string[], subTools: string[]): string[] {
187
+ const parent = grantedNames(parentTools);
188
+ return [...grantedNames(subTools)].filter((t) => parent.has(t) && !(DELEGATE_TOOLS as readonly string[]).includes(t));
189
+ }
package/src/tool-names.ts CHANGED
@@ -14,29 +14,43 @@ import { refList, refNames, type Ref } from "./refs.ts";
14
14
 
15
15
  /** Groups: one word an author can hold in their head, expanded to SDK names. */
16
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.
17
+ // The web is one tool of ours, `web` — search, fetch, browse, crawl, map,
18
+ // extract, answer, monitor, served from the gallery and on the run record
19
+ // whichever model is driving. So `web` is deliberately NOT here: a name in
20
+ // TOOL_MAP shadows a real tool of the same name (see runner.ts).
21
+ // `read` is deliberately separate from `write`: an agent that may inspect a
22
+ // repository but must never modify it is a real and common design. The pair
23
+ // says what it grants. `files` and `bash` were the old names for `write`
24
+ // and `code` (`code`, because it is not only shell — anything in the
25
+ // sandbox runs through it); both are retired, below.
25
26
  read: ["Read", "Glob", "Grep"],
27
+ write: ["Read", "Write", "Edit", "Glob", "Grep"],
26
28
  files: ["Read", "Write", "Edit", "Glob", "Grep"],
29
+ code: ["Bash"],
27
30
  bash: ["Bash"],
28
31
  };
29
32
 
33
+ /** Names an author may no longer write, and what to write instead. They are
34
+ * still granted, so nothing deployed breaks; `check` and the run log say the
35
+ * rewrite. */
36
+ export const RETIRED_TOOLS: Record<string, string> = {
37
+ files: "write",
38
+ bash: "code",
39
+ };
40
+
30
41
  /** Exact SDK tool names, accepted alongside the group aliases so a Claude Code
31
42
  * subagent's `tools: Read, Grep` works unchanged. The aliases exist because
32
- * vendors rename tools; `web` survives a rename that `WebSearch` would not. */
43
+ * vendors rename tools; `write` survives a rename that `Write` would not.
44
+ * The SDK's WebSearch and WebFetch are not here: the web is our `web` tool,
45
+ * and a model provider's own search (`web: {search: zai}`) is the runtime's
46
+ * to swap in, not the author's to name. */
33
47
  export const BUILTIN_TOOLS = new Set([
34
48
  "Read", "Write", "Edit", "MultiEdit", "Glob", "Grep", "Bash",
35
- "WebSearch", "WebFetch", "NotebookEdit", "TodoWrite",
49
+ "NotebookEdit", "TodoWrite",
36
50
  ]);
37
51
 
38
52
  /** The platform's own groups, served in-process rather than by the SDK. */
39
- export const PLATFORM_GROUPS = new Set(["search", "history", "desks"]);
53
+ export const PLATFORM_GROUPS = new Set(["search", "history", "desks", "ask"]);
40
54
 
41
55
  /** Would this name resolve to something the runtime provides? Built-ins win a
42
56
  * clash, so a tool file of the same name is shadowed rather than granted. */
@@ -85,6 +99,21 @@ export function legacyUseNames(front: ToolFrontmatter): string[] {
85
99
  return refNames(front.use);
86
100
  }
87
101
 
102
+ /** Retired names written bare in `tools:`, deduped. A `[[link]]` is the
103
+ * author's own file, so it is never one of these. */
104
+ export function retiredToolNames(front: ToolFrontmatter): string[] {
105
+ const out: string[] = [];
106
+ for (const ref of toolRefs(front)) {
107
+ if (!ref.linked && RETIRED_TOOLS[ref.name] && !out.includes(ref.name)) out.push(ref.name);
108
+ }
109
+ return out;
110
+ }
111
+
112
+ /** The one sentence every reader of a retired name says. */
113
+ export function retiredToolError(name: string): string {
114
+ return `tools: "${name}" is retired — use ${RETIRED_TOOLS[name]} instead: the same grant, under the name that says what it does. It is still granted for now.`;
115
+ }
116
+
88
117
  /** The one sentence every reader of a `use:` key says. */
89
118
  export function legacyUseError(names: string[]): string {
90
119
  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.`;
@@ -15,6 +15,9 @@
15
15
 
16
16
  import fs from "node:fs";
17
17
  import path from "node:path";
18
+ import { builtinModules } from "node:module";
19
+ import matter from "gray-matter";
20
+ import { parseRuntime } from "./runtime.ts";
18
21
 
19
22
  import { workspaceDir, workspaceTools, type ToolDef } from "./store.ts";
20
23
  import { libraryDir, libraryTools } from "./library.ts";
@@ -63,3 +66,179 @@ export function missingToolPrograms(tenant: string, workspace: string): MissingP
63
66
  scan(workspaceTools(tenant, workspace), "workspace");
64
67
  return out;
65
68
  }
69
+
70
+ // ---------------------------------------------------------------- imports
71
+
72
+ /**
73
+ * The Python standard library, top-level names: CPython's
74
+ * sys.stdlib_module_names, plus what 3.13 removed (the image's python is
75
+ * older than the list's). Never a declaration's business.
76
+ */
77
+ const PY_STDLIB = new Set(
78
+ `
79
+ abc aifc annotationlib antigravity argparse array ast asynchat asyncio
80
+ asyncore atexit audioop base64 bdb binascii bisect builtins bz2 cProfile
81
+ calendar cgi cgitb chunk cmath cmd code codecs codeop collections colorsys
82
+ compileall compression concurrent configparser contextlib contextvars copy
83
+ copyreg crypt csv ctypes curses dataclasses datetime dbm decimal difflib
84
+ dis distutils doctest email encodings ensurepip enum errno faulthandler
85
+ fcntl filecmp fileinput fnmatch fractions ftplib functools gc genericpath
86
+ getopt getpass gettext glob graphlib grp gzip hashlib heapq hmac html http
87
+ idlelib imaplib imghdr imp importlib inspect io ipaddress itertools json
88
+ keyword lib2to3 linecache locale logging lzma mailbox mailcap marshal math
89
+ mimetypes mmap modulefinder msilib msvcrt multiprocessing netrc nis nntplib
90
+ nt ntpath nturl2path numbers opcode operator optparse os ossaudiodev
91
+ pathlib pdb pickle pickletools pipes pkgutil platform plistlib poplib posix
92
+ posixpath pprint profile pstats pty pwd py_compile pyclbr pydoc pydoc_data
93
+ pyexpat queue quopri random re readline reprlib resource rlcompleter runpy
94
+ sched secrets select selectors shelve shlex shutil signal site smtpd
95
+ smtplib sndhdr socket socketserver spwd sqlite3 sre_compile sre_constants
96
+ sre_parse ssl stat statistics string stringprep struct subprocess sunau
97
+ symtable sys sysconfig syslog tabnanny tarfile telnetlib tempfile termios
98
+ textwrap this threading time timeit tkinter token tokenize tomllib trace
99
+ traceback tracemalloc tty turtle turtledemo types typing unicodedata
100
+ unittest urllib uu uuid venv warnings wave weakref webbrowser winreg
101
+ winsound wsgiref xdrlib xml xmlrpc zipapp zipfile zipimport zlib zoneinfo
102
+ `
103
+ .trim()
104
+ .split(/\s+/),
105
+ );
106
+
107
+ /** Distributions whose import name is not their own name, lower-cased. */
108
+ const PY_IMPORT_ALIASES: Record<string, string[]> = {
109
+ beautifulsoup4: ["bs4"],
110
+ pillow: ["PIL"],
111
+ pyyaml: ["yaml"],
112
+ "python-dateutil": ["dateutil"],
113
+ "scikit-learn": ["sklearn"],
114
+ "opencv-python": ["cv2"],
115
+ "opencv-python-headless": ["cv2"],
116
+ "python-docx": ["docx"],
117
+ "python-pptx": ["pptx"],
118
+ pymupdf: ["fitz", "pymupdf"],
119
+ "google-api-python-client": ["googleapiclient"],
120
+ "google-auth": ["google"],
121
+ protobuf: ["google"],
122
+ "psycopg2-binary": ["psycopg2"],
123
+ "psycopg-binary": ["psycopg"],
124
+ attrs: ["attr", "attrs"],
125
+ "python-dotenv": ["dotenv"],
126
+ pyjwt: ["jwt"],
127
+ "typing-extensions": ["typing_extensions"],
128
+ };
129
+
130
+ /** The names `import` would use for a declared distribution. */
131
+ export function importNamesOf(requirement: string): string[] {
132
+ const dist = requirement.split(/[[<>=!~@ ;]/)[0].trim().toLowerCase();
133
+ return PY_IMPORT_ALIASES[dist] ?? [dist.replace(/[-.]/g, "_")];
134
+ }
135
+
136
+ /** Top-level modules a Python file imports; relative imports excluded. */
137
+ export function pythonImports(source: string): string[] {
138
+ const out = new Set<string>();
139
+ for (const line of source.split("\n")) {
140
+ const from = /^\s*from\s+([A-Za-z_][\w.]*)\s+import\b/.exec(line);
141
+ if (from) out.add(from[1].split(".")[0]);
142
+ const imp = /^\s*import\s+([A-Za-z_][\w.]*(?:\s+as\s+\w+)?(?:\s*,\s*[A-Za-z_][\w.]*(?:\s+as\s+\w+)?)*)/.exec(line);
143
+ if (imp) for (const part of imp[1].split(",")) out.add(part.trim().split(/\s+/)[0].split(".")[0]);
144
+ }
145
+ return [...out];
146
+ }
147
+
148
+ /** Packages a JavaScript file imports or requires; relative and node: excluded. */
149
+ export function nodeImports(source: string): string[] {
150
+ const out = new Set<string>();
151
+ const re = /(?:\bfrom\s*|\bimport\s*\(\s*|\brequire\s*\(\s*|^\s*import\s+)["']([^"'./][^"']*)["']/gm;
152
+ for (const m of source.matchAll(re)) {
153
+ const spec = m[1];
154
+ if (spec.startsWith("node:")) continue;
155
+ out.add(spec.startsWith("@") ? spec.split("/").slice(0, 2).join("/") : spec.split("/")[0]);
156
+ }
157
+ return [...out];
158
+ }
159
+
160
+ /** What the runner image provides without a declaration (run-container.ts). */
161
+ const IMAGE_NODE_MODULES = new Set(["playwright", "playwright-core", "axe-core", "@foldrun/core"]);
162
+
163
+ export interface UndeclaredImport {
164
+ name: string;
165
+ scope: "workspace" | "account";
166
+ file: string;
167
+ modules: string[];
168
+ language: "python" | "node";
169
+ }
170
+
171
+ /**
172
+ * Script tools whose program imports a package nothing declares. Advisory: a
173
+ * warning, because the import may be satisfied some way this cannot see (a
174
+ * vendored module in a subfolder, a package the agent's own `runtime:`
175
+ * brings). What it catches is the common case — a tool that `import openpyxl`
176
+ * with no `runtime:` anywhere, which runs fine on the laptop that has it and
177
+ * fails on the platform on its first call.
178
+ *
179
+ * Declared means: the tool's own runtime (tool.md, requirements.txt,
180
+ * package.json), the workspace's AGENTS.md runtime, or any agent's runtime
181
+ * in the workspace — the union, so an agent-level declaration is trusted.
182
+ */
183
+ export function undeclaredImports(tenant: string, workspace: string): UndeclaredImport[] {
184
+ const dir = workspaceDir(tenant, workspace);
185
+ const from = path.join(dir, "agents", "_probe");
186
+ const libScripts = libraryDir(tenant, "scripts");
187
+ const sharedPy = new Set<string>();
188
+ const sharedNpm = new Set<string>();
189
+ const addRuntime = (raw: unknown) => {
190
+ const rt = parseRuntime(raw);
191
+ if (!rt) return;
192
+ for (const p of rt.packages) for (const n of importNamesOf(p)) sharedPy.add(n.toLowerCase());
193
+ for (const n of rt.npm) sharedNpm.add(n.replace(/^(@[^/]+\/[^@]+|[^@]+).*$/, "$1"));
194
+ };
195
+ const readFront = (file: string) => {
196
+ try {
197
+ return matter(fs.readFileSync(file, "utf8")).data as Record<string, unknown>;
198
+ } catch {
199
+ return {};
200
+ }
201
+ };
202
+ addRuntime(readFront(path.join(dir, "AGENTS.md")).runtime);
203
+ addRuntime(readFront(path.join(dir, "project.md")).runtime);
204
+ const agentsRoot = path.join(dir, "agents");
205
+ if (fs.existsSync(agentsRoot)) {
206
+ for (const a of fs.readdirSync(agentsRoot)) addRuntime(readFront(path.join(agentsRoot, a, "agent.md")).runtime);
207
+ }
208
+
209
+ const out: UndeclaredImport[] = [];
210
+ const scan = (defs: Record<string, ToolDef>, scope: "workspace" | "account") => {
211
+ for (const [name, def] of Object.entries(defs)) {
212
+ if (def.kind !== "script") continue;
213
+ const run = String((def.spec as { run?: unknown }).run ?? "");
214
+ if (!run) continue;
215
+ const file = resolveRunPath(from, run, libScripts);
216
+ if (!fs.existsSync(file)) continue;
217
+ const own = parseRuntime((def.spec as { runtime?: unknown }).runtime);
218
+ const ext = path.extname(file).toLowerCase();
219
+ const source = fs.readFileSync(file, "utf8");
220
+ const siblings = new Set(
221
+ fs.readdirSync(path.dirname(file)).map((f) => f.replace(/\.(py|mjs|cjs|js|ts)$/, "").toLowerCase()),
222
+ );
223
+ if (ext === ".py" || (!ext && /^#!.*python/.test(source))) {
224
+ const declared = new Set(sharedPy);
225
+ for (const p of own?.packages ?? []) for (const n of importNamesOf(p)) declared.add(n.toLowerCase());
226
+ const missing = pythonImports(source).filter(
227
+ (m) => !PY_STDLIB.has(m) && !declared.has(m.toLowerCase()) && !siblings.has(m.toLowerCase()),
228
+ );
229
+ if (missing.length) out.push({ name, scope, file: run, modules: missing, language: "python" });
230
+ } else if ([".js", ".mjs", ".cjs", ".ts"].includes(ext)) {
231
+ const declared = new Set(sharedNpm);
232
+ for (const n of own?.npm ?? []) declared.add(n.replace(/^(@[^/]+\/[^@]+|[^@]+).*$/, "$1"));
233
+ const missing = nodeImports(source).filter(
234
+ (m) => !builtinModules.includes(m) && !declared.has(m) && !IMAGE_NODE_MODULES.has(m),
235
+ );
236
+ if (missing.length) out.push({ name, scope, file: run, modules: missing, language: "node" });
237
+ }
238
+ }
239
+ };
240
+ scan(libraryTools(tenant), "account");
241
+ scan(workspaceTools(tenant, workspace), "workspace");
242
+ return out;
243
+ }
244
+
package/src/tool-test.ts CHANGED
@@ -19,13 +19,15 @@ import { spawn } from "node:child_process";
19
19
  import fs from "node:fs";
20
20
  import path from "node:path";
21
21
 
22
- import { resolveSecrets } from "./secrets.ts";
22
+ import { resolveSecrets, materializeSecrets } from "./secrets.ts";
23
23
  import { listAgents, workspaceDir, type ToolDef } from "./store.ts";
24
24
  import { libraryDir } from "./library.ts";
25
25
  import { secretsUsedByApi } from "./api-tools.ts";
26
+ import { attachOperations, prefetchOpenApi, type OperationSpec } from "./openapi.ts";
26
27
  import { commandFor, resolveRunPath } from "./script-tools.ts";
27
28
  import { parseRuntime, prepareRuntime } from "./runtime.ts";
28
29
  import { hostSafeEnv } from "./host-env.ts";
30
+ import { linkWorkspace, workspaceLinkEnv } from "./confine.ts";
29
31
 
30
32
  export interface ToolTestResult {
31
33
  ok: boolean;
@@ -60,6 +62,32 @@ const clip = (s: string) =>
60
62
  const substitute = (v: string, env: Record<string, string>) =>
61
63
  v.replace(/\$\{([A-Z0-9_]+)\}/g, (_, k) => env[k] ?? "");
62
64
 
65
+ /**
66
+ * The secrets as a run would hand them over: an oauth2 or service-account
67
+ * secret swapped for a live access token. Without this the tester passed the
68
+ * stored `@oauth2 {…}` recipe itself, so every tool behind Google OAuth failed
69
+ * its Test button while working in every run (gbp-desk review_candidates,
70
+ * 2026-09-28). A refresh that fails is the tester's answer, not a throw.
71
+ */
72
+ async function liveSecrets(
73
+ env: Record<string, string>,
74
+ ctx: { tenant: string; workspace?: string },
75
+ ): Promise<{ env: Record<string, string> } | { error: string }> {
76
+ try {
77
+ return { env: await materializeSecrets(env, ctx) };
78
+ } catch (err) {
79
+ return { error: err instanceof Error ? err.message : String(err) };
80
+ }
81
+ }
82
+
83
+ /** Does a concrete path fit an operation's template (`/contacts/{id}`)? */
84
+ function matchesTemplate(op: OperationSpec, concrete: string): boolean {
85
+ const re = new RegExp(
86
+ "^" + op.path.split(/\{[^/}]+\}/).map((part) => part.replace(/[.*+?^$()|[\]\\]/g, "\\$&")).join("[^/]+") + "$",
87
+ );
88
+ return re.test(concrete);
89
+ }
90
+
63
91
  export interface ToolTestInput {
64
92
  /** http: the path to try, appended to `base:`. */
65
93
  path?: string;
@@ -80,10 +108,58 @@ export async function testTool(
80
108
  if (def.kind === "http") {
81
109
  const api = def.spec;
82
110
  const needed = secretsUsedByApi(api);
83
- const { env, missing } = resolveSecrets(tenant, needed, workspace);
111
+ const { env: stored, missing } = resolveSecrets(tenant, needed, workspace);
112
+ const live = await liveSecrets(stored, { tenant, workspace });
113
+ if ("error" in live) {
114
+ return done({ ok: false, transport: "http", missingSecrets: missing, summary: "a secret could not be refreshed", detail: live.error });
115
+ }
116
+ const env = live.env;
84
117
 
85
118
  try {
86
- const rel = probePath.startsWith("/") || probePath === "" ? probePath : `/${probePath}`;
119
+ let rel = probePath.startsWith("/") || probePath === "" ? probePath : `/${probePath}`;
120
+ let method = api.methods.includes("GET") ? "GET" : api.methods[0];
121
+
122
+ // An `operations:` allowlist binds the tester exactly as it binds a run:
123
+ // the probe may only be a GET to one of the listed operations, and an
124
+ // allowlist that resolves nothing is a failure, as it is in a run. A
125
+ // tester that reached the whole API would be the one door the allowlist
126
+ // left open, and would report "working" for a tool a run cannot use.
127
+ if (api.operations?.length) {
128
+ const fetched = await prefetchOpenApi(tenant, api.openapi ? [api.openapi] : []);
129
+ const { specs, warnings } = attachOperations([api], tenant, workspaceDir(tenant, workspace));
130
+ const ops = specs[0].resolvedOperations ?? [];
131
+ if (!ops.length) {
132
+ return done({
133
+ ok: false, transport: "http", missingSecrets: missing,
134
+ summary: "its operations: allowlist resolved no operations, so a run gets no tools from it",
135
+ detail: [...fetched, ...warnings].join("\n"),
136
+ });
137
+ }
138
+ const listed = ops.map((o) => `${o.method} ${o.path}`).join("\n");
139
+ const gets = ops.filter((o) => o.method === "GET");
140
+ const pathOnly = rel.split("?")[0];
141
+ if (pathOnly === "") {
142
+ const plain = gets.find((o) => !o.path.includes("{"));
143
+ if (!plain) {
144
+ return done({
145
+ ok: false, transport: "http", missingSecrets: missing,
146
+ summary: gets.length
147
+ ? "every GET operation takes a parameter; give a path, like " + gets[0].path
148
+ : "the allowlist has no GET operation, and the tester only sends GET: it will not probe a write",
149
+ detail: `operations:\n${listed}`,
150
+ });
151
+ }
152
+ rel = plain.path;
153
+ } else if (!gets.some((o) => matchesTemplate(o, pathOnly))) {
154
+ return done({
155
+ ok: false, transport: "http", missingSecrets: missing,
156
+ summary: "that path is not one of the tool's GET operations",
157
+ detail: `path: ${pathOnly}\noperations:\n${listed}`,
158
+ });
159
+ }
160
+ method = "GET";
161
+ }
162
+
87
163
  const url = new URL(api.base + rel);
88
164
  // Same confinement the agent gets: a probe can't reach another host.
89
165
  if (!url.href.startsWith(api.base)) {
@@ -98,7 +174,6 @@ export async function testTool(
98
174
  const headers: Record<string, string> = {};
99
175
  for (const [k, v] of Object.entries(api.headers)) headers[k] = substitute(v, env);
100
176
 
101
- const method = api.methods.includes("GET") ? "GET" : api.methods[0];
102
177
  const res = await fetch(url, {
103
178
  method,
104
179
  headers,
@@ -203,7 +278,12 @@ export async function testTool(
203
278
  }
204
279
 
205
280
  const names = Array.isArray(def.spec.secrets) ? def.spec.secrets.map(String) : [];
206
- const { env, missing } = resolveSecrets(tenant, names, workspace);
281
+ const { env: stored, missing } = resolveSecrets(tenant, names, workspace);
282
+ const live = await liveSecrets(stored, { tenant, workspace });
283
+ if ("error" in live) {
284
+ return done({ ok: false, transport: "script", missingSecrets: missing, summary: "a secret could not be refreshed", detail: live.error });
285
+ }
286
+ const env = live.env;
207
287
  // The runtime's own choice of interpreter, not a second table beside it.
208
288
  const interpreter =
209
289
  typeof def.spec.interpreter === "string" ? def.spec.interpreter : undefined;
@@ -234,11 +314,24 @@ export async function testTool(
234
314
  file,
235
315
  toolRuntime.interpreters,
236
316
  );
237
- const { code, out } = await runOnce(cmd, [...args, ...flags], cwd, {
238
- ...hostSafeBaseEnv(),
239
- ...toolRuntime.env,
240
- ...env,
241
- });
317
+ // The `workspace` link a step holds, held for the test too — a tool
318
+ // whose code says `workspace/state/x` found nothing here while the same
319
+ // call worked in a flow. Only from an agent's folder: at the workspace
320
+ // root there is no depth for it to stand at (the note below says so).
321
+ const link = caller ? linkWorkspace(cwd, dir) : null;
322
+ let ran: Awaited<ReturnType<typeof runOnce>>;
323
+ try {
324
+ ran = await runOnce(cmd, [...args, ...flags], cwd, workspaceLinkEnv({
325
+ ...hostSafeBaseEnv(),
326
+ ...toolRuntime.env,
327
+ ...env,
328
+ }, cwd));
329
+ } finally {
330
+ link?.release();
331
+ // A private build (a concurrent one was wedged) is this test's alone.
332
+ toolRuntime.dispose?.();
333
+ }
334
+ const { code, out } = ran;
242
335
 
243
336
  // A script that needs arguments and got none has not failed — it has not
244
337
  // been tested. Saying "exited 2" would send someone looking for a bug.
@@ -254,7 +347,7 @@ export async function testTool(
254
347
  detail: clip(
255
348
  (caller
256
349
  ? `ran from agents/${caller.name}/, where a run runs\n\n`
257
- : `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`) +
350
+ : `ran from the workspace root — there is no agent to stand in for, so a script that reaches workspace/ or ../../ will not find the workspace there\n\n`) +
258
351
  (out || "(no output)"),
259
352
  ),
260
353
  });
@@ -294,7 +387,12 @@ export async function testTool(
294
387
  const named = Object.entries(spec.env ?? {})
295
388
  .map(([, v]) => String(v).match(/^\$\{([A-Z0-9_]+)\}$/)?.[1])
296
389
  .filter((n): n is string => Boolean(n));
297
- const { env, missing } = resolveSecrets(tenant, named, workspace);
390
+ const { env: stored, missing } = resolveSecrets(tenant, named, workspace);
391
+ const live = await liveSecrets(stored, { tenant, workspace });
392
+ if ("error" in live) {
393
+ return done({ ok: false, transport: "mcp", missingSecrets: missing, summary: "a secret could not be refreshed", detail: live.error });
394
+ }
395
+ const env = live.env;
298
396
  const childEnv: NodeJS.ProcessEnv = { ...hostSafeBaseEnv() };
299
397
  for (const [k, v] of Object.entries(spec.env ?? {})) childEnv[k] = substitute(String(v), env);
300
398