workspaceRoutes.test.ts 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532
  1. // @vitest-environment node
  2. import { mkdtempSync, rmSync, readFileSync } from 'node:fs'
  3. import { tmpdir } from 'node:os'
  4. import { join } from 'node:path'
  5. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
  6. import { randomUUID } from 'node:crypto'
  7. import request from 'supertest'
  8. import type Koa from 'koa'
  9. import { createApplication, type ApplicationRuntime } from '../../src/server/app'
  10. import { loadServerConfig } from '../../src/server/config'
  11. import { LocalAuthStore, type LocalUser } from '../../src/server/localAuth'
  12. import { WorkspaceAssets } from '../../src/server/workspaceAssets'
  13. let home: string, runtime: ApplicationRuntime, cookie: string, csrf: string, upstream: string[], bridgeReady: boolean
  14. const first: LocalUser = {
  15. id: 'first',
  16. username: 'first',
  17. role: 'admin',
  18. enabled: true,
  19. mustChangePassword: false,
  20. createdAt: 1,
  21. updatedAt: 1,
  22. }
  23. class TestAuth extends LocalAuthStore {
  24. override current(ctx: Koa.Context) {
  25. return this.require(ctx)
  26. }
  27. override require(ctx: Koa.Context) {
  28. return { ...first, id: ctx.get('x-test-user') || 'first' }
  29. }
  30. override requireAdmin(ctx: Koa.Context) {
  31. return this.require(ctx)
  32. }
  33. override canUseSource() { return true }
  34. override currentFromCookieHeader() {
  35. return first
  36. }
  37. override isUserActive() {
  38. return true
  39. }
  40. }
  41. function req(method: 'get' | 'post' | 'put' | 'patch' | 'delete', path: string, user = 'first') {
  42. return request(runtime.app.callback())
  43. [method](path)
  44. .set('Host', '127.0.0.1:15300')
  45. .set('Origin', 'http://127.0.0.1:15300')
  46. .set('Cookie', cookie)
  47. .set('X-CSRF-Token', csrf)
  48. .set('x-test-user', user)
  49. }
  50. beforeEach(async () => {
  51. home = mkdtempSync(join(tmpdir(), 'yaoyao-workspace-routes-'))
  52. upstream = []
  53. bridgeReady = false
  54. const config = loadServerConfig({
  55. HERMES_YAOYAO_HOME: home,
  56. HERMES_YAOYAO_UPSTREAM: 'http://127.0.0.1:19119',
  57. HERMES_YAOYAO_ALLOWED_HOSTS: '127.0.0.1,localhost',
  58. })
  59. runtime = createApplication({
  60. config,
  61. auth: new TestAuth(home, false),
  62. fetchImpl: (async (input) => {
  63. const path = new URL(String(input)).pathname
  64. upstream.push(path)
  65. const body =
  66. path === '/api/plugins/yaoyao-bot-bridge/capabilities' && bridgeReady
  67. ? {version:1,ready:true,in_process:true,native_tools:true}
  68. : path === '/api/pair/v1/capabilities'
  69. ? {protocolVersion:1,serviceType:'yaoyao-web',nodeId:'11111111-1111-4111-8111-111111111111',fingerprint:'f'.repeat(64)}
  70. : path === '/api/pair/v1/claim'
  71. ? {protocolVersion:1,serviceType:'yaoyao-web',nodeId:'11111111-1111-4111-8111-111111111111',fingerprint:'f'.repeat(64),deviceId:'22222222-2222-4222-8222-222222222222',token:'delegated-token',scopes:['agents.read','sessions.execute','history.read'],serverUrl:'http://child.test:15300/node/22222222-2222-4222-8222-222222222222'}
  72. : path === '/api/status'
  73. ? { auth_required: true }
  74. : path === '/api/auth/me'
  75. ? { user_id: 'upstream' }
  76. : path.endsWith('/api/profiles')
  77. ? { profiles: [{ name: 'default', display_name: '基础 Agent' }] }
  78. : { ok: true }
  79. return new Response(JSON.stringify(body), {
  80. status: path.includes('/plugins/') && !bridgeReady ? 404 : 200,
  81. headers: { 'Content-Type': 'application/json' },
  82. })
  83. }) as typeof fetch,
  84. })
  85. const b = await request(runtime.app.callback())
  86. .get('/api/app/bootstrap')
  87. .set('Host', '127.0.0.1:15300')
  88. .expect(200)
  89. cookie = (b.headers['set-cookie'] as unknown as string[]).map((v) => v.split(';')[0]).join('; ')
  90. csrf = b.body.csrfToken
  91. })
  92. afterEach(() => {
  93. runtime.close()
  94. rmSync(home, { recursive: true, force: true })
  95. })
  96. describe('application workspace HTTP contract', () => {
  97. it.each(['direct', 'group'] as const)('uses versioned unread acknowledgments for %s without recounting history', async kind => {
  98. const store = runtime.workspace
  99. const first = store.createAgent('first', { name: '未读甲', profile: 'default' })
  100. const second = store.createAgent('first', { name: '未读乙', profile: 'default' })
  101. const conversation = kind === 'group'
  102. ? store.createGroup('first', { name: '未读群', memberIds: [first.id, second.id], administratorId: first.id })
  103. : store.list<any>('first', 'conversation').find(c => c.memberIds[0] === first.id)
  104. const task = kind === 'group' ? store.tasks('first', conversation.id)[0] : undefined
  105. const message: import('../../src/shared/workspace').WorkspaceMessage = { id: randomUUID(), conversationId: conversation.id, conversationTaskId: task?.id,
  106. seq: 0, role: 'assistant', content: '进行中', reasoning: '', status: 'streaming', attachments: [], tools: [], createdAt: Date.now() }
  107. store.saveMessage('first', message)
  108. const snapshot = (await req('get', `/api/app/conversations/${conversation.id}`).expect(200)).body
  109. const read = snapshot.task ?? snapshot.conversation
  110. expect(read.unread).toBe(false)
  111. message.status = 'complete'; message.content = '已完成'; store.saveMessage('first', message)
  112. const path = `/api/app/conversations/${conversation.id}${task ? `/tasks/${task.id}` : ''}/read`
  113. const display = vi.spyOn(store, 'messageForDisplay')
  114. const stale = (await req('put', path).send({ seq: read.lastSeq, unreadVersion: read.unreadVersion }).expect(200)).body
  115. expect(stale.conversation.unread).toBe(true)
  116. const current = stale.task ?? stale.conversation
  117. const acknowledged = (await req('put', path).send({ seq: current.lastSeq, unreadVersion: current.unreadVersion }).expect(200)).body
  118. expect(acknowledged.conversation).toMatchObject({ unread: false, unreadCount: 0 })
  119. expect(display).not.toHaveBeenCalled()
  120. await req('put', path, 'foreign').send({ seq: current.lastSeq, unreadVersion: current.unreadVersion }).expect(404)
  121. await req('put', path).send({ seq: current.lastSeq, unreadVersion: -1 }).expect(400)
  122. display.mockRestore()
  123. })
  124. it.each(['archive', 'delete'] as const)('%s removes a confirmed ordinary member from all groups atomically', async action => {
  125. const store = runtime.workspace
  126. const lead = store.createAgent('first', { name: '管理者', profile: 'default' })
  127. const bot = store.createAgent('first', { name: '待处理成员', profile: 'default' })
  128. const direct = store.list<any>('first', 'conversation').find(c => c.kind === 'direct' && c.memberIds[0] === bot.id)
  129. const group = store.createGroup('first', { name: '设计群', memberIds: [lead.id, bot.id], administratorId: lead.id, autoReplyIds: [bot.id], memberRoles: { [bot.id]: { name: '设计', description: '' } } })
  130. const oldGroup = store.createGroup('first', { name: '旧群', memberIds: [lead.id, bot.id], administratorId: lead.id })
  131. store.updateConversation('first', oldGroup.id, { archived: true })
  132. const path = `/api/app/conversations/${direct.id}/lifecycle`
  133. const preview = (await req('get', path).expect(200)).body
  134. expect(preview.groups).toEqual(expect.arrayContaining([{ id: group.id, name: '设计群', administrator: false }, { id: oldGroup.id, name: '旧群', administrator: false }]))
  135. await req('post', path).send({ action }).expect(409)
  136. expect((await req('patch', `/api/app/agents/${bot.id}`).send({ archived: true }).expect(409)).body.code).toBe('agent_in_group')
  137. await req('get', path, 'foreign').expect(404)
  138. await req('post', path, 'foreign').send({ action, confirmationToken: preview.confirmationToken }).expect(404)
  139. await req('post', path).set('X-CSRF-Token', 'invalid').send({ action, confirmationToken: preview.confirmationToken }).expect(403)
  140. await req('post', path).send({ action, confirmationToken: preview.confirmationToken }).expect(200)
  141. expect(store.require<any>('first', 'conversation', group.id)).toMatchObject({ memberIds: [lead.id], autoReplyIds: [], memberRoles: {}, administratorId: lead.id })
  142. expect(store.require<any>('first', 'conversation', oldGroup.id).memberIds).toEqual([lead.id])
  143. if (action === 'delete') {
  144. expect(store.get('first', 'agent', bot.id)).toBeUndefined()
  145. expect(store.get('first', 'conversation', direct.id)).toBeUndefined()
  146. } else {
  147. expect(store.require<any>('first', 'agent', bot.id).archived).toBe(true)
  148. await req('post', path).send({ action: 'restore' }).expect(200)
  149. expect(store.require<any>('first', 'conversation', direct.id).archived).toBe(false)
  150. expect(store.require<any>('first', 'conversation', group.id).memberIds).toEqual([lead.id])
  151. }
  152. expect(store.get('first', 'agent', lead.id)).toBeDefined()
  153. })
  154. it.each(['archive', 'delete'] as const)('blocks %s for a group administrator without removing other memberships', async action => {
  155. const store = runtime.workspace
  156. const bot = store.createAgent('first', { name: '群管理者', profile: 'default' })
  157. const other = store.createAgent('first', { name: '其他成员', profile: 'default' })
  158. const direct = store.list<any>('first', 'conversation').find(c => c.kind === 'direct' && c.memberIds[0] === bot.id)
  159. const regular = store.createGroup('first', { name: '普通群', memberIds: [bot.id, other.id], administratorId: other.id })
  160. const managed = store.createGroup('first', { name: '管理群', memberIds: [bot.id, other.id], administratorId: bot.id })
  161. const path = `/api/app/conversations/${direct.id}/lifecycle`
  162. const preview = (await req('get', path).expect(200)).body
  163. const cursor = store.cursor('first')
  164. const result = await req('post', path).send({ action, confirmationToken: preview.confirmationToken }).expect(409)
  165. expect(result.body.code).toBe('agent_group_administrator')
  166. expect(result.body.error).toContain('管理群')
  167. expect(store.require<any>('first', 'conversation', regular.id).memberIds).toContain(bot.id)
  168. expect(store.require<any>('first', 'conversation', managed.id).administratorId).toBe(bot.id)
  169. expect(store.require<any>('first', 'agent', bot.id).archived).toBe(false)
  170. expect(store.cursor('first')).toBe(cursor)
  171. })
  172. it('rejects a stale group confirmation and rolls back membership on a later deletion failure', async () => {
  173. const store = runtime.workspace
  174. const lead = store.createAgent('first', { name: '负责人', profile: 'default' })
  175. const bot = store.createAgent('first', { name: '成员', profile: 'default' })
  176. const direct = store.list<any>('first', 'conversation').find(c => c.kind === 'direct' && c.memberIds[0] === bot.id)
  177. const path = `/api/app/conversations/${direct.id}/lifecycle`
  178. const old = (await req('get', path).expect(200)).body
  179. const group = store.createGroup('first', { name: '后来加入的群', memberIds: [lead.id, bot.id], administratorId: lead.id })
  180. expect((await req('post', path).send({ action: 'delete', confirmationToken: old.confirmationToken }).expect(409)).body.code).toBe('lifecycle_confirmation_stale')
  181. const current = (await req('get', path).expect(200)).body
  182. store.put('first', 'turn', 'pending', { id: 'pending', agentId: bot.id, conversationId: group.id, status: 'uncertain' })
  183. await req('post', path).send({ action: 'archive', confirmationToken: current.confirmationToken }).expect(409)
  184. store.remove('first', 'turn', 'pending')
  185. const cursor = store.cursor('first')
  186. const failure = vi.spyOn(store, 'deleteAgent').mockImplementation(() => { throw new Error('模拟写入失败') })
  187. await req('post', path).send({ action: 'delete', confirmationToken: current.confirmationToken }).expect(500)
  188. failure.mockRestore()
  189. expect(store.require<any>('first', 'conversation', group.id).memberIds).toContain(bot.id)
  190. expect(store.require<any>('first', 'agent', bot.id).archived).toBe(false)
  191. expect(store.require<any>('first', 'conversation', direct.id).archived).toBe(false)
  192. expect(store.cursor('first')).toBe(cursor)
  193. })
  194. it('deletes an unarchived group while preserving its member Bots', async () => {
  195. const store = runtime.workspace
  196. const a = store.createAgent('first', { name: '甲', profile: 'default' }), b = store.createAgent('first', { name: '乙', profile: 'default' })
  197. const group = store.createGroup('first', { name: '待删除群', memberIds: [a.id, b.id], administratorId: a.id })
  198. const path = `/api/app/conversations/${group.id}/lifecycle`
  199. const preview = (await req('get', path).expect(200)).body
  200. await req('post', path).send({ action: 'delete', confirmationToken: preview.confirmationToken }).expect(200)
  201. expect(store.get('first', 'conversation', group.id)).toBeUndefined()
  202. expect(store.get('first', 'agent', a.id)).toBeDefined()
  203. expect(store.get('first', 'agent', b.id)).toBeDefined()
  204. })
  205. it('deletes only archived owned chats, clears their records, and preserves library files and members', async () => {
  206. const store = runtime.workspace
  207. const lead = store.createAgent('first', { name: '负责人', profile: 'default' })
  208. const member = store.createAgent('first', { name: '成员', profile: 'default' })
  209. const group = store.createGroup('first', { name: '待删除群聊', memberIds: [lead.id, member.id], administratorId: lead.id })
  210. const task = store.tasks('first', group.id)[0]!
  211. const path = `/api/app/conversations/${group.id}`
  212. await req('delete', path).expect(409)
  213. store.updateConversation('first', group.id, { archived: true })
  214. store.put('second', 'conversation', group.id, group)
  215. for (const kind of ['message', 'run', 'turn', 'interaction', 'interaction-binding', 'binding', 'assignment']) {
  216. store.put('first', kind, kind, { id: kind, conversationId: group.id, conversationTaskId: task.id, status: 'complete' })
  217. store.put('second', kind, kind, { id: kind, conversationId: group.id })
  218. }
  219. store.put('first', 'context', task.id, { used: 5 })
  220. const bindingKey = `${group.id}:${task.id}:${lead.id}`
  221. store.put('first', 'binding', bindingKey, { conversationTaskId: task.id, sessionId: 'session' })
  222. store.put('first', 'interaction-binding', 'legacy-interaction', { key: bindingKey, taskId: 'turn' })
  223. store.put('second', 'binding', bindingKey, { sessionId: 'other-session' })
  224. store.put('first', 'file', 'file', { id: 'file', conversationId: group.id, messageId: 'message', name: '保留.txt' })
  225. await req('delete', path, 'third').expect(404)
  226. await req('delete', path).set('X-CSRF-Token', 'invalid').expect(403)
  227. await req('delete', path).expect(200)
  228. await req('get', path).expect(404)
  229. expect(store.get('first', 'context', task.id)).toBeUndefined()
  230. expect(store.get('first', 'conversation-task', task.id)).toBeUndefined()
  231. expect(store.list('first', 'message')).toEqual([])
  232. expect(store.list('first', 'run')).toEqual([])
  233. expect(store.list('first', 'binding')).toEqual([])
  234. expect(store.list('first', 'interaction-binding')).toEqual([])
  235. expect(store.get('second', 'binding', bindingKey)).toEqual({ sessionId: 'other-session' })
  236. expect(store.get('first', 'file', 'file')).toEqual({ id: 'file', name: '保留.txt' })
  237. expect(store.list('first', 'agent')).toHaveLength(2)
  238. expect(store.get('second', 'conversation', group.id)).toEqual(group)
  239. expect(store.get('second', 'message', 'message')).toBeDefined()
  240. expect((await req('get', '/api/app/events')).body.events.some((e: any) => e.type === 'conversation.deleted' && e.conversationId === group.id)).toBe(true)
  241. })
  242. it('deletes an archived Bot and its direct chat only after group references are removed', async () => {
  243. const store = runtime.workspace
  244. const bot = store.createAgent('first', { name: '归档 Bot', profile: 'default' })
  245. const member = store.createAgent('first', { name: '成员', profile: 'default' })
  246. const direct = store.list<any>('first', 'conversation').find(c => c.memberIds[0] === bot.id)!
  247. const group = store.createGroup('first', { name: '关联群', memberIds: [bot.id, member.id], administratorId: member.id })
  248. const path = `/api/app/agents/${bot.id}`
  249. await req('delete', path).expect(409)
  250. store.updateAgent('first', bot.id, { archived: true })
  251. await req('delete', `/api/app/conversations/${direct.id}`).expect(400)
  252. expect((await req('delete', path).expect(409)).body.code).toBe('agent_in_group')
  253. expect(store.get('first', 'agent', bot.id)).toBeDefined()
  254. store.updateConversation('first', group.id, { memberIds: [member.id] })
  255. await req('delete', path, 'second').expect(404)
  256. await req('delete', path).expect(200)
  257. expect(store.get('first', 'agent', bot.id)).toBeUndefined()
  258. expect(store.get('first', 'conversation', direct.id)).toBeUndefined()
  259. expect(store.get('first', 'conversation', group.id)).toBeDefined()
  260. expect(store.get('first', 'agent', member.id)).toBeDefined()
  261. })
  262. it('keeps archived chats intact while runs or delegated goals are unresolved', async () => {
  263. const store = runtime.workspace
  264. const bot = store.createAgent('first', { name: '运行中', profile: 'default' })
  265. const direct = store.list<any>('first', 'conversation')[0]!
  266. store.updateAgent('first', bot.id, { archived: true })
  267. store.put('first', 'run', 'pending', { id: 'pending', conversationId: direct.id, status: 'uncertain' })
  268. const path = `/api/app/agents/${bot.id}`
  269. await req('delete', path).expect(409)
  270. expect(store.get('first', 'conversation', direct.id)).toBeDefined()
  271. store.remove('first', 'run', 'pending')
  272. store.put('first', 'goal', 'delegated', { id: 'delegated', conversationId: 'another-chat', origin: { conversationId: direct.id }, status: 'waiting' })
  273. await req('delete', path).expect(409)
  274. expect(store.get('first', 'agent', bot.id)).toBeDefined()
  275. })
  276. it('resumes a stopped goal from a real user request without duplicating its run',async()=>{
  277. vi.spyOn(runtime.workspaceRuntime,'wake').mockImplementation(()=>{})
  278. vi.spyOn(runtime.workspaceRuntime.teamTools,'requireAvailable').mockResolvedValue()
  279. const lead=runtime.workspace.createAgent('first',{name:'任务负责人',profile:'default',canManageTeam:true})
  280. const member=runtime.workspace.createAgent('first',{name:'成员',profile:'default'})
  281. const source=runtime.workspace.list<any>('first','conversation').find(c=>c.kind==='direct'&&c.memberIds[0]===lead.id)!
  282. const original=runtime.workspaceRuntime.send('first',source.id,{requestId:randomUUID(),content:'完成任务'})
  283. const team=runtime.workspace.createGroup('first',{name:'任务组',memberIds:[lead.id,member.id],administratorId:lead.id})
  284. const task=runtime.workspace.tasks('first',team.id)[0]!
  285. runtime.workspaceRuntime.tasks.begin('first',task,lead,'完成任务',{conversationId:source.id,runId:original.id,agentId:lead.id})
  286. await req('post',`/api/app/conversations/${team.id}/tasks/${task.id}/stop`).send({}).expect(200)
  287. const input={requestId:randomUUID()}
  288. const resumed=await req('post',`/api/app/conversations/${team.id}/tasks/${task.id}/resume`).send(input).expect(200)
  289. expect(resumed.body.goal.status).toBe('running')
  290. const repeated=await req('post',`/api/app/conversations/${team.id}/tasks/${task.id}/resume`).send(input).expect(200)
  291. expect(repeated.body.run.id).toBe(resumed.body.run.id)
  292. await req('post',`/api/app/conversations/${team.id}/tasks/${task.id}/resume`,'second').send({requestId:randomUUID()}).expect(404)
  293. })
  294. it('starts an explicit delivery and lets only its owner edit versioned acceptance criteria',async()=>{
  295. vi.spyOn(runtime.workspaceRuntime,'wake').mockImplementation(()=>{})
  296. vi.spyOn(runtime.workspaceRuntime.teamTools,'requireAvailable').mockResolvedValue()
  297. const lead=runtime.workspace.createAgent('first',{name:'负责人',profile:'default',canManageTeam:true})
  298. const member=runtime.workspace.createAgent('first',{name:'成员',profile:'default'})
  299. const team=runtime.workspace.createGroup('first',{name:'交付群',memberIds:[lead.id,member.id],administratorId:lead.id})
  300. const task=runtime.workspace.tasks('first',team.id)[0]!
  301. const path=`/api/app/conversations/${team.id}`
  302. const input={requestId:randomUUID(),taskId:task.id,content:'完成并交付报告',mode:'goal'}
  303. const sent=await req('post',`${path}/messages`).send(input).expect(202)
  304. expect(sent.body.run.goalId).toBe(task.id)
  305. expect((await req('post',`${path}/messages`).send(input).expect(202)).body.run.id).toBe(sent.body.run.id)
  306. expect((await req('get',`${path}/tasks/${task.id}/plan`).expect(200)).body.assignments).toEqual([])
  307. const update={requestId:randomUUID(),expectedRevision:1,acceptanceCriteria:['包含三种方案的比较']}
  308. const edited=await req('patch',`${path}/tasks/${task.id}/plan`).send(update).expect(200)
  309. expect(edited.body.goal).toMatchObject({acceptanceRevision:2,acceptanceCriteria:update.acceptanceCriteria})
  310. await req('patch',`${path}/tasks/${task.id}/plan`,'second').send({...update,requestId:randomUUID()}).expect(404)
  311. expect((await req('patch',`${path}/tasks/${task.id}/plan`).send({...update,requestId:randomUUID()}).expect(409)).body.code).toBe('goal_criteria_changed')
  312. })
  313. it('grants team management only with a ready bridge and persists explicit revocation', async () => {
  314. const denied = await req('post','/api/app/agents').send({name:'老板',profile:'default',canManageTeam:true}).expect(409)
  315. expect(denied.body.code).toBe('team_tools_unavailable')
  316. expect((await req('get','/api/app/agents')).body.agents).toEqual([])
  317. bridgeReady = true
  318. const created = await req('post','/api/app/agents').send({name:'老板',profile:'default',canManageTeam:true})
  319. expect(created.status, JSON.stringify(created.body)).toBe(201)
  320. const agent = created.body.agent
  321. expect(agent.canManageTeam).toBe(true)
  322. await req('patch',`/api/app/agents/${agent.id}`,'second').send({canManageTeam:false}).expect(404)
  323. bridgeReady = false
  324. const disabled = await req('patch',`/api/app/agents/${agent.id}`).send({canManageTeam:false}).expect(200)
  325. expect(disabled.body.agent.canManageTeam).toBe(false)
  326. expect((await req('get','/api/app/agents')).body.agents[0].canManageTeam).toBe(false)
  327. await req('patch',`/api/app/agents/${agent.id}`).send({canManageTeam:true}).expect(409)
  328. })
  329. it('accepts only child pairing, hides credentials, and scopes address edits to the owner', async () => {
  330. const code = new URL('yaoyao://pair')
  331. for (const [key,value] of Object.entries({v:'1',url:'http://child.test:15300',node:'11111111-1111-4111-8111-111111111111',id:'33333333-3333-4333-8333-333333333333',fingerprint:'f'.repeat(64),secret:'s'.repeat(64)})) code.searchParams.set(key,value)
  332. await req('post','/api/app/nodes').send({name:'legacy',url:'http://child.test:9119',username:'user',password:'password'}).expect(400)
  333. await req('post','/api/app/nodes').send({name:'child',qrPayload:code.href.replace('://pair','://login')}).expect(400)
  334. await req('post','/api/app/nodes').send({name:'child',qrPayload:code.href}).expect(201)
  335. const listing=await req('get','/api/app/nodes').expect(200), node=listing.body.nodes[0]
  336. expect(node.transport).toBe('paired-web'); expect(JSON.stringify(listing.body)).not.toContain('delegated-token'); expect(node.secret).toBeUndefined()
  337. expect((await req('get','/api/app/nodes','second').expect(200)).body.nodes).toEqual([])
  338. await req('patch',`/api/app/nodes/${node.id}`,'second').send({name:'other',url:'http://new-ip.test:15300'}).expect(404)
  339. await req('patch',`/api/app/nodes/${node.id}`).send({name:'new name',url:'http://new-ip.test:15300'}).expect(200)
  340. const updated=(await req('get','/api/app/nodes').expect(200)).body.nodes[0]
  341. expect(updated.id).toBe(node.id); expect(updated.url).toBe('http://new-ip.test:15300/')
  342. const sources=(await req('get','/api/app/agents/sources').expect(200)).body.sources
  343. expect(sources.some((s:any)=>s.nodeId===node.id && s.profile==='default')).toBe(true)
  344. })
  345. it('archives native-chat uploads through the standard file API with user ownership', async () => {
  346. const assets = new WorkspaceAssets(runtime.workspace, runtime.workspaceRuntime.nodes, home)
  347. await assets.archiveText(
  348. 'first',
  349. '{"path":"/tmp/native-upload.txt"}',
  350. 'local',
  351. 'default',
  352. 'native-session',
  353. 'native-message',
  354. 'user',
  355. )
  356. const page = await req('get', '/api/app/files').expect(200)
  357. expect(page.body.items[0]).toMatchObject({
  358. name: 'native-upload.txt',
  359. sender: 'user',
  360. profile: 'default',
  361. })
  362. expect(upstream).toContain('/api/files/download')
  363. expect((await req('get', '/api/app/files', 'second')).body.items).toEqual([])
  364. expect(upstream.some((p) => p.includes('/plugins/'))).toBe(false)
  365. })
  366. it('works without plugin discovery, installation, or any plugin HTTP request', async () => {
  367. const capabilities = (await req('get', '/api/app/capabilities').expect(200)).body.features
  368. expect(capabilities).toContain('editableGroups')
  369. expect(capabilities).not.toContain('immutableGroups')
  370. await req('get', '/api/app/files').expect(200)
  371. await req('get', '/api/app/voice/runtime').expect(200)
  372. await req('get', '/api/app/system/update/status').expect(200)
  373. await req('post', '/api/app/plugins/yaoyao/reconcile').send({}).expect(410)
  374. await req('get', '/api/plugins/yaoyao/v1/capabilities').expect(410)
  375. expect(upstream.some((p) => p.includes('/plugins/'))).toBe(false)
  376. })
  377. it('changes members over HTTP while protecting the administrator and ownership', async () => {
  378. const a = (
  379. await req('post', '/api/app/agents').send({ name: '编辑', profile: 'default' }).expect(201)
  380. ).body.agent
  381. const b = (
  382. await req('post', '/api/app/agents').send({ name: '审查', profile: 'default' }).expect(201)
  383. ).body.agent
  384. const c = (
  385. await req('post', '/api/app/conversations')
  386. .send({ name: '团队', memberIds: [a.id, b.id], administratorId: a.id })
  387. .expect(201)
  388. ).body.conversation
  389. const rows = await req('get', '/api/app/conversations').expect(200)
  390. expect(rows.body.conversations).toHaveLength(3)
  391. await req('patch', `/api/app/conversations/${c.id}`)
  392. .send({ memberIds: [b.id], administratorId: b.id })
  393. .expect(400)
  394. await req('patch', `/api/app/conversations/${c.id}`).send({ memberIds: [a.id] }).expect(200)
  395. expect((await req('get', `/api/app/conversations/${c.id}`)).body.conversation.memberIds).toEqual([a.id])
  396. await req('patch', `/api/app/conversations/${c.id}`).send({ memberIds: [a.id, b.id] }).expect(200)
  397. await req('get', `/api/app/conversations/${c.id}`, 'second').expect(404)
  398. await req('patch', `/api/app/agents/${a.id}`, 'second').send({ name: '入侵' }).expect(404)
  399. expect((await req('get', '/api/app/events', 'second')).body.events).toEqual([])
  400. expect(upstream.some((p) => p.includes('sessions'))).toBe(false)
  401. })
  402. it('creates, lists, renames, selects, and deletes group tasks without changing remote members', async () => {
  403. const a = (
  404. await req('post', '/api/app/agents').send({ name: '本机管理员', profile: 'default' }).expect(201)
  405. ).body.agent
  406. const b = (
  407. await req('post', '/api/app/agents').send({ name: '远程成员', nodeId: 'local', profile: 'default' }).expect(201)
  408. ).body.agent
  409. const conversation = (
  410. await req('post', '/api/app/conversations')
  411. .send({ name: '多任务团队', memberIds: [a.id, b.id], administratorId: a.id })
  412. .expect(201)
  413. ).body.conversation
  414. const initial = (await req('get', `/api/app/conversations/${conversation.id}/tasks`).expect(200)).body.tasks
  415. expect(initial).toHaveLength(1)
  416. expect(initial[0]).toMatchObject({
  417. conversationId: conversation.id,
  418. title: '新任务',
  419. titleSource: 'automatic',
  420. messageCount: 0,
  421. })
  422. const task = (
  423. await req('post', `/api/app/conversations/${conversation.id}/tasks`)
  424. .send({ title: '远程 Agent 调研' })
  425. .expect(201)
  426. ).body.task
  427. expect(task).toMatchObject({ title: '远程 Agent 调研', titleSource: 'user' })
  428. const renamed = (
  429. await req('patch', `/api/app/conversations/${conversation.id}/tasks/${task.id}`)
  430. .send({ title: '远程 Agent 复核' })
  431. .expect(200)
  432. ).body.task
  433. expect(renamed).toMatchObject({ title: '远程 Agent 复核', titleSource: 'user' })
  434. const detail = (await req('get', `/api/app/conversations/${conversation.id}?taskId=${task.id}`).expect(200)).body
  435. expect(detail.task.id).toBe(task.id)
  436. expect(detail.tasks.map((row: { id: string }) => row.id)).toContain(task.id)
  437. expect(detail.messages).toEqual([])
  438. expect(detail.conversation).toMatchObject({ memberIds: [a.id, b.id], administratorId: a.id })
  439. expect((await req('put', `/api/app/conversations/${conversation.id}/tasks/${task.id}/read`).send({ seq: 100 }).expect(200)).body.task)
  440. .toMatchObject({ readSeq: 0, lastSeq: 0, unreadCount: 0 })
  441. await req('get', `/api/app/conversations/${conversation.id}/tasks`, 'second').expect(404)
  442. await req('delete', `/api/app/conversations/${conversation.id}/tasks/${task.id}`).expect(200)
  443. const remaining = (await req('get', `/api/app/conversations/${conversation.id}/tasks`).expect(200)).body.tasks
  444. expect(remaining.map((row: { id: string }) => row.id)).not.toContain(task.id)
  445. expect((await req('get', `/api/app/conversations/${conversation.id}`)).body.conversation)
  446. .toMatchObject({ memberIds: [a.id, b.id], administratorId: a.id })
  447. await req('delete', `/api/app/conversations/${conversation.id}/tasks/${initial[0].id}`).expect(200)
  448. const replacement = (await req('get', `/api/app/conversations/${conversation.id}/tasks`).expect(200)).body.tasks
  449. expect(replacement).toHaveLength(1)
  450. expect(replacement[0].id).not.toBe(initial[0].id)
  451. })
  452. it('archives files on the Web server, preserves native library fields, and blocks cross-user downloads', async () => {
  453. const f = (
  454. await req('post', '/api/app/uploads')
  455. .attach('files', Buffer.from('hello'), { filename: 'hello.txt', contentType: 'text/plain' })
  456. .expect(201)
  457. ).body.files[0]
  458. expect(f.path).toBeUndefined()
  459. expect(runtime.uploads.cleanupUncommitted(0)).toBe(0)
  460. const list = await req('get', '/api/app/files').expect(200),
  461. item = list.body.items[0]
  462. expect(typeof item.id).toBe('number')
  463. expect(item.path).toBe(`/api/app/files/${f.id}/download`)
  464. expect(item).toMatchObject({ name: 'hello.txt', size: 5, exists: true, archiveStatus: 'ready' })
  465. expect((await req('get', item.path).expect(200)).text).toBe('hello')
  466. await req('get', item.path, 'second').expect(404)
  467. await req('get', `/api/app/files/${item.id}/download`, 'second').expect(404)
  468. expect((await req('get', '/api/app/files', 'second')).body.total).toBe(0)
  469. expect(upstream.some((p) => p.includes('/files'))).toBe(false)
  470. })
  471. it('does not serve uploaded HTML or SVG as executable same-origin previews', async () => {
  472. const f = (
  473. await req('post', '/api/app/uploads')
  474. .attach('files', Buffer.from('<svg onload="alert(1)"/>'), {
  475. filename: 'unsafe.svg',
  476. contentType: 'image/svg+xml',
  477. })
  478. .expect(201)
  479. ).body.files[0]
  480. const response = await req('get', `/api/app/files/${f.id}/preview`).expect(200)
  481. expect(response.headers['content-type']).toContain('application/octet-stream')
  482. expect(response.headers['content-disposition']).toContain('attachment')
  483. })
  484. it('persists encrypted voice credentials while giving each user their own selected voice', async () => {
  485. await req('put', '/api/app/admin/duplex-voice')
  486. .send({
  487. apiKey: 'private-test-key',
  488. voices: [
  489. { id: 'a', name: '甲' },
  490. { id: 'b', name: '乙' },
  491. ],
  492. currentVoiceId: 'a',
  493. })
  494. .expect(200)
  495. const publicResponse = await req('get', '/api/app/admin/duplex-voice').expect(200)
  496. expect(publicResponse.body.hasApiKey).toBe(true)
  497. expect(publicResponse.body.apiKey).toBeUndefined()
  498. await req('put', '/api/app/voice/current-voice', 'second')
  499. .send({ currentVoiceId: 'b' })
  500. .expect(200)
  501. expect((await req('get', '/api/app/voice/runtime')).body.currentVoiceId).toBe('a')
  502. expect((await req('get', '/api/app/voice/runtime', 'second')).body.currentVoiceId).toBe('b')
  503. expect(
  504. readFileSync(join(home, 'workspace.sqlite3')).includes(Buffer.from('private-test-key')),
  505. ).toBe(false)
  506. })
  507. it('stores monotonic profile-scoped context snapshots without a plugin', async () => {
  508. const path = '/api/app/session-context/session-one?profile=default'
  509. const b = { usedTokens: 200, limitTokens: 1000, percent: 20, observedAt: 20 }
  510. await req('put', path).send(b).expect(200)
  511. await req('put', path)
  512. .send({ ...b, usedTokens: 10, observedAt: 10 })
  513. .expect(200)
  514. expect((await req('get', path)).body.snapshot).toMatchObject({ ...b, sessionId: 'session-one' })
  515. expect((await req('get', path, 'second')).body.snapshot).toBeNull()
  516. expect(
  517. (await req('get', '/api/app/session-context/session-one?profile=other')).body.snapshot,
  518. ).toBeNull()
  519. })
  520. it('protects internal Hermes session IDs on native HTTP endpoints', async () => {
  521. runtime.workspace.put('first', 'binding', 'binding', {
  522. storedId: 'internal-session',
  523. runtimeId: 'internal-runtime',
  524. aliases: ['old-id'],
  525. })
  526. await req('get', '/api/app/sessions/internal-session/messages').expect(404)
  527. await req('get', '/api/sessions/old-id/messages', 'second').expect(404)
  528. expect(upstream.some((p) => p.includes('internal-session') || p.includes('old-id'))).toBe(false)
  529. })
  530. })