@foldrun/core 0.3.0 → 0.4.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/src/approvals.d.ts +104 -1
- package/dist/src/approvals.js +192 -2
- package/dist/src/budget.d.ts +32 -0
- package/dist/src/budget.js +177 -0
- package/dist/src/catalog.js +2 -1
- package/dist/src/confine.js +6 -1
- package/dist/src/egress.d.ts +19 -2
- package/dist/src/egress.js +2 -1
- package/dist/src/evals.d.ts +4 -0
- package/dist/src/evals.js +20 -11
- package/dist/src/flow-lint.d.ts +4 -0
- package/dist/src/flow-lint.js +108 -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/library.js +13 -1
- package/dist/src/notify.d.ts +79 -1
- package/dist/src/notify.js +243 -9
- package/dist/src/paths.d.ts +5 -0
- package/dist/src/paths.js +32 -0
- package/dist/src/platform.d.ts +102 -2
- package/dist/src/platform.js +18 -2
- package/dist/src/provider-health.d.ts +55 -0
- package/dist/src/provider-health.js +232 -0
- package/dist/src/run-container.d.ts +57 -4
- package/dist/src/run-container.js +147 -41
- package/dist/src/runner.d.ts +39 -3
- package/dist/src/runner.js +422 -73
- package/dist/src/script-tools.d.ts +12 -0
- package/dist/src/script-tools.js +10 -2
- package/dist/src/secret-health.d.ts +59 -0
- package/dist/src/secret-health.js +155 -0
- package/dist/src/secrets.d.ts +19 -1
- package/dist/src/secrets.js +44 -12
- package/dist/src/starter.js +3 -0
- package/dist/src/step-exec.d.ts +47 -1
- package/dist/src/step-exec.js +155 -60
- package/dist/src/store.d.ts +276 -3
- package/dist/src/store.js +552 -26
- 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-test.js +48 -21
- package/dist/src/translator.js +2 -1
- package/dist/src/trigger-log.d.ts +43 -0
- package/dist/src/trigger-log.js +122 -0
- package/dist/src/triggers.d.ts +20 -0
- package/dist/src/triggers.js +91 -10
- package/dist/src/webhook.d.ts +15 -3
- package/dist/src/webhook.js +11 -11
- package/package.json +4 -4
- package/src/approvals.ts +253 -2
- package/src/budget.ts +178 -0
- package/src/catalog.ts +2 -1
- package/src/confine.ts +6 -1
- package/src/egress.ts +20 -4
- package/src/evals.ts +25 -9
- package/src/flow-lint.ts +118 -0
- package/src/host-env.ts +51 -0
- package/src/json-schema.ts +184 -0
- package/src/library.ts +13 -1
- package/src/notify.ts +272 -11
- package/src/paths.ts +29 -0
- package/src/platform.ts +107 -3
- package/src/provider-health.ts +274 -0
- package/src/run-container.ts +175 -36
- package/src/runner.ts +435 -65
- package/src/script-tools.ts +18 -2
- package/src/secret-health.ts +185 -0
- package/src/secrets.ts +44 -10
- package/src/starter.ts +3 -0
- package/src/step-exec.ts +128 -13
- package/src/storage.ts +8 -2
- package/src/store.ts +746 -25
- package/src/tar.ts +2 -1
- package/src/test-mode.ts +311 -0
- package/src/tool-test.ts +49 -20
- package/src/translator.ts +2 -1
- package/src/trigger-log.ts +159 -0
- package/src/triggers.ts +92 -10
- package/src/webhook.ts +17 -11
package/src/script-tools.ts
CHANGED
|
@@ -20,6 +20,7 @@ import { spawn } from "node:child_process";
|
|
|
20
20
|
import { z } from "zod";
|
|
21
21
|
import { createSdkMcpServer, tool } from "@anthropic-ai/claude-agent-sdk";
|
|
22
22
|
import { runInContainer } from "./container.ts";
|
|
23
|
+
import { hostSafeEnv } from "./host-env.ts";
|
|
23
24
|
|
|
24
25
|
// A script's only clock is the `timeout:` its own tool.md declares. There
|
|
25
26
|
// was a two-minute default and a ten-minute cap here; both were the
|
|
@@ -55,6 +56,14 @@ export interface ScriptSpec {
|
|
|
55
56
|
* default, `materialised`, is a program that reads values from its
|
|
56
57
|
* environment and needs them real — a browser seeding cookies. */
|
|
57
58
|
secrets?: "proxied" | "materialised";
|
|
59
|
+
/** `outward: true` — this program makes something happen outside (sends,
|
|
60
|
+
* posts, orders). On a test run a step that grants it is handed no real
|
|
61
|
+
* secret at all, whatever their names. */
|
|
62
|
+
outward?: boolean;
|
|
63
|
+
/** `test_mode: allow` — a reader that happens to use a send-capable
|
|
64
|
+
* secret's name (a GBP reader on a GBP token): on a test run the step
|
|
65
|
+
* still gets the real values. An `outward` tool in the same step wins. */
|
|
66
|
+
testMode?: "allow";
|
|
58
67
|
}
|
|
59
68
|
|
|
60
69
|
export function parseScripts(raw: unknown): ScriptSpec[] {
|
|
@@ -82,6 +91,8 @@ export function parseScripts(raw: unknown): ScriptSpec[] {
|
|
|
82
91
|
args,
|
|
83
92
|
interpreter: typeof e.interpreter === "string" ? e.interpreter : undefined,
|
|
84
93
|
secrets: e.secrets === "proxied" ? "proxied" : undefined,
|
|
94
|
+
outward: e.outward === true ? true : undefined,
|
|
95
|
+
testMode: e.test_mode === "allow" ? "allow" : undefined,
|
|
85
96
|
timeout: Number.isFinite(timeout) && timeout > 0 ? timeout : undefined,
|
|
86
97
|
});
|
|
87
98
|
}
|
|
@@ -128,7 +139,7 @@ function containerInterpreter(abs: string): string {
|
|
|
128
139
|
return byExt[path.extname(abs).toLowerCase()] ?? abs;
|
|
129
140
|
}
|
|
130
141
|
|
|
131
|
-
function runScript(
|
|
142
|
+
export function runScript(
|
|
132
143
|
cwd: string,
|
|
133
144
|
spec: ScriptSpec,
|
|
134
145
|
values: Record<string, string>,
|
|
@@ -229,9 +240,14 @@ function runScript(
|
|
|
229
240
|
return;
|
|
230
241
|
}
|
|
231
242
|
|
|
243
|
+
// Not process.env: on a platform that is every tenant's root key and
|
|
244
|
+
// the datastore credentials. The child starts from the allowlisted
|
|
245
|
+
// base and gets what the agent declared, by name — see host-env.ts.
|
|
246
|
+
// In a run container `env` already carries the container's whole
|
|
247
|
+
// environment, which is the boundary, so nothing is lost there.
|
|
232
248
|
const child = spawn(cmd, args, {
|
|
233
249
|
cwd,
|
|
234
|
-
env: { ...
|
|
250
|
+
env: { ...hostSafeEnv(), ...env },
|
|
235
251
|
timeout: timeoutFor(spec),
|
|
236
252
|
});
|
|
237
253
|
let out = "";
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// Does this credential still work?
|
|
2
|
+
//
|
|
3
|
+
// The vault knows a secret's name, its scope and its shape. It knows nothing
|
|
4
|
+
// about whether the thing on the other end still accepts it, and that is the
|
|
5
|
+
// only question anyone actually has. A key that has quietly stopped working
|
|
6
|
+
// looks identical to a key nobody has used yet, and both look identical to a
|
|
7
|
+
// key that is fine — three very different situations rendered the same way.
|
|
8
|
+
//
|
|
9
|
+
// Two facts, recorded where they are already known and nowhere else:
|
|
10
|
+
//
|
|
11
|
+
// when it was last used, and against which host
|
|
12
|
+
// what the other end said the last time — accepted, or refused
|
|
13
|
+
//
|
|
14
|
+
// The egress proxy sees both for every http tool and every model call,
|
|
15
|
+
// because filling the credential is its whole job. Notifications and the
|
|
16
|
+
// provider check report their own. Nothing new is instrumented, and a secret
|
|
17
|
+
// materialised into a sandbox reports nothing, which the page says plainly
|
|
18
|
+
// rather than guessing.
|
|
19
|
+
//
|
|
20
|
+
// Never the value, never a hash of the value, never the request body. A
|
|
21
|
+
// name, a host, a status and a time.
|
|
22
|
+
|
|
23
|
+
import fs from "node:fs";
|
|
24
|
+
import path from "node:path";
|
|
25
|
+
import crypto from "node:crypto";
|
|
26
|
+
import { accountDir } from "./store.ts";
|
|
27
|
+
|
|
28
|
+
/** How a credential is identified here. A name alone is not enough: the
|
|
29
|
+
* account and each workspace can hold a secret by the same name, and they
|
|
30
|
+
* are different credentials with different health. */
|
|
31
|
+
export function healthKey(name: string, scope: "account" | "workspace", workspace?: string | null): string {
|
|
32
|
+
return scope === "workspace" && workspace ? `workspace:${workspace}:${name}` : `account:${name}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface SecretUse {
|
|
36
|
+
/** ISO time of the most recent use. */
|
|
37
|
+
at: string;
|
|
38
|
+
/** The host it was sent to, so "which of my four keys is this" has an
|
|
39
|
+
* answer without opening a run. */
|
|
40
|
+
host: string;
|
|
41
|
+
/** The response status, when there was one. */
|
|
42
|
+
status: number | null;
|
|
43
|
+
/** Refused means the credential itself was rejected: 401, 402 or 403.
|
|
44
|
+
* A 500 is the other end failing, which says nothing about the key. */
|
|
45
|
+
outcome: "accepted" | "refused" | "error";
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface SecretRecord {
|
|
49
|
+
last: SecretUse;
|
|
50
|
+
/** Consecutive refusals, newest first — one is a fluke, five is a dead
|
|
51
|
+
* key. Reset by any acceptance. */
|
|
52
|
+
refusals: number;
|
|
53
|
+
/** The last acceptance, kept through a run of refusals so a page can say
|
|
54
|
+
* "worked until Tuesday", which is the sentence that dates the breakage. */
|
|
55
|
+
lastAccepted: string | null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
type HealthFile = Record<string, SecretRecord>;
|
|
59
|
+
|
|
60
|
+
const healthFile = (tenant: string) => path.join(accountDir(tenant), "secret-health.json");
|
|
61
|
+
|
|
62
|
+
function read(tenant: string): HealthFile {
|
|
63
|
+
try {
|
|
64
|
+
return JSON.parse(fs.readFileSync(healthFile(tenant), "utf8")) as HealthFile;
|
|
65
|
+
} catch {
|
|
66
|
+
return {};
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function write(tenant: string, data: HealthFile): void {
|
|
71
|
+
const file = healthFile(tenant);
|
|
72
|
+
if (!fs.existsSync(path.dirname(file))) return;
|
|
73
|
+
// pid alone is not unique across pods on a shared volume.
|
|
74
|
+
const tmp = `${file}.${process.pid}.${crypto.randomBytes(4).toString("hex")}`;
|
|
75
|
+
fs.writeFileSync(tmp, JSON.stringify(data, null, 2));
|
|
76
|
+
fs.renameSync(tmp, file);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Writes are queued and coalesced off the caller's path. The egress proxy
|
|
80
|
+
// notes a use per secret per request, before it streams the response body,
|
|
81
|
+
// and a synchronous read-modify-write of a JSON file there is latency on
|
|
82
|
+
// every proxied call — "never blocks" has to be true, not aspirational.
|
|
83
|
+
// One writer per process; the queue drains in order.
|
|
84
|
+
const pending = new Map<string, { name: string; use: { host: string; status: number | null; at?: string } }[]>();
|
|
85
|
+
let draining = false;
|
|
86
|
+
|
|
87
|
+
function drain(): void {
|
|
88
|
+
if (draining) return;
|
|
89
|
+
draining = true;
|
|
90
|
+
setImmediate(() => {
|
|
91
|
+
try {
|
|
92
|
+
for (const [tenant, uses] of pending) {
|
|
93
|
+
pending.delete(tenant);
|
|
94
|
+
const data = read(tenant);
|
|
95
|
+
for (const { name, use } of uses) apply(data, name, use);
|
|
96
|
+
write(tenant, data);
|
|
97
|
+
}
|
|
98
|
+
} catch {
|
|
99
|
+
// best effort, always
|
|
100
|
+
} finally {
|
|
101
|
+
draining = false;
|
|
102
|
+
if (pending.size) drain();
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function apply(data: HealthFile, name: string, use: { host: string; status: number | null; at?: string }): void {
|
|
108
|
+
const outcome = outcomeFor(use.status);
|
|
109
|
+
const at = use.at ?? new Date().toISOString();
|
|
110
|
+
const prev = data[name];
|
|
111
|
+
data[name] = {
|
|
112
|
+
last: { at, host: use.host, status: use.status, outcome },
|
|
113
|
+
// Only a refusal counts against a key. An error at the other end is
|
|
114
|
+
// the other end's problem and must not accumulate into a false alarm.
|
|
115
|
+
refusals: outcome === "refused" ? (prev?.refusals ?? 0) + 1 : outcome === "accepted" ? 0 : prev?.refusals ?? 0,
|
|
116
|
+
lastAccepted: outcome === "accepted" ? at : prev?.lastAccepted ?? null,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** 401, 402 and 403 are the credential being refused. Everything else is
|
|
121
|
+
* the request, the model or the provider — none of which is the key's
|
|
122
|
+
* fault, and calling them a credential failure would send people to
|
|
123
|
+
* rotate a key that was never the problem. */
|
|
124
|
+
export function outcomeFor(status: number | null): SecretUse["outcome"] {
|
|
125
|
+
if (status === null) return "error";
|
|
126
|
+
if (status === 401 || status === 402 || status === 403) return "refused";
|
|
127
|
+
if (status >= 200 && status < 400) return "accepted";
|
|
128
|
+
return "error";
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Record one use. `key` is a health key (see healthKey), never a bare name.
|
|
133
|
+
* Never throws and never blocks: the write is queued and happens off the
|
|
134
|
+
* caller's path, because this runs on every proxied request.
|
|
135
|
+
*/
|
|
136
|
+
export function noteSecretUse(
|
|
137
|
+
tenant: string,
|
|
138
|
+
key: string,
|
|
139
|
+
use: { host: string; status: number | null; at?: string },
|
|
140
|
+
): void {
|
|
141
|
+
if (!key) return;
|
|
142
|
+
const list = pending.get(tenant) ?? [];
|
|
143
|
+
list.push({ name: key, use: { ...use, at: use.at ?? new Date().toISOString() } });
|
|
144
|
+
pending.set(tenant, list);
|
|
145
|
+
drain();
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Wait for queued writes to land — for tests, and for a process that is
|
|
149
|
+
* about to exit. */
|
|
150
|
+
export function flushSecretHealth(): Promise<void> {
|
|
151
|
+
return new Promise((resolve) => {
|
|
152
|
+
const check = () => (pending.size === 0 && !draining ? resolve() : setImmediate(check));
|
|
153
|
+
check();
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Everything known about this account's credentials, by name. Names the
|
|
158
|
+
* vault holds but nothing has used are simply absent, which is the honest
|
|
159
|
+
* answer: unused is not the same as broken. */
|
|
160
|
+
export function secretHealth(tenant: string): HealthFile {
|
|
161
|
+
return read(tenant);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** The credentials worth saying something about: refused the last time they
|
|
165
|
+
* were used. Sorted worst first — most consecutive refusals, then oldest
|
|
166
|
+
* last-success, which is the order someone would work through them. */
|
|
167
|
+
export function failingSecrets(tenant: string): { name: string; record: SecretRecord }[] {
|
|
168
|
+
return Object.entries(read(tenant))
|
|
169
|
+
.filter(([, r]) => r.last.outcome === "refused")
|
|
170
|
+
.map(([name, record]) => ({ name, record }))
|
|
171
|
+
.sort((a, b) => b.record.refusals - a.record.refusals || (a.record.lastAccepted ?? "").localeCompare(b.record.lastAccepted ?? ""));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Drop what is known about a secret — for a delete, and for a rotation,
|
|
175
|
+
* where the old key's refusals say nothing about the new one. */
|
|
176
|
+
export function forgetSecretHealth(tenant: string, name: string): void {
|
|
177
|
+
try {
|
|
178
|
+
const data = read(tenant);
|
|
179
|
+
if (!(name in data)) return;
|
|
180
|
+
delete data[name];
|
|
181
|
+
write(tenant, data);
|
|
182
|
+
} catch {
|
|
183
|
+
// best effort
|
|
184
|
+
}
|
|
185
|
+
}
|
package/src/secrets.ts
CHANGED
|
@@ -24,25 +24,52 @@ import path from "node:path";
|
|
|
24
24
|
import { dataRoot } from "./paths.ts";
|
|
25
25
|
import { platform } from "./platform.ts";
|
|
26
26
|
|
|
27
|
+
/**
|
|
28
|
+
* GCM's auth tag, pinned to its full 16 bytes at every cipher call. Without
|
|
29
|
+
* `authTagLength`, Node accepts whatever length `setAuthTag` is handed — 4, 8,
|
|
30
|
+
* 12, 13, 14, 15 or 16 bytes — so a stored record whose tag had been cut to
|
|
31
|
+
* four bytes would still "verify" on 32 bits of authentication instead of
|
|
32
|
+
* 128. The tag comes from the record on disk; the record is the thing an
|
|
33
|
+
* attacker with file access would edit. One option makes a short tag an
|
|
34
|
+
* error rather than a weaker check.
|
|
35
|
+
*/
|
|
36
|
+
const GCM = { authTagLength: 16 } as const;
|
|
37
|
+
|
|
27
38
|
// The account's own data key, when the platform holds one; the install key
|
|
28
39
|
// otherwise. See platform.ts — locally there is no account key.
|
|
29
40
|
const tenantKey = (tenant: string) => platform.tenantKey(tenant);
|
|
30
41
|
import crypto from "node:crypto";
|
|
31
42
|
import { assertSafeName } from "./store.ts";
|
|
43
|
+
import { trimSlashes } from "./paths.ts";
|
|
32
44
|
|
|
33
45
|
const keyFile = () => path.join(dataRoot(), ".secret-key");
|
|
34
46
|
|
|
35
47
|
/** Which store a secret came from. */
|
|
36
48
|
export type SecretScope = "account" | "workspace";
|
|
37
49
|
|
|
38
|
-
|
|
50
|
+
/**
|
|
51
|
+
* The install's root secret, as text: FOLDRUN_SECRET_KEY when set, else the
|
|
52
|
+
* contents of `.secret-key` under the data root — created here, the first
|
|
53
|
+
* time anything asks, so there is never a moment when a derived token has
|
|
54
|
+
* nothing real to hang off. The vault, hook tokens, approval links and git
|
|
55
|
+
* push secrets all derive from this one value; rotating it invalidates every
|
|
56
|
+
* one of them together. Exported for webhook.ts, which used to fall back to
|
|
57
|
+
* a fixed dev string until the vault happened to create this file — so hook
|
|
58
|
+
* URLs were computable by anyone who read the source, and then changed the
|
|
59
|
+
* first time a secret was stored.
|
|
60
|
+
*/
|
|
61
|
+
export function installKeyMaterial(): string {
|
|
39
62
|
const fromEnv = process.env.FOLDRUN_SECRET_KEY;
|
|
40
|
-
if (fromEnv) return
|
|
63
|
+
if (fromEnv) return fromEnv;
|
|
41
64
|
fs.mkdirSync(dataRoot(), { recursive: true });
|
|
42
65
|
if (!fs.existsSync(keyFile())) {
|
|
43
66
|
fs.writeFileSync(keyFile(), crypto.randomBytes(32).toString("hex"), { mode: 0o600 });
|
|
44
67
|
}
|
|
45
|
-
return
|
|
68
|
+
return fs.readFileSync(keyFile(), "utf8");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function masterKey(): Buffer {
|
|
72
|
+
return crypto.createHash("sha256").update(installKeyMaterial()).digest();
|
|
46
73
|
}
|
|
47
74
|
|
|
48
75
|
// `workspace` undefined means the account store.
|
|
@@ -135,7 +162,7 @@ export function encryptValue(
|
|
|
135
162
|
): { iv: string; tag: string; data: string; k?: "tenant" } {
|
|
136
163
|
const { key, mark } = keyFor(tenant);
|
|
137
164
|
const iv = crypto.randomBytes(12);
|
|
138
|
-
const cipher = crypto.createCipheriv("aes-256-gcm", key, iv);
|
|
165
|
+
const cipher = crypto.createCipheriv("aes-256-gcm", key, iv, GCM);
|
|
139
166
|
const enc = Buffer.concat([cipher.update(value, "utf8"), cipher.final()]);
|
|
140
167
|
return {
|
|
141
168
|
iv: iv.toString("base64"),
|
|
@@ -180,6 +207,7 @@ function decrypt(tenant: string, rec: StoredSecret | undefined): string | null {
|
|
|
180
207
|
"aes-256-gcm",
|
|
181
208
|
key,
|
|
182
209
|
Buffer.from(rec.iv, "base64"),
|
|
210
|
+
GCM,
|
|
183
211
|
);
|
|
184
212
|
decipher.setAuthTag(Buffer.from(rec.tag, "base64"));
|
|
185
213
|
return Buffer.concat([
|
|
@@ -357,7 +385,7 @@ export function rotateMasterKey(
|
|
|
357
385
|
}
|
|
358
386
|
let plain: string;
|
|
359
387
|
try {
|
|
360
|
-
const d = crypto.createDecipheriv("aes-256-gcm", oldKey, Buffer.from(rec.iv, "base64"));
|
|
388
|
+
const d = crypto.createDecipheriv("aes-256-gcm", oldKey, Buffer.from(rec.iv, "base64"), GCM);
|
|
361
389
|
d.setAuthTag(Buffer.from(rec.tag, "base64"));
|
|
362
390
|
plain = Buffer.concat([d.update(Buffer.from(rec.data, "base64")), d.final()]).toString("utf8");
|
|
363
391
|
} catch {
|
|
@@ -366,7 +394,7 @@ export function rotateMasterKey(
|
|
|
366
394
|
continue;
|
|
367
395
|
}
|
|
368
396
|
const iv = crypto.randomBytes(12);
|
|
369
|
-
const c = crypto.createCipheriv("aes-256-gcm", newKey, iv);
|
|
397
|
+
const c = crypto.createCipheriv("aes-256-gcm", newKey, iv, GCM);
|
|
370
398
|
const enc = Buffer.concat([c.update(plain, "utf8"), c.final()]);
|
|
371
399
|
next[name] = {
|
|
372
400
|
iv: iv.toString("base64"),
|
|
@@ -498,6 +526,9 @@ async function exchange(config: OAuth2Config, cacheKey: string): Promise<string>
|
|
|
498
526
|
*/
|
|
499
527
|
export async function materializeSecrets(
|
|
500
528
|
env: Record<string, string>,
|
|
529
|
+
/** Whose secrets these are, when the caller knows: lets the platform keep
|
|
530
|
+
* each OAuth connection's health and quote a reconnect link on failure. */
|
|
531
|
+
ctx?: { tenant: string; workspace?: string },
|
|
501
532
|
): Promise<Record<string, string>> {
|
|
502
533
|
const out: Record<string, string> = { ...env };
|
|
503
534
|
for (const [name, value] of Object.entries(env)) {
|
|
@@ -518,8 +549,11 @@ export async function materializeSecrets(
|
|
|
518
549
|
const fp = crypto.createHash("sha256").update(grantMaterial).digest("hex").slice(0, 16);
|
|
519
550
|
out[name] = await exchange(config, `${name}:${config.client_id}:${fp}`);
|
|
520
551
|
} catch (err) {
|
|
521
|
-
|
|
552
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
553
|
+
const link = ctx ? await platform.noteOAuthRefresh(ctx, name, message).catch(() => undefined) : undefined;
|
|
554
|
+
throw new Error(`secret ${name}: ${message}${link ? ` — reconnect it in one click: ${link}` : ""}`);
|
|
522
555
|
}
|
|
556
|
+
if (ctx) await platform.noteOAuthRefresh(ctx, name, null).catch(() => undefined);
|
|
523
557
|
} else if (value.startsWith(SERVICE_ACCOUNT_PREFIX)) {
|
|
524
558
|
let config: ServiceAccountConfig;
|
|
525
559
|
try {
|
|
@@ -570,7 +604,7 @@ export function setServiceAccountSecret(
|
|
|
570
604
|
for (const field of ["token_url", "issuer", "private_key", "scope"] as const) {
|
|
571
605
|
if (!config[field]?.trim()) throw new Error(`service account secret needs ${field}`);
|
|
572
606
|
}
|
|
573
|
-
if (!/BEGIN [A-Z ]
|
|
607
|
+
if (!/BEGIN [A-Z ]{0,40}PRIVATE KEY/.test(config.private_key)) {
|
|
574
608
|
throw new Error("private_key must be a PEM key (from the service account JSON)");
|
|
575
609
|
}
|
|
576
610
|
setSecret(tenant, name, SERVICE_ACCOUNT_PREFIX + JSON.stringify(config), workspace, "service-account");
|
|
@@ -692,7 +726,7 @@ export function setSshSecret(tenant: string, name: string, config: SshConfig, wo
|
|
|
692
726
|
const hasKey = Boolean(config.private_key?.trim());
|
|
693
727
|
const hasPassword = Boolean(config.password);
|
|
694
728
|
if (hasKey === hasPassword) throw new Error("ssh secret needs a private key or a password (not both)");
|
|
695
|
-
if (hasKey && !/BEGIN [A-Z ]
|
|
729
|
+
if (hasKey && !/BEGIN [A-Z ]{0,40}PRIVATE KEY/.test(config.private_key!)) {
|
|
696
730
|
throw new Error("private_key must be a PEM/OpenSSH private key");
|
|
697
731
|
}
|
|
698
732
|
if (hasPassword && /[\n\r]/.test(config.password!)) throw new Error("ssh password cannot contain newlines");
|
|
@@ -737,7 +771,7 @@ export function setApiSecret(tenant: string, name: string, config: ApiConfig, wo
|
|
|
737
771
|
if (base && !/^https?:\/\//.test(base)) throw new Error("base_url must be http(s)");
|
|
738
772
|
setSecret(
|
|
739
773
|
tenant, name,
|
|
740
|
-
API_PREFIX + JSON.stringify({ ...(base ? { base_url: base
|
|
774
|
+
API_PREFIX + JSON.stringify({ ...(base ? { base_url: trimSlashes(base) } : {}), headers: config.headers }),
|
|
741
775
|
workspace, "api",
|
|
742
776
|
);
|
|
743
777
|
}
|
package/src/starter.ts
CHANGED
|
@@ -355,7 +355,10 @@ Indented under a step, any of:
|
|
|
355
355
|
| \`preview: draft/*.mdx\` | what the approval box shows — paths under \`storage/\`, globs allowed |
|
|
356
356
|
| \`delegate: [[a]], [[b]]\` | hand the step to whichever fits, up to 5 |
|
|
357
357
|
| \`output: json\` | the reply ends with one JSON value; the next step gets it as data, \`each: items\` fans out over it |
|
|
358
|
+
| \`schema: {...}\` | with \`output: json\` — the JSON Schema the value must satisfy; inline, a YAML block under the key, or a file path |
|
|
358
359
|
| \`each: items\` | run once per element of the previous step's JSON array |
|
|
360
|
+
| \`parallel: 3\` | how many \`each:\` instances run at once — default all |
|
|
361
|
+
| \`max_turns: 20\` | the most model turns before the step is stopped, beside \`budget:\` and \`timeout:\` |
|
|
359
362
|
|
|
360
363
|
## Writing an agent
|
|
361
364
|
|
package/src/step-exec.ts
CHANGED
|
@@ -11,10 +11,13 @@
|
|
|
11
11
|
import fs from "node:fs";
|
|
12
12
|
import path from "node:path";
|
|
13
13
|
import { query } from "@anthropic-ai/claude-agent-sdk";
|
|
14
|
-
import type { McpServerConfig } from "@anthropic-ai/claude-agent-sdk";
|
|
14
|
+
import type { McpServerConfig, SDKMessage } from "@anthropic-ai/claude-agent-sdk";
|
|
15
15
|
import type { Effort } from "./store.ts";
|
|
16
|
+
import type { TestEffect } from "./test-mode.ts";
|
|
16
17
|
import { spawn } from "node:child_process";
|
|
17
18
|
import { checkPaths, checkBash, isFilesystemTool } from "./confine.ts";
|
|
19
|
+
import { hostSafeEnv } from "./host-env.ts";
|
|
20
|
+
import { validateSchema, describeSchemaErrors, looksLikeSchema } from "./json-schema.ts";
|
|
18
21
|
|
|
19
22
|
export interface ExecOutcome {
|
|
20
23
|
status: "completed" | "failed";
|
|
@@ -57,6 +60,9 @@ export interface ExecOptions {
|
|
|
57
60
|
* The runner derives it from the flow's `budget:` and what the run has
|
|
58
61
|
* already spent; absent means no ceiling on this step. */
|
|
59
62
|
budgetUsd?: number | null;
|
|
63
|
+
/** Which line set the ceiling, for the error that names it — "budget: in
|
|
64
|
+
* the flow file" or "budget: on the <agent> agent". */
|
|
65
|
+
budgetNote?: string;
|
|
60
66
|
/** Price per token for the model, when the catalogue knows it, so spend
|
|
61
67
|
* can be counted turn by turn instead of learned at the end. Without it
|
|
62
68
|
* the step prices its turns from the SDK's own running total. */
|
|
@@ -69,15 +75,38 @@ export interface ExecOptions {
|
|
|
69
75
|
/** `output: json` — the reply must carry one JSON value; extracting it is
|
|
70
76
|
* part of finishing the step, and failing to is failing the step. */
|
|
71
77
|
output?: "json";
|
|
78
|
+
/** `schema:` — what that value must look like; a value that does not fit
|
|
79
|
+
* fails the step naming the field. See json-schema.ts. */
|
|
80
|
+
schema?: Record<string, unknown> | boolean;
|
|
81
|
+
/** `max_turns:` — the most model turns before the step is stopped. The
|
|
82
|
+
* SDK enforces it; the step reads the result's reason and says so. */
|
|
83
|
+
maxTurns?: number;
|
|
72
84
|
/** false when the caller is already an isolation boundary (a run
|
|
73
85
|
* container): the SDK's bash sandbox is then redundant and would block
|
|
74
86
|
* declared network use. Default (undefined/true) keeps it on. */
|
|
75
87
|
sandboxBash?: boolean;
|
|
88
|
+
/** Has a person asked for the run to stop? Polled every couple of
|
|
89
|
+
* seconds while the model loop runs; true interrupts it. The in-process
|
|
90
|
+
* runner answers from the run record (stopRun writes there from another
|
|
91
|
+
* process); a container has a sandbox that is destroyed instead. */
|
|
92
|
+
stopRequested?: () => boolean;
|
|
76
93
|
emit: (type: "text" | "tool" | "info" | "error", text: string, extra?: EventExtra) => void;
|
|
77
94
|
}
|
|
78
95
|
|
|
96
|
+
/** How often the stop flag is read while a step runs. */
|
|
97
|
+
export const STOP_POLL_MS = 2000;
|
|
98
|
+
/** After an interrupt is asked for, how long the model loop gets to wind
|
|
99
|
+
* down on its own before the query is aborted outright. */
|
|
100
|
+
export const INTERRUPT_GRACE_MS = 10_000;
|
|
101
|
+
|
|
102
|
+
/** The model loop, as executeStep needs it — the SDK's `query`, or a test's
|
|
103
|
+
* stand-in. Only the shape the loop reads. */
|
|
104
|
+
export type QueryLike = AsyncIterable<unknown> & { interrupt(): Promise<unknown> };
|
|
105
|
+
export type QueryFn = (args: { prompt: string; options: Record<string, unknown> }) => QueryLike;
|
|
106
|
+
|
|
79
107
|
/** The pairing fields on a tool event — see RunEvent in store.ts. */
|
|
80
|
-
export type EventExtra = { call?: string; ms?: number; err?: boolean };
|
|
108
|
+
export type EventExtra = { call?: string; ms?: number; err?: boolean; effect?: TestEffect };
|
|
109
|
+
|
|
81
110
|
|
|
82
111
|
/** Conservative per-token rates for a model nothing else can price — an
|
|
83
112
|
* unknown id on a gateway with no catalogue. Opus-class, so a ceiling
|
|
@@ -145,7 +174,34 @@ export function stepCeiling(budgetUsd: number | null | undefined, spentUsd: numb
|
|
|
145
174
|
return Math.max(0, budgetUsd - spentUsd) / Math.max(1, launching);
|
|
146
175
|
}
|
|
147
176
|
|
|
148
|
-
|
|
177
|
+
/**
|
|
178
|
+
* The ceiling a step actually runs under, and which line set it.
|
|
179
|
+
*
|
|
180
|
+
* Two caps can apply to one step: the flow's `budget:` gives it an equal
|
|
181
|
+
* share of what the run has left; the agent's own `budget:` is the most that
|
|
182
|
+
* agent may spend in one run, less what its earlier steps in this run cost.
|
|
183
|
+
* The tighter one wins, and the note says which, because "over budget" with
|
|
184
|
+
* no line to go and raise is a message that sends someone to the wrong file.
|
|
185
|
+
*/
|
|
186
|
+
export function stepCeilingFor(
|
|
187
|
+
flowShareUsd: number | null,
|
|
188
|
+
agentBudgetUsd: number | null | undefined,
|
|
189
|
+
agentSpentUsd: number,
|
|
190
|
+
agent: string,
|
|
191
|
+
): { ceilingUsd: number | null; note: string } {
|
|
192
|
+
const flow = { ceilingUsd: flowShareUsd, note: "budget: in the flow file" };
|
|
193
|
+
if (!agentBudgetUsd || agentBudgetUsd <= 0) return flow;
|
|
194
|
+
const left = Math.max(0, agentBudgetUsd - agentSpentUsd);
|
|
195
|
+
if (flowShareUsd !== null && flowShareUsd <= left) return flow;
|
|
196
|
+
return { ceilingUsd: left, note: `budget: on the ${agent} agent` };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export async function executeStep(
|
|
200
|
+
opts: ExecOptions,
|
|
201
|
+
/** The model loop. Injected so the clock and the stop can be tested
|
|
202
|
+
* without a model; production passes nothing and gets the SDK. */
|
|
203
|
+
runQuery: QueryFn = query as unknown as QueryFn,
|
|
204
|
+
): Promise<ExecOutcome> {
|
|
149
205
|
const { agentDir, workspaceRoot, libraryRoot, emit } = opts;
|
|
150
206
|
let status: "running" | "completed" | "failed" = "running";
|
|
151
207
|
let costUsd: number | null = null;
|
|
@@ -155,16 +211,23 @@ export async function executeStep(opts: ExecOptions): Promise<ExecOutcome> {
|
|
|
155
211
|
fs.mkdirSync(path.join(agentDir, "outputs"), { recursive: true });
|
|
156
212
|
fs.mkdirSync(path.join(agentDir, "memory"), { recursive: true });
|
|
157
213
|
|
|
158
|
-
|
|
214
|
+
// The one handle that can end the loop from outside: the timeout and a
|
|
215
|
+
// stop both ask the query to interrupt, and abort it outright if it has
|
|
216
|
+
// not wound down within the grace period.
|
|
217
|
+
const abort = new AbortController();
|
|
218
|
+
|
|
219
|
+
const q = runQuery({
|
|
159
220
|
prompt: opts.prompt,
|
|
160
221
|
options: {
|
|
161
222
|
cwd: agentDir,
|
|
223
|
+
abortController: abort,
|
|
162
224
|
model: opts.model,
|
|
163
225
|
// Omitted, not passed as undefined-with-a-default: an unset effort
|
|
164
226
|
// should mean "whatever this model does normally", which is not a
|
|
165
227
|
// level we can name — it moves as models ship.
|
|
166
228
|
...(opts.effort ? { effort: opts.effort } : {}),
|
|
167
229
|
systemPrompt: opts.systemPrompt,
|
|
230
|
+
...(opts.maxTurns ? { maxTurns: opts.maxTurns } : {}),
|
|
168
231
|
// Restrict the toolset itself, not just approval: an agent that
|
|
169
232
|
// declares no tools gets none, instead of seeing the full Claude
|
|
170
233
|
// Code toolset and burning turns on denied calls.
|
|
@@ -218,7 +281,34 @@ export async function executeStep(opts: ExecOptions): Promise<ExecOutcome> {
|
|
|
218
281
|
},
|
|
219
282
|
});
|
|
220
283
|
|
|
221
|
-
|
|
284
|
+
// Why the loop was ended from outside, if it was. The deadline used to be
|
|
285
|
+
// checked only when a message arrived, so a step waiting on one long tool
|
|
286
|
+
// call — a crawl, a build — sailed past its timeout: with no message there
|
|
287
|
+
// was nothing to check it against. The backstop timer fires regardless,
|
|
288
|
+
// and a stop is read on a clock rather than between groups.
|
|
289
|
+
let ended: "timeout" | "stopped" | null = null;
|
|
290
|
+
const timers: NodeJS.Timeout[] = [];
|
|
291
|
+
const endWith = (why: "timeout" | "stopped") => {
|
|
292
|
+
if (ended) return;
|
|
293
|
+
ended = why;
|
|
294
|
+
void q.interrupt().catch(() => {});
|
|
295
|
+
timers.push(setTimeout(() => abort.abort(), INTERRUPT_GRACE_MS));
|
|
296
|
+
};
|
|
297
|
+
if (opts.timeoutSec) timers.push(setTimeout(() => endWith("timeout"), opts.timeoutSec * 1000));
|
|
298
|
+
if (opts.stopRequested) {
|
|
299
|
+
timers.push(setInterval(() => {
|
|
300
|
+
try {
|
|
301
|
+
if (opts.stopRequested!()) endWith("stopped");
|
|
302
|
+
} catch {
|
|
303
|
+
// an unreadable record is not a stop
|
|
304
|
+
}
|
|
305
|
+
}, STOP_POLL_MS));
|
|
306
|
+
}
|
|
307
|
+
// The clocks stay referenced on purpose: a step hanging in a tool call may
|
|
308
|
+
// be the only thing keeping the loop alive, and an unreferenced timer
|
|
309
|
+
// would let the process exit around it instead of ending it. They are all
|
|
310
|
+
// cleared when the step ends, so nothing outlives the step.
|
|
311
|
+
|
|
222
312
|
// The hard cap. Spend is counted as each assistant turn arrives — its
|
|
223
313
|
// usage is in the message — so the step can stop at the ceiling rather
|
|
224
314
|
// than learn afterwards that it crossed it. Cache traffic is counted as
|
|
@@ -231,18 +321,15 @@ export async function executeStep(opts: ExecOptions): Promise<ExecOutcome> {
|
|
|
231
321
|
// its call and the trace can say how long each tool ran.
|
|
232
322
|
const openCalls = new Map<string, { name: string; at: number }>();
|
|
233
323
|
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
status = "failed";
|
|
238
|
-
break;
|
|
239
|
-
}
|
|
324
|
+
try {
|
|
325
|
+
for await (const message of q as AsyncIterable<SDKMessage>) {
|
|
326
|
+
if (ended) break;
|
|
240
327
|
if (message.type === "assistant") {
|
|
241
328
|
const u = (message.message as unknown as { usage?: Record<string, number | undefined> }).usage;
|
|
242
329
|
if (ceiling && u) {
|
|
243
330
|
spentUsd += priceTurn(u, opts.price ?? null);
|
|
244
331
|
if (spentUsd >= ceiling) {
|
|
245
|
-
emit("error", `over budget — this step reached $${spentUsd.toFixed(4)} of its $${ceiling.toFixed(4)} ceiling mid-turn and was stopped (budget: in the flow file)`);
|
|
332
|
+
emit("error", `over budget — this step reached $${spentUsd.toFixed(4)} of its $${ceiling.toFixed(4)} ceiling mid-turn and was stopped (${opts.budgetNote ?? "budget: in the flow file"})`);
|
|
246
333
|
status = "failed";
|
|
247
334
|
break;
|
|
248
335
|
}
|
|
@@ -275,6 +362,9 @@ export async function executeStep(opts: ExecOptions): Promise<ExecOutcome> {
|
|
|
275
362
|
}
|
|
276
363
|
} else if (message.type === "result") {
|
|
277
364
|
status = message.subtype === "success" ? "completed" : "failed";
|
|
365
|
+
if (message.subtype === "error_max_turns") {
|
|
366
|
+
emit("error", `stopped after ${opts.maxTurns ?? "its"} turns (max_turns: in the flow file) — the step did not finish`);
|
|
367
|
+
}
|
|
278
368
|
costUsd = "total_cost_usd" in message ? (message.total_cost_usd ?? null) : null;
|
|
279
369
|
if ("usage" in message && message.usage) {
|
|
280
370
|
const u = message.usage as unknown as Record<string, number | undefined>;
|
|
@@ -291,6 +381,19 @@ export async function executeStep(opts: ExecOptions): Promise<ExecOutcome> {
|
|
|
291
381
|
}
|
|
292
382
|
}
|
|
293
383
|
}
|
|
384
|
+
} catch (err) {
|
|
385
|
+
// An abort we asked for is not an error of the step's; anything else is.
|
|
386
|
+
if (!ended) throw err;
|
|
387
|
+
} finally {
|
|
388
|
+
for (const t of timers) clearTimeout(t);
|
|
389
|
+
}
|
|
390
|
+
if (ended === "timeout") {
|
|
391
|
+
emit("error", `timed out after ${opts.timeoutSec}s (timeout: in the flow file) — the step was stopped with the files it wrote so far kept`);
|
|
392
|
+
status = "failed";
|
|
393
|
+
} else if (ended === "stopped") {
|
|
394
|
+
emit("error", "stopped by a person mid-step");
|
|
395
|
+
status = "failed";
|
|
396
|
+
}
|
|
294
397
|
// The SDK ends a healthy run with a `result` message. A stream that just
|
|
295
398
|
// stops — subprocess OOM-killed, crashed, or torn down — used to fall
|
|
296
399
|
// through as "completed", which reported a run that produced nothing as a
|
|
@@ -319,6 +422,14 @@ export async function executeStep(opts: ExecOptions): Promise<ExecOutcome> {
|
|
|
319
422
|
if (extracted.ok) {
|
|
320
423
|
data = extracted.value;
|
|
321
424
|
emit("info", `output: json — ${describeJson(data)}`);
|
|
425
|
+
// The declared shape, checked here beside the extraction: a value
|
|
426
|
+
// that parses but is not what the next step was promised is the same
|
|
427
|
+
// failure as no value, and the message names the field.
|
|
428
|
+
const errors = opts.schema !== undefined && looksLikeSchema(opts.schema) ? validateSchema(data, opts.schema) : [];
|
|
429
|
+
if (errors.length) {
|
|
430
|
+
emit("error", `schema: the value does not fit — ${describeSchemaErrors(errors)}`);
|
|
431
|
+
status = "failed";
|
|
432
|
+
}
|
|
322
433
|
} else {
|
|
323
434
|
emit("error", `output: json — ${extracted.reason}`);
|
|
324
435
|
status = "failed";
|
|
@@ -515,9 +626,13 @@ function runVerify(
|
|
|
515
626
|
return new Promise((resolve) => {
|
|
516
627
|
// No clock of the platform's: a verify like `npm run build` takes what
|
|
517
628
|
// it takes, and the step's own `timeout:` is the bound if the flow set one.
|
|
629
|
+
// The allowlisted host base plus what the caller passed: the step's
|
|
630
|
+
// secrets and identifiers on the in-process path (runner.ts), the
|
|
631
|
+
// container's own environment on the isolated one (run-container.ts).
|
|
632
|
+
// Never process.env whole — see host-env.ts.
|
|
518
633
|
const child = spawn("bash", ["-lc", command], {
|
|
519
634
|
cwd: agentDir,
|
|
520
|
-
env: { ...
|
|
635
|
+
env: { ...hostSafeEnv(), ...env },
|
|
521
636
|
stdio: ["pipe", "pipe", "pipe"],
|
|
522
637
|
});
|
|
523
638
|
// An `output: json` step's data arrives on stdin, so a check can be
|
package/src/storage.ts
CHANGED
|
@@ -302,8 +302,14 @@ export function s3Config(): S3Config | null {
|
|
|
302
302
|
};
|
|
303
303
|
}
|
|
304
304
|
|
|
305
|
-
|
|
306
|
-
|
|
305
|
+
// Narrower than crypto.BinaryLike on purpose. BinaryLike also covers views
|
|
306
|
+
// over a SharedArrayBuffer, which .update() has never accepted and which
|
|
307
|
+
// @types/node 26 finally says so about — the old typings let this compile by
|
|
308
|
+
// being vaguer than the runtime. Every caller here passes a string or a
|
|
309
|
+
// Buffer, so this is the honest signature, not a workaround.
|
|
310
|
+
type Hashable = string | Uint8Array;
|
|
311
|
+
const sha256 = (b: Hashable) => crypto.createHash("sha256").update(b).digest("hex");
|
|
312
|
+
const hmac = (key: Hashable, data: string) =>
|
|
307
313
|
crypto.createHmac("sha256", key).update(data, "utf8").digest();
|
|
308
314
|
|
|
309
315
|
/** RFC 3986. encodeURIComponent leaves !'()* alone and SigV4 does not. */
|