diff --git a/package.json b/package.json index 1d9f350..c9b7cfe 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,7 @@ "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", + "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", diff --git a/src/core/wire-mesh-transport.ts b/src/core/wire-mesh-transport.ts new file mode 100644 index 0000000..9346088 --- /dev/null +++ b/src/core/wire-mesh-transport.ts @@ -0,0 +1,630 @@ +/** + * WireMeshTransport — MeshTransport implemented over @exadev/wire-mesh-core, carrying the existing MeshMessage union as an opaque payload rather than inventing new wire semantics. This is the P2 substrate swap's own deliverable: MeshStore, CommsTool, and every existing agent/room/message behaviour above the transport are completely unaffected -- only how bytes move between peers changes. + * + * Every agent-comms MeshMessage rides as a single namespaced manage-command verb (FRAME_VERB) under one flat scope (FRAME_SCOPE); the message itself is carried opaquely in the command's own params.message field. This needs no spec/CDDL change: manage-command-params is already an open `{* tstr => any}` socket for exactly this purpose. + * + * Peer identity is authenticated at the transport layer, not asserted on the wire: createTlsTransport verifies a peer's presented certificate cryptographically and exposes the result as Connection.peerDeviceId before any MeshMessage is ever exchanged, which is what lets the `pong` self-identification message retire entirely -- there is nothing left for it to prove that the connection itself hasn't already proven. + * + * connect_request's accept/reject flow maps onto sendManageRequest's own request/response round trip directly, rather than a separate pair of connect_accepted/connect_rejected messages: manage-request-frame already tolerates arbitrary latency between a request and its response, so "waiting for a human to approve" is simply a manage-response that hasn't been sent yet, not a protocol gap needing its own mechanism. connect_request therefore holds its own IncomingManageRequest open (rather than responding immediately) until acceptConnection/rejectConnection is actually called. + */ + +import { createTlsTransport } from "@exadev/wire-mesh-core/adapters/tls-transport"; +import { + acceptMeshSession, + type AcceptedMeshSession, + type IncomingManageRequest, +} from "@exadev/wire-mesh-core/domain/mesh-session"; +import { deviceIdToHex } from "@exadev/wire-mesh-core/domain/device-id"; +import type { + CapabilityScope, + ManageCommand, +} from "@exadev/wire-mesh-core/generated/protocol"; +import type { + Connection, + Listener, + Transport, +} from "@exadev/wire-mesh-core/ports/transport"; +import { isMeshMessage } from "./wire-protocol.js"; +import type { MeshMessage, PeerInfo } from "./wire-protocol.js"; +import type { + ConnectionHandle, + ListenerInfo, + ListenerPolicy, + MeshTransport, + TransportEvents, +} from "./transport.js"; +import type { PeerIdentity } from "./identity.js"; +import { toIdentityPort } from "./wire-mesh-identity.js"; +import { nanoid } from "./nanoid.js"; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const COORDINATOR_HOST = "127.0.0.1"; + +/** This project's own namespaced domain (registrant "exadev.io", local name "agent-comms-v1"), matching namespaced-domain-id's "/" shape -- registry/core-domains.md's own recommended pattern for a third party. */ +const DOMAIN = "exadev.io/agent-comms-v1"; + +/** One verb for the entire MeshMessage union: this phase carries every message opaquely rather than modelling each arm as its own verb, which is core/room's own job once P3 gives this substrate real room semantics. */ +const FRAME_VERB = "exadev.io/agent-comms-v1:frame"; + +/** No finer-grained authorisation model exists above "you're a TLS-authenticated member of this mesh" at this phase -- exactly today's TlsTransport model, which does no per-message authorisation either. */ +const FRAME_SCOPE: Readonly = { kind: "agent-comms-mesh" }; + +// --------------------------------------------------------------------------- +// Message carriage +// --------------------------------------------------------------------------- + +function buildCommand(message: MeshMessage): ManageCommand { + return { verb: FRAME_VERB, params: { message } }; +} + +/** Extracts and validates the carried MeshMessage from an incoming command. Returns undefined for anything that isn't a well-formed frame -- an unrecognised or malformed payload is dropped, not thrown, the same tolerance TlsTransport's own frame parser already extends to input it can't make sense of. */ +function extractMessage(command: ManageCommand): MeshMessage | undefined { + if (command.verb !== FRAME_VERB) return undefined; + const params: unknown = command.params; + if (typeof params !== "object" || params === null) return undefined; + if (!("message" in params)) return undefined; + const message: unknown = params.message; + return isMeshMessage(message) ? message : undefined; +} + +// --------------------------------------------------------------------------- +// Internal session bookkeeping +// --------------------------------------------------------------------------- + +interface PendingConnection { + respond: IncomingManageRequest["respond"]; + dataPort: number; + name: string; + fingerprint: string; + policy: ListenerPolicy | undefined; +} + +interface TrackedListener { + listener: Listener; + policy: ListenerPolicy; + host: string; + port: number; + isDefault: boolean; +} + +// --------------------------------------------------------------------------- +// WireMeshTransport +// --------------------------------------------------------------------------- + +export class WireMeshTransport implements MeshTransport { + private readonly wireTransport: Transport; + private readonly events: TransportEvents; + private readonly identityReady: Promise< + Awaited> + >; + + private _dataPort = 0; + private _isCoordinator = false; + private shutDown = false; + + /** A method call, not a direct property read -- deliberately, so TS's control-flow narrowing (which does track `this.shutDown` as staying false for the rest of a function once an early `if (this.shutDown) return` has passed, with no awareness that an `await` in between gave a concurrent shutdown() call the chance to flip it) doesn't treat every later re-check in the same async function as unreachable. Each of these re-checks is real: shutdown() can run at any time this function is suspended on an await. */ + private isShuttingDown(): boolean { + return this.shutDown; + } + + // -- Data server (accepts data connections from already-known peers) -- + private dataListener: Listener | undefined; + + // -- Coordinator listeners (the well-known bootstrap port, plus any additional adapters addListener creates) -- + private coordinatorListeners = new Map(); + private defaultListenerId: string | undefined; + + // -- The session dialled via connectToCoordinator, when this instance is not itself the coordinator -- + private coordinatorSession: AcceptedMeshSession | undefined; + + // -- Every live session, keyed by the peer's authenticated device-id hex (== ConnectionHandle.id) -- covers coordinator-client, coordinator-accepted, and peer data sessions alike, since send()/broadcast() must reach whichever kind of session a peer happens to be reachable through. A single-slot-per-key map by construction: mesh formation genuinely establishes TWO independent sessions to the same peer (see the dataDials comment below), and the second one registered here simply overwrites the first as far as addressing goes -- fine for send()/broadcast() (either socket reaches the same peer), but NOT fine for shutdown, which must close every live session regardless of whether it's still reachable through this map. allSessions below exists specifically so shutdown never leaks the one this map's overwrite silently stopped tracking. + private peerSessions = new Map(); + + // -- Every live session this transport has ever created, accepted or dialled, for shutdown's own use only -- never used for addressing (peerSessions is), so it never loses track of one session to another sharing the same peer id. + private allSessions = new Set(); + + /** Registers a session in both peerSessions (addressing -- last one in for a given peer wins) and allSessions (shutdown -- every session, always). */ + private trackSession(key: string, session: AcceptedMeshSession): void { + this.peerSessions.set(key, session); + this.allSessions.add(session); + } + + // -- Peers this side has dialled via connectToPeer specifically (in flight or established) -- deliberately separate from peerSessions, which also holds sessions this side reached the SAME peer through for an unrelated reason (most concretely, the coordinator-client session connectToCoordinator opens to the coordinator's own device-id). Mesh formation deliberately establishes a second, independent connection in each direction so each side's own acceptor can push its own state (see mesh-store.ts's own handlePeerConnected), so connectToPeer's "don't dial twice" guard must not be satisfied by an unrelated session that merely happens to share the same peer ID. + private dataDials = new Set(); + + // -- connect_request frames awaiting a human accept/reject decision, keyed by the requester's device-id hex -- + private pendingConnections = new Map(); + + constructor(events: TransportEvents, identity: Readonly) { + this.events = events; + this.wireTransport = createTlsTransport({ + certificatePem: identity.certificate, + privateKeyPem: identity.privateKey, + }); + this.identityReady = toIdentityPort(identity); + } + + // -- Public getters -- + + get dataPort(): number { + return this._dataPort; + } + + get isCoordinator(): boolean { + return this._isCoordinator; + } + + get hasCoordinatorConnection(): boolean { + return this.coordinatorSession !== undefined; + } + + // ----------------------------------------------------------------------- + // Connection acceptance -- shared by every listener (coordinator or data) + // ----------------------------------------------------------------------- + + private async handleAcceptedConnection( + connection: Readonly, + policy: ListenerPolicy | undefined, + fireOnPeerConnected: boolean, + ): Promise { + if (this.shutDown) { + await connection.close(); + return; + } + const peerDeviceId = connection.peerDeviceId; + if (peerDeviceId === undefined) { + // No certificate presented at all -- nothing to authenticate this peer against. + await connection.close(); + return; + } + const deviceIdHex = deviceIdToHex(peerDeviceId); + const identity = await this.identityReady; + const session = await acceptMeshSession(connection, identity, [DOMAIN]); + if (this.isShuttingDown()) { + await session.close(); + return; + } + this.trackSession(deviceIdHex, session); + // ConnectionHandle.policy is `?: ListenerPolicy`, not `?: ListenerPolicy | undefined` -- under exactOptionalPropertyTypes these are genuinely different types, so the key must be entirely absent rather than present-with-undefined-value when this listener has no policy of its own. + const handle: ConnectionHandle = { + id: deviceIdHex, + ...(policy !== undefined ? { policy } : {}), + }; + + if (fireOnPeerConnected) { + const info: PeerInfo = { + id: deviceIdHex, + port: 0, + startedAt: new Date().toISOString(), + }; + this.events.onPeerConnected(handle, info); + } + + this.consumeIncoming(session, handle); + this.watchForDisconnect(session, handle, deviceIdHex); + } + + private consumeIncoming( + session: AcceptedMeshSession, + handle: ConnectionHandle, + ): void { + void (async () => { + for await (const request of session.incomingManageRequests) { + if (this.shutDown) break; + await this.dispatchIncoming(request, handle); + } + })(); + } + + private async dispatchIncoming( + request: IncomingManageRequest, + handle: ConnectionHandle, + ): Promise { + const message = extractMessage(request.command); + if (message === undefined) { + await request.respond({ result: "ok" }).catch(() => undefined); + return; + } + + if (message.method === "connect_request") { + // Held open deliberately -- see this file's own header comment. Answered later by acceptConnection/rejectConnection, not here. + this.pendingConnections.set(handle.id, { + respond: request.respond, + dataPort: message.dataPort, + name: message.name, + fingerprint: message.fingerprint, + policy: handle.policy, + }); + this.events.onConnectionRequest(handle, { + peerId: handle.id, + dataPort: message.dataPort, + name: message.name, + fingerprint: message.fingerprint, + }); + return; + } + + this.route(handle, message); + await request.respond({ result: "ok" }).catch(() => undefined); + } + + /** Routes an already-decoded MeshMessage to the matching TransportEvents callback -- the same dispatch regardless of which listener (coordinator or data) accepted the connection it arrived on, since the message's own method, not the port it arrived on, is what determines meaning. */ + private route(handle: ConnectionHandle, message: MeshMessage): void { + switch (message.method) { + case "introduce": { + this.events.onIntroduction(handle, { + peerId: handle.id, + dataPort: message.dataPort, + }); + return; + } + case "peer_list": { + this.events.onPeerList(message.peers); + return; + } + case "peer_joined": { + this.events.onPeerJoined(message.peer); + return; + } + case "become_coordinator": { + this.events.onBecomeCoordinator(message.peerList); + return; + } + case "connect_accepted": + case "connect_rejected": { + // Never constructed by this transport -- connectToRemote's own sendManageRequest outcome carries this meaning directly. Dropped rather than treated as an error in case a future peer still sends one (forward compatibility with anything else speaking this same opaque-frame domain). + return; + } + default: { + this.events.onMessage(handle, message); + } + } + } + + private watchForDisconnect( + session: AcceptedMeshSession, + handle: ConnectionHandle, + deviceIdHex: string, + ): void { + void (async () => { + for await (const event of session.events) { + if (event.state.status === "closed") { + const wasTracked = this.peerSessions.get(deviceIdHex) === session; + if (wasTracked) this.peerSessions.delete(deviceIdHex); + this.allSessions.delete(session); + this.pendingConnections.delete(deviceIdHex); + // A no-op when this session was never a connectToPeer dial (e.g. the coordinator-client or an accepted connection) -- Set.delete on an absent key is always safe. + this.dataDials.delete(deviceIdHex); + if (wasTracked && !this.shutDown) { + this.events.onPeerDisconnected(handle); + } + return; + } + } + })(); + } + + // ----------------------------------------------------------------------- + // MeshTransport -- Data server + // ----------------------------------------------------------------------- + + async startDataServer(): Promise { + this.dataListener = await this.wireTransport.listen( + `${COORDINATOR_HOST}:0`, + (connection) => { + void this.handleAcceptedConnection(connection, undefined, true); + }, + ); + const port = this.dataListener.address.split(":").pop(); + this._dataPort = port === undefined ? 0 : Number(port); + } + + // ----------------------------------------------------------------------- + // MeshTransport -- Coordinator connection (client side) + // ----------------------------------------------------------------------- + + async connectToCoordinator( + host: string, + port: number, + peerId: string, + dataPort: number, + ): Promise { + const connection = await this.wireTransport.connect( + `${host}:${String(port)}`, + ); + const identity = await this.identityReady; + const session = await acceptMeshSession(connection, identity, [DOMAIN]); + this.coordinatorSession = session; + const coordinatorDeviceId = connection.peerDeviceId; + if (coordinatorDeviceId !== undefined) { + const handle: ConnectionHandle = { + id: deviceIdToHex(coordinatorDeviceId), + }; + this.trackSession(handle.id, session); + this.consumeIncoming(session, handle); + this.watchForDisconnect(session, handle, handle.id); + } + // Fire-and-forget, matching the previous transport's own contract: this resolves once the introduction is sent, not once a response arrives -- peer_list/peer_joined/become_coordinator arrive asynchronously via the normal dispatch path above, independent of this call's own promise. + void session + .sendManageRequest( + buildCommand({ method: "introduce", peerId, dataPort }), + FRAME_SCOPE, + ) + .catch(() => undefined); + } + + // ----------------------------------------------------------------------- + // MeshTransport -- Become coordinator (server side) + // ----------------------------------------------------------------------- + + async becomeCoordinator(host: string, port: number): Promise { + const id = nanoid(8); + const listener = await this.wireTransport.listen( + `${host}:${String(port)}`, + (connection) => { + const tracked = this.coordinatorListeners.get(id); + void this.handleAcceptedConnection(connection, tracked?.policy, false); + }, + ); + this._isCoordinator = true; + this.coordinatorListeners.set(id, { + listener, + policy: "full", + host, + port, + isDefault: true, + }); + this.defaultListenerId = id; + } + + // ----------------------------------------------------------------------- + // MeshTransport -- Peer-to-peer data connections + // ----------------------------------------------------------------------- + + async connectToPeer(peer: PeerInfo, _ownPeerId: string): Promise { + if (this.shutDown || this.dataDials.has(peer.id)) return; + this.dataDials.add(peer.id); + const connection = await this.wireTransport + .connect(`${COORDINATOR_HOST}:${String(peer.port)}`) + .catch((error: unknown) => { + this.events.onError?.( + error instanceof Error + ? new Error( + `connectToPeer(${peer.id}, port ${String(peer.port)}): ${error.message}`, + ) + : new Error( + `connectToPeer(${peer.id}, port ${String(peer.port)}) failed`, + ), + ); + return undefined; + }); + if (connection === undefined || this.isShuttingDown()) { + this.dataDials.delete(peer.id); + return; + } + const peerDeviceId = connection.peerDeviceId; + if (peerDeviceId === undefined) { + this.dataDials.delete(peer.id); + await connection.close(); + return; + } + const deviceIdHex = deviceIdToHex(peerDeviceId); + if (deviceIdHex !== peer.id) { + // The peer we reached does not hold the key peer.id claims to name -- refuse exactly like a mismatched fingerprint would have under the previous transport. + this.events.onError?.( + new Error( + `connectToPeer: peer at port ${String(peer.port)} authenticated as ${deviceIdHex}, expected ${peer.id}`, + ), + ); + this.dataDials.delete(peer.id); + await connection.close(); + return; + } + const identity = await this.identityReady; + const session = await acceptMeshSession(connection, identity, [DOMAIN]); + if (this.isShuttingDown()) { + this.dataDials.delete(peer.id); + await session.close(); + return; + } + this.trackSession(deviceIdHex, session); + const handle: ConnectionHandle = { id: deviceIdHex }; + this.consumeIncoming(session, handle); + this.watchForDisconnect(session, handle, deviceIdHex); + } + + // ----------------------------------------------------------------------- + // MeshTransport -- Send / broadcast + // ----------------------------------------------------------------------- + + async send(handle: ConnectionHandle, message: MeshMessage): Promise { + const session = this.peerSessions.get(handle.id); + if (session === undefined) return; + await session + .sendManageRequest(buildCommand(message), FRAME_SCOPE) + .catch((error: unknown) => { + this.events.onError?.( + error instanceof Error + ? error + : new Error(`send(${handle.id}) failed: ${String(error)}`), + ); + }); + } + + async broadcast(message: MeshMessage): Promise { + const command = buildCommand(message); + await Promise.all( + [...this.peerSessions.values()].map((session) => + session.sendManageRequest(command, FRAME_SCOPE).catch(() => undefined), + ), + ); + } + + // ----------------------------------------------------------------------- + // MeshTransport -- Connection approval (connectToRemote flow) + // ----------------------------------------------------------------------- + + async connectToRemote( + host: string, + port: number, + peerId: string, + dataPort: number, + name: string, + fingerprint: string, + ): Promise { + const connection = await this.wireTransport.connect( + `${host}:${String(port)}`, + ); + const identity = await this.identityReady; + const session = await acceptMeshSession(connection, identity, [DOMAIN]); + const outcome = await session.sendManageRequest( + buildCommand({ + method: "connect_request", + peerId, + dataPort, + name, + fingerprint, + }), + FRAME_SCOPE, + ); + if (outcome.result === "error") { + await session.close(); + throw new Error(outcome.message ?? outcome.code); + } + const coordinatorDeviceId = connection.peerDeviceId; + if (coordinatorDeviceId !== undefined) { + const handle: ConnectionHandle = { + id: deviceIdToHex(coordinatorDeviceId), + }; + this.trackSession(handle.id, session); + this.consumeIncoming(session, handle); + this.watchForDisconnect(session, handle, handle.id); + } + } + + async acceptConnection(handle: ConnectionHandle): Promise { + const pending = this.pendingConnections.get(handle.id); + if (pending === undefined) { + throw new Error(`No pending connection for handle ${handle.id}`); + } + this.pendingConnections.delete(handle.id); + await pending.respond({ result: "ok" }); + const acceptedHandle: ConnectionHandle = { + id: handle.id, + ...(pending.policy !== undefined ? { policy: pending.policy } : {}), + }; + this.events.onIntroduction(acceptedHandle, { + peerId: handle.id, + dataPort: pending.dataPort, + }); + } + + async rejectConnection( + handle: ConnectionHandle, + reason: string, + ): Promise { + const pending = this.pendingConnections.get(handle.id); + if (pending === undefined) { + throw new Error(`No pending connection for handle ${handle.id}`); + } + this.pendingConnections.delete(handle.id); + await pending.respond({ + result: "error", + code: "rejected", + message: reason, + }); + const session = this.peerSessions.get(handle.id); + this.peerSessions.delete(handle.id); + if (session !== undefined) await session.close(); + } + + // ----------------------------------------------------------------------- + // MeshTransport -- Listener management + // ----------------------------------------------------------------------- + + async addListener( + host: string, + port: number, + policy: ListenerPolicy, + ): Promise { + const id = nanoid(8); + const listener = await this.wireTransport.listen( + `${host}:${String(port)}`, + (connection) => { + void this.handleAcceptedConnection(connection, policy, false); + }, + ); + this.coordinatorListeners.set(id, { + listener, + policy, + host, + port, + isDefault: false, + }); + return id; + } + + async removeListener(id: string): Promise { + if (id === this.defaultListenerId) { + throw new Error("Cannot remove the default listener"); + } + const tracked = this.coordinatorListeners.get(id); + if (tracked === undefined) return; + this.coordinatorListeners.delete(id); + await tracked.listener.close(); + } + + listListeners(): ListenerInfo[] { + return [...this.coordinatorListeners.entries()].map(([id, tracked]) => ({ + id, + host: tracked.host, + port: tracked.port, + policy: tracked.policy, + isDefault: tracked.isDefault, + })); + } + + // ----------------------------------------------------------------------- + // MeshTransport -- Shutdown / unref + // ----------------------------------------------------------------------- + + async shutdown(): Promise { + this.shutDown = true; + this.dataDials.clear(); + + for (const pending of this.pendingConnections.values()) { + await pending + .respond({ result: "error", code: "shutting_down" }) + .catch(() => undefined); + } + this.pendingConnections.clear(); + + // Every live session, whichever of possibly several to the same peer -- peerSessions alone would only close the last one registered under a shared key, leaving any other (e.g. the coordinator-client session to a peer this side also has a data connection to) still open and accepting, which would make the listener it belongs to wait forever for it to end. + for (const session of this.allSessions) { + await session.close().catch(() => undefined); + } + this.allSessions.clear(); + this.peerSessions.clear(); + this.coordinatorSession = undefined; + + if (this.dataListener !== undefined) { + await this.dataListener.close(); + this.dataListener = undefined; + } + + for (const tracked of this.coordinatorListeners.values()) { + await tracked.listener.close(); + } + this.coordinatorListeners.clear(); + } + + unref(): void { + this.dataListener?.unref?.(); + for (const tracked of this.coordinatorListeners.values()) { + tracked.listener.unref?.(); + } + } +} diff --git a/src/test/test-transport.ts b/src/test/test-transport.ts index 599035e..8b5f01a 100644 --- a/src/test/test-transport.ts +++ b/src/test/test-transport.ts @@ -1,7 +1,9 @@ /** Wires a real TlsTransport with a freshly generated identity onto a freshly constructed MeshStore -- the same setTransport() call every production bridge makes immediately after construction. MeshStore has no default transport, so every test that constructs one needs this (or an equivalent explicit setTransport() call) before init() or any other transport-using method runs. */ import { TlsTransport } from "../core/tls-transport.js"; +import { WireMeshTransport } from "../core/wire-mesh-transport.js"; import { generateIdentity } from "../core/identity.js"; +import { deviceIdToHex } from "@exadev/wire-mesh-core/domain/device-id"; import type { MeshStore } from "../core/mesh-store.js"; export function wireTestTransport(store: MeshStore): void { @@ -15,6 +17,18 @@ export function wireTestTransport(store: MeshStore): void { }; } +/** + * Wires a real WireMeshTransport onto a freshly constructed MeshStore -- the P2 substrate swap's own analogue of wireTestTransport above. Sets peerId to deviceIdToHex(identity.deviceId) rather than the certificate fingerprint: production bridges don't cut MeshStore.peerId over to this until step 4 of the migration's own sequencing, but WireMeshTransport's own internal session bookkeeping is already keyed by device-id, so a test exercising it end-to-end through a real MeshStore needs the two identity spaces to actually correlate. + */ +export function wireWireMeshTestTransport(store: MeshStore): void { + const identity = generateIdentity(); + store.peerId = deviceIdToHex(Uint8Array.from(identity.deviceId)); + store.setTransport(new WireMeshTransport(store.events, identity)); + store.onError = (e) => { + console.error(`[transport error, peerId=${store.peerId}]`, e.message); + }; +} + // Generous on purpose: waitFor returns the instant its condition holds, so a long ceiling costs nothing on the happy path (a local run settles in well under a second) and only matters for the worst case -- a loaded CI runner working through a real, sequential chain of TLS handshakes (each one genuine X.509 certificate work, not instant) for the accept-flow's second connection direction, confirmed to need meaningfully more than 5s on at least one real CI run. const DEFAULT_WAIT_FOR_TIMEOUT_MS = 20_000; const WAIT_FOR_POLL_INTERVAL_MS = 20; diff --git a/src/test/wire-mesh-transport-approval.integration.test.ts b/src/test/wire-mesh-transport-approval.integration.test.ts new file mode 100644 index 0000000..fd1493e --- /dev/null +++ b/src/test/wire-mesh-transport-approval.integration.test.ts @@ -0,0 +1,216 @@ +/** + * Parity test for WireMeshTransport's connection-approval flow (connectToRemote/acceptConnection/rejectConnection) and listener management, mirroring approval.integration.test.ts's own scenarios against the new substrate. + */ + +import * as assert from "node:assert/strict"; +import { test, describe } from "node:test"; +import * as net from "node:net"; +import { MeshStore } from "../core/mesh-store.js"; +import type { DeliveryEvent } from "../core/types.js"; +import { waitFor, wireWireMeshTestTransport } from "./test-transport.js"; + +function findFreePort(): Promise { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.listen(0, "127.0.0.1", () => { + const addr = server.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + server.close(() => resolve(port)); + }); + server.on("error", reject); + }); +} + +let portOffset = 0; +async function uniquePort(): Promise { + portOffset += 10; + const base = await findFreePort(); + return base + portOffset; +} + +describe("WireMeshTransport connection approval", () => { + void test("accept establishes the peer connection", async () => { + const portA = await uniquePort(); + const portB = portA + 100; + + const storeA = new MeshStore(portA); + wireWireMeshTestTransport(storeA); + const storeB = new MeshStore(portB); + wireWireMeshTestTransport(storeB); + try { + const receivedRequests: Extract< + DeliveryEvent, + { type: "connection_request" } + >[] = []; + storeA.onDelivery = (_id, event) => { + if (event.type === "connection_request") { + receivedRequests.push(event); + } + }; + await storeA.init(); + await storeA.registerAgent({ + name: "coordinator", + harness: "test", + cwd: "/test/a", + pid: process.pid, + visibility: "visible", + tags: [], + }); + + await storeB.startDataServerOnly(); + await storeB.registerAgent({ + name: "connector", + harness: "test", + cwd: "/test/b", + pid: process.pid, + visibility: "visible", + tags: [], + }); + + storeB.connectToRemote("127.0.0.1", portA); + + await waitFor( + () => receivedRequests.length === 1, + "coordinator receives the connection request", + ); + const request = receivedRequests[0]; + assert.ok(request?.connectionId); + + await storeA.acceptConnection(request.connectionId); + + await waitFor( + () => storeA.serialise().agents[storeB.peerId] !== undefined, + "coordinator sees the connector agent", + ); + await waitFor( + () => storeB.serialise().agents[storeA.peerId] !== undefined, + "connector sees the coordinator agent", + ); + + const agentsA = await storeA.listAgents(storeA.peerId); + const agentsB = await storeB.listAgents(storeB.peerId); + assert.ok( + agentsA.some((a) => a.id === storeB.peerId), + "Coordinator should see the connector agent", + ); + assert.ok( + agentsB.some((a) => a.id === storeA.peerId), + "Connector should see the coordinator agent", + ); + } finally { + await storeB.shutdown(); + await storeA.shutdown(); + } + }); + + void test("reject closes with reason", async () => { + const portA = await uniquePort(); + const portB = portA + 100; + + const storeA = new MeshStore(portA); + wireWireMeshTestTransport(storeA); + const storeB = new MeshStore(portB); + wireWireMeshTestTransport(storeB); + try { + const receivedRequests: Extract< + DeliveryEvent, + { type: "connection_request" } + >[] = []; + storeA.onDelivery = (_id, event) => { + if (event.type === "connection_request") { + receivedRequests.push(event); + } + }; + await storeA.init(); + await storeA.registerAgent({ + name: "coordinator", + harness: "test", + cwd: "/test/a", + pid: process.pid, + visibility: "visible", + tags: [], + }); + + await storeB.startDataServerOnly(); + await storeB.registerAgent({ + name: "connector", + harness: "test", + cwd: "/test/b", + pid: process.pid, + visibility: "visible", + tags: [], + }); + + storeB.connectToRemote("127.0.0.1", portA); + + await waitFor( + () => receivedRequests.length === 1, + "coordinator receives the connection request", + ); + const request = receivedRequests[0]; + assert.ok(request?.connectionId); + + await storeA.rejectConnection(request.connectionId, "unauthorised"); + + await waitFor( + () => storeA.listPendingConnections().length === 0, + "the pending connection clears", + ); + const agentsA = await storeA.listAgents(storeA.peerId); + assert.ok( + !agentsA.some((a) => a.id === storeB.peerId), + "Rejected peer should not appear in agent list", + ); + } finally { + await storeB.shutdown(); + await storeA.shutdown(); + } + }); +}); + +describe("WireMeshTransport listener management", () => { + void test("addListener creates an additional listener; removeListener removes it", async () => { + const port = await uniquePort(); + const store = new MeshStore(port); + wireWireMeshTestTransport(store); + try { + await store.init(); + const before = store.listListeners(); + assert.strictEqual( + before.length, + 1, + "starts with just the default listener", + ); + assert.ok(before[0]?.isDefault); + + const extraPort = await uniquePort(); + const id = await store.addListener("127.0.0.1", extraPort, "observe"); + const afterAdd = store.listListeners(); + assert.strictEqual(afterAdd.length, 2); + const added = afterAdd.find((l) => l.id === id); + assert.ok(added); + assert.strictEqual(added.policy, "observe"); + assert.strictEqual(added.isDefault, false); + + await store.removeListener(id); + const afterRemove = store.listListeners(); + assert.strictEqual(afterRemove.length, 1); + } finally { + await store.shutdown(); + } + }); + + void test("removeListener rejects removing the default listener", async () => { + const port = await uniquePort(); + const store = new MeshStore(port); + wireWireMeshTestTransport(store); + try { + await store.init(); + const [defaultListener] = store.listListeners(); + assert.ok(defaultListener); + await assert.rejects(() => store.removeListener(defaultListener.id)); + } finally { + await store.shutdown(); + } + }); +}); diff --git a/src/test/wire-mesh-transport.integration.test.ts b/src/test/wire-mesh-transport.integration.test.ts new file mode 100644 index 0000000..43d3bb8 --- /dev/null +++ b/src/test/wire-mesh-transport.integration.test.ts @@ -0,0 +1,130 @@ +/** + * End-to-end parity test for WireMeshTransport, mirroring mesh-e2e.integration.test.ts's own scenario (coordinator discovery, room creation, messaging, delivery push) but against the new substrate instead of TlsTransport -- the concrete proof this phase's own charter ("semantics unchanged") holds for the core mesh-formation path, using waitFor polling rather than fixed sleeps for the same reason approval.integration.test.ts already does. + */ + +import * as assert from "node:assert/strict"; +import { test } from "node:test"; +import { MeshStore } from "../core/mesh-store.js"; +import { CommsTool } from "../core/tool.js"; +import { buildAction } from "../core/bridge.js"; +import type { DeliveryEvent } from "../core/types.js"; +import { waitFor, wireWireMeshTestTransport } from "./test-transport.js"; + +async function createStore( + port: number, + name: string, + harness: string, +): Promise<{ store: MeshStore; tool: CommsTool; deliveries: DeliveryEvent[] }> { + const store = new MeshStore(port); + wireWireMeshTestTransport(store); + + const deliveries: DeliveryEvent[] = []; + store.onDelivery = (_agentId, event) => { + deliveries.push(event); + }; + + const tool = new CommsTool(store); + + await store.init(); + await store.registerAgent({ + name, + harness, + cwd: `/test/${name}`, + pid: process.pid, + visibility: "visible", + tags: [], + }); + + return { store, tool, deliveries }; +} + +void test("WireMeshTransport: coordinator discovery, rooms, messaging, and delivery match TlsTransport's own semantics", async () => { + const port = 20_000 + Math.floor(Math.random() * 1000); + const a = await createStore(port, "peer-a", "test-a"); + let b: Awaited> | undefined; + + try { + b = await createStore(port, "peer-b", "test-b"); + + await waitFor( + () => a.store.serialise().agents[b?.store.peerId ?? ""] !== undefined, + "A sees B's agent", + ); + await waitFor( + () => b?.store.serialise().agents[a.store.peerId] !== undefined, + "B sees A's agent", + ); + + const agentsA = await a.store.listAgents(a.store.peerId); + const agentsB = await b.store.listAgents(b.store.peerId); + assert.ok(agentsA.length >= 2, "A should see both agents"); + assert.ok(agentsB.length >= 2, "B should see both agents"); + + const roomId = `test-room-${String(Date.now())}`; + const room = await a.store.createRoom({ + name: roomId, + type: "public", + owner: a.store.peerId, + description: "Test room", + }); + + await waitFor( + () => b?.store.serialise().rooms[room.id] !== undefined, + "B sees the room", + ); + await b.store.joinRoom(room.id, b.store.peerId); + + await waitFor( + () => a.store.serialise().rooms[room.id]?.members.length === 2, + "A sees B join the room", + ); + const roomA = await a.store.getRoom(room.id); + const roomB = await b.store.getRoom(room.id); + assert.strictEqual(roomA?.members.length, 2, "A should see 2 room members"); + assert.strictEqual(roomB?.members.length, 2, "B should see 2 room members"); + + b.deliveries.length = 0; + await a.store.sendRoomMessage(room.id, a.store.peerId, "Hello from A!"); + await waitFor( + () => b?.deliveries.length !== 0, + "B receives the room message", + ); + const roomMsg = b.deliveries[0]; + assert.ok(roomMsg); + assert.strictEqual(roomMsg.type, "room_message"); + assert.strictEqual(roomMsg.message.content, "Hello from A!"); + + b.deliveries.length = 0; + await a.store.sendDm(a.store.peerId, b.store.peerId, "Hey B!"); + await waitFor(() => b?.deliveries.length !== 0, "B receives the DM"); + const dmEvent = b.deliveries[0]; + assert.ok(dmEvent); + assert.strictEqual(dmEvent.type, "dm"); + + const messages = await b.store.readRoomMessages(room.id); + assert.ok(messages.length >= 1, "B should see the message"); + + a.deliveries.length = 0; + const action = buildAction({ + action: "send", + target: room.id, + content: "Hello from B via tool!", + }); + await b.tool.handle( + { + agentId: b.store.peerId, + harness: "test-b", + cwd: "/test/peer-b", + pid: process.pid, + }, + action, + ); + await waitFor( + () => a.deliveries.length !== 0, + "A receives B's tool-sent message", + ); + } finally { + await b?.store.shutdown(); + await a.store.shutdown(); + } +});