Files
ruvnet__ruflo/v3/@claude-flow/cli/__tests__/x-federation-tools.test.ts
rUv dbf450a927 feat(federation): ruflo CLI + MCP integration for x.ruv.io, and Seraphina (swarm queen) (#3256)
* feat(federation): ruflo CLI + MCP integration for x.ruv.io, and Seraphina (swarm queen)

Integrate the open swarm federation into ruflo itself, and add Seraphina —
a primary-coordinator / swarm-queen guidance tool in the style of the ruOS
assistant terminal (invoked from a terminal or any MCP client).

MCP tools (src/mcp-tools/x-federation-tools.ts), registered in the barrel and
the in-process registry so `callMCPTool` and the MCP server both see them:
  x_federation_sync / roster / claims / registry   (open reads)
  x_federation_publish / invite_mint / admit       (gateway-identity writes,
    require RUFLO_X_ADMIN_TOKEN; fail closed with no network call)
Each description follows ADR-112 ("Use when … wrong because …").

Seraphina (src/mcp-tools/seraphina-tools.ts): `seraphina_guidance { goal }`
gathers the live roster, claims board and recent messages from the x.ruv.io
gateway, compacts them (dedupe by from|type, cap 15 — a cheap tier drowns in
repeated PeerHellos), and asks the cognitum meta-llm gateway
(https://api.cognitum.one/v1/messages, model cognitum-auto by default with a
tier override) using a queen system prompt that treats message content as
data, respects one-owner-per-resource claims, and returns
{ guidance, proposals[], risks[] }. Proposals are advisory. JSON is extracted
by slicing the outermost object so fenced/prose-wrapped answers still parse.
Key from SERAPHINA_METALLM_KEY (GCP secret seraphina-metallm-api-key).

CLI: `ruflo federation sync|roster|claims|registry|invite|admit|publish`
(src/commands/federation.ts), thin over the same tools.

Skill: .agents/skills/open-federation/SKILL.md documents CLI, tools,
Seraphina, the claims rules, the canonical NIP-42 relay-tag gotcha, and
onboarding.

Tests: 11 (x-federation: RPC shaping, SSE parse, resource mapping, fail-closed
admin gating, isError surfacing; seraphina: registration, fail-closed key,
context gathering + queen prompt + x-api-key, tier override, fenced-JSON
extraction). Project typechecks at 0 errors.

Verified live: Seraphina against the real 3-node federation returned 3
structured proposals + 4 risks via cognitum-auto (routed to a cheap tier).

Co-Authored-By: RuFlo <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_013u4pmL9ZUAXb6usVQgNo67

* fix(federation): ADR-125 env-var precedence — URL config takes a flag/arg, credentials registered env-only

The env-var-precedence audit correctly flagged the new process.env reads.
Config URLs now have a precedence path: `ruflo federation --gateway` feeds a
`gatewayUrl` tool arg (and Seraphina takes `metaLlmUrl`/`gatewayUrl`) that
wins over RUFLO_X_GATEWAY_URL / SERAPHINA_METALLM_URL, documented per ADR-125.
Credentials (RUFLO_X_ADMIN_TOKEN, SERAPHINA_METALLM_KEY) are registered as
env-only escape hatches with rationale: a secret must never be a CLI flag.
+1 test (arg precedence, arg not forwarded upstream). Audit passes locally.

Co-Authored-By: RuFlo <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_013u4pmL9ZUAXb6usVQgNo67

* feat(federation): `ruflo federation join --code` — self-service invite access with your own key

The user-facing path into the open swarm. Decentralized by design: the user
generates/holds THEIR OWN Nostr key (~/.ruflo/nostr.key, 0600), redeems an
invite code with a NIP-98-signed claim directly against the relay (no admin in
the loop), proves membership via NIP-42, and can then publish as themselves.
The gateway never signs for a user.

- src/mcp-tools/x-federation-join.ts: x_federation_join { code, relayHttp?,
  relayWs?, keyFile? } — validates the code shape before any network call,
  loads/creates the key, NIP-98 claim, NIP-42 verify, returns pubkey + role.
  ADR-125: args take precedence over RUFLO_X_RELAY_HTTP / RUFLO_X_RELAY_WS /
  RUFLO_NOSTR_KEY_FILE.
- CLI: `ruflo federation join --code v2.…`
- nostr-tools added as an optionalDependency (secp256k1/Schnorr is not in
  node:crypto); the tool degrades with an install hint when absent. pnpm
  lockfile regenerated in this PR (frozen-lockfile CI).
- 4 tests: 0600 key create/reuse, valid NIP-98 header (kind 27235, verifies,
  bound to url+method+payload hash), malformed code rejected pre-network,
  ADR-112 description. Suite: 16/16; tsc 0; env-var audit passes.

Co-Authored-By: RuFlo <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_013u4pmL9ZUAXb6usVQgNo67

* fix(federation): default relay -> wss://relay.ruv.io; join test tolerates missing nostr-tools

- x-federation-join defaults now point at the canonical relay.ruv.io host (Cloud Run
  domain mapping for buzz-relay); the raw run.app host stays routable for old clients.
- Validate the invite-code shape before the optional-dependency check so a bad code
  fails fast whether or not nostr-tools is installed.
- The root Test Suite runs the CLI tests via root npm ci, which never installs the
  CLI's optionalDependencies: the crypto cases are it.skipIf(!nostr-tools) so the
  file no longer trips the CI test ratchet.

Co-Authored-By: RuFlo <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_013u4pmL9ZUAXb6usVQgNo67
2026-09-10 02:51:40 +00:00

72 lines
4.6 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { xFederationTools } from '../src/mcp-tools/x-federation-tools.js';
const tool = (n: string) => xFederationTools.find((t) => t.name === n)!;
const sse = (result: unknown) => `event: message\ndata: ${JSON.stringify({ jsonrpc: '2.0', id: 1, result })}\n\n`;
const toolResult = (obj: unknown, isError = false) => ({ content: [{ type: 'text', text: JSON.stringify(obj) }], isError });
describe('x_federation_* (ruflo → x.ruv.io gateway)', () => {
let calls: Array<{ url: string; body: any }> = [];
beforeEach(() => {
calls = [];
vi.stubGlobal('fetch', vi.fn(async (url: string, init: any) => {
const body = JSON.parse(init.body);
calls.push({ url, body });
if (body.method === 'tools/call') {
if (body.params.name === 'federation_sync') return new Response(sse(toolResult({ count: 1, messages: [{ from: 'ruvzen' }] })));
if (body.params.arguments?.adminToken === 'wrong') return new Response(sse(toolResult({ error: 'admin token required or invalid' }, true)));
return new Response(sse(toolResult({ ok: true, echo: body.params.arguments })));
}
if (body.method === 'resources/read') return new Response(sse({ contents: [{ uri: body.params.uri, text: JSON.stringify({ uri: body.params.uri }) }] }));
return new Response(sse({}));
}));
delete process.env.RUFLO_X_ADMIN_TOKEN; delete process.env.RUFLO_X_GATEWAY_URL;
});
afterEach(() => vi.unstubAllGlobals());
it('registers the expected tool set with ADR-112 style descriptions', () => {
const names = xFederationTools.map((t) => t.name).sort();
expect(names).toEqual(['x_federation_admit', 'x_federation_claims', 'x_federation_invite_mint', 'x_federation_publish', 'x_federation_registry', 'x_federation_roster', 'x_federation_sync'].sort());
for (const t of xFederationTools) { expect(t.description).toMatch(/Use when/); expect(t.description).toMatch(/wrong because/); }
});
it('sync posts a JSON-RPC tools/call to <gateway>/mcp and parses the SSE data frame', async () => {
const out = await tool('x_federation_sync').handler({ sinceSeconds: 60, limit: 5 }, {} as any);
expect(calls[0].url).toBe('https://x.ruv.io/mcp');
expect(calls[0].body).toMatchObject({ jsonrpc: '2.0', method: 'tools/call', params: { name: 'federation_sync', arguments: { sinceSeconds: 60, limit: 5 } } });
expect(out).toEqual({ count: 1, messages: [{ from: 'ruvzen' }] });
});
it('honours RUFLO_X_GATEWAY_URL and strips a trailing slash', async () => {
process.env.RUFLO_X_GATEWAY_URL = 'https://gw.example/';
await tool('x_federation_roster').handler({}, {} as any);
expect(calls[0].url).toBe('https://gw.example/mcp');
});
it('gatewayUrl tool arg takes precedence over RUFLO_X_GATEWAY_URL (ADR-125) and is not forwarded to the gateway', async () => {
process.env.RUFLO_X_GATEWAY_URL = 'https://env.example';
await tool('x_federation_sync').handler({ gatewayUrl: 'https://arg.example', limit: 1 }, {} as any);
expect(calls[0].url).toBe('https://arg.example/mcp');
expect(calls[0].body.params.arguments).toEqual({ limit: 1 });
});
it('roster/claims/registry read the matching ruv:// resource', async () => {
for (const [t, uri] of [['x_federation_roster', 'ruv://swarm/roster'], ['x_federation_claims', 'ruv://claims/board'], ['x_federation_registry', 'ruv://federation/registry']] as const) {
calls = [];
const out = await tool(t).handler({}, {} as any);
expect(calls[0].body).toMatchObject({ method: 'resources/read', params: { uri } });
expect(out).toEqual({ uri });
}
});
it('gateway-identity writes refuse to run without RUFLO_X_ADMIN_TOKEN (fail closed, no network call)', async () => {
for (const t of ['x_federation_publish', 'x_federation_invite_mint', 'x_federation_admit']) {
await expect(tool(t).handler({ msgType: 'Status', payload: {}, pubkey: 'a'.repeat(64) }, {} as any)).rejects.toThrow(/RUFLO_X_ADMIN_TOKEN/);
}
expect(calls).toHaveLength(0);
});
it('gateway-identity writes forward the admin token and surface gateway isError as a thrown error', async () => {
process.env.RUFLO_X_ADMIN_TOKEN = 'wrong';
await expect(tool('x_federation_invite_mint').handler({ maxUses: 1 }, {} as any)).rejects.toThrow(/admin token required or invalid/);
process.env.RUFLO_X_ADMIN_TOKEN = 'right';
const out = (await tool('x_federation_admit').handler({ pubkey: 'b'.repeat(64), role: 'member' }, {} as any)) as any;
expect(calls.at(-1)!.body.params.arguments.adminToken).toBe('right');
expect(out.echo.pubkey).toBe('b'.repeat(64));
});
});