@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/dist/src/tool-names.js
CHANGED
|
@@ -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`
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
//
|
|
22
|
-
//
|
|
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; `
|
|
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
|
-
"
|
|
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
|
+
}
|
package/dist/src/tool-test.js
CHANGED
|
@@ -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
|
|
34
|
-
//
|
|
35
|
-
// and
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
|
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);
|
package/dist/src/translator.d.ts
CHANGED
|
@@ -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
|
-
|
|
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);
|