@claude-flow/cli 3.39.3 → 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.
- package/.claude/helpers/helpers.manifest.json +2 -2
- package/catalog-manifest.json +3 -3
- package/dist/src/commands/memory.js +9 -2
- package/dist/src/mcp-tools/agentbbs-federation.d.ts +178 -0
- package/dist/src/mcp-tools/agentbbs-federation.js +447 -0
- package/dist/src/mcp-tools/agentbbs-tools.js +139 -2
- package/dist/src/services/harness-project-anchor.js +16 -5
- package/package.json +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"manifest": {
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.40.0",
|
|
4
4
|
"files": {
|
|
5
5
|
"auto-memory-hook.mjs": "85fe05c757421c52137c0bc8545a0896bab6b4714538c2a11d1d0835bfcc8c1c",
|
|
6
6
|
"hook-handler.cjs": "209d9fafe10e17d1be0866727f6f9cf9ac66f9a0793f1c793a4f58319e8e4583",
|
|
@@ -8,6 +8,6 @@
|
|
|
8
8
|
"statusline.cjs": "4a48353b4f1566fa4379b00fd0321b6676a22b6cc91fbbd8380ac5183d619468"
|
|
9
9
|
}
|
|
10
10
|
},
|
|
11
|
-
"signature": "
|
|
11
|
+
"signature": "faVaRzF1tnQZD/Yu/Tze+7hSyjEhSgbJ2GkqBtF1wsmRuUgZKsAcKJ70E5Je+Am5UH32ZEj3mfMmqwNlsAlACg==",
|
|
12
12
|
"algorithm": "ed25519"
|
|
13
13
|
}
|
package/catalog-manifest.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
3
|
"generation": 6,
|
|
4
|
-
"generatedAt": "2026-09-
|
|
5
|
-
"gitSha": "
|
|
4
|
+
"generatedAt": "2026-09-09T23:09:38.206Z",
|
|
5
|
+
"gitSha": "e111bfbd",
|
|
6
6
|
"catalog": {
|
|
7
7
|
"agents": 167,
|
|
8
|
-
"tools":
|
|
8
|
+
"tools": 402,
|
|
9
9
|
"skills": 34
|
|
10
10
|
},
|
|
11
11
|
"benchmark": null
|
|
@@ -352,7 +352,14 @@ const searchCommand = {
|
|
|
352
352
|
name: 'threshold',
|
|
353
353
|
description: 'Similarity threshold (0-1)',
|
|
354
354
|
type: 'number',
|
|
355
|
-
|
|
355
|
+
// MUST stay <= 0.4. The recall fusion in bridgeSearchEntries scores a
|
|
356
|
+
// full-coverage exact-keyword hit as 0.6*max(0,semantic) + 0.4*lexical,
|
|
357
|
+
// so when the semantic cosine is <= 0 (routine for a one-word query) a
|
|
358
|
+
// perfect keyword match tops out at exactly 0.40. #2790 raised this
|
|
359
|
+
// default to 0.7, which silently re-broke #2558: `memory search` matched
|
|
360
|
+
// content word-for-word and still returned nothing. Regression guard:
|
|
361
|
+
// __tests__/memory-search-recall-2558.test.ts.
|
|
362
|
+
default: 0.3
|
|
356
363
|
},
|
|
357
364
|
{
|
|
358
365
|
name: 'type',
|
|
@@ -421,7 +428,7 @@ const searchCommand = {
|
|
|
421
428
|
// coalescing preserves an explicit zero. Fallback aligned with the
|
|
422
429
|
// option's declared `default: 0.7` (was `0.3` — the two disagreed
|
|
423
430
|
// and --help advertised a default the code did not honor).
|
|
424
|
-
const threshold = ctx.flags.threshold ?? 0.
|
|
431
|
+
const threshold = ctx.flags.threshold ?? 0.3;
|
|
425
432
|
const searchType = ctx.flags.type || 'semantic';
|
|
426
433
|
const buildHnsw = (ctx.flags['build-hnsw'] || ctx.flags.buildHnsw);
|
|
427
434
|
const requestedIntent = ctx.flags.intent || 'mixed';
|
|
@@ -0,0 +1,178 @@
|
|
|
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 { type Server } from 'node:http';
|
|
54
|
+
/** Max envelopes accepted from one peer in one sync. */
|
|
55
|
+
export declare const MAX_ENVELOPES_PER_SYNC = 5000;
|
|
56
|
+
/** Max serialized bytes for a single envelope. */
|
|
57
|
+
export declare const MAX_ENVELOPE_BYTES: number;
|
|
58
|
+
/** Max total bytes read from one peer response. */
|
|
59
|
+
export declare const MAX_SYNC_RESPONSE_BYTES: number;
|
|
60
|
+
/** Max peers in the registry. */
|
|
61
|
+
export declare const MAX_PEERS = 256;
|
|
62
|
+
/** Max federation hops before an envelope stops propagating. */
|
|
63
|
+
export declare const MAX_HOPS = 8;
|
|
64
|
+
/** Per-request timeout when pulling from a peer. */
|
|
65
|
+
export declare const SYNC_TIMEOUT_MS = 15000;
|
|
66
|
+
export interface NodeIdentity {
|
|
67
|
+
nodeId: string;
|
|
68
|
+
publicKey: string;
|
|
69
|
+
privateKey: string;
|
|
70
|
+
createdAt: string;
|
|
71
|
+
}
|
|
72
|
+
export interface FederationPeer {
|
|
73
|
+
nodeId: string;
|
|
74
|
+
url: string;
|
|
75
|
+
publicKey: string;
|
|
76
|
+
label?: string;
|
|
77
|
+
addedAt: string;
|
|
78
|
+
lastSyncedAt?: string;
|
|
79
|
+
lastSeq?: Record<string, number>;
|
|
80
|
+
}
|
|
81
|
+
export interface SignedEnvelope {
|
|
82
|
+
envelopeId: string;
|
|
83
|
+
roomId: string;
|
|
84
|
+
seq: number;
|
|
85
|
+
msgType: string;
|
|
86
|
+
payload: unknown;
|
|
87
|
+
timestamp: string;
|
|
88
|
+
origin?: string;
|
|
89
|
+
hops?: number;
|
|
90
|
+
signature?: string;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Deterministic byte string an envelope's signature covers.
|
|
94
|
+
*
|
|
95
|
+
* Field order is fixed here rather than relying on `JSON.stringify` key order,
|
|
96
|
+
* because a receiver must reconstruct byte-identical input from a payload that
|
|
97
|
+
* survived a JSON round trip. `payload` is canonicalized recursively with
|
|
98
|
+
* sorted keys for the same reason: `{a:1,b:2}` and `{b:2,a:1}` are the same
|
|
99
|
+
* value and must not produce different signatures.
|
|
100
|
+
*
|
|
101
|
+
* `hops` is deliberately excluded — it is mutated in transit by design, so
|
|
102
|
+
* including it would invalidate the signature at the first relay.
|
|
103
|
+
*/
|
|
104
|
+
export declare function canonicalEnvelopeBytes(env: SignedEnvelope): Uint8Array;
|
|
105
|
+
/**
|
|
106
|
+
* Load or create this host's long-lived Ed25519 identity.
|
|
107
|
+
*
|
|
108
|
+
* Phase 1 minted an ephemeral key per process, which is fine for local token
|
|
109
|
+
* signing but useless across hosts: a peer cannot pin a key that changes on
|
|
110
|
+
* every restart. This persists one, 0600, and derives a stable nodeId from the
|
|
111
|
+
* public key so identity is verifiable rather than self-asserted.
|
|
112
|
+
*/
|
|
113
|
+
export declare function getNodeIdentity(basePath: string): Promise<NodeIdentity>;
|
|
114
|
+
export declare function signEnvelope(basePath: string, env: SignedEnvelope): Promise<SignedEnvelope>;
|
|
115
|
+
/**
|
|
116
|
+
* Verify an envelope against a public key the caller already trusts.
|
|
117
|
+
*
|
|
118
|
+
* The key is passed in rather than read from the envelope on purpose: an
|
|
119
|
+
* envelope-carried key proves only that the sender holds *a* key, not that they
|
|
120
|
+
* are who the `origin` field claims. Callers pass the key pinned at peer-add.
|
|
121
|
+
*/
|
|
122
|
+
export declare function verifyEnvelope(env: SignedEnvelope, publicKeyHex: string): Promise<boolean>;
|
|
123
|
+
export declare function readPeers(basePath: string): FederationPeer[];
|
|
124
|
+
/**
|
|
125
|
+
* Reject anything that is not a plain http(s) URL to a host.
|
|
126
|
+
*
|
|
127
|
+
* Blocks credentials-in-URL (they would be logged), and non-http schemes such
|
|
128
|
+
* as `file:` which would turn a peer entry into a local file read.
|
|
129
|
+
*/
|
|
130
|
+
export declare function validatePeerUrl(raw: string): string;
|
|
131
|
+
export declare function addPeer(basePath: string, input: {
|
|
132
|
+
nodeId: string;
|
|
133
|
+
url: string;
|
|
134
|
+
publicKey: string;
|
|
135
|
+
label?: string;
|
|
136
|
+
}): FederationPeer;
|
|
137
|
+
export declare function removePeer(basePath: string, nodeId: string): boolean;
|
|
138
|
+
export declare function readEnvelopes(basePath: string, roomId: string): SignedEnvelope[];
|
|
139
|
+
export declare function validateRoomId(roomId: string): string;
|
|
140
|
+
export interface MergeResult {
|
|
141
|
+
merged: number;
|
|
142
|
+
skippedDuplicate: number;
|
|
143
|
+
skippedUnverified: number;
|
|
144
|
+
skippedOversize: number;
|
|
145
|
+
skippedHopLimit: number;
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Union-merge verified envelopes from a peer into the local room log.
|
|
149
|
+
*
|
|
150
|
+
* Idempotent: `envelopeId` is the merge key, so replaying the same batch is a
|
|
151
|
+
* no-op. Anything that fails verification is dropped and counted rather than
|
|
152
|
+
* quarantined — a receiver has no use for an envelope it cannot attribute.
|
|
153
|
+
*/
|
|
154
|
+
export declare function mergeEnvelopes(basePath: string, roomId: string, incoming: SignedEnvelope[], peerPublicKey: string): Promise<MergeResult>;
|
|
155
|
+
/** Pull one room from one peer and merge what verifies. */
|
|
156
|
+
export declare function syncRoomFromPeer(basePath: string, peer: FederationPeer, roomId: string, fetchImpl?: typeof fetch): Promise<MergeResult & {
|
|
157
|
+
peerNodeId: string;
|
|
158
|
+
roomId: string;
|
|
159
|
+
}>;
|
|
160
|
+
/**
|
|
161
|
+
* Serve this node's room logs for peers to pull.
|
|
162
|
+
*
|
|
163
|
+
* Binds loopback unless the caller explicitly asks otherwise, so starting a
|
|
164
|
+
* server never silently exposes room contents on a routable interface. Read
|
|
165
|
+
* only by construction: there is no route that mutates state, which removes the
|
|
166
|
+
* whole class of unauthenticated-write attacks that a push design would open.
|
|
167
|
+
*/
|
|
168
|
+
export declare function serveFederation(basePath: string, opts?: {
|
|
169
|
+
port?: number;
|
|
170
|
+
bindHost?: string;
|
|
171
|
+
}): Promise<{
|
|
172
|
+
server: Server;
|
|
173
|
+
port: number;
|
|
174
|
+
host: string;
|
|
175
|
+
}>;
|
|
176
|
+
/** Constant-time compare for any future shared-secret paths. */
|
|
177
|
+
export declare function safeEqual(a: string, b: string): boolean;
|
|
178
|
+
//# sourceMappingURL=agentbbs-federation.d.ts.map
|
|
@@ -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
|
|
@@ -38,6 +38,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync, appendFileSync } fr
|
|
|
38
38
|
import { resolve, isAbsolute, join } from 'node:path';
|
|
39
39
|
import { randomBytes, createHash } from 'node:crypto';
|
|
40
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';
|
|
41
42
|
import { getProjectCwd } from './types.js';
|
|
42
43
|
const CLI_NAME = 'agentbbs';
|
|
43
44
|
// Cache: amortize the subprocess probe cost across handler calls within a
|
|
@@ -290,15 +291,20 @@ export const agentbbsTools = [
|
|
|
290
291
|
return degradedResult('agentbbs-not-found');
|
|
291
292
|
ensureDir(basePath);
|
|
292
293
|
const logPath = roomLogPath(basePath, roomId);
|
|
293
|
-
const
|
|
294
|
+
const base = {
|
|
294
295
|
envelopeId: base64url(randomBytes(12)),
|
|
295
296
|
roomId,
|
|
296
297
|
seq: nextSeq(logPath),
|
|
297
298
|
msgType,
|
|
298
299
|
payload: input.payload,
|
|
299
300
|
timestamp: new Date().toISOString(),
|
|
300
|
-
signature: input.signature ? String(input.signature) : undefined,
|
|
301
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));
|
|
302
308
|
appendFileSync(logPath, JSON.stringify(env) + '\n');
|
|
303
309
|
// Phase 1: recipientHopCount is always 0 (single-node). Phase 4+ will
|
|
304
310
|
// surface real hop counts from the WG mesh transport.
|
|
@@ -413,5 +419,136 @@ export const agentbbsTools = [
|
|
|
413
419
|
};
|
|
414
420
|
},
|
|
415
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
|
+
},
|
|
416
553
|
];
|
|
417
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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(
|
|
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
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@claude-flow/cli",
|
|
3
|
-
"version": "3.
|
|
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",
|