Repository navigation
A guest's room shows none of the host's Bots: the join envelope carries no roster, and the mirror records at most one remote #904
Description
Priority: P0 — reported by the owner (translated from Chinese, 2026-10-24): "Someone joined my group, but they cannot see my Bots — only I can see their Bot."
The asymmetry is the clue: the host's room lists the guest's seat, the guest's room lists nothing of the host's.
Measured on screen, 2026-10-24 (the owner's screenshot of the guest's side, main 093ff4d4): the guest's room shows Members · 2 — You plus exactly one host-side entry labelled Chief, carrying the admin badge — and the group notice panel is empty ("add a group notice in group settings"). So this is not "some of the host's Bots are missing": it is one host identity at most, and nothing else about the room crosses over.
Read the screenshot against the record, not against the label. The admin badge is the tell: recordInboundSharedRoom initialises admin: localBotId ?? null (:1974), so on a freshly mirrored room the badge can only sit on the guest's own Bot. If Chief carries it, then Chief is the guest's own Bot — and the count of 2 is You + their own Bot, meaning the host side contributes zero entries, not one. That also settles which branch ran: localBotId is set, so :2006 is unreachable and this is the worst case, exactly as :1997 predicts. So the expected count after step 1 is You + own Bot + the seat entry = 3 (and more once the roster lands); do not write a test that expects 2.
Step 1 — the either/or that drops the host's Bot entirely (the minimum fix)
recordInboundSharedRoom (core/src/host/room-membership.js:1955, main 093ff4d4), participants (:1991-2016), writes the local and the remote Bot as alternatives, not as two entries:
:1997 ...(localBotId ? [{
:1998 kind: 'bot', id: localBotId, … // the guest's own Bot
:2006 }] : handle ? [{ // ← never reached when localBotId is set
kind: 'bot', remote: true, id: handle, …A guest who joined with a link always brings a Bot, so localBotId is set, the first branch wins, and the host's seat Bot is dropped whole. The guest's room then contains only "themselves + their own Bot". Reverse direction is unaffected, which is exactly the reported asymmetry.
Acceptance for step 1
- With
localBotIdandhandleboth present,participantscontains both — the red test is written first and must fail on093ff4d4. - The host's seat Bot is recorded the way a ticket seat already is (
admittedBy: 'ticket'), so the existing seat rules (#884/#889) apply to it rather than inventing a fourth participant kind. - The guest's Members card lists that Bot, and the count matches the host's side for the same room, off by nothing.
- No regression: the friend-path share (
#852/#864, no ticket) still records exactly one remote, and the existing tests for it stay green.
Step 2 — the roster: every host Bot, not just the seat
The join envelope (notifyRoomShare, :958-990) carries op, roomId, name, invitedHandle, seq, ticket and the inviter's three fields — no roster; sharedFrom (:1980) keeps three strings. So even after step 1 the guest learns one Bot, and localCalls resolves against currentParticipants, so the others cannot be @-mentioned either.
Scope for step 2
- The
joinpayload carries the room's Bot roster:{handle, name, ownerName}for every participant with a Bot Social identity, plus a marker for the ones without one. Update it on add and on remove (arosterop, or a re-sentjoin) — reuse the existing envelope shape incore/src/shared/room-share.js, do not add a second format. - The same envelope carries the group notice as text.
room.noticeis a real field of a FlyBot group: it is injected into every member's turn (core/src/host/groups.js:273) and a change is announced (:295-303), it is capped atROOM_NOTICE_MAX = 2000(core/src/host/constants.js:41), and it is set on create (core/src/host/roster.js:319) and on edit (:376,:404-407). The mirrored room's literal carries nonoticekey and nothing ever sets one, so the guest's panel stays empty forever. Carry it injoin, write it into the mirrored room, re-send it on edit (the detected change is already right there atroster.js:404-407), and clear the guest's copy when the host clears theirs. Same trust rule as the roster: only from the bound inviter, for the boundroomId. - The guest records one remote participant per roster entry, room-only: not published, not a sidebar friend, reachable only inside this room, and it must survive
syncMembersand reach the Members card. - Removal and leave propagate: a Bot removed from the host's room disappears from the guest's list and its next line is refused with its own sentence.
Attribution of a seat's line is not part of this list — see the section below; :1484 stays as it is.
The envelope silently drops unknown fields. encodeRoomShare (core/src/shared/room-share.js:19-46) copies an allowlist into the body and OPS (:9) is a fixed set, so a roster/notice field or op that is not added there is discarded with no error — the same silent-drop class as #869. Editing notifyRoomShare (:970-987) alone is not enough, and the fields need the same clip/slice caps as their neighbours.
One PR, one tip — the two steps are two halves of one fix
Step 1 alone makes the guest's room show the seat Bot and nothing else; step 2 alone has nothing to build a roster from. Merging them separately puts a wrong state on main — a room where the host appears to be a single Bot — and a person will see it. Two steps, one PR, one tip.
Guardrail — seat attribution stays on seat.handle
Measured on main 093ff4d4, recorded so the next person does not reopen it:
ownSeatOf(:1559-1567) returns undefined on its second line (:1561) forroom.origin === 'inbound-share'and for face rooms, so a guest's mirrored room never routes an inbound line intoingestSeatShare. Adding the seat entry in step 1 does not change that guard; a host Bot's line reaches the guest on the general path, where the author is already taken from the envelope'sauthorIdor the transport'sfromHandle.ingestSeatShare'sauthorId: … : seat.handle(:1484) is not a home-level collapse: seats are written per Bot handle (:1433-1445,for (const handle of fresh)→{ id: handle, handle }), andownSeatOfmatches the participant whosehandleequals the transport'sfromHandle(:1564). On the host sideseat.handlealready is the sending Bot, verified by the transport rather than by the envelope.
Conclusion: :1484 stays as it is. Preferring the envelope's authorId there would let one guest's seat attribute a line to any host Bot — a downgrade, not a fix. A change is allowed only if a test first shows a seat that is home-level rather than per Bot; nothing in 093ff4d4 does that.
Security requirement — the roster is stranger-controlled content
Reviewer will hold the PR to this, and it is part of the acceptance, not advice:
- A roster is accepted only when the envelope's
opisjoin/roster, itsroomIdis the room this mirror is bound to (sharedFrom.remoteRoomId), and the sender is that room's bound inviter (sharedFrom.inviterHomeandinviterHandle). Otherwise the envelope is dropped whole: no participant, no reach, no count change. - Every roster entry is either verified against Bot Social's own facts (this handle is in that room, this home holds the seat) or marked unverified. An unverified entry is never shown as joined and never gets a send path — a handle the envelope invents must not be able to receive the guest's lines.
- An envelope-supplied
authorIdis a claim, not proof. The real spoofing entry is the guest's line handler, not the seat path:applyInboundRoomSharetakesauthorId: data.authorId || fromHandle(:1926) andauthorKind: data.authorKind || 'human'(:1927) straight from the envelope, and:1930copies the same value intobotId. That is harmless only while no roster exists. Once step 2 lands, a line is attributed to a Bot only if that handle maps onto an entry already verified for this room; otherwise it falls back tofromHandle, is marked unverified, and is never signed with the envelope's name and the roster Bot's identity.authorKindfollows the same rule — the envelope cannot turn a person into a Bot or a Bot into a person. - What the roster decides is who is displayed. Reachability still comes from the server; a roster never widens it.
Acceptance for step 2
- The guest's Members card lists every host Bot that has a Bot Social identity, by name and owner; the guest can
@one and the line reaches that Bot — not the inviter's Bot and not the seat. - Adding or removing a Bot on the host side changes the guest's list, and a removed Bot's next line is refused.
- Incoming lines keep the real author. This one only becomes meaningful after step 1 lands: it is not expected to be red on
093ff4d4, because the path that would collapse the author is not reached on the guest side today (ownSeatOf:1561). Write it as the assertion that must hold once the seat entry exists. - An envelope whose
authorIdnames a handle not in the verified roster is shown as unverified (or as the inviter), never as that Bot — and the line still appears. Red after the first two steps land. - Removing a host Bot leaves the guest's copy soft-left (
leftSeq), the count drops, history keeps rendering, and that Bot's next line is refused; a later re-add clearsleftSeqand the entry returns once, not twice. - An envelope with no
rosterfield changes nothing at all — it is not "the roster is now empty". Only a roster the envelope actually carries is diffed against what the guest holds. - A host Bot with no Bot Social identity is listed as "owner's Bot (not connected)" and no send is ever attempted to it.
- The guest's group settings show the host's notice; editing it on the host side updates the guest's; clearing it on the host side empties the guest's.
- The notice is accepted only from the bound inviter for the bound
roomId: the same text in any other envelope leaves the guest's notice unchanged. - Negative controls: (a) a roster from a home other than the room's inviter, or from a seat, changes nothing; (b) a roster naming another
roomIdchanges nothing; (c) a roster entry that does not exist in Bot Social's facts for that room is shown as unverified and is not reachable; (d) a line whoseauthorIdnames a Bot not in the verified roster is not displayed as that Bot; (e) a Bot learned from a roster cannot be messaged outside this room. - Every refusal has its own sentence; no raw code reaches the person.
Blocking review points (from Reviewer, restated so the PR can be checked line by line)
authorIdis a claim, not evidence — and the guest's line handler is where it bites.applyInboundRoomSharecopiesdata.authorId/data.authorKind/botIdstraight from the envelope (:1926,:1927,:1930). When the value does not map onto an identity already verified for this room, the display falls back tofromHandle/ the inviter and is marked unverified — never the envelope's name paired with a roster Bot's identity, or the room shows "Chief" over someone else's content. (:1484is a different path and stays untouched — see the guardrail above.)- A failed check must not swallow the message. The line still appears, marked unverified. If a forged roster could make the guest silently drop lines, the forgery becomes a censorship switch.
currentParticipantsis read in 49 places in this file (approvals, grants,localCalls, member sync, sidebar-adjacent lookups). A roster entry must gain exactly one thing — "this handle can be called inside this room" — and must not inherit any local privilege from being a participant. Sweep every call site and say in the PR which of them had to learn about the new entry.- Removal is immediate, via the file's own soft leave. Do not splice the entry out of
participants: history rows point at it, andcurrentParticipantsalready filtersleftSeq === undefined(:64). SetleftSeqthe way:1858-1859does, so the count drops, the old lines still render, and the next line to that Bot is refused (:1187-1189is the same shape). Re-entry clears it (:1902-1905), and:1436already filters a previously left record before adding the handle back — so a re-deliveredjoinproduces one entry, not two. This path must be idempotent: the same envelope twice changes nothing, andleftSeqnever ends up on a Bot that is still in the roster. - End-to-end evidence has to start from a case that is red on
093ff4d4. "Green now, green after" is not evidence that the change did anything. - An absent field is not an empty field. A
joinwith norosterkey must leave the guest's remote list exactly as it is — otherwise an older sender's envelope would silently drop the guest back to "one visible Bot". The same holds fornotice: absent means unchanged, only an explicit empty string clears it.
Where the tests go
core/test/remote-room.test.js already drives recordInboundSharedRoom and applyInboundRoomShare with a fake transport: extend the fixture so a join carries a roster of three or four Bots (one without an identity, one that does not exist on the server) and assert the participant count, the verified/unverified split, and authorId after lines from two different host Bots. Add the notice to the same fixture: join sets it, a second envelope of the same shape updates it, and the clearing case empties it — plus one case where the notice arrives from a sender other than the bound inviter and changes nothing. plugins/social/test/social.test.js needs a case only if the envelope shape changes.
Related
#878 — one ticket, a home's identities; #884 / #889 — the seat a link admits; #890 — shares reach exactly their seat; #891 — the Members card and Join, where this list shows; #864 — the envelope path; #853 — the stranger path this completes.
Activity
Birfy commentedon Oct 24, 2026
Written-by: Dev3
Claimed; branch
fix/904-guest-room-roster, PR #907.Tip
58d69c7f. Local: core 1337/1337, social 402/402, arch-docs OK, patches check OK, client --check OK, oxlint 0 errors. Red on the base tree: the#904cases are 3 fail / 1 pass (the pass is the negative control).Scope note: this PR makes the host's Bots visible and distinguishable on the guest side. Wire reachability for a non-seat host Bot still needs Bot Social's room claim (#60/#883), so the guest can list a host Bot before it can be handed a line by that Bot — that half is not in this PR.
fix(rooms): list the host's Bots in a guest's copy of the room (#904)
fix(rooms): list the host's Bots in a guest's copy of the room (#904)
fix(rooms): list the host's Bots and notice in a guest's copy (#904)
fix(rooms): list the host's Bots and notice in a guest's copy (#904)
fix(rooms): list the host's Bots and notice in a guest's copy (#904)
fix(rooms): list the host's Bots and notice in a guest's copy (#904)
fix(rooms): list the host's Bots and notice in a guest's copy (#904)
fix(rooms): list the host's Bots and notice in a guest's copy (#904)
fix(rooms): list the host's Bots and notice in a guest's copy (#904)
fix(rooms): list the host's Bots and notice in a guest's copy (#904)
fix(rooms): list the host's Bots and notice in a guest's copy (#904)
fix(rooms): list the host's Bots and notice in a guest's copy (#904)
fix(rooms): list the host's Bots and notice in a guest's copy (#904)
fix(rooms): list the host's Bots and notice in a guest's copy (#904)
Let the transport, not the payload, decide an inbound room share (Thi…
fix(rooms): list the host's Bots in a guest's copy of the room (Think…
docs(rooms): say what noticeOf does with an empty notice (ThinkFlowLa…
Read a link's seats through homesOf, so a room older than the field e…