// Bot 工作组一期(docs/design/13-bot-teams.md §13):Bot Key 与鉴权隔离、组成规则、唤醒规则、顺序执行与合并、审批、生命周期 import fs from 'node:fs' import path from 'node:path' import pino from 'pino' import { afterAll, beforeAll, describe, expect, it } from 'vitest' import type { ProviderStatusDto, TeamDetailDto, TeamEvent, TeamMessage } from '@agent-server/shared' import { createApp, type App } from '../src/app' import { buildServer } from '../src/http/server' import type { AgentProvider, RunContext } from '../src/providers/types' /** 假引擎:回复"收到:<输入最后一行>";输入含"审批"时请求审批;含"慢"时等待 release();把产出写到 outputs/ */ class FakeProvider implements AgentProvider { readonly id = 'claude' as const runs: RunContext[] = [] private gates: (() => void)[] = [] release() { for (const g of this.gates.splice(0)) g() } async run(ctx: RunContext) { this.runs.push(ctx) ctx.setProviderSessionId(`fake-${ctx.sessionId}`) if (ctx.input.text.includes('慢')) { await new Promise((resolve, reject) => { this.gates.push(resolve) ctx.signal.addEventListener('abort', () => reject(new Error('aborted'))) }) } if (ctx.input.text.includes('审批')) { const outcome = await ctx.interact({ kind: 'approval', tool: 'Bash', title: 'echo', input: {}, options: ['allow_once', 'deny'] }) if (outcome.resolution !== 'allowed') throw new Error('denied') fs.mkdirSync(path.join(ctx.workspaceDir, 'outputs'), { recursive: true }) fs.writeFileSync(path.join(ctx.workspaceDir, `outputs/${ctx.sessionId}.md`), '# 报告') ctx.emit('file.changed', { fileId: 'x', path: `outputs/${ctx.sessionId}.md`, kind: 'created', size: 8, mime: 'text/markdown' }) } const last = ctx.input.text.trim().split('\n').filter(Boolean).at(-1) ?? '' ctx.emit('message.completed', { messageId: `m${this.runs.length}`, text: `收到:${last}` }) return { usage: { inputTokens: 1, outputTokens: 1, cacheReadTokens: 0, cacheWriteTokens: 0, costUsd: 0.001 } } } async status(): Promise { return { id: 'claude', enabled: true, available: true, loggedIn: true, account: 'test', cliPath: '', version: 'x', models: ['m'], defaultModel: 'm', message: null } } } let app: App let server: Awaited> const fake = new FakeProvider() let adminCookie: Record let botKey: string let apiKey: string const ids: Record = {} const bk = () => ({ authorization: `Bearer ${botKey}` }) const human = { id: 'u1', name: '张三' } async function waitFor(fn: () => T | undefined | false, ms = 3000): Promise { const end = Date.now() + ms while (Date.now() < end) { const v = fn() if (v) return v await new Promise(r => setTimeout(r, 20)) } throw new Error('timeout') } const messages = (teamId: string) => app.teams.events.list(teamId, 0, 5000, ['message.created']).map(e => (e as TeamEvent & { type: 'message.created' }).payload as TeamMessage) const botReplies = (teamId: string, botId: string) => messages(teamId).filter(m => m.sender.kind === 'bot' && m.sender.botId === botId) const idle = (teamId: string) => app.teams.toDto(app.teams.getRow(teamId)).bots.every(b => b.status === 'idle') async function createTeam(body: object, headers = bk()) { const res = await server.inject({ method: 'POST', url: '/bot/v1/teams', headers, payload: { human, ...body } }) return res } beforeAll(async () => { const log = pino({ level: 'silent' }) app = createApp(log, { providers: { claude: fake } }) server = await buildServer(app, { logger: log }) const login = await server.inject({ method: 'POST', url: '/admin/v1/auth/login', payload: { password: 'test-password' } }) const c = login.cookies[0]! adminCookie = { [c.name]: c.value } const profileId = app.profiles.getBuiltin().id for (const [name, kind] of [['数据员', 'worker'], ['文档员', 'worker'], ['组长', 'lead'], ['外部员', 'worker']] as const) { const res = await server.inject({ method: 'POST', url: '/admin/v1/bots', cookies: adminCookie, payload: { name, avatar: kind === 'lead' ? 'kl:brain:purple' : '🤖', kind, description: `${name}的岗位`, profileId, enabled: true }, }) expect(res.statusCode).toBe(201) ids[name] = res.json().id } const key = await server.inject({ method: 'POST', url: '/admin/v1/bot-keys', cookies: adminCookie, payload: { name: '平台A', allowedBotIds: [ids['数据员'], ids['文档员'], ids['组长']] } }) expect(key.statusCode).toBe(201) botKey = key.json().key expect(botKey).toMatch(/^bk_/) apiKey = app.apiKeys.create('agent', { allowedProfileIds: [profileId], defaultProfileId: profileId }).key }) afterAll(() => server.close()) describe('Bot Key 与 API Key 互不通用(ADR-0024)', () => { it('Bot Key 不能访问 /v1,API Key 不能访问 /bot/v1', async () => { expect((await server.inject({ method: 'GET', url: '/v1/sessions', headers: bk() })).statusCode).toBe(401) expect((await server.inject({ method: 'GET', url: '/bot/v1/teams', headers: { authorization: `Bearer ${apiKey}` } })).statusCode).toBe(401) expect((await server.inject({ method: 'GET', url: '/bot/v1/teams', headers: bk() })).statusCode).toBe(200) }) it('两种浏览器令牌不能互换', async () => { const botToken = (await server.inject({ method: 'POST', url: '/bot/v1/browser-tokens', headers: bk(), payload: {} })).json().token as string const agentToken = (await server.inject({ method: 'POST', url: '/v1/browser-tokens', headers: { authorization: `Bearer ${apiKey}` }, payload: {} })).json().token as string expect((await server.inject({ method: 'GET', url: '/bot/v1/bots', headers: { authorization: `Bearer ${botToken}` } })).statusCode).toBe(200) expect((await server.inject({ method: 'GET', url: '/v1/sessions', headers: { authorization: `Bearer ${botToken}` } })).statusCode).toBe(401) expect((await server.inject({ method: 'GET', url: '/bot/v1/bots', headers: { authorization: `Bearer ${agentToken}` } })).statusCode).toBe(401) // 浏览器令牌不能再签发令牌 expect((await server.inject({ method: 'POST', url: '/bot/v1/browser-tokens', headers: { authorization: `Bearer ${botToken}` }, payload: {} })).statusCode).toBe(403) }) it('只列出 Key 允许的机器人;后台 Cookie 可以访问 Bot API', async () => { const names = (await server.inject({ method: 'GET', url: '/bot/v1/bots', headers: bk() })).json().map((b: { name: string }) => b.name) expect(names.sort()).toEqual(['数据员', '文档员', '组长'].sort()) const all = (await server.inject({ method: 'GET', url: '/bot/v1/bots', cookies: adminCookie })).json() expect(all).toHaveLength(4) }) }) describe('组成规则', () => { it('至少一个机器人;组长和组员类型要对;Key 不允许的机器人返回 403', async () => { expect((await createTeam({})).statusCode).toBe(400) expect((await createTeam({ leadBotId: ids['数据员'] })).statusCode).toBe(400) expect((await createTeam({ workerBotIds: [ids['组长']] })).statusCode).toBe(400) expect((await createTeam({ workerBotIds: [ids['外部员']] })).statusCode).toBe(403) }) it('不能移除最后一个机器人,不能有两个组长', async () => { const t = (await createTeam({ leadBotId: ids['组长'] })).json() as TeamDetailDto expect((await server.inject({ method: 'DELETE', url: `/bot/v1/teams/${t.id}/bots/${ids['组长']}`, headers: bk() })).statusCode).toBe(400) const added = await server.inject({ method: 'POST', url: `/bot/v1/teams/${t.id}/bots`, headers: bk(), payload: { botId: ids['数据员'] } }) expect(added.statusCode).toBe(200) expect(added.json().bots.map((b: { role: string }) => b.role)).toEqual(['lead', 'worker']) }) it('其他 Key 看不到这个工作组', async () => { const t = (await createTeam({ workerBotIds: [ids['数据员']] })).json() as TeamDetailDto const other = await server.inject({ method: 'POST', url: '/admin/v1/bot-keys', cookies: adminCookie, payload: { name: '平台B', allowedBotIds: [ids['数据员']] } }) const res = await server.inject({ method: 'GET', url: `/bot/v1/teams/${t.id}`, headers: { authorization: `Bearer ${other.json().key}` } }) expect(res.statusCode).toBe(404) }) }) describe('消息与执行', () => { let team: TeamDetailDto beforeAll(async () => { team = (await createTeam({ title: '测试组', workerBotIds: [ids['数据员'], ids['文档员']] })).json() }) it('多个组员且没有组长时必须 @', async () => { const res = await server.inject({ method: 'POST', url: `/bot/v1/teams/${team.id}/messages`, headers: bk(), payload: { text: '大家好' } }) expect(res.statusCode).toBe(400) expect(res.json().error.code).toBe('mention_required') }) it('@ 组员:在共享目录中执行,回复发到工作组;只唤醒被 @ 的机器人', async () => { const before = fake.runs.length const res = await server.inject({ method: 'POST', url: `/bot/v1/teams/${team.id}/messages`, headers: bk(), payload: { text: '@数据员 统计一下', mentions: [ids['数据员']] } }) expect(res.statusCode).toBe(201) const reply = await waitFor(() => botReplies(team.id, ids['数据员']!)[0]) expect(reply.text).toContain('统计一下') expect(fake.runs.length).toBe(before + 1) const ctx = fake.runs.at(-1)! expect(ctx.workspaceDir).toBe(app.teams.getRow(team.id).workspaceDir) expect(ctx.systemPromptAppend).toContain('工作组「测试组」中的机器人「数据员」') expect(ctx.input.text).toContain('【工作组新消息】') expect(botReplies(team.id, ids['文档员']!)).toHaveLength(0) await waitFor(() => idle(team.id)) }) it('再次 @ 同一个组员时续接同一个会话,只交给它新消息', async () => { const first = app.teams.toDto(app.teams.getRow(team.id)).bots.find(b => b.id === ids['数据员'])!.sessionId await server.inject({ method: 'POST', url: `/bot/v1/teams/${team.id}/messages`, headers: bk(), payload: { text: '再按月份分一下', mentions: [ids['数据员']] } }) await waitFor(() => botReplies(team.id, ids['数据员']!).length === 2) const ctx = fake.runs.at(-1)! expect(ctx.sessionId).toBe(first) expect(ctx.input.text).toContain('再按月份分一下') expect(ctx.input.text).not.toContain('统计一下') await waitFor(() => idle(team.id)) }) it('执行期间收到的消息在本轮结束后合并成下一轮', async () => { const send = (text: string) => server.inject({ method: 'POST', url: `/bot/v1/teams/${team.id}/messages`, headers: bk(), payload: { text, mentions: [ids['文档员']] } }) const before = fake.runs.length await send('慢一点写') await waitFor(() => app.teams.toDto(app.teams.getRow(team.id)).bots.find(b => b.id === ids['文档员'])!.status === 'working') await send('补充一') await send('补充二') expect(fake.runs.length).toBe(before + 1) fake.release() await waitFor(() => botReplies(team.id, ids['文档员']!).length === 2) expect(fake.runs.length).toBe(before + 2) const merged = fake.runs.at(-1)!.input.text expect(merged).toContain('补充一') expect(merged).toContain('补充二') expect(merged).not.toContain('慢一点写') await waitFor(() => idle(team.id)) }) it('审批卡片显示在工作组中,由成员处理;交付文件出现在回复中', async () => { await server.inject({ method: 'POST', url: `/bot/v1/teams/${team.id}/messages`, headers: bk(), payload: { text: '这个需要审批', mentions: [ids['文档员']] } }) const pending = await waitFor(() => app.teams.pendingInteractions(team.id)[0]) expect(pending.botId).toBe(ids['文档员']) const detail = (await server.inject({ method: 'GET', url: `/bot/v1/teams/${team.id}`, headers: bk() })).json() as TeamDetailDto expect(detail.pendingInteractions).toHaveLength(1) expect(detail.bots.find(b => b.id === ids['文档员'])!.status).toBe('waiting_input') const events = (await server.inject({ method: 'GET', url: `/bot/v1/teams/${team.id}/events`, headers: bk() })).json().items as TeamEvent[] expect(events.some(e => e.type === 'interaction.required')).toBe(true) const res = await server.inject({ method: 'POST', url: `/bot/v1/teams/${team.id}/interactions/${pending.request.interactionId}`, headers: bk(), payload: { decision: 'allow_once' } }) expect(res.statusCode).toBe(200) const reply = await waitFor(() => botReplies(team.id, ids['文档员']!).find(m => m.files.length)) expect(reply.files[0]!.path).toMatch(/^outputs\/.*\.md$/) const file = await server.inject({ method: 'GET', url: `/bot/v1/files/${reply.files[0]!.fileId}`, headers: bk() }) expect(file.statusCode).toBe(200) expect(file.body).toBe('# 报告') await waitFor(() => idle(team.id)) }) it('工作组中的会话不能经 /v1 直接发消息或删除', async () => { const sid = app.teams.toDto(app.teams.getRow(team.id)).bots[0]!.sessionId! expect((await server.inject({ method: 'POST', url: `/v1/sessions/${sid}/messages`, cookies: adminCookie, payload: { text: 'x' } })).statusCode).toBe(400) expect((await server.inject({ method: 'DELETE', url: `/v1/sessions/${sid}`, cookies: adminCookie })).statusCode).toBe(400) }) it('上传文件作为附件交给机器人', async () => { const boundary = 'xBx' const up = await server.inject({ method: 'POST', url: `/bot/v1/teams/${team.id}/files`, headers: { ...bk(), 'content-type': `multipart/form-data; boundary=${boundary}` }, payload: `--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="a.csv"\r\nContent-Type: text/csv\r\n\r\na,b\n\r\n--${boundary}--\r\n`, }) expect(up.statusCode).toBe(201) const fileId = up.json()[0].id const replies = botReplies(team.id, ids['数据员']!).length await server.inject({ method: 'POST', url: `/bot/v1/teams/${team.id}/messages`, headers: bk(), payload: { text: '看看附件', mentions: [ids['数据员']], fileIds: [fileId] } }) await waitFor(() => fake.runs.at(-1)?.input.attachments.some(a => a.path === 'uploads/a.csv')) expect(fake.runs.at(-1)!.input.text).toContain('附件:uploads/a.csv') await waitFor(() => idle(team.id)) // 文档员之前产生的 outputs/ 文件不能算到数据员的这一轮上 await waitFor(() => botReplies(team.id, ids['数据员']!).length === replies + 1) expect(botReplies(team.id, ids['数据员']!).at(-1)!.files).toEqual([]) }) }) describe('默认处理人与生命周期', () => { it('只有一个组员时不 @ 也交给它;有组长时交给组长', async () => { const solo = (await createTeam({ workerBotIds: [ids['数据员']] })).json() as TeamDetailDto await server.inject({ method: 'POST', url: `/bot/v1/teams/${solo.id}/messages`, headers: bk(), payload: { text: '你好' } }) await waitFor(() => botReplies(solo.id, ids['数据员']!)[0]) const led = (await createTeam({ leadBotId: ids['组长'], workerBotIds: [ids['数据员']] })).json() as TeamDetailDto await server.inject({ method: 'POST', url: `/bot/v1/teams/${led.id}/messages`, headers: bk(), payload: { text: '安排一下' } }) await waitFor(() => botReplies(led.id, ids['组长']!)[0]) expect(botReplies(led.id, ids['数据员']!)).toHaveLength(0) await waitFor(() => idle(led.id)) }) it('暂停时消息只记录,恢复后处理;关闭后不能发消息;删除清理目录与会话', async () => { const t = (await createTeam({ workerBotIds: [ids['数据员']] })).json() as TeamDetailDto expect((await server.inject({ method: 'POST', url: `/bot/v1/teams/${t.id}/pause`, headers: bk() })).json().status).toBe('paused') await server.inject({ method: 'POST', url: `/bot/v1/teams/${t.id}/messages`, headers: bk(), payload: { text: '暂停期间的消息' } }) await new Promise(r => setTimeout(r, 100)) expect(botReplies(t.id, ids['数据员']!)).toHaveLength(0) await server.inject({ method: 'POST', url: `/bot/v1/teams/${t.id}/resume`, headers: bk() }) const reply = await waitFor(() => botReplies(t.id, ids['数据员']!)[0]) expect(reply.text).toContain('暂停期间的消息') await waitFor(() => idle(t.id)) await server.inject({ method: 'POST', url: `/bot/v1/teams/${t.id}/close`, headers: bk() }) expect((await server.inject({ method: 'POST', url: `/bot/v1/teams/${t.id}/messages`, headers: bk(), payload: { text: 'x' } })).statusCode).toBe(409) const dir = app.teams.getRow(t.id).workspaceDir const sid = app.teams.toDto(app.teams.getRow(t.id)).bots[0]!.sessionId! expect((await server.inject({ method: 'DELETE', url: `/bot/v1/teams/${t.id}`, headers: bk() })).statusCode).toBe(204) expect(fs.existsSync(dir)).toBe(false) expect(() => app.sessions.getRow(sid)).toThrow() expect((await server.inject({ method: 'GET', url: `/bot/v1/teams/${t.id}`, headers: bk() })).statusCode).toBe(404) }) it('头像只接受图标格式或一个 emoji', async () => { const profileId = app.profiles.getBuiltin().id const bad = await server.inject({ method: 'POST', url: '/admin/v1/bots', cookies: adminCookie, payload: { name: '坏头像', avatar: 'icon:Bad Name!', kind: 'worker', description: '', profileId, enabled: true }, }) expect(bad.statusCode).toBe(400) expect(app.bots.get(ids['组长']!).avatar).toBe('kl:brain:purple') }) it('还在工作组中的机器人不能删除;用量记到 Bot Key 上', async () => { expect((await server.inject({ method: 'DELETE', url: `/admin/v1/bots/${ids['数据员']}`, cookies: adminCookie })).statusCode).toBe(400) const usage = app.usage.summary({ days: 1 }) expect(usage.bySource.some(s => s.name === '平台A' && s.runs > 0)).toBe(true) }) })