@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.
Files changed (65) hide show
  1. package/dist/src/a2a-client.d.ts +165 -0
  2. package/dist/src/a2a-client.js +440 -0
  3. package/dist/src/a2a-publish.d.ts +37 -0
  4. package/dist/src/a2a-publish.js +116 -0
  5. package/dist/src/a2a-remotes.d.ts +73 -0
  6. package/dist/src/a2a-remotes.js +192 -0
  7. package/dist/src/a2a-tools.d.ts +148 -0
  8. package/dist/src/a2a-tools.js +833 -0
  9. package/dist/src/api-tools.js +15 -10
  10. package/dist/src/approvals.d.ts +18 -10
  11. package/dist/src/approvals.js +51 -1
  12. package/dist/src/browser-pod.js +4 -0
  13. package/dist/src/catalog.d.ts +14 -0
  14. package/dist/src/catalog.js +28 -0
  15. package/dist/src/completions.js +8 -8
  16. package/dist/src/cron.d.ts +9 -0
  17. package/dist/src/cron.js +20 -0
  18. package/dist/src/frontmatter-fields.js +3 -1
  19. package/dist/src/frontmatter-schema.d.ts +5 -1
  20. package/dist/src/frontmatter-schema.js +44 -1
  21. package/dist/src/limits.js +22 -4
  22. package/dist/src/notify.d.ts +6 -3
  23. package/dist/src/notify.js +25 -12
  24. package/dist/src/package.d.ts +5 -1
  25. package/dist/src/package.js +5 -3
  26. package/dist/src/run-container.d.ts +10 -1
  27. package/dist/src/run-container.js +17 -0
  28. package/dist/src/runner.d.ts +10 -0
  29. package/dist/src/runner.js +163 -16
  30. package/dist/src/scan.js +123 -5
  31. package/dist/src/secrets.d.ts +122 -10
  32. package/dist/src/secrets.js +291 -27
  33. package/dist/src/starter.js +5 -1
  34. package/dist/src/store.d.ts +34 -0
  35. package/dist/src/store.js +28 -7
  36. package/dist/src/subagents.d.ts +16 -0
  37. package/dist/src/subagents.js +23 -2
  38. package/dist/src/triggers.js +2 -2
  39. package/dist/src/webhook.d.ts +19 -3
  40. package/dist/src/webhook.js +14 -5
  41. package/package.json +4 -1
  42. package/src/a2a-client.ts +517 -0
  43. package/src/a2a-publish.ts +120 -0
  44. package/src/a2a-remotes.ts +225 -0
  45. package/src/a2a-tools.ts +919 -0
  46. package/src/api-tools.ts +20 -8
  47. package/src/approvals.ts +55 -1
  48. package/src/browser-pod.ts +3 -0
  49. package/src/catalog.ts +27 -0
  50. package/src/completions.ts +8 -8
  51. package/src/cron.ts +19 -0
  52. package/src/frontmatter-fields.ts +3 -1
  53. package/src/frontmatter-schema.ts +42 -2
  54. package/src/limits.ts +22 -4
  55. package/src/notify.ts +23 -12
  56. package/src/package.ts +5 -3
  57. package/src/run-container.ts +22 -0
  58. package/src/runner.ts +157 -13
  59. package/src/scan.ts +146 -6
  60. package/src/secrets.ts +319 -33
  61. package/src/starter.ts +5 -1
  62. package/src/store.ts +47 -2
  63. package/src/subagents.ts +29 -2
  64. package/src/triggers.ts +2 -2
  65. package/src/webhook.ts +22 -5
package/dist/src/store.js CHANGED
@@ -45,6 +45,8 @@ export function readFrontmatter(file) {
45
45
  import { ownToolNames, legacyUseNames, retiredToolNames } from "./tool-names.js";
46
46
  import { refNames } from "./refs.js";
47
47
  import { checkFilter } from "./trigger-filter.js";
48
+ import { parseA2aPublish } from "./a2a-publish.js";
49
+ import { parseRemotes, remoteNetworkHosts, A2A_LIMIT_KEY } from "./a2a-remotes.js";
48
50
  import { parseBudget, budgetProblem } from "./budget.js";
49
51
  import { timezoneProblem } from "./clock.js";
50
52
  import { parseStepLimits, readLimits } from "./limits.js";
@@ -1115,6 +1117,9 @@ function inlineToolNames(data) {
1115
1117
  }
1116
1118
  if (data.mcpServers && typeof data.mcpServers === "object")
1117
1119
  out.push(...Object.keys(data.mcpServers));
1120
+ // remotes: grants the a2a tools, counted under `limits: {a2a: N}`.
1121
+ if (parseRemotes(data.remotes).length)
1122
+ out.push(A2A_LIMIT_KEY);
1118
1123
  return out;
1119
1124
  }
1120
1125
  export function listAgents(tenant, workspace) {
@@ -1158,12 +1163,16 @@ export function listAgents(tenant, workspace) {
1158
1163
  workspace: workspaceFront.network,
1159
1164
  agent: data.network,
1160
1165
  readsWeb,
1161
- implied: parseApis(data.apis).map((a) => { try {
1162
- return new URL(a.base).host;
1163
- }
1164
- catch {
1165
- return "";
1166
- } }),
1166
+ implied: [
1167
+ ...parseApis(data.apis).map((a) => { try {
1168
+ return new URL(a.base).host;
1169
+ }
1170
+ catch {
1171
+ return "";
1172
+ } }),
1173
+ // remotes: — each card's own host, and nothing its card says.
1174
+ ...remoteNetworkHosts(parseRemotes(data.remotes)),
1175
+ ],
1167
1176
  }),
1168
1177
  readsWeb,
1169
1178
  };
@@ -1176,6 +1185,7 @@ export function listAgents(tenant, workspace) {
1176
1185
  effort: data.effort ?? null,
1177
1186
  tools,
1178
1187
  apis: parseApis(data.apis),
1188
+ remotes: parseRemotes(data.remotes),
1179
1189
  ownTools: ownToolNames(data),
1180
1190
  legacyUse: legacyUseNames(data),
1181
1191
  retiredTools: retiredToolNames(data),
@@ -1689,6 +1699,7 @@ export function parseFlow(file, raw) {
1689
1699
  approveWithin: durationOf(data.approve_within),
1690
1700
  description: typeof data.description === "string" ? data.description.trim() || null : null,
1691
1701
  ...resolveOwner(data.owner, undefined, undefined, "flow"),
1702
+ a2a: parseA2aPublish(data.a2a),
1692
1703
  frontProblems,
1693
1704
  steps,
1694
1705
  };
@@ -2505,7 +2516,9 @@ export function renameWorkspaceFile(tenant, workspace, from, to, meta = {}) {
2505
2516
  // Refuse with the reason before any path validation: "not an editable
2506
2517
  // path" is true of AGENTS.md too, and the generic sentence buries the
2507
2518
  // specific one worth reading.
2508
- const why = anchoredReason(from) ?? anchoredReason(to);
2519
+ // Read as the paths the rename will use: `./AGENTS.md` is AGENTS.md
2520
+ // (1 Nov 2026 — the raw spelling slipped past this and still moved it).
2521
+ const why = anchoredReason(path.normalize(from)) ?? anchoredReason(path.normalize(to));
2509
2522
  if (why)
2510
2523
  throw new Error(why);
2511
2524
  const dir = workspaceDir(tenant, workspace);
@@ -3014,6 +3027,8 @@ export function summarizeRun(run) {
3014
3027
  ...(run.startedBy ? { startedBy: run.startedBy } : {}),
3015
3028
  ...(run.parkedAt ? { parkedAt: run.parkedAt } : {}),
3016
3029
  ...(run.revision ? { revision: run.revision } : {}),
3030
+ ...(run.trigger ? { trigger: run.trigger } : {}),
3031
+ ...(run.a2a ? { a2a: run.a2a } : {}),
3017
3032
  awaitingApproval: run.steps.some((s) => s.status === "awaiting-approval" && s.waitFor !== "event"),
3018
3033
  steps: run.steps.map((s) => {
3019
3034
  const error = s.status === "failed" ? s.events.filter((e) => e.type === "error").at(-1)?.text : undefined;
@@ -3212,6 +3227,12 @@ export function listAllRunSummaries(tenant) {
3212
3227
  .flatMap((p) => listRunSummaries(tenant, p.name).map((s) => ({ ...s, workspace: p.name })))
3213
3228
  .sort((a, b) => b.startedAt.localeCompare(a.startedAt));
3214
3229
  }
3230
+ /** Who stopped a run, for a trail line: "a person" unless stopRun was told
3231
+ * otherwise (RunRecord.stoppedBy). */
3232
+ export function stoppedByText(run) {
3233
+ const by = run?.stoppedBy?.trim();
3234
+ return by ? by : "a person";
3235
+ }
3215
3236
  /**
3216
3237
  * The status a person should see. "failed" and "someone stopped it" are
3217
3238
  * different facts — the record keeps them apart (stopRequested), and the
@@ -1,3 +1,15 @@
1
+ /**
2
+ * The a2a tools (remotes:) are the parent's: one server, built for the
3
+ * PARENT's remotes with the parent's credentials, under the same four names
4
+ * whatever remotes a sub-agent declares. Intersecting names would hand a
5
+ * shared-sandbox sub-agent every remote its parent may call. So they are
6
+ * withheld from it entirely — not in its list, and refused if it calls one.
7
+ * A `sandbox: own` sub-agent runs as a step of its own, from its own file,
8
+ * and gets its OWN remotes there (runner.ts delegateFor → runStep).
9
+ */
10
+ export declare function isA2aTool(tool: string): boolean;
11
+ /** What a shared-sandbox sub-agent is told, and what its refused call says. */
12
+ export declare const SUBAGENT_NO_REMOTES = "remotes: are not available to shared-sandbox sub-agents yet \u2014 give the sub-agent `sandbox: own`, or call the remote from the parent";
1
13
  /** Plain JSON: this crosses into the run container. */
2
14
  export interface SubagentSpec {
3
15
  name: string;
@@ -50,6 +62,10 @@ export declare function subagentPrompt(o: {
50
62
  parent: string;
51
63
  workspace: string;
52
64
  shared?: string | null;
65
+ }, extra?: {
66
+ /** It declares remotes:, which it does not get here. A second argument,
67
+ * so the signature only widened. */
68
+ remotesWithheld?: boolean;
53
69
  }): string;
54
70
  /** SDK definitions. `tools` is always set — an absent list inherits every
55
71
  * tool the parent has, which is the one thing this module exists to stop. */
@@ -27,6 +27,21 @@ import fs from "node:fs";
27
27
  import path from "node:path";
28
28
  import { refNames } from "./refs.js";
29
29
  import { TOOL_MAP } from "./tool-names.js";
30
+ import { A2A_SERVER } from "./a2a-remotes.js";
31
+ /**
32
+ * The a2a tools (remotes:) are the parent's: one server, built for the
33
+ * PARENT's remotes with the parent's credentials, under the same four names
34
+ * whatever remotes a sub-agent declares. Intersecting names would hand a
35
+ * shared-sandbox sub-agent every remote its parent may call. So they are
36
+ * withheld from it entirely — not in its list, and refused if it calls one.
37
+ * A `sandbox: own` sub-agent runs as a step of its own, from its own file,
38
+ * and gets its OWN remotes there (runner.ts delegateFor → runStep).
39
+ */
40
+ export function isA2aTool(tool) {
41
+ return tool === `mcp__${A2A_SERVER}` || tool.startsWith(`mcp__${A2A_SERVER}__`);
42
+ }
43
+ /** What a shared-sandbox sub-agent is told, and what its refused call says. */
44
+ export const SUBAGENT_NO_REMOTES = "remotes: are not available to shared-sandbox sub-agents yet — give the sub-agent `sandbox: own`, or call the remote from the parent";
30
45
  /** Does this agent run in its own sandbox when delegated to? */
31
46
  export function ownSandbox(front) {
32
47
  return front.sandbox === "own";
@@ -63,7 +78,7 @@ export function subagentTools(own, parent) {
63
78
  const servers = new Set(parent.filter((t) => /^mcp__[^_]/.test(t)).map((t) => t.replace(/__\*$/, "")).filter((t) => !t.slice(5).includes("__")));
64
79
  const out = [];
65
80
  for (const t of own) {
66
- if (DELEGATE_TOOLS.includes(t) || out.includes(t))
81
+ if (DELEGATE_TOOLS.includes(t) || isA2aTool(t) || out.includes(t))
67
82
  continue;
68
83
  const server = /^mcp__([^_].*?)__/.exec(t)?.[1];
69
84
  if (held.has(t) || (server && servers.has(`mcp__${server}`)))
@@ -73,9 +88,12 @@ export function subagentTools(own, parent) {
73
88
  }
74
89
  /** The sub-agent's system prompt: its own prose, told where it stands. It
75
90
  * works in the PARENT's directory — the SDK shares the session's cwd. */
76
- export function subagentPrompt(o) {
91
+ export function subagentPrompt(o, extra = {}) {
77
92
  return [
78
93
  o.body.trim(),
94
+ ...(extra.remotesWithheld
95
+ ? [`# Outside agents\n\nYour remotes: are not available to you as ${o.parent}'s sub-agent: ${SUBAGENT_NO_REMOTES}. If the job needs an outside agent, say so in your reply and ${o.parent} can call it.`]
96
+ : []),
79
97
  `# Where you are\n\n` +
80
98
  `You are ${o.name}, a sub-agent started by ${o.parent} to do one job and report back. ` +
81
99
  `You work in ${o.parent}'s directory, \`agents/${o.parent}/\` in the \`${o.workspace}\` workspace: ` +
@@ -131,6 +149,9 @@ export function subagentGuard(specs) {
131
149
  if (isDelegate(tool)) {
132
150
  return `${s.name} is a sub-agent and cannot start another — do the work with your own tools`;
133
151
  }
152
+ // The parent's remotes, with the parent's credentials: never a sub-agent's.
153
+ if (isA2aTool(tool))
154
+ return `${tool} is not available to ${s.name}: ${SUBAGENT_NO_REMOTES}`;
134
155
  if (s.disallowedTools.includes(tool))
135
156
  return `${tool} is disallowed for ${s.name}`;
136
157
  if (subagentTools([tool], s.tools).length === 0)
@@ -14,7 +14,7 @@
14
14
  import crypto from "node:crypto";
15
15
  import { platform } from "./platform.js";
16
16
  import { listFlows, readRun, runVerdict } from "./store.js";
17
- import { getSecret } from "./secrets.js";
17
+ import { getSecret, signingSecretShown } from "./secrets.js";
18
18
  /** The last step's result — the same reading runner.ts makes, kept local so
19
19
  * this module never imports the runner (the queue imports both). */
20
20
  function runResult(run) {
@@ -251,7 +251,7 @@ export function verifyWebhookSignature(tenant, workspace, flow, rawBody, header,
251
251
  return { ok: false, reason: `signature: ${flow.signature} needs signing_secret: \${NAME}` };
252
252
  const secret = getSecret(tenant, flow.signingSecret, workspace);
253
253
  if (!secret)
254
- return { ok: false, reason: `signing secret ${flow.signingSecret} is not set` };
254
+ return { ok: false, reason: `signing secret ${signingSecretShown(flow.signingSecret)} is not set` };
255
255
  const hmac = (data) => crypto.createHmac("sha256", secret.value).update(data).digest("hex");
256
256
  const fresh = (ts) => Math.abs(now / 1000 - ts) <= 300;
257
257
  switch (flow.signature) {
@@ -27,14 +27,30 @@ export declare function webhookPath(tenant: string, workspace: string, flow: str
27
27
  * working hook token.
28
28
  */
29
29
  export declare function approveToken(tenant: string, workspace: string, runId: string): string;
30
+ /** One parked `wait: event` gate: the step's index in the run record and
31
+ * the moment it parked (the `t` of its "waiting for an external event"
32
+ * trace line). */
33
+ export interface EventGate {
34
+ step: number;
35
+ at: string;
36
+ }
30
37
  /**
31
38
  * The token that releases a run parked on `wait: event`. Its own HMAC
32
39
  * family, so an approval link cannot double as an event URL or the reverse.
40
+ *
41
+ * With `gate`, the token is for that one park of that one step (30 Oct
42
+ * 2026). The per-run token released every later `wait: event` of the run,
43
+ * so a URL handed to a third party for the first gate also opened the
44
+ * second, and never expired. A gate's token dies with its park: the step
45
+ * released is no longer waiting, and parking again (a loop) stamps a new
46
+ * `at`. The per-run form, without `gate`, is what URLs printed before then
47
+ * carry — the events route still takes it for a step parked under it.
33
48
  */
34
- export declare function eventToken(tenant: string, workspace: string, runId: string): string;
49
+ export declare function eventToken(tenant: string, workspace: string, runId: string, gate?: EventGate): string;
35
50
  /** The URL a `wait: event` step is released at — absolute when the install
36
- * knows its origin, a path otherwise. */
37
- export declare function eventUrl(tenant: string, workspace: string, runId: string): string;
51
+ * knows its origin, a path otherwise. With `gate`, that step's own URL
52
+ * (`&step=<index>`); the route finds the park's moment on the record. */
53
+ export declare function eventUrl(tenant: string, workspace: string, runId: string, gate?: EventGate): string;
38
54
  /**
39
55
  * Where this install can be reached from outside, or null. The platform has
40
56
  * never needed to know its own address — every URL it hands out has been a
@@ -80,18 +80,27 @@ export function approveToken(tenant, workspace, runId) {
80
80
  /**
81
81
  * The token that releases a run parked on `wait: event`. Its own HMAC
82
82
  * family, so an approval link cannot double as an event URL or the reverse.
83
+ *
84
+ * With `gate`, the token is for that one park of that one step (30 Oct
85
+ * 2026). The per-run token released every later `wait: event` of the run,
86
+ * so a URL handed to a third party for the first gate also opened the
87
+ * second, and never expired. A gate's token dies with its park: the step
88
+ * released is no longer waiting, and parking again (a loop) stamps a new
89
+ * `at`. The per-run form, without `gate`, is what URLs printed before then
90
+ * carry — the events route still takes it for a step parked under it.
83
91
  */
84
- export function eventToken(tenant, workspace, runId) {
92
+ export function eventToken(tenant, workspace, runId, gate) {
85
93
  return crypto
86
94
  .createHmac("sha256", installKey())
87
- .update(`event:${tenant}/${workspace}/${runId}`)
95
+ .update(gate ? `event:${tenant}/${workspace}/${runId}#${gate.step}@${gate.at}` : `event:${tenant}/${workspace}/${runId}`)
88
96
  .digest("hex")
89
97
  .slice(0, 32);
90
98
  }
91
99
  /** The URL a `wait: event` step is released at — absolute when the install
92
- * knows its origin, a path otherwise. */
93
- export function eventUrl(tenant, workspace, runId) {
94
- const p = `/api/events/${tenant}/${workspace}/${runId}?token=${eventToken(tenant, workspace, runId)}`;
100
+ * knows its origin, a path otherwise. With `gate`, that step's own URL
101
+ * (`&step=<index>`); the route finds the park's moment on the record. */
102
+ export function eventUrl(tenant, workspace, runId, gate) {
103
+ const p = `/api/events/${tenant}/${workspace}/${runId}?token=${eventToken(tenant, workspace, runId, gate)}${gate ? `&step=${gate.step}` : ""}`;
95
104
  const base = publicUrl();
96
105
  return base ? `${base}${p}` : p;
97
106
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@foldrun/core",
3
- "version": "9.0.0",
3
+ "version": "9.2.0",
4
4
  "description": "Agents are just folders. The format, the runner, the checks.",
5
5
  "license": "FSL-1.1-ALv2",
6
6
  "type": "module",
@@ -17,6 +17,9 @@
17
17
  "foldrun": {
18
18
  "publicApi": [
19
19
  ".",
20
+ "./a2a-client",
21
+ "./a2a-publish",
22
+ "./a2a-remotes",
20
23
  "./agents-md",
21
24
  "./browser-pod",
22
25
  "./flow-patterns",