/** Host HTTP bridge for browser-client RPC. */ import type { Context } from 'cordis' import z from 'schemastery' // Activates the httpServer Context merge used below. import type { WebRoute, WebUpgradeRoute } from '@deepseek-ai/dsh-host-webserver' import { toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy' import { API_PATH, HOST_EVENTS_PATH, MUX_EVENTS_PATH } from './api-path.ts' import { bridge } from './http-bridge.ts' import { assertTrustedAuthority, isTrustedApiRequest } from './api-request-trust.ts' import { HostConnectionService } from './rpc-host.ts' import { rejectWebSocketUpgrade, WebSocketDownlinks } from './websocket-downlink.ts' export type { ConnectionRpcAuthority, ConnectionRpcEndpointMatcher, ConnectionRpcHandler, ConnectionRpcHandlerOptions, HostConnectionHandle, HostConnectionRpc, } from './rpc.ts' export { HostConnectionService } from './rpc-host.ts' export { API_PATH, HOST_EVENTS_PATH, MUX_EVENTS_PATH } from './api-path.ts' /** Stable Cordis plugin name. */ export const name = 'client-connection' /** Services required before providing Connection; API Proxy is an optional `/api` fallback. */ export const inject = ['httpServer'] /** Plugin config: the deployment's non-loopback serving authorities. */ export interface ConnectionConfig { /** * Authorities this deployment serves beyond loopback: exact `host:port`, or * port-less `host` matching any port. The /api trust fence refuses any * request whose Host is neither loopback nor listed here, so a * non-loopback (`0.0.0.0`) deployment must declare the names it is reached * by (the dsh CLI derives the machine's LAN IP literals itself). An entry * that is not a bare, canonical authority fails the plugin load. */ trustedHosts?: string[] } export const Config: z = z.object({ trustedHosts: z.array(String).default([]), }) /** * Methods gated to loopback even on a trusted-host deployment. Native dialogs * act on the host machine; the settings and credential domains mutate the * user's configuration and secret store, and READING them is equally * privileged — `settings.describe` returns every exposed namespace's * configuration and `credentials.describe` reports whether an arbitrary * environment-variable name is configured and where from, which is * reconnaissance no anonymous caller should have. `trustedHosts` is a * DNS-rebinding fence, explicitly not authentication, so the whole * configuration plane stays loopback-same-origin until a real authentication * layer exists. `llm.discoverModels` belongs to that plane on both counts: it * carries a draft credential, and it makes the HOST issue a GET to a URL the * caller chose and reports back the status or the parsed body — an anonymous * LAN caller would have a probe for whatever the host can reach and the * browser cannot. * * The model catalog (`llm.providers`, `llm.models`) is deliberately NOT here: * it carries provider ids, display names, and model lists — no endpoints, * keys, or key state — and a LAN client's model picker legitimately needs it. */ const PRIVILEGED_METHODS = new Set([ 'host.pickDirectory', 'host.openPath', 'settings.describe', 'settings.openDocument', 'settings.update', 'settings.replace', 'settings.mutate', 'credentials.describe', 'credentials.set', 'credentials.unset', 'llm.discoverModels', ]) /** * Mounts the API gateway under the browser transport prefix. Every request on * the prefix passes the browser-trust fence first (DNS-rebinding and * cross-site defense — [api-request-trust](./api-request-trust.ts)); * privileged methods additionally pass it with an empty trust list, which * pins them to loopback. * @param ctx - Host plugin context. * @param config - resolved plugin config (schema defaults applied). */ export function apply(ctx: Context, config?: ConnectionConfig): void { // The Loader resolves schema defaults; hand-built test contexts may pass none. const trustedHosts = config?.trustedHosts ?? [] // Config boundary: a malformed entry fails the load loudly here rather than // silently authorizing its hostname prefix at request time. for (const entry of trustedHosts) assertTrustedAuthority(entry) const connection = new HostConnectionService(ctx, trustedHosts) const fetchHandler = connection.createSharedFetchHandler(API_PATH, { async fetch(request) { const pathname = new URL(request.url).pathname const method = pathname.startsWith(`${API_PATH}/`) ? pathname.slice(API_PATH.length + 1) : undefined if (method !== undefined && PRIVILEGED_METHODS.has(method) && !isTrustedApiRequest(request, [])) { return new Response('forbidden', { status: 403 }) } if (request.method === 'GET' && (pathname === MUX_EVENTS_PATH || pathname === HOST_EVENTS_PATH)) { return new Response('upgrade required', { status: 426, headers: { connection: 'Upgrade', upgrade: 'websocket' }, }) } const apiProxy = ctx.get('apiProxy') if (apiProxy === undefined) return new Response('not found', { status: 404 }) return toFetchHandler(apiProxy).fetch(request) }, }) const route: WebRoute = { kind: 'prefix', path: API_PATH, handler: async (req, res) => { if (!isTrustedApiRequest(req, trustedHosts)) { res.writeHead(403) res.end('forbidden') return } await bridge(req, res, fetchHandler) }, } ctx.effect(() => ctx.httpServer.register(route), 'client-connection: /api route') ctx.inject(['apiProxy'], (apiCtx) => { const downlinks = new WebSocketDownlinks(apiCtx.apiProxy) const registerDownlink = ( path: string, handle: WebUpgradeRoute['handler'], ): void => { apiCtx.effect(() => apiCtx.httpServer.registerUpgrade({ path, handler: (req, socket, head) => { if (!isTrustedApiRequest(req, trustedHosts)) { rejectWebSocketUpgrade(socket) return } return handle(req, socket, head) }, }), `client-connection: ${path} WebSocket`) } apiCtx.effect(() => () => downlinks.close(), 'client-connection: WebSocket downlinks') registerDownlink(MUX_EVENTS_PATH, (req, socket, head) => { downlinks.handleMux(req, socket, head) }) registerDownlink(HOST_EVENTS_PATH, (req, socket, head) => { downlinks.handleHost(req, socket, head) }) }) }