Support concrete Web listener bind addresses

Co-authored-by: liukx0205 <harusoda@berkeley.edu>
This commit is contained in:
Yichen Yanandliukx0205 authored and Xu Hanxiang committed 2026-10-08 18:25:43 +08:00
1 parent d394af8ee8
commit cb9a590c86
80 files changed
+1171 -425

No files matched your search

@@ -9,11 +9,11 @@
en: c25db2ef111b2276
zh: 557891ca9e030ae5
/agent-note-one-carrier-level-browser-trust-boundary-for-all-api-routes/decision:
en: 0bfb5ba20be135a5
zh: 447e7d88a9ec0601
en: ca8044c9e0013be6
zh: d60af7af291299c3
/agent-note-one-carrier-level-browser-trust-boundary-for-all-api-routes/alternatives-considered:
en: 1a4b0ae8072e8e50
zh: d469553ad3b5d112
/agent-note-one-carrier-level-browser-trust-boundary-for-all-api-routes/consequences:
en: 8acf5e6f55a747c2
zh: bbd0b10927302965
en: e31df28a2e82a64d
zh: 4ca7097ebbc24cd2
@@ -13,9 +13,9 @@ The web GUI host serves `/api` over plain loopback HTTP (default `127.0.0.1:3080
Enforce browser trust once, at the carrier, for the entire `/api` prefix — two halves:
- **Media-type fence (dsh-client-connection)**: every `/api` POST must declare `application/json`, else 415 before parsing. Cross-site "simple" requests thereby stop existing: any cross-site attempt is forced into a CORS preflight this server never answers.
- **Authority fence (dsh-client-connection, `src/api-request-trust.ts`)**: every request must present a `Host` that is loopback or matches a `trustedHosts` entry (exact on `host:port`, any port on port-less entries, WHATWG-normalized; rebinding defense). Deliberately no shortcut for unmarked requests: over plain HTTP a browser attaches neither `Origin` nor Fetch-Metadata to reads (EventSource, images, navigations — those headers go only to trustworthy destinations), so an unmarked request may be a rebound browser read whose response the page can read, and Host is the one header rebinding cannot forge; non-browser clients pass via loopback, the derived LAN IP literals, or a declared authority. An attached `Origin` must equal the Host authority; `sec-fetch-site: cross-site` is refused outright. A `trustedHosts` entry that is not a bare, canonical authority fails the plugin load — WHATWG parsing would otherwise quietly authorize the hostname inside a typo or broaden an exact-port grant. `host.pickDirectory` loses its bespoke guard and rides the same fence.
- **Authority fence (dsh-client-connection, `src/api-request-trust.ts`)**: every request must present a `Host` that is loopback or matches a `trustedHosts` entry (exact on `host:port`, any port on port-less entries, WHATWG-normalized; rebinding defense). Deliberately no shortcut for unmarked requests: over plain HTTP a browser attaches neither `Origin` nor Fetch-Metadata to reads (EventSource, images, navigations — those headers go only to trustworthy destinations), so an unmarked request may be a rebound browser read whose response the page can read, and Host is the one header rebinding cannot forge; non-browser clients pass via loopback, a declared authority, or the listener's own bind literal that [Bind one concrete Web address](2026-09-19-concrete-web-bind-address.md) admits. An attached `Origin` must equal the Host authority; `sec-fetch-site: cross-site` is refused outright. A `trustedHosts` entry that is not a bare, canonical authority fails the plugin load — WHATWG parsing would otherwise quietly authorize the hostname inside a typo or broaden an exact-port grant. `host.pickDirectory` loses its bespoke guard and rides the same fence.
Reachability is the webserver binding's policy (`host: 127.0.0.1 | 0.0.0.0`), and this fence is a confused-deputy defense rather than identity. Connection applies the separate [browser token authentication](2026-08-24-browser-token-authentication.md) after the fence. The fence does not inspect peer socket addresses: binding expresses reachability, `trustedHosts` names accepted authorities, and the socket address adds nothing the Host/Origin checks need.
Reachability is the webserver binding's policy (`host`; [Bind one concrete Web address](2026-09-19-concrete-web-bind-address.md) owns which literals it accepts), and this fence is a confused-deputy defense rather than identity. Connection applies the separate [browser token authentication](2026-08-24-browser-token-authentication.md) after the fence. The fence does not inspect peer socket addresses: binding expresses reachability, `trustedHosts` names accepted authorities, and the socket address adds nothing the Host/Origin checks need.
`HostConnectionHandle.allowsRemoteAuthorities` projects whether any validated `trustedHosts` entry names a non-loopback hostname: a loopback listener behind a proxy can still serve remote browsers, so the directory picker requires a policy without remote authorities before it picks the native chooser.
@@ -28,6 +28,6 @@ Reachability is the webserver binding's policy (`host: 127.0.0.1 | 0.0.0.0`), an
## Consequences
- Any future `/api` method is covered by construction; there is no per-route trust decision left to forget.
- A custom non-loopback composition must trust its serving authorities or requests are refused, then satisfy browser authentication like every loopback request. The shipped CLI rejects `--host 0.0.0.0`; `--trusted-host` only extends the Host/Origin fence and grants no identity.
- A non-loopback listener accepts its own bind IP directly ([Bind one concrete Web address](2026-09-19-concrete-web-bind-address.md)); other remote authorities require configured trust. Every accepted authority still requires browser authentication. `--trusted-host` extends only the Host/Origin fence and grants no identity.
- Clients must label POST bodies `application/json` (ours always did; raw-fetch tests gained the header).
- Host and Origin remain request-routing evidence only. The process token and signed cookie establish the browser identity used by every Host method.
@@ -13,9 +13,9 @@ Web GUI 宿主以纯 loopback HTTP 提供 `/api`(默认 `127.0.0.1:3080`;CLI
在载体层对整个 `/api` 前缀一次性执行浏览器信任检查——分为两部分:
- **媒体类型栅栏(dsh-client-connection)**:每个 `/api` POST 必须声明 `application/json`,否则在解析前以 415 拒绝。跨站「简单请求」由此不复存在:任何跨站尝试都被逼进一次本服务器从不应答的 CORS 预检。
- **权威栅栏(dsh-client-connection,`src/api-request-trust.ts`)**:每个请求的 `Host` 都必须是回环地址,或与某个 `trustedHosts` 条目匹配(带端口的 `host:port` 条目精确匹配,不带端口的条目匹配任意端口,均经 WHATWG 归一化;rebinding 防御)。刻意不为无标记请求开捷径:明文 HTTP 下浏览器的读取(EventSource、图片、导航——这些头只发给可信目标)既不带 `Origin` 也不带 Fetch-Metadata,因此无标记请求可能是被重绑页面发起且响应可被读走的读取,而 Host 是重绑唯一伪造不了的请求头;非浏览器客户端经由回环地址、推导的 LAN IP 字面量或已声明的权威通过。若带 `Origin` 则必须与 Host 权威完全一致;`sec-fetch-site: cross-site` 一律拒绝。不是单纯规范化 authority 的 `trustedHosts` 条目会导致插件加载失败——否则 WHATWG 解析会悄悄授权笔误里的 hostname,或放大精确端口授权。`host.pickDirectory` 失去专属守卫,与其他请求同栅而行。
- **权威栅栏(dsh-client-connection,`src/api-request-trust.ts`)**:每个请求的 `Host` 都必须是回环地址,或与某个 `trustedHosts` 条目匹配(带端口的 `host:port` 条目精确匹配,不带端口的条目匹配任意端口,均经 WHATWG 归一化;rebinding 防御)。刻意不为无标记请求开捷径:明文 HTTP 下浏览器的读取(EventSource、图片、导航——这些头只发给可信目标)既不带 `Origin` 也不带 Fetch-Metadata,因此无标记请求可能是被重绑页面发起且响应可被读走的读取,而 Host 是重绑唯一伪造不了的请求头;非浏览器客户端经由回环地址、已声明的权威,或[绑定一个具体的 Web 地址](2026-09-19-concrete-web-bind-address.zh.md)所接纳的监听器自身绑定字面量通过。若带 `Origin` 则必须与 Host 权威完全一致;`sec-fetch-site: cross-site` 一律拒绝。不是单纯规范化 authority 的 `trustedHosts` 条目会导致插件加载失败——否则 WHATWG 解析会悄悄授权笔误里的 hostname,或放大精确端口授权。`host.pickDirectory` 失去专属守卫,与其他请求同栅而行。
可达性由 webserver 的绑定配置(`host: 127.0.0.1 | 0.0.0.0`)控制,这道栅栏是混淆代理人防御,而不是身份。Connection 在栅栏之后应用独立的[浏览器令牌认证](2026-08-24-browser-token-authentication.zh.md)。栅栏不检查对端 socket 地址:绑定表达可达性,`trustedHosts` 点名接受的 authority,socket 地址提供不了 Host/Origin 校验需要的额外信息。
可达性由 webserver 的绑定配置(`host`;[绑定一个具体的 Web 地址](2026-09-19-concrete-web-bind-address.zh.md)拥有它接受哪些字面量)控制,这道栅栏是混淆代理人防御,而不是身份。Connection 在栅栏之后应用独立的[浏览器令牌认证](2026-08-24-browser-token-authentication.zh.md)。栅栏不检查对端 socket 地址:绑定表达可达性,`trustedHosts` 点名接受的 authority,socket 地址提供不了 Host/Origin 校验需要的额外信息。
`HostConnectionHandle.allowsRemoteAuthorities` 表示是否有通过校验的 `trustedHosts` 条目命名非回环主机名:代理后的回环监听器也能服务远程浏览器,因此目录选择器要求策略不接纳远程 authority,才会选择原生交互。
@@ -28,6 +28,6 @@ Web GUI 宿主以纯 loopback HTTP 提供 `/api`(默认 `127.0.0.1:3080`;CLI
## 后果
- 未来任何 `/api` 方法天然在覆盖范围内;不存在会被遗忘的按路由信任决定。
- 自定义非 loopback 组合必须信任其服务 authority,否则请求会被拒绝;随后仍像每个 loopback 请求一样满足浏览器认证。随附 CLI 拒绝 `--host 0.0.0.0`;`--trusted-host` 只扩展 Host/Origin 栅栏,绝不授予身份。
- 非回环监听器直接接受自己的绑定 IP([绑定一个具体的 Web 地址](2026-09-19-concrete-web-bind-address.zh.md));其他远程 authority 需要配置的信任。每个被接受的 authority 仍须满足浏览器认证。`--trusted-host` 只扩展 Host/Origin 栅栏,绝不授予身份。
- 客户端必须给 POST 体标注 `application/json`(我们自己的客户端一向如此;裸 fetch 测试补上了该头)。
- Host 与 Origin 仍只是请求路由证据。进程令牌与签名 cookie 建立每个 Host 方法使用的浏览器身份。
@@ -72,5 +72,5 @@
en: 7433bdb522bb2083
zh: 545c17613b636f00
/agent-note-typert-gateway-targeted-method-calls/consequences:
en: c3459afdb606cfbe
zh: ec69f2bb39170a7c
en: abab48251550f584
zh: 2568c46d78dbefdf
@@ -520,7 +520,7 @@ A consumer may import a Remote contract that is not currently mounted on the Hos
Connection's general channel API must suit both the current HTTP carrier and a future WebSocket carrier. If the Client Remote or Gateway exposes `fetch`, an HTTP request, or a route handle, WebSocket migration will pierce the Remote layer again. Those physical objects must therefore remain internal to Connection.
Remote endpoints use Connection's `trusted-host` authority. Loopback is accepted by default and LAN callers require an explicit trusted-host configuration, but this layer adds no per-method caller authorization; every trusted host can invoke a mounted Remote endpoint.
Remote endpoints use Connection's `trusted-host` authority. Loopback and the listener's own bind address are accepted by default; any other LAN authority requires an explicit trusted-host configuration. This layer adds no per-method caller authorization; every accepted authority can invoke a mounted Remote endpoint.
`hasSeen()` favors strict-definition safety over SRC availability. While a strict descriptor is withdrawn, such as during HMR, the Gateway continues to claim the endpoint and reports it unavailable instead of falling back to a weak SRC descriptor. Re-registration restores it; only a Typert registry restart forgets the historical strict definition.
@@ -520,7 +520,7 @@ SRC 弱 descriptor 不验证普通 JSON 内部结构。Host Remote 签名变化
Connection 的通用 channel API 必须同时适合当前 HTTP carrier 和后续 WebSocket carrier。若 Client Remote 或 Gateway 暴露 `fetch`、HTTP request 或 route handle,WebSocket 迁移会再次穿透 Remote 层,因此这些物理对象必须留在 Connection 内部。
Remote endpoint 使用 Connection 的 `trusted-host` authority。系统默认接受 loopback;LAN 调用方必须通过显式 trusted-host 配置接入,但本层不增加逐方法调用方授权,因此每个 trusted host 都能调用已挂载的 Remote endpoint。
Remote endpoint 使用 Connection 的 `trusted-host` authority。系统默认接受 loopback 与监听器自身的绑定地址;其他 LAN authority 必须通过显式 trusted-host 配置接入。本层不增加逐方法调用方授权,因此每个被接受的 authority 都能调用已挂载的 Remote endpoint。
`hasSeen()` 优先保障 strict definition 的安全性,而非 SRC 可用性。strict descriptor 撤回时(例如 HMR 期间),Gateway 会继续认领 endpoint 并报告不可用,而不会回退到弱 SRC descriptor。重新注册即可恢复;只有重启 Typert 注册表才会忘记历史 strict definition。
@@ -9,8 +9,8 @@
en: 01293500639c9d79
zh: 6d19e4843955c811
/agent-note-browser-launch-token-authentication/decision:
en: 063f9698b8a94287
zh: 848b2c1134b7a100
en: 54efa59339f75256
zh: 0f53ead3844151c6
/agent-note-browser-launch-token-authentication/verification:
en: 189b36d0fc5f3a17
zh: 08993cb585204fc0
@@ -14,13 +14,13 @@ The Web Host runs tool-capable Sessions with the current operating-system user's
Each Host process generates a random launch token, retained by the root application context across Connection hot reloads. `dsh-web-app` prints and opens the normal root URL with that token in the query once per process. `frontend-static` asks Connection to authorize index responses: only `GET /?token=...` exchanges the process token for a cookie, then redirects to clean `./`; the token is not accepted on API paths or in an Authorization header. An obsolete token paired with a valid cookie redirects to clean `./`. Missing and invalid credentials receive one minimal 401 response. Static non-index assets remain public.
The cookie is a signed, authority-bound bearer. Its deterministic name and signed payload both include the normalized hostname plus port, so one Harness home can run independent Web ports without cookie collisions. The payload carries safe-integer issue and expiry times under an absolute lifetime; `cookieMaxAgeDays` defaults to 30. The cookie is host-only, `Path=/`, `HttpOnly`, and `SameSite=Strict`. It omits `Secure` because the shipped server uses loopback HTTP. There is no logout operation.
The cookie is a signed, authority-bound bearer. Its deterministic name and signed payload both include the normalized hostname plus port, so one Harness home can run independent Web ports without cookie collisions. The payload carries safe-integer issue and expiry times under an absolute lifetime; `cookieMaxAgeDays` defaults to 30. The cookie is host-only, `Path=/`, `HttpOnly`, and `SameSite=Strict`. The HTTP listener omits `Secure`. There is no logout operation.
The HMAC secret is a versioned `grant` record at `client-connection/browser-session` in `ctx.credentials`; the local provider stores it in `$DSH_HOME/.credentials.yaml`. Connection loads or creates the record during activation and retains the secret for synchronous request verification. An active Connection continues using its loaded secret if the durable record changes; the next activation loads the replacement or creates a missing record, so deleting the record and restarting the process revokes every existing cookie. Invalid owner payloads fail loud instead of being replaced. The launch token itself is never persisted and changes on every process start, while an unexpired cookie remains valid across restarts on the same authority.
The in-page Web Worker preview exposes no network socket. Its page-owned `postMessage` tunnel enters the real route first, then retries a 401 or 403 through the worker-local fetch handler. This keeps Connection interceptors while limiting the authentication bypass to the page that created the Host worker.
The shipped CLI continues to reject `--host 0.0.0.0`. Authentication does not imply supported network deployment, TLS, forwarding-header interpretation, or proxy configuration.
The shipped CLI accepts concrete local bind addresses but rejects wildcard addresses such as `--host 0.0.0.0`. Authentication does not configure TLS, forwarding-header interpretation, or proxies.
## Verification
@@ -14,13 +14,13 @@ Web Host 以当前操作系统用户的权限运行具有工具能力的 Session
每个 Host 进程生成随机启动令牌,并由应用根 context 跨 Connection 热重载保留。`dsh-web-app` 每个进程只打印并打开一次 query 中带该令牌的普通根 URL。`frontend-static` 请求 Connection 授权 index 响应:只有 `GET /?token=...` 会把进程令牌交换为 cookie,再重定向到干净的 `./`;API 路径和 Authorization header 都不接受该令牌。过时令牌如果同时带有有效 cookie,会重定向到干净的 `./`。缺失与无效凭据得到同一份最小 401 响应。非 index 静态资产保持公开。
cookie 是签名且绑定 authority 的 bearer。确定性名称与签名 payload 都包含规范化 hostname 和 port,因此同一 Harness home 可以在不同 Web port 运行而不发生 cookie 冲突。payload 在绝对有效期内携带安全整数形式的签发与过期时间;`cookieMaxAgeDays` 默认为 30。cookie 是 host-only、`Path=/`、`HttpOnly`、`SameSite=Strict`。随附服务器使用 loopback HTTP,因此不设置 `Secure`。这里没有 logout 操作。
cookie 是签名且绑定 authority 的 bearer。确定性名称与签名 payload 都包含规范化 hostname 和 port,因此同一 Harness home 可以在不同 Web port 运行而不发生 cookie 冲突。payload 在绝对有效期内携带安全整数形式的签发与过期时间;`cookieMaxAgeDays` 默认为 30。cookie 是 host-only、`Path=/`、`HttpOnly`、`SameSite=Strict`。HTTP 监听器不设置 `Secure`。这里没有 logout 操作。
HMAC 密钥是 `ctx.credentials` 中位于 `client-connection/browser-session` 的版本化 `grant` 记录;本地提供方将其存入 `$DSH_HOME/.credentials.yaml`。Connection 在激活期间加载或创建该记录,并保留密钥以同步校验请求。持久记录发生变化后,当前 Connection 继续使用已加载的密钥;下一次激活会加载替换记录或创建缺失记录,因此删除记录并重启进程会撤销全部既有 cookie。无效 owner payload 会明确失败,而不是被覆盖。启动令牌本身绝不持久化并在每次进程启动时变化;未过期 cookie 则能在相同 authority 上跨重启继续有效。
页内 Web Worker preview 不暴露网络 socket。其由页面持有的 `postMessage` tunnel 先进入真实 route,收到 401 或 403 后再经 worker 本地 fetch handler 重试。这样既保留 Connection interceptor,又把认证绕过限制在创建 Host worker 的页面内。
随附 CLI 继续拒绝 `--host 0.0.0.0`。认证不代表支持网络部署、TLS、转发 header 解释或代理配置。
随附 CLI 接受本机具体绑定地址,但拒绝 `--host 0.0.0.0` 等通配地址。认证不会配置 TLS、转发 header 解释或代理。
## 验证
@@ -0,0 +1,22 @@
# Bilingual-pair consistency record for 2026-09-19-concrete-web-bind-address.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-19-concrete-web-bind-address.md
/agent-note-bind-one-concrete-web-address:
en: f70f2b6b24ef16dd
zh: 64100dbc55eee33d
/agent-note-bind-one-concrete-web-address/problem:
en: ffc265e61eadc31b
zh: fcc70f0a8817914c
/agent-note-bind-one-concrete-web-address/decision:
en: 5fe4f2ddba2ee2da
zh: bb140a16f056d4b4
/agent-note-bind-one-concrete-web-address/alternatives-considered:
en: 0e4d475347796821
zh: e621e606bc5b1045
/agent-note-bind-one-concrete-web-address/consequences:
en: 09ca30632d3930b2
zh: 87d286ed53f834db
/agent-note-bind-one-concrete-web-address/testing:
en: 4bb8f4bf4ad943c3
zh: 2e24d56d01b52574
@@ -0,0 +1,33 @@
# Agent Note: Bind one concrete Web address
Status: implemented
English | [中文](2026-09-19-concrete-web-bind-address.zh.md)
## Problem
The Web listener accepted `127.0.0.1` or `0.0.0.0`. A wildcard bind exposed every interface of the machine, so the bundle sampled the LAN IPv4 addresses at bind time into a `webRuntime` service and appended them to `trustedHosts`, granting authorities for interfaces the operator never named and mixing inferred reachability into configured trust. A container or workstation that should expose exactly one address had no way to say so, IPv6 addresses were not accepted at all, and a browser dialing the exposed address sent it as `Host` only to be refused unless the sampled list happened to contain it.
## Decision
`webserver.config.host` accepts one concrete IPv4 or IPv6 address literal, loopback by default; the unspecified address in every spelling (`0.0.0.0`, `::`, `::ffff:0.0.0.0`, and their expansions) is rejected when the config loads, and the shipped `dsh web` command refuses the same spellings for `--host` before any consumer activates. An IPv6 `%zone` is kept for `listen`; classification through the exported `isWildcardHost` and `isLoopbackHost` reads the address alone.
The browser Host/Origin fence accepts the listener's own bind IP literal as `Host` on any port, independently of `trustedHosts` and ignoring the zone. A browser dialing that literal presents it as `Host`, the literal is no rebinding target, and the Origin and cross-site checks still apply. DNS names resolving to the address, other interfaces, and proxy authorities still require `--trusted-host`. Connection reads the invocation authorities directly from the `webStartup` provider; the `webRuntime` service and LAN sampling no longer exist.
The advertised URL uses IPv4 for mapped loopback binds, brackets other IPv6 binds, drops a loopback zone, and requires `--public-url` for a zoned non-loopback bind because a zone cannot appear in a URL. A non-loopback bind serves plain HTTP and warns at startup even when an HTTPS `--public-url` fronts it. `web-runtime` is a required startup entry, so a bind failure fails startup instead of leaving a silent listener-less process. Desktop pins `--host 127.0.0.1`, the address Electron dials.
## Alternatives considered
**Keep sampling LAN addresses into `trustedHosts`.** Rejected: the listener already owns the concrete bind address. Sampling grants authorities for other interfaces and mixes inferred reachability with configured trust; direct bind-IP acceptance preserves that distinction without another runtime service.
**Accept the bind IP only on the bound port.** Rejected: a port-less `trustedHosts` entry already matches any port, a proxy on the same address may listen elsewhere, and the port adds nothing to the rebinding defense the Host check provides.
**Keep `0.0.0.0` with a warning.** Rejected: binding every interface exposes remote code execution to every network the machine joins, and the operator can name each address that should be exposed.
## Consequences
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.
## 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.
@@ -0,0 +1,33 @@
# Agent Note: 绑定一个具体的 Web 地址
Status: implemented
[English](2026-09-19-concrete-web-bind-address.md) | 中文
## 问题
Web 监听器只接受 `127.0.0.1` 或 `0.0.0.0`。通配绑定会暴露机器的每个接口,因此 bundle 在绑定时把 LAN IPv4 地址采样进 `webRuntime` 服务并追加到 `trustedHosts`,为操作者从未点名的接口授予 authority,并把推断的可达性混入配置的信任。应当只暴露一个地址的容器或工作站无法表达这一点,IPv6 地址完全不被接受,浏览器拨号已暴露地址时把它作为 `Host` 发送,却只在采样列表恰好包含它时才被接受。
## 决策
`webserver.config.host` 接受一个具体的 IPv4 或 IPv6 地址字面量,默认回环;未指定地址的所有拼写(`0.0.0.0`、`::`、`::ffff:0.0.0.0` 及其展开形式)在配置加载时被拒绝,随附的 `dsh web` 命令在任何消费者激活前对 `--host` 拒绝同样的拼写。IPv6 的 `%zone` 保留给 `listen`;通过导出的 `isWildcardHost` 与 `isLoopbackHost` 分类时只读取地址本身。
浏览器 Host/Origin 栅栏接受监听器自身的绑定 IP 字面量作为任意端口上的 `Host`,独立于 `trustedHosts` 并忽略 zone。拨号该字面量的浏览器把它作为 `Host` 呈现,该字面量不是重绑目标,Origin 与跨站检查仍然生效。解析到该地址的 DNS 名称、其他接口与代理 authority 仍需 `--trusted-host`。Connection 直接从 `webStartup` 提供方读取调用 authority;`webRuntime` 服务与 LAN 采样不再存在。
公告 URL 对 mapped 回环绑定使用 IPv4,为其他 IPv6 绑定加方括号、剥离回环 zone,并因 zone 不能出现在 URL 中而要求带 zone 的非回环绑定显式提供 `--public-url`。非回环绑定提供明文 HTTP,即使前置 HTTPS 的 `--public-url` 也会在启动时警告。`web-runtime` 是必需的启动条目,因此绑定失败会使启动失败,而不是留下一个没有监听器的静默进程。Desktop 固定 `--host 127.0.0.1`,即 Electron 拨号的地址。
## 备选方案
**继续把采样的 LAN 地址加入 `trustedHosts`。** 否决:监听器已经持有具体绑定地址。采样会授权其他接口的 authority,并把推断的可达性混入配置的信任;直接接受绑定 IP 可以保留两者的区分,无需另设运行时服务。
**只在绑定端口上接受绑定 IP。** 否决:不带端口的 `trustedHosts` 条目本就匹配任意端口,同一地址上的代理可能监听其他端口,端口对 Host 检查提供的重绑防御没有增益。
**保留 `0.0.0.0` 并给出警告。** 否决:绑定所有接口会向机器加入的每个网络暴露远程代码执行,而操作者可以逐个点名应暴露的地址。
## 影响
操作者只暴露 `--host` 点名的接口,并无需额外配置即可通过该字面量访问监听器;其他每个远程 authority 都是一个 `--trusted-host` 决定。曾绑定 `0.0.0.0` 的组合在加载时失败,并给出点名被拒绝拼写的消息。纯 IPv6 主机得到端到端支持,包括打印与打开的 URL。
## 测试
`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` 条目。
+2 -2
View File
@@ -21,8 +21,8 @@
en: 8d2cfff9bab7327f
zh: 21515ab6e6798504
/dsh-cli-behavior-reference/web-profile:
en: 536c6883a0d54b14
zh: 6dd2469b71189c87
en: 8a805800d806e0e9
zh: ba88c38a0dd4f51b
/dsh-cli-behavior-reference/shared-deployment-behavior:
en: 6a7abc9339e8f37f
zh: 133adb834c8e195d
+4 -3
View File
@@ -106,19 +106,20 @@ 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 values of the rows that carry them, repeatable `--trusted-host` contributes invocation authorities through `ctx.webRuntime.trustedHosts` (a deployment expression concatenates its own authorities), and `--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. 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 loopback URL. 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.
`--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.
```sh
dsh web
dsh web --host "$(hostname -i | awk '{print $1}')" --public-url https://app.example/ --trusted-host app.example
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 serves `http://127.0.0.1:3080` by default and, for a local launch, opens that canonical host URL — the advertised root when `--public-url` names one — only after the complete Loader tree settles. A non-empty inherited `SSH_CONNECTION` or `SSH_TTY` suppresses the browser handoff because the SSH client or editor owns the local forwarded address; the host URL is still printed. The CLI intentionally does not support `--host 0.0.0.0` and exits with a usage error. Immediately before a local handoff it prints `dsh web: opening the default browser; pass --no-open to disable`; if the operating-system handoff fails, a diagnostic on stderr states the reason, leaves the server running, and names the URL for manual use. `--trusted-host` adds named authorities accepted by the `/api` browser-trust fence.
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.
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.
+4 -3
View File
@@ -108,19 +108,20 @@ dsh --profile tui
## Web Profile
`dsh web` 使用 profile 简写。启动器先解析自身的 flag,其余 flag 属于 web 应用,由组合包中的普通提供方解析。`--host` 和 `--port` 覆盖承载它们的那些行的组合取值,可重复的 `--trusted-host` 通过 `ctx.webRuntime.trustedHosts` 提供本次调用的 authority(部署表达式会拼接自己的 authority),`--no-open` 则只对本次调用关闭默认浏览器交接。客户端插件 HMR(热模块替换)接收器始终挂载,在 `pnpm run dev:web` 重建客户端 bundle 之前保持空闲;该命令先构建一次,再启动这同一个启动器并持续重建客户端 bundle,加 `--no-serve` 则只运行 watcher、配合别处启动的 `dsh web`。
`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`。
`--public-url <url>` 公告唯一的 HTTP(S) 应用根——可带转发前缀——替代监听器的 loopback URL。它不授予信任,因此浏览器可见的 authority 仍需用 `--trusted-host` 点名;[在反向代理之后发布 Web UI](../../../docs/user/guide/public-deployments.zh.md)列出了前置代理必须提供的内容。
`--public-url <url>` 公告唯一的 HTTP(S) 应用根——可带转发前缀——替代监听器的绑定地址 URL(回环绑定时为回环地址)。它不授予信任,因此浏览器可见的 authority 仍需用 `--trusted-host` 点名;[在反向代理之后发布 Web UI](../../../docs/user/guide/public-deployments.zh.md)列出了前置代理必须提供的内容。
```sh
dsh web
dsh web --host "$(hostname -i | awk '{print $1}')" --public-url https://app.example/ --trusted-host app.example
dsh web --no-open
dsh web --patch ./extra.cordis.yml
dsh web --dump-config
dsh web --help
```
生产 Web 运行器需要已构建的包和前端产物(`pnpm run build`)。默认服务地址是 `http://127.0.0.1:3080`;本机启动时,只在完整 Loader 配置树结算后才用默认浏览器打开该规范宿主机 URL(配置了 `--public-url` 时即为公告根)。继承的 `SSH_CONNECTION` 或 `SSH_TTY` 非空时会跳过浏览器交接,因为本地转发地址由 SSH 客户端或编辑器持有;宿主机 URL 仍会打印。CLI 有意不支持 `--host 0.0.0.0`,并会以用法错误退出。本机交接前会打印英文提示 `dsh web: opening the default browser; pass --no-open to disable`;若操作系统交接失败,stderr 诊断会说明原因、给出 URL 供手动访问,服务器仍继续运行。`--trusted-host` 可添加 `/api` 浏览器信任围栏接受的具名 authority。
生产 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,服务器仍继续运行。
进程关闭时,插件树最多有 5 秒完成 dispose。首次收到 `SIGINT` 或 `SIGTERM` 时会开始优雅排空:`SIGTERM` 是监督进程发出的常规停止请求,在所有运行模式下都以 0 退出;`SIGINT` 则报告 130。第二次收到信号时会立即强制退出。如果一次性运行在正常结束时已经卡在 dispose 阶段,第一次按下 `Ctrl+C` 就会直接升级为强制退出,而不会被忽略。
-1
View File
@@ -381,7 +381,6 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
})
expect(wildcardHost.code).toBe(1)
expect(wildcardHost.stdout).toBe('')
expect(wildcardHost.stderr).toContain('--host 0.0.0.0 is intentionally not supported yet for safety: it would expose remote code execution to the network; use 127.0.0.1 instead')
expect(wildcardHost.stderr).not.toContain('dsh web: http://')
const headlessHelp = await runBuiltBin(['headless', '--help'], {
@@ -1,4 +1,4 @@
/** Built Web-profile acceptance for the advertised public root and browser authentication behind a prefix-stripping proxy. */
/** Built Web-profile acceptance for concrete bind addresses, the advertised public root, and browser authentication. */
import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { request as httpRequest, type IncomingMessage } from 'node:http'
@@ -9,6 +9,7 @@ import { Readable } from 'node:stream'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { execa } from 'execa'
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'
@@ -19,6 +20,18 @@ const builtArtifactsExist = existsSync(dshBin)
/** Browser-facing mount the reference proxy serves and strips. */
const MOUNT = '/tools/dsh/'
const NON_LOOPBACK_IPV4 = builtArtifactsExist ? await probeNonLoopbackIpv4() : undefined
const ZONED_LOOPBACK = builtArtifactsExist ? await probeZonedLoopback() : undefined
/**
* Every spelling that binds IPv6 loopback. `::0.0.0.1` is the same address as
* `::1`, and the zoned rows reuse the probed scope on that same address.
*/
const IPV6_LOOPBACKS = ZONED_LOOPBACK === undefined
? ['::1', '::0.0.0.1']
: ['::1', '::0.0.0.1', ZONED_LOOPBACK, ZONED_LOOPBACK.replace('::1%', '::0.0.0.1%')]
/** One live Web child, as `execa` types it. */
type WebChild = ReturnType<typeof spawnWeb>
@@ -156,7 +169,8 @@ function expectCleanExit(stopped: StoppedWeb): void {
/**
* One HTTP request that connects to an explicit transport address while
* presenting an explicit browser-facing authority, as a proxy would.
* presenting an explicit browser-facing authority, so a concrete-bind case can
* dial the bound address instead of loopback.
*/
async function request(
dial: string, port: number, path: string, host: string, method: 'GET' | 'POST', cookie?: string,
@@ -190,7 +204,7 @@ async function exchange(
return { cookie: setCookie.split(';', 1)[0]!, setCookie }
}
describe.skipIf(!builtArtifactsExist)('dsh Web profile advertised public root', () => {
describe.skipIf(!builtArtifactsExist)('dsh Web profile bind address and advertised root', () => {
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,4 +232,89 @@ describe.skipIf(!builtArtifactsExist)('dsh Web profile advertised public root',
expectCleanExit(await web.stop())
})
})
it.skipIf(NON_LOOPBACK_IPV4 === undefined)('serves the bound address and a declared authority, refusing any other name', async () => {
const trustedHost = 'dsh-direct.example'
await withWeb(['--host', NON_LOOPBACK_IPV4!, '--trusted-host', trustedHost], async (web) => {
expect(web.startup.origin).toBe(`http://${NON_LOOPBACK_IPV4!}:${String(web.port)}`)
expect(web.startup.pathname).toBe('/')
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)
// 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)
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)
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)
expect(rebound.status).toBe(403)
await rebound.arrayBuffer()
const stopped = await web.stop()
expectCleanExit(stopped)
expect(stopped.stderr).toContain(`listening on ${NON_LOOPBACK_IPV4!} over plain HTTP`)
expect(stopped.stderr).not.toContain(token!)
})
})
it.skipIf(NON_LOOPBACK_IPV4 === undefined)('warns about the plain-HTTP bind even when an HTTPS root is advertised', async () => {
await withWeb(['--host', NON_LOOPBACK_IPV4!, '--public-url', 'https://app.example/', '--trusted-host', 'app.example'], async (web) => {
expect(web.startup.origin).toBe('https://app.example')
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)
expect(authenticated.status).toBe(200)
expect(await authenticated.json()).toMatchObject({ result: { ok: true } })
const stopped = await web.stop()
expectCleanExit(stopped)
expect(stopped.stderr).toContain(`listening on ${NON_LOOPBACK_IPV4!} over plain HTTP`)
})
})
it.each([
'::ffff:127.0.0.1',
'::ffff:7f00:1',
])('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 authenticated = await request(
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 } })
const stopped = await web.stop()
expectCleanExit(stopped)
expect(stopped.stderr).not.toContain('plain HTTP')
})
})
it.each(IPV6_LOOPBACKS)('prints the IPv6 loopback root for %s and serves it', async (host) => {
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)
expect(authenticated.status).toBe(200)
expect(await authenticated.json()).toMatchObject({ result: { ok: true } })
const stopped = await web.stop()
expectCleanExit(stopped)
expect(stopped.stderr).not.toContain('plain HTTP')
})
})
})
@@ -296,7 +296,7 @@ describe.skipIf(!builtArtifactsExist)('dsh Web profile best-effort startup', ()
expect(result.stderr).toContain('Failed plugins (1):')
expect(result.stderr).toContain(' webserver (required)\n Package: @deepseek-ai/dsh-host-webserver')
expect(result.stderr).toContain('Plugins waiting for services (')
expect(result.stderr).toMatch(/connection \(required\) +webRuntime/u)
expect(result.stderr).toMatch(/web-runtime \(required\) +webServer/u)
expect(result.stderr).toContain('at Server.setupListenHandle')
const summary = result.stderr.split(/\n\n(?:Full diagnostics:|dsh: warning:)/u)[0]!
expect(summary.match(/EADDRINUSE/gu)).toHaveLength(1)
@@ -319,7 +319,7 @@ describe.skipIf(!builtArtifactsExist)('dsh Web profile best-effort startup', ()
expect(report).toContain('configurationPath:')
expect(report).toContain("code: 'EADDRINUSE'")
expect(report).toContain(`port: ${String(address.port)}`)
expect(report).toContain("module: '@deepseek-ai/dsh-client-connection'")
expect(report).toContain("module: '@deepseek-ai/dsh-web-app'")
expect(report).toContain('at auditStartupEntries')
} finally {
await new Promise<void>((resolve, reject) => {
@@ -59,8 +59,13 @@ function fixture() {
' }, 20)',
' ctx.effect(() => () => { clearInterval(timer) })',
' ctx.inject(["webServer", "connection"], scope => {',
' writeFileSync(config.url, scope.connection.authenticatedUrl(`http://127.0.0.1:${scope.webServer.port}`))',
' scope.effect(() => () => { rmSync(config.url, { force: true }) })',
' let disposed = false',
' scope.effect(() => () => { disposed = true; rmSync(config.url, { force: true }) })',
// A recovery URL is a readiness signal; sibling HMR work must settle first.
' void scope.get("loader").await().then(() => {',
' if (disposed || !scope.get("webServer") || !scope.get("connection")) return',
' writeFileSync(config.url, scope.connection.authenticatedUrl(`http://127.0.0.1:${scope.webServer.port}`))',
' })',
' })',
'}',
'',
@@ -107,8 +112,8 @@ function fixture() {
return { root, home, events, diagnostics, serverUrl, states, observer, stop, patch, watcher, render, url, config, witness, target }
}
function start(f: ReturnType<typeof fixture>, extra: string[] = []) {
const child = execa(process.execPath, [bin, '--profile', 'web', '--patch', f.watcher, ...extra, '--no-open', '--port', '0'], {
function start(f: ReturnType<typeof fixture>, extra: string[] = [], nodeArgs: string[] = []) {
const child = execa(process.execPath, [...nodeArgs, bin, '--profile', 'web', '--patch', f.watcher, ...extra, '--no-open', '--port', '0'], {
cwd: f.root,
env: { ...process.env, DSH_HOME: f.home, DSH_AGENTS_HOME: join(f.root, '.agents'), DSH_TELEMETRY_DISABLED: '1', DEEPSEEK_API_KEY: 'keyless-matrix-no-call', NODE_NO_WARNINGS: '1' },
input: '', reject: false, timeout: 110_000, killSignal: 'SIGKILL',
@@ -167,6 +172,38 @@ function exit(result: { timedOut: boolean; signal?: string | undefined; exitCode
}
describe.skipIf(!built)('Web process failure matrix', () => {
it('rejects a non-loopback zone bind through the required Web runtime before readiness', async () => {
const f = fixture()
const host = 'fe80::1%dsh-test'
const network = join(f.root, 'zone-listen.mjs')
// Mock only the OS bind: the real WebServer retains its configured zone,
// and the shipped runtime row, Loader dependencies, and boot audit stay real.
writeFileSync(network, [
"import { Server } from 'node:net'",
'const listen = Server.prototype.listen',
'Server.prototype.listen = function (...args) {',
` if (args[1] === ${JSON.stringify(host)}) {`,
' args[1] = "127.0.0.1"',
' }',
' return Reflect.apply(listen, this, args)',
'}',
'',
].join('\n'))
const app = start(f, ['--host', host], ['--import', pathToFileURL(network).href])
try {
const result = await app.child
exit(result, 1)
expect(result.stderr).toContain('dsh: startup failed: 1 required plugin did not activate')
expect(result.stderr).toContain(' web-runtime (required)\n Package: @deepseek-ai/dsh-web-app')
expect(result.stderr).toContain(`bind address "${host}" carries an interface zone id`)
expect(result.stderr).toContain('pass --public-url')
expect(result.stdout).not.toContain('dsh web: http')
expect(app.events()).toBe('witness apply 0\nwitness dispose 0\n')
} finally {
exit(await app.close(), 1)
}
})
for (const required of [false, true]) {
const id = required ? 'acp' : 'matrix-optional'
it.each(failures)(`${required ? 'required' : 'optional'} startup %s`, async (failure, diagnostic) => {
@@ -408,7 +445,7 @@ export function apply(ctx, config) {
// Entry-level injection requirements are captured when the fiber is created.
writeFileSync(f.patch, f.render('matrix-optional') + `- id: ${id}\n disabled: true\n`)
await app.wait(() => app.state(id) === FiberState.DISPOSED)
const inject = id === 'connection' ? ['webRuntime', 'matrixMissingWebDependency'] : ['matrixMissingWebDependency']
const inject = id === 'connection' ? ['webStartup', 'matrixMissingWebDependency'] : ['matrixMissingWebDependency']
const pending = f.render('matrix-optional', undefined, 2) + `- id: ${id}\n inject: ${JSON.stringify(inject)}\n`
writeFileSync(f.patch, pending)
await app.wait(() => app.state(id) === FiberState.PENDING && app.events().includes('witness apply 2\n'))
+1 -1
View File
@@ -27,7 +27,7 @@ async function main(): Promise<void> {
profile: 'desktop',
resolvedProfile: { profile, installAnchor },
patchFiles: [],
args: ['--no-open', '--port', '0'],
args: ['--no-open', '--host', '127.0.0.1', '--port', '0'],
...(process.argv[5] === undefined ? {} : {
packageManager: {
command: process.execPath,
+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: 0f0f6ea914eff51e
zh: 4934245b04e9f1c0
en: ccc7ba3d64aa38ef
zh: 846aea7879b0fe4e
/deepseek-harness-desktop/terminal-command:
en: bef92953c237363f
zh: d21d6effd21b8c00
+2
View File
@@ -6,6 +6,8 @@ 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.
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.
Desktop’s local native directory flow opens an Electron folder dialog attached to the application window, restoring, showing, and focusing that window first. Concurrent requests share one dialog; cancellation returns no path and failures remain retryable. Ordinary Web uses the Host chooser. Browse mode lists Host directories. On Linux without zenity or kdialog, automatic selection uses browse instead of the Electron dialog.
+2
View File
@@ -6,6 +6,8 @@
桌面应用是完整 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 凭据过滤器拨号的地址。
应用菜单第一项“**关于 DeepSeek Harness**”打开 Electron 原生关于面板,展示应用图标、产品名称和当前安装的发布版本。菜单文案跟随桌面壳的语言。macOS 的隐藏、隐藏其他、显示全部和退出条目使用本地化文案,隐藏和退出条目包含 DeepSeek Harness 产品名称。这些条目保留原生动作和快捷键。macOS 从应用包读取图标,因此未打包的开发启动会显示 Electron 图标;Windows 使用随包分发的 PNG。
Desktop 的本地原生目录流程打开绑定应用窗口的 Electron 文件夹对话框,并先恢复、显示和聚焦该窗口。并发请求共用一个对话框;取消不返回路径,失败后可以重试。普通 Web 使用 Host 选择器。浏览模式列出 Host 目录。Linux 缺少 zenity 或 kdialog 时,自动选择使用浏览模式,不使用 Electron 对话框。
+4 -1
View File
@@ -173,7 +173,9 @@ Usage: dsh --profile web [options]
Serve the DeepSeek Harness browser UI.
Options:
--host <host> bind host
--host <host> bind address: one concrete IPv4 or IPv6 literal
of a local interface; wildcard addresses are
rejected
--no-open do not open the Web UI in the default browser
--port <port> listen port; pass 0 to let the OS pick a free
one
@@ -188,6 +190,7 @@ Examples:
dsh --profile web serve on the composed host and port
dsh --profile web --no-open serve without opening a browser
dsh --profile web --port 8080 serve on another port
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
+4 -1
View File
@@ -175,7 +175,9 @@ Usage: dsh --profile web [options]
Serve the DeepSeek Harness browser UI.
Options:
--host <host> bind host
--host <host> bind address: one concrete IPv4 or IPv6 literal
of a local interface; wildcard addresses are
rejected
--no-open do not open the Web UI in the default browser
--port <port> listen port; pass 0 to let the OS pick a free
one
@@ -190,6 +192,7 @@ Examples:
dsh --profile web serve on the composed host and port
dsh --profile web --no-open serve without opening a browser
dsh --profile web --port 8080 serve on another port
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
+19 -14
View File
@@ -444,11 +444,9 @@ export interface ConnectionConfig {
recovery?: ConnectionRecoveryConfig
/**
* Authorities this deployment serves beyond loopback: exact `host:port`, or
* port-less `host` matching any port. The /api trust fence refuses any
* request whose Host is neither loopback nor listed here, so a
* non-loopback (`0.0.0.0`) deployment must declare the names it is reached
* by; the Web runtime derives LAN IP literals from an active all-interface
* bind. An entry that is not a bare, canonical authority fails plugin load.
* port-less `host` matching any port. The fence accepts loopback and the
* listener's own bind IP independently; other remote authorities require
* an entry here. A non-canonical or non-bare authority fails plugin load.
*/
trustedHosts?: string[]
/** Absolute browser-session lifetime in days. Default: 30. */
@@ -1626,13 +1624,22 @@ export interface Config {
## `@deepseek-ai/dsh-host-webserver`
- `source`: [`packages/host/webserver/src/index.ts:59`](../packages/host/webserver/src/index.ts)
- `source`: [`packages/host/webserver/src/index.ts:60`](../packages/host/webserver/src/index.ts)
```ts config-catalog
/** Web server listen and response-compression config. */
export interface Config {
/** Listen host; the two supported values are loopback and all-interfaces. */
host: '127.0.0.1' | '0.0.0.0'
/**
* 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.
*/
host: string
/** Listen port; zero requests an OS-assigned port. */
port: number
/** Response compression for socket-backed HTTP requests. @default 'none' */
@@ -4205,7 +4212,7 @@ export interface WebRuntimeConfig {
## `@deepseek-ai/dsh-web-app`
- `inject`: `webServer`
- `source`: [`packages/bundle/web-app/src/index.ts:46`](../packages/bundle/web-app/src/index.ts)
- `source`: [`packages/bundle/web-app/src/index.ts:43`](../packages/bundle/web-app/src/index.ts)
```ts config-catalog
/** Plugin config: composed deployment settings plus per-invocation command-line values. */
@@ -4224,13 +4231,11 @@ export interface Config {
/**
* Canonical HTTP(S) root to advertise in the printed and opened URL,
* `DSH_WEB_URL`, and the web-surface orientation, e.g.
* `https://app.example/ui/`, normalized to end in `/`. Advertisement only;
* see [public deployments](../README.md#public-deployments). Absent or YAML
* `null` advertises the loopback URL.
* `https://app.example/ui/`, normalized to end in `/`. Configures no routing
* or authentication; see [public deployments](../README.md#public-deployments).
* Absent or YAML `null` advertises the bind-address URL.
*/
publicUrl?: string
/** Explicit `--trusted-host` authorities from this invocation. */
trustedHosts: string[]
}
```
<!-- END GENERATED config-catalog:@deepseek-ai/dsh-web-app -->
+19 -14
View File
@@ -446,11 +446,9 @@ export interface ConnectionConfig {
recovery?: ConnectionRecoveryConfig
/**
* Authorities this deployment serves beyond loopback: exact `host:port`, or
* port-less `host` matching any port. The /api trust fence refuses any
* request whose Host is neither loopback nor listed here, so a
* non-loopback (`0.0.0.0`) deployment must declare the names it is reached
* by; the Web runtime derives LAN IP literals from an active all-interface
* bind. An entry that is not a bare, canonical authority fails plugin load.
* port-less `host` matching any port. The fence accepts loopback and the
* listener's own bind IP independently; other remote authorities require
* an entry here. A non-canonical or non-bare authority fails plugin load.
*/
trustedHosts?: string[]
/** Absolute browser-session lifetime in days. Default: 30. */
@@ -1628,13 +1626,22 @@ export interface Config {
## `@deepseek-ai/dsh-host-webserver`
- `source`: [`packages/host/webserver/src/index.ts:59`](../packages/host/webserver/src/index.ts)
- `source`: [`packages/host/webserver/src/index.ts:60`](../packages/host/webserver/src/index.ts)
```ts config-catalog
/** Web server listen and response-compression config. */
export interface Config {
/** Listen host; the two supported values are loopback and all-interfaces. */
host: '127.0.0.1' | '0.0.0.0'
/**
* 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.
*/
host: string
/** Listen port; zero requests an OS-assigned port. */
port: number
/** Response compression for socket-backed HTTP requests. @default 'none' */
@@ -4207,7 +4214,7 @@ export interface WebRuntimeConfig {
## `@deepseek-ai/dsh-web-app`
- `inject`: `webServer`
- `source`: [`packages/bundle/web-app/src/index.ts:46`](../packages/bundle/web-app/src/index.ts)
- `source`: [`packages/bundle/web-app/src/index.ts:43`](../packages/bundle/web-app/src/index.ts)
```ts config-catalog
/** Plugin config: composed deployment settings plus per-invocation command-line values. */
@@ -4226,13 +4233,11 @@ export interface Config {
/**
* Canonical HTTP(S) root to advertise in the printed and opened URL,
* `DSH_WEB_URL`, and the web-surface orientation, e.g.
* `https://app.example/ui/`, normalized to end in `/`. Advertisement only;
* see [public deployments](../README.md#public-deployments). Absent or YAML
* `null` advertises the loopback URL.
* `https://app.example/ui/`, normalized to end in `/`. Configures no routing
* or authentication; see [public deployments](../README.md#public-deployments).
* Absent or YAML `null` advertises the bind-address URL.
*/
publicUrl?: string
/** Explicit `--trusted-host` authorities from this invocation. */
trustedHosts: string[]
}
```
<!-- END GENERATED config-catalog:@deepseek-ai/dsh-web-app -->
+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:34`](../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:35`](../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:34`](../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:35`](../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`) | - |
+2 -2
View File
@@ -9,8 +9,8 @@
en: 940bc78642c990b0
zh: 901d2ba7e354a766
/http-server/config:
en: 30118110a0ea3702
zh: 940f2b69d2dd54af
en: d5bd005d51a49acd
zh: 9b652e39a41a4b6b
/http-server/the-service:
en: e304734ab2143438
zh: 065f9c2acbbb07ca
+12 -3
View File
@@ -31,8 +31,17 @@ Match order is fixed: exact table first, then longest matching prefix, then the
```ts type-equiv
/** Web server listen and response-compression config. */
interface Config {
/** Listen host; the two supported values are loopback and all-interfaces. */
host: '127.0.0.1' | '0.0.0.0'
/**
* 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.
*/
host: string
/** Listen port; zero requests an OS-assigned port. */
port: number
/** Response compression for socket-backed HTTP requests. @default 'none' */
@@ -44,7 +53,7 @@ interface Config {
}
```
`host` accepts only `127.0.0.1` (default posture) and `0.0.0.0` (deliberate network exposure). The carrier itself owns no TLS, authentication, or Origin policy, so a non-loopback bind exposes the server 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 and rejects `--host 0.0.0.0`; its Connection plugin supplies Host/Origin checks plus browser-session authentication for every Host API route and stream. Other compositions own their bind and route-authentication policy. The dist location is an assembly fact of the frontend plugin that claims the seat.
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.
## The service
+12 -3
View File
@@ -31,8 +31,17 @@ interface WebRoute {
```ts type-equiv
/** Web server listen and response-compression config. */
interface Config {
/** Listen host; the two supported values are loopback and all-interfaces. */
host: '127.0.0.1' | '0.0.0.0'
/**
* 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.
*/
host: string
/** Listen port; zero requests an OS-assigned port. */
port: number
/** Response compression for socket-backed HTTP requests. @default 'none' */
@@ -44,7 +53,7 @@ interface Config {
}
```
`host` 只接受 `127.0.0.1`(默认姿态)和 `0.0.0.0`(刻意的网络暴露)。载体本身不拥有 TLS、认证或 Origin 策略,因此绑定到非回环地址会暴露服务器,除非组合层提供这些控制。`compression` 默认为 `none`;随附的 Web 组合选择 gzip level 1 和 1024 字节阈值。随附的 `dsh web` 命令选择 loopback 并拒绝 `--host 0.0.0.0`;其 Connection 插件为每个 Host API route 与 stream 提供 Host/Origin 校验和浏览器会话认证。其他组合自行拥有绑定与路由认证策略。dist 位置是认领席位的前端插件的组装事实。
没有组合能一次性绑定所有网络接口:未指定地址在加载时即被拒绝。导出的 `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 位置是认领席位的前端插件的组装事实。
## 服务
@@ -0,0 +1,16 @@
# Bilingual-pair consistency record for guide.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 docs/upgrade-guide/v0.2.1-alpha.1/web-listener-trust-config/guide.md
/:
en: af5832d4e9eece99
zh: 8f97dad6921ed8a0
/web-listener-and-trust-configuration-moves-to-webstartup:
en: de3788e700062659
zh: 9def0e8f38896304
/web-listener-and-trust-configuration-moves-to-webstartup/change:
en: 19f901fb2974aab5
zh: b4ef886f85b61637
/web-listener-and-trust-configuration-moves-to-webstartup/migration:
en: 30917eb37ead3e10
zh: fd1b26eada570dd3
@@ -0,0 +1,36 @@
---
kind: upgrade-guide
description: "The Web profile replaces the `webRuntime` service and the `web-runtime` row's `trustedHosts` config with `webStartup`, and rejects a wildcard listener host."
---
# Web listener and trust configuration moves to `webStartup`
English | [中文](guide.zh.md)
## Change
The Web profile no longer provides the `webRuntime` service, and the `web-runtime` row no longer declares a `trustedHosts` config value. A profile, overlay, or `--patch` file that injects `webRuntime` now waits for a service that never mounts, and an expression reading `ctx.webRuntime.trustedHosts` fails to evaluate, so the required Connection cannot start.
The `web-startup` row provides one `webStartup` service carrying the invocation's `--trusted-host` authorities, and the `connection` row reads them from `ctx.webStartup.trustedHosts`. The listener host must also be one concrete IPv4 or IPv6 address of a local interface: `host: 0.0.0.0`, `--host ::`, and every other unspecified address are rejected at load, because binding every interface exposes the port to the network.
## Migration
1. Replace `inject: [webRuntime]` with `inject: [webStartup]` on every row that needs invocation trust.
2. Replace `ctx.webRuntime.trustedHosts` expressions with `ctx.webStartup.trustedHosts`, and drop `ctx.webRuntime.lanAddresses`, which no longer exists.
3. Rewrite the `connection` overlay for `webStartup`, and move any `trustedHosts` value that the `web-runtime` row carried into its expression. A patch replaces the matched row's whole `config`, so restate that config completely, and write an array-valued `!!js` expression as a quoted scalar:
```yaml
# before
- id: connection
inject: [webRuntime]
config:
trustedHosts: !!js "['app.internal', ...ctx.webRuntime.trustedHosts]"
# after
- id: connection
inject: [webStartup]
config:
trustedHosts: !!js "['app.internal', ...ctx.webStartup.trustedHosts]"
```
4. Replace a wildcard `host` with one concrete address of a local interface. The bind IP itself is accepted by the Host fence with no `--trusted-host` entry; a proxy or DNS authority still needs one.
5. Boot with the migrated overlay: `dsh --profile web --patch ./extra.yml --no-open` must print the `dsh web:` URL line and serve. A row still waiting on `webRuntime` leaves the required Connection pending and reports an activation failure, and a rejected `host` fails at load. `dsh --profile web --patch ./extra.yml --dump-config` prints the composed patch before boot. [The Web bundle README](../../../../packages/bundle/web-app/README.md) owns the composed rows.
@@ -0,0 +1,36 @@
---
kind: upgrade-guide
description: "Web profile 用 `webStartup` 取代 `webRuntime` 服务与 `web-runtime` 行的 `trustedHosts` 配置,并拒绝通配监听地址。"
---
# Web 监听与信任配置迁移到 `webStartup`
[English](guide.md) | 中文
## 变更
Web profile 不再提供 `webRuntime` 服务,`web-runtime` 行也不再声明 `trustedHosts` 配置值。仍注入 `webRuntime` 的 profile、overlay 或 `--patch` 文件会等待一个永不挂载的服务,读取 `ctx.webRuntime.trustedHosts` 的表达式则会求值失败,导致必需的 Connection 无法启动。
`web-startup` 行提供唯一的 `webStartup` 服务,携带本次调用的 `--trusted-host` authority;`connection` 行通过 `ctx.webStartup.trustedHosts` 读取它们。监听 host 现在也必须是本机网卡的一个具体 IPv4 或 IPv6 地址:`host: 0.0.0.0`、`--host ::` 及其他未指定地址都会在加载时被拒绝,因为绑定所有接口会把端口暴露到网络。
## 迁移
1. 将所有需要调用信任的行的 `inject: [webRuntime]` 换成 `inject: [webStartup]`。
2. 将 `ctx.webRuntime.trustedHosts` 表达式换成 `ctx.webStartup.trustedHosts`,并删除已不存在的 `ctx.webRuntime.lanAddresses`。
3. 按 `webStartup` 改写 `connection` overlay,并把原先由 `web-runtime` 行携带的 `trustedHosts` 值并入其表达式。patch 会整体替换所匹配行的 `config`,因此必须完整重述该配置;数组值的 `!!js` 表达式要写成带引号的标量:
```yaml
# before
- id: connection
inject: [webRuntime]
config:
trustedHosts: !!js "['app.internal', ...ctx.webRuntime.trustedHosts]"
# after
- id: connection
inject: [webStartup]
config:
trustedHosts: !!js "['app.internal', ...ctx.webStartup.trustedHosts]"
```
4. 将通配 `host` 换成本机网卡的一个具体地址。所绑定的 IP 会被 Host 栅栏直接接受,无需 `--trusted-host`;代理或 DNS authority 仍需显式配置。
5. 使用迁移后的 overlay 启动:`dsh --profile web --patch ./extra.yml --no-open` 必须打印 `dsh web:` URL 行并正常服务。仍在等待 `webRuntime` 的行会让必需的 Connection 保持 pending 并报告激活失败;被拒绝的 `host` 会在加载时失败。`dsh --profile web --patch ./extra.yml --dump-config` 可在启动前打印组合后的 patch。[Web 组合包 README](../../../../packages/bundle/web-app/README.zh.md)说明这些行。
+4 -4
View File
@@ -12,8 +12,8 @@
en: 8dd9d910a93fe54d
zh: 1e9e21d9949c8ab2
/publish-the-web-ui-behind-a-reverse-proxy/trust-the-authority-browsers-use:
en: 907b5f90c9860733
zh: dcbffd3f00d6c64f
en: b85ae06cb83c23fe
zh: 4197ab824ff5a6f8
/publish-the-web-ui-behind-a-reverse-proxy/secure-the-external-leg:
en: a833880da5c8bbf4
zh: 6f9bd565b7266241
en: ddcdd76a0739a38c
zh: 07940b66df2335f5
+2 -2
View File
@@ -22,7 +22,7 @@ dsh --profile web --public-url https://app.example/ui/ --trusted-host app.exampl
## Trust the authority browsers use
The fence accepts loopback plus 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.
Neither the advertised URL nor the fence protects the listening port itself, so restrict the port to the trusted proxy or network.
@@ -30,4 +30,4 @@ Neither the advertised URL nor the fence protects the listening port itself, so
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.
The [Web app reference](../../../packages/bundle/web-app/README.md#public-deployments) owns the `--public-url` and `--trusted-host` command-line contract and the `publicUrl` and `trustedHosts` fields.
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).
+2 -2
View File
@@ -22,7 +22,7 @@ dsh --profile web --public-url https://app.example/ui/ --trusted-host app.exampl
## 信任浏览器使用的 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 才完成认证。
无论是公告 URL 还是栅栏都不保护监听端口本身,因此请把端口限制在可信代理或网络内。
@@ -30,4 +30,4 @@ dsh --profile web --public-url https://app.example/ui/ --trusted-host app.exampl
在代理处终止 TLS。`http://` 公告根会以明文发送启动 token 与会话 cookie,而 `https://` 根只加密浏览器到代理这一段。打印的 URL 携带进程凭据,只应与预期用户分享。
[Web 应用参考](../../../packages/bundle/web-app/README.zh.md#public-deployments)负责 `--public-url` 与 `--trusted-host` 的命令行约定,以及 `publicUrl` 与 `trustedHosts` 字段。
[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)。
+2 -2
View File
@@ -21,8 +21,8 @@
en: 2fdfe02f2ed4a258
zh: d748228ece579c4a
/deepseek-ai-dsh-app-boot/use-this-package/starting-the-app:
en: 1099528bde0e2aee
zh: b68772bdb9bb7856
en: 4a424cd401556f20
zh: 0b34dd18e0db6cea
/deepseek-ai-dsh-app-boot/use-this-package/profiles:
en: d50b0fdaf62353dd
zh: 0fccb6968602e5de
+1 -1
View File
@@ -40,7 +40,7 @@ installFailLoud('dsh')
const ctx = await boot('dsh', resolveConfigPath(argv[2], process.env.DSH_SNAPSHOT))
```
`installFailLoud` writes one labelled `util.inspect` diagnostic to stderr for an unhandled rejection or an uncaught exception, awaits the surface's release hook under a fixed timeout, and exits 1; control never returns to the failed operation, because only the throw site knows which state is intact, and the event loop runs only until the release settles or times out. With that entry point, startup keeps every plugin that can activate. An enabled failed plugin produces a labelled warning. A failed required entry makes startup dispose the whole app and exit nonzero; required ids absent from a profile and disabled required entries do not affect startup. The global required list covers shared Agent execution, application endpoints, and Web bootstrap/transport: `agent-loop`, `webserver`, `modules`, `connection`, `headless-runner`, `acp`, and `sdk-jsonrpc-server`.
`installFailLoud` writes one labelled `util.inspect` diagnostic to stderr for an unhandled rejection or an uncaught exception, awaits the surface's release hook under a fixed timeout, and exits 1; control never returns to the failed operation, because only the throw site knows which state is intact, and the event loop runs only until the release settles or times out. With that entry point, startup keeps every plugin that can activate. An enabled failed plugin produces a labelled warning. A failed required entry makes startup dispose the whole app and exit nonzero; required ids absent from a profile and disabled required entries do not affect startup. The global required list covers shared Agent execution, application endpoints, and Web bootstrap/transport: `agent-loop`, `webserver`, `web-runtime`, `modules`, `connection`, `headless-runner`, `acp`, and `sdk-jsonrpc-server`.
<a id="profiles"></a>
### Profiles
+1 -1
View File
@@ -40,7 +40,7 @@ installFailLoud('dsh')
const ctx = await boot('dsh', resolveConfigPath(argv[2], process.env.DSH_SNAPSHOT))
```
`installFailLoud` 会为未处理 rejection 或未捕获异常向 stderr 写一条带标签的 `util.inspect` 诊断,在固定超时内等待界面的 release 钩子,然后以 1 退出;控制流不会回到失败的操作,因为只有抛出点知道哪些状态仍然完整,事件循环只运行到 release 结束或超时。有了这个入口,启动会保留所有能够激活的插件。启用但失败的插件会产生带标签的警告。required entry 失败时,启动会拆卸整个应用并以非零码退出;profile 中不存在的 required id 和已禁用的 required entry 不影响启动。全局 required list 覆盖共享 Agent 执行、应用 endpoint,以及 Web 启动与传输:`agent-loop`、`webserver`、`modules`、`connection`、`headless-runner`、`acp` 和 `sdk-jsonrpc-server`。
`installFailLoud` 会为未处理 rejection 或未捕获异常向 stderr 写一条带标签的 `util.inspect` 诊断,在固定超时内等待界面的 release 钩子,然后以 1 退出;控制流不会回到失败的操作,因为只有抛出点知道哪些状态仍然完整,事件循环只运行到 release 结束或超时。有了这个入口,启动会保留所有能够激活的插件。启用但失败的插件会产生带标签的警告。required entry 失败时,启动会拆卸整个应用并以非零码退出;profile 中不存在的 required id 和已禁用的 required entry 不影响启动。全局 required list 覆盖共享 Agent 执行、应用 endpoint,以及 Web 启动与传输:`agent-loop`、`webserver`、`web-runtime`、`modules`、`connection`、`headless-runner`、`acp` 和 `sdk-jsonrpc-server`。
<a id="profiles"></a>
### Profile
+1
View File
@@ -748,6 +748,7 @@ const FIBER_DISPOSED = 4 as FiberState.DISPOSED
const requiredStartupEntryIds = new Set<string>([
'agent-loop',
'webserver',
'web-runtime',
'modules',
'connection',
'headless-runner',
+11 -10
View File
@@ -558,6 +558,7 @@ describe('auditStartupEntries', () => {
const requiredIds = [
'agent-loop',
'webserver',
'web-runtime',
'modules',
'connection',
'headless-runner',
@@ -739,7 +740,7 @@ describe('auditStartupEntries', () => {
it('omits an error cause when required plugins are only waiting for services', async () => {
const error = await auditStartupEntries(ctxWith([
{ fiber: fiber(0, undefined, { webRuntime: {} }), options: { id: 'connection', name: './connection.mjs' } },
{ fiber: fiber(0, undefined, { webStartup: {} }), options: { id: 'connection', name: './connection.mjs' } },
]), NAME, vi.fn()).catch((error: unknown) => error)
expect(error).toBeInstanceOf(StartupError)
expect(Object.hasOwn(error as StartupError, 'cause')).toBe(false)
@@ -750,9 +751,9 @@ describe('auditStartupEntries', () => {
it('keeps diagnostic metadata available without expanding it in ordinary error inspection', () => {
const entries = [{
id: 'connection', module: './connection.mjs', required: true, fiberState: 0,
outcome: { kind: 'pending' as const, missing: ['webRuntime'] },
outcome: { kind: 'pending' as const, missing: ['webStartup'] },
}]
const error = new StartupError('waiting for webRuntime', entries)
const error = new StartupError('waiting for webStartup', entries)
const startup = { configurationPath: '/private/cordis.yml', messages: [
{ ts: 1, name: 'loader', type: 'warn', args: ['raw diagnostic argument'] },
] }
@@ -760,7 +761,7 @@ describe('auditStartupEntries', () => {
expect(error.entries).toBe(entries)
expect(error.startup).toBe(startup)
const output = inspect(error)
expect(output).toContain('waiting for webRuntime')
expect(output).toContain('waiting for webStartup')
expect(output).not.toContain('connection.mjs')
expect(output).not.toContain('/private/cordis.yml')
expect(output).not.toContain('raw diagnostic argument')
@@ -792,12 +793,12 @@ describe('auditStartupEntries', () => {
const error = await auditStartupEntries(ctxWith([
{ fiber: fiber(0, undefined, { webServer: {} }), options: { id: 'web-runtime', name: './web.mjs' } },
{ fiber: fiber(3, original), options: { id: 'webserver', name: '@deepseek-ai/dsh-host-webserver' } },
{ fiber: fiber(0, undefined, { webRuntime: {} }), options: { id: 'connection', name: './connection.mjs' } },
{ fiber: fiber(0, undefined, { webStartup: {} }), options: { id: 'connection', name: './connection.mjs' } },
{ fiber: fiber(0), options: { id: 'unknown', name: './unknown.mjs' } },
]), NAME, warn).catch((error: unknown) => error)
expect(error).toBeInstanceOf(StartupError)
expect((error as Error).message).toMatchInlineSnapshot(`
"dsh-test-bin: startup failed: 2 required plugins did not activate
"dsh-test-bin: startup failed: 3 required plugins did not activate
Failed plugins (1):
webserver (required)
@@ -806,10 +807,10 @@ describe('auditStartupEntries', () => {
at Server.listen (node:net:1:2)
Plugins waiting for services (3):
Plugin Missing services
connection (required) webRuntime
web-runtime webServer
unknown unknown"
Plugin Missing services
web-runtime (required) webServer
connection (required) webStartup
unknown unknown"
`)
expect(warn).not.toHaveBeenCalled()
})
+16 -19
View File
@@ -9,8 +9,8 @@
en: a85684740ecd278a
zh: 7c54099b9edccf01
/deepseek-ai-dsh-web-app/summary:
en: fd97ee0c14fb5378
zh: 0e1ccba47bc408d9
en: b295353906560038
zh: c9348f651258c218
/deepseek-ai-dsh-web-app/table-of-contents:
en: d152484eb41ac6b4
zh: 09388d293f9be9cb
@@ -21,35 +21,32 @@
en: b7ab01cfe9fed40f
zh: 23703ab7727c3ab5
/deepseek-ai-dsh-web-app/use-this-package/configuration:
en: a9ee366c5af68a6c
zh: 2711d7a3926e1101
en: 66a11266a5877a9d
zh: 9a907da83b0859c4
/deepseek-ai-dsh-web-app/use-this-package/listening-trust-and-public-deployments:
en: a79add86cb73d966
zh: 78b58259f98ed063
en: f2c2ea2c61d6f164
zh: 94c71cb42eef3827
/deepseek-ai-dsh-web-app/use-this-package/running-over-ssh:
en: 45db677dcc91c5c9
zh: 75b765f7f1e15035
en: df205a26b6d49168
zh: e46ed7cf9e06248a
/deepseek-ai-dsh-web-app/use-this-package/per-session-agent-setup:
en: ccd6fc264b51e44b
zh: 3265c68477a89ec9
/deepseek-ai-dsh-web-app/understand-the-implementation:
en: fb819d10930089a8
zh: f2dee0ff170a4d41
en: 2ea88398028ebff8
zh: 0e589c297aeb9d43
/deepseek-ai-dsh-web-app/understand-the-implementation/patch-semantics:
en: 23ddb4d33e79c0a1
zh: dc9a458b9492b49b
en: 8a2554b3d17daa1f
zh: 66f18946791cabb6
/deepseek-ai-dsh-web-app/understand-the-implementation/advertised-application-url:
en: ca977d2b2bcf72c5
zh: 2b81b377c133c1a8
/deepseek-ai-dsh-web-app/understand-the-implementation/readiness:
en: 973128fd346869df
zh: 54e6792f9f82a192
/deepseek-ai-dsh-web-app/understand-the-implementation/lan-trust-sampling:
en: b5ed8ff0056ca9da
zh: fdc1fa4f14cb00b3
/deepseek-ai-dsh-web-app/understand-the-implementation/source-map:
en: e41444bed021a121
zh: 44088a90df50d5b4
en: 0735a22bb413d26c
zh: 723c1d9460c51298
/deepseek-ai-dsh-web-app/further-exploration:
en: f81694f7424e2988
zh: 6643f9227039e228
@@ -69,8 +66,8 @@
en: 1d7d87f54b20b563
zh: d150a51615d278e8
/deepseek-ai-dsh-web-app/known-limitations-and-deferred-work:
en: d6de1d07249ef3b0
zh: 49d01329256e2eb3
en: 744f155fbcefda1f
zh: 7c9a262ce6db837a
/deepseek-ai-dsh-web-app/known-limitations-and-deferred-work/dev-note:
en: 3f81b4f3beb74a0d
zh: 2e377ca40822d093
+23 -18
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, using 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 and allow extra hosts, but cannot bind all network interfaces. Remote access supports an advertised public HTTP(S) URL behind a prefix-stripping proxy. For one-shot command-line tasks, use `dsh-headless`.
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.
## Table of Contents
@@ -53,23 +53,34 @@ Saved model selections override the composition default. The settings card accep
| `openBrowser` | `true` | Open the default browser after startup; SSH launches suppress it |
| `printUrl` | `true` | Print the `dsh web:` URL line at startup |
| `surfaceContext` | `true` | Give the agent GUI-orientation context and expose `DSH_WEB_URL` to its shell commands |
| `publicUrl` | Unset | Advertised HTTP(S) application root; otherwise announce the listener's loopback URL |
| `trustedHosts` | `[]` | Extra hosts allowed to reach the GUI from the network |
| `publicUrl` | Unset | Advertised HTTP(S) application root; otherwise announce the bind-address URL |
The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-web-app) lists this runtime plugin's accepted fields and their JSDoc. The shipped composition inserts the `schedule` service row and the `ui-schedule` task page row, while the clock reading and the four reminder tools belong to the `standard`, `cordis`, and `ptc` presets. The `tool-subagent` and `tool-subagent-fork` rows in those presets deny the four tools, so a delegated child's scope never lists them.
<a id="public-deployments"></a>
### Listening, trust, and public deployments
By default the GUI listens on loopback and accepts connections from this machine only; repeatable `--trusted-host` adds the authorities its Host/Origin fence accepts, so a remote browser reaches it behind a prefix-stripping proxy or through a port-forwarding client that presents a trusted hostname.
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.
A container can bind its Pod address and advertise the ingress that fronts it:
```sh
dsh --profile web --no-open --host "$(hostname -i | awk '{print $1}')" --public-url https://app.example/ --trusted-host app.example
```
`hostname -i` may list several addresses; `--host` takes one, so the command selects the first. A zone ID such as `fe80::1%eth0` cannot appear in a URL, so the advertised root drops a redundant loopback zone while a non-loopback zone requires an explicit `--public-url`.
Without `--public-url`, mapped IPv6 loopback binds such as `::ffff:127.0.0.1` advertise their IPv4 form, `127.0.0.1`, so the browser recognizes a local, trustworthy origin. The listener keeps the configured bind address. A dotted-quad IPv6 tail names the address's own low 32 bits, exactly as `listen` reads it, so `::0.0.0.1` is IPv6 loopback and advertises `[::1]`, while `::127.0.0.1` is a non-loopback address, not IPv4 loopback.
`--public-url` advertises the HTTP(S) root browsers use — the printed and opened startup URL, `DSH_WEB_URL`, and the web-surface orientation. Advertisement grants no trust: the browser-visible authority must also be named with `--trusted-host`. The flag configures no listener, routing, or cookie scope, because the proxy owns the external leg: [Publish the Web UI behind a reverse proxy](../../../docs/user/guide/public-deployments.md) lists what such a deployment must provide.
The printed URL contains a process credential; share it only with intended users, and `printUrl: false` suppresses the line with or without `--public-url`.
Neither the advertised URL nor the browser-trust fence protects the port itself, so restrict the port to the trusted proxy or network.
The printed URL contains a process credential; share it only with intended users. `printUrl: false` suppresses the line with or without `--public-url`, and the credential never appears in the model context or in `DSH_WEB_URL`.
### Running over SSH
When you launch `dsh --profile web` over SSH, the URL line still prints but the browser is not opened for you: the SSH client or editor owns the local forwarding address. Without an advertised root the printed URL names the remote host's loopback endpoint, which you reach through your forwarding address. With `--public-url` the printed URL is the authenticated advertised root; the browser handoff stays suppressed, because opening a browser on the remote host cannot reach your screen.
When you launch `dsh --profile web` over SSH, the URL line still prints but the browser is not opened for you: the SSH client or editor owns the local forwarding address. Without an advertised root the printed URL names the remote host's bind-address endpoint — loopback for a loopback bind — which you reach through your forwarding address. With `--public-url` the printed URL is the authenticated advertised root; the browser handoff stays suppressed, because opening a browser on the remote host cannot reach your screen.
### Per-session agent setup
@@ -83,11 +94,11 @@ Each browser session selects a shipped preset (`standard` by default). The Agent
<details>
<summary>Implementation internals — click to expand</summary>
The bundle is one patch layer of five files plus one runtime glue plugin: `cordis.patch.yml` carries the host rows and the preset registry, and each `presets/<id>.patch.yml` inserts one shipped preset declaration, applied in the order `dsh.bundle.patch` lists them. The storage stack and projection cache come from `dsh-base`; the web overlay's workspace and message-feedback rows consume that shared `storageDomain` service. The patch restates the surface-specific values the base deliberately omits, inserts the web-only host rows and browser roster, then moves the agent plane behind presets. The glue plugin owns dist serving, the advertised application URL, trust sampling, prompt sections, the bash variable, and the readiness announcements. The `office-to-pdf` row mounts one lazy [Office conversion provider](../../document/office-to-pdf/README.md) for Host consumers, including Desktop compositions using this bundle. The conversion service's Remote methods authorize preview reads, while Document Preview owns the Office viewer and Client cache.
The bundle is one patch layer of five files plus one runtime glue plugin: `cordis.patch.yml` carries the host rows and the preset registry, and each `presets/<id>.patch.yml` inserts one shipped preset declaration, applied in the order `dsh.bundle.patch` lists them. The storage stack and projection cache come from `dsh-base`; the web overlay's workspace and message-feedback rows consume that shared `storageDomain` service. The patch restates the surface-specific values the base deliberately omits, inserts the web-only host rows and browser roster, then moves the agent plane behind presets. The glue plugin owns dist serving, the advertised application URL, the plain-HTTP exposure warning, prompt sections, the bash variable, and the readiness announcements. The `office-to-pdf` row mounts one lazy [Office conversion provider](../../document/office-to-pdf/README.md) for Host consumers, including Desktop compositions using this bundle. The conversion service's Remote methods authorize preview reads, while Document Preview owns the Office viewer and Client cache.
### Patch semantics
A patch replaces the targeted row's whole `config`, so each web row restates every key it owns: the persona prefix template, the `DSH_TOOLS_MODE` PTC mode opt-in, and the `session-query-sqlite` values on the base rows, then `insert` adds the web host rows, transport, and browser roster. The `webserver` and `web-runtime` rows inject the `webStartup` provider and read their invocation values directly; the `connection` row instead reads the bind-dependent `webRuntime` values the web-runtime row publishes, which are that provider's authorities plus the LAN literals of an all-interfaces bind. The per-agent tool rows the base mounts process-wide are disabled here and the preset roster takes over; the reasoning for each host-plane versus preset-plane decision is inline in the patch.
A patch replaces the targeted row's whole `config`, so each web row restates every key it owns: the persona prefix template, the `DSH_TOOLS_MODE` PTC mode opt-in, and the `session-query-sqlite` values on the base rows, then `insert` adds the web host rows, transport, and browser roster. The `webserver`, `web-runtime`, and `connection` rows inject the `webStartup` provider and read their invocation values directly; Connection composes its accepted authorities from `ctx.webStartup.trustedHosts`, and the Host/Origin fence accepts the listener's bind IP independently of that list. The per-agent tool rows the base mounts process-wide are disabled here and the preset roster takes over; the reasoning for each host-plane versus preset-plane decision is inline in the patch.
### Advertised application URL
@@ -97,23 +108,18 @@ Startup display and browser handoff receive the advertised root with its launch
The URL line and browser handoff are readiness signals: supervisors RPC as soon as they observe the line, and a browser requests the page as soon as it opens, so both run only after the Loader tree settles, the required-startup audit passes, and Connection authentication is available — or immediately in a hand-built tree without a Loader. Client combo JavaScript and source maps remain unmaterialized at this point. Optional plugin failures do not suppress readiness; a required startup failure or a tree disposed mid-boot announces nothing.
### LAN trust sampling
`resolveLanTrust` samples the network once at boot: a loopback bind (`127.0.0.1`) derives no LAN addresses, while an all-interfaces bind adds every non-internal IPv4 literal. The derived literals plus the explicit `--trusted-host` authorities form the `/api` browser-trust fence, and the printed LAN URL always matches that fence.
### Source map
| File | Role |
|---|---|
| [`src/index.ts`](src/index.ts) | The `web-app` glue plugin: dist resolution, advertised application URL, LAN trust sampling, prompt sections, bash variable, URL line, browser handoff |
| [`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` |
| [`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 resolution, fallback seat, prompt sections, readiness, advertised URL publication |
| [`tests/web-app.spec.ts`](tests/web-app.spec.ts) | Dist index anchoring, advertised-URL publication and warnings, prompt sections, readiness publication |
| [`tests/startup.spec.ts`](tests/startup.spec.ts) | Command-line parsing over a real Loader tree |
| [`tests/public-url.spec.ts`](tests/public-url.spec.ts) | Advertised-root parsing and normalization |
| [`tests/trusted-hosts.spec.ts`](tests/trusted-hosts.spec.ts) | LAN-trust sampling |
| [`tests/browser-open.spec.ts`](tests/browser-open.spec.ts) | Default-browser handoff after the page is reachable |
</details>
@@ -159,11 +165,10 @@ These limits tell you what to expect in unusual setups — a source checkout, SS
- **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.
- **LAN addresses are sampled once at startup** — interface changes after boot are not re-advertised; the printed LAN URL always matches what was sampled.
- **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 loopback endpoint; the SSH client or editor must expose and open the local forwarded address.
- **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.
- **Binding all network interfaces is not supported** — `--host 0.0.0.0` is rejected at startup for safety; use the default loopback host.
- **Binding all network interfaces is not supported** — a wildcard `--host`, including the IPv4-mapped forms Linux reads as IPv4 any, is rejected at startup for safety; bind one concrete IPv4 or IPv6 address of a local interface instead.
<a id="dev-note"></a>
### Dev Note
+23 -18
View File
@@ -13,7 +13,7 @@ kind: "package-bundle"
## 概述
运行 `dsh --profile web`,获得浏览器内的聊天、模型与设置管理以及会话历史,并与其他 dsh 表层共用同一套模型访问、工具与安全默认值。启动时会打印带 token 的 URL,通常还会在默认浏览器中打开;SSH 会话和 `--no-open` 需要手动打开。你可以更改端口并允许额外主机,但不能绑定所有网络接口。跨机访问支持在剥离前缀的代理之后公告一个公开的 HTTP(S) URL。一次性的命令行任务应使用 `dsh-headless`。
运行 `dsh --profile web`,获得浏览器内的聊天、模型与设置管理以及会话历史,并与其他 dsh 表层共用同一套模型访问、工具与安全默认值。启动时会打印带 token 的 URL,通常还会在默认浏览器中打开;SSH 会话和 `--no-open` 需要手动打开。你可以更改端口、允许额外 authority,并绑定一个具体的本机 IP;通配地址会被拒绝。跨机访问使用剥离前缀代理后公告的 HTTP(S) URL,或非 loopback 绑定上的明文 HTTP。一次性的命令行任务应使用 headless 配置。
## 目录
@@ -53,23 +53,34 @@ dsh --profile web --no-open --port 8080
| `openBrowser` | `true` | 启动后用默认浏览器打开;SSH 启动会抑制它 |
| `printUrl` | `true` | 启动时打印 `dsh web:` URL 行 |
| `surfaceContext` | `true` | 给 agent 提供 GUI 定位上下文,并把 `DSH_WEB_URL` 暴露给其 shell 命令 |
| `publicUrl` | 未设置 | 对外公告的 HTTP(S) 应用根;否则公告监听器的 loopback URL |
| `trustedHosts` | `[]` | 允许从网络访问 GUI 的额外主机 |
| `publicUrl` | 未设置 | 对外公告的 HTTP(S) 应用根;否则公告绑定地址 URL |
生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-web-app)列出了本运行时插件接受的字段及其 JSDoc。随发行版交付的组合插入 `schedule` 服务行与 `ui-schedule` 任务页面行,时钟读数与四个提醒工具则属于 `standard`、`cordis` 与 `ptc` 三个 preset。这三个 preset 的 `tool-subagent` 与 `tool-subagent-fork` 两行都 deny 这四个工具,因此被委派子 agent 的作用域不会列出它们。
<a id="public-deployments"></a>
### 监听、信任与公开部署
默认情况下 GUI 只在 loopback 上监听,只接受本机的连接;可重复的 `--trusted-host` 会添加其 Host/Origin 栅栏接受的 authority,因此远端浏览器要么经由剥离前缀的代理访问,要么通过以可信主机名呈现的端口转发客户端访问。
默认情况下 GUI 监听 loopback。只有 `--host` 会改变监听器:它接受一个具体的本机 IPv4 或 IPv6 地址,不接受通配地址。非 loopback 监听器提供明文 HTTP,并在启动时警告,即使前置 HTTPS 的 `--public-url` 也不例外。Host/Origin 栅栏接受绑定 IP 本身;其他非 loopback authority 需要 `--trusted-host`。启动 token 交换与签名会话 cookie 认证每个 API 方法与 WebSocket 流。
容器可以绑定其 Pod 地址,并公告前置的 ingress:
```sh
dsh --profile web --no-open --host "$(hostname -i | awk '{print $1}')" --public-url https://app.example/ --trusted-host app.example
```
`hostname -i` 可能列出多个地址;`--host` 只接受一个,因此命令取第一个。`fe80::1%eth0` 这样的 zone ID 不能出现在 URL 中,因此公告根会剥离多余的 loopback zone,而非 loopback zone 则要求显式提供 `--public-url`。
未设置 `--public-url` 时,`::ffff:127.0.0.1` 这样的 mapped IPv6 回环绑定会公告其 IPv4 形式 `127.0.0.1`,让浏览器将其识别为本地可信来源。监听器仍使用配置的绑定地址。点分四段式的 IPv6 尾部表示该地址自身的低 32 位,与 `listen` 的读法完全一致:`::0.0.0.1` 属于 IPv6 回环并公告 `[::1]`,而 `::127.0.0.1` 是非回环地址,并非 IPv4 回环。
`--public-url` 公告浏览器使用的 HTTP(S) 根——打印与打开的启动 URL、`DSH_WEB_URL` 与 web 表层定位。公告不授予任何信任:浏览器可见的 authority 还必须用 `--trusted-host` 点名。该 flag 不配置监听器、路由或 cookie 作用域,因为外部链路归代理所有:[在反向代理之后发布 Web UI](../../../docs/user/guide/public-deployments.zh.md)列出了这样的部署必须提供什么。
打印的 URL 包含进程凭据,只应与预期用户分享;`printUrl: false` 无论是否配置 `--public-url` 都会抑制该行。
无论是公告 URL 还是浏览器信任栅栏都不保护端口本身,因此请把端口限制在可信代理或网络内。
打印的 URL 包含进程凭据,只应与预期用户分享。`printUrl: false` 无论是否配置 `--public-url` 都会抑制该行,凭据也绝不会出现在模型上下文或 `DSH_WEB_URL` 中。
### 通过 SSH 运行
通过 SSH 启动 `dsh --profile web` 时,URL 行仍会打印,但不会为你打开浏览器:本地转发地址由 SSH 客户端或编辑器持有。没有公告根时,打印的 URL 指向远端宿主机 loopback 端点,你通过自己的转发地址访问它。配置了 `--public-url` 时,打印的 URL 是带认证的公告根;浏览器交接仍被抑制,因为在远端宿主机上打开浏览器无法到达你的屏幕。
通过 SSH 启动 `dsh --profile web` 时,URL 行仍会打印,但不会为你打开浏览器:本地转发地址由 SSH 客户端或编辑器持有。没有公告根时,打印的 URL 指向远端宿主机的绑定地址端点——绑定 loopback 时即 loopback——你通过自己的转发地址访问它。配置了 `--public-url` 时,打印的 URL 是带认证的公告根;浏览器交接仍被抑制,因为在远端宿主机上打开浏览器无法到达你的屏幕。
### 按会话的 agent 设置
@@ -83,11 +94,11 @@ dsh --profile web --no-open --port 8080
<details>
<summary>实现细节——点击展开</summary>
此 bundle 由一层五个文件的补丁和一个运行时胶水插件组成:`cordis.patch.yml` 承载宿主行和 preset 注册表,每个 `presets/<id>.patch.yml` 插入一条随发行版交付的 preset 声明,按 `dsh.bundle.patch` 列出的顺序应用。存储栈与投影缓存来自 `dsh-base`;Web 叠加层的工作区和消息反馈条目消费共享的 `storageDomain` 服务。补丁重述 base 有意省略的界面专用值,插入 Web 专用宿主条目和浏览器插件列表,再将 Agent 层移到预设后面。胶水插件负责 dist 服务、公告应用 URL、信任采样、提示词段落、bash 变量和就绪通知。`office-to-pdf` 条目为宿主消费者挂载一个延迟创建引擎的 [Office 转换提供方](../../document/office-to-pdf/README.zh.md),使用此 bundle 的 Desktop 组合也共享该提供方。 转换服务的 Remote 方法负责预览读取授权,Document Preview 负责 Office 查看器和客户端缓存。
此 bundle 由一层五个文件的补丁和一个运行时胶水插件组成:`cordis.patch.yml` 承载宿主行和 preset 注册表,每个 `presets/<id>.patch.yml` 插入一条随发行版交付的 preset 声明,按 `dsh.bundle.patch` 列出的顺序应用。存储栈与投影缓存来自 `dsh-base`;Web 叠加层的工作区和消息反馈条目消费共享的 `storageDomain` 服务。补丁重述 base 有意省略的界面专用值,插入 Web 专用宿主条目和浏览器插件列表,再将 Agent 层移到预设后面。胶水插件负责 dist 服务、公告应用 URL、明文 HTTP 暴露警告、提示词段落、bash 变量和就绪通知。`office-to-pdf` 条目为宿主消费者挂载一个延迟创建引擎的 [Office 转换提供方](../../document/office-to-pdf/README.zh.md),使用此 bundle 的 Desktop 组合也共享该提供方。 转换服务的 Remote 方法负责预览读取授权,Document Preview 负责 Office 查看器和客户端缓存。
### patch 语义
patch 会替换目标行的整个 `config`,因此每个 Web 行都重述自己拥有的每个键:基础行上的 persona 前缀模板、`DSH_TOOLS_MODE` PTC mode 开关与 `session-query-sqlite` 值,随后 `insert` 添加 Web 宿主行、传输层与浏览器名录。`webserver` 与 `web-runtime` 行注入 `webStartup` 提供方并直接读取本次调用的取值;`connection` 行则改为读取 web-runtime 行发布的、与绑定相关的 `webRuntime` 值,即该提供方的 authority 加上全接口绑定的 LAN 字面量。base 以进程级挂载的按 agent 工具行在这里被禁用,由 preset 名录接管;每项宿主层与 preset 层归属决策的理由以行内注释写在 patch 里。
patch 会替换目标行的整个 `config`,因此每个 Web 行都重述自己拥有的每个键:基础行上的 persona 前缀模板、`DSH_TOOLS_MODE` PTC mode 开关与 `session-query-sqlite` 值,随后 `insert` 添加 Web 宿主行、传输层与浏览器名录。`webserver`、`web-runtime` 与 `connection` 行都注入 `webStartup` 提供方并直接读取本次调用的取值;Connection 的被接受 authority 全部来自 `ctx.webStartup.trustedHosts`,Host/Origin 栅栏则独立于该列表接受监听器的绑定 IP。base 以进程级挂载的按 agent 工具行在这里被禁用,由 preset 名录接管;每项宿主层与 preset 层归属决策的理由以行内注释写在 patch 里。
### 公告应用 URL
@@ -97,23 +108,18 @@ patch 会替换目标行的整个 `config`,因此每个 Web 行都重述自己
URL 行与浏览器交接都是就绪信号:监督方一观察到该行就发起 RPC,浏览器一打开就请求页面,因此两者只在 Loader 配置树结算、通过 required 启动检查且 Connection 认证可用后运行——在没有 Loader 的手工构建树中则立即运行。此时 client combo JavaScript 和 source map 仍未物化。可选插件失败不会阻止就绪宣告;required 启动失败或启动中途被释放的树不会宣告任何内容。
### LAN 信任采样
`resolveLanTrust` 在启动时只采样一次网络:loopback 绑定(`127.0.0.1`)不派生任何 LAN 地址,绑定所有网卡则会加入每个非 internal IPv4 字面量。派生字面量加上显式的 `--trusted-host` 权威标识组成 `/api` 浏览器信任栅栏,打印的 LAN URL 始终与该栅栏一致。
### 源码地图
| 文件 | 职责 |
|---|---|
| [`src/index.ts`](src/index.ts) | `web-app` 粘合插件:dist 解析、公告应用 URL、LAN 信任采样、提示词段落、bash 变量、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` |
| [`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 解析、回退席位、提示词段落、就绪宣告、公告 URL 发布 |
| [`tests/web-app.spec.ts`](tests/web-app.spec.ts) | dist index 锚定、公告 URL 发布与警告、提示词段落、就绪宣告发布 |
| [`tests/startup.spec.ts`](tests/startup.spec.ts) | 在真实 Loader 树上的命令行解析 |
| [`tests/public-url.spec.ts`](tests/public-url.spec.ts) | 公告根的解析与归一化 |
| [`tests/trusted-hosts.spec.ts`](tests/trusted-hosts.spec.ts) | LAN 信任采样 |
| [`tests/browser-open.spec.ts`](tests/browser-open.spec.ts) | 页面可达后的默认浏览器交接 |
</details>
@@ -159,11 +165,10 @@ URL 行与浏览器交接都是就绪信号:监督方一观察到该行就发
- **前端必须已构建**——源码 checkout 需要先运行 `pnpm run build`;dist 缺失时启动会以构建提示停止,且没有从源码直接服务的回退路径。
- **监听器不提供 TLS**——请用终止 TLS 的代理保护外部链路;HTTP 公告根会以明文发送凭据。
- **LAN 地址只在启动时采样一次**——启动后的网卡变化不会重新公告;打印的 LAN URL 始终与采样结果一致。
- **只能观察到交接的启动**——GUI 只报告浏览器被请求打开,而不是它确实打开了;之后的浏览器退出永远不会上报,打印的 URL 是你的手动回退路径。
- **SSH 会话保留 URL 但跳过浏览器交接**——没有公告根时,打印的 URL 指向远端宿主机 loopback 端点;SSH 客户端或编辑器必须暴露并打开本地转发地址。
- **SSH 会话保留 URL 但跳过浏览器交接**——没有公告根时,打印的 URL 指向远端宿主机的绑定地址;SSH 客户端或编辑器必须暴露并打开本地转发地址。
- **`BROWSER` 覆盖只能来自环境**——被发现的 `.env` 不能设置 `BROWSER`;只有继承值能为自动交接选择可执行文件。
- **不支持绑定所有网络接口**——出于安全考虑,`--host 0.0.0.0` 会在启动时被拒绝;请使用默认 loopback 主机。
- **不支持绑定所有网络接口**——出于安全考虑,通配 `--host`(含 Linux 视为 IPv4 any 的 IPv4-mapped 形式)会在启动时被拒绝;请改为绑定本机某个接口的一个具体 IPv4 或 IPv6 地址。
<a id="dev-note"></a>
### 开发备注
+14 -14
View File
@@ -7,8 +7,9 @@
#
# The web-startup plugin injects `cmdlineArgs` and provides `webStartup` as an
# ordinary Cordis service. Rows configured from flags inject that service, so
# Loader resolves their expressions only after it exists. The web runtime then
# provides bind-dependent `webRuntime` values to the trust fence.
# Loader resolves their expressions only after it exists. The web-runtime row
# validates the advertised public URL; Connection reads invocation authorities
# directly from `webStartup`.
# `dsh --profile web --help` provides no `webStartup`, so no server binds.
# ── surface-specific values the base deliberately omits ─────────────────────
@@ -173,7 +174,8 @@
# webStartup provider, with these deployment fallbacks. The dist is served
# by the web-runtime row below through the fallback seat. Routing and
# browser-facing asset paths do not depend on the advertised URL, which the
# web-runtime row owns.
# web-runtime row owns; Connection reads trust authorities from webStartup.
# This row rejects wildcard binds.
- id: webserver
name: '@deepseek-ai/dsh-host-webserver'
inject: [webStartup]
@@ -190,9 +192,8 @@
# section and the bash runtime variable, prints the URL line, and opens the
# canonical URL after the full Loader tree settles and the required-
# entry audit passes. Invocation-only values come from the webStartup
# provider; the advertised application URL is this row's `publicUrl` (the
# loopback URL when unset). After the server binds, this row samples LAN
# trust once and provides `webRuntime`. A complete agent-preset persona
# provider; the advertised application URL is this row's `publicUrl`,
# otherwise the bind-address URL. A complete agent-preset persona
# suppresses the prompt section for that agent while retaining the
# host-owned shell variable.
- id: web-runtime
@@ -203,7 +204,6 @@
printUrl: true
publicUrl: !!js ctx.webStartup.publicUrl
surfaceContext: true
trustedHosts: !!js ctx.webStartup.trustedHosts
# The client-plugin reload chain, always mounted: it is idle until a
# rebuild watcher (pnpm run dev:web) actually rewrites client bundles. It
@@ -223,16 +223,16 @@
# Owns both ends of the web transport: node half binds the gateway to the
# webserver under /api; browser half is the fetch/SSE client. Every API
# request and upgrade passes the loopback plus trustedHosts policy; an
# advertised public URL contributes nothing to it.
# request and upgrade passes the loopback, listener-bind, or trustedHosts
# policy; an advertised public URL contributes nothing to it.
- id: connection
name: '@deepseek-ai/dsh-client-connection'
inject: [webRuntime]
inject: [webStartup]
config:
# LAN literals derived from the active bind plus --trusted-host extras.
# A deployment adding authorities keeps this expression and concatenates
# its literals, for example: ['app.internal', ...ctx.webRuntime.trustedHosts].
trustedHosts: !!js ctx.webRuntime.trustedHosts
# Keep invocation authorities (each --trusted-host value) when adding
# deployment values, for example: ['app.internal', ...ctx.webStartup.trustedHosts].
# The listener's bind IP is accepted separately from this list.
trustedHosts: !!js ctx.webStartup.trustedHosts
# Raw Blob and ReadableStream uploads run independently of Connection's
# RPC and generation service.
+57 -66
View File
@@ -6,26 +6,26 @@
* config), mounts the `frontend-static` fallback owner over it, registers the
* 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 root — the loopback URL
* otherwise. App command-line values arrive through the `webStartup` service
* expressions in the bundle patch.
* advertised `publicUrl` replaces the published bind-address URL. Non-loopback
* binds trigger a plain-HTTP exposure warning. App command-line values arrive
* through the `webStartup` service expressions in the bundle patch.
* @module @deepseek-ai/dsh-web-app
*/
import { spawn, type ChildProcess } from 'node:child_process'
import { createRequire } from 'node:module'
import { isIP } from 'node:net'
import { dirname, join } from 'node:path'
import { networkInterfaces } from 'node:os'
import { fileURLToPath } from 'node:url'
import type { Context } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
import { addHarnessSourceSection, auditStartupEntries } from '@deepseek-ai/dsh-app-boot'
import type {} from '@deepseek-ai/dsh-client-connection'
import * as FrontendStatic from '@deepseek-ai/dsh-host-frontend-static'
import { isLoopbackHost, normalizeBindAddress } from '@deepseek-ai/dsh-host-webserver'
import { launchedThroughSsh, launchEnvironmentOf } from '@deepseek-ai/dsh-launch-environment'
import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess'
import type {} from '@deepseek-ai/cordis-plugin-loader'
import type {} from '@deepseek-ai/dsh-host-webserver'
import type {} from '@deepseek-ai/dsh-shell-env'
import { parsePublicUrl } from './public-url.ts'
@@ -36,9 +36,6 @@ export const name = 'web-app'
const SOURCE_ROOT = fileURLToPath(new URL('../../../..', import.meta.url))
const ANNOUNCED_ROOTS = new WeakSet<Context>()
/** Runtime service that releases Web rows after bind-dependent values resolve. */
const WEB_RUNTIME_SERVICE = 'webRuntime'
/** Services required before the web runtime can mount. */
export const inject = ['webServer']
@@ -58,13 +55,11 @@ export interface Config {
/**
* Canonical HTTP(S) root to advertise in the printed and opened URL,
* `DSH_WEB_URL`, and the web-surface orientation, e.g.
* `https://app.example/ui/`, normalized to end in `/`. Advertisement only;
* see [public deployments](../README.md#public-deployments). Absent or YAML
* `null` advertises the loopback URL.
* `https://app.example/ui/`, normalized to end in `/`. Configures no routing
* or authentication; see [public deployments](../README.md#public-deployments).
* Absent or YAML `null` advertises the bind-address URL.
*/
publicUrl?: string
/** Explicit `--trusted-host` authorities from this invocation. */
trustedHosts: string[]
}
export const Config: z<Config> = z.object({
@@ -72,26 +67,11 @@ export const Config: z<Config> = z.object({
printUrl: z.boolean().default(true),
surfaceContext: z.boolean().default(true),
publicUrl: z.transform(z.string(), value => parsePublicUrl(value).href),
trustedHosts: z.array(String).default([]),
})
/** Bind-dependent Web values shared by the trust fence and URL display. */
export interface WebRuntimeValues {
/** LAN IPv4 literals sampled once when the server binds all interfaces. */
lanAddresses: string[]
/** LAN literals followed by explicit invocation authorities. */
trustedHosts: string[]
}
/** Environment variable naming the advertised URL of this Web GUI. */
const DSH_WEB_URL = 'DSH_WEB_URL' as const
// Display-only mirror of the webserver schema's loopback host: the address the
// local URL always prints. Not a source of truth — the schema is.
const LOOPBACK_HOST = '127.0.0.1'
/** The webserver schema's all-interfaces bind literal. */
const ALL_INTERFACES_HOST = '0.0.0.0'
const BROWSER_OPENER_MODULE = import.meta.resolve('open')
const BROWSER_OPENER_PROGRAM = `
@@ -124,22 +104,48 @@ try {
`
/**
* Resolve one LAN-trust snapshot from the active server bind.
*
* Derived entries are port-less IP literals: DNS rebinding needs an
* attacker-controlled name, while an IP-literal Host is safe on any port and
* an OS-assigned port is unknowable before bind.
* @param bindHost - the active webserver bind host.
* @param extra - explicit `--trusted-host` values, in argument order.
* @returns the LAN display addresses and invocation-derived fence authorities.
* Local root URL of the active Web server, using the browser-compatible form
* of its bind address and brackets for IPv6 literals. Non-loopback binds such
* as a container's Pod address advertise that address.
* @throws when the runtime resolves without a bound webServer, or when the bind
* address has no URL form (see {@link advertisedBindHost}).
*/
export function resolveLanTrust(bindHost: string, extra: readonly string[]): WebRuntimeValues {
const lanAddresses = bindHost === ALL_INTERFACES_HOST
? Object.values(networkInterfaces()).flat()
.filter((iface): iface is NonNullable<typeof iface> => iface !== undefined && iface.family === 'IPv4' && !iface.internal)
.map(iface => iface.address)
: []
return { lanAddresses, trustedHosts: [...lanAddresses, ...extra] }
function localWebUrl(ctx: Context): string {
const webServer = ctx.get('webServer')
const port = webServer?.port
if (webServer === undefined || port === undefined) {
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)}`
}
/**
* Bind address in a URL. Loopback addresses use their canonical address text,
* so browsers treat them as local, trustworthy origins: a genuinely mapped
* literal such as `::ffff:127.0.0.1` reads as `127.0.0.1`, and a dotted-quad
* tail reads as the address it names, so `::0.0.0.1` reads as `::1` rather
* than as `0.0.0.1`. Interface zone IDs cannot appear in a URL; only a
* redundant loopback zone can be dropped.
* @param host - webserver bind address.
* @returns the zone-free URL host.
* @throws when a non-loopback zone requires an explicit public URL.
*/
function advertisedBindHost(host: string): string {
const zoneAt = host.indexOf('%')
const bare = zoneAt === -1 ? host : host.slice(0, zoneAt)
if (isLoopbackHost(bare)) return normalizeBindAddress(bare)
if (zoneAt === -1) return host
throw new Error(
`web-app: bind address ${JSON.stringify(host)} carries an interface zone id, which no URL can express;`
+ ' pass --public-url with the root browsers actually reach it through',
)
}
/** Resolve the advertised root: this plugin's canonical `publicUrl`, or the bind-address URL (loopback when bound to loopback). */
function appRootUrl(ctx: Context, publicUrl: string | undefined): string {
if (publicUrl !== undefined) return publicUrl
return localWebUrl(ctx)
}
/** Model-visible orientation and acceptance boundary for sessions created through `dsh web`. */
@@ -156,18 +162,6 @@ function webSurfacePrompt(webUrl: string): string {
+ 'Do not start a replacement server unless the user asks; if one is needed, use a managed background job and verify its exact URL.'
}
/** Resolve the canonical loopback URL from the active Web server. */
function localWebUrl(ctx: Context): string {
const port = ctx.get('webServer')?.port
if (port === undefined) throw new Error('web-app: webServer service missing while resolving Web runtime')
return `http://${LOOPBACK_HOST}:${String(port)}`
}
function appRootUrl(ctx: Context, publicUrl: string | undefined): string {
if (publicUrl !== undefined) return publicUrl
return localWebUrl(ctx)
}
/**
* Dist location is workspace knowledge of this bundle: anchored on the
* frontend package manifest, not configured. Existence is a request-time
@@ -239,15 +233,18 @@ export const internals: {
* @param config - validated {@link Config}.
*/
export function apply(ctx: Context, config: Config): void {
const runtime = resolveLanTrust(ctx.webServer.host, config.trustedHosts)
// The schema validates a present string; an explicit YAML `null` bypasses
// the string transform and reaches here, meaning unset.
const publicUrl = config.publicUrl ?? undefined
// The loopback URL belongs to this host. Under SSH, the operator reaches it
const boundHost = ctx.webServer.host
if (publicUrl === undefined) advertisedBindHost(boundHost)
// The bound transport remains plain HTTP even when publicUrl uses HTTPS.
if (!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
// through a local forwarding address that this process cannot derive.
const handoffBrowser = config.openBrowser && !launchedThroughSsh(launchEnvironmentOf(ctx))
// Release dependent rows only after bind-dependent trust has been sampled once.
ctx.provide(WEB_RUNTIME_SERVICE, runtime)
ctx.plugin(FrontendStatic, { distIndex: internals.resolveDistIndex() })
if (config.surfaceContext) {
ctx.inject(['systemPrompt'], (promptCtx) => {
@@ -279,15 +276,9 @@ export function apply(ctx: Context, config: Config): void {
if (ANNOUNCED_ROOTS.has(connectionCtx.root)) return
const webUrl = appRootUrl(connectionCtx, publicUrl)
const authenticatedUrl = connectionCtx.connection.authenticatedUrl(webUrl)
// Reuse the exact LAN snapshot provided to the /api trust fence.
const lanCandidate = runtime.lanAddresses[0]
const port = connectionCtx.webServer.port
const lanUrl = lanCandidate === undefined
? undefined
: connectionCtx.connection.authenticatedUrl(`http://${lanCandidate}:${String(port)}`)
ANNOUNCED_ROOTS.add(connectionCtx.root)
if (config.printUrl) {
console.log(`dsh web: ${authenticatedUrl}${lanUrl === undefined ? '' : ` (LAN: ${lanUrl})`}`)
console.log(`dsh web: ${authenticatedUrl}`)
}
if (handoffBrowser) {
console.log('dsh web: opening the default browser; pass --no-open to disable')
+8 -6
View File
@@ -10,6 +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 { parsePublicUrl } from './public-url.ts'
/** Stable Cordis plugin name. */
@@ -56,7 +57,7 @@ function webCommand(): Command {
.name('dsh --profile web')
.description('Serve the DeepSeek Harness browser UI.')
.helpOption('-h, --help', 'show this help')
.option('--host <host>', 'bind host')
.option('--host <host>', 'bind address: one concrete IPv4 or IPv6 literal of a local interface; wildcard addresses are rejected')
.option('--no-open', 'do not open the Web UI in the default browser')
.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')
@@ -66,6 +67,7 @@ Examples:
dsh --profile web serve on the composed host and port
dsh --profile web --no-open serve without opening a browser
dsh --profile web --port 8080 serve on another port
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
`)
@@ -73,17 +75,17 @@ Examples:
/**
* Parse and provide the Web invocation as an ordinary Cordis service. The
* command's action publishes the flags this invocation named; `--host 0.0.0.0`,
* a non-numeric `--port`, or a 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; a wildcard
* `--host`, non-numeric `--port`, or malformed `--public-url` is a usage error,
* so on rejection (and on `--help`) nothing is provided.
* @param ctx - plugin context carrying the command line.
*/
export function apply(ctx: Context): void {
const program = webCommand()
program.action(() => {
const options = program.opts<WebOptions>()
if (options.host === '0.0.0.0') {
program.error('error: --host 0.0.0.0 is intentionally not supported yet for safety: it would expose remote code execution to the network; use 127.0.0.1 instead')
if (options.host !== undefined && isWildcardHost(options.host)) {
program.error(`error: --host ${options.host} is an unspecified (wildcard) address, which is not supported: binding every interface would expose remote code execution to the network; bind one concrete IPv4 or IPv6 address instead`)
}
if (options.port !== undefined && !/^\d+$/.test(options.port)) {
program.error(`error: --port must be a number, got ${JSON.stringify(options.port)}`)
@@ -72,7 +72,6 @@ describe('web app browser startup', () => {
' openBrowser: true',
' printUrl: false',
' surfaceContext: false',
' trustedHosts: []',
'',
].join('\n'))
+10 -8
View File
@@ -111,6 +111,16 @@ describe('web command-line provider', () => {
expect(observed.exits).toEqual([])
})
// The webserver suite owns the wildcard spellings; the CLI only has to
// refuse them by address value before any consumer activates.
it.each(['0.0.0.0', '::ffff:0.0.0.0'])('rejects wildcard --host %s before activating consumers', async (host) => {
const { values, observed } = await bootProvider(['--host', host])
expect(observed.out).toContain(`error: --host ${host} is an unspecified (wildcard) address`)
expect(values).toBeUndefined()
expect(observed.readerConfig).toBeUndefined()
expect(observed.exits).toEqual([1])
})
it('leaves deployment values to each consumer when flags omit them', async () => {
const { values, observed } = await bootProvider([])
expect(values).toEqual({ openBrowser: true, trustedHosts: [] })
@@ -141,14 +151,6 @@ describe('web command-line provider', () => {
expect(observed.exits).toEqual([1])
})
it('rejects the intentionally unsupported all-interfaces host before the consumer activates', async () => {
const { values, observed } = await bootProvider(['--host', '0.0.0.0'])
expect(observed.out).toContain('--host 0.0.0.0 is intentionally not supported yet for safety: it would expose remote code execution to the network; use 127.0.0.1 instead')
expect(values).toBeUndefined()
expect(observed.readerConfig).toBeUndefined()
expect(observed.exits).toEqual([1])
})
it('publishes --public-url as advertisement only, leaving the fence to --trusted-host', async () => {
const { values, observed } = await bootProvider([
'--public-url', 'https://web.example/ui',
@@ -1,34 +0,0 @@
/** Single-sample LAN-trust resolution for the /api browser-trust fence (`resolveLanTrust`). */
import { describe, expect, it, vi } from 'vitest'
import { resolveLanTrust } from '../src/index.ts'
vi.mock('node:os', () => ({
networkInterfaces: () => ({
lo0: [
{ family: 'IPv4', internal: true, address: '127.0.0.1' },
],
en0: [
{ family: 'IPv6', internal: false, address: 'fe80::1' },
{ family: 'IPv4', internal: false, address: '192.168.1.5' },
],
en1: [
{ family: 'IPv4', internal: false, address: '10.0.0.7' },
],
utun0: undefined,
}),
}))
describe('resolveLanTrust', () => {
it('samples non-internal IPv4 addresses once for an all-interfaces bind: trust and display share them', () => {
const { lanAddresses, trustedHosts } = resolveLanTrust('0.0.0.0', ['harness.internal:3080'])
expect(lanAddresses).toEqual(['192.168.1.5', '10.0.0.7'])
expect(trustedHosts).toEqual(['192.168.1.5', '10.0.0.7', 'harness.internal:3080'])
})
it('derives nothing for a loopback bind — extras alone stand, no LAN URL to print', () => {
expect(resolveLanTrust('127.0.0.1', [])).toEqual({ lanAddresses: [], trustedHosts: [] })
expect(resolveLanTrust('127.0.0.1', ['lab.internal']))
.toEqual({ lanAddresses: [], trustedHosts: ['lab.internal'] })
})
})
+117 -57
View File
@@ -1,11 +1,4 @@
/**
* Web runtime glue behavior: dist resolution through the bundle's own hook,
* the frontend-static child claiming the fallback seat, the web-surface
* prompt section and bash runtime variables, and readiness publication through
* the URL line and default-browser handoff. The advertised URL comes from this
* plugin's `publicUrl` config (loopback when unset), never from the bound
* webserver.
*/
/** Web runtime URL publication, exposure warnings, and Loader/browser handoff readiness. */
import { EventEmitter } from 'node:events'
import { spawn, type ChildProcess } from 'node:child_process'
@@ -27,14 +20,6 @@ vi.mock('node:child_process', async importOriginal => ({
spawn: vi.fn(),
}))
vi.mock('node:os', async importOriginal => ({
...await importOriginal<typeof import('node:os')>(),
networkInterfaces: () => ({
lo0: [{ family: 'IPv4', internal: true, address: '127.0.0.1' }],
en0: [{ family: 'IPv4', internal: false, address: '192.168.1.5' }],
}),
}))
let dist: string | undefined
beforeEach(() => {
@@ -72,19 +57,15 @@ function stageDist(): string {
return index
}
/** A fake webServer capturing the fallback seat and index taps. */
function fakeHttpServer(host: '127.0.0.1' | '0.0.0.0' = '127.0.0.1'): { server: WebServer; seat: () => unknown } {
let fallback: unknown
const server = {
/** Web carrier without a network listener. */
function fakeHttpServer(host = '127.0.0.1'): { server: WebServer } {
const server: Pick<WebServer, 'host' | 'port' | 'registerFallback' | 'renderIndex'> = {
host,
port: 4567,
registerFallback: (handler: unknown) => {
fallback = handler
return () => { fallback = undefined }
},
registerFallback: () => () => {},
renderIndex: (html: string) => html,
} as unknown as WebServer
return { server, seat: () => fallback }
}
return { server: server as WebServer }
}
/** Deterministic Host Connection face for URL publication and frontend injection. */
@@ -115,15 +96,20 @@ interface BashContribution {
}
describe('web-app runtime glue', () => {
it('mounts dist serving, prompt section, bash variables, and publishes the URL with the LAN snapshot', async () => {
it.each([
'127.0.0.1',
'::ffff:127.0.0.1',
'::ffff:7f00:1',
])('publishes the URL and surface context for %s before handing it to the browser', async (bind) => {
stageDist()
const ctx = new Context()
onTestFinished(() => ctx.fiber.dispose())
// Editor markers and a project .env SSH value do not establish a remote launch.
ctx.provide(DSH_LAUNCH_ENVIRONMENT_KEY, createLaunchEnvironmentSnapshot([
{ source: 'process', values: { VSCODE_IPC_HOOK_CLI: '/tmp/local-vscode-ipc' } },
{ source: 'project-env', path: '/work/.env', values: { SSH_CONNECTION: 'stale-project-value' } },
]))
const { server, seat } = fakeHttpServer('0.0.0.0')
const { server } = fakeHttpServer(bind)
ctx.provide('webServer', server)
provideConnection(ctx)
const contributions: BashContribution[] = []
@@ -138,21 +124,15 @@ describe('web-app runtime glue', () => {
const log = vi.spyOn(console, 'log').mockImplementation((message) => { lifecycle.push(String(message)) })
const openBrowser = vi.fn(async (url: string) => { lifecycle.push(`open:${url}`) })
internals.openBrowser = openBrowser
apply(ctx, new Config({ openBrowser: true, printUrl: true, surfaceContext: true, trustedHosts: ['lab.internal'] }))
apply(ctx, new Config({ openBrowser: true, printUrl: true, surfaceContext: true }))
await ctx.plugin(SystemPrompt, { personaPrefix: '' })
// Settle the injected registrations.
await new Promise(resolve => setTimeout(resolve, 0))
await vi.waitFor(() => { expect(openBrowser).toHaveBeenCalled() })
expect(seat()).toBeDefined() // frontend-static claimed the fallback
expect(ctx.get('webRuntime')).toEqual({
lanAddresses: ['192.168.1.5'],
trustedHosts: ['192.168.1.5', 'lab.internal'],
})
expect(log).toHaveBeenCalledWith('dsh web: http://127.0.0.1:4567/?token=test-token (LAN: http://192.168.1.5:4567/?token=test-token)')
expect(log).toHaveBeenCalledWith('dsh web: http://127.0.0.1:4567/?token=test-token')
expect(log).toHaveBeenCalledWith('dsh web: opening the default browser; pass --no-open to disable')
expect(openBrowser).toHaveBeenCalledWith('http://127.0.0.1:4567/?token=test-token')
expect(lifecycle).toEqual([
'dsh web: http://127.0.0.1:4567/?token=test-token (LAN: http://192.168.1.5:4567/?token=test-token)',
'dsh web: http://127.0.0.1:4567/?token=test-token',
'dsh web: opening the default browser; pass --no-open to disable',
'open:http://127.0.0.1:4567/?token=test-token',
])
@@ -165,7 +145,6 @@ describe('web-app runtime glue', () => {
expect(section?.text).toContain('pnpm run dev:web')
const webRuntime = contributions.find(contribution => contribution.name === 'web-runtime')
expect(webRuntime?.resolve()).toEqual({ DSH_WEB_URL: 'http://127.0.0.1:4567' })
await ctx.fiber.dispose()
})
it('advertises and opens the configured public root behind a prefix-stripping proxy', async () => {
@@ -192,7 +171,6 @@ describe('web-app runtime glue', () => {
printUrl: true,
surfaceContext: true,
publicUrl: 'https://proxy.example:8443/web',
trustedHosts: ['proxy.example:8443'],
}))
await ctx.plugin(SystemPrompt, { personaPrefix: '' })
await vi.waitFor(() => { expect(openBrowser).toHaveBeenCalled() })
@@ -218,7 +196,7 @@ describe('web-app runtime glue', () => {
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
const openBrowser = vi.fn(async () => {})
internals.openBrowser = openBrowser
apply(ctx, new Config({ openBrowser: false, printUrl: false, surfaceContext: true, trustedHosts: [] }))
apply(ctx, new Config({ openBrowser: false, printUrl: false, surfaceContext: true }))
await ctx.plugin(SystemPrompt, { personaPrefix: '' })
await new Promise(resolve => setTimeout(resolve, 0))
expect(log).not.toHaveBeenCalled()
@@ -241,7 +219,7 @@ describe('web-app runtime glue', () => {
return () => {}
},
} as never)
apply(ctx, new Config({ openBrowser: false, printUrl: false, surfaceContext: false, trustedHosts: [] }))
apply(ctx, new Config({ openBrowser: false, printUrl: false, surfaceContext: false }))
await ctx.plugin(SystemPrompt, { personaPrefix: '' })
await new Promise(resolve => setTimeout(resolve, 0))
const assembly = await ctx.systemPrompt.assemble()
@@ -251,18 +229,100 @@ describe('web-app runtime glue', () => {
await ctx.fiber.dispose()
})
it('prints the loopback-only URL line when no LAN snapshot exists', async () => {
it.each([
{ bind: '127.0.0.1', printed: '127.0.0.1' },
{ bind: '::1', printed: '[::1]' },
{ bind: '0:0:0:0:0:ffff:7f08:a', printed: '127.8.0.10' },
{ bind: '::ffff:127.0.0.1%lo', printed: '127.0.0.1' },
{ bind: '::0.0.0.1', printed: '[::1]' },
{ bind: '::0.0.0.1%lo', printed: '[::1]' },
])('prints the $bind loopback address without warning or opening a browser', async ({ bind, printed }) => {
stageDist()
const ctx = new Context()
ctx.provide('webServer', fakeHttpServer().server)
onTestFinished(() => ctx.fiber.dispose())
ctx.provide('webServer', fakeHttpServer(bind).server)
provideConnection(ctx)
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
apply(ctx, new Config({ openBrowser: false, printUrl: true, surfaceContext: true, trustedHosts: [] }))
await new Promise(resolve => setTimeout(resolve, 0))
expect(log).toHaveBeenCalledWith('dsh web: http://127.0.0.1:4567/?token=test-token')
await ctx.fiber.dispose()
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const openBrowser = vi.fn(async () => {})
internals.openBrowser = openBrowser
apply(ctx, new Config({ openBrowser: false, printUrl: true, surfaceContext: true }))
await vi.waitFor(() => { expect(log).toHaveBeenCalledWith(`dsh web: http://${printed}:4567/?token=test-token`) })
expect(warn).not.toHaveBeenCalled()
expect(openBrowser).not.toHaveBeenCalled()
})
it.each([
{ bind: '10.1.2.3', printed: '10.1.2.3' },
// `::127.0.0.1` names the IPv6 address `::7f00:1`, so the tail spelling an
// IPv4 loopback address makes it no loopback bind.
{ bind: '::127.0.0.1', printed: '[::7f00:1]' },
])('warns and advertises the $bind bind address when the listener is not loopback', async ({ bind, printed }) => {
stageDist()
const ctx = new Context()
onTestFinished(() => ctx.fiber.dispose())
ctx.provide('webServer', fakeHttpServer(bind).server)
provideConnection(ctx)
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const openBrowser = vi.fn(async () => {})
internals.openBrowser = openBrowser
apply(ctx, new Config({ openBrowser: true, printUrl: true, surfaceContext: false }))
await vi.waitFor(() => { expect(openBrowser).toHaveBeenCalled() })
// A non-loopback bind has no loopback listener, so the printed and opened
// root names the bind address; startup warns that the port is plain HTTP.
expect(warn).toHaveBeenCalledTimes(1)
expect(warn).toHaveBeenCalledWith(
`dsh web: listening on ${bind} over plain HTTP; restrict this port to a trusted proxy or network`,
)
expect(log).toHaveBeenCalledWith(`dsh web: http://${printed}:4567/?token=test-token`)
expect(openBrowser).toHaveBeenCalledWith(`http://${printed}:4567/?token=test-token`)
})
it('drops an IPv6 zone id from the advertised URL', async () => {
stageDist()
const ctx = new Context()
onTestFinished(() => ctx.fiber.dispose())
// `::1%lo` listens on IPv6 loopback, but a zone id cannot appear in a URL;
// the advertised form must stay a parseable address.
ctx.provide('webServer', fakeHttpServer('::1%lo').server)
provideConnection(ctx)
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
vi.spyOn(console, 'warn').mockImplementation(() => {})
apply(ctx, new Config({ openBrowser: false, printUrl: true, surfaceContext: false }))
await vi.waitFor(() => { expect(log).toHaveBeenCalled() })
expect(log).toHaveBeenCalledWith('dsh web: http://[::1]:4567/?token=test-token')
})
it.each(['fe80::1%lo', '::127.0.0.1%lo'])('refuses its own activation when %s carries an interface zone id', async (bind) => {
stageDist()
// A zone id cannot appear in a URL, and a non-loopback bind that names one
// has no root this runtime can prove reachable: this row must fail rather
// than let a boot continue without an advertised entry point.
const ctx = new Context()
onTestFinished(() => ctx.fiber.dispose())
ctx.provide('webServer', fakeHttpServer(bind).server)
expect(() => { apply(ctx, new Config({ openBrowser: false, printUrl: true, surfaceContext: false })) })
.toThrow(/carries an interface zone id, which no URL can express; pass --public-url/u)
// A declared root makes the same bind usable.
const declared = new Context()
onTestFinished(() => declared.fiber.dispose())
declared.provide('webServer', fakeHttpServer(bind).server)
provideConnection(declared)
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
vi.spyOn(console, 'warn').mockImplementation(() => {})
apply(declared, new Config({
openBrowser: false, printUrl: true, surfaceContext: false, publicUrl: 'https://app.example/ui/',
}))
await vi.waitFor(() => { expect(log).toHaveBeenCalled() })
expect(log).toHaveBeenCalledWith('dsh web: https://app.example/ui/?token=test-token')
})
it('does not publish readiness again when Connection reloads', async () => {
stageDist()
const ctx = new Context()
@@ -270,7 +330,7 @@ describe('web-app runtime glue', () => {
const first = ctx.plugin((connectionCtx: Context) => { provideConnection(connectionCtx) })
await first
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
apply(ctx, new Config({ openBrowser: false, printUrl: true, surfaceContext: true, trustedHosts: [] }))
apply(ctx, new Config({ openBrowser: false, printUrl: true, surfaceContext: true }))
await new Promise(resolve => setTimeout(resolve, 0))
expect(log).toHaveBeenCalledTimes(1)
@@ -288,7 +348,7 @@ describe('web-app runtime glue', () => {
ctx.provide('webServer', fakeHttpServer().server)
provideConnection(ctx)
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
apply(ctx, new Config({ openBrowser: false, printUrl: true, surfaceContext: false, publicUrl: null as never, trustedHosts: [] }))
apply(ctx, new Config({ openBrowser: false, printUrl: true, surfaceContext: false, publicUrl: null as never }))
await vi.waitFor(() => { expect(log).toHaveBeenCalledWith('dsh web: http://127.0.0.1:4567/?token=test-token') })
})
@@ -304,7 +364,7 @@ describe('web-app runtime glue', () => {
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
const openBrowser = vi.fn(async () => {})
internals.openBrowser = openBrowser
apply(ctx, new Config({ openBrowser: true, printUrl: true, surfaceContext: false, trustedHosts: [] }))
apply(ctx, new Config({ openBrowser: true, printUrl: true, surfaceContext: false }))
await new Promise(resolve => setTimeout(resolve, 0))
expect(log).toHaveBeenCalledWith('dsh web: http://127.0.0.1:4567/?token=test-token')
expect(openBrowser).not.toHaveBeenCalled()
@@ -324,7 +384,7 @@ describe('web-app runtime glue', () => {
const settlement = new Promise<void>((resolve) => { release = resolve })
provideLoader(settled, () => settlement)
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
apply(settled, new Config({ openBrowser: true, printUrl: true, surfaceContext: true, trustedHosts: [] }))
apply(settled, new Config({ openBrowser: true, printUrl: true, surfaceContext: true }))
await new Promise(resolve => setTimeout(resolve, 0))
expect(log).not.toHaveBeenCalled()
expect(openBrowser).not.toHaveBeenCalled()
@@ -342,7 +402,7 @@ describe('web-app runtime glue', () => {
failed.provide('webServer', fakeHttpServer().server)
provideConnection(failed)
provideLoader(failed, async () => { throw new Error('boot failed') })
apply(failed, new Config({ openBrowser: true, printUrl: true, surfaceContext: true, trustedHosts: [] }))
apply(failed, new Config({ openBrowser: true, printUrl: true, surfaceContext: true }))
await new Promise(resolve => setTimeout(resolve, 0))
expect(log).not.toHaveBeenCalled()
expect(openBrowser).not.toHaveBeenCalled()
@@ -361,7 +421,7 @@ describe('web-app runtime glue', () => {
let releaseTorn: () => void
const tornSettlement = new Promise<void>((resolve) => { releaseTorn = resolve })
provideLoader(torn, () => tornSettlement)
apply(torn, new Config({ openBrowser: true, printUrl: true, surfaceContext: true, trustedHosts: [] }))
apply(torn, new Config({ openBrowser: true, printUrl: true, surfaceContext: true }))
await new Promise(resolve => setTimeout(resolve, 0))
await child.dispose() // the webServer service goes away
releaseTorn!()
@@ -392,7 +452,7 @@ describe('web-app runtime glue', () => {
const openBrowser = vi.fn(async () => {})
internals.openBrowser = openBrowser
const audit = vi.spyOn(AppBoot, 'auditStartupEntries')
apply(ctx, new Config({ openBrowser: true, printUrl: true, surfaceContext: false, trustedHosts: [] }))
apply(ctx, new Config({ openBrowser: true, printUrl: true, surfaceContext: false }))
await vi.waitFor(() => { expect(audit).toHaveBeenCalledOnce() })
await Promise.allSettled(audit.mock.results.map(result => result.value as Promise<void>))
if (announces) {
@@ -413,7 +473,7 @@ describe('web-app runtime glue', () => {
Object.defineProperty(server, 'port', { get: () => undefined })
ctx.provide('webServer', server)
provideConnection(ctx)
apply(ctx, new Config({ openBrowser: false, printUrl: false, surfaceContext: true, trustedHosts: [] }))
apply(ctx, new Config({ openBrowser: false, printUrl: false, surfaceContext: true }))
await ctx.plugin(SystemPrompt, { personaPrefix: '' })
await new Promise(resolve => setTimeout(resolve, 0))
await expect(ctx.systemPrompt.assemble()).rejects.toThrow('webServer service missing')
@@ -439,7 +499,7 @@ describe('web-app runtime glue', () => {
internals.openBrowser = vi.fn(async () => { throw failure })
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
const diagnostic = vi.spyOn(console, 'error').mockImplementation(() => {})
apply(ctx, new Config({ openBrowser: true, printUrl: false, surfaceContext: false, trustedHosts: [] }))
apply(ctx, new Config({ openBrowser: true, printUrl: false, surfaceContext: false }))
await new Promise(resolve => setTimeout(resolve, 0))
expect(log).toHaveBeenCalledWith('dsh web: opening the default browser; pass --no-open to disable')
expect(diagnostic).toHaveBeenCalledWith(
+4 -4
View File
@@ -18,8 +18,8 @@
en: 13b54969ff1d00a4
zh: 4d2ffff0445472ed
/deepseek-ai-dsh-client-connection/browser-authentication-and-request-trust:
en: 29aba8e9c02bd273
zh: 24cb7cc5769f0304
en: 1c5011f44aff1b43
zh: 9916c4ff40d014ce
/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: 2c964a7546604347
zh: c30b59fc92a01c36
en: 7fd70be262a6ddc4
zh: f698394f6a299f14
/deepseek-ai-dsh-client-connection/known-limitations-and-deferred-work/dev-note:
en: e67f633dc6fe7773
zh: e88aeb617ffb74ea
+3 -3
View File
@@ -38,9 +38,9 @@ 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 deliberately omit `Secure` because the shipped server uses loopback 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`; they omit `Secure` because the shipped server uses plain HTTP.
Before authentication, every request still passes `src/api-request-trust.ts`. Its `Host` must be loopback 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. 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` remains 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).
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).
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 +72,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`** — loopback HTTP is the shipped transport, so exposing the same authority over plaintext networking can expose the bearer cookie in transit.
- **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.
- **There is no logout operation** — clearing the browser cookie ends one browser session; deleting the owner credential record and restarting `dsh` revokes every session.
+3 -3
View File
@@ -38,9 +38,9 @@ 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`;随附服务器使用 loopback 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`;随附服务器使用明文 HTTP,因此不设置 `Secure`。
认证之前,每个请求仍经过 `src/api-request-trust.ts`。其 `Host` 必须是 loopback,或与 `trustedHosts` 条目匹配:带端口的 `host:port` 精确匹配,不带端口的条目匹配任意端口,两侧均经 WHATWG 归一化。若附带 `Origin`,它必须等于该 Host;`sec-fetch-site: cross-site` 一律拒绝。畸形配置 authority 会让插件加载失败。`allowsRemoteAuthorities` 报告 `trustedHosts` 是否允许非 loopback authority。这些检查防御 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)。
认证之前,每个请求都经过 `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)。
每个被接纳的请求都代表同一个 Peer——操作者。`ctx.connection.operator` 就是这个 `PeerScope`:其 `ctx` 是拥有连接期注册的 Cordis scope,随 Connection 一起释放。`ctx.connection.admit(request)` 执行信任与认证检查,以拒绝状态或操作者作答;`/api` 路由与 Gateway 的 WebSocket 升级都经它接纳,每个 RPC 处理器都收到本次调用的 Peer。`OperatorPeer` 对外导出,供没有 Connection 的组合(例如 Gateway 的进程内载体)以同一约定拥有一个操作者 scope。
@@ -72,7 +72,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`**:当前随产品提供的传输方式是 loopback HTTP;若部署经明文网络暴露同一 authority,bearer cookie 可能在传输中泄露。
- **浏览器 cookie 不带 `Secure`**:随产品提供的传输方式是明文 HTTP;若把同一 authority 暴露到网络上,bearer cookie 可能在传输中泄露。
- **没有 logout 操作**:清除浏览器 cookie 会结束单个浏览器会话;删除 owner 凭据记录并重启 `dsh` 会撤销全部会话。
@@ -4,13 +4,12 @@
* the attacker's domain while the socket reaches this server) and cross-site
* requests fired from a malicious page. The Host fence binds every request,
* browser-looking or not: over plain HTTP a browser attaches neither Origin
* nor Fetch-Metadata to reads (images and navigations — those
* headers go only to trustworthy destinations), so an unmarked request may
* still be a rebound browser read and Host is the one header rebinding cannot
* forge. Non-browser and remote clients pass the same fence via loopback,
* deployment-derived LAN IP literals, or a declared `trustedHosts` authority.
* Network reachability and authentication stay out of scope: binding policy
* belongs to the webserver config, and this fence is not an auth layer.
* nor Fetch-Metadata to reads (images and navigations — those headers go only
* to trustworthy destinations), so an unmarked request may still be a rebound
* browser read and Host is the one header rebinding cannot forge. Non-browser
* and remote clients pass the same fence via loopback, the listener's bind IP
* literal, or a declared `trustedHosts` authority. Binding policy belongs to
* the webserver config; this fence never establishes identity.
*/
import { isLoopbackHostname } from './loopback-hostname.ts'
@@ -68,9 +67,8 @@ 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 (the shape the CLI derives for IP-literal LAN serving,
* where the bound port may be OS-assigned). Both sides compare through WHATWG
* normalization, so case and a redundant `:80` never decide trust.
* hostname on any port. Both sides compare through WHATWG normalization,
* so case and a redundant `:80` never decide trust.
*/
function isTrustedAuthority(hostUrl: URL, trustedHosts: readonly string[]): boolean {
return trustedHosts.some((entry) => {
@@ -92,13 +90,34 @@ export function isRemoteAuthority(entry: string): boolean {
return url === undefined || !isLoopbackHostname(url.hostname)
}
/**
* Whether a parsed authority names the deployment's own bind address. A browser
* dialing that literal sends it as Host, and accepting it grants nothing
* further: the literal is no rebinding target, the Origin and cross-site checks
* still apply, and it stays outside `trustedHosts`. Matching ignores the port,
* like a port-less entry, and any `%zone` id (an interface selector, not
* address text).
*/
function isBindAddressAuthority(hostUrl: URL, bindHost: string | undefined): boolean {
if (bindHost === undefined) return false
const zoneAt = bindHost.indexOf('%')
const address = zoneAt === -1 ? bindHost : bindHost.slice(0, zoneAt)
const bindUrl = parseAuthority(address.includes(':') ? `[${address}]` : address)
return bindUrl !== undefined && bindUrl.hostname === hostUrl.hostname
}
/**
* Decide whether one /api request may reach the RPC bridge.
* @param request - Node HTTP or Fetch request facts (headers).
* @param trustedHosts - non-loopback authorities this deployment serves: exact `host:port`, or port-less `host` matching any port.
* @returns true when the Host is ours (loopback or trusted) and any attached browser markers are same-origin.
* @param bindHost - the listener's own bind IP literal, accepted on any port independently of `trustedHosts`.
* @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[]): boolean {
export function isTrustedApiRequest(
request: ConnectionTrustRequest,
trustedHosts: readonly string[],
bindHost?: string,
): 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
// carries the attacker's domain here even though the socket lands on this
@@ -110,7 +129,9 @@ export function isTrustedApiRequest(request: ConnectionTrustRequest, trustedHost
if (host === undefined) return false
const hostUrl = parseAuthority(host)
if (hostUrl === undefined) return false
if (!isLoopbackHostname(hostUrl.hostname) && !isTrustedAuthority(hostUrl, trustedHosts)) return false
if (!isLoopbackHostname(hostUrl.hostname)
&& !isTrustedAuthority(hostUrl, trustedHosts)
&& !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.
if (header(request.headers, 'sec-fetch-site') === 'cross-site') return false
+3 -5
View File
@@ -94,11 +94,9 @@ export interface ConnectionConfig {
recovery?: ConnectionRecoveryConfig
/**
* Authorities this deployment serves beyond loopback: exact `host:port`, or
* port-less `host` matching any port. The /api trust fence refuses any
* request whose Host is neither loopback nor listed here, so a
* non-loopback (`0.0.0.0`) deployment must declare the names it is reached
* by; the Web runtime derives LAN IP literals from an active all-interface
* bind. An entry that is not a bare, canonical authority fails plugin load.
* port-less `host` matching any port. The fence accepts loopback and the
* listener's own bind IP independently; other remote authorities require
* an entry here. A non-canonical or non-bare authority fails plugin load.
*/
trustedHosts?: string[]
/** Absolute browser-session lifetime in days. Default: 30. */
+7 -2
View File
@@ -103,9 +103,14 @@ export class HostConnectionService extends Service implements HostConnectionHand
}
}
/** Apply the configured Host/Origin fence, then browser authentication. */
/** 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)) return 403
if (!isTrustedApiRequest(request, this.trustedHosts, this.bindHost)) return 403
return this.browserAuth.isAuthenticated(request) ? undefined : 401
}
@@ -25,6 +25,26 @@ describe('isTrustedApiRequest', () => {
}
})
it('accepts the listener\'s own bind address port-lessly, and nothing that merely resembles it', () => {
// A browser dialing the bind address sends that IP literal as Host; the
// port is not part of the grant.
for (const host of ['10.1.2.3', '10.1.2.3:3080', '10.1.2.3:9999']) {
expect(isTrustedApiRequest(request({ host }), [], '10.1.2.3')).toBe(true)
}
expect(isTrustedApiRequest(request({ host: '10.1.2.4:3080' }), [], '10.1.2.3')).toBe(false)
expect(isTrustedApiRequest(request({ host: 'dsh-direct.lan:3080' }), [], '10.1.2.3')).toBe(false)
expect(isTrustedApiRequest(request({ host: '10.1.2.3:3080' }), [])).toBe(false)
// IPv6 spellings compare through the same normalization as Host, mapped
// forms included; a zone id selects the local interface, not the address.
for (const host of ['[fd00::1]', '[fd00::1]:3080', '[::ffff:a01:203]:3080', '[::ffff:10.1.2.3]:3080']) {
const bindHost = host.startsWith('[::ffff') ? '::ffff:10.1.2.3' : 'fd00::1'
expect(isTrustedApiRequest(request({ host }), [], bindHost)).toBe(true)
}
expect(isTrustedApiRequest(request({ host: '[fd00::2]:3080' }), [], 'fd00::1')).toBe(false)
expect(isTrustedApiRequest(request({ host: '[fd00::1]:3080' }), [], 'fe80::1%lo')).toBe(false)
expect(isTrustedApiRequest(request({ host: '[fe80::1]:3080' }), [], 'fe80::1%lo')).toBe(true)
})
it('refuses a rebound Host: the attacker domain names the socket it did not expect', () => {
expect(isTrustedApiRequest(request({
host: 'evil.example:3080',
@@ -25,8 +25,10 @@ import { provideBrowserCredentials } from './browser-credentials.ts'
function fakeHttpServer(
routes: WebRoute[],
upgrades: WebUpgradeRoute[],
): Pick<WebServer, 'register' | 'registerUpgrade' | 'tapIndex' | 'port'> {
host = '127.0.0.1',
): Pick<WebServer, 'register' | 'registerUpgrade' | 'tapIndex' | 'port' | 'host'> {
return {
host,
register(route) {
if (routes.some(candidate => candidate.kind === route.kind && candidate.path === route.path)) {
throw new Error(`duplicate route ${route.path}`)
@@ -194,6 +196,31 @@ describe('connection node half', () => {
await fiber.dispose()
})
it('accepts the bind address of a web carrier attached after Connection', async () => {
const ctx = new Context()
const routes: WebRoute[] = []
const upgrades: WebUpgradeRoute[] = []
provideBrowserCredentials(ctx)
const fiber = ctx.plugin({ inject: [...inject], apply }, { trustedHosts: ['harness.example'] })
try {
await fiber.await()
const connection = ctx.get('connection') as HostConnectionHandle
const web = ctx.plugin((webCtx) => {
webCtx.provide('webServer', fakeHttpServer(routes, upgrades, '10.1.2.3') as WebServer)
})
try {
await web.await()
await expect.poll(() => routes.find(route => route.path === API_PATH)).toBeDefined()
const bound = browserCookie(connection, '10.1.2.3:3080')
expect(connection.requestRejection(fakeRequest({ host: '10.1.2.3:3080', cookie: bound }))).toBeUndefined()
} finally {
await web.dispose()
}
} finally {
await fiber.dispose()
}
})
it('injects validated browser recovery timing and withdraws it on disposal', async () => {
const { ctx, dispose } = await mounted({ recovery: { generationReadyTimeoutMs: 25_000 } })
try {
@@ -327,8 +354,6 @@ describe('connection node half', () => {
cookie: browserCookie(connection, '127.0.0.1:3080'),
}), loopback.response)
expect(loopback.state.status).toBe(404)
// An all-interfaces composition derives port-less LAN IP literals, which
// pass markerless curl on any port.
const lan = fakeResponse()
await routes[0]!.handler(fakeRequest({
host: '192.168.1.5:3080',
@@ -84,8 +84,7 @@ export function cpus(): CpuInfo[] {
/**
* Network interfaces.
* @returns an empty record — the worker webserver binds the loopback literal, so
* no LAN address is ever derived.
* @returns an empty record — a worker has no view of the host's network.
*/
export function networkInterfaces(): NodeJS.Dict<NetworkInterfaceInfo[]> {
return {}
@@ -98,8 +98,7 @@ export interface WorkerHostOptions {
readonly configPath?: string
/**
* Inner arguments the tree parses. The default binds the web server to the
* loopback authority the tunnel synthesizes, which also keeps
* `networkInterfaces()` out of the trust snapshot.
* loopback authority the tunnel synthesizes.
*/
readonly cmdlineArgs?: readonly string[]
/** Port named on the default command line; defaults to {@link DEFAULT_PORT}. */
@@ -168,8 +168,6 @@ describe('node:os', () => {
it('reports no per-core facts and no network interfaces', () => {
expect(os.cpus()).toEqual([])
// The worker webserver binds the loopback literal, so a LAN address is never
// derived — and an empty record keeps it out of the trust snapshot.
expect(os.networkInterfaces()).toEqual({})
expect(os.availableParallelism()).toBeGreaterThanOrEqual(1)
})
@@ -36,8 +36,8 @@
en: abc8d3962e4580c3
zh: 4442dde69faf680f
/deepseek-ai-dsh-host-directory-picker-auto/understand-the-implementation/the-resolution-table:
en: c08256b335ad92cf
zh: 1cbb1357bc9d649b
en: 0edb097d5df327e8
zh: e9b59fc2070dfc42
/deepseek-ai-dsh-host-directory-picker-auto/understand-the-implementation/source-map:
en: 43a0268a54380a72
zh: a8f8cd0c3f502e6b
@@ -51,8 +51,8 @@
en: ca75c51c89c2c9b0
zh: de54f3467ef3b9be
/deepseek-ai-dsh-host-directory-picker-auto/known-limitations-and-deferred-work:
en: 1ae2375b3be24d5f
zh: 6bc8d84a5612bd75
en: 77982e87798474a3
zh: b60a3ec8b4ce49e8
/deepseek-ai-dsh-host-directory-picker-auto/known-limitations-and-deferred-work/dev-note:
en: e67f633dc6fe7773
zh: 3bb0c20c93f16f98
@@ -60,7 +60,7 @@ The chooser is a pure decision plus a mount: `resolveDirectoryPickerBackend` sam
| Condition | Backend |
|---|---|
| A `trustedHosts` entry admits a non-loopback authority | `browse` |
| Bind host is not `127.0.0.1` | `browse` |
| Bind host is not loopback (`isLoopbackHost`) | `browse` |
| `SSH_CONNECTION` or `SSH_TTY` present | `browse` |
| darwin or win32 | `native` |
| linux with a chooser binary and a display | `native` |
@@ -109,7 +109,7 @@ These limits define when the boot-time sample can misjudge the host. They are cu
- **Detection infers operator location from launch context, which no launch-side signal can prove** — a tmux session detached from its SSH launch loses the `SSH_*` markers; a Darwin process outside an Aqua session still counts as displayed; and a workstation-local launch later reached through `ssh -L` arrives from `127.0.0.1`, resolves `native`, and opens the chooser on the unattended workstation. A wrong `native` choice degrades to the backend's existing retryable failure dialog, and composing `-browse` directly selects the safe interaction for such deployments.
- **The Linux chooser probe reads `PATH` only** — a zenity/kdialog reachable some other way (shell alias, non-PATH install) still resolves `browse`; installing either binary on `PATH` restores `native` eligibility at the next boot.
- **Boot-time only** — one resolution serves every client of the boot; per-connection adaptivity (native for a local browser, browse for a remote one, same server) would need a per-client capability and the wire advertisement the seam does not carry, and waits for a deployment that serves both at once.
- **A loopback-presenting proxy hides a remote browser** — a reverse proxy that rewrites both the request `Host` and `Origin` to a loopback authority (say `127.0.0.1:3080`), with no non-loopback `trustedHosts` entry, passes the browser-trust fence, so a remote browser still resolves `native` and opens the chooser on the unattended host display.
- **A loopback-presenting proxy hides a remote browser** — a reverse proxy that rewrites both the request `Host` and `Origin` to a loopback authority (say `127.0.0.1:3080`), with no non-loopback `trustedHosts` entry, passes the browser-trust fence: a browser's `/api` POST carries `Origin: https://app.example`, which the Origin fence rejects unless the proxy rewrites that header too. Only then does a remote browser still resolve `native` and open the chooser on the host display.
<a id="dev-note"></a>
### Dev Note
@@ -60,7 +60,7 @@ kind: "package-reference"
| 条件 | 后端 |
|---|---|
| 某条 `trustedHosts` 条目信任非回环 authority | `browse` |
| 绑定宿主不是 `127.0.0.1` | `browse` |
| 绑定地址不是 loopback(`isLoopbackHost`) | `browse` |
| 存在 `SSH_CONNECTION` 或 `SSH_TTY` | `browse` |
| darwin 或 win32 | `native` |
| linux 且带选择器二进制与显示 | `native` |
@@ -109,7 +109,7 @@ kind: "package-reference"
- **探测是从启动上下文推断操作者位置,而任何启动侧信号都无法证明这一点**——从 SSH 启动中脱离的 tmux 会话会丢失 `SSH_*` 标记;Aqua 会话之外的 Darwin 进程仍被算作有显示;在工作站本地启动、之后经 `ssh -L` 访问时,请求会从 `127.0.0.1` 到达,系统会判定 `native`,并把选择器弹在无人值守的工作站上。错误的 `native` 选择会退化为后端既有的可重试失败对话框,而对这类部署,直接组合 `-browse` 即选择安全的交互。
- **Linux 选择器探查只读 `PATH`**——以其他途径可用的 zenity/kdialog(shell 别名、未装在 PATH 上)仍判定为 `browse`;把任一二进制装到 `PATH` 上,下次启动即恢复 `native` 资格。
- **仅在启动时判定**——一次判定服务本次启动的所有客户端;按连接自适应(同一台服务器,本地浏览器用 native、远程浏览器用 browse)需要按客户端的能力对象以及 seam 未携带的协议通告,等到出现同时服务两种形态的部署再做。
- **以 loopback authority 呈现的代理会藏起远程浏览器**——反向代理把请求 `Host` 和 `Origin` 一并改写为 loopback authority(如 `127.0.0.1:3080`),且没有任何非 loopback 的 `trustedHosts` 条目时,便能通过浏览器信任栅栏,于是远程浏览器仍判定为 `native`,选择器在无人值守的宿主屏幕上打开。
- **以 loopback authority 呈现的代理会藏起远程浏览器**——反向代理把请求 `Host` 和 `Origin` 一并改写为 loopback authority(如 `127.0.0.1:3080`),且没有任何非 loopback 的 `trustedHosts` 条目时,请求才会通过浏览器信任栅栏:浏览器对 `/api` 的 POST 会附带 `Origin: https://app.example`,代理若不改写该头就会被 Origin 栅栏拒绝。也只有在两者都被改写时,远程浏览器才会仍判定为 `native`,选择器在宿主屏幕上打开。
<a id="dev-note"></a>
### 开发备注
@@ -6,7 +6,7 @@
* @module @deepseek-ai/dsh-host-directory-picker-auto/resolve
*/
import type { Config as HttpServerConfig } from '@deepseek-ai/dsh-host-webserver'
import { isLoopbackHost, type Config as HttpServerConfig } from '@deepseek-ai/dsh-host-webserver'
/** Concrete interaction backend the resolver chooses between. */
export type DirectoryPickerBackendKind = 'native' | 'browse'
@@ -18,7 +18,7 @@ export type DirectoryPickerEnv = Readonly<
/** Host facts the backend choice is a pure function of, sampled once at boot. */
export interface DirectoryPickerHostFacts {
/** Effective webserver bind host (the schema's closed loopback/all-interfaces union). */
/** Effective webserver bind address (a concrete IP literal of one local interface). */
bindHost: HttpServerConfig['host']
/**
* Whether the Connection trust policy admits a non-loopback authority (a
@@ -41,19 +41,19 @@ const present = (value: string | undefined): boolean => value !== undefined && v
/**
* Resolve which backend serves this boot. `native` requires every signal that
* the operator can see the host display and the native backend can serve it:
* no remote-authority trust, a loopback-only bind (remote-authority trust or
* a non-loopback bind admits remote browsers no OS chooser can reach), no SSH
* launch (under SSH port-forwarding the chooser would open on the unattended
* server), and a servable display session — assumed on darwin/win32,
* requiring `DISPLAY`/`WAYLAND_DISPLAY` plus a chooser binary on linux, and
* never true elsewhere (the native backend drives exactly darwin/win32/linux).
* Anything ambiguous resolves to
* no remote-authority trust, a loopback bind (remote-authority trust or a
* non-loopback bind admits remote browsers no OS chooser can reach), no SSH
* launch (a forwarded connection cannot display the server's chooser), and a
* servable display session — assumed on darwin/win32, requiring
* `DISPLAY`/`WAYLAND_DISPLAY` plus a chooser binary on linux, and never true
* elsewhere (the native backend drives exactly darwin/win32/linux). Anything
* ambiguous resolves to
* `browse`, which works everywhere.
* @param facts - the sampled host facts.
* @returns the backend kind to mount.
*/
export function resolveDirectoryPickerBackend(facts: DirectoryPickerHostFacts): DirectoryPickerBackendKind {
if (facts.allowsRemoteAuthorities || facts.bindHost !== '127.0.0.1') return 'browse'
if (facts.allowsRemoteAuthorities || !isLoopbackHost(facts.bindHost)) return 'browse'
if (facts.ssh) return 'browse'
if (facts.platform === 'darwin' || facts.platform === 'win32') return 'native'
if (facts.platform !== 'linux' || !facts.linuxChooser) return 'browse'
@@ -24,6 +24,7 @@ import NativeDirectoryPicker from '@deepseek-ai/dsh-host-directory-picker-native
import {
createLaunchEnvironmentSnapshot, DSH_LAUNCH_ENVIRONMENT_KEY, type LaunchEnvironmentSnapshot,
} from '@deepseek-ai/dsh-launch-environment'
import { probeNonLoopbackIpv4 } from '../../../../scripts/test-non-loopback-address.ts'
import * as DirectoryPickerAuto from '../src/index.ts'
const renameControl = vi.hoisted(() => ({
@@ -55,6 +56,15 @@ const BROWSE = '@deepseek-ai/dsh-host-directory-picker-browse'
const NATIVE_SURFACE = '@deepseek-ai/dsh-client-ui-directory-picker-native'
const BROWSE_SURFACE = '@deepseek-ai/dsh-client-ui-directory-picker-browse'
/**
* This machine's first non-internal IPv4 address that really accepts a listen.
* The webserver really listens, so only a non-loopback bind case needs one; a
* host with no bindable candidate skips that case, because requesting an
* address the host cannot bind fails the bind rather than exercising the
* chooser.
*/
const NON_LOOPBACK_IPV4 = await probeNonLoopbackIpv4()
/**
* Loader-visible stand-in for a client surface package: the surfaces belong to
* the Client program and publish browser entry points only, so a Host-face spec
@@ -96,7 +106,7 @@ afterEach(async () => {
* provides the Connection trust policy, then boot it through the real Loader.
*/
async function loadComposition(
bindHost: '127.0.0.1' | '0.0.0.0',
bindHost: string,
options: { failSurface?: boolean; launchEnvironment?: LaunchEnvironmentSnapshot; remoteAuthorities?: boolean } = {},
): Promise<{ ctx: Context; configPath: string }> {
root = await mkdtemp(join(tmpdir(), 'dsh-directory-picker-auto-'))
@@ -255,9 +265,9 @@ describe('real Loader composition', () => {
expect(picker.capability().kind).toBe('browse')
})
it('mounts the browse backend for an all-interfaces bind even on an attended host', { timeout: 60_000 }, async () => {
it.skipIf(NON_LOOPBACK_IPV4 === undefined)('mounts the browse backend for a non-loopback bind even on an attended host', { timeout: 60_000 }, async () => {
stubAttendedHost()
const { ctx } = await loadComposition('0.0.0.0')
const { ctx } = await loadComposition(NON_LOOPBACK_IPV4!)
expect(entryNames(ctx)).toContain(BROWSE)
expect(entryNames(ctx)).toContain(BROWSE_SURFACE)
@@ -20,12 +20,15 @@ describe('resolveDirectoryPickerBackend', () => {
it('resolves native for a loopback bind on a display platform', () => {
expect(resolveDirectoryPickerBackend(attended)).toBe('native')
expect(resolveDirectoryPickerBackend({ ...attended, platform: 'win32' })).toBe('native')
expect(resolveDirectoryPickerBackend({ ...attended, bindHost: '::1' })).toBe('native')
})
it('resolves browse for an all-interfaces bind regardless of other signals', () => {
expect(resolveDirectoryPickerBackend({ ...attended, bindHost: '0.0.0.0' })).toBe('browse')
it('resolves browse for a non-loopback bind regardless of other signals', () => {
expect(resolveDirectoryPickerBackend({ ...attended, bindHost: '10.1.2.3' })).toBe('browse')
expect(resolveDirectoryPickerBackend({ ...attended, bindHost: 'fd00::1' })).toBe('browse')
})
it('resolves browse when the trust policy admits a remote authority, even on an attended loopback host', () => {
expect(resolveDirectoryPickerBackend({ ...attended, allowsRemoteAuthorities: true })).toBe('browse')
})
+3 -3
View File
@@ -18,8 +18,8 @@
en: 0246258969a54c27
zh: ba67f4df89d049de
/deepseek-ai-dsh-host-webserver/use-this-package/minimal-configuration:
en: 7016f10d53d3238e
zh: c7968dc08a6b8a41
en: 9f91b3c220b37c29
zh: c5067440d173f9ac
/deepseek-ai-dsh-host-webserver/use-this-package/registering-routes:
en: 296ffba200f0dc93
zh: 5d78d6b5f48634c9
@@ -34,7 +34,7 @@
zh: 4ed0861911abaf5d
/deepseek-ai-dsh-host-webserver/understand-the-implementation/design-concept:
en: 3eb8591cc484305d
zh: 53e427897fda8b7e
zh: b778e2ca57752ee1
/deepseek-ai-dsh-host-webserver/understand-the-implementation/matching-and-lifecycle:
en: 70dc6875e07497dd
zh: a1d041236e361f84
+1 -1
View File
@@ -36,7 +36,7 @@ Compose the webserver as the HTTP transport of a browser-facing host, then let t
port: 3000
```
`host` accepts exactly two values: `127.0.0.1` (default posture, loopback only) and `0.0.0.0` (deliberate network exposure — the server carries no TLS, authentication, or origin policy of its own). `port` 0 requests an OS-assigned port; `ctx.webServer.port` reads the listening port afterwards.
`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.
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.
+2 -2
View File
@@ -36,7 +36,7 @@ kind: "package-reference"
port: 3000
```
`host` 只接受两个值:`127.0.0.1`(默认姿态,仅回环)与 `0.0.0.0`(有意向网络开放——服务器自身不携带 TLS、认证或来源策略)。`port` 为 0 时请求 OS 分配端口;之后用 `ctx.webServer.port` 读取正在监听的端口。
`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 字节阈值;其他组合默认不压缩。
@@ -64,7 +64,7 @@ index 启动输入分两层。`collectIndexInjections()` 收集一张全新的
### 设计理念
本包是一个不带任何 harness 词汇的普通路由注册表:`WebServer` 继承 Cordis `Service`,持有三张路由表、回退 slot、原始 index 转换列表,以及 index 渲染器经其收集行的 `webserver/index-inject` 事件。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` getter 暴露其他插件据以自适应的组合期事实(例如 directory-picker 选择器)。
### 匹配与生命周期
+1
View File
@@ -32,6 +32,7 @@
"dependencies": {
"@deepseek-ai/schemastery": "workspace:~",
"compression": "^1.8.1",
"ipaddr.js": "^2.5.0",
"negotiator": "^1.1.0"
},
"devDependencies": {
+101 -5
View File
@@ -8,11 +8,12 @@
import { createServer } from 'node:http'
import type { IncomingMessage, ServerResponse, Server } from 'node:http'
import type { AddressInfo } from 'node:net'
import { isIP, type AddressInfo } from 'node:net'
import type { Duplex } from 'node:stream'
import { Context, Service } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
import compressionMiddleware from 'compression'
import ipaddr from 'ipaddr.js'
import Negotiator from 'negotiator'
import { renderIndexInjections, type IndexInjection } from './injections.ts'
@@ -57,8 +58,17 @@ export interface WebUpgradeRoute {
/** Web server listen and response-compression config. */
export interface Config {
/** Listen host; the two supported values are loopback and all-interfaces. */
host: '127.0.0.1' | '0.0.0.0'
/**
* 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.
*/
host: string
/** Listen port; zero requests an OS-assigned port. */
port: number
/** Response compression for socket-backed HTTP requests. @default 'none' */
@@ -85,6 +95,85 @@ type NodeMiddleware = (
next: () => void,
) => void
/** Dotted-quad tail of the IPv6 mixed notation: `::0.0.0.1`, `1:2:3:4:5:6:127.0.0.1`. */
const IPV4_TAIL = /^(.*:)(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/
/**
* Rewrite a dotted-quad tail as the two hex groups holding the same bytes.
* ipaddr.js 2.5.0 reads an all-zero prefix plus a quad (`::0.0.0.1`) as the
* IPv4-mapped form `::ffff:0:1`, while `getaddrinfo` — and so `listen` — reads
* that tail as the low 32 bits of the address itself, which makes `::0.0.0.1`
* the loopback literal `::1`. Parsing the rewritten text keeps the parsed value
* equal to the value the socket binds.
*/
function withHexTail(host: string): string {
const tail = IPV4_TAIL.exec(host)
if (tail === null) return host
const [, head = '', a = '', b = '', c = '', d = ''] = tail
const high = ((Number(a) << 8) | Number(b)).toString(16)
const low = ((Number(c) << 8) | Number(d)).toString(16)
return `${head}${high}:${low}`
}
/**
* Parse a concrete IP literal, normalizing IPv4-mapped IPv6 to IPv4.
* `node:net` decides what a value must look like for `listen` to accept it, and
* a trailing `%zone` (a local interface selector, never address text) comes off
* before parsing; the value handed to `listen` keeps its zone.
*/
function parseIpLiteral(host: string): ipaddr.IPv4 | ipaddr.IPv6 | undefined {
if (isIP(host) === 0) return undefined
const zoneAt = host.indexOf('%')
const parsed = ipaddr.parse(withHexTail(zoneAt === -1 ? host : host.slice(0, zoneAt)))
return parsed instanceof ipaddr.IPv6 && parsed.isIPv4MappedAddress() ? parsed.toIPv4Address() : parsed
}
/** Rejection for a bind address that is no concrete IP literal. */
function notLiteralError(value: string): Error {
return new Error(`webserver: host ${JSON.stringify(value)} is not a concrete IPv4 or IPv6 address literal`)
}
/** ipaddr's IPv4 unspecified range covers all of 0/8; only the all-zero address binds every interface. */
function isWildcardAddress(parsed: ipaddr.IPv4 | ipaddr.IPv6): boolean {
const parts = parsed instanceof ipaddr.IPv4 ? parsed.octets : parsed.parts
return parts.every(part => part === 0)
}
/**
* Whether a bind address requests every interface at once.
* @param host - bind address from configuration.
* @returns true for IPv4 any, IPv6 any, and an IPv4-mapped form of IPv4 any.
*/
export function isWildcardHost(host: string): boolean {
const parsed = parseIpLiteral(host)
return parsed !== undefined && isWildcardAddress(parsed)
}
/**
* Whether a bind address names the local loopback authority.
* @param host - bind address from configuration.
* @returns true for any address in 127/8, `::1`, and their IPv4-mapped forms.
*/
export function isLoopbackHost(host: string): boolean {
const parsed = parseIpLiteral(host)
return parsed !== undefined && parsed.range() === 'loopback'
}
/**
* Canonical text of the address a bind literal names: IPv4 and IPv4-mapped
* literals read as dotted quad, every other literal as its compressed IPv6
* value. The text names the address `listen` binds, so `::0.0.0.1` reads as
* `::1`. A `%zone` selects an interface and never appears in the text.
* @param host - concrete IPv4 or IPv6 literal, with or without a zone.
* @returns the address text, ready to be a URL host.
* @throws when `host` is no concrete IPv4 or IPv6 address literal.
*/
export function normalizeBindAddress(host: string): string {
const parsed = parseIpLiteral(host)
if (parsed === undefined) throw notLiteralError(host)
return parsed.toString()
}
function createGzipMiddleware(config: ResolvedConfig): NodeMiddleware {
// `compression` is typed for Express, but its runtime uses only the
// node:http request and response members supplied here.
@@ -124,7 +213,14 @@ function createGzipMiddleware(config: ResolvedConfig): NodeMiddleware {
*/
export class WebServer extends Service {
static Config: z<Config> = z.object({
host: z.union([z.const('127.0.0.1'), z.const('0.0.0.0')]).required(),
host: z.transform(z.string(), (value) => {
const parsed = parseIpLiteral(value)
if (parsed === undefined) throw notLiteralError(value)
if (isWildcardAddress(parsed)) {
throw new Error(`webserver: host ${JSON.stringify(value)} is an unspecified (wildcard) address, which is not supported: binding every interface would expose remote code execution to the network; bind one concrete IPv4 or IPv6 address of a local interface instead`)
}
return value
}).required(),
port: z.natural().max(65535).required(),
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),
@@ -152,7 +248,7 @@ export class WebServer extends Service {
return this.listenedPort
}
/** The configured bind host (the loopback or all-interfaces literal). */
/** The configured bind address (one concrete local interface). */
get host(): Config['host'] {
return this.config.host
}
@@ -2,7 +2,9 @@
* 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).
* 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.
*/
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
@@ -15,7 +17,7 @@ import { afterEach, describe, expect, it } 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, { renderIndexInjections } from '../src/index.ts'
import HttpServer, { isLoopbackHost, normalizeBindAddress, renderIndexInjections } from '../src/index.ts'
let root: string | undefined
let context: Context | undefined
@@ -28,13 +30,13 @@ afterEach(async () => {
})
/** Write a cordis.yml with one webserver row, then boot it through the real Loader. */
async function loadComposition(port = 0, gzip = false): Promise<Context> {
async function loadComposition(port = 0, gzip = false, host = '127.0.0.1'): Promise<Context> {
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: '127.0.0.1'",
` host: '${host}'`,
` port: ${String(port)}`,
...(gzip
? [
@@ -97,6 +99,68 @@ async function upgrade(port: number, path: string): Promise<ReturnType<typeof co
}
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']) {
expect(HttpServer.Config({ host, port: 0 }).host).toBe(host)
}
})
it('rejects every wildcard spelling by address value, not by text', () => {
// Every spelling node:net accepts that parses to the unspecified address:
// IPv4 any, IPv6 any in long and short forms, IPv4-compatible, and the
// IPv4-mapped forms of IPv4 any, with or without a zone.
for (const host of [
'0.0.0.0', '::', '::0', '0000::', '0::', '0:0:0:0:0:0:0:0', '::0.0.0.0', '::0.0.0.0%lo', '::%lo',
'::ffff:0.0.0.0', '::ffff:0:0', '::ffff:0000:0000', '0:0:0:0:0:ffff:0:0',
'0:0:0:0:0:ffff:0.0.0.0', '::ffff:0.0.0.0%eth0',
]) {
expect(() => HttpServer.Config({ host, port: 0 })).toThrow(
/is an unspecified \(wildcard\) address, which is not supported/,
)
}
for (const host of ['localhost', 'example.com', '*', '[::]', '[fd00::1]', '10.1.2.3/8']) {
expect(() => HttpServer.Config({ host, port: 0 }))
.toThrow(/is not a concrete IPv4 or IPv6 address literal/)
}
})
it('denies the IPv4-mapped wildcard through the real plugin load, not only the predicate', async () => {
// 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/)
})
it('classifies loopback bind addresses from the parsed value, including mapped and zone forms', () => {
for (const host of ['127.0.0.1', '127.8.9.10', '127.5.5.5', '::1', '0:0:0:0:0:0:0:1', '::ffff:127.0.0.1', '::ffff:7f00:1', '::1%lo', '::0.0.0.1', '::0.0.0.1%lo']) {
expect(isLoopbackHost(host)).toBe(true)
}
// A dotted-quad tail is the address's own low 32 bits, never an IPv4 tail:
// ::127.0.0.1 is the unrelated IPv6 address ::7f00:1, not IPv4 loopback.
for (const host of ['10.1.2.3', '::ffff:10.1.2.3', 'fd00::1', 'fe80::1%lo', '::2', 'localhost', '::127.0.0.1', '::127.0.0.1%lo', '::0.0.0.2']) {
expect(isLoopbackHost(host)).toBe(false)
}
})
it('reads a dotted-quad IPv6 tail as the address it names', () => {
const rows: [string, string][] = [
['::0.0.0.1', '::1'],
['::0.0.0.1%lo', '::1'],
['::127.0.0.1', '::7f00:1'],
['::1', '::1'],
['10.1.2.3', '10.1.2.3'],
// Genuinely IPv4-mapped literals keep their IPv4 form for a browser URL.
['::ffff:127.0.0.1', '127.0.0.1'],
['::ffff:7f00:1', '127.0.0.1'],
['::ffff:10.1.2.3', '10.1.2.3'],
]
for (const [host, text] of rows) expect(normalizeBindAddress(host)).toBe(text)
expect(() => normalizeBindAddress('localhost')).toThrow(/is not a concrete IPv4 or IPv6 address literal/)
})
it('applies gzip only to eligible socket-backed HTTP responses', { timeout: 60_000 }, async () => {
expect(HttpServer.Config({ host: '127.0.0.1', port: 0 })).toEqual({
host: '127.0.0.1',
+3
View File
@@ -10267,6 +10267,9 @@ importers:
compression:
specifier: ^1.8.1
version: 1.8.2(supports-color@9.4.0)
ipaddr.js:
specifier: ^2.5.0
version: 2.5.0
negotiator:
specifier: ^1.1.0
version: 1.1.0
-3
View File
@@ -387,9 +387,6 @@
"packages/bundle/web-app/tests/startup.spec.ts": {
"2e78263d16d54ccfd8d322bcdf1bf738bd8134e15c24063ece9a1d1b52f4ebd9": 1
},
"packages/bundle/web-app/tests/web-app.spec.ts": {
"603938cefaa75059bc3902dedc4cd5cbfb2d6d3f6e56bb3cd89788e21ece0389": 1
},
"packages/client/connection/src/browser-auth.ts": {
"98acffd14d7c77e3412ce5a7d8e9c555b66de76eb12c14d4b22b251684b60447": 1
},
+57
View File
@@ -0,0 +1,57 @@
import { isIP } from 'node:net'
import { networkInterfaces } from 'node:os'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { probeZonedLoopback } from './test-non-loopback-address.ts'
const binding = vi.hoisted(() => ({ refusal: undefined as Error | undefined }))
vi.mock('node:os', async importOriginal => ({
...await importOriginal<typeof import('node:os')>(),
networkInterfaces: vi.fn(),
}))
// The regression concerns OS interface metadata, not the test host's IPv6 availability.
vi.mock('node:http', () => ({
createServer: () => {
let reject: (error: Error) => void
return {
once(_event: string, listener: (error: Error) => void) { reject = listener },
listen(_options: { host: string; port: number }, ready: () => void) {
if (binding.refusal) reject(binding.refusal)
else ready()
},
close(closed: () => void) { closed() },
}
},
}))
beforeEach(() => {
binding.refusal = undefined
vi.mocked(networkInterfaces).mockReturnValue({
'Loopback Pseudo-Interface 1': [{
address: '::1',
netmask: 'ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff',
family: 'IPv6',
mac: '00:00:00:00:00:00',
internal: true,
scopeid: 0,
cidr: '::1/128',
}],
})
})
afterEach(() => {
vi.restoreAllMocks()
})
it('returns a valid scoped literal when the interface display name contains spaces', async () => {
const host = await probeZonedLoopback()
expect(isIP(host ?? '')).toBe(6)
expect(host).toContain('%')
})
it('skips a scoped loopback whose listener refuses the bind', async () => {
binding.refusal = new Error('address unavailable')
vi.spyOn(console, 'info').mockImplementation(() => {})
expect(await probeZonedLoopback()).toBeUndefined()
})
+70
View File
@@ -0,0 +1,70 @@
/**
* Address probes for concrete-bind integration cases. Interface membership
* does not guarantee a usable address; hosts without one report why their
* network coverage is skipped.
*/
import { createServer } from 'node:http'
import { networkInterfaces } from 'node:os'
/**
* First candidate whose listener opens and closes successfully.
* @param candidates - local IP literals to probe.
* @param description - address category reported when no candidate can bind.
* @returns a bindable address, or undefined when the scenario must skip.
*/
async function probeBindableAddress(candidates: string[], description: string): Promise<string | undefined> {
const refused: string[] = []
for (const address of candidates) {
const server = createServer()
try {
await new Promise<void>((resolve, reject) => {
server.once('error', reject)
server.listen({ host: address, port: 0 }, resolve)
})
} catch (error) {
// A failed listen owns no socket to close.
refused.push(`${address}: ${error instanceof Error ? error.message : String(error)}`)
continue
}
await new Promise<void>((resolve) => { server.close(() => { resolve() }) })
return address
}
console.info(
`${description} coverage skipped: no bindable ${description} address`
+ ` (${candidates.length === 0 ? 'this host reports none' : `refused ${refused.join('; ')}`})`,
)
return undefined
}
/**
* First local non-loopback IPv4 address that really accepts a listen.
*
* IPv4 only: supported CI hosts need not own a non-loopback IPv6 address.
* @returns the bindable address, or undefined when the scenario must skip.
*/
export async function probeNonLoopbackIpv4(): Promise<string | undefined> {
const candidates: string[] = []
for (const entry of Object.values(networkInterfaces()).flat()) {
if (entry !== undefined && entry.family === 'IPv4' && !entry.internal) candidates.push(entry.address)
}
return probeBindableAddress(candidates, 'non-loopback IPv4')
}
/**
* Bindable IPv6 loopback with the numeric scope reported by the OS.
* Interface display names are not portable IPv6 zone identifiers.
*
* Loopback only: a link-local candidate is no substitute, because these
* scenarios need the loopback address itself — their advertised root and Host
* fence follow from it. A host that reports no bindable `::1` skips them.
* @returns the zoned literal after closing its probe listener, or undefined when none can bind.
*/
export async function probeZonedLoopback(): Promise<string | undefined> {
const candidates: string[] = []
for (const entry of Object.values(networkInterfaces()).flat()) {
if (entry !== undefined && entry.family === 'IPv6' && entry.address === '::1') {
candidates.push(`::1%${entry.scopeid}`)
}
}
return probeBindableAddress(candidates, 'zoned IPv6 loopback')
}