@foldrun/core 9.0.0 → 9.1.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.
@@ -31,8 +31,15 @@ interface StoredSecret {
31
31
  * these sites alone. Metadata, not a value, so it is stored in the clear
32
32
  * beside the ciphertext and survives the value being set again. */
33
33
  sites?: string[];
34
+ /** An ACCOUNT secret limited to these workspaces (GitHub's "selected
35
+ * repositories" for an organisation secret). Absent: every workspace may
36
+ * use it, as before. Every other workspace is answered as if it were not
37
+ * set (getSecret). Metadata like `sites`: in the clear, and in the seal
38
+ * of a bound record. Never on a workspace's own record. */
39
+ workspaces?: string[];
34
40
  /** 2: sealed with the record's place as AAD — account, scope, name, kind,
35
- * key and sites (vaultAad). Absent: the older, unbound form. */
41
+ * key, sites and, when limited, workspaces (vaultAad). Absent: the older,
42
+ * unbound form. */
36
43
  v?: 2;
37
44
  }
38
45
  type SecretsFile = Record<string, StoredSecret>;
@@ -44,7 +51,30 @@ declare const writeBound: () => boolean;
44
51
  */
45
52
  export declare function mutateVaultFile(file: string, change: (all: SecretsFile) => SecretsFile | null): SecretsFile;
46
53
  export declare const SECRET_NAME: RegExp;
47
- export declare function setSecret(tenant: string, name: string, value: string, workspace?: string, kind?: SecretKind): void;
54
+ /**
55
+ * How a `signing_secret:` is shown in an answer, a log line or a delivery
56
+ * record: the name it holds, and only a name (30 Oct 2026). It is meant to
57
+ * name a vault entry, but nothing stopped the value itself being written
58
+ * there, and notify's test send echoed it back to the dashboard as "signed
59
+ * with <it>". Anything that is not a secret name is described, never
60
+ * repeated (foldrun check flags it as secret/signing-secret-value).
61
+ */
62
+ export declare function signingSecretShown(name: string): string;
63
+ /**
64
+ * Which workspaces an ACCOUNT secret may be used in, given with the value so
65
+ * both are sealed in one locked write (30 Oct 2026): a new secret created
66
+ * and limited in two calls was open to every workspace between them, and
67
+ * stayed open if the second failed. `workspaces` undefined keeps what the
68
+ * record has; null opens it to every workspace; a list limits it.
69
+ * `exists` is asked about each name under the vault lock, so a workspace
70
+ * deleted meanwhile is not granted (the delete takes its name off every
71
+ * list under the same lock — retargetSecretWorkspaces).
72
+ */
73
+ export interface SecretLimit {
74
+ workspaces?: string[] | null;
75
+ exists?: (workspace: string) => boolean;
76
+ }
77
+ export declare function setSecret(tenant: string, name: string, value: string, workspace?: string, kind?: SecretKind, limit?: SecretLimit): void;
48
78
  /**
49
79
  * Bind a secret to sites, or unbind it (an empty list). The secret must
50
80
  * exist in that scope. Sites are `api.example.com` or `*.example.com` — no
@@ -71,11 +101,61 @@ export declare function decryptValue(tenant: string, rec: {
71
101
  data: string;
72
102
  k?: "tenant";
73
103
  } | undefined): string | null;
74
- /** Nearest wins: a workspace secret shadows an account one of the same name. */
104
+ /** Nearest wins: a workspace secret shadows an account one of the same name.
105
+ * An account secret limited to some workspaces (setSecretWorkspaces) is
106
+ * absent for every other — the answer a secret that was never set gets, on
107
+ * every path that resolves one for a workspace: runs, tool tests, notify,
108
+ * the egress proxy, MCP servers. Without a workspace it is the account's
109
+ * own use (its model key, its mail sender), which no list limits. */
75
110
  export declare function getSecret(tenant: string, name: string, workspace?: string): {
76
111
  value: string;
77
112
  scope: SecretScope;
78
113
  } | null;
114
+ /** May the ACCOUNT's secret `name` be used for this workspace? True when it
115
+ * is not limited (or not set); for callers that read the account's copy on
116
+ * a workspace's behalf without getSecret's workspace-first lookup — the
117
+ * account's mail sender for a workspace's notifications (notify.ts). */
118
+ export declare function accountSecretUsableIn(tenant: string, name: string, workspace: string): boolean;
119
+ /**
120
+ * Limit an account secret to some workspaces, or lift the limit (null).
121
+ * An empty list means no workspace may use it. Returns the list as stored,
122
+ * sorted, or null.
123
+ *
124
+ * `exists` is asked about each name under the vault lock (the API passes
125
+ * "is there such a workspace"), so it cannot grant a name a delete has just
126
+ * taken off every list. `ifWorkspaces`, when given, is the list the caller
127
+ * saw (null: every workspace): a list changed since is refused (409), so
128
+ * two admins editing it at once do not silently overwrite each other.
129
+ *
130
+ * The record is sealed (v2) whatever it was, whatever
131
+ * FOLDRUN_VAULT_WRITE_BOUND says: a list in the clear on an unbound record
132
+ * could be stripped on disk to open the secret to every workspace; sealed,
133
+ * a stripped list makes it unreadable. Every reader since 29 Oct 2026 reads v2.
134
+ */
135
+ export declare function setSecretWorkspaces(tenant: string, name: string, workspaces: string[] | null, opts?: {
136
+ exists?: (workspace: string) => boolean;
137
+ ifWorkspaces?: string[] | null;
138
+ }): string[] | null;
139
+ /**
140
+ * A workspace is being renamed (to: its new name) or deleted (to: null):
141
+ * every account secret limited to it follows. Without this a rename quietly
142
+ * cut the workspace off its secrets, and a delete left the name on the list
143
+ * for the next workspace given it.
144
+ *
145
+ * `failed` names the records that hold the name but will not open with this
146
+ * install's keys: they are left exactly as they are, so the old name is
147
+ * STILL on their list — a key restored later would bring the grant back.
148
+ * The caller must not report the rename or delete as clean while any are
149
+ * left (the API refuses it, naming them: set them again, or delete them).
150
+ *
151
+ * Names, not ids: a grant naming a workspace's immutable id would survive a
152
+ * rename untouched and could never pass to a new workspace given an old
153
+ * name — the stronger design, not built yet (30 Oct 2026).
154
+ */
155
+ export declare function retargetSecretWorkspaces(tenant: string, from: string, to: string | null): {
156
+ changed: string[];
157
+ failed: string[];
158
+ };
79
159
  export declare function deleteSecret(tenant: string, name: string, workspace?: string): void;
80
160
  export interface SecretEntry {
81
161
  name: string;
@@ -89,6 +169,8 @@ export interface SecretEntry {
89
169
  /** The sites it is bound to — the proxy fills it for these and no other,
90
170
  * and it never enters a sandbox. Absent when unbound. */
91
171
  sites?: string[];
172
+ /** An account secret limited to these workspaces; absent: every one. */
173
+ workspaces?: string[];
92
174
  }
93
175
  export declare function listSecrets(tenant: string, workspace?: string): SecretEntry[];
94
176
  export declare function resolveSecrets(tenant: string, names: string[], workspace?: string): {
@@ -154,7 +236,7 @@ export interface OAuth2Config {
154
236
  export declare const OAUTH_REFRESH_TRIES = 3;
155
237
  /** Wait before try n (1-based retry): 1s, 3s — FOLDRUN_OAUTH_RETRY_MS scales it for tests. */
156
238
  export declare function oauthRetryDelayMs(n: number): number;
157
- export declare function setOAuth2Secret(tenant: string, name: string, config: OAuth2Config, workspace?: string): void;
239
+ export declare function setOAuth2Secret(tenant: string, name: string, config: OAuth2Config, workspace?: string, limit?: SecretLimit): void;
158
240
  export declare function isOAuth2Value(value: string): boolean;
159
241
  /**
160
242
  * The cache key for a live token: SHA-256 over the kind, the account and the
@@ -205,8 +287,8 @@ export interface ServiceAccountConfig {
205
287
  * a Workspace admin grants it. Absent, the account acts as itself. */
206
288
  subject?: string;
207
289
  }
208
- export declare function setServiceAccountSecret(tenant: string, name: string, config: ServiceAccountConfig, workspace?: string): void;
209
- export declare function setFileSecret(tenant: string, name: string, content: string, workspace?: string): void;
290
+ export declare function setServiceAccountSecret(tenant: string, name: string, config: ServiceAccountConfig, workspace?: string, limit?: SecretLimit): void;
291
+ export declare function setFileSecret(tenant: string, name: string, content: string, workspace?: string, limit?: SecretLimit): void;
210
292
  export declare function isFileValue(value: string): boolean;
211
293
  export declare function fileContent(value: string): string;
212
294
  export interface SshConfig {
@@ -217,7 +299,7 @@ export interface SshConfig {
217
299
  private_key?: string;
218
300
  password?: string;
219
301
  }
220
- export declare function setSshSecret(tenant: string, name: string, config: SshConfig, workspace?: string): void;
302
+ export declare function setSshSecret(tenant: string, name: string, config: SshConfig, workspace?: string, limit?: SecretLimit): void;
221
303
  export declare function isSshValue(value: string): boolean;
222
304
  export declare function sshConfigOf(value: string): SshConfig;
223
305
  /** A client certificate for mutual TLS: presented by the egress proxy, on its
@@ -232,14 +314,14 @@ export interface ClientCertConfig {
232
314
  passphrase?: string;
233
315
  export?: boolean;
234
316
  }
235
- export declare function setClientCertSecret(tenant: string, name: string, config: ClientCertConfig, workspace?: string): void;
317
+ export declare function setClientCertSecret(tenant: string, name: string, config: ClientCertConfig, workspace?: string, limit?: SecretLimit): void;
236
318
  /** A keyless cloud identity: no credential at all, only the audience the
237
319
  * platform's signed tokens are for. A step that declares it gets a token
238
320
  * file the sandbox driver keeps fresh through the egress proxy. */
239
321
  export interface IdentityConfig {
240
322
  audience: string;
241
323
  }
242
- export declare function setIdentitySecret(tenant: string, name: string, config: IdentityConfig, workspace?: string): void;
324
+ export declare function setIdentitySecret(tenant: string, name: string, config: IdentityConfig, workspace?: string, limit?: SecretLimit): void;
243
325
  export declare function isIdentityValue(value: string): boolean;
244
326
  export declare function identityOf(value: string): IdentityConfig;
245
327
  export declare function isClientCertValue(value: string): boolean;
@@ -260,7 +342,7 @@ export interface ApiConfig {
260
342
  /** `Authorization: Basic …` from a username and password (RFC 7617 §2):
261
343
  * UTF-8, no colon in the username, no control characters in either. */
262
344
  export declare function basicAuthorization(username: string, password: string): string;
263
- export declare function setApiSecret(tenant: string, name: string, config: ApiConfig, workspace?: string): void;
345
+ export declare function setApiSecret(tenant: string, name: string, config: ApiConfig, workspace?: string, limit?: SecretLimit): void;
264
346
  export declare function isApiValue(value: string): boolean;
265
347
  export declare function apiConfigOf(value: string): ApiConfig;
266
348
  /**
@@ -106,11 +106,43 @@ function secretsFile(tenant, workspace) {
106
106
  // every rename a re-seal racing other writers. What that leaves open — a
107
107
  // ciphertext moved between two workspaces of the same account by someone who
108
108
  // can already write the volume — is the residual, and stays inside one account.
109
+ //
110
+ // Rolling out (30 Oct 2026, accepted, no switch): a pod from before this
111
+ // release cannot open a limited record (its AAD lacks the list), and one that
112
+ // sets a new value over it writes the record back without the list — open to
113
+ // every workspace again. So limits are set after every pod, worker and the
114
+ // egress proxy run this version; the secrets docs say so.
115
+ //
116
+ // The workspaces an account secret is limited to are in the seal too (30 Oct
117
+ // 2026), appended only when the record has the list: every record sealed
118
+ // before keeps the AAD it was sealed with, and a list added to, widened in or
119
+ // stripped from a sealed record on disk makes it fail to open — "not set",
120
+ // never "every workspace". These ARE workspace names, so a rename re-seals
121
+ // them (retargetSecretWorkspaces), under the vault lock.
109
122
  function vaultAad(tenant, workspace, name, rec) {
110
123
  if (rec.sites !== undefined && (!Array.isArray(rec.sites) || rec.sites.some((x) => typeof x !== "string"))) {
111
124
  throw new Error("malformed sites");
112
125
  }
113
- return Buffer.from(JSON.stringify(["foldrun-vault/2", tenant, workspace ? "w" : "a", name, rec.kind ?? "", rec.k ?? "install", [...(rec.sites ?? [])].sort()]), "utf8");
126
+ if (rec.workspaces !== undefined && (!Array.isArray(rec.workspaces) || rec.workspaces.some((x) => typeof x !== "string"))) {
127
+ throw new Error("malformed workspaces");
128
+ }
129
+ return Buffer.from(JSON.stringify([
130
+ "foldrun-vault/2",
131
+ tenant,
132
+ workspace ? "w" : "a",
133
+ name,
134
+ rec.kind ?? "",
135
+ rec.k ?? "install",
136
+ [...(rec.sites ?? [])].sort(),
137
+ ...(rec.workspaces !== undefined ? [["workspaces", [...rec.workspaces].sort()]] : []),
138
+ ]), "utf8");
139
+ }
140
+ /** May an account record be used in this workspace? No list: everywhere. A
141
+ * list that is not a list of names (rewritten on disk) allows nowhere. */
142
+ function usableIn(rec, workspace) {
143
+ if (!rec || rec.workspaces === undefined)
144
+ return true;
145
+ return Array.isArray(rec.workspaces) && rec.workspaces.includes(workspace);
114
146
  }
115
147
  const writeBound = () => process.env.FOLDRUN_VAULT_WRITE_BOUND === "1";
116
148
  const requireBound = () => process.env.FOLDRUN_VAULT_REQUIRE_BOUND === "1";
@@ -126,6 +158,8 @@ function sealRecord(tenant, workspace, name, value, meta, bound = writeBound())
126
158
  ...(meta.kind ? { kind: meta.kind } : {}),
127
159
  ...(mark ? { k: mark } : {}),
128
160
  ...(meta.sites?.length ? { sites: [...meta.sites] } : {}),
161
+ // An empty list is a choice (no workspace may use it), kept as written.
162
+ ...(meta.workspaces !== undefined ? { workspaces: [...meta.workspaces] } : {}),
129
163
  };
130
164
  const iv = crypto.randomBytes(12);
131
165
  const cipher = crypto.createCipheriv("aes-256-gcm", key, iv, GCM);
@@ -237,7 +271,38 @@ export function mutateVaultFile(file, change) {
237
271
  }
238
272
  }
239
273
  export const SECRET_NAME = /^[A-Z][A-Z0-9_]{1,63}$/;
240
- export function setSecret(tenant, name, value, workspace, kind) {
274
+ /**
275
+ * How a `signing_secret:` is shown in an answer, a log line or a delivery
276
+ * record: the name it holds, and only a name (30 Oct 2026). It is meant to
277
+ * name a vault entry, but nothing stopped the value itself being written
278
+ * there, and notify's test send echoed it back to the dashboard as "signed
279
+ * with <it>". Anything that is not a secret name is described, never
280
+ * repeated (foldrun check flags it as secret/signing-secret-value).
281
+ */
282
+ export function signingSecretShown(name) {
283
+ return SECRET_NAME.test(name) ? name : "the signing_secret: (not a secret name — see foldrun check)";
284
+ }
285
+ /** A list as stored: trimmed, de-duplicated, sorted; every name a safe one
286
+ * and, when the caller can say, one that exists. */
287
+ function cleanWorkspaceList(workspaces) {
288
+ if (workspaces === null)
289
+ return null;
290
+ if (!Array.isArray(workspaces) || workspaces.some((w) => typeof w !== "string")) {
291
+ throw new Error("workspaces is a list of workspace names, or null for every workspace");
292
+ }
293
+ const clean = [...new Set(workspaces.map((w) => w.trim()).filter(Boolean))].sort();
294
+ for (const w of clean)
295
+ assertSafeName(w, "workspace");
296
+ return clean;
297
+ }
298
+ function assertWorkspacesExist(list, exists) {
299
+ if (!list || !exists)
300
+ return;
301
+ const missing = list.filter((w) => !exists(w));
302
+ if (missing.length)
303
+ throw Object.assign(new Error(`no workspace ${missing.join(", ")} in this account`), { status: 400 });
304
+ }
305
+ export function setSecret(tenant, name, value, workspace, kind, limit = {}) {
241
306
  // A string, not anything that prints as one: RegExp.test and the vault's
242
307
  // keys both coerce, so ["RESEND_API_KEY"] from a JSON body was stored as
243
308
  // RESEND_API_KEY past a caller's check on the name (30 Oct 2026).
@@ -246,18 +311,35 @@ export function setSecret(tenant, name, value, workspace, kind) {
246
311
  }
247
312
  if (value.length > 8192)
248
313
  throw new Error("secret too long");
314
+ const wanted = limitFor(workspace, limit);
249
315
  mutateVaultFile(secretsFile(tenant, workspace), (all) => {
250
316
  // A new value keeps the sites the old one was bound to: binding is a
251
317
  // property of the credential's use, not of its current value, and a
252
318
  // rotation that silently unbound it would put the new value in pods.
253
319
  // Sealed here, under the lock: a bound record's AAD names those sites.
254
320
  const sites = all[name]?.sites;
321
+ // The workspaces it is limited to stay too, for the same reason: a new
322
+ // value that every workspace could suddenly read is a widening nobody
323
+ // asked for — unless this call says otherwise, in the same write.
324
+ if (wanted !== undefined)
325
+ assertWorkspacesExist(wanted, limit.exists);
326
+ const workspaces = wanted !== undefined ? (wanted ?? undefined) : all[name]?.workspaces;
255
327
  // Once bound, always bound: a re-save with the switch off must not
256
- // quietly drop the record's authentication.
257
- all[name] = sealRecord(tenant, workspace, name, value, { kind, sites }, all[name]?.v === 2 || writeBound());
328
+ // quietly drop the record's authentication. A limited record is always
329
+ // bound, so its list is in its seal (see setSecretWorkspaces).
330
+ all[name] = sealRecord(tenant, workspace, name, value, { kind, sites, workspaces }, all[name]?.v === 2 || workspaces !== undefined || writeBound());
258
331
  return all;
259
332
  });
260
333
  }
334
+ /** The list a write was asked to store, checked: undefined when it was not
335
+ * asked to change it. Only the account's store has lists. */
336
+ function limitFor(workspace, limit) {
337
+ if (limit.workspaces === undefined)
338
+ return undefined;
339
+ if (workspace)
340
+ throw new Error("a workspace's own secret is that workspace's already — only an account secret is limited to workspaces");
341
+ return cleanWorkspaceList(limit.workspaces);
342
+ }
261
343
  /**
262
344
  * Bind a secret to sites, or unbind it (an empty list). The secret must
263
345
  * exist in that scope. Sites are `api.example.com` or `*.example.com` — no
@@ -296,7 +378,7 @@ export function setSecretSites(tenant, name, sites, workspace) {
296
378
  const value = openRecord(tenant, workspace, name, rec);
297
379
  if (value === null)
298
380
  throw new Error(`${name} cannot be read with this install's keys — set its value again before binding it`);
299
- all[name] = sealRecord(tenant, workspace, name, value, { kind: rec.kind, sites: clean, updatedAt: rec.updatedAt }, true);
381
+ all[name] = sealRecord(tenant, workspace, name, value, { kind: rec.kind, sites: clean, workspaces: rec.workspaces, updatedAt: rec.updatedAt }, true);
300
382
  return all;
301
383
  }
302
384
  if (clean.length)
@@ -323,7 +405,11 @@ export function secretSites(tenant, names, workspace) {
323
405
  // when it opens, else the account's. Picking by presence paired an
324
406
  // account value with a workspace record's (unauthenticated) sites.
325
407
  const ownRec = own[name];
326
- const rec = ownRec && openRecord(tenant, workspace, name, ownRec) !== null ? ownRec : account[name];
408
+ const ownOpens = Boolean(ownRec && openRecord(tenant, workspace, name, ownRec) !== null);
409
+ // An account record limited to other workspaces is not this one's.
410
+ if (!ownOpens && workspace && !usableIn(account[name], workspace))
411
+ continue;
412
+ const rec = ownOpens ? ownRec : account[name];
327
413
  if (rec?.sites?.length)
328
414
  out[name] = [...rec.sites];
329
415
  }
@@ -398,16 +484,141 @@ function decrypt(tenant, rec, aad) {
398
484
  return null; // key rotated or file tampered with
399
485
  }
400
486
  }
401
- /** Nearest wins: a workspace secret shadows an account one of the same name. */
487
+ /** Nearest wins: a workspace secret shadows an account one of the same name.
488
+ * An account secret limited to some workspaces (setSecretWorkspaces) is
489
+ * absent for every other — the answer a secret that was never set gets, on
490
+ * every path that resolves one for a workspace: runs, tool tests, notify,
491
+ * the egress proxy, MCP servers. Without a workspace it is the account's
492
+ * own use (its model key, its mail sender), which no list limits. */
402
493
  export function getSecret(tenant, name, workspace) {
403
494
  if (workspace) {
404
495
  const own = openRecord(tenant, workspace, name, read(tenant, workspace)[name]);
405
496
  if (own !== null)
406
497
  return { value: own, scope: "workspace" };
407
498
  }
408
- const shared = openRecord(tenant, undefined, name, read(tenant)[name]);
499
+ const rec = read(tenant)[name];
500
+ if (workspace && !usableIn(rec, workspace))
501
+ return null;
502
+ const shared = openRecord(tenant, undefined, name, rec);
409
503
  return shared === null ? null : { value: shared, scope: "account" };
410
504
  }
505
+ /** May the ACCOUNT's secret `name` be used for this workspace? True when it
506
+ * is not limited (or not set); for callers that read the account's copy on
507
+ * a workspace's behalf without getSecret's workspace-first lookup — the
508
+ * account's mail sender for a workspace's notifications (notify.ts). */
509
+ export function accountSecretUsableIn(tenant, name, workspace) {
510
+ return usableIn(read(tenant)[name], workspace);
511
+ }
512
+ /**
513
+ * Limit an account secret to some workspaces, or lift the limit (null).
514
+ * An empty list means no workspace may use it. Returns the list as stored,
515
+ * sorted, or null.
516
+ *
517
+ * `exists` is asked about each name under the vault lock (the API passes
518
+ * "is there such a workspace"), so it cannot grant a name a delete has just
519
+ * taken off every list. `ifWorkspaces`, when given, is the list the caller
520
+ * saw (null: every workspace): a list changed since is refused (409), so
521
+ * two admins editing it at once do not silently overwrite each other.
522
+ *
523
+ * The record is sealed (v2) whatever it was, whatever
524
+ * FOLDRUN_VAULT_WRITE_BOUND says: a list in the clear on an unbound record
525
+ * could be stripped on disk to open the secret to every workspace; sealed,
526
+ * a stripped list makes it unreadable. Every reader since 29 Oct 2026 reads v2.
527
+ */
528
+ export function setSecretWorkspaces(tenant, name, workspaces, opts = {}) {
529
+ if (typeof name !== "string")
530
+ throw new Error("a secret's name is a string");
531
+ const clean = cleanWorkspaceList(workspaces);
532
+ const expected = opts.ifWorkspaces === undefined ? undefined : cleanWorkspaceList(opts.ifWorkspaces);
533
+ let found = false;
534
+ mutateVaultFile(secretsFile(tenant), (all) => {
535
+ const rec = all[name];
536
+ if (!rec)
537
+ return null;
538
+ found = true;
539
+ if (expected !== undefined) {
540
+ const now = Array.isArray(rec.workspaces) ? [...rec.workspaces].sort() : null;
541
+ if (JSON.stringify(now) !== JSON.stringify(expected)) {
542
+ throw Object.assign(new Error(`${name}'s workspaces changed since you loaded them (now ${now === null ? "every workspace" : now.length ? now.join(", ") : "none"}) — reload and try again`), { status: 409 });
543
+ }
544
+ }
545
+ assertWorkspacesExist(clean, opts.exists);
546
+ all[name] = withWorkspaces(tenant, name, rec, clean);
547
+ return all;
548
+ });
549
+ if (!found)
550
+ throw Object.assign(new Error(`no secret ${name} in the account`), { status: 404 });
551
+ return clean;
552
+ }
553
+ /** An account record with its workspace list replaced (null: removed),
554
+ * opened and sealed again as a bound record with the new list in its seal.
555
+ * One that will not open is refused, never relabelled (setSecretSites). */
556
+ function withWorkspaces(tenant, name, rec, workspaces) {
557
+ const value = openRecord(tenant, undefined, name, rec);
558
+ if (value === null)
559
+ throw new Error(`${name} cannot be read with this install's keys — set its value again before changing its workspaces`);
560
+ return sealRecord(tenant, undefined, name, value, { kind: rec.kind, sites: rec.sites, workspaces: workspaces ?? undefined, updatedAt: rec.updatedAt }, true);
561
+ }
562
+ /**
563
+ * A workspace is being renamed (to: its new name) or deleted (to: null):
564
+ * every account secret limited to it follows. Without this a rename quietly
565
+ * cut the workspace off its secrets, and a delete left the name on the list
566
+ * for the next workspace given it.
567
+ *
568
+ * `failed` names the records that hold the name but will not open with this
569
+ * install's keys: they are left exactly as they are, so the old name is
570
+ * STILL on their list — a key restored later would bring the grant back.
571
+ * The caller must not report the rename or delete as clean while any are
572
+ * left (the API refuses it, naming them: set them again, or delete them).
573
+ *
574
+ * Names, not ids: a grant naming a workspace's immutable id would survive a
575
+ * rename untouched and could never pass to a new workspace given an old
576
+ * name — the stronger design, not built yet (30 Oct 2026).
577
+ */
578
+ export function retargetSecretWorkspaces(tenant, from, to) {
579
+ const changed = [];
580
+ const failed = [];
581
+ const file = secretsFile(tenant);
582
+ if (!fs.existsSync(file))
583
+ return { changed, failed };
584
+ mutateVaultFile(file, (all) => {
585
+ changed.length = 0;
586
+ failed.length = 0;
587
+ const staged = {};
588
+ for (const [name, rec] of Object.entries(all)) {
589
+ const list = rec.workspaces;
590
+ if (list === undefined)
591
+ continue;
592
+ if (!Array.isArray(list)) {
593
+ // A list rewritten on disk into something else allows nowhere
594
+ // (usableIn) — nothing for this rename to carry.
595
+ continue;
596
+ }
597
+ if (!list.includes(from))
598
+ continue;
599
+ const next = [...new Set(list.map((w) => (w === from ? to : w)).filter((w) => typeof w === "string"))].sort();
600
+ try {
601
+ staged[name] = withWorkspaces(tenant, name, rec, next);
602
+ changed.push(name);
603
+ }
604
+ catch {
605
+ failed.push(name);
606
+ }
607
+ }
608
+ // All or nothing (Codex, 30 Oct 2026): writing the readable records and
609
+ // refusing the rename for the unreadable ones moved grants to a name the
610
+ // workspace never took. One failure leaves the vault exactly as it was.
611
+ if (failed.length) {
612
+ changed.length = 0;
613
+ return null;
614
+ }
615
+ if (!changed.length)
616
+ return null;
617
+ Object.assign(all, staged);
618
+ return all;
619
+ });
620
+ return { changed, failed };
621
+ }
411
622
  export function deleteSecret(tenant, name, workspace) {
412
623
  mutateVaultFile(secretsFile(tenant, workspace), (all) => {
413
624
  if (!(name in all))
@@ -420,12 +631,18 @@ export function deleteSecret(tenant, name, workspace) {
420
631
  // store; with one it lists what that workspace actually resolves, marking
421
632
  // which scope each name comes from.
422
633
  export function listSecrets(tenant, workspace) {
423
- const account = Object.entries(read(tenant)).map(([name, rec]) => ({
634
+ const records = Object.entries(read(tenant));
635
+ const account = records
636
+ // What a workspace resolves leaves out an account secret limited to
637
+ // others: it is not set, as far as that workspace is concerned.
638
+ .filter(([, rec]) => !workspace || usableIn(rec, workspace))
639
+ .map(([name, rec]) => ({
424
640
  name,
425
641
  updatedAt: rec.updatedAt,
426
642
  scope: "account",
427
643
  ...(rec.kind ? { kind: rec.kind } : {}),
428
644
  ...(rec.sites?.length ? { sites: [...rec.sites] } : {}),
645
+ ...(!workspace && Array.isArray(rec.workspaces) ? { workspaces: [...rec.workspaces] } : {}),
429
646
  }));
430
647
  if (!workspace)
431
648
  return account.sort((a, b) => a.name.localeCompare(b.name));
@@ -559,6 +776,9 @@ export function rotateMasterKey(tenant, from, to) {
559
776
  // Kept too (it was dropped until 29 Oct 2026): a rotation that
560
777
  // unbinds every bound secret puts their values into pods.
561
778
  ...(rec.sites?.length ? { sites: rec.sites } : {}),
779
+ // And the workspaces it is limited to: dropped, it would read as
780
+ // "every workspace" (and fail its seal).
781
+ ...(rec.workspaces !== undefined ? { workspaces: rec.workspaces } : {}),
562
782
  ...(aad ? { v: 2 } : {}),
563
783
  };
564
784
  count++;
@@ -578,7 +798,7 @@ export function oauthRetryDelayMs(n) {
578
798
  const base = Number(process.env.FOLDRUN_OAUTH_RETRY_MS ?? 1000);
579
799
  return base * (n === 1 ? 1 : 3);
580
800
  }
581
- export function setOAuth2Secret(tenant, name, config, workspace) {
801
+ export function setOAuth2Secret(tenant, name, config, workspace, limit = {}) {
582
802
  for (const field of ["token_url", "client_id"]) {
583
803
  if (!config[field]?.trim())
584
804
  throw new Error(`oauth2 secret needs ${field}`);
@@ -596,7 +816,7 @@ export function setOAuth2Secret(tenant, name, config, workspace) {
596
816
  if (!/^https:\/\//.test(config.token_url) && !/^http:\/\/(127\.0\.0\.1|localhost)[:/]/.test(config.token_url)) {
597
817
  throw new Error("token_url must be https — a refresh token over http is a leaked one");
598
818
  }
599
- setSecret(tenant, name, OAUTH2_PREFIX + JSON.stringify(config), workspace, "oauth2");
819
+ setSecret(tenant, name, OAUTH2_PREFIX + JSON.stringify(config), workspace, "oauth2", limit);
600
820
  }
601
821
  export function isOAuth2Value(value) {
602
822
  return value.startsWith(OAUTH2_PREFIX);
@@ -638,7 +858,7 @@ export function replaceOAuth2RefreshToken(tenant, name, workspace, usedValue, re
638
858
  // Built whole, not spread over the old record: a spread kept a stale
639
859
  // `k: "tenant"` when this seal fell back to the install key, and the
640
860
  // record then opened with neither.
641
- all[name] = sealRecord(tenant, scope, name, next, { kind: rec.kind, sites: rec.sites }, rec.v === 2 || writeBound());
861
+ all[name] = sealRecord(tenant, scope, name, next, { kind: rec.kind, sites: rec.sites, workspaces: rec.workspaces }, rec.v === 2 || writeBound());
642
862
  replaced = true;
643
863
  }
644
864
  return all;
@@ -808,7 +1028,7 @@ ctx) {
808
1028
  // Stored as the JSON key, encrypted. Materialised the same way oauth2 is: a
809
1029
  // live access token appears at the last host-side moment, cached until expiry.
810
1030
  const SERVICE_ACCOUNT_PREFIX = "@service-account ";
811
- export function setServiceAccountSecret(tenant, name, config, workspace) {
1031
+ export function setServiceAccountSecret(tenant, name, config, workspace, limit = {}) {
812
1032
  for (const field of ["token_url", "issuer", "private_key", "scope"]) {
813
1033
  if (!config[field]?.trim())
814
1034
  throw new Error(`service account secret needs ${field}`);
@@ -823,7 +1043,7 @@ export function setServiceAccountSecret(tenant, name, config, workspace) {
823
1043
  throw new Error("subject is one identifier, no spaces or control characters");
824
1044
  }
825
1045
  const stored = { ...config, ...(config.subject !== undefined ? { subject: config.subject.trim() } : {}) };
826
- setSecret(tenant, name, SERVICE_ACCOUNT_PREFIX + JSON.stringify(stored), workspace, "service-account");
1046
+ setSecret(tenant, name, SERVICE_ACCOUNT_PREFIX + JSON.stringify(stored), workspace, "service-account", limit);
827
1047
  }
828
1048
  function base64url(input) {
829
1049
  return Buffer.from(input).toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
@@ -886,10 +1106,10 @@ async function exchangeServiceAccount(config, cacheKey) {
886
1106
  // the only place that knows where a run's writable scratch is; here we only
887
1107
  // mark the kind and carry the content.
888
1108
  const FILE_PREFIX = "@file ";
889
- export function setFileSecret(tenant, name, content, workspace) {
1109
+ export function setFileSecret(tenant, name, content, workspace, limit = {}) {
890
1110
  if (!content)
891
1111
  throw new Error("a file secret needs content");
892
- setSecret(tenant, name, FILE_PREFIX + content, workspace, "file");
1112
+ setSecret(tenant, name, FILE_PREFIX + content, workspace, "file", limit);
893
1113
  }
894
1114
  export function isFileValue(value) {
895
1115
  return value.startsWith(FILE_PREFIX);
@@ -905,7 +1125,7 @@ export function fileContent(value) {
905
1125
  // config into an executable wrapper, so the agent runs `"$NAME" 'uptime'`
906
1126
  // and never learns which auth flavour it is.
907
1127
  const SSH_PREFIX = "@ssh ";
908
- export function setSshSecret(tenant, name, config, workspace) {
1128
+ export function setSshSecret(tenant, name, config, workspace, limit = {}) {
909
1129
  const host = config.host?.trim();
910
1130
  const user = config.user?.trim();
911
1131
  if (!host || /[\s'"`$\\]/.test(host))
@@ -928,7 +1148,7 @@ export function setSshSecret(tenant, name, config, workspace) {
928
1148
  host, user, port,
929
1149
  ...(hasKey ? { private_key: config.private_key } : { password: config.password }),
930
1150
  };
931
- setSecret(tenant, name, SSH_PREFIX + JSON.stringify(stored), workspace, "ssh");
1151
+ setSecret(tenant, name, SSH_PREFIX + JSON.stringify(stored), workspace, "ssh", limit);
932
1152
  }
933
1153
  export function isSshValue(value) {
934
1154
  return value.startsWith(SSH_PREFIX);
@@ -944,7 +1164,7 @@ export function sshConfigOf(value) {
944
1164
  // never appears in the bash command at all.
945
1165
  const API_PREFIX = "@api ";
946
1166
  const CLIENT_CERT_PREFIX = "@client-cert ";
947
- export function setClientCertSecret(tenant, name, config, workspace) {
1167
+ export function setClientCertSecret(tenant, name, config, workspace, limit = {}) {
948
1168
  if (!/-----BEGIN CERTIFICATE-----/.test(config.cert ?? ""))
949
1169
  throw new Error("cert must be a PEM certificate");
950
1170
  if (!/-----BEGIN [A-Z ]{0,40}PRIVATE KEY-----/.test(config.key ?? ""))
@@ -973,23 +1193,27 @@ export function setClientCertSecret(tenant, name, config, workspace) {
973
1193
  // Written under the vault's own lock, with the binding checked in the same
974
1194
  // step: a value set over a secret keeps its site binding, and a wildcard
975
1195
  // one would present the certificate to a whole domain.
1196
+ const wanted = limitFor(workspace, limit);
976
1197
  mutateVaultFile(secretsFile(tenant, workspace), (all) => {
977
1198
  const sites = all[name]?.sites;
1199
+ if (wanted !== undefined)
1200
+ assertWorkspacesExist(wanted, limit.exists);
1201
+ const workspaces = wanted !== undefined ? (wanted ?? undefined) : all[name]?.workspaces;
978
1202
  if (sites?.some((site) => site.startsWith("*."))) {
979
1203
  throw new Error(`${name} is bound to ${sites.join(", ")} — a client certificate is bound to exact hosts; unbind the wildcard first`);
980
1204
  }
981
- all[name] = sealRecord(tenant, workspace, name, plainCert, { kind: "client-cert", sites }, all[name]?.v === 2 || writeBound());
1205
+ all[name] = sealRecord(tenant, workspace, name, plainCert, { kind: "client-cert", sites, workspaces }, all[name]?.v === 2 || workspaces !== undefined || writeBound());
982
1206
  return all;
983
1207
  });
984
1208
  }
985
1209
  const IDENTITY_PREFIX = "@identity ";
986
- export function setIdentitySecret(tenant, name, config, workspace) {
1210
+ export function setIdentitySecret(tenant, name, config, workspace, limit = {}) {
987
1211
  const audience = typeof config.audience === "string" ? config.audience.trim() : "";
988
1212
  if (!audience)
989
1213
  throw new Error("an identity needs an audience — the value your cloud's trust expects in aud");
990
1214
  if (audience.length > 600 || /[\u0000-\u001f\u007f]/.test(audience))
991
1215
  throw new Error("audience is one value, at most 600 characters, no control characters");
992
- setSecret(tenant, name, IDENTITY_PREFIX + JSON.stringify({ audience }), workspace, "identity");
1216
+ setSecret(tenant, name, IDENTITY_PREFIX + JSON.stringify({ audience }), workspace, "identity", limit);
993
1217
  }
994
1218
  export function isIdentityValue(value) {
995
1219
  return value.startsWith(IDENTITY_PREFIX);
@@ -1021,7 +1245,7 @@ export function basicAuthorization(username, password) {
1021
1245
  throw new Error("Basic credentials cannot contain control characters");
1022
1246
  return `Basic ${Buffer.from(`${username}:${password}`, "utf8").toString("base64")}`;
1023
1247
  }
1024
- export function setApiSecret(tenant, name, config, workspace) {
1248
+ export function setApiSecret(tenant, name, config, workspace, limit = {}) {
1025
1249
  let all = { ...(config.headers ?? {}) };
1026
1250
  if (config.basic) {
1027
1251
  if (Object.keys(all).some((h) => h.toLowerCase() === "authorization")) {
@@ -1056,7 +1280,7 @@ export function setApiSecret(tenant, name, config, workspace) {
1056
1280
  const base = config.base_url?.trim();
1057
1281
  if (base && !/^https?:\/\//.test(base))
1058
1282
  throw new Error("base_url must be http(s)");
1059
- setSecret(tenant, name, API_PREFIX + JSON.stringify({ ...(base ? { base_url: trimSlashes(base) } : {}), headers: config.headers }), workspace, "api");
1283
+ setSecret(tenant, name, API_PREFIX + JSON.stringify({ ...(base ? { base_url: trimSlashes(base) } : {}), headers: config.headers }), workspace, "api", limit);
1060
1284
  }
1061
1285
  export function isApiValue(value) {
1062
1286
  return value.startsWith(API_PREFIX);
@@ -1094,7 +1318,7 @@ export function sealVaultBindings(tenant) {
1094
1318
  const next = { ...all };
1095
1319
  for (const [name, value] of plain) {
1096
1320
  const rec = all[name];
1097
- next[name] = sealRecord(tenant, workspace, name, value, { kind: rec.kind, sites: rec.sites, updatedAt: rec.updatedAt }, true);
1321
+ next[name] = sealRecord(tenant, workspace, name, value, { kind: rec.kind, sites: rec.sites, workspaces: rec.workspaces, updatedAt: rec.updatedAt }, true);
1098
1322
  }
1099
1323
  sealed += plain.size;
1100
1324
  return next;
@@ -1142,7 +1366,7 @@ export function rewrapTenantSecrets(tenant) {
1142
1366
  return null;
1143
1367
  const next = { ...all };
1144
1368
  for (const [name, value] of plain) {
1145
- next[name] = sealRecord(tenant, workspace, name, value, { kind: all[name].kind, sites: all[name].sites, updatedAt: all[name].updatedAt }, all[name].v === 2 || writeBound());
1369
+ next[name] = sealRecord(tenant, workspace, name, value, { kind: all[name].kind, sites: all[name].sites, workspaces: all[name].workspaces, updatedAt: all[name].updatedAt }, all[name].v === 2 || writeBound());
1146
1370
  }
1147
1371
  moved += plain.size;
1148
1372
  return next;