index.ts 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198
  1. /** Host HTTP bridge for browser-client RPC. */
  2. import type { Context } from '@deepseek-ai/cordis'
  3. import z from '@deepseek-ai/schemastery'
  4. import type {} from '@deepseek-ai/dsh-attachment'
  5. // Activates the httpServer Context merge used below.
  6. import type { WebRoute, WebUpgradeRoute } from '@deepseek-ai/dsh-host-webserver'
  7. import { toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy'
  8. import { API_PATH, HOST_EVENTS_PATH, MUX_EVENTS_PATH } from './api-path.ts'
  9. import { bridge } from './http-bridge.ts'
  10. import { assertTrustedAuthority, isTrustedApiRequest } from './api-request-trust.ts'
  11. import { HostConnectionService } from './rpc-host.ts'
  12. import { rejectWebSocketUpgrade, WebSocketDownlinks } from './websocket-downlink.ts'
  13. export type {
  14. ConnectionRpcAuthority,
  15. ConnectionRpcEndpointMatcher,
  16. ConnectionRpcHandler,
  17. ConnectionRpcHandlerOptions,
  18. HostConnectionHandle,
  19. HostConnectionRpc,
  20. } from './rpc.ts'
  21. export { HostConnectionService } from './rpc-host.ts'
  22. export { API_PATH, HOST_EVENTS_PATH, MUX_EVENTS_PATH } from './api-path.ts'
  23. /** Stable Cordis plugin name. */
  24. export const name = 'client-connection'
  25. /** Headroom for RPC JSON fields around aggregate base64 image payloads. */
  26. const REQUEST_ENVELOPE_HEADROOM_BYTES = 1024 * 1024
  27. function assertImageBodyCapacity(ctx: Context, maxRequestBodyBytes: number): void {
  28. const attachments = ctx.get('attachments')
  29. if (attachments === undefined) return
  30. const requiredImageBodyBytes = Math.ceil(
  31. attachments.imageLimits.maxMessageImageBytes * 4 / 3,
  32. ) + REQUEST_ENVELOPE_HEADROOM_BYTES
  33. if (maxRequestBodyBytes < requiredImageBodyBytes) {
  34. throw new Error(
  35. `client-connection maxRequestBodyBytes (${String(maxRequestBodyBytes)}) must be at least `
  36. + `${String(requiredImageBodyBytes)} for the configured aggregate image limit`,
  37. )
  38. }
  39. }
  40. /** Default carrier cap for all HTTP RPC bodies. */
  41. const DEFAULT_MAX_REQUEST_BODY_BYTES = 32 * 1024 * 1024
  42. /** Services required before providing Connection; API Proxy is an optional `/api` fallback. */
  43. export const inject = ['httpServer']
  44. /** Plugin config: the deployment's non-loopback serving authorities. */
  45. export interface ConnectionConfig {
  46. /**
  47. * Authorities this deployment serves beyond loopback: exact `host:port`, or
  48. * port-less `host` matching any port. The /api trust fence refuses any
  49. * request whose Host is neither loopback nor listed here, so a
  50. * non-loopback (`0.0.0.0`) deployment must declare the names it is reached
  51. * by (the dsh CLI derives the machine's LAN IP literals itself). An entry
  52. * that is not a bare, canonical authority fails the plugin load.
  53. */
  54. trustedHosts?: string[]
  55. /** Maximum buffered JSON body for every `/api` request. */
  56. maxRequestBodyBytes?: number
  57. }
  58. export const Config: z<ConnectionConfig> = z.object({
  59. trustedHosts: z.array(String).default([]),
  60. maxRequestBodyBytes: z.natural().min(1).default(DEFAULT_MAX_REQUEST_BODY_BYTES),
  61. })
  62. /**
  63. * Methods gated to loopback even on a trusted-host deployment. Native dialogs
  64. * act on the host machine; the settings and credential domains mutate the
  65. * user's configuration and secret store, and READING them is equally
  66. * privileged — `settings.describe` returns every exposed namespace's
  67. * configuration and `credentials.describe` reports whether an arbitrary
  68. * environment-variable name is configured and where from, which is
  69. * reconnaissance no anonymous caller should have. `trustedHosts` is a
  70. * DNS-rebinding fence, explicitly not authentication, so the whole
  71. * configuration plane stays loopback-same-origin until a real authentication
  72. * layer exists. `llm.discoverModels` belongs to that plane on both counts: it
  73. * carries a draft credential, and it makes the HOST issue a GET to a URL the
  74. * caller chose and reports back the status or the parsed body — an anonymous
  75. * LAN caller would have a probe for whatever the host can reach and the
  76. * browser cannot.
  77. *
  78. * The model catalog (`llm.providers`, `llm.models`) is deliberately NOT here:
  79. * it carries provider ids, display names, and model lists — no endpoints,
  80. * keys, or key state — and a LAN client's model picker legitimately needs it.
  81. */
  82. const PRIVILEGED_METHODS = new Set([
  83. // A preset composition names the plugins a session runs, so reading one is
  84. // reconnaissance; copy and remove rearrange what the deployment offers, and
  85. // openDocument drives the host desktop — all more than the roster beside
  86. // them. (Authoring is copy-only, so no method here accepts composition text
  87. // or a path; the pin is about who may manage the roster at all.)
  88. //
  89. // CHOOSING one is not pinned, and `agentPreset.list` is not either. Picking a
  90. // preset looks like escalation — one of them mounts the toolset that edits the
  91. // live runtime — but `session.create` already takes an `agentPreset`, so
  92. // pinning only the switch would leave the same capability one method over.
  93. // The deeper reason is that the capability is not the preset's to grant: the
  94. // deployment's own default already carries `bash` and the filesystem tools, so
  95. // any caller that may start a session at all can already run commands as this
  96. // process. Pinning the switch would be a fence beside an open gate.
  97. 'agentPreset.read',
  98. 'agentPreset.copy',
  99. 'agentPreset.openDocument',
  100. 'agentPreset.remove',
  101. 'host.pickDirectory',
  102. 'host.openPath',
  103. 'settings.describe',
  104. 'settings.openDocument',
  105. 'settings.update',
  106. 'settings.replace',
  107. 'settings.mutate',
  108. 'credentials.describe',
  109. 'credentials.set',
  110. 'credentials.unset',
  111. 'llm.discoverModels',
  112. ])
  113. /**
  114. * Mounts the API gateway under the browser transport prefix. Every request on
  115. * the prefix passes the browser-trust fence first (DNS-rebinding and
  116. * cross-site defense — [api-request-trust](./api-request-trust.ts));
  117. * privileged methods additionally pass it with an empty trust list, which
  118. * pins them to loopback.
  119. * @param ctx - Host plugin context.
  120. * @param config - resolved plugin config (schema defaults applied).
  121. */
  122. export function apply(ctx: Context, config?: ConnectionConfig): void {
  123. // The Loader resolves schema defaults; hand-built test contexts may pass none.
  124. const trustedHosts = config?.trustedHosts ?? []
  125. const maxRequestBodyBytes = config?.maxRequestBodyBytes ?? DEFAULT_MAX_REQUEST_BODY_BYTES
  126. // Config boundary: a malformed entry fails the load loudly here rather than
  127. // silently authorizing its hostname prefix at request time.
  128. for (const entry of trustedHosts) assertTrustedAuthority(entry)
  129. if (ctx.get('apiProxy') !== undefined) assertImageBodyCapacity(ctx, maxRequestBodyBytes)
  130. const connection = new HostConnectionService(ctx, trustedHosts)
  131. const fetchHandler = connection.createSharedFetchHandler(API_PATH, {
  132. async fetch(request) {
  133. const pathname = new URL(request.url).pathname
  134. const method = pathname.startsWith(`${API_PATH}/`)
  135. ? pathname.slice(API_PATH.length + 1)
  136. : undefined
  137. if (method !== undefined
  138. && PRIVILEGED_METHODS.has(method)
  139. && !isTrustedApiRequest(request, [])) {
  140. return new Response('forbidden', { status: 403 })
  141. }
  142. if (request.method === 'GET' && (pathname === MUX_EVENTS_PATH || pathname === HOST_EVENTS_PATH)) {
  143. return new Response('upgrade required', {
  144. status: 426,
  145. headers: { connection: 'Upgrade', upgrade: 'websocket' },
  146. })
  147. }
  148. const apiProxy = ctx.get('apiProxy')
  149. if (apiProxy === undefined) return new Response('not found', { status: 404 })
  150. return toFetchHandler(apiProxy).fetch(request)
  151. },
  152. })
  153. const route: WebRoute = {
  154. kind: 'prefix',
  155. path: API_PATH,
  156. handler: async (req, res) => {
  157. if (!isTrustedApiRequest(req, trustedHosts)) {
  158. res.writeHead(403)
  159. res.end('forbidden')
  160. return
  161. }
  162. await bridge(req, res, fetchHandler, maxRequestBodyBytes)
  163. },
  164. }
  165. ctx.effect(() => ctx.httpServer.register(route), 'client-connection: /api route')
  166. ctx.inject(['apiProxy'], (apiCtx) => {
  167. assertImageBodyCapacity(apiCtx, maxRequestBodyBytes)
  168. const downlinks = new WebSocketDownlinks(apiCtx.apiProxy)
  169. const registerDownlink = (
  170. path: string,
  171. handle: WebUpgradeRoute['handler'],
  172. ): void => {
  173. apiCtx.effect(() => apiCtx.httpServer.registerUpgrade({
  174. path,
  175. handler: (req, socket, head) => {
  176. if (!isTrustedApiRequest(req, trustedHosts)) {
  177. rejectWebSocketUpgrade(socket)
  178. return
  179. }
  180. return handle(req, socket, head)
  181. },
  182. }), `client-connection: ${path} WebSocket`)
  183. }
  184. apiCtx.effect(() => () => downlinks.close(), 'client-connection: WebSocket downlinks')
  185. registerDownlink(MUX_EVENTS_PATH, (req, socket, head) => { downlinks.handleMux(req, socket, head) })
  186. registerDownlink(HOST_EVENTS_PATH, (req, socket, head) => { downlinks.handleHost(req, socket, head) })
  187. })
  188. }