# Conflicts: # .agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.i18n.yaml # .agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md # .agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.zh.md # .agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml # .agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md # .agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md # THIRD_PARTY_NOTICES.md # apps/cli/composition.md # apps/cli/config/base.cordis.yml # apps/cli/package.json # apps/cli/src/app-cli-entry.ts # apps/cli/src/bin.ts # apps/cli/tests/args.spec.ts # apps/web/tests/built-boot.snapshot.ts # apps/web/tests/navigation-panes.e2e.ts # docs/architecture.i18n.yaml # docs/architecture.md # docs/architecture.zh.md # docs/config-catalog.md # docs/cordis-catalog/services.md # docs/core-data-structures/core.i18n.yaml # docs/core-data-structures/llm-streaming.i18n.yaml # docs/event-producer-consumer.md # docs/module-graph.md # examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl # packages/README.i18n.yaml # packages/bundle/README.i18n.yaml # packages/client/connection/README.i18n.yaml # packages/client/connection/README.md # packages/client/connection/README.zh.md # packages/client/connection/src/client/fixture.ts # packages/client/connection/src/http-bridge.ts # packages/client/connection/src/index.ts # packages/client/connection/tests/fixture.spec.ts # packages/client/connection/tests/node-half.spec.ts # packages/client/runtime/README.i18n.yaml # packages/client/runtime/README.md # packages/client/runtime/README.zh.md # packages/client/runtime/src/client/contract/session.ts # packages/client/runtime/src/client/sessions/session.ts # packages/client/ui-conversation/README.i18n.yaml # packages/client/ui-conversation/README.md # packages/client/ui-conversation/README.zh.md # packages/client/ui-conversation/src/client/apply.ts # packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx # packages/client/ui-conversation/src/client/chat/ChatView.tsx # packages/client/ui-conversation/src/client/chat/MessageItem.module.css # packages/client/ui-conversation/src/client/chat/MessageItem.tsx # packages/client/ui-conversation/src/client/contract/slots.ts # packages/client/ui-conversation/src/client/index.ts # packages/client/ui-conversation/src/client/input/contract.ts # packages/client/ui-conversation/src/client/input/facade.ts # packages/client/ui-conversation/src/client/input/hub.ts # packages/client/ui-conversation/src/client/locales.ts # packages/client/ui-conversation/src/client/service.ts # packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx # packages/client/ui-conversation/src/client/skeleton/InputBar.tsx # packages/client/ui-conversation/tests/apply-inject.spec.tsx # packages/client/ui-conversation/tests/input-bar.spec.tsx # packages/client/ui-conversation/tests/input-matrix.spec.tsx # packages/client/ui-conversation/tests/input-scenarios.spec.tsx # packages/client/ui-conversation/tests/service-orchestration.spec.ts # packages/client/ui-conversation/tests/skeleton.spec.tsx # packages/client/ui-trajectory/tests/views.spec.tsx # packages/compact/compact-basic/README.i18n.yaml # packages/cordis/tool-cordis/src/api-catalog.ts # packages/host/apiproxy/README.i18n.yaml # packages/host/apiproxy/README.md # packages/host/apiproxy/README.zh.md # packages/host/apiproxy/src/api-proxy.ts # packages/host/apiproxy/src/api/rpc.ts # packages/host/apiproxy/src/api/sessions.ts # packages/host/apiproxy/src/index.ts # packages/host/apiproxy/tests/api-proxy-models.spec.ts # packages/host/apiproxy/tests/rpc-schemas.spec.ts # packages/llm/llm-pi-ai/README.i18n.yaml # packages/llm/llm-pi-ai/README.md # packages/llm/llm-pi-ai/README.zh.md # packages/llm/llm-pi-ai/src/adapter.ts # packages/llm/llm/README.i18n.yaml # packages/ui/tui/README.md # packages/ui/tui/README.zh.md # packages/ui/tui/src/components/content.ts # packages/ui/tui/src/components/transcript.ts # packages/ui/tui/tests/tui.spec.ts # pnpm-lock.yaml
181 lines
7.6 KiB
TypeScript
181 lines
7.6 KiB
TypeScript
/** Host HTTP bridge for browser-client RPC. */
|
|
import type { Context } from 'cordis'
|
|
import z from 'schemastery'
|
|
import type {} from '@deepseek-ai/dsh-attachment'
|
|
// 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'
|
|
|
|
/** Headroom for RPC JSON fields around aggregate base64 image payloads. */
|
|
const REQUEST_ENVELOPE_HEADROOM_BYTES = 1024 * 1024
|
|
|
|
function assertImageBodyCapacity(ctx: Context, maxRequestBodyBytes: number): void {
|
|
const attachments = ctx.get('attachments')
|
|
if (attachments === undefined) return
|
|
const requiredImageBodyBytes = Math.ceil(
|
|
attachments.imageLimits.maxMessageImageBytes * 4 / 3,
|
|
) + REQUEST_ENVELOPE_HEADROOM_BYTES
|
|
if (maxRequestBodyBytes < requiredImageBodyBytes) {
|
|
throw new Error(
|
|
`client-connection maxRequestBodyBytes (${String(maxRequestBodyBytes)}) must be at least `
|
|
+ `${String(requiredImageBodyBytes)} for the configured aggregate image limit`,
|
|
)
|
|
}
|
|
}
|
|
/** Default carrier cap for all HTTP RPC bodies. */
|
|
const DEFAULT_MAX_REQUEST_BODY_BYTES = 32 * 1024 * 1024
|
|
|
|
/** 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[]
|
|
/** Maximum buffered JSON body for every `/api` request. */
|
|
maxRequestBodyBytes?: number
|
|
}
|
|
|
|
export const Config: z<ConnectionConfig> = z.object({
|
|
trustedHosts: z.array(String).default([]),
|
|
maxRequestBodyBytes: z.natural().min(1).default(DEFAULT_MAX_REQUEST_BODY_BYTES),
|
|
})
|
|
|
|
/**
|
|
* 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 ?? []
|
|
const maxRequestBodyBytes = config?.maxRequestBodyBytes ?? DEFAULT_MAX_REQUEST_BODY_BYTES
|
|
// 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)
|
|
if (ctx.get('apiProxy') !== undefined) assertImageBodyCapacity(ctx, maxRequestBodyBytes)
|
|
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, maxRequestBodyBytes)
|
|
},
|
|
}
|
|
ctx.effect(() => ctx.httpServer.register(route), 'client-connection: /api route')
|
|
ctx.inject(['apiProxy'], (apiCtx) => {
|
|
assertImageBodyCapacity(apiCtx, maxRequestBodyBytes)
|
|
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) })
|
|
})
|
|
}
|