@passioncode-ai/fabric-agent-adapter 0.37.0 → 0.37.1

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/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.37.1 - 2026-10-20
4
+
5
+ ### Added
6
+
7
+ - **The rest of contract DEC-0084 in both kits** (FAA-57), in `fabric_ceremonies.py` / `fabric-ceremonies.mjs`: the
8
+ nearby pairing (`pairing_commit`, `check_reveal`, `comparison_code`, `checked_comparison_code`, `estate_fingerprint`,
9
+ `rendezvous_verdict`, `join_listed`, `own_keys_decision`, `machine_of_keys`) held to the contract's `pairing.json`
10
+ (116 cases in eight groups), and the hub's single use of a token (`redeem_enroll_token`, with the `enroll-join/1`
11
+ proof as ES256 r and s low S or Ed25519) held to `enroll-token.json` group `redeem` and a high-S variant. 26 planted
12
+ defects across both kits, all caught. `fabric-estate.mjs` exports `validDeviceKey`.
13
+
14
+ ### Changed
15
+
16
+ - **`building-fabric-services` → remote placement:** which `installedBy` form an agent installer writes (the string
17
+ form, or `{by: "person", role: "operator", tool}` when a person asked it), and that a TypeScript app must name its
18
+ `.well-known` route folder in `tsconfig` `include`, which skips dot folders. Both reported by an agent that built a
19
+ remote service in a Next.js app.
20
+
3
21
  ## 0.37.0 - 2026-10-19
4
22
 
5
23
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@passioncode-ai/fabric-agent-adapter",
3
- "version": "0.37.0",
3
+ "version": "0.37.1",
4
4
  "description": "Create Fabric-compatible agents, run them as always-alive local services with dashboards (fabric-service/0.1), and adapt existing projects to the Fabric Agent Contract. This package is the installer CLI.",
5
5
  "scripts": {
6
6
  "test": "python3 test/validate.py && python3 -m unittest discover -s test -q && node --test 'test/*.test.mjs'"
@@ -3,7 +3,7 @@
3
3
  "name": "fabric-agent-adapter",
4
4
  "displayName": "Fabric Agent Adapter",
5
5
  "description": "Create new Fabric-compatible agents, build them as always-alive local services with dashboards (fabric-service/0.1), and adapt existing projects to the Fabric Agent Contract through evidence-backed MCP, A2A, or local-runner provider bundles; and teach a coding agent to work in a project through Fabric (fabric-mcp, fabric_whoami, claim and release), with Claude Code session hooks.",
6
- "version": "0.37.0",
6
+ "version": "0.37.1",
7
7
  "author": {
8
8
  "name": "PassionCode.ai",
9
9
  "url": "https://passioncode.ai/"
@@ -5,7 +5,7 @@ license: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
5
5
  compatibility: Requires filesystem access and Python 3.9+. Exact schema checks additionally need git, Node.js, pnpm, and the pinned fabric-agent-contract checkout (a public repository). Works without those tools in an explicitly degraded structural-check mode.
6
6
  metadata:
7
7
  author: PassionCode.ai
8
- version: "0.37.0"
8
+ version: "0.37.1"
9
9
  contract-version: "0.1.0"
10
10
  contract-commit: "07042181bfae2a481aa0da1a5651f24c76669c48"
11
11
  ---
@@ -18,7 +18,7 @@ license: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
18
18
  compatibility: Python 3.9+ or Node.js 20+ for the kits; the probe needs Python 3.9+. launchd steps are macOS-only; Linux services install a systemd user unit and Windows ones a per-user Scheduled Task with the kits' writers. Dashboard handoff optionally uses Fabric Dashboards MCP link/host_status/open; without it, report unresolved host capability. The contract checkout is optional.
19
19
  metadata:
20
20
  author: PassionCode.ai
21
- version: "0.37.0"
21
+ version: "0.37.1"
22
22
  contract-version: "0.1.0"
23
23
  extension: "fabric-service/0.1"
24
24
  extension-commit: "07042181bfae2a481aa0da1a5651f24c76669c48"
@@ -137,9 +137,19 @@ passkey; what a service applying the record checks is in the record itself:
137
137
  with the machine's `#sig` key and present that proof with the token. The token gives the machine no trust: only the
138
138
  passkey-signed revision that lists it does. `enroll_token_rules("enroll-token", token)` is FAC-SEM-113.
139
139
 
140
- The nearby pairing (`pairing-message/1`, the comparison code, the rendezvous) and the hub's single use of a token are
141
- Project Observatory's node and Fabric's hub; the kits port them in a later version (FAA-57). The relay's admission and
142
- session checks belong to the relay; the kits do not run a relay.
140
+ - **A machine nearby pairs by a comparison code.** Each side commits (`pairing_commit`), then reveals; each checks the
141
+ other's reveal against its commitment, and both screens show the same six digits only when nothing sat in the middle
142
+ (`checked_comparison_code`, never `comparison_code` on unchecked reveals). The one-time rendezvous burns on the first
143
+ repeat, out-of-order message, mismatch, abort or after 600 s (`rendezvous_verdict`). Before it pins the estate, the new
144
+ machine checks that the revision lists it with the keys it revealed and the trusted machine with its `#sig` key
145
+ (`join_listed`), and shows `estate_fingerprint` of revision 1 for the person to compare. On each later revision it
146
+ runs `own_keys_decision`: apply and retire the keys the record dropped, or stop when it was removed or another holder
147
+ carries its key.
148
+ - **The hub binds a token once** (`redeem_enroll_token`): the joining machine's proof is its `#sig` key's signature over
149
+ `enroll_join_input(token)` (ES256 as 64 bytes r and s, low S; Ed25519), and a second use, by any key, is refused and
150
+ logged.
151
+
152
+ The relay's admission and session checks belong to the relay; the kits do not run a relay.
143
153
 
144
154
  ## Share grants into an organization
145
155
 
@@ -30,6 +30,16 @@ same objects — well-known document, events feed, login code — at the same pa
30
30
  literal; not `localhost`, `*.local`, `*.internal`, `*.home.arpa`, `*.lan`, `*.localdomain`.
31
31
  - `lifecycle.manager` is `none`, with no `label` or `plist`. `paths` is optional.
32
32
  - `commands` may carry `doctor` (a local executable); never `update`.
33
+ - `installedBy` is the installer's name and version as a string (`"example-installer 1.2.0"`), or
34
+ `{by, role?, tool}` where `by` is `person`, `node` or `organization` and `role` (`operator`, `administrator`) is a
35
+ person's only. There is no `agent` kind: an agent that registers a service writes the string form, or
36
+ `{by: "person", role: "operator", tool: "<agent> <version>"}` when a person asked it to. `registerRemote` defaults to
37
+ the person form with `tool: "fabric-service register-remote"`.
38
+
39
+ **A TypeScript app (Next.js and the like):** TypeScript's `include` and typescript-eslint skip dot folders by default,
40
+ so `app/.well-known/fabric-service/route.ts` is neither type-checked nor linted until the project names it, for example
41
+ `"include": [..., "src/app/.well-known/**/*.ts"]` in `tsconfig.json`. Check it is: put a type error in the route and
42
+ watch the gate fail.
33
43
 
34
44
  ## On the platform
35
45
 
@@ -8,7 +8,7 @@
8
8
 
9
9
  import crypto from 'node:crypto';
10
10
  import { jcs } from './fabric-delegation.mjs';
11
- import { CLOCK_SKEW_SECONDS, estateTime, isSigningRpId, originWithin, verifyAssertion } from './fabric-estate.mjs';
11
+ import { CLOCK_SKEW_SECONDS, deviceKeyFingerprint, deviceKeyRef, estateTime, isSigningRpId, isValidP256Key, originWithin, validDeviceKey, verifyAssertion } from './fabric-estate.mjs';
12
12
 
13
13
  export const ENROLL_TOKEN_SCHEMA = 'enroll-token/1';
14
14
  export const ENROLL_TOKEN_TYPE = 'enroll-token/1';
@@ -126,4 +126,213 @@ export function enrollTokenRules(kind, value) {
126
126
  if (kind === 'enroll-token') return tokenFindings(isObject(value) ? value : {});
127
127
  return undefined;
128
128
  }
129
+ // --- the hub's single use of a token (DEC-0084) ------------------------------------------------------------------------
130
+
131
+ const PROOF = /^[A-Za-z0-9_-]{85}[AQgw]$/;
132
+ const P256_HALF_N = 0x7fffffff800000007fffffffffffffffde737d56d38bcf4279dce5617e3192a8n;
133
+
134
+ function es256Raw(octets, message, raw) {
135
+ if (octets.length !== 65 || octets[0] !== 4 || !isValidP256Key(octets) || raw.length !== 64) return false;
136
+ if (BigInt(`0x${raw.subarray(32).toString('hex')}`) > P256_HALF_N) return false;
137
+ const jwk = { kty: 'EC', crv: 'P-256', x: octets.subarray(1, 33).toString('base64url'), y: octets.subarray(33).toString('base64url') };
138
+ try { return crypto.verify('sha256', Buffer.from(message, 'utf8'), { key: jwk, format: 'jwk', dsaEncoding: 'ieee-p1363' }, raw); } catch { return false; }
139
+ }
140
+
141
+ function proves(key, message, proof) {
142
+ if (!isString(proof) || !PROOF.test(proof)) return false;
143
+ const octets = Buffer.from(key.publicKey, 'base64');
144
+ const signature = Buffer.from(proof, 'base64url');
145
+ if (key.alg === 'es256') return es256Raw(octets, message, signature);
146
+ try {
147
+ const publicKey = crypto.createPublicKey({ key: { kty: 'OKP', crv: 'Ed25519', x: octets.toString('base64url') }, format: 'jwk' });
148
+ return crypto.verify(null, Buffer.from(message, 'utf8'), publicKey, signature);
149
+ } catch { return false; }
150
+ }
151
+
152
+ /** The hub's single use of a verified token; `ledger` is a Map from nonce to {state: redeemed, fingerprint} or {state: revoked}. */
153
+ export function redeemEnrollToken(token, machineKey, proof, ledger) {
154
+ const input = enrollJoinInput(token);
155
+ if (input === undefined || !(ledger instanceof Map)) return { ok: false, problem: 'malformed' };
156
+ const { nonce } = statementFields(token);
157
+ if (machineOfKeys([machineKey]) === undefined) return { ok: false, problem: 'bad-key' };
158
+ if (!proves(machineKey, input, proof)) return { ok: false, problem: 'bad-proof' };
159
+ const fingerprint = deviceKeyFingerprint(machineKey);
160
+ const entry = ledger.get(nonce);
161
+ if (isObject(entry) && entry.state === 'revoked') return { ok: false, problem: 'revoked' };
162
+ if (entry !== undefined) return { ok: false, problem: 'reused', log: { nonce, presented: fingerprint, boundTo: isObject(entry) ? show(entry.fingerprint) : show(entry) } };
163
+ return { ok: true, record: { nonce, state: 'redeemed', fingerprint } };
164
+ }
165
+
166
+ // --- the nearby pairing (DEC-0084) ---------------------------------------------------------------------------------------
167
+
168
+ export const PAIRING_SCHEMA = 'pairing-message/1';
169
+ export const COMMIT_TYPE = 'fabric-enroll-commit/1';
170
+ export const CODE_TYPE = 'fabric-enroll-code/1';
171
+ export const FINGERPRINT_TYPE = 'estate-fingerprint/1';
172
+ export const CODE_DIGITS = 6;
173
+ export const FINGERPRINT_GROUPS = 5;
174
+ export const FINGERPRINT_GROUP_DIGITS = 5;
175
+ export const RENDEZVOUS_SECONDS = 600;
176
+ const MACHINE_ID = /^[A-Za-z0-9][A-Za-z0-9._:~-]{0,127}$/;
177
+ const KEY_MEMBERS = 'alg,kid,publicKey,use';
178
+ const ALGORITHMS = new Map([['sig', ['es256', 'ed25519']], ['kem', ['ecdh-p256']]]);
179
+ const sha = (text) => crypto.createHash('sha256').update(Buffer.from(text, 'utf8')).digest();
180
+ const uint40 = (digest, offset) => digest.readUIntBE(offset, 5);
181
+ const own = (value, key) => (isObject(value) && Object.hasOwn(value, key) ? value[key] : undefined);
182
+ const isRole = (value) => value === 'new' || value === 'trusted';
183
+ function jsonEqual(a, b) {
184
+ if (Array.isArray(a) || Array.isArray(b)) return Array.isArray(a) && Array.isArray(b) && a.length === b.length && a.every((item, i) => jsonEqual(item, b[i]));
185
+ if (isObject(a) || isObject(b)) {
186
+ if (!isObject(a) || !isObject(b)) return false;
187
+ const keys = Object.keys(a);
188
+ return keys.length === Object.keys(b).length && keys.every((key) => Object.hasOwn(b, key) && jsonEqual(a[key], b[key]));
189
+ }
190
+ return a === b;
191
+ }
192
+
193
+ /** The machine a key list belongs to, or undefined (one sig key, at most one kem key, one machine id, valid keys). */
194
+ export function machineOfKeys(keys) {
195
+ if (!Array.isArray(keys) || keys.length < 1 || keys.length > 2) return undefined;
196
+ const ids = new Set(); const uses = new Set(); const octets = new Set();
197
+ for (const key of keys) {
198
+ if (!isObject(key) || Object.keys(key).sort().join() !== KEY_MEMBERS) return undefined;
199
+ const { use, kid } = key;
200
+ if (!isString(use) || !isString(kid) || !isString(key.alg) || !ALGORITHMS.get(use)?.includes(key.alg)) return undefined;
201
+ const id = kid.endsWith(`#${use}`) ? kid.slice(0, -(use.length + 1)) : '';
202
+ if (!MACHINE_ID.test(id) || uses.has(use) || deviceKeyFingerprint(key) === undefined || !validDeviceKey(key) || octets.has(key.publicKey)) return undefined;
203
+ ids.add(id); uses.add(use); octets.add(key.publicKey);
204
+ }
205
+ const [id] = [...ids];
206
+ return ids.size === 1 && uses.has('sig') ? id : undefined;
207
+ }
208
+
209
+ function sideProblem(side) {
210
+ if (!isObject(side)) return 'malformed';
211
+ const role = own(side, 'role');
212
+ if (!isRole(role)) return 'bad-role';
213
+ if (!matches(NONCE32, own(side, 'nonce'))) return 'bad-nonce';
214
+ if (machineOfKeys(own(side, 'keys')) === undefined) return 'bad-keys';
215
+ const genesis = own(side, 'genesis');
216
+ if (role === 'trusted' ? !matches(SHA256, genesis) : Object.hasOwn(side, 'genesis')) return 'bad-genesis';
217
+ return undefined;
218
+ }
219
+ const covered = (side) => (side.role === 'trusted' ? { keys: side.keys, nonce: side.nonce, genesis: side.genesis } : { keys: side.keys, nonce: side.nonce });
220
+
221
+ /** A side's commitment over {type, role, keys, nonce, genesis?}. */
222
+ export function pairingCommit(side) {
223
+ const problem = sideProblem(side);
224
+ if (problem) return { ok: false, problem };
225
+ return { ok: true, commit: `sha256:${sha(jcs({ type: COMMIT_TYPE, role: side.role, ...covered(side) })).toString('hex')}` };
226
+ }
227
+
228
+ function message(value, step) {
229
+ if (!isObject(value) || own(value, 'schema') !== PAIRING_SCHEMA || own(value, 'step') !== step) return undefined;
230
+ const members = Object.keys(value).sort().join();
231
+ if (step === 'commit') return members === 'commit,role,schema,step' && matches(SHA256, value.commit) ? value : undefined;
232
+ return members === 'keys,nonce,role,schema,step' || members === 'genesis,keys,nonce,role,schema,step' ? value : undefined;
233
+ }
234
+
235
+ /** Check the other side's reveal against its commitment; answers the revealing machine's id. */
236
+ export function checkReveal(commit, reveal, expected) {
237
+ const c = message(commit, 'commit');
238
+ const r = message(reveal, 'reveal');
239
+ if (!c || !r) return { ok: false, problem: 'malformed' };
240
+ if (r.role !== c.role || (expected !== undefined && expected !== null && r.role !== expected)) return { ok: false, problem: 'role-mismatch' };
241
+ const { schema: _schema, step: _step, ...side } = r;
242
+ const made = pairingCommit(side);
243
+ if (!made.ok) return made;
244
+ if (made.commit !== c.commit) return { ok: false, problem: 'commit-mismatch' };
245
+ return { ok: true, machine: machineOfKeys(r.keys) };
246
+ }
247
+
248
+ /** The six-digit code both screens show. */
249
+ export function comparisonCode(newReveal, trustedReveal) {
250
+ const a = message(newReveal, 'reveal');
251
+ const b = message(trustedReveal, 'reveal');
252
+ if (!a || !b) return { ok: false, problem: 'malformed' };
253
+ if (a.role !== 'new' || b.role !== 'trusted') return { ok: false, problem: isRole(a.role) && isRole(b.role) ? 'role-mismatch' : 'bad-role' };
254
+ const problem = sideProblem(a) ?? sideProblem(b);
255
+ if (problem) return { ok: false, problem };
256
+ if (machineOfKeys(a.keys) === machineOfKeys(b.keys)) return { ok: false, problem: 'same-machine' };
257
+ const value = uint40(sha(jcs({ type: CODE_TYPE, new: covered(a), trusted: covered(b) })), 0) % 10 ** CODE_DIGITS;
258
+ return { ok: true, code: String(value).padStart(CODE_DIGITS, '0') };
259
+ }
260
+
261
+ /** The estate's fingerprint from revision 1's digest. */
262
+ export function estateFingerprint(digest) {
263
+ if (!matches(SHA256, digest)) return { ok: false, problem: 'malformed' };
264
+ const f = sha(jcs({ type: FINGERPRINT_TYPE, digest }));
265
+ return { ok: true, fingerprint: Array.from({ length: FINGERPRINT_GROUPS }, (_, i) => String(uint40(f, 5 * i) % 10 ** FINGERPRINT_GROUP_DIGITS).padStart(FINGERPRINT_GROUP_DIGITS, '0')).join(' ') };
266
+ }
267
+
268
+ /** The comparison code only after each reveal opened its own commitment. */
269
+ export function checkedComparisonCode(newCommit, newReveal, trustedCommit, trustedReveal) {
270
+ const a = checkReveal(newCommit, newReveal, 'new');
271
+ if (!a.ok) return a;
272
+ const b = checkReveal(trustedCommit, trustedReveal, 'trusted');
273
+ if (!b.ok) return b;
274
+ return comparisonCode(newReveal, trustedReveal);
275
+ }
276
+
277
+ /** A one-time rendezvous after its messages at `at` (a Date): open, complete, or burned with its reason and index. */
278
+ export function rendezvousVerdict(openedAt, messages, at) {
279
+ const opened = estateTime(openedAt);
280
+ const now = at instanceof Date ? at.getTime() : Number.NaN;
281
+ if (!Number.isFinite(opened) || !Number.isFinite(now) || !Array.isArray(messages)) return { state: 'burned', reason: 'malformed', index: -1 };
282
+ if (now > opened + RENDEZVOUS_SECONDS * 1000 || now < opened - CLOCK_SKEW_SECONDS * 1000) return { state: 'burned', reason: 'expired', index: -1 };
283
+ const commits = new Map();
284
+ const revealed = new Set();
285
+ for (const [index, item] of messages.entries()) {
286
+ const burn = (reason) => ({ state: 'burned', reason, index });
287
+ if (isObject(item) && own(item, 'schema') === PAIRING_SCHEMA && own(item, 'step') === 'abort' && Object.keys(item).sort().join() === 'role,schema,step' && isRole(item.role)) return burn('aborted');
288
+ if (revealed.size === 2) return burn('closed');
289
+ const commit = message(item, 'commit');
290
+ const reveal = message(item, 'reveal');
291
+ const role = (commit ?? reveal)?.role;
292
+ if (!isRole(role)) return burn('malformed');
293
+ if (commit) {
294
+ if (commits.has(role)) return burn('repeated');
295
+ commits.set(role, commit);
296
+ continue;
297
+ }
298
+ if (revealed.has(role)) return burn('repeated');
299
+ if (commits.size < 2) return burn('out-of-order');
300
+ const checked = checkReveal(commits.get(role), reveal, role);
301
+ if (!checked.ok) return burn(checked.problem === 'commit-mismatch' ? 'commit-mismatch' : 'malformed');
302
+ revealed.add(role);
303
+ }
304
+ return revealed.size === 2 ? { state: 'complete' } : { state: 'open' };
305
+ }
306
+
307
+ const machineOf = (record, id) => (Array.isArray(record.machines) ? record.machines.find((machine) => isObject(machine) && machine.id === id) : undefined);
308
+ const machineRevoked = (record, id) => Array.isArray(record.revocations) && record.revocations.some((entry) => isObject(entry) && entry.kind === 'machine' && entry.ref === id);
309
+ const sameKeys = (a, b) => a.length === b.length && a.every((key) => b.some((other) => jsonEqual(key, other)));
310
+
311
+ /** What the new machine checks before it pins and applies a revision that adds it. */
312
+ export function joinListed(record, newReveal, trustedReveal, genesis) {
313
+ const a = message(newReveal, 'reveal');
314
+ const b = message(trustedReveal, 'reveal');
315
+ if (!isObject(record) || !a || !b || a.role !== 'new' || b.role !== 'trusted' || sideProblem(a) || sideProblem(b)) return { ok: false, problem: 'malformed' };
316
+ if (b.genesis !== genesis) return { ok: false, problem: 'other-estate' };
317
+ const newId = machineOfKeys(a.keys);
318
+ const trustedId = machineOfKeys(b.keys);
319
+ const machine = machineOf(record, newId);
320
+ if (!machine || machineRevoked(record, newId)) return { ok: false, problem: 'not-listed' };
321
+ if (!Array.isArray(machine.deviceKeys) || !sameKeys(machine.deviceKeys, a.keys)) return { ok: false, problem: 'other-keys' };
322
+ const trusted = machineOf(record, trustedId);
323
+ const sig = b.keys.find((key) => key.use === 'sig');
324
+ if (!trusted || machineRevoked(record, trustedId) || !Array.isArray(trusted.deviceKeys) || !trusted.deviceKeys.some((key) => jsonEqual(key, sig))) return { ok: false, problem: 'trusted-not-listed' };
325
+ return { ok: true };
326
+ }
327
+
328
+ /** What a node does with a verified revision when its own keys are in question. */
329
+ export function ownKeysDecision(record, machineId, held) {
330
+ if (!isObject(record) || !isString(machineId) || !Array.isArray(held)) return { action: 'malformed' };
331
+ const machine = machineOf(record, machineId);
332
+ if (!machine || machineRevoked(record, machineId)) return { action: 'stop', reason: 'removed' };
333
+ const listed = Array.isArray(machine.deviceKeys) ? machine.deviceKeys : [];
334
+ if (listed.length === 0 || !listed.every((key) => held.some((mine) => jsonEqual(key, mine)))) return { action: 'stop', reason: 'keys-replaced' };
335
+ const retire = held.filter((key) => !listed.some((other) => jsonEqual(key, other))).map(deviceKeyRef).filter(isString);
336
+ return { action: 'apply', retire };
337
+ }
129
338
  // #endregion ceremonies-kit-node
@@ -412,7 +412,7 @@ export function deviceKeyRef(key) {
412
412
  return fingerprint !== undefined && isString(kid) ? `${kid}@${fingerprint}` : undefined;
413
413
  }
414
414
 
415
- const validDeviceKey = (key) => {
415
+ export const validDeviceKey = (key) => {
416
416
  const raw = Buffer.from(show(key.publicKey ?? ''), 'base64');
417
417
  return key.alg === 'ed25519' ? raw.length === 32 && !smallOrder(raw) : isValidP256Key(raw);
418
418
  };
@@ -10,6 +10,9 @@ no trust; only a passkey-signed revision that lists it does. Its ``genesis`` aut
10
10
  - ``verify_enroll_token(token, record=, genesis=, at=)``: the reference verifier, the hub's and the node's.
11
11
  - ``enroll_join_input(token)``: the ``enroll-join/1`` statement the joining machine signs with its ``#sig`` key.
12
12
  - ``enroll_token_rules("enroll-token", token)``: FAC-SEM-113.
13
+ - ``redeem_enroll_token(token, machine_key, proof, ledger)``: the hub's single use of a verified token.
14
+ - The nearby pairing: ``pairing_commit``, ``check_reveal``, ``comparison_code``, ``checked_comparison_code``,
15
+ ``estate_fingerprint``, ``rendezvous_verdict``, ``join_listed``, ``own_keys_decision`` (``machine_of_keys``).
13
16
 
14
17
  Every function answers and never raises on malformed input. Normative source: the contract's src/enroll-token.ts,
15
18
  src/signing-challenges.ts and docs/specification/ceremonies.md.
@@ -18,6 +21,7 @@ src/signing-challenges.ts and docs/specification/ceremonies.md.
18
21
 
19
22
  from __future__ import annotations
20
23
 
24
+ import base64
21
25
  import hashlib
22
26
  import re
23
27
  import sys
@@ -191,4 +195,298 @@ def enroll_token_rules(kind: str, value: Any) -> Optional[List[Dict[str, str]]]:
191
195
  if kind == "enroll-token":
192
196
  return _token_findings(value if isinstance(value, dict) else {})
193
197
  return None
198
+ # --- the hub's single use of a token (DEC-0084) ----------------------------------------------------------------------
199
+
200
+ _PROOF = re.compile(r"[A-Za-z0-9_-]{85}[AQgw]\Z")
201
+
202
+
203
+ def _b64url(text: str) -> bytes:
204
+ return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4))
205
+
206
+
207
+ def _es256_raw(octets: bytes, message: bytes, raw: bytes) -> bool:
208
+ """ECDSA P-256 over SHA-256 with a 64-byte r || s signature, low-S only (relay-auth/1's form)."""
209
+ if len(octets) != 65 or octets[0] != 4 or not _estate.is_valid_p256_key(octets) or len(raw) != 64:
210
+ return False
211
+ r, s = int.from_bytes(raw[:32], "big"), int.from_bytes(raw[32:], "big")
212
+ n = _estate._N
213
+ if not (1 <= r < n and 1 <= s <= n // 2):
214
+ return False
215
+ e = int.from_bytes(hashlib.sha256(message).digest(), "big")
216
+ w = pow(s, -1, n)
217
+ point = _estate._shamir(e * w % n, r * w % n, (int.from_bytes(octets[1:33], "big"), int.from_bytes(octets[33:], "big")))
218
+ return point is not None and point[0] % n == r
219
+
220
+
221
+ def _proves(key: Dict[str, Any], message: str, proof: Any) -> bool:
222
+ """Whether ``proof`` is the presented key's signature over ``message``: 64 bytes in their one canonical base64url
223
+ form, ES256 as r || s low-S, Ed25519 as itself."""
224
+ if not isinstance(proof, str) or not _PROOF.match(proof):
225
+ return False
226
+ octets = base64.b64decode(key["publicKey"])
227
+ signature = _b64url(proof)
228
+ if key.get("alg") == "es256":
229
+ return _es256_raw(octets, message.encode("utf-8"), signature)
230
+ return _delegation.ed25519_verify(octets, message.encode("utf-8"), signature)
231
+
232
+
233
+ def redeem_enroll_token(token: Any, machine_key: Any, proof: Any, ledger: Any) -> Dict[str, Any]:
234
+ """The hub's single use of a token verify_enroll_token accepted. ``machine_key`` is the joining machine's #sig key,
235
+ ``proof`` its signature over ``enroll_join_input(token)``, ``ledger`` the hub's dict from nonce to entry
236
+ (``{"state": "redeemed", "fingerprint"}`` or ``{"state": "revoked"}``). The first failure answers: malformed,
237
+ bad-key, bad-proof, revoked, reused (with the log the hub records); the first use answers the record to store."""
238
+ join = enroll_join_input(token)
239
+ if join is None or not isinstance(ledger, dict):
240
+ return {"ok": False, "problem": "malformed"}
241
+ nonce = _statement_fields(token)["nonce"]
242
+ if machine_of_keys([machine_key]) is None:
243
+ return {"ok": False, "problem": "bad-key"}
244
+ if not _proves(machine_key, join, proof):
245
+ return {"ok": False, "problem": "bad-proof"}
246
+ fingerprint = _estate.device_key_fingerprint(machine_key)
247
+ entry = ledger.get(nonce, _estate._MISSING)
248
+ if isinstance(entry, dict) and entry.get("state") == "revoked":
249
+ return {"ok": False, "problem": "revoked"}
250
+ if entry is not _estate._MISSING:
251
+ bound = _estate._show(entry.get("fingerprint", _estate._MISSING)) if isinstance(entry, dict) else _estate._show(entry)
252
+ return {"ok": False, "problem": "reused", "log": {"nonce": nonce, "presented": fingerprint, "boundTo": bound}}
253
+ return {"ok": True, "record": {"nonce": nonce, "state": "redeemed", "fingerprint": fingerprint}}
254
+
255
+
256
+ # --- the nearby pairing (DEC-0084) ------------------------------------------------------------------------------------
257
+
258
+ PAIRING_SCHEMA = "pairing-message/1"
259
+ COMMIT_TYPE = "fabric-enroll-commit/1"
260
+ CODE_TYPE = "fabric-enroll-code/1"
261
+ FINGERPRINT_TYPE = "estate-fingerprint/1"
262
+ CODE_DIGITS = 6
263
+ FINGERPRINT_GROUPS = 5
264
+ FINGERPRINT_GROUP_DIGITS = 5
265
+ RENDEZVOUS_SECONDS = 600
266
+ _MACHINE_ID = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:~-]{0,127}\Z")
267
+ _KEY_MEMBERS = ["alg", "kid", "publicKey", "use"]
268
+ _ALGORITHMS = {"sig": ("es256", "ed25519"), "kem": ("ecdh-p256",)}
269
+
270
+
271
+ def _json_equal(a: Any, b: Any) -> bool:
272
+ """The contract's jsonEqual: arrays in order, objects by their members, primitives as JavaScript's ===."""
273
+ if isinstance(a, list) or isinstance(b, list):
274
+ return isinstance(a, list) and isinstance(b, list) and len(a) == len(b) and all(_json_equal(x, y) for x, y in zip(a, b))
275
+ if isinstance(a, dict) or isinstance(b, dict):
276
+ return isinstance(a, dict) and isinstance(b, dict) and len(a) == len(b) and all(k in b and _json_equal(a[k], b[k]) for k in a)
277
+ return _estate._same(a, b)
278
+
279
+
280
+ def _is_role(value: Any) -> bool:
281
+ return value in ("new", "trusted") and isinstance(value, str)
282
+
283
+
284
+ def machine_of_keys(keys: Any) -> Optional[str]:
285
+ """The machine a key list belongs to, or None: one or two keys, each exactly {kid, use, alg, publicKey}, one sig
286
+ (es256 or ed25519) and at most one kem (ecdh-p256), each kid <id>#<use> for one machine id, each key valid and in
287
+ canonical padded base64, no key in both roles."""
288
+ if not isinstance(keys, list) or not 1 <= len(keys) <= 2:
289
+ return None
290
+ ids, uses, octets = set(), set(), set()
291
+ for key in keys:
292
+ if not isinstance(key, dict) or sorted(key) != _KEY_MEMBERS:
293
+ return None
294
+ use, kid, alg = key["use"], key["kid"], key["alg"]
295
+ if not isinstance(use, str) or not isinstance(kid, str) or not isinstance(alg, str) or alg not in _ALGORITHMS.get(use, ()):
296
+ return None
297
+ machine = kid[: -(len(use) + 1)] if kid.endswith("#" + use) else ""
298
+ if (not _MACHINE_ID.match(machine) or use in uses or _estate.device_key_fingerprint(key) is None
299
+ or not _estate._valid_device_key(key) or key["publicKey"] in octets):
300
+ return None
301
+ ids.add(machine)
302
+ uses.add(use)
303
+ octets.add(key["publicKey"])
304
+ return next(iter(ids)) if len(ids) == 1 and "sig" in uses else None
305
+
306
+
307
+ def _side_problem(side: Any) -> Optional[str]:
308
+ if not isinstance(side, dict):
309
+ return "malformed"
310
+ role = side.get("role")
311
+ if not _is_role(role):
312
+ return "bad-role"
313
+ if not _matches(_NONCE32, side.get("nonce")):
314
+ return "bad-nonce"
315
+ if machine_of_keys(side.get("keys")) is None:
316
+ return "bad-keys"
317
+ if role == "trusted":
318
+ if not _matches(_SHA256, side.get("genesis")):
319
+ return "bad-genesis"
320
+ elif "genesis" in side:
321
+ return "bad-genesis"
322
+ return None
323
+
324
+
325
+ def _covered(side: Dict[str, Any]) -> Dict[str, Any]:
326
+ if side["role"] == "trusted":
327
+ return {"keys": side["keys"], "nonce": side["nonce"], "genesis": side["genesis"]}
328
+ return {"keys": side["keys"], "nonce": side["nonce"]}
329
+
330
+
331
+ def _sha_hex(text: str) -> str:
332
+ return hashlib.sha256(text.encode("utf-8")).hexdigest()
333
+
334
+
335
+ def pairing_commit(side: Any) -> Dict[str, Any]:
336
+ """A side's commitment: sha256 of the RFC 8785 form of {type: fabric-enroll-commit/1, role, keys, nonce, genesis?}."""
337
+ problem = _side_problem(side)
338
+ if problem:
339
+ return {"ok": False, "problem": problem}
340
+ return {"ok": True, "commit": "sha256:" + _sha_hex(_delegation.jcs(dict({"type": COMMIT_TYPE, "role": side["role"]}, **_covered(side))))}
341
+
342
+
343
+ def _message(value: Any, step: str) -> Optional[Dict[str, Any]]:
344
+ if not isinstance(value, dict) or value.get("schema") != PAIRING_SCHEMA or value.get("step") != step:
345
+ return None
346
+ members = ",".join(sorted(value))
347
+ if step == "commit":
348
+ return value if members == "commit,role,schema,step" and _matches(_SHA256, value.get("commit")) else None
349
+ return value if members in ("keys,nonce,role,schema,step", "genesis,keys,nonce,role,schema,step") else None
350
+
351
+
352
+ def check_reveal(commit: Any, reveal: Any, expected: Optional[str] = None) -> Dict[str, Any]:
353
+ """Check the other side's reveal against its commitment; answers the revealing machine's id."""
354
+ c, r = _message(commit, "commit"), _message(reveal, "reveal")
355
+ if c is None or r is None:
356
+ return {"ok": False, "problem": "malformed"}
357
+ if not _estate._same(r["role"], c["role"]) or (expected is not None and not _estate._same(r["role"], expected)):
358
+ return {"ok": False, "problem": "role-mismatch"}
359
+ made = pairing_commit({k: v for k, v in r.items() if k not in ("schema", "step")})
360
+ if not made["ok"]:
361
+ return made
362
+ if made["commit"] != c["commit"]:
363
+ return {"ok": False, "problem": "commit-mismatch"}
364
+ return {"ok": True, "machine": machine_of_keys(r["keys"])}
365
+
366
+
367
+ def comparison_code(new_reveal: Any, trusted_reveal: Any) -> Dict[str, Any]:
368
+ """The six-digit code both screens show, over both reveals' keys, nonces and the estate's genesis."""
369
+ a, b = _message(new_reveal, "reveal"), _message(trusted_reveal, "reveal")
370
+ if a is None or b is None:
371
+ return {"ok": False, "problem": "malformed"}
372
+ if not _estate._same(a["role"], "new") or not _estate._same(b["role"], "trusted"):
373
+ return {"ok": False, "problem": "role-mismatch" if _is_role(a["role"]) and _is_role(b["role"]) else "bad-role"}
374
+ problem = _side_problem(a) or _side_problem(b)
375
+ if problem:
376
+ return {"ok": False, "problem": problem}
377
+ if machine_of_keys(a["keys"]) == machine_of_keys(b["keys"]):
378
+ return {"ok": False, "problem": "same-machine"}
379
+ digest = hashlib.sha256(_delegation.jcs({"type": CODE_TYPE, "new": _covered(a), "trusted": _covered(b)}).encode("utf-8")).digest()
380
+ return {"ok": True, "code": str(int.from_bytes(digest[:5], "big") % 10 ** CODE_DIGITS).zfill(CODE_DIGITS)}
381
+
382
+
383
+ def estate_fingerprint(digest: Any) -> Dict[str, Any]:
384
+ """The estate's fingerprint from revision 1's digest: five groups of five digits a person compares."""
385
+ if not _matches(_SHA256, digest):
386
+ return {"ok": False, "problem": "malformed"}
387
+ f = hashlib.sha256(_delegation.jcs({"type": FINGERPRINT_TYPE, "digest": digest}).encode("utf-8")).digest()
388
+ groups = [str(int.from_bytes(f[5 * i:5 * i + 5], "big") % 10 ** FINGERPRINT_GROUP_DIGITS).zfill(FINGERPRINT_GROUP_DIGITS) for i in range(FINGERPRINT_GROUPS)]
389
+ return {"ok": True, "fingerprint": " ".join(groups)}
390
+
391
+
392
+ def checked_comparison_code(new_commit: Any, new_reveal: Any, trusted_commit: Any, trusted_reveal: Any) -> Dict[str, Any]:
393
+ """The comparison code only after each reveal opened its own commitment."""
394
+ a = check_reveal(new_commit, new_reveal, "new")
395
+ if not a["ok"]:
396
+ return a
397
+ b = check_reveal(trusted_commit, trusted_reveal, "trusted")
398
+ if not b["ok"]:
399
+ return b
400
+ return comparison_code(new_reveal, trusted_reveal)
401
+
402
+
403
+ def rendezvous_verdict(opened_at: Any, messages: Any, at: Any) -> Dict[str, Any]:
404
+ """A one-time rendezvous after the messages it carried, at ``at`` (an aware datetime or epoch seconds): open,
405
+ complete, or burned with the reason and the index of the message that burned it."""
406
+ opened = _estate.estate_time(opened_at)
407
+ now = _estate._clock(at)
408
+ if not _finite(opened) or not _finite(now) or not isinstance(messages, list):
409
+ return {"state": "burned", "reason": "malformed", "index": -1}
410
+ if now > opened + RENDEZVOUS_SECONDS * 1000 or now < opened - _estate.CLOCK_SKEW_SECONDS * 1000:
411
+ return {"state": "burned", "reason": "expired", "index": -1}
412
+ commits: Dict[str, Any] = {}
413
+ revealed: set = set()
414
+ for index, item in enumerate(messages):
415
+ def burn(reason: str) -> Dict[str, Any]:
416
+ return {"state": "burned", "reason": reason, "index": index}
417
+ if (isinstance(item, dict) and item.get("schema") == PAIRING_SCHEMA and item.get("step") == "abort"
418
+ and sorted(item) == ["role", "schema", "step"] and _is_role(item.get("role"))):
419
+ return burn("aborted")
420
+ if len(revealed) == 2:
421
+ return burn("closed")
422
+ commit, reveal = _message(item, "commit"), _message(item, "reveal")
423
+ role = (commit or reveal or {}).get("role")
424
+ if not _is_role(role):
425
+ return burn("malformed")
426
+ if commit is not None:
427
+ if role in commits:
428
+ return burn("repeated")
429
+ commits[role] = commit
430
+ continue
431
+ if role in revealed:
432
+ return burn("repeated")
433
+ if len(commits) < 2:
434
+ return burn("out-of-order")
435
+ checked = check_reveal(commits.get(role), reveal, role)
436
+ if not checked["ok"]:
437
+ return burn("commit-mismatch" if checked["problem"] == "commit-mismatch" else "malformed")
438
+ revealed.add(role)
439
+ return {"state": "complete"} if len(revealed) == 2 else {"state": "open"}
440
+
441
+
442
+ def _machine_of(record: Dict[str, Any], machine_id: Any) -> Optional[Dict[str, Any]]:
443
+ machines = record.get("machines")
444
+ if not isinstance(machines, list):
445
+ return None
446
+ return next((m for m in machines if isinstance(m, dict) and _estate._same(m.get("id", _estate._MISSING), machine_id)), None)
447
+
448
+
449
+ def _machine_revoked(record: Dict[str, Any], machine_id: Any) -> bool:
450
+ revocations = record.get("revocations")
451
+ return isinstance(revocations, list) and any(isinstance(e, dict) and e.get("kind") == "machine"
452
+ and _estate._same(e.get("ref", _estate._MISSING), machine_id) for e in revocations)
453
+
454
+
455
+ def join_listed(record: Any, new_reveal: Any, trusted_reveal: Any, genesis: Any) -> Dict[str, Any]:
456
+ """What the new machine checks before it pins and applies: the estate is the one the code bound, and the record
457
+ lists it with exactly the keys it revealed and the trusted machine with its revealed #sig key, revoking neither."""
458
+ a, b = _message(new_reveal, "reveal"), _message(trusted_reveal, "reveal")
459
+ if (not isinstance(record, dict) or a is None or b is None or not _estate._same(a["role"], "new")
460
+ or not _estate._same(b["role"], "trusted") or _side_problem(a) or _side_problem(b)):
461
+ return {"ok": False, "problem": "malformed"}
462
+ if not isinstance(genesis, str) or b["genesis"] != genesis:
463
+ return {"ok": False, "problem": "other-estate"}
464
+ new_id, trusted_id = machine_of_keys(a["keys"]), machine_of_keys(b["keys"])
465
+ machine = _machine_of(record, new_id)
466
+ if machine is None or _machine_revoked(record, new_id):
467
+ return {"ok": False, "problem": "not-listed"}
468
+ listed = machine.get("deviceKeys")
469
+ if not isinstance(listed, list) or len(listed) != len(a["keys"]) or not all(any(_json_equal(k, o) for o in a["keys"]) for k in listed):
470
+ return {"ok": False, "problem": "other-keys"}
471
+ trusted = _machine_of(record, trusted_id)
472
+ sig = next(k for k in b["keys"] if k["use"] == "sig")
473
+ if (trusted is None or _machine_revoked(record, trusted_id) or not isinstance(trusted.get("deviceKeys"), list)
474
+ or not any(_json_equal(k, sig) for k in trusted["deviceKeys"])):
475
+ return {"ok": False, "problem": "trusted-not-listed"}
476
+ return {"ok": True}
477
+
478
+
479
+ def own_keys_decision(record: Any, machine_id: Any, held: Any) -> Dict[str, Any]:
480
+ """What a node does with a verified revision when its own keys are in question: apply (retiring the held keys the
481
+ record no longer lists, by key reference), stop as removed, stop as keys-replaced, or malformed."""
482
+ if not isinstance(record, dict) or not isinstance(machine_id, str) or not isinstance(held, list):
483
+ return {"action": "malformed"}
484
+ machine = _machine_of(record, machine_id)
485
+ if machine is None or _machine_revoked(record, machine_id):
486
+ return {"action": "stop", "reason": "removed"}
487
+ listed = machine.get("deviceKeys") if isinstance(machine.get("deviceKeys"), list) else []
488
+ if not listed or not all(any(_json_equal(k, mine) for mine in held) for k in listed):
489
+ return {"action": "stop", "reason": "keys-replaced"}
490
+ retire = [ref for ref in (_estate.device_key_ref(k) for k in held if not any(_json_equal(k, o) for o in listed)) if isinstance(ref, str)]
491
+ return {"action": "apply", "retire": retire}
194
492
  # #endregion ceremonies-kit
@@ -5,7 +5,7 @@ license: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
5
5
  compatibility: Requires filesystem access and Python 3.9+. Exact schema checks additionally need git, Node.js, pnpm, and the pinned fabric-agent-contract checkout (a public repository). Works without those tools in an explicitly degraded structural-check mode. Ships in one plugin with adapting-projects-to-fabric, whose scripts it reuses.
6
6
  metadata:
7
7
  author: PassionCode.ai
8
- version: "0.37.0"
8
+ version: "0.37.1"
9
9
  contract-version: "0.1.0"
10
10
  contract-commit: "07042181bfae2a481aa0da1a5651f24c76669c48"
11
11
  ---
@@ -19,7 +19,7 @@ compatibility: >-
19
19
  this plugin; Codex/Kimi use command hooks; Hermes/OpenCode/OpenClaw/Pi use native context plugins after opt-in.
20
20
  metadata:
21
21
  author: PassionCode.ai
22
- version: "0.37.0"
22
+ version: "0.37.1"
23
23
  ---
24
24
 
25
25
  # Working with Fabric
@@ -66,7 +66,7 @@ WATCH_MAX_BACKOFF_S = 900
66
66
  SESSION_ID = re.compile(r"[A-Za-z0-9._:-]{1,128}")
67
67
  LOOPBACK_ORIGIN = re.compile(r"http://127\.0\.0\.1:(\d{1,5})")
68
68
  LOCAL_ORIGIN = re.compile(r'"origin": "http://(?:127\.0\.0\.1|localhost)')
69
- VERSION = "0.37.0"
69
+ VERSION = "0.37.1"
70
70
  HOSTS = {"claude": "Claude Code", "codex": "Codex", "kimi": "Kimi Code",
71
71
  "hermes": "Hermes", "opencode": "OpenCode", "openclaw": "OpenClaw", "pi": "Pi"}
72
72
  NATIVE_PLUGIN_HOSTS = frozenset(("hermes", "opencode", "openclaw", "pi"))