@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.
- package/README.md +35 -14
- package/dist/a2a/card.js +54 -0
- package/dist/a2a/server.js +276 -0
- package/dist/a2a/types.js +20 -0
- package/dist/build/bundle.js +2 -1
- package/dist/cli-cell.js +176 -16
- package/dist/cli.js +150 -5
- package/dist/core/world.js +17 -0
- package/dist/demo/birth.js +11 -5
- package/dist/demo/loop-d.js +39 -1
- package/dist/demo/quickstart.js +97 -11
- package/dist/genesis/operator-scope.js +1 -0
- package/dist/genesis/probe.js +23 -7
- package/dist/http/access.js +105 -0
- package/dist/http/server.js +84 -43
- package/dist/http/signed.js +136 -0
- package/dist/mcp/server.js +48 -1
- package/dist/node/advise.js +134 -0
- package/dist/node/config.js +55 -1
- package/dist/node/documents.js +298 -0
- package/dist/node/preflight.js +208 -0
- package/dist/node/propagation.js +95 -0
- package/dist/node/wallet.js +132 -0
- package/dist/tools/documents.js +74 -0
- package/dist/tools/index.js +3 -1
- package/dist/tools/propagation.js +35 -0
- package/dist/tools/tenders.js +22 -6
- package/dist/util/version.js +2 -0
- package/dist/world/sourcify.js +165 -0
- package/docs/a2a.md +31 -0
- package/docs/design-memo.md +23 -6
- package/docs/documents.md +25 -0
- package/docs/examples/README.md +2 -2
- package/docs/extending.md +49 -0
- package/docs/loop-d.md +11 -0
- package/docs/mcp.md +18 -11
- package/docs/node.md +35 -11
- package/docs/og-deployment.md +131 -0
- package/docs/propagation.md +41 -0
- package/docs/quickstart.md +36 -21
- package/docs/wallet.md +62 -0
- package/docs/world.md +12 -0
- package/package.json +1 -1
- package/server.json +2 -2
package/dist/demo/quickstart.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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) =>
|
|
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
|
-
|
|
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})` : ""}
|
|
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
|
|
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)" }],
|
package/dist/genesis/probe.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
126
|
-
const accepted = await
|
|
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
|
|
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
|
|
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
|
|
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
|
+
}
|
package/dist/http/server.js
CHANGED
|
@@ -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
|
-
|
|
7
|
-
|
|
8
|
-
export
|
|
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
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
204
|
-
|
|
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) {
|