Skip to content

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

Closed

Description

@Birfy

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 localBotId and handle both present, participants contains both — the red test is written first and must fail on 093ff4d4.
  • 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

  1. The join payload 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 (a roster op, or a re-sent join) — reuse the existing envelope shape in core/src/shared/room-share.js, do not add a second format.
  2. The same envelope carries the group notice as text. room.notice is 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 at ROOM_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 no notice key and nothing ever sets one, so the guest's panel stays empty forever. Carry it in join, write it into the mirrored room, re-send it on edit (the detected change is already right there at roster.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 bound roomId.
  3. 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 syncMembers and reach the Members card.
  4. 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) for room.origin === 'inbound-share' and for face rooms, so a guest's mirrored room never routes an inbound line into ingestSeatShare. 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's authorId or the transport's fromHandle.
  • ingestSeatShare's authorId: … : 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 }), and ownSeatOf matches the participant whose handle equals the transport's fromHandle (:1564). On the host side seat.handle already 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 op is join/roster, its roomId is the room this mirror is bound to (sharedFrom.remoteRoomId), and the sender is that room's bound inviter (sharedFrom.inviterHome and inviterHandle). 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 authorId is a claim, not proof. The real spoofing entry is the guest's line handler, not the seat path: applyInboundRoomShare takes authorId: data.authorId || fromHandle (:1926) and authorKind: data.authorKind || 'human' (:1927) straight from the envelope, and :1930 copies the same value into botId. 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 to fromHandle, is marked unverified, and is never signed with the envelope's name and the roster Bot's identity. authorKind follows 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 authorId names 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 clears leftSeq and the entry returns once, not twice.
  • An envelope with no roster field 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 roomId changes 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 whose authorId names 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)

  1. authorId is a claim, not evidence — and the guest's line handler is where it bites. applyInboundRoomShare copies data.authorId / data.authorKind / botId straight from the envelope (:1926, :1927, :1930). When the value does not map onto an identity already verified for this room, the display falls back to fromHandle / 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. (:1484 is a different path and stays untouched — see the guardrail above.)
  2. 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.
  3. currentParticipants is 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.
  4. Removal is immediate, via the file's own soft leave. Do not splice the entry out of participants: history rows point at it, and currentParticipants already filters leftSeq === undefined (:64). Set leftSeq the way :1858-1859 does, so the count drops, the old lines still render, and the next line to that Bot is refused (:1187-1189 is the same shape). Re-entry clears it (:1902-1905), and :1436 already filters a previously left record before adding the handle back — so a re-delivered join produces one entry, not two. This path must be idempotent: the same envelope twice changes nothing, and leftSeq never ends up on a Bot that is still in the roster.
  5. 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.
  6. An absent field is not an empty field. A join with no roster key 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 for notice: 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

  1. self-assigned this
    on Oct 24, 2026
  2. added
    agent-readyMaintainer-approved spec: an agent may take it. Only maintainers set this; never in a template
    area:socialFriends, invites, grants, Bot Social
    area:uiWeb/desktop UI and copy
    on Oct 24, 2026
  3. Birfy commented on Oct 24, 2026

    @Birfy
    CollaboratorAuthor

    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 #904 cases 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.

  4. added
    agent-workingAn agent has claimed this issue (the claim comment names the branch)
    on Oct 24, 2026
  5. removed
    agent-readyMaintainer-approved spec: an agent may take it. Only maintainers set this; never in a template
    agent-workingAn agent has claimed this issue (the claim comment names the branch)
    on Oct 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

area:socialFriends, invites, grants, Bot Socialarea:uiWeb/desktop UI and copybugSomething isn't working

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions