The web YAML editor is gone. agentPreset.write (arbitrary composition
text) became agentPreset.copy { from, agentPreset, name? }: a host-side
whole-directory copy of ids the host resolves itself — symlinks
dereferenced, modes re-tightened to owner-only with owner-execute kept,
metadata rewritten to keep the source's description but never its name or
roster order. No composition text or path crosses the wire in either
authoring direction, and the entryListSchema/!!js concern dissolves with
assertComposition itself.
The settings section becomes: a read-only viewer over shipped
compositions, a copy dialog (id + optional display name) as the only
create entry, delete for custom rows, and a location action leading into
the preset's own files — agentPreset.openDocument { agentPreset } resolves
the directory host-side and opens it natively, or answers
{ opened: false, path } for the row to show as text where the deployment
has no desktop. agentPreset.list reports hasDocument beside authorable;
the gateway's nativeOpen config pins the capability where
canOpenNativePath platform detection would mislead. The privileged set is
now read/copy/openDocument/remove.
With files as the only composition editor, standing mounts grew
stamp-keyed generations: ensureStanding compares the composition file's
mtime+size and starts the next generation for later sessions, while every
joined session keeps the generation it runs on.
New keyless web lane (agent-preset-authoring, overlay pins
nativeOpen: false so goldens render one branch on every platform) drives
view/copy/reveal/delete end to end; the real-composition CLI e2e switches
to copy semantics.
173 lines
7.4 KiB
TypeScript
173 lines
7.4 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 { 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<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; 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 ?? []
|
|
// 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) })
|
|
})
|
|
}
|