api.test.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301
  1. import fs from 'node:fs'
  2. import path from 'node:path'
  3. import pino from 'pino'
  4. import { afterAll, beforeAll, describe, expect, it } from 'vitest'
  5. import type { AgentEvent, ProviderStatusDto } from '@agent-server/shared'
  6. import { createApp, type App } from '../src/app'
  7. import { buildServer } from '../src/http/server'
  8. import type { AgentProvider, RunContext } from '../src/providers/types'
  9. /** 假引擎:输出文本 → 请求审批 → 写产出文件 */
  10. class FakeProvider implements AgentProvider {
  11. readonly id = 'claude' as const
  12. lastCtx: RunContext | null = null
  13. async run(ctx: RunContext) {
  14. this.lastCtx = ctx
  15. ctx.setProviderSessionId('fake-session')
  16. ctx.emit('message.delta', { messageId: 'm1', text: '你好' })
  17. ctx.emit('message.completed', { messageId: 'm1', text: '你好' })
  18. if (ctx.input.text.includes('审批')) {
  19. ctx.emit('tool.started', { toolCallId: 't1', name: 'Bash', title: 'echo', input: { command: 'echo' } })
  20. const outcome = await ctx.interact({ kind: 'approval', tool: 'Bash', title: 'echo', input: {}, options: ['allow_once', 'allow_session', 'deny'] })
  21. ctx.emit('tool.completed', { toolCallId: 't1', status: outcome.resolution === 'allowed' ? 'ok' : 'denied', output: '', truncated: false })
  22. }
  23. if (ctx.input.text.includes('等待')) await new Promise((_, reject) => ctx.signal.addEventListener('abort', () => reject(new Error('aborted'))))
  24. fs.writeFileSync(path.join(ctx.workspaceDir, 'outputs/report.md'), '# 报告')
  25. return { usage: { inputTokens: 1, outputTokens: 2, cacheReadTokens: 0, cacheWriteTokens: 0, costUsd: 0.001 } }
  26. }
  27. async status(): Promise<ProviderStatusDto> {
  28. return { id: 'claude', enabled: true, available: true, loggedIn: true, account: 'test', cliPath: '', version: 'x', models: ['m'], defaultModel: 'm', message: null }
  29. }
  30. }
  31. let app: App
  32. let server: Awaited<ReturnType<typeof buildServer>>
  33. let apiKey: string
  34. const fake = new FakeProvider()
  35. const auth = () => ({ authorization: `Bearer ${apiKey}` })
  36. async function waitFor<T>(fn: () => T | undefined, ms = 3000): Promise<T> {
  37. const end = Date.now() + ms
  38. while (Date.now() < end) {
  39. const v = fn()
  40. if (v) return v
  41. await new Promise(r => setTimeout(r, 20))
  42. }
  43. throw new Error('timeout')
  44. }
  45. const events = async (sid: string) =>
  46. (await server.inject({ method: 'GET', url: `/v1/sessions/${sid}/events`, headers: auth() })).json().items as AgentEvent[]
  47. beforeAll(async () => {
  48. const log = pino({ level: 'silent' })
  49. app = createApp(log, { providers: { claude: fake } })
  50. server = await buildServer(app, { logger: log })
  51. const builtin = app.profiles.getBuiltin().id
  52. apiKey = app.apiKeys.create('test', { allowedProfileIds: [builtin], defaultProfileId: builtin }).key
  53. })
  54. afterAll(() => server.close())
  55. describe('鉴权', () => {
  56. it('没有 Key 返回 401', async () => {
  57. expect((await server.inject({ method: 'GET', url: '/v1/sessions' })).statusCode).toBe(401)
  58. })
  59. it('后台登录后可以访问管理接口', async () => {
  60. const login = await server.inject({ method: 'POST', url: '/admin/v1/auth/login', payload: { password: 'test-password' } })
  61. expect(login.statusCode).toBe(200)
  62. const cookie = login.cookies[0]!
  63. const res = await server.inject({ method: 'GET', url: '/admin/v1/settings', cookies: { [cookie.name]: cookie.value } })
  64. expect(res.statusCode).toBe(200)
  65. expect(res.json().settings.paths.workspaceRoot).toContain('workspaces')
  66. })
  67. it('错误密码返回 401', async () => {
  68. expect((await server.inject({ method: 'POST', url: '/admin/v1/auth/login', payload: { password: 'nope' } })).statusCode).toBe(401)
  69. })
  70. })
  71. describe('会话与 Run', () => {
  72. it('完整流程:上传 → 发送 → 审批 → 产出文件', async () => {
  73. const created = await server.inject({ method: 'POST', url: '/v1/sessions', headers: auth(), payload: {} })
  74. expect(created.statusCode).toBe(201)
  75. const sid = created.json().id as string
  76. const boundary = 'xBOUNDARYx'
  77. const upload = await server.inject({
  78. method: 'POST', url: `/v1/sessions/${sid}/files`,
  79. headers: { ...auth(), 'content-type': `multipart/form-data; boundary=${boundary}` },
  80. payload: `--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="销售.csv"\r\nContent-Type: text/csv\r\n\r\na,b\n1,2\n\r\n--${boundary}--\r\n`,
  81. })
  82. expect(upload.statusCode).toBe(201)
  83. const fileId = upload.json()[0].id as string
  84. expect(upload.json()[0].path).toBe('uploads/销售.csv')
  85. const sent = await server.inject({ method: 'POST', url: `/v1/sessions/${sid}/messages`, headers: auth(), payload: { text: '请审批', attachments: [fileId] } })
  86. expect(sent.statusCode).toBe(202)
  87. const runId = sent.json().id as string
  88. // 会话忙时不能再发
  89. const busy = await server.inject({ method: 'POST', url: `/v1/sessions/${sid}/messages`, headers: auth(), payload: { text: 'x' } })
  90. expect(busy.statusCode).toBe(409)
  91. const itx = await waitFor(() => app.interactions.pendingFor(sid)[0])
  92. expect(fake.lastCtx?.input.attachments[0]?.path).toBe('uploads/销售.csv')
  93. const detail = await server.inject({ method: 'GET', url: `/v1/sessions/${sid}`, headers: auth() })
  94. expect(detail.json().status).toBe('waiting_input')
  95. const reply = await server.inject({ method: 'POST', url: `/v1/interactions/${itx.interactionId}`, headers: auth(), payload: { decision: 'allow_once' } })
  96. expect(reply.statusCode).toBe(200)
  97. const again = await server.inject({ method: 'POST', url: `/v1/interactions/${itx.interactionId}`, headers: auth(), payload: { decision: 'allow_once' } })
  98. expect(again.statusCode).toBe(409)
  99. await waitFor(() => app.runs.activeForSession(sid) === undefined || undefined)
  100. const run = (await server.inject({ method: 'GET', url: `/v1/runs/${runId}`, headers: auth() })).json()
  101. expect(run.status).toBe('completed')
  102. // 每个 Run 签发网关令牌,结束后作废
  103. expect(fake.lastCtx?.gateway.token).toMatch(/^rt_/)
  104. expect(app.gatewayTokens.get(fake.lastCtx!.gateway.token)).toBeNull()
  105. expect(run.usage.outputTokens).toBe(2)
  106. const types = (await events(sid)).map(e => e.type)
  107. expect(types).toEqual(expect.arrayContaining([
  108. 'user.message', 'run.queued', 'run.started', 'message.completed', 'tool.started',
  109. 'interaction.required', 'interaction.resolved', 'tool.completed', 'file.changed', 'usage', 'run.completed',
  110. ]))
  111. // 内存缓冲中包含瞬时的 delta 事件
  112. expect(types).toContain('message.delta')
  113. const files = (await server.inject({ method: 'GET', url: `/v1/sessions/${sid}/files`, headers: auth() })).json()
  114. const report = files.find((f: { path: string }) => f.path === 'outputs/report.md')
  115. expect(report.origin).toBe('agent')
  116. const dl = await server.inject({ method: 'GET', url: `/v1/files/${report.id}`, headers: auth() })
  117. expect(dl.body).toBe('# 报告')
  118. const session = (await server.inject({ method: 'GET', url: `/v1/sessions/${sid}`, headers: auth() })).json()
  119. expect(session.title).toBe('请审批')
  120. })
  121. it('中断运行中的 Run', async () => {
  122. const sid = (await server.inject({ method: 'POST', url: '/v1/sessions', headers: auth(), payload: {} })).json().id
  123. const runId = (await server.inject({ method: 'POST', url: `/v1/sessions/${sid}/messages`, headers: auth(), payload: { text: '等待' } })).json().id
  124. await waitFor(() => app.runs.activeForSession(sid)?.status === 'running' || undefined)
  125. expect((await server.inject({ method: 'POST', url: `/v1/runs/${runId}/interrupt`, headers: auth() })).statusCode).toBe(202)
  126. await waitFor(() => app.runs.activeForSession(sid) === undefined || undefined)
  127. expect(app.runs.get(runId).status).toBe('interrupted')
  128. expect((await events(sid)).at(-1)?.type).toBe('run.interrupted')
  129. })
  130. it('其他 API Key 看不到这个会话', async () => {
  131. const sid = (await server.inject({ method: 'POST', url: '/v1/sessions', headers: auth(), payload: {} })).json().id
  132. const b = app.profiles.getBuiltin().id
  133. const otherKey = app.apiKeys.create('other', { allowedProfileIds: [b], defaultProfileId: b }).key
  134. const res = await server.inject({ method: 'GET', url: `/v1/sessions/${sid}`, headers: { authorization: `Bearer ${otherKey}` } })
  135. expect(res.statusCode).toBe(404)
  136. })
  137. it('删除会话同时删除工作区', async () => {
  138. const created = await app.sessions.create(app.apiKeys.list()[0]!.id, {})
  139. expect(fs.existsSync(created.workspaceDir)).toBe(true)
  140. await app.sessions.delete(created.id, 'admin')
  141. expect(fs.existsSync(created.workspaceDir)).toBe(false)
  142. })
  143. })
  144. describe('资料库', () => {
  145. it('拒绝不安全的路径', () => {
  146. expect(() => app.knowledge.create({ name: 'home', path: '~' })).toThrow(/全局禁止项/)
  147. expect(() => app.knowledge.create({ name: 'none', path: '/does/not/exist' })).toThrow(/不存在/)
  148. })
  149. })
  150. describe('API Key 可用的 Profile', () => {
  151. const profileInput = (name: string, permissionMode: 'ask' | 'auto' | 'readonly' = 'ask') => ({
  152. name, description: '', provider: 'claude' as const, model: 'haiku', systemPrompt: '', permissionMode,
  153. allowedTools: [], disallowedTools: [], skills: 'all' as const, fileAccess: { mode: 'restricted' as const, read: [], write: [], deny: [] },
  154. knowledgeRootIds: [], isDefault: false,
  155. })
  156. it('平台只能看到并使用配置给它的 Profile', async () => {
  157. const builtin = app.profiles.getBuiltin()
  158. const finance = app.profiles.create(profileInput('财务'))
  159. const secret = app.profiles.create(profileInput('其他'))
  160. const { key } = app.apiKeys.create('平台A', { allowedProfileIds: [builtin.id, finance.id], defaultProfileId: finance.id })
  161. const h = { authorization: `Bearer ${key}` }
  162. const list = (await server.inject({ method: 'GET', url: '/v1/profiles', headers: h })).json() as { id: string; isDefault: boolean }[]
  163. expect(list.map(p => p.id).sort()).toEqual([builtin.id, finance.id].sort())
  164. expect(list.find(p => p.isDefault)?.id).toBe(finance.id)
  165. // 不指定时用平台的默认 Profile,并使用其默认模型
  166. const s1 = (await server.inject({ method: 'POST', url: '/v1/sessions', headers: h, payload: {} })).json()
  167. expect(s1.profileId).toBe(finance.id)
  168. expect(s1.model).toBe('haiku')
  169. const denied = await server.inject({ method: 'POST', url: '/v1/sessions', headers: h, payload: { profileId: secret.id } })
  170. expect(denied.statusCode).toBe(403)
  171. expect(denied.json().error.code).toBe('profile_not_allowed')
  172. // 可以自选模型,但必须是可用模型
  173. expect((await server.inject({ method: 'POST', url: '/v1/sessions', headers: h, payload: { model: 'opus' } })).statusCode).toBe(201)
  174. expect((await server.inject({ method: 'POST', url: '/v1/sessions', headers: h, payload: { model: 'gpt-x' } })).statusCode).toBe(400)
  175. })
  176. it('权限模式由 Profile 决定:平台不能指定,修改 Profile 后立即生效', async () => {
  177. const p = app.profiles.create(profileInput('只读助手', 'readonly'))
  178. const { key } = app.apiKeys.create('平台B', { allowedProfileIds: [p.id], defaultProfileId: p.id })
  179. const h = { authorization: `Bearer ${key}` }
  180. expect((await server.inject({ method: 'POST', url: '/v1/sessions', headers: h, payload: { permissionMode: 'auto' } })).statusCode).toBe(400)
  181. const sid = (await server.inject({ method: 'POST', url: '/v1/sessions', headers: h, payload: {} })).json().id
  182. expect((await server.inject({ method: 'PATCH', url: `/v1/sessions/${sid}`, headers: h, payload: { permissionMode: 'auto' } })).statusCode).toBe(400)
  183. expect((await server.inject({ method: 'GET', url: `/v1/sessions/${sid}`, headers: h })).json().permissionMode).toBe('readonly')
  184. app.profiles.update(p.id, { ...profileInput('只读助手', 'ask') })
  185. const detail = (await server.inject({ method: 'GET', url: `/v1/sessions/${sid}`, headers: h })).json()
  186. expect(detail.permissionMode).toBe('ask')
  187. expect(detail.permissionModeOverridden).toBe(false)
  188. await server.inject({ method: 'POST', url: `/v1/sessions/${sid}/messages`, headers: h, payload: { text: 'hi' } })
  189. await waitFor(() => app.runs.activeForSession(sid) === undefined || undefined)
  190. expect(fake.lastCtx?.permissionMode).toBe('ask')
  191. })
  192. it('撤销 Profile 后,已有会话不能继续运行', async () => {
  193. const builtin = app.profiles.getBuiltin()
  194. const p = app.profiles.create(profileInput('临时'))
  195. const { key, apiKey } = app.apiKeys.create('平台C', { allowedProfileIds: [builtin.id, p.id], defaultProfileId: p.id })
  196. const h = { authorization: `Bearer ${key}` }
  197. const sid = (await server.inject({ method: 'POST', url: '/v1/sessions', headers: h, payload: {} })).json().id
  198. app.apiKeys.setProfiles(apiKey.id, { allowedProfileIds: [builtin.id], defaultProfileId: builtin.id })
  199. const res = await server.inject({ method: 'POST', url: `/v1/sessions/${sid}/messages`, headers: h, payload: { text: 'hi' } })
  200. expect(res.statusCode).toBe(403)
  201. })
  202. it('Key 配置校验;内置 Profile 和被引用的 Profile 不能删除', async () => {
  203. const builtin = app.profiles.getBuiltin()
  204. expect(builtin.builtin).toBe(true)
  205. expect(() => app.apiKeys.create('x', { allowedProfileIds: [], defaultProfileId: builtin.id })).toThrow(/至少选择一个/)
  206. const p = app.profiles.create(profileInput('另一个'))
  207. expect(() => app.apiKeys.create('x', { allowedProfileIds: [builtin.id], defaultProfileId: p.id })).toThrow(/默认 Profile 必须/)
  208. expect(() => app.profiles.delete(builtin.id)).toThrow(/内置/)
  209. app.apiKeys.create('平台D', { allowedProfileIds: [p.id], defaultProfileId: p.id })
  210. const login = await server.inject({ method: 'POST', url: '/admin/v1/auth/login', payload: { password: 'test-password' } })
  211. const cookie = login.cookies[0]!
  212. const del = await server.inject({ method: 'DELETE', url: `/admin/v1/profiles/${p.id}`, cookies: { [cookie.name]: cookie.value } })
  213. expect(del.statusCode).toBe(400)
  214. expect(del.json().error.message).toContain('平台D')
  215. })
  216. })
  217. describe('引擎由 Profile 决定', () => {
  218. it('每个引擎一个内置通用助手:不可删除,引擎不可修改', () => {
  219. const claude = app.profiles.getBuiltin('claude')
  220. const codex = app.profiles.getBuiltin('codex')
  221. expect(claude).toMatchObject({ name: '通用助手(Claude)', provider: 'claude', builtin: true })
  222. expect(codex).toMatchObject({ name: '通用助手(Codex)', provider: 'codex', builtin: true })
  223. expect(() => app.profiles.delete(codex.id)).toThrow(/内置/)
  224. const { id: _i, version: _v, updatedAt: _u, builtin: _b, ...input } = codex
  225. expect(() => app.profiles.update(codex.id, { ...input, provider: 'claude' })).toThrow(/引擎不能修改/)
  226. })
  227. it('平台和后台都不能单独指定引擎;会话引擎取自 Profile', async () => {
  228. const claude = app.profiles.getBuiltin('claude')
  229. const codex = app.profiles.getBuiltin('codex')
  230. const { key } = app.apiKeys.create('双引擎平台', { allowedProfileIds: [claude.id, codex.id], defaultProfileId: claude.id })
  231. const h = { authorization: `Bearer ${key}` }
  232. const bad = await server.inject({ method: 'POST', url: '/v1/sessions', headers: h, payload: { provider: 'codex' } })
  233. expect(bad.statusCode).toBe(400)
  234. expect(bad.json().error.message).toContain('引擎由 Profile 决定')
  235. await expect(app.sessions.create('admin', { provider: 'codex' } as never)).rejects.toThrow(/引擎由 Profile 决定/)
  236. // Codex 未启用时不能建会话;启用后按 Profile 使用 codex
  237. expect((await server.inject({ method: 'POST', url: '/v1/sessions', headers: h, payload: { profileId: codex.id } })).statusCode).toBe(400)
  238. app.settings.update({ providers: { codex: { enabled: true } } })
  239. const s = (await server.inject({ method: 'POST', url: '/v1/sessions', headers: h, payload: { profileId: codex.id } })).json()
  240. expect(s.provider).toBe('codex')
  241. expect(s.model).toBe(codex.model)
  242. // 换 Profile 时引擎随之改变(尚未开始对话)
  243. const patched = (await server.inject({ method: 'PATCH', url: `/v1/sessions/${s.id}`, headers: h, payload: { profileId: claude.id } })).json()
  244. expect(patched.provider).toBe('claude')
  245. expect((await server.inject({ method: 'PATCH', url: `/v1/sessions/${s.id}`, headers: h, payload: { provider: 'codex' } })).statusCode).toBe(400)
  246. })
  247. })
  248. describe('内置 Profile 的兜底', () => {
  249. it('设置中的默认模型为空时,仍能创建内置 Profile', async () => {
  250. const { createDb } = await import('../src/db')
  251. const { SettingsService } = await import('../src/services/settings')
  252. const { ProfileService } = await import('../src/services/catalog')
  253. const os = await import('node:os')
  254. const db = createDb(path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'seed-')), 'db.sqlite'))
  255. const { migrateDb } = await import('../src/db')
  256. migrateDb(db)
  257. const settings = new SettingsService(db)
  258. settings.update({ providers: { codex: { defaultModel: '' } } })
  259. const profiles = new ProfileService(db, settings)
  260. expect(profiles.getBuiltin('codex').model).toBe('gpt-6.1-sol')
  261. })
  262. })