node-half.host.spec.ts 22 KB

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