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
54 changes: 54 additions & 0 deletions src/core/room-lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import {
import type { DeliveryEngine } from "./delivery-engine.js";
import type { FederationManager } from "./federation.js";
import type { MeshTransport } from "./transport.js";
import type { HostedRoomAdvert } from "./wire-mesh-transport.js";
import type {
AgentIdentity,
AgentStatus,
Expand All @@ -49,6 +50,21 @@ import type {
RoomType,
} from "./types.js";

/** Narrows an untrusted gossiped value (WireMeshTransport.listKnownDevices' own advert["room/hosted"], self-asserted by whichever peer advertised it) into a HostedRoomAdvert -- a malformed or non-conforming entry is silently skipped rather than treated as an error, the same convention presence/status' own gossip consumption already established: this is a discovery hint over self-asserted data, not a security check. */
function isHostedRoomAdvert(value: unknown): value is HostedRoomAdvert {
if (typeof value !== "object" || value === null) return false;
if (!("path" in value) || typeof value.path !== "string") return false;
if (!("name" in value) || typeof value.name !== "string") return false;
if (
!("type" in value) ||
(value.type !== "public" && value.type !== "private")
)
return false;
if (!("description" in value) || typeof value.description !== "string")
return false;
return true;
}

/** The state and collaborators RoomLifecycle needs from MeshStore. rooms/messages/agents/dmRequestsInitiatedByMe are direct references into MeshStore's own fields (dmRequestsInitiatedByMe shared with RoomProtocol, which reads what requestDmAccess writes here); deliveryEngine and federation are the already-constructed instances, narrowed to what room CRUD ever needs. */
export interface RoomLifecycleDeps {
rooms: Map<string, Room>;
Expand Down Expand Up @@ -160,6 +176,44 @@ export class RoomLifecycle {
continue;
result.push(room);
}
for (const discovered of this.listDiscoverableRooms()) {
if (this.deps.rooms.has(discovered.path)) continue;
result.push({
id: discovered.path,
version: 0,
name: discovered.name,
type: discovered.type,
owner: discovered.ownerDeviceId,
createdAt: "",
description: discovered.description,
members: [],
invited: [],
memberJoins: {},
memberLeaves: {},
invitedJoins: {},
invitedLeaves: {},
});
}
return result;
}

/**
* Every public/private room this store has heard gossiped by another device but never joined or otherwise locally recorded -- the read half of P3.8's room-discovery replacement for createRoom's own broadcastPatch (agent-comms#48/#50). Never merged into this.deps.rooms: a gossip hint is not membership, and a room this store was never admitted to has nothing real to synthesize beyond what the advert itself carries. Secret rooms never need filtering here the way listRooms' own local-room check needs -- HostedRoomAdvert's own type field is restricted to "public" | "private" at the source (WireMeshTransport's getHostedRooms), so a secret room is never gossiped under this key at all.
*/
private listDiscoverableRooms(): readonly (HostedRoomAdvert & {
ownerDeviceId: string;
})[] {
const transport = this.deps.requireTransport();
if (transport.listKnownDevices === undefined) return [];
const result: (HostedRoomAdvert & { ownerDeviceId: string })[] = [];
for (const { deviceId, advert } of transport.listKnownDevices()) {
const hosted = advert["room/hosted"];
if (!Array.isArray(hosted)) continue;
for (const candidate of hosted) {
if (!isHostedRoomAdvert(candidate)) continue;
result.push({ ...candidate, ownerDeviceId: deviceId });
}
}
return result;
}

Expand Down
8 changes: 8 additions & 0 deletions src/core/transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -251,4 +251,12 @@ export interface MeshTransport {
* Unref all root handles so the event loop can exit when the agent process shuts down. Sockets still function for I/O but don't keep the process alive.
*/
unref: () => void;

/**
* Every device this side has ever heard gossip from, mesh-wide, with each one's own latest full advert (any open-extension field such as room/hosted). Optional: WireMeshTransport is the only implementation that offers it today (agent-comms#48/#50's own gossip-directory aggregation), so a caller (listRooms' own room-discovery merge) must treat its absence as "nothing to merge," never assume every MeshTransport has it.
*/
listKnownDevices?: () => readonly {
deviceId: string;
advert: Readonly<Record<string, unknown>>;
}[];
}
72 changes: 72 additions & 0 deletions src/test/room-lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,78 @@ describe("RoomLifecycle — listRooms", () => {
const result = await h.lifecycle.listRooms(h.ids.memberId);
expect(result.map((r) => r.id)).toEqual(["sec"]);
});

it("merges in a gossip-discovered room not otherwise locally known, as a placeholder-shaped Room", async () => {
const h = await makeHarness();
h.deps.requireTransport = () =>
({
listKnownDevices: () => [
{
deviceId: h.ids.ownerId,
advert: {
"room/hosted": [
{
path: "discovered-room",
name: "discovered",
type: "public",
description: "found via gossip",
},
],
},
},
],
}) as unknown as ReturnType<RoomLifecycleDeps["requireTransport"]>;

const result = await h.lifecycle.listRooms(h.ids.memberId);
const discovered = result.find((r) => r.id === "discovered-room");
expect(discovered).toMatchObject({
id: "discovered-room",
name: "discovered",
type: "public",
owner: h.ids.ownerId,
description: "found via gossip",
members: [],
});
});

it("never lets a gossip-discovered room shadow a room this store already knows locally", async () => {
const h = await makeHarness();
h.deps.rooms.set(
"already-known",
room({ id: "already-known", type: "public", description: "real" }),
);
h.deps.requireTransport = () =>
({
listKnownDevices: () => [
{
deviceId: h.ids.ownerId,
advert: {
"room/hosted": [
{
path: "already-known",
name: "stale-gossip-copy",
type: "public",
description: "gossip",
},
],
},
},
],
}) as unknown as ReturnType<RoomLifecycleDeps["requireTransport"]>;

const result = await h.lifecycle.listRooms(h.ids.memberId);
expect(result.filter((r) => r.id === "already-known")).toHaveLength(1);
expect(result.find((r) => r.id === "already-known")?.description).toBe(
"real",
);
});

it("ignores a transport with no listKnownDevices capability, matching every construction site that predates this feature", async () => {
const h = await makeHarness();
h.deps.rooms.set("pub", room({ id: "pub", type: "public" }));
const result = await h.lifecycle.listRooms(h.ids.memberId);
expect(result.map((r) => r.id)).toEqual(["pub"]);
});
});

// joinRoom's own `this.deps.rooms.set(roomId, room)` and `this.deps.agents.set(agentId, agent)` calls each have one provable equivalent mutant Stryker still raises: removing them. Both `room` and `agent` are the same object references already fetched via `.get()`, and every mutation up to each call (bump/recordMemberOp/refreshMembership for room; push/bump for agent) already happened in place on that reference -- so re-setting the map entry to the identical reference it already holds changes nothing observable, the same Map.set-same-reference pattern documented throughout this repo's own mutation-testing work (agent-registry.ts's setAgentOffline, delivery-engine.ts's applyPatch(agent_offline)).
Expand Down