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
1 change: 1 addition & 0 deletions src/core/bridge-mesh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ export function createBridgeMeshSyncFromIdentity(
() => store.hostedRooms,
dataStorage,
() => store.selfAgentAdvert,
store.gatewayTrust,
),
);
const versionChecker = new VersionDriftChecker({
Expand Down
15 changes: 15 additions & 0 deletions src/core/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@ export const MCP_TOOL_PARAMS = z.object({
"mesh_listeners",
"mesh_set_visibility",
"mesh_get_visibility",
"gateway_trust",
"gateway_untrust",
"gateway_list_trusted",
]),
name: z.string().optional(),
visibility: VisibilityEnum.optional(),
Expand Down Expand Up @@ -95,6 +98,8 @@ export const MCP_TOOL_PARAMS = z.object({
capability: z.string().optional(),
expires: z.number().optional(),
delegationsRemaining: z.number().optional(),
/** A remote gateway's own device-id (hex), for gateway_trust/gateway_untrust. */
device: z.string().optional(),
});

export type ToolParams = z.infer<typeof MCP_TOOL_PARAMS>;
Expand Down Expand Up @@ -376,6 +381,16 @@ export function buildAction(params: Record<string, unknown>): CommsAction {
};
return result;
}
case "gateway_trust":
if (p.device === undefined)
throw new BuildActionError("gateway_trust", "device");
return { action: "gateway_trust", device: p.device };
case "gateway_untrust":
if (p.device === undefined)
throw new BuildActionError("gateway_untrust", "device");
return { action: "gateway_untrust", device: p.device };
case "gateway_list_trusted":
return { action: "gateway_list_trusted" };
default:
return p.action satisfies never;
}
Expand Down
40 changes: 40 additions & 0 deletions src/core/gateway-trust.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/**
* 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.
*
* 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.
*/
/** 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>();

/** 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. */
add(deviceHex: string): void {
this.trusted.add(deviceHex.toLowerCase());
}

/** 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. */
remove(deviceHex: string): void {
this.trusted.delete(deviceHex.toLowerCase());
}

/** Every currently trusted device-id, lowercase hex, in insertion order. */
list(): string[] {
return [...this.trusted];
}

/** Whether the given device-id (hex, case-insensitive) is currently trusted. */
isTrusted(deviceHex: string): boolean {
return this.trusted.has(deviceHex.toLowerCase());
}

/**
* Whether at least one remote device is currently trusted -- the outbound gossip gate. wire-mesh-core's relay-hub broadcasts a gossiped advert to every connected hub peer with no per-recipient targeting (RelayHub.handleConnection's own "re-broadcasts each gossip frame to every other connected client"), so "advertise local agents only to allowlisted remote gateways" can only be approximated at the coarse granularity this side actually controls: don't advertise anything at all until the operator has opted in by trusting at least one remote device. Once true, an advertisement still reaches every hub-connected peer, trusted or not -- the per-device isTrusted() check above is what keeps this side from ACTING on anything an untrusted peer sends back, which is the boundary that actually matters.
*/
hasAny(): boolean {
return this.trusted.size > 0;
}
}
25 changes: 21 additions & 4 deletions src/core/hub-forwarding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,15 @@ import type {
import type { HubSession } from "./hub-session.js";
import { AGENT_SELF_GOSSIP_KEY } from "./wire-mesh-transport.js";

/** Forwards every directory entry carrying an agent/self extension onto hub, if hub currently holds a live connection -- a no-op otherwise, which is every local peer except whichever one is currently the gateway. Called both from WireMeshTransport's own watchForDisconnect (as each local peer session's directory changes) and from connectHub's own initial catch-up push, so this side's already-known local devices reach the hub immediately on taking over the gateway role rather than waiting for their own next periodic gossip tick. Forwards unconditionally on every call (no caching or dedup against a previous call), mirroring relay-hub.ts's own "forward every gossip frame, as received" philosophy: an advert's own snapshot-seconds/addresses are meant to keep propagating as a liveness heartbeat, so suppressing a "duplicate" would silently stop legitimate freshness updates from reaching remote gateways. A forward that fails is reported via onError and swallowed, matching every other best-effort gossip send in this codebase. */
/** Forwards every directory entry carrying an agent/self extension onto hub, if hub currently holds a live connection AND at least one remote gateway is currently trusted -- a no-op otherwise. The trust gate (agent-comms#156, GatewayTrust.hasAny's own doc) is coarse by necessity: wire-mesh-core's relay-hub broadcasts a gossip frame to every connected peer with no per-recipient targeting, so "advertise only to allowlisted remote gateways" can only be approximated as "advertise nothing at all until the operator has trusted someone" -- it is not a per-recipient filter. Called both from WireMeshTransport's own watchForDisconnect (as each local peer session's directory changes) and from connectHub's own initial catch-up push, so this side's already-known local devices reach the hub immediately on taking over the gateway role rather than waiting for their own next periodic gossip tick. Forwards unconditionally on every call once past the gates above (no caching or dedup against a previous call), mirroring relay-hub.ts's own "forward every gossip frame, as received" philosophy: an advert's own snapshot-seconds/addresses are meant to keep propagating as a liveness heartbeat, so suppressing a "duplicate" would silently stop legitimate freshness updates from reaching remote gateways. A forward that fails is reported via onError and swallowed, matching every other best-effort gossip send in this codebase. */
export function forwardAdvertsToHub(
hub: Readonly<Pick<HubSession, "isConnected" | "advertiseDevices">>,
directory: readonly DirectoryEntry[],
onError: ((error: Error) => void) | undefined,
hasAnyTrustedGateway: () => boolean,
): void {
if (!hub.isConnected) return;
if (!hasAnyTrustedGateway()) return;
const eligible = directory.filter(
(entry) => entry.advert[AGENT_SELF_GOSSIP_KEY] !== undefined,
);
Expand All @@ -31,20 +33,21 @@ export function forwardAdvertsToHub(
});
}

/** Catches the hub up with every local device already known at the moment this side becomes the gateway (connectHub's own trailing call, right after hub.connect resolves) -- without this, a device whose own last gossip arrived before this coordinator took over the gateway role would never be (re-)advertised until its own next periodic gossip tick (hub-side state is rebuilt from scratch on every takeover, per coordinator-gateway.ts's own class doc). Reuses forwardAdvertsToHub's own eligibility filter, so only ever a visible, agent/self-bearing device is pushed, exactly as an ordinary directory-change forward would. */
/** Catches the hub up with every local device already known at the moment this side becomes the gateway (connectHub's own trailing call, right after hub.connect resolves) -- without this, a device whose own last gossip arrived before this coordinator took over the gateway role would never be (re-)advertised until its own next periodic gossip tick (hub-side state is rebuilt from scratch on every takeover, per coordinator-gateway.ts's own class doc). Reuses forwardAdvertsToHub's own eligibility filter and trust gate, so only ever a visible, agent/self-bearing device is pushed, and only once a remote gateway is trusted, exactly as an ordinary directory-change forward would. */
export function pushHubCatchUp(
hub: Readonly<Pick<HubSession, "isConnected" | "advertiseDevices">>,
knownDevices: ReadonlyMap<string, Readonly<PeerAdvert>>,
onError: ((error: Error) => void) | undefined,
hasAnyTrustedGateway: () => boolean,
): void {
const catchUp = [...knownDevices.values()].map((advert) => ({
device: advert.device,
advert,
}));
forwardAdvertsToHub(hub, catchUp, onError);
forwardAdvertsToHub(hub, catchUp, onError, hasAnyTrustedGateway);
}

/** Routes a room-domain request through the hub's relay-connect/relay-data pairing when memberId isn't a local peer session -- WireMeshTransport.sendRoomRequest's own fallback, since the member may be a remote agent reachable only via this machine's gateway connection (agent-comms#155's local-to-remote leg). Resolves the same not_connected outcome sendRoomRequest already returned before the hub existed at all when this side isn't currently the gateway. */
/** Routes a room-domain request through the hub's relay-connect/relay-data pairing when memberId isn't a local peer session -- WireMeshTransport.sendRoomRequest's own fallback, since the member may be a remote agent reachable only via this machine's gateway connection (agent-comms#155's local-to-remote leg). WireMeshTransport.sendRoomRequest itself gates memberId against the gateway trust boundary (agent-comms#156) before ever calling this, so by the time this runs memberId is already known-trusted -- this function stays focused on the hub-connectivity outcome alone. Resolves the same not_connected outcome sendRoomRequest already returned before the hub existed at all when this side isn't currently the gateway. */
export async function routeRoomRequestViaHub(
hub: Readonly<Pick<HubSession, "isConnected" | "sendRoomRequest">>,
memberId: string,
Expand All @@ -55,3 +58,17 @@ export async function routeRoomRequestViaHub(
if (!hub.isConnected) return { result: "error", code: "not_connected" };
return hub.sendRoomRequest(memberId, command, scope, token);
}

/** Dials the hub and immediately pushes a catch-up of every already-known local device onto it (agent-comms#154's own hub-connection-establishment sequence, kept together here rather than split across two call-site statements in WireMeshTransport.connectHub) -- without the trailing catch-up, a device whose own last gossip arrived before this coordinator took over the gateway role would stay invisible on the hub until its own next periodic gossip tick. */
export async function connectHubGateway(
hub: Readonly<
Pick<HubSession, "isConnected" | "advertiseDevices" | "connect">
>,
url: string,
knownDevices: ReadonlyMap<string, Readonly<PeerAdvert>>,
onError: ((error: Error) => void) | undefined,
hasAnyTrustedGateway: () => boolean,
): Promise<void> {
await hub.connect(url);
pushHubCatchUp(hub, knownDevices, onError, hasAnyTrustedGateway);
}
Loading
Loading