Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion src/core/bridge-mesh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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) });
Expand Down
27 changes: 24 additions & 3 deletions src/core/gateway-trust.ts
Original file line number Diff line number Diff line change
@@ -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<GatewayTrust, "isTrusted" | "hasAny">` inline at every field/parameter that takes one. */
export type GatewayTrustReader = Pick<GatewayTrust, "isTrusted" | "hasAny">;

export class GatewayTrust {
private readonly trusted = new Set<string>();
private readonly slot: Readonly<IdentitySlot> | 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<IdentitySlot>) {
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. */
Expand Down
39 changes: 38 additions & 1 deletion src/core/identity-store.ts
Original file line number Diff line number Diff line change
@@ -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.
*
Expand Down Expand Up @@ -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<IdentitySlot>): 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<IdentitySlot>): 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<IdentitySlot>,
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 },
);
}
7 changes: 5 additions & 2 deletions src/core/mesh-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -188,11 +189,13 @@ export class MeshStore implements CommsStore {
constructor(
coordinatorPort: number = DEFAULT_COORDINATOR_PORT,
hubUrl: string = DEFAULT_HUB_URL,
gatewayTrustSlot?: Readonly<IdentitySlot>,
) {
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();
Expand Down
15 changes: 15 additions & 0 deletions src/test/bridge-mesh.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
});
80 changes: 79 additions & 1 deletion src/test/gateway-trust.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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([]);
});
});
Loading
Loading