@foldrun/core 2.6.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/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`);