keyless-smoke.e2e.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308
  1. import { createServer } from 'node:http'
  2. import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises'
  3. import { tmpdir } from 'node:os'
  4. import { join } from 'node:path'
  5. import { fileURLToPath } from 'node:url'
  6. import { promisify } from 'node:util'
  7. import { zstdDecompress } from 'node:zlib'
  8. import { execa } from 'execa'
  9. import { describe, expect, it } from 'vitest'
  10. const binScript = fileURLToPath(new URL('../../../apps/cli/src/bin.ts', import.meta.url))
  11. const patchPath = fileURLToPath(new URL('./keyless.patch.yml', import.meta.url))
  12. const minimalPatchPath = fileURLToPath(new URL('../minimal.patch.yml', import.meta.url))
  13. const repoRoot = fileURLToPath(new URL('../../..', import.meta.url))
  14. const decompress = promisify(zstdDecompress)
  15. function waitForLine(
  16. lines: string[],
  17. predicate: (value: Record<string, unknown>) => boolean,
  18. stderr: () => string,
  19. ): Promise<Record<string, unknown>> {
  20. return new Promise((resolve, reject) => {
  21. const deadline = Date.now() + 30_000
  22. const poll = (): void => {
  23. while (lines.length > 0) {
  24. const line = lines.shift()!
  25. if (!line.trim()) continue
  26. try {
  27. const value = JSON.parse(line) as Record<string, unknown>
  28. if (predicate(value)) {
  29. resolve(value)
  30. return
  31. }
  32. } catch {
  33. reject(new Error(`non-JSON stdout from JSON-RPC agent runtime: ${line}`))
  34. return
  35. }
  36. }
  37. if (Date.now() >= deadline) {
  38. reject(new Error(`timed out waiting for JSON-RPC response; stderr=${stderr()}`))
  39. return
  40. }
  41. setTimeout(poll, 10)
  42. }
  43. poll()
  44. })
  45. }
  46. describe('Python SDK dsh profile keyless smoke', () => {
  47. it.each([
  48. { label: 'reports max-token turns with the default mapping config', envValue: undefined },
  49. { label: 'reports max-token turns with mapping enabled through env', envValue: 'true' },
  50. { label: 'reports max-token turns with mapping disabled through env', envValue: 'false' },
  51. ])('$label', async ({ envValue }) => {
  52. const root = await mkdtemp(join(tmpdir(), 'dsh-python-sdk-runtime-smoke-'))
  53. const modelRequests: Record<string, unknown>[] = []
  54. const modelServer = createServer((request, response) => {
  55. let body = ''
  56. request.setEncoding('utf8')
  57. request.on('data', (chunk: string) => { body += chunk })
  58. request.on('end', () => {
  59. modelRequests.push(JSON.parse(body) as Record<string, unknown>)
  60. response.writeHead(200, { 'content-type': 'text/event-stream' })
  61. response.write('data: {"choices":[{"delta":{"role":"assistant","content":null}}]}\n\n')
  62. response.write('data: {"choices":[{"delta":{"content":"done"}}]}\n\n')
  63. response.write('data: {"choices":[{"delta":{},"finish_reason":"length"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}\n\n')
  64. response.end('data: [DONE]\n\n')
  65. })
  66. })
  67. await new Promise<void>(resolve => modelServer.listen(0, '127.0.0.1', resolve))
  68. const address = modelServer.address()
  69. if (address === null || typeof address === 'string') throw new Error('model server did not bind a TCP port')
  70. // The line-predicate protocol driving below is the genuinely custom part;
  71. // execa owns spawn, the deadline, and exit settlement around it.
  72. const child = execa(process.execPath, [
  73. '--import',
  74. 'tsx/esm',
  75. binScript,
  76. '--profile',
  77. 'sdk',
  78. '--patch',
  79. patchPath,
  80. ], {
  81. cwd: repoRoot,
  82. env: {
  83. DSH_HOME: join(root, '.dsh'),
  84. DSH_PERMISSION_MODE: 'danger-full-access',
  85. DSH_TELEMETRY_DISABLED: '1',
  86. DEEPSEEK_API_KEY: 'keyless-smoke-no-call',
  87. DEEPSEEK_BASE_URL: `http://127.0.0.1:${address.port}`,
  88. ...(envValue === undefined ? {} : { DSH_MAX_TOKENS_AS_SUCCESS: envValue }),
  89. },
  90. timeout: 35_000,
  91. killSignal: 'SIGKILL',
  92. reject: false,
  93. })
  94. const lines: string[] = []
  95. let stdoutBuffer = ''
  96. let stderr = ''
  97. child.stdout.on('data', (chunk: Buffer) => {
  98. stdoutBuffer += chunk.toString('utf8')
  99. const parts = stdoutBuffer.split('\n')
  100. stdoutBuffer = parts.pop() ?? ''
  101. lines.push(...parts)
  102. })
  103. child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString('utf8') })
  104. try {
  105. child.stdin.write(`${JSON.stringify({
  106. jsonrpc: '2.0',
  107. id: 1,
  108. method: 'initialize',
  109. params: { cwd: root, provider: 'deepseek-official', model: 'deepseek-v4-pro', maxTokens: 1234 },
  110. })}\n`)
  111. const initialized = await waitForLine(lines, value => value.id === 1, () => stderr)
  112. expect(initialized).toMatchObject({
  113. jsonrpc: '2.0',
  114. id: 1,
  115. result: { serverInfo: { name: 'deepseek-harness-sdk-runtime' } },
  116. })
  117. child.stdin.write(`${JSON.stringify({
  118. jsonrpc: '2.0',
  119. id: 2,
  120. method: 'session/prompt',
  121. params: { sessionId: 'main', contentBlocks: [{ type: 'text', text: 'inspect tools' }] },
  122. })}\n`)
  123. const prompt = await waitForLine(lines, value => value.id === 2, () => stderr)
  124. expect(prompt).toMatchObject({
  125. jsonrpc: '2.0',
  126. id: 2,
  127. result: { messageId: expect.any(String) as unknown },
  128. })
  129. const turnEnd = await waitForLine(lines, (value) => {
  130. if (value.method !== 'session.event') return false
  131. const params = value.params as Record<string, unknown> | undefined
  132. const event = params?.event as Record<string, unknown> | undefined
  133. return params?.sessionId === 'main' && event?.type === 'turn/end'
  134. }, () => stderr)
  135. expect(turnEnd).toMatchObject({
  136. jsonrpc: '2.0',
  137. method: 'session.event',
  138. params: {
  139. sessionId: 'main',
  140. event: {
  141. type: 'turn/end',
  142. data: { reason: { kind: 'max-tokens' } },
  143. },
  144. },
  145. })
  146. const tools = modelRequests[0]?.tools as { function?: { name?: string } }[]
  147. expect(modelRequests[0]?.max_tokens).toBe(1234)
  148. expect(tools.map(tool => tool.function?.name).sort()).toEqual([
  149. 'bash',
  150. 'edit',
  151. 'read',
  152. 'read_image',
  153. 'subagent',
  154. 'todo_write',
  155. 'write',
  156. ])
  157. child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id: 3, method: 'shutdown' })}\n`)
  158. const shutdown = await waitForLine(lines, value => value.id === 3, () => stderr)
  159. expect(shutdown).toMatchObject({ jsonrpc: '2.0', id: 3, result: {} })
  160. const exit = await child
  161. expect(exit.exitCode, `signal=${String(exit.signal)}; stderr=${stderr}`).toBe(0)
  162. const sessionsRoot = join(root, '.dsh', 'sessions')
  163. const files = await readdir(sessionsRoot, { recursive: true })
  164. const log = files.find(file => file.endsWith('.jsonl.zstd'))
  165. expect(log).toBeDefined()
  166. const compressed = await readFile(join(sessionsRoot, log!))
  167. expect(compressed.subarray(0, 4).toString('hex')).toBe('28b52ffd')
  168. expect(JSON.parse((await decompress(compressed)).toString())).toMatchObject({ type: 'session', id: 'main' })
  169. } finally {
  170. // No-op after exit; reject: false settles on every outcome, so cleanup never races teardown.
  171. child.kill('SIGKILL')
  172. await child
  173. await new Promise<void>(resolve => modelServer.close(() => { resolve() }))
  174. await rm(root, { recursive: true, force: true })
  175. }
  176. }, 40_000)
  177. it('keeps the minimal overlay on an explicit model-facing allowlist', async () => {
  178. const root = await mkdtemp(join(tmpdir(), 'dsh-python-sdk-minimal-'))
  179. const modelRequests: Record<string, unknown>[] = []
  180. const modelServer = createServer((request, response) => {
  181. let body = ''
  182. request.setEncoding('utf8')
  183. request.on('data', (chunk: string) => { body += chunk })
  184. request.on('end', () => {
  185. modelRequests.push(JSON.parse(body) as Record<string, unknown>)
  186. response.writeHead(200, { 'content-type': 'text/event-stream' })
  187. response.write('data: {"choices":[{"delta":{"role":"assistant","content":null}}]}\n\n')
  188. response.write('data: {"choices":[{"delta":{"content":"done"}}]}\n\n')
  189. response.write('data: {"choices":[{"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}\n\n')
  190. response.end('data: [DONE]\n\n')
  191. })
  192. })
  193. await new Promise<void>(resolve => modelServer.listen(0, '127.0.0.1', resolve))
  194. const address = modelServer.address()
  195. if (address === null || typeof address === 'string') throw new Error('model server did not bind a TCP port')
  196. const child = execa(process.execPath, [
  197. '--import',
  198. 'tsx/esm',
  199. binScript,
  200. '--profile',
  201. 'sdk',
  202. '--patch',
  203. minimalPatchPath,
  204. ], {
  205. cwd: repoRoot,
  206. env: {
  207. DSH_HOME: join(root, '.dsh'),
  208. DSH_PERMISSION_MODE: 'danger-full-access',
  209. DSH_SYSTEM_PROMPT: 'Minimal allowlist prompt.',
  210. DSH_TELEMETRY_DISABLED: '1',
  211. DEEPSEEK_API_KEY: 'keyless-smoke-no-call',
  212. DEEPSEEK_BASE_URL: `http://127.0.0.1:${address.port}`,
  213. },
  214. timeout: 35_000,
  215. killSignal: 'SIGKILL',
  216. reject: false,
  217. })
  218. const lines: string[] = []
  219. let stdoutBuffer = ''
  220. let stderr = ''
  221. child.stdout.on('data', (chunk: Buffer) => {
  222. stdoutBuffer += chunk.toString('utf8')
  223. const parts = stdoutBuffer.split('\n')
  224. stdoutBuffer = parts.pop() ?? ''
  225. lines.push(...parts)
  226. })
  227. child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString('utf8') })
  228. try {
  229. child.stdin.write(`${JSON.stringify({
  230. jsonrpc: '2.0',
  231. id: 1,
  232. method: 'initialize',
  233. params: { cwd: root, provider: 'deepseek-official', model: 'deepseek-v4-pro' },
  234. })}\n`)
  235. await waitForLine(lines, value => value.id === 1, () => stderr)
  236. child.stdin.write(`${JSON.stringify({
  237. jsonrpc: '2.0',
  238. id: 2,
  239. method: 'session/prompt',
  240. params: { sessionId: 'minimal', contentBlocks: [{ type: 'text', text: 'inspect tools' }] },
  241. })}\n`)
  242. await waitForLine(lines, (value) => {
  243. const params = value.params as Record<string, unknown> | undefined
  244. const event = params?.event as Record<string, unknown> | undefined
  245. return params?.sessionId === 'minimal' && event?.type === 'turn/end'
  246. }, () => stderr)
  247. const request = modelRequests[0] as {
  248. messages?: Array<{ role?: string; content?: unknown }>
  249. tools?: Array<{ function?: { name?: string } }>
  250. }
  251. expect(request.messages?.[0]).toMatchObject({ role: 'system', content: 'Minimal allowlist prompt.' })
  252. expect(request.tools?.map(tool => tool.function?.name).sort()).toEqual(['bash', 'str_replace_editor'])
  253. child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id: 3, method: 'shutdown' })}\n`)
  254. await waitForLine(lines, value => value.id === 3, () => stderr)
  255. const exit = await child
  256. expect(exit.exitCode, `signal=${String(exit.signal)}; stderr=${stderr}`).toBe(0)
  257. } finally {
  258. child.kill('SIGKILL')
  259. await child
  260. await new Promise<void>(resolve => modelServer.close(() => { resolve() }))
  261. await rm(root, { recursive: true, force: true })
  262. }
  263. }, 40_000)
  264. it('rejects an invalid max-token success env value', async () => {
  265. const root = await mkdtemp(join(tmpdir(), 'dsh-python-sdk-runtime-invalid-'))
  266. try {
  267. const { exitCode, stdout, stderr } = await execa(process.execPath, [
  268. '--import',
  269. 'tsx/esm',
  270. binScript,
  271. '--profile',
  272. 'sdk',
  273. '--patch',
  274. patchPath,
  275. ], {
  276. cwd: repoRoot,
  277. env: {
  278. DSH_HOME: join(root, '.dsh'),
  279. DEEPSEEK_API_KEY: 'keyless-smoke-no-call',
  280. DSH_MAX_TOKENS_AS_SUCCESS: 'sometimes',
  281. },
  282. stdin: 'ignore',
  283. timeout: 25_000,
  284. killSignal: 'SIGKILL',
  285. reject: false,
  286. })
  287. expect(exitCode, stderr).toBe(1)
  288. expect(stdout).toBe('')
  289. expect(stderr).toContain('plugin tree failed to load')
  290. expect(stderr).toContain('failed to apply loader entry sdk-jsonrpc-server (@deepseek-ai/dsh-sdk-jsonrpc-server)')
  291. expect(stderr).toContain('sometimes')
  292. } finally {
  293. await rm(root, { recursive: true, force: true })
  294. }
  295. }, 30_000)
  296. })