@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.
- package/README.md +27 -10
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/src/account.d.ts +45 -0
- package/dist/src/account.js +130 -0
- package/dist/src/api-tools.d.ts +8 -0
- package/dist/src/api-tools.js +22 -4
- package/dist/src/approvals.d.ts +104 -1
- package/dist/src/approvals.js +192 -2
- package/dist/src/browser-pod.d.ts +129 -0
- package/dist/src/browser-pod.js +284 -0
- package/dist/src/budget.d.ts +32 -0
- package/dist/src/budget.js +177 -0
- package/dist/src/catalog.js +3 -1
- package/dist/src/clock.d.ts +49 -0
- package/dist/src/clock.js +121 -0
- package/dist/src/completions.js +17 -8
- package/dist/src/confine.d.ts +22 -0
- package/dist/src/confine.js +224 -4
- package/dist/src/deploy.d.ts +33 -0
- package/dist/src/deploy.js +145 -6
- package/dist/src/egress.d.ts +26 -2
- package/dist/src/egress.js +8 -1
- package/dist/src/eol.d.ts +2 -0
- package/dist/src/eol.js +7 -0
- package/dist/src/evals.d.ts +9 -0
- package/dist/src/evals.js +26 -13
- package/dist/src/flow-lint.d.ts +12 -0
- package/dist/src/flow-lint.js +331 -1
- package/dist/src/flow-patterns.d.ts +141 -0
- package/dist/src/flow-patterns.js +568 -0
- package/dist/src/gitrepo.d.ts +18 -0
- package/dist/src/gitrepo.js +49 -0
- package/dist/src/host-env.d.ts +8 -0
- package/dist/src/host-env.js +48 -0
- package/dist/src/json-schema.d.ts +12 -0
- package/dist/src/json-schema.js +202 -0
- package/dist/src/kinds.js +1 -1
- package/dist/src/language.d.ts +17 -0
- package/dist/src/language.js +55 -0
- package/dist/src/layout.d.ts +69 -0
- package/dist/src/layout.js +200 -0
- package/dist/src/library.d.ts +9 -0
- package/dist/src/library.js +43 -4
- package/dist/src/limits.d.ts +84 -0
- package/dist/src/limits.js +287 -0
- package/dist/src/locale.d.ts +47 -0
- package/dist/src/locale.js +177 -0
- package/dist/src/mail.d.ts +88 -0
- package/dist/src/mail.js +245 -0
- package/dist/src/model-probe.d.ts +3 -0
- package/dist/src/model-probe.js +1 -0
- package/dist/src/notify.d.ts +132 -8
- package/dist/src/notify.js +360 -58
- package/dist/src/openapi.js +5 -3
- package/dist/src/operator.d.ts +201 -0
- package/dist/src/operator.js +251 -0
- package/dist/src/paths.d.ts +33 -0
- package/dist/src/paths.js +87 -0
- package/dist/src/platform.d.ts +144 -4
- package/dist/src/platform.js +27 -3
- package/dist/src/provider-health.d.ts +55 -0
- package/dist/src/provider-health.js +233 -0
- package/dist/src/providers.d.ts +481 -0
- package/dist/src/providers.js +1105 -6
- package/dist/src/refusal.d.ts +83 -0
- package/dist/src/refusal.js +281 -0
- package/dist/src/run-container.d.ts +206 -17
- package/dist/src/run-container.js +547 -80
- package/dist/src/runner.d.ts +179 -10
- package/dist/src/runner.js +1423 -169
- package/dist/src/runtime.d.ts +52 -0
- package/dist/src/runtime.js +493 -58
- package/dist/src/script-tools.d.ts +15 -2
- package/dist/src/script-tools.js +44 -4
- package/dist/src/secret-files.d.ts +6 -5
- package/dist/src/secret-files.js +24 -11
- package/dist/src/secret-health.d.ts +83 -0
- package/dist/src/secret-health.js +177 -0
- package/dist/src/secrets.d.ts +38 -1
- package/dist/src/secrets.js +197 -96
- package/dist/src/starter.d.ts +9 -0
- package/dist/src/starter.js +95 -11
- package/dist/src/step-exec.d.ts +106 -1
- package/dist/src/step-exec.js +558 -85
- package/dist/src/step-removal.d.ts +15 -0
- package/dist/src/step-removal.js +49 -0
- package/dist/src/storage.d.ts +23 -4
- package/dist/src/storage.js +125 -23
- package/dist/src/store.d.ts +492 -38
- package/dist/src/store.js +882 -69
- package/dist/src/subagents.d.ts +75 -0
- package/dist/src/subagents.js +149 -0
- package/dist/src/tar.js +2 -1
- package/dist/src/test-mode.d.ts +111 -0
- package/dist/src/test-mode.js +285 -0
- package/dist/src/tool-names.d.ts +13 -1
- package/dist/src/tool-names.js +38 -11
- package/dist/src/tool-programs.d.ts +26 -0
- package/dist/src/tool-programs.js +172 -0
- package/dist/src/tool-test.js +152 -33
- package/dist/src/translator.d.ts +8 -2
- package/dist/src/translator.js +71 -24
- package/dist/src/trigger-log.d.ts +43 -0
- package/dist/src/trigger-log.js +122 -0
- package/dist/src/triggers.d.ts +23 -0
- package/dist/src/triggers.js +101 -12
- package/dist/src/untrusted-fetch.d.ts +11 -0
- package/dist/src/untrusted-fetch.js +77 -0
- package/dist/src/web-actions.d.ts +91 -0
- package/dist/src/web-actions.js +199 -0
- package/dist/src/webhook.d.ts +19 -3
- package/dist/src/webhook.js +50 -11
- package/index.ts +3 -0
- package/package.json +5 -5
- package/src/account.ts +149 -0
- package/src/api-tools.ts +32 -4
- package/src/approvals.ts +253 -2
- package/src/browser-pod.ts +313 -0
- package/src/budget.ts +178 -0
- package/src/catalog.ts +3 -1
- package/src/clock.ts +149 -0
- package/src/completions.ts +17 -8
- package/src/confine.ts +224 -4
- package/src/deploy.ts +137 -6
- package/src/egress.ts +32 -4
- package/src/eol.ts +7 -0
- package/src/evals.ts +36 -11
- package/src/flow-lint.ts +371 -1
- package/src/flow-patterns.ts +597 -0
- package/src/gitrepo.ts +48 -0
- package/src/host-env.ts +51 -0
- package/src/json-schema.ts +184 -0
- package/src/kinds.ts +1 -1
- package/src/language.ts +62 -0
- package/src/layout.ts +228 -0
- package/src/library.ts +50 -4
- package/src/limits.ts +290 -0
- package/src/locale.ts +217 -0
- package/src/mail.ts +316 -0
- package/src/model-probe.ts +4 -0
- package/src/notify.ts +391 -44
- package/src/openapi.ts +5 -3
- package/src/operator.ts +300 -0
- package/src/paths.ts +83 -0
- package/src/platform.ts +158 -5
- package/src/provider-health.ts +275 -0
- package/src/providers.ts +1368 -6
- package/src/refusal.ts +320 -0
- package/src/run-container.ts +611 -76
- package/src/runner.ts +1463 -165
- package/src/runtime.ts +487 -55
- package/src/script-tools.ts +55 -6
- package/src/secret-files.ts +22 -10
- package/src/secret-health.ts +230 -0
- package/src/secrets.ts +191 -95
- package/src/starter.ts +96 -11
- package/src/step-exec.ts +548 -41
- package/src/step-removal.ts +59 -0
- package/src/storage.ts +149 -24
- package/src/store.ts +1227 -74
- package/src/subagents.ts +189 -0
- package/src/tar.ts +2 -1
- package/src/test-mode.ts +311 -0
- package/src/tool-names.ts +40 -11
- package/src/tool-programs.ts +179 -0
- package/src/tool-test.ts +159 -32
- package/src/translator.ts +74 -23
- package/src/trigger-log.ts +160 -0
- package/src/triggers.ts +102 -12
- package/src/untrusted-fetch.ts +88 -0
- package/src/web-actions.ts +237 -0
- package/src/webhook.ts +58 -11
- package/templates/hello/agents/curator/agent.md +1 -1
- package/templates/hello/agents/notetaker/agent.md +1 -1
package/src/tool-programs.ts
CHANGED
|
@@ -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
|
|
50
|
-
//
|
|
51
|
-
// and
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
|
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
|
|