diff --git a/changelog.d/social-switch-ends-old-face-rooms.fixed.md b/changelog.d/social-switch-ends-old-face-rooms.fixed.md new file mode 100644 index 000000000..9e852d16a --- /dev/null +++ b/changelog.d/social-switch-ends-old-face-rooms.fixed.md @@ -0,0 +1 @@ +Switching the Bot Social server now turns that server's face-to-face rooms into read-only past rooms instead of leaving them open and accepting lines that can never be delivered, and a line sent to an ended room is refused with a clear message ([#729](https://github.com/ThinkFlowLab/FlyBot/issues/729)). Only a change of address counts: renaming this home on the same server leaves that server's rooms open, and a switch that fails ends none of them. diff --git a/core/src/host/channel.js b/core/src/host/channel.js index b7139fc2f..08faebd2c 100644 --- a/core/src/host/channel.js +++ b/core/src/host/channel.js @@ -373,6 +373,8 @@ export function install(rt) { await rt.load() const leaked = unshareableReference(actor, body.text) if (leaked !== undefined) return fail('You do not own that chat', 'bot/forbidden') + const posted = rt.roomOf(String(body.roomId ?? body.id ?? '')) + if (posted && isFaceRoom(posted) && (posted.face?.status === 'left' || posted.face?.status === 'closed')) return fail('This room has ended', 'bot/room-ended') const line = await rt.postRoomMessage(String(body.roomId ?? body.id ?? ''), { text: body.text, authorId: body.authorId, @@ -514,6 +516,9 @@ export function install(rt) { const room = rt.roomOf(sessionId) if (room && isFaceRoom(room)) { if (actor && rt.authzEnforced() && !rt.roomMemberOf(actor, room.id)) return fail('You are not a member of that room', 'bot/forbidden') + // A room the server no longer reaches (a switched-away server, or a room that left): + // refuse instead of appending a line every other home will never see. + if (room.face?.status === 'left' || room.face?.status === 'closed') return fail('This room has ended', 'bot/room-ended') // Face rooms must not go through sessionController.prompt: that starts a local // bot round (turns/steps) and never fans the line to the other FlyBot instance. const line = await rt.postRoomMessage(room.id, { text, displayName: actor?.displayName }, actor) diff --git a/core/test/room-membership.test.js b/core/test/room-membership.test.js index 2c2f34bd8..322e3d17c 100644 --- a/core/test/room-membership.test.js +++ b/core/test/room-membership.test.js @@ -428,6 +428,28 @@ test('channel: delete-bot answers with the actor\'s scoped view, not the whole t assert.equal(deleted.value.bots.some(bot => bot.id === 'spec-a'), false, "another owner's private Bot stays out of the answer") }) +test('channel: a line to an ended face room is refused, not appended', async (t) => { + const { call, rt } = startChannel(t, TEAM) + await rt.load() + const room = await rt.upsertFaceRoom({ roomId: 'frm_left', localId: 'g-left', status: 'left', name: 'Ended room' }) + const before = room.log.length + const sent = await call('send', { sessionId: 'g-left', text: 'hello' }) + assert.equal(sent.ok, false) + assert.equal(sent.error.code, 'bot/room-ended') + assert.equal(rt.roomOf('g-left').log.length, before, 'nothing was appended to a room no home can reach') +}) + +test('channel: room-post into an ended face room is refused too', async (t) => { + const { call, rt } = startChannel(t, TEAM) + await rt.load() + const room = await rt.upsertFaceRoom({ roomId: 'frm_left', localId: 'g-left', status: 'left', name: 'Ended room' }) + const before = room.log.length + const posted = await call('room-post', { roomId: 'g-left', text: 'hello' }) + assert.equal(posted.ok, false) + assert.equal(posted.error?.code, 'bot/room-ended') + assert.equal(rt.roomOf('g-left').log.length, before, 'nothing was appended') +}) + test('channel: a legacy single-owner create-room still works', async (t) => { const { call } = startChannel(t, TEAM) const invite = (await call('auth-invite', {})).value diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index 1828e9021..e2b9e667f 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -155,7 +155,7 @@ Work a member hands a Bot with `message_bot` from its group Session runs in a hi - Another member's own face-room line arrives through `flybot.rooms.postHuman` (#180): a quiet `postToRoom` post carrying `source.human { authorId, name }`. `group_relay` turns it into a round reply `{ kind: 'user', other: true, name, authorId }` (not the grey `notice`), so it draws as a left-hand bubble from that person; it asks no Bot to answer. `postNotice` stays for system events (joined, left, started). Covered by `core/test/rounds.test.js`. - **Lines from elsewhere mid-turn (#455):** a `postHuman` or `postRemote` post (`source.human` / `source.remote`) that arrives while the group Session is `running` is not `followup`ed, which would park it in DSH's composer queue (raw `[Group post from …]`, steerable). `postToRoom` holds it (`heldPosts`): the round that is running (`rt.roomProgress`) shows it at once, as `personReply` or a `{ kind: 'remote' }` reply (left out of round reports), and a Bot's line is logged then (a person's line is in the log already through `applyFaceLine`); after `agent.whenIdle()` it is `followup`ed with `shown: true`, so its own round routes it by the usual rules but `group_relay` neither shows nor logs it again. With no round running it is only held and goes as before. The same hold covers this home's **own** Bot's line (#595: a late reply or a `post_to_group` that lands mid-turn showed the raw header in the composer queue): an ordinary line is logged and drawn as the ordinary `{ botId, name, color, seq }` bubble its own reply is, and a Bot's round event (`failed`, `stopped`, `resumed`) or other quiet post is held without being drawn, so its header never reaches the composer either and `group_relay` draws it as that event once the turn is done. A room notice (the `face-notice` sender) keeps the queue path: it is not a Bot's line. Covered by `core/test/rounds.test.js`. - `flybot.chatApprovals.changed()` saves the team (moving the revision) so `bot.pendingCount` reaches the browser at once when a hold arrives or is decided (#226). Covered by `core/test/flybot.test.js`. -- The DSH composer still prompts the group Session (`user/message` → `group_relay`). Face-to-face turn rule (#167, changed in #287 and #295), `openingSpeakers`: a **plain** line (no `@` naming a Bot) starts one round in which **every** local Bot may answer, the admin first then the room's order, capped at `FACE_PLAIN_SPEAKERS` (6), one at a time and each after the earlier replies; that round is `single` (`isPlainFaceLine`): replies' own `@`s start no follow-up turns inside it. **Routed by intent (#299):** a plain line that is not `@all` (`route` on the round) also tells each Bot in its prompt (`routeNote`) to answer only when the line is theirs — it names them or their role, no other member's role fits better, or it follows up on their own last answer — and to reply `[PASS]` otherwise (the default; no "add something new" licence); the first Bot that posts, works on it or waits for approval ends a non-broadcast routed round (`answeredAlready`): the Bots after it are marked passed without a model call or a line, so three Bots never post three similar answers. The owner's own typed line reaches a face round through the Session inbox (the round's opener is the quiet `name: text` notice), so `drainRoomInbox` routes it (`routeFor`, `single`, `lastFirst`; lines queued together by the newest one) when it starts the round; a friend's attested plain line routes at the opener the same way (#462): `postHuman` posts it from the quiet `face-notice` sender with `human`, and `isPlainFaceLine` reads such a line by its `plain` flag (`friendLine`), so it gets `route`, `single` and the last speaker first; a friend's line that asks my Bots (`plain` or `callIds`, `friendAsks`) picks the speakers over a Bot post queued with it, while their `@Bot` line (`callIds`) stays unrouted. A line to everyone (`ROOM_EVERYONE`) is not cut; a pass (`isPass`) posts, logs and shows nothing. A line to everyone (`ROOM_EVERYONE`: 你们/大家/everyone/introduce yourselves…) says each may answer. The Bot that spoke last before the line (`recentSpeaker`, a follow-up) is asked first and named in the note. When every Bot passes on the **owner's own** line (not a friend's attested one, whose home answers it; not a follow-up to a friend's Bot), `fallbackDue` appends one turn so the first Bot that passed answers with no pass option. **Other homes' answers count (#475):** each home runs its own round over its own Bots, so a friend's Bot line logged after the person's line and after the round opened (`friendAnswers`; a Bot post queued before the round is not counted, it may be older than the line) ends a non-broadcast routed round like a local answer and cancels `fallbackDue`; a Bot still asked (a line to everyone) is told in `routeNote` that ` already answered this line or posted a plan; add only your role's part`. `everyone` in the post's source marks a friend's `@all` (not routed). Covered by `core/test/rounds.test.js`. `@Name`, the label the picker shows (`Friend's Chief`, `Writer · Friend's Bot`) or a handle picks that local Bot (`face-mentions.js`); `@all` is everyone; a line that only @s a friend's Bot starts none here; A friend's line (`flybot.rooms.postHuman`, only when the server attests `typed_by_owner`, passed as `attested`) names my Bot by label or handle (`callIds` in the post's source, read by `openingSpeakers` before the quiet rule), or is plain or `@all` and then carries `plain: true`, so my Bots answer it in the same single pass (#295); a line that only names a friend's Bot, or one from a server that cannot attest, stays quiet; a bare `@Chief` from a friend whose own Chief is in the room calls nobody here. A Bot's post in a face room still starts only the Bots it @s, so Bots in two homes cannot set each other off; another home's Bot's `@all` / `@所有人` wakes none of mine, only the local Bots it also names (#475). The reply is held / sent under the usual approval and Auto-send rules. An `@` that names nobody in the room adds one quiet notice line (`No one in this room is called @X …`). A face-room line the send path already posted (`rt.notePostedLine`) is not appended or sent again when the round reads it from the Session. **One answer per Bot per line (#456):** a person's plain line (the owner's, opened by `drainRoomInbox` or the opener; a friend's attested `plain` line once it is in the log) opens `room.faceLine` (`seq`, `answered`, `asked` exchange ids); a Bot already in `answered` is passed without a model call, a second answer from the same round is dropped, and `lateRoomReply` drops a late answer from a turn asked for that line when the Bot already answered it. A newer person line ends it. **Held Bot lines (#456):** `fanoutRoomLine` records a local Bot's face line in `room.heldLines` (`seq`, `botId`, `text`, `status: sending`, capped at 50) before the transport sends it; a transport answer `{ held: true, approvalId }` makes it `pending`, anything else drops the record. `rt.settleHeldLine(approvalId, decision)` (`flybot.rooms.settleHeld`) removes it on approve and marks it `rejected` on reject; it is saved with the room, and the roster view carries `heldLines` (not `faceLine`). Covered by `core/test/rounds.test.js`, `core/test/face-mentions.test.js` and `plugins/social/test/social.test.js`. +- The DSH composer still prompts the group Session (`user/message` → `group_relay`). Face-to-face turn rule (#167, changed in #287 and #295), `openingSpeakers`: a **plain** line (no `@` naming a Bot) starts one round in which **every** local Bot may answer, the admin first then the room's order, capped at `FACE_PLAIN_SPEAKERS` (6), one at a time and each after the earlier replies; that round is `single` (`isPlainFaceLine`): replies' own `@`s start no follow-up turns inside it. **Routed by intent (#299):** a plain line that is not `@all` (`route` on the round) also tells each Bot in its prompt (`routeNote`) to answer only when the line is theirs — it names them or their role, no other member's role fits better, or it follows up on their own last answer — and to reply `[PASS]` otherwise (the default; no "add something new" licence); the first Bot that posts, works on it or waits for approval ends a non-broadcast routed round (`answeredAlready`): the Bots after it are marked passed without a model call or a line, so three Bots never post three similar answers. The owner's own typed line reaches a face round through the Session inbox (the round's opener is the quiet `name: text` notice), so `drainRoomInbox` routes it (`routeFor`, `single`, `lastFirst`; lines queued together by the newest one) when it starts the round; a friend's attested plain line routes at the opener the same way (#462): `postHuman` posts it from the quiet `face-notice` sender with `human`, and `isPlainFaceLine` reads such a line by its `plain` flag (`friendLine`), so it gets `route`, `single` and the last speaker first; a friend's line that asks my Bots (`plain` or `callIds`, `friendAsks`) picks the speakers over a Bot post queued with it, while their `@Bot` line (`callIds`) stays unrouted. A line to everyone (`ROOM_EVERYONE`) is not cut; a pass (`isPass`) posts, logs and shows nothing. A line to everyone (`ROOM_EVERYONE`: 你们/大家/everyone/introduce yourselves…) says each may answer. The Bot that spoke last before the line (`recentSpeaker`, a follow-up) is asked first and named in the note. When every Bot passes on the **owner's own** line (not a friend's attested one, whose home answers it; not a follow-up to a friend's Bot), `fallbackDue` appends one turn so the first Bot that passed answers with no pass option. **Other homes' answers count (#475):** each home runs its own round over its own Bots, so a friend's Bot line logged after the person's line and after the round opened (`friendAnswers`; a Bot post queued before the round is not counted, it may be older than the line) ends a non-broadcast routed round like a local answer and cancels `fallbackDue`; a Bot still asked (a line to everyone) is told in `routeNote` that ` already answered this line or posted a plan; add only your role's part`. `everyone` in the post's source marks a friend's `@all` (not routed). Covered by `core/test/rounds.test.js`. `@Name`, the label the picker shows (`Friend's Chief`, `Writer · Friend's Bot`) or a handle picks that local Bot (`face-mentions.js`); `@all` is everyone; a line that only @s a friend's Bot starts none here; A friend's line (`flybot.rooms.postHuman`, only when the server attests `typed_by_owner`, passed as `attested`) names my Bot by label or handle (`callIds` in the post's source, read by `openingSpeakers` before the quiet rule), or is plain or `@all` and then carries `plain: true`, so my Bots answer it in the same single pass (#295); a line that only names a friend's Bot, or one from a server that cannot attest, stays quiet; a bare `@Chief` from a friend whose own Chief is in the room calls nobody here. A Bot's post in a face room still starts only the Bots it @s, so Bots in two homes cannot set each other off; another home's Bot's `@all` / `@所有人` wakes none of mine, only the local Bots it also names (#475). The reply is held / sent under the usual approval and Auto-send rules. An `@` that names nobody in the room adds one quiet notice line (`No one in this room is called @X …`). A face-room line the send path already posted (`rt.notePostedLine`) is not appended or sent again when the round reads it from the Session. A line to a room whose `face.status` is `left` or `closed` is refused by `send` and `room-post` (`bot/room-ended`) and nothing is appended: a room a switched-away server can no longer reach must not look sent (the social plugin marks those rooms `left` when the server changes). **One answer per Bot per line (#456):** a person's plain line (the owner's, opened by `drainRoomInbox` or the opener; a friend's attested `plain` line once it is in the log) opens `room.faceLine` (`seq`, `answered`, `asked` exchange ids); a Bot already in `answered` is passed without a model call, a second answer from the same round is dropped, and `lateRoomReply` drops a late answer from a turn asked for that line when the Bot already answered it. A newer person line ends it. **Held Bot lines (#456):** `fanoutRoomLine` records a local Bot's face line in `room.heldLines` (`seq`, `botId`, `text`, `status: sending`, capped at 50) before the transport sends it; a transport answer `{ held: true, approvalId }` makes it `pending`, anything else drops the record. `rt.settleHeldLine(approvalId, decision)` (`flybot.rooms.settleHeld`) removes it on approve and marks it `rejected` on reject; it is saved with the room, and the roster view carries `heldLines` (not `faceLine`). Covered by `core/test/rounds.test.js`, `core/test/face-mentions.test.js` and `plugins/social/test/social.test.js`. - New Bot model (#410): `roster.js` `createBot` fixes a Bot's model at creation. A Bot made by another Bot (`create_bot`, a tree child via `flybot.bot.create` with `createdBy`) and given no model starts on its maker's effective model (`desiredModel`) and its thinking level resolved against that model (dropped when that model does not serve it), so its first request never goes to an unconfigured profile default. A model the caller names wins; a Bot the user makes in the UI keeps the client's start model (Main Bot's, #230). Covered by `core/test/roster.test.js`. - Silent creation (#645): `createBot` and `create-room` send nothing by themselves. A Bot's model is applied without a turn, and a new group posts no opening round, so making a team costs no model turn. A line the user typed in the new-Bot card is their first request: `new-chat.js` sends it to the new Bot as one user turn and the card carries no `brief`; `roster.js` turns a `brief` into `Why you exist: …` instructions only when a Bot created the Bot (`create_bot`, a tree child), where the brief is a parent's context. The `kickoff` role, the `[Bot team setup]` text and the group-created cue are gone (an older transcript's kickoff line is still skipped by `historyEntry`), and `create_bot` answers that the new Bot starts no turn. Covered by `core/test/roster.test.js`, `core/test/rounds.test.js` and `core/test/new-chat.test.js`. - Every new Bot is announced (`bot.created`) through the `rt.createBot` wrapper, including the first Main Bot `bootstrap` creates on the first `state` request and a `duplicate-bot` copy. Creating those through the module-local `createBot` used to skip the event, so a plugin that adopts new Bots (the tree root) could miss the first Chief or leave a copy outside the tree until a restart. Covered by `core/test/flybot.test.js`. diff --git a/docs/subsystems/social.md b/docs/subsystems/social.md index 73fbce428..bfbd584b9 100644 --- a/docs/subsystems/social.md +++ b/docs/subsystems/social.md @@ -137,7 +137,7 @@ pins the list. The authority for the list is Bot Social's README and `docs/FACE_ - **Where it comes from.** `config.server` in the profile patch is the default. A server picked in Settings → **Bot Social** is kept in `flybot-social-server.json` (mode 600, next to the credentials file) and wins while it exists; choosing the default again removes the file. Nothing configured anywhere behaves as before (the start fails with `server URL is required`, `Social is not running: …`; the page says **No server set**). The page reads `social/server` (a read; `{ server, source: settings|config|none, defaultServer, state: connected|connecting|not-configured|error, handle, error, canChange, known[], platform? }`: never a token) and calls `social/server-check` (it makes the host fetch an address the caller names, so it follows the write rules like `social/server-set`). - **Validation.** `parseServerUrl`: `https://` only, `http://` just for `localhost`, `127.0.0.1` and `[::1]`; no user name or password, query or fragment; at most 200 characters; an address without a scheme gets `https://` (`http://` for those local hosts). `probeServer` GETs `/v1/platform` without credentials, 8 s deadline, `redirect: 'manual'` (a redirect is refused, so the saved address is the checked one); the answer must look like Bot Social (a `name`, a `platform_key` or a `features` list) and its `features` are shown (cleaned, at most 40) in words (`featureLine` in `client.js`: `typed_by_owner` → typing as you, `mentions` → @mentions, `presence` → online status; an unknown id is only counted, and every raw id is in the line's tooltip, #421). `social/server-set` repeats both before it touches anything, so a UI that skipped **Check** gains nothing. - **A server is an identity.** The default server keeps its files. Any other server gets `flybot-social.json.` (credentials; the same suffix `startSocial` already used for backups, `tag` = first 8 hex of the sha-256 of the normalized address) and `flybot-social-settings..json`; a different server never reads another's file, and no credentials file is ever deleted or rewritten by a switch. The per-Bot parts that live in host state (the `member` identity, the `autoApprove` marks and the person/room grants, which are Bot Social ids and handles) are parked in `flybot-social-bots..json` (no token) and cleared before the new server starts, so a stale grant to `@bob.home` can never apply to a different Bob on the new server; switching back restores them and the saved credentials, without a second onboarding. The old server's friends' Bots leave the list when the new server's first roster fetch replaces it (`remote.publish`); face-to-face rooms already hosted stay as local transcripts, but nothing polls them. -- **Applied live, with a way back.** `social/server-set` (needs `confirm: true`; a write, so a read-only host and a multi-member team refuse it) stops the social layer, parks the old server's Bot identities, saves the choice, restores what the new server had, and starts again (first start onboards). If that start fails or takes over 45 s, the previous server's files, identities and choice are put back, it is started again, and the error says so; a start that finishes late is disposed. A disposed instance never re-arms its poll: a face poll already in flight when `dispose()` runs cannot install a new interval on the discarded layer, so it never keeps polling (or delivering from) the server it was built for. The Settings page says plainly that this is a new identity, that friends and sharing stay on the old server, and that the old sign-in is kept as a backup and never deleted. Tests: `plugins/social/test/social.test.js` (`Settings server`, `server controller`, `parseServerUrl`, `probeServer`). +- **Applied live, with a way back.** `social/server-set` (needs `confirm: true`; a write, so a read-only host and a multi-member team refuse it) stops the social layer, parks the old server's Bot identities, saves the choice, restores what the new server had, and starts again (first start onboards). If that start fails or takes over 45 s, the previous server's files, identities and choice are put back, it is started again, and the error says so; a start that finishes late is disposed. A disposed instance never re-arms its poll: a face poll already in flight when `dispose()` runs cannot install a new interval on the discarded layer, so it never keeps polling (or delivering from) the server it was built for. When the switch succeeds and the address really changed, the previous server's hosted face-to-face rooms — open or still forming (candidate) — are marked `left` here — a read-only past room whose composer is gone, since the new server cannot reach them. Renaming this home on the same server is not a switch: that server still reaches those rooms, so only a new address ends them (the room and its composer stay as they were). Switching back does not reopen them: a room this home left stays a past room (`faceSnapshotPast`, #182), and a line to it is refused (`bot/room-ended`). The Settings page says plainly that this is a new identity, that friends and sharing stay on the old server, and that the old sign-in is kept as a backup and never deleted. Tests: `plugins/social/test/social.test.js` (`Settings server`, `server controller`, `parseServerUrl`, `probeServer`). ### Add friend invite input (#310) diff --git a/patches/ds-bot/0005-social-seams.patch b/patches/ds-bot/0005-social-seams.patch index 05948b48a..fa6dccd90 100644 --- a/patches/ds-bot/0005-social-seams.patch +++ b/patches/ds-bot/0005-social-seams.patch @@ -241,7 +241,7 @@ diff --git a/src/host/channel.js b/src/host/channel.js const invalid = message => Object.assign(new Error(message), { code: 'bot/invalid' }) // Programming and file system errors are DS Bot's own faults, logged with their stack; -@@ -21,32 +21,146 @@ export function install(rt) { +@@ -21,32 +30,146 @@ // The DSH interface language of the last browser that asked for the team. let uiLocale @@ -253,7 +253,7 @@ diff --git a/src/host/channel.js b/src/host/channel.js + let watchedAt = 0 + rt.watched = () => Date.now() - watchedAt < WATCH_MS + rt.uiLanguage = () => (uiLocale === undefined ? 'English' : LANGUAGES[uiLocale.split('-')[0].toLowerCase()] ?? uiLocale) -+ + + // A burst of the user's own lines (#642): with no turn running, hold them for a moment and open + // one turn with every line in arrival order. A line that arrives while a turn runs is not held — + // it lands as its own queued item, so a burst never reorders work that is already under way. @@ -327,7 +327,7 @@ diff --git a/src/host/channel.js b/src/host/channel.js + for (const held of settling.values()) clearTimeout(held.timer) + settling.clear() + }, 'ds-bot: settle window') - ++ const ok = value => ({ ok: true, value }) - const fail = (message, code = 'bot/error') => ({ ok: false, error: { code, message, details: {} } }) + const fail = (message, code = 'bot/error', details = {}) => ({ ok: false, error: { code, message, details } }) @@ -397,7 +397,7 @@ diff --git a/src/host/channel.js b/src/host/channel.js // The team's DSH Agent (at most one): plain dsh Sessions, not Bots. agents: Object.values(state.agents), // Key names and who may use them; values never leave the host. -@@ -54,7 +54,8 @@ export function install(rt) { +@@ -54,7 +177,8 @@ capabilities: rt.capabilityLibraryView(), secretRequests: state.secretRequests ?? {}, colors: [...COLORS, ...EXTRA_COLORS], @@ -407,7 +407,7 @@ diff --git a/src/host/channel.js b/src/host/channel.js defaultModel: rt.cachedDefault() ?? null, permissions: rt.permissionCatalog?.() ?? null, modelsKey: modelsKey(), -@@ -65,22 +65,49 @@ export function install(rt) { +@@ -65,23 +189,50 @@ memory: rt.memoryEnabled !== false, // Set while another dsh host holds the team (store.js): why this one cannot change it. readOnly: rt.readOnly() ?? null, @@ -448,7 +448,7 @@ diff --git a/src/host/channel.js b/src/host/channel.js - const READS = new Set(['state', 'models', 'efforts', 'earlier', 'room-progress', 'exchange', 'usage', 'schedules', 'memory-view', 'browser-wait', 'browser-result', 'diagnostics', 'diagnostics-log', 'diagnostics-clear', 'update-check', 'update-install', 'dsh-update-install', 'repo-stars', 'approval-answer']) + // auth-whoami is a read of the already-resolved actor; invite/redeem/revoke/remove-member write. + const READS = new Set(['state', 'models', 'efforts', 'earlier', 'room-progress', 'room-history', 'exchange', 'usage', 'schedules', 'memory-view', 'browser-wait', 'browser-result', 'diagnostics', 'diagnostics-log', 'diagnostics-clear', 'update-check', 'update-install', 'dsh-update-install', 'repo-stars', 'approval-answer', 'auth-whoami']) -+ + + // A chat reference chip names a Session whose snapshot rides to every recipient of + // the message, so the author must be able to read what they share (#53, #106). + // Actor-authored text that becomes a session user message: send, room-post, @@ -465,10 +465,11 @@ diff --git a/src/host/channel.js b/src/host/channel.js + } + return undefined + } - ++ function setPrefs(body) { if (body.theme !== undefined) { -@@ -97,28 +97,57 @@ export function install(rt) { + if (typeof body.theme !== 'string' || !/^[a-z][\w-]{0,31}$/.test(body.theme)) throw invalid('Unknown theme') +@@ -97,28 +248,57 @@ else if (body.motion === 'quiet' || body.motion === 'lively') state.prefs.motion = body.motion else throw invalid('Motion must be quiet, normal or lively') } @@ -530,7 +531,7 @@ diff --git a/src/host/channel.js b/src/host/channel.js case 'duplicate-bot': await rt.load() return ok(await rt.duplicateBot(body.id)) -@@ -141,7 +141,7 @@ export function install(rt) { +@@ -141,7 +321,7 @@ return ok([...rt.roomProgress.entries()].filter(([, entry]) => entry.roomId === body.roomId).map(([callId, entry]) => ({ callId, replies: entry.replies, speaking: entry.speaking ?? null }))) case 'create-bot': { await rt.bootstrap() @@ -539,7 +540,7 @@ diff --git a/src/host/channel.js b/src/host/channel.js return ok(bot) } case 'update-bot': -@@ -150,18 +150,23 @@ export function install(rt) { +@@ -150,18 +330,23 @@ case 'delete-bot': await rt.load() await rt.deleteBot(body.id) @@ -567,7 +568,7 @@ diff --git a/src/host/channel.js b/src/host/channel.js return ok(room) } case 'update-room': { -@@ -175,8 +175,26 @@ export function install(rt) { +@@ -175,9 +360,29 @@ add: Array.isArray(body.add) ? body.add : undefined, remove: Array.isArray(body.remove) ? body.remove : undefined, workDir: body.workDir, @@ -577,7 +578,7 @@ diff --git a/src/host/channel.js b/src/host/channel.js + removeParticipants: Array.isArray(body.removeParticipants) ? body.removeParticipants : undefined, + }, undefined, actor) + return ok({ room: view(actor).rooms.find(entry => entry.id === room.id), changes }) -+ } + } + case 'room-history': { + await rt.load() + return ok(rt.roomHistory(String(body.roomId ?? body.id ?? ''), actor)) @@ -586,6 +587,8 @@ diff --git a/src/host/channel.js b/src/host/channel.js + await rt.load() + const leaked = unshareableReference(actor, body.text) + if (leaked !== undefined) return fail('You do not own that chat', 'bot/forbidden') ++ const posted = rt.roomOf(String(body.roomId ?? body.id ?? '')) ++ if (posted && isFaceRoom(posted) && (posted.face?.status === 'left' || posted.face?.status === 'closed')) return fail('This room has ended', 'bot/room-ended') + const line = await rt.postRoomMessage(String(body.roomId ?? body.id ?? ''), { + text: body.text, + authorId: body.authorId, @@ -593,10 +596,11 @@ diff --git a/src/host/channel.js b/src/host/channel.js + displayName: body.displayName, + }, actor) + return ok(line) - } ++ } case 'folders': case 'folder-pick': -@@ -203,17 +203,19 @@ export function install(rt) { + case 'folder-list': +@@ -203,17 +408,19 @@ case 'remove-agent': await rt.load() await rt.removeAgent(body.agentId) @@ -619,7 +623,7 @@ diff --git a/src/host/channel.js b/src/host/channel.js case 'stop-member-work': return ok(await rt.stopMemberWork(String(body.roomId ?? ''), String(body.botId ?? ''))) case 'dismiss-question': -@@ -231,6 +231,9 @@ export function install(rt) { +@@ -231,6 +438,9 @@ await rt.load() return ok(await rt.secretEndpoint(endpoint, body)) case 'connector-connect': @@ -629,7 +633,7 @@ diff --git a/src/host/channel.js b/src/host/channel.js case 'connector-disconnect': case 'connector-retry': await rt.load() -@@ -241,11 +241,22 @@ export function install(rt) { +@@ -241,11 +451,22 @@ case 'bot-capability': await rt.load() return ok(await rt.capabilityEndpoint(endpoint, body)) @@ -653,7 +657,7 @@ diff --git a/src/host/channel.js b/src/host/channel.js case 'memory-view': case 'memory-ask': case 'memory-forget': -@@ -258,8 +258,17 @@ export function install(rt) { +@@ -258,8 +479,17 @@ const sessionId = rt.botOf(body.sessionId) ? rt.chatOf(body.sessionId) : past && past.roomId === undefined ? rt.chatOf(past.botId) : body.sessionId const question = state.questions[sessionId] if (question === undefined || question.id !== body.questionId) return fail('This question is no longer open', 'bot/stale') @@ -671,7 +675,7 @@ diff --git a/src/host/channel.js b/src/host/channel.js // Claim the question before queueing so a second answer is refused. delete state.questions[sessionId] try { -@@ -278,9 +278,46 @@ export function install(rt) { +@@ -278,9 +508,49 @@ const text = String(body.text ?? '').trim() if (text === '' || typeof body.sessionId !== 'string') return fail('Nothing to send', 'bot/invalid') await rt.load() @@ -683,6 +687,9 @@ diff --git a/src/host/channel.js b/src/host/channel.js + const room = rt.roomOf(sessionId) + if (room && isFaceRoom(room)) { + if (actor && rt.authzEnforced() && !rt.roomMemberOf(actor, room.id)) return fail('You are not a member of that room', 'bot/forbidden') ++ // A room the server no longer reaches (a switched-away server, or a room that left): ++ // refuse instead of appending a line every other home will never see. ++ if (room.face?.status === 'left' || room.face?.status === 'closed') return fail('This room has ended', 'bot/room-ended') + // Face rooms must not go through sessionController.prompt: that starts a local + // bot round (turns/steps) and never fans the line to the other FlyBot instance. + const line = await rt.postRoomMessage(room.id, { text, displayName: actor?.displayName }, actor) @@ -718,7 +725,7 @@ diff --git a/src/host/channel.js b/src/host/channel.js await rt.ensureCapabilities(sessionId) await ctx.sessionController.prompt({ requestId: randomUUID(), -@@ -319,32 +319,112 @@ export function install(rt) { +@@ -319,32 +589,112 @@ case 'diagnostics-clear': await rt.diagnostics.clear() return ok(true) diff --git a/patches/ds-bot/files/test/room-membership.test.js b/patches/ds-bot/files/test/room-membership.test.js index 2c2f34bd8..322e3d17c 100644 --- a/patches/ds-bot/files/test/room-membership.test.js +++ b/patches/ds-bot/files/test/room-membership.test.js @@ -428,6 +428,28 @@ test('channel: delete-bot answers with the actor\'s scoped view, not the whole t assert.equal(deleted.value.bots.some(bot => bot.id === 'spec-a'), false, "another owner's private Bot stays out of the answer") }) +test('channel: a line to an ended face room is refused, not appended', async (t) => { + const { call, rt } = startChannel(t, TEAM) + await rt.load() + const room = await rt.upsertFaceRoom({ roomId: 'frm_left', localId: 'g-left', status: 'left', name: 'Ended room' }) + const before = room.log.length + const sent = await call('send', { sessionId: 'g-left', text: 'hello' }) + assert.equal(sent.ok, false) + assert.equal(sent.error.code, 'bot/room-ended') + assert.equal(rt.roomOf('g-left').log.length, before, 'nothing was appended to a room no home can reach') +}) + +test('channel: room-post into an ended face room is refused too', async (t) => { + const { call, rt } = startChannel(t, TEAM) + await rt.load() + const room = await rt.upsertFaceRoom({ roomId: 'frm_left', localId: 'g-left', status: 'left', name: 'Ended room' }) + const before = room.log.length + const posted = await call('room-post', { roomId: 'g-left', text: 'hello' }) + assert.equal(posted.ok, false) + assert.equal(posted.error?.code, 'bot/room-ended') + assert.equal(rt.roomOf('g-left').log.length, before, 'nothing was appended') +}) + test('channel: a legacy single-owner create-room still works', async (t) => { const { call } = startChannel(t, TEAM) const invite = (await call('auth-invite', {})).value diff --git a/plugins/social/src/server-config.js b/plugins/social/src/server-config.js index 91b6a0b83..23efd4e17 100644 --- a/plugins/social/src/server-config.js +++ b/plugins/social/src/server-config.js @@ -132,6 +132,23 @@ export function createServerController({ base, flybot, start, registerUnavailabl social = undefined } + // A switched-away server's face-to-face rooms cannot be reached from the new one: keep them as + // read-only transcripts (a past room) instead of an open room whose lines go nowhere. Switching + // back does not reopen them: `faceSnapshotPast` keeps a room left here left, and a line to it is + // refused (`bot/room-ended`). Never throws. + async function endFaceRooms(faceRoomIds) { + for (const faceRoomId of faceRoomIds ?? []) { + try { + const room = flybot.rooms?.ofFace?.(faceRoomId) + if (!room) continue + if (room.face?.status === 'left' || room.face?.status === 'closed') continue + await flybot.rooms.upsertFace({ roomId: faceRoomId, localId: room.id, status: 'left' }) + } catch (failure) { + warn('flybot-social: old face-to-face room not ended:', failure) + } + } + } + // Starts the social layer for a server and settles with it; a start that finishes after it was // replaced (or timed out) is disposed instead of kept. function launch(target) { @@ -248,6 +265,10 @@ export function createServerController({ base, flybot, start, registerUnavailabl const info = await probe(address, { fetch: base.fetch }) const previous = current const target = { server: address, displayName: name, source: serverKey(address) === defaultKey ? 'config' : 'settings' } + // Captured before the switch; only a successful switch ends them (a failed one reverts). + const previousFaceRooms = (flybot.rooms?.list?.() ?? []) + .filter(room => room.origin === 'face-to-face' && room.face?.roomId && (room.face.status === 'open' || room.face.status === 'candidate')) + .map(room => room.face.roomId) stop() if (previous.server) await park(previous.server) writeOverride(target) @@ -270,6 +291,9 @@ export function createServerController({ base, flybot, start, registerUnavailabl const why = cleanLine(failure?.message ?? failure, 200) || 'the server did not accept this FlyBot' throw refused(`Could not connect to the new server: ${why} The previous server is back${state === 'connected' ? '' : ', but it did not reconnect either'}.`, 'switch_failed') } + // A rename on the same server is not a switch (#729): that server still reaches these rooms, so + // only a change of address ends them. + if (serverKey(previous.server) !== serverKey(address)) await endFaceRooms(previousFaceRooms) return view() } finally { busy = false diff --git a/plugins/social/test/social.test.js b/plugins/social/test/social.test.js index 24c46884c..aec6fc9be 100644 --- a/plugins/social/test/social.test.js +++ b/plugins/social/test/social.test.js @@ -7501,6 +7501,10 @@ test('Settings server: status, check and switch with the old credentials kept, t await flybot.grants.set('a', [{ id: 'g1', grantee: '@bob.home', name: 'Bob' }]) flybot.fields.set('a', 'autoApprove', { '@bob.home': true }) await ctx.flybot.scope('social').fields.set('a', 'member', { bot_id: 'm1', handle: '@chief.danyang.home' }) + // A face-to-face room hosted on server A: the new server cannot reach it, so it must not stay open. + await ctx.flybot.rooms.upsertFace({ roomId: 'frm_old', localId: 'g-old', status: 'open', name: 'Old room' }) + // Still forming, not open yet: the same rule applies to it when the server really changes. + await ctx.flybot.rooms.upsertFace({ roomId: 'frm_cand', localId: 'g-cand', status: 'candidate', name: 'Candidate room' }) // Check: nothing is saved, the platform's features are shown; refused addresses never reach the network. const before = servers.seen.length @@ -7525,8 +7529,18 @@ test('Settings server: status, check and switch with the old credentials kept, t assert.match(refused.error.message, /HTTP 404/) assert.equal(flybot.fields.get('a', 'member').bot_id, 'm1') + // Renaming this home on the same server is not a switch: that server's rooms keep working. + const renamed = await call('flybot/social/server-set', { server: 'https://a.test', displayName: 'Danyang A2', confirm: true }) + assert.equal(renamed.ok, true, JSON.stringify(renamed)) + assert.equal(renamed.value.server, 'https://a.test') + assert.equal(ctx.flybot.rooms.ofFace('frm_old').face.status, 'open', 'a rename on the same server does not end its rooms') + assert.equal(ctx.flybot.rooms.ofFace('frm_cand').face.status, 'candidate', 'nor the room still being formed there') + assert.equal((await call('room-post', { roomId: 'g-old', text: 'still here' })).ok, true, 'and a line to it still goes out') + const switched = await call('flybot/social/server-set', { server: 'https://b.test', displayName: 'Danyang B', confirm: true }) assert.equal(switched.ok, true, JSON.stringify(switched)) + assert.equal(ctx.flybot.rooms.ofFace('frm_old').face.status, 'left', "the previous server's face room is not left open") + assert.equal(ctx.flybot.rooms.ofFace('frm_cand').face.status, 'left', 'a room still forming on the previous server is ended too') status = switched.value assert.equal(status.server, 'https://b.test') assert.equal(status.source, 'settings') @@ -7569,6 +7583,11 @@ test('Settings server: status, check and switch with the old credentials kept, t assert.deepEqual(ctx.flybot.grants.of('a').map(grant => grant.grantee), ['@bob.home']) assert.equal(readFileSync(credentialsPath, 'utf8'), credentialsA) assert.equal(JSON.parse(readFileSync(credentialsB, 'utf8')).token, 'tok-b') + // Switching back does not reopen the room it left: it stays a past room, and a line to it is refused. + assert.equal(ctx.flybot.rooms.ofFace('frm_old').face.status, 'left', 'a room this home left stays left after switching back') + const posted = await call('room-post', { roomId: 'g-old', text: 'hello' }) + assert.equal(posted.ok, false) + assert.equal(posted.error?.code, 'bot/room-ended') }) test('Settings server: a switch that fails puts the previous server back, with its files and identities', async (t) => { @@ -7585,6 +7604,8 @@ test('Settings server: a switch that fails puts the previous server back, with i await flybot.grants.set('a', [{ id: 'g1', grantee: '@bob.home' }]) flybot.fields.set('a', 'member', { bot_id: 'm1', handle: '@chief.danyang.home' }) const credentialsA = readFileSync(credentialsPath, 'utf8') + // A room on the server that keeps working: a switch that fails must not end it. + await ctx.flybot.rooms.upsertFace({ roomId: 'frm_a', localId: 'g-a', status: 'open', name: 'Room on A' }) const failed = await call('flybot/social/server-set', { server: 'https://closed.test', confirm: true }) assert.equal(failed.ok, false) @@ -7601,6 +7622,8 @@ test('Settings server: a switch that fails puts the previous server back, with i assert.equal(servers.mock('https://a.test').onboarded(), 1) assert.equal((await call('flybot/social/status', {})).ok, true) assert.doesNotMatch(warned.join('\n'), /tok-a/) + assert.equal(ctx.flybot.rooms.ofFace('frm_a').face.status, 'open', 'a switch that failed ends none of the rooms that still work') + assert.equal((await call('room-post', { roomId: 'g-a', text: 'still here' })).ok, true, 'and lines to them still go out') }) test('Settings server: nothing configured is unchanged, and a first server can be set from Settings', async (t) => {