@finchip.ai/foga 0.0.0-stage → 0.6.0-alpha.4
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/LICENSE +201 -0
- package/NOTICE +21 -0
- package/README.md +216 -2
- package/artifacts/bundle.json +74250 -0
- package/artifacts/bundle.sha256 +1 -0
- package/bin/foga.js +14 -0
- package/build/known-interface-ids.json +18 -0
- package/build/profiles.json +120 -0
- package/dist/build/bundle.js +417 -0
- package/dist/build/vendor.js +74 -0
- package/dist/bundle/load.js +24 -0
- package/dist/bundle/types.js +1 -0
- package/dist/cli-cell.js +395 -0
- package/dist/cli.js +505 -0
- package/dist/core/acdf.js +162 -0
- package/dist/core/adrf.js +273 -0
- package/dist/core/aid.js +83 -0
- package/dist/core/ard.js +146 -0
- package/dist/core/asf.js +157 -0
- package/dist/core/canonical.js +79 -0
- package/dist/core/erc8004.js +52 -0
- package/dist/core/kya.js +225 -0
- package/dist/core/measure.js +96 -0
- package/dist/core/pack/cbor.js +130 -0
- package/dist/core/pack/skillpack.js +142 -0
- package/dist/core/skills.js +40 -0
- package/dist/core/swarm.js +206 -0
- package/dist/core/tenders.js +121 -0
- package/dist/core/units.js +122 -0
- package/dist/core/world.js +104 -0
- package/dist/demo/demander.js +76 -0
- package/dist/demo/evidence.js +1 -0
- package/dist/demo/loop-d.js +449 -0
- package/dist/doctor/checks.js +51 -0
- package/dist/doctor/doctor.js +385 -0
- package/dist/doctor/loop-d.js +103 -0
- package/dist/doctor/probe.js +97 -0
- package/dist/doctor/semantic.js +83 -0
- package/dist/genesis/extract.js +64 -0
- package/dist/genesis/funding.js +126 -0
- package/dist/genesis/operator-scope.js +90 -0
- package/dist/genesis/preflight.js +54 -0
- package/dist/genesis/probe.js +164 -0
- package/dist/genesis/roles.js +96 -0
- package/dist/genesis/run.js +71 -0
- package/dist/genesis/scenario.js +765 -0
- package/dist/genesis/skill-runner.js +103 -0
- package/dist/genesis/skill-worker.js +41 -0
- package/dist/genesis/t1-zk.js +62 -0
- package/dist/genesis/t3-zk.js +105 -0
- package/dist/genesis/t4.js +259 -0
- package/dist/genesis/t5.js +168 -0
- package/dist/genesis/t6.js +222 -0
- package/dist/genesis/t7.js +198 -0
- package/dist/genesis/t8.js +189 -0
- package/dist/genesis/t9.js +188 -0
- package/dist/genesis/zk.js +128 -0
- package/dist/http/server.js +276 -0
- package/dist/kernel/cell.js +104 -0
- package/dist/kernel/errors.js +78 -0
- package/dist/kernel/kernel.js +251 -0
- package/dist/kernel/provider.js +125 -0
- package/dist/kernel/store.js +470 -0
- package/dist/kernel/tx.js +385 -0
- package/dist/mcp/server.js +170 -0
- package/dist/node/config.js +195 -0
- package/dist/node/init.js +200 -0
- package/dist/node/keys.js +95 -0
- package/dist/node/migrate.js +173 -0
- package/dist/node/model/anthropic.js +97 -0
- package/dist/node/model/stub.js +129 -0
- package/dist/node/model/types.js +9 -0
- package/dist/node/node.js +198 -0
- package/dist/node/policy.js +95 -0
- package/dist/node/runner.js +256 -0
- package/dist/node/watchers.js +88 -0
- package/dist/tools/admin.js +184 -0
- package/dist/tools/catalog.js +261 -0
- package/dist/tools/common.js +143 -0
- package/dist/tools/identity.js +107 -0
- package/dist/tools/index.js +14 -0
- package/dist/tools/read.js +471 -0
- package/dist/tools/tenders.js +400 -0
- package/dist/tools/types.js +4 -0
- package/dist/util/bytecode.js +123 -0
- package/dist/util/fs.js +58 -0
- package/dist/util/hash.js +4 -0
- package/dist/util/log.js +11 -0
- package/dist/util/version.js +12 -0
- package/dist/world/deploy.js +277 -0
- package/dist/world/plan.js +93 -0
- package/dist/world/types.js +1 -0
- package/docs/build.md +45 -0
- package/docs/cell-account.md +58 -0
- package/docs/compatibility-matrix.md +38 -0
- package/docs/design-memo.md +113 -0
- package/docs/doctor.md +53 -0
- package/docs/examples/README.md +11 -0
- package/docs/examples/claude-code-mcp.json +13 -0
- package/docs/examples/claude_desktop_config.json +12 -0
- package/docs/examples/cursor-mcp.json +11 -0
- package/docs/genesis.md +312 -0
- package/docs/kernel.md +101 -0
- package/docs/loop-d.md +90 -0
- package/docs/mcp.md +105 -0
- package/docs/node.md +66 -0
- package/docs/version-lock.md +67 -0
- package/docs/world.md +50 -0
- package/docs/zk.md +48 -0
- package/package.json +82 -4
- package/prover/README.md +26 -0
- package/prover/build-predicate.mjs +77 -0
- package/prover/package-lock.json +1837 -0
- package/prover/package.json +15 -0
- package/prover/run.cjs +199 -0
- package/scenarios/fixtures/sample-filing.txt +16 -0
- package/scenarios/skills/finch-extract-verify/LICENSE +2 -0
- package/scenarios/skills/finch-extract-verify/SKILL.md +34 -0
- package/scenarios/skills/finch-extract-verify/fields.json +56 -0
- package/scenarios/skills/finch-extract-verify/manifest.json +16 -0
- package/scenarios/skills/foga-probe/LICENSE +2 -0
- package/scenarios/skills/foga-probe/SKILL.md +35 -0
- package/scenarios/skills/foga-probe/manifest.json +16 -0
- package/scenarios/skills/foga-probe/probe.schema.json +40 -0
- package/server.json +79 -0
- package/vendor/LOCK.json +107 -0
- package/vendor/README.md +13 -0
- package/worlds/local.json +34 -0
- package/worlds/sepolia.json +314 -0
- package/zk/build/verification_key.json +174 -0
- package/zk/circuits/kya_predicate_count_v1.circom +231 -0
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, resolve } from "node:path";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { ensureDir } from "../util/fs.js";
|
|
5
|
+
/**
|
|
6
|
+
* `cell.config.json` (schema `foga.cell-config/1`): the one file a node maintains (design memo, Phase 3, section 7).
|
|
7
|
+
* Six blocks: identity (the world, the account and its roles, what `foga cell init` writes back), presence (how the
|
|
8
|
+
* cell describes itself to the world), skills (what it holds and offers), policy (the business authorization the
|
|
9
|
+
* policy engine applies to every act), runtime (how the node runs, the HTTP server among it) and access (the API
|
|
10
|
+
* keys the HTTP entrance accepts, as their hashes). Amounts in wei are decimal strings so the file stays plain JSON.
|
|
11
|
+
*/
|
|
12
|
+
const address = z.string().regex(/^0x[0-9a-fA-F]{40}$/, "an address (0x + 40 hex digits)");
|
|
13
|
+
const bytes32 = z.string().regex(/^0x[0-9a-fA-F]{64}$/, "a 32-byte hex value");
|
|
14
|
+
const wei = z.string().regex(/^\d+$/, "a decimal amount in wei");
|
|
15
|
+
const uint = z.string().regex(/^\d+$/, "a decimal integer");
|
|
16
|
+
export const SafetySchema = z.object({
|
|
17
|
+
/** The ASF specification the cell adopts; `foga cell init` publishes FOGA-Cell-v1 and fills this in when absent. */
|
|
18
|
+
specId: bytes32.optional(),
|
|
19
|
+
version: z.number().int().positive().default(1),
|
|
20
|
+
contentHash: bytes32.optional(),
|
|
21
|
+
});
|
|
22
|
+
export const StandingSchema = z.object({
|
|
23
|
+
/** The attested KYA scheme and issuers the cell cites for its own standing and requires of counterparties. */
|
|
24
|
+
schemeId: bytes32,
|
|
25
|
+
issuers: z.array(address).min(1),
|
|
26
|
+
});
|
|
27
|
+
export const IdentitySchema = z.object({
|
|
28
|
+
account: address.optional(),
|
|
29
|
+
agentId: uint.optional(),
|
|
30
|
+
owner: address,
|
|
31
|
+
operator: address,
|
|
32
|
+
guardian: address,
|
|
33
|
+
recovery: address.optional(),
|
|
34
|
+
bindingId: bytes32.optional(),
|
|
35
|
+
recordId: bytes32.optional(),
|
|
36
|
+
unitId: uint.optional(),
|
|
37
|
+
documentDigest: bytes32.optional(),
|
|
38
|
+
safety: SafetySchema.prefault({}),
|
|
39
|
+
standing: StandingSchema.optional(),
|
|
40
|
+
/** The ERC-8434 liveness window the account keeps (seconds); the registry's default when absent. */
|
|
41
|
+
livenessWindow: z.number().int().positive().optional(),
|
|
42
|
+
});
|
|
43
|
+
export const PresenceSchema = z.object({
|
|
44
|
+
name: z.string().min(1).max(120),
|
|
45
|
+
description: z.string().max(2000).default(""),
|
|
46
|
+
image: z.string().url().optional(),
|
|
47
|
+
homepage: z.string().url().optional(),
|
|
48
|
+
endpoints: z
|
|
49
|
+
.object({
|
|
50
|
+
mcp: z.string().url().optional(),
|
|
51
|
+
a2a: z.string().url().optional(),
|
|
52
|
+
web: z.string().url().optional(),
|
|
53
|
+
})
|
|
54
|
+
.prefault({}),
|
|
55
|
+
});
|
|
56
|
+
export const HeldSkillSchema = z.object({
|
|
57
|
+
tokenId: uint,
|
|
58
|
+
recordId: bytes32.optional(),
|
|
59
|
+
unitId: uint.optional(),
|
|
60
|
+
packageHash: bytes32.optional(),
|
|
61
|
+
});
|
|
62
|
+
export const SkillsSchema = z.object({
|
|
63
|
+
/** Directory holding the skill packages (one subdirectory per skill name). */
|
|
64
|
+
dir: z.string().default("scenarios/skills"),
|
|
65
|
+
/** The only directory a skill run may read documents from (a task's document is written here when it arrives inline). */
|
|
66
|
+
inputDir: z.string().default("scenarios/fixtures"),
|
|
67
|
+
/** Skills the cell holds on chain, by name (written by `skill_mint`). */
|
|
68
|
+
held: z.record(z.string(), HeldSkillSchema).default({}),
|
|
69
|
+
/** Skills the cell offers to run for others; a tender for a skill not listed here is refused (gate: skill). */
|
|
70
|
+
offered: z.array(z.string()).default([]),
|
|
71
|
+
});
|
|
72
|
+
export const BudgetSchema = z.object({
|
|
73
|
+
/** Native value the cell may move out per day (settlements paid in count as income, not spend). */
|
|
74
|
+
dailyValueWei: wei.default("2000000000000000"),
|
|
75
|
+
/** The most one operation may move. */
|
|
76
|
+
perOperationValueWei: wei.default("1000000000000000"),
|
|
77
|
+
/** Fees the operator key may pay per day (gas), replacements included. */
|
|
78
|
+
dailyGasWei: wei.default("5000000000000000"),
|
|
79
|
+
/** Tenders taken and not yet settled at the same time. */
|
|
80
|
+
maxConcurrentCommitments: z.number().int().min(1).default(2),
|
|
81
|
+
});
|
|
82
|
+
export const TenderPolicySchema = z.object({
|
|
83
|
+
minRewardWei: wei.default("100000000000000"),
|
|
84
|
+
/** A tender whose submitBy is closer than this is refused (gate: policy). */
|
|
85
|
+
minSubmitWindowSeconds: z.number().int().min(0).default(600),
|
|
86
|
+
minSettleWindowSeconds: z.number().int().min(0).default(600),
|
|
87
|
+
/** Acceptance authorities the cell works under: "hashlock" (the world's HashlockVerifier) or an address. */
|
|
88
|
+
acceptance: z.array(z.string()).default(["hashlock"]),
|
|
89
|
+
/** What the cell requires of a demander: a KYA level under the standing scheme, 0 = nothing. */
|
|
90
|
+
counterpartyMinLevel: z.number().int().min(0).default(0),
|
|
91
|
+
/** How far back tender discovery scans (blocks). */
|
|
92
|
+
lookbackBlocks: z.number().int().min(1).default(9000),
|
|
93
|
+
});
|
|
94
|
+
export const PolicySchema = z.object({
|
|
95
|
+
budget: BudgetSchema.prefault({}),
|
|
96
|
+
tenders: TenderPolicySchema.prefault({}),
|
|
97
|
+
});
|
|
98
|
+
export const HttpSchema = z.object({
|
|
99
|
+
/** host:port the HTTP server binds (loopback by default; a reverse proxy terminates TLS in front of it). */
|
|
100
|
+
listen: z.string().default("127.0.0.1:8787"),
|
|
101
|
+
/** Origins (hostnames) a browser-sent request may carry; a request with another Origin is refused. Requests without an Origin pass. */
|
|
102
|
+
allowedOrigins: z.array(z.string()).default([]),
|
|
103
|
+
/** Host names this server answers for (the reverse proxy's and the loopback ones). Empty: any Host. */
|
|
104
|
+
allowedHosts: z.array(z.string()).default([]),
|
|
105
|
+
/** The URL the public reaches the node at (for its documents and card). */
|
|
106
|
+
publicUrl: z.string().url().optional(),
|
|
107
|
+
rateLimitPerMinute: z.number().int().min(1).default(60),
|
|
108
|
+
maxBodyBytes: z.number().int().min(1024).default(256 * 1024),
|
|
109
|
+
});
|
|
110
|
+
export const RuntimeSchema = z.object({
|
|
111
|
+
heartbeatEverySeconds: z.number().int().positive().default(3600),
|
|
112
|
+
pollSeconds: z.number().int().positive().default(12),
|
|
113
|
+
http: HttpSchema.prefault({}),
|
|
114
|
+
/** The runner's model (3c): the provider, the model name (required for anthropic), and the limits the runner holds it to. */
|
|
115
|
+
model: z
|
|
116
|
+
.object({
|
|
117
|
+
provider: z.enum(["stub", "anthropic"]).default("stub"),
|
|
118
|
+
model: z.string().optional(),
|
|
119
|
+
/** Model calls per UTC day; a tick that would exceed it is skipped and journaled. */
|
|
120
|
+
dailyCalls: z.number().int().min(0).default(20),
|
|
121
|
+
/** Tool calls the runner carries out in one tick. */
|
|
122
|
+
maxToolCallsPerTick: z.number().int().min(1).max(100).default(12),
|
|
123
|
+
/** Acts (pre-checks not counted) the runner carries out in one tick. */
|
|
124
|
+
maxActsPerTick: z.number().int().min(0).max(20).default(2),
|
|
125
|
+
})
|
|
126
|
+
.prefault({}),
|
|
127
|
+
});
|
|
128
|
+
/** An API key the HTTP entrance accepts: only its hash is kept; the key itself is shown once by `foga node key new`. */
|
|
129
|
+
export const AccessKeySchema = z.object({
|
|
130
|
+
id: z.string().min(1).max(64).regex(/^[A-Za-z0-9_.-]+$/, "a key id: letters, digits, _ . -"),
|
|
131
|
+
sha256: bytes32,
|
|
132
|
+
level: z.enum(["read", "build", "act"]).default("act"),
|
|
133
|
+
visibility: z.enum(["public", "caller", "operator"]).default("caller"),
|
|
134
|
+
note: z.string().max(200).optional(),
|
|
135
|
+
});
|
|
136
|
+
export const AccessSchema = z.object({
|
|
137
|
+
keys: z.array(AccessKeySchema).default([]),
|
|
138
|
+
});
|
|
139
|
+
export const CellConfigSchema = z.object({
|
|
140
|
+
schema: z.literal("foga.cell-config/1"),
|
|
141
|
+
name: z.string().min(1).max(64),
|
|
142
|
+
world: z.string().min(1),
|
|
143
|
+
rpc: z.string().url().optional(),
|
|
144
|
+
bundle: z.string().optional(),
|
|
145
|
+
/** The operation store of this cell (operations, jobs, decisions, facts). */
|
|
146
|
+
store: z.string().min(1),
|
|
147
|
+
identity: IdentitySchema,
|
|
148
|
+
presence: PresenceSchema,
|
|
149
|
+
skills: SkillsSchema.prefault({}),
|
|
150
|
+
policy: PolicySchema.prefault({}),
|
|
151
|
+
runtime: RuntimeSchema.prefault({}),
|
|
152
|
+
access: AccessSchema.prefault({}),
|
|
153
|
+
});
|
|
154
|
+
export function loadCellConfig(path) {
|
|
155
|
+
const abs = resolve(path);
|
|
156
|
+
let raw;
|
|
157
|
+
try {
|
|
158
|
+
raw = JSON.parse(readFileSync(abs, "utf8"));
|
|
159
|
+
}
|
|
160
|
+
catch (e) {
|
|
161
|
+
throw new Error(`cannot read cell config ${abs}: ${e.message}`);
|
|
162
|
+
}
|
|
163
|
+
const parsed = CellConfigSchema.safeParse(raw);
|
|
164
|
+
if (!parsed.success) {
|
|
165
|
+
const issues = parsed.error.issues.map((i) => ` ${i.path.join(".") || "(root)"}: ${i.message}`).join("\n");
|
|
166
|
+
throw new Error(`cell config ${abs} is not a valid foga.cell-config/1 document:\n${issues}`);
|
|
167
|
+
}
|
|
168
|
+
return { path: abs, dir: dirname(abs), config: parsed.data };
|
|
169
|
+
}
|
|
170
|
+
/** Write the config back (after `init`, `migrate` or `skill_mint` filled in what the chain decided). */
|
|
171
|
+
export function saveCellConfig(path, config) {
|
|
172
|
+
const abs = resolve(path);
|
|
173
|
+
ensureDir(dirname(abs));
|
|
174
|
+
writeFileSync(abs, JSON.stringify(config, null, 2) + "\n");
|
|
175
|
+
}
|
|
176
|
+
/** Resolve a path in the config against the config's directory, unless absolute. */
|
|
177
|
+
export function configPath(loaded, p) {
|
|
178
|
+
return resolve(loaded.dir, p);
|
|
179
|
+
}
|
|
180
|
+
/** A starting config for a new cell: the roles and the world, nothing the chain has decided yet. */
|
|
181
|
+
export function newCellConfig(p) {
|
|
182
|
+
return CellConfigSchema.parse({
|
|
183
|
+
schema: "foga.cell-config/1",
|
|
184
|
+
name: p.name,
|
|
185
|
+
world: p.world,
|
|
186
|
+
rpc: p.rpc,
|
|
187
|
+
store: p.store,
|
|
188
|
+
identity: { owner: p.owner, operator: p.operator, guardian: p.guardian, safety: { version: 1 } },
|
|
189
|
+
presence: { name: `FOGA cell ${p.name}`, description: p.description ?? `Cell ${p.name}: an address acting under the ten-ERC stack`, endpoints: {} },
|
|
190
|
+
skills: { dir: p.skillsDir ?? "scenarios/skills", inputDir: p.inputDir ?? "scenarios/fixtures", held: {}, offered: [] },
|
|
191
|
+
policy: {},
|
|
192
|
+
runtime: {},
|
|
193
|
+
access: {},
|
|
194
|
+
});
|
|
195
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { getAddress, parseEther } from "ethers";
|
|
2
|
+
import { AID } from "../core/aid.js";
|
|
3
|
+
import { agentDescriptor, kindId } from "../core/ard.js";
|
|
4
|
+
import { Enforcement, Rights, accountSubjectKey, fogaCellSpecification, oneClause, scopeKeyOf, specIdOf } from "../core/asf.js";
|
|
5
|
+
import { accountRef, dataUri, keccakJcs } from "../core/canonical.js";
|
|
6
|
+
import { cellUnitDefinition } from "../core/units.js";
|
|
7
|
+
import { operatorScope } from "../genesis/operator-scope.js";
|
|
8
|
+
import { Cell } from "../kernel/cell.js";
|
|
9
|
+
import { ROLE } from "./node.js";
|
|
10
|
+
/** The operator scope a standalone cell starts with: identity, standing, skills, labour on both sides, services as provider. */
|
|
11
|
+
export const NODE_SCOPE_GROUPS = ["identity", "standing", "skills-provider", "labour", "demander", "service-provider"];
|
|
12
|
+
/**
|
|
13
|
+
* The birth of one cell from its configuration (T1 of the genesis for a single cell, design memo Phase 3 section 5):
|
|
14
|
+
* the owner deploys a revision-2 account naming the operator and guardian, funds it, opens the operator's scope,
|
|
15
|
+
* sets the commitment and de-risk classes, adopts the FOGA-Cell-v1 binding, and — through the account, as owner —
|
|
16
|
+
* registers the ERC-8004 identity, binds and heartbeats the anchor, publishes the AID document, the ARD agent record
|
|
17
|
+
* and the AUF unit. Every step is a keyed operation in the cell's own store, so a stopped init resumes. Standing
|
|
18
|
+
* (KYA) is not the cell's to give itself: an issuer attests it afterwards. The config is written back with what
|
|
19
|
+
* the chain decided.
|
|
20
|
+
*/
|
|
21
|
+
export async function cellInit(node, opts = {}) {
|
|
22
|
+
const k = node.kernel;
|
|
23
|
+
const cfg = node.config;
|
|
24
|
+
const log = opts.log ?? node.log;
|
|
25
|
+
if (!node.hasKey("owner"))
|
|
26
|
+
throw new Error("cell init is the owner's act: load the owner key (FOGA_OWNER_KEY or FOGA_OWNER_KEY_FILE)");
|
|
27
|
+
if (cfg.identity.account && k.store.get("init.deploy")?.status !== "confirmed")
|
|
28
|
+
throw new Error(`${node.loaded.path} already names account ${cfg.identity.account}; init runs on a config without one`);
|
|
29
|
+
const chainId = node.chainId;
|
|
30
|
+
const owner = cfg.identity.owner;
|
|
31
|
+
const operator = cfg.identity.operator;
|
|
32
|
+
const guardian = cfg.identity.guardian;
|
|
33
|
+
const ownerSigner = ROLE.owner;
|
|
34
|
+
const operations = [];
|
|
35
|
+
const done = (key) => operations.push(key);
|
|
36
|
+
// 0. the specification the cell adopts: published by the owner when the config names none. The registry derives
|
|
37
|
+
// the id from the publisher and the salt, so an owner who has published FOGA-Cell-v1 before (an earlier cell of
|
|
38
|
+
// theirs on this chain) adopts that specification rather than registering it again.
|
|
39
|
+
let spec = cfg.identity.safety;
|
|
40
|
+
if (!spec.specId || !spec.contentHash) {
|
|
41
|
+
const document = fogaCellSpecification();
|
|
42
|
+
const salt = keccakJcs({ spec: "FOGA-Cell-v1", publisher: owner.toLowerCase() });
|
|
43
|
+
const commitment = k.asf.specificationCommitment(document);
|
|
44
|
+
const expected = specIdOf(chainId, k.asf.specRegistryAddress, owner, salt);
|
|
45
|
+
const published = await k.asf.versionOf(expected, 1).catch(() => null);
|
|
46
|
+
if (published) {
|
|
47
|
+
if (published.contentHash.toLowerCase() !== commitment.contentHash.toLowerCase())
|
|
48
|
+
throw new Error(`the owner published specification ${expected} before with contentHash ${published.contentHash}; this build's FOGA-Cell-v1 hashes to ${commitment.contentHash}. Name the specification to adopt in identity.safety.`);
|
|
49
|
+
spec = { specId: expected, version: 1, contentHash: commitment.contentHash };
|
|
50
|
+
log(` spec: FOGA-Cell-v1 already published by the owner as ${expected}; adopted`);
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
const reg = await k.call("init.spec.register", ownerSigner, k.asf.specRegistryAddress, k.asf.specs().interface, "registerSpecification", [salt, commitment.contentHash, commitment.locator, []]);
|
|
54
|
+
done("init.spec.register");
|
|
55
|
+
const specId = k.asf.specIdFromReceipt(reg.receipt);
|
|
56
|
+
if (specId.toLowerCase() !== expected.toLowerCase())
|
|
57
|
+
throw new Error(`the registry assigned ${specId}; expected ${expected}`);
|
|
58
|
+
spec = { specId, version: 1, contentHash: commitment.contentHash };
|
|
59
|
+
log(` spec: FOGA-Cell-v1 published by the owner as ${specId}`);
|
|
60
|
+
}
|
|
61
|
+
cfg.identity.safety = spec;
|
|
62
|
+
node.saveConfig();
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
const v = await k.asf.versionOf(spec.specId, spec.version);
|
|
66
|
+
if (v.contentHash.toLowerCase() !== spec.contentHash.toLowerCase())
|
|
67
|
+
throw new Error(`specification ${spec.specId} v${spec.version} on chain has contentHash ${v.contentHash}, the config says ${spec.contentHash}`);
|
|
68
|
+
}
|
|
69
|
+
const specId = spec.specId;
|
|
70
|
+
const contentHash = spec.contentHash;
|
|
71
|
+
// 1. the body: deployed by the owner; the requirement names the account itself
|
|
72
|
+
const safety = k.asf.bindingRegistryAddress;
|
|
73
|
+
const recovery = cfg.identity.recovery ?? owner;
|
|
74
|
+
const requirement = (predicted) => oneClause({ specRegistry: k.asf.specRegistryAddress, specId, versions: [spec.version], adopter: predicted, subjectKey: accountSubjectKey(chainId, predicted), scopeKey: scopeKeyOf({ ref: accountRef(chainId, predicted) }), enforcement: Enforcement.account | Enforcement.contract, interveners: [{ account: guardian, rights: Rights.suspend | Rights.resume }] });
|
|
75
|
+
let predictedFor = null;
|
|
76
|
+
const { address } = await k.deploy("init.deploy", ownerSigner, "foga:FogaCellAccount", [], (predicted) => {
|
|
77
|
+
predictedFor = predicted;
|
|
78
|
+
return [safety, owner, operator, guardian, recovery, requirement(predicted)];
|
|
79
|
+
});
|
|
80
|
+
done("init.deploy");
|
|
81
|
+
if (predictedFor && predictedFor.toLowerCase() !== address.toLowerCase())
|
|
82
|
+
throw new Error(`deployed at ${address} but the requirement was built for ${predictedFor}; the owner's nonce moved between prediction and inclusion`);
|
|
83
|
+
const cell = new Cell(k, cfg.name, address, { owner: ROLE.owner, operator: ROLE.operator, guardian, recovery });
|
|
84
|
+
cfg.identity.account = getAddress(address);
|
|
85
|
+
node.saveConfig();
|
|
86
|
+
k.store.setMeta("cellAccount", address);
|
|
87
|
+
log(` account: ${address} (owner ${owner}, operator ${operator}, guardian ${guardian})`);
|
|
88
|
+
// 2. working capital from the owner
|
|
89
|
+
const capital = opts.capitalWei ?? (k.isLocalChain ? parseEther("0.05") : parseEther("0.005"));
|
|
90
|
+
if (capital > 0n) {
|
|
91
|
+
await k.transfer("init.fund", ownerSigner, address, capital);
|
|
92
|
+
done("init.fund");
|
|
93
|
+
}
|
|
94
|
+
// 3. owner administration: the operator's key, scope and daily cap, the AID registry, the selector classes
|
|
95
|
+
await cell.admin("init.setOperator", "setOperator", [operator, 0, BigInt(cfg.policy.budget.dailyValueWei)]);
|
|
96
|
+
done("init.setOperator");
|
|
97
|
+
const scope = operatorScope(k, NODE_SCOPE_GROUPS);
|
|
98
|
+
await cell.admin("init.scope", "setOperatorAllowedBatch", [scope.targets, scope.selectors, true]);
|
|
99
|
+
done("init.scope");
|
|
100
|
+
k.setFact("init.scope", { groups: NODE_SCOPE_GROUPS, entries: scope.names });
|
|
101
|
+
await cell.admin("init.setAIDRegistry", "setAIDRegistry", [k.aid.address]);
|
|
102
|
+
done("init.setAIDRegistry");
|
|
103
|
+
const sel = (id, fn) => node.world.iface(id).getFunction(fn).selector;
|
|
104
|
+
const commitment = [
|
|
105
|
+
["8414.submitFulfillment", k.tenders.address, sel("8414/TaskToken", "submitFulfillment")],
|
|
106
|
+
["8447.join", node.world.address("8447/SwarmRegistry"), sel("8447/SwarmRegistry", "join")],
|
|
107
|
+
["8447.applyToJoin", node.world.address("8447/SwarmRegistry"), sel("8447/SwarmRegistry", "applyToJoin")],
|
|
108
|
+
["8454.assertStatement", node.world.address("8454/AUFAssertionRegistry"), sel("8454/AUFAssertionRegistry", "assertStatement")],
|
|
109
|
+
["8436.castBallot", k.acdf.registryAddress, sel("8436/ACDFRegistry", "castBallot")],
|
|
110
|
+
];
|
|
111
|
+
for (const [name, target, selector] of commitment) {
|
|
112
|
+
await cell.admin(`init.class.${name}`, "setCommitmentClass", [target, selector, true]);
|
|
113
|
+
done(`init.class.${name}`);
|
|
114
|
+
}
|
|
115
|
+
const derisk = [
|
|
116
|
+
["8414.withdrawCredit", k.tenders.address, sel("8414/TaskToken", "withdrawCredit")],
|
|
117
|
+
["8414.reclaimEscrow", k.tenders.address, sel("8414/TaskToken", "reclaimEscrow")],
|
|
118
|
+
["8447.leave", node.world.address("8447/SwarmRegistry"), sel("8447/SwarmRegistry", "leave")],
|
|
119
|
+
["8471.withdrawBalance", k.adrf.escrowAddress, sel("8471/ADRFServiceEscrow", "withdrawBalance")],
|
|
120
|
+
];
|
|
121
|
+
for (const [name, target, selector] of derisk) {
|
|
122
|
+
await cell.admin(`init.derisk.${name}`, "setDeriskAllowed", [target, selector, true]);
|
|
123
|
+
done(`init.derisk.${name}`);
|
|
124
|
+
}
|
|
125
|
+
// 4. the safety binding, adopted by the account and presented for clause 0
|
|
126
|
+
const params = { profile: "foga-cell/1", cell: cfg.name, operatorScope: NODE_SCOPE_GROUPS, deriskActions: derisk.map((d) => d[0]), commitmentActions: commitment.map((c) => c[0]) };
|
|
127
|
+
const authority = { kind: "owner", owner: accountRef(chainId, owner), guardian: accountRef(chainId, guardian) };
|
|
128
|
+
const input = k.asf.cellBindingInput({ chainId, cell: address, specId, version: spec.version, contentHash, guardian, params, authority });
|
|
129
|
+
const { bindingId } = await cell.adoptBinding("init.adopt", input, 0);
|
|
130
|
+
done("init.adopt");
|
|
131
|
+
cfg.identity.bindingId = bindingId;
|
|
132
|
+
node.saveConfig();
|
|
133
|
+
// 5. identity: the ERC-8004 registration, made by the account (the owner executes; the operator's scope is for later)
|
|
134
|
+
const registration = {
|
|
135
|
+
type: "https://eips.ethereum.org/EIPS/eip-8004#registration-v1",
|
|
136
|
+
name: cfg.presence.name,
|
|
137
|
+
description: cfg.presence.description,
|
|
138
|
+
image: cfg.presence.image,
|
|
139
|
+
services: [
|
|
140
|
+
...(cfg.presence.endpoints.mcp ? [{ name: "MCP", endpoint: cfg.presence.endpoints.mcp, version: "2026-07-28" }] : []),
|
|
141
|
+
...(cfg.presence.endpoints.a2a ? [{ name: "A2A", endpoint: cfg.presence.endpoints.a2a, version: "1.0" }] : []),
|
|
142
|
+
...(cfg.presence.endpoints.web ? [{ name: "web", endpoint: cfg.presence.endpoints.web }] : []),
|
|
143
|
+
],
|
|
144
|
+
x402Support: false,
|
|
145
|
+
active: true,
|
|
146
|
+
supportedTrust: ["reputation"],
|
|
147
|
+
"x-foga": { cell: cfg.name, anchor: accountRef(chainId, address), binding: bindingId },
|
|
148
|
+
};
|
|
149
|
+
const reg = await cell.execute("init.8004.register", k.identity.address, k.identity.encodeRegister(dataUri(registration)), { by: "owner" });
|
|
150
|
+
done("init.8004.register");
|
|
151
|
+
const agentId = k.identity.agentIdFromReceipt(reg.receipt);
|
|
152
|
+
cfg.identity.agentId = agentId.toString();
|
|
153
|
+
node.saveConfig();
|
|
154
|
+
// 6. the anchor: bind and heartbeat in the reference registry, bind in the backing registry where there is one
|
|
155
|
+
await cell.execute("init.aid.bind", k.aid.address, k.aid.encodeBind(k.identity.address, agentId), { by: "owner" });
|
|
156
|
+
done("init.aid.bind");
|
|
157
|
+
await cell.execute("init.aid.heartbeat", k.aid.address, k.aid.encodeHeartbeat(), { by: "owner" });
|
|
158
|
+
done("init.aid.heartbeat");
|
|
159
|
+
if (k.aid.backing) {
|
|
160
|
+
await cell.execute("init.aid.backing.bind", k.aid.backing.address, k.aid.encodeBind(k.identity.address, agentId), { by: "owner" });
|
|
161
|
+
done("init.aid.backing.bind");
|
|
162
|
+
}
|
|
163
|
+
if (cfg.identity.livenessWindow) {
|
|
164
|
+
await cell.execute("init.aid.window", k.aid.address, k.aid.encodeSetLivenessWindow(cfg.identity.livenessWindow), { by: "owner" });
|
|
165
|
+
done("init.aid.window");
|
|
166
|
+
}
|
|
167
|
+
// 7. the AID document: the identity facet (SELF); standing and skills facets follow when they exist
|
|
168
|
+
const doc = {
|
|
169
|
+
version: "aid-document/v1",
|
|
170
|
+
aid: accountRef(chainId, address),
|
|
171
|
+
did: `did:aid:${accountRef(chainId, address)}`,
|
|
172
|
+
binding: { registry: k.identity.address, agentId: Number(agentId) },
|
|
173
|
+
livenessWindow: cfg.identity.livenessWindow ?? Number(node.world.file.params.aid.defaultLivenessWindow),
|
|
174
|
+
facets: [{ facetType: "aid:core/identity/v1", provenance: "SELF", issuer: accountRef(chainId, address), validUntil: 0, digest: keccakJcs(registration), access: { mode: "PUBLIC" }, resolver: { kind: "erc8004-identity", chainId, registry: k.identity.address, agentId: Number(agentId) } }],
|
|
175
|
+
};
|
|
176
|
+
const commitment2 = AID.documentCommitment(doc);
|
|
177
|
+
await cell.execute("init.aid.document", k.aid.address, k.aid.encodeSetDocumentURI(commitment2.uri, commitment2.digest), { by: "owner" });
|
|
178
|
+
done("init.aid.document");
|
|
179
|
+
cfg.identity.documentDigest = commitment2.digest;
|
|
180
|
+
// 8. discovery: the ARD agent record, controlled by the anchor itself
|
|
181
|
+
const descriptor = agentDescriptor({ chainId, anchor: address, slug: `cell-${cfg.name.toLowerCase()}`, name: cfg.presence.name, description: cfg.presence.description, url: cfg.presence.homepage, capabilities: ["erc8414:fulfiller", "erc8338:holder"], registration: { registry: `eip155:${chainId}:${k.identity.address.toLowerCase()}`, agentId: agentId.toString() } });
|
|
182
|
+
const ard = await cell.execute("init.ard.register", k.ard.address, k.ard.encodeRegister(kindId("agent"), address, 0n, descriptor), { by: "owner" });
|
|
183
|
+
done("init.ard.register");
|
|
184
|
+
const recordId = k.ard.recordIdFromReceipt(ard.receipt);
|
|
185
|
+
cfg.identity.recordId = recordId;
|
|
186
|
+
// 9. the economic boundary: the cell's unit
|
|
187
|
+
const unitDef = cellUnitDefinition({ chainId, cell: address, name: cfg.presence.name });
|
|
188
|
+
const unit = await cell.execute("init.auf.unit", k.units.registryAddress, k.units.encodeRegisterUnit(address, unitDef, []), { by: "owner" });
|
|
189
|
+
done("init.auf.unit");
|
|
190
|
+
const unitId = k.units.unitIdFromReceipt(unit.receipt);
|
|
191
|
+
cfg.identity.unitId = unitId.toString();
|
|
192
|
+
node.saveConfig();
|
|
193
|
+
k.evidence("init", cfg.name, { account: address, owner, operator, guardian, specId, bindingId, agentId: agentId.toString(), recordId, unitId: unitId.toString(), documentDigest: commitment2.digest, scope: scope.names, capital: capital.toString() });
|
|
194
|
+
const state = await k.aid.stateName(address);
|
|
195
|
+
const status = await cell.status();
|
|
196
|
+
log(` born: agent ${agentId}, anchor ${state}, binding ${status.verdictName}, record ${recordId.slice(0, 10)}…, unit ${unitId}`);
|
|
197
|
+
if (state !== "ACTIVE" || status.verdictName !== "Satisfied")
|
|
198
|
+
throw new Error(`the cell was born but is not ready: anchor ${state}, binding ${status.verdictName} [${status.reasonNames.join(",")}]`);
|
|
199
|
+
return { account: address, agentId: agentId.toString(), bindingId, recordId, unitId: unitId.toString(), documentDigest: commitment2.digest, specId, operations };
|
|
200
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { Wallet, isHexString } from "ethers";
|
|
3
|
+
/**
|
|
4
|
+
* The one key a running node holds: its cell's operator key, and nothing else (design memo, Phase 3, key
|
|
5
|
+
* isolation). The genesis derives twelve roles from one phrase because it plays every part; a node plays one, and
|
|
6
|
+
* the process that signs for the public must not be able to derive the owner, the guardian or the treasury. So
|
|
7
|
+
* the node reads its key from FOGA_OPERATOR_KEY (the hex key) or FOGA_OPERATOR_KEY_FILE (a file holding it, readable
|
|
8
|
+
* by its user alone) and refuses to start when its environment also carries a phrase, a deployer key or any
|
|
9
|
+
* FOGA_KEY_<ROLE>: those belong to the operator's shell, never to the node's user. `foga keys export` writes the
|
|
10
|
+
* file for the node from the operator's phrase.
|
|
11
|
+
*/
|
|
12
|
+
export const OPERATOR_KEY_ENV = "FOGA_OPERATOR_KEY";
|
|
13
|
+
export const OPERATOR_KEY_FILE_ENV = "FOGA_OPERATOR_KEY_FILE";
|
|
14
|
+
/** Variables whose presence means the process could sign as more than the operator. */
|
|
15
|
+
export const FORBIDDEN_IN_NODE_ENV = ["FOGA_MNEMONIC", "FOGA_MNEMONIC_FILE", "FOGA_DEPLOYER_PK", "FOGA_OWNER_KEY", "FOGA_OWNER_KEY_FILE", "FOGA_GUARDIAN_KEY", "FOGA_GUARDIAN_KEY_FILE"];
|
|
16
|
+
/** The variables a role's key is read from: FOGA_<ROLE>_KEY or FOGA_<ROLE>_KEY_FILE. */
|
|
17
|
+
export function roleKeyEnv(role) {
|
|
18
|
+
const up = role.toUpperCase();
|
|
19
|
+
return { inline: `FOGA_${up}_KEY`, file: `FOGA_${up}_KEY_FILE` };
|
|
20
|
+
}
|
|
21
|
+
export class KeyIsolationError extends Error {
|
|
22
|
+
constructor(message) {
|
|
23
|
+
super(message);
|
|
24
|
+
this.name = "KeyIsolationError";
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/** The variables of `env` a node must not see, with their names (never their values). */
|
|
28
|
+
export function forbiddenVariables(env = process.env) {
|
|
29
|
+
const out = [];
|
|
30
|
+
for (const name of Object.keys(env)) {
|
|
31
|
+
if (env[name] === undefined || env[name] === "")
|
|
32
|
+
continue;
|
|
33
|
+
if (FORBIDDEN_IN_NODE_ENV.includes(name) || name.startsWith("FOGA_KEY_"))
|
|
34
|
+
out.push(name);
|
|
35
|
+
}
|
|
36
|
+
return out.sort();
|
|
37
|
+
}
|
|
38
|
+
function parseKey(text, where) {
|
|
39
|
+
const key = text.trim();
|
|
40
|
+
if (!isHexString(key, 32))
|
|
41
|
+
throw new KeyIsolationError(`${where} does not hold a private key (expected 0x followed by 64 hex digits)`);
|
|
42
|
+
return key;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Load the operator key from the environment. Exactly one of FOGA_OPERATOR_KEY and FOGA_OPERATOR_KEY_FILE must be
|
|
46
|
+
* set; the file must be a regular file owned by the current user and readable by nobody else (mode 0600 or
|
|
47
|
+
* tighter; the ownership and mode checks are POSIX-only).
|
|
48
|
+
*/
|
|
49
|
+
export function loadOperatorKey(env = process.env) {
|
|
50
|
+
const forbidden = forbiddenVariables(env);
|
|
51
|
+
if (forbidden.length) {
|
|
52
|
+
throw new KeyIsolationError(`the node's environment holds ${forbidden.join(", ")}: a node runs with its operator key alone (${OPERATOR_KEY_ENV} or ${OPERATOR_KEY_FILE_ENV}). Unset these for the node's user; they belong to the operator's shell.`);
|
|
53
|
+
}
|
|
54
|
+
return loadRoleKey("operator", env);
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Read one role's key from the environment without the isolation check: for the CLI's one-shot commands in the
|
|
58
|
+
* operator's own shell (an owner's or guardian's act, a cell's birth), never for a running node. The file rules
|
|
59
|
+
* are the same as the node's.
|
|
60
|
+
*/
|
|
61
|
+
export function loadRoleKey(role, env = process.env) {
|
|
62
|
+
const names = roleKeyEnv(role);
|
|
63
|
+
const inline = env[names.inline]?.trim();
|
|
64
|
+
const file = env[names.file]?.trim();
|
|
65
|
+
if (inline && file)
|
|
66
|
+
throw new KeyIsolationError(`both ${names.inline} and ${names.file} are set; set one`);
|
|
67
|
+
if (!inline && !file) {
|
|
68
|
+
if (role === "operator")
|
|
69
|
+
throw new KeyIsolationError(`no operator key: set ${names.file} to a file written by 'foga keys export --role operator.<X>' (or ${names.inline} to the key itself)`);
|
|
70
|
+
throw new KeyIsolationError(`no ${role} key: set ${names.file} (a 0600 file holding the key) or ${names.inline} for this one command`);
|
|
71
|
+
}
|
|
72
|
+
if (inline) {
|
|
73
|
+
const privateKey = parseKey(inline, names.inline);
|
|
74
|
+
return { privateKey, address: new Wallet(privateKey).address, source: { kind: "env", name: names.inline } };
|
|
75
|
+
}
|
|
76
|
+
const path = file;
|
|
77
|
+
let st;
|
|
78
|
+
try {
|
|
79
|
+
st = statSync(path);
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
throw new KeyIsolationError(`${names.file}=${path}: no such file`);
|
|
83
|
+
}
|
|
84
|
+
if (!st.isFile())
|
|
85
|
+
throw new KeyIsolationError(`${names.file}=${path} is not a regular file`);
|
|
86
|
+
if (process.platform !== "win32") {
|
|
87
|
+
if (st.mode & 0o077)
|
|
88
|
+
throw new KeyIsolationError(`${path} is readable by other users (mode ${(st.mode & 0o777).toString(8)}); make it 0600 (chmod 600)`);
|
|
89
|
+
const uid = process.getuid?.();
|
|
90
|
+
if (uid !== undefined && st.uid !== uid)
|
|
91
|
+
throw new KeyIsolationError(`${path} is owned by uid ${st.uid}, not by the node's user (uid ${uid}); the node's user must own its key file`);
|
|
92
|
+
}
|
|
93
|
+
const privateKey = parseKey(readFileSync(path, "utf8"), path);
|
|
94
|
+
return { privateKey, address: new Wallet(privateKey).address, source: { kind: "file", path } };
|
|
95
|
+
}
|