diff --git a/package.json b/package.json index 0fd2fc5..baa2dcd 100644 --- a/package.json +++ b/package.json @@ -52,11 +52,9 @@ "generate:server-json": "tsx scripts/sync-release-metadata.ts", "publish:mcp-registry": "tsx scripts/publish-mcp-registry.ts", "lint": "eslint .", - "test": "node --test --test-concurrency=1 dist/test/coordinator-socket-error.integration.test.js dist/test/identity-store.test.js dist/test/identity-cert.test.js dist/test/identity-device-id.test.js dist/test/wire-mesh-identity.test.js dist/test/broadcast-window.integration.test.js dist/test/identity-restart.integration.test.js dist/test/mesh-e2e.integration.test.js dist/test/tls-transport.integration.test.js dist/test/peer-id-verification.integration.test.js dist/test/become-coordinator-actual-port.integration.test.js dist/test/state-sync-convergence.test.js dist/test/downtime-replay.test.js dist/test/downtime-replay.integration.test.js dist/test/filestore.test.js dist/test/handshake.test.js dist/test/approval.integration.test.js dist/test/listener-policy.integration.test.js dist/test/mesh-smoke.integration.test.js dist/test/wire-mesh-transport.integration.test.js dist/test/wire-mesh-transport-approval.integration.test.js", - "test:visibility": "node --test dist/test/visibility.integration.test.js", - "test:delivery": "node dist/test/delivery-receipt.runner.js", - "test:federation": "node dist/test/federation.integration.test.js", - "test:all": "pnpm test && pnpm test:delivery && pnpm test:federation", + "test": "tsx --test --test-concurrency=1 'src/test/**/*.test.ts'", + "test:delivery": "tsx src/test/delivery-receipt.runner.ts", + "test:all": "pnpm test && pnpm test:delivery", "test:frontend": "tsx --test src/bridges/user/web/frontend/test/**/*.unit.test.ts", "test:e2e": "playwright test", "typecheck": "tsc --noEmit" diff --git a/src/bridges/mcp/index.ts b/src/bridges/mcp/index.ts index 5dc0550..3398201 100644 --- a/src/bridges/mcp/index.ts +++ b/src/bridges/mcp/index.ts @@ -12,18 +12,13 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { - MeshStore, - CommsTool, + createBridgeMesh, buildAction, ensureRegistered, drainAndFormat, MCP_TOOL_PARAMS, } from "../../core/index.js"; -import { TlsTransport } from "../../core/tls-transport.js"; -import { - loadOrCreateIdentity, - type IdentitySlot, -} from "../../core/identity-store.js"; +import type { IdentitySlot } from "../../core/identity-store.js"; import { tryStartWebServer } from "../user/web/server.js"; import { nanoid } from "../../core/nanoid.js"; @@ -32,15 +27,9 @@ function isRecord(value: unknown): value is Record { } export async function run(): Promise { - // Persistent identity for this slot: a stable fingerprint means the agent - // ID survives restarts, so peers can keep targeting us. The stdio server - // has no graceful shutdown hook; a stale lock self-heals via the pid probe. + // Persistent identity for this slot: a stable device-id means the agent ID survives restarts, so peers can keep targeting us. The stdio server has no graceful shutdown hook; a stale lock self-heals via the pid probe. const identitySlot: IdentitySlot = { harness: "mcp", cwd: process.cwd() }; - const identity = loadOrCreateIdentity(identitySlot); - const store = new MeshStore(); - store.peerId = identity.fingerprint; - store.setTransport(new TlsTransport(store.events, identity)); - const tool = new CommsTool(store, store.discovery); + const { store, tool } = createBridgeMesh(identitySlot); let agentId: string | undefined; const mcp = new McpServer( diff --git a/src/core/bridge-mesh.ts b/src/core/bridge-mesh.ts new file mode 100644 index 0000000..6a1c61e --- /dev/null +++ b/src/core/bridge-mesh.ts @@ -0,0 +1,29 @@ +/** + * Shared bootstrap for a bridge's own mesh participation: load or create this bridge's persisted identity, construct a MeshStore wired to WireMeshTransport, and build the CommsTool that sits on top of it. Every bridge previously repeated this same four-line block against TlsTransport/identity.fingerprint directly; this factory is the single place that wiring lives now, so the substrate a bridge runs on is a one-line change here rather than six repeated ones. + * + * peerId is deviceIdToHex(identity.deviceId), not identity.fingerprint -- WireMeshTransport's own session bookkeeping is keyed by device-id, so MeshStore's own notion of "this peer's id" has to be the same value for the two to correlate. Every existing agent id changes the first time a bridge starts through this factory: there is no migration path, since the value is a hash of genuinely different bytes (device-id is SHA-256(raw public key); fingerprint is SHA-256(certificate DER)) -- a hard cutover, already established as correct when identity.ts first grew deviceId, not relitigated here. + */ + +import { deviceIdToHex } from "@exadev/wire-mesh-core/domain/device-id"; +import { MeshStore } from "./mesh-store.js"; +import { CommsTool } from "./tool.js"; +import { WireMeshTransport } from "./wire-mesh-transport.js"; +import { loadOrCreateIdentity } from "./identity-store.js"; +import type { IdentitySlot } from "./identity-store.js"; + +export interface BridgeMesh { + store: MeshStore; + tool: CommsTool; +} + +export function createBridgeMesh( + slot: Readonly, + coordinatorPort?: number, +): BridgeMesh { + const identity = loadOrCreateIdentity(slot); + const store = new MeshStore(coordinatorPort); + store.peerId = deviceIdToHex(Uint8Array.from(identity.deviceId)); + store.setTransport(new WireMeshTransport(store.events, identity)); + const tool = new CommsTool(store, store.discovery); + return { store, tool }; +} diff --git a/src/core/index.ts b/src/core/index.ts index 8a32031..58bf0be 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -9,6 +9,8 @@ export type { CommsStore } from "./comms-store.js"; export { FileStore, CommsError } from "./store.js"; export { MeshStore } from "./mesh-store.js"; export { CommsTool } from "./tool.js"; +export { createBridgeMesh } from "./bridge-mesh.js"; +export type { BridgeMesh } from "./bridge-mesh.js"; export type { CommsContext, CommsResult } from "./tool.js"; export { buildAction, diff --git a/src/test/bridge-mesh.test.ts b/src/test/bridge-mesh.test.ts new file mode 100644 index 0000000..c2ca148 --- /dev/null +++ b/src/test/bridge-mesh.test.ts @@ -0,0 +1,79 @@ +/** + * Unit tests for createBridgeMesh -- the shared factory every bridge builds its own MeshStore/WireMeshTransport/CommsTool from, replacing the identical four-line block each of the six bridges used to repeat against TlsTransport directly. + */ + +import * as assert from "node:assert/strict"; +import * as fs from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { test } from "node:test"; +import { deviceIdToHex } from "@exadev/wire-mesh-core/domain/device-id"; +import { createBridgeMesh } from "../core/bridge-mesh.js"; +import { + loadOrCreateIdentity, + type IdentitySlot, +} from "../core/identity-store.js"; +import { waitFor } from "./test-transport.js"; + +function tempSlot(harness: string): IdentitySlot { + const dir = fs.mkdtempSync( + path.join(tmpdir(), "agent-comms-bridge-mesh-test-"), + ); + return { harness, cwd: "/tmp/project", dir }; +} + +void test("createBridgeMesh sets peerId to deviceIdToHex(identity.deviceId), not the certificate fingerprint", async () => { + const slot = tempSlot("test-harness"); + const identity = loadOrCreateIdentity(slot); + const { store } = createBridgeMesh(slot); + try { + assert.strictEqual( + store.peerId, + deviceIdToHex(Uint8Array.from(identity.deviceId)), + ); + assert.notStrictEqual(store.peerId, identity.fingerprint); + } finally { + await store.shutdown(); + } +}); + +void test("createBridgeMesh wires a WireMeshTransport, not TlsTransport", async () => { + const slot = tempSlot("test-harness"); + const { store } = createBridgeMesh(slot); + try { + // init() is the only way to prove the transport is actually usable end to end, which also confirms it's a WireMeshTransport by construction (createBridgeMesh only ever builds one). + await store.init(); + assert.ok(store.connected); + } finally { + await store.shutdown(); + } +}); + +void test("createBridgeMesh passes an explicit coordinatorPort through to MeshStore, forming one shared mesh", async () => { + const slotA = tempSlot("test-harness-a"); + const slotB = tempSlot("test-harness-b"); + const port = 20_900 + Math.floor(Math.random() * 100); + const a = createBridgeMesh(slotA, port); + const b = createBridgeMesh(slotB, port); + try { + await a.store.init(); + await b.store.init(); + assert.ok(a.store.connected); + assert.ok(b.store.connected); + await a.store.registerAgent({ + name: "peer-a", + harness: "test-harness-a", + cwd: "/tmp/project", + pid: process.pid, + visibility: "visible", + tags: [], + }); + await waitFor( + () => b.store.serialise().agents[a.store.peerId] !== undefined, + "b sees a's agent, proving both joined the same mesh on the shared coordinatorPort", + ); + } finally { + await b.store.shutdown(); + await a.store.shutdown(); + } +}); diff --git a/src/test/delivery-receipt.runner.ts b/src/test/delivery-receipt.runner.ts index 411a7ea..d47e044 100644 --- a/src/test/delivery-receipt.runner.ts +++ b/src/test/delivery-receipt.runner.ts @@ -1,13 +1,8 @@ #!/usr/bin/env node /** - * Delivery receipt test runner — runs each test case in an isolated child - * process. Tests MUST NOT be run from within a process that has the - * agent-comms extension loaded (e.g. pi's agent session), because the - * parent's active TCP handles interfere with child process forking. + * Delivery receipt test runner — runs each test case in an isolated child process. Tests MUST NOT be run from within a process that has the agent-comms extension loaded (e.g. pi's agent session), because the parent's active TCP handles interfere with child process forking. * - * Usage: - * pnpm test:delivery # from a clean shell (recommended) - * node dist/test/delivery-receipt.runner.js + * Usage: pnpm test:delivery # from a clean shell (recommended) tsx src/test/delivery-receipt.runner.ts */ import { execFileSync } from "node:child_process"; @@ -15,7 +10,7 @@ import * as path from "node:path"; import * as url from "node:url"; const __dirname = path.dirname(url.fileURLToPath(import.meta.url)); -const HELPER = path.join(__dirname, "delivery-receipt.helper.js"); +const HELPER = path.join(__dirname, "delivery-receipt.helper.ts"); const tests = [ "push-room", @@ -31,11 +26,15 @@ let failed = false; for (const t of tests) { try { - const stdout = execFileSync(process.execPath, [HELPER, t], { - timeout: 15_000, - stdio: ["pipe", "pipe", "pipe"], - encoding: "utf-8", - }); + const stdout = execFileSync( + process.execPath, + ["--import", "tsx", HELPER, t], + { + timeout: 15_000, + stdio: ["pipe", "pipe", "pipe"], + encoding: "utf-8", + }, + ); if (stdout.trim()) console.log(stdout.trim()); console.log(`${t} ✓`); } catch (e: unknown) {