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.
@@ -21,33 +21,51 @@
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 { existsSync, mkdirSync, readFileSync, writeFileSync, appendFileSync } from 'node:fs';
27
38
  import { resolve, isAbsolute, join } from 'node:path';
28
39
  import { randomBytes, createHash } from 'node:crypto';
40
+ import { execFileSync } from 'node:child_process';
41
+ import { getNodeIdentity, signEnvelope, addPeer, removePeer, readPeers, syncRoomFromPeer, serveFederation, validateRoomId as fedValidateRoomId, MAX_PEERS, MAX_HOPS, } from './agentbbs-federation.js';
29
42
  import { getProjectCwd } from './types.js';
30
- const PACKAGE_NAME = 'agentbbs';
31
- // Cache: amortize dynamic-import cost across handler calls.
32
- // null = not yet attempted; false = unavailable; module = loaded.
33
- let _agentbbsMod = null;
34
- let _loadAttempted = false;
35
- async function loadAgentbbs() {
36
- if (_loadAttempted)
37
- return _agentbbsMod || null;
38
- _loadAttempted = true;
43
+ const CLI_NAME = 'agentbbs';
44
+ // Cache: amortize the subprocess probe cost across handler calls within a
45
+ // process. null = not yet probed; boolean = probe result.
46
+ let _cliAvailable = null;
47
+ function agentbbsCliAvailable() {
48
+ if (_cliAvailable !== null)
49
+ return _cliAvailable;
50
+ const bin = process.env.AGENTBBS_BIN || CLI_NAME;
39
51
  try {
40
- _agentbbsMod = await import(PACKAGE_NAME);
41
- return _agentbbsMod;
52
+ // On Windows npm's global-install shim is `agentbbs.cmd`, and
53
+ // child_process cannot spawn a .cmd without going through cmd.exe, which
54
+ // is what resolves the PATHEXT extension. POSIX keeps shell:false so the
55
+ // AGENTBBS_BIN override is never shell-interpreted — same convention as
56
+ // browser-tools.ts and commands/init.ts.
57
+ execFileSync(bin, ['--version'], {
58
+ stdio: 'ignore',
59
+ timeout: 5000,
60
+ shell: process.platform === 'win32',
61
+ windowsHide: true,
62
+ });
63
+ _cliAvailable = true;
42
64
  }
43
- catch (err) {
44
- if (err && (err.code === 'ERR_MODULE_NOT_FOUND' || err.code === 'MODULE_NOT_FOUND' ||
45
- /Cannot find (module|package)/i.test(String(err?.message)))) {
46
- _agentbbsMod = false;
47
- return null;
48
- }
49
- throw err;
65
+ catch {
66
+ _cliAvailable = false;
50
67
  }
68
+ return _cliAvailable;
51
69
  }
52
70
  function degradedResult(reason) {
53
71
  return { success: true, degraded: true, reason };
@@ -128,12 +146,22 @@ function nextSeq(path) {
128
146
  * agentbbs server's responsibility (nonce JTI tracking, per ADR-164 §3.2.4).
129
147
  * Phase 2+ will wire this to the existing federation Ed25519 keypair.
130
148
  */
149
+ // Use @noble/ed25519 (already a hard dep of @claude-flow/cli for IPFS
150
+ // signing). Memoized behind one loader — two separate dynamic `import()`
151
+ // call sites for the same bare specifier in this module tripped up the
152
+ // test runner's dependency pre-bundling (surfaced once federation_bbs_
153
+ // human_join actually ran instead of always short-circuiting to degraded).
154
+ let _edMod = null;
155
+ async function loadEd25519() {
156
+ if (!_edMod)
157
+ _edMod = await import('@noble/ed25519');
158
+ return _edMod;
159
+ }
131
160
  let _signingKey = null;
132
161
  async function getSigningKey() {
133
162
  if (_signingKey)
134
163
  return _signingKey;
135
- // Use @noble/ed25519 (already a hard dep of @claude-flow/cli for IPFS signing).
136
- const ed = await import('@noble/ed25519');
164
+ const ed = await loadEd25519();
137
165
  const priv = ed.utils.randomPrivateKey
138
166
  ? ed.utils.randomPrivateKey()
139
167
  : randomBytes(32);
@@ -177,8 +205,7 @@ export const agentbbsTools = [
177
205
  // to mask malformed requests.
178
206
  const roomLabel = validateRoomLabel(String(input.roomLabel));
179
207
  const basePath = resolveBasePath(input.basePath);
180
- const api = await loadAgentbbs();
181
- if (!api)
208
+ if (!agentbbsCliAvailable())
182
209
  return degradedResult('agentbbs-not-found');
183
210
  ensureDir(basePath);
184
211
  const roomId = roomIdFromLabel(roomLabel);
@@ -260,20 +287,24 @@ export const agentbbsTools = [
260
287
  if (typeof input.payload !== 'object' || input.payload === null) {
261
288
  throw new Error('payload must be a JSON object');
262
289
  }
263
- const api = await loadAgentbbs();
264
- if (!api)
290
+ if (!agentbbsCliAvailable())
265
291
  return degradedResult('agentbbs-not-found');
266
292
  ensureDir(basePath);
267
293
  const logPath = roomLogPath(basePath, roomId);
268
- const env = {
294
+ const base = {
269
295
  envelopeId: base64url(randomBytes(12)),
270
296
  roomId,
271
297
  seq: nextSeq(logPath),
272
298
  msgType,
273
299
  payload: input.payload,
274
300
  timestamp: new Date().toISOString(),
275
- signature: input.signature ? String(input.signature) : undefined,
276
301
  };
302
+ // Phase 2: sign with this host's persistent node identity so peers can
303
+ // attribute and verify the envelope after a cross-host merge. An
304
+ // explicitly supplied signature is preserved rather than overwritten.
305
+ const env = input.signature
306
+ ? { ...base, signature: String(input.signature) }
307
+ : (await signEnvelope(basePath, base));
277
308
  appendFileSync(logPath, JSON.stringify(env) + '\n');
278
309
  // Phase 1: recipientHopCount is always 0 (single-node). Phase 4+ will
279
310
  // surface real hop counts from the WG mesh transport.
@@ -314,8 +345,7 @@ export const agentbbsTools = [
314
345
  handler: async (input) => {
315
346
  const basePath = resolveBasePath(input.basePath);
316
347
  const roomId = validateRoomId(String(input.roomId));
317
- const api = await loadAgentbbs();
318
- if (!api)
348
+ if (!agentbbsCliAvailable())
319
349
  return degradedResult('agentbbs-not-found');
320
350
  const limitRaw = typeof input.limit === 'number' ? input.limit : 50;
321
351
  const limit = Math.max(1, Math.min(500, Math.trunc(limitRaw)));
@@ -358,8 +388,7 @@ export const agentbbsTools = [
358
388
  },
359
389
  handler: async (input) => {
360
390
  const roomId = validateRoomId(String(input.roomId));
361
- const api = await loadAgentbbs();
362
- if (!api)
391
+ if (!agentbbsCliAvailable())
363
392
  return degradedResult('agentbbs-not-found');
364
393
  const ttlRaw = typeof input.ttlSeconds === 'number' ? input.ttlSeconds : 300;
365
394
  const ttlSeconds = Math.max(30, Math.min(900, Math.trunc(ttlRaw)));
@@ -368,7 +397,7 @@ export const agentbbsTools = [
368
397
  const nonce = base64url(randomBytes(16));
369
398
  const payload = { roomId, nonce, expiresAt };
370
399
  const canonical = JSON.stringify(payload);
371
- const ed = await import('@noble/ed25519');
400
+ const ed = await loadEd25519();
372
401
  const { priv, pub } = await getSigningKey();
373
402
  const sigBytes = await (ed.signAsync ?? ed.sign)(new TextEncoder().encode(canonical), priv);
374
403
  // Token format: base64url(JSON{payload, sig, pub}). Single string, easy
@@ -390,5 +419,136 @@ export const agentbbsTools = [
390
419
  };
391
420
  },
392
421
  },
422
+ // ---------------------------------------------------------------- Phase 2
423
+ {
424
+ name: 'federation_bbs_identity',
425
+ description: "agentbbs Phase 2 — return this host's persistent federation node identity (nodeId + Ed25519 public key), creating it on first call. Give the nodeId and publicKey to a peer so they can pin you with federation_bbs_peer_add. The private key never leaves this host and is never returned. Use when you are bootstrapping a new host into the federation and a peer needs something to pin. Reading node-identity.json directly is wrong because it also holds the private key, and the file is created lazily so it may not exist yet.",
426
+ inputSchema: {
427
+ type: 'object',
428
+ properties: {
429
+ basePath: { type: 'string', description: 'Override the .agentbbs directory.' },
430
+ },
431
+ },
432
+ handler: async (input) => {
433
+ const basePath = resolveBasePath(input.basePath);
434
+ if (!agentbbsCliAvailable())
435
+ return degradedResult('agentbbs-not-found');
436
+ const id = await getNodeIdentity(basePath);
437
+ return { success: true, nodeId: id.nodeId, publicKey: id.publicKey, createdAt: id.createdAt };
438
+ },
439
+ },
440
+ {
441
+ name: 'federation_bbs_peer_add',
442
+ description: `agentbbs Phase 2 — pin a remote federation peer by nodeId, URL and Ed25519 public key. The key is pinned at add time and every envelope merged from this peer is verified against it, so a hostile peer cannot forge another node's envelopes. Re-adding a known nodeId with a different key is refused; remove it first. Max ${MAX_PEERS} peers. Use when you have a peer's identity out of band and want to start syncing with it. Trusting a key carried inside an incoming envelope is wrong because that only proves the sender holds some key, not that they are the node they claim to be.`,
443
+ inputSchema: {
444
+ type: 'object',
445
+ properties: {
446
+ nodeId: { type: 'string', description: "Peer's 16-hex nodeId from its federation_bbs_identity." },
447
+ url: { type: 'string', description: 'Peer base URL, e.g. http://100.104.125.72:7777' },
448
+ publicKey: { type: 'string', description: "Peer's 64-hex Ed25519 public key." },
449
+ label: { type: 'string', description: 'Optional human label.' },
450
+ basePath: { type: 'string' },
451
+ },
452
+ required: ['nodeId', 'url', 'publicKey'],
453
+ },
454
+ handler: async (input) => {
455
+ const basePath = resolveBasePath(input.basePath);
456
+ if (!agentbbsCliAvailable())
457
+ return degradedResult('agentbbs-not-found');
458
+ const peer = addPeer(basePath, {
459
+ nodeId: String(input.nodeId),
460
+ url: String(input.url),
461
+ publicKey: String(input.publicKey),
462
+ label: input.label ? String(input.label) : undefined,
463
+ });
464
+ return { success: true, peer: { nodeId: peer.nodeId, url: peer.url, label: peer.label, addedAt: peer.addedAt } };
465
+ },
466
+ },
467
+ {
468
+ name: 'federation_bbs_peers',
469
+ description: 'agentbbs Phase 2 — list pinned federation peers with last-sync state. Public keys are returned so an operator can compare a pin against what the peer reports; private material is never included. Use when you want to audit who this host will accept envelopes from, or unpin a peer. Editing peers.json by hand is wrong because a malformed entry silently disables verification for that peer on the next sync.',
470
+ inputSchema: {
471
+ type: 'object',
472
+ properties: {
473
+ remove: { type: 'string', description: 'Optional nodeId to unpin instead of listing.' },
474
+ basePath: { type: 'string' },
475
+ },
476
+ },
477
+ handler: async (input) => {
478
+ const basePath = resolveBasePath(input.basePath);
479
+ if (!agentbbsCliAvailable())
480
+ return degradedResult('agentbbs-not-found');
481
+ if (input.remove) {
482
+ const removed = removePeer(basePath, String(input.remove));
483
+ return { success: true, removed, nodeId: String(input.remove) };
484
+ }
485
+ return { success: true, peers: readPeers(basePath) };
486
+ },
487
+ },
488
+ {
489
+ name: 'federation_bbs_serve',
490
+ description: 'agentbbs Phase 2 — start the read-only pull endpoint peers fetch from (GET /agentbbs/v1/rooms/:roomId/envelopes). Binds 127.0.0.1 unless bindHost is given explicitly, so room contents are never exposed on a routable interface by accident. There is no route that mutates state. Use when this host needs to be reachable by peers that pull from it. Exposing the .agentbbs directory over a static file server is wrong because that would serve node-identity.json, which contains the private key.',
491
+ inputSchema: {
492
+ type: 'object',
493
+ properties: {
494
+ port: { type: 'number', description: 'Port to listen on. 0 picks a free one.' },
495
+ bindHost: { type: 'string', description: 'Interface to bind. Defaults to 127.0.0.1; set a tailnet IP to federate.' },
496
+ basePath: { type: 'string' },
497
+ },
498
+ },
499
+ handler: async (input) => {
500
+ const basePath = resolveBasePath(input.basePath);
501
+ if (!agentbbsCliAvailable())
502
+ return degradedResult('agentbbs-not-found');
503
+ const { port, host } = await serveFederation(basePath, {
504
+ port: typeof input.port === 'number' ? input.port : undefined,
505
+ bindHost: input.bindHost ? String(input.bindHost) : undefined,
506
+ });
507
+ const id = await getNodeIdentity(basePath);
508
+ return { success: true, listening: `http://${host}:${port}`, nodeId: id.nodeId, publicKey: id.publicKey };
509
+ },
510
+ },
511
+ {
512
+ name: 'federation_bbs_sync',
513
+ description: `agentbbs Phase 2 — pull a room from pinned peers and union-merge what verifies. Merge is keyed on envelopeId so it is idempotent and order-independent; unsigned, misattributed, oversize and over-hop (>${MAX_HOPS}) envelopes are dropped and counted rather than merged. Use after publish to propagate, or on a timer to converge. Use when you want to converge this host's rooms with its peers, on demand or on a timer. Copying room-*.jsonl between machines is wrong because it bypasses signature verification, dedupe and the hop limit, so a single hostile or looping file can corrupt the log.`,
514
+ inputSchema: {
515
+ type: 'object',
516
+ properties: {
517
+ roomId: { type: 'string', description: 'Room to sync. Same label yields the same roomId on every host.' },
518
+ nodeId: { type: 'string', description: 'Optional single peer to sync from; default is all pinned peers.' },
519
+ basePath: { type: 'string' },
520
+ },
521
+ required: ['roomId'],
522
+ },
523
+ handler: async (input) => {
524
+ const basePath = resolveBasePath(input.basePath);
525
+ const roomId = fedValidateRoomId(String(input.roomId));
526
+ if (!agentbbsCliAvailable())
527
+ return degradedResult('agentbbs-not-found');
528
+ const all = readPeers(basePath);
529
+ const targets = input.nodeId ? all.filter(p => p.nodeId === String(input.nodeId)) : all;
530
+ if (targets.length === 0)
531
+ return { success: true, roomId, peersSynced: 0, results: [], note: 'no pinned peers' };
532
+ const results = [];
533
+ for (const peer of targets) {
534
+ try {
535
+ results.push(await syncRoomFromPeer(basePath, peer, roomId));
536
+ }
537
+ catch (e) {
538
+ // One unreachable peer must not fail the whole sync — the merge is
539
+ // idempotent, so this peer simply catches up on the next run.
540
+ results.push({ peerNodeId: peer.nodeId, roomId, error: e.message,
541
+ merged: 0, skippedDuplicate: 0, skippedUnverified: 0, skippedOversize: 0, skippedHopLimit: 0 });
542
+ }
543
+ }
544
+ return {
545
+ success: true,
546
+ roomId,
547
+ peersSynced: results.length,
548
+ totalMerged: results.reduce((n, r) => n + (r.merged || 0), 0),
549
+ results,
550
+ };
551
+ },
552
+ },
393
553
  ];
394
554
  //# sourceMappingURL=agentbbs-tools.js.map
@@ -17,14 +17,25 @@ function normalizeHash(value) {
17
17
  return trimmed.startsWith('sha256:') ? trimmed : `sha256:${trimmed}`;
18
18
  }
19
19
  function containedPath(projectRoot, requested) {
20
- const root = realpathSync(resolve(projectRoot));
21
- const absolute = isAbsolute(requested) ? resolve(requested) : resolve(root, requested);
22
- const lexical = relative(root, absolute);
23
- if (lexical === '..' || lexical.startsWith(`..${sep}`) || isAbsolute(lexical)) {
20
+ // The project root has two equally valid spellings when its path crosses a
21
+ // symlink — on macOS `/tmp/x` and `/private/tmp/x` name the same directory.
22
+ // Comparing a realpath'd root against a NON-realpath'd candidate (as this
23
+ // did) makes every such project look like an escape, so a project anchored
24
+ // anywhere under a symlink was rejected outright. Compare like with like:
25
+ // the lexical guard accepts either spelling of the root, and the symlink
26
+ // guard below still resolves the target and re-checks it physically.
27
+ const rootLexical = resolve(projectRoot);
28
+ const rootPhysical = realpathSync(rootLexical);
29
+ const absolute = isAbsolute(requested) ? resolve(requested) : resolve(rootLexical, requested);
30
+ const escapes = (base) => {
31
+ const rel = relative(base, absolute);
32
+ return rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel);
33
+ };
34
+ if (escapes(rootLexical) && escapes(rootPhysical)) {
24
35
  throw new Error('flywheel anchor path must stay inside project root');
25
36
  }
26
37
  const actual = realpathSync(absolute);
27
- const physical = relative(root, actual);
38
+ const physical = relative(rootPhysical, actual);
28
39
  if (physical === '..' || physical.startsWith(`..${sep}`) || isAbsolute(physical)) {
29
40
  throw new Error('flywheel anchor symlink escapes project root');
30
41
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@claude-flow/cli",
3
- "version": "3.39.2",
3
+ "version": "3.40.0",
4
4
  "type": "module",
5
5
  "description": "Ruflo CLI - Enterprise AI agent orchestration with 60+ specialized agents, swarm coordination, MCP server, self-learning hooks, and vector memory for Claude Code",
6
6
  "main": "dist/src/index.js",