workspaceRoutes.test.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324
  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 request from 'supertest'
  7. import type Koa from 'koa'
  8. import { createApplication, type ApplicationRuntime } from '../../src/server/app'
  9. import { loadServerConfig } from '../../src/server/config'
  10. import { LocalAuthStore, type LocalUser } from '../../src/server/localAuth'
  11. import { WorkspaceAssets } from '../../src/server/workspaceAssets'
  12. let home: string, runtime: ApplicationRuntime, cookie: string, csrf: string, upstream: string[]
  13. const first: LocalUser = {
  14. id: 'first',
  15. username: 'first',
  16. role: 'admin',
  17. enabled: true,
  18. mustChangePassword: false,
  19. createdAt: 1,
  20. updatedAt: 1,
  21. }
  22. class TestAuth extends LocalAuthStore {
  23. override current(ctx: Koa.Context) {
  24. return this.require(ctx)
  25. }
  26. override require(ctx: Koa.Context) {
  27. return { ...first, id: ctx.get('x-test-user') || 'first' }
  28. }
  29. override requireAdmin(ctx: Koa.Context) {
  30. return this.require(ctx)
  31. }
  32. override canUseSource() { return true }
  33. override currentFromCookieHeader() {
  34. return first
  35. }
  36. override isUserActive() {
  37. return true
  38. }
  39. }
  40. function req(method: 'get' | 'post' | 'put' | 'patch' | 'delete', path: string, user = 'first') {
  41. return request(runtime.app.callback())
  42. [method](path)
  43. .set('Host', '127.0.0.1:15300')
  44. .set('Origin', 'http://127.0.0.1:15300')
  45. .set('Cookie', cookie)
  46. .set('X-CSRF-Token', csrf)
  47. .set('x-test-user', user)
  48. }
  49. beforeEach(async () => {
  50. home = mkdtempSync(join(tmpdir(), 'yaoyao-workspace-routes-'))
  51. upstream = []
  52. const config = loadServerConfig({
  53. HERMES_YAOYAO_HOME: home,
  54. HERMES_YAOYAO_UPSTREAM: 'http://127.0.0.1:19119',
  55. HERMES_YAOYAO_ALLOWED_HOSTS: '127.0.0.1,localhost',
  56. })
  57. runtime = createApplication({
  58. config,
  59. auth: new TestAuth(home, false),
  60. fetchImpl: (async (input) => {
  61. const path = new URL(String(input)).pathname
  62. upstream.push(path)
  63. const body =
  64. path === '/api/pair/v1/capabilities'
  65. ? {protocolVersion:1,serviceType:'yaoyao-web',nodeId:'11111111-1111-4111-8111-111111111111',fingerprint:'f'.repeat(64)}
  66. : path === '/api/pair/v1/claim'
  67. ? {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'}
  68. : path === '/api/status'
  69. ? { auth_required: true }
  70. : path === '/api/auth/me'
  71. ? { user_id: 'upstream' }
  72. : path.endsWith('/api/profiles')
  73. ? { profiles: [{ name: 'default', display_name: '基础 Agent' }] }
  74. : { ok: true }
  75. return new Response(JSON.stringify(body), {
  76. status: path.includes('/plugins/') ? 404 : 200,
  77. headers: { 'Content-Type': 'application/json' },
  78. })
  79. }) as typeof fetch,
  80. })
  81. const b = await request(runtime.app.callback())
  82. .get('/api/app/bootstrap')
  83. .set('Host', '127.0.0.1:15300')
  84. .expect(200)
  85. cookie = (b.headers['set-cookie'] as unknown as string[]).map((v) => v.split(';')[0]).join('; ')
  86. csrf = b.body.csrfToken
  87. })
  88. afterEach(() => {
  89. runtime.close()
  90. rmSync(home, { recursive: true, force: true })
  91. })
  92. describe('application workspace HTTP contract', () => {
  93. it('archives native chat reply attachments without collecting tool process files', async () => {
  94. const emit = runtime.realtime.broker.onNativeEvent
  95. emit('first', 'default', 'native-session', { type: 'tool.complete', payload: {
  96. output: '![example](/tmp/skill-example.png)', output_path: '/tmp/process.csv',
  97. } })
  98. emit('first', 'default', 'native-session', { type: 'message.complete', payload: {
  99. row_id: 'reply', text: '下载 [报告](/tmp/final-report.pdf)',
  100. tools: [{ output_path: '/tmp/internal.csv' }],
  101. } })
  102. await vi.waitFor(() => expect(runtime.workspace.list('first', 'file')).toHaveLength(1))
  103. const page = await req('get', '/api/app/files').expect(200)
  104. expect(page.body.items.map((item: { name: string }) => item.name)).toEqual(['final-report.pdf'])
  105. expect(page.body.items[0]).toMatchObject({ sender: 'agent', messageId: 'reply' })
  106. })
  107. it('archives workspace reply files without collecting examples in tools', async () => {
  108. const agent = (await req('post', '/api/app/agents').send({ name: '附件测试', profile: 'default' }).expect(201)).body.agent
  109. const conversation = (await req('get', '/api/app/conversations').expect(200)).body.conversations[0]
  110. const assets = new WorkspaceAssets(runtime.workspace, runtime.workspaceRuntime.nodes, home)
  111. await assets.archive('first', {
  112. id: 'reply', conversationId: conversation.id, agentId: agent.id, role: 'assistant',
  113. seq: 1, content: '![结果](/tmp/final.png)', reasoning: '', status: 'complete',
  114. attachments: [], tools: [{ name: 'skill_view', output: '![示例](/tmp/example.png)' }], createdAt: Date.now(),
  115. })
  116. const page = await req('get', '/api/app/files').expect(200)
  117. expect(page.body.items.map((item: { name: string }) => item.name)).toEqual(['final.png'])
  118. })
  119. it('accepts only child pairing, hides credentials, and scopes address edits to the owner', async () => {
  120. const code = new URL('yaoyao://pair')
  121. 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)
  122. await req('post','/api/app/nodes').send({name:'legacy',url:'http://child.test:9119',username:'user',password:'password'}).expect(400)
  123. await req('post','/api/app/nodes').send({name:'child',qrPayload:code.href.replace('://pair','://login')}).expect(400)
  124. await req('post','/api/app/nodes').send({name:'child',qrPayload:code.href}).expect(201)
  125. const listing=await req('get','/api/app/nodes').expect(200), node=listing.body.nodes[0]
  126. expect(node.transport).toBe('paired-web'); expect(JSON.stringify(listing.body)).not.toContain('delegated-token'); expect(node.secret).toBeUndefined()
  127. expect((await req('get','/api/app/nodes','second').expect(200)).body.nodes).toEqual([])
  128. await req('patch',`/api/app/nodes/${node.id}`,'second').send({name:'other',url:'http://new-ip.test:15300'}).expect(404)
  129. await req('patch',`/api/app/nodes/${node.id}`).send({name:'new name',url:'http://new-ip.test:15300'}).expect(200)
  130. const updated=(await req('get','/api/app/nodes').expect(200)).body.nodes[0]
  131. expect(updated.id).toBe(node.id); expect(updated.url).toBe('http://new-ip.test:15300/')
  132. const sources=(await req('get','/api/app/agents/sources').expect(200)).body.sources
  133. expect(sources.some((s:any)=>s.nodeId===node.id && s.profile==='default')).toBe(true)
  134. })
  135. it('archives native-chat uploads through the standard file API with user ownership', async () => {
  136. const assets = new WorkspaceAssets(runtime.workspace, runtime.workspaceRuntime.nodes, home)
  137. await assets.archiveText(
  138. 'first',
  139. '{"path":"/tmp/native-upload.txt"}',
  140. 'local',
  141. 'default',
  142. 'native-session',
  143. 'native-message',
  144. 'user',
  145. )
  146. const page = await req('get', '/api/app/files').expect(200)
  147. expect(page.body.items[0]).toMatchObject({
  148. name: 'native-upload.txt',
  149. sender: 'user',
  150. profile: 'default',
  151. })
  152. expect(upstream).toContain('/api/files/download')
  153. expect((await req('get', '/api/app/files', 'second')).body.items).toEqual([])
  154. expect(upstream.some((p) => p.includes('/plugins/'))).toBe(false)
  155. })
  156. it('works without plugin discovery, installation, or any plugin HTTP request', async () => {
  157. const capabilities = (await req('get', '/api/app/capabilities').expect(200)).body.features
  158. expect(capabilities).toContain('editableGroups')
  159. expect(capabilities).not.toContain('immutableGroups')
  160. await req('get', '/api/app/files').expect(200)
  161. await req('get', '/api/app/voice/runtime').expect(200)
  162. await req('get', '/api/app/system/update/status').expect(200)
  163. await req('post', '/api/app/plugins/yaoyao/reconcile').send({}).expect(410)
  164. await req('get', '/api/plugins/yaoyao/v1/capabilities').expect(410)
  165. expect(upstream.some((p) => p.includes('/plugins/'))).toBe(false)
  166. })
  167. it('changes members over HTTP while protecting the administrator and ownership', async () => {
  168. const a = (
  169. await req('post', '/api/app/agents').send({ name: '编辑', profile: 'default' }).expect(201)
  170. ).body.agent
  171. const b = (
  172. await req('post', '/api/app/agents').send({ name: '审查', profile: 'default' }).expect(201)
  173. ).body.agent
  174. const c = (
  175. await req('post', '/api/app/conversations')
  176. .send({ name: '团队', memberIds: [a.id, b.id], administratorId: a.id })
  177. .expect(201)
  178. ).body.conversation
  179. const rows = await req('get', '/api/app/conversations').expect(200)
  180. expect(rows.body.conversations).toHaveLength(3)
  181. await req('patch', `/api/app/conversations/${c.id}`)
  182. .send({ memberIds: [b.id], administratorId: b.id })
  183. .expect(400)
  184. await req('patch', `/api/app/conversations/${c.id}`).send({ memberIds: [a.id] }).expect(200)
  185. expect((await req('get', `/api/app/conversations/${c.id}`)).body.conversation.memberIds).toEqual([a.id])
  186. await req('patch', `/api/app/conversations/${c.id}`).send({ memberIds: [a.id, b.id] }).expect(200)
  187. await req('get', `/api/app/conversations/${c.id}`, 'second').expect(404)
  188. await req('patch', `/api/app/agents/${a.id}`, 'second').send({ name: '入侵' }).expect(404)
  189. expect((await req('get', '/api/app/events', 'second')).body.events).toEqual([])
  190. expect(upstream.some((p) => p.includes('sessions'))).toBe(false)
  191. })
  192. it('creates, lists, renames, selects, and deletes group tasks without changing remote members', async () => {
  193. const a = (
  194. await req('post', '/api/app/agents').send({ name: '本机管理员', profile: 'default' }).expect(201)
  195. ).body.agent
  196. const b = (
  197. await req('post', '/api/app/agents').send({ name: '远程成员', nodeId: 'local', profile: 'default' }).expect(201)
  198. ).body.agent
  199. const conversation = (
  200. await req('post', '/api/app/conversations')
  201. .send({ name: '多任务团队', memberIds: [a.id, b.id], administratorId: a.id })
  202. .expect(201)
  203. ).body.conversation
  204. const initial = (await req('get', `/api/app/conversations/${conversation.id}/tasks`).expect(200)).body.tasks
  205. expect(initial).toHaveLength(1)
  206. expect(initial[0]).toMatchObject({
  207. conversationId: conversation.id,
  208. title: '新任务',
  209. titleSource: 'automatic',
  210. messageCount: 0,
  211. })
  212. const task = (
  213. await req('post', `/api/app/conversations/${conversation.id}/tasks`)
  214. .send({ title: '远程 Agent 调研' })
  215. .expect(201)
  216. ).body.task
  217. expect(task).toMatchObject({ title: '远程 Agent 调研', titleSource: 'user' })
  218. const renamed = (
  219. await req('patch', `/api/app/conversations/${conversation.id}/tasks/${task.id}`)
  220. .send({ title: '远程 Agent 复核' })
  221. .expect(200)
  222. ).body.task
  223. expect(renamed).toMatchObject({ title: '远程 Agent 复核', titleSource: 'user' })
  224. const detail = (await req('get', `/api/app/conversations/${conversation.id}?taskId=${task.id}`).expect(200)).body
  225. expect(detail.task.id).toBe(task.id)
  226. expect(detail.tasks.map((row: { id: string }) => row.id)).toContain(task.id)
  227. expect(detail.messages).toEqual([])
  228. expect(detail.conversation).toMatchObject({ memberIds: [a.id, b.id], administratorId: a.id })
  229. expect((await req('put', `/api/app/conversations/${conversation.id}/tasks/${task.id}/read`).send({ seq: 100 }).expect(200)).body.task)
  230. .toMatchObject({ readSeq: 0, lastSeq: 0, unreadCount: 0 })
  231. await req('get', `/api/app/conversations/${conversation.id}/tasks`, 'second').expect(404)
  232. await req('delete', `/api/app/conversations/${conversation.id}/tasks/${task.id}`).expect(200)
  233. const remaining = (await req('get', `/api/app/conversations/${conversation.id}/tasks`).expect(200)).body.tasks
  234. expect(remaining.map((row: { id: string }) => row.id)).not.toContain(task.id)
  235. expect((await req('get', `/api/app/conversations/${conversation.id}`)).body.conversation)
  236. .toMatchObject({ memberIds: [a.id, b.id], administratorId: a.id })
  237. await req('delete', `/api/app/conversations/${conversation.id}/tasks/${initial[0].id}`).expect(200)
  238. const replacement = (await req('get', `/api/app/conversations/${conversation.id}/tasks`).expect(200)).body.tasks
  239. expect(replacement).toHaveLength(1)
  240. expect(replacement[0].id).not.toBe(initial[0].id)
  241. })
  242. it('archives files on the Web server, preserves native library fields, and blocks cross-user downloads', async () => {
  243. const f = (
  244. await req('post', '/api/app/uploads')
  245. .attach('files', Buffer.from('hello'), { filename: 'hello.txt', contentType: 'text/plain' })
  246. .expect(201)
  247. ).body.files[0]
  248. expect(f.path).toBeUndefined()
  249. expect(runtime.uploads.cleanupUncommitted(0)).toBe(0)
  250. const list = await req('get', '/api/app/files').expect(200),
  251. item = list.body.items[0]
  252. expect(typeof item.id).toBe('number')
  253. expect(item.path).toBe(`/api/app/files/${f.id}/download`)
  254. expect(item).toMatchObject({ name: 'hello.txt', size: 5, exists: true, archiveStatus: 'ready' })
  255. expect((await req('get', item.path).expect(200)).text).toBe('hello')
  256. await req('get', item.path, 'second').expect(404)
  257. await req('get', `/api/app/files/${item.id}/download`, 'second').expect(404)
  258. expect((await req('get', '/api/app/files', 'second')).body.total).toBe(0)
  259. expect(upstream.some((p) => p.includes('/files'))).toBe(false)
  260. })
  261. it('does not serve uploaded HTML or SVG as executable same-origin previews', async () => {
  262. const f = (
  263. await req('post', '/api/app/uploads')
  264. .attach('files', Buffer.from('<svg onload="alert(1)"/>'), {
  265. filename: 'unsafe.svg',
  266. contentType: 'image/svg+xml',
  267. })
  268. .expect(201)
  269. ).body.files[0]
  270. const response = await req('get', `/api/app/files/${f.id}/preview`).expect(200)
  271. expect(response.headers['content-type']).toContain('application/octet-stream')
  272. expect(response.headers['content-disposition']).toContain('attachment')
  273. })
  274. it('persists encrypted voice credentials while giving each user their own selected voice', async () => {
  275. await req('put', '/api/app/admin/duplex-voice')
  276. .send({
  277. apiKey: 'private-test-key',
  278. voices: [
  279. { id: 'a', name: '甲' },
  280. { id: 'b', name: '乙' },
  281. ],
  282. currentVoiceId: 'a',
  283. })
  284. .expect(200)
  285. const publicResponse = await req('get', '/api/app/admin/duplex-voice').expect(200)
  286. expect(publicResponse.body.hasApiKey).toBe(true)
  287. expect(publicResponse.body.apiKey).toBeUndefined()
  288. await req('put', '/api/app/voice/current-voice', 'second')
  289. .send({ currentVoiceId: 'b' })
  290. .expect(200)
  291. expect((await req('get', '/api/app/voice/runtime')).body.currentVoiceId).toBe('a')
  292. expect((await req('get', '/api/app/voice/runtime', 'second')).body.currentVoiceId).toBe('b')
  293. expect(
  294. readFileSync(join(home, 'workspace.sqlite3')).includes(Buffer.from('private-test-key')),
  295. ).toBe(false)
  296. })
  297. it('stores monotonic profile-scoped context snapshots without a plugin', async () => {
  298. const path = '/api/app/session-context/session-one?profile=default'
  299. const b = { usedTokens: 200, limitTokens: 1000, percent: 20, observedAt: 20 }
  300. await req('put', path).send(b).expect(200)
  301. await req('put', path)
  302. .send({ ...b, usedTokens: 10, observedAt: 10 })
  303. .expect(200)
  304. expect((await req('get', path)).body.snapshot).toMatchObject({ ...b, sessionId: 'session-one' })
  305. expect((await req('get', path, 'second')).body.snapshot).toBeNull()
  306. expect(
  307. (await req('get', '/api/app/session-context/session-one?profile=other')).body.snapshot,
  308. ).toBeNull()
  309. })
  310. it('protects internal Hermes session IDs on native HTTP endpoints', async () => {
  311. runtime.workspace.put('first', 'binding', 'binding', {
  312. storedId: 'internal-session',
  313. runtimeId: 'internal-runtime',
  314. aliases: ['old-id'],
  315. })
  316. await req('get', '/api/app/sessions/internal-session/messages').expect(404)
  317. await req('get', '/api/sessions/old-id/messages', 'second').expect(404)
  318. expect(upstream.some((p) => p.includes('internal-session') || p.includes('old-id'))).toBe(false)
  319. })
  320. })