mirror of
https://github.com/ruvnet/ruflo.git
synced 2026-09-14 14:01:28 +08:00
72df96c297
* feat(agentbbs): cross-host federation — signed envelopes, pinned peers, union merge
Phase 1 gave every host a local append-only room log and derived roomId
deterministically from the room label, so two hosts that register #sales
already compute the same roomId without talking. This adds the layer that
actually moves envelopes between them.
Design. A room log is an append-only set of immutable envelopes, so reconciling
two hosts is a set union — there is no conflicting write to arbitrate and
nothing for a consensus round to decide. Union is commutative, associative and
idempotent, so sync is order independent and safe to retry. That is a
grow-only set keyed on envelopeId, and it is cheaper and less failure-prone
than the Byzantine agreement the plugin README gestures at.
What union does not give you is authenticity: if any peer can inject, the merge
faithfully replicates forgeries. So each host now holds a persistent Ed25519
identity (Phase 1's key was ephemeral per process, which no peer can pin),
every published envelope is signed, and a receiver verifies against the key it
pinned at peer-add time rather than one carried in the envelope — otherwise an
attacker signs with their own key and claims any origin.
Transport is pull-based HTTP: peers poll GET /agentbbs/v1/rooms/:roomId/
envelopes?since=N. Pull needs no inbound connectivity, tolerates a node being
offline, and leaves ingest volume under the receiver's control; push would make
every node an unauthenticated write target. The server binds 127.0.0.1 unless
a bindHost is passed explicitly, and exposes no route that mutates state.
Bounds are enforced receive-side where a sender cannot negotiate them away:
envelopes per sync, bytes per envelope, bytes per response, peer count, and a
hop limit that is incremented on merge so a cycle terminates. hops is excluded
from the signed material, since it is mutated in transit by design.
Five tools: identity, peer_add, peers, serve, sync.
Tested: 35 new tests, most of them the trust boundary — forged payload,
forged origin, key substitution, unpinned signer, cross-room injection, replay,
oversize, hop exhaustion, traversal at the HTTP boundary, and private key never
served. Convergence and idempotence are asserted against two real nodes over a
real socket, not mocks. Verified end to end with three independent nodes:
all three converge on the same envelope set, a second round merges nothing,
and an unpinned node's injection is rejected.
The Phase 1 structural test pinned the surface at exactly 4 tools; it now pins
9 and names both groups, so adding a tool stays a deliberate contract change.
Full package suite: 68 failures against 70 on main, +35 passing — no
regressions.
Co-Authored-By: RuFlo <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_013u4pmL9ZUAXb6usVQgNo67
* ci(agentbbs): stop rule 2 firing on the federation wire namespace
ADR-164 rule 2 exists to stop `agentbbs` becoming a mandatory dependency: a
static `import ... from 'agentbbs'` is forbidden, and any other mention has to
sit behind loadAgentbbs / a dynamic import. Its catch-all is a substring test
for "agentbbs" anywhere in the file.
agentbbs-federation.ts trips that catch-all without importing anything. Its
only remaining mentions after comment-stripping are string literals that never
reach a module resolver: the '/agentbbs/v1/...' HTTP route prefix and the
'agentbbs:<kind>:' hash domain-separators.
Adds those two shapes to the existing benign-pattern strip list. The
static-import check is untouched and remains the real gate.
Verified this narrows the check rather than loosening it, by running the
workflow's own rule-2 body verbatim against fixtures:
- the real federation module -> passes (false positive gone)
- `import { x } from 'agentbbs'` -> still caught
- unguarded runtime reference -> still caught
- guarded `await import('agentbbs')` -> passes
Co-Authored-By: RuFlo <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_013u4pmL9ZUAXb6usVQgNo67
* ci(agentbbs): feed rule 2 via a quoted heredoc so bash stops mangling it
The rule 2 fix in fda44cd1f passed locally but still failed in CI. Cause:
the check runs as `node -e "..."` — a bash double-quoted string — so bash
rewrites the script before node ever parses it:
- `\\?` collapsed to `\?`, turning the intended optional-backslash
lookahead into a lookahead for a literal '?'. It could never match
`agentbbs/v1`, so the strip was a no-op and rule 2 kept firing.
- backticks in the explanatory comments were command substitution, so
bash actually executed `export { agentbbsTools } from ...` etc. and
spliced their (empty) output back into the script. Pre-existing since
#2503 — visible as `import: command not found` noise in every run.
Fix the boundary rather than re-escaping: feed the script through a
QUOTED heredoc (`node - <<'RULE2'`), which bash passes through verbatim.
Regex backslashes and comment backticks now reach node as written. Also
drops backslashes from the new lookaheads so the pattern is robust even
if the step is ever converted back to an inline string.
Verified by executing the step's actual `run:` block through `bash -c`
(parsed out of the YAML), not a hand-retyped copy — that gap is what hid
the bug. Negative controls, all through that same path:
static import of agentbbs ............................. CAUGHT
unguarded runtime ref (deps['agentbbs']) .............. CAUGHT
wire route + real unguarded ref (bypass test) ......... CAUGHT
guarded dynamic import ................................ pass
wire route only ('/agentbbs/v1/...') .................. pass
hash domain only ('agentbbs:node:') ................... pass
escaped-slash route (/agentbbs\/v1\/rooms/) ........... pass
real tree ............................................. pass
The static-import check is untouched and remains the real gate.
Co-Authored-By: RuFlo <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_013u4pmL9ZUAXb6usVQgNo67
203 lines
8.4 KiB
TypeScript
203 lines
8.4 KiB
TypeScript
/**
|
|
* Tests for agentbbs MCP tools (ADR-164 Phase 1).
|
|
*
|
|
* Two surfaces under test:
|
|
* 1. STRUCTURAL — exposure, schema, validation. Runs regardless of agentbbs presence.
|
|
* 2. HAPPY PATH — gated on the `agentbbs` CLI being resolvable, via it.skipIf(!havePkg).
|
|
*
|
|
* The degraded path is exercised structurally: when the agentbbs CLI is
|
|
* missing, every handler returns `{degraded: true, reason: 'agentbbs-not-found'}`
|
|
* matching the metaharness / agenticow / testgen contract.
|
|
*/
|
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
import { mkdtempSync, rmSync, existsSync, readFileSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import { tmpdir } from 'node:os';
|
|
import { execFileSync } from 'node:child_process';
|
|
|
|
import { agentbbsTools } from '../src/mcp-tools/agentbbs-tools.js';
|
|
|
|
function findTool(name: string) {
|
|
const t = agentbbsTools.find(t => t.name === name);
|
|
if (!t) throw new Error(`tool not found: ${name}`);
|
|
return t;
|
|
}
|
|
|
|
// Detect agentbbs availability at module scope so it.skipIf evaluates correctly.
|
|
// The published `agentbbs` package is a CLI-only launcher with no importable
|
|
// JS entry point, so presence is a subprocess probe, not a module import —
|
|
// mirrors agentbbsCliAvailable() in the tools module under test.
|
|
let havePkg = false;
|
|
try {
|
|
// shell only on win32 — see agentbbsCliAvailable() in the module under test.
|
|
execFileSync(process.env.AGENTBBS_BIN || 'agentbbs', ['--version'], {
|
|
stdio: 'ignore',
|
|
timeout: 5000,
|
|
shell: process.platform === 'win32',
|
|
windowsHide: true,
|
|
});
|
|
havePkg = true;
|
|
} catch { havePkg = false; }
|
|
|
|
describe('agentbbs MCP tools — structural contract', () => {
|
|
it('exposes the Phase 1 room surface plus the Phase 2 federation surface', () => {
|
|
const names = agentbbsTools.map(t => t.name).sort();
|
|
// Phase 1 — local room/envelope handling.
|
|
for (const n of ['federation_bbs_register', 'federation_bbs_publish',
|
|
'federation_bbs_watch', 'federation_bbs_human_join']) {
|
|
expect(names).toContain(n);
|
|
}
|
|
// Phase 2 — cross-host identity, peer pinning, transport and sync.
|
|
for (const n of ['federation_bbs_identity', 'federation_bbs_peer_add',
|
|
'federation_bbs_peers', 'federation_bbs_serve', 'federation_bbs_sync']) {
|
|
expect(names).toContain(n);
|
|
}
|
|
// Pinned so adding a tool is a deliberate contract change, not a slip.
|
|
expect(names).toHaveLength(9);
|
|
});
|
|
|
|
it('every tool has an object inputSchema with handler + ≥80-char description', () => {
|
|
for (const t of agentbbsTools) {
|
|
expect(t.inputSchema.type).toBe('object');
|
|
expect(t.inputSchema.properties).toBeDefined();
|
|
expect(typeof t.handler).toBe('function');
|
|
expect(t.description.length).toBeGreaterThanOrEqual(80);
|
|
// ADR-112 — descriptions must carry "Use when ... is wrong because ..." guidance.
|
|
expect(t.description).toMatch(/Use when/i);
|
|
expect(t.description).toMatch(/wrong because/i);
|
|
}
|
|
});
|
|
|
|
it('register rejects path traversal in basePath', async () => {
|
|
const register = findTool('federation_bbs_register');
|
|
await expect(register.handler({
|
|
basePath: '../../../../etc/passwd',
|
|
roomLabel: '#sales',
|
|
})).rejects.toThrow(/disallowed characters/);
|
|
});
|
|
|
|
it('register rejects an illegal roomLabel', async () => {
|
|
const register = findTool('federation_bbs_register');
|
|
await expect(register.handler({
|
|
roomLabel: 'evil; rm -rf /',
|
|
})).rejects.toThrow(/may only contain/);
|
|
});
|
|
|
|
it('register accepts conventional #-prefixed room labels', async () => {
|
|
// Even when agentbbs is missing we should validate inputs first OR degrade
|
|
// — but the contract is: validation errors must surface as throws.
|
|
// With agentbbs missing we get a degraded result instead of validation.
|
|
// So this test only meaningfully covers regex acceptance shape.
|
|
expect(/^[A-Za-z0-9_.\-:/@#]+$/.test('#sales')).toBe(true);
|
|
expect(/^[A-Za-z0-9_.\-:/@#]+$/.test('finance')).toBe(true);
|
|
expect(/^[A-Za-z0-9_.\-:/@#]+$/.test('hr/onboarding')).toBe(true);
|
|
});
|
|
|
|
it('publish rejects malformed msgType (validation runs before optional-dep check)', async () => {
|
|
const publish = findTool('federation_bbs_publish');
|
|
await expect(publish.handler({
|
|
roomId: 'sales-12345678',
|
|
msgType: 'bad type with spaces',
|
|
payload: {},
|
|
})).rejects.toThrow(/msgType must be alnum/);
|
|
});
|
|
|
|
it('publish rejects non-object payload', async () => {
|
|
const publish = findTool('federation_bbs_publish');
|
|
await expect(publish.handler({
|
|
roomId: 'sales-12345678',
|
|
msgType: 'task-result',
|
|
payload: 'not an object',
|
|
})).rejects.toThrow(/payload must be a JSON object/);
|
|
});
|
|
|
|
it('all 4 tools return {degraded:true, reason:"agentbbs-not-found"} when agentbbs is missing', async () => {
|
|
if (havePkg) return; // happy-path case is covered elsewhere
|
|
for (const t of agentbbsTools) {
|
|
const minimalInput: Record<string, unknown> =
|
|
t.name === 'federation_bbs_register' ? { roomLabel: '#sales' } :
|
|
t.name === 'federation_bbs_publish' ? { roomId: 'sales-12345678', msgType: 'task-result', payload: {} } :
|
|
t.name === 'federation_bbs_watch' ? { roomId: 'sales-12345678' } :
|
|
t.name === 'federation_bbs_human_join' ? { roomId: 'sales-12345678' } :
|
|
{};
|
|
const r: any = await t.handler(minimalInput);
|
|
expect(r.success).toBe(true);
|
|
expect(r.degraded).toBe(true);
|
|
expect(r.reason).toBe('agentbbs-not-found');
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('agentbbs MCP tools — happy path (real package)', () => {
|
|
let workdir: string;
|
|
let roomId: string;
|
|
|
|
beforeAll(() => {
|
|
workdir = mkdtempSync(join(tmpdir(), 'agentbbs-tools-'));
|
|
});
|
|
|
|
afterAll(() => {
|
|
try { rmSync(workdir, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
});
|
|
|
|
it.skipIf(!havePkg)('register: creates a room and persists registry', async () => {
|
|
const register = findTool('federation_bbs_register');
|
|
const r: any = await register.handler({ basePath: workdir, roomLabel: '#sales' });
|
|
expect(r.success).toBe(true);
|
|
expect(r.degraded).toBeUndefined();
|
|
expect(r.trustLevel).toBe('attested');
|
|
expect(r.roomId).toMatch(/^sales-[0-9a-f]{8}$/);
|
|
expect(r.nodeId).toMatch(/^[0-9a-f]{16}$/);
|
|
roomId = r.roomId;
|
|
const reg = JSON.parse(readFileSync(join(workdir, 'rooms.json'), 'utf-8'));
|
|
expect(reg[roomId]).toBeDefined();
|
|
expect(reg[roomId].roomLabel).toBe('#sales');
|
|
});
|
|
|
|
it.skipIf(!havePkg)('publish: writes a ReplicateMessage envelope to the room log', async () => {
|
|
const publish = findTool('federation_bbs_publish');
|
|
const r: any = await publish.handler({
|
|
basePath: workdir,
|
|
roomId,
|
|
msgType: 'task-result',
|
|
payload: { agent: 'lead-gen', leadsQualified: 3 },
|
|
});
|
|
expect(r.success).toBe(true);
|
|
expect(r.degraded).toBeUndefined();
|
|
expect(r.envelopeId).toBeTruthy();
|
|
expect(r.recipientHopCount).toBe(0);
|
|
expect(existsSync(join(workdir, `room-${roomId}.jsonl`))).toBe(true);
|
|
});
|
|
|
|
it.skipIf(!havePkg)('watch: returns envelopes from the room log with monotonic seq', async () => {
|
|
const watch = findTool('federation_bbs_watch');
|
|
const r: any = await watch.handler({ basePath: workdir, roomId, limit: 50 });
|
|
expect(r.success).toBe(true);
|
|
expect(r.envelopes.length).toBeGreaterThan(0);
|
|
// Seq must be strictly monotonic
|
|
let lastSeq = 0;
|
|
for (const e of r.envelopes) {
|
|
expect(e.seq).toBeGreaterThan(lastSeq);
|
|
lastSeq = e.seq;
|
|
}
|
|
});
|
|
|
|
it.skipIf(!havePkg)('human_join: mints a single-use Ed25519-signed token that expires in the future', async () => {
|
|
const join = findTool('federation_bbs_human_join');
|
|
const before = Date.now();
|
|
const r: any = await join.handler({ roomId: 'sales-12345678', ttlSeconds: 120 });
|
|
const after = Date.now();
|
|
expect(r.success).toBe(true);
|
|
expect(r.degraded).toBeUndefined();
|
|
expect(r.handshakeToken).toBeTruthy();
|
|
expect(r.handshakeToken.length).toBeGreaterThan(50);
|
|
expect(r.webUrl).toMatch(/^https:\/\/agentbbs\.local\//);
|
|
expect(r.sshCommand).toMatch(/^ssh /);
|
|
const expiresAt = new Date(r.expiresAt).getTime();
|
|
expect(expiresAt).toBeGreaterThan(before);
|
|
// Expiry must be ≤ ttlSeconds from now (within a wide margin to absorb scheduling jitter).
|
|
expect(expiresAt - after).toBeLessThanOrEqual(120 * 1000 + 1000);
|
|
expect(expiresAt - before).toBeGreaterThanOrEqual(120 * 1000 - 1000);
|
|
});
|
|
});
|