subaccountPermissions.test.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144
  1. // @vitest-environment node
  2. import { randomUUID } from 'node:crypto'
  3. import { mkdtempSync, rmSync } from 'node:fs'
  4. import { tmpdir } from 'node:os'
  5. import { join } from 'node:path'
  6. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
  7. import request from 'supertest'
  8. import { createApplication, type ApplicationRuntime } from '../../src/server/app'
  9. import { loadServerConfig } from '../../src/server/config'
  10. import { LocalAuthStore } from '../../src/server/localAuth'
  11. import type { Work } from '../../src/server/workspaceScheduler'
  12. let runtime: ApplicationRuntime, home: string, admin: ReturnType<typeof request.agent>, child: ReturnType<typeof request.agent>
  13. let adminCSRF: string, childCSRF: string, childID: string, upstreamPaths: string[]
  14. const host = '127.0.0.1:15300'
  15. function call(client: ReturnType<typeof request.agent>, method: 'get' | 'post' | 'put' | 'patch' | 'delete', path: string, csrf = '') {
  16. return client[method](path).set('Host', host).set('Origin', `http://${host}`).set('X-CSRF-Token', csrf)
  17. }
  18. async function signInChild(password = 'child-password') {
  19. const boot = await call(child, 'get', '/api/app/bootstrap').expect(200)
  20. const login = await call(child, 'post', '/api/app/login', boot.body.csrfToken).send({ username: 'child', password }).expect(200)
  21. childCSRF = login.body.csrfToken
  22. }
  23. beforeEach(async () => {
  24. home = mkdtempSync(join(tmpdir(), 'yaoyao-subaccount-')); upstreamPaths = []
  25. const config = loadServerConfig({ HERMES_YAOYAO_HOME: home, HERMES_UPSTREAM_URL: 'http://127.0.0.1:1' })
  26. runtime = createApplication({ config, fetchImpl: (async input => {
  27. const path = new URL(String(input)).pathname; upstreamPaths.push(path)
  28. if (path === '/api/status') return Response.json({ auth_required: true })
  29. if (path === '/api/auth/me') return Response.json({ user_id: 'service' })
  30. if (path === '/api/profiles') return Response.json({ profiles: [{ name: 'allowed' }, { name: 'private' }] })
  31. return Response.json({ ok: true })
  32. }) as typeof fetch })
  33. vi.spyOn(runtime.workspaceRuntime, 'wake').mockImplementation(() => {})
  34. admin = request.agent(runtime.app.callback()); child = request.agent(runtime.app.callback())
  35. const boot = await call(admin, 'get', '/api/app/bootstrap').expect(200)
  36. const setup = await call(admin, 'post', '/api/app/setup', boot.body.csrfToken).send({ username: 'owner', password: 'owner-password' }).expect(200)
  37. adminCSRF = setup.body.csrfToken
  38. const created = await call(admin, 'post', '/api/app/admin/users', adminCSRF).send({ username: 'child', password: 'temporary-password', assignedProfiles: ['allowed'] }).expect(201)
  39. childID = created.body.id
  40. await signInChild('temporary-password')
  41. const changed = await call(child, 'put', '/api/app/account/credentials', childCSRF).send({ currentPassword: 'temporary-password', newPassword: 'child-password' }).expect(200)
  42. childCSRF = changed.body.csrfToken
  43. })
  44. afterEach(() => { runtime.close(); rmSync(home, { recursive: true, force: true }); vi.restoreAllMocks() })
  45. describe('subaccount Bot-only security boundary with real login sessions', () => {
  46. async function startBotTurn() {
  47. const agent = (await call(child, 'post', '/api/app/agents', childCSRF).send({ name: '子账号负责人', profile: 'allowed' }).expect(201)).body.agent
  48. const conversation = (await call(child, 'get', '/api/app/conversations').expect(200)).body.conversations[0]
  49. await call(child, 'post', `/api/app/conversations/${conversation.id}/messages`, childCSRF)
  50. .send({ requestId: randomUUID(), content: '帮我创建一个长期研究助手' }).expect(202)
  51. const work = runtime.workspace.list<Work>(childID, 'turn')[0]!
  52. // Simulate the grant normally issued after a healthy Hermes bridge handshake.
  53. runtime.workspace.put(childID, 'turn', work.id, { ...work, status: 'running', teamManagementRevision: agent.revision })
  54. return { agent, work }
  55. }
  56. it('lets a subaccount Bot create persistent Bots from its assigned source with owner isolation and idempotency', async () => {
  57. const { agent, work } = await startBotTurn()
  58. const tools = runtime.workspaceRuntime.teamTools
  59. expect(tools.catalog(childID, work.id).map(tool => tool.name)).toContain('workspace_create_agent')
  60. const sources = await tools.call(childID, work.id, 'workspace_list_sources', {})
  61. expect(sources).toMatchObject({ sources: [{ nodeId: 'local', profile: 'allowed' }] })
  62. const input = { requestId: randomUUID(), name: '研究助手', profile: 'allowed', instructions: '核对资料并提供来源' }
  63. const result = await tools.call(childID, work.id, 'workspace_create_agent', input)
  64. expect(await tools.call(childID, work.id, 'workspace_create_agent', input)).toEqual(result)
  65. const agents = (await call(child, 'get', '/api/app/agents').expect(200)).body.agents
  66. expect(agents).toHaveLength(2)
  67. expect(agents.find((item: any) => item.name === input.name)).toMatchObject({
  68. profile: 'allowed', createdByAgentId: agent.id, createdFromRunId: work.runId, canManageTeam: true,
  69. })
  70. expect((await call(child, 'get', '/api/app/conversations').expect(200)).body.conversations).toHaveLength(2)
  71. expect((await call(admin, 'get', '/api/app/agents').expect(200)).body.agents).toEqual([])
  72. })
  73. it('rejects unassigned sources and revokes a subaccount Bot creation grant after reassignment', async () => {
  74. const { work } = await startBotTurn()
  75. const tools = runtime.workspaceRuntime.teamTools
  76. for (const source of [{ profile: 'private' }, { profile: 'allowed', nodeId: randomUUID() }]) {
  77. await expect(tools.call(childID, work.id, 'workspace_create_agent', {
  78. requestId: randomUUID(), name: '越权助手', ...source,
  79. })).rejects.toMatchObject({ status: 403, code: 'agent_source_forbidden' })
  80. }
  81. await call(admin, 'patch', `/api/app/admin/users/${childID}`, adminCSRF).send({ assignedProfiles: [] }).expect(200)
  82. await expect(tools.call(childID, work.id, 'workspace_create_agent', {
  83. requestId: randomUUID(), name: '撤权后助手', profile: 'allowed',
  84. })).rejects.toMatchObject({ status: 403 })
  85. expect(runtime.workspace.list(childID, 'agent')).toHaveLength(1)
  86. })
  87. it('persists assignment, filters sources, hides native profiles and rejects unassigned/local-node spoofing', async () => {
  88. expect((await call(child, 'get', '/api/app/bootstrap')).body.profiles).toEqual([])
  89. expect((await call(child, 'get', '/api/profiles')).body.profiles).toEqual([])
  90. const sources = await call(child, 'get', '/api/app/agents/sources').expect(200)
  91. expect(sources.body.sources.map((s: any) => s.profile)).toEqual(['allowed'])
  92. expect(new LocalAuthStore(home).canUseSource(childID, 'local', 'allowed')).toBe(true)
  93. expect(new LocalAuthStore(home).canUseSource(childID, 'local', 'private')).toBe(false)
  94. const caps = await call(child, 'get', '/api/app/capabilities').expect(200)
  95. expect(caps.body.features).not.toContain('nodes')
  96. await call(child, 'post', '/api/app/agents', childCSRF).send({ name: 'mine', profile: 'allowed' }).expect(201)
  97. for (const input of [{ profile: 'private' }, { profile: 'allowed', nodeId: randomUUID() }, { profile: 'ALLOWED' }]) {
  98. await call(child, 'post', '/api/app/agents', childCSRF).send({ name: 'forbidden', ...input }).expect(403)
  99. }
  100. })
  101. it('denies native REST, realtime, relay, settings secrets and administration before any upstream request', async () => {
  102. upstreamPaths.length = 0
  103. for (const path of ['/Users/test/.hermes/cache/private.png', '/Users/test/.hermes/profiles/private/images/private.png', '/Users/test/Agents/private.txt', '/attachments/private.txt', '/api/sessions', '/api/profiles/sessions', '/api/profile-identities', '/api/files/download?path=/tmp/private', '/api/realtime/capabilities', '/api/app/hermes-bot/local/api/profiles', '/api/app/admin/users', '/api/app/system/push-config', '/api/app/voice/runtime', '/api/app/tts/settings', '/api/app/models', '/api/app/nodes', '/api/app/session-context/test']) {
  104. await call(child, 'get', path).expect(403)
  105. }
  106. for (const path of ['/api/realtime/channels', '/api/app/nodes', '/api/app/agents/remote', '/api/app/agents/remote/', '/api/app/pairings', '/api/app/admin/users']) {
  107. await call(child, 'post', path, childCSRF).send({ channel: 'chat' }).expect(403)
  108. }
  109. expect(upstreamPaths).toEqual([])
  110. })
  111. it('keeps agents, conversations, files, runs and interactions isolated by owner', async () => {
  112. const a = await call(admin, 'post', '/api/app/agents', adminCSRF).send({ name: 'admin bot', profile: 'private' }).expect(201)
  113. const c = (await call(admin, 'get', '/api/app/conversations')).body.conversations[0]
  114. expect((await call(child, 'get', '/api/app/agents')).body.agents).toEqual([])
  115. expect((await call(child, 'get', '/api/app/conversations')).body.conversations).toEqual([])
  116. for (const path of [`/api/app/conversations/${c.id}`, `/api/app/conversations/${c.id}/messages`, `/api/app/files/${randomUUID()}/download`]) await call(child, 'get', path).expect(404)
  117. await call(child, 'patch', `/api/app/agents/${a.body.agent.id}`, childCSRF).send({ name: 'stolen' }).expect(404)
  118. await call(child, 'post', `/api/app/conversations/${c.id}/messages`, childCSRF).send({ requestId: randomUUID(), content: 'stolen' }).expect(404)
  119. await call(child, 'post', `/api/app/runs/${randomUUID()}/reconcile`, childCSRF).send({}).expect(404)
  120. await call(child, 'post', `/api/app/interactions/${randomUUID()}/respond`, childCSRF).send({ answer: 'approve' }).expect(404)
  121. })
  122. it('invalidates sessions and stops existing work when assignment is revoked, including old agent references', async () => {
  123. const a = (await call(child, 'post', '/api/app/agents', childCSRF).send({ name: 'mine', profile: 'allowed' }).expect(201)).body.agent
  124. const c = (await call(child, 'get', '/api/app/conversations')).body.conversations[0]
  125. await call(child, 'post', `/api/app/conversations/${c.id}/messages`, childCSRF).send({ requestId: randomUUID(), content: 'allowed' }).expect(202)
  126. const stop = vi.spyOn(runtime.workspaceRuntime, 'stopConversation')
  127. await call(admin, 'patch', `/api/app/admin/users/${childID}`, adminCSRF).send({ assignedProfiles: [] }).expect(200)
  128. expect(stop).toHaveBeenCalledWith(childID, c.id)
  129. await call(child, 'get', '/api/app/agents').expect(401)
  130. await signInChild()
  131. expect((await call(child, 'get', '/api/app/agents/sources')).body.sources).toEqual([])
  132. await call(child, 'post', `/api/app/conversations/${c.id}/messages`, childCSRF).send({ requestId: randomUUID(), content: 'revoked' }).expect(403)
  133. await call(child, 'patch', `/api/app/agents/${a.id}`, childCSRF).send({ name: 'revoked' }).expect(403)
  134. await call(child, 'post', '/api/app/conversations', childCSRF).send({ name: 'bypass', memberIds: [a.id], administratorId: a.id }).expect(403)
  135. })
  136. })