diff --git a/core/client.js b/core/client.js index 12bfefae5..b45b58bc7 100644 --- a/core/client.js +++ b/core/client.js @@ -1587,7 +1587,7 @@ function remoteCallName(record) { return false; }; if (!unusable(bare2)) return bare2; - const slug = handle.split(".")[0]; + const slug = cleanPeerName(handle.split(".")[0], ""); if (slug && !unusable(slug)) return slug; return "Bot"; } diff --git a/core/src/shared/remote-label.js b/core/src/shared/remote-label.js index dbc0c693f..7312732e0 100644 --- a/core/src/shared/remote-label.js +++ b/core/src/shared/remote-label.js @@ -153,7 +153,9 @@ export function remoteCallName(record) { return false } if (!unusable(bare)) return bare - const slug = handle.split('.')[0] + // The first label of a handle is peer-controlled (a ticket or a claim). The same + // cleaner as a display name, so a newline or a fence fragment cannot be the token. + const slug = cleanPeerName(handle.split('.')[0], '') if (slug && !unusable(slug)) return slug return 'Bot' } diff --git a/core/test/at-menu.test.js b/core/test/at-menu.test.js index 4aaeb3a8b..302b323e3 100644 --- a/core/test/at-menu.test.js +++ b/core/test/at-menu.test.js @@ -3,7 +3,7 @@ import { test } from 'node:test' import { formatSessionReferenceMention, parseSessionReferenceText } from '@deepseek-ai/dsh-session-reference' import { BOTS_SOURCE, CHATS_SOURCE, atSources, chatRows, mentionRows, sessionMention } from '../src/client/at-menu.js' import { indexRoster } from '../src/client/sources.js' -import { remoteBotTitle } from '../src/shared/remote-label.js' +import { remoteBotTitle, remoteCallName } from '../src/shared/remote-label.js' const roster = indexRoster({ bots: [ @@ -239,3 +239,26 @@ test('#1141: a group @ inserts the seat name, never a .home handle or (Friend)', assert.ok(backend) assert.equal(backend.name, 'Backend (Friend)') }) + +test('#1141: a dirty handle slug is not the group @ insert or the call name', async () => { + const handle = '@bad\nname.flybot.home' + const call = remoteCallName({ handle, displayName: 'Friend' }) + assert.equal(call, 'bad name/untrusted-message') + assert.doesNotMatch(call, /[<>\r\n\u0000-\u001f]/) + assert.equal(remoteCallName({ handle: '@\n.evil.home', displayName: 'Friend' }), '/untrusted-message') + assert.equal(remoteCallName({ handle: '@\n\n.evil.home', displayName: 'Friend' }), 'Bot') + const group = indexRoster({ + bots: [{ id: 'chief', name: 'Chief', sessionId: 'room' }], + rooms: [{ + id: 'room', name: '研发组', members: ['chief'], + participants: [ + { kind: 'bot', id: 'chief', displayName: 'Chief' }, + { kind: 'bot', remote: true, id: handle, handle, displayName: 'Friend', ownerName: 'Friend' }, + ], + }], + }) + const rows = mentionRows(group, 'room') + const seat = rows.find(row => row.bot?.handle === handle) + assert.equal(seat.text, `@${call} `) + assert.doesNotMatch(rows.map(row => row.text).join(''), /<\/?untrusted-message>|[\u0000-\u001f]/) +}) diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index 8cc1b9823..f78248cf9 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -189,7 +189,7 @@ The overlay (`flybot-slots.js` `FlyBotApprovals`) is the fallback, not a second - **Group and face-room Bot labels (#526, owner decision 2026-10-20, option a).** The same person-first format: `Reviewer (flybot682b8a43)`, never Bot Social's member label plus the handle (`Reviewer · FlyBot 682b8a43 (flybot682b8a43)`). `remoteBotName` drops a trailing ` · ` from a Bot's name when it is that Bot's owner (the server's `label` reaching a name slot, e.g. a bubble header with no seat yet); `ownerNameOf` passes over an owner name made up from an install id (`isAutoOwnerName`: `FlyBot` + hex id) to the owner's username from the home handle, and keeps it only when no handle is known; `seatOwnerName` reads my own seat's owner the same way (`face-seats.js`, `at-menu.js`). The Bot names in `FacePersonRow` and the remote rows of the group Members lists (`group-settings.js`, `details-drawer.js`) carry the handle as a tooltip, as bubble headers already do. Covered by `core/test/group-bot-label.test.js`. - A face-to-face room's subtitle (`roomSubtitle` → `faceRoomCount` in `group-settings.js`) names what it counts, `1 person · 1 Bot` / `2 people · 4 Bots`: Bots are the live Bot seats, people the distinct owners among them (me once, a friend by owner name); human seats and Bots that left do not count (#306). Any other group's subtitle, command-palette row and Groups-list tag use `seatedCount`: humans, this home's Bots, and remote Bots still in the room — the same people the Members card lists — so a shared room does not read as only the Bots this home brought. A remote that left is not counted. The "Add a Bot first" note uses that same total. - The composer `@` in a face-to-face room (`mentionRows` / `atSources` in `at-menu.js`) offers the room's own Bots, each once (a repeated remote seat is folded by handle; Bots that left are out), labelled as the Members panel names them (`Chief (SelfA)`, `Chief (SelfB)`), plus **Everyone** with the room's Bot count (`2 Bots`, #457). Published friends' Bots (`remoteMentionRows`) and plugin rows (the social `Friends' Bots` section) are not offered there, since only the Bots in the room can be reached in it. Only the label changes: the inserted text is still the `@Name` the room routes on (`@SelfA's Chief`), never a handle (#305). -- An ordinary group's `@` still shows the seat as `remoteBotTitle` (`Backend (Friend)`, handle on the second line) but inserts `remoteCallName` (#1141): the Bot's own name (`@Backend`, `@architect`), which `callsIn` also aliases. It does not insert a second `@`, a `.home` handle, or the placeholder `(Friend)`. +- An ordinary group's `@` still shows the seat as `remoteBotTitle` (`Backend (Friend)`, handle on the second line) but inserts `remoteCallName` (#1141): the Bot's own name (`@Backend`, `@architect`), which `callsIn` also aliases. It does not insert a second `@`, a `.home` handle, or the placeholder `(Friend)`. A handle used as that name goes through `cleanPeerName`, so a newline or a fence fragment in the handle is not the insert token. - A face room's `@` menu shows no DSH Sessions rows (#305, owner decision 2026-10-19: filter in FlyBot for now; #422 asks DSH for an API). DSH's built-in `reference` source cannot be dropped for one Session, so `installHeaderEffects` (`header-effects.js`) starts `actions.filterFaceAtMenu` for a face room, which watches that Session's trigger controller (`inputTriggers.sessionOf`, the same one the composer uses) with `watchFaceMenu` (`face-at-filter.js`): a `