mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
Machine-produced by `pnpm run rescope-vendor --apply` plus the regeneration it prints: `pnpm install` for the lockfile, `pnpm run gen-third-party-notices`, `verify-translation-pairing --write` for the touched bilingual pairs, `gen-doc-graphs`, and one typert snapshot whose ids embed character offsets. `pnpm run rescope-vendor --check` verifies the result. Renames nine vendored packages (cordis, cosmokit, schemastery and the six @cordisjs plugins) and every reference that resolves them: manifest names and dependency keys, module specifiers including declare-module merges, cordis.yml plugin names, tsconfig paths, every Markdown fence, and `docs/` prose. Directory names, upstream versions, and dependency ranges are unchanged, so vendor/README.md still reads as an upstream snapshot; its manifest table gains an upstream-name column so THIRD_PARTY_NOTICES keeps MIT attribution pointed at each fork's origin. The tutorial tier follows the rename end to end: its yaml fences named plugins the Loader can no longer resolve, its `ts ignore-check` fences disagreed with the compiled fences beside them, and its prose quoted both. The contracts that told readers to keep upstream names — the root convention and the vendoring cookbook's tree comment and manifest invariant — now say to rescope instead. Two rules read `@deepseek-ai/` as "another workspace plugin": the client bundle purity gate now names the vendored libraries a browser bundle inlines, and the files where a bare `cordis` is an agent-preset id keep that product data.
199 lines
8.7 KiB
TypeScript
199 lines
8.7 KiB
TypeScript
/** Host HTTP bridge for browser-client RPC. */
|
|
import type { Context } from '@deepseek-ai/cordis'
|
|
import z from '@deepseek-ai/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([
|
|
// A preset composition names the plugins a session runs, so reading one is
|
|
// reconnaissance; copy and remove rearrange what the deployment offers, and
|
|
// openDocument drives the host desktop — all more than the roster beside
|
|
// them. (Authoring is copy-only, so no method here accepts composition text
|
|
// or a path; the pin is about who may manage the roster at all.)
|
|
//
|
|
// CHOOSING one is not pinned, and `agentPreset.list` is not either. Picking a
|
|
// preset looks like escalation — one of them mounts the toolset that edits the
|
|
// live runtime — but `session.create` already takes an `agentPreset`, so
|
|
// pinning only the switch would leave the same capability one method over.
|
|
// The deeper reason is that the capability is not the preset's to grant: the
|
|
// deployment's own default already carries `bash` and the filesystem tools, so
|
|
// any caller that may start a session at all can already run commands as this
|
|
// process. Pinning the switch would be a fence beside an open gate.
|
|
'agentPreset.read',
|
|
'agentPreset.copy',
|
|
'agentPreset.openDocument',
|
|
'agentPreset.remove',
|
|
'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) })
|
|
})
|
|
}
|