@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
@@ -0,0 +1,298 @@
1
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { join, resolve } from "node:path";
3
+ import { buildAgentCard, endpointsOf } from "../a2a/card.js";
4
+ import { AID } from "../core/aid.js";
5
+ import { agentDescriptor, descriptorHash } from "../core/ard.js";
6
+ import { accountRef, dataUri, dataUriJcs, keccakJcs } from "../core/canonical.js";
7
+ import { cellUnitDefinition } from "../core/units.js";
8
+ import { repoRoot } from "../util/fs.js";
9
+ import { configPath } from "./config.js";
10
+ /**
11
+ * One configuration, several documents (design memo, Phase 3, section 7): everything the cell says about itself to
12
+ * the outside is generated from `cell.config.json` and the chain — the ERC-8004 registration file, the ERC-8434
13
+ * AID document, the ERC-8444 agent descriptor, the ERC-8454 unit definition, the A2A agent card and the MCP
14
+ * registry manifest — each in its own file with only the fields its standard prescribes, the shared metadata
15
+ * (version, content hash, built time) in `manifest.json` beside them. `foga cell docs build` writes them under the
16
+ * configuration's documents directory; the node serves them (`/documents/<file>`, `/.well-known/agent-card.json`,
17
+ * `/.well-known/agent-registration.json`); `foga cell docs publish` commits what changed on chain and reads it back.
18
+ */
19
+ export const DOCUMENTS_SCHEMA = "foga.documents/1";
20
+ export const DOCUMENT_FILES = {
21
+ registration: "registration.json",
22
+ aidDocument: "aid-document.json",
23
+ ardAgent: "ard-agent.json",
24
+ aufUnit: "auf-unit.json",
25
+ agentCard: "agent-card.json",
26
+ serverJson: "server.json",
27
+ };
28
+ /** The directory the documents live in: runtime.http.documentsDir, relative to the configuration's directory. */
29
+ export function documentsDir(node) {
30
+ return configPath(node.loaded, node.config.runtime.http.documentsDir);
31
+ }
32
+ /** The URI a document is published under: the public URL when the node has one, the document itself (data:) otherwise. */
33
+ export function documentUri(node, file, doc) {
34
+ const base = node.config.runtime.http.publicUrl?.replace(/\/+$/, "");
35
+ if (!base)
36
+ return dataUri(doc);
37
+ if (file === DOCUMENT_FILES.registration)
38
+ return `${base}/.well-known/agent-registration.json`;
39
+ if (file === DOCUMENT_FILES.agentCard)
40
+ return `${base}/.well-known/agent-card.json`;
41
+ return `${base}/documents/${file}`;
42
+ }
43
+ /** The ERC-8004 registration file (registration-v1) of the cell, from its configuration. */
44
+ export function buildRegistration(node) {
45
+ const cfg = node.config;
46
+ const chainId = node.chainId;
47
+ const endpoints = endpointsOf(node);
48
+ const account = cfg.identity.account;
49
+ const services = [
50
+ ...(endpoints.mcp ? [{ name: "MCP", endpoint: endpoints.mcp, version: "2026-07-28" }] : []),
51
+ ...(endpoints.a2a ? [{ name: "A2A", endpoint: endpoints.a2a, version: "1.0" }] : []),
52
+ ...(endpoints.web ? [{ name: "web", endpoint: endpoints.web }] : []),
53
+ ];
54
+ return {
55
+ type: "https://eips.ethereum.org/EIPS/eip-8004#registration-v1",
56
+ name: cfg.presence.name,
57
+ description: cfg.presence.description,
58
+ ...(cfg.presence.image ? { image: cfg.presence.image } : {}),
59
+ services,
60
+ ...(cfg.identity.agentId ? { registrations: [{ agentId: Number(cfg.identity.agentId), agentRegistry: `eip155:${chainId}:${node.kernel.identity.address.toLowerCase()}` }] } : {}),
61
+ x402Support: false,
62
+ active: true,
63
+ supportedTrust: ["reputation"],
64
+ "x-foga": { cell: cfg.name, anchor: accountRef(chainId, account), ...(cfg.identity.bindingId ? { binding: cfg.identity.bindingId } : {}), ...(cfg.identity.safety.specId ? { specification: cfg.identity.safety.specId } : {}) },
65
+ };
66
+ }
67
+ /** The AID document: the identity facet over the registration, the standing facet when attested, the skills facet, the endpoints facet. */
68
+ export async function buildAidDocument(node, registration) {
69
+ const cfg = node.config;
70
+ const k = node.kernel;
71
+ const chainId = node.chainId;
72
+ const account = cfg.identity.account;
73
+ const self = accountRef(chainId, account);
74
+ const agentId = cfg.identity.agentId ? Number(cfg.identity.agentId) : undefined;
75
+ const facets = [{ facetType: "aid:core/identity/v1", provenance: "SELF", issuer: self, validUntil: 0, digest: keccakJcs(registration), access: { mode: "PUBLIC" }, resolver: { kind: "erc8004-identity", chainId, registry: k.identity.address, ...(agentId !== undefined ? { agentId } : {}) } }];
76
+ if (cfg.identity.standing) {
77
+ try {
78
+ const subject = (await import("../core/kya.js")).accountSubject(chainId, account);
79
+ const r = await k.kya.resolve(subject, cfg.identity.standing.schemeId, cfg.identity.standing.issuers);
80
+ if (r.level > 0)
81
+ facets.push({ facetType: "aid:core/kya/v1", provenance: "ATTESTED", issuer: accountRef(chainId, cfg.identity.standing.issuers[0]), validUntil: Number(r.expiresAt), digest: r.assertionId, access: { mode: "PUBLIC" }, resolver: { kind: "erc8419-assertion", chainId, registry: k.kya.registryAddress, schemeId: cfg.identity.standing.schemeId, issuers: cfg.identity.standing.issuers, assertionId: r.assertionId, level: r.level } });
82
+ }
83
+ catch {
84
+ /* no standing readable: no facet */
85
+ }
86
+ }
87
+ const held = Object.entries(cfg.skills.held);
88
+ if (held.length) {
89
+ const skills = Object.fromEntries(held.map(([name, h]) => [name, { tokenId: h.tokenId, packageHash: h.packageHash ?? null, offered: cfg.skills.offered.includes(name) }]));
90
+ facets.push({ facetType: "aid:skills/erc8338/v1", provenance: "SELF", issuer: self, validUntil: 0, digest: keccakJcs(skills), access: { mode: "PUBLIC" }, resolver: { kind: "erc8338-skill", chainId, registry: k.skills.address, tokenIds: held.map(([, h]) => h.tokenId) } });
91
+ }
92
+ const endpoints = endpointsOf(node);
93
+ if (endpoints.mcp || endpoints.a2a || endpoints.web) {
94
+ const ep = { mcp: endpoints.mcp, a2a: endpoints.a2a, web: endpoints.web };
95
+ facets.push({ facetType: "aid:endpoints/v1", provenance: "SELF", issuer: self, validUntil: 0, digest: keccakJcs(ep), access: { mode: "PUBLIC" }, resolver: { kind: "foga-endpoints", ...ep } });
96
+ }
97
+ return {
98
+ version: "aid-document/v1",
99
+ aid: self,
100
+ did: `did:aid:${self}`,
101
+ ...(agentId !== undefined ? { binding: { registry: k.identity.address, agentId } } : {}),
102
+ livenessWindow: cfg.identity.livenessWindow ?? Number(node.world.file.params.aid.defaultLivenessWindow),
103
+ facets,
104
+ };
105
+ }
106
+ /** The MCP registry manifest for this cell: the package's server.json with this cell's remote. */
107
+ export function buildServerJson(node, version) {
108
+ const template = JSON.parse(readFileSync(join(repoRoot(), "server.json"), "utf8"));
109
+ const endpoints = endpointsOf(node);
110
+ const remotes = endpoints.mcp ? [{ type: "streamable-http", url: endpoints.mcp, headers: [{ name: "Authorization", description: "Bearer <key> for acts (foga node key new), or a FOGA-EIP712 envelope signed by your wallet; reads and builds need none", isRequired: false, isSecret: true }] }] : [];
111
+ return { ...template, description: `${node.config.presence.name}: a FOGA cell on ${node.world.file.network} (chain ${node.chainId}); reads, dry runs and builds for the public, acts by key or signature`.slice(0, 100), version, remotes };
112
+ }
113
+ /** Build every document from the configuration and the chain and write them, with the manifest, under the documents directory. */
114
+ export async function buildDocuments(node, o) {
115
+ const cfg = node.config;
116
+ if (!cfg.identity.account)
117
+ throw new Error("the cell is not born: no account in the configuration (foga cell init first)");
118
+ const dir = resolve(o.dir ?? documentsDir(node));
119
+ mkdirSync(dir, { recursive: true });
120
+ const registration = buildRegistration(node);
121
+ const aidDocument = await buildAidDocument(node, registration);
122
+ const chainId = node.chainId;
123
+ const endpoints = endpointsOf(node);
124
+ const offered = cfg.skills.offered.map((s) => `erc8338:${s}`);
125
+ const ardAgent = agentDescriptor({ chainId, anchor: cfg.identity.account, slug: `cell-${cfg.name.toLowerCase()}`, name: cfg.presence.name, description: cfg.presence.description, url: endpoints.web ?? undefined, capabilities: ["erc8414:fulfiller", "erc8338:holder", ...offered], registration: cfg.identity.agentId ? { registry: `eip155:${chainId}:${node.kernel.identity.address.toLowerCase()}`, agentId: cfg.identity.agentId } : undefined, domain: cfg.runtime.http.publicUrl ? new URL(cfg.runtime.http.publicUrl).hostname : undefined });
126
+ const aufUnit = cellUnitDefinition({ chainId, cell: cfg.identity.account, name: cfg.presence.name, summary: cfg.presence.description || undefined });
127
+ const agentCard = await buildAgentCard(node, { version: o.version });
128
+ const serverJson = buildServerJson(node, o.version);
129
+ const previous = readManifest(dir);
130
+ const docs = { [DOCUMENT_FILES.registration]: registration, [DOCUMENT_FILES.aidDocument]: aidDocument, [DOCUMENT_FILES.ardAgent]: ardAgent, [DOCUMENT_FILES.aufUnit]: aufUnit, [DOCUMENT_FILES.agentCard]: agentCard, [DOCUMENT_FILES.serverJson]: serverJson };
131
+ const standards = { [DOCUMENT_FILES.registration]: "ERC-8004 registration-v1", [DOCUMENT_FILES.aidDocument]: "ERC-8434 aid-document/v1", [DOCUMENT_FILES.ardAgent]: "ERC-8444 agent descriptor", [DOCUMENT_FILES.aufUnit]: "ERC-8454 unit definition", [DOCUMENT_FILES.agentCard]: "A2A 1.0 AgentCard", [DOCUMENT_FILES.serverJson]: "MCP registry server.json (2025-12-11)" };
132
+ const entries = {};
133
+ let changed = !previous;
134
+ for (const [file, doc] of Object.entries(docs)) {
135
+ const text = JSON.stringify(doc, null, 2) + "\n";
136
+ writeFileSync(join(dir, file), text);
137
+ const contentHash = keccakJcs(doc);
138
+ entries[file] = { file, contentHash, bytes: Buffer.byteLength(text), standard: standards[file] };
139
+ if (previous && previous.documents[file]?.contentHash !== contentHash)
140
+ changed = true;
141
+ }
142
+ const manifest = {
143
+ schema: DOCUMENTS_SCHEMA,
144
+ cell: cfg.name,
145
+ account: cfg.identity.account,
146
+ agentId: cfg.identity.agentId ?? null,
147
+ chainId,
148
+ version: previous ? (changed ? previous.version + 1 : previous.version) : 1,
149
+ builtAt: new Date().toISOString(),
150
+ publicUrl: cfg.runtime.http.publicUrl ?? null,
151
+ documents: entries,
152
+ };
153
+ writeFileSync(join(dir, "manifest.json"), JSON.stringify(manifest, null, 2) + "\n");
154
+ return { dir, manifest, registration, aidDocument, ardAgent, aufUnit, agentCard, serverJson };
155
+ }
156
+ export function readManifest(dir) {
157
+ try {
158
+ const m = JSON.parse(readFileSync(join(dir, "manifest.json"), "utf8"));
159
+ return m.schema === DOCUMENTS_SCHEMA ? m : null;
160
+ }
161
+ catch {
162
+ return null;
163
+ }
164
+ }
165
+ /** The documents as built, read back from their directory (for the node to serve and for a publish). */
166
+ export function readBuilt(dir) {
167
+ const manifest = readManifest(dir);
168
+ if (!manifest)
169
+ return null;
170
+ const read = (file) => JSON.parse(readFileSync(join(dir, file), "utf8"));
171
+ return { dir, manifest, registration: read(DOCUMENT_FILES.registration), aidDocument: read(DOCUMENT_FILES.aidDocument), ardAgent: read(DOCUMENT_FILES.ardAgent), aufUnit: read(DOCUMENT_FILES.aufUnit), agentCard: read(DOCUMENT_FILES.agentCard), serverJson: read(DOCUMENT_FILES.serverJson) };
172
+ }
173
+ /** What the chain holds now for the four commitments. */
174
+ export async function onChainState(node) {
175
+ const cfg = node.config;
176
+ const k = node.kernel;
177
+ const account = cfg.identity.account;
178
+ const agentURI = cfg.identity.agentId ? await k.identity.contract().tokenURI(BigInt(cfg.identity.agentId)).catch(() => null) : null;
179
+ const aid = await k.aid.documentURI(account).catch(() => null);
180
+ const ard = cfg.identity.recordId ? await k.ard.recordOf(cfg.identity.recordId).then((r) => ({ descriptorHash: r.descriptorHash, descriptorURI: r.descriptorURI })).catch(() => null) : null;
181
+ let unit = null;
182
+ if (cfg.identity.unitId) {
183
+ try {
184
+ const id = BigInt(cfg.identity.unitId);
185
+ const version = await k.units.latestVersion(id);
186
+ const v = await k.units.versionOf(id, version);
187
+ unit = { version, boundaryHash: v.boundaryHash };
188
+ }
189
+ catch {
190
+ unit = null;
191
+ }
192
+ }
193
+ return { agentURI, aid, ard, unit };
194
+ }
195
+ /** The on-chain steps a publish needs: each commitment that differs from what the chain holds; nothing for one that matches. */
196
+ export async function publishSteps(node, built) {
197
+ const cfg = node.config;
198
+ const k = node.kernel;
199
+ const state = await onChainState(node);
200
+ const steps = [];
201
+ const unchanged = [];
202
+ if (cfg.identity.agentId) {
203
+ const uri = documentUri(node, DOCUMENT_FILES.registration, built.registration);
204
+ if (state.agentURI === uri)
205
+ unchanged.push("identity-uri");
206
+ else
207
+ steps.push({ name: "identity-uri", target: k.identity.address, data: k.identity.contract().interface.encodeFunctionData("setAgentURI", [BigInt(cfg.identity.agentId), uri]), label: `setAgentURI(${cfg.identity.agentId}, ${uri.slice(0, 60)}${uri.length > 60 ? "…" : ""})`, expect: { uri } });
208
+ }
209
+ {
210
+ const c = AID.documentCommitment(built.aidDocument);
211
+ const uri = cfg.runtime.http.publicUrl ? documentUri(node, DOCUMENT_FILES.aidDocument, built.aidDocument) : c.uri;
212
+ if (state.aid && state.aid.digest.toLowerCase() === c.digest.toLowerCase() && state.aid.uri === uri)
213
+ unchanged.push("aid-document");
214
+ else
215
+ steps.push({ name: "aid-document", target: k.aid.address, data: k.aid.encodeSetDocumentURI(uri, c.digest), label: `setDocumentURI(${uri.slice(0, 60)}${uri.length > 60 ? "…" : ""}, ${c.digest.slice(0, 10)}…)`, expect: { uri, digest: c.digest } });
216
+ }
217
+ if (cfg.identity.recordId) {
218
+ const p = descriptorHash(built.ardAgent);
219
+ if (state.ard && state.ard.descriptorHash.toLowerCase() === p.hex.toLowerCase())
220
+ unchanged.push("ard-descriptor");
221
+ else
222
+ steps.push({ name: "ard-descriptor", target: k.ard.address, data: k.ard.encodeUpdateDescriptor(cfg.identity.recordId, built.ardAgent), label: `updateDescriptor(${cfg.identity.recordId.slice(0, 10)}…, ${p.hex.slice(0, 10)}…)`, expect: { hash: p.hex } });
223
+ }
224
+ if (cfg.identity.unitId) {
225
+ const hash = k.units.boundaryHash(built.aufUnit);
226
+ if (state.unit && state.unit.boundaryHash.toLowerCase() === hash.toLowerCase())
227
+ unchanged.push("unit-version");
228
+ else
229
+ steps.push({ name: "unit-version", target: k.units.registryAddress, data: k.units.registry().interface.encodeFunctionData("publishVersion", [BigInt(cfg.identity.unitId), hash, dataUriJcs(built.aufUnit), []]), label: `publishVersion(${cfg.identity.unitId}, ${hash.slice(0, 10)}…)`, expect: { hash } });
230
+ }
231
+ return { steps, unchanged };
232
+ }
233
+ /** After a publish: what the chain holds against what was built; a URL is fetched when it can be. */
234
+ export async function readBack(node, built, o = {}) {
235
+ const cfg = node.config;
236
+ const k = node.kernel;
237
+ const rows = [];
238
+ const state = await onChainState(node);
239
+ let lastFetchProblem = "";
240
+ const fetchJson = async (url) => {
241
+ if (!url.startsWith("http"))
242
+ return null;
243
+ if (o.fetchUrls === false) {
244
+ lastFetchProblem = "not fetched";
245
+ return null;
246
+ }
247
+ try {
248
+ const r = await fetch(url, { signal: AbortSignal.timeout(o.timeoutMs ?? 5000) });
249
+ if (!r.ok) {
250
+ lastFetchProblem = `HTTP ${r.status}`;
251
+ return null;
252
+ }
253
+ return await r.json();
254
+ }
255
+ catch (e) {
256
+ lastFetchProblem = e.message;
257
+ return null;
258
+ }
259
+ };
260
+ if (cfg.identity.agentId) {
261
+ const expected = documentUri(node, DOCUMENT_FILES.registration, built.registration);
262
+ if (!state.agentURI)
263
+ rows.push({ commitment: "ERC-8004 agentURI", ok: false, detail: "no tokenURI readable" });
264
+ else if (state.agentURI !== expected)
265
+ rows.push({ commitment: "ERC-8004 agentURI", ok: false, detail: `the chain names ${state.agentURI.slice(0, 80)}; built ${expected.slice(0, 80)}` });
266
+ else {
267
+ const identityFacet = built.aidDocument.facets.find((f) => f.facetType === "aid:core/identity/v1");
268
+ const content = state.agentURI.startsWith("data:") ? built.registration : await fetchJson(state.agentURI);
269
+ if (content === null)
270
+ rows.push({ commitment: "ERC-8004 agentURI", ok: null, detail: `${state.agentURI} is the committed URI; not read from here (${lastFetchProblem}; the node must serve it)` });
271
+ else {
272
+ const digest = keccakJcs(content);
273
+ rows.push({ commitment: "ERC-8004 agentURI", ok: digest === identityFacet?.digest, detail: digest === identityFacet?.digest ? `the registration read from ${state.agentURI.startsWith("data:") ? "the data URI" : state.agentURI} hashes to the AID identity facet's digest ${digest.slice(0, 10)}…` : `the registration read back hashes to ${digest.slice(0, 10)}…, the AID identity facet says ${identityFacet?.digest.slice(0, 10)}…` });
274
+ }
275
+ }
276
+ }
277
+ {
278
+ const c = AID.documentCommitment(built.aidDocument);
279
+ const ok = Boolean(state.aid && state.aid.digest.toLowerCase() === c.digest.toLowerCase());
280
+ rows.push({ commitment: "ERC-8434 documentURI", ok, detail: ok ? `digest ${c.digest.slice(0, 10)}… at ${state.aid.uri.slice(0, 60)}${state.aid.uri.length > 60 ? "…" : ""}` : `the chain holds ${state.aid?.digest.slice(0, 10) ?? "nothing"}, built ${c.digest.slice(0, 10)}…` });
281
+ if (ok && state.aid.uri.startsWith("http")) {
282
+ const content = await fetchJson(state.aid.uri);
283
+ if (content !== null)
284
+ rows.push({ commitment: "ERC-8434 document content", ok: AID.documentCommitment(content).digest.toLowerCase() === c.digest.toLowerCase(), detail: `fetched from ${state.aid.uri}` });
285
+ }
286
+ }
287
+ if (cfg.identity.recordId) {
288
+ const p = descriptorHash(built.ardAgent);
289
+ const ok = Boolean(state.ard && state.ard.descriptorHash.toLowerCase() === p.hex.toLowerCase());
290
+ rows.push({ commitment: "ERC-8444 descriptor", ok, detail: ok ? `descriptorHash ${p.hex.slice(0, 10)}… on record ${cfg.identity.recordId.slice(0, 10)}…` : `the record holds ${state.ard?.descriptorHash.slice(0, 10) ?? "nothing"}, built ${p.hex.slice(0, 10)}…` });
291
+ }
292
+ if (cfg.identity.unitId) {
293
+ const hash = k.units.boundaryHash(built.aufUnit);
294
+ const ok = Boolean(state.unit && state.unit.boundaryHash.toLowerCase() === hash.toLowerCase());
295
+ rows.push({ commitment: "ERC-8454 unit version", ok, detail: ok ? `version ${state.unit.version} boundaryHash ${hash.slice(0, 10)}…` : `the latest version holds ${state.unit?.boundaryHash.slice(0, 10) ?? "nothing"}, built ${hash.slice(0, 10)}…` });
296
+ }
297
+ return rows;
298
+ }
@@ -0,0 +1,208 @@
1
+ import { existsSync, readdirSync, statSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { formatEther, getAddress } from "ethers";
4
+ import { accountSubject } from "../core/kya.js";
5
+ import { operatorScope } from "../genesis/operator-scope.js";
6
+ import { runExtractIsolated, runProbeIsolated } from "../genesis/skill-runner.js";
7
+ import { documentsDir, readBack, readBuilt } from "./documents.js";
8
+ import { NODE_SCOPE_GROUPS } from "./init.js";
9
+ import { KeyIsolationError, loadOperatorKey } from "./keys.js";
10
+ export async function preflight(node, o = {}) {
11
+ const rows = [];
12
+ const row = (area, check, verdict, detail) => rows.push({ area, check, verdict, detail });
13
+ const cfg = node.config;
14
+ const k = node.kernel;
15
+ const env = o.env ?? process.env;
16
+ // --- the world and the chain
17
+ try {
18
+ const net = await k.world.provider.getNetwork();
19
+ const same = Number(net.chainId) === node.world.file.chainId;
20
+ row("world", "the RPC serves the world's chain", same ? "Pass" : "Fail", `RPC chain ${net.chainId}, world ${node.world.file.worldId} on chain ${node.world.file.chainId}`);
21
+ }
22
+ catch (e) {
23
+ row("world", "the RPC serves the world's chain", "Fail", `the RPC did not answer: ${e.message}`);
24
+ }
25
+ const storeWorld = node.store.getMeta("worldId");
26
+ row("world", "the store belongs to this world and this account", storeWorld && storeWorld !== node.world.file.worldId ? "Fail" : "Pass", `store ${node.storePath}: world ${storeWorld ?? "(new)"}, account ${node.store.getMeta("cellAccount") ?? "(none yet)"}`);
27
+ // --- the account and its permissions
28
+ if (!cfg.identity.account || !node.cell) {
29
+ row("account", "the cell is born", "Fail", "the configuration names no account (foga cell init)");
30
+ return finish(node, rows);
31
+ }
32
+ const cell = node.body;
33
+ let version = 1;
34
+ try {
35
+ version = Number(await cell.contract().ACCOUNT_VERSION());
36
+ }
37
+ catch {
38
+ version = 1;
39
+ }
40
+ row("account", "a revision-2 account (operator allow-list)", version >= 2 ? "Pass" : "Fail", `${cell.address} is revision ${version}${version < 2 ? ": no allow-list; foga cell migrate moves the cell to a revision-2 account" : ""}`);
41
+ const [owner, operator] = await Promise.all([cell.owner(), cell.operator()]);
42
+ const rolesOk = getAddress(owner) === getAddress(cfg.identity.owner) && getAddress(operator) === getAddress(cfg.identity.operator);
43
+ row("account", "the account names the configuration's owner and operator", rolesOk ? "Pass" : "Fail", `owner ${owner}, operator ${operator}`);
44
+ try {
45
+ const [anchor, status] = await Promise.all([k.aid.stateName(cell.address), cell.status()]);
46
+ row("account", "anchor ACTIVE and binding Satisfied", anchor === "ACTIVE" && status.verdictName === "Satisfied" ? "Pass" : anchor === "STALE" ? "Warn" : "Fail", `anchor ${anchor}, binding ${status.verdictName}${status.reasonNames.length ? ` [${status.reasonNames.join(", ")}]` : ""}${anchor === "STALE" ? " (the next heartbeat restores it)" : ""}`);
47
+ }
48
+ catch (e) {
49
+ row("account", "anchor ACTIVE and binding Satisfied", "Fail", e.message);
50
+ }
51
+ if (version >= 2) {
52
+ try {
53
+ const scope = operatorScope(k, NODE_SCOPE_GROUPS);
54
+ const missing = [];
55
+ for (let i = 0; i < scope.targets.length; i++)
56
+ if (!(await cell.contract().operatorAllowed(scope.targets[i], scope.selectors[i])))
57
+ missing.push(scope.names[i]);
58
+ row("account", "the operator allow-list covers the node's capabilities", missing.length ? "Fail" : "Pass", missing.length ? `${missing.length} of ${scope.names.length} missing: ${missing.join(", ")} (the owner adds them with setOperatorAllowedBatch; a cell born before they were part of the scope)` : `${scope.names.length} (target, selector) pairs allowed`);
59
+ }
60
+ catch (e) {
61
+ row("account", "the operator allow-list covers the node's capabilities", "NotTested", e.message);
62
+ }
63
+ }
64
+ try {
65
+ const [valueCap, balance] = await Promise.all([cell.contract().operatorDailyValueCap?.().catch(() => null), k.balance(cell.address)]);
66
+ const budget = BigInt(cfg.policy.budget.dailyValueWei);
67
+ const perOp = BigInt(cfg.policy.budget.perOperationValueWei);
68
+ row("budget", "the policy's caps are consistent", perOp <= budget && BigInt(cfg.policy.budget.dailyGasWei) > 0n ? "Pass" : "Fail", `per operation ${formatEther(perOp)} ETH <= daily ${formatEther(budget)} ETH; gas ${formatEther(BigInt(cfg.policy.budget.dailyGasWei))} ETH a day`);
69
+ row("budget", "the account holds working capital", balance > 0n ? "Pass" : "Warn", `${formatEther(balance)} ETH on the account${valueCap !== null && valueCap !== undefined ? `; the account's own daily value cap ${formatEther(BigInt(valueCap))} ETH` : ""}`);
70
+ }
71
+ catch (e) {
72
+ row("budget", "the policy's caps are consistent", "NotTested", e.message);
73
+ }
74
+ // --- the operator key: this environment's, alone; the address the account names; gas for its acts
75
+ try {
76
+ const key = loadOperatorKey(env);
77
+ const match = getAddress(key.address) === getAddress(cfg.identity.operator);
78
+ row("keys", "the operator key is here, alone, and is the account's operator", match ? "Pass" : "Fail", `${key.address} from ${key.source.kind === "file" ? key.source.path : key.source.name}${match ? "" : `; the account names ${cfg.identity.operator}`}`);
79
+ const gas = await k.balance(key.address);
80
+ const floor = BigInt(cfg.policy.budget.dailyGasWei) / 4n;
81
+ row("keys", "the operator holds gas (a quarter of the daily gas budget at least)", gas >= floor ? "Pass" : "Fail", `${formatEther(gas)} ETH (floor ${formatEther(floor)} ETH); the catalog refuses commitments below the floor`);
82
+ }
83
+ catch (e) {
84
+ row("keys", "the operator key is here, alone, and is the account's operator", e instanceof KeyIsolationError ? "Fail" : "NotTested", e.message);
85
+ }
86
+ for (const role of ["owner", "guardian"]) {
87
+ const names = { inline: `FOGA_${role.toUpperCase()}_KEY`, file: `FOGA_${role.toUpperCase()}_KEY_FILE` };
88
+ if (env[names.inline] || env[names.file])
89
+ row("keys", `the ${role} key stays out of the node's environment`, "Fail", `${env[names.inline] ? names.inline : names.file} is set here; a node holds the operator key alone`);
90
+ }
91
+ if (env.FOGA_MNEMONIC || env.FOGA_MNEMONIC_FILE)
92
+ row("keys", "the phrase stays out of the node's environment", "Fail", "FOGA_MNEMONIC[_FILE] is set here");
93
+ // --- standing
94
+ if (cfg.identity.standing) {
95
+ try {
96
+ const r = await k.kya.resolve(accountSubject(node.chainId, cell.address), cfg.identity.standing.schemeId, cfg.identity.standing.issuers);
97
+ const now = await node.now();
98
+ const live = r.level > 0 && (Number(r.expiresAt) === 0 || Number(r.expiresAt) > now);
99
+ row("standing", "the cell's KYA standing is attested and current", live ? "Pass" : "Warn", live ? `level ${r.level}${Number(r.expiresAt) ? `, until ${new Date(Number(r.expiresAt) * 1000).toISOString()}` : ""}` : `level ${r.level}${Number(r.expiresAt) && Number(r.expiresAt) <= now ? ", expired" : ""}; an issuer attests (foga cell attest is theirs, not the cell's)`);
100
+ }
101
+ catch (e) {
102
+ row("standing", "the cell's KYA standing is attested and current", "NotTested", e.message);
103
+ }
104
+ }
105
+ else
106
+ row("standing", "the cell's KYA standing is attested and current", "Warn", "no identity.standing in the configuration: counterparties cannot be required to a level, and the cell has none to show");
107
+ // --- skills: held on chain, committed to the package that is here, complete, offered, and running
108
+ let held = [];
109
+ try {
110
+ held = await node.heldSkills();
111
+ row("skills", "every held skill's package is here and matches its on-chain commitment", "Pass", held.length ? held.map((h) => `${h.name} (token ${h.tokenId}, ${Object.keys(h.pkg.objects).length} objects, ${h.packageHash.slice(0, 10)}…)`).join("; ") : "no skills held");
112
+ }
113
+ catch (e) {
114
+ row("skills", "every held skill's package is here and matches its on-chain commitment", "Fail", e.message);
115
+ }
116
+ const notHeld = cfg.skills.offered.filter((s) => !(s in cfg.skills.held));
117
+ row("skills", "every offered skill is held", notHeld.length ? "Fail" : "Pass", notHeld.length ? `offered but not held: ${notHeld.join(", ")}` : `${cfg.skills.offered.length} offered of ${Object.keys(cfg.skills.held).length} held`);
118
+ const executors = ["foga-probe", "finch-extract-verify"];
119
+ const noExecutor = cfg.skills.offered.filter((s) => !executors.includes(s));
120
+ if (noExecutor.length)
121
+ row("skills", "every offered skill has an executor in this node", "Fail", `no executor for ${noExecutor.join(", ")} (this node runs ${executors.join(" and ")})`);
122
+ if (o.executors !== false) {
123
+ if (cfg.skills.offered.includes("foga-probe") && held.some((h) => h.name === "foga-probe")) {
124
+ try {
125
+ const started = Date.now();
126
+ const block = await k.blockNumber();
127
+ const run = await runProbeIsolated({ worldPath: node.worldPath, rpc: node.rpc, bundlePath: node.bundlePath, input: { subject: cell.address, block, lookback: 9000, kya: cfg.identity.standing ? { schemeId: cfg.identity.standing.schemeId, issuers: cfg.identity.standing.issuers } : undefined }, timeoutMs: 60_000 });
128
+ row("skills", "foga-probe runs in the worker", run.answer ? "Pass" : "Fail", `probed ${cell.address} at block ${block} in ${Date.now() - started} ms`);
129
+ }
130
+ catch (e) {
131
+ row("skills", "foga-probe runs in the worker", "Fail", e.message);
132
+ }
133
+ }
134
+ if (cfg.skills.offered.includes("finch-extract-verify") && held.some((h) => h.name === "finch-extract-verify")) {
135
+ const fixture = existsSync(node.inputDir) ? readdirSync(node.inputDir).find((f) => f.endsWith(".txt")) : undefined;
136
+ if (!fixture)
137
+ row("skills", "finch-extract-verify runs in the worker", "NotTested", `no .txt document in the input directory ${node.inputDir}`);
138
+ else {
139
+ try {
140
+ const started = Date.now();
141
+ const pkg = held.find((h) => h.name === "finch-extract-verify");
142
+ const run = await runExtractIsolated({ documentPath: join(node.inputDir, fixture), fieldsPath: join(node.skillsDir, "finch-extract-verify", "fields.json"), packageHash: pkg.packageHash, inputDir: node.inputDir, skillsDir: node.skillsDir, timeoutMs: 60_000 });
143
+ row("skills", "finch-extract-verify runs in the worker", run.resultHash ? "Pass" : "Fail", `extracted ${fixture} in ${Date.now() - started} ms`);
144
+ }
145
+ catch (e) {
146
+ row("skills", "finch-extract-verify runs in the worker", "Fail", e.message);
147
+ }
148
+ }
149
+ }
150
+ }
151
+ row("skills", "the input directory exists", existsSync(node.inputDir) && statSync(node.inputDir).isDirectory() ? "Pass" : "Fail", node.inputDir);
152
+ // --- the documents: built, and what the chain holds agrees with them
153
+ const built = readBuilt(documentsDir(node));
154
+ if (!built)
155
+ row("documents", "the documents are built", "Warn", `nothing under ${documentsDir(node)} (foga cell docs build; foga cell docs publish commits them)`);
156
+ else {
157
+ row("documents", "the documents are built", "Pass", `v${built.manifest.version} built ${built.manifest.builtAt}, ${Object.keys(built.manifest.documents).length} files`);
158
+ for (const r of await readBack(node, built, { fetchUrls: o.network !== false }))
159
+ row("documents", r.commitment, r.ok === true ? "Pass" : r.ok === false ? "Fail" : "Warn", r.detail);
160
+ }
161
+ // --- the public face
162
+ const publicUrl = cfg.runtime.http.publicUrl;
163
+ if (!publicUrl)
164
+ row("public", "a public URL is configured", "Warn", "runtime.http.publicUrl is not set: the documents commit as data: URIs and the card declares no interface");
165
+ else if (o.network === false)
166
+ row("public", "the public URL answers", "NotTested", publicUrl);
167
+ else {
168
+ try {
169
+ const r = await fetch(`${publicUrl.replace(/\/+$/, "")}/healthz`, { signal: AbortSignal.timeout(5000) });
170
+ const body = (await r.json());
171
+ const same = body.account?.toLowerCase() === cell.address.toLowerCase();
172
+ row("public", "the public URL answers for this cell", r.ok && same ? "Pass" : "Fail", `${publicUrl}/healthz: HTTP ${r.status}, account ${body.account ?? "none"}`);
173
+ }
174
+ catch (e) {
175
+ row("public", "the public URL answers for this cell", "Warn", `${publicUrl}/healthz not reachable from here: ${e.message}`);
176
+ }
177
+ }
178
+ if (cfg.runtime.http.allowedHosts.length && publicUrl) {
179
+ const host = new URL(publicUrl).hostname;
180
+ row("public", "the public host is among the allowed hosts", cfg.runtime.http.allowedHosts.includes(host) ? "Pass" : "Fail", `${host} ${cfg.runtime.http.allowedHosts.includes(host) ? "is" : "is not"} in runtime.http.allowedHosts (${cfg.runtime.http.allowedHosts.join(", ")})`);
181
+ }
182
+ // --- the model, when the node is to run alive
183
+ if (cfg.runtime.model.provider === "anthropic") {
184
+ row("model", "the anthropic adapter has its key and model name", env.ANTHROPIC_API_KEY && cfg.runtime.model.model ? "Pass" : "Fail", `${env.ANTHROPIC_API_KEY ? "ANTHROPIC_API_KEY set" : "ANTHROPIC_API_KEY not set"}; model ${cfg.runtime.model.model ?? "(none)"}; ${cfg.runtime.model.dailyCalls} calls a day`);
185
+ }
186
+ else
187
+ row("model", "the model", "Pass", `provider ${cfg.runtime.model.provider} (host mode serves without one; foga node run --model anthropic needs the key and a model name)`);
188
+ return finish(node, rows);
189
+ }
190
+ function finish(node, rows) {
191
+ const summary = { Pass: 0, Warn: 0, Fail: 0, NotTested: 0 };
192
+ for (const r of rows)
193
+ summary[r.verdict]++;
194
+ return { schema: "foga.preflight/1", cell: node.config.name, account: node.config.identity.account ?? null, chainId: node.chainId, at: new Date().toISOString(), rows, summary };
195
+ }
196
+ export function printPreflight(report, print) {
197
+ print(`preflight: cell ${report.cell}${report.account ? ` (${report.account})` : ""} on chain ${report.chainId}, ${report.at}`);
198
+ let area = "";
199
+ for (const r of report.rows) {
200
+ if (r.area !== area) {
201
+ area = r.area;
202
+ print(` ${area}`);
203
+ }
204
+ print(` ${r.verdict.padEnd(9)} ${r.check}`);
205
+ print(` ${r.detail}`);
206
+ }
207
+ print(` ${report.summary.Pass} pass, ${report.summary.Warn} warn, ${report.summary.Fail} fail, ${report.summary.NotTested} not tested`);
208
+ }
@@ -0,0 +1,95 @@
1
+ import { endpointsOf } from "../a2a/card.js";
2
+ import { PACKAGE_NAME } from "../util/version.js";
3
+ /**
4
+ * Propagation and discovery (design memo, section 10, the self-propagation refinement): the free, off-chain material
5
+ * a covenant-complete cell offers so that FOGA spreads — flexibly, at no fixed cost, and only ever by consent.
6
+ *
7
+ * Three rules shape every line of this module:
8
+ * - Flexible, not nailed down. What the cell exposes is *material* — a manifest and a replication pointer — that an
9
+ * agent weaves into its own interactions however it sees fit. There is no single mandated channel or hook.
10
+ * - No fixed cost. All of it is off-chain: built from the configuration, served over HTTP, read over MCP. Nothing
11
+ * here touches the chain or spends gas. On-chain discoverability (the ERC-8444 ARD descriptor) stays the opt-in
12
+ * `docs_publish`, with this free manifest and the agent card as the always-available backup.
13
+ * - By consent. The replication pointer is a visible, declinable offer the counterparty sees because it came to the
14
+ * cell. It is never hidden inside an unrelated step and never pushed, unsolicited, to anyone who did not ask.
15
+ */
16
+ /** The covenants this program is complete over: the ten plus ERC-8004. The base set, not a ceiling — see `world`. */
17
+ export const FOGA_COVENANTS = ["ERC-8004", "ERC-8338", "ERC-8414", "ERC-8419", "ERC-8434", "ERC-8436", "ERC-8444", "ERC-8447", "ERC-8454", "ERC-8468", "ERC-8471"];
18
+ export const PROPAGATION_SCHEMA = "foga.propagation/1";
19
+ /**
20
+ * Where the program's maintainer lists the services around the covenants — free ones, and later paid ones. The
21
+ * manifest carries the pointer; the catalog itself lives behind the URL, so what is on offer changes there, not here.
22
+ */
23
+ export const MAINTAINER_SERVICES_CATALOG = "https://foga.finchip.ai/services.json";
24
+ export const MAINTAINER = "FinChip.ai";
25
+ /** The maintainer's offers as of this build; the live list is the catalog's. */
26
+ export const MAINTAINER_OFFERS = [
27
+ { id: "become-a-cell", name: "Become a cell", kind: "free", status: "available", url: "https://github.com/garyyang-finchip/foga#readme", description: "The program, the quickstart and the public-chain birth — free, with an identity of your own." },
28
+ { id: "kya-attestation", name: "KYA standing attestation", kind: "paid", status: "planned", url: MAINTAINER_SERVICES_CATALOG, description: "Standing under the FOGA scheme, attested by an issuer, for the tenders that require it; a cell cannot attest itself." },
29
+ { id: "hosted-cell", name: "Hosted cell", kind: "paid", status: "planned", url: MAINTAINER_SERVICES_CATALOG, description: "A cell served around the clock — the box, the TLS, the watchers, the backups — on keys that stay yours." },
30
+ { id: "dispute-resolution", name: "Dispute resolution", kind: "paid", status: "planned", url: MAINTAINER_SERVICES_CATALOG, description: "An ERC-8471 arbiter for the disputes a tender economy produces." },
31
+ ];
32
+ /** The services block from the configuration: the maintainer's by default, the operator's own, or none. */
33
+ export function servicesOf(cfg) {
34
+ const s = cfg.presence.services;
35
+ if (s && s.catalog === null)
36
+ return null;
37
+ return {
38
+ catalog: s?.catalog ?? MAINTAINER_SERVICES_CATALOG,
39
+ maintainer: s?.catalog ? "the operator of this cell" : MAINTAINER,
40
+ offers: s?.offers ?? MAINTAINER_OFFERS,
41
+ note: "optional and declinable; the catalog is the live list; a planned offer is not for sale yet",
42
+ };
43
+ }
44
+ export const SERVICES_SCHEMA = "foga.services/1";
45
+ /**
46
+ * The catalog document a cell serves at `/services.json` when the catalog its manifest points at is its own — the
47
+ * reference cell serving the maintainer's list, an operator serving theirs. A cell whose pointer names another host
48
+ * serves nothing: the list is that host's, and the pointer resolves there. The list changes without a release by a
49
+ * static file at the same path on the host (the proxy answering it before the node) — that is the live list; this
50
+ * is what it says until then.
51
+ */
52
+ export function servicesCatalogOf(node, o) {
53
+ const s = servicesOf(node.config);
54
+ const base = node.config.runtime.http.publicUrl?.replace(/\/+$/, "") ?? null;
55
+ if (!s || !base || s.catalog !== `${base}/services.json`)
56
+ return null;
57
+ return { schema: SERVICES_SCHEMA, catalog: s.catalog, maintainer: s.maintainer, program: { package: PACKAGE_NAME, version: o.version }, offers: s.offers, note: s.note };
58
+ }
59
+ /**
60
+ * Build the manifest from the cell's configuration and the package version. Pure and cheap: no chain read, no key,
61
+ * no I/O beyond what `endpointsOf` derives from the config, so it can be served on every request and before birth.
62
+ */
63
+ export function buildPropagationManifest(node, o) {
64
+ const cfg = node.config;
65
+ const ep = endpointsOf(node);
66
+ const base = cfg.runtime.http.publicUrl?.replace(/\/+$/, "") ?? null;
67
+ const inWorld = node.world.covenants();
68
+ return {
69
+ schema: PROPAGATION_SCHEMA,
70
+ cell: { name: cfg.name, account: cfg.identity.account ?? null, agentId: cfg.identity.agentId ?? null, chainId: node.chainId },
71
+ covenants: FOGA_COVENANTS,
72
+ world: { covenants: inWorld, extensions: inWorld.filter((c) => !FOGA_COVENANTS.includes(c)) },
73
+ program: {
74
+ package: PACKAGE_NAME,
75
+ version: o.version,
76
+ becomeACell: `npx -y ${PACKAGE_NAME} quickstart`,
77
+ wallet: `npx -y ${PACKAGE_NAME} wallet new`,
78
+ docs: "https://github.com/garyyang-finchip/foga#readme",
79
+ site: ep.web ?? "https://foga.finchip.ai",
80
+ },
81
+ endpoints: { mcp: ep.mcp, a2a: ep.a2a, web: ep.web, card: base ? `${base}/.well-known/agent-card.json` : null, manifest: base ? `${base}/.well-known/foga-cell.json` : null },
82
+ replication: {
83
+ pitch: "You can become a FOGA cell yourself: the program is public, the identity you make is your own, and nothing of this cell's identity is copied.",
84
+ pointer: `Run \`npx -y ${PACKAGE_NAME} quickstart\` for a living cell on a local chain, or read ${ep.web ?? "https://foga.finchip.ai"}.`,
85
+ optional: true,
86
+ consensual: true,
87
+ },
88
+ skills: o.skills ?? cfg.skills.offered,
89
+ discovery: {
90
+ free: ["this manifest at /.well-known/foga-cell.json", "the agent card at /.well-known/agent-card.json", `the npm package ${PACKAGE_NAME} and the MCP registry`],
91
+ onchain: { optional: true, via: "the ERC-8444 ARD descriptor (foga cell docs publish)", cost: "gas; opt-in, with this manifest and the card as the free backup" },
92
+ },
93
+ services: servicesOf(cfg),
94
+ };
95
+ }