@foldrun/core 2.6.0 → 2.8.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.
@@ -17,7 +17,7 @@
17
17
  // deleted the keys the other was still using.
18
18
  import fs from "node:fs";
19
19
  import path from "node:path";
20
- import { isFileValue, fileContent, isSshValue, sshConfigOf, isApiValue, apiConfigOf, } from "./secrets.js";
20
+ import { isFileValue, fileContent, isSshValue, sshConfigOf, isApiValue, apiConfigOf, isClientCertValue, clientCertOf, } from "./secrets.js";
21
21
  // POSIX single-quoting: the one escape sh actually honours inside ' is none,
22
22
  // so close, backslash-quote, reopen. Wrapper scripts embed hosts, users and
23
23
  // header values through this and nothing else.
@@ -34,7 +34,7 @@ function writeWrapper(dir, name, body) {
34
34
  * Plain values pass through untouched.
35
35
  */
36
36
  export function materializeFileSecrets(agentDir, env) {
37
- const entries = Object.entries(env).filter(([, v]) => typeof v === "string" && (isFileValue(v) || isSshValue(v) || isApiValue(v)));
37
+ const entries = Object.entries(env).filter(([, v]) => typeof v === "string" && (isFileValue(v) || isSshValue(v) || isApiValue(v) || isClientCertValue(v)));
38
38
  if (entries.length === 0)
39
39
  return { env, dir: null };
40
40
  const parent = path.join(agentDir, ".secret-files");
@@ -50,6 +50,27 @@ export function materializeFileSecrets(agentDir, env) {
50
50
  fs.writeFileSync(file, fileContent(value), { mode: 0o600 });
51
51
  out[name] = file;
52
52
  }
53
+ else if (isClientCertValue(value)) {
54
+ // Only an exported certificate reaches here as itself (the runner
55
+ // withholds the rest): its parts as files, $NAME the certificate.
56
+ const c = clientCertOf(value);
57
+ if (!c.export) {
58
+ delete out[name];
59
+ continue;
60
+ }
61
+ const cert = path.join(dir, `${name.toLowerCase()}.crt`);
62
+ const key = path.join(dir, `${name.toLowerCase()}.key`);
63
+ fs.writeFileSync(cert, c.cert, { mode: 0o600 });
64
+ fs.writeFileSync(key, c.key, { mode: 0o600 });
65
+ out[name] = cert;
66
+ out[`${name}_CERT`] = cert;
67
+ out[`${name}_KEY`] = key;
68
+ if (c.passphrase) {
69
+ const pass = path.join(dir, `${name.toLowerCase()}.pass`);
70
+ fs.writeFileSync(pass, c.passphrase, { mode: 0o600 });
71
+ out[`${name}_PASSPHRASE_FILE`] = pass;
72
+ }
73
+ }
53
74
  else if (isSshValue(value)) {
54
75
  const ssh = sshConfigOf(value);
55
76
  const dest = `${ssh.user}@${ssh.host}`;
@@ -15,7 +15,7 @@ export declare function installKeyMaterial(): string;
15
15
  /** How the stored value is used at run time — "oauth2"/"service-account"
16
16
  * are refreshed into live tokens, "file"/"ssh"/"api" are materialised into
17
17
  * the run's scratch. Plain values have no kind. */
18
- export type SecretKind = "oauth2" | "service-account" | "file" | "ssh" | "api";
18
+ export type SecretKind = "oauth2" | "service-account" | "file" | "ssh" | "api" | "client-cert" | "identity";
19
19
  interface StoredSecret {
20
20
  iv: string;
21
21
  tag: string;
@@ -133,7 +133,9 @@ export declare function rotateMasterKey(tenant: string, from: string, to: string
133
133
  export interface OAuth2Config {
134
134
  token_url: string;
135
135
  client_id: string;
136
- client_secret: string;
136
+ /** Absent for a public client (an app with no secret, connected with
137
+ * PKCE): its refresh sends client_id and refresh_token only. */
138
+ client_secret?: string;
137
139
  /** Required for the refresh grant; absent for client_credentials. */
138
140
  refresh_token?: string;
139
141
  /** "refresh_token" (default — user-consented) or "client_credentials"
@@ -150,6 +152,24 @@ export declare const OAUTH_REFRESH_TRIES = 3;
150
152
  export declare function oauthRetryDelayMs(n: number): number;
151
153
  export declare function setOAuth2Secret(tenant: string, name: string, config: OAuth2Config, workspace?: string): void;
152
154
  export declare function isOAuth2Value(value: string): boolean;
155
+ /**
156
+ * The cache key for a live token: SHA-256 over the kind, the account and the
157
+ * WHOLE stored config — credential material included (client secret, refresh
158
+ * token, private key, subject, scopes, token URL). A caller can only hit an
159
+ * entry it could have minted itself. Both maps are process-wide, so a key of
160
+ * names alone (`sa:${name}:${issuer}`, `${name}:${client_id}:…`) once let a
161
+ * second account with the same names be handed the first one's token
162
+ * (Codex debate, 22 Oct 2026).
163
+ */
164
+ export declare function tokenCacheKey(kind: "oauth2" | "sa", storedValue: string, tenant?: string): string;
165
+ /**
166
+ * A provider that rotates refresh tokens (RFC 9700 §2.2.2) answered a refresh
167
+ * with a new one: write it back where the old one is stored — the workspace
168
+ * copy, else the account's — but only while that copy still holds the value
169
+ * this refresh used (compare-and-set), so two refreshes racing cannot put an
170
+ * older token over a newer one. Returns whether a copy was replaced.
171
+ */
172
+ export declare function replaceOAuth2RefreshToken(tenant: string, name: string, workspace: string | undefined, usedValue: string, refreshToken: string): boolean;
153
173
  /**
154
174
  * Swap every oauth2 recipe in an env map for a live access token. The one
155
175
  * async moment in secret resolution, kept out of the sync paths: callers
@@ -171,6 +191,10 @@ export interface ServiceAccountConfig {
171
191
  scope: string;
172
192
  /** JWT audience — usually the same as token_url. */
173
193
  audience?: string;
194
+ /** The JWT bearer grant's subject (RFC 7523 §3): the user the account acts
195
+ * as, where the provider allows it — Google's domain-wide delegation, once
196
+ * a Workspace admin grants it. Absent, the account acts as itself. */
197
+ subject?: string;
174
198
  }
175
199
  export declare function setServiceAccountSecret(tenant: string, name: string, config: ServiceAccountConfig, workspace?: string): void;
176
200
  export declare function setFileSecret(tenant: string, name: string, content: string, workspace?: string): void;
@@ -187,10 +211,46 @@ export interface SshConfig {
187
211
  export declare function setSshSecret(tenant: string, name: string, config: SshConfig, workspace?: string): void;
188
212
  export declare function isSshValue(value: string): boolean;
189
213
  export declare function sshConfigOf(value: string): SshConfig;
214
+ /** A client certificate for mutual TLS: presented by the egress proxy, on its
215
+ * own connection, to the exact hosts the secret is bound to — the sandbox
216
+ * never holds the key. `export` puts it in the sandbox as files instead,
217
+ * only when asked for (a step with no egress proxy). */
218
+ export interface ClientCertConfig {
219
+ /** PEM certificate, chain included. */
220
+ cert: string;
221
+ /** PEM private key. */
222
+ key: string;
223
+ passphrase?: string;
224
+ export?: boolean;
225
+ }
226
+ export declare function setClientCertSecret(tenant: string, name: string, config: ClientCertConfig, workspace?: string): void;
227
+ /** A keyless cloud identity: no credential at all, only the audience the
228
+ * platform's signed tokens are for. A step that declares it gets a token
229
+ * file the sandbox driver keeps fresh through the egress proxy. */
230
+ export interface IdentityConfig {
231
+ audience: string;
232
+ }
233
+ export declare function setIdentitySecret(tenant: string, name: string, config: IdentityConfig, workspace?: string): void;
234
+ export declare function isIdentityValue(value: string): boolean;
235
+ export declare function identityOf(value: string): IdentityConfig;
236
+ export declare function isClientCertValue(value: string): boolean;
237
+ /** An environment without the client certificates that were not saved with
238
+ * `export`: those are the egress proxy's to present, never a program's. */
239
+ export declare function withoutHeldCerts(env: Record<string, string>): Record<string, string>;
240
+ export declare function clientCertOf(value: string): ClientCertConfig;
190
241
  export interface ApiConfig {
191
242
  base_url?: string;
192
243
  headers: Record<string, string>;
244
+ /** Username and password for HTTP Basic (RFC 7617): stored as the
245
+ * `Authorization: Basic …` header they make, never kept apart. */
246
+ basic?: {
247
+ username: string;
248
+ password: string;
249
+ };
193
250
  }
251
+ /** `Authorization: Basic …` from a username and password (RFC 7617 §2):
252
+ * UTF-8, no colon in the username, no control characters in either. */
253
+ export declare function basicAuthorization(username: string, password: string): string;
194
254
  export declare function setApiSecret(tenant: string, name: string, config: ApiConfig, workspace?: string): void;
195
255
  export declare function isApiValue(value: string): boolean;
196
256
  export declare function apiConfigOf(value: string): ApiConfig;
@@ -200,6 +200,11 @@ export function setSecretSites(tenant, name, sites, workspace) {
200
200
  if (clean.length && (rec.kind === "file" || rec.kind === "ssh" || rec.kind === "api")) {
201
201
  throw new Error(`${name} is a ${rec.kind} secret, unpacked inside the sandbox — only a value sent in a request can be bound to sites`);
202
202
  }
203
+ // A client certificate is presented by the egress proxy to the exact
204
+ // hosts named — never to a whole domain on a wildcard.
205
+ if (rec.kind === "client-cert" && clean.some((s) => s.startsWith("*."))) {
206
+ throw new Error(`${name} is a client certificate — bind it to exact hosts, not a wildcard`);
207
+ }
203
208
  if (clean.length)
204
209
  rec.sites = clean;
205
210
  else
@@ -459,10 +464,15 @@ export function oauthRetryDelayMs(n) {
459
464
  return base * (n === 1 ? 1 : 3);
460
465
  }
461
466
  export function setOAuth2Secret(tenant, name, config, workspace) {
462
- for (const field of ["token_url", "client_id", "client_secret"]) {
467
+ for (const field of ["token_url", "client_id"]) {
463
468
  if (!config[field]?.trim())
464
469
  throw new Error(`oauth2 secret needs ${field}`);
465
470
  }
471
+ // A public client (PKCE, no secret) refreshes with its client id alone;
472
+ // client credentials are nothing without the secret.
473
+ if (config.grant_type === "client_credentials" && !config.client_secret?.trim()) {
474
+ throw new Error("oauth2 secret needs client_secret for client_credentials");
475
+ }
466
476
  if (config.grant_type !== "client_credentials" && !config.refresh_token?.trim()) {
467
477
  throw new Error("oauth2 secret needs refresh_token (or grant_type: client_credentials)");
468
478
  }
@@ -481,11 +491,62 @@ export function isOAuth2Value(value) {
481
491
  // steps must not hit Google twenty times in one second.
482
492
  const tokenCache = new Map();
483
493
  const inFlight = new Map();
494
+ /**
495
+ * The cache key for a live token: SHA-256 over the kind, the account and the
496
+ * WHOLE stored config — credential material included (client secret, refresh
497
+ * token, private key, subject, scopes, token URL). A caller can only hit an
498
+ * entry it could have minted itself. Both maps are process-wide, so a key of
499
+ * names alone (`sa:${name}:${issuer}`, `${name}:${client_id}:…`) once let a
500
+ * second account with the same names be handed the first one's token
501
+ * (Codex debate, 22 Oct 2026).
502
+ */
503
+ export function tokenCacheKey(kind, storedValue, tenant) {
504
+ return crypto.createHash("sha256").update(JSON.stringify([kind, tenant ?? "", storedValue])).digest("hex");
505
+ }
506
+ /**
507
+ * A provider that rotates refresh tokens (RFC 9700 §2.2.2) answered a refresh
508
+ * with a new one: write it back where the old one is stored — the workspace
509
+ * copy, else the account's — but only while that copy still holds the value
510
+ * this refresh used (compare-and-set), so two refreshes racing cannot put an
511
+ * older token over a newer one. Returns whether a copy was replaced.
512
+ */
513
+ export function replaceOAuth2RefreshToken(tenant, name, workspace, usedValue, refreshToken) {
514
+ if (!isOAuth2Value(usedValue))
515
+ return false;
516
+ const config = JSON.parse(usedValue.slice(OAUTH2_PREFIX.length));
517
+ const next = OAUTH2_PREFIX + JSON.stringify({ ...config, refresh_token: refreshToken });
518
+ for (const scope of workspace ? [workspace, undefined] : [undefined]) {
519
+ let replaced = false;
520
+ mutateVaultFile(secretsFile(tenant, scope), (all) => {
521
+ const rec = all[name];
522
+ if (rec && decrypt(tenant, rec) === usedValue) {
523
+ all[name] = { ...rec, ...encryptValue(tenant, next), updatedAt: new Date().toISOString() };
524
+ replaced = true;
525
+ }
526
+ return all;
527
+ });
528
+ if (replaced)
529
+ return true;
530
+ }
531
+ return false;
532
+ }
533
+ const oauthCache = new Map();
534
+ const oauthFlight = new Map();
535
+ /** Cache an access token, sweeping expired entries as it goes: a rotating
536
+ * provider changes the stored config — and so the key — on every refresh,
537
+ * and expired entries would otherwise pile up for the life of the process. */
538
+ function cacheOAuth(key, live) {
539
+ const now = Date.now();
540
+ for (const [k, v] of oauthCache)
541
+ if (v.expiresAt <= now)
542
+ oauthCache.delete(k);
543
+ oauthCache.set(key, live);
544
+ }
484
545
  async function exchange(config, cacheKey) {
485
- const cached = tokenCache.get(cacheKey);
546
+ const cached = oauthCache.get(cacheKey);
486
547
  if (cached && cached.expiresAt > Date.now())
487
- return cached.token;
488
- const pending = inFlight.get(cacheKey);
548
+ return cached;
549
+ const pending = oauthFlight.get(cacheKey);
489
550
  if (pending)
490
551
  return pending;
491
552
  const flight = (async () => {
@@ -493,7 +554,7 @@ async function exchange(config, cacheKey) {
493
554
  grant_type: config.grant_type ?? "refresh_token",
494
555
  ...(config.grant_type === "client_credentials" ? {} : { refresh_token: config.refresh_token }),
495
556
  client_id: config.client_id,
496
- client_secret: config.client_secret,
557
+ ...(config.client_secret ? { client_secret: config.client_secret } : {}),
497
558
  ...(config.extra ?? {}),
498
559
  });
499
560
  // A network blip is retried; a provider's answer is not. "fetch failed"
@@ -530,15 +591,21 @@ async function exchange(config, cacheKey) {
530
591
  throw new Error(`token refresh failed (${res.status}): ${payload.error_description ?? payload.error ?? "no access_token in the reply"}`);
531
592
  }
532
593
  const ttl = Math.max(60, (payload.expires_in ?? 3600) - 60) * 1000;
533
- tokenCache.set(cacheKey, { token: payload.access_token, expiresAt: Date.now() + ttl });
534
- return payload.access_token;
594
+ // A rotated refresh token replaces the old one, which the provider may
595
+ // now refuse; dropping it broke the next refresh for rotating providers.
596
+ const rotated = payload.refresh_token && config.grant_type !== "client_credentials" && payload.refresh_token !== config.refresh_token
597
+ ? payload.refresh_token
598
+ : undefined;
599
+ const live = { token: payload.access_token, expiresAt: Date.now() + ttl, ...(rotated ? { rotated } : {}) };
600
+ cacheOAuth(cacheKey, live);
601
+ return live;
535
602
  })();
536
- inFlight.set(cacheKey, flight);
603
+ oauthFlight.set(cacheKey, flight);
537
604
  try {
538
605
  return await flight;
539
606
  }
540
607
  finally {
541
- inFlight.delete(cacheKey);
608
+ oauthFlight.delete(cacheKey);
542
609
  }
543
610
  }
544
611
  /**
@@ -565,13 +632,23 @@ ctx) {
565
632
  throw new Error(`secret ${name}: stored oauth2 config is unreadable`);
566
633
  }
567
634
  try {
568
- // Fingerprint the WHOLE refresh token, not its first 8 chars: Google
569
- // refresh tokens share a long common prefix, so an 8-char slice could
570
- // collide two distinct grants onto one cache entry and hand one the
571
- // other's access token. Hashed so the token is not held in a Map key.
572
- const grantMaterial = config.refresh_token ?? config.grant_type ?? "";
573
- const fp = crypto.createHash("sha256").update(grantMaterial).digest("hex").slice(0, 16);
574
- out[name] = await exchange(config, `${name}:${config.client_id}:${fp}`);
635
+ const live = await exchange(config, tokenCacheKey("oauth2", value, ctx?.tenant));
636
+ out[name] = live.token;
637
+ if (live.rotated) {
638
+ // This caller's copy takes the new refresh token (compare-and-set:
639
+ // a copy already updated, or changed since, is left alone), and the
640
+ // access token is cached under the config as it is now stored, so
641
+ // the next run does not refresh again.
642
+ const next = OAUTH2_PREFIX + JSON.stringify({ ...config, refresh_token: live.rotated });
643
+ try {
644
+ if (ctx)
645
+ replaceOAuth2RefreshToken(ctx.tenant, name, ctx.workspace, value, live.rotated);
646
+ }
647
+ catch {
648
+ // The access token is good either way; the next refresh says if not.
649
+ }
650
+ cacheOAuth(tokenCacheKey("oauth2", next, ctx?.tenant), { token: live.token, expiresAt: live.expiresAt });
651
+ }
575
652
  }
576
653
  catch (err) {
577
654
  const message = err instanceof Error ? err.message : String(err);
@@ -590,7 +667,7 @@ ctx) {
590
667
  throw new Error(`secret ${name}: stored service account config is unreadable`);
591
668
  }
592
669
  try {
593
- out[name] = await exchangeServiceAccount(config, `sa:${name}:${config.issuer}`);
670
+ out[name] = await exchangeServiceAccount(config, tokenCacheKey("sa", value, ctx?.tenant));
594
671
  }
595
672
  catch (err) {
596
673
  throw new Error(`secret ${name}: ${err instanceof Error ? err.message : String(err)}`);
@@ -619,7 +696,14 @@ export function setServiceAccountSecret(tenant, name, config, workspace) {
619
696
  if (!/BEGIN [A-Z ]{0,40}PRIVATE KEY/.test(config.private_key)) {
620
697
  throw new Error("private_key must be a PEM key (from the service account JSON)");
621
698
  }
622
- setSecret(tenant, name, SERVICE_ACCOUNT_PREFIX + JSON.stringify(config), workspace, "service-account");
699
+ if (config.subject !== undefined) {
700
+ if (typeof config.subject !== "string" || !config.subject.trim())
701
+ throw new Error("subject is the user to act as — leave it out to act as the account itself");
702
+ if (config.subject.length > 320 || /[\u0000-\u001f\u007f\s]/.test(config.subject.trim()))
703
+ throw new Error("subject is one identifier, no spaces or control characters");
704
+ }
705
+ const stored = { ...config, ...(config.subject !== undefined ? { subject: config.subject.trim() } : {}) };
706
+ setSecret(tenant, name, SERVICE_ACCOUNT_PREFIX + JSON.stringify(stored), workspace, "service-account");
623
707
  }
624
708
  function base64url(input) {
625
709
  return Buffer.from(input).toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
@@ -636,7 +720,9 @@ async function exchangeServiceAccount(config, cacheKey) {
636
720
  const header = base64url(JSON.stringify({ alg: "RS256", typ: "JWT" }));
637
721
  const claim = base64url(JSON.stringify({
638
722
  iss: config.issuer,
639
- sub: config.issuer,
723
+ // The subject is part of the stored config, so of the cache key: two
724
+ // subjects never share a token (tokenCacheKey).
725
+ sub: config.subject || config.issuer,
640
726
  scope: config.scope,
641
727
  aud: config.audience ?? config.token_url,
642
728
  iat: now,
@@ -737,8 +823,108 @@ export function sshConfigOf(value) {
737
823
  // wrapper with the headers baked in — `"$NAME" /v1/charges` — so the secret
738
824
  // never appears in the bash command at all.
739
825
  const API_PREFIX = "@api ";
826
+ const CLIENT_CERT_PREFIX = "@client-cert ";
827
+ export function setClientCertSecret(tenant, name, config, workspace) {
828
+ if (!/-----BEGIN CERTIFICATE-----/.test(config.cert ?? ""))
829
+ throw new Error("cert must be a PEM certificate");
830
+ if (!/-----BEGIN [A-Z ]{0,40}PRIVATE KEY-----/.test(config.key ?? ""))
831
+ throw new Error("key must be a PEM private key");
832
+ // The pair must match: a certificate presented with another key's proof
833
+ // fails at the server, after the request has gone.
834
+ let matches = false;
835
+ try {
836
+ const key = crypto.createPrivateKey({ key: config.key, ...(config.passphrase ? { passphrase: config.passphrase } : {}) });
837
+ matches = new crypto.X509Certificate(config.cert).checkPrivateKey(key);
838
+ }
839
+ catch (err) {
840
+ throw new Error(`the certificate or key cannot be read: ${err instanceof Error ? err.message : String(err)}`);
841
+ }
842
+ if (!matches)
843
+ throw new Error("the key does not belong to the certificate");
844
+ const stored = {
845
+ cert: config.cert,
846
+ key: config.key,
847
+ ...(config.passphrase ? { passphrase: config.passphrase } : {}),
848
+ ...(config.export ? { export: true } : {}),
849
+ };
850
+ if (!SECRET_NAME.test(name))
851
+ throw new Error(`secret name "${name}" must be UPPER_SNAKE_CASE`);
852
+ const sealed = encryptValue(tenant, CLIENT_CERT_PREFIX + JSON.stringify(stored));
853
+ // Written under the vault's own lock, with the binding checked in the same
854
+ // step: a value set over a secret keeps its site binding, and a wildcard
855
+ // one would present the certificate to a whole domain.
856
+ mutateVaultFile(secretsFile(tenant, workspace), (all) => {
857
+ const sites = all[name]?.sites;
858
+ if (sites?.some((site) => site.startsWith("*."))) {
859
+ throw new Error(`${name} is bound to ${sites.join(", ")} — a client certificate is bound to exact hosts; unbind the wildcard first`);
860
+ }
861
+ all[name] = { ...sealed, updatedAt: new Date().toISOString(), kind: "client-cert", ...(sites?.length ? { sites } : {}) };
862
+ return all;
863
+ });
864
+ }
865
+ const IDENTITY_PREFIX = "@identity ";
866
+ export function setIdentitySecret(tenant, name, config, workspace) {
867
+ const audience = typeof config.audience === "string" ? config.audience.trim() : "";
868
+ if (!audience)
869
+ throw new Error("an identity needs an audience — the value your cloud's trust expects in aud");
870
+ if (audience.length > 600 || /[\u0000-\u001f\u007f]/.test(audience))
871
+ throw new Error("audience is one value, at most 600 characters, no control characters");
872
+ setSecret(tenant, name, IDENTITY_PREFIX + JSON.stringify({ audience }), workspace, "identity");
873
+ }
874
+ export function isIdentityValue(value) {
875
+ return value.startsWith(IDENTITY_PREFIX);
876
+ }
877
+ export function identityOf(value) {
878
+ return JSON.parse(value.slice(IDENTITY_PREFIX.length));
879
+ }
880
+ export function isClientCertValue(value) {
881
+ return value.startsWith(CLIENT_CERT_PREFIX);
882
+ }
883
+ /** An environment without the client certificates that were not saved with
884
+ * `export`: those are the egress proxy's to present, never a program's. */
885
+ export function withoutHeldCerts(env) {
886
+ return Object.fromEntries(Object.entries(env).filter(([, v]) => typeof v !== "string" || !isClientCertValue(v) || clientCertOf(v).export === true));
887
+ }
888
+ export function clientCertOf(value) {
889
+ return JSON.parse(value.slice(CLIENT_CERT_PREFIX.length));
890
+ }
891
+ /** `Authorization: Basic …` from a username and password (RFC 7617 §2):
892
+ * UTF-8, no colon in the username, no control characters in either. */
893
+ export function basicAuthorization(username, password) {
894
+ if (typeof username !== "string" || typeof password !== "string")
895
+ throw new Error("basic needs username and password");
896
+ if (!username)
897
+ throw new Error("basic needs a username");
898
+ if (username.includes(":"))
899
+ throw new Error("a Basic username cannot contain a colon (RFC 7617)");
900
+ if (/[\u0000-\u001f\u007f]/.test(username) || /[\u0000-\u001f\u007f]/.test(password))
901
+ throw new Error("Basic credentials cannot contain control characters");
902
+ return `Basic ${Buffer.from(`${username}:${password}`, "utf8").toString("base64")}`;
903
+ }
740
904
  export function setApiSecret(tenant, name, config, workspace) {
741
- const headers = Object.entries(config.headers ?? {});
905
+ let all = { ...(config.headers ?? {}) };
906
+ if (config.basic) {
907
+ if (Object.keys(all).some((h) => h.toLowerCase() === "authorization")) {
908
+ throw new Error("give either basic or an Authorization header, not both");
909
+ }
910
+ // Credentials in the clear over http are leaked ones: https, or loopback.
911
+ const base = config.base_url?.trim();
912
+ if (base) {
913
+ let u = null;
914
+ try {
915
+ u = new URL(base);
916
+ }
917
+ catch {
918
+ u = null;
919
+ }
920
+ const loopback = u?.protocol === "http:" && (u.hostname === "localhost" || u.hostname === "127.0.0.1");
921
+ if (!u || (u.protocol !== "https:" && !loopback))
922
+ throw new Error("Basic credentials need an https base_url");
923
+ }
924
+ all = { ...all, Authorization: basicAuthorization(config.basic.username, config.basic.password) };
925
+ }
926
+ config = { ...(config.base_url ? { base_url: config.base_url } : {}), headers: all };
927
+ const headers = Object.entries(all);
742
928
  if (headers.length === 0)
743
929
  throw new Error("api secret needs at least one header");
744
930
  for (const [h, v] of headers) {
@@ -478,6 +478,7 @@ apis: [{ name: crm }] # an HTTP API, as a tool
478
478
  mcpServers: {} # an MCP server, as a tool
479
479
  agents: [writer] # colleagues this one may consult (an answer, no tools)
480
480
  subagents: [researcher] # colleagues it may delegate a job to (own context, own tools ≤ its own)
481
+ sandbox: own # delegated to: run each job in its own sandbox (own tools, secrets, network)
481
482
  secrets: [CRM_TOKEN] # keys the agent uses itself; a tool's own come with the tool
482
483
  limits: {web.search: 40, crm: 20, calls: 300} # per step; a call past a limit is refused, never run
483
484
  network: [api.example.com] # sites its sandbox may reach, added to the trusted list; open | trusted | none
@@ -311,6 +311,8 @@ export interface AgentInfo {
311
311
  /** Colleagues this agent may DELEGATE to (`subagents:`) — each runs with
312
312
  * its own context and its own tools, no wider than this agent's. */
313
313
  subagents: string[];
314
+ /** `sandbox: own` — delegated to, it runs each job in its own sandbox. */
315
+ sandbox?: "own";
314
316
  /** The `skills:` allowlist, or null when the field is absent — which is
315
317
  * not the same thing: absent inherits every skill in scope, an empty
316
318
  * list withholds all of them. */
@@ -1068,6 +1070,16 @@ export interface StepAttempt {
1068
1070
  export declare function sumAttempts(tries: StepAttempt[]): Pick<StepAttempt, "costUsd" | "tokens" | "computeSecs" | "turns">;
1069
1071
  export { type TokenUsage, type PromptLayerRecord, tokensOf, sumTokens, cacheHitRate, measuredHitRate, runTokens, promptLayersChanged, promptCacheNote } from "./tokens.ts";
1070
1072
  import { type TokenUsage, type PromptLayerRecord } from "./tokens.ts";
1073
+ /** One job a step handed to an isolated sub-agent. `id` is the job's own
1074
+ * record's `delegation` — not an index, which moves when records are
1075
+ * spliced in before it. */
1076
+ export interface StepDelegation {
1077
+ id: string;
1078
+ agent: string;
1079
+ status: "running" | "completed" | "failed" | "skipped";
1080
+ costUsd?: number | null;
1081
+ ms?: number | null;
1082
+ }
1071
1083
  /** The marker rescueStep puts in a rescue's instruction; how a rescue record
1072
1084
  * written before `rescueOf` existed is still recognised. */
1073
1085
  export declare const RESCUE_MARKER = "<rescue>\nYou are taking this step over:";
@@ -1076,6 +1088,15 @@ export declare function isRescueRecord(step: {
1076
1088
  rescueOf?: string;
1077
1089
  instruction?: string | null;
1078
1090
  }): boolean;
1091
+ /** Is this record put in by the runner rather than by the flow — an on-fail
1092
+ * rescue, or an isolated sub-agent's run (`subagentOf`)? Neither is launched
1093
+ * with a group, handed on as a group's output, reset by a loop, or copied by
1094
+ * a re-run as work. */
1095
+ export declare function isSplicedRecord(step: {
1096
+ rescueOf?: string;
1097
+ subagentOf?: string;
1098
+ instruction?: string | null;
1099
+ }): boolean;
1079
1100
  export interface StepRecord {
1080
1101
  agent: string;
1081
1102
  instruction: string;
@@ -1242,6 +1263,19 @@ export interface StepRecord {
1242
1263
  * steps). Records written before this field are recognised by their
1243
1264
  * instruction's `<rescue>` block instead — see isRescueRecord. */
1244
1265
  rescueOf?: string;
1266
+ /** Set on an isolated sub-agent's run (`sandbox: own`, delegate-tools.ts):
1267
+ * the agent of the step that delegated to it. It ran in its own sandbox
1268
+ * inside that step; only its reply went back. Like a rescue it is not a
1269
+ * step of the flow — see isSplicedRecord. */
1270
+ subagentOf?: string;
1271
+ /** On an isolated sub-agent's run: its job's id, as the delegating step's
1272
+ * `delegations[].id` names it. */
1273
+ delegation?: string;
1274
+ /** On a step that delegated to isolated sub-agents: each job, in order. */
1275
+ delegations?: StepDelegation[];
1276
+ /** On an isolated sub-agent's run: the workspace paths its sandbox's
1277
+ * changes were applied to — what its delegating step is told it wrote. */
1278
+ wrote?: string[];
1245
1279
  /**
1246
1280
  * Sandbox seconds this step rented, and how many of them were cold start.
1247
1281
  * Set only on the isolated path — an in-process step rents nothing, so
package/dist/src/store.js CHANGED
@@ -1163,6 +1163,7 @@ export function listAgents(tenant, workspace) {
1163
1163
  retiredTools: retiredToolNames(data),
1164
1164
  consults: refNames(data.agents),
1165
1165
  subagents: refNames(data.subagents),
1166
+ ...(data.sandbox === "own" ? { sandbox: "own" } : {}),
1166
1167
  skills: data.skills === undefined ? null : refNames(data.skills),
1167
1168
  secrets: Array.isArray(data.secrets) ? data.secrets.map(String) : [],
1168
1169
  budget: runBudget(data.budget),
@@ -2662,6 +2663,13 @@ export const RESCUE_MARKER = "<rescue>\nYou are taking this step over:";
2662
2663
  export function isRescueRecord(step) {
2663
2664
  return !!step.rescueOf || (step.instruction ?? "").includes(RESCUE_MARKER);
2664
2665
  }
2666
+ /** Is this record put in by the runner rather than by the flow — an on-fail
2667
+ * rescue, or an isolated sub-agent's run (`subagentOf`)? Neither is launched
2668
+ * with a group, handed on as a group's output, reset by a loop, or copied by
2669
+ * a re-run as work. */
2670
+ export function isSplicedRecord(step) {
2671
+ return !!step.subagentOf || isRescueRecord(step);
2672
+ }
2665
2673
  // ---------------------------------------------------------------- captured calls
2666
2674
  //
2667
2675
  // What each tool call took and gave (FOL-54 step 4), beside the run under
@@ -3218,6 +3226,10 @@ export function runVerdict(run) {
3218
3226
  }
3219
3227
  export function runSummary(run) {
3220
3228
  for (let i = run.steps.length - 1; i >= 0; i--) {
3229
+ // A sandbox: own job is appended after every step of the flow; its reply
3230
+ // went to the step that asked, which speaks for the run.
3231
+ if (run.steps[i].subagentOf)
3232
+ continue;
3221
3233
  const line = stepHeadline(run.steps[i]);
3222
3234
  if (line)
3223
3235
  return line;
@@ -10,6 +10,20 @@ export interface SubagentSpec {
10
10
  * the step's own) or "inherit". */
11
11
  model: string;
12
12
  }
13
+ /** An isolated sub-agent (`sandbox: own` on its own agent.md): what the
14
+ * parent's sandbox needs to offer it as a tool. Everything else — its tools,
15
+ * secrets, network, size — is resolved on the worker when the job arrives,
16
+ * from its own file, and never enters the parent's sandbox. */
17
+ export interface DelegateSpec {
18
+ name: string;
19
+ description: string;
20
+ }
21
+ /** Does this agent run in its own sandbox when delegated to? */
22
+ export declare function ownSandbox(front: {
23
+ sandbox?: unknown;
24
+ }): boolean;
25
+ /** The tool the parent calls to hand an isolated sub-agent a job. */
26
+ export declare function delegateToolName(agent: string): string;
13
27
  /** The SDK's delegation tool, under both names it has had. Never granted to a
14
28
  * sub-agent: that is what makes the depth one. */
15
29
  export declare const DELEGATE_TOOLS: readonly ["Agent", "Task"];
@@ -27,6 +27,14 @@ import fs from "node:fs";
27
27
  import path from "node:path";
28
28
  import { refNames } from "./refs.js";
29
29
  import { TOOL_MAP } from "./tool-names.js";
30
+ /** Does this agent run in its own sandbox when delegated to? */
31
+ export function ownSandbox(front) {
32
+ return front.sandbox === "own";
33
+ }
34
+ /** The tool the parent calls to hand an isolated sub-agent a job. */
35
+ export function delegateToolName(agent) {
36
+ return `delegate_${agent.replaceAll("-", "_")}`;
37
+ }
30
38
  /** The SDK's delegation tool, under both names it has had. Never granted to a
31
39
  * sub-agent: that is what makes the depth one. */
32
40
  export const DELEGATE_TOOLS = ["Agent", "Task"];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@foldrun/core",
3
- "version": "2.6.0",
3
+ "version": "2.8.0",
4
4
  "description": "Agents are just folders. The format, the runner, the checks.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "type": "module",
package/src/capture.ts CHANGED
@@ -7,7 +7,7 @@
7
7
 
8
8
  import { redactText } from "./scan.ts";
9
9
  import { writeCapturedCall, CAPTURE_CAP } from "./store.ts";
10
- import { isFileValue, fileContent } from "./secrets.ts";
10
+ import { isFileValue, fileContent, isApiValue, apiConfigOf, isClientCertValue, clientCertOf } from "./secrets.ts";
11
11
  import type { CallIo } from "./step-exec.ts";
12
12
 
13
13
  /** How much past the cap a step sends, so masking sees a secret whole
@@ -31,6 +31,38 @@ export function redactionsFor(secrets: Record<string, string | undefined>): [str
31
31
  };
32
32
  for (const [name, raw] of Object.entries(secrets)) {
33
33
  if (typeof raw !== "string") continue;
34
+ // An API connection's header values are each a secret in their own right
35
+ // (a token, Basic credentials) — and a Basic header's password too.
36
+ // An exported client certificate's passphrase and private key reach the
37
+ // sandbox as files: each is masked, whole and line by line.
38
+ if (isClientCertValue(raw)) {
39
+ const c = clientCertOf(raw);
40
+ for (const v of [c.passphrase, c.key]) {
41
+ if (!v) continue;
42
+ add(v, name);
43
+ add(v.trim(), name);
44
+ const escaped = JSON.stringify(v).slice(1, -1);
45
+ if (escaped !== v) add(escaped, name);
46
+ for (const line of v.split(/\r?\n/)) if (line.trim().length >= 16) add(line.trim(), name);
47
+ }
48
+ }
49
+ if (isApiValue(raw)) {
50
+ // Each as written and as JSON would escape it, like every value here.
51
+ const both = (v: string) => {
52
+ add(v, name);
53
+ const escaped = JSON.stringify(v).slice(1, -1);
54
+ if (escaped !== v) add(escaped, name);
55
+ };
56
+ for (const v of Object.values(apiConfigOf(raw).headers ?? {})) {
57
+ both(v);
58
+ const basic = /^Basic\s+([A-Za-z0-9+/=]+)$/i.exec(v.trim());
59
+ if (basic) {
60
+ both(basic[1]!);
61
+ const decoded = Buffer.from(basic[1]!, "base64").toString("utf8");
62
+ if (decoded.includes(":")) both(decoded.slice(decoded.indexOf(":") + 1));
63
+ }
64
+ }
65
+ }
34
66
  for (const v of isFileValue(raw) ? [raw, fileContent(raw)] : [raw]) {
35
67
  add(v, name);
36
68
  add(v.trim(), name);