From 86ced06f553717a1b0212475504f33cf8e80dee6 Mon Sep 17 00:00:00 2001 From: Danyang Chen Date: Sun, 8 Nov 2026 15:21:00 +0800 Subject: [PATCH] Keep a read-only social window from onboarding a home `startSocial` runs from the plugin lifecycle (`apply` -> `ready` -> `launch`), which the core's read-only endpoint guard cannot see, so a second, read-only window still onboarded a Bot Social home and wrote the shared credentials file. The start config now carries the host switch: a read-only window skips the onboarding and every credentials write, while saved credentials still start the social layer so friends and messages stay visible. Fixes #2675 Docs-not-needed: the read-only start boundary is documented in docs/subsystems/social.md in this change; no other subsystem page changes. --- changelog.d/2675-readonly-onboard.fixed.md | 1 + docs/subsystems/social.md | 2 + plugins/social/src/server-config.js | 8 +- plugins/social/src/social.js | 11 +- plugins/social/test/server-config.test.js | 140 +++++++++++++++++++++ 5 files changed, 159 insertions(+), 3 deletions(-) create mode 100644 changelog.d/2675-readonly-onboard.fixed.md create mode 100644 plugins/social/test/server-config.test.js diff --git a/changelog.d/2675-readonly-onboard.fixed.md b/changelog.d/2675-readonly-onboard.fixed.md new file mode 100644 index 000000000..907b9cfe1 --- /dev/null +++ b/changelog.d/2675-readonly-onboard.fixed.md @@ -0,0 +1 @@ +A second, read-only DSH window on the same home no longer onboards a Bot Social home or writes the shared credentials file: it starts the social layer from the credentials the writable window already saved, so friends and messages stay visible, while the window that holds the team is the only one that can create a home or change that file ([#2675](https://github.com/ThinkFlowLab/FlyBot/issues/2675)). diff --git a/docs/subsystems/social.md b/docs/subsystems/social.md index 51beb6a5a..a99d9f38c 100644 --- a/docs/subsystems/social.md +++ b/docs/subsystems/social.md @@ -12,6 +12,8 @@ Bot Social plugin for chatting with friends' Bots (send middleware `social` with `$DSH_HOME` is resolved the way the core resolves it — a blank value counts as unset, `~` expands, and the root is absolute — so `bot/flybot-social.json` does not move with the process's working directory (#2643). +A second, read-only window starts the social layer from the credentials file the writable window owns, so friends and messages stay visible, but it never onboards a home and never writes that file: the plugin start is a lifecycle path (`apply` → `ready` → `launch`), not an API endpoint, so the core's read-only guard cannot stop it and `startSocial` reads `config.readOnly` itself (#2675). + Face-to-face rooms (#83, #92) also live here: the primary entry is the sidebar **+** menu (slot `flybot.new-chat.action` on the core new-chat picker, after New group chat; `label` / `hint` use the same `t()` map). **Join by number or link** (zh 用号码或链接加入) and **Start a face-to-face room** (zh 面对面建群, #1021) are two rows, because one label naming a create path and a join path at once read as neither (#892 had one); **Add friend** is the third (copying an invite and your own handle live inside Add friend); the social strip has no **+ Add** button. Both face rows open the same screen with the same handlers, titled after the row that was tapped; the start path keeps the six-digit number (a room is built by typing one together) and drops the link and scan fields, which are ways into a group that already exists. The join path's copy names both ways in and its link field reads *Or paste a group invite link* (pick one or more local Bots, six-digit number, live waiting room that shows the code, **Enter chat**). The pick list and waiting roster draw each Bot with the host `BotAvatar` (team roster look for yours; a friend's Bot uses its published look or a handle-seeded look, plus any avatar face-room `members[]` carries). Waiting-room and picker Bots are a wrapping tile grid (avatar on top, name centered below; yours a tiny accent dot; waiting tiles use the server owner label, e.g. `Chief · BoxB`); **Leave** and **Enter chat** share a fixed footer while a long grid scrolls under the countdown. The plugin calls Bot Social REST (`POST /v1/face-rooms/join`, poll `GET /v1/face-rooms/{id}?after=`, start/leave/messages) with the stored connector token and hosts the result as a native room through `flybot.rooms` (`origin: 'face-to-face'`). Those face-room routes accept a connector token; owner settings do not (`GET`/`PATCH /v1/me` returns 403 `connector_token_rest_forbidden`). Peer text is fenced; `pending_approval` drafts of kind `face_room_message` use the in-chat Approve / Reject path. **Auto-send** for face rooms is one compact checkbox (tooltip) on the open room's overview, via MCP `whoami` (read) and `set_auto_approve { auto_approve_face_rooms }` (write; the owner's click requests and uses `confirm_code` in the same step); default off. A friend's Bot in the room card, waiting grid, **main sidebar row**, and core group overview uses the host `BotAvatar` (published look, optional member `avatar`, else a handle-seeded look). **Sharing → Copy invite link** creates a per-Bot grant when the friend accepts; **Share with a friend** (#500) lists current friends who do not have this Bot yet and grants it in one click (Bot Social `grant_bot_to_friend`, companion Birfy/bot-social#34) without an invite link — one row per **Bot**, not per person, labelled `Bot (Owner)` and carrying that Bot's handle and `peer_bot_id`, so a friend who runs several Bots gets a row each and the click grants to the row's own Bot while the receipt names it (#973) — from the row label the click carries, not from the home key, so a server answer without names never prints `selftestb2` (#994), and a caller that sends no label gets `with your friend` instead; the filter asks per **Bot** too, so a friend whose Bot already has it still gets a row for the others and only a grant that names no Bot falls back to hiding that whole friend (#981); the friend is named by `friend_bot_id`, the peer id `list_friends` returns — the platform never lists a friend's owner id — and `friend_owner_id` still works for a server that only knows it (#592); a server without that tool tries the REST route `POST /v1/bots/:id/grants` and, when it 404s too, says *Bot Social needs an update to share with a friend. Invite links still work.* instead of the raw MCP error (the production `MCP error -32602: Tool … not found` wording counts as an unknown tool, #563); a server that has the tool but refuses the arguments says *Bot Social did not accept that share request. Its server may need an update; invite links still work.*, never the raw `-32602` text (#592); A failed **Share** and a failed friend-list read keep separate notes (#987) — two slots, not one (#992): the share outcome (`share-note`) and the list error (`share-list-error`) are drawn side by side, so a share click cannot erase the list error and its **Try again**, and the 5 s poll cannot erase the share's message; a successful read settles the list error only, a successful share settles the share line only, and "No friends left to share with" is suppressed by a list error only — a share error never speaks for the list. **People with access** lists one row per **Bot** that has this Bot — named `Bot (Owner)` like the share list, carrying that Bot's handle, the groups it is used in, and its own **Revoke** (#995). **Revoke** asks once before it runs (#1073): the button opens `Revoke ? They lose this Bot.` (and `in N rooms` when that row already names rooms) with **Revoke** and **Keep**, and never the browser confirm box. The handle, or the home when the row names no Bot, and the usage hint share one line with a middle dot between them (`@handle · In use in N rooms`), so the two never run together (#1073). One **Auto-send** switch per person. The hint stays one readable row however long the share lives (#955): it names at most three rooms and then `+N more`, keeps every room one per line in its tooltip, and a room a grant row already named is not printed again under **This room only**. There is no published toggle. A line that Bot sends 1:1 to a seat in that room is judged under the room (#948): the plugin resolves it before the member check (`roomFor` → `roomContextFor`), the seat itself is the target when the roster does not list it (`pairFor`), and the send names that room (`room_id`) so Bot Social checks the same consent the host already did; the `roomOnly` identity is neither dropped nor widened — with no such room the refusal is the same sentence as before, and `send_as_member` is never reached. Bringing my own Bot by number is an implicit room-scoped grant: if it has no identity yet, join registers it room-only (`add_home_member`) and revokes that identity (`remove_home_member`) when it leaves its last face room. Friends' Bots lists only Bots granted to me. Held replies show in the chat (owner Approve / Reject). **Remove this Bot** declines that grant on Bot Social (`decline_bot_grant`) so it leaves the owner's Manage access; an older server falls back to a local hide. The row drops immediately; room leave and a roster refresh continue in the background. A local hide is forgotten only after two consecutive successful `list_remote_bots` results without that handle, or an explicit re-share / `regranted` join. Hidden, gone, and home-level remotes stay out of `flybot.remote.publish` and the new-chat picker. Friends' Bots paints a cached roster immediately and refreshes in the background (keep-alive HTTP, parallel mount calls). ## Owns diff --git a/plugins/social/src/server-config.js b/plugins/social/src/server-config.js index 3dbc9b505..221558a7b 100644 --- a/plugins/social/src/server-config.js +++ b/plugins/social/src/server-config.js @@ -94,7 +94,13 @@ const readJson = (path) => { try { return JSON.parse(readFileSync(path, 'utf8')) * @param flybot the plugin-scoped FlyBot service * @param start (config) => Promise: startSocial */ -export function createServerController({ base, flybot, start, registerUnavailable, onFailure, probe = probeServer, warn = () => {}, startTimeoutMs = START_MS }) { +export function createServerController({ base: configured, flybot, start, registerUnavailable, onFailure, probe = probeServer, warn = () => {}, startTimeoutMs = START_MS }) { + // A read-only window may still start the layer to show friends and messages, but it must not + // onboard a home or write the shared credentials file (#2675). The plugin lifecycle reaches + // `startSocial` through `apply`, which the core's read-only endpoint guard cannot see, so every + // start config carries the host switch. A getter, not a snapshot: `configFor` reads it each time + // a start is composed, the way the other `flybot.readOnly?.()` sites read it at use. + const base = Object.defineProperty({ ...configured }, 'readOnly', { get: () => flybot.readOnly?.() === true, enumerable: true, configurable: true }) const credentialsPath = base.credentialsPath const dir = dirname(credentialsPath) const overridePath = join(dir, 'flybot-social-server.json') diff --git a/plugins/social/src/social.js b/plugins/social/src/social.js index f888c22ce..bce54ceed 100644 --- a/plugins/social/src/social.js +++ b/plugins/social/src/social.js @@ -4754,6 +4754,10 @@ export function createSocial(service, config = {}, { client: given, api: givenAp // Plugin start: onboard once (credentials file), then build the social layer. export async function startSocial(service, config = {}) { + // `startSocial` is the plugin lifecycle (`apply` → `ready` → `launch`), not an API endpoint, so the + // core's read-only guard never sees it: a read-only window must skip the onboarding and the + // credentials writes here (#2675). It still starts the layer below when an identity already exists. + const readOnly = config.readOnly === true // Saved credentials belong to the server they came from: they are used only for that // server (a file from before this check is bound to the configured server on first // use), and an explicit token (config or FLYBOT_SOCIAL_TOKEN) always wins. @@ -4763,12 +4767,12 @@ export async function startSocial(service, config = {}) { if (credentials && config.server) { if (credentials.server === undefined) { credentials = { ...credentials, server: serverKey(config.server) } - writeCredentials(config.credentialsPath, credentials) + if (!readOnly) writeCredentials(config.credentialsPath, credentials) } else if (serverKey(credentials.server) !== serverKey(config.server)) { credentials = undefined } } - if (credentials === undefined && explicit === undefined && config.server && config.onboard !== false) { + if (credentials === undefined && explicit === undefined && config.server && config.onboard !== false && !readOnly) { // Asked before the onboarding, not after it: that call is a real home on the server, and a start // that cannot keep the token it is given must not spend one (and a round-trip) on every boot. // Both leaves this start writes are named from what is already on disk, a local read before the @@ -4786,6 +4790,9 @@ export async function startSocial(service, config = {}) { writeCredentials(config.credentialsPath, credentials) } } + // A read-only window has no identity to show and must not ask for one; `createSocial` cannot build + // a client without a token, so there is no layer until a writable window onboards the home. + if (credentials === undefined && explicit === undefined && readOnly) return undefined const social = createSocial(service, { ...config, token: explicit ?? credentials?.token, home: explicit !== undefined ? config.home : credentials?.home ?? config.home }) try { if (social.home) await social.registerAll() diff --git a/plugins/social/test/server-config.test.js b/plugins/social/test/server-config.test.js new file mode 100644 index 000000000..a8809689b --- /dev/null +++ b/plugins/social/test/server-config.test.js @@ -0,0 +1,140 @@ +// #2675: a read-only window still shows the Bot Social home another window onboarded, but it +// must never ask Bot Social for one and never write the shared credentials file. `startSocial` +// is reached from the plugin lifecycle (`apply` → `controller.ready()` → `launch()`), which the +// core's read-only endpoint guard cannot see, so the guard has to live on this path too. +import assert from 'node:assert/strict' +import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { test } from 'node:test' +import { startSocial } from '../src/social.js' +import { createServerController } from '../src/server-config.js' + +const homeOf = (t) => { + const home = mkdtempSync(join(tmpdir(), 'flybot-social-readonly-')) + t.after(() => rmSync(home, { recursive: true, force: true })) + return home +} + +// The plugin service `startSocial` receives, reduced to what a start touches; `readOnly` is the +// host switch (`flybot.readOnly()`), the same one the social endpoints already read. +function fakeFlybot(readOnly) { + return { + readOnly, + bot: { + list: () => [], get: () => undefined, update: async () => {}, hasAnyGrant: () => false, + send: { use: () => () => {}, priority: { APPROVAL: 900 } }, + }, + fields: { get: () => undefined, set: () => {} }, + grants: { of: () => [], set: async () => {}, has: () => false }, + rooms: { list: () => [], ofFace: () => undefined }, + transports: { add: () => () => {} }, + tools: { register: () => () => {} }, + api: { handle: () => () => {} }, + remote: { publish: value => value }, + on: () => () => {}, + ready: async () => undefined, + warn: () => {}, + } +} +const serviceOf = (flybot) => ({ scope: () => flybot }) + +// A Bot Social server that records every request: onboarding is the one write this issue is about, +// the MCP and face-room reads answer empty so a started layer settles without a real server. +function mockServer() { + let onboarded = 0 + const calls = [] + const json = (value, status = 200) => new Response(JSON.stringify(value), { status, headers: { 'content-type': 'application/json' } }) + const fetch = async (url, init = {}) => { + const text = String(url) + calls.push({ url: text, method: init.method ?? 'GET' }) + if (text.endsWith('/v1/onboard/flybot')) { + onboarded += 1 + return json({ owner_id: 'o1', connector: { token: 'tok-new' }, home: { bot_id: 'h1', handle: '@me.home' } }) + } + if (text.includes('/v1/face-rooms')) return json({ rooms: [] }) + if (text.endsWith('/mcp')) { + const body = JSON.parse(init.body) + const results = { list_remote_bots: { homes: [] }, list_friends: { friends: [] }, whoami: { kind: 'owner' }, list_bot_grants: [] } + return json({ jsonrpc: '2.0', id: body.id, result: { content: [{ type: 'text', text: JSON.stringify(results[body.params.name] ?? {}) }] } }) + } + return json({}) + } + return { fetch, calls, onboarded: () => onboarded } +} + +const configOf = (path, server, extra = {}) => ({ server: 'https://social.test', credentialsPath: path, fetch: server.fetch, inboxPollMs: 0, ...extra }) + +test('#2675: a read-only window without credentials never onboards and writes no credentials', async (t) => { + const home = homeOf(t) + const path = join(home, 'flybot-social.json') + const server = mockServer() + const before = readdirSync(home) + const social = await startSocial(serviceOf(fakeFlybot(() => true)), configOf(path, server, { readOnly: true })) + social?.dispose?.() + assert.equal(server.calls.length, 0, `a read-only start made no HTTP request, saw ${server.calls.map(call => `${call.method} ${call.url}`).join(', ')}`) + assert.equal(server.onboarded(), 0, 'no home was onboarded') + assert.equal(existsSync(path), false, 'no credentials file was written') + assert.deepEqual(readdirSync(home), before, 'the data folder is what it was') +}) + +test('#2675: a read-only window leaves a credentials file that has no server field byte for byte', async (t) => { + const home = homeOf(t) + const path = join(home, 'flybot-social.json') + writeFileSync(path, JSON.stringify({ token: 'tok-old', home: { id: 'h1', handle: '@me.home' }, displayName: 'Me' }, null, 2)) + const before = readFileSync(path) + const server = mockServer() + const social = await startSocial(serviceOf(fakeFlybot(() => true)), configOf(path, server, { readOnly: true })) + t.after(() => social?.dispose?.()) + assert.deepEqual(readFileSync(path), before, 'the file the writable window owns is not rewritten') + assert.equal(JSON.parse(readFileSync(path, 'utf8')).server, undefined) +}) + +test('#2675: a writable window still onboards once and writes the credentials once', async (t) => { + const home = homeOf(t) + const path = join(home, 'flybot-social.json') + const server = mockServer() + const social = await startSocial(serviceOf(fakeFlybot(() => false)), configOf(path, server, { readOnly: false })) + t.after(() => social?.dispose?.()) + assert.equal(server.onboarded(), 1, 'the onboarding still happens in a writable window') + const saved = JSON.parse(readFileSync(path, 'utf8')) + assert.equal(saved.token, 'tok-new') + assert.deepEqual(saved.home, { id: 'h1', handle: '@me.home' }) + assert.equal(saved.server, 'https://social.test') + assert.deepEqual(readdirSync(home).filter(name => name.startsWith('flybot-social.json')), ['flybot-social.json'], 'one credentials file, no temp leftover') +}) + +test('#2675: a read-only window with saved credentials still starts the layer and writes nothing', async (t) => { + const home = homeOf(t) + const path = join(home, 'flybot-social.json') + const saved = { token: 'tok-old', home: { id: 'h1', handle: '@me.home' }, server: 'https://social.test' } + writeFileSync(path, JSON.stringify(saved, null, 2)) + const before = readFileSync(path) + const server = mockServer() + const social = await startSocial(serviceOf(fakeFlybot(() => true)), configOf(path, server, { readOnly: true })) + t.after(() => social?.dispose?.()) + assert.ok(social, 'the read-only window still gets the social layer so friends and messages stay visible') + assert.deepEqual(social.home, { id: 'h1', handle: '@me.home' }, 'the saved home is the one it shows') + await new Promise(resolve => setTimeout(resolve, 20)) + assert.deepEqual(readFileSync(path), before, 'a start from a read-only window writes neither the credentials nor their server field') + assert.deepEqual(readdirSync(home).sort(), ['flybot-social.json'], 'no settings or backup file is created') + assert.equal(server.onboarded(), 0) +}) + +test('#2675: the start the server controller composes carries the live read-only switch', async (t) => { + const home = homeOf(t) + const seen = [] + const controller = createServerController({ + base: { + server: 'https://social.test', + credentialsPath: join(home, 'flybot-social.json'), + settingsPath: join(home, 'flybot-social-settings.json'), + inboxPollMs: 0, + }, + flybot: fakeFlybot(() => true), + start: async (config) => { seen.push(config.readOnly); return { dispose() {} } }, + }) + t.after(() => controller.dispose()) + await controller.ready() + assert.deepEqual(seen, [true], 'the start config reads the host switch instead of a snapshot') +})