Support native HTTPS Web listeners

This commit is contained in:
Yichen Yan authored and Yichen Yan committed 2026-10-08 19:49:01 +08:00
1 parent 4f8e70f936
commit ac080346f0
71 files changed
+1307 -289

No files matched your search

@@ -18,5 +18,5 @@
en: 5275c87f19fe6eee
zh: ccb922367c4bb3e2
/agent-note-browser-launch-token-authentication/consequences:
en: 0714a8a7cc74918a
zh: 0f3878b09463ab2e
en: af55274a2f345cdd
zh: 4d1810141ef03bae
@@ -42,6 +42,8 @@ Unit coverage pins process-token retention across Connection reloads, one secret
Possession of the browser cookie authorizes the complete tool-capable Host API, matching the authority the Web application exposes after Session creation. `Host` does not grant a higher method tier, and a method migration between API Proxy and Typert Remote cannot change its caller set.
The persistent secret makes cookies survive restarts but gives a stolen cookie up to the configured absolute lifetime. Deleting the record and restarting the process is the global revocation mechanism; the active Connection intentionally avoids credential-provider work on each request. Omitting `Secure` preserves loopback HTTP and permits plaintext transmission if an operator makes the same cookie authority reachable over an unencrypted network. The startup URL contains a process credential and must be treated as sensitive output; runtime diagnostics do not repeat it.
The persistent secret makes cookies survive restarts but gives a stolen cookie up to the configured absolute lifetime. Deleting the record and restarting the process is the global revocation mechanism; the active Connection intentionally avoids credential-provider work on each request. An HTTP listener permits plaintext cookie transmission if its authority is reachable over an unencrypted network. The startup URL contains a process credential and must be treated as sensitive output; runtime diagnostics do not repeat it.
The decision partially supersedes the authentication deferral and unauthenticated non-loopback consequences in the [browser trust note](2026-07-28-api-browser-trust-boundary.md). That note remains active authority for media-type, Host, Origin, Fetch-Metadata, and configured-authority validation. No active Agent Note is archived: the overlap is partial and both security rules retain future decision value.
The [native HTTPS listener decision](2026-09-23-native-https-web-listener.md) supersedes the HTTP-only transport and omitted-`Secure` assumptions here. Listener protocol now determines `Secure`; the token exchange, signed-cookie authority, and credential lifetime remain owned by this note.
@@ -42,6 +42,8 @@ HMAC 密钥是 `ctx.credentials` 中位于 `client-connection/browser-session`
持有浏览器 cookie 就能调用完整的工具型 Host API,这与 Web 应用在创建 Session 后暴露的 authority 一致。`Host` 不授予更高的方法层级,方法在 API Proxy 与 Typert Remote 之间迁移也不会改变调用者集合。
持久密钥使 cookie 跨重启生效,也让被盗 cookie 最多保有配置的绝对有效期。删除记录并重启进程是全局撤销机制;当前 Connection 刻意避免在每个请求上访问凭据提供方。不设置 `Secure` 保留 loopback HTTP,但如果操作者让同一 cookie authority 经未加密网络可达,cookie 会以明文传输。启动 URL 含进程凭据,必须视为敏感输出;运行时诊断不会重复它。
持久密钥使 cookie 跨重启生效,也让被盗 cookie 最多保有配置的绝对有效期。删除记录并重启进程是全局撤销机制;当前 Connection 刻意避免在每个请求上访问凭据提供方。如果 HTTP listener 的 authority 经未加密网络可达,cookie 就可能以明文传输。启动 URL 含进程凭据,必须视为敏感输出;运行时诊断不会重复它。
本决策部分取代[浏览器信任说明](2026-07-28-api-browser-trust-boundary.zh.md)中的认证延期与未认证非 loopback 后果。该说明仍是媒体类型、Host、Origin、Fetch-Metadata 和配置 authority 校验的有效权威。没有 active Agent Note 被归档:重叠只发生在局部,两条安全规则都保有未来决策价值。
[原生 HTTPS 监听器决策](2026-09-23-native-https-web-listener.zh.md)取代本说明仅支持 HTTP 传输及不设置 `Secure` 的前提。`Secure` 由监听器协议决定;令牌交换、签名 cookie 的 authority 与凭据有效期仍由本说明定义。
@@ -15,8 +15,8 @@
en: 0e4d475347796821
zh: e621e606bc5b1045
/agent-note-bind-one-concrete-web-address/consequences:
en: 09ca30632d3930b2
zh: 87d286ed53f834db
en: ba7a839bca09992d
zh: a11171005405d1e2
/agent-note-bind-one-concrete-web-address/testing:
en: 4bb8f4bf4ad943c3
zh: 2e24d56d01b52574
@@ -28,6 +28,8 @@ The advertised URL uses IPv4 for mapped loopback binds, brackets other IPv6 bind
An operator exposes exactly the interfaces named by `--host` and reaches the listener through that literal without further configuration; every other remote authority is a `--trusted-host` decision. Compositions that bound `0.0.0.0` fail at load with a message naming the rejected spelling. IPv6-only hosts are supported end to end, including the printed and opened URL.
The [native HTTPS listener decision](2026-09-23-native-https-web-listener.md) supersedes this note's HTTP-only transport assumption. The non-loopback warning applies only without TLS; bind-address validation and authority admission remain unchanged.
## Testing
`packages/host/webserver/tests/webserver.spec.ts` classifies wildcard and loopback spellings and rejects wildcard config; `packages/bundle/web-app/tests/startup.spec.ts` refuses wildcard `--host` before consumers activate; `packages/client/connection/tests/api-request-trust.host.spec.ts` and `node-half.host.spec.ts` admit the bind literal on any port without admitting other authorities; `packages/bundle/web-app/tests/web-app.spec.ts` covers the bracketed IPv6 URL and the plain-HTTP warning; `apps/cli/tests/profiles/web/tests/public-url.expected.e2e.ts` boots the built profile on a non-loopback and a zoned loopback address; `web-failure-matrix.expected.e2e.ts` and `packages/boot/app-boot/tests/app-boot.spec.ts` report the required `web-runtime` entry.
@@ -28,6 +28,8 @@ Web 监听器只接受 `127.0.0.1` 或 `0.0.0.0`。通配绑定会暴露机器
操作者只暴露 `--host` 点名的接口,并无需额外配置即可通过该字面量访问监听器;其他每个远程 authority 都是一个 `--trusted-host` 决定。曾绑定 `0.0.0.0` 的组合在加载时失败,并给出点名被拒绝拼写的消息。纯 IPv6 主机得到端到端支持,包括打印与打开的 URL。
[原生 HTTPS 监听器决策](2026-09-23-native-https-web-listener.zh.md)取代本说明仅支持 HTTP 传输的前提。非回环警告只适用于未启用 TLS 的监听器;绑定地址校验与 authority 接纳保持不变。
## 测试
`packages/host/webserver/tests/webserver.spec.ts` 分类通配与回环拼写并拒绝通配配置;`packages/bundle/web-app/tests/startup.spec.ts` 在消费者激活前拒绝通配 `--host`;`packages/client/connection/tests/api-request-trust.host.spec.ts` 与 `node-half.host.spec.ts` 在任意端口上接纳绑定字面量而不接纳其他 authority;`packages/bundle/web-app/tests/web-app.spec.ts` 覆盖带方括号的 IPv6 URL 与明文 HTTP 警告;`apps/cli/tests/profiles/web/tests/public-url.expected.e2e.ts` 在非回环地址与带 zone 的回环地址上启动已构建的 profile;`web-failure-matrix.expected.e2e.ts` 与 `packages/boot/app-boot/tests/app-boot.spec.ts` 报告必需的 `web-runtime` 条目。
@@ -0,0 +1,22 @@
# Bilingual-pair consistency record for 2026-09-23-native-https-web-listener.md (docs/i18n/README.md): per heading
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-23-native-https-web-listener.md
/agent-note-native-https-on-the-web-listener:
en: bdb6e7794af113f0
zh: b9f325b0cbe8d34d
/agent-note-native-https-on-the-web-listener/problem:
en: 685a00e6f84f642d
zh: 404e463f56ec5a1f
/agent-note-native-https-on-the-web-listener/decision:
en: 7f9ea8a4f2577d2f
zh: e9b2a1fc6944428e
/agent-note-native-https-on-the-web-listener/alternatives-considered:
en: 44c051936dd2529e
zh: 7588669ec4c302ef
/agent-note-native-https-on-the-web-listener/consequences:
en: 6a8be07b24f957ba
zh: fbf48476e092dfc8
/agent-note-native-https-on-the-web-listener/testing:
en: f92f7ec224dba94d
zh: 0f0e0304c834f0af
@@ -0,0 +1,41 @@
# Agent Note: Native HTTPS on the Web listener
Status: implemented
English | [中文](2026-09-23-native-https-web-listener.zh.md)
## Problem
A directly reached Web listener needs to encrypt launch tokens and session cookies without requiring a separate reverse proxy. Advertising an HTTPS URL does not encrypt the listener or establish certificate trust.
## Decision
WebServer owns optional TLS through `tls: { certFile, keyFile }`, exposed by `dsh web` as paired `--tls-cert` and `--tls-key` flags. Certificate and unencrypted private-key PEM files are loaded before binding; unreadable, empty, invalid, or mismatched material fails activation without an HTTP fallback. HTTP remains the default. Both transports share routing, upgrades, and disposal; disposal also owns incomplete TLS handshakes.
`webServer.protocol` is the source for the bind-address URL, the non-loopback plain-HTTP warning, and Connection's `Secure` cookie attribute. Neither forwarded headers nor `publicUrl` control encryption or that attribute. Explicit Host-port grants normalize under the listener's scheme, while Origin comparison uses the Origin's HTTP(S) scheme so HTTPS proxies can retain HTTP upstreams. TLS grants no additional Host authority and does not replace authentication.
The API authority check does not equate a proxy's upstream transport with the browser page's scheme. An HTTPS listener can accept a matching HTTP Origin, but `sec-fetch-site: cross-site` still rejects the request and authentication remains required. Schemeful `SameSite=Strict` cookies prevent cross-scheme browser credential delivery; this is not support for serving a public browser frontend over HTTP with a TLS upstream.
HTTP cookie audiences retain their existing spelling so enabling TLS support does not invalidate unrelated HTTP sessions. HTTPS cookie names and signed audiences include the `https:` scheme, preventing replay of an HTTP credential on any HTTPS port even if the cookie is renamed. Default ports follow URL normalization without a separate port-443 spelling.
Certificate issuance, trust, renewal, and replacement belong to the operator. Files are read once per activation; replacement requires a listener reload or process restart. The certificate must cover the hostname or IP the TLS client dials. The default port remains 3080; `--port` selects another port, subject to operating-system permission.
## Alternatives considered
**Require a TLS-terminating proxy.** This remains supported, but requires another service for a directly reached listener.
**Derive transport or cookie security from `publicUrl` or forwarding headers.** An advertisement does not describe the receiving socket, and request headers cannot establish trusted proxy identity. Native listener state keeps this decision independent of proxy policy.
**Issue, renew, or generate certificates.** This would add certificate lifecycle and trust-distribution responsibilities. An automatically generated certificate would not establish browser trust.
**Fall back to HTTP on certificate errors.** This would expose credentials despite an explicit request for encryption; activation must fail instead.
## Consequences
Direct HTTPS needs no TLS-terminating proxy. An HTTPS proxy in front of an HTTP listener still owns external cookie rewriting. An HTTPS listener emits `Secure` cookies, so public browser-facing deployments must retain HTTPS rather than depend on local-browser exceptions for HTTP. Deployment procedures belong to the [public deployments guide](../../../../docs/user/guide/public-deployments.md).
This partially supersedes the HTTP-only assumptions in [browser launch-token authentication](2026-08-24-browser-token-authentication.md) and [concrete bind addresses](2026-09-19-concrete-web-bind-address.md). Those notes remain active for cookie payloads and lifetimes, and bind admission respectively.
## Testing
The WebServer suite covers verified HTTPS, invalid material, and disposal. Connection covers listener-owned cookie attributes, cross-protocol credential replay, and exact-port grants; Web startup rejects unpaired flags. The built CLI's `public-url.expected.e2e.ts` authenticates over verified TLS with a `Secure` cookie while retaining HTTP and proxy cases; its failure matrix verifies unreadable TLS material exits without opening a listener. Desktop's startup suite covers matching WS/WSS credentials and refusal on a different transport.
@@ -0,0 +1,41 @@
# Agent Note: Web 监听器原生 HTTPS
Status: implemented
[English](2026-09-23-native-https-web-listener.md) | 中文
## Problem
浏览器直接访问的 Web 监听器需要在不依赖独立反向代理的情况下加密启动 token 与会话 cookie。公告 HTTPS URL 不会加密监听器,也不会建立证书信任。
## Decision
WebServer 通过 `tls: { certFile, keyFile }` 拥有可选 TLS,由 `dsh web` 的成对 `--tls-cert` 与 `--tls-key` flag 暴露。证书与不带口令的私钥 PEM 文件在绑定前加载;材料不可读、为空、无效或不匹配时激活失败,绝不回退到 HTTP。HTTP 仍为默认值。两种传输共用路由、升级与释放;释放也负责未完成的 TLS 握手。
`webServer.protocol` 决定绑定地址 URL、非 loopback 明文 HTTP 警告及 Connection 的 `Secure` cookie 属性。转发 header 与 `publicUrl` 都不控制加密或该属性。显式 Host 端口授权按监听器的 scheme 归一化,Origin 比较则按 Origin 的 HTTP(S) scheme 进行,以允许 HTTPS 代理保留 HTTP 上游。TLS 不授予额外的 Host authority,也不替代认证。
API authority 校验不会把代理的上游传输等同于浏览器页面的 scheme。HTTPS 监听器可接受 authority 匹配的 HTTP Origin,但 `sec-fetch-site: cross-site` 仍会拒绝请求,且请求仍须认证。区分 scheme 的 `SameSite=Strict` cookie 阻止浏览器跨 scheme 携带凭据;这不表示支持以 HTTP 向公开浏览器前端提供页面、同时使用 TLS 上游。
HTTP cookie audience 保留既有写法,避免启用 TLS 支持时使无关的 HTTP 会话失效。HTTPS cookie 名称与签名 audience 包含 `https:` scheme,因此即使修改 cookie 名称,HTTP 凭据也不能在任何 HTTPS 端口上重放。默认端口遵循 URL 归一化,不为 443 端口另设写法。
证书签发、信任、续期与更换归操作者负责。每次激活只读取一次文件;更换后需重新加载监听器或重启进程。证书必须覆盖 TLS 客户端拨号的 hostname 或 IP。默认端口仍为 3080;`--port` 可选择其他端口,但受操作系统权限约束。
## Alternatives considered
**要求终止 TLS 的代理。** 仍然支持,但会为直接访问的监听器增加一个服务。
**根据 `publicUrl` 或转发 header 推导传输或 cookie 安全属性。** 公告不描述接收请求的 socket,请求 header 也不能建立可信代理身份。原生监听器状态使这一决策独立于代理策略。
**签发、续期或生成证书。** 这会增加证书生命周期与信任分发职责。自动生成证书本身不能建立浏览器信任。
**证书错误时回退到 HTTP。** 这会在操作者明确要求加密时暴露凭据;激活必须失败。
## Consequences
直接 HTTPS 不需要终止 TLS 的代理。HTTP 监听器前的 HTTPS 代理仍负责外部 cookie 重写。HTTPS 监听器签发 `Secure` cookie,因此公开部署的浏览器侧必须保持 HTTPS,而不应依赖本地浏览器对 HTTP 的例外。部署步骤归[公开部署指南](../../../../docs/user/guide/public-deployments.zh.md)所有。
本决策部分取代[浏览器启动 token 认证](2026-08-24-browser-token-authentication.zh.md)与[具体绑定地址](2026-09-19-concrete-web-bind-address.zh.md)中仅支持 HTTP 的前提。这些说明分别继续定义 cookie 载荷与有效期及绑定准入,保持活跃。
## Testing
WebServer 套件覆盖校验证书的 HTTPS、无效材料与释放。Connection 覆盖由监听器决定的 cookie 属性、跨协议凭据重放和精确端口授权;Web 启动拒绝未配对的 flag。已构建 CLI 的 `public-url.expected.e2e.ts` 使用 `Secure` cookie 经校验证书的 TLS 完成认证,并保留 HTTP 与代理用例;失败矩阵验证 TLS 材料不可读时进程退出且不打开监听器。Desktop 启动套件覆盖匹配的 WS/WSS 凭据及不同传输下的拒绝。
+1
View File
@@ -247,6 +247,7 @@ External packages **directly declared** for development, tests, types, or toolin
| [`playwright`](https://github.com/microsoft/playwright) | Apache-2.0 |
| [`pnpm`](https://github.com/pnpm/pnpm) | MIT |
| [`publint`](https://github.com/publint/publint) | MIT |
| [`selfsigned`](https://github.com/jfromaniello/selfsigned) | MIT |
| [`smol-toml`](https://github.com/squirrelchat/smol-toml) | BSD-3-Clause |
| [`spdx-expression-parse`](https://github.com/jslicense/spdx-expression-parse.js) | MIT |
| [`tar`](https://github.com/isaacs/node-tar) | BlueOak-1.0.0 |
+4 -4
View File
@@ -9,8 +9,8 @@
en: e3a159750f5f9ed3
zh: e02d9004fcc239f9
/dsh-cli-behavior-reference/profile-boot/app-arguments:
en: be06f11a3b585d0f
zh: 28c7ee6e0d86dc3b
en: 3d2f4cf6a0afc3cd
zh: 9fe7e8f6a4acb596
/dsh-cli-behavior-reference/profile-boot/config-schema-dump:
en: a356f8a68f3b3685
zh: c718713b5d709295
@@ -21,8 +21,8 @@
en: 8d2cfff9bab7327f
zh: 21515ab6e6798504
/dsh-cli-behavior-reference/web-profile:
en: 8a805800d806e0e9
zh: ba88c38a0dd4f51b
en: ee6c00e55a2f0a10
zh: 404942e397e213ab
/dsh-cli-behavior-reference/shared-deployment-behavior:
en: 6a7abc9339e8f37f
zh: 133adb834c8e195d
+6 -3
View File
@@ -35,7 +35,7 @@ The shipped apps own these command lines:
| Profile | Arguments |
|---|---|
| `web` | `--host`, `--port`, `--public-url`, repeatable `--trusted-host`, `--no-open` |
| `web` | `--host`, `--port`, `--tls-cert`, `--tls-key`, `--public-url`, repeatable `--trusted-host`, `--no-open` |
| `headless` | the task text, as the positional argument |
| `sdk` | no options; stdio carries the JSON-RPC protocol |
| `sdk-minimal` | no options; stdio carries the same JSON-RPC protocol |
@@ -106,20 +106,23 @@ Git-hosted plugins that ship sources build during install through their `prepare
## Web profile
`dsh web` uses the profile shorthand. Launcher flags are parsed first; the remaining flags belong to the web app, whose ordinary bundle provider parses them. `--host` and `--port` override the composed listener values. The host must name one concrete local address, not a wildcard (`--host 0.0.0.0` exits with a usage error); `hostname -i` may list several, so pass one. Repeatable `--trusted-host` values are collected in `ctx.webStartup.trustedHosts`; a deployment expression may add its own authorities. `--no-open` disables the default-browser handoff for this invocation. The client-plugin HMR receiver is always mounted and stays idle until `pnpm run dev:web` rebuilds client bundles; that command builds once, starts this same launcher, and keeps client bundles rebuilt, or with `--no-serve` runs only the watchers beside a `dsh web` started elsewhere.
`dsh web` uses the profile shorthand. Launcher flags are parsed first; the remaining flags belong to the web app, whose ordinary bundle provider parses them. `--host` and `--port` override the composed listener values, and `--tls-cert`/`--tls-key` set the listener's `tls` pair. The host must name one concrete local address, not a wildcard (`--host 0.0.0.0` exits with a usage error); `hostname -i` may list several, so pass one. Repeatable `--trusted-host` values are collected in `ctx.webStartup.trustedHosts`; a deployment expression may add its own authorities. `--no-open` disables the default-browser handoff for this invocation. The client-plugin HMR receiver is always mounted and stays idle until `pnpm run dev:web` rebuilds client bundles; that command builds once, starts this same launcher, and keeps client bundles rebuilt, or with `--no-serve` runs only the watchers beside a `dsh web` started elsewhere.
`--public-url <url>` advertises one HTTP(S) application root — with an optional forwarding prefix — in place of the listener's bind-address URL (loopback for a loopback bind). It grants no trust, so the browser-visible authority still has to be named with `--trusted-host`; [Publish the Web UI behind a reverse proxy](../../../docs/user/guide/public-deployments.md) lists what the proxy in front must provide.
`--tls-cert <file>` and `--tls-key <file>` make the listener serve HTTPS in place of plain HTTP. They are one setting: both or neither, and each names a file resolved against the process working directory. The certificate file holds the full chain, including intermediates; the key file holds an unencrypted PEM private key. A missing, unreadable, empty, or invalid pair fails startup, which never falls back to HTTP, and a replacement pair takes effect only after a listener reload or a process restart because the carrier reads the files once and nothing obtains, renews, or watches certificates. HTTPS also grants no trust: the browser must trust the certificate and open an authority its names cover, Connection still authenticates each request and checks Host/Origin, and the session cookie is `Secure` because the receiving listener serves HTTPS. The default port stays 3080, and `--public-url` remains an independent advertisement.
```sh
dsh web
dsh web --host "$(hostname -i | awk '{print $1}')" --public-url https://app.example/ --trusted-host app.example
dsh web --tls-cert ./server-chain.pem --tls-key ./server-key.pem
dsh web --no-open
dsh web --patch ./extra.cordis.yml
dsh web --dump-config
dsh web --help
```
The production Web runner needs built package and frontend artifacts (`pnpm run build`). It prints the advertised `--public-url` when configured, otherwise the listener's bind-address URL (default `http://127.0.0.1:3080`). The Host/Origin fence accepts the bind IP directly; a proxy or DNS authority still needs `--trusted-host`. The startup line and default-browser handoff wait for the complete Loader tree. A non-empty inherited `SSH_CONNECTION` or `SSH_TTY` suppresses browser opening, not the startup line. Immediately before a browser handoff it prints `dsh web: opening the default browser; pass --no-open to disable`; if the operating-system handoff fails, stderr names the reason and points to the startup URL while the server keeps running.
The production Web runner needs built package and frontend artifacts (`pnpm run build`). It prints the advertised `--public-url` when configured, otherwise the listener's bind-address URL with the listener's scheme (default `http://127.0.0.1:3080`, or `https://` when TLS is configured). The Host/Origin fence accepts the bind IP directly; a proxy or DNS authority still needs `--trusted-host`. The startup line and default-browser handoff wait for the complete Loader tree. A non-empty inherited `SSH_CONNECTION` or `SSH_TTY` suppresses browser opening, not the startup line. Immediately before a browser handoff it prints `dsh web: opening the default browser; pass --no-open to disable`; if the operating-system handoff fails, stderr names the reason and points to the startup URL while the server keeps running.
Process shutdown gives the plugin tree up to five seconds to dispose. The first `SIGINT`/`SIGTERM` starts that graceful drain — `SIGTERM` is a supervisor's ordinary stop request and exits 0 on every surface, `SIGINT` reports 130; a second signal forces immediate exit. If one-shot normal completion is already stuck in disposal, the first `Ctrl+C` is the escalation and exits immediately instead of being swallowed.
+6 -3
View File
@@ -37,7 +37,7 @@ dsh rescue
| Profile | 参数 |
|---|---|
| `web` | `--host`、`--port`、`--public-url`、可重复的 `--trusted-host`、`--no-open` |
| `web` | `--host`、`--port`、`--tls-cert`、`--tls-key`、`--public-url`、可重复的 `--trusted-host`、`--no-open` |
| `headless` | 任务文本,作为位置参数 |
| `sdk` | 无选项;stdio 携带 JSON-RPC 协议 |
| `sdk-minimal` | 无选项;stdio 携带相同的 JSON-RPC 协议 |
@@ -108,20 +108,23 @@ dsh --profile tui
## Web Profile
`dsh web` 使用 profile 简写。启动器先解析自身的 flag,其余 flag 属于 web 应用,由组合包中的普通提供方解析。`--host` 与 `--port` 覆盖组合的监听器取值。host 必须指名一个具体的本机地址,不能是通配地址(`--host 0.0.0.0` 会以用法错误退出);`hostname -i` 可能列出多个,请只传一个。可重复的 `--trusted-host` 值会收集到 `ctx.webStartup.trustedHosts`;部署表达式可以添加自己的 authority。`--no-open` 只对本次调用关闭默认浏览器交接。客户端插件 HMR(热模块替换)接收器始终挂载,在 `pnpm run dev:web` 重建客户端 bundle 之前保持空闲;该命令先构建一次,再启动这同一个启动器并持续重建客户端 bundle,加 `--no-serve` 则只运行 watcher、配合别处启动的 `dsh web`。
`dsh web` 使用 profile 简写。启动器先解析自身的 flag,其余 flag 属于 web 应用,由组合包中的普通提供方解析。`--host` 与 `--port` 覆盖组合的监听器取值,`--tls-cert`/`--tls-key` 则设置监听器的 `tls` 证书对。host 必须指名一个具体的本机地址,不能是通配地址(`--host 0.0.0.0` 会以用法错误退出);`hostname -i` 可能列出多个,请只传一个。可重复的 `--trusted-host` 值会收集到 `ctx.webStartup.trustedHosts`;部署表达式可以添加自己的 authority。`--no-open` 只对本次调用关闭默认浏览器交接。客户端插件 HMR(热模块替换)接收器始终挂载,在 `pnpm run dev:web` 重建客户端 bundle 之前保持空闲;该命令先构建一次,再启动这同一个启动器并持续重建客户端 bundle,加 `--no-serve` 则只运行 watcher、配合别处启动的 `dsh web`。
`--public-url <url>` 公告唯一的 HTTP(S) 应用根——可带转发前缀——替代监听器的绑定地址 URL(回环绑定时为回环地址)。它不授予信任,因此浏览器可见的 authority 仍需用 `--trusted-host` 点名;[在反向代理之后发布 Web UI](../../../docs/user/guide/public-deployments.zh.md)列出了前置代理必须提供的内容。
`--tls-cert <file>` 与 `--tls-key <file>` 让监听器以 HTTPS 取代明文 HTTP 提供服务。它们属于同一个设置:要么都给,要么都不给;两者各是相对进程工作目录解析的路径。证书文件包含完整证书链(含中间证书),密钥文件为不带口令的 PEM 私钥。文件缺失、不可读、为空或无效时启动失败,且绝不回退到 HTTP;由于载体只读取一次文件,且既不获取、不续期也不监视证书,替换后的证书对只有在重新加载监听器或重启进程后才生效。HTTPS 同样不授予信任:浏览器必须信任该证书并访问其名称覆盖的 authority,Connection 仍认证每个请求并校验 Host/Origin,而由于接收请求的监听器提供 HTTPS,会话 cookie 带 `Secure`。默认端口仍为 3080,`--public-url` 仍是相互独立的公告。
```sh
dsh web
dsh web --host "$(hostname -i | awk '{print $1}')" --public-url https://app.example/ --trusted-host app.example
dsh web --tls-cert ./server-chain.pem --tls-key ./server-key.pem
dsh web --no-open
dsh web --patch ./extra.cordis.yml
dsh web --dump-config
dsh web --help
```
生产 Web 运行器需要已构建的包和前端产物(`pnpm run build`)。配置了 `--public-url` 时打印公告地址,否则打印监听器的绑定地址 URL(默认为 `http://127.0.0.1:3080`)。Host/Origin 栅栏直接接受绑定 IP;代理或 DNS authority 仍需 `--trusted-host`。启动日志与默认浏览器交接都会等待完整 Loader 配置树结算。继承的 `SSH_CONNECTION` 或 `SSH_TTY` 非空时会抑制浏览器打开,但仍打印启动 URL。浏览器交接前会打印 `dsh web: opening the default browser; pass --no-open to disable`;若操作系统交接失败,stderr 会说明原因并指向启动 URL,服务器仍继续运行。
生产 Web 运行器需要已构建的包和前端产物(`pnpm run build`)。配置了 `--public-url` 时打印公告地址,否则打印采用监听器协议的绑定地址 URL(默认为 `http://127.0.0.1:3080`,配置 TLS 时为 `https://`)。Host/Origin 栅栏直接接受绑定 IP;代理或 DNS authority 仍需 `--trusted-host`。启动日志与默认浏览器交接都会等待完整 Loader 配置树结算。继承的 `SSH_CONNECTION` 或 `SSH_TTY` 非空时会抑制浏览器打开,但仍打印启动 URL。浏览器交接前会打印 `dsh web: opening the default browser; pass --no-open to disable`;若操作系统交接失败,stderr 会说明原因并指向启动 URL,服务器仍继续运行。
进程关闭时,插件树最多有 5 秒完成 dispose。首次收到 `SIGINT` 或 `SIGTERM` 时会开始优雅排空:`SIGTERM` 是监督进程发出的常规停止请求,在所有运行模式下都以 0 退出;`SIGINT` 则报告 130。第二次收到信号时会立即强制退出。如果一次性运行在正常结束时已经卡在 dispose 阶段,第一次按下 `Ctrl+C` 就会直接升级为强制退出,而不会被忽略。
@@ -2,6 +2,7 @@
import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { request as httpRequest, type IncomingMessage } from 'node:http'
import { request as httpsRequest } from 'node:https'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { createInterface } from 'node:readline'
@@ -12,6 +13,7 @@ import { describe, expect, it } from 'vitest'
import { probeNonLoopbackIpv4, probeZonedLoopback } from '../../../../../../scripts/test-non-loopback-address.ts'
import { startPrefixProxy } from '../../../../../../apps/web/tests/prefix-proxy.ts'
import { PROCESS_SHUTDOWN_TIMEOUT_MS } from '../../../../src/process-shutdown.ts'
import { materializeTlsFixture } from '../../../../../../packages/host/webserver/tests/tls-fixture.ts'
const repoRoot = fileURLToPath(new URL('../../../../../../', import.meta.url))
const dshBin = join(repoRoot, 'apps/cli/lib/bin.js')
@@ -168,17 +170,22 @@ function expectCleanExit(stopped: StoppedWeb): void {
}
/**
* One HTTP request that connects to an explicit transport address while
* presenting an explicit browser-facing authority, so a concrete-bind case can
* dial the bound address instead of loopback.
* One request to an explicit transport address with a browser-facing authority.
* HTTPS verifies the certificate and hostname against the supplied CA or system roots.
*/
async function request(
dial: string, port: number, path: string, host: string, method: 'GET' | 'POST', cookie?: string,
protocol: 'http:' | 'https:',
dial: string, port: number, path: string, host: string, method: 'GET' | 'POST', cookie?: string, ca?: string,
): Promise<Response> {
const { promise, resolve, reject } = Promise.withResolvers<IncomingMessage>()
const req = httpRequest({
const req = (protocol === 'http:' ? httpRequest : httpsRequest)({
host: dial, port, path, method,
headers: { host, 'content-type': 'application/json', ...cookie === undefined ? {} : { cookie } },
...ca === undefined ? {} : { ca },
headers: {
host, 'content-type': 'application/json',
...protocol === 'http:' ? {} : { origin: `https://${host}` },
...cookie === undefined ? {} : { cookie },
},
}, resolve)
req.once('error', reject)
req.setTimeout(10_000, () => { req.destroy(new Error('HTTP request timed out')) })
@@ -195,9 +202,10 @@ async function request(
/** Establish the browser session needed by the protected API, keeping the minted cookie's own attributes. */
async function exchange(
dial: string, port: number, path: string, host: string,
protocol: 'http:' | 'https:',
dial: string, port: number, path: string, host: string, ca?: string,
): Promise<{ cookie: string; setCookie: string }> {
const response = await request(dial, port, path, host, 'GET')
const response = await request(protocol, dial, port, path, host, 'GET', undefined, ca)
const setCookie = response.headers.get('set-cookie')
await response.arrayBuffer()
if (setCookie === null) throw new Error(`entry returned ${String(response.status)} without a browser cookie`)
@@ -205,6 +213,34 @@ async function exchange(
}
describe.skipIf(!builtArtifactsExist)('dsh Web profile bind address and advertised root', () => {
it('serves the default advertised URL over verified TLS with Secure browser authentication', async () => {
const root = mkdtempSync(join(tmpdir(), 'dsh-web-tls-'))
try {
const tls = await materializeTlsFixture(root)
await withWeb(['--tls-cert', tls.certFile, '--tls-key', tls.keyFile], async (web) => {
expect(web.startup.origin).toBe(`https://127.0.0.1:${String(web.port)}`)
await expect(request('https:', '127.0.0.1', web.port, '/', web.startup.host, 'GET'))
.rejects.toThrow(/self-signed certificate/i)
const { cookie, setCookie } = await exchange(
'https:', '127.0.0.1', web.port, `${web.startup.pathname}${web.startup.search}`, web.startup.host, tls.ca,
)
expect(setCookie).toContain('; Secure')
const index = await request('https:', '127.0.0.1', web.port, '/', web.startup.host, 'GET', cookie, tls.ca)
expect(index.status).toBe(200)
expect(await index.text()).toContain('<html')
const response = await request('https:', '127.0.0.1', web.port, '/api/session/create', web.startup.host, 'POST', cookie, tls.ca)
expect(response.status).toBe(200)
expect(await response.json()).toMatchObject({ result: { ok: true } })
const unauthenticated = await request('https:', '127.0.0.1', web.port, '/api/session/create', web.startup.host, 'POST', undefined, tls.ca)
expect(unauthenticated.status).toBe(401)
await unauthenticated.arrayBuffer()
expectCleanExit(await web.stop())
})
} finally {
rmSync(root, { recursive: true, force: true })
}
})
it('serves authenticated requests at the advertised mount and at loopback', async () => {
await withWeb(['--public-url', `http://gateway.example${MOUNT.slice(0, -1)}`, '--trusted-host', 'gateway.example'], async (web) => {
expect(web.startup.origin).toBe('http://gateway.example')
@@ -218,12 +254,12 @@ describe.skipIf(!builtArtifactsExist)('dsh Web profile bind address and advertis
{ dial: '127.0.0.1', port: web.port, prefix: '/', host: `127.0.0.1:${String(web.port)}` },
]
for (const entry of entries) {
const { cookie } = await exchange(entry.dial, entry.port, `${entry.prefix}${web.startup.search}`, entry.host)
const response = await request(entry.dial, entry.port, `${entry.prefix}api/session/create`, entry.host, 'POST', cookie)
const { cookie } = await exchange('http:', entry.dial, entry.port, `${entry.prefix}${web.startup.search}`, entry.host)
const response = await request('http:', entry.dial, entry.port, `${entry.prefix}api/session/create`, entry.host, 'POST', cookie)
expect(response.status).toBe(200)
expect(await response.json()).toMatchObject({ result: { ok: true } })
}
const foreign = await request('127.0.0.1', proxy.port, `${MOUNT}api/session/create`, 'evil.example', 'POST')
const foreign = await request('http:', '127.0.0.1', proxy.port, `${MOUNT}api/session/create`, 'evil.example', 'POST')
expect(foreign.status).toBe(403)
await foreign.arrayBuffer()
} finally {
@@ -241,21 +277,21 @@ describe.skipIf(!builtArtifactsExist)('dsh Web profile bind address and advertis
const token = web.startup.searchParams.get('token')
expect(token).not.toBeNull()
const authority = `${NON_LOOPBACK_IPV4!}:${String(web.port)}`
const { cookie, setCookie } = await exchange(NON_LOOPBACK_IPV4!, web.port, `/${web.startup.search}`, authority)
const { cookie, setCookie } = await exchange('http:', NON_LOOPBACK_IPV4!, web.port, `/${web.startup.search}`, authority)
// The carrier is plain HTTP, where a browser never returns a Secure cookie.
expect(setCookie).not.toContain('; Secure')
const authenticated = await request(NON_LOOPBACK_IPV4!, web.port, '/api/session/create', authority, 'POST', cookie)
const authenticated = await request('http:', NON_LOOPBACK_IPV4!, web.port, '/api/session/create', authority, 'POST', cookie)
expect(authenticated.status).toBe(200)
expect(await authenticated.json()).toMatchObject({ result: { ok: true } })
// A declared authority is admitted without being advertised; any other
// name for the same socket is a rebound Host.
const declared = `${trustedHost}:${String(web.port)}`
const declaredSession = await exchange(NON_LOOPBACK_IPV4!, web.port, `/${web.startup.search}`, declared)
const viaDeclared = await request(NON_LOOPBACK_IPV4!, web.port, '/api/session/create', declared, 'POST', declaredSession.cookie)
const declaredSession = await exchange('http:', NON_LOOPBACK_IPV4!, web.port, `/${web.startup.search}`, declared)
const viaDeclared = await request('http:', NON_LOOPBACK_IPV4!, web.port, '/api/session/create', declared, 'POST', declaredSession.cookie)
expect(viaDeclared.status).toBe(200)
await viaDeclared.arrayBuffer()
const rebound = await request(NON_LOOPBACK_IPV4!, web.port, '/api/session/create', `evil.example:${String(web.port)}`, 'POST', cookie)
const rebound = await request('http:', NON_LOOPBACK_IPV4!, web.port, '/api/session/create', `evil.example:${String(web.port)}`, 'POST', cookie)
expect(rebound.status).toBe(403)
await rebound.arrayBuffer()
@@ -273,8 +309,8 @@ describe.skipIf(!builtArtifactsExist)('dsh Web profile bind address and advertis
expect(web.startup.host).toBe('app.example')
const token = web.startup.searchParams.get('token')
expect(token).not.toBeNull()
const { cookie } = await exchange(NON_LOOPBACK_IPV4!, web.port, `/${web.startup.search}`, 'app.example')
const authenticated = await request(NON_LOOPBACK_IPV4!, web.port, '/api/session/create', 'app.example', 'POST', cookie)
const { cookie } = await exchange('http:', NON_LOOPBACK_IPV4!, web.port, `/${web.startup.search}`, 'app.example')
const authenticated = await request('http:', NON_LOOPBACK_IPV4!, web.port, '/api/session/create', 'app.example', 'POST', cookie)
expect(authenticated.status).toBe(200)
expect(await authenticated.json()).toMatchObject({ result: { ok: true } })
@@ -290,9 +326,9 @@ describe.skipIf(!builtArtifactsExist)('dsh Web profile bind address and advertis
])('prints an IPv4 root for mapped loopback %s and serves it', async (host) => {
await withWeb(['--host', host], async (web) => {
expect(web.startup.origin).toBe(`http://127.0.0.1:${String(web.port)}`)
const { cookie } = await exchange(web.startup.hostname, web.port, `/${web.startup.search}`, web.startup.host)
const { cookie } = await exchange('http:', web.startup.hostname, web.port, `/${web.startup.search}`, web.startup.host)
const authenticated = await request(
web.startup.hostname, web.port, '/api/session/create', web.startup.host, 'POST', cookie,
'http:', web.startup.hostname, web.port, '/api/session/create', web.startup.host, 'POST', cookie,
)
expect(authenticated.status).toBe(200)
expect(await authenticated.json()).toMatchObject({ result: { ok: true } })
@@ -307,8 +343,8 @@ describe.skipIf(!builtArtifactsExist)('dsh Web profile bind address and advertis
await withWeb(['--host', host], async (web) => {
expect(web.startup.origin).toBe(`http://[::1]:${String(web.port)}`)
const authority = `[::1]:${String(web.port)}`
const { cookie } = await exchange('::1', web.port, `/${web.startup.search}`, authority)
const authenticated = await request('::1', web.port, '/api/session/create', authority, 'POST', cookie)
const { cookie } = await exchange('http:', '::1', web.port, `/${web.startup.search}`, authority)
const authenticated = await request('http:', '::1', web.port, '/api/session/create', authority, 'POST', cookie)
expect(authenticated.status).toBe(200)
expect(await authenticated.json()).toMatchObject({ result: { ok: true } })
@@ -172,6 +172,32 @@ function exit(result: { timedOut: boolean; signal?: string | undefined; exitCode
}
describe.skipIf(!built)('Web process failure matrix', () => {
it('exits on unreadable TLS material without opening a listener', async () => {
const f = fixture()
const missingCert = join(f.root, 'missing-cert.pem')
const listenerProbe = join(f.root, 'listen-probe.mjs')
const listening = join(f.root, 'listening')
writeFileSync(listenerProbe, [
"import { Server } from 'node:net'",
"import { appendFileSync } from 'node:fs'",
'const listen = Server.prototype.listen',
'Server.prototype.listen = function (...args) {',
` this.once('listening', () => appendFileSync(${JSON.stringify(listening)}, 'listening\\n'))`,
' return Reflect.apply(listen, this, args)',
'}',
'',
].join('\n'))
const app = start(f, ['--tls-cert', missingCert, '--tls-key', missingCert], ['--import', pathToFileURL(listenerProbe).href])
try {
const result = await app.child
exit(result, 1)
expect(result.stderr).toContain(missingCert)
expect(existsSync(listening)).toBe(false)
} finally {
exit(await app.close(), 1)
}
})
it('rejects a non-loopback zone bind through the required Web runtime before readiness', async () => {
const f = fixture()
const host = 'fe80::1%dsh-test'
+1 -1
View File
@@ -100,7 +100,7 @@ async function main(): Promise<void> {
installPlatformSessionPublisher(ctx, (session) => {
if (process.connected) process.send?.({ type: 'platform-session', session })
})
const url = ctx.connection.authenticatedUrl(`http://127.0.0.1:${String(ctx.webServer.port)}`)
const url = ctx.connection.authenticatedUrl(`${ctx.webServer.protocol}//127.0.0.1:${String(ctx.webServer.port)}`)
if (process.connected) process.send?.({ type: 'ready', url, injections: ctx.webServer.collectIndexInjections() }, (error) => { if (error !== null) console.error(error) })
}
+2 -2
View File
@@ -3,8 +3,8 @@
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write apps/desktop/README.md
/deepseek-harness-desktop:
en: ccc7ba3d64aa38ef
zh: 846aea7879b0fe4e
en: 001b7d11c237078b
zh: eda6b580dc224c7f
/deepseek-harness-desktop/terminal-command:
en: bef92953c237363f
zh: d21d6effd21b8c00
+1 -1
View File
@@ -6,7 +6,7 @@ Desktop analytics follows the [product collection policy](../../packages/client/
The desktop application is an Electron shell around the complete dsh Web application. An Electron RunAsNode child starts the shared profile runner, and Electron immediately loads the packaged Web entry at `dsh-app://app/`. Its shared loading page waits for Host boot injections, then starts the client without navigating to another document. Electron forwards application HTTP requests to the authenticated Web Host, dropping connection-level response headers (`transfer-encoding`, `connection`, `keep-alive`) that describe the Node fetch rather than the resource, and marking plugin bundle responses `no-store` because their per-launch revisions would only accumulate in Chromium's disk cache; WebSocket streams connect to that Host with credentials attached only for the owned application window. Node IPC carries boot injections, readiness, and shutdown. Desktop listens on an OS-assigned port by default, so it cannot collide with Web’s `3080` or a reserved port; a `webserver.config.port` patch can override it.
Desktop binds `127.0.0.1`, the address Electron dials for the ready URL and the WebSocket credential filter.
Desktop binds `127.0.0.1`, the address Electron dials for the ready URL and the WebSocket credential filter. WebSocket streams use `ws:` or `wss:` according to the Host listener; credentials require matching authority and scheme.
The first application-menu command, **About DeepSeek Harness**, opens Electron's native About panel with the application icon, product name, and installed release version. The menu follows the Desktop shell locale. On macOS, Hide, Hide Others, Show All, and Quit use localized labels; Hide and Quit include the DeepSeek Harness product name. These commands retain their native actions and shortcuts. macOS reads the icon from its application bundle, so an unpackaged development launch displays Electron's icon; Windows receives the packaged PNG.
+1 -1
View File
@@ -6,7 +6,7 @@
桌面应用是完整 dsh Web 应用外的一层 Electron 壳。Electron RunAsNode 子进程启动共享 profile runner,Electron 立即从 `dsh-app://app/` 加载打包内的 Web 入口。共享加载页等待 Host 启动注入,然后在同一文档中启动客户端。Electron 将应用 HTTP 请求转发给已认证的 Web Host,转发时丢弃描述 Node fetch 连接而非资源本身的响应头(`transfer-encoding`、`connection`、`keep-alive`),并把插件 bundle 响应标记为 `no-store`,因为其每次启动都变化的 revision 只会在 Chromium 磁盘缓存中累积;WebSocket 流连接到该 Host,仅为归属的应用窗口附加凭据。Node IPC 承载启动注入、就绪与关闭。Desktop 默认监听系统分配的端口,因此不会与 Web 的 `3080` 或系统保留端口冲突;可通过 `webserver.config.port` patch 覆盖。
Desktop 绑定 `127.0.0.1`,即 Electron 为就绪 URL 与 WebSocket 凭据过滤器拨号的地址。
Desktop 绑定 `127.0.0.1`,即 Electron 为就绪 URL 与 WebSocket 凭据过滤器拨号的地址。WebSocket 流按 Host 监听器使用 `ws:` 或 `wss:`;附加凭据要求 authority 与 scheme 均匹配。
应用菜单第一项“**关于 DeepSeek Harness**”打开 Electron 原生关于面板,展示应用图标、产品名称和当前安装的发布版本。菜单文案跟随桌面壳的语言。macOS 的隐藏、隐藏其他、显示全部和退出条目使用本地化文案,隐藏和退出条目包含 DeepSeek Harness 产品名称。这些条目保留原生动作和快捷键。macOS 从应用包读取图标,因此未打包的开发启动会显示 Electron 图标;Windows 使用随包分发的 PNG。
+2 -2
View File
@@ -709,14 +709,14 @@ async function main(): Promise<void> {
return browserGuests.release(event.sender, lease)
})
session.defaultSession.webRequest.onBeforeSendHeaders({ urls: ['ws://127.0.0.1/*'] }, (details, callback) => {
session.defaultSession.webRequest.onBeforeSendHeaders({ urls: ['ws://127.0.0.1/*', 'wss://127.0.0.1/*'] }, (details, callback) => {
if (hostUrl === undefined || hostCookie === undefined || details.webContentsId !== mainWindow?.webContents.id) {
callback({})
return
}
const target = new URL(hostUrl)
const requested = new URL(details.url)
if (requested.host !== target.host) { callback({}); return }
if (requested.host !== target.host || requested.protocol !== (target.protocol === 'https:' ? 'wss:' : 'ws:')) { callback({}); return }
const headers = Object.fromEntries(Object.entries(details.requestHeaders).map(([name, value]) => [name.toLowerCase(), value]))
if (headers.origin !== 'dsh-app://app') { callback({ cancel: true }); return }
callback({ requestHeaders: { ...headers, origin: target.origin, cookie: hostCookie, 'sec-fetch-site': 'same-origin' } })
+8 -4
View File
@@ -1019,11 +1019,12 @@ describe('desktop main startup', () => {
} finally { harness.app.name = originalName }
})
it('attaches Host socket credentials only to the owned application origin and window', async () => {
it.each(['http:', 'https:'] as const)('attaches %s Host socket credentials only to the owned application origin and window', async (protocol) => {
await import('../src/main.ts')
await harness.preparing.promise
harness.prepared.resolve()
await harness.hostStarted.promise
harness.hosts[0]!.url = `${protocol}//127.0.0.1:3080/?token=test`
harness.hosts[0]!.ready.resolve()
await Promise.resolve(invoke(DESKTOP_IPC.boot))
const handler = harness.socketHeaders.mock.calls[0]![1] as (
@@ -1031,16 +1032,19 @@ describe('desktop main startup', () => {
callback: (result: unknown) => void,
) => void
const callback = vi.fn()
const details = { url: 'ws://127.0.0.1:3080/api/remote.mux', webContentsId: 42, requestHeaders: { Origin: 'dsh-app://app' } }
const socketProtocol = protocol === 'https:' ? 'wss:' : 'ws:'
const details = { url: `${socketProtocol}//127.0.0.1:3080/api/remote.mux`, webContentsId: 42, requestHeaders: { Origin: 'dsh-app://app' } }
handler(details, callback)
expect(callback).toHaveBeenLastCalledWith({ requestHeaders: {
origin: 'http://127.0.0.1:3080', cookie: 'test-cookie', 'sec-fetch-site': 'same-origin',
origin: `${protocol}//127.0.0.1:3080`, cookie: 'test-cookie', 'sec-fetch-site': 'same-origin',
} })
handler({ ...details, requestHeaders: { Origin: 'https://other.example' } }, callback)
expect(callback).toHaveBeenLastCalledWith({ cancel: true })
handler({ ...details, webContentsId: 43 }, callback)
expect(callback).toHaveBeenLastCalledWith({})
handler({ ...details, url: 'ws://127.0.0.1:9999/api/remote.mux' }, callback)
handler({ ...details, url: `${socketProtocol}//127.0.0.1:9999/api/remote.mux` }, callback)
expect(callback).toHaveBeenLastCalledWith({})
handler({ ...details, url: `${socketProtocol === 'wss:' ? 'ws:' : 'wss:'}//127.0.0.1:3080/api/remote.mux` }, callback)
expect(callback).toHaveBeenLastCalledWith({})
})
+6
View File
@@ -184,6 +184,10 @@ Options:
grants no trust
--trusted-host <authority...> extra authority the /api browser-trust fence
accepts (host or host:port; repeatable)
--tls-cert <file> PEM server certificate chain; requires
--tls-key
--tls-key <file> unencrypted PEM private key; requires
--tls-cert
-h, --help show this help
Examples:
@@ -193,5 +197,7 @@ Examples:
dsh --profile web --host 10.0.0.7 bind one local interface address
dsh --profile web --public-url https://app.example/ui/ --trusted-host app.example
advertise a prefix-stripping HTTPS proxy entry and admit its authority
dsh --profile web --tls-cert ./server-chain.pem --tls-key ./server-key.pem
serve HTTPS with an existing certificate and key
```
+6
View File
@@ -186,6 +186,10 @@ Options:
grants no trust
--trusted-host <authority...> extra authority the /api browser-trust fence
accepts (host or host:port; repeatable)
--tls-cert <file> PEM server certificate chain; requires
--tls-key
--tls-key <file> unencrypted PEM private key; requires
--tls-cert
-h, --help show this help
Examples:
@@ -195,5 +199,7 @@ Examples:
dsh --profile web --host 10.0.0.7 bind one local interface address
dsh --profile web --public-url https://app.example/ui/ --trusted-host app.example
advertise a prefix-stripping HTTPS proxy entry and admit its authority
dsh --profile web --tls-cert ./server-chain.pem --tls-key ./server-key.pem
serve HTTPS with an existing certificate and key
```
+26 -6
View File
@@ -1624,24 +1624,32 @@ export interface Config {
## `@deepseek-ai/dsh-host-webserver`
- `source`: [`packages/host/webserver/src/index.ts:60`](../packages/host/webserver/src/index.ts)
- `source`: [`packages/host/webserver/src/index.ts:76`](../packages/host/webserver/src/index.ts)
```ts config-catalog
/** Web server listen and response-compression config. */
/** Web server listen, TLS, and response-compression config. */
export interface Config {
/**
* Listen address: a concrete IPv4 or IPv6 literal of one local interface,
* for example the container's own Pod address from `hostname -i`. A loopback
* literal (any address in 127/8, `::1`, or a mapped form of either) keeps the
* server on this machine; any other literal serves the network that address
* belongs to over plain HTTP, because the carrier adds no TLS. The
* unspecified address — IPv4 any, IPv6 any, and the IPv4-mapped forms of
* IPv4 any — is rejected at load: it would expose the port on every interface
* at once.
* belongs to, over plain HTTP unless `tls` is set. The unspecified address —
* IPv4 any, IPv6 any, and the IPv4-mapped forms of IPv4 any — is rejected at
* load: it would expose the port on every interface at once.
*/
host: string
/** Listen port; zero requests an OS-assigned port. */
port: number
/**
* Serve HTTPS with this certificate and key instead of plain HTTP. Both files
* are read once, before the listener binds: an unreadable or empty file,
* invalid PEM, or a key that does not match the certificate rejects
* initialization rather than falling back to HTTP. The material is never
* re-read, so replacing a certificate takes a reload. Omitted or null listens
* over plain HTTP.
*/
tls?: TlsConfig
/** Response compression for socket-backed HTTP requests. @default 'none' */
compression?: 'none' | 'gzip'
/** Gzip DEFLATE level from 0 through 9. @default 1 */
@@ -1649,6 +1657,18 @@ export interface Config {
/** Minimum known response length eligible for gzip; unknown-length streams are eligible. @default 1024 */
compressionThresholdBytes?: number
}
/**
* TLS material for the HTTPS listener: one certificate chain file and the
* private key file it pairs with. Both hold PEM text and both resolve against
* the process working directory.
*/
export interface TlsConfig {
/** Certificate chain file, leaf certificate first, PEM, no passphrase. */
certFile: string
/** Private key file for the chain's leaf certificate; unencrypted PEM. */
keyFile: string
}
```
<!-- END GENERATED config-catalog:@deepseek-ai/dsh-host-webserver -->
+26 -6
View File
@@ -1626,24 +1626,32 @@ export interface Config {
## `@deepseek-ai/dsh-host-webserver`
- `source`: [`packages/host/webserver/src/index.ts:60`](../packages/host/webserver/src/index.ts)
- `source`: [`packages/host/webserver/src/index.ts:76`](../packages/host/webserver/src/index.ts)
```ts config-catalog
/** Web server listen and response-compression config. */
/** Web server listen, TLS, and response-compression config. */
export interface Config {
/**
* Listen address: a concrete IPv4 or IPv6 literal of one local interface,
* for example the container's own Pod address from `hostname -i`. A loopback
* literal (any address in 127/8, `::1`, or a mapped form of either) keeps the
* server on this machine; any other literal serves the network that address
* belongs to over plain HTTP, because the carrier adds no TLS. The
* unspecified address — IPv4 any, IPv6 any, and the IPv4-mapped forms of
* IPv4 any — is rejected at load: it would expose the port on every interface
* at once.
* belongs to, over plain HTTP unless `tls` is set. The unspecified address —
* IPv4 any, IPv6 any, and the IPv4-mapped forms of IPv4 any — is rejected at
* load: it would expose the port on every interface at once.
*/
host: string
/** Listen port; zero requests an OS-assigned port. */
port: number
/**
* Serve HTTPS with this certificate and key instead of plain HTTP. Both files
* are read once, before the listener binds: an unreadable or empty file,
* invalid PEM, or a key that does not match the certificate rejects
* initialization rather than falling back to HTTP. The material is never
* re-read, so replacing a certificate takes a reload. Omitted or null listens
* over plain HTTP.
*/
tls?: TlsConfig
/** Response compression for socket-backed HTTP requests. @default 'none' */
compression?: 'none' | 'gzip'
/** Gzip DEFLATE level from 0 through 9. @default 1 */
@@ -1651,6 +1659,18 @@ export interface Config {
/** Minimum known response length eligible for gzip; unknown-length streams are eligible. @default 1024 */
compressionThresholdBytes?: number
}
/**
* TLS material for the HTTPS listener: one certificate chain file and the
* private key file it pairs with. Both hold PEM text and both resolve against
* the process working directory.
*/
export interface TlsConfig {
/** Certificate chain file, leaf certificate first, PEM, no passphrase. */
certFile: string
/** Private key file for the chain's leaf certificate; unencrypted PEM. */
keyFile: string
}
```
<!-- END GENERATED config-catalog:@deepseek-ai/dsh-host-webserver -->
+1 -1
View File
@@ -80,7 +80,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `tools/ptc-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:191`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) |
| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:199`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, [`subagent`](../packages/subagent/subagent), [`tool-present`](../packages/deliverables/tool-present) |
| `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:157`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes` |
| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:35`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules`, `shortcuts`, `ui-sidebar-documentpreview`, `ui-theme` |
| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:39`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules`, `shortcuts`, `ui-sidebar-documentpreview`, `ui-theme` |
| `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) |
| `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:68`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) |
| `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:89`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - |
+1 -1
View File
@@ -82,7 +82,7 @@
| `tools/ptc-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:191`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) |
| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:199`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, [`subagent`](../packages/subagent/subagent), [`tool-present`](../packages/deliverables/tool-present) |
| `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:157`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes` |
| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:35`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules`, `shortcuts`, `ui-sidebar-documentpreview`, `ui-theme` |
| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:39`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules`, `shortcuts`, `ui-sidebar-documentpreview`, `ui-theme` |
| `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) |
| `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:68`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) |
| `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:89`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - |
+8 -8
View File
@@ -3,14 +3,14 @@
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/subsystems/web-server.md
/http-server:
en: 778b8499fa5ec417
zh: f2073db3d2ed8b70
en: c9b2f67d3248c496
zh: 5fa36d2837ff4f63
/http-server/routes:
en: 940bc78642c990b0
zh: 901d2ba7e354a766
en: e949c3738a35b58f
zh: 138e3aaeb319ddc4
/http-server/config:
en: d5bd005d51a49acd
zh: 9b652e39a41a4b6b
en: 4b162bd9f280eb06
zh: 3bea98dfee88c582
/http-server/the-service:
en: e304734ab2143438
zh: 065f9c2acbbb07ca
en: cc36972b8458279f
zh: c129044935d44769
+36 -10
View File
@@ -2,7 +2,7 @@
English | [中文](web-server.zh.md)
[dsh-host-webserver](../../packages/host/webserver) is the browser HTTP carrier for the GUI host: a single `node:http` plugin providing `ctx.webServer`, a named-route registry, optional gzip response compression, index.html transform callbacks, and one fallback handler that a plugin may claim. It is not part of the agent loop and not a capability seam; it knows no harness concepts, and another plugin registers every feature route, including the `/api` bridge, plugin bundles, and the HMR event stream ([layering reference](../../packages/boot/app-boot/README.md)). It serves browsers only: Electron loads the built files over `file://` and sends fetch requests through an IPC bridge instead of this server.
[dsh-host-webserver](../../packages/host/webserver) is the browser HTTP carrier for the GUI host: one `node:http` server — `node:https` when `tls` is configured — providing `ctx.webServer`, a named-route registry, optional gzip response compression, index.html transform callbacks, and one fallback handler that a plugin may claim. It is not part of the agent loop and not a capability seam; it knows no harness concepts, and another plugin registers every feature route, including the `/api` bridge, plugin bundles, and the HMR event stream ([layering reference](../../packages/boot/app-boot/README.md)). It serves browsers only: Electron loads the built files over `file://` and sends fetch requests through an IPC bridge instead of this server.
Source: [`packages/host/webserver/src/index.ts`](../../packages/host/webserver/src/index.ts)
@@ -26,24 +26,34 @@ interface WebRoute {
Match order is fixed: exact table first, then longest matching prefix, then the registered fallback. Registration order carries no request-facing semantics — named routes are composed to be disjoint, and the fallback seat answers anything no named route claims; one owner only, a second registration throws. The shipped Web composition claims the seat with [`dsh-host-frontend-static`](../../packages/host/frontend-static/src/index.ts), the SPA dist server with locked semantics: Connection authenticates the dist root and configured index before their HTML is read; non-index assets remain public; non-GET/HEAD is 405, traversal outside the dist root is 403, existing files are served directly, absent or non-file targets are empty 404 responses, and unknown extensions ship as octet-stream.
<a id="config"></a>
## Config
```ts type-equiv
/** Web server listen and response-compression config. */
/** Web server listen, TLS, and response-compression config. */
interface Config {
/**
* Listen address: a concrete IPv4 or IPv6 literal of one local interface,
* for example the container's own Pod address from `hostname -i`. A loopback
* literal (any address in 127/8, `::1`, or a mapped form of either) keeps the
* server on this machine; any other literal serves the network that address
* belongs to over plain HTTP, because the carrier adds no TLS. The
* unspecified address — IPv4 any, IPv6 any, and the IPv4-mapped forms of
* IPv4 any — is rejected at load: it would expose the port on every interface
* at once.
* belongs to, over plain HTTP unless `tls` is set. The unspecified address —
* IPv4 any, IPv6 any, and the IPv4-mapped forms of IPv4 any — is rejected at
* load: it would expose the port on every interface at once.
*/
host: string
/** Listen port; zero requests an OS-assigned port. */
port: number
/**
* Serve HTTPS with this certificate and key instead of plain HTTP. Both files
* are read once, before the listener binds: an unreadable or empty file,
* invalid PEM, or a key that does not match the certificate rejects
* initialization rather than falling back to HTTP. The material is never
* re-read, so replacing a certificate takes a reload. Omitted or null listens
* over plain HTTP.
*/
tls?: TlsConfig
/** Response compression for socket-backed HTTP requests. @default 'none' */
compression?: 'none' | 'gzip'
/** Gzip DEFLATE level from 0 through 9. @default 1 */
@@ -53,13 +63,27 @@ interface Config {
}
```
No composition can bind every interface at once: the unspecified address is rejected at load. The exported `isWildcardHost` and `isLoopbackHost` classify a bind address from its parsed value, and an IPv6 `%zone` is kept for `listen` while classification reads the address alone. The carrier itself owns no TLS, authentication, or Origin policy, so a non-loopback bind serves plain HTTP on that network unless the composition supplies those controls. `compression` defaults to `none`; the shipped Web bundle selects gzip level 1 with a 1024-byte threshold. The shipped `dsh web` command selects loopback by default and accepts one concrete `--host` address, warning at startup when the bind is not loopback; its Connection plugin supplies Host/Origin checks plus browser-session authentication for every Host API route and stream, and the bind address plus `--trusted-host` govern which browser Host is accepted; `--public-url` changes only the advertised URL. Other compositions own their bind and route-authentication policy. The dist location is an assembly fact of the frontend plugin that claims the seat.
```ts type-equiv
/**
* TLS material for the HTTPS listener: one certificate chain file and the
* private key file it pairs with. Both hold PEM text and both resolve against
* the process working directory.
*/
interface TlsConfig {
/** Certificate chain file, leaf certificate first, PEM, no passphrase. */
certFile: string
/** Private key file for the chain's leaf certificate; unencrypted PEM. */
keyFile: string
}
```
No composition can bind every interface at once: the unspecified address is rejected at load. The exported `isWildcardHost` and `isLoopbackHost` classify a bind address from its parsed value, and an IPv6 `%zone` is kept for `listen` while classification reads the address alone. The carrier owns no authentication or Origin policy, and it serves plain HTTP unless `tls` supplies a certificate and key. `compression` defaults to `none`; the shipped Web bundle selects gzip level 1 with a 1024-byte threshold. The shipped `dsh web` command selects loopback by default and accepts one concrete `--host` address, warning at startup when a non-loopback bind serves plain HTTP; its Connection plugin supplies Host/Origin checks plus browser-session authentication for every Host API route and stream, and the bind address plus `--trusted-host` govern which browser Host is accepted; `--public-url` changes only the advertised URL. Other compositions own their bind and route-authentication policy. The dist location is an assembly fact of the frontend plugin that claims the seat.
## The service
`WebServer` (`ctx.webServer`) listens immediately on activation; a listen failure (EADDRINUSE…) rejects initialization, and the boot process reports the failed fiber. `register(route)` adds one named route and returns its disposer; a duplicate `(kind, path)` throws because route patterns are a composition-level contract and a collision is a misconfiguration. Gzip wraps eligible socket-backed responses inside the server, so route handlers retain direct `ServerResponse` ownership and no response-writing API is added to the service. Existing content encodings, `Cache-Control: no-transform`, ranges, SSE, ZIP, and the packaged `.gz` Worker image remain identity responses. `collectIndexInjections()` gathers structured `IndexInjection` rows over one `webserver/index-inject` emit, and `renderIndex(html)` renders them into successful root and configured index responses before applying the raw `tapIndex(transform)` escape-hatch transforms in registration order; [dsh-client-modules](../../packages/client/modules) answers the event with the boot manifest rows. `port` reads the listening port, including the port assigned by the OS when `config.port` is 0.
`WebServer` (`ctx.webServer`) listens immediately on activation; a listen failure (EADDRINUSE…) rejects initialization, and the boot process reports the failed fiber. The `tls` pair is read once before the listener binds, and an unreadable, empty, or mismatched pair rejects initialization the same way instead of falling back to HTTP. `register(route)` adds one named route and returns its disposer; a duplicate `(kind, path)` throws because route patterns are a composition-level contract and a collision is a misconfiguration. Gzip wraps eligible socket-backed responses inside the server, so route handlers retain direct `ServerResponse` ownership and no response-writing API is added to the service. Existing content encodings, `Cache-Control: no-transform`, ranges, SSE, ZIP, and the packaged `.gz` Worker image remain identity responses. `collectIndexInjections()` gathers structured `IndexInjection` rows over one `webserver/index-inject` emit, and `renderIndex(html)` renders them into successful root and configured index responses before applying the raw `tapIndex(transform)` escape-hatch transforms in registration order; [dsh-client-modules](../../packages/client/modules) answers the event with the boot manifest rows. `port` reads the listening port, including the port assigned by the OS when `config.port` is 0, and `protocol` reports the scheme the listener serves (`http:` or `https:`).
A request whose handling throws (a malformed %-escape hitting `decodeURIComponent`, a client dropping mid-body) is logged as a warning and answered 400 — or the socket destroyed when headers are already out — never a process exit. Disposal pairs `close()` with `closeAllConnections()` because a handler may hold its response open (SSE) and such connections never end on their own; without the force-close, teardown would hang. The package never prints: the URL line belongs to the shell. Per-package operational detail, including the dev-mode bundle watch pipeline, stays in the [README](../../packages/host/webserver/README.md).
A request whose handling throws (a malformed %-escape hitting `decodeURIComponent`, a client dropping mid-body) is logged as a warning and answered 400 — or the socket destroyed when headers are already out — never a process exit. Disposal pairs `close()` with `closeAllConnections()` because a handler may hold its response open (SSE) and such connections never end on their own; without the force-close, teardown would hang. A TLS socket counts as a connection only after its handshake, so the server tracks raw TLS sockets beside upgraded ones and destroys both at disposal. The package never prints: the URL line belongs to the shell. Per-package operational detail, including the dev-mode bundle watch pipeline, stays in the [README](../../packages/host/webserver/README.md).
<!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
@@ -101,6 +125,8 @@ admit(request: ConnectionTrustRequest): PeerAdmission
/**
* Authenticate one frontend index request, owning a token redirect or 401.
* The cookie this mints is `Secure` when the mounted Web carrier serves TLS,
* which only that listener's protocol decides.
* @param request - root or configured-index HTTP request.
* @param response - response owned when the result is false.
* @returns true only when the frontend may serve index.html.
@@ -121,7 +147,7 @@ Source: [`packages/client/connection/src/rpc.ts`](../../packages/client/connecti
### `ctx.webServer` — `WebServer`
The browser HTTP carrier service. Activation listens immediately. Route registration order does not affect requests because configured named routes must be distinct, and the fallback handler answers anything not yet claimed during startup with 404 until its owner registers. A listen failure rejects initialization, and the boot process reports the failed fiber.
The browser HTTP carrier service. Activation loads any configured TLS material, then listens immediately; a material or listen failure rejects initialization, and the boot process reports the failed fiber. Route registration order does not affect requests because configured named routes must be distinct, and the fallback handler answers anything not yet claimed during startup with 404 until its owner registers.
```ts cordis-catalog
/**
+36 -10
View File
@@ -2,7 +2,7 @@
[English](web-server.md) | 中文
[dsh-host-webserver](../../packages/host/webserver) 是 GUI Host 的浏览器 HTTP 载体:它是一个提供 `ctx.webServer` 的 `node:http` 插件,包含具名路由注册表、可选的 gzip 响应压缩、index.html 转换回调,以及一个可由插件认领的回退处理器。它不属于 agent loop(智能体循环),也不是能力 seam;它不了解任何 harness 概念。其他插件负责注册所有功能路由,包括 `/api` 桥接、插件 bundle 和 HMR(热模块替换)事件流([分层说明](../../packages/boot/app-boot/README.zh.md))。该服务器只服务浏览器:Electron 通过 `file://` 加载已构建文件,并经 IPC 桥接发送 fetch 请求,不使用本服务器。
[dsh-host-webserver](../../packages/host/webserver) 是 GUI Host 的浏览器 HTTP 载体:一个 `node:http` 服务器——配置 `tls` 时改用 `node:https`——提供 `ctx.webServer`、具名路由注册表、可选的 gzip 响应压缩、index.html 转换回调,以及一个可由插件认领的回退处理器。它不属于 agent loop(智能体循环),也不是能力 seam;它不了解任何 harness 概念。其他插件负责注册所有功能路由,包括 `/api` 桥接、插件 bundle 和 HMR(热模块替换)事件流([分层说明](../../packages/boot/app-boot/README.zh.md))。该服务器只服务浏览器:Electron 通过 `file://` 加载已构建文件,并经 IPC 桥接发送 fetch 请求,不使用本服务器。
源码:[`packages/host/webserver/src/index.ts`](../../packages/host/webserver/src/index.ts)
@@ -26,24 +26,34 @@ interface WebRoute {
匹配顺序固定:先查 exact 表,再取最长匹配前缀,最后落到已注册的回退。注册顺序不携带任何面向请求的语义:具名路由在组合上互不相交,任何未被具名路由认领的请求都由回退席位应答;席位只有一个所有者,第二次注册会抛出异常。发布的 Web 组合用 [`dsh-host-frontend-static`](../../packages/host/frontend-static/src/index.ts) 认领席位,即遵循固定语义的 SPA dist 服务器:Connection 在读取 dist 根目录和配置 index 的 HTML 前完成认证;非 index 资产保持公开;非 GET/HEAD 返回 405,越出 dist 根目录的遍历返回 403,现有文件直接提供,缺失或不是文件的目标返回空的 404,未知扩展名按 octet-stream 发送。
<a id="config"></a>
## 配置
```ts type-equiv
/** Web server listen and response-compression config. */
/** Web server listen, TLS, and response-compression config. */
interface Config {
/**
* Listen address: a concrete IPv4 or IPv6 literal of one local interface,
* for example the container's own Pod address from `hostname -i`. A loopback
* literal (any address in 127/8, `::1`, or a mapped form of either) keeps the
* server on this machine; any other literal serves the network that address
* belongs to over plain HTTP, because the carrier adds no TLS. The
* unspecified address — IPv4 any, IPv6 any, and the IPv4-mapped forms of
* IPv4 any — is rejected at load: it would expose the port on every interface
* at once.
* belongs to, over plain HTTP unless `tls` is set. The unspecified address —
* IPv4 any, IPv6 any, and the IPv4-mapped forms of IPv4 any — is rejected at
* load: it would expose the port on every interface at once.
*/
host: string
/** Listen port; zero requests an OS-assigned port. */
port: number
/**
* Serve HTTPS with this certificate and key instead of plain HTTP. Both files
* are read once, before the listener binds: an unreadable or empty file,
* invalid PEM, or a key that does not match the certificate rejects
* initialization rather than falling back to HTTP. The material is never
* re-read, so replacing a certificate takes a reload. Omitted or null listens
* over plain HTTP.
*/
tls?: TlsConfig
/** Response compression for socket-backed HTTP requests. @default 'none' */
compression?: 'none' | 'gzip'
/** Gzip DEFLATE level from 0 through 9. @default 1 */
@@ -53,13 +63,27 @@ interface Config {
}
```
没有组合能一次性绑定所有网络接口:未指定地址在加载时即被拒绝。导出的 `isWildcardHost` 与 `isLoopbackHost` 按解析后的地址值分类,IPv6 的 `%zone` 会保留给 `listen`,而分类只读取地址本身。载体本身不拥有 TLS、认证或 Origin 策略,因此绑定到非回环地址时会在该网络上提供明文 HTTP,除非组合层提供这些控制。`compression` 默认为 `none`;随附的 Web 组合选择 gzip level 1 和 1024 字节阈值。随附的 `dsh web` 命令默认选择 loopback 并接受一个具体的 `--host` 地址,绑定非 loopback 时会在启动时警告;其 Connection 插件为每个 Host API route 与 stream 提供 Host/Origin 校验和浏览器会话认证,绑定地址加 `--trusted-host` 决定接受哪个浏览器 Host;`--public-url` 只改变公告的 URL。其他组合自行拥有绑定与路由认证策略。dist 位置是认领席位的前端插件的组装事实。
```ts type-equiv
/**
* TLS material for the HTTPS listener: one certificate chain file and the
* private key file it pairs with. Both hold PEM text and both resolve against
* the process working directory.
*/
interface TlsConfig {
/** Certificate chain file, leaf certificate first, PEM, no passphrase. */
certFile: string
/** Private key file for the chain's leaf certificate; unencrypted PEM. */
keyFile: string
}
```
没有组合能一次性绑定所有网络接口:未指定地址在加载时即被拒绝。导出的 `isWildcardHost` 与 `isLoopbackHost` 按解析后的地址值分类,IPv6 的 `%zone` 会保留给 `listen`,而分类只读取地址本身。载体不拥有认证或 Origin 策略,并且除非 `tls` 提供证书与密钥,否则以明文 HTTP 提供服务。`compression` 默认为 `none`;随附的 Web 组合选择 gzip level 1 和 1024 字节阈值。随附的 `dsh web` 命令默认选择 loopback 并接受一个具体的 `--host` 地址,非 loopback 绑定以明文 HTTP 提供服务时会在启动时警告;其 Connection 插件为每个 Host API route 与 stream 提供 Host/Origin 校验和浏览器会话认证,绑定地址加 `--trusted-host` 决定接受哪个浏览器 Host;`--public-url` 只改变公告的 URL。其他组合自行拥有绑定与路由认证策略。dist 位置是认领席位的前端插件的组装事实。
## 服务
`WebServer`(`ctx.webServer`)在激活时立即监听;监听失败(EADDRINUSE 等)会使初始化被拒绝,启动进程会报告失败的 fiber。`register(route)` 添加一条具名路由并返回其 disposer;重复的 `(kind, path)` 抛出异常,因为路由模式是组合层约定,冲突即配置错误。Gzip 在服务器内部包装符合条件且基于 socket 的响应,因此 route handler 继续直接持有 `ServerResponse`,服务也不新增响应写出 API。已有内容编码、`Cache-Control: no-transform`、范围响应、SSE、ZIP 与打包后的 `.gz` Worker 镜像均保持 identity 响应。`collectIndexInjections()` 经一次 `webserver/index-inject` emit 收集结构化 `IndexInjection` 行,`renderIndex(html)` 把它们渲染进成功的根路径和配置 index 响应,随后再按注册顺序应用原始的 `tapIndex(transform)` 逃生口转换;[dsh-client-modules](../../packages/client/modules) 以启动 manifest(元数据清单)行回应该事件。`port` 读取监听端口,包括 `config.port` 为 0 时操作系统分配的端口。
`WebServer`(`ctx.webServer`)在激活时立即监听;监听失败(EADDRINUSE 等)会使初始化被拒绝,启动进程会报告失败的 fiber。`tls` 证书对在监听器绑定前读取一次;文件不可读、为空或二者不匹配时同样会使初始化被拒绝,而不是回退到 HTTP。`register(route)` 添加一条具名路由并返回其 disposer;重复的 `(kind, path)` 抛出异常,因为路由模式是组合层约定,冲突即配置错误。Gzip 在服务器内部包装符合条件且基于 socket 的响应,因此 route handler 继续直接持有 `ServerResponse`,服务也不新增响应写出 API。已有内容编码、`Cache-Control: no-transform`、范围响应、SSE、ZIP 与打包后的 `.gz` Worker 镜像均保持 identity 响应。`collectIndexInjections()` 经一次 `webserver/index-inject` emit 收集结构化 `IndexInjection` 行,`renderIndex(html)` 把它们渲染进成功的根路径和配置 index 响应,随后再按注册顺序应用原始的 `tapIndex(transform)` 逃生口转换;[dsh-client-modules](../../packages/client/modules) 以启动 manifest(元数据清单)行回应该事件。`port` 读取监听端口,包括 `config.port` 为 0 时操作系统分配的端口,`protocol` 则报告监听器提供的协议(`http:` 或 `https:`)。
处理过程中抛出异常的请求(畸形的 % 转义撞上 `decodeURIComponent`、客户端在请求体中途断开)会记录为警告并应答 400(响应头已发出时则销毁 socket),绝不导致进程退出。dispose(资源释放)把 `close()` 与 `closeAllConnections()` 配对使用,因为处理器可能像 SSE(Server-Sent Events)那样保持响应打开,而这类连接永远不会自行结束;没有强制关闭,拆卸就会挂起。该包从不打印输出:URL 行归 shell 所有。逐包运维细节(含开发模式的 bundle 监视流水线)留在 [README](../../packages/host/webserver/README.zh.md) 中。
处理过程中抛出异常的请求(畸形的 % 转义撞上 `decodeURIComponent`、客户端在请求体中途断开)会记录为警告并应答 400(响应头已发出时则销毁 socket),绝不导致进程退出。dispose(资源释放)把 `close()` 与 `closeAllConnections()` 配对使用,因为处理器可能像 SSE(Server-Sent Events)那样保持响应打开,而这类连接永远不会自行结束;没有强制关闭,拆卸就会挂起。TLS socket 只有完成握手后才计入连接,因此服务器会把原始 TLS socket 与升级后的 socket 一并跟踪,并在 dispose 时销毁两者。该包从不打印输出:URL 行归 shell 所有。逐包运维细节(含开发模式的 bundle 监视流水线)留在 [README](../../packages/host/webserver/README.zh.md) 中。
<!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
@@ -101,6 +125,8 @@ admit(request: ConnectionTrustRequest): PeerAdmission
/**
* Authenticate one frontend index request, owning a token redirect or 401.
* The cookie this mints is `Secure` when the mounted Web carrier serves TLS,
* which only that listener's protocol decides.
* @param request - root or configured-index HTTP request.
* @param response - response owned when the result is false.
* @returns true only when the frontend may serve index.html.
@@ -121,7 +147,7 @@ Source: [`packages/client/connection/src/rpc.ts`](../../packages/client/connecti
### `ctx.webServer` — `WebServer`
The browser HTTP carrier service. Activation listens immediately. Route registration order does not affect requests because configured named routes must be distinct, and the fallback handler answers anything not yet claimed during startup with 404 until its owner registers. A listen failure rejects initialization, and the boot process reports the failed fiber.
The browser HTTP carrier service. Activation loads any configured TLS material, then listens immediately; a material or listen failure rejects initialization, and the boot process reports the failed fiber. Route registration order does not affect requests because configured named routes must be distinct, and the fallback handler answers anything not yet claimed during startup with 404 until its owner registers.
```ts cordis-catalog
/**
+8 -8
View File
@@ -3,17 +3,17 @@
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/user/guide/public-deployments.md
/publish-the-web-ui-behind-a-reverse-proxy:
en: 15b92e92d2526f37
zh: b58079cf6eee78cc
en: 867565aa675f3fb3
zh: c60606b5e5bdb778
/publish-the-web-ui-behind-a-reverse-proxy/what-public-url-advertises:
en: 576292b93ccf299a
zh: ded5adc55396869c
/publish-the-web-ui-behind-a-reverse-proxy/what-the-proxy-must-do:
en: 8dd9d910a93fe54d
zh: 1e9e21d9949c8ab2
en: 2f93051763125365
zh: 1dd699cb84ecf034
/publish-the-web-ui-behind-a-reverse-proxy/trust-the-authority-browsers-use:
en: b85ae06cb83c23fe
zh: 4197ab824ff5a6f8
en: 08e5a6cc45918876
zh: d59178e10e915748
/publish-the-web-ui-behind-a-reverse-proxy/secure-the-external-leg:
en: ddcdd76a0739a38c
zh: 07940b66df2335f5
en: 2afac1f10e2d2e88
zh: f9b4b03a5aad27e1
+9 -5
View File
@@ -2,7 +2,7 @@
English | [中文](public-deployments.zh.md)
`dsh --profile web` serves the GUI over plain HTTP on a loopback port, so a browser on another machine cannot reach it, and the process knows nothing about the address you do use. A reverse proxy in front of it owns that external leg — the public host name, TLS, and the path prefix it strips before forwarding to the listener — and `--public-url` tells DSH which address browsers use:
`dsh --profile web` serves the GUI on a loopback port, so a browser on another machine cannot reach it, and the process knows nothing about the address you do use. A reverse proxy in front of it owns that external leg — the public host name, TLS, and the path prefix it strips before forwarding to the listener — and `--public-url` tells DSH which address browsers use:
```sh
dsh --profile web --public-url https://app.example/ui/ --trusted-host app.example
@@ -17,17 +17,21 @@ dsh --profile web --public-url https://app.example/ui/ --trusted-host app.exampl
- **Preserve the browser-facing `Host`.** The fence compares the received `Host` against the accepted authorities, so the proxy forwards it unchanged instead of rewriting it to the listener's address.
- **Strip the mount prefix.** The listener answers origin-root routes, so a request for `/ui/api/...` must arrive as `/api/...`.
- **Forward upgrades.** Every request and WebSocket upgrade the page opens must reach the listener with its `Upgrade` and `Connection` headers intact; TLS terminates on the proxy's external leg when it serves HTTPS.
- **Rewrite cookie scope.** The backend always issues host-only `Path=/` cookies without `Secure`; the proxy rewrites `Path` to the mount (`/ui/`) and adds `Secure` on its HTTPS leg.
- **Rewrite cookie scope.** A backend serving plain HTTP issues host-only `Path=/` cookies without `Secure`; the proxy then rewrites `Path` to the mount (`/ui/`) and adds `Secure` on its HTTPS leg. A backend listener serving its own TLS already marks the cookie `Secure`, so the proxy only rewrites `Path`.
- **Redirect the bare mount.** `/ui/` is the only entry: it alone exchanges the launch token for the session cookie, and it or `/ui/index.html` serves the document to a browser that already holds that cookie, because the served document resolves URLs against its own directory. A request to `/ui` must arrive as `/ui/`, and the stripped backend cannot reconstruct that external path.
## Trust the authority browsers use
The fence accepts loopback, the listener's own bind address, and every authority `--trusted-host` names. A browser that reaches the deployment under any other authority gets 403 for every API call, however correct the proxy is, so name the browser-visible authority with `--trusted-host`; advertising it with `--public-url` is display only and does not admit it. An entry without a port matches any port, which suits a tunnel that binds a different one each time. The fence only admits the request; the launch token in the printed URL and the signed session cookie authenticate it.
The fence accepts loopback, the listener's own bind address, and every authority `--trusted-host` names. A browser that reaches the deployment under any other authority gets 403 for every API call, however correct the proxy is, so name the browser-visible authority with `--trusted-host`; advertising it with `--public-url` is display only and does not admit it. An entry without a port matches any port, which suits a tunnel that binds a different one each time. The fence only admits the request; the launch token in the printed URL and the signed session cookie authenticate it. TLS, wherever it terminates, changes none of this: the certificate identifies the server to the browser, not the browser to the server.
Neither the advertised URL nor the fence protects the listening port itself, so restrict the port to the trusted proxy or network.
## Secure the external leg
Terminate TLS at the proxy. An `http://` advertised root sends the launch token and the session cookie unencrypted, and an `https://` root encrypts only the browser-to-proxy leg. The printed URL carries a process credential, so share it only with intended users.
Terminate TLS at the proxy, on the listener itself, or on both; `--public-url` remains an advertisement. An `http://` browser-facing root exposes the launch token in plaintext. An `https://` proxy root protects the browser-to-proxy leg; encryption between proxy and listener depends on the listener's TLS configuration.
The [Web app reference](../../../packages/bundle/web-app/README.md#public-deployments) documents the `--public-url` and `--trusted-host` command-line options. In the Web profile, `publicUrl` configures the `web-runtime` row; `trustedHosts` configures the [Connection row](../../../packages/client/connection/README.md).
To terminate on the listener, pass `--tls-cert` and `--tls-key`, each a path resolved against the process working directory: the certificate file holds the full chain and the key file an unencrypted PEM private key. DSH never obtains, renews, or watches certificates, and the carrier reads the files once, so replace them and reload the listener or restart the process to change them; startup fails on a missing or invalid pair and never falls back to HTTP. The browser applies its own trust store and name checks, so the certificate must cover the hostname or IP you open. The default port stays 3080. Native TLS needs no proxy, but proxy deployments and `--public-url` remain supported independently. A TLS listener marks session cookies `Secure`; keep the public browser-facing leg HTTPS so browsers can return those cookies.
The printed URL carries a process credential, so share it only with intended users.
The [Web app reference](../../../packages/bundle/web-app/README.md#public-deployments) documents the `--public-url`, `--trusted-host`, and `--tls-cert`/`--tls-key` command-line options. In the Web profile, `publicUrl` configures the `web-runtime` row, `trustedHosts` configures the [Connection row](../../../packages/client/connection/README.md), and `tls` configures the [webserver row](../../../docs/subsystems/web-server.md#config).
+9 -5
View File
@@ -2,7 +2,7 @@
[English](public-deployments.md) | 中文
`dsh --profile web` 在 loopback 端口上以明文 HTTP 提供 GUI,因此其他机器上的浏览器无法访问它,进程也无从得知你实际使用的地址。它前面的反向代理拥有这条外部链路——公开主机名、TLS,以及转发到监听器之前剥离的路径前缀——`--public-url` 则告诉 DSH 浏览器使用的是哪个地址:
`dsh --profile web` 在 loopback 端口上提供 GUI,因此其他机器上的浏览器无法访问它,进程也无从得知你实际使用的地址。它前面的反向代理拥有这条外部链路——公开主机名、TLS,以及转发到监听器之前剥离的路径前缀——`--public-url` 则告诉 DSH 浏览器使用的是哪个地址:
```sh
dsh --profile web --public-url https://app.example/ui/ --trusted-host app.example
@@ -17,17 +17,21 @@ dsh --profile web --public-url https://app.example/ui/ --trusted-host app.exampl
- **保留浏览器可见的 `Host`。** 栅栏把收到的 `Host` 与被接受的 authority 比对,因此代理应原样转发,而不要改写成监听器的地址。
- **剥离挂载前缀。** 监听器应答的是 origin-root 路由,因此对 `/ui/api/...` 的请求必须以 `/api/...` 抵达。
- **转发升级请求。** 页面发出的每个请求与 WebSocket 升级都必须连同其 `Upgrade` 与 `Connection` 头抵达监听器;代理对外提供 HTTPS 时,TLS 在其外部链路上终止。
- **改写 cookie 作用域。** 后端始终签发不带 `Secure` 的 host-only `Path=/` cookie;代理把 `Path` 改写为挂载(`/ui/`),并仅在其 HTTPS 链路上添加 `Secure`。
- **改写 cookie 作用域。** 以明文 HTTP 提供服务的后端签发不带 `Secure` 的 host-only `Path=/` cookie;代理再把 `Path` 改写为挂载(`/ui/`),并仅在其 HTTPS 链路上添加 `Secure`。监听器自身提供 TLS 时 cookie 已带 `Secure`,代理只需改写 `Path`。
- **重定向裸挂载。** `/ui/` 是唯一入口:只有它把启动 token 换成会话 cookie;已持有该 cookie 的浏览器可由它或 `/ui/index.html` 取得文档,因为所提供的文档以自身目录解析 URL。对 `/ui` 的请求必须以 `/ui/` 抵达,而被剥离路径的后端无法重建该外部路径。
## 信任浏览器使用的 authority
栅栏接受 loopback、监听器自身的绑定地址,以及每个由 `--trusted-host` 点名的 authority。浏览器若以其他任何 authority 访问部署,无论代理多么正确,每个 API 调用都会得到 403,因此请用 `--trusted-host` 点名浏览器可见的 authority;用 `--public-url` 公告它只是展示,并不等于接纳。不带端口的条目匹配任意端口,适合每次绑定不同端口的隧道。栅栏只放行请求;打印 URL 中的启动 token 与签名会话 cookie 才完成认证。
栅栏接受 loopback、监听器自身的绑定地址,以及每个由 `--trusted-host` 点名的 authority。浏览器若以其他任何 authority 访问部署,无论代理多么正确,每个 API 调用都会得到 403,因此请用 `--trusted-host` 点名浏览器可见的 authority;用 `--public-url` 公告它只是展示,并不等于接纳。不带端口的条目匹配任意端口,适合每次绑定不同端口的隧道。栅栏只放行请求;打印 URL 中的启动 token 与签名会话 cookie 才完成认证。无论 TLS 在哪里终止都不改变这一切:证书只是向浏览器标识服务器,而不是向服务器标识浏览器。
无论是公告 URL 还是栅栏都不保护监听端口本身,因此请把端口限制在可信代理或网络内。
## 保护外部链路
在代理处终止 TLS。`http://` 公告根会以明文发送启动 token 与会话 cookie,而 `https://` 根只加密浏览器到代理这一段。打印的 URL 携带进程凭据,只应与预期用户分享。
可以在代理处、监听器本身或两者上终止 TLS;`--public-url` 仍只是公告。面向浏览器的 `http://` 根会明文暴露启动 token。`https://` 代理根保护浏览器到代理这一段;代理与监听器之间是否加密取决于监听器的 TLS 配置。
[Web 应用参考](../../../packages/bundle/web-app/README.zh.md#public-deployments)说明 `--public-url` 与 `--trusted-host` 命令行选项。在 Web profile 中,`publicUrl` 配置在 `web-runtime` 行,`trustedHosts` 配置在 [Connection 行](../../../packages/client/connection/README.zh.md)。
要在监听器上终止 TLS,请传入 `--tls-cert` 与 `--tls-key`,两者各是相对进程工作目录解析的路径:证书文件包含完整证书链,密钥文件为不带口令的 PEM 私钥。DSH 从不获取、续期或监视证书,且载体只读取一次文件,因此更换文件后需重新加载监听器或重启进程;文件缺失或无效时启动失败,绝不回退到 HTTP。浏览器使用自己的信任库与名称校验,因此证书必须覆盖你所访问的 hostname 或 IP。默认端口仍为 3080。原生 TLS 不需要代理,但代理部署与 `--public-url` 仍分别受支持。TLS 监听器会给会话 cookie 加上 `Secure`;公开部署的浏览器侧链路应保持 HTTPS,才能让浏览器回传这些 cookie。
打印的 URL 携带进程凭据,只应与预期用户分享。
[Web 应用参考](../../../packages/bundle/web-app/README.zh.md#public-deployments)说明 `--public-url`、`--trusted-host` 与 `--tls-cert`/`--tls-key` 命令行选项。在 Web profile 中,`publicUrl` 配置在 `web-runtime` 行,`trustedHosts` 配置在 [Connection 行](../../../packages/client/connection/README.zh.md),`tls` 配置在 [webserver 行](../../../docs/subsystems/web-server.zh.md#config)。
+10 -10
View File
@@ -9,8 +9,8 @@
en: a85684740ecd278a
zh: 7c54099b9edccf01
/deepseek-ai-dsh-web-app/summary:
en: b295353906560038
zh: c9348f651258c218
en: 0a2a9876194543e5
zh: ff472776a44ae260
/deepseek-ai-dsh-web-app/table-of-contents:
en: d152484eb41ac6b4
zh: 09388d293f9be9cb
@@ -21,11 +21,11 @@
en: e3cac911f080d8ef
zh: 60749e9f5c39c1bc
/deepseek-ai-dsh-web-app/use-this-package/configuration:
en: 66a11266a5877a9d
zh: 9a907da83b0859c4
en: 2b09528c8fc8d370
zh: 7b374ebda8841172
/deepseek-ai-dsh-web-app/use-this-package/listening-trust-and-public-deployments:
en: f2c2ea2c61d6f164
zh: 94c71cb42eef3827
en: 177400de33509826
zh: dd88bc096accffc8
/deepseek-ai-dsh-web-app/use-this-package/running-over-ssh:
en: df205a26b6d49168
zh: e46ed7cf9e06248a
@@ -45,8 +45,8 @@
en: 973128fd346869df
zh: 54e6792f9f82a192
/deepseek-ai-dsh-web-app/understand-the-implementation/source-map:
en: 0735a22bb413d26c
zh: 723c1d9460c51298
en: d4c7f39a9fd90683
zh: ac2dbf75b2ceb255
/deepseek-ai-dsh-web-app/further-exploration:
en: f81694f7424e2988
zh: 6643f9227039e228
@@ -66,8 +66,8 @@
en: 1d7d87f54b20b563
zh: d150a51615d278e8
/deepseek-ai-dsh-web-app/known-limitations-and-deferred-work:
en: 744f155fbcefda1f
zh: 7c9a262ce6db837a
en: 635c1336e2f4e821
zh: caabdecb02e2df14
/deepseek-ai-dsh-web-app/known-limitations-and-deferred-work/dev-note:
en: 3f81b4f3beb74a0d
zh: 2e377ca40822d093
+8 -5
View File
@@ -13,7 +13,7 @@ Desktop analytics schedules partial batches every 30 seconds, with a 15-second e
## Summary
Run `dsh --profile web` for browser chat, model and settings management, and session history, with the same model access, tools, and safety defaults as other dsh surfaces. Startup prints a tokenized URL and normally opens the default browser; SSH sessions and `--no-open` require manual opening. You can change the port, allow extra authorities, and bind one concrete local IP; wildcard addresses are rejected. Remote access uses an advertised HTTP(S) URL behind a prefix-stripping proxy or plain HTTP on a non-loopback bind. Use the headless profile for one-shot command-line tasks.
Run `dsh --profile web` for browser chat, model and settings management, and session history, with the same model access, tools, and safety defaults as other dsh surfaces. Startup prints a tokenized URL and normally opens the default browser; SSH sessions and `--no-open` require manual opening. You can change the port, allow extra authorities, bind one concrete local IP, and serve HTTPS from a certificate you supply; wildcard addresses are rejected. Remote access uses an advertised HTTP(S) URL behind a prefix-stripping proxy, listener TLS, or plain HTTP on a non-loopback bind. Use the headless profile for one-shot command-line tasks.
## Table of Contents
@@ -46,7 +46,7 @@ Saved model selections override the composition default. The settings card accep
### Configuration
`--host` and `--port` configure the listener; `--public-url` names the advertised public HTTP(S) root the GUI is reached at behind a prefix-stripping proxy, and `--trusted-host` adds further accepted authorities. All are described under [Listening, trust, and public deployments](#public-deployments):
`--host` and `--port` configure the listener; `--tls-cert` and `--tls-key` make it serve HTTPS; `--public-url` names the advertised public HTTP(S) root the GUI is reached at behind a prefix-stripping proxy, and `--trusted-host` adds further accepted authorities. All are described under [Listening, trust, and public deployments](#public-deployments), and the `tls` field those flags set is documented with the [carrier's config](../../../docs/subsystems/web-server.md#config):
| Field | Default | Meaning |
|---|---|---|
@@ -60,7 +60,9 @@ The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-a
<a id="public-deployments"></a>
### Listening, trust, and public deployments
By default the GUI listens on loopback. Only `--host` changes the listener: it accepts one concrete local IPv4 or IPv6 address, never a wildcard. A non-loopback listener serves plain HTTP and warns at startup, even when an HTTPS `--public-url` fronts it. The Host/Origin fence accepts the bind IP itself; other non-loopback authorities require `--trusted-host`. The launch-token exchange and signed session cookie authenticate every API method and WebSocket stream.
By default the GUI listens on loopback and accepts connections from this machine only. Only `--host` changes the listener: it accepts one concrete local IPv4 or IPv6 address, never a wildcard. A non-loopback listener without TLS serves plain HTTP and warns at startup, even when an HTTPS `--public-url` fronts it. The Host/Origin fence accepts the bind IP itself; other non-loopback authorities require `--trusted-host`, so a remote browser reaches the GUI behind a prefix-stripping proxy or through a port-forwarding client that presents a trusted hostname. The launch-token exchange and signed session cookie authenticate every API method and WebSocket stream.
Pass `--tls-cert` and `--tls-key` together to serve HTTPS from the listener instead of plain HTTP. Each names a file resolved against the process working directory: the certificate file holds the full chain and the key file an unencrypted PEM private key. The flags are one setting — supplying only one is a usage error, and supplying neither keeps the plain-HTTP default. Startup fails when a file is missing, unreadable, empty, or not a valid certificate-and-key pair; it never falls back to HTTP. Nothing re-reads the material while the listener runs, so serving a different certificate needs a listener reload or a process restart. DSH issues, renews, and watches nothing: there is no ACME client, no self-signed fallback, and no redirect listener. The browser applies its own trust store and host-name matching, so a certificate the browser does not trust, or one whose subject alternative names miss the authority you open, fails before the page loads. The default port stays 3080, and `--tls-cert`/`--tls-key` do not follow `--public-url`: the listener's certificate covers the authority browsers dial. TLS changes nothing about authority: Connection still authenticates every request and still checks Host/Origin, and the session cookie it mints is `Secure` whenever the receiving listener serves HTTPS.
A container can bind its Pod address and advertise the ingress that fronts it:
@@ -114,7 +116,7 @@ The URL line and browser handoff are readiness signals: supervisors RPC as soon
|---|---|
| [`src/index.ts`](src/index.ts) | The `web-app` glue plugin: dist resolution, advertised application URL, prompt sections, bash variable, URL line, browser handoff |
| [`src/public-url.ts`](src/public-url.ts) | Advertised-root validation and trailing-slash normalization; a leaf module for local imports, not package API |
| [`src/startup.ts`](src/startup.ts) | The `web-startup` provider: `--host`, `--port`, `--public-url`, `--trusted-host`, `--no-open`, `--help` |
| [`src/startup.ts`](src/startup.ts) | The `web-startup` provider: `--host`, `--port`, `--tls-cert`, `--tls-key`, `--public-url`, `--trusted-host`, `--no-open`, `--help` |
| [`cordis.patch.yml`](cordis.patch.yml) | The web patch: restated base values, web host rows, browser roster, preset registry |
| [`presets/`](presets) | One `@deepseek-ai/dsh-agent-preset` declaration per shipped preset (`standard`, `ptc`, `minimal`, `cordis`), each its own patch file |
| [`tests/web-app.spec.ts`](tests/web-app.spec.ts) | Dist index anchoring, advertised-URL publication and warnings, prompt sections, readiness publication |
@@ -164,7 +166,8 @@ Source and Web sections follow first-party reusable instructions. Different chec
These limits tell you what to expect in unusual setups — a source checkout, SSH sessions, or strict networks. They are current package constraints, not a general browser comparison or a task backlog.
- **The frontend must be built** — a source checkout needs `pnpm run build` first; startup stops with a build hint when the dist is missing, and there is no source-serving fallback.
- **The listener has no TLS** — protect the external leg with a TLS-terminating proxy; an HTTP advertised root sends credentials without encryption.
- **TLS is opt-in and unmanaged** — the listener serves HTTPS only when `--tls-cert` and `--tls-key` name a full chain and its unencrypted key; DSH never obtains, renews, or watches certificates, and a changed file needs a listener reload or a process restart. The browser must trust that certificate, its names must cover the authority you open, and the certificate is independent of `--public-url`.
- **Without TLS the external leg is plain HTTP** — protect it with a TLS-terminating proxy, and do not send the launch URL over a network you do not trust.
- **Only the handoff start is observable** — the GUI reports that the browser was asked to open, not that it actually opened; a later browser exit is never reported, and the printed URL is your manual fallback.
- **SSH sessions keep the URL but skip the browser handoff** — without an advertised root the printed URL names the remote host's bind-address endpoint (loopback for a loopback bind); the SSH client or editor must expose and open the local forwarded address.
- **`BROWSER` overrides only come from the environment** — a discovered `.env` cannot set `BROWSER`; only an inherited value can choose the executable for the automatic handoff.
+8 -5
View File
@@ -13,7 +13,7 @@ kind: "package-bundle"
## 概述
运行 `dsh --profile web`,获得浏览器内的聊天、模型与设置管理以及会话历史,并与其他 dsh 表层共用同一套模型访问、工具与安全默认值。启动时会打印带 token 的 URL,通常还会在默认浏览器中打开;SSH 会话和 `--no-open` 需要手动打开。你可以更改端口、允许额外 authority,并绑定一个具体的本机 IP;通配地址会被拒绝。跨机访问使用剥离前缀代理后公告的 HTTP(S) URL,或非 loopback 绑定上的明文 HTTP。一次性的命令行任务应使用 headless 配置。
运行 `dsh --profile web`,获得浏览器内的聊天、模型与设置管理以及会话历史,并与其他 dsh 表层共用同一套模型访问、工具与安全默认值。启动时会打印带 token 的 URL,通常还会在默认浏览器中打开;SSH 会话和 `--no-open` 需要手动打开。你可以更改端口、允许额外 authority、绑定一个具体的本机 IP,并用自己提供的证书直接提供 HTTPS;通配地址会被拒绝。跨机访问使用剥离前缀代理后公告的 HTTP(S) URL、监听器 TLS,或非 loopback 绑定上的明文 HTTP。一次性的命令行任务应使用 headless 配置。
## 目录
@@ -46,7 +46,7 @@ dsh --profile web --no-open --port 8080
### 配置
`--host` 与 `--port` 配置监听器;`--public-url` 指定 GUI 在剥离前缀的代理之后对外公告的公开 HTTP(S) 根,`--trusted-host` 则添加更多被接受的 authority。两者都在[监听、信任与公开部署](#public-deployments)中说明:
`--host` 与 `--port` 配置监听器;`--tls-cert` 与 `--tls-key` 让它直接提供 HTTPS;`--public-url` 指定 GUI 在剥离前缀的代理之后对外公告的公开 HTTP(S) 根,`--trusted-host` 则添加更多被接受的 authority。这些都在[监听、信任与公开部署](#public-deployments)中说明,而这些 flag 设置的 `tls` 字段记录在[载体的配置](../../../docs/subsystems/web-server.zh.md#config)中:
| 字段 | 默认值 | 含义 |
|---|---|---|
@@ -60,7 +60,9 @@ dsh --profile web --no-open --port 8080
<a id="public-deployments"></a>
### 监听、信任与公开部署
默认情况下 GUI 监听 loopback。只有 `--host` 会改变监听器:它接受一个具体的本机 IPv4 或 IPv6 地址,不接受通配地址。非 loopback 监听器提供明文 HTTP,并在启动时警告,即使前置 HTTPS 的 `--public-url` 也不例外。Host/Origin 栅栏接受绑定 IP 本身;其他非 loopback authority 需要 `--trusted-host`。启动 token 交换与签名会话 cookie 认证每个 API 方法与 WebSocket 流。
默认情况下 GUI 只监听 loopback,只接受本机的连接。只有 `--host` 会改变监听器:它接受一个具体的本机 IPv4 或 IPv6 地址,不接受通配地址。未启用 TLS 的非 loopback 监听器提供明文 HTTP,并在启动时警告,即使前置 HTTPS 的 `--public-url` 也不例外。Host/Origin 栅栏接受绑定 IP 本身;其他非 loopback authority 需要 `--trusted-host`,因此远端浏览器要么经由剥离前缀的代理访问 GUI,要么通过以可信主机名呈现的端口转发客户端访问。启动 token 交换与签名会话 cookie 认证每个 API 方法与 WebSocket 流。
同时传入 `--tls-cert` 与 `--tls-key`,监听器便以 HTTPS 而非明文 HTTP 提供服务。两者各指定一个相对进程工作目录解析的文件:证书文件包含完整证书链,密钥文件为不带口令的 PEM 私钥。它们属于同一个设置——只提供一个会报用法错误,两个都不提供则保持明文 HTTP 默认值。文件缺失、不可读、为空,或不是有效的证书与密钥对时启动失败;绝不回退到 HTTP。监听器运行期间不会重新读取这些材料,因此更换证书需要重新加载监听器或重启进程。DSH 不签发、不续期也不监视任何证书:没有 ACME 客户端、没有自签名兜底,也没有重定向监听器。浏览器使用自己的信任库与主机名匹配,因此浏览器不信任的证书,或其 subject alternative name 未覆盖你所访问 authority 的证书,会在页面加载前失败。默认端口仍为 3080,且 `--tls-cert`/`--tls-key` 不跟随 `--public-url`:监听器的证书覆盖浏览器实际访问的 authority。TLS 不改变授权模型:Connection 仍认证每个请求并仍校验 Host/Origin,且只要接收请求的监听器提供 HTTPS,它所签发的会话 cookie 就带 `Secure`。
容器可以绑定其 Pod 地址,并公告前置的 ingress:
@@ -114,7 +116,7 @@ URL 行与浏览器交接都是就绪信号:监督方一观察到该行就发
|---|---|
| [`src/index.ts`](src/index.ts) | `web-app` 粘合插件:dist 解析、公告应用 URL、提示词段落、bash 变量、URL 行、浏览器交接 |
| [`src/public-url.ts`](src/public-url.ts) | 公告根的校验与尾斜杠归一化;供本地导入的叶子模块,不属于包 API |
| [`src/startup.ts`](src/startup.ts) | `web-startup` 提供方:`--host`、`--port`、`--public-url`、`--trusted-host`、`--no-open`、`--help` |
| [`src/startup.ts`](src/startup.ts) | `web-startup` 提供方:`--host`、`--port`、`--tls-cert`、`--tls-key`、`--public-url`、`--trusted-host`、`--no-open`、`--help` |
| [`cordis.patch.yml`](cordis.patch.yml) | Web patch:重述的基础值、Web 宿主行、浏览器名录、preset 注册表 |
| [`presets/`](presets) | 每个随发行版交付的 preset(`standard`、`ptc`、`minimal`、`cordis`)各一条 `@deepseek-ai/dsh-agent-preset` 声明,各自一个补丁文件 |
| [`tests/web-app.spec.ts`](tests/web-app.spec.ts) | dist index 锚定、公告 URL 发布与警告、提示词段落、就绪宣告发布 |
@@ -164,7 +166,8 @@ URL 行与浏览器交接都是就绪信号:监督方一观察到该行就发
这些限制告诉你在不常见的环境下会遇到什么——源码 checkout、SSH 会话或严格网络。它们是当前包约束,不是通用的浏览器对比或任务积压。
- **前端必须已构建**——源码 checkout 需要先运行 `pnpm run build`;dist 缺失时启动会以构建提示停止,且没有从源码直接服务的回退路径。
- **监听器不提供 TLS**——请用终止 TLS 的代理保护外部链路;HTTP 公告根会以明文发送凭据。
- **TLS 需自行启用且不代管**——只有传入 `--tls-cert` 与 `--tls-key`(完整证书链与不带口令的密钥)时监听器才提供 HTTPS;DSH 从不获取、续期或监视证书,更换文件需要重新加载监听器或重启进程。浏览器必须信任该证书,其名称必须覆盖你所访问的 authority,且证书与 `--public-url` 相互独立。
- **未启用 TLS 时外部链路是明文 HTTP**——请用终止 TLS 的代理保护它,不要在不受信任的网络上发送启动 URL。
- **只能观察到交接的启动**——GUI 只报告浏览器被请求打开,而不是它确实打开了;之后的浏览器退出永远不会上报,打印的 URL 是你的手动回退路径。
- **SSH 会话保留 URL 但跳过浏览器交接**——没有公告根时,打印的 URL 指向远端宿主机的绑定地址;SSH 客户端或编辑器必须暴露并打开本地转发地址。
- **`BROWSER` 覆盖只能来自环境**——被发现的 `.env` 不能设置 `BROWSER`;只有继承值能为自动交接选择可执行文件。
+1
View File
@@ -182,6 +182,7 @@
config:
host: !!js ctx.webStartup.host ?? '127.0.0.1'
port: !!js ctx.webStartup.port ?? 3080
tls: !!js ctx.webStartup.tls
compression: gzip
compressionLevel: 1
compressionThresholdBytes: 1024
+3 -4
View File
@@ -7,7 +7,7 @@
* harness-source and web-surface prompt sections, the bash-visible web runtime
* variable, the process-token URL line, and the default-browser handoff. An
* advertised `publicUrl` replaces the published bind-address URL. Non-loopback
* binds trigger a plain-HTTP exposure warning. App command-line values arrive
* HTTP binds trigger an exposure warning. App command-line values arrive
* through the `webStartup` service expressions in the bundle patch.
* @module @deepseek-ai/dsh-web-app
*/
@@ -117,7 +117,7 @@ function localWebUrl(ctx: Context): string {
throw new Error('web-app: webServer service missing while resolving Web runtime')
}
const host = advertisedBindHost(webServer.host)
return `http://${isIP(host) === 6 ? `[${host}]` : host}:${String(port)}`
return `${webServer.protocol}//${isIP(host) === 6 ? `[${host}]` : host}:${String(port)}`
}
/**
@@ -238,8 +238,7 @@ export function apply(ctx: Context, config: Config): void {
const publicUrl = config.publicUrl ?? undefined
const boundHost = ctx.webServer.host
if (publicUrl === undefined) advertisedBindHost(boundHost)
// The bound transport remains plain HTTP even when publicUrl uses HTTPS.
if (!isLoopbackHost(boundHost)) {
if (ctx.webServer.protocol === 'http:' && !isLoopbackHost(boundHost)) {
console.warn(`dsh web: listening on ${boundHost} over plain HTTP; restrict this port to a trusted proxy or network`)
}
// The bind URL belongs to this host. Under SSH, the operator reaches it
+20 -5
View File
@@ -1,6 +1,6 @@
/**
* The web app's command-line provider: it parses the `dsh --profile web` flag
* family (`--host`, `--port`, `--public-url`, `--trusted-host`, `--no-open`)
* family (`--host`, `--port`, `--public-url`, `--trusted-host`, `--no-open`, TLS)
* and its `--help` text, then provides the immutable values as
* {@link WEB_STARTUP_SERVICE}. Ordinary rows inject that service before
* reading it from lazy config.
@@ -10,7 +10,7 @@
import { Command } from 'commander'
import type { Context } from '@deepseek-ai/cordis'
import { parseCmdline } from '@deepseek-ai/dsh-cmdline'
import { isWildcardHost } from '@deepseek-ai/dsh-host-webserver'
import { isWildcardHost, type TlsConfig } from '@deepseek-ai/dsh-host-webserver'
import { parsePublicUrl } from './public-url.ts'
/** Stable Cordis plugin name. */
@@ -37,6 +37,8 @@ export interface WebStartupValues {
publicUrl?: string
/** Explicit `--trusted-host` authorities, in argument order. */
trustedHosts: string[]
/** Server certificate chain and unencrypted private key supplied together. */
tls?: TlsConfig
}
/** The web flag family, as commander parsed it. */
@@ -46,6 +48,8 @@ interface WebOptions {
port?: string
publicUrl?: string
trustedHost?: string[]
tlsCert?: string
tlsKey?: string
}
/**
@@ -62,6 +66,8 @@ function webCommand(): Command {
.option('--port <port>', 'listen port; pass 0 to let the OS pick a free one')
.option('--public-url <url>', 'advertise this HTTP(S) root in the printed, opened, web-surface, and DSH_WEB_URL forms; grants no trust')
.option('--trusted-host <authority...>', 'extra authority the /api browser-trust fence accepts (host or host:port; repeatable)')
.option('--tls-cert <file>', 'PEM server certificate chain; requires --tls-key')
.option('--tls-key <file>', 'unencrypted PEM private key; requires --tls-cert')
.addHelpText('after', `
Examples:
dsh --profile web serve on the composed host and port
@@ -70,14 +76,16 @@ Examples:
dsh --profile web --host 10.0.0.7 bind one local interface address
dsh --profile web --public-url https://app.example/ui/ --trusted-host app.example
advertise a prefix-stripping HTTPS proxy entry and admit its authority
dsh --profile web --tls-cert ./server-chain.pem --tls-key ./server-key.pem
serve HTTPS with an existing certificate and key
`)
}
/**
* Parse and provide the Web invocation as an ordinary Cordis service. The
* command's action publishes the flags this invocation named; a wildcard
* `--host`, non-numeric `--port`, or malformed `--public-url` is a usage error,
* so on rejection (and on `--help`) nothing is provided.
* command's action publishes the flags this invocation named. Invalid flags,
* including an unpaired TLS certificate or key, are usage errors; rejection
* and `--help` provide no service.
* @param ctx - plugin context carrying the command line.
*/
export function apply(ctx: Context): void {
@@ -90,6 +98,12 @@ export function apply(ctx: Context): void {
if (options.port !== undefined && !/^\d+$/.test(options.port)) {
program.error(`error: --port must be a number, got ${JSON.stringify(options.port)}`)
}
let tls: TlsConfig | undefined
if (options.tlsCert !== undefined && options.tlsKey !== undefined) {
tls = { certFile: options.tlsCert, keyFile: options.tlsKey }
} else if (options.tlsCert !== undefined || options.tlsKey !== undefined) {
program.error('error: --tls-cert and --tls-key must be supplied together')
}
if (options.publicUrl !== undefined) {
try {
parsePublicUrl(options.publicUrl, '--public-url')
@@ -102,6 +116,7 @@ export function apply(ctx: Context): void {
...options.host !== undefined && { host: options.host },
...options.port !== undefined && { port: Number(options.port) },
...options.publicUrl !== undefined && { publicUrl: options.publicUrl },
...tls !== undefined && { tls },
trustedHosts: options.trustedHost ?? [],
} satisfies WebStartupValues)
})
@@ -65,6 +65,7 @@ export const apply = ctx => globalThis.__webStartupApply(ctx)
' port: !!js ctx.webStartup.port ?? 3080',
' publicUrl: !!js ctx.webStartup.publicUrl',
' trustedHosts: !!js ctx.webStartup.trustedHosts',
' tls: !!js ctx.webStartup.tls',
'- id: provider',
` name: ${pathToFileURL(join(dir, 'provider.mjs')).href}`,
'',
@@ -100,12 +101,15 @@ describe('web command-line provider', () => {
'--port', '8080',
'--trusted-host', 'lab.internal', 'lab-2.internal',
'--trusted-host', '10.0.0.9',
'--tls-cert', 'fullchain.pem',
'--tls-key', 'private-key.pem',
])
expect(values).toEqual({
host: '127.0.0.1',
openBrowser: false,
port: 8080,
trustedHosts: ['lab.internal', 'lab-2.internal', '10.0.0.9'],
tls: { certFile: 'fullchain.pem', keyFile: 'private-key.pem' },
})
expect(observed.readerConfig).toEqual(values)
expect(observed.exits).toEqual([])
@@ -171,6 +175,14 @@ describe('web command-line provider', () => {
expect(observed.exits).toEqual([])
})
it.each(['--tls-cert', '--tls-key'])('rejects an unpaired %s before activating consumers', async (flag) => {
const { values, observed } = await bootProvider([flag, 'server.pem'])
expect(observed.out).toContain('--tls-cert and --tls-key must be supplied together')
expect(values).toBeUndefined()
expect(observed.readerConfig).toBeUndefined()
expect(observed.exits).toEqual([1])
})
it('rejects a malformed --public-url before the consumer activates', async () => {
// The parser's own suite owns the exhaustive spellings; the provider only
// has to fail the invocation before any consumer activates.
@@ -59,9 +59,10 @@ function stageDist(): string {
/** Web carrier without a network listener. */
function fakeHttpServer(host = '127.0.0.1'): { server: WebServer } {
const server: Pick<WebServer, 'host' | 'port' | 'registerFallback' | 'renderIndex'> = {
const server: Pick<WebServer, 'host' | 'port' | 'protocol' | 'registerFallback' | 'renderIndex'> = {
host,
port: 4567,
protocol: 'http:',
registerFallback: () => () => {},
renderIndex: (html: string) => html,
}
+4 -4
View File
@@ -18,8 +18,8 @@
en: 13b54969ff1d00a4
zh: 4d2ffff0445472ed
/deepseek-ai-dsh-client-connection/browser-authentication-and-request-trust:
en: 1c5011f44aff1b43
zh: 9916c4ff40d014ce
en: 44956be3cc11908a
zh: d518f765d58f3558
/deepseek-ai-dsh-client-connection/connection-generation:
en: 116f94e44c0d87e7
zh: 59ef48984154d858
@@ -30,8 +30,8 @@
en: ca75c51c89c2c9b0
zh: de54f3467ef3b9be
/deepseek-ai-dsh-client-connection/known-limitations-and-deferred-work:
en: 7fd70be262a6ddc4
zh: f698394f6a299f14
en: ad73c3b97f348ad1
zh: fec950f970112182
/deepseek-ai-dsh-client-connection/known-limitations-and-deferred-work/dev-note:
en: e67f633dc6fe7773
zh: e88aeb617ffb74ea
+5 -3
View File
@@ -38,9 +38,11 @@ The browser uses HTTP POST for Remote unary calls. API Gateway owns the `/api/re
Every Host RPC method and WebSocket stream requires one browser session; there is no method-specific loopback tier. Each process mints a random launch token. `dsh-web-app` prints and opens its application URL with `?token=...`, preserving the caller's authority and mount; `frontend-static` delegates root and index requests to `ctx.connection.authorizeIndex`, which accepts that token only on `GET /`, writes an authority-bound signed cookie, and redirects to clean `./`, which drops the token and keeps the request's directory. A missing, expired, malformed, or wrong-authority cookie returns 401 before RPC dispatch. Static assets remain public. The HTTP carrier accepts no query token outside the root exchange and no Authorization-header token.
The cookie signing secret is the owner-scoped `client-connection/browser-session` grant record in `ctx.credentials`. The local provider persists it in `$DSH_HOME/.credentials.yaml`; `BrowserAuth` loads or creates the record during Connection activation and retains the secret in memory, so request authentication is synchronous. Deleting or replacing the record takes effect on the next Connection activation. Cookies carry an absolute issue/expiry interval, defaulting to 30 days through `cookieMaxAgeDays`, and bind the normalized hostname plus port in both their deterministic name and signed payload. They are host-only, `Path=/`, `HttpOnly`, and `SameSite=Strict`; they omit `Secure` because the shipped server uses plain HTTP.
The cookie signing secret is the owner-scoped `client-connection/browser-session` grant record in `ctx.credentials`. The local provider persists it in `$DSH_HOME/.credentials.yaml`; `BrowserAuth` loads or creates the record during Connection activation and retains the secret in memory, so request authentication is synchronous. Deleting or replacing the record takes effect on the next Connection activation. Cookies carry an absolute issue/expiry interval, defaulting to 30 days through `cookieMaxAgeDays`, and bind the normalized hostname plus port in both their deterministic name and signed payload. They are host-only, `Path=/`, `HttpOnly`, and `SameSite=Strict`, and carry `Secure` exactly when the listener that received the index request serves TLS; only that listener's own protocol decides, never a `publicUrl` announcement or a forwarded header.
Before authentication, every request passes `src/api-request-trust.ts`. Its `Host` must be loopback, equal the listener's bind IP literal on any port, or match a `trustedHosts` entry: exact on `host:port`, any port on port-less entries, both sides WHATWG-normalized. An attached `Origin` must equal that Host and `sec-fetch-site: cross-site` is refused. Malformed configured authorities fail plugin load. `allowsRemoteAuthorities` reports whether `trustedHosts` permits a non-loopback authority; accepting the bind IP does not change that list. These checks defend DNS rebinding and cross-site browser requests; they never establish identity. A failed Host/Origin check returns 403, while a trusted but unauthenticated request returns 401. `dsh web --host 0.0.0.0` is unsupported. Decision records: [browser request trust](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md) and [browser token authentication](../../../.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.md).
HTTPS cookie names and signed audiences include the `https:` scheme, so HTTP cookies cannot authenticate to a TLS listener even on the same hostname and port. Default ports normalize under the listener's scheme. Existing HTTP cookie audiences remain unchanged.
Before authentication, every request passes `src/api-request-trust.ts`. Its `Host` must be loopback, equal the listener's bind IP literal on any port, or match a `trustedHosts` entry: exact on `host:port`, any port on port-less entries. Host and configured entries use the listener's scheme for WHATWG default-port normalization, not a forwarded header or `Origin`. An attached HTTP(S) `Origin` must name the same authority, with Host parsed under that Origin's scheme to support HTTPS proxies over HTTP upstreams; `sec-fetch-site: cross-site` is refused. Malformed configured authorities fail plugin load. `allowsRemoteAuthorities` reports whether `trustedHosts` permits a non-loopback authority; accepting the bind IP does not change that list. These checks defend DNS rebinding and cross-site browser requests; they never establish identity. A failed Host/Origin check returns 403, while a trusted but unauthenticated request returns 401. `dsh web --host 0.0.0.0` is unsupported. Decision records: [browser request trust](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md) and [browser token authentication](../../../.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.md).
Every admitted request speaks for one Peer, the operator. `ctx.connection.operator` is that `PeerScope`: its `ctx` is a Cordis scope that owns connection-lifetime registrations and is disposed with the Connection. `ctx.connection.admit(request)` runs the trust and authentication checks and answers with the rejection status or the operator; the `/api` route and the Gateway's WebSocket upgrade admit through it, and every RPC handler receives the Peer of its call. `OperatorPeer` is exported so a composition without Connection, such as the Gateway's in-process carrier, owns an operator scope with the same contract.
@@ -72,7 +74,7 @@ None; this package neither assembles nor sends a provider request.
<a id="known-limitations-and-deferred-work"></a>
- **Buffered `/api` routes retain each request body in memory** — `maxRequestBodyBytes` (default 300 MiB, sized for the default 200 MiB aggregate image limit after base64 expansion plus envelope headroom) bounds ordinary image and RPC envelopes. Opt-in streaming routes receive backpressured chunks and bypass the aggregate cap; route implementations own persistence, cancellation, and any storage quota.
- **The browser cookie is not marked `Secure`** — the shipped transport is plain HTTP, so exposing the same authority over a network can expose the bearer cookie in transit.
- **A plain-HTTP listener exposes the bearer cookie in transit** — such a deployment cannot mark the browser cookie `Secure`, so serving the same authority over a network can leak the session cookie; configure TLS on the Web carrier ([`dsh-host-webserver`](../../host/webserver/README.md)) to have the cookie marked `Secure` and confined to TLS.
- **There is no logout operation** — clearing the browser cookie ends one browser session; deleting the owner credential record and restarting `dsh` revokes every session.
+5 -3
View File
@@ -38,9 +38,11 @@ kind: "package-reference"
每个 Host RPC 方法和 WebSocket 流都要求一个浏览器会话,不存在按方法区分的 loopback 层。每个进程生成一个随机启动令牌。`dsh-web-app` 打印并打开带 `?token=...` 的应用 URL,保留调用方的 authority 与挂载;`frontend-static` 把根路径和 index 请求交给 `ctx.connection.authorizeIndex`,后者只在 `GET /` 接受该令牌,写入绑定 authority 的签名 cookie,再重定向到干净的 `./`,移除令牌并保留请求目录。缺失、过期、畸形或 authority 不匹配的 cookie 会在 RPC 分发前得到 401。静态资源保持公开。HTTP 载体不在根路径交换之外接受 query token,也不接受 Authorization header token。
cookie 签名密钥是 `ctx.credentials` 中由 `client-connection/browser-session` 拥有的 grant 记录。本地提供方把它持久化到 `$DSH_HOME/.credentials.yaml`;`BrowserAuth` 在 Connection 激活期间加载或创建该记录,并把密钥留在内存中,因此请求认证同步执行。删除或替换该记录会在下一次 Connection 激活时生效。cookie 携带绝对签发与过期区间,`cookieMaxAgeDays` 默认设为 30 天,并在确定性名称与签名 payload 中同时绑定规范化 hostname 和 port。它是 host-only、`Path=/`、`HttpOnly`、`SameSite=Strict`;随附服务器使用明文 HTTP,因此不设置 `Secure`。
cookie 签名密钥是 `ctx.credentials` 中由 `client-connection/browser-session` 拥有的 grant 记录。本地提供方把它持久化到 `$DSH_HOME/.credentials.yaml`;`BrowserAuth` 在 Connection 激活期间加载或创建该记录,并把密钥留在内存中,因此请求认证同步执行。删除或替换该记录会在下一次 Connection 激活时生效。cookie 携带绝对签发与过期区间,`cookieMaxAgeDays` 默认设为 30 天,并在确定性名称与签名 payload 中同时绑定规范化 hostname 和 port。它是 host-only、`Path=/`、`HttpOnly`、`SameSite=Strict`;收到该 index 请求的监听器提供 TLS 时才带上 `Secure`,且只由该监听器自身的协议决定,`publicUrl` 声明与转发头都不参与判断。
认证之前,每个请求都经过 `src/api-request-trust.ts`。其 `Host` 必须是 loopback、等于监听器的绑定 IP 字面量(端口不限),或与 `trustedHosts` 条目匹配:带端口的 `host:port` 精确匹配,不带端口的条目匹配任意端口,两侧均经 WHATWG 归一化。若附带 `Origin`,它必须等于该 Host;`sec-fetch-site: cross-site` 一律拒绝。畸形配置 authority 会让插件加载失败。`allowsRemoteAuthorities` 报告 `trustedHosts` 是否允许非 loopback authority;接受绑定 IP 不会改变该列表。这些检查防御 DNS rebinding 与跨站浏览器请求,绝不建立身份。Host/Origin 校验失败返回 403;Host 可信但未认证的请求返回 401。`dsh web --host 0.0.0.0` 不受支持。决策记录:[浏览器请求信任](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.zh.md)与[浏览器令牌认证](../../../.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.zh.md)。
HTTPS cookie 名称与签名 audience 包含 `https:` scheme,因此即使 hostname 和 port 相同,HTTP cookie 也无法通过 TLS 监听器的认证。默认端口按监听器的 scheme 归一化。既有 HTTP cookie audience 保持不变。
认证之前,每个请求都经过 `src/api-request-trust.ts`。其 `Host` 必须是 loopback、等于监听器的绑定 IP 字面量(端口不限),或与 `trustedHosts` 条目匹配:带端口的 `host:port` 精确匹配,不带端口的条目匹配任意端口。Host 与配置条目按监听器的 scheme 做 WHATWG 默认端口归一化,不采信转发 header 或 `Origin`。附带的 HTTP(S) `Origin` 必须指向同一 authority;此时按 Origin 的 scheme 解析 Host,以支持 HTTPS 代理转发到 HTTP 上游;`sec-fetch-site: cross-site` 一律拒绝。畸形配置 authority 会让插件加载失败。`allowsRemoteAuthorities` 报告 `trustedHosts` 是否允许非 loopback authority;接受绑定 IP 不会改变该列表。这些检查防御 DNS rebinding 与跨站浏览器请求,绝不建立身份。Host/Origin 校验失败返回 403;Host 可信但未认证的请求返回 401。`dsh web --host 0.0.0.0` 不受支持。决策记录:[浏览器请求信任](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.zh.md)与[浏览器令牌认证](../../../.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.zh.md)。
每个被接纳的请求都代表同一个 Peer——操作者。`ctx.connection.operator` 就是这个 `PeerScope`:其 `ctx` 是拥有连接期注册的 Cordis scope,随 Connection 一起释放。`ctx.connection.admit(request)` 执行信任与认证检查,以拒绝状态或操作者作答;`/api` 路由与 Gateway 的 WebSocket 升级都经它接纳,每个 RPC 处理器都收到本次调用的 Peer。`OperatorPeer` 对外导出,供没有 Connection 的组合(例如 Gateway 的进程内载体)以同一约定拥有一个操作者 scope。
@@ -72,7 +74,7 @@ API Gateway Client 把内部 `$events` 逻辑流注册为唯一 generation sourc
<a id="known-limitations-and-deferred-work"></a>
- **缓冲型 `/api` 路由会把每个请求体保留在内存里**:`maxRequestBodyBytes`(默认 300 MiB,按默认 200 MiB 图片总量上限经 base64 膨胀加信封余量得出)限制普通图片与 RPC 信封。显式启用的流式路由接收带背压的分块并绕过总量上限;路由实现负责持久化、取消与存储配额。
- **浏览器 cookie 不带 `Secure`**:随产品提供的传输方式是明文 HTTP;若把同一 authority 暴露到网络上,bearer cookie 可能在传输中泄露。
- **明文 HTTP 监听器会在传输中暴露 bearer cookie**:此类部署无法给浏览器 cookie 加 `Secure`;若把同一 authority 暴露到网络上,会话 cookie 可能泄露;在 Web 载体([`dsh-host-webserver`](../../host/webserver/README.zh.md))上配置 TLS 即可让 cookie 带上 `Secure` 并只经 TLS 发送。
- **没有 logout 操作**:清除浏览器 cookie 会结束单个浏览器会话;删除 owner 凭据记录并重启 `dsh` 会撤销全部会话。
@@ -21,11 +21,16 @@ function header(headers: ConnectionTrustRequest['headers'], name: string): strin
return typeof value === 'string' ? value : undefined
}
/** Normalized URL of a Host-header authority (hostname lowercased, default port stripped, IPv6 bracketed), or undefined when unparsable. */
function parseAuthority(authority: string): URL | undefined {
/**
* Normalized URL of a bare authority (hostname lowercased, default port
* stripped, IPv6 bracketed), or undefined when unparsable.
* @param authority - `host` or `host:port` text.
* @param scheme - special scheme to parse under; its default port is the one stripped.
*/
function parseAuthority(authority: string, scheme: 'http:' | 'https:' = 'http:'): URL | undefined {
try {
// http: is a WHATWG "special scheme": parsing yields a non-empty hostname or throws.
return new URL(`http://${authority}`)
// http: and https: are WHATWG "special schemes": parsing yields a non-empty hostname or throws.
return new URL(`${scheme}//${authority}`)
} catch {
return undefined
}
@@ -59,8 +64,7 @@ export function assertTrustedAuthority(entry: string): void {
* shapes like `host:port ` as port-less.
*/
function canonicalAuthority(entry: string, entryUrl: URL): string {
// An authority that parsed under http cannot fail under https.
const port = entryUrl.port !== '' ? entryUrl.port : new URL(`https://${entry}`).port
const port = entryUrl.port !== '' ? entryUrl.port : new URL(`${entryUrl.protocol === 'http:' ? 'https:' : 'http:'}//${entry}`).port
return port === '' ? entryUrl.hostname : `${entryUrl.hostname}:${port}`
}
@@ -68,11 +72,11 @@ function canonicalAuthority(entry: string, entryUrl: URL): string {
* Whether the request authority matches a `trustedHosts` entry. An entry with
* an explicit port matches that exact authority; a port-less entry matches the
* hostname on any port. Both sides compare through WHATWG normalization,
* so case and a redundant `:80` never decide trust.
* so case and a redundant scheme-default port never decide trust.
*/
function isTrustedAuthority(hostUrl: URL, trustedHosts: readonly string[]): boolean {
function isTrustedAuthority(hostUrl: URL, trustedHosts: readonly string[], protocol: 'http:' | 'https:'): boolean {
return trustedHosts.some((entry) => {
const entryUrl = parseAuthority(entry)
const entryUrl = parseAuthority(entry, protocol)
if (entryUrl === undefined) return false
return canonicalAuthority(entry, entryUrl) === entryUrl.hostname
? entryUrl.hostname === hostUrl.hostname
@@ -111,12 +115,14 @@ function isBindAddressAuthority(hostUrl: URL, bindHost: string | undefined): boo
* @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.
* @param bindHost - the listener's own bind IP literal, accepted on any port independently of `trustedHosts`.
* @param protocol - listener protocol for default Host ports; HTTP when no Web carrier is mounted.
* @returns true when the Host is ours (loopback, the bind address, or trusted) and any attached browser markers are same-origin.
*/
export function isTrustedApiRequest(
request: ConnectionTrustRequest,
trustedHosts: readonly string[],
bindHost?: string,
protocol: 'http:' | 'https:' = 'http:',
): boolean {
// Host fence (DNS-rebinding defense), applied to every request: the browser
// fills Host from the URL it believes it is talking to, so a rebound page
@@ -127,10 +133,10 @@ export function isTrustedApiRequest(
// by the rebound page.
const host = header(request.headers, 'host')
if (host === undefined) return false
const hostUrl = parseAuthority(host)
const hostUrl = parseAuthority(host, protocol)
if (hostUrl === undefined) return false
if (!isLoopbackHostname(hostUrl.hostname)
&& !isTrustedAuthority(hostUrl, trustedHosts)
&& !isTrustedAuthority(hostUrl, trustedHosts, protocol)
&& !isBindAddressAuthority(hostUrl, bindHost)) return false
// Cross-site fence: modern browsers label the initiator relationship on
// every fetch; an explicit cross-site marker is refused regardless of Origin.
@@ -141,9 +147,30 @@ export function isTrustedApiRequest(
// literal "null" (sandboxed iframes, file: pages) is an opaque origin, refused.
const origin = header(request.headers, 'origin')
if (origin === undefined) return true
return isSameAuthorityOrigin(origin, host)
}
/**
* Compare Host under the Origin's HTTP(S) scheme so default ports normalize
* consistently. The transport may remain HTTP behind an HTTPS proxy.
* @param origin - verbatim Origin header value.
* @param host - verbatim Host header value already accepted by the Host fence.
* @returns true only when the Origin is this listener's own `http(s)` authority.
*/
function isSameAuthorityOrigin(origin: string, host: string): boolean {
let originUrl: URL
try {
return new URL(origin).host === hostUrl.host
originUrl = new URL(origin)
} catch {
return false
}
// Only http(s) can name this listener: the opaque `null` already threw above,
// and every other scheme (file:, ftp:) fails here rather than matching by
// hostname alone.
const scheme = originUrl.protocol === 'https:' ? 'https:'
: originUrl.protocol === 'http:' ? 'http:'
: undefined
if (scheme === undefined) return false
const hostUrl = parseAuthority(host, scheme)
return hostUrl !== undefined && originUrl.host === hostUrl.host
}
+25 -12
View File
@@ -66,12 +66,13 @@ function header(
return typeof value === 'string' ? value : undefined
}
/** Canonical request authority used as the cookie name and signed audience. */
function requestAuthority(headers: ConnectionTrustRequest['headers']): string | undefined {
/** Protocol-scoped cookie audience; HTTP retains its existing authority-only spelling. */
function requestAudience(headers: ConnectionTrustRequest['headers'], secure: boolean): string | undefined {
const host = header(headers, 'host')
if (host === undefined) return undefined
try {
return new URL(`http://${host}`).host
const url = new URL(`${secure ? 'https:' : 'http:'}//${host}`)
return secure ? url.origin : url.host
} catch {
return undefined
}
@@ -118,8 +119,15 @@ function cookieValue(headerValue: string, name: string): string | undefined {
}
/** Serialize the fixed browser-session attributes; generated names and values are cookie-safe base64url. */
function sessionCookie(name: string, value: string, expiresAt: number, maxAgeSeconds: number): string {
return `${name}=${value}; Max-Age=${String(maxAgeSeconds)}; Path=/; Expires=${new Date(expiresAt).toUTCString()}; HttpOnly; SameSite=Strict`
function sessionCookie(
name: string,
value: string,
expiresAt: number,
maxAgeSeconds: number,
secure: boolean,
): string {
const attributes = `${name}=${value}; Max-Age=${String(maxAgeSeconds)}; Path=/; Expires=${new Date(expiresAt).toUTCString()}; HttpOnly; SameSite=Strict`
return secure ? `${attributes}; Secure` : attributes
}
function signature(secret: Buffer, body: string): Buffer {
@@ -233,14 +241,18 @@ export class BrowserAuth {
* 401 response.
* @param req - incoming root or configured-index request.
* @param res - response owned when this method returns false.
* @param secure - whether the listener receiving this request serves TLS; the
* minted cookie carries `Secure` when it does. Only that listener's own
* protocol decides, never a `publicUrl` announcement or a forwarded header:
* those describe a hop this process does not terminate.
* @returns true only when the caller may serve index.html.
*/
authorizeIndex(req: ConnectionIndexRequest, res: ConnectionIndexResponse): boolean {
authorizeIndex(req: ConnectionIndexRequest, res: ConnectionIndexResponse, secure = false): boolean {
/* v8 ignore next -- node:http always supplies url on server requests. */
const url = new URL(req.url ?? '/', 'http://dsh.invalid')
const tokens = url.searchParams.getAll(TOKEN_QUERY)
if (tokens.length > 0) {
const authority = requestAuthority(req.headers)
const authority = requestAudience(req.headers, secure)
if (req.method === 'GET' && url.pathname === '/' && tokens.length === 1
&& authority !== undefined && tokenMatches(tokens.join(''), this.launchToken)) {
const issuedAt = Date.now()
@@ -256,13 +268,13 @@ export class BrowserAuth {
'location': './',
'referrer-policy': 'no-referrer',
'set-cookie': sessionCookie(
cookieName(authority), value, expiresAt, Math.floor(this.maxAgeMilliseconds / 1000),
cookieName(authority), value, expiresAt, Math.floor(this.maxAgeMilliseconds / 1000), secure,
),
})
res.end()
return false
}
if (req.method === 'GET' && url.pathname === '/' && this.isAuthenticated(req)) {
if (req.method === 'GET' && url.pathname === '/' && this.isAuthenticated(req, secure)) {
res.writeHead(303, {
'cache-control': 'no-store',
'location': './',
@@ -274,7 +286,7 @@ export class BrowserAuth {
this.writeUnauthorized(req, res)
return false
}
if (this.isAuthenticated(req)) return true
if (this.isAuthenticated(req, secure)) return true
this.writeUnauthorized(req, res)
return false
}
@@ -282,10 +294,11 @@ export class BrowserAuth {
/**
* Verify the authority-bound browser cookie on a Host request.
* @param request - request headers carrying Host and Cookie.
* @param secure - whether the receiving listener uses TLS, including its default port.
* @returns true only for an unexpired cookie signed by this activation's loaded secret.
*/
isAuthenticated(request: ConnectionTrustRequest): boolean {
const authority = requestAuthority(request.headers)
isAuthenticated(request: ConnectionTrustRequest, secure = false): boolean {
const authority = requestAudience(request.headers, secure)
const rawCookie = header(request.headers, 'cookie')
if (authority === undefined || rawCookie === undefined) return false
const value = cookieValue(rawCookie, cookieName(authority))
+7 -9
View File
@@ -103,15 +103,11 @@ export class HostConnectionService extends Service implements HostConnectionHand
}
}
/** Active Web carrier's bind address; absent when no Web carrier is mounted. */
private get bindHost(): string | undefined {
return this.ctx.get('webServer')?.host
}
/** Apply the Host/Origin fence, then browser authentication. */
requestRejection(request: ConnectionTrustRequest): ConnectionRequestRejection {
if (!isTrustedApiRequest(request, this.trustedHosts, this.bindHost)) return 403
return this.browserAuth.isAuthenticated(request) ? undefined : 401
const carrier = this.ctx.get('webServer')
if (!isTrustedApiRequest(request, this.trustedHosts, carrier?.host, carrier?.protocol ?? 'http:')) return 403
return this.browserAuth.isAuthenticated(request, carrier?.protocol === 'https:') ? undefined : 401
}
/** A request that passes the fence and authentication speaks for the operator. */
@@ -120,9 +116,11 @@ export class HostConnectionService extends Service implements HostConnectionHand
return rejection === undefined ? { peer: this.operator } : { rejection }
}
/** Authenticate an index request through the process-token exchange or cookie. */
/**
* Authenticate an index request; native TLS listeners mint Secure cookies.
*/
authorizeIndex(request: ConnectionIndexRequest, response: ConnectionIndexResponse): boolean {
return this.browserAuth.authorizeIndex(request, response)
return this.browserAuth.authorizeIndex(request, response, this.ctx.get('webServer')?.protocol === 'https:')
}
/** Add this process's launch token to the clean application URL. */
+2
View File
@@ -229,6 +229,8 @@ export interface HostConnectionHandle {
/**
* Authenticate one frontend index request, owning a token redirect or 401.
* The cookie this mints is `Secure` when the mounted Web carrier serves TLS,
* which only that listener's protocol decides.
* @param request - root or configured-index HTTP request.
* @param response - response owned when the result is false.
* @returns true only when the frontend may serve index.html.
@@ -53,6 +53,32 @@ describe('isTrustedApiRequest', () => {
}), [])).toBe(false)
})
it('compares Origin and Host under the Origin scheme, so HTTPS default ports normalize like HTTP ones', () => {
// A browser omits the default port it dialed, while a proxy or a direct
// client may still write it in Host: both spellings name one authority.
expect(isTrustedApiRequest(request({ host: 'harness.internal:443', origin: 'https://harness.internal' }), ['harness.internal'])).toBe(true)
expect(isTrustedApiRequest(request({ host: 'harness.internal:443', origin: 'https://harness.internal:443' }), ['harness.internal'])).toBe(true)
expect(isTrustedApiRequest(request({ host: 'harness.internal', origin: 'https://harness.internal:443' }), ['harness.internal'])).toBe(true)
expect(isTrustedApiRequest(request({ host: 'harness.internal:80', origin: 'http://harness.internal' }), ['harness.internal'])).toBe(true)
// A TLS-terminating proxy forwards the browser's https Origin to a plain-HTTP upstream.
expect(isTrustedApiRequest(request({ host: 'harness.internal', origin: 'https://harness.internal' }), ['harness.internal'])).toBe(true)
// A real hostname or port difference still decides, in both directions.
expect(isTrustedApiRequest(request({ host: 'harness.internal:443', origin: 'https://harness.internal:444' }), ['harness.internal'])).toBe(false)
expect(isTrustedApiRequest(request({ host: 'harness.internal:80', origin: 'https://harness.internal' }), ['harness.internal'])).toBe(false)
expect(isTrustedApiRequest(request({ host: 'harness.internal:443', origin: 'http://harness.internal' }), ['harness.internal'])).toBe(false)
expect(isTrustedApiRequest(request({ host: 'harness.internal:443', origin: 'https://evil.example' }), ['harness.internal'])).toBe(false)
// Cross-site labelling outranks a purely spelling-level match.
expect(isTrustedApiRequest(request({
host: 'harness.internal:443',
origin: 'https://harness.internal',
'sec-fetch-site': 'cross-site',
}), ['harness.internal'])).toBe(false)
// Only http(s) names this listener; opaque and other schemes never compare as an authority.
for (const origin of ['ftp://harness.internal', 'file://harness.internal', 'ws://harness.internal', 'null']) {
expect(isTrustedApiRequest(request({ host: 'harness.internal', origin }), ['harness.internal']), origin).toBe(false)
}
})
it('accepts a declared public authority: exact on host:port entries, any port on port-less entries', () => {
const headers = { host: 'harness.internal:3080', origin: 'http://harness.internal:3080' }
expect(isTrustedApiRequest(request(headers), ['harness.internal:3080'])).toBe(true)
@@ -76,11 +76,12 @@ function request(url: string, authority = '127.0.0.1:3080', init?: {
function exchange(
auth: BrowserAuth,
authority = '127.0.0.1:3080',
secure = false,
): { cookie: string; launchUrl: string; state: ResponseState } {
const launchUrl = auth.authenticatedUrl(`http://${authority}`)
const target = new URL(launchUrl)
const res = response()
expect(auth.authorizeIndex(request(`${target.pathname}${target.search}`, authority), res.value)).toBe(false)
expect(auth.authorizeIndex(request(`${target.pathname}${target.search}`, authority), res.value, secure)).toBe(false)
const setCookie = res.state.headers?.['set-cookie']
if (setCookie === undefined) throw new Error('token exchange did not set a cookie')
return { cookie: setCookie.split(';', 1)[0]!, launchUrl, state: res.state }
@@ -140,6 +141,34 @@ describe('BrowserAuth', () => {
})
})
it('keeps HTTPS port 443 distinct from HTTP port 80 with one signing secret', async () => {
const auth = await createAuth(new RecordCredentials())
const plain = exchange(auth, 'harness.example')
const tls = exchange(auth, 'harness.example', true)
expect(auth.isAuthenticated(request('/', 'harness.example', { cookie: plain.cookie }), true)).toBe(false)
expect(auth.isAuthenticated(request('/', 'harness.example', { cookie: tls.cookie }), true)).toBe(true)
expect(auth.isAuthenticated(request('/', 'harness.example:443', { cookie: tls.cookie }), true)).toBe(true)
expect(auth.isAuthenticated(request('/', 'harness.example', { cookie: tls.cookie }))).toBe(false)
})
it('rejects HTTP cookie replay after enabling TLS on the same authority', async () => {
const store = new RecordCredentials()
const authority = 'harness.example:3080'
const plain = exchange(await createAuth(store), authority)
const restarted = await createAuth(store)
const denied = response()
expect(restarted.authorizeIndex(request('/', authority, { cookie: plain.cookie }), denied.value, true)).toBe(false)
expect(denied.state.status).toBe(401)
const tls = exchange(restarted, authority, true)
expect(tls.state.headers?.['set-cookie']).toContain('; Secure')
expect(restarted.isAuthenticated(request('/', authority, { cookie: tls.cookie }), true)).toBe(true)
expect(restarted.isAuthenticated(request('/', authority, { cookie: tls.cookie }))).toBe(false)
const tlsName = tls.cookie.slice(0, tls.cookie.indexOf('='))
const plainValue = plain.cookie.slice(plain.cookie.indexOf('=') + 1)
expect(restarted.isAuthenticated(request('/', authority, { cookie: `${tlsName}=${plainValue}` }), true)).toBe(false)
})
it('preserves the caller authority and mount while adding only this process token', async () => {
const auth = await createAuth(new RecordCredentials())
const mounted = new URL(auth.authenticatedUrl('https://gateway.example/tools/dsh/'))
@@ -26,9 +26,11 @@ function fakeHttpServer(
routes: WebRoute[],
upgrades: WebUpgradeRoute[],
host = '127.0.0.1',
): Pick<WebServer, 'register' | 'registerUpgrade' | 'tapIndex' | 'port' | 'host'> {
protocol: WebServer['protocol'] = 'http:',
): Pick<WebServer, 'register' | 'registerUpgrade' | 'tapIndex' | 'port' | 'host' | 'protocol'> {
return {
host,
protocol,
register(route) {
if (routes.some(candidate => candidate.kind === route.kind && candidate.path === route.path)) {
throw new Error(`duplicate route ${route.path}`)
@@ -92,7 +94,7 @@ function fakeResponse(): {
return { response, state }
}
async function mounted(config?: ConnectionConfig): Promise<{
async function mounted(config?: ConnectionConfig, protocol: WebServer['protocol'] = 'http:'): Promise<{
ctx: Context
routes: WebRoute[]
upgrades: WebUpgradeRoute[]
@@ -103,7 +105,7 @@ async function mounted(config?: ConnectionConfig): Promise<{
const routes: WebRoute[] = []
const upgrades: WebUpgradeRoute[] = []
provideBrowserCredentials(ctx)
ctx.provide('webServer', fakeHttpServer(routes, upgrades) as WebServer)
ctx.provide('webServer', fakeHttpServer(routes, upgrades, '127.0.0.1', protocol) as WebServer)
const fiber = ctx.plugin({ inject: [...inject], apply }, config)
await fiber.await()
return {
@@ -221,6 +223,40 @@ describe('connection node half', () => {
}
})
it('uses the TLS default port for markerless exact-authority grants', async () => {
const { connection, dispose } = await mounted({ trustedHosts: ['tls.example:443', 'plain.example:80'] }, 'https:')
try {
expect(connection.requestRejection(fakeRequest({ host: 'tls.example' }))).toBe(401)
expect(connection.requestRejection(fakeRequest({ host: 'plain.example' }))).toBe(403)
expect(connection.requestRejection(fakeRequest({ host: 'tls.example:444' }))).toBe(403)
} finally {
await dispose()
}
})
it('marks the minted browser cookie Secure from the mounted listener protocol', async () => {
for (const [protocol, secure] of [['http:', false], ['https:', true]] as const) {
const { connection, dispose } = await mounted(undefined, protocol)
try {
const url = new URL(connection.authenticatedUrl('http://127.0.0.1:3080'))
const exchanged = fakeResponse()
connection.authorizeIndex(
fakeRequest({ host: '127.0.0.1:3080', 'x-forwarded-proto': secure ? 'http' : 'https' }, `${url.pathname}${url.search}`),
exchanged.response,
)
const setCookie = exchanged.state.headers?.['set-cookie']
expect(setCookie?.endsWith('; Secure'), protocol).toBe(secure)
if (setCookie === undefined) throw new Error('browser token exchange did not set a cookie')
expect(connection.requestRejection(fakeRequest({
host: '127.0.0.1:3080',
cookie: setCookie.split(';', 1)[0]!,
}))).toBeUndefined()
} finally {
await dispose()
}
}
})
it('injects validated browser recovery timing and withdraws it on disposal', async () => {
const { ctx, dispose } = await mounted({ recovery: { generationReadyTimeoutMs: 25_000 } })
try {
@@ -24,8 +24,8 @@
en: ca75c51c89c2c9b0
zh: 64715dc7e6d02f71
/deepseek-ai-dsh-experimental-webworker-runtime/known-limitations-and-deferred-work:
en: dd2469d07c86127a
zh: efa1be38e57bdd1e
en: 1bc41bcc7a7e065e
zh: 9e9558ff06c508bc
/deepseek-ai-dsh-experimental-webworker-runtime/known-limitations-and-deferred-work/dev-note:
en: e67f633dc6fe7773
zh: e88aeb617ffb74ea
@@ -54,6 +54,7 @@ None; this package neither assembles nor sends a provider request.
- **The worker composition writes plaintext session logs** (`compression: 'none'` boot patch): it carries no Zstandard codec, so exported logs are `.jsonl`, never `.jsonl.zstd`.
- **`node:dns/promises`, `node:vm`, `node:net`, `node:sqlite`, `node:worker_threads` are structural stubs**: every call reports its refusal on the console and throws. Rows needing native DNS, a real process, or realm isolation cannot run here.
- **`node:assert/strict` supports truthiness assertions only**: for falsy values, the callable default and `ok` throw `ERR_ASSERTION` with the supplied message or generic text, or rethrow a supplied Error unchanged. Other assertion APIs are absent.
- **Native TLS listeners require a Node Host**: the worker's `node:https.createServer` rejects activation rather than substituting plaintext. The preview's internal HTTP tunnel does not bind sockets; browser-facing HTTPS belongs to its deployment server.
- **Desktop product telemetry is unavailable**: `got.post` reports an explicit worker-host refusal. Got and its Node HTTP dependencies are excluded from the browser image; Desktop reporting remains disabled in the preview composition.
- **Host package commands are unavailable**: `execa` reports an explicit worker-host refusal; the preview cannot run pnpm, install plugins, or install native dependencies.
- **PTC Node programs are unavailable**: the process shim exposes `/dsh/bin/node` as its executable identity so the provider can activate, but the Worker has neither a Node executable nor `stripTypeScriptTypes`. Program execution fails before launching a child.
@@ -54,6 +54,7 @@ kind: "package-library"
- **worker 组合写明文会话日志**(`compression: 'none'` boot patch):不带 Zstandard 编解码器,导出日志是 `.jsonl`,不会是 `.jsonl.zstd`。
- **`node:dns/promises`、`node:vm`、`node:net`、`node:sqlite`、`node:worker_threads` 是结构化 stub**:每次调用在 console 报告拒绝并抛出。需要原生 DNS、真进程或真 realm 隔离的行在此无法运行。
- **`node:assert/strict` 仅支持真值断言**:对于假值,可调用的默认导出和 `ok` 使用传入的消息或通用文本抛出 `ERR_ASSERTION`,或原样抛出传入的 Error。其他断言 API 未提供。
- **原生 TLS 监听器需要 Node Host**:worker 的 `node:https.createServer` 会拒绝激活,不会替换成明文。预览内部的 HTTP 隧道不绑定套接字;面向浏览器的 HTTPS 由部署服务器提供。
- **桌面产品埋点不可用**:`got.post` 明确报告 worker host 不支持该调用。浏览器镜像不包含 Got 及其 Node HTTP 依赖;预览组合不启用桌面上报。
- **宿主包管理命令不可用**:`execa` 明确报告 worker host 不支持该调用;预览无法运行 pnpm、安装插件或安装原生依赖。
- **PTC Node 程序不可用**:process shim 用 `/dsh/bin/node` 表示可执行文件身份,使 provider 能够激活,但 Worker 既没有 Node 可执行文件,也没有 `stripTypeScriptTypes`。程序执行会在启动子进程前失败。
@@ -59,6 +59,7 @@ export const MODULE_PROXIES: Record<string, string> = {
// the VFS, because a browser worker has no processes to fork.
'node:child_process': './node/builtin_modules/implemented/child_process.ts',
// Structural mocks: every symbol exists, every call throws.
'node:https': './node/builtin_modules/mock/https.ts',
'node:dns/promises': './node/builtin_modules/mock/dns/promises.ts',
'dns/promises': './node/builtin_modules/mock/dns/promises.ts',
'node:net': './node/builtin_modules/mock/net.ts',
@@ -0,0 +1,16 @@
/** Native TLS listeners are unavailable in a browser worker's HTTP tunnel. */
import { notAvailableError } from '../../notImplementedFail.ts'
/**
* Reject native TLS instead of serving plaintext for an HTTPS configuration.
* @returns Never; a browser worker cannot bind a TLS socket.
*/
export function createServer(): never {
throw notAvailableError('node:https', 'createServer')
}
/** CommonJS interop marker for the worker module loader. */
export const __esModule = true
/** CommonJS exports for the worker's explicit TLS-listener refusal. */
export default { createServer } satisfies Partial<typeof import('node:https')>
@@ -30,6 +30,7 @@ import * as nodeEvents from './builtin_modules/implemented/events.ts'
import * as nodeFs from './builtin_modules/implemented/fs.ts'
import * as nodeFsPromises from './builtin_modules/implemented/fs/promises.ts'
import * as nodeHttp from './builtin_modules/implemented/http.ts'
import * as nodeHttps from './builtin_modules/mock/https.ts'
import * as nodeModule from './builtin_modules/implemented/module.ts'
import * as nodeOs from './builtin_modules/implemented/os.ts'
import * as nodePath from './builtin_modules/implemented/path.ts'
@@ -72,6 +73,7 @@ const BUILTINS: Record<string, StaticModuleFactory> = {
fs: () => nodeFs,
'fs/promises': () => nodeFsPromises,
http: () => nodeHttp,
https: () => nodeHttps,
module: () => nodeModule,
net: () => nodeNet,
os: () => nodeOs,
@@ -15,6 +15,7 @@ import { describe, expect, it, vi } from 'vitest'
import { notAvailableError, notImplementedFail } from '../../src/node/notImplementedFail.ts'
import * as childProcess from '../../src/node/builtin_modules/implemented/child_process.ts'
import * as dnsPromises from '../../src/node/builtin_modules/mock/dns/promises.ts'
import * as https from '../../src/node/builtin_modules/mock/https.ts'
import * as net from '../../src/node/builtin_modules/mock/net.ts'
import * as sqlite from '../../src/node/builtin_modules/mock/sqlite.ts'
import * as stream from '../../src/node/builtin_modules/implemented/stream.ts'
@@ -38,6 +39,7 @@ const quiet = (): void => { vi.spyOn(console, 'error').mockImplementation(() =>
/** Symbols that refuse when called. */
const CALLED: [string, Record<string, unknown>, readonly string[]][] = [
['node:dns/promises', dnsPromises, ['lookup']],
['node:https', https, ['createServer']],
['node:net', net, ['createServer', 'connect']],
['node:sqlite', sqlite, ['backup']],
['node:vm', vm, ['createContext', 'runInContext', 'runInNewContext', 'runInThisContext', 'isContext']],
@@ -783,7 +783,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
},
{
signature: 'authorizeIndex(request: ConnectionIndexRequest, response: ConnectionIndexResponse): boolean',
description: 'Authenticate one frontend index request, owning a token redirect or 401.',
description: 'Authenticate one frontend index request, owning a token redirect or 401. The cookie this mints is `Secure` when the mounted Web carrier serves TLS, which only that listener\'s protocol decides.',
parameters: [{ name: 'request', description: 'root or configured-index HTTP request.' }, { name: 'response', description: 'response owned when the result is false.' }],
returns: 'true only when the frontend may serve index.html.',
},
@@ -3569,7 +3569,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
{
key: 'webServer',
summary: 'The browser HTTP carrier service.',
description: 'The browser HTTP carrier service. Activation listens immediately. Route registration order does not affect requests because configured named routes must be distinct, and the fallback handler answers anything not yet claimed during startup with 404 until its owner registers. A listen failure rejects initialization, and the boot process reports the failed fiber.',
description: 'The browser HTTP carrier service. Activation loads any configured TLS material, then listens immediately; a material or listen failure rejects initialization, and the boot process reports the failed fiber. Route registration order does not affect requests because configured named routes must be distinct, and the fallback handler answers anything not yet claimed during startup with 404 until its owner registers.',
methods: [
{
signature: 'register(route: WebRoute): () => void',
+2 -2
View File
@@ -9,8 +9,8 @@
en: dfb700c867632d7f
zh: 8afebea57ae7845a
/host-web-gui-host-half/summary:
en: 2e87442a03e973ee
zh: 1cb9d4ff114e101d
en: dab3d44c2ea9af42
zh: 8aed6a4fd47dd8f7
/host-web-gui-host-half/table-of-contents:
en: 1eb35b015a6c423f
zh: a8a3dceee2c9e943
+1 -1
View File
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
## Summary
The `host/` group provides the web GUI's plain HTTP server, the SPA dist server that serves the built Web shell, the workspace-directory picking seam with its native, browse, and adaptive composition packages, the open-in-app application probe and launch routes, the read-only plugin inventory projection, and explicit product event reporting. These packages are product packages; the browser transport lives in [`client/`](../client/README.md), and the composed application is [`apps/cli`](../../apps/cli/README.md) booting the [`dsh-base` bundle](../bundle/base/cordis.patch.yml) that serves the web app under `apps/web/`. The picker backends replace one another behind the shared seam.
The `host/` group provides the web GUI's HTTP(S) server, the SPA dist server that serves the built Web shell, the workspace-directory picking seam with its native, browse, and adaptive composition packages, the open-in-app application probe and launch routes, the read-only plugin inventory projection, and explicit product event reporting. These packages are product packages; the browser transport lives in [`client/`](../client/README.md), and the composed application is [`apps/cli`](../../apps/cli/README.md) booting the [`dsh-base` bundle](../bundle/base/cordis.patch.yml) that serves the web app under `apps/web/`. The picker backends replace one another behind the shared seam.
## Table of Contents
+1 -1
View File
@@ -9,7 +9,7 @@ kind: "package-group"
## 概述
`host/` 组提供 Web GUI 的普通 HTTP 服务器、服务已构建 Web 壳的 SPA dist 服务器、带原生/浏览/自适应组合包的工作区目录选择 seam、open-in-app 的应用探测与启动路由,只读的插件清单投影以及显式产品事件上报。这些包都是产品包;浏览器传输位于 [`client/`](../client/README.zh.md),组合应用是 [`apps/cli`](../../apps/cli/README.zh.md),它启动 [`dsh-base` 组合包](../bundle/base/cordis.patch.yml) 来提供 `apps/web/` 下的 Web 应用。选择器后端可在共享 seam 后互相替换。
`host/` 组提供 Web GUI 的 HTTP(S) 服务器、服务已构建 Web 壳的 SPA dist 服务器、带原生/浏览/自适应组合包的工作区目录选择 seam、open-in-app 的应用探测与启动路由,只读的插件清单投影以及显式产品事件上报。这些包都是产品包;浏览器传输位于 [`client/`](../client/README.zh.md),组合应用是 [`apps/cli`](../../apps/cli/README.zh.md),它启动 [`dsh-base` 组合包](../bundle/base/cordis.patch.yml) 来提供 `apps/web/` 下的 Web 应用。选择器后端可在共享 seam 后互相替换。
## 目录
+23 -20
View File
@@ -3,23 +3,26 @@
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/host/webserver/README.md
/:
en: 40ce7ea7cd628127
zh: f4f1983ead36c8a4
en: 7b0123143b731803
zh: 0ef8ad760107d8f6
/deepseek-ai-dsh-host-webserver:
en: 9c6c5fcadd836722
zh: f5b79ea2c4be6dde
/deepseek-ai-dsh-host-webserver/summary:
en: 2b56ae0c86dae540
zh: 9dd7e304074703d4
en: 0d1ffed238b25446
zh: 9f66adce1c593c53
/deepseek-ai-dsh-host-webserver/table-of-contents:
en: d152484eb41ac6b4
zh: 09388d293f9be9cb
/deepseek-ai-dsh-host-webserver/use-this-package:
en: 0246258969a54c27
zh: ba67f4df89d049de
en: 1998834973f7cf17
zh: 63455a2f75d090ad
/deepseek-ai-dsh-host-webserver/use-this-package/minimal-configuration:
en: 9f91b3c220b37c29
zh: c5067440d173f9ac
en: bbbb5ba69338ca01
zh: 9fb460f1d0742f3a
/deepseek-ai-dsh-host-webserver/use-this-package/serving-https:
en: 90352948bfdc1d99
zh: 35d148bf3bfcf76f
/deepseek-ai-dsh-host-webserver/use-this-package/registering-routes:
en: 296ffba200f0dc93
zh: 5d78d6b5f48634c9
@@ -27,20 +30,20 @@
en: e21232b7aecbe7a8
zh: 2c4a94cab2aa53ff
/deepseek-ai-dsh-host-webserver/use-this-package/behavior-under-failure:
en: 7ef1f5bedb859676
zh: b146ebf7ca3c0bfb
en: dd2529dd54707e39
zh: 2615f469d66ac681
/deepseek-ai-dsh-host-webserver/understand-the-implementation:
en: 42b15ac6321706aa
zh: 4ed0861911abaf5d
/deepseek-ai-dsh-host-webserver/understand-the-implementation/design-concept:
en: 3eb8591cc484305d
zh: b778e2ca57752ee1
en: 830640c170fd3270
zh: d9ef28ef65fbaad3
/deepseek-ai-dsh-host-webserver/understand-the-implementation/matching-and-lifecycle:
en: 70dc6875e07497dd
zh: a1d041236e361f84
en: 8589aba0cbaa1c76
zh: babf3b27b0cdb35e
/deepseek-ai-dsh-host-webserver/understand-the-implementation/source-map:
en: ce33753861660637
zh: 09ecb20f47f59e55
en: 4a0b809827d92cb3
zh: 609885ab34257ff1
/deepseek-ai-dsh-host-webserver/further-exploration:
en: 75eae86778f0fc15
zh: d87775b660e58c9f
@@ -51,8 +54,8 @@
en: ca75c51c89c2c9b0
zh: de54f3467ef3b9be
/deepseek-ai-dsh-host-webserver/known-limitations-and-deferred-work:
en: 49b11936de031b32
zh: 5aa36b8f07a95e45
en: 9aa6b8cf92d5b506
zh: 55ee3f6ad1cace50
/deepseek-ai-dsh-host-webserver/known-limitations-and-deferred-work/dev-note:
en: e67f633dc6fe7773
zh: 3bb0c20c93f16f98
en: 245b35764295ba1e
zh: a15b132b111bfef0
+31 -10
View File
@@ -1,5 +1,5 @@
---
description: "The web GUI host's HTTP server: named-route and upgrade registration, index transforms, and the single fallback seat that serves the Web shell's SPA dist."
description: "The web GUI host's HTTP server: named-route and upgrade registration, an optional TLS listener, index transforms, and the single fallback seat that serves the Web shell's SPA dist."
kind: "package-reference"
---
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
## Summary
Browsers reach the web GUI over HTTP through `dsh-host-webserver`: a `node:http` server where other plugins register named routes, upgrade routes, index startup inputs, and one fallback handler. It knows no harness concepts and serves no files — the `/api` bridge, plugin bundles, the HMR event stream, and the SPA dist belong to the plugins that register them. Route matching is fixed: exact over the whole table, then longest prefix, then the fallback handler. It serves browsers only; Electron loads dist over `file://` and carries fetch over an IPC bridge.
Browsers reach the web GUI over HTTP through `dsh-host-webserver`: a `node:http` server — `node:https` once `tls` names a certificate and key — where other plugins register named routes, upgrade routes, index startup inputs, and one fallback handler. It knows no harness concepts and serves no files — the `/api` bridge, plugin bundles, the HMR event stream, and the SPA dist belong to the plugins that register them. Route matching is fixed: exact over the whole table, then longest prefix, then the fallback handler. It serves browsers only; Electron loads dist over `file://` and carries fetch over an IPC bridge.
## Table of Contents
@@ -25,7 +25,7 @@ Browsers reach the web GUI over HTTP through `dsh-host-webserver`: a `node:http`
<a id="use-this-package"></a>
## Use this package
Compose the webserver as the HTTP transport of a browser-facing host, then let the feature plugins claim their routes. Activation listens immediately; registration order carries no request-facing semantics because named routes compose to be disjoint.
Compose the webserver as the HTTP transport of a browser-facing host, then let the feature plugins claim their routes. Activation listens immediately; registration order carries no request-facing semantics because named routes compose to be disjoint. Add `tls` to carry those same routes over HTTPS instead of plain HTTP.
### Minimal configuration
@@ -36,10 +36,26 @@ Compose the webserver as the HTTP transport of a browser-facing host, then let t
port: 3000
```
`host` takes one concrete IPv4 or IPv6 literal of a local interface — for example a container's own Pod address from `hostname -i`. A loopback literal (any address in 127/8, `::1`, or a mapped form of either) keeps the server on this machine; any other literal serves the network that address belongs to over plain HTTP, because this server carries no TLS. An unspecified address — including the IPv4-mapped forms Linux reads as IPv4 any — is rejected at load instead of opening the port on every interface at once. `port` 0 requests an OS-assigned port; `ctx.webServer.port` reads the listening port afterwards, and the exported `isLoopbackHost(host)` and `isWildcardHost(host)` classify an address by its parsed value, so a mapped or zoned spelling classifies as the address it names. A dotted-quad IPv6 tail names the address's own low 32 bits, exactly as `listen` reads it, so `::0.0.0.1` is loopback `::1` while `::127.0.0.1` is the non-loopback `::7f00:1`; `normalizeBindAddress(host)` returns that address text — `127.0.0.1` for a genuinely mapped `::ffff:127.0.0.1`, `::1` for `::0.0.0.1` and for `::1%lo` — ready to be a URL host.
`host` takes one concrete IPv4 or IPv6 literal of a local interface — for example a container's own Pod address from `hostname -i`. A loopback literal (any address in 127/8, `::1`, or a mapped form of either) keeps the server on this machine; any other literal serves the network that address belongs to, over plain HTTP unless `tls` is set. An unspecified address — including the IPv4-mapped forms Linux reads as IPv4 any — is rejected at load instead of opening the port on every interface at once. `port` 0 requests an OS-assigned port; `ctx.webServer.port` reads the listening port afterwards, and the exported `isLoopbackHost(host)` and `isWildcardHost(host)` classify an address by its parsed value, so a mapped or zoned spelling classifies as the address it names. A dotted-quad IPv6 tail names the address's own low 32 bits, exactly as `listen` reads it, so `::0.0.0.1` is loopback `::1` while `::127.0.0.1` is the non-loopback `::7f00:1`; `normalizeBindAddress(host)` returns that address text — `127.0.0.1` for a genuinely mapped `::ffff:127.0.0.1`, `::1` for `::0.0.0.1` and for `::1%lo` — ready to be a URL host.
Set `compression: 'gzip'` to wrap eligible socket-backed responses without changing route APIs. The client must accept gzip and the media type must be compressible or `multipart/form-data`; known response lengths below `compressionThresholdBytes` remain uncompressed, while unknown-length streams are eligible immediately. Existing encodings, `Cache-Control: no-transform`, range responses, SSE, ZIP, and the packaged `.gz` Worker image remain unchanged. The shipped Web bundle uses compression level 1 with a 1024-byte threshold; other compositions default to no compression.
### Serving HTTPS
`tls` serves the same routes over TLS on that one address: the carrier opens no second HTTP listener and never redirects. Both files are read once during activation, before the socket binds, so an unreadable, empty, or malformed file, a key that does not match the certificate, or a bind failure rejects initialization — a misconfigured certificate never falls back to HTTP. Paths resolve against the process working directory, the key must be unencrypted PEM, and `certFile` may hold a chain whose leaf certificate comes first. Replacing either file takes a reload, and this carrier neither provisions nor renews certificates.
```yaml
- name: '@deepseek-ai/dsh-host-webserver'
config:
host: 127.0.0.1
port: 3000
tls:
certFile: /etc/dsh/tls/cert.pem
keyFile: /etc/dsh/tls/key.pem
```
`ctx.webServer.protocol` reads `'http:'` or `'https:'` for the bound listener, so a plugin that keys request policy — a session cookie's `Secure` flag, an origin check — on the transport reads one configuration-derived value instead of inspecting sockets or trusting forwarded headers. Clients still verify the certificate you supplied: this carrier ships no trust store, no HTTP-to-HTTPS redirect, and no HSTS header.
### Registering routes
`register(route)` adds a named `exact` or `prefix` HTTP route, `registerUpgrade(route)` adds an upgrade route for an exact pathname, and both return a disposer that removes the registration. A duplicate path within either table throws — route patterns are a composition-level contract, so a collision is a misconfiguration. HTTP matching is exact over the whole table, then longest prefix, then the fallback handler; upgrades match exactly and unmatched connections are closed.
@@ -52,7 +68,7 @@ Index startup inputs are two layers. `collectIndexInjections()` gathers a fresh
### Behavior under failure
A listen failure (for example EADDRINUSE) rejects plugin initialization with the bind diagnostic. An HTTP request whose handler throws is answered 400 — or the socket destroyed when headers are already out — and logged as a warning; it never exits the process. An upgrade-handler exception or upgraded-socket transport error logs a warning and destroys its socket.
A listen failure (for example EADDRINUSE) rejects plugin initialization with the bind diagnostic, and unusable TLS material rejects it too, naming the config field and the resolved path but never the file's contents. An HTTP request whose handler throws is answered 400 — or the socket destroyed when headers are already out — and logged as a warning; it never exits the process. An upgrade-handler exception or upgraded-socket transport error logs a warning and destroys its socket.
-----
@@ -64,18 +80,19 @@ A listen failure (for example EADDRINUSE) rejects plugin initialization with the
### Design concept
The package is a plain route registry with no harness vocabulary: `WebServer` extends Cordis `Service` and holds three route tables plus the fallback slot, the raw index-tap list, and the `webserver/index-inject` event the index renderer gathers rows through. Index rendering composes two layers per response: `renderIndex` renders the fresh injection table, including advisory `script-preload` rows, into the body, then applies the raw taps in registration order; `applyIndexTaps` runs the taps alone. The upgrade handler owns the protocol handshake and connection contents; the webserver only delivers the raw socket and request. `host` and `port` getters expose composition-time facts other plugins adapt to (for example the directory-picker chooser).
The package is a plain route registry with no harness vocabulary: `WebServer` extends Cordis `Service` and holds three route tables plus the fallback slot, the raw index-tap list, and the `webserver/index-inject` event the index renderer gathers rows through. Index rendering composes two layers per response: `renderIndex` renders the fresh injection table, including advisory `script-preload` rows, into the body, then applies the raw taps in registration order; `applyIndexTaps` runs the taps alone. The upgrade handler owns the protocol handshake and connection contents; the webserver only delivers the raw socket and request. `host`, `port`, and `protocol` getters expose composition-time facts other plugins adapt to (for example the directory-picker chooser), and an explicit `tls: null` normalizes to an unset listener, so `protocol` has a single source.
### Matching and lifecycle
`match(pathname)` consults the exact table first, then walks the prefix table for the longest match, then the fallback. Activation (`[Service.init]`) listens immediately; disposal starts `close()` and `closeAllConnections()`, destroys every tracked upgraded socket, and returns only after the server and those sockets have closed. Node does not include upgraded sockets in `closeAllConnections()`, so the service tracks them explicitly.
`match(pathname)` consults the exact table first, then walks the prefix table for the longest match, then the fallback. Activation registers one effect that owns the whole acquisition — the TLS reads, the plain or secure server, and the bind — so a disposal that lands mid-initialization waits for the listener and then closes it rather than leaving it unowned. Disposal starts `close()` and `closeAllConnections()`, destroys every tracked upgraded socket and every raw TLS connection, and returns only after the server and those sockets have closed. Node's `closeAllConnections()` includes neither upgraded sockets nor a TLS socket whose handshake has not finished, so the service tracks both explicitly.
### Source map
| File | Role |
|---|---|
| [`src/index.ts`](src/index.ts) | `WebServer` service: route tables, fallback seat, index rendering, matching, lifecycle |
| [`src/index.ts`](src/index.ts) | `WebServer` service: route tables, fallback seat, index rendering, matching, TLS listener, lifecycle |
| [`src/injections.ts`](src/injections.ts) | Structured `IndexInjection` rows and `renderIndexInjections` row rendering |
| [`tests/tls-fixture.ts`](tests/tls-fixture.ts) | Generated TLS material for TLS-listener tests; another suite may materialize the same pair |
</details>
@@ -109,7 +126,8 @@ None; this package neither assembles nor sends a provider request.
These limits define where the server is intentionally minimal. They are current package constraints, not a task backlog.
- **No server-wide TLS, authentication, or origin policy** — route owners such as `dsh-client-connection` enforce their own request policy. Binding a non-loopback address still exposes unprotected routes and static assets to that network.
- **No certificate lifecycle** — `tls` serves the PEM files present at activation: the carrier never provisions, renews, or watches them, and it applies Node's default TLS policy. Replacing a certificate takes a reload.
- **No server-wide authentication or origin policy** — route owners such as `dsh-client-connection` enforce their own request policy. Binding a non-loopback address still exposes unprotected routes and static assets to that network.
- **Socket options are fixed** — config selects the bind host and port, while backlog and other socket settings remain internal until a deployment needs them.
<a id="dev-note"></a>
@@ -118,6 +136,9 @@ These limits define where the server is intentionally minimal. They are current
<details>
<summary>Working context for maintainers — click to expand</summary>
None.
Two working notes, both non-authoritative:
- TLS test material is generated at test time (`tests/tls-fixture.ts`), never committed: GitHub push protection rejects PEM private keys even in tests, and the certificate is disposable anyway. The helper issues one self-signed localhost certificate per process, covering 127.0.0.1, `::1`, and `localhost`, and writes the key owner-only.
- `selfsigned` stays on the 4.x line: 5.x installs two `@peculiar/asn1-schema` copies under pnpm, and its extension builder then throws for every `generate()` call.
</details>
+31 -10
View File
@@ -1,5 +1,5 @@
---
description: "web GUI 宿主的 HTTP 服务器:具名路由与 upgrade 注册、index 转换,以及服务 Web 壳 SPA dist 的唯一回退席位。"
description: "web GUI 宿主的 HTTP 服务器:具名路由与 upgrade 注册、可选的 TLS 监听、index 转换,以及服务 Web 壳 SPA dist 的唯一回退席位。"
kind: "package-reference"
---
@@ -9,7 +9,7 @@ kind: "package-reference"
## 概述
浏览器经由 `dsh-host-webserver` 通过 HTTP 访问 web GUI:一个 `node:http` 服务器,其他插件在其中注册具名路由、upgrade 路由、index 启动输入与一个回退 handler。它不了解任何 harness 概念,也不提供任何文件服务——`/api` 桥接、插件 bundle、HMR(热模块替换)事件流与 SPA dist 都属于注册它们的插件。路由匹配顺序固定不变:先在整张表中匹配精确 route,再匹配最长前缀,最后交给回退 handler。它只服务浏览器;Electron 通过 `file://` 加载 dist,并经 IPC 桥接承载 fetch。
浏览器经由 `dsh-host-webserver` 通过 HTTP 访问 web GUI:一个 `node:http` 服务器——当 `tls` 指定证书与私钥时改用 `node:https`——其他插件在其中注册具名路由、upgrade 路由、index 启动输入与一个回退 handler。它不了解任何 harness 概念,也不提供任何文件服务——`/api` 桥接、插件 bundle、HMR(热模块替换)事件流与 SPA dist 都属于注册它们的插件。路由匹配顺序固定不变:先在整张表中匹配精确 route,再匹配最长前缀,最后交给回退 handler。它只服务浏览器;Electron 通过 `file://` 加载 dist,并经 IPC 桥接承载 fetch。
## 目录
@@ -25,7 +25,7 @@ kind: "package-reference"
<a id="use-this-package"></a>
## 使用本包
把 webserver 组合为面向浏览器宿主的 HTTP 传输,然后让功能插件认领各自的路由。激活即开始监听;注册顺序不影响请求处理,因为具名路由组合起来互不相交。
把 webserver 组合为面向浏览器宿主的 HTTP 传输,然后让功能插件认领各自的路由。激活即开始监听;注册顺序不影响请求处理,因为具名路由组合起来互不相交。添加 `tls` 即可让同一批路由以 HTTPS 而非明文 HTTP 提供。
### 最小配置
@@ -36,10 +36,26 @@ kind: "package-reference"
port: 3000
```
`host` 接受本机某个接口的一个具体 IPv4 或 IPv6 字面量——例如容器自身来自 `hostname -i` 的 Pod 地址。回环字面量(127/8 内的任意地址、`::1`,或二者的 mapped 形式)把服务器留在本机;其他任何字面量都会在该地址所属网络上提供明文 HTTP,因为本服务器不携带 TLS。未指定地址——包括 Linux 视为 IPv4 any 的 IPv4-mapped 形式——会在加载时被拒绝,而不是一次性打开每个网络接口的端口。`port` 为 0 时请求 OS 分配端口;之后用 `ctx.webServer.port` 读取正在监听的端口,导出的 `isLoopbackHost(host)` 与 `isWildcardHost(host)` 按解析后的地址值分类,因此 mapped 或带 zone 的写法会按其指名的地址分类。点分四段式的 IPv6 尾部表示该地址自身的低 32 位,与 `listen` 的读法完全一致:`::0.0.0.1` 是回环 `::1`,而 `::127.0.0.1` 是非回环的 `::7f00:1`;`normalizeBindAddress(host)` 返回该地址的文本——真正 mapped 的 `::ffff:127.0.0.1` 为 `127.0.0.1`,`::0.0.0.1` 与 `::1%lo` 为 `::1`——可直接用作 URL host。
`host` 接受本机某个接口的一个具体 IPv4 或 IPv6 字面量——例如容器自身来自 `hostname -i` 的 Pod 地址。回环字面量(127/8 内的任意地址、`::1`,或二者的 mapped 形式)把服务器留在本机;其他任何字面量都会在该地址所属网络上提供 HTTP,除非设置 `tls`,否则为明文。未指定地址——包括 Linux 视为 IPv4 any 的 IPv4-mapped 形式——会在加载时被拒绝,而不是一次性打开每个网络接口的端口。`port` 为 0 时请求 OS 分配端口;之后用 `ctx.webServer.port` 读取正在监听的端口,导出的 `isLoopbackHost(host)` 与 `isWildcardHost(host)` 按解析后的地址值分类,因此 mapped 或带 zone 的写法会按其指名的地址分类。点分四段式的 IPv6 尾部表示该地址自身的低 32 位,与 `listen` 的读法完全一致:`::0.0.0.1` 是回环 `::1`,而 `::127.0.0.1` 是非回环的 `::7f00:1`;`normalizeBindAddress(host)` 返回该地址的文本——真正 mapped 的 `::ffff:127.0.0.1` 为 `127.0.0.1`,`::0.0.0.1` 与 `::1%lo` 为 `::1`——可直接用作 URL host。
设置 `compression: 'gzip'` 可以包装符合条件的 socket-backed 响应,而不改变 route API。客户端必须接受 gzip,且媒体类型必须可压缩或为 `multipart/form-data`;已知长度小于 `compressionThresholdBytes` 的响应保持未压缩,未知长度的流则立即符合条件。已有编码、`Cache-Control: no-transform`、range 响应、SSE(Server-Sent Events)、ZIP 与已打包的 `.gz` Worker image 均保持不变。随附 Web bundle 使用 level 1 与 1024 字节阈值;其他组合默认不压缩。
### 服务 HTTPS
`tls` 在同一地址上以 TLS 提供同一批路由:载体不会另开一个 HTTP 监听,也从不重定向。两个文件在激活期间、socket 绑定之前各读取一次,因此不可读、为空或格式错误的文件、与证书不匹配的私钥,或绑定失败都会拒绝初始化——配置错误的证书绝不会退回 HTTP。路径相对进程工作目录解析,私钥必须是无口令的 PEM,`certFile` 可以包含证书链且叶子证书在前。替换任一文件都需要重新加载;本载体既不申请也不续期证书。
```yaml
- name: '@deepseek-ai/dsh-host-webserver'
config:
host: 127.0.0.1
port: 3000
tls:
certFile: /etc/dsh/tls/cert.pem
keyFile: /etc/dsh/tls/key.pem
```
`ctx.webServer.protocol` 读出所绑定监听是 `'http:'` 还是 `'https:'`,因此按传输层制定请求策略(会话 Cookie 的 `Secure` 属性、来源检查)的插件只需读一个源自配置的值,无需检查 socket,也无需信任转发头。客户端仍会校验你提供的证书:本载体不附带信任库、不做 HTTP 到 HTTPS 的重定向,也不发送 HSTS 头。
### 注册路由
`register(route)` 添加具名的 `exact`/`prefix` HTTP route,`registerUpgrade(route)` 为精确 pathname 添加 upgrade route,两者返回的 disposer 都会移除注册。同一张表内的重复路径会抛错——route 模式是组合层约定,冲突即配置错误。HTTP 匹配先在整张表中匹配精确 route,再匹配最长前缀,最后交给回退 handler;upgrade 只做精确匹配,未命中连接直接关闭。
@@ -52,7 +68,7 @@ index 启动输入分两层。`collectIndexInjections()` 收集一张全新的
### 失败时的行为
监听失败(例如 EADDRINUSE)会以绑定诊断信息拒绝插件初始化。handler 抛错的 HTTP 请求会得到 400——若响应头已经发出则销毁 socket——并记录 warning;它绝不会退出进程。upgrade handler 抛错或升级 socket 出现传输错误时,会记录 warning 并销毁对应 socket。
监听失败(例如 EADDRINUSE)会以绑定诊断信息拒绝插件初始化,不可用的 TLS 材料同样会拒绝它,诊断信息给出配置字段与解析后的路径,但绝不包含文件内容。handler 抛错的 HTTP 请求会得到 400——若响应头已经发出则销毁 socket——并记录 warning;它绝不会退出进程。upgrade handler 抛错或升级 socket 出现传输错误时,会记录 warning 并销毁对应 socket。
-----
@@ -64,18 +80,19 @@ index 启动输入分两层。`collectIndexInjections()` 收集一张全新的
### 设计理念
本包是一个不带任何 harness 词汇的普通路由注册表:`WebServer` 继承 Cordis `Service`,持有三张路由表、回退 slot、原始 index 转换列表,以及 `webserver/index-inject` 事件,index 渲染器经其收集行。index 渲染每次响应组合两层:`renderIndex` 先把包含提示性 `script-preload` 行的全新注入表渲染进正文,再按注册顺序应用原始转换;`applyIndexTaps` 只运行转换。upgrade handler 拥有协议握手与连接内容;webserver 只交付原始 socket 与 request。`host` 与 `port` getter 暴露其他插件据以自适应的组合期事实(例如 directory-picker 选择器)。
本包是一个不带任何 harness 词汇的普通路由注册表:`WebServer` 继承 Cordis `Service`,持有三张路由表、回退 slot、原始 index 转换列表,以及 `webserver/index-inject` 事件,index 渲染器经其收集行。index 渲染每次响应组合两层:`renderIndex` 先把包含提示性 `script-preload` 行的全新注入表渲染进正文,再按注册顺序应用原始转换;`applyIndexTaps` 只运行转换。upgrade handler 拥有协议握手与连接内容;webserver 只交付原始 socket 与 request。`host`、`port` 与 `protocol` getter 暴露其他插件据以自适应的组合期事实(例如 directory-picker 选择器),而显式的 `tls: null` 会归一为未设置监听,使 `protocol` 只有一个来源。
### 匹配与生命周期
`match(pathname)` 先查精确表,再遍历前缀表取最长匹配,最后走回退。激活(`[Service.init]`)即开始监听;资源释放会启动 `close()` 与 `closeAllConnections()`,销毁所有受跟踪的升级 socket,并仅在服务器与这些 socket 均已关闭后返回。Node 的 `closeAllConnections()` 不包含升级 socket,因此服务显式跟踪它们。
`match(pathname)` 先查精确表,再遍历前缀表取最长匹配,最后走回退。激活会注册一个拥有整段获取过程的 effect——TLS 读取、明文或安全服务器、以及绑定——因此落在初始化中途的资源释放会等待该监听并在其后关闭它,而不会留下无主的监听。资源释放会启动 `close()` 与 `closeAllConnections()`,销毁所有受跟踪的升级 socket 与所有原始 TLS 连接,并仅在服务器与这些 socket 均已关闭后返回。Node 的 `closeAllConnections()` 既不包含升级 socket,也不包含握手尚未完成的 TLS socket,因此服务显式跟踪二者。
### 源码地图
| 文件 | 职责 |
|---|---|
| [`src/index.ts`](src/index.ts) | `WebServer` 服务:路由表、回退席位、index 渲染、匹配、生命周期 |
| [`src/index.ts`](src/index.ts) | `WebServer` 服务:路由表、回退席位、index 渲染、匹配、TLS 监听、生命周期 |
| [`src/injections.ts`](src/injections.ts) | 结构化 `IndexInjection` 行与 `renderIndexInjections` 行渲染 |
| [`tests/tls-fixture.ts`](tests/tls-fixture.ts) | 为 TLS 监听测试生成的 TLS 材料;其他测试套件可物化同一套材料 |
</details>
@@ -109,7 +126,8 @@ index 启动输入分两层。`collectIndexInjections()` 收集一张全新的
这些限制说明服务器在何处有意保持最小。它们是当前包约束,不是任务积压。
- **不提供服务器级 TLS、认证或来源策略**:`dsh-client-connection` 等 route owner 会实施自己的请求策略。绑定非回环地址仍会向该网络公开未受保护的 route 与静态资源。
- **不管理证书生命周期**:`tls` 只提供激活时存在的 PEM 文件——载体从不申请、续期或监视它们,并采用 Node 默认的 TLS 策略。替换证书需要重新加载。
- **不提供服务器级认证或来源策略**:`dsh-client-connection` 等 route owner 会实施自己的请求策略。绑定非回环地址仍会向该网络公开未受保护的 route 与静态资源。
- **Socket 选项固定不变**:配置只选择绑定宿主与端口;在具体部署产生需求前,backlog 和其他 socket 设置仍保持内部实现。
<a id="dev-note"></a>
@@ -118,6 +136,9 @@ index 启动输入分两层。`collectIndexInjections()` 收集一张全新的
<details>
<summary>维护者的工作上下文——点击展开</summary>
无。
两条工作备注,均非权威结论:
- TLS 测试材料在测试时生成(`tests/tls-fixture.ts`),绝不入库:GitHub push protection 会拦截测试中的 PEM 私钥,况且该证书本就是一次性的。该助手每个进程签发一张自签名的 localhost 证书,覆盖 127.0.0.1、`::1` 与 `localhost`,并以仅属主可读的权限写入私钥。
- `selfsigned` 固定在 4.x:在 pnpm 下 5.x 会装入两份 `@peculiar/asn1-schema`,其 extension 构造器随后会让每次 `generate()` 抛错。
</details>
+2 -1
View File
@@ -38,6 +38,7 @@
"devDependencies": {
"@deepseek-ai/cordis": "workspace:~",
"@types/compression": "^1.8.1",
"@types/negotiator": "^0.6.5"
"@types/negotiator": "^0.6.5",
"selfsigned": "^4.0.1"
}
}
+114 -18
View File
@@ -1,14 +1,18 @@
/**
* @deepseek-ai/dsh-host-webserver — node:http route registration with optional
* gzip, index injection, and one fallback seat. It knows no harness concepts
* and serves no files; the composing application owns dist serving. Electron
* uses file:// plus IPC instead, and this package never prints the URL.
* @deepseek-ai/dsh-host-webserver — node:http route registration — node:https
* when `tls` names a certificate and key — with optional gzip, index injection,
* and one fallback seat. It knows no harness concepts and serves no files; the
* composing application owns dist serving. Electron uses file:// plus IPC
* instead, and this package never prints the URL.
* Route handlers retain direct response ownership.
*/
import { readFile } from 'node:fs/promises'
import { createServer } from 'node:http'
import type { IncomingMessage, ServerResponse, Server } from 'node:http'
import { createServer as createSecureServer } from 'node:https'
import { isIP, type AddressInfo } from 'node:net'
import { resolve } from 'node:path'
import type { Duplex } from 'node:stream'
import { Context, Service } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
@@ -56,21 +60,41 @@ export interface WebUpgradeRoute {
handler: (req: IncomingMessage, socket: Duplex, head: Buffer) => void | Promise<void>
}
/** Web server listen and response-compression config. */
/**
* TLS material for the HTTPS listener: one certificate chain file and the
* private key file it pairs with. Both hold PEM text and both resolve against
* the process working directory.
*/
export interface TlsConfig {
/** Certificate chain file, leaf certificate first, PEM, no passphrase. */
certFile: string
/** Private key file for the chain's leaf certificate; unencrypted PEM. */
keyFile: string
}
/** Web server listen, TLS, and response-compression config. */
export interface Config {
/**
* Listen address: a concrete IPv4 or IPv6 literal of one local interface,
* for example the container's own Pod address from `hostname -i`. A loopback
* literal (any address in 127/8, `::1`, or a mapped form of either) keeps the
* server on this machine; any other literal serves the network that address
* belongs to over plain HTTP, because the carrier adds no TLS. The
* unspecified address — IPv4 any, IPv6 any, and the IPv4-mapped forms of
* IPv4 any — is rejected at load: it would expose the port on every interface
* at once.
* belongs to, over plain HTTP unless `tls` is set. The unspecified address —
* IPv4 any, IPv6 any, and the IPv4-mapped forms of IPv4 any — is rejected at
* load: it would expose the port on every interface at once.
*/
host: string
/** Listen port; zero requests an OS-assigned port. */
port: number
/**
* Serve HTTPS with this certificate and key instead of plain HTTP. Both files
* are read once, before the listener binds: an unreadable or empty file,
* invalid PEM, or a key that does not match the certificate rejects
* initialization rather than falling back to HTTP. The material is never
* re-read, so replacing a certificate takes a reload. Omitted or null listens
* over plain HTTP.
*/
tls?: TlsConfig
/** Response compression for socket-backed HTTP requests. @default 'none' */
compression?: 'none' | 'gzip'
/** Gzip DEFLATE level from 0 through 9. @default 1 */
@@ -205,11 +229,34 @@ function createGzipMiddleware(config: ResolvedConfig): NodeMiddleware {
}
/**
* The browser HTTP carrier service. Activation listens immediately. Route
* Read one TLS material file for activation. The error names the config field
* and the resolved path; empty content is rejected here because node:tls
* silently accepts it, which would bind a listener that can never complete a
* handshake.
* @param field - the config field being read, for the diagnostic.
* @param path - absolute path to read.
* @returns the file's text.
*/
async function readTlsFile(field: 'certFile' | 'keyFile', path: string): Promise<string> {
let content: string
try {
content = await readFile(path, 'utf8')
} catch (error) {
throw new Error(`webserver: cannot read tls.${field} ${path}: ${error instanceof Error ? error.message : String(error)}`)
}
if (content.trim().length === 0) {
throw new Error(`webserver: tls.${field} ${path} is empty`)
}
return content
}
/**
* The browser HTTP carrier service. Activation loads any configured TLS
* material, then listens immediately; a material or listen failure rejects
* initialization, and the boot process reports the failed fiber. Route
* registration order does not affect requests because configured named routes
* must be distinct, and the fallback handler answers anything not yet claimed
* during startup with 404 until its owner registers. A listen failure rejects
* initialization, and the boot process reports the failed fiber.
* during startup with 404 until its owner registers.
*/
export class WebServer extends Service {
static Config: z<Config> = z.object({
@@ -222,6 +269,11 @@ export class WebServer extends Service {
return value
}).required(),
port: z.natural().max(65535).required(),
// An omitted object must not become `{}` and fail the required pair fields.
tls: z.object({
certFile: z.string().required(),
keyFile: z.string().required(),
}).default(undefined as never),
compression: z.union([z.const('none'), z.const('gzip')]).default(DEFAULT_COMPRESSION),
compressionLevel: z.number().step(1).min(0).max(9).default(DEFAULT_COMPRESSION_LEVEL),
compressionThresholdBytes: z.natural().default(DEFAULT_COMPRESSION_THRESHOLD_BYTES),
@@ -231,15 +283,26 @@ export class WebServer extends Service {
private readonly prefixes = new Map<string, WebRoute>()
private readonly upgrades = new Map<string, WebUpgradeRoute>()
private readonly upgradedSockets = new Set<Duplex>()
/**
* Raw connections of a TLS listener, held until they close. A TLS connection
* joins closeAllConnections()'s table only once its handshake completes, so a
* socket still handshaking — or one whose peer never sends a ClientHello —
* would otherwise keep `close()` waiting on the TLS handshake timeout. Empty
* for the plain listener, whose connections closeAllConnections() reaches.
*/
private readonly tlsConnections = new Set<Duplex>()
private readonly indexTaps: ((html: string) => string)[] = []
private fallback: WebRoute['handler'] | undefined
private server!: Server
private listenedPort!: number
private readonly gzip: NodeMiddleware | undefined
private readonly tlsConfig: TlsConfig | undefined
constructor(ctx: Context, private config: Config) {
super(ctx, 'webServer')
const resolved = config as ResolvedConfig
// Schemastery passes an explicit `tls: null` through; only a real object serves TLS.
this.tlsConfig = resolved.tls ?? undefined
this.gzip = resolved.compression === 'gzip' ? createGzipMiddleware(resolved) : undefined
}
@@ -253,6 +316,15 @@ export class WebServer extends Service {
return this.config.host
}
/**
* The protocol the listener speaks: `'https:'` when `tls` is configured. The
* value follows configuration, never a socket or a request header, so a
* consumer reads it before any request and no proxy can change it.
*/
get protocol(): 'http:' | 'https:' {
return this.tlsConfig === undefined ? 'http:' : 'https:'
}
/**
* Register a named route. Duplicate (kind, path) throws — route patterns are
* a composition-level contract, so a collision is a misconfiguration.
@@ -336,7 +408,7 @@ export class WebServer extends Service {
// rejection killing the process on one malformed request (bad %-escape,
// client dropping mid-body). Per-request failures log and answer 400 —
// never a process exit.
this.server = createServer((req, res) => {
const listener = (req: IncomingMessage, res: ServerResponse): void => {
const next = (): void => {
void handle(req, res).catch((err: unknown) => {
this.ctx.logger.warn(err instanceof Error ? err : new Error(String(err)))
@@ -350,7 +422,29 @@ export class WebServer extends Service {
}
if (this.gzip === undefined) next()
else this.gzip(req, res, next)
}
const tls = this.tlsConfig
// Disposal waits for in-flight acquisition and closes any listener it produces.
await this.ctx.effect(async () => {
if (tls === undefined) {
this.server = createServer(listener)
} else {
const certFile = resolve(tls.certFile)
const keyFile = resolve(tls.keyFile)
const [cert, key] = await Promise.all([
readTlsFile('certFile', certFile),
readTlsFile('keyFile', keyFile),
])
try {
this.server = createSecureServer({ cert, key }, listener)
} catch (error) {
throw new Error(`webserver: tls.certFile ${certFile} and tls.keyFile ${keyFile} are not a usable certificate and key pair: ${error instanceof Error ? error.message : String(error)}`)
}
this.server.on('connection', (socket) => {
this.tlsConnections.add(socket)
socket.once('close', () => { this.tlsConnections.delete(socket) })
})
}
this.server.on('upgrade', (req, socket, head) => {
const onError = (error: Error): void => {
this.ctx.logger.warn(error)
@@ -396,18 +490,20 @@ export class WebServer extends Service {
})
})
// Node does not include upgraded sockets in closeAllConnections(). The service
// owns them with the other connections, so it tracks and destroys them explicitly.
this.ctx.effect(() => async () => {
// Node does not include upgraded sockets in closeAllConnections(), and a TLS
// connection joins that table only after its handshake. The service owns both
// kinds with the other connections, so it tracks and destroys them explicitly.
return async () => {
const serverClosed = new Promise<void>((resolve) => {
this.server.close(() => { resolve() })
})
this.server.closeAllConnections()
const upgradedClosed = [...this.upgradedSockets].map(socket => new Promise<void>((resolve) => {
const socketsClosed = [...this.upgradedSockets, ...this.tlsConnections].map(socket => new Promise<void>((resolve) => {
socket.once('close', () => { resolve() })
socket.destroy()
}))
await Promise.all([serverClosed, ...upgradedClosed])
await Promise.all([serverClosed, ...socketsClosed])
}
}, 'webServer.listen')
}
@@ -0,0 +1,70 @@
/** Disposable localhost TLS material; clients must explicitly trust the generated certificate. */
import { mkdir, writeFile } from 'node:fs/promises'
import { join, resolve } from 'node:path'
import { generate } from 'selfsigned'
/** Materialized TLS fixture: the served certificate and key, plus the client's trust anchor. */
export interface TlsFixture {
/** Certificate file for `tls.certFile`. */
certFile: string
/** Private key file for `tls.keyFile` (unencrypted PEM, mode 0600). */
keyFile: string
/** Certificate file a client trusts, or imports into a browser trust store. */
caFile: string
/** The certificate PEM, as written to {@link certFile} and {@link caFile}. */
ca: string
}
const COMMON_NAME = 'localhost'
let generated: { cert: string; key: string } | undefined
/** One self-signed localhost certificate per process; RSA key generation is the only slow part. */
function tlsPems(): { cert: string; key: string } {
generated ??= (() => {
const { cert, private: key } = generate([{ name: 'commonName', value: COMMON_NAME }], {
keySize: 2048,
days: 30,
algorithm: 'sha256',
extensions: [
{ name: 'basicConstraints', cA: false },
{ name: 'keyUsage', digitalSignature: true, keyEncipherment: true },
{ name: 'extKeyUsage', serverAuth: true },
{
name: 'subjectAltName',
altNames: [
{ type: 2, value: COMMON_NAME },
{ type: 7, ip: '127.0.0.1' },
{ type: 7, ip: '::1' },
],
},
],
})
return { cert, key }
})()
return generated
}
/**
* Write the fixture into a directory, creating it when needed.
* @param directory - output directory; the fixture's own file names are replaced.
* @returns the written paths plus the PEM a client trusts.
*/
export async function materializeTlsFixture(directory: string): Promise<TlsFixture> {
const pems = tlsPems()
const root = resolve(directory)
const fixture: TlsFixture = {
certFile: join(root, 'tls-cert.pem'),
keyFile: join(root, 'tls-key.pem'),
caFile: join(root, 'tls-ca.pem'),
ca: pems.cert,
}
await mkdir(root, { recursive: true })
await Promise.all([
writeFile(fixture.certFile, pems.cert),
writeFile(fixture.caFile, pems.cert),
writeFile(fixture.keyFile, pems.key, { mode: 0o600 }),
])
return fixture
}
+331 -36
View File
@@ -2,42 +2,110 @@
* REAL-composition coverage: a test-only cordis.yml booted through the
* vendored Loader mounts the webserver row, and every assertion observes the
* user-visible HTTP surface of the running server (routing precedence, index
* taps, fallback-seat semantics, per-request error containment, teardown). The
* bind-address schema and both address predicates are asserted directly, before
* any server binds; one wildcard load denial goes through the real Loader.
* taps, fallback-seat semantics, per-request error containment, the TLS
* listener and its teardown). The bind-address schema and both address
* predicates are asserted directly, before any server binds; one wildcard load
* denial goes through the real Loader.
*/
import { generateKeyPairSync } from 'node:crypto'
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { once } from 'node:events'
import { connect } from 'node:net'
import { Agent, request as httpsRequest } from 'node:https'
import { connect, createServer as createTcpServer, type AddressInfo } from 'node:net'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { dirname, join } from 'node:path'
import type { Duplex } from 'node:stream'
import { pathToFileURL } from 'node:url'
import { afterEach, describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context, FiberState } from '@deepseek-ai/cordis'
import Loader from '@deepseek-ai/cordis-plugin-loader'
import Include from '@deepseek-ai/cordis-plugin-include'
import HttpServer, { isLoopbackHost, normalizeBindAddress, renderIndexInjections } from '../src/index.ts'
import HttpServer, { isLoopbackHost, normalizeBindAddress, renderIndexInjections, type TlsConfig } from '../src/index.ts'
import { materializeTlsFixture, type TlsFixture } from './tls-fixture.ts'
let root: string | undefined
let context: Context | undefined
afterEach(async () => {
await context?.fiber.dispose()
context = undefined
if (root !== undefined) await rm(root, { recursive: true, force: true })
root = undefined
/** One-shot barrier for disposal overlapping a pending PEM read. */
const tlsReadGate = vi.hoisted(() => {
let held: { entered: () => void; resume: Promise<void> } | undefined
return {
/** Arm the gate and return the barrier the reading side reaches. */
arm() {
const entered = Promise.withResolvers<undefined>()
const resumed = Promise.withResolvers<undefined>()
held = { entered: () => { entered.resolve(undefined) }, resume: resumed.promise }
return { entered: entered.promise, release: () => { resumed.resolve(undefined) } }
},
async hold(path: string): Promise<void> {
if (held === undefined || !path.endsWith('.pem')) return
const gate = held
held = undefined
gate.entered()
await gate.resume
},
}
})
/** Write a cordis.yml with one webserver row, then boot it through the real Loader. */
async function loadComposition(port = 0, gzip = false, host = '127.0.0.1'): Promise<Context> {
root = await mkdtemp(join(tmpdir(), 'dsh-webserver-loader-'))
vi.mock('node:fs/promises', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs/promises')>()
return {
...actual,
async readFile(...args: Parameters<typeof actual.readFile>) {
if (typeof args[0] === 'string') await tlsReadGate.hold(args[0])
return actual.readFile(...args)
},
}
})
// Every composition a test boots owns a temp root and a fiber; both are released
// even when an assertion fails, so a test may boot more than one.
let compositions: { context: Context; root: string }[] = []
let tlsRoot: string | undefined
afterEach(async () => {
const loaded = compositions
compositions = []
await Promise.all(loaded.map(async ({ context, root }) => {
await context.fiber.dispose()
await rm(root, { recursive: true, force: true })
}))
if (tlsRoot !== undefined) await rm(tlsRoot, { recursive: true, force: true })
tlsRoot = undefined
})
/** Materialize the generated TLS fixture into a temp root this file cleans up. */
async function tlsFixture(): Promise<TlsFixture> {
tlsRoot ??= await mkdtemp(join(tmpdir(), 'dsh-webserver-tls-'))
return materializeTlsFixture(tlsRoot)
}
/**
* Write a cordis.yml with one webserver row, then boot it through the real Loader.
* @param tls - a TLS row (partial rows model a user's incomplete YAML), `null`
* for an explicit empty `tls:` (what a user's `tls:` with no value parses to),
* or undefined to omit the field entirely.
*/
async function loadComposition(
port = 0,
gzip = false,
host = '127.0.0.1',
tls?: Partial<TlsConfig> | null,
): Promise<Context> {
const root = await mkdtemp(join(tmpdir(), 'dsh-webserver-loader-'))
const configPath = join(root, 'cordis.yml')
await writeFile(configPath, [
"- name: '@deepseek-ai/dsh-host-webserver'",
' config:',
` host: '${host}'`,
` port: ${String(port)}`,
...(tls === undefined
? []
: tls === null
? [' tls:']
: [
' tls:',
...(tls.certFile === undefined ? [] : [` certFile: '${tls.certFile}'`]),
...(tls.keyFile === undefined ? [] : [` keyFile: '${tls.keyFile}'`]),
]),
...(gzip
? [
' compression: gzip',
@@ -48,7 +116,8 @@ async function loadComposition(port = 0, gzip = false, host = '127.0.0.1'): Prom
'',
].join('\n'))
context = new Context()
const context = new Context()
compositions.push({ context, root })
context.baseUrl = pathToFileURL(root).href + '/'
await context.plugin(Loader)
context.loader.builtins.include = Include
@@ -70,6 +139,11 @@ async function loadComposition(port = 0, gzip = false, host = '127.0.0.1'): Prom
return context
}
/** The webserver row's fiber in a Loaded composition. */
function webserverFiber(context: Context) {
return [...context.loader.entries()].find(entry => entry.options.name === '@deepseek-ai/dsh-host-webserver')?.fiber
}
/** GET (by default) one path against the running server; returns status plus a body prefix. */
async function request(
port: number,
@@ -98,6 +172,52 @@ async function upgrade(port: number, path: string): Promise<ReturnType<typeof co
return socket
}
/**
* GET one path over TLS. `ca` is the client's trust anchor, and a call without
* it trusts nothing, so it only succeeds if the listener really speaks TLS.
*/
function tlsRequest(
port: number,
path: string,
ca?: string,
agent?: Agent,
): Promise<{ status: number; body: string }> {
const { promise, resolve, reject } = Promise.withResolvers<{ status: number; body: string }>()
const request = httpsRequest({
host: '127.0.0.1',
port,
path,
...(ca === undefined ? {} : { ca }),
...(agent === undefined ? {} : { agent }),
}, (response) => {
let body = ''
response.setEncoding('utf8')
response.on('data', (chunk: string) => { body += chunk })
response.on('end', () => { resolve({ status: response.statusCode ?? 0, body }) })
})
request.on('error', reject)
request.end()
return promise
}
/** Open one upgrade request over TLS and return after the handler answers 101. */
function tlsUpgrade(port: number, path: string, ca: string): Promise<{ status: number; socket: Duplex }> {
const { promise, resolve, reject } = Promise.withResolvers<{ status: number; socket: Duplex }>()
const request = httpsRequest({
host: '127.0.0.1',
port,
path,
ca,
headers: { Connection: 'Upgrade', Upgrade: 'dsh-test' },
})
request.on('upgrade', (response, socket) => {
resolve({ status: response.statusCode ?? 0, socket })
})
request.on('error', reject)
request.end()
return promise
}
describe('real Loader composition', () => {
it('accepts one concrete IP literal, keeping an IPv6 zone for listen', () => {
for (const host of ['127.0.0.1', '10.1.2.3', '0.1.2.3', '::1', 'fd00::1', '::ffff:10.1.2.3', '::ffff:0.1.2.3', '0:0:0:0:0:0:0:1', 'fe80::1%lo', '::0.0.0.1', '::0.0.0.1%lo', '::127.0.0.1']) {
@@ -128,9 +248,9 @@ describe('real Loader composition', () => {
// The Loader is nontransactional: a rejected config leaves a FAILED fiber
// whose await carries the schema error rather than rejecting loader.await().
const denied = await loadComposition(0, false, '::ffff:0.0.0.0')
const entry = [...denied.loader.entries()].find(e => e.options.name === '@deepseek-ai/dsh-host-webserver')
expect(entry?.fiber?.state).toBe(FiberState.FAILED)
await expect(entry?.fiber?.await()).rejects.toThrow(/is an unspecified \(wildcard\) address/)
const fiber = webserverFiber(denied)
expect(fiber?.state).toBe(FiberState.FAILED)
await expect(fiber?.await()).rejects.toThrow(/is an unspecified \(wildcard\) address/)
})
it('classifies loopback bind addresses from the parsed value, including mapped and zone forms', () => {
@@ -419,23 +539,198 @@ describe('real Loader composition', () => {
+ '<script>(globalThis.__DSH_BOOT_READY__ ??= Promise.withResolvers()).resolve()</script>')
})
it('fails the fiber when the port is already taken (fail-loud at activation)', { timeout: 60_000 }, async () => {
const first = await loadComposition()
const takenPort = first.webServer.port
const firstRoot = root
root = undefined // keep the first composition's files until the end
it('serves HTTPS to a verifying client and reports the https protocol', { timeout: 60_000 }, async () => {
const fixture = await tlsFixture()
const tls = { certFile: fixture.certFile, keyFile: fixture.keyFile }
expect(HttpServer.Config({ host: '127.0.0.1', port: 0, tls })).toEqual({
host: '127.0.0.1',
port: 0,
tls,
compression: 'none',
compressionLevel: 1,
compressionThresholdBytes: 1024,
})
let second: Context | undefined
const loaded = await loadComposition(0, false, '127.0.0.1', tls)
const server = loaded.webServer
expect(server.protocol).toBe('https:')
server.register({
kind: 'exact',
path: '/tls',
handler: (_req, res) => {
res.writeHead(200, { 'content-type': 'text/plain' })
res.end('SECURE')
},
})
// The fixture CA is the only reason the handshake succeeds, so this is a
// verifying client rather than one that disabled its checks.
expect(await tlsRequest(server.port, '/tls', fixture.ca)).toEqual({ status: 200, body: 'SECURE' })
await expect(tlsRequest(server.port, '/tls')).rejects.toMatchObject({ code: 'DEPTH_ZERO_SELF_SIGNED_CERT' })
// The listener speaks TLS only: a plain-HTTP client cannot reach the route.
await expect(request(server.port, '/tls')).rejects.toThrow()
await loaded.fiber.dispose()
await expect(tlsRequest(server.port, '/tls', fixture.ca)).rejects.toThrow()
})
it('treats an explicit null tls as no TLS and keeps serving plain HTTP', { timeout: 60_000 }, async () => {
// A user's `tls:` with no value reaches the service as null through the schema.
const loaded = await loadComposition(0, false, '127.0.0.1', null)
const server = loaded.webServer
expect(server.protocol).toBe('http:')
server.register({ kind: 'exact', path: '/plain', handler: (_req, res) => { res.writeHead(200); res.end('PLAIN') } })
expect(await request(server.port, '/plain')).toMatchObject({ status: 200, body: 'PLAIN' })
await expect(tlsRequest(server.port, '/plain')).rejects.toThrow()
})
it('rejects unusable TLS material at activation, before the listener binds', { timeout: 60_000 }, async () => {
const fixture = await tlsFixture()
const directory = dirname(fixture.certFile)
// A valid key that pairs with nothing, plus an empty and an absent file.
const otherKey = generateKeyPairSync('rsa', { modulusLength: 2048 })
.privateKey.export({ type: 'pkcs8', format: 'pem' }).toString()
const otherKeyFile = join(directory, 'tls-other-key.pem')
const emptyFile = join(directory, 'tls-empty.pem')
await writeFile(otherKeyFile, otherKey)
await writeFile(emptyFile, '')
// Holding the port makes the ordering observable: material validated only
// after a successful bind would report EADDRINUSE here instead.
const holder = createTcpServer()
const listening = once(holder, 'listening')
holder.listen(0, '127.0.0.1')
await listening
const port = (holder.address() as AddressInfo).port
const cases: { label: string; config: Partial<TlsConfig>; expected: RegExp }[] = [
{
label: 'mismatched key',
config: { certFile: fixture.certFile, keyFile: otherKeyFile },
expected: /are not a usable certificate and key pair/,
},
{
label: 'absent file',
config: { certFile: join(directory, 'tls-absent.pem'), keyFile: fixture.keyFile },
expected: /cannot read tls\.certFile/,
},
{
label: 'empty file',
config: { certFile: emptyFile, keyFile: fixture.keyFile },
expected: /tls\.certFile .* is empty/,
},
{
// Both files are one setting: a row naming only the certificate never resolves.
label: 'incomplete pair',
config: { certFile: fixture.certFile },
expected: /tls\.keyFile/,
},
]
try {
second = await loadComposition(takenPort)
const entry = [...second.loader.entries()].find(e => e.options.name === '@deepseek-ai/dsh-host-webserver')
expect(entry?.fiber?.state).toBe(FiberState.FAILED)
await expect(entry?.fiber?.await()).rejects.toThrow('EADDRINUSE')
for (const { label, config, expected } of cases) {
const denied = await loadComposition(port, false, '127.0.0.1', config)
const fiber = webserverFiber(denied)
expect(fiber?.state, label).toBe(FiberState.FAILED)
if (fiber === undefined) throw new Error(`${label}: webserver row is missing from the composition`)
const failure = await fiber.await().then(() => '', (error: unknown) => error instanceof Error ? error.message : String(error))
expect(failure, label).toMatch(expected)
expect(failure, label).not.toMatch(/EADDRINUSE/)
// Diagnostics carry the paths that failed and never the material itself.
expect(failure, label).not.toMatch(/PRIVATE KEY/)
expect(failure, label).not.toContain(otherKey.split('\n')[1])
}
} finally {
await second?.fiber.dispose()
context = first
if (root !== undefined) await rm(root, { recursive: true, force: true })
root = firstRoot
const closed = once(holder, 'close')
holder.close()
await closed
}
})
it('reaches teardown quiescence for upgraded, idle, and handshaking TLS sockets', { timeout: 60_000 }, async () => {
const fixture = await tlsFixture()
const loaded = await loadComposition(0, false, '127.0.0.1', { certFile: fixture.certFile, keyFile: fixture.keyFile })
const server = loaded.webServer
let upgradeClosed = false
server.registerUpgrade({
path: '/events',
handler: (_req, socket) => {
socket.once('close', () => { upgradeClosed = true })
socket.write('HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: dsh-test\r\n\r\n')
},
})
server.register({ kind: 'exact', path: '/probe', handler: (_req, res) => { res.writeHead(200); res.end('EXACT') } })
const clients = new Map<Duplex, Promise<void>>()
const clientErrors: Error[] = []
const observeClient = (socket: Duplex): void => {
const onError = (error: Error): void => { clientErrors.push(error) }
socket.on('error', onError)
// Destruction may emit ECONNRESET before close; only close confirms release.
clients.set(socket, new Promise<void>((resolve) => {
socket.once('close', () => {
socket.off('error', onError)
resolve()
})
}))
}
const agent = new Agent({ keepAlive: true, ca: fixture.ca, maxSockets: 1 })
const idleReady = new Promise<void>((resolve) => {
agent.once('free', (socket: Duplex) => {
observeClient(socket)
resolve()
})
})
try {
const upgraded = await tlsUpgrade(server.port, '/events', fixture.ca)
observeClient(upgraded.socket)
expect(upgraded.status).toBe(101)
expect(await tlsRequest(server.port, '/probe', fixture.ca, agent)).toEqual({ status: 200, body: 'EXACT' })
await idleReady
// A TCP connection without a ClientHello is absent from closeAllConnections().
const handshaking = connect(server.port, '127.0.0.1')
observeClient(handshaking)
await once(handshaking, 'connect')
expect(clientErrors).toEqual([])
for (const socket of clients.keys()) expect(socket.destroyed).toBe(false)
await loaded.fiber.dispose()
await Promise.all(clients.values())
for (const error of clientErrors) expect(error).toMatchObject({ code: 'ECONNRESET' })
expect(upgradeClosed).toBe(true)
await expect(tlsRequest(server.port, '/probe', fixture.ca)).rejects.toThrow()
} finally {
agent.destroy()
for (const socket of clients.keys()) socket.destroy()
await Promise.all(clients.values())
}
})
it('closes the listener when disposal lands while the TLS material is still being read', { timeout: 60_000 }, async () => {
const fixture = await tlsFixture()
const gate = tlsReadGate.arm()
const loading = loadComposition(0, false, '127.0.0.1', { certFile: fixture.certFile, keyFile: fixture.keyFile })
await gate.entered
// loadComposition registers the composition before its Loader starts, so the
// booting one — and the service its constructor already provided — is
// observable while initialization is parked inside the TLS read.
const composed = compositions.at(-1)
if (composed === undefined) throw new Error('the booting composition is not registered')
const server = composed.context.webServer
// Disposal is issued first and settles only after the read, the listen, and
// the teardown it owns: the listener bound in between must be closed.
const disposal = composed.context.fiber.dispose()
gate.release()
await disposal
await loading
expect(server.port).toBeGreaterThan(0)
await expect(tlsRequest(server.port, '/', fixture.ca)).rejects.toThrow()
})
it('fails the fiber when the port is already taken (fail-loud at activation)', { timeout: 60_000 }, async () => {
const first = await loadComposition()
const second = await loadComposition(first.webServer.port)
const fiber = webserverFiber(second)
expect(fiber?.state).toBe(FiberState.FAILED)
await expect(fiber?.await()).rejects.toThrow('EADDRINUSE')
})
})
+17
View File
@@ -10289,6 +10289,9 @@ importers:
'@types/negotiator':
specifier: ^0.6.5
version: 0.6.5
selfsigned:
specifier: ^4.0.1
version: 4.0.1
packages/identity/anonymous-user-id:
devDependencies:
@@ -21380,6 +21383,10 @@ packages:
resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
[email protected]:
resolution: {integrity: sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==}
engines: {node: '>= 6.13.0'}
[email protected]:
resolution: {integrity: sha512-OMcPNvqTCFUnNaBlmdgq+lfNqY7gTiSmNRDjY3uAXRyudeKZEZxu3CLtjMQrx4zZxCX2b/mpNqTtwuCJgXhHkw==}
engines: {node: ^20.17.0 || >=22.9.0}
@@ -22031,6 +22038,10 @@ packages:
[email protected]:
resolution: {integrity: sha512-AckIIV90rPDcBcglUwXPF3kg0P0qmPsPXAj6BBEENQE1p5yA1xfmDJzfi1Tappj37Pv2mVbKpL3Z1T+Nn7k1Qw==}
[email protected]:
resolution: {integrity: sha512-QVGzJryYPB7ctpYxoK4DDvH4kFf23wtBl0s6km/wN+JCWLqjutdyWaiXHwSg3B3ftDOFfu0B7FWRVNH0xNn6rw==}
engines: {node: '>=10'}
[email protected]:
resolution: {integrity: sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==}
@@ -29090,6 +29101,8 @@ snapshots:
fetch-blob: 3.2.0
formdata-polyfill: 4.0.10
[email protected]: {}
[email protected]:
dependencies:
env-paths: 2.2.1
@@ -29790,6 +29803,10 @@ snapshots:
[email protected]: {}
[email protected]:
dependencies:
node-forge: 1.4.0
[email protected]:
optional: true
+5
View File
@@ -1916,6 +1916,11 @@
"symbol": "Config",
"source": "packages/host/webserver/src/index.ts"
},
{
"doc": "docs/subsystems/web-server.md",
"symbol": "TlsConfig",
"source": "packages/host/webserver/src/index.ts"
},
{
"doc": "docs/subsystems/storage.md",
"symbol": "StorageForms",