@foldrun/core 0.3.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 (175) hide show
  1. package/README.md +27 -10
  2. package/dist/index.d.ts +3 -0
  3. package/dist/index.js +3 -0
  4. package/dist/src/account.d.ts +45 -0
  5. package/dist/src/account.js +130 -0
  6. package/dist/src/api-tools.d.ts +8 -0
  7. package/dist/src/api-tools.js +22 -4
  8. package/dist/src/approvals.d.ts +104 -1
  9. package/dist/src/approvals.js +192 -2
  10. package/dist/src/browser-pod.d.ts +129 -0
  11. package/dist/src/browser-pod.js +284 -0
  12. package/dist/src/budget.d.ts +32 -0
  13. package/dist/src/budget.js +177 -0
  14. package/dist/src/catalog.js +3 -1
  15. package/dist/src/clock.d.ts +49 -0
  16. package/dist/src/clock.js +121 -0
  17. package/dist/src/completions.js +17 -8
  18. package/dist/src/confine.d.ts +22 -0
  19. package/dist/src/confine.js +224 -4
  20. package/dist/src/deploy.d.ts +33 -0
  21. package/dist/src/deploy.js +145 -6
  22. package/dist/src/egress.d.ts +26 -2
  23. package/dist/src/egress.js +8 -1
  24. package/dist/src/eol.d.ts +2 -0
  25. package/dist/src/eol.js +7 -0
  26. package/dist/src/evals.d.ts +9 -0
  27. package/dist/src/evals.js +26 -13
  28. package/dist/src/flow-lint.d.ts +12 -0
  29. package/dist/src/flow-lint.js +331 -1
  30. package/dist/src/flow-patterns.d.ts +141 -0
  31. package/dist/src/flow-patterns.js +568 -0
  32. package/dist/src/gitrepo.d.ts +18 -0
  33. package/dist/src/gitrepo.js +49 -0
  34. package/dist/src/host-env.d.ts +8 -0
  35. package/dist/src/host-env.js +48 -0
  36. package/dist/src/json-schema.d.ts +12 -0
  37. package/dist/src/json-schema.js +202 -0
  38. package/dist/src/kinds.js +1 -1
  39. package/dist/src/language.d.ts +17 -0
  40. package/dist/src/language.js +55 -0
  41. package/dist/src/layout.d.ts +69 -0
  42. package/dist/src/layout.js +200 -0
  43. package/dist/src/library.d.ts +9 -0
  44. package/dist/src/library.js +43 -4
  45. package/dist/src/limits.d.ts +84 -0
  46. package/dist/src/limits.js +287 -0
  47. package/dist/src/locale.d.ts +47 -0
  48. package/dist/src/locale.js +177 -0
  49. package/dist/src/mail.d.ts +88 -0
  50. package/dist/src/mail.js +245 -0
  51. package/dist/src/model-probe.d.ts +3 -0
  52. package/dist/src/model-probe.js +1 -0
  53. package/dist/src/notify.d.ts +132 -8
  54. package/dist/src/notify.js +360 -58
  55. package/dist/src/openapi.js +5 -3
  56. package/dist/src/operator.d.ts +201 -0
  57. package/dist/src/operator.js +251 -0
  58. package/dist/src/paths.d.ts +33 -0
  59. package/dist/src/paths.js +87 -0
  60. package/dist/src/platform.d.ts +144 -4
  61. package/dist/src/platform.js +27 -3
  62. package/dist/src/provider-health.d.ts +55 -0
  63. package/dist/src/provider-health.js +233 -0
  64. package/dist/src/providers.d.ts +481 -0
  65. package/dist/src/providers.js +1105 -6
  66. package/dist/src/refusal.d.ts +83 -0
  67. package/dist/src/refusal.js +281 -0
  68. package/dist/src/run-container.d.ts +206 -17
  69. package/dist/src/run-container.js +547 -80
  70. package/dist/src/runner.d.ts +179 -10
  71. package/dist/src/runner.js +1423 -169
  72. package/dist/src/runtime.d.ts +52 -0
  73. package/dist/src/runtime.js +493 -58
  74. package/dist/src/script-tools.d.ts +15 -2
  75. package/dist/src/script-tools.js +44 -4
  76. package/dist/src/secret-files.d.ts +6 -5
  77. package/dist/src/secret-files.js +24 -11
  78. package/dist/src/secret-health.d.ts +83 -0
  79. package/dist/src/secret-health.js +177 -0
  80. package/dist/src/secrets.d.ts +38 -1
  81. package/dist/src/secrets.js +197 -96
  82. package/dist/src/starter.d.ts +9 -0
  83. package/dist/src/starter.js +95 -11
  84. package/dist/src/step-exec.d.ts +106 -1
  85. package/dist/src/step-exec.js +558 -85
  86. package/dist/src/step-removal.d.ts +15 -0
  87. package/dist/src/step-removal.js +49 -0
  88. package/dist/src/storage.d.ts +23 -4
  89. package/dist/src/storage.js +125 -23
  90. package/dist/src/store.d.ts +492 -38
  91. package/dist/src/store.js +882 -69
  92. package/dist/src/subagents.d.ts +75 -0
  93. package/dist/src/subagents.js +149 -0
  94. package/dist/src/tar.js +2 -1
  95. package/dist/src/test-mode.d.ts +111 -0
  96. package/dist/src/test-mode.js +285 -0
  97. package/dist/src/tool-names.d.ts +13 -1
  98. package/dist/src/tool-names.js +38 -11
  99. package/dist/src/tool-programs.d.ts +26 -0
  100. package/dist/src/tool-programs.js +172 -0
  101. package/dist/src/tool-test.js +152 -33
  102. package/dist/src/translator.d.ts +8 -2
  103. package/dist/src/translator.js +71 -24
  104. package/dist/src/trigger-log.d.ts +43 -0
  105. package/dist/src/trigger-log.js +122 -0
  106. package/dist/src/triggers.d.ts +23 -0
  107. package/dist/src/triggers.js +101 -12
  108. package/dist/src/untrusted-fetch.d.ts +11 -0
  109. package/dist/src/untrusted-fetch.js +77 -0
  110. package/dist/src/web-actions.d.ts +91 -0
  111. package/dist/src/web-actions.js +199 -0
  112. package/dist/src/webhook.d.ts +19 -3
  113. package/dist/src/webhook.js +50 -11
  114. package/index.ts +3 -0
  115. package/package.json +5 -5
  116. package/src/account.ts +149 -0
  117. package/src/api-tools.ts +32 -4
  118. package/src/approvals.ts +253 -2
  119. package/src/browser-pod.ts +313 -0
  120. package/src/budget.ts +178 -0
  121. package/src/catalog.ts +3 -1
  122. package/src/clock.ts +149 -0
  123. package/src/completions.ts +17 -8
  124. package/src/confine.ts +224 -4
  125. package/src/deploy.ts +137 -6
  126. package/src/egress.ts +32 -4
  127. package/src/eol.ts +7 -0
  128. package/src/evals.ts +36 -11
  129. package/src/flow-lint.ts +371 -1
  130. package/src/flow-patterns.ts +597 -0
  131. package/src/gitrepo.ts +48 -0
  132. package/src/host-env.ts +51 -0
  133. package/src/json-schema.ts +184 -0
  134. package/src/kinds.ts +1 -1
  135. package/src/language.ts +62 -0
  136. package/src/layout.ts +228 -0
  137. package/src/library.ts +50 -4
  138. package/src/limits.ts +290 -0
  139. package/src/locale.ts +217 -0
  140. package/src/mail.ts +316 -0
  141. package/src/model-probe.ts +4 -0
  142. package/src/notify.ts +391 -44
  143. package/src/openapi.ts +5 -3
  144. package/src/operator.ts +300 -0
  145. package/src/paths.ts +83 -0
  146. package/src/platform.ts +158 -5
  147. package/src/provider-health.ts +275 -0
  148. package/src/providers.ts +1368 -6
  149. package/src/refusal.ts +320 -0
  150. package/src/run-container.ts +611 -76
  151. package/src/runner.ts +1463 -165
  152. package/src/runtime.ts +487 -55
  153. package/src/script-tools.ts +55 -6
  154. package/src/secret-files.ts +22 -10
  155. package/src/secret-health.ts +230 -0
  156. package/src/secrets.ts +191 -95
  157. package/src/starter.ts +96 -11
  158. package/src/step-exec.ts +548 -41
  159. package/src/step-removal.ts +59 -0
  160. package/src/storage.ts +149 -24
  161. package/src/store.ts +1227 -74
  162. package/src/subagents.ts +189 -0
  163. package/src/tar.ts +2 -1
  164. package/src/test-mode.ts +311 -0
  165. package/src/tool-names.ts +40 -11
  166. package/src/tool-programs.ts +179 -0
  167. package/src/tool-test.ts +159 -32
  168. package/src/translator.ts +74 -23
  169. package/src/trigger-log.ts +160 -0
  170. package/src/triggers.ts +102 -12
  171. package/src/untrusted-fetch.ts +88 -0
  172. package/src/web-actions.ts +237 -0
  173. package/src/webhook.ts +58 -11
  174. package/templates/hello/agents/curator/agent.md +1 -1
  175. package/templates/hello/agents/notetaker/agent.md +1 -1
@@ -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,12 +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";
29
+ import { hostSafeEnv } from "./host-env.ts";
30
+ import { linkWorkspace, workspaceLinkEnv } from "./confine.ts";
28
31
 
29
32
  export interface ToolTestResult {
30
33
  ok: boolean;
@@ -46,24 +49,11 @@ const TIMEOUT_MS = 15_000;
46
49
  // process.env. That env holds FOLDRUN_SECRET_KEY (the global install key that
47
50
  // signs every tenant's webhook tokens and git-push secrets), plus the S3/DB/
48
51
  // 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
- }
52
+ // script tool is cross-tenant compromise. The child instead starts from the
53
+ // allowlisted host base (host-env.ts — the same one every in-process script,
54
+ // verify shell and SDK Bash tool starts from) and gets the tool's own
55
+ // declared secrets layered on top by the callers.
56
+ const hostSafeBaseEnv = (): NodeJS.ProcessEnv => hostSafeEnv();
67
57
 
68
58
  const clip = (s: string) =>
69
59
  s.length > MAX_DETAIL ? `${s.slice(0, MAX_DETAIL)}\n… truncated` : s;
@@ -72,6 +62,32 @@ const clip = (s: string) =>
72
62
  const substitute = (v: string, env: Record<string, string>) =>
73
63
  v.replace(/\$\{([A-Z0-9_]+)\}/g, (_, k) => env[k] ?? "");
74
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
+
75
91
  export interface ToolTestInput {
76
92
  /** http: the path to try, appended to `base:`. */
77
93
  path?: string;
@@ -92,10 +108,58 @@ export async function testTool(
92
108
  if (def.kind === "http") {
93
109
  const api = def.spec;
94
110
  const needed = secretsUsedByApi(api);
95
- 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;
96
117
 
97
118
  try {
98
- 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
+
99
163
  const url = new URL(api.base + rel);
100
164
  // Same confinement the agent gets: a probe can't reach another host.
101
165
  if (!url.href.startsWith(api.base)) {
@@ -110,7 +174,6 @@ export async function testTool(
110
174
  const headers: Record<string, string> = {};
111
175
  for (const [k, v] of Object.entries(api.headers)) headers[k] = substitute(v, env);
112
176
 
113
- const method = api.methods.includes("GET") ? "GET" : api.methods[0];
114
177
  const res = await fetch(url, {
115
178
  method,
116
179
  headers,
@@ -144,7 +207,7 @@ export async function testTool(
144
207
  }
145
208
 
146
209
  if (def.kind === "script") {
147
- const run = String(def.spec.run ?? "");
210
+ let run = String(def.spec.run ?? "");
148
211
  // A run's working directory is the calling AGENT's folder, not the
149
212
  // workspace root — and scripts rely on that. `path.resolve(cwd, "..",
150
213
  // "..", "state", f)` is the ordinary way to reach workspace state, and
@@ -163,7 +226,48 @@ export async function testTool(
163
226
  // workspace root is wrong but it exists, and the note says so rather
164
227
  // than failing a test over a workspace that cannot run anything yet.
165
228
  const cwd = caller ? path.join(dir, "agents", caller.name) : dir;
166
- const file = resolveRunPath(path.join(dir, "agents", "_probe"), run, libraryDir(tenant, "scripts"));
229
+
230
+ // A single-file tool carries its program in a fenced block rather than
231
+ // in a file: `run:` is empty and `code:` holds it. The RUNTIME writes it
232
+ // out at call time (script-tools.ts) and has always worked; the tester
233
+ // only ever looked for a file, so the Test button failed for every one
234
+ // of them while the same tool ran fine in a flow — which is the worst
235
+ // possible way round, since that button is where a developer goes to
236
+ // find out whether their work is broken.
237
+ //
238
+ // Materialised the same way and in the same place a call would, so the
239
+ // test runs the identical program from the identical directory.
240
+ const inlineCode = typeof def.spec.code === "string" ? def.spec.code : "";
241
+ const inline = !run && !!inlineCode;
242
+ if (inline && !caller) {
243
+ // Nowhere to stand: the file would land at the workspace root, which
244
+ // is not where a run puts it and not somewhere a deploy diff should
245
+ // find it. Say what is missing instead.
246
+ return done({
247
+ ok: false, transport: "script", missingSecrets: [],
248
+ summary: "no agent to test it from",
249
+ detail: "This tool carries its code inline and is materialised inside the calling agent's folder. Grant it to an agent (tools:) and test again.",
250
+ });
251
+ }
252
+ if (inline) {
253
+ const ext = typeof def.spec.codeExt === "string" ? def.spec.codeExt : ".mjs";
254
+ const codeDir = path.join(cwd, ".tool-code");
255
+ try {
256
+ fs.mkdirSync(codeDir, { recursive: true });
257
+ fs.writeFileSync(path.join(codeDir, `${def.name}${ext}`), inlineCode, { mode: 0o755 });
258
+ run = `.tool-code/${def.name}${ext}`;
259
+ } catch (err) {
260
+ return done({
261
+ ok: false, transport: "script", missingSecrets: [],
262
+ summary: "its inline code could not be written",
263
+ detail: err instanceof Error ? err.message : String(err),
264
+ });
265
+ }
266
+ }
267
+
268
+ // A materialised file was just written into cwd, so it resolves from
269
+ // there. A declared `run:` keeps resolving exactly as it always has.
270
+ const file = resolveRunPath(inline ? cwd : path.join(dir, "agents", "_probe"), run, libraryDir(tenant, "scripts"));
167
271
 
168
272
  if (!fs.existsSync(file)) {
169
273
  return done({
@@ -174,7 +278,12 @@ export async function testTool(
174
278
  }
175
279
 
176
280
  const names = Array.isArray(def.spec.secrets) ? def.spec.secrets.map(String) : [];
177
- 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;
178
287
  // The runtime's own choice of interpreter, not a second table beside it.
179
288
  const interpreter =
180
289
  typeof def.spec.interpreter === "string" ? def.spec.interpreter : undefined;
@@ -205,11 +314,24 @@ export async function testTool(
205
314
  file,
206
315
  toolRuntime.interpreters,
207
316
  );
208
- const { code, out } = await runOnce(cmd, [...args, ...flags], cwd, {
209
- ...hostSafeBaseEnv(),
210
- ...toolRuntime.env,
211
- ...env,
212
- });
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;
213
335
 
214
336
  // A script that needs arguments and got none has not failed — it has not
215
337
  // been tested. Saying "exited 2" would send someone looking for a bug.
@@ -225,7 +347,7 @@ export async function testTool(
225
347
  detail: clip(
226
348
  (caller
227
349
  ? `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`) +
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`) +
229
351
  (out || "(no output)"),
230
352
  ),
231
353
  });
@@ -265,7 +387,12 @@ export async function testTool(
265
387
  const named = Object.entries(spec.env ?? {})
266
388
  .map(([, v]) => String(v).match(/^\$\{([A-Z0-9_]+)\}$/)?.[1])
267
389
  .filter((n): n is string => Boolean(n));
268
- 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;
269
396
  const childEnv: NodeJS.ProcessEnv = { ...hostSafeBaseEnv() };
270
397
  for (const [k, v] of Object.entries(spec.env ?? {})) childEnv[k] = substitute(String(v), env);
271
398