Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion core/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
}
Expand Down
4 changes: 3 additions & 1 deletion core/src/shared/remote-label.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
}
Expand Down
25 changes: 24 additions & 1 deletion core/test/at-menu.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down Expand Up @@ -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</untrusted-message>.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: '@</untrusted-message>\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]/)
})
2 changes: 1 addition & 1 deletion docs/subsystems/core-client.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 ` · <owner>` 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 `<style data-bt-face-at>` hides the `reference` rows whose icon is `session` (Sessions and Subagents) and their section title by MenuView's option ids, `controller.hover` moves the highlight past them in the direction of the key (so arrow keys skip them and Enter never picks one), and a menu with nothing else left is `dismiss()`ed, so Enter sends. File rows stay. Elsewhere, **Everyone**'s count is the Bots it reaches (local members and seated friends' Bots), `1 member` / `2 members`.

### Silent creation (#645)
Expand Down
2 changes: 1 addition & 1 deletion docs/subsystems/core-host.md
Original file line number Diff line number Diff line change
Expand Up @@ -404,4 +404,4 @@ A discarded `.catch(() => {})` in the host sources either starts with `void` (fi
- Nothing is copied into a Bot's working folder: a copy would need a lifecycle and a cleanup policy of its own, and the readable path already lets a Bot move or submit the bytes itself.
- Covered by `core/test/images.test.js` and, through the Session hook and the group relay, `core/test/session-identity.test.js`.

- A live ordinary group is consent for a send to a seated remote Bot, and for a send addressed to that seat's home, even when the caller names no room and the line is not a user mention (#1141, `mentionRoom`). Another Bot of the same home who is not seated stays denied. Face-to-face room-only lines stay on `sharedRoom` (#948). `callsIn` also matches `remoteCallName`, the token a group `@` inserts.
- A live ordinary group is consent for a send to a seated remote Bot, and for a send addressed to that seat's home, even when the caller names no room and the line is not a user mention (#1141, `mentionRoom`). Another Bot of the same home who is not seated stays denied. Face-to-face room-only lines stay on `sharedRoom` (#948). `callsIn` also matches `remoteCallName`, the token a group `@` inserts. A handle slug used as that token is passed through `cleanPeerName`, so a newline or a fence fragment is not an alias.
2 changes: 1 addition & 1 deletion patches/ds-bot/0008-client-bundle.patch
Original file line number Diff line number Diff line change
Expand Up @@ -873,7 +873,7 @@ diff --git a/client.js b/client.js
+ 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";
+}
Expand Down
27 changes: 25 additions & 2 deletions patches/ds-bot/0012-room-membership.patch
Original file line number Diff line number Diff line change
Expand Up @@ -1845,7 +1845,7 @@ diff --git a/test/at-menu.test.js b/test/at-menu.test.js
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: [
Expand Down Expand Up @@ -1875,7 +1875,7 @@ diff --git a/test/at-menu.test.js b/test/at-menu.test.js
test('"@" offers a group\'s members and everyone, or every other Bot in a Bot\'s chat', () => {
assert.deepEqual(names(mentionRows(roster, 'room')), ['Chief', 'King', 'everyone'])
assert.deepEqual(names(mentionRows(roster, 'chief-2')), ['King', 'Writer'])
@@ -76,3 +76,146 @@ test('the chats join only the menu that "+" opened, and a pick inserts a session
@@ -76,3 +76,169 @@ test('the chats join only the menu that "+" opened, and a pick inserts a session
assert.deepEqual(bots.onPick({ candidate: mentions[1] }), { text: '@King ' })
assert.deepEqual(await bots.candidates({ sessionId: 'plain' }, { query: '' }), [])
})
Expand Down Expand Up @@ -2022,3 +2022,26 @@ diff --git a/test/at-menu.test.js b/test/at-menu.test.js
+ 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</untrusted-message>.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: '@</untrusted-message>\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]/)
+})
4 changes: 3 additions & 1 deletion patches/ds-bot/files/src/shared/remote-label.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
}
Expand Down
Loading