diff --git a/changelog.d/550-group-questions.fixed.md b/changelog.d/550-group-questions.fixed.md new file mode 100644 index 00000000..815568fb --- /dev/null +++ b/changelog.d/550-group-questions.fixed.md @@ -0,0 +1 @@ +Group chats: when a Bot works on something handed out in a group and needs to ask you a question, the question card now shows in that group instead of in the Bot's private chat. Answer it there and the Bot carries on with the work, reporting back in the group; the Bot's private chat stays untouched ([#550](https://github.com/ThinkFlowLab/FlyBot/issues/550)). diff --git a/core/src/host/authz.js b/core/src/host/authz.js index 890c45cf..13e65372 100644 --- a/core/src/host/authz.js +++ b/core/src/host/authz.js @@ -134,6 +134,11 @@ export function install(rt) { return false } + // A card a Bot asks in a group about work handed over from there (#550) is its owner's to see + // and answer, like that Bot's approvals; the room's owner may too. Other members get no card. + const questionFor = (actor, sessionId, question) => question?.botId === undefined + || botOwnedBy(actor, question.botId) || roomOwnedBy(actor, sessionId) + rt.botOwnedBy = botOwnedBy rt.roomOwnedBy = roomOwnedBy rt.roomMemberOf = roomMemberOf @@ -162,7 +167,7 @@ export function install(rt) { bots: full.bots.filter(bot => bot.ownerId === actor.memberId || sharedBots.has(bot.id)), rooms, self: { memberId: actor.memberId, displayName: actor.displayName }, - questions: bySession(full.questions), + questions: Object.fromEntries(Object.entries(bySession(full.questions)).filter(([id, question]) => questionFor(actor, id, question))), exchangeTurns: bySession(full.exchangeTurns), remoteTypedTurns: bySession(full.remoteTypedTurns), remoteAwaiting: bySession(full.remoteAwaiting), @@ -347,6 +352,9 @@ export function install(rt) { if (String(body?.choice ?? '').startsWith('always')) return botOwnedBy(actor, approval.botId) ? ALLOW : deny('bot/forbidden', 'Only the Bot\'s owner can always allow') return botOwnedBy(actor, approval.botId) || roomOwnedBy(actor, approval.roomId) ? ALLOW : deny('bot/forbidden', 'You do not own that approval') } + if ((ep === 'answer-question' || ep === 'dismiss-question') && !questionFor(actor, String(body?.sessionId ?? ''), state.questions?.[String(body?.sessionId ?? '')])) { + return deny('bot/forbidden', 'This question is not yours to answer') + } return requireSession(body?.sessionId) } diff --git a/core/src/host/channel.js b/core/src/host/channel.js index 44e57758..4a13f2e2 100644 --- a/core/src/host/channel.js +++ b/core/src/host/channel.js @@ -401,6 +401,13 @@ export function install(rt) { const sessionId = rt.botOf(body.sessionId) ? rt.chatOf(body.sessionId) : past && past.roomId === undefined ? rt.chatOf(past.botId) : body.sessionId const question = state.questions[sessionId] if (question === undefined || question.id !== body.questionId) return fail('This question is no longer open', 'bot/stale') + // A card in a group about work handed over from there (#550) answers the Bot that asked. + if (question.botId !== undefined && rt.roomOf(sessionId) !== undefined) { + const leaked = unshareableReference(actor, String(body.answer?.custom ?? '')) + if (leaked !== undefined) return fail('You do not own that chat', 'bot/forbidden') + const answered = await rt.answerGroupQuestion(sessionId, question, body.answer, actor) + return answered.ok ? ok(true) : fail(answered.error, answered.code) + } const reply = questionReply(question, body.answer) if (reply === undefined) return fail('Nothing to send', 'bot/invalid') const leaked = unshareableReference(actor, reply.text) diff --git a/core/src/host/delivery.js b/core/src/host/delivery.js index e5672f2d..2ebc641b 100644 --- a/core/src/host/delivery.js +++ b/core/src/host/delivery.js @@ -69,7 +69,8 @@ export function install(rt) { return resolved.agent } - const headerFor = (role, fromName, roomName, audience, note = 'new message', deferred = false, { resumed = false, retried = false } = {}) => { + const headerFor = (role, fromName, roomName, audience, note = 'new message', deferred = false, { resumed = false, retried = false, answeredIn } = {}) => { + if (answeredIn !== undefined) return `[The user answered your question card in the group "${answeredIn}" · your reply goes back to ${fromName} there]` if (deferred) return `[Message from ${fromName} · you read it during your last task; answer it now]` if (retried && role !== 'room') return '[Your last reply wrote a tool call as text, so nothing ran]' if (resumed && role === 'resume') return '[DeepSeek Harness restarted while you were working]' @@ -126,8 +127,9 @@ export function install(rt) { // text (resume.js, turns.js), and `resent` is a message a shutdown cancelled; their // exchange is recorded already too. `askedBy` is the member whose @mention called a // group turn (groups.js). `fromRoomId`: the group whose Session the sender wrote - // from; the reply goes back to that Session (turns.js). - async function deliver({ fromId, toId, sessionId, text, role, hop = 0, replyTo, roomId, audience, note, exchangeId = randomUUID(), steer = false, deferred = false, resumed = false, retried = false, resent = false, askedBy, fromRoomId, fromName: givenName, untrusted = false, remote, attachments, origin: givenOrigin }) { + // from; the reply goes back to that Session (turns.js). `answeredIn`: the group where the user + // answered the Bot's question card about work handed over from there (tools.js, #550). + async function deliver({ fromId, toId, sessionId, text, role, hop = 0, replyTo, roomId, audience, note, exchangeId = randomUUID(), steer = false, deferred = false, resumed = false, retried = false, resent = false, askedBy, fromRoomId, answeredIn, fromName: givenName, untrusted = false, remote, attachments, origin: givenOrigin }) { const from = rt.botOf(fromId) const to = rt.botOf(toId) if (to === undefined) throw new Error(`Unknown Bot ${toId}`) @@ -174,7 +176,7 @@ export function install(rt) { // 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, body) : body}` }], + content: [{ type: 'text', text: `${headerFor(role, fromName, room?.name, audience, note, deferred, { resumed, retried, answeredIn })}\n${untrusted ? fenceUntrusted(fromName, body) : body}` }], source, }) if (steer) agent.steer(message) diff --git a/core/src/host/roster.js b/core/src/host/roster.js index 567799bf..970bae57 100644 --- a/core/src/host/roster.js +++ b/core/src/host/roster.js @@ -475,6 +475,7 @@ export function install(rt) { if (room === undefined) throw new Error('Unknown group chat') for (const botId of Object.keys(room.sessions ?? {})) rt.dropGroupSession(room, botId) delete state.rooms[id] + delete state.questions[id] rt.roomInbox.delete(id) const failed = await saveForDelete() // Armed scheduled tasks refuse a plain archive and would keep delivering into @@ -532,6 +533,10 @@ export function install(rt) { delete state.exchangeTurns[part] delete state.remoteTypedTurns?.[part] } + // A card it asked in a group (#550) has nobody left to answer. + for (const [key, question] of Object.entries(state.questions)) { + if (question?.botId === id) delete state.questions[key] + } const touched = [] for (const room of Object.values(state.rooms)) { if (!room.members.includes(id)) continue diff --git a/core/src/host/tools.js b/core/src/host/tools.js index 053d8220..ce441205 100644 --- a/core/src/host/tools.js +++ b/core/src/host/tools.js @@ -6,6 +6,9 @@ import { COLORS, EXTRA_COLORS, MAX_HOPS, MAX_UNREAD_REQUESTS, ROOM_BOT_ROUNDS, R import { unquote } from './text.js' import { isListedRemoteChat } from '../shared/remote-label.js' import { currentParticipants } from './room-membership.js' +import { questionReply, replyText } from './parts.js' +import { OTHER, settled } from './turn-origin.js' +import { cleanName } from './delivery.js' export const output = { schema: { type: 'string' }, render: (_args, value) => [{ type: 'text', text: String(value) }] } export const GROUP_REF = { type: 'string', required: true, description: 'Exact group chat name from list_bots' } @@ -524,6 +527,8 @@ export function install(rt) { .filter(option => option.label !== '') .slice(0, 6) if (options.length === 0) return 'Give at least one option.' + const handed = handedFromGroup(id) + if (handed !== undefined) return askInGroup(handed, args, options, exec) // A hidden Bot-to-Bot turn shows none of its calls, so its card goes to the // composer dock, which takes questions without a call. const turn = rt.live.get(id)?.turn @@ -544,6 +549,89 @@ export function install(rt) { }, })) + // Work a group member hands a Bot with message_bot runs in a hidden turn of the Bot's own chat, + // and its reply goes back to that member's Session in the group (turns.js). The user handed the + // work out there, so a question about it is asked there too, never in the Bot's own chat (#550). + const handedFromGroup = (sessionId) => { + if (!rt.chatOwners.has(sessionId)) return undefined + const info = rt.live.get(sessionId)?.info + if (info?.role !== 'request' || info.audience === 'user' || info.remote !== undefined) return undefined + const room = rt.roomOf(info.senderRoomId) + if (room?.sessions?.[info.senderSessionId] === undefined) return undefined + return { room, info } + } + + // The card sits in the group's composer dock, named for the Bot that asks. One card per group: + // while another Bot's waits there, this one asks in its reply, which goes back to the group. + async function askInGroup({ room, info }, args, options, exec) { + const id = exec.agent?.id + const bot = rt.botOf(rt.selfOf(id)) + const open = state.questions[room.id] + if (open !== undefined && open.botId !== bot?.id) { + return `Another question is waiting in the group "${room.name}". Ask in your reply instead; it goes back to the group.` + } + state.questions[room.id] = { + id: randomUUID(), + header: bot?.name ?? 'Bot', + question: String(args.question ?? '').trim(), + ...(args.detail ? { detail: String(args.detail).trim() } : {}), + options, + multiSelect: args.multiSelect === true, + // The group's card always takes an answer in the user's own words: a line typed in the + // group goes to the group's round, not to this Bot. + allowCustom: true, + askedAt: Date.now(), + botId: bot?.id, + replyTo: { botId: info.senderSessionId, hop: info.hop ?? 0 }, + // The answer carries on this work with the authority it had (turn-origin.js). + origin: settled(rt, id) ?? OTHER, + } + await rt.save() + exec.concludeTurn?.() + return `The card is on screen in the group "${room.name}", where this work came from; the user's answer arrives as your next message.` + } + + // The answer to such a card reaches the Bot the way the work did: a hidden request from the + // member that handed it over, so the Bot's reply goes back to that member's Session in the group + // and nothing new shows in the Bot's own chat. The group sees the answer as a quiet notice. + async function answerGroupQuestion(roomId, question, answer, actor) { + const reply = questionReply(question, answer) + if (reply === undefined) return { error: 'Nothing to send', code: 'bot/invalid' } + const room = rt.roomOf(roomId) + const bot = rt.botOf(question.botId) + delete state.questions[roomId] + if (room === undefined || bot === undefined) { + await rt.save() + return { error: 'This question is no longer open', code: 'bot/stale' } + } + // Someone other than the Bot's owner answering (a room owner) does not lend the owner's authority. + const byOwner = actor === undefined || actor === null || !rt.authzEnforced?.() || actor.memberId === bot.ownerId + const text = replyText(reply.text) + try { + await rt.deliver({ + fromId: question.replyTo?.botId, + toId: bot.id, + role: 'request', + hop: question.replyTo?.hop ?? 0, + fromRoomId: room.id, + answeredIn: room.name, + origin: byOwner ? question.origin : OTHER, + text, + }) + } catch (error) { + state.questions[roomId] ??= question + throw error + } + await rt.save() + const who = cleanName(actor?.displayName, 'You') + const given = JSON.parse(reply.text).answers[0] + const values = [...given.selected, ...(given.custom ? [given.custom] : [])].join(', ').replace(/\s+/g, ' ').slice(0, 300) + void rt.postToRoom?.(room, { id: 'face-notice', name: 'Room' }, `${who} answered ${bot.name}: ${question.question} → ${values}`, { quiet: true, notice: true }) + .catch(error => rt.warn('ds-bot: the answer notice in %s was not shown: %s', room.name, error)) + return { ok: true } + } + rt.answerGroupQuestion = answerGroupQuestion + // dsh's own question tool holds the turn open until the user answers, and every // message from another Bot or a group round queues behind it. Bot Sessions ask // through ask_user instead, so the tool is taken out of their tool list. diff --git a/core/src/host/turns.js b/core/src/host/turns.js index 1b53358b..114e5f24 100644 --- a/core/src/host/turns.js +++ b/core/src/host/turns.js @@ -461,8 +461,9 @@ export function install(rt) { } const own = rt.chatOwners.get(id) if (own !== undefined) rt.dropKickoff(own) - // Any user message answers or supersedes the open question card. - if (state.questions[id] !== undefined) { + // Any user message answers or supersedes the open question card. A group's card waits for + // its own answer: a line typed in the group goes to its round, not to the Bot that asked (#550). + if (state.questions[id] !== undefined && !rt.roomOf(id)) { delete state.questions[id] void rt.save().catch(() => {}) } diff --git a/core/test/authz.test.js b/core/test/authz.test.js index 31277028..5d497f1c 100644 --- a/core/test/authz.test.js +++ b/core/test/authz.test.js @@ -382,3 +382,30 @@ test('views keep the sole owner whole and narrow per owner with two members', as assert.deepEqual(live2.connectors, []) assert.equal(live2.memoryReviews, 0) }) + +test('a group\'s question card is its Bot owner\'s to see and answer, not every member\'s (#550)', async (t) => { + const { rt } = runtime(t, TEAM) + await rt.load() + const sessionA = await redeemOwner(rt) + const sessionB = await redeemOwner(rt) + rt.state.bots.a1 = { id: 'a1', sessionId: 'a1', name: 'A', instructions: '', createdAt: 2, ownerId: 'owner-1' } + rt.state.bots.b1 = { id: 'b1', sessionId: 'b1', name: 'B', instructions: '', createdAt: 3, ownerId: 'owner-2' } + rt.state.rooms.rx = { + id: 'rx', name: 'Shared', members: ['a1', 'b1'], admin: 'a1', sessions: { a1: 'gs-a1', b1: 'gs-b1' }, + participants: [ + { kind: 'human', id: 'owner-1', role: 'admin', joinedSeq: 0 }, + { kind: 'human', id: 'owner-2', role: 'member', joinedSeq: 0 }, + ], + } + rt.state.questions.rx = { id: 'q1', question: 'Which branch?', options: [{ label: 'dev' }], botId: 'a1' } + await rt.save() + const actorA = await rt.authenticate(sessionA.sessionToken) + const actorB = await rt.authenticate(sessionB.sessionToken) + assert.equal(rt.authorize(actorB, 'send', { sessionId: 'rx' }).ok, true) + assert.equal(rt.authorize(actorA, 'answer-question', { sessionId: 'rx' }).ok, true) + assert.equal(rt.authorize(actorB, 'answer-question', { sessionId: 'rx' }).ok, false) + assert.equal(rt.authorize(actorB, 'dismiss-question', { sessionId: 'rx' }).ok, false) + const full = { bots: Object.values(rt.state.bots), rooms: [], questions: { rx: rt.state.questions.rx } } + assert.deepEqual(Object.keys(rt.ownedView(full, actorA).questions), ['rx']) + assert.deepEqual(Object.keys(rt.ownedView(full, actorB).questions), []) +}) diff --git a/core/test/diagnostics.test.js b/core/test/diagnostics.test.js index 8a7abff1..7c3c3a32 100644 --- a/core/test/diagnostics.test.js +++ b/core/test/diagnostics.test.js @@ -72,9 +72,17 @@ test('warnings are kept, cleaned, written to the log, and read back after a rest assert.doesNotMatch(report.markdown, /sk-abc|\/home\//) assert.equal(report.entries[0].code, 'RATE_LIMIT', 'newest first') - // Written as JSON lines; a new host reads them back. - await new Promise(resolve => setTimeout(resolve, 50)) - assert.equal(readFileSync(join(home, 'logs', 'diagnostics.jsonl'), 'utf8').trim().split('\n').length, 2) + // Written as JSON lines in the background; a new host reads them back. Wait for the + // lines rather than a fixed pause, which a busy CI disk can outlast (#551 queue run). + const logLines = () => { + try { + return readFileSync(join(home, 'logs', 'diagnostics.jsonl'), 'utf8').trim().split('\n').length + } catch { + return 0 // not written yet + } + } + for (let waited = 0; logLines() < 2 && waited < 5000; waited += 20) await new Promise(resolve => setTimeout(resolve, 20)) + assert.equal(logLines(), 2) const again = host(home) assert.deepEqual((await again.diagnostics.entries()).map(entry => entry.code), ['EACCES', 'RATE_LIMIT']) assert.match(await again.diagnostics.logText(), /\n {4}at TestContext/) diff --git a/core/test/group-work.test.js b/core/test/group-work.test.js index 20c93bc9..5080772b 100644 --- a/core/test/group-work.test.js +++ b/core/test/group-work.test.js @@ -287,3 +287,82 @@ test('members still at work show in the team view and in the prompts of the othe host.finish('gc', '没问题。') await settle(() => result() !== undefined) }) + +// #550: work a group member hands a Bot with message_bot runs in a hidden turn of the Bot's own +// chat; a question there belongs to the group the work came from, and its answer goes back the same way. +const askBranch = (host, sessionId = 'c') => host.tool('ask_user', { question: 'Which branch?', options: [{ label: 'main' }, { label: 'dev' }] }, sessionId, { callId: 'q1', concludeTurn() {} }) + +test('a question about work handed out from a group shows in that group, not in the Bot\'s own chat (#550)', async (t) => { + const host = await start(t) + await host.tool('message_bot', { to: 'Tester', message: '把测试跑一遍' }, 'gb') + host.begin('c') + assert.match(await askBranch(host), /in the group "Ops"/) + const { value } = await host.call('state', {}) + assert.equal(value.questions.c, undefined) + assert.deepEqual([value.questions.r1?.question, value.questions.r1?.botId, value.questions.r1?.header], ['Which branch?', 'c', 'Tester']) +}) + +test('the answer given in the group reaches the asking Bot\'s waiting work and its reply returns to the group (#550)', async (t) => { + const host = await start(t) + await host.tool('message_bot', { to: 'Tester', message: '把测试跑一遍' }, 'gb') + host.begin('c') + await askBranch(host) + host.finish('c', 'Need to know the branch first.') + await settle(() => host.agentOf('gb').calls.length === 1) + const asked = (await host.call('state', {})).value.questions.r1 + // A line typed in the group goes to the group's round; the card waits for its own answer. + host.emit('r1', 'user/message', host.say('大家好')) + assert.equal((await host.call('state', {})).value.questions.r1?.id, asked.id) + const before = host.agentOf('c').calls.length + const answered = await host.call('answer-question', { sessionId: 'r1', questionId: asked.id, answer: { selected: ['dev'] } }) + assert.equal(answered.ok, true) + assert.equal((await host.call('state', {})).value.questions.r1, undefined) + // Exactly one new message in Tester's own chat: a hidden request that carries the answer, not a user turn. + const added = host.agentOf('c').calls.slice(before) + assert.equal(added.length, 1) + assert.deepEqual([added[0].source.kind, added[0].source.role, added[0].source.senderSessionId, added[0].source.senderRoomId], ['bot', 'request', 'b', 'r1']) + assert.match(text(added[0]), /group "Ops"/) + assert.match(text(added[0]), /Which branch\?\nAnswer: dev/) + // The group sees the answer as a quiet notice. + await settle(() => host.agentOf('r1').calls.some(call => call.source?.notice === true)) + const notice = host.agentOf('r1').calls.find(call => call.source?.notice === true) + assert.equal(notice.source.quiet, true) + assert.match(text(notice), /answered Tester: Which branch\? → dev/) + // Tester's reply goes back to Writer's Session in the group, which posts it there. + host.begin('c') + host.finish('c', 'Ran it on dev: all green.') + await settle(() => host.agentOf('gb').calls.length === 2) + assert.match(text(host.agentOf('gb').calls.at(-1)), /^\[Reply from Tester\]\nRan it on dev: all green\./) + assert.equal((await host.call('answer-question', { sessionId: 'r1', questionId: asked.id, answer: { selected: ['dev'] } })).error.code, 'bot/stale') +}) + +test('a question in the Bot\'s own chat stays there, and a request answered to the user keeps its card there (#550)', async (t) => { + const host = await start(t) + host.emit('c', 'turn/start', { turn: 1 }) + host.emit('c', 'user/message', host.say('hi')) + await askBranch(host) + let { value } = await host.call('state', {}) + assert.deepEqual([value.questions.c?.question, value.questions.r1], ['Which branch?', undefined]) + host.finish('c', '') + await host.call('dismiss-question', { sessionId: 'c' }) + await host.tool('message_bot', { to: 'Tester', message: '跟用户打个招呼', reply_to: 'user' }, 'gb') + host.begin('c') + await askBranch(host) + ;({ value } = await host.call('state', {})) + assert.deepEqual([value.questions.c?.question, value.questions.r1], ['Which branch?', undefined]) +}) + +test('a face-to-face room asks and answers the same way (#550)', async (t) => { + const host = await start(t, { origin: 'face-to-face' }) + await host.tool('message_bot', { to: 'Tester', message: 'check it' }, 'gb') + host.begin('c') + await askBranch(host) + const asked = (await host.call('state', {})).value.questions.r1 + assert.equal(asked?.botId, 'c') + host.finish('c', '') + const before = host.agentOf('c').calls.length + assert.equal((await host.call('answer-question', { sessionId: 'r1', questionId: asked.id, answer: { selected: [], custom: 'the release branch' } })).ok, true) + const added = host.agentOf('c').calls.slice(before) + assert.deepEqual(added.map(call => [call.source.role, call.source.senderRoomId]), [['request', 'r1']]) + assert.match(text(added[0]), /Answer: the release branch/) +}) diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index 53af80c3..e1e73ba7 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -113,6 +113,10 @@ credentials and team mode remain separate work under #55/#198. `work.bots[id]` (`turns.js` `workView`) carries `step`: what the newest running call acts on, from its arguments (`command`, `cmd`, `pattern`, `query`, `url`, `file_path`, `path`, else `description`), whitespace collapsed and capped at 80 characters. It is in the owner-only Bot view (`authz.js` `ownedLive` picks the actor's own Bots), not in `work.rooms`, which other members of a shared group read. It goes with the call's result and the turn's end. Covered by `core/test/group-status.test.js`. +### Questions about work handed out from a group (#550) + +Work a member hands a Bot with `message_bot` from its group Session runs in a hidden turn of the Bot's own chat (the request carries `senderRoomId`), and the reply goes back to that member's Session in the group. `ask_user` in such a turn (`tools.js` `handedFromGroup`: role `request`, not `audience: 'user'`, not a friend's, the sender still has a Session in the room) does not write the Bot's own-chat card: it writes `state.questions[]` with `header` (the asking Bot's name), `botId`, `replyTo` (`{ botId, hop }` of the member that handed the work over), the asking turn's `origin` and `allowCustom: true`, so the card shows in the group's composer dock (the client's `QuestionDock` reads `questions[sessionId]`). One card per group: while another Bot's waits there, the tool tells the Bot to ask in its reply. `answer-question` on the room id calls `rt.answerGroupQuestion`: the answer reaches the asking Bot as a hidden `request` from the member (`fromRoomId` = the room, header names the group via `deliver`'s `answeredIn`), so its reply goes back to the group exactly as the work did and the Bot's own chat gets no card and no visible turn; the group shows a quiet notice (` answered : → `). The answer keeps the asking turn's `origin` only when the Bot's owner gave it (fail closed: `other` for anyone else). A line typed in the group does not close the group's card (it goes to the group's round). `authz.js` limits seeing (`ownedView`) and answering/dismissing such a card to the asking Bot's owner or the room's owner. Deleting the room or the Bot drops the card. Face-to-face rooms take the same path on the local home. `ask_user` inside a group Session is still refused (the Bot asks in its reply, which the group shows). Covered by `core/test/group-work.test.js` and `core/test/authz.test.js`. + ### Inbound shared rooms - Bot Social conversations are 1:1; it does not list a FlyBot group. When a room gains a granted remote Bot (create-room, addParticipants, or 新建群聊 picking a friend's chat), the host fans out a `FLYBOT-ROOM-SHARE` envelope (`src/shared/room-share.js`) through `flybotSend` (`via: 'room-share'` for join/leave, `via: 'room'` for lines, `meta.roomId` set) so the Bot's owner's FlyBot can project it immediately — including a room with no chat lines yet. Receive is `rt.applyInboundRoomShare` (`origin: 'inbound-share'`, `recordInboundSharedRoom`), labelled **Shared room from \** with the room name stored. A leave envelope, hide, or grant revoke marks it left/hidden. `reach()` allows a local member to message a remote that already sits in that room when `roomId` is on the send. Still not team mode. diff --git a/patches/ds-bot/0001-core-fixes.patch b/patches/ds-bot/0001-core-fixes.patch index c7be1c29..72ec22f5 100644 --- a/patches/ds-bot/0001-core-fixes.patch +++ b/patches/ds-bot/0001-core-fixes.patch @@ -3700,7 +3700,7 @@ diff --git a/src/host/turns.js b/src/host/turns.js }) } else if (source?.kind === SOURCE_KIND && source.role === 'kickoff' && rt.roomOf(id)) { queueRoomMessage(id, { text: '', kickoff: true }) -@@ -363,7 +363,11 @@ export function install(rt) { +@@ -363,12 +363,17 @@ export function install(rt) { if (rt.roomOf(id) && source.kind === 'user') { const images = (Array.isArray(event.data.content) ? event.data.content : []) .filter(block => block?.type === 'image' && !block.offloaded).map(block => block.attachment) @@ -3713,6 +3713,14 @@ diff --git a/src/host/turns.js b/src/host/turns.js } const own = rt.chatOwners.get(id) if (own !== undefined) rt.dropKickoff(own) +- // Any user message answers or supersedes the open question card. +- if (state.questions[id] !== undefined) { ++ // Any user message answers or supersedes the open question card. A group's card waits for ++ // its own answer: a line typed in the group goes to its round, not to the Bot that asked (#550). ++ if (state.questions[id] !== undefined && !rt.roomOf(id)) { + delete state.questions[id] + void rt.save().catch(() => {}) + } @@ -386,11 +386,16 @@ export function install(rt) { case 'turn/end': { setActivity(id, undefined) @@ -4925,6 +4933,92 @@ diff --git a/test/group-status.test.js b/test/group-status.test.js + host.emit('gb', 'turn/end', { reason: { kind: 'error', error: new Error('boom') } }) + assert.equal((await bots()).b, undefined) +}) +diff --git a/test/group-work.test.js b/test/group-work.test.js +--- a/test/group-work.test.js ++++ b/test/group-work.test.js +@@ -287,3 +287,82 @@ test('members still at work show in the team view and in the prompts of the othe + host.finish('gc', '没问题。') + await settle(() => result() !== undefined) + }) ++ ++// #550: work a group member hands a Bot with message_bot runs in a hidden turn of the Bot's own ++// chat; a question there belongs to the group the work came from, and its answer goes back the same way. ++const askBranch = (host, sessionId = 'c') => host.tool('ask_user', { question: 'Which branch?', options: [{ label: 'main' }, { label: 'dev' }] }, sessionId, { callId: 'q1', concludeTurn() {} }) ++ ++test('a question about work handed out from a group shows in that group, not in the Bot\'s own chat (#550)', async (t) => { ++ const host = await start(t) ++ await host.tool('message_bot', { to: 'Tester', message: '把测试跑一遍' }, 'gb') ++ host.begin('c') ++ assert.match(await askBranch(host), /in the group "Ops"/) ++ const { value } = await host.call('state', {}) ++ assert.equal(value.questions.c, undefined) ++ assert.deepEqual([value.questions.r1?.question, value.questions.r1?.botId, value.questions.r1?.header], ['Which branch?', 'c', 'Tester']) ++}) ++ ++test('the answer given in the group reaches the asking Bot\'s waiting work and its reply returns to the group (#550)', async (t) => { ++ const host = await start(t) ++ await host.tool('message_bot', { to: 'Tester', message: '把测试跑一遍' }, 'gb') ++ host.begin('c') ++ await askBranch(host) ++ host.finish('c', 'Need to know the branch first.') ++ await settle(() => host.agentOf('gb').calls.length === 1) ++ const asked = (await host.call('state', {})).value.questions.r1 ++ // A line typed in the group goes to the group's round; the card waits for its own answer. ++ host.emit('r1', 'user/message', host.say('大家好')) ++ assert.equal((await host.call('state', {})).value.questions.r1?.id, asked.id) ++ const before = host.agentOf('c').calls.length ++ const answered = await host.call('answer-question', { sessionId: 'r1', questionId: asked.id, answer: { selected: ['dev'] } }) ++ assert.equal(answered.ok, true) ++ assert.equal((await host.call('state', {})).value.questions.r1, undefined) ++ // Exactly one new message in Tester's own chat: a hidden request that carries the answer, not a user turn. ++ const added = host.agentOf('c').calls.slice(before) ++ assert.equal(added.length, 1) ++ assert.deepEqual([added[0].source.kind, added[0].source.role, added[0].source.senderSessionId, added[0].source.senderRoomId], ['bot', 'request', 'b', 'r1']) ++ assert.match(text(added[0]), /group "Ops"/) ++ assert.match(text(added[0]), /Which branch\?\nAnswer: dev/) ++ // The group sees the answer as a quiet notice. ++ await settle(() => host.agentOf('r1').calls.some(call => call.source?.notice === true)) ++ const notice = host.agentOf('r1').calls.find(call => call.source?.notice === true) ++ assert.equal(notice.source.quiet, true) ++ assert.match(text(notice), /answered Tester: Which branch\? → dev/) ++ // Tester's reply goes back to Writer's Session in the group, which posts it there. ++ host.begin('c') ++ host.finish('c', 'Ran it on dev: all green.') ++ await settle(() => host.agentOf('gb').calls.length === 2) ++ assert.match(text(host.agentOf('gb').calls.at(-1)), /^\[Reply from Tester\]\nRan it on dev: all green\./) ++ assert.equal((await host.call('answer-question', { sessionId: 'r1', questionId: asked.id, answer: { selected: ['dev'] } })).error.code, 'bot/stale') ++}) ++ ++test('a question in the Bot\'s own chat stays there, and a request answered to the user keeps its card there (#550)', async (t) => { ++ const host = await start(t) ++ host.emit('c', 'turn/start', { turn: 1 }) ++ host.emit('c', 'user/message', host.say('hi')) ++ await askBranch(host) ++ let { value } = await host.call('state', {}) ++ assert.deepEqual([value.questions.c?.question, value.questions.r1], ['Which branch?', undefined]) ++ host.finish('c', '') ++ await host.call('dismiss-question', { sessionId: 'c' }) ++ await host.tool('message_bot', { to: 'Tester', message: '跟用户打个招呼', reply_to: 'user' }, 'gb') ++ host.begin('c') ++ await askBranch(host) ++ ;({ value } = await host.call('state', {})) ++ assert.deepEqual([value.questions.c?.question, value.questions.r1], ['Which branch?', undefined]) ++}) ++ ++test('a face-to-face room asks and answers the same way (#550)', async (t) => { ++ const host = await start(t, { origin: 'face-to-face' }) ++ await host.tool('message_bot', { to: 'Tester', message: 'check it' }, 'gb') ++ host.begin('c') ++ await askBranch(host) ++ const asked = (await host.call('state', {})).value.questions.r1 ++ assert.equal(asked?.botId, 'c') ++ host.finish('c', '') ++ const before = host.agentOf('c').calls.length ++ assert.equal((await host.call('answer-question', { sessionId: 'r1', questionId: asked.id, answer: { selected: [], custom: 'the release branch' } })).ok, true) ++ const added = host.agentOf('c').calls.slice(before) ++ assert.deepEqual(added.map(call => [call.source.role, call.source.senderRoomId]), [['request', 'r1']]) ++ assert.match(text(added[0]), /Answer: the release branch/) ++}) diff --git a/test/home-lock.test.js b/test/home-lock.test.js --- a/test/home-lock.test.js +++ b/test/home-lock.test.js diff --git a/patches/ds-bot/0002-flybot-api-host.patch b/patches/ds-bot/0002-flybot-api-host.patch index bb7be407..2f490c0f 100644 --- a/patches/ds-bot/0002-flybot-api-host.patch +++ b/patches/ds-bot/0002-flybot-api-host.patch @@ -342,6 +342,14 @@ diff --git a/src/host/roster.js b/src/host/roster.js if (room.name !== before) { changes.push(`renamed it "${room.name}"`) await retitleRoom(room) +@@ -385,6 +385,7 @@ export function install(rt) { + if (room === undefined) throw new Error('Unknown group chat') + for (const botId of Object.keys(room.sessions ?? {})) rt.dropGroupSession(room, botId) + delete state.rooms[id] ++ delete state.questions[id] + rt.roomInbox.delete(id) + const failed = await saveForDelete() + // Armed scheduled tasks refuse a plain archive and would keep delivering into @@ -422,6 +422,7 @@ export function install(rt) { if (rt.botOf(other)?.kickoff?.main) delete rt.botOf(other).kickoff } @@ -350,7 +358,7 @@ diff --git a/src/host/roster.js b/src/host/roster.js await rt.save() } -@@ -435,9 +435,11 @@ export function install(rt) { +@@ -435,9 +435,15 @@ export function install(rt) { // Questions and exchange turns are kept per Session, so every part of the chat is cleared. const parts = new Set([id, current, ...(bot.segments ?? []).map(segment => segment.sessionId)]) delete state.bots[id] @@ -359,6 +367,10 @@ diff --git a/src/host/roster.js b/src/host/roster.js delete state.questions[part] delete state.exchangeTurns[part] + delete state.remoteTypedTurns?.[part] ++ } ++ // A card it asked in a group (#550) has nobody left to answer. ++ for (const [key, question] of Object.entries(state.questions)) { ++ if (question?.botId === id) delete state.questions[key] } const touched = [] for (const room of Object.values(state.rooms)) { @@ -680,12 +692,15 @@ diff --git a/src/host/store.js b/src/host/store.js diff --git a/src/host/tools.js b/src/host/tools.js --- a/src/host/tools.js +++ b/src/host/tools.js -@@ -4,6 +4,8 @@ import { randomUUID } from 'node:crypto' +@@ -4,6 +4,11 @@ import { randomUUID } from 'node:crypto' import { defineTool } from '@deepseek-ai/dsh-tools' import { COLORS, EXTRA_COLORS, MAX_HOPS, MAX_UNREAD_REQUESTS, ROOM_BOT_ROUNDS, ROOM_MODES, SOURCE_KIND } from './constants.js' import { unquote } from './text.js' +import { isListedRemoteChat } from '../shared/remote-label.js' +import { currentParticipants } from './room-membership.js' ++import { questionReply, replyText } from './parts.js' ++import { OTHER, settled } from './turn-origin.js' ++import { cleanName } from './delivery.js' export const output = { schema: { type: 'string' }, render: (_args, value) => [{ type: 'text', text: String(value) }] } export const GROUP_REF = { type: 'string', required: true, description: 'Exact group chat name from list_bots' } @@ -885,6 +900,105 @@ diff --git a/src/host/tools.js b/src/host/tools.js } catch (failure) { return `Could not post in "${room.name}": ${failure.message}` } +@@ -445,6 +445,8 @@ export function install(rt) { + .filter(option => option.label !== '') + .slice(0, 6) + if (options.length === 0) return 'Give at least one option.' ++ const handed = handedFromGroup(id) ++ if (handed !== undefined) return askInGroup(handed, args, options, exec) + // A hidden Bot-to-Bot turn shows none of its calls, so its card goes to the + // composer dock, which takes questions without a call. + const turn = rt.live.get(id)?.turn +@@ -465,6 +465,89 @@ export function install(rt) { + }, + })) + ++ // Work a group member hands a Bot with message_bot runs in a hidden turn of the Bot's own chat, ++ // and its reply goes back to that member's Session in the group (turns.js). The user handed the ++ // work out there, so a question about it is asked there too, never in the Bot's own chat (#550). ++ const handedFromGroup = (sessionId) => { ++ if (!rt.chatOwners.has(sessionId)) return undefined ++ const info = rt.live.get(sessionId)?.info ++ if (info?.role !== 'request' || info.audience === 'user' || info.remote !== undefined) return undefined ++ const room = rt.roomOf(info.senderRoomId) ++ if (room?.sessions?.[info.senderSessionId] === undefined) return undefined ++ return { room, info } ++ } ++ ++ // The card sits in the group's composer dock, named for the Bot that asks. One card per group: ++ // while another Bot's waits there, this one asks in its reply, which goes back to the group. ++ async function askInGroup({ room, info }, args, options, exec) { ++ const id = exec.agent?.id ++ const bot = rt.botOf(rt.selfOf(id)) ++ const open = state.questions[room.id] ++ if (open !== undefined && open.botId !== bot?.id) { ++ return `Another question is waiting in the group "${room.name}". Ask in your reply instead; it goes back to the group.` ++ } ++ state.questions[room.id] = { ++ id: randomUUID(), ++ header: bot?.name ?? 'Bot', ++ question: String(args.question ?? '').trim(), ++ ...(args.detail ? { detail: String(args.detail).trim() } : {}), ++ options, ++ multiSelect: args.multiSelect === true, ++ // The group's card always takes an answer in the user's own words: a line typed in the ++ // group goes to the group's round, not to this Bot. ++ allowCustom: true, ++ askedAt: Date.now(), ++ botId: bot?.id, ++ replyTo: { botId: info.senderSessionId, hop: info.hop ?? 0 }, ++ // The answer carries on this work with the authority it had (turn-origin.js). ++ origin: settled(rt, id) ?? OTHER, ++ } ++ await rt.save() ++ exec.concludeTurn?.() ++ return `The card is on screen in the group "${room.name}", where this work came from; the user's answer arrives as your next message.` ++ } ++ ++ // The answer to such a card reaches the Bot the way the work did: a hidden request from the ++ // member that handed it over, so the Bot's reply goes back to that member's Session in the group ++ // and nothing new shows in the Bot's own chat. The group sees the answer as a quiet notice. ++ async function answerGroupQuestion(roomId, question, answer, actor) { ++ const reply = questionReply(question, answer) ++ if (reply === undefined) return { error: 'Nothing to send', code: 'bot/invalid' } ++ const room = rt.roomOf(roomId) ++ const bot = rt.botOf(question.botId) ++ delete state.questions[roomId] ++ if (room === undefined || bot === undefined) { ++ await rt.save() ++ return { error: 'This question is no longer open', code: 'bot/stale' } ++ } ++ // Someone other than the Bot's owner answering (a room owner) does not lend the owner's authority. ++ const byOwner = actor === undefined || actor === null || !rt.authzEnforced?.() || actor.memberId === bot.ownerId ++ const text = replyText(reply.text) ++ try { ++ await rt.deliver({ ++ fromId: question.replyTo?.botId, ++ toId: bot.id, ++ role: 'request', ++ hop: question.replyTo?.hop ?? 0, ++ fromRoomId: room.id, ++ answeredIn: room.name, ++ origin: byOwner ? question.origin : OTHER, ++ text, ++ }) ++ } catch (error) { ++ state.questions[roomId] ??= question ++ throw error ++ } ++ await rt.save() ++ const who = cleanName(actor?.displayName, 'You') ++ const given = JSON.parse(reply.text).answers[0] ++ const values = [...given.selected, ...(given.custom ? [given.custom] : [])].join(', ').replace(/\s+/g, ' ').slice(0, 300) ++ void rt.postToRoom?.(room, { id: 'face-notice', name: 'Room' }, `${who} answered ${bot.name}: ${question.question} → ${values}`, { quiet: true, notice: true }) ++ .catch(error => rt.warn('ds-bot: the answer notice in %s was not shown: %s', room.name, error)) ++ return { ok: true } ++ } ++ rt.answerGroupQuestion = answerGroupQuestion ++ + // dsh's own question tool holds the turn open until the user answers, and every + // message from another Bot or a group round queues behind it. Bot Sessions ask + // through ask_user instead, so the tool is taken out of their tool list. @@ -525,7 +525,7 @@ export function install(rt) { name: 'save_capability', description: 'Create or update a shared MCP server, skill, or pack. Changes apply from your next message.', diff --git a/patches/ds-bot/0005-social-seams.patch b/patches/ds-bot/0005-social-seams.patch index 73187df7..04839d6a 100644 --- a/patches/ds-bot/0005-social-seams.patch +++ b/patches/ds-bot/0005-social-seams.patch @@ -548,8 +548,17 @@ diff --git a/src/host/channel.js b/src/host/channel.js case 'memory-view': case 'memory-ask': case 'memory-forget': -@@ -260,6 +260,8 @@ export function install(rt) { +@@ -258,8 +258,17 @@ export function install(rt) { + const sessionId = rt.botOf(body.sessionId) ? rt.chatOf(body.sessionId) : past && past.roomId === undefined ? rt.chatOf(past.botId) : body.sessionId + const question = state.questions[sessionId] if (question === undefined || question.id !== body.questionId) return fail('This question is no longer open', 'bot/stale') ++ // A card in a group about work handed over from there (#550) answers the Bot that asked. ++ if (question.botId !== undefined && rt.roomOf(sessionId) !== undefined) { ++ const leaked = unshareableReference(actor, String(body.answer?.custom ?? '')) ++ if (leaked !== undefined) return fail('You do not own that chat', 'bot/forbidden') ++ const answered = await rt.answerGroupQuestion(sessionId, question, body.answer, actor) ++ return answered.ok ? ok(true) : fail(answered.error, answered.code) ++ } const reply = questionReply(question, body.answer) if (reply === undefined) return fail('Nothing to send', 'bot/invalid') + const leaked = unshareableReference(actor, reply.text) @@ -783,7 +792,7 @@ diff --git a/src/host/delivery.js b/src/host/delivery.js export function install(rt) { const { ctx, state } = rt -@@ -10,6 +10,8 @@ export function install(rt) { +@@ -10,10 +10,13 @@ export function install(rt) { const resolved = await ctx.sessionController.resolveAgent(sessionId) if ('error' in resolved) throw new Error(resolved.error?.message ?? 'Session unavailable') rt.hideBlockingQuestion(resolved.agent) @@ -792,6 +801,12 @@ diff --git a/src/host/delivery.js b/src/host/delivery.js return resolved.agent } +- const headerFor = (role, fromName, roomName, audience, note = 'new message', deferred = false, { resumed = false, retried = false } = {}) => { ++ const headerFor = (role, fromName, roomName, audience, note = 'new message', deferred = false, { resumed = false, retried = false, answeredIn } = {}) => { ++ if (answeredIn !== undefined) return `[The user answered your question card in the group "${answeredIn}" · your reply goes back to ${fromName} there]` + if (deferred) return `[Message from ${fromName} · you read it during your last task; answer it now]` + if (retried && role !== 'room') return '[Your last reply wrote a tool call as text, so nothing ran]' + if (resumed && role === 'resume') return '[DeepSeek Harness restarted while you were working]' @@ -28,6 +28,38 @@ export function install(rt) { return `[Message from ${fromName}]` } @@ -831,12 +846,15 @@ diff --git a/src/host/delivery.js b/src/host/delivery.js // Roles recorded as team exchanges. const EXCHANGE_ROLES = new Set(['request', 'reply', 'stopped']) -@@ -39,12 +39,16 @@ export function install(rt) { +@@ -38,13 +38,18 @@ export function install(rt) { + // text (resume.js, turns.js), and `resent` is a message a shutdown cancelled; their // exchange is recorded already too. `askedBy` is the member whose @mention called a // group turn (groups.js). `fromRoomId`: the group whose Session the sender wrote - // from; the reply goes back to that Session (turns.js). +- // from; the reply goes back to that Session (turns.js). - async function deliver({ fromId, toId, sessionId, text, role, hop = 0, replyTo, roomId, audience, note, exchangeId = randomUUID(), steer = false, deferred = false, resumed = false, retried = false, resent = false, askedBy, fromRoomId }) { -+ async function deliver({ fromId, toId, sessionId, text, role, hop = 0, replyTo, roomId, audience, note, exchangeId = randomUUID(), steer = false, deferred = false, resumed = false, retried = false, resent = false, askedBy, fromRoomId, fromName: givenName, untrusted = false, remote, attachments, origin: givenOrigin }) { ++ // from; the reply goes back to that Session (turns.js). `answeredIn`: the group where the user ++ // answered the Bot's question card about work handed over from there (tools.js, #550). ++ async function deliver({ fromId, toId, sessionId, text, role, hop = 0, replyTo, roomId, audience, note, exchangeId = randomUUID(), steer = false, deferred = false, resumed = false, retried = false, resent = false, askedBy, fromRoomId, answeredIn, fromName: givenName, untrusted = false, remote, attachments, origin: givenOrigin }) { const from = rt.botOf(fromId) const to = rt.botOf(toId) if (to === undefined) throw new Error(`Unknown Bot ${toId}`) @@ -879,7 +897,7 @@ diff --git a/src/host/delivery.js b/src/host/delivery.js + 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, body) : body}` }], ++ content: [{ type: 'text', text: `${headerFor(role, fromName, room?.name, audience, note, deferred, { resumed, retried, answeredIn })}\n${untrusted ? fenceUntrusted(fromName, body) : body}` }], source, }) if (steer) agent.steer(message) diff --git a/patches/ds-bot/0007-version-and-tooling.patch b/patches/ds-bot/0007-version-and-tooling.patch index f26c9c86..6c989112 100644 --- a/patches/ds-bot/0007-version-and-tooling.patch +++ b/patches/ds-bot/0007-version-and-tooling.patch @@ -148,6 +148,27 @@ diff --git a/test/diagnostics.test.js b/test/diagnostics.test.js assert.match(report.markdown, /\| Surface \| Web \|/) assert.match(report.markdown, /\| Models \| deepseek\/deepseek-chat \|/) assert.match(report.markdown, /WARN host \[EACCES\] state write failed/) +@@ -72,9 +72,17 @@ test('warnings are kept, cleaned, written to the log, and read back after a rest + assert.doesNotMatch(report.markdown, /sk-abc|\/home\//) + assert.equal(report.entries[0].code, 'RATE_LIMIT', 'newest first') + +- // Written as JSON lines; a new host reads them back. +- await new Promise(resolve => setTimeout(resolve, 50)) +- assert.equal(readFileSync(join(home, 'logs', 'diagnostics.jsonl'), 'utf8').trim().split('\n').length, 2) ++ // Written as JSON lines in the background; a new host reads them back. Wait for the ++ // lines rather than a fixed pause, which a busy CI disk can outlast (#551 queue run). ++ const logLines = () => { ++ try { ++ return readFileSync(join(home, 'logs', 'diagnostics.jsonl'), 'utf8').trim().split('\n').length ++ } catch { ++ return 0 // not written yet ++ } ++ } ++ for (let waited = 0; logLines() < 2 && waited < 5000; waited += 20) await new Promise(resolve => setTimeout(resolve, 20)) ++ assert.equal(logLines(), 2) + const again = host(home) + assert.deepEqual((await again.diagnostics.entries()).map(entry => entry.code), ['EACCES', 'RATE_LIMIT']) + assert.match(await again.diagnostics.logText(), /\n {4}at TestContext/) @@ -108,6 +108,23 @@ test('warnings send only cleaned text to the logger and stderr', (t) => { } }) diff --git a/patches/ds-bot/files/src/host/authz.js b/patches/ds-bot/files/src/host/authz.js index 890c45cf..13e65372 100644 --- a/patches/ds-bot/files/src/host/authz.js +++ b/patches/ds-bot/files/src/host/authz.js @@ -134,6 +134,11 @@ export function install(rt) { return false } + // A card a Bot asks in a group about work handed over from there (#550) is its owner's to see + // and answer, like that Bot's approvals; the room's owner may too. Other members get no card. + const questionFor = (actor, sessionId, question) => question?.botId === undefined + || botOwnedBy(actor, question.botId) || roomOwnedBy(actor, sessionId) + rt.botOwnedBy = botOwnedBy rt.roomOwnedBy = roomOwnedBy rt.roomMemberOf = roomMemberOf @@ -162,7 +167,7 @@ export function install(rt) { bots: full.bots.filter(bot => bot.ownerId === actor.memberId || sharedBots.has(bot.id)), rooms, self: { memberId: actor.memberId, displayName: actor.displayName }, - questions: bySession(full.questions), + questions: Object.fromEntries(Object.entries(bySession(full.questions)).filter(([id, question]) => questionFor(actor, id, question))), exchangeTurns: bySession(full.exchangeTurns), remoteTypedTurns: bySession(full.remoteTypedTurns), remoteAwaiting: bySession(full.remoteAwaiting), @@ -347,6 +352,9 @@ export function install(rt) { if (String(body?.choice ?? '').startsWith('always')) return botOwnedBy(actor, approval.botId) ? ALLOW : deny('bot/forbidden', 'Only the Bot\'s owner can always allow') return botOwnedBy(actor, approval.botId) || roomOwnedBy(actor, approval.roomId) ? ALLOW : deny('bot/forbidden', 'You do not own that approval') } + if ((ep === 'answer-question' || ep === 'dismiss-question') && !questionFor(actor, String(body?.sessionId ?? ''), state.questions?.[String(body?.sessionId ?? '')])) { + return deny('bot/forbidden', 'This question is not yours to answer') + } return requireSession(body?.sessionId) } diff --git a/patches/ds-bot/files/test/authz.test.js b/patches/ds-bot/files/test/authz.test.js index 31277028..5d497f1c 100644 --- a/patches/ds-bot/files/test/authz.test.js +++ b/patches/ds-bot/files/test/authz.test.js @@ -382,3 +382,30 @@ test('views keep the sole owner whole and narrow per owner with two members', as assert.deepEqual(live2.connectors, []) assert.equal(live2.memoryReviews, 0) }) + +test('a group\'s question card is its Bot owner\'s to see and answer, not every member\'s (#550)', async (t) => { + const { rt } = runtime(t, TEAM) + await rt.load() + const sessionA = await redeemOwner(rt) + const sessionB = await redeemOwner(rt) + rt.state.bots.a1 = { id: 'a1', sessionId: 'a1', name: 'A', instructions: '', createdAt: 2, ownerId: 'owner-1' } + rt.state.bots.b1 = { id: 'b1', sessionId: 'b1', name: 'B', instructions: '', createdAt: 3, ownerId: 'owner-2' } + rt.state.rooms.rx = { + id: 'rx', name: 'Shared', members: ['a1', 'b1'], admin: 'a1', sessions: { a1: 'gs-a1', b1: 'gs-b1' }, + participants: [ + { kind: 'human', id: 'owner-1', role: 'admin', joinedSeq: 0 }, + { kind: 'human', id: 'owner-2', role: 'member', joinedSeq: 0 }, + ], + } + rt.state.questions.rx = { id: 'q1', question: 'Which branch?', options: [{ label: 'dev' }], botId: 'a1' } + await rt.save() + const actorA = await rt.authenticate(sessionA.sessionToken) + const actorB = await rt.authenticate(sessionB.sessionToken) + assert.equal(rt.authorize(actorB, 'send', { sessionId: 'rx' }).ok, true) + assert.equal(rt.authorize(actorA, 'answer-question', { sessionId: 'rx' }).ok, true) + assert.equal(rt.authorize(actorB, 'answer-question', { sessionId: 'rx' }).ok, false) + assert.equal(rt.authorize(actorB, 'dismiss-question', { sessionId: 'rx' }).ok, false) + const full = { bots: Object.values(rt.state.bots), rooms: [], questions: { rx: rt.state.questions.rx } } + assert.deepEqual(Object.keys(rt.ownedView(full, actorA).questions), ['rx']) + assert.deepEqual(Object.keys(rt.ownedView(full, actorB).questions), []) +}) diff --git a/patches/ds-bot/topics.json b/patches/ds-bot/topics.json index 84cd041a..2f3dadb7 100644 --- a/patches/ds-bot/topics.json +++ b/patches/ds-bot/topics.json @@ -39,7 +39,8 @@ "src/client/member-status.js", "src/host/approvals.js", "test/group-status.test.js", - "test/member-status.test.js" + "test/member-status.test.js", + "test/group-work.test.js" ], "0002-flybot-api-host": [ "index.js",