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/106-bot-to-bot-chips.fixed.md
Original file line number Diff line number Diff line change
@@ -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))
1 change: 1 addition & 0 deletions changelog.d/111-taskid-stamp.fixed.md
Original file line number Diff line number Diff line change
@@ -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))
1 change: 1 addition & 0 deletions changelog.d/132-face-room-titles.fixed.md
Original file line number Diff line number Diff line change
@@ -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))
21 changes: 20 additions & 1 deletion core/src/host/delivery.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,24 @@ export const cleanName = (name, fallback = 'a remote Bot') => {
}
export const fenceUntrusted = (name, text) => `<untrusted-message from="${cleanName(name)}">\n${stripUntrusted(text ?? '')}\n</untrusted-message>`

// 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

Expand Down Expand Up @@ -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)
Expand Down
19 changes: 2 additions & 17 deletions core/src/host/groups.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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({
Expand Down
8 changes: 7 additions & 1 deletion core/src/host/hooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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)
Expand Down
12 changes: 10 additions & 2 deletions core/src/host/room-membership.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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}`)
Expand Down Expand Up @@ -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
Expand Down
24 changes: 24 additions & 0 deletions core/src/host/tasks.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
48 changes: 48 additions & 0 deletions core/src/shared/face-title.js
Original file line number Diff line number Diff line change
@@ -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(' · ')
}
21 changes: 21 additions & 0 deletions core/test/context-sharing.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
2 changes: 2 additions & 0 deletions core/test/remote-room.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
24 changes: 22 additions & 2 deletions core/test/tasks.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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' })
Expand All @@ -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) => {
Expand Down
Loading
Loading