present-open.host.spec.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235
  1. /** Native delivery actions resolve the viewed Session's current workspace files. */
  2. import { mkdtemp, rm, readFile, writeFile, mkdir, realpath, symlink, unlink } from 'node:fs/promises'
  3. import { tmpdir } from 'node:os'
  4. import { join } from 'node:path'
  5. import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local'
  6. import { WorkspaceFiles } from '@deepseek-ai/dsh-api-workspace-files'
  7. import { Context } from '@deepseek-ai/cordis'
  8. import { HostConnectionService } from '@deepseek-ai/dsh-client-connection'
  9. import type { BrowserAuth } from '@deepseek-ai/dsh-client-connection/src/browser-auth.ts'
  10. import { SessionId } from '@deepseek-ai/dsh-session'
  11. import type { SessionEvent } from '@deepseek-ai/dsh-session'
  12. import { SessionQueryError } from '@deepseek-ai/dsh-session-query'
  13. import type { SessionEventReadRequest } from '@deepseek-ai/dsh-session-query'
  14. import { afterEach, describe, expect, it, vi } from 'vitest'
  15. import { registerPresentOpen } from '../src/present-open.ts'
  16. import { presentedFileUrl, PRESENT_OPEN_PATH } from '../src/presented.ts'
  17. const cleanups: Array<() => Promise<unknown>> = []
  18. afterEach(async () => {
  19. for (const cleanup of cleanups.reverse()) await cleanup()
  20. cleanups.length = 0
  21. vi.restoreAllMocks()
  22. })
  23. async function fixture() {
  24. const root = await mkdtemp(join(tmpdir(), 'dsh-present-open-'))
  25. cleanups.push(() => rm(root, { recursive: true, force: true }))
  26. const cwd = join(root, 'workspace')
  27. await mkdir(cwd)
  28. const file = { path: '日记模板.docx' }
  29. await writeFile(join(cwd, file.path), Uint8Array.of(80, 75, 0, 255))
  30. const ctx = new Context()
  31. cleanups.push(() => ctx.fiber.dispose())
  32. const session: { cwd?: string } = { cwd }
  33. await ctx.plugin(LocalFileSystem, { cwd })
  34. ctx.provide('sandboxPolicy', { workspaceRoot: cwd } as never)
  35. await ctx.plugin({
  36. inject: ['fs', 'sandboxPolicy'],
  37. apply: (scope) => { new WorkspaceFiles(scope, { maxBytes: 1024, maxFileBytes: 1024, maxLines: 100, maxEntries: 100 }) },
  38. })
  39. const resolveAgent = vi.fn<Context['sessionController']['resolveAgent']>(() => { throw new Error('Agent activation is unavailable') })
  40. const readEvent = vi.fn(async (request: SessionEventReadRequest) => {
  41. if (request.sessionId !== 'owner') throw new SessionQueryError('missing', 'SESSION_QUERY_SESSION_NOT_FOUND')
  42. if (request.seq !== 7) throw new SessionQueryError('missing', 'SESSION_QUERY_EVENT_NOT_FOUND')
  43. return { session, target: { type: 'deliverables/presented', data: { turn: 1, callId: 'present-call', files: [file] } } as SessionEvent }
  44. })
  45. ctx.provide('sessionQuery', { readEvent } as never)
  46. const opener = vi.fn(async (_request: { path: string; action?: 'reveal' }, _signal: AbortSignal) => ({ opened: true as const }))
  47. ctx.provide('sessionController', { resolveAgent, openWorkspacePath: opener, workspaceDesktop: () => ({ name: 'desktop', available: true, fileManager: 'finder' }) } as never)
  48. const connection = new HostConnectionService(ctx, [], {} as BrowserAuth)
  49. const fiber = ctx.plugin({ inject: ['connection', 'sessionQuery', 'sessionController', 'workspaceFiles', 'fs', 'sandboxPolicy'], apply: registerPresentOpen })
  50. await fiber
  51. const handler = connection.createSharedFetchHandler('/api')
  52. const open = (query = '?sessionId=owner&seq=7&index=0', signal?: AbortSignal) => handler.fetch(new Request(
  53. `http://localhost${PRESENT_OPEN_PATH}${query}`, { method: 'POST', signal: signal ?? null },
  54. ))
  55. return { root, cwd, ctx, fiber, file, session, readEvent, open, opener, handler, resolveAgent }
  56. }
  57. describe('Presented workspace file native open route', () => {
  58. it('opens the source itself with current bytes and leaves it intact at disposal', async () => {
  59. const { cwd, open, file, fiber, opener, handler, ctx } = await fixture()
  60. const source = await realpath(join(cwd, file.path))
  61. expect(presentedFileUrl(SessionId('owner'), 7, 0)).toBe(`${PRESENT_OPEN_PATH}?sessionId=owner&seq=7&index=0`)
  62. expect((await handler.fetch(new Request(`http://localhost${PRESENT_OPEN_PATH}`))).status).toBe(404)
  63. expect((await handler.fetch(new Request('http://localhost/api/present.download?sessionId=owner&seq=7&index=0'))).status).toBe(404)
  64. for (const contents of ['current source', 'edited source']) {
  65. await writeFile(source, contents)
  66. const response = await open()
  67. expect(response.status).toBe(204)
  68. expect(response.headers.get('content-disposition')).toBeNull()
  69. expect(response.headers.get('cache-control')).toBe('no-store')
  70. expect(opener.mock.lastCall?.[0].path).toBe(source)
  71. expect(await readFile(opener.mock.lastCall![0].path, 'utf8')).toBe(contents)
  72. }
  73. expect(ctx.get('attachments')).toBeUndefined()
  74. await fiber.dispose()
  75. expect(await readFile(source, 'utf8')).toBe('edited source')
  76. expect((await open()).status).toBe(404)
  77. })
  78. it('resolves inherited declarations in the viewed fork workspace', async () => {
  79. const { root, file, readEvent, session, open, opener } = await fixture()
  80. const fork = join(root, 'fork')
  81. await mkdir(fork)
  82. await writeFile(join(fork, file.path), 'child source')
  83. session.cwd = fork
  84. readEvent.mockResolvedValueOnce({ session, target: { type: 'deliverables/presented', data: { turn: 1, callId: 'inherited', files: [file] } } as SessionEvent })
  85. expect((await open('?sessionId=fork&seq=7&index=0')).status).toBe(204)
  86. expect(opener.mock.lastCall?.[0].path).toBe(await realpath(join(fork, file.path)))
  87. })
  88. it.each(['', '?seq=7&index=0', '?sessionId=owner&index=0', '?sessionId=owner&seq=7',
  89. '?sessionId=owner&seq=-1&index=0', '?sessionId=owner&seq=7&index=0.1',
  90. '?sessionId=owner&seq=9007199254740992&index=0', '?sessionId=owner&seq=7&index=9007199254740992',
  91. ])('rejects invalid coordinates before reading: %s', async (query) => {
  92. const { open, readEvent } = await fixture()
  93. expect((await open(query)).status).toBe(400)
  94. expect(readEvent).not.toHaveBeenCalled()
  95. })
  96. it('refuses unrelated Sessions, absent events, and undeclared file indices', async () => {
  97. const { open, readEvent, session, opener } = await fixture()
  98. expect((await open('?sessionId=other&seq=7&index=0')).status).toBe(404)
  99. expect((await open('?sessionId=owner&seq=8&index=0')).status).toBe(404)
  100. expect((await open('?sessionId=owner&seq=7&index=1')).status).toBe(404)
  101. readEvent.mockResolvedValueOnce({ session, target: { type: 'turn/start' } as SessionEvent })
  102. expect((await open()).status).toBe(404)
  103. expect(opener).not.toHaveBeenCalled()
  104. })
  105. it.each([null, [], 'invalid', {}, { turn: 1, callId: 'call', files: null },
  106. { turn: 1, callId: 'call', files: [null] }, { turn: 1, callId: 'call', files: [{ path: '' }] },
  107. { turn: 1, callId: 'call', files: [{ path: 'a', description: 1 }] },
  108. ])('refuses malformed recorded delivery data: %j', async (data) => {
  109. const { open, readEvent, session, opener } = await fixture()
  110. readEvent.mockResolvedValueOnce({ session, target: { type: 'deliverables/presented', data } as unknown as SessionEvent })
  111. expect((await open()).status).toBe(404)
  112. expect(opener).not.toHaveBeenCalled()
  113. })
  114. it('reports removed files and directories without launching', async () => {
  115. const { cwd, file, open, opener } = await fixture()
  116. await unlink(join(cwd, file.path))
  117. expect((await open()).status).toBe(404)
  118. file.path = '.'
  119. expect((await open()).status).toBe(404)
  120. expect(opener).not.toHaveBeenCalled()
  121. })
  122. it('opens external regular files through absolute and relative paths but refuses final symlinks', async () => {
  123. const { root, cwd, file, open, opener } = await fixture()
  124. const outside = join(root, 'outside.txt')
  125. await writeFile(outside, 'outside')
  126. const source = join(cwd, file.path)
  127. await unlink(source)
  128. await symlink(outside, source)
  129. expect((await open()).status).toBe(404)
  130. expect(opener).not.toHaveBeenCalled()
  131. for (const path of ['../outside.txt', outside]) {
  132. file.path = path
  133. expect((await open()).status).toBe(204)
  134. expect(opener.mock.lastCall?.[0].path).toBe(await realpath(outside))
  135. }
  136. })
  137. it('reports query and launcher failures without leaking Host paths and allows retry', async () => {
  138. const { open, readEvent, opener } = await fixture()
  139. readEvent.mockRejectedValueOnce(new SessionQueryError('corrupt', 'SESSION_QUERY_CORRUPT_SESSION'))
  140. expect((await open()).status).toBe(500)
  141. opener.mockRejectedValueOnce(new Error('/private/host/path'))
  142. const response = await open()
  143. expect(response.status).toBe(500)
  144. expect(await response.text()).not.toContain('/private/host/path')
  145. expect((await open()).status).toBe(204)
  146. })
  147. it('honors cancellation before lookup', async () => {
  148. const { open, readEvent } = await fixture()
  149. const controller = new AbortController()
  150. controller.abort(new Error('cancelled'))
  151. await expect(open(undefined, controller.signal)).rejects.toThrow('cancelled')
  152. expect(readEvent).not.toHaveBeenCalled()
  153. })
  154. it('disposal aborts and awaits a pending native launch', async () => {
  155. const entered = Promise.withResolvers<undefined>()
  156. const aborted = Promise.withResolvers<undefined>()
  157. const release = Promise.withResolvers<undefined>()
  158. const { open, fiber, opener } = await fixture()
  159. opener.mockImplementation(async (_request, signal) => {
  160. signal.addEventListener('abort', () => { aborted.resolve(undefined) }, { once: true })
  161. entered.resolve(undefined)
  162. await release.promise
  163. signal.throwIfAborted()
  164. return { opened: true }
  165. })
  166. const request = open().catch((error: unknown) => error)
  167. await entered.promise
  168. let disposed = false
  169. const disposal = fiber.dispose().then(() => { disposed = true })
  170. await aborted.promise
  171. expect(disposed).toBe(false)
  172. release.resolve(undefined)
  173. await Promise.all([request, disposal])
  174. })
  175. })
  176. it('reports the serving desktop and reveals only an authorized declared source', async () => {
  177. const { cwd, file, open, opener, handler } = await fixture()
  178. const info = await handler.fetch(new Request('http://localhost/api/present.host'))
  179. expect(await info.json()).toEqual({ name: 'desktop', available: true, fileManager: 'finder' })
  180. expect((await open('?sessionId=owner&seq=7&index=0&action=reveal')).status).toBe(204)
  181. expect(opener).toHaveBeenCalledWith({ path: await realpath(join(cwd, file.path)), action: 'reveal' }, expect.any(AbortSignal))
  182. expect((await open('?sessionId=owner&seq=7&index=0&action=delete')).status).toBe(400)
  183. file.path = '..'
  184. expect((await open('?sessionId=owner&seq=7&index=0&action=reveal')).status).toBe(404)
  185. expect(opener).toHaveBeenCalledOnce()
  186. })
  187. it('refuses native actions when the configured Host desktop is unavailable', async () => {
  188. const { ctx, open, opener, handler } = await fixture()
  189. vi.spyOn(ctx.sessionController, 'workspaceDesktop').mockReturnValue({ name: 'desktop', available: false, fileManager: 'finder' })
  190. expect(await (await handler.fetch(new Request('http://localhost/api/present.host'))).json()).toMatchObject({ available: false })
  191. for (const action of ['open', 'reveal']) {
  192. expect((await open(`?sessionId=owner&seq=7&index=0&action=${action}`)).status).toBe(409)
  193. }
  194. expect(opener).not.toHaveBeenCalled()
  195. })
  196. it('refuses native opening without a matching Host mapping even when a same-name Host file exists', async () => {
  197. const { ctx, open, opener } = await fixture()
  198. const mapping = vi.spyOn(ctx.fs, 'processPathFromHostPath').mockReturnValue(undefined)
  199. expect((await open()).status).toBe(422)
  200. mapping.mockReturnValue('/another-filesystem/file')
  201. expect((await open()).status).toBe(422)
  202. expect(opener).not.toHaveBeenCalled()
  203. })
  204. it('opens a viewed child Session without activating an Agent', async () => {
  205. const { session, readEvent, open, opener, resolveAgent } = await fixture()
  206. readEvent.mockResolvedValueOnce({ session, target: { type: 'deliverables/presented', data: { turn: 1, callId: 'child', files: [{ path: '日记模板.docx' }] } } as SessionEvent })
  207. expect((await open('?sessionId=child&seq=7&index=0')).status).toBe(204)
  208. expect(opener).toHaveBeenCalledOnce()
  209. expect(resolveAgent).not.toHaveBeenCalled()
  210. })
  211. it('uses the deployment workspace root when the viewed Session has no cwd', async () => {
  212. const { session, open, cwd, file, opener } = await fixture()
  213. delete session.cwd
  214. expect((await open()).status).toBe(204)
  215. expect(opener.mock.lastCall?.[0].path).toBe(await realpath(join(cwd, file.path)))
  216. })