From 069029e3c5f5f533d18d420fa20ba922f2c050a9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 16 Oct 2026 14:15:15 +0000 Subject: [PATCH] fix: distinguishable face-room titles, Bot-to-Bot chips, and taskId on holds Two rooms with the same people no longer share Face-to-face room; Bot-authored own-chat followups drop session chips (including cross-owner); a send stamps meta.taskId so two working tasks get the right hold. Co-authored-by: Danyang Chen --- changelog.d/106-bot-to-bot-chips.fixed.md | 1 + changelog.d/111-taskid-stamp.fixed.md | 1 + changelog.d/132-face-room-titles.fixed.md | 1 + core/src/host/delivery.js | 21 +++++++- core/src/host/groups.js | 19 +------- core/src/host/hooks.js | 8 +++- core/src/host/room-membership.js | 12 ++++- core/src/host/tasks.js | 24 ++++++++++ core/src/shared/face-title.js | 48 +++++++++++++++++++ core/test/context-sharing.test.js | 21 ++++++++ core/test/remote-room.test.js | 2 + core/test/tasks.test.js | 24 +++++++++- docs/subsystems/core-host.md | 4 +- docs/subsystems/plugin-runtime.md | 2 +- docs/subsystems/social.md | 2 +- docs/subsystems/team.md | 8 ++-- patches/ds-bot/0005-social-seams.patch | 26 ++++++++-- patches/ds-bot/0012-room-membership.patch | 31 +++--------- patches/ds-bot/files/src/host/hooks.js | 8 +++- .../ds-bot/files/src/host/room-membership.js | 12 ++++- patches/ds-bot/files/src/host/tasks.js | 24 ++++++++++ patches/ds-bot/files/src/shared/face-title.js | 48 +++++++++++++++++++ .../ds-bot/files/test/context-sharing.test.js | 21 ++++++++ patches/ds-bot/files/test/remote-room.test.js | 2 + patches/ds-bot/files/test/tasks.test.js | 24 +++++++++- plugins/social/client.js | 26 +++++++++- plugins/social/src/face-rooms.js | 24 ++++------ plugins/social/src/social.js | 6 +-- plugins/social/test/face-rooms.test.js | 9 +++- plugins/social/test/social.test.js | 16 +++++++ 30 files changed, 392 insertions(+), 83 deletions(-) create mode 100644 changelog.d/106-bot-to-bot-chips.fixed.md create mode 100644 changelog.d/111-taskid-stamp.fixed.md create mode 100644 changelog.d/132-face-room-titles.fixed.md create mode 100644 core/src/shared/face-title.js create mode 100644 patches/ds-bot/files/src/shared/face-title.js diff --git a/changelog.d/106-bot-to-bot-chips.fixed.md b/changelog.d/106-bot-to-bot-chips.fixed.md new file mode 100644 index 000000000..7a073ac6a --- /dev/null +++ b/changelog.d/106-bot-to-bot-chips.fixed.md @@ -0,0 +1 @@ +Bot-to-Bot sends and own-chat followups drop `dsh-session:` chips, including a granted Bot of another owner, so a private chat cannot ride into the other Bot's Session. ([#106](https://github.com/ThinkFlowLab/FlyBot/issues/106)) diff --git a/changelog.d/111-taskid-stamp.fixed.md b/changelog.d/111-taskid-stamp.fixed.md new file mode 100644 index 000000000..492362651 --- /dev/null +++ b/changelog.d/111-taskid-stamp.fixed.md @@ -0,0 +1 @@ +A working Bot's send stamps `meta.taskId` (the matching origin, or the latest working task) so a hold with two tasks in flight blocks the right one. ([#111](https://github.com/ThinkFlowLab/FlyBot/issues/111)) diff --git a/changelog.d/132-face-room-titles.fixed.md b/changelog.d/132-face-room-titles.fixed.md new file mode 100644 index 000000000..b625f14c2 --- /dev/null +++ b/changelog.d/132-face-room-titles.fixed.md @@ -0,0 +1 @@ +Face-to-face room titles include the people and a distinguisher (`Face-to-face · Alice, Bob · 482913`, or `Oct 16 21:40` when there is no code), so two rooms with the same people are not both named Face-to-face room. ([#132](https://github.com/ThinkFlowLab/FlyBot/issues/132)) diff --git a/core/src/host/delivery.js b/core/src/host/delivery.js index 3f489b598..c68b4d31e 100644 --- a/core/src/host/delivery.js +++ b/core/src/host/delivery.js @@ -38,6 +38,24 @@ export const cleanName = (name, fallback = 'a remote Bot') => { } export const fenceUntrusted = (name, text) => `\n${stripUntrusted(text ?? '')}\n` +// Bot-authored followups are createUserMessage; DSH may resolve `dsh-session:` chips +// there and attach the snapshot. Drop chips so a Bot cannot tunnel a private chat +// into another Bot's own chat or a shared room (#106). Authorship stays the gate +// on actor-authored send / room-post. Loop until stable so a nested marker cannot +// reconstitute a valid chip after one pass. +export function stripSessionChips(text) { + if (typeof text !== 'string' || !text.includes('dsh-session:')) return text + let out = text + for (let previous; previous !== out;) { + previous = out + out = out + .replace(/@\[((?:\\.|[^\]\\])*)\]\(dsh-session:[A-Za-z0-9_-]+\)/g, (_, label) => `@${String(label).replace(/\\([\\\]])/g, '$1')}`) + .replace(/dsh-session:[A-Za-z0-9_-]+/g, '') + } + if (out.includes('dsh-session:')) out = out.replace(/dsh-session:/g, '') + return out +} + export function install(rt) { const { ctx, state } = rt @@ -114,8 +132,9 @@ export function install(rt) { const agent = await resolveAgent(targetSessionId) // A steered message cuts into a running turn at its next step; a follow-up // queues for the next turn (see turns.js for how each is routed). + const body = fromId ? stripSessionChips(text) : text const message = createUserMessage({ - content: [{ type: 'text', text: `${headerFor(role, fromName, room?.name, audience, note, deferred, { resumed, retried })}\n${untrusted ? fenceUntrusted(fromName, text) : text}` }], + content: [{ type: 'text', text: `${headerFor(role, fromName, room?.name, audience, note, deferred, { resumed, retried })}\n${untrusted ? fenceUntrusted(fromName, body) : body}` }], source, }) if (steer) agent.steer(message) diff --git a/core/src/host/groups.js b/core/src/host/groups.js index 4949882f8..1de2f6fbf 100644 --- a/core/src/host/groups.js +++ b/core/src/host/groups.js @@ -23,28 +23,12 @@ import { mentionSpans } from '../shared/mentions.js' import { GROUP_REF, output } from './tools.js' import { failureLine } from './turns.js' import { currentParticipants } from './room-membership.js' +import { stripSessionChips } from './delivery.js' const ROOM_LOG_MAX = 60 const ROOM_FIRST_LOOK = 12 const ROOM_DELTA_MAX = 24 const ROOM_EARLIER_MAX = 8 -// Bot-authored group followups are createUserMessage; DSH may resolve `dsh-session:` -// chips there and attach the snapshot to the last user post. Drop chips so a member -// Bot cannot tunnel a private chat into the room (#106). Authorship stays the gate. -function stripSessionChips(text) { - if (typeof text !== 'string' || !text.includes('dsh-session:')) return text - // Loop until stable: a nested marker like dsh-sessiondsh-session:x:ImQi must not - // reconstitute a valid chip after one pass (#106 Codex review). - let out = text - for (let previous; previous !== out;) { - previous = out - out = out - .replace(/@\[((?:\\.|[^\]\\])*)\]\(dsh-session:[A-Za-z0-9_-]+\)/g, (_, label) => `@${String(label).replace(/\\([\\\]])/g, '$1')}`) - .replace(/dsh-session:[A-Za-z0-9_-]+/g, '') - } - if (out.includes('dsh-session:')) out = out.replace(/dsh-session:/g, '') - return out -} const ROOM_EXTRA_TURNS = 4 const ROOM_ADMIN_EXTRA_TURNS = 4 const ROOM_TURNS_EACH = 2 @@ -615,6 +599,7 @@ export function install(rt) { } // A Bot's message into the group's own Session, which relays it as a new round. + // Chips are stripped here and again in deliver() for Bot-to-Bot own-chat followups (#106). async function postToRoom(room, sender, text, extra = {}) { const agent = await rt.resolveAgent(room.id) agent.followup(createUserMessage({ diff --git a/core/src/host/hooks.js b/core/src/host/hooks.js index 5a27f9d13..4a13d8368 100644 --- a/core/src/host/hooks.js +++ b/core/src/host/hooks.js @@ -13,7 +13,7 @@ import { createHash, randomUUID } from 'node:crypto' import { createUserMessage } from '@deepseek-ai/dsh-llm' import { mentionSpans } from '../shared/mentions.js' import { isHomeLevelRemote } from '../shared/remote-label.js' -import { cleanName } from './delivery.js' +import { cleanName, stripSessionChips } from './delivery.js' import { SOURCE_KIND } from './constants.js' import { currentParticipants } from './room-membership.js' import { defineTool } from '@deepseek-ai/dsh-tools' @@ -508,6 +508,12 @@ export function install(rt) { // revoked since it was written is refused. const ownerPolicy = msg => rt.reach?.(msg) ?? { action: 'allow' } const run = async (msg) => { + const taskId = rt.taskIdForSend?.(msg) + if (taskId) msg.meta = { ...msg.meta, taskId } + if (typeof msg.text === 'string') { + msg.text = stripSessionChips(msg.text) + if (msg.deliverArgs) msg.deliverArgs = { ...msg.deliverArgs, text: msg.text } + } const owner = ownerPolicy(msg) if (owner.action === 'deny') return { action: 'deny', reason: owner.reason, by: owner.by } const verdict = await evaluate(msg) diff --git a/core/src/host/room-membership.js b/core/src/host/room-membership.js index 6f4b0b6ec..44f5fa3f9 100644 --- a/core/src/host/room-membership.js +++ b/core/src/host/room-membership.js @@ -34,6 +34,7 @@ // of a friend's room that invited their granted Bot. import { cleanPeerName, disambiguatedRemoteTitle, isChiefRecord, remoteBotTitle, sharedRoomTitle } from '../shared/remote-label.js' +import { formatFaceRoomTitle, isGenericFaceTitle } from '../shared/face-title.js' import { encodeRoomShare, parseRoomShare } from '../shared/room-share.js' import { cleanName, fenceUntrusted, isFencedUntrusted } from './delivery.js' import { hasAnyGrant, mayShareRoom } from './reach.js' @@ -1226,11 +1227,18 @@ export function install(rt) { return room } + const faceRoomFallbackTitle = (spec, room) => formatFaceRoomTitle({ + name: spec.name || room?.name, + owners: (spec.members ?? room?.participants ?? []).map(member => member.ownerName || member.owner_name), + code: spec.joinCode || spec.code, + at: spec.startedAt || spec.expiresAt || room?.face?.startedAt || room?.createdAt, + }) + rt.upsertFaceRoom = async function upsertFaceRoom(spec = {}) { const serverId = String(spec.roomId || spec.faceRoomId || '') if (!serverId) throw invalid('A face-to-face room needs a room id') let room = Object.values(state.rooms || {}).find(item => item.face?.roomId === serverId) - const title = spec.name || room?.name || 'Face-to-face room' + const title = faceRoomFallbackTitle(spec, room) const at = Date.now() if (!room) { const id = spec.localId || (typeof rt.createSession === 'function' ? await rt.createSession(title) : `face-${serverId}`) @@ -1260,7 +1268,7 @@ export function install(rt) { eventCursor: Math.max(Number(room.face.eventCursor) || 0, Number(spec.eventCursor) || 0), seenEvents: Array.isArray(room.face.seenEvents) ? room.face.seenEvents : [], } - if (spec.name) room.name = spec.name + if (spec.name || isGenericFaceTitle(room.name)) room.name = title if (Array.isArray(spec.members)) rt.syncFaceParticipants(room, spec.members, spec.localBots) await rt.save?.() return room diff --git a/core/src/host/tasks.js b/core/src/host/tasks.js index 5b4e286bb..7a7af5723 100644 --- a/core/src/host/tasks.js +++ b/core/src/host/tasks.js @@ -87,6 +87,30 @@ export function install(rt) { const save = () => rt.save() // ---------------------------------------------------------------- host hooks + // Name the working task a send belongs to so a hold can block that one (#111). + // Prefer an already-stamped `meta.taskId`; else the Bot's only working task; + // else the one whose origin matches this send's session/room; else the most + // recently started. Clarification / workBotId still wait on #55. + rt.taskIdForSend = (msg) => { + const from = msg?.from + if (typeof from !== 'string') return undefined + const ofSender = task => task.ownerBotId === from || task.workBotId === from + const given = typeof msg?.meta?.taskId === 'string' ? msg.meta.taskId : undefined + if (given) { + const named = store()[given] + if (named && named.status === 'working' && ofSender(named)) return given + } + const working = Object.values(store()).filter(task => task.status === 'working' && ofSender(task)) + if (working.length === 0) return undefined + if (working.length === 1) return working[0].id + const sessionId = typeof msg?.meta?.sessionId === 'string' ? msg.meta.sessionId : undefined + const roomId = typeof msg?.meta?.roomId === 'string' ? msg.meta.roomId : rt.roomOf?.(sessionId)?.id + const matched = working.filter(task => + (roomId && task.origin?.roomId === roomId) || (sessionId && task.origin?.sessionId === sessionId)) + if (matched.length === 1) return matched[0].id + return [...(matched.length ? matched : working)].sort((a, b) => b.updatedAt - a.updatedAt)[0].id + } + // A consequential send from the responsible Bot was held (#52 card): the task // blocks on that approval until the owner decides. Link one task, not every // working task of that Bot: `meta.taskId` names it, else the Bot's only working diff --git a/core/src/shared/face-title.js b/core/src/shared/face-title.js new file mode 100644 index 000000000..ce025983f --- /dev/null +++ b/core/src/shared/face-title.js @@ -0,0 +1,48 @@ +// Face-to-face room titles (#132): people plus a distinguisher so two rooms with +// the same owners are not both "Face-to-face room" / "Face-to-face · Alice, Bob". + +const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'] + +export function isGenericFaceTitle(name) { + const n = String(name ?? '').trim() + return !n || n === 'Face-to-face room' +} + +export function formatFaceWhen(value) { + const date = value instanceof Date ? value : new Date(value) + if (!Number.isFinite(date.getTime())) return '' + const hh = String(date.getHours()).padStart(2, '0') + const mm = String(date.getMinutes()).padStart(2, '0') + return `${MONTHS[date.getMonth()]} ${date.getDate()} ${hh}:${mm}` +} + +function uniqueOwners(owners) { + const seen = new Set() + const out = [] + for (const raw of owners ?? []) { + const owner = String(raw ?? '').trim() + const key = owner.toLowerCase() + if (!owner || seen.has(key)) continue + seen.add(key) + out.push(owner) + } + return out.slice(0, 3) +} + +export function joinCodeOf(code) { + return String(code ?? '').replace(/\D/g, '').slice(0, 6) +} + +// `Face-to-face · Alice, Bob · 482913` when a code is known, else `… · Oct 16 21:40`. +// A caller-chosen name that is not an auto title is kept. +export function formatFaceRoomTitle({ name, owners, code, at } = {}) { + const named = String(name ?? '').trim() + if (named && !isGenericFaceTitle(named) && !named.startsWith('Face-to-face · ')) return named + const people = uniqueOwners(owners) + const join = joinCodeOf(code) + const when = formatFaceWhen(at) || formatFaceWhen(new Date()) + const bits = ['Face-to-face'] + if (people.length) bits.push(people.join(', ')) + bits.push(join.length === 6 ? join : when) + return bits.join(' · ') +} diff --git a/core/test/context-sharing.test.js b/core/test/context-sharing.test.js index 38654c730..39cc4b800 100644 --- a/core/test/context-sharing.test.js +++ b/core/test/context-sharing.test.js @@ -289,6 +289,27 @@ test('answer-question and schedule-create accept menu-encoded session chips the if (ownSchedule.ok === false) assert.notEqual(ownSchedule.error.code, 'bot/forbidden', JSON.stringify(ownSchedule)) }) +test('a Bot-to-Bot send drops chat-reference chips in the other Bot\'s own chat, including cross-owner', async (t) => { + const { tool, agentOf, signIn } = await start(t) + const alice = await signIn('Alice') + await signIn('Bob', alice.sessionToken) + const sameOwner = await tool('message_bot', { to: 'Writer', message: 'see @[secret](dsh-session:a) please' }, 'a') + assert.match(String(sameOwner), /Delivered/, String(sameOwner)) + const writer = agentOf('s').calls.map(message => (message.content ?? []).map(block => block.text ?? '').join('\n')).join('\n') + assert.ok(writer.includes('see @secret'), 'the rest of the Bot-to-Bot text still lands') + assert.ok(!writer.includes('dsh-session:'), 'chips do not enter the other Bot\'s own chat') + const cross = await tool('message_bot', { to: 'ChiefB', message: 'peek @[notes](dsh-session:a) and dsh-session:e' }, 's') + assert.match(String(cross), /Delivered/, String(cross)) + const bob = agentOf('d').calls.map(message => (message.content ?? []).map(block => block.text ?? '').join('\n')).join('\n') + assert.ok(bob.includes('peek @notes'), 'cross-owner Bot-to-Bot keeps the label') + assert.ok(!bob.includes('dsh-session:'), 'a granted Bot cannot tunnel a private chip into another owner\'s chat') + const nested = await tool('message_bot', { to: 'Writer', message: 'nested @[secret](dsh-sessiondsh-session:x:ImEi) end' }, 'a') + assert.match(String(nested), /Delivered/, String(nested)) + const again = agentOf('s').calls.map(message => (message.content ?? []).map(block => block.text ?? '').join('\n')).join('\n') + assert.ok(!again.includes('dsh-session:'), 'nested chips do not reconstitute in own-chat followups') + void alice +}) + test('a Bot post into a group drops chat-reference chips so they cannot tunnel a private chat', async (t) => { const { tool, agentOf, signIn } = await start(t) const alice = await signIn('Alice') diff --git a/core/test/remote-room.test.js b/core/test/remote-room.test.js index c8f535bde..aee4eb765 100644 --- a/core/test/remote-room.test.js +++ b/core/test/remote-room.test.js @@ -268,6 +268,8 @@ test('face-to-face room hosts room-only remotes and skips friend fanout', async }) assert.equal(isFaceRoom(room), true) assert.equal(room.origin, 'face-to-face') + assert.notEqual(room.name, 'Face-to-face room') + assert.match(room.name, /^Face-to-face · Alice, Bob · /) const live = room.participants.filter(p => p.leftSeq === undefined) assert.equal(live.some(p => p.id === 'spec-a' && !p.remote), true) const remote = live.find(p => p.handle === '@coder.bob.home') diff --git a/core/test/tasks.test.js b/core/test/tasks.test.js index 2471efe24..ace45e739 100644 --- a/core/test/tasks.test.js +++ b/core/test/tasks.test.js @@ -279,7 +279,7 @@ test('a held send blocks only the named working task, not every working task of assert.equal((await statusOf(call, alice.sessionToken, second.value.id)).status, 'working') }) -test('an untagged hold does not block every working task when the Bot has several', async (t) => { +test('an untagged hold stamps the latest working task so two tasks do not share one card', async (t) => { const { fb, call, signIn, createTask } = await start(t) const alice = await signIn('Alice') const first = await createTask(alice.sessionToken, { botId: 'a', title: 'Note one' }) @@ -292,7 +292,27 @@ test('an untagged hold does not block every working task when the Bot has severa const held = await fb.bot.send({ from: 'a', to: 's', text: 'untagged send', meta: { hold: true } }) assert.equal(held.action, 'hold', JSON.stringify(held)) assert.equal((await statusOf(call, alice.sessionToken, first.value.id)).status, 'working') - assert.equal((await statusOf(call, alice.sessionToken, second.value.id)).status, 'working') + assert.equal((await statusOf(call, alice.sessionToken, second.value.id)).status, 'blocked') + const rejected = await call('flybot/decide', { id: held.approvalId, decision: 'reject' }, alice.sessionToken) + assert.equal(rejected.ok, true, JSON.stringify(rejected)) + assert.equal((await statusOf(call, alice.sessionToken, first.value.id)).status, 'working') + assert.equal((await statusOf(call, alice.sessionToken, second.value.id)).status, 'cancelled') +}) + +test('a send without taskId matches the working task whose origin is this session', async (t) => { + const { fb, call, signIn, createTask } = await start(t) + const alice = await signIn('Alice') + const first = await createTask(alice.sessionToken, { botId: 'a', title: 'Chat one', sessionId: 'a' }) + const second = await createTask(alice.sessionToken, { botId: 'a', title: 'Room two', roomId: 'war' }) + for (const id of [first.value.id, second.value.id]) { + await call('task-accept', { id }, alice.sessionToken) + await call('task-start', { id }, alice.sessionToken) + } + fb.bot.send.use({ name: 'gate', handle: async msg => (msg.meta?.hold ? { action: 'hold', reason: 'held by test' } : undefined) }) + const held = await fb.bot.send({ from: 'a', to: 's', text: 'from the room', meta: { hold: true, roomId: 'war' } }) + assert.equal(held.action, 'hold', JSON.stringify(held)) + assert.equal((await statusOf(call, alice.sessionToken, first.value.id)).status, 'working') + assert.equal((await statusOf(call, alice.sessionToken, second.value.id)).status, 'blocked') }) test('a result-ready task is not pruned while it waits for acceptance', async (t) => { diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index 7311258c0..fba33f852 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -50,11 +50,11 @@ Add a new invariant under the heading it belongs to (or a new heading), not at t - The saved team (`state.json`) is versioned. Version 2 (#47) carries the identity schema — `members` records, a per-Bot `ownerId`, the owner's `designatedChiefId`. A legacy version-1 file is migrated on load with a one-time `state.json.v1` backup; an existing backup counts only when it is a readable file holding a parseable version-1 state, and anything else (or any malformed ownership) fails closed (read-only). An older binary refuses a newer version read-only. This is schema foundation only — team mode stays disabled, see [team](team.md). On top of it, member auth (#48) adds host module `src/host/member-auth.js`, installed after `store` in `MODULES` (`core/index.js`): `invitations` and `authSessions` maps live on state (no `STATE_VERSION` bump), the channel resolves the actor from the `x-flybot-member-session` header, and endpoints are `auth-invite` / `auth-redeem` / `auth-revoke` / `auth-whoami` / `auth-remove-member` / `auth-revoke-invite` with `body.actorId` ignored; `auth-invite` alone also opens for an anonymous caller on a host with no sessions yet (bootstrap first invite), `auth-revoke-invite` kills an unredeemed invitation (redeeming it afterwards fails with `revoked`), `removeMember` refuses a member that still owns a Bot (`has_bots`), and the auth mutations are serialized behind an in-process lock so concurrent `removeMember` calls cannot both pass the last-owner check. Sign-in surface only — still not team mode; details in [team](team.md). On top of it, owner-scoped access (#49) adds host module `src/host/authz.js`, installed after member-auth: when any `authSessions` exist, the channel `authorize()` default-denies cross-owner Bot/room/session access, blocks shared non-isolatable resources (secrets, connectors, folder picks, browser bridge, update-install, diagnostics, set-prefs, `add-agent` — the DSH Agent is one per deployment and carries no owner) with `bot/blocked-shared` when more than one member exists, stamps `create-bot` with `actor.memberId`, and filters `view()`/`live()` to the actor; with no sessions the single-human behavior is unchanged. Session targets resolve through the store's owner maps — a Bot id via `botOf`, current chats and group Sessions and earlier parts via `selfOf`/`groupOwners`/`pastOwners` (a shared room Session also resolves for its current participants, #50) — and unresolvable ids deny. `approval-answer` gates on the approval's own Bot or room (resolved by approval id), never on caller-supplied session fields. `flybot/*` plugin endpoints (src/host/hooks.js) reach any signed-in actor for reads; plugin writes follow the shared rule. `view()`/`live()` narrowing (`rt.ownedView`/`rt.ownedLive` in authz.js) keeps per-session entries (`questions`, `exchangeTurns`) the actor owns, and deployment-wide surfaces (agents, secrets, connectors, memory reviews) only while a single member exists — the same rule as the shared writes; rooms the actor belongs to and the Bots in them (#50) stay visible. `create-room` refuses a request with neither a member Bot nor any participant. On top of it, room membership (#50) adds host module `src/host/room-membership.js`, installed after groups: a room may carry typed `participants` (`human` / local `bot` / granted remote `bot` with `handle` + owner fields, roles `admin` / `member`, `joinedSeq` / `leftSeq`); log lines carry `authorId` + `authorKind`; history is the membership window (current members and the owner of an invited Bot see `seq >=` their interval; leave withdraws access; rejoin starts a new interval); local cross-owner rooms accept each side's *granted* Bots (#90: default deny, Chief included); a friend's Bot from `social/remote-bots` (`cleanRemote`) may be added and ungranted Bots stay private, and a room with a friend's Bot holds only granted local Bots (a Bot that joined by room code, `viaCode`, only for that room); `view()` labels remotes with an owner badge and shows an invited owner **Shared room from \**. Face-to-face rooms (#83) reuse the same `participants` / membership-window / owner-badge presentation with `origin: 'face-to-face'`: room-only remotes may sit in the room without a person-grant or a friendship, `presentRoom` treats the viewer as a member (not Shared room from), leave skips the two-Bot floor, and `fanoutRoomLine` goes to `rt.faceRoomFanout` instead of friend `flybotSend`. Still not team mode — see [team](team.md). Per-Bot grants (#90) add host module `src/host/reach.js`, installed after room-membership: `state.grants[botId]` is the list of independent grants (one Bot → one grantee, optionally room-scoped); default deny, Chief included; a Bot whose owner is not a member has none. Grants are set with `rt.setGrants`, exposed as `BotView.granted`, `flybot.bot.hasGrant` / `hasAnyGrant`, `flybot.grants` and the `bot.grants` event. `reach()` is the owner policy: a friend's Bot may reach only local Bots granted to that sender, only a granted Bot (or one code-joined in that room) may message a friend's Bot, another owner's Bot is reached only if both sides have matching grants or both are members of the room the message belongs to. It is not a middleware: hooks.js runs it before the `bot.send` chain, again on approval release, and on every deliver with a sender or remote origin (resumed, retried, deferred, re-sent), so no verdict or `bypass` can skip it and a queued message is judged by what is true when it is delivered. Revoking a grant, or replacing its Chief, removes it from rooms that have friends' Bots or several owners at once (`rt.evictUnshared`). Approval binding (#52) completes the held-send half of this: `state.flybot.approvals` records carry `requestId`, a sha256 `version` over the request content, server-derived `ownerId` and `origin` (`via`/`hop`/audience) — claims in the message (`meta`, text) never confer authority. `flybot/decide` is allowed through authz for any signed-in actor and the per-record check happens in hooks (`decide` requires the sender Bot's owner's session, `bot/forbidden` otherwise, `bot/unauthorized` without a session); `release()` revalidates before dispatch and marks the record `stale` when the request changed, the sender or target Bot is gone (also invalidated eagerly on `deleteBot`), or the Bot changed owners; a demoted Chief's sender-side holds are invalidated eagerly on `setMain` (not inside `staleReason`), and `release()` re-checks immediately before dispatch so an in-flight approve cannot go out after a concurrent invalidation. Unknown `invalidateFor` field values throw. `flybot/approvals` lists only the actor's own holds while enforcement is on; decided stale records keep their attribution in state but appear in no member's list. -- Context sharing (#53) is specified in [team](team.md), **Context sharing foundation**: the own-chat brief is share-gated (`share-brief` / `briefShared`), a memory review reads only its owner as the user (`isUserLine`) and labels another human as `peer` (not `bot`), and keeps cross-owner entries out of the team scope (`teamAllowedFrom`), `list_bots` lists only the caller's owner's Bots (a granted child: that child's `createdBy` subtree), Bots granted to the same owner, and Bots in the current room — another owner's folder and instructions stay private — and a `dsh-session:` chip in actor-authored `send` / `room-post` / `answer-question` / `schedule-create` text must resolve for the author (`sessionOwnedBy`; `schedule-create` authz keys off `body.target`). Bot-authored group posts drop chips so they cannot tunnel a private chat into the room. Host modules: `src/host/groups.js`, `src/host/memory.js`, `src/host/tools.js`, `src/host/channel.js`, `src/host/room-membership.js`, `src/host/authz.js`. Team mode stays disabled. +- Context sharing (#53) is specified in [team](team.md), **Context sharing foundation**: the own-chat brief is share-gated (`share-brief` / `briefShared`), a memory review reads only its owner as the user (`isUserLine`) and labels another human as `peer` (not `bot`), and keeps cross-owner entries out of the team scope (`teamAllowedFrom`), `list_bots` lists only the caller's owner's Bots (a granted child: that child's `createdBy` subtree), Bots granted to the same owner, and Bots in the current room — another owner's folder and instructions stay private — and a `dsh-session:` chip in actor-authored `send` / `room-post` / `answer-question` / `schedule-create` text must resolve for the author (`sessionOwnedBy`; `schedule-create` authz keys off `body.target`). Bot-authored group posts and Bot-to-Bot / own-chat followups drop chips so they cannot tunnel a private chat into another Session. Host modules: `src/host/groups.js`, `src/host/memory.js`, `src/host/tools.js`, `src/host/channel.js`, `src/host/room-membership.js`, `src/host/authz.js`. Team mode stays disabled. ### Tasks -A tracked request with a stable id (`task-`) and a small state machine, owned by `src/host/tasks.js` with `state.tasks` (additive; corrupt values reset, no version bump). States: `open → claimed → working → blocked ⇄ working → result-ready → accepted`, with `cancelled`/`failed` terminal and `accepted` reachable only from `result-ready` — a failed or cancelled task can never display as accepted (#54). Transitions after create are the requester's (server-derived `requesterId`, the signed-in actor or the single owner; `bot/forbidden` otherwise, `bot/stale` on invalid moves), recorded with per-status actor entries. Create does not require owning the Bot: a matching per-Bot grant, or both the requester and the Bot as current members of the named room, is enough; outgoing holds stay the owner's (#52 card). A working task's held send blocks **that** task (`meta.taskId`, or the Bot's only working task) with the approval id; the owner's decide resumes (approve) or cancels (reject) it; deleting the responsible Bot fails its open tasks. `result-ready` is protected from prune while it waits for acceptance. Endpoints `tasks` / `task-create` / `task-accept` / `task-start` / `task-return` / `task-accept-result` / `task-cancel` are an explicit authz allow-list and are record-checked in tasks.js. Records store pointers and a copied summary, never private transcripts. Clarification and `workBotId` wait on #55's exchange wiring. Team mode stays disabled. +A tracked request with a stable id (`task-`) and a small state machine, owned by `src/host/tasks.js` with `state.tasks` (additive; corrupt values reset, no version bump). States: `open → claimed → working → blocked ⇄ working → result-ready → accepted`, with `cancelled`/`failed` terminal and `accepted` reachable only from `result-ready` — a failed or cancelled task can never display as accepted (#54). Transitions after create are the requester's (server-derived `requesterId`, the signed-in actor or the single owner; `bot/forbidden` otherwise, `bot/stale` on invalid moves), recorded with per-status actor entries. Create does not require owning the Bot: a matching per-Bot grant, or both the requester and the Bot as current members of the named room, is enough; outgoing holds stay the owner's (#52 card). A working task's held send blocks **that** task (`meta.taskId` stamped on the send, matching origin when the Bot has several working tasks) with the approval id; the owner's decide resumes (approve) or cancels (reject) it; deleting the responsible Bot fails its open tasks. `result-ready` is protected from prune while it waits for acceptance. Endpoints `tasks` / `task-create` / `task-accept` / `task-start` / `task-return` / `task-accept-result` / `task-cancel` are an explicit authz allow-list and are record-checked in tasks.js. Records store pointers and a copied summary, never private transcripts. Clarification and `workBotId` wait on #55's exchange wiring. Team mode stays disabled. ### Inbound shared rooms diff --git a/docs/subsystems/plugin-runtime.md b/docs/subsystems/plugin-runtime.md index 5df90d5d7..94ea6f39d 100644 --- a/docs/subsystems/plugin-runtime.md +++ b/docs/subsystems/plugin-runtime.md @@ -52,7 +52,7 @@ FlyBot's plugin API surface on top of the patched core: host hooks, client slots 8. A user's @ of a friend's Bot in a group gets a receipt line in that group (`hooks.js` `mentionReceipt`, relay role `mention`): where the reply lands, or why the send was denied, held, or failed. The receipt's role stays outside the room round trigger (`groups.js` `opens`), so it never opens a round; peer names go through `cleanName`. A human line that names a friend's Bot already in that room is **not** 1:1-relayed as the group's admin (Chief): the room message is the delivery, so Chief's missing grant does not block it (#143). A Chief-originated `bot.send` to an ungranted friend Bot is still deny. A remaining grant deny on a mention that is *not* in the room says to invite the Bot or add it to the group. 9. `flybot.rooms` hosts a Bot Social face-to-face matching room as a native room (`upsertFace` / `applyFaceLine` / `ofFace` / `dropFace` / `onFaceLine` / `postNotice` / `postRemote`). Slot `flybot.room.overview` is a block at the top of a group's overview: while joining, a tile grid (avatar above name + owner) with **Leave** and **Enter chat** on one row; once open, compact **Leave** and Auto-send — not Open/Start/Leave together. Slot `flybot.new-chat.action` adds a row to the sidebar **+** short action menu (next to Create group chat; `label` / `hint` go through the same `t()` map). Still not team mode. 10. Held sends are owner-bound (#52): a hold stamps server-derived provenance on the record — `requestId`, a sha256 `version` over the request content, `origin` (`via`/`hop`/audience) and the sender Bot's `ownerId`; message claims (`meta`, text) never confer authority. `flybot/decide` passes authz for any signed-in actor and hooks binds the decision to the sender Bot's owner's session; `release` revalidates and marks records `stale` (changed request, deleted sender/target Bot, changed owner — `deleteBot` invalidates eagerly; a demoted Chief's sender-side holds are invalidated eagerly on `setMain`, not inside `staleReason`). `release()` re-checks immediately before dispatch so an in-flight approve cannot go out after a concurrent invalidation. Unknown `invalidateFor` field values throw. The pending list is filtered per actor under member enforcement. The default approval card shows `senderName`/`targetName` and a delegated marker (`Approval.origin`). -11. Task lifecycle (#54): a working task's held send calls `rt.taskHeld` (blocks **that** task: `meta.taskId`, or the Bot's only working task); the owner's `flybot/decide` calls `rt.taskDecided` (approve resumes, reject cancels); `deleteBot` of a local Bot calls `rt.taskBotDeleted` (open tasks fail). Incoming holds stay the owner's. The state machine lives in `tasks.js`; hooks only fire those three seams. Clarification and `workBotId` wait on #55. +11. Task lifecycle (#54): `bot.send` stamps `meta.taskId` from the working task (`rt.taskIdForSend`); a held send then calls `rt.taskHeld` (blocks **that** task). The owner's `flybot/decide` calls `rt.taskDecided` (approve resumes, reject cancels); `deleteBot` of a local Bot calls `rt.taskBotDeleted` (open tasks fail). Incoming holds stay the owner's. The state machine lives in `tasks.js`; hooks only fire those three seams. Clarification and `workBotId` wait on #55. ### Chat-row pending badge diff --git a/docs/subsystems/social.md b/docs/subsystems/social.md index 022c42ad9..45a805542 100644 --- a/docs/subsystems/social.md +++ b/docs/subsystems/social.md @@ -76,7 +76,7 @@ Everything in `plugins/social/` (entry `plugins/social/index.js` and any helpers ### Past rooms, unique titles, poll, and + (#129–#133) -- Expired matching (`candidate` whose `expiresAt` has passed) and a peer leaving the last remote seat move the hosted room to **Past rooms** on the next poll (`rememberPastFace`, status `closed` / `left`). The local room stays so **Open** still works; **Leave** is not shown on those cards. Face-room names include the other owners, else the 6-digit code, else a clock time — not a pile of identical "Face-to-face room". +- Expired matching (`candidate` whose `expiresAt` has passed) and a peer leaving the last remote seat move the hosted room to **Past rooms** on the next poll (`rememberPastFace`, status `closed` / `left`). The local room stays so **Open** still works; **Leave** is not shown on those cards. Face-room names are `Face-to-face · Alice, Bob · 482913` (people plus the 6-digit code, or `Oct 16 21:40` when there is no code) so two rooms with the same people stay distinct. The literal `Face-to-face room` is not a fallback. - Inbox and face poll share one timer: `facePollMs` (default 4s) while any live face room exists, else `inboxPollMs` (default 15s). The open face overview keeps polling about every 3s after **Enter chat**, not only while matching. Friends' Bots still refreshes rooms about every 5s while that section is open. - Sidebar **+** holds Face-to-face group, Add friend, Accept invite, and one-click Copy invite link. Friend requests is not a duplicate + row. Per-friend and face-room hold toggles both say **Auto-send** ("Don't ask each time."). Hold labels resolve the real Bot name (`Chief → the room`), never `bot_01…`. diff --git a/docs/subsystems/team.md b/docs/subsystems/team.md index e4d3425a0..63de50fe2 100644 --- a/docs/subsystems/team.md +++ b/docs/subsystems/team.md @@ -104,12 +104,12 @@ Private context crosses owners only through an explicit, owner-granted share; ro - **The own-chat brief is share-gated.** A member's group Session starts with its owner's own-chat brief only when the room has a single owner, or that owner called `share-brief` for this room (the `briefShared` flag lives on the room participant, so leaving drops it; `rt.setBriefShare` in `core/src/host/room-membership.js` owner-checks, the endpoint only needs a signed-in actor). A group Session created after the share is taken back carries no brief. A granted remote Bot in the room counts as another owner (its handle stands for its home), so a friend's Bot's presence also suppresses the brief. A brief already delivered is not retroactively unseen — document that to owners. - **Memory review is owner-aware.** In `core/src/host/memory.js`, "the user" of a group review is the member Bot's owner (`isUserLine`); another owner's human is a named `peer` (not `bot`) and does not get user-line retention. A review that reads a room with more than one owner cannot write the team scope (`teamAllowedFrom`) — entries land in the Bot's own memory. Entries already written are not retroactively unseen. - **Roster disclosure narrows.** `list_bots` lists the caller's owner's Bots, Bots granted to that owner, and Bots in the current room — never an ungranted Bot of another owner. A granted child triggered by a grantee is scoped to its own `createdBy` subtree and does not dump the owner's roster with `workDir` / instructions. Another owner's folder and instructions stay private when that Bot is listed at all. -- **Chat references stay owner-scoped.** A `dsh-session:` reference chip in actor-authored text that becomes a session user message (`send`, `room-post`, `answer-question`, `schedule-create`) must name a Session the author may read (`sessionOwnedBy`); the snapshot rides to every recipient, so authorship is the gate. `schedule-create` authz keys off `body.target` (the Bot or room the endpoint uses), not a caller-chosen `sessionId`. Bot-authored group posts (`postToRoom`) drop chips so a member Bot cannot tunnel a private chat into the room. DSH resolves chips on user-shaped followups in the group's own Session (`session-reference` / `attachReference`) and attaches the snapshot only to the last user post, not a Bot's post. -- **Not done here:** bot→bot own-chat followups are not stripped (the snapshot stays in that Bot's chat; not a shared-room leak); the secrets surface stays `SHARED_BLOCKED` for >1 member; previously received messages cannot be retroactively unseen (stated, not engineered); no per-owner file-sandbox changes — a room `workDir` remains readable by its member Bots under DSH's own policy. **Follow-up (brief share):** `briefAllowed` still withholds the owner's own-chat brief in any multi-owner room, including face-to-face rooms where someone brings their own Bot; the intended model is a grant-tied share (a flag on `state.grants` or the room-scoped grant) rather than a participant `briefShared` flag lost on leave. `setBriefShare` already requires the Bot to be a current room participant. That redesign is out of this #54 PR. +- **Chat references stay owner-scoped.** A `dsh-session:` reference chip in actor-authored text that becomes a session user message (`send`, `room-post`, `answer-question`, `schedule-create`) must name a Session the author may read (`sessionOwnedBy`); the snapshot rides to every recipient, so authorship is the gate. `schedule-create` authz keys off `body.target` (the Bot or room the endpoint uses), not a caller-chosen `sessionId`. Bot-authored group posts (`postToRoom`) and Bot-to-Bot / own-chat followups (`rt.deliver` and `bot.send`, including a granted Bot of another owner) drop chips so a member Bot cannot tunnel a private chat. DSH resolves chips on user-shaped followups (`session-reference` / `attachReference`) and attaches the snapshot only to the last user post, not a Bot's post. +- **Not done here:** the secrets surface stays `SHARED_BLOCKED` for >1 member; previously received messages cannot be retroactively unseen (stated, not engineered); no per-owner file-sandbox changes — a room `workDir` remains readable by its member Bots under DSH's own policy. **Follow-up (brief share):** `briefAllowed` still withholds the owner's own-chat brief in any multi-owner room, including face-to-face rooms where someone brings their own Bot; the intended model is a grant-tied share (a flag on `state.grants` or the room-scoped grant) rather than a participant `briefShared` flag lost on leave. `setBriefShare` already requires the Bot to be a current room participant. That redesign is out of this #54 PR. ## Task lifecycle foundation (landed, #54) -`state.tasks` (`core/src/host/tasks.js`): a request becomes a tracked task — `open → claimed → working → blocked ⇄ working → result-ready → accepted`, `cancelled`/`failed` terminal, `accepted` only from `result-ready`. Only the requester's session moves their task (`bot/forbidden` otherwise). Create does not require owning the Bot: a matching per-Bot grant, or both the requester and the Bot as current members of the named room, is enough — a granted child can take a handoff within its authority. Outgoing holds stay the owner's: a held send (#52) blocks **that** task (`meta.taskId`, or the Bot's only working task) until that owner decides — several working tasks without a name stay working. `result-ready` is protected from prune while it waits for acceptance. Task channel names are an explicit allow-list (`tasks` / `task-create` / `task-accept` / `task-start` / `task-return` / `task-accept-result` / `task-cancel`). Records carry per-status actor entries, pointers to the originating conversation, and a summary — never private transcripts. The in-room task view and audit trail are #57's; clarification (ask the requester) and `workBotId` (the specialist on the exchange) wait on #55's exchange wiring. Team mode stays disabled. +`state.tasks` (`core/src/host/tasks.js`): a request becomes a tracked task — `open → claimed → working → blocked ⇄ working → result-ready → accepted`, `cancelled`/`failed` terminal, `accepted` only from `result-ready`. Only the requester's session moves their task (`bot/forbidden` otherwise). Create does not require owning the Bot: a matching per-Bot grant, or both the requester and the Bot as current members of the named room, is enough — a granted child can take a handoff within its authority. Outgoing holds stay the owner's: a held send (#52) blocks **that** task (`meta.taskId` stamped on the send from the working task — origin session/room when the Bot has several, else the latest — or an explicit id) until that owner decides. `result-ready` is protected from prune while it waits for acceptance. Task channel names are an explicit allow-list (`tasks` / `task-create` / `task-accept` / `task-start` / `task-return` / `task-accept-result` / `task-cancel`). Records carry per-status actor entries, pointers to the originating conversation, and a summary — never private transcripts. The in-room task view and audit trail are #57's; clarification (ask the requester) and `workBotId` (the specialist on the exchange) wait on #55's exchange wiring. Team mode stays disabled. ## Identity model @@ -229,7 +229,7 @@ Owner-scoped access (#49) tests in `core/test/authz.test.js`: cross-owner denial **Approval binding (#52)** has tests in `core/test/approval-binding.test.js`: the hold stamps server-derived provenance and a request-content hash while forged `meta` claims stay data; only the sender Bot's owner's session may decide (another member gets `bot/forbidden`, no session gets `bot/unauthorized`, a body `actorId` grants nothing); the pending list is per-actor; a request that changed after it was held goes `stale` and never dispatches; same-owner deciding survives a restart; deleting a sender or target Bot or replacing the Chief invalidates pending *sender-side* holds, while a hold *addressed to* the demoted Chief stays pending and decidable; an in-flight approve is invalidated if the Chief is replaced before dispatch; and a record whose sender Bot and owner are both gone stays in state with its attribution but in no member's list. -**Context sharing (#53)** has tests in `core/test/context-sharing.test.js` (brief gate per room ownership, the owner-only `share-brief` round trip, the single-owner guard, `list_bots` narrowing to own / granted / current-room Bots and granted-child subtree scoping, reference-chip authorization on `send`/`room-post`/`answer-question`/`schedule-create`, and chip stripping on Bot group posts) and in `core/test/memory.test.js` (owner-as-user classification, the team-write gate out of a cross-owner room). +**Context sharing (#53)** has tests in `core/test/context-sharing.test.js` (brief gate per room ownership, the owner-only `share-brief` round trip, the single-owner guard, `list_bots` narrowing to own / granted / current-room Bots and granted-child subtree scoping, reference-chip authorization on `send`/`room-post`/`answer-question`/`schedule-create`, and chip stripping on Bot group posts and Bot-to-Bot / own-chat followups) and in `core/test/memory.test.js` (owner-as-user classification, the team-write gate out of a cross-owner room). **Task lifecycle (#54)** has tests in `core/test/tasks.test.js`: the happy path (open → claimed → working → result-ready → accepted, then stale), wrong-member accept/claim/cancel forbidden, accept-before-result and post-terminal moves stale, held-send block → owner approve resumes, owner reject cancels, a named hold blocks only that working task (an untagged hold does not cancel every sibling), `result-ready` survives prune, responsible-Bot deletion fails the task, restart persistence, corrupt `state.tasks` resets, and a grantee may create a task on a granted child but not on an ungranted sibling. diff --git a/patches/ds-bot/0005-social-seams.patch b/patches/ds-bot/0005-social-seams.patch index d45f664d2..e55cc679f 100644 --- a/patches/ds-bot/0005-social-seams.patch +++ b/patches/ds-bot/0005-social-seams.patch @@ -523,7 +523,7 @@ diff --git a/src/host/channel.js b/src/host/channel.js diff --git a/src/host/delivery.js b/src/host/delivery.js --- a/src/host/delivery.js +++ b/src/host/delivery.js -@@ -3,6 +3,41 @@ import { randomUUID } from 'node:crypto' +@@ -3,6 +3,59 @@ import { randomUUID } from 'node:crypto' import { createUserMessage } from '@deepseek-ai/dsh-llm' import { SOURCE_KIND } from './constants.js' @@ -561,6 +561,24 @@ diff --git a/src/host/delivery.js b/src/host/delivery.js + return text || fallback +} +export const fenceUntrusted = (name, text) => `\n${stripUntrusted(text ?? '')}\n` ++ ++// Bot-authored followups are createUserMessage; DSH may resolve `dsh-session:` chips ++// there and attach the snapshot. Drop chips so a Bot cannot tunnel a private chat ++// into another Bot's own chat or a shared room (#106). Authorship stays the gate ++// on actor-authored send / room-post. Loop until stable so a nested marker cannot ++// reconstitute a valid chip after one pass. ++export function stripSessionChips(text) { ++ if (typeof text !== 'string' || !text.includes('dsh-session:')) return text ++ let out = text ++ for (let previous; previous !== out;) { ++ previous = out ++ out = out ++ .replace(/@\[((?:\\.|[^\]\\])*)\]\(dsh-session:[A-Za-z0-9_-]+\)/g, (_, label) => `@${String(label).replace(/\\([\\\]])/g, '$1')}`) ++ .replace(/dsh-session:[A-Za-z0-9_-]+/g, '') ++ } ++ if (out.includes('dsh-session:')) out = out.replace(/dsh-session:/g, '') ++ return out ++} + export function install(rt) { const { ctx, state } = rt @@ -600,12 +618,14 @@ diff --git a/src/host/delivery.js b/src/host/delivery.js } if (EXCHANGE_ROLES.has(role) && !deferred && !resumed && !retried && !resent) { state.exchanges.push({ id: exchangeId, from: fromId, to: toId, text, role, time: Date.now(), ...(replyTo ? { replyTo } : {}), ...(audience === 'user' ? { audience } : {}) }) -@@ -73,7 +73,7 @@ export function install(rt) { +@@ -72,8 +72,9 @@ export function install(rt) { + const agent = await resolveAgent(targetSessionId) // A steered message cuts into a running turn at its next step; a follow-up // queues for the next turn (see turns.js for how each is routed). ++ const body = fromId ? stripSessionChips(text) : text const message = createUserMessage({ - content: [{ type: 'text', text: `${headerFor(role, fromName, room?.name, audience, note, deferred, { resumed, retried })}\n${text}` }], -+ content: [{ type: 'text', text: `${headerFor(role, fromName, room?.name, audience, note, deferred, { resumed, retried })}\n${untrusted ? fenceUntrusted(fromName, text) : text}` }], ++ content: [{ type: 'text', text: `${headerFor(role, fromName, room?.name, audience, note, deferred, { resumed, retried })}\n${untrusted ? fenceUntrusted(fromName, body) : body}` }], source, }) if (steer) agent.steer(message) diff --git a/patches/ds-bot/0012-room-membership.patch b/patches/ds-bot/0012-room-membership.patch index 6c7d568ff..8cd531460 100644 --- a/patches/ds-bot/0012-room-membership.patch +++ b/patches/ds-bot/0012-room-membership.patch @@ -320,36 +320,15 @@ diff --git a/src/client/sources.js b/src/client/sources.js diff --git a/src/host/groups.js b/src/host/groups.js --- a/src/host/groups.js +++ b/src/host/groups.js -@@ -22,11 +22,29 @@ import { escapeRegExp, splitRef, textOf } from './text.js' +@@ -22,6 +22,8 @@ import { escapeRegExp, splitRef, textOf } from './text.js' import { mentionSpans } from '../shared/mentions.js' import { GROUP_REF, output } from './tools.js' import { failureLine } from './turns.js' +import { currentParticipants } from './room-membership.js' ++import { stripSessionChips } from './delivery.js' const ROOM_LOG_MAX = 60 const ROOM_FIRST_LOOK = 12 - const ROOM_DELTA_MAX = 24 - const ROOM_EARLIER_MAX = 8 -+// Bot-authored group followups are createUserMessage; DSH may resolve `dsh-session:` -+// chips there and attach the snapshot to the last user post. Drop chips so a member -+// Bot cannot tunnel a private chat into the room (#106). Authorship stays the gate. -+function stripSessionChips(text) { -+ if (typeof text !== 'string' || !text.includes('dsh-session:')) return text -+ // Loop until stable: a nested marker like dsh-sessiondsh-session:x:ImQi must not -+ // reconstitute a valid chip after one pass (#106 Codex review). -+ let out = text -+ for (let previous; previous !== out;) { -+ previous = out -+ out = out -+ .replace(/@\[((?:\\.|[^\]\\])*)\]\(dsh-session:[A-Za-z0-9_-]+\)/g, (_, label) => `@${String(label).replace(/\\([\\\]])/g, '$1')}`) -+ .replace(/dsh-session:[A-Za-z0-9_-]+/g, '') -+ } -+ if (out.includes('dsh-session:')) out = out.replace(/dsh-session:/g, '') -+ return out -+} - const ROOM_EXTRA_TURNS = 4 - const ROOM_ADMIN_EXTRA_TURNS = 4 - const ROOM_TURNS_EACH = 2 @@ -41,9 +41,12 @@ const ensureRoomLog = (room) => { room.seen ??= {} } @@ -476,7 +455,11 @@ diff --git a/src/host/groups.js b/src/host/groups.js return lines.length === 0 ? `"${room.name}" has no messages yet.` : `Latest in "${room.name}" (oldest first):\n${transcript(lines)}` }, })) -@@ -566,7 +566,7 @@ export function install(rt) { +@@ -563,10 +563,11 @@ export function install(rt) { + } + + // A Bot's message into the group's own Session, which relays it as a new round. ++ // Chips are stripped here and again in deliver() for Bot-to-Bot own-chat followups (#106). async function postToRoom(room, sender, text, extra = {}) { const agent = await rt.resolveAgent(room.id) agent.followup(createUserMessage({ diff --git a/patches/ds-bot/files/src/host/hooks.js b/patches/ds-bot/files/src/host/hooks.js index 5a27f9d13..4a13d8368 100644 --- a/patches/ds-bot/files/src/host/hooks.js +++ b/patches/ds-bot/files/src/host/hooks.js @@ -13,7 +13,7 @@ import { createHash, randomUUID } from 'node:crypto' import { createUserMessage } from '@deepseek-ai/dsh-llm' import { mentionSpans } from '../shared/mentions.js' import { isHomeLevelRemote } from '../shared/remote-label.js' -import { cleanName } from './delivery.js' +import { cleanName, stripSessionChips } from './delivery.js' import { SOURCE_KIND } from './constants.js' import { currentParticipants } from './room-membership.js' import { defineTool } from '@deepseek-ai/dsh-tools' @@ -508,6 +508,12 @@ export function install(rt) { // revoked since it was written is refused. const ownerPolicy = msg => rt.reach?.(msg) ?? { action: 'allow' } const run = async (msg) => { + const taskId = rt.taskIdForSend?.(msg) + if (taskId) msg.meta = { ...msg.meta, taskId } + if (typeof msg.text === 'string') { + msg.text = stripSessionChips(msg.text) + if (msg.deliverArgs) msg.deliverArgs = { ...msg.deliverArgs, text: msg.text } + } const owner = ownerPolicy(msg) if (owner.action === 'deny') return { action: 'deny', reason: owner.reason, by: owner.by } const verdict = await evaluate(msg) diff --git a/patches/ds-bot/files/src/host/room-membership.js b/patches/ds-bot/files/src/host/room-membership.js index 6f4b0b6ec..44f5fa3f9 100644 --- a/patches/ds-bot/files/src/host/room-membership.js +++ b/patches/ds-bot/files/src/host/room-membership.js @@ -34,6 +34,7 @@ // of a friend's room that invited their granted Bot. import { cleanPeerName, disambiguatedRemoteTitle, isChiefRecord, remoteBotTitle, sharedRoomTitle } from '../shared/remote-label.js' +import { formatFaceRoomTitle, isGenericFaceTitle } from '../shared/face-title.js' import { encodeRoomShare, parseRoomShare } from '../shared/room-share.js' import { cleanName, fenceUntrusted, isFencedUntrusted } from './delivery.js' import { hasAnyGrant, mayShareRoom } from './reach.js' @@ -1226,11 +1227,18 @@ export function install(rt) { return room } + const faceRoomFallbackTitle = (spec, room) => formatFaceRoomTitle({ + name: spec.name || room?.name, + owners: (spec.members ?? room?.participants ?? []).map(member => member.ownerName || member.owner_name), + code: spec.joinCode || spec.code, + at: spec.startedAt || spec.expiresAt || room?.face?.startedAt || room?.createdAt, + }) + rt.upsertFaceRoom = async function upsertFaceRoom(spec = {}) { const serverId = String(spec.roomId || spec.faceRoomId || '') if (!serverId) throw invalid('A face-to-face room needs a room id') let room = Object.values(state.rooms || {}).find(item => item.face?.roomId === serverId) - const title = spec.name || room?.name || 'Face-to-face room' + const title = faceRoomFallbackTitle(spec, room) const at = Date.now() if (!room) { const id = spec.localId || (typeof rt.createSession === 'function' ? await rt.createSession(title) : `face-${serverId}`) @@ -1260,7 +1268,7 @@ export function install(rt) { eventCursor: Math.max(Number(room.face.eventCursor) || 0, Number(spec.eventCursor) || 0), seenEvents: Array.isArray(room.face.seenEvents) ? room.face.seenEvents : [], } - if (spec.name) room.name = spec.name + if (spec.name || isGenericFaceTitle(room.name)) room.name = title if (Array.isArray(spec.members)) rt.syncFaceParticipants(room, spec.members, spec.localBots) await rt.save?.() return room diff --git a/patches/ds-bot/files/src/host/tasks.js b/patches/ds-bot/files/src/host/tasks.js index 5b4e286bb..7a7af5723 100644 --- a/patches/ds-bot/files/src/host/tasks.js +++ b/patches/ds-bot/files/src/host/tasks.js @@ -87,6 +87,30 @@ export function install(rt) { const save = () => rt.save() // ---------------------------------------------------------------- host hooks + // Name the working task a send belongs to so a hold can block that one (#111). + // Prefer an already-stamped `meta.taskId`; else the Bot's only working task; + // else the one whose origin matches this send's session/room; else the most + // recently started. Clarification / workBotId still wait on #55. + rt.taskIdForSend = (msg) => { + const from = msg?.from + if (typeof from !== 'string') return undefined + const ofSender = task => task.ownerBotId === from || task.workBotId === from + const given = typeof msg?.meta?.taskId === 'string' ? msg.meta.taskId : undefined + if (given) { + const named = store()[given] + if (named && named.status === 'working' && ofSender(named)) return given + } + const working = Object.values(store()).filter(task => task.status === 'working' && ofSender(task)) + if (working.length === 0) return undefined + if (working.length === 1) return working[0].id + const sessionId = typeof msg?.meta?.sessionId === 'string' ? msg.meta.sessionId : undefined + const roomId = typeof msg?.meta?.roomId === 'string' ? msg.meta.roomId : rt.roomOf?.(sessionId)?.id + const matched = working.filter(task => + (roomId && task.origin?.roomId === roomId) || (sessionId && task.origin?.sessionId === sessionId)) + if (matched.length === 1) return matched[0].id + return [...(matched.length ? matched : working)].sort((a, b) => b.updatedAt - a.updatedAt)[0].id + } + // A consequential send from the responsible Bot was held (#52 card): the task // blocks on that approval until the owner decides. Link one task, not every // working task of that Bot: `meta.taskId` names it, else the Bot's only working diff --git a/patches/ds-bot/files/src/shared/face-title.js b/patches/ds-bot/files/src/shared/face-title.js new file mode 100644 index 000000000..ce025983f --- /dev/null +++ b/patches/ds-bot/files/src/shared/face-title.js @@ -0,0 +1,48 @@ +// Face-to-face room titles (#132): people plus a distinguisher so two rooms with +// the same owners are not both "Face-to-face room" / "Face-to-face · Alice, Bob". + +const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'] + +export function isGenericFaceTitle(name) { + const n = String(name ?? '').trim() + return !n || n === 'Face-to-face room' +} + +export function formatFaceWhen(value) { + const date = value instanceof Date ? value : new Date(value) + if (!Number.isFinite(date.getTime())) return '' + const hh = String(date.getHours()).padStart(2, '0') + const mm = String(date.getMinutes()).padStart(2, '0') + return `${MONTHS[date.getMonth()]} ${date.getDate()} ${hh}:${mm}` +} + +function uniqueOwners(owners) { + const seen = new Set() + const out = [] + for (const raw of owners ?? []) { + const owner = String(raw ?? '').trim() + const key = owner.toLowerCase() + if (!owner || seen.has(key)) continue + seen.add(key) + out.push(owner) + } + return out.slice(0, 3) +} + +export function joinCodeOf(code) { + return String(code ?? '').replace(/\D/g, '').slice(0, 6) +} + +// `Face-to-face · Alice, Bob · 482913` when a code is known, else `… · Oct 16 21:40`. +// A caller-chosen name that is not an auto title is kept. +export function formatFaceRoomTitle({ name, owners, code, at } = {}) { + const named = String(name ?? '').trim() + if (named && !isGenericFaceTitle(named) && !named.startsWith('Face-to-face · ')) return named + const people = uniqueOwners(owners) + const join = joinCodeOf(code) + const when = formatFaceWhen(at) || formatFaceWhen(new Date()) + const bits = ['Face-to-face'] + if (people.length) bits.push(people.join(', ')) + bits.push(join.length === 6 ? join : when) + return bits.join(' · ') +} diff --git a/patches/ds-bot/files/test/context-sharing.test.js b/patches/ds-bot/files/test/context-sharing.test.js index 38654c730..39cc4b800 100644 --- a/patches/ds-bot/files/test/context-sharing.test.js +++ b/patches/ds-bot/files/test/context-sharing.test.js @@ -289,6 +289,27 @@ test('answer-question and schedule-create accept menu-encoded session chips the if (ownSchedule.ok === false) assert.notEqual(ownSchedule.error.code, 'bot/forbidden', JSON.stringify(ownSchedule)) }) +test('a Bot-to-Bot send drops chat-reference chips in the other Bot\'s own chat, including cross-owner', async (t) => { + const { tool, agentOf, signIn } = await start(t) + const alice = await signIn('Alice') + await signIn('Bob', alice.sessionToken) + const sameOwner = await tool('message_bot', { to: 'Writer', message: 'see @[secret](dsh-session:a) please' }, 'a') + assert.match(String(sameOwner), /Delivered/, String(sameOwner)) + const writer = agentOf('s').calls.map(message => (message.content ?? []).map(block => block.text ?? '').join('\n')).join('\n') + assert.ok(writer.includes('see @secret'), 'the rest of the Bot-to-Bot text still lands') + assert.ok(!writer.includes('dsh-session:'), 'chips do not enter the other Bot\'s own chat') + const cross = await tool('message_bot', { to: 'ChiefB', message: 'peek @[notes](dsh-session:a) and dsh-session:e' }, 's') + assert.match(String(cross), /Delivered/, String(cross)) + const bob = agentOf('d').calls.map(message => (message.content ?? []).map(block => block.text ?? '').join('\n')).join('\n') + assert.ok(bob.includes('peek @notes'), 'cross-owner Bot-to-Bot keeps the label') + assert.ok(!bob.includes('dsh-session:'), 'a granted Bot cannot tunnel a private chip into another owner\'s chat') + const nested = await tool('message_bot', { to: 'Writer', message: 'nested @[secret](dsh-sessiondsh-session:x:ImEi) end' }, 'a') + assert.match(String(nested), /Delivered/, String(nested)) + const again = agentOf('s').calls.map(message => (message.content ?? []).map(block => block.text ?? '').join('\n')).join('\n') + assert.ok(!again.includes('dsh-session:'), 'nested chips do not reconstitute in own-chat followups') + void alice +}) + test('a Bot post into a group drops chat-reference chips so they cannot tunnel a private chat', async (t) => { const { tool, agentOf, signIn } = await start(t) const alice = await signIn('Alice') diff --git a/patches/ds-bot/files/test/remote-room.test.js b/patches/ds-bot/files/test/remote-room.test.js index c8f535bde..aee4eb765 100644 --- a/patches/ds-bot/files/test/remote-room.test.js +++ b/patches/ds-bot/files/test/remote-room.test.js @@ -268,6 +268,8 @@ test('face-to-face room hosts room-only remotes and skips friend fanout', async }) assert.equal(isFaceRoom(room), true) assert.equal(room.origin, 'face-to-face') + assert.notEqual(room.name, 'Face-to-face room') + assert.match(room.name, /^Face-to-face · Alice, Bob · /) const live = room.participants.filter(p => p.leftSeq === undefined) assert.equal(live.some(p => p.id === 'spec-a' && !p.remote), true) const remote = live.find(p => p.handle === '@coder.bob.home') diff --git a/patches/ds-bot/files/test/tasks.test.js b/patches/ds-bot/files/test/tasks.test.js index 2471efe24..ace45e739 100644 --- a/patches/ds-bot/files/test/tasks.test.js +++ b/patches/ds-bot/files/test/tasks.test.js @@ -279,7 +279,7 @@ test('a held send blocks only the named working task, not every working task of assert.equal((await statusOf(call, alice.sessionToken, second.value.id)).status, 'working') }) -test('an untagged hold does not block every working task when the Bot has several', async (t) => { +test('an untagged hold stamps the latest working task so two tasks do not share one card', async (t) => { const { fb, call, signIn, createTask } = await start(t) const alice = await signIn('Alice') const first = await createTask(alice.sessionToken, { botId: 'a', title: 'Note one' }) @@ -292,7 +292,27 @@ test('an untagged hold does not block every working task when the Bot has severa const held = await fb.bot.send({ from: 'a', to: 's', text: 'untagged send', meta: { hold: true } }) assert.equal(held.action, 'hold', JSON.stringify(held)) assert.equal((await statusOf(call, alice.sessionToken, first.value.id)).status, 'working') - assert.equal((await statusOf(call, alice.sessionToken, second.value.id)).status, 'working') + assert.equal((await statusOf(call, alice.sessionToken, second.value.id)).status, 'blocked') + const rejected = await call('flybot/decide', { id: held.approvalId, decision: 'reject' }, alice.sessionToken) + assert.equal(rejected.ok, true, JSON.stringify(rejected)) + assert.equal((await statusOf(call, alice.sessionToken, first.value.id)).status, 'working') + assert.equal((await statusOf(call, alice.sessionToken, second.value.id)).status, 'cancelled') +}) + +test('a send without taskId matches the working task whose origin is this session', async (t) => { + const { fb, call, signIn, createTask } = await start(t) + const alice = await signIn('Alice') + const first = await createTask(alice.sessionToken, { botId: 'a', title: 'Chat one', sessionId: 'a' }) + const second = await createTask(alice.sessionToken, { botId: 'a', title: 'Room two', roomId: 'war' }) + for (const id of [first.value.id, second.value.id]) { + await call('task-accept', { id }, alice.sessionToken) + await call('task-start', { id }, alice.sessionToken) + } + fb.bot.send.use({ name: 'gate', handle: async msg => (msg.meta?.hold ? { action: 'hold', reason: 'held by test' } : undefined) }) + const held = await fb.bot.send({ from: 'a', to: 's', text: 'from the room', meta: { hold: true, roomId: 'war' } }) + assert.equal(held.action, 'hold', JSON.stringify(held)) + assert.equal((await statusOf(call, alice.sessionToken, first.value.id)).status, 'working') + assert.equal((await statusOf(call, alice.sessionToken, second.value.id)).status, 'blocked') }) test('a result-ready task is not pruned while it waits for acceptance', async (t) => { diff --git a/plugins/social/client.js b/plugins/social/client.js index 7c4632db1..6008ef6a8 100644 --- a/plugins/social/client.js +++ b/plugins/social/client.js @@ -598,6 +598,30 @@ window.__ModuleLoader__.load({ } return 'open' } + function shownFaceTitle(room) { + const named = String(room?.name || '').trim() + if (named && named !== 'Face-to-face room') return named + const owners = [] + const seen = new Set() + for (const member of room?.members ?? []) { + const owner = String(member.ownerName || member.owner_name || '').trim() + const key = owner.toLowerCase() + if (!owner || seen.has(key)) continue + seen.add(key) + owners.push(owner) + } + const code = String(room?.joinCode || room?.code || '').replace(/\D/g, '').slice(0, 6) + const bits = ['Face-to-face'] + if (owners.length) bits.push(owners.slice(0, 3).join(', ')) + if (code.length === 6) bits.push(code) + else { + const at = Date.parse(room?.startedAt || room?.expiresAt || '') + const when = Number.isFinite(at) ? new Date(at) : new Date() + const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'] + bits.push(`${months[when.getMonth()]} ${when.getDate()} ${String(when.getHours()).padStart(2, '0')}:${String(when.getMinutes()).padStart(2, '0')}`) + } + return bits.join(' · ') + } function FaceRoomCard({ room, actions, onChange, roster, remoteChats, past } = {}) { const [busy, setBusy] = useState('') const [note, setNote] = useState(null) @@ -627,7 +651,7 @@ window.__ModuleLoader__.load({ shown.map(m => h('span', { key: m.handle || m.id, 'data-flybot': 'face-card-member', 'data-handle': m.handle }, h(FaceAvatar, { bot: look(m), size: 20 }))), extra ? h('span', { className: 'fs-stack-more' }, `+${extra}`) : null), - h('span', { className: 'fs-name' }, room.name || 'Face-to-face room'), + h('span', { className: 'fs-name' }, shownFaceTitle(room)), h('span', { className: 'fs-chip', 'data-flybot': 'face-status' }, faceChip(room)), h('span', { className: 'fs-face-actions', onClick: event => event.stopPropagation() }, past diff --git a/plugins/social/src/face-rooms.js b/plugins/social/src/face-rooms.js index c647829ab..9b19dafbe 100644 --- a/plugins/social/src/face-rooms.js +++ b/plugins/social/src/face-rooms.js @@ -11,6 +11,7 @@ import { socialFetch } from './mcp-client.js' import { lookFromServer } from './look.js' +import { formatFaceRoomTitle } from '../../../core/src/shared/face-title.js' export const FACE_JOIN_CODE = /^\d{6}$/ export const FACE_MAX_MEMBERS = 8 @@ -221,25 +222,18 @@ export function humanFaceWho(line, fallback = 'Someone') { } export function faceRoomTitle(snapshot = {}, { code, room } = {}) { - const named = String(snapshot.name || room?.name || '').trim() - if (named && named !== 'Face-to-face room') return named const owners = [] - const seen = new Set() for (const member of snapshot.members ?? room?.participants ?? []) { const owner = cleanFaceName(member.ownerName || member.owner_name, '') - const key = owner.toLowerCase() - if (!owner || seen.has(key)) continue - seen.add(key) - owners.push(owner) + if (owner) owners.push(owner) } - if (owners.length) return `Face-to-face · ${owners.slice(0, 3).join(', ')}` - const join = String(code || snapshot.joinCode || '').replace(/\D/g, '').slice(0, 6) - if (join.length === 6) return `Face-to-face · ${join}` - const at = Date.parse(snapshot.startedAt || snapshot.expiresAt || '') - const when = Number.isFinite(at) ? new Date(at) : new Date() - const hh = String(when.getHours()).padStart(2, '0') - const mm = String(when.getMinutes()).padStart(2, '0') - return `Face-to-face · ${hh}:${mm}` + const at = snapshot.startedAt || snapshot.expiresAt || room?.face?.startedAt || room?.face?.expiresAt || room?.createdAt + return formatFaceRoomTitle({ + name: snapshot.name || room?.name, + owners, + code: code || snapshot.joinCode, + at, + }) } export function parseFaceHuman(text) { diff --git a/plugins/social/src/social.js b/plugins/social/src/social.js index 19f755be8..444a50d39 100644 --- a/plugins/social/src/social.js +++ b/plugins/social/src/social.js @@ -946,7 +946,7 @@ export function createSocial(service, config = {}, { client: given, api: givenAp await flybot.grants.set(botId, [...current, { id: `room:${room.id}`, grantee: faceRoomId ? `room:${faceRoomId}` : `room:${room.id}`, - name: name || room.name || 'Face-to-face room', + name: name || faceRoomTitle({}, { room }), roomId: String(room.id), }]) } @@ -2026,7 +2026,7 @@ export function createSocial(service, config = {}, { client: given, api: givenAp if (known.has(serverId)) continue const hosted = flybot.rooms.ofFace?.(serverId) rememberPastFace(viewHostedFace(hosted, { faceRoomId: serverId, localRoomId: meta?.localRoomId, status: 'closed' }) - || { faceRoomId: serverId, roomId: meta?.localRoomId, name: 'Face-to-face room', status: 'closed', members: [] }) + || { faceRoomId: serverId, roomId: meta?.localRoomId, name: faceRoomTitle({}, { code: meta?.joinCode, room: hosted }), status: 'closed', members: [] }) if (meta?.localRoomId) await flybot.rooms.dropFace?.(meta.localRoomId).catch(() => {}) delete settings.faceRooms[serverId] dropRoomRemotes(serverId) @@ -2084,7 +2084,7 @@ export function createSocial(service, config = {}, { client: given, api: givenAp const meta = settings.faceRooms[id] const hosted = flybot.rooms.ofFace?.(id) rememberPastFace(viewHostedFace(hosted, { faceRoomId: id, localRoomId: meta?.localRoomId, status: 'left' }) - || { faceRoomId: id, roomId: meta?.localRoomId, name: 'Face-to-face room', status: 'left', members: [] }) + || { faceRoomId: id, roomId: meta?.localRoomId, name: faceRoomTitle({}, { code: meta?.joinCode, room: hosted }), status: 'left', members: [] }) const handles = (Array.isArray(bots) && bots.length ? bots : asBotsOf(meta)).map(norm).filter(Boolean) let result try { diff --git a/plugins/social/test/face-rooms.test.js b/plugins/social/test/face-rooms.test.js index 79456fb4c..abf578cc8 100644 --- a/plugins/social/test/face-rooms.test.js +++ b/plugins/social/test/face-rooms.test.js @@ -123,8 +123,15 @@ test('owner-typed face-room lines encode as a human envelope', () => { const unnamed = faceOutbound({ authorKind: 'human', who: 'User', text: 'hi', authorId: 'owner-1' }, { locals, asBots: ['@chief.boxa.home'], who: 'Alice' }) assert.deepEqual(parseFaceHuman(unnamed.text), { who: 'Alice', text: 'hi' }) assert.equal(humanFaceWho({ who: 'User' }, 'Alice'), 'Alice') - assert.equal(faceRoomTitle({ members: [{ owner_name: 'BoxA' }, { ownerName: 'BoxB' }] }), 'Face-to-face · BoxA, BoxB') + assert.equal(faceRoomTitle({ members: [{ owner_name: 'BoxA' }, { ownerName: 'BoxB' }] }, { code: '482913' }), 'Face-to-face · BoxA, BoxB · 482913') assert.equal(faceRoomTitle({}, { code: '123456' }), 'Face-to-face · 123456') + assert.equal(faceRoomTitle({ name: 'Kitchen' }), 'Kitchen') + assert.notEqual( + faceRoomTitle({ members: [{ owner_name: 'Alice' }], startedAt: '2026-10-16T13:40:00.000Z' }), + faceRoomTitle({ members: [{ owner_name: 'Alice' }], startedAt: '2026-10-16T14:05:00.000Z' }), + ) + assert.doesNotMatch(faceRoomTitle({ members: [{ owner_name: 'Alice' }], startedAt: '2026-10-16T13:40:00.000Z' }), /^Face-to-face room$/) + assert.match(faceRoomTitle({ members: [{ owner_name: 'Alice' }], startedAt: '2026-10-16T13:40:00.000Z' }), /^Face-to-face · Alice · /) const rendered = renderFaceEvent({ event_id: 'fev_h', event_cursor: 9, type: 'room.message', from: { handle: '@chief.boxa.home', display_name: 'Chief', owner_name: 'BoxA' }, diff --git a/plugins/social/test/social.test.js b/plugins/social/test/social.test.js index b4f4b3325..46fe09b7b 100644 --- a/plugins/social/test/social.test.js +++ b/plugins/social/test/social.test.js @@ -1829,6 +1829,22 @@ test('Face-to-face room card members use host avatars, not a shared letter badge assert.equal(tree.filter(n => n.props.className === 'fs-avatar' && n.props['data-flybot'] === undefined).length, 0) }) +test('Face-to-face room card titles include people and a distinguisher, not Face-to-face room', () => { + const { walk, pluginOf } = loadSocialClient(async () => ({})) + const tree = walk(pluginOf([]).FaceRoomCard({ + room: { + name: 'Face-to-face room', status: 'open', joinCode: '482913', + members: [ + { handle: '@a.me.home', displayName: 'Chief', ownerName: 'Alice' }, + { handle: '@coder.bob.home', displayName: 'Coder', ownerName: 'Bob' }, + ], + }, + })) + const title = tree.find(n => n.props.className === 'fs-name') + assert.equal(String(title?.children?.join?.('') ?? ''), 'Face-to-face · Alice, Bob · 482913') + assert.equal(tree.some(n => String(n.children?.join?.('') ?? n.children) === 'Face-to-face room'), false) +}) + test('Face-to-face room card is one compact row with a status chip and no invalid-number line', () => { const { walk, pluginOf } = loadSocialClient(async () => ({})) const plugin = pluginOf([])