@foldrun/core 0.2.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 +31 -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/deploy.js +4 -2
- 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 +112 -2
- package/dist/src/platform.js +20 -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 +80 -4
- package/dist/src/run-container.js +176 -37
- package/dist/src/runner.d.ts +41 -5
- package/dist/src/runner.js +513 -81
- 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 +4 -0
- package/dist/src/step-exec.d.ts +62 -6
- package/dist/src/step-exec.js +199 -68
- package/dist/src/store.d.ts +300 -3
- package/dist/src/store.js +574 -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 +6 -5
- 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/deploy.ts +4 -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 +119 -3
- package/src/provider-health.ts +274 -0
- package/src/run-container.ts +215 -35
- package/src/runner.ts +525 -73
- package/src/script-tools.ts +18 -2
- package/src/secret-health.ts +185 -0
- package/src/secrets.ts +44 -10
- package/src/starter.ts +4 -0
- package/src/step-exec.ts +176 -21
- package/src/storage.ts +8 -2
- package/src/store.ts +785 -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
|
@@ -26,12 +26,24 @@ export interface ScriptSpec {
|
|
|
26
26
|
* default, `materialised`, is a program that reads values from its
|
|
27
27
|
* environment and needs them real — a browser seeding cookies. */
|
|
28
28
|
secrets?: "proxied" | "materialised";
|
|
29
|
+
/** `outward: true` — this program makes something happen outside (sends,
|
|
30
|
+
* posts, orders). On a test run a step that grants it is handed no real
|
|
31
|
+
* secret at all, whatever their names. */
|
|
32
|
+
outward?: boolean;
|
|
33
|
+
/** `test_mode: allow` — a reader that happens to use a send-capable
|
|
34
|
+
* secret's name (a GBP reader on a GBP token): on a test run the step
|
|
35
|
+
* still gets the real values. An `outward` tool in the same step wins. */
|
|
36
|
+
testMode?: "allow";
|
|
29
37
|
}
|
|
30
38
|
export declare function parseScripts(raw: unknown): ScriptSpec[];
|
|
31
39
|
export declare function commandFor(spec: ScriptSpec, abs: string, overrides?: Record<string, string>): {
|
|
32
40
|
cmd: string;
|
|
33
41
|
args: string[];
|
|
34
42
|
};
|
|
43
|
+
export declare function runScript(cwd: string, spec: ScriptSpec, values: Record<string, string>, env: Record<string, string>, libraryScripts: string, interpreters: Record<string, string>, exec: ExecutionContext | null): Promise<{
|
|
44
|
+
code: number | null;
|
|
45
|
+
out: string;
|
|
46
|
+
}>;
|
|
35
47
|
export interface ExecutionContext {
|
|
36
48
|
/** "docker" nests each script in its own container; "sandbox" spawns it
|
|
37
49
|
* directly inside the isolation the step already has. */
|
package/dist/src/script-tools.js
CHANGED
|
@@ -19,6 +19,7 @@ import { spawn } from "node:child_process";
|
|
|
19
19
|
import { z } from "zod";
|
|
20
20
|
import { createSdkMcpServer, tool } from "@anthropic-ai/claude-agent-sdk";
|
|
21
21
|
import { runInContainer } from "./container.js";
|
|
22
|
+
import { hostSafeEnv } from "./host-env.js";
|
|
22
23
|
// A script's only clock is the `timeout:` its own tool.md declares. There
|
|
23
24
|
// was a two-minute default and a ten-minute cap here; both were the
|
|
24
25
|
// platform's opinion about how long a person's script may take, and the
|
|
@@ -55,6 +56,8 @@ export function parseScripts(raw) {
|
|
|
55
56
|
args,
|
|
56
57
|
interpreter: typeof e.interpreter === "string" ? e.interpreter : undefined,
|
|
57
58
|
secrets: e.secrets === "proxied" ? "proxied" : undefined,
|
|
59
|
+
outward: e.outward === true ? true : undefined,
|
|
60
|
+
testMode: e.test_mode === "allow" ? "allow" : undefined,
|
|
58
61
|
timeout: Number.isFinite(timeout) && timeout > 0 ? timeout : undefined,
|
|
59
62
|
});
|
|
60
63
|
}
|
|
@@ -96,7 +99,7 @@ function containerInterpreter(abs) {
|
|
|
96
99
|
};
|
|
97
100
|
return byExt[path.extname(abs).toLowerCase()] ?? abs;
|
|
98
101
|
}
|
|
99
|
-
function runScript(cwd, spec, values, env, libraryScripts, interpreters, exec) {
|
|
102
|
+
export function runScript(cwd, spec, values, env, libraryScripts, interpreters, exec) {
|
|
100
103
|
// An inline-code tool has no file yet: write it inside the agent's own
|
|
101
104
|
// directory — already an allowed root — and run it like any other script.
|
|
102
105
|
// Materialised per call rather than per build, so an edit to the tool's
|
|
@@ -188,9 +191,14 @@ function runScript(cwd, spec, values, env, libraryScripts, interpreters, exec) {
|
|
|
188
191
|
}).then(resolve, (err) => resolve({ code: null, out: `container error: ${err instanceof Error ? err.message : String(err)}` }));
|
|
189
192
|
return;
|
|
190
193
|
}
|
|
194
|
+
// Not process.env: on a platform that is every tenant's root key and
|
|
195
|
+
// the datastore credentials. The child starts from the allowlisted
|
|
196
|
+
// base and gets what the agent declared, by name — see host-env.ts.
|
|
197
|
+
// In a run container `env` already carries the container's whole
|
|
198
|
+
// environment, which is the boundary, so nothing is lost there.
|
|
191
199
|
const child = spawn(cmd, args, {
|
|
192
200
|
cwd,
|
|
193
|
-
env: { ...
|
|
201
|
+
env: { ...hostSafeEnv(), ...env },
|
|
194
202
|
timeout: timeoutFor(spec),
|
|
195
203
|
});
|
|
196
204
|
let out = "";
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/** How a credential is identified here. A name alone is not enough: the
|
|
2
|
+
* account and each workspace can hold a secret by the same name, and they
|
|
3
|
+
* are different credentials with different health. */
|
|
4
|
+
export declare function healthKey(name: string, scope: "account" | "workspace", workspace?: string | null): string;
|
|
5
|
+
export interface SecretUse {
|
|
6
|
+
/** ISO time of the most recent use. */
|
|
7
|
+
at: string;
|
|
8
|
+
/** The host it was sent to, so "which of my four keys is this" has an
|
|
9
|
+
* answer without opening a run. */
|
|
10
|
+
host: string;
|
|
11
|
+
/** The response status, when there was one. */
|
|
12
|
+
status: number | null;
|
|
13
|
+
/** Refused means the credential itself was rejected: 401, 402 or 403.
|
|
14
|
+
* A 500 is the other end failing, which says nothing about the key. */
|
|
15
|
+
outcome: "accepted" | "refused" | "error";
|
|
16
|
+
}
|
|
17
|
+
export interface SecretRecord {
|
|
18
|
+
last: SecretUse;
|
|
19
|
+
/** Consecutive refusals, newest first — one is a fluke, five is a dead
|
|
20
|
+
* key. Reset by any acceptance. */
|
|
21
|
+
refusals: number;
|
|
22
|
+
/** The last acceptance, kept through a run of refusals so a page can say
|
|
23
|
+
* "worked until Tuesday", which is the sentence that dates the breakage. */
|
|
24
|
+
lastAccepted: string | null;
|
|
25
|
+
}
|
|
26
|
+
type HealthFile = Record<string, SecretRecord>;
|
|
27
|
+
/** 401, 402 and 403 are the credential being refused. Everything else is
|
|
28
|
+
* the request, the model or the provider — none of which is the key's
|
|
29
|
+
* fault, and calling them a credential failure would send people to
|
|
30
|
+
* rotate a key that was never the problem. */
|
|
31
|
+
export declare function outcomeFor(status: number | null): SecretUse["outcome"];
|
|
32
|
+
/**
|
|
33
|
+
* Record one use. `key` is a health key (see healthKey), never a bare name.
|
|
34
|
+
* Never throws and never blocks: the write is queued and happens off the
|
|
35
|
+
* caller's path, because this runs on every proxied request.
|
|
36
|
+
*/
|
|
37
|
+
export declare function noteSecretUse(tenant: string, key: string, use: {
|
|
38
|
+
host: string;
|
|
39
|
+
status: number | null;
|
|
40
|
+
at?: string;
|
|
41
|
+
}): void;
|
|
42
|
+
/** Wait for queued writes to land — for tests, and for a process that is
|
|
43
|
+
* about to exit. */
|
|
44
|
+
export declare function flushSecretHealth(): Promise<void>;
|
|
45
|
+
/** Everything known about this account's credentials, by name. Names the
|
|
46
|
+
* vault holds but nothing has used are simply absent, which is the honest
|
|
47
|
+
* answer: unused is not the same as broken. */
|
|
48
|
+
export declare function secretHealth(tenant: string): HealthFile;
|
|
49
|
+
/** The credentials worth saying something about: refused the last time they
|
|
50
|
+
* were used. Sorted worst first — most consecutive refusals, then oldest
|
|
51
|
+
* last-success, which is the order someone would work through them. */
|
|
52
|
+
export declare function failingSecrets(tenant: string): {
|
|
53
|
+
name: string;
|
|
54
|
+
record: SecretRecord;
|
|
55
|
+
}[];
|
|
56
|
+
/** Drop what is known about a secret — for a delete, and for a rotation,
|
|
57
|
+
* where the old key's refusals say nothing about the new one. */
|
|
58
|
+
export declare function forgetSecretHealth(tenant: string, name: string): void;
|
|
59
|
+
export {};
|
|
@@ -0,0 +1,155 @@
|
|
|
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
|
+
import fs from "node:fs";
|
|
23
|
+
import path from "node:path";
|
|
24
|
+
import crypto from "node:crypto";
|
|
25
|
+
import { accountDir } from "./store.js";
|
|
26
|
+
/** How a credential is identified here. A name alone is not enough: the
|
|
27
|
+
* account and each workspace can hold a secret by the same name, and they
|
|
28
|
+
* are different credentials with different health. */
|
|
29
|
+
export function healthKey(name, scope, workspace) {
|
|
30
|
+
return scope === "workspace" && workspace ? `workspace:${workspace}:${name}` : `account:${name}`;
|
|
31
|
+
}
|
|
32
|
+
const healthFile = (tenant) => path.join(accountDir(tenant), "secret-health.json");
|
|
33
|
+
function read(tenant) {
|
|
34
|
+
try {
|
|
35
|
+
return JSON.parse(fs.readFileSync(healthFile(tenant), "utf8"));
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return {};
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
function write(tenant, data) {
|
|
42
|
+
const file = healthFile(tenant);
|
|
43
|
+
if (!fs.existsSync(path.dirname(file)))
|
|
44
|
+
return;
|
|
45
|
+
// pid alone is not unique across pods on a shared volume.
|
|
46
|
+
const tmp = `${file}.${process.pid}.${crypto.randomBytes(4).toString("hex")}`;
|
|
47
|
+
fs.writeFileSync(tmp, JSON.stringify(data, null, 2));
|
|
48
|
+
fs.renameSync(tmp, file);
|
|
49
|
+
}
|
|
50
|
+
// Writes are queued and coalesced off the caller's path. The egress proxy
|
|
51
|
+
// notes a use per secret per request, before it streams the response body,
|
|
52
|
+
// and a synchronous read-modify-write of a JSON file there is latency on
|
|
53
|
+
// every proxied call — "never blocks" has to be true, not aspirational.
|
|
54
|
+
// One writer per process; the queue drains in order.
|
|
55
|
+
const pending = new Map();
|
|
56
|
+
let draining = false;
|
|
57
|
+
function drain() {
|
|
58
|
+
if (draining)
|
|
59
|
+
return;
|
|
60
|
+
draining = true;
|
|
61
|
+
setImmediate(() => {
|
|
62
|
+
try {
|
|
63
|
+
for (const [tenant, uses] of pending) {
|
|
64
|
+
pending.delete(tenant);
|
|
65
|
+
const data = read(tenant);
|
|
66
|
+
for (const { name, use } of uses)
|
|
67
|
+
apply(data, name, use);
|
|
68
|
+
write(tenant, data);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
// best effort, always
|
|
73
|
+
}
|
|
74
|
+
finally {
|
|
75
|
+
draining = false;
|
|
76
|
+
if (pending.size)
|
|
77
|
+
drain();
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
function apply(data, name, use) {
|
|
82
|
+
const outcome = outcomeFor(use.status);
|
|
83
|
+
const at = use.at ?? new Date().toISOString();
|
|
84
|
+
const prev = data[name];
|
|
85
|
+
data[name] = {
|
|
86
|
+
last: { at, host: use.host, status: use.status, outcome },
|
|
87
|
+
// Only a refusal counts against a key. An error at the other end is
|
|
88
|
+
// the other end's problem and must not accumulate into a false alarm.
|
|
89
|
+
refusals: outcome === "refused" ? (prev?.refusals ?? 0) + 1 : outcome === "accepted" ? 0 : prev?.refusals ?? 0,
|
|
90
|
+
lastAccepted: outcome === "accepted" ? at : prev?.lastAccepted ?? null,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
/** 401, 402 and 403 are the credential being refused. Everything else is
|
|
94
|
+
* the request, the model or the provider — none of which is the key's
|
|
95
|
+
* fault, and calling them a credential failure would send people to
|
|
96
|
+
* rotate a key that was never the problem. */
|
|
97
|
+
export function outcomeFor(status) {
|
|
98
|
+
if (status === null)
|
|
99
|
+
return "error";
|
|
100
|
+
if (status === 401 || status === 402 || status === 403)
|
|
101
|
+
return "refused";
|
|
102
|
+
if (status >= 200 && status < 400)
|
|
103
|
+
return "accepted";
|
|
104
|
+
return "error";
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Record one use. `key` is a health key (see healthKey), never a bare name.
|
|
108
|
+
* Never throws and never blocks: the write is queued and happens off the
|
|
109
|
+
* caller's path, because this runs on every proxied request.
|
|
110
|
+
*/
|
|
111
|
+
export function noteSecretUse(tenant, key, use) {
|
|
112
|
+
if (!key)
|
|
113
|
+
return;
|
|
114
|
+
const list = pending.get(tenant) ?? [];
|
|
115
|
+
list.push({ name: key, use: { ...use, at: use.at ?? new Date().toISOString() } });
|
|
116
|
+
pending.set(tenant, list);
|
|
117
|
+
drain();
|
|
118
|
+
}
|
|
119
|
+
/** Wait for queued writes to land — for tests, and for a process that is
|
|
120
|
+
* about to exit. */
|
|
121
|
+
export function flushSecretHealth() {
|
|
122
|
+
return new Promise((resolve) => {
|
|
123
|
+
const check = () => (pending.size === 0 && !draining ? resolve() : setImmediate(check));
|
|
124
|
+
check();
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
/** Everything known about this account's credentials, by name. Names the
|
|
128
|
+
* vault holds but nothing has used are simply absent, which is the honest
|
|
129
|
+
* answer: unused is not the same as broken. */
|
|
130
|
+
export function secretHealth(tenant) {
|
|
131
|
+
return read(tenant);
|
|
132
|
+
}
|
|
133
|
+
/** The credentials worth saying something about: refused the last time they
|
|
134
|
+
* were used. Sorted worst first — most consecutive refusals, then oldest
|
|
135
|
+
* last-success, which is the order someone would work through them. */
|
|
136
|
+
export function failingSecrets(tenant) {
|
|
137
|
+
return Object.entries(read(tenant))
|
|
138
|
+
.filter(([, r]) => r.last.outcome === "refused")
|
|
139
|
+
.map(([name, record]) => ({ name, record }))
|
|
140
|
+
.sort((a, b) => b.record.refusals - a.record.refusals || (a.record.lastAccepted ?? "").localeCompare(b.record.lastAccepted ?? ""));
|
|
141
|
+
}
|
|
142
|
+
/** Drop what is known about a secret — for a delete, and for a rotation,
|
|
143
|
+
* where the old key's refusals say nothing about the new one. */
|
|
144
|
+
export function forgetSecretHealth(tenant, name) {
|
|
145
|
+
try {
|
|
146
|
+
const data = read(tenant);
|
|
147
|
+
if (!(name in data))
|
|
148
|
+
return;
|
|
149
|
+
delete data[name];
|
|
150
|
+
write(tenant, data);
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
// best effort
|
|
154
|
+
}
|
|
155
|
+
}
|
package/dist/src/secrets.d.ts
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
/** Which store a secret came from. */
|
|
2
2
|
export type SecretScope = "account" | "workspace";
|
|
3
|
+
/**
|
|
4
|
+
* The install's root secret, as text: FOLDRUN_SECRET_KEY when set, else the
|
|
5
|
+
* contents of `.secret-key` under the data root — created here, the first
|
|
6
|
+
* time anything asks, so there is never a moment when a derived token has
|
|
7
|
+
* nothing real to hang off. The vault, hook tokens, approval links and git
|
|
8
|
+
* push secrets all derive from this one value; rotating it invalidates every
|
|
9
|
+
* one of them together. Exported for webhook.ts, which used to fall back to
|
|
10
|
+
* a fixed dev string until the vault happened to create this file — so hook
|
|
11
|
+
* URLs were computable by anyone who read the source, and then changed the
|
|
12
|
+
* first time a secret was stored.
|
|
13
|
+
*/
|
|
14
|
+
export declare function installKeyMaterial(): string;
|
|
3
15
|
/** How the stored value is used at run time — "oauth2"/"service-account"
|
|
4
16
|
* are refreshed into live tokens, "file"/"ssh"/"api" are materialised into
|
|
5
17
|
* the run's scratch. Plain values have no kind. */
|
|
@@ -102,7 +114,13 @@ export declare function isOAuth2Value(value: string): boolean;
|
|
|
102
114
|
* refresh throws with the secret's name and the provider's reason — a run
|
|
103
115
|
* should fail saying "GOOGLE_ADS_TOKEN: invalid_grant", not 401 somewhere.
|
|
104
116
|
*/
|
|
105
|
-
export declare function materializeSecrets(env: Record<string, string
|
|
117
|
+
export declare function materializeSecrets(env: Record<string, string>,
|
|
118
|
+
/** Whose secrets these are, when the caller knows: lets the platform keep
|
|
119
|
+
* each OAuth connection's health and quote a reconnect link on failure. */
|
|
120
|
+
ctx?: {
|
|
121
|
+
tenant: string;
|
|
122
|
+
workspace?: string;
|
|
123
|
+
}): Promise<Record<string, string>>;
|
|
106
124
|
export interface ServiceAccountConfig {
|
|
107
125
|
token_url: string;
|
|
108
126
|
issuer: string;
|
package/dist/src/secrets.js
CHANGED
|
@@ -22,21 +22,46 @@ import fs from "node:fs";
|
|
|
22
22
|
import path from "node:path";
|
|
23
23
|
import { dataRoot } from "./paths.js";
|
|
24
24
|
import { platform } from "./platform.js";
|
|
25
|
+
/**
|
|
26
|
+
* GCM's auth tag, pinned to its full 16 bytes at every cipher call. Without
|
|
27
|
+
* `authTagLength`, Node accepts whatever length `setAuthTag` is handed — 4, 8,
|
|
28
|
+
* 12, 13, 14, 15 or 16 bytes — so a stored record whose tag had been cut to
|
|
29
|
+
* four bytes would still "verify" on 32 bits of authentication instead of
|
|
30
|
+
* 128. The tag comes from the record on disk; the record is the thing an
|
|
31
|
+
* attacker with file access would edit. One option makes a short tag an
|
|
32
|
+
* error rather than a weaker check.
|
|
33
|
+
*/
|
|
34
|
+
const GCM = { authTagLength: 16 };
|
|
25
35
|
// The account's own data key, when the platform holds one; the install key
|
|
26
36
|
// otherwise. See platform.ts — locally there is no account key.
|
|
27
37
|
const tenantKey = (tenant) => platform.tenantKey(tenant);
|
|
28
38
|
import crypto from "node:crypto";
|
|
29
39
|
import { assertSafeName } from "./store.js";
|
|
40
|
+
import { trimSlashes } from "./paths.js";
|
|
30
41
|
const keyFile = () => path.join(dataRoot(), ".secret-key");
|
|
31
|
-
|
|
42
|
+
/**
|
|
43
|
+
* The install's root secret, as text: FOLDRUN_SECRET_KEY when set, else the
|
|
44
|
+
* contents of `.secret-key` under the data root — created here, the first
|
|
45
|
+
* time anything asks, so there is never a moment when a derived token has
|
|
46
|
+
* nothing real to hang off. The vault, hook tokens, approval links and git
|
|
47
|
+
* push secrets all derive from this one value; rotating it invalidates every
|
|
48
|
+
* one of them together. Exported for webhook.ts, which used to fall back to
|
|
49
|
+
* a fixed dev string until the vault happened to create this file — so hook
|
|
50
|
+
* URLs were computable by anyone who read the source, and then changed the
|
|
51
|
+
* first time a secret was stored.
|
|
52
|
+
*/
|
|
53
|
+
export function installKeyMaterial() {
|
|
32
54
|
const fromEnv = process.env.FOLDRUN_SECRET_KEY;
|
|
33
55
|
if (fromEnv)
|
|
34
|
-
return
|
|
56
|
+
return fromEnv;
|
|
35
57
|
fs.mkdirSync(dataRoot(), { recursive: true });
|
|
36
58
|
if (!fs.existsSync(keyFile())) {
|
|
37
59
|
fs.writeFileSync(keyFile(), crypto.randomBytes(32).toString("hex"), { mode: 0o600 });
|
|
38
60
|
}
|
|
39
|
-
return
|
|
61
|
+
return fs.readFileSync(keyFile(), "utf8");
|
|
62
|
+
}
|
|
63
|
+
function masterKey() {
|
|
64
|
+
return crypto.createHash("sha256").update(installKeyMaterial()).digest();
|
|
40
65
|
}
|
|
41
66
|
// `workspace` undefined means the account store.
|
|
42
67
|
function secretsFile(tenant, workspace) {
|
|
@@ -98,7 +123,7 @@ function keyFor(tenant) {
|
|
|
98
123
|
export function encryptValue(tenant, value) {
|
|
99
124
|
const { key, mark } = keyFor(tenant);
|
|
100
125
|
const iv = crypto.randomBytes(12);
|
|
101
|
-
const cipher = crypto.createCipheriv("aes-256-gcm", key, iv);
|
|
126
|
+
const cipher = crypto.createCipheriv("aes-256-gcm", key, iv, GCM);
|
|
102
127
|
const enc = Buffer.concat([cipher.update(value, "utf8"), cipher.final()]);
|
|
103
128
|
return {
|
|
104
129
|
iv: iv.toString("base64"),
|
|
@@ -134,7 +159,7 @@ function decrypt(tenant, rec) {
|
|
|
134
159
|
else {
|
|
135
160
|
key = masterKey();
|
|
136
161
|
}
|
|
137
|
-
const decipher = crypto.createDecipheriv("aes-256-gcm", key, Buffer.from(rec.iv, "base64"));
|
|
162
|
+
const decipher = crypto.createDecipheriv("aes-256-gcm", key, Buffer.from(rec.iv, "base64"), GCM);
|
|
138
163
|
decipher.setAuthTag(Buffer.from(rec.tag, "base64"));
|
|
139
164
|
return Buffer.concat([
|
|
140
165
|
decipher.update(Buffer.from(rec.data, "base64")),
|
|
@@ -279,7 +304,7 @@ export function rotateMasterKey(tenant, from, to) {
|
|
|
279
304
|
}
|
|
280
305
|
let plain;
|
|
281
306
|
try {
|
|
282
|
-
const d = crypto.createDecipheriv("aes-256-gcm", oldKey, Buffer.from(rec.iv, "base64"));
|
|
307
|
+
const d = crypto.createDecipheriv("aes-256-gcm", oldKey, Buffer.from(rec.iv, "base64"), GCM);
|
|
283
308
|
d.setAuthTag(Buffer.from(rec.tag, "base64"));
|
|
284
309
|
plain = Buffer.concat([d.update(Buffer.from(rec.data, "base64")), d.final()]).toString("utf8");
|
|
285
310
|
}
|
|
@@ -289,7 +314,7 @@ export function rotateMasterKey(tenant, from, to) {
|
|
|
289
314
|
continue;
|
|
290
315
|
}
|
|
291
316
|
const iv = crypto.randomBytes(12);
|
|
292
|
-
const c = crypto.createCipheriv("aes-256-gcm", newKey, iv);
|
|
317
|
+
const c = crypto.createCipheriv("aes-256-gcm", newKey, iv, GCM);
|
|
293
318
|
const enc = Buffer.concat([c.update(plain, "utf8"), c.final()]);
|
|
294
319
|
next[name] = {
|
|
295
320
|
iv: iv.toString("base64"),
|
|
@@ -376,7 +401,10 @@ async function exchange(config, cacheKey) {
|
|
|
376
401
|
* refresh throws with the secret's name and the provider's reason — a run
|
|
377
402
|
* should fail saying "GOOGLE_ADS_TOKEN: invalid_grant", not 401 somewhere.
|
|
378
403
|
*/
|
|
379
|
-
export async function materializeSecrets(env
|
|
404
|
+
export async function materializeSecrets(env,
|
|
405
|
+
/** Whose secrets these are, when the caller knows: lets the platform keep
|
|
406
|
+
* each OAuth connection's health and quote a reconnect link on failure. */
|
|
407
|
+
ctx) {
|
|
380
408
|
const out = { ...env };
|
|
381
409
|
for (const [name, value] of Object.entries(env)) {
|
|
382
410
|
if (typeof value !== "string")
|
|
@@ -399,8 +427,12 @@ export async function materializeSecrets(env) {
|
|
|
399
427
|
out[name] = await exchange(config, `${name}:${config.client_id}:${fp}`);
|
|
400
428
|
}
|
|
401
429
|
catch (err) {
|
|
402
|
-
|
|
430
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
431
|
+
const link = ctx ? await platform.noteOAuthRefresh(ctx, name, message).catch(() => undefined) : undefined;
|
|
432
|
+
throw new Error(`secret ${name}: ${message}${link ? ` — reconnect it in one click: ${link}` : ""}`);
|
|
403
433
|
}
|
|
434
|
+
if (ctx)
|
|
435
|
+
await platform.noteOAuthRefresh(ctx, name, null).catch(() => undefined);
|
|
404
436
|
}
|
|
405
437
|
else if (value.startsWith(SERVICE_ACCOUNT_PREFIX)) {
|
|
406
438
|
let config;
|
|
@@ -437,7 +469,7 @@ export function setServiceAccountSecret(tenant, name, config, workspace) {
|
|
|
437
469
|
if (!config[field]?.trim())
|
|
438
470
|
throw new Error(`service account secret needs ${field}`);
|
|
439
471
|
}
|
|
440
|
-
if (!/BEGIN [A-Z ]
|
|
472
|
+
if (!/BEGIN [A-Z ]{0,40}PRIVATE KEY/.test(config.private_key)) {
|
|
441
473
|
throw new Error("private_key must be a PEM key (from the service account JSON)");
|
|
442
474
|
}
|
|
443
475
|
setSecret(tenant, name, SERVICE_ACCOUNT_PREFIX + JSON.stringify(config), workspace, "service-account");
|
|
@@ -534,7 +566,7 @@ export function setSshSecret(tenant, name, config, workspace) {
|
|
|
534
566
|
const hasPassword = Boolean(config.password);
|
|
535
567
|
if (hasKey === hasPassword)
|
|
536
568
|
throw new Error("ssh secret needs a private key or a password (not both)");
|
|
537
|
-
if (hasKey && !/BEGIN [A-Z ]
|
|
569
|
+
if (hasKey && !/BEGIN [A-Z ]{0,40}PRIVATE KEY/.test(config.private_key)) {
|
|
538
570
|
throw new Error("private_key must be a PEM/OpenSSH private key");
|
|
539
571
|
}
|
|
540
572
|
if (hasPassword && /[\n\r]/.test(config.password))
|
|
@@ -571,7 +603,7 @@ export function setApiSecret(tenant, name, config, workspace) {
|
|
|
571
603
|
const base = config.base_url?.trim();
|
|
572
604
|
if (base && !/^https?:\/\//.test(base))
|
|
573
605
|
throw new Error("base_url must be http(s)");
|
|
574
|
-
setSecret(tenant, name, API_PREFIX + JSON.stringify({ ...(base ? { base_url: base
|
|
606
|
+
setSecret(tenant, name, API_PREFIX + JSON.stringify({ ...(base ? { base_url: trimSlashes(base) } : {}), headers: config.headers }), workspace, "api");
|
|
575
607
|
}
|
|
576
608
|
export function isApiValue(value) {
|
|
577
609
|
return value.startsWith(API_PREFIX);
|
package/dist/src/starter.js
CHANGED
|
@@ -304,6 +304,7 @@ timezone: Australia/Sydney
|
|
|
304
304
|
model: default # a default for every step here
|
|
305
305
|
effort: low
|
|
306
306
|
overlap: skip # skip | queue — what a second run does while one is live
|
|
307
|
+
priority: normal # high | normal | low — where its runs stand in the queue
|
|
307
308
|
---
|
|
308
309
|
|
|
309
310
|
1. [[researcher]] — Find three sources on {{topic}}.
|
|
@@ -339,7 +340,10 @@ Indented under a step, any of:
|
|
|
339
340
|
| \`preview: draft/*.mdx\` | what the approval box shows — paths under \`storage/\`, globs allowed |
|
|
340
341
|
| \`delegate: [[a]], [[b]]\` | hand the step to whichever fits, up to 5 |
|
|
341
342
|
| \`output: json\` | the reply ends with one JSON value; the next step gets it as data, \`each: items\` fans out over it |
|
|
343
|
+
| \`schema: {...}\` | with \`output: json\` — the JSON Schema the value must satisfy; inline, a YAML block under the key, or a file path |
|
|
342
344
|
| \`each: items\` | run once per element of the previous step's JSON array |
|
|
345
|
+
| \`parallel: 3\` | how many \`each:\` instances run at once — default all |
|
|
346
|
+
| \`max_turns: 20\` | the most model turns before the step is stopped, beside \`budget:\` and \`timeout:\` |
|
|
343
347
|
|
|
344
348
|
## Writing an agent
|
|
345
349
|
|
package/dist/src/step-exec.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { McpServerConfig } from "@anthropic-ai/claude-agent-sdk";
|
|
2
2
|
import type { Effort } from "./store.ts";
|
|
3
|
+
import type { TestEffect } from "./test-mode.ts";
|
|
3
4
|
export interface ExecOutcome {
|
|
4
5
|
status: "completed" | "failed";
|
|
5
6
|
result: string | null;
|
|
@@ -43,6 +44,9 @@ export interface ExecOptions {
|
|
|
43
44
|
* The runner derives it from the flow's `budget:` and what the run has
|
|
44
45
|
* already spent; absent means no ceiling on this step. */
|
|
45
46
|
budgetUsd?: number | null;
|
|
47
|
+
/** Which line set the ceiling, for the error that names it — "budget: in
|
|
48
|
+
* the flow file" or "budget: on the <agent> agent". */
|
|
49
|
+
budgetNote?: string;
|
|
46
50
|
/** Price per token for the model, when the catalogue knows it, so spend
|
|
47
51
|
* can be counted turn by turn instead of learned at the end. Without it
|
|
48
52
|
* the step prices its turns from the SDK's own running total. */
|
|
@@ -58,28 +62,64 @@ export interface ExecOptions {
|
|
|
58
62
|
/** `output: json` — the reply must carry one JSON value; extracting it is
|
|
59
63
|
* part of finishing the step, and failing to is failing the step. */
|
|
60
64
|
output?: "json";
|
|
65
|
+
/** `schema:` — what that value must look like; a value that does not fit
|
|
66
|
+
* fails the step naming the field. See json-schema.ts. */
|
|
67
|
+
schema?: Record<string, unknown> | boolean;
|
|
68
|
+
/** `max_turns:` — the most model turns before the step is stopped. The
|
|
69
|
+
* SDK enforces it; the step reads the result's reason and says so. */
|
|
70
|
+
maxTurns?: number;
|
|
61
71
|
/** false when the caller is already an isolation boundary (a run
|
|
62
72
|
* container): the SDK's bash sandbox is then redundant and would block
|
|
63
73
|
* declared network use. Default (undefined/true) keeps it on. */
|
|
64
74
|
sandboxBash?: boolean;
|
|
75
|
+
/** Has a person asked for the run to stop? Polled every couple of
|
|
76
|
+
* seconds while the model loop runs; true interrupts it. The in-process
|
|
77
|
+
* runner answers from the run record (stopRun writes there from another
|
|
78
|
+
* process); a container has a sandbox that is destroyed instead. */
|
|
79
|
+
stopRequested?: () => boolean;
|
|
65
80
|
emit: (type: "text" | "tool" | "info" | "error", text: string, extra?: EventExtra) => void;
|
|
66
81
|
}
|
|
82
|
+
/** How often the stop flag is read while a step runs. */
|
|
83
|
+
export declare const STOP_POLL_MS = 2000;
|
|
84
|
+
/** After an interrupt is asked for, how long the model loop gets to wind
|
|
85
|
+
* down on its own before the query is aborted outright. */
|
|
86
|
+
export declare const INTERRUPT_GRACE_MS = 10000;
|
|
87
|
+
/** The model loop, as executeStep needs it — the SDK's `query`, or a test's
|
|
88
|
+
* stand-in. Only the shape the loop reads. */
|
|
89
|
+
export type QueryLike = AsyncIterable<unknown> & {
|
|
90
|
+
interrupt(): Promise<unknown>;
|
|
91
|
+
};
|
|
92
|
+
export type QueryFn = (args: {
|
|
93
|
+
prompt: string;
|
|
94
|
+
options: Record<string, unknown>;
|
|
95
|
+
}) => QueryLike;
|
|
67
96
|
/** The pairing fields on a tool event — see RunEvent in store.ts. */
|
|
68
97
|
export type EventExtra = {
|
|
69
98
|
call?: string;
|
|
70
99
|
ms?: number;
|
|
71
100
|
err?: boolean;
|
|
101
|
+
effect?: TestEffect;
|
|
72
102
|
};
|
|
73
|
-
/** Conservative per-token rates for a model
|
|
74
|
-
*
|
|
103
|
+
/** Conservative per-token rates for a model nothing else can price — an
|
|
104
|
+
* unknown id on a gateway with no catalogue. Opus-class, so a ceiling
|
|
105
|
+
* still means something. Only ever reached when `knownPrice` below does
|
|
106
|
+
* not recognise the model either. */
|
|
75
107
|
export declare const FALLBACK_PRICE: {
|
|
76
108
|
input: number;
|
|
77
109
|
output: number;
|
|
78
110
|
};
|
|
111
|
+
/** What a token costs on this model, when we can say without a catalogue. */
|
|
112
|
+
export declare function knownPrice(model: string | undefined): {
|
|
113
|
+
input: number;
|
|
114
|
+
output: number;
|
|
115
|
+
} | null;
|
|
79
116
|
/**
|
|
80
|
-
* What one assistant turn cost
|
|
81
|
-
*
|
|
82
|
-
*
|
|
117
|
+
* What one assistant turn cost, from the usage the SDK reports. Pure.
|
|
118
|
+
*
|
|
119
|
+
* A gateway whose cache is priced differently is over- or under-counted
|
|
120
|
+
* here by that difference alone; the run record's own cost is repriced
|
|
121
|
+
* from the catalogue afterwards and stays the authority on the bill. This
|
|
122
|
+
* number exists to stop a runaway step mid-turn, and wants to be close.
|
|
83
123
|
*/
|
|
84
124
|
export declare function priceTurn(u: Record<string, number | undefined>, price: {
|
|
85
125
|
input: number;
|
|
@@ -91,7 +131,23 @@ export declare function priceTurn(u: Record<string, number | undefined>, price:
|
|
|
91
131
|
* over the cap however many steps it fans out to. No budget, no ceiling.
|
|
92
132
|
*/
|
|
93
133
|
export declare function stepCeiling(budgetUsd: number | null | undefined, spentUsd: number, launching: number): number | null;
|
|
94
|
-
|
|
134
|
+
/**
|
|
135
|
+
* The ceiling a step actually runs under, and which line set it.
|
|
136
|
+
*
|
|
137
|
+
* Two caps can apply to one step: the flow's `budget:` gives it an equal
|
|
138
|
+
* share of what the run has left; the agent's own `budget:` is the most that
|
|
139
|
+
* agent may spend in one run, less what its earlier steps in this run cost.
|
|
140
|
+
* The tighter one wins, and the note says which, because "over budget" with
|
|
141
|
+
* no line to go and raise is a message that sends someone to the wrong file.
|
|
142
|
+
*/
|
|
143
|
+
export declare function stepCeilingFor(flowShareUsd: number | null, agentBudgetUsd: number | null | undefined, agentSpentUsd: number, agent: string): {
|
|
144
|
+
ceilingUsd: number | null;
|
|
145
|
+
note: string;
|
|
146
|
+
};
|
|
147
|
+
export declare function executeStep(opts: ExecOptions,
|
|
148
|
+
/** The model loop. Injected so the clock and the stop can be tested
|
|
149
|
+
* without a model; production passes nothing and gets the SDK. */
|
|
150
|
+
runQuery?: QueryFn): Promise<ExecOutcome>;
|
|
95
151
|
/**
|
|
96
152
|
* The one JSON value a reply carries. A ```json fence wins — it is what the
|
|
97
153
|
* step was asked to write — and the LAST one at that, because a model that
|