index.ts 5.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124
  1. /** Host HTTP bridge for browser-client RPC. */
  2. import type { Context } from 'cordis'
  3. import z from 'schemastery'
  4. // Activates the httpServer Context merge used below.
  5. import type { WebRoute, WebUpgradeRoute } from '@deepseek-ai/dsh-host-webserver'
  6. import { toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy'
  7. import { API_PATH, HOST_EVENTS_PATH, MUX_EVENTS_PATH } from './api-path.ts'
  8. import { bridge } from './http-bridge.ts'
  9. import { assertTrustedAuthority, isTrustedApiRequest } from './api-request-trust.ts'
  10. import { rejectWebSocketUpgrade, WebSocketDownlinks } from './websocket-downlink.ts'
  11. export { API_PATH, HOST_EVENTS_PATH, MUX_EVENTS_PATH } from './api-path.ts'
  12. /** Stable Cordis plugin name. */
  13. export const name = 'client-connection'
  14. /** Services required before mounting the route. */
  15. export const inject = ['httpServer', 'apiProxy']
  16. /** Plugin config: the deployment's non-loopback serving authorities. */
  17. export interface ConnectionConfig {
  18. /**
  19. * Authorities this deployment serves beyond loopback: exact `host:port`, or
  20. * port-less `host` matching any port. The /api trust fence refuses any
  21. * request whose Host is neither loopback nor listed here, so a
  22. * non-loopback (`0.0.0.0`) deployment must declare the names it is reached
  23. * by (the dsh CLI derives the machine's LAN IP literals itself). An entry
  24. * that is not a bare, canonical authority fails the plugin load.
  25. */
  26. trustedHosts?: string[]
  27. }
  28. export const Config: z<ConnectionConfig> = z.object({
  29. trustedHosts: z.array(String).default([]),
  30. })
  31. /**
  32. * Methods gated to loopback even on a trusted-host deployment. Native dialogs
  33. * act on the host machine; the settings and credential domains mutate the
  34. * user's configuration and secret store, and READING them is equally
  35. * privileged — `settings.describe` returns every exposed namespace's
  36. * configuration and `credentials.describe` reports whether an arbitrary
  37. * environment-variable name is configured and where from, which is
  38. * reconnaissance no anonymous caller should have. `trustedHosts` is a
  39. * DNS-rebinding fence, explicitly not authentication, so the whole
  40. * configuration plane stays loopback-same-origin until a real authentication
  41. * layer exists. The model catalog (`llm.providers`, `llm.models`) is
  42. * deliberately NOT here: it carries provider ids, display names, and model
  43. * lists — no endpoints, keys, or key state — and a LAN client's model picker
  44. * legitimately needs it.
  45. */
  46. const PRIVILEGED_METHODS = new Set([
  47. 'host.pickDirectory',
  48. 'host.openPath',
  49. 'settings.describe',
  50. 'settings.update',
  51. 'settings.replace',
  52. 'settings.mutate',
  53. 'credentials.describe',
  54. 'credentials.set',
  55. 'credentials.unset',
  56. ])
  57. /**
  58. * Mounts the API gateway under the browser transport prefix. Every request on
  59. * the prefix passes the browser-trust fence first (DNS-rebinding and
  60. * cross-site defense — [api-request-trust](./api-request-trust.ts));
  61. * privileged methods additionally pass it with an empty trust list, which
  62. * pins them to loopback.
  63. * @param ctx - Host plugin context.
  64. * @param config - resolved plugin config (schema defaults applied).
  65. */
  66. export function apply(ctx: Context, config?: ConnectionConfig): void {
  67. // The Loader resolves schema defaults; hand-built test contexts may pass none.
  68. const trustedHosts = config?.trustedHosts ?? []
  69. // Config boundary: a malformed entry fails the load loudly here rather than
  70. // silently authorizing its hostname prefix at request time.
  71. for (const entry of trustedHosts) assertTrustedAuthority(entry)
  72. const apiHandler = toFetchHandler(ctx.apiProxy)
  73. const downlinks = new WebSocketDownlinks(ctx.apiProxy)
  74. const route: WebRoute = {
  75. kind: 'prefix',
  76. path: API_PATH,
  77. handler: async (req, res) => {
  78. const pathname = new URL(req.url ?? '/', 'http://dsh.internal').pathname
  79. const method = pathname.startsWith(`${API_PATH}/`)
  80. ? pathname.slice(API_PATH.length + 1)
  81. : undefined
  82. const allowed = method !== undefined && PRIVILEGED_METHODS.has(method)
  83. ? isTrustedApiRequest(req, [])
  84. : isTrustedApiRequest(req, trustedHosts)
  85. if (!allowed) {
  86. res.writeHead(403)
  87. res.end('forbidden')
  88. return
  89. }
  90. if (req.method === 'GET' && (pathname === MUX_EVENTS_PATH || pathname === HOST_EVENTS_PATH)) {
  91. res.writeHead(426, { connection: 'Upgrade', upgrade: 'websocket' })
  92. res.end('upgrade required')
  93. return
  94. }
  95. await bridge(req, res, apiHandler)
  96. },
  97. }
  98. ctx.effect(() => ctx.httpServer.register(route), 'client-connection: /api route')
  99. const registerDownlink = (
  100. path: string,
  101. handle: WebUpgradeRoute['handler'],
  102. ): void => {
  103. ctx.effect(() => ctx.httpServer.registerUpgrade({
  104. path,
  105. handler: (req, socket, head) => {
  106. if (!isTrustedApiRequest(req, trustedHosts)) {
  107. rejectWebSocketUpgrade(socket)
  108. return
  109. }
  110. return handle(req, socket, head)
  111. },
  112. }), `client-connection: ${path} WebSocket`)
  113. }
  114. ctx.effect(() => () => downlinks.close(), 'client-connection: WebSocket downlinks')
  115. registerDownlink(MUX_EVENTS_PATH, (req, socket, head) => { downlinks.handleMux(req, socket, head) })
  116. registerDownlink(HOST_EVENTS_PATH, (req, socket, head) => { downlinks.handleHost(req, socket, head) })
  117. }