diff --git a/src/core/bridge-mesh.ts b/src/core/bridge-mesh.ts index 3e8510c8..bd95171e 100644 --- a/src/core/bridge-mesh.ts +++ b/src/core/bridge-mesh.ts @@ -8,6 +8,8 @@ * Also starts this bridge's own VersionDriftChecker (agent-comms#166) and wires its result into the CommsTool it builds, so every real bridge gets npm release-drift reporting on whoami/update for free from this one construction point, with no per-bridge wiring. fetchLatestVersion is exposed purely for tests -- every real caller omits it and gets VersionDriftChecker's own default (a real npm registry lookup); a test that would otherwise trigger real network I/O on every createBridgeMesh call injects a fake resolver instead. * * createBridgeMeshSync/createBridgeMesh own loadOrCreateIdentity's slot lock on the caller's behalf; createBridgeMeshSyncFromIdentity/createBridgeMeshFromIdentity take an already-loaded identity instead and never touch the lock at all -- the cc-peer front (agent-comms#157) uses these directly, via loadIdentityForFront's lock-free load, to build a mesh identity for a not-yet-live session's slot while leaving that slot's own lock free for its real bridge to acquire normally later. + * + * Passes slot through to MeshStore's own constructor (agent-comms#186) so the gatewayTrust allowlist it builds loads whatever remote device-ids were trusted before the last restart, and persists every subsequent addTrustedGateway/removeTrustedGateway back to that same slot's storage. */ import { deviceIdToHex } from "wire-mesh-core/domain/device-id"; @@ -64,7 +66,7 @@ export function createBridgeMeshSyncFromIdentity( // The user-principal identity (agent-comms#160) is shared by every bridge on this machine account -- deliberately not scoped to slot, unlike identity above. userIdentityOptions is empty (the default ~/.agent-comms location); every real bridge shares it, and only tests need an override. const userIdentityOptions = {}; const userIdentity = loadOrCreateUserIdentity(userIdentityOptions); - const store = new MeshStore(coordinatorPort, hubUrl); + const store = new MeshStore(coordinatorPort, hubUrl, slot); store.peerId = deviceIdToHex(Uint8Array.from(identity.deviceId)); // One shared dataStorage instance for both the transport's own data-domain frame responder and the store's own durable-send mint path (P5, agent-comms#50) -- oplogDirFor(slot) needs only the slot, not the async identity below, so this can be constructed synchronously right here. const dataStorage = createNodeFsStorage({ dir: oplogDirFor(slot) }); diff --git a/src/core/gateway-trust.ts b/src/core/gateway-trust.ts index d119f7df..c5fc826a 100644 --- a/src/core/gateway-trust.ts +++ b/src/core/gateway-trust.ts @@ -1,24 +1,45 @@ /** * GatewayTrust -- the cross-machine trust boundary (agent-comms#156, agent-comms#153's third leg): an allowlist of remote device-ids this machine's gateway will advertise its local agents to, accept forwarded hub traffic from, and route outbound hub requests to. Deny-all by default: empty until an operator explicitly trusts at least one remote device, the same no-CA pin-the-key model ordinary peer connections already use. * - * In-memory only, deliberately mirroring the precedent set by v1's own FederationManager.trustedFingerprints (retired with federation.ts, commit 4232b08) -- neither persists to disk, so trust is re-established each run rather than carried across restarts. This isn't a gap being deferred: v1 never persisted its own equivalent allowlist either, so no existing behaviour is being narrowed by keeping this one in memory too. + * Persisted per bridge slot when constructed with one (agent-comms#186), mirroring identity-store.ts's own per-slot room-token/issued-grant persistence: the trusted set is loaded from that slot's own sibling JSON file (identity-store.ts's loadGatewayTrust) on construction, and written back in full (saveGatewayTrust) after every add/remove, so trust survives a gateway restart instead of needing to be re-established every run. The in-memory Set below remains the live source of truth for isTrusted/hasAny at all times; persistence is purely load-on-construct and save-on-mutate. Constructed with no slot, this class keeps the original v1 FederationManager.trustedFingerprints precedent (retired with federation.ts, commit 4232b08): in-memory only, never touching disk. Every pre-#186 construction site (most tests, and any caller with no bridge identity slot to hand) falls into this no-slot case unchanged. * * Keyed by individual device-id, not by "one entry per remote machine": wire-mesh-core's relay-hub protocol (relay-hub.ts, gossip-frame, relay-data-frame) carries no field identifying which remote gateway connection a given directory entry or relayed request actually originated from -- only the entry/request's own device-id, which may be an ordinary local peer forwarded on a remote machine's behalf rather than that machine's own coordinator. Gating per individual device-id is therefore the finest-grained, and only wire-protocol-honest, trust boundary actually implementable without a wire-mesh-core protocol change (deliberately out of scope here, matching agent-comms#156's own "gating the hub itself is out of scope" framing) -- confirmed as the intended granularity by hub-session.ts's own pre-existing isStateMutatingMessage doc comment, which already named this exact gap as "agent-comms#156's own future deliverable" of "per-peer" admission control. An operator who wants every local peer on a remote machine reachable trusts each of that machine's device-ids individually, not just its coordinator's. */ +import type { IdentitySlot } from "./identity-store.js"; +import { loadGatewayTrust, saveGatewayTrust } from "./identity-store.js"; + /** The read-only slice of GatewayTrust every consumer of the trust boundary actually needs (WireMeshTransport, HubSession, hub-forwarding.ts) -- named so call sites that only ever read trust decisions, never mutate them, don't repeat the same `Pick` inline at every field/parameter that takes one. */ export type GatewayTrustReader = Pick; export class GatewayTrust { private readonly trusted = new Set(); + private readonly slot: Readonly | undefined; + + /** Constructs the trust boundary, optionally bound to a bridge identity slot for persistence (agent-comms#186); see this class's own doc comment for what a slot does and doesn't change. Given a slot, immediately loads whatever device-ids were trusted before the last restart into the initial in-memory set. */ + constructor(slot?: Readonly) { + this.slot = slot; + if (slot !== undefined) { + for (const deviceHex of loadGatewayTrust(slot)) { + this.trusted.add(deviceHex); + } + } + } - /** Marks a remote device-id (hex, case-insensitive) as trusted: this side will merge its gossiped directory entries, dispatch its relayed requests, and route outbound hub requests to it. Idempotent. */ + /** Marks a remote device-id (hex, case-insensitive) as trusted: this side will merge its gossiped directory entries, dispatch its relayed requests, and route outbound hub requests to it. Idempotent. Persists the updated set when this instance was constructed with a slot. */ add(deviceHex: string): void { this.trusted.add(deviceHex.toLowerCase()); + this.persist(); } - /** Withdraws a previously trusted device-id (hex, case-insensitive). A no-op if it was never trusted. Mirrors FederationManager.removeTrustedFingerprint's own precedent: already-merged directory entries and in-flight requests are unaffected -- this governs future traffic only. */ + /** Withdraws a previously trusted device-id (hex, case-insensitive). A no-op if it was never trusted. Mirrors FederationManager.removeTrustedFingerprint's own precedent: already-merged directory entries and in-flight requests are unaffected -- this governs future traffic only. Persists the updated set when this instance was constructed with a slot. */ remove(deviceHex: string): void { this.trusted.delete(deviceHex.toLowerCase()); + this.persist(); + } + + /** Writes the complete current trusted set back to this instance's own slot, if it was constructed with one. A no-op for the in-memory-only (no slot) case. */ + private persist(): void { + if (this.slot !== undefined) saveGatewayTrust(this.slot, this.list()); } /** Every currently trusted device-id, lowercase hex, in insertion order. */ diff --git a/src/core/identity-store.ts b/src/core/identity-store.ts index 12008316..8ba9e645 100644 --- a/src/core/identity-store.ts +++ b/src/core/identity-store.ts @@ -1,5 +1,5 @@ /** - * Persistent bridge identity: load-or-create the TLS key material for a (harness, cwd) slot so the device-id -- and therefore the peer and agent ID -- survives restarts. Also persists a per-room capability token set in the same slot, so a room membership grant survives a restart the same way the identity it was minted against does. + * Persistent bridge identity: load-or-create the TLS key material for a (harness, cwd) slot so the device-id -- and therefore the peer and agent ID -- survives restarts. Also persists a per-room capability token set in the same slot, so a room membership grant survives a restart the same way the identity it was minted against does. Also persists a slot's own trusted-gateway device-id set (agent-comms#186) in a sibling JSON file, so GatewayTrust's allowlist survives a restart the same way; unlike the room/group tokens above, this doesn't require an identity to already exist for the slot, since the trusted set has no dependency on this slot's own key material. * * Mesh state stays in memory and on the wire; the only thing on disk is this local credential (plus, now, the tokens minted against it), the same trust model as an SSH key. A lock file holding a PID keeps two live bridges in one slot from sharing an identity, which would put duplicate peer IDs on the mesh; the second bridge runs with an ephemeral identity (the behaviour before persistence) instead. Bridges without a graceful shutdown hook can skip releasing the lock: a stale lock is detected by probing the recorded PID, the same way the coordinator probes for stale agents. * @@ -501,3 +501,40 @@ export function deleteIssuedRoomGrant( ); writeStoredIdentity(identityFile, { ...stored, issuedGrants }); } + +/** A slot's own trusted-gateway allowlist (agent-comms#186) lives in its own sibling JSON file rather than inside the identity file: the trusted set has no dependency on this slot's own key material, so it doesn't share loadRoomTokens/saveRoomToken's "call loadOrCreateIdentity first" requirement, and GatewayTrust can be constructed against a slot before or independently of that slot's identity ever being loaded. */ +function gatewayTrustFilePath(slot: Readonly): string { + const { dir } = slotPaths(slot); + const base = `gateway-trust-${slot.harness}--${slugifyCwd(slot.cwd)}`; + return path.join(dir, `${base}.json`); +} + +/** + * Every remote device-id this slot's gateway currently trusts, lowercase hex, in insertion order. Empty if the slot has never saved a trusted set, or its gateway trust file is missing or unparseable. + */ +export function loadGatewayTrust(slot: Readonly): string[] { + let parsed: unknown; + try { + parsed = JSON.parse(fs.readFileSync(gatewayTrustFilePath(slot), "utf-8")); + } catch { + return []; + } + if (!Array.isArray(parsed)) return []; + return parsed.filter((entry): entry is string => typeof entry === "string"); +} + +/** + * Persists a slot's complete trusted-gateway device-id set, surviving a restart the same way the identity it gates alongside does. Overwrites whatever was saved before in full: GatewayTrust always calls this with its own current list() after every add/remove, so there is no per-device partial update to preserve here the way saveRoomToken preserves other rooms' tokens. + */ +export function saveGatewayTrust( + slot: Readonly, + trusted: readonly string[], +): void { + const { dir } = slotPaths(slot); + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); + fs.writeFileSync( + gatewayTrustFilePath(slot), + `${JSON.stringify(trusted, null, 2)}\n`, + { encoding: "utf-8", mode: 0o600 }, + ); +} diff --git a/src/core/mesh-store.ts b/src/core/mesh-store.ts index 673c7556..2103d64c 100644 --- a/src/core/mesh-store.ts +++ b/src/core/mesh-store.ts @@ -18,6 +18,7 @@ import { COORDINATOR_HOST, DEFAULT_HUB_URL } from "./mesh-store-shared.js"; import type { MeshStoreIdentity } from "./mesh-store-shared.js"; import { CoordinatorGateway } from "./coordinator-gateway.js"; import { GatewayTrust } from "./gateway-trust.js"; +import type { IdentitySlot } from "./identity-store.js"; import { DeliveryEngine } from "./delivery-engine.js"; import { RoomProtocol } from "./room-protocol.js"; import { RoomMessaging } from "./room-messaging.js"; @@ -98,8 +99,8 @@ export class MeshStore implements CommsStore { discovery: DiscoveryManager; - /** The cross-machine trust boundary (agent-comms#156) -- constructed once here (mirroring discovery above) and shared with WireMeshTransport by every construction site (bridge-mesh.ts, test-transport.ts) that passes it into WireMeshTransport's own constructor, so store.addTrustedGateway() and the transport's own hub-forwarding/hub-session gates read the exact same in-memory set. Public so those construction sites can reach it; addTrustedGateway/removeTrustedGateway/listTrustedGateways below are the methods CommsTool actually calls through MeshOnlyFeatures. */ - readonly gatewayTrust = new GatewayTrust(); + /** The cross-machine trust boundary (agent-comms#156), constructed once in the constructor below (mirroring discovery above) and shared with WireMeshTransport by every construction site (bridge-mesh.ts, test-transport.ts) that passes it into WireMeshTransport's own constructor, so store.addTrustedGateway() and the transport's own hub-forwarding/hub-session gates read the exact same set. Persists across restarts (agent-comms#186) when a slot is passed to this store's own constructor; stays in-memory only, exactly as before, for every construction site that omits one. Public so those construction sites can reach it; addTrustedGateway/removeTrustedGateway/listTrustedGateways below are the methods CommsTool actually calls through MeshOnlyFeatures. */ + readonly gatewayTrust: GatewayTrust; private readonly deliveryEngine: DeliveryEngine; private readonly roomProtocol: RoomProtocol; @@ -188,11 +189,13 @@ export class MeshStore implements CommsStore { constructor( coordinatorPort: number = DEFAULT_COORDINATOR_PORT, hubUrl: string = DEFAULT_HUB_URL, + gatewayTrustSlot?: Readonly, ) { this.peerId = nanoid(PEER_ID_LENGTH); this.startedAt = new Date().toISOString(); this.coordinatorPort = coordinatorPort; this.hubUrl = hubUrl; + this.gatewayTrust = new GatewayTrust(gatewayTrustSlot); // Discovery manager — registers available backends this.discovery = new DiscoveryManager(); diff --git a/src/test/bridge-mesh.test.ts b/src/test/bridge-mesh.test.ts index 00caf4a0..47f641d5 100644 --- a/src/test/bridge-mesh.test.ts +++ b/src/test/bridge-mesh.test.ts @@ -125,3 +125,18 @@ test("createBridgeMesh passes an explicit hubUrl through to MeshStore, dialled o await hub.close(); } }); + +test("gateway trust survives a bridge restart: a device trusted before shutdown is still trusted when createBridgeMesh runs again against the same slot (agent-comms#186)", async () => { + const slot = tempSlot("test-harness-gateway-trust"); + const first = await createBridgeMesh(slot); + first.store.addTrustedGateway("aabbccdd"); + expect(first.store.listTrustedGateways()).toEqual(["aabbccdd"]); + await first.store.shutdown(); + + const restarted = await createBridgeMesh(slot); + try { + expect(restarted.store.listTrustedGateways()).toEqual(["aabbccdd"]); + } finally { + await restarted.store.shutdown(); + } +}); diff --git a/src/test/gateway-trust.test.ts b/src/test/gateway-trust.test.ts index d5c34ea5..8b8b7d6d 100644 --- a/src/test/gateway-trust.test.ts +++ b/src/test/gateway-trust.test.ts @@ -1,8 +1,19 @@ /** - * Direct unit tests for GatewayTrust -- the cross-machine trust boundary's own allowlist (agent-comms#156), tested standalone against no real transport or hub socket, mirroring coordinator-gateway.test.ts's own approach for the sibling gateway-lifecycle class. + * Direct unit tests for GatewayTrust -- the cross-machine trust boundary's own allowlist (agent-comms#156), tested standalone against no real transport or hub socket, mirroring coordinator-gateway.test.ts's own approach for the sibling gateway-lifecycle class. Also covers slot-based persistence (agent-comms#186): loading a previously trusted set on construction and writing it back on every add/remove. */ +import * as fs from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; import { describe, expect, it } from "vitest"; import { GatewayTrust } from "../core/gateway-trust.js"; +import type { IdentitySlot } from "../core/identity-store.js"; + +function tempSlot(harness: string): IdentitySlot { + const dir = fs.mkdtempSync( + path.join(tmpdir(), "agent-comms-gateway-trust-test-"), + ); + return { harness, cwd: "/tmp/project", dir }; +} describe("GatewayTrust", () => { it("trusts nobody by default", () => { @@ -65,3 +76,70 @@ describe("GatewayTrust", () => { expect(trust.list()).toEqual(["ddeeff"]); }); }); + +describe("GatewayTrust persistence (agent-comms#186)", () => { + it("constructed with no slot, never touches disk and behaves exactly as before", () => { + const trust = new GatewayTrust(); + trust.add("aabbcc"); + expect(trust.isTrusted("aabbcc")).toBe(true); + expect(trust.list()).toEqual(["aabbcc"]); + }); + + it("constructed with a slot that has never saved a trusted set, starts empty", () => { + const slot = tempSlot("pi"); + const trust = new GatewayTrust(slot); + expect(trust.hasAny()).toBe(false); + expect(trust.list()).toEqual([]); + }); + + it("loads a previously persisted trusted set on construction", () => { + const slot = tempSlot("pi"); + new GatewayTrust(slot).add("aabbcc"); + + const restarted = new GatewayTrust(slot); + + expect(restarted.isTrusted("aabbcc")).toBe(true); + expect(restarted.list()).toEqual(["aabbcc"]); + }); + + it("persists an add immediately, visible to a fresh instance for the same slot without either instance restarting", () => { + const slot = tempSlot("pi"); + const trust = new GatewayTrust(slot); + trust.add("aabbcc"); + + const other = new GatewayTrust(slot); + + expect(other.isTrusted("aabbcc")).toBe(true); + }); + + it("persists a remove, so a restarted instance no longer trusts the removed device", () => { + const slot = tempSlot("pi"); + const first = new GatewayTrust(slot); + first.add("aabbcc"); + first.add("ddeeff"); + first.remove("aabbcc"); + + const restarted = new GatewayTrust(slot); + + expect(restarted.list()).toEqual(["ddeeff"]); + }); + + it("normalises hex case in the persisted set the same way the in-memory set is normalised", () => { + const slot = tempSlot("pi"); + new GatewayTrust(slot).add("AaBbCc"); + + const restarted = new GatewayTrust(slot); + + expect(restarted.list()).toEqual(["aabbcc"]); + }); + + it("two independent slots persist to distinct files and never see each other's trust", () => { + const slotA = tempSlot("pi"); + const slotB = tempSlot("claude-code"); + new GatewayTrust(slotA).add("aabbcc"); + + const restartedB = new GatewayTrust(slotB); + + expect(restartedB.list()).toEqual([]); + }); +}); diff --git a/src/test/identity-store.test.ts b/src/test/identity-store.test.ts index 2555e97e..8bf02455 100644 --- a/src/test/identity-store.test.ts +++ b/src/test/identity-store.test.ts @@ -14,12 +14,14 @@ import { generateIdentity } from "../core/identity.js"; import { toIdentityPort } from "../core/wire-mesh-identity.js"; import { deleteGroupToken, + loadGatewayTrust, loadGroupTokens, loadOrCreateIdentity, loadIdentityForFront, probeSlotOwner, oplogDirFor, releaseIdentityLock, + saveGatewayTrust, saveGroupToken, type IdentitySlot, } from "../core/identity-store.js"; @@ -316,3 +318,71 @@ test("deleteGroupToken is a no-op when nothing was saved for that group path", ( expect(loadGroupTokens(slot)).toEqual({}); releaseIdentityLock(slot); }); + +test("loadGatewayTrust is empty for a slot that has never saved a trusted set", () => { + const { slot } = tempSlot("pi"); + expect(loadGatewayTrust(slot)).toEqual([]); +}); + +test("loadGatewayTrust does not require an identity to have been created first, unlike loadRoomTokens/loadGroupTokens", () => { + const { slot } = tempSlot("pi"); + expect(() => { + saveGatewayTrust(slot, ["aabbcc"]); + }).not.toThrow(); + expect(loadGatewayTrust(slot)).toEqual(["aabbcc"]); +}); + +test("saveGatewayTrust persists the trusted set, loadGatewayTrust reloads the same list", () => { + const { slot } = tempSlot("pi"); + saveGatewayTrust(slot, ["aabbcc", "ddeeff"]); + expect(loadGatewayTrust(slot)).toEqual(["aabbcc", "ddeeff"]); +}); + +test("saveGatewayTrust overwrites the previously saved set rather than merging with it", () => { + const { slot } = tempSlot("pi"); + saveGatewayTrust(slot, ["aabbcc", "ddeeff"]); + saveGatewayTrust(slot, ["112233"]); + expect(loadGatewayTrust(slot)).toEqual(["112233"]); +}); + +test("saveGatewayTrust persists an empty set, clearing whatever was saved before", () => { + const { slot } = tempSlot("pi"); + saveGatewayTrust(slot, ["aabbcc"]); + saveGatewayTrust(slot, []); + expect(loadGatewayTrust(slot)).toEqual([]); +}); + +test("the gateway trust file is written with owner-only permissions", () => { + const { slot, dir } = tempSlot("pi"); + saveGatewayTrust(slot, ["aabbcc"]); + const file = fs + .readdirSync(dir) + .find((f) => f.startsWith("gateway-trust-") && f.endsWith(".json")); + expect(file, `expected a gateway-trust-*.json file in ${dir}`).toBeDefined(); + if (file === undefined) throw new Error("expected a gateway-trust file"); + const mode = fs.statSync(path.join(dir, file)).mode & PERMISSION_BITS_MASK; + expect(mode).toBe(OWNER_ONLY_RW_PERMISSIONS); +}); + +test("the gateway trust file is a sibling of the identity file, distinct per (harness, cwd)", () => { + const { slot, dir } = tempSlot("pi"); + saveGatewayTrust(slot, ["aabbcc"]); + const file = fs + .readdirSync(dir) + .find((f) => f.startsWith("gateway-trust-") && f.endsWith(".json")); + expect(file).toBeDefined(); + if (file === undefined) throw new Error("expected a gateway-trust file"); + expect(path.dirname(path.join(dir, file))).toBe(dir); + expect(file).toContain("pi"); +}); + +test("loadGatewayTrust returns empty for a slot whose gateway trust file is corrupt", () => { + const { slot, dir } = tempSlot("pi"); + saveGatewayTrust(slot, ["aabbcc"]); + const file = fs + .readdirSync(dir) + .find((f) => f.startsWith("gateway-trust-") && f.endsWith(".json")); + if (file === undefined) throw new Error("expected a gateway-trust file"); + fs.writeFileSync(path.join(dir, file), "not valid json{{{"); + expect(loadGatewayTrust(slot)).toEqual([]); +});