@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/dist/src/secrets.js
CHANGED
|
@@ -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
|
-
|
|
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);
|
|
@@ -187,6 +221,16 @@ function readFile(file) {
|
|
|
187
221
|
}
|
|
188
222
|
}
|
|
189
223
|
function lockVault(file) {
|
|
224
|
+
return lockFileExclusive(file, "the vault is busy — another change is being saved; try again");
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* An exclusive lock on `file` for a read-change-write, across processes and
|
|
228
|
+
* pods: `<file>.lock` opened O_EXCL (NFS 4.1 and EFS honour it), taken over
|
|
229
|
+
* when older than LOCK_STALE_MS (a holder that died), waited for up to
|
|
230
|
+
* LOCK_WAIT_MS, then `busy` is thrown. Returns the release. The vault's,
|
|
231
|
+
* and any other small state file several runs write (a2a-tools' handles).
|
|
232
|
+
*/
|
|
233
|
+
export function lockFileExclusive(file, busy = `${path.basename(file)} is busy — another writer holds it; try again`) {
|
|
190
234
|
const lock = `${file}.lock`;
|
|
191
235
|
const deadline = Date.now() + LOCK_WAIT_MS;
|
|
192
236
|
for (;;) {
|
|
@@ -209,7 +253,7 @@ function lockVault(file) {
|
|
|
209
253
|
continue; // released between the open and the stat
|
|
210
254
|
}
|
|
211
255
|
if (Date.now() > deadline)
|
|
212
|
-
throw new Error(
|
|
256
|
+
throw new Error(busy);
|
|
213
257
|
sleep(25);
|
|
214
258
|
}
|
|
215
259
|
}
|
|
@@ -237,7 +281,38 @@ export function mutateVaultFile(file, change) {
|
|
|
237
281
|
}
|
|
238
282
|
}
|
|
239
283
|
export const SECRET_NAME = /^[A-Z][A-Z0-9_]{1,63}$/;
|
|
240
|
-
|
|
284
|
+
/**
|
|
285
|
+
* How a `signing_secret:` is shown in an answer, a log line or a delivery
|
|
286
|
+
* record: the name it holds, and only a name (30 Oct 2026). It is meant to
|
|
287
|
+
* name a vault entry, but nothing stopped the value itself being written
|
|
288
|
+
* there, and notify's test send echoed it back to the dashboard as "signed
|
|
289
|
+
* with <it>". Anything that is not a secret name is described, never
|
|
290
|
+
* repeated (foldrun check flags it as secret/signing-secret-value).
|
|
291
|
+
*/
|
|
292
|
+
export function signingSecretShown(name) {
|
|
293
|
+
return SECRET_NAME.test(name) ? name : "the signing_secret: (not a secret name — see foldrun check)";
|
|
294
|
+
}
|
|
295
|
+
/** A list as stored: trimmed, de-duplicated, sorted; every name a safe one
|
|
296
|
+
* and, when the caller can say, one that exists. */
|
|
297
|
+
function cleanWorkspaceList(workspaces) {
|
|
298
|
+
if (workspaces === null)
|
|
299
|
+
return null;
|
|
300
|
+
if (!Array.isArray(workspaces) || workspaces.some((w) => typeof w !== "string")) {
|
|
301
|
+
throw new Error("workspaces is a list of workspace names, or null for every workspace");
|
|
302
|
+
}
|
|
303
|
+
const clean = [...new Set(workspaces.map((w) => w.trim()).filter(Boolean))].sort();
|
|
304
|
+
for (const w of clean)
|
|
305
|
+
assertSafeName(w, "workspace");
|
|
306
|
+
return clean;
|
|
307
|
+
}
|
|
308
|
+
function assertWorkspacesExist(list, exists) {
|
|
309
|
+
if (!list || !exists)
|
|
310
|
+
return;
|
|
311
|
+
const missing = list.filter((w) => !exists(w));
|
|
312
|
+
if (missing.length)
|
|
313
|
+
throw Object.assign(new Error(`no workspace ${missing.join(", ")} in this account`), { status: 400 });
|
|
314
|
+
}
|
|
315
|
+
export function setSecret(tenant, name, value, workspace, kind, limit = {}) {
|
|
241
316
|
// A string, not anything that prints as one: RegExp.test and the vault's
|
|
242
317
|
// keys both coerce, so ["RESEND_API_KEY"] from a JSON body was stored as
|
|
243
318
|
// RESEND_API_KEY past a caller's check on the name (30 Oct 2026).
|
|
@@ -246,18 +321,35 @@ export function setSecret(tenant, name, value, workspace, kind) {
|
|
|
246
321
|
}
|
|
247
322
|
if (value.length > 8192)
|
|
248
323
|
throw new Error("secret too long");
|
|
324
|
+
const wanted = limitFor(workspace, limit);
|
|
249
325
|
mutateVaultFile(secretsFile(tenant, workspace), (all) => {
|
|
250
326
|
// A new value keeps the sites the old one was bound to: binding is a
|
|
251
327
|
// property of the credential's use, not of its current value, and a
|
|
252
328
|
// rotation that silently unbound it would put the new value in pods.
|
|
253
329
|
// Sealed here, under the lock: a bound record's AAD names those sites.
|
|
254
330
|
const sites = all[name]?.sites;
|
|
331
|
+
// The workspaces it is limited to stay too, for the same reason: a new
|
|
332
|
+
// value that every workspace could suddenly read is a widening nobody
|
|
333
|
+
// asked for — unless this call says otherwise, in the same write.
|
|
334
|
+
if (wanted !== undefined)
|
|
335
|
+
assertWorkspacesExist(wanted, limit.exists);
|
|
336
|
+
const workspaces = wanted !== undefined ? (wanted ?? undefined) : all[name]?.workspaces;
|
|
255
337
|
// Once bound, always bound: a re-save with the switch off must not
|
|
256
|
-
// quietly drop the record's authentication.
|
|
257
|
-
|
|
338
|
+
// quietly drop the record's authentication. A limited record is always
|
|
339
|
+
// bound, so its list is in its seal (see setSecretWorkspaces).
|
|
340
|
+
all[name] = sealRecord(tenant, workspace, name, value, { kind, sites, workspaces }, all[name]?.v === 2 || workspaces !== undefined || writeBound());
|
|
258
341
|
return all;
|
|
259
342
|
});
|
|
260
343
|
}
|
|
344
|
+
/** The list a write was asked to store, checked: undefined when it was not
|
|
345
|
+
* asked to change it. Only the account's store has lists. */
|
|
346
|
+
function limitFor(workspace, limit) {
|
|
347
|
+
if (limit.workspaces === undefined)
|
|
348
|
+
return undefined;
|
|
349
|
+
if (workspace)
|
|
350
|
+
throw new Error("a workspace's own secret is that workspace's already — only an account secret is limited to workspaces");
|
|
351
|
+
return cleanWorkspaceList(limit.workspaces);
|
|
352
|
+
}
|
|
261
353
|
/**
|
|
262
354
|
* Bind a secret to sites, or unbind it (an empty list). The secret must
|
|
263
355
|
* exist in that scope. Sites are `api.example.com` or `*.example.com` — no
|
|
@@ -296,7 +388,7 @@ export function setSecretSites(tenant, name, sites, workspace) {
|
|
|
296
388
|
const value = openRecord(tenant, workspace, name, rec);
|
|
297
389
|
if (value === null)
|
|
298
390
|
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);
|
|
391
|
+
all[name] = sealRecord(tenant, workspace, name, value, { kind: rec.kind, sites: clean, workspaces: rec.workspaces, updatedAt: rec.updatedAt }, true);
|
|
300
392
|
return all;
|
|
301
393
|
}
|
|
302
394
|
if (clean.length)
|
|
@@ -323,7 +415,11 @@ export function secretSites(tenant, names, workspace) {
|
|
|
323
415
|
// when it opens, else the account's. Picking by presence paired an
|
|
324
416
|
// account value with a workspace record's (unauthenticated) sites.
|
|
325
417
|
const ownRec = own[name];
|
|
326
|
-
const
|
|
418
|
+
const ownOpens = Boolean(ownRec && openRecord(tenant, workspace, name, ownRec) !== null);
|
|
419
|
+
// An account record limited to other workspaces is not this one's.
|
|
420
|
+
if (!ownOpens && workspace && !usableIn(account[name], workspace))
|
|
421
|
+
continue;
|
|
422
|
+
const rec = ownOpens ? ownRec : account[name];
|
|
327
423
|
if (rec?.sites?.length)
|
|
328
424
|
out[name] = [...rec.sites];
|
|
329
425
|
}
|
|
@@ -398,16 +494,141 @@ function decrypt(tenant, rec, aad) {
|
|
|
398
494
|
return null; // key rotated or file tampered with
|
|
399
495
|
}
|
|
400
496
|
}
|
|
401
|
-
/** Nearest wins: a workspace secret shadows an account one of the same name.
|
|
497
|
+
/** Nearest wins: a workspace secret shadows an account one of the same name.
|
|
498
|
+
* An account secret limited to some workspaces (setSecretWorkspaces) is
|
|
499
|
+
* absent for every other — the answer a secret that was never set gets, on
|
|
500
|
+
* every path that resolves one for a workspace: runs, tool tests, notify,
|
|
501
|
+
* the egress proxy, MCP servers. Without a workspace it is the account's
|
|
502
|
+
* own use (its model key, its mail sender), which no list limits. */
|
|
402
503
|
export function getSecret(tenant, name, workspace) {
|
|
403
504
|
if (workspace) {
|
|
404
505
|
const own = openRecord(tenant, workspace, name, read(tenant, workspace)[name]);
|
|
405
506
|
if (own !== null)
|
|
406
507
|
return { value: own, scope: "workspace" };
|
|
407
508
|
}
|
|
408
|
-
const
|
|
509
|
+
const rec = read(tenant)[name];
|
|
510
|
+
if (workspace && !usableIn(rec, workspace))
|
|
511
|
+
return null;
|
|
512
|
+
const shared = openRecord(tenant, undefined, name, rec);
|
|
409
513
|
return shared === null ? null : { value: shared, scope: "account" };
|
|
410
514
|
}
|
|
515
|
+
/** May the ACCOUNT's secret `name` be used for this workspace? True when it
|
|
516
|
+
* is not limited (or not set); for callers that read the account's copy on
|
|
517
|
+
* a workspace's behalf without getSecret's workspace-first lookup — the
|
|
518
|
+
* account's mail sender for a workspace's notifications (notify.ts). */
|
|
519
|
+
export function accountSecretUsableIn(tenant, name, workspace) {
|
|
520
|
+
return usableIn(read(tenant)[name], workspace);
|
|
521
|
+
}
|
|
522
|
+
/**
|
|
523
|
+
* Limit an account secret to some workspaces, or lift the limit (null).
|
|
524
|
+
* An empty list means no workspace may use it. Returns the list as stored,
|
|
525
|
+
* sorted, or null.
|
|
526
|
+
*
|
|
527
|
+
* `exists` is asked about each name under the vault lock (the API passes
|
|
528
|
+
* "is there such a workspace"), so it cannot grant a name a delete has just
|
|
529
|
+
* taken off every list. `ifWorkspaces`, when given, is the list the caller
|
|
530
|
+
* saw (null: every workspace): a list changed since is refused (409), so
|
|
531
|
+
* two admins editing it at once do not silently overwrite each other.
|
|
532
|
+
*
|
|
533
|
+
* The record is sealed (v2) whatever it was, whatever
|
|
534
|
+
* FOLDRUN_VAULT_WRITE_BOUND says: a list in the clear on an unbound record
|
|
535
|
+
* could be stripped on disk to open the secret to every workspace; sealed,
|
|
536
|
+
* a stripped list makes it unreadable. Every reader since 29 Oct 2026 reads v2.
|
|
537
|
+
*/
|
|
538
|
+
export function setSecretWorkspaces(tenant, name, workspaces, opts = {}) {
|
|
539
|
+
if (typeof name !== "string")
|
|
540
|
+
throw new Error("a secret's name is a string");
|
|
541
|
+
const clean = cleanWorkspaceList(workspaces);
|
|
542
|
+
const expected = opts.ifWorkspaces === undefined ? undefined : cleanWorkspaceList(opts.ifWorkspaces);
|
|
543
|
+
let found = false;
|
|
544
|
+
mutateVaultFile(secretsFile(tenant), (all) => {
|
|
545
|
+
const rec = all[name];
|
|
546
|
+
if (!rec)
|
|
547
|
+
return null;
|
|
548
|
+
found = true;
|
|
549
|
+
if (expected !== undefined) {
|
|
550
|
+
const now = Array.isArray(rec.workspaces) ? [...rec.workspaces].sort() : null;
|
|
551
|
+
if (JSON.stringify(now) !== JSON.stringify(expected)) {
|
|
552
|
+
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 });
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
assertWorkspacesExist(clean, opts.exists);
|
|
556
|
+
all[name] = withWorkspaces(tenant, name, rec, clean);
|
|
557
|
+
return all;
|
|
558
|
+
});
|
|
559
|
+
if (!found)
|
|
560
|
+
throw Object.assign(new Error(`no secret ${name} in the account`), { status: 404 });
|
|
561
|
+
return clean;
|
|
562
|
+
}
|
|
563
|
+
/** An account record with its workspace list replaced (null: removed),
|
|
564
|
+
* opened and sealed again as a bound record with the new list in its seal.
|
|
565
|
+
* One that will not open is refused, never relabelled (setSecretSites). */
|
|
566
|
+
function withWorkspaces(tenant, name, rec, workspaces) {
|
|
567
|
+
const value = openRecord(tenant, undefined, name, rec);
|
|
568
|
+
if (value === null)
|
|
569
|
+
throw new Error(`${name} cannot be read with this install's keys — set its value again before changing its workspaces`);
|
|
570
|
+
return sealRecord(tenant, undefined, name, value, { kind: rec.kind, sites: rec.sites, workspaces: workspaces ?? undefined, updatedAt: rec.updatedAt }, true);
|
|
571
|
+
}
|
|
572
|
+
/**
|
|
573
|
+
* A workspace is being renamed (to: its new name) or deleted (to: null):
|
|
574
|
+
* every account secret limited to it follows. Without this a rename quietly
|
|
575
|
+
* cut the workspace off its secrets, and a delete left the name on the list
|
|
576
|
+
* for the next workspace given it.
|
|
577
|
+
*
|
|
578
|
+
* `failed` names the records that hold the name but will not open with this
|
|
579
|
+
* install's keys: they are left exactly as they are, so the old name is
|
|
580
|
+
* STILL on their list — a key restored later would bring the grant back.
|
|
581
|
+
* The caller must not report the rename or delete as clean while any are
|
|
582
|
+
* left (the API refuses it, naming them: set them again, or delete them).
|
|
583
|
+
*
|
|
584
|
+
* Names, not ids: a grant naming a workspace's immutable id would survive a
|
|
585
|
+
* rename untouched and could never pass to a new workspace given an old
|
|
586
|
+
* name — the stronger design, not built yet (30 Oct 2026).
|
|
587
|
+
*/
|
|
588
|
+
export function retargetSecretWorkspaces(tenant, from, to) {
|
|
589
|
+
const changed = [];
|
|
590
|
+
const failed = [];
|
|
591
|
+
const file = secretsFile(tenant);
|
|
592
|
+
if (!fs.existsSync(file))
|
|
593
|
+
return { changed, failed };
|
|
594
|
+
mutateVaultFile(file, (all) => {
|
|
595
|
+
changed.length = 0;
|
|
596
|
+
failed.length = 0;
|
|
597
|
+
const staged = {};
|
|
598
|
+
for (const [name, rec] of Object.entries(all)) {
|
|
599
|
+
const list = rec.workspaces;
|
|
600
|
+
if (list === undefined)
|
|
601
|
+
continue;
|
|
602
|
+
if (!Array.isArray(list)) {
|
|
603
|
+
// A list rewritten on disk into something else allows nowhere
|
|
604
|
+
// (usableIn) — nothing for this rename to carry.
|
|
605
|
+
continue;
|
|
606
|
+
}
|
|
607
|
+
if (!list.includes(from))
|
|
608
|
+
continue;
|
|
609
|
+
const next = [...new Set(list.map((w) => (w === from ? to : w)).filter((w) => typeof w === "string"))].sort();
|
|
610
|
+
try {
|
|
611
|
+
staged[name] = withWorkspaces(tenant, name, rec, next);
|
|
612
|
+
changed.push(name);
|
|
613
|
+
}
|
|
614
|
+
catch {
|
|
615
|
+
failed.push(name);
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
// All or nothing (Codex, 30 Oct 2026): writing the readable records and
|
|
619
|
+
// refusing the rename for the unreadable ones moved grants to a name the
|
|
620
|
+
// workspace never took. One failure leaves the vault exactly as it was.
|
|
621
|
+
if (failed.length) {
|
|
622
|
+
changed.length = 0;
|
|
623
|
+
return null;
|
|
624
|
+
}
|
|
625
|
+
if (!changed.length)
|
|
626
|
+
return null;
|
|
627
|
+
Object.assign(all, staged);
|
|
628
|
+
return all;
|
|
629
|
+
});
|
|
630
|
+
return { changed, failed };
|
|
631
|
+
}
|
|
411
632
|
export function deleteSecret(tenant, name, workspace) {
|
|
412
633
|
mutateVaultFile(secretsFile(tenant, workspace), (all) => {
|
|
413
634
|
if (!(name in all))
|
|
@@ -420,12 +641,18 @@ export function deleteSecret(tenant, name, workspace) {
|
|
|
420
641
|
// store; with one it lists what that workspace actually resolves, marking
|
|
421
642
|
// which scope each name comes from.
|
|
422
643
|
export function listSecrets(tenant, workspace) {
|
|
423
|
-
const
|
|
644
|
+
const records = Object.entries(read(tenant));
|
|
645
|
+
const account = records
|
|
646
|
+
// What a workspace resolves leaves out an account secret limited to
|
|
647
|
+
// others: it is not set, as far as that workspace is concerned.
|
|
648
|
+
.filter(([, rec]) => !workspace || usableIn(rec, workspace))
|
|
649
|
+
.map(([name, rec]) => ({
|
|
424
650
|
name,
|
|
425
651
|
updatedAt: rec.updatedAt,
|
|
426
652
|
scope: "account",
|
|
427
653
|
...(rec.kind ? { kind: rec.kind } : {}),
|
|
428
654
|
...(rec.sites?.length ? { sites: [...rec.sites] } : {}),
|
|
655
|
+
...(!workspace && Array.isArray(rec.workspaces) ? { workspaces: [...rec.workspaces] } : {}),
|
|
429
656
|
}));
|
|
430
657
|
if (!workspace)
|
|
431
658
|
return account.sort((a, b) => a.name.localeCompare(b.name));
|
|
@@ -559,6 +786,9 @@ export function rotateMasterKey(tenant, from, to) {
|
|
|
559
786
|
// Kept too (it was dropped until 29 Oct 2026): a rotation that
|
|
560
787
|
// unbinds every bound secret puts their values into pods.
|
|
561
788
|
...(rec.sites?.length ? { sites: rec.sites } : {}),
|
|
789
|
+
// And the workspaces it is limited to: dropped, it would read as
|
|
790
|
+
// "every workspace" (and fail its seal).
|
|
791
|
+
...(rec.workspaces !== undefined ? { workspaces: rec.workspaces } : {}),
|
|
562
792
|
...(aad ? { v: 2 } : {}),
|
|
563
793
|
};
|
|
564
794
|
count++;
|
|
@@ -578,7 +808,7 @@ export function oauthRetryDelayMs(n) {
|
|
|
578
808
|
const base = Number(process.env.FOLDRUN_OAUTH_RETRY_MS ?? 1000);
|
|
579
809
|
return base * (n === 1 ? 1 : 3);
|
|
580
810
|
}
|
|
581
|
-
export function setOAuth2Secret(tenant, name, config, workspace) {
|
|
811
|
+
export function setOAuth2Secret(tenant, name, config, workspace, limit = {}) {
|
|
582
812
|
for (const field of ["token_url", "client_id"]) {
|
|
583
813
|
if (!config[field]?.trim())
|
|
584
814
|
throw new Error(`oauth2 secret needs ${field}`);
|
|
@@ -596,7 +826,7 @@ export function setOAuth2Secret(tenant, name, config, workspace) {
|
|
|
596
826
|
if (!/^https:\/\//.test(config.token_url) && !/^http:\/\/(127\.0\.0\.1|localhost)[:/]/.test(config.token_url)) {
|
|
597
827
|
throw new Error("token_url must be https — a refresh token over http is a leaked one");
|
|
598
828
|
}
|
|
599
|
-
setSecret(tenant, name, OAUTH2_PREFIX + JSON.stringify(config), workspace, "oauth2");
|
|
829
|
+
setSecret(tenant, name, OAUTH2_PREFIX + JSON.stringify(config), workspace, "oauth2", limit);
|
|
600
830
|
}
|
|
601
831
|
export function isOAuth2Value(value) {
|
|
602
832
|
return value.startsWith(OAUTH2_PREFIX);
|
|
@@ -638,7 +868,7 @@ export function replaceOAuth2RefreshToken(tenant, name, workspace, usedValue, re
|
|
|
638
868
|
// Built whole, not spread over the old record: a spread kept a stale
|
|
639
869
|
// `k: "tenant"` when this seal fell back to the install key, and the
|
|
640
870
|
// record then opened with neither.
|
|
641
|
-
all[name] = sealRecord(tenant, scope, name, next, { kind: rec.kind, sites: rec.sites }, rec.v === 2 || writeBound());
|
|
871
|
+
all[name] = sealRecord(tenant, scope, name, next, { kind: rec.kind, sites: rec.sites, workspaces: rec.workspaces }, rec.v === 2 || writeBound());
|
|
642
872
|
replaced = true;
|
|
643
873
|
}
|
|
644
874
|
return all;
|
|
@@ -799,6 +1029,36 @@ ctx) {
|
|
|
799
1029
|
}
|
|
800
1030
|
return out;
|
|
801
1031
|
}
|
|
1032
|
+
/**
|
|
1033
|
+
* materializeSecrets for a tool that holds secrets for a whole step and
|
|
1034
|
+
* makes them live per call (the API tools, the a2a tools), on a host with
|
|
1035
|
+
* the vault.
|
|
1036
|
+
*
|
|
1037
|
+
* Two things the step's copy of a recipe cannot do alone. A provider that
|
|
1038
|
+
* rotates refresh tokens has spent the refresh token the step started with —
|
|
1039
|
+
* the runner materialised it at step start, and any refresh since spent its
|
|
1040
|
+
* successor — so an oauth2 recipe is read again from the vault as it is now
|
|
1041
|
+
* (and `env` updated in place, for the next call). And it is made live in
|
|
1042
|
+
* the account's scope, as the runner and the egress proxy do: the cached
|
|
1043
|
+
* access token is keyed by account, and a rotation is written back to the
|
|
1044
|
+
* vault by the compare-and-set above. Refreshing the starting recipe outside
|
|
1045
|
+
* that scope missed the cache and sent a spent refresh token: invalid_grant.
|
|
1046
|
+
*
|
|
1047
|
+
* No `scope` (inside a sandbox, where there is no vault and the values
|
|
1048
|
+
* crossed already made live): materializeSecrets as it was.
|
|
1049
|
+
*/
|
|
1050
|
+
export async function materializeCurrent(env, scope) {
|
|
1051
|
+
if (!scope?.tenant)
|
|
1052
|
+
return materializeSecrets(env);
|
|
1053
|
+
for (const [name, value] of Object.entries(env)) {
|
|
1054
|
+
if (typeof value !== "string" || !isOAuth2Value(value))
|
|
1055
|
+
continue;
|
|
1056
|
+
const current = getSecret(scope.tenant, name, scope.workspace)?.value;
|
|
1057
|
+
if (current && isOAuth2Value(current))
|
|
1058
|
+
env[name] = current;
|
|
1059
|
+
}
|
|
1060
|
+
return materializeSecrets(env, { tenant: scope.tenant, ...(scope.workspace ? { workspace: scope.workspace } : {}) });
|
|
1061
|
+
}
|
|
802
1062
|
// ---------------------------------------------------------------- service account
|
|
803
1063
|
// Signed-key credentials: a Google service account, or anything that mints a
|
|
804
1064
|
// token by self-signing a JWT with a private key and exchanging it. No user,
|
|
@@ -808,7 +1068,7 @@ ctx) {
|
|
|
808
1068
|
// Stored as the JSON key, encrypted. Materialised the same way oauth2 is: a
|
|
809
1069
|
// live access token appears at the last host-side moment, cached until expiry.
|
|
810
1070
|
const SERVICE_ACCOUNT_PREFIX = "@service-account ";
|
|
811
|
-
export function setServiceAccountSecret(tenant, name, config, workspace) {
|
|
1071
|
+
export function setServiceAccountSecret(tenant, name, config, workspace, limit = {}) {
|
|
812
1072
|
for (const field of ["token_url", "issuer", "private_key", "scope"]) {
|
|
813
1073
|
if (!config[field]?.trim())
|
|
814
1074
|
throw new Error(`service account secret needs ${field}`);
|
|
@@ -823,7 +1083,7 @@ export function setServiceAccountSecret(tenant, name, config, workspace) {
|
|
|
823
1083
|
throw new Error("subject is one identifier, no spaces or control characters");
|
|
824
1084
|
}
|
|
825
1085
|
const stored = { ...config, ...(config.subject !== undefined ? { subject: config.subject.trim() } : {}) };
|
|
826
|
-
setSecret(tenant, name, SERVICE_ACCOUNT_PREFIX + JSON.stringify(stored), workspace, "service-account");
|
|
1086
|
+
setSecret(tenant, name, SERVICE_ACCOUNT_PREFIX + JSON.stringify(stored), workspace, "service-account", limit);
|
|
827
1087
|
}
|
|
828
1088
|
function base64url(input) {
|
|
829
1089
|
return Buffer.from(input).toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
@@ -886,10 +1146,10 @@ async function exchangeServiceAccount(config, cacheKey) {
|
|
|
886
1146
|
// the only place that knows where a run's writable scratch is; here we only
|
|
887
1147
|
// mark the kind and carry the content.
|
|
888
1148
|
const FILE_PREFIX = "@file ";
|
|
889
|
-
export function setFileSecret(tenant, name, content, workspace) {
|
|
1149
|
+
export function setFileSecret(tenant, name, content, workspace, limit = {}) {
|
|
890
1150
|
if (!content)
|
|
891
1151
|
throw new Error("a file secret needs content");
|
|
892
|
-
setSecret(tenant, name, FILE_PREFIX + content, workspace, "file");
|
|
1152
|
+
setSecret(tenant, name, FILE_PREFIX + content, workspace, "file", limit);
|
|
893
1153
|
}
|
|
894
1154
|
export function isFileValue(value) {
|
|
895
1155
|
return value.startsWith(FILE_PREFIX);
|
|
@@ -905,7 +1165,7 @@ export function fileContent(value) {
|
|
|
905
1165
|
// config into an executable wrapper, so the agent runs `"$NAME" 'uptime'`
|
|
906
1166
|
// and never learns which auth flavour it is.
|
|
907
1167
|
const SSH_PREFIX = "@ssh ";
|
|
908
|
-
export function setSshSecret(tenant, name, config, workspace) {
|
|
1168
|
+
export function setSshSecret(tenant, name, config, workspace, limit = {}) {
|
|
909
1169
|
const host = config.host?.trim();
|
|
910
1170
|
const user = config.user?.trim();
|
|
911
1171
|
if (!host || /[\s'"`$\\]/.test(host))
|
|
@@ -928,7 +1188,7 @@ export function setSshSecret(tenant, name, config, workspace) {
|
|
|
928
1188
|
host, user, port,
|
|
929
1189
|
...(hasKey ? { private_key: config.private_key } : { password: config.password }),
|
|
930
1190
|
};
|
|
931
|
-
setSecret(tenant, name, SSH_PREFIX + JSON.stringify(stored), workspace, "ssh");
|
|
1191
|
+
setSecret(tenant, name, SSH_PREFIX + JSON.stringify(stored), workspace, "ssh", limit);
|
|
932
1192
|
}
|
|
933
1193
|
export function isSshValue(value) {
|
|
934
1194
|
return value.startsWith(SSH_PREFIX);
|
|
@@ -944,7 +1204,7 @@ export function sshConfigOf(value) {
|
|
|
944
1204
|
// never appears in the bash command at all.
|
|
945
1205
|
const API_PREFIX = "@api ";
|
|
946
1206
|
const CLIENT_CERT_PREFIX = "@client-cert ";
|
|
947
|
-
export function setClientCertSecret(tenant, name, config, workspace) {
|
|
1207
|
+
export function setClientCertSecret(tenant, name, config, workspace, limit = {}) {
|
|
948
1208
|
if (!/-----BEGIN CERTIFICATE-----/.test(config.cert ?? ""))
|
|
949
1209
|
throw new Error("cert must be a PEM certificate");
|
|
950
1210
|
if (!/-----BEGIN [A-Z ]{0,40}PRIVATE KEY-----/.test(config.key ?? ""))
|
|
@@ -973,23 +1233,27 @@ export function setClientCertSecret(tenant, name, config, workspace) {
|
|
|
973
1233
|
// Written under the vault's own lock, with the binding checked in the same
|
|
974
1234
|
// step: a value set over a secret keeps its site binding, and a wildcard
|
|
975
1235
|
// one would present the certificate to a whole domain.
|
|
1236
|
+
const wanted = limitFor(workspace, limit);
|
|
976
1237
|
mutateVaultFile(secretsFile(tenant, workspace), (all) => {
|
|
977
1238
|
const sites = all[name]?.sites;
|
|
1239
|
+
if (wanted !== undefined)
|
|
1240
|
+
assertWorkspacesExist(wanted, limit.exists);
|
|
1241
|
+
const workspaces = wanted !== undefined ? (wanted ?? undefined) : all[name]?.workspaces;
|
|
978
1242
|
if (sites?.some((site) => site.startsWith("*."))) {
|
|
979
1243
|
throw new Error(`${name} is bound to ${sites.join(", ")} — a client certificate is bound to exact hosts; unbind the wildcard first`);
|
|
980
1244
|
}
|
|
981
|
-
all[name] = sealRecord(tenant, workspace, name, plainCert, { kind: "client-cert", sites }, all[name]?.v === 2 || writeBound());
|
|
1245
|
+
all[name] = sealRecord(tenant, workspace, name, plainCert, { kind: "client-cert", sites, workspaces }, all[name]?.v === 2 || workspaces !== undefined || writeBound());
|
|
982
1246
|
return all;
|
|
983
1247
|
});
|
|
984
1248
|
}
|
|
985
1249
|
const IDENTITY_PREFIX = "@identity ";
|
|
986
|
-
export function setIdentitySecret(tenant, name, config, workspace) {
|
|
1250
|
+
export function setIdentitySecret(tenant, name, config, workspace, limit = {}) {
|
|
987
1251
|
const audience = typeof config.audience === "string" ? config.audience.trim() : "";
|
|
988
1252
|
if (!audience)
|
|
989
1253
|
throw new Error("an identity needs an audience — the value your cloud's trust expects in aud");
|
|
990
1254
|
if (audience.length > 600 || /[\u0000-\u001f\u007f]/.test(audience))
|
|
991
1255
|
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");
|
|
1256
|
+
setSecret(tenant, name, IDENTITY_PREFIX + JSON.stringify({ audience }), workspace, "identity", limit);
|
|
993
1257
|
}
|
|
994
1258
|
export function isIdentityValue(value) {
|
|
995
1259
|
return value.startsWith(IDENTITY_PREFIX);
|
|
@@ -1021,7 +1285,7 @@ export function basicAuthorization(username, password) {
|
|
|
1021
1285
|
throw new Error("Basic credentials cannot contain control characters");
|
|
1022
1286
|
return `Basic ${Buffer.from(`${username}:${password}`, "utf8").toString("base64")}`;
|
|
1023
1287
|
}
|
|
1024
|
-
export function setApiSecret(tenant, name, config, workspace) {
|
|
1288
|
+
export function setApiSecret(tenant, name, config, workspace, limit = {}) {
|
|
1025
1289
|
let all = { ...(config.headers ?? {}) };
|
|
1026
1290
|
if (config.basic) {
|
|
1027
1291
|
if (Object.keys(all).some((h) => h.toLowerCase() === "authorization")) {
|
|
@@ -1056,7 +1320,7 @@ export function setApiSecret(tenant, name, config, workspace) {
|
|
|
1056
1320
|
const base = config.base_url?.trim();
|
|
1057
1321
|
if (base && !/^https?:\/\//.test(base))
|
|
1058
1322
|
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");
|
|
1323
|
+
setSecret(tenant, name, API_PREFIX + JSON.stringify({ ...(base ? { base_url: trimSlashes(base) } : {}), headers: config.headers }), workspace, "api", limit);
|
|
1060
1324
|
}
|
|
1061
1325
|
export function isApiValue(value) {
|
|
1062
1326
|
return value.startsWith(API_PREFIX);
|
|
@@ -1094,7 +1358,7 @@ export function sealVaultBindings(tenant) {
|
|
|
1094
1358
|
const next = { ...all };
|
|
1095
1359
|
for (const [name, value] of plain) {
|
|
1096
1360
|
const rec = all[name];
|
|
1097
|
-
next[name] = sealRecord(tenant, workspace, name, value, { kind: rec.kind, sites: rec.sites, updatedAt: rec.updatedAt }, true);
|
|
1361
|
+
next[name] = sealRecord(tenant, workspace, name, value, { kind: rec.kind, sites: rec.sites, workspaces: rec.workspaces, updatedAt: rec.updatedAt }, true);
|
|
1098
1362
|
}
|
|
1099
1363
|
sealed += plain.size;
|
|
1100
1364
|
return next;
|
|
@@ -1142,7 +1406,7 @@ export function rewrapTenantSecrets(tenant) {
|
|
|
1142
1406
|
return null;
|
|
1143
1407
|
const next = { ...all };
|
|
1144
1408
|
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());
|
|
1409
|
+
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
1410
|
}
|
|
1147
1411
|
moved += plain.size;
|
|
1148
1412
|
return next;
|
package/dist/src/starter.js
CHANGED
|
@@ -373,6 +373,7 @@ All of it, \`/api/workspaces/<workspace>\` unless noted:
|
|
|
373
373
|
| \`/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) |
|
|
374
374
|
| \`/restore\` | POST | the workspace's source back to a point in its history — \`dryRun\` first, then \`confirm\` |
|
|
375
375
|
| \`/vocabulary\` | GET | what this workspace's documents may say |
|
|
376
|
+
| \`/a2a\` | GET | what it serves to outside agents over A2A: flows that say \`a2a:\`, card URLs, recent tasks |
|
|
376
377
|
| \`/check\` | GET | what \`foldrun check\` says about the deployed copy, as findings with rule ids (\`?security=1\`) |
|
|
377
378
|
| \`/assistant\` | POST | ask the assistant, or have it write agents, flows, tools and skills — from the docs only; proposals come back, nothing is written |
|
|
378
379
|
|
|
@@ -382,7 +383,8 @@ Account-level, outside \`/workspaces\`: \`/api/secrets\`, \`/api/keys\`, \`/api/
|
|
|
382
383
|
\`/api/me/inbox\` (what waits on you: gates, questions, issues, proposed fixes, your failed runs),
|
|
383
384
|
\`/api/account/backups\` (snapshots, restore requests), \`/api/me/onboarding\` (getting started),
|
|
384
385
|
\`/api/healthz\` and \`/api/version\` (open), \`/api/changelog\` (release notes)
|
|
385
|
-
and \`/api/metrics\` (Prometheus).
|
|
386
|
+
and \`/api/metrics\` (Prometheus). Outside \`/api\`: \`/a2a/<account>/<ws>/flows/<flow>\`
|
|
387
|
+
(a published flow's agent card and A2A 1.0 JSON-RPC endpoint — an A2A key only, \`foldrun docs a2a\`).
|
|
386
388
|
|
|
387
389
|
Every call answers — and the CLI sends — \`Foldrun-Version: YYYY-MM-DD\` (dated API
|
|
388
390
|
versions; an account pin in Settings → API), \`X-RateLimit-*\` headers (a \`429\`
|
|
@@ -418,6 +420,7 @@ effort: low
|
|
|
418
420
|
overlap: skip # skip | queue — what a second run does while one is live
|
|
419
421
|
priority: normal # high | normal | low — where its runs stand in the queue
|
|
420
422
|
compute: { vcpu: 4, memory: 8Gi, disk: 40Gi } # optional: what one run may hold at once
|
|
423
|
+
a2a: { publish: false } # true: outside agents may call it over A2A (admin only; callers need an A2A key)
|
|
421
424
|
---
|
|
422
425
|
|
|
423
426
|
1. [[researcher]] — Find three sources on {{topic}}.
|
|
@@ -483,6 +486,7 @@ hooks: [no-force-push] # from hooks/ or the library: code at every tool cal
|
|
|
483
486
|
scripts: [summarise.py] # from scripts/, each becomes a callable tool
|
|
484
487
|
apis: [{ name: crm }] # an HTTP API, as a tool
|
|
485
488
|
mcpServers: {} # an MCP server, as a tool
|
|
489
|
+
remotes: [] # outside A2A agents [{name, card, secret?}] — the a2a_send/status/reply/cancel tools
|
|
486
490
|
agents: [writer] # colleagues this one may consult (an answer, no tools)
|
|
487
491
|
subagents: [researcher] # colleagues it may delegate a job to (own context, own tools ≤ its own)
|
|
488
492
|
sandbox: own # delegated to: run each job in its own sandbox (own tools, secrets, network)
|
package/dist/src/store.d.ts
CHANGED
|
@@ -14,6 +14,8 @@ import { frontmatterErrorText } from "./matter.ts";
|
|
|
14
14
|
* the runtime, and there is nothing to fail to import.
|
|
15
15
|
*/
|
|
16
16
|
export declare function readFrontmatter(file: string): Record<string, unknown>;
|
|
17
|
+
import { type A2aPublication } from "./a2a-publish.ts";
|
|
18
|
+
import { type RemoteSpec } from "./a2a-remotes.ts";
|
|
17
19
|
import { type NetworkPolicy } from "./network.ts";
|
|
18
20
|
import { type WireFormat, type AuthShape } from "./providers.ts";
|
|
19
21
|
/**
|
|
@@ -306,6 +308,9 @@ export interface AgentInfo {
|
|
|
306
308
|
effort: string | null;
|
|
307
309
|
tools: string[];
|
|
308
310
|
apis: ApiSpec[];
|
|
311
|
+
/** `remotes:` — the outside A2A agents it may call, the entries that read
|
|
312
|
+
* (a2a-remotes.ts). Never a token: names, card URLs, secret NAMES. */
|
|
313
|
+
remotes?: RemoteSpec[];
|
|
309
314
|
/** The author's own tools named in `tools:` — the subset that is not a
|
|
310
315
|
* built-in. Same list as `tools`, minus what the runtime provides. */
|
|
311
316
|
ownTools: string[];
|
|
@@ -652,6 +657,10 @@ export interface FlowInfo {
|
|
|
652
657
|
owner: string | null;
|
|
653
658
|
/** Which of the three `owner` came from. */
|
|
654
659
|
ownerFrom: OwnerSource | null;
|
|
660
|
+
/** `a2a:` — whether outside agents may call this flow over A2A, and what
|
|
661
|
+
* its agent card says (a2a-publish.ts). Null when absent or unreadable:
|
|
662
|
+
* a block with a problem publishes nothing. */
|
|
663
|
+
a2a?: A2aPublication | null;
|
|
655
664
|
/** Flow-level frontmatter values the parser could not read — a bad
|
|
656
665
|
* `overlap:`/`priority:`/`catchup:`/`on:`/`signature:`, or a step line
|
|
657
666
|
* whose `[[target]]` is malformed. Errors, the way a bad step option is,
|
|
@@ -1445,6 +1454,11 @@ export interface RunRecord {
|
|
|
1445
1454
|
* expressed only as a status, because "failed" and "someone stopped it"
|
|
1446
1455
|
* are different facts and the trace should not conflate them. */
|
|
1447
1456
|
stopRequested?: boolean;
|
|
1457
|
+
/** Who stopped it, as the trail says it, when that was not a person
|
|
1458
|
+
* stopping it from the dashboard, the CLI or the API: "A2A caller
|
|
1459
|
+
* <key label>" for an A2A CancelTask (1 Nov 2026). Absent: a person.
|
|
1460
|
+
* Written by stopRun beside stopRequested, carried the same way. */
|
|
1461
|
+
stoppedBy?: string;
|
|
1448
1462
|
/**
|
|
1449
1463
|
* What this run concluded, in one line — the first line of the last step
|
|
1450
1464
|
* that produced a result, captured when the run finishes.
|
|
@@ -1523,6 +1537,17 @@ export interface RunRecord {
|
|
|
1523
1537
|
* step of this run — never the tokens. A link is good once; the second
|
|
1524
1538
|
* click is a 409, not a second approval. Written by approvals.ts. */
|
|
1525
1539
|
approvalLinksUsed?: string[];
|
|
1540
|
+
/** What started it, when that is not a person, a schedule or an event the
|
|
1541
|
+
* flow's own trigger names: "a2a" — an outside agent, over A2A (1 Nov
|
|
1542
|
+
* 2026). Absent for every other run. */
|
|
1543
|
+
trigger?: "a2a";
|
|
1544
|
+
/** For an A2A run: the A2A task it serves and the id of the A2A key (its
|
|
1545
|
+
* first id, kept across rotations) that asked. A correlation only — the
|
|
1546
|
+
* protocol's own state lives in the platform's a2a_tasks. */
|
|
1547
|
+
a2a?: {
|
|
1548
|
+
task: string;
|
|
1549
|
+
key: string;
|
|
1550
|
+
};
|
|
1526
1551
|
steps: StepRecord[];
|
|
1527
1552
|
}
|
|
1528
1553
|
/** Merge one part (the input, on the call; the output, on its completion)
|
|
@@ -1643,6 +1668,12 @@ export interface RunSummary {
|
|
|
1643
1668
|
revision?: string | null;
|
|
1644
1669
|
/** A step is parked for a person — what the approvals banner counts. */
|
|
1645
1670
|
awaitingApproval: boolean;
|
|
1671
|
+
/** RunRecord.trigger and .a2a: an outside agent started it. */
|
|
1672
|
+
trigger?: "a2a";
|
|
1673
|
+
a2a?: {
|
|
1674
|
+
task: string;
|
|
1675
|
+
key: string;
|
|
1676
|
+
};
|
|
1646
1677
|
steps: RunSummaryStep[];
|
|
1647
1678
|
}
|
|
1648
1679
|
/** One run's row. */
|
|
@@ -1661,6 +1692,9 @@ export declare function listRunSummaries(tenant: string, workspace: string): Run
|
|
|
1661
1692
|
export declare function listAllRunSummaries(tenant: string): (RunSummary & {
|
|
1662
1693
|
workspace: string;
|
|
1663
1694
|
})[];
|
|
1695
|
+
/** Who stopped a run, for a trail line: "a person" unless stopRun was told
|
|
1696
|
+
* otherwise (RunRecord.stoppedBy). */
|
|
1697
|
+
export declare function stoppedByText(run: Pick<RunRecord, "stoppedBy"> | null | undefined): string;
|
|
1664
1698
|
/**
|
|
1665
1699
|
* The status a person should see. "failed" and "someone stopped it" are
|
|
1666
1700
|
* different facts — the record keeps them apart (stopRequested), and the
|