Ver Fonte

Merge pull request #814 from deepseek-harness/doc/host-client-group-readmes

fix(connection): browser-trust fence for /api + host/client group docs
imccyu há 2 meses atrás
pai
commit
19a9cfe035
43 ficheiros alterados com 753 adições e 205 exclusões
  1. 6 0
      .agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.i18n.yaml
  2. 31 0
      .agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md
  3. 31 0
      .agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.zh.md
  4. 2 2
      apps/cli/README.i18n.yaml
  5. 1 1
      apps/cli/README.md
  6. 1 1
      apps/cli/README.zh.md
  7. 53 0
      apps/cli/src/app-cli-entry.ts
  8. 5 0
      apps/cli/src/args.ts
  9. 1 1
      apps/cli/src/bin.ts
  10. 9 10
      apps/cli/src/web.ts
  11. 3 0
      apps/cli/tests/args.spec.ts
  12. 33 0
      apps/cli/tests/trusted-hosts.spec.ts
  13. 21 1
      docs/config-catalog.md
  14. 2 2
      packages/README.i18n.yaml
  15. 2 0
      packages/README.md
  16. 2 0
      packages/README.zh.md
  17. 6 0
      packages/client/README.i18n.yaml
  18. 34 0
      packages/client/README.md
  19. 34 0
      packages/client/README.zh.md
  20. 3 3
      packages/client/connection/README.i18n.yaml
  21. 4 0
      packages/client/connection/README.md
  22. 4 0
      packages/client/connection/README.zh.md
  23. 2 1
      packages/client/connection/package.json
  24. 129 0
      packages/client/connection/src/api-request-trust.ts
  25. 30 7
      packages/client/connection/src/index.ts
  26. 0 52
      packages/client/connection/src/native-dialog-request.ts
  27. 108 0
      packages/client/connection/tests/api-request-trust.spec.ts
  28. 0 57
      packages/client/connection/tests/native-dialog-request.spec.ts
  29. 102 35
      packages/client/connection/tests/node-half.spec.ts
  30. 6 0
      packages/host/README.i18n.yaml
  31. 12 0
      packages/host/README.md
  32. 12 0
      packages/host/README.zh.md
  33. 2 2
      packages/host/apiproxy/README.i18n.yaml
  34. 2 2
      packages/host/apiproxy/README.md
  35. 2 2
      packages/host/apiproxy/README.zh.md
  36. 13 2
      packages/host/apiproxy/src/fetch/handler.ts
  37. 24 6
      packages/host/apiproxy/tests/client-handler.spec.ts
  38. 12 12
      packages/host/apiproxy/tests/fetch-carrier.spec.ts
  39. 3 3
      packages/host/webserver/README.i18n.yaml
  40. 1 1
      packages/host/webserver/README.md
  41. 1 1
      packages/host/webserver/README.zh.md
  42. 3 0
      pnpm-lock.yaml
  43. 1 1
      scripts/doc-budgets.manifest.json

+ 6 - 0
.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.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-07-28-api-browser-trust-boundary.md
+2026-07-28-api-browser-trust-boundary.md: e56d0fc2a7bd551899605491f3a0522b62b961b0
+2026-07-28-api-browser-trust-boundary.zh.md: 2958f7e49bfd4a258c63fc96c2e8aee0f98183ee

+ 31 - 0
.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md

@@ -0,0 +1,31 @@
+# Agent Note: One carrier-level browser-trust boundary for the whole /api surface
+
+Status: implemented
+
+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 upcoming in-app directory browser, whose whole point is serving legitimately remote clients that a loopback rule would refuse.
+
+## Decision
+
+Enforce browser trust once, at the carrier, for the entire `/api` prefix — two halves in two stacked PRs:
+
+- **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.
+
+## 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.
+- **Auth tokens now.** Rejected for this change: token minting/storage/rotation is real product surface; the fence closes the browser-deputy holes today without pre-deciding the auth design.
+
+## 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.
+- 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.

+ 31 - 0
.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.zh.md

@@ -0,0 +1,31 @@
+# Agent Note:整个 /api 面共用一道载体级浏览器信任边界
+
+状态:已实现
+
+[English](2026-07-28-api-browser-trust-boundary.md) | 中文
+
+## 问题
+
+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 逐个设防也活不过即将到来的应用内目录浏览器:它存在的意义就是服务合法的远程客户端,回环规则恰恰会拒绝它们。
+
+## 决策
+
+在载体层对整个 `/api` 前缀一次性执行浏览器信任检查——两半各占一个栈式 PR:
+
+- **媒体类型栅栏(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` 一律拒绝。不是纯的、规范形权威的 `trustedHosts` 条目会让插件加载失败——否则 WHATWG 解析会悄悄授权笔误里的 hostname,或放大精确端口授权。`host.pickDirectory` 失去专属守卫,与其他请求同栅而行。
+
+两条边界刻意留在范围之外:可达性归 webserver 绑定配置(`host: 127.0.0.1 | 0.0.0.0`)管辖;真正远程部署的认证是延期工作,记录在 connection README——这道栅栏是混淆代理人防御,不是认证层。旧守卫的回环 socket 检查被放弃而非泛化:绑定表达可达性、`trustedHosts` 点名远程权威之后,socket 地址提供不了头部栅栏覆盖不到的任何东西。
+
+## 曾考虑的替代方案
+
+- **按 RPC 设防(延续现状)。** 否决:守卫清单永远追着方法清单跑,价值最高的方法本来就没被守住,而 browse RPC 上的回环规则会破坏它们为之存在的远程部署。
+- **CORS 头 + 省略凭据。** 否决:我们根本不想要任何跨源读取,应答预检只会扩大暴露面;拒绝预检严格更强也更简单。
+- **现在就上认证令牌。** 在本变更中否决:令牌的签发/存储/轮换是真实的产品面;栅栏今天就能封死浏览器代理人漏洞,无需预先决定认证设计。
+
+## 后果
+
+- 未来任何 `/api` 方法天然在覆盖范围内;不存在会被遗忘的按路由信任决定。
+- 非回环部署的服务权威必须获得信任,否则请求会被拒绝。dsh CLI 通过把本机 LAN IP 字面量推导进 connection 行(不带端口的条目——IP 字面量 Host 不可能是被重绑的域名,且绑定端口可能由操作系统分配)来保住它广告出的 `--host 0.0.0.0` LAN URL,并提供 `dsh web --trusted-host` 声明具名权威;CLI 不参与引导的组合自行声明 `trustedHosts`。非浏览器自动化走同一道栅栏:回环地址、推导的 LAN IP 或已声明的权威可通过;未声明的 DNS 别名会被拒绝。
+- 客户端必须给 POST 体标注 `application/json`(我们自己的客户端一向如此;裸 fetch 测试补上了该头)。
+- 无认证 `0.0.0.0` 部署的"信任网络"假设从隐含变为成文。

+ 2 - 2
apps/cli/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 apps/cli/README.md
-README.md: 13a80b1d0e0105bc0c30c019209b2e0295b7bef9
-README.zh.md: 2a5d9c15c57351ef03ebe60a5cdf90f0d0c8f18b
+README.md: 5e7326107e46d5a469f99365ea25168dc09950c3
+README.zh.md: 9dad51cf012293ba9ecba08b22e62e9249016602

+ 1 - 1
apps/cli/README.md

@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
 
 The `dsh` command-line entry follows the `apps/` assembly tier: `apps/*` are product assemblies over `packages/*` libraries. Plain `dsh` boots the interactive TUI coding agent, `dsh -p "task"` runs one headless turn, and `dsh web` serves the browser UI.
 
-Argv is parsed once through a [Commander](https://github.com/tj/commander.js) adapter ([`src/args.ts`](src/args.ts)): one program whose default (no subcommand) is the TUI/headless surface (`--config`, `-p`/`--prompt`, `--resume`) and whose `web` subcommand is the browser UI. `src/bin.ts` switches on the resolved mode and dynamic-imports only that mode's module. `dsh --help` lists every mode and `dsh web --help` renders the web usage, `dsh --version` prints this app's version, and an unknown option or a mistyped `--resume` fails loud (stderr, exit 1) instead of misrouting. `dsh web`'s `--host`/`--port` are unvalidated pass-through overrides: the `dsh-host-webserver` schema is the single source of both the default (the shipped `cordis.yml` value when a flag is absent) and validity, and rejects a bad value at boot.
+Argv is parsed once through a [Commander](https://github.com/tj/commander.js) adapter ([`src/args.ts`](src/args.ts)): one program whose default (no subcommand) is the TUI/headless surface (`--config`, `-p`/`--prompt`, `--resume`) and whose `web` subcommand is the browser UI. `src/bin.ts` switches on the resolved mode and dynamic-imports only that mode's module. `dsh --help` lists every mode and `dsh web --help` renders the web usage, `dsh --version` prints this app's version, and an unknown option or a mistyped `--resume` fails loud (stderr, exit 1) instead of misrouting. `dsh web`'s `--host`/`--port` are unvalidated pass-through overrides: the `dsh-host-webserver` schema is the single source of both the default (the shipped `cordis.yml` value when a flag is absent) and validity, and rejects a bad value at boot. `--trusted-host` appends named authorities for the /api browser-trust fence; an all-interfaces bind additionally derives the machine's LAN IP literals itself ([`src/app-cli-entry.ts`](src/app-cli-entry.ts)), so the printed LAN URL works without flags.
 
 The TUI surface:
 

+ 1 - 1
apps/cli/README.zh.md

@@ -4,7 +4,7 @@
 
 `dsh` 命令行入口遵循 `apps/` 组装层:`apps/*` 是位于 `packages/*` 库之上的产品组装。直接运行 `dsh` 会启动交互式 TUI 编码 agent(智能体),`dsh -p "task"` 运行一个无头轮次,`dsh web` 则提供浏览器 UI。
 
-Argv 只会通过 [Commander](https://github.com/tj/commander.js) 适配器([`src/args.ts`](src/args.ts))解析一次:同一个程序的默认形式(无子命令)是 TUI/无头界面(`--config`、`-p`/`--prompt`、`--resume`),`web` 子命令则是浏览器 UI。`src/bin.ts` 按解析后的 mode 分支,仅动态导入该 mode 的模块。`dsh --help` 列出所有 mode,`dsh web --help` 渲染 Web 用法,`dsh --version` 打印此应用的版本;未知选项或拼错的 `--resume` 会明确报错(stderr,退出码 1),而不会被错路由。`dsh web` 的 `--host`/`--port` 是未验证的直通覆盖:`dsh-host-webserver` schema 是默认值(标志缺失时使用已交付的 `cordis.yml` 值)和有效性的唯一真源,并在启动时拒绝错误值。
+Argv 只会通过 [Commander](https://github.com/tj/commander.js) 适配器([`src/args.ts`](src/args.ts))解析一次:同一个程序的默认形式(无子命令)是 TUI/无头界面(`--config`、`-p`/`--prompt`、`--resume`),`web` 子命令则是浏览器 UI。`src/bin.ts` 按解析后的 mode 分支,仅动态导入该 mode 的模块。`dsh --help` 列出所有 mode,`dsh web --help` 渲染 Web 用法,`dsh --version` 打印此应用的版本;未知选项或拼错的 `--resume` 会明确报错(stderr,退出码 1),而不会被错路由。`dsh web` 的 `--host`/`--port` 是未验证的直通覆盖:`dsh-host-webserver` schema 是默认值(标志缺失时使用已交付的 `cordis.yml` 值)和有效性的唯一真源,并在启动时拒绝错误值。`--trusted-host` 为 /api 浏览器信任栅栏追加具名权威;全接口绑定还会自行推导本机的 LAN IP 字面量([`src/app-cli-entry.ts`](src/app-cli-entry.ts)),因此打印出的 LAN URL 无需任何标志即可使用。
 
 TUI 界面:
 

+ 53 - 0
apps/cli/src/app-cli-entry.ts

@@ -9,6 +9,7 @@
 
 import { readFileSync } from 'node:fs'
 import { createRequire } from 'node:module'
+import { networkInterfaces } from 'node:os'
 import { join, resolve } from 'node:path'
 import { pathToFileURL } from 'node:url'
 import { Context } from 'cordis'
@@ -25,6 +26,41 @@ import type {} from '@deepseek-ai/dsh-host-webserver'
 const PROFILE_DIR = '.dsh-tmp-profile'
 const PROFILE_FILE = 'config.json'
 
+/** The webserver schema's all-interfaces bind literal: gates LAN-authority derivation here and the printed LAN URL in web.ts. */
+const ALL_INTERFACES_HOST = '0.0.0.0'
+
+/**
+ * Non-internal IPv4 interface addresses of this machine — the IP-literal
+ * authorities an all-interfaces bind is reachable by on the LAN.
+ * @returns the addresses in interface order (possibly empty).
+ */
+function lanIPv4Addresses(): string[] {
+  return Object.values(networkInterfaces()).flat()
+    .filter((iface): iface is NonNullable<typeof iface> => iface !== undefined && iface.family === 'IPv4' && !iface.internal)
+    .map(iface => iface.address)
+}
+
+/**
+ * One LAN-trust resolution for one invocation, sampled exactly once: the
+ * machine's LAN IP literals when the effective bind is all-interfaces, and
+ * the `trustedHosts` value built from them plus the explicit extras. The
+ * single sample is deliberate — display must advertise only addresses the
+ * fence was configured with, so both read this snapshot. Derived entries are
+ * port-less IP literals: DNS rebinding needs an attacker-controlled name, so
+ * an IP-literal Host is safe on any port, and the bound port may be
+ * OS-assigned, unknowable pre-boot.
+ * @param bindHost - the effective webserver bind host (CLI flag, else the yml default).
+ * @param extra - `--trusted-host` values, in argv order.
+ * @returns the sampled LAN addresses and the connection row's `trustedHosts` value (each possibly empty).
+ */
+export function resolveLanTrust(
+  bindHost: string | undefined,
+  extra: readonly string[],
+): { lanAddresses: string[]; trustedHosts: string[] } {
+  const lanAddresses = bindHost === ALL_INTERFACES_HOST ? lanIPv4Addresses() : []
+  return { lanAddresses, trustedHosts: [...lanAddresses, ...extra] }
+}
+
 /** One profile-json key mapped onto a yml row's config field. */
 interface ProfileMapping {
   jsonPath: string
@@ -79,6 +115,8 @@ export interface AppCLIEntryOptions {
   port?: number
   /** Parent directory for name-created Workspaces; undefined uses the gateway's cwd fallback. */
   workspaceRoot?: string
+  /** Extra authorities for the /api browser-trust fence (`host` or `host:port`), appended to the derived LAN IP literals. */
+  trustedHosts?: string[]
 }
 
 /**
@@ -91,6 +129,14 @@ export class AppCLIEntry {
   /** The root context, set by {@link run}. */
   ctx!: Context
 
+  /**
+   * LAN IPv4 addresses sampled once at patch composition — the exact snapshot
+   * the /api trust fence was configured with. Display reads this instead of
+   * re-sampling, so the advertised LAN URL can never name an address the
+   * fence rejects. Empty unless the effective bind is all-interfaces.
+   */
+  lanAddresses: readonly string[] = []
+
   private patches: PatchOptions[] = []
 
   constructor(private readonly options: AppCLIEntryOptions) {}
@@ -152,6 +198,13 @@ export class AppCLIEntry {
     if (this.options.port !== undefined) put('webserver', 'port', this.options.port)
     if (this.options.workspaceRoot !== undefined) put('api-gateway', 'workspaceRoot', this.options.workspaceRoot)
 
+    // Source 2b: authorities for the /api browser-trust fence (rationale on
+    // resolveLanTrust).
+    const ymlHost = (rows.get('webserver')?.config as { host?: string } | undefined)?.host
+    const { lanAddresses, trustedHosts } = resolveLanTrust(this.options.host ?? ymlHost, this.options.trustedHosts ?? [])
+    this.lanAddresses = lanAddresses
+    if (trustedHosts.length > 0) put('connection', 'trustedHosts', trustedHosts)
+
     // Source 3: the frontend dist — an assembly fact of this app, never yml
     // user config. Workspace knowledge stays here.
     put('webserver', 'distIndex', this.resolveDistIndex())

+ 5 - 0
apps/cli/src/args.ts

@@ -40,6 +40,8 @@ interface WebInvocation {
   port?: number
   dev: boolean
   workspaceRoot?: string
+  /** Extra authorities for the /api browser-trust fence (`host` or `host:port`); LAN IP literals are derived, not listed here. */
+  trustedHosts?: string[]
 }
 
 /** The resolved `dsh` invocation: exactly one mode. `--help`/`--version`/errors exit inside {@link parseDshArgs}. */
@@ -51,6 +53,7 @@ interface WebOptions {
   port?: string
   dev?: boolean
   workspaceRoot?: string
+  trustedHost?: string[]
 }
 
 /**
@@ -66,6 +69,7 @@ function resolveWeb(options: WebOptions): WebInvocation {
     ...options.port !== undefined && { port: Number(options.port) },
     dev: options.dev === true,
     ...options.workspaceRoot !== undefined && { workspaceRoot: options.workspaceRoot },
+    ...options.trustedHost !== undefined && { trustedHosts: options.trustedHost },
   }
 }
 
@@ -117,6 +121,7 @@ export function parseDshArgs(argv: readonly string[], version: string): DshInvoc
     .option('--port <port>', 'override the config listen port (0 requests an OS-assigned port)')
     .option('--dev', 'mount the client HMR driver and watch plugin bundles for rebuilds')
     .option('--workspace-root <path>', 'parent directory for name-created workspaces')
+    .option('--trusted-host <authority...>', 'extra authority the /api browser-trust fence accepts (host or host:port; repeatable)')
     .action((options: WebOptions) => {
       // Commander parses the parent (default-surface) options on either side of
       // the subcommand into `program.opts()`. `web` shares none of them, so a

+ 1 - 1
apps/cli/src/bin.ts

@@ -30,7 +30,7 @@ const invocation = parseDshArgs(process.argv.slice(2), readVersion())
 switch (invocation.mode) {
   case 'web': {
     const { runWeb } = await import('./web.ts')
-    await runWeb(invocation.host, invocation.port, invocation.dev, invocation.workspaceRoot)
+    await runWeb(invocation.host, invocation.port, invocation.dev, invocation.workspaceRoot, invocation.trustedHosts)
     break
   }
   case 'headless': {

+ 9 - 10
apps/cli/src/web.ts

@@ -6,17 +6,14 @@
  * gates them at boot.
  */
 
-import { networkInterfaces } from 'node:os'
 import { fileURLToPath } from 'node:url'
 import { AppCLIEntry } from './app-cli-entry.ts'
 
 const CONFIG_PATH = fileURLToPath(new URL('../cordis.yml', import.meta.url))
 
-// Display-only mirrors of the webserver schema's allowed hosts: the loopback
-// address the local URL always prints, and the all-interfaces value that gates
-// LAN-address discovery. Not a source of truth — the schema is.
+// Display-only mirror of the webserver schema's loopback host: the address the
+// local URL always prints. Not a source of truth — the schema is.
 const LOOPBACK_HOST = '127.0.0.1'
-const ALL_INTERFACES_HOST = '0.0.0.0'
 
 /**
  * Serve the browser UI from the shipped config tree. `host`/`port` are passed
@@ -25,12 +22,14 @@ const ALL_INTERFACES_HOST = '0.0.0.0'
  * @param port - the listen port (`0` requests an OS-assigned port), or `undefined` to keep the config default.
  * @param dev - mount the client HMR driver and watch plugin bundles for rebuilds.
  * @param workspaceRoot - parent directory for name-created workspaces, or `undefined` for the gateway's cwd fallback.
+ * @param trustedHosts - extra authorities for the /api browser-trust fence, or `undefined` for the derived LAN literals alone.
  */
 export async function runWeb(
   host: string | undefined,
   port: number | undefined,
   dev: boolean,
   workspaceRoot: string | undefined,
+  trustedHosts: string[] | undefined,
 ): Promise<void> {
   const entry = new AppCLIEntry({
     configPath: CONFIG_PATH,
@@ -38,6 +37,7 @@ export async function runWeb(
     ...host !== undefined && { host },
     ...port !== undefined && { port },
     ...workspaceRoot !== undefined && { workspaceRoot },
+    ...trustedHosts !== undefined && { trustedHosts },
   })
   const { ctx, port: boundPort } = await entry.run()
 
@@ -48,12 +48,11 @@ export async function runWeb(
     void Promise.resolve(ctx.fiber.dispose()).finally(() => { process.exit(code) })
   }
 
-  const lanCandidate = host === ALL_INTERFACES_HOST
-    ? Object.values(networkInterfaces()).flat()
-      .find(iface => iface !== undefined && iface.family === 'IPv4' && !iface.internal)
-    : undefined
+  // The entry's boot-time snapshot, not a fresh sample: the printed LAN URL
+  // must name an address the /api trust fence was configured with.
+  const lanCandidate = entry.lanAddresses[0]
   const localUrl = `http://${LOOPBACK_HOST}:${boundPort}`
-  console.log(`dsh web: ${localUrl}${lanCandidate === undefined ? '' : ` (LAN: http://${lanCandidate.address}:${boundPort})`}`)
+  console.log(`dsh web: ${localUrl}${lanCandidate === undefined ? '' : ` (LAN: http://${lanCandidate}:${boundPort})`}`)
 
   process.on('SIGTERM', () => { shutdown(0) })
   process.on('SIGINT', () => { shutdown(130) })

+ 3 - 0
apps/cli/tests/args.spec.ts

@@ -35,6 +35,9 @@ describe('parseDshArgs', () => {
     // at boot); the adapter only coerces the port string to a number.
     expect(parse(['web', '--host', '0.0.0.0', '--port', '8080', '--dev', '--workspace-root', '/w']))
       .toEqual({ mode: 'web', host: '0.0.0.0', port: 8080, dev: true, workspaceRoot: '/w' })
+    // --trusted-host is variadic and repeatable; authorities pass through unvalidated.
+    expect(parse(['web', '--trusted-host', 'harness.internal:3080', 'lab.internal', '--trusted-host', '10.0.0.9']))
+      .toEqual({ mode: 'web', dev: false, trustedHosts: ['harness.internal:3080', 'lab.internal', '10.0.0.9'] })
   })
 
   it('exits nonzero instead of silently starting fresh or dropping inputs', () => {

+ 33 - 0
apps/cli/tests/trusted-hosts.spec.ts

@@ -0,0 +1,33 @@
+/** Single-sample LAN-trust resolution for the /api browser-trust fence (`resolveLanTrust`). */
+
+import { describe, expect, it, vi } from 'vitest'
+import { resolveLanTrust } from '../src/app-cli-entry.ts'
+
+vi.mock('node:os', () => ({
+  networkInterfaces: () => ({
+    lo0: [
+      { family: 'IPv4', internal: true, address: '127.0.0.1' },
+    ],
+    en0: [
+      { family: 'IPv6', internal: false, address: 'fe80::1' },
+      { family: 'IPv4', internal: false, address: '192.168.1.5' },
+    ],
+    en1: [
+      { family: 'IPv4', internal: false, address: '10.0.0.7' },
+    ],
+    utun0: undefined,
+  }),
+}))
+
+describe('resolveLanTrust', () => {
+  it('samples non-internal IPv4 addresses once for an all-interfaces bind: trust and display share them', () => {
+    const { lanAddresses, trustedHosts } = resolveLanTrust('0.0.0.0', ['harness.internal:3080'])
+    expect(lanAddresses).toEqual(['192.168.1.5', '10.0.0.7'])
+    expect(trustedHosts).toEqual(['192.168.1.5', '10.0.0.7', 'harness.internal:3080'])
+  })
+
+  it('derives nothing for a loopback or unresolved bind — extras alone stand, no LAN URL to print', () => {
+    expect(resolveLanTrust('127.0.0.1', [])).toEqual({ lanAddresses: [], trustedHosts: [] })
+    expect(resolveLanTrust(undefined, ['lab.internal'])).toEqual({ lanAddresses: [], trustedHosts: ['lab.internal'] })
+  })
+})

+ 21 - 1
docs/config-catalog.md

@@ -270,6 +270,27 @@ Depends on: [`agentCore`](../packages/examples/agent-spine-demo/src/index.ts) ·
 
 Source: [`packages/examples/cli-demo/src/index.ts:26`](../packages/examples/cli-demo/src/index.ts)
 
+## `@deepseek-ai/dsh-client-connection`
+
+Requires: `httpServer` · `apiProxy`
+
+```ts config-catalog
+/** Plugin config: the deployment's non-loopback serving authorities. */
+export interface ConnectionConfig {
+  /**
+   * Authorities this deployment serves beyond loopback: exact `host:port`, or
+   * port-less `host` matching any port. The /api trust fence refuses any
+   * request whose Host is neither loopback nor listed here, so a
+   * non-loopback (`0.0.0.0`) deployment must declare the names it is reached
+   * by (the dsh CLI derives the machine's LAN IP literals itself). An entry
+   * that is not a bare, canonical authority fails the plugin load.
+   */
+  trustedHosts?: string[]
+}
+```
+
+Source: [`packages/client/connection/src/index.ts:20`](../packages/client/connection/src/index.ts)
+
 ## `@deepseek-ai/dsh-client-hmr`
 
 Requires: `clientModuleHost` · `httpServer`
@@ -2166,7 +2187,6 @@ Source: [`packages/context/workspace-context/src/config.ts:17`](../packages/cont
 These load from a `cordis.yml` entry with no `config:` block; they declare no config surface.
 
 - `@deepseek-ai/dsh-agent` ([`packages/core/agent/src/index.ts`](../packages/core/agent/src/index.ts))
-- `@deepseek-ai/dsh-client-connection` — requires `httpServer` · `apiProxy` ([`packages/client/connection/src/index.ts`](../packages/client/connection/src/index.ts))
 - `@deepseek-ai/dsh-client-locale` ([`packages/client/locale/src/index.ts`](../packages/client/locale/src/index.ts))
 - `@deepseek-ai/dsh-client-modules` — requires `httpServer` · `loader` ([`packages/client/modules/src/index.ts`](../packages/client/modules/src/index.ts))
 - `@deepseek-ai/dsh-client-runtime` ([`packages/client/runtime/src/index.ts`](../packages/client/runtime/src/index.ts))

+ 2 - 2
packages/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/README.md
-README.md: f5420b6f2f30837b030a0e832a438c34674a6f23
-README.zh.md: 7beeaadf380a742cbdb6447553f42692a97fad10
+README.md: 7a86e0f034264d4059e75775016d8d5d84600d8d
+README.zh.md: bfcba626bea2a70f5c2aa508bb2a5b8c09bb61dc

+ 2 - 0
packages/README.md

@@ -44,6 +44,8 @@ Packages live at `packages/<group>/<pkg>/`; groups are containers, while names r
 | [`sdk/`](sdk/README.md) | Project SDK tooling | Product — stable surface |
 | [`acp/`](acp/README.md) | Automation-only Agent Client Protocol server | Product — stable surface |
 | [`ui/`](ui/README.md) | TUI and JSON-RPC integrations, approval/interaction seams, ask-user tool | Product — stable surface |
+| [`host/`](host/README.md) | Web-GUI host half: API gateway + HTTP route server | Product — stable surface |
+| [`client/`](client/README.md) | Web-GUI browser half: shell, wire, object services, slots, `ui-*` plugins | Product — stable surface |
 | [`examples/`](examples/README.md) | Demo bundles (agent-spine + TUI/CLI/ACP/JSON-RPC bins) leaves load | Support — example infra |
 | [`support/`](support/README.md) | Support infrastructure (testkits, invariants, replay, Loader smokes) | Support — lower compatibility expectations |
 | [`util/`](util/README.md) | Low-level zero-dependency utilities shared across groups (`Branded<B>`, Harness home/path helpers, timeout, retention) | Support — small, stable, harness-dep-free |

+ 2 - 0
packages/README.zh.md

@@ -44,6 +44,8 @@
 | [`sdk/`](sdk/README.md) | 项目 SDK 工具 | 产品:稳定表面 |
 | [`acp/`](acp/README.md) | 仅面向自动化的 Agent Client Protocol 服务器 | 产品:稳定表面 |
 | [`ui/`](ui/README.md) | TUI 与 JSON-RPC 集成、批准/交互 seam、用户问答工具 | 产品:稳定表面 |
+| [`host/`](host/README.md) | web GUI 宿主半侧:API 网关 + HTTP 路由服务器 | 产品:稳定表面 |
+| [`client/`](client/README.md) | web GUI 浏览器半侧:shell、协议层、对象服务、slot、`ui-*` 插件 | 产品:稳定表面 |
 | [`examples/`](examples/README.md) | 演示组合包(agent-spine + TUI/CLI/ACP/JSON-RPC bin),由叶节点加载 | 支持:示例基础设施 |
 | [`support/`](support/README.md) | 支持基础设施(testkit、不变式、回放、Loader 冒烟测试) | 支持:兼容性预期较低 |
 | [`util/`](util/README.md) | 组间共享的低层零依赖工具(`Branded<B>`、Harness home/路径辅助函数、超时、保留策略) | 支持:小型、稳定、无 harness 依赖 |

+ 6 - 0
packages/client/README.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 packages/client/README.md
+README.md: b111d67fa49e06227e324a33bd53417ad28c3a5b
+README.zh.md: b498008eb82f6ab357718f2af761f38e51140ef8

+ 34 - 0
packages/client/README.md

@@ -0,0 +1,34 @@
+# client/ — web-GUI browser half
+
+English | [中文](README.zh.md)
+
+The browser side of the dsh web GUI: shell kernel, module system, wire consumer, React-free object services, the slot system, and the `ui-*` feature-plugin roster. Authoring rules live in [AGENTS.md](AGENTS.md); the host half is [`host/`](../host/README.md). All **product** packages, named `@deepseek-ai/dsh-client-<name>`.
+
+| Package | Role | ctx key / slot |
+|---|---|---|
+| `web/` | Shell kernel: `AppWebEntry` runs the two-stage boot over the host-pushed entry graph | (boots the tree) |
+| `modules/` | Client module system: browser peer of Node's ESM loader as a lazy CJS table under the vendored cordis Loader | (module face) |
+| `web-react/` | Shell-side React glue: `createSlotRenderer` + `SessionProvider` render seats | (renderer install) |
+| `connection/` | Wire consumer both ends: browser `ctx.connection` (shared api client + stream loop) and the node half mounting the `/api` route with its browser-trust fence | `ctx.connection` |
+| `runtime/` | Client cordis boot and React-free object services: slots, Sessions, Workspaces, per-session bindings | `ctx.slots` `ctx.sessions` `ctx.workspaces` |
+| `hmr/` | Dev-only hot reload for fetch-arrival client plugins (`--dev` graphs) | (dev entry) |
+| `locale/` | Browser locale preference (`zh`/`en`) plus the ns×locale dictionary registry | `ctx.locale` |
+| `ui-slots/` | Slot registry pure core: SlotMap merging, single `register` API, the four-share props family | (types + core) |
+| `ui-theme/` | Theme preference over the `--dsw-*` token stylesheets (`light`/`dark`/`system`) | `ctx.theme` |
+| `ui-primitives/` | Pure React atoms: icons, Button/Pill/Menu/Modal/Input, markdown family | (component library) |
+| `ui-layout/` | Shell three-column AppFrame; declares `sidebar` / `conversation` / `details` / `conversation.empty` | `ctx.layout` |
+| `ui-sidebar/` | Sidebar shell: Workspace/session rail, search, collapse; declares `sidebar.workspaces` | (slot host) |
+| `ui-workspace/` | Shared Workspace picker: browser region + hero picker over the same creation flow | (fills `sidebar.workspaces`, `conversation.hero.workspace`) |
+| `ui-conversation/` | Conversation domain: skeleton, chat view, input dock, per-tool row slots | (slot host) |
+| `ui-trajectory/` | Trajectory/Waterfall view tabs; the minimal pure-consumer plugin exemplar | (fills `conversation.view`) |
+| `ui-command/` | Command surface: session-keyed directory cache, `/` source, three-kind dispatch | `ctx.command` |
+| `ui-slash/` | Input trigger pipeline: `/` and `@` detection, grouped candidate menu, source roster | `ctx.slash` |
+| `ui-skill/` | `/`-trigger skill reference source over the `skill.list` RPC | (registers into `ctx.slash`) |
+| `ui-subagent/` | `@`-trigger subagent reference source over the sessions snapshot | (registers into `ctx.slash`) |
+| `ui-model/` | Model selection: `/model` popupSelect + the composer model seat over `ModelService` | `ctx.models` |
+| `ui-question/` | Web `ask_user_question`: host half mounts the tool, browser half fills the composer seat | (fills `conversation.composer`) |
+| `ui-settings/` | Settings shell: trigger chrome + modal panel; declares the `settings.*` slots | (slot host) |
+| `ui-settings-general/` | Settings ownerless copy: chrome content + General section skeleton | (fills `settings.*`) |
+| `ui-models/` | Models settings nav entry (content column lands in a later phase) | (fills `settings.section`) |
+
+Feature UI composes only through the slot system (`ctx.slots.register`) — the [slot system standard](../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md) is the definitive model; the [web client architecture note](../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md) owns the loading chain and object layer.

+ 34 - 0
packages/client/README.zh.md

@@ -0,0 +1,34 @@
+# client/ — web GUI 浏览器半侧
+
+[English](README.md) | 中文
+
+dsh web GUI 的浏览器侧:shell 内核、模块系统、协议消费层、无 React 依赖的对象服务、slot 系统,以及 `ui-*` 特性插件阵列。编写规则见 [AGENTS.md](AGENTS.md);宿主半侧是 [`host/`](../host/README.md)。全部为**产品**包,命名为 `@deepseek-ai/dsh-client-<name>`。
+
+| 包 | 角色 | ctx 键/slot |
+|---|---|---|
+| `web/` | shell 内核:`AppWebEntry` 基于宿主推送的条目图运行两阶段启动 | (启动整棵树) |
+| `modules/` | 客户端模块系统:Node ESM 加载器的浏览器对等物,是 vendored cordis Loader 之下的惰性 CJS 表 | (模块面) |
+| `web-react/` | shell 侧 React 胶水:`createSlotRenderer` + `SessionProvider` 渲染座位 | (渲染器安装) |
+| `connection/` | 协议两端的消费者:浏览器侧 `ctx.connection`(共享 api 客户端 + 流循环),node 半侧挂载带浏览器信任栅栏的 `/api` 路由 | `ctx.connection` |
+| `runtime/` | 客户端 cordis 启动与无 React 对象服务:slots、Session、Workspace、逐会话绑定 | `ctx.slots` `ctx.sessions` `ctx.workspaces` |
+| `hmr/` | 仅开发用的 fetch 到达型客户端插件热重载(`--dev` 图) | (开发条目) |
+| `locale/` | 浏览器语言偏好(`zh`/`en`)与 ns×locale 词典注册表 | `ctx.locale` |
+| `ui-slots/` | slot 注册表纯核心:SlotMap 合并、单一 `register` API、四份额 props 族 | (类型 + 核心) |
+| `ui-theme/` | 基于 `--dsw-*` token 样式表的主题偏好(`light`/`dark`/`system`) | `ctx.theme` |
+| `ui-primitives/` | 纯 React 原子:图标、Button/Pill/Menu/Modal/Input、markdown 族 | (组件库) |
+| `ui-layout/` | shell 三栏 AppFrame;声明 `sidebar`/`conversation`/`details`/`conversation.empty` | `ctx.layout` |
+| `ui-sidebar/` | 侧栏 shell:Workspace/会话栏、搜索、折叠;声明 `sidebar.workspaces` | (slot 宿主) |
+| `ui-workspace/` | 共享 Workspace 选择器:浏览区域 + hero 选择器共用同一创建流程 | (填充 `sidebar.workspaces`、`conversation.hero.workspace`) |
+| `ui-conversation/` | 会话域:骨架、聊天视图、输入坞、逐工具行 slot | (slot 宿主) |
+| `ui-trajectory/` | Trajectory/Waterfall 视图标签;最小纯消费者插件范例 | (填充 `conversation.view`) |
+| `ui-command/` | 命令面:按会话键控的目录缓存、`/` 源、三类分发 | `ctx.command` |
+| `ui-slash/` | 输入触发流水线:光标下的 `/` 与 `@` 检测、分组候选菜单、源名册 | `ctx.slash` |
+| `ui-skill/` | 基于 `skill.list` RPC 的 `/` 触发技能引用源 | (注册进 `ctx.slash`) |
+| `ui-subagent/` | 基于会话快照的 `@` 触发子代理引用源 | (注册进 `ctx.slash`) |
+| `ui-model/` | 模型选择:`/model` popupSelect + 输入坞模型座位,均由 `ModelService` 驱动 | `ctx.models` |
+| `ui-question/` | Web `ask_user_question`:宿主半侧挂载工具,浏览器半侧填充输入坞座位 | (填充 `conversation.composer`) |
+| `ui-settings/` | 设置 shell:触发 chrome + 模态面板;声明 `settings.*` slot | (slot 宿主) |
+| `ui-settings-general/` | 设置的无主文案:chrome 内容 + General 分区骨架 | (填充 `settings.*`) |
+| `ui-models/` | 模型设置导航项(内容列留待后续阶段) | (填充 `settings.section`) |
+
+特性 UI 只通过 slot 系统组合(`ctx.slots.register`)——[slot 系统标准](../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md)是权威模型;[web 客户端架构 Note](../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md) 拥有加载链与对象层。

+ 3 - 3
packages/client/connection/README.i18n.yaml

@@ -1,6 +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
-README.md: 80228a180faba0c556ff720e999b29b5bb1635b6
-README.zh.md: f4b857886bfafa891ceb1bd6b79b27e1fb725819
+#   pnpm run verify-translation-pairing --write packages/client/connection/README.md
+README.md: 173a9b9998e17d201b2d31d73ea74a94b319dae6
+README.zh.md: ca5da643db443956c25399f07c8b460900942ad4

+ 4 - 0
packages/client/connection/README.md

@@ -4,6 +4,10 @@ English | [中文](README.zh.md)
 
 Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. The platform subclasses (WebApiClient/FixtureApiClient), the ConnectionController loop, and the fixture data source are package-internal — apply selects and drives them; tests reach them via src. Contract: api-contracts v3 §3.
 
+## /api browser-trust fence
+
+The node half guards every request under `/api` before bridging (`src/api-request-trust.ts`). Every request — browser-marked or not — must present a `Host` that is a loopback authority or matches a `trustedHosts` entry: exact on `host:port` entries, any port on port-less entries, both sides compared through WHATWG normalization (DNS-rebinding defense). There is deliberately no shortcut for requests without browser markers: over plain HTTP a browser attaches neither `Origin` nor Fetch-Metadata to reads (EventSource, images, navigations — those headers go only to trustworthy destinations), so an unmarked request may still be a rebound browser read with a readable response, and Host is the one header rebinding cannot forge; non-browser clients pass the same fence via loopback, the CLI-derived LAN IP literals, or a declared authority. When markers are present, an attached `Origin` must equal the Host authority, and an explicit `sec-fetch-site: cross-site` marker is refused. A `trustedHosts` entry that is not a bare, canonical `host[:port]` authority — one WHATWG parsing reads back exactly as written — fails the plugin load loudly: parsing would otherwise quietly authorize the hostname inside `harness.internal/path`, or broaden a dangling-colon or zero-padded port to an any-port grant. Failures answer plain 403 before any RPC dispatch. A non-loopback (`--host 0.0.0.0`) deployment therefore needs its serving authorities trusted: the dsh CLI derives the machine's LAN IP literals itself and its `--trusted-host` flag declares named ones, so `trustedHosts` in cordis.yml is for compositions the CLI does not boot. The fence is deliberately not an authentication layer — reachability policy stays with the webserver binding, and auth remains deferred work. Decision record: [the api browser-trust boundary Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md).
+
 ## Keyless fixture
 
 Any `fixture` query parameter selects the in-memory carrier. `fixture=empty` starts with no Workspace or Session; `fixturePrompt=reject` rejects prompts before acceptance; `fixtureAttach=fail` publishes a Session but rejects its Workspace attachment; `fixtureSessionCreate=drop-response` publishes and frames a Session before dropping the create response; and `fixtureFrames=workspace-first` reverses the default session-first create-frame order. Workspace creation by name/path and caller-preallocated SessionIds remain deterministic enough for assembled Web tests to reconcile list and frame arrival.

+ 4 - 0
packages/client/connection/README.zh.md

@@ -4,6 +4,10 @@
 
 协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam,以及循环的 sink/配置类型。平台子类(WebApiClient/FixtureApiClient)、ConnectionController 循环和 fixture 数据源都属于包内部:apply 负责选择并驱动它们,测试则通过 src 访问。契约:api-contracts v3 §3。
 
+## /api 浏览器信任栅栏
+
+node 半侧在桥接前守卫 `/api` 下的每个请求(`src/api-request-trust.ts`)。每个请求——无论是否带浏览器标记——`Host` 都必须是回环地址权威,或与某个 `trustedHosts` 条目匹配:带端口的 `host:port` 条目精确匹配,不带端口的条目匹配任意端口,两侧均经 WHATWG 归一化后比较(DNS rebinding 防御)。刻意不为无浏览器标记的请求开捷径:明文 HTTP 下浏览器的读取(EventSource、图片、导航——这些头只发给可信目标)既不带 `Origin` 也不带 Fetch-Metadata,因此无标记请求仍可能是被重绑页面发起的、响应可被读走的读取,而 Host 是重绑唯一伪造不了的请求头;非浏览器客户端经由回环地址、CLI 推导的 LAN IP 字面量或已声明的权威通过同一道栅栏。当标记存在时,`Origin` 必须与 Host 权威完全一致;显式的 `sec-fetch-site: cross-site` 标记一律拒绝。不是纯的、规范形 `host[:port]` 权威的 `trustedHosts` 条目——即 WHATWG 解析读回后与原文不完全一致的——会让插件加载大声失败:否则解析会悄悄授权 `harness.internal/path` 这类笔误里的 hostname,或把悬空冒号、补零端口放大成任意端口授权。失败在任何 RPC 分发之前以纯 403 应答。因此非回环(`--host 0.0.0.0`)部署需要让自己的服务权威被信任:dsh CLI 会自行推导本机的 LAN IP 字面量,其 `--trusted-host` flag 用于声明具名权威,所以 cordis.yml 中的 `trustedHosts` 面向 CLI 不参与引导的组合。这道栅栏刻意不承担认证职责——可达性策略归 webserver 绑定配置,认证仍是延期工作。决策记录:[api 浏览器信任边界 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md)。
+
 ## 无密钥 fixture
 
 任何 `fixture` 查询参数都会选择内存载体。`fixture=empty` 启动时不含 Workspace 或 Session;`fixturePrompt=reject` 在接受前拒绝提示词;`fixtureAttach=fail` 发布 Session 但拒绝将其附加到 Workspace;`fixtureSessionCreate=drop-response` 在丢弃创建响应前发布 Session 并为其发出帧;`fixtureFrames=workspace-first` 则反转默认的 Session 优先创建帧顺序。按名称/路径创建 Workspace 以及由调用方预先分配 SessionId,均具有足够的确定性,组装后的 Web 测试可以据此协调列表与帧的到达。

+ 2 - 1
packages/client/connection/package.json

@@ -33,7 +33,8 @@
     "@deepseek-ai/dsh-commands": "workspace:^",
     "@deepseek-ai/dsh-llm": "workspace:^",
     "@deepseek-ai/dsh-session": "workspace:^",
-    "@deepseek-ai/dsh-tools": "workspace:^"
+    "@deepseek-ai/dsh-tools": "workspace:^",
+    "schemastery": "^3.18.0"
   },
   "files": [
     "lib/index.js",

+ 129 - 0
packages/client/connection/src/api-request-trust.ts

@@ -0,0 +1,129 @@
+/**
+ * Browser-trust fence for every /api request. Defends the two confused-deputy
+ * paths a browser opens against a local HTTP API — DNS rebinding (Host names
+ * the attacker's domain while the socket reaches this server) and cross-site
+ * requests fired from a malicious page. The Host fence binds every request,
+ * browser-looking or not: over plain HTTP a browser attaches neither Origin
+ * nor Fetch-Metadata to reads (EventSource, images, navigations — those
+ * headers go only to trustworthy destinations), so an unmarked request may
+ * still be a rebound browser read and Host is the one header rebinding cannot
+ * forge. Non-browser and remote clients pass the same fence via loopback, the
+ * CLI-derived LAN IP literals, or a declared `trustedHosts` authority.
+ * Network reachability and authentication stay out of scope: binding policy
+ * belongs to the webserver config, and this fence is not an auth layer.
+ */
+
+import type { IncomingHttpHeaders } from 'node:http'
+
+/** The request facts the fence reads (structural subset of IncomingMessage). */
+interface ApiTrustRequest {
+  headers: IncomingHttpHeaders
+}
+
+function header(headers: IncomingHttpHeaders, name: string): string | undefined {
+  const value = headers[name]
+  return typeof value === 'string' ? value : undefined
+}
+
+function isLoopbackHostname(hostname: string): boolean {
+  if (hostname === 'localhost' || hostname === '[::1]') return true
+  const parts = hostname.split('.')
+  return parts.length === 4
+    && parts[0] === '127'
+    && parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255)
+}
+
+/** Normalized URL of a Host-header authority (hostname lowercased, default port stripped, IPv6 bracketed), or undefined when unparsable. */
+function parseAuthority(authority: string): URL | undefined {
+  try {
+    // http: is a WHATWG "special scheme": parsing yields a non-empty hostname or throws.
+    return new URL(`http://${authority}`)
+  } catch {
+    return undefined
+  }
+}
+
+/**
+ * Assert one configured `trustedHosts` entry is a bare authority (`host` or
+ * `host:port`) in canonical form: it must survive WHATWG parsing unchanged
+ * (case aside). Anything parsing would silently rewrite is refused as a typo
+ * that must fail the load loudly instead of being ignored until requests 403
+ * or quietly changing the grant: URL parts beyond the authority
+ * (`harness.internal/path`, `user@harness.internal` — which would authorize
+ * the embedded hostname), stripped whitespace, a dangling colon or
+ * zero-padded port (which would broaden an intended exact-port grant to every
+ * port), and non-canonical host spellings (`0x7f.0.0.1`, percent-encoding,
+ * unbracketed IPv6; IDN hosts are declared in punycode, the form the wire
+ * carries).
+ * @param entry - the configured value, verbatim.
+ */
+export function assertTrustedAuthority(entry: string): void {
+  const entryUrl = parseAuthority(entry)
+  if (entryUrl !== undefined && canonicalAuthority(entry, entryUrl) === entry.toLowerCase()) return
+  throw new Error(`client-connection: trustedHosts entry ${JSON.stringify(entry)} is not a bare host[:port] authority`)
+}
+
+/**
+ * Canonical form of a parsed authority: `hostname` when no port was written,
+ * else `hostname:port`. The port is judged from URL parses under both special
+ * schemes (their default ports differ, so `:80` and `:443` still count as
+ * explicit), never from the raw string, where WHATWG trimming would misread
+ * shapes like `host:port ` as port-less.
+ */
+function canonicalAuthority(entry: string, entryUrl: URL): string {
+  // An authority that parsed under http cannot fail under https.
+  const port = entryUrl.port !== '' ? entryUrl.port : new URL(`https://${entry}`).port
+  return port === '' ? entryUrl.hostname : `${entryUrl.hostname}:${port}`
+}
+
+/**
+ * Whether the request authority matches a `trustedHosts` entry. An entry with
+ * an explicit port matches that exact authority; a port-less entry matches the
+ * hostname on any port (the shape the CLI derives for IP-literal LAN serving,
+ * where the bound port may be OS-assigned). Both sides compare through WHATWG
+ * normalization, so case and a redundant `:80` never decide trust.
+ */
+function isTrustedAuthority(hostUrl: URL, trustedHosts: readonly string[]): boolean {
+  return trustedHosts.some((entry) => {
+    const entryUrl = parseAuthority(entry)
+    if (entryUrl === undefined) return false
+    return canonicalAuthority(entry, entryUrl) === entryUrl.hostname
+      ? entryUrl.hostname === hostUrl.hostname
+      : entryUrl.host === hostUrl.host
+  })
+}
+
+/**
+ * Decide whether one /api request may reach the RPC bridge.
+ * @param request - node HTTP request facts (headers).
+ * @param trustedHosts - non-loopback authorities this deployment serves: exact `host:port`, or port-less `host` matching any port.
+ * @returns true when the Host is ours (loopback or trusted) and any attached browser markers are same-origin.
+ */
+export function isTrustedApiRequest(request: ApiTrustRequest, trustedHosts: readonly string[]): boolean {
+  // Host fence (DNS-rebinding defense), applied to every request: the browser
+  // fills Host from the URL it believes it is talking to, so a rebound page
+  // carries the attacker's domain here even though the socket lands on this
+  // server. There is no marker shortcut — a browser read over plain HTTP
+  // (EventSource, images, navigations) arrives with neither Origin nor
+  // Fetch-Metadata, indistinguishable from curl, and its response is readable
+  // by the rebound page.
+  const host = header(request.headers, 'host')
+  if (host === undefined) return false
+  const hostUrl = parseAuthority(host)
+  if (hostUrl === undefined) return false
+  if (!isLoopbackHostname(hostUrl.hostname) && !isTrustedAuthority(hostUrl, trustedHosts)) return false
+  // Cross-site fence: modern browsers label the initiator relationship on
+  // every fetch; an explicit cross-site marker is refused regardless of Origin.
+  if (header(request.headers, 'sec-fetch-site') === 'cross-site') return false
+  // Origin fence: when a browser attaches an Origin it must be exactly this
+  // authority (compared through the same normalization as the Host). Absent
+  // Origin is fine — the Host fence above already bound the request. The
+  // literal "null" (sandboxed iframes, file: pages) is an opaque origin, refused.
+  const origin = header(request.headers, 'origin')
+  if (origin === undefined) return true
+  try {
+    return new URL(origin).host === hostUrl.host
+  } catch {
+    return false
+  }
+}

+ 30 - 7
packages/client/connection/src/index.ts

@@ -1,11 +1,12 @@
 /** Host HTTP bridge for browser-client RPC. */
 import type { Context } from 'cordis'
+import z from 'schemastery'
 // Activates the httpServer 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 } from './http-bridge.ts'
-import { isTrustedNativeDialogRequest } from './native-dialog-request.ts'
+import { assertTrustedAuthority, isTrustedApiRequest } from './api-request-trust.ts'
 
 export { API_PATH } from './api-path.ts'
 
@@ -15,20 +16,42 @@ export const name = 'client-connection'
 /** Services required before mounting the route. */
 export const inject = ['httpServer', 'apiProxy']
 
+/** Plugin config: the deployment's non-loopback serving authorities. */
+export interface ConnectionConfig {
+  /**
+   * Authorities this deployment serves beyond loopback: exact `host:port`, or
+   * port-less `host` matching any port. The /api trust fence refuses any
+   * request whose Host is neither loopback nor listed here, so a
+   * non-loopback (`0.0.0.0`) deployment must declare the names it is reached
+   * by (the dsh CLI derives the machine's LAN IP literals itself). An entry
+   * that is not a bare, canonical authority fails the plugin load.
+   */
+  trustedHosts?: string[]
+}
+
+export const Config: z<ConnectionConfig> = z.object({
+  trustedHosts: z.array(String).default([]),
+})
+
 /**
- * Mounts the API gateway under the browser transport prefix.
+ * 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)).
  * @param ctx - Host plugin context.
+ * @param config - resolved plugin config (schema defaults applied).
  */
-export function apply(ctx: Context): void {
+export function apply(ctx: Context, config?: ConnectionConfig): void {
+  // The Loader resolves schema defaults; hand-built test contexts may pass none.
+  const trustedHosts = config?.trustedHosts ?? []
+  // Config boundary: a malformed entry fails the load loudly here rather than
+  // silently authorizing its hostname prefix at request time.
+  for (const entry of trustedHosts) assertTrustedAuthority(entry)
   const apiHandler = toFetchHandler(ctx.apiProxy)
   const route: WebRoute = {
     kind: 'prefix',
     path: API_PATH,
     handler: async (req, res) => {
-      const pathname = new URL(req.url ?? '/', 'http://dsh.internal').pathname
-      if ((pathname === `${API_PATH}/host.pickDirectory`
-        || pathname === `${API_PATH}/host.openPath`)
-        && !isTrustedNativeDialogRequest(req)) {
+      if (!isTrustedApiRequest(req, trustedHosts)) {
         res.writeHead(403)
         res.end('forbidden')
         return

+ 0 - 52
packages/client/connection/src/native-dialog-request.ts

@@ -1,52 +0,0 @@
-/** Trust check for browser requests that can invoke privileged native host actions. */
-
-import type { IncomingHttpHeaders } from 'node:http'
-
-interface NativeDialogRequest {
-  headers: IncomingHttpHeaders
-  socket: { remoteAddress?: string | undefined }
-}
-
-function header(headers: IncomingHttpHeaders, name: string): string | undefined {
-  const value = headers[name]
-  return typeof value === 'string' ? value : undefined
-}
-
-function isLoopback(address: string | undefined): boolean {
-  if (address === undefined) return false
-  if (address === '::1') return true
-  const ipv4 = address.startsWith('::ffff:') ? address.slice('::ffff:'.length) : address
-  const first = ipv4.split('.')[0]
-  return first === '127'
-}
-
-function isLoopbackHostname(hostname: string): boolean {
-  if (hostname === 'localhost' || hostname === '[::1]' || hostname === '::1') return true
-  const parts = hostname.split('.')
-  return parts.length === 4
-    && parts[0] === '127'
-    && parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255)
-}
-
-/**
- * Require a local socket plus browser-controlled same-origin metadata.
- * @param request - the node HTTP request facts used by the carrier guard.
- * @returns true only for a same-origin browser request whose peer and URL are loopback.
- */
-export function isTrustedNativeDialogRequest(request: NativeDialogRequest): boolean {
-  if (!isLoopback(request.socket.remoteAddress)) return false
-  if (header(request.headers, 'sec-fetch-site') !== 'same-origin') return false
-  const origin = header(request.headers, 'origin')
-  const host = header(request.headers, 'host')
-  if (origin === undefined || host === undefined) return false
-  try {
-    const parsed = new URL(origin)
-    const hostUrl = new URL(`http://${host}`)
-    return (parsed.protocol === 'http:' || parsed.protocol === 'https:')
-      && parsed.host === host
-      && isLoopbackHostname(parsed.hostname)
-      && isLoopbackHostname(hostUrl.hostname)
-  } catch {
-    return false
-  }
-}

+ 108 - 0
packages/client/connection/tests/api-request-trust.spec.ts

@@ -0,0 +1,108 @@
+/** Behavior of the /api browser-trust fence (rebinding + cross-site defense). */
+
+import { describe, expect, it } from 'vitest'
+import { assertTrustedAuthority, isTrustedApiRequest } from '../src/api-request-trust.ts'
+
+function request(headers: Record<string, string | undefined>): { headers: Record<string, string | undefined> } {
+  return { headers }
+}
+
+describe('isTrustedApiRequest', () => {
+  it('holds markerless requests to the same Host fence — a plain-HTTP browser read carries no markers', () => {
+    // Over plain HTTP a browser attaches neither Origin nor Fetch-Metadata to
+    // reads (EventSource, images, navigations), so a rebound-origin GET is
+    // markerless and its response readable: no marker shortcut may exist.
+    expect(isTrustedApiRequest(request({ host: '127.0.0.1:3080' }), [])).toBe(true)
+    expect(isTrustedApiRequest(request({ host: '192.168.1.5:3080' }), ['192.168.1.5'])).toBe(true)
+    expect(isTrustedApiRequest(request({ host: '192.168.1.5:3080' }), [])).toBe(false)
+    expect(isTrustedApiRequest(request({ host: 'harness.example' }), [])).toBe(false)
+    expect(isTrustedApiRequest(request({}), [])).toBe(false)
+  })
+
+  it('accepts loopback Hosts in every spelling, with and without ports, for browser requests', () => {
+    for (const host of ['localhost', 'localhost:3080', '127.0.0.1', '127.0.0.1:3080', '127.8.9.10:80', '[::1]', '[::1]:3080', 'LOCALHOST:3080']) {
+      expect(isTrustedApiRequest(request({ host, origin: `http://${host}` }), [])).toBe(true)
+    }
+  })
+
+  it('refuses a rebound Host: the attacker domain names the socket it did not expect', () => {
+    expect(isTrustedApiRequest(request({
+      host: 'evil.example:3080',
+      origin: 'http://evil.example:3080',
+      'sec-fetch-site': 'same-origin',
+    }), [])).toBe(false)
+  })
+
+  it('accepts a declared public authority: exact on host:port entries, any port on port-less entries', () => {
+    const headers = { host: 'harness.internal:3080', origin: 'http://harness.internal:3080' }
+    expect(isTrustedApiRequest(request(headers), ['harness.internal:3080'])).toBe(true)
+    expect(isTrustedApiRequest(request(headers), ['harness.internal'])).toBe(true)
+    expect(isTrustedApiRequest(request(headers), ['harness.internal:9999'])).toBe(false)
+    expect(isTrustedApiRequest(request(headers), [])).toBe(false)
+  })
+
+  it('matches Host, Origin, and trusted entries through WHATWG normalization (case, default port)', () => {
+    expect(isTrustedApiRequest(request({ host: 'Harness.INTERNAL:3080', origin: 'http://harness.internal:3080' }), ['harness.internal:3080'])).toBe(true)
+    expect(isTrustedApiRequest(request({ host: 'harness.internal', origin: 'http://harness.internal' }), ['HARNESS.internal:80'])).toBe(true)
+    // An unparsable entry never matches; it must not poison the rest of the list.
+    expect(isTrustedApiRequest(request({ host: 'harness.internal', origin: 'http://harness.internal' }), ['bad entry', 'harness.internal'])).toBe(true)
+    expect(isTrustedApiRequest(request({ host: 'harness.internal', origin: 'http://harness.internal' }), ['bad entry'])).toBe(false)
+  })
+
+  it('refuses cross-origin browser markers even on a loopback Host', () => {
+    // Origin present and different → cross-site request that survived preflight rules.
+    expect(isTrustedApiRequest(request({ host: '127.0.0.1:3080', origin: 'http://evil.example' }), [])).toBe(false)
+    // Explicit cross-site label → refused regardless of Origin.
+    expect(isTrustedApiRequest(request({ host: '127.0.0.1:3080', 'sec-fetch-site': 'cross-site' }), [])).toBe(false)
+    // Opaque origin (sandboxed iframe, file: page) parses to no authority.
+    expect(isTrustedApiRequest(request({ host: '127.0.0.1:3080', origin: 'null' }), [])).toBe(false)
+  })
+
+  it('accepts a same-origin browser request, with or without an Origin header', () => {
+    expect(isTrustedApiRequest(request({
+      host: 'localhost:3080',
+      origin: 'http://localhost:3080',
+      'sec-fetch-site': 'same-origin',
+    }), [])).toBe(true)
+    // Origin-less browser shapes (same-origin GETs) still carry sec-fetch-site.
+    expect(isTrustedApiRequest(request({ host: 'localhost:3080', 'sec-fetch-site': 'same-origin' }), [])).toBe(true)
+  })
+
+  it('assertTrustedAuthority accepts bare authorities and throws on anything more', () => {
+    for (const entry of ['harness.internal', 'harness.internal:3080', 'HARNESS.internal:80', '10.0.0.9', '[::1]:3080']) {
+      expect(() => { assertTrustedAuthority(entry) }).not.toThrow()
+    }
+    // WHATWG parsing would quietly read a hostname out of each of these; the
+    // config boundary must refuse them instead of authorizing the prefix.
+    for (const entry of ['harness.internal/path', 'harness.internal/', 'user@harness.internal', 'harness.internal?x', 'harness.internal#f', 'harness.internal\\path', 'bad entry', '']) {
+      expect(() => { assertTrustedAuthority(entry) }).toThrow(/not a bare host\[:port\] authority/)
+    }
+    // WHATWG trimming would silently strip these; the entry must fail instead.
+    for (const entry of ['harness.internal:3080 ', ' harness.internal', 'harness.internal:30\t80']) {
+      expect(() => { assertTrustedAuthority(entry) }).toThrow(/not a bare host\[:port\] authority/)
+    }
+    // WHATWG parsing would silently rewrite these — a dangling colon or
+    // zero-padded port would broaden an intended exact-port grant to every
+    // port, and non-canonical host spellings would not read back as written.
+    for (const entry of ['harness.internal:', '[::1]:', 'harness.internal:0080', '0x7f.0.0.1', '[0:0:0:0:0:0:0:1]']) {
+      expect(() => { assertTrustedAuthority(entry) }).toThrow(/not a bare host\[:port\] authority/)
+    }
+  })
+
+  it('never lets stray whitespace broaden an exact-port entry to every port', () => {
+    // Defense in depth below the load-time assert: the explicit-port judgment
+    // reads the parsed URL, so a trimmed `host:port ` entry stays exact.
+    const trusted = ['harness.internal:3080 ']
+    expect(isTrustedApiRequest(request({ host: 'harness.internal:9999', origin: 'http://harness.internal:9999' }), trusted)).toBe(false)
+    expect(isTrustedApiRequest(request({ host: 'harness.internal:3080', origin: 'http://harness.internal:3080' }), trusted)).toBe(true)
+  })
+
+  it('refuses malformed or untrusted authorities on browser requests', () => {
+    const markers = { 'sec-fetch-site': 'same-origin' }
+    expect(isTrustedApiRequest(request({ ...markers }), [])).toBe(false)
+    expect(isTrustedApiRequest(request({ ...markers, host: '' }), [])).toBe(false)
+    expect(isTrustedApiRequest(request({ ...markers, host: 'bad host' }), [])).toBe(false)
+    expect(isTrustedApiRequest(request({ ...markers, host: '127.0.0.999' }), [])).toBe(false)
+    expect(isTrustedApiRequest(request({ ...markers, host: '128.0.0.1' }), [])).toBe(false)
+  })
+})

+ 0 - 57
packages/client/connection/tests/native-dialog-request.spec.ts

@@ -1,57 +0,0 @@
-import type { IncomingHttpHeaders } from 'node:http'
-import { describe, expect, it } from 'vitest'
-import { isTrustedNativeDialogRequest } from '../src/native-dialog-request.ts'
-
-function request(
-  remoteAddress: string | undefined,
-  headers: IncomingHttpHeaders = {
-    host: '127.0.0.1:3080',
-    origin: 'http://127.0.0.1:3080',
-    'sec-fetch-site': 'same-origin',
-  },
-) {
-  return { socket: { remoteAddress }, headers }
-}
-
-describe('native dialog request trust', () => {
-  it('accepts loopback same-origin browser requests', () => {
-    expect(isTrustedNativeDialogRequest(request('127.0.0.1'))).toBe(true)
-    expect(isTrustedNativeDialogRequest(request('::1', {
-      host: '[::1]:3080', origin: 'http://[::1]:3080', 'sec-fetch-site': 'same-origin',
-    }))).toBe(true)
-    expect(isTrustedNativeDialogRequest(request('::ffff:127.0.0.1'))).toBe(true)
-    expect(isTrustedNativeDialogRequest(request('127.0.0.1', {
-      host: 'localhost:3080', origin: 'http://localhost:3080', 'sec-fetch-site': 'same-origin',
-    }))).toBe(true)
-    expect(isTrustedNativeDialogRequest(request('127.0.0.2', {
-      host: '127.0.0.2:3080', origin: 'https://127.0.0.2:3080', 'sec-fetch-site': 'same-origin',
-    }))).toBe(true)
-  })
-
-  it('rejects remote sockets and requests without matching browser metadata', () => {
-    expect(isTrustedNativeDialogRequest(request('192.168.1.5'))).toBe(false)
-    expect(isTrustedNativeDialogRequest(request(undefined))).toBe(false)
-    expect(isTrustedNativeDialogRequest(request('127.0.0.1', {
-      host: '127.0.0.1:3080', origin: 'http://evil.example', 'sec-fetch-site': 'cross-site',
-    }))).toBe(false)
-    expect(isTrustedNativeDialogRequest(request('127.0.0.1', {
-      host: '127.0.0.1:3080', origin: 'http://localhost:3080', 'sec-fetch-site': 'same-origin',
-    }))).toBe(false)
-    expect(isTrustedNativeDialogRequest(request('127.0.0.1', { host: '127.0.0.1:3080' }))).toBe(false)
-    expect(isTrustedNativeDialogRequest(request('127.0.0.1', {
-      origin: 'http://127.0.0.1:3080', 'sec-fetch-site': 'same-origin',
-    }))).toBe(false)
-    expect(isTrustedNativeDialogRequest(request('127.0.0.1', {
-      host: 'attacker.example:3080', origin: 'http://attacker.example:3080', 'sec-fetch-site': 'same-origin',
-    }))).toBe(false)
-    expect(isTrustedNativeDialogRequest(request('127.0.0.1', {
-      host: '127.0.0.1:3080', origin: 'ftp://127.0.0.1:3080', 'sec-fetch-site': 'same-origin',
-    }))).toBe(false)
-    expect(isTrustedNativeDialogRequest(request('127.0.0.1', {
-      host: '127.999.0.1:3080', origin: 'http://127.999.0.1:3080', 'sec-fetch-site': 'same-origin',
-    }))).toBe(false)
-    expect(isTrustedNativeDialogRequest(request('127.0.0.1', {
-      host: '[invalid', origin: 'http://[invalid', 'sec-fetch-site': 'same-origin',
-    }))).toBe(false)
-  })
-})

+ 102 - 35
packages/client/connection/tests/node-half.spec.ts

@@ -1,4 +1,6 @@
 /** Node half: registers the /api prefix route bridging to the api gateway. */
+import { EventEmitter } from 'node:events'
+import { Readable } from 'node:stream'
 import { Context } from 'cordis'
 import { describe, expect, it } from 'vitest'
 import type { IncomingMessage, ServerResponse } from 'node:http'
@@ -6,48 +8,113 @@ import type { ApiProxy } from '@deepseek-ai/dsh-host-apiproxy/api'
 import type { HttpServerService, WebRoute } from '@deepseek-ai/dsh-host-webserver'
 import { API_PATH, apply, inject } from '../src/index.ts'
 
+/** Structural httpServer fake: the plugin only touches register(). */
+function fakeHttpServer(routes: WebRoute[]): Pick<HttpServerService, 'register' | 'tapIndex' | 'port'> {
+  return {
+    register(route) {
+      routes.push(route)
+      return () => { routes.splice(routes.indexOf(route), 1) }
+    },
+    tapIndex: () => () => {},
+    port: 0,
+  }
+}
+
+/** Bodyless GET carrying the given headers (enough for the trust fence + bridge). */
+function fakeRequest(headers: Record<string, string>): IncomingMessage {
+  const request = Readable.from([]) as unknown as IncomingMessage
+  Object.assign(request, { url: `${API_PATH}/session.list`, method: 'GET', headers })
+  return request
+}
+
+/** Response recorder compatible with both the fence's short-circuit and the bridge. */
+function fakeResponse(): { response: ServerResponse; state: { status?: number; body?: unknown } } {
+  const state: { status?: number; body?: unknown } = {}
+  const response = Object.assign(new EventEmitter(), {
+    writableEnded: false,
+    writeHead(value: number) { state.status = value; return this },
+    write() { return true },
+    end(this: { writableEnded: boolean }, value?: unknown) {
+      if (value !== undefined) state.body = value
+      this.writableEnded = true
+      return this
+    },
+  }) as unknown as ServerResponse
+  return { response, state }
+}
+
+async function mounted(config?: { trustedHosts?: string[] }): Promise<{ routes: WebRoute[]; dispose: () => Promise<void> }> {
+  const ctx = new Context()
+  const routes: WebRoute[] = []
+  ctx.provide('httpServer', fakeHttpServer(routes) as HttpServerService)
+  ctx.provide('apiProxy', {} as unknown as ApiProxy)
+  const fiber = ctx.plugin({ inject: [...inject], apply }, config)
+  await fiber.await()
+  return { routes, dispose: () => fiber.dispose() }
+}
+
 describe('connection node half', () => {
-  it('registers the /api prefix route and removes it with the fiber', async () => {
-    const ctx = new Context()
+  it('fails the load on a trustedHosts entry that is not a bare authority', async () => {
     const routes: WebRoute[] = []
-    // Structural fake: the plugin only touches register(); the service class
-    // carries private state a literal cannot (and need not) reproduce.
-    const httpServer: Pick<HttpServerService, 'register' | 'tapIndex' | 'port'> = {
-      register(route) {
-        routes.push(route)
-        return () => { routes.splice(routes.indexOf(route), 1) }
-      },
-      tapIndex: () => () => {},
-      port: 0,
-    }
-    ctx.provide('httpServer', httpServer as HttpServerService)
+    const ctx = new Context()
+    ctx.provide('httpServer', fakeHttpServer(routes) as HttpServerService)
     ctx.provide('apiProxy', {} as unknown as ApiProxy)
+    // The apply throw also escapes cordis as a late rejection — the shape the
+    // boot's installFailLoud is contracted to catch. Capture it so the run
+    // stays clean, same pattern as the webserver bind-failure test.
+    const rejections: unknown[] = []
+    const onUnhandled = (err: unknown): void => { rejections.push(err) }
+    process.on('unhandledRejection', onUnhandled)
+    try {
+      const fiber = ctx.plugin({ inject: [...inject], apply }, { trustedHosts: ['harness.internal/path'] })
+      await expect(fiber.await()).rejects.toThrow(/not a bare host\[:port\] authority/)
+      expect(routes).toHaveLength(0)
+      for (let i = 0; i < 100 && rejections.length === 0; i++) {
+        await new Promise(resolve => setTimeout(resolve, 10))
+      }
+      expect(rejections.map(String).join('\n')).toContain('not a bare host[:port] authority')
+    } finally {
+      process.off('unhandledRejection', onUnhandled)
+    }
+  })
 
-    const fiber = ctx.plugin({ inject: [...inject], apply })
-    await fiber.await()
+  it('registers the /api prefix route and removes it with the fiber', async () => {
+    const { routes, dispose } = await mounted()
     expect(routes).toHaveLength(1)
     expect(routes[0]).toMatchObject({ kind: 'prefix', path: API_PATH })
+    await dispose()
+    expect(routes).toHaveLength(0)
+  })
 
-    for (const url of ['/api/host.pickDirectory', '/api/host.openPath']) {
-      let status: number | undefined
-      let body: unknown
-      const deniedRequest = {
-        url,
-        headers: {
-          host: 'harness.example', origin: 'http://harness.example', 'sec-fetch-site': 'same-origin',
-        },
-        socket: { remoteAddress: '192.168.1.8' },
-      } as unknown as IncomingMessage
-      const deniedResponse = {
-        writeHead(value: number) { status = value; return this },
-        end(value?: unknown) { body = value; return this },
-      } as unknown as ServerResponse
-      await routes[0]!.handler(deniedRequest, deniedResponse)
-      expect(status).toBe(403)
-      expect(body).toBe('forbidden')
-    }
+  it('refuses an untrusted Host on any /api path before the bridge runs', async () => {
+    const { routes, dispose } = await mounted()
+    const { response, state } = fakeResponse()
+    await routes[0]!.handler(fakeRequest({
+      host: 'harness.example', origin: 'http://harness.example', 'sec-fetch-site': 'same-origin',
+    }), response)
+    expect(state.status).toBe(403)
+    expect(state.body).toBe('forbidden')
+    await dispose()
+  })
 
-    await fiber.dispose()
-    expect(routes).toHaveLength(0)
+  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'] })
+    // 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)
+    expect(loopback.state.status).toBe(404)
+    // LAN authority declared as a port-less IP literal — the shape the CLI
+    // derives for `--host 0.0.0.0` — passes markerless curl on any port.
+    const lan = fakeResponse()
+    await routes[0]!.handler(fakeRequest({ host: '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',
+    }), declared.response)
+    expect(declared.state.status).toBe(404)
+    await dispose()
   })
 })

+ 6 - 0
packages/host/README.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 packages/host/README.md
+README.md: 61e50cb64b95932085342b01a22d029cf8d5a228
+README.zh.md: 3109eccf89ee4c2d4d01be546e3ee9ead9084edc

+ 12 - 0
packages/host/README.md

@@ -0,0 +1,12 @@
+# host/ — web-GUI host half
+
+English | [中文](README.zh.md)
+
+The host side of the dsh web GUI: the API gateway every client shape shares, and the plain HTTP server it rides on. The browser side lives in [`client/`](../client/README.md); the composed application is [`apps/cli`](../../apps/cli/cordis.yml) serving [`apps/web`](../../apps/web/). All **product** packages.
+
+| Package | Role | ctx key |
+|---|---|---|
+| `apiproxy/` | The shared API gateway: the zero-Node TS wire contract (`src/api/`), the fetch carrier pair (`toFetchHandler` host-side, `AbstractApiClient` client-side), and the host implementation over `ctx.agents`/`ctx.workspace` | `ctx.apiProxy` |
+| `webserver/` | Plain HTTP route-registration carrier: `node:http` server listening on activation; routes register as named `exact`/`prefix` handlers | `ctx.httpServer` |
+
+`apiproxy` is transport-agnostic by design — it registers no routes; carriers wrap `ctx.apiProxy` themselves. The HTTP carrier route (with its `/api` browser-trust fence) is mounted by [`client/connection`](../client/connection/README.md)'s node half, which is why that package lives in the client group: it owns both ends of the wire.

+ 12 - 0
packages/host/README.zh.md

@@ -0,0 +1,12 @@
+# host/ — web GUI 宿主半侧
+
+[English](README.md) | 中文
+
+dsh web GUI 的宿主侧:所有客户端形态共用的 API 网关,以及承载它的纯 HTTP 服务器。浏览器侧位于 [`client/`](../client/README.md);组合后的应用是 [`apps/cli`](../../apps/cli/cordis.yml),它负责服务 [`apps/web`](../../apps/web/)。全部为**产品**包。
+
+| 包 | 角色 | ctx 键 |
+|---|---|---|
+| `apiproxy/` | 共享 API 网关:零 Node 依赖的 TS 协议契约(`src/api/`)、fetch 载体对(宿主侧 `toFetchHandler`、客户端侧 `AbstractApiClient`),以及基于 `ctx.agents`/`ctx.workspace` 的宿主实现 | `ctx.apiProxy` |
+| `webserver/` | 纯 HTTP 路由注册载体:激活即监听的 `node:http` 服务器;路由以命名的 `exact`/`prefix` 处理器注册 | `ctx.httpServer` |
+
+`apiproxy` 在设计上与传输方式无关——它不注册任何路由;载体自行包装 `ctx.apiProxy`。HTTP 载体路由(连同其 `/api` 浏览器信任栅栏)由 [`client/connection`](../client/connection/README.md) 的 node 半侧挂载,这正是该包住在 client 组的原因:它拥有这条线的两端。

+ 2 - 2
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: c20887b73b9b9deb278db30d34d84df07257d664
-README.zh.md: 18a2f97477e5f57127371429b0dd59ba01f04341
+README.md: 3639722ab25826af8f7a0721f22d244f78b4210b
+README.zh.md: 3ac81fb412d4e4caf192953bc7a7c846a1d29965

+ 2 - 2
packages/host/apiproxy/README.md

@@ -6,7 +6,7 @@ The API gateway every client shape shares: the TS contract (`src/api/`, zero Nod
 
 ## Contract layer (`/api`)
 
-Wire messages form a four-quadrant discriminated union — who initiates × request/response — decoupled from the physical channel: `ClientRequest` (POST `/api/<method>` body), `ServerResponse` (that POST's response body), `ServerRequest` (SSE frame), `ClientResponse` (POST `/api/respond` body). Responses always echo the matching request's `rpcId` and never mint a new one. Method parameter/return structures live only in the domain interface signatures (`SessionsApi`, `HostApi`, `EventsApi`); `RpcMethodMap` registers the methods and every other position derives via `RequestPayload<K>`/`ResponseValue<K>`. Zod schemas anchor `satisfies z.ZodType<Wire<T>>` and parse at two levels: envelope first, business payload second, dispatched per method. Business errors ride `RpcResult`'s error branch (`RpcErrorDetailsMap` closes the code set); HTTP status expresses only the carrier.
+Wire messages form a four-quadrant discriminated union — who initiates × request/response — decoupled from the physical channel: `ClientRequest` (POST `/api/<method>` body), `ServerResponse` (that POST's response body), `ServerRequest` (SSE frame), `ClientResponse` (POST `/api/respond` body). Responses always echo the matching request's `rpcId` and never mint a new one. Method parameter/return structures live only in the domain interface signatures (`SessionsApi`, `HostApi`, `EventsApi`); `RpcMethodMap` registers the methods and every other position derives via `RequestPayload<K>`/`ResponseValue<K>`. Zod schemas anchor `satisfies z.ZodType<Wire<T>>` and parse at two levels: envelope first, business payload second, dispatched per method. Business errors ride `RpcResult`'s error branch (`RpcErrorDetailsMap` closes the code set); HTTP status expresses only the carrier. Every `/api` POST must declare the `application/json` media type — anything else is refused with 415 before dispatch, so cross-site "simple" requests (which browsers send without a CORS preflight) can never execute a side-effectful method blind.
 
 The layering/protocol decisions are recorded in the [GUI layering and RPC protocol RFC](../../../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md); the browser-side consumption architecture in the [web client architecture RFC](../../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md).
 
@@ -18,7 +18,7 @@ Session model routing is a session-domain contract. `session.models` returns the
 
 Workspace and Session lists are separate reconnect baselines. `workspace.create` creates a unique name or adopts an existing directory, `workspace.delete` removes only the Workspace registration, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed`, `host/workspace-removed`, plus `host/session-added` carry committed increments in either arrival order. Registration deletion preserves the directory and session logs; its Sessions remain in `session.list` and become Ungrouped. `SessionSummary.blank` and the `host/session-added` frame carry the derived zero-events bit: clients hide blank sessions and reuse them per workspace, flip blank on the first `host/session-status(running:true)`, and treat `session.list` as the reconnect authority; cold summaries are never blank because lazy persistence keeps never-appended sessions out of `list()`.
 
-`host.pickDirectory` opens one native directory picker and returns its selected path, or `null` when the user cancels. Its host implementation invokes platform tools without a shell: `osascript` on macOS, an STA PowerShell `FolderBrowserDialog` on Windows, and Zenity with a KDialog fallback on Linux. The picker function is injectable for tests. This user-paced method is the sole unary call exempt from the default 30-second timeout; caller and connection aborts still propagate to the native process. The browser carrier separately restricts this privileged method to loopback, same-origin requests.
+`host.pickDirectory` opens one native directory picker and returns its selected path, or `null` when the user cancels. Its host implementation invokes platform tools without a shell: `osascript` on macOS, an STA PowerShell `FolderBrowserDialog` on Windows, and Zenity with a KDialog fallback on Linux. The picker function is injectable for tests. This user-paced method is the sole unary call exempt from the default 30-second timeout; caller and connection aborts still propagate to the native process. The browser carrier's prefix-wide trust fence (dsh-client-connection) covers this method 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, `xdg-open` on Linux). The opener is injectable for tests. The browser carrier applies the same loopback, same-origin restriction as `host.pickDirectory`.
 

+ 2 - 2
packages/host/apiproxy/README.zh.md

@@ -6,7 +6,7 @@
 
 ## 契约层(`/api`)
 
-协议消息组成一个四象限可辨识联合:发起方 × 请求/响应,与物理通道解耦。四种消息分别是 `ClientRequest`(POST `/api/<method>` 的请求体)、`ServerResponse`(该 POST 的响应体)、`ServerRequest`(SSE 帧)和 `ClientResponse`(POST `/api/respond` 的请求体)。响应始终回显对应请求的 `rpcId`,绝不签发新值。方法的参数与返回值结构只存在于领域接口签名(`SessionsApi`、`HostApi`、`EventsApi`)中;`RpcMethodMap` 注册方法,其他所有位置均通过 `RequestPayload<K>`/`ResponseValue<K>` 派生。Zod schema 以 `satisfies z.ZodType<Wire<T>>` 锚定类型,并分两层解析:先解析信封,再解析业务载荷,随后按方法分发。业务错误由 `RpcResult` 的错误分支承载(`RpcErrorDetailsMap` 封闭错误码集合);HTTP 状态只表达载体层结果。
+协议消息组成一个四象限可辨识联合:发起方 × 请求/响应,与物理通道解耦。四种消息分别是 `ClientRequest`(POST `/api/<method>` 的请求体)、`ServerResponse`(该 POST 的响应体)、`ServerRequest`(SSE 帧)和 `ClientResponse`(POST `/api/respond` 的请求体)。响应始终回显对应请求的 `rpcId`,绝不签发新值。方法的参数与返回值结构只存在于领域接口签名(`SessionsApi`、`HostApi`、`EventsApi`)中;`RpcMethodMap` 注册方法,其他所有位置均通过 `RequestPayload<K>`/`ResponseValue<K>` 派生。Zod schema 以 `satisfies z.ZodType<Wire<T>>` 锚定类型,并分两层解析:先解析信封,再解析业务载荷,随后按方法分发。业务错误由 `RpcResult` 的错误分支承载(`RpcErrorDetailsMap` 封闭错误码集合);HTTP 状态只表达载体层结果。每个 `/api` POST 都必须声明 `application/json` 媒体类型——否则在分发前即以 415 拒绝,因此跨站"简单请求"(浏览器不经 CORS 预检就会发出)永远无法盲目执行有副作用的方法。
 
 分层与协议决策记录在 [GUI 分层与 RPC 协议 RFC](../../../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md)中;浏览器侧消费架构记录在 [Web 客户端架构 RFC](../../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md)中。
 
@@ -18,7 +18,7 @@
 
 Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create` 会创建唯一名称或接纳现有目录,`workspace.delete` 只移除 Workspace 注册记录,`session.create` 接受可选的预分配 Session id,`host/workspace-changed`、`host/workspace-removed` 与 `host/session-added` 则以任意到达顺序携带已提交的增量。删除注册记录会保留目录和会话日志;相关 Session 仍留在 `session.list` 中,并进入 Ungrouped。`SessionSummary.blank` 与 `host/session-added` 帧携带派生的零事件位:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank,并以 `session.list` 作为重连权威;冷会话摘要永远不是空白:惰性持久化让从未追加过事件的会话根本不出现在 `list()` 中。
 
-`host.pickDirectory` 会打开一个原生目录选择器并返回选中的路径;用户取消时返回 `null`。宿主实现不经 shell 调用平台工具:macOS 使用 `osascript`,Windows 使用以 STA 模式运行的 PowerShell `FolderBrowserDialog`,Linux 使用 Zenity,并以 KDialog 作为回退。选择器函数可在测试中注入。该方法需等待用户完成操作,是唯一不受默认 30 秒超时限制的一元调用;调用方发出的中止信号和连接中止仍会传播至原生进程。浏览器载体另行将这一特权方法限制为仅接受来自回环地址的同源请求。
+`host.pickDirectory` 会打开一个原生目录选择器并返回选中的路径;用户取消时返回 `null`。宿主实现不经 shell 调用平台工具:macOS 使用 `osascript`,Windows 使用以 STA 模式运行的 PowerShell `FolderBrowserDialog`,Linux 使用 Zenity,并以 KDialog 作为回退。选择器函数可在测试中注入。该方法需等待用户完成操作,是唯一不受默认 30 秒超时限制的一元调用;调用方发出的中止信号和连接中止仍会传播至原生进程。浏览器载体的前缀级信任栅栏(dsh-client-connection)像覆盖其他所有 `/api` 请求一样覆盖该方法。
 
 `host.openPath` 会用操作系统的默认应用打开一个文件系统路径(macOS 为 `open`,Windows 为 `Invoke-Item`,Linux 为 `xdg-open`)。打开器可在测试中注入。浏览器载体对其施加与 `host.pickDirectory` 相同的回环、同源限制。
 

+ 13 - 2
packages/host/apiproxy/src/fetch/handler.ts

@@ -2,8 +2,8 @@
  * Server side of the fetch carrier: maps an ApiProxy onto a pure
  * WHATWG Request->Response function. Two-level parse: full form (type/rpcId/method +
  * path==method) -> payload dispatched per method. HTTP status expresses only the carrier
- * (404 unknown path / 400 non-JSON body / 500 handler crash); business errors are always
- * 200 + ServerResponse.
+ * (404 unknown path / 415 non-JSON media type / 400 non-JSON body / 500 handler crash);
+ * business errors are always 200 + ServerResponse.
  */
 
 import { randomUUID } from 'node:crypto'
@@ -205,6 +205,17 @@ export function toFetchHandler(api: ApiProxy): { fetch: typeof fetch } {
         return new Response('not found', { status: 404 })
       }
 
+      // Cross-site write fence: browsers send "simple" POSTs (text/plain,
+      // form encodings) without a CORS preflight, so a malicious page could
+      // otherwise execute side-effectful RPCs blind — the response stays
+      // unreadable cross-origin, but session.prompt would still run. Only the
+      // JSON media type is accepted; anything else is forced into a preflight
+      // this server never answers. 415 = carrier layer, like the 400 below.
+      const mediaType = req.headers.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase()
+      if (mediaType !== 'application/json') {
+        return new Response('content type must be application/json', { status: 415 })
+      }
+
       let body: unknown
       try {
         body = await req.json()

+ 24 - 6
packages/host/apiproxy/tests/client-handler.spec.ts

@@ -151,7 +151,7 @@ describe('unary round trip', () => {
   it('rejects a method/path mismatch as bad-request', async () => {
     const handler = toFetchHandler(scriptedApi())
     const body = { type: 'client-request', rpcId: 'r1', method: 'session.create', payload: {} }
-    const response = await handler.fetch('http://dsh.internal/api/session.list', { method: 'POST', body: JSON.stringify(body) })
+    const response = await handler.fetch('http://dsh.internal/api/session.list', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) })
     expect(response.status).toBe(200)
     const parsed = await response.json() as { result: { ok: boolean; error?: { code: string; message: string } } }
     expect(parsed.result.ok).toBe(false)
@@ -162,13 +162,13 @@ describe('unary round trip', () => {
   it('rejects a malformed envelope as bad-request, salvaging the rpcId or falling back to the sentinel', async () => {
     const handler = toFetchHandler(scriptedApi())
     // No salvageable rpcId → the fixed invalid-request sentinel keeps the response a valid ServerResponse.
-    const noId = await handler.fetch('http://dsh.internal/api/session.list', { method: 'POST', body: JSON.stringify({ nonsense: true }) })
+    const noId = await handler.fetch('http://dsh.internal/api/session.list', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ nonsense: true }) })
     expect(noId.status).toBe(200)
     const noIdParsed = await noId.json() as { rpcId: string; result: { ok: boolean } }
     expect(noIdParsed.result.ok).toBe(false)
     expect(noIdParsed.rpcId).toBe('invalid-request')
     // A string rpcId in the otherwise-bad body is salvaged for correlation.
-    const withId = await handler.fetch('http://dsh.internal/api/session.list', { method: 'POST', body: JSON.stringify({ rpcId: 'salvage-me', nonsense: true }) })
+    const withId = await handler.fetch('http://dsh.internal/api/session.list', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ rpcId: 'salvage-me', nonsense: true }) })
     const withIdParsed = await withId.json() as { rpcId: string; result: { ok: boolean } }
     expect(withIdParsed.result.ok).toBe(false)
     expect(withIdParsed.rpcId).toBe('salvage-me')
@@ -177,16 +177,34 @@ describe('unary round trip', () => {
   it('maps carrier failures to HTTP statuses and the client throws transport failure', async () => {
     const handler = toFetchHandler(scriptedApi())
     // Unknown method → 404.
-    const notFound = await handler.fetch('http://dsh.internal/api/no.such', { method: 'POST', body: '{}' })
+    const notFound = await handler.fetch('http://dsh.internal/api/no.such', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' })
     expect(notFound.status).toBe(404)
     // Non-JSON body → 400.
-    const badBody = await handler.fetch('http://dsh.internal/api/session.list', { method: 'POST', body: '{oops' })
+    const badBody = await handler.fetch('http://dsh.internal/api/session.list', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{oops' })
     expect(badBody.status).toBe(400)
     // Impl crash → 500, and through the client that is a throw, not an err result.
     const crashing = scriptedApi({ sessions: { list: () => { throw new Error('impl exploded') } } })
     await expect(client(crashing).sessions.list({})).rejects.toThrow(/transport failure .*500/)
   })
 
+  it('rejects non-JSON media types before executing anything (cross-site simple-request fence)', async () => {
+    const list = vi.fn((r: RpcRequest<{}>) => ok(r, { items: [] }))
+    const handler = toFetchHandler(scriptedApi({ sessions: { list } }))
+    const body = JSON.stringify({ type: 'client-request', rpcId: 'r1', method: 'session.list', payload: {} })
+    // A "simple" browser POST (text/plain — sent with no CORS preflight) is
+    // refused at the carrier before the impl runs.
+    const plain = await handler.fetch('http://dsh.internal/api/session.list', { method: 'POST', headers: { 'content-type': 'text/plain' }, body })
+    expect(plain.status).toBe(415)
+    // A string body with no explicit header defaults to text/plain — same fence.
+    const unlabelled = await handler.fetch('http://dsh.internal/api/session.list', { method: 'POST', body })
+    expect(unlabelled.status).toBe(415)
+    expect(list).not.toHaveBeenCalled()
+    // Media-type parameters pass: the fence checks the type, not the exact string.
+    const charset = await handler.fetch('http://dsh.internal/api/session.list', { method: 'POST', headers: { 'content-type': 'application/json; charset=utf-8' }, body })
+    expect(charset.status).toBe(200)
+    expect(list).toHaveBeenCalledTimes(1)
+  })
+
   it('rejects when the transport never resolves within timeoutMs', async () => {
     // AbortSignal.timeout is immune to fake timers; a short real timeout keeps this fast.
     const never = new InProcessApiClient({
@@ -495,7 +513,7 @@ describe('respond path', () => {
   it('returns bad-response for a malformed client-response without reaching the impl', async () => {
     const respond = vi.fn()
     const handler = toFetchHandler(scriptedApi({ respond }))
-    const response = await handler.fetch('http://dsh.internal/api/respond', { method: 'POST', body: JSON.stringify({ type: 'client-response' }) })
+    const response = await handler.fetch('http://dsh.internal/api/respond', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ type: 'client-response' }) })
     expect(await response.json()).toEqual({ accepted: false, reason: 'bad-response' })
     expect(respond).not.toHaveBeenCalled()
   })

+ 12 - 12
packages/host/apiproxy/tests/fetch-carrier.spec.ts

@@ -263,7 +263,7 @@ describe('unary round trip (handler ⇄ client, no network)', () => {
     const body = JSON.stringify({ type: 'client-request', rpcId: 'r-sig', method: 'command.execute', payload: { sessionId: 's', line: '/hang' } })
     // The fake's /hang settles only when the invoke-level signal aborts: a
     // completed response with the cancelled error proves req.signal reached it.
-    const pending = handler.fetch(new Request('http://x/api/command.execute', { method: 'POST', body, signal: controller.signal }))
+    const pending = handler.fetch(new Request('http://x/api/command.execute', { method: 'POST', headers: { 'content-type': 'application/json' }, body, signal: controller.signal }))
     controller.abort()
     const response = await pending
     const parsed = await response.json() as { rpcId: string; result: { ok: boolean; error?: { code: string } } }
@@ -288,7 +288,7 @@ describe('unary round trip (handler ⇄ client, no network)', () => {
     const controller = new AbortController()
     const body = JSON.stringify({ type: 'client-request', rpcId: 'r-picker', method: 'host.pickDirectory', payload: {} })
     const pending = handler.fetch(new Request('http://x/api/host.pickDirectory', {
-      method: 'POST', body, signal: controller.signal,
+      method: 'POST', headers: { 'content-type': 'application/json' }, body, signal: controller.signal,
     }))
     controller.abort()
     const parsed = await (await pending).json() as { result: { error?: { code: string } } }
@@ -300,18 +300,18 @@ describe('handler carrier-layer statuses', () => {
   const handler = toFetchHandler(fakeApi())
 
   it('404s unknown paths and non-POST non-stream methods', async () => {
-    expect((await handler.fetch(new Request('http://x/other', { method: 'POST', body: '{}' }))).status).toBe(404)
+    expect((await handler.fetch(new Request('http://x/other', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' }))).status).toBe(404)
     expect((await handler.fetch(new Request('http://x/api/session.list', { method: 'GET' }))).status).toBe(404)
-    expect((await handler.fetch(new Request('http://x/api/no.such', { method: 'POST', body: JSON.stringify({ type: 'client-request', rpcId: 'r', method: 'no.such', payload: {} }) }))).status).toBe(404)
+    expect((await handler.fetch(new Request('http://x/api/no.such', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ type: 'client-request', rpcId: 'r', method: 'no.such', payload: {} }) }))).status).toBe(404)
   })
 
   it('400s a non-JSON body', async () => {
-    const response = await handler.fetch(new Request('http://x/api/session.list', { method: 'POST', body: 'not json' }))
+    const response = await handler.fetch(new Request('http://x/api/session.list', { method: 'POST', headers: { 'content-type': 'application/json' }, body: 'not json' }))
     expect(response.status).toBe(400)
   })
 
   it('rejects a malformed envelope with bad-request and the invalid-request sentinel rpcId', async () => {
-    const response = await handler.fetch(new Request('http://x/api/session.list', { method: 'POST', body: JSON.stringify({ nope: true }) }))
+    const response = await handler.fetch(new Request('http://x/api/session.list', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ nope: true }) }))
     expect(response.status).toBe(200)
     const body = await response.json() as { rpcId: string; result: { ok: boolean; error?: { code: string } } }
     expect(body.rpcId).toBe('invalid-request')
@@ -320,7 +320,7 @@ describe('handler carrier-layer statuses', () => {
 
   it('rejects a method/path mismatch echoing the envelope rpcId', async () => {
     const body = JSON.stringify({ type: 'client-request', rpcId: 'r-9', method: 'session.cancel', payload: {} })
-    const response = await handler.fetch(new Request('http://x/api/session.list', { method: 'POST', body }))
+    const response = await handler.fetch(new Request('http://x/api/session.list', { method: 'POST', headers: { 'content-type': 'application/json' }, body }))
     const parsed = await response.json() as { rpcId: string; result: { error?: { message: string } } }
     expect(parsed.rpcId).toBe('r-9')
     expect(parsed.result.error?.message).toContain('does not match path')
@@ -328,7 +328,7 @@ describe('handler carrier-layer statuses', () => {
 
   it('rejects an invalid payload with the zod issues attached', async () => {
     const body = JSON.stringify({ type: 'client-request', rpcId: 'r-10', method: 'session.cancel', payload: {} })
-    const response = await handler.fetch(new Request('http://x/api/session.cancel', { method: 'POST', body }))
+    const response = await handler.fetch(new Request('http://x/api/session.cancel', { method: 'POST', headers: { 'content-type': 'application/json' }, body }))
     const parsed = await response.json() as { result: { error?: { code: string; details: { issues: unknown[] } } } }
     expect(parsed.result.error?.code).toBe('bad-request')
     expect(parsed.result.error?.details.issues.length).toBeGreaterThan(0)
@@ -337,23 +337,23 @@ describe('handler carrier-layer statuses', () => {
   it('500s when the impl itself throws', async () => {
     const crashing = toFetchHandler(fakeApi({ crashOn: 'session.list' }))
     const body = JSON.stringify({ type: 'client-request', rpcId: 'r-11', method: 'session.list', payload: {} })
-    const response = await crashing.fetch(new Request('http://x/api/session.list', { method: 'POST', body }))
+    const response = await crashing.fetch(new Request('http://x/api/session.list', { method: 'POST', headers: { 'content-type': 'application/json' }, body }))
     expect(response.status).toBe(500)
     expect(await response.text()).toContain('impl crashed')
   })
 
   it('routes /api/respond, rejecting malformed client-responses as a receipt', async () => {
     const good = JSON.stringify({ type: 'client-response', rpcId: 'known', result: { ok: true, value: null } })
-    const goodReceipt: unknown = await (await handler.fetch(new Request('http://x/api/respond', { method: 'POST', body: good }))).json()
+    const goodReceipt: unknown = await (await handler.fetch(new Request('http://x/api/respond', { method: 'POST', headers: { 'content-type': 'application/json' }, body: good }))).json()
     expect(goodReceipt).toEqual({ accepted: true })
     const bad = JSON.stringify({ type: 'client-request', rpcId: 'r', method: 'x', payload: {} })
-    const badReceipt: unknown = await (await handler.fetch(new Request('http://x/api/respond', { method: 'POST', body: bad }))).json()
+    const badReceipt: unknown = await (await handler.fetch(new Request('http://x/api/respond', { method: 'POST', headers: { 'content-type': 'application/json' }, body: bad }))).json()
     expect(badReceipt).toEqual({ accepted: false, reason: 'bad-response' })
   })
 
   it('accepts (url, init) form fetch invocation', async () => {
     const body = JSON.stringify({ type: 'client-request', rpcId: 'r-12', method: 'session.list', payload: {} })
-    const response = await handler.fetch('http://x/api/session.list', { method: 'POST', body })
+    const response = await handler.fetch('http://x/api/session.list', { method: 'POST', headers: { 'content-type': 'application/json' }, body })
     expect(response.status).toBe(200)
   })
 })

+ 3 - 3
packages/host/webserver/README.i18n.yaml

@@ -1,6 +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
-README.md: c589c32c4e641e188f19ac6c5ad2e88e3eb79be3
-README.zh.md: 767195086b90a76160d87865caebf514ca75b0e3
+#   pnpm run verify-translation-pairing --write packages/host/webserver/README.md
+README.md: e715e4452ddb808f36e6b097eee0fda7b8d0bfb0
+README.zh.md: 05e7e10d7815c8f26bb90597b38b7c6b83a86dbc

+ 1 - 1
packages/host/webserver/README.md

@@ -2,7 +2,7 @@
 
 English | [中文](README.zh.md)
 
-Plain HTTP route-registration plugin (default-exported `WebServerService`, config `{host, port, distIndex}`): a `node:http` server that listens on activation and provides `ctx.webServer` — `register(route)` adds a named `exact`/`prefix` route (duplicate `(kind, path)` throws: route patterns are a composition-level contract, so a collision is a misconfiguration; the returned disposer removes the route), `tapIndex(transform)` adds an index.html transform applied in registration order, and `port` reads the listening port (the OS-assigned value when `port` is 0). The match order is fixed — exact over the whole table, then longest prefix, then the static dist fallback with the locked semantics: traversal outside the dist root is 403, any miss falls back to `index.html` with HTTP 200 (SPA routing), unknown extensions ship as octet-stream, non-GET/HEAD is 405. Registration order carries no request-facing semantics.
+Plain HTTP route-registration plugin (default-exported `HttpServerService`, config `{host, port, distIndex}`): a `node:http` server that listens on activation and provides `ctx.httpServer` — `register(route)` adds a named `exact`/`prefix` route (duplicate `(kind, path)` throws: route patterns are a composition-level contract, so a collision is a misconfiguration; the returned disposer removes the route), `tapIndex(transform)` adds an index.html transform applied in registration order, and `port` reads the listening port (the OS-assigned value when `port` is 0). The match order is fixed — exact over the whole table, then longest prefix, then the static dist fallback with the locked semantics: traversal outside the dist root is 403, any miss falls back to `index.html` with HTTP 200 (SPA routing), unknown extensions ship as octet-stream, non-GET/HEAD is 405. Registration order carries no request-facing semantics.
 
 The package knows no harness concepts: the `/api` bridge is the connection plugin's route, plugin bundles and the HMR event stream are the modules/hmr plugins' routes. `host` accepts only `127.0.0.1` (default posture) and `0.0.0.0` (deliberate network exposure); `distIndex` is an assembly fact the composing app resolves and injects, never self-resolved (dist location is workspace knowledge of the app). Web (browser) shape only — Electron loads dist over `file://` and carries fetch over an IPC bridge, not this server. This package never prints; the URL line belongs to the shell.
 

+ 1 - 1
packages/host/webserver/README.zh.md

@@ -2,7 +2,7 @@
 
 [English](README.md) | 中文
 
-朴素的 HTTP 路由注册插件(默认导出 `WebServerService`,配置为 `{host, port, distIndex}`):一个在激活时开始监听的 `node:http` 服务器,提供 `ctx.webServer`。`register(route)` 添加具名的 `exact`/`prefix` 路由;重复的 `(kind, path)` 会抛错,因为路由模式是组合层契约,冲突即配置错误;返回的 disposer 会移除该路由。`tapIndex(transform)` 添加按注册顺序应用的 index.html 转换,`port` 读取正在监听的端口(当 `port` 为 0 时读取 OS 分配的值)。匹配顺序固定不变:先在整张表中匹配精确路由,再匹配最长前缀,最后回退到静态 dist,并遵循固定语义:越出 dist 根目录的遍历返回 403,任何未命中项都以 HTTP 200 回退到 `index.html`(SPA 路由),未知扩展名按 octet-stream 提供,GET/HEAD 之外的方法返回 405。注册顺序不承载任何面向请求的语义。
+朴素的 HTTP 路由注册插件(默认导出 `HttpServerService`,配置为 `{host, port, distIndex}`):一个在激活时开始监听的 `node:http` 服务器,提供 `ctx.httpServer`。`register(route)` 添加具名的 `exact`/`prefix` 路由;重复的 `(kind, path)` 会抛错,因为路由模式是组合层契约,冲突即配置错误;返回的 disposer 会移除该路由。`tapIndex(transform)` 添加按注册顺序应用的 index.html 转换,`port` 读取正在监听的端口(当 `port` 为 0 时读取 OS 分配的值)。匹配顺序固定不变:先在整张表中匹配精确路由,再匹配最长前缀,最后回退到静态 dist,并遵循固定语义:越出 dist 根目录的遍历返回 403,任何未命中项都以 HTTP 200 回退到 `index.html`(SPA 路由),未知扩展名按 octet-stream 提供,GET/HEAD 之外的方法返回 405。注册顺序不承载任何面向请求的语义。
 
 该包不了解任何 harness 概念:`/api` 桥接是 connection 插件的路由,插件 bundle 与 HMR(热模块替换)事件流则是 modules/hmr 插件的路由。`host` 只接受 `127.0.0.1`(默认姿态)和 `0.0.0.0`(有意向网络开放);`distIndex` 是由组合应用解析并注入的组装事实,绝不会自行解析,因为 dist 位置属于应用的工作区知识。该服务器只服务 Web(浏览器)形态;Electron 通过 `file://` 加载 dist,并经 IPC 桥接承载 fetch,而不使用本服务器。该包从不打印内容;URL 行属于 shell。
 

+ 3 - 0
pnpm-lock.yaml

@@ -821,6 +821,9 @@ importers:
       '@deepseek-ai/dsh-tools':
         specifier: workspace:^
         version: link:../../core/tools
+      schemastery:
+        specifier: ^3.18.0
+        version: 3.18.0
     devDependencies:
       '@deepseek-ai/dsh-host-webserver':
         specifier: workspace:^

+ 1 - 1
scripts/doc-budgets.manifest.json

@@ -7,5 +7,5 @@
   "docs/testing.md": 1100,
   "examples/AGENTS.md": 310,
   "packages/AGENTS.md": 675,
-  "packages/README.md": 835
+  "packages/README.md": 870
 }