workspaceTeamTools.test.ts 34 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505
  1. // @vitest-environment node
  2. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
  3. import { mkdtempSync, rmSync } from 'node:fs'
  4. import { tmpdir } from 'node:os'
  5. import { join } from 'node:path'
  6. import { randomUUID } from 'node:crypto'
  7. import { WorkspaceStore } from '../../src/server/workspaceStore'
  8. import { WorkspaceRuntime } from '../../src/server/workspaceRuntime'
  9. import type { WorkspaceNodes, GatewayTarget } from '../../src/server/workspaceGateway'
  10. import { UploadStore } from '../../src/server/uploads'
  11. import { HttpError } from '../../src/server/errors'
  12. import { createWorkspaceToolLease, requireTeamToolBridge, type WorkspaceToolLease } from '../../src/server/workspaceToolLease'
  13. import type { Work } from '../../src/server/workspaceScheduler'
  14. import type { WorkspaceAgent, WorkspaceConversation, WorkspaceTask, WorkspaceRun } from '../../src/shared/workspace'
  15. let home: string, store: WorkspaceStore, runtime: WorkspaceRuntime, uploads: UploadStore
  16. let manager: WorkspaceAgent, work: Work, nodes: WorkspaceNodes, active: boolean
  17. let denied: Set<string>, target: GatewayTarget, binding: Record<string, any> | undefined
  18. let leases: WorkspaceToolLease[], upstream: Array<{ path: string; body: any }>
  19. const owner = 'account-one'
  20. const call = (tool: string, args: unknown = {}) => runtime.teamTools.call(owner, work.id, `workspace_${tool}`, args) as Promise<any>
  21. const selfCall = (tool: 'get' | 'update', args: unknown = {}) => runtime.knowledgeTools.call(owner, work.id, `workspace_${tool}_self_rules`, args, false) as Promise<any>
  22. beforeEach(() => {
  23. home = mkdtempSync(join(tmpdir(), 'yaoyao-team-tools-'))
  24. store = new WorkspaceStore(home); uploads = new UploadStore(home)
  25. active = true; denied = new Set(); leases = []; upstream = []; binding = undefined
  26. target = {
  27. url: new URL('http://127.0.0.1:19119'),
  28. session: { request: vi.fn(async (path, options) => {
  29. upstream.push({ path, body: options?.body })
  30. if (path.endsWith('/bind')) binding = options!.body as Record<string, any>
  31. return { status: 200, body: Buffer.from(JSON.stringify(path.endsWith('/capabilities')
  32. ? { version: 1, ready: true, native_tools: true, in_process: true }
  33. : { ok: true, native_tools: true })), headers: new Headers() }
  34. }) },
  35. } as unknown as GatewayTarget
  36. nodes = {
  37. target: () => target,
  38. requireSource: (user: string, source: {profile: string}) => {
  39. if (user !== owner || denied.has(source.profile)) throw new HttpError(403, '未分配', 'agent_source_forbidden')
  40. },
  41. sources: async () => ({ sources: [{nodeId:'local',profile:'default',name:'基础 Agent'}, {nodeId:'local',profile:'writer',name:'写作'}], errors: [] }),
  42. } as unknown as WorkspaceNodes
  43. runtime = new WorkspaceRuntime(store, nodes, uploads, () => active)
  44. vi.spyOn(runtime, 'wake').mockImplementation(() => {})
  45. manager = store.createAgent(owner, {name:'老板',profile:'default',canManageTeam:true})
  46. const direct = store.list<WorkspaceConversation>(owner,'conversation')[0]!
  47. const run = runtime.send(owner,direct.id,{requestId:randomUUID(),content:'组建项目团队'})
  48. work = store.list<Work>(owner,'turn').find(w=>w.runId===run.id)!
  49. work.status = 'running'; work.teamManagementRevision=manager.revision; store.put(owner,'turn',work.id,work)
  50. })
  51. afterEach(async () => {
  52. await Promise.all(leases.map(lease => lease.dispose()))
  53. runtime.close(); uploads.close(); store.close()
  54. rmSync(home,{recursive:true,force:true})
  55. })
  56. async function buildTeam() {
  57. const member = (await call('create_agent',{requestId:randomUUID(),name:'研究员',profile:'writer'})).agent as WorkspaceAgent
  58. const created = await call('create_team',{requestId:randomUUID(),name:'项目团队',memberIds:[member.id]})
  59. return { member, team: created.team as WorkspaceConversation, task: created.task as WorkspaceTask }
  60. }
  61. describe('Agent-owned team tools', () => {
  62. it('only assembles members when creating a team, without goals or assignments', async () => {
  63. const before = store.list(owner,'run').length
  64. const {team,task} = await buildTeam()
  65. expect(store.tasks(owner,team.id)).toHaveLength(1)
  66. expect(task.goal).toBeUndefined()
  67. expect(store.list(owner,'goal')).toEqual([])
  68. expect(store.list(owner,'assignment')).toEqual([])
  69. expect(store.list(owner,'run')).toHaveLength(before)
  70. })
  71. it('promotes the current user conversation into a goal without creating another task or turn', async () => {
  72. const {team,task} = await buildTeam()
  73. const run = runtime.send(owner,team.id,{requestId:randomUUID(),taskId:task.id,content:'把这件事做完并交付报告'})
  74. work = store.list<Work>(owner,'turn').find(w=>w.runId===run.id)!
  75. work.status='running';work.teamManagementRevision=manager.revision;store.put(owner,'turn',work.id,work)
  76. const before = store.list(owner,'run').length
  77. const input = {requestId:randomUUID(),teamId:team.id,title:'报告',content:'交付报告',acceptanceCriteria:['包含结论和依据']}
  78. const started = await call('start_team_task',input)
  79. expect((await call('start_team_task',input)).task.id).toBe(task.id)
  80. expect(store.tasks(owner,team.id)).toHaveLength(1)
  81. expect(store.list(owner,'run')).toHaveLength(before)
  82. expect(started.run.goalId).toBe(task.id)
  83. const edited = await call('update_team_goal',{requestId:randomUUID(),goalId:task.id,expectedRevision:1,acceptanceCriteria:['包含三条结论及对应来源']})
  84. expect(edited.goal.acceptanceRevision).toBe(2)
  85. await expect(call('start_team_task',{...input,requestId:randomUUID()})).rejects.toMatchObject({code:'goal_exists'})
  86. })
  87. it('records creator authority and archives only an unused member it created',async()=>{
  88. const initialRules = { instructions:'核验任务结果', description:'负责事实核验', job:'核验资料', antiJobs:['不猜测结论'], voice:'rigorous', actBias:'ask_key_then_act' }
  89. const child=(await call('create_agent',{requestId:randomUUID(),name:'临时项目成员',profile:'default',...initialRules})).agent
  90. expect(child.createdByAgentId).toBe(manager.id)
  91. expect(store.require(owner,'agent',child.id)).toMatchObject(initialRules)
  92. for (const [field, value] of Object.entries(initialRules)) await expect(call('update_created_agent',{requestId:randomUUID(),agentId:child.id,name:'改名成员',[field]:value})).rejects.toMatchObject({code:'invalid_workspace_request'})
  93. expect(store.require(owner,'agent',child.id)).toMatchObject({...initialRules,revision:1})
  94. const edited=await call('update_created_agent',{requestId:randomUUID(),agentId:child.id,name:'改名成员'})
  95. expect(edited.agent.name).toBe('改名成员')
  96. expect(store.require(owner,'agent',child.id)).toMatchObject(initialRules)
  97. const other=store.createAgent(owner,{name:'用户成员',profile:'default'})
  98. await expect(call('archive_created_agent',{requestId:randomUUID(),agentId:other.id,confirmName:other.name})).rejects.toMatchObject({status:403})
  99. const team=(await call('create_team',{requestId:randomUUID(),name:'成员管理测试',memberIds:[child.id]})).team
  100. await expect(call('archive_created_agent',{requestId:randomUUID(),agentId:child.id,confirmName:edited.agent.name})).rejects.toMatchObject({code:'agent_in_use'})
  101. await call('update_team',{requestId:randomUUID(),teamId:team.id,archived:true})
  102. expect((await call('archive_created_agent',{requestId:randomUUID(),agentId:child.id,confirmName:edited.agent.name})).agent.archived).toBe(true)
  103. })
  104. it('creates durable members, a managed team and one idempotent task with client-visible events', async () => {
  105. const requestId = randomUUID(), args = {requestId,name:'研究员',profile:'writer',instructions:'研究事实并给出处'}
  106. const first = await call('create_agent',args)
  107. expect(await call('create_agent',args)).toEqual(first)
  108. expect(first.agent.canManageTeam).toBe(true)
  109. expect(store.list(owner,'agent')).toHaveLength(2)
  110. const groupArgs = {requestId:randomUUID(),name:'项目团队',memberIds:[first.agent.id]}
  111. const created = await call('create_team',groupArgs)
  112. expect(await call('create_team',groupArgs)).toEqual(created)
  113. expect(created.team).toMatchObject({memberIds:[manager.id,first.agent.id],administratorId:manager.id,mode:'free',collaborationMode:'discussion'})
  114. const taskArgs = {requestId:randomUUID(),teamId:created.team.id,title:'核对资料',content:'请研究员核对资料后汇总。'}
  115. const root=store.require<any>(owner,'run',work.runId)
  116. root.deviceHost=randomUUID();store.saveRun(owner,root)
  117. const started = await call('start_team_task',taskArgs)
  118. expect(started.run.deviceHost).toBe(root.deviceHost)
  119. expect(await call('start_team_task',taskArgs)).toEqual(started)
  120. expect(started.run.status).toBe('queued')
  121. expect(store.tasks(owner,created.team.id)).toHaveLength(1)
  122. expect(started.task.id).toBe(created.task.id)
  123. const result = await call('get_team_task',{teamId:created.team.id,taskId:started.task.id})
  124. expect(result.runs).toHaveLength(1)
  125. expect(result.messages[0].content).toContain(taskArgs.content)
  126. expect(result.messages[0].role).toBe('system')
  127. expect(store.events(owner,0).map(e=>e.type)).toEqual(expect.arrayContaining(['agent.changed','conversation.changed','task.changed','message.changed','run.changed']))
  128. expect(store.list('other','agent')).toEqual([])
  129. expect(await call('list_teams')).toMatchObject({teams:[{id:created.team.id}]})
  130. })
  131. it('denies grants, caller identity and arbitrary fields supplied by the model', async () => {
  132. for (const extra of [{canManageTeam:true},{owner:'other'},{agentId:randomUUID()},{token:'pretend'}])
  133. await expect(call('create_agent',{requestId:randomUUID(),name:'伪造',profile:'default',...extra})).rejects.toMatchObject({status:400})
  134. await expect(call('list_agents',{owner:'other'})).rejects.toMatchObject({status:400})
  135. expect(store.list(owner,'agent')).toHaveLength(1)
  136. const ordinary = store.createAgent(owner,{name:'普通',profile:'default'})
  137. expect(ordinary.canManageTeam).toBe(true)
  138. store.updateAgent(owner,manager.id,{archived:true})
  139. await expect(call('list_agents')).rejects.toMatchObject({status:403})
  140. })
  141. it('checks source permission before and after asynchronous discovery', async () => {
  142. denied.add('writer')
  143. expect((await call('list_sources')).sources.map((s:any)=>s.profile)).toEqual(['default'])
  144. await expect(call('create_agent',{requestId:randomUUID(),name:'越权',profile:'writer'})).rejects.toMatchObject({status:403})
  145. denied.clear()
  146. vi.spyOn(nodes,'sources').mockImplementationOnce(async () => {
  147. store.updateAgent(owner,manager.id,{archived:true})
  148. return {sources:[{nodeId:'local',profile:'writer',name:'写作'}],errors:[]}
  149. })
  150. await expect(call('create_agent',{requestId:randomUUID(),name:'竞态',profile:'writer'})).rejects.toMatchObject({status:403})
  151. expect(store.list(owner,'agent')).toHaveLength(1)
  152. })
  153. it('never revives a previous turn grant after archiving and restoring the Bot', async () => {
  154. store.updateAgent(owner,manager.id,{archived:true})
  155. store.updateAgent(owner,manager.id,{archived:false})
  156. await expect(call('list_agents')).rejects.toMatchObject({status:403})
  157. store.put(owner,'turn',work.id,{...work,teamManagementRevision:store.require<WorkspaceAgent>(owner,'agent',manager.id).revision})
  158. expect((await call('list_agents')).agents).toHaveLength(1)
  159. })
  160. it('prevents account crossing and modification of teams controlled by another Agent', async () => {
  161. const {team,member} = await buildTeam()
  162. const other = store.createAgent('other',{name:'外部',profile:'default'})
  163. await expect(call('create_team',{requestId:randomUUID(),name:'跨账号',memberIds:[other.id]})).rejects.toMatchObject({status:404})
  164. store.updateConversation(owner,team.id,{administratorId:member.id})
  165. await expect(call('start_team_task',{requestId:randomUUID(),teamId:team.id,title:'越权',content:'开始'})).rejects.toMatchObject({status:403})
  166. expect((await call('list_teams')).teams).toEqual([])
  167. })
  168. it.each(['account','archive','cancel','stop','source','member','complete'])('revokes a live grant on %s changes', async reason => {
  169. if(reason==='account')active=false
  170. if(reason==='archive')store.updateAgent(owner,manager.id,{archived:true})
  171. if(reason==='source')denied.add('default')
  172. if(reason==='cancel')store.put(owner,'turn',work.id,{...work,cancelRequested:true})
  173. if(reason==='complete')store.put(owner,'turn',work.id,{...work,status:'complete'})
  174. if(reason==='stop')store.put(owner,'run',work.runId,{...store.require(owner,'run',work.runId),stopRequested:true})
  175. if(reason==='member')store.put(owner,'conversation',work.conversationId,{...store.require(owner,'conversation',work.conversationId),memberIds:[]})
  176. await expect(call('create_agent',{requestId:randomUUID(),name:'不应创建',profile:'default'})).rejects.toMatchObject({status:403})
  177. })
  178. it('rejects conflicting retries, duplicate teams and recursive starts', async () => {
  179. const requestId = randomUUID()
  180. await call('create_agent',{requestId,name:'甲',profile:'default'})
  181. await expect(call('create_agent',{requestId,name:'乙',profile:'default'})).rejects.toMatchObject({code:'idempotency_conflict'})
  182. const {team} = await buildTeam()
  183. await expect(call('create_team',{requestId:randomUUID(),name:team.name,memberIds:team.memberIds})).rejects.toMatchObject({code:'duplicate_team_name'})
  184. store.put(owner,'turn',work.id,{...work,conversationId:team.id})
  185. await expect(call('start_team_task',{requestId:randomUUID(),teamId:team.id,title:'递归',content:'再开始'})).rejects.toMatchObject({code:'team_task_reentrant'})
  186. })
  187. it('keeps operation receipts through a store reopen without creating duplicates', async () => {
  188. const args = {requestId:randomUUID(),name:'持久成员',profile:'default'}
  189. const first = await call('create_agent',args)
  190. runtime.close(); store.close()
  191. store = new WorkspaceStore(home)
  192. runtime = new WorkspaceRuntime(store,nodes,uploads,()=>active)
  193. vi.spyOn(runtime,'wake').mockImplementation(()=>{})
  194. store.put(owner,'turn',work.id,{...work,status:'running'})
  195. expect(await call('create_agent',args)).toEqual(first)
  196. expect(store.list(owner,'agent')).toHaveLength(2)
  197. })
  198. })
  199. describe('Bot self rules', () => {
  200. it('reads full rules, edits only itself and keeps its current tools usable across retries', async () => {
  201. const instructions = '完整长期角色规则。'.repeat(400)
  202. store.put(owner,'agent',manager.id,{...manager,instructions,voice:'custom',voiceCustom:'原先语气'})
  203. const before = (await selfCall('get')).agent
  204. expect(before).toMatchObject({id:manager.id,revision:1,instructions})
  205. const request = {requestId:randomUUID(),expectedRevision:before.revision,name:'核验助手',instructions:'先核验再回答',description:'事实助手',job:'核对来源',antiJobs:['不编造来源'],voice:'rigorous',voiceCustom:null,actBias:'ask_key_then_act'}
  206. const updated = await selfCall('update',request)
  207. expect(updated.agent).toMatchObject({id:manager.id,name:request.name,instructions:request.instructions,description:request.description,job:request.job,antiJobs:request.antiJobs,voice:request.voice,actBias:request.actBias,revision:2})
  208. expect(updated.agent.voiceCustom).toBeUndefined()
  209. expect(await selfCall('update',request)).toEqual(updated)
  210. expect((await selfCall('get')).agent).toEqual(updated.agent)
  211. expect(store.require<WorkspaceAgent>(owner,'agent',manager.id)).toMatchObject({profile:'default',nodeId:'local',revision:2,memoryEnabled:true})
  212. expect(store.require<WorkspaceConversation>(owner,'conversation',work.conversationId).name).toBe(request.name)
  213. expect(store.require<Work>(owner,'turn',work.id).teamManagementRevision).toBe(2)
  214. expect((await call('list_agents')).agents[0].name).toBe(request.name)
  215. await expect(selfCall('update',{...request,instructions:'同一编号改写'})).rejects.toMatchObject({code:'idempotency_conflict'})
  216. expect(upstream).toEqual([])
  217. })
  218. it('rejects stale revisions and empty edits without overwriting a user edit', async () => {
  219. const before = (await selfCall('get')).agent
  220. store.updateAgent(owner,manager.id,{instructions:'用户刚保存的规则'})
  221. await expect(selfCall('update',{requestId:randomUUID(),expectedRevision:before.revision,instructions:'旧版本覆盖'})).rejects.toMatchObject({code:'agent_revision_conflict'})
  222. await expect(selfCall('update',{requestId:randomUUID(),expectedRevision:2})).rejects.toMatchObject({code:'invalid_workspace_request'})
  223. expect(store.require<WorkspaceAgent>(owner,'agent',manager.id)).toMatchObject({instructions:'用户刚保存的规则',revision:2})
  224. expect(store.require<Work>(owner,'turn',work.id).teamManagementRevision).toBe(1)
  225. await expect(call('list_agents')).rejects.toMatchObject({code:'team_tools_forbidden'})
  226. })
  227. it.each([
  228. ['agentId',randomUUID()], ['profile','writer'], ['nodeId','other'], ['approvalPolicy','allow'],
  229. ['canManageTeam',true], ['canCollaborate',true], ['memoryEnabled',false], ['envs',{vm:true}], ['modelSettings',null],
  230. ])('rejects the protected %s field', async (field, value) => {
  231. await expect(selfCall('update',{requestId:randomUUID(),expectedRevision:1,instructions:'新规则',[field as string]:value})).rejects.toMatchObject({code:'invalid_workspace_request'})
  232. expect(store.require<WorkspaceAgent>(owner,'agent',manager.id)).toEqual(manager)
  233. })
  234. it('keeps self edits available to a persistent assigned Bot without granting team management', async () => {
  235. store.put(owner,'run',work.runId,{...store.require<WorkspaceRun>(owner,'run',work.runId),assignmentId:randomUUID()})
  236. store.put(owner,'turn',work.id,{...work,teamManagementRevision:undefined})
  237. const catalog = runtime.knowledgeTools.catalog(owner,work.id,false).map(tool => tool.name)
  238. expect(catalog).toEqual(expect.arrayContaining(['workspace_get_self_rules','workspace_update_self_rules']))
  239. expect(catalog).not.toContain('workspace_memory_write')
  240. await selfCall('update',{requestId:randomUUID(),expectedRevision:1,instructions:'自己的后续规则'})
  241. expect(store.require<Work>(owner,'turn',work.id).teamManagementRevision).toBeUndefined()
  242. await expect(call('list_agents')).rejects.toMatchObject({code:'team_tools_forbidden'})
  243. })
  244. it('keeps temporary helpers and remote references from editing their rules', async () => {
  245. for (const restricted of [{temporaryGoalId:randomUUID()},{remoteAgentId:randomUUID()}]) {
  246. store.put(owner,'agent',manager.id,{...manager,...restricted})
  247. if ('temporaryGoalId' in restricted) expect(()=>runtime.knowledgeTools.catalog(owner,work.id,false)).toThrow()
  248. else expect(runtime.knowledgeTools.catalog(owner,work.id,false).map(tool => tool.name)).not.toContain('workspace_update_self_rules')
  249. await expect(selfCall('update',{requestId:randomUUID(),expectedRevision:1,instructions:'不能修改'})).rejects.toMatchObject({code:'knowledge_tool_forbidden'})
  250. }
  251. })
  252. })
  253. async function mount(signal = new AbortController().signal, onFailure: (error: Error) => void = () => {}) {
  254. const lease = await createWorkspaceToolLease({
  255. target, profile:manager.profile, workId:work.id,
  256. session:()=>({runtimeId:'runtime',storedId:'stored'}),signal,
  257. assertActive:()=>{runtime.teamTools.assertTurn(owner,work.id)},
  258. catalog:()=>[...runtime.teamTools.catalog(owner,work.id),...runtime.knowledgeTools.catalog(owner,work.id,false)],
  259. call:(id,args)=>runtime.knowledgeTools.handles(id)?runtime.knowledgeTools.call(owner,work.id,id,args,false):runtime.teamTools.call(owner,work.id,id,args),onFailure,
  260. })
  261. leases.push(lease); await lease.bind()
  262. return lease
  263. }
  264. function http(path:string, body:unknown, options:RequestInit={}) {
  265. return fetch(binding!.bridge_url+path,{method:'POST',headers:{'Content-Type':'application/json',Authorization:`Bearer ${binding!.token}`},body:JSON.stringify(body),...options})
  266. }
  267. describe('loopback tool lease', () => {
  268. it('prepares context only on a supporting bridge and within the active session grant', async () => {
  269. const request = vi.mocked(target.session.request).getMockImplementation()!
  270. vi.mocked(target.session.request).mockImplementation(async (path, options) => {
  271. const response = await request(path, options)
  272. if (path.endsWith('/bind') || path.endsWith('/context'))
  273. return {...response,body:Buffer.from(JSON.stringify({ok:true,native_tools:true,context_projection_version:1}))}
  274. return response
  275. })
  276. await mount()
  277. expect(await leases[0]!.prepareContext!('latest full rules', 'user original')).toBe(true)
  278. expect(upstream.at(-1)).toMatchObject({path:expect.stringContaining('/context'),body:{
  279. session_id:binding!.session_id,generation:binding!.generation,prompt:'latest full rules',history_text:'user original',
  280. }})
  281. active=false
  282. await expect(leases[0]!.prepareContext!('changed','changed')).rejects.toThrow()
  283. })
  284. it('leaves context untouched when an older bridge has no context selector', async () => {
  285. await mount()
  286. expect(await leases[0]!.prepareContext!('latest rules','user original')).toBe(false)
  287. expect(upstream.some(entry=>entry.path.endsWith('/context'))).toBe(false)
  288. })
  289. it.each(['503', 'upstream_unavailable'])('retains the original grant through %s renewal failures, then renews after recovery', async failure => {
  290. vi.useFakeTimers({toFake: ['Date', 'setInterval', 'clearInterval']})
  291. try {
  292. const failed = vi.fn()
  293. await mount(undefined, failed)
  294. const original = {...binding}, request = vi.mocked(target.session.request).getMockImplementation()!
  295. let unavailable = true
  296. vi.mocked(target.session.request).mockImplementation(async (path, options) => {
  297. if (path.endsWith('/bind') && unavailable) {
  298. if (failure === 'upstream_unavailable') throw new HttpError(502, 'Unable to reach Hermes', 'upstream_unavailable')
  299. return {status: 503, body: Buffer.from('{}'), headers: new Headers()}
  300. }
  301. return request(path, options)
  302. })
  303. await vi.advanceTimersByTimeAsync(5 * 60_000)
  304. expect(failed).not.toHaveBeenCalled()
  305. expect((await http('/tools/list', {})).status).toBe(200)
  306. unavailable = false
  307. await vi.advanceTimersByTimeAsync(30_000)
  308. expect(binding).toMatchObject({generation: original.generation, token: original.token, bridge_url: original.bridge_url})
  309. expect(binding!.expires_at).toBeGreaterThan(original.expires_at)
  310. expect(failed).not.toHaveBeenCalled()
  311. } finally { vi.useRealTimers() }
  312. })
  313. it.each([401, 503])('retires a lease on rejected renewal or its original expiry (HTTP %s)', async status => {
  314. vi.useFakeTimers({toFake: ['Date', 'setInterval', 'clearInterval']})
  315. try {
  316. const failed = vi.fn()
  317. await mount(undefined, failed)
  318. const request = vi.mocked(target.session.request).getMockImplementation()!
  319. vi.mocked(target.session.request).mockImplementation(async (path, options) => path.endsWith('/bind')
  320. ? {status, body: Buffer.from('{}'), headers: new Headers()} : request(path, options))
  321. await vi.advanceTimersByTimeAsync((status === 401 ? 5 : 30) * 60_000)
  322. expect(failed).toHaveBeenCalledOnce()
  323. expect(failed.mock.calls[0]![0]).toMatchObject({code: status === 401 ? 'team_tools_bind_failed' : 'team_tools_expired'})
  324. await expect(http('/tools/list', {})).rejects.toThrow()
  325. } finally { vi.useRealTimers() }
  326. })
  327. it('updates self rules through the bridge without ending the current tool lease', async () => {
  328. await mount()
  329. const catalog = await (await http('/tools/list',{})).json()
  330. const readId = catalog.tools.find((tool:any)=>tool.name==='workspace_get_self_rules').id
  331. const writeId = catalog.tools.find((tool:any)=>tool.name==='workspace_update_self_rules').id
  332. const current = await (await http('/tools/call',{callId:'read-self',toolId:readId,arguments:{}})).json()
  333. const request = {callId:'update-self',toolId:writeId,arguments:{requestId:randomUUID(),expectedRevision:current.structuredContent.agent.revision,name:'自己的新名字',instructions:'自己的新规则'}}
  334. const updated = await (await http('/tools/call',request)).json()
  335. expect(updated.structuredContent.agent).toMatchObject({id:manager.id,name:'自己的新名字',instructions:'自己的新规则',revision:2})
  336. expect(await (await http('/tools/call',request)).json()).toEqual(updated)
  337. expect((await http('/tools/list',{})).status).toBe(200)
  338. const after = await (await http('/tools/call',{callId:'read-updated',toolId:readId,arguments:{}})).json()
  339. expect(after.structuredContent.agent).toEqual(updated.structuredContent.agent)
  340. store.updateAgent(owner,manager.id,{instructions:'用户再修改'})
  341. expect((await http('/tools/list',{})).status).toBe(403)
  342. })
  343. it.each(['initializing','agent_initialization_failed','binding_busy','bridge_catalog_unreachable'])('preserves the Profile and the %s binding failure without reflecting upstream secrets',async code=>{
  344. manager.profile='yaoer'
  345. vi.mocked(target.session.request).mockImplementation(async path=>({status:path.endsWith('/bind')?409:200,
  346. body:Buffer.from(JSON.stringify(path.endsWith('/bind')?{code,detail:'api-key=fixture-secret'}:{ok:true})),headers:new Headers()}))
  347. await expect(mount()).rejects.toMatchObject({code:`hermes_bridge_${code}`,message:expect.stringContaining('Profile「yaoer」')})
  348. await expect(leases[0]!.bind()).rejects.not.toThrow('fixture-secret')
  349. })
  350. it('binds only the current session and executes the real HTTP catalog and call protocol', async () => {
  351. const lease = await mount()
  352. expect(binding).toMatchObject({native_tools:true,session_id:'runtime',stored_session_id:'stored',profile:'default'})
  353. const catalog = await (await http('/tools/list',{})).json()
  354. expect(catalog.tools.map((t:any)=>t.name)).toEqual(expect.arrayContaining(['workspace_create_agent','workspace_create_team','workspace_assign_task','workspace_review_assignment','workspace_finish_team_task']))
  355. expect(JSON.stringify(catalog)).not.toContain(binding!.token)
  356. const body={callId:'same-call',toolId:catalog.tools.find((t:any)=>t.name==='workspace_create_agent').id,arguments:{requestId:randomUUID(),name:'HTTP 成员',profile:'writer'}}
  357. const [first,second]=await Promise.all([http('/tools/call',body).then(r=>r.json()),http('/tools/call',body).then(r=>r.json())])
  358. expect(second).toEqual(first)
  359. expect(first.structuredContent.agent.canManageTeam).toBe(true)
  360. expect(store.list(owner,'agent')).toHaveLength(2)
  361. expect((await http('/tools/call',{...body,arguments:{...body.arguments,name:'替换'}})).status).toBe(409)
  362. await lease.dispose()
  363. expect(upstream.at(-1)).toMatchObject({path:expect.stringContaining('/unbind'),body:{session_id:'runtime',generation:binding!.generation}})
  364. await expect(http('/tools/list',{})).rejects.toThrow()
  365. })
  366. it('rejects missing tokens, browser origins, forged envelope identities and revoked permissions', async () => {
  367. await mount()
  368. expect((await http('/tools/list',{}, {headers:{'Content-Type':'application/json'}})).status).toBe(401)
  369. expect((await http('/tools/list',{}, {headers:{'Content-Type':'application/json',Authorization:`Bearer ${binding!.token}`,Origin:'http://evil.test'}})).status).toBe(403)
  370. expect((await http('/tools/list',{owner:'other'})).status).toBe(400)
  371. expect((await http('/tools/call',{toolId:'workspace_list_agents',arguments:{},callId:'id',owner:'other'})).status).toBe(400)
  372. store.updateAgent(owner,manager.id,{archived:true})
  373. expect((await http('/tools/list',{})).status).toBe(403)
  374. })
  375. it('reports the underlying error message instead of masking tool failures', async () => {
  376. const lease = await createWorkspaceToolLease({
  377. target, profile:manager.profile, workId:work.id,
  378. session:()=>({runtimeId:'runtime',storedId:'stored'}),signal:new AbortController().signal,
  379. assertActive:()=>{},catalog:()=>[{id:'workspace_probe',name:'workspace_probe',description:'probe',inputSchema:{}}],
  380. call:async()=>{throw new Error('虚拟机磁盘已满')},onFailure:()=>{},
  381. })
  382. leases.push(lease); await lease.bind()
  383. const catalog = await (await http('/tools/list',{})).json()
  384. const result = await (await http('/tools/call',{toolId:catalog.tools[0].id,arguments:{},callId:'probe'})).json()
  385. expect(result.isError).toBe(true)
  386. expect(JSON.parse(result.content[0].text)).toMatchObject({error:'虚拟机磁盘已满',code:'team_tool_failed'})
  387. })
  388. it('keeps plain authorization stop messages readable', async () => {
  389. let active = true
  390. const lease = await createWorkspaceToolLease({
  391. target, profile:manager.profile, workId:work.id,
  392. session:()=>({runtimeId:'runtime',storedId:'stored'}),signal:new AbortController().signal,
  393. assertActive:()=>{if(!active)throw new Error('运行已停止')},
  394. catalog:()=>[],call:async()=>({}),onFailure:()=>{},
  395. })
  396. leases.push(lease); await lease.bind()
  397. active = false
  398. const response = await http('/tools/list',{})
  399. expect(response.status).toBe(500)
  400. expect(await response.json()).toMatchObject({error:'运行已停止',code:'team_tool_failed'})
  401. })
  402. it('checks readiness per Profile and rejects unavailable or remote bridges', async () => {
  403. await requireTeamToolBridge(target,'writer')
  404. expect(target.session.request).toHaveBeenCalledWith(expect.stringContaining('/capabilities'),expect.objectContaining({search:new URLSearchParams({profile:'writer'})}))
  405. await expect(requireTeamToolBridge({...target,url:new URL('http://192.168.1.20:9119')},'default')).rejects.toMatchObject({code:'team_tools_local_required'})
  406. vi.mocked(target.session.request).mockResolvedValueOnce({status:404,body:Buffer.from('{}'),headers:new Headers()})
  407. await expect(requireTeamToolBridge(target,'default')).rejects.toMatchObject({code:'team_tools_unavailable'})
  408. })
  409. it('closes a cancelled lease even if binding has not returned yet', async () => {
  410. let finish!:()=>void
  411. vi.mocked(target.session.request).mockImplementation(async(path,options)=>{
  412. upstream.push({path,body:options?.body})
  413. if(path.endsWith('/bind')){
  414. binding=options?.body as any
  415. await new Promise<void>(resolve=>{finish=resolve})
  416. }
  417. return {status:200,body:Buffer.from('{"ok":true,"native_tools":true}'),headers:new Headers()}
  418. })
  419. const controller=new AbortController()
  420. const pending=mount(controller.signal)
  421. await vi.waitFor(()=>expect(finish).toBeTypeOf('function'))
  422. controller.abort();finish()
  423. await expect(pending).rejects.toThrow()
  424. await vi.waitFor(()=>expect(upstream.at(-1)?.path).toContain('/unbind'))
  425. await expect(http('/tools/list',{})).rejects.toThrow()
  426. })
  427. it('rejects an expired lease and another lease token', async () => {
  428. await mount()
  429. const first={...binding}
  430. const firstCatalog=await (await http('/tools/list',{})).json()
  431. await mount()
  432. const secondCatalog=await (await http('/tools/list',{})).json()
  433. expect(secondCatalog.tools.map((t:any)=>t.id)).not.toEqual(firstCatalog.tools.map((t:any)=>t.id))
  434. expect((await http('/tools/call',{toolId:firstCatalog.tools[0].id,arguments:{},callId:'cross-lease'})).status).toBe(404)
  435. expect((await http('/tools/list',{}, {headers:{'Content-Type':'application/json',Authorization:`Bearer ${first.token}`}})).status).toBe(401)
  436. vi.useFakeTimers({toFake:['Date']})
  437. try {
  438. vi.setSystemTime(Date.now()+31*60_000)
  439. expect((await http('/tools/list',{})).status).toBe(410)
  440. } finally { vi.useRealTimers() }
  441. })
  442. })
  443. it('legacy VM managers create members on server Hermes and cannot rebind a running Agent',async()=>{
  444. store.put(owner,'agent',manager.id,{...manager,execution:'computer'})
  445. expect((await call('create_agent',{requestId:randomUUID(),name:'统一成员',profile:'default',execution:'profile'})).agent).toMatchObject({execution:'profile',canManageTeam:true})
  446. expect(()=>store.updateAgent(owner,manager.id,{execution:'profile'})).toThrowError(expect.objectContaining({code:'agent_execution_busy'}))
  447. })
  448. it('creates a task-scoped helper once and rejects promotion or use outside its goal',async()=>{
  449. const {team,task}=await buildTeam()
  450. const goal=runtime.tasks.begin(owner,task,manager,'完成当前任务',{conversationId:work.conversationId,runId:work.runId,agentId:manager.id})
  451. const runnerId=randomUUID()
  452. nodes.runnerTarget=()=>({...target,runner:{id:runnerId,helperRetirement:true}} as GatewayTarget)
  453. const request={requestId:randomUUID(),goalId:goal.id,name:'临时调研',title:'调研资料',brief:'只核验当前任务的资料'}
  454. const created=await call('create_helper',request)
  455. expect(await call('create_helper',request)).toEqual(created)
  456. expect(created.helper).toMatchObject({execution:'profile',canManageTeam:true,temporaryGoalId:goal.id,helperActivation:1,helperRunnerId:runnerId,createdByAgentId:manager.id})
  457. expect(store.list<WorkspaceConversation>(owner,'conversation').some(c=>c.kind==='direct'&&c.memberIds.includes(created.helper.id))).toBe(false)
  458. expect(store.taskMemberIds(owner,team,goal.id)).toContain(created.helper.id)
  459. expect(store.taskMemberIds(owner,team,randomUUID())).not.toContain(created.helper.id)
  460. expect(()=>store.updateAgent(owner,created.helper.id,{archived:false})).toThrow()
  461. expect(()=>store.createGroup(owner,{name:'错误团队',memberIds:[manager.id,created.helper.id],administratorId:manager.id})).toThrow()
  462. expect(()=>runtime.send(owner,team.id,{requestId:randomUUID(),taskId:store.createTask(owner,team.id,{title:'另一个任务'}).id,content:'不应跨任务',mentionIds:[created.helper.id]})).toThrow()
  463. })
  464. it('retires task helpers after cancellation and retries cleanup without reviving the helper',async()=>{
  465. const {team,task}=await buildTeam()
  466. const goal=runtime.tasks.begin(owner,task,manager,'完成任务',{conversationId:work.conversationId,runId:work.runId,agentId:manager.id})
  467. nodes.runnerTarget=()=>({...target,runner:{id:randomUUID(),helperRetirement:true}} as GatewayTarget)
  468. const created=await call('create_helper',{requestId:randomUUID(),goalId:goal.id,name:'将退役的助手',title:'工作',brief:'完成工作'})
  469. const cleanup=vi.spyOn(runtime,'retireHelper').mockResolvedValue(undefined)
  470. store.put(owner,'turn',work.id,{...work,status:'complete',planned:true});const source=store.require<any>(owner,'run',work.runId);source.status='complete';store.saveRun(owner,source)
  471. vi.mocked(runtime.wake).mockRestore()
  472. await runtime.stopTask(owner,team.id,task.id)
  473. await vi.waitFor(()=>expect(store.require<any>(owner,'agent',created.helper.id).cleanupState).toBe('complete'))
  474. expect(store.require<any>(owner,'agent',created.helper.id)).toMatchObject({archived:true,temporaryGoalId:goal.id})
  475. expect(cleanup).toHaveBeenCalledTimes(1)
  476. expect(()=>store.updateAgent(owner,created.helper.id,{archived:false})).toThrow()
  477. })