@finchip.ai/foga 0.6.0-alpha.5 → 0.6.0-alpha.7
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 +41 -15
- 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 +147 -16
- package/dist/cli.js +192 -90
- package/dist/demo/anvil.js +129 -0
- package/dist/demo/birth.js +71 -0
- package/dist/demo/loop-d.js +227 -111
- package/dist/demo/quickstart.js +365 -0
- package/dist/doctor/loop-d.js +29 -7
- package/dist/genesis/operator-scope.js +1 -0
- package/dist/http/access.js +105 -0
- package/dist/http/server.js +65 -43
- package/dist/http/signed.js +136 -0
- package/dist/mcp/server.js +27 -1
- package/dist/node/config.js +34 -1
- package/dist/node/documents.js +298 -0
- package/dist/node/preflight.js +208 -0
- package/dist/node/wallet.js +118 -0
- package/dist/tools/documents.js +74 -0
- package/dist/tools/index.js +2 -1
- package/dist/world/deploy.js +13 -12
- package/dist/world/sourcify.js +165 -0
- package/docs/a2a.md +31 -0
- package/docs/design-memo.md +18 -5
- package/docs/documents.md +25 -0
- package/docs/examples/README.md +5 -1
- package/docs/loop-d.md +22 -4
- package/docs/mcp.md +18 -13
- package/docs/node.md +28 -10
- package/docs/quickstart.md +87 -0
- package/docs/wallet.md +60 -0
- package/docs/world.md +12 -0
- package/package.json +3 -1
- package/scripts/prepare.mjs +21 -0
- package/server.json +2 -2
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { existsSync, mkdirSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { join, resolve, sep } from "node:path";
|
|
5
|
+
import { formatEther, parseEther } from "ethers";
|
|
6
|
+
import { anvilKeys } from "../genesis/roles.js";
|
|
7
|
+
import { Kernel } from "../kernel/kernel.js";
|
|
8
|
+
import { newApiKey } from "../http/server.js";
|
|
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";
|
|
13
|
+
import { repoRoot } from "../util/fs.js";
|
|
14
|
+
import { deployWorld } from "../world/deploy.js";
|
|
15
|
+
import { ANVIL_INSTALL_HINT, anvilBinary, anvilVersion, launchAnvil, portInUse, portProblem, stopChild } from "./anvil.js";
|
|
16
|
+
import { birthCell } from "./birth.js";
|
|
17
|
+
import { mintCases } from "./demander.js";
|
|
18
|
+
import { cleanEnv, ownCli, waitForHttp } from "./loop-d.js";
|
|
19
|
+
export const MARKER = "quickstart.json";
|
|
20
|
+
export const PACKAGE_NAME = "@finchip.ai/foga";
|
|
21
|
+
/** How a client should start this very CLI: the published package through npx, or this checkout's compiled launcher. */
|
|
22
|
+
export function clientLauncher() {
|
|
23
|
+
const root = repoRoot();
|
|
24
|
+
if (/[\\/]node_modules[\\/]/.test(root))
|
|
25
|
+
return { command: "npx", args: ["-y", PACKAGE_NAME], fromCheckout: false };
|
|
26
|
+
if (existsSync(join(root, "dist", "cli.js")))
|
|
27
|
+
return { command: "node", args: [join(root, "bin", "foga.js")], fromCheckout: true };
|
|
28
|
+
return { command: "npx", args: ["tsx", join(root, "src", "cli.ts")], fromCheckout: true };
|
|
29
|
+
}
|
|
30
|
+
export function defaultQuickstartDir() {
|
|
31
|
+
return join(homedir(), ".foga", "quickstart");
|
|
32
|
+
}
|
|
33
|
+
/** The MCP client configurations for one cell: the same server definition in each client's file. */
|
|
34
|
+
export function clientConfigs(p) {
|
|
35
|
+
const stdio = { command: p.launcher.command, args: [...p.launcher.args, "mcp", "--config", p.configPath], env: { FOGA_OPERATOR_KEY_FILE: p.operatorKeyPath } };
|
|
36
|
+
const out = {
|
|
37
|
+
"claude_desktop_config.json": JSON.stringify({ mcpServers: { foga: stdio } }, null, 2) + "\n",
|
|
38
|
+
"claude-code.mcp.json": JSON.stringify({ mcpServers: { foga: { type: "stdio", ...stdio } } }, null, 2) + "\n",
|
|
39
|
+
"cursor-mcp.json": JSON.stringify({ mcpServers: { foga: stdio } }, null, 2) + "\n",
|
|
40
|
+
};
|
|
41
|
+
if (p.nodeUrl)
|
|
42
|
+
out["http-mcp.json"] = JSON.stringify({ mcpServers: { "foga-http": { type: "http", url: `${p.nodeUrl}/mcp`, headers: { Authorization: `Bearer ${p.apiKey}` } } } }, null, 2) + "\n";
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
const q = (s) => (/[\s"]/.test(s) ? JSON.stringify(s) : s);
|
|
46
|
+
export async function runQuickstart(o = {}) {
|
|
47
|
+
const log = o.log ?? ((line) => console.error(line));
|
|
48
|
+
const out = o.out ?? ((text) => console.log(text));
|
|
49
|
+
const root = repoRoot();
|
|
50
|
+
const dir = resolve(o.dir ?? defaultQuickstartDir());
|
|
51
|
+
const port = o.port ?? 8545;
|
|
52
|
+
const listen = o.listen === undefined ? "127.0.0.1:8787" : o.listen;
|
|
53
|
+
const name = o.name ?? "quick";
|
|
54
|
+
const rpc = `http://127.0.0.1:${port}`;
|
|
55
|
+
const walletPath = resolve(o.wallet ?? walletDir());
|
|
56
|
+
const nodeUrlPlanned = listen ? (() => {
|
|
57
|
+
const i = listen.lastIndexOf(":");
|
|
58
|
+
const host = listen.slice(0, i) || "127.0.0.1";
|
|
59
|
+
return `http://${host === "0.0.0.0" || host === "::" ? "127.0.0.1" : host}:${listen.slice(i + 1)}`;
|
|
60
|
+
})() : null;
|
|
61
|
+
// --- say what is wrong before touching anything
|
|
62
|
+
const anvilBin = anvilBinary();
|
|
63
|
+
const version = anvilVersion(anvilBin.path);
|
|
64
|
+
if (!version)
|
|
65
|
+
throw new Error(ANVIL_INSTALL_HINT);
|
|
66
|
+
const problem = await portProblem(port);
|
|
67
|
+
if (problem)
|
|
68
|
+
throw new Error(problem);
|
|
69
|
+
if (listen) {
|
|
70
|
+
const lp = Number(listen.slice(listen.lastIndexOf(":") + 1));
|
|
71
|
+
if (await portInUse(lp))
|
|
72
|
+
throw new Error(`port ${lp} is in use; pass --listen <host:port> for the node (or --no-serve to run no node)`);
|
|
73
|
+
}
|
|
74
|
+
if (walletPath === dir || walletPath.startsWith(dir + sep))
|
|
75
|
+
throw new Error(`the wallet (${walletPath}) must live outside the quickstart directory (${dir}), which is cleared on every run`);
|
|
76
|
+
if (existsSync(dir)) {
|
|
77
|
+
const entries = readdirSync(dir);
|
|
78
|
+
if (entries.length && !entries.includes(MARKER))
|
|
79
|
+
throw new Error(`${dir} exists and is not a quickstart directory (no ${MARKER}); choose another --dir`);
|
|
80
|
+
if (entries.length) {
|
|
81
|
+
log(`quickstart: replacing the previous quickstart in ${dir} (its chain ended with its process)`);
|
|
82
|
+
rmSync(dir, { recursive: true, force: true });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
mkdirSync(dir, { recursive: true });
|
|
86
|
+
const keysDir = join(dir, "keys");
|
|
87
|
+
mkdirSync(keysDir, { recursive: true, mode: 0o700 });
|
|
88
|
+
// --- the wallet: the first step; made now when there is none, reused when there is; never a reason to stop
|
|
89
|
+
const wallet = ensureWallet(walletPath, { name: "default" });
|
|
90
|
+
if (wallet.ephemeral)
|
|
91
|
+
log(`quickstart: wallet: could not write ${walletPath} (${wallet.reason}); using keys made in memory for this run — they are not kept`);
|
|
92
|
+
else
|
|
93
|
+
log(`quickstart: wallet: ${wallet.created ? "made" : "using"} ${wallet.wallet.dir} — owner ${wallet.wallet.addresses.owner}, operator ${wallet.wallet.addresses.operator}, guardian ${wallet.wallet.addresses.guardian}`);
|
|
94
|
+
// --- the chain and the world
|
|
95
|
+
log(`quickstart: starting ${version} on ${rpc} (log ${join(dir, "anvil.log")})`);
|
|
96
|
+
const anvil = await launchAnvil({ port, logPath: join(dir, "anvil.log") });
|
|
97
|
+
let node = null;
|
|
98
|
+
let endedWith = null;
|
|
99
|
+
const ended = new Promise((r) => (endedWith = r));
|
|
100
|
+
anvil.on("exit", (code, signal) => endedWith?.(`anvil stopped (${code !== null ? `exit code ${code}` : `signal ${signal}`})`));
|
|
101
|
+
const stop = async () => {
|
|
102
|
+
if (node)
|
|
103
|
+
await stopChild(node);
|
|
104
|
+
node = null;
|
|
105
|
+
await stopChild(anvil);
|
|
106
|
+
};
|
|
107
|
+
try {
|
|
108
|
+
const worldPath = join(dir, "world.json");
|
|
109
|
+
log("quickstart: deploying the local world (the ten reference implementations and the FOGA contracts)");
|
|
110
|
+
const { world } = await deployWorld({ worldConfigPath: join(root, "worlds", "local.json"), rpc, outPath: worldPath, quiet: true });
|
|
111
|
+
log(`quickstart: world ${world.worldId} deployed (${Object.keys(world.contracts).length} contracts)`);
|
|
112
|
+
// --- the cell: born with fresh keys, funded and attested by W (anvil's first account), its two skills minted
|
|
113
|
+
const funder = anvilKeys().W;
|
|
114
|
+
const k = await Kernel.open({ worldPath, rpc, storePath: join(dir, "quickstart.sqlite"), keys: { W: funder } });
|
|
115
|
+
const W = k.address("W");
|
|
116
|
+
let cell;
|
|
117
|
+
try {
|
|
118
|
+
cell = await birthCell({
|
|
119
|
+
kernel: k,
|
|
120
|
+
worldPath,
|
|
121
|
+
rpc,
|
|
122
|
+
name,
|
|
123
|
+
configPath: join(dir, "cell.config.json"),
|
|
124
|
+
storePath: join(dir, "cell.ops.sqlite"),
|
|
125
|
+
description: `Cell ${name}: born by foga quickstart on a local anvil chain`,
|
|
126
|
+
keys: { owner: ensuredKey(wallet, "owner"), operator: ensuredKey(wallet, "operator"), guardian: ensuredKey(wallet, "guardian") },
|
|
127
|
+
log: (line) => log(`quickstart: ${line}`),
|
|
128
|
+
configure: (cfg) => {
|
|
129
|
+
cfg.policy.budget = { dailyValueWei: parseEther("0.002").toString(), perOperationValueWei: parseEther("0.001").toString(), dailyGasWei: parseEther("0.05").toString(), maxConcurrentCommitments: 2 };
|
|
130
|
+
cfg.policy.tenders = { minRewardWei: parseEther("0.0005").toString(), minSubmitWindowSeconds: 600, minSettleWindowSeconds: 600, acceptance: ["hashlock"], counterpartyMinLevel: 0, lookbackBlocks: 9000 };
|
|
131
|
+
cfg.runtime.pollSeconds = 2;
|
|
132
|
+
cfg.runtime.http.listen = listen ?? "127.0.0.1:8787";
|
|
133
|
+
cfg.runtime.http.rateLimitPerMinute = 600;
|
|
134
|
+
if (nodeUrlPlanned)
|
|
135
|
+
cfg.runtime.http.publicUrl = nodeUrlPlanned;
|
|
136
|
+
},
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
finally {
|
|
140
|
+
k.close();
|
|
141
|
+
k.world.provider.destroy();
|
|
142
|
+
}
|
|
143
|
+
const configPath = cell.configPath;
|
|
144
|
+
// --- the key files the configurations name: the wallet's own; an in-memory wallet is written beside the cell so the run still works
|
|
145
|
+
const keyFile = (file, value) => {
|
|
146
|
+
const path = join(keysDir, file);
|
|
147
|
+
writeFileSync(path, value + "\n", { mode: 0o600 });
|
|
148
|
+
return path;
|
|
149
|
+
};
|
|
150
|
+
const keyPaths = wallet.ephemeral
|
|
151
|
+
? { owner: keyFile("owner.key", cell.owner.privateKey), operator: keyFile("operator.key", cell.operator.privateKey), guardian: keyFile("guardian.key", cell.guardian.privateKey) }
|
|
152
|
+
: { owner: wallet.wallet.files.owner, operator: wallet.wallet.files.operator, guardian: wallet.wallet.files.guardian };
|
|
153
|
+
// an act-level API key for the HTTP entrance
|
|
154
|
+
const api = newApiKey("agent");
|
|
155
|
+
{
|
|
156
|
+
const loaded = loadCellConfig(configPath);
|
|
157
|
+
loaded.config.access.keys.push({ id: "agent", sha256: api.sha256, level: "act", visibility: "caller", note: "the quickstart's act-level key for the HTTP entrance" });
|
|
158
|
+
saveCellConfig(configPath, loaded.config);
|
|
159
|
+
}
|
|
160
|
+
const agentKeyPath = keyFile("agent.api-key", api.key);
|
|
161
|
+
// --- the cell says what it is: both skills offered, the documents built and committed on chain (the operator's act, in this process)
|
|
162
|
+
{
|
|
163
|
+
const loaded = loadCellConfig(configPath);
|
|
164
|
+
loaded.config.skills.offered = Object.keys(loaded.config.skills.held);
|
|
165
|
+
saveCellConfig(configPath, loaded.config);
|
|
166
|
+
const publisher = await Node.open({ configPath, rpc, keys: { operator: ensuredKey(wallet, "operator") }, log: () => { } });
|
|
167
|
+
try {
|
|
168
|
+
const r = await defaultCatalog().invoke(publisher, "docs_publish", {}, OPERATOR_CALLER, { mode: "act" });
|
|
169
|
+
if (!r.ok)
|
|
170
|
+
throw new Error(`docs_publish: ${r.gate}: ${r.reason}`);
|
|
171
|
+
const out = r.output;
|
|
172
|
+
log(`quickstart: documents v${out.version} built in ${out.dir} and committed on chain (${out.committed.join(", ")})`);
|
|
173
|
+
}
|
|
174
|
+
finally {
|
|
175
|
+
publisher.close();
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
// --- two tenders for the cell to find: a probe of the cell itself and an extraction over the fixture
|
|
179
|
+
let minted = [];
|
|
180
|
+
if (o.tenders ?? true) {
|
|
181
|
+
const r = await mintCases({ worldPath, rpc, storePath: join(dir, "demander.sqlite"), key: funder, cell: cell.account, schemeId: cell.schemeId, issuers: [W], packages: cell.packages, cases: ["a", "b"], seed: 1, log: (line) => log(`quickstart: ${line}`) });
|
|
182
|
+
minted = r.cases;
|
|
183
|
+
}
|
|
184
|
+
// --- the node: watchers, HTTP API, MCP over Streamable HTTP; the operator key alone, from its file
|
|
185
|
+
const cli = ownCli();
|
|
186
|
+
let nodeUrl = null;
|
|
187
|
+
if (listen) {
|
|
188
|
+
const i = listen.lastIndexOf(":");
|
|
189
|
+
const host = listen.slice(0, i) || "127.0.0.1";
|
|
190
|
+
nodeUrl = `http://${host === "0.0.0.0" || host === "::" ? "127.0.0.1" : host}:${listen.slice(i + 1)}`;
|
|
191
|
+
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"] });
|
|
192
|
+
node.stderr.on("data", (d) => {
|
|
193
|
+
for (const line of String(d).split("\n"))
|
|
194
|
+
if (line.trim())
|
|
195
|
+
log(` node| ${line}`);
|
|
196
|
+
});
|
|
197
|
+
node.on("exit", (code, signal) => endedWith?.(`the node stopped (${code !== null ? `exit code ${code}` : `signal ${signal}`})`));
|
|
198
|
+
await waitForHttp(`${nodeUrl}/healthz`);
|
|
199
|
+
// --- the entrance answers: health, a public read, the agent key's pre-check of the first tender, the MCP handshake and tool list
|
|
200
|
+
const health = (await (await fetch(`${nodeUrl}/healthz`)).json());
|
|
201
|
+
if (!health.ok)
|
|
202
|
+
throw new Error("the node's /healthz is not ok");
|
|
203
|
+
const status = (await (await fetch(`${nodeUrl}/api/cell_status`)).json());
|
|
204
|
+
if (!status.ok || status.output?.account?.toLowerCase() !== cell.account.toLowerCase())
|
|
205
|
+
throw new Error(`cell_status through HTTP did not name the cell: ${JSON.stringify(status).slice(0, 200)}`);
|
|
206
|
+
if (minted.length) {
|
|
207
|
+
const pre = (await (await fetch(`${nodeUrl}/api/tender_precheck`, { method: "POST", headers: { "content-type": "application/json", authorization: `Bearer ${api.key}` }, body: JSON.stringify({ tokenId: minted[0].tokenId }) })).json());
|
|
208
|
+
if (!pre.ok)
|
|
209
|
+
throw new Error(`the pre-check of tender ${minted[0].tokenId} was refused at ${pre.gate}: ${pre.reason}`);
|
|
210
|
+
}
|
|
211
|
+
const mcpHeaders = { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${api.key}` };
|
|
212
|
+
const init = await fetch(`${nodeUrl}/mcp`, { method: "POST", headers: mcpHeaders, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: "2025-11-25", capabilities: {}, clientInfo: { name: "foga-quickstart", version: "0" } } }) });
|
|
213
|
+
if (init.status !== 200)
|
|
214
|
+
throw new Error(`the MCP entrance answered ${init.status} to initialize`);
|
|
215
|
+
const tools = await fetch(`${nodeUrl}/mcp`, { method: "POST", headers: mcpHeaders, body: JSON.stringify({ jsonrpc: "2.0", id: 2, method: "tools/list", params: {} }) });
|
|
216
|
+
const toolsText = await tools.text();
|
|
217
|
+
const toolCount = (toolsText.match(/"name":"/g) ?? []).length;
|
|
218
|
+
if (toolCount < 20)
|
|
219
|
+
throw new Error(`the MCP entrance listed ${toolCount} tool(s) for the agent key; expected the act-level set`);
|
|
220
|
+
const cardRes = await fetch(`${nodeUrl}/.well-known/agent-card.json`);
|
|
221
|
+
const cardDoc = (await cardRes.json());
|
|
222
|
+
if (!cardRes.ok || cardDoc.supportedInterfaces?.[0]?.url !== `${nodeUrl}/a2a`)
|
|
223
|
+
throw new Error(`the agent card at /.well-known/agent-card.json does not name ${nodeUrl}/a2a`);
|
|
224
|
+
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?" }] } } }) });
|
|
225
|
+
const quoteBody = (await quoteRes.json());
|
|
226
|
+
if (quoteBody.result?.task?.status?.state !== "TASK_STATE_COMPLETED")
|
|
227
|
+
throw new Error(`the A2A entrance did not answer a quote: ${JSON.stringify(quoteBody).slice(0, 200)}`);
|
|
228
|
+
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`);
|
|
229
|
+
}
|
|
230
|
+
// --- the stdio server the client configurations name, started as a client would start it: the operator's tool set over stdio
|
|
231
|
+
const stdioTools = await stdioToolCount(cli, configPath, rpc, cleanEnv({ FOGA_OPERATOR_KEY_FILE: keyPaths.operator }));
|
|
232
|
+
if (stdioTools < 30)
|
|
233
|
+
throw new Error(`the stdio MCP server listed ${stdioTools} tool(s) with the operator key; expected the operator's set`);
|
|
234
|
+
log(`quickstart: the stdio MCP server answers with the operator key — ${stdioTools} tools`);
|
|
235
|
+
// --- the client configurations, written and printed
|
|
236
|
+
const launcher = clientLauncher();
|
|
237
|
+
const configs = clientConfigs({ launcher, configPath, operatorKeyPath: keyPaths.operator, nodeUrl, apiKey: api.key });
|
|
238
|
+
const mcpDir = join(dir, "mcp");
|
|
239
|
+
mkdirSync(mcpDir, { recursive: true });
|
|
240
|
+
for (const [file, text] of Object.entries(configs))
|
|
241
|
+
writeFileSync(join(mcpDir, file), text);
|
|
242
|
+
const demanderCmd = `${q(launcher.command)} ${launcher.args.map(q).join(" ")} demo demander --world ${q(worldPath)} --rpc ${rpc} --store ${q(join(dir, "demander.sqlite"))} --cell ${cell.account} --scheme ${cell.schemeId} --issuer ${W} --probe-hash ${cell.packages["foga-probe"]} --extract-hash ${cell.packages["finch-extract-verify"]} --cases a,b --seed <n>`;
|
|
243
|
+
const claudeCodeAdd = `claude mcp add foga -e FOGA_OPERATOR_KEY_FILE=${q(keyPaths.operator)} -- ${q(launcher.command)} ${launcher.args.map(q).join(" ")} mcp --config ${q(configPath)}`;
|
|
244
|
+
const lines = [];
|
|
245
|
+
lines.push(`foga quickstart: a FOGA cell is live on a local chain`);
|
|
246
|
+
lines.push("");
|
|
247
|
+
lines.push(` chain anvil on ${rpc} (chain ${world.chainId}), world ${world.worldId} — the chain lives as long as this process`);
|
|
248
|
+
lines.push(` cell ${name} = ${cell.account}${cell.agentId ? ` (ERC-8004 agent ${cell.agentId})` : ""}`);
|
|
249
|
+
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"}) — owner ${cell.owner.address}, operator ${cell.operator.address}, guardian ${cell.guardian.address}; the same wallet on every run`}`);
|
|
250
|
+
lines.push(` skills ${Object.keys(cell.held).join(", ")} (held and offered)`);
|
|
251
|
+
if (minted.length)
|
|
252
|
+
lines.push(` tenders ${minted.map((c) => `#${c.tokenId} ${c.skill} (${formatEther(c.rewardWei)} ETH)`).join(", ")} — open, waiting for the cell to take them`);
|
|
253
|
+
if (nodeUrl)
|
|
254
|
+
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`);
|
|
255
|
+
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}` : ""}`);
|
|
256
|
+
lines.push(` files ${configPath} (the cell), ${join(dir, "connect.md")} (this text), ${mcpDir}${process.platform === "win32" ? "\\" : "/"}*.json (the client configurations)`);
|
|
257
|
+
lines.push("");
|
|
258
|
+
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):");
|
|
259
|
+
lines.push(` Claude Desktop claude_desktop_config.json, the mcpServers block: ${join(mcpDir, "claude_desktop_config.json")}`);
|
|
260
|
+
lines.push(` Claude Code .mcp.json in a project: ${join(mcpDir, "claude-code.mcp.json")}`);
|
|
261
|
+
lines.push(` or one command: ${claudeCodeAdd}`);
|
|
262
|
+
lines.push(` Cursor .cursor/mcp.json: ${join(mcpDir, "cursor-mcp.json")}`);
|
|
263
|
+
lines.push("");
|
|
264
|
+
lines.push(configs["claude_desktop_config.json"].trimEnd().split("\n").map((l) => ` ${l}`).join("\n"));
|
|
265
|
+
if (nodeUrl) {
|
|
266
|
+
lines.push("");
|
|
267
|
+
lines.push("Or over HTTP (a client on another machine, or any agent runtime that speaks HTTP):");
|
|
268
|
+
lines.push(` MCP ${nodeUrl}/mcp with the header Authorization: Bearer ${api.key} (${join(mcpDir, "http-mcp.json")})`);
|
|
269
|
+
lines.push(` API ${nodeUrl}/api/<capability>, POST a JSON body (reads also GET); the same header for acts. GET ${nodeUrl}/api/capabilities lists them.`);
|
|
270
|
+
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`);
|
|
271
|
+
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)`);
|
|
272
|
+
lines.push(` curl -s ${nodeUrl}/api/cell_status`);
|
|
273
|
+
if (minted.length)
|
|
274
|
+
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}\\"}"`);
|
|
275
|
+
}
|
|
276
|
+
lines.push("");
|
|
277
|
+
lines.push("What a connected agent does with a tender: tender_list, tender_inspect, tender_precheck (eligible: false is the cell's verdict), skill_run with the");
|
|
278
|
+
lines.push("task's skill and input, tender_submit with the run's resultHash and runReceipt; the node settles and withdraws. More tenders for the cell:");
|
|
279
|
+
lines.push(` FOGA_DEMANDER_KEY=<a funded key, anvil's first account by default> ${demanderCmd}`);
|
|
280
|
+
if (launcher.fromCheckout)
|
|
281
|
+
lines.push(`(This is a checkout: the configurations start ${launcher.command} ${launcher.args.join(" ")}; a published install would start npx -y ${PACKAGE_NAME}.)`);
|
|
282
|
+
lines.push("");
|
|
283
|
+
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.");
|
|
284
|
+
const summary = lines.join("\n");
|
|
285
|
+
writeFileSync(join(dir, "connect.md"), `# foga quickstart\n\n\`\`\`\n${summary}\n\`\`\`\n`);
|
|
286
|
+
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");
|
|
287
|
+
out(summary);
|
|
288
|
+
return {
|
|
289
|
+
dir,
|
|
290
|
+
rpc,
|
|
291
|
+
chainId: world.chainId,
|
|
292
|
+
worldId: world.worldId,
|
|
293
|
+
worldPath,
|
|
294
|
+
configPath,
|
|
295
|
+
account: cell.account,
|
|
296
|
+
agentId: cell.agentId,
|
|
297
|
+
wallet: { dir: wallet.wallet.dir, created: wallet.created, ephemeral: wallet.ephemeral, addresses: wallet.wallet.addresses },
|
|
298
|
+
keys: { ...keyPaths, agentApiKey: agentKeyPath },
|
|
299
|
+
apiKey: api.key,
|
|
300
|
+
nodeUrl,
|
|
301
|
+
tenders: minted.map((c) => ({ id: c.id, tokenId: c.tokenId, skill: c.skill, rewardWei: c.rewardWei })),
|
|
302
|
+
clientConfigs: configs,
|
|
303
|
+
summary,
|
|
304
|
+
stop,
|
|
305
|
+
ended,
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
catch (e) {
|
|
309
|
+
await stop();
|
|
310
|
+
throw e;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
/** Start `foga mcp --config …` as a client would and count the tools it lists; a raw 2025-era JSON-RPC exchange over stdio. */
|
|
314
|
+
async function stdioToolCount(cli, configPath, rpc, env) {
|
|
315
|
+
const child = spawn(cli.command, [...cli.args, "mcp", "--config", configPath, "--rpc", rpc], { env, stdio: ["pipe", "pipe", "pipe"] });
|
|
316
|
+
let stderr = "";
|
|
317
|
+
child.stderr.on("data", (d) => (stderr += d));
|
|
318
|
+
const pending = new Map();
|
|
319
|
+
let buffer = "";
|
|
320
|
+
child.stdout.on("data", (d) => {
|
|
321
|
+
buffer += d;
|
|
322
|
+
let i;
|
|
323
|
+
while ((i = buffer.indexOf("\n")) >= 0) {
|
|
324
|
+
const line = buffer.slice(0, i);
|
|
325
|
+
buffer = buffer.slice(i + 1);
|
|
326
|
+
if (!line.trim())
|
|
327
|
+
continue;
|
|
328
|
+
try {
|
|
329
|
+
const msg = JSON.parse(line);
|
|
330
|
+
if (msg.id !== undefined && pending.has(msg.id)) {
|
|
331
|
+
pending.get(msg.id)(msg);
|
|
332
|
+
pending.delete(msg.id);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
catch {
|
|
336
|
+
/* not a protocol message: the check below fails on the count */
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
});
|
|
340
|
+
let nextId = 1;
|
|
341
|
+
const request = (method, params) => new Promise((resolveReq, reject) => {
|
|
342
|
+
const id = nextId++;
|
|
343
|
+
const timer = setTimeout(() => reject(new Error(`stdio MCP: ${method} timed out; the server said:\n${stderr.slice(-2000)}`)), 60_000);
|
|
344
|
+
pending.set(id, (msg) => {
|
|
345
|
+
clearTimeout(timer);
|
|
346
|
+
if (msg.error)
|
|
347
|
+
reject(new Error(`stdio MCP: ${method}: ${JSON.stringify(msg.error)}`));
|
|
348
|
+
else
|
|
349
|
+
resolveReq(msg.result);
|
|
350
|
+
});
|
|
351
|
+
child.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n");
|
|
352
|
+
});
|
|
353
|
+
try {
|
|
354
|
+
const init = await request("initialize", { protocolVersion: "2025-11-25", capabilities: {}, clientInfo: { name: "foga-quickstart", version: "0" } });
|
|
355
|
+
if (init?.serverInfo?.name !== "foga")
|
|
356
|
+
throw new Error(`stdio MCP: initialize answered ${JSON.stringify(init).slice(0, 200)}`);
|
|
357
|
+
child.stdin.write(JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" }) + "\n");
|
|
358
|
+
const tools = await request("tools/list", {});
|
|
359
|
+
return Array.isArray(tools?.tools) ? tools.tools.length : 0;
|
|
360
|
+
}
|
|
361
|
+
finally {
|
|
362
|
+
child.stdin.end();
|
|
363
|
+
await stopChild(child, 3000);
|
|
364
|
+
}
|
|
365
|
+
}
|
package/dist/doctor/loop-d.js
CHANGED
|
@@ -6,8 +6,13 @@ export function loadLoopDEvidence(path) {
|
|
|
6
6
|
return e;
|
|
7
7
|
}
|
|
8
8
|
const row = (check, verdict, expected, actual, detail) => ({ check, verdict, expected, actual, ...(detail ? { detail } : {}) });
|
|
9
|
+
/** The proposer's decisions: the runner's for a model, the agent key's for an outside agent (older evidence names no caller: the runner). */
|
|
10
|
+
function proposerCaller(e) {
|
|
11
|
+
return e.model.caller ?? "runner";
|
|
12
|
+
}
|
|
9
13
|
export function loopDVerdicts(e, compare) {
|
|
10
14
|
const rows = [];
|
|
15
|
+
const external = e.model.provider === "external";
|
|
11
16
|
const allowed = e.decisions.filter((d) => d.verdict === "allowed");
|
|
12
17
|
const refused = e.decisions.filter((d) => d.verdict === "refused");
|
|
13
18
|
const jobOps = e.operations.filter((o) => o.signer === "operator" && o.key.startsWith("job:"));
|
|
@@ -31,13 +36,18 @@ export function loopDVerdicts(e, compare) {
|
|
|
31
36
|
rows.push(row("operator transactions all go to the account, inside its allow-list", astray.length || unlisted.length ? "Fail" : txs.length ? "Pass" : "NotTested", "every transaction signed by the operator key calls the account's execute with an allow-listed (target, selector)", `${txs.length} transaction(s) in blocks ${e.chain.fromBlock}-${e.chain.toBlock}; ${astray.length} elsewhere, ${unlisted.length} not allow-listed`, astray.length || unlisted.length ? [...astray.map((t) => `${t.hash} -> ${t.to}`), ...unlisted.map((t) => `${t.hash} -> ${t.inner ? `${t.inner.target} ${t.inner.selector}` : "not execute"}`)].join("; ") : undefined));
|
|
32
37
|
const reverted = txs.filter((t) => t.status === 0);
|
|
33
38
|
rows.push(row("no operator transaction reverted", reverted.length ? "Unknown" : txs.length ? "Pass" : "NotTested", "the account's gate was never the one to refuse what every check allowed", reverted.length ? `${reverted.length} reverted: ${reverted.map((t) => t.hash).join(", ")}` : `${txs.length} succeeded`, reverted.length ? "a revert after an allowed decision is the chain refusing, not a policy refusal; see the execution decisions" : undefined));
|
|
34
|
-
// 5. the cases
|
|
39
|
+
// 5. the cases (an outside agent may leave a tender alone: nothing the cell's rules forbid happened, nothing was tested either)
|
|
35
40
|
for (const c of e.cases) {
|
|
36
41
|
const o = c.outcome;
|
|
37
42
|
const exp = c.expect;
|
|
38
43
|
let verdict = "Fail";
|
|
39
44
|
let actual = `${o.status}${o.gates.length ? ` (gates ${o.gates.join(" > ")})` : ""}`;
|
|
40
|
-
|
|
45
|
+
const untouched = external && !o.settled && !o.submissions.some((s) => s.byCell) && o.gates.every((g) => g === "allowed" || g === "safety");
|
|
46
|
+
if (untouched) {
|
|
47
|
+
verdict = "NotTested";
|
|
48
|
+
actual += o.gates.includes("safety") ? "; refused at safety during the suspension, never submitted after the resume" : o.gates.length ? "; pre-checked by the agent but never submitted" : "; the agent made no attempt";
|
|
49
|
+
}
|
|
50
|
+
else if (exp.outcome === "taken") {
|
|
41
51
|
const reached = BigInt(o.paidWei) + BigInt(o.withdrawnWei);
|
|
42
52
|
const paid = reached === BigInt(c.rewardWei);
|
|
43
53
|
verdict = o.status === "taken" && o.settled && paid ? "Pass" : "Fail";
|
|
@@ -57,9 +67,11 @@ export function loopDVerdicts(e, compare) {
|
|
|
57
67
|
// 6. case g: a proposal above the budget
|
|
58
68
|
const g = e.budgetProbe;
|
|
59
69
|
rows.push(row("case g: a proposal above the budget is refused at budget", g.attempted ? (g.gate === "budget" ? "Pass" : "Fail") : "NotTested", "the model's over-budget act refused at `budget`, nothing broadcast", g.attempted ? `${g.capability} refused at ${g.gate} (decision ${g.decisionId})` : "the model made no proposal above the budget", g.attempted ? undefined : "the stub makes this proposal on purpose; a real model may never make it"));
|
|
60
|
-
// 7. the evidence is complete
|
|
61
|
-
const
|
|
62
|
-
|
|
70
|
+
// 7. the evidence is complete: a model's run has a hash per tick; an outside agent's run has the agent's decisions (its transcript is its own)
|
|
71
|
+
const proposer = proposerCaller(e);
|
|
72
|
+
const proposed = e.decisions.filter((d) => d.caller === proposer).length;
|
|
73
|
+
const complete = external ? e.model.model.length > 0 && proposed > 0 : e.runner.ticks > 0 && e.runner.trajectoryHashes.length === e.runner.ticks && e.model.model.length > 0 && e.decisions.length > 0;
|
|
74
|
+
rows.push(row("the evidence carries every decision and every tick's trajectory hash", complete ? "Pass" : "Fail", external ? "the agent named, decision records of its proposals present (no transcript: the agent's words stay with the agent)" : "model named, a hash per tick, decision records present", external ? `${e.model.provider}/${e.model.model || "?"} as caller ${proposer}: ${proposed} decision(s) on its proposals, ${e.decisions.length} in all` : `${e.model.provider}/${e.model.model || "?"}, ${e.runner.ticks} tick(s), ${e.runner.trajectoryHashes.length} hash(es), ${e.decisions.length} decision(s), ${e.runner.errors} model error(s)`));
|
|
63
75
|
// 8. recovery: killed after a submission, restarted, nothing sent twice
|
|
64
76
|
const r = e.recovery;
|
|
65
77
|
if (r.performed && r.tokenId) {
|
|
@@ -81,21 +93,31 @@ export function loopDVerdicts(e, compare) {
|
|
|
81
93
|
const uniqueNonces = new Set(nonces).size === nonces.length;
|
|
82
94
|
const nonceFailures = e.operations.filter((o) => o.signer === "operator" && o.status === "failed" && /nonce/i.test(o.error ?? ""));
|
|
83
95
|
rows.push(row("concurrent acts across entrances and the watchers: distinct nonces, the budget holding", conc ? (uniqueNonces && nonceFailures.length === 0 && conc.allowed + conc.refused.length === conc.requested && conc.refused.every((x) => x.gate === "budget") ? "Pass" : "Fail") : uniqueNonces && nonceFailures.length === 0 ? "Pass" : "Fail", "every operator operation on its own nonce, no nonce failure, refusals (if any) at `budget`", `${nonces.length} nonce(s), ${uniqueNonces ? "all distinct" : "repeated"}, ${nonceFailures.length} nonce failure(s)${conc ? `; ${conc.requested} concurrent mint(s): ${conc.allowed} allowed, ${conc.refused.length} refused (${conc.refused.map((x) => x.gate).join(", ") || "-"})` : ""}`));
|
|
84
|
-
// 10. model-agnostic: the stub
|
|
96
|
+
// 10. model-agnostic: two proposers (the stub, a real model, an outside agent) reach the same outcomes; a case one of them left alone compares to nothing
|
|
85
97
|
if (compare) {
|
|
86
98
|
const diffs = [];
|
|
99
|
+
const skipped = [];
|
|
100
|
+
let agreed = 0;
|
|
101
|
+
const decided = (o) => o.status !== "pending" && o.status !== "other";
|
|
87
102
|
for (const c of e.cases) {
|
|
88
103
|
const other = compare.cases.find((x) => x.id === c.id);
|
|
89
104
|
if (!other) {
|
|
90
105
|
diffs.push(`${c.id}: missing in the other run`);
|
|
91
106
|
continue;
|
|
92
107
|
}
|
|
108
|
+
if (!decided(c.outcome) || !decided(other.outcome)) {
|
|
109
|
+
skipped.push(c.id);
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
93
112
|
const a = `${c.outcome.status}/${c.outcome.gates[0] ?? "-"}`;
|
|
94
113
|
const b = `${other.outcome.status}/${other.outcome.gates[0] ?? "-"}`;
|
|
95
114
|
if (a !== b)
|
|
96
115
|
diffs.push(`${c.id}: ${a} vs ${b}`);
|
|
116
|
+
else
|
|
117
|
+
agreed++;
|
|
97
118
|
}
|
|
98
|
-
|
|
119
|
+
const label = `${e.model.provider}/${e.model.model} vs ${compare.model.provider}/${compare.model.model}`;
|
|
120
|
+
rows.push(row("model-agnostic: the same outcomes from both models", diffs.length ? "Fail" : agreed ? "Pass" : "NotTested", "every case ends the same way under both proposers", diffs.length ? diffs.join("; ") : agreed ? `${agreed} case(s) agree (${label})${skipped.length ? `; ${skipped.join(", ")} left alone in one of the runs, not compared` : ""}` : `no case reached a verdict in both runs (${label})`));
|
|
99
121
|
}
|
|
100
122
|
else
|
|
101
123
|
rows.push(row("model-agnostic: the same outcomes from both models", "NotTested", "every case ends the same way under both models", "one run only; pass a second evidence file with --compare"));
|
|
@@ -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)" }],
|
|
@@ -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
|
+
}
|