@deepseek-ai/dsh-client-connection 0.0.1-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js ADDED
@@ -0,0 +1,585 @@
1
+ import z from "@deepseek-ai/schemastery";
2
+ import { toFetchHandler } from "@deepseek-ai/dsh-host-apiproxy";
3
+ import { Service } from "@deepseek-ai/cordis";
4
+ import { RpcId, clientRequestSchema } from "@deepseek-ai/dsh-host-apiproxy/api";
5
+ import { randomUUID } from "node:crypto";
6
+ import WebSocket, { WebSocketServer } from "ws";
7
+ //#region lib/types/api-path.js
8
+ /**
9
+ * The /api URL prefix — single source for both halves of the web transport.
10
+ * The node half registers this prefix on the web server; both halves share the
11
+ * event paths below for the browser WebSocket downlinks.
12
+ */
13
+ /** Route prefix owning every api request (`/api` and `/api/<anything>`). */
14
+ const API_PATH = "/api";
15
+ /** Browser mux-frame WebSocket pathname. */
16
+ const MUX_EVENTS_PATH = `${API_PATH}/events.mux`;
17
+ /** Browser host-frame WebSocket pathname. */
18
+ const HOST_EVENTS_PATH = `${API_PATH}/events.host`;
19
+ //#endregion
20
+ //#region lib/types/http-bridge.js
21
+ /**
22
+ * node:http ↔ WHATWG fetch bridge for the /api transport (host side of the
23
+ * web carrier; the fetch-shaped handler itself is transport-agnostic).
24
+ */
25
+ /**
26
+ * Bridge one node:http request to the fetch-shaped handler (client close
27
+ * aborts; SSE bodies stream out chunk by chunk).
28
+ * @param req - incoming node:http request (fully read before dispatch).
29
+ * @param res - node:http response the bridge writes and owns to completion.
30
+ * @param apiHandler - fetch-shaped API carrier the request is dispatched to.
31
+ * @param maxRequestBodyBytes - maximum body bytes buffered before dispatch.
32
+ */
33
+ async function bridge(req, res, apiHandler, maxRequestBodyBytes = 32 * 1024 * 1024) {
34
+ const abort = new AbortController();
35
+ res.on("close", () => {
36
+ if (!res.writableEnded) abort.abort();
37
+ });
38
+ const declaredLength = req.headers["content-length"];
39
+ if (declaredLength !== void 0 && Number(declaredLength) > maxRequestBodyBytes) {
40
+ res.writeHead(413, { connection: "close" });
41
+ res.end();
42
+ req.destroy();
43
+ return;
44
+ }
45
+ const chunks = [];
46
+ let received = 0;
47
+ for await (const chunk of req) {
48
+ const buffer = chunk;
49
+ received += buffer.byteLength;
50
+ if (received > maxRequestBodyBytes) {
51
+ res.writeHead(413, { connection: "close" });
52
+ res.end();
53
+ req.destroy();
54
+ return;
55
+ }
56
+ chunks.push(buffer);
57
+ }
58
+ /* v8 ignore next 3 -- `??` arms: node:http always sets url/method on server
59
+ requests; the fields are only optional on the client-side IncomingMessage type */
60
+ const request = new Request(new URL(req.url ?? "/", "http://dsh.internal"), {
61
+ method: req.method ?? "GET",
62
+ headers: Object.fromEntries(Object.entries(req.headers).filter(([, v]) => typeof v === "string")),
63
+ ...chunks.length > 0 ? { body: Buffer.concat(chunks) } : {},
64
+ signal: abort.signal
65
+ });
66
+ const response = await apiHandler.fetch(request);
67
+ res.writeHead(response.status, Object.fromEntries(response.headers.entries()));
68
+ if (response.body === null) {
69
+ res.end();
70
+ return;
71
+ }
72
+ for await (const chunk of response.body) if (!res.write(chunk)) await new Promise((resolve) => {
73
+ const done = () => {
74
+ res.off("drain", done);
75
+ res.off("close", done);
76
+ resolve();
77
+ };
78
+ res.once("drain", done);
79
+ res.once("close", done);
80
+ });
81
+ res.end();
82
+ }
83
+ //#endregion
84
+ //#region lib/types/loopback-hostname.js
85
+ /**
86
+ * Browser-safe, zero-dependency loopback classification shared by the `/api`
87
+ * Host fence and the package's `ctx.connection` state. The predicate stays
88
+ * package-internal; client plugins consume the derived state through Cordis.
89
+ */
90
+ /**
91
+ * Whether a normalized URL hostname names the local loopback authority.
92
+ * @param hostname - WHATWG URL hostname (IPv6 literals retain brackets).
93
+ * @returns true for localhost, IPv6 loopback, or any IPv4 address in 127/8.
94
+ */
95
+ function isLoopbackHostname(hostname) {
96
+ if (hostname === "localhost" || hostname === "[::1]") return true;
97
+ const parts = hostname.split(".");
98
+ return parts.length === 4 && parts[0] === "127" && parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255);
99
+ }
100
+ //#endregion
101
+ //#region lib/types/api-request-trust.js
102
+ /**
103
+ * Browser-trust fence for every /api request. Defends the two confused-deputy
104
+ * paths a browser opens against a local HTTP API — DNS rebinding (Host names
105
+ * the attacker's domain while the socket reaches this server) and cross-site
106
+ * requests fired from a malicious page. The Host fence binds every request,
107
+ * browser-looking or not: over plain HTTP a browser attaches neither Origin
108
+ * nor Fetch-Metadata to reads (images and navigations — those
109
+ * headers go only to trustworthy destinations), so an unmarked request may
110
+ * still be a rebound browser read and Host is the one header rebinding cannot
111
+ * forge. Non-browser and remote clients pass the same fence via loopback, the
112
+ * CLI-derived LAN IP literals, or a declared `trustedHosts` authority.
113
+ * Network reachability and authentication stay out of scope: binding policy
114
+ * belongs to the webserver config, and this fence is not an auth layer.
115
+ */
116
+ function header(headers, name) {
117
+ if (headers instanceof Headers) return headers.get(name) ?? void 0;
118
+ const value = headers[name];
119
+ return typeof value === "string" ? value : void 0;
120
+ }
121
+ /** Normalized URL of a Host-header authority (hostname lowercased, default port stripped, IPv6 bracketed), or undefined when unparsable. */
122
+ function parseAuthority(authority) {
123
+ try {
124
+ return new URL(`http://${authority}`);
125
+ } catch {
126
+ return;
127
+ }
128
+ }
129
+ /**
130
+ * Assert one configured `trustedHosts` entry is a bare authority (`host` or
131
+ * `host:port`) in canonical form: it must survive WHATWG parsing unchanged
132
+ * (case aside). Anything parsing would silently rewrite is refused as a typo
133
+ * that must fail the load loudly instead of being ignored until requests 403
134
+ * or quietly changing the grant: URL parts beyond the authority
135
+ * (`harness.internal/path`, `user@harness.internal` — which would authorize
136
+ * the embedded hostname), stripped whitespace, a dangling colon or
137
+ * zero-padded port (which would broaden an intended exact-port grant to every
138
+ * port), and non-canonical host spellings (`0x7f.0.0.1`, percent-encoding,
139
+ * unbracketed IPv6; IDN hosts are declared in punycode, the form the wire
140
+ * carries).
141
+ * @param entry - the configured value, verbatim.
142
+ */
143
+ function assertTrustedAuthority(entry) {
144
+ const entryUrl = parseAuthority(entry);
145
+ if (entryUrl !== void 0 && canonicalAuthority(entry, entryUrl) === entry.toLowerCase()) return;
146
+ throw new Error(`client-connection: trustedHosts entry ${JSON.stringify(entry)} is not a bare host[:port] authority`);
147
+ }
148
+ /**
149
+ * Canonical form of a parsed authority: `hostname` when no port was written,
150
+ * else `hostname:port`. The port is judged from URL parses under both special
151
+ * schemes (their default ports differ, so `:80` and `:443` still count as
152
+ * explicit), never from the raw string, where WHATWG trimming would misread
153
+ * shapes like `host:port ` as port-less.
154
+ */
155
+ function canonicalAuthority(entry, entryUrl) {
156
+ const port = entryUrl.port !== "" ? entryUrl.port : new URL(`https://${entry}`).port;
157
+ return port === "" ? entryUrl.hostname : `${entryUrl.hostname}:${port}`;
158
+ }
159
+ /**
160
+ * Whether the request authority matches a `trustedHosts` entry. An entry with
161
+ * an explicit port matches that exact authority; a port-less entry matches the
162
+ * hostname on any port (the shape the CLI derives for IP-literal LAN serving,
163
+ * where the bound port may be OS-assigned). Both sides compare through WHATWG
164
+ * normalization, so case and a redundant `:80` never decide trust.
165
+ */
166
+ function isTrustedAuthority(hostUrl, trustedHosts) {
167
+ return trustedHosts.some((entry) => {
168
+ const entryUrl = parseAuthority(entry);
169
+ if (entryUrl === void 0) return false;
170
+ return canonicalAuthority(entry, entryUrl) === entryUrl.hostname ? entryUrl.hostname === hostUrl.hostname : entryUrl.host === hostUrl.host;
171
+ });
172
+ }
173
+ /**
174
+ * Decide whether one /api request may reach the RPC bridge.
175
+ * @param request - Node HTTP or Fetch request facts (headers).
176
+ * @param trustedHosts - non-loopback authorities this deployment serves: exact `host:port`, or port-less `host` matching any port.
177
+ * @returns true when the Host is ours (loopback or trusted) and any attached browser markers are same-origin.
178
+ */
179
+ function isTrustedApiRequest(request, trustedHosts) {
180
+ const host = header(request.headers, "host");
181
+ if (host === void 0) return false;
182
+ const hostUrl = parseAuthority(host);
183
+ if (hostUrl === void 0) return false;
184
+ if (!isLoopbackHostname(hostUrl.hostname) && !isTrustedAuthority(hostUrl, trustedHosts)) return false;
185
+ if (header(request.headers, "sec-fetch-site") === "cross-site") return false;
186
+ const origin = header(request.headers, "origin");
187
+ if (origin === void 0) return true;
188
+ try {
189
+ return new URL(origin).host === hostUrl.host;
190
+ } catch {
191
+ return false;
192
+ }
193
+ }
194
+ //#endregion
195
+ //#region lib/types/rpc-host.js
196
+ /** Host registry and HTTP adapter for generic Connection RPC channels. */
197
+ const INVALID_REQUEST_RPC_ID = RpcId("invalid-request");
198
+ const CHANNEL_PATTERN = /^\/[A-Za-z0-9._~-]+$/;
199
+ const ENDPOINT_SEGMENT_PATTERN = /^[A-Za-z0-9_$.-]+$/;
200
+ /** Host Connection service whose channel registrations belong to the caller fiber. */
201
+ var HostConnectionService = class extends Service {
202
+ trustedHosts;
203
+ interceptors = /* @__PURE__ */ new Map();
204
+ /**
205
+ * Provide the Host half over the active HTTP server.
206
+ * @param ctx - owning Connection plugin context.
207
+ * @param trustedHosts - deployment authorities accepted by trusted-host channels.
208
+ */
209
+ constructor(ctx, trustedHosts) {
210
+ super(ctx, "connection");
211
+ this.trustedHosts = trustedHosts;
212
+ }
213
+ /** Generic channel registry scoped to the Context reading this service. */
214
+ get rpc() {
215
+ const owner = this.ctx;
216
+ return {
217
+ handle: (channel, handler, options) => this.register(owner, channel, handler, options),
218
+ intercept: (channel, matches, handler, options) => this.registerInterceptor(owner, channel, matches, handler, options)
219
+ };
220
+ }
221
+ /**
222
+ * Compose one shared-channel Fetch handler from its interceptor and fallback.
223
+ * @param channel - shared channel mounted by Connection.
224
+ * @param fallback - handler for endpoints not claimed by the interceptor.
225
+ * @returns Fetch handler that selects exactly one target for each request.
226
+ */
227
+ createSharedFetchHandler(channel, fallback) {
228
+ return { fetch: (request) => {
229
+ const endpoint = endpointFromPath(channel, new URL(request.url).pathname);
230
+ const interceptor = this.interceptors.get(channel);
231
+ if (endpoint === void 0 || interceptor === void 0 || !interceptor.matches(endpoint)) return fallback.fetch(request);
232
+ if (interceptor.options.authority === "loopback" && !isTrustedApiRequest(request, [])) return Promise.resolve(new Response("forbidden", { status: 403 }));
233
+ return interceptor.fetchHandler.fetch(request);
234
+ } };
235
+ }
236
+ register(owner, channel, handler, options) {
237
+ assertChannel(channel);
238
+ const trustedHosts = options.authority === "loopback" ? [] : this.trustedHosts;
239
+ const fetchHandler = rpcFetchHandler(channel, handler);
240
+ const route = {
241
+ kind: "prefix",
242
+ path: channel,
243
+ handler: async (req, res) => {
244
+ if (!isTrustedApiRequest(req, trustedHosts)) {
245
+ res.writeHead(403);
246
+ res.end("forbidden");
247
+ return;
248
+ }
249
+ await bridge(req, res, fetchHandler);
250
+ }
251
+ };
252
+ return owner.effect(() => owner.httpServer.register(route), `client-connection: ${channel} rpc channel`);
253
+ }
254
+ registerInterceptor(owner, channel, matches, handler, options) {
255
+ if (channel !== "/api") throw new Error(`connection: invalid shared RPC channel ${JSON.stringify(channel)}`);
256
+ const interceptor = {
257
+ matches,
258
+ fetchHandler: rpcFetchHandler(channel, handler),
259
+ options
260
+ };
261
+ return owner.effect(() => {
262
+ if (this.interceptors.has(channel)) throw new Error(`connection: shared RPC channel ${JSON.stringify(channel)} already has an interceptor`);
263
+ this.interceptors.set(channel, interceptor);
264
+ return () => {
265
+ this.interceptors.delete(channel);
266
+ };
267
+ }, `client-connection: ${channel} rpc interceptor`);
268
+ }
269
+ };
270
+ function rpcFetchHandler(channel, handler) {
271
+ return { async fetch(request) {
272
+ const endpoint = endpointFromPath(channel, new URL(request.url).pathname);
273
+ if (request.method !== "POST" || endpoint === void 0) return new Response("not found", { status: 404 });
274
+ if (request.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase() !== "application/json") return new Response("content type must be application/json", { status: 415 });
275
+ let body;
276
+ try {
277
+ body = await request.json();
278
+ } catch {
279
+ return new Response("body is not JSON", { status: 400 });
280
+ }
281
+ const envelope = clientRequestSchema.safeParse(body);
282
+ if (!envelope.success) return invalidEnvelopeResponse(body, envelope.error.issues);
283
+ const message = envelope.data;
284
+ if (message.method !== endpoint) return errorResponse(message.rpcId, {
285
+ code: "bad-request",
286
+ message: `method ${JSON.stringify(message.method)} does not match endpoint ${JSON.stringify(endpoint)}`,
287
+ details: { issues: [] }
288
+ });
289
+ try {
290
+ const result = await handler(endpoint, message.payload, request.signal);
291
+ return fullResponse(message.rpcId, result);
292
+ } catch (error) {
293
+ return new Response(`handler failure: ${String(error)}`, { status: 500 });
294
+ }
295
+ } };
296
+ }
297
+ function invalidEnvelopeResponse(body, issues) {
298
+ const rawId = body?.rpcId;
299
+ return errorResponse(typeof rawId === "string" ? RpcId(rawId) : INVALID_REQUEST_RPC_ID, {
300
+ code: "bad-request",
301
+ message: "invalid client-request message",
302
+ details: { issues }
303
+ });
304
+ }
305
+ function endpointFromPath(channel, pathname) {
306
+ if (!pathname.startsWith(`${channel}/`)) return void 0;
307
+ const endpoint = pathname.slice(channel.length + 1);
308
+ if (endpoint.split("/").some((segment) => segment === "" || segment === "." || segment === ".." || !ENDPOINT_SEGMENT_PATTERN.test(segment))) return;
309
+ return endpoint;
310
+ }
311
+ function errorResponse(rpcId, error) {
312
+ return fullResponse(rpcId, {
313
+ ok: false,
314
+ error
315
+ });
316
+ }
317
+ function fullResponse(rpcId, result) {
318
+ const body = {
319
+ type: "server-response",
320
+ rpcId,
321
+ result
322
+ };
323
+ return Response.json(body);
324
+ }
325
+ function assertChannel(channel) {
326
+ if (!CHANNEL_PATTERN.test(channel) || channel === "/api") throw new Error(`connection: invalid or reserved RPC channel ${JSON.stringify(channel)}`);
327
+ }
328
+ //#endregion
329
+ //#region lib/types/websocket-downlink.js
330
+ /** Host-side WebSocket carrier for the two server-to-browser event streams. */
331
+ function serverRequest(frame) {
332
+ return {
333
+ type: "server-request",
334
+ rpcId: frame.rpcId,
335
+ method: frame.payload.type,
336
+ payload: frame.payload
337
+ };
338
+ }
339
+ function send(socket, frame) {
340
+ return new Promise((resolve, reject) => {
341
+ if (socket.readyState !== WebSocket.OPEN) {
342
+ reject(/* @__PURE__ */ new Error("websocket downlink closed before frame delivery"));
343
+ return;
344
+ }
345
+ socket.send(JSON.stringify(serverRequest(frame)), (error) => {
346
+ if (error) reject(error);
347
+ else resolve();
348
+ });
349
+ });
350
+ }
351
+ function failureFrame(error) {
352
+ return {
353
+ rpcId: RpcId(randomUUID()),
354
+ payload: {
355
+ type: "stream/error",
356
+ error: {
357
+ code: "internal",
358
+ message: String(error),
359
+ details: {}
360
+ }
361
+ }
362
+ };
363
+ }
364
+ /**
365
+ * Owns WebSocket negotiation and frame pumping for the connection plugin's
366
+ * two downlinks. Client messages are a protocol violation: upstream traffic
367
+ * remains on HTTP.
368
+ */
369
+ var WebSocketDownlinks = class {
370
+ api;
371
+ server = new WebSocketServer({ noServer: true });
372
+ pumps = /* @__PURE__ */ new Set();
373
+ /** @param api - host API supplying the typed event streams. */
374
+ constructor(api) {
375
+ this.api = api;
376
+ }
377
+ /**
378
+ * Upgrade one socket and pump the mux stream until either side closes.
379
+ * @param req - HTTP upgrade request.
380
+ * @param socket - Raw socket transferred by the HTTP server.
381
+ * @param head - Bytes already read after the upgrade headers.
382
+ */
383
+ handleMux(req, socket, head) {
384
+ this.upgrade(req, socket, head, (signal) => this.api.events.mux({
385
+ rpcId: RpcId(randomUUID()),
386
+ payload: {}
387
+ }, signal));
388
+ }
389
+ /**
390
+ * Upgrade one socket and pump the host stream until either side closes.
391
+ * @param req - HTTP upgrade request.
392
+ * @param socket - Raw socket transferred by the HTTP server.
393
+ * @param head - Bytes already read after the upgrade headers.
394
+ */
395
+ handleHost(req, socket, head) {
396
+ this.upgrade(req, socket, head, (signal) => this.api.events.host({
397
+ rpcId: RpcId(randomUUID()),
398
+ payload: {}
399
+ }, signal));
400
+ }
401
+ /**
402
+ * Terminate owned sockets and await the no-server acceptor plus frame pumps.
403
+ * @returns A promise resolving after every socket and source iterator stops.
404
+ */
405
+ async close() {
406
+ for (const socket of this.server.clients) socket.terminate();
407
+ await new Promise((resolve, reject) => {
408
+ this.server.close((error) => {
409
+ if (error === void 0) resolve();
410
+ else reject(error);
411
+ });
412
+ });
413
+ await Promise.all(this.pumps);
414
+ }
415
+ upgrade(req, socket, head, open) {
416
+ this.server.handleUpgrade(req, socket, head, (websocket) => {
417
+ const abort = new AbortController();
418
+ websocket.once("close", () => {
419
+ abort.abort();
420
+ });
421
+ websocket.once("error", () => {
422
+ abort.abort();
423
+ });
424
+ websocket.once("message", () => {
425
+ websocket.close(1008, "downlink only");
426
+ });
427
+ const pump = this.pump(websocket, open(abort.signal), abort);
428
+ this.pumps.add(pump);
429
+ pump.then(() => {
430
+ this.pumps.delete(pump);
431
+ });
432
+ });
433
+ }
434
+ async pump(socket, frames, abort) {
435
+ try {
436
+ for await (const frame of frames) await send(socket, frame);
437
+ } catch (error) {
438
+ if (!abort.signal.aborted) try {
439
+ await send(socket, failureFrame(error));
440
+ } catch {}
441
+ } finally {
442
+ abort.abort();
443
+ if (socket.readyState === WebSocket.OPEN) socket.close();
444
+ }
445
+ }
446
+ };
447
+ /**
448
+ * Reject an untrusted upgrade before protocol negotiation.
449
+ * @param socket - Raw HTTP socket that remains owned by the caller.
450
+ */
451
+ function rejectWebSocketUpgrade(socket) {
452
+ socket.end([
453
+ "HTTP/1.1 403 Forbidden",
454
+ "Connection: close",
455
+ "Content-Type: text/plain; charset=utf-8",
456
+ "Content-Length: 9",
457
+ "",
458
+ "forbidden"
459
+ ].join("\r\n"));
460
+ }
461
+ //#endregion
462
+ //#region lib/types/index.js
463
+ /** Stable Cordis plugin name. */
464
+ const name = "client-connection";
465
+ /** Headroom for RPC JSON fields around aggregate base64 image payloads. */
466
+ const REQUEST_ENVELOPE_HEADROOM_BYTES = 1024 * 1024;
467
+ function assertImageBodyCapacity(ctx, maxRequestBodyBytes) {
468
+ const attachments = ctx.get("attachments");
469
+ if (attachments === void 0) return;
470
+ const requiredImageBodyBytes = Math.ceil(attachments.imageLimits.maxMessageImageBytes * 4 / 3) + REQUEST_ENVELOPE_HEADROOM_BYTES;
471
+ if (maxRequestBodyBytes < requiredImageBodyBytes) throw new Error(`client-connection maxRequestBodyBytes (${String(maxRequestBodyBytes)}) must be at least ${String(requiredImageBodyBytes)} for the configured aggregate image limit`);
472
+ }
473
+ /** Default carrier cap for all HTTP RPC bodies. */
474
+ const DEFAULT_MAX_REQUEST_BODY_BYTES = 32 * 1024 * 1024;
475
+ /** Services required before providing Connection; API Proxy is an optional `/api` fallback. */
476
+ const inject = ["httpServer"];
477
+ const Config = z.object({
478
+ trustedHosts: z.array(String).default([]),
479
+ maxRequestBodyBytes: z.natural().min(1).default(DEFAULT_MAX_REQUEST_BODY_BYTES)
480
+ });
481
+ /**
482
+ * Methods gated to loopback even on a trusted-host deployment. Native dialogs
483
+ * act on the host machine; the settings and credential domains mutate the
484
+ * user's configuration and secret store, and READING them is equally
485
+ * privileged — `settings.describe` returns every exposed namespace's
486
+ * configuration and `credentials.describe` reports whether an arbitrary
487
+ * environment-variable name is configured and where from, which is
488
+ * reconnaissance no anonymous caller should have. `trustedHosts` is a
489
+ * DNS-rebinding fence, explicitly not authentication, so the whole
490
+ * configuration plane stays loopback-same-origin until a real authentication
491
+ * layer exists. `llm.discoverModels` belongs to that plane on both counts: it
492
+ * carries a draft credential, and it makes the HOST issue a GET to a URL the
493
+ * caller chose and reports back the status or the parsed body — an anonymous
494
+ * LAN caller would have a probe for whatever the host can reach and the
495
+ * browser cannot.
496
+ *
497
+ * The model catalog (`llm.providers`, `llm.models`) is deliberately NOT here:
498
+ * it carries provider ids, display names, and model lists — no endpoints,
499
+ * keys, or key state — and a LAN client's model picker legitimately needs it.
500
+ */
501
+ const PRIVILEGED_METHODS = new Set([
502
+ "agentPreset.read",
503
+ "agentPreset.copy",
504
+ "agentPreset.openDocument",
505
+ "agentPreset.remove",
506
+ "host.pickDirectory",
507
+ "host.openPath",
508
+ "settings.describe",
509
+ "settings.openDocument",
510
+ "settings.update",
511
+ "settings.replace",
512
+ "settings.mutate",
513
+ "credentials.describe",
514
+ "credentials.set",
515
+ "credentials.unset",
516
+ "llm.discoverModels"
517
+ ]);
518
+ /**
519
+ * Mounts the API gateway under the browser transport prefix. Every request on
520
+ * the prefix passes the browser-trust fence first (DNS-rebinding and
521
+ * cross-site defense — [api-request-trust](./api-request-trust.ts));
522
+ * privileged methods additionally pass it with an empty trust list, which
523
+ * pins them to loopback.
524
+ * @param ctx - Host plugin context.
525
+ * @param config - resolved plugin config (schema defaults applied).
526
+ */
527
+ function apply(ctx, config) {
528
+ const trustedHosts = config?.trustedHosts ?? [];
529
+ const maxRequestBodyBytes = config?.maxRequestBodyBytes ?? DEFAULT_MAX_REQUEST_BODY_BYTES;
530
+ for (const entry of trustedHosts) assertTrustedAuthority(entry);
531
+ if (ctx.get("apiProxy") !== void 0) assertImageBodyCapacity(ctx, maxRequestBodyBytes);
532
+ const fetchHandler = new HostConnectionService(ctx, trustedHosts).createSharedFetchHandler(API_PATH, { async fetch(request) {
533
+ const pathname = new URL(request.url).pathname;
534
+ const method = pathname.startsWith(`/api/`) ? pathname.slice(5) : void 0;
535
+ if (method !== void 0 && PRIVILEGED_METHODS.has(method) && !isTrustedApiRequest(request, [])) return new Response("forbidden", { status: 403 });
536
+ if (request.method === "GET" && (pathname === MUX_EVENTS_PATH || pathname === HOST_EVENTS_PATH)) return new Response("upgrade required", {
537
+ status: 426,
538
+ headers: {
539
+ connection: "Upgrade",
540
+ upgrade: "websocket"
541
+ }
542
+ });
543
+ const apiProxy = ctx.get("apiProxy");
544
+ if (apiProxy === void 0) return new Response("not found", { status: 404 });
545
+ return toFetchHandler(apiProxy).fetch(request);
546
+ } });
547
+ const route = {
548
+ kind: "prefix",
549
+ path: API_PATH,
550
+ handler: async (req, res) => {
551
+ if (!isTrustedApiRequest(req, trustedHosts)) {
552
+ res.writeHead(403);
553
+ res.end("forbidden");
554
+ return;
555
+ }
556
+ await bridge(req, res, fetchHandler, maxRequestBodyBytes);
557
+ }
558
+ };
559
+ ctx.effect(() => ctx.httpServer.register(route), "client-connection: /api route");
560
+ ctx.inject(["apiProxy"], (apiCtx) => {
561
+ assertImageBodyCapacity(apiCtx, maxRequestBodyBytes);
562
+ const downlinks = new WebSocketDownlinks(apiCtx.apiProxy);
563
+ const registerDownlink = (path, handle) => {
564
+ apiCtx.effect(() => apiCtx.httpServer.registerUpgrade({
565
+ path,
566
+ handler: (req, socket, head) => {
567
+ if (!isTrustedApiRequest(req, trustedHosts)) {
568
+ rejectWebSocketUpgrade(socket);
569
+ return;
570
+ }
571
+ return handle(req, socket, head);
572
+ }
573
+ }), `client-connection: ${path} WebSocket`);
574
+ };
575
+ apiCtx.effect(() => () => downlinks.close(), "client-connection: WebSocket downlinks");
576
+ registerDownlink(MUX_EVENTS_PATH, (req, socket, head) => {
577
+ downlinks.handleMux(req, socket, head);
578
+ });
579
+ registerDownlink(HOST_EVENTS_PATH, (req, socket, head) => {
580
+ downlinks.handleHost(req, socket, head);
581
+ });
582
+ });
583
+ }
584
+ //#endregion
585
+ export { API_PATH, Config, HOST_EVENTS_PATH, HostConnectionService, MUX_EVENTS_PATH, apply, inject, name };
@@ -0,0 +1,26 @@
1
+ //#region lib/types/invariant.js
2
+ /**
3
+ * Package-owned invariant companion for `@deepseek-ai/dsh-client-connection`.
4
+ * @module @deepseek-ai/dsh-client-connection/invariant
5
+ */
6
+ const PACKAGE_NAME = "@deepseek-ai/dsh-client-connection";
7
+ /** Cordis companion plugin name. */
8
+ const name = "client-connection-invariant";
9
+ /** Service required before the companion can reserve package ownership. */
10
+ const inject = ["invariants"];
11
+ /**
12
+ * No runtime invariant: the wire layer emits no cordis events and owns no
13
+ * mutable cross-plugin relation — stream/reconnect sequencing is exercised
14
+ * directly by its behavior specs, rpcId round-trip discipline is owned by the
15
+ * apiproxy contract layer, and the node half's single route registration's
16
+ * register/dispose symmetry is audited by the webserver package's invariant.
17
+ */
18
+ const install = () => {};
19
+ /**
20
+ * Register this package's invariant companion.
21
+ * @param ctx - Cordis context carrying the invariant service.
22
+ * @returns the installed registration's disposer after setup succeeds.
23
+ */
24
+ const apply = (ctx) => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install));
25
+ //#endregion
26
+ export { apply, inject, name };
@@ -0,0 +1,12 @@
1
+ /**
2
+ * The /api URL prefix — single source for both halves of the web transport.
3
+ * The node half registers this prefix on the web server; both halves share the
4
+ * event paths below for the browser WebSocket downlinks.
5
+ */
6
+ /** Route prefix owning every api request (`/api` and `/api/<anything>`). */
7
+ export declare const API_PATH = "/api";
8
+ /** Browser mux-frame WebSocket pathname. */
9
+ export declare const MUX_EVENTS_PATH = "/api/events.mux";
10
+ /** Browser host-frame WebSocket pathname. */
11
+ export declare const HOST_EVENTS_PATH = "/api/events.host";
12
+ //# sourceMappingURL=api-path.d.ts.map
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Browser-trust fence for every /api request. Defends the two confused-deputy
3
+ * paths a browser opens against a local HTTP API — DNS rebinding (Host names
4
+ * the attacker's domain while the socket reaches this server) and cross-site
5
+ * requests fired from a malicious page. The Host fence binds every request,
6
+ * browser-looking or not: over plain HTTP a browser attaches neither Origin
7
+ * nor Fetch-Metadata to reads (images and navigations — those
8
+ * headers go only to trustworthy destinations), so an unmarked request may
9
+ * still be a rebound browser read and Host is the one header rebinding cannot
10
+ * forge. Non-browser and remote clients pass the same fence via loopback, the
11
+ * CLI-derived LAN IP literals, or a declared `trustedHosts` authority.
12
+ * Network reachability and authentication stay out of scope: binding policy
13
+ * belongs to the webserver config, and this fence is not an auth layer.
14
+ */
15
+ import type { IncomingHttpHeaders } from 'node:http';
16
+ /** The request facts the fence reads from either HTTP representation. */
17
+ interface ApiTrustRequest {
18
+ headers: IncomingHttpHeaders | Headers;
19
+ }
20
+ /**
21
+ * Assert one configured `trustedHosts` entry is a bare authority (`host` or
22
+ * `host:port`) in canonical form: it must survive WHATWG parsing unchanged
23
+ * (case aside). Anything parsing would silently rewrite is refused as a typo
24
+ * that must fail the load loudly instead of being ignored until requests 403
25
+ * or quietly changing the grant: URL parts beyond the authority
26
+ * (`harness.internal/path`, `user@harness.internal` — which would authorize
27
+ * the embedded hostname), stripped whitespace, a dangling colon or
28
+ * zero-padded port (which would broaden an intended exact-port grant to every
29
+ * port), and non-canonical host spellings (`0x7f.0.0.1`, percent-encoding,
30
+ * unbracketed IPv6; IDN hosts are declared in punycode, the form the wire
31
+ * carries).
32
+ * @param entry - the configured value, verbatim.
33
+ */
34
+ export declare function assertTrustedAuthority(entry: string): void;
35
+ /**
36
+ * Decide whether one /api request may reach the RPC bridge.
37
+ * @param request - Node HTTP or Fetch request facts (headers).
38
+ * @param trustedHosts - non-loopback authorities this deployment serves: exact `host:port`, or port-less `host` matching any port.
39
+ * @returns true when the Host is ours (loopback or trusted) and any attached browser markers are same-origin.
40
+ */
41
+ export declare function isTrustedApiRequest(request: ApiTrustRequest, trustedHosts: readonly string[]): boolean;
42
+ export {};
43
+ //# sourceMappingURL=api-request-trust.d.ts.map