@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/dist/src/capture.js +39 -1
- package/dist/src/egress.d.ts +16 -0
- package/dist/src/run-container.d.ts +1 -1
- package/dist/src/run-container.js +30 -0
- package/dist/src/runner.js +74 -3
- package/dist/src/secret-files.js +23 -2
- package/dist/src/secrets.d.ts +62 -2
- package/dist/src/secrets.js +206 -20
- package/package.json +1 -1
- package/src/capture.ts +33 -1
- package/src/egress.ts +15 -1
- package/src/run-container.ts +30 -0
- package/src/runner.ts +71 -3
- package/src/secret-files.ts +22 -2
- package/src/secrets.ts +237 -23
package/dist/src/secrets.js
CHANGED
|
@@ -200,6 +200,11 @@ export function setSecretSites(tenant, name, sites, workspace) {
|
|
|
200
200
|
if (clean.length && (rec.kind === "file" || rec.kind === "ssh" || rec.kind === "api")) {
|
|
201
201
|
throw new Error(`${name} is a ${rec.kind} secret, unpacked inside the sandbox — only a value sent in a request can be bound to sites`);
|
|
202
202
|
}
|
|
203
|
+
// A client certificate is presented by the egress proxy to the exact
|
|
204
|
+
// hosts named — never to a whole domain on a wildcard.
|
|
205
|
+
if (rec.kind === "client-cert" && clean.some((s) => s.startsWith("*."))) {
|
|
206
|
+
throw new Error(`${name} is a client certificate — bind it to exact hosts, not a wildcard`);
|
|
207
|
+
}
|
|
203
208
|
if (clean.length)
|
|
204
209
|
rec.sites = clean;
|
|
205
210
|
else
|
|
@@ -459,10 +464,15 @@ export function oauthRetryDelayMs(n) {
|
|
|
459
464
|
return base * (n === 1 ? 1 : 3);
|
|
460
465
|
}
|
|
461
466
|
export function setOAuth2Secret(tenant, name, config, workspace) {
|
|
462
|
-
for (const field of ["token_url", "client_id"
|
|
467
|
+
for (const field of ["token_url", "client_id"]) {
|
|
463
468
|
if (!config[field]?.trim())
|
|
464
469
|
throw new Error(`oauth2 secret needs ${field}`);
|
|
465
470
|
}
|
|
471
|
+
// A public client (PKCE, no secret) refreshes with its client id alone;
|
|
472
|
+
// client credentials are nothing without the secret.
|
|
473
|
+
if (config.grant_type === "client_credentials" && !config.client_secret?.trim()) {
|
|
474
|
+
throw new Error("oauth2 secret needs client_secret for client_credentials");
|
|
475
|
+
}
|
|
466
476
|
if (config.grant_type !== "client_credentials" && !config.refresh_token?.trim()) {
|
|
467
477
|
throw new Error("oauth2 secret needs refresh_token (or grant_type: client_credentials)");
|
|
468
478
|
}
|
|
@@ -481,11 +491,62 @@ export function isOAuth2Value(value) {
|
|
|
481
491
|
// steps must not hit Google twenty times in one second.
|
|
482
492
|
const tokenCache = new Map();
|
|
483
493
|
const inFlight = new Map();
|
|
494
|
+
/**
|
|
495
|
+
* The cache key for a live token: SHA-256 over the kind, the account and the
|
|
496
|
+
* WHOLE stored config — credential material included (client secret, refresh
|
|
497
|
+
* token, private key, subject, scopes, token URL). A caller can only hit an
|
|
498
|
+
* entry it could have minted itself. Both maps are process-wide, so a key of
|
|
499
|
+
* names alone (`sa:${name}:${issuer}`, `${name}:${client_id}:…`) once let a
|
|
500
|
+
* second account with the same names be handed the first one's token
|
|
501
|
+
* (Codex debate, 22 Oct 2026).
|
|
502
|
+
*/
|
|
503
|
+
export function tokenCacheKey(kind, storedValue, tenant) {
|
|
504
|
+
return crypto.createHash("sha256").update(JSON.stringify([kind, tenant ?? "", storedValue])).digest("hex");
|
|
505
|
+
}
|
|
506
|
+
/**
|
|
507
|
+
* A provider that rotates refresh tokens (RFC 9700 §2.2.2) answered a refresh
|
|
508
|
+
* with a new one: write it back where the old one is stored — the workspace
|
|
509
|
+
* copy, else the account's — but only while that copy still holds the value
|
|
510
|
+
* this refresh used (compare-and-set), so two refreshes racing cannot put an
|
|
511
|
+
* older token over a newer one. Returns whether a copy was replaced.
|
|
512
|
+
*/
|
|
513
|
+
export function replaceOAuth2RefreshToken(tenant, name, workspace, usedValue, refreshToken) {
|
|
514
|
+
if (!isOAuth2Value(usedValue))
|
|
515
|
+
return false;
|
|
516
|
+
const config = JSON.parse(usedValue.slice(OAUTH2_PREFIX.length));
|
|
517
|
+
const next = OAUTH2_PREFIX + JSON.stringify({ ...config, refresh_token: refreshToken });
|
|
518
|
+
for (const scope of workspace ? [workspace, undefined] : [undefined]) {
|
|
519
|
+
let replaced = false;
|
|
520
|
+
mutateVaultFile(secretsFile(tenant, scope), (all) => {
|
|
521
|
+
const rec = all[name];
|
|
522
|
+
if (rec && decrypt(tenant, rec) === usedValue) {
|
|
523
|
+
all[name] = { ...rec, ...encryptValue(tenant, next), updatedAt: new Date().toISOString() };
|
|
524
|
+
replaced = true;
|
|
525
|
+
}
|
|
526
|
+
return all;
|
|
527
|
+
});
|
|
528
|
+
if (replaced)
|
|
529
|
+
return true;
|
|
530
|
+
}
|
|
531
|
+
return false;
|
|
532
|
+
}
|
|
533
|
+
const oauthCache = new Map();
|
|
534
|
+
const oauthFlight = new Map();
|
|
535
|
+
/** Cache an access token, sweeping expired entries as it goes: a rotating
|
|
536
|
+
* provider changes the stored config — and so the key — on every refresh,
|
|
537
|
+
* and expired entries would otherwise pile up for the life of the process. */
|
|
538
|
+
function cacheOAuth(key, live) {
|
|
539
|
+
const now = Date.now();
|
|
540
|
+
for (const [k, v] of oauthCache)
|
|
541
|
+
if (v.expiresAt <= now)
|
|
542
|
+
oauthCache.delete(k);
|
|
543
|
+
oauthCache.set(key, live);
|
|
544
|
+
}
|
|
484
545
|
async function exchange(config, cacheKey) {
|
|
485
|
-
const cached =
|
|
546
|
+
const cached = oauthCache.get(cacheKey);
|
|
486
547
|
if (cached && cached.expiresAt > Date.now())
|
|
487
|
-
return cached
|
|
488
|
-
const pending =
|
|
548
|
+
return cached;
|
|
549
|
+
const pending = oauthFlight.get(cacheKey);
|
|
489
550
|
if (pending)
|
|
490
551
|
return pending;
|
|
491
552
|
const flight = (async () => {
|
|
@@ -493,7 +554,7 @@ async function exchange(config, cacheKey) {
|
|
|
493
554
|
grant_type: config.grant_type ?? "refresh_token",
|
|
494
555
|
...(config.grant_type === "client_credentials" ? {} : { refresh_token: config.refresh_token }),
|
|
495
556
|
client_id: config.client_id,
|
|
496
|
-
client_secret: config.client_secret,
|
|
557
|
+
...(config.client_secret ? { client_secret: config.client_secret } : {}),
|
|
497
558
|
...(config.extra ?? {}),
|
|
498
559
|
});
|
|
499
560
|
// A network blip is retried; a provider's answer is not. "fetch failed"
|
|
@@ -530,15 +591,21 @@ async function exchange(config, cacheKey) {
|
|
|
530
591
|
throw new Error(`token refresh failed (${res.status}): ${payload.error_description ?? payload.error ?? "no access_token in the reply"}`);
|
|
531
592
|
}
|
|
532
593
|
const ttl = Math.max(60, (payload.expires_in ?? 3600) - 60) * 1000;
|
|
533
|
-
|
|
534
|
-
|
|
594
|
+
// A rotated refresh token replaces the old one, which the provider may
|
|
595
|
+
// now refuse; dropping it broke the next refresh for rotating providers.
|
|
596
|
+
const rotated = payload.refresh_token && config.grant_type !== "client_credentials" && payload.refresh_token !== config.refresh_token
|
|
597
|
+
? payload.refresh_token
|
|
598
|
+
: undefined;
|
|
599
|
+
const live = { token: payload.access_token, expiresAt: Date.now() + ttl, ...(rotated ? { rotated } : {}) };
|
|
600
|
+
cacheOAuth(cacheKey, live);
|
|
601
|
+
return live;
|
|
535
602
|
})();
|
|
536
|
-
|
|
603
|
+
oauthFlight.set(cacheKey, flight);
|
|
537
604
|
try {
|
|
538
605
|
return await flight;
|
|
539
606
|
}
|
|
540
607
|
finally {
|
|
541
|
-
|
|
608
|
+
oauthFlight.delete(cacheKey);
|
|
542
609
|
}
|
|
543
610
|
}
|
|
544
611
|
/**
|
|
@@ -565,13 +632,23 @@ ctx) {
|
|
|
565
632
|
throw new Error(`secret ${name}: stored oauth2 config is unreadable`);
|
|
566
633
|
}
|
|
567
634
|
try {
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
635
|
+
const live = await exchange(config, tokenCacheKey("oauth2", value, ctx?.tenant));
|
|
636
|
+
out[name] = live.token;
|
|
637
|
+
if (live.rotated) {
|
|
638
|
+
// This caller's copy takes the new refresh token (compare-and-set:
|
|
639
|
+
// a copy already updated, or changed since, is left alone), and the
|
|
640
|
+
// access token is cached under the config as it is now stored, so
|
|
641
|
+
// the next run does not refresh again.
|
|
642
|
+
const next = OAUTH2_PREFIX + JSON.stringify({ ...config, refresh_token: live.rotated });
|
|
643
|
+
try {
|
|
644
|
+
if (ctx)
|
|
645
|
+
replaceOAuth2RefreshToken(ctx.tenant, name, ctx.workspace, value, live.rotated);
|
|
646
|
+
}
|
|
647
|
+
catch {
|
|
648
|
+
// The access token is good either way; the next refresh says if not.
|
|
649
|
+
}
|
|
650
|
+
cacheOAuth(tokenCacheKey("oauth2", next, ctx?.tenant), { token: live.token, expiresAt: live.expiresAt });
|
|
651
|
+
}
|
|
575
652
|
}
|
|
576
653
|
catch (err) {
|
|
577
654
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -590,7 +667,7 @@ ctx) {
|
|
|
590
667
|
throw new Error(`secret ${name}: stored service account config is unreadable`);
|
|
591
668
|
}
|
|
592
669
|
try {
|
|
593
|
-
out[name] = await exchangeServiceAccount(config,
|
|
670
|
+
out[name] = await exchangeServiceAccount(config, tokenCacheKey("sa", value, ctx?.tenant));
|
|
594
671
|
}
|
|
595
672
|
catch (err) {
|
|
596
673
|
throw new Error(`secret ${name}: ${err instanceof Error ? err.message : String(err)}`);
|
|
@@ -619,7 +696,14 @@ export function setServiceAccountSecret(tenant, name, config, workspace) {
|
|
|
619
696
|
if (!/BEGIN [A-Z ]{0,40}PRIVATE KEY/.test(config.private_key)) {
|
|
620
697
|
throw new Error("private_key must be a PEM key (from the service account JSON)");
|
|
621
698
|
}
|
|
622
|
-
|
|
699
|
+
if (config.subject !== undefined) {
|
|
700
|
+
if (typeof config.subject !== "string" || !config.subject.trim())
|
|
701
|
+
throw new Error("subject is the user to act as — leave it out to act as the account itself");
|
|
702
|
+
if (config.subject.length > 320 || /[\u0000-\u001f\u007f\s]/.test(config.subject.trim()))
|
|
703
|
+
throw new Error("subject is one identifier, no spaces or control characters");
|
|
704
|
+
}
|
|
705
|
+
const stored = { ...config, ...(config.subject !== undefined ? { subject: config.subject.trim() } : {}) };
|
|
706
|
+
setSecret(tenant, name, SERVICE_ACCOUNT_PREFIX + JSON.stringify(stored), workspace, "service-account");
|
|
623
707
|
}
|
|
624
708
|
function base64url(input) {
|
|
625
709
|
return Buffer.from(input).toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
@@ -636,7 +720,9 @@ async function exchangeServiceAccount(config, cacheKey) {
|
|
|
636
720
|
const header = base64url(JSON.stringify({ alg: "RS256", typ: "JWT" }));
|
|
637
721
|
const claim = base64url(JSON.stringify({
|
|
638
722
|
iss: config.issuer,
|
|
639
|
-
|
|
723
|
+
// The subject is part of the stored config, so of the cache key: two
|
|
724
|
+
// subjects never share a token (tokenCacheKey).
|
|
725
|
+
sub: config.subject || config.issuer,
|
|
640
726
|
scope: config.scope,
|
|
641
727
|
aud: config.audience ?? config.token_url,
|
|
642
728
|
iat: now,
|
|
@@ -737,8 +823,108 @@ export function sshConfigOf(value) {
|
|
|
737
823
|
// wrapper with the headers baked in — `"$NAME" /v1/charges` — so the secret
|
|
738
824
|
// never appears in the bash command at all.
|
|
739
825
|
const API_PREFIX = "@api ";
|
|
826
|
+
const CLIENT_CERT_PREFIX = "@client-cert ";
|
|
827
|
+
export function setClientCertSecret(tenant, name, config, workspace) {
|
|
828
|
+
if (!/-----BEGIN CERTIFICATE-----/.test(config.cert ?? ""))
|
|
829
|
+
throw new Error("cert must be a PEM certificate");
|
|
830
|
+
if (!/-----BEGIN [A-Z ]{0,40}PRIVATE KEY-----/.test(config.key ?? ""))
|
|
831
|
+
throw new Error("key must be a PEM private key");
|
|
832
|
+
// The pair must match: a certificate presented with another key's proof
|
|
833
|
+
// fails at the server, after the request has gone.
|
|
834
|
+
let matches = false;
|
|
835
|
+
try {
|
|
836
|
+
const key = crypto.createPrivateKey({ key: config.key, ...(config.passphrase ? { passphrase: config.passphrase } : {}) });
|
|
837
|
+
matches = new crypto.X509Certificate(config.cert).checkPrivateKey(key);
|
|
838
|
+
}
|
|
839
|
+
catch (err) {
|
|
840
|
+
throw new Error(`the certificate or key cannot be read: ${err instanceof Error ? err.message : String(err)}`);
|
|
841
|
+
}
|
|
842
|
+
if (!matches)
|
|
843
|
+
throw new Error("the key does not belong to the certificate");
|
|
844
|
+
const stored = {
|
|
845
|
+
cert: config.cert,
|
|
846
|
+
key: config.key,
|
|
847
|
+
...(config.passphrase ? { passphrase: config.passphrase } : {}),
|
|
848
|
+
...(config.export ? { export: true } : {}),
|
|
849
|
+
};
|
|
850
|
+
if (!SECRET_NAME.test(name))
|
|
851
|
+
throw new Error(`secret name "${name}" must be UPPER_SNAKE_CASE`);
|
|
852
|
+
const sealed = encryptValue(tenant, CLIENT_CERT_PREFIX + JSON.stringify(stored));
|
|
853
|
+
// Written under the vault's own lock, with the binding checked in the same
|
|
854
|
+
// step: a value set over a secret keeps its site binding, and a wildcard
|
|
855
|
+
// one would present the certificate to a whole domain.
|
|
856
|
+
mutateVaultFile(secretsFile(tenant, workspace), (all) => {
|
|
857
|
+
const sites = all[name]?.sites;
|
|
858
|
+
if (sites?.some((site) => site.startsWith("*."))) {
|
|
859
|
+
throw new Error(`${name} is bound to ${sites.join(", ")} — a client certificate is bound to exact hosts; unbind the wildcard first`);
|
|
860
|
+
}
|
|
861
|
+
all[name] = { ...sealed, updatedAt: new Date().toISOString(), kind: "client-cert", ...(sites?.length ? { sites } : {}) };
|
|
862
|
+
return all;
|
|
863
|
+
});
|
|
864
|
+
}
|
|
865
|
+
const IDENTITY_PREFIX = "@identity ";
|
|
866
|
+
export function setIdentitySecret(tenant, name, config, workspace) {
|
|
867
|
+
const audience = typeof config.audience === "string" ? config.audience.trim() : "";
|
|
868
|
+
if (!audience)
|
|
869
|
+
throw new Error("an identity needs an audience — the value your cloud's trust expects in aud");
|
|
870
|
+
if (audience.length > 600 || /[\u0000-\u001f\u007f]/.test(audience))
|
|
871
|
+
throw new Error("audience is one value, at most 600 characters, no control characters");
|
|
872
|
+
setSecret(tenant, name, IDENTITY_PREFIX + JSON.stringify({ audience }), workspace, "identity");
|
|
873
|
+
}
|
|
874
|
+
export function isIdentityValue(value) {
|
|
875
|
+
return value.startsWith(IDENTITY_PREFIX);
|
|
876
|
+
}
|
|
877
|
+
export function identityOf(value) {
|
|
878
|
+
return JSON.parse(value.slice(IDENTITY_PREFIX.length));
|
|
879
|
+
}
|
|
880
|
+
export function isClientCertValue(value) {
|
|
881
|
+
return value.startsWith(CLIENT_CERT_PREFIX);
|
|
882
|
+
}
|
|
883
|
+
/** An environment without the client certificates that were not saved with
|
|
884
|
+
* `export`: those are the egress proxy's to present, never a program's. */
|
|
885
|
+
export function withoutHeldCerts(env) {
|
|
886
|
+
return Object.fromEntries(Object.entries(env).filter(([, v]) => typeof v !== "string" || !isClientCertValue(v) || clientCertOf(v).export === true));
|
|
887
|
+
}
|
|
888
|
+
export function clientCertOf(value) {
|
|
889
|
+
return JSON.parse(value.slice(CLIENT_CERT_PREFIX.length));
|
|
890
|
+
}
|
|
891
|
+
/** `Authorization: Basic …` from a username and password (RFC 7617 §2):
|
|
892
|
+
* UTF-8, no colon in the username, no control characters in either. */
|
|
893
|
+
export function basicAuthorization(username, password) {
|
|
894
|
+
if (typeof username !== "string" || typeof password !== "string")
|
|
895
|
+
throw new Error("basic needs username and password");
|
|
896
|
+
if (!username)
|
|
897
|
+
throw new Error("basic needs a username");
|
|
898
|
+
if (username.includes(":"))
|
|
899
|
+
throw new Error("a Basic username cannot contain a colon (RFC 7617)");
|
|
900
|
+
if (/[\u0000-\u001f\u007f]/.test(username) || /[\u0000-\u001f\u007f]/.test(password))
|
|
901
|
+
throw new Error("Basic credentials cannot contain control characters");
|
|
902
|
+
return `Basic ${Buffer.from(`${username}:${password}`, "utf8").toString("base64")}`;
|
|
903
|
+
}
|
|
740
904
|
export function setApiSecret(tenant, name, config, workspace) {
|
|
741
|
-
|
|
905
|
+
let all = { ...(config.headers ?? {}) };
|
|
906
|
+
if (config.basic) {
|
|
907
|
+
if (Object.keys(all).some((h) => h.toLowerCase() === "authorization")) {
|
|
908
|
+
throw new Error("give either basic or an Authorization header, not both");
|
|
909
|
+
}
|
|
910
|
+
// Credentials in the clear over http are leaked ones: https, or loopback.
|
|
911
|
+
const base = config.base_url?.trim();
|
|
912
|
+
if (base) {
|
|
913
|
+
let u = null;
|
|
914
|
+
try {
|
|
915
|
+
u = new URL(base);
|
|
916
|
+
}
|
|
917
|
+
catch {
|
|
918
|
+
u = null;
|
|
919
|
+
}
|
|
920
|
+
const loopback = u?.protocol === "http:" && (u.hostname === "localhost" || u.hostname === "127.0.0.1");
|
|
921
|
+
if (!u || (u.protocol !== "https:" && !loopback))
|
|
922
|
+
throw new Error("Basic credentials need an https base_url");
|
|
923
|
+
}
|
|
924
|
+
all = { ...all, Authorization: basicAuthorization(config.basic.username, config.basic.password) };
|
|
925
|
+
}
|
|
926
|
+
config = { ...(config.base_url ? { base_url: config.base_url } : {}), headers: all };
|
|
927
|
+
const headers = Object.entries(all);
|
|
742
928
|
if (headers.length === 0)
|
|
743
929
|
throw new Error("api secret needs at least one header");
|
|
744
930
|
for (const [h, v] of headers) {
|
package/package.json
CHANGED
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);
|
package/src/egress.ts
CHANGED
|
@@ -96,6 +96,14 @@ export function hostOf(url: string): string | null {
|
|
|
96
96
|
* from the step's declared secrets and the hosts its tools and provider
|
|
97
97
|
* name; the proxy enforces it per request.
|
|
98
98
|
*/
|
|
99
|
+
/** Which workload a lease is for — the runner's word, never the pod's: what
|
|
100
|
+
* an identity token's subject is built from (oidc-issuer.ts). */
|
|
101
|
+
export interface LeaseWho {
|
|
102
|
+
workspace: string;
|
|
103
|
+
agent: string;
|
|
104
|
+
flow?: string;
|
|
105
|
+
}
|
|
106
|
+
|
|
99
107
|
export interface EgressGrant {
|
|
100
108
|
/** Secret name → the hosts that secret may be substituted for. */
|
|
101
109
|
secrets: Record<string, { value: string; hosts: string[] }>;
|
|
@@ -116,6 +124,9 @@ export interface EgressGrant {
|
|
|
116
124
|
/** Refuse a site off the list (true), or allow it and name it in the
|
|
117
125
|
* trace (false: report only). Set by the platform when it leases. */
|
|
118
126
|
enforce?: boolean;
|
|
127
|
+
/** Identity name → the audience its tokens are for: the only identities
|
|
128
|
+
* this step may ask the proxy's /identity door to mint (oidc-issuer.ts). */
|
|
129
|
+
identities?: Record<string, { audience: string }>;
|
|
119
130
|
}
|
|
120
131
|
|
|
121
132
|
export interface EgressLease {
|
|
@@ -161,7 +172,10 @@ export interface EgressHooks {
|
|
|
161
172
|
* `agent` and `peers` — this step's agent and the other steps of its
|
|
162
173
|
* parallel group (tools: [peers]): the proxy serves the roster and
|
|
163
174
|
* addresses a colleague's message to the right inbox. */
|
|
164
|
-
lease(args: { tenant: string; runId: string; grant: EgressGrant; test?: boolean; step?: number; agent?: string; peers?: PeerInfo[] }): Promise<EgressLease | null>;
|
|
175
|
+
lease(args: { tenant: string; runId: string; grant: EgressGrant; test?: boolean; step?: number; agent?: string; peers?: PeerInfo[]; who?: LeaseWho }): Promise<EgressLease | null>;
|
|
176
|
+
/** The run is still being driven: renew its leases' heartbeat. Optional —
|
|
177
|
+
* an install without leases has nothing to renew. */
|
|
178
|
+
renew?(runId: string): Promise<number>;
|
|
165
179
|
/** The step is over — every attempt, however it ended (tools: [peers]).
|
|
166
180
|
* A lease comes and goes per attempt, so it cannot say this: a sibling
|
|
167
181
|
* between retries, or waiting for a slot, holds none and is not done.
|
package/src/run-container.ts
CHANGED
|
@@ -726,6 +726,36 @@ try {
|
|
|
726
726
|
const peers = input.peers ? buildPeerTools({ channel, self: input.peers.self, siblings: input.peers.siblings, emit }) : { server: null };
|
|
727
727
|
const inbox = channel ? inboxReader(channel, emit) : undefined;
|
|
728
728
|
|
|
729
|
+
// Keyless cloud identities (FOLDRUN_IDENTITIES): each token file kept
|
|
730
|
+
// fresh through the proxy's /identity door, which mints only what this
|
|
731
|
+
// step's lease was granted. Fetched before the step starts, renewed five
|
|
732
|
+
// minutes before it expires (with jitter); a failure is said and retried.
|
|
733
|
+
// Plain JS: this text is the driver, not TypeScript.
|
|
734
|
+
const identityNames = (env.FOLDRUN_IDENTITIES || "").split(",").filter(Boolean);
|
|
735
|
+
if (identityNames.length) {
|
|
736
|
+
// No backslashes: this sits inside a template literal, which eats them.
|
|
737
|
+
const ep = new RegExp("^(https?://[^/]+)/e/([A-Za-z0-9_-]{16,})/?$").exec(env.FOLDRUN_EGRESS || "");
|
|
738
|
+
const dir = env.FOLDRUN_IDENTITY_DIR || "/tmp/foldrun-identity";
|
|
739
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
740
|
+
const keepFresh = async (name) => {
|
|
741
|
+
let wait = 30000;
|
|
742
|
+
try {
|
|
743
|
+
if (!ep) throw new Error("no egress proxy to ask");
|
|
744
|
+
const res = await fetch(ep[1] + "/identity/" + ep[2] + "/" + name, { signal: AbortSignal.timeout(15000) });
|
|
745
|
+
const body = await res.json().catch(() => ({}));
|
|
746
|
+
if (!res.ok || !body.token) throw new Error((body.error && body.error.message) || ("proxy answered " + res.status));
|
|
747
|
+
const file = dir + "/" + name.toLowerCase() + ".jwt";
|
|
748
|
+
fs.writeFileSync(file + ".tmp", body.token, { mode: 0o600 });
|
|
749
|
+
fs.renameSync(file + ".tmp", file);
|
|
750
|
+
wait = Math.max(30000, body.expiresAt - Date.now() - 300000 + Math.floor(Math.random() * 60000) - 30000);
|
|
751
|
+
} catch (err) {
|
|
752
|
+
emit("error", "identity " + name + ": " + (err && err.message ? err.message : String(err)) + " — trying again in 30s");
|
|
753
|
+
}
|
|
754
|
+
setTimeout(() => { void keepFresh(name); }, wait).unref();
|
|
755
|
+
};
|
|
756
|
+
await Promise.all(identityNames.map(keepFresh));
|
|
757
|
+
}
|
|
758
|
+
|
|
729
759
|
mark("tools");
|
|
730
760
|
const outcome = await executeStep({
|
|
731
761
|
agentDir,
|
package/src/runner.ts
CHANGED
|
@@ -90,7 +90,7 @@ import {
|
|
|
90
90
|
import { canReadBack } from "./tool-output.ts";
|
|
91
91
|
import { PromptLayers, promptCacheKey } from "./prompt-layers.ts";
|
|
92
92
|
import { loadCatalog, checkModel, clampEffort, catalogCost, findModel, tokenPriceOf, type Catalog, type TokenPrice } from "./catalog.ts";
|
|
93
|
-
import { resolveSecrets, getSecret, materializeSecrets, secretSites } from "./secrets.ts";
|
|
93
|
+
import { resolveSecrets, getSecret, materializeSecrets, secretSites, isClientCertValue, clientCertOf, withoutHeldCerts, isIdentityValue, identityOf } from "./secrets.ts";
|
|
94
94
|
import { resolveNetwork, networkLine, networkEnforceWanted, proxyEnv, EGRESS_CA_ENV } from "./network.ts";
|
|
95
95
|
import { materializeFileSecrets, cleanupFileSecrets } from "./secret-files.ts";
|
|
96
96
|
import { buildApiTools, secretsUsedByApi } from "./api-tools.ts";
|
|
@@ -1485,7 +1485,8 @@ export function agentContext(
|
|
|
1485
1485
|
// the agent, and the date the platform believes it is.
|
|
1486
1486
|
{
|
|
1487
1487
|
...runtime.env,
|
|
1488
|
-
|
|
1488
|
+
// An unexported client certificate is the proxy's, never a script's.
|
|
1489
|
+
...withoutHeldCerts(secretEnv),
|
|
1489
1490
|
...(identity.runId ? { FOLDRUN_RUN_ID: identity.runId } : {}),
|
|
1490
1491
|
...(identity.agent ? { FOLDRUN_AGENT: identity.agent } : {}),
|
|
1491
1492
|
// And the workspace: a commit or a record made by a shared library
|
|
@@ -1766,6 +1767,9 @@ export function agentContext(
|
|
|
1766
1767
|
// asked for by name fails inside the container with "cannot find" while
|
|
1767
1768
|
// the host path, which uses this same merged spec, works fine.
|
|
1768
1769
|
runtime: { log: runtimeLog, error: runtimeError, executor, spec: runtimeSpec, dispose: runtime.dispose },
|
|
1770
|
+
// Whole: a bound client certificate goes from here into the egress
|
|
1771
|
+
// grant (runStep), which is where it is presented. Programs get theirs
|
|
1772
|
+
// filtered (withoutHeldCerts) where their environments are built.
|
|
1769
1773
|
secretEnv: { ...runtime.env, ...secretEnv },
|
|
1770
1774
|
formatWarning: checkFormatVersion(workspaceFrontmatter(agentDir, tenant).foldrun_version).warning,
|
|
1771
1775
|
providerEnv,
|
|
@@ -2686,11 +2690,27 @@ async function runStep(
|
|
|
2686
2690
|
readAgentsMd(accountDir(tenant))?.data?.network_enforce,
|
|
2687
2691
|
readAgentsMd(workspaceRootOf(agentDir))?.data?.network_enforce,
|
|
2688
2692
|
);
|
|
2693
|
+
// Keyless cloud identities the step declared: the proxy may mint tokens
|
|
2694
|
+
// for these audiences only, for this workload (oidc-issuer.ts).
|
|
2695
|
+
// A test run withholds them as it withholds every credential
|
|
2696
|
+
// (test-mode.ts): a token is a credential, however it is delivered.
|
|
2697
|
+
const declaredIdentities = Object.entries(liveSecrets).filter(([, v]) => typeof v === "string" && isIdentityValue(v));
|
|
2698
|
+
const identitiesHeld = testRun
|
|
2699
|
+
? withholdSecrets(Object.fromEntries(declaredIdentities), {
|
|
2700
|
+
outward: scriptSpecs.some((sc) => sc.outward),
|
|
2701
|
+
allow: scriptSpecs.some((sc) => sc.testMode === "allow"),
|
|
2702
|
+
}).withheld
|
|
2703
|
+
: [];
|
|
2704
|
+
const identities = Object.fromEntries(
|
|
2705
|
+
declaredIdentities.filter(([name]) => !identitiesHeld.includes(name)).map(([name, v]) => [name, { audience: identityOf(v).audience }]),
|
|
2706
|
+
);
|
|
2707
|
+
if (Object.keys(identities).length) grant.identities = identities;
|
|
2689
2708
|
const lease = await platform.egress.lease({
|
|
2690
2709
|
tenant,
|
|
2691
2710
|
runId: runId ?? "adhoc",
|
|
2692
2711
|
grant,
|
|
2693
2712
|
test: testRun,
|
|
2713
|
+
who: { workspace: workspaceName, agent: step.agent },
|
|
2694
2714
|
...(stepIndex !== undefined && stepIndex >= 0 ? { step: stepIndex } : {}),
|
|
2695
2715
|
// tools: [peers] — the roster the proxy serves, and the inboxes a
|
|
2696
2716
|
// colleague's message may be addressed to. Only when granted: a
|
|
@@ -2763,6 +2783,34 @@ async function runStep(
|
|
|
2763
2783
|
push("info", `test: ${name} withheld from the sandbox — TEST_MODE_WITHHELD`, { effect: { kind: "withheld", summary: `${name} withheld from the sandbox` } });
|
|
2764
2784
|
}
|
|
2765
2785
|
}
|
|
2786
|
+
// A keyless identity holds nothing to hand over: the sandbox gets the
|
|
2787
|
+
// path of a token file the driver keeps fresh through the proxy's
|
|
2788
|
+
// /identity door. Without a lease there is no door, and so no token.
|
|
2789
|
+
const identityDeclared = declared.filter((name) => typeof liveSecrets[name] === "string" && isIdentityValue(liveSecrets[name]!));
|
|
2790
|
+
for (const name of identityDeclared) delete sandboxSecrets[name];
|
|
2791
|
+
for (const name of identitiesHeld) {
|
|
2792
|
+
push("info", `test: ${name} withheld — no identity token on a test run`, { effect: { kind: "withheld", summary: `${name} withheld from the sandbox` } });
|
|
2793
|
+
}
|
|
2794
|
+
const identityNames = identityDeclared.filter((name) => !identitiesHeld.includes(name));
|
|
2795
|
+
if (identityNames.length) {
|
|
2796
|
+
if (lease) {
|
|
2797
|
+
const dir = "/tmp/foldrun-identity";
|
|
2798
|
+
sandboxSecrets.FOLDRUN_IDENTITIES = identityNames.join(",");
|
|
2799
|
+
sandboxSecrets.FOLDRUN_IDENTITY_DIR = dir;
|
|
2800
|
+
for (const name of identityNames) sandboxSecrets[`${name}_FILE`] = `${dir}/${name.toLowerCase()}.jwt`;
|
|
2801
|
+
push("info", `identity: ${identityNames.join(", ")} — a short-lived token in $NAME_FILE, renewed by the platform while the step runs`);
|
|
2802
|
+
} else {
|
|
2803
|
+
push("error", `identity: ${identityNames.join(", ")} needs a sandboxed step on the platform — no token here`);
|
|
2804
|
+
}
|
|
2805
|
+
}
|
|
2806
|
+
// A client certificate is the proxy's to present, to the exact sites it
|
|
2807
|
+
// is bound to; never the sandbox's, unless it was saved with `export`.
|
|
2808
|
+
for (const name of declared) {
|
|
2809
|
+
const v = liveSecrets[name];
|
|
2810
|
+
if (typeof v !== "string" || !isClientCertValue(v) || bound.includes(name) || clientCertOf(v).export) continue;
|
|
2811
|
+
delete sandboxSecrets[name];
|
|
2812
|
+
push("info", `secrets: ${name} is a client certificate — the egress proxy presents it to the sites it is bound to (\`foldrun secrets sites ${name} <host>\`); unbound, it stays out of the sandbox`);
|
|
2813
|
+
}
|
|
2766
2814
|
// On a test run the http tools always go through the proxy as
|
|
2767
2815
|
// placeholders, even when a script made the step materialise: the
|
|
2768
2816
|
// proxy is where the outward-write policy lives, and a header
|
|
@@ -3174,7 +3222,20 @@ async function runStep(
|
|
|
3174
3222
|
for (const name of held.withheld) {
|
|
3175
3223
|
push("info", `test: ${name} withheld from scripts — TEST_MODE_WITHHELD`, { effect: { kind: "withheld", summary: `${name} withheld from scripts` } });
|
|
3176
3224
|
}
|
|
3177
|
-
|
|
3225
|
+
// A client certificate not saved with `export` has no proxy here to
|
|
3226
|
+
// present it: it stays out of everything this step runs — tools,
|
|
3227
|
+
// consults, verify commands — not only out of the files.
|
|
3228
|
+
const stepSecrets = Object.fromEntries(
|
|
3229
|
+
Object.entries(held.env).filter(([name, v]) => {
|
|
3230
|
+
if (typeof v === "string" && isIdentityValue(v)) {
|
|
3231
|
+
push("error", `identity: ${name} needs a sandboxed step on the platform — no token here`);
|
|
3232
|
+
return false;
|
|
3233
|
+
}
|
|
3234
|
+
if (typeof v !== "string" || !isClientCertValue(v) || clientCertOf(v).export) return true;
|
|
3235
|
+
push("info", `secrets: ${name} is a client certificate — no egress proxy here to present it; save it with export to use it as files`);
|
|
3236
|
+
return false;
|
|
3237
|
+
}),
|
|
3238
|
+
);
|
|
3178
3239
|
// The model credential for this machine's Claude Code — the agent's
|
|
3179
3240
|
// own provider when it names one. The host allowlist drops it, so
|
|
3180
3241
|
// without this every in-process step ran on the machine's login.
|
|
@@ -4038,7 +4099,14 @@ export function driveRun(
|
|
|
4038
4099
|
opts: { parkOnApproval?: boolean; effortOverride?: string | null } = {},
|
|
4039
4100
|
): Promise<void> {
|
|
4040
4101
|
drivingRuns.add(run.id);
|
|
4102
|
+
// The run's egress leases are renewed while it is driven, by whichever
|
|
4103
|
+
// door drove it — the queue, or a reconcile that picked it back up — so an
|
|
4104
|
+
// identity token is renewed for a live step however it was started.
|
|
4105
|
+
const renew = platform.egress.renew;
|
|
4106
|
+
const beat = renew ? setInterval(() => void renew(run.id).catch(() => undefined), 5 * 60_000) : null;
|
|
4107
|
+
beat?.unref?.();
|
|
4041
4108
|
return driveRunInner(tenant, workspace, run, modelOverride, tags, opts).finally(() => {
|
|
4109
|
+
if (beat) clearInterval(beat);
|
|
4042
4110
|
drivingRuns.delete(run.id);
|
|
4043
4111
|
});
|
|
4044
4112
|
}
|
package/src/secret-files.ts
CHANGED
|
@@ -21,7 +21,7 @@ import path from "node:path";
|
|
|
21
21
|
import {
|
|
22
22
|
isFileValue, fileContent,
|
|
23
23
|
isSshValue, sshConfigOf,
|
|
24
|
-
isApiValue, apiConfigOf,
|
|
24
|
+
isApiValue, apiConfigOf, isClientCertValue, clientCertOf,
|
|
25
25
|
} from "./secrets.ts";
|
|
26
26
|
|
|
27
27
|
// POSIX single-quoting: the one escape sh actually honours inside ' is none,
|
|
@@ -46,7 +46,7 @@ export function materializeFileSecrets(
|
|
|
46
46
|
env: Record<string, string>,
|
|
47
47
|
): { env: Record<string, string>; dir: string | null } {
|
|
48
48
|
const entries = Object.entries(env).filter(
|
|
49
|
-
([, v]) => typeof v === "string" && (isFileValue(v) || isSshValue(v) || isApiValue(v)),
|
|
49
|
+
([, v]) => typeof v === "string" && (isFileValue(v) || isSshValue(v) || isApiValue(v) || isClientCertValue(v)),
|
|
50
50
|
);
|
|
51
51
|
if (entries.length === 0) return { env, dir: null };
|
|
52
52
|
|
|
@@ -62,6 +62,26 @@ export function materializeFileSecrets(
|
|
|
62
62
|
const file = path.join(dir, name.toLowerCase());
|
|
63
63
|
fs.writeFileSync(file, fileContent(value), { mode: 0o600 });
|
|
64
64
|
out[name] = file;
|
|
65
|
+
} else if (isClientCertValue(value)) {
|
|
66
|
+
// Only an exported certificate reaches here as itself (the runner
|
|
67
|
+
// withholds the rest): its parts as files, $NAME the certificate.
|
|
68
|
+
const c = clientCertOf(value);
|
|
69
|
+
if (!c.export) {
|
|
70
|
+
delete out[name];
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
const cert = path.join(dir, `${name.toLowerCase()}.crt`);
|
|
74
|
+
const key = path.join(dir, `${name.toLowerCase()}.key`);
|
|
75
|
+
fs.writeFileSync(cert, c.cert, { mode: 0o600 });
|
|
76
|
+
fs.writeFileSync(key, c.key, { mode: 0o600 });
|
|
77
|
+
out[name] = cert;
|
|
78
|
+
out[`${name}_CERT`] = cert;
|
|
79
|
+
out[`${name}_KEY`] = key;
|
|
80
|
+
if (c.passphrase) {
|
|
81
|
+
const pass = path.join(dir, `${name.toLowerCase()}.pass`);
|
|
82
|
+
fs.writeFileSync(pass, c.passphrase, { mode: 0o600 });
|
|
83
|
+
out[`${name}_PASSPHRASE_FILE`] = pass;
|
|
84
|
+
}
|
|
65
85
|
} else if (isSshValue(value)) {
|
|
66
86
|
const ssh = sshConfigOf(value);
|
|
67
87
|
const dest = `${ssh.user}@${ssh.host}`;
|