From 7ff8da56dfb8e6ad0130e9dcbdb55f034c3ff530 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Tue, 28 Jul 2026 16:49:52 +0800 Subject: [PATCH 1/3] fix(connection): require trustedHosts entries in canonical authority form A dangling colon (harness.internal:) or zero-padded port parses cleanly while WHATWG silently rewrites it, turning an intended exact-port grant into an any-port grant. Replace the character blacklist with a round-trip check: an entry must read back from parsing exactly as written (case aside), refusing the whole rewrite class at load. --- docs/config-catalog.md | 2 +- packages/client/connection/README.i18n.yaml | 4 +- packages/client/connection/README.md | 2 +- packages/client/connection/README.zh.md | 2 +- .../connection/src/api-request-trust.ts | 41 +++++++++++-------- packages/client/connection/src/index.ts | 2 +- .../tests/api-request-trust.spec.ts | 6 +++ 7 files changed, 35 insertions(+), 24 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 5a7f63c85f..878de38a19 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -283,7 +283,7 @@ export interface ConnectionConfig { * browser 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 authority fails the plugin load. + * that is not a bare, canonical authority fails the plugin load. */ trustedHosts?: string[] } diff --git a/packages/client/connection/README.i18n.yaml b/packages/client/connection/README.i18n.yaml index f0775848d3..c390223071 100644 --- a/packages/client/connection/README.i18n.yaml +++ b/packages/client/connection/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/connection/README.md -README.md: 591e8361c1d28fab909bfe4a4f176fa1887edd93 -README.zh.md: bd772b2ab0f36abc8dbce35d30f55b40e53d0756 +README.md: 7e437e8fd81d1d57ead5ead64d2049111ad163dc +README.zh.md: 3615d5ea1be44e6da8e41fa17f645eb414d1ef3e diff --git a/packages/client/connection/README.md b/packages/client/connection/README.md index 591e8361c1..7e437e8fd8 100644 --- a/packages/client/connection/README.md +++ b/packages/client/connection/README.md @@ -6,7 +6,7 @@ Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared a ## /api browser-trust fence -The node half guards every request under `/api` before bridging (`src/api-request-trust.ts`). Requests without browser markers (no `Origin`, no `sec-fetch-site` — curl, tests, native clients) pass on any Host: without a browser there is no confused deputy, and such a sender forges every header anyway. For browser requests, the `Host` header must be a loopback authority or match a `trustedHosts` entry — exact on `host:port` entries, any port on port-less entries, both sides compared through WHATWG normalization (DNS-rebinding defense); an attached `Origin` must equal that authority, and an explicit `sec-fetch-site: cross-site` marker is refused. A `trustedHosts` entry that is not a bare `host[:port]` authority fails the plugin load loudly — WHATWG parsing would otherwise quietly authorize the hostname inside a typo like `harness.internal/path`. 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). +The node half guards every request under `/api` before bridging (`src/api-request-trust.ts`). Requests without browser markers (no `Origin`, no `sec-fetch-site` — curl, tests, native clients) pass on any Host: without a browser there is no confused deputy, and such a sender forges every header anyway. For browser requests, the `Host` header must be a loopback authority or match a `trustedHosts` entry — exact on `host:port` entries, any port on port-less entries, both sides compared through WHATWG normalization (DNS-rebinding defense); an attached `Origin` must equal that 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 diff --git a/packages/client/connection/README.zh.md b/packages/client/connection/README.zh.md index bd772b2ab0..3615d5ea1b 100644 --- a/packages/client/connection/README.zh.md +++ b/packages/client/connection/README.zh.md @@ -6,7 +6,7 @@ ## /api 浏览器信任栅栏 -node 半侧在桥接前守卫 `/api` 下的每个请求(`src/api-request-trust.ts`)。不带浏览器标记的请求(无 `Origin`、无 `sec-fetch-site`——curl、测试、原生客户端)在任何 Host 上都放行:没有浏览器就不存在"混淆代理人",且这类发送方本就可以伪造任何请求头。对浏览器请求,`Host` 头必须是回环地址权威,或与某个 `trustedHosts` 条目匹配——带端口的 `host:port` 条目精确匹配,不带端口的条目匹配任意端口,两侧均经 WHATWG 归一化后比较(DNS rebinding 防御);若带有 `Origin` 则必须与该权威完全一致;显式的 `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)。 +node 半侧在桥接前守卫 `/api` 下的每个请求(`src/api-request-trust.ts`)。不带浏览器标记的请求(无 `Origin`、无 `sec-fetch-site`——curl、测试、原生客户端)在任何 Host 上都放行:没有浏览器就不存在"混淆代理人",且这类发送方本就可以伪造任何请求头。对浏览器请求,`Host` 头必须是回环地址权威,或与某个 `trustedHosts` 条目匹配——带端口的 `host:port` 条目精确匹配,不带端口的条目匹配任意端口,两侧均经 WHATWG 归一化后比较(DNS rebinding 防御);若带有 `Origin` 则必须与该权威完全一致;显式的 `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 diff --git a/packages/client/connection/src/api-request-trust.ts b/packages/client/connection/src/api-request-trust.ts index ad2519d90b..57a8cb179c 100644 --- a/packages/client/connection/src/api-request-trust.ts +++ b/packages/client/connection/src/api-request-trust.ts @@ -42,30 +42,35 @@ function parseAuthority(authority: string): URL | undefined { /** * Assert one configured `trustedHosts` entry is a bare authority (`host` or - * `host:port`) and nothing else. WHATWG parsing would quietly read a hostname - * out of `harness.internal/path` or `user@harness.internal` — a typo must fail - * the load loudly instead of authorizing its hostname or being ignored until - * requests 403. The character test refuses every URL part beyond the authority - * (path, backslash path, query, fragment, userinfo) and all whitespace, which - * WHATWG trimming would otherwise strip silently; IPv6 brackets use none of - * them. + * `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 { - if (parseAuthority(entry) !== undefined && !/[/\\?#@\s]/.test(entry)) return + 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`) } /** - * Whether the parsed authority carries an explicit port: 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 of stray whitespace would misread `host:port ` as port-less and - * broaden an exact-port grant to every port. + * 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 hasExplicitPort(entry: string, entryUrl: URL): boolean { +function canonicalAuthority(entry: string, entryUrl: URL): string { // An authority that parsed under http cannot fail under https. - return entryUrl.port !== '' || new URL(`https://${entry}`).port !== '' + const port = entryUrl.port !== '' ? entryUrl.port : new URL(`https://${entry}`).port + return port === '' ? entryUrl.hostname : `${entryUrl.hostname}:${port}` } /** @@ -79,9 +84,9 @@ function isTrustedAuthority(hostUrl: URL, trustedHosts: readonly string[]): bool return trustedHosts.some((entry) => { const entryUrl = parseAuthority(entry) if (entryUrl === undefined) return false - return hasExplicitPort(entry, entryUrl) - ? entryUrl.host === hostUrl.host - : entryUrl.hostname === hostUrl.hostname + return canonicalAuthority(entry, entryUrl) === entryUrl.hostname + ? entryUrl.hostname === hostUrl.hostname + : entryUrl.host === hostUrl.host }) } diff --git a/packages/client/connection/src/index.ts b/packages/client/connection/src/index.ts index f37a64fb21..a649afda1a 100644 --- a/packages/client/connection/src/index.ts +++ b/packages/client/connection/src/index.ts @@ -24,7 +24,7 @@ export interface ConnectionConfig { * browser 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 authority fails the plugin load. + * that is not a bare, canonical authority fails the plugin load. */ trustedHosts?: string[] } diff --git a/packages/client/connection/tests/api-request-trust.spec.ts b/packages/client/connection/tests/api-request-trust.spec.ts index e3f1c91caf..2c608cc988 100644 --- a/packages/client/connection/tests/api-request-trust.spec.ts +++ b/packages/client/connection/tests/api-request-trust.spec.ts @@ -79,6 +79,12 @@ describe('isTrustedApiRequest', () => { 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', () => { From 772653464d546121c90c5d4fc77337d9716a513f Mon Sep 17 00:00:00 2001 From: creatixchu Date: Tue, 28 Jul 2026 17:02:39 +0800 Subject: [PATCH 2/3] =?UTF-8?q?fix(connection):=20hold=20markerless=20requ?= =?UTF-8?q?ests=20to=20the=20Host=20fence=20=E2=80=94=20plain-HTTP=20brows?= =?UTF-8?q?er=20reads=20carry=20no=20markers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fetch-Metadata and Origin are only attached to trustworthy destinations, so over plain HTTP a rebound page's same-origin GET (EventSource, images, navigations) arrives with no browser markers and a readable response. Remove the marker shortcut; non-browser clients pass the same fence via loopback, the CLI-derived LAN IP literals, or a declared authority. --- ...07-28-api-browser-trust-boundary.i18n.yaml | 4 +- .../2026-07-28-api-browser-trust-boundary.md | 4 +- ...026-07-28-api-browser-trust-boundary.zh.md | 4 +- docs/config-catalog.md | 2 +- packages/client/connection/README.i18n.yaml | 4 +- packages/client/connection/README.md | 2 +- packages/client/connection/README.zh.md | 2 +- .../connection/src/api-request-trust.ts | 40 ++++++++++--------- packages/client/connection/src/index.ts | 2 +- .../tests/api-request-trust.spec.ts | 16 ++++---- .../client/connection/tests/node-half.spec.ts | 6 +-- 11 files changed, 45 insertions(+), 41 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.i18n.yaml index 1e10e92f49..c15af141bd 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md -2026-07-28-api-browser-trust-boundary.md: 4dd913bb73da3b24073c020ff80fdfa83b44a812 -2026-07-28-api-browser-trust-boundary.zh.md: 0be817aca9dde68588959d2cd622639d90d9f993 +2026-07-28-api-browser-trust-boundary.md: e56d0fc2a7bd551899605491f3a0522b62b961b0 +2026-07-28-api-browser-trust-boundary.zh.md: 2958f7e49bfd4a258c63fc96c2e8aee0f98183ee diff --git a/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md b/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md index 4dd913bb73..e56d0fc2a7 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md +++ b/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md @@ -13,7 +13,7 @@ The web GUI host serves `/api` over plain HTTP (default `127.0.0.1:3080`, `--hos 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`)**: requests without browser markers (no `Origin`, no `sec-fetch-site`) pass on any Host — a non-browser client is the principal itself, not a deputy, and forges every header anyway, so fencing it buys nothing and breaks non-browser LAN automation. For browser requests, `Host` must be loopback or match a `trustedHosts` entry (exact on `host:port`, any port on port-less entries, WHATWG-normalized; rebinding defense); an attached `Origin` must equal that authority; `sec-fetch-site: cross-site` is refused outright. A `trustedHosts` entry that is not a bare authority fails the plugin load — WHATWG parsing would otherwise quietly authorize the hostname inside a typo. `host.pickDirectory` loses its bespoke guard and rides the same fence. +- **Authority fence (dsh-client-connection, `src/api-request-trust.ts`)**: every request must present a `Host` that is loopback or matches a `trustedHosts` entry (exact on `host:port`, any port on port-less entries, WHATWG-normalized; rebinding defense). Deliberately no shortcut for unmarked requests: over plain HTTP a browser attaches neither `Origin` nor Fetch-Metadata to reads (EventSource, images, navigations — those headers go only to trustworthy destinations), so an unmarked request may be a rebound browser read whose response the page can read, and Host is the one header rebinding cannot forge; non-browser clients pass via loopback, 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. @@ -26,6 +26,6 @@ Two boundaries stay deliberately out of scope: reachability is the webserver bin ## 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 browsers 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. Plain curl-shape automation is unaffected everywhere. +- 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. diff --git a/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.zh.md b/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.zh.md index 0be817aca9..2958f7e49b 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.zh.md @@ -13,7 +13,7 @@ Web GUI 宿主以纯 HTTP 提供 `/api`(默认 `127.0.0.1:3080`,支持 `--ho 在载体层对整个 `/api` 前缀一次性执行浏览器信任检查——两半各占一个栈式 PR: - **媒体类型栅栏(dsh-host-apiproxy)**:每个 `/api` POST 必须声明 `application/json`,否则在解析前以 415 拒绝。跨站"简单请求"由此不复存在:任何跨站尝试都被逼进一次本服务器从不应答的 CORS 预检。 -- **权威栅栏(dsh-client-connection,`src/api-request-trust.ts`)**:不带浏览器标记的请求(无 `Origin`、无 `sec-fetch-site`)在任何 Host 上都放行——非浏览器客户端是委托人本人,不是代理人,且本就可以伪造任何请求头,对它设栅一无所获,反而会打断非浏览器的 LAN 自动化。对浏览器请求,`Host` 必须是回环地址,或与某个 `trustedHosts` 条目匹配(带端口的 `host:port` 条目精确匹配,不带端口的条目匹配任意端口,均经 WHATWG 归一化;rebinding 防御);若带 `Origin` 则必须与该权威完全一致;`sec-fetch-site: cross-site` 一律拒绝。不是纯权威的 `trustedHosts` 条目会让插件加载失败——否则 WHATWG 解析会悄悄授权笔误里的 hostname。`host.pickDirectory` 失去专属守卫,与其他请求同栅而行。 +- **权威栅栏(dsh-client-connection,`src/api-request-trust.ts`)**:每个请求的 `Host` 都必须是回环地址,或与某个 `trustedHosts` 条目匹配(带端口的 `host:port` 条目精确匹配,不带端口的条目匹配任意端口,均经 WHATWG 归一化;rebinding 防御)。刻意不为无标记请求开捷径:明文 HTTP 下浏览器的读取(EventSource、图片、导航——这些头只发给可信目标)既不带 `Origin` 也不带 Fetch-Metadata,因此无标记请求可能是被重绑页面发起且响应可被读走的读取,而 Host 是重绑唯一伪造不了的请求头;非浏览器客户端经由回环地址、推导的 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 地址提供不了头部栅栏覆盖不到的任何东西。 @@ -26,6 +26,6 @@ Web GUI 宿主以纯 HTTP 提供 `/api`(默认 `127.0.0.1:3080`,支持 `--ho ## 后果 - 未来任何 `/api` 方法天然在覆盖范围内;不存在会被遗忘的按路由信任决定。 -- 非回环部署的服务权威必须获得信任,否则浏览器会被拒绝。dsh CLI 通过把本机 LAN IP 字面量推导进 connection 行(不带端口的条目——IP 字面量 Host 不可能是被重绑的域名,且绑定端口可能由操作系统分配)来保住它广告出的 `--host 0.0.0.0` LAN URL,并提供 `dsh web --trusted-host` 声明具名权威;CLI 不参与引导的组合自行声明 `trustedHosts`。curl 形态的自动化在任何地方都不受影响。 +- 非回环部署的服务权威必须获得信任,否则请求会被拒绝。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` 部署的"信任网络"假设从隐含变为成文。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 878de38a19..43c9b5c8bf 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -280,7 +280,7 @@ 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 - * browser request whose Host is neither loopback nor listed here, so a + * 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. diff --git a/packages/client/connection/README.i18n.yaml b/packages/client/connection/README.i18n.yaml index c390223071..6b8558f9be 100644 --- a/packages/client/connection/README.i18n.yaml +++ b/packages/client/connection/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/connection/README.md -README.md: 7e437e8fd81d1d57ead5ead64d2049111ad163dc -README.zh.md: 3615d5ea1be44e6da8e41fa17f645eb414d1ef3e +README.md: 173a9b9998e17d201b2d31d73ea74a94b319dae6 +README.zh.md: ca5da643db443956c25399f07c8b460900942ad4 diff --git a/packages/client/connection/README.md b/packages/client/connection/README.md index 7e437e8fd8..173a9b9998 100644 --- a/packages/client/connection/README.md +++ b/packages/client/connection/README.md @@ -6,7 +6,7 @@ Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared a ## /api browser-trust fence -The node half guards every request under `/api` before bridging (`src/api-request-trust.ts`). Requests without browser markers (no `Origin`, no `sec-fetch-site` — curl, tests, native clients) pass on any Host: without a browser there is no confused deputy, and such a sender forges every header anyway. For browser requests, the `Host` header must be a loopback authority or match a `trustedHosts` entry — exact on `host:port` entries, any port on port-less entries, both sides compared through WHATWG normalization (DNS-rebinding defense); an attached `Origin` must equal that 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). +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 diff --git a/packages/client/connection/README.zh.md b/packages/client/connection/README.zh.md index 3615d5ea1b..ca5da643db 100644 --- a/packages/client/connection/README.zh.md +++ b/packages/client/connection/README.zh.md @@ -6,7 +6,7 @@ ## /api 浏览器信任栅栏 -node 半侧在桥接前守卫 `/api` 下的每个请求(`src/api-request-trust.ts`)。不带浏览器标记的请求(无 `Origin`、无 `sec-fetch-site`——curl、测试、原生客户端)在任何 Host 上都放行:没有浏览器就不存在"混淆代理人",且这类发送方本就可以伪造任何请求头。对浏览器请求,`Host` 头必须是回环地址权威,或与某个 `trustedHosts` 条目匹配——带端口的 `host:port` 条目精确匹配,不带端口的条目匹配任意端口,两侧均经 WHATWG 归一化后比较(DNS rebinding 防御);若带有 `Origin` 则必须与该权威完全一致;显式的 `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)。 +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 diff --git a/packages/client/connection/src/api-request-trust.ts b/packages/client/connection/src/api-request-trust.ts index 57a8cb179c..8c1bddd631 100644 --- a/packages/client/connection/src/api-request-trust.ts +++ b/packages/client/connection/src/api-request-trust.ts @@ -2,12 +2,15 @@ * 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 — without blocking non-browser clients - * (no browser markers → no deputy to confuse, and a native client forges Host - * freely anyway) or legitimately remote browsers (their authority is declared - * via `trustedHosts`, or derived by the composing app for IP-literal LAN - * serving). Network reachability and authentication stay out of scope: binding - * policy belongs to the webserver config, and this fence is not an auth layer. + * 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' @@ -94,19 +97,16 @@ function isTrustedAuthority(hostUrl: URL, trustedHosts: readonly string[]): bool * 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 for requests without browser markers, and for browser requests whose Host is ours and whose markers are same-origin. + * @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 { - // Marker gate: Origin and sec-fetch-site exist only when a browser is the - // sender's deputy. Absent both, the sender is the principal itself (curl, - // tests, native shells) and could forge every header below — fencing it - // would add nothing and would break non-browser LAN automation. - const origin = header(request.headers, 'origin') - const secFetchSite = header(request.headers, 'sec-fetch-site') - if (origin === undefined && secFetchSite === undefined) return true - // Host fence (DNS-rebinding defense): 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. + // 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) @@ -114,10 +114,12 @@ export function isTrustedApiRequest(request: ApiTrustRequest, trustedHosts: read 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 (secFetchSite === 'cross-site') return false + 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). The + // 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 diff --git a/packages/client/connection/src/index.ts b/packages/client/connection/src/index.ts index a649afda1a..ce55089bdb 100644 --- a/packages/client/connection/src/index.ts +++ b/packages/client/connection/src/index.ts @@ -21,7 +21,7 @@ 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 - * browser request whose Host is neither loopback nor listed here, so a + * 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. diff --git a/packages/client/connection/tests/api-request-trust.spec.ts b/packages/client/connection/tests/api-request-trust.spec.ts index 2c608cc988..f145230a8b 100644 --- a/packages/client/connection/tests/api-request-trust.spec.ts +++ b/packages/client/connection/tests/api-request-trust.spec.ts @@ -8,13 +8,15 @@ function request(headers: Record): { headers: Record } describe('isTrustedApiRequest', () => { - it('accepts every request without browser markers — curl, tests, native clients, on any Host', () => { - // No Origin and no sec-fetch-site → the sender is the principal itself - // (it forges Host freely anyway); this is the LAN-serving shape a Host - // fence must not break. - for (const host of ['127.0.0.1:3080', '192.168.1.5:3080', 'harness.example', undefined]) { - expect(isTrustedApiRequest(request(host === undefined ? {} : { host }), [])).toBe(true) - } + 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', () => { diff --git a/packages/client/connection/tests/node-half.spec.ts b/packages/client/connection/tests/node-half.spec.ts index 5404f1c798..2c7fd0b281 100644 --- a/packages/client/connection/tests/node-half.spec.ts +++ b/packages/client/connection/tests/node-half.spec.ts @@ -98,14 +98,14 @@ describe('connection node half', () => { }) it('passes loopback and declared-authority requests through to the bridge', async () => { - const { routes, dispose } = await mounted({ trustedHosts: ['harness.example:3080'] }) + 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) - // Undeclared LAN authority, no browser markers: the `--host 0.0.0.0` curl - // shape must reach the bridge even with an empty-by-default trust list. + // 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) From c565022c8af85c5fd3b780c6459a6939aca8c4bf Mon Sep 17 00:00:00 2001 From: creatixchu Date: Tue, 28 Jul 2026 17:09:43 +0800 Subject: [PATCH 3/3] fix(host): derive the picker capability union from a merge-extensible map MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ds-review-bot round 1: the seam documented a merge-extensible union but shipped a closed alias, and the gateway schema rejected any kind beyond dialog/browse — a third backend could neither implement the seam nor be advertised. The union now derives from an augmentable DirectoryPickerCapabilities map, host.describe.directoryPicker preserves unknown wire kinds, and the browse fixture applies listDirectory's root special case so creating under '/' no longer mints a '//name' identity. --- docs/cordis-catalog/services.md | 2 +- packages/client/connection/src/client/fixture.ts | 4 +++- packages/client/connection/tests/fixture.spec.ts | 16 ++++++++++++++++ packages/cordis/tool-cordis/src/api-catalog.ts | 6 +++++- packages/host/apiproxy/src/api/host.schema.ts | 4 +++- packages/host/apiproxy/src/api/host.ts | 5 ++++- packages/host/apiproxy/tests/rpc-schemas.spec.ts | 4 +++- packages/host/directory-picker/README.i18n.yaml | 4 ++-- packages/host/directory-picker/README.md | 2 +- packages/host/directory-picker/README.zh.md | 2 +- packages/host/directory-picker/src/index.ts | 14 ++++++++++++-- 11 files changed, 51 insertions(+), 12 deletions(-) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index eb212207b3..397e7320d5 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -481,7 +481,7 @@ Abstract directory-picking service. Subclass, implement `capability()`, and load abstract capability(): DirectoryPickerCapability ``` -Source: [`packages/host/directory-picker/src/index.ts:108`](../../packages/host/directory-picker/src/index.ts) +Source: [`packages/host/directory-picker/src/index.ts:118`](../../packages/host/directory-picker/src/index.ts) ## `ctx.fs` — `FileSystem` (abstract seam) diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index a490f0d27d..fdc258beed 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -831,7 +831,9 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { if (children === undefined) { return err(request, { code: 'directory-create-failed', message: `missing parent ${parent}`, details: { path: parent } }) } - const target = `${parent}/${request.payload.name}` + // Same root special case as listDirectory's entry paths: a plain join + // under '/' would mint '//name' and fork the tree's identity. + const target = parent === '/' ? `/${request.payload.name}` : `${parent}/${request.payload.name}` if (children.includes(request.payload.name)) { return err(request, { code: 'directory-exists', message: `${target} already exists`, details: { path: target } }) } diff --git a/packages/client/connection/tests/fixture.spec.ts b/packages/client/connection/tests/fixture.spec.ts index 1b413441df..3f07922f4d 100644 --- a/packages/client/connection/tests/fixture.spec.ts +++ b/packages/client/connection/tests/fixture.spec.ts @@ -312,6 +312,22 @@ describe('createFixtureApi', () => { expect(empty.result).toMatchObject({ ok: true, value: { attachedSessions: 0 } }) }) + it('createDirectory under the root mints /name whose listing and crumbs share the identity', async () => { + const api = createFixtureApi() + const created = await api.host.createDirectory(req({ path: '/', name: 'srv' })) + if (!created.result.ok) throw new Error('create failed') + expect(created.result.value.path).toBe('/srv') + const listed = await api.host.listDirectory(req({ path: '/srv' })) + if (!listed.result.ok) throw new Error('list failed') + expect(listed.result.value.crumbs).toEqual([ + { name: '/', path: '/', hidden: false }, + { name: 'srv', path: '/srv', hidden: false }, + ]) + const root = await api.host.listDirectory(req({ path: '/' })) + if (!root.result.ok) throw new Error('root list failed') + expect(root.result.value.entries).toContainEqual({ name: 'srv', path: '/srv', hidden: false }) + }) + it('workspace.list serves the resident account and create reuses on path collision', async () => { const api = createFixtureApi() const listed = await api.workspace.list(req({})) diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 04b2e60ef0..698dd32201 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -1603,9 +1603,13 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'DirectoryPickerBrowseCapability', declaration: 'export interface DirectoryPickerBrowseCapability {\n kind: \'browse\';\n list(path?: string): Promise;\n createDirectory(path: string, name: string): Promise;\n}', }, + { + name: 'DirectoryPickerCapabilities', + declaration: 'export interface DirectoryPickerCapabilities {\n dialog: DirectoryPickerDialogCapability;\n browse: DirectoryPickerBrowseCapability;\n}', + }, { name: 'DirectoryPickerCapability', - declaration: 'export type DirectoryPickerCapability = DirectoryPickerDialogCapability | DirectoryPickerBrowseCapability;', + declaration: 'export type DirectoryPickerCapability = DirectoryPickerCapabilities[keyof DirectoryPickerCapabilities];', }, { name: 'DirectoryPickerDialogCapability', diff --git a/packages/host/apiproxy/src/api/host.schema.ts b/packages/host/apiproxy/src/api/host.schema.ts index a73b659970..d4a3a95754 100644 --- a/packages/host/apiproxy/src/api/host.schema.ts +++ b/packages/host/apiproxy/src/api/host.schema.ts @@ -17,7 +17,9 @@ export const hostDescribeValueSchema = z.object({ provider: z.string().optional(), model: z.string().optional(), attachedSessions: z.number().int().nonnegative(), - directoryPicker: z.union([z.literal('dialog'), z.literal('browse')]), + // Open string, not a literal union: unknown kinds must survive the wire so + // a merge-added capability can advertise (the client hides the affordance). + directoryPicker: z.string(), }) satisfies z.ZodType>> /** host.pickDirectory request payload (empty object literal). */ diff --git a/packages/host/apiproxy/src/api/host.ts b/packages/host/apiproxy/src/api/host.ts index abdb3c468b..3de58b7bc7 100644 --- a/packages/host/apiproxy/src/api/host.ts +++ b/packages/host/apiproxy/src/api/host.ts @@ -11,8 +11,11 @@ import type { RpcRequest, RpcResponse } from './rpc.ts' * the host display (`host.pickDirectory`); `browse` = in-app listing/creation * primitives (`host.listDirectory`/`host.createDirectory`). Calling a method * outside the advertised kind fails with `directory-picker-unavailable`. + * The wire preserves kinds beyond the two with methods here (a merge-added + * capability advertises before its RPCs exist); the client's documented + * default for a kind it does not recognize is to hide the picking affordance. */ -export type DirectoryPickerKind = 'dialog' | 'browse' +export type DirectoryPickerKind = 'dialog' | 'browse' | (string & {}) /** One directory row of a listing: a child entry or a breadcrumb ancestor. */ export interface DirectoryEntry { diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index f64883c85b..939de7677e 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -211,7 +211,9 @@ describe('host domain schemas', () => { const value = hostDescribeValueSchema.parse({ version: '1', cwd: '/x', provider: 'p', model: 'm', attachedSessions: 2, directoryPicker: 'dialog' }) expect(value.attachedSessions).toBe(2) expect(hostDescribeValueSchema.parse({ version: '1', cwd: '/x', attachedSessions: 0, directoryPicker: 'browse' }).provider).toBeUndefined() - expect(() => hostDescribeValueSchema.parse({ version: '1', cwd: '/x', attachedSessions: 0, directoryPicker: 'other' })).toThrow() + // A kind beyond the two with methods survives the wire (merge-added + // capabilities advertise; the client hides the affordance). + expect(hostDescribeValueSchema.parse({ version: '1', cwd: '/x', attachedSessions: 0, directoryPicker: 'other' }).directoryPicker).toBe('other') }) it('validates the browse listing/creation payloads', () => { diff --git a/packages/host/directory-picker/README.i18n.yaml b/packages/host/directory-picker/README.i18n.yaml index 0d5c753e92..a331a2caeb 100644 --- a/packages/host/directory-picker/README.i18n.yaml +++ b/packages/host/directory-picker/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/directory-picker/README.md -README.md: c1a801cf72f128e6e5ef668c5e28e03ad2284868 -README.zh.md: c352b35b70dfa835aecfcb5ffec2a9ac46f25c42 +README.md: 0332f7df067bfa79c7505be948554e814a690c6c +README.zh.md: a9a782019d0badfba33a7d108113ff5323fde77a diff --git a/packages/host/directory-picker/README.md b/packages/host/directory-picker/README.md index c1a801cf72..0332f7df06 100644 --- a/packages/host/directory-picker/README.md +++ b/packages/host/directory-picker/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The **workspace-directory picking seam** for the web-GUI host: an abstract `DirectoryPicker` service (`ctx.directoryPicker`) whose single contract method `capability()` returns a discriminated capability describing how an operator selects a directory. Backends differ in interaction shape, not just mechanism, so the seam models the shapes explicitly instead of one method set: `{ kind: 'dialog', pick(signal) }` opens one native OS chooser on the host display ([`-dialog`](../directory-picker-dialog/README.md)); `{ kind: 'browse', list(path?), createDirectory(path, name) }` serves listing/creation primitives an in-app browser drives, which works for remote clients no OS dialog can reach ([`-browse`](../directory-picker-browse/README.md)). Consumers switch on `capability().kind`; the union is merge-extensible and the documented default for an unknown kind is to hide the picking affordance rather than fail. The capability object must be stable for the service lifetime. +The **workspace-directory picking seam** for the web-GUI host: an abstract `DirectoryPicker` service (`ctx.directoryPicker`) whose single contract method `capability()` returns a discriminated capability describing how an operator selects a directory. Backends differ in interaction shape, not just mechanism, so the seam models the shapes explicitly instead of one method set: `{ kind: 'dialog', pick(signal) }` opens one native OS chooser on the host display ([`-dialog`](../directory-picker-dialog/README.md)); `{ kind: 'browse', list(path?), createDirectory(path, name) }` serves listing/creation primitives an in-app browser drives, which works for remote clients no OS dialog can reach ([`-browse`](../directory-picker-browse/README.md)). Consumers switch on `capability().kind`; the union derives from the merge-extensible `DirectoryPickerCapabilities` map (a new backend declaration-merges its shape there), and the documented default for an unknown kind is to hide the picking affordance rather than fail. The capability object must be stable for the service lifetime. Browse primitives fail with the typed `DirectoryPickerError` (`directory-unreadable` / `directory-exists` / `directory-create-failed`, each carrying the subject `path`), which the consuming gateway maps 1:1 onto wire error codes. `DirectoryEntry` rows carry a host-owned `hidden` flag (POSIX dot convention) so display policy stays client-side; `DirectoryListing.crumbs` is the ancestor chain from the filesystem root, every crumb a jump target. Design rationale, the `ctx.fs` separation, and the policy decisions live in [the directory-picker capability seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md). diff --git a/packages/host/directory-picker/README.zh.md b/packages/host/directory-picker/README.zh.md index c352b35b70..a9a782019d 100644 --- a/packages/host/directory-picker/README.zh.md +++ b/packages/host/directory-picker/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -web GUI 宿主的**工作区目录选择 seam**:抽象服务 `DirectoryPicker`(`ctx.directoryPicker`),唯一契约方法 `capability()` 返回一个可辨识能力对象,描述操作者以何种方式选择目录。后端之间的差异在交互形态而不只是机制,因此 seam 显式建模形态而非统一方法集:`{ kind: 'dialog', pick(signal) }` 在宿主屏幕上打开一个原生 OS 选择器([`-dialog`](../directory-picker-dialog/README.md));`{ kind: 'browse', list(path?), createDirectory(path, name) }` 提供应用内浏览器驱动的列举/创建原语,可服务任何 OS 对话框都触及不到的远程客户端([`-browse`](../directory-picker-browse/README.md))。消费方按 `capability().kind` 分支;联合类型可合并扩展,未知 kind 的文档化默认行为是隐藏选择入口而非失败。能力对象在服务生命周期内必须保持稳定。 +web GUI 宿主的**工作区目录选择 seam**:抽象服务 `DirectoryPicker`(`ctx.directoryPicker`),唯一契约方法 `capability()` 返回一个可辨识能力对象,描述操作者以何种方式选择目录。后端之间的差异在交互形态而不只是机制,因此 seam 显式建模形态而非统一方法集:`{ kind: 'dialog', pick(signal) }` 在宿主屏幕上打开一个原生 OS 选择器([`-dialog`](../directory-picker-dialog/README.md));`{ kind: 'browse', list(path?), createDirectory(path, name) }` 提供应用内浏览器驱动的列举/创建原语,可服务任何 OS 对话框都触及不到的远程客户端([`-browse`](../directory-picker-browse/README.md))。消费方按 `capability().kind` 分支;联合类型由可合并扩展的 `DirectoryPickerCapabilities` 映射派生(新后端在其中声明合并自己的形态),未知 kind 的文档化默认行为是隐藏选择入口而非失败。能力对象在服务生命周期内必须保持稳定。 浏览原语以带类型的 `DirectoryPickerError` 失败(`directory-unreadable`/`directory-exists`/`directory-create-failed`,各自携带主体 `path`),消费网关将其 1:1 映射为协议错误码。`DirectoryEntry` 行携带宿主判定的 `hidden` 标志(POSIX 点前缀约定),展示策略留在客户端;`DirectoryListing.crumbs` 是从文件系统根开始的祖先链,每个 crumb 都是跳转目标。设计依据、与 `ctx.fs` 的切分、策略裁决见[目录选择能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md)。 diff --git a/packages/host/directory-picker/src/index.ts b/packages/host/directory-picker/src/index.ts index 5e184b36e1..5a6040e51f 100644 --- a/packages/host/directory-picker/src/index.ts +++ b/packages/host/directory-picker/src/index.ts @@ -73,8 +73,18 @@ export interface DirectoryPickerBrowseCapability { createDirectory(path: string, name: string): Promise } -/** Union of interaction shapes a backend can provide (merge-extensible: grows with backends). */ -export type DirectoryPickerCapability = DirectoryPickerDialogCapability | DirectoryPickerBrowseCapability +/** + * Merge-extensible registry of interaction shapes keyed by capability kind: a + * new backend declaration-merges its shape here (the entry's `kind` literal + * must equal its key) instead of editing this package. + */ +export interface DirectoryPickerCapabilities { + dialog: DirectoryPickerDialogCapability + browse: DirectoryPickerBrowseCapability +} + +/** Union of interaction shapes a backend can provide, derived from the merge-extensible {@link DirectoryPickerCapabilities} map. */ +export type DirectoryPickerCapability = DirectoryPickerCapabilities[keyof DirectoryPickerCapabilities] /** Closed failure vocabulary of the browse primitives (mirrored onto the wire by consumers). */ export type DirectoryPickerErrorCode = 'directory-unreadable' | 'directory-exists' | 'directory-create-failed'