@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.
Files changed (70) hide show
  1. package/dist/src/agent-import.js +2 -2
  2. package/dist/src/agent-tools.js +2 -2
  3. package/dist/src/agents-md.d.ts +5 -1
  4. package/dist/src/agents-md.js +42 -18
  5. package/dist/src/capture.js +39 -1
  6. package/dist/src/completions.js +25 -3
  7. package/dist/src/deploy.js +72 -7
  8. package/dist/src/egress.d.ts +16 -0
  9. package/dist/src/evals.d.ts +22 -1
  10. package/dist/src/evals.js +43 -14
  11. package/dist/src/flow-lint.d.ts +14 -0
  12. package/dist/src/flow-lint.js +35 -0
  13. package/dist/src/flow-patterns.js +29 -14
  14. package/dist/src/frontmatter-fields.d.ts +27 -0
  15. package/dist/src/frontmatter-fields.js +51 -3
  16. package/dist/src/frontmatter-guard.d.ts +19 -0
  17. package/dist/src/frontmatter-guard.js +67 -0
  18. package/dist/src/frontmatter-schema.d.ts +2 -2
  19. package/dist/src/frontmatter-schema.js +30 -2
  20. package/dist/src/frontmatter-text.d.ts +31 -0
  21. package/dist/src/frontmatter-text.js +102 -0
  22. package/dist/src/judge-verdict.d.ts +19 -0
  23. package/dist/src/judge-verdict.js +54 -0
  24. package/dist/src/matter.d.ts +34 -0
  25. package/dist/src/matter.js +104 -0
  26. package/dist/src/network.d.ts +0 -1
  27. package/dist/src/network.js +10 -7
  28. package/dist/src/package.js +9 -2
  29. package/dist/src/provider-health.js +2 -2
  30. package/dist/src/run-container.d.ts +9 -0
  31. package/dist/src/run-container.js +34 -1
  32. package/dist/src/runner.js +193 -39
  33. package/dist/src/scan.d.ts +11 -0
  34. package/dist/src/scan.js +86 -8
  35. package/dist/src/secret-files.js +23 -2
  36. package/dist/src/secrets.d.ts +62 -2
  37. package/dist/src/secrets.js +206 -20
  38. package/dist/src/starter.js +23 -0
  39. package/dist/src/step-exec.d.ts +10 -2
  40. package/dist/src/step-exec.js +12 -9
  41. package/dist/src/store.d.ts +17 -3
  42. package/dist/src/store.js +14 -10
  43. package/package.json +1 -1
  44. package/src/agent-import.ts +2 -2
  45. package/src/agent-tools.ts +2 -2
  46. package/src/agents-md.ts +39 -14
  47. package/src/capture.ts +33 -1
  48. package/src/completions.ts +26 -3
  49. package/src/deploy.ts +59 -7
  50. package/src/egress.ts +15 -1
  51. package/src/evals.ts +45 -13
  52. package/src/flow-lint.ts +36 -0
  53. package/src/flow-patterns.ts +26 -13
  54. package/src/frontmatter-fields.ts +74 -3
  55. package/src/frontmatter-guard.ts +70 -0
  56. package/src/frontmatter-schema.ts +30 -4
  57. package/src/frontmatter-text.ts +109 -0
  58. package/src/judge-verdict.ts +52 -0
  59. package/src/matter.ts +112 -0
  60. package/src/network.ts +10 -6
  61. package/src/package.ts +8 -2
  62. package/src/provider-health.ts +2 -2
  63. package/src/run-container.ts +41 -1
  64. package/src/runner.ts +189 -40
  65. package/src/scan.ts +86 -8
  66. package/src/secret-files.ts +22 -2
  67. package/src/secrets.ts +237 -23
  68. package/src/starter.ts +23 -0
  69. package/src/step-exec.ts +21 -8
  70. 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 re = new RegExp(`^${key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*:`);
605
- const i = lines.findIndex((l) => re.test(l));
606
- return i >= 0 ? i + 1 : undefined;
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
- return matter(content).data as Record<string, unknown>;
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
  }
@@ -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
- client_secret: string;
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", "client_secret"] as const) {
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
- async function exchange(config: OAuth2Config, cacheKey: string): Promise<string> {
601
- const cached = tokenCache.get(cacheKey);
602
- if (cached && cached.expiresAt > Date.now()) return cached.token;
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
- const pending = inFlight.get(cacheKey);
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
- tokenCache.set(cacheKey, { token: payload.access_token, expiresAt: Date.now() + ttl });
653
- return payload.access_token;
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
- inFlight.set(cacheKey, flight);
731
+ oauthFlight.set(cacheKey, flight);
657
732
  try {
658
733
  return await flight;
659
734
  } finally {
660
- inFlight.delete(cacheKey);
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
- // Fingerprint the WHOLE refresh token, not its first 8 chars: Google
689
- // refresh tokens share a long common prefix, so an 8-char slice could
690
- // collide two distinct grants onto one cache entry and hand one the
691
- // other's access token. Hashed so the token is not held in a Map key.
692
- const grantMaterial = config.refresh_token ?? config.grant_type ?? "";
693
- const fp = crypto.createHash("sha256").update(grantMaterial).digest("hex").slice(0, 16);
694
- out[name] = await exchange(config, `${name}:${config.client_id}:${fp}`);
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, `sa:${name}:${config.issuer}`);
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
- setSecret(tenant, name, SERVICE_ACCOUNT_PREFIX + JSON.stringify(config), workspace, "service-account");
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
- sub: config.issuer,
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
- const headers = Object.entries(config.headers ?? {});
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