@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/src/scan.ts
CHANGED
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
// reported, because "why is this allowed?" is the question a reviewer asks.
|
|
28
28
|
|
|
29
29
|
import crypto from "node:crypto";
|
|
30
|
-
import matter from "./matter.ts";
|
|
30
|
+
import matter, { frontmatterErrorLine, frontmatterErrorText, parseLenient, configMatter } from "./matter.ts";
|
|
31
31
|
import { refNames } from "./refs.ts";
|
|
32
32
|
import { resolveNetwork } from "./network.ts";
|
|
33
33
|
import { frontmatterProblems, schemaKindOf } from "./frontmatter-schema.ts";
|
|
@@ -223,6 +223,35 @@ export const SCAN_RULES: readonly ScanRule[] = [
|
|
|
223
223
|
"`effort: extreme`, `size: giant`, `trigger: sometimes`, a cron the scheduler cannot read: each is ignored or falls back, " +
|
|
224
224
|
"so the agent or flow quietly does something else. The message lists what the field takes.",
|
|
225
225
|
},
|
|
226
|
+
{
|
|
227
|
+
id: "config/conflict",
|
|
228
|
+
category: "config",
|
|
229
|
+
level: "error",
|
|
230
|
+
title: "A field set twice: at the top and inside its job block",
|
|
231
|
+
help:
|
|
232
|
+
"`limits:` at the top and `limits:` inside `guardrail:` cannot both be meant. The top one would be read and the other " +
|
|
233
|
+
"quietly ignored, so the file is refused until one is removed.",
|
|
234
|
+
},
|
|
235
|
+
{
|
|
236
|
+
id: "config/misplaced",
|
|
237
|
+
category: "config",
|
|
238
|
+
level: "error",
|
|
239
|
+
title: "A field inside the wrong job block",
|
|
240
|
+
help:
|
|
241
|
+
"Each of the four blocks (governance, guardrail, observability, evaluation) holds its own fields. A setting written in " +
|
|
242
|
+
"another block is not read — a guardrail under governance: guards nothing — so the file is refused until it is moved " +
|
|
243
|
+
"to the block the message names, or to the top. A word foldrun does not read at all is only a note.",
|
|
244
|
+
},
|
|
245
|
+
{
|
|
246
|
+
id: "config/invalid-frontmatter",
|
|
247
|
+
category: "config",
|
|
248
|
+
level: "warn",
|
|
249
|
+
title: "Frontmatter that YAML cannot read",
|
|
250
|
+
help:
|
|
251
|
+
"A value with a colon followed by a space (`description: the format (Markdown, ::: directives)`) ends the YAML. " +
|
|
252
|
+
"foldrun reads a skill like that by quoting the value, but the dashboard editor and the source API refuse to save it, " +
|
|
253
|
+
"and other tools that read the file may not. Quote the value: `description: \"…\"`.",
|
|
254
|
+
},
|
|
226
255
|
{
|
|
227
256
|
id: "config/missing-field",
|
|
228
257
|
category: "config",
|
|
@@ -601,17 +630,31 @@ export function nearestField(key: string, known: readonly string[]): string | nu
|
|
|
601
630
|
}
|
|
602
631
|
|
|
603
632
|
function keyLine(lines: string[], key: string): number | undefined {
|
|
604
|
-
const
|
|
605
|
-
const
|
|
606
|
-
|
|
633
|
+
const k = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
634
|
+
const top = new RegExp(`^${k}\\s*:`);
|
|
635
|
+
const i = lines.findIndex((l) => top.test(l));
|
|
636
|
+
if (i >= 0) return i + 1;
|
|
637
|
+
// A key inside one of the four job blocks: indented, under its header.
|
|
638
|
+
const inner = new RegExp(`^\\s+${k}\\s*:`);
|
|
639
|
+
let inBlock = false;
|
|
640
|
+
for (let n = 0; n < lines.length; n++) {
|
|
641
|
+
const l = lines[n]!;
|
|
642
|
+
if (/^(governance|guardrail|observability|evaluation)\s*:\s*(#.*)?$/.test(l)) inBlock = true;
|
|
643
|
+
else if (/^\S/.test(l)) inBlock = false;
|
|
644
|
+
else if (inBlock && inner.test(l)) return n + 1;
|
|
645
|
+
}
|
|
646
|
+
return undefined;
|
|
607
647
|
}
|
|
608
648
|
|
|
609
649
|
/** Read-only: gray-matter caches by content, so two files with the same
|
|
610
650
|
* text share one object, and writing to it would leak between them. */
|
|
611
|
-
function frontOf(content: string): Record<string, unknown> | null {
|
|
651
|
+
function frontOf(content: string, filePath?: string): Record<string, unknown> | null {
|
|
612
652
|
if (!/^?---\r?\n/.test(content)) return null;
|
|
613
653
|
try {
|
|
614
|
-
|
|
654
|
+
// An agent, a flow or AGENTS.md reads its job blocks as flat keys, as
|
|
655
|
+
// the runtime does; every other document is read as written.
|
|
656
|
+
const kind = filePath ? schemaKindOf(filePath) : null;
|
|
657
|
+
return (kind === "agent" || kind === "flow" || kind === "agents-md" ? configMatter(content) : matter(content)).data as Record<string, unknown>;
|
|
615
658
|
} catch {
|
|
616
659
|
return null; // a frontmatter YAML cannot read is check's finding, not ours
|
|
617
660
|
}
|
|
@@ -773,6 +816,16 @@ const VENDORED = /(?:^|\/)(?:node_modules|\.venv|venv|site-packages|__pycache__|
|
|
|
773
816
|
* since a key in a log here is a key in the workspace (seen live on
|
|
774
817
|
* 17 Oct 2026: zero-width characters in a CLI MCP log, reported as hidden
|
|
775
818
|
* text). */
|
|
819
|
+
/** The definition files a deploy refuses outright when their frontmatter does
|
|
820
|
+
* not parse (deploy.ts deployIssues): the workspace's own, by these paths.
|
|
821
|
+
* Everything else the platform reads frontmatter from — skills, and the
|
|
822
|
+
* account library's agents, tools and hooks — is the scanner's to report. */
|
|
823
|
+
export const STRICT_FRONTMATTER_PATH = /^(AGENTS\.md|agents\/[^/]+\/agent\.md|flows\/[^/]+\.md|tools\/[^/]+\.md|tools\/[^/]+\/tool\.md|evals\/[^/]+\.md|hooks\/[^/]+\/hook\.md|\.claude\/agents\/[^/]+\.md)$/;
|
|
824
|
+
|
|
825
|
+
/** A skill file in either layout the runtime discovers: <name>/SKILL.md, or
|
|
826
|
+
* the flat <name>.md, under skills/ or .agents/skills/, in the library too. */
|
|
827
|
+
const SKILL_FILE = /(?:^|\/)skills\/(?:[^/]+\/SKILL\.md|[^/]+\.md)$/;
|
|
828
|
+
|
|
776
829
|
const RUNTIME_WRITTEN = /(?:^|\/)(?:\.cache|\.npm|\.local|\.pnpm-store|\.yarn|\.foldrun)\//;
|
|
777
830
|
|
|
778
831
|
/** Text files only — a PNG read as UTF-8 is noise to every rule here. */
|
|
@@ -833,9 +886,34 @@ export function scanFiles(files: ScanFile[], options: ScanOptions = {}): ScanFin
|
|
|
833
886
|
scanSupplyChain(f, lines, push);
|
|
834
887
|
|
|
835
888
|
const schema = schemaKindOf(f.path);
|
|
889
|
+
// Frontmatter YAML cannot read. Until 2026-10-21 nothing reported it for
|
|
890
|
+
// a skill or a library file: frontOf below reads it as "no frontmatter"
|
|
891
|
+
// and moves on, and the deploy's own refusal covers only the workspace's
|
|
892
|
+
// definition files. A vaza.ai skill with `description: … (Markdown,
|
|
893
|
+
// frontmatter, ::: directives) …` deployed clean, ran (the runtime quotes
|
|
894
|
+
// the value and retries), and was refused by the source API on its next
|
|
895
|
+
// save.
|
|
896
|
+
if ((schema || SKILL_FILE.test(f.path)) && !STRICT_FRONTMATTER_PATH.test(f.path) && /^\uFEFF?---\r?\n/.test(f.content)) {
|
|
897
|
+
let err: unknown = null;
|
|
898
|
+
try {
|
|
899
|
+
matter(f.content);
|
|
900
|
+
} catch (e) {
|
|
901
|
+
err = e;
|
|
902
|
+
}
|
|
903
|
+
if (err) {
|
|
904
|
+
const isSkill = schema === "skill" || SKILL_FILE.test(f.path);
|
|
905
|
+
const read = parseLenient(f.content);
|
|
906
|
+
const what = isSkill && !read.failed
|
|
907
|
+
? "foldrun reads this skill anyway by quoting the value, but the dashboard editor and the source API refuse to save it, and other skills clients may not load it"
|
|
908
|
+
: isSkill
|
|
909
|
+
? "the runtime reads it as empty, so the skill has no name or description and is never loaded"
|
|
910
|
+
: "the runtime cannot read this file";
|
|
911
|
+
push("config/invalid-frontmatter", f.path, frontmatterErrorLine(err), `config/invalid-frontmatter ${frontmatterErrorText(err)}`, `${frontmatterErrorText(err)} ${what === "the runtime cannot read this file" ? "The runtime cannot read this file" : what.replace(/^foldrun/, "foldrun").replace(/^the/, "The")}.`);
|
|
912
|
+
}
|
|
913
|
+
}
|
|
836
914
|
// A skill with no frontmatter at all is the clearest one that never
|
|
837
915
|
// loads: read it as empty, so its required fields are reported.
|
|
838
|
-
const data = schema || docKind(f.path) ? (frontOf(f.content) ?? (schema === "skill" && !/^\uFEFF?---\r?\n/.test(f.content) ? {} : null)) : null;
|
|
916
|
+
const data = schema || docKind(f.path) ? (frontOf(f.content, f.path) ?? (schema === "skill" && !/^\uFEFF?---\r?\n/.test(f.content) ? {} : null)) : null;
|
|
839
917
|
if (!data) continue;
|
|
840
918
|
if (schema) {
|
|
841
919
|
for (const p of frontmatterProblems(schema, data, nearestField)) {
|
|
@@ -859,7 +937,7 @@ export function scanFiles(files: ScanFile[], options: ScanOptions = {}): ScanFin
|
|
|
859
937
|
}
|
|
860
938
|
// project.md is the older name the runtime still reads (Codex review, FOL-58).
|
|
861
939
|
const workspaceMd = files.find((f) => f.path === "AGENTS.md") ?? files.find((f) => f.path === "project.md");
|
|
862
|
-
scanAgents(agents, outward, toolDefs, push, { account: options.accountNetwork, workspace: workspaceMd ? frontOf(workspaceMd.content)?.network : undefined });
|
|
940
|
+
scanAgents(agents, outward, toolDefs, push, { account: options.accountNetwork, workspace: workspaceMd ? frontOf(workspaceMd.content, "AGENTS.md")?.network : undefined });
|
|
863
941
|
for (const m of more.values()) {
|
|
864
942
|
findings.push({ rule: m.rule, level: m.level ?? scanRule(m.rule)?.level ?? "warn", file: m.file, message: `and ${m.n} more like this in this file, not listed`, fingerprint: m.hash.digest("hex").slice(0, 16), truncated: true });
|
|
865
943
|
}
|
package/src/secret-files.ts
CHANGED
|
@@ -21,7 +21,7 @@ import path from "node:path";
|
|
|
21
21
|
import {
|
|
22
22
|
isFileValue, fileContent,
|
|
23
23
|
isSshValue, sshConfigOf,
|
|
24
|
-
isApiValue, apiConfigOf,
|
|
24
|
+
isApiValue, apiConfigOf, isClientCertValue, clientCertOf,
|
|
25
25
|
} from "./secrets.ts";
|
|
26
26
|
|
|
27
27
|
// POSIX single-quoting: the one escape sh actually honours inside ' is none,
|
|
@@ -46,7 +46,7 @@ export function materializeFileSecrets(
|
|
|
46
46
|
env: Record<string, string>,
|
|
47
47
|
): { env: Record<string, string>; dir: string | null } {
|
|
48
48
|
const entries = Object.entries(env).filter(
|
|
49
|
-
([, v]) => typeof v === "string" && (isFileValue(v) || isSshValue(v) || isApiValue(v)),
|
|
49
|
+
([, v]) => typeof v === "string" && (isFileValue(v) || isSshValue(v) || isApiValue(v) || isClientCertValue(v)),
|
|
50
50
|
);
|
|
51
51
|
if (entries.length === 0) return { env, dir: null };
|
|
52
52
|
|
|
@@ -62,6 +62,26 @@ export function materializeFileSecrets(
|
|
|
62
62
|
const file = path.join(dir, name.toLowerCase());
|
|
63
63
|
fs.writeFileSync(file, fileContent(value), { mode: 0o600 });
|
|
64
64
|
out[name] = file;
|
|
65
|
+
} else if (isClientCertValue(value)) {
|
|
66
|
+
// Only an exported certificate reaches here as itself (the runner
|
|
67
|
+
// withholds the rest): its parts as files, $NAME the certificate.
|
|
68
|
+
const c = clientCertOf(value);
|
|
69
|
+
if (!c.export) {
|
|
70
|
+
delete out[name];
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
const cert = path.join(dir, `${name.toLowerCase()}.crt`);
|
|
74
|
+
const key = path.join(dir, `${name.toLowerCase()}.key`);
|
|
75
|
+
fs.writeFileSync(cert, c.cert, { mode: 0o600 });
|
|
76
|
+
fs.writeFileSync(key, c.key, { mode: 0o600 });
|
|
77
|
+
out[name] = cert;
|
|
78
|
+
out[`${name}_CERT`] = cert;
|
|
79
|
+
out[`${name}_KEY`] = key;
|
|
80
|
+
if (c.passphrase) {
|
|
81
|
+
const pass = path.join(dir, `${name.toLowerCase()}.pass`);
|
|
82
|
+
fs.writeFileSync(pass, c.passphrase, { mode: 0o600 });
|
|
83
|
+
out[`${name}_PASSPHRASE_FILE`] = pass;
|
|
84
|
+
}
|
|
65
85
|
} else if (isSshValue(value)) {
|
|
66
86
|
const ssh = sshConfigOf(value);
|
|
67
87
|
const dest = `${ssh.user}@${ssh.host}`;
|
package/src/secrets.ts
CHANGED
|
@@ -88,7 +88,7 @@ function secretsFile(tenant: string, workspace?: string) {
|
|
|
88
88
|
/** How the stored value is used at run time — "oauth2"/"service-account"
|
|
89
89
|
* are refreshed into live tokens, "file"/"ssh"/"api" are materialised into
|
|
90
90
|
* the run's scratch. Plain values have no kind. */
|
|
91
|
-
export type SecretKind = "oauth2" | "service-account" | "file" | "ssh" | "api";
|
|
91
|
+
export type SecretKind = "oauth2" | "service-account" | "file" | "ssh" | "api" | "client-cert" | "identity";
|
|
92
92
|
|
|
93
93
|
interface StoredSecret {
|
|
94
94
|
iv: string;
|
|
@@ -236,6 +236,11 @@ export function setSecretSites(tenant: string, name: string, sites: string[], wo
|
|
|
236
236
|
if (clean.length && (rec.kind === "file" || rec.kind === "ssh" || rec.kind === "api")) {
|
|
237
237
|
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`);
|
|
238
238
|
}
|
|
239
|
+
// A client certificate is presented by the egress proxy to the exact
|
|
240
|
+
// hosts named — never to a whole domain on a wildcard.
|
|
241
|
+
if (rec.kind === "client-cert" && clean.some((s) => s.startsWith("*."))) {
|
|
242
|
+
throw new Error(`${name} is a client certificate — bind it to exact hosts, not a wildcard`);
|
|
243
|
+
}
|
|
239
244
|
if (clean.length) rec.sites = clean;
|
|
240
245
|
else delete rec.sites;
|
|
241
246
|
return all;
|
|
@@ -545,7 +550,9 @@ export function rotateMasterKey(
|
|
|
545
550
|
export interface OAuth2Config {
|
|
546
551
|
token_url: string;
|
|
547
552
|
client_id: string;
|
|
548
|
-
|
|
553
|
+
/** Absent for a public client (an app with no secret, connected with
|
|
554
|
+
* PKCE): its refresh sends client_id and refresh_token only. */
|
|
555
|
+
client_secret?: string;
|
|
549
556
|
/** Required for the refresh grant; absent for client_credentials. */
|
|
550
557
|
refresh_token?: string;
|
|
551
558
|
/** "refresh_token" (default — user-consented) or "client_credentials"
|
|
@@ -573,9 +580,14 @@ export function setOAuth2Secret(
|
|
|
573
580
|
config: OAuth2Config,
|
|
574
581
|
workspace?: string,
|
|
575
582
|
) {
|
|
576
|
-
for (const field of ["token_url", "client_id"
|
|
583
|
+
for (const field of ["token_url", "client_id"] as const) {
|
|
577
584
|
if (!config[field]?.trim()) throw new Error(`oauth2 secret needs ${field}`);
|
|
578
585
|
}
|
|
586
|
+
// A public client (PKCE, no secret) refreshes with its client id alone;
|
|
587
|
+
// client credentials are nothing without the secret.
|
|
588
|
+
if (config.grant_type === "client_credentials" && !config.client_secret?.trim()) {
|
|
589
|
+
throw new Error("oauth2 secret needs client_secret for client_credentials");
|
|
590
|
+
}
|
|
579
591
|
if (config.grant_type !== "client_credentials" && !config.refresh_token?.trim()) {
|
|
580
592
|
throw new Error("oauth2 secret needs refresh_token (or grant_type: client_credentials)");
|
|
581
593
|
}
|
|
@@ -597,11 +609,67 @@ export function isOAuth2Value(value: string): boolean {
|
|
|
597
609
|
const tokenCache = new Map<string, { token: string; expiresAt: number }>();
|
|
598
610
|
const inFlight = new Map<string, Promise<string>>();
|
|
599
611
|
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
612
|
+
/**
|
|
613
|
+
* The cache key for a live token: SHA-256 over the kind, the account and the
|
|
614
|
+
* WHOLE stored config — credential material included (client secret, refresh
|
|
615
|
+
* token, private key, subject, scopes, token URL). A caller can only hit an
|
|
616
|
+
* entry it could have minted itself. Both maps are process-wide, so a key of
|
|
617
|
+
* names alone (`sa:${name}:${issuer}`, `${name}:${client_id}:…`) once let a
|
|
618
|
+
* second account with the same names be handed the first one's token
|
|
619
|
+
* (Codex debate, 22 Oct 2026).
|
|
620
|
+
*/
|
|
621
|
+
export function tokenCacheKey(kind: "oauth2" | "sa", storedValue: string, tenant?: string): string {
|
|
622
|
+
return crypto.createHash("sha256").update(JSON.stringify([kind, tenant ?? "", storedValue])).digest("hex");
|
|
623
|
+
}
|
|
603
624
|
|
|
604
|
-
|
|
625
|
+
/**
|
|
626
|
+
* A provider that rotates refresh tokens (RFC 9700 §2.2.2) answered a refresh
|
|
627
|
+
* with a new one: write it back where the old one is stored — the workspace
|
|
628
|
+
* copy, else the account's — but only while that copy still holds the value
|
|
629
|
+
* this refresh used (compare-and-set), so two refreshes racing cannot put an
|
|
630
|
+
* older token over a newer one. Returns whether a copy was replaced.
|
|
631
|
+
*/
|
|
632
|
+
export function replaceOAuth2RefreshToken(tenant: string, name: string, workspace: string | undefined, usedValue: string, refreshToken: string): boolean {
|
|
633
|
+
if (!isOAuth2Value(usedValue)) return false;
|
|
634
|
+
const config = JSON.parse(usedValue.slice(OAUTH2_PREFIX.length)) as OAuth2Config;
|
|
635
|
+
const next = OAUTH2_PREFIX + JSON.stringify({ ...config, refresh_token: refreshToken });
|
|
636
|
+
for (const scope of workspace ? [workspace, undefined] : [undefined]) {
|
|
637
|
+
let replaced = false;
|
|
638
|
+
mutateVaultFile(secretsFile(tenant, scope), (all) => {
|
|
639
|
+
const rec = all[name];
|
|
640
|
+
if (rec && decrypt(tenant, rec) === usedValue) {
|
|
641
|
+
all[name] = { ...rec, ...encryptValue(tenant, next), updatedAt: new Date().toISOString() };
|
|
642
|
+
replaced = true;
|
|
643
|
+
}
|
|
644
|
+
return all;
|
|
645
|
+
});
|
|
646
|
+
if (replaced) return true;
|
|
647
|
+
}
|
|
648
|
+
return false;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/** An oauth2 refresh's outcome: the access token and, when the provider
|
|
652
|
+
* rotated it, the new refresh token — kept with the cache entry so every
|
|
653
|
+
* copy of the old credential (another secret name, another scope) that is
|
|
654
|
+
* served this token also gets the new refresh token written back. */
|
|
655
|
+
interface OAuthLive { token: string; expiresAt: number; rotated?: string }
|
|
656
|
+
const oauthCache = new Map<string, OAuthLive>();
|
|
657
|
+
const oauthFlight = new Map<string, Promise<OAuthLive>>();
|
|
658
|
+
|
|
659
|
+
/** Cache an access token, sweeping expired entries as it goes: a rotating
|
|
660
|
+
* provider changes the stored config — and so the key — on every refresh,
|
|
661
|
+
* and expired entries would otherwise pile up for the life of the process. */
|
|
662
|
+
function cacheOAuth(key: string, live: OAuthLive): void {
|
|
663
|
+
const now = Date.now();
|
|
664
|
+
for (const [k, v] of oauthCache) if (v.expiresAt <= now) oauthCache.delete(k);
|
|
665
|
+
oauthCache.set(key, live);
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
async function exchange(config: OAuth2Config, cacheKey: string): Promise<OAuthLive> {
|
|
669
|
+
const cached = oauthCache.get(cacheKey);
|
|
670
|
+
if (cached && cached.expiresAt > Date.now()) return cached;
|
|
671
|
+
|
|
672
|
+
const pending = oauthFlight.get(cacheKey);
|
|
605
673
|
if (pending) return pending;
|
|
606
674
|
|
|
607
675
|
const flight = (async () => {
|
|
@@ -609,7 +677,7 @@ async function exchange(config: OAuth2Config, cacheKey: string): Promise<string>
|
|
|
609
677
|
grant_type: config.grant_type ?? "refresh_token",
|
|
610
678
|
...(config.grant_type === "client_credentials" ? {} : { refresh_token: config.refresh_token! }),
|
|
611
679
|
client_id: config.client_id,
|
|
612
|
-
client_secret: config.client_secret,
|
|
680
|
+
...(config.client_secret ? { client_secret: config.client_secret } : {}),
|
|
613
681
|
...(config.extra ?? {}),
|
|
614
682
|
});
|
|
615
683
|
// A network blip is retried; a provider's answer is not. "fetch failed"
|
|
@@ -639,6 +707,7 @@ async function exchange(config: OAuth2Config, cacheKey: string): Promise<string>
|
|
|
639
707
|
if (!res) throw lastNetwork instanceof Error ? lastNetwork : new Error(String(lastNetwork));
|
|
640
708
|
const payload = (await res.json().catch(() => ({}))) as {
|
|
641
709
|
access_token?: string;
|
|
710
|
+
refresh_token?: string;
|
|
642
711
|
expires_in?: number;
|
|
643
712
|
error?: string;
|
|
644
713
|
error_description?: string;
|
|
@@ -649,15 +718,21 @@ async function exchange(config: OAuth2Config, cacheKey: string): Promise<string>
|
|
|
649
718
|
);
|
|
650
719
|
}
|
|
651
720
|
const ttl = Math.max(60, (payload.expires_in ?? 3600) - 60) * 1000;
|
|
652
|
-
|
|
653
|
-
|
|
721
|
+
// A rotated refresh token replaces the old one, which the provider may
|
|
722
|
+
// now refuse; dropping it broke the next refresh for rotating providers.
|
|
723
|
+
const rotated = payload.refresh_token && config.grant_type !== "client_credentials" && payload.refresh_token !== config.refresh_token
|
|
724
|
+
? payload.refresh_token
|
|
725
|
+
: undefined;
|
|
726
|
+
const live: OAuthLive = { token: payload.access_token, expiresAt: Date.now() + ttl, ...(rotated ? { rotated } : {}) };
|
|
727
|
+
cacheOAuth(cacheKey, live);
|
|
728
|
+
return live;
|
|
654
729
|
})();
|
|
655
730
|
|
|
656
|
-
|
|
731
|
+
oauthFlight.set(cacheKey, flight);
|
|
657
732
|
try {
|
|
658
733
|
return await flight;
|
|
659
734
|
} finally {
|
|
660
|
-
|
|
735
|
+
oauthFlight.delete(cacheKey);
|
|
661
736
|
}
|
|
662
737
|
}
|
|
663
738
|
|
|
@@ -685,13 +760,21 @@ export async function materializeSecrets(
|
|
|
685
760
|
throw new Error(`secret ${name}: stored oauth2 config is unreadable`);
|
|
686
761
|
}
|
|
687
762
|
try {
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
763
|
+
const live = await exchange(config, tokenCacheKey("oauth2", value, ctx?.tenant));
|
|
764
|
+
out[name] = live.token;
|
|
765
|
+
if (live.rotated) {
|
|
766
|
+
// This caller's copy takes the new refresh token (compare-and-set:
|
|
767
|
+
// a copy already updated, or changed since, is left alone), and the
|
|
768
|
+
// access token is cached under the config as it is now stored, so
|
|
769
|
+
// the next run does not refresh again.
|
|
770
|
+
const next = OAUTH2_PREFIX + JSON.stringify({ ...config, refresh_token: live.rotated });
|
|
771
|
+
try {
|
|
772
|
+
if (ctx) replaceOAuth2RefreshToken(ctx.tenant, name, ctx.workspace, value, live.rotated);
|
|
773
|
+
} catch {
|
|
774
|
+
// The access token is good either way; the next refresh says if not.
|
|
775
|
+
}
|
|
776
|
+
cacheOAuth(tokenCacheKey("oauth2", next, ctx?.tenant), { token: live.token, expiresAt: live.expiresAt });
|
|
777
|
+
}
|
|
695
778
|
} catch (err) {
|
|
696
779
|
const message = err instanceof Error ? err.message : String(err);
|
|
697
780
|
const link = ctx ? await platform.noteOAuthRefresh(ctx, name, message).catch(() => undefined) : undefined;
|
|
@@ -706,7 +789,7 @@ export async function materializeSecrets(
|
|
|
706
789
|
throw new Error(`secret ${name}: stored service account config is unreadable`);
|
|
707
790
|
}
|
|
708
791
|
try {
|
|
709
|
-
out[name] = await exchangeServiceAccount(config,
|
|
792
|
+
out[name] = await exchangeServiceAccount(config, tokenCacheKey("sa", value, ctx?.tenant));
|
|
710
793
|
} catch (err) {
|
|
711
794
|
throw new Error(`secret ${name}: ${err instanceof Error ? err.message : String(err)}`);
|
|
712
795
|
}
|
|
@@ -737,6 +820,10 @@ export interface ServiceAccountConfig {
|
|
|
737
820
|
scope: string; // space-separated
|
|
738
821
|
/** JWT audience — usually the same as token_url. */
|
|
739
822
|
audience?: string;
|
|
823
|
+
/** The JWT bearer grant's subject (RFC 7523 §3): the user the account acts
|
|
824
|
+
* as, where the provider allows it — Google's domain-wide delegation, once
|
|
825
|
+
* a Workspace admin grants it. Absent, the account acts as itself. */
|
|
826
|
+
subject?: string;
|
|
740
827
|
}
|
|
741
828
|
|
|
742
829
|
export function setServiceAccountSecret(
|
|
@@ -751,7 +838,12 @@ export function setServiceAccountSecret(
|
|
|
751
838
|
if (!/BEGIN [A-Z ]{0,40}PRIVATE KEY/.test(config.private_key)) {
|
|
752
839
|
throw new Error("private_key must be a PEM key (from the service account JSON)");
|
|
753
840
|
}
|
|
754
|
-
|
|
841
|
+
if (config.subject !== undefined) {
|
|
842
|
+
if (typeof config.subject !== "string" || !config.subject.trim()) throw new Error("subject is the user to act as — leave it out to act as the account itself");
|
|
843
|
+
if (config.subject.length > 320 || /[\u0000-\u001f\u007f\s]/.test(config.subject.trim())) throw new Error("subject is one identifier, no spaces or control characters");
|
|
844
|
+
}
|
|
845
|
+
const stored: ServiceAccountConfig = { ...config, ...(config.subject !== undefined ? { subject: config.subject.trim() } : {}) };
|
|
846
|
+
setSecret(tenant, name, SERVICE_ACCOUNT_PREFIX + JSON.stringify(stored), workspace, "service-account");
|
|
755
847
|
}
|
|
756
848
|
|
|
757
849
|
function base64url(input: Buffer | string): string {
|
|
@@ -770,7 +862,9 @@ async function exchangeServiceAccount(config: ServiceAccountConfig, cacheKey: st
|
|
|
770
862
|
const claim = base64url(
|
|
771
863
|
JSON.stringify({
|
|
772
864
|
iss: config.issuer,
|
|
773
|
-
|
|
865
|
+
// The subject is part of the stored config, so of the cache key: two
|
|
866
|
+
// subjects never share a token (tokenCacheKey).
|
|
867
|
+
sub: config.subject || config.issuer,
|
|
774
868
|
scope: config.scope,
|
|
775
869
|
aud: config.audience ?? config.token_url,
|
|
776
870
|
iat: now,
|
|
@@ -898,14 +992,134 @@ export function sshConfigOf(value: string): SshConfig {
|
|
|
898
992
|
// never appears in the bash command at all.
|
|
899
993
|
|
|
900
994
|
const API_PREFIX = "@api ";
|
|
995
|
+
const CLIENT_CERT_PREFIX = "@client-cert ";
|
|
996
|
+
|
|
997
|
+
/** A client certificate for mutual TLS: presented by the egress proxy, on its
|
|
998
|
+
* own connection, to the exact hosts the secret is bound to — the sandbox
|
|
999
|
+
* never holds the key. `export` puts it in the sandbox as files instead,
|
|
1000
|
+
* only when asked for (a step with no egress proxy). */
|
|
1001
|
+
export interface ClientCertConfig {
|
|
1002
|
+
/** PEM certificate, chain included. */
|
|
1003
|
+
cert: string;
|
|
1004
|
+
/** PEM private key. */
|
|
1005
|
+
key: string;
|
|
1006
|
+
passphrase?: string;
|
|
1007
|
+
export?: boolean;
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
export function setClientCertSecret(tenant: string, name: string, config: ClientCertConfig, workspace?: string) {
|
|
1011
|
+
if (!/-----BEGIN CERTIFICATE-----/.test(config.cert ?? "")) throw new Error("cert must be a PEM certificate");
|
|
1012
|
+
if (!/-----BEGIN [A-Z ]{0,40}PRIVATE KEY-----/.test(config.key ?? "")) throw new Error("key must be a PEM private key");
|
|
1013
|
+
// The pair must match: a certificate presented with another key's proof
|
|
1014
|
+
// fails at the server, after the request has gone.
|
|
1015
|
+
let matches = false;
|
|
1016
|
+
try {
|
|
1017
|
+
const key = crypto.createPrivateKey({ key: config.key, ...(config.passphrase ? { passphrase: config.passphrase } : {}) });
|
|
1018
|
+
matches = new crypto.X509Certificate(config.cert).checkPrivateKey(key);
|
|
1019
|
+
} catch (err) {
|
|
1020
|
+
throw new Error(`the certificate or key cannot be read: ${err instanceof Error ? err.message : String(err)}`);
|
|
1021
|
+
}
|
|
1022
|
+
if (!matches) throw new Error("the key does not belong to the certificate");
|
|
1023
|
+
|
|
1024
|
+
const stored: ClientCertConfig = {
|
|
1025
|
+
cert: config.cert,
|
|
1026
|
+
key: config.key,
|
|
1027
|
+
...(config.passphrase ? { passphrase: config.passphrase } : {}),
|
|
1028
|
+
...(config.export ? { export: true } : {}),
|
|
1029
|
+
};
|
|
1030
|
+
if (!SECRET_NAME.test(name)) throw new Error(`secret name "${name}" must be UPPER_SNAKE_CASE`);
|
|
1031
|
+
const sealed = encryptValue(tenant, CLIENT_CERT_PREFIX + JSON.stringify(stored));
|
|
1032
|
+
// Written under the vault's own lock, with the binding checked in the same
|
|
1033
|
+
// step: a value set over a secret keeps its site binding, and a wildcard
|
|
1034
|
+
// one would present the certificate to a whole domain.
|
|
1035
|
+
mutateVaultFile(secretsFile(tenant, workspace), (all) => {
|
|
1036
|
+
const sites = all[name]?.sites;
|
|
1037
|
+
if (sites?.some((site) => site.startsWith("*."))) {
|
|
1038
|
+
throw new Error(`${name} is bound to ${sites.join(", ")} — a client certificate is bound to exact hosts; unbind the wildcard first`);
|
|
1039
|
+
}
|
|
1040
|
+
all[name] = { ...sealed, updatedAt: new Date().toISOString(), kind: "client-cert", ...(sites?.length ? { sites } : {}) };
|
|
1041
|
+
return all;
|
|
1042
|
+
});
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
const IDENTITY_PREFIX = "@identity ";
|
|
1046
|
+
|
|
1047
|
+
/** A keyless cloud identity: no credential at all, only the audience the
|
|
1048
|
+
* platform's signed tokens are for. A step that declares it gets a token
|
|
1049
|
+
* file the sandbox driver keeps fresh through the egress proxy. */
|
|
1050
|
+
export interface IdentityConfig {
|
|
1051
|
+
audience: string;
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
export function setIdentitySecret(tenant: string, name: string, config: IdentityConfig, workspace?: string) {
|
|
1055
|
+
const audience = typeof config.audience === "string" ? config.audience.trim() : "";
|
|
1056
|
+
if (!audience) throw new Error("an identity needs an audience — the value your cloud's trust expects in aud");
|
|
1057
|
+
if (audience.length > 600 || /[\u0000-\u001f\u007f]/.test(audience)) throw new Error("audience is one value, at most 600 characters, no control characters");
|
|
1058
|
+
setSecret(tenant, name, IDENTITY_PREFIX + JSON.stringify({ audience }), workspace, "identity");
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
export function isIdentityValue(value: string): boolean {
|
|
1062
|
+
return value.startsWith(IDENTITY_PREFIX);
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
export function identityOf(value: string): IdentityConfig {
|
|
1066
|
+
return JSON.parse(value.slice(IDENTITY_PREFIX.length));
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
export function isClientCertValue(value: string): boolean {
|
|
1070
|
+
return value.startsWith(CLIENT_CERT_PREFIX);
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
/** An environment without the client certificates that were not saved with
|
|
1074
|
+
* `export`: those are the egress proxy's to present, never a program's. */
|
|
1075
|
+
export function withoutHeldCerts(env: Record<string, string>): Record<string, string> {
|
|
1076
|
+
return Object.fromEntries(Object.entries(env).filter(([, v]) => typeof v !== "string" || !isClientCertValue(v) || clientCertOf(v).export === true));
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
export function clientCertOf(value: string): ClientCertConfig {
|
|
1080
|
+
return JSON.parse(value.slice(CLIENT_CERT_PREFIX.length));
|
|
1081
|
+
}
|
|
901
1082
|
|
|
902
1083
|
export interface ApiConfig {
|
|
903
1084
|
base_url?: string;
|
|
904
1085
|
headers: Record<string, string>;
|
|
1086
|
+
/** Username and password for HTTP Basic (RFC 7617): stored as the
|
|
1087
|
+
* `Authorization: Basic …` header they make, never kept apart. */
|
|
1088
|
+
basic?: { username: string; password: string };
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
/** `Authorization: Basic …` from a username and password (RFC 7617 §2):
|
|
1092
|
+
* UTF-8, no colon in the username, no control characters in either. */
|
|
1093
|
+
export function basicAuthorization(username: string, password: string): string {
|
|
1094
|
+
if (typeof username !== "string" || typeof password !== "string") throw new Error("basic needs username and password");
|
|
1095
|
+
if (!username) throw new Error("basic needs a username");
|
|
1096
|
+
if (username.includes(":")) throw new Error("a Basic username cannot contain a colon (RFC 7617)");
|
|
1097
|
+
if (/[\u0000-\u001f\u007f]/.test(username) || /[\u0000-\u001f\u007f]/.test(password)) throw new Error("Basic credentials cannot contain control characters");
|
|
1098
|
+
return `Basic ${Buffer.from(`${username}:${password}`, "utf8").toString("base64")}`;
|
|
905
1099
|
}
|
|
906
1100
|
|
|
907
1101
|
export function setApiSecret(tenant: string, name: string, config: ApiConfig, workspace?: string) {
|
|
908
|
-
|
|
1102
|
+
let all = { ...(config.headers ?? {}) };
|
|
1103
|
+
if (config.basic) {
|
|
1104
|
+
if (Object.keys(all).some((h) => h.toLowerCase() === "authorization")) {
|
|
1105
|
+
throw new Error("give either basic or an Authorization header, not both");
|
|
1106
|
+
}
|
|
1107
|
+
// Credentials in the clear over http are leaked ones: https, or loopback.
|
|
1108
|
+
const base = config.base_url?.trim();
|
|
1109
|
+
if (base) {
|
|
1110
|
+
let u: URL | null = null;
|
|
1111
|
+
try {
|
|
1112
|
+
u = new URL(base);
|
|
1113
|
+
} catch {
|
|
1114
|
+
u = null;
|
|
1115
|
+
}
|
|
1116
|
+
const loopback = u?.protocol === "http:" && (u.hostname === "localhost" || u.hostname === "127.0.0.1");
|
|
1117
|
+
if (!u || (u.protocol !== "https:" && !loopback)) throw new Error("Basic credentials need an https base_url");
|
|
1118
|
+
}
|
|
1119
|
+
all = { ...all, Authorization: basicAuthorization(config.basic.username, config.basic.password) };
|
|
1120
|
+
}
|
|
1121
|
+
config = { ...(config.base_url ? { base_url: config.base_url } : {}), headers: all };
|
|
1122
|
+
const headers = Object.entries(all);
|
|
909
1123
|
if (headers.length === 0) throw new Error("api secret needs at least one header");
|
|
910
1124
|
for (const [h, v] of headers) {
|
|
911
1125
|
if (!/^[A-Za-z0-9-]+$/.test(h)) throw new Error(`"${h}" is not a valid header name`);
|
package/src/starter.ts
CHANGED
|
@@ -62,6 +62,22 @@ foldrun_version: "0.1"
|
|
|
62
62
|
#
|
|
63
63
|
# The token is sent as a bearer credential. A gateway that wants it as a key
|
|
64
64
|
# header asks for that by name: headers: { x-api-key: \${THE_SECRET} }.
|
|
65
|
+
#
|
|
66
|
+
# The four jobs of running agents, each a block — or write the same keys at
|
|
67
|
+
# the top; one place or the other, never both:
|
|
68
|
+
#
|
|
69
|
+
# governance: # who answers for it, what it may spend and ship
|
|
70
|
+
# owner: you@example.com
|
|
71
|
+
# budget: 60/month
|
|
72
|
+
# deploy_block: secrets
|
|
73
|
+
# guardrail: # what agents may reach and call
|
|
74
|
+
# limits: {calls: 300, repeat: 5}
|
|
75
|
+
# network: trusted
|
|
76
|
+
# observability: # what is recorded, and who is told
|
|
77
|
+
# notify: {email: you@example.com, events: [failed]}
|
|
78
|
+
# alerts: {failure_rate: 30%}
|
|
79
|
+
# evaluation: # how work is judged
|
|
80
|
+
# judge: {model: fast}
|
|
65
81
|
---
|
|
66
82
|
|
|
67
83
|
# ${account}
|
|
@@ -492,6 +508,13 @@ runtime: # only if scripts need packages
|
|
|
492
508
|
You research a topic and report what you found...
|
|
493
509
|
\`\`\`
|
|
494
510
|
|
|
511
|
+
The fields that do one of the four jobs of running agents may be grouped
|
|
512
|
+
under it — \`governance:\` (owner, budget), \`guardrail:\` (limits, network,
|
|
513
|
+
hooks, disallowedTools, permissionMode, secrets), and in AGENTS.md also
|
|
514
|
+
\`observability:\` (alerts, telemetry, notify, capture) and \`evaluation:\`
|
|
515
|
+
(judge). A block reads exactly as the same keys at the top; a key written
|
|
516
|
+
both ways is refused (\`config/conflict\`). \`foldrun docs four-jobs\`.
|
|
517
|
+
|
|
495
518
|
Only \`name\` and the prose are required. Every other field widens or narrows
|
|
496
519
|
what the agent can reach, and the default is narrow.
|
|
497
520
|
|