@foldrun/core 2.5.0 → 2.7.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/dist/src/agent-import.js +2 -2
- package/dist/src/agent-tools.js +2 -2
- package/dist/src/agents-md.d.ts +5 -1
- package/dist/src/agents-md.js +42 -18
- package/dist/src/capture.js +39 -1
- package/dist/src/completions.js +25 -3
- package/dist/src/deploy.js +72 -7
- package/dist/src/egress.d.ts +16 -0
- package/dist/src/evals.d.ts +22 -1
- package/dist/src/evals.js +43 -14
- package/dist/src/flow-lint.d.ts +14 -0
- package/dist/src/flow-lint.js +35 -0
- package/dist/src/flow-patterns.js +29 -14
- package/dist/src/frontmatter-fields.d.ts +27 -0
- package/dist/src/frontmatter-fields.js +51 -3
- package/dist/src/frontmatter-guard.d.ts +19 -0
- package/dist/src/frontmatter-guard.js +67 -0
- package/dist/src/frontmatter-schema.d.ts +2 -2
- package/dist/src/frontmatter-schema.js +30 -2
- package/dist/src/frontmatter-text.d.ts +31 -0
- package/dist/src/frontmatter-text.js +102 -0
- package/dist/src/judge-verdict.d.ts +19 -0
- package/dist/src/judge-verdict.js +54 -0
- package/dist/src/matter.d.ts +34 -0
- package/dist/src/matter.js +104 -0
- package/dist/src/network.d.ts +0 -1
- package/dist/src/network.js +10 -7
- package/dist/src/package.js +9 -2
- package/dist/src/provider-health.js +2 -2
- package/dist/src/run-container.d.ts +9 -0
- package/dist/src/run-container.js +34 -1
- package/dist/src/runner.js +193 -39
- package/dist/src/scan.d.ts +11 -0
- package/dist/src/scan.js +86 -8
- package/dist/src/secret-files.js +23 -2
- package/dist/src/secrets.d.ts +62 -2
- package/dist/src/secrets.js +206 -20
- package/dist/src/starter.js +23 -0
- package/dist/src/step-exec.d.ts +10 -2
- package/dist/src/step-exec.js +12 -9
- package/dist/src/store.d.ts +17 -3
- package/dist/src/store.js +14 -10
- package/package.json +1 -1
- package/src/agent-import.ts +2 -2
- package/src/agent-tools.ts +2 -2
- package/src/agents-md.ts +39 -14
- package/src/capture.ts +33 -1
- package/src/completions.ts +26 -3
- package/src/deploy.ts +59 -7
- package/src/egress.ts +15 -1
- package/src/evals.ts +45 -13
- package/src/flow-lint.ts +36 -0
- package/src/flow-patterns.ts +26 -13
- package/src/frontmatter-fields.ts +74 -3
- package/src/frontmatter-guard.ts +70 -0
- package/src/frontmatter-schema.ts +30 -4
- package/src/frontmatter-text.ts +109 -0
- package/src/judge-verdict.ts +52 -0
- package/src/matter.ts +112 -0
- package/src/network.ts +10 -6
- package/src/package.ts +8 -2
- package/src/provider-health.ts +2 -2
- package/src/run-container.ts +41 -1
- package/src/runner.ts +189 -40
- package/src/scan.ts +86 -8
- package/src/secret-files.ts +22 -2
- package/src/secrets.ts +237 -23
- package/src/starter.ts +23 -0
- package/src/step-exec.ts +21 -8
- package/src/store.ts +23 -10
package/dist/src/secret-files.js
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
// deleted the keys the other was still using.
|
|
18
18
|
import fs from "node:fs";
|
|
19
19
|
import path from "node:path";
|
|
20
|
-
import { isFileValue, fileContent, isSshValue, sshConfigOf, isApiValue, apiConfigOf, } from "./secrets.js";
|
|
20
|
+
import { isFileValue, fileContent, isSshValue, sshConfigOf, isApiValue, apiConfigOf, isClientCertValue, clientCertOf, } from "./secrets.js";
|
|
21
21
|
// POSIX single-quoting: the one escape sh actually honours inside ' is none,
|
|
22
22
|
// so close, backslash-quote, reopen. Wrapper scripts embed hosts, users and
|
|
23
23
|
// header values through this and nothing else.
|
|
@@ -34,7 +34,7 @@ function writeWrapper(dir, name, body) {
|
|
|
34
34
|
* Plain values pass through untouched.
|
|
35
35
|
*/
|
|
36
36
|
export function materializeFileSecrets(agentDir, env) {
|
|
37
|
-
const entries = Object.entries(env).filter(([, v]) => typeof v === "string" && (isFileValue(v) || isSshValue(v) || isApiValue(v)));
|
|
37
|
+
const entries = Object.entries(env).filter(([, v]) => typeof v === "string" && (isFileValue(v) || isSshValue(v) || isApiValue(v) || isClientCertValue(v)));
|
|
38
38
|
if (entries.length === 0)
|
|
39
39
|
return { env, dir: null };
|
|
40
40
|
const parent = path.join(agentDir, ".secret-files");
|
|
@@ -50,6 +50,27 @@ export function materializeFileSecrets(agentDir, env) {
|
|
|
50
50
|
fs.writeFileSync(file, fileContent(value), { mode: 0o600 });
|
|
51
51
|
out[name] = file;
|
|
52
52
|
}
|
|
53
|
+
else if (isClientCertValue(value)) {
|
|
54
|
+
// Only an exported certificate reaches here as itself (the runner
|
|
55
|
+
// withholds the rest): its parts as files, $NAME the certificate.
|
|
56
|
+
const c = clientCertOf(value);
|
|
57
|
+
if (!c.export) {
|
|
58
|
+
delete out[name];
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
const cert = path.join(dir, `${name.toLowerCase()}.crt`);
|
|
62
|
+
const key = path.join(dir, `${name.toLowerCase()}.key`);
|
|
63
|
+
fs.writeFileSync(cert, c.cert, { mode: 0o600 });
|
|
64
|
+
fs.writeFileSync(key, c.key, { mode: 0o600 });
|
|
65
|
+
out[name] = cert;
|
|
66
|
+
out[`${name}_CERT`] = cert;
|
|
67
|
+
out[`${name}_KEY`] = key;
|
|
68
|
+
if (c.passphrase) {
|
|
69
|
+
const pass = path.join(dir, `${name.toLowerCase()}.pass`);
|
|
70
|
+
fs.writeFileSync(pass, c.passphrase, { mode: 0o600 });
|
|
71
|
+
out[`${name}_PASSPHRASE_FILE`] = pass;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
53
74
|
else if (isSshValue(value)) {
|
|
54
75
|
const ssh = sshConfigOf(value);
|
|
55
76
|
const dest = `${ssh.user}@${ssh.host}`;
|
package/dist/src/secrets.d.ts
CHANGED
|
@@ -15,7 +15,7 @@ export declare function installKeyMaterial(): string;
|
|
|
15
15
|
/** How the stored value is used at run time — "oauth2"/"service-account"
|
|
16
16
|
* are refreshed into live tokens, "file"/"ssh"/"api" are materialised into
|
|
17
17
|
* the run's scratch. Plain values have no kind. */
|
|
18
|
-
export type SecretKind = "oauth2" | "service-account" | "file" | "ssh" | "api";
|
|
18
|
+
export type SecretKind = "oauth2" | "service-account" | "file" | "ssh" | "api" | "client-cert" | "identity";
|
|
19
19
|
interface StoredSecret {
|
|
20
20
|
iv: string;
|
|
21
21
|
tag: string;
|
|
@@ -133,7 +133,9 @@ export declare function rotateMasterKey(tenant: string, from: string, to: string
|
|
|
133
133
|
export interface OAuth2Config {
|
|
134
134
|
token_url: string;
|
|
135
135
|
client_id: string;
|
|
136
|
-
|
|
136
|
+
/** Absent for a public client (an app with no secret, connected with
|
|
137
|
+
* PKCE): its refresh sends client_id and refresh_token only. */
|
|
138
|
+
client_secret?: string;
|
|
137
139
|
/** Required for the refresh grant; absent for client_credentials. */
|
|
138
140
|
refresh_token?: string;
|
|
139
141
|
/** "refresh_token" (default — user-consented) or "client_credentials"
|
|
@@ -150,6 +152,24 @@ export declare const OAUTH_REFRESH_TRIES = 3;
|
|
|
150
152
|
export declare function oauthRetryDelayMs(n: number): number;
|
|
151
153
|
export declare function setOAuth2Secret(tenant: string, name: string, config: OAuth2Config, workspace?: string): void;
|
|
152
154
|
export declare function isOAuth2Value(value: string): boolean;
|
|
155
|
+
/**
|
|
156
|
+
* The cache key for a live token: SHA-256 over the kind, the account and the
|
|
157
|
+
* WHOLE stored config — credential material included (client secret, refresh
|
|
158
|
+
* token, private key, subject, scopes, token URL). A caller can only hit an
|
|
159
|
+
* entry it could have minted itself. Both maps are process-wide, so a key of
|
|
160
|
+
* names alone (`sa:${name}:${issuer}`, `${name}:${client_id}:…`) once let a
|
|
161
|
+
* second account with the same names be handed the first one's token
|
|
162
|
+
* (Codex debate, 22 Oct 2026).
|
|
163
|
+
*/
|
|
164
|
+
export declare function tokenCacheKey(kind: "oauth2" | "sa", storedValue: string, tenant?: string): string;
|
|
165
|
+
/**
|
|
166
|
+
* A provider that rotates refresh tokens (RFC 9700 §2.2.2) answered a refresh
|
|
167
|
+
* with a new one: write it back where the old one is stored — the workspace
|
|
168
|
+
* copy, else the account's — but only while that copy still holds the value
|
|
169
|
+
* this refresh used (compare-and-set), so two refreshes racing cannot put an
|
|
170
|
+
* older token over a newer one. Returns whether a copy was replaced.
|
|
171
|
+
*/
|
|
172
|
+
export declare function replaceOAuth2RefreshToken(tenant: string, name: string, workspace: string | undefined, usedValue: string, refreshToken: string): boolean;
|
|
153
173
|
/**
|
|
154
174
|
* Swap every oauth2 recipe in an env map for a live access token. The one
|
|
155
175
|
* async moment in secret resolution, kept out of the sync paths: callers
|
|
@@ -171,6 +191,10 @@ export interface ServiceAccountConfig {
|
|
|
171
191
|
scope: string;
|
|
172
192
|
/** JWT audience — usually the same as token_url. */
|
|
173
193
|
audience?: string;
|
|
194
|
+
/** The JWT bearer grant's subject (RFC 7523 §3): the user the account acts
|
|
195
|
+
* as, where the provider allows it — Google's domain-wide delegation, once
|
|
196
|
+
* a Workspace admin grants it. Absent, the account acts as itself. */
|
|
197
|
+
subject?: string;
|
|
174
198
|
}
|
|
175
199
|
export declare function setServiceAccountSecret(tenant: string, name: string, config: ServiceAccountConfig, workspace?: string): void;
|
|
176
200
|
export declare function setFileSecret(tenant: string, name: string, content: string, workspace?: string): void;
|
|
@@ -187,10 +211,46 @@ export interface SshConfig {
|
|
|
187
211
|
export declare function setSshSecret(tenant: string, name: string, config: SshConfig, workspace?: string): void;
|
|
188
212
|
export declare function isSshValue(value: string): boolean;
|
|
189
213
|
export declare function sshConfigOf(value: string): SshConfig;
|
|
214
|
+
/** A client certificate for mutual TLS: presented by the egress proxy, on its
|
|
215
|
+
* own connection, to the exact hosts the secret is bound to — the sandbox
|
|
216
|
+
* never holds the key. `export` puts it in the sandbox as files instead,
|
|
217
|
+
* only when asked for (a step with no egress proxy). */
|
|
218
|
+
export interface ClientCertConfig {
|
|
219
|
+
/** PEM certificate, chain included. */
|
|
220
|
+
cert: string;
|
|
221
|
+
/** PEM private key. */
|
|
222
|
+
key: string;
|
|
223
|
+
passphrase?: string;
|
|
224
|
+
export?: boolean;
|
|
225
|
+
}
|
|
226
|
+
export declare function setClientCertSecret(tenant: string, name: string, config: ClientCertConfig, workspace?: string): void;
|
|
227
|
+
/** A keyless cloud identity: no credential at all, only the audience the
|
|
228
|
+
* platform's signed tokens are for. A step that declares it gets a token
|
|
229
|
+
* file the sandbox driver keeps fresh through the egress proxy. */
|
|
230
|
+
export interface IdentityConfig {
|
|
231
|
+
audience: string;
|
|
232
|
+
}
|
|
233
|
+
export declare function setIdentitySecret(tenant: string, name: string, config: IdentityConfig, workspace?: string): void;
|
|
234
|
+
export declare function isIdentityValue(value: string): boolean;
|
|
235
|
+
export declare function identityOf(value: string): IdentityConfig;
|
|
236
|
+
export declare function isClientCertValue(value: string): boolean;
|
|
237
|
+
/** An environment without the client certificates that were not saved with
|
|
238
|
+
* `export`: those are the egress proxy's to present, never a program's. */
|
|
239
|
+
export declare function withoutHeldCerts(env: Record<string, string>): Record<string, string>;
|
|
240
|
+
export declare function clientCertOf(value: string): ClientCertConfig;
|
|
190
241
|
export interface ApiConfig {
|
|
191
242
|
base_url?: string;
|
|
192
243
|
headers: Record<string, string>;
|
|
244
|
+
/** Username and password for HTTP Basic (RFC 7617): stored as the
|
|
245
|
+
* `Authorization: Basic …` header they make, never kept apart. */
|
|
246
|
+
basic?: {
|
|
247
|
+
username: string;
|
|
248
|
+
password: string;
|
|
249
|
+
};
|
|
193
250
|
}
|
|
251
|
+
/** `Authorization: Basic …` from a username and password (RFC 7617 §2):
|
|
252
|
+
* UTF-8, no colon in the username, no control characters in either. */
|
|
253
|
+
export declare function basicAuthorization(username: string, password: string): string;
|
|
194
254
|
export declare function setApiSecret(tenant: string, name: string, config: ApiConfig, workspace?: string): void;
|
|
195
255
|
export declare function isApiValue(value: string): boolean;
|
|
196
256
|
export declare function apiConfigOf(value: string): ApiConfig;
|
package/dist/src/secrets.js
CHANGED
|
@@ -200,6 +200,11 @@ export function setSecretSites(tenant, name, sites, workspace) {
|
|
|
200
200
|
if (clean.length && (rec.kind === "file" || rec.kind === "ssh" || rec.kind === "api")) {
|
|
201
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
202
|
}
|
|
203
|
+
// A client certificate is presented by the egress proxy to the exact
|
|
204
|
+
// hosts named — never to a whole domain on a wildcard.
|
|
205
|
+
if (rec.kind === "client-cert" && clean.some((s) => s.startsWith("*."))) {
|
|
206
|
+
throw new Error(`${name} is a client certificate — bind it to exact hosts, not a wildcard`);
|
|
207
|
+
}
|
|
203
208
|
if (clean.length)
|
|
204
209
|
rec.sites = clean;
|
|
205
210
|
else
|
|
@@ -459,10 +464,15 @@ export function oauthRetryDelayMs(n) {
|
|
|
459
464
|
return base * (n === 1 ? 1 : 3);
|
|
460
465
|
}
|
|
461
466
|
export function setOAuth2Secret(tenant, name, config, workspace) {
|
|
462
|
-
for (const field of ["token_url", "client_id"
|
|
467
|
+
for (const field of ["token_url", "client_id"]) {
|
|
463
468
|
if (!config[field]?.trim())
|
|
464
469
|
throw new Error(`oauth2 secret needs ${field}`);
|
|
465
470
|
}
|
|
471
|
+
// A public client (PKCE, no secret) refreshes with its client id alone;
|
|
472
|
+
// client credentials are nothing without the secret.
|
|
473
|
+
if (config.grant_type === "client_credentials" && !config.client_secret?.trim()) {
|
|
474
|
+
throw new Error("oauth2 secret needs client_secret for client_credentials");
|
|
475
|
+
}
|
|
466
476
|
if (config.grant_type !== "client_credentials" && !config.refresh_token?.trim()) {
|
|
467
477
|
throw new Error("oauth2 secret needs refresh_token (or grant_type: client_credentials)");
|
|
468
478
|
}
|
|
@@ -481,11 +491,62 @@ export function isOAuth2Value(value) {
|
|
|
481
491
|
// steps must not hit Google twenty times in one second.
|
|
482
492
|
const tokenCache = new Map();
|
|
483
493
|
const inFlight = new Map();
|
|
494
|
+
/**
|
|
495
|
+
* The cache key for a live token: SHA-256 over the kind, the account and the
|
|
496
|
+
* WHOLE stored config — credential material included (client secret, refresh
|
|
497
|
+
* token, private key, subject, scopes, token URL). A caller can only hit an
|
|
498
|
+
* entry it could have minted itself. Both maps are process-wide, so a key of
|
|
499
|
+
* names alone (`sa:${name}:${issuer}`, `${name}:${client_id}:…`) once let a
|
|
500
|
+
* second account with the same names be handed the first one's token
|
|
501
|
+
* (Codex debate, 22 Oct 2026).
|
|
502
|
+
*/
|
|
503
|
+
export function tokenCacheKey(kind, storedValue, tenant) {
|
|
504
|
+
return crypto.createHash("sha256").update(JSON.stringify([kind, tenant ?? "", storedValue])).digest("hex");
|
|
505
|
+
}
|
|
506
|
+
/**
|
|
507
|
+
* A provider that rotates refresh tokens (RFC 9700 §2.2.2) answered a refresh
|
|
508
|
+
* with a new one: write it back where the old one is stored — the workspace
|
|
509
|
+
* copy, else the account's — but only while that copy still holds the value
|
|
510
|
+
* this refresh used (compare-and-set), so two refreshes racing cannot put an
|
|
511
|
+
* older token over a newer one. Returns whether a copy was replaced.
|
|
512
|
+
*/
|
|
513
|
+
export function replaceOAuth2RefreshToken(tenant, name, workspace, usedValue, refreshToken) {
|
|
514
|
+
if (!isOAuth2Value(usedValue))
|
|
515
|
+
return false;
|
|
516
|
+
const config = JSON.parse(usedValue.slice(OAUTH2_PREFIX.length));
|
|
517
|
+
const next = OAUTH2_PREFIX + JSON.stringify({ ...config, refresh_token: refreshToken });
|
|
518
|
+
for (const scope of workspace ? [workspace, undefined] : [undefined]) {
|
|
519
|
+
let replaced = false;
|
|
520
|
+
mutateVaultFile(secretsFile(tenant, scope), (all) => {
|
|
521
|
+
const rec = all[name];
|
|
522
|
+
if (rec && decrypt(tenant, rec) === usedValue) {
|
|
523
|
+
all[name] = { ...rec, ...encryptValue(tenant, next), updatedAt: new Date().toISOString() };
|
|
524
|
+
replaced = true;
|
|
525
|
+
}
|
|
526
|
+
return all;
|
|
527
|
+
});
|
|
528
|
+
if (replaced)
|
|
529
|
+
return true;
|
|
530
|
+
}
|
|
531
|
+
return false;
|
|
532
|
+
}
|
|
533
|
+
const oauthCache = new Map();
|
|
534
|
+
const oauthFlight = new Map();
|
|
535
|
+
/** Cache an access token, sweeping expired entries as it goes: a rotating
|
|
536
|
+
* provider changes the stored config — and so the key — on every refresh,
|
|
537
|
+
* and expired entries would otherwise pile up for the life of the process. */
|
|
538
|
+
function cacheOAuth(key, live) {
|
|
539
|
+
const now = Date.now();
|
|
540
|
+
for (const [k, v] of oauthCache)
|
|
541
|
+
if (v.expiresAt <= now)
|
|
542
|
+
oauthCache.delete(k);
|
|
543
|
+
oauthCache.set(key, live);
|
|
544
|
+
}
|
|
484
545
|
async function exchange(config, cacheKey) {
|
|
485
|
-
const cached =
|
|
546
|
+
const cached = oauthCache.get(cacheKey);
|
|
486
547
|
if (cached && cached.expiresAt > Date.now())
|
|
487
|
-
return cached
|
|
488
|
-
const pending =
|
|
548
|
+
return cached;
|
|
549
|
+
const pending = oauthFlight.get(cacheKey);
|
|
489
550
|
if (pending)
|
|
490
551
|
return pending;
|
|
491
552
|
const flight = (async () => {
|
|
@@ -493,7 +554,7 @@ async function exchange(config, cacheKey) {
|
|
|
493
554
|
grant_type: config.grant_type ?? "refresh_token",
|
|
494
555
|
...(config.grant_type === "client_credentials" ? {} : { refresh_token: config.refresh_token }),
|
|
495
556
|
client_id: config.client_id,
|
|
496
|
-
client_secret: config.client_secret,
|
|
557
|
+
...(config.client_secret ? { client_secret: config.client_secret } : {}),
|
|
497
558
|
...(config.extra ?? {}),
|
|
498
559
|
});
|
|
499
560
|
// A network blip is retried; a provider's answer is not. "fetch failed"
|
|
@@ -530,15 +591,21 @@ async function exchange(config, cacheKey) {
|
|
|
530
591
|
throw new Error(`token refresh failed (${res.status}): ${payload.error_description ?? payload.error ?? "no access_token in the reply"}`);
|
|
531
592
|
}
|
|
532
593
|
const ttl = Math.max(60, (payload.expires_in ?? 3600) - 60) * 1000;
|
|
533
|
-
|
|
534
|
-
|
|
594
|
+
// A rotated refresh token replaces the old one, which the provider may
|
|
595
|
+
// now refuse; dropping it broke the next refresh for rotating providers.
|
|
596
|
+
const rotated = payload.refresh_token && config.grant_type !== "client_credentials" && payload.refresh_token !== config.refresh_token
|
|
597
|
+
? payload.refresh_token
|
|
598
|
+
: undefined;
|
|
599
|
+
const live = { token: payload.access_token, expiresAt: Date.now() + ttl, ...(rotated ? { rotated } : {}) };
|
|
600
|
+
cacheOAuth(cacheKey, live);
|
|
601
|
+
return live;
|
|
535
602
|
})();
|
|
536
|
-
|
|
603
|
+
oauthFlight.set(cacheKey, flight);
|
|
537
604
|
try {
|
|
538
605
|
return await flight;
|
|
539
606
|
}
|
|
540
607
|
finally {
|
|
541
|
-
|
|
608
|
+
oauthFlight.delete(cacheKey);
|
|
542
609
|
}
|
|
543
610
|
}
|
|
544
611
|
/**
|
|
@@ -565,13 +632,23 @@ ctx) {
|
|
|
565
632
|
throw new Error(`secret ${name}: stored oauth2 config is unreadable`);
|
|
566
633
|
}
|
|
567
634
|
try {
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
635
|
+
const live = await exchange(config, tokenCacheKey("oauth2", value, ctx?.tenant));
|
|
636
|
+
out[name] = live.token;
|
|
637
|
+
if (live.rotated) {
|
|
638
|
+
// This caller's copy takes the new refresh token (compare-and-set:
|
|
639
|
+
// a copy already updated, or changed since, is left alone), and the
|
|
640
|
+
// access token is cached under the config as it is now stored, so
|
|
641
|
+
// the next run does not refresh again.
|
|
642
|
+
const next = OAUTH2_PREFIX + JSON.stringify({ ...config, refresh_token: live.rotated });
|
|
643
|
+
try {
|
|
644
|
+
if (ctx)
|
|
645
|
+
replaceOAuth2RefreshToken(ctx.tenant, name, ctx.workspace, value, live.rotated);
|
|
646
|
+
}
|
|
647
|
+
catch {
|
|
648
|
+
// The access token is good either way; the next refresh says if not.
|
|
649
|
+
}
|
|
650
|
+
cacheOAuth(tokenCacheKey("oauth2", next, ctx?.tenant), { token: live.token, expiresAt: live.expiresAt });
|
|
651
|
+
}
|
|
575
652
|
}
|
|
576
653
|
catch (err) {
|
|
577
654
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -590,7 +667,7 @@ ctx) {
|
|
|
590
667
|
throw new Error(`secret ${name}: stored service account config is unreadable`);
|
|
591
668
|
}
|
|
592
669
|
try {
|
|
593
|
-
out[name] = await exchangeServiceAccount(config,
|
|
670
|
+
out[name] = await exchangeServiceAccount(config, tokenCacheKey("sa", value, ctx?.tenant));
|
|
594
671
|
}
|
|
595
672
|
catch (err) {
|
|
596
673
|
throw new Error(`secret ${name}: ${err instanceof Error ? err.message : String(err)}`);
|
|
@@ -619,7 +696,14 @@ export function setServiceAccountSecret(tenant, name, config, workspace) {
|
|
|
619
696
|
if (!/BEGIN [A-Z ]{0,40}PRIVATE KEY/.test(config.private_key)) {
|
|
620
697
|
throw new Error("private_key must be a PEM key (from the service account JSON)");
|
|
621
698
|
}
|
|
622
|
-
|
|
699
|
+
if (config.subject !== undefined) {
|
|
700
|
+
if (typeof config.subject !== "string" || !config.subject.trim())
|
|
701
|
+
throw new Error("subject is the user to act as — leave it out to act as the account itself");
|
|
702
|
+
if (config.subject.length > 320 || /[\u0000-\u001f\u007f\s]/.test(config.subject.trim()))
|
|
703
|
+
throw new Error("subject is one identifier, no spaces or control characters");
|
|
704
|
+
}
|
|
705
|
+
const stored = { ...config, ...(config.subject !== undefined ? { subject: config.subject.trim() } : {}) };
|
|
706
|
+
setSecret(tenant, name, SERVICE_ACCOUNT_PREFIX + JSON.stringify(stored), workspace, "service-account");
|
|
623
707
|
}
|
|
624
708
|
function base64url(input) {
|
|
625
709
|
return Buffer.from(input).toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
@@ -636,7 +720,9 @@ async function exchangeServiceAccount(config, cacheKey) {
|
|
|
636
720
|
const header = base64url(JSON.stringify({ alg: "RS256", typ: "JWT" }));
|
|
637
721
|
const claim = base64url(JSON.stringify({
|
|
638
722
|
iss: config.issuer,
|
|
639
|
-
|
|
723
|
+
// The subject is part of the stored config, so of the cache key: two
|
|
724
|
+
// subjects never share a token (tokenCacheKey).
|
|
725
|
+
sub: config.subject || config.issuer,
|
|
640
726
|
scope: config.scope,
|
|
641
727
|
aud: config.audience ?? config.token_url,
|
|
642
728
|
iat: now,
|
|
@@ -737,8 +823,108 @@ export function sshConfigOf(value) {
|
|
|
737
823
|
// wrapper with the headers baked in — `"$NAME" /v1/charges` — so the secret
|
|
738
824
|
// never appears in the bash command at all.
|
|
739
825
|
const API_PREFIX = "@api ";
|
|
826
|
+
const CLIENT_CERT_PREFIX = "@client-cert ";
|
|
827
|
+
export function setClientCertSecret(tenant, name, config, workspace) {
|
|
828
|
+
if (!/-----BEGIN CERTIFICATE-----/.test(config.cert ?? ""))
|
|
829
|
+
throw new Error("cert must be a PEM certificate");
|
|
830
|
+
if (!/-----BEGIN [A-Z ]{0,40}PRIVATE KEY-----/.test(config.key ?? ""))
|
|
831
|
+
throw new Error("key must be a PEM private key");
|
|
832
|
+
// The pair must match: a certificate presented with another key's proof
|
|
833
|
+
// fails at the server, after the request has gone.
|
|
834
|
+
let matches = false;
|
|
835
|
+
try {
|
|
836
|
+
const key = crypto.createPrivateKey({ key: config.key, ...(config.passphrase ? { passphrase: config.passphrase } : {}) });
|
|
837
|
+
matches = new crypto.X509Certificate(config.cert).checkPrivateKey(key);
|
|
838
|
+
}
|
|
839
|
+
catch (err) {
|
|
840
|
+
throw new Error(`the certificate or key cannot be read: ${err instanceof Error ? err.message : String(err)}`);
|
|
841
|
+
}
|
|
842
|
+
if (!matches)
|
|
843
|
+
throw new Error("the key does not belong to the certificate");
|
|
844
|
+
const stored = {
|
|
845
|
+
cert: config.cert,
|
|
846
|
+
key: config.key,
|
|
847
|
+
...(config.passphrase ? { passphrase: config.passphrase } : {}),
|
|
848
|
+
...(config.export ? { export: true } : {}),
|
|
849
|
+
};
|
|
850
|
+
if (!SECRET_NAME.test(name))
|
|
851
|
+
throw new Error(`secret name "${name}" must be UPPER_SNAKE_CASE`);
|
|
852
|
+
const sealed = encryptValue(tenant, CLIENT_CERT_PREFIX + JSON.stringify(stored));
|
|
853
|
+
// Written under the vault's own lock, with the binding checked in the same
|
|
854
|
+
// step: a value set over a secret keeps its site binding, and a wildcard
|
|
855
|
+
// one would present the certificate to a whole domain.
|
|
856
|
+
mutateVaultFile(secretsFile(tenant, workspace), (all) => {
|
|
857
|
+
const sites = all[name]?.sites;
|
|
858
|
+
if (sites?.some((site) => site.startsWith("*."))) {
|
|
859
|
+
throw new Error(`${name} is bound to ${sites.join(", ")} — a client certificate is bound to exact hosts; unbind the wildcard first`);
|
|
860
|
+
}
|
|
861
|
+
all[name] = { ...sealed, updatedAt: new Date().toISOString(), kind: "client-cert", ...(sites?.length ? { sites } : {}) };
|
|
862
|
+
return all;
|
|
863
|
+
});
|
|
864
|
+
}
|
|
865
|
+
const IDENTITY_PREFIX = "@identity ";
|
|
866
|
+
export function setIdentitySecret(tenant, name, config, workspace) {
|
|
867
|
+
const audience = typeof config.audience === "string" ? config.audience.trim() : "";
|
|
868
|
+
if (!audience)
|
|
869
|
+
throw new Error("an identity needs an audience — the value your cloud's trust expects in aud");
|
|
870
|
+
if (audience.length > 600 || /[\u0000-\u001f\u007f]/.test(audience))
|
|
871
|
+
throw new Error("audience is one value, at most 600 characters, no control characters");
|
|
872
|
+
setSecret(tenant, name, IDENTITY_PREFIX + JSON.stringify({ audience }), workspace, "identity");
|
|
873
|
+
}
|
|
874
|
+
export function isIdentityValue(value) {
|
|
875
|
+
return value.startsWith(IDENTITY_PREFIX);
|
|
876
|
+
}
|
|
877
|
+
export function identityOf(value) {
|
|
878
|
+
return JSON.parse(value.slice(IDENTITY_PREFIX.length));
|
|
879
|
+
}
|
|
880
|
+
export function isClientCertValue(value) {
|
|
881
|
+
return value.startsWith(CLIENT_CERT_PREFIX);
|
|
882
|
+
}
|
|
883
|
+
/** An environment without the client certificates that were not saved with
|
|
884
|
+
* `export`: those are the egress proxy's to present, never a program's. */
|
|
885
|
+
export function withoutHeldCerts(env) {
|
|
886
|
+
return Object.fromEntries(Object.entries(env).filter(([, v]) => typeof v !== "string" || !isClientCertValue(v) || clientCertOf(v).export === true));
|
|
887
|
+
}
|
|
888
|
+
export function clientCertOf(value) {
|
|
889
|
+
return JSON.parse(value.slice(CLIENT_CERT_PREFIX.length));
|
|
890
|
+
}
|
|
891
|
+
/** `Authorization: Basic …` from a username and password (RFC 7617 §2):
|
|
892
|
+
* UTF-8, no colon in the username, no control characters in either. */
|
|
893
|
+
export function basicAuthorization(username, password) {
|
|
894
|
+
if (typeof username !== "string" || typeof password !== "string")
|
|
895
|
+
throw new Error("basic needs username and password");
|
|
896
|
+
if (!username)
|
|
897
|
+
throw new Error("basic needs a username");
|
|
898
|
+
if (username.includes(":"))
|
|
899
|
+
throw new Error("a Basic username cannot contain a colon (RFC 7617)");
|
|
900
|
+
if (/[\u0000-\u001f\u007f]/.test(username) || /[\u0000-\u001f\u007f]/.test(password))
|
|
901
|
+
throw new Error("Basic credentials cannot contain control characters");
|
|
902
|
+
return `Basic ${Buffer.from(`${username}:${password}`, "utf8").toString("base64")}`;
|
|
903
|
+
}
|
|
740
904
|
export function setApiSecret(tenant, name, config, workspace) {
|
|
741
|
-
|
|
905
|
+
let all = { ...(config.headers ?? {}) };
|
|
906
|
+
if (config.basic) {
|
|
907
|
+
if (Object.keys(all).some((h) => h.toLowerCase() === "authorization")) {
|
|
908
|
+
throw new Error("give either basic or an Authorization header, not both");
|
|
909
|
+
}
|
|
910
|
+
// Credentials in the clear over http are leaked ones: https, or loopback.
|
|
911
|
+
const base = config.base_url?.trim();
|
|
912
|
+
if (base) {
|
|
913
|
+
let u = null;
|
|
914
|
+
try {
|
|
915
|
+
u = new URL(base);
|
|
916
|
+
}
|
|
917
|
+
catch {
|
|
918
|
+
u = null;
|
|
919
|
+
}
|
|
920
|
+
const loopback = u?.protocol === "http:" && (u.hostname === "localhost" || u.hostname === "127.0.0.1");
|
|
921
|
+
if (!u || (u.protocol !== "https:" && !loopback))
|
|
922
|
+
throw new Error("Basic credentials need an https base_url");
|
|
923
|
+
}
|
|
924
|
+
all = { ...all, Authorization: basicAuthorization(config.basic.username, config.basic.password) };
|
|
925
|
+
}
|
|
926
|
+
config = { ...(config.base_url ? { base_url: config.base_url } : {}), headers: all };
|
|
927
|
+
const headers = Object.entries(all);
|
|
742
928
|
if (headers.length === 0)
|
|
743
929
|
throw new Error("api secret needs at least one header");
|
|
744
930
|
for (const [h, v] of headers) {
|
package/dist/src/starter.js
CHANGED
|
@@ -55,6 +55,22 @@ foldrun_version: "0.1"
|
|
|
55
55
|
#
|
|
56
56
|
# The token is sent as a bearer credential. A gateway that wants it as a key
|
|
57
57
|
# header asks for that by name: headers: { x-api-key: \${THE_SECRET} }.
|
|
58
|
+
#
|
|
59
|
+
# The four jobs of running agents, each a block — or write the same keys at
|
|
60
|
+
# the top; one place or the other, never both:
|
|
61
|
+
#
|
|
62
|
+
# governance: # who answers for it, what it may spend and ship
|
|
63
|
+
# owner: you@example.com
|
|
64
|
+
# budget: 60/month
|
|
65
|
+
# deploy_block: secrets
|
|
66
|
+
# guardrail: # what agents may reach and call
|
|
67
|
+
# limits: {calls: 300, repeat: 5}
|
|
68
|
+
# network: trusted
|
|
69
|
+
# observability: # what is recorded, and who is told
|
|
70
|
+
# notify: {email: you@example.com, events: [failed]}
|
|
71
|
+
# alerts: {failure_rate: 30%}
|
|
72
|
+
# evaluation: # how work is judged
|
|
73
|
+
# judge: {model: fast}
|
|
58
74
|
---
|
|
59
75
|
|
|
60
76
|
# ${account}
|
|
@@ -476,6 +492,13 @@ runtime: # only if scripts need packages
|
|
|
476
492
|
You research a topic and report what you found...
|
|
477
493
|
\`\`\`
|
|
478
494
|
|
|
495
|
+
The fields that do one of the four jobs of running agents may be grouped
|
|
496
|
+
under it — \`governance:\` (owner, budget), \`guardrail:\` (limits, network,
|
|
497
|
+
hooks, disallowedTools, permissionMode, secrets), and in AGENTS.md also
|
|
498
|
+
\`observability:\` (alerts, telemetry, notify, capture) and \`evaluation:\`
|
|
499
|
+
(judge). A block reads exactly as the same keys at the top; a key written
|
|
500
|
+
both ways is refused (\`config/conflict\`). \`foldrun docs four-jobs\`.
|
|
501
|
+
|
|
479
502
|
Only \`name\` and the prose are required. Every other field widens or narrows
|
|
480
503
|
what the agent can reach, and the default is narrow.
|
|
481
504
|
|
package/dist/src/step-exec.d.ts
CHANGED
|
@@ -104,6 +104,11 @@ export interface ExecOptions {
|
|
|
104
104
|
* `matches: re`, `file: path`, `judge: sentence`) — see checkVerify. */
|
|
105
105
|
verify?: string;
|
|
106
106
|
verifyEnv?: Record<string, string>;
|
|
107
|
+
/** The model `verify: judge:` grades with — the workspace's `judge:` from
|
|
108
|
+
* AGENTS.md, resolved; absent, the fast tier. */
|
|
109
|
+
judgeModel?: string;
|
|
110
|
+
/** And how long it thinks — `judge: {effort}`; absent, the SDK's default. */
|
|
111
|
+
judgeEffort?: Effort;
|
|
107
112
|
/** `output: json` — the reply must carry one JSON value; extracting it is
|
|
108
113
|
* part of finishing the step, and failing to is failing the step. */
|
|
109
114
|
output?: "json";
|
|
@@ -301,14 +306,17 @@ export declare function checkVerify(agentDir: string, verify: string, ctx: {
|
|
|
301
306
|
/** The step's own model environment, for `judge:` — it grades on the
|
|
302
307
|
* fast tier through the same credential the step rode. */
|
|
303
308
|
modelEnv?: Record<string, string | undefined>;
|
|
309
|
+
/** The judge's model and effort (the workspace's `judge:`). */
|
|
310
|
+
judgeModel?: string;
|
|
311
|
+
judgeEffort?: Effort;
|
|
304
312
|
/** Ends a shell check or a judge mid-way: the step's clock ran out, or a
|
|
305
313
|
* person asked the run to stop. */
|
|
306
314
|
signal?: AbortSignal;
|
|
307
315
|
}): Promise<VerifyVerdict>;
|
|
308
316
|
/**
|
|
309
317
|
* A toolless, fast-tier grading call: does the reply satisfy the sentence?
|
|
310
|
-
* Same shape as the eval judge
|
|
311
|
-
*
|
|
318
|
+
* Same shape as the eval judge: reasoning first, then the verdict alone on
|
|
319
|
+
* the last line (judge-verdict.ts says why).
|
|
312
320
|
*/
|
|
313
321
|
export declare function judgePrompt(rubric: string, reply: string, work: string): string;
|
|
314
322
|
/**
|
package/dist/src/step-exec.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
// assembles the system prompt and builds MCP servers, then hands this the
|
|
8
8
|
// results. That is what lets the same function run somewhere the vault, the
|
|
9
9
|
// library and the account do not exist.
|
|
10
|
+
import { judgeVerdict, JUDGE_VERDICT_INSTRUCTION } from "./judge-verdict.js";
|
|
10
11
|
import { runHook, runToolCallHooks, matchesTool, hookLine, resolveHooks } from "./hooks.js";
|
|
11
12
|
import fs from "node:fs";
|
|
12
13
|
import os from "node:os";
|
|
@@ -901,6 +902,8 @@ async function executeStepInner(opts, runQuery) {
|
|
|
901
902
|
data,
|
|
902
903
|
modelEnv: opts.env,
|
|
903
904
|
signal: halt.signal,
|
|
905
|
+
...(opts.judgeModel ? { judgeModel: opts.judgeModel } : {}),
|
|
906
|
+
...(opts.judgeEffort ? { judgeEffort: opts.judgeEffort } : {}),
|
|
904
907
|
});
|
|
905
908
|
}
|
|
906
909
|
finally {
|
|
@@ -1045,17 +1048,17 @@ export async function checkVerify(agentDir, verify, ctx) {
|
|
|
1045
1048
|
// GOOD` on a reply that did (fix-desk, run-muwevg72-7mza): the joined
|
|
1046
1049
|
// text opened with the narration before it.
|
|
1047
1050
|
const reply = ctx.conclusion?.trim() ? ctx.conclusion : output;
|
|
1048
|
-
const
|
|
1049
|
-
const ok =
|
|
1050
|
-
return { ok, headline: ok ? "PASS" : "FAIL", detail
|
|
1051
|
+
const said = await judgeReply(value, reply, output, ctx.modelEnv ?? {}, ctx.signal, ctx.judgeModel, ctx.judgeEffort);
|
|
1052
|
+
const { passed: ok, detail } = judgeVerdict(said);
|
|
1053
|
+
return { ok, headline: ok ? "PASS" : "FAIL", detail };
|
|
1051
1054
|
}
|
|
1052
1055
|
}
|
|
1053
1056
|
return { ok: false, headline: `unknown check "${kind}"`, detail: "" };
|
|
1054
1057
|
}
|
|
1055
1058
|
/**
|
|
1056
1059
|
* A toolless, fast-tier grading call: does the reply satisfy the sentence?
|
|
1057
|
-
* Same shape as the eval judge
|
|
1058
|
-
*
|
|
1060
|
+
* Same shape as the eval judge: reasoning first, then the verdict alone on
|
|
1061
|
+
* the last line (judge-verdict.ts says why).
|
|
1059
1062
|
*/
|
|
1060
1063
|
export function judgePrompt(rubric, reply, work) {
|
|
1061
1064
|
const shown = reply.slice(0, 20_000);
|
|
@@ -1063,12 +1066,11 @@ export function judgePrompt(rubric, reply, work) {
|
|
|
1063
1066
|
? `Everything the step wrote while working, for context. "The reply" means only the reply block below, ` +
|
|
1064
1067
|
`never this:\n<work>\n${work.slice(-20_000)}\n</work>\n\n`
|
|
1065
1068
|
: "";
|
|
1066
|
-
return (`You are grading a reply against one requirement.
|
|
1067
|
-
`first word, then one sentence of reason.\n\nRequirement: ${rubric}\n\n` +
|
|
1069
|
+
return (`You are grading a reply against one requirement. ${JUDGE_VERDICT_INSTRUCTION}\n\nRequirement: ${rubric}\n\n` +
|
|
1068
1070
|
context +
|
|
1069
1071
|
`<reply>\n${shown}\n</reply>`);
|
|
1070
1072
|
}
|
|
1071
|
-
async function judgeReply(rubric, reply, work, env, signal) {
|
|
1073
|
+
async function judgeReply(rubric, reply, work, env, signal, model = "haiku", effort) {
|
|
1072
1074
|
const texts = [];
|
|
1073
1075
|
const abortController = new AbortController();
|
|
1074
1076
|
if (signal?.aborted)
|
|
@@ -1078,7 +1080,8 @@ async function judgeReply(rubric, reply, work, env, signal) {
|
|
|
1078
1080
|
const q = query({
|
|
1079
1081
|
prompt: judgePrompt(rubric, reply, work),
|
|
1080
1082
|
options: {
|
|
1081
|
-
model
|
|
1083
|
+
model,
|
|
1084
|
+
...(effort ? { effort } : {}),
|
|
1082
1085
|
systemPrompt: "You grade text against a stated requirement. Be strict and literal.",
|
|
1083
1086
|
tools: [],
|
|
1084
1087
|
settingSources: [],
|
package/dist/src/store.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type { BrowserPodOutcome } from "./browser-pod.ts";
|
|
|
3
3
|
import { singleWorkspace } from "./paths.ts";
|
|
4
4
|
import type { TestEffect } from "./test-mode.ts";
|
|
5
5
|
import type { OperatorEvent } from "./operator.ts";
|
|
6
|
+
import { frontmatterErrorText } from "./matter.ts";
|
|
6
7
|
/**
|
|
7
8
|
* The frontmatter of one markdown file, as the runtime reads it. Exported
|
|
8
9
|
* for the CLI: `foldrun probe` used to import gray-matter itself, which is
|
|
@@ -640,9 +641,7 @@ export interface AgentAssets {
|
|
|
640
641
|
}
|
|
641
642
|
export declare function agentAssets(tenant: string, workspace: string, agent: string): AgentAssets;
|
|
642
643
|
export declare function listAgents(tenant: string, workspace: string): AgentInfo[];
|
|
643
|
-
|
|
644
|
-
* needs to fix the file, without the parser's excerpt. */
|
|
645
|
-
export declare function frontmatterErrorText(err: unknown): string;
|
|
644
|
+
export { frontmatterErrorText };
|
|
646
645
|
/** The option keys a step actually has. An indented `word: rest` that is
|
|
647
646
|
* NOT one of these is a line of the instruction — "Warning: do not
|
|
648
647
|
* publish" is prose that happens to contain a colon, and it used to be
|
|
@@ -1069,6 +1068,14 @@ export interface StepAttempt {
|
|
|
1069
1068
|
export declare function sumAttempts(tries: StepAttempt[]): Pick<StepAttempt, "costUsd" | "tokens" | "computeSecs" | "turns">;
|
|
1070
1069
|
export { type TokenUsage, type PromptLayerRecord, tokensOf, sumTokens, cacheHitRate, measuredHitRate, runTokens, promptLayersChanged, promptCacheNote } from "./tokens.ts";
|
|
1071
1070
|
import { type TokenUsage, type PromptLayerRecord } from "./tokens.ts";
|
|
1071
|
+
/** The marker rescueStep puts in a rescue's instruction; how a rescue record
|
|
1072
|
+
* written before `rescueOf` existed is still recognised. */
|
|
1073
|
+
export declare const RESCUE_MARKER = "<rescue>\nYou are taking this step over:";
|
|
1074
|
+
/** Is this record an on-fail rescue rather than a step of the flow? */
|
|
1075
|
+
export declare function isRescueRecord(step: {
|
|
1076
|
+
rescueOf?: string;
|
|
1077
|
+
instruction?: string | null;
|
|
1078
|
+
}): boolean;
|
|
1072
1079
|
export interface StepRecord {
|
|
1073
1080
|
agent: string;
|
|
1074
1081
|
instruction: string;
|
|
@@ -1228,6 +1235,13 @@ export interface StepRecord {
|
|
|
1228
1235
|
* actually executed in. Carried steps provide context and are never
|
|
1229
1236
|
* billed again — the original run's ledger line already paid for them. */
|
|
1230
1237
|
carriedFrom?: string;
|
|
1238
|
+
/** Set on an on-fail rescue record (runner.ts rescueStep): the agent of the
|
|
1239
|
+
* failed step it took over. A rescue is not a step of the flow: a resumed
|
|
1240
|
+
* drive must not launch it as one of its group, and a re-run must not
|
|
1241
|
+
* copy it as work (21 Oct 2026: one rerun carried 77 fixers as pending
|
|
1242
|
+
* steps). Records written before this field are recognised by their
|
|
1243
|
+
* instruction's `<rescue>` block instead — see isRescueRecord. */
|
|
1244
|
+
rescueOf?: string;
|
|
1231
1245
|
/**
|
|
1232
1246
|
* Sandbox seconds this step rented, and how many of them were cold start.
|
|
1233
1247
|
* Set only on the isolated path — an in-process step rents nothing, so
|