mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
A composition is a file, but "edit it on the filesystem" is not a browser affordance. The roster gains `read`/`write`/`remove` beside `select`, and the browser gains a settings section over them: the presets as rows, one composition open in a YAML editor at a time, and per-row default, duplicate, and delete. All four authoring methods are loopback-pinned. A composition names the plugins a session runs, so reading one is reconnaissance, writing one is arbitrary capability, and selecting one can move a session onto a preset that edits the live runtime. `agentPreset.list` deliberately stays ordinary and now reports `authorable`, so a surface knows whether creating is possible at all rather than offering a button whose save always fails. Authoring starts by duplicating: a shipped preset opens read-only because the deployment's copy is what a broken local one is compared against. Ids are contained before they become directory names, and the text is parsed with the loader's own schema, so a save cannot leave a file no session could load. Fixes a defect the real-composition test found: a preset written under the user's home could never mount, because the loader resolves a row against the composition's own directory and Node's `node_modules` walk from there never reaches the installed harness. The mount now records the host base and sends bare specifiers there, leaving relative paths resolving from the preset. Also closes the coverage the earlier surfaces in this stack shipped without — the General row, the composer seat, and the plugin halves now have tests.
143 lines
6.2 KiB
TypeScript
143 lines
6.2 KiB
TypeScript
/** 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 { rejectWebSocketUpgrade, WebSocketDownlinks } from './websocket-downlink.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 mounting the route. */
|
|
export const inject = ['httpServer', 'apiProxy']
|
|
|
|
/** 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<ConnectionConfig> = 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([
|
|
// A preset composition names the plugins a session runs, so reading one is
|
|
// reconnaissance and writing one is arbitrary capability — strictly more than
|
|
// the settings document beside it. `agentPreset.select` joins them because
|
|
// it can move a session from a two-tool preset onto one that edits the live
|
|
// runtime, which is a real escalation even though every candidate is already
|
|
// installed. `agentPreset.list` deliberately stays out: it carries ids and
|
|
// trust only, like the model catalog, and a LAN client's picker needs it.
|
|
'agentPreset.select',
|
|
'agentPreset.read',
|
|
'agentPreset.write',
|
|
'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 ?? []
|
|
// 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 apiHandler = toFetchHandler(ctx.apiProxy)
|
|
const downlinks = new WebSocketDownlinks(ctx.apiProxy)
|
|
const route: WebRoute = {
|
|
kind: 'prefix',
|
|
path: API_PATH,
|
|
handler: async (req, res) => {
|
|
const pathname = new URL(req.url ?? '/', 'http://dsh.internal').pathname
|
|
const method = pathname.startsWith(`${API_PATH}/`)
|
|
? pathname.slice(API_PATH.length + 1)
|
|
: undefined
|
|
const allowed = method !== undefined && PRIVILEGED_METHODS.has(method)
|
|
? isTrustedApiRequest(req, [])
|
|
: isTrustedApiRequest(req, trustedHosts)
|
|
if (!allowed) {
|
|
res.writeHead(403)
|
|
res.end('forbidden')
|
|
return
|
|
}
|
|
if (req.method === 'GET' && (pathname === MUX_EVENTS_PATH || pathname === HOST_EVENTS_PATH)) {
|
|
res.writeHead(426, { connection: 'Upgrade', upgrade: 'websocket' })
|
|
res.end('upgrade required')
|
|
return
|
|
}
|
|
await bridge(req, res, apiHandler)
|
|
},
|
|
}
|
|
ctx.effect(() => ctx.httpServer.register(route), 'client-connection: /api route')
|
|
const registerDownlink = (
|
|
path: string,
|
|
handle: WebUpgradeRoute['handler'],
|
|
): void => {
|
|
ctx.effect(() => ctx.httpServer.registerUpgrade({
|
|
path,
|
|
handler: (req, socket, head) => {
|
|
if (!isTrustedApiRequest(req, trustedHosts)) {
|
|
rejectWebSocketUpgrade(socket)
|
|
return
|
|
}
|
|
return handle(req, socket, head)
|
|
},
|
|
}), `client-connection: ${path} WebSocket`)
|
|
}
|
|
ctx.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) })
|
|
}
|