@foldrun/core 0.7.0 → 1.1.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 +14 -1
- package/dist/src/completions.js +1 -0
- package/dist/src/deploy.js +5 -0
- package/dist/src/egress.d.ts +21 -0
- package/dist/src/egress.js +8 -0
- package/dist/src/hooks.d.ts +114 -0
- package/dist/src/hooks.js +370 -0
- package/dist/src/kinds.d.ts +1 -1
- package/dist/src/kinds.js +26 -0
- package/dist/src/library.d.ts +1 -1
- package/dist/src/library.js +17 -1
- package/dist/src/mail.js +1 -1
- package/dist/src/network.d.ts +120 -0
- package/dist/src/network.js +360 -0
- package/dist/src/notify.d.ts +8 -0
- package/dist/src/notify.js +10 -1
- package/dist/src/providers.d.ts +5 -0
- package/dist/src/providers.js +21 -1
- package/dist/src/run-container.d.ts +13 -0
- package/dist/src/run-container.js +8 -1
- package/dist/src/runner.d.ts +1 -0
- package/dist/src/runner.js +144 -16
- package/dist/src/secrets.d.ts +20 -0
- package/dist/src/secrets.js +65 -1
- package/dist/src/starter.js +9 -1
- package/dist/src/step-exec.d.ts +10 -0
- package/dist/src/step-exec.js +113 -2
- package/dist/src/store.d.ts +23 -1
- package/dist/src/store.js +33 -1
- package/dist/src/tool-loop.d.ts +45 -0
- package/dist/src/tool-loop.js +133 -0
- package/package.json +19 -1
- package/src/completions.ts +1 -0
- package/src/deploy.ts +4 -0
- package/src/egress.ts +26 -0
- package/src/hooks.ts +406 -0
- package/src/kinds.ts +28 -0
- package/src/library.ts +18 -2
- package/src/mail.ts +1 -1
- package/src/network.ts +390 -0
- package/src/notify.ts +11 -1
- package/src/providers.ts +27 -1
- package/src/run-container.ts +16 -2
- package/src/runner.ts +145 -14
- package/src/secrets.ts +69 -1
- package/src/starter.ts +9 -1
- package/src/step-exec.ts +115 -3
- package/src/store.ts +39 -1
- package/src/tool-loop.ts +176 -0
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
import { type HookRecord } from "./hooks.ts";
|
|
1
2
|
import type { McpServerConfig } from "@anthropic-ai/claude-agent-sdk";
|
|
2
3
|
import { type ApiSpec, type Effort } from "./store.ts";
|
|
3
4
|
import type { EventExtra } from "./step-exec.ts";
|
|
4
5
|
import { type RuntimeSpec } from "./runtime.ts";
|
|
6
|
+
import { type NetworkLevel } from "./network.ts";
|
|
5
7
|
import type { ScriptSpec } from "./script-tools.ts";
|
|
6
8
|
import type { ConsultSpec } from "./agent-tools.ts";
|
|
7
9
|
import type { SubagentSpec } from "./subagents.ts";
|
|
@@ -52,6 +54,9 @@ export interface ContainerStepInput {
|
|
|
52
54
|
/** `limits:` resolved host-side, and the SDK-name → foldrun-name map the
|
|
53
55
|
* count reads (limits.ts) — enforced in the pod by the same hook. */
|
|
54
56
|
limits?: Record<string, number>;
|
|
57
|
+
/** `hooks:` names, resolved inside the sandbox against its own roots. */
|
|
58
|
+
hookNames?: string[];
|
|
59
|
+
hookBase?: Record<string, unknown>;
|
|
55
60
|
toolOwners?: Record<string, string>;
|
|
56
61
|
/** tools: [search] — the directories to search, as paths INSIDE the
|
|
57
62
|
* container (/workspace/…, /library/…). */
|
|
@@ -340,6 +345,7 @@ export declare function parseDriverLine(line: string): {
|
|
|
340
345
|
operator?: OperatorEvent;
|
|
341
346
|
subagent?: string;
|
|
342
347
|
check?: boolean;
|
|
348
|
+
hook?: HookRecord;
|
|
343
349
|
} | ({
|
|
344
350
|
e: "done";
|
|
345
351
|
} & ContainerStepOutcome) | null;
|
|
@@ -354,6 +360,13 @@ export interface RunInContainerArgs {
|
|
|
354
360
|
input: ContainerStepInput;
|
|
355
361
|
/** Secret + provider env — crosses as an env file, never argv. */
|
|
356
362
|
env: Record<string, string>;
|
|
363
|
+
/** The step's network (network.ts), for an executor that can wall the
|
|
364
|
+
* sandbox in itself: the k8s tier labels a filtered pod so its policy
|
|
365
|
+
* admits the proxy and DNS alone. `enforced` false is report-only. */
|
|
366
|
+
network?: {
|
|
367
|
+
level: NetworkLevel;
|
|
368
|
+
enforced: boolean;
|
|
369
|
+
};
|
|
357
370
|
emit: (type: "text" | "tool" | "info" | "error", text: string, extra?: EventExtra) => void;
|
|
358
371
|
/** The run this step belongs to. Stamped on the sandbox as a label so a
|
|
359
372
|
* person stopping the run can reach the thing actually burning money —
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
//
|
|
18
18
|
// The runner image bundles @foldrun/core itself, so the driver runs the same
|
|
19
19
|
// executeStep the server would — one implementation, two homes.
|
|
20
|
+
import { isHookRecord } from "./hooks.js";
|
|
20
21
|
import fs from "node:fs";
|
|
21
22
|
import os from "node:os";
|
|
22
23
|
import path from "node:path";
|
|
@@ -26,6 +27,7 @@ import { isPlatformPath } from "./store.js";
|
|
|
26
27
|
import { divertedSummary, isDivertedPath, overlayTestWrites } from "./test-mode.js";
|
|
27
28
|
import { isFileValue, fileContent } from "./secrets.js";
|
|
28
29
|
import { safeTenantSegment, systemInstallSh, SYSTEM_FROM_INPUT_SH, INSTALL_SOCKET } from "./runtime.js";
|
|
30
|
+
import { egressCaSh } from "./network.js";
|
|
29
31
|
import { isOperatorEvent } from "./operator.js";
|
|
30
32
|
import { parseBrowserPod } from "./browser-pod.js";
|
|
31
33
|
/**
|
|
@@ -42,7 +44,9 @@ const cli = () => process.env.FOLDRUN_CONTAINER_CLI ?? "docker";
|
|
|
42
44
|
// there: everything platform-owned (store.ts#isPlatformPath — also never
|
|
43
45
|
// copied in), plus knowledge/, which goes in for reading and is dropped on
|
|
44
46
|
// the way out, and .git/.
|
|
45
|
-
|
|
47
|
+
// hooks/: a step must not rewrite the code that guards it — for this run (the
|
|
48
|
+
// code is read into memory before the model starts, hooks.ts) or the next.
|
|
49
|
+
const DENY_BACK = ["knowledge/", "hooks/", ".git/"];
|
|
46
50
|
// A .git ANYWHERE in the tree, not just at the root. A step that clones a
|
|
47
51
|
// repository into a shared area (files/site, say) was shipping the entire
|
|
48
52
|
// object store back to the workspace, where the harvest then filed thousands
|
|
@@ -531,6 +535,7 @@ try {
|
|
|
531
535
|
schema: input.schema,
|
|
532
536
|
maxTurns: input.maxTurns,
|
|
533
537
|
...(input.limits && Object.keys(input.limits).length ? { limits: input.limits, toolOwners: input.toolOwners ?? {} } : {}),
|
|
538
|
+
...(input.hookNames?.length ? { hookNames: input.hookNames, hookBase: input.hookBase ?? {} } : {}),
|
|
534
539
|
// Slim browsing: the platform put this step on the slim image to browse
|
|
535
540
|
// through the account's pod, and named the file the web tool logs its
|
|
536
541
|
// reconnects to. A lost pod stops the step; what it wrote decides what
|
|
@@ -808,6 +813,7 @@ ${CORE_INSTALL}`;
|
|
|
808
813
|
// reads the status file and fails the step with apt's own words.
|
|
809
814
|
const ENTRY = `#!/bin/sh
|
|
810
815
|
set +e
|
|
816
|
+
${egressCaSh("/opt/runner/job")}
|
|
811
817
|
${SYSTEM_FROM_INPUT_SH}
|
|
812
818
|
${systemInstallSh(RUNTIME_CACHE)}
|
|
813
819
|
${INSTALL_HELPER_SH}
|
|
@@ -983,6 +989,7 @@ export function parseDriverLine(line) {
|
|
|
983
989
|
...(isOperatorEvent(parsed.operator) ? { operator: parsed.operator } : {}),
|
|
984
990
|
...(typeof parsed.subagent === "string" && /^[a-z0-9-]{1,64}$/.test(parsed.subagent) ? { subagent: parsed.subagent } : {}),
|
|
985
991
|
...(parsed.check === true ? { check: true } : {}),
|
|
992
|
+
...(isHookRecord(parsed.hook) ? { hook: parsed.hook } : {}),
|
|
986
993
|
};
|
|
987
994
|
}
|
|
988
995
|
if (parsed.e === "done") {
|
package/dist/src/runner.d.ts
CHANGED
|
@@ -250,6 +250,7 @@ grantsOnly?: boolean): {
|
|
|
250
250
|
FOLDRUN_BROWSER_WEBGPU?: string | undefined;
|
|
251
251
|
FOLDRUN_BROWSER_IGNORE_HTTPS_ERRORS?: string | undefined;
|
|
252
252
|
FOLDRUN_BROWSER_STATE_KEY?: string | undefined;
|
|
253
|
+
FOLDRUN_BROWSER_STATE?: string | undefined;
|
|
253
254
|
FOLDRUN_BROWSER_VIDEO?: string | undefined;
|
|
254
255
|
FOLDRUN_BROWSER_LIVE_VIEW?: string | undefined;
|
|
255
256
|
};
|
package/dist/src/runner.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// group receives every prior result, labeled per agent. Optional steps ("2?")
|
|
3
3
|
// fail without failing the flow. A direct agent run is a one-step ad-hoc
|
|
4
4
|
// flow, so there is a single execution path.
|
|
5
|
+
import { hookNames } from "./hooks.js";
|
|
5
6
|
import fs from "node:fs";
|
|
6
7
|
import os from "node:os";
|
|
7
8
|
import path from "node:path";
|
|
@@ -34,7 +35,8 @@ import { TOOL_MAP, BUILTIN_TOOLS, ownToolNames, toolRefs, legacyUseNames, legacy
|
|
|
34
35
|
import { refNames } from "./refs.js";
|
|
35
36
|
import { accountDir, workspaceDir, buildMemoryIndex, workspaceMemoryIndex, knowledgeIndex, workspaceKnowledgeIndex, workspaceTools, brokenToolReport, listWorkspaces, listTenants, listRuns, syncBundleFor, parseToolDef, readFlow, markerPresent, whenRowsPath, parseApis, resolveModel, parseProvider, providerEnvFor, resolveEffort, isEffortWord, EFFORT_LEVELS, checkFormatVersion, readRun, writeRun, runSummary, verdictOf, sumAttempts, parseSchemaText, STORAGE_DIR, adoptLegacyVersionKey, listAgents, } from "./store.js";
|
|
36
37
|
import { loadCatalog, checkModel, clampEffort, catalogCost, findModel } from "./catalog.js";
|
|
37
|
-
import { resolveSecrets, getSecret, materializeSecrets } from "./secrets.js";
|
|
38
|
+
import { resolveSecrets, getSecret, materializeSecrets, secretSites } from "./secrets.js";
|
|
39
|
+
import { resolveNetwork, networkLine, proxyEnv, EGRESS_CA_ENV } from "./network.js";
|
|
38
40
|
import { materializeFileSecrets, cleanupFileSecrets } from "./secret-files.js";
|
|
39
41
|
import { buildApiTools, secretsUsedByApi } from "./api-tools.js";
|
|
40
42
|
import { attachOperations, prefetchOpenApi } from "./openapi.js";
|
|
@@ -59,6 +61,24 @@ export { localDate } from "./clock.js";
|
|
|
59
61
|
// An MCP tool definition becomes an SDK server config. ${SECRET} placeholders
|
|
60
62
|
// in env and headers resolve server-side, so a credential reaches the server
|
|
61
63
|
// process without ever entering the model's context.
|
|
64
|
+
/** Remote MCP servers with each bound secret's value put back as its
|
|
65
|
+
* `${NAME}`, granted to that server's host — the sandbox then holds the
|
|
66
|
+
* name and the proxy fills it, as for an http tool. */
|
|
67
|
+
function unbindMcpHeaders(servers, bound, values, grant) {
|
|
68
|
+
const known = Object.fromEntries(bound.filter((n) => n in values).map((n) => [n, values[n]]));
|
|
69
|
+
const out = {};
|
|
70
|
+
for (const [name, server] of Object.entries(servers)) {
|
|
71
|
+
if (server.type !== "http" || !server.headers) {
|
|
72
|
+
out[name] = server;
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
const host = hostOf(server.url);
|
|
76
|
+
out[name] = host
|
|
77
|
+
? { ...server, headers: Object.fromEntries(Object.entries(server.headers).map(([k, v]) => [k, unsubstitute(v, known, host, grant)])) }
|
|
78
|
+
: server;
|
|
79
|
+
}
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
62
82
|
function mcpConfig(spec, tenant, workspace) {
|
|
63
83
|
const fill = (values) => {
|
|
64
84
|
const out = {};
|
|
@@ -1214,6 +1234,7 @@ grantsOnly = false) {
|
|
|
1214
1234
|
...(browseSettings.webgpu ? { FOLDRUN_BROWSER_WEBGPU: "1" } : {}),
|
|
1215
1235
|
...(browseSettings.ignore_https_errors ? { FOLDRUN_BROWSER_IGNORE_HTTPS_ERRORS: "1" } : {}),
|
|
1216
1236
|
...(browseSettings.state_key ? { FOLDRUN_BROWSER_STATE_KEY: browseSettings.state_key } : {}),
|
|
1237
|
+
...(browseSettings.state ? { FOLDRUN_BROWSER_STATE: browseSettings.state } : {}),
|
|
1217
1238
|
...(browseSettings.video ? { FOLDRUN_BROWSER_VIDEO: "1" } : {}),
|
|
1218
1239
|
...(browseSettings.live_view === false ? { FOLDRUN_BROWSER_LIVE_VIEW: "0" } : {}),
|
|
1219
1240
|
};
|
|
@@ -2167,6 +2188,18 @@ stepIndex) {
|
|
|
2167
2188
|
const limitOpts = Object.keys(stepLimits).length ? { limits: stepLimits, toolOwners: limitOwners } : {};
|
|
2168
2189
|
if (limitOpts.limits)
|
|
2169
2190
|
push("info", `limits: ${Object.entries(stepLimits).map(([k, n]) => `${k} ${n}`).join(", ")} — per step; a call past one is refused`);
|
|
2191
|
+
// `hooks:` — the account's, the workspace's, then the agent's own, each
|
|
2192
|
+
// once; resolved and run inside the step (hooks.ts, step-exec.ts).
|
|
2193
|
+
const stepHookNames = [...new Set([
|
|
2194
|
+
...hookNames(readAgentsMd(accountDir(tenant))?.data?.hooks),
|
|
2195
|
+
...hookNames(readAgentsMd(workspaceRootOf(agentDir))?.data?.hooks),
|
|
2196
|
+
...hookNames(front.hooks),
|
|
2197
|
+
])];
|
|
2198
|
+
const hookOpts = stepHookNames.length
|
|
2199
|
+
? { hookNames: stepHookNames, hookBase: { run_id: runId ?? null, step: stepIndex ?? null, agent: step.agent, workspace: path.basename(workspaceRootOf(agentDir)) } }
|
|
2200
|
+
: {};
|
|
2201
|
+
if (stepHookNames.length)
|
|
2202
|
+
push("info", `hooks: ${stepHookNames.join(", ")}`);
|
|
2170
2203
|
const isolation = process.env.FOLDRUN_RUN_ISOLATION;
|
|
2171
2204
|
if (isolatedRun()) {
|
|
2172
2205
|
// The isolated path: the whole loop — model, built-in tools, scripts —
|
|
@@ -2217,7 +2250,46 @@ stepIndex) {
|
|
|
2217
2250
|
}
|
|
2218
2251
|
}
|
|
2219
2252
|
}
|
|
2253
|
+
// `network:` — which sites the sandbox may reach (network.ts). Resolved
|
|
2254
|
+
// before the lease, because the proxy holds the list per request. A
|
|
2255
|
+
// step that may fetch or browse the open web defaults to open; every
|
|
2256
|
+
// other to the trusted list plus what it declares.
|
|
2257
|
+
const workspaceName = path.basename(workspaceRootOf(agentDir));
|
|
2258
|
+
const readsWeb = scriptSpecs.some((sc) => sc.name === "web") && (!web.actions || web.actions.some((a) => a === "fetch" || a === "browse"));
|
|
2259
|
+
// Secrets bound to sites in the vault: filled by the proxy for those
|
|
2260
|
+
// sites alone, never put in the sandbox (egress.ts).
|
|
2261
|
+
const boundSites = secretSites(tenant, Object.keys(liveSecrets), workspaceName);
|
|
2262
|
+
const network = resolveNetwork({
|
|
2263
|
+
account: readAgentsMd(accountDir(tenant))?.data?.network,
|
|
2264
|
+
workspace: readAgentsMd(workspaceRootOf(agentDir))?.data?.network,
|
|
2265
|
+
agent: front.network,
|
|
2266
|
+
readsWeb,
|
|
2267
|
+
implied: [
|
|
2268
|
+
...apiSpecs.map((a) => hostOf(a.base)).filter((h) => !!h),
|
|
2269
|
+
...Object.values(mcpServers).map((m) => ("url" in m && typeof m.url === "string" ? hostOf(m.url) : null)).filter((h) => !!h),
|
|
2270
|
+
...Object.values(grant.secrets).flatMap((g) => g.hosts),
|
|
2271
|
+
...Object.values(boundSites).flat(),
|
|
2272
|
+
],
|
|
2273
|
+
});
|
|
2274
|
+
grant.network = { level: network.level, hosts: network.hosts };
|
|
2220
2275
|
const lease = await platform.egress.lease({ tenant, runId: runId ?? "adhoc", grant, test: testRun, ...(stepIndex !== undefined && stepIndex >= 0 ? { step: stepIndex } : {}) });
|
|
2276
|
+
// The forward door: every program in the sandbox sends through the
|
|
2277
|
+
// proxy, which holds the site list and fills bound secrets. A proxy
|
|
2278
|
+
// without the door (an older platform) leaves programs going direct.
|
|
2279
|
+
// A step that may browse gets no proxy variables: the browser in it
|
|
2280
|
+
// reads HTTPS_PROXY too, and Chromium cannot send credentials written
|
|
2281
|
+
// into a proxy URL — every page failed ERR_INVALID_AUTH_CREDENTIALS
|
|
2282
|
+
// (14 Oct 2026, the reel posters). Such a step is open by default,
|
|
2283
|
+
// and its bound secrets materialise as they did before the door.
|
|
2284
|
+
const browses = scriptSpecs.some((sc) => sc.name === "web") && (!web.actions || web.actions.includes("browse"));
|
|
2285
|
+
const forward = lease?.proxy && !browses ? lease : null;
|
|
2286
|
+
push("info", networkLine(network, forward ? forward.enforce === true : null));
|
|
2287
|
+
const bound = forward ? Object.keys(boundSites).filter((name) => name in liveSecrets) : [];
|
|
2288
|
+
for (const name of bound) {
|
|
2289
|
+
for (const site of boundSites[name])
|
|
2290
|
+
addGrant(grant, name, liveSecrets[name], site);
|
|
2291
|
+
(grant.healthKeys ??= {})[name] = healthKey(name, secretScopes[name] === "workspace" ? "workspace" : "account", workspaceName);
|
|
2292
|
+
}
|
|
2221
2293
|
// Which secrets must still be real inside the sandbox: a script tool
|
|
2222
2294
|
// reads them from its environment, and unless it says `secrets:
|
|
2223
2295
|
// proxied` it needs the value, not the name. bash is a script by
|
|
@@ -2229,19 +2301,24 @@ stepIndex) {
|
|
|
2229
2301
|
...(scriptSpecs.some((sc) => sc.name === "web") && (!web.actions || web.actions.includes("browse")) ? ["web (browse)"] : []),
|
|
2230
2302
|
...(allowed.includes("Bash") ? ["bash"] : []),
|
|
2231
2303
|
];
|
|
2232
|
-
const materialise = !lease || materialisers.length > 0;
|
|
2233
2304
|
const declared = Object.keys(liveSecrets);
|
|
2305
|
+
// A bound secret is never materialised, whatever the step grants.
|
|
2306
|
+
const unbound = declared.filter((name) => !bound.includes(name));
|
|
2307
|
+
const materialise = (!lease || materialisers.length > 0) && unbound.length > 0;
|
|
2234
2308
|
if (lease) {
|
|
2309
|
+
const boundNote = bound.length ? `${bound.join(", ")} bound to sites — filled by the egress proxy, never in the sandbox` : "";
|
|
2235
2310
|
push("info", declared.length
|
|
2236
2311
|
? materialise
|
|
2237
|
-
? `secrets: ${
|
|
2238
|
-
: `secrets: ${declared.join(", ")} proxied — nothing in the sandbox but the names`
|
|
2312
|
+
? `secrets: ${unbound.join(", ")} materialised in the sandbox for ${materialisers.join(", ")}${boundNote ? `; ${boundNote}` : ""}; api tools and the model key go through the egress proxy`
|
|
2313
|
+
: `secrets: ${declared.join(", ")} proxied — nothing in the sandbox but the names${boundNote ? ` (${boundNote})` : ""}`
|
|
2239
2314
|
: "secrets: none declared; the model key goes through the egress proxy");
|
|
2240
2315
|
}
|
|
2241
2316
|
// Sandbox env for the declared secrets: the value, or its own name as
|
|
2242
2317
|
// a placeholder that the proxy fills on the way out.
|
|
2243
2318
|
const placeholders = Object.fromEntries(declared.map((name) => [name, `\${${name}}`]));
|
|
2244
|
-
let sandboxSecrets = materialise
|
|
2319
|
+
let sandboxSecrets = materialise
|
|
2320
|
+
? { ...liveSecrets, ...Object.fromEntries(bound.map((name) => [name, placeholders[name]])) }
|
|
2321
|
+
: placeholders;
|
|
2245
2322
|
// A test run hands the sandbox no send-capable value. The env var is
|
|
2246
2323
|
// still there, reading TEST_MODE_WITHHELD, so a script that never
|
|
2247
2324
|
// checked FOLDRUN_TEST_MODE fails at the provider's door with an auth
|
|
@@ -2249,11 +2326,13 @@ stepIndex) {
|
|
|
2249
2326
|
// has a declared sender (everything withheld) or a declared reader
|
|
2250
2327
|
// (nothing withheld); see test-mode.ts for the order.
|
|
2251
2328
|
if (testRun && materialise) {
|
|
2252
|
-
const held = withholdSecrets(liveSecrets, {
|
|
2329
|
+
const held = withholdSecrets(Object.fromEntries(unbound.map((name) => [name, liveSecrets[name]])), {
|
|
2253
2330
|
outward: scriptSpecs.some((sc) => sc.outward),
|
|
2254
2331
|
allow: scriptSpecs.some((sc) => sc.testMode === "allow"),
|
|
2255
2332
|
});
|
|
2256
|
-
|
|
2333
|
+
// Bound secrets stay placeholders: the proxy, which holds the test
|
|
2334
|
+
// policy, is the only party that ever fills them.
|
|
2335
|
+
sandboxSecrets = { ...held.env, ...Object.fromEntries(bound.map((name) => [name, placeholders[name]])) };
|
|
2257
2336
|
for (const name of held.withheld) {
|
|
2258
2337
|
push("info", `test: ${name} withheld from the sandbox — TEST_MODE_WITHHELD`, { effect: { kind: "withheld", summary: `${name} withheld from the sandbox` } });
|
|
2259
2338
|
}
|
|
@@ -2308,7 +2387,9 @@ stepIndex) {
|
|
|
2308
2387
|
systemPrompt,
|
|
2309
2388
|
allowed,
|
|
2310
2389
|
mcpNames: consultNames,
|
|
2311
|
-
|
|
2390
|
+
// A remote MCP server's headers had their secrets filled host-side;
|
|
2391
|
+
// a bound one goes back to its ${NAME} for the proxy to fill.
|
|
2392
|
+
mcpServers: bound.length ? unbindMcpHeaders(mcpServers, bound, liveSecrets, grant) : mcpServers,
|
|
2312
2393
|
apis: substitutedApis,
|
|
2313
2394
|
scripts: scriptSpecs,
|
|
2314
2395
|
// The merged spec, not `front.runtime` — see the plan's `runtime`
|
|
@@ -2325,6 +2406,7 @@ stepIndex) {
|
|
|
2325
2406
|
schema: resolvedSchema ?? undefined,
|
|
2326
2407
|
maxTurns: step.maxTurns,
|
|
2327
2408
|
...limitOpts,
|
|
2409
|
+
...hookOpts,
|
|
2328
2410
|
translator: keyName === MODEL_KEY_NAME ? primaryTranslator : secondTranslator,
|
|
2329
2411
|
// Outward steps keep a browser of their own: an executor that
|
|
2330
2412
|
// could browse from the slim image through the account's pod
|
|
@@ -2350,6 +2432,14 @@ stepIndex) {
|
|
|
2350
2432
|
// after and carries the agent's chosen endpoint + token.
|
|
2351
2433
|
...proxied(Object.keys(providerEnv).length === 0 ? modelEnv : providerEnv, keyName),
|
|
2352
2434
|
...(lease ? { [EGRESS_ENV]: lease.url } : {}),
|
|
2435
|
+
// Every program's HTTP(S) through the proxy's forward door, and
|
|
2436
|
+
// the CA it signs bound sites with, for the sandbox to trust.
|
|
2437
|
+
...(forward ? proxyEnv(forward.proxy) : {}),
|
|
2438
|
+
// Only a step with a bound secret has a site the proxy
|
|
2439
|
+
// intercepts, so only that step's TLS clients are pointed at a
|
|
2440
|
+
// bundle with the egress CA in it; every other step's are as
|
|
2441
|
+
// they always were.
|
|
2442
|
+
...(forward?.ca && bound.length ? { [EGRESS_CA_ENV]: Buffer.from(forward.ca).toString("base64") } : {}),
|
|
2353
2443
|
...clockEnv,
|
|
2354
2444
|
// Who is running: the in-process path stamps scripts with these
|
|
2355
2445
|
// host-side, but a container rebuilds its tools from its own
|
|
@@ -2366,6 +2456,9 @@ stepIndex) {
|
|
|
2366
2456
|
// Stamps the sandbox, so stopping this run can destroy it.
|
|
2367
2457
|
runId,
|
|
2368
2458
|
size,
|
|
2459
|
+
// The pod's network, for the executor's own wall (the k8s tier's
|
|
2460
|
+
// network policy): a filtered pod reaches the proxy and DNS only.
|
|
2461
|
+
network: { level: network.level, enforced: forward?.enforce === true },
|
|
2369
2462
|
// A test run's state/ and storage/ changes come out under the run.
|
|
2370
2463
|
divert: testRun && runId ? divertFor(runId) : undefined,
|
|
2371
2464
|
test: testRun,
|
|
@@ -2683,6 +2776,7 @@ stepIndex) {
|
|
|
2683
2776
|
schema: resolvedSchema ?? undefined,
|
|
2684
2777
|
maxTurns: step.maxTurns,
|
|
2685
2778
|
...limitOpts,
|
|
2779
|
+
...hookOpts,
|
|
2686
2780
|
stopRequested,
|
|
2687
2781
|
// What the scripts saw, the verify sees: a flow can then check that
|
|
2688
2782
|
// the proof a step left names THIS run, not one that came before —
|
|
@@ -4281,11 +4375,13 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
|
|
|
4281
4375
|
// Not after a stop: the failure a stop leaves behind is not one a
|
|
4282
4376
|
// rescuer should take over. The merge in save() surfaces the flag.
|
|
4283
4377
|
save();
|
|
4284
|
-
|
|
4285
|
-
|
|
4286
|
-
|
|
4287
|
-
|
|
4288
|
-
|
|
4378
|
+
// One rescue of one failed step. Also used when an evaluator loop
|
|
4379
|
+
// runs out of cycles below, which used to fail the run without ever
|
|
4380
|
+
// reaching the step's on-fail (medium-desk's claim-trimmer was
|
|
4381
|
+
// written for exactly that case and could never run).
|
|
4382
|
+
const rescueStep = async (step) => {
|
|
4383
|
+
if (!step.onFail)
|
|
4384
|
+
return;
|
|
4289
4385
|
const rescuerDir = path.join(pDir, "agents", step.onFail);
|
|
4290
4386
|
if (!fs.existsSync(path.join(rescuerDir, "agent.md"))) {
|
|
4291
4387
|
step.events.push({
|
|
@@ -4293,7 +4389,7 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
|
|
|
4293
4389
|
type: "error",
|
|
4294
4390
|
text: `on-fail: "${step.onFail}" is not an agent in this workspace — no rescue`,
|
|
4295
4391
|
});
|
|
4296
|
-
|
|
4392
|
+
return;
|
|
4297
4393
|
}
|
|
4298
4394
|
step.events.push({
|
|
4299
4395
|
t: new Date().toISOString(),
|
|
@@ -4301,10 +4397,21 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
|
|
|
4301
4397
|
text: `failed — handing to [[${step.onFail}]] (on-fail)`,
|
|
4302
4398
|
});
|
|
4303
4399
|
const failure = (step.result ?? step.events.filter((e) => e.type === "error").map((e) => e.text).join("\n")).slice(0, 4000);
|
|
4400
|
+
// A gated step's approval travels with it: the rescuer acts inside
|
|
4401
|
+
// the same answer the failed agent had, and must be told what it
|
|
4402
|
+
// was. Fixers used to dig it out of the run record, and stop when
|
|
4403
|
+
// they could not (15 Oct 2026).
|
|
4404
|
+
const approved = step.approvedAt
|
|
4405
|
+
? `\nA person approved this step before it ran${step.approvalNote ? " — their answer is below" : ", with no note: a plain yes"}. Act within that approval and nothing wider.`
|
|
4406
|
+
: "";
|
|
4407
|
+
const owesMarker = step.until ? `\nThe step's loop ended without its marker: your reply must end with a line saying ${step.until}.` : "";
|
|
4304
4408
|
const rescue = {
|
|
4305
4409
|
agent: step.onFail,
|
|
4306
4410
|
instruction: `${step.instruction}\n\n<rescue>\nYou are taking this step over: [[${step.agent}]] attempted it and failed. ` +
|
|
4307
|
-
`Its failure, for whatever it is worth:\n${failure}\n</rescue>`,
|
|
4411
|
+
`Its failure, for whatever it is worth:\n${failure}${approved}${owesMarker}\n</rescue>`,
|
|
4412
|
+
...(step.ask ? { ask: step.ask } : {}),
|
|
4413
|
+
...(step.approvedAt ? { approvedAt: step.approvedAt } : {}),
|
|
4414
|
+
...(step.approvalNote ? { approvalNote: step.approvalNote } : {}),
|
|
4308
4415
|
group: step.group,
|
|
4309
4416
|
optional: false,
|
|
4310
4417
|
model: step.model,
|
|
@@ -4352,6 +4459,13 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
|
|
|
4352
4459
|
});
|
|
4353
4460
|
}
|
|
4354
4461
|
save();
|
|
4462
|
+
};
|
|
4463
|
+
for (const step of freshGroup) {
|
|
4464
|
+
if (run.stopRequested)
|
|
4465
|
+
break;
|
|
4466
|
+
if (step.status !== "failed" || !step.onFail)
|
|
4467
|
+
continue;
|
|
4468
|
+
await rescueStep(step);
|
|
4355
4469
|
}
|
|
4356
4470
|
// A step carried into a re-run failed in the run it came from, not
|
|
4357
4471
|
// here: the person chose to start after it. Counting it stopped
|
|
@@ -4451,7 +4565,21 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
|
|
|
4451
4565
|
text: `loop exhausted: after ${looper.loop} cycle${looper.loop === 1 ? "" : "s"} the result still does not say "${looper.until}"`,
|
|
4452
4566
|
});
|
|
4453
4567
|
save();
|
|
4454
|
-
if (
|
|
4568
|
+
if (looper.onFail && !run.stopRequested) {
|
|
4569
|
+
await rescueStep(looper);
|
|
4570
|
+
// The rescuer owes what the loop owed: its marker.
|
|
4571
|
+
// rescueStep mutates the status; widen past TS's "failed" narrowing.
|
|
4572
|
+
if (looper.status === "completed" && !saysUntilMarker(looper.result ?? null, looper.until)) {
|
|
4573
|
+
looper.status = "failed";
|
|
4574
|
+
looper.events.push({
|
|
4575
|
+
t: new Date().toISOString(),
|
|
4576
|
+
type: "error",
|
|
4577
|
+
text: `on-fail: [[${looper.onFail}]]'s result does not say "${looper.until}" either`,
|
|
4578
|
+
});
|
|
4579
|
+
save();
|
|
4580
|
+
}
|
|
4581
|
+
}
|
|
4582
|
+
if (looper.status === "failed" && !looper.optional) {
|
|
4455
4583
|
run.status = "failed";
|
|
4456
4584
|
break;
|
|
4457
4585
|
}
|
package/dist/src/secrets.d.ts
CHANGED
|
@@ -26,6 +26,11 @@ interface StoredSecret {
|
|
|
26
26
|
* record written before per-account keys existed is encrypted with — so an
|
|
27
27
|
* old vault stays readable without a migration having to run first. */
|
|
28
28
|
k?: "tenant";
|
|
29
|
+
/** The sites this secret is bound to (network.ts SITE shape). A bound
|
|
30
|
+
* secret never enters a sandbox: the egress proxy fills its `${NAME}` for
|
|
31
|
+
* these sites alone. Metadata, not a value, so it is stored in the clear
|
|
32
|
+
* beside the ciphertext and survives the value being set again. */
|
|
33
|
+
sites?: string[];
|
|
29
34
|
}
|
|
30
35
|
type SecretsFile = Record<string, StoredSecret>;
|
|
31
36
|
/**
|
|
@@ -36,6 +41,18 @@ type SecretsFile = Record<string, StoredSecret>;
|
|
|
36
41
|
export declare function mutateVaultFile(file: string, change: (all: SecretsFile) => SecretsFile | null): SecretsFile;
|
|
37
42
|
export declare const SECRET_NAME: RegExp;
|
|
38
43
|
export declare function setSecret(tenant: string, name: string, value: string, workspace?: string, kind?: SecretKind): void;
|
|
44
|
+
/**
|
|
45
|
+
* Bind a secret to sites, or unbind it (an empty list). The secret must
|
|
46
|
+
* exist in that scope. Sites are `api.example.com` or `*.example.com` — no
|
|
47
|
+
* scheme, path or port. Returns the list as stored.
|
|
48
|
+
*/
|
|
49
|
+
export declare function setSecretSites(tenant: string, name: string, sites: string[], workspace?: string): string[];
|
|
50
|
+
/**
|
|
51
|
+
* The sites each named secret is bound to, nearest scope first — the same
|
|
52
|
+
* record getSecret would read the value from. A name with no binding (or
|
|
53
|
+
* no secret) is absent from the answer.
|
|
54
|
+
*/
|
|
55
|
+
export declare function secretSites(tenant: string, names: string[], workspace?: string): Record<string, string[]>;
|
|
39
56
|
/** Encrypt one value for an account — the same envelope secrets use, exported
|
|
40
57
|
* so sibling stores (OAuth clients) don't invent a second crypto path. */
|
|
41
58
|
export declare function encryptValue(tenant: string, value: string): {
|
|
@@ -65,6 +82,9 @@ export interface SecretEntry {
|
|
|
65
82
|
/** How this credential works: auto-refreshing oauth2, a signed service
|
|
66
83
|
* account, a file/ssh/api materialised into runs — absent for static values. */
|
|
67
84
|
kind?: SecretKind;
|
|
85
|
+
/** The sites it is bound to — the proxy fills it for these and no other,
|
|
86
|
+
* and it never enters a sandbox. Absent when unbound. */
|
|
87
|
+
sites?: string[];
|
|
68
88
|
}
|
|
69
89
|
export declare function listSecrets(tenant: string, workspace?: string): SecretEntry[];
|
|
70
90
|
export declare function resolveSecrets(tenant: string, names: string[], workspace?: string): {
|
package/dist/src/secrets.js
CHANGED
|
@@ -39,6 +39,7 @@ const tenantKey = (tenant) => platform.tenantKey(tenant);
|
|
|
39
39
|
import crypto from "node:crypto";
|
|
40
40
|
import { assertSafeName } from "./store.js";
|
|
41
41
|
import { trimSlashes } from "./paths.js";
|
|
42
|
+
import { SITE } from "./network.js";
|
|
42
43
|
const keyFile = () => path.join(dataRoot(), ".secret-key");
|
|
43
44
|
/**
|
|
44
45
|
* The install's root secret, as text: FOLDRUN_SECRET_KEY when set, else the
|
|
@@ -163,14 +164,70 @@ export function setSecret(tenant, name, value, workspace, kind) {
|
|
|
163
164
|
throw new Error("secret too long");
|
|
164
165
|
const sealed = encryptValue(tenant, value);
|
|
165
166
|
mutateVaultFile(secretsFile(tenant, workspace), (all) => {
|
|
167
|
+
// A new value keeps the sites the old one was bound to: binding is a
|
|
168
|
+
// property of the credential's use, not of its current value, and a
|
|
169
|
+
// rotation that silently unbound it would put the new value in pods.
|
|
170
|
+
const sites = all[name]?.sites;
|
|
166
171
|
all[name] = {
|
|
167
172
|
...sealed,
|
|
168
173
|
updatedAt: new Date().toISOString(),
|
|
169
174
|
...(kind ? { kind } : {}),
|
|
175
|
+
...(sites?.length ? { sites } : {}),
|
|
170
176
|
};
|
|
171
177
|
return all;
|
|
172
178
|
});
|
|
173
179
|
}
|
|
180
|
+
/**
|
|
181
|
+
* Bind a secret to sites, or unbind it (an empty list). The secret must
|
|
182
|
+
* exist in that scope. Sites are `api.example.com` or `*.example.com` — no
|
|
183
|
+
* scheme, path or port. Returns the list as stored.
|
|
184
|
+
*/
|
|
185
|
+
export function setSecretSites(tenant, name, sites, workspace) {
|
|
186
|
+
const clean = [...new Set(sites.map((x) => x.trim().toLowerCase()).filter(Boolean))];
|
|
187
|
+
for (const site of clean) {
|
|
188
|
+
if (!SITE.test(site))
|
|
189
|
+
throw new Error(`"${site}" is not a site — a domain like api.example.com or *.example.com, no scheme, path or port`);
|
|
190
|
+
}
|
|
191
|
+
let found = false;
|
|
192
|
+
mutateVaultFile(secretsFile(tenant, workspace), (all) => {
|
|
193
|
+
const rec = all[name];
|
|
194
|
+
if (!rec)
|
|
195
|
+
return null;
|
|
196
|
+
found = true;
|
|
197
|
+
// A file, an ssh key or an @api header set is not a value that travels
|
|
198
|
+
// in a header — it is unpacked inside the sandbox, so there is nothing
|
|
199
|
+
// for the proxy to fill.
|
|
200
|
+
if (clean.length && (rec.kind === "file" || rec.kind === "ssh" || rec.kind === "api")) {
|
|
201
|
+
throw new Error(`${name} is a ${rec.kind} secret, unpacked inside the sandbox — only a value sent in a request can be bound to sites`);
|
|
202
|
+
}
|
|
203
|
+
if (clean.length)
|
|
204
|
+
rec.sites = clean;
|
|
205
|
+
else
|
|
206
|
+
delete rec.sites;
|
|
207
|
+
return all;
|
|
208
|
+
});
|
|
209
|
+
if (!found)
|
|
210
|
+
throw new Error(`no secret ${name}${workspace ? ` in workspace ${workspace}` : " in the account"}`);
|
|
211
|
+
return clean;
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* The sites each named secret is bound to, nearest scope first — the same
|
|
215
|
+
* record getSecret would read the value from. A name with no binding (or
|
|
216
|
+
* no secret) is absent from the answer.
|
|
217
|
+
*/
|
|
218
|
+
export function secretSites(tenant, names, workspace) {
|
|
219
|
+
const out = {};
|
|
220
|
+
const own = workspace ? read(tenant, workspace) : {};
|
|
221
|
+
const source = workspace ? platform.previewSourceOf(tenant, workspace) : null;
|
|
222
|
+
const inherited = source ? read(tenant, source) : {};
|
|
223
|
+
const account = read(tenant);
|
|
224
|
+
for (const name of names) {
|
|
225
|
+
const rec = own[name] ?? inherited[name] ?? account[name];
|
|
226
|
+
if (rec?.sites?.length)
|
|
227
|
+
out[name] = [...rec.sites];
|
|
228
|
+
}
|
|
229
|
+
return out;
|
|
230
|
+
}
|
|
174
231
|
/**
|
|
175
232
|
* Which key writes this account's records.
|
|
176
233
|
*
|
|
@@ -271,6 +328,7 @@ export function listSecrets(tenant, workspace) {
|
|
|
271
328
|
updatedAt: rec.updatedAt,
|
|
272
329
|
scope: "account",
|
|
273
330
|
...(rec.kind ? { kind: rec.kind } : {}),
|
|
331
|
+
...(rec.sites?.length ? { sites: [...rec.sites] } : {}),
|
|
274
332
|
}));
|
|
275
333
|
if (!workspace)
|
|
276
334
|
return account.sort((a, b) => a.name.localeCompare(b.name));
|
|
@@ -280,6 +338,7 @@ export function listSecrets(tenant, workspace) {
|
|
|
280
338
|
scope: "workspace",
|
|
281
339
|
shadowed: account.some((a) => a.name === name),
|
|
282
340
|
...(rec.kind ? { kind: rec.kind } : {}),
|
|
341
|
+
...(rec.sites?.length ? { sites: [...rec.sites] } : {}),
|
|
283
342
|
}));
|
|
284
343
|
const ownNames = new Set(own.map((s) => s.name));
|
|
285
344
|
return [...own, ...account.filter((a) => !ownNames.has(a.name))].sort((a, b) => a.name.localeCompare(b.name));
|
|
@@ -748,7 +807,12 @@ export function rewrapTenantSecrets(tenant) {
|
|
|
748
807
|
return null;
|
|
749
808
|
const next = { ...all };
|
|
750
809
|
for (const [name, value] of plain) {
|
|
751
|
-
next[name] = {
|
|
810
|
+
next[name] = {
|
|
811
|
+
...encryptValue(tenant, value),
|
|
812
|
+
updatedAt: all[name].updatedAt,
|
|
813
|
+
...(all[name].kind ? { kind: all[name].kind } : {}),
|
|
814
|
+
...(all[name].sites?.length ? { sites: all[name].sites } : {}),
|
|
815
|
+
};
|
|
752
816
|
}
|
|
753
817
|
moved += plain.size;
|
|
754
818
|
return next;
|
package/dist/src/starter.js
CHANGED
|
@@ -263,6 +263,7 @@ only when they asked for exactly that deletion.
|
|
|
263
263
|
| \`memory/\` | what past runs learned — agents write here themselves |
|
|
264
264
|
| \`evals/\` | tests for agents, same idea as unit tests |
|
|
265
265
|
| \`skills/\` | a procedure an agent can follow, named in \`skills:\` |
|
|
266
|
+
| \`hooks/\` | your code at every tool call, step start or step end (\`hook.md\` + \`run.mjs\`), turned on with \`hooks:\` — refuse, rewrite, add context; same on every model |
|
|
266
267
|
| \`tools/\` | a folder tool: its definition and its code together |
|
|
267
268
|
| \`scripts/\` | code the agents call, referenced by \`scripts:\` |
|
|
268
269
|
| \`state/\` | what runs accumulate. Yours to read, never deployed over |
|
|
@@ -294,7 +295,11 @@ memory an agent wrote that your push does not mention. Those belong to the platf
|
|
|
294
295
|
reverted what an agent learned would make every run a little dumber.
|
|
295
296
|
|
|
296
297
|
Secrets never go in these files. \`foldrun secrets\` puts them in the vault, and
|
|
297
|
-
agents reference them by name.
|
|
298
|
+
agents reference them by name. Bind one to the sites it is for
|
|
299
|
+
(\`foldrun secrets sites NAME api.example.com\`) and it never enters a sandbox:
|
|
300
|
+
the egress proxy fills \`\${NAME}\` in requests to those sites alone. Which sites
|
|
301
|
+
a sandbox may reach at all is \`network:\` — open for an agent that reads the web,
|
|
302
|
+
the trusted list plus what it declares for every other (\`foldrun agent network\`).
|
|
298
303
|
|
|
299
304
|
## Talking to a deployed workspace
|
|
300
305
|
|
|
@@ -340,6 +345,7 @@ All of it, \`/api/workspaces/<workspace>\` unless noted:
|
|
|
340
345
|
| \`/export\` · \`/import\` | GET · POST | the workspace, a flow (\`?kind=flow&name=\`) or an agent as a .zip; take one in (\`?dryRun=1\` first, \`?overwrite=1\` to replace) |
|
|
341
346
|
| \`/restore\` | POST | the workspace's source back to a point in its history — \`dryRun\` first, then \`confirm\` |
|
|
342
347
|
| \`/vocabulary\` | GET | what this workspace's documents may say |
|
|
348
|
+
| \`/assistant\` | POST | ask the assistant, or have it write agents, flows, tools and skills — from the docs only; proposals come back, nothing is written |
|
|
343
349
|
|
|
344
350
|
Account-level, outside \`/workspaces\`: \`/api/secrets\`, \`/api/keys\`, \`/api/schedule\`,
|
|
345
351
|
\`/api/approvals\`, \`/api/usage\`, \`/api/library/<kind>\`, \`/api/team\`, \`/api/billing\`,
|
|
@@ -439,6 +445,7 @@ size: large # small | large | heavy — the sandbox it rents
|
|
|
439
445
|
tools: [web, read, ask, my-folder-tool] # built-ins and your own tools/, one list; \`ask\` lets it ask you mid-step
|
|
440
446
|
disallowedTools: [Bash] # subtract from what it would otherwise have
|
|
441
447
|
skills: [house-style] # from skills/
|
|
448
|
+
hooks: [no-force-push] # from hooks/ or the library: code at every tool call
|
|
442
449
|
scripts: [summarise.py] # from scripts/, each becomes a callable tool
|
|
443
450
|
apis: [{ name: crm }] # an HTTP API, as a tool
|
|
444
451
|
mcpServers: {} # an MCP server, as a tool
|
|
@@ -446,6 +453,7 @@ agents: [writer] # colleagues this one may consult (an answer, no too
|
|
|
446
453
|
subagents: [researcher] # colleagues it may delegate a job to (own context, own tools ≤ its own)
|
|
447
454
|
secrets: [CRM_TOKEN] # keys the agent uses itself; a tool's own come with the tool
|
|
448
455
|
limits: {web.search: 40, crm: 20, calls: 300} # per step; a call past a limit is refused, never run
|
|
456
|
+
network: [api.example.com] # sites its sandbox may reach, added to the trusted list; open | trusted | none
|
|
449
457
|
timezone: Australia/Sydney # the calendar it works to — IANA name or UTC+10
|
|
450
458
|
provider: {} # BYOK for this agent; account-wide: \`foldrun account model set\`
|
|
451
459
|
permissionMode: plan # plan first, act once approved
|
package/dist/src/step-exec.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type HookDef, type HookRecord } from "./hooks.ts";
|
|
1
2
|
import type { McpServerConfig } from "@anthropic-ai/claude-agent-sdk";
|
|
2
3
|
import type { Effort } from "./store.ts";
|
|
3
4
|
import type { TestEffect } from "./test-mode.ts";
|
|
@@ -40,6 +41,14 @@ export interface ExecOutcome {
|
|
|
40
41
|
browserPod?: BrowserPodOutcome;
|
|
41
42
|
}
|
|
42
43
|
export interface ExecOptions {
|
|
44
|
+
/** `hooks:` resolved host-side (hooks.ts): run at this step's own dispatch,
|
|
45
|
+
* inside its sandbox. `hookBase` is what every hook event carries. */
|
|
46
|
+
hooks?: HookDef[];
|
|
47
|
+
/** Or the names (`hooks:` from the account, workspace and agent), resolved
|
|
48
|
+
* here against this step's own roots — in a sandbox those are not the
|
|
49
|
+
* host's paths. A name that resolves to nothing refuses the step. */
|
|
50
|
+
hookNames?: string[];
|
|
51
|
+
hookBase?: Record<string, unknown>;
|
|
43
52
|
agentDir: string;
|
|
44
53
|
workspaceRoot: string;
|
|
45
54
|
libraryRoot: string;
|
|
@@ -151,6 +160,7 @@ export type EventExtra = {
|
|
|
151
160
|
operator?: OperatorEvent;
|
|
152
161
|
subagent?: string;
|
|
153
162
|
check?: boolean;
|
|
163
|
+
hook?: HookRecord;
|
|
154
164
|
};
|
|
155
165
|
/** Conservative per-token rates for a model nothing else can price — an
|
|
156
166
|
* unknown id on a gateway with no catalogue. Opus-class, so a ceiling
|