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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@
"cc-peer": "1.4.1",
"preact": "10.29.7",
"typebox": "1.3.6",
"wire-mesh-core": "1.30.1",
"wire-mesh-core": "1.48.2",
"ws": "8.21.1",
"zod": "4.4.3"
},
Expand Down
10 changes: 5 additions & 5 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

42 changes: 40 additions & 2 deletions src/core/gossip-directory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,20 @@
*/

import { deviceIdToHex } from "wire-mesh-core/domain/device-id";
import type { DirectoryEntry } from "wire-mesh-core/domain/mesh-session";
import type {
AcceptedMeshSession,
DirectoryEntry,
} from "wire-mesh-core/domain/mesh-session";
import type { PeerAdvert } from "wire-mesh-core/generated/protocol";
import { AgentStatus } from "./types.js";
import { PRESENCE_GOSSIP_KEY } from "./wire-mesh-transport.js";
import {
AGENT_SELF_GOSSIP_KEY,
HOSTED_ROOMS_GOSSIP_KEY,
PRESENCE_GOSSIP_KEY,
type AgentSelfAdvert,
type HostedRoomAdvert,
} from "./wire-mesh-transport.js";
import type { HubSession } from "./hub-session.js";

/** Merges one session event's own directory into the mesh-wide knownDevices view (mutated in place), keeping the newer advert (by snapshot-seconds) whenever a device-id is already known from an earlier event or a different session. */
export function mergeKnownDevices(
Expand Down Expand Up @@ -37,3 +47,31 @@ export function findPresenceAdvert(
const status: unknown = entry.advert[PRESENCE_GOSSIP_KEY];
return AgentStatus.is(status) ? status : undefined;
}

/** Re-sends this side's own current presence status and currently-hosted rooms, together, onto every live session's gossip self-advert -- one gossip frame per tick carrying whichever of the two sources is wired in, rather than a separate frame per fact. Split out of wire-mesh-transport.ts's own WireMeshTransport class purely to keep that file under the repo's max-lines cap, the same reason mergeKnownDevices/findPresenceAdvert above already live here rather than there. A session that fails to send (mid-disconnect, most likely -- watchForDisconnect will independently notice and clean it up) is reported via onError and skipped, not allowed to stop the tick from reaching the rest of allSessions: a periodic broadcast to N peers is N independent operations, not one atomic unit. A no-op tick (neither source wired in, or no sessions exist yet) is expected and silent. The hub's own session (agent-comms#156) is gated separately from every ordinary local-peer session in allSessions: local mesh trust is a different layer (connect_request/introduce approval already gated it before it ever joined allSessions), but the hub session is a broadcast to every connected hub peer, trusted or not, and would otherwise leak this side's own presence/hosted-rooms/self-agent advert onto the hub regardless of GatewayTrust -- forwardAdvertsToHub/pushHubCatchUp's own hasAny gate exists to prevent exactly this for OTHER local peers' adverts, and this side's own self-advert deserves the identical gate, not a bypass. */
export function readvertiseGossip(
allSessions: ReadonlySet<AcceptedMeshSession>,
hub: Readonly<Pick<HubSession, "ownsSession">>,
hasAnyTrustedGateway: () => boolean,
onError: ((error: Error) => void) | undefined,
getCurrentPresence: (() => AgentStatus | undefined) | undefined,
getHostedRooms: (() => readonly HostedRoomAdvert[]) | undefined,
getSelfAgentAdvert: (() => AgentSelfAdvert | undefined) | undefined,
): void {
const extensions: Record<string, unknown> = {};
const status = getCurrentPresence?.();
if (status !== undefined) extensions[PRESENCE_GOSSIP_KEY] = status;
const hostedRooms = getHostedRooms?.();
if (hostedRooms !== undefined)
extensions[HOSTED_ROOMS_GOSSIP_KEY] = hostedRooms;
const selfAgentAdvert = getSelfAgentAdvert?.();
if (selfAgentAdvert !== undefined)
extensions[AGENT_SELF_GOSSIP_KEY] = selfAgentAdvert;
if (Object.keys(extensions).length === 0) return;
for (const session of allSessions) {
if (hub.ownsSession(session) && !hasAnyTrustedGateway()) continue;
session.sendGossipUpdate(extensions).catch((error: unknown) => {
onError?.(error instanceof Error ? error : new Error(String(error)));
});
}
}
14 changes: 14 additions & 0 deletions src/core/hub-forwarding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type {
PeerAdvert,
} from "wire-mesh-core/generated/protocol";
import type {
AcceptedMeshSession,
DirectoryEntry,
ManageOutcome,
} from "wire-mesh-core/domain/mesh-session";
Expand Down Expand Up @@ -47,6 +48,19 @@ export function pushHubCatchUp(
forwardAdvertsToHub(hub, catchUp, onError, hasAnyTrustedGateway);
}

/** Sends a room-domain manage-request to a LOCAL peer session only (peerSessions), never falling back to hub routing -- HubSession's own toDevice-forwarding leg (agent-comms#184: a hub-relayed request explicitly addressed to a non-gateway local peer this gateway also fronts), wired in as WireMeshTransport's forwardToLocalPeer dependency. Returns undefined when no local session exists for that device-id, in which case HubSession falls back to dispatching the request against this gateway's own local state instead. */
export function sendToLocalPeer(
peerSessions: ReadonlyMap<string, AcceptedMeshSession>,
memberId: string,
command: ManageCommand,
scope: Readonly<CapabilityScope>,
token?: CapabilityToken,
): Promise<ManageOutcome> | undefined {
const session = peerSessions.get(memberId);
if (session === undefined) return undefined;
return session.sendManageRequest(command, scope, undefined, token);
}

/** 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">>,
Expand Down
38 changes: 35 additions & 3 deletions src/core/hub-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,13 @@ export interface HubSessionDeps {
) => Promise<void>;
/** The gateway trust boundary (agent-comms#156): whether the given device-id (hex) is currently trusted. Checked against every gossiped directory entry's own device and every relayed request's own fromDevice before this side merges or dispatches it -- see consume()/connect()'s own doc comments for exactly where and why. */
isTrusted: (deviceHex: string) => boolean;
/** Forwards a room-domain manage-request on to a specific LOCAL peer session (one this gateway is directly connected to over the ordinary local mesh, keyed by device-id hex) rather than dispatching it against this gateway's own local state -- consume()'s own toDevice disambiguation (agent-comms#184, wire-mesh-core 1.48.1's IncomingManageRequest.toDevice). Returns undefined when no local session exists for that device-id, in which case consume() falls back to handleRoomRequest exactly as it always has. */
forwardToLocalPeer: (
deviceHex: string,
command: ManageCommand,
scope: Readonly<CapabilityScope>,
token?: CapabilityToken,
) => Promise<ManageOutcome> | undefined;
}

export class HubSession {
Expand Down Expand Up @@ -132,14 +139,14 @@ export class HubSession {
if (event.state.status === "closed") break;
}
})();
this.consume(session);
this.consume(session, deviceIdToHex(identity.deviceId));
void (async () => {
await this.watchDisconnect(session);
})();
}

/** Dispatches inbound relayed manage-requests: each is handled with a handle keyed by the SENDING device (request.fromDevice names it on relay-routed requests), so every downstream consumer sees the true origin, never the hub -- the same discipline extends to a real room-domain verb (agent-comms#155's "remote to local" leg) as it already applied to the legacy opaque-frame path. Every request is first checked against the gateway trust boundary (agent-comms#156, deps.isTrusted): a request with no fromDevice at all (senderHex falls back to the literal string "hub-peer", never a real trusted device-id) or an unrecognised fromDevice is never dispatched to either path below -- a legacy FRAME_VERB message is silently dropped (matching isStateMutatingMessage's own swallow-and-ack style, so an untrusted sender learns nothing about why), and a room-domain request gets an explicit `unauthorized` error rather than being dispatched, so its caller fails fast instead of waiting out HUB_ROOM_REQUEST_TIMEOUT_MS's local-session-side counterpart. A legacy FRAME_VERB carrying state_sync/state_update is dropped before ever reaching onMessage/applyPatch even from an otherwise-trusted sender -- see isStateMutatingMessage's own doc for why: gateway trust says "this device's traffic is worth acting on," not "this device may directly overwrite this side's mesh state," which is a strictly stronger claim the trust boundary here was never meant to grant (a security review finding on agent-comms#169). A real room-domain verb (room.send, room.join, room.notify, ...) carries no equivalent risk -- it is independently gated by its own room:member capability token, verified regardless of which transport path it arrived over -- so a trusted sender's request is dispatched to the same roomVerbHandlers a local peer session's own drainSession uses, via handleRoomRequest, against THIS side's own local mesh state. Known limitation, inherited from wire-mesh-core's own session layer rather than something agent-comms can fix here: a session tracks at most one active relay pairing per remote device (mesh-session.ts's own single relayPeerDevice slot), so a request relayed here is dispatched as "addressed to this gateway's own agent" unconditionally -- there is no target-device disambiguation available to route it on to a DIFFERENT local peer this gateway also advertises. Forwarding this gateway's own agent's traffic is therefore correct; a remote request genuinely meant for another local peer behind this same gateway is not yet distinguishable from one meant for this gateway's own agent. */
private consume(session: AcceptedMeshSession): void {
/** Dispatches inbound relayed manage-requests: each is handled with a handle keyed by the SENDING device (request.fromDevice names it on relay-routed requests), so every downstream consumer sees the true origin, never the hub -- the same discipline extends to a real room-domain verb (agent-comms#155's "remote to local" leg) as it already applied to the legacy opaque-frame path. Every request is first checked against the gateway trust boundary (agent-comms#156, deps.isTrusted): a request with no fromDevice at all (senderHex falls back to the literal string "hub-peer", never a real trusted device-id) or an unrecognised fromDevice is never dispatched to either path below -- a legacy FRAME_VERB message is silently dropped (matching isStateMutatingMessage's own swallow-and-ack style, so an untrusted sender learns nothing about why), and a room-domain request gets an explicit `unauthorized` error rather than being dispatched, so its caller fails fast instead of waiting out HUB_ROOM_REQUEST_TIMEOUT_MS's local-session-side counterpart. A legacy FRAME_VERB carrying state_sync/state_update is dropped before ever reaching onMessage/applyPatch even from an otherwise-trusted sender -- see isStateMutatingMessage's own doc for why: gateway trust says "this device's traffic is worth acting on," not "this device may directly overwrite this side's mesh state," which is a strictly stronger claim the trust boundary here was never meant to grant (a security review finding on agent-comms#169). A real room-domain verb (room.send, room.join, room.notify, ...) carries no equivalent risk -- it is independently gated by its own room:member capability token, verified regardless of which transport path it arrived over. Multi-device gateway routing (agent-comms#184, wire-mesh-core 1.48.1's own IncomingManageRequest.toDevice, read directly from each relay-data frame's own to-device field rather than guessed from pairing state): when the request carries a toDevice that names a different device than ownDeviceHex, the command is first re-stamped with an "on-behalf-of" params field naming senderHex (already verified trusted above) before deps.forwardToLocalPeer forwards it on to that device's own local mesh session (one this gateway is directly connected to, never merely gossiped-about) -- room-router.ts's own resolveHandle reads that field back out on the receiving end, so the forwarded request is attributed to the true remote sender there, not to this gateway, matching this same method's own "every downstream consumer sees the true origin, never the hub" discipline for the local hop too. Its outcome is relayed straight back. Only when forwardToLocalPeer finds no such local session (toDevice is absent, matches ownDeviceHex, or names a device this gateway doesn't actually front) does the request fall through to handleRoomRequest, dispatched to the same roomVerbHandlers a local peer session's own drainSession uses, against THIS side's own local mesh state -- correct for traffic genuinely addressed to this gateway's own agent, and the same fallback a sender still on a pre-#184 wire-mesh-core (never stamping toDevice at all) already relied on. */
private consume(session: AcceptedMeshSession, ownDeviceHex: string): void {
void (async () => {
for await (const request of session.incomingManageRequests) {
if (this.deps.isShuttingDown()) break;
Expand Down Expand Up @@ -167,6 +174,31 @@ export class HubSession {
await request.respond({ result: "ok" }).catch(() => undefined);
continue;
}
const toDeviceHex =
request.toDevice !== undefined
? deviceIdToHex(request.toDevice)
: undefined;
if (toDeviceHex !== undefined && toDeviceHex !== ownDeviceHex) {
// Stamps the already-verified true sender (senderHex -- trusted above, never the "hub-peer" fallback, since an untrusted or fromDevice-less request already continued away) onto the forwarded command's own params, so the local peer's own resolveHandle (room-router.ts) can attribute the request to senderHex instead of this side's own device once it arrives over that peer's ordinary local-mesh session -- otherwise every downstream consumer at the local peer would see this gateway as the requester, never the real remote origin, defeating consume()'s own "true origin, never the hub" discipline for this forwarded leg specifically.
const forwardedCommand = {
...request.command,
params: {
...request.command.params,
"on-behalf-of": senderHex,
},
};
const forwarded = this.deps.forwardToLocalPeer(
toDeviceHex,
forwardedCommand,
request.scope,
request.token,
);
if (forwarded !== undefined) {
const outcome = await forwarded;
await request.respond(outcome).catch(() => undefined);
continue;
}
}
await this.deps.handleRoomRequest(request, handle);
}
})();
Expand Down
Loading
Loading