node-half.host.spec.ts 22 KB

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