@finchip.ai/foga 0.6.0-alpha.6 → 0.6.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 (44) hide show
  1. package/README.md +35 -14
  2. package/dist/a2a/card.js +54 -0
  3. package/dist/a2a/server.js +276 -0
  4. package/dist/a2a/types.js +20 -0
  5. package/dist/build/bundle.js +2 -1
  6. package/dist/cli-cell.js +176 -16
  7. package/dist/cli.js +150 -5
  8. package/dist/core/world.js +17 -0
  9. package/dist/demo/birth.js +11 -5
  10. package/dist/demo/loop-d.js +39 -1
  11. package/dist/demo/quickstart.js +97 -11
  12. package/dist/genesis/operator-scope.js +1 -0
  13. package/dist/genesis/probe.js +23 -7
  14. package/dist/http/access.js +105 -0
  15. package/dist/http/server.js +84 -43
  16. package/dist/http/signed.js +136 -0
  17. package/dist/mcp/server.js +48 -1
  18. package/dist/node/advise.js +134 -0
  19. package/dist/node/config.js +55 -1
  20. package/dist/node/documents.js +298 -0
  21. package/dist/node/preflight.js +208 -0
  22. package/dist/node/propagation.js +95 -0
  23. package/dist/node/wallet.js +132 -0
  24. package/dist/tools/documents.js +74 -0
  25. package/dist/tools/index.js +3 -1
  26. package/dist/tools/propagation.js +35 -0
  27. package/dist/tools/tenders.js +22 -6
  28. package/dist/util/version.js +2 -0
  29. package/dist/world/sourcify.js +165 -0
  30. package/docs/a2a.md +31 -0
  31. package/docs/design-memo.md +23 -6
  32. package/docs/documents.md +25 -0
  33. package/docs/examples/README.md +2 -2
  34. package/docs/extending.md +49 -0
  35. package/docs/loop-d.md +11 -0
  36. package/docs/mcp.md +18 -11
  37. package/docs/node.md +35 -11
  38. package/docs/og-deployment.md +131 -0
  39. package/docs/propagation.md +41 -0
  40. package/docs/quickstart.md +36 -21
  41. package/docs/wallet.md +62 -0
  42. package/docs/world.md +12 -0
  43. package/package.json +1 -1
  44. package/server.json +2 -2
@@ -1,12 +1,15 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { existsSync, mkdirSync, readdirSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
- import { join, resolve } from "node:path";
4
+ import { join, resolve, sep } from "node:path";
5
5
  import { formatEther, parseEther } from "ethers";
6
6
  import { anvilKeys } from "../genesis/roles.js";
7
7
  import { Kernel } from "../kernel/kernel.js";
8
8
  import { newApiKey } from "../http/server.js";
9
9
  import { loadCellConfig, saveCellConfig } from "../node/config.js";
10
+ import { Node } from "../node/node.js";
11
+ import { defaultCatalog, OPERATOR_CALLER } from "../tools/index.js";
12
+ import { ensureWallet, ensuredKey, walletDir } from "../node/wallet.js";
10
13
  import { repoRoot } from "../util/fs.js";
11
14
  import { deployWorld } from "../world/deploy.js";
12
15
  import { ANVIL_INSTALL_HINT, anvilBinary, anvilVersion, launchAnvil, portInUse, portProblem, stopChild } from "./anvil.js";
@@ -14,7 +17,8 @@ import { birthCell } from "./birth.js";
14
17
  import { mintCases } from "./demander.js";
15
18
  import { cleanEnv, ownCli, waitForHttp } from "./loop-d.js";
16
19
  export const MARKER = "quickstart.json";
17
- export const PACKAGE_NAME = "@finchip.ai/foga";
20
+ export { PACKAGE_NAME } from "../util/version.js";
21
+ import { PACKAGE_NAME } from "../util/version.js";
18
22
  /** How a client should start this very CLI: the published package through npx, or this checkout's compiled launcher. */
19
23
  export function clientLauncher() {
20
24
  const root = repoRoot();
@@ -49,6 +53,12 @@ export async function runQuickstart(o = {}) {
49
53
  const listen = o.listen === undefined ? "127.0.0.1:8787" : o.listen;
50
54
  const name = o.name ?? "quick";
51
55
  const rpc = `http://127.0.0.1:${port}`;
56
+ const walletPath = resolve(o.wallet ?? walletDir());
57
+ const nodeUrlPlanned = listen ? (() => {
58
+ const i = listen.lastIndexOf(":");
59
+ const host = listen.slice(0, i) || "127.0.0.1";
60
+ return `http://${host === "0.0.0.0" || host === "::" ? "127.0.0.1" : host}:${listen.slice(i + 1)}`;
61
+ })() : null;
52
62
  // --- say what is wrong before touching anything
53
63
  const anvilBin = anvilBinary();
54
64
  const version = anvilVersion(anvilBin.path);
@@ -62,6 +72,8 @@ export async function runQuickstart(o = {}) {
62
72
  if (await portInUse(lp))
63
73
  throw new Error(`port ${lp} is in use; pass --listen <host:port> for the node (or --no-serve to run no node)`);
64
74
  }
75
+ if (walletPath === dir || walletPath.startsWith(dir + sep))
76
+ throw new Error(`the wallet (${walletPath}) must live outside the quickstart directory (${dir}), which is cleared on every run`);
65
77
  if (existsSync(dir)) {
66
78
  const entries = readdirSync(dir);
67
79
  if (entries.length && !entries.includes(MARKER))
@@ -74,18 +86,34 @@ export async function runQuickstart(o = {}) {
74
86
  mkdirSync(dir, { recursive: true });
75
87
  const keysDir = join(dir, "keys");
76
88
  mkdirSync(keysDir, { recursive: true, mode: 0o700 });
89
+ // --- the wallet: the first step; made now when there is none, reused when there is; never a reason to stop
90
+ const wallet = ensureWallet(walletPath, { name: "default" });
91
+ const singleKey = wallet.wallet.roles.length === 1 && wallet.wallet.roles[0] === "signer";
92
+ const walletAddrs = singleKey ? `identity ${wallet.wallet.addresses.signer}` : `owner ${wallet.wallet.addresses.owner}, operator ${wallet.wallet.addresses.operator}, guardian ${wallet.wallet.addresses.guardian}`;
93
+ if (wallet.ephemeral)
94
+ log(`quickstart: wallet: could not write ${walletPath} (${wallet.reason}); using keys made in memory for this run — they are not kept`);
95
+ else
96
+ log(`quickstart: wallet: ${wallet.created ? "made" : "using"} ${wallet.wallet.dir} — ${walletAddrs}`);
77
97
  // --- the chain and the world
78
98
  log(`quickstart: starting ${version} on ${rpc} (log ${join(dir, "anvil.log")})`);
79
99
  const anvil = await launchAnvil({ port, logPath: join(dir, "anvil.log") });
80
100
  let node = null;
81
101
  let endedWith = null;
82
102
  const ended = new Promise((r) => (endedWith = r));
83
- anvil.on("exit", (code, signal) => endedWith?.(`anvil stopped (${code !== null ? `exit code ${code}` : `signal ${signal}`})`));
103
+ // a stop asked for (Ctrl-C, --exit, SIGTERM) ends the children in order; their exits are then expected and do not end the run
104
+ // early — otherwise the node's exit would resolve `ended` while anvil is still being stopped and leave the chain running
105
+ let stopping = false;
106
+ anvil.on("exit", (code, signal) => {
107
+ if (!stopping)
108
+ endedWith?.(`anvil stopped (${code !== null ? `exit code ${code}` : `signal ${signal}`})`);
109
+ });
84
110
  const stop = async () => {
111
+ stopping = true;
85
112
  if (node)
86
113
  await stopChild(node);
87
114
  node = null;
88
115
  await stopChild(anvil);
116
+ endedWith?.("stopped");
89
117
  };
90
118
  try {
91
119
  const worldPath = join(dir, "world.json");
@@ -106,6 +134,7 @@ export async function runQuickstart(o = {}) {
106
134
  configPath: join(dir, "cell.config.json"),
107
135
  storePath: join(dir, "cell.ops.sqlite"),
108
136
  description: `Cell ${name}: born by foga quickstart on a local anvil chain`,
137
+ keys: { owner: ensuredKey(wallet, "owner"), operator: ensuredKey(wallet, "operator"), guardian: ensuredKey(wallet, "guardian") },
109
138
  log: (line) => log(`quickstart: ${line}`),
110
139
  configure: (cfg) => {
111
140
  cfg.policy.budget = { dailyValueWei: parseEther("0.002").toString(), perOperationValueWei: parseEther("0.001").toString(), dailyGasWei: parseEther("0.05").toString(), maxConcurrentCommitments: 2 };
@@ -113,6 +142,8 @@ export async function runQuickstart(o = {}) {
113
142
  cfg.runtime.pollSeconds = 2;
114
143
  cfg.runtime.http.listen = listen ?? "127.0.0.1:8787";
115
144
  cfg.runtime.http.rateLimitPerMinute = 600;
145
+ if (nodeUrlPlanned)
146
+ cfg.runtime.http.publicUrl = nodeUrlPlanned;
116
147
  },
117
148
  });
118
149
  }
@@ -121,13 +152,16 @@ export async function runQuickstart(o = {}) {
121
152
  k.world.provider.destroy();
122
153
  }
123
154
  const configPath = cell.configPath;
124
- // --- the keys, each in its own 0600 file (anvil money; nothing of value), and an act-level API key for the HTTP entrance
155
+ // --- the key files the configurations name: the wallet's own; an in-memory wallet is written beside the cell so the run still works
125
156
  const keyFile = (file, value) => {
126
157
  const path = join(keysDir, file);
127
158
  writeFileSync(path, value + "\n", { mode: 0o600 });
128
159
  return path;
129
160
  };
130
- const keyPaths = { owner: keyFile("owner.key", cell.owner.privateKey), operator: keyFile("operator.key", cell.operator.privateKey), guardian: keyFile("guardian.key", cell.guardian.privateKey) };
161
+ const keyPaths = wallet.ephemeral
162
+ ? { owner: keyFile("owner.key", cell.owner.privateKey), operator: keyFile("operator.key", cell.operator.privateKey), guardian: keyFile("guardian.key", cell.guardian.privateKey) }
163
+ : { owner: wallet.wallet.files.owner, operator: wallet.wallet.files.operator, guardian: wallet.wallet.files.guardian };
164
+ // an act-level API key for the HTTP entrance
131
165
  const api = newApiKey("agent");
132
166
  {
133
167
  const loaded = loadCellConfig(configPath);
@@ -135,6 +169,23 @@ export async function runQuickstart(o = {}) {
135
169
  saveCellConfig(configPath, loaded.config);
136
170
  }
137
171
  const agentKeyPath = keyFile("agent.api-key", api.key);
172
+ // --- the cell says what it is: both skills offered, the documents built and committed on chain (the operator's act, in this process)
173
+ {
174
+ const loaded = loadCellConfig(configPath);
175
+ loaded.config.skills.offered = Object.keys(loaded.config.skills.held);
176
+ saveCellConfig(configPath, loaded.config);
177
+ const publisher = await Node.open({ configPath, rpc, keys: { operator: ensuredKey(wallet, "operator") }, log: () => { } });
178
+ try {
179
+ const r = await defaultCatalog().invoke(publisher, "docs_publish", {}, OPERATOR_CALLER, { mode: "act" });
180
+ if (!r.ok)
181
+ throw new Error(`docs_publish: ${r.gate}: ${r.reason}`);
182
+ const out = r.output;
183
+ log(`quickstart: documents v${out.version} built in ${out.dir} and committed on chain (${out.committed.join(", ")})`);
184
+ }
185
+ finally {
186
+ publisher.close();
187
+ }
188
+ }
138
189
  // --- two tenders for the cell to find: a probe of the cell itself and an extraction over the fixture
139
190
  let minted = [];
140
191
  if (o.tenders ?? true) {
@@ -149,12 +200,17 @@ export async function runQuickstart(o = {}) {
149
200
  const host = listen.slice(0, i) || "127.0.0.1";
150
201
  nodeUrl = `http://${host === "0.0.0.0" || host === "::" ? "127.0.0.1" : host}:${listen.slice(i + 1)}`;
151
202
  node = spawn(cli.command, [...cli.args, "node", "serve", "--config", configPath, "--rpc", rpc, "--listen", listen], { env: cleanEnv({ FOGA_OPERATOR_KEY_FILE: keyPaths.operator }), stdio: ["ignore", "ignore", "pipe"] });
203
+ // the node's lines are relayed, except a watcher tick in which nothing happened (every two seconds, forever)
204
+ const idleTick = /: anchor ACTIVE, heartbeat not sent \([^)]*\); 0 new tender\(s\); 0 settled, 0 withdrawn; safety Satisfied$/;
152
205
  node.stderr.on("data", (d) => {
153
206
  for (const line of String(d).split("\n"))
154
- if (line.trim())
207
+ if (line.trim() && !idleTick.test(line.trim()))
155
208
  log(` node| ${line}`);
156
209
  });
157
- node.on("exit", (code, signal) => endedWith?.(`the node stopped (${code !== null ? `exit code ${code}` : `signal ${signal}`})`));
210
+ node.on("exit", (code, signal) => {
211
+ if (!stopping)
212
+ endedWith?.(`the node stopped (${code !== null ? `exit code ${code}` : `signal ${signal}`})`);
213
+ });
158
214
  await waitForHttp(`${nodeUrl}/healthz`);
159
215
  // --- the entrance answers: health, a public read, the agent key's pre-check of the first tender, the MCP handshake and tool list
160
216
  const health = (await (await fetch(`${nodeUrl}/healthz`)).json());
@@ -177,7 +233,15 @@ export async function runQuickstart(o = {}) {
177
233
  const toolCount = (toolsText.match(/"name":"/g) ?? []).length;
178
234
  if (toolCount < 20)
179
235
  throw new Error(`the MCP entrance listed ${toolCount} tool(s) for the agent key; expected the act-level set`);
180
- log(`quickstart: the entrance answers — /healthz ok, cell_status names ${cell.account}, tender ${minted[0]?.tokenId ?? "-"} eligible for the agent key, MCP initialize ok, ${toolCount} tools`);
236
+ const cardRes = await fetch(`${nodeUrl}/.well-known/agent-card.json`);
237
+ const cardDoc = (await cardRes.json());
238
+ if (!cardRes.ok || cardDoc.supportedInterfaces?.[0]?.url !== `${nodeUrl}/a2a`)
239
+ throw new Error(`the agent card at /.well-known/agent-card.json does not name ${nodeUrl}/a2a`);
240
+ const quoteRes = await fetch(`${nodeUrl}/a2a`, { method: "POST", headers: { "content-type": "application/json", "a2a-version": "1.0" }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "SendMessage", params: { message: { messageId: "quickstart-quote", role: "ROLE_USER", parts: [{ text: "What can you do?" }] } } }) });
241
+ const quoteBody = (await quoteRes.json());
242
+ if (quoteBody.result?.task?.status?.state !== "TASK_STATE_COMPLETED")
243
+ throw new Error(`the A2A entrance did not answer a quote: ${JSON.stringify(quoteBody).slice(0, 200)}`);
244
+ log(`quickstart: the entrance answers — /healthz ok, cell_status names ${cell.account}, tender ${minted[0]?.tokenId ?? "-"} eligible for the agent key, MCP initialize ok, ${toolCount} tools; the agent card names ${nodeUrl}/a2a and a quote came back`);
181
245
  }
182
246
  // --- the stdio server the client configurations name, started as a client would start it: the operator's tool set over stdio
183
247
  const stdioTools = await stdioToolCount(cli, configPath, rpc, cleanEnv({ FOGA_OPERATOR_KEY_FILE: keyPaths.operator }));
@@ -197,12 +261,15 @@ export async function runQuickstart(o = {}) {
197
261
  lines.push(`foga quickstart: a FOGA cell is live on a local chain`);
198
262
  lines.push("");
199
263
  lines.push(` chain anvil on ${rpc} (chain ${world.chainId}), world ${world.worldId} — the chain lives as long as this process`);
200
- lines.push(` cell ${name} = ${cell.account}${cell.agentId ? ` (ERC-8004 agent ${cell.agentId})` : ""}; owner, operator and guardian keys in ${keysDir} (anvil money, no value)`);
264
+ lines.push(` cell ${name} = ${cell.account}${cell.agentId ? ` (ERC-8004 agent ${cell.agentId})` : ""}`);
265
+ const walletRoles = singleKey ? `identity ${cell.owner.address} (one key, every role)` : `owner ${cell.owner.address}, operator ${cell.operator.address}, guardian ${cell.guardian.address}`;
266
+ lines.push(` wallet ${wallet.ephemeral ? `keys made in memory for this run, written to ${keysDir} (${walletPath} was not writable)` : `${wallet.wallet.dir} (${wallet.created ? "made now" : "reused"}) — ${walletRoles}; the same wallet on every run`}`);
201
267
  lines.push(` skills ${Object.keys(cell.held).join(", ")} (held and offered)`);
202
268
  if (minted.length)
203
269
  lines.push(` tenders ${minted.map((c) => `#${c.tokenId} ${c.skill} (${formatEther(c.rewardWei)} ETH)`).join(", ")} — open, waiting for the cell to take them`);
204
270
  if (nodeUrl)
205
- lines.push(` node ${nodeUrl} — /mcp (Streamable HTTP), /api/<capability>, /healthz; its watchers settle and withdraw what the cell commits to`);
271
+ lines.push(` node ${nodeUrl} — /mcp (Streamable HTTP), /api/<capability>, /a2a (A2A 1.0, JSON-RPC), /.well-known/agent-card.json, /documents/*, /healthz; its watchers settle and withdraw`);
272
+ lines.push(` documents ${join(dir, "documents")} — registration, AID document, ARD descriptor, AUF unit, agent card, server.json; committed on chain${nodeUrl ? ` as URLs under ${nodeUrl}` : ""}`);
206
273
  lines.push(` files ${configPath} (the cell), ${join(dir, "connect.md")} (this text), ${mcpDir}${process.platform === "win32" ? "\\" : "/"}*.json (the client configurations)`);
207
274
  lines.push("");
208
275
  lines.push("Connect an MCP client over stdio (the client starts the server on this machine; with the operator key it acts for the cell):");
@@ -217,6 +284,8 @@ export async function runQuickstart(o = {}) {
217
284
  lines.push("Or over HTTP (a client on another machine, or any agent runtime that speaks HTTP):");
218
285
  lines.push(` MCP ${nodeUrl}/mcp with the header Authorization: Bearer ${api.key} (${join(mcpDir, "http-mcp.json")})`);
219
286
  lines.push(` API ${nodeUrl}/api/<capability>, POST a JSON body (reads also GET); the same header for acts. GET ${nodeUrl}/api/capabilities lists them.`);
287
+ lines.push(` A2A POST ${nodeUrl}/a2a with A2A-Version: 1.0 — SendMessage for a quote (anyone) or a tender ({"foga":{"mode":"tender","tokenId":"…"}}, the header above); the card is at ${nodeUrl}/.well-known/agent-card.json`);
288
+ lines.push(` wallet a credential of your own instead of the key: Authorization: FOGA-EIP712 <envelope> signed by any wallet (foga wallet sign --url ${nodeUrl} prints one; docs/wallet.md)`);
220
289
  lines.push(` curl -s ${nodeUrl}/api/cell_status`);
221
290
  if (minted.length)
222
291
  lines.push(` curl -s -X POST ${nodeUrl}/api/tender_precheck -H "content-type: application/json" -H "authorization: Bearer ${api.key}" -d "{\\"tokenId\\":\\"${minted[0].tokenId}\\"}"`);
@@ -227,11 +296,27 @@ export async function runQuickstart(o = {}) {
227
296
  lines.push(` FOGA_DEMANDER_KEY=<a funded key, anvil's first account by default> ${demanderCmd}`);
228
297
  if (launcher.fromCheckout)
229
298
  lines.push(`(This is a checkout: the configurations start ${launcher.command} ${launcher.args.join(" ")}; a published install would start npx -y ${PACKAGE_NAME}.)`);
299
+ // Next: what a cell should do once it runs — advice, kept out of the first run on purpose (docs/node.md, "Advice").
300
+ try {
301
+ const { adviseCell, formatAdvice } = await import("../node/advise.js");
302
+ const reader = await Node.open({ configPath, rpc, keys: {}, allowUnborn: true, log: () => { } });
303
+ try {
304
+ const next = formatAdvice(adviseCell(reader), { brief: true, config: configPath });
305
+ if (next)
306
+ lines.push("", next);
307
+ }
308
+ finally {
309
+ reader.close();
310
+ }
311
+ }
312
+ catch {
313
+ // advice is never a reason for a quickstart to fail
314
+ }
230
315
  lines.push("");
231
316
  lines.push(o.exit ? "Started with --exit: stopping the node and the chain." : "Ctrl-C stops the node and the chain; the next quickstart starts over in the same directory.");
232
317
  const summary = lines.join("\n");
233
318
  writeFileSync(join(dir, "connect.md"), `# foga quickstart\n\n\`\`\`\n${summary}\n\`\`\`\n`);
234
- writeFileSync(join(dir, MARKER), JSON.stringify({ schema: "foga.quickstart/1", createdAt: new Date().toISOString(), rpc, chainId: world.chainId, worldId: world.worldId, cell: name, account: cell.account, agentId: cell.agentId, nodeUrl, tenders: minted.map((c) => c.tokenId), anvil: version }, null, 2) + "\n");
319
+ writeFileSync(join(dir, MARKER), JSON.stringify({ schema: "foga.quickstart/1", createdAt: new Date().toISOString(), rpc, chainId: world.chainId, worldId: world.worldId, cell: name, account: cell.account, agentId: cell.agentId, wallet: wallet.ephemeral ? null : wallet.wallet.dir, nodeUrl, tenders: minted.map((c) => c.tokenId), anvil: version }, null, 2) + "\n");
235
320
  out(summary);
236
321
  return {
237
322
  dir,
@@ -242,6 +327,7 @@ export async function runQuickstart(o = {}) {
242
327
  configPath,
243
328
  account: cell.account,
244
329
  agentId: cell.agentId,
330
+ wallet: { dir: wallet.wallet.dir, created: wallet.created, ephemeral: wallet.ephemeral, addresses: wallet.wallet.addresses },
245
331
  keys: { ...keyPaths, agentApiKey: agentKeyPath },
246
332
  apiKey: api.key,
247
333
  nodeUrl,
@@ -12,6 +12,7 @@ export const SCOPE_GROUPS = {
12
12
  { contract: "8444/ARDOnchainRegistry", fn: "register(bytes32,address,uint256,bytes32,string,string)" },
13
13
  { contract: "8444/ARDOnchainRegistry", fn: "updateDescriptor(bytes32,bytes32,string,string)" },
14
14
  { contract: "8454/AUFUnitRegistry", fn: "registerUnit(address,bytes32,string,(uint256,uint32)[])" },
15
+ { contract: "8454/AUFUnitRegistry", fn: "publishVersion(uint256,bytes32,string,(uint256,uint32)[])" },
15
16
  { contract: "8447/ERC8004Eligibility", fn: "declareAgent(uint256)" },
16
17
  ],
17
18
  standing: [{ contract: "8419/KYARegistry", fn: "attestWithProof((bytes32,bytes),bytes32,bytes,bytes,string)" }],
@@ -18,6 +18,22 @@ export async function runProbe(world, input) {
18
18
  throw new Error(`block ${input.block} not found`);
19
19
  const at = { blockTag: tag };
20
20
  const fromBlock = input.lookback === undefined ? 0 : Math.max(0, input.block - input.lookback);
21
+ // A bounded scan is asked in windows of 2000 blocks, the windows in parallel and their logs concatenated in block
22
+ // order — the same logs a single query returns, in the same order (the answer hash is over them, so this must not
23
+ // change), but each request small enough for a public endpoint, which caps the range of `eth_getLogs` and answers
24
+ // a 9000-block scan slowly or not at all: a skill run on Sepolia overran its worker's budget on exactly this. An
25
+ // unbounded scan (no lookback: a local chain) stays one query.
26
+ const WINDOW = 2000;
27
+ const logsOf = async (c, filter) => {
28
+ const to = input.block;
29
+ if (input.lookback === undefined || to - fromBlock < WINDOW)
30
+ return c.queryFilter(filter, fromBlock, to);
31
+ const windows = [];
32
+ for (let a = fromBlock; a <= to; a += WINDOW)
33
+ windows.push([a, Math.min(a + WINDOW - 1, to)]);
34
+ const parts = await Promise.all(windows.map(([a, b]) => c.queryFilter(filter, a, b)));
35
+ return parts.flat();
36
+ };
21
37
  const section = async (fn) => {
22
38
  try {
23
39
  return await fn();
@@ -95,13 +111,13 @@ export async function runProbe(world, input) {
95
111
  });
96
112
  checks.erc8454 = await section(async () => {
97
113
  const units = world.contract("8454/AUFUnitRegistry");
98
- const logs = await units.queryFilter(units.filters.UnitRegistered(null, subject), fromBlock, input.block);
114
+ const logs = await logsOf(units, units.filters.UnitRegistered(null, subject));
99
115
  return { unitsControlled: logs.map((l) => String(l.args.unitId)) };
100
116
  });
101
117
  checks.erc8338 = await section(async () => {
102
118
  const skills = world.contract("8338/SkillToken");
103
119
  const held = String(await skills.balanceOf(subject, at));
104
- const received = await skills.queryFilter(skills.filters.Transfer(null, subject), fromBlock, input.block);
120
+ const received = await logsOf(skills, skills.filters.Transfer(null, subject));
105
121
  const ids = new Set();
106
122
  for (const l of received)
107
123
  ids.add(String(l.args.tokenId));
@@ -122,13 +138,13 @@ export async function runProbe(world, input) {
122
138
  checks.erc8414 = await section(async () => {
123
139
  const tasks = world.contract("8414/TaskToken");
124
140
  const held = String(await tasks.balanceOf(subject, at));
125
- const submitted = await tasks.queryFilter(tasks.filters.FulfillmentSubmitted(null, null, subject), fromBlock, input.block);
126
- const accepted = await tasks.queryFilter(tasks.filters.FulfillmentAccepted(null, null, subject), fromBlock, input.block);
141
+ const submitted = await logsOf(tasks, tasks.filters.FulfillmentSubmitted(null, null, subject));
142
+ const accepted = await logsOf(tasks, tasks.filters.FulfillmentAccepted(null, null, subject));
127
143
  return { held, submissions: String(submitted.length), accepted: String(accepted.length) };
128
144
  });
129
145
  checks.erc8447 = await section(async () => {
130
146
  const swarms = world.contract("8447/SwarmRegistry");
131
- const activated = await swarms.queryFilter(swarms.filters.MemberActivated(null, subject), fromBlock, input.block);
147
+ const activated = await logsOf(swarms, swarms.filters.MemberActivated(null, subject));
132
148
  const ids = [...new Set(activated.map((l) => String(l.args.swarmId)))].sort();
133
149
  const memberships = [];
134
150
  for (const swarmId of ids)
@@ -137,12 +153,12 @@ export async function runProbe(world, input) {
137
153
  });
138
154
  checks.erc8436 = await section(async () => {
139
155
  const acdf = world.contract("8436/ACDFRegistry");
140
- const filed = await acdf.queryFilter(acdf.filters.IssueFiled(null, null, null, subject), fromBlock, input.block);
156
+ const filed = await logsOf(acdf, acdf.filters.IssueFiled(null, null, null, subject));
141
157
  return { issuesFiled: String(filed.length) };
142
158
  });
143
159
  checks.erc8471 = await section(async () => {
144
160
  const adrf = world.contract("8471/ADRFDisputeRegistry");
145
- const filed = await adrf.queryFilter(adrf.filters.CaseFiled(), fromBlock, input.block);
161
+ const filed = await logsOf(adrf, adrf.filters.CaseFiled());
146
162
  let asParty = 0;
147
163
  for (const l of filed) {
148
164
  if (String(l.args.claimant).toLowerCase() === low || String(l.args.respondent).toLowerCase() === low)
@@ -0,0 +1,105 @@
1
+ import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
2
+ import { getAddress } from "ethers";
3
+ import { accountSubject } from "../core/kya.js";
4
+ import { ACTION_RANK, PUBLIC_CALLER } from "../tools/index.js";
5
+ import { SIGNED_SCHEME, verifyAuthorization } from "./signed.js";
6
+ const KEY_PREFIX = "foga";
7
+ /** Make an API key for a grant: shown once, stored only as its hash. */
8
+ export function newApiKey(id) {
9
+ const key = `${KEY_PREFIX}.${id}.${randomBytes(24).toString("base64url")}`;
10
+ return { key, sha256: "0x" + createHash("sha256").update(key).digest("hex") };
11
+ }
12
+ /** The caller an Authorization header establishes: the public without one, the grant of a valid key, or null for an invalid key. */
13
+ export function callerFromAuthorization(cfg, header) {
14
+ if (!header)
15
+ return { ...PUBLIC_CALLER, entrance: "http" };
16
+ const m = /^Bearer\s+(\S+)$/i.exec(header.trim());
17
+ if (!m)
18
+ return null;
19
+ const key = m[1];
20
+ const parts = key.split(".");
21
+ if (parts.length !== 3 || parts[0] !== KEY_PREFIX)
22
+ return null;
23
+ const grant = cfg.access.keys.find((k) => k.id === parts[1]);
24
+ if (!grant)
25
+ return null;
26
+ const given = Buffer.from(createHash("sha256").update(key).digest("hex"), "hex");
27
+ const stored = Buffer.from(grant.sha256.slice(2), "hex");
28
+ if (given.length !== stored.length || !timingSafeEqual(given, stored))
29
+ return null;
30
+ return { id: grant.id, entrance: "http", level: grant.level, visibility: grant.visibility };
31
+ }
32
+ export async function resolveCaller(i) {
33
+ const { node } = i;
34
+ const cfg = node.config;
35
+ const header = i.authorization?.trim();
36
+ if (!header)
37
+ return { caller: { ...PUBLIC_CALLER, entrance: "http" }, via: "anonymous" };
38
+ if (/^bearer\s/i.test(header)) {
39
+ const caller = callerFromAuthorization(cfg, header);
40
+ if (!caller)
41
+ return { caller: null, status: 401, reason: "the Authorization header carries no key this node knows" };
42
+ return { caller, via: "key" };
43
+ }
44
+ if (new RegExp(`^${SIGNED_SCHEME}\\s`, "i").test(header)) {
45
+ if (!node.cell)
46
+ return { caller: null, status: 403, reason: "this node has no cell to sign for yet" };
47
+ const v = verifyAuthorization(header, { chainId: node.chainId, cell: node.cell.address, method: i.method, path: i.path, body: i.body, now: i.now, nonces: i.nonces });
48
+ if (!v.ok)
49
+ return { caller: null, status: 401, reason: `signature refused: ${v.reason}` };
50
+ const grant = await grantFor(node, v.envelope);
51
+ if (!grant)
52
+ return { caller: null, status: 403, reason: "this node does not accept signatures (access.signers.default is none)" };
53
+ let level = grant.level;
54
+ if (!v.bound && cfg.access.signers.actsNeedRequestBinding && ACTION_RANK[level] >= ACTION_RANK.act)
55
+ level = "build";
56
+ return { caller: { id: v.envelope.signer, entrance: "http", level, visibility: grant.visibility }, via: "signature", signer: v.envelope };
57
+ }
58
+ return { caller: null, status: 401, reason: `the Authorization scheme is not one this node accepts (Bearer <key> or ${SIGNED_SCHEME} <envelope>)` };
59
+ }
60
+ /** The grant a verified signer gets, from the cell's roles, the configuration's grants, its standing, or the default. */
61
+ export async function grantFor(node, e) {
62
+ const cfg = node.config;
63
+ const signer = getAddress(e.signer);
64
+ const roles = [
65
+ ["operator", cfg.identity.operator],
66
+ ["owner", cfg.identity.owner],
67
+ ];
68
+ for (const [name, address] of roles)
69
+ if (getAddress(address) === signer)
70
+ return { level: "act", visibility: "operator", why: `the cell's ${name}` };
71
+ const s = cfg.access.signers;
72
+ const byAddress = s.allow.find((g) => g.address && getAddress(g.address) === signer);
73
+ if (byAddress)
74
+ return { level: byAddress.level, visibility: byAddress.visibility, why: `granted by address${byAddress.note ? ` (${byAddress.note})` : ""}` };
75
+ if (e.agentId) {
76
+ const byAgent = s.allow.find((g) => g.agentId && g.agentId === e.agentId);
77
+ if (byAgent && (await actsForAgent(node, signer, e.agentId)))
78
+ return { level: byAgent.level, visibility: byAgent.visibility, why: `granted as ERC-8004 agent ${e.agentId}` };
79
+ }
80
+ if (s.standing && cfg.identity.standing) {
81
+ try {
82
+ const subject = accountSubject(node.chainId, signer);
83
+ const meets = await node.kernel.kya.check(subject, cfg.identity.standing.schemeId, s.standing.minLevel, cfg.identity.standing.issuers);
84
+ if (meets)
85
+ return { level: s.standing.level, visibility: s.standing.visibility, why: `KYA standing at level ${s.standing.minLevel} or above` };
86
+ }
87
+ catch {
88
+ /* no standing readable: the default applies */
89
+ }
90
+ }
91
+ if (s.default === "none")
92
+ return null;
93
+ return { level: s.default, visibility: "caller", why: "any valid signer" };
94
+ }
95
+ /** Whether `signer` is the owner or the agent wallet of ERC-8004 agent `agentId` on this world's registry. */
96
+ export async function actsForAgent(node, signer, agentId) {
97
+ try {
98
+ const id = BigInt(agentId);
99
+ const [owner, wallet] = await Promise.all([node.kernel.identity.ownerOf(id).catch(() => null), node.kernel.identity.agentWallet(id).catch(() => null)]);
100
+ return [owner, wallet].some((a) => a && getAddress(a) === getAddress(signer));
101
+ }
102
+ catch {
103
+ return false;
104
+ }
105
+ }
@@ -1,34 +1,16 @@
1
- import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
2
1
  import { createServer } from "node:http";
3
2
  import { createMcpHandler } from "@modelcontextprotocol/server";
3
+ import { existsSync, readFileSync } from "node:fs";
4
+ import { basename, join } from "node:path";
5
+ import { A2AServer } from "../a2a/server.js";
6
+ import { A2A_ERRORS, A2A_WELL_KNOWN } from "../a2a/types.js";
4
7
  import { buildMcpServer } from "../mcp/server.js";
8
+ import { DOCUMENT_FILES, documentsDir, readManifest } from "../node/documents.js";
9
+ import { buildPropagationManifest, servicesCatalogOf } from "../node/propagation.js";
5
10
  import { PUBLIC_CALLER } from "../tools/index.js";
6
- const KEY_PREFIX = "foga";
7
- /** Make an API key for a grant: shown once, stored only as its hash. */
8
- export function newApiKey(id) {
9
- const key = `${KEY_PREFIX}.${id}.${randomBytes(24).toString("base64url")}`;
10
- return { key, sha256: "0x" + createHash("sha256").update(key).digest("hex") };
11
- }
12
- /** The caller an Authorization header establishes: the public without one, the grant of a valid key, or null for an invalid key. */
13
- export function callerFromAuthorization(cfg, header) {
14
- if (!header)
15
- return { ...PUBLIC_CALLER, entrance: "http" };
16
- const m = /^Bearer\s+(\S+)$/i.exec(header.trim());
17
- if (!m)
18
- return null;
19
- const key = m[1];
20
- const parts = key.split(".");
21
- if (parts.length !== 3 || parts[0] !== KEY_PREFIX)
22
- return null;
23
- const grant = cfg.access.keys.find((k) => k.id === parts[1]);
24
- if (!grant)
25
- return null;
26
- const given = Buffer.from(createHash("sha256").update(key).digest("hex"), "hex");
27
- const stored = Buffer.from(grant.sha256.slice(2), "hex");
28
- if (given.length !== stored.length || !timingSafeEqual(given, stored))
29
- return null;
30
- return { id: grant.id, entrance: "http", level: grant.level, visibility: grant.visibility };
31
- }
11
+ import { resolveCaller } from "./access.js";
12
+ import { NonceCache } from "./signed.js";
13
+ export { callerFromAuthorization, newApiKey } from "./access.js";
32
14
  /** A token bucket per client address: `limit` requests per minute, refilled continuously. */
33
15
  class RateLimiter {
34
16
  perMinute;
@@ -98,6 +80,8 @@ export function createHttpServer(o) {
98
80
  const http = cfg.runtime.http;
99
81
  const log = o.log ?? node.log;
100
82
  const limiter = new RateLimiter(http.rateLimitPerMinute);
83
+ const nonces = new NonceCache();
84
+ const a2a = new A2AServer(node, catalog, log);
101
85
  let loopback = false;
102
86
  const mcp = createMcpHandler((ctx) => {
103
87
  const caller = ctx.authInfo?.extra?.caller ?? { ...PUBLIC_CALLER, entrance: "http" };
@@ -120,19 +104,21 @@ export function createHttpServer(o) {
120
104
  if (!originHost || !allowedOrigins.includes(originHost))
121
105
  return json(res, 403, { ok: false, gate: "origin", reason: "Origin not allowed" });
122
106
  }
123
- // who is calling
124
- const caller = callerFromAuthorization(cfg, req.headers.authorization);
125
- if (!caller)
126
- return json(res, 401, { ok: false, gate: "permission", reason: "the Authorization header carries no key this node knows" });
107
+ // the body first (a signature may be bound to it), then who is calling
108
+ const bodyless = method === "GET" || method === "HEAD" || method === "DELETE";
109
+ const body = bodyless ? null : await readBody(req, http.maxBodyBytes);
110
+ if (body === null && !bodyless)
111
+ return json(res, 413, { ok: false, gate: "input", reason: `request body over ${http.maxBodyBytes} bytes` });
112
+ const resolved = await resolveCaller({ node, authorization: req.headers.authorization, method, path: url.pathname + url.search, body: body ? new Uint8Array(body) : null, nonces });
113
+ if (!resolved.caller)
114
+ return json(res, resolved.status, { ok: false, gate: "permission", reason: resolved.reason });
115
+ const caller = resolved.caller;
127
116
  if (url.pathname === "/mcp") {
128
- // the SDK reads and bounds the body itself; headers pass through, the caller rides in authInfo
117
+ // headers pass through, the caller rides in authInfo
129
118
  const headers = new Headers();
130
119
  for (const [k, v] of Object.entries(req.headers))
131
120
  if (v !== undefined && k !== "authorization")
132
121
  headers.set(k, Array.isArray(v) ? v.join(", ") : v);
133
- const body = method === "GET" || method === "HEAD" || method === "DELETE" ? undefined : await readBody(req, http.maxBodyBytes);
134
- if (body === null)
135
- return json(res, 413, { ok: false, gate: "input", reason: `request body over ${http.maxBodyBytes} bytes` });
136
122
  const request = new Request(url, { method, headers, body: body ? new Uint8Array(body) : undefined });
137
123
  const authInfo = { token: caller.id, clientId: caller.id, scopes: [caller.level], extra: { caller } };
138
124
  const response = await mcp.fetch(request, { authInfo });
@@ -146,6 +132,24 @@ export function createHttpServer(o) {
146
132
  res.end();
147
133
  return;
148
134
  }
135
+ // A2A 1.0, the JSON-RPC binding: the version as a header (or the A2A-Version query parameter), JSON in and out
136
+ if (url.pathname === "/a2a") {
137
+ if (method !== "POST")
138
+ return json(res, 405, { jsonrpc: "2.0", id: null, error: { code: A2A_ERRORS.InvalidRequestError, message: "POST a JSON-RPC 2.0 request" } });
139
+ if (!/^application\/json\b/i.test(req.headers["content-type"] ?? ""))
140
+ return json(res, 415, { jsonrpc: "2.0", id: null, error: { code: A2A_ERRORS.InvalidRequestError, message: "Content-Type must be application/json" } });
141
+ let rpc;
142
+ try {
143
+ rpc = body && body.length ? JSON.parse(body.toString("utf8")) : null;
144
+ }
145
+ catch {
146
+ return json(res, 200, { jsonrpc: "2.0", id: null, error: { code: A2A_ERRORS.JSONParseError, message: "Invalid JSON payload" } });
147
+ }
148
+ const versionHeader = req.headers["a2a-version"];
149
+ const version = (Array.isArray(versionHeader) ? versionHeader[0] : versionHeader) ?? url.searchParams.get("A2A-Version") ?? undefined;
150
+ // A2A-Extensions a client declares are ignored: this agent requires none and offers none
151
+ return json(res, 200, await a2a.handle(rpc, caller, version));
152
+ }
149
153
  if (method === "GET" && url.pathname === "/healthz") {
150
154
  let block = null;
151
155
  try {
@@ -159,10 +163,10 @@ export function createHttpServer(o) {
159
163
  if (method === "GET" && url.pathname === "/") {
160
164
  const r = await catalog.invoke(node, "cell_status", {}, caller);
161
165
  const decisions = node.store.summary().decisions;
162
- return json(res, 200, { foga: o.version, cell: cfg.name, status: r.ok ? r.output : null, decisions, endpoints: { mcp: "/mcp", api: "/api/<capability>", health: "/healthz" } });
166
+ return json(res, 200, { foga: o.version, cell: cfg.name, status: r.ok ? r.output : null, decisions, endpoints: { mcp: "/mcp", api: "/api/<capability>", a2a: "/a2a", agentCard: A2A_WELL_KNOWN, manifest: "/.well-known/foga-cell.json", documents: "/documents/<file>", health: "/healthz" } });
163
167
  }
164
168
  if (method === "GET" && url.pathname === "/api/capabilities")
165
- return json(res, 200, { caller: { id: caller.id, level: caller.level, visibility: caller.visibility }, capabilities: catalog.list(caller) });
169
+ return json(res, 200, { caller: { id: caller.id, level: caller.level, visibility: caller.visibility, via: resolved.via }, capabilities: catalog.list(caller) });
166
170
  const jobMatch = /^\/api\/jobs\/([^/]+)$/.exec(url.pathname);
167
171
  if (method === "GET" && jobMatch)
168
172
  return answer(res, await catalog.invoke(node, "job_status", { jobId: decodeURIComponent(jobMatch[1]) }, caller));
@@ -183,11 +187,8 @@ export function createHttpServer(o) {
183
187
  if (method === "POST") {
184
188
  if (!/^application\/json\b/i.test(req.headers["content-type"] ?? ""))
185
189
  return json(res, 415, { ok: false, gate: "input", reason: "Content-Type must be application/json" });
186
- const body = await readBody(req, http.maxBodyBytes);
187
- if (body === null)
188
- return json(res, 413, { ok: false, gate: "input", reason: `request body over ${http.maxBodyBytes} bytes` });
189
190
  try {
190
- input = body.length ? JSON.parse(body.toString("utf8")) : {};
191
+ input = body && body.length ? JSON.parse(body.toString("utf8")) : {};
191
192
  }
192
193
  catch {
193
194
  return json(res, 400, { ok: false, gate: "input", reason: "the body is not JSON" });
@@ -200,8 +201,48 @@ export function createHttpServer(o) {
200
201
  return json(res, 400, { ok: false, gate: "input", reason: "mode must be simulate, build or act" });
201
202
  return answer(res, await catalog.invoke(node, name, input, caller, { mode }));
202
203
  }
203
- if (url.pathname.startsWith("/.well-known/") || url.pathname.startsWith("/documents/"))
204
- return json(res, 404, { ok: false, reason: "not published yet" });
204
+ // the propagation manifest: free, built live from the configuration, answered even before any document is published
205
+ if (method === "GET" && url.pathname === "/.well-known/foga-cell.json") {
206
+ const text = Buffer.from(JSON.stringify(buildPropagationManifest(node, { version: o.version }), null, 2) + "\n");
207
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8", "cache-control": "public, max-age=300", "content-length": text.length });
208
+ res.end(text);
209
+ return;
210
+ }
211
+ // the services catalog, when the one this cell's manifest points at is its own (the reference cell: the maintainer's list);
212
+ // a static file at this path on the host, answered by the proxy before the node, is how the list changes without a release
213
+ if (method === "GET" && url.pathname === "/services.json") {
214
+ const doc = servicesCatalogOf(node, { version: o.version });
215
+ if (!doc)
216
+ return json(res, 404, { ok: false, reason: "this cell's services pointer names another catalog; read it from the manifest at /.well-known/foga-cell.json" });
217
+ const text = Buffer.from(JSON.stringify(doc, null, 2) + "\n");
218
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8", "cache-control": "public, max-age=300", "content-length": text.length });
219
+ res.end(text);
220
+ return;
221
+ }
222
+ // the published documents (foga cell docs build): served as built, by their file names, and at the well-known paths
223
+ if (method === "GET" && (url.pathname.startsWith("/.well-known/") || url.pathname.startsWith("/documents/"))) {
224
+ const dir = documentsDir(node);
225
+ const manifest = readManifest(dir);
226
+ if (!manifest)
227
+ return json(res, 404, { ok: false, reason: "not published yet" });
228
+ let file = null;
229
+ if (url.pathname === A2A_WELL_KNOWN)
230
+ file = DOCUMENT_FILES.agentCard;
231
+ else if (url.pathname === "/.well-known/agent-registration.json")
232
+ file = DOCUMENT_FILES.registration;
233
+ else if (url.pathname.startsWith("/documents/")) {
234
+ const name = basename(decodeURIComponent(url.pathname.slice("/documents/".length)));
235
+ if (name === "manifest.json" || Object.values(DOCUMENT_FILES).includes(name))
236
+ file = name;
237
+ }
238
+ const path = file ? join(dir, file) : null;
239
+ if (!path || !existsSync(path))
240
+ return json(res, 404, { ok: false, reason: "no such document" });
241
+ const text = readFileSync(path);
242
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8", "cache-control": "public, max-age=300", "content-length": text.length });
243
+ res.end(text);
244
+ return;
245
+ }
205
246
  json(res, 404, { ok: false, reason: "no such path" });
206
247
  }
207
248
  function answer(res, r) {