@foldrun/core 9.0.0 → 9.2.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/a2a-client.d.ts +165 -0
- package/dist/src/a2a-client.js +440 -0
- package/dist/src/a2a-publish.d.ts +37 -0
- package/dist/src/a2a-publish.js +116 -0
- package/dist/src/a2a-remotes.d.ts +73 -0
- package/dist/src/a2a-remotes.js +192 -0
- package/dist/src/a2a-tools.d.ts +148 -0
- package/dist/src/a2a-tools.js +833 -0
- package/dist/src/api-tools.js +15 -10
- package/dist/src/approvals.d.ts +18 -10
- package/dist/src/approvals.js +51 -1
- package/dist/src/browser-pod.js +4 -0
- package/dist/src/catalog.d.ts +14 -0
- package/dist/src/catalog.js +28 -0
- package/dist/src/completions.js +8 -8
- package/dist/src/cron.d.ts +9 -0
- package/dist/src/cron.js +20 -0
- package/dist/src/frontmatter-fields.js +3 -1
- package/dist/src/frontmatter-schema.d.ts +5 -1
- package/dist/src/frontmatter-schema.js +44 -1
- package/dist/src/limits.js +22 -4
- package/dist/src/notify.d.ts +6 -3
- package/dist/src/notify.js +25 -12
- package/dist/src/package.d.ts +5 -1
- package/dist/src/package.js +5 -3
- package/dist/src/run-container.d.ts +10 -1
- package/dist/src/run-container.js +17 -0
- package/dist/src/runner.d.ts +10 -0
- package/dist/src/runner.js +163 -16
- package/dist/src/scan.js +123 -5
- package/dist/src/secrets.d.ts +122 -10
- package/dist/src/secrets.js +291 -27
- package/dist/src/starter.js +5 -1
- package/dist/src/store.d.ts +34 -0
- package/dist/src/store.js +28 -7
- package/dist/src/subagents.d.ts +16 -0
- package/dist/src/subagents.js +23 -2
- package/dist/src/triggers.js +2 -2
- package/dist/src/webhook.d.ts +19 -3
- package/dist/src/webhook.js +14 -5
- package/package.json +4 -1
- package/src/a2a-client.ts +517 -0
- package/src/a2a-publish.ts +120 -0
- package/src/a2a-remotes.ts +225 -0
- package/src/a2a-tools.ts +919 -0
- package/src/api-tools.ts +20 -8
- package/src/approvals.ts +55 -1
- package/src/browser-pod.ts +3 -0
- package/src/catalog.ts +27 -0
- package/src/completions.ts +8 -8
- package/src/cron.ts +19 -0
- package/src/frontmatter-fields.ts +3 -1
- package/src/frontmatter-schema.ts +42 -2
- package/src/limits.ts +22 -4
- package/src/notify.ts +23 -12
- package/src/package.ts +5 -3
- package/src/run-container.ts +22 -0
- package/src/runner.ts +157 -13
- package/src/scan.ts +146 -6
- package/src/secrets.ts +319 -33
- package/src/starter.ts +5 -1
- package/src/store.ts +47 -2
- package/src/subagents.ts +29 -2
- package/src/triggers.ts +2 -2
- package/src/webhook.ts +22 -5
package/src/secrets.ts
CHANGED
|
@@ -111,8 +111,15 @@ interface StoredSecret {
|
|
|
111
111
|
* these sites alone. Metadata, not a value, so it is stored in the clear
|
|
112
112
|
* beside the ciphertext and survives the value being set again. */
|
|
113
113
|
sites?: string[];
|
|
114
|
+
/** An ACCOUNT secret limited to these workspaces (GitHub's "selected
|
|
115
|
+
* repositories" for an organisation secret). Absent: every workspace may
|
|
116
|
+
* use it, as before. Every other workspace is answered as if it were not
|
|
117
|
+
* set (getSecret). Metadata like `sites`: in the clear, and in the seal
|
|
118
|
+
* of a bound record. Never on a workspace's own record. */
|
|
119
|
+
workspaces?: string[];
|
|
114
120
|
/** 2: sealed with the record's place as AAD — account, scope, name, kind,
|
|
115
|
-
* key and
|
|
121
|
+
* key, sites and, when limited, workspaces (vaultAad). Absent: the older,
|
|
122
|
+
* unbound form. */
|
|
116
123
|
v?: 2;
|
|
117
124
|
}
|
|
118
125
|
|
|
@@ -143,15 +150,47 @@ type SecretsFile = Record<string, StoredSecret>;
|
|
|
143
150
|
// every rename a re-seal racing other writers. What that leaves open — a
|
|
144
151
|
// ciphertext moved between two workspaces of the same account by someone who
|
|
145
152
|
// can already write the volume — is the residual, and stays inside one account.
|
|
146
|
-
|
|
153
|
+
//
|
|
154
|
+
// Rolling out (30 Oct 2026, accepted, no switch): a pod from before this
|
|
155
|
+
// release cannot open a limited record (its AAD lacks the list), and one that
|
|
156
|
+
// sets a new value over it writes the record back without the list — open to
|
|
157
|
+
// every workspace again. So limits are set after every pod, worker and the
|
|
158
|
+
// egress proxy run this version; the secrets docs say so.
|
|
159
|
+
//
|
|
160
|
+
// The workspaces an account secret is limited to are in the seal too (30 Oct
|
|
161
|
+
// 2026), appended only when the record has the list: every record sealed
|
|
162
|
+
// before keeps the AAD it was sealed with, and a list added to, widened in or
|
|
163
|
+
// stripped from a sealed record on disk makes it fail to open — "not set",
|
|
164
|
+
// never "every workspace". These ARE workspace names, so a rename re-seals
|
|
165
|
+
// them (retargetSecretWorkspaces), under the vault lock.
|
|
166
|
+
function vaultAad(tenant: string, workspace: string | undefined, name: string, rec: Pick<StoredSecret, "kind" | "k" | "sites" | "workspaces">): Buffer {
|
|
147
167
|
if (rec.sites !== undefined && (!Array.isArray(rec.sites) || rec.sites.some((x) => typeof x !== "string"))) {
|
|
148
168
|
throw new Error("malformed sites");
|
|
149
169
|
}
|
|
170
|
+
if (rec.workspaces !== undefined && (!Array.isArray(rec.workspaces) || rec.workspaces.some((x) => typeof x !== "string"))) {
|
|
171
|
+
throw new Error("malformed workspaces");
|
|
172
|
+
}
|
|
150
173
|
return Buffer.from(
|
|
151
|
-
JSON.stringify([
|
|
174
|
+
JSON.stringify([
|
|
175
|
+
"foldrun-vault/2",
|
|
176
|
+
tenant,
|
|
177
|
+
workspace ? "w" : "a",
|
|
178
|
+
name,
|
|
179
|
+
rec.kind ?? "",
|
|
180
|
+
rec.k ?? "install",
|
|
181
|
+
[...(rec.sites ?? [])].sort(),
|
|
182
|
+
...(rec.workspaces !== undefined ? [["workspaces", [...rec.workspaces].sort()]] : []),
|
|
183
|
+
]),
|
|
152
184
|
"utf8",
|
|
153
185
|
);
|
|
154
186
|
}
|
|
187
|
+
|
|
188
|
+
/** May an account record be used in this workspace? No list: everywhere. A
|
|
189
|
+
* list that is not a list of names (rewritten on disk) allows nowhere. */
|
|
190
|
+
function usableIn(rec: Pick<StoredSecret, "workspaces"> | undefined, workspace: string): boolean {
|
|
191
|
+
if (!rec || rec.workspaces === undefined) return true;
|
|
192
|
+
return Array.isArray(rec.workspaces) && rec.workspaces.includes(workspace);
|
|
193
|
+
}
|
|
155
194
|
const writeBound = () => process.env.FOLDRUN_VAULT_WRITE_BOUND === "1";
|
|
156
195
|
const requireBound = () => process.env.FOLDRUN_VAULT_REQUIRE_BOUND === "1";
|
|
157
196
|
|
|
@@ -162,7 +201,7 @@ function sealRecord(
|
|
|
162
201
|
workspace: string | undefined,
|
|
163
202
|
name: string,
|
|
164
203
|
value: string,
|
|
165
|
-
meta: { kind?: SecretKind; sites?: string[]; updatedAt?: string },
|
|
204
|
+
meta: { kind?: SecretKind; sites?: string[]; workspaces?: string[]; updatedAt?: string },
|
|
166
205
|
bound = writeBound(),
|
|
167
206
|
): StoredSecret {
|
|
168
207
|
const { key, mark } = keyFor(tenant);
|
|
@@ -174,6 +213,8 @@ function sealRecord(
|
|
|
174
213
|
...(meta.kind ? { kind: meta.kind } : {}),
|
|
175
214
|
...(mark ? { k: mark } : {}),
|
|
176
215
|
...(meta.sites?.length ? { sites: [...meta.sites] } : {}),
|
|
216
|
+
// An empty list is a choice (no workspace may use it), kept as written.
|
|
217
|
+
...(meta.workspaces !== undefined ? { workspaces: [...meta.workspaces] } : {}),
|
|
177
218
|
};
|
|
178
219
|
const iv = crypto.randomBytes(12);
|
|
179
220
|
const cipher = crypto.createCipheriv("aes-256-gcm", key, iv, GCM);
|
|
@@ -237,6 +278,17 @@ function readFile(file: string): SecretsFile {
|
|
|
237
278
|
}
|
|
238
279
|
|
|
239
280
|
function lockVault(file: string): () => void {
|
|
281
|
+
return lockFileExclusive(file, "the vault is busy — another change is being saved; try again");
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* An exclusive lock on `file` for a read-change-write, across processes and
|
|
286
|
+
* pods: `<file>.lock` opened O_EXCL (NFS 4.1 and EFS honour it), taken over
|
|
287
|
+
* when older than LOCK_STALE_MS (a holder that died), waited for up to
|
|
288
|
+
* LOCK_WAIT_MS, then `busy` is thrown. Returns the release. The vault's,
|
|
289
|
+
* and any other small state file several runs write (a2a-tools' handles).
|
|
290
|
+
*/
|
|
291
|
+
export function lockFileExclusive(file: string, busy = `${path.basename(file)} is busy — another writer holds it; try again`): () => void {
|
|
240
292
|
const lock = `${file}.lock`;
|
|
241
293
|
const deadline = Date.now() + LOCK_WAIT_MS;
|
|
242
294
|
for (;;) {
|
|
@@ -255,7 +307,7 @@ function lockVault(file: string): () => void {
|
|
|
255
307
|
} catch {
|
|
256
308
|
continue; // released between the open and the stat
|
|
257
309
|
}
|
|
258
|
-
if (Date.now() > deadline) throw new Error(
|
|
310
|
+
if (Date.now() > deadline) throw new Error(busy);
|
|
259
311
|
sleep(25);
|
|
260
312
|
}
|
|
261
313
|
}
|
|
@@ -284,12 +336,57 @@ export function mutateVaultFile(file: string, change: (all: SecretsFile) => Secr
|
|
|
284
336
|
|
|
285
337
|
export const SECRET_NAME = /^[A-Z][A-Z0-9_]{1,63}$/;
|
|
286
338
|
|
|
339
|
+
/**
|
|
340
|
+
* How a `signing_secret:` is shown in an answer, a log line or a delivery
|
|
341
|
+
* record: the name it holds, and only a name (30 Oct 2026). It is meant to
|
|
342
|
+
* name a vault entry, but nothing stopped the value itself being written
|
|
343
|
+
* there, and notify's test send echoed it back to the dashboard as "signed
|
|
344
|
+
* with <it>". Anything that is not a secret name is described, never
|
|
345
|
+
* repeated (foldrun check flags it as secret/signing-secret-value).
|
|
346
|
+
*/
|
|
347
|
+
export function signingSecretShown(name: string): string {
|
|
348
|
+
return SECRET_NAME.test(name) ? name : "the signing_secret: (not a secret name — see foldrun check)";
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Which workspaces an ACCOUNT secret may be used in, given with the value so
|
|
353
|
+
* both are sealed in one locked write (30 Oct 2026): a new secret created
|
|
354
|
+
* and limited in two calls was open to every workspace between them, and
|
|
355
|
+
* stayed open if the second failed. `workspaces` undefined keeps what the
|
|
356
|
+
* record has; null opens it to every workspace; a list limits it.
|
|
357
|
+
* `exists` is asked about each name under the vault lock, so a workspace
|
|
358
|
+
* deleted meanwhile is not granted (the delete takes its name off every
|
|
359
|
+
* list under the same lock — retargetSecretWorkspaces).
|
|
360
|
+
*/
|
|
361
|
+
export interface SecretLimit {
|
|
362
|
+
workspaces?: string[] | null;
|
|
363
|
+
exists?: (workspace: string) => boolean;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** A list as stored: trimmed, de-duplicated, sorted; every name a safe one
|
|
367
|
+
* and, when the caller can say, one that exists. */
|
|
368
|
+
function cleanWorkspaceList(workspaces: unknown): string[] | null {
|
|
369
|
+
if (workspaces === null) return null;
|
|
370
|
+
if (!Array.isArray(workspaces) || workspaces.some((w) => typeof w !== "string")) {
|
|
371
|
+
throw new Error("workspaces is a list of workspace names, or null for every workspace");
|
|
372
|
+
}
|
|
373
|
+
const clean = [...new Set((workspaces as string[]).map((w) => w.trim()).filter(Boolean))].sort();
|
|
374
|
+
for (const w of clean) assertSafeName(w, "workspace");
|
|
375
|
+
return clean;
|
|
376
|
+
}
|
|
377
|
+
function assertWorkspacesExist(list: string[] | null, exists?: (w: string) => boolean) {
|
|
378
|
+
if (!list || !exists) return;
|
|
379
|
+
const missing = list.filter((w) => !exists(w));
|
|
380
|
+
if (missing.length) throw Object.assign(new Error(`no workspace ${missing.join(", ")} in this account`), { status: 400 });
|
|
381
|
+
}
|
|
382
|
+
|
|
287
383
|
export function setSecret(
|
|
288
384
|
tenant: string,
|
|
289
385
|
name: string,
|
|
290
386
|
value: string,
|
|
291
387
|
workspace?: string,
|
|
292
388
|
kind?: SecretKind,
|
|
389
|
+
limit: SecretLimit = {},
|
|
293
390
|
) {
|
|
294
391
|
// A string, not anything that prints as one: RegExp.test and the vault's
|
|
295
392
|
// keys both coerce, so ["RESEND_API_KEY"] from a JSON body was stored as
|
|
@@ -298,19 +395,34 @@ export function setSecret(
|
|
|
298
395
|
throw new Error(`secret name "${name}" must be UPPER_SNAKE_CASE`);
|
|
299
396
|
}
|
|
300
397
|
if (value.length > 8192) throw new Error("secret too long");
|
|
398
|
+
const wanted = limitFor(workspace, limit);
|
|
301
399
|
mutateVaultFile(secretsFile(tenant, workspace), (all) => {
|
|
302
400
|
// A new value keeps the sites the old one was bound to: binding is a
|
|
303
401
|
// property of the credential's use, not of its current value, and a
|
|
304
402
|
// rotation that silently unbound it would put the new value in pods.
|
|
305
403
|
// Sealed here, under the lock: a bound record's AAD names those sites.
|
|
306
404
|
const sites = all[name]?.sites;
|
|
405
|
+
// The workspaces it is limited to stay too, for the same reason: a new
|
|
406
|
+
// value that every workspace could suddenly read is a widening nobody
|
|
407
|
+
// asked for — unless this call says otherwise, in the same write.
|
|
408
|
+
if (wanted !== undefined) assertWorkspacesExist(wanted, limit.exists);
|
|
409
|
+
const workspaces = wanted !== undefined ? (wanted ?? undefined) : all[name]?.workspaces;
|
|
307
410
|
// Once bound, always bound: a re-save with the switch off must not
|
|
308
|
-
// quietly drop the record's authentication.
|
|
309
|
-
|
|
411
|
+
// quietly drop the record's authentication. A limited record is always
|
|
412
|
+
// bound, so its list is in its seal (see setSecretWorkspaces).
|
|
413
|
+
all[name] = sealRecord(tenant, workspace, name, value, { kind, sites, workspaces }, all[name]?.v === 2 || workspaces !== undefined || writeBound());
|
|
310
414
|
return all;
|
|
311
415
|
});
|
|
312
416
|
}
|
|
313
417
|
|
|
418
|
+
/** The list a write was asked to store, checked: undefined when it was not
|
|
419
|
+
* asked to change it. Only the account's store has lists. */
|
|
420
|
+
function limitFor(workspace: string | undefined, limit: SecretLimit): string[] | null | undefined {
|
|
421
|
+
if (limit.workspaces === undefined) return undefined;
|
|
422
|
+
if (workspace) throw new Error("a workspace's own secret is that workspace's already — only an account secret is limited to workspaces");
|
|
423
|
+
return cleanWorkspaceList(limit.workspaces);
|
|
424
|
+
}
|
|
425
|
+
|
|
314
426
|
/**
|
|
315
427
|
* Bind a secret to sites, or unbind it (an empty list). The secret must
|
|
316
428
|
* exist in that scope. Sites are `api.example.com` or `*.example.com` — no
|
|
@@ -345,7 +457,7 @@ export function setSecretSites(tenant: string, name: string, sites: string[], wo
|
|
|
345
457
|
if (rec.v === 2) {
|
|
346
458
|
const value = openRecord(tenant, workspace, name, rec);
|
|
347
459
|
if (value === null) throw new Error(`${name} cannot be read with this install's keys — set its value again before binding it`);
|
|
348
|
-
all[name] = sealRecord(tenant, workspace, name, value, { kind: rec.kind, sites: clean, updatedAt: rec.updatedAt }, true);
|
|
460
|
+
all[name] = sealRecord(tenant, workspace, name, value, { kind: rec.kind, sites: clean, workspaces: rec.workspaces, updatedAt: rec.updatedAt }, true);
|
|
349
461
|
return all;
|
|
350
462
|
}
|
|
351
463
|
if (clean.length) rec.sites = clean;
|
|
@@ -370,7 +482,10 @@ export function secretSites(tenant: string, names: string[], workspace?: string)
|
|
|
370
482
|
// when it opens, else the account's. Picking by presence paired an
|
|
371
483
|
// account value with a workspace record's (unauthenticated) sites.
|
|
372
484
|
const ownRec = own[name];
|
|
373
|
-
const
|
|
485
|
+
const ownOpens = Boolean(ownRec && openRecord(tenant, workspace, name, ownRec) !== null);
|
|
486
|
+
// An account record limited to other workspaces is not this one's.
|
|
487
|
+
if (!ownOpens && workspace && !usableIn(account[name], workspace)) continue;
|
|
488
|
+
const rec = ownOpens ? ownRec : account[name];
|
|
374
489
|
if (rec?.sites?.length) out[name] = [...rec.sites];
|
|
375
490
|
}
|
|
376
491
|
return out;
|
|
@@ -457,7 +572,12 @@ function decrypt(tenant: string, rec: StoredSecret | undefined, aad?: Buffer): s
|
|
|
457
572
|
}
|
|
458
573
|
}
|
|
459
574
|
|
|
460
|
-
/** Nearest wins: a workspace secret shadows an account one of the same name.
|
|
575
|
+
/** Nearest wins: a workspace secret shadows an account one of the same name.
|
|
576
|
+
* An account secret limited to some workspaces (setSecretWorkspaces) is
|
|
577
|
+
* absent for every other — the answer a secret that was never set gets, on
|
|
578
|
+
* every path that resolves one for a workspace: runs, tool tests, notify,
|
|
579
|
+
* the egress proxy, MCP servers. Without a workspace it is the account's
|
|
580
|
+
* own use (its model key, its mail sender), which no list limits. */
|
|
461
581
|
export function getSecret(
|
|
462
582
|
tenant: string,
|
|
463
583
|
name: string,
|
|
@@ -467,10 +587,129 @@ export function getSecret(
|
|
|
467
587
|
const own = openRecord(tenant, workspace, name, read(tenant, workspace)[name]);
|
|
468
588
|
if (own !== null) return { value: own, scope: "workspace" };
|
|
469
589
|
}
|
|
470
|
-
const
|
|
590
|
+
const rec = read(tenant)[name];
|
|
591
|
+
if (workspace && !usableIn(rec, workspace)) return null;
|
|
592
|
+
const shared = openRecord(tenant, undefined, name, rec);
|
|
471
593
|
return shared === null ? null : { value: shared, scope: "account" };
|
|
472
594
|
}
|
|
473
595
|
|
|
596
|
+
/** May the ACCOUNT's secret `name` be used for this workspace? True when it
|
|
597
|
+
* is not limited (or not set); for callers that read the account's copy on
|
|
598
|
+
* a workspace's behalf without getSecret's workspace-first lookup — the
|
|
599
|
+
* account's mail sender for a workspace's notifications (notify.ts). */
|
|
600
|
+
export function accountSecretUsableIn(tenant: string, name: string, workspace: string): boolean {
|
|
601
|
+
return usableIn(read(tenant)[name], workspace);
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* Limit an account secret to some workspaces, or lift the limit (null).
|
|
606
|
+
* An empty list means no workspace may use it. Returns the list as stored,
|
|
607
|
+
* sorted, or null.
|
|
608
|
+
*
|
|
609
|
+
* `exists` is asked about each name under the vault lock (the API passes
|
|
610
|
+
* "is there such a workspace"), so it cannot grant a name a delete has just
|
|
611
|
+
* taken off every list. `ifWorkspaces`, when given, is the list the caller
|
|
612
|
+
* saw (null: every workspace): a list changed since is refused (409), so
|
|
613
|
+
* two admins editing it at once do not silently overwrite each other.
|
|
614
|
+
*
|
|
615
|
+
* The record is sealed (v2) whatever it was, whatever
|
|
616
|
+
* FOLDRUN_VAULT_WRITE_BOUND says: a list in the clear on an unbound record
|
|
617
|
+
* could be stripped on disk to open the secret to every workspace; sealed,
|
|
618
|
+
* a stripped list makes it unreadable. Every reader since 29 Oct 2026 reads v2.
|
|
619
|
+
*/
|
|
620
|
+
export function setSecretWorkspaces(
|
|
621
|
+
tenant: string,
|
|
622
|
+
name: string,
|
|
623
|
+
workspaces: string[] | null,
|
|
624
|
+
opts: { exists?: (workspace: string) => boolean; ifWorkspaces?: string[] | null } = {},
|
|
625
|
+
): string[] | null {
|
|
626
|
+
if (typeof name !== "string") throw new Error("a secret's name is a string");
|
|
627
|
+
const clean = cleanWorkspaceList(workspaces);
|
|
628
|
+
const expected = opts.ifWorkspaces === undefined ? undefined : cleanWorkspaceList(opts.ifWorkspaces);
|
|
629
|
+
let found = false;
|
|
630
|
+
mutateVaultFile(secretsFile(tenant), (all) => {
|
|
631
|
+
const rec = all[name];
|
|
632
|
+
if (!rec) return null;
|
|
633
|
+
found = true;
|
|
634
|
+
if (expected !== undefined) {
|
|
635
|
+
const now = Array.isArray(rec.workspaces) ? [...rec.workspaces].sort() : null;
|
|
636
|
+
if (JSON.stringify(now) !== JSON.stringify(expected)) {
|
|
637
|
+
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 });
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
assertWorkspacesExist(clean, opts.exists);
|
|
641
|
+
all[name] = withWorkspaces(tenant, name, rec, clean);
|
|
642
|
+
return all;
|
|
643
|
+
});
|
|
644
|
+
if (!found) throw Object.assign(new Error(`no secret ${name} in the account`), { status: 404 });
|
|
645
|
+
return clean;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/** An account record with its workspace list replaced (null: removed),
|
|
649
|
+
* opened and sealed again as a bound record with the new list in its seal.
|
|
650
|
+
* One that will not open is refused, never relabelled (setSecretSites). */
|
|
651
|
+
function withWorkspaces(tenant: string, name: string, rec: StoredSecret, workspaces: string[] | null): StoredSecret {
|
|
652
|
+
const value = openRecord(tenant, undefined, name, rec);
|
|
653
|
+
if (value === null) throw new Error(`${name} cannot be read with this install's keys — set its value again before changing its workspaces`);
|
|
654
|
+
return sealRecord(tenant, undefined, name, value, { kind: rec.kind, sites: rec.sites, workspaces: workspaces ?? undefined, updatedAt: rec.updatedAt }, true);
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/**
|
|
658
|
+
* A workspace is being renamed (to: its new name) or deleted (to: null):
|
|
659
|
+
* every account secret limited to it follows. Without this a rename quietly
|
|
660
|
+
* cut the workspace off its secrets, and a delete left the name on the list
|
|
661
|
+
* for the next workspace given it.
|
|
662
|
+
*
|
|
663
|
+
* `failed` names the records that hold the name but will not open with this
|
|
664
|
+
* install's keys: they are left exactly as they are, so the old name is
|
|
665
|
+
* STILL on their list — a key restored later would bring the grant back.
|
|
666
|
+
* The caller must not report the rename or delete as clean while any are
|
|
667
|
+
* left (the API refuses it, naming them: set them again, or delete them).
|
|
668
|
+
*
|
|
669
|
+
* Names, not ids: a grant naming a workspace's immutable id would survive a
|
|
670
|
+
* rename untouched and could never pass to a new workspace given an old
|
|
671
|
+
* name — the stronger design, not built yet (30 Oct 2026).
|
|
672
|
+
*/
|
|
673
|
+
export function retargetSecretWorkspaces(tenant: string, from: string, to: string | null): { changed: string[]; failed: string[] } {
|
|
674
|
+
const changed: string[] = [];
|
|
675
|
+
const failed: string[] = [];
|
|
676
|
+
const file = secretsFile(tenant);
|
|
677
|
+
if (!fs.existsSync(file)) return { changed, failed };
|
|
678
|
+
mutateVaultFile(file, (all) => {
|
|
679
|
+
changed.length = 0;
|
|
680
|
+
failed.length = 0;
|
|
681
|
+
const staged: typeof all = {};
|
|
682
|
+
for (const [name, rec] of Object.entries(all)) {
|
|
683
|
+
const list = rec.workspaces as unknown;
|
|
684
|
+
if (list === undefined) continue;
|
|
685
|
+
if (!Array.isArray(list)) {
|
|
686
|
+
// A list rewritten on disk into something else allows nowhere
|
|
687
|
+
// (usableIn) — nothing for this rename to carry.
|
|
688
|
+
continue;
|
|
689
|
+
}
|
|
690
|
+
if (!list.includes(from)) continue;
|
|
691
|
+
const next = [...new Set(list.map((w) => (w === from ? to : w)).filter((w): w is string => typeof w === "string"))].sort();
|
|
692
|
+
try {
|
|
693
|
+
staged[name] = withWorkspaces(tenant, name, rec, next);
|
|
694
|
+
changed.push(name);
|
|
695
|
+
} catch {
|
|
696
|
+
failed.push(name);
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
// All or nothing (Codex, 30 Oct 2026): writing the readable records and
|
|
700
|
+
// refusing the rename for the unreadable ones moved grants to a name the
|
|
701
|
+
// workspace never took. One failure leaves the vault exactly as it was.
|
|
702
|
+
if (failed.length) {
|
|
703
|
+
changed.length = 0;
|
|
704
|
+
return null;
|
|
705
|
+
}
|
|
706
|
+
if (!changed.length) return null;
|
|
707
|
+
Object.assign(all, staged);
|
|
708
|
+
return all;
|
|
709
|
+
});
|
|
710
|
+
return { changed, failed };
|
|
711
|
+
}
|
|
712
|
+
|
|
474
713
|
export function deleteSecret(tenant: string, name: string, workspace?: string) {
|
|
475
714
|
mutateVaultFile(secretsFile(tenant, workspace), (all) => {
|
|
476
715
|
if (!(name in all)) return null;
|
|
@@ -491,19 +730,27 @@ export interface SecretEntry {
|
|
|
491
730
|
/** The sites it is bound to — the proxy fills it for these and no other,
|
|
492
731
|
* and it never enters a sandbox. Absent when unbound. */
|
|
493
732
|
sites?: string[];
|
|
733
|
+
/** An account secret limited to these workspaces; absent: every one. */
|
|
734
|
+
workspaces?: string[];
|
|
494
735
|
}
|
|
495
736
|
|
|
496
737
|
// Metadata only — never values. Without a workspace this lists the account
|
|
497
738
|
// store; with one it lists what that workspace actually resolves, marking
|
|
498
739
|
// which scope each name comes from.
|
|
499
740
|
export function listSecrets(tenant: string, workspace?: string): SecretEntry[] {
|
|
500
|
-
const
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
741
|
+
const records = Object.entries(read(tenant));
|
|
742
|
+
const account = records
|
|
743
|
+
// What a workspace resolves leaves out an account secret limited to
|
|
744
|
+
// others: it is not set, as far as that workspace is concerned.
|
|
745
|
+
.filter(([, rec]) => !workspace || usableIn(rec, workspace))
|
|
746
|
+
.map(([name, rec]) => ({
|
|
747
|
+
name,
|
|
748
|
+
updatedAt: rec.updatedAt,
|
|
749
|
+
scope: "account" as const,
|
|
750
|
+
...(rec.kind ? { kind: rec.kind } : {}),
|
|
751
|
+
...(rec.sites?.length ? { sites: [...rec.sites] } : {}),
|
|
752
|
+
...(!workspace && Array.isArray(rec.workspaces) ? { workspaces: [...rec.workspaces] } : {}),
|
|
753
|
+
}));
|
|
507
754
|
if (!workspace) return account.sort((a, b) => a.name.localeCompare(b.name));
|
|
508
755
|
|
|
509
756
|
const own = Object.entries(read(tenant, workspace)).map(([name, rec]) => ({
|
|
@@ -648,6 +895,9 @@ export function rotateMasterKey(
|
|
|
648
895
|
// Kept too (it was dropped until 29 Oct 2026): a rotation that
|
|
649
896
|
// unbinds every bound secret puts their values into pods.
|
|
650
897
|
...(rec.sites?.length ? { sites: rec.sites } : {}),
|
|
898
|
+
// And the workspaces it is limited to: dropped, it would read as
|
|
899
|
+
// "every workspace" (and fail its seal).
|
|
900
|
+
...(rec.workspaces !== undefined ? { workspaces: rec.workspaces } : {}),
|
|
651
901
|
...(aad ? { v: 2 as const } : {}),
|
|
652
902
|
};
|
|
653
903
|
count++;
|
|
@@ -702,6 +952,7 @@ export function setOAuth2Secret(
|
|
|
702
952
|
name: string,
|
|
703
953
|
config: OAuth2Config,
|
|
704
954
|
workspace?: string,
|
|
955
|
+
limit: SecretLimit = {},
|
|
705
956
|
) {
|
|
706
957
|
for (const field of ["token_url", "client_id"] as const) {
|
|
707
958
|
if (!config[field]?.trim()) throw new Error(`oauth2 secret needs ${field}`);
|
|
@@ -719,7 +970,7 @@ export function setOAuth2Secret(
|
|
|
719
970
|
if (!/^https:\/\//.test(config.token_url) && !/^http:\/\/(127\.0\.0\.1|localhost)[:/]/.test(config.token_url)) {
|
|
720
971
|
throw new Error("token_url must be https — a refresh token over http is a leaked one");
|
|
721
972
|
}
|
|
722
|
-
setSecret(tenant, name, OAUTH2_PREFIX + JSON.stringify(config), workspace, "oauth2");
|
|
973
|
+
setSecret(tenant, name, OAUTH2_PREFIX + JSON.stringify(config), workspace, "oauth2", limit);
|
|
723
974
|
}
|
|
724
975
|
|
|
725
976
|
export function isOAuth2Value(value: string): boolean {
|
|
@@ -764,7 +1015,7 @@ export function replaceOAuth2RefreshToken(tenant: string, name: string, workspac
|
|
|
764
1015
|
// Built whole, not spread over the old record: a spread kept a stale
|
|
765
1016
|
// `k: "tenant"` when this seal fell back to the install key, and the
|
|
766
1017
|
// record then opened with neither.
|
|
767
|
-
all[name] = sealRecord(tenant, scope, name, next, { kind: rec.kind, sites: rec.sites }, rec.v === 2 || writeBound());
|
|
1018
|
+
all[name] = sealRecord(tenant, scope, name, next, { kind: rec.kind, sites: rec.sites, workspaces: rec.workspaces }, rec.v === 2 || writeBound());
|
|
768
1019
|
replaced = true;
|
|
769
1020
|
}
|
|
770
1021
|
return all;
|
|
@@ -936,6 +1187,37 @@ export async function materializeSecrets(
|
|
|
936
1187
|
return out;
|
|
937
1188
|
}
|
|
938
1189
|
|
|
1190
|
+
/**
|
|
1191
|
+
* materializeSecrets for a tool that holds secrets for a whole step and
|
|
1192
|
+
* makes them live per call (the API tools, the a2a tools), on a host with
|
|
1193
|
+
* the vault.
|
|
1194
|
+
*
|
|
1195
|
+
* Two things the step's copy of a recipe cannot do alone. A provider that
|
|
1196
|
+
* rotates refresh tokens has spent the refresh token the step started with —
|
|
1197
|
+
* the runner materialised it at step start, and any refresh since spent its
|
|
1198
|
+
* successor — so an oauth2 recipe is read again from the vault as it is now
|
|
1199
|
+
* (and `env` updated in place, for the next call). And it is made live in
|
|
1200
|
+
* the account's scope, as the runner and the egress proxy do: the cached
|
|
1201
|
+
* access token is keyed by account, and a rotation is written back to the
|
|
1202
|
+
* vault by the compare-and-set above. Refreshing the starting recipe outside
|
|
1203
|
+
* that scope missed the cache and sent a spent refresh token: invalid_grant.
|
|
1204
|
+
*
|
|
1205
|
+
* No `scope` (inside a sandbox, where there is no vault and the values
|
|
1206
|
+
* crossed already made live): materializeSecrets as it was.
|
|
1207
|
+
*/
|
|
1208
|
+
export async function materializeCurrent(
|
|
1209
|
+
env: Record<string, string>,
|
|
1210
|
+
scope?: { tenant: string; workspace?: string } | null,
|
|
1211
|
+
): Promise<Record<string, string>> {
|
|
1212
|
+
if (!scope?.tenant) return materializeSecrets(env);
|
|
1213
|
+
for (const [name, value] of Object.entries(env)) {
|
|
1214
|
+
if (typeof value !== "string" || !isOAuth2Value(value)) continue;
|
|
1215
|
+
const current = getSecret(scope.tenant, name, scope.workspace)?.value;
|
|
1216
|
+
if (current && isOAuth2Value(current)) env[name] = current;
|
|
1217
|
+
}
|
|
1218
|
+
return materializeSecrets(env, { tenant: scope.tenant, ...(scope.workspace ? { workspace: scope.workspace } : {}) });
|
|
1219
|
+
}
|
|
1220
|
+
|
|
939
1221
|
// ---------------------------------------------------------------- service account
|
|
940
1222
|
|
|
941
1223
|
// Signed-key credentials: a Google service account, or anything that mints a
|
|
@@ -966,6 +1248,7 @@ export function setServiceAccountSecret(
|
|
|
966
1248
|
name: string,
|
|
967
1249
|
config: ServiceAccountConfig,
|
|
968
1250
|
workspace?: string,
|
|
1251
|
+
limit: SecretLimit = {},
|
|
969
1252
|
) {
|
|
970
1253
|
for (const field of ["token_url", "issuer", "private_key", "scope"] as const) {
|
|
971
1254
|
if (!config[field]?.trim()) throw new Error(`service account secret needs ${field}`);
|
|
@@ -978,7 +1261,7 @@ export function setServiceAccountSecret(
|
|
|
978
1261
|
if (config.subject.length > 320 || /[\u0000-\u001f\u007f\s]/.test(config.subject.trim())) throw new Error("subject is one identifier, no spaces or control characters");
|
|
979
1262
|
}
|
|
980
1263
|
const stored: ServiceAccountConfig = { ...config, ...(config.subject !== undefined ? { subject: config.subject.trim() } : {}) };
|
|
981
|
-
setSecret(tenant, name, SERVICE_ACCOUNT_PREFIX + JSON.stringify(stored), workspace, "service-account");
|
|
1264
|
+
setSecret(tenant, name, SERVICE_ACCOUNT_PREFIX + JSON.stringify(stored), workspace, "service-account", limit);
|
|
982
1265
|
}
|
|
983
1266
|
|
|
984
1267
|
function base64url(input: Buffer | string): string {
|
|
@@ -1056,9 +1339,9 @@ async function exchangeServiceAccount(config: ServiceAccountConfig, cacheKey: st
|
|
|
1056
1339
|
|
|
1057
1340
|
const FILE_PREFIX = "@file ";
|
|
1058
1341
|
|
|
1059
|
-
export function setFileSecret(tenant: string, name: string, content: string, workspace?: string) {
|
|
1342
|
+
export function setFileSecret(tenant: string, name: string, content: string, workspace?: string, limit: SecretLimit = {}) {
|
|
1060
1343
|
if (!content) throw new Error("a file secret needs content");
|
|
1061
|
-
setSecret(tenant, name, FILE_PREFIX + content, workspace, "file");
|
|
1344
|
+
setSecret(tenant, name, FILE_PREFIX + content, workspace, "file", limit);
|
|
1062
1345
|
}
|
|
1063
1346
|
|
|
1064
1347
|
export function isFileValue(value: string): boolean {
|
|
@@ -1089,7 +1372,7 @@ export interface SshConfig {
|
|
|
1089
1372
|
password?: string;
|
|
1090
1373
|
}
|
|
1091
1374
|
|
|
1092
|
-
export function setSshSecret(tenant: string, name: string, config: SshConfig, workspace?: string) {
|
|
1375
|
+
export function setSshSecret(tenant: string, name: string, config: SshConfig, workspace?: string, limit: SecretLimit = {}) {
|
|
1093
1376
|
const host = config.host?.trim();
|
|
1094
1377
|
const user = config.user?.trim();
|
|
1095
1378
|
if (!host || /[\s'"`$\\]/.test(host)) throw new Error("ssh secret needs a host (no spaces or quotes)");
|
|
@@ -1107,7 +1390,7 @@ export function setSshSecret(tenant: string, name: string, config: SshConfig, wo
|
|
|
1107
1390
|
host, user, port,
|
|
1108
1391
|
...(hasKey ? { private_key: config.private_key } : { password: config.password }),
|
|
1109
1392
|
};
|
|
1110
|
-
setSecret(tenant, name, SSH_PREFIX + JSON.stringify(stored), workspace, "ssh");
|
|
1393
|
+
setSecret(tenant, name, SSH_PREFIX + JSON.stringify(stored), workspace, "ssh", limit);
|
|
1111
1394
|
}
|
|
1112
1395
|
|
|
1113
1396
|
export function isSshValue(value: string): boolean {
|
|
@@ -1142,7 +1425,7 @@ export interface ClientCertConfig {
|
|
|
1142
1425
|
export?: boolean;
|
|
1143
1426
|
}
|
|
1144
1427
|
|
|
1145
|
-
export function setClientCertSecret(tenant: string, name: string, config: ClientCertConfig, workspace?: string) {
|
|
1428
|
+
export function setClientCertSecret(tenant: string, name: string, config: ClientCertConfig, workspace?: string, limit: SecretLimit = {}) {
|
|
1146
1429
|
if (!/-----BEGIN CERTIFICATE-----/.test(config.cert ?? "")) throw new Error("cert must be a PEM certificate");
|
|
1147
1430
|
if (!/-----BEGIN [A-Z ]{0,40}PRIVATE KEY-----/.test(config.key ?? "")) throw new Error("key must be a PEM private key");
|
|
1148
1431
|
// The pair must match: a certificate presented with another key's proof
|
|
@@ -1167,12 +1450,15 @@ export function setClientCertSecret(tenant: string, name: string, config: Client
|
|
|
1167
1450
|
// Written under the vault's own lock, with the binding checked in the same
|
|
1168
1451
|
// step: a value set over a secret keeps its site binding, and a wildcard
|
|
1169
1452
|
// one would present the certificate to a whole domain.
|
|
1453
|
+
const wanted = limitFor(workspace, limit);
|
|
1170
1454
|
mutateVaultFile(secretsFile(tenant, workspace), (all) => {
|
|
1171
1455
|
const sites = all[name]?.sites;
|
|
1456
|
+
if (wanted !== undefined) assertWorkspacesExist(wanted, limit.exists);
|
|
1457
|
+
const workspaces = wanted !== undefined ? (wanted ?? undefined) : all[name]?.workspaces;
|
|
1172
1458
|
if (sites?.some((site) => site.startsWith("*."))) {
|
|
1173
1459
|
throw new Error(`${name} is bound to ${sites.join(", ")} — a client certificate is bound to exact hosts; unbind the wildcard first`);
|
|
1174
1460
|
}
|
|
1175
|
-
all[name] = sealRecord(tenant, workspace, name, plainCert, { kind: "client-cert", sites }, all[name]?.v === 2 || writeBound());
|
|
1461
|
+
all[name] = sealRecord(tenant, workspace, name, plainCert, { kind: "client-cert", sites, workspaces }, all[name]?.v === 2 || workspaces !== undefined || writeBound());
|
|
1176
1462
|
return all;
|
|
1177
1463
|
});
|
|
1178
1464
|
}
|
|
@@ -1186,11 +1472,11 @@ export interface IdentityConfig {
|
|
|
1186
1472
|
audience: string;
|
|
1187
1473
|
}
|
|
1188
1474
|
|
|
1189
|
-
export function setIdentitySecret(tenant: string, name: string, config: IdentityConfig, workspace?: string) {
|
|
1475
|
+
export function setIdentitySecret(tenant: string, name: string, config: IdentityConfig, workspace?: string, limit: SecretLimit = {}) {
|
|
1190
1476
|
const audience = typeof config.audience === "string" ? config.audience.trim() : "";
|
|
1191
1477
|
if (!audience) throw new Error("an identity needs an audience — the value your cloud's trust expects in aud");
|
|
1192
1478
|
if (audience.length > 600 || /[\u0000-\u001f\u007f]/.test(audience)) throw new Error("audience is one value, at most 600 characters, no control characters");
|
|
1193
|
-
setSecret(tenant, name, IDENTITY_PREFIX + JSON.stringify({ audience }), workspace, "identity");
|
|
1479
|
+
setSecret(tenant, name, IDENTITY_PREFIX + JSON.stringify({ audience }), workspace, "identity", limit);
|
|
1194
1480
|
}
|
|
1195
1481
|
|
|
1196
1482
|
export function isIdentityValue(value: string): boolean {
|
|
@@ -1233,7 +1519,7 @@ export function basicAuthorization(username: string, password: string): string {
|
|
|
1233
1519
|
return `Basic ${Buffer.from(`${username}:${password}`, "utf8").toString("base64")}`;
|
|
1234
1520
|
}
|
|
1235
1521
|
|
|
1236
|
-
export function setApiSecret(tenant: string, name: string, config: ApiConfig, workspace?: string) {
|
|
1522
|
+
export function setApiSecret(tenant: string, name: string, config: ApiConfig, workspace?: string, limit: SecretLimit = {}) {
|
|
1237
1523
|
let all = { ...(config.headers ?? {}) };
|
|
1238
1524
|
if (config.basic) {
|
|
1239
1525
|
if (Object.keys(all).some((h) => h.toLowerCase() === "authorization")) {
|
|
@@ -1265,7 +1551,7 @@ export function setApiSecret(tenant: string, name: string, config: ApiConfig, wo
|
|
|
1265
1551
|
setSecret(
|
|
1266
1552
|
tenant, name,
|
|
1267
1553
|
API_PREFIX + JSON.stringify({ ...(base ? { base_url: trimSlashes(base) } : {}), headers: config.headers }),
|
|
1268
|
-
workspace, "api",
|
|
1554
|
+
workspace, "api", limit,
|
|
1269
1555
|
);
|
|
1270
1556
|
}
|
|
1271
1557
|
|
|
@@ -1304,7 +1590,7 @@ export function sealVaultBindings(tenant: string): { sealed: number; unreadable:
|
|
|
1304
1590
|
const next: SecretsFile = { ...all };
|
|
1305
1591
|
for (const [name, value] of plain) {
|
|
1306
1592
|
const rec = all[name];
|
|
1307
|
-
next[name] = sealRecord(tenant, workspace, name, value, { kind: rec.kind, sites: rec.sites, updatedAt: rec.updatedAt }, true);
|
|
1593
|
+
next[name] = sealRecord(tenant, workspace, name, value, { kind: rec.kind, sites: rec.sites, workspaces: rec.workspaces, updatedAt: rec.updatedAt }, true);
|
|
1308
1594
|
}
|
|
1309
1595
|
sealed += plain.size;
|
|
1310
1596
|
return next;
|
|
@@ -1353,7 +1639,7 @@ export function rewrapTenantSecrets(tenant: string): { moved: number; unreadable
|
|
|
1353
1639
|
if (plain.size === 0 || unreadableHere.length) return null;
|
|
1354
1640
|
const next: SecretsFile = { ...all };
|
|
1355
1641
|
for (const [name, value] of plain) {
|
|
1356
|
-
next[name] = sealRecord(tenant, workspace, name, value, { kind: all[name].kind, sites: all[name].sites, updatedAt: all[name].updatedAt }, all[name].v === 2 || writeBound());
|
|
1642
|
+
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());
|
|
1357
1643
|
}
|
|
1358
1644
|
moved += plain.size;
|
|
1359
1645
|
return next;
|
package/src/starter.ts
CHANGED
|
@@ -389,6 +389,7 @@ All of it, \`/api/workspaces/<workspace>\` unless noted:
|
|
|
389
389
|
| \`/export\` · \`/import\` | GET · POST | the workspace, a flow (\`?kind=flow&name=\`) or an agent as a .zip; take one in (\`?dryRun=1\` first, \`?overwrite=1\` to replace) |
|
|
390
390
|
| \`/restore\` | POST | the workspace's source back to a point in its history — \`dryRun\` first, then \`confirm\` |
|
|
391
391
|
| \`/vocabulary\` | GET | what this workspace's documents may say |
|
|
392
|
+
| \`/a2a\` | GET | what it serves to outside agents over A2A: flows that say \`a2a:\`, card URLs, recent tasks |
|
|
392
393
|
| \`/check\` | GET | what \`foldrun check\` says about the deployed copy, as findings with rule ids (\`?security=1\`) |
|
|
393
394
|
| \`/assistant\` | POST | ask the assistant, or have it write agents, flows, tools and skills — from the docs only; proposals come back, nothing is written |
|
|
394
395
|
|
|
@@ -398,7 +399,8 @@ Account-level, outside \`/workspaces\`: \`/api/secrets\`, \`/api/keys\`, \`/api/
|
|
|
398
399
|
\`/api/me/inbox\` (what waits on you: gates, questions, issues, proposed fixes, your failed runs),
|
|
399
400
|
\`/api/account/backups\` (snapshots, restore requests), \`/api/me/onboarding\` (getting started),
|
|
400
401
|
\`/api/healthz\` and \`/api/version\` (open), \`/api/changelog\` (release notes)
|
|
401
|
-
and \`/api/metrics\` (Prometheus).
|
|
402
|
+
and \`/api/metrics\` (Prometheus). Outside \`/api\`: \`/a2a/<account>/<ws>/flows/<flow>\`
|
|
403
|
+
(a published flow's agent card and A2A 1.0 JSON-RPC endpoint — an A2A key only, \`foldrun docs a2a\`).
|
|
402
404
|
|
|
403
405
|
Every call answers — and the CLI sends — \`Foldrun-Version: YYYY-MM-DD\` (dated API
|
|
404
406
|
versions; an account pin in Settings → API), \`X-RateLimit-*\` headers (a \`429\`
|
|
@@ -434,6 +436,7 @@ effort: low
|
|
|
434
436
|
overlap: skip # skip | queue — what a second run does while one is live
|
|
435
437
|
priority: normal # high | normal | low — where its runs stand in the queue
|
|
436
438
|
compute: { vcpu: 4, memory: 8Gi, disk: 40Gi } # optional: what one run may hold at once
|
|
439
|
+
a2a: { publish: false } # true: outside agents may call it over A2A (admin only; callers need an A2A key)
|
|
437
440
|
---
|
|
438
441
|
|
|
439
442
|
1. [[researcher]] — Find three sources on {{topic}}.
|
|
@@ -499,6 +502,7 @@ hooks: [no-force-push] # from hooks/ or the library: code at every tool cal
|
|
|
499
502
|
scripts: [summarise.py] # from scripts/, each becomes a callable tool
|
|
500
503
|
apis: [{ name: crm }] # an HTTP API, as a tool
|
|
501
504
|
mcpServers: {} # an MCP server, as a tool
|
|
505
|
+
remotes: [] # outside A2A agents [{name, card, secret?}] — the a2a_send/status/reply/cancel tools
|
|
502
506
|
agents: [writer] # colleagues this one may consult (an answer, no tools)
|
|
503
507
|
subagents: [researcher] # colleagues it may delegate a job to (own context, own tools ≤ its own)
|
|
504
508
|
sandbox: own # delegated to: run each job in its own sandbox (own tools, secrets, network)
|