@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/subagents.ts
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// Sub-agents: delegate a job to a colleague with its OWN context and its own
|
|
2
|
+
// tools, inside the step that asked.
|
|
3
|
+
//
|
|
4
|
+
// agents: [editor] # consult: a quick opinion, no tools (agent-tools.ts)
|
|
5
|
+
// subagents: [researcher] # delegate: own context, own tools
|
|
6
|
+
//
|
|
7
|
+
// Built on the Agent SDK's native sub-agents (query()'s `agents` option and
|
|
8
|
+
// its Agent tool), so a delegation behaves as it does in Claude Code: a fresh
|
|
9
|
+
// context does the work — reads forty pages, runs the checks — and hands back
|
|
10
|
+
// a summary, keeping the parent's context on its own job.
|
|
11
|
+
//
|
|
12
|
+
// What keeps it the platform's and not a second, wider agent:
|
|
13
|
+
// - never wider than the parent: a sub-agent's tools are its own `tools:`
|
|
14
|
+
// resolved exactly as a step's, INTERSECTED with what the parent step
|
|
15
|
+
// holds — and always an explicit list: the SDK gives a definition with
|
|
16
|
+
// no `tools` every tool the parent has
|
|
17
|
+
// - depth one: the Agent/Task tool is never in a sub-agent's list, so it
|
|
18
|
+
// cannot delegate again
|
|
19
|
+
// - the same step: same sandbox, files, secrets, egress lease, and the
|
|
20
|
+
// parent's hooks — the SDK runs a sub-agent's tool calls through the
|
|
21
|
+
// session's PreToolUse and canUseTool (tagged agent_id/agent_type), so
|
|
22
|
+
// confinement applies unchanged; subagentGuard() below refuses anything
|
|
23
|
+
// outside the sub-agent's own list there too, belt and braces
|
|
24
|
+
// - cost and trace land on the parent step; flows still decide what runs
|
|
25
|
+
// next — a sub-agent is a helper inside one step, never a step.
|
|
26
|
+
|
|
27
|
+
import fs from "node:fs";
|
|
28
|
+
import path from "node:path";
|
|
29
|
+
import { refNames } from "./refs.ts";
|
|
30
|
+
import { TOOL_MAP } from "./tool-names.ts";
|
|
31
|
+
|
|
32
|
+
/** Plain JSON: this crosses into the run container. */
|
|
33
|
+
export interface SubagentSpec {
|
|
34
|
+
name: string;
|
|
35
|
+
description: string;
|
|
36
|
+
prompt: string;
|
|
37
|
+
/** Always explicit — never absent, never inherited. */
|
|
38
|
+
tools: string[];
|
|
39
|
+
disallowedTools: string[];
|
|
40
|
+
/** A model alias (haiku/sonnet/opus, remapped by the step's provider like
|
|
41
|
+
* the step's own) or "inherit". */
|
|
42
|
+
model: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The SDK's delegation tool, under both names it has had. Never granted to a
|
|
46
|
+
* sub-agent: that is what makes the depth one. */
|
|
47
|
+
export const DELEGATE_TOOLS = ["Agent", "Task"] as const;
|
|
48
|
+
export const DELEGATE_TOOL = "Agent";
|
|
49
|
+
|
|
50
|
+
const NAME = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
51
|
+
|
|
52
|
+
/** The named sub-agents' folders, host-side. Unknown names are reported. */
|
|
53
|
+
export function gatherSubagents(
|
|
54
|
+
workspaceRoot: string,
|
|
55
|
+
names: unknown,
|
|
56
|
+
): { found: { name: string; dir: string }[]; missing: string[] } {
|
|
57
|
+
const found: { name: string; dir: string }[] = [];
|
|
58
|
+
const missing: string[] = [];
|
|
59
|
+
for (const name of refNames(names)) {
|
|
60
|
+
const dir = path.join(workspaceRoot, "agents", name);
|
|
61
|
+
if (!NAME.test(name) || !fs.existsSync(path.join(dir, "agent.md"))) missing.push(name);
|
|
62
|
+
else if (!found.some((f) => f.name === name)) found.push({ name, dir });
|
|
63
|
+
}
|
|
64
|
+
return { found, missing };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* A sub-agent's tools: its own, only where the parent holds them too. An MCP
|
|
69
|
+
* tool is held when the parent holds that exact name or its whole server
|
|
70
|
+
* (`mcp__<server>` / `mcp__<server>__*`). The delegate tool never passes.
|
|
71
|
+
*/
|
|
72
|
+
export function subagentTools(own: string[], parent: string[]): string[] {
|
|
73
|
+
const held = new Set(parent);
|
|
74
|
+
const servers = new Set(
|
|
75
|
+
parent.filter((t) => /^mcp__[^_]/.test(t)).map((t) => t.replace(/__\*$/, "")).filter((t) => !t.slice(5).includes("__")),
|
|
76
|
+
);
|
|
77
|
+
const out: string[] = [];
|
|
78
|
+
for (const t of own) {
|
|
79
|
+
if ((DELEGATE_TOOLS as readonly string[]).includes(t) || out.includes(t)) continue;
|
|
80
|
+
const server = /^mcp__([^_].*?)__/.exec(t)?.[1];
|
|
81
|
+
if (held.has(t) || (server && servers.has(`mcp__${server}`))) out.push(t);
|
|
82
|
+
}
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The sub-agent's system prompt: its own prose, told where it stands. It
|
|
87
|
+
* works in the PARENT's directory — the SDK shares the session's cwd. */
|
|
88
|
+
export function subagentPrompt(o: {
|
|
89
|
+
body: string;
|
|
90
|
+
name: string;
|
|
91
|
+
parent: string;
|
|
92
|
+
workspace: string;
|
|
93
|
+
shared?: string | null;
|
|
94
|
+
}): string {
|
|
95
|
+
return [
|
|
96
|
+
o.body.trim(),
|
|
97
|
+
`# Where you are\n\n` +
|
|
98
|
+
`You are ${o.name}, a sub-agent started by ${o.parent} to do one job and report back. ` +
|
|
99
|
+
`You work in ${o.parent}'s directory, \`agents/${o.parent}/\` in the \`${o.workspace}\` workspace: ` +
|
|
100
|
+
`\`../../\` is the workspace root, its knowledge is at \`../../knowledge/\`, and absolute paths are refused. ` +
|
|
101
|
+
`You have only the tools listed for you. When the job is done, reply with what you found or made — ` +
|
|
102
|
+
`your reply is all ${o.parent} sees of your work, so put the facts and file paths in it.`,
|
|
103
|
+
...(o.shared?.trim() ? [o.shared.trim()] : []),
|
|
104
|
+
].join("\n\n");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** SDK definitions. `tools` is always set — an absent list inherits every
|
|
108
|
+
* tool the parent has, which is the one thing this module exists to stop. */
|
|
109
|
+
export function toAgentDefinitions(specs: SubagentSpec[]): Record<string, {
|
|
110
|
+
description: string;
|
|
111
|
+
prompt: string;
|
|
112
|
+
tools: string[];
|
|
113
|
+
disallowedTools: string[];
|
|
114
|
+
model: string;
|
|
115
|
+
}> {
|
|
116
|
+
const out: Record<string, { description: string; prompt: string; tools: string[]; disallowedTools: string[]; model: string }> = {};
|
|
117
|
+
for (const s of specs) {
|
|
118
|
+
out[s.name] = {
|
|
119
|
+
description: s.description,
|
|
120
|
+
prompt: s.prompt,
|
|
121
|
+
tools: [...s.tools],
|
|
122
|
+
disallowedTools: [...new Set([...s.disallowedTools, ...DELEGATE_TOOLS])],
|
|
123
|
+
model: s.model || "inherit",
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
return out;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** One tool call as the parent's PreToolUse hook sees it. */
|
|
130
|
+
export interface GuardedCall {
|
|
131
|
+
/** The SDK's agent_id: set when the call comes from inside a sub-agent. */
|
|
132
|
+
agentId?: string;
|
|
133
|
+
/** The SDK's agent_type: which sub-agent. */
|
|
134
|
+
agentType?: string;
|
|
135
|
+
tool: string;
|
|
136
|
+
input: Record<string, unknown>;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* For the parent's PreToolUse hook, on a step that has sub-agents: a reason
|
|
141
|
+
* to refuse the call — or null.
|
|
142
|
+
*
|
|
143
|
+
* Only DECLARED sub-agents may run. The SDK also offers its built-ins
|
|
144
|
+
* (general-purpose and the rest, with every tool the session has); they are
|
|
145
|
+
* switched off in executeStep, and refused here too. A call from inside a
|
|
146
|
+
* sub-agent whose type is missing or not declared used to pass unjudged —
|
|
147
|
+
* `general-purpose` was a way round every sub-agent's own list.
|
|
148
|
+
*/
|
|
149
|
+
export function subagentGuard(specs: SubagentSpec[]): (call: GuardedCall) => string | null {
|
|
150
|
+
const by = new Map(specs.map((s) => [s.name, s]));
|
|
151
|
+
const declared = specs.map((s) => s.name).join(", ");
|
|
152
|
+
const isDelegate = (tool: string) => (DELEGATE_TOOLS as readonly string[]).includes(tool);
|
|
153
|
+
return ({ agentId, agentType, tool, input }) => {
|
|
154
|
+
if (!agentId) {
|
|
155
|
+
// The main thread: its tools are canUseTool's business — except the
|
|
156
|
+
// delegation itself, which may name only a declared sub-agent.
|
|
157
|
+
if (!isDelegate(tool)) return null;
|
|
158
|
+
const to = typeof input.subagent_type === "string" ? input.subagent_type : "";
|
|
159
|
+
if (by.has(to)) return null;
|
|
160
|
+
return `${to ? `"${to}" is not a sub-agent this step declared` : "name the sub-agent to delegate to"} — subagent_type must be one of: ${declared}`;
|
|
161
|
+
}
|
|
162
|
+
const s = agentType ? by.get(agentType) : undefined;
|
|
163
|
+
if (!s) return `${agentType ? `"${agentType}"` : "an unnamed sub-agent"} is not declared in subagents: for this step (only ${declared}) — its calls are refused`;
|
|
164
|
+
if (isDelegate(tool)) {
|
|
165
|
+
return `${s.name} is a sub-agent and cannot start another — do the work with your own tools`;
|
|
166
|
+
}
|
|
167
|
+
if (s.disallowedTools.includes(tool)) return `${tool} is disallowed for ${s.name}`;
|
|
168
|
+
if (subagentTools([tool], s.tools).length === 0) return `${tool} is not one of ${s.name}'s tools`;
|
|
169
|
+
return null;
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* What `foldrun check` can say without running anything: the SDK and built-in
|
|
175
|
+
* tool names two `tools:` lists grant, and their overlap. Own tools compare by
|
|
176
|
+
* name. Used to warn when a sub-agent would be left with nothing.
|
|
177
|
+
*/
|
|
178
|
+
export function grantedNames(tools: string[]): Set<string> {
|
|
179
|
+
const out = new Set<string>();
|
|
180
|
+
for (const t of tools) {
|
|
181
|
+
for (const n of TOOL_MAP[t] ?? [t]) out.add(n);
|
|
182
|
+
}
|
|
183
|
+
return out;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function checkOverlap(parentTools: string[], subTools: string[]): string[] {
|
|
187
|
+
const parent = grantedNames(parentTools);
|
|
188
|
+
return [...grantedNames(subTools)].filter((t) => parent.has(t) && !(DELEGATE_TOOLS as readonly string[]).includes(t));
|
|
189
|
+
}
|
package/src/tar.ts
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
// argument about which library to add.
|
|
11
11
|
|
|
12
12
|
import zlib from "node:zlib";
|
|
13
|
+
import { trimSlashes } from "./paths.ts";
|
|
13
14
|
|
|
14
15
|
const BLOCK = 512;
|
|
15
16
|
|
|
@@ -100,7 +101,7 @@ export function stripRoot(entries: TarEntry[]): TarEntry[] {
|
|
|
100
101
|
* case, since a repo usually holds more than one thing.
|
|
101
102
|
*/
|
|
102
103
|
export function filesFromTarball(gz: Buffer, subdir = ""): { path: string; content: string }[] {
|
|
103
|
-
const prefix = subdir
|
|
104
|
+
const prefix = trimSlashes(subdir, "both");
|
|
104
105
|
const out: { path: string; content: string }[] = [];
|
|
105
106
|
|
|
106
107
|
for (const entry of stripRoot(readTarGz(gz))) {
|
package/src/test-mode.ts
ADDED
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
// Test mode: a run that exercises everything and changes nothing outside.
|
|
2
|
+
//
|
|
3
|
+
// Every desk that sends — email, SMS, orders, posts — grew its own test
|
|
4
|
+
// switch inside its own script: a `test_to` address here, a `mode: test`
|
|
5
|
+
// there. On 2026-09-14 a send step edited its own gate file to get past a
|
|
6
|
+
// script's guard and texted 25 customers. A guard the script can reach is a
|
|
7
|
+
// guard the script can move; this one lives where the script cannot: on the
|
|
8
|
+
// run record, at the egress proxy, and in what the sandbox is handed.
|
|
9
|
+
//
|
|
10
|
+
// The rule has one table (below) so it is testable without a server, and
|
|
11
|
+
// three enforcement points that all read it:
|
|
12
|
+
//
|
|
13
|
+
// - the egress proxy: on a test lease, an outward write to a host that is
|
|
14
|
+
// not read-safe or a model provider is refused with 403 and a JSON body
|
|
15
|
+
// naming what would have been sent; Resend's sink address is the one
|
|
16
|
+
// exception, so a desk still gets a real message id back
|
|
17
|
+
// - the runner: send-capable secrets never enter the sandbox as values on
|
|
18
|
+
// a test run; the env var says TEST_MODE_WITHHELD instead, so a script
|
|
19
|
+
// that forgot to check fails at the provider with an auth error
|
|
20
|
+
// - the write-back: state/ and storage/ go INTO the sandbox as usual and
|
|
21
|
+
// come out into runs/<id>/test-writes/ instead of the real directories
|
|
22
|
+
//
|
|
23
|
+
// Nothing here weakens a live run: every function is a no-op unless the run
|
|
24
|
+
// says `test: true`.
|
|
25
|
+
|
|
26
|
+
import fs from "node:fs";
|
|
27
|
+
import path from "node:path";
|
|
28
|
+
|
|
29
|
+
/** Where a test run's email actually goes — Resend's delivered sink. */
|
|
30
|
+
export const TEST_SINK_EMAIL = "delivered@resend.dev";
|
|
31
|
+
/** Prefixed to every subject a test run sends, so the sink is readable. */
|
|
32
|
+
export const TEST_SUBJECT_PREFIX = "[TEST] ";
|
|
33
|
+
/** What a withheld secret's env var reads as inside the sandbox. */
|
|
34
|
+
export const WITHHELD = "TEST_MODE_WITHHELD";
|
|
35
|
+
/** Both are set to "1" in a test run's sandbox: the first is what a script
|
|
36
|
+
* checks, the second is the run-level spelling for verifies and flows. */
|
|
37
|
+
export const TEST_MODE_ENV = "FOLDRUN_TEST_MODE";
|
|
38
|
+
export const RUN_TEST_ENV = "FOLDRUN_RUN_TEST";
|
|
39
|
+
/** Under runs/<id>/, where diverted state/ and storage/ writes land. */
|
|
40
|
+
export const TEST_WRITES_DIR = "test-writes";
|
|
41
|
+
/** The workspace directories a test run may not write for real. */
|
|
42
|
+
export const TEST_DIVERTED_DIRS = ["state", "storage"] as const;
|
|
43
|
+
|
|
44
|
+
/** What a test run did instead of the real thing — one per intervention,
|
|
45
|
+
* on the run event that reports it, so the run page can list them. */
|
|
46
|
+
export interface TestEffect {
|
|
47
|
+
kind: "refused" | "redirected" | "diverted" | "withheld";
|
|
48
|
+
host?: string;
|
|
49
|
+
method?: string;
|
|
50
|
+
path?: string;
|
|
51
|
+
summary: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ---------------------------------------------------------------- secrets
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Secret names that can make something happen outside. Matched by name
|
|
58
|
+
* because the name is all the runner knows before the value is used: the
|
|
59
|
+
* vault does not say what a key can do, and the provider hosts are only
|
|
60
|
+
* known for http tools, not for the scripts these are withheld from.
|
|
61
|
+
*/
|
|
62
|
+
export const SEND_CAPABLE_SECRETS: RegExp[] = [
|
|
63
|
+
/^RESEND_API_KEY$/,
|
|
64
|
+
/^TWILIO_/,
|
|
65
|
+
/^GETREACH_API_KEY$/,
|
|
66
|
+
/^GITHUB_TOKEN$/,
|
|
67
|
+
/^MONDAY_API_TOKEN$/,
|
|
68
|
+
/^OI_CRM_API_KEY$/,
|
|
69
|
+
/^CLOUDFLARE_/,
|
|
70
|
+
/^LINKEDIN_/,
|
|
71
|
+
/^MEDIUM_COOKIES$/,
|
|
72
|
+
/^TIKTOK_/,
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
export function isSendCapableSecret(name: string): boolean {
|
|
76
|
+
return SEND_CAPABLE_SECRETS.some((re) => re.test(name));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The secrets a test-run sandbox is handed, and which were withheld.
|
|
81
|
+
*
|
|
82
|
+
* `outward` — the step grants a script tool whose file says `outward: true`:
|
|
83
|
+
* then every secret is withheld, because a tool that declared itself a
|
|
84
|
+
* sender is not one to hand anything real. `allow` — a script tool said
|
|
85
|
+
* `test_mode: allow` (a reader that happens to use a listed key): the list
|
|
86
|
+
* is not applied. Outward wins over allow, because a step that has both
|
|
87
|
+
* has a sender in it.
|
|
88
|
+
*/
|
|
89
|
+
export function withholdSecrets(
|
|
90
|
+
secrets: Record<string, string>,
|
|
91
|
+
opts: { outward?: boolean; allow?: boolean } = {},
|
|
92
|
+
): { env: Record<string, string>; withheld: string[] } {
|
|
93
|
+
const env: Record<string, string> = {};
|
|
94
|
+
const withheld: string[] = [];
|
|
95
|
+
for (const [name, value] of Object.entries(secrets)) {
|
|
96
|
+
const hold = opts.outward || (!opts.allow && isSendCapableSecret(name));
|
|
97
|
+
env[name] = hold ? WITHHELD : value;
|
|
98
|
+
if (hold) withheld.push(name);
|
|
99
|
+
}
|
|
100
|
+
return { env, withheld };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ---------------------------------------------------------------- the proxy
|
|
104
|
+
|
|
105
|
+
/** Methods that read. Anything else is a write until the host says otherwise. */
|
|
106
|
+
const READ_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Hosts an outward write may reach on a test run: model providers (the run
|
|
110
|
+
* cannot happen without them, and nothing there is a send), and read-only
|
|
111
|
+
* services whose POSTs are queries. A host is matched exactly, or by a
|
|
112
|
+
* leading `*.` for its subdomains. The list is deliberately short — the
|
|
113
|
+
* default is refusal, and a host earns its place here by having no way to
|
|
114
|
+
* reach a customer.
|
|
115
|
+
*/
|
|
116
|
+
export const READ_SAFE_HOSTS: string[] = [
|
|
117
|
+
// model providers
|
|
118
|
+
"api.anthropic.com",
|
|
119
|
+
"openrouter.ai",
|
|
120
|
+
"api.openai.com",
|
|
121
|
+
"generativelanguage.googleapis.com",
|
|
122
|
+
"api.groq.com",
|
|
123
|
+
"api.mistral.ai",
|
|
124
|
+
"api.together.xyz",
|
|
125
|
+
"api.deepseek.com",
|
|
126
|
+
"api.x.ai",
|
|
127
|
+
"api.fireworks.ai",
|
|
128
|
+
"api.cerebras.ai",
|
|
129
|
+
"api.perplexity.ai",
|
|
130
|
+
"*.openai.azure.com",
|
|
131
|
+
// queries that are POSTs
|
|
132
|
+
"api.dataforseo.com",
|
|
133
|
+
"*.dataforseo.com",
|
|
134
|
+
"*.svc.cluster.local",
|
|
135
|
+
];
|
|
136
|
+
|
|
137
|
+
export function isReadSafeHost(host: string): boolean {
|
|
138
|
+
const h = host.toLowerCase().replace(/:\d+$/, "");
|
|
139
|
+
return READ_SAFE_HOSTS.some((rule) =>
|
|
140
|
+
rule.startsWith("*.") ? h.endsWith(rule.slice(1)) && h !== rule.slice(2) : h === rule,
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export type TestPolicy =
|
|
145
|
+
| { action: "allow" }
|
|
146
|
+
| { action: "refuse" }
|
|
147
|
+
| { action: "rewrite"; rewrite: "resend-email" };
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* What the proxy does with one request on a test lease. Pure: method, host
|
|
151
|
+
* and path in, a verdict out. Reads always pass — a test run has to be able
|
|
152
|
+
* to look at the world it is not allowed to change.
|
|
153
|
+
*/
|
|
154
|
+
export function testPolicy(method: string, host: string, path: string): TestPolicy {
|
|
155
|
+
const m = method.toUpperCase();
|
|
156
|
+
if (READ_METHODS.has(m)) return { action: "allow" };
|
|
157
|
+
const h = host.toLowerCase().replace(/:\d+$/, "");
|
|
158
|
+
// Resend's sink: the message really goes out, to an address that keeps
|
|
159
|
+
// it, with the subject marked — so the desk sees a real id and a real
|
|
160
|
+
// 200, and nobody receives anything.
|
|
161
|
+
if (h === "api.resend.com" && /^\/emails(\/|$|\?)/.test(path)) return { action: "rewrite", rewrite: "resend-email" };
|
|
162
|
+
if (isReadSafeHost(h)) return { action: "allow" };
|
|
163
|
+
return { action: "refuse" };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* A Resend `POST /emails` body with its recipients pointed at the sink and
|
|
168
|
+
* the subject marked. Returns the recipients it replaced, for the trace.
|
|
169
|
+
* Null when the body is not a JSON object — then there is nothing safe to
|
|
170
|
+
* rewrite, and the caller refuses instead.
|
|
171
|
+
*/
|
|
172
|
+
export function rewriteResendEmail(body: string): { body: string; to: string[] } | null {
|
|
173
|
+
let parsed: unknown;
|
|
174
|
+
try {
|
|
175
|
+
parsed = JSON.parse(body);
|
|
176
|
+
} catch {
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
|
|
180
|
+
const mail = parsed as Record<string, unknown>;
|
|
181
|
+
const to: string[] = [];
|
|
182
|
+
for (const field of ["to", "cc", "bcc"] as const) {
|
|
183
|
+
const v = mail[field];
|
|
184
|
+
if (v === undefined || v === null) continue;
|
|
185
|
+
for (const addr of Array.isArray(v) ? v : [v]) to.push(String(addr));
|
|
186
|
+
// One sink address for the lot: two copies of the same test mail teach
|
|
187
|
+
// nobody anything, and cc/bcc to the sink would be exactly that.
|
|
188
|
+
if (field === "to") mail[field] = [TEST_SINK_EMAIL];
|
|
189
|
+
else delete mail[field];
|
|
190
|
+
}
|
|
191
|
+
if (!Array.isArray(mail.to) || mail.to.length === 0) mail.to = [TEST_SINK_EMAIL];
|
|
192
|
+
const subject = typeof mail.subject === "string" ? mail.subject : "";
|
|
193
|
+
mail.subject = subject.startsWith(TEST_SUBJECT_PREFIX) ? subject : `${TEST_SUBJECT_PREFIX}${subject}`;
|
|
194
|
+
return { body: JSON.stringify(mail), to };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** The first `max` characters of a body, whitespace collapsed — enough to
|
|
198
|
+
* say what would have gone out, not enough to be the thing itself. The
|
|
199
|
+
* caller redacts; this only cuts. */
|
|
200
|
+
export function bodySummary(body: string, max = 200): string {
|
|
201
|
+
const flat = body.replace(/\s+/g, " ").trim();
|
|
202
|
+
return flat.length > max ? `${flat.slice(0, max)}…` : flat;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** The recipient field of a body, if it has one — for `would_have.to`. */
|
|
206
|
+
export function recipientsOf(body: string): string | string[] | null {
|
|
207
|
+
try {
|
|
208
|
+
const parsed = JSON.parse(body) as Record<string, unknown>;
|
|
209
|
+
if (parsed && typeof parsed === "object") {
|
|
210
|
+
for (const key of ["to", "To", "recipient", "phone", "email"]) {
|
|
211
|
+
const v = parsed[key];
|
|
212
|
+
if (typeof v === "string" || Array.isArray(v)) return v as string | string[];
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
} catch {
|
|
216
|
+
// a form body: Twilio's Messages endpoint
|
|
217
|
+
const m = body.match(/(?:^|&)To=([^&]*)/);
|
|
218
|
+
if (m) return decodeURIComponent(m[1].replace(/\+/g, " "));
|
|
219
|
+
}
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** The 403 a refused write is answered with. JSON, so a script that parses
|
|
224
|
+
* the error sees `test_mode` and can say so instead of retrying. */
|
|
225
|
+
export function refusalBody(method: string, host: string, path: string, body: string): string {
|
|
226
|
+
return JSON.stringify({
|
|
227
|
+
test_mode: true,
|
|
228
|
+
error: { type: "test_mode", message: `test run: ${method} ${host}${path} refused — nothing was sent` },
|
|
229
|
+
would_have: { method, host, path, to: recipientsOf(body), body: bodySummary(body) },
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ---------------------------------------------------------------- write-back
|
|
234
|
+
|
|
235
|
+
/** Is this workspace-relative path one a test run diverts? */
|
|
236
|
+
export function isDivertedPath(rel: string): boolean {
|
|
237
|
+
const norm = rel.replaceAll("\\", "/");
|
|
238
|
+
return TEST_DIVERTED_DIRS.some((d) => norm === d || norm.startsWith(`${d}/`));
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** The line the trace and the run page show for one diverted write. */
|
|
242
|
+
export function divertedSummary(rel: string, was: Buffer | null, next: Buffer): string {
|
|
243
|
+
if (was === null) return `would have created ${rel} (${next.length} bytes)`;
|
|
244
|
+
const before = was.toString("utf8").split("\n").length;
|
|
245
|
+
const after = next.toString("utf8").split("\n").length;
|
|
246
|
+
const delta = after - before;
|
|
247
|
+
return `would have written ${rel}${delta > 0 ? `, +${delta} line${delta === 1 ? "" : "s"}` : delta < 0 ? `, ${delta} lines` : ""}`;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* The in-process path has no copy-in and copy-out — a step writes the
|
|
252
|
+
* workspace directly — so a test run there snapshots state/ and storage/
|
|
253
|
+
* before the step and, after it, moves what changed under the run and
|
|
254
|
+
* puts the originals back. Same outcome as the sandbox's divert, reached
|
|
255
|
+
* from the other side. Used by `foldrun run --test`.
|
|
256
|
+
*/
|
|
257
|
+
export function snapshotDivertedDirs(wsRoot: string): Map<string, Buffer> {
|
|
258
|
+
const snap = new Map<string, Buffer>();
|
|
259
|
+
for (const dir of TEST_DIVERTED_DIRS) {
|
|
260
|
+
const root = path.join(wsRoot, dir);
|
|
261
|
+
if (!fs.existsSync(root)) continue;
|
|
262
|
+
for (const entry of fs.readdirSync(root, { recursive: true, withFileTypes: true })) {
|
|
263
|
+
if (!entry.isFile()) continue;
|
|
264
|
+
const abs = path.join(entry.parentPath, entry.name);
|
|
265
|
+
snap.set(path.relative(wsRoot, abs).replaceAll("\\", "/"), fs.readFileSync(abs));
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
return snap;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Undo a step's writes to the diverted directories, keeping each changed
|
|
272
|
+
* or new file under `to` by its workspace-relative path. Deletions are
|
|
273
|
+
* undone too — the original is put back. Returns what was set aside. */
|
|
274
|
+
export function restoreDivertedDirs(
|
|
275
|
+
wsRoot: string,
|
|
276
|
+
before: Map<string, Buffer>,
|
|
277
|
+
to: string,
|
|
278
|
+
note: (rel: string, summary: string) => void,
|
|
279
|
+
): string[] {
|
|
280
|
+
const now = snapshotDivertedDirs(wsRoot);
|
|
281
|
+
const aside: string[] = [];
|
|
282
|
+
for (const [rel, next] of now) {
|
|
283
|
+
const was = before.get(rel) ?? null;
|
|
284
|
+
if (was && was.equals(next)) continue;
|
|
285
|
+
const dest = path.join(to, rel);
|
|
286
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
287
|
+
fs.writeFileSync(dest, next);
|
|
288
|
+
aside.push(rel);
|
|
289
|
+
note(rel, divertedSummary(rel, was, next));
|
|
290
|
+
if (was) fs.writeFileSync(path.join(wsRoot, rel), was);
|
|
291
|
+
else fs.rmSync(path.join(wsRoot, rel), { force: true });
|
|
292
|
+
}
|
|
293
|
+
for (const [rel, was] of before) {
|
|
294
|
+
if (now.has(rel)) continue;
|
|
295
|
+
fs.mkdirSync(path.dirname(path.join(wsRoot, rel)), { recursive: true });
|
|
296
|
+
fs.writeFileSync(path.join(wsRoot, rel), was);
|
|
297
|
+
note(rel, `would have deleted ${rel}`);
|
|
298
|
+
aside.push(rel);
|
|
299
|
+
}
|
|
300
|
+
return aside;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** `[test] ` in front of a headline, once. */
|
|
304
|
+
export function testHeadline(summary: string): string {
|
|
305
|
+
return summary.startsWith("[test]") ? summary : `[test] ${summary}`;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** The env every test-run sandbox gets, beside its secrets. */
|
|
309
|
+
export function testModeEnv(): Record<string, string> {
|
|
310
|
+
return { [TEST_MODE_ENV]: "1", [RUN_TEST_ENV]: "1" };
|
|
311
|
+
}
|
package/src/tool-names.ts
CHANGED
|
@@ -14,29 +14,43 @@ import { refList, refNames, type Ref } from "./refs.ts";
|
|
|
14
14
|
|
|
15
15
|
/** Groups: one word an author can hold in their head, expanded to SDK names. */
|
|
16
16
|
export const TOOL_MAP: Record<string, string[]> = {
|
|
17
|
-
// `web`
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
//
|
|
24
|
-
//
|
|
17
|
+
// The web is one tool of ours, `web` — search, fetch, browse, crawl, map,
|
|
18
|
+
// extract, answer, monitor, served from the gallery and on the run record
|
|
19
|
+
// whichever model is driving. So `web` is deliberately NOT here: a name in
|
|
20
|
+
// TOOL_MAP shadows a real tool of the same name (see runner.ts).
|
|
21
|
+
// `read` is deliberately separate from `write`: an agent that may inspect a
|
|
22
|
+
// repository but must never modify it is a real and common design. The pair
|
|
23
|
+
// says what it grants. `files` and `bash` were the old names for `write`
|
|
24
|
+
// and `code` (`code`, because it is not only shell — anything in the
|
|
25
|
+
// sandbox runs through it); both are retired, below.
|
|
25
26
|
read: ["Read", "Glob", "Grep"],
|
|
27
|
+
write: ["Read", "Write", "Edit", "Glob", "Grep"],
|
|
26
28
|
files: ["Read", "Write", "Edit", "Glob", "Grep"],
|
|
29
|
+
code: ["Bash"],
|
|
27
30
|
bash: ["Bash"],
|
|
28
31
|
};
|
|
29
32
|
|
|
33
|
+
/** Names an author may no longer write, and what to write instead. They are
|
|
34
|
+
* still granted, so nothing deployed breaks; `check` and the run log say the
|
|
35
|
+
* rewrite. */
|
|
36
|
+
export const RETIRED_TOOLS: Record<string, string> = {
|
|
37
|
+
files: "write",
|
|
38
|
+
bash: "code",
|
|
39
|
+
};
|
|
40
|
+
|
|
30
41
|
/** Exact SDK tool names, accepted alongside the group aliases so a Claude Code
|
|
31
42
|
* subagent's `tools: Read, Grep` works unchanged. The aliases exist because
|
|
32
|
-
* vendors rename tools; `
|
|
43
|
+
* vendors rename tools; `write` survives a rename that `Write` would not.
|
|
44
|
+
* The SDK's WebSearch and WebFetch are not here: the web is our `web` tool,
|
|
45
|
+
* and a model provider's own search (`web: {search: zai}`) is the runtime's
|
|
46
|
+
* to swap in, not the author's to name. */
|
|
33
47
|
export const BUILTIN_TOOLS = new Set([
|
|
34
48
|
"Read", "Write", "Edit", "MultiEdit", "Glob", "Grep", "Bash",
|
|
35
|
-
"
|
|
49
|
+
"NotebookEdit", "TodoWrite",
|
|
36
50
|
]);
|
|
37
51
|
|
|
38
52
|
/** The platform's own groups, served in-process rather than by the SDK. */
|
|
39
|
-
export const PLATFORM_GROUPS = new Set(["search", "history", "desks"]);
|
|
53
|
+
export const PLATFORM_GROUPS = new Set(["search", "history", "desks", "ask"]);
|
|
40
54
|
|
|
41
55
|
/** Would this name resolve to something the runtime provides? Built-ins win a
|
|
42
56
|
* clash, so a tool file of the same name is shadowed rather than granted. */
|
|
@@ -85,6 +99,21 @@ export function legacyUseNames(front: ToolFrontmatter): string[] {
|
|
|
85
99
|
return refNames(front.use);
|
|
86
100
|
}
|
|
87
101
|
|
|
102
|
+
/** Retired names written bare in `tools:`, deduped. A `[[link]]` is the
|
|
103
|
+
* author's own file, so it is never one of these. */
|
|
104
|
+
export function retiredToolNames(front: ToolFrontmatter): string[] {
|
|
105
|
+
const out: string[] = [];
|
|
106
|
+
for (const ref of toolRefs(front)) {
|
|
107
|
+
if (!ref.linked && RETIRED_TOOLS[ref.name] && !out.includes(ref.name)) out.push(ref.name);
|
|
108
|
+
}
|
|
109
|
+
return out;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The one sentence every reader of a retired name says. */
|
|
113
|
+
export function retiredToolError(name: string): string {
|
|
114
|
+
return `tools: "${name}" is retired — use ${RETIRED_TOOLS[name]} instead: the same grant, under the name that says what it does. It is still granted for now.`;
|
|
115
|
+
}
|
|
116
|
+
|
|
88
117
|
/** The one sentence every reader of a `use:` key says. */
|
|
89
118
|
export function legacyUseError(names: string[]): string {
|
|
90
119
|
return `\`use:\` is no longer read — write \`tools: [${names.join(", ")}]\` instead (scripts/migrate-use-to-tools.mjs rewrites every agent). Nothing was granted for it.`;
|