From 3e24087bfaeabe40b58ba2f7b936895b8f93fe27 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 25 Aug 2026 00:59:04 +0800 Subject: [PATCH] fix(web): authenticate the browser Host API --- ...07-28-api-browser-trust-boundary.i18n.yaml | 4 +- .../2026-07-28-api-browser-trust-boundary.md | 10 +- ...026-07-28-api-browser-trust-boundary.zh.md | 10 +- ...26-07-30-config-plane-boundaries.i18n.yaml | 4 +- .../2026-07-30-config-plane-boundaries.md | 2 +- .../2026-07-30-config-plane-boundaries.zh.md | 2 +- .../2026-07-30-web-config-plane.i18n.yaml | 4 +- .../2026-07-30-web-config-plane.md | 4 +- .../2026-07-30-web-config-plane.zh.md | 4 +- ...-08-03-per-session-agent-presets.i18n.yaml | 4 +- .../2026-08-03-per-session-agent-presets.md | 2 +- ...2026-08-03-per-session-agent-presets.zh.md | 2 +- ...-provider-endpoint-interrogation.i18n.yaml | 4 +- ...4-draft-provider-endpoint-interrogation.md | 2 +- ...raft-provider-endpoint-interrogation.zh.md | 2 +- ...08-04-websocket-downlink-carrier.i18n.yaml | 4 +- .../2026-08-04-websocket-downlink-carrier.md | 2 +- ...026-08-04-websocket-downlink-carrier.zh.md | 2 +- ...12-plugin-owned-settings-surface.i18n.yaml | 4 +- ...026-08-12-plugin-owned-settings-surface.md | 2 +- ...-08-12-plugin-owned-settings-surface.zh.md | 2 +- ...-24-browser-token-authentication.i18n.yaml | 6 + ...2026-08-24-browser-token-authentication.md | 45 +++ ...6-08-24-browser-token-authentication.zh.md | 45 +++ ...8-06-host-backed-web-preferences.i18n.yaml | 4 +- .../2026-08-06-host-backed-web-preferences.md | 4 +- ...26-08-06-host-backed-web-preferences.zh.md | 4 +- .../2026-07-22-web-bind-address.i18n.yaml | 4 +- .../feature/2026-07-22-web-bind-address.md | 10 +- .../feature/2026-07-22-web-bind-address.zh.md | 10 +- ...-07-28-tool-call-file-open-in-os.i18n.yaml | 4 +- .../2026-07-28-tool-call-file-open-in-os.md | 2 +- ...2026-07-28-tool-call-file-open-in-os.zh.md | 4 +- ...-shared-modal-product-onboarding.i18n.yaml | 4 +- ...6-08-13-shared-modal-product-onboarding.md | 2 +- ...8-13-shared-modal-product-onboarding.zh.md | 2 +- ...08-08-copy-only-preset-authoring.i18n.yaml | 4 +- .../2026-08-08-copy-only-preset-authoring.md | 4 +- ...026-08-08-copy-only-preset-authoring.zh.md | 4 +- ...-unary-apiproxy-remote-migration.i18n.yaml | 4 +- ...6-08-10-unary-apiproxy-remote-migration.md | 19 +- ...8-10-unary-apiproxy-remote-migration.zh.md | 19 +- apps/cli/package.json | 4 +- .../tests/fixtures/web-browser-open/open.mjs | 10 +- apps/cli/tests/github-webhook-real.e2e.ts | 39 ++- apps/cli/tests/web-auth.e2e.ts | 210 +++++++++++++ .../tests/web-browser-open.expected.e2e.ts | 15 +- apps/web/package.json | 4 +- apps/web/tests/smoke-real.e2e.ts | 51 +++- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 16 +- docs/config-catalog.zh.md | 16 +- docs/subsystems/web-server.i18n.yaml | 4 +- docs/subsystems/web-server.md | 4 +- docs/subsystems/web-server.zh.md | 4 +- packages/api/gateway/src/index.ts | 8 +- packages/api/gateway/src/stream-server.ts | 11 +- .../gateway/tests/gateway-stream.host.spec.ts | 61 +++- .../api/gateway/tests/gateway.host.spec.ts | 41 ++- packages/bundle/web-app/README.i18n.yaml | 4 +- packages/bundle/web-app/README.md | 2 +- packages/bundle/web-app/README.zh.md | 2 +- packages/bundle/web-app/src/index.ts | 82 +++--- packages/bundle/web-app/tests/web-app.spec.ts | 43 ++- packages/bundle/web-app/tsconfig.json | 3 + packages/client/connection/README.i18n.yaml | 4 +- packages/client/connection/README.md | 12 +- packages/client/connection/README.zh.md | 12 +- packages/client/connection/package.json | 2 + .../client/connection/src/browser-auth.ts | 276 ++++++++++++++++++ packages/client/connection/src/index.ts | 103 ++----- packages/client/connection/src/invariant.ts | 11 +- packages/client/connection/src/rpc-host.ts | 53 ++-- packages/client/connection/src/rpc.ts | 49 +++- .../tests/browser-auth.host.spec.ts | 231 +++++++++++++++ .../connection/tests/node-half.host.spec.ts | 262 ++++++++++------- packages/client/connection/tsconfig.host.json | 4 + packages/client/locale/README.i18n.yaml | 4 +- packages/client/locale/README.md | 2 +- packages/client/locale/README.zh.md | 2 +- .../client/ui-agent-preset/README.i18n.yaml | 4 +- packages/client/ui-agent-preset/README.md | 2 +- packages/client/ui-agent-preset/README.zh.md | 2 +- .../src/client/settings-store.ts | 2 +- .../tests/settings-store.client.spec.ts | 4 +- .../src/client/settings-store.ts | 2 +- .../ui-settings-general/README.i18n.yaml | 4 +- packages/client/ui-settings-general/README.md | 2 +- .../client/ui-settings-general/README.zh.md | 2 +- .../tests/apply.client.spec.ts | 2 +- .../tests/welcome-store.client.spec.ts | 2 +- packages/client/ui-settings/README.i18n.yaml | 4 +- packages/client/ui-settings/README.md | 2 +- packages/client/ui-settings/README.zh.md | 2 +- .../ui-settings/src/client/settings-mirror.ts | 2 +- .../ui-settings/src/client/settings-scope.ts | 2 +- packages/client/ui-theme/README.i18n.yaml | 4 +- packages/client/ui-theme/README.md | 2 +- packages/client/ui-theme/README.zh.md | 2 +- packages/host/apiproxy/README.i18n.yaml | 4 +- packages/host/apiproxy/README.md | 6 +- packages/host/apiproxy/README.zh.md | 6 +- packages/host/apiproxy/src/api-proxy.ts | 5 +- .../host/apiproxy/src/api/agent-presets.ts | 10 +- packages/host/apiproxy/src/api/host.ts | 4 +- packages/host/apiproxy/src/api/settings.ts | 4 +- .../apiproxy/tests/api-proxy-config.spec.ts | 4 +- .../host/frontend-static/README.i18n.yaml | 4 +- packages/host/frontend-static/README.md | 2 + packages/host/frontend-static/README.zh.md | 2 + packages/host/frontend-static/package.json | 3 + packages/host/frontend-static/src/index.ts | 22 +- .../tests/frontend-static.spec.ts | 46 ++- packages/host/frontend-static/tsconfig.json | 3 + pnpm-lock.yaml | 21 ++ 115 files changed, 1617 insertions(+), 522 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.md create mode 100644 .agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.zh.md create mode 100644 apps/cli/tests/web-auth.e2e.ts create mode 100644 packages/client/connection/src/browser-auth.ts create mode 100644 packages/client/connection/tests/browser-auth.host.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.i18n.yaml index c206e01dba..f2e1a2ed33 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.i18n.yaml @@ -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 diff --git a/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md b/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md index 92b76c109a..2997b0f2af 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md +++ b/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md @@ -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. diff --git a/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.zh.md b/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.zh.md index 653ff32f2e..ff952ede9a 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.zh.md @@ -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 方法使用的浏览器身份。 diff --git a/.agents/notes/implemented/architecture/2026-07-30-config-plane-boundaries.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-config-plane-boundaries.i18n.yaml index ac8f90ed76..08e1de4e5f 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-config-plane-boundaries.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-30-config-plane-boundaries.i18n.yaml @@ -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 diff --git a/.agents/notes/implemented/architecture/2026-07-30-config-plane-boundaries.md b/.agents/notes/implemented/architecture/2026-07-30-config-plane-boundaries.md index 7b234ec646..d7ae13f08c 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-config-plane-boundaries.md +++ b/.agents/notes/implemented/architecture/2026-07-30-config-plane-boundaries.md @@ -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. diff --git a/.agents/notes/implemented/architecture/2026-07-30-config-plane-boundaries.zh.md b/.agents/notes/implemented/architecture/2026-07-30-config-plane-boundaries.zh.md index f543c511d9..e53a68f91f 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-config-plane-boundaries.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-30-config-plane-boundaries.zh.md @@ -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`),因此探测无法枚举注册表。 diff --git a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.i18n.yaml index ca0cd569c9..a345f69828 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.i18n.yaml @@ -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 diff --git a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md index c8397d03cd..d5bd9c05e8 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md +++ b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md @@ -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. diff --git a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md index b0724c3a8c..3c7f801766 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md @@ -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` 这个聚合器条目;依预发布立场,不设别名。 diff --git a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.i18n.yaml index 0a95947eaf..21995b0838 100644 --- a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.i18n.yaml @@ -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 diff --git a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md index b219500458..97352a0e33 100644 --- a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md +++ b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md @@ -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. diff --git a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md index 9f689c74dd..b62baf3e30 100644 --- a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md @@ -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 平面的包。 diff --git a/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.i18n.yaml index f620af93e3..9647bf2513 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.i18n.yaml @@ -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 diff --git a/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.md b/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.md index 6a545ea5c9..0132dba588 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.md +++ b/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.md @@ -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. diff --git a/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.zh.md b/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.zh.md index eb8133d0f2..8cea5d2809 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.zh.md @@ -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 时所用的两段式形状一致。 diff --git a/.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.i18n.yaml index 28a4778928..8ded320f05 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.i18n.yaml @@ -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 diff --git a/.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md b/.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md index f757487c7b..420a0d30f3 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md +++ b/.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md @@ -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. diff --git a/.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.zh.md b/.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.zh.md index 5fa603b9a8..5f277a4ed3 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.zh.md @@ -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` 的 Host/Origin 信任栅栏。未受信任的 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` Host/Origin 校验,再执行与一元 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。 diff --git a/.agents/notes/implemented/architecture/2026-08-12-plugin-owned-settings-surface.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-12-plugin-owned-settings-surface.i18n.yaml index 8ae275a581..822f8fa0e2 100644 --- a/.agents/notes/implemented/architecture/2026-08-12-plugin-owned-settings-surface.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-12-plugin-owned-settings-surface.i18n.yaml @@ -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 diff --git a/.agents/notes/implemented/architecture/2026-08-12-plugin-owned-settings-surface.md b/.agents/notes/implemented/architecture/2026-08-12-plugin-owned-settings-surface.md index daed91b8ac..f1c9b48abd 100644 --- a/.agents/notes/implemented/architecture/2026-08-12-plugin-owned-settings-surface.md +++ b/.agents/notes/implemented/architecture/2026-08-12-plugin-owned-settings-surface.md @@ -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. diff --git a/.agents/notes/implemented/architecture/2026-08-12-plugin-owned-settings-surface.zh.md b/.agents/notes/implemented/architecture/2026-08-12-plugin-owned-settings-surface.zh.md index ba39b84faa..ff9f37290a 100644 --- a/.agents/notes/implemented/architecture/2026-08-12-plugin-owned-settings-surface.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-12-plugin-owned-settings-surface.zh.md @@ -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')` 逐字段作答:比整命名空间开关更精细,而且已经在执行。 diff --git a/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.i18n.yaml new file mode 100644 index 0000000000..e8c68740aa --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.i18n.yaml @@ -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 diff --git a/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.md b/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.md new file mode 100644 index 0000000000..fb55a35018 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.md @@ -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. diff --git a/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.zh.md b/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.zh.md new file mode 100644 index 0000000000..6cd7711739 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.zh.md @@ -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 被归档:重叠只发生在局部,两条安全规则都保有未来决策价值。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.i18n.yaml index 26fc02785a..a0c01325bf 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.i18n.yaml @@ -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 diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md b/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md index 2e33d05417..83d4b87f35 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md +++ b/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md @@ -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 diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.zh.md b/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.zh.md index 79c15af723..ead4a0d27a 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.zh.md @@ -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 之外的进程内扩展;移除其中一个会重置实时注册表,但不会替换上一个持久化的内置偏好。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/feature/2026-07-22-web-bind-address.i18n.yaml b/.agents/notes/implemented/feature/2026-07-22-web-bind-address.i18n.yaml index ac5e4cb4c6..ec7451e28d 100644 --- a/.agents/notes/implemented/feature/2026-07-22-web-bind-address.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-22-web-bind-address.i18n.yaml @@ -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 diff --git a/.agents/notes/implemented/feature/2026-07-22-web-bind-address.md b/.agents/notes/implemented/feature/2026-07-22-web-bind-address.md index 3332176c0c..68275d220f 100644 --- a/.agents/notes/implemented/feature/2026-07-22-web-bind-address.md +++ b/.agents/notes/implemented/feature/2026-07-22-web-bind-address.md @@ -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. diff --git a/.agents/notes/implemented/feature/2026-07-22-web-bind-address.zh.md b/.agents/notes/implemented/feature/2026-07-22-web-bind-address.zh.md index 5ad73b8916..5bb4fdbbc3 100644 --- a/.agents/notes/implemented/feature/2026-07-22-web-bind-address.zh.md +++ b/.agents/notes/implemented/feature/2026-07-22-web-bind-address.zh.md @@ -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 被拒绝。 diff --git a/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.i18n.yaml index 22ba3ccbe8..824846180a 100644 --- a/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.i18n.yaml @@ -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 diff --git a/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.md b/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.md index 08fc51cc3a..e6e590b2a9 100644 --- a/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.md +++ b/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.md @@ -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 diff --git a/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.zh.md b/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.zh.md index c99a99dcd9..eb600a69cb 100644 --- a/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.zh.md +++ b/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.zh.md @@ -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))。 ## 风险 diff --git a/.agents/notes/implemented/feature/2026-08-13-shared-modal-product-onboarding.i18n.yaml b/.agents/notes/implemented/feature/2026-08-13-shared-modal-product-onboarding.i18n.yaml index fda5e932c9..fac721984b 100644 --- a/.agents/notes/implemented/feature/2026-08-13-shared-modal-product-onboarding.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-13-shared-modal-product-onboarding.i18n.yaml @@ -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 diff --git a/.agents/notes/implemented/feature/2026-08-13-shared-modal-product-onboarding.md b/.agents/notes/implemented/feature/2026-08-13-shared-modal-product-onboarding.md index ec3d3e5f11..9f72ee5b4e 100644 --- a/.agents/notes/implemented/feature/2026-08-13-shared-modal-product-onboarding.md +++ b/.agents/notes/implemented/feature/2026-08-13-shared-modal-product-onboarding.md @@ -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. diff --git a/.agents/notes/implemented/feature/2026-08-13-shared-modal-product-onboarding.zh.md b/.agents/notes/implemented/feature/2026-08-13-shared-modal-product-onboarding.zh.md index 78b17d02db..867b23202f 100644 --- a/.agents/notes/implemented/feature/2026-08-13-shared-modal-product-onboarding.zh.md +++ b/.agents/notes/implemented/feature/2026-08-13-shared-modal-product-onboarding.zh.md @@ -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`,不会修改提供方设置。「保存并继续」会等待写入与就绪状态刷新;「稍后配置」只完成协调器当前这一轮。 diff --git a/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.i18n.yaml b/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.i18n.yaml index 241d95f649..df36182179 100644 --- a/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.i18n.yaml @@ -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 diff --git a/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.md b/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.md index c16518b087..bfe0d49755 100644 --- a/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.md +++ b/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.md @@ -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 diff --git a/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.zh.md b/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.zh.md index 63a184f582..fc2d9ac5c6 100644 --- a/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.zh.md +++ b/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.zh.md @@ -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。 ## 考虑过的替代方案 diff --git a/.agents/notes/proposed/architecture/2026-08-10-unary-apiproxy-remote-migration.i18n.yaml b/.agents/notes/proposed/architecture/2026-08-10-unary-apiproxy-remote-migration.i18n.yaml index d327972db7..67e708c2ff 100644 --- a/.agents/notes/proposed/architecture/2026-08-10-unary-apiproxy-remote-migration.i18n.yaml +++ b/.agents/notes/proposed/architecture/2026-08-10-unary-apiproxy-remote-migration.i18n.yaml @@ -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 diff --git a/.agents/notes/proposed/architecture/2026-08-10-unary-apiproxy-remote-migration.md b/.agents/notes/proposed/architecture/2026-08-10-unary-apiproxy-remote-migration.md index c4a308e16b..cd019ef10c 100644 --- a/.agents/notes/proposed/architecture/2026-08-10-unary-apiproxy-remote-migration.md +++ b/.agents/notes/proposed/architecture/2026-08-10-unary-apiproxy-remote-migration.md @@ -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. diff --git a/.agents/notes/proposed/architecture/2026-08-10-unary-apiproxy-remote-migration.zh.md b/.agents/notes/proposed/architecture/2026-08-10-unary-apiproxy-remote-migration.zh.md index db263e1a78..e6842cc82c 100644 --- a/.agents/notes/proposed/architecture/2026-08-10-unary-apiproxy-remote-migration.zh.md +++ b/.agents/notes/proposed/architecture/2026-08-10-unary-apiproxy-remote-migration.zh.md @@ -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*` 包装层。 - Agent/Session 集成测试证明共享 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)仍具权威性,无需执行归档操作。 diff --git a/apps/cli/package.json b/apps/cli/package.json index 3d785a12cd..d940c56bb4 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -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" } } diff --git a/apps/cli/tests/fixtures/web-browser-open/open.mjs b/apps/cli/tests/fixtures/web-browser-open/open.mjs index 0f196f0e3f..85ca246fbd 100644 --- a/apps/cli/tests/fixtures/web-browser-open/open.mjs +++ b/apps/cli/tests/fixtures/web-browser-open/open.mjs @@ -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, diff --git a/apps/cli/tests/github-webhook-real.e2e.ts b/apps/cli/tests/github-webhook-real.e2e.ts index e16aef8b3e..fd465714d3 100644 --- a/apps/cli/tests/github-webhook-real.e2e.ts +++ b/apps/cli/tests/github-webhook-real.e2e.ts @@ -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>() + +/** 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 { /** Invoke one public Remote method over its HTTP carrier. */ async function remoteRpc(baseUrl: string, endpoint: string, args: object): Promise { - 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> { - 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((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): 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( diff --git a/apps/cli/tests/web-auth.e2e.ts b/apps/cli/tests/web-auth.e2e.ts new file mode 100644 index 0000000000..b22f611306 --- /dev/null +++ b/apps/cli/tests/web-auth.e2e.ts @@ -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') +} + +/** Reserve one concrete loopback port, then release it for the CLI process. */ +async function freePort(): Promise { + const server = createServer() + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(0, '127.0.0.1', resolve) + }) + const port = (server.address() as AddressInfo).port + await new Promise((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 { + 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((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 { + if (running.child.exitCode !== null) return + const exited = new Promise((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 { + 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 }) + } + }) +}) diff --git a/apps/cli/tests/web-browser-open.expected.e2e.ts b/apps/cli/tests/web-browser-open.expected.e2e.ts index 0441ab6e17..96bbbc0215 100644 --- a/apps/cli/tests/web-browser-open.expected.e2e.ts +++ b/apps/cli/tests/web-browser-open.expected.e2e.ts @@ -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": "", } `) diff --git a/apps/web/package.json b/apps/web/package.json index d1f70bedfc..eeb6693e6b 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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" } } diff --git a/apps/web/tests/smoke-real.e2e.ts b/apps/web/tests/smoke-real.e2e.ts index 7271449361..763e3746fd 100644 --- a/apps/web/tests/smoke-real.e2e.ts +++ b/apps/web/tests/smoke-real.e2e.ts @@ -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>() + +/** 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 { } async function remoteRpc(baseUrl: string, endpoint: string, args: object): Promise { - 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(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 { - 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((resolve, reject) => { @@ -112,10 +137,13 @@ async function sessionCursor(baseUrl: string, sessionId: string): Promise): 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(` { diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 14ac7c7714..15b0eabc28 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -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 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index d6145e898e..a4eeb7ffbe 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -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) @@ -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) @@ -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) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 28c36a899b..8490829a78 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -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) @@ -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) @@ -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) diff --git a/docs/subsystems/web-server.i18n.yaml b/docs/subsystems/web-server.i18n.yaml index 76eb0f109e..87cca8b0b2 100644 --- a/docs/subsystems/web-server.i18n.yaml +++ b/docs/subsystems/web-server.i18n.yaml @@ -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 diff --git a/docs/subsystems/web-server.md b/docs/subsystems/web-server.md index 9e1e88d6c7..b806f5b40a 100644 --- a/docs/subsystems/web-server.md +++ b/docs/subsystems/web-server.md @@ -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 diff --git a/docs/subsystems/web-server.zh.md b/docs/subsystems/web-server.zh.md index 4401ccf628..e5f4a8794d 100644 --- a/docs/subsystems/web-server.zh.md +++ b/docs/subsystems/web-server.zh.md @@ -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 位置是认领席位的前端插件的组装事实。 ## 服务 diff --git a/packages/api/gateway/src/index.ts b/packages/api/gateway/src/index.ts index 208090851e..edc3f8697e 100644 --- a/packages/api/gateway/src/index.ts +++ b/packages/api/gateway/src/index.ts @@ -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) diff --git a/packages/api/gateway/src/stream-server.ts b/packages/api/gateway/src/stream-server.ts index bf7711b81b..04b0df0427 100644 --- a/packages/api/gateway/src/stream-server.ts +++ b/packages/api/gateway/src/stream-server.ts @@ -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')) } diff --git a/packages/api/gateway/tests/gateway-stream.host.spec.ts b/packages/api/gateway/tests/gateway-stream.host.spec.ts index cf95c84f27..273d5f9b99 100644 --- a/packages/api/gateway/tests/gateway-stream.host.spec.ts +++ b/packages/api/gateway/tests/gateway-stream.host.spec.ts @@ -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>() type AgentWireId = TypertContextWire 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 { + 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[] = [] socket.on('message', (data) => { frames.push(JSON.parse(rawText(data)) as Record) }) @@ -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[] = [] socket.on('message', (data) => { frames.push(JSON.parse(rawText(data)) as Record) }) @@ -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[] = [] socket.on('message', (data) => { frames.push(JSON.parse(rawText(data)) as Record) }) @@ -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 { 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[] = [] socket.on('message', (data) => { frames.push(JSON.parse(rawText(data)) as Record) }) @@ -1010,7 +1059,7 @@ async function openEventClient(ctx: Context, streamId: string): Promise 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 { + return Promise.resolve(undefined) + } } function fakeHttpServer(routes: WebRoute[]): Pick { @@ -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 { + 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() diff --git a/packages/bundle/web-app/README.i18n.yaml b/packages/bundle/web-app/README.i18n.yaml index d22a500223..bc9ac5d953 100644 --- a/packages/bundle/web-app/README.i18n.yaml +++ b/packages/bundle/web-app/README.i18n.yaml @@ -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 diff --git a/packages/bundle/web-app/README.md b/packages/bundle/web-app/README.md index c428bbd352..c330e5557c 100644 --- a/packages/bundle/web-app/README.md +++ b/packages/bundle/web-app/README.md @@ -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. diff --git a/packages/bundle/web-app/README.zh.md b/packages/bundle/web-app/README.zh.md index 0d5a33fe35..b2170c762a 100644 --- a/packages/bundle/web-app/README.zh.md +++ b/packages/bundle/web-app/README.zh.md @@ -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 相互独立。 diff --git a/packages/bundle/web-app/src/index.ts b/packages/bundle/web-app/src/index.ts index 713f6d10a4..5ec4ae7072 100644 --- a/packages/bundle/web-app/src/index.ts +++ b/packages/bundle/web-app/src/index.ts @@ -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. - }, () => {}) - } + }) } } diff --git a/packages/bundle/web-app/tests/web-app.spec.ts b/packages/bundle/web-app/tests/web-app.spec.ts index 5639129362..90f7063bf7 100644 --- a/packages/bundle/web-app/tests/web-app.spec.ts +++ b/packages/bundle/web-app/tests/web-app.spec.ts @@ -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 = 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((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() diff --git a/packages/bundle/web-app/tsconfig.json b/packages/bundle/web-app/tsconfig.json index c00b64f5a9..2f8cc6c3b2 100644 --- a/packages/bundle/web-app/tsconfig.json +++ b/packages/bundle/web-app/tsconfig.json @@ -23,6 +23,9 @@ { "path": "../../boot/cmdline" }, + { + "path": "../../client/connection/tsconfig.host.json" + }, { "path": "../../host/frontend-static" }, diff --git a/packages/client/connection/README.i18n.yaml b/packages/client/connection/README.i18n.yaml index 8f1d179212..83d376368b 100644 --- a/packages/client/connection/README.i18n.yaml +++ b/packages/client/connection/README.i18n.yaml @@ -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 diff --git a/packages/client/connection/README.md b/packages/client/connection/README.md index d261451574..293e9f9d6b 100644 --- a/packages/client/connection/README.md +++ b/packages/client/connection/README.md @@ -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. diff --git a/packages/client/connection/README.zh.md b/packages/client/connection/README.zh.md index 13df74ddf7..e3cea191ab 100644 --- a/packages/client/connection/README.zh.md +++ b/packages/client/connection/README.zh.md @@ -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 凭据记录会撤销全部会话,下一次启动令牌交换会创建新的签名密钥。 diff --git a/packages/client/connection/package.json b/packages/client/connection/package.json index 95ce0d1981..5c7e53bb70 100644 --- a/packages/client/connection/package.json +++ b/packages/client/connection/package.json @@ -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:^", diff --git a/packages/client/connection/src/browser-auth.ts b/packages/client/connection/src/browser-auth.ts new file mode 100644 index 0000000000..7c57622bb2 --- /dev/null +++ b/packages/client/connection/src/browser-auth.ts @@ -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 { + 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 { + 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 { + /* 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 { + 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 { + 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') + } +} diff --git a/packages/client/connection/src/index.ts b/packages/client/connection/src/index.ts index 1944e09722..0d43a322a3 100644 --- a/packages/client/connection/src/index.ts +++ b/packages/client/connection/src/index.ts @@ -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 = 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 { // 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) diff --git a/packages/client/connection/src/invariant.ts b/packages/client/connection/src/invariant.ts index 78394263cf..5a187545b5 100644 --- a/packages/client/connection/src/invariant.ts +++ b/packages/client/connection/src/invariant.ts @@ -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 = () => {} diff --git a/packages/client/connection/src/rpc-host.ts b/packages/client/connection/src/rpc-host.ts index 162045ed0d..5c079e092a 100644 --- a/packages/client/connection/src/rpc-host.ts +++ b/packages/client/connection/src/rpc-host.ts @@ -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 { + 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 { + 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 { 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 { 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)) { diff --git a/packages/client/connection/src/rpc.ts b/packages/client/connection/src/rpc.ts index e8dc585d38..d05e2f0b81 100644 --- a/packages/client/connection/src/rpc.ts +++ b/packages/client/connection/src/rpc.ts @@ -12,19 +12,25 @@ export type ConnectionRpcResult = | { 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> } -/** 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>): 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 /** @@ -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 } @@ -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 + + /** + * 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 + + /** + * 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. */ diff --git a/packages/client/connection/tests/browser-auth.host.spec.ts b/packages/client/connection/tests/browser-auth.host.spec.ts new file mode 100644 index 0000000000..21ce8f592b --- /dev/null +++ b/packages/client/connection/tests/browser-auth.host.spec.ts @@ -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 { + return Promise.resolve(this.record) + } + + async modifyRecord( + _key: unknown, + mutate: (current: CredentialRecord | undefined) => Promise, + ): Promise { + const next = await mutate(this.record) + if (this.discardWrites) return undefined + if (next !== undefined) this.record = next + return this.record + } + + deleteRecord(): Promise { + 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> + 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) + }) +}) diff --git a/packages/client/connection/tests/node-half.host.spec.ts b/packages/client/connection/tests/node-half.host.spec.ts index 504e50d9fd..a175a7f005 100644 --- a/packages/client/connection/tests/node-half.host.spec.ts +++ b/packages/client/connection/tests/node-half.host.spec.ts @@ -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, 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; body?: unknown } +} { + const state: { status?: number; headers?: Record; 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) { + 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 { + 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 { + function call(port: number, method: string, host: string, cookie?: string): Promise { 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() diff --git a/packages/client/connection/tsconfig.host.json b/packages/client/connection/tsconfig.host.json index ed5305797d..ad9bacc704 100644 --- a/packages/client/connection/tsconfig.host.json +++ b/packages/client/connection/tsconfig.host.json @@ -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" }, diff --git a/packages/client/locale/README.i18n.yaml b/packages/client/locale/README.i18n.yaml index 096e4ab66a..dca2ac21ab 100644 --- a/packages/client/locale/README.i18n.yaml +++ b/packages/client/locale/README.i18n.yaml @@ -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 diff --git a/packages/client/locale/README.md b/packages/client/locale/README.md index 4f54a9a2c5..1fa0262e7c 100644 --- a/packages/client/locale/README.md +++ b/packages/client/locale/README.md @@ -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 `` 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`; 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 `` 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`; 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 diff --git a/packages/client/locale/README.zh.md b/packages/client/locale/README.zh.md index d4c4833b23..45cf657d5f 100644 --- a/packages/client/locale/README.zh.md +++ b/packages/client/locale/README.zh.md @@ -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` 仅在切换语言时触发;插件会在激活时以及每次切换时把 `` 指向当前 locale(`zh-CN`/`en`)。该服务还拥有 ns×locale 字典注册表(类型化 `register(ns, {zh, en})` 按 `LocaleNamespaceMap` 校验,`bind(ns)`→`TranslateNS`;查找链 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` 仅在切换语言时触发;插件会在激活时以及每次切换时把 `` 指向当前 locale(`zh-CN`/`en`)。该服务还拥有 ns×locale 字典注册表(类型化 `register(ns, {zh, en})` 按 `LocaleNamespaceMap` 校验,`bind(ns)`→`TranslateNS`;查找链 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)拥有。 ## 模型体验 diff --git a/packages/client/ui-agent-preset/README.i18n.yaml b/packages/client/ui-agent-preset/README.i18n.yaml index 15b1810f04..64d8176b13 100644 --- a/packages/client/ui-agent-preset/README.i18n.yaml +++ b/packages/client/ui-agent-preset/README.i18n.yaml @@ -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 diff --git a/packages/client/ui-agent-preset/README.md b/packages/client/ui-agent-preset/README.md index 35b667a1f0..b7cd57fed5 100644 --- a/packages/client/ui-agent-preset/README.md +++ b/packages/client/ui-agent-preset/README.md @@ -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 diff --git a/packages/client/ui-agent-preset/README.zh.md b/packages/client/ui-agent-preset/README.zh.md index 03420776bd..4f828c3eec 100644 --- a/packages/client/ui-agent-preset/README.zh.md +++ b/packages/client/ui-agent-preset/README.zh.md @@ -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 管理名单并驱动宿主桌面。 ## 何时不显示这些表层 diff --git a/packages/client/ui-agent-preset/src/client/settings-store.ts b/packages/client/ui-agent-preset/src/client/settings-store.ts index d0a89f358b..8c7e7444f1 100644 --- a/packages/client/ui-agent-preset/src/client/settings-store.ts +++ b/packages/client/ui-agent-preset/src/client/settings-store.ts @@ -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. */ diff --git a/packages/client/ui-agent-preset/tests/settings-store.client.spec.ts b/packages/client/ui-agent-preset/tests/settings-store.client.spec.ts index 9d7baa26d1..6d66b4ee6d 100644 --- a/packages/client/ui-agent-preset/tests/settings-store.client.spec.ts +++ b/packages/client/ui-agent-preset/tests/settings-store.client.spec.ts @@ -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) diff --git a/packages/client/ui-permission-presets/src/client/settings-store.ts b/packages/client/ui-permission-presets/src/client/settings-store.ts index 2f78d7c4b3..2015cfe0b9 100644 --- a/packages/client/ui-permission-presets/src/client/settings-store.ts +++ b/packages/client/ui-permission-presets/src/client/settings-store.ts @@ -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' diff --git a/packages/client/ui-settings-general/README.i18n.yaml b/packages/client/ui-settings-general/README.i18n.yaml index 77b5c4e2d3..e3ddfd3faf 100644 --- a/packages/client/ui-settings-general/README.i18n.yaml +++ b/packages/client/ui-settings-general/README.i18n.yaml @@ -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 diff --git a/packages/client/ui-settings-general/README.md b/packages/client/ui-settings-general/README.md index c59b77617c..b625f42598 100644 --- a/packages/client/ui-settings-general/README.md +++ b/packages/client/ui-settings-general/README.md @@ -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. diff --git a/packages/client/ui-settings-general/README.zh.md b/packages/client/ui-settings-general/README.zh.md index ea4896284b..9381bec1cc 100644 --- a/packages/client/ui-settings-general/README.zh.md +++ b/packages/client/ui-settings-general/README.zh.md @@ -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`;外壳本身仍不持有产品策略。 diff --git a/packages/client/ui-settings-general/tests/apply.client.spec.ts b/packages/client/ui-settings-general/tests/apply.client.spec.ts index c91b608509..d26af3c58a 100644 --- a/packages/client/ui-settings-general/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-general/tests/apply.client.spec.ts @@ -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 }) diff --git a/packages/client/ui-settings-models/tests/welcome-store.client.spec.ts b/packages/client/ui-settings-models/tests/welcome-store.client.spec.ts index 52d2771b0b..efcb2aefdb 100644 --- a/packages/client/ui-settings-models/tests/welcome-store.client.spec.ts +++ b/packages/client/ui-settings-models/tests/welcome-store.client.spec.ts @@ -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') diff --git a/packages/client/ui-settings/README.i18n.yaml b/packages/client/ui-settings/README.i18n.yaml index c4188a0eaf..98fc9433d0 100644 --- a/packages/client/ui-settings/README.i18n.yaml +++ b/packages/client/ui-settings/README.i18n.yaml @@ -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 diff --git a/packages/client/ui-settings/README.md b/packages/client/ui-settings/README.md index 9904695733..7e4f3e20ad 100644 --- a/packages/client/ui-settings/README.md +++ b/packages/client/ui-settings/README.md @@ -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. diff --git a/packages/client/ui-settings/README.zh.md b/packages/client/ui-settings/README.zh.md index a0ce9d5cf6..cc1c0c6bd9 100644 --- a/packages/client/ui-settings/README.zh.md +++ b/packages/client/ui-settings/README.zh.md @@ -15,5 +15,5 @@ ## 已知限制与暂缓事项 -- **远程浏览器没有持久化设置**:设置 RPC 仅限 loopback,因此在非 loopback 浏览器中绑定的 scope 以 `unavailable` 起步且从不跨线路,它支撑的每一行在那里都是无效的。 +- **非 loopback 页面没有持久化设置**:本 Client 在那里禁用 Host 持久化,因此 scope 以 `unavailable` 起步且从不跨线路;尽管 Connection 认证覆盖 API,它支撑的每一行仍在那里无效。 - **每次写入仅一个字段**:`set` 只发送单个 `set` op,因此需要同时改动两个字段的行没有事务可用,会发布两个 revision。 diff --git a/packages/client/ui-settings/src/client/settings-mirror.ts b/packages/client/ui-settings/src/client/settings-mirror.ts index 81b190e034..bd4aedfc16 100644 --- a/packages/client/ui-settings/src/client/settings-mirror.ts +++ b/packages/client/ui-settings/src/client/settings-mirror.ts @@ -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, diff --git a/packages/client/ui-settings/src/client/settings-scope.ts b/packages/client/ui-settings/src/client/settings-scope.ts index a49b8ba707..1b06940577 100644 --- a/packages/client/ui-settings/src/client/settings-scope.ts +++ b/packages/client/ui-settings/src/client/settings-scope.ts @@ -57,7 +57,7 @@ export class SettingsScopeController implements SettingsScope { * @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( diff --git a/packages/client/ui-theme/README.i18n.yaml b/packages/client/ui-theme/README.i18n.yaml index 9d080a036f..cdc4c96fa1 100644 --- a/packages/client/ui-theme/README.i18n.yaml +++ b/packages/client/ui-theme/README.i18n.yaml @@ -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 diff --git a/packages/client/ui-theme/README.md b/packages/client/ui-theme/README.md index a9f0eb4287..0c9bdf3ee3 100644 --- a/packages/client/ui-theme/README.md +++ b/packages/client/ui-theme/README.md @@ -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 `` 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. diff --git a/packages/client/ui-theme/README.zh.md b/packages/client/ui-theme/README.zh.md index 1447536c3e..033bf7867a 100644 --- a/packages/client/ui-theme/README.zh.md +++ b/packages/client/ui-theme/README.zh.md @@ -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 服务器时,主机侧紧接 `` 起始标签注入同步引导代码。每份 index 响应会嵌入已注册的 Host 设置 `ui-theme.preference`,没有 settings provider 时则嵌入 `system`;浏览器按操作系统配色解析 `system`,随后在外壳加载页面渲染前设置 `color-scheme` 和 `body[data-ds-dark-theme]`。不含 HTTP 服务器的组合不受影响,插件树激活后,ThemeRuntime 与 ui-layout 仍分别是客户端状态和后续 DOM 更新的权威来源。 diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index a369fe508f..c07785d98f 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/README.i18n.yaml @@ -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 diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 4cf11c7ffe..69826d76b4 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -50,15 +50,15 @@ A stale continuation discards every partial result, deduplication entry, and cur Directory picking delegates to the composed `ctx.directoryPicker` backend ([the directory-picker seam](../directory-picker/README.md)); a method called outside the composed capability's kind fails with `directory-picker-unavailable` (the client needs no advertisement — the composed picker package's own client half renders the matching interaction). Under `native`, `host.pickDirectory` opens one native chooser and returns its selected path (`null` on cancel); this user-paced method does not use the default 30-second unary timeout, while caller/connection aborts still propagate to the native process. Under `browse`, `host.listDirectory` returns one name-sorted directory level with breadcrumb ancestry, a `home` anchor, and host-owned `hidden` flags (absent path = home directory), and `host.createDirectory` creates one validated child segment; the backend's typed failures map 1:1 onto the `directory-unreadable`/`directory-exists`/`directory-create-failed` codes. The browser carrier's prefix-wide trust fence (dsh-client-connection) covers all of these like every other `/api` request. -`host.openPath` opens a filesystem path with the operating system's default application (`open` on macOS, `Invoke-Item` on Windows, and `xdg-open` on desktop Linux). For `.html`, `.htm`, `.xhtml`, and `.svg`, macOS and desktop Linux prefer a named default browser and fall back to that application handoff when none can be named. WSL translates every Linux path through `wslpath -w` and hands the resulting Windows/UNC path to Windows `Invoke-Item`, including browser-renderable documents, instead of assuming a Linux desktop association. `host.describe.home` is the host account home directory. The Web client uses it to display POSIX home-rooted paths as `~`; Windows values are still reported and are not abbreviated. `host.describe.canOpenPath` advertises whether that handoff can reach a user-visible desktop: explicit gateway `nativeOpen` wins, an injected opener is usable by definition, and platform detection otherwise accepts macOS, Windows, WSL, or Linux with a display while rejecting headless/container Linux. The browser carrier applies the same loopback, same-origin restriction as `host.pickDirectory`; clients combine both facts before presenting a native action. +`host.openPath` opens a filesystem path with the operating system's default application (`open` on macOS, `Invoke-Item` on Windows, and `xdg-open` on desktop Linux). For `.html`, `.htm`, `.xhtml`, and `.svg`, macOS and desktop Linux prefer a named default browser and fall back to that application handoff when none can be named. WSL translates every Linux path through `wslpath -w` and hands the resulting Windows/UNC path to Windows `Invoke-Item`, including browser-renderable documents, instead of assuming a Linux desktop association. `host.describe.home` is the host account home directory. The Web client uses it to display POSIX home-rooted paths as `~`; Windows values are still reported and are not abbreviated. `host.describe.canOpenPath` advertises whether that handoff can reach a user-visible desktop: explicit gateway `nativeOpen` wins, an injected opener is usable by definition, and platform detection otherwise accepts macOS, Windows, WSL, or Linux with a display while rejecting headless/container Linux. The browser carrier applies the same Host/Origin checks and signed-cookie authentication as every Host API method; clients combine the capability facts before presenting a native action. The `agentPreset.list` domain exposes the deployment's preset roster so a browser can offer a choice when starting a session; each row carries its `trust` (a `user` preset is exactly as privileged as the plugins it names), whether it is the current default, and — when the preset cannot compose a session — a `broken` reason, because a damaged directory still occupies its id and a surface must be able to show and delete it rather than offer it and fail the session start. A deployment composing no presets answers with an empty roster rather than an error, because sharing the host composition is a valid deployment. `agentPreset.select` recomposes one session's agent from a different preset, and is allowed only while the 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 the attempt answers `agent-preset-locked`. The agent and the session survive — only the composition is swapped, and a failed swap restores the previous one. -`agentPreset.read`, `copy`, `openDocument`, and `remove` manage the compositions themselves. `read` reports the text with its `trust`, for the read-only viewer. Authoring is copy-only: `copy` takes `{ from, agentPreset, name? }` — two ids the Host resolves against its own roots plus an optional display name — and copies the source's whole directory, so no composition text crosses the wire and a copy is exactly as loadable as its source; an uncontainable or already-taken id answers `agent-preset-invalid`, and `remove` refuses a shipped preset as `agent-preset-read-only`. `openDocument` hands one locally authored preset's DIRECTORY to the platform opener — the request carries an id, never a path, so no browser payload can select an arbitrary filesystem target; where the deployment has no native opener the reply is `{ opened: false, path }` for the surface to show as text, a shipped preset is refused like `remove`, and the gateway's `nativeOpen` config pins the capability where platform detection (`canOpenNativePath`) would mislead. These four are loopback-pinned in [`dsh-client-connection`](../../client/connection/README.md): a composition names the plugins a session runs, so reading one is reconnaissance, and copy/remove/openDocument manage the roster and drive the host desktop. `list` and `select` stay ordinary — the roster carries ids and trust and every preset picker needs it, and choosing a preset grants nothing `session.create`'s own `agentPreset` did not, over a default that already carries bash. `list` reports two path-free capability flags: `authorable`, whether the deployment configures a root a new preset could be copied to, and `hasDocument`, whether `openDocument` would open natively rather than answer a path. +`agentPreset.read`, `copy`, `openDocument`, and `remove` manage the compositions themselves. `read` reports the text with its `trust`, for the read-only viewer. Authoring is copy-only: `copy` takes `{ from, agentPreset, name? }` — two ids the Host resolves against its own roots plus an optional display name — and copies the source's whole directory, so no composition text crosses the wire and a copy is exactly as loadable as its source; an uncontainable or already-taken id answers `agent-preset-invalid`, and `remove` refuses a shipped preset as `agent-preset-read-only`. `openDocument` hands one locally authored preset's DIRECTORY to the platform opener — the request carries an id, never a path, so no browser payload can select an arbitrary filesystem target; where the deployment has no native opener the reply is `{ opened: false, path }` for the surface to show as text, a shipped preset is refused like `remove`, and the gateway's `nativeOpen` config pins the capability where platform detection (`canOpenNativePath`) would mislead. [`dsh-client-connection`](../../client/connection/README.md) authenticates these methods with `list`, `select`, and the complete Host API. A composition still names the plugins a session runs, so reading one is reconnaissance and copy/remove/openDocument manage the roster and drive the host desktop. `list` reports two path-free capability flags: `authorable`, whether the deployment configures a root a new preset could be copied to, and `hasDocument`, whether `openDocument` would open natively rather than answer a path. The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `skill.list` serves the composer's menu: it returns every user-invocable skill with its `modelInvocable` flag, so menus can mark user-only (`disable-model-invocation`) entries whose only invocation path is the slash gesture. Listing is the skill domain's only RPC — invocation itself is an ordinary `session.prompt` whose whitespace-bounded `/name` tokens `dsh-tool-skill` recognizes at the pre-step boundary and answers with injected `` context, so every entry point (Web, TUI, and ACP) shares one deterministic path—including for hand-typed text—with no dedicated invocation wire. `command.execute` runs a slash-command line host-side with pure admission semantics: the response reports whether the line resolved to a handler plus the minted lifecycle `commandId` when it did (correlating the acknowledgment with the flow node), while the outcome rides the durably logged `command/run`/`command/done` lifecycle pair broadcast on the mux stream. Command handlers may legitimately outlast the 30-second transport health deadline, so `command.execute` carries only caller/connection cancellation; that signal cancels the running handler. `commands/change` rides the forwarded-event frame as the registry-wide catalog invalidation signal: clients refetch `command.list` instead of diffing. Forwarded `agent-preset/selected` is its per-session counterpart, emitted from the logged selection commit: recomposing a blank session's agent re-parents its scope without registering anything, so both catalogs that session's composition decides (`command.list`, `skill.list`) go stale with no registry change to announce it. -The `settings.*`, `credentials.*`, and `llm.*` domains are the configuration-page wire. The settings domain serves every registered namespace: a plugin distributed outside this repository becomes browser-configurable by registering its section, with no change here, and this proxy adds no boundary of its own — a name no registration answers folds into the seam's own `settings-rejected`. Which surface renders a namespace is the browser's decision (the plugin configuration page keys its cards on the namespace), never this proxy's. `settings.describe` returns each namespace's serialized schemastery schema, redacted layered values (resolved/`base`/`user` — a field's presence in `user` marks it user-overridden), the `secrets` slot list, the section's `revision`, and the boolean `hasDocument` capability flag. The browser receives no Host path: pathless `settings.openDocument` asks the provider to materialize its document and then hands the Host-resolved result to the native opener, so no browser payload can select any filesystem target. `settings.update`/`settings.replace` write the user layer; `settings.mutate` applies path ops (`set`/`unset`) against the section as stored, which is the removal path for a client holding the redacted view — rebuilding a section from it and replacing wholesale would delete the secrets the wire never returned. Any write may carry `expectedRevision`; a stale one answers `settings-conflict` with both revisions rather than overwriting the writer that landed first, and every other seam refusal folds into `settings-rejected`. Secret-role values never ride any response in any layer; a secret crosses the wire in exactly one direction — inside an `update`/`mutate` payload or `credentials.set`. `credentials.describe` returns value-free views (`configured`/`source`/`writable`), and `credentials.set`/`credentials.unset` map a shadowed-reference refusal onto `credential-rejected`. `llm.providers` merges the configurable-provider directory with live routes (dormant entries carry `active: false`; undeclared live routes append with no settings address) and `llm.models` is the session-independent catalog. `llm.discoverModels` interrogates a provider endpoint the page is still drafting: `settingsNs` selects the adapter family that knows how to read the listing, and the endpoint, protocol, and key come from the form rather than from storage. It writes nothing — the reply is candidates, and only a later `settings.mutate` decides what a route serves — so its `apiKey` is the third payload on which a secret may ride, alongside `settings.update`/`mutate` and `credentials.set`. The host never stores or returns it; like the other two it does ride the client's outgoing envelope, which `subscribeEnvelopes()` observers can see, and redacting that tap is a configuration-plane-wide change rather than this method's to make alone. Every refusal (an unserved namespace, a protocol with no readable listing, an unreachable endpoint, a rejected credential) 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. Invalidations keep every surface converged without polling. `settings/document-updated` and `credentials/reference-updated` ride the verbatim forwarded-event frame (see below), so a raw settings change whose resolved value is unchanged still reaches clients, and a credential invalidation still carries reference names only, never values. `llm/adapters-updated` is forwarded beside `settings/document-updated`; concrete model consumers subscribe to both owner events directly because topology commits and settings documents can independently change their directories. The browser carrier restricts the whole configuration plane, reads and native actions included (`settings.describe`/`openDocument`/`update`/`replace`/`mutate`, `credentials.describe`/`set`/`unset`), to loopback same-origin requests — the `host.pickDirectory` privileged set. A composition without a settings or credential provider answers those domains with an actionable `internal` error naming the missing plugin. +The `settings.*`, `credentials.*`, and `llm.*` domains are the configuration-page wire. The settings domain serves every registered namespace: a plugin distributed outside this repository becomes browser-configurable by registering its section, with no change here, and this proxy adds no boundary of its own — a name no registration answers folds into the seam's own `settings-rejected`. Which surface renders a namespace is the browser's decision (the plugin configuration page keys its cards on the namespace), never this proxy's. `settings.describe` returns each namespace's serialized schemastery schema, redacted layered values (resolved/`base`/`user` — a field's presence in `user` marks it user-overridden), the `secrets` slot list, the section's `revision`, and the boolean `hasDocument` capability flag. The browser receives no Host path: pathless `settings.openDocument` asks the provider to materialize its document and then hands the Host-resolved result to the native opener, so no browser payload can select any filesystem target. `settings.update`/`settings.replace` write the user layer; `settings.mutate` applies path ops (`set`/`unset`) against the section as stored, which is the removal path for a client holding the redacted view — rebuilding a section from it and replacing wholesale would delete the secrets the wire never returned. Any write may carry `expectedRevision`; a stale one answers `settings-conflict` with both revisions rather than overwriting the writer that landed first, and every other seam refusal folds into `settings-rejected`. Secret-role values never ride any response in any layer; a secret crosses the wire in exactly one direction — inside an `update`/`mutate` payload or `credentials.set`. `credentials.describe` returns value-free views (`configured`/`source`/`writable`), and `credentials.set`/`credentials.unset` map a shadowed-reference refusal onto `credential-rejected`. `llm.providers` merges the configurable-provider directory with live routes (dormant entries carry `active: false`; undeclared live routes append with no settings address) and `llm.models` is the session-independent catalog. `llm.discoverModels` interrogates a provider endpoint the page is still drafting: `settingsNs` selects the adapter family that knows how to read the listing, and the endpoint, protocol, and key come from the form rather than from storage. It writes nothing — the reply is candidates, and only a later `settings.mutate` decides what a route serves — so its `apiKey` is the third payload on which a secret may ride, alongside `settings.update`/`mutate` and `credentials.set`. The host never stores or returns it; like the other two it does ride the client's outgoing envelope, which `subscribeEnvelopes()` observers can see, and redacting that tap is a configuration-plane-wide change rather than this method's to make alone. Every refusal (an unserved namespace, a protocol with no readable listing, an unreachable endpoint, a rejected credential) 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. Invalidations keep every surface converged without polling. `settings/document-updated` and `credentials/reference-updated` ride the verbatim forwarded-event frame (see below), so a raw settings change whose resolved value is unchanged still reaches clients, and a credential invalidation still carries reference names only, never values. `llm/adapters-updated` is forwarded beside `settings/document-updated`; concrete model consumers subscribe to both owner events directly because topology commits and settings documents can independently change their directories. Connection authenticates the whole configuration plane, reads and native actions included, with the same browser session as every Host API method. A composition without a settings or credential provider answers those domains with an actionable `internal` error naming the missing plugin. ## Carrier layer (`/client` + root) diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index 934a00f36a..c7f5d5b579 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -50,15 +50,15 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr 目录选择委托给组合的 `ctx.directoryPicker` 后端([目录选择 seam](../directory-picker/README.zh.md));调用组合能力 kind 之外的方法会以 `directory-picker-unavailable` 失败(客户端不需要广播——组合的选择器包自己的 client half 渲染匹配的交互)。在 `native` 下,`host.pickDirectory` 打开一个原生选择器并返回选中路径(取消为 `null`);该方法需等待用户完成操作,不使用默认的 30 秒一元调用超时,而调用方与连接的中止仍会传播至原生进程。在 `browse` 下,`host.listDirectory` 返回一个按名称排序的目录层级,携带面包屑祖先链、`home` 锚点与宿主判定的 `hidden` 标志(不带路径即家目录),`host.createDirectory` 创建一个经校验的子段;后端的类型化失败 1:1 映射为 `directory-unreadable`/`directory-exists`/`directory-create-failed` 错误码。浏览器载体的前缀级信任栅栏(dsh-client-connection)像覆盖其他所有 `/api` 请求一样覆盖上述全部方法。 -`host.openPath` 会用操作系统的默认应用打开一个文件系统路径(macOS 为 `open`,Windows 为 `Invoke-Item`,桌面 Linux 为 `xdg-open`)。对于 `.html`、`.htm`、`.xhtml` 与 `.svg`,macOS 和桌面 Linux 会优先使用能够确定的默认浏览器;无法确定时回退到上述应用交接。WSL 会通过 `wslpath -w` 转换每个 Linux 路径,并将所得 Windows/UNC 路径交给 Windows `Invoke-Item`,浏览器可渲染的文档也不例外,而非假定存在 Linux 桌面文件关联。`host.describe.home` 是宿主账户的家目录。Web 客户端用它把 POSIX 家目录路径显示为 `~`;Windows 值仍会上报,但不会缩写。`host.describe.canOpenPath` 会宣告这次交接能否抵达用户可见的桌面:网关显式配置的 `nativeOpen` 优先,注入的 opener 按定义可用,否则平台检测接受 macOS、Windows、WSL 或带 display 的 Linux,并拒绝 headless/容器 Linux。浏览器载体对其施加与 `host.pickDirectory` 相同的回环、同源限制;客户端会组合这两个事实后再呈现原生操作。 +`host.openPath` 会用操作系统的默认应用打开一个文件系统路径(macOS 为 `open`,Windows 为 `Invoke-Item`,桌面 Linux 为 `xdg-open`)。对于 `.html`、`.htm`、`.xhtml` 与 `.svg`,macOS 和桌面 Linux 会优先使用能够确定的默认浏览器;无法确定时回退到上述应用交接。WSL 会通过 `wslpath -w` 转换每个 Linux 路径,并将所得 Windows/UNC 路径交给 Windows `Invoke-Item`,浏览器可渲染的文档也不例外,而非假定存在 Linux 桌面文件关联。`host.describe.home` 是宿主账户的家目录。Web 客户端用它把 POSIX 家目录路径显示为 `~`;Windows 值仍会上报,但不会缩写。`host.describe.canOpenPath` 会宣告这次交接能否抵达用户可见的桌面:网关显式配置的 `nativeOpen` 优先,注入的 opener 按定义可用,否则平台检测接受 macOS、Windows、WSL 或带 display 的 Linux,并拒绝 headless/容器 Linux。浏览器载体对其施加与每个 Host API 方法相同的 Host/Origin 校验和签名 cookie 认证;客户端会组合能力事实后再呈现原生操作。 `agentPreset.list` 领域向浏览器暴露部署的 preset 名单,使其在开启会话时能够提供选择;每一行携带它的 `trust`(`user` preset 的权限恰好等于它所引用的插件)、它是否为当前默认值,以及——当该 preset 无法组装会话时——一条 `broken` 原因:损坏的目录仍占着它的 id,界面必须能展示并删除它,而不是把它端出来然后在会话启动时失败。未组装任何 preset 的部署返回空名单而非错误,因为共用宿主组装本身就是一种有效部署。`agentPreset.select` 用另一个 preset 重组某个会话的 agent,且仅在会话空白时允许:一旦跑过任何轮次,那段历史就是在该 preset 的工具下产生的,替换会留下无法执行的已记录的工具调用,此时返回 `agent-preset-locked`。agent 与会话都不销毁——只替换组装,且替换失败会恢复原来的组装。 -`agentPreset.read`、`copy`、`openDocument` 与 `remove` 负责管理组装本身。`read` 返回文本连同它的 `trust`,供只读查看器使用。创作只有复制一种写入:`copy` 接收 `{ from, agentPreset, name? }`——两个由 Host 对照自身根目录解析的 id 加一个可选显示名——并整目录复制来源,因此组装文本不经过传输层,副本与其来源同等可加载;不可约束或已被占用的 id 回答 `agent-preset-invalid`,`remove` 对随附 preset 回答 `agent-preset-read-only`。`openDocument` 把一个本地创作 preset 的**目录**交给平台打开器——请求只携带 id、绝不携带路径,因此没有任何浏览器载荷能选中任意文件系统目标;部署没有原生打开器时回答 `{ opened: false, path }` 供界面以文本展示,随附 preset 与 `remove` 一样被拒绝,而网关的 `nativeOpen` 配置可在平台探测(`canOpenNativePath`)失真处钉死该能力。这四个方法在 [`dsh-client-connection`](../../client/connection/README.zh.md) 中被固定在环回地址:组装指明了一个会话所运行的插件,因此读取它是侦察,而 copy/remove/openDocument 管理名单并驱动宿主桌面。`list` 与 `select` 保持为普通方法——名单只携带 id 与信任级别,每个 preset 选择器都需要它;而选择一个 preset 并不比 `session.create` 自带的 `agentPreset` 多给任何能力,何况默认 preset 本就带着 bash。`list` 报告两个不含路径的能力标志:`authorable`,即部署是否配置了可供复制新 preset 的根目录;`hasDocument`,即 `openDocument` 会原生打开、还是回答一个路径。 +`agentPreset.read`、`copy`、`openDocument` 与 `remove` 负责管理组装本身。`read` 返回文本连同它的 `trust`,供只读查看器使用。创作只有复制一种写入:`copy` 接收 `{ from, agentPreset, name? }`——两个由 Host 对照自身根目录解析的 id 加一个可选显示名——并整目录复制来源,因此组装文本不经过传输层,副本与其来源同等可加载;不可约束或已被占用的 id 回答 `agent-preset-invalid`,`remove` 对随附 preset 回答 `agent-preset-read-only`。`openDocument` 把一个本地创作 preset 的**目录**交给平台打开器——请求只携带 id、绝不携带路径,因此没有任何浏览器载荷能选中任意文件系统目标;部署没有原生打开器时回答 `{ opened: false, path }` 供界面以文本展示,随附 preset 与 `remove` 一样被拒绝,而网关的 `nativeOpen` 配置可在平台探测(`canOpenNativePath`)失真处钉死该能力。[`dsh-client-connection`](../../client/connection/README.zh.md) 用与 `list`、`select` 及完整 Host API 相同的浏览器会话认证这些方法。组装仍指明一个会话所运行的插件,因此读取它是侦察,而 copy/remove/openDocument 管理名单并驱动宿主桌面。`list` 报告两个不含路径的能力标志:`authorable`,即部署是否配置了可供复制新 preset 的根目录;`hasDocument`,即 `openDocument` 会原生打开、还是回答一个路径。 `command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和 skill(技能)目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`skill.list` 服务于 composer 的菜单:它返回每一个用户可调用的 skill 及其 `modelInvocable` 标志,让菜单能够标出仅限用户(`disable-model-invocation`)的条目——斜杠手势是这类条目唯一的调用路径。列表是 skill 领域唯一的 RPC——调用本身就是一次普通的 `session.prompt`,`dsh-tool-skill` 会在 pre-step 边界识别其中以空白为界的 `/name` token,并以注入的 `` 上下文作答,因此所有入口(Web、TUI 与 ACP(Agent Client Protocol))共享同一条确定性路径,手动键入的文本也走该路径,且没有专设的调用协议。`command.execute` 在宿主侧运行一条斜杠命令行,语义为纯准入:响应报告该行是否解析到处理器,并在解析到时回带铸造的生命周期 `commandId`(将本次确认与流节点关联);结局经由持久落账并在 mux 流广播的 `command/run`/`command/done` 生命周期事件对承载。命令处理器运行超过 30 秒的传输健康时限仍属正常,因此 `command.execute` 仅携带调用方/连接取消信号;该信号可取消正在运行的处理器。`commands/change` 搭乘转发事件帧作为注册表级目录失效信号:客户端重新拉取 `command.list` 而不是做差分。转发的 `agent-preset/selected` 是它按会话粒度的对应物,由落账的选择提交点发出:重组空会话的 agent 只是重新挂接其 scope,不产生任何注册,因此该会话组成所决定的两份目录(`command.list`、`skill.list`)都会失效,却没有任何注册表变化来宣告它。 -`settings.*`、`credentials.*` 与 `llm.*` 领域是配置页协议。settings 领域服务于每一个已注册 namespace:在本仓库之外分发的插件只要注册自己的分节即可变得可从浏览器配置,无需改动这里;本代理也不再自设边界——没有任何注册应答的名字会折叠为 seam 自己的 `settings-rejected`。由哪个界面渲染某个 namespace 是浏览器的决定(插件配置页按 namespace 为其卡片编键),从不由本代理决定。`settings.describe` 为每个 namespace 提供其序列化 schemastery schema、脱敏后的分层值(resolved/`base`/`user`——字段出现在 `user` 中即标记其被用户覆盖)、`secrets` 槽位列表、该分节的 `revision`,以及布尔型 `hasDocument` 能力标志。浏览器不会收到 Host 路径:无路径参数的 `settings.openDocument` 会请求提供方准备文档,再把由 Host 解析出的结果交给原生打开器,因此任何浏览器载荷都无法选择任意文件系统目标。`settings.update`/`settings.replace` 写入用户层;`settings.mutate` 则在已存分节上施加路径 op(`set`/`unset`),这是持有脱敏视图的客户端的删除路径——据此重建分节再整体替换,会删掉协议从未回传过的那些机密。任何写入都可携带 `expectedRevision`;陈旧的期望值会以 `settings-conflict` 连同两个 revision 作答,而不是覆盖先落地的那个写方,其余每种 seam 拒绝则折叠为 `settings-rejected`。secret 角色的值绝不在任何一层搭乘任何响应;secret 只沿一个方向跨越协议——在 `update`/`mutate` 载荷或 `credentials.set` 之内。`credentials.describe` 返回不含值的视图(`configured`/`source`/`writable`),`credentials.set`/`credentials.unset` 则把被遮蔽引用的拒绝映射为 `credential-rejected`。`llm.providers` 把可配置提供方目录与存活路由合并(休眠条目携带 `active: false`;未声明的存活路由追加在后,不带 settings 地址),`llm.models` 则是与会话无关的目录。`llm.discoverModels` 询问页面尚在起草的提供方端点:`settingsNs` 选出懂得读取该列表的适配器家族,端点、协议与密钥则来自表单而非存储。它什么都不写——回复是候选,只有随后的 `settings.mutate` 才决定路由服务什么——因此其 `apiKey` 是 secret 可以搭乘的第三个载荷(另两个是 `settings.update`/`mutate` 与 `credentials.set`),且绝不被存储或回显。host 从不存储或回传它;与另两者一样,它确实会搭乘客户端的出站信封,`subscribeEnvelopes()` 的观察者能看到——为该 tap 做脱敏是整个配置面的改动,而非本方法一家的事。每一种拒绝(无人服务的 namespace、没有可读列表的协议、不可达端点、被拒凭据)都折叠为 `model-discovery-failed`,其消息是适配器自己的文本,details 点名被询问的端点,绝不点名所提供的凭据。失效通知让每个面无需轮询即保持收敛。`settings/document-updated` 与 `credentials/reference-updated` 搭乘原样转发事件帧(见下),因此解析值未变的原始设置变更同样能到达客户端,凭据失效通知也仍然只带引用名、绝不带值。`llm/adapters-updated` 与 `settings/document-updated` 一并原样转发;具体模型消费方直接订阅这两个 owner 事件,因为拓扑提交和设置文档都能独立改变其目录。浏览器载体把整个配置面(含读取与原生操作:`settings.describe`/`openDocument`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`)限制为仅接受来自回环地址的同源请求——即 `host.pickDirectory` 所在的特权集合。未装 settings 或凭据提供方的组合会以指名缺失插件、包含解决建议的 `internal` 错误应答这些领域。 +`settings.*`、`credentials.*` 与 `llm.*` 领域是配置页协议。settings 领域服务于每一个已注册 namespace:在本仓库之外分发的插件只要注册自己的分节即可变得可从浏览器配置,无需改动这里;本代理也不再自设边界——没有任何注册应答的名字会折叠为 seam 自己的 `settings-rejected`。由哪个界面渲染某个 namespace 是浏览器的决定(插件配置页按 namespace 为其卡片编键),从不由本代理决定。`settings.describe` 为每个 namespace 提供其序列化 schemastery schema、脱敏后的分层值(resolved/`base`/`user`——字段出现在 `user` 中即标记其被用户覆盖)、`secrets` 槽位列表、该分节的 `revision`,以及布尔型 `hasDocument` 能力标志。浏览器不会收到 Host 路径:无路径参数的 `settings.openDocument` 会请求提供方准备文档,再把由 Host 解析出的结果交给原生打开器,因此任何浏览器载荷都无法选择任意文件系统目标。`settings.update`/`settings.replace` 写入用户层;`settings.mutate` 则在已存分节上施加路径 op(`set`/`unset`),这是持有脱敏视图的客户端的删除路径——据此重建分节再整体替换,会删掉协议从未回传过的那些机密。任何写入都可携带 `expectedRevision`;陈旧的期望值会以 `settings-conflict` 连同两个 revision 作答,而不是覆盖先落地的那个写方,其余每种 seam 拒绝则折叠为 `settings-rejected`。secret 角色的值绝不在任何一层搭乘任何响应;secret 只沿一个方向跨越协议——在 `update`/`mutate` 载荷或 `credentials.set` 之内。`credentials.describe` 返回不含值的视图(`configured`/`source`/`writable`),`credentials.set`/`credentials.unset` 则把被遮蔽引用的拒绝映射为 `credential-rejected`。`llm.providers` 把可配置提供方目录与存活路由合并(休眠条目携带 `active: false`;未声明的存活路由追加在后,不带 settings 地址),`llm.models` 则是与会话无关的目录。`llm.discoverModels` 询问页面尚在起草的提供方端点:`settingsNs` 选出懂得读取该列表的适配器家族,端点、协议与密钥则来自表单而非存储。它什么都不写——回复是候选,只有随后的 `settings.mutate` 才决定路由服务什么——因此其 `apiKey` 是 secret 可以搭乘的第三个载荷(另两个是 `settings.update`/`mutate` 与 `credentials.set`),且绝不被存储或回显。host 从不存储或回传它;与另两者一样,它确实会搭乘客户端的出站信封,`subscribeEnvelopes()` 的观察者能看到——为该 tap 做脱敏是整个配置面的改动,而非本方法一家的事。每一种拒绝(无人服务的 namespace、没有可读列表的协议、不可达端点、被拒凭据)都折叠为 `model-discovery-failed`,其消息是适配器自己的文本,details 点名被询问的端点,绝不点名所提供的凭据。失效通知让每个面无需轮询即保持收敛。`settings/document-updated` 与 `credentials/reference-updated` 搭乘原样转发事件帧(见下),因此解析值未变的原始设置变更同样能到达客户端,凭据失效通知也仍然只带引用名、绝不带值。`llm/adapters-updated` 与 `settings/document-updated` 一并原样转发;具体模型消费方直接订阅这两个 owner 事件,因为拓扑提交和设置文档都能独立改变其目录。Connection 用与每个 Host API 方法相同的浏览器会话认证整个配置面,包括读取与原生操作。未装 settings 或凭据提供方的组合会以指名缺失插件、包含解决建议的 `internal` 错误应答这些领域。 ## 载体层(`/client` + 根路径) diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index a236782544..1a8835383a 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -745,10 +745,9 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro } }, - // Authoring is privileged (see PRIVILEGED_METHODS in dsh-client-connection): - // a composition names the plugins a session runs, so reading one is + // A composition names the plugins a session runs, so reading one is // reconnaissance, and copy/remove/openDocument manage the roster and - // drive the host desktop. + // drive the host desktop. Connection authenticates the complete API. async read(request) { const { agentPreset } = request.payload const presets = ctx.get('agentPresets') diff --git a/packages/host/apiproxy/src/api/agent-presets.ts b/packages/host/apiproxy/src/api/agent-presets.ts index fe33062df6..3918de5894 100644 --- a/packages/host/apiproxy/src/api/agent-presets.ts +++ b/packages/host/apiproxy/src/api/agent-presets.ts @@ -2,11 +2,11 @@ * agent-presets domain contract: the roster a browser offers when starting a * session, plus the authoring calls behind it. * - * `list` is ordinary: it carries ids and trust, and every preset picker needs - * it. The authoring calls are privileged and loopback-pinned — a composition - * names the plugins a session runs, so reading one is reconnaissance, and - * although authoring is copy-only (no caller supplies composition text or a - * path), copying and deleting still rearrange what the deployment offers. + * A composition names the plugins a session runs, so reading one is + * reconnaissance; although authoring is copy-only (no caller supplies + * composition text or a path), copying and deleting still rearrange what the + * deployment offers. Connection authenticates these calls with the complete + * Host API rather than assigning a separate method tier. */ import type { SessionId } from '@deepseek-ai/dsh-session/types' diff --git a/packages/host/apiproxy/src/api/host.ts b/packages/host/apiproxy/src/api/host.ts index 5e45fcf3a5..e700b86db3 100644 --- a/packages/host/apiproxy/src/api/host.ts +++ b/packages/host/apiproxy/src/api/host.ts @@ -88,8 +88,8 @@ export interface HostApi { /** * Open a filesystem path with the operating system's default application * (Finder / Explorer / xdg-open hand-off). The browser carrier's - * prefix-wide trust fence covers this privileged method like every other - * `/api` request. + * prefix-wide trust and authentication checks cover this method like every + * other `/api` request. */ openPath( request: RpcRequest<{ path: string }>, diff --git a/packages/host/apiproxy/src/api/settings.ts b/packages/host/apiproxy/src/api/settings.ts index 5bbc6d56d9..ec7017eef7 100644 --- a/packages/host/apiproxy/src/api/settings.ts +++ b/packages/host/apiproxy/src/api/settings.ts @@ -55,8 +55,8 @@ export interface SettingsApi { * Describe every registered namespace: redacted layered values plus the * serialized schema a client renders its form from. `hasDocument` reports * whether a file-backed provider owns a local document without exposing its - * Host path. This method is loopback-only; `writable: false` (read-only - * provider) tells the client to disable every write control. + * Host path. Connection requires the browser session used by every Host API + * method; `writable: false` tells the client to disable every write control. */ describe(request: RpcRequest<{}>): Promise { it('serves every registered namespace, including one this repository never named', async () => { // Registering IS the exposure: a plugin distributed outside this // repository configures itself from the browser without a change here. - // The plane stays loopback-only and secret-redacted, and which surface - // renders a namespace is the browser's decision, not this proxy's. + // The plane stays browser-authenticated and secret-redacted, and which + // surface renders a namespace is the browser's decision, not this proxy's. const ctx = await harness() ctx.settings.register(NS, AdapterConfig) ctx.settings.register(settingsNamespace('some-other-plugin'), z.object({ secretPath: z.string() })) diff --git a/packages/host/frontend-static/README.i18n.yaml b/packages/host/frontend-static/README.i18n.yaml index 771455e06a..f21081f544 100644 --- a/packages/host/frontend-static/README.i18n.yaml +++ b/packages/host/frontend-static/README.i18n.yaml @@ -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/frontend-static/README.md -README.md: e4f3765a0471566dfb2f6780b1072d3e79788dc5 -README.zh.md: 7672478480f07b760634fef0c8640fcbdc0e6e7e +README.md: c541791e667dc07995a91263aa219afb84ee488a +README.zh.md: 76aeaff93db4eb3eb4b098e865acee46776f85c6 diff --git a/packages/host/frontend-static/README.md b/packages/host/frontend-static/README.md index e4f3765a04..c541791e66 100644 --- a/packages/host/frontend-static/README.md +++ b/packages/host/frontend-static/README.md @@ -4,6 +4,8 @@ English | [中文](README.zh.md) SPA dist server for the Web shell: a function plugin (config `{distIndex}`) that claims the [webserver](../webserver/README.md)'s single fallback seat and serves the built frontend directory with explicit index entry points. While `distIndex` is readable, the dist root and configured index path render `index.html` with HTTP 200; other existing files are served directly. An absent or non-file target inside the dist root, including a missing configured index, returns an empty 404; traversal outside the dist root returns 403, unknown extensions ship as `application/octet-stream`, and non-GET/HEAD without a matching named route returns 405. Every successful index response is rendered through the webserver's `renderIndex` — structured injection rows first, then the raw index taps — which is how the boot manifest reaches the page. `distIndex` is an assembly fact of the composing application: [`dsh-web-app`](../../bundle/web-app/README.md) resolves it through the frontend package's exports and mounts this plugin; a deployment never hardcodes it. +Root and configured-index responses require `ctx.connection.authorizeIndex` before reading the HTML bytes. A valid process token receives a 303 redirect plus the persistent browser cookie; an existing valid cookie serves the index; every other index request receives the Connection-owned 401 response. Non-index files remain public static assets. Connection owns all token, cookie, expiry, and signing-record semantics; this package only places that decision before the HTML read. + The fallback seat is single-owner (a second claim throws) and effect-scoped: disposing the plugin's fiber releases the seat, after which the unclaimed webserver answers 404. ## Model Experience diff --git a/packages/host/frontend-static/README.zh.md b/packages/host/frontend-static/README.zh.md index 7672478480..76aeaff93d 100644 --- a/packages/host/frontend-static/README.zh.md +++ b/packages/host/frontend-static/README.zh.md @@ -4,6 +4,8 @@ Web 壳的 SPA dist 服务器:一个函数插件(配置为 `{distIndex}`),占据 [webserver](../webserver/README.zh.md) 的唯一回退席位,并通过显式 index 入口服务已构建的前端目录。`distIndex` 可读时,dist 根目录和配置的 index 路径以 HTTP 200 渲染 `index.html`;其他现有文件直接提供。dist 根目录内缺失或不是文件的目标——包括缺失的配置 index——返回空的 404;越出 dist 根目录的遍历返回 403,未知扩展名按 `application/octet-stream` 提供,GET/HEAD 之外的方法在没有匹配的具名路由时返回 405。每个成功的 index 响应都经 webserver 的 `renderIndex` 渲染——先结构化注入行、后原始 index 转换器——启动 manifest(元数据清单)就是经这条路径送达页面的。`distIndex` 是组合应用的组装事实:[`dsh-web-app`](../../bundle/web-app/README.zh.md) 通过前端包的 exports 解析它并挂载本插件;部署绝不硬编码它。 +根路径与配置 index 响应在读取 HTML 字节前必须通过 `ctx.connection.authorizeIndex`。有效进程令牌得到带持久浏览器 cookie 的 303 重定向;既有有效 cookie 允许提供 index;其他 index 请求都得到 Connection 持有的 401 响应。非 index 文件保持公开静态资源。令牌、cookie、有效期和签名记录的全部语义归 Connection;本包只把这项判定放在 HTML 读取之前。 + 回退席位只有单一所有者(第二次占据会抛错),并受 effect 作用域约束:dispose(资源释放)插件的 fiber 会释放席位,此后无人占据的 webserver 回答 404。 ## 模型体验 diff --git a/packages/host/frontend-static/package.json b/packages/host/frontend-static/package.json index 2fcc012873..067548dca2 100644 --- a/packages/host/frontend-static/package.json +++ b/packages/host/frontend-static/package.json @@ -34,6 +34,7 @@ "peerDependencies": { "@deepseek-ai/dsh-host-webserver": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { @@ -41,6 +42,8 @@ }, "devDependencies": { "@deepseek-ai/cordis-plugin-loader": "workspace:^", + "@deepseek-ai/dsh-client-connection": "workspace:^", + "@deepseek-ai/dsh-credentials-local": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/cordis": "workspace:^" diff --git a/packages/host/frontend-static/src/index.ts b/packages/host/frontend-static/src/index.ts index 1227299362..6855a21190 100644 --- a/packages/host/frontend-static/src/index.ts +++ b/packages/host/frontend-static/src/index.ts @@ -4,8 +4,9 @@ * entry points. A readable index renders at the dist root and configured index * path; missing paths return 404, traversal outside the dist root is 403, * unknown extensions ship as octet-stream, and non-GET/HEAD is 405. Every - * index response runs through the webserver's index render (structured - * injection rows, then raw taps). The dist location is workspace knowledge of + * index response first passes Connection's browser authentication, then the + * webserver's index render (structured injection rows, then raw taps). + * Non-index assets stay public. The dist location is workspace knowledge of * the composing application, so `distIndex` is typically supplied through a * `!!js` expression, never hardcoded by a deployment. * @module @deepseek-ai/dsh-host-frontend-static @@ -16,13 +17,14 @@ import { readFile } from 'node:fs/promises' import { dirname, extname, join, normalize, resolve, sep } from 'node:path' import type { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' +import type {} from '@deepseek-ai/dsh-client-connection' import type {} from '@deepseek-ai/dsh-host-webserver' /** Stable Cordis plugin name. */ export const name = 'frontend-static' -/** Service required before the fallback seat can be claimed. */ -export const inject = ['webServer'] +/** Services required before the authenticated fallback seat can be claimed. */ +export const inject = ['webServer', 'connection'] /** Plugin config: the dist anchor. */ export interface Config { @@ -62,11 +64,13 @@ const STATIC_MISS_CODES: ReadonlySet = new Set([ * @param res - the node:http response to write. * @param distRoot - absolute dist root directory (resolved by the caller). * @param distIndex - absolute path of index.html inside distRoot. + * @param authorizeIndex - authenticates an index response before its bytes are read. * @param renderIndex - produces the index.html body (structured injection * rendering) for the dist root and configured index path. */ export async function serveStatic( pathname: string, res: ServerResponse, distRoot: string, distIndex: string, + authorizeIndex: () => Promise, renderIndex: () => Promise, ): Promise { const target = resolve(normalize(join(distRoot, pathname))) @@ -82,6 +86,7 @@ export async function serveStatic( let type: string try { if (target === distRoot || target === distIndex) { + if (!await authorizeIndex()) return body = await renderIndex() type = HTML_MIME } else { @@ -126,6 +131,13 @@ export function apply(ctx: Context, config: Config): void { } /* v8 ignore next -- node:http always sets url on server requests */ const rawPath = new URL(req.url ?? '/', 'http://x').pathname - await serveStatic(decodeURIComponent(rawPath), res, distRoot, distIndex, renderIndex) + await serveStatic( + decodeURIComponent(rawPath), + res, + distRoot, + distIndex, + () => ctx.connection.authorizeIndex(req, res), + renderIndex, + ) }), 'frontend-static: fallback seat') } diff --git a/packages/host/frontend-static/tests/frontend-static.spec.ts b/packages/host/frontend-static/tests/frontend-static.spec.ts index 93989857b9..a503777254 100644 --- a/packages/host/frontend-static/tests/frontend-static.spec.ts +++ b/packages/host/frontend-static/tests/frontend-static.spec.ts @@ -14,6 +14,8 @@ import { afterEach, describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import Loader from '@deepseek-ai/cordis-plugin-loader' import Include from '@deepseek-ai/cordis-plugin-include' +import * as Connection from '@deepseek-ai/dsh-client-connection' +import LocalCredentials from '@deepseek-ai/dsh-credentials-local' import HttpServer from '@deepseek-ai/dsh-host-webserver' import * as FrontendStatic from '../src/index.ts' @@ -27,7 +29,7 @@ afterEach(async () => { root = undefined }) -/** Write a dist fixture and a two-row cordis.yml, then boot it through the real Loader. */ +/** Write a dist fixture and the authenticated Web rows, then boot them through the real Loader. */ async function loadComposition(): Promise { root = await mkdtemp(join(tmpdir(), 'dsh-frontend-static-')) const dist = join(root, 'dist') @@ -40,10 +42,15 @@ async function loadComposition(): Promise { await mkdir(join(dist, 'empty')) const configPath = join(root, 'cordis.yml') await writeFile(configPath, [ + "- name: '@deepseek-ai/dsh-credentials-local'", + ' config:', + ` path: '${join(root, '.credentials.yaml')}'`, + ' watch: false', "- name: '@deepseek-ai/dsh-host-webserver'", ' config:', " host: '127.0.0.1'", ' port: 0', + "- name: '@deepseek-ai/dsh-client-connection'", '- id: frontend', " name: '@deepseek-ai/dsh-host-frontend-static'", ' config:', @@ -56,7 +63,9 @@ async function loadComposition(): Promise { await context.plugin(Loader) context.loader.builtins.include = Include const modules = new Map([ + ['@deepseek-ai/dsh-credentials-local', LocalCredentials], ['@deepseek-ai/dsh-host-webserver', HttpServer], + ['@deepseek-ai/dsh-client-connection', Connection], ['@deepseek-ai/dsh-host-frontend-static', FrontendStatic], ]) context.loader.internal = { @@ -95,6 +104,24 @@ describe('real Loader composition', () => { expect(unloaded).toEqual([]) const server = loaded.webServer const port = server.port + const launchUrl = loaded.connection.authenticatedUrl(`http://127.0.0.1:${String(port)}`) + const exchange = await fetch(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('authenticated frontend did not set a cookie') + const cookie = setCookie.split(';', 1)[0]! + const authenticated = (init?: RequestInit): RequestInit => { + const headers = new Headers(init?.headers) + headers.set('cookie', cookie) + return { ...init, headers } + } + + expect(await request(port, '/')).toMatchObject({ + status: 401, + type: 'text/plain; charset=utf-8', + body: 'dsh web authentication required; reopen the URL printed by dsh web.\n', + }) // Real assets with their MIME types; a live rebuild is served on the next read. expect(await request(port, '/app.js')).toMatchObject({ status: 200, type: 'text/javascript; charset=utf-8', body: 'export {}' }) @@ -117,33 +144,33 @@ describe('real Loader composition', () => { // Only the root and index path render index.html through registered taps. const untap = server.tapIndex(html => html.replace('', '')) for (const path of ['/', '/index.html', '/?fixture']) { - const got = await request(port, path) + const got = await request(port, path, authenticated()) expect(got.status).toBe(200) expect(got.type).toBe('text/html; charset=utf-8') expect(got.body).toContain('__T__') expect(got.body).toContain('shell') } - expect(await request(port, '/', { method: 'HEAD' })).toEqual({ + expect(await request(port, '/', authenticated({ method: 'HEAD' }))).toEqual({ status: 200, type: 'text/html; charset=utf-8', body: '', }) untap() - expect((await request(port, '/')).body).not.toContain('__T__') + expect((await request(port, '/', authenticated())).body).not.toContain('__T__') // A missing configured index follows the same empty-404 contract for both // of its public entry paths and for both supported methods. await rm(join(root!, 'dist', 'index.html')) for (const path of ['/', '/index.html']) { - const get = await request(port, path) - const head = await request(port, path, { method: 'HEAD' }) + const get = await request(port, path, authenticated()) + const head = await request(port, path, authenticated({ method: 'HEAD' })) expect(get).toEqual({ status: 404, type: null, body: '' }) expect(head).toEqual(get) } // Ordinary unknown paths and static-resource misses are empty 404s for // both GET and HEAD; neither class can be mistaken for the HTML shell. - const ordinaryMisses = ['/no/such/route', '/api/no/such/route', '/empty', '/app.js/child'] + const ordinaryMisses = ['/no/such/route', '/empty', '/app.js/child'] const assetMisses = [ '/missing.js', '/missing.css', @@ -158,6 +185,11 @@ describe('real Loader composition', () => { expect(get).toEqual({ status: 404, type: null, body: '' }) expect(head).toEqual(get) } + expect(await request(port, '/api/no/such/route', authenticated())).toEqual({ + status: 404, + type: 'text/plain;charset=UTF-8', + body: 'not found', + }) // Traversal outside the dist root is 403, non-GET/HEAD is 405, and a // malformed filesystem target still reaches the webserver's 400 guard. diff --git a/packages/host/frontend-static/tsconfig.json b/packages/host/frontend-static/tsconfig.json index 6c09296ed9..5eda998b9b 100644 --- a/packages/host/frontend-static/tsconfig.json +++ b/packages/host/frontend-static/tsconfig.json @@ -17,6 +17,9 @@ { "path": "../../../vendor/schemastery" }, + { + "path": "../../client/connection/tsconfig.host.json" + }, { "path": "../webserver" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6e95ecdff6..86359634bb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -454,9 +454,15 @@ importers: '@types/js-yaml': specifier: ^4.0.9 version: 4.0.9 + '@types/ws': + specifier: 8.18.1 + version: 8.18.1 execa: specifier: ^10.0.0 version: 10.0.0 + ws: + specifier: 8.21.0 + version: 8.21.0 apps/web: devDependencies: @@ -499,6 +505,9 @@ importers: '@types/react-dom': specifier: ~18.3.0 version: 18.3.7(@types/react@18.3.31) + '@types/ws': + specifier: 8.18.1 + version: 8.18.1 '@vitejs/plugin-react': specifier: ^4.0.0 version: 4.7.0(vite@6.4.3(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0)) @@ -526,6 +535,9 @@ importers: vitest: specifier: ^4.1.8 version: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@22.20.0)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1(@noble/hashes@2.3.0))(vite@6.4.3(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0)) + ws: + specifier: 8.21.0 + version: 8.21.0 native/landlock-run: devDependencies: @@ -1579,6 +1591,9 @@ importers: '@deepseek-ai/dsh-attachment': specifier: workspace:^ version: link:../../attachment/attachment + '@deepseek-ai/dsh-credentials': + specifier: workspace:^ + version: link:../../credentials/credentials '@deepseek-ai/dsh-commands': specifier: workspace:^ version: link:../../interaction/commands @@ -5692,6 +5707,12 @@ importers: '@deepseek-ai/cordis-plugin-loader': specifier: workspace:^ version: link:../../../vendor/loader + '@deepseek-ai/dsh-client-connection': + specifier: workspace:^ + version: link:../../client/connection + '@deepseek-ai/dsh-credentials-local': + specifier: workspace:^ + version: link:../../credentials/credentials-local '@deepseek-ai/dsh-host-webserver': specifier: workspace:^ version: link:../webserver