fix(web): authenticate the browser Host API

This commit is contained in:
Tianyi Cui
2026-08-25 14:23:45 +08:00
parent 19c772f46c
commit 3e24087bfa
115 changed files with 1617 additions and 522 deletions
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md
2026-07-28-api-browser-trust-boundary.md: 92b76c109aa9b55bc72bb76f8b208ea2d814d8ce
2026-07-28-api-browser-trust-boundary.zh.md: 653ff32f2e62bf0e2982517f82649ff2d06e40d4
2026-07-28-api-browser-trust-boundary.md: 2997b0f2affaac59be9cd58108bc20086442f6db
2026-07-28-api-browser-trust-boundary.zh.md: ff952ede9adc53c9d590ad2ba24a32b448172f82
@@ -6,7 +6,7 @@ English | [中文](2026-07-28-api-browser-trust-boundary.zh.md)
## Problem
The web GUI host serves `/api` over plain HTTP (default `127.0.0.1:3080`, `--host 0.0.0.0` supported), and the surface includes remote-code-execution-grade methods — `session.prompt` drives an agent that runs bash. A browser turns the operator into a confused deputy against such a local API in two classic ways: a malicious page fires a "simple" cross-site POST (`text/plain` — sent without a CORS preflight) whose side effects execute even though the response stays unreadable, and a DNS-rebound origin talks to the socket as if same-origin, making CORS inapplicable entirely, with only the `Host` header betraying the attacker's domain. Before this decision the system's only browser-trust check (`isTrustedNativeDialogRequest`: loopback socket + same-origin + loopback Host) guarded exactly one cosmetic route — `host.pickDirectory`, whose native dialog pops on the host's screen — while every consequential method was unguarded. Guarding per-RPC also could not survive the in-app directory browser, whose whole point is serving legitimately remote clients that a loopback rule would refuse.
The web GUI host serves `/api` over plain loopback HTTP (default `127.0.0.1:3080`; the CLI rejects `--host 0.0.0.0`), and the surface includes remote-code-execution-grade methods — `session.prompt` drives an agent that runs bash. A browser turns the operator into a confused deputy against such a local API in two classic ways: a malicious page fires a "simple" cross-site POST (`text/plain` — sent without a CORS preflight) whose side effects execute even though the response stays unreadable, and a DNS-rebound origin talks to the socket as if same-origin, making CORS inapplicable entirely, with only the `Host` header betraying the attacker's domain. Before this decision the system's only browser-trust check (`isTrustedNativeDialogRequest`: loopback socket + same-origin + loopback Host) guarded exactly one cosmetic route — `host.pickDirectory`, whose native dialog pops on the host's screen — while every consequential method was unguarded. Guarding per-RPC also could not survive the in-app directory browser, whose whole point is serving legitimately remote clients that a loopback rule would refuse.
## Decision
@@ -15,17 +15,17 @@ Enforce browser trust once, at the carrier, for the entire `/api` prefix — two
- **Media-type fence (dsh-host-apiproxy)**: 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.
Two boundaries stay deliberately out of scope: reachability is the webserver binding's policy (`host: 127.0.0.1 | 0.0.0.0`), and authentication for genuinely remote deployments is deferred work recorded in the connection README — the fence is a confused-deputy defense, not an auth layer. The old guard's loopback-socket check was dropped rather than generalized: with binding expressing reachability and `trustedHosts` naming remote authorities, the socket address adds nothing a header fence does not already cover.
Reachability remains the webserver binding's policy (`host: 127.0.0.1 | 0.0.0.0`), and this fence remains 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 old guard's loopback-socket check was dropped rather than generalized: with binding expressing reachability and `trustedHosts` naming accepted authorities, the socket address adds nothing the Host/Origin checks need.
## Alternatives considered
- **Per-RPC guards (status quo extended).** Rejected: the guard list trails the method list forever, the highest-value methods were already unguarded, and a loopback rule on browse RPCs would break the remote deployments they exist for.
- **CORS headers + credential omission.** Rejected: we never want cross-origin reads at all, so answering preflights only widens the surface; refusing them is strictly stronger and simpler.
- **Authentication tokens.** Rejected for this change: token minting/storage/rotation is real product surface; the fence closes the browser-deputy holes without pre-deciding the auth design.
- **Authentication tokens.** Rejected for this change: token minting, storage, and rotation are separate product decisions. The later [browser token authentication](2026-08-24-browser-token-authentication.md) owns them without changing this fence.
## Consequences
- Any future `/api` method is covered by construction; there is no per-route trust decision left to forget.
- Non-loopback deployments must have their serving authorities trusted or requests are refused. The dsh CLI keeps its advertised `--host 0.0.0.0` LAN URL working by deriving the machine's LAN IP literals into the connection row (port-less entries — an IP-literal Host cannot be a rebound name, and the bound port may be OS-assigned) and offers `dsh web --trusted-host` for named authorities; compositions the CLI does not boot declare `trustedHosts` themselves. Non-browser automation rides the same fence: loopback, a derived LAN IP, or a declared authority passes; an undeclared DNS alias is refused.
- 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.
- Clients must label POST bodies `application/json` (ours always did; raw-fetch tests gained the header).
- The trusted-network assumption of an unauthenticated `0.0.0.0` deployment is now documented instead of implicit.
- Host and Origin remain request-routing evidence only. The process token and signed cookie establish the browser identity used by every Host method.
@@ -6,7 +6,7 @@ Status: implemented
## 问题
Web GUI 宿主以纯 HTTP 提供 `/api`(默认 `127.0.0.1:3080`,支持 `--host 0.0.0.0`),而这个面上有远程代码执行级别的方法——`session.prompt` 驱动的 agent(智能体)可以运行 bash。浏览器会用两种经典方式把操作者变成攻击此类本地 API 的「混淆代理人」:恶意页面发出跨站「简单请求」 POST(`text/plain`——不经 CORS 预检即发出),其副作用照常执行、只是响应不可读;以及 DNS rebinding 后的源以「同源」身份直连 socket,CORS 整体失效,只有 `Host` 头会暴露攻击者的域名。在本决策之前,系统里唯一的浏览器信任检查(`isTrustedNativeDialogRequest`:回环 socket、同源、回环 Host)只守着一个装饰性的路由——`host.pickDirectory`,其原生对话框弹在宿主屏幕上——而所有真正具有严重后果的方法都没有防护。按 RPC 逐个设防也活不过应用内目录浏览器:它存在的意义就是服务合法的远程客户端,回环规则恰恰会拒绝它们。
Web GUI 宿主以纯 loopback HTTP 提供 `/api`(默认 `127.0.0.1:3080`CLI 拒绝 `--host 0.0.0.0`),而这个面上有远程代码执行级别的方法——`session.prompt` 驱动的 agent(智能体)可以运行 bash。浏览器会用两种经典方式把操作者变成攻击此类本地 API 的「混淆代理人」:恶意页面发出跨站「简单请求」 POST(`text/plain`——不经 CORS 预检即发出),其副作用照常执行、只是响应不可读;以及 DNS rebinding 后的源以「同源」身份直连 socket,CORS 整体失效,只有 `Host` 头会暴露攻击者的域名。在本决策之前,系统里唯一的浏览器信任检查(`isTrustedNativeDialogRequest`:回环 socket、同源、回环 Host)只守着一个装饰性的路由——`host.pickDirectory`,其原生对话框弹在宿主屏幕上——而所有真正具有严重后果的方法都没有防护。按 RPC 逐个设防也活不过应用内目录浏览器:它存在的意义就是服务合法的远程客户端,回环规则恰恰会拒绝它们。
## 决策
@@ -15,17 +15,17 @@ Web GUI 宿主以纯 HTTP 提供 `/api`(默认 `127.0.0.1:3080`,支持 `--ho
- **媒体类型栅栏(dsh-host-apiproxy**:每个 `/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` 失去专属守卫,与其他请求同栅而行。
两条边界刻意留在范围之外:可达性由 webserver 的绑定配置(`host: 127.0.0.1 | 0.0.0.0`)控制;真正远程部署的认证是延期工作,记录在 connection README——这道栅栏是混淆代理人防御,不是认证层。旧守卫的回环 socket 检查被放弃而非泛化:绑定表达可达性、`trustedHosts` 点名远程权威之后,socket 地址提供不了头部栅栏覆盖不到的任何东西
可达性由 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 校验需要的额外信息
## 曾考虑的替代方案
- **按 RPC 设防(延续现状)。** 否决:守卫清单永远追着方法清单跑,价值最高的方法本来就没被守住,而 browse RPC 上的回环规则会破坏它们为之存在的远程部署。
- **CORS 头与省略凭据。** 否决:我们根本不想要任何跨源读取,应答预检只会扩大暴露面;拒绝预检严格更强也更简单。
- **认证令牌。** 在本变更中否决:令牌签发、存储轮换是真实的产品面;栅栏能够封死浏览器混淆代理人漏洞,无需预先决定认证设计
- **认证令牌。** 在本变更中否决:令牌签发、存储轮换属于独立产品决策。后续[浏览器令牌认证](2026-08-24-browser-token-authentication.zh.md)持有这些机制,不改变本栅栏
## 后果
- 未来任何 `/api` 方法天然在覆盖范围内;不存在会被遗忘的按路由信任决定。
- 非回环部署的对外服务 authority 必须列入信任范围,否则请求会被拒绝。dsh CLI 通过把本机 LAN IP 字面量推导进 connection 行(不带端口的条目——IP 字面量 Host 不可能是被重绑的域名,且绑定端口可能由操作系统分配)来保住它公布的 `--host 0.0.0.0` LAN URL,并提供 `dsh web --trusted-host` 声明具名权威;并非由 CLI 启动的组合自行声明 `trustedHosts`。非浏览器自动化走同一道栅栏:回环地址、推导的 LAN IP 或已声明的权威可通过;未声明的 DNS 别名会被拒绝
- 自定义非 loopback 组合必须信任其服务 authority,否则请求会被拒绝;随后仍像每个 loopback 请求一样满足浏览器认证。随附 CLI 拒绝 `--host 0.0.0.0``--trusted-host` 只扩展 Host/Origin 栅栏,绝不授予身份
- 客户端必须给 POST 体标注 `application/json`(我们自己的客户端一向如此;裸 fetch 测试补上了该头)。
- 无认证 `0.0.0.0` 部署的「可信网络」假设从隐含变为成文
- Host 与 Origin 仍只是请求路由证据。进程令牌与签名 cookie 建立每个 Host 方法使用的浏览器身份
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-30-config-plane-boundaries.md
2026-07-30-config-plane-boundaries.md: 7b234ec64669cc1e6f0d5a67437005747edcea98
2026-07-30-config-plane-boundaries.zh.md: f543c511d9374a50955331911b2eb2d62dab0675
2026-07-30-config-plane-boundaries.md: d7ae13f08ca5c3957a8a5c1871b0022453e9b562
2026-07-30-config-plane-boundaries.zh.md: e53a68f91f4d961af5a01fd7bbfb4472b4b17dfe
@@ -20,7 +20,7 @@ Three smaller defects sat beside them. `llm/adapters-updated` documented contain
## Decision
**Reading configuration is as privileged as writing it.** `settings.describe` and `credentials.describe` join the loopback-only set, so the whole configuration plane stays same-origin until real authentication exists. The model catalog (`llm.providers`, `llm.models`) deliberately does not: it carries provider ids, display names, and model lists — no endpoints, no key state — and a LAN client's model picker needs it. The boundary is asserted over a real HTTP server rather than a hand-assembled request, because the `Host` header a browser actually sends is what decides it.
**Reading configuration is as privileged as writing it.** `settings.describe`, `credentials.describe`, the model catalog, and every other Host operation require one browser session. The configuration plane still redacts secrets independently of authentication. The boundary is asserted over a real HTTP server rather than a hand-assembled request, proving that a forged loopback `Host` value never establishes identity.
**The plane serves exactly the namespaces a registered model provider addresses.** `ctx.llm.listConfigurableProviders()` is the allow-list, so the product boundary is enforced rather than inferred from the installed plugin set, and a future namespace becomes web-configurable only by joining that directory. An unregistered namespace and an unexposed one answer identically (`settings-not-exposed`), so probing cannot enumerate the registry.
@@ -20,7 +20,7 @@ Status: implemented
## 决策
**读取配置与写入配置同样属于特权操作。**`settings.describe``credentials.describe` 加入仅限回环的集合,因此在真正的认证层出现之前,整个配置面都保持同源。模型目录(`llm.providers``llm.models`)刻意不在其中:它携带的是提供方 id、显示名与模型列表——没有端点、没有密钥状态——而 LAN 客户端的模型选择器正需要它。这条边界由一台真实 HTTP 服务器来断言,而不是手工拼装的请求,因为真正决定它的,是浏览器实际发出的那个 `Host`
**读取配置与写入配置同样属于特权操作。**`settings.describe``credentials.describe`、模型目录及其他所有 Host 操作都要求同一个浏览器会话。配置面仍独立于认证对 secret 脱敏。这条边界由真实 HTTP 服务器而非手工请求来断言,证明伪造 loopback `Host` 值绝不建立身份
**这个面恰好服务于已注册模型提供方所指向的那些 namespace。**`ctx.llm.listConfigurableProviders()` 就是允许列表,于是产品边界是被执行的,而不是从已安装的插件集合里推断出来的;将来的 namespace 只有加入该目录才会变得可在 Web 上配置。未注册的 namespace 与未暴露的 namespace 得到完全相同的答复(`settings-not-exposed`),因此探测无法枚举注册表。
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-30-web-config-plane.md
2026-07-30-web-config-plane.md: c8397d03cd7b8eb4ea127cdc26124f5e721e822d
2026-07-30-web-config-plane.zh.md: b0724c3a8cb160cbac5fc88fe07d35e79accfc49
2026-07-30-web-config-plane.md: d5bd9c05e8352536c5c6f8b265db7dbd56a4fb84
2026-07-30-web-config-plane.zh.md: 3c7f801766b1a6c197cc208a3c4a030b8aaac771
@@ -12,11 +12,11 @@ The request-level configuration seam made LLM adapter configuration restart-free
## Decision
**Wire domains on the compiled RPC map, rejections as codes, owner events forwarded verbatim.** `settings.describe/openDocument/update/replace/mutate`, `credentials.describe/set/unset`, `llm.providers`, and `llm.models` join `RpcMethodMap`, so the compiler-locked wiring sites keep schema, handler, and client in lockstep. Seam rejections fold into `settings-rejected {ns}` / `credential-rejected {ref}` business errors, while clients subscribe to forwarded settings, credentials, and LLM owner events and converge without polling ([forwarded Remote events](2026-08-10-remote-event-delivery.md)). Settings reads, native actions, and writes join `pickDirectory`/`openPath` in the connection guard's privileged set: loopback + same-origin or 403, because a LAN-exposed dsh web must not accept configuration access from another origin.
**Wire domains on the compiled RPC map, rejections as codes, owner events forwarded verbatim.** `settings.describe/openDocument/update/replace/mutate`, `credentials.describe/set/unset`, `llm.providers`, and `llm.models` join `RpcMethodMap`, so the compiler-locked wiring sites keep schema, handler, and client in lockstep. Seam rejections fold into `settings-rejected {ns}` / `credential-rejected {ref}` business errors, while clients subscribe to forwarded settings, credentials, and LLM owner events and converge without polling ([forwarded Remote events](2026-08-10-remote-event-delivery.md)). Connection authenticates settings reads, native actions, writes, `pickDirectory`, `openPath`, and every other Host operation with one browser session; Host/Origin failures still return 403 before identity is checked.
**`describe()` grows layers and structural secret redaction.** `SettingsDescriptor` carries `base`/`user` beside the effective value, so the form marks "overridden" by presence in the user layer, not value inequality (an override *equal* to the base is still an override). `describe({ redactSecrets: true })` — mandatory at every wire face — strips `role('secret')` subtrees from all three layers via a pure structural walk of the schema (object/dict/array containers; a secret-role subtree is one opaque leaf) and enumerates the stripped slots as `{path, set}`, so a page can render write-only inputs without ever receiving a value.
**The Host identifies and opens the local settings document.** The settings seam exposes optional `documentPath` provider metadata and a `prepareDocument()` operation; `settings-file` returns its fully resolved custom or `$DSH_HOME/settings.yaml` filename and exclusively creates an absent empty document with owner-only permissions, while non-file providers retain the base `undefined`. The loopback-only `settings.describe` response carries only the boolean `hasDocument` capability beside the redacted namespace views. `ui-settings-general` registers a `settings.action` entry only on loopback pages, shows it only after the metadata confirms that a provider-owned local document can be prepared, and invokes pathless `settings.openDocument`; the Host resolves the provider path again before a text-document handoff (`open -t` on macOS so an arbitrary YAML file association cannot redirect the gesture, `xdg-open` on desktop Linux, `Invoke-Item` on Windows, and `wslpath -w` followed by that Windows handoff on WSL). Generic workspace paths retain the default intent, including its browser preference for browser-renderable documents. The browser neither derives `$DSH_HOME` nor receives a filesystem target; remote pages make no privileged settings read for this action.
**The Host identifies and opens the local settings document.** The settings seam exposes optional `documentPath` provider metadata and a `prepareDocument()` operation; `settings-file` returns its fully resolved custom or `$DSH_HOME/settings.yaml` filename and exclusively creates an absent empty document with owner-only permissions, while non-file providers retain the base `undefined`. The browser-authenticated `settings.describe` response carries only the boolean `hasDocument` capability beside the redacted namespace views. `ui-settings-general` registers a `settings.action` entry only on loopback pages, shows it only after the metadata confirms that a provider-owned local document can be prepared, and invokes pathless `settings.openDocument`; the Host resolves the provider path again before a text-document handoff (`open -t` on macOS so an arbitrary YAML file association cannot redirect the gesture, `xdg-open` on desktop Linux, `Invoke-Item` on Windows, and `wslpath -w` followed by that Windows handoff on WSL). Generic workspace paths retain the default intent, including its browser preference for browser-renderable documents. The browser neither derives `$DSH_HOME` nor receives a filesystem target; non-loopback pages retain the Client policy that makes no Host settings read for this action.
**The llm seam declares configurability and announces topology.** `registerConfigurableProviders()` is an all-or-nothing, fiber-scoped directory of `{provider, displayName, settingsNs, settingsPath}` — the addressing a config page needs to open the right settings subtree for a route that may not exist yet; `listConfigurableProviders()` merges with live routes in the wire handler so undeclared live routes still report active. The zero-payload `'llm/adapters-updated'` event fires from all four registration/unregistration commit points with contained listener dispatch (INVARIANT rethrow), following the settings/commands precedent. `llm-deepseek`'s route renamed to `deepseek-official` because the pi-ai catalog legitimately owns `deepseek` as an aggregator entry; pre-release stance, no alias.
@@ -12,11 +12,11 @@ Status: implemented
## 决策
**wire 领域挂上编译期 RPC 映射,拒绝落为错误码,owner 事件原样转发。**`settings.describe/openDocument/update/replace/mutate``credentials.describe/set/unset``llm.providers``llm.models` 一同加入 `RpcMethodMap`,由编译器锁定的接线位点让 schema、处理器与客户端保持步调一致。seam 侧拒绝折叠为业务错误,客户端则订阅转发的 settings、credentials 与 LLM owner 事件,无需轮询即可收敛(见[转发的 Remote 事件](2026-08-10-remote-event-delivery.zh.md))。settings 读取、原生操作写入`pickDirectory`/`openPath` 一起进入连接守卫的特权集合:回环 + 同源,否则 403,因为暴露在局域网上的 dsh web 绝不能接受来自其他源的配置访问
**wire 领域挂上编译期 RPC 映射,拒绝落为错误码,owner 事件原样转发。**`settings.describe/openDocument/update/replace/mutate``credentials.describe/set/unset``llm.providers``llm.models` 一同加入 `RpcMethodMap`,由编译器锁定的接线位点让 schema、处理器与客户端保持步调一致。seam 侧拒绝折叠为业务错误,客户端则订阅转发的 settings、credentials 与 LLM owner 事件,无需轮询即可收敛(见[转发的 Remote 事件](2026-08-10-remote-event-delivery.zh.md))。Connection 用一个浏览器会话认证 settings 读取、原生操作写入`pickDirectory``openPath` 与其他所有 Host 操作;Host/Origin 失败仍会在身份校验前返回 403
**`describe()` 增加分层与结构化 secret 脱敏。**`SettingsDescriptor` 在生效值之外携带 `base`/`user`,表单据此按「字段是否出现在用户层」来标记「已覆盖」,而非按值是否不等(与 base *相等*的覆盖仍然是覆盖)。`describe({ redactSecrets: true })`——在每个 wire 面都强制启用——经由对 schema 的纯结构遍历(object/dict/array 容器;secret 角色子树整体是一个不透明叶节点)从全部三层剥除 `role('secret')` 子树,并把剥除的槽位枚举为 `{path, set}`,页面因此不必收到任何值就能渲染只写输入框。
**Host 识别并打开本地设置文档。** settings seam 暴露可选的 `documentPath` 提供方元数据和 `prepareDocument()` 操作;`settings-file` 返回已完全解析的自定义文件名或 `$DSH_HOME/settings.yaml` 文件名,并在文档缺失时以仅属主可访问的权限独占创建空文档,非文件提供方则保留基类的 `undefined`仅限回环访问`settings.describe` 响应会在脱敏 namespace 视图旁只携带布尔型 `hasDocument` 能力。`ui-settings-general` 只在回环页面注册一条 `settings.action` 条目,只有元数据确认可准备好一份由提供方持有的本地文档后才显示,并调用无路径参数的 `settings.openDocument`;Host 会在文本文档交接前再次解析提供方路径(macOS 上使用 `open -t`,使任意 YAML 文件关联无法重定向这次操作;桌面 Linux 上使用 `xdg-open`Windows 上使用 `Invoke-Item`WSL 上先执行 `wslpath -w`,再使用同一 Windows 交接)。通用 Workspace 路径仍保留默认意图,包括针对浏览器可渲染文档的浏览器偏好。浏览器既不推导 `$DSH_HOME`,也不会收到文件系统目标;远程页面不会为这项操作发起特权 settings 读取。
**Host 识别并打开本地设置文档。** settings seam 暴露可选的 `documentPath` 提供方元数据和 `prepareDocument()` 操作;`settings-file` 返回已完全解析的自定义文件名或 `$DSH_HOME/settings.yaml` 文件名,并在文档缺失时以仅属主可访问的权限独占创建空文档,非文件提供方则保留基类的 `undefined`经浏览器认证`settings.describe` 响应会在脱敏 namespace 视图旁只携带布尔型 `hasDocument` 能力。`ui-settings-general` 只在回环页面注册一条 `settings.action` 条目,只有元数据确认可准备好一份由提供方持有的本地文档后才显示,并调用无路径参数的 `settings.openDocument`;Host 会在文本文档交接前再次解析提供方路径(macOS 上使用 `open -t`,使任意 YAML 文件关联无法重定向这次操作;桌面 Linux 上使用 `xdg-open`Windows 上使用 `Invoke-Item`WSL 上先执行 `wslpath -w`,再使用同一 Windows 交接)。通用 Workspace 路径仍保留默认意图,包括针对浏览器可渲染文档的浏览器偏好。浏览器既不推导 `$DSH_HOME`,也不会收到文件系统目标;非 loopback 页面保留 Client 策略,不为这项操作发起 Host settings 读取。
**llm seam 声明可配置性并公布拓扑。**`registerConfigurableProviders()` 是一个全有或全无、以 fiber 为作用域的目录,条目为 `{provider, displayName, settingsNs, settingsPath}`——这正是配置页要为一条可能尚不存在的路由打开正确设置子树时所需要的寻址;`listConfigurableProviders()` 在 wire 处理器里与存活路由合并,未声明的存活路由因此仍报告为激活。零负载的 `'llm/adapters-updated'` 事件从全部四个注册/注销提交点触发,listener 派发带异常隔离(INVARIANT 重抛),沿用 settings/commands 的先例。`llm-deepseek` 的路由重命名为 `deepseek-official`,因为 pi-ai catalog 名正言顺地拥有 `deepseek` 这个聚合器条目;依预发布立场,不设别名。
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md
2026-08-03-per-session-agent-presets.md: b2195004580e1f4bf4be5527c61a8ee9b816c964
2026-08-03-per-session-agent-presets.zh.md: 9f689c74dd570d6ec9aaa7cd1274618326a836dd
2026-08-03-per-session-agent-presets.md: 97352a0e3376ce1fe26bac62b88c2c674202adc7
2026-08-03-per-session-agent-presets.zh.md: b62baf3e3097ba99247c2e86cc3fb5b7e43e2fab
@@ -53,7 +53,7 @@ Which preset an unnamed session gets is a user setting (`agent-presets.default`)
**Switching is allowed only while a session is blank.** Once a turn has run, that history was produced under the preset's tools and swapping them would strand logged tool calls, so `agentPreset.select` answers `agent-preset-locked`. A blank switch keeps the agent and the session and replaces only the subtree, because the host discards the `AgentHandle` it creates and there is no delete RPC — and keeping them is the better outcome anyway, since the session id, its workspace attachment, and its projections all stay put. The swap is unmount-then-mount (two compositions would register the same tool names into one layer), so it resolves the new preset before tearing anything down and restores the previous one when the new mount fails.
**Authoring a preset is an RPC, and a privileged one.** A composition is a file, but "edit it on the filesystem" is not a browser affordance, so the roster gained `read`/`write`/`remove` beside `select`. Those three are loopback-pinned: a composition names the plugins a session runs, so reading one is reconnaissance and writing one is arbitrary capability. `list` and `select` deliberately stay ordinary. The roster carries ids and trust only, and a LAN client's picker needs it; and choosing a preset looked like escalation — one of them mounts the toolset that edits the live runtime — but `session.create` already takes an `agentPreset`, so pinning only the switch would have left the same capability one method over. The capability is not the preset's to grant either: the deployment's own default already carries `bash` and the filesystem tools, so any caller that may start a session at all can already run commands as this process. Containment is a property of the id (`[a-z0-9][a-z0-9-]*`), checked before it becomes a directory name rather than by inspecting the joined path afterwards; the text is parsed with the loader's own schema and dialect, so a save cannot leave a file no session could load. Shipped presets are refused for writes and deletes, because the deployment's copy is what a broken local preset is compared against — which also makes "duplicate, then edit" the authoring path rather than an afterthought.
**Authoring a preset is an RPC, and a privileged one.** A composition is a file, but "edit it on the filesystem" is not a browser affordance, so the roster gained `read`/`write`/`remove` beside `select`. Connection authenticates authoring, `list`, `select`, and the complete Host API with one browser session: a composition names the plugins a session runs, so reading one is reconnaissance and writing one is arbitrary capability, while choosing a preset grants nothing `session.create` with `agentPreset` did not already grant. The capability is not the preset's to grant either: the deployment's own default already carries `bash` and the filesystem tools, so any caller that may start a session at all can already run commands as this process. Containment is a property of the id (`[a-z0-9][a-z0-9-]*`), checked before it becomes a directory name rather than by inspecting the joined path afterwards; the text is parsed with the loader's own schema and dialect, so a save cannot leave a file no session could load. Shipped presets are refused for writes and deletes, because the deployment's copy is what a broken local preset is compared against — which also makes "duplicate, then edit" the authoring path rather than an afterthought.
**A service with a consumer outside the agent plane cannot move into a preset.** The aggressive split moved the `subagents` registry and its spawn/fork backends into the delegation group's entry-local realm, and `dsh web` then failed to boot: `dsh-host-apiproxy` is a HOST row that injects `subagents` to answer the browser's cross-session queries (`listChildren`, `followup`), so it waited forever for a service only sessions now provided. A per-session copy is wrong twice over — a provider name registers once, so the second session would have collided anyway. The registry and every shared backend, including the [fixed Codex and Claude Code product providers](2026-08-10-product-subagent-providers-in-shared-host.md), are host-plane; a preset contributes whichever delegation TOOLS its agent should see, and those tools resolve the host registry. `workflows` stays entry-local because nothing outside an agent reads it. Grepping injectors is what should have caught this and did not: the search has to include the host packages, not just the agent-plane ones.
@@ -54,7 +54,7 @@ Status: implemented
**只有空白会话才允许切换。** 一旦跑过任何轮次,那段历史就是在该 preset 的工具下产生的,替换会留下无法执行的已记录 tool call,因此 `agentPreset.select` 返回 `agent-preset-locked`。空白期的切换保留 agent 与 session,只替换子树——因为宿主丢弃了它创建的 `AgentHandle`,也没有 delete RPC;而保留它们本身就是更好的结果,会话 id、workspace 挂接与 projections 都原地不动。该替换是"先卸后装"(两份组装会把同名工具注册进同一分层),因此它在拆除任何东西之前先解析新 preset,并在新组装装载失败时恢复原来的那一份。
**创作 preset 是一次 RPC,而且是特权 RPC。** 组装是一个文件,但“去文件系统里改它”并不是浏览器能提供的操作,因此名单在 `select` 之外新增了 `read`/`write`/`remove`这三者被固定在环回地址:组装指明一个会话所运行的插件,因此读取它是侦察,写入它是任意能力`list``select` 刻意保持为普通方法。名单只携带 id 与信任级别,而局域网客户端的选择器需要它;至于选择本身,它看起来像提权——其中一个 preset 会挂载可编辑活动运行时的工具集——但 `session.create` 本就接受 `agentPreset`,只固定切换会把同一能力留在隔壁一个方法上。这份能力也不由 preset 授予:部署自带的默认 preset 本就带着 `bash` 与文件系统工具,因此任何被允许开启会话的调用方,早已能以本进程的身份执行命令。约束是 id 自身的性质(`[a-z0-9][a-z0-9-]*`),在它成为目录名之前就检查,而不是事后再去审视拼接出的路径;文本使用 loader 自身的 schema 与方言解析,因此保存不会留下任何会话都无法加载的文件。随部署提供的 preset 拒绝写入与删除,因为部署自带的那一份正是用来对照有问题的本地 preset 的——这也让“先复制、再编辑”成为创作路径本身,而非事后补充。
**创作 preset 是一次 RPC,而且是特权 RPC。** 组装是一个文件,但“去文件系统里改它”并不是浏览器能提供的操作,因此名单在 `select` 之外新增了 `read`/`write`/`remove`Connection 用一个浏览器会话认证创作操作、`list``select` 与完整 Host API:组装指明一个会话所运行的插件,因此读取它是侦察,写入它是任意能力;选择 preset 则没有授予 `session.create` 携带 `agentPreset` 时尚未拥有的能力。这份能力也不由 preset 授予:部署自带的默认 preset 本就带着 `bash` 与文件系统工具,因此任何被允许开启会话的调用方,早已能以本进程的身份执行命令。约束是 id 自身的性质(`[a-z0-9][a-z0-9-]*`),在它成为目录名之前就检查,而不是事后再去审视拼接出的路径;文本使用 loader 自身的 schema 与方言解析,因此保存不会留下任何会话都无法加载的文件。随部署提供的 preset 拒绝写入与删除,因为部署自带的那一份正是用来对照有问题的本地 preset 的——这也让“先复制、再编辑”成为创作路径本身,而非事后补充。
**在 agent 平面之外还有消费方的服务,不能搬进 preset。** 激进拆分把 `subagents` 注册表连同 spawn/fork 后端一起搬进了 delegation 组的 entry-local realm,于是 `dsh web` 直接起不来:`dsh-host-apiproxy` 是宿主行,它注入 `subagents` 来回答浏览器的跨会话查询(`listChildren``followup`),因而永远等待一个此刻只有会话才提供的服务。按会话各一份在两个层面上都是错的——provider 名只能注册一次,第二个会话本来也会相撞。注册表与所有共享后端,包括[固定的 Codex 与 Claude Code 产品 provider](2026-08-10-product-subagent-providers-in-shared-host.zh.md),都属于宿主平面;preset 只贡献自己的 agent 应看见的委派**工具**,这些工具解析宿主注册表。`workflows` 保持 entry-local,因为 agent 之外没有任何东西读它。本该拦下它的是「检索注入方」这一步,而它没拦住:检索必须覆盖宿主包,而不只是 agent 平面的包。
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.md
2026-08-04-draft-provider-endpoint-interrogation.md: 6a545ea5c97ef7e7f0e916c1676c1361f7a9bf3b
2026-08-04-draft-provider-endpoint-interrogation.zh.md: eb8133d0f2ec4a1999770468782db9c47ba170c8
2026-08-04-draft-provider-endpoint-interrogation.md: 0132dba5888faa1b9e6a4e59b45ee56a259eeef7
2026-08-04-draft-provider-endpoint-interrogation.zh.md: 8cea5d2809611696606113b2e38578f1b54b4e09
@@ -19,7 +19,7 @@ Interrogation is keyed by **settings namespace**, not by provider route:
- `ctx.llm.registerModelDiscovery(settingsNs, discover)` lets an adapter plugin offer to interrogate endpoints for the namespace it owns, and `ctx.llm.discoverModels(settingsNs, request)` asks. There is no way to enumerate which namespaces registered: a surface that cannot interrogate learns it from the refusal, and a list nothing consumed would be a required wire field doing nothing. The namespace is the right key because a configuration surface already holds it from the configurable-provider directory, and because a provider being added has no route to name.
- `LlmModelDiscoveryRequest` carries the draft — an optional `provider`, an optional `baseURL`, an optional `api`, an optional `apiKey`, and a signal — and needs at least one of `provider` or `baseURL` to have anything to answer about. `provider` exists because a route the adapter already describes is answered from its own registry with no network call at all; only a route it does not describe reaches an endpoint. Nothing in this path writes settings or credentials. The one read is the credential of a route the request names: a configuration surface holds a redacted descriptor rather than the stored secret, so the draft's `apiKey` is present only while the user is typing one, and without that read an already-configured route would be interrogated unauthenticated and answer 401. The typed key wins, being the one under test.
- `LlmDiscoveredModel` makes every field but `id` optional, because most listings disclose an id and nothing else. The reply is candidates, not a catalog: a surface adopting one still owes the capacities the adapter requires.
- `llm.discoverModels` carries the same draft over the wire. Its `apiKey` is the third and last payload on which a secret may ride, alongside `settings.update`/`mutate` and `credentials.set`, and it is never stored or echoed back. It does ride the client's outgoing envelope like every other secret-bearing payload, where a `subscribeEnvelopes()` observer can see it; redacting that tap is a configuration-plane-wide change, not this method's to make alone. The method is loopback-only for a second reason besides the key: it makes the host issue a GET to a caller-chosen URL and reports the outcome, which is a probe an anonymous LAN caller must not have. Every refusal folds into `model-discovery-failed`, whose message is the adapter's own text and whose details name the endpoint asked but never the credential offered.
- `llm.discoverModels` carries the same draft over the wire. Its `apiKey` is the third and last payload on which a secret may ride, alongside `settings.update`/`mutate` and `credentials.set`, and it is never stored or echoed back. It does ride the client's outgoing envelope like every other secret-bearing payload, where a `subscribeEnvelopes()` observer can see it; redacting that tap is a configuration-plane-wide change, not this method's to make alone. Connection authenticates the method with the complete Host API: it makes the host issue a GET to a caller-chosen URL and reports the outcome, which an anonymous caller must not receive. Every refusal folds into `model-discovery-failed`, whose message is the adapter's own text and whose details name the endpoint asked but never the credential offered.
`dsh-llm-pi-ai` implements the wire path as a plain `GET {baseURL}/models`, reading `openai-completions` and `openai-responses`: their `GET /models` shape with bearer auth is the one a gateway, a self-hosted server, and the official endpoints all agree on. Azure is excluded despite its OpenAI lineage — it authenticates with an `api-key` header and requires an `api-version` query — and Codex uses OAuth; both would have reported an authentication failure as a provider with no models. Every other protocol answers `DISCOVERY_UNSUPPORTED`, so the surface falls back to hand-entry rather than reporting a guessed response shape as an empty provider. `baseURL` is treated as a prefix rather than a URL to resolve against, so a deployment path such as `https://gateway.example/openai/v1` keeps its segments. The reply is read under a four-megabyte ceiling enforced on the bytes actually received — the endpoint is a URL the user typed, so a declared `content-length` is checked first as a courtesy but never trusted as the bound, matching `dsh-web-fetch`'s two-stage shape for its own caller-supplied URLs.
@@ -19,7 +19,7 @@ Status: implemented
- `ctx.llm.registerModelDiscovery(settingsNs, discover)` 让适配器插件为自己拥有的 namespace 提供「询问端点」的能力,`ctx.llm.discoverModels(settingsNs, request)` 发起询问。没有任何办法枚举哪些 namespace 注册过:询问不了的界面会从那句拒绝里知道,而一份无人消费的列表只会变成一个什么都不做的必填协议字段。以 namespace 为键是对的,因为配置界面已经从可配置提供方目录里拿到了它,也因为正在新增的提供方没有路由可点名。
- `LlmModelDiscoveryRequest` 携带草稿——可选的 `provider`、可选的 `baseURL`、可选的 `api`、可选的 `apiKey`,以及一个 signal——且 `provider``baseURL` 至少要有一个,才有东西可答。`provider` 之所以存在,是因为适配器已经描述过的路由直接由它自己的注册表作答、完全不联网;只有它未描述的路由才会抵达某个端点。这条路径不写 settings 与 credentials。唯一的读取是请求所点名路由的凭据:配置界面拿到的是脱敏描述符而非已存的机密,因此草稿里的 `apiKey` 只在用户正键入时才存在;没有这次读取,已配置好的路由就会被不带认证地询问,只换回一个 401。键入的密钥优先,因为那正是被测试的那一把。
- `LlmDiscoveredModel``id` 外每个字段都可选,因为大多数列表只公布 id。回复是候选而非 catalog:采纳其中一条的界面仍要补上适配器所需的容量。
- `llm.discoverModels` 把同一份草稿送过协议层。它的 `apiKey` 是可承载机密的第三个、也是最后一个载荷(另两个是 `settings.update`/`mutate``credentials.set`),且绝不被存储或回显。它确实会像其他承载机密的载荷一样随客户端外发信封同行,`subscribeEnvelopes()` 观察者看得到;把那个抽头脱敏是整个配置面的改动,不该由这一个方法独自决定。除密钥之外,将它限制为仅可通过回环访问还有第二个理由:它让宿主向调用方选定的 URL 发起 GET 并回报结果,这是匿名 LAN 调用者不该拥有的探测能力。每一种拒绝都折叠为 `model-discovery-failed`,其消息是适配器自己的文本,details 点名被询问的端点,绝不点名所提供的凭据。
- `llm.discoverModels` 把同一份草稿送过协议层。它的 `apiKey` 是可承载机密的第三个、也是最后一个载荷(另两个是 `settings.update`/`mutate``credentials.set`),且绝不被存储或回显。它确实会像其他承载机密的载荷一样随客户端外发信封同行,`subscribeEnvelopes()` 观察者看得到;把那个抽头脱敏是整个配置面的改动,不该由这一个方法独自决定。Connection 用与完整 Host API 相同的会话认证该方法:它让宿主向调用方选定的 URL 发起 GET 并回报结果,匿名调用者绝不能获得这类探测能力。每一种拒绝都折叠为 `model-discovery-failed`,其消息是适配器自己的文本,details 点名被询问的端点,绝不点名所提供的凭据。
`dsh-llm-pi-ai` 的实现只是一次朴素的 `GET {baseURL}/models`,且仅限 OpenAI 兼容协议。它们的列表形状是网关、自建服务与官方端点三方一致认可的那一种,而这正是该动作存在的场景。其余协议一律以 `DISCOVERY_UNSUPPORTED` 回答,让界面回退到手工填写,而不是把猜错的响应形状报成一个空提供方。`baseURL` 按前缀而非待解析 URL 处理,因此 `https://gateway.example/openai/v1` 这类部署路径会保留其路径段。回复在四兆字节上限下读取,且上限落在实际收到的字节上——端点是用户自己填的 URL,因此会先看声明的 `content-length` 作为善意提示,但绝不把它当作边界;这与 `dsh-web-fetch` 面对自己的调用方提供 URL 时所用的两段式形状一致。
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md
2026-08-04-websocket-downlink-carrier.md: f757487c7b0880cfff25b93644341dc85b5e0e38
2026-08-04-websocket-downlink-carrier.zh.md: 5fa603b9a8dc66146c777f54dfbdc25cb131debd
2026-08-04-websocket-downlink-carrier.md: 420a0d30f31cca58448adc0afd46a5d6d5e9107f
2026-08-04-websocket-downlink-carrier.zh.md: 5f277a4ed33ffe97b51587fef960746b93eff1c1
@@ -16,7 +16,7 @@ WebSocket carries only the host→browser downlink. All client→host unary call
## Upgrade and lifecycle boundaries
`dsh-host-webserver` provides an exact upgrade-route registration point alongside ordinary routes, dispatches Node upgrade sockets by pathname only, contains raw-socket errors, and waits for surviving upgraded connections to close during server teardown; it knows nothing about Harness frames or WebSocket messages. `dsh-client-connection` owns the WebSocket handshake, frame output, and stream cancellation, and reuses the `/api` Host/Origin trust fence before upgrade. An untrusted authority or cross-origin Origin is rejected before `ctx.apiProxy.events.*` starts.
`dsh-host-webserver` provides an exact upgrade-route registration point alongside ordinary routes, dispatches Node upgrade sockets by pathname only, contains raw-socket errors, and waits for surviving upgraded connections to close during server teardown; it knows nothing about Harness frames or WebSocket messages. `dsh-client-connection` owns the WebSocket handshake, frame output, and stream cancellation. Before upgrade it applies the `/api` Host/Origin checks followed by the same signed browser-cookie authentication as unary HTTP. An untrusted authority or cross-origin Origin receives 403; a trusted but unauthenticated request receives 401; neither starts a Remote stream.
A browser abort or socket close cancels the corresponding host stream; plugin teardown also waits for that source iterator's cleanup. If a host stream throws midway, the carrier sends one existing `stream/error` frame and then closes the socket; the client treats that frame as connection loss rather than delivering it to a business sink. Each WebSocket reports open independently, and the existing readiness handshake still waits until mux and host are both open and the `host.describe` HTTP call has succeeded before publishing connected.
@@ -16,7 +16,7 @@ WebSocket 只承担 host→browser 下行。所有 client→host unary 调用和
## Upgrade 与生命周期边界
`dsh-host-webserver` 提供与普通 route 并列的精确 upgrade-route 注册点,只按 pathname 分发 Node upgrade socket,隔离原始 socket 错误,并在 server teardown 期间等待仍存活的升级连接关闭;它不认识 Harness 帧或 WebSocket 消息。`dsh-client-connection` 拥有 WebSocket handshake、frame 写出和流取消,并在 upgrade 前复用 `/api` HostOrigin 信任栅栏。未受信任的 authority 或跨来源 Origin 在 `ctx.apiProxy.events.*` 启动前即被拒绝
`dsh-host-webserver` 提供与普通 route 并列的精确 upgrade-route 注册点,只按 pathname 分发 Node upgrade socket,隔离原始 socket 错误,并在 server teardown 期间等待仍存活的升级连接关闭;它不认识 Harness 帧或 WebSocket 消息。`dsh-client-connection` 拥有 WebSocket handshake、frame 写出和流取消upgrade 前先执行 `/api` HostOrigin 校验,再执行与一元 HTTP 相同的签名浏览器 cookie 认证。未受信任的 authority 或跨来源 Origin 得到 403;Host 可信但未认证的请求得到 401;两者都不会启动 Remote stream
浏览器 abort 或 socket close 会取消对应的 host 流;插件 teardown 还会等待该 source iterator 完成清理。host 流中途抛错时,载体发送一个现有的 `stream/error` frame 后关闭 socket;客户端把该 frame 收敛为连接丢失,不投递给业务 sink。每条 WebSocket 独立报告 open,既有 readiness handshake 仍等待 mux、host 都 open 且 `host.describe` HTTP 调用成功后才发布 connected。
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-12-plugin-owned-settings-surface.md
2026-08-12-plugin-owned-settings-surface.md: daed91b8ac4ce0acb81e19908ec08c9f3f7ac21e
2026-08-12-plugin-owned-settings-surface.zh.md: ba39b84faaf90be413ad8d7b8327c67b2fa27cea
2026-08-12-plugin-owned-settings-surface.md: f1c9b48abd3459ed58e3e10fbdd95558884c9a22
2026-08-12-plugin-owned-settings-surface.zh.md: ff9f37290a8dd8f1ab9ef1bfef3790a3ef20086b
@@ -32,7 +32,7 @@ Keying makes absence the signal, and that is what removes the bookkeeping the pr
The gate did keep one thing off the wire, and this note states it plainly because the decision has to survive the accurate version: a registered namespace the list did not name never had its resolved, `base`, or `user` values reach the browser at all. The plugin inventory page is not a substitute — `PluginInventoryEntry` carries `entryId`, `moduleName`, `enabled`, and `fiberPhase`, and its "configuration" row renders an enabled/disabled tag, never a stored value.
What the gate was not is the boundary its position suggested. Every `settings.*` method sits in `PRIVILEGED_METHODS` (`packages/client/connection`), so a non-loopback or cross-origin request is refused with 403 before reaching this code; `role('secret')` fields are structurally stripped from every layer of every response; and the document the plane edits is the user's own `settings.yaml`, which the same settings page offers to open. The writes it did not block were also the consequential ones: `permission` (which can widen the approval preset) and `agent-presets` (which decides what a session mounts) were both already served.
What the gate was not is the security boundary its position suggested. Connection authenticates every Host API request before reaching this code; `role('secret')` fields are structurally stripped from every layer of every response; and the document the plane edits is the user's own `settings.yaml`, which the same settings page offers to open. The writes it did not block were also the consequential ones: `permission` (which can widen the approval preset) and `agent-presets` (which decides what a session mounts) were both already served.
So the exposure this change actually adds, in this repository, is one namespace: `agent-default-model`, whose two fields name a provider and a model and which no browser half renders. A future namespace whose values genuinely must not cross the wire is answered per field by `role('secret')` — finer than a namespace switch, and already enforced.
@@ -32,7 +32,7 @@ Status: implemented
这道门确实挡住了一样东西,本 note 如实写出,因为这个决策必须在准确版本下也站得住:不在名单上的已注册命名空间,其 resolved、`base``user` 值根本不会抵达浏览器。插件清单页不能替代它——`PluginInventoryEntry` 携带的是 `entryId``moduleName``enabled``fiberPhase`,它那一行「configuration」渲染的是启用/停用标签,从不是任何已存值。
这道门不是的,是它所处位置暗示的那种边界。每个 `settings.*` 方法都在 `PRIVILEGED_METHODS` 里(`packages/client/connection`),非回环或跨源请求在到达这段代码之前就以 403 被拒`role('secret')` 字段在每种响应的每一层都被结构性剥离;而这个面所编辑的文档,本就是用户自己的 `settings.yaml`,同一个设置页还提供了打开它的入口。它没有挡住的写入,恰恰是有分量的那些:`permission`(能放宽审批预设)与 `agent-presets`(决定一个会话挂载什么)本来就已被服务。
这道门不是的,是它所处位置暗示的那种安全边界。Connection 在到达这段代码前认证每个 Host API 请求`role('secret')` 字段在每种响应的每一层都被结构性剥离;而这个面所编辑的文档,本就是用户自己的 `settings.yaml`,同一个设置页还提供了打开它的入口。它没有挡住的写入,恰恰是有分量的那些:`permission`(能放宽审批预设)与 `agent-presets`(决定一个会话挂载什么)本来就已被服务。
因此本次改动在本仓库实际新增的暴露面是一个命名空间:`agent-default-model`——它的两个字段指明一个提供方与一个模型,且没有任何浏览器半侧渲染它。将来若某个命名空间的值确实不该跨越协议,由 `role('secret')` 逐字段作答:比整命名空间开关更精细,而且已经在执行。
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.md
2026-08-24-browser-token-authentication.md: fb55a35018b7c8df45213f9b9e59dccc3512c0ab
2026-08-24-browser-token-authentication.zh.md: 6cd77117395ff753d36d39da0ec247e064bdb393
@@ -0,0 +1,45 @@
# Agent Note: Browser launch-token authentication
Status: implemented
English | [中文](2026-08-24-browser-token-authentication.zh.md)
## Problem
The Web Host runs tool-capable Sessions with the current operating-system user's authority, but its HTTP interface identified privileged callers from request routing facts. In particular, the method-specific loopback list treated a loopback `Host` value as local authority even though an HTTP client controls that header. A caller that could reach the server could therefore name `localhost`, enter configuration methods, and use Host-side operations such as model discovery to disclose stored credentials. Binding the shipped CLI to loopback limits ordinary reachability but does not authenticate a request forwarded or otherwise delivered to that socket.
## Decision
`dsh-client-connection` authenticates the complete Host API before dispatch. Every API Proxy method, Remote unary call, generic Connection channel, and Remote WebSocket stream requires the same browser session; endpoint ownership and method names do not alter authority. The existing Host/Origin checks run first and retain their DNS-rebinding and cross-site-request role, returning 403 when they fail. A trusted Host without a valid browser session receives 401. The browser-trust rules remain owned by the [carrier-level browser trust decision](2026-07-28-api-browser-trust-boundary.md).
Each Connection process generates a random launch token. `dsh-web-app` prints and opens the normal root URL with that token in the query. `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. 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 or reverse-proxy-specific handling.
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 reads the record for each verification, so deletion or replacement revokes every existing cookie without restarting the process. A missing record is recreated only by a valid process-token exchange. 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 shipped CLI continues to reject `--host 0.0.0.0`. Authentication does not imply supported network deployment, TLS, forwarding-header interpretation, or proxy configuration.
## Verification
Unit coverage pins token comparison, cookie attributes, HMAC and payload validation, authority and lifetime checks, persistent-secret reuse, record deletion, and invalid durable records. Host transport suites pin uniform 401/403 behavior for API Proxy, generic RPC, Typert Remote HTTP, and WebSocket upgrade paths. The frontend real-composition test boots credentials, Connection, webserver, and static serving through Loader and proves token exchange before index reads while static assets remain public. A real-CLI test starts `dsh web` twice on one port with a temporary `DSH_HOME`, proves that forged `Host: localhost` is unauthenticated, calls `host.describe` with the exchanged cookie, observes a new process token, and reuses the old cookie after restart.
## Alternatives considered
**Determine privileged callers from the TCP peer address.** A direct peer address still identifies a local forwarding process rather than the browser user, retains a second authority model beside the API's command-execution capability, and requires proxy policy to answer who the original caller was. One application credential is the enforceable identity used for every operation.
**Keep a method-specific privileged list and restrict stored credentials to configured targets.** The list can omit new endpoints and does not constrain callers that already control a tool-capable Session. A `discoverModels` target rule would not form a security boundary because the same authenticated principal can update settings and run commands. Uniform authentication covers the operation that grants process control.
**Persist or accept the launch token as an API bearer.** A durable launch token would become a second long-lived credential, while Authorization-header support would add a non-browser client contract with no current consumer. The process token performs one browser-cookie exchange only.
**Rotate the signing secret on every restart.** This prevents an existing browser from reconnecting after an ordinary DSH restart. Persisting only the signing secret keeps that workflow while process-token rotation limits the startup URL to one process lifetime.
**Add logout, TLS-proxy, and forwarding-header configuration.** None is required by the loopback Web application or the reported authentication gap. Adding them would define deployment contracts without current consumers. Browser site-data controls and credential-record deletion provide the two revocation operations this decision needs.
## Consequences
Possession of the browser cookie authorizes the complete tool-capable Host API, matching the authority the Web application already exposes after Session creation. `Host` no longer grants a higher method tier, and a method migration between API Proxy and Typert Remote cannot change its caller set.
The persistent secret makes cookies survive restarts but gives a stolen cookie up to the configured absolute lifetime; deletion or rotation of the record is the global revocation mechanism. Omitting `Secure` preserves loopback HTTP and permits plaintext transmission if an operator makes the same cookie authority reachable over an unencrypted network. The startup URL contains a process credential and must be treated as sensitive output; runtime diagnostics do not repeat it.
The decision partially supersedes the authentication deferral and unauthenticated non-loopback consequences in the [browser trust note](2026-07-28-api-browser-trust-boundary.md). That note remains active authority for media-type, Host, Origin, Fetch-Metadata, and configured-authority validation. No active Agent Note is archived: the overlap is partial and both security rules retain future decision value.
@@ -0,0 +1,45 @@
# Agent Note: 浏览器启动令牌认证
Status: implemented
[English](2026-08-24-browser-token-authentication.md) | 中文
## 问题
Web Host 以当前操作系统用户的权限运行具有工具能力的 Session,但其 HTTP 接口用请求路由事实识别特权调用者。具体而言,按方法维护的 loopback 列表把 loopback `Host` 值视为本地 authority,尽管 HTTP 客户端可以控制该 header。能够到达服务器的调用者因此可以声明 `localhost`、进入配置方法,再利用模型发现等 Host 侧操作披露存储的凭据。随附 CLI 绑定 loopback 可以限制普通可达性,却不能认证被转发或以其他方式送达该 socket 的请求。
## 决策
`dsh-client-connection` 在分发前认证完整 Host API。每个 API Proxy 方法、Remote 一元调用、通用 Connection channel 和 Remote WebSocket stream 都要求同一个浏览器会话;endpoint 所有权与方法名称不改变 authority。既有 Host/Origin 校验先执行,继续负责 DNS rebinding 和跨站请求防御,失败时返回 403。Host 可信但没有有效浏览器会话时返回 401。浏览器信任规则仍由[载体级浏览器信任决策](2026-07-28-api-browser-trust-boundary.zh.md)持有。
每个 Connection 进程生成随机启动令牌。`dsh-web-app` 打印并打开 query 中带该令牌的普通根 URL。`frontend-static` 请求 Connection 授权 index 响应:只有 `GET /?token=...` 会把进程令牌交换为 cookie,再重定向到干净的 `/`API 路径和 Authorization header 都不接受该令牌。缺失与无效凭据得到同一份最小 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 操作或反向代理专用处理。
HMAC 密钥是 `ctx.credentials` 中位于 `client-connection/browser-session` 的版本化 `grant` 记录;本地提供方将其存入 `$DSH_HOME/.credentials.yaml`。Connection 每次校验都读取记录,因此删除或替换记录无需重启进程即可撤销全部既有 cookie。缺失记录只能由有效进程令牌交换重新创建。无效 owner payload 会明确失败,而不是被覆盖。启动令牌本身绝不持久化并在每次进程启动时变化;未过期 cookie 则能在相同 authority 上跨重启继续有效。
随附 CLI 继续拒绝 `--host 0.0.0.0`。认证不代表支持网络部署、TLS、转发 header 解释或代理配置。
## 验证
单元覆盖固定令牌比较、cookie 属性、HMAC 与 payload 校验、authority 与有效期校验、持久密钥复用、记录删除及无效持久记录。Host 传输套件固定 API Proxy、通用 RPC、Typert Remote HTTP 和 WebSocket upgrade 路径上一致的 401/403 行为。frontend 真实组合测试经 Loader 启动 credentials、Connection、webserver 与静态服务,证明读取 index 前完成令牌交换,同时静态资产仍公开。真实 CLI 测试在临时 `DSH_HOME` 上用同一端口两次启动 `dsh web`,证明伪造 `Host: localhost` 仍未认证,以交换所得 cookie 调用 `host.describe`,观测新的进程令牌,并在重启后复用旧 cookie。
## 曾考虑的替代方案
**从 TCP 对端地址判定特权调用者。** 直接对端地址仍可能只识别本地转发进程,而非浏览器用户;它会在 API 的命令执行能力旁保留第二套 authority 模型,并要求代理策略回答原始调用者是谁。一个应用凭据才是每项操作都能执行的身份。
**保留按方法的特权列表,并把存储凭据限制在已配置目标。** 列表可能漏掉新 endpoint,也不能约束已经控制工具型 Session 的调用者。`discoverModels` 目标规则不构成安全边界,因为同一已认证主体可以更新 settings 并运行命令。统一认证覆盖授予进程控制权的操作。
**持久化启动令牌或把它作为 API bearer 接受。** 持久启动令牌会成为第二份长期凭据;Authorization header 支持则会增加没有当前 consumer 的非浏览器客户端约定。进程令牌只完成一次浏览器 cookie 交换。
**每次重启都轮换签名密钥。** 这会阻止既有浏览器在普通 DSH 重启后重连。只持久化签名密钥既保留该工作流,又由进程令牌轮换把启动 URL 限定在一个进程生命周期。
**增加 logout、TLS 代理和转发 header 配置。** loopback Web 应用与已报告认证缺口都不需要这些能力;加入它们会在没有当前 consumer 时定义部署约定。浏览器站点数据控制与凭据记录删除已经提供本决策所需的两种撤销操作。
## 后果
持有浏览器 cookie 就能调用完整的工具型 Host API,这与 Web 应用在创建 Session 后本就暴露的 authority 一致。`Host` 不再授予更高的方法层级,方法在 API Proxy 与 Typert Remote 之间迁移也不会改变调用者集合。
持久密钥使 cookie 跨重启生效,也让被盗 cookie 最多保有配置的绝对有效期;删除或轮换记录是全局撤销机制。不设置 `Secure` 保留 loopback HTTP,但如果操作者让同一 cookie authority 经未加密网络可达,cookie 会以明文传输。启动 URL 含进程凭据,必须视为敏感输出;运行时诊断不会重复它。
本决策部分取代[浏览器信任说明](2026-07-28-api-browser-trust-boundary.zh.md)中的认证延期与未认证非 loopback 后果。该说明仍是媒体类型、Host、Origin、Fetch-Metadata 和配置 authority 校验的有效权威。没有 active Agent Note 被归档:重叠只发生在局部,两条安全规则都保有未来决策价值。
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md
2026-08-06-host-backed-web-preferences.md: 2e33d05417bf6c347a57b5c0b6c7281ff1392b5b
2026-08-06-host-backed-web-preferences.zh.md: 79c15af723347b92fb0b0a15ccb8de80841dd3b0
2026-08-06-host-backed-web-preferences.md: 83d4b87f35e2bb3710ecea60d64a8c800ce5df08
2026-08-06-host-backed-web-preferences.zh.md: ead4a0d27a3bf94f6ef3ffc2915ba721c8379075
@@ -12,13 +12,13 @@ The first theme implementation moved only Appearance to Host settings but awaite
## Decision
The owning Host halves register three schemas: optional `locale.preference` (`zh` or `en`, where absence delegates to the browser), `ui-theme.preference` (`light`, `dark`, or `system`, default `system`), and `ui-conversation.busyEnter` (`queue` or `steer`, default `queue`). The local settings provider stores explicit choices in `$DSH_HOME/settings.yaml`, which resolves to `~/.dsh/settings.yaml` under the default home. The API proxy serves every registered namespace to a loopback client; field roles still redact secrets.
The owning Host halves register three schemas: optional `locale.preference` (`zh` or `en`, where absence delegates to the browser), `ui-theme.preference` (`light`, `dark`, or `system`, default `system`), and `ui-conversation.busyEnter` (`queue` or `steer`, default `queue`). The local settings provider stores explicit choices in `$DSH_HOME/settings.yaml`, which resolves to `~/.dsh/settings.yaml` under the default home. The API proxy serves every registered namespace behind Connection's browser authentication; field roles still redact secrets.
`dsh-client-ui-settings` owns one browser-wide settings describe mirror and provides `ctx.settingsScope.bind(spec)` as a per-namespace selector over it. The mirror installs `settings/document-updated` and `connection/reset` listeners before starting its background read, so no settings transport can block plugin activation and an invalidation cannot fall into a read-before-subscribe gap. Each bound scope publishes a snapshot store (status, section value, revision, writability, host/memory mode) the domain service subscribes to, without adding a wire read or listener of its own. The default decoder validates each incoming section against the namespace's own serialized wire schema, rehydrated through the colocated `ctx.settingsSchema` service, so domains carry no hand-written wire guards. Domain services take the scope as an ordinary constructor collaborator, publish their provisional defaults immediately—browser-derived locale, system theme, and Queue—then adopt an accepted Host section without writing it back; a service constructed without a scope (standalone dictionary or policy fixtures) simply stays process-local. The shared read and invalidation lifecycle is specified by the later [settings describe mirror decision](../architecture/2026-08-17-settings-describe-mirror.md).
User changes update the live service synchronously and queue a `settings.mutate` path operation through `scope.set`. The scope serializes gestures, sends the latest known namespace revision as `expectedRevision`, records every successful revision, and lets only the latest write settlement republish live state. A rejected or failed latest write reloads Host state. Disposal rejects new work, skips queued operations, suppresses publication by the in-flight operation, and waits for that operation to settle before the plugin reaches quiescence.
Remote browsers cannot call the loopback-only configuration API, so their preferences remain process-local. Dynamic third-party theme ids remain in-process extensions outside the built-in Host schema; removing one resets the live registry without replacing the last durable built-in preference.
The Client keeps Host persistence disabled on non-loopback pages, so their preferences remain process-local even though Connection authenticates the complete API. Dynamic third-party theme ids remain in-process extensions outside the built-in Host schema; removing one resets the live registry without replacing the last durable built-in preference.
## Alternatives considered
@@ -12,13 +12,13 @@ Web 的 Appearance、Language 和繁忙态 Enter 偏好原本存在浏览器 `lo
## 决策
各领域所属的 Host half 注册三份 schema:可选的 `locale.preference``zh``en`,缺失时交由浏览器决定)、`ui-theme.preference``light``dark``system`,默认为 `system`),以及 `ui-conversation.busyEnter``queue``steer`,默认为 `queue`)。本地 settings 提供方将显式选择存入 `$DSH_HOME/settings.yaml`,在使用默认 home 时,该路径解析为 `~/.dsh/settings.yaml`。API 代理会向回环客户端服务每一个已注册的 namespace;字段角色仍会脱敏机密值。
各领域所属的 Host half 注册三份 schema:可选的 `locale.preference``zh``en`,缺失时交由浏览器决定)、`ui-theme.preference``light``dark``system`,默认为 `system`),以及 `ui-conversation.busyEnter``queue``steer`,默认为 `queue`)。本地 settings 提供方将显式选择存入 `$DSH_HOME/settings.yaml`,在使用默认 home 时,该路径解析为 `~/.dsh/settings.yaml`。API 代理在 Connection 浏览器认证之后服务每一个已注册的 namespace;字段角色仍会脱敏机密值。
`dsh-client-ui-settings` 持有一个浏览器全局的 settings describe 镜像,并提供 `ctx.settingsScope.bind(spec)` 作为该镜像上的逐 namespace selector。镜像在开始后台读取之前安装 `settings/document-updated``connection/reset` 监听器,因此任何 settings 传输都不会阻塞插件激活,失效通知也不会掉入先读取、后订阅的空档。每个绑定的 scope 会发布一个供领域服务订阅的快照 store(状态、分节值、revision、可写性、host/内存模式),自身不再增加协议读取或监听器。默认解码器会对照该 namespace 自身的序列化 wire schema(经同包的 `ctx.settingsSchema` 服务还原)校验每个传入分节,因此各领域无需携带手写的 wire 校验器。领域服务把 scope 当作普通的构造函数协作者接收,立即发布各自的暂定默认值:由浏览器派生的 locale、系统主题和 Queue;随后采纳已获接受的 Host 分节,但不将其写回;不带 scope 构造的服务——独立词典或政策 fixture(测试前置数据)——则仅停留在进程本地。共享读取与失效生命周期由后续的 [settings describe 镜像决策](../architecture/2026-08-17-settings-describe-mirror.zh.md)规定。
用户变更会同步更新实时服务,并经 `scope.set` 将一项 `settings.mutate` 路径操作排入队列。scope 会串行处理手势,以最新已知 namespace revision 作为 `expectedRevision` 发送,记录每次成功写入的 revision,并且只允许最新写入的结算结果重新发布实时状态。最新写入被拒或失败时,scope 会重新加载 Host 状态。插件释放会拒绝新工作、跳过已排队操作、抑制运行中操作发布状态,并等待该操作结算后才让插件达到完全停稳。
远程浏览器无法调用仅限回环请求的配置 API,因此其偏好仅保留在进程内。动态第三方主题 id 仍是内置 Host schema 之外的进程内扩展;移除其中一个会重置实时注册表,但不会替换上一个持久化的内置偏好。
Client 在非 loopback 页面禁用 Host 持久化,因此这些页面的偏好仍只保留在进程内,尽管 Connection 认证完整 API。动态第三方主题 id 仍是内置 Host schema 之外的进程内扩展;移除其中一个会重置实时注册表,但不会替换上一个持久化的内置偏好。
## 曾考虑的替代方案
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-22-web-bind-address.md
2026-07-22-web-bind-address.md: 3332176c0cee940648ad334a44edd30879225503
2026-07-22-web-bind-address.zh.md: 5ad73b8916d9cedce1073f4a2d025f40197b7d65
2026-07-22-web-bind-address.md: 68275d220f2270ef924efdbc6a72f3dfcbfd10f8
2026-07-22-web-bind-address.zh.md: 5bb4fdbbc3e58285b0d227c3ec376786be2782da
@@ -6,15 +6,15 @@ English | [中文](2026-07-22-web-bind-address.zh.md)
## Problem
`dsh web` binds every network interface even when its browser runs on the same machine. Local use therefore exposes an unauthenticated development server without an explicit operator choice, while remote-container and LAN-browser use still needs a supported way to accept non-loopback connections.
The Web application can run commands with the Host user's authority. Same-machine use needs only loopback reachability, while an all-interface CLI mode would imply a supported network deployment without TLS or a defined proxy contract.
The HTTP carrier also hides the bind address inside `startWebServer()`, so alternate shells cannot state their own network policy at the package boundary.
## Decision
`dsh web` binds `127.0.0.1` by default. The CLI accepts `--host 0.0.0.0` as the explicit all-interface mode and rejects other values so its network modes remain a small, deliberate contract. All-interface mode keeps printing the loopback URL and, when available, the first external IPv4 URL.
`dsh web` binds `127.0.0.1` and rejects `--host 0.0.0.0`; the CLI exposes no network mode. The process-token and browser-cookie authentication does not broaden that deployment contract ([decision](../architecture/2026-08-24-browser-token-authentication.md)).
`WebServerOptions.host` is required. The HTTP carrier passes that value to `node:http` without supplying a fallback, leaving each shell responsible for its bind policy. Programmatic carrier consumers may select another hostname or address directly.
`WebServer` still requires `host: '127.0.0.1' | '0.0.0.0'` and passes it to `node:http` without a fallback. The generic carrier leaves custom composition policy visible at its package interface; the product CLI owns the stricter loopback choice.
## Alternatives considered
@@ -22,8 +22,10 @@ The HTTP carrier also hides the bind address inside `startWebServer()`, so alter
**Use a boolean exposure flag.** Rejected because `--host 0.0.0.0` names the resulting socket behavior directly and matches the underlying server option without introducing a second term.
**Keep an explicit `--host 0.0.0.0` mode.** Rejected because authentication alone does not supply TLS, forwarding semantics, or a supported remote-deployment contract for the tool-capable Host.
**Default inside `startWebServer()`.** Rejected because the carrier has multiple possible shells and no basis for choosing their deployment policy. Requiring `host` makes the choice visible at every assembly call.
## Consequences
Local `dsh web` starts remain reachable at `http://127.0.0.1:3080`; a browser on another machine must opt in with `dsh web --host 0.0.0.0`. The CLI does not yet expose custom interface addresses or IPv6 modes, while programmatic carrier consumers retain that flexibility. Server tests pin both loopback and all-interface forwarding into the Node listen boundary, and the web smoke continues to exercise the default CLI path.
Local `dsh web` starts remain reachable at `http://127.0.0.1:3080`. The CLI exposes no custom interface, all-interface, or IPv6 mode; custom WebServer compositions retain the carrier's two-address choice and own every consequence. Server tests pin both carrier values into Node listen, while CLI tests pin rejection of the all-interface flag.
@@ -6,15 +6,15 @@ Status: implemented
## 问题
即便浏览器与服务器运行在同一台机器上,`dsh web` 也会绑定所有网络接口。因此,本地使用会在操作者未明确选择的情况下暴露一个未经身份验证的开发服务器;另一方面,远程容器和局域网浏览器场景仍需要一种受支持的方式来接受非环回连接
Web 应用可以用 Host 用户的 authority 运行命令。同机使用只需要 loopback 可达性;CLI 若提供全接口模式,就会在没有 TLS 或明确代理约定时暗示支持网络部署
HTTP 承载层还把绑定地址隐藏在 `startWebServer()` 内部,导致其他壳层无法在包边界明确表达自己的网络策略。
## 决策
`dsh web` 默认绑定 `127.0.0.1`。CLI(命令行界面)接受 `--host 0.0.0.0` 作为显式启用的全接口模式,并拒绝其他取值,使网络模式保持为一份规模小、经过审慎限定的约定。全接口模式仍然输出本机环回 URL,并在可用时输出第一个外部 IPv4 URL
`dsh web` 绑定 `127.0.0.1` 并拒绝 `--host 0.0.0.0`;CLI 不开放网络模式。进程令牌与浏览器 cookie 认证不扩大该部署约定([决策](../architecture/2026-08-24-browser-token-authentication.zh.md)
`WebServerOptions.host` 为必填项。HTTP 承载层将该值直接传给 `node:http`,不提供回退值,因此每个壳层负责制定自己的绑定策略。以编程方式使用承载层的消费方可以直接选择其他主机名或地址
`WebServer` 仍要求 `host: '127.0.0.1' | '0.0.0.0'`,并在没有 fallback 的情况下传给 `node:http`。通用承载层让自定义组合策略显式留在包接口上;产品 CLI 持有更严格的 loopback 选择
## 曾考虑的替代方案
@@ -22,8 +22,10 @@ HTTP 承载层还把绑定地址隐藏在 `startWebServer()` 内部,导致其
**使用布尔型暴露标志。** 不予采纳,因为 `--host 0.0.0.0` 直接说明最终的套接字行为,并与底层服务器选项一致,无需再引入第二套术语。
**保留显式 `--host 0.0.0.0` 模式。** 不予采纳,因为仅有认证并不能为工具型 Host 提供 TLS、转发语义或受支持的远程部署约定。
**在 `startWebServer()` 内设置默认值。** 不予采纳,因为承载层可能由多种壳层调用,没有依据替它们选择部署策略。要求传入 `host`,可使每次装配调用都明确作出这一选择。
## 后果
`dsh web` 的本地启动仍可通过 `http://127.0.0.1:3080` 访问;其他机器上的浏览器必须使用 `dsh web --host 0.0.0.0` 显式启用。CLI 尚未开放自定义接口地址或 IPv6 模式,而以编程方式使用承载层的消费方仍保留这种灵活性。服务器测试将环回模式和全接口模式向 Node 监听边界的传递固定为约定,Web 冒烟测试继续覆盖默认 CLI 路径
`dsh web` 的本地启动仍可通过 `http://127.0.0.1:3080` 访问。CLI 开放自定义接口、全接口或 IPv6 模式;自定义 WebServer 组合保留承载层的两个地址选择并自行承担全部后果。服务器测试固定两个承载值都会进入 Node listen,CLI 测试则固定全接口 flag 被拒绝
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.md
2026-07-28-tool-call-file-open-in-os.md: 08fc51cc3a5d2fb43b67dc158fd1ee789fafdb68
2026-07-28-tool-call-file-open-in-os.zh.md: c99a99dcd91a8cfabbbc381af79bb3570f4295f6
2026-07-28-tool-call-file-open-in-os.md: e6e590b2a97654b8b68d5a9842de10818f544088
2026-07-28-tool-call-file-open-in-os.zh.md: eb600a69cb2a2c3cc0d7463519d3de4dce76047b
@@ -23,7 +23,7 @@ File-tool path summaries (`read` / `write` / `edit` args carrying `path` or `fil
## Consequences
Clicking a file path in a tool row opens that path on the host. Non-file tool rows are inert summaries (expand toggles remain where the row already supported them). Remote or non-loopback clients cannot invoke `host.openPath`. A Host or OS refusal is owned by the chat view: it shows the thrown reason and retries the same path ([file-open failure](../bug-fix/2026-08-18-tool-row-file-open-failure.md)).
Clicking a file path in a tool row opens that path on the host. Non-file tool rows are inert summaries (expand toggles remain where the row already supported them). The Client withholds `host.openPath` on non-loopback pages; every exposed Host invocation still requires the browser session. A Host or OS refusal is owned by the chat view: it shows the thrown reason and retries the same path ([file-open failure](../bug-fix/2026-08-18-tool-row-file-open-failure.md)).
## Risks
@@ -12,7 +12,7 @@ Status: implemented
文件工具的路径摘要(`read``write``edit` 参数中的 `path``file_path`)渲染为静止状态下即带下划线的链接,并使用 pointer 光标。点击路径会经 `WorkspaceRuntime.openPath` 调用 `host.openPath`,相对路径以会话 cwd 为基准解析。带文件链接的行关闭参数展开(左侧图标不可点);工具行(含 bash 与 todo 注册)去掉整行点击、整行悬停底色,以及点击打开 details 的手势。details 面板及其 inject 面仍保留供程序化选择;工具行不再驱动它们。
`host.openPath`特权一元 RPC仅接受来自回环地址且同源的浏览器请求(与 `host.pickDirectory` 相同的载体守卫)。平台适配器不经 shell 打开:macOS 为 `open`Windows 为 PowerShell `Invoke-Item`,桌面 Linux 为 `xdg-open`;浏览器可渲染的文档会在 macOS 与桌面 Linux 上优先使用指定的默认浏览器。尽管 Node 将 WSL 报告为 `linux`,WSL 仍是一种独立的宿主形态:适配器根据其环境或 Microsoft 内核 release 识别它,用 `wslpath -w` 转换 Linux 路径,并将所得 Windows/UNC 路径交给同一 PowerShell 交接。打开器的平台信息和命令运行器可在测试中注入。仅含 URL 的 read 参数(`web_fetch`)不是文件链接。
`host.openPath` 是一元 RPC与每个 Host API 方法一样要求通过 Host/Origin 校验和浏览器会话认证。平台适配器不经 shell 打开:macOS 为 `open`Windows 为 PowerShell `Invoke-Item`,桌面 Linux 为 `xdg-open`;浏览器可渲染的文档会在 macOS 与桌面 Linux 上优先使用指定的默认浏览器。尽管 Node 将 WSL 报告为 `linux`,WSL 仍是一种独立的宿主形态:适配器根据其环境或 Microsoft 内核 release 识别它,用 `wslpath -w` 转换 Linux 路径,并将所得 Windows/UNC 路径交给同一 PowerShell 交接。打开器的平台信息和命令运行器可在测试中注入。仅含 URL 的 read 参数(`web_fetch`)不是文件链接。
## 考虑过的替代方案
@@ -23,7 +23,7 @@ Status: implemented
## 后果
点击工具行中的文件路径会在宿主上打开该路径。非文件工具行只是不可交互的摘要(行内已有的展开开关仍保留)。远程或非回环客户端无法调用 `host.openPath`。Host 或操作系统拒绝由聊天视图拥有:它展示抛出的原因,并对同一路径提供重试([打开失败](../bug-fix/2026-08-18-tool-row-file-open-failure.zh.md))。
点击工具行中的文件路径会在宿主上打开该路径。非文件工具行只是不可交互的摘要(行内已有的展开开关仍保留)。Client 在非 loopback 页面不提供 `host.openPath`;每次已暴露的 Host 调用仍要求浏览器会话。Host 或操作系统拒绝由聊天视图拥有:它展示抛出的原因,并对同一路径提供重试([打开失败](../bug-fix/2026-08-18-tool-row-file-open-failure.zh.md))。
## 风险
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-13-shared-modal-product-onboarding.md
2026-08-13-shared-modal-product-onboarding.md: ec3d3e5f112b04736a15645c6e62e942367bb4fa
2026-08-13-shared-modal-product-onboarding.zh.md: 78b17d02dba52170722042126cc5616e9272c722
2026-08-13-shared-modal-product-onboarding.md: 9f72ee5b4e2abd8dd9ad70c819976be73f3f8116
2026-08-13-shared-modal-product-onboarding.zh.md: 867b23202f2e15701d72dd5e11cbe6ff2346ef11
@@ -14,7 +14,7 @@ First-run onboarding mixed two interaction models: a viewport takeover for produ
**Both steps share one modal component.** `OnboardingModal` wraps the existing ui-primitives `Modal`, supplies the common title and content geometry, and owns `#root` inert for exactly the visible lifetime. Escape and mask clicks do not silently complete mandatory onboarding; each step exposes only its explicit actions. A step still loading private facts returns `null`, so it paints and blocks nothing.
**The welcome notice reuses the existing durable field.** Its exact copy and version live in `onboarding-copy.ts`. Loopback clients compare and write `ui-onboarding.welcomeNoticeVersion` through the existing settings API, and only Continue acknowledges the current version. Remote clients retain the existing process-local fallback because the settings namespace is loopback-only. No Host schema, API-proxy allowlist, or persistence implementation changes.
**The welcome notice reuses the existing durable field.** Its exact copy and version live in `onboarding-copy.ts`. Loopback clients compare and write `ui-onboarding.welcomeNoticeVersion` through the existing settings API, and only Continue acknowledges the current version. Non-loopback pages retain the existing process-local fallback because the Client keeps Host settings persistence disabled there. No Host schema, API-proxy allowlist, or persistence implementation changes.
**The credential dialog reuses the existing editor and write boundary.** The Models join still decides whether any provider is usable. When the official DeepSeek reference is writable and missing, `ProviderEditor` renders in credential-only mode inside the shared modal. It validates the key and calls the existing `credentials.set`; it does not mutate provider settings. Save and continue waits for the write and refreshed readiness, while Configure later completes only the current coordinator pass.
@@ -14,7 +14,7 @@ Status: implemented
**两个步骤共用同一个弹窗组件。** `OnboardingModal` 包装既有 ui-primitives `Modal`,提供统一的标题和内容布局,并只在可见期间持有 `#root` 的 inert 状态。Escape 和遮罩点击不会静默完成强制引导;每个步骤只暴露自己的明确操作。步骤仍在加载私有事实时返回 `null`,因此不会绘制或阻塞界面。
**欢迎声明复用既有持久化字段。** 完整文案与版本由 `onboarding-copy.ts` 持有。回环客户端通过既有 settings API 比较和写入 `ui-onboarding.welcomeNoticeVersion`,且只有点击「继续」才确认当前版本。远程客户端继续使用既有的进程内回退,因为该 settings namespace 仅限回环访问。不改变 Host schema、API Proxy 允许列表或持久化实现。
**欢迎声明复用既有持久化字段。** 完整文案与版本由 `onboarding-copy.ts` 持有。回环客户端通过既有 settings API 比较和写入 `ui-onboarding.welcomeNoticeVersion`,且只有点击「继续」才确认当前版本。非 loopback 页面继续使用既有的进程内回退,因为 Client 在那里禁用 Host settings 持久化。不改变 Host schema、API Proxy 允许列表或持久化实现。
**凭据弹窗复用既有编辑器与写入边界。** Models 联接仍负责判断是否已有任意可用提供方。当 DeepSeek 官方引用可写但缺失时,`ProviderEditor` 以仅凭据模式渲染在共用弹窗中。它校验密钥并调用既有 `credentials.set`,不会修改提供方设置。「保存并继续」会等待写入与就绪状态刷新;「稍后配置」只完成协调器当前这一轮。
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.md
2026-08-08-copy-only-preset-authoring.md: c16518b087c7acedbee3d89ce5cc8dbcaa0a0cde
2026-08-08-copy-only-preset-authoring.zh.md: 63a184f5825530a2c99a26b8afa606412decab6e
2026-08-08-copy-only-preset-authoring.md: bfe0d49755abf47314a7b4cf56c738537fca3963
2026-08-08-copy-only-preset-authoring.zh.md: fc2d9ac5c6ace45c46fc920e1f7f28aca508ba9c
@@ -14,7 +14,7 @@ Authoring is a host-side copy, and files are the editor. `agentPreset.write` bec
## Consequences
- No composition text and no path crosses the browser wire in either authoring direction; the `entryListSchema`/`!!js` concern dissolves with `assertComposition` itself (deleted). The privileged set is now `read`/`copy`/`openDocument`/`remove` — none accepts a filesystem target.
- No composition text and no path crosses the browser wire in either authoring direction; the `entryListSchema`/`!!js` concern dissolves with `assertComposition` itself (deleted). The authoring operations are `read`/`copy`/`openDocument`/`remove` — none accepts a filesystem target, and Connection authenticates them with the complete Host API.
- With the editor gone, hand-editing `agent.cordis.yml` is the ONLY composition edit, so the standing-mount layer grew stamp-keyed generations: `ensureStanding` compares the file's mtime+size and starts the next generation for later sessions ([standing-mounts note](../architecture/2026-08-08-per-preset-standing-mounts.md), updated in place). Without this, an edited file would serve stale compositions until process restart.
- A copy is a full snapshot that drifts from an upgraded shipped source — accepted; the preset layer has no patch semantics (that is the bundle layer's `cordis.patch.yml`), and the shipped set itself pays the same cost (`cordis`/`code` are full copies of `standard`) for one-file readability.
- `read` dropped `writable` (no editor to gate) and builtin directories are never opened (`openDocument` refuses non-`user` trust like `remove`): the install is overwritten by upgrades, and pointing an editor into it invites edits an upgrade silently discards.
@@ -22,7 +22,7 @@ Authoring is a host-side copy, and files are the editor. `agentPreset.write` bec
## Load-bearing details
- **Copy target refusal is two checks on purpose.** The roster check refuses any id a root supplies — a user directory named like a shipped preset would be shadowed, so "create" would land a file nothing ever lists; the disk check (`PresetExistsError` before `cp` with `errorOnExist` as the race backstop) refuses a directory occupying the name without being a preset, which discovery cannot see.
- **The revealed path is response-direction disclosure, loopback-pinned.** The invariant "no browser payload can select an arbitrary filesystem target" is about the request direction; showing the resolved directory to the loopback user is the fallback the plan requires. It never rides the unprivileged `list`.
- **The revealed path is response-direction disclosure, browser-authenticated.** The invariant "no browser payload can select an arbitrary filesystem target" is about the request direction; showing the resolved directory to the authenticated browser is the fallback the plan requires. It never rides `list`.
- **The e2e lane pins `nativeOpen: false`** (`agent-preset-authoring.overlay.yml`) — both so goldens render the same branch on macOS dev and headless Linux CI, and so test runs never pop a real file manager. The revealed directory is tokenized as `{{presetRoot}}` by the lane itself, since `normalizeAria` only knows the workspace cwd.
## Alternatives considered
@@ -14,7 +14,7 @@ agent-preset 设置页带着一个网页 YAML 编辑器:`agentPreset.write`
## 后果
- 创作两个方向都不再有组装文本或路径跨越浏览器传输层;`entryListSchema`/`!!js` 的顾虑随 `assertComposition` 本身(已删除)一并消解。特权集现为 `read`/`copy`/`openDocument`/`remove`——没有一个接收文件系统目标。
- 创作两个方向都不再有组装文本或路径跨越浏览器传输层;`entryListSchema`/`!!js` 的顾虑随 `assertComposition` 本身(已删除)一并消解。创作操作现为 `read`/`copy`/`openDocument`/`remove`——没有一个接收文件系统目标,且 Connection 用完整 Host API 的同一会话认证它们
- 编辑器移除后,手改 `agent.cordis.yml` 成为唯一的组装编辑方式,因此常驻挂载层增加了以 stamp 为键的代际:`ensureStanding` 比对文件的 mtime+大小,为后续会话开启下一代际([常驻挂载 note](../architecture/2026-08-08-per-preset-standing-mounts.zh.md),已就地更新)。没有它,改过的文件要等进程重启才生效。
- 副本是完整快照,会随随附来源升级而漂移——接受;preset 层没有 patch 语义(那是 bundle 层 `cordis.patch.yml` 的能力),随附集合自己也为「一个文件读完整份组装」付了同样的代价(`cordis`/`code` 就是 `standard` 的完整副本)。
- `read` 去掉了 `writable`(没有编辑器可门控),内置目录绝不被打开(`openDocument``remove` 一样拒绝非 `user` 信任):安装目录会被升级覆盖,把编辑器指向它等于招揽会被升级悄悄丢弃的编辑。
@@ -22,7 +22,7 @@ agent-preset 设置页带着一个网页 YAML 编辑器:`agentPreset.write`
## 关键实现细节
- **复制目标的拒绝刻意分两道检查。** roster 检查拒绝任一根目录提供的 id——与随附 preset 同名的用户目录会被遮蔽,「创建」只会落下一个永远不被列出的文件;磁盘检查(`cp` 之前的 `PresetExistsError``errorOnExist` 作竞态兜底)拒绝占着名字却不是 preset 的目录,那是 discovery 看不见的。
- **展示的路径是响应方向的披露,且钉在环回**「没有任何浏览器载荷能选中任意文件系统目标」这条不变量说的是请求方向;把解析出的目录展示给环回用户正是方案要求的降级。它绝不搭乘非特权的 `list`
- **展示的路径是响应方向的披露,且经过浏览器认证**「没有任何浏览器载荷能选中任意文件系统目标」这条不变量说的是请求方向;把解析出的目录展示给已认证浏览器正是方案要求的降级。它绝不搭乘 `list`
- **e2e lane 钉死 `nativeOpen: false`**`agent-preset-authoring.overlay.yml`)——既让 golden 在 macOS 开发机与无头 Linux CI 上渲染同一分支,也让测试运行永不弹出真实文件管理器。揭示的目录由 lane 自己 token 化为 `{{presetRoot}}`,因为 `normalizeAria` 只认识 workspace cwd。
## 考虑过的替代方案
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/proposed/architecture/2026-08-10-unary-apiproxy-remote-migration.md
2026-08-10-unary-apiproxy-remote-migration.md: c4a308e16bee994df69f16228e859dd88d90c346
2026-08-10-unary-apiproxy-remote-migration.zh.md: db263e1a7843963cf38082a88405453ee4b66af7
2026-08-10-unary-apiproxy-remote-migration.md: cd019ef10c6d584a98b185ac50856a3fe63b8bd0
2026-08-10-unary-apiproxy-remote-migration.zh.md: e6842cc82cfd7f850bb58e9d640ec23394287427
@@ -12,7 +12,7 @@ Moving a method mechanically is not sufficient. Agent-bound API Proxy methods ca
The API Proxy also contains BFF operations whose contract is not a business method: Session lifecycle and transcript assembly, model-selection state, live-only input control, configuration filtering, skill presentation, Host composition facts, and native desktop operations. Stateful interactions and streams have different lifecycles again. Treating all unary syntax as evidence that a method is simple would move product policy into arbitrary Service packages or force new packages that have no independent business owner.
Finally, Connection currently applies its loopback-only privileged-method list inside the API Proxy fallback. A Typert interceptor claims its endpoint before that fallback, so migrating credential or preset authoring calls without moving the privilege check would grant trusted-LAN callers operations that are currently loopback-only.
Finally, Connection must authenticate a request before choosing the API Proxy fallback or a Typert interceptor. A migration that authenticates only the fallback would let Remote-owned endpoints bypass the browser identity required by every Host operation.
## Proposal
@@ -76,14 +76,9 @@ Generated Remote methods return business values and throw an Error whose `cause`
Resolver-owned `session-not-found` and `agent-busy` errors remain stable because the shared resolver raises `TypertLookupFailure`. Ordinary business exceptions become the Gateway's existing `internal` RPC failure. A selected Client consumer may migrate only if it does not branch on a more specific legacy business error code; if implementation finds such a branch, that RPC leaves this set unless the business package gains a transport-independent typed failure.
## Privileged authority
## Browser authentication
Connection must enforce privileged endpoint authority before choosing the Typert interceptor or API Proxy fallback. The check must recognize both legacy dotted names and Remote slash endpoints and keep these migrated operations loopback-only:
- `agentPresets/readDocument`, `agentPresets/copy`, and `agentPresets/remove`;
- `credentials/describe`, `credentials/set`, and `credentials/unset`.
The carrier-wide trusted-host and origin checks remain unchanged. This is a non-escalation requirement: endpoint ownership may change, but the set of callers authorized to invoke the operation may not widen.
Connection authenticates the complete `/api` request before choosing the Typert interceptor or API Proxy fallback. Legacy dotted names and Remote slash endpoints therefore use the same process-token-established browser session without an endpoint list. This is a non-escalation requirement: endpoint ownership may change, but an unauthenticated request can reach neither dispatch path.
## Commit boundaries
@@ -101,14 +96,14 @@ The final commit generates every `/remote` artifact from a clean state, updates
**Preserve every legacy RPC name and response envelope.** That would turn business packages into copies of the old protocol. Service-oriented names and business values let the Client own joins while Connection continues to own the one RPC envelope.
**Trust the API Proxy fallback to enforce privileged methods.** Interceptor selection bypasses that fallback, so this would silently widen authority for migrated methods.
**Trust the API Proxy fallback to authenticate requests.** Interceptor selection bypasses that fallback, so Remote methods would become anonymously callable.
## Acceptance criteria
- Every migration-table method is callable through its listed `ctx.remote` Service and has no production legacy API Proxy route, schema, map row, client stub, or invocation.
- Existing methods with matching signatures carry `@Remote` directly; every added method performs the adaptation stated in the table and no identity `remote*` wrapper remains.
- Agent/Session integration tests prove the shared lookup outcomes, and subagent interrupt tests prove no cold resume occurs.
- Privileged migrated endpoints reject trusted non-loopback callers and accept loopback callers before either dispatch path runs.
- Migrated endpoints reject unauthenticated requests and accept the same valid browser session as legacy endpoints before either dispatch path runs.
- Client behavior and immediate state settlement remain equivalent for every migrated call, including cancellation where supported.
- Deferred methods remain on the API Proxy with their existing behavior.
- A clean generation/build produces and consumes every selected Remote contribution, and focused tests plus final repository gates pass.
@@ -119,6 +114,6 @@ Removing legacy schemas also removes their protocol-specific error taxonomy. A h
Generated Remote contracts add build ordering and publication entries to each business package. Missing one runtime mount, declaration export, source-map source, package dependency, or Project Reference can pass a narrow source test while failing a clean Client build.
Moving privilege enforcement to composite dispatch changes security-sensitive carrier code. Tests must exercise both a Remote-owned endpoint and a legacy fallback endpoint so neither path can bypass the loopback decision.
Composite dispatch changes security-sensitive carrier code. Tests must exercise both a Remote-owned endpoint and a legacy fallback endpoint so neither path can bypass browser authentication.
This note applies the existing Typert Remote architecture rather than superseding it. It partially supersedes the central unary ownership and five-step extension checklist in the [GUI RPC protocol note](../../implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md) and the central wiring inventory in the [Web configuration plane note](../../implemented/architecture/2026-07-30-web-config-plane.md); those notes remain authoritative for Connection envelopes and configuration behavior outside the migrated methods. The title, command, configuration-boundary, subagent-interrupt, and archive notes continue to own their business behavior and require factual transport updates rather than archival. The [browser trust boundary](../../implemented/architecture/2026-07-28-api-browser-trust-boundary.md) and [generated-contract build order](../../implemented/process/2026-08-08-api-remotes-generated-contract-build.md) remain authoritative and require no archival action.
This note applies the existing Typert Remote architecture rather than superseding it. It partially supersedes the central unary ownership and five-step extension checklist in the [GUI RPC protocol note](../../implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md) and the central wiring inventory in the [Web configuration plane note](../../implemented/architecture/2026-07-30-web-config-plane.md); those notes remain authoritative for Connection envelopes and configuration behavior outside the migrated methods. The title, command, configuration-boundary, subagent-interrupt, and archive notes continue to own their business behavior and require factual transport updates rather than archival. The [browser trust boundary](../../implemented/architecture/2026-07-28-api-browser-trust-boundary.md), [browser authentication](../../implemented/architecture/2026-08-24-browser-token-authentication.md), and [generated-contract build order](../../implemented/process/2026-08-08-api-remotes-generated-contract-build.md) remain authoritative and require no archival action.
@@ -12,7 +12,7 @@ Host API Proxy 仍承载许多一元方法。这些方法的实现仅执行服
API Proxy 还包含一些不以业务方法为约定的 BFF 操作:Session 生命周期与 transcript(文本记录)组装、模型选择状态、仅限 live 的输入控制、配置过滤、skill(技能)呈现、Host 组合信息和原生桌面操作。有状态交互与流又具有不同的生命周期。若把一元调用的语法一概视为方法简单的依据,就会把产品策略移入任意服务包,或者迫使系统新增没有独立业务所有者的包。
最后,Connection 目前在 API Proxy 回退路径内执行仅限环回地址的特权方法清单。Typert interceptor 会先于该回退路径认领自己的端点,因此,如果迁移凭据或 preset 创作调用时不一并迁移权限检查,受信任的局域网调用方就会获得目前仅向环回调用方开放的操作权限
最后,Connection 必须在选择 API Proxy 回退路径Typert interceptor 前认证请求。若迁移只在回退路径执行认证,由 Remote 持有的 endpoint 就能绕过每个 Host 操作都要求的浏览器身份
## 提案
@@ -76,14 +76,9 @@ Lookup 策略作用于整个 key,而非特定端点。提示词输入、队列
Resolver 拥有的 `session-not-found``agent-busy` 错误保持稳定,因为共享 resolver 会抛出 `TypertLookupFailure`。普通业务异常会变成 Gateway 现有的 `internal` RPC 失败。只有在选定的 Client 消费方不根据更具体的旧版业务错误码进行分支时,才能迁移该调用;如果实现过程中发现这种分支,除非业务包新增与传输无关的类型化失败,否则该 RPC 将退出此集合。
## 特权调用权限
## 浏览器认证
Connection 必须在选择 Typert interceptor 或 API Proxy 回退路径之前检查调用方是否有权访问特权端点。该检查必须同时识别旧式点分名称和 Remote 斜杠端点,并保持以下已迁移操作仅限环回地址:
- `agentPresets/readDocument``agentPresets/copy``agentPresets/remove`
- `credentials/describe``credentials/set``credentials/unset`
贯穿整个载体的 trusted-host 和 origin 检查保持不变。这是一项非升权要求:端点所有权可以变化,但获准调用该操作的调用方集合不得扩大。
Connection 在选择 Typert interceptor 或 API Proxy 回退路径前认证完整 `/api` 请求。旧式点分名称和 Remote 斜杠 endpoint 因此无需 endpoint 清单,就能使用同一个由进程令牌建立的浏览器会话。这是一条非提权要求:endpoint 所有权可以变化,但未认证请求不能进入任一分发路径。
## 提交边界
@@ -101,14 +96,14 @@ Connection 必须在选择 Typert interceptor 或 API Proxy 回退路径之前
**保留每一个旧版 RPC 名称和响应 envelope。** 这会使业务包变成旧协议的副本。面向服务的名称和业务值让 Client 负责关联操作,而 Connection 继续负责统一的 RPC envelope。
**依赖 API Proxy 回退路径强制执行特权方法权限。** interceptor 选择会绕过该回退路径,因此这会悄然扩大已迁移方法的权限范围
**依赖 API Proxy 回退路径认证请求。** interceptor 选择会绕过该回退路径,使 Remote 方法变成匿名可调用
## 验收标准
- 迁移表中的每个方法都可通过表中列出的 `ctx.remote` 服务调用,并且不存在生产环境中的旧版 API Proxy 路由、schema、映射表行、客户端 stub 或调用。
- 签名匹配的现有方法直接带有 `@Remote`;每个新增方法都执行表中所述的适配,且不保留只做恒等转发的 `remote*` 包装层。
- AgentSession 集成测试证明共享 lookup 的各项结果,subagent 中断测试证明不会发生冷恢复。
- 已迁移的特权端点拒绝受信任的非环回调用方,并接受环回调用方,且该判定在任一分发路径运行前完成
- 已迁移 endpoint 拒绝未认证请求,并在任一分发路径运行前接受与旧 endpoint 相同的有效浏览器会话
- 每项已迁移调用的 Client 行为和立即提交状态的行为保持等价,包括支持取消之处的取消行为。
- 暂缓迁移的方法及其现有行为仍保留在 API Proxy 上。
- 一次从干净状态开始的生成与构建会生成并消费所选的每项 Remote 贡献,且聚焦测试和最终仓库门禁均通过。
@@ -119,6 +114,6 @@ Connection 必须在选择 Typert interceptor 或 API Proxy 回退路径之前
生成的 Remote 约定会为每个业务包引入构建顺序要求和发布条目。如果遗漏运行时挂载、声明导出、source map 来源、包依赖或 Project Reference 中的任何一项,局部源码测试可能仍会通过,但从干净状态开始的 Client 构建会失败。
将权限强制执行移至复合分发会改变安全敏感的载体代码。测试必须覆盖一个由 Remote 拥有的端点和一个旧版回退端点,确保两条路径都无法绕过环回判定
复合分发会改变安全敏感的载体代码。测试必须覆盖一个由 Remote 拥有的 endpoint 和一个旧版回退 endpoint,确保两条路径都无法绕过浏览器认证
本文应用现有 Typert Remote 架构,而非取代它。本文部分取代 [GUI RPC 协议笔记](../../implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md)中的中央一元调用所有权和五步扩展检查清单,以及 [Web 配置平面笔记](../../implemented/architecture/2026-07-30-web-config-plane.zh.md)中的中央接线清单;对于已迁移方法之外的 Connection envelope 和配置行为,这些笔记仍具权威性。标题、命令、配置边界、subagent 中断和归档笔记继续负责各自的业务行为,只需如实更新传输相关事实,无需归档。[浏览器信任边界](../../implemented/architecture/2026-07-28-api-browser-trust-boundary.zh.md)和[生成约定构建顺序](../../implemented/process/2026-08-08-api-remotes-generated-contract-build.zh.md)仍具权威性,无需执行归档操作。
本文应用现有 Typert Remote 架构,而非取代它。本文部分取代 [GUI RPC 协议笔记](../../implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md)中的中央一元调用所有权和五步扩展检查清单,以及 [Web 配置平面笔记](../../implemented/architecture/2026-07-30-web-config-plane.zh.md)中的中央接线清单;对于已迁移方法之外的 Connection envelope 和配置行为,这些笔记仍具权威性。标题、命令、配置边界、subagent 中断和归档笔记继续负责各自的业务行为,只需如实更新传输相关事实,无需归档。[浏览器信任边界](../../implemented/architecture/2026-07-28-api-browser-trust-boundary.zh.md)、[浏览器认证](../../implemented/architecture/2026-08-24-browser-token-authentication.zh.md)和[生成约定构建顺序](../../implemented/process/2026-08-08-api-remotes-generated-contract-build.zh.md)仍具权威性,无需执行归档操作。
+3 -1
View File
@@ -136,6 +136,8 @@
"@deepseek-ai/dsh-tool-subagent-report": "workspace:^",
"@deepseek-ai/dsh-user-approval": "workspace:^",
"@types/js-yaml": "^4.0.9",
"execa": "^10.0.0"
"@types/ws": "8.18.1",
"execa": "^10.0.0",
"ws": "8.21.0"
}
}
+9 -1
View File
@@ -22,7 +22,15 @@ export default async function open(url) {
if (process.env.BROWSER_OPEN_TEST_FAILURE !== undefined) {
throw new Error(process.env.BROWSER_OPEN_TEST_FAILURE)
}
const response = await fetch(url)
const exchange = await fetch(url, { redirect: 'manual' })
const setCookie = exchange.headers.get('set-cookie')
const location = exchange.headers.get('location')
if (exchange.status !== 303 || setCookie === null || location === null) {
throw new Error(`browser authentication exchange returned HTTP ${exchange.status}`)
}
const response = await fetch(new URL(location, url), {
headers: { cookie: setCookie.split(';', 1)[0] },
})
const html = await response.text()
console.log(`dsh browser-open: ${JSON.stringify({
url,
+32 -7
View File
@@ -12,6 +12,7 @@ import { join } from 'node:path'
import { setTimeout as delay } from 'node:timers/promises'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
import WebSocket from 'ws'
const REPO_ROOT = fileURLToPath(new URL('../../..', import.meta.url))
const BUILT_BIN = join(REPO_ROOT, 'apps/cli/lib/bin.js')
@@ -23,6 +24,23 @@ const SECRET = 'github-webhook-real-e2e-secret'
const DELIVERY = 'github-webhook-real-e2e-delivery'
const MARKER = 'DSH_GITHUB_WEBHOOK_REAL_E2E_OK'
const TITLE = 'GitHub webhook real e2e'
const authenticatedCookies = new Map<string, Promise<{ origin: string; cookie: string }>>()
/** Exchange the printed process token once for Node-side API probes. */
function authenticatedWeb(launchUrl: string): Promise<{ origin: string; cookie: string }> {
const existing = authenticatedCookies.get(launchUrl)
if (existing !== undefined) return existing
const exchange = (async () => {
const response = await fetch(launchUrl, { redirect: 'manual' })
const setCookie = response.headers.get('set-cookie')
if (response.status !== 303 || setCookie === null) {
throw new Error(`dsh web authentication returned HTTP ${String(response.status)}`)
}
return { origin: new URL(launchUrl).origin, cookie: setCookie.split(';', 1)[0]! }
})()
authenticatedCookies.set(launchUrl, exchange)
return exchange
}
interface SessionList {
items: Array<{
@@ -111,9 +129,10 @@ async function freePort(): Promise<number> {
/** Invoke one public Remote method over its HTTP carrier. */
async function remoteRpc<T>(baseUrl: string, endpoint: string, args: object): Promise<T> {
const response = await fetch(`${baseUrl}/api/${endpoint}`, {
const authenticated = await authenticatedWeb(baseUrl)
const response = await fetch(`${authenticated.origin}/api/${endpoint}`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
headers: { 'content-type': 'application/json', cookie: authenticated.cookie },
body: JSON.stringify({
type: 'client-request',
rpcId: `github-webhook-real-${endpoint}-${randomUUID()}`,
@@ -140,7 +159,10 @@ async function openingStreamItem(
args: object,
accepts: (value: unknown) => boolean,
): Promise<Record<string, unknown>> {
const socket = new WebSocket(`${baseUrl.replace(/^http/u, 'ws')}/api/remote.mux`)
const authenticated = await authenticatedWeb(baseUrl)
const socket = new WebSocket(`${authenticated.origin.replace(/^http/u, 'ws')}/api/remote.mux`, {
headers: { cookie: authenticated.cookie },
})
const streamId = `github-webhook-real-${endpoint}-${randomUUID()}`
try {
await new Promise<void>((resolve, reject) => {
@@ -179,10 +201,13 @@ async function openingStreamItem(
else if (value === undefined) reject(new Error(`${endpoint} opening item was absent`))
else resolve(value)
}
const message = (event: MessageEvent<unknown>): void => {
const message = (event: WebSocket.MessageEvent): void => {
try {
if (typeof event.data !== 'string') throw new Error(`${endpoint} published a non-text frame`)
const frame: unknown = JSON.parse(event.data)
const text = typeof event.data === 'string'
? event.data
: Buffer.isBuffer(event.data) ? event.data.toString('utf8') : undefined
if (text === undefined) throw new Error(`${endpoint} published a non-text frame`)
const frame: unknown = JSON.parse(text)
if (!isRecord(frame) || frame.streamId !== streamId) return
if (frame.type === 'error') {
finish(new Error(`${endpoint} failed: ${JSON.stringify(frame.error)}`))
@@ -356,7 +381,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('GitHub webhook through the real
const webhookOrigin = `http://127.0.0.1:${String(webhookPort)}`
expect((await fetch(`${webhookOrigin}/api`)).status).toBe(404)
expect((await sendGitHubDelivery(baseUrl)).status).not.toBe(202)
expect((await sendGitHubDelivery(new URL(baseUrl).origin)).status).not.toBe(202)
expect((await sendGitHubDelivery(webhookOrigin)).status).toBe(202)
const workspaces = await eventually(
+210
View File
@@ -0,0 +1,210 @@
/** Real `dsh web` authentication against a temporary Harness home. */
import type { ChildProcess } from 'node:child_process'
import { spawn } from 'node:child_process'
import { stat } from 'node:fs/promises'
import { request as httpRequest } from 'node:http'
import { createRequire } from 'node:module'
import { createServer } from 'node:net'
import type { AddressInfo } from 'node:net'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { describe, expect, it } from 'vitest'
const REPO_ROOT = fileURLToPath(new URL('../../..', import.meta.url))
const DSH_SOURCE_BIN = join(REPO_ROOT, 'apps/cli/src/bin.ts')
const TSX_LOADER = pathToFileURL(createRequire(join(REPO_ROOT, 'package.json')).resolve('tsx')).href
interface RunningWeb {
readonly child: ChildProcess
readonly launchUrl: string
readonly output: () => string
}
interface HttpResult {
readonly status: number
readonly body: string
}
function redact(output: string): string {
return output.replace(/([?&]token=)[^\s)]+/gu, '$1<redacted>')
}
/** Reserve one concrete loopback port, then release it for the CLI process. */
async function freePort(): Promise<number> {
const server = createServer()
await new Promise<void>((resolve, reject) => {
server.once('error', reject)
server.listen(0, '127.0.0.1', resolve)
})
const port = (server.address() as AddressInfo).port
await new Promise<void>((resolve, reject) => {
server.close((error) => {
if (error === undefined) resolve()
else reject(error)
})
})
return port
}
function cleanEnvironment(root: string, dshHome: string): NodeJS.ProcessEnv {
const env = Object.fromEntries(Object.entries(process.env).filter(([name]) =>
!/(?:KEY|SECRET|TOKEN|PASSWORD)/iu.test(name)))
return {
...env,
DSH_AGENTS_HOME: join(root, '.agents'),
DSH_HOME: dshHome,
DSH_TELEMETRY_DISABLED: '1',
NODE_NO_WARNINGS: '1',
SSH_CONNECTION: '',
SSH_TTY: '',
TSX_TSCONFIG_PATH: join(REPO_ROOT, 'tsconfig.json'),
}
}
/** Start the public source CLI and wait for its authenticated readiness URL. */
async function startWeb(root: string, dshHome: string, port: number): Promise<RunningWeb> {
const child = spawn(process.execPath, [
'--import', TSX_LOADER,
DSH_SOURCE_BIN,
'web',
'--no-open',
'--port', String(port),
], {
cwd: root,
env: cleanEnvironment(root, dshHome),
stdio: ['ignore', 'pipe', 'pipe'],
})
let output = ''
const launchUrl = await new Promise<string>((resolve, reject) => {
let settled = false
const fail = (error: Error): void => {
if (settled) return
settled = true
clearTimeout(timer)
reject(error)
}
const timer = setTimeout(() => {
fail(new Error(`dsh web did not become ready:\n${redact(output)}`))
}, 90_000)
const append = (chunk: Buffer | string): void => {
output = `${output}${String(chunk)}`.slice(-100_000)
const match = /dsh web: (http:\/\/[^\s]+)/u.exec(output)
if (settled || match?.[1] === undefined) return
settled = true
clearTimeout(timer)
resolve(match[1])
}
child.stdout?.on('data', append)
child.stderr?.on('data', append)
child.once('error', (error) => {
fail(error)
})
child.once('exit', (code) => {
fail(new Error(`dsh web exited before readiness (${String(code)}):\n${redact(output)}`))
})
})
return { child, launchUrl, output: () => output }
}
async function stopWeb(running: RunningWeb): Promise<void> {
if (running.child.exitCode !== null) return
const exited = new Promise<void>((resolve) => { running.child.once('exit', () => { resolve() }) })
running.child.kill('SIGTERM')
const forced = setTimeout(() => { running.child.kill('SIGKILL') }, 10_000)
forced.unref()
await exited
clearTimeout(forced)
}
/** POST one real API Proxy envelope while controlling the wire Host header. */
function describeHost(port: number, host: string, cookie?: string): Promise<HttpResult> {
const body = JSON.stringify({
type: 'client-request',
rpcId: 'web-auth-real-cli',
method: 'host.describe',
payload: {},
})
return new Promise((resolve, reject) => {
const req = httpRequest({
hostname: '127.0.0.1',
port,
path: '/api/host.describe',
method: 'POST',
headers: {
host,
'content-type': 'application/json',
'content-length': Buffer.byteLength(body),
...cookie === undefined ? {} : { cookie },
},
}, (res) => {
const chunks: Uint8Array[] = []
res.on('data', (chunk: Buffer) => { chunks.push(chunk) })
res.on('end', () => {
resolve({ status: res.statusCode ?? 0, body: Buffer.concat(chunks).toString('utf8') })
})
})
req.once('error', reject)
req.end(body)
})
}
describe('dsh web authentication through the real CLI', () => {
it('rejects a forged loopback Host and preserves the browser cookie across restart', { timeout: 180_000 }, async () => {
const root = await mkdtemp(join(tmpdir(), 'dsh-web-auth-real-cli-'))
const dshHome = join(root, '.dsh')
const port = await freePort()
let first: RunningWeb | undefined
let second: RunningWeb | undefined
try {
first = await startWeb(root, dshHome, port)
const firstUrl = new URL(first.launchUrl)
expect(firstUrl.origin).toBe(`http://127.0.0.1:${String(port)}`)
expect(firstUrl.pathname).toBe('/')
expect(firstUrl.searchParams.get('token')).toMatch(/^[A-Za-z0-9_-]{43}$/u)
expect(await describeHost(port, `localhost:${String(port)}`)).toEqual({
status: 401,
body: 'unauthorized',
})
const exchange = await fetch(first.launchUrl, { redirect: 'manual' })
expect(exchange.status).toBe(303)
expect(exchange.headers.get('location')).toBe('/')
const setCookie = exchange.headers.get('set-cookie')
if (setCookie === null) throw new Error('real CLI token exchange omitted Set-Cookie')
expect(setCookie).toContain('HttpOnly')
expect(setCookie).toContain('SameSite=Strict')
expect(setCookie).not.toContain('Secure')
const cookie = setCookie.split(';', 1)[0]!
const authenticated = await describeHost(port, firstUrl.host, cookie)
expect(authenticated.status).toBe(200)
const authenticatedBody = JSON.parse(authenticated.body) as unknown
expect(authenticatedBody).toMatchObject({
type: 'server-response',
rpcId: 'web-auth-real-cli',
result: { ok: true, value: { version: expect.any(String) as unknown } },
})
await stopWeb(first)
first = undefined
second = await startWeb(root, dshHome, port)
const secondUrl = new URL(second.launchUrl)
expect(secondUrl.searchParams.get('token')).not.toBe(firstUrl.searchParams.get('token'))
expect((await describeHost(port, secondUrl.host, cookie)).status).toBe(200)
const credentialMode = (await stat(join(dshHome, '.credentials.yaml'))).mode & 0o777
expect(credentialMode).toBe(0o600)
} catch (error) {
const evidence = [first?.output(), second?.output()].filter(value => value !== undefined).join('\n')
throw new Error(`${error instanceof Error ? error.message : String(error)}\n${redact(evidence)}`, { cause: error })
} finally {
if (second !== undefined) await stopWeb(second)
if (first !== undefined) await stopWeb(first)
await rm(root, { recursive: true, force: true })
}
})
})
@@ -32,7 +32,9 @@ interface BrowserOpenRecord {
}
function normalizeLocalUrl(url: string): string {
return url.replace(/:\d+$/, ':{{port}}')
return url
.replace(/:\d+/u, ':{{port}}')
.replace(/token=[^&]+/u, 'token={{token}}')
}
describe.skipIf(!builtArtifactsExist)('dsh web browser-open assembled snapshot', () => {
@@ -85,9 +87,9 @@ describe.skipIf(!builtArtifactsExist)('dsh web browser-open assembled snapshot',
"bootManifest": true,
"dshHomePresent": false,
"exitCode": 0,
"openedUrl": "http://127.0.0.1:{{port}}",
"openedUrl": "http://127.0.0.1:{{port}}/?token={{token}}",
"opening": true,
"readyUrl": "http://127.0.0.1:{{port}}",
"readyUrl": "http://127.0.0.1:{{port}}/?token={{token}}",
"status": 200,
"stderr": "",
}
@@ -124,7 +126,6 @@ describe.skipIf(!builtArtifactsExist)('dsh web browser-open assembled snapshot',
const readyUrl = /dsh web: (http:\/\/[^\s]+)/u.exec(result.stdout)?.[1]
const diagnostic = result.stderr.split(/\r?\n/u)
.find(line => line.startsWith('web-app: could not open the default browser because '))
?.replace(/http:\/\/127\.0\.0\.1:\d+/u, 'http://127.0.0.1:{{port}}')
expect({
diagnostic,
@@ -134,11 +135,11 @@ describe.skipIf(!builtArtifactsExist)('dsh web browser-open assembled snapshot',
readyUrl: readyUrl === undefined ? undefined : normalizeLocalUrl(readyUrl),
}).toMatchInlineSnapshot(`
{
"diagnostic": "web-app: could not open the default browser because fixture desktop unavailable; visit http://127.0.0.1:{{port}} manually",
"diagnostic": "web-app: could not open the default browser because fixture desktop unavailable; use the dsh web URL printed at startup",
"exitCode": 0,
"opened": false,
"opening": true,
"readyUrl": "http://127.0.0.1:{{port}}",
"readyUrl": "http://127.0.0.1:{{port}}/?token={{token}}",
}
`)
})
@@ -183,7 +184,7 @@ describe.skipIf(!builtArtifactsExist)('dsh web browser-open assembled snapshot',
"exitCode": 0,
"opened": false,
"opening": false,
"readyUrl": "http://127.0.0.1:{{port}}",
"readyUrl": "http://127.0.0.1:{{port}}/?token={{token}}",
"stderr": "",
}
`)
+3 -1
View File
@@ -43,6 +43,7 @@
"@types/node": "^22.0.0",
"@types/react": "~18.3.1",
"@types/react-dom": "~18.3.0",
"@types/ws": "8.18.1",
"@vitejs/plugin-react": "^4.0.0",
"http-server": "^14.1.1",
"fflate": "^0.8.2",
@@ -51,6 +52,7 @@
"react-dom": "^18.2.0",
"typescript": "^6.0.3",
"vite": "^6.0.0",
"vitest": "^4.1.8"
"vitest": "^4.1.8",
"ws": "8.21.0"
}
}
+40 -11
View File
@@ -26,9 +26,30 @@ import { fileURLToPath, pathToFileURL } from 'node:url'
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import WebSocket from 'ws'
import { REPO_ROOT, connectFreshWorkspace, newEnglishPage, probeFreePort, requireDist, saveFailureShot } from './support.ts'
const WEB_SURFACE_PROMPT = fileURLToPath(new URL('./expected/web-runtime-context/web-surface-prompt.expected.md', import.meta.url))
const authenticatedCookies = new Map<string, Promise<{ origin: string; cookie: string }>>()
/** Exchange a printed process token once for Node-side HTTP/WebSocket probes. */
function authenticatedWeb(launchUrl: string): Promise<{ origin: string; cookie: string }> {
const existing = authenticatedCookies.get(launchUrl)
if (existing !== undefined) return existing
const exchange = (async () => {
const response = await fetch(launchUrl, { redirect: 'manual' })
const setCookie = response.headers.get('set-cookie')
if (response.status !== 303 || setCookie === null) {
throw new Error(`dsh web authentication returned HTTP ${String(response.status)}`)
}
return {
origin: new URL(launchUrl).origin,
cookie: setCookie.split(';', 1)[0]!,
}
})()
authenticatedCookies.set(launchUrl, exchange)
return exchange
}
const comboMapUrl = (url: string): string => url.replace(/\/client\.js(?=,|&rev=)/g, '/client.js.map')
@@ -54,9 +75,10 @@ function waitForReadyLine(child: ChildProcess): Promise<string> {
}
async function remoteRpc<T>(baseUrl: string, endpoint: string, args: object): Promise<T> {
const response = await fetch(`${baseUrl}/api/${endpoint}`, {
const authenticated = await authenticatedWeb(baseUrl)
const response = await fetch(`${authenticated.origin}/api/${endpoint}`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
headers: { 'content-type': 'application/json', cookie: authenticated.cookie },
body: JSON.stringify({
type: 'client-request',
rpcId: `smoke-${endpoint}`,
@@ -74,7 +96,10 @@ async function remoteRpc<T>(baseUrl: string, endpoint: string, args: object): Pr
/** Read the explicit page cut from a freshly opened Session follow stream. */
async function sessionCursor(baseUrl: string, sessionId: string): Promise<number> {
const socket = new WebSocket(`${baseUrl.replace(/^http/, 'ws')}/api/remote.mux`)
const authenticated = await authenticatedWeb(baseUrl)
const socket = new WebSocket(`${authenticated.origin.replace(/^http/u, 'ws')}/api/remote.mux`, {
headers: { cookie: authenticated.cookie },
})
const streamId = `smoke-history-${randomUUID()}`
try {
await new Promise<void>((resolve, reject) => {
@@ -112,10 +137,13 @@ async function sessionCursor(baseUrl: string, sessionId: string): Promise<number
if (error !== undefined) reject(error)
else resolve(cursor ?? -1)
}
const message = (event: MessageEvent<unknown>): void => {
const message = (event: WebSocket.MessageEvent): void => {
try {
if (typeof event.data !== 'string') throw new Error('session/follow published a non-text frame')
const frame: unknown = JSON.parse(event.data)
const text = typeof event.data === 'string'
? event.data
: Buffer.isBuffer(event.data) ? event.data.toString('utf8') : undefined
if (text === undefined) throw new Error('session/follow published a non-text frame')
const frame: unknown = JSON.parse(text)
if (!isRecord(frame) || frame.streamId !== streamId) return
if (frame.type === 'error') {
finish(new Error(`session/follow failed: ${JSON.stringify(frame.error)}`))
@@ -278,8 +306,8 @@ describe('dsh web keyless CLI smoke', () => {
let browser: Browser | undefined
try {
const readyUrl = await waitForReadyLine(child)
expect(readyUrl).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/)
expect((await fetch(readyUrl)).status).toBe(200)
expect(readyUrl).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/\?token=[A-Za-z0-9_-]+$/u)
expect((await fetch(readyUrl, { redirect: 'manual' })).status).toBe(303)
browser = await chromium.launch({ headless: true })
const page = await newEnglishPage(browser)
const pluginScripts: string[] = []
@@ -310,14 +338,15 @@ describe('dsh web keyless CLI smoke', () => {
expect(batchPaths).toContainEqual(expect.stringMatching(
/^\/plugins\/\?\?@deepseek-ai\/dsh-client-modules\/client\.js&rev=[a-f\d]{12}$/,
))
const readyOrigin = new URL(readyUrl).origin
expect([...cacheHeaders.values()]).toEqual([
'public, max-age=31536000, immutable',
'public, max-age=31536000, immutable',
])
for (const path of batchPaths) {
const [scriptResponse, mapResponse] = await Promise.all([
fetch(`${readyUrl}${path}`),
fetch(`${readyUrl}${comboMapUrl(path)}`),
fetch(`${readyOrigin}${path}`),
fetch(`${readyOrigin}${comboMapUrl(path)}`),
])
expect(scriptResponse.status).toBe(200)
expect(mapResponse.status).toBe(200)
@@ -421,7 +450,7 @@ describe('dsh web keyless CLI smoke', () => {
message.role === 'user' && message.content?.includes('web-workspace-context-probe'))
const systemMessage = captured.messages?.find(message => message.role === 'system')
const expectedWebSection = readFileSync(WEB_SURFACE_PROMPT, 'utf8').trimEnd()
.replace('{{webUrl}}', baseUrl)
.replace('{{webUrl}}', new URL(baseUrl).origin)
expect(systemMessage?.content).toContain(expectedWebSection)
expect(workspaceMessage).toMatchInlineSnapshot(`
{
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/config-catalog.md
config-catalog.md: d6145e898e07614a771f24cf7e7ed5ab31390a17
config-catalog.zh.md: 28c36a899b452686de2bc2428e1a2246f2519b4e
config-catalog.md: a4eeb7ffbe09253d7f0f979cd8dc681b4ae038ac
config-catalog.zh.md: 8490829a7881e6503fdf7b7652c679e5d3cc0f3c
+9 -7
View File
@@ -376,7 +376,7 @@ Source: [`packages/shell/bash-sandbox/src/index.ts:35`](../packages/shell/bash-s
## `@deepseek-ai/dsh-client-connection`
Requires: `webServer`
Requires: `webServer` · `credentials`
```ts config-catalog
/** Plugin config: the deployment's non-loopback serving authorities. */
@@ -386,16 +386,18 @@ export interface ConnectionConfig {
* port-less `host` matching any port. The /api trust fence refuses any
* request whose Host is neither loopback nor listed here, so a
* non-loopback (`0.0.0.0`) deployment must declare the names it is reached
* by (the dsh CLI derives the machine's LAN IP literals itself). An entry
* that is not a bare, canonical authority fails the plugin load.
* 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.
*/
trustedHosts?: string[]
/** Absolute browser-session lifetime in days. Default: 30. */
cookieMaxAgeDays?: number
/** Maximum buffered JSON body for every `/api` request. Default: 300 MiB. */
maxRequestBodyBytes?: number
}
```
Source: [`packages/client/connection/src/index.ts:52`](../packages/client/connection/src/index.ts)
Source: [`packages/client/connection/src/index.ts:55`](../packages/client/connection/src/index.ts)
<a id="deepseek-aidsh-client-hmr"></a>
@@ -814,7 +816,7 @@ Source: [`packages/host/directory-picker-browse/src/index.ts:181`](../packages/h
## `@deepseek-ai/dsh-host-frontend-static`
Requires: `webServer`
Requires: `webServer` · `connection`
```ts config-catalog
/** Plugin config: the dist anchor. */
@@ -824,7 +826,7 @@ export interface Config {
}
```
Source: [`packages/host/frontend-static/src/index.ts:28`](../packages/host/frontend-static/src/index.ts)
Source: [`packages/host/frontend-static/src/index.ts:30`](../packages/host/frontend-static/src/index.ts)
<a id="deepseek-aidsh-host-webserver"></a>
@@ -3141,7 +3143,7 @@ export interface Config {
}
```
Source: [`packages/bundle/web-app/src/index.ts:43`](../packages/bundle/web-app/src/index.ts)
Source: [`packages/bundle/web-app/src/index.ts:44`](../packages/bundle/web-app/src/index.ts)
<a id="deepseek-aidsh-web-fetch-http"></a>
+9 -7
View File
@@ -378,7 +378,7 @@ export type Config = LocalConfig
## `@deepseek-ai/dsh-client-connection`
需要:`webServer`
需要:`webServer` · `credentials`
```ts config-catalog
/** Plugin config: the deployment's non-loopback serving authorities. */
@@ -388,16 +388,18 @@ export interface ConnectionConfig {
* port-less `host` matching any port. The /api trust fence refuses any
* request whose Host is neither loopback nor listed here, so a
* non-loopback (`0.0.0.0`) deployment must declare the names it is reached
* by (the dsh CLI derives the machine's LAN IP literals itself). An entry
* that is not a bare, canonical authority fails the plugin load.
* 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.
*/
trustedHosts?: string[]
/** Absolute browser-session lifetime in days. Default: 30. */
cookieMaxAgeDays?: number
/** Maximum buffered JSON body for every `/api` request. Default: 300 MiB. */
maxRequestBodyBytes?: number
}
```
来源:[`packages/client/connection/src/index.ts:52`](../packages/client/connection/src/index.ts)
来源:[`packages/client/connection/src/index.ts:55`](../packages/client/connection/src/index.ts)
<a id="deepseek-aidsh-client-hmr"></a>
@@ -816,7 +818,7 @@ export interface Config {
## `@deepseek-ai/dsh-host-frontend-static`
需要:`webServer`
需要:`webServer` · `connection`
```ts config-catalog
/** Plugin config: the dist anchor. */
@@ -826,7 +828,7 @@ export interface Config {
}
```
来源:[`packages/host/frontend-static/src/index.ts:28`](../packages/host/frontend-static/src/index.ts)
来源:[`packages/host/frontend-static/src/index.ts:30`](../packages/host/frontend-static/src/index.ts)
<a id="deepseek-aidsh-host-webserver"></a>
@@ -3143,7 +3145,7 @@ export interface Config {
}
```
来源:[`packages/bundle/web-app/src/index.ts:43`](../packages/bundle/web-app/src/index.ts)
来源:[`packages/bundle/web-app/src/index.ts:44`](../packages/bundle/web-app/src/index.ts)
<a id="deepseek-aidsh-web-fetch-http"></a>
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/subsystems/web-server.md
web-server.md: 9e1e88d6c796e457fa6c185c1927b46cca9fcf52
web-server.zh.md: 4401ccf628360a9571e77ea14ae6c29bd22af151
web-server.md: b806f5b40a2f5ddada40752367e3da23e86ea13d
web-server.zh.md: e5f4a8794d46a2b55d4a69e2cdd13c7dc4c2a6dd
+2 -2
View File
@@ -24,7 +24,7 @@ interface WebRoute {
}
```
Match order is fixed: exact table first, then longest matching prefix, then the registered fallback. Registration order carries no request-facing semantics — named routes are composed to be disjoint, and the fallback seat answers anything no named route claims; one owner only, a second registration throws. The shipped Web composition claims the seat with [`dsh-host-frontend-static`](../../packages/host/frontend-static/src/index.ts), the SPA dist server with locked semantics: non-GET/HEAD is 405, traversal outside the dist root is 403, a readable index renders at the dist root and configured index path, existing files are served directly, absent or non-file targets are empty 404 responses, and unknown extensions ship as octet-stream.
Match order is fixed: exact table first, then longest matching prefix, then the registered fallback. Registration order carries no request-facing semantics — named routes are composed to be disjoint, and the fallback seat answers anything no named route claims; one owner only, a second registration throws. The shipped Web composition claims the seat with [`dsh-host-frontend-static`](../../packages/host/frontend-static/src/index.ts), the SPA dist server with locked semantics: Connection authenticates the dist root and configured index before their HTML is read; non-index assets remain public; non-GET/HEAD is 405, traversal outside the dist root is 403, existing files are served directly, absent or non-file targets are empty 404 responses, and unknown extensions ship as octet-stream.
## Config
@@ -44,7 +44,7 @@ interface Config {
}
```
`host` accepts only `127.0.0.1` (default posture) and `0.0.0.0` (deliberate network exposure); there is no TLS, auth, or origin policy, so a non-loopback bind exposes the server to that network. `compression` defaults to `none`; the shipped Web bundle selects gzip level 1 with a 1024-byte threshold. The dist location is an assembly fact of the frontend plugin that claims the seat.
`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.
## The service
+2 -2
View File
@@ -24,7 +24,7 @@ interface WebRoute {
}
```
匹配顺序固定:先查 exact 表,再取最长匹配前缀,最后落到已注册的回退。注册顺序不携带任何面向请求的语义:具名路由在组合上互不相交,任何未被具名路由认领的请求都由回退席位应答;席位只有一个所有者,第二次注册会抛出异常。发布的 Web 组合用 [`dsh-host-frontend-static`](../../packages/host/frontend-static/src/index.ts) 认领席位,即遵循固定语义的 SPA dist 服务器:非 GET/HEAD 返回 405,越出 dist 根目录的遍历返回 403,可读的 index 在 dist 根目录和配置的 index 路径渲染,现有文件直接提供,缺失或不是文件的目标返回空的 404,未知扩展名按 octet-stream 发送。
匹配顺序固定:先查 exact 表,再取最长匹配前缀,最后落到已注册的回退。注册顺序不携带任何面向请求的语义:具名路由在组合上互不相交,任何未被具名路由认领的请求都由回退席位应答;席位只有一个所有者,第二次注册会抛出异常。发布的 Web 组合用 [`dsh-host-frontend-static`](../../packages/host/frontend-static/src/index.ts) 认领席位,即遵循固定语义的 SPA dist 服务器:Connection 在读取 dist 根目录和配置 index 的 HTML 前完成认证;非 index 资产保持公开;非 GET/HEAD 返回 405,越出 dist 根目录的遍历返回 403,现有文件直接提供,缺失或不是文件的目标返回空的 404,未知扩展名按 octet-stream 发送。
## 配置
@@ -44,7 +44,7 @@ interface Config {
}
```
`host` 只接受 `127.0.0.1`(默认姿态)和 `0.0.0.0`(刻意的网络暴露);没有 TLS、认证或 origin 策略,因此绑定到非回环地址会服务器暴露给该网络`compression` 默认为 `none`;随附的 Web 组合选择 gzip level 1 和 1024 字节阈值。dist 位置是认领席位的前端插件的组装事实。
`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 位置是认领席位的前端插件的组装事实。
## 服务
+4 -4
View File
@@ -182,7 +182,6 @@ export class TypertGatewayService extends Service implements TypertGateway {
'/api',
endpoint => this.claimsEndpoint(endpoint),
(endpoint, payload, signal) => this.dispatchRpc(endpoint, payload, signal),
{ authority: 'trusted-host' },
)
})
ctx.inject(['connection', 'webServer'], (webCtx) => {
@@ -193,9 +192,10 @@ export class TypertGatewayService extends Service implements TypertGateway {
webCtx.effect(() => {
const route: WebUpgradeRoute = {
path: REMOTE_STREAM_MUX_PATH,
handler: (req, socket, head) => {
if (!webCtx.connection.isTrustedRequest(req, 'trusted-host')) {
rejectRemoteStreamUpgrade(socket)
handler: async (req, socket, head) => {
const rejection = await webCtx.connection.requestRejection(req)
if (rejection !== undefined) {
rejectRemoteStreamUpgrade(socket, rejection)
return
}
mux.handleUpgrade(req, socket, head)
+7 -4
View File
@@ -175,14 +175,17 @@ function rawText(data: RawData): string {
/**
* Reject an upgrade without transferring socket ownership to ws.
* @param socket - carrier socket that receives the HTTP rejection.
* @param status - authentication or browser-trust rejection status.
*/
export function rejectRemoteStreamUpgrade(socket: Duplex): void {
export function rejectRemoteStreamUpgrade(socket: Duplex, status: 401 | 403): void {
const reason = status === 401 ? 'Unauthorized' : 'Forbidden'
const body = reason.toLowerCase()
socket.end([
'HTTP/1.1 403 Forbidden',
`HTTP/1.1 ${String(status)} ${reason}`,
'Connection: close',
'Content-Type: text/plain; charset=utf-8',
'Content-Length: 9',
`Content-Length: ${String(Buffer.byteLength(body))}`,
'',
'forbidden',
body,
].join('\r\n'))
}
@@ -14,6 +14,7 @@ import {
TypertRemoteFailure,
} from '@deepseek-ai/dsh-typert-protocol'
import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
import { MemoryCredentials } from '../../../credentials/credentials/tests/memory.ts'
import TypertGatewayService, {
TypertGatewayError,
type TypertRemoteEventDispatch,
@@ -32,9 +33,33 @@ vi.mock('node:crypto', async (importOriginal) => {
})
const randomUuid = vi.mocked(randomUUID)
const browserCookies = new WeakMap<Context, Promise<string>>()
type AgentWireId = TypertContextWire<TypertContextMap['agent']>
const agentId = (value: string): AgentWireId => value as AgentWireId
/** Exchange this test Host's process token for its WebSocket/HTTP Cookie header. */
function browserCookie(ctx: Context): Promise<string> {
const existing = browserCookies.get(ctx)
if (existing !== undefined) return existing
const exchange = (async () => {
const origin = `http://127.0.0.1:${String(ctx.webServer.port)}`
const target = new URL(ctx.connection.authenticatedUrl(origin))
let setCookie: string | undefined
await ctx.connection.authorizeIndex({
method: 'GET',
url: `${target.pathname}${target.search}`,
headers: { host: target.host },
}, {
writeHead(_status, headers) { setCookie = headers?.['set-cookie'] },
end() {},
})
if (setCookie === undefined) throw new Error('gateway stream fixture did not receive a browser cookie')
return setCookie.split(';', 1)[0]!
})()
browserCookies.set(ctx, exchange)
return exchange
}
class FeedService extends Service {
readonly typertRemote = bindTypertRemote(this, 'feed')
readonly signals: AbortSignal[] = []
@@ -259,7 +284,9 @@ describe('Typert Remote streams', () => {
it('multiplexes independent streams over one WebSocket and propagates cancellation', async () => {
const { ctx, service } = await setup(true)
const socket = new WebSocket(`ws://127.0.0.1:${String(ctx.webServer.port)}/api/remote.mux`)
const socket = new WebSocket(`ws://127.0.0.1:${String(ctx.webServer.port)}/api/remote.mux`, {
headers: { cookie: await browserCookie(ctx) },
})
await once(socket, 'open')
const frames: Record<string, unknown>[] = []
socket.on('message', (data) => { frames.push(JSON.parse(rawText(data)) as Record<string, unknown>) })
@@ -341,7 +368,9 @@ describe('Typert Remote streams', () => {
expect(() => { ctx.typertGateway.registerRemoteEvents(source) })
.toThrow('forwarded Remote event source is already registered')
const socket = new WebSocket(`ws://127.0.0.1:${String(ctx.webServer.port)}/api/remote.mux`)
const socket = new WebSocket(`ws://127.0.0.1:${String(ctx.webServer.port)}/api/remote.mux`, {
headers: { cookie: await browserCookie(ctx) },
})
await once(socket, 'open')
const frames: Record<string, unknown>[] = []
socket.on('message', (data) => { frames.push(JSON.parse(rawText(data)) as Record<string, unknown>) })
@@ -861,7 +890,9 @@ describe('Typert Remote streams', () => {
it('validates the internal Remote event request and reports an absent source', async () => {
const { ctx } = await setup(true)
const socket = new WebSocket(`ws://127.0.0.1:${String(ctx.webServer.port)}/api/remote.mux`)
const socket = new WebSocket(`ws://127.0.0.1:${String(ctx.webServer.port)}/api/remote.mux`, {
headers: { cookie: await browserCookie(ctx) },
})
await once(socket, 'open')
const frames: Record<string, unknown>[] = []
socket.on('message', (data) => { frames.push(JSON.parse(rawText(data)) as Record<string, unknown>) })
@@ -920,6 +951,19 @@ describe('Typert Remote streams', () => {
rejected.resume()
;(request as { abort(): void }).abort()
})
it('answers an unauthenticated trusted Host with 401 before opening a stream', async () => {
const { ctx } = await setup(true)
const socket = new WebSocket(`ws://127.0.0.1:${String(ctx.webServer.port)}/api/remote.mux`)
socket.on('error', () => {})
const responseEvent: unknown[] = await once(socket, 'unexpected-response')
const request = responseEvent[0]
const response = responseEvent[1]
const rejected = response as { statusCode?: number; resume(): void }
expect(rejected.statusCode).toBe(401)
rejected.resume()
;(request as { abort(): void }).abort()
})
})
async function setup(transport: boolean): Promise<{ readonly ctx: Context; readonly service: FeedService }> {
@@ -927,6 +971,7 @@ async function setup(transport: boolean): Promise<{ readonly ctx: Context; reado
roots.push(ctx)
if (transport) {
await ctx.plugin(WebServer, { host: '127.0.0.1', port: 0 })
await ctx.plugin(MemoryCredentials)
}
await ctx.plugin(TypertRegistry)
await ctx.plugin(TypertGatewayService)
@@ -989,11 +1034,15 @@ interface RemoteEventTestClient {
readonly streamId: string
readonly clientId: RemoteEventClientId
readonly origin: string
readonly cookie: string
}
async function openEventClient(ctx: Context, streamId: string): Promise<RemoteEventTestClient> {
const origin = `http://127.0.0.1:${String(ctx.webServer.port)}`
const socket = new WebSocket(`${origin.replace('http:', 'ws:')}/api/remote.mux`)
const cookie = await browserCookie(ctx)
const socket = new WebSocket(`${origin.replace('http:', 'ws:')}/api/remote.mux`, {
headers: { cookie },
})
await once(socket, 'open')
const frames: Record<string, unknown>[] = []
socket.on('message', (data) => { frames.push(JSON.parse(rawText(data)) as Record<string, unknown>) })
@@ -1010,7 +1059,7 @@ async function openEventClient(ctx: Context, streamId: string): Promise<RemoteEv
if (typeof candidate === 'string') clientId = candidate as RemoteEventClientId
})
if (clientId === undefined) throw new Error('Remote event stream omitted its Client id')
return { socket, frames, streamId, clientId, origin }
return { socket, frames, streamId, clientId, origin, cookie }
}
function deliveredInvocation(client: RemoteEventTestClient): RemoteEventInvocationFrame | undefined {
@@ -1042,7 +1091,7 @@ async function sendEventResult(
const rpcId = `remote-event-result-${client.streamId}`
const response = await fetch(`${client.origin}/api/$events/result`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
headers: { 'content-type': 'application/json', cookie: client.cookie },
body: JSON.stringify({
type: 'client-request',
rpcId,
@@ -4,6 +4,7 @@ import { describe, expect, it } from 'vitest'
import { Context, Service, symbols } from '@deepseek-ai/cordis'
import { z } from 'zod'
import { apply as applyConnection, inject as connectionInject } from '@deepseek-ai/dsh-client-connection'
import type { HostConnectionHandle } from '@deepseek-ai/dsh-client-connection'
import type { WebServer, WebRoute } from '@deepseek-ai/dsh-host-webserver'
import {
bindTypertRemote,
@@ -17,6 +18,7 @@ import {
} from '@deepseek-ai/dsh-typert-protocol'
import TypertRegistry, { type TypertContribution } from '@deepseek-ai/dsh-typert-registry'
import TypertGatewayService, { TypertGatewayError } from '@deepseek-ai/dsh-api-gateway'
import { MemoryCredentials } from '../../../credentials/credentials/tests/memory.ts'
interface FixtureAgent {
readonly id: string
@@ -104,7 +106,6 @@ type FakeRpcHandler = (endpoint: string, payload: unknown, signal: AbortSignal)
class FakeConnectionService extends Service {
channel: string | undefined
authority: string | undefined
matches: ((endpoint: string) => boolean) | undefined
handler: FakeRpcHandler | undefined
@@ -119,22 +120,23 @@ class FakeConnectionService extends Service {
channel: string,
matches: (endpoint: string) => boolean,
handler: FakeRpcHandler,
options: { readonly authority: string },
) =>
owner.effect(() => {
this.channel = channel
this.authority = options.authority
this.matches = matches
this.handler = handler
return () => {
this.channel = undefined
this.authority = undefined
this.matches = undefined
this.handler = undefined
}
}),
}
}
requestRejection(): Promise<undefined> {
return Promise.resolve(undefined)
}
}
function fakeHttpServer(routes: WebRoute[]): Pick<WebServer, 'register' | 'tapIndex' | 'port'> {
@@ -168,6 +170,22 @@ async function serveRoute(route: WebRoute): Promise<{ readonly origin: string; c
}
}
/** Exchange a Connection launch token without mounting the frontend fallback. */
async function browserCookie(connection: HostConnectionHandle, origin: string): Promise<string> {
const target = new URL(connection.authenticatedUrl(origin))
let setCookie: string | undefined
await connection.authorizeIndex({
method: 'GET',
url: `${target.pathname}${target.search}`,
headers: { host: target.host },
}, {
writeHead(_status, headers) { setCookie = headers?.['set-cookie'] },
end() {},
})
if (setCookie === undefined) throw new Error('gateway fixture did not receive an authentication cookie')
return setCookie.split(';', 1)[0]!
}
class FirstSharedService extends Service {
readonly typertRemote = bindTypertRemote(this, 'firstShared', { namespace: 'shared' })
@@ -958,7 +976,7 @@ describe('TypertGatewayService', () => {
await gatewayFiber
await ctx.plugin(GoalService)
const connection = rawConnection(ctx)
expect(connection).toMatchObject({ channel: '/api', authority: 'trusted-host' })
expect(connection).toMatchObject({ channel: '/api' })
registerAgentLookup(ctx, { id: 'agent-1' })
registerStrict(ctx, [createDescriptor(), maybeDescriptor()])
@@ -1150,6 +1168,7 @@ describe('TypertGatewayService', () => {
it('dispatches claimed invocations through /api and leaves unclaimed endpoints to its fallback', async () => {
const ctx = new Context().extend({ fixtureScope: 'http-caller' })
const routes: WebRoute[] = []
await ctx.plugin(MemoryCredentials)
ctx.provide('webServer', fakeHttpServer(routes) as WebServer)
const connectionFiber = ctx.plugin({ inject: [...connectionInject], apply: applyConnection })
await connectionFiber
@@ -1163,11 +1182,12 @@ describe('TypertGatewayService', () => {
let strictActive = true
expect(routes).toHaveLength(1)
const server = await serveRoute(routes[0]!)
const cookie = await browserCookie(ctx.connection, server.origin)
try {
const response = await fetch(`${server.origin}/api/goals/create`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
headers: { 'content-type': 'application/json', cookie },
body: JSON.stringify({
type: 'client-request',
rpcId: 'rpc-http',
@@ -1187,7 +1207,7 @@ describe('TypertGatewayService', () => {
const invalid = await fetch(`${server.origin}/api/goals/create`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
headers: { 'content-type': 'application/json', cookie },
body: JSON.stringify({
type: 'client-request',
rpcId: 'rpc-invalid',
@@ -1211,7 +1231,7 @@ describe('TypertGatewayService', () => {
strictActive = false
const withdrawn = await fetch(`${server.origin}/api/goals/create`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
headers: { 'content-type': 'application/json', cookie },
body: JSON.stringify({
type: 'client-request',
rpcId: 'rpc-withdrawn',
@@ -1231,7 +1251,10 @@ describe('TypertGatewayService', () => {
})
expect(JSON.stringify(withdrawnBody)).toContain('strict definition was withdrawn')
const unclaimed = await fetch(`${server.origin}/api/legacy/list`, { method: 'POST' })
const unclaimed = await fetch(`${server.origin}/api/legacy/list`, {
method: 'POST',
headers: { cookie },
})
expect(unclaimed.status).toBe(404)
} finally {
await server.close()
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/bundle/web-app/README.md
README.md: c428bbd352f7f58fe4b76cd078e2c1a1993b422a
README.zh.md: 0d5a33fe356d749fa619ad980ab9ee13ed9ba3fc
README.md: c330e5557c2fa70f60ededcd368ec713db11269e
README.zh.md: b2170c762a2e9425417df09e7ba6e7c16f612e96
+1 -1
View File
@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
The dsh browser-surface bundle. [`cordis.patch.yml`](cordis.patch.yml) rides over [`dsh-base`](../base/README.md): it sets the coding persona, inserts the Web host rows (webserver, API gateway, workspace, projection cache, storage) and the browser plugin roster, the always-on client-plugin reload chain ([`dsh-client-hmr`](../../client/hmr/README.md), idle until a rebuild watcher rewrites client bundles), and mounts this package's `web-runtime` glue plugin (config `{openBrowser, printUrl, surfaceContext, trustedHosts}`). That plugin resolves the built frontend dist through `@deepseek-ai/dsh-web-frontend`'s exports, samples bind-dependent LAN trust once, provides it as `webRuntime` to the browser-trust fence and client roster, mounts the [`frontend-static`](../../host/frontend-static/README.md) fallback owner, and registers the harness-source and web-surface prompt sections plus the bash-visible `DSH_WEB_URL` runtime variable when `surfaceContext` is true. After its Loader tree settles, it prints the `dsh web:` URL line when `printUrl` is true and opens the canonical host URL in the default browser when `openBrowser` is true and the inherited `SSH_CONNECTION` and `SSH_TTY` are blank or absent. An SSH launch keeps the URL line but suppresses browser handoff because the SSH client or editor owns the local forwarded address. Immediately before a handoff, the runtime prints `dsh web: opening the default browser; pass --no-open to disable`. A short-lived Node helper runs the maintained platform opener with the canonical scrubbed child environment. On Windows it stays alive until the short-lived PowerShell launcher exits, because `open` reports spawn before that launcher has handed the URL to the shell; elsewhere the helper stops after the opener accepts spawn. A helper failure writes a diagnostic with its reason and the manual URL to stderr without stopping the server, and no path waits for the browser to exit. This bundle also owns the app command line: the ordinary `web-startup` provider ([`src/startup.ts`](src/startup.ts)) injects `ctx.cmdlineArgs` ([`dsh-cmdline`](../../boot/cmdline/README.md)), parses `--host`, `--port`, repeatable `--trusted-host`, `--no-open`, and the app's `--help`, then provides `webStartup`; browser opening defaults on for local launches, and `--no-open` turns it off for this invocation. It rejects `--host 0.0.0.0` before publishing that service because the CLI intentionally does not support all-interfaces binding yet. Flag-configured rows inject the service and read it directly from lazy config, so nothing binds a port before argument resolution and `dsh --profile web --help` starts no server. [`dsh-headless`](../headless/README.md) is a sibling surface over the same base and does not mount this bundle.
The dsh browser-surface bundle. [`cordis.patch.yml`](cordis.patch.yml) rides over [`dsh-base`](../base/README.md): it sets the coding persona, inserts the Web host rows (webserver, API gateway, workspace, projection cache, storage) and the browser plugin roster, the always-on client-plugin reload chain ([`dsh-client-hmr`](../../client/hmr/README.md), idle until a rebuild watcher rewrites client bundles), and mounts this package's `web-runtime` glue plugin (config `{openBrowser, printUrl, surfaceContext, trustedHosts}`). That plugin resolves the built frontend dist through `@deepseek-ai/dsh-web-frontend`'s exports, samples bind-dependent LAN trust once, provides it as `webRuntime` to the browser-trust fence and client roster, mounts the [`frontend-static`](../../host/frontend-static/README.md) fallback owner, and registers the harness-source and web-surface prompt sections plus the bash-visible `DSH_WEB_URL` runtime variable when `surfaceContext` is true. After its Loader tree settles and Connection authentication is available, it prints the `dsh web:` root URL with the fresh process token when `printUrl` is true and opens that authenticated URL in the default browser when `openBrowser` is true and the inherited `SSH_CONNECTION` and `SSH_TTY` are blank or absent. The model prompt and `DSH_WEB_URL` retain the clean canonical URL without credentials. An SSH launch keeps the tokenized URL line but suppresses browser handoff because the SSH client or editor owns the local forwarded address. Immediately before a handoff, the runtime prints `dsh web: opening the default browser; pass --no-open to disable`. A short-lived Node helper runs the maintained platform opener with the canonical scrubbed child environment. On Windows it stays alive until the short-lived PowerShell launcher exits, because `open` reports spawn before that launcher has handed the URL to the shell; elsewhere the helper stops after the opener accepts spawn. A helper failure writes a credential-free diagnostic with its reason and points to the startup URL without stopping the server, and no path waits for the browser to exit. This bundle also owns the app command line: the ordinary `web-startup` provider ([`src/startup.ts`](src/startup.ts)) injects `ctx.cmdlineArgs` ([`dsh-cmdline`](../../boot/cmdline/README.md)), parses `--host`, `--port`, repeatable `--trusted-host`, `--no-open`, and the app's `--help`, then provides `webStartup`; browser opening defaults on for local launches, and `--no-open` turns it off for this invocation. It rejects `--host 0.0.0.0` before publishing that service because the CLI intentionally does not support all-interfaces binding. Flag-configured rows inject the service and read it directly from lazy config, so nothing binds a port before argument resolution and `dsh --profile web --help` starts no server. [`dsh-headless`](../headless/README.md) is a sibling surface over the same base and does not mount this bundle.
The base module-HMR row remains disabled. The Web profile's `patchReload: live` lifecycle uses the launcher's config-only watcher; the browser-facing `dsh-client-hmr` reload chain is separate from server module HMR.
+1 -1
View File
@@ -2,7 +2,7 @@
[English](README.md) | 中文
dsh 浏览器表层组合包。[`cordis.patch.yml`](cordis.patch.yml) 叠加在 [`dsh-base`](../base/README.zh.md) 之上:设置 coding persona,插入 Web 宿主行(webserver、API 网关、workspace、投影缓存、存储)、浏览器插件名录与始终挂载的客户端插件重载链([`dsh-client-hmr`](../../client/hmr/README.zh.md),在重建 watcher 改写客户端 bundle 之前保持空闲),并挂载本包的 `web-runtime` 粘合插件(配置为 `{openBrowser, printUrl, surfaceContext, trustedHosts}`)。该插件通过 `@deepseek-ai/dsh-web-frontend` 的 exports 解析已构建的前端 dist,只采样一次依赖 bind 的 LAN 信任信息并将其作为 `webRuntime` 提供给浏览器信任栅栏和客户端名录,挂载 [`frontend-static`](../../host/frontend-static/README.zh.md) 回退席位所有者,并在 `surfaceContext` 为 true 时注册 Harness 源码与 Web 表层提示词段落,以及 bash 可见的 `DSH_WEB_URL` 运行时变量。自身 Loader 配置树结算后,它在 `printUrl` 为 true 时打印 `dsh web:` URL`openBrowser` 为 true 且继承的 `SSH_CONNECTION``SSH_TTY` 均为空或不存在时,才会用默认浏览器打开规范宿主机 URL。SSH 启动仍保留 URL 行,但会跳过浏览器交接,因为本地转发地址由 SSH 客户端或编辑器持有。交接前,运行时会打印英文提示 `dsh web: opening the default browser; pass --no-open to disable`。短生命周期 Node helper 使用规范的脱敏子进程环境运行受维护的平台 opener。在 Windows 上,helper 会保持存活,直至短生命周期的 PowerShell launcher 退出,因为 `open` 会在 launcher 把 URL 交给 shell 之前、仅在 spawn 时返回;其他平台则在 opener 接受 spawn 后结束。helper 失败时会向 stderr 写入包含原因和手动访问 URL 的诊断,不会停止服务器,且任何路径都不会等待浏览器退出。本组合包还持有应用命令行:普通 `web-startup` 提供方([`src/startup.ts`](src/startup.ts))注入 `ctx.cmdlineArgs`[`dsh-cmdline`](../../boot/cmdline/README.zh.md)),解析 `--host``--port`、可重复的 `--trusted-host``--no-open` 以及应用自己的 `--help`,再提供 `webStartup`;本机启动默认会打开浏览器,`--no-open` 则只对本次调用关闭该行为。它会在发布该服务前拒绝 `--host 0.0.0.0`,因为 CLI 目前有意不支持绑定所有网络接口。由 flag 配置的行会注入该服务,并在惰性配置中直接读取它,因此参数解析完成前不会有任何东西绑定端口,`dsh --profile web --help` 也不会启动服务器。[`dsh-headless`](../headless/README.zh.md) 是同一 base 之上的同级表层,不挂载本组合包。
dsh 浏览器表层组合包。[`cordis.patch.yml`](cordis.patch.yml) 叠加在 [`dsh-base`](../base/README.zh.md) 之上:设置 coding persona,插入 Web 宿主行(webserver、API 网关、workspace、投影缓存、存储)、浏览器插件名录与始终挂载的客户端插件重载链([`dsh-client-hmr`](../../client/hmr/README.zh.md),在重建 watcher 改写客户端 bundle 之前保持空闲),并挂载本包的 `web-runtime` 粘合插件(配置为 `{openBrowser, printUrl, surfaceContext, trustedHosts}`)。该插件通过 `@deepseek-ai/dsh-web-frontend` 的 exports 解析已构建的前端 dist,只采样一次依赖 bind 的 LAN 信任信息并将其作为 `webRuntime` 提供给浏览器信任栅栏和客户端名录,挂载 [`frontend-static`](../../host/frontend-static/README.zh.md) 回退席位所有者,并在 `surfaceContext` 为 true 时注册 Harness 源码与 Web 表层提示词段落,以及 bash 可见的 `DSH_WEB_URL` 运行时变量。Loader 配置树结算且 Connection 认证可用后,它在 `printUrl` 为 true 时打印带新进程令牌的 `dsh web:` URL`openBrowser` 为 true 且继承的 `SSH_CONNECTION``SSH_TTY` 均为空或不存在时,才会用默认浏览器打开该认证 URL。模型提示词与 `DSH_WEB_URL` 仍携带不含凭据的干净规范 URL。SSH 启动仍保留带令牌的 URL 行,但会跳过浏览器交接,因为本地转发地址由 SSH 客户端或编辑器持有。交接前,运行时会打印英文提示 `dsh web: opening the default browser; pass --no-open to disable`。短生命周期 Node helper 使用规范的脱敏子进程环境运行受维护的平台 opener。在 Windows 上,helper 会保持存活,直至短生命周期的 PowerShell launcher 退出,因为 `open` 会在 launcher 把 URL 交给 shell 之前、仅在 spawn 时返回;其他平台则在 opener 接受 spawn 后结束。helper 失败时会向 stderr 写入不含凭据的原因并指向启动 URL,不会停止服务器,且任何路径都不会等待浏览器退出。本组合包还持有应用命令行:普通 `web-startup` 提供方([`src/startup.ts`](src/startup.ts))注入 `ctx.cmdlineArgs`[`dsh-cmdline`](../../boot/cmdline/README.zh.md)),解析 `--host``--port`、可重复的 `--trusted-host``--no-open` 以及应用自己的 `--help`,再提供 `webStartup`;本机启动默认会打开浏览器,`--no-open` 则只对本次调用关闭该行为。它会在发布该服务前拒绝 `--host 0.0.0.0`,因为 CLI 不支持绑定所有网络接口。由 flag 配置的行会注入该服务,并在惰性配置中直接读取它,因此参数解析完成前不会有任何东西绑定端口,`dsh --profile web --help` 也不会启动服务器。[`dsh-headless`](../headless/README.zh.md) 是同一 base 之上的同级表层,不挂载本组合包。
base 的模块 HMR 配置项保持禁用。Web profile 的 `patchReload: live` 生命周期使用启动器的仅配置 watcher;面向浏览器的 `dsh-client-hmr` 重载链与服务器模块 HMR 相互独立。
+45 -37
View File
@@ -5,9 +5,9 @@
* the built frontend dist (workspace knowledge of this bundle, never user
* 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 URL line, and the default-browser handoff. App command-line
* values arrive through the `webStartup` service expressions in the bundle
* patch.
* variable, the process-token URL line, and the default-browser handoff. The
* model and shell retain the clean URL. App command-line values arrive through
* the `webStartup` service expressions in the bundle patch.
* @module @deepseek-ai/dsh-web-app
*/
@@ -19,6 +19,7 @@ import { fileURLToPath } from 'node:url'
import type { Context } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
import { addHarnessSourceSection } 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 { launchEnvironmentOf } from '@deepseek-ai/dsh-launch-environment'
import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess'
@@ -258,41 +259,48 @@ export function apply(ctx: Context, config: Config): void {
})
}
if (config.printUrl || handoffBrowser) {
// The URL line and browser handoff are readiness signals: supervisors RPC
// as soon as they observe the line, while a browser requests the page as
// soon as it opens. Neither may run while sibling rows such as the /api
// route owner are still mounting. Await Loader settlement first; a
// hand-built tree without a Loader is already the complete tree.
const announceReady = (): void => {
const webUrl = localWebUrl(ctx)
// Reuse the exact LAN snapshot provided to the /api trust fence.
const lanCandidate = runtime.lanAddresses[0]
const port = ctx.webServer.port
if (config.printUrl) {
console.log(`dsh web: ${webUrl}${lanCandidate === undefined ? '' : ` (LAN: http://${lanCandidate}:${String(port)})`}`)
ctx.inject(['connection'], (connectionCtx) => {
// The URL line and browser handoff are readiness signals: supervisors RPC
// as soon as they observe the line, while a browser requests the page as
// soon as it opens. Neither may run while sibling rows such as the /api
// route owner are still mounting. Await Loader settlement first; a
// hand-built tree without a Loader is already the complete tree.
const announceReady = (): void => {
const webUrl = localWebUrl(connectionCtx)
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)}`)
if (config.printUrl) {
console.log(`dsh web: ${authenticatedUrl}${lanUrl === undefined ? '' : ` (LAN: ${lanUrl})`}`)
}
if (handoffBrowser) {
console.log('dsh web: opening the default browser; pass --no-open to disable')
void internals.openBrowser(authenticatedUrl).catch((error: unknown) => {
const reason = error instanceof Error ? error.message : String(error)
console.error(`web-app: could not open the default browser because ${reason}; use the dsh web URL printed at startup`)
})
}
}
if (handoffBrowser) {
console.log('dsh web: opening the default browser; pass --no-open to disable')
void internals.openBrowser(webUrl).catch((error: unknown) => {
const reason = error instanceof Error ? error.message : String(error)
console.error(`web-app: could not open the default browser because ${reason}; visit ${webUrl} manually`)
})
// This row's own activation can precede a sibling failure. The app owns
// readiness by waiting for its Loader tree, or announces at once in a
// hand-built tree without Loader.
const settled = connectionCtx.get('loader')?.await()
if (settled === undefined) announceReady()
else {
void settled.then(() => {
// The tree can be disposed while the boot was in flight (early
// SIGTERM); a URL line or browser tab for a dead server would only
// mislead, and reading torn-down services would turn a clean shutdown
// into a crash.
if (connectionCtx.get('webServer') !== undefined
&& connectionCtx.get('connection') !== undefined) announceReady()
// Loader reports a failed boot; this row only stays quiet.
}, () => {})
}
}
// This row's own activation can precede a sibling failure. The app owns
// readiness by waiting for its Loader tree, or announces at once in a
// hand-built context without Loader.
const settled = ctx.get('loader')?.await()
if (settled === undefined) announceReady()
else {
void settled.then(() => {
// The tree can be disposed while the boot was in flight (early
// SIGTERM); a URL line or browser tab for a dead server would only
// mislead, and reading the torn-down port would turn a clean shutdown
// into a crash.
if (ctx.get('webServer') !== undefined) announceReady()
// Loader reports a failed boot; this row only stays quiet.
}, () => {})
}
})
}
}
+34 -9
View File
@@ -83,6 +83,21 @@ function fakeHttpServer(host: '127.0.0.1' | '0.0.0.0' = '127.0.0.1'): { server:
return { server, seat: () => fallback }
}
/** Deterministic Host Connection face for URL publication and frontend injection. */
function provideConnection(ctx: Context): void {
ctx.provide('connection', {
authenticatedUrl(baseUrl: string) {
const url = new URL(baseUrl)
url.pathname = '/'
url.searchParams.set('token', 'test-token')
return url.href
},
authorizeIndex: () => Promise.resolve(true),
requestRejection: () => Promise.resolve(undefined),
rpc: {},
} as never)
}
/** A fake Loader whose settlement the test controls (the URL line waits on it). */
function provideLoader(ctx: Context, settle: () => Promise<void> = async () => {}): void {
ctx.provide('loader', { await: settle } as never)
@@ -105,6 +120,7 @@ describe('web-app runtime glue', () => {
]))
const { server, seat } = fakeHttpServer('0.0.0.0')
ctx.provide('webServer', server)
provideConnection(ctx)
const contributions: BashContribution[] = []
ctx.provide('shellEnv', {
register: (contribution: BashContribution) => {
@@ -127,13 +143,13 @@ describe('web-app runtime glue', () => {
lanAddresses: ['192.168.1.5'],
trustedHosts: ['192.168.1.5', 'lab.internal'],
})
expect(log).toHaveBeenCalledWith('dsh web: http://127.0.0.1:4567 (LAN: http://192.168.1.5:4567)')
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: opening the default browser; pass --no-open to disable')
expect(openBrowser).toHaveBeenCalledWith('http://127.0.0.1:4567')
expect(openBrowser).toHaveBeenCalledWith('http://127.0.0.1:4567/?token=test-token')
expect(lifecycle).toEqual([
'dsh web: http://127.0.0.1:4567 (LAN: http://192.168.1.5:4567)',
'dsh web: http://127.0.0.1:4567/?token=test-token (LAN: http://192.168.1.5:4567/?token=test-token)',
'dsh web: opening the default browser; pass --no-open to disable',
'open:http://127.0.0.1:4567',
'open:http://127.0.0.1:4567/?token=test-token',
])
const assembly = await ctx.systemPrompt.assemble()
expect(assembly.sections.find(entry => entry.name === 'harness:source')?.text).toContain('DeepSeek Harness implementation checkout')
@@ -151,6 +167,7 @@ describe('web-app runtime glue', () => {
stageDist()
const ctx = new Context()
ctx.provide('webServer', fakeHttpServer().server)
provideConnection(ctx)
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
const openBrowser = vi.fn(async () => {})
internals.openBrowser = openBrowser
@@ -169,6 +186,7 @@ describe('web-app runtime glue', () => {
stageDist()
const ctx = new Context()
ctx.provide('webServer', fakeHttpServer().server)
provideConnection(ctx)
const contributions: BashContribution[] = []
ctx.provide('shellEnv', {
register: (contribution: BashContribution) => {
@@ -190,10 +208,11 @@ describe('web-app runtime glue', () => {
stageDist()
const ctx = new Context()
ctx.provide('webServer', fakeHttpServer().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')
expect(log).toHaveBeenCalledWith('dsh web: http://127.0.0.1:4567/?token=test-token')
await ctx.fiber.dispose()
})
@@ -205,12 +224,13 @@ describe('web-app runtime glue', () => {
stageDist()
const ctx = new Context()
ctx.provide('webServer', fakeHttpServer().server)
provideConnection(ctx)
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: [] }))
await new Promise(resolve => setTimeout(resolve, 0))
expect(log).toHaveBeenCalledWith('dsh web: http://127.0.0.1:4567')
expect(log).toHaveBeenCalledWith('dsh web: http://127.0.0.1:4567/?token=test-token')
expect(openBrowser).not.toHaveBeenCalled()
await ctx.fiber.dispose()
})
@@ -223,6 +243,7 @@ describe('web-app runtime glue', () => {
// can request the complete app immediately.
const settled = new Context()
settled.provide('webServer', fakeHttpServer().server)
provideConnection(settled)
let release: () => void
const settlement = new Promise<void>((resolve) => { release = resolve })
provideLoader(settled, () => settlement)
@@ -233,8 +254,8 @@ describe('web-app runtime glue', () => {
expect(openBrowser).not.toHaveBeenCalled()
release!()
await new Promise(resolve => setTimeout(resolve, 0))
expect(log).toHaveBeenCalledWith('dsh web: http://127.0.0.1:4567')
expect(openBrowser).toHaveBeenCalledWith('http://127.0.0.1:4567')
expect(log).toHaveBeenCalledWith('dsh web: http://127.0.0.1:4567/?token=test-token')
expect(openBrowser).toHaveBeenCalledWith('http://127.0.0.1:4567/?token=test-token')
await settled.fiber.dispose()
// Failed path: Loader reports the sibling failure; the app prints no URL
@@ -243,6 +264,7 @@ describe('web-app runtime glue', () => {
openBrowser.mockClear()
const failed = new Context()
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: [] }))
await new Promise(resolve => setTimeout(resolve, 0))
@@ -257,6 +279,7 @@ describe('web-app runtime glue', () => {
const torn = new Context()
const child = torn.plugin((childCtx: Context) => {
childCtx.provide('webServer', fakeHttpServer().server)
provideConnection(childCtx)
})
await child
let releaseTorn: () => void
@@ -279,6 +302,7 @@ describe('web-app runtime glue', () => {
const { server } = fakeHttpServer()
Object.defineProperty(server, 'port', { get: () => undefined })
ctx.provide('webServer', server)
provideConnection(ctx)
apply(ctx, new Config({ openBrowser: false, printUrl: false, surfaceContext: true, trustedHosts: [] }))
await ctx.plugin(SystemPrompt, { persona: '' })
await new Promise(resolve => setTimeout(resolve, 0))
@@ -301,6 +325,7 @@ describe('web-app runtime glue', () => {
stageDist()
const ctx = new Context()
ctx.provide('webServer', fakeHttpServer().server)
provideConnection(ctx)
internals.openBrowser = vi.fn(async () => { throw failure })
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
const diagnostic = vi.spyOn(console, 'error').mockImplementation(() => {})
@@ -308,7 +333,7 @@ describe('web-app runtime glue', () => {
await new Promise(resolve => setTimeout(resolve, 0))
expect(log).toHaveBeenCalledWith('dsh web: opening the default browser; pass --no-open to disable')
expect(diagnostic).toHaveBeenCalledWith(
`web-app: could not open the default browser because ${reason}; visit http://127.0.0.1:4567 manually`,
`web-app: could not open the default browser because ${reason}; use the dsh web URL printed at startup`,
)
expect(ctx.get('webServer')).toBeDefined()
await ctx.fiber.dispose()
+3
View File
@@ -23,6 +23,9 @@
{
"path": "../../boot/cmdline"
},
{
"path": "../../client/connection/tsconfig.host.json"
},
{
"path": "../../host/frontend-static"
},
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/connection/README.md
README.md: d2614515744ee69ca11443a7bc440a589d3f26b3
README.zh.md: 13df74ddf7bb21455bb5528119bd7c3d5d149b87
README.md: 293e9f9d6b158e325325f4f741a244031b1d2e02
README.zh.md: e3cea191ab1c9745a1920b5cfd13fcd8d8b77692
+8 -4
View File
@@ -4,13 +4,15 @@ English | [中文](README.zh.md)
Protocol and connection-generation layer. The Client plugin mounts `ctx.connection`, containing the shared API client, current-page loopback state, generation-scoped observable `hostDescription`, a generic RPC carrier, and the registration point for one generation source and the connection loop. A generation publishes `hostDescription` and calls `onConnected` only after its source is ready and `host.describe` succeeds; source completion, failure, withdrawal, or an explicit stop clears that value before `ConnectionController` reconnects with backoff.
The browser uses HTTP POST for API Proxy and generic Remote unary calls. API Gateway owns the `/api/remote.mux` WebSocket and its logical streams; in-process compositions provide equivalent Remote streams through `connection.rpc.open` without opening a WebSocket. The Host half owns the sole `/api` route, Fetch bridge, and trust checks. Typert Gateway claims its Remote endpoints first, and unclaimed requests fall through to API Proxy. Loopback hostname classification remains package-internal: the Host fence and WebSocket upgrade use it directly, while other Client plugins consume `ctx.connection.isLoopback`.
The browser uses HTTP POST for API Proxy and generic Remote unary calls. API Gateway owns the `/api/remote.mux` WebSocket and its logical streams; in-process compositions provide equivalent Remote streams through `connection.rpc.open` without opening a WebSocket. The Host half owns the sole `/api` route, Fetch bridge, browser authentication, and Host/Origin checks. Typert Gateway claims its Remote endpoints first, and unclaimed requests fall through to API Proxy. Loopback hostname classification remains package-internal to the browser-facing Client state.
The Node half keeps privileged methods (`host.pickDirectory`, `host.openPath`, the settings and credentials configuration planes, `llm.discoverModels`, and `agentPreset.read`/`copy`/`openDocument`/`remove`) loopback-only by passing an empty trust list to the fence. `agentPreset.list` and `agentPreset.select` are excluded: the roster carries only ids and trust levels, while `session.create` already selects a preset. Declared `trustedHosts` authorities can reach other methods; privileged operations remain loopback-only until a real authentication layer exists.
## Browser authentication and request trust
## /api browser-trust fence
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 the ordinary root URL with `?token=...`; `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 `/`. 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 node half guards every entry under `/api` before bridging or upgrading (`src/api-request-trust.ts`). Every request — browser-marked or not — must present a `Host` that is a loopback authority or matches a `trustedHosts` entry: exact on `host:port` entries, any port on port-less entries, both sides compared through WHATWG normalization (DNS-rebinding defense). There is deliberately no shortcut for unmarked HTTP requests: over plain HTTP a browser attaches neither `Origin` nor Fetch-Metadata to image and navigation reads, so an unmarked request may still be a rebound browser read with a readable response, and Host is the one header rebinding cannot forge; a browser WebSocket handshake carries `Origin` and passes the same comparison. Non-browser clients pass the same fence via loopback, deployment-derived LAN IP literals, or a declared authority. When markers are present, an attached `Origin` must equal the Host authority, and an explicit `sec-fetch-site: cross-site` marker is refused. A `trustedHosts` entry that is not a bare, canonical `host[:port]` authority — one WHATWG parsing reads back exactly as written — fails the plugin load loudly: parsing would otherwise quietly authorize the hostname inside `harness.internal/path`, or broaden a dangling-colon or zero-padded port to an any-port grant. HTTP failures answer plain 403 before any RPC dispatch; upgrade failures reject the handshake before any event stream starts. Non-loopback compositions must trust their serving authorities explicitly: the Web runtime derives LAN IP literals from an all-interfaces server config, while `trustedHosts` in cordis.yml and the CLI's `--trusted-host` flag declare named authorities. `dsh web --host 0.0.0.0` is intentionally unsupported until remote access has an authentication layer. The fence is a reachability policy, not authentication; the Web carrier provides no authentication layer. Decision record: [the api browser-trust boundary Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md).
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` reads the current record for every verification, so deletion or rotation revokes cookies without restarting the process. 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.
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. 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).
## Connection generation
@@ -29,3 +31,5 @@ None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **The `/api` bridge buffers each request body in memory**`maxRequestBodyBytes` (default 300 MiB, sized for the default 200 MiB aggregate image limit after base64 expansion plus envelope headroom) is therefore also the per-request resident bound; a streaming body path would be needed to lower it without shrinking the image limits.
- **The browser cookie is not marked `Secure`** — loopback HTTP is the shipped transport, so deployments that make the same authority reachable over plaintext networking 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 revokes every session and the next launch-token exchange creates a new signing secret.
+8 -4
View File
@@ -4,13 +4,15 @@
协议与连接世代层:Client 插件挂载 `ctx.connection`,包含共享 API 客户端、当前页面的 loopback 状态、按 generation 生效的可观察 `hostDescription`、通用 RPC carrier,以及单一 generation source 与连接循环的注册面。每个 generation 只在 source 已就绪且 `host.describe` 成功后发布 `hostDescription` 并调用 `onConnected`;source 结束、失败、被撤回或显式 stop 都会清空该值,再由 `ConnectionController` 退避重连。
浏览器通过 HTTP POST 执行 API Proxy 一元调用与通用 Remote 一元调用;API Gateway 自己拥有 `/api/remote.mux` WebSocket 及其逻辑流。进程内组合通过 `connection.rpc.open` 提供等价的 Remote 流,不打开 WebSocket。Host half 拥有唯一 `/api` route、Fetch bridge 和信任校验;Typert Gateway 先认领自己的 Remote endpoint,未认领的请求再回退 API Proxy。Loopback hostname 判定留在包内Host fence 与 WebSocket upgrade 直接使用它,其他 Client 插件消费 `ctx.connection.isLoopback`
浏览器通过 HTTP POST 执行 API Proxy 一元调用与通用 Remote 一元调用;API Gateway 自己拥有 `/api/remote.mux` WebSocket 及其逻辑流。进程内组合通过 `connection.rpc.open` 提供等价的 Remote 流,不打开 WebSocket。Host half 拥有唯一 `/api` route、Fetch bridge、浏览器认证与 Host/Origin 校验;Typert Gateway 先认领自己的 Remote endpoint,未认领的请求再回退 API Proxy。Loopback hostname 判定只供浏览器侧当前页面状态使用,留在包内。
node 半侧的 `/api` 路由让特权方法集(`host.pickDirectory``host.openPath`,整个 settings 与 credentials 配置面,`llm.discoverModels`,以及 `agentPreset.read`/`copy`/`openDocument`/`remove`)以空信任表过 fence,从而钉在回环本机。`agentPreset.list``agentPreset.select` 不在其中:名单只携带 id 与信任级别,而 `session.create` 已能选择 preset。已声明的 `trustedHosts` 授权可达其余方法;在真正的认证层出现前,特权面始终只限回环。
## 浏览器认证与请求信任
## /api 浏览器信任栅栏
每个 Host RPC 方法和 WebSocket stream 都要求同一个浏览器会话,不再存在按方法区分的 loopback 层。每个进程生成一个随机启动令牌。`dsh-web-app` 打印并打开带 `?token=...` 的普通根 URL`frontend-static` 把根路径和 index 请求交给 `ctx.connection.authorizeIndex`,后者只在 `GET /` 接受该令牌,写入绑定 authority 的签名 cookie,再重定向到干净的 `/`。缺失、过期、畸形或 authority 不匹配的 cookie 会在 RPC 分发前得到 401。静态资源保持公开。HTTP 载体不在根路径交换之外接受 query token,也不接受 Authorization header token。
node 半侧在桥接或 upgrade 前守卫 `/api` 下的每个入口(`src/api-request-trust.ts`)。每个请求——无论是否带浏览器标记——`Host` 都必须是回环地址权威,或与某个 `trustedHosts` 条目匹配:带端口的 `host:port` 条目精确匹配,不带端口的条目匹配任意端口,两侧均经 WHATWG 归一化后比较(DNS rebinding 防御)。刻意不为无浏览器标记的 HTTP 请求开捷径:明文 HTTP 下浏览器的图片与导航读取既不带 `Origin` 也不带 Fetch-Metadata,因此无标记请求仍可能是被重绑页面发起的、响应可被读走的读取,而 Host 是重绑唯一伪造不了的请求头;WebSocket 浏览器握手会带 `Origin` 并通过同一道比较。非浏览器客户端经由回环地址、部署推导的 LAN IP 字面量或已声明的权威通过同一道栅栏。当标记存在时,如附带 `Origin`,则它必须与 Host 权威完全一致;显式的 `sec-fetch-site: cross-site` 标记一律拒绝。不是纯的、规范形 `host[:port]` 权威的 `trustedHosts` 条目——即 WHATWG 解析读回后与原文不完全一致的——会让插件加载明确报错:否则解析会悄悄授权 `harness.internal/path` 这类笔误里的 hostname,或把悬空冒号、补零端口放大成任意端口授权。HTTP 失败在任何 RPC 分发之前以纯 403 应答,upgrade 失败在启动任何事件流前拒绝握手。非回环组合必须显式信任其服务权威:Web 运行时从全接口服务器配置推导 LAN IP 字面量,cordis.yml 中的 `trustedHosts` 与 CLI(命令行界面)的 `--trusted-host` flag 则声明具名权威。`dsh web --host 0.0.0.0` 在远程访问具备认证层之前有意不受支持。这道栅栏是可达性策略,而不是认证;Web 载体不提供认证层。决策记录:[api 浏览器信任边界 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.zh.md)
cookie 签名密钥是 `ctx.credentials` 中由 `client-connection/browser-session` 拥有的 grant 记录。本地提供方把它持久化到 `$DSH_HOME/.credentials.yaml``BrowserAuth` 每次校验都读取当前记录,因此删除或轮换记录无需重启进程即可撤销 cookie。cookie 携带绝对签发与过期区间,`cookieMaxAgeDays` 默认设为 30 天,并在确定性名称与签名 payload 中同时绑定规范化 hostname 和 port。它是 host-only、`Path=/``HttpOnly``SameSite=Strict`;随附服务器使用 loopback HTTP,因此刻意不设置 `Secure`
认证之前,每个请求仍经过 `src/api-request-trust.ts`。其 `Host` 必须是 loopback,或与 `trustedHosts` 条目匹配:带端口的 `host:port` 精确匹配,不带端口的条目匹配任意端口,两侧均经 WHATWG 归一化。若附带 `Origin`,它必须等于该 Host`sec-fetch-site: cross-site` 一律拒绝。畸形配置 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)。
## Connection generation
@@ -29,3 +31,5 @@ API Gateway Client 把内部 `$events` logical stream 注册为唯一 generation
## 已知限制与暂缓事项
- **`/api` 桥把每个请求体整体缓冲在内存里**`maxRequestBodyBytes`(默认 300 MiB,按默认 200 MiB 图片总量上限经 base64 膨胀加信封余量得出)因此同时是单请求的驻留内存上界;要降低它而不缩小图片限额,需要流式请求体路径。
- **浏览器 cookie 不带 `Secure`**:随附载体是 loopback HTTP;若部署把同一 authority 经明文网络暴露,bearer cookie 可能在传输中泄露。
- **没有 logout 操作**:清除浏览器 cookie 会结束单个浏览器会话;删除 owner 凭据记录会撤销全部会话,下一次启动令牌交换会创建新的签名密钥。
+2
View File
@@ -51,6 +51,7 @@
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-attachment": "workspace:^",
"@deepseek-ai/dsh-credentials": "workspace:^",
"@deepseek-ai/dsh-host-apiproxy": "workspace:^",
"@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
@@ -62,6 +63,7 @@
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-attachment": "workspace:^",
"@deepseek-ai/dsh-credentials": "workspace:^",
"@deepseek-ai/dsh-host-apiproxy": "workspace:^",
"@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
@@ -0,0 +1,276 @@
/** Browser-session authentication for the Host Connection carrier. */
import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto'
import { credentialKey } from '@deepseek-ai/dsh-credentials'
import type { CredentialProvider, CredentialRecord } from '@deepseek-ai/dsh-credentials'
import type {
ConnectionIndexRequest,
ConnectionIndexResponse,
ConnectionTrustRequest,
} from './rpc.ts'
const AUTH_RECORD_KEY = credentialKey('client-connection', 'browser-session')
const DAY_MILLISECONDS = 24 * 60 * 60 * 1000
const SECRET_BYTES = 32
const TOKEN_QUERY = 'token'
const COOKIE_PREFIX = 'dsh-auth-'
const COOKIE_PAYLOAD_VERSION = 1
const STORED_SECRET_VERSION = 1
interface StoredSecretPayload {
readonly version: typeof STORED_SECRET_VERSION
readonly secret: string
}
interface BrowserCookiePayload {
readonly version: typeof COOKIE_PAYLOAD_VERSION
readonly authority: string
readonly issuedAt: number
readonly expiresAt: number
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function header(
headers: ConnectionTrustRequest['headers'],
name: string,
): string | undefined {
if (headers instanceof Headers) return headers.get(name) ?? undefined
const value = headers[name]
return typeof value === 'string' ? value : undefined
}
/** Canonical request authority used as the cookie name and signed audience. */
function requestAuthority(headers: ConnectionTrustRequest['headers']): string | undefined {
const host = header(headers, 'host')
if (host === undefined) return undefined
try {
return new URL(`http://${host}`).host
} catch {
return undefined
}
}
function canonicalSecret(value: unknown): Buffer | undefined {
if (typeof value !== 'string') return undefined
const decoded = Buffer.from(value, 'base64url')
if (decoded.byteLength !== SECRET_BYTES || decoded.toString('base64url') !== value) return undefined
return decoded
}
function storedSecret(record: CredentialRecord | undefined): Buffer | undefined {
if (record === undefined) return undefined
if (record.kind !== 'grant' || !isRecord(record.payload)
|| record.payload.version !== STORED_SECRET_VERSION) {
throw new Error('client-connection: browser-session credential record has an unsupported format')
}
const secret = canonicalSecret(record.payload.secret)
if (secret === undefined) {
throw new Error('client-connection: browser-session credential record has an invalid secret')
}
return secret
}
function tokenMatches(actual: string, expected: string): boolean {
const actualBytes = Buffer.from(actual, 'utf8')
const expectedBytes = Buffer.from(expected, 'utf8')
return actualBytes.byteLength === expectedBytes.byteLength && timingSafeEqual(actualBytes, expectedBytes)
}
function cookieName(authority: string): string {
return COOKIE_PREFIX + createHash('sha256').update(authority).digest('base64url')
}
/** Read the exact generated cookie without implementing general Cookie decoding. */
function cookieValue(headerValue: string, name: string): string | undefined {
for (const segment of headerValue.split(';')) {
const at = segment.indexOf('=')
if (at === -1 || segment.slice(0, at).trim() !== name) continue
return segment.slice(at + 1).trim()
}
return undefined
}
/** Serialize the fixed browser-session attributes; generated names and values are cookie-safe base64url. */
function sessionCookie(name: string, value: string, expiresAt: number, maxAgeSeconds: number): string {
return `${name}=${value}; Max-Age=${String(maxAgeSeconds)}; Path=/; Expires=${new Date(expiresAt).toUTCString()}; HttpOnly; SameSite=Strict`
}
function signature(secret: Buffer, body: string): Buffer {
return createHmac('sha256', secret).update(body).digest()
}
function encodeCookie(payload: BrowserCookiePayload, secret: Buffer): string {
const body = Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url')
return `v1.${body}.${signature(secret, body).toString('base64url')}`
}
function decodeCookie(value: string, secret: Buffer): BrowserCookiePayload | undefined {
const parts = value.split('.')
const [version, body, encodedSignature] = parts
if (parts.length !== 3 || version !== 'v1' || body === undefined || encodedSignature === undefined) {
return undefined
}
const actualSignature = Buffer.from(encodedSignature, 'base64url')
if (actualSignature.toString('base64url') !== encodedSignature) return undefined
const expectedSignature = signature(secret, body)
if (actualSignature.byteLength !== expectedSignature.byteLength
|| !timingSafeEqual(actualSignature, expectedSignature)) return undefined
let decoded: unknown
try {
decoded = JSON.parse(Buffer.from(body, 'base64url').toString('utf8'))
} catch {
return undefined
}
if (!isRecord(decoded)
|| decoded.version !== COOKIE_PAYLOAD_VERSION
|| typeof decoded.authority !== 'string'
|| !Number.isSafeInteger(decoded.issuedAt)
|| !Number.isSafeInteger(decoded.expiresAt)) return undefined
return decoded as unknown as BrowserCookiePayload
}
/**
* Process launch-token exchange and persistent signed-cookie verification.
* The credential provider owns the signing secret; this object reads it for
* each operation so deletion or rotation revokes existing cookies without a
* process restart.
*/
export class BrowserAuth {
private readonly launchToken = randomBytes(SECRET_BYTES).toString('base64url')
private readonly maxAgeMilliseconds: number
private constructor(
private readonly credentials: CredentialProvider,
maxAgeDays: number,
) {
this.maxAgeMilliseconds = maxAgeDays * DAY_MILLISECONDS
if (!Number.isSafeInteger(this.maxAgeMilliseconds)
|| !Number.isSafeInteger(Date.now() + this.maxAgeMilliseconds)) {
throw new Error('client-connection: cookieMaxAgeDays exceeds the safe timestamp range')
}
}
/**
* Initialize browser authentication and create its durable signing secret
* when this Harness home has none.
* @param credentials - persistent credential provider for the Web profile.
* @param maxAgeDays - positive absolute browser-cookie lifetime in days.
* @returns initialized authentication owner with a fresh process token.
*/
static async create(credentials: CredentialProvider, maxAgeDays: number): Promise<BrowserAuth> {
const auth = new BrowserAuth(credentials, maxAgeDays)
await auth.ensureSecret()
return auth
}
/**
* Add this process's launch token to the ordinary application root URL.
* @param baseUrl - canonical browser origin without credentials.
* @returns root URL carrying the process token as its sole authentication input.
*/
authenticatedUrl(baseUrl: string): string {
const url = new URL(baseUrl)
url.pathname = '/'
url.search = ''
url.hash = ''
url.searchParams.set(TOKEN_QUERY, this.launchToken)
return url.href
}
/**
* Authenticate an index request. A valid root query token mints the cookie
* and redirects to clean `/`; a valid cookie lets the caller serve the
* index; every other request receives the same minimal 401 response.
* @param req - incoming root or configured-index request.
* @param res - response owned when this method returns false.
* @returns true only when the caller may serve index.html.
*/
async authorizeIndex(req: ConnectionIndexRequest, res: ConnectionIndexResponse): Promise<boolean> {
/* v8 ignore next -- node:http always supplies url on server requests. */
const url = new URL(req.url ?? '/', 'http://dsh.invalid')
const tokens = url.searchParams.getAll(TOKEN_QUERY)
if (tokens.length > 0) {
const authority = requestAuthority(req.headers)
if (req.method === 'GET' && url.pathname === '/' && tokens.length === 1
&& authority !== undefined && tokenMatches(tokens.join(''), this.launchToken)) {
const issuedAt = Date.now()
const expiresAt = issuedAt + this.maxAgeMilliseconds
const value = encodeCookie({
version: COOKIE_PAYLOAD_VERSION,
authority,
issuedAt,
expiresAt,
}, await this.ensureSecret())
res.writeHead(303, {
'cache-control': 'no-store',
'location': '/',
'referrer-policy': 'no-referrer',
'set-cookie': sessionCookie(
cookieName(authority), value, expiresAt, Math.floor(this.maxAgeMilliseconds / 1000),
),
})
res.end()
return false
}
this.writeUnauthorized(req, res)
return false
}
if (await this.isAuthenticated(req)) return true
this.writeUnauthorized(req, res)
return false
}
/**
* Verify the authority-bound browser cookie on a Host request.
* @param request - request headers carrying Host and Cookie.
* @returns true only for an unexpired cookie signed by the current durable secret.
*/
async isAuthenticated(request: ConnectionTrustRequest): Promise<boolean> {
const authority = requestAuthority(request.headers)
const rawCookie = header(request.headers, 'cookie')
if (authority === undefined || rawCookie === undefined) return false
const value = cookieValue(rawCookie, cookieName(authority))
if (value === undefined) return false
const secret = storedSecret(await this.credentials.readRecord(AUTH_RECORD_KEY))
if (secret === undefined) return false
const payload = decodeCookie(value, secret)
if (payload === undefined || payload.authority !== authority) return false
const now = Date.now()
return payload.issuedAt <= now
&& payload.expiresAt > now
&& payload.expiresAt > payload.issuedAt
&& payload.expiresAt - payload.issuedAt <= this.maxAgeMilliseconds
}
private async ensureSecret(): Promise<Buffer> {
const generated: StoredSecretPayload = {
version: STORED_SECRET_VERSION,
secret: randomBytes(SECRET_BYTES).toString('base64url'),
}
const record = await this.credentials.modifyRecord(AUTH_RECORD_KEY, (current) => {
if (current !== undefined) {
storedSecret(current)
return Promise.resolve(undefined)
}
return Promise.resolve({ kind: 'grant', payload: generated })
})
const secret = storedSecret(record)
if (secret === undefined) {
throw new Error('client-connection: browser-session credential record was not created')
}
return secret
}
private writeUnauthorized(req: ConnectionIndexRequest, res: ConnectionIndexResponse): void {
res.writeHead(401, {
'cache-control': 'no-store',
'content-type': 'text/plain; charset=utf-8',
})
res.end(req.method === 'HEAD'
? undefined
: 'dsh web authentication required; reopen the URL printed by dsh web.\n')
}
}
+26 -77
View File
@@ -2,20 +2,23 @@
import type { Context } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
import type {} from '@deepseek-ai/dsh-attachment'
import type {} from '@deepseek-ai/dsh-credentials'
// Activates the webServer Context merge used below.
import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
import { toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy'
import { API_PATH } from './api-path.ts'
import { bridge, DEFAULT_MAX_REQUEST_BODY_BYTES } from './http-bridge.ts'
import { assertTrustedAuthority, isTrustedApiRequest } from './api-request-trust.ts'
import { assertTrustedAuthority } from './api-request-trust.ts'
import { BrowserAuth } from './browser-auth.ts'
import { HostConnectionService } from './rpc-host.ts'
export type {
ConnectionRpcAuthority,
ConnectionIndexRequest,
ConnectionIndexResponse,
ConnectionRpcEndpointMatcher,
ConnectionRpcFailure,
ConnectionRpcHandler,
ConnectionRpcHandlerOptions,
ConnectionRequestRejection,
ConnectionRpcResult,
ConnectionTrustRequest,
HostConnectionHandle,
@@ -46,7 +49,7 @@ function assertImageBodyCapacity(ctx: Context, maxRequestBodyBytes: number): voi
}
/** Services required before providing Connection; API Proxy is an optional `/api` fallback. */
export const inject = ['webServer']
export const inject = ['webServer', 'credentials']
/** Plugin config: the deployment's non-loopback serving authorities. */
export interface ConnectionConfig {
@@ -55,112 +58,58 @@ export interface ConnectionConfig {
* port-less `host` matching any port. The /api trust fence refuses any
* request whose Host is neither loopback nor listed here, so a
* non-loopback (`0.0.0.0`) deployment must declare the names it is reached
* by (the dsh CLI derives the machine's LAN IP literals itself). An entry
* that is not a bare, canonical authority fails the plugin load.
* 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.
*/
trustedHosts?: string[]
/** Absolute browser-session lifetime in days. Default: 30. */
cookieMaxAgeDays?: number
/** Maximum buffered JSON body for every `/api` request. Default: 300 MiB. */
maxRequestBodyBytes?: number
}
export const Config: z<ConnectionConfig> = z.object({
trustedHosts: z.array(String).default([]),
cookieMaxAgeDays: z.natural().min(1).default(30),
maxRequestBodyBytes: z.natural().min(1).default(DEFAULT_MAX_REQUEST_BODY_BYTES),
})
/**
* Methods gated to loopback even on a trusted-host deployment. Native dialogs
* act on the host machine; the settings and credential domains mutate the
* user's configuration and secret store, and READING them is equally
* privileged `settings.describe` returns every exposed namespace's
* configuration and `credentials.describe` reports whether an arbitrary
* environment-variable name is configured and where from, which is
* reconnaissance no anonymous caller should have. `trustedHosts` is a
* DNS-rebinding fence, explicitly not authentication, so the whole
* configuration plane stays loopback-same-origin until a real authentication
* layer exists. `llm.discoverModels` belongs to that plane on both counts: it
* carries a draft credential, and it makes the HOST issue a GET to a URL the
* caller chose and reports back the status or the parsed body an anonymous
* LAN caller would have a probe for whatever the host can reach and the
* browser cannot.
*
* The model catalog (`llm.providers`, `llm.models`) is deliberately NOT here:
* it carries provider ids, display names, and model lists no endpoints,
* keys, or key state and a LAN client's model picker legitimately needs it.
*/
const PRIVILEGED_METHODS = new Set([
// A preset composition names the plugins a session runs, so reading one is
// reconnaissance; copy and remove rearrange what the deployment offers, and
// openDocument drives the host desktop — all more than the roster beside
// them. (Authoring is copy-only, so no method here accepts composition text
// or a path; the pin is about who may manage the roster at all.)
//
// CHOOSING one is not pinned, and `agentPreset.list` is not either. Picking a
// preset looks like escalation — one of them mounts the toolset that edits the
// live runtime — but `session.create` already takes an `agentPreset`, so
// pinning only the switch would leave the same capability one method over.
// The deeper reason is that the capability is not the preset's to grant: the
// deployment's own default already carries `bash` and the filesystem tools, so
// any caller that may start a session at all can already run commands as this
// process. Pinning the switch would be a fence beside an open gate.
'agentPreset.read',
'agentPreset.copy',
'agentPreset.openDocument',
'agentPreset.remove',
'host.pickDirectory',
'host.openPath',
'settings.describe',
'settings.openDocument',
'settings.update',
'settings.replace',
'settings.mutate',
'credentials.describe',
'credentials.set',
'credentials.unset',
'llm.discoverModels',
])
/**
* Mounts the API gateway under the browser transport prefix. Every request on
* the prefix passes the browser-trust fence first (DNS-rebinding and
* cross-site defense [api-request-trust](./api-request-trust.ts));
* privileged methods additionally pass it with an empty trust list, which
* pins them to loopback.
* the prefix passes the Host/Origin browser-trust fence and persistent browser
* authentication before dispatch.
* @param ctx - Host plugin context.
* @param config - resolved plugin config (schema defaults applied).
*/
export function apply(ctx: Context, config?: ConnectionConfig): void {
export async function apply(ctx: Context, config?: ConnectionConfig): Promise<void> {
// The Loader resolves schema defaults; hand-built test contexts may pass none.
const trustedHosts = config?.trustedHosts ?? []
const cookieMaxAgeDays = config?.cookieMaxAgeDays ?? 30
const maxRequestBodyBytes = config?.maxRequestBodyBytes ?? DEFAULT_MAX_REQUEST_BODY_BYTES
// Config boundary: a malformed entry fails the load loudly here rather than
// silently authorizing its hostname prefix at request time.
for (const entry of trustedHosts) assertTrustedAuthority(entry)
if (ctx.get('apiProxy') !== undefined) assertImageBodyCapacity(ctx, maxRequestBodyBytes)
const connection = new HostConnectionService(ctx, trustedHosts)
const connection = new HostConnectionService(
ctx,
trustedHosts,
await BrowserAuth.create(ctx.credentials, cookieMaxAgeDays),
)
const fetchHandler = connection.createSharedFetchHandler(API_PATH, {
async fetch(request) {
const pathname = new URL(request.url).pathname
const method = pathname.startsWith(`${API_PATH}/`)
? pathname.slice(API_PATH.length + 1)
: undefined
if (method !== undefined
&& PRIVILEGED_METHODS.has(method)
&& !isTrustedApiRequest(request, [])) {
return new Response('forbidden', { status: 403 })
}
const apiProxy = ctx.get('apiProxy')
if (apiProxy === undefined) return new Response('not found', { status: 404 })
return toFetchHandler(apiProxy).fetch(request)
return await toFetchHandler(apiProxy).fetch(request)
},
})
const route: WebRoute = {
kind: 'prefix',
path: API_PATH,
handler: async (req, res) => {
if (!isTrustedApiRequest(req, trustedHosts)) {
res.writeHead(403)
res.end('forbidden')
const rejection = await connection.requestRejection(req)
if (rejection !== undefined) {
res.writeHead(rejection)
res.end(rejection === 401 ? 'unauthorized' : 'forbidden')
return
}
await bridge(req, res, fetchHandler, maxRequestBodyBytes)
+6 -5
View File
@@ -15,11 +15,12 @@ export const name = 'client-connection-invariant'
export const inject = ['invariants']
/**
* No runtime invariant: the wire layer emits no cordis events and owns no
* mutable cross-plugin relation stream/reconnect sequencing is exercised
* directly by its behavior specs, rpcId round-trip discipline is owned by the
* apiproxy contract layer, and the node half's single route registration's
* register/dispose symmetry is audited by the webserver package's invariant.
* No runtime invariant: browser-session verification reads the credential
* record asynchronously at the request that authorizes work, while the
* credentials companion owns record commit-event lifetime. Stream/reconnect
* sequencing is exercised directly by behavior specs, rpcId round-trip
* discipline belongs to apiproxy, and route register/dispose symmetry is
* audited by the webserver companion.
*/
const install: InvariantInstaller = () => {}
+32 -21
View File
@@ -13,12 +13,14 @@ import {
import { bridge, type FetchHandler } from './http-bridge.ts'
import { isTrustedApiRequest } from './api-request-trust.ts'
import { API_PATH } from './api-path.ts'
import type { BrowserAuth } from './browser-auth.ts'
import type {
ConnectionIndexRequest,
ConnectionIndexResponse,
ConnectionRpcEndpointMatcher,
ConnectionRpcHandler,
ConnectionRpcHandlerOptions,
ConnectionRpcResult,
ConnectionRpcAuthority,
ConnectionRequestRejection,
ConnectionTrustRequest,
HostConnectionHandle,
HostConnectionRpc,
@@ -31,7 +33,6 @@ const ENDPOINT_SEGMENT_PATTERN = /^[A-Za-z0-9_$.-]+$/
interface ConnectionRpcInterceptor {
readonly matches: ConnectionRpcEndpointMatcher
readonly fetchHandler: FetchHandler
readonly options: ConnectionRpcHandlerOptions
}
interface ConnectionServerResponse {
@@ -54,9 +55,14 @@ export class HostConnectionService extends Service implements HostConnectionHand
/**
* Provide the Host half over the active HTTP server.
* @param ctx - owning Connection plugin context.
* @param trustedHosts - deployment authorities accepted by trusted-host channels.
* @param trustedHosts - deployment authorities accepted by the Host/Origin fence.
* @param browserAuth - process token and persistent browser-session owner.
*/
constructor(ctx: Context, private readonly trustedHosts: readonly string[]) {
constructor(
ctx: Context,
private readonly trustedHosts: readonly string[],
private readonly browserAuth: BrowserAuth,
) {
super(ctx, 'connection')
}
@@ -64,15 +70,26 @@ export class HostConnectionService extends Service implements HostConnectionHand
get rpc(): HostConnectionRpc {
const owner = this.ctx
return {
handle: (channel, handler, options) => this.register(owner, channel, handler, options),
intercept: (channel, matches, handler, options) =>
this.registerInterceptor(owner, channel, matches, handler, options),
handle: (channel, handler) => this.register(owner, channel, handler),
intercept: (channel, matches, handler) =>
this.registerInterceptor(owner, channel, matches, handler),
}
}
/** Apply the existing configured request trust policy to a sibling Web route. */
isTrustedRequest(request: ConnectionTrustRequest, authority: ConnectionRpcAuthority): boolean {
return isTrustedApiRequest(request, authority === 'loopback' ? [] : this.trustedHosts)
/** Apply the configured Host/Origin fence, then browser authentication. */
async requestRejection(request: ConnectionTrustRequest): Promise<ConnectionRequestRejection> {
if (!isTrustedApiRequest(request, this.trustedHosts)) return 403
return await this.browserAuth.isAuthenticated(request) ? undefined : 401
}
/** Authenticate an index request through the process-token exchange or cookie. */
authorizeIndex(request: ConnectionIndexRequest, response: ConnectionIndexResponse): Promise<boolean> {
return this.browserAuth.authorizeIndex(request, response)
}
/** Add this process's launch token to the clean application URL. */
authenticatedUrl(baseUrl: string): string {
return this.browserAuth.authenticatedUrl(baseUrl)
}
/**
@@ -92,9 +109,6 @@ export class HostConnectionService extends Service implements HostConnectionHand
if (endpoint === undefined || interceptor === undefined || !interceptor.matches(endpoint)) {
return fallback.fetch(request)
}
if (interceptor.options.authority === 'loopback' && !isTrustedApiRequest(request, [])) {
return Promise.resolve(new Response('forbidden', { status: 403 }))
}
return interceptor.fetchHandler.fetch(request)
},
}
@@ -104,18 +118,17 @@ export class HostConnectionService extends Service implements HostConnectionHand
owner: Context,
channel: string,
handler: ConnectionRpcHandler,
options: ConnectionRpcHandlerOptions,
): () => Promise<void> {
assertChannel(channel)
const trustedHosts = options.authority === 'loopback' ? [] : this.trustedHosts
const fetchHandler = rpcFetchHandler(channel, handler)
const route: WebRoute = {
kind: 'prefix',
path: channel,
handler: async (req, res) => {
if (!isTrustedApiRequest(req, trustedHosts)) {
res.writeHead(403)
res.end('forbidden')
const rejection = await this.requestRejection(req)
if (rejection !== undefined) {
res.writeHead(rejection)
res.end(rejection === 401 ? 'unauthorized' : 'forbidden')
return
}
await bridge(req, res, fetchHandler)
@@ -132,7 +145,6 @@ export class HostConnectionService extends Service implements HostConnectionHand
channel: string,
matches: ConnectionRpcEndpointMatcher,
handler: ConnectionRpcHandler,
options: ConnectionRpcHandlerOptions,
): () => Promise<void> {
if (channel !== API_PATH) {
throw new Error(`connection: invalid shared RPC channel ${JSON.stringify(channel)}`)
@@ -140,7 +152,6 @@ export class HostConnectionService extends Service implements HostConnectionHand
const interceptor: ConnectionRpcInterceptor = {
matches,
fetchHandler: rpcFetchHandler(channel, handler),
options,
}
return owner.effect(() => {
if (this.interceptors.has(channel)) {
+33 -16
View File
@@ -12,19 +12,25 @@ export type ConnectionRpcResult<T> =
| { readonly ok: true; readonly value: T }
| { readonly ok: false; readonly error: ConnectionRpcFailure }
/** HTTP request facts consumed by the existing browser trust fence. */
/** HTTP request facts consumed by browser trust and authentication. */
export interface ConnectionTrustRequest {
/** Request headers supplied by either the Fetch or node:http representation. */
readonly headers: Headers | Readonly<Record<string, string | readonly string[] | undefined>>
}
/** Trust fence applied before a Host RPC channel reaches its handler. */
export type ConnectionRpcAuthority = 'trusted-host' | 'loopback'
/** HTTP status returned before dispatch, or undefined when the request may proceed. */
export type ConnectionRequestRejection = 401 | 403 | undefined
/** Registration policy for one logical RPC channel. */
export interface ConnectionRpcHandlerOptions {
/** Browser authority accepted by every endpoint in this channel. */
readonly authority: ConnectionRpcAuthority
/** Root/index request facts used by the browser-token exchange. */
export interface ConnectionIndexRequest extends ConnectionTrustRequest {
readonly method?: string | undefined
readonly url?: string | undefined
}
/** Root/index response operations owned by the browser-token exchange. */
export interface ConnectionIndexResponse {
writeHead(status: number, headers?: Readonly<Record<string, string>>): unknown
end(body?: string): unknown
}
/** Handler invoked after Connection has decoded the transport envelope. */
@@ -40,16 +46,14 @@ export type ConnectionRpcEndpointMatcher = (endpoint: string) => boolean
/** Host registry for logical RPC channels carried by the current transport. */
export interface HostConnectionRpc {
/**
* Register one absolute channel prefix and its trust policy.
* Register one authenticated absolute channel prefix.
* @param channel - absolute logical channel such as `/rpc`.
* @param handler - decoded endpoint handler returning the existing RPC result shape.
* @param options - channel trust policy.
* @returns asynchronous disposer removing the channel and its physical route.
*/
handle(
channel: string,
handler: ConnectionRpcHandler,
options: ConnectionRpcHandlerOptions,
): () => Promise<void>
/**
@@ -57,14 +61,12 @@ export interface HostConnectionRpc {
* @param channel - reserved shared channel; currently `/api`.
* @param matches - synchronous endpoint ownership test.
* @param handler - decoded endpoint handler returning the existing RPC result shape.
* @param options - trust policy for every endpoint claimed by this interceptor.
* @returns asynchronous disposer removing the interceptor.
*/
intercept(
channel: '/api',
matches: ConnectionRpcEndpointMatcher,
handler: ConnectionRpcHandler,
options: ConnectionRpcHandlerOptions,
): () => Promise<void>
}
@@ -74,12 +76,27 @@ export interface HostConnectionHandle {
readonly rpc: HostConnectionRpc
/**
* Apply Connection's configured browser trust policy to another Web route.
* Apply Connection's Host/Origin checks and browser authentication to
* another Web route.
* @param request - request headers from the HTTP or upgrade request.
* @param authority - configured trusted hosts or loopback-only policy.
* @returns whether the route may accept the request.
* @returns rejection status, or undefined when the route may accept the request.
*/
isTrustedRequest(request: ConnectionTrustRequest, authority: ConnectionRpcAuthority): boolean
requestRejection(request: ConnectionTrustRequest): Promise<ConnectionRequestRejection>
/**
* Authenticate one frontend index request, owning a token redirect or 401.
* @param request - root or configured-index HTTP request.
* @param response - response owned when the result is false.
* @returns true only when the frontend may serve index.html.
*/
authorizeIndex(request: ConnectionIndexRequest, response: ConnectionIndexResponse): Promise<boolean>
/**
* Add the fresh process token to an ordinary Web application URL.
* @param baseUrl - clean canonical browser origin.
* @returns root URL accepted by {@link authorizeIndex} for initial login.
*/
authenticatedUrl(baseUrl: string): string
}
/** Client caller for logical RPC channels carried by the current transport. */
@@ -0,0 +1,231 @@
/** Browser launch-token and persistent-cookie behavior. */
import { createHmac } from 'node:crypto'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { CredentialProvider, CredentialRecord } from '@deepseek-ai/dsh-credentials'
import { BrowserAuth } from '../src/browser-auth.ts'
import type { ConnectionIndexRequest, ConnectionIndexResponse } from '../src/rpc.ts'
class RecordCredentials {
record: CredentialRecord | undefined
discardWrites = false
readRecord(): Promise<CredentialRecord | undefined> {
return Promise.resolve(this.record)
}
async modifyRecord(
_key: unknown,
mutate: (current: CredentialRecord | undefined) => Promise<CredentialRecord | undefined>,
): Promise<CredentialRecord | undefined> {
const next = await mutate(this.record)
if (this.discardWrites) return undefined
if (next !== undefined) this.record = next
return this.record
}
deleteRecord(): Promise<void> {
this.record = undefined
return Promise.resolve()
}
}
function signedCookie(store: RecordCredentials, name: string, payload: unknown): string {
const record = store.record
if (record?.kind !== 'grant' || typeof record.payload !== 'object' || record.payload === null) {
throw new Error('test credential store has no signing secret')
}
const secret: unknown = Reflect.get(record.payload, 'secret')
if (typeof secret !== 'string') throw new Error('test credential record has no string secret')
const body = typeof payload === 'string'
? Buffer.from(payload, 'utf8').toString('base64url')
: Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url')
const signature = createHmac('sha256', Buffer.from(secret, 'base64url')).update(body).digest('base64url')
return `${name}=v1.${body}.${signature}`
}
interface ResponseState {
status?: number
headers?: Readonly<Record<string, string>>
body?: string
}
function response(): { value: ConnectionIndexResponse; state: ResponseState } {
const state: ResponseState = {}
return {
value: {
writeHead(status, headers) {
state.status = status
if (headers !== undefined) state.headers = headers
},
end(body) {
if (body !== undefined) state.body = body
},
},
state,
}
}
function credentials(store: RecordCredentials): CredentialProvider {
return store as unknown as CredentialProvider
}
function request(url: string, authority = '127.0.0.1:3080', init?: {
cookie?: string
method?: string
}): ConnectionIndexRequest {
return {
method: init?.method ?? 'GET',
url,
headers: {
host: authority,
...init?.cookie === undefined ? {} : { cookie: init.cookie },
},
}
}
async function exchange(
auth: BrowserAuth,
authority = '127.0.0.1:3080',
): Promise<{ cookie: string; launchUrl: string; state: ResponseState }> {
const launchUrl = auth.authenticatedUrl(`http://${authority}`)
const target = new URL(launchUrl)
const res = response()
expect(await auth.authorizeIndex(request(`${target.pathname}${target.search}`, authority), res.value)).toBe(false)
const setCookie = res.state.headers?.['set-cookie']
if (setCookie === undefined) throw new Error('token exchange did not set a cookie')
return { cookie: setCookie.split(';', 1)[0]!, launchUrl, state: res.state }
}
afterEach(() => {
vi.useRealTimers()
})
describe('BrowserAuth', () => {
it('mints one process token and a persistent authority-bound cookie', async () => {
const store = new RecordCredentials()
const first = await BrowserAuth.create(credentials(store), 30)
const login = await exchange(first)
expect(login.state).toMatchObject({
status: 303,
headers: {
'cache-control': 'no-store',
'location': '/',
'referrer-policy': 'no-referrer',
},
})
expect(login.state.headers?.['set-cookie']).toMatch(/; Max-Age=2592000; Path=\/; Expires=.*; HttpOnly; SameSite=Strict$/u)
expect(login.state.headers?.['set-cookie']).not.toContain('Secure')
expect(await first.isAuthenticated(request('/', '127.0.0.1:3080', { cookie: login.cookie }))).toBe(true)
expect(await first.isAuthenticated({
headers: new Headers({ host: '127.0.0.1:3080', cookie: login.cookie }),
})).toBe(true)
expect(await first.isAuthenticated({ headers: new Headers() })).toBe(false)
expect(await first.isAuthenticated(request('/', 'localhost:3080', { cookie: login.cookie }))).toBe(false)
expect(await first.isAuthenticated(request('/', '127.0.0.1:3081', { cookie: login.cookie }))).toBe(false)
const restarted = await BrowserAuth.create(credentials(store), 30)
expect(new URL(restarted.authenticatedUrl('http://127.0.0.1:3080')).searchParams.get('token'))
.not.toBe(new URL(login.launchUrl).searchParams.get('token'))
expect(await restarted.isAuthenticated(request('/', '127.0.0.1:3080', { cookie: login.cookie }))).toBe(true)
})
it('accepts the cookie for index serving and gives every unauthenticated request one response', async () => {
const auth = await BrowserAuth.create(credentials(new RecordCredentials()), 30)
const { cookie } = await exchange(auth)
const allowed = response()
expect(await auth.authorizeIndex(request('/index.html', '127.0.0.1:3080', { cookie }), allowed.value)).toBe(true)
expect(allowed.state).toEqual({})
for (const candidate of [
request('/'),
request('/?token=wrong'),
request('/?token=wrong&token=again'),
request('/index.html?token=wrong'),
request(auth.authenticatedUrl('http://127.0.0.1:3080'), '127.0.0.1:3080', { method: 'HEAD' }),
]) {
const denied = response()
expect(await auth.authorizeIndex(candidate, denied.value)).toBe(false)
expect(denied.state.status).toBe(401)
expect(denied.state.headers).toEqual({
'cache-control': 'no-store',
'content-type': 'text/plain; charset=utf-8',
})
expect(denied.state.body).toBe(candidate.method === 'HEAD'
? undefined
: 'dsh web authentication required; reopen the URL printed by dsh web.\n')
}
})
it('rejects tampering, expiry, future issuance, and a longer lifetime than configured', async () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-08-24T00:00:00.000Z'))
const store = new RecordCredentials()
const auth = await BrowserAuth.create(credentials(store), 30)
const { cookie } = await exchange(auth)
const [name, value] = cookie.split('=') as [string, string]
expect(await auth.isAuthenticated(request('/', '127.0.0.1:3080', { cookie: `${name}=broken` }))).toBe(false)
expect(await auth.isAuthenticated(request('/', '127.0.0.1:3080', { cookie: `${name}=${value.slice(0, -1)}x` }))).toBe(false)
expect(await auth.isAuthenticated(request('/', '127.0.0.1:3080', { cookie: `${name}=%` }))).toBe(false)
expect(await auth.isAuthenticated({ headers: {} })).toBe(false)
expect(await auth.isAuthenticated({ headers: { host: 'bad host', cookie } })).toBe(false)
expect(await auth.isAuthenticated({ headers: { host: '127.0.0.1:3080' } })).toBe(false)
const invalidPayloads: unknown[] = [
'not json',
null,
{ version: 2, authority: '127.0.0.1:3080', issuedAt: Date.now(), expiresAt: Date.now() + 1000 },
{ version: 1, authority: 42, issuedAt: Date.now(), expiresAt: Date.now() + 1000 },
{ version: 1, authority: '127.0.0.1:3080', issuedAt: 'now', expiresAt: Date.now() + 1000 },
{ version: 1, authority: '127.0.0.1:3080', issuedAt: Date.now(), expiresAt: 'later' },
]
for (const payload of invalidPayloads) {
expect(await auth.isAuthenticated(request('/', '127.0.0.1:3080', {
cookie: signedCookie(store, name, payload),
}))).toBe(false)
}
const shorter = await BrowserAuth.create(credentials(store), 1)
expect(await shorter.isAuthenticated(request('/', '127.0.0.1:3080', { cookie }))).toBe(false)
vi.setSystemTime(new Date('2026-09-24T00:00:00.000Z'))
expect(await auth.isAuthenticated(request('/', '127.0.0.1:3080', { cookie }))).toBe(false)
vi.setSystemTime(new Date('2026-08-23T00:00:00.000Z'))
expect(await auth.isAuthenticated(request('/', '127.0.0.1:3080', { cookie }))).toBe(false)
})
it('revokes on record deletion and creates a new secret on the next token exchange', async () => {
const store = new RecordCredentials()
const auth = await BrowserAuth.create(credentials(store), 30)
const first = await exchange(auth)
await store.deleteRecord()
expect(await auth.isAuthenticated(request('/', '127.0.0.1:3080', { cookie: first.cookie }))).toBe(false)
const second = await exchange(auth)
expect(second.cookie).not.toBe(first.cookie)
expect(await auth.isAuthenticated(request('/', '127.0.0.1:3080', { cookie: first.cookie }))).toBe(false)
expect(await auth.isAuthenticated(request('/', '127.0.0.1:3080', { cookie: second.cookie }))).toBe(true)
})
it('fails loud on an invalid owner record instead of replacing it', async () => {
const unsupported = new RecordCredentials()
unsupported.record = { kind: 'api-key', key: 'not-a-cookie-secret' }
await expect(BrowserAuth.create(credentials(unsupported), 30)).rejects.toThrow(/unsupported format/u)
const malformed = new RecordCredentials()
malformed.record = { kind: 'grant', payload: { version: 1, secret: 'short' } }
await expect(BrowserAuth.create(credentials(malformed), 30)).rejects.toThrow(/invalid secret/u)
const nonString = new RecordCredentials()
nonString.record = { kind: 'grant', payload: { version: 1, secret: 42 } }
await expect(BrowserAuth.create(credentials(nonString), 30)).rejects.toThrow(/invalid secret/u)
const discarded = new RecordCredentials()
discarded.discardWrites = true
await expect(BrowserAuth.create(credentials(discarded), 30)).rejects.toThrow(/was not created/u)
await expect(BrowserAuth.create(credentials(new RecordCredentials()), Number.MAX_SAFE_INTEGER))
.rejects.toThrow(/safe timestamp range/u)
})
})
@@ -12,6 +12,7 @@ import { RpcId, type ClientRequest } from '@deepseek-ai/dsh-host-apiproxy/api'
import type { WebServer, WebRoute, WebUpgradeRoute } from '@deepseek-ai/dsh-host-webserver'
import { API_PATH, apply, inject, type HostConnectionHandle } from '../src/index.ts'
import { DEFAULT_MAX_REQUEST_BODY_BYTES } from '../src/http-bridge.ts'
import { MemoryCredentials } from '../../../credentials/credentials/tests/memory.ts'
/** Structural webServer fake recording both route registries. */
function fakeHttpServer(
@@ -57,12 +58,19 @@ function fakeRawPost(headers: Record<string, string>, url: string, body: string)
}
/** Response recorder compatible with both the fence's short-circuit and the bridge. */
function fakeResponse(): { response: ServerResponse; state: { status?: number; body?: unknown } } {
const state: { status?: number; body?: unknown } = {}
function fakeResponse(): {
response: ServerResponse
state: { status?: number; headers?: Record<string, string>; body?: unknown }
} {
const state: { status?: number; headers?: Record<string, string>; body?: unknown } = {}
const chunks: Buffer[] = []
const response = Object.assign(new EventEmitter(), {
writableEnded: false,
writeHead(value: number) { state.status = value; return this },
writeHead(value: number, headers?: Record<string, string>) {
state.status = value
if (headers !== undefined) state.headers = headers
return this
},
write(value: string | Uint8Array) { chunks.push(Buffer.from(value)); return true },
end(this: { writableEnded: boolean }, value?: unknown) {
if (typeof value === 'string' || value instanceof Uint8Array) chunks.push(Buffer.from(value))
@@ -84,6 +92,7 @@ async function mounted(config?: { trustedHosts?: string[] }): Promise<{
const ctx = new Context()
const routes: WebRoute[] = []
const upgrades: WebUpgradeRoute[] = []
await ctx.plugin(MemoryCredentials)
ctx.provide('webServer', fakeHttpServer(routes, upgrades) as WebServer)
ctx.provide('apiProxy', {} as unknown as ApiProxy)
const fiber = ctx.plugin({ inject: [...inject], apply }, config)
@@ -96,13 +105,26 @@ async function mounted(config?: { trustedHosts?: string[] }): Promise<{
}
}
/** Exchange a service's process token for one authority-bound Cookie header. */
async function browserCookie(connection: HostConnectionHandle, authority: string): Promise<string> {
const url = new URL(connection.authenticatedUrl(`http://${authority}`))
const exchanged = fakeResponse()
await connection.authorizeIndex(
fakeRequest({ host: authority }, `${url.pathname}${url.search}`),
exchanged.response,
)
const setCookie = exchanged.state.headers?.['set-cookie']
if (setCookie === undefined) throw new Error('browser token exchange did not set a cookie')
return setCookie.split(';', 1)[0]!
}
describe('connection node half', () => {
it('reserves enough default carrier capacity for the 200 MiB image batch', () => {
expect(DEFAULT_MAX_REQUEST_BODY_BYTES).toBe(300 * 1024 * 1024)
expect(DEFAULT_MAX_REQUEST_BODY_BYTES).toBeGreaterThan(Math.ceil(200 * 1024 * 1024 * 4 / 3) + 1024 * 1024)
})
it('fails loud when the carrier cap cannot hold the configured image batch', () => {
it('fails loud when the carrier cap cannot hold the configured image batch', async () => {
const ctx = new Context()
const routes: WebRoute[] = []
ctx.provide('webServer', fakeHttpServer(routes, []) as WebServer)
@@ -110,8 +132,8 @@ describe('connection node half', () => {
imageLimits: { maxMessageImageBytes: 20 * 1024 * 1024 },
} as AttachmentStore)
ctx.provide('apiProxy', {} as ApiProxy)
expect(() => { apply(ctx, { maxRequestBodyBytes: 1024 }) })
.toThrow(/must be at least .* aggregate image limit/)
await expect(apply(ctx, { maxRequestBodyBytes: 1024 }))
.rejects.toThrow(/must be at least .* aggregate image limit/)
expect(routes).toHaveLength(0)
})
@@ -119,6 +141,7 @@ describe('connection node half', () => {
const routes: WebRoute[] = []
const upgrades: WebUpgradeRoute[] = []
const ctx = new Context()
await ctx.plugin(MemoryCredentials)
ctx.provide('webServer', fakeHttpServer(routes, upgrades) as WebServer)
ctx.provide('apiProxy', {} as unknown as ApiProxy)
const fiber = ctx.plugin({ inject: [...inject], apply }, { trustedHosts: ['harness.internal/path'] })
@@ -148,72 +171,83 @@ describe('connection node half', () => {
await dispose()
})
it('pins privileged methods to loopback even for a declared trusted authority', async () => {
const { routes, dispose } = await mounted({ trustedHosts: ['harness.example'] })
// The privileged set: native dialogs plus the whole settings/credential
// configuration plane, reads included, plus the one method that makes the
// host fetch a caller-chosen URL. The same declared authority reaches
// ordinary reads (carrier-level 404 from the empty proxy proves the fence
// passed), but each privileged method stays loopback-only and 403s.
for (const method of [
it('requires the same browser session for every method on every trusted authority', async () => {
const { routes, connection, dispose } = await mounted({ trustedHosts: ['harness.example'] })
const methods = [
'host.pickDirectory', 'host.openPath',
'settings.describe', 'settings.openDocument', 'settings.update', 'settings.replace', 'settings.mutate',
'credentials.describe', 'credentials.set', 'credentials.unset',
'llm.discoverModels',
// A composition names the plugins a session runs: reading one is
// reconnaissance, and copy/remove/openDocument manage the roster and
// drive the host desktop.
'agentPreset.read', 'agentPreset.copy', 'agentPreset.openDocument', 'agentPreset.remove',
]) {
'settings.describe', 'settings.update', 'credentials.describe', 'credentials.set',
'llm.discoverModels', 'llm.models', 'agentPreset.read', 'agentPreset.list',
]
for (const method of methods) {
const denied = fakeResponse()
await routes[0]!.handler(
fakeRequest({ host: 'harness.example' }, `${API_PATH}/${method}`),
denied.response,
)
expect(denied.state.status).toBe(403)
expect(denied.state.body).toBe('forbidden')
await routes[0]!.handler(fakeRequest({ host: 'harness.example' }, `${API_PATH}/${method}`), denied.response)
expect([method, denied.state.status, denied.state.body]).toEqual([method, 401, 'unauthorized'])
}
const read = fakeResponse()
await routes[0]!.handler(fakeRequest({ host: 'harness.example' }), read.response)
expect(read.state.status).not.toBe(403)
const cookie = await browserCookie(connection, 'harness.example')
for (const method of methods) {
const allowed = fakeResponse()
await routes[0]!.handler(
fakeRequest({ host: 'harness.example', cookie }, `${API_PATH}/${method}`),
allowed.response,
)
expect([method, allowed.state.status]).toEqual([method, 404])
}
const forged = fakeResponse()
await routes[0]!.handler(fakeRequest({ host: 'localhost:3080' }), forged.response)
expect(forged.state).toMatchObject({ status: 401, body: 'unauthorized' })
await dispose()
})
it('passes loopback and declared-authority requests through to the bridge', async () => {
const { routes, dispose } = await mounted({ trustedHosts: ['harness.example:3080', '192.168.1.5'] })
const { routes, connection, dispose } = await mounted({ trustedHosts: ['harness.example:3080', '192.168.1.5'] })
// Loopback, no browser markers (curl shape): the fence passes; the carrier
// answers 404 for a GET unary path — proof the bridge ran.
const loopback = fakeResponse()
await routes[0]!.handler(fakeRequest({ host: '127.0.0.1:3080' }), loopback.response)
await routes[0]!.handler(fakeRequest({
host: '127.0.0.1:3080',
cookie: await 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' }), lan.response)
await routes[0]!.handler(fakeRequest({
host: '192.168.1.5:3080',
cookie: await browserCookie(connection, '192.168.1.5:3080'),
}), lan.response)
expect(lan.state.status).toBe(404)
// Declared public authority, same-origin browser shape.
const declared = fakeResponse()
await routes[0]!.handler(fakeRequest({
host: 'harness.example:3080', origin: 'http://harness.example:3080', 'sec-fetch-site': 'same-origin',
host: 'harness.example:3080',
origin: 'http://harness.example:3080',
'sec-fetch-site': 'same-origin',
cookie: await browserCookie(connection, 'harness.example:3080'),
}), declared.response)
expect(declared.state.status).toBe(404)
await dispose()
})
it('shares its configured trust policy with sibling routes', async () => {
it('shares its configured trust and authentication policy with sibling routes', async () => {
const { connection, dispose } = await mounted({ trustedHosts: ['harness.example'] })
const loopback = fakeRequest({ host: '127.0.0.1:3080' })
const declared = fakeRequest({ host: 'harness.example' })
expect(connection.isTrustedRequest(loopback, 'loopback')).toBe(true)
expect(connection.isTrustedRequest(declared, 'loopback')).toBe(false)
expect(connection.isTrustedRequest(declared, 'trusted-host')).toBe(true)
expect(await connection.requestRejection(loopback)).toBe(401)
expect(await connection.requestRejection(declared)).toBe(401)
expect(await connection.requestRejection(fakeRequest({
host: 'harness.example',
cookie: await browserCookie(connection, 'harness.example'),
}))).toBeUndefined()
await dispose()
})
it('provides a disposable dedicated RPC channel without requiring apiProxy', async () => {
const ctx = new Context()
const routes: WebRoute[] = []
await ctx.plugin(MemoryCredentials)
ctx.provide('webServer', fakeHttpServer(routes, []) as WebServer)
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
@@ -225,7 +259,7 @@ describe('connection node half', () => {
const remove = connection.rpc.handle('/rpc', async (endpoint, payload) => {
calls.push({ endpoint, payload })
return { ok: true, value: { accepted: true } }
}, { authority: 'trusted-host' })
})
const route = routes.find(candidate => candidate.path === '/rpc')
expect(route).toBeDefined()
@@ -236,7 +270,10 @@ describe('connection node half', () => {
payload: { args: { agentId: 'agent-1' } },
}
const result = fakeResponse()
await route!.handler(fakePost({ host: '127.0.0.1:3080' }, '/rpc/goals/create', request), result.response)
await route!.handler(fakePost({
host: '127.0.0.1:3080',
cookie: await browserCookie(connection, '127.0.0.1:3080'),
}, '/rpc/goals/create', request), result.response)
expect(result.state.status).toBe(200)
expect(JSON.parse(String(result.state.body))).toEqual({
type: 'server-response',
@@ -248,9 +285,8 @@ describe('connection node half', () => {
payload: { args: { agentId: 'agent-1' } },
}])
expect(() => connection.rpc.handle('/rpc', async () => ({ ok: true, value: null }), {
authority: 'trusted-host',
})).toThrow(/duplicate route/)
expect(() => connection.rpc.handle('/rpc', async () => ({ ok: true, value: null })))
.toThrow(/duplicate route/)
await remove()
expect(routes.map(candidate => candidate.path)).toEqual([API_PATH])
await fiber.dispose()
@@ -260,6 +296,7 @@ describe('connection node half', () => {
it('dispatches claimed /api endpoints before the API Proxy fallback and withdraws the claim', async () => {
const ctx = new Context()
const routes: WebRoute[] = []
await ctx.plugin(MemoryCredentials)
ctx.provide('webServer', fakeHttpServer(routes, []) as WebServer)
ctx.provide('apiProxy', {} as unknown as ApiProxy)
const fiber = ctx.plugin({ inject: [...inject], apply }, { trustedHosts: ['harness.example'] })
@@ -273,19 +310,16 @@ describe('connection node half', () => {
calls.push({ endpoint, payload })
return { ok: true, value: { accepted: true } }
},
{ authority: 'trusted-host' },
)
expect(() => connection.rpc.intercept(
'/api',
() => true,
async () => ({ ok: true, value: null }),
{ authority: 'trusted-host' },
)).toThrow('already has an interceptor')
expect(() => connection.rpc.intercept(
'/rpc' as '/api',
() => true,
async () => ({ ok: true, value: null }),
{ authority: 'trusted-host' },
)).toThrow('invalid shared RPC channel')
const route = routes.find(candidate => candidate.path === API_PATH)!
const request: ClientRequest = {
@@ -296,7 +330,10 @@ describe('connection node half', () => {
}
const claimed = fakeResponse()
await route.handler(fakePost({ host: '127.0.0.1:3080' }, '/api/goals/create', request), claimed.response)
const loopbackCookie = await browserCookie(connection, '127.0.0.1:3080')
await route.handler(fakePost({
host: '127.0.0.1:3080', cookie: loopbackCookie,
}, '/api/goals/create', request), claimed.response)
expect(JSON.parse(String(claimed.state.body))).toEqual({
type: 'server-response',
rpcId: 'rpc-shared',
@@ -313,31 +350,38 @@ describe('connection node half', () => {
expect(calls).toHaveLength(1)
const unclaimed = fakeResponse()
await route.handler(fakeRequest({ host: '127.0.0.1:3080' }, '/api/session.list'), unclaimed.response)
await route.handler(fakeRequest({
host: '127.0.0.1:3080', cookie: loopbackCookie,
}, '/api/session.list'), unclaimed.response)
expect(unclaimed.state.status).toBe(404)
await remove()
const withdrawn = fakeResponse()
await route.handler(fakePost({ host: '127.0.0.1:3080' }, '/api/goals/create', request), withdrawn.response)
await route.handler(fakePost({
host: '127.0.0.1:3080', cookie: loopbackCookie,
}, '/api/goals/create', request), withdrawn.response)
expect(withdrawn.state.status).toBe(404)
expect(calls).toHaveLength(1)
const removeLoopback = connection.rpc.intercept(
const removeAuthenticated = connection.rpc.intercept(
'/api',
endpoint => endpoint === 'goals/create',
async () => ({ ok: true, value: null }),
{ authority: 'loopback' },
)
const loopbackOnly = fakeResponse()
await route.handler(fakePost({ host: 'harness.example' }, '/api/goals/create', request), loopbackOnly.response)
expect(loopbackOnly.state.status).toBe(403)
await removeLoopback()
const declared = fakeResponse()
await route.handler(fakePost({
host: 'harness.example',
cookie: await browserCookie(connection, 'harness.example'),
}, '/api/goals/create', request), declared.response)
expect(declared.state.status).toBe(200)
await removeAuthenticated()
await fiber.dispose()
})
it('applies the configured trust fence and JSON envelope checks to generic channels', async () => {
const ctx = new Context()
const routes: WebRoute[] = []
await ctx.plugin(MemoryCredentials)
ctx.provide('webServer', fakeHttpServer(routes, []) as WebServer)
const fiber = ctx.plugin({ inject: [...inject], apply }, { trustedHosts: ['harness.example'] })
await fiber.await()
@@ -345,17 +389,23 @@ describe('connection node half', () => {
const remove = connection.rpc.handle('/rpc', async (endpoint) => {
if (endpoint === 'fail') throw new Error('handler broke')
return { ok: true, value: null }
}, {
authority: 'trusted-host',
})
const route = routes.find(candidate => candidate.path === '/rpc')!
const harnessHeaders = {
host: 'harness.example',
cookie: await browserCookie(connection, 'harness.example'),
}
const denied = fakeResponse()
await route.handler(fakePost({ host: 'other.example' }, '/rpc/goals/create', {}), denied.response)
expect(denied.state).toMatchObject({ status: 403, body: 'forbidden' })
const unauthenticated = fakeResponse()
await route.handler(fakePost({ host: 'harness.example' }, '/rpc/goals/create', {}), unauthenticated.response)
expect(unauthenticated.state).toMatchObject({ status: 401, body: 'unauthorized' })
const methodMismatch = fakeResponse()
await route.handler(fakePost({ host: 'harness.example' }, '/rpc/goals/create', {
await route.handler(fakePost(harnessHeaders, '/rpc/goals/create', {
type: 'client-request', rpcId: 'rpc-bad', method: 'other', payload: {},
}), methodMismatch.response)
expect(JSON.parse(String(methodMismatch.state.body))).toMatchObject({
@@ -364,12 +414,12 @@ describe('connection node half', () => {
})
for (const [request, status] of [
[fakeRequest({ host: 'harness.example' }, '/rpc/goals/create'), 404],
[fakePost({ host: 'harness.example' }, '/outside/goals/create', {}), 404],
[fakePost({ host: 'harness.example' }, '/rpc/goals//create', {}), 404],
[fakeRawPost({ host: 'harness.example' }, '/rpc/goals/create', '{}'), 415],
[fakeRawPost({ host: 'harness.example', 'content-type': 'text/plain' }, '/rpc/goals/create', '{}'), 415],
[fakeRawPost({ host: 'harness.example', 'content-type': 'application/json; charset=utf-8' }, '/rpc/goals/create', '{'), 400],
[fakeRequest(harnessHeaders, '/rpc/goals/create'), 404],
[fakePost(harnessHeaders, '/outside/goals/create', {}), 404],
[fakePost(harnessHeaders, '/rpc/goals//create', {}), 404],
[fakeRawPost(harnessHeaders, '/rpc/goals/create', '{}'), 415],
[fakeRawPost({ ...harnessHeaders, 'content-type': 'text/plain' }, '/rpc/goals/create', '{}'), 415],
[fakeRawPost({ ...harnessHeaders, 'content-type': 'application/json; charset=utf-8' }, '/rpc/goals/create', '{'), 400],
] as const) {
const response = fakeResponse()
await route.handler(request, response.response)
@@ -382,7 +432,7 @@ describe('connection node half', () => {
[null, 'invalid-request'],
] as const) {
const response = fakeResponse()
await route.handler(fakePost({ host: 'harness.example' }, '/rpc/goals/create', body), response.response)
await route.handler(fakePost(harnessHeaders, '/rpc/goals/create', body), response.response)
expect(JSON.parse(String(response.state.body))).toMatchObject({
rpcId,
result: { ok: false, error: { code: 'bad-request' } },
@@ -390,28 +440,15 @@ describe('connection node half', () => {
}
const failed = fakeResponse()
await route.handler(fakePost({ host: 'harness.example' }, '/rpc/fail', {
await route.handler(fakePost(harnessHeaders, '/rpc/fail', {
type: 'client-request', rpcId: 'rpc-fail', method: 'fail', payload: {},
}), failed.response)
expect(failed.state).toMatchObject({ status: 500, body: 'handler failure: Error: handler broke' })
expect(() => connection.rpc.handle('/api', async () => ({ ok: true, value: null }), {
authority: 'loopback',
})).toThrow('invalid or reserved RPC channel')
expect(() => connection.rpc.handle('api3', async () => ({ ok: true, value: null }), {
authority: 'loopback',
})).toThrow('invalid or reserved RPC channel')
const removeLoopback = connection.rpc.handle('/loopback', async () => ({ ok: true, value: null }), {
authority: 'loopback',
})
const loopbackRoute = routes.find(candidate => candidate.path === '/loopback')!
const publicResponse = fakeResponse()
await loopbackRoute.handler(fakePost({ host: 'harness.example' }, '/loopback/read', {
type: 'client-request', rpcId: 'rpc-public', method: 'read', payload: {},
}), publicResponse.response)
expect(publicResponse.state.status).toBe(403)
await removeLoopback()
expect(() => connection.rpc.handle('/api', async () => ({ ok: true, value: null })))
.toThrow('invalid or reserved RPC channel')
expect(() => connection.rpc.handle('api3', async () => ({ ok: true, value: null })))
.toThrow('invalid or reserved RPC channel')
await remove()
await fiber.dispose()
})
@@ -437,10 +474,16 @@ describe('connection node half over a real HTTP server', () => {
}
/** One real request; `host` spoofs the authority the way a LAN client's browser would send it. */
function call(port: number, method: string, host: string): Promise<number> {
function call(port: number, method: string, host: string, cookie?: string): Promise<number> {
return new Promise((resolve, reject) => {
const request = httpRequest(
{ host: '127.0.0.1', port, path: `${API_PATH}/${method}`, method: 'GET', headers: { host } },
{
host: '127.0.0.1',
port,
path: `${API_PATH}/${method}`,
method: 'GET',
headers: { host, ...cookie === undefined ? {} : { cookie } },
},
(response) => {
response.resume()
response.on('end', () => { resolve(response.statusCode ?? 0) })
@@ -451,40 +494,37 @@ describe('connection node half over a real HTTP server', () => {
})
}
it('answers a declared LAN authority with 403 on every configuration method, over real HTTP', async () => {
// The fence's input is a real IncomingMessage parsed by Node from the
// wire, not a hand-assembled object: the Host header a LAN browser sends
// is exactly what decides loopback-only here, so the boundary is asserted
// against the parse the server actually performs.
const { routes, dispose } = await mounted({ trustedHosts: ['harness.example'] })
it('requires authentication uniformly over a real HTTP request', async () => {
// A real IncomingMessage pins the exploit boundary: a client-controlled
// Host naming loopback passes the rebinding fence but never authenticates.
const { routes, connection, dispose } = await mounted({ trustedHosts: ['harness.example'] })
const { port, close } = await serve(routes)
try {
// Reads are as privileged as writes: describe returns the exposed
// configuration, and credentials.describe probes arbitrary env-var names.
for (const method of [
const methods = [
'settings.describe', 'settings.openDocument', 'settings.update', 'settings.replace', 'settings.mutate',
'credentials.describe', 'credentials.set', 'credentials.unset',
'host.pickDirectory', 'host.openPath',
// Carries a draft credential and turns the host into a fetcher for a
// URL the caller picked: an anonymous LAN caller must not reach it.
'llm.discoverModels',
'agentPreset.read', 'agentPreset.copy', 'agentPreset.openDocument', 'agentPreset.remove',
]) {
expect([method, await call(port, method, 'harness.example')]).toEqual([method, 403])
'llm.providers', 'llm.models', 'agentPreset.list', 'agentPreset.select',
]
for (const method of methods) {
expect([method, await call(port, method, 'localhost')]).toEqual([method, 401])
expect([method, await call(port, method, 'harness.example')]).toEqual([method, 401])
}
// The model catalog stays reachable for the same authority: a LAN
// client's model picker needs it, and it carries no key or endpoint
// state (404 is the empty proxy's carrier answer — the fence passed).
// `agentPreset.list` joins the model catalog for the same reason: ids and
// trust only, and a LAN client's preset picker needs it. `select` is
// reachable too: `session.create` already takes an `agentPreset`, and the
// deployment's own default already carries bash, so pinning the switch
// would be a fence beside an open gate.
for (const method of ['llm.providers', 'llm.models', 'agentPreset.list', 'agentPreset.select']) {
expect([method, await call(port, method, 'harness.example')]).toEqual([method, 404])
expect(await call(port, 'settings.describe', 'other.example')).toBe(403)
const declaredCookie = await browserCookie(connection, 'harness.example')
for (const method of methods) {
expect([method, await call(port, method, 'harness.example', declaredCookie)]).toEqual([method, 404])
}
// Loopback reaches everything, configuration included.
expect(await call(port, 'settings.describe', `127.0.0.1:${String(port)}`)).toBe(404)
const loopbackAuthority = `127.0.0.1:${String(port)}`
expect(await call(
port,
'settings.describe',
loopbackAuthority,
await browserCookie(connection, loopbackAuthority),
)).toBe(404)
} finally {
await close()
await dispose()
@@ -8,6 +8,7 @@
"files": [
"src/api-path.ts",
"src/api-request-trust.ts",
"src/browser-auth.ts",
"src/http-bridge.ts",
"src/index.ts",
"src/invariant.ts",
@@ -19,6 +20,9 @@
{
"path": "../../attachment/attachment"
},
{
"path": "../../credentials/credentials"
},
{
"path": "../../host/apiproxy"
},
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/locale/README.md
README.md: 4f54a9a2c5aa9d39ee3c93a4d8f2c6deb538b792
README.zh.md: d4c4833b2334c1b21f31e9b6f4d5116bbbb8591d
README.md: 1fa0262e7c1e8aa50f12fd2b7533d97ea4737199
README.zh.md: 45cf657d5f8455fe128f378c2a2b77d919a441da
+1 -1
View File
@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
Locale plugin: LocaleRuntime — the `zh`/`en` preference stored as `locale.preference` in `$DSH_HOME/settings.yaml`; when that explicit Host value is absent, a fresh browser starts provisionally in the language `navigator` asks for (primary-subtag matching, with `en` when it asks for no language this app ships). The Host read runs after plugin activation so an unavailable settings service cannot block the page; its result replaces the provisional browser value live. Remote browsers retain only a process-local selection because the settings API is loopback-only. `locale/change` fires on switches, and the plugin points `<html lang>` at the active locale (`zh-CN`/`en`) on activation and on every switch. The service also owns the ns×locale dictionary registry (typed `register(ns, {zh, en})` checked against `LocaleNamespaceMap`, `bind(ns)``TranslateNS<ns>`; lookup chain ns → common → en → key), implements the slot system's `LocaleFace`, and installs itself through `ctx.slots.installLocale`, backing the framework-injected `t` standard seat (`Translate`/`TranslateNS` are ui-slots types; import them from there — this package only re-exports for dictionary owners' convenience). Product-authored Client UI text must enter through these typed dictionaries or an already-localized primitive prop; `verify-client-ui-i18n` enforces that source ownership ([decision](../../../.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.md)). The [Host-backed preferences decision](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md) owns the persistence boundary.
Locale plugin: LocaleRuntime — the `zh`/`en` preference stored as `locale.preference` in `$DSH_HOME/settings.yaml`; when that explicit Host value is absent, a fresh browser starts provisionally in the language `navigator` asks for (primary-subtag matching, with `en` when it asks for no language this app ships). The Host read runs after plugin activation so an unavailable settings service cannot block the page; its result replaces the provisional browser value live. The Client keeps Host settings persistence disabled on non-loopback pages, so their locale selection remains process-local even though Connection authenticates every API method. `locale/change` fires on switches, and the plugin points `<html lang>` at the active locale (`zh-CN`/`en`) on activation and on every switch. The service also owns the ns×locale dictionary registry (typed `register(ns, {zh, en})` checked against `LocaleNamespaceMap`, `bind(ns)``TranslateNS<ns>`; lookup chain ns → common → en → key), implements the slot system's `LocaleFace`, and installs itself through `ctx.slots.installLocale`, backing the framework-injected `t` standard seat (`Translate`/`TranslateNS` are ui-slots types; import them from there — this package only re-exports for dictionary owners' convenience). Product-authored Client UI text must enter through these typed dictionaries or an already-localized primitive prop; `verify-client-ui-i18n` enforces that source ownership ([decision](../../../.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.md)). The [Host-backed preferences decision](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md) owns the persistence boundary.
## Model Experience
+1 -1
View File
@@ -2,7 +2,7 @@
[English](README.md) | 中文
locale 插件:LocaleRuntime——`zh``en` 偏好以 `locale.preference` 存储在 `$DSH_HOME/settings.yaml` 中;若没有显式 Host 值,全新浏览器会暂时使用 `navigator` 请求的语言(按主子标签匹配;若其请求的语言本应用都不提供,则使用 `en`)。Host 读取在插件激活后执行,因此 settings 服务不可用不会阻塞页面;读取结果会实时替换浏览器暂定值。settings API 仅限回环请求,因此远程浏览器的选择仅保留在进程内`locale/change` 仅在切换语言时触发;插件会在激活时以及每次切换时把 `<html lang>` 指向当前 locale`zh-CN``en`)。该服务还拥有 ns×locale 字典注册表(类型化 `register(ns, {zh, en})``LocaleNamespaceMap` 校验,`bind(ns)``TranslateNS<ns>`;查找链 ns → common → en → key),实现 slot 系统的 `LocaleFace`,并经 `ctx.slots.installLocale` 自行安装,支撑框架注入的 `t` 标准席位(`Translate``TranslateNS` 是 ui-slots 的类型;请从那里导入——本包的再导出仅为字典所有者提供便利)。产品编写的 Client UI 文本必须经这些 typed 字典或已本地化原子组件 prop 进入展示;`verify-client-ui-i18n` 会强制这项源码归属([决策](../../../.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.zh.md))。该持久化边界由[Host settings 支撑的偏好决策](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.zh.md)拥有。
locale 插件:LocaleRuntime——`zh``en` 偏好以 `locale.preference` 存储在 `$DSH_HOME/settings.yaml` 中;若没有显式 Host 值,全新浏览器会暂时使用 `navigator` 请求的语言(按主子标签匹配;若其请求的语言本应用都不提供,则使用 `en`)。Host 读取在插件激活后执行,因此 settings 服务不可用不会阻塞页面;读取结果会实时替换浏览器暂定值。Client 在非 loopback 页面禁用 Host settings 持久化,因此这些页面的 locale 选择仍只保留在进程内,尽管 Connection 会认证每个 API 方法`locale/change` 仅在切换语言时触发;插件会在激活时以及每次切换时把 `<html lang>` 指向当前 locale`zh-CN``en`)。该服务还拥有 ns×locale 字典注册表(类型化 `register(ns, {zh, en})``LocaleNamespaceMap` 校验,`bind(ns)``TranslateNS<ns>`;查找链 ns → common → en → key),实现 slot 系统的 `LocaleFace`,并经 `ctx.slots.installLocale` 自行安装,支撑框架注入的 `t` 标准席位(`Translate``TranslateNS` 是 ui-slots 的类型;请从那里导入——本包的再导出仅为字典所有者提供便利)。产品编写的 Client UI 文本必须经这些 typed 字典或已本地化原子组件 prop 进入展示;`verify-client-ui-i18n` 会强制这项源码归属([决策](../../../.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.zh.md))。该持久化边界由[Host settings 支撑的偏好决策](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.zh.md)拥有。
## 模型体验
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-agent-preset/README.md
README.md: 35b667a1f02cfb56567c71350c117194e08aad6b
README.zh.md: 03420776bd3af6d0c7b6be7a9f88c1db8933901c
README.md: b7cd57fed5ea08e272c97df5bc9e96d481ff6a3a
README.zh.md: 4f828c3eec977a57ea34b20ee21ef23c0c7fc922
+1 -1
View File
@@ -50,7 +50,7 @@ A roster row carrying `broken` (the host's shape check found the composition mis
Setting the default writes the `agent-presets` settings namespace, which the host exposes to configuration clients ([`dsh-apiproxy`](../../host/apiproxy/README.md) keeps an explicit allowlist — a namespace outside it makes a picker move and then silently forget).
`agentPreset.read`, `copy`, `openDocument`, and `remove` are loopback-pinned ([`dsh-client-connection`](../connection/README.md)): a composition names the plugins a session runs, so reading one is reconnaissance, and the rest manage the roster and drive the host desktop. `agentPreset.list` is not — it carries ids, trust, and the two path-free capability flags, and a LAN client's picker needs it.
[`dsh-client-connection`](../connection/README.md) authenticates `agentPreset.read`, `copy`, `openDocument`, `remove`, `list`, and every other Host API method with the same browser session. A composition still names the plugins a session runs, so reading one is reconnaissance, while copy/remove/openDocument manage the roster and drive the host desktop.
## When the surfaces are absent
+1 -1
View File
@@ -50,7 +50,7 @@ preset 自行发布描述,长度不限,而网格让每一行卡片等高—
设置默认值写入的是 `agent-presets` settings 命名空间,宿主需将其暴露给配置客户端([`dsh-apiproxy`](../../host/apiproxy/README.zh.md) 维护一份显式白名单——不在其中的命名空间会让选择器动一下然后悄悄忘记)。
`agentPreset.read``copy``openDocument``remove` 被固定在环回地址(见 [`dsh-client-connection`](../connection/README.zh.md)):组装指明一个会话所运行的插件,因此读取它是侦察,其余几个则管理名单并驱动宿主桌面。`agentPreset.list` 不在其中——它携带 id、信任级别与两个不含路径的能力标志,而局域网客户端的选择器需要它
[`dsh-client-connection`](../connection/README.zh.md) 用同一浏览器会话认证 `agentPreset.read``copy``openDocument``remove``list` 及其他所有 Host API 方法。组装指明一个会话所运行的插件,因此读取它是侦察,而 copy/remove/openDocument 管理名单并驱动宿主桌面
## 何时不显示这些表层
@@ -168,7 +168,7 @@ export interface AgentPresetSettingsState {
error: string | null
/**
* Whether this browser may persist the choice at all. `settings.describe` is
* loopback-only and reports a read-only provider as `writable: false`; the
* enabled Host settings path reports a read-only provider as `writable: false`; the
* row then shows the current default and disables the control rather than
* offering a write the gateway will refuse.
*/
@@ -42,7 +42,7 @@ function fakeApi(
: { rpcId: 'r', result: { ok: false as const, error: { code: 'internal', message: options.failList, details: {} } } }),
},
settings: {
// Loopback-only in production; a read-only provider answers writable:false
// Host persistence is enabled in production only on the selected client path; a read-only provider answers writable:false
// and the row disables its control instead of offering a refused write.
describe: () => Promise.resolve({
rpcId: 'r',
@@ -75,7 +75,7 @@ describe('the agent-preset settings controller', () => {
await controller.load()
// `settings.describe` is loopback-only and reports a read-only provider;
// The enabled `settings.describe` path reports a read-only provider;
// offering a control whose write answers `settings-rejected` would promise
// a switch the host refuses.
expect(controller.store.getSnapshot().writable).toBe(false)
@@ -168,7 +168,7 @@ export class PermissionPresetSettingsController {
if (this.disposed || this.saving) return
const mirrored = this.describeFace.getSnapshot()
if (mirrored.status === 'unavailable') {
// The terminal non-loopback state: settings RPCs are loopback-only, so
// The terminal non-loopback state: this client keeps Host persistence disabled, so
// the row hides itself exactly like an unserved namespace.
this.store.update((state) => {
state.status = 'unavailable'
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-settings-general/README.md
README.md: c59b77617cfd5848553ba340e97bafe37b7b2a2f
README.zh.md: ea4896284b677a7b430cceb949b53aa6e3a5a242
README.md: b625f4259869de1449ff18cceeac7f0c6f7c7f0d
README.zh.md: 9381bec1cc0b2a33dd4e8afac20ff6edcfc38dd9
@@ -6,7 +6,7 @@ Settings shell, ownerless copy, and durable product-onboarding namespace. It occ
The shell ships no onboarding copy of its own — all text arrives from registrants. Nav labels may be locale-following thunks, so the nav projection resolves them through `resolveSlotLabel` and re-renders on the section ledger bump or the locale revision (an optional `ctx.get('locale')` read; no hard locale dependency). The onboarding ledger projects in ascending order and mounts exactly one step at a time. Visible steps own their dialog chrome and app-root `inert` lifecycle; a mounted step still resolving private facts renders null, so nothing paints or blocks while it decides. The active registrant receives its id, `complete()`, and an `openSection(id)` callback; completing or skipping transfers ownership to the next entry. Registrants own durable completion, capability readiness, copy, mutations, and their visible wrapper, so independently registered flows cannot stack and the shell does not become a second configuration fact source.
A loopback browser loads the provider's `hasDocument` capability through `settings.describe` and renders **Open configuration file** only when the Host confirms that a provider-owned local document can be prepared. The action sends the pathless, loopback-only `settings.openDocument` request; the Host resolves the provider path again, materializes an absent document, and hands it to a native text editor (`open -t` on macOS, bypassing a browser file association; the desktop file association on Linux and Windows; Windows association after `wslpath -w` translation on WSL). Open failures keep the action available and render a localized error. Reopening the dialog or reconnecting refreshes availability after a transient read failure or Host topology change. Remote browsers never register the action and never issue the privileged settings read.
On a loopback page, the Client loads the provider's `hasDocument` capability through `settings.describe` and renders **Open configuration file** only when the Host confirms that a provider-owned local document can be prepared. The action sends the pathless, browser-authenticated `settings.openDocument` request; the Host resolves the provider path again, materializes an absent document, and hands it to a native text editor (`open -t` on macOS, bypassing a browser file association; the desktop file association on Linux and Windows; Windows association after `wslpath -w` translation on WSL). Open failures keep the action available and render a localized error. Reopening the dialog or reconnecting refreshes availability after a transient read failure or Host topology change. Non-loopback pages retain the Client policy that withholds this native action and its settings read.
The Host half registers `ui-onboarding` in the user-settings seam. The welcome step contributed by `ui-settings-models` reads and writes its `welcomeNoticeVersion` through the existing public settings boundary; the shell itself remains policy-free.
@@ -6,7 +6,7 @@
外壳不自带引导文案:所有文本都来自注册方。导航 label 可以是跟随语言的 thunk,因此导航投影经 `resolveSlotLabel` 解析,并在分区账本更新或 locale revision 变化时重新渲染(`ctx.get('locale')` 可选读取,无硬 locale 依赖)。首次使用引导记录按升序投影,每次只挂载一个步骤;可见步骤自行持有弹窗框架和应用根节点 `inert` 生命周期。已挂载但仍在判定私有事实的步骤渲染 null,因此判定期间不绘制也不阻塞任何内容。当前注册方会收到该条目的 id、`complete()``openSection(id)` 回调;完成或跳过当前步骤后,所有权转交给下一项。持久化完成状态、能力就绪状态、文案、变更操作以及可见包装均由注册方持有,因此独立注册的流程无法堆叠,外壳也不会成为第二个配置事实来源。
回环浏览器通过 `settings.describe` 加载提供方的 `hasDocument` 能力,且只有在 Host 确认可准备好一份由提供方持有的本地文档时才渲染**打开配置文件**。该操作发送无路径参数且仅限回环访问`settings.openDocument` 请求;Host 会再次解析提供方路径、在文档缺失时将其创建出来,并交给原生文本编辑器(macOS 上使用 `open -t`,绕过浏览器文件关联;Linux 和 Windows 上使用桌面文件关联;WSL 上经 `wslpath -w` 转换后使用 Windows 文件关联)。打开失败时该操作仍可使用,并渲染本地化错误。临时读取失败或 Host 拓扑变化后,重新打开对话框或重新连接会刷新可用性。远程浏览器从不注册该操作,也从不发起这项特权设置读取。
在 loopback 页面上,Client 通过 `settings.describe` 加载提供方的 `hasDocument` 能力,且只有在 Host 确认可准备好一份由提供方持有的本地文档时才渲染**打开配置文件**。该操作发送无路径参数且经浏览器认证`settings.openDocument` 请求;Host 会再次解析提供方路径、在文档缺失时将其创建出来,并交给原生文本编辑器(macOS 上使用 `open -t`,绕过浏览器文件关联;Linux 和 Windows 上使用桌面文件关联;WSL 上经 `wslpath -w` 转换后使用 Windows 文件关联)。打开失败时该操作仍可使用,并渲染本地化错误。临时读取失败或 Host 拓扑变化后,重新打开对话框或重新连接会刷新可用性。非 loopback 页面保留 Client 策略,不提供该原生操作及其 settings 读取。
宿主端在用户设置 seam 中注册 `ui-onboarding``ui-settings-models` 提供的欢迎步骤通过既有公开 settings 边界读写其中的 `welcomeNoticeVersion`;外壳本身仍不持有产品策略。
@@ -168,7 +168,7 @@ describe('ui-settings-general apply', () => {
await vi.waitFor(() => { expect(b.settingsDescribe).toHaveBeenCalledTimes(2) })
})
it('withholds the loopback-only document action off-loopback', async () => {
it('withholds the Host document action off-loopback', async () => {
const b = await bench(false)
declare(b.slots)
const fiber = b.ctx.plugin({ inject: [...inject], apply })
@@ -49,7 +49,7 @@ function buildWelcome(
}
describe('WelcomeNoticeStore', () => {
it('acknowledges in memory without calling loopback-only settings APIs', async () => {
it('acknowledges in memory while Host settings persistence is disabled', async () => {
const describeCall = vi.fn()
const mutate = vi.fn()
const { controller } = buildWelcome({ describe: describeCall, mutate }, 'memory')
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-settings/README.md
README.md: 990469573309b3a92b5eeb8bc41d89b226dbd0d4
README.zh.md: a0ce9d5cf6d4633cec9fbf466083c7a11a947e8e
README.md: 7e4f3e20ad84e05ee8ec17e37d6922c6d7cf9fce
README.zh.md: cc1c0c6bd94cf770a39d6833ef6efc5d2bd227df
+1 -1
View File
@@ -15,5 +15,5 @@ None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **Remote browsers get no durable settings** — the settings RPCs are loopback-only, so a scope bound in a non-loopback browser starts `unavailable` and never crosses the wire; every row it backs is inert there.
- **Non-loopback pages get no durable settings** — this Client keeps Host persistence disabled there, so a scope starts `unavailable` and never crosses the wire; every row it backs is inert even though Connection authentication covers the API.
- **One field per write**`set` sends a single `set` op, so a row that must move two fields together has no transaction and publishes two revisions.
+1 -1
View File
@@ -15,5 +15,5 @@
## 已知限制与暂缓事项
- **远程浏览器没有持久化设置**:设置 RPC 仅限 loopback,因此在非 loopback 浏览器中绑定的 scope 以 `unavailable` 起步且从不跨线路,它支撑的每一行在那里都是无效
- **非 loopback 页面没有持久化设置**:本 Client 在那里禁用 Host 持久化,因此 scope 以 `unavailable` 起步且从不跨线路;尽管 Connection 认证覆盖 API,它支撑的每一行在那里无效。
- **每次写入仅一个字段**`set` 只发送单个 `set` op,因此需要同时改动两个字段的行没有事务可用,会发布两个 revision。
@@ -79,7 +79,7 @@ export class SettingsDescribeMirror implements SettingsDescribeFace {
/**
* @param api - settings wire face.
* @param persistence - remote browsers stay process-local because settings RPCs are loopback-only.
* @param persistence - client-selected Host persistence; non-loopback pages may remain process-local.
*/
constructor(
private readonly api: SettingsFace,
@@ -57,7 +57,7 @@ export class SettingsScopeController<T> implements SettingsScope<T> {
* @param api - settings wire face (writes only; reads ride the mirror).
* @param spec - namespace identity and optional narrowing decoder.
* @param mirror - the shared describe mirror this scope derives from.
* @param persistence - remote browsers remain process-local because settings RPCs are loopback-only.
* @param persistence - client-selected Host persistence; non-loopback pages may remain process-local.
* @param schema - settings-owned schema operations.
*/
constructor(
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-theme/README.md
README.md: a9f0eb428789bc117e2fcbbdd2366066e0b994cc
README.zh.md: 1447536c3e12a415c3ca241e4fe04a636ecaa6fd
README.md: 0c9bdf3ee3d99aee1e05453e8f455c1309deb284
README.zh.md: 033bf7867ac19c664e6303377cae545876ade6f1
+1 -1
View File
@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
Theme plugin: ThemeRuntime over the --dsw-* token base stylesheets (static scale + alias semantic layers). The service owns the live theme preference (`light`/`dark`/`system`), resolves `system` through `prefers-color-scheme`, and publishes immutable `ThemeSnapshot`s on the `theme/change` event; it never touches the DOM — ui-layout's presenter applies the resolved snapshot (`html { color-scheme }`, `body[data-ds-dark-theme]`, and inline alias tokens). A loopback browser provides the service immediately with `system`, then loads `ui-theme.preference` in the background and writes each built-in selection through the Host settings API, whose local provider stores it in `$DSH_HOME/settings.yaml` by default; pushed settings changes and reconnects refetch it, rapid selections are serialized in gesture order with namespace revisions, and a rejected latest write reloads the durable value. A remote browser cannot access the privileged settings API, so its selection remains process-local. Third-party registered theme ids remain an in-process extension and do not cross the built-in settings schema; removing one never overwrites the last durable built-in preference. The [Host-backed preferences decision](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md) owns the persistence boundary.
Theme plugin: ThemeRuntime over the --dsw-* token base stylesheets (static scale + alias semantic layers). The service owns the live theme preference (`light`/`dark`/`system`), resolves `system` through `prefers-color-scheme`, and publishes immutable `ThemeSnapshot`s on the `theme/change` event; it never touches the DOM — ui-layout's presenter applies the resolved snapshot (`html { color-scheme }`, `body[data-ds-dark-theme]`, and inline alias tokens). A loopback browser provides the service immediately with `system`, then loads `ui-theme.preference` in the background and writes each built-in selection through the Host settings API, whose local provider stores it in `$DSH_HOME/settings.yaml` by default; pushed settings changes and reconnects refetch it, rapid selections are serialized in gesture order with namespace revisions, and a rejected latest write reloads the durable value. The Client keeps Host settings persistence disabled on non-loopback pages, so their selections remain process-local even though Connection authentication applies to every API method. Third-party registered theme ids remain an in-process extension and do not cross the built-in settings schema; removing one never overwrites the last durable built-in preference. The [Host-backed preferences decision](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md) owns the persistence boundary.
When the host composition includes an HTTP server, the host half injects a synchronous bootstrap immediately after the opening `<body>` tag. Each index response embeds the registered Host setting for `ui-theme.preference`, or `system` when no settings provider is present; the browser resolves `system` from the OS scheme, then sets `color-scheme` and `body[data-ds-dark-theme]` before the shell loading page renders. Compositions without an HTTP server remain unaffected, and ThemeRuntime and ui-layout remain authoritative for client state and subsequent DOM updates after the plugin tree activates.
+1 -1
View File
@@ -2,7 +2,7 @@
[English](README.md) | 中文
主题插件:基于 --dsw-* token 基础样式表(静态尺度 + 别名语义层)的 ThemeRuntime。该服务拥有实时主题偏好(`light``dark``system`),将 `system` 通过 `prefers-color-scheme` 解析为实际主题,并发布不可变的 `ThemeSnapshot`,通过 `theme/change` 事件通知变化;它绝不接触 DOM:ui-layout 的呈现器会应用解析后的快照(`html { color-scheme }``body[data-ds-dark-theme]`,以及主题的别名 token 内联变量)。来自回环地址的浏览器会先以 `system` 立即提供该服务,随后在后台加载 `ui-theme.preference`,并将每次内置主题选择通过 Host settings API 写入;其本地提供方默认将设置存入 `$DSH_HOME/settings.yaml`。收到推送的 settings 变更时或重连后,浏览器都会重新拉取该设置;连续快速选择会按操作顺序携带 namespace revision 串行写入,最新写入被拒时则重新加载持久化值。远程浏览器无法访问特权 settings API,因此它的选择保留在进程内。已注册的第三方主题 id 仍是进程内扩展,不会跨越内置 settings schema;移除其中任意一个都绝不会覆盖最后一个持久化的内置偏好。该持久化边界由[Host settings 支撑的偏好决策](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.zh.md)拥有。
主题插件:基于 --dsw-* token 基础样式表(静态尺度 + 别名语义层)的 ThemeRuntime。该服务拥有实时主题偏好(`light``dark``system`),将 `system` 通过 `prefers-color-scheme` 解析为实际主题,并发布不可变的 `ThemeSnapshot`,通过 `theme/change` 事件通知变化;它绝不接触 DOM:ui-layout 的呈现器会应用解析后的快照(`html { color-scheme }``body[data-ds-dark-theme]`,以及主题的别名 token 内联变量)。来自回环地址的浏览器会先以 `system` 立即提供该服务,随后在后台加载 `ui-theme.preference`,并将每次内置主题选择通过 Host settings API 写入;其本地提供方默认将设置存入 `$DSH_HOME/settings.yaml`。收到推送的 settings 变更时或重连后,浏览器都会重新拉取该设置;连续快速选择会按操作顺序携带 namespace revision 串行写入,最新写入被拒时则重新加载持久化值。Client 在非 loopback 页面禁用 Host settings 持久化,因此这些页面的选择仍只保留在进程内,尽管 Connection 会认证每个 API 方法。已注册的第三方主题 id 仍是进程内扩展,不会跨越内置 settings schema;移除其中任意一个都绝不会覆盖最后一个持久化的内置偏好。该持久化边界由[Host settings 支撑的偏好决策](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.zh.md)拥有。
当主机组合包含 HTTP 服务器时,主机侧紧接 `<body>` 起始标签注入同步引导代码。每份 index 响应会嵌入已注册的 Host 设置 `ui-theme.preference`,没有 settings provider 时则嵌入 `system`;浏览器按操作系统配色解析 `system`,随后在外壳加载页面渲染前设置 `color-scheme``body[data-ds-dark-theme]`。不含 HTTP 服务器的组合不受影响,插件树激活后,ThemeRuntime 与 ui-layout 仍分别是客户端状态和后续 DOM 更新的权威来源。
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md
README.md: 4cf11c7ffe55484fd2c27e597931f22f8f861a14
README.zh.md: 934a00f36ab764f13336e26aea5aa7a274de17e5
README.md: 69826d76b437ea482655d91a930310185079414c
README.zh.md: c7f5d5b579e20236fe7a9f79ed1db8417b641c64

Some files were not shown because too many files have changed in this diff Show More