node-half.host.spec.ts 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623
  1. /** Node half: registers the /api prefix route bridging to the api gateway. */
  2. import { EventEmitter } from 'node:events'
  3. import { createServer, request as httpRequest } from 'node:http'
  4. import { Readable } from 'node:stream'
  5. import { Context } from '@deepseek-ai/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 { AttachmentStore } from '@deepseek-ai/dsh-attachment'
  10. import type { IndexInjection, WebServer, WebRoute, WebUpgradeRoute } from '@deepseek-ai/dsh-host-webserver'
  11. import { API_PATH, RpcId, apply, inject, type ClientRequest, type ConnectionConfig, type HostConnectionHandle } from '../src/index.ts'
  12. import { DEFAULT_MAX_REQUEST_BODY_BYTES } from '../src/http-bridge.ts'
  13. import { provideBrowserCredentials } from './browser-credentials.ts'
  14. /** Structural webServer fake recording both route registries. */
  15. function fakeHttpServer(
  16. routes: WebRoute[],
  17. upgrades: WebUpgradeRoute[],
  18. ): Pick<WebServer, 'register' | 'registerUpgrade' | 'tapIndex' | 'port'> {
  19. return {
  20. register(route) {
  21. if (routes.some(candidate => candidate.kind === route.kind && candidate.path === route.path)) {
  22. throw new Error(`duplicate route ${route.path}`)
  23. }
  24. routes.push(route)
  25. return () => { routes.splice(routes.indexOf(route), 1) }
  26. },
  27. registerUpgrade(route) {
  28. upgrades.push(route)
  29. return () => { upgrades.splice(upgrades.indexOf(route), 1) }
  30. },
  31. tapIndex: () => () => {},
  32. port: 0,
  33. }
  34. }
  35. /** Bodyless GET carrying the given headers (enough for the trust fence + bridge). */
  36. function fakeRequest(headers: Record<string, string>, url = `${API_PATH}/session.list`): IncomingMessage {
  37. const request = Readable.from([]) as unknown as IncomingMessage
  38. Object.assign(request, { url, method: 'GET', headers })
  39. return request
  40. }
  41. /** JSON POST carrying a complete client-request envelope. */
  42. function fakePost(headers: Record<string, string>, url: string, body: unknown): IncomingMessage {
  43. const request = Readable.from([Buffer.from(JSON.stringify(body))]) as unknown as IncomingMessage
  44. Object.assign(request, { url, method: 'POST', headers: { 'content-type': 'application/json', ...headers } })
  45. return request
  46. }
  47. /** Raw POST for malformed-body and media-type boundary cases. */
  48. function fakeRawPost(headers: Record<string, string>, url: string, body: string): IncomingMessage {
  49. const request = Readable.from([Buffer.from(body)]) as unknown as IncomingMessage
  50. Object.assign(request, { url, method: 'POST', headers })
  51. return request
  52. }
  53. /** Response recorder compatible with both the fence's short-circuit and the bridge. */
  54. function fakeResponse(): {
  55. response: ServerResponse
  56. state: { status?: number; headers?: Record<string, string>; body?: unknown }
  57. } {
  58. const state: { status?: number; headers?: Record<string, string>; body?: unknown } = {}
  59. const chunks: Buffer[] = []
  60. const response = Object.assign(new EventEmitter(), {
  61. writableEnded: false,
  62. writeHead(value: number, headers?: Record<string, string>) {
  63. state.status = value
  64. if (headers !== undefined) state.headers = headers
  65. return this
  66. },
  67. write(value: string | Uint8Array) { chunks.push(Buffer.from(value)); return true },
  68. end(this: { writableEnded: boolean }, value?: unknown) {
  69. if (typeof value === 'string' || value instanceof Uint8Array) chunks.push(Buffer.from(value))
  70. else if (value !== undefined) throw new TypeError('fake response only accepts string or Uint8Array bodies')
  71. if (chunks.length > 0) state.body = Buffer.concat(chunks).toString()
  72. this.writableEnded = true
  73. return this
  74. },
  75. }) as unknown as ServerResponse
  76. return { response, state }
  77. }
  78. async function mounted(config?: ConnectionConfig): Promise<{
  79. ctx: Context
  80. routes: WebRoute[]
  81. upgrades: WebUpgradeRoute[]
  82. connection: HostConnectionHandle
  83. dispose: () => Promise<void>
  84. }> {
  85. const ctx = new Context()
  86. const routes: WebRoute[] = []
  87. const upgrades: WebUpgradeRoute[] = []
  88. provideBrowserCredentials(ctx)
  89. ctx.provide('webServer', fakeHttpServer(routes, upgrades) as WebServer)
  90. const fiber = ctx.plugin({ inject: [...inject], apply }, config)
  91. await fiber.await()
  92. return {
  93. ctx,
  94. routes,
  95. upgrades,
  96. connection: ctx.get('connection') as HostConnectionHandle,
  97. dispose: () => fiber.dispose(),
  98. }
  99. }
  100. /** Exchange a service's process token for one authority-bound Cookie header. */
  101. function browserCookie(connection: HostConnectionHandle, authority: string): string {
  102. const url = new URL(connection.authenticatedUrl(`http://${authority}`))
  103. const exchanged = fakeResponse()
  104. connection.authorizeIndex(
  105. fakeRequest({ host: authority }, `${url.pathname}${url.search}`),
  106. exchanged.response,
  107. )
  108. const setCookie = exchanged.state.headers?.['set-cookie']
  109. if (setCookie === undefined) throw new Error('browser token exchange did not set a cookie')
  110. return setCookie.split(';', 1)[0]!
  111. }
  112. describe('connection node half', () => {
  113. it('runs request admission after authentication and removes it with its owning fiber', async () => {
  114. const { ctx, routes, connection, dispose } = await mounted()
  115. let admitted = 0
  116. const guard = ctx.plugin({ apply(owner: Context) {
  117. owner.on('connection/request', async (_request, response) => {
  118. admitted++
  119. response.writeHead(503)
  120. response.end()
  121. })
  122. } })
  123. try {
  124. await guard.await()
  125. const unauthorized = fakeResponse()
  126. await routes[0]!.handler(fakeRequest({ host: 'localhost' }), unauthorized.response)
  127. expect(unauthorized.state.status).toBe(401)
  128. expect(admitted).toBe(0)
  129. const headers = { host: 'localhost', cookie: browserCookie(connection, 'localhost') }
  130. const refused = fakeResponse()
  131. await routes[0]!.handler(fakeRequest(headers), refused.response)
  132. expect(refused.state.status).toBe(503)
  133. expect(admitted).toBe(1)
  134. await guard.dispose()
  135. const allowed = fakeResponse()
  136. await routes[0]!.handler(fakeRequest(headers), allowed.response)
  137. expect(allowed.state.status).toBe(404)
  138. expect(admitted).toBe(1)
  139. } finally { await guard.dispose(); await dispose() }
  140. })
  141. it('awaits delegated response transfer before releasing the admission listener', async () => {
  142. const { ctx, routes, connection, dispose } = await mounted()
  143. const entered = Promise.withResolvers<undefined>()
  144. const finish = Promise.withResolvers<undefined>()
  145. let completed = false
  146. connection.fetch.register({ path: '/api/held', methods: ['GET'], requestBody: 'buffered',
  147. async fetch() {
  148. return new Response(new ReadableStream({ async start(controller) {
  149. entered.resolve(undefined)
  150. await finish.promise
  151. controller.close()
  152. } }))
  153. },
  154. })
  155. const remove = ctx.on('connection/request', async (_request, _response, next) => {
  156. await next()
  157. completed = true
  158. })
  159. const response = fakeResponse()
  160. const pending = routes[0]!.handler(fakeRequest({ host: 'localhost', cookie: browserCookie(connection, 'localhost') }, '/api/held'), response.response)
  161. try {
  162. await entered.promise
  163. expect(completed).toBe(false)
  164. finish.resolve(undefined)
  165. await pending
  166. expect(completed).toBe(true)
  167. } finally { finish.resolve(undefined); await pending; remove(); await dispose() }
  168. })
  169. it('provides the carrier-neutral service without a Web server', async () => {
  170. const ctx = new Context()
  171. provideBrowserCredentials(ctx)
  172. const fiber = ctx.plugin({ inject: [...inject], apply })
  173. await fiber.await()
  174. expect(ctx.get('connection')).toBeInstanceOf(Object)
  175. await fiber.dispose()
  176. })
  177. it('injects validated browser recovery timing and withdraws it on disposal', async () => {
  178. const { ctx, dispose } = await mounted({ recovery: { generationReadyTimeoutMs: 25_000 } })
  179. try {
  180. const rows: IndexInjection[] = []
  181. ctx.emit('webserver/index-inject', rows)
  182. expect(rows).toEqual([{
  183. kind: 'global', name: '__DSH_CONNECTION_RECOVERY__', value: {
  184. backoffBaseMs: 500, backoffFactor: 2, backoffMaxMs: 10_000,
  185. generationReadyWarnMs: 3_000, generationReadyTimeoutMs: 25_000,
  186. },
  187. }])
  188. await dispose()
  189. const after: IndexInjection[] = []
  190. ctx.emit('webserver/index-inject', after)
  191. expect(after).toEqual([])
  192. } finally {
  193. await dispose()
  194. }
  195. })
  196. it.each([
  197. { recovery: { backoffBaseMs: 0 }, error: /backoffBaseMs/ },
  198. { recovery: { backoffFactor: NaN }, error: /backoffFactor.*finite/ },
  199. ])('rejects invalid recovery timing before acquiring Host resources: $recovery', async ({ recovery, error }) => {
  200. const ctx = new Context()
  201. await expect(apply(ctx, { recovery })).rejects.toThrow(error)
  202. expect(ctx.get('connection')).toBeUndefined()
  203. })
  204. it('reserves enough default carrier capacity for the 200 MiB image batch', () => {
  205. expect(DEFAULT_MAX_REQUEST_BODY_BYTES).toBe(300 * 1024 * 1024)
  206. expect(DEFAULT_MAX_REQUEST_BODY_BYTES).toBeGreaterThan(Math.ceil(200 * 1024 * 1024 * 4 / 3) + 1024 * 1024)
  207. })
  208. it('fails loud when the carrier cap cannot hold the configured image batch', async () => {
  209. const ctx = new Context()
  210. const routes: WebRoute[] = []
  211. ctx.provide('webServer', fakeHttpServer(routes, []) as WebServer)
  212. ctx.provide('attachments', {
  213. imageLimits: { maxMessageImageBytes: 20 * 1024 * 1024 },
  214. } as AttachmentStore)
  215. await expect(apply(ctx, { maxRequestBodyBytes: 1024 }))
  216. .rejects.toThrow(/must be at least .* aggregate image limit/)
  217. expect(routes).toHaveLength(0)
  218. })
  219. it('fails the load on a trustedHosts entry that is not a bare authority', async () => {
  220. const routes: WebRoute[] = []
  221. const upgrades: WebUpgradeRoute[] = []
  222. const ctx = new Context()
  223. provideBrowserCredentials(ctx)
  224. ctx.provide('webServer', fakeHttpServer(routes, upgrades) as WebServer)
  225. const fiber = ctx.plugin({ inject: [...inject], apply }, { trustedHosts: ['harness.internal/path'] })
  226. await expect(fiber).rejects.toThrow(/not a bare host\[:port\] authority/)
  227. expect(routes).toHaveLength(0)
  228. expect(upgrades).toHaveLength(0)
  229. })
  230. it('registers only the HTTP route and removes it with the fiber', async () => {
  231. const { routes, upgrades, dispose } = await mounted()
  232. expect(routes).toHaveLength(1)
  233. expect(routes[0]).toMatchObject({ kind: 'prefix', path: API_PATH })
  234. expect(upgrades).toHaveLength(0)
  235. await dispose()
  236. expect(routes).toHaveLength(0)
  237. expect(upgrades).toHaveLength(0)
  238. })
  239. it('refuses an untrusted Host on any /api path before the bridge runs', async () => {
  240. const { routes, dispose } = await mounted()
  241. const { response, state } = fakeResponse()
  242. await routes[0]!.handler(fakeRequest({
  243. host: 'harness.example', origin: 'http://harness.example', 'sec-fetch-site': 'same-origin',
  244. }), response)
  245. expect(state.status).toBe(403)
  246. expect(state.body).toBe('forbidden')
  247. await dispose()
  248. })
  249. it('requires the same browser session for every method on every trusted authority', async () => {
  250. const { routes, connection, dispose } = await mounted({ trustedHosts: ['harness.example'] })
  251. const methods = [
  252. 'session/openWorkspacePath',
  253. 'llm/discoverModels', 'skills/list', 'settings/openAgentPresetDirectory',
  254. ]
  255. for (const method of methods) {
  256. const denied = fakeResponse()
  257. await routes[0]!.handler(fakeRequest({ host: 'harness.example' }, `${API_PATH}/${method}`), denied.response)
  258. expect([method, denied.state.status, denied.state.body]).toEqual([method, 401, 'unauthorized'])
  259. }
  260. const cookie = browserCookie(connection, 'harness.example')
  261. for (const method of methods) {
  262. const allowed = fakeResponse()
  263. await routes[0]!.handler(
  264. fakeRequest({ host: 'harness.example', cookie }, `${API_PATH}/${method}`),
  265. allowed.response,
  266. )
  267. expect([method, allowed.state.status]).toEqual([method, 404])
  268. }
  269. const forged = fakeResponse()
  270. await routes[0]!.handler(fakeRequest({ host: 'localhost:3080' }), forged.response)
  271. expect(forged.state).toMatchObject({ status: 401, body: 'unauthorized' })
  272. await dispose()
  273. })
  274. it('passes loopback and declared-authority requests through to the bridge', async () => {
  275. const { routes, connection, dispose } = await mounted({ trustedHosts: ['harness.example:3080', '192.168.1.5'] })
  276. // Loopback, no browser markers (curl shape): the fence passes; the carrier
  277. // answers 404 for a GET unary path — proof the bridge ran.
  278. const loopback = fakeResponse()
  279. await routes[0]!.handler(fakeRequest({
  280. host: '127.0.0.1:3080',
  281. cookie: browserCookie(connection, '127.0.0.1:3080'),
  282. }), loopback.response)
  283. expect(loopback.state.status).toBe(404)
  284. // An all-interfaces composition derives port-less LAN IP literals, which
  285. // pass markerless curl on any port.
  286. const lan = fakeResponse()
  287. await routes[0]!.handler(fakeRequest({
  288. host: '192.168.1.5:3080',
  289. cookie: browserCookie(connection, '192.168.1.5:3080'),
  290. }), lan.response)
  291. expect(lan.state.status).toBe(404)
  292. // Declared public authority, same-origin browser shape.
  293. const declared = fakeResponse()
  294. await routes[0]!.handler(fakeRequest({
  295. host: 'harness.example:3080',
  296. origin: 'http://harness.example:3080',
  297. 'sec-fetch-site': 'same-origin',
  298. cookie: browserCookie(connection, 'harness.example:3080'),
  299. }), declared.response)
  300. expect(declared.state.status).toBe(404)
  301. await dispose()
  302. })
  303. it('shares its configured trust and authentication policy with sibling routes', async () => {
  304. const { connection, dispose } = await mounted({ trustedHosts: ['harness.example'] })
  305. const loopback = fakeRequest({ host: '127.0.0.1:3080' })
  306. const declared = fakeRequest({ host: 'harness.example' })
  307. expect(connection.requestRejection(loopback)).toBe(401)
  308. expect(connection.requestRejection(declared)).toBe(401)
  309. expect(connection.requestRejection(fakeRequest({
  310. host: 'harness.example',
  311. cookie: browserCookie(connection, 'harness.example'),
  312. }))).toBeUndefined()
  313. await dispose()
  314. })
  315. it('provides a disposable dedicated RPC channel', async () => {
  316. const ctx = new Context()
  317. const routes: WebRoute[] = []
  318. provideBrowserCredentials(ctx)
  319. ctx.provide('webServer', fakeHttpServer(routes, []) as WebServer)
  320. const fiber = ctx.plugin({ inject: [...inject], apply })
  321. await fiber.await()
  322. expect(routes).toHaveLength(1)
  323. expect(routes[0]).toMatchObject({ kind: 'prefix', path: API_PATH })
  324. const connection = ctx.get('connection') as HostConnectionHandle
  325. const calls: unknown[] = []
  326. const remove = connection.rpc.handle('/rpc', async (endpoint, payload) => {
  327. calls.push({ endpoint, payload })
  328. return { ok: true, value: { accepted: true } }
  329. })
  330. const route = routes.find(candidate => candidate.path === '/rpc')
  331. expect(route).toBeDefined()
  332. const request: ClientRequest = {
  333. type: 'client-request',
  334. rpcId: RpcId('rpc-dedicated'),
  335. method: 'goals/create',
  336. payload: { args: { agentId: 'agent-1' } },
  337. }
  338. const result = fakeResponse()
  339. await route!.handler(fakePost({
  340. host: '127.0.0.1:3080',
  341. cookie: browserCookie(connection, '127.0.0.1:3080'),
  342. }, '/rpc/goals/create', request), result.response)
  343. expect(result.state.status).toBe(200)
  344. expect(JSON.parse(String(result.state.body))).toEqual({
  345. type: 'server-response',
  346. rpcId: 'rpc-dedicated',
  347. result: { ok: true, value: { accepted: true } },
  348. })
  349. expect(calls).toEqual([{
  350. endpoint: 'goals/create',
  351. payload: { args: { agentId: 'agent-1' } },
  352. }])
  353. expect(() => connection.rpc.handle('/rpc', async () => ({ ok: true, value: null })))
  354. .toThrow(/duplicate route/)
  355. await remove()
  356. expect(routes.map(candidate => candidate.path)).toEqual([API_PATH])
  357. await fiber.dispose()
  358. expect(routes).toHaveLength(0)
  359. })
  360. it('dispatches claimed /api endpoints and withdraws the claim', async () => {
  361. const ctx = new Context()
  362. const routes: WebRoute[] = []
  363. provideBrowserCredentials(ctx)
  364. ctx.provide('webServer', fakeHttpServer(routes, []) as WebServer)
  365. const fiber = ctx.plugin({ inject: [...inject], apply }, { trustedHosts: ['harness.example'] })
  366. await fiber.await()
  367. const connection = ctx.get('connection') as HostConnectionHandle
  368. const calls: unknown[] = []
  369. const remove = connection.rpc.intercept(
  370. '/api',
  371. endpoint => endpoint === 'goals/create',
  372. async (endpoint, payload) => {
  373. calls.push({ endpoint, payload })
  374. return { ok: true, value: { accepted: true } }
  375. },
  376. )
  377. expect(() => connection.rpc.intercept(
  378. '/api',
  379. () => true,
  380. async () => ({ ok: true, value: null }),
  381. )).toThrow('already has an interceptor')
  382. expect(() => connection.rpc.intercept(
  383. '/rpc' as '/api',
  384. () => true,
  385. async () => ({ ok: true, value: null }),
  386. )).toThrow('invalid shared RPC channel')
  387. const route = routes.find(candidate => candidate.path === API_PATH)!
  388. const request: ClientRequest = {
  389. type: 'client-request',
  390. rpcId: RpcId('rpc-shared'),
  391. method: 'goals/create',
  392. payload: { args: { agentId: 'agent-1' } },
  393. }
  394. const claimed = fakeResponse()
  395. const loopbackCookie = browserCookie(connection, '127.0.0.1:3080')
  396. await route.handler(fakePost({
  397. host: '127.0.0.1:3080', cookie: loopbackCookie,
  398. }, '/api/goals/create', request), claimed.response)
  399. expect(JSON.parse(String(claimed.state.body))).toEqual({
  400. type: 'server-response',
  401. rpcId: 'rpc-shared',
  402. result: { ok: true, value: { accepted: true } },
  403. })
  404. expect(calls).toEqual([{
  405. endpoint: 'goals/create',
  406. payload: { args: { agentId: 'agent-1' } },
  407. }])
  408. const denied = fakeResponse()
  409. await route.handler(fakePost({ host: 'other.example' }, '/api/goals/create', request), denied.response)
  410. expect(denied.state).toMatchObject({ status: 403, body: 'forbidden' })
  411. expect(calls).toHaveLength(1)
  412. const unclaimed = fakeResponse()
  413. await route.handler(fakeRequest({
  414. host: '127.0.0.1:3080', cookie: loopbackCookie,
  415. }, '/api/session.list'), unclaimed.response)
  416. expect(unclaimed.state.status).toBe(404)
  417. await remove()
  418. const withdrawn = fakeResponse()
  419. await route.handler(fakePost({
  420. host: '127.0.0.1:3080', cookie: loopbackCookie,
  421. }, '/api/goals/create', request), withdrawn.response)
  422. expect(withdrawn.state.status).toBe(404)
  423. expect(calls).toHaveLength(1)
  424. const removeAuthenticated = connection.rpc.intercept(
  425. '/api',
  426. endpoint => endpoint === 'goals/create',
  427. async () => ({ ok: true, value: null }),
  428. )
  429. const declared = fakeResponse()
  430. await route.handler(fakePost({
  431. host: 'harness.example',
  432. cookie: browserCookie(connection, 'harness.example'),
  433. }, '/api/goals/create', request), declared.response)
  434. expect(declared.state.status).toBe(200)
  435. await removeAuthenticated()
  436. await fiber.dispose()
  437. })
  438. it('applies the configured trust fence and JSON envelope checks to generic channels', async () => {
  439. const ctx = new Context()
  440. const routes: WebRoute[] = []
  441. provideBrowserCredentials(ctx)
  442. ctx.provide('webServer', fakeHttpServer(routes, []) as WebServer)
  443. const fiber = ctx.plugin({ inject: [...inject], apply }, { trustedHosts: ['harness.example'] })
  444. await fiber.await()
  445. const connection = ctx.get('connection') as HostConnectionHandle
  446. const remove = connection.rpc.handle('/rpc', async (endpoint) => {
  447. if (endpoint === 'fail') throw new Error('handler broke')
  448. return { ok: true, value: null }
  449. })
  450. const route = routes.find(candidate => candidate.path === '/rpc')!
  451. const harnessHeaders = {
  452. host: 'harness.example',
  453. cookie: browserCookie(connection, 'harness.example'),
  454. }
  455. const denied = fakeResponse()
  456. await route.handler(fakePost({ host: 'other.example' }, '/rpc/goals/create', {}), denied.response)
  457. expect(denied.state).toMatchObject({ status: 403, body: 'forbidden' })
  458. const unauthenticated = fakeResponse()
  459. await route.handler(fakePost({ host: 'harness.example' }, '/rpc/goals/create', {}), unauthenticated.response)
  460. expect(unauthenticated.state).toMatchObject({ status: 401, body: 'unauthorized' })
  461. const methodMismatch = fakeResponse()
  462. await route.handler(fakePost(harnessHeaders, '/rpc/goals/create', {
  463. type: 'client-request', rpcId: 'rpc-bad', method: 'other', payload: {},
  464. }), methodMismatch.response)
  465. expect(JSON.parse(String(methodMismatch.state.body))).toMatchObject({
  466. rpcId: 'rpc-bad',
  467. result: { ok: false, error: { code: 'gateway/bad-request' } },
  468. })
  469. for (const [request, status] of [
  470. [fakeRequest(harnessHeaders, '/rpc/goals/create'), 404],
  471. [fakePost(harnessHeaders, '/outside/goals/create', {}), 404],
  472. [fakePost(harnessHeaders, '/rpc/goals//create', {}), 404],
  473. [fakeRawPost(harnessHeaders, '/rpc/goals/create', '{}'), 415],
  474. [fakeRawPost({ ...harnessHeaders, 'content-type': 'text/plain' }, '/rpc/goals/create', '{}'), 415],
  475. [fakeRawPost({ ...harnessHeaders, 'content-type': 'application/json; charset=utf-8' }, '/rpc/goals/create', '{'), 400],
  476. ] as const) {
  477. const response = fakeResponse()
  478. await route.handler(request, response.response)
  479. expect(response.state.status).toBe(status)
  480. }
  481. for (const [body, rpcId] of [
  482. [{ rpcId: 'retained-id' }, 'retained-id'],
  483. [{ rpcId: 42 }, 'invalid-request'],
  484. [null, 'invalid-request'],
  485. ] as const) {
  486. const response = fakeResponse()
  487. await route.handler(fakePost(harnessHeaders, '/rpc/goals/create', body), response.response)
  488. expect(JSON.parse(String(response.state.body))).toMatchObject({
  489. rpcId,
  490. result: { ok: false, error: { code: 'gateway/bad-request' } },
  491. })
  492. }
  493. const failed = fakeResponse()
  494. await route.handler(fakePost(harnessHeaders, '/rpc/fail', {
  495. type: 'client-request', rpcId: 'rpc-fail', method: 'fail', payload: {},
  496. }), failed.response)
  497. expect(failed.state).toMatchObject({ status: 500, body: 'handler failure: Error: handler broke' })
  498. expect(() => connection.rpc.handle('/api', async () => ({ ok: true, value: null })))
  499. .toThrow('invalid or reserved RPC channel')
  500. expect(() => connection.rpc.handle('api3', async () => ({ ok: true, value: null })))
  501. .toThrow('invalid or reserved RPC channel')
  502. await remove()
  503. await fiber.dispose()
  504. })
  505. })
  506. describe('connection node half over a real HTTP server', () => {
  507. /** Serve the registered prefix route from a real server and return its port. */
  508. async function serve(routes: WebRoute[]): Promise<{ port: number; close: () => Promise<void> }> {
  509. const server = createServer((request, response) => {
  510. void routes[0]!.handler(request, response)
  511. })
  512. await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
  513. const address = server.address() as AddressInfo
  514. return {
  515. port: address.port,
  516. close: () => new Promise<void>((resolve, reject) => {
  517. server.close((error) => {
  518. if (error === undefined || error === null) resolve()
  519. else reject(error)
  520. })
  521. }),
  522. }
  523. }
  524. /** One real request; `host` spoofs the authority the way a LAN client's browser would send it. */
  525. function call(port: number, method: string, host: string, cookie?: string): Promise<number> {
  526. return new Promise((resolve, reject) => {
  527. const request = httpRequest(
  528. {
  529. host: '127.0.0.1',
  530. port,
  531. path: `${API_PATH}/${method}`,
  532. method: 'GET',
  533. headers: { host, ...cookie === undefined ? {} : { cookie } },
  534. },
  535. (response) => {
  536. response.resume()
  537. response.on('end', () => { resolve(response.statusCode ?? 0) })
  538. },
  539. )
  540. request.on('error', reject)
  541. request.end()
  542. })
  543. }
  544. it('requires authentication uniformly over a real HTTP request', async () => {
  545. // A real IncomingMessage pins the exploit boundary: a client-controlled
  546. // Host naming loopback passes the rebinding fence but never authenticates.
  547. const { routes, connection, dispose } = await mounted({ trustedHosts: ['harness.example'] })
  548. const { port, close } = await serve(routes)
  549. try {
  550. const methods = [
  551. 'settings/openSettingsDocument',
  552. 'session/openWorkspacePath',
  553. 'llm/discoverModels', 'skills/list',
  554. 'settings/openAgentPresetDirectory',
  555. 'llm/listProviders', 'session/modelCatalog',
  556. ]
  557. for (const method of methods) {
  558. expect([method, await call(port, method, 'localhost')]).toEqual([method, 401])
  559. expect([method, await call(port, method, 'harness.example')]).toEqual([method, 401])
  560. }
  561. expect(await call(port, 'settings/openSettingsDocument', 'other.example')).toBe(403)
  562. const declaredCookie = browserCookie(connection, 'harness.example')
  563. for (const method of methods) {
  564. expect([method, await call(port, method, 'harness.example', declaredCookie)]).toEqual([method, 404])
  565. }
  566. const loopbackAuthority = `127.0.0.1:${String(port)}`
  567. expect(await call(
  568. port,
  569. 'settings/openSettingsDocument',
  570. loopbackAuthority,
  571. browserCookie(connection, loopbackAuthority),
  572. )).toBe(404)
  573. } finally {
  574. await close()
  575. await dispose()
  576. }
  577. })
  578. })