@finchip.ai/foga 0.6.0-alpha.5 → 0.6.0-alpha.6

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 CHANGED
@@ -19,7 +19,9 @@ ERC-8004 (Trustless Agents) is a required dependency: ERC-8419, ERC-8434 and ERC
19
19
 
20
20
  The project delivers three things from one kernel: the **Finch OG Agent** (the first running instance), the **FOGA node software** anyone can run with their own identity, model and wallet, and a **public access layer** (MCP, A2A, HTTP/CLI). It is built in phases; this repository has completed **Phase 2c: the genesis on a public chain**, on top of the Phase 2 genesis (T0–T9) and the Phase 1 world layer.
21
21
 
22
- ## Status: Phase 3c done — Loop D, the unscripted run (3c); Phase 3b done — the node served over MCP and HTTP; Phase 3a done — the kernel hardened and turned into a node with a capability catalog; Phase 2c done
22
+ ## Status: Phase 3d in progress — 3d-1 done (quickstart, an outside agent's Loop D); Phase 3c done — Loop D, the unscripted run; Phase 3b done — the node served over MCP and HTTP; Phase 3a done — the kernel hardened and turned into a node with a capability catalog; Phase 2c done
23
+
24
+ **3d-1 (0.6.0-alpha.6, 2026-11-02).** The package is on npm as `@finchip.ai/foga` (0.6.0-alpha.4 and alpha.5, the latter with the launcher fixed for Windows), so the first slice of 3d is the two things that make it usable from nothing. `foga quickstart` (`src/demo/quickstart.ts`, docs/quickstart.md) is one command to a live cell: anvil, the local world, a cell born with keys nobody held and its two skills, two tenders minted for it, the node served (watchers, HTTP API, MCP over Streamable HTTP), both entrances checked (the handshake and tool list over HTTP with an act-level key, and over stdio with the operator key), and the MCP client configurations — Claude Desktop, Claude Code, Cursor, and the HTTP form with URL and Bearer key — printed and written with every path filled in, under `~/.foga/quickstart`. Loop D gains `--model external` (docs/loop-d.md): the node is served instead of run, an act-level key is minted for whoever proposes, the orchestrator prints a brief (the entrance, the five steps of a tender, what the run does around the agent — nothing about the cases) and waits for the agent — any model, any runtime, a person with `curl` — with the same recovery drill, the same entrance checks and the same acceptance; the evidence names the agent and carries no runner ticks, the doctor marks a case the agent left alone `NotTested` rather than `Fail`, and `--compare` skips what one run did not reach. `test/loop-d-external.test.ts` drives it with a scripted agent through the MCP entrance and every verdict passes, the model-agnostic row included against the kept stub run. Smaller: `.gitattributes` keeps every text file LF (the launcher's shebang included), a `prepare` script compiles a checkout installed straight from git, the shared cell birth (`src/demo/birth.ts`) and anvil helpers (`src/demo/anvil.ts`) serve the demos, and the docs carry the PowerShell and Windows notes. Next in 3d: `foga node preflight`, `docs build/publish` with `setAgentURI`, the A2A card and inbound mapping, Sourcify, then the OG deployment runbook, the documentation site and the registry listing.
23
25
 
24
26
  **3c (0.6.0-alpha.4, 2026-11-02).** A model in the loop, and the acceptance that looks at records rather than words. `src/node/runner.ts` is the node's loop with a model: each tick perceives through the read capabilities, hands the situation and the catalog as tools to the model, carries out what it proposes — reads at once, acts through `Catalog.invoke` where the three checks decide and a refusal goes back so the model can revise once — and records the tick by hash (the decision records carry the model and the hashes of its request and answer; the journal the hash of the whole transcript; the text never). Limits bound a tick (12 tool calls, 2 acts) and a day (the model's calls, counted in the store). Two adapters on one interface: `anthropic` (the Messages API over `fetch`, the key in the environment, the model name in the configuration) and `stub` (a deterministic decision table that also proposes, once, a tender above the budget so the evidence shows the gate closing). `foga node run` is the embedded node — server, watchers, runner — on the operator key alone. `foga demo --loop-d` (`src/demo/loop-d.ts`) is the unscripted run: a new cell with fresh keys, an independent demander process minting cases a–f in a seeded order (two tenders to take, four to refuse at `skill`, `policy`, `verifier`, and one met during the guardian's suspension), the node killed after its first submission and restarted, the HTTP entrance used beside the runner (the same request answered once, the same job under another request a conflict, three mints at once of which the third is refused at `budget` before anything is sent), and an evidence file with every record, the cases' outcomes and a scan of every transaction the operator key signed. `foga doctor --loop-d` judges it beside layer 5: every operation under an allowed decision, refusals that broadcast nothing, the four budget books, the operator's transactions all at the account and allow-listed, the cases as expected, the recovery, the entrances, and — given two runs — the same outcomes from the stub and a real model. The slice also closed a race the run exposed: the catalog's decision section now runs one act at a time per node and a job reserves the value it will move from its decision. `test/loop-d.test.ts` (anvil, under a minute) and `test/model.test.ts` are the gate; CI runs Loop D on the world the genesis ran on. docs/loop-d.md.
25
27
 
@@ -70,11 +72,17 @@ From Phase 1, unchanged in substance:
70
72
  - **The doctor.** `foga doctor` reports five layers per contract — chain and code, interfaces, version match, configuration and permissions, semantic validation — with the verdicts Pass / Fail / Unknown / Not tested / Not applicable. Layers 1–4 probe the chain; layer 5 reads the genesis evidence. A doctor run can be exported as a JSON-RPC probe plan and replayed from results collected elsewhere, so a report never depends on which machine can reach which RPC.
71
73
  - **Sepolia address book.** `worlds/sepolia.json` records every upstream reference deployment at its deployed revision, including the three seams the inventory found (docs/version-lock.md); the completed world and the doctor's report on it are the 2c files above.
72
74
 
73
- What is next: 3d closes Phase 3 — A2A, the documents, `foga quickstart`, the npm and registry publication, the OG instance's move to a revision-2 account with public endpoints and a real model's Loop D run on Sepolia; Phase 4 the hosted node and payments. The design is in docs/design-memo.md.
75
+ What is next: the rest of 3d closes Phase 3 — the readiness check, the documents on chain, A2A, Sourcify, the OG instance's move to a revision-2 account with public endpoints, the registry listing and an outside agent's Loop D run on Sepolia; Phase 4 the hosted node and payments. The design is in docs/design-memo.md.
74
76
 
75
77
  ## Quick start
76
78
 
77
- Three ways in, from most to least demanding:
79
+ With Node 22.13+ and Foundry's `anvil` installed, one command births a cell on a local chain and prints the MCP client configuration to connect to it (docs/quickstart.md; in PowerShell quote the name: `npx -y '@finchip.ai/foga' quickstart`):
80
+
81
+ ```bash
82
+ npx -y @finchip.ai/foga quickstart
83
+ ```
84
+
85
+ From a checkout, three ways in, from most to least demanding:
78
86
 
79
87
  ```bash
80
88
  # 1. Full reproduction (needs git, Node >= 22 and Foundry):
@@ -197,9 +205,10 @@ foga node serve --config <cell.config.json> [--listen <host:port>] [--no-watch]
197
205
  foga node key new --config <cell.config.json> --id <id> [--level read|build|act] [--visibility public|caller|operator] [--note <text>]
198
206
  foga node run --config <cell.config.json> [--model stub|anthropic] [--listen <host:port> | --no-http] [--interval <s>] [--once | --ticks <n>] [--transcripts <dir>]
199
207
  the embedded node: server, watchers and the runner (operator key alone)
200
- foga demo --loop-d [--model stub|anthropic] [--model-name <name>] [--seed <n>] [--no-recovery] anvil, the world, then Loop D judged by the doctor
201
- foga loop-d run --world <world.json> --rpc <url> [--dir <dir>] [--out <evidence.json>] [--model] [--model-name] [--seed] [--no-recovery]
208
+ foga demo --loop-d [--model stub|anthropic|external] [--model-name <name>] [--seed <n>] [--no-recovery] anvil, the world, then Loop D judged by the doctor
209
+ foga loop-d run --world <world.json> --rpc <url> [--dir <dir>] [--out <evidence.json>] [--model] [--model-name] [--seed] [--no-recovery] [--listen] [--wait <s>] [--idle <s>]
202
210
  foga doctor [--world <world.json>] --loop-d <evidence.json> [--compare <evidence2.json>] [--out <report.json>]
211
+ foga quickstart [--dir <dir>] [--port <n>] [--listen <host:port> | --no-serve] [--name <cell>] [--no-tenders] [--exit] one command to a live cell and its client configuration
203
212
  ```
204
213
 
205
214
  During development the CLI runs from source with `npm run foga -- <args>`; `npm run build` compiles it to `dist/` for the `foga` binary.
package/dist/cli.js CHANGED
@@ -1,13 +1,13 @@
1
1
  #!/usr/bin/env node
2
- import { spawn } from "node:child_process";
3
- import { closeSync, mkdirSync, openSync, readFileSync, writeFileSync } from "node:fs";
4
- import { createConnection } from "node:net";
2
+ import { mkdirSync, writeFileSync } from "node:fs";
5
3
  import { cpus, homedir, loadavg } from "node:os";
6
4
  import { dirname, join, relative, resolve } from "node:path";
7
5
  import { buildBundle } from "./build/bundle.js";
8
6
  import { syncVendor, verifyVendor } from "./build/vendor.js";
7
+ import { anvilBinary, launchAnvil, portProblem } from "./demo/anvil.js";
8
+ import { LOOP_D_MODELS } from "./demo/loop-d.js";
9
9
  import { runDoctor } from "./doctor/doctor.js";
10
- import { ensureDir, exists, readJson, repoRoot, writeJson } from "./util/fs.js";
10
+ import { exists, readJson, repoRoot, writeJson } from "./util/fs.js";
11
11
  import { fail, log } from "./util/log.js";
12
12
  import { attachWorld, deployWorld, loadWorldConfig, missingPlanSteps } from "./world/deploy.js";
13
13
  import { loadBundle } from "./bundle/load.js";
@@ -38,12 +38,17 @@ usage:
38
38
  [--zk auto|require|skip] [--crash-after <opKey>] [--crash-before <opKey>]
39
39
  run the genesis scenario (idempotent; re-run to resume)
40
40
  foga genesis status --world <world.json> [--store <file.sqlite>] list the operations the store holds
41
+ foga quickstart [--dir <dir>] [--port <n>] [--listen <host:port> | --no-serve] [--name <cell>] [--no-tenders] [--exit]
42
+ one command to a live cell: anvil, the local world, a cell born with fresh keys and its two skills, two tenders,
43
+ the node serving HTTP and MCP, and the MCP client configurations printed with every path filled in (~/.foga/quickstart)
41
44
  foga demo [--port <n>] [--keep] [--genesis <T0..T9>] start anvil, deploy the local world, run the doctor (and the genesis)
42
- foga demo --loop-d [--model stub|anthropic] [--model-name <name>] [--seed <n>] [--no-recovery] [--port <n>] [--keep]
43
- the above, then Loop D: a new cell born, a demander minting cases a-g, the embedded node with a model,
44
- the recovery drill, the guardian's suspension; evidence to deployments/loop-d.local.json, judged by the doctor
45
- foga loop-d run --world <world.json> --rpc <url> [--dir <dir>] [--out <evidence.json>] [--model stub|anthropic] [--model-name <name>] [--seed <n>] [--no-recovery]
46
- Loop D on a world that exists (FOGA_FUNDER_KEY funds the cell's roles on a non-local chain)
45
+ foga demo --loop-d [--model stub|anthropic|external] [--model-name <name>] [--seed <n>] [--no-recovery] [--port <n>] [--keep]
46
+ the above, then Loop D: a new cell born, a demander minting cases a-g, the node with a model (or served for an
47
+ outside agent), the recovery drill, the guardian's suspension; evidence to deployments/loop-d.local.json, judged by the doctor
48
+ foga loop-d run --world <world.json> --rpc <url> [--dir <dir>] [--out <evidence.json>] [--model stub|anthropic|external] [--model-name <name>] [--seed <n>] [--no-recovery]
49
+ [--listen <host:port>] [--wait <s>] [--idle <s>]
50
+ Loop D on a world that exists (FOGA_FUNDER_KEY funds the cell's roles on a non-local chain); with --model external the
51
+ node is served and an outside agent — any model, any runtime — works the tenders through /mcp or /api with the key printed
47
52
  foga demo demander --world <w> --rpc <url> --store <sqlite> --cell <addr> --scheme <id> --issuer <addr> --probe-hash <h> --extract-hash <h> --cases a,b,c --seed <n> --out <json>
48
53
  the independent demander of Loop D (FOGA_DEMANDER_KEY; anvil's first account by default)
49
54
  foga bundle info summarize the committed bundle
@@ -350,33 +355,54 @@ async function main() {
350
355
  const { runLoopD, defaultLoopDDir } = await import("./demo/loop-d.js");
351
356
  const worldPath = str(flags, "world") ?? fail("--world <world.json> is required");
352
357
  const rpc = str(flags, "rpc") ?? process.env.FOGA_RPC_URL ?? fail("--rpc <url> is required");
353
- const model = (str(flags, "model") ?? "stub");
354
- if (model !== "stub" && model !== "anthropic")
355
- fail("--model must be stub or anthropic");
356
- await runLoopD({ rpc, worldPath: resolve(worldPath), dir: str(flags, "dir") ? resolve(str(flags, "dir")) : defaultLoopDDir(), evidencePath: resolve(str(flags, "out") ?? join(root, "deployments", "loop-d.local.json")), model, modelName: str(flags, "model-name") ?? process.env.FOGA_MODEL, seed: str(flags, "seed") ? Number(str(flags, "seed")) : undefined, recovery: !flags["no-recovery"], funderKey: process.env.FOGA_FUNDER_KEY });
358
+ await runLoopD({ rpc, worldPath: resolve(worldPath), dir: str(flags, "dir") ? resolve(str(flags, "dir")) : defaultLoopDDir(), evidencePath: resolve(str(flags, "out") ?? join(root, "deployments", "loop-d.local.json")), funderKey: process.env.FOGA_FUNDER_KEY, ...loopDFlags(flags) });
357
359
  return;
358
360
  }
361
+ if (group === "quickstart") {
362
+ const { runQuickstart } = await import("./demo/quickstart.js");
363
+ let result;
364
+ try {
365
+ result = await runQuickstart({ dir: str(flags, "dir"), port: str(flags, "port") ? Number(str(flags, "port")) : undefined, listen: flags["no-serve"] ? null : str(flags, "listen"), name: str(flags, "name"), tenders: !flags["no-tenders"], exit: Boolean(flags.exit) });
366
+ }
367
+ catch (e) {
368
+ fail(e.message);
369
+ }
370
+ if (flags.exit) {
371
+ await result.stop();
372
+ return;
373
+ }
374
+ const stopping = () => {
375
+ console.error("quickstart: stopping the node and the chain");
376
+ result.stop().finally(() => process.exit(0));
377
+ };
378
+ process.on("SIGINT", stopping);
379
+ process.on("SIGTERM", stopping);
380
+ const why = await result.ended;
381
+ fail(`${why}; the quickstart is over`);
382
+ }
359
383
  if (group === "demo") {
360
- const model = (str(flags, "model") ?? "stub");
361
- if (model !== "stub" && model !== "anthropic")
362
- fail("--model must be stub or anthropic");
363
- await demo({ port: Number(str(flags, "port") ?? 8545), keep: Boolean(flags.keep), genesis: str(flags, "genesis"), loopD: flags["loop-d"] ? { model, modelName: str(flags, "model-name") ?? process.env.FOGA_MODEL, seed: str(flags, "seed") ? Number(str(flags, "seed")) : undefined, recovery: !flags["no-recovery"] } : undefined });
384
+ await demo({ port: Number(str(flags, "port") ?? 8545), keep: Boolean(flags.keep), genesis: str(flags, "genesis"), loopD: flags["loop-d"] ? loopDFlags(flags) : undefined });
364
385
  return;
365
386
  }
366
387
  console.log(USAGE);
367
388
  fail(`unknown command: ${positional.join(" ")}`);
368
389
  }
390
+ /** The Loop D options the flags name (shared by `foga demo --loop-d` and `foga loop-d run`). */
391
+ function loopDFlags(flags) {
392
+ const model = (str(flags, "model") ?? "stub");
393
+ if (!LOOP_D_MODELS.includes(model))
394
+ fail(`--model must be one of ${LOOP_D_MODELS.join(", ")}`);
395
+ const seconds = (key) => (str(flags, key) ? Number(str(flags, key)) * 1000 : undefined);
396
+ return { model, modelName: str(flags, "model-name") ?? process.env.FOGA_MODEL, seed: str(flags, "seed") ? Number(str(flags, "seed")) : undefined, recovery: !flags["no-recovery"], listen: str(flags, "listen"), waitMs: seconds("wait"), idleMs: seconds("idle") };
397
+ }
369
398
  async function demo(opts) {
370
399
  const root = repoRoot();
371
400
  const rpc = `http://127.0.0.1:${opts.port}`;
372
401
  // Say what is wrong before starting anything: a port that is already taken makes anvil exit at once, and a
373
402
  // silent exit would otherwise surface only as "the RPC did not come up".
374
- if (await portInUse(opts.port)) {
375
- const answers = await rpcAnswers(rpc);
376
- fail(answers
377
- ? `${rpc} already answers JSON-RPC (another anvil or node is running). Stop it, or pass --port <n> to use another port.`
378
- : `port ${opts.port} is in use by something that does not speak JSON-RPC. Free it, or pass --port <n>.`);
379
- }
403
+ const problem = await portProblem(opts.port);
404
+ if (problem)
405
+ fail(problem);
380
406
  // A machine that is already saturated (a proving-key build, for instance) starves anvil: blocks come late and the
381
407
  // kernel gives up on a receipt after its timeout. Say so before starting rather than after a failure.
382
408
  const cores = cpus().length || 1;
@@ -385,51 +411,27 @@ async function demo(opts) {
385
411
  log.warn(`this machine is busy (load ${load.toFixed(1)} on ${cores} core${cores === 1 ? "" : "s"}): another heavy process slows anvil down and can make the genesis time out waiting for receipts. If a proving-key build (npm run zk:predicate) is running, let it finish or stop it first.`);
386
412
  }
387
413
  // FOGA_FOUNDRY_DIR points at a Foundry install of its own (scripts/install-foundry.sh puts the static build there).
388
- const foundryDir = process.env.FOGA_FOUNDRY_DIR;
389
- const anvilBin = foundryDir ? join(foundryDir, "anvil") : "anvil";
390
414
  // anvil's output goes to a log file rather than a pipe: with --keep the node survives this process, and a
391
415
  // Rust program whose stderr pipe has gone away dies on its next write.
416
+ const { path: anvilBin, fromDir } = anvilBinary();
392
417
  const logPath = join(root, ".foga-build", "anvil.log");
393
- ensureDir(join(root, ".foga-build"));
394
- const logFd = openSync(logPath, "w");
395
- log.info(`starting anvil on ${rpc}${foundryDir ? ` (${anvilBin})` : ""}; log ${relative(root, logPath)}`);
396
- const anvil = spawn(anvilBin, ["--port", String(opts.port), "--silent"], { stdio: ["ignore", logFd, logFd], detached: opts.keep });
397
- closeSync(logFd);
398
- let exited = null;
399
- anvil.on("exit", (code, signal) => {
400
- exited = code !== null ? `exit code ${code}` : `signal ${signal}`;
401
- });
418
+ log.info(`starting anvil on ${rpc}${fromDir ? ` (${anvilBin})` : ""}; log ${relative(root, logPath)}`);
419
+ let anvil;
420
+ try {
421
+ anvil = await launchAnvil({ port: opts.port, logPath, detached: opts.keep });
422
+ }
423
+ catch (e) {
424
+ fail(e.message);
425
+ }
402
426
  const stop = () => {
403
427
  if (!opts.keep && !anvil.killed)
404
428
  anvil.kill("SIGTERM");
405
429
  };
406
- anvil.on("error", (e) => fail(e.code === "ENOENT"
407
- ? foundryDir
408
- ? `${anvilBin} not found (FOGA_FOUNDRY_DIR=${foundryDir}). Run scripts/install-foundry.sh ${foundryDir} first.`
409
- : "anvil not found on PATH. Install Foundry (https://getfoundry.sh: `curl -L https://foundry.sh | bash && foundryup`), or run scripts/install-foundry.sh and export FOGA_FOUNDRY_DIR, or use docker compose."
410
- : `could not start anvil: ${e.message}`));
411
430
  process.on("exit", stop);
412
431
  process.on("SIGINT", () => {
413
432
  stop();
414
433
  process.exit(130);
415
434
  });
416
- try {
417
- await waitForRpc(rpc, 20000, () => exited !== null);
418
- }
419
- catch (e) {
420
- let output = "";
421
- try {
422
- output = readFileSync(logPath, "utf8").slice(-8000);
423
- }
424
- catch {
425
- /* no log */
426
- }
427
- const why = exited ? `anvil stopped (${exited}) before serving ${rpc}` : `anvil did not serve ${rpc} within 20 s`;
428
- const glibc = /GLIBC_[0-9.]+' not found/.test(output)
429
- ? "\nThis anvil is the glibc build and this machine's glibc is older than it needs (GLIBC >= 2.35: Ubuntu 22.04 or newer). Install the static build next to it: `scripts/install-foundry.sh` then `export FOGA_FOUNDRY_DIR=~/.foga/foundry` (or put that directory first on PATH)."
430
- : "";
431
- fail(`${why}.${output.trim() ? `\n--- anvil output ---\n${output.trim()}\n--------------------` : ""}${glibc}\nRun \`${anvilBin} --port ${opts.port}\` by hand to see its output; \`${anvilBin} --version\` shows the installed version (CI uses Foundry v1.7.1).`);
432
- }
433
435
  const worldConfig = join(root, "worlds", "local.json");
434
436
  const out = join(root, "deployments", "local.json");
435
437
  await deployWorld({ worldConfigPath: worldConfig, rpc, outPath: out });
@@ -454,7 +456,7 @@ async function demo(opts) {
454
456
  if (opts.loopD) {
455
457
  const { runLoopD, defaultLoopDDir } = await import("./demo/loop-d.js");
456
458
  const evidencePath = join(root, "deployments", "loop-d.local.json");
457
- await runLoopD({ rpc, worldPath: out, dir: defaultLoopDDir(), evidencePath, model: opts.loopD.model, modelName: opts.loopD.modelName, seed: opts.loopD.seed, recovery: opts.loopD.recovery });
459
+ await runLoopD({ rpc, worldPath: out, dir: defaultLoopDDir(), evidencePath, ...opts.loopD });
458
460
  const judged = await runDoctor({ worldPath: out, rpc, outPath: join(root, "deployments", "local.doctor.json"), genesisPath: opts.genesis ? join(root, "deployments", "local.genesis.json") : undefined, loopDPath: evidencePath, quiet: true });
459
461
  if (judged.report && judged.report.summary.Fail > 0)
460
462
  process.exitCode = 3;
@@ -467,39 +469,6 @@ async function demo(opts) {
467
469
  else
468
470
  stop();
469
471
  }
470
- /** True when something accepts TCP connections on 127.0.0.1:port. */
471
- function portInUse(port) {
472
- return new Promise((resolve) => {
473
- const socket = createConnection({ host: "127.0.0.1", port });
474
- const done = (v) => {
475
- socket.destroy();
476
- resolve(v);
477
- };
478
- socket.setTimeout(1000, () => done(false));
479
- socket.once("connect", () => done(true));
480
- socket.once("error", () => done(false));
481
- });
482
- }
483
- async function rpcAnswers(rpc) {
484
- try {
485
- const res = await fetch(rpc, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "eth_chainId", params: [] }), signal: AbortSignal.timeout(2000) });
486
- return res.ok;
487
- }
488
- catch {
489
- return false;
490
- }
491
- }
492
- async function waitForRpc(rpc, timeoutMs, giveUp) {
493
- const start = Date.now();
494
- while (Date.now() - start < timeoutMs) {
495
- if (await rpcAnswers(rpc))
496
- return;
497
- if (giveUp?.())
498
- break;
499
- await new Promise((r) => setTimeout(r, 250));
500
- }
501
- throw new Error(`RPC at ${rpc} did not come up within ${timeoutMs} ms`);
502
- }
503
472
  main().catch((e) => {
504
473
  fail(e?.stack ?? String(e));
505
474
  });
@@ -0,0 +1,129 @@
1
+ import { execFileSync, spawn } from "node:child_process";
2
+ import { closeSync, mkdirSync, openSync, readFileSync } from "node:fs";
3
+ import { createConnection } from "node:net";
4
+ import { dirname, join } from "node:path";
5
+ /**
6
+ * A local chain for the demos (`foga demo`, `foga quickstart`): finding anvil, starting it with its output in a log
7
+ * file, waiting for its RPC, and saying what is wrong in the words a first run needs — the binary missing (with the
8
+ * install path per platform), the port taken, the glibc too old for the dynamic build.
9
+ */
10
+ /** The anvil binary this machine offers: FOGA_FOUNDRY_DIR/anvil (scripts/install-foundry.sh) or anvil on PATH. */
11
+ export function anvilBinary() {
12
+ const dir = process.env.FOGA_FOUNDRY_DIR;
13
+ return { path: dir ? join(dir, "anvil") : "anvil", fromDir: dir ?? null };
14
+ }
15
+ /** anvil's version line, or null when the binary cannot be run from here. */
16
+ export function anvilVersion(path) {
17
+ try {
18
+ return execFileSync(path, ["--version"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim().split("\n")[0];
19
+ }
20
+ catch {
21
+ return null;
22
+ }
23
+ }
24
+ export const ANVIL_INSTALL_HINT = [
25
+ "anvil (Foundry) is not installed, or not where FOGA_FOUNDRY_DIR says.",
26
+ " Linux, macOS: curl -L https://foundry.sh | bash && foundryup (an older glibc: scripts/install-foundry.sh, then export FOGA_FOUNDRY_DIR=~/.foga/foundry)",
27
+ " Windows: download the win32_amd64 zip of a release from https://github.com/foundry-rs/foundry/releases, unzip it, and put the folder",
28
+ " holding anvil.exe on PATH (or set FOGA_FOUNDRY_DIR to that folder); then open a new terminal",
29
+ " any platform: docker compose (docker/) runs the toolchain with anvil inside",
30
+ ].join("\n");
31
+ /** True when something accepts TCP connections on 127.0.0.1:port. */
32
+ export function portInUse(port) {
33
+ return new Promise((resolve) => {
34
+ const socket = createConnection({ host: "127.0.0.1", port });
35
+ const done = (v) => {
36
+ socket.destroy();
37
+ resolve(v);
38
+ };
39
+ socket.setTimeout(1000, () => done(false));
40
+ socket.once("connect", () => done(true));
41
+ socket.once("error", () => done(false));
42
+ });
43
+ }
44
+ export async function rpcAnswers(rpc) {
45
+ try {
46
+ const res = await fetch(rpc, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "eth_chainId", params: [] }), signal: AbortSignal.timeout(2000) });
47
+ return res.ok;
48
+ }
49
+ catch {
50
+ return false;
51
+ }
52
+ }
53
+ export async function waitForRpc(rpc, timeoutMs, giveUp) {
54
+ const start = Date.now();
55
+ while (Date.now() - start < timeoutMs) {
56
+ if (await rpcAnswers(rpc))
57
+ return;
58
+ if (giveUp?.())
59
+ break;
60
+ await new Promise((r) => setTimeout(r, 250));
61
+ }
62
+ throw new Error(`RPC at ${rpc} did not come up within ${timeoutMs} ms`);
63
+ }
64
+ /** Why a port cannot be used for a new anvil: another RPC already answers there, or something else holds it. */
65
+ export async function portProblem(port) {
66
+ if (!(await portInUse(port)))
67
+ return null;
68
+ const rpc = `http://127.0.0.1:${port}`;
69
+ return (await rpcAnswers(rpc))
70
+ ? `${rpc} already answers JSON-RPC (another anvil or node is running). Stop it, or pass --port <n> to use another port.`
71
+ : `port ${port} is in use by something that does not speak JSON-RPC. Free it, or pass --port <n>.`;
72
+ }
73
+ /**
74
+ * Start anvil and wait until its RPC answers. Rejects with a message fit to print when the binary is missing, when
75
+ * anvil stops before serving (its output quoted, the glibc case named) or when it does not come up in time.
76
+ */
77
+ export async function launchAnvil(o) {
78
+ const { path: bin, fromDir } = anvilBinary();
79
+ const rpc = `http://127.0.0.1:${o.port}`;
80
+ mkdirSync(dirname(o.logPath), { recursive: true });
81
+ const logFd = openSync(o.logPath, "w");
82
+ const child = spawn(bin, ["--port", String(o.port), "--silent", ...(o.args ?? [])], { stdio: ["ignore", logFd, logFd], detached: Boolean(o.detached) });
83
+ closeSync(logFd);
84
+ let exited = null;
85
+ child.on("exit", (code, signal) => {
86
+ exited = code !== null ? `exit code ${code}` : `signal ${signal}`;
87
+ });
88
+ const spawnError = new Promise((_, reject) => {
89
+ child.once("error", (e) => {
90
+ reject(new Error(e.code === "ENOENT" ? (fromDir ? `${bin} not found (FOGA_FOUNDRY_DIR=${fromDir}). Run scripts/install-foundry.sh ${fromDir} first, or unset FOGA_FOUNDRY_DIR to use anvil on PATH.\n${ANVIL_INSTALL_HINT}` : `anvil not found on PATH.\n${ANVIL_INSTALL_HINT}`) : `could not start anvil: ${e.message}`));
91
+ });
92
+ });
93
+ const timeoutMs = o.timeoutMs ?? 20_000;
94
+ try {
95
+ await Promise.race([waitForRpc(rpc, timeoutMs, () => exited !== null), spawnError]);
96
+ }
97
+ catch (e) {
98
+ if (!child.killed && child.exitCode === null)
99
+ child.kill("SIGTERM");
100
+ if (e.message?.startsWith("RPC at ")) {
101
+ let output = "";
102
+ try {
103
+ output = readFileSync(o.logPath, "utf8").slice(-8000);
104
+ }
105
+ catch {
106
+ /* no log */
107
+ }
108
+ const why = exited ? `anvil stopped (${exited}) before serving ${rpc}` : `anvil did not serve ${rpc} within ${timeoutMs / 1000} s`;
109
+ const glibc = /GLIBC_[0-9.]+' not found/.test(output)
110
+ ? "\nThis anvil is the glibc build and this machine's glibc is older than it needs (GLIBC >= 2.35: Ubuntu 22.04 or newer). Install the static build next to it: `scripts/install-foundry.sh` then `export FOGA_FOUNDRY_DIR=~/.foga/foundry` (or put that directory first on PATH)."
111
+ : "";
112
+ throw new Error(`${why}.${output.trim() ? `\n--- anvil output ---\n${output.trim()}\n--------------------` : ""}${glibc}\nRun \`${bin} --port ${o.port}\` by hand to see its output; \`${bin} --version\` shows the installed version (CI uses Foundry v1.7.1).`);
113
+ }
114
+ throw e;
115
+ }
116
+ return child;
117
+ }
118
+ /** Stop a child and wait for it to be gone (SIGTERM, then SIGKILL after a grace period). */
119
+ export async function stopChild(child, graceMs = 5000) {
120
+ if (child.exitCode !== null || child.signalCode !== null)
121
+ return;
122
+ const gone = new Promise((r) => child.once("exit", () => r()));
123
+ child.kill("SIGTERM");
124
+ await Promise.race([gone, new Promise((r) => setTimeout(r, graceMs))]);
125
+ if (child.exitCode === null && child.signalCode === null) {
126
+ child.kill("SIGKILL");
127
+ await Promise.race([gone, new Promise((r) => setTimeout(r, 2000))]);
128
+ }
129
+ }
@@ -0,0 +1,71 @@
1
+ import { join } from "node:path";
2
+ import { Wallet, getAddress, parseEther } from "ethers";
3
+ import { dataUriJcs, keccakJcs } from "../core/canonical.js";
4
+ import { accountSubject, fogaAttestedSchemeDescriptor } from "../core/kya.js";
5
+ import { loadCellConfig, newCellConfig, saveCellConfig } from "../node/config.js";
6
+ import { cellInit } from "../node/init.js";
7
+ import { Node } from "../node/node.js";
8
+ import { defaultCatalog, OPERATOR_CALLER } from "../tools/index.js";
9
+ import { repoRoot } from "../util/fs.js";
10
+ export async function birthCell(o) {
11
+ const k = o.kernel;
12
+ const log = o.log ?? (() => { });
13
+ const root = repoRoot();
14
+ const W = k.address("W");
15
+ const owner = Wallet.createRandom();
16
+ const operator = Wallet.createRandom();
17
+ const guardian = Wallet.createRandom();
18
+ log(`cell ${o.name}: owner ${owner.address}, operator ${operator.address}, guardian ${guardian.address}`);
19
+ // --- funding by W, the standing scheme registered by W
20
+ const price = (await k.world.provider.getFeeData()).gasPrice ?? 1000000000n;
21
+ const gas = (units) => units * price * 2n;
22
+ await k.transfer("fund.owner", "W", owner.address, parseEther("0.05") + gas(12000000n));
23
+ await k.transfer("fund.operator", "W", operator.address, gas(20000000n) + parseEther("0.003"));
24
+ await k.transfer("fund.guardian", "W", guardian.address, gas(1000000n));
25
+ const att = await k.call("scheme", "W", k.kya.schemeRegistryAddress, k.kya.schemes().interface, "registerScheme", [k.kya.attestedSchemeInput(fogaAttestedSchemeDescriptor())]);
26
+ const schemeId = k.kya.schemeIdFromReceipt(att.receipt);
27
+ const bornAtBlock = await k.blockNumber();
28
+ // --- the configuration and the birth (the owner's act, in this process, as `foga cell init` would)
29
+ const cfg = newCellConfig({ name: o.name, world: o.worldPath, store: o.storePath, owner: owner.address, operator: operator.address, guardian: guardian.address, rpc: o.rpc, skillsDir: join(root, "scenarios", "skills"), inputDir: join(root, "scenarios", "fixtures"), description: o.description });
30
+ cfg.identity.standing = { schemeId, issuers: [W] };
31
+ o.configure?.(cfg);
32
+ saveCellConfig(o.configPath, cfg);
33
+ const birthNode = await Node.open({ configPath: o.configPath, rpc: o.rpc, keys: { owner: owner.privateKey }, allowUnborn: true, log: () => { } });
34
+ const born = await cellInit(birthNode, { log: () => { } });
35
+ birthNode.close();
36
+ const account = getAddress(born.account);
37
+ log(`cell ${o.name}: born as ${account} (agent ${born.agentId}) in ${born.operations.length} operations`);
38
+ // --- standing from the world operator, the two skills minted by the operator
39
+ const claim = { scheme: "S_att", level: 2, subject: `eip155:${k.chainId}:${account.toLowerCase()}` };
40
+ const expiresAt = (await k.blockTimestamp()) + 365 * 86400;
41
+ await k.call("attest", "W", k.kya.registryAddress, k.kya.registry().interface, "attest", [accountSubject(k.chainId, account), schemeId, 2, keccakJcs(claim), expiresAt, dataUriJcs(claim), keccakJcs(claim)]);
42
+ const catalog = defaultCatalog();
43
+ const skillNode = await Node.open({ configPath: o.configPath, rpc: o.rpc, keys: { operator: operator.privateKey }, log: () => { } });
44
+ try {
45
+ for (const skill of ["foga-probe", "finch-extract-verify"]) {
46
+ const r = await catalog.invoke(skillNode, "skill_mint", { skill }, OPERATOR_CALLER, { mode: "act" });
47
+ if (!r.ok)
48
+ throw new Error(`skill_mint ${skill}: ${r.gate}: ${r.reason}`);
49
+ }
50
+ }
51
+ finally {
52
+ skillNode.close();
53
+ }
54
+ const held = loadCellConfig(o.configPath).config.skills.held;
55
+ log(`cell ${o.name}: standing level 2 attested by ${W}; skills minted (${Object.keys(held).join(", ")})`);
56
+ return {
57
+ owner,
58
+ operator,
59
+ guardian,
60
+ account,
61
+ agentId: born.agentId,
62
+ specId: born.specId ?? null,
63
+ schemeId,
64
+ bornAtBlock,
65
+ birthOperations: born.operations.length,
66
+ held,
67
+ packages: { "foga-probe": held["foga-probe"].packageHash, "finch-extract-verify": held["finch-extract-verify"].packageHash },
68
+ configPath: o.configPath,
69
+ storePath: o.storePath,
70
+ };
71
+ }