node-half.spec.ts 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471
  1. /** Node half: registers the /api prefix route bridging to the api gateway. */
  2. import { EventEmitter, once } from 'node:events'
  3. import { createServer, request as httpRequest } from 'node:http'
  4. import { PassThrough, Readable } from 'node:stream'
  5. import { Context } from 'cordis'
  6. import { describe, expect, it } from 'vitest'
  7. import type { AddressInfo } from 'node:net'
  8. import type { IncomingMessage, ServerResponse } from 'node:http'
  9. import type { ApiProxy } from '@deepseek-ai/dsh-host-apiproxy/api'
  10. import { RpcId, type ClientRequest } from '@deepseek-ai/dsh-host-apiproxy/api'
  11. import type { HttpServerService, WebRoute, WebUpgradeRoute } from '@deepseek-ai/dsh-host-webserver'
  12. import { API_PATH, apply, HOST_EVENTS_PATH, inject, MUX_EVENTS_PATH, type HostConnectionHandle } from '../src/index.ts'
  13. /** Structural httpServer fake recording both route registries. */
  14. function fakeHttpServer(
  15. routes: WebRoute[],
  16. upgrades: WebUpgradeRoute[],
  17. ): Pick<HttpServerService, 'register' | 'registerUpgrade' | 'tapIndex' | 'port'> {
  18. return {
  19. register(route) {
  20. if (routes.some(candidate => candidate.kind === route.kind && candidate.path === route.path)) {
  21. throw new Error(`duplicate route ${route.path}`)
  22. }
  23. routes.push(route)
  24. return () => { routes.splice(routes.indexOf(route), 1) }
  25. },
  26. registerUpgrade(route) {
  27. upgrades.push(route)
  28. return () => { upgrades.splice(upgrades.indexOf(route), 1) }
  29. },
  30. tapIndex: () => () => {},
  31. port: 0,
  32. }
  33. }
  34. /** Bodyless GET carrying the given headers (enough for the trust fence + bridge). */
  35. function fakeRequest(headers: Record<string, string>, url = `${API_PATH}/session.list`): IncomingMessage {
  36. const request = Readable.from([]) as unknown as IncomingMessage
  37. Object.assign(request, { url, method: 'GET', headers })
  38. return request
  39. }
  40. /** JSON POST carrying a complete client-request envelope. */
  41. function fakePost(headers: Record<string, string>, url: string, body: unknown): IncomingMessage {
  42. const request = Readable.from([Buffer.from(JSON.stringify(body))]) as unknown as IncomingMessage
  43. Object.assign(request, { url, method: 'POST', headers: { 'content-type': 'application/json', ...headers } })
  44. return request
  45. }
  46. /** Raw POST for malformed-body and media-type boundary cases. */
  47. function fakeRawPost(headers: Record<string, string>, url: string, body: string): IncomingMessage {
  48. const request = Readable.from([Buffer.from(body)]) as unknown as IncomingMessage
  49. Object.assign(request, { url, method: 'POST', headers })
  50. return request
  51. }
  52. /** Response recorder compatible with both the fence's short-circuit and the bridge. */
  53. function fakeResponse(): { response: ServerResponse; state: { status?: number; body?: unknown } } {
  54. const state: { status?: number; body?: unknown } = {}
  55. const chunks: Buffer[] = []
  56. const response = Object.assign(new EventEmitter(), {
  57. writableEnded: false,
  58. writeHead(value: number) { state.status = value; return this },
  59. write(value: string | Uint8Array) { chunks.push(Buffer.from(value)); return true },
  60. end(this: { writableEnded: boolean }, value?: unknown) {
  61. if (typeof value === 'string' || value instanceof Uint8Array) chunks.push(Buffer.from(value))
  62. else if (value !== undefined) throw new TypeError('fake response only accepts string or Uint8Array bodies')
  63. if (chunks.length > 0) state.body = Buffer.concat(chunks).toString()
  64. this.writableEnded = true
  65. return this
  66. },
  67. }) as unknown as ServerResponse
  68. return { response, state }
  69. }
  70. async function mounted(config?: { trustedHosts?: string[] }): Promise<{
  71. routes: WebRoute[]
  72. upgrades: WebUpgradeRoute[]
  73. dispose: () => Promise<void>
  74. }> {
  75. const ctx = new Context()
  76. const routes: WebRoute[] = []
  77. const upgrades: WebUpgradeRoute[] = []
  78. ctx.provide('httpServer', fakeHttpServer(routes, upgrades) as HttpServerService)
  79. ctx.provide('apiProxy', {} as unknown as ApiProxy)
  80. const fiber = ctx.plugin({ inject: [...inject], apply }, config)
  81. await fiber.await()
  82. return { routes, upgrades, dispose: () => fiber.dispose() }
  83. }
  84. describe('connection node half', () => {
  85. it('fails the load on a trustedHosts entry that is not a bare authority', async () => {
  86. const routes: WebRoute[] = []
  87. const upgrades: WebUpgradeRoute[] = []
  88. const ctx = new Context()
  89. ctx.provide('httpServer', fakeHttpServer(routes, upgrades) as HttpServerService)
  90. ctx.provide('apiProxy', {} as unknown as ApiProxy)
  91. const fiber = ctx.plugin({ inject: [...inject], apply }, { trustedHosts: ['harness.internal/path'] })
  92. await expect(fiber).rejects.toThrow(/not a bare host\[:port\] authority/)
  93. expect(routes).toHaveLength(0)
  94. expect(upgrades).toHaveLength(0)
  95. })
  96. it('registers one HTTP route plus one upgrade route per downlink and removes all three with the fiber', async () => {
  97. const { routes, upgrades, dispose } = await mounted()
  98. expect(routes).toHaveLength(1)
  99. expect(routes[0]).toMatchObject({ kind: 'prefix', path: API_PATH })
  100. expect(upgrades.map(route => route.path)).toEqual([MUX_EVENTS_PATH, HOST_EVENTS_PATH])
  101. await dispose()
  102. expect(routes).toHaveLength(0)
  103. expect(upgrades).toHaveLength(0)
  104. })
  105. it('requires WebSocket upgrade for network GETs to either event path', async () => {
  106. const { routes, dispose } = await mounted()
  107. for (const path of [MUX_EVENTS_PATH, HOST_EVENTS_PATH]) {
  108. const { response, state } = fakeResponse()
  109. await routes[0]!.handler(fakeRequest({ host: '127.0.0.1:3080' }, path), response)
  110. expect(state.status).toBe(426)
  111. expect(state.body).toBe('upgrade required')
  112. }
  113. await dispose()
  114. })
  115. it('rejects an untrusted WebSocket upgrade before protocol negotiation', async () => {
  116. const { upgrades, dispose } = await mounted()
  117. const socket = new PassThrough()
  118. const chunks: Buffer[] = []
  119. socket.on('data', (chunk: Buffer) => { chunks.push(chunk) })
  120. const ended = once(socket, 'end')
  121. await upgrades[0]!.handler(fakeRequest({
  122. host: 'harness.example', origin: 'http://harness.example', 'sec-fetch-site': 'same-origin',
  123. }, MUX_EVENTS_PATH), socket, Buffer.alloc(0))
  124. await ended
  125. expect(Buffer.concat(chunks).toString()).toContain('HTTP/1.1 403 Forbidden')
  126. await dispose()
  127. })
  128. it('refuses an untrusted Host on any /api path before the bridge runs', async () => {
  129. const { routes, dispose } = await mounted()
  130. const { response, state } = fakeResponse()
  131. await routes[0]!.handler(fakeRequest({
  132. host: 'harness.example', origin: 'http://harness.example', 'sec-fetch-site': 'same-origin',
  133. }), response)
  134. expect(state.status).toBe(403)
  135. expect(state.body).toBe('forbidden')
  136. await dispose()
  137. })
  138. it('pins privileged methods to loopback even for a declared trusted authority', async () => {
  139. const { routes, dispose } = await mounted({ trustedHosts: ['harness.example'] })
  140. // The privileged set: native dialogs plus the whole settings/credential
  141. // configuration plane, reads included, plus the one method that makes the
  142. // host fetch a caller-chosen URL. The same declared authority reaches
  143. // ordinary reads (carrier-level 404 from the empty proxy proves the fence
  144. // passed), but each privileged method stays loopback-only and 403s.
  145. for (const method of [
  146. 'host.pickDirectory', 'host.openPath',
  147. 'settings.describe', 'settings.openDocument', 'settings.update', 'settings.replace', 'settings.mutate',
  148. 'credentials.describe', 'credentials.set', 'credentials.unset',
  149. 'llm.discoverModels',
  150. ]) {
  151. const denied = fakeResponse()
  152. await routes[0]!.handler(
  153. fakeRequest({ host: 'harness.example' }, `${API_PATH}/${method}`),
  154. denied.response,
  155. )
  156. expect(denied.state.status).toBe(403)
  157. expect(denied.state.body).toBe('forbidden')
  158. }
  159. const read = fakeResponse()
  160. await routes[0]!.handler(fakeRequest({ host: 'harness.example' }), read.response)
  161. expect(read.state.status).not.toBe(403)
  162. await dispose()
  163. })
  164. it('passes loopback and declared-authority requests through to the bridge', async () => {
  165. const { routes, dispose } = await mounted({ trustedHosts: ['harness.example:3080', '192.168.1.5'] })
  166. // Loopback, no browser markers (curl shape): the fence passes; the carrier
  167. // answers 404 for a GET unary path — proof the bridge ran.
  168. const loopback = fakeResponse()
  169. await routes[0]!.handler(fakeRequest({ host: '127.0.0.1:3080' }), loopback.response)
  170. expect(loopback.state.status).toBe(404)
  171. // LAN authority declared as a port-less IP literal — the shape the CLI
  172. // derives for `--host 0.0.0.0` — passes markerless curl on any port.
  173. const lan = fakeResponse()
  174. await routes[0]!.handler(fakeRequest({ host: '192.168.1.5:3080' }), lan.response)
  175. expect(lan.state.status).toBe(404)
  176. // Declared public authority, same-origin browser shape.
  177. const declared = fakeResponse()
  178. await routes[0]!.handler(fakeRequest({
  179. host: 'harness.example:3080', origin: 'http://harness.example:3080', 'sec-fetch-site': 'same-origin',
  180. }), declared.response)
  181. expect(declared.state.status).toBe(404)
  182. await dispose()
  183. })
  184. it('provides a disposable dedicated RPC channel without requiring apiProxy', async () => {
  185. const ctx = new Context()
  186. const routes: WebRoute[] = []
  187. ctx.provide('httpServer', fakeHttpServer(routes, []) as HttpServerService)
  188. const fiber = ctx.plugin({ inject: [...inject], apply })
  189. await fiber.await()
  190. expect(routes).toHaveLength(1)
  191. expect(routes[0]).toMatchObject({ kind: 'prefix', path: API_PATH })
  192. const connection = ctx.get('connection') as HostConnectionHandle
  193. const calls: unknown[] = []
  194. const remove = connection.rpc.handle('/rpc', async (endpoint, payload) => {
  195. calls.push({ endpoint, payload })
  196. return { ok: true, value: { accepted: true } }
  197. }, { authority: 'trusted-host' })
  198. const route = routes.find(candidate => candidate.path === '/rpc')
  199. expect(route).toBeDefined()
  200. const request: ClientRequest = {
  201. type: 'client-request',
  202. rpcId: RpcId('rpc-dedicated'),
  203. method: 'goals/create',
  204. payload: { args: { agentId: 'agent-1' } },
  205. }
  206. const result = fakeResponse()
  207. await route!.handler(fakePost({ host: '127.0.0.1:3080' }, '/rpc/goals/create', request), result.response)
  208. expect(result.state.status).toBe(200)
  209. expect(JSON.parse(String(result.state.body))).toEqual({
  210. type: 'server-response',
  211. rpcId: 'rpc-dedicated',
  212. result: { ok: true, value: { accepted: true } },
  213. })
  214. expect(calls).toEqual([{
  215. endpoint: 'goals/create',
  216. payload: { args: { agentId: 'agent-1' } },
  217. }])
  218. expect(() => connection.rpc.handle('/rpc', async () => ({ ok: true, value: null }), {
  219. authority: 'trusted-host',
  220. })).toThrow(/duplicate route/)
  221. await remove()
  222. expect(routes.map(candidate => candidate.path)).toEqual([API_PATH])
  223. await fiber.dispose()
  224. expect(routes).toHaveLength(0)
  225. })
  226. it('dispatches claimed /api endpoints before the API Proxy fallback and withdraws the claim', async () => {
  227. const ctx = new Context()
  228. const routes: WebRoute[] = []
  229. ctx.provide('httpServer', fakeHttpServer(routes, []) as HttpServerService)
  230. ctx.provide('apiProxy', {} as unknown as ApiProxy)
  231. const fiber = ctx.plugin({ inject: [...inject], apply }, { trustedHosts: ['harness.example'] })
  232. await fiber.await()
  233. const connection = ctx.get('connection') as HostConnectionHandle
  234. const calls: unknown[] = []
  235. const remove = connection.rpc.intercept(
  236. '/api',
  237. endpoint => endpoint === 'goals/create',
  238. async (endpoint, payload) => {
  239. calls.push({ endpoint, payload })
  240. return { ok: true, value: { accepted: true } }
  241. },
  242. { authority: 'trusted-host' },
  243. )
  244. expect(() => connection.rpc.intercept(
  245. '/api',
  246. () => true,
  247. async () => ({ ok: true, value: null }),
  248. { authority: 'trusted-host' },
  249. )).toThrow('already has an interceptor')
  250. expect(() => connection.rpc.intercept(
  251. '/rpc' as '/api',
  252. () => true,
  253. async () => ({ ok: true, value: null }),
  254. { authority: 'trusted-host' },
  255. )).toThrow('invalid shared RPC channel')
  256. const route = routes.find(candidate => candidate.path === API_PATH)!
  257. const request: ClientRequest = {
  258. type: 'client-request',
  259. rpcId: RpcId('rpc-shared'),
  260. method: 'goals/create',
  261. payload: { args: { agentId: 'agent-1' } },
  262. }
  263. const claimed = fakeResponse()
  264. await route.handler(fakePost({ host: '127.0.0.1:3080' }, '/api/goals/create', request), claimed.response)
  265. expect(JSON.parse(String(claimed.state.body))).toEqual({
  266. type: 'server-response',
  267. rpcId: 'rpc-shared',
  268. result: { ok: true, value: { accepted: true } },
  269. })
  270. expect(calls).toEqual([{
  271. endpoint: 'goals/create',
  272. payload: { args: { agentId: 'agent-1' } },
  273. }])
  274. const denied = fakeResponse()
  275. await route.handler(fakePost({ host: 'other.example' }, '/api/goals/create', request), denied.response)
  276. expect(denied.state).toMatchObject({ status: 403, body: 'forbidden' })
  277. expect(calls).toHaveLength(1)
  278. const unclaimed = fakeResponse()
  279. await route.handler(fakeRequest({ host: '127.0.0.1:3080' }, '/api/session.list'), unclaimed.response)
  280. expect(unclaimed.state.status).toBe(404)
  281. await remove()
  282. const withdrawn = fakeResponse()
  283. await route.handler(fakePost({ host: '127.0.0.1:3080' }, '/api/goals/create', request), withdrawn.response)
  284. expect(withdrawn.state.status).toBe(404)
  285. expect(calls).toHaveLength(1)
  286. const removeLoopback = connection.rpc.intercept(
  287. '/api',
  288. endpoint => endpoint === 'goals/create',
  289. async () => ({ ok: true, value: null }),
  290. { authority: 'loopback' },
  291. )
  292. const loopbackOnly = fakeResponse()
  293. await route.handler(fakePost({ host: 'harness.example' }, '/api/goals/create', request), loopbackOnly.response)
  294. expect(loopbackOnly.state.status).toBe(403)
  295. await removeLoopback()
  296. await fiber.dispose()
  297. })
  298. it('applies the configured trust fence and JSON envelope checks to generic channels', async () => {
  299. const ctx = new Context()
  300. const routes: WebRoute[] = []
  301. ctx.provide('httpServer', fakeHttpServer(routes, []) as HttpServerService)
  302. const fiber = ctx.plugin({ inject: [...inject], apply }, { trustedHosts: ['harness.example'] })
  303. await fiber.await()
  304. const connection = ctx.get('connection') as HostConnectionHandle
  305. const remove = connection.rpc.handle('/rpc', async (endpoint) => {
  306. if (endpoint === 'fail') throw new Error('handler broke')
  307. return { ok: true, value: null }
  308. }, {
  309. authority: 'trusted-host',
  310. })
  311. const route = routes.find(candidate => candidate.path === '/rpc')!
  312. const denied = fakeResponse()
  313. await route.handler(fakePost({ host: 'other.example' }, '/rpc/goals/create', {}), denied.response)
  314. expect(denied.state).toMatchObject({ status: 403, body: 'forbidden' })
  315. const methodMismatch = fakeResponse()
  316. await route.handler(fakePost({ host: 'harness.example' }, '/rpc/goals/create', {
  317. type: 'client-request', rpcId: 'rpc-bad', method: 'other', payload: {},
  318. }), methodMismatch.response)
  319. expect(JSON.parse(String(methodMismatch.state.body))).toMatchObject({
  320. rpcId: 'rpc-bad',
  321. result: { ok: false, error: { code: 'bad-request' } },
  322. })
  323. for (const [request, status] of [
  324. [fakeRequest({ host: 'harness.example' }, '/rpc/goals/create'), 404],
  325. [fakePost({ host: 'harness.example' }, '/outside/goals/create', {}), 404],
  326. [fakePost({ host: 'harness.example' }, '/rpc/goals//create', {}), 404],
  327. [fakeRawPost({ host: 'harness.example' }, '/rpc/goals/create', '{}'), 415],
  328. [fakeRawPost({ host: 'harness.example', 'content-type': 'text/plain' }, '/rpc/goals/create', '{}'), 415],
  329. [fakeRawPost({ host: 'harness.example', 'content-type': 'application/json; charset=utf-8' }, '/rpc/goals/create', '{'), 400],
  330. ] as const) {
  331. const response = fakeResponse()
  332. await route.handler(request, response.response)
  333. expect(response.state.status).toBe(status)
  334. }
  335. for (const [body, rpcId] of [
  336. [{ rpcId: 'retained-id' }, 'retained-id'],
  337. [{ rpcId: 42 }, 'invalid-request'],
  338. [null, 'invalid-request'],
  339. ] as const) {
  340. const response = fakeResponse()
  341. await route.handler(fakePost({ host: 'harness.example' }, '/rpc/goals/create', body), response.response)
  342. expect(JSON.parse(String(response.state.body))).toMatchObject({
  343. rpcId,
  344. result: { ok: false, error: { code: 'bad-request' } },
  345. })
  346. }
  347. const failed = fakeResponse()
  348. await route.handler(fakePost({ host: 'harness.example' }, '/rpc/fail', {
  349. type: 'client-request', rpcId: 'rpc-fail', method: 'fail', payload: {},
  350. }), failed.response)
  351. expect(failed.state).toMatchObject({ status: 500, body: 'handler failure: Error: handler broke' })
  352. expect(() => connection.rpc.handle('/api', async () => ({ ok: true, value: null }), {
  353. authority: 'loopback',
  354. })).toThrow('invalid or reserved RPC channel')
  355. expect(() => connection.rpc.handle('api3', async () => ({ ok: true, value: null }), {
  356. authority: 'loopback',
  357. })).toThrow('invalid or reserved RPC channel')
  358. const removeLoopback = connection.rpc.handle('/loopback', async () => ({ ok: true, value: null }), {
  359. authority: 'loopback',
  360. })
  361. const loopbackRoute = routes.find(candidate => candidate.path === '/loopback')!
  362. const publicResponse = fakeResponse()
  363. await loopbackRoute.handler(fakePost({ host: 'harness.example' }, '/loopback/read', {
  364. type: 'client-request', rpcId: 'rpc-public', method: 'read', payload: {},
  365. }), publicResponse.response)
  366. expect(publicResponse.state.status).toBe(403)
  367. await removeLoopback()
  368. await remove()
  369. await fiber.dispose()
  370. })
  371. })
  372. describe('connection node half over a real HTTP server', () => {
  373. /** Serve the registered prefix route from a real server and return its port. */
  374. async function serve(routes: WebRoute[]): Promise<{ port: number; close: () => Promise<void> }> {
  375. const server = createServer((request, response) => {
  376. void routes[0]!.handler(request, response)
  377. })
  378. await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
  379. const address = server.address() as AddressInfo
  380. return {
  381. port: address.port,
  382. close: () => new Promise<void>((resolve, reject) => {
  383. server.close((error) => {
  384. if (error === undefined || error === null) resolve()
  385. else reject(error)
  386. })
  387. }),
  388. }
  389. }
  390. /** One real request; `host` spoofs the authority the way a LAN client's browser would send it. */
  391. function call(port: number, method: string, host: string): Promise<number> {
  392. return new Promise((resolve, reject) => {
  393. const request = httpRequest(
  394. { host: '127.0.0.1', port, path: `${API_PATH}/${method}`, method: 'GET', headers: { host } },
  395. (response) => {
  396. response.resume()
  397. response.on('end', () => { resolve(response.statusCode ?? 0) })
  398. },
  399. )
  400. request.on('error', reject)
  401. request.end()
  402. })
  403. }
  404. it('answers a declared LAN authority with 403 on every configuration method, over real HTTP', async () => {
  405. // The fence's input is a real IncomingMessage parsed by Node from the
  406. // wire, not a hand-assembled object: the Host header a LAN browser sends
  407. // is exactly what decides loopback-only here, so the boundary is asserted
  408. // against the parse the server actually performs.
  409. const { routes, dispose } = await mounted({ trustedHosts: ['harness.example'] })
  410. const { port, close } = await serve(routes)
  411. try {
  412. // Reads are as privileged as writes: describe returns the exposed
  413. // configuration, and credentials.describe probes arbitrary env-var names.
  414. for (const method of [
  415. 'settings.describe', 'settings.openDocument', 'settings.update', 'settings.replace', 'settings.mutate',
  416. 'credentials.describe', 'credentials.set', 'credentials.unset',
  417. 'host.pickDirectory', 'host.openPath',
  418. // Carries a draft credential and turns the host into a fetcher for a
  419. // URL the caller picked: an anonymous LAN caller must not reach it.
  420. 'llm.discoverModels',
  421. ]) {
  422. expect([method, await call(port, method, 'harness.example')]).toEqual([method, 403])
  423. }
  424. // The model catalog stays reachable for the same authority: a LAN
  425. // client's model picker needs it, and it carries no key or endpoint
  426. // state (404 is the empty proxy's carrier answer — the fence passed).
  427. for (const method of ['llm.providers', 'llm.models']) {
  428. expect([method, await call(port, method, 'harness.example')]).toEqual([method, 404])
  429. }
  430. // Loopback reaches everything, configuration included.
  431. expect(await call(port, 'settings.describe', `127.0.0.1:${String(port)}`)).toBe(404)
  432. } finally {
  433. await close()
  434. await dispose()
  435. }
  436. })
  437. })