@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
@@ -12,27 +12,40 @@
12
12
  import { refList, refNames } from "./refs.js";
13
13
  /** Groups: one word an author can hold in their head, expanded to SDK names. */
14
14
  export const TOOL_MAP = {
15
- // `web` includes Anthropic's server-side WebSearch, the one built-in tool
16
- // that runs off the box and bills per call. `fetch` is the local half only:
17
- // an agent that searches through the account's own `websearch` tool and
18
- // reads pages with WebFetch never leaves the box for anything but tokens.
19
- web: ["WebSearch", "WebFetch"],
20
- fetch: ["WebFetch"],
21
- // `read` is deliberately separate from `files`: an agent that may inspect a
22
- // repository but must never modify it is a real and common design.
15
+ // The web is one tool of ours, `web` — search, fetch, browse, crawl, map,
16
+ // extract, answer, monitor, served from the gallery and on the run record
17
+ // whichever model is driving. So `web` is deliberately NOT here: a name in
18
+ // TOOL_MAP shadows a real tool of the same name (see runner.ts).
19
+ // `read` is deliberately separate from `write`: an agent that may inspect a
20
+ // repository but must never modify it is a real and common design. The pair
21
+ // says what it grants. `files` and `bash` were the old names for `write`
22
+ // and `code` (`code`, because it is not only shell — anything in the
23
+ // sandbox runs through it); both are retired, below.
23
24
  read: ["Read", "Glob", "Grep"],
25
+ write: ["Read", "Write", "Edit", "Glob", "Grep"],
24
26
  files: ["Read", "Write", "Edit", "Glob", "Grep"],
27
+ code: ["Bash"],
25
28
  bash: ["Bash"],
26
29
  };
30
+ /** Names an author may no longer write, and what to write instead. They are
31
+ * still granted, so nothing deployed breaks; `check` and the run log say the
32
+ * rewrite. */
33
+ export const RETIRED_TOOLS = {
34
+ files: "write",
35
+ bash: "code",
36
+ };
27
37
  /** Exact SDK tool names, accepted alongside the group aliases so a Claude Code
28
38
  * subagent's `tools: Read, Grep` works unchanged. The aliases exist because
29
- * vendors rename tools; `web` survives a rename that `WebSearch` would not. */
39
+ * vendors rename tools; `write` survives a rename that `Write` would not.
40
+ * The SDK's WebSearch and WebFetch are not here: the web is our `web` tool,
41
+ * and a model provider's own search (`web: {search: zai}`) is the runtime's
42
+ * to swap in, not the author's to name. */
30
43
  export const BUILTIN_TOOLS = new Set([
31
44
  "Read", "Write", "Edit", "MultiEdit", "Glob", "Grep", "Bash",
32
- "WebSearch", "WebFetch", "NotebookEdit", "TodoWrite",
45
+ "NotebookEdit", "TodoWrite",
33
46
  ]);
34
47
  /** The platform's own groups, served in-process rather than by the SDK. */
35
- export const PLATFORM_GROUPS = new Set(["search", "history", "desks"]);
48
+ export const PLATFORM_GROUPS = new Set(["search", "history", "desks", "ask"]);
36
49
  /** Would this name resolve to something the runtime provides? Built-ins win a
37
50
  * clash, so a tool file of the same name is shadowed rather than granted. */
38
51
  export function isRuntimeTool(name) {
@@ -73,6 +86,20 @@ export function ownToolNames(front) {
73
86
  export function legacyUseNames(front) {
74
87
  return refNames(front.use);
75
88
  }
89
+ /** Retired names written bare in `tools:`, deduped. A `[[link]]` is the
90
+ * author's own file, so it is never one of these. */
91
+ export function retiredToolNames(front) {
92
+ const out = [];
93
+ for (const ref of toolRefs(front)) {
94
+ if (!ref.linked && RETIRED_TOOLS[ref.name] && !out.includes(ref.name))
95
+ out.push(ref.name);
96
+ }
97
+ return out;
98
+ }
99
+ /** The one sentence every reader of a retired name says. */
100
+ export function retiredToolError(name) {
101
+ 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.`;
102
+ }
76
103
  /** The one sentence every reader of a `use:` key says. */
77
104
  export function legacyUseError(names) {
78
105
  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.`;
@@ -16,3 +16,29 @@ export interface MissingProgram {
16
16
  * call a working account tool broken.
17
17
  */
18
18
  export declare function missingToolPrograms(tenant: string, workspace: string): MissingProgram[];
19
+ /** The names `import` would use for a declared distribution. */
20
+ export declare function importNamesOf(requirement: string): string[];
21
+ /** Top-level modules a Python file imports; relative imports excluded. */
22
+ export declare function pythonImports(source: string): string[];
23
+ /** Packages a JavaScript file imports or requires; relative and node: excluded. */
24
+ export declare function nodeImports(source: string): string[];
25
+ export interface UndeclaredImport {
26
+ name: string;
27
+ scope: "workspace" | "account";
28
+ file: string;
29
+ modules: string[];
30
+ language: "python" | "node";
31
+ }
32
+ /**
33
+ * Script tools whose program imports a package nothing declares. Advisory: a
34
+ * warning, because the import may be satisfied some way this cannot see (a
35
+ * vendored module in a subfolder, a package the agent's own `runtime:`
36
+ * brings). What it catches is the common case — a tool that `import openpyxl`
37
+ * with no `runtime:` anywhere, which runs fine on the laptop that has it and
38
+ * fails on the platform on its first call.
39
+ *
40
+ * Declared means: the tool's own runtime (tool.md, requirements.txt,
41
+ * package.json), the workspace's AGENTS.md runtime, or any agent's runtime
42
+ * in the workspace — the union, so an agent-level declaration is trusted.
43
+ */
44
+ export declare function undeclaredImports(tenant: string, workspace: string): UndeclaredImport[];
@@ -14,6 +14,9 @@
14
14
  // runner — passing a tool that cannot run, or failing one that can.
15
15
  import fs from "node:fs";
16
16
  import path from "node:path";
17
+ import { builtinModules } from "node:module";
18
+ import matter from "gray-matter";
19
+ import { parseRuntime } from "./runtime.js";
17
20
  import { workspaceDir, workspaceTools } from "./store.js";
18
21
  import { libraryDir, libraryTools } from "./library.js";
19
22
  import { resolveRunPath } from "./script-tools.js";
@@ -50,3 +53,172 @@ export function missingToolPrograms(tenant, workspace) {
50
53
  scan(workspaceTools(tenant, workspace), "workspace");
51
54
  return out;
52
55
  }
56
+ // ---------------------------------------------------------------- imports
57
+ /**
58
+ * The Python standard library, top-level names: CPython's
59
+ * sys.stdlib_module_names, plus what 3.13 removed (the image's python is
60
+ * older than the list's). Never a declaration's business.
61
+ */
62
+ const PY_STDLIB = new Set(`
63
+ abc aifc annotationlib antigravity argparse array ast asynchat asyncio
64
+ asyncore atexit audioop base64 bdb binascii bisect builtins bz2 cProfile
65
+ calendar cgi cgitb chunk cmath cmd code codecs codeop collections colorsys
66
+ compileall compression concurrent configparser contextlib contextvars copy
67
+ copyreg crypt csv ctypes curses dataclasses datetime dbm decimal difflib
68
+ dis distutils doctest email encodings ensurepip enum errno faulthandler
69
+ fcntl filecmp fileinput fnmatch fractions ftplib functools gc genericpath
70
+ getopt getpass gettext glob graphlib grp gzip hashlib heapq hmac html http
71
+ idlelib imaplib imghdr imp importlib inspect io ipaddress itertools json
72
+ keyword lib2to3 linecache locale logging lzma mailbox mailcap marshal math
73
+ mimetypes mmap modulefinder msilib msvcrt multiprocessing netrc nis nntplib
74
+ nt ntpath nturl2path numbers opcode operator optparse os ossaudiodev
75
+ pathlib pdb pickle pickletools pipes pkgutil platform plistlib poplib posix
76
+ posixpath pprint profile pstats pty pwd py_compile pyclbr pydoc pydoc_data
77
+ pyexpat queue quopri random re readline reprlib resource rlcompleter runpy
78
+ sched secrets select selectors shelve shlex shutil signal site smtpd
79
+ smtplib sndhdr socket socketserver spwd sqlite3 sre_compile sre_constants
80
+ sre_parse ssl stat statistics string stringprep struct subprocess sunau
81
+ symtable sys sysconfig syslog tabnanny tarfile telnetlib tempfile termios
82
+ textwrap this threading time timeit tkinter token tokenize tomllib trace
83
+ traceback tracemalloc tty turtle turtledemo types typing unicodedata
84
+ unittest urllib uu uuid venv warnings wave weakref webbrowser winreg
85
+ winsound wsgiref xdrlib xml xmlrpc zipapp zipfile zipimport zlib zoneinfo
86
+ `
87
+ .trim()
88
+ .split(/\s+/));
89
+ /** Distributions whose import name is not their own name, lower-cased. */
90
+ const PY_IMPORT_ALIASES = {
91
+ beautifulsoup4: ["bs4"],
92
+ pillow: ["PIL"],
93
+ pyyaml: ["yaml"],
94
+ "python-dateutil": ["dateutil"],
95
+ "scikit-learn": ["sklearn"],
96
+ "opencv-python": ["cv2"],
97
+ "opencv-python-headless": ["cv2"],
98
+ "python-docx": ["docx"],
99
+ "python-pptx": ["pptx"],
100
+ pymupdf: ["fitz", "pymupdf"],
101
+ "google-api-python-client": ["googleapiclient"],
102
+ "google-auth": ["google"],
103
+ protobuf: ["google"],
104
+ "psycopg2-binary": ["psycopg2"],
105
+ "psycopg-binary": ["psycopg"],
106
+ attrs: ["attr", "attrs"],
107
+ "python-dotenv": ["dotenv"],
108
+ pyjwt: ["jwt"],
109
+ "typing-extensions": ["typing_extensions"],
110
+ };
111
+ /** The names `import` would use for a declared distribution. */
112
+ export function importNamesOf(requirement) {
113
+ const dist = requirement.split(/[[<>=!~@ ;]/)[0].trim().toLowerCase();
114
+ return PY_IMPORT_ALIASES[dist] ?? [dist.replace(/[-.]/g, "_")];
115
+ }
116
+ /** Top-level modules a Python file imports; relative imports excluded. */
117
+ export function pythonImports(source) {
118
+ const out = new Set();
119
+ for (const line of source.split("\n")) {
120
+ const from = /^\s*from\s+([A-Za-z_][\w.]*)\s+import\b/.exec(line);
121
+ if (from)
122
+ out.add(from[1].split(".")[0]);
123
+ 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);
124
+ if (imp)
125
+ for (const part of imp[1].split(","))
126
+ out.add(part.trim().split(/\s+/)[0].split(".")[0]);
127
+ }
128
+ return [...out];
129
+ }
130
+ /** Packages a JavaScript file imports or requires; relative and node: excluded. */
131
+ export function nodeImports(source) {
132
+ const out = new Set();
133
+ const re = /(?:\bfrom\s*|\bimport\s*\(\s*|\brequire\s*\(\s*|^\s*import\s+)["']([^"'./][^"']*)["']/gm;
134
+ for (const m of source.matchAll(re)) {
135
+ const spec = m[1];
136
+ if (spec.startsWith("node:"))
137
+ continue;
138
+ out.add(spec.startsWith("@") ? spec.split("/").slice(0, 2).join("/") : spec.split("/")[0]);
139
+ }
140
+ return [...out];
141
+ }
142
+ /** What the runner image provides without a declaration (run-container.ts). */
143
+ const IMAGE_NODE_MODULES = new Set(["playwright", "playwright-core", "axe-core", "@foldrun/core"]);
144
+ /**
145
+ * Script tools whose program imports a package nothing declares. Advisory: a
146
+ * warning, because the import may be satisfied some way this cannot see (a
147
+ * vendored module in a subfolder, a package the agent's own `runtime:`
148
+ * brings). What it catches is the common case — a tool that `import openpyxl`
149
+ * with no `runtime:` anywhere, which runs fine on the laptop that has it and
150
+ * fails on the platform on its first call.
151
+ *
152
+ * Declared means: the tool's own runtime (tool.md, requirements.txt,
153
+ * package.json), the workspace's AGENTS.md runtime, or any agent's runtime
154
+ * in the workspace — the union, so an agent-level declaration is trusted.
155
+ */
156
+ export function undeclaredImports(tenant, workspace) {
157
+ const dir = workspaceDir(tenant, workspace);
158
+ const from = path.join(dir, "agents", "_probe");
159
+ const libScripts = libraryDir(tenant, "scripts");
160
+ const sharedPy = new Set();
161
+ const sharedNpm = new Set();
162
+ const addRuntime = (raw) => {
163
+ const rt = parseRuntime(raw);
164
+ if (!rt)
165
+ return;
166
+ for (const p of rt.packages)
167
+ for (const n of importNamesOf(p))
168
+ sharedPy.add(n.toLowerCase());
169
+ for (const n of rt.npm)
170
+ sharedNpm.add(n.replace(/^(@[^/]+\/[^@]+|[^@]+).*$/, "$1"));
171
+ };
172
+ const readFront = (file) => {
173
+ try {
174
+ return matter(fs.readFileSync(file, "utf8")).data;
175
+ }
176
+ catch {
177
+ return {};
178
+ }
179
+ };
180
+ addRuntime(readFront(path.join(dir, "AGENTS.md")).runtime);
181
+ addRuntime(readFront(path.join(dir, "project.md")).runtime);
182
+ const agentsRoot = path.join(dir, "agents");
183
+ if (fs.existsSync(agentsRoot)) {
184
+ for (const a of fs.readdirSync(agentsRoot))
185
+ addRuntime(readFront(path.join(agentsRoot, a, "agent.md")).runtime);
186
+ }
187
+ const out = [];
188
+ const scan = (defs, scope) => {
189
+ for (const [name, def] of Object.entries(defs)) {
190
+ if (def.kind !== "script")
191
+ continue;
192
+ const run = String(def.spec.run ?? "");
193
+ if (!run)
194
+ continue;
195
+ const file = resolveRunPath(from, run, libScripts);
196
+ if (!fs.existsSync(file))
197
+ continue;
198
+ const own = parseRuntime(def.spec.runtime);
199
+ const ext = path.extname(file).toLowerCase();
200
+ const source = fs.readFileSync(file, "utf8");
201
+ const siblings = new Set(fs.readdirSync(path.dirname(file)).map((f) => f.replace(/\.(py|mjs|cjs|js|ts)$/, "").toLowerCase()));
202
+ if (ext === ".py" || (!ext && /^#!.*python/.test(source))) {
203
+ const declared = new Set(sharedPy);
204
+ for (const p of own?.packages ?? [])
205
+ for (const n of importNamesOf(p))
206
+ declared.add(n.toLowerCase());
207
+ const missing = pythonImports(source).filter((m) => !PY_STDLIB.has(m) && !declared.has(m.toLowerCase()) && !siblings.has(m.toLowerCase()));
208
+ if (missing.length)
209
+ out.push({ name, scope, file: run, modules: missing, language: "python" });
210
+ }
211
+ else if ([".js", ".mjs", ".cjs", ".ts"].includes(ext)) {
212
+ const declared = new Set(sharedNpm);
213
+ for (const n of own?.npm ?? [])
214
+ declared.add(n.replace(/^(@[^/]+\/[^@]+|[^@]+).*$/, "$1"));
215
+ const missing = nodeImports(source).filter((m) => !builtinModules.includes(m) && !declared.has(m) && !IMAGE_NODE_MODULES.has(m));
216
+ if (missing.length)
217
+ out.push({ name, scope, file: run, modules: missing, language: "node" });
218
+ }
219
+ }
220
+ };
221
+ scan(libraryTools(tenant), "account");
222
+ scan(workspaceTools(tenant, workspace), "workspace");
223
+ return out;
224
+ }
@@ -17,12 +17,15 @@
17
17
  import { spawn } from "node:child_process";
18
18
  import fs from "node:fs";
19
19
  import path from "node:path";
20
- import { resolveSecrets } from "./secrets.js";
20
+ import { resolveSecrets, materializeSecrets } from "./secrets.js";
21
21
  import { listAgents, workspaceDir } from "./store.js";
22
22
  import { libraryDir } from "./library.js";
23
23
  import { secretsUsedByApi } from "./api-tools.js";
24
+ import { attachOperations, prefetchOpenApi } from "./openapi.js";
24
25
  import { commandFor, resolveRunPath } from "./script-tools.js";
25
26
  import { parseRuntime, prepareRuntime } from "./runtime.js";
27
+ import { hostSafeEnv } from "./host-env.js";
28
+ import { linkWorkspace, workspaceLinkEnv } from "./confine.js";
26
29
  const MAX_DETAIL = 4000;
27
30
  const TIMEOUT_MS = 15_000;
28
31
  // A tool test runs tenant-authored code directly on the host — real runs are
@@ -30,28 +33,34 @@ const TIMEOUT_MS = 15_000;
30
33
  // process.env. That env holds FOLDRUN_SECRET_KEY (the global install key that
31
34
  // signs every tenant's webhook tokens and git-push secrets), plus the S3/DB/
32
35
  // Stripe/GitHub credentials: handing them to a `console.log(process.env)`
33
- // script tool is cross-tenant compromise. The child instead starts from a
34
- // minimal host base — only enough for an interpreter to be found and run —
35
- // and gets the tool's own declared secrets layered on top by the callers.
36
- const HOST_BASE_ENV_KEYS = [
37
- "PATH", "HOME", "TMPDIR", "TMP", "TEMP", "LANG", "LANGUAGE",
38
- "LC_ALL", "LC_CTYPE", "TZ", "TERM", "SHELL", "USER", "LOGNAME",
39
- "NODE_ENV", // not a secret; some interpreters/tools read it.
40
- // Windows equivalents, harmless elsewhere.
41
- "SYSTEMROOT", "COMSPEC", "PATHEXT", "WINDIR",
42
- ];
43
- function hostSafeBaseEnv() {
44
- const base = {};
45
- for (const k of HOST_BASE_ENV_KEYS) {
46
- const v = process.env[k];
47
- if (v !== undefined)
48
- base[k] = v;
49
- }
50
- return base;
51
- }
36
+ // script tool is cross-tenant compromise. The child instead starts from the
37
+ // allowlisted host base (host-env.ts — the same one every in-process script,
38
+ // verify shell and SDK Bash tool starts from) and gets the tool's own
39
+ // declared secrets layered on top by the callers.
40
+ const hostSafeBaseEnv = () => hostSafeEnv();
52
41
  const clip = (s) => s.length > MAX_DETAIL ? `${s.slice(0, MAX_DETAIL)}\n… truncated` : s;
53
42
  /** `${NAME}` → value, for headers and query. Same rule the runtime uses. */
54
43
  const substitute = (v, env) => v.replace(/\$\{([A-Z0-9_]+)\}/g, (_, k) => env[k] ?? "");
44
+ /**
45
+ * The secrets as a run would hand them over: an oauth2 or service-account
46
+ * secret swapped for a live access token. Without this the tester passed the
47
+ * stored `@oauth2 {…}` recipe itself, so every tool behind Google OAuth failed
48
+ * its Test button while working in every run (gbp-desk review_candidates,
49
+ * 2026-09-28). A refresh that fails is the tester's answer, not a throw.
50
+ */
51
+ async function liveSecrets(env, ctx) {
52
+ try {
53
+ return { env: await materializeSecrets(env, ctx) };
54
+ }
55
+ catch (err) {
56
+ return { error: err instanceof Error ? err.message : String(err) };
57
+ }
58
+ }
59
+ /** Does a concrete path fit an operation's template (`/contacts/{id}`)? */
60
+ function matchesTemplate(op, concrete) {
61
+ const re = new RegExp("^" + op.path.split(/\{[^/}]+\}/).map((part) => part.replace(/[.*+?^$()|[\]\\]/g, "\\$&")).join("[^/]+") + "$");
62
+ return re.test(concrete);
63
+ }
55
64
  export async function testTool(tenant, workspace, def, input = {}) {
56
65
  const probePath = input.path ?? "";
57
66
  const started = Date.now();
@@ -59,9 +68,56 @@ export async function testTool(tenant, workspace, def, input = {}) {
59
68
  if (def.kind === "http") {
60
69
  const api = def.spec;
61
70
  const needed = secretsUsedByApi(api);
62
- const { env, missing } = resolveSecrets(tenant, needed, workspace);
71
+ const { env: stored, missing } = resolveSecrets(tenant, needed, workspace);
72
+ const live = await liveSecrets(stored, { tenant, workspace });
73
+ if ("error" in live) {
74
+ return done({ ok: false, transport: "http", missingSecrets: missing, summary: "a secret could not be refreshed", detail: live.error });
75
+ }
76
+ const env = live.env;
63
77
  try {
64
- const rel = probePath.startsWith("/") || probePath === "" ? probePath : `/${probePath}`;
78
+ let rel = probePath.startsWith("/") || probePath === "" ? probePath : `/${probePath}`;
79
+ let method = api.methods.includes("GET") ? "GET" : api.methods[0];
80
+ // An `operations:` allowlist binds the tester exactly as it binds a run:
81
+ // the probe may only be a GET to one of the listed operations, and an
82
+ // allowlist that resolves nothing is a failure, as it is in a run. A
83
+ // tester that reached the whole API would be the one door the allowlist
84
+ // left open, and would report "working" for a tool a run cannot use.
85
+ if (api.operations?.length) {
86
+ const fetched = await prefetchOpenApi(tenant, api.openapi ? [api.openapi] : []);
87
+ const { specs, warnings } = attachOperations([api], tenant, workspaceDir(tenant, workspace));
88
+ const ops = specs[0].resolvedOperations ?? [];
89
+ if (!ops.length) {
90
+ return done({
91
+ ok: false, transport: "http", missingSecrets: missing,
92
+ summary: "its operations: allowlist resolved no operations, so a run gets no tools from it",
93
+ detail: [...fetched, ...warnings].join("\n"),
94
+ });
95
+ }
96
+ const listed = ops.map((o) => `${o.method} ${o.path}`).join("\n");
97
+ const gets = ops.filter((o) => o.method === "GET");
98
+ const pathOnly = rel.split("?")[0];
99
+ if (pathOnly === "") {
100
+ const plain = gets.find((o) => !o.path.includes("{"));
101
+ if (!plain) {
102
+ return done({
103
+ ok: false, transport: "http", missingSecrets: missing,
104
+ summary: gets.length
105
+ ? "every GET operation takes a parameter; give a path, like " + gets[0].path
106
+ : "the allowlist has no GET operation, and the tester only sends GET: it will not probe a write",
107
+ detail: `operations:\n${listed}`,
108
+ });
109
+ }
110
+ rel = plain.path;
111
+ }
112
+ else if (!gets.some((o) => matchesTemplate(o, pathOnly))) {
113
+ return done({
114
+ ok: false, transport: "http", missingSecrets: missing,
115
+ summary: "that path is not one of the tool's GET operations",
116
+ detail: `path: ${pathOnly}\noperations:\n${listed}`,
117
+ });
118
+ }
119
+ method = "GET";
120
+ }
65
121
  const url = new URL(api.base + rel);
66
122
  // Same confinement the agent gets: a probe can't reach another host.
67
123
  if (!url.href.startsWith(api.base)) {
@@ -76,7 +132,6 @@ export async function testTool(tenant, workspace, def, input = {}) {
76
132
  const headers = {};
77
133
  for (const [k, v] of Object.entries(api.headers))
78
134
  headers[k] = substitute(v, env);
79
- const method = api.methods.includes("GET") ? "GET" : api.methods[0];
80
135
  const res = await fetch(url, {
81
136
  method,
82
137
  headers,
@@ -108,7 +163,7 @@ export async function testTool(tenant, workspace, def, input = {}) {
108
163
  }
109
164
  }
110
165
  if (def.kind === "script") {
111
- const run = String(def.spec.run ?? "");
166
+ let run = String(def.spec.run ?? "");
112
167
  // A run's working directory is the calling AGENT's folder, not the
113
168
  // workspace root — and scripts rely on that. `path.resolve(cwd, "..",
114
169
  // "..", "state", f)` is the ordinary way to reach workspace state, and
@@ -126,7 +181,47 @@ export async function testTool(tenant, workspace, def, input = {}) {
126
181
  // workspace root is wrong but it exists, and the note says so rather
127
182
  // than failing a test over a workspace that cannot run anything yet.
128
183
  const cwd = caller ? path.join(dir, "agents", caller.name) : dir;
129
- const file = resolveRunPath(path.join(dir, "agents", "_probe"), run, libraryDir(tenant, "scripts"));
184
+ // A single-file tool carries its program in a fenced block rather than
185
+ // in a file: `run:` is empty and `code:` holds it. The RUNTIME writes it
186
+ // out at call time (script-tools.ts) and has always worked; the tester
187
+ // only ever looked for a file, so the Test button failed for every one
188
+ // of them while the same tool ran fine in a flow — which is the worst
189
+ // possible way round, since that button is where a developer goes to
190
+ // find out whether their work is broken.
191
+ //
192
+ // Materialised the same way and in the same place a call would, so the
193
+ // test runs the identical program from the identical directory.
194
+ const inlineCode = typeof def.spec.code === "string" ? def.spec.code : "";
195
+ const inline = !run && !!inlineCode;
196
+ if (inline && !caller) {
197
+ // Nowhere to stand: the file would land at the workspace root, which
198
+ // is not where a run puts it and not somewhere a deploy diff should
199
+ // find it. Say what is missing instead.
200
+ return done({
201
+ ok: false, transport: "script", missingSecrets: [],
202
+ summary: "no agent to test it from",
203
+ 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.",
204
+ });
205
+ }
206
+ if (inline) {
207
+ const ext = typeof def.spec.codeExt === "string" ? def.spec.codeExt : ".mjs";
208
+ const codeDir = path.join(cwd, ".tool-code");
209
+ try {
210
+ fs.mkdirSync(codeDir, { recursive: true });
211
+ fs.writeFileSync(path.join(codeDir, `${def.name}${ext}`), inlineCode, { mode: 0o755 });
212
+ run = `.tool-code/${def.name}${ext}`;
213
+ }
214
+ catch (err) {
215
+ return done({
216
+ ok: false, transport: "script", missingSecrets: [],
217
+ summary: "its inline code could not be written",
218
+ detail: err instanceof Error ? err.message : String(err),
219
+ });
220
+ }
221
+ }
222
+ // A materialised file was just written into cwd, so it resolves from
223
+ // there. A declared `run:` keeps resolving exactly as it always has.
224
+ const file = resolveRunPath(inline ? cwd : path.join(dir, "agents", "_probe"), run, libraryDir(tenant, "scripts"));
130
225
  if (!fs.existsSync(file)) {
131
226
  return done({
132
227
  ok: false, transport: "script", missingSecrets: [],
@@ -135,7 +230,12 @@ export async function testTool(tenant, workspace, def, input = {}) {
135
230
  });
136
231
  }
137
232
  const names = Array.isArray(def.spec.secrets) ? def.spec.secrets.map(String) : [];
138
- const { env, missing } = resolveSecrets(tenant, names, workspace);
233
+ const { env: stored, missing } = resolveSecrets(tenant, names, workspace);
234
+ const live = await liveSecrets(stored, { tenant, workspace });
235
+ if ("error" in live) {
236
+ return done({ ok: false, transport: "script", missingSecrets: missing, summary: "a secret could not be refreshed", detail: live.error });
237
+ }
238
+ const env = live.env;
139
239
  // The runtime's own choice of interpreter, not a second table beside it.
140
240
  const interpreter = typeof def.spec.interpreter === "string" ? def.spec.interpreter : undefined;
141
241
  // Declared args become long flags, exactly as the runtime passes them.
@@ -160,11 +260,25 @@ export async function testTool(tenant, workspace, def, input = {}) {
160
260
  });
161
261
  }
162
262
  const { cmd, args } = commandFor({ interpreter, run: run, name: "", description: "", args: {} }, file, toolRuntime.interpreters);
163
- const { code, out } = await runOnce(cmd, [...args, ...flags], cwd, {
164
- ...hostSafeBaseEnv(),
165
- ...toolRuntime.env,
166
- ...env,
167
- });
263
+ // The `workspace` link a step holds, held for the test too — a tool
264
+ // whose code says `workspace/state/x` found nothing here while the same
265
+ // call worked in a flow. Only from an agent's folder: at the workspace
266
+ // root there is no depth for it to stand at (the note below says so).
267
+ const link = caller ? linkWorkspace(cwd, dir) : null;
268
+ let ran;
269
+ try {
270
+ ran = await runOnce(cmd, [...args, ...flags], cwd, workspaceLinkEnv({
271
+ ...hostSafeBaseEnv(),
272
+ ...toolRuntime.env,
273
+ ...env,
274
+ }, cwd));
275
+ }
276
+ finally {
277
+ link?.release();
278
+ // A private build (a concurrent one was wedged) is this test's alone.
279
+ toolRuntime.dispose?.();
280
+ }
281
+ const { code, out } = ran;
168
282
  // A script that needs arguments and got none has not failed — it has not
169
283
  // been tested. Saying "exited 2" would send someone looking for a bug.
170
284
  const untested = code !== 0 && declared.length > 0 && flags.length === 0;
@@ -177,7 +291,7 @@ export async function testTool(tenant, workspace, def, input = {}) {
177
291
  : code === 0 ? "exited 0" : `exited ${code ?? "with an error"}`,
178
292
  detail: clip((caller
179
293
  ? `ran from agents/${caller.name}/, where a run runs\n\n`
180
- : `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`) +
294
+ : `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`) +
181
295
  (out || "(no output)")),
182
296
  });
183
297
  }
@@ -214,7 +328,12 @@ export async function testTool(tenant, workspace, def, input = {}) {
214
328
  const named = Object.entries(spec.env ?? {})
215
329
  .map(([, v]) => String(v).match(/^\$\{([A-Z0-9_]+)\}$/)?.[1])
216
330
  .filter((n) => Boolean(n));
217
- const { env, missing } = resolveSecrets(tenant, named, workspace);
331
+ const { env: stored, missing } = resolveSecrets(tenant, named, workspace);
332
+ const live = await liveSecrets(stored, { tenant, workspace });
333
+ if ("error" in live) {
334
+ return done({ ok: false, transport: "mcp", missingSecrets: missing, summary: "a secret could not be refreshed", detail: live.error });
335
+ }
336
+ const env = live.env;
218
337
  const childEnv = { ...hostSafeBaseEnv() };
219
338
  for (const [k, v] of Object.entries(spec.env ?? {}))
220
339
  childEnv[k] = substitute(String(v), env);
@@ -22,6 +22,9 @@ export interface TranslatorSpec {
22
22
  * upstream request goes through it, and `upstreamKey` is a placeholder
23
23
  * the proxy fills. */
24
24
  egress?: string;
25
+ /** The step's own zone, from the clock cascade — what a reset time in a
26
+ * refusal is told in. UTC when nothing said otherwise. */
27
+ timezone?: string;
25
28
  }
26
29
  export interface RunningTranslator {
27
30
  port: number;
@@ -81,7 +84,7 @@ export declare function toResponses(req: Json, opts: {
81
84
  params?: Record<string, unknown>;
82
85
  }, drop?: Dropped): Json;
83
86
  /** A finished Chat Completion as an Anthropic message. Pure. */
84
- export declare function fromChatCompletion(res: Json, requestedModel: string): Json;
87
+ export declare function fromChatCompletion(res: Json, requestedModel: string, stopSequences?: unknown): Json;
85
88
  /**
86
89
  * The streaming half: Chat-Completions chunks in, Anthropic SSE events out.
87
90
  * A state machine because the two streams disagree about structure — one
@@ -95,11 +98,13 @@ export declare class StreamTranslator {
95
98
  private textIndex;
96
99
  /** Chat-Completions tool index → Anthropic block index. */
97
100
  private tools;
101
+ /** The last choice's stop fields: finish_reason and any matched stop. */
98
102
  private finish;
99
103
  private usage;
100
104
  private id;
101
105
  private readonly model;
102
- constructor(model: string);
106
+ private readonly stops;
107
+ constructor(model: string, stopSequences?: unknown);
103
108
  private event;
104
109
  private start;
105
110
  private closeText;
@@ -125,6 +130,7 @@ export declare class ResponsesStreamTranslator {
125
130
  private usage;
126
131
  private incomplete;
127
132
  private sawTool;
133
+ private sawRefusal;
128
134
  private id;
129
135
  private readonly model;
130
136
  constructor(model: string);