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
1 change: 1 addition & 0 deletions changelog.d/1923-published-name-cut.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
A friend's Bot name no longer shows half of an emoji ([#1923](https://github.com/ThinkFlowLab/FlyBot/issues/1923)): the published name stored from the roster, the group member line, the room label `othersInRoom` returns and the envelope's name, handle, ownerName and roster fields now cut on a code point boundary, so such a value that straddles its 60 (or 40) cap keeps the whole character instead of U+FFFD. The envelope's message body and its fitTransport shrink loop are untouched.
3 changes: 2 additions & 1 deletion core/src/host/hooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { createPluginModel } from './plugin-model.js'
import { defineTool } from './tool-schema.js'
import { retiredBot } from './store.js'
import { codePoints } from './text.js'
import { cut } from '../shared/text-boundary.js'
import { EPHEMERAL_DENIED_TOOLS } from './tools.js'

export const FLYBOT_API_VERSION = 1
Expand All @@ -44,7 +45,7 @@ const clone = value => (value === undefined ? undefined : JSON.parse(JSON.string
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b)
// Peer-controlled labels (display name, home, owner): strip @ so they cannot look
// like a mention of another Bot, strip controls/newlines, keep a 60-char cap (#75).
export const cleanPublishedName = (value) => String(value ?? '').replace(/@/g, '').replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 60)
export const cleanPublishedName = (value) => cut(String(value ?? '').replace(/@/g, '').replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, ' ').replace(/\s+/g, ' ').trim(), 60).text
// A remote address: { kind: 'remote', handle, home? } or a '@handle' string. Remote Bots
// are never in bot.list(); their handle is '@<bot>.<home>' (home '@<home>') or a plain
// per-bot identity.
Expand Down
3 changes: 2 additions & 1 deletion core/src/host/reach.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { ownEntry } from './store.js'
// no sender the owner or the system: allowed

import { currentParticipants, homesOf } from './room-membership.js'
import { cut } from '../shared/text-boundary.js'

export const POLICY = 'owner-policy'

Expand Down Expand Up @@ -154,7 +155,7 @@ export function mayShareRoom(state, room, botId) {
return false
}

const label = value => String(value ?? '').replace(/\p{Cc}/gu, ' ').trim().slice(0, 60)
const label = value => cut(String(value ?? '').replace(/\p{Cc}/gu, ' ').trim(), 60).text

// The people and Bots in one room that are not the Bot's owner's own (a friend's Bot, a person who
// is not its owner, a Bot of another owner, a room joined by code), as short labels.
Expand Down
3 changes: 2 additions & 1 deletion core/src/host/tools.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { currentParticipants } from './room-membership.js'
import { questionReply, replyText } from './parts.js'
import { OTHER, settled } from './turn-origin.js'
import { cleanName } from './delivery.js'
import { cut } from '../shared/text-boundary.js'
import { retiredBot } from './store.js'

// A run Bot (ephemeral) is not a team member: every tool below has an effect that reaches past
Expand Down Expand Up @@ -81,7 +82,7 @@ export function install(rt) {
const members = room.members.map(rt.botOf).filter(Boolean)
.map(bot => `${bot.name}${bot.id === room.admin ? ' (admin)' : ''}${bot.id === self ? ' (you)' : ''}`)
const remotes = (room.participants ?? []).filter(p => p.remote && p.leftSeq === undefined)
.map(p => `${String(p.displayName ?? p.handle).replace(/[\u0000-\u001f\u007f-\u009f[\]"<>`]/g, '').slice(0, 60)} (${String(p.ownerName ?? p.ownerHome ?? 'friend').replace(/[\u0000-\u001f\u007f-\u009f[\]"<>`]/g, '').slice(0, 40)}'s Bot)`)
.map(p => `${cut(String(p.displayName ?? p.handle).replace(/[\u0000-\u001f\u007f-\u009f[\]"<>`]/g, ''), 60).text} (${cut(String(p.ownerName ?? p.ownerHome ?? 'friend').replace(/[\u0000-\u001f\u007f-\u009f[\]"<>`]/g, ''), 40).text}'s Bot)`)
const parts = [
`members: ${[...members, ...remotes].join(', ')}`,
room.mode === 'admin' && room.admin ? 'plain messages go to the admin first' : 'every member replies in turn',
Expand Down
4 changes: 3 additions & 1 deletion core/src/shared/room-share.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
// Bot Social has no FlyBot-group record: the owner's FlyBot parses this on inbox receive
// and projects an inbound-share room. Keep the marker unique so ordinary chat never matches.

import { cut } from './text-boundary.js'

export const ROOM_SHARE_MARK = 'FLYBOT-ROOM-SHARE'

// #852: `ask` is the invitee knocking at a local room's link (or accepting it with `accept: true`);
Expand All @@ -11,7 +13,7 @@ const OPS = new Set(['join', 'leave', 'line', 'ask', 'invite'])
const TICKET_SHAPE = /^[A-Za-z0-9_-]{16,64}$/

function clip(value, max = 60) {
return String(value ?? '').replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ').trim().slice(0, max)
return cut(String(value ?? '').replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ').trim(), max).text
}

// #904: a room's roster entry. A Bot inside a real room is a handful, not a crowd; the cap keeps one
Expand Down
65 changes: 65 additions & 0 deletions core/test/flybot.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -815,6 +815,71 @@ test('cleanPublishedName strips @, controls and newlines, and caps at 60 (#75)',
assert.equal(cleanPublishedName('Owner\u0000@chief.home'), 'Owner chief.home')
assert.equal(cleanPublishedName('@@@'), '')
assert.equal(cleanPublishedName('x'.repeat(80)), 'x'.repeat(60))
// #1923: the 60 cap is a UTF-16 cut, and `'a'.repeat(59) + '😀tail'` used to split the emoji.
const straddle = 'a'.repeat(59) + '😀tail'
const kept = cleanPublishedName(straddle)
assert.equal(kept.isWellFormed(), true, `the kept name is not well formed: ${JSON.stringify(kept.slice(-2))}`)
assert.equal(kept, 'a'.repeat(59), 'the straddling emoji is dropped whole, never split')
assert.ok(kept.length <= 60, `the cap still holds: ${kept.length}`)
assert.ok(Buffer.from(kept, 'utf8').equals(Buffer.from(straddle, 'utf8').subarray(0, Buffer.byteLength(kept))), 'the kept name is a prefix of the input, byte for byte')
})

// #1923: the same bound at the record boundary the name is stored through (hooks.js:947-949).
test('a published peer name, homeName and owner keep whole characters at the 60 bound (#1923)', async (t) => {
const { ctx, fb, stop, grantTo } = await start()
t.after(stop)
await grantTo('a', '@eve.friend.home')
ctx.llm.listProviders = () => [{ id: 'flybot-remote', name: 'Friends\' Bots' }]
ctx.llm.listModels = async () => [{ id: '@eve.friend.home', name: 'Eve' }]
ctx.sessionController.create = async () => ({ sessionId: 'remote-1' })
const straddle = 'a'.repeat(59) + '😀tail'
const ownerStraddle = 'o'.repeat(59) + '😀tail'
const social = fb.scope('social')
social.remote.publish([{ handle: '@eve.friend.home', name: straddle, home: '@friend.home', homeName: straddle, owner: ownerStraddle }])
const [member] = social.remote.list()
// All three share cleanPublishedName's 60 bound; tools.js applies the tighter 40 to the owner name it shows.
const cases = [['name', member.name, straddle, 60], ['homeName', member.homeName, straddle, 60], ['owner', member.owner, ownerStraddle, 60]]
for (const [field, kept, source, max] of cases) {
assert.equal(typeof kept, 'string', `${field} is stored: ${JSON.stringify(member)}`)
assert.equal(kept.isWellFormed(), true, `${field} is not well formed: ${JSON.stringify(kept.slice(-2))}`)
assert.ok(kept.length <= max, `${field} keeps the ${max} cap: ${kept.length}`)
assert.ok(kept.length > max - 10, `${field} does not drop the whole name for one character: ${kept.length}`)
assert.equal(kept.includes('😀'), false, `${field} drops the straddling emoji whole`)
assert.ok(Buffer.from(kept, 'utf8').equals(Buffer.from(source, 'utf8').subarray(0, Buffer.byteLength(kept))), `${field} is a prefix of the published name, byte for byte`)
}
})

// #1923: the group member line (tools.js:84) carries a peer display name at 60 and an owner name at 40.
test('list_bots keeps whole characters in a peer display name and owner name (#1923)', async (t) => {
const displayName = 'a'.repeat(59) + '😀tail'
const ownerName = 'o'.repeat(39) + '😀tail'
const { tool, stop } = await start({
rooms: {
g: {
id: 'g', name: 'Group', members: ['a'],
participants: [{ kind: 'bot', remote: true, id: '@peer.bob.home', handle: '@peer.bob.home', displayName, ownerName }],
},
},
})
t.after(stop)
const listed = await tool('list_bots', {}, 'a')
assert.equal(listed.isWellFormed(), true, `the member line is not well formed: ${JSON.stringify(listed.slice(0, 80))}`)
const line = listed.split('\n').find(l => l.includes('members: ') && l.includes("'s Bot)"))
assert.ok(line, `the room line is listed: ${JSON.stringify(listed)}`)
// The peer entry is last on the member line: `<displayName> (<ownerName>'s Bot)`.
const members = line.split('members: ')[1].split(';')[0]
const peer = members.slice(members.lastIndexOf(', ') + 2)
const atOwner = peer.lastIndexOf(' (')
const keptDisplay = peer.slice(0, atOwner)
const keptOwner = peer.slice(atOwner + 2, peer.lastIndexOf('\'s Bot)'))
assert.equal(keptDisplay.isWellFormed(), true, `the display name is not well formed: ${JSON.stringify(keptDisplay)}`)
assert.equal(keptDisplay.includes('😀'), false, 'the display name drops the straddling emoji whole')
assert.ok(keptDisplay.length <= 60 && keptDisplay.length > 50, `the display name keeps the 60 bound: ${keptDisplay.length}`)
assert.ok(Buffer.from(keptDisplay, 'utf8').equals(Buffer.from(displayName, 'utf8').subarray(0, Buffer.byteLength(keptDisplay))), 'the display name is a prefix of what the peer published, byte for byte')
assert.equal(keptOwner.isWellFormed(), true, `the owner name is not well formed: ${JSON.stringify(keptOwner)}`)
assert.equal(keptOwner.includes('😀'), false, 'the owner name drops the straddling emoji whole')
assert.ok(keptOwner.length <= 40 && keptOwner.length > 30, `the owner name keeps the 40 bound: ${keptOwner.length}`)
assert.ok(Buffer.from(keptOwner, 'utf8').equals(Buffer.from(ownerName, 'utf8').subarray(0, Buffer.byteLength(keptOwner))), 'the owner name is a prefix of what the peer published, byte for byte')
})

test('cleanRemote sanitizes peer name, homeName and owner at the record boundary (#75)', async (t) => {
Expand Down
19 changes: 18 additions & 1 deletion core/test/reach-policy.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { join } from 'node:path'
import { test } from 'node:test'
import { createRuntime, provideFlyBot } from '../index.js'
import { fakeContext } from './fake-ctx.js'
import { hasAnyGrant, hasGrant, reach } from '../src/host/reach.js'
import { hasAnyGrant, hasGrant, othersInRoom, reach } from '../src/host/reach.js'

const bot = (id, ownerId, extra = {}) => ({ id, sessionId: id, name: id.toUpperCase(), instructions: '', createdAt: 1, ownerId, ...extra })

Expand Down Expand Up @@ -375,3 +375,20 @@ test('replacing the designated Chief does not grant the new one', async (t) => {
assert.equal(fb.bot.hasAnyGrant('a'), false)
assert.deepEqual(events, [])
})

// #1923: `othersInRoom` labels a friend's Bot for the model, so the 60 bound must not split a character.
test('a peer label for a room is cut on a code point boundary (#1923)', () => {
const straddle = 'a'.repeat(59) + '😀tail'
const state = team()
state.rooms = {
r: {
id: 'r', members: ['a'],
participants: [{ kind: 'bot', remote: true, id: '@peer.bob.home', handle: '@peer.bob.home', displayName: straddle }],
},
}
const [label] = othersInRoom(state, state.rooms.r, state.bots.a)
assert.equal(label.isWellFormed(), true, `the label is not well formed: ${JSON.stringify(label.slice(-2))}`)
assert.equal(label.includes('😀'), false, 'the straddling emoji is dropped whole')
assert.ok(label.length <= 60 && label.length > 50, `the label keeps the 60 bound: ${label.length}`)
assert.ok(Buffer.from(label, 'utf8').equals(Buffer.from(straddle, 'utf8').subarray(0, Buffer.byteLength(label))), 'the label is a prefix of the published name, byte for byte')
})
27 changes: 27 additions & 0 deletions core/test/remote-room.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -863,6 +863,33 @@ test('room-share envelope round-trips join, line and leave', () => {
assert.equal(parseRoomShare('hello there'), undefined)
})

// #1923: these fields cross an owner boundary, so the envelope's own bounds must never split a character.
test('a room-share envelope clips peer names on a code point boundary (#1923)', () => {
const name = 'a'.repeat(59) + '😀tail'
const handle = '@' + 'b'.repeat(118) + '😀tail'
const packed = encodeRoomShare({
op: 'join', roomId: 'war', name, inviterName: name,
inviterHome: '@alice.home', inviterHandle: '@chief.alice.home', invitedHandle: '@coder.bob.home', seq: 1,
roster: [{ handle, name }],
})
const parsed = parseRoomShare(packed)
assert.equal(parsed.op, 'join')
const cases = [
['name', parsed.name, name, 60],
['inviterName', parsed.inviterName, name, 60],
['roster[0].handle', parsed.roster[0].handle, handle, 120],
['roster[0].name', parsed.roster[0].name, name, 60],
]
for (const [field, kept, source, max] of cases) {
assert.equal(typeof kept, 'string', `${field} is in the envelope: ${JSON.stringify(parsed)}`)
assert.equal(kept.isWellFormed(), true, `${field} is not well formed: ${JSON.stringify(kept.slice(-2))}`)
assert.equal(kept.includes('😀'), false, `${field} drops the straddling emoji whole`)
assert.ok(kept.length <= max, `${field} keeps the ${max} bound: ${kept.length}`)
assert.ok(kept.length > max - 10, `${field} does not drop the whole field for one character: ${kept.length}`)
assert.ok(Buffer.from(kept, 'utf8').equals(Buffer.from(source, 'utf8').subarray(0, Buffer.byteLength(kept))), `${field} is a prefix of what the sender meant, byte for byte`)
}
})

test('applyInboundRoomShare projects a join with no chat line, then a leave hides it', async (t) => {
const { rt } = host(t, TEAM)
await rt.load()
Expand Down
Loading
Loading