1
0

node-half.spec.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245
  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 type { HttpServerService, WebRoute, WebUpgradeRoute } from '@deepseek-ai/dsh-host-webserver'
  11. import { API_PATH, apply, HOST_EVENTS_PATH, inject, MUX_EVENTS_PATH } from '../src/index.ts'
  12. /** Structural httpServer fake recording both route registries. */
  13. function fakeHttpServer(
  14. routes: WebRoute[],
  15. upgrades: WebUpgradeRoute[],
  16. ): Pick<HttpServerService, 'register' | 'registerUpgrade' | 'tapIndex' | 'port'> {
  17. return {
  18. register(route) {
  19. routes.push(route)
  20. return () => { routes.splice(routes.indexOf(route), 1) }
  21. },
  22. registerUpgrade(route) {
  23. upgrades.push(route)
  24. return () => { upgrades.splice(upgrades.indexOf(route), 1) }
  25. },
  26. tapIndex: () => () => {},
  27. port: 0,
  28. }
  29. }
  30. /** Bodyless GET carrying the given headers (enough for the trust fence + bridge). */
  31. function fakeRequest(headers: Record<string, string>, url = `${API_PATH}/session.list`): IncomingMessage {
  32. const request = Readable.from([]) as unknown as IncomingMessage
  33. Object.assign(request, { url, method: 'GET', headers })
  34. return request
  35. }
  36. /** Response recorder compatible with both the fence's short-circuit and the bridge. */
  37. function fakeResponse(): { response: ServerResponse; state: { status?: number; body?: unknown } } {
  38. const state: { status?: number; body?: unknown } = {}
  39. const response = Object.assign(new EventEmitter(), {
  40. writableEnded: false,
  41. writeHead(value: number) { state.status = value; return this },
  42. write() { return true },
  43. end(this: { writableEnded: boolean }, value?: unknown) {
  44. if (value !== undefined) state.body = value
  45. this.writableEnded = true
  46. return this
  47. },
  48. }) as unknown as ServerResponse
  49. return { response, state }
  50. }
  51. async function mounted(config?: { trustedHosts?: string[] }): Promise<{
  52. routes: WebRoute[]
  53. upgrades: WebUpgradeRoute[]
  54. dispose: () => Promise<void>
  55. }> {
  56. const ctx = new Context()
  57. const routes: WebRoute[] = []
  58. const upgrades: WebUpgradeRoute[] = []
  59. ctx.provide('httpServer', fakeHttpServer(routes, upgrades) as HttpServerService)
  60. ctx.provide('apiProxy', {} as unknown as ApiProxy)
  61. const fiber = ctx.plugin({ inject: [...inject], apply }, config)
  62. await fiber.await()
  63. return { routes, upgrades, dispose: () => fiber.dispose() }
  64. }
  65. describe('connection node half', () => {
  66. it('fails the load on a trustedHosts entry that is not a bare authority', async () => {
  67. const routes: WebRoute[] = []
  68. const upgrades: WebUpgradeRoute[] = []
  69. const ctx = new Context()
  70. ctx.provide('httpServer', fakeHttpServer(routes, upgrades) as HttpServerService)
  71. ctx.provide('apiProxy', {} as unknown as ApiProxy)
  72. const fiber = ctx.plugin({ inject: [...inject], apply }, { trustedHosts: ['harness.internal/path'] })
  73. await expect(fiber).rejects.toThrow(/not a bare host\[:port\] authority/)
  74. expect(routes).toHaveLength(0)
  75. expect(upgrades).toHaveLength(0)
  76. })
  77. it('registers one HTTP route plus one upgrade route per downlink and removes all three with the fiber', async () => {
  78. const { routes, upgrades, dispose } = await mounted()
  79. expect(routes).toHaveLength(1)
  80. expect(routes[0]).toMatchObject({ kind: 'prefix', path: API_PATH })
  81. expect(upgrades.map(route => route.path)).toEqual([MUX_EVENTS_PATH, HOST_EVENTS_PATH])
  82. await dispose()
  83. expect(routes).toHaveLength(0)
  84. expect(upgrades).toHaveLength(0)
  85. })
  86. it('requires WebSocket upgrade for network GETs to either event path', async () => {
  87. const { routes, dispose } = await mounted()
  88. for (const path of [MUX_EVENTS_PATH, HOST_EVENTS_PATH]) {
  89. const { response, state } = fakeResponse()
  90. await routes[0]!.handler(fakeRequest({ host: '127.0.0.1:3080' }, path), response)
  91. expect(state.status).toBe(426)
  92. expect(state.body).toBe('upgrade required')
  93. }
  94. await dispose()
  95. })
  96. it('rejects an untrusted WebSocket upgrade before protocol negotiation', async () => {
  97. const { upgrades, dispose } = await mounted()
  98. const socket = new PassThrough()
  99. const chunks: Buffer[] = []
  100. socket.on('data', (chunk: Buffer) => { chunks.push(chunk) })
  101. const ended = once(socket, 'end')
  102. await upgrades[0]!.handler(fakeRequest({
  103. host: 'harness.example', origin: 'http://harness.example', 'sec-fetch-site': 'same-origin',
  104. }, MUX_EVENTS_PATH), socket, Buffer.alloc(0))
  105. await ended
  106. expect(Buffer.concat(chunks).toString()).toContain('HTTP/1.1 403 Forbidden')
  107. await dispose()
  108. })
  109. it('refuses an untrusted Host on any /api path before the bridge runs', async () => {
  110. const { routes, dispose } = await mounted()
  111. const { response, state } = fakeResponse()
  112. await routes[0]!.handler(fakeRequest({
  113. host: 'harness.example', origin: 'http://harness.example', 'sec-fetch-site': 'same-origin',
  114. }), response)
  115. expect(state.status).toBe(403)
  116. expect(state.body).toBe('forbidden')
  117. await dispose()
  118. })
  119. it('pins privileged methods to loopback even for a declared trusted authority', async () => {
  120. const { routes, dispose } = await mounted({ trustedHosts: ['harness.example'] })
  121. // The privileged set: native dialogs plus the whole settings/credential
  122. // configuration plane, reads included, plus the one method that makes the
  123. // host fetch a caller-chosen URL. The same declared authority reaches
  124. // ordinary reads (carrier-level 404 from the empty proxy proves the fence
  125. // passed), but each privileged method stays loopback-only and 403s.
  126. for (const method of [
  127. 'host.pickDirectory', 'host.openPath',
  128. 'settings.describe', 'settings.openDocument', 'settings.update', 'settings.replace', 'settings.mutate',
  129. 'credentials.describe', 'credentials.set', 'credentials.unset',
  130. 'llm.discoverModels',
  131. ]) {
  132. const denied = fakeResponse()
  133. await routes[0]!.handler(
  134. fakeRequest({ host: 'harness.example' }, `${API_PATH}/${method}`),
  135. denied.response,
  136. )
  137. expect(denied.state.status).toBe(403)
  138. expect(denied.state.body).toBe('forbidden')
  139. }
  140. const read = fakeResponse()
  141. await routes[0]!.handler(fakeRequest({ host: 'harness.example' }), read.response)
  142. expect(read.state.status).not.toBe(403)
  143. await dispose()
  144. })
  145. it('passes loopback and declared-authority requests through to the bridge', async () => {
  146. const { routes, dispose } = await mounted({ trustedHosts: ['harness.example:3080', '192.168.1.5'] })
  147. // Loopback, no browser markers (curl shape): the fence passes; the carrier
  148. // answers 404 for a GET unary path — proof the bridge ran.
  149. const loopback = fakeResponse()
  150. await routes[0]!.handler(fakeRequest({ host: '127.0.0.1:3080' }), loopback.response)
  151. expect(loopback.state.status).toBe(404)
  152. // LAN authority declared as a port-less IP literal — the shape the CLI
  153. // derives for `--host 0.0.0.0` — passes markerless curl on any port.
  154. const lan = fakeResponse()
  155. await routes[0]!.handler(fakeRequest({ host: '192.168.1.5:3080' }), lan.response)
  156. expect(lan.state.status).toBe(404)
  157. // Declared public authority, same-origin browser shape.
  158. const declared = fakeResponse()
  159. await routes[0]!.handler(fakeRequest({
  160. host: 'harness.example:3080', origin: 'http://harness.example:3080', 'sec-fetch-site': 'same-origin',
  161. }), declared.response)
  162. expect(declared.state.status).toBe(404)
  163. await dispose()
  164. })
  165. })
  166. describe('connection node half over a real HTTP server', () => {
  167. /** Serve the registered prefix route from a real server and return its port. */
  168. async function serve(routes: WebRoute[]): Promise<{ port: number; close: () => Promise<void> }> {
  169. const server = createServer((request, response) => {
  170. void routes[0]!.handler(request, response)
  171. })
  172. await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
  173. const address = server.address() as AddressInfo
  174. return {
  175. port: address.port,
  176. close: () => new Promise<void>((resolve, reject) => {
  177. server.close((error) => {
  178. if (error === undefined || error === null) resolve()
  179. else reject(error)
  180. })
  181. }),
  182. }
  183. }
  184. /** One real request; `host` spoofs the authority the way a LAN client's browser would send it. */
  185. function call(port: number, method: string, host: string): Promise<number> {
  186. return new Promise((resolve, reject) => {
  187. const request = httpRequest(
  188. { host: '127.0.0.1', port, path: `${API_PATH}/${method}`, method: 'GET', headers: { host } },
  189. (response) => {
  190. response.resume()
  191. response.on('end', () => { resolve(response.statusCode ?? 0) })
  192. },
  193. )
  194. request.on('error', reject)
  195. request.end()
  196. })
  197. }
  198. it('answers a declared LAN authority with 403 on every configuration method, over real HTTP', async () => {
  199. // The fence's input is a real IncomingMessage parsed by Node from the
  200. // wire, not a hand-assembled object: the Host header a LAN browser sends
  201. // is exactly what decides loopback-only here, so the boundary is asserted
  202. // against the parse the server actually performs.
  203. const { routes, dispose } = await mounted({ trustedHosts: ['harness.example'] })
  204. const { port, close } = await serve(routes)
  205. try {
  206. // Reads are as privileged as writes: describe returns the exposed
  207. // configuration, and credentials.describe probes arbitrary env-var names.
  208. for (const method of [
  209. 'settings.describe', 'settings.openDocument', 'settings.update', 'settings.replace', 'settings.mutate',
  210. 'credentials.describe', 'credentials.set', 'credentials.unset',
  211. 'host.pickDirectory', 'host.openPath',
  212. // Carries a draft credential and turns the host into a fetcher for a
  213. // URL the caller picked: an anonymous LAN caller must not reach it.
  214. 'llm.discoverModels',
  215. ]) {
  216. expect([method, await call(port, method, 'harness.example')]).toEqual([method, 403])
  217. }
  218. // The model catalog stays reachable for the same authority: a LAN
  219. // client's model picker needs it, and it carries no key or endpoint
  220. // state (404 is the empty proxy's carrier answer — the fence passed).
  221. for (const method of ['llm.providers', 'llm.models']) {
  222. expect([method, await call(port, method, 'harness.example')]).toEqual([method, 404])
  223. }
  224. // Loopback reaches everything, configuration included.
  225. expect(await call(port, 'settings.describe', `127.0.0.1:${String(port)}`)).toBe(404)
  226. } finally {
  227. await close()
  228. await dispose()
  229. }
  230. })
  231. })