Merge remote-tracking branch 'origin/master' into worktree/web-multimodal-image-input

# 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
This commit is contained in:
Yichen Jiang
2026-08-08 01:56:29 +08:00
3973 changed files with 144864 additions and 60887 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/connection/README.md
README.md: 8e8cb23cf56745116142d786ad712d7230f4f2c5
README.zh.md: dc2416d8b4dafa06f715e60f97e6dcd6d7fc29d9
README.md: 161e34c4b6018625fb690e178eb9a9f8ac0ef21b
README.zh.md: d17012cc89c02a1b11f16d126b7c0cafe67fb2a0

View File

@@ -2,15 +2,15 @@
English | [中文](README.zh.md)
Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. Each successful generation validates `host.describe` before `onConnected`; a business-error response fails the generation like a transport error. The node half's `/api` route pins the privileged method set (`host.pickDirectory`, `host.openPath`, and the whole configuration plane — `settings.describe`/`update`/`replace`/`mutate` and `credentials.describe`/`set`/`unset`, reads included, since describing returns the exposed configuration and probing an arbitrary reference reports where a credential comes from) to loopback by passing the trust fence with an empty trust list — a declared `trustedHosts` authority reaches every other method, while these stay loopback-local until a real authentication layer exists. Its independent `maxRequestBodyBytes` config caps every buffered API request (32 MiB default) and must be large enough for the configured aggregate image payload after base64 and envelope expansion; changing image policy does not silently redefine the carrier cap for text and other methods. The platform subclasses (WebApiClient/FixtureApiClient), the ConnectionController loop, and the fixture data source are package-internal — apply selects and drives them; tests reach them via src. Contract: api-contracts v3 §3.
Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + current-page loopback state + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. The browser carrier uses HTTP POST for unary and respond operations and opens one downlink-only WebSocket each for `events.mux` and `events.host`; the in-process carrier satisfies the same two-stream abstraction. The Host half owns the single `/api` route and its Fetch bridge; a registered TypeRT interceptor claims its Remote endpoints before the API Proxy fallback. Loopback hostname classification stays package-internal: the `/api` Host fence and WebSocket upgrades use it directly, while other client plugins consume the derived `ctx.connection.isLoopback` state. The node half's `/api` route pins the privileged method set (`host.pickDirectory`, `host.openPath`, and the whole configuration plane — `settings.describe`/`openDocument`/`update`/`replace`/`mutate` and `credentials.describe`/`set`/`unset`; reads and native actions included, since describing returns the exposed configuration, opening acts on the Host desktop, and probing an arbitrary reference reports where a credential comes from) to loopback by passing the trust fence with an empty trust list — a declared `trustedHosts` authority reaches every other method, while these stay loopback-local until a real authentication layer exists. The platform carriers and ConnectionController loop are package-internal; apply selects and drives them. The downlink boundary is documented in the [WebSocket downlink carrier Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md); the protocol contract is api-contracts v3 §3.
## /api browser-trust fence
The node half guards every request under `/api` before bridging (`src/api-request-trust.ts`). Every request — browser-marked or not — must present a `Host` that is a loopback authority or matches a `trustedHosts` entry: exact on `host:port` entries, any port on port-less entries, both sides compared through WHATWG normalization (DNS-rebinding defense). There is deliberately no shortcut for requests without browser markers: over plain HTTP a browser attaches neither `Origin` nor Fetch-Metadata to reads (EventSource, images, navigations — those headers go only to trustworthy destinations), so an unmarked request may still be a rebound browser read with a readable response, and Host is the one header rebinding cannot forge; non-browser clients pass the same fence via loopback, the CLI-derived LAN IP literals, or a declared authority. When markers are present, an attached `Origin` must equal the Host authority, and an explicit `sec-fetch-site: cross-site` marker is refused. A `trustedHosts` entry that is not a bare, canonical `host[:port]` authority — one WHATWG parsing reads back exactly as written — fails the plugin load loudly: parsing would otherwise quietly authorize the hostname inside `harness.internal/path`, or broaden a dangling-colon or zero-padded port to an any-port grant. Failures answer plain 403 before any RPC dispatch. A non-loopback (`--host 0.0.0.0`) deployment therefore needs its serving authorities trusted: the dsh CLI derives the machine's LAN IP literals itself and its `--trusted-host` flag declares named ones, so `trustedHosts` in cordis.yml is for compositions the CLI does not boot. The fence is deliberately not an authentication layer — reachability policy stays with the webserver binding, and auth remains deferred work. Decision record: [the api browser-trust boundary Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md).
The node half guards every entry under `/api` before bridging or upgrading (`src/api-request-trust.ts`). Every request — browser-marked or not — must present a `Host` that is a loopback authority or matches a `trustedHosts` entry: exact on `host:port` entries, any port on port-less entries, both sides compared through WHATWG normalization (DNS-rebinding defense). There is deliberately no shortcut for unmarked HTTP requests: over plain HTTP a browser attaches neither `Origin` nor Fetch-Metadata to image and navigation reads, so an unmarked request may still be a rebound browser read with a readable response, and Host is the one header rebinding cannot forge; a browser WebSocket handshake carries `Origin` and passes the same comparison. Non-browser clients pass the same fence via loopback, the CLI-derived LAN IP literals, or a declared authority. When markers are present, an attached `Origin` must equal the Host authority, and an explicit `sec-fetch-site: cross-site` marker is refused. A `trustedHosts` entry that is not a bare, canonical `host[:port]` authority — one WHATWG parsing reads back exactly as written — fails the plugin load loudly: parsing would otherwise quietly authorize the hostname inside `harness.internal/path`, or broaden a dangling-colon or zero-padded port to an any-port grant. HTTP failures answer plain 403 before any RPC dispatch; upgrade failures reject the handshake before any event stream starts. A non-loopback (`--host 0.0.0.0`) deployment therefore needs its serving authorities trusted: the dsh CLI derives the machine's LAN IP literals itself and its `--trusted-host` flag declares named ones, so `trustedHosts` in cordis.yml is for compositions the CLI does not boot. The fence is a reachability policy, not authentication; the Web carrier provides no authentication layer. Decision record: [the api browser-trust boundary Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md).
## Keyless fixture
## `/api` WebSocket downlinks
Any `fixture` query parameter selects the in-memory carrier. `fixture=empty` starts with no Workspace or Session; `fixturePrompt=reject` rejects prompts before acceptance; `fixtureAttach=fail` publishes a Session but rejects its Workspace attachment; `fixtureSessionCreate=drop-response` publishes and frames a Session before dropping the create response; and `fixtureFrames=workspace-first` reverses the default session-first create-frame order. Workspace creation by name/path and caller-preallocated SessionIds remain deterministic enough for assembled Web tests to reconcile list and frame arrival. Fixture content search preserves the production-facing `unicode61`-style case, diacritic, and token-phrase behavior and returns a match-centered snippet of at most 120 Unicode code points.
`/api/events.mux` and `/api/events.host` each accept a WebSocket upgrade and send only the corresponding `ServerRequest` text messages to the browser; the client sends no application data over these sockets. If either socket ends, the current connection generation fails and rebuilds both streams; readiness still requires both sockets to be open and the `host.describe` HTTP call to succeed. Host teardown terminates both sockets, aborts their sources, and waits for source cleanup before returning. Ordinary network GETs to these paths return 426 with no SSE fallback; `toFetchHandler`'s SSE codec serves only the isomorphic in-process carrier.
## Model Experience
@@ -22,6 +22,4 @@ None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **`attachments` is a hard inject** — the route plugin (and `host-apiproxy`) will not mount until an attachment backend provides `ctx.attachments`, and a composition missing one stalls silently as a cordis inject gap rather than failing loud; text-only deployments therefore still carry the native `sharp` dependency through `attachment-local`. A capability-degraded (image-refusing) mount is deliberate deferred work.
- **history's implicit resume is arguable** — opening history on an unattached session pulls an agent up host-side; the pure-persistence-read alternative is recorded in the rt-core reconciliation ledger, unchanged in P-I. This package's consumers see it as latency on first open.
- **`ToolEventView`/`ToolCallView`/`ToolResultView` re-exports are scheduled for removal** — they fall when the toolview migration deletes the host `viewFor` line (presentation belongs to the client); the fixture keeps a local `viewFor` mirror until then.
- **History resumes an unattached session** — opening history may create the host-side agent and add latency to the first open; there is no persistence-only read path.

View File

@@ -2,15 +2,15 @@
[English](README.md) | 中文
协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam,以及循环的 sink/配置类型。每个成功连接代都会在 `onConnected` 前校验 `host.describe`;业务错误响应会像传输错误一样使该连接代失败。node 半侧的 `/api` 路由让特权方法集(`host.pickDirectory`、`host.openPath`,以及整个配置面——`settings.describe`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`,读取也在内,因为 describe 会返回已暴露的配置,而探测任意引用会报出某条凭据来自何处)以空信任表过信任 fence,从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法,而这些方法在真正的认证层出现之前仍只限回环本机。其独立的 `maxRequestBodyBytes` 配置会限制每个缓冲 API 请求(默认 32 MiB),并且必须足以容纳配置的图片总载荷经 base64 和请求封装膨胀后的大小;修改图片策略不会静默地为文本和其他方法重定义载体上限。平台子类(WebApiClient/FixtureApiClient)、ConnectionController 循环和 fixture 数据源都属于包内部:apply 负责选择并驱动它们,测试则通过 src 访问。契约:api-contracts v3 §3。
协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 当前页面的 loopback 状态 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam,以及循环的 sink/配置类型。浏览器载体以 HTTP POST 发送 unary/respond,并为 `events.mux` 与 `events.host` 各开一条只下行的 WebSocket;进程内载体满足同一双流抽象。Host half 持有唯一 `/api` route 及其 Fetch bridge;已注册的 TypeRT interceptor 会先认领自己的 Remote endpoint,未认领请求再回退 API Proxy。Loopback hostname 判定逻辑留在包内部:`/api` Host fence 与 WebSocket upgrade 会直接使用它,其他客户端插件则消费派生的 `ctx.connection.isLoopback` 状态。node 半侧的 `/api` 路由让特权方法集(`host.pickDirectory`、`host.openPath`,以及整个配置面——`settings.describe`/`openDocument`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`;读取与原生操作也在内,因为 describe 会返回已暴露的配置、打开操作会作用于 Host 桌面,而探测任意引用会报出某条凭据来自何处)以空信任表过信任 fence,从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法,而这些方法在真正的认证层出现之前仍只限回环本机。平台载体与 ConnectionController 循环属于包内部;apply 负责选择并驱动它们。下行边界见 [WebSocket 下行载体 Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md);协议契约见 api-contracts v3 §3。
## /api 浏览器信任栅栏
node 半侧在桥接前守卫 `/api` 下的每个请求(`src/api-request-trust.ts`)。每个请求——无论是否带浏览器标记——`Host` 都必须是回环地址权威,或与某个 `trustedHosts` 条目匹配:带端口的 `host:port` 条目精确匹配,不带端口的条目匹配任意端口,两侧均经 WHATWG 归一化后比较(DNS rebinding 防御)。刻意不为无浏览器标记的请求开捷径:明文 HTTP 下浏览器的读取(EventSource、图片、导航——这些头只发给可信目标)既不带 `Origin` 也不带 Fetch-Metadata,因此无标记请求仍可能是被重绑页面发起的、响应可被读走的读取,而 Host 是重绑唯一伪造不了的请求头;非浏览器客户端经由回环地址、CLI 推导的 LAN IP 字面量或已声明的权威通过同一道栅栏。当标记存在时,`Origin` 必须与 Host 权威完全一致;显式的 `sec-fetch-site: cross-site` 标记一律拒绝。不是纯的、规范形 `host[:port]` 权威的 `trustedHosts` 条目——即 WHATWG 解析读回后与原文不完全一致的——会让插件加载大声失败:否则解析会悄悄授权 `harness.internal/path` 这类笔误里的 hostname,或把悬空冒号、补零端口放大成任意端口授权。失败在任何 RPC 分发之前以纯 403 应答。因此非回环(`--host 0.0.0.0`)部署需要让自己的服务权威被信任:dsh CLI 会自行推导本机的 LAN IP 字面量,其 `--trusted-host` flag 用于声明具名权威,所以 cordis.yml 中的 `trustedHosts` 面向 CLI 不参与引导的组合。这道栅栏刻意不承担认证职责——可达性策略归 webserver 绑定配置,认证仍是延期工作。决策记录:[api 浏览器信任边界 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md)。
node 半侧在桥接或 upgrade 前守卫 `/api` 下的每个入口(`src/api-request-trust.ts`)。每个请求——无论是否带浏览器标记——`Host` 都必须是回环地址权威,或与某个 `trustedHosts` 条目匹配:带端口的 `host:port` 条目精确匹配,不带端口的条目匹配任意端口,两侧均经 WHATWG 归一化后比较(DNS rebinding 防御)。刻意不为无浏览器标记的 HTTP 请求开捷径:明文 HTTP 下浏览器的图片与导航读取既不带 `Origin` 也不带 Fetch-Metadata,因此无标记请求仍可能是被重绑页面发起的、响应可被读走的读取,而 Host 是重绑唯一伪造不了的请求头;WebSocket 浏览器握手会带 `Origin` 并通过同一道比较。非浏览器客户端经由回环地址、CLI 推导的 LAN IP 字面量或已声明的权威通过同一道栅栏。当标记存在时,`Origin` 必须与 Host 权威完全一致;显式的 `sec-fetch-site: cross-site` 标记一律拒绝。不是纯的、规范形 `host[:port]` 权威的 `trustedHosts` 条目——即 WHATWG 解析读回后与原文不完全一致的——会让插件加载大声失败:否则解析会悄悄授权 `harness.internal/path` 这类笔误里的 hostname,或把悬空冒号、补零端口放大成任意端口授权。HTTP 失败在任何 RPC 分发之前以纯 403 应答,upgrade 失败在启动任何 event stream 前拒绝握手。因此非回环(`--host 0.0.0.0`)部署需要让自己的服务权威被信任:dsh CLI 会自行推导本机的 LAN IP 字面量,其 `--trusted-host` flag 用于声明具名权威,所以 cordis.yml 中的 `trustedHosts` 面向 CLI 不参与引导的组合。这道栅栏是可达性策略,而不是认证;Web 载体不提供认证层。决策记录:[api 浏览器信任边界 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md)。
## 无密钥 fixture
## `/api` WebSocket 下行
任何 `fixture` 查询参数都会选择内存载体。`fixture=empty` 启动时不含 Workspace 或 Session;`fixturePrompt=reject` 在接受前拒绝提示词;`fixtureAttach=fail` 发布 Session 但拒绝将其附加到 Workspace;`fixtureSessionCreate=drop-response` 在丢弃创建响应前发布 Session 并为其发出帧;`fixtureFrames=workspace-first` 则反转默认的 Session 优先创建帧顺序。按名称/路径创建 Workspace 以及由调用方预先分配 SessionId,均具有足够的确定性,组装后的 Web 测试可以据此协调列表与帧的到达。fixture 内容搜索会保留面向生产环境的 `unicode61` 式大小写、变音符号和 token/短语行为,并返回以匹配位置为中心、最多包含 120 个 Unicode 码点的 snippet。
`/api/events.mux` 与 `/api/events.host` 各接受一条 WebSocket upgrade,并只向浏览器发送对应的 `ServerRequest` text message;客户端不会在这些 socket 上发送业务数据。任一 socket 结束都会使当前 connection generation 失败并重建两条流,连接就绪仍要求两条 socket open 且 `host.describe` HTTP 调用成功。Host teardown 会终止两条 socket、中止各自的 source,并等待 source 清理完成后再返回。普通网络 GET 这些路径会返回 426,不保留 SSE 回退;`toFetchHandler` 的 SSE 编解码只服务进程内同构载体。
## 模型体验
@@ -22,6 +22,4 @@ node 半侧在桥接前守卫 `/api` 下的每个请求(`src/api-request-trust
## 已知限制与暂缓事项
- **`attachments` 是硬性注入依赖**:路由插件(以及 `host-apiproxy`)在附件后端提供 `ctx.attachments` 之前不会挂载;缺少后端的组合会以 cordis 注入缺口的形式静默停滞,而非响亮失败;因此纯文本部署也会经由 `attachment-local` 携带原生 `sharp` 依赖。降级为拒绝图片的挂载方式是有意延期的工作。
- **history 的隐式恢复存在争议**:在未附加的会话上打开 history,会在主机侧拉起 agent;纯持久化读取的替代方案记录在 rt-core 协调账本中,P-I 不作改变。该包的消费方会在首次打开时感受到这段延迟。
- **计划移除 `ToolEventView`/`ToolCallView`/`ToolResultView` 的重新导出**:当 toolview 迁移删除主机 `viewFor` 行时,它们会一并移除(呈现属于客户端);在此之前,fixture 保留一份局部 `viewFor` 镜像。
- **History 会恢复未附加的会话**:打开 history 可能创建宿主侧 agent,并增加首次打开的延迟;没有仅从持久化读取的路径。

View File

@@ -1,6 +1,6 @@
{
"name": "@deepseek-ai/dsh-client-connection",
"description": "Wire consumer layer: IApiClient subclasses, ConnectionController (SSE dual-stream + reconnect), fixture api (no cordis)",
"description": "Wire consumer layer: HTTP-up/WebSocket-down client, ConnectionController dual streams with reconnect, and fixture api",
"version": "0.0.1",
"private": true,
"type": "module",
@@ -35,15 +35,14 @@
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"schemastery": "^3.18.0"
"schemastery": "^3.18.0",
"ws": "^8.21.0"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/client.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
"lib/types/**/*.d.ts"
],
"peerDependencies": {
"@deepseek-ai/dsh-host-webserver": "^0.0.1",
@@ -53,6 +52,7 @@
"devDependencies": {
"@deepseek-ai/dsh-host-webserver": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@types/ws": "^8.18.1",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -1,8 +1,14 @@
/**
* The /api URL prefix — single source for both halves of the web transport.
* The node half registers this prefix on the web server; browser-side path
* literals currently live in the apiproxy client layer (out of scope here).
* The node half registers this prefix on the web server; both halves share the
* event paths below for the browser WebSocket downlinks.
*/
/** Route prefix owning every api request (`/api` and `/api/<anything>`). */
export const API_PATH = '/api'
/** Browser mux-frame WebSocket pathname. */
export const MUX_EVENTS_PATH = `${API_PATH}/events.mux`
/** Browser host-frame WebSocket pathname. */
export const HOST_EVENTS_PATH = `${API_PATH}/events.host`

View File

@@ -4,7 +4,7 @@
* the attacker's domain while the socket reaches this server) and cross-site
* requests fired from a malicious page. The Host fence binds every request,
* browser-looking or not: over plain HTTP a browser attaches neither Origin
* nor Fetch-Metadata to reads (EventSource, images, navigations — those
* nor Fetch-Metadata to reads (images and navigations — those
* headers go only to trustworthy destinations), so an unmarked request may
* still be a rebound browser read and Host is the one header rebinding cannot
* forge. Non-browser and remote clients pass the same fence via loopback, the
@@ -14,25 +14,19 @@
*/
import type { IncomingHttpHeaders } from 'node:http'
import { isLoopbackHostname } from './loopback-hostname.ts'
/** The request facts the fence reads (structural subset of IncomingMessage). */
/** The request facts the fence reads from either HTTP representation. */
interface ApiTrustRequest {
headers: IncomingHttpHeaders
headers: IncomingHttpHeaders | Headers
}
function header(headers: IncomingHttpHeaders, name: string): string | undefined {
function header(headers: IncomingHttpHeaders | Headers, name: string): string | undefined {
if (headers instanceof Headers) return headers.get(name) ?? undefined
const value = headers[name]
return typeof value === 'string' ? value : undefined
}
function isLoopbackHostname(hostname: string): boolean {
if (hostname === 'localhost' || hostname === '[::1]') return true
const parts = hostname.split('.')
return parts.length === 4
&& parts[0] === '127'
&& parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255)
}
/** Normalized URL of a Host-header authority (hostname lowercased, default port stripped, IPv6 bracketed), or undefined when unparsable. */
function parseAuthority(authority: string): URL | undefined {
try {
@@ -95,7 +89,7 @@ function isTrustedAuthority(hostUrl: URL, trustedHosts: readonly string[]): bool
/**
* Decide whether one /api request may reach the RPC bridge.
* @param request - node HTTP request facts (headers).
* @param request - Node HTTP or Fetch request facts (headers).
* @param trustedHosts - non-loopback authorities this deployment serves: exact `host:port`, or port-less `host` matching any port.
* @returns true when the Host is ours (loopback or trusted) and any attached browser markers are same-origin.
*/
@@ -104,7 +98,7 @@ export function isTrustedApiRequest(request: ApiTrustRequest, trustedHosts: read
// fills Host from the URL it believes it is talking to, so a rebound page
// carries the attacker's domain here even though the socket lands on this
// server. There is no marker shortcut — a browser read over plain HTTP
// (EventSource, images, navigations) arrives with neither Origin nor
// (images and navigations) arrives with neither Origin nor
// Fetch-Metadata, indistinguishable from curl, and its response is readable
// by the rebound page.
const host = header(request.headers, 'host')

View File

@@ -12,10 +12,11 @@ export type {
ResponseValue, WorkspaceApi, WorkspaceId, WorkspaceView,
CommandsApi, CommandDescriptor, SkillsApi, SkillEntry,
ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
InboxItemId, ModelReasoningEffort, ModelTarget, QueueAction, QueuedInboxItem, SessionModels,
ModelReasoningEffort, ModelTarget, QueueAction, QueuedInboxItem, SessionModels,
GoalsApi, GoalRef,
SettingsApi, SettingsNamespaceView, SettingsPathOpView, SettingsSecretView,
CredentialsApi, CredentialView, ConfigurableProviderView, LlmApi,
CredentialsApi, CredentialView, ConfigurableProviderView, DiscoveredModelView, LlmApi,
SubagentsApi, SubagentAddress, SubagentCatalog, SubagentListEntry, SubagentPromptReceipt,
} from '@deepseek-ai/dsh-host-apiproxy/api'
export type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools/presentation'
export type {
@@ -33,6 +34,7 @@ export {
export { AbstractApiClient } from '@deepseek-ai/dsh-host-apiproxy/client'
export type { IApiClient } from '@deepseek-ai/dsh-host-apiproxy/client'
export type { SessionId, SessionEvent } from '@deepseek-ai/dsh-session/types'
export type { MessageId } from '@deepseek-ai/dsh-llm/brand'
export type { ContentBlock, StreamChunk } from '@deepseek-ai/dsh-llm/types'
/** Successful value returned by the connection-generation host handshake. */

View File

@@ -126,7 +126,7 @@ export class ConnectionController {
try {
// Strict readiness handshake (audit C2): describe proves unary reachability, onOpen
// proves each SSE transport is established (response headers in, before any frame) —
// proves each physical stream is established before any frame —
// only then may onConnected fire, so the resync it triggers cannot outrun the
// subscribed baseline. The timeout guards against a carrier that never fires onOpen
// (see ConnectionConfig.streamOpenTimeoutMs).

File diff suppressed because it is too large Load Diff

View File

@@ -8,6 +8,9 @@ import type { IApiClient } from './api.ts'
import { ConnectionController, type ConnectionConfig, type ConnectionSinks, type ConnectionState } from './connection.ts'
import { FixtureApiClient } from './fixture.ts'
import { WebApiClient } from './web-api-client.ts'
import { createWebConnectionRpc } from './rpc.ts'
import { isLoopbackHostname } from '../loopback-hostname.ts'
import type { ClientConnectionRpc } from '../rpc.ts'
// ---- Contract re-exports (browser-safe apiproxy channels + core types) ----
export type {
@@ -17,13 +20,14 @@ export type {
ToolCallView, ToolResultView, WorkspaceApi, WorkspaceId, WorkspaceView,
CommandsApi, CommandDescriptor, SkillsApi, SkillEntry,
ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
InboxItemId, ModelReasoningEffort, ModelTarget, QueueAction, QueuedInboxItem, SessionModels,
MessageId, ModelReasoningEffort, ModelTarget, QueueAction, QueuedInboxItem, SessionModels,
SubagentsApi, SubagentAddress, SubagentCatalog, SubagentListEntry, SubagentPromptReceipt,
RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode,
ClientRequest, ServerResponse, ServerRequest, ClientResponse, RpcMessage, RpcReceipt,
HostDescription, IApiClient, SessionId, SessionEvent, ContentBlock, StreamChunk,
GoalsApi, GoalRef,
SettingsApi, SettingsNamespaceView, SettingsPathOpView, SettingsSecretView,
CredentialsApi, CredentialView, ConfigurableProviderView, LlmApi,
CredentialsApi, CredentialView, ConfigurableProviderView, DiscoveredModelView, LlmApi,
} from './api.ts'
export {
RpcId,
@@ -34,6 +38,7 @@ export {
// Connection loop types are public through ConnectionHandle.start; the
// controller remains package-internal.
export type { ConnectionConfig, ConnectionSinks, ConnectionState }
export type { ClientConnectionRpc } from '../rpc.ts'
/** Required services (none — this is the wire root). */
@@ -47,6 +52,10 @@ export const inject: string[] = []
export interface ConnectionHandle {
/** Shared api client (fixture or real, decided at boot from the page URL). */
readonly api: IApiClient
/** Whether the current page authority is loopback; non-browser contexts default to true. */
readonly isLoopback: boolean
/** Generic logical RPC channels over the same Connection transport. */
readonly rpc: ClientConnectionRpc
/**
* Start the connect/pump/reconnect loop with the consumer's frame sinks.
* One consumer owns the streams (the runtime object layer); a second call
@@ -63,11 +72,16 @@ export interface ConnectionHandle {
* @param ctx - client cordis context.
*/
export function apply(ctx: Context): void {
const fixture = typeof location !== 'undefined' && new URLSearchParams(location.search).has('fixture')
const api: IApiClient = fixture ? new FixtureApiClient() : new WebApiClient()
const pageLocation = typeof location === 'undefined' ? undefined : location
const fixture = pageLocation !== undefined && new URLSearchParams(pageLocation.search).has('fixture')
const fixtureClient = fixture ? new FixtureApiClient() : undefined
const api: IApiClient = fixtureClient ?? new WebApiClient()
const rpc = fixtureClient?.rpc ?? createWebConnectionRpc()
let started = false
const handle: ConnectionHandle = {
api,
isLoopback: pageLocation === undefined || isLoopbackHostname(pageLocation.hostname),
rpc,
start(sinks, config) {
if (started) throw new Error('connection: the stream loop is already owned by another consumer')
started = true

View File

@@ -0,0 +1,14 @@
/** Browser-safe UUID generation for client-side wire correlation. */
/**
* Generate an RFC 4122 version 4 UUID without requiring a secure context.
* @returns a UUID backed by `crypto.getRandomValues()`, which browsers expose on insecure origins.
*/
export function randomUuid(): string {
const bytes = globalThis.crypto.getRandomValues(new Uint8Array(16))
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
view.setUint8(6, (view.getUint8(6) & 0x0f) | 0x40)
view.setUint8(8, (view.getUint8(8) & 0x3f) | 0x80)
const hex = Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join('')
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
}

View File

@@ -0,0 +1,63 @@
/** Browser caller for generic Connection unary RPC channels. */
import {
RpcId,
serverResponseSchema,
type ClientRequest,
} from '@deepseek-ai/dsh-host-apiproxy/api'
import type { ClientConnectionRpc } from '../rpc.ts'
import { randomUuid } from './random-uuid.ts'
const INTERNAL_BASE = 'http://dsh.internal'
const CHANNEL_PATTERN = /^\/[A-Za-z0-9._~-]+$/
const ENDPOINT_SEGMENT_PATTERN = /^[A-Za-z0-9_$.-]+$/
/**
* Create the browser-backed generic RPC caller.
* @returns caller that owns request correlation and response-envelope validation.
*/
export function createWebConnectionRpc(): ClientConnectionRpc {
return {
async call(channel, endpoint, payload, signal) {
assertTarget(channel, endpoint)
const rpcId = RpcId(randomUuid())
const message: ClientRequest = {
type: 'client-request',
rpcId,
method: endpoint,
payload,
}
const response = await globalThis.fetch(
new URL(`${channel}/${endpoint}`, resolveBase()),
{
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(message),
...signal === undefined ? {} : { signal },
},
)
if (!response.ok) {
throw new Error(`transport failure for ${channel}/${endpoint}: HTTP ${response.status}`)
}
const full = serverResponseSchema.parse(await response.json())
if (full.rpcId !== rpcId) {
throw new Error(`rpcId mismatch for ${endpoint}: sent ${rpcId}, got ${full.rpcId}`)
}
return full.result
},
}
}
function resolveBase(): string {
const location = (globalThis as { location?: { origin?: string } }).location
return location?.origin !== undefined && location.origin !== 'null' ? location.origin : INTERNAL_BASE
}
function assertTarget(channel: string, endpoint: string): void {
const segments = endpoint.split('/')
if (!CHANNEL_PATTERN.test(channel)
|| segments.some(segment =>
segment === '' || segment === '.' || segment === '..' || !ENDPOINT_SEGMENT_PATTERN.test(segment))) {
throw new Error(`connection: invalid RPC target ${JSON.stringify(`${channel}/${endpoint}`)}`)
}
}

View File

@@ -1,12 +1,91 @@
// WebApiClient: the browser platform subclass — transport = global fetch over same-origin
// /api/* (base resolution handled by AbstractApiClient). Envelope observation comes from the
// base batching aspect; subscribers attach via subscribeEnvelopes (see boot).
/** Browser API carrier: HTTP upstream plus one WebSocket per downstream event stream. */
import type { ApiProxy, HostFrame, MuxFrame, RpcRequest, ServerRequest } from './api.ts'
import { AbstractApiClient } from './api.ts'
import { hostFrameSchema, muxFrameSchema } from '@deepseek-ai/dsh-host-apiproxy/api/events.schema'
import { serverRequestSchema } from '@deepseek-ai/dsh-host-apiproxy/api/rpc.schema'
import { HOST_EVENTS_PATH, MUX_EVENTS_PATH } from '../api-path.ts'
/** Browser platform subclass: transport = global fetch over same-origin /api/*. */
type SocketItem<F> = { kind: 'frame'; envelope: RpcRequest<F> } | { kind: 'end' }
type Parser<F> = { parse(value: unknown): F }
/** Browser platform subclass: unary/respond use fetch; mux/host use downlink-only WebSockets. */
export class WebApiClient extends AbstractApiClient {
protected doFetch(input: URL, init?: RequestInit): Promise<Response> {
return globalThis.fetch(input, init)
}
protected override openMux(
_payload: Parameters<ApiProxy['events']['mux']>[0]['payload'],
signal: AbortSignal,
onOpen?: () => void,
): AsyncIterable<RpcRequest<MuxFrame>> {
return this.readWebSocket(MUX_EVENTS_PATH, signal, muxFrameSchema, onOpen)
}
protected override openHost(
_payload: Parameters<ApiProxy['events']['host']>[0]['payload'],
signal: AbortSignal,
onOpen?: () => void,
): AsyncIterable<RpcRequest<HostFrame>> {
return this.readWebSocket(HOST_EVENTS_PATH, signal, hostFrameSchema, onOpen)
}
private async *readWebSocket<F extends MuxFrame | HostFrame>(
path: string,
signal: AbortSignal,
frameSchema: Parser<F>,
onOpen?: () => void,
): AsyncGenerator<RpcRequest<F>> {
const url = new URL(path, this.resolveBase())
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
const socket = new WebSocket(url)
const inbox: SocketItem<F>[] = []
let wake: (() => void) | undefined
const enqueue = (item: SocketItem<F>): void => {
inbox.push(item)
wake?.()
wake = undefined
}
const handleOpen = (): void => { onOpen?.() }
const handleMessage = (event: MessageEvent): void => {
let full: ServerRequest
let frame: F
try {
if (typeof event.data !== 'string') throw new Error('binary WebSocket frame')
full = serverRequestSchema.parse(JSON.parse(event.data))
frame = frameSchema.parse(full.payload)
} catch (error) {
console.error(`[client-connection] dropping malformed WebSocket frame on ${path}:`, error)
return
}
this.onEnvelope(full)
enqueue({ kind: 'frame', envelope: { rpcId: full.rpcId, payload: frame } })
}
const handleClose = (): void => { enqueue({ kind: 'end' }) }
const handleAbort = (): void => {
if (socket.readyState === WebSocket.CONNECTING || socket.readyState === WebSocket.OPEN) socket.close()
}
socket.addEventListener('open', handleOpen)
socket.addEventListener('message', handleMessage)
socket.addEventListener('close', handleClose, { once: true })
signal.addEventListener('abort', handleAbort, { once: true })
if (signal.aborted) handleAbort()
try {
while (true) {
while (inbox.length > 0) {
const item = inbox.shift() as SocketItem<F>
if (item.kind === 'end') return
yield item.envelope
}
await new Promise<void>((resolve) => { wake = resolve })
}
} finally {
signal.removeEventListener('abort', handleAbort)
socket.removeEventListener('open', handleOpen)
socket.removeEventListener('message', handleMessage)
socket.removeEventListener('close', handleClose)
handleAbort()
}
}
}

View File

@@ -5,6 +5,16 @@
import type { IncomingMessage, ServerResponse } from 'node:http'
/** Transport-independent request handler consumed by the Host HTTP bridge. */
export interface FetchHandler {
/**
* Handle one standard Fetch request.
* @param request - request produced by the active transport bridge.
* @returns complete or streaming Fetch response.
*/
fetch(request: Request): Promise<Response>
}
/**
* Bridge one node:http request to the fetch-shaped handler (client close
* aborts; SSE bodies stream out chunk by chunk).
@@ -16,8 +26,8 @@ import type { IncomingMessage, ServerResponse } from 'node:http'
export async function bridge(
req: IncomingMessage,
res: ServerResponse,
apiHandler: { fetch: typeof fetch },
maxRequestBodyBytes: number,
apiHandler: FetchHandler,
maxRequestBodyBytes = 32 * 1024 * 1024,
): Promise<void> {
const abort = new AbortController()
// Client-disconnect detection MUST hang off the response, not the request:
@@ -30,10 +40,6 @@ export async function bridge(
})
const declaredLength = req.headers['content-length']
if (declaredLength !== undefined && Number(declaredLength) > maxRequestBodyBytes) {
// Same discipline as the chunked-overrun path below: destroy, never
// drain. resume() would keep the socket open while the client trickles
// its declared length — an already-rejected request holding a server
// socket for as long as it likes.
res.writeHead(413, { connection: 'close' })
res.end()
req.destroy()

View File

@@ -3,24 +3,50 @@ 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 } from '@deepseek-ai/dsh-host-webserver'
import type { WebRoute, WebUpgradeRoute } from '@deepseek-ai/dsh-host-webserver'
import { toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy'
import { API_PATH } from './api-path.ts'
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 { API_PATH } from './api-path.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 the aggregate base64 image payload. */
/** Headroom for RPC JSON fields around aggregate base64 image payloads. */
const REQUEST_ENVELOPE_HEADROOM_BYTES = 1024 * 1024
/** Independent default carrier cap; deployments may raise it for larger valid non-image RPCs. */
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 mounting the route. */
export const inject = ['httpServer', 'apiProxy', 'attachments']
/** 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 {
@@ -33,11 +59,7 @@ export interface ConnectionConfig {
* that is not a bare, canonical authority fails the plugin load.
*/
trustedHosts?: string[]
/**
* Maximum buffered JSON body for every `/api` request. This carrier policy
* is independent of image limits but must be large enough for the configured
* aggregate image bytes after base64 and envelope expansion.
*/
/** Maximum buffered JSON body for every `/api` request. */
maxRequestBodyBytes?: number
}
@@ -56,21 +78,28 @@ export const Config: z<ConnectionConfig> = z.object({
* 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. 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.
* 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',
])
/**
@@ -85,38 +114,67 @@ const PRIVILEGED_METHODS = new Set([
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)
const apiHandler = toFetchHandler(ctx.apiProxy)
const requiredImageBodyBytes = Math.ceil(
ctx.attachments.imageLimits.maxMessageImageBytes * 4 / 3,
) + REQUEST_ENVELOPE_HEADROOM_BYTES
const maxRequestBodyBytes = config?.maxRequestBodyBytes ?? DEFAULT_MAX_REQUEST_BODY_BYTES
if (maxRequestBodyBytes < requiredImageBodyBytes) {
throw new Error(
`client-connection maxRequestBodyBytes (${String(maxRequestBodyBytes)}) must be at least `
+ `${String(requiredImageBodyBytes)} for the configured aggregate image limit`,
)
}
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) => {
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) {
if (!isTrustedApiRequest(req, trustedHosts)) {
res.writeHead(403)
res.end('forbidden')
return
}
await bridge(req, res, apiHandler, maxRequestBodyBytes)
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) })
})
}

View File

@@ -0,0 +1,18 @@
/**
* Browser-safe, zero-dependency loopback classification shared by the `/api`
* Host fence and the package's `ctx.connection` state. The predicate stays
* package-internal; client plugins consume the derived state through Cordis.
*/
/**
* Whether a normalized URL hostname names the local loopback authority.
* @param hostname - WHATWG URL hostname (IPv6 literals retain brackets).
* @returns true for localhost, IPv6 loopback, or any IPv4 address in 127/8.
*/
export function isLoopbackHostname(hostname: string): boolean {
if (hostname === 'localhost' || hostname === '[::1]') return true
const parts = hostname.split('.')
return parts.length === 4
&& parts[0] === '127'
&& parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255)
}

View File

@@ -0,0 +1,224 @@
/** Host registry and HTTP adapter for generic Connection RPC channels. */
import { Context, Service } from 'cordis'
import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
import {
clientRequestSchema,
RpcId,
type ClientRequest,
type RpcError,
type RpcErrorDetailsMap,
type RpcId as RpcIdType,
type ServerResponse as RpcServerResponse,
} from '@deepseek-ai/dsh-host-apiproxy/api'
import { bridge, type FetchHandler } from './http-bridge.ts'
import { isTrustedApiRequest } from './api-request-trust.ts'
import { API_PATH } from './api-path.ts'
import type {
ConnectionRpcEndpointMatcher,
ConnectionRpcHandler,
ConnectionRpcHandlerOptions,
HostConnectionHandle,
HostConnectionRpc,
} from './rpc.ts'
const INVALID_REQUEST_RPC_ID = RpcId('invalid-request')
const CHANNEL_PATTERN = /^\/[A-Za-z0-9._~-]+$/
const ENDPOINT_SEGMENT_PATTERN = /^[A-Za-z0-9_$.-]+$/
interface ConnectionRpcInterceptor {
readonly matches: ConnectionRpcEndpointMatcher
readonly fetchHandler: FetchHandler
readonly options: ConnectionRpcHandlerOptions
}
declare module 'cordis' {
interface Context {
/** Host Connection transport and RPC registrations. */
connection: HostConnectionHandle
}
}
/** Host Connection service whose channel registrations belong to the caller fiber. */
export class HostConnectionService extends Service implements HostConnectionHandle {
private readonly interceptors = new Map<string, ConnectionRpcInterceptor>()
/**
* Provide the Host half over the active HTTP server.
* @param ctx - owning Connection plugin context.
* @param trustedHosts - deployment authorities accepted by trusted-host channels.
*/
constructor(ctx: Context, private readonly trustedHosts: readonly string[]) {
super(ctx, 'connection')
}
/** Generic channel registry scoped to the Context reading this service. */
get rpc(): HostConnectionRpc {
const owner = this.ctx
return {
handle: (channel, handler, options) => this.register(owner, channel, handler, options),
intercept: (channel, matches, handler, options) =>
this.registerInterceptor(owner, channel, matches, handler, options),
}
}
/**
* Compose one shared-channel Fetch handler from its interceptor and fallback.
* @param channel - shared channel mounted by Connection.
* @param fallback - handler for endpoints not claimed by the interceptor.
* @returns Fetch handler that selects exactly one target for each request.
*/
createSharedFetchHandler(
channel: '/api',
fallback: FetchHandler,
): FetchHandler {
return {
fetch: (request) => {
const endpoint = endpointFromPath(channel, new URL(request.url).pathname)
const interceptor = this.interceptors.get(channel)
if (endpoint === undefined || interceptor === undefined || !interceptor.matches(endpoint)) {
return fallback.fetch(request)
}
if (interceptor.options.authority === 'loopback' && !isTrustedApiRequest(request, [])) {
return Promise.resolve(new Response('forbidden', { status: 403 }))
}
return interceptor.fetchHandler.fetch(request)
},
}
}
private register(
owner: Context,
channel: string,
handler: ConnectionRpcHandler,
options: ConnectionRpcHandlerOptions,
): () => Promise<void> {
assertChannel(channel)
const trustedHosts = options.authority === 'loopback' ? [] : this.trustedHosts
const fetchHandler = rpcFetchHandler(channel, handler)
const route: WebRoute = {
kind: 'prefix',
path: channel,
handler: async (req, res) => {
if (!isTrustedApiRequest(req, trustedHosts)) {
res.writeHead(403)
res.end('forbidden')
return
}
await bridge(req, res, fetchHandler)
},
}
return owner.effect(
() => owner.httpServer.register(route),
`client-connection: ${channel} rpc channel`,
)
}
private registerInterceptor(
owner: Context,
channel: string,
matches: ConnectionRpcEndpointMatcher,
handler: ConnectionRpcHandler,
options: ConnectionRpcHandlerOptions,
): () => Promise<void> {
if (channel !== API_PATH) {
throw new Error(`connection: invalid shared RPC channel ${JSON.stringify(channel)}`)
}
const interceptor: ConnectionRpcInterceptor = {
matches,
fetchHandler: rpcFetchHandler(channel, handler),
options,
}
return owner.effect(() => {
if (this.interceptors.has(channel)) {
throw new Error(`connection: shared RPC channel ${JSON.stringify(channel)} already has an interceptor`)
}
this.interceptors.set(channel, interceptor)
return () => {
this.interceptors.delete(channel)
}
}, `client-connection: ${channel} rpc interceptor`)
}
}
function rpcFetchHandler(
channel: string,
handler: ConnectionRpcHandler,
): FetchHandler {
return {
async fetch(request: Request): Promise<Response> {
const endpoint = endpointFromPath(channel, new URL(request.url).pathname)
if (request.method !== 'POST' || endpoint === undefined) {
return new Response('not found', { status: 404 })
}
const mediaType = request.headers.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase()
if (mediaType !== 'application/json') {
return new Response('content type must be application/json', { status: 415 })
}
let body: unknown
try {
body = await request.json()
} catch {
return new Response('body is not JSON', { status: 400 })
}
const envelope = clientRequestSchema.safeParse(body)
if (!envelope.success) {
return invalidEnvelopeResponse(body, envelope.error.issues)
}
const message: ClientRequest = envelope.data
if (message.method !== endpoint) {
return errorResponse(message.rpcId, {
code: 'bad-request',
message: `method ${JSON.stringify(message.method)} does not match endpoint ${JSON.stringify(endpoint)}`,
details: { issues: [] },
})
}
try {
const result = await handler(endpoint, message.payload, request.signal)
return fullResponse(message.rpcId, result)
} catch (error) {
return new Response(`handler failure: ${String(error)}`, { status: 500 })
}
},
}
}
function invalidEnvelopeResponse(body: unknown, issues: RpcErrorDetailsMap['bad-request']['issues']): Response {
const rawId = (body as { rpcId?: unknown } | null)?.rpcId
const rpcId = typeof rawId === 'string' ? RpcId(rawId) : INVALID_REQUEST_RPC_ID
return errorResponse(rpcId, {
code: 'bad-request',
message: 'invalid client-request message',
details: { issues },
})
}
function endpointFromPath(channel: string, pathname: string): string | undefined {
if (!pathname.startsWith(`${channel}/`)) return undefined
const endpoint = pathname.slice(channel.length + 1)
const segments = endpoint.split('/')
if (segments.some(segment =>
segment === '' || segment === '.' || segment === '..' || !ENDPOINT_SEGMENT_PATTERN.test(segment))) {
return undefined
}
return endpoint
}
function errorResponse(rpcId: RpcIdType, error: RpcError): Response {
return fullResponse(rpcId, { ok: false, error })
}
function fullResponse(rpcId: RpcIdType, result: RpcServerResponse['result']): Response {
const body: RpcServerResponse = { type: 'server-response', rpcId, result }
return Response.json(body)
}
function assertChannel(channel: string): void {
if (!CHANNEL_PATTERN.test(channel) || channel === '/api') {
throw new Error(`connection: invalid or reserved RPC channel ${JSON.stringify(channel)}`)
}
}

View File

@@ -0,0 +1,77 @@
/** Generic unary RPC contracts shared by the Host and Client Connection halves. */
import type { RpcResult } from '@deepseek-ai/dsh-host-apiproxy/api'
/** Trust fence applied before a Host RPC channel reaches its handler. */
export type ConnectionRpcAuthority = 'trusted-host' | 'loopback'
/** Registration policy for one logical RPC channel. */
export interface ConnectionRpcHandlerOptions {
/** Browser authority accepted by every endpoint in this channel. */
readonly authority: ConnectionRpcAuthority
}
/** Handler invoked after Connection has decoded the transport envelope. */
export type ConnectionRpcHandler = (
endpoint: string,
payload: unknown,
signal: AbortSignal,
) => Promise<RpcResult<unknown>>
/** Synchronous ownership test for one endpoint on a shared RPC channel. */
export type ConnectionRpcEndpointMatcher = (endpoint: string) => boolean
/** Host registry for logical RPC channels carried by the current transport. */
export interface HostConnectionRpc {
/**
* Register one absolute channel prefix and its trust policy.
* @param channel - absolute logical channel such as `/rpc`.
* @param handler - decoded endpoint handler returning the existing RPC result shape.
* @param options - channel trust policy.
* @returns asynchronous disposer removing the channel and its physical route.
*/
handle(
channel: string,
handler: ConnectionRpcHandler,
options: ConnectionRpcHandlerOptions,
): () => Promise<void>
/**
* Intercept owned endpoints on the shared `/api` channel before its fallback.
* @param channel - reserved shared channel; currently `/api`.
* @param matches - synchronous endpoint ownership test.
* @param handler - decoded endpoint handler returning the existing RPC result shape.
* @param options - trust policy for every endpoint claimed by this interceptor.
* @returns asynchronous disposer removing the interceptor.
*/
intercept(
channel: '/api',
matches: ConnectionRpcEndpointMatcher,
handler: ConnectionRpcHandler,
options: ConnectionRpcHandlerOptions,
): () => Promise<void>
}
/** Host `ctx.connection` shape consumed by transport-independent adapters. */
export interface HostConnectionHandle {
/** Generic RPC channel registry. */
readonly rpc: HostConnectionRpc
}
/** Client caller for logical RPC channels carried by the current transport. */
export interface ClientConnectionRpc {
/**
* Call one endpoint through an already registered logical channel.
* @param channel - absolute logical channel such as `/api`.
* @param endpoint - channel-relative endpoint such as `goals/create`.
* @param payload - channel-owned request payload.
* @param signal - optional caller cancellation.
* @returns the existing RPC success/error result; correlation stays inside Connection.
*/
call(
channel: string,
endpoint: string,
payload: unknown,
signal?: AbortSignal,
): Promise<RpcResult<unknown>>
}

View File

@@ -0,0 +1,153 @@
/** Host-side WebSocket carrier for the two server-to-browser event streams. */
import { randomUUID } from 'node:crypto'
import type { IncomingMessage } from 'node:http'
import type { Duplex } from 'node:stream'
import WebSocket, { WebSocketServer } from 'ws'
import type {
ApiProxy, HostFrame, MuxFrame, RpcRequest, ServerRequest,
} from '@deepseek-ai/dsh-host-apiproxy/api'
import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api'
type Frame = MuxFrame | HostFrame
function serverRequest(frame: RpcRequest<Frame>): ServerRequest {
return {
type: 'server-request',
rpcId: frame.rpcId,
method: frame.payload.type,
payload: frame.payload,
}
}
function send(socket: WebSocket, frame: RpcRequest<Frame>): Promise<void> {
return new Promise((resolve, reject) => {
if (socket.readyState !== WebSocket.OPEN) {
reject(new Error('websocket downlink closed before frame delivery'))
return
}
socket.send(JSON.stringify(serverRequest(frame)), (error) => {
if (error) reject(error)
else resolve()
})
})
}
function failureFrame(error: unknown): RpcRequest<Frame> {
return {
rpcId: RpcId(randomUUID()),
payload: {
type: 'stream/error',
error: { code: 'internal', message: String(error), details: {} },
},
}
}
/**
* Owns WebSocket negotiation and frame pumping for the connection plugin's
* two downlinks. Client messages are a protocol violation: upstream traffic
* remains on HTTP.
*/
export class WebSocketDownlinks {
private readonly server = new WebSocketServer({ noServer: true })
private readonly pumps = new Set<Promise<void>>()
/** @param api - host API supplying the typed event streams. */
constructor(private readonly api: ApiProxy) {}
/**
* Upgrade one socket and pump the mux stream until either side closes.
* @param req - HTTP upgrade request.
* @param socket - Raw socket transferred by the HTTP server.
* @param head - Bytes already read after the upgrade headers.
*/
handleMux(req: IncomingMessage, socket: Duplex, head: Buffer): void {
this.upgrade(req, socket, head, signal => this.api.events.mux({
rpcId: RpcId(randomUUID()),
payload: {},
}, signal))
}
/**
* Upgrade one socket and pump the host stream until either side closes.
* @param req - HTTP upgrade request.
* @param socket - Raw socket transferred by the HTTP server.
* @param head - Bytes already read after the upgrade headers.
*/
handleHost(req: IncomingMessage, socket: Duplex, head: Buffer): void {
this.upgrade(req, socket, head, signal => this.api.events.host({
rpcId: RpcId(randomUUID()),
payload: {},
}, signal))
}
/**
* Terminate owned sockets and await the no-server acceptor plus frame pumps.
* @returns A promise resolving after every socket and source iterator stops.
*/
async close(): Promise<void> {
for (const socket of this.server.clients) socket.terminate()
await new Promise<void>((resolve, reject) => {
this.server.close((error) => {
if (error === undefined) resolve()
else reject(error)
})
})
await Promise.all(this.pumps)
}
private upgrade<F extends Frame>(
req: IncomingMessage,
socket: Duplex,
head: Buffer,
open: (signal: AbortSignal) => AsyncIterable<RpcRequest<F>>,
): void {
this.server.handleUpgrade(req, socket, head, (websocket) => {
const abort = new AbortController()
websocket.once('close', () => { abort.abort() })
websocket.once('error', () => { abort.abort() })
websocket.once('message', () => {
websocket.close(1008, 'downlink only')
})
const pump = this.pump(websocket, open(abort.signal), abort)
this.pumps.add(pump)
void pump.then(() => { this.pumps.delete(pump) })
})
}
private async pump<F extends Frame>(
socket: WebSocket,
frames: AsyncIterable<RpcRequest<F>>,
abort: AbortController,
): Promise<void> {
try {
for await (const frame of frames) await send(socket, frame)
} catch (error) {
if (!abort.signal.aborted) {
try {
await send(socket, failureFrame(error))
} catch {
// Socket loss won the race; no downstream remains to receive the failure frame.
}
}
} finally {
abort.abort()
if (socket.readyState === WebSocket.OPEN) socket.close()
}
}
}
/**
* Reject an untrusted upgrade before protocol negotiation.
* @param socket - Raw HTTP socket that remains owned by the caller.
*/
export function rejectWebSocketUpgrade(socket: Duplex): void {
socket.end([
'HTTP/1.1 403 Forbidden',
'Connection: close',
'Content-Type: text/plain; charset=utf-8',
'Content-Length: 9',
'',
'forbidden',
].join('\r\n'))
}

View File

@@ -3,15 +3,55 @@
* selection off the page URL, and the single-consumer stream-loop ownership.
*/
import { Context } from 'cordis'
import { afterEach, describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { apply, type ConnectionHandle } from '../src/client/index.ts'
import type { RpcMessage } from '../src/client/api.ts'
import { RpcId } from '../src/client/api.ts'
import { FixtureApiClient } from '../src/client/fixture.ts'
import { WebApiClient } from '../src/client/web-api-client.ts'
type Win = { location?: { search: string } }
type Win = { location?: { hostname: string; search: string; origin?: string } }
type WebSocketGlobal = { WebSocket?: typeof WebSocket }
const originalWebSocket = globalThis.WebSocket
const sockets: FakeWebSocket[] = []
class FakeWebSocket extends EventTarget {
static readonly CONNECTING = 0
static readonly OPEN = 1
static readonly CLOSING = 2
static readonly CLOSED = 3
readonly url: string
readyState = FakeWebSocket.CONNECTING
constructor(url: string | URL) {
super()
this.url = String(url)
sockets.push(this)
queueMicrotask(() => {
if (this.readyState !== FakeWebSocket.CONNECTING) return
this.readyState = FakeWebSocket.OPEN
this.dispatchEvent(new Event('open'))
})
}
close(): void {
if (this.readyState === FakeWebSocket.CLOSED) return
this.readyState = FakeWebSocket.CLOSED
this.dispatchEvent(new Event('close'))
}
receive(data: unknown): void {
this.dispatchEvent(new MessageEvent('message', { data }))
}
}
afterEach(() => {
delete (globalThis as Win).location
sockets.length = 0
if (originalWebSocket === undefined) delete (globalThis as WebSocketGlobal).WebSocket
else globalThis.WebSocket = originalWebSocket
})
async function mount(): Promise<ConnectionHandle> {
@@ -24,20 +64,28 @@ async function mount(): Promise<ConnectionHandle> {
describe('connection client apply', () => {
it('mounts ctx.connection with the real client when no ?fixture switch is present', async () => {
;(globalThis as Win).location = { search: '' }
;(globalThis as Win).location = { hostname: 'localhost', search: '' }
const handle = await mount()
expect(handle.api).toBeInstanceOf(WebApiClient)
expect(handle.isLoopback).toBe(true)
})
it('selects the fixture client under ?fixture (and with no location at all stays real)', async () => {
;(globalThis as Win).location = { search: '?fixture' }
;(globalThis as Win).location = { hostname: '127.0.0.1', search: '?fixture' }
expect((await mount()).api).toBeInstanceOf(FixtureApiClient)
delete (globalThis as Win).location
expect((await mount()).api).toBeInstanceOf(WebApiClient)
const handle = await mount()
expect(handle.api).toBeInstanceOf(WebApiClient)
expect(handle.isLoopback).toBe(true)
})
it('reports non-loopback page authority through the connection handle', async () => {
;(globalThis as Win).location = { hostname: '192.0.2.20', search: '' }
expect((await mount()).isLoopback).toBe(false)
})
it('start() hands out one loop, rejects a second consumer, and stop() aborts the streams', async () => {
;(globalThis as Win).location = { search: '?fixture' }
;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' }
const handle = await mount()
// config omitted: the `config ?? {}` default arm is part of the surface.
const loop = handle.start({})
@@ -45,8 +93,8 @@ describe('connection client apply', () => {
loop.stop() // teardown must not throw; the fixture streams abort quietly
})
it('WebApiClient carries requests over globalThis.fetch', async () => {
;(globalThis as Win).location = { search: '' }
it('WebApiClient keeps unary calls and respond on globalThis.fetch', async () => {
;(globalThis as Win).location = { hostname: 'localhost', search: '' }
const handle = await mount()
const original = globalThis.fetch
const seen: string[] = []
@@ -57,9 +105,217 @@ describe('connection client apply', () => {
try {
// Schema rejection is fine — the transport hop is the assertion.
await (handle.api as WebApiClient).host.describe({}).catch(() => undefined)
await handle.api.respond({
type: 'client-response',
rpcId: RpcId('response-over-http'),
result: { ok: true, value: {} },
}).catch(() => undefined)
} finally {
globalThis.fetch = original
}
expect(seen.some(u => u.includes('/api/'))).toBe(true)
expect(seen.some(u => u.includes('/api/host.describe'))).toBe(true)
expect(seen.some(u => u.includes('/api/respond'))).toBe(true)
})
it('opens one WebSocket per downlink, parses frames, and aborts both without using fetch', async () => {
;(globalThis as Win).location = {
hostname: 'localhost', search: '', origin: 'http://localhost:3080',
}
;(globalThis as WebSocketGlobal).WebSocket = FakeWebSocket as unknown as typeof WebSocket
const fetch = vi.spyOn(globalThis, 'fetch')
const client = (await mount()).api as WebApiClient
const envelopes: RpcMessage[][] = []
client.subscribeEnvelopes((batch) => { envelopes.push([...batch]) })
const opened: string[] = []
const muxAbort = new AbortController()
const hostAbort = new AbortController()
const mux = client.events.mux({}, muxAbort.signal, () => { opened.push('mux') })[Symbol.asyncIterator]()
const host = client.events.host({}, hostAbort.signal, () => { opened.push('host') })[Symbol.asyncIterator]()
const muxFrame = mux.next()
const hostFrame = host.next()
await vi.waitFor(() => { expect(sockets).toHaveLength(2) })
expect(sockets.map(socket => socket.url)).toEqual([
'ws://localhost:3080/api/events.mux',
'ws://localhost:3080/api/events.host',
])
await vi.waitFor(() => { expect(opened).toEqual(['mux', 'host']) })
const errors = vi.spyOn(console, 'error').mockImplementation(() => {})
sockets[0]!.receive(new Uint8Array([1, 2, 3]))
sockets[1]!.receive(JSON.stringify({ type: 'server-request', rpcId: 'bad', method: 'host/session-status', payload: {} }))
sockets[0]!.receive(JSON.stringify({
type: 'server-request',
rpcId: 'mux-browser',
method: 'session/subscribed',
payload: { type: 'session/subscribed', sessionId: 'session-browser', lastSeq: 8 },
}))
sockets[1]!.receive(JSON.stringify({
type: 'server-request',
rpcId: 'host-browser',
method: 'host/commands-changed',
payload: { type: 'host/commands-changed' },
}))
expect(await muxFrame).toMatchObject({
value: { rpcId: 'mux-browser', payload: { type: 'session/subscribed', lastSeq: 8 } },
})
expect(await hostFrame).toMatchObject({
value: { rpcId: 'host-browser', payload: { type: 'host/commands-changed' } },
})
expect(errors).toHaveBeenCalledTimes(2)
await vi.waitFor(() => { expect(envelopes.flat()).toHaveLength(2) })
expect(fetch).not.toHaveBeenCalled()
const muxEnd = mux.next()
const hostEnd = host.next()
muxAbort.abort()
hostAbort.abort()
await expect(muxEnd).resolves.toMatchObject({ done: true })
await expect(hostEnd).resolves.toMatchObject({ done: true })
expect(sockets.every(socket => socket.readyState === FakeWebSocket.CLOSED)).toBe(true)
errors.mockRestore()
fetch.mockRestore()
})
it('maps an HTTPS page origin to a secure WebSocket URL', async () => {
;(globalThis as Win).location = {
hostname: 'harness.example', search: '', origin: 'https://harness.example',
}
;(globalThis as WebSocketGlobal).WebSocket = FakeWebSocket as unknown as typeof WebSocket
const client = (await mount()).api
const abort = new AbortController()
const iterator = client.events.mux({}, abort.signal)[Symbol.asyncIterator]()
const pending = iterator.next()
await vi.waitFor(() => { expect(sockets[0]?.url).toBe('wss://harness.example/api/events.mux') })
abort.abort()
await expect(pending).resolves.toMatchObject({ done: true })
})
it('closes a WebSocket immediately when its signal was already aborted', async () => {
;(globalThis as Win).location = {
hostname: 'localhost', search: '', origin: 'http://localhost:3080',
}
;(globalThis as WebSocketGlobal).WebSocket = FakeWebSocket as unknown as typeof WebSocket
const client = (await mount()).api
const abort = new AbortController()
abort.abort()
const iterator = client.events.mux({}, abort.signal)[Symbol.asyncIterator]()
await expect(iterator.next()).resolves.toMatchObject({ done: true })
expect(sockets).toHaveLength(1)
expect(sockets[0]?.readyState).toBe(FakeWebSocket.CLOSED)
})
it('carries RPC calls without requiring secure-context randomUUID', async () => {
;(globalThis as Win).location = { hostname: 'localhost', search: '' }
vi.stubGlobal('crypto', {
getRandomValues(bytes: Uint8Array) {
return bytes.fill(0)
},
})
const handle = await mount()
const original = globalThis.fetch
const seen: { url: string; body: unknown }[] = []
globalThis.fetch = async (input: URL | RequestInfo, init?: RequestInit) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url
if (typeof init?.body !== 'string') throw new TypeError('expected a JSON string request body')
const body = JSON.parse(init.body) as { rpcId: string }
seen.push({ url, body })
return Response.json({
type: 'server-response',
rpcId: body.rpcId,
result: { ok: true, value: { ref: 'goal-1' } },
})
}
try {
await expect(handle.rpc.call('/api', 'goals/create', { args: { agentId: 'agent-1' } }))
.resolves.toEqual({ ok: true, value: { ref: 'goal-1' } })
} finally {
globalThis.fetch = original
vi.unstubAllGlobals()
}
expect(seen).toHaveLength(1)
expect(seen[0]?.url).toBe('http://dsh.internal/api/goals/create')
expect(seen[0]?.body).toMatchObject({
type: 'client-request',
rpcId: '00000000-0000-4000-8000-000000000000',
method: 'goals/create',
payload: { args: { agentId: 'agent-1' } },
})
})
it('validates generic RPC transport failures, correlation, and targets', async () => {
;(globalThis as Win).location = {
hostname: 'harness.example', search: '', origin: 'https://harness.example',
}
const handle = await mount()
const original = globalThis.fetch
const abort = new AbortController()
globalThis.fetch = vi.fn().mockResolvedValue(new Response('unavailable', { status: 503 }))
try {
await expect(handle.rpc.call('/api', 'goals/create', {}, abort.signal))
.rejects.toThrow('HTTP 503')
expect(globalThis.fetch).toHaveBeenCalledWith(
new URL('https://harness.example/api/goals/create'),
expect.objectContaining({ signal: abort.signal }),
)
;(globalThis as Win).location = { hostname: 'localhost', search: '', origin: 'null' }
globalThis.fetch = vi.fn().mockResolvedValue(Response.json({
type: 'server-response',
rpcId: 'different-rpc',
result: { ok: true, value: null },
}))
await expect(handle.rpc.call('/api', 'goals/create', {})).rejects.toThrow('rpcId mismatch')
const fetch = vi.mocked(globalThis.fetch)
expect(fetch.mock.calls[0]?.[0]).toEqual(new URL('http://dsh.internal/api/goals/create'))
expect(fetch.mock.calls[0]?.[1]).not.toHaveProperty('signal')
} finally {
globalThis.fetch = original
}
for (const [channel, endpoint] of [
['api2', 'goals/create'],
['/api/path', 'goals/create'],
['/api', ''],
['/api', '.'],
['/api', '..'],
['/api', 'goals//create'],
['/api', 'goals/create?unsafe'],
] as const) {
await expect(handle.rpc.call(channel, endpoint, {})).rejects.toThrow('invalid RPC target')
}
})
it('carries Goal Remotes over the same state as the client-only fixture API', async () => {
;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' }
const handle = await mount()
const created = await handle.rpc.call('/api', 'goals/create', {
args: { agentId: 'fx-alpha', request: { objective: 'fixture remote' } },
})
expect(created).toMatchObject({ ok: true, value: { ref: { revision: 1 } } })
if (!created.ok) throw new Error('fixture Goal create failed')
const ref = (created.value as { ref: { id: string; revision: number } }).ref
const edited = await handle.rpc.call('/api', 'goals/edit', {
args: { agentId: 'fx-alpha', ref, request: { objective: 'edited fixture remote' } },
})
expect(edited).toMatchObject({ ok: true, value: { objective: 'edited fixture remote', revision: 2 } })
const editedRef = { id: ref.id, revision: 2 }
const paused = await handle.rpc.call('/api', 'goals/pause', {
args: { agentId: 'fx-alpha', ref: editedRef },
})
expect(paused).toMatchObject({ ok: true, value: { phase: 'paused', activation: 'disarmed', revision: 3 } })
const resumed = await handle.rpc.call('/api', 'goals/resume', {
args: { agentId: 'fx-alpha', ref: { id: ref.id, revision: 3 } },
})
expect(resumed).toMatchObject({ ok: true, value: { phase: 'active', activation: 'armed', revision: 4 } })
const completed = await handle.rpc.call('/api', 'goals/complete', {
args: { agentId: 'fx-alpha', ref: { id: ref.id, revision: 4 } },
})
expect(completed).toMatchObject({ ok: true, value: { phase: 'complete', activation: 'disarmed', revision: 5 } })
await expect(handle.rpc.call('/api', 'goals/clear', {
args: { agentId: 'fx-alpha', ref: { id: ref.id, revision: 5 } },
})).resolves.toEqual({ ok: true, value: { id: ref.id, revision: 6 } })
await expect(handle.rpc.call('/other', 'goals/create', {})).rejects.toThrow(/channel.*unavailable/)
await expect(handle.rpc.call('/api', 'unknown/read', { args: { agentId: 'fx-alpha' } }))
.rejects.toThrow(/endpoint.*unavailable/)
})
})

View File

@@ -59,6 +59,7 @@ export class FakeApiClient implements IApiClient {
onModels: (payload: unknown) => Promise<RpcResponse<SessionModels>> = () => Promise.resolve(ok({
current: { provider: 'deepseek-official', model: 'deepseek-chat' },
routable: true,
groups: [],
failures: [],
}))
@@ -116,6 +117,20 @@ export class FakeApiClient implements IApiClient {
cancel: (payload: unknown) => this.record('session.cancel', payload, this.onCancel(payload)),
}
readonly subagents: IApiClient['subagents'] = {
list: (payload: unknown) => this.record('subagent.list', payload, Promise.resolve(ok({
entries: [],
parentAvailable: true,
}))),
history: (payload: unknown) => this.record('subagent.history', payload, Promise.resolve(ok({
events: [],
hasMore: false,
}))),
prompt: (payload: unknown) => this.record('subagent.prompt', payload, Promise.resolve(ok({
messageId: 'fake-message' as never,
}))),
}
readonly host: IApiClient['host'] = {
describe: payload => this.record('host.describe', payload, this.onDescribe(payload)),
pickDirectory: payload => this.record('host.pickDirectory', payload, this.onPickDirectory(payload)),
@@ -170,7 +185,8 @@ export class FakeApiClient implements IApiClient {
}
readonly settings: IApiClient['settings'] = {
describe: payload => this.record('settings.describe', payload, Promise.resolve(ok({ writable: true, namespaces: [] }))),
describe: payload => this.record('settings.describe', payload, Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [] }))),
openDocument: payload => this.record('settings.openDocument', payload, Promise.resolve(ok({ opened: true as const }))),
update: payload => this.record('settings.update', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 0 }))),
replace: payload => this.record('settings.replace', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 0 }))),
mutate: payload => this.record('settings.mutate', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 0 }))),
@@ -185,6 +201,7 @@ export class FakeApiClient implements IApiClient {
readonly llm: IApiClient['llm'] = {
providers: payload => this.record('llm.providers', payload, Promise.resolve(ok({ providers: [] }))),
models: payload => this.record('llm.models', payload, Promise.resolve(ok({ groups: [], failures: [] }))),
discoverModels: payload => this.record('llm.discoverModels', payload, Promise.resolve(ok({ models: [] }))),
}
/** When true, streams never fire onOpen (misbehaving-carrier material for the handshake timeout guard). */

View File

@@ -19,6 +19,16 @@ interface TimingHooks {
failNextHistory(): void
appendUser(id: string, msg: string): void
appendTitle(id: string, title: string): void
startReasoningChunkStorm(id: string, chunkCount: number, chunksPerInterval: number, intervalMs: number): string
reasoningChunkStormState(): {
sessionId: string
chunkCount: number
chunksPerInterval: number
intervalMs: number
emitted: number
marker: string
emitting: boolean
} | null
beginModelRetry(id: string): void
scheduleModelRetry(id: string, retry?: number, delayMs?: number): void
cancelModelRetryDuringBackoff(id: string, delayMs?: number): void
@@ -149,6 +159,11 @@ describe('createFixtureApi', () => {
},
// No request ran, so neither pressure nor capacity is known yet.
contextPressure: {},
contextBreakdown: {
systemTokens: 0,
toolsTokens: 0,
messageTokens: 0,
},
} },
})
})
@@ -227,6 +242,10 @@ describe('createFixtureApi', () => {
const times = events.slice(todoAt - 1, todoAt + 2).map(e => e.time)
expect(times[0]).toBeLessThanOrEqual(times[1] ?? 0)
expect(times[1]).toBeLessThanOrEqual(times[2] ?? 0)
// The sample is a parallel plan: this fixture chooses the parallel policy,
// so the surfaces fed from here face more than one active item.
const snapshot = events[todoAt] as { data: { todos: { status: string }[] } }
expect(snapshot.data.todos.filter(t => t.status === 'in_progress')).toHaveLength(2)
})
it('create adds a session and pushes host/session-added to open host streams', async () => {
@@ -294,6 +313,10 @@ describe('createFixtureApi', () => {
frame.type === 'session/projection'
&& frame.key === 'contextPressure'
&& (frame.value as { contextWindow?: number }).contextWindow === 128_000)).toBe(true)
expect(frames.some(frame =>
frame.type === 'session/projection'
&& frame.key === 'contextBreakdown'
&& (frame.value as { messageTokens?: number }).messageTokens! > 0)).toBe(true)
const finalize = frames.find((f): f is Extract<MuxFrame, { type: 'session/event' }> => f.type === 'session/event' && f.event.type === 'assistant/message')
expect(JSON.stringify(finalize?.event.data)).toContain('(已中断)')
// Idle cancel: no replay in flight, must not explode; running flips false.
@@ -301,7 +324,7 @@ describe('createFixtureApi', () => {
expect(idleCancel.result).toMatchObject({ ok: true })
})
it('steer during a replay inserts a steering message and the replay continues to completion', async () => {
it('steer during a replay lands a user/message inside the current turn and the replay continues', async () => {
const api = createFixtureApi()
const created = await api.sessions.create(req({}))
if (!created.result.ok) throw new Error('create failed')
@@ -314,7 +337,7 @@ describe('createFixtureApi', () => {
await api.sessions.prompt(req({ sessionId: id, mode: 'steer' as const, content: [{ type: 'text' as const, text: '插话' }] }))
const frames = await framesPromise
const types = frames.filter((f): f is Extract<MuxFrame, { type: 'session/event' }> => f.type === 'session/event').map(f => f.event.type)
expect(types).toContain('steering/message')
expect(JSON.stringify(frames)).toContain('插话')
expect(types.at(-1)).toBe('turn/end') // steer did not restart the turn
})
@@ -325,7 +348,7 @@ describe('createFixtureApi', () => {
const envelopes: RpcRequest<MuxFrame>[] = []
for await (const envelope of api.events.mux(req({}), abort.signal)) {
envelopes.push(envelope)
if (envelopes.length >= 10) abort.abort()
if (envelopes.length >= 11) abort.abort()
}
return envelopes
}
@@ -341,13 +364,18 @@ describe('createFixtureApi', () => {
expect(first[5]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'goal', value: null })
expect(first[6]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'tokenUsage' })
expect(first[7]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'contextPressure' })
expect(first[8]?.payload).toMatchObject({ type: 'approval/requested', toolName: 'dangerous_tool' })
expect(second[8]?.rpcId).toBe(first[8]?.rpcId) // stable rpcId across replays (host replay semantics)
expect(first[9]?.payload).toMatchObject({ type: 'question/requested', sessionId: 'fx-alpha' })
expect(second[9]?.rpcId).toBe(first[9]?.rpcId)
expect(first[8]?.payload).toMatchObject({
type: 'session/projection', sessionId: 'fx-alpha', key: 'contextBreakdown',
value: { systemTokens: 0, toolsTokens: 0 },
})
expect((first[8]?.payload as { value: { messageTokens: number } }).value.messageTokens).toBeGreaterThan(0)
expect(first[9]?.payload).toMatchObject({ type: 'approval/requested', toolName: 'dangerous_tool' })
expect(second[9]?.rpcId).toBe(first[9]?.rpcId) // stable rpcId across replays (host replay semantics)
expect(first[10]?.payload).toMatchObject({ type: 'question/requested', sessionId: 'fx-alpha' })
expect(second[10]?.rpcId).toBe(first[10]?.rpcId)
})
it('steer with no replay in flight promotes image bytes to a session-scoped reference', async () => {
it('steer with no replay in flight falls through to a fresh queued turn; non-text blocks stringify empty', async () => {
const api = createFixtureApi()
const abort = new AbortController()
const framesPromise = collect<MuxFrame>(api.events.mux(req({}), abort.signal), abort,
@@ -355,61 +383,14 @@ describe('createFixtureApi', () => {
await new Promise(resolve => setTimeout(resolve, 10))
const created = await api.sessions.create(req({}))
if (!created.result.ok) throw new Error('create failed')
// steer while idle + an image: the fixture mirrors the host's durable send boundary.
// steer while idle + a non-text content block (covers the '' arm of the text join).
await api.sessions.prompt(req({
sessionId: created.result.value.sessionId, mode: 'steer' as const,
content: [{ type: 'text' as const, text: '短' }, {
type: 'image' as const,
mediaType: 'image/png' as const,
data: 'iVBORw0KGgoAAAANSUhEUgAAAKAAAABaCAYAAAA/xl1SAAAAvklEQVR42u3SMQ0AAAjAMIyhELM4AAe8PD1qYFlk9cCXEAEDYkAwIAYEA2JAMCAGBANiQDAgBgQDYkAwIAYEA2JAMCAGBANiQDAgBgQDYkAwIAYEA2JAMCAGxIBCYEAMCAbEgGBADAgGxIBgQAwIBsSAYEAMCAbEgGBADAgGxIBgQAwIBsSAYEAMCAbEgGBADAgGxIAYEAyIAcGAGBAMiAHBgBgQDIgBwYAYEAyIAcGAGBAMiAHBgBgQDIgB4bYWLb6pnOb1xAAAAABJRU5ErkJggg==',
name: 'pixel.png',
}],
content: [{ type: 'text' as const, text: '短' }, { type: 'image', data: 'x' } as never],
}))
const frames = await framesPromise
const types = frames.filter((f): f is Extract<MuxFrame, { type: 'session/event' }> => f.type === 'session/event').map(f => f.event.type)
expect(types[0]).toBe('turn/start') // idle steer degraded to a queued turn, not a steering insert
const user = frames.find((f): f is Extract<MuxFrame, { type: 'session/event' }> =>
f.type === 'session/event' && f.event.type === 'user/message')
const image = ((user?.event.data as { content?: { type: string; attachment?: { attachmentId: never } }[] } | undefined)?.content)
?.find(block => block.type === 'image')
expect(image?.attachment).toBeDefined()
if (image?.attachment === undefined) throw new Error('fixture image missing')
const loaded = await api.sessions.attachment(req({
sessionId: created.result.value.sessionId,
attachmentId: image.attachment.attachmentId,
}))
expect(loaded.result).toMatchObject({ ok: true, value: { attachment: { name: 'pixel.png' } } })
const denied = await api.sessions.attachment(req({
sessionId: sid('fx-beta'), attachmentId: image.attachment.attachmentId,
}))
expect(denied.result).toMatchObject({
ok: false, error: { details: { reason: 'ATTACHMENT_NOT_REFERENCED' } },
})
})
it('accounts for every base64 padding form and reports a missing fixture attachment', async () => {
const api = createFixtureApi()
const created = await api.sessions.create(req({}))
if (!created.result.ok) throw new Error('create failed')
const sessionId = created.result.value.sessionId
const prompted = await api.sessions.prompt(req({
sessionId,
mode: 'queue' as const,
content: ['YQ==', 'YWI=', 'YWJj'].map(data => ({
type: 'image' as const,
mediaType: 'image/png' as const,
data,
})),
}))
expect(prompted.result.ok).toBe(true)
const missing = await api.sessions.attachment(req({
sessionId,
attachmentId: 'fixture:missing' as never,
}))
expect(missing.result).toMatchObject({
ok: false, error: { details: { reason: 'ATTACHMENT_NOT_FOUND' } },
})
await api.sessions.cancel(req({ sessionId }))
expect(types[0]).toBe('turn/start') // idle steer degraded to a queued turn, not an in-turn insert
})
it('gamma interval flip emits host/session-status and a running log-less session subscribes at lastSeq -1', async () => {
@@ -920,6 +901,50 @@ describe('createFixtureApi', () => {
expect(abort.signal.aborted).toBe(false)
expect(habort.signal.aborted).toBe(false)
})
it('paces the opt-in reasoning stress hook from an external interval', async () => {
vi.useFakeTimers()
vi.setSystemTime(0)
const api = createFixtureApi()
const hooks = timing()
expect(hooks.reasoningChunkStormState()).toBeNull()
expect(() => hooks.startReasoningChunkStorm('fx-alpha', 0, 1, 16)).toThrow(/chunk count/)
expect(() => hooks.startReasoningChunkStorm('fx-alpha', 1, 0, 16)).toThrow(/chunks per interval/)
expect(() => hooks.startReasoningChunkStorm('fx-alpha', 1, 1, 0)).toThrow(/reasoning interval/)
const abort = new AbortController()
try {
const streamed = collect(api.events.mux(req({}), abort.signal), abort, frames => frames.some(frame => (
frame.type === 'session/event'
&& frame.event.type === 'assistant/chunk'
&& frame.event.data.chunk.type === 'reasoning-delta'
&& frame.event.data.chunk.text.includes('REASONING_STRESS_COMPLETE')
)))
const marker = hooks.startReasoningChunkStorm('fx-alpha', 3, 2, 16)
expect(() => hooks.startReasoningChunkStorm('fx-alpha', 1, 1, 16)).toThrow(/already running/)
expect(hooks.reasoningChunkStormState()).toMatchObject({ emitted: 0, emitting: true, marker })
await vi.advanceTimersByTimeAsync(0)
expect(hooks.reasoningChunkStormState()).toMatchObject({ emitted: 2, emitting: true })
await vi.advanceTimersByTimeAsync(16)
expect(hooks.reasoningChunkStormState()).toEqual({
sessionId: 'fx-alpha', chunkCount: 3, chunksPerInterval: 2, intervalMs: 16,
emitted: 3, marker, emitting: false,
})
const frames = await streamed
const deltas = frames.flatMap(frame => (
frame.type === 'session/event'
&& frame.event.type === 'assistant/chunk'
&& frame.event.data.chunk.type === 'reasoning-delta'
? [frame.event.data.chunk.text]
: []
))
expect(deltas).toEqual(['推理', '推理', `\n${marker}`])
} finally {
abort.abort()
vi.useRealTimers()
}
})
})
describe('FixtureApiClient (protocol-level fake carrier)', () => {
@@ -963,7 +988,6 @@ describe('FixtureApiClient (protocol-level fake carrier)', () => {
const id = created.result.value.sessionId
expect((await client.sessions.history({ sessionId: id })).result.ok).toBe(true)
expect((await client.sessions.prompt({ sessionId: id, mode: 'queue', content: [{ type: 'text', text: '嗨' }] })).result.ok).toBe(true)
expect((await client.sessions.attachment({ sessionId: sid('fx-alpha'), attachmentId: 'fixture:image' as never })).result.ok).toBe(true)
expect((await client.sessions.cancel({ sessionId: id })).result.ok).toBe(true)
expect((await client.host.describe({})).result.ok).toBe(true)
expect((await client.workspace.list({})).result.ok).toBe(true)
@@ -1002,6 +1026,21 @@ describe('FixtureApiClient (protocol-level fake carrier)', () => {
// complete → complete is an invalid transition.
expect((await client.goals.complete({ sessionId: id, ref })).result.ok).toBe(false)
expect((await client.goals.clear({ sessionId: id, ref })).result).toEqual({ ok: true, value: { cleared: true } })
const goalHistory = await client.sessions.history({ sessionId: id })
if (!goalHistory.result.ok) throw new Error('goal history failed')
const goalEvents = goalHistory.result.value.events.map(entry => entry.event as unknown as {
type: string
data: {
operation?: string
source?: { kind?: string; round?: number }
}
})
const goalChanges = goalEvents.filter(event => event.type === 'goal/change')
expect(goalChanges.map(event => event.data.operation))
.toEqual(['create', 'edit', 'pause', 'resume', 'complete', 'clear'])
expect(goalEvents.some(event => event.type === 'user/message'
&& event.data.source?.kind === 'goal' && event.data.source.round === 0)).toBe(false)
})
it('maps empty, prompt-reject, and workspace-first query scenarios', async () => {

View File

@@ -56,7 +56,7 @@ describe('HTTP bridge abort', () => {
let carrierSignal: AbortSignal | undefined
const pending = bridge(request, response, {
fetch: async (input) => {
const fetchRequest = input as Request
const fetchRequest = input
carrierSignal = fetchRequest.signal
resolveStarted()
if (!fetchRequest.signal.aborted) {

View File

@@ -0,0 +1,18 @@
/** Shared loopback-hostname semantics for the Host fence and browser UI. */
import { describe, expect, it } from 'vitest'
import { isLoopbackHostname } from '../src/loopback-hostname.ts'
describe('isLoopbackHostname', () => {
it('accepts localhost, IPv6 loopback, and the whole IPv4 127/8 block', () => {
for (const hostname of ['localhost', '[::1]', '127.0.0.1', '127.8.9.10', '127.255.255.255']) {
expect(isLoopbackHostname(hostname)).toBe(true)
}
})
it('refuses malformed and non-loopback hostnames', () => {
for (const hostname of ['remote.localhost', '::1', '128.0.0.1', '127.0.0', '127.0.0.256', '127.0.0.-1']) {
expect(isLoopbackHostname(hostname)).toBe(false)
}
})
})

View File

@@ -1,33 +1,39 @@
/** Node half: registers the /api prefix route bridging to the api gateway. */
import { EventEmitter } from 'node:events'
import { EventEmitter, once } from 'node:events'
import { createServer, request as httpRequest } from 'node:http'
import { Readable } from 'node:stream'
import { PassThrough, Readable } from 'node:stream'
import { Context } from 'cordis'
import { describe, expect, it } from 'vitest'
import type { AddressInfo } from 'node:net'
import type { IncomingMessage, ServerResponse } from 'node:http'
import type { ApiProxy } from '@deepseek-ai/dsh-host-apiproxy/api'
import type { HttpServerService, WebRoute } from '@deepseek-ai/dsh-host-webserver'
import type { AttachmentStore } from '@deepseek-ai/dsh-attachment'
import { API_PATH, apply, inject } from '../src/index.ts'
import { RpcId, type ClientRequest } from '@deepseek-ai/dsh-host-apiproxy/api'
import type { HttpServerService, WebRoute, WebUpgradeRoute } from '@deepseek-ai/dsh-host-webserver'
import { API_PATH, apply, HOST_EVENTS_PATH, inject, MUX_EVENTS_PATH, type HostConnectionHandle } from '../src/index.ts'
/** Structural httpServer fake: the plugin only touches register(). */
function fakeHttpServer(routes: WebRoute[]): Pick<HttpServerService, 'register' | 'tapIndex' | 'port'> {
/** Structural httpServer fake recording both route registries. */
function fakeHttpServer(
routes: WebRoute[],
upgrades: WebUpgradeRoute[],
): Pick<HttpServerService, 'register' | 'registerUpgrade' | 'tapIndex' | 'port'> {
return {
register(route) {
if (routes.some(candidate => candidate.kind === route.kind && candidate.path === route.path)) {
throw new Error(`duplicate route ${route.path}`)
}
routes.push(route)
return () => { routes.splice(routes.indexOf(route), 1) }
},
registerUpgrade(route) {
upgrades.push(route)
return () => { upgrades.splice(upgrades.indexOf(route), 1) }
},
tapIndex: () => () => {},
port: 0,
}
}
/** Structural attachments fake: the plugin only reads imageLimits for the body cap. */
function fakeAttachments(): AttachmentStore {
return { imageLimits: { maxMessageImageBytes: 20 * 1024 * 1024 } } as AttachmentStore
}
/** Bodyless GET carrying the given headers (enough for the trust fence + bridge). */
function fakeRequest(headers: Record<string, string>, url = `${API_PATH}/session.list`): IncomingMessage {
const request = Readable.from([]) as unknown as IncomingMessage
@@ -35,15 +41,32 @@ function fakeRequest(headers: Record<string, string>, url = `${API_PATH}/session
return request
}
/** JSON POST carrying a complete client-request envelope. */
function fakePost(headers: Record<string, string>, url: string, body: unknown): IncomingMessage {
const request = Readable.from([Buffer.from(JSON.stringify(body))]) as unknown as IncomingMessage
Object.assign(request, { url, method: 'POST', headers: { 'content-type': 'application/json', ...headers } })
return request
}
/** Raw POST for malformed-body and media-type boundary cases. */
function fakeRawPost(headers: Record<string, string>, url: string, body: string): IncomingMessage {
const request = Readable.from([Buffer.from(body)]) as unknown as IncomingMessage
Object.assign(request, { url, method: 'POST', headers })
return request
}
/** Response recorder compatible with both the fence's short-circuit and the bridge. */
function fakeResponse(): { response: ServerResponse; state: { status?: number; body?: unknown } } {
const state: { status?: number; body?: unknown } = {}
const chunks: Buffer[] = []
const response = Object.assign(new EventEmitter(), {
writableEnded: false,
writeHead(value: number) { state.status = value; return this },
write() { return true },
write(value: string | Uint8Array) { chunks.push(Buffer.from(value)); return true },
end(this: { writableEnded: boolean }, value?: unknown) {
if (value !== undefined) state.body = value
if (typeof value === 'string' || value instanceof Uint8Array) chunks.push(Buffer.from(value))
else if (value !== undefined) throw new TypeError('fake response only accepts string or Uint8Array bodies')
if (chunks.length > 0) state.body = Buffer.concat(chunks).toString()
this.writableEnded = true
return this
},
@@ -51,51 +74,82 @@ function fakeResponse(): { response: ServerResponse; state: { status?: number; b
return { response, state }
}
async function mounted(config?: {
trustedHosts?: string[]
maxRequestBodyBytes?: number
}): Promise<{ routes: WebRoute[]; dispose: () => Promise<void> }> {
async function mounted(config?: { trustedHosts?: string[] }): Promise<{
routes: WebRoute[]
upgrades: WebUpgradeRoute[]
dispose: () => Promise<void>
}> {
const ctx = new Context()
const routes: WebRoute[] = []
ctx.provide('httpServer', fakeHttpServer(routes) as HttpServerService)
const upgrades: WebUpgradeRoute[] = []
ctx.provide('httpServer', fakeHttpServer(routes, upgrades) as HttpServerService)
ctx.provide('apiProxy', {} as unknown as ApiProxy)
ctx.provide('attachments', fakeAttachments())
const fiber = ctx.plugin({ inject: [...inject], apply }, config)
await fiber.await()
return { routes, dispose: () => fiber.dispose() }
return { routes, upgrades, dispose: () => fiber.dispose() }
}
describe('connection node half', () => {
it('fails the load on a trustedHosts entry that is not a bare authority', async () => {
const routes: WebRoute[] = []
const ctx = new Context()
ctx.provide('httpServer', fakeHttpServer(routes) as HttpServerService)
ctx.provide('apiProxy', {} as unknown as ApiProxy)
ctx.provide('attachments', fakeAttachments())
const fiber = ctx.plugin({ inject: [...inject], apply }, { trustedHosts: ['harness.internal/path'] })
await expect(fiber).rejects.toThrow(/not a bare host\[:port\] authority/)
expect(routes).toHaveLength(0)
})
it('registers the /api prefix route and removes it with the fiber', async () => {
const { routes, dispose } = await mounted()
expect(routes).toHaveLength(1)
expect(routes[0]).toMatchObject({ kind: 'prefix', path: API_PATH })
await dispose()
expect(routes).toHaveLength(0)
})
it('fails loud when the independent carrier cap cannot hold the configured image batch', () => {
it('fails loud when the carrier cap cannot hold the configured image batch', () => {
const ctx = new Context()
const routes: WebRoute[] = []
ctx.provide('httpServer', fakeHttpServer(routes) as HttpServerService)
ctx.provide('apiProxy', {} as unknown as ApiProxy)
ctx.provide('attachments', fakeAttachments())
ctx.provide('httpServer', fakeHttpServer(routes, []) as HttpServerService)
ctx.provide('attachments', {
imageLimits: { maxMessageImageBytes: 20 * 1024 * 1024 },
} as AttachmentStore)
ctx.provide('apiProxy', {} as ApiProxy)
expect(() => { apply(ctx, { maxRequestBodyBytes: 1024 }) })
.toThrow(/must be at least .* aggregate image limit/)
expect(routes).toHaveLength(0)
})
it('fails the load on a trustedHosts entry that is not a bare authority', async () => {
const routes: WebRoute[] = []
const upgrades: WebUpgradeRoute[] = []
const ctx = new Context()
ctx.provide('httpServer', fakeHttpServer(routes, upgrades) as HttpServerService)
ctx.provide('apiProxy', {} as unknown as ApiProxy)
const fiber = ctx.plugin({ inject: [...inject], apply }, { trustedHosts: ['harness.internal/path'] })
await expect(fiber).rejects.toThrow(/not a bare host\[:port\] authority/)
expect(routes).toHaveLength(0)
expect(upgrades).toHaveLength(0)
})
it('registers one HTTP route plus one upgrade route per downlink and removes all three with the fiber', async () => {
const { routes, upgrades, dispose } = await mounted()
expect(routes).toHaveLength(1)
expect(routes[0]).toMatchObject({ kind: 'prefix', path: API_PATH })
expect(upgrades.map(route => route.path)).toEqual([MUX_EVENTS_PATH, HOST_EVENTS_PATH])
await dispose()
expect(routes).toHaveLength(0)
expect(upgrades).toHaveLength(0)
})
it('requires WebSocket upgrade for network GETs to either event path', async () => {
const { routes, dispose } = await mounted()
for (const path of [MUX_EVENTS_PATH, HOST_EVENTS_PATH]) {
const { response, state } = fakeResponse()
await routes[0]!.handler(fakeRequest({ host: '127.0.0.1:3080' }, path), response)
expect(state.status).toBe(426)
expect(state.body).toBe('upgrade required')
}
await dispose()
})
it('rejects an untrusted WebSocket upgrade before protocol negotiation', async () => {
const { upgrades, dispose } = await mounted()
const socket = new PassThrough()
const chunks: Buffer[] = []
socket.on('data', (chunk: Buffer) => { chunks.push(chunk) })
const ended = once(socket, 'end')
await upgrades[0]!.handler(fakeRequest({
host: 'harness.example', origin: 'http://harness.example', 'sec-fetch-site': 'same-origin',
}, MUX_EVENTS_PATH), socket, Buffer.alloc(0))
await ended
expect(Buffer.concat(chunks).toString()).toContain('HTTP/1.1 403 Forbidden')
await dispose()
})
it('refuses an untrusted Host on any /api path before the bridge runs', async () => {
const { routes, dispose } = await mounted()
const { response, state } = fakeResponse()
@@ -110,13 +164,15 @@ describe('connection node half', () => {
it('pins privileged methods to loopback even for a declared trusted authority', async () => {
const { routes, dispose } = await mounted({ trustedHosts: ['harness.example'] })
// The privileged set: native dialogs plus the whole settings/credential
// configuration plane, reads included. The same declared authority reaches
// configuration plane, reads included, plus the one method that makes the
// host fetch a caller-chosen URL. The same declared authority reaches
// ordinary reads (carrier-level 404 from the empty proxy proves the fence
// passed), but each privileged method stays loopback-only and 403s.
for (const method of [
'host.pickDirectory', 'host.openPath',
'settings.describe', 'settings.update', 'settings.replace', 'settings.mutate',
'settings.describe', 'settings.openDocument', 'settings.update', 'settings.replace', 'settings.mutate',
'credentials.describe', 'credentials.set', 'credentials.unset',
'llm.discoverModels',
]) {
const denied = fakeResponse()
await routes[0]!.handler(
@@ -152,6 +208,211 @@ describe('connection node half', () => {
expect(declared.state.status).toBe(404)
await dispose()
})
it('provides a disposable dedicated RPC channel without requiring apiProxy', async () => {
const ctx = new Context()
const routes: WebRoute[] = []
ctx.provide('httpServer', fakeHttpServer(routes, []) as HttpServerService)
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
expect(routes).toHaveLength(1)
expect(routes[0]).toMatchObject({ kind: 'prefix', path: API_PATH })
const connection = ctx.get('connection') as HostConnectionHandle
const calls: unknown[] = []
const remove = connection.rpc.handle('/rpc', async (endpoint, payload) => {
calls.push({ endpoint, payload })
return { ok: true, value: { accepted: true } }
}, { authority: 'trusted-host' })
const route = routes.find(candidate => candidate.path === '/rpc')
expect(route).toBeDefined()
const request: ClientRequest = {
type: 'client-request',
rpcId: RpcId('rpc-dedicated'),
method: 'goals/create',
payload: { args: { agentId: 'agent-1' } },
}
const result = fakeResponse()
await route!.handler(fakePost({ host: '127.0.0.1:3080' }, '/rpc/goals/create', request), result.response)
expect(result.state.status).toBe(200)
expect(JSON.parse(String(result.state.body))).toEqual({
type: 'server-response',
rpcId: 'rpc-dedicated',
result: { ok: true, value: { accepted: true } },
})
expect(calls).toEqual([{
endpoint: 'goals/create',
payload: { args: { agentId: 'agent-1' } },
}])
expect(() => connection.rpc.handle('/rpc', async () => ({ ok: true, value: null }), {
authority: 'trusted-host',
})).toThrow(/duplicate route/)
await remove()
expect(routes.map(candidate => candidate.path)).toEqual([API_PATH])
await fiber.dispose()
expect(routes).toHaveLength(0)
})
it('dispatches claimed /api endpoints before the API Proxy fallback and withdraws the claim', async () => {
const ctx = new Context()
const routes: WebRoute[] = []
ctx.provide('httpServer', fakeHttpServer(routes, []) as HttpServerService)
ctx.provide('apiProxy', {} as unknown as ApiProxy)
const fiber = ctx.plugin({ inject: [...inject], apply }, { trustedHosts: ['harness.example'] })
await fiber.await()
const connection = ctx.get('connection') as HostConnectionHandle
const calls: unknown[] = []
const remove = connection.rpc.intercept(
'/api',
endpoint => endpoint === 'goals/create',
async (endpoint, payload) => {
calls.push({ endpoint, payload })
return { ok: true, value: { accepted: true } }
},
{ authority: 'trusted-host' },
)
expect(() => connection.rpc.intercept(
'/api',
() => true,
async () => ({ ok: true, value: null }),
{ authority: 'trusted-host' },
)).toThrow('already has an interceptor')
expect(() => connection.rpc.intercept(
'/rpc' as '/api',
() => true,
async () => ({ ok: true, value: null }),
{ authority: 'trusted-host' },
)).toThrow('invalid shared RPC channel')
const route = routes.find(candidate => candidate.path === API_PATH)!
const request: ClientRequest = {
type: 'client-request',
rpcId: RpcId('rpc-shared'),
method: 'goals/create',
payload: { args: { agentId: 'agent-1' } },
}
const claimed = fakeResponse()
await route.handler(fakePost({ host: '127.0.0.1:3080' }, '/api/goals/create', request), claimed.response)
expect(JSON.parse(String(claimed.state.body))).toEqual({
type: 'server-response',
rpcId: 'rpc-shared',
result: { ok: true, value: { accepted: true } },
})
expect(calls).toEqual([{
endpoint: 'goals/create',
payload: { args: { agentId: 'agent-1' } },
}])
const denied = fakeResponse()
await route.handler(fakePost({ host: 'other.example' }, '/api/goals/create', request), denied.response)
expect(denied.state).toMatchObject({ status: 403, body: 'forbidden' })
expect(calls).toHaveLength(1)
const unclaimed = fakeResponse()
await route.handler(fakeRequest({ host: '127.0.0.1:3080' }, '/api/session.list'), unclaimed.response)
expect(unclaimed.state.status).toBe(404)
await remove()
const withdrawn = fakeResponse()
await route.handler(fakePost({ host: '127.0.0.1:3080' }, '/api/goals/create', request), withdrawn.response)
expect(withdrawn.state.status).toBe(404)
expect(calls).toHaveLength(1)
const removeLoopback = connection.rpc.intercept(
'/api',
endpoint => endpoint === 'goals/create',
async () => ({ ok: true, value: null }),
{ authority: 'loopback' },
)
const loopbackOnly = fakeResponse()
await route.handler(fakePost({ host: 'harness.example' }, '/api/goals/create', request), loopbackOnly.response)
expect(loopbackOnly.state.status).toBe(403)
await removeLoopback()
await fiber.dispose()
})
it('applies the configured trust fence and JSON envelope checks to generic channels', async () => {
const ctx = new Context()
const routes: WebRoute[] = []
ctx.provide('httpServer', fakeHttpServer(routes, []) as HttpServerService)
const fiber = ctx.plugin({ inject: [...inject], apply }, { trustedHosts: ['harness.example'] })
await fiber.await()
const connection = ctx.get('connection') as HostConnectionHandle
const remove = connection.rpc.handle('/rpc', async (endpoint) => {
if (endpoint === 'fail') throw new Error('handler broke')
return { ok: true, value: null }
}, {
authority: 'trusted-host',
})
const route = routes.find(candidate => candidate.path === '/rpc')!
const denied = fakeResponse()
await route.handler(fakePost({ host: 'other.example' }, '/rpc/goals/create', {}), denied.response)
expect(denied.state).toMatchObject({ status: 403, body: 'forbidden' })
const methodMismatch = fakeResponse()
await route.handler(fakePost({ host: 'harness.example' }, '/rpc/goals/create', {
type: 'client-request', rpcId: 'rpc-bad', method: 'other', payload: {},
}), methodMismatch.response)
expect(JSON.parse(String(methodMismatch.state.body))).toMatchObject({
rpcId: 'rpc-bad',
result: { ok: false, error: { code: 'bad-request' } },
})
for (const [request, status] of [
[fakeRequest({ host: 'harness.example' }, '/rpc/goals/create'), 404],
[fakePost({ host: 'harness.example' }, '/outside/goals/create', {}), 404],
[fakePost({ host: 'harness.example' }, '/rpc/goals//create', {}), 404],
[fakeRawPost({ host: 'harness.example' }, '/rpc/goals/create', '{}'), 415],
[fakeRawPost({ host: 'harness.example', 'content-type': 'text/plain' }, '/rpc/goals/create', '{}'), 415],
[fakeRawPost({ host: 'harness.example', 'content-type': 'application/json; charset=utf-8' }, '/rpc/goals/create', '{'), 400],
] as const) {
const response = fakeResponse()
await route.handler(request, response.response)
expect(response.state.status).toBe(status)
}
for (const [body, rpcId] of [
[{ rpcId: 'retained-id' }, 'retained-id'],
[{ rpcId: 42 }, 'invalid-request'],
[null, 'invalid-request'],
] as const) {
const response = fakeResponse()
await route.handler(fakePost({ host: 'harness.example' }, '/rpc/goals/create', body), response.response)
expect(JSON.parse(String(response.state.body))).toMatchObject({
rpcId,
result: { ok: false, error: { code: 'bad-request' } },
})
}
const failed = fakeResponse()
await route.handler(fakePost({ host: 'harness.example' }, '/rpc/fail', {
type: 'client-request', rpcId: 'rpc-fail', method: 'fail', payload: {},
}), failed.response)
expect(failed.state).toMatchObject({ status: 500, body: 'handler failure: Error: handler broke' })
expect(() => connection.rpc.handle('/api', async () => ({ ok: true, value: null }), {
authority: 'loopback',
})).toThrow('invalid or reserved RPC channel')
expect(() => connection.rpc.handle('api3', async () => ({ ok: true, value: null }), {
authority: 'loopback',
})).toThrow('invalid or reserved RPC channel')
const removeLoopback = connection.rpc.handle('/loopback', async () => ({ ok: true, value: null }), {
authority: 'loopback',
})
const loopbackRoute = routes.find(candidate => candidate.path === '/loopback')!
const publicResponse = fakeResponse()
await loopbackRoute.handler(fakePost({ host: 'harness.example' }, '/loopback/read', {
type: 'client-request', rpcId: 'rpc-public', method: 'read', payload: {},
}), publicResponse.response)
expect(publicResponse.state.status).toBe(403)
await removeLoopback()
await remove()
await fiber.dispose()
})
})
describe('connection node half over a real HTTP server', () => {
@@ -199,9 +460,12 @@ describe('connection node half over a real HTTP server', () => {
// Reads are as privileged as writes: describe returns the exposed
// configuration, and credentials.describe probes arbitrary env-var names.
for (const method of [
'settings.describe', 'settings.update', 'settings.replace', 'settings.mutate',
'settings.describe', 'settings.openDocument', 'settings.update', 'settings.replace', 'settings.mutate',
'credentials.describe', 'credentials.set', 'credentials.unset',
'host.pickDirectory', 'host.openPath',
// Carries a draft credential and turns the host into a fetcher for a
// URL the caller picked: an anonymous LAN caller must not reach it.
'llm.discoverModels',
]) {
expect([method, await call(port, method, 'harness.example')]).toEqual([method, 403])
}

View File

@@ -0,0 +1,308 @@
import { once } from 'node:events'
import { createServer } from 'node:http'
import type { AddressInfo } from 'node:net'
import { afterEach, describe, expect, it, vi } from 'vitest'
import WebSocket from 'ws'
import type {
ApiProxy, HostFrame, MuxFrame, RpcRequest, ServerRequest,
} from '@deepseek-ai/dsh-host-apiproxy/api'
import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api'
import { HOST_EVENTS_PATH, MUX_EVENTS_PATH } from '../src/api-path.ts'
import { WebSocketDownlinks } from '../src/websocket-downlink.ts'
type MuxSource = (signal: AbortSignal) => AsyncIterable<RpcRequest<MuxFrame>>
type HostSource = (signal: AbortSignal) => AsyncIterable<RpcRequest<HostFrame>>
const running: (() => Promise<void>)[] = []
afterEach(async () => {
await Promise.all(running.splice(0).map(close => close()))
})
function untilAbort(signal: AbortSignal): Promise<void> {
if (signal.aborted) return Promise.resolve()
return new Promise((resolve) => {
signal.addEventListener('abort', () => { resolve() }, { once: true })
})
}
async function * idle<F>(signal: AbortSignal): AsyncGenerator<RpcRequest<F>> {
await untilAbort(signal)
}
function api(mux: MuxSource, host: HostSource): ApiProxy {
return {
events: {
mux: (_request, signal) => mux(signal),
host: (_request, signal) => host(signal),
},
} as ApiProxy
}
async function serve(downlinks: WebSocketDownlinks): Promise<{
origin: string
close: () => Promise<void>
}> {
const server = createServer()
server.on('upgrade', (request, socket, head) => {
const pathname = new URL(request.url ?? '/', 'http://dsh.internal').pathname
if (pathname === MUX_EVENTS_PATH) downlinks.handleMux(request, socket, head)
else if (pathname === HOST_EVENTS_PATH) downlinks.handleHost(request, socket, head)
else socket.destroy()
})
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
const port = (server.address() as AddressInfo).port
return {
origin: `ws://127.0.0.1:${String(port)}`,
close: async () => {
await downlinks.close()
await new Promise<void>(resolve => server.close(() => { resolve() }))
},
}
}
function read(socket: WebSocket): Promise<ServerRequest> {
return once(socket, 'message').then(([data]) => JSON.parse(String(data)) as ServerRequest)
}
async function acceptedSocket(downlinks: WebSocketDownlinks): Promise<WebSocket> {
const server = (downlinks as unknown as { server: { clients: Set<WebSocket> } }).server
let accepted: WebSocket | undefined
await vi.waitFor(() => {
accepted = server.clients.values().next().value
expect(accepted).toBeDefined()
})
return accepted as WebSocket
}
describe('WebSocket downlinks', () => {
it('carries mux and host over independent downstream sockets and cancels each source on close', async () => {
let muxAborted = false
let hostAborted = false
const downlinks = new WebSocketDownlinks(api(
async function * (signal) {
try {
yield {
rpcId: RpcId('mux-1'),
payload: { type: 'session/subscribed', sessionId: 'session-1' as never, lastSeq: 4 },
}
await untilAbort(signal)
} finally {
muxAborted = true
}
},
async function * (signal) {
try {
yield { rpcId: RpcId('host-1'), payload: { type: 'host/commands-changed' } }
await untilAbort(signal)
} finally {
hostAborted = true
}
},
))
const host = await serve(downlinks)
running.push(host.close)
const mux = new WebSocket(`${host.origin}${MUX_EVENTS_PATH}`)
const hostSocket = new WebSocket(`${host.origin}${HOST_EVENTS_PATH}`)
const muxFrame = read(mux)
const hostFrame = read(hostSocket)
expect(await muxFrame).toEqual({
type: 'server-request',
rpcId: 'mux-1',
method: 'session/subscribed',
payload: { type: 'session/subscribed', sessionId: 'session-1', lastSeq: 4 },
})
expect(await hostFrame).toEqual({
type: 'server-request',
rpcId: 'host-1',
method: 'host/commands-changed',
payload: { type: 'host/commands-changed' },
})
const muxClosed = once(mux, 'close')
const hostClosed = once(hostSocket, 'close')
mux.close()
hostSocket.close()
await Promise.all([muxClosed, hostClosed])
await vi.waitFor(() => {
expect(muxAborted).toBe(true)
expect(hostAborted).toBe(true)
})
})
it('rejects client messages because upstream remains HTTP', async () => {
let aborted = false
const downlinks = new WebSocketDownlinks(api(
async function * (signal) {
try {
await untilAbort(signal)
} finally {
aborted = true
}
},
idle,
))
const host = await serve(downlinks)
running.push(host.close)
const socket = new WebSocket(`${host.origin}${MUX_EVENTS_PATH}`)
await once(socket, 'open')
const closed = once(socket, 'close')
socket.send('upstream payload')
const [code, reason] = await closed as [number, Buffer]
expect(code).toBe(1008)
expect(String(reason)).toBe('downlink only')
await vi.waitFor(() => { expect(aborted).toBe(true) })
})
it('sends stream/error before closing when a source fails', async () => {
const downlinks = new WebSocketDownlinks(api(
async function * () {
throw new Error('mux source failed')
},
idle,
))
const host = await serve(downlinks)
running.push(host.close)
const socket = new WebSocket(`${host.origin}${MUX_EVENTS_PATH}`)
const failure = read(socket)
const closed = once(socket, 'close')
expect((await failure).payload).toEqual({
type: 'stream/error',
error: { code: 'internal', message: 'Error: mux source failed', details: {} },
})
await closed
})
it('aborts the source when an accepted socket reports a transport error', async () => {
let aborted = false
const downlinks = new WebSocketDownlinks(api(
async function * (signal) {
try {
await untilAbort(signal)
} finally {
aborted = true
}
},
idle,
))
const host = await serve(downlinks)
running.push(host.close)
const socket = new WebSocket(`${host.origin}${MUX_EVENTS_PATH}`)
await once(socket, 'open')
const accepted = await acceptedSocket(downlinks)
const closed = once(socket, 'close')
accepted.emit('error', new Error('transport failed'))
await closed
expect(aborted).toBe(true)
})
it('drops a source frame that races after the client has closed', async () => {
let release!: () => void
const gate = new Promise<void>((resolve) => { release = resolve })
let finish!: () => void
const finished = new Promise<void>((resolve) => { finish = resolve })
let sourceSignal: AbortSignal | undefined
const downlinks = new WebSocketDownlinks(api(
async function * (signal) {
sourceSignal = signal
try {
await gate
yield {
rpcId: RpcId('late'),
payload: { type: 'session/subscribed', sessionId: 'session-late' as never, lastSeq: 0 },
}
} finally {
finish()
}
},
idle,
))
const host = await serve(downlinks)
running.push(host.close)
const socket = new WebSocket(`${host.origin}${MUX_EVENTS_PATH}`)
await once(socket, 'open')
const closed = once(socket, 'close')
socket.close()
await closed
await vi.waitFor(() => { expect(sourceSignal?.aborted).toBe(true) })
release()
await finished
})
it('contains socket send callback failures and closes the downlink', async () => {
let release!: () => void
const gate = new Promise<void>((resolve) => { release = resolve })
const downlinks = new WebSocketDownlinks(api(
async function * () {
await gate
yield {
rpcId: RpcId('send-failure'),
payload: { type: 'session/subscribed', sessionId: 'session-send' as never, lastSeq: 0 },
}
},
idle,
))
const host = await serve(downlinks)
running.push(host.close)
const socket = new WebSocket(`${host.origin}${MUX_EVENTS_PATH}`)
await once(socket, 'open')
const accepted = await acceptedSocket(downlinks)
const send = vi.spyOn(accepted, 'send').mockImplementation(((
_data: unknown,
optionsOrCallback?: unknown,
callback?: (error?: Error) => void,
) => {
const done = typeof optionsOrCallback === 'function'
? optionsOrCallback as (error?: Error) => void
: callback
done?.(new Error('socket send failed'))
}) as WebSocket['send'])
const closed = once(socket, 'close')
release()
await closed
expect(send).toHaveBeenCalledTimes(2)
send.mockRestore()
})
it('rejects when its acceptor has already closed', async () => {
const downlinks = new WebSocketDownlinks(api(idle, idle))
await downlinks.close()
await expect(downlinks.close()).rejects.toThrow('The server is not running')
})
it('waits for source cleanup before teardown resolves', async () => {
let cleanupStarted!: () => void
const started = new Promise<void>((resolve) => { cleanupStarted = resolve })
let releaseCleanup!: () => void
const cleanupGate = new Promise<void>((resolve) => { releaseCleanup = resolve })
let cleaned = false
const downlinks = new WebSocketDownlinks(api(
async function * (signal) {
try {
await untilAbort(signal)
} finally {
cleanupStarted()
await cleanupGate
cleaned = true
}
},
idle,
))
const host = await serve(downlinks)
const socket = new WebSocket(`${host.origin}${MUX_EVENTS_PATH}`)
await once(socket, 'open')
let closed = false
const closing = host.close().then(() => { closed = true })
try {
await started
expect(closed).toBe(false)
releaseCleanup()
await closing
expect(cleaned).toBe(true)
} finally {
releaseCleanup()
await closing
}
})
})