claude-flow 3.39.2 → 3.40.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,447 @@
1
+ /**
2
+ * agentbbs Phase 2 — cross-host federation.
3
+ *
4
+ * Phase 1 (`agentbbs-tools.ts`) gives every host an append-only room log at
5
+ * `<basePath>/room-<roomId>.jsonl`, and derives `roomId` deterministically from
6
+ * the room label. Two independent hosts that register `#sales` therefore
7
+ * compute the *same* roomId without ever talking to each other. That is the
8
+ * property this module builds on.
9
+ *
10
+ * ## Why a signed union-merge, and not a consensus protocol
11
+ *
12
+ * A room log is an append-only set of immutable envelopes. Two hosts that each
13
+ * append locally hold two subsets of the same logical set, so reconciling them
14
+ * is a set union — there is no conflicting write to arbitrate, and therefore
15
+ * nothing for a consensus round to decide. Union is commutative, associative
16
+ * and idempotent, which makes sync order-independent and safe to retry: pulling
17
+ * the same peer twice, or pulling A-then-B versus B-then-A, converges on the
18
+ * same log. That is a CRDT (a grow-only set keyed by `envelopeId`), and it is
19
+ * strictly cheaper and less failure-prone than the Byzantine agreement the
20
+ * plugin README gestures at.
21
+ *
22
+ * What union does *not* give you is authenticity. If any peer can inject an
23
+ * envelope, the merge faithfully replicates forgeries. So every envelope is
24
+ * Ed25519-signed by its originating node, and a receiver verifies the signature
25
+ * against the *pinned* public key it recorded when the peer was added — not
26
+ * against a key carried in the envelope, which would let an attacker sign with
27
+ * their own key and claim any origin. Trust is pinned at peer-add time; the
28
+ * wire is treated as hostile.
29
+ *
30
+ * ## Transport
31
+ *
32
+ * Pull-based HTTP. A node serves `GET /agentbbs/v1/rooms/:roomId/envelopes?since=N`
33
+ * and peers poll it. Pull is deliberate: it needs no inbound connectivity from
34
+ * the peer's side, survives a node being offline (it just catches up later),
35
+ * and gives the *receiver* control over how much it ingests. Push would invert
36
+ * that and make every node an unauthenticated write target.
37
+ *
38
+ * Designed for a private overlay (Tailscale/WireGuard) where the network
39
+ * already authenticates the host. Signatures mean a compromised or hostile peer
40
+ * still cannot forge another node's envelopes. See `bindHost` on serve() — it
41
+ * binds loopback by default, and binding a routable interface is an explicit
42
+ * operator choice.
43
+ *
44
+ * ## Bounds
45
+ *
46
+ * Every ingest path is bounded: envelope count per sync, byte size per envelope,
47
+ * total bytes per response, peer count, and hop count. An unbounded merge from
48
+ * an untrusted peer is a memory-exhaustion primitive, so limits are enforced on
49
+ * the *receiving* side where they cannot be negotiated away by the sender.
50
+ *
51
+ * @module @claude-flow/cli/mcp-tools/agentbbs-federation
52
+ */
53
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, appendFileSync, chmodSync } from 'node:fs';
54
+ import { join } from 'node:path';
55
+ import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
56
+ import { createServer } from 'node:http';
57
+ /** Max envelopes accepted from one peer in one sync. */
58
+ export const MAX_ENVELOPES_PER_SYNC = 5_000;
59
+ /** Max serialized bytes for a single envelope. */
60
+ export const MAX_ENVELOPE_BYTES = 64 * 1024;
61
+ /** Max total bytes read from one peer response. */
62
+ export const MAX_SYNC_RESPONSE_BYTES = 16 * 1024 * 1024;
63
+ /** Max peers in the registry. */
64
+ export const MAX_PEERS = 256;
65
+ /** Max federation hops before an envelope stops propagating. */
66
+ export const MAX_HOPS = 8;
67
+ /** Per-request timeout when pulling from a peer. */
68
+ export const SYNC_TIMEOUT_MS = 15_000;
69
+ const ROOM_ID_RE = /^[A-Za-z0-9_.\-:/@#]+$/;
70
+ const NODE_ID_RE = /^[0-9a-f]{16}$/;
71
+ const HEX64_RE = /^[0-9a-f]{64}$/;
72
+ let _edMod = null;
73
+ async function loadEd25519() {
74
+ if (!_edMod)
75
+ _edMod = await import('@noble/ed25519');
76
+ return _edMod;
77
+ }
78
+ function ensureDir(dir) {
79
+ if (!existsSync(dir))
80
+ mkdirSync(dir, { recursive: true });
81
+ }
82
+ function hex(buf) {
83
+ return Buffer.from(buf).toString('hex');
84
+ }
85
+ function unhex(s) {
86
+ return new Uint8Array(Buffer.from(s, 'hex'));
87
+ }
88
+ /**
89
+ * Deterministic byte string an envelope's signature covers.
90
+ *
91
+ * Field order is fixed here rather than relying on `JSON.stringify` key order,
92
+ * because a receiver must reconstruct byte-identical input from a payload that
93
+ * survived a JSON round trip. `payload` is canonicalized recursively with
94
+ * sorted keys for the same reason: `{a:1,b:2}` and `{b:2,a:1}` are the same
95
+ * value and must not produce different signatures.
96
+ *
97
+ * `hops` is deliberately excluded — it is mutated in transit by design, so
98
+ * including it would invalidate the signature at the first relay.
99
+ */
100
+ export function canonicalEnvelopeBytes(env) {
101
+ const canon = (v) => {
102
+ if (v === null || typeof v !== 'object')
103
+ return v;
104
+ if (Array.isArray(v))
105
+ return v.map(canon);
106
+ const out = {};
107
+ for (const k of Object.keys(v).sort()) {
108
+ out[k] = canon(v[k]);
109
+ }
110
+ return out;
111
+ };
112
+ const material = JSON.stringify({
113
+ envelopeId: env.envelopeId,
114
+ roomId: env.roomId,
115
+ seq: env.seq,
116
+ msgType: env.msgType,
117
+ timestamp: env.timestamp,
118
+ origin: env.origin ?? '',
119
+ payload: canon(env.payload),
120
+ });
121
+ return new TextEncoder().encode(material);
122
+ }
123
+ function identityPath(basePath) {
124
+ return join(basePath, 'node-identity.json');
125
+ }
126
+ function peersPath(basePath) {
127
+ return join(basePath, 'peers.json');
128
+ }
129
+ function roomLogPath(basePath, roomId) {
130
+ return join(basePath, `room-${roomId}.jsonl`);
131
+ }
132
+ /**
133
+ * Load or create this host's long-lived Ed25519 identity.
134
+ *
135
+ * Phase 1 minted an ephemeral key per process, which is fine for local token
136
+ * signing but useless across hosts: a peer cannot pin a key that changes on
137
+ * every restart. This persists one, 0600, and derives a stable nodeId from the
138
+ * public key so identity is verifiable rather than self-asserted.
139
+ */
140
+ export async function getNodeIdentity(basePath) {
141
+ ensureDir(basePath);
142
+ const p = identityPath(basePath);
143
+ if (existsSync(p)) {
144
+ const parsed = JSON.parse(readFileSync(p, 'utf-8'));
145
+ if (!NODE_ID_RE.test(parsed.nodeId ?? '') || !HEX64_RE.test(parsed.publicKey ?? '')) {
146
+ throw new Error('node-identity.json is malformed');
147
+ }
148
+ return parsed;
149
+ }
150
+ const ed = await loadEd25519();
151
+ const priv = ed.utils?.randomPrivateKey ? ed.utils.randomPrivateKey() : new Uint8Array(randomBytes(32));
152
+ const pub = await (ed.getPublicKeyAsync ?? ed.getPublicKey)(priv);
153
+ const publicKey = hex(pub);
154
+ const identity = {
155
+ nodeId: createHash('sha256').update(`agentbbs:node:${publicKey}`).digest('hex').slice(0, 16),
156
+ publicKey,
157
+ privateKey: hex(priv),
158
+ createdAt: new Date().toISOString(),
159
+ };
160
+ writeFileSync(p, JSON.stringify(identity, null, 2) + '\n', { mode: 0o600 });
161
+ try {
162
+ chmodSync(p, 0o600);
163
+ }
164
+ catch { /* best effort on filesystems without modes */ }
165
+ return identity;
166
+ }
167
+ export async function signEnvelope(basePath, env) {
168
+ const id = await getNodeIdentity(basePath);
169
+ const ed = await loadEd25519();
170
+ const withOrigin = { ...env, origin: id.nodeId, hops: env.hops ?? 0 };
171
+ const sig = await (ed.signAsync ?? ed.sign)(canonicalEnvelopeBytes(withOrigin), unhex(id.privateKey));
172
+ return { ...withOrigin, signature: hex(sig) };
173
+ }
174
+ /**
175
+ * Verify an envelope against a public key the caller already trusts.
176
+ *
177
+ * The key is passed in rather than read from the envelope on purpose: an
178
+ * envelope-carried key proves only that the sender holds *a* key, not that they
179
+ * are who the `origin` field claims. Callers pass the key pinned at peer-add.
180
+ */
181
+ export async function verifyEnvelope(env, publicKeyHex) {
182
+ if (!env.signature || !HEX64_RE.test(publicKeyHex))
183
+ return false;
184
+ if (!/^[0-9a-f]{128}$/.test(env.signature))
185
+ return false;
186
+ try {
187
+ const ed = await loadEd25519();
188
+ return await (ed.verifyAsync ?? ed.verify)(unhex(env.signature), canonicalEnvelopeBytes(env), unhex(publicKeyHex));
189
+ }
190
+ catch {
191
+ return false;
192
+ }
193
+ }
194
+ export function readPeers(basePath) {
195
+ const p = peersPath(basePath);
196
+ if (!existsSync(p))
197
+ return [];
198
+ try {
199
+ const parsed = JSON.parse(readFileSync(p, 'utf-8'));
200
+ return Array.isArray(parsed) ? parsed : [];
201
+ }
202
+ catch {
203
+ return [];
204
+ }
205
+ }
206
+ function writePeers(basePath, peers) {
207
+ ensureDir(basePath);
208
+ writeFileSync(peersPath(basePath), JSON.stringify(peers, null, 2) + '\n');
209
+ }
210
+ /**
211
+ * Reject anything that is not a plain http(s) URL to a host.
212
+ *
213
+ * Blocks credentials-in-URL (they would be logged), and non-http schemes such
214
+ * as `file:` which would turn a peer entry into a local file read.
215
+ */
216
+ export function validatePeerUrl(raw) {
217
+ let u;
218
+ try {
219
+ u = new URL(raw);
220
+ }
221
+ catch {
222
+ throw new Error('peer url is not a valid URL');
223
+ }
224
+ if (u.protocol !== 'http:' && u.protocol !== 'https:') {
225
+ throw new Error('peer url must be http or https');
226
+ }
227
+ if (u.username || u.password)
228
+ throw new Error('peer url must not embed credentials');
229
+ return u.origin;
230
+ }
231
+ export function addPeer(basePath, input) {
232
+ if (!NODE_ID_RE.test(input.nodeId ?? ''))
233
+ throw new Error('nodeId must be 16 lowercase hex chars');
234
+ if (!HEX64_RE.test(input.publicKey ?? ''))
235
+ throw new Error('publicKey must be 64 lowercase hex chars');
236
+ const url = validatePeerUrl(String(input.url));
237
+ const peers = readPeers(basePath);
238
+ if (peers.length >= MAX_PEERS)
239
+ throw new Error(`peer registry is full (${MAX_PEERS})`);
240
+ const existing = peers.find(p => p.nodeId === input.nodeId);
241
+ if (existing) {
242
+ // Re-pinning a different key for a known nodeId is how a key-substitution
243
+ // attack would present. Require an explicit remove first.
244
+ if (existing.publicKey !== input.publicKey) {
245
+ throw new Error(`nodeId ${input.nodeId} is already pinned to a different publicKey; remove it first`);
246
+ }
247
+ existing.url = url;
248
+ if (input.label)
249
+ existing.label = input.label;
250
+ writePeers(basePath, peers);
251
+ return existing;
252
+ }
253
+ const peer = {
254
+ nodeId: input.nodeId,
255
+ url,
256
+ publicKey: input.publicKey,
257
+ label: input.label,
258
+ addedAt: new Date().toISOString(),
259
+ lastSeq: {},
260
+ };
261
+ peers.push(peer);
262
+ writePeers(basePath, peers);
263
+ return peer;
264
+ }
265
+ export function removePeer(basePath, nodeId) {
266
+ const peers = readPeers(basePath);
267
+ const next = peers.filter(p => p.nodeId !== nodeId);
268
+ if (next.length === peers.length)
269
+ return false;
270
+ writePeers(basePath, next);
271
+ return true;
272
+ }
273
+ export function readEnvelopes(basePath, roomId) {
274
+ const p = roomLogPath(basePath, roomId);
275
+ if (!existsSync(p))
276
+ return [];
277
+ const out = [];
278
+ for (const line of readFileSync(p, 'utf-8').split(/\r?\n/)) {
279
+ if (!line.trim())
280
+ continue;
281
+ try {
282
+ out.push(JSON.parse(line));
283
+ }
284
+ catch { /* skip malformed */ }
285
+ }
286
+ return out;
287
+ }
288
+ export function validateRoomId(roomId) {
289
+ if (!roomId || typeof roomId !== 'string')
290
+ throw new Error('roomId is required');
291
+ if (roomId.length > 128)
292
+ throw new Error('roomId exceeds 128 chars');
293
+ // Also blocks path traversal: `.` is allowed but `/` segments cannot form
294
+ // `..` without tripping the explicit check below.
295
+ if (!ROOM_ID_RE.test(roomId))
296
+ throw new Error('roomId has invalid characters');
297
+ if (roomId.includes('..'))
298
+ throw new Error('roomId must not contain ..');
299
+ return roomId;
300
+ }
301
+ /**
302
+ * Union-merge verified envelopes from a peer into the local room log.
303
+ *
304
+ * Idempotent: `envelopeId` is the merge key, so replaying the same batch is a
305
+ * no-op. Anything that fails verification is dropped and counted rather than
306
+ * quarantined — a receiver has no use for an envelope it cannot attribute.
307
+ */
308
+ export async function mergeEnvelopes(basePath, roomId, incoming, peerPublicKey) {
309
+ validateRoomId(roomId);
310
+ ensureDir(basePath);
311
+ const result = {
312
+ merged: 0, skippedDuplicate: 0, skippedUnverified: 0, skippedOversize: 0, skippedHopLimit: 0,
313
+ };
314
+ const existing = readEnvelopes(basePath, roomId);
315
+ const seen = new Set(existing.map(e => e.envelopeId));
316
+ const logPath = roomLogPath(basePath, roomId);
317
+ const batch = incoming.slice(0, MAX_ENVELOPES_PER_SYNC);
318
+ for (const env of batch) {
319
+ if (!env || typeof env !== 'object' || typeof env.envelopeId !== 'string') {
320
+ result.skippedUnverified++;
321
+ continue;
322
+ }
323
+ if (env.roomId !== roomId) {
324
+ result.skippedUnverified++;
325
+ continue;
326
+ }
327
+ if (seen.has(env.envelopeId)) {
328
+ result.skippedDuplicate++;
329
+ continue;
330
+ }
331
+ if (JSON.stringify(env).length > MAX_ENVELOPE_BYTES) {
332
+ result.skippedOversize++;
333
+ continue;
334
+ }
335
+ if ((env.hops ?? 0) >= MAX_HOPS) {
336
+ result.skippedHopLimit++;
337
+ continue;
338
+ }
339
+ if (!(await verifyEnvelope(env, peerPublicKey))) {
340
+ result.skippedUnverified++;
341
+ continue;
342
+ }
343
+ appendFileSync(logPath, JSON.stringify({ ...env, hops: (env.hops ?? 0) + 1 }) + '\n');
344
+ seen.add(env.envelopeId);
345
+ result.merged++;
346
+ }
347
+ return result;
348
+ }
349
+ /** Pull one room from one peer and merge what verifies. */
350
+ export async function syncRoomFromPeer(basePath, peer, roomId, fetchImpl = fetch) {
351
+ validateRoomId(roomId);
352
+ const since = peer.lastSeq?.[roomId] ?? 0;
353
+ const url = `${peer.url}/agentbbs/v1/rooms/${encodeURIComponent(roomId)}/envelopes?since=${since}`;
354
+ const controller = new AbortController();
355
+ const timer = setTimeout(() => controller.abort(), SYNC_TIMEOUT_MS);
356
+ let body;
357
+ try {
358
+ const res = await fetchImpl(url, { signal: controller.signal, headers: { accept: 'application/json' } });
359
+ if (!res.ok)
360
+ throw new Error(`peer ${peer.nodeId} returned ${res.status}`);
361
+ const text = await res.text();
362
+ if (text.length > MAX_SYNC_RESPONSE_BYTES)
363
+ throw new Error(`peer ${peer.nodeId} response exceeds size cap`);
364
+ body = JSON.parse(text);
365
+ }
366
+ finally {
367
+ clearTimeout(timer);
368
+ }
369
+ const envelopes = Array.isArray(body?.envelopes) ? body.envelopes : [];
370
+ const merged = await mergeEnvelopes(basePath, roomId, envelopes, peer.publicKey);
371
+ const peers = readPeers(basePath);
372
+ const rec = peers.find(p => p.nodeId === peer.nodeId);
373
+ if (rec) {
374
+ rec.lastSyncedAt = new Date().toISOString();
375
+ rec.lastSeq = rec.lastSeq ?? {};
376
+ const maxSeq = envelopes.reduce((m, e) => Math.max(m, Number(e.seq) || 0), since);
377
+ rec.lastSeq[roomId] = maxSeq;
378
+ writePeers(basePath, peers);
379
+ }
380
+ return { ...merged, peerNodeId: peer.nodeId, roomId };
381
+ }
382
+ /**
383
+ * Serve this node's room logs for peers to pull.
384
+ *
385
+ * Binds loopback unless the caller explicitly asks otherwise, so starting a
386
+ * server never silently exposes room contents on a routable interface. Read
387
+ * only by construction: there is no route that mutates state, which removes the
388
+ * whole class of unauthenticated-write attacks that a push design would open.
389
+ */
390
+ export function serveFederation(basePath, opts = {}) {
391
+ const host = opts.bindHost ?? '127.0.0.1';
392
+ const server = createServer((req, res) => {
393
+ const send = (code, obj) => {
394
+ const buf = Buffer.from(JSON.stringify(obj));
395
+ res.writeHead(code, { 'content-type': 'application/json', 'content-length': buf.length });
396
+ res.end(buf);
397
+ };
398
+ try {
399
+ if (req.method !== 'GET')
400
+ return send(405, { error: 'method-not-allowed' });
401
+ const u = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`);
402
+ if (u.pathname === '/agentbbs/v1/identity') {
403
+ const p = identityPath(basePath);
404
+ if (!existsSync(p))
405
+ return send(404, { error: 'no-identity' });
406
+ const id = JSON.parse(readFileSync(p, 'utf-8'));
407
+ // Never serve privateKey.
408
+ return send(200, { nodeId: id.nodeId, publicKey: id.publicKey, createdAt: id.createdAt });
409
+ }
410
+ const m = u.pathname.match(/^\/agentbbs\/v1\/rooms\/([^/]+)\/envelopes$/);
411
+ if (m) {
412
+ let roomId;
413
+ try {
414
+ roomId = validateRoomId(decodeURIComponent(m[1]));
415
+ }
416
+ catch (e) {
417
+ return send(400, { error: e.message });
418
+ }
419
+ const since = Math.max(0, Math.trunc(Number(u.searchParams.get('since') ?? 0)) || 0);
420
+ const all = readEnvelopes(basePath, roomId);
421
+ const out = all.filter(e => (Number(e.seq) || 0) > since).slice(0, MAX_ENVELOPES_PER_SYNC);
422
+ return send(200, { roomId, since, count: out.length, envelopes: out });
423
+ }
424
+ return send(404, { error: 'not-found' });
425
+ }
426
+ catch (e) {
427
+ return send(500, { error: 'internal' });
428
+ }
429
+ });
430
+ return new Promise((resolve, reject) => {
431
+ server.once('error', reject);
432
+ server.listen(opts.port ?? 0, host, () => {
433
+ const addr = server.address();
434
+ const port = typeof addr === 'object' && addr ? addr.port : 0;
435
+ resolve({ server, port, host });
436
+ });
437
+ });
438
+ }
439
+ /** Constant-time compare for any future shared-secret paths. */
440
+ export function safeEqual(a, b) {
441
+ const ab = Buffer.from(a);
442
+ const bb = Buffer.from(b);
443
+ if (ab.length !== bb.length)
444
+ return false;
445
+ return timingSafeEqual(ab, bb);
446
+ }
447
+ //# sourceMappingURL=agentbbs-federation.js.map
@@ -21,6 +21,17 @@
21
21
  * so callers see one contract regardless of install state
22
22
  * - Phase 1: polling-based watch (streaming subscriptions are Phase 4)
23
23
  *
24
+ * Presence check: the published `agentbbs` package (as of 0.2.1) is a CLI-only
25
+ * launcher — a `bin` script with no importable JS entry point (no `main` /
26
+ * `exports` field) — that downloads/builds a Rust binary and shells out to it.
27
+ * `await import('agentbbs')` can therefore never resolve, even when the CLI is
28
+ * correctly installed and on PATH: there is no module for Node to find. This
29
+ * previously made every tool report `degraded: true` unconditionally,
30
+ * regardless of install state. Detect real availability instead by probing the
31
+ * CLI itself, matching how the rest of this codebase treats optional
32
+ * command-line tools (`shutil.which()`-style guards) rather than assuming a
33
+ * package shape the dependency doesn't have.
34
+ *
24
35
  * @module @claude-flow/cli/mcp-tools/agentbbs
25
36
  */
26
37
  import type { MCPTool } from './types.js';