Merge pull request #4200 from deepseek-harness/yyc/web-directory-picker-reviewed

fix(web): use in-page directory picker for trusted remote hosts
This commit is contained in:
Yichen Yan authored and GitHub committed 2026-10-08 18:25:38 +08:00
commit d394af8ee8
25 files changed
+147 -62

No files matched your search

@@ -9,8 +9,8 @@
en: c25db2ef111b2276
zh: 557891ca9e030ae5
/agent-note-one-carrier-level-browser-trust-boundary-for-all-api-routes/decision:
en: 1a1af1e8125ba4b0
zh: dd6c282651a06af5
en: 0bfb5ba20be135a5
zh: 447e7d88a9ec0601
/agent-note-one-carrier-level-browser-trust-boundary-for-all-api-routes/alternatives-considered:
en: 1a4b0ae8072e8e50
zh: d469553ad3b5d112
@@ -17,6 +17,8 @@ Enforce browser trust once, at the carrier, for the entire `/api` prefix — two
Reachability is the webserver binding's policy (`host: 127.0.0.1 | 0.0.0.0`), and this fence is a confused-deputy defense rather than identity. Connection applies the separate [browser token authentication](2026-08-24-browser-token-authentication.md) after the fence. The fence does not inspect peer socket addresses: binding expresses reachability, `trustedHosts` names accepted authorities, and the socket address adds nothing the Host/Origin checks need.
`HostConnectionHandle.allowsRemoteAuthorities` projects whether any validated `trustedHosts` entry names a non-loopback hostname: a loopback listener behind a proxy can still serve remote browsers, so the directory picker requires a policy without remote authorities before it picks the native chooser.
## 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.
@@ -17,6 +17,8 @@ Web GUI 宿主以纯 loopback HTTP 提供 `/api`(默认 `127.0.0.1:3080`;CLI
可达性由 webserver 的绑定配置(`host: 127.0.0.1 | 0.0.0.0`)控制,这道栅栏是混淆代理人防御,而不是身份。Connection 在栅栏之后应用独立的[浏览器令牌认证](2026-08-24-browser-token-authentication.zh.md)。栅栏不检查对端 socket 地址:绑定表达可达性,`trustedHosts` 点名接受的 authority,socket 地址提供不了 Host/Origin 校验需要的额外信息。
`HostConnectionHandle.allowsRemoteAuthorities` 表示是否有通过校验的 `trustedHosts` 条目命名非回环主机名:代理后的回环监听器也能服务远程浏览器,因此目录选择器要求策略不接纳远程 authority,才会选择原生交互。
## 曾考虑的替代方案
- **按 RPC 设防(延续现状)。** 否决:守卫清单永远追着方法清单跑,价值最高的方法本来就没被守住,而 browse RPC 上的回环规则会破坏它们为之存在的远程部署。
+1 -1
View File
@@ -4515,7 +4515,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
| `@deepseek-ai/dsh-fs-observation-policy` | — | [`packages/fs/fs-observation-policy/src/index.ts`](../packages/fs/fs-observation-policy/src/index.ts) |
| `@deepseek-ai/dsh-fs-ssh` | `ssh` · `sandboxPolicy` | [`packages/ssh/fs-ssh/src/index.ts`](../packages/ssh/fs-ssh/src/index.ts) |
| `@deepseek-ai/dsh-goal-round-driver` | `agents` · `goals` · `sessions` | [`packages/goal/goal-round-driver/src/index.ts`](../packages/goal/goal-round-driver/src/index.ts) |
| `@deepseek-ai/dsh-host-directory-picker-auto` | `webServer` · `loader` | [`packages/host/directory-picker-auto/src/index.ts`](../packages/host/directory-picker-auto/src/index.ts) |
| `@deepseek-ai/dsh-host-directory-picker-auto` | `webServer` · `connection` · `loader` | [`packages/host/directory-picker-auto/src/index.ts`](../packages/host/directory-picker-auto/src/index.ts) |
| `@deepseek-ai/dsh-host-directory-picker-native` | — | [`packages/host/directory-picker-native/src/index.ts`](../packages/host/directory-picker-native/src/index.ts) |
| `@deepseek-ai/dsh-host-plugin-inventory` | `loader` | [`packages/host/plugin-inventory/src/index.ts`](../packages/host/plugin-inventory/src/index.ts) |
| `@deepseek-ai/dsh-llm` | — | [`packages/llm/llm/src/index.ts`](../packages/llm/llm/src/index.ts) |
+1 -1
View File
@@ -4517,7 +4517,7 @@ export interface Config {
| `@deepseek-ai/dsh-fs-observation-policy` | — | [`packages/fs/fs-observation-policy/src/index.ts`](../packages/fs/fs-observation-policy/src/index.ts) |
| `@deepseek-ai/dsh-fs-ssh` | `ssh` · `sandboxPolicy` | [`packages/ssh/fs-ssh/src/index.ts`](../packages/ssh/fs-ssh/src/index.ts) |
| `@deepseek-ai/dsh-goal-round-driver` | `agents` · `goals` · `sessions` | [`packages/goal/goal-round-driver/src/index.ts`](../packages/goal/goal-round-driver/src/index.ts) |
| `@deepseek-ai/dsh-host-directory-picker-auto` | `webServer` · `loader` | [`packages/host/directory-picker-auto/src/index.ts`](../packages/host/directory-picker-auto/src/index.ts) |
| `@deepseek-ai/dsh-host-directory-picker-auto` | `webServer` · `connection` · `loader` | [`packages/host/directory-picker-auto/src/index.ts`](../packages/host/directory-picker-auto/src/index.ts) |
| `@deepseek-ai/dsh-host-directory-picker-native` | — | [`packages/host/directory-picker-native/src/index.ts`](../packages/host/directory-picker-native/src/index.ts) |
| `@deepseek-ai/dsh-host-plugin-inventory` | `loader` | [`packages/host/plugin-inventory/src/index.ts`](../packages/host/plugin-inventory/src/index.ts) |
| `@deepseek-ai/dsh-llm` | — | [`packages/llm/llm/src/index.ts`](../packages/llm/llm/src/index.ts) |
+7 -6
View File
@@ -458,11 +458,6 @@ flowchart TD
pkg_credentials_local --> pkg_credentials
pkg_credentials_local --> pkg_home_paths
pkg_credentials_local --> pkg_launch_environment
pkg_host_directory_picker_auto --> pkg_client_ui_directory_picker_browse
pkg_host_directory_picker_auto --> pkg_client_ui_directory_picker_native
pkg_host_directory_picker_auto --> pkg_host_directory_picker_browse
pkg_host_directory_picker_auto --> pkg_host_directory_picker_native
pkg_host_directory_picker_auto --> pkg_host_webserver
pkg_anonymous_user_id --> pkg_brand
pkg_anonymous_user_id --> pkg_home_paths
pkg_lsp --> pkg_brand
@@ -499,6 +494,12 @@ flowchart TD
pkg_experimental_webworker_runtime --> pkg_client_modules
pkg_experimental_webworker_runtime --> pkg_host_webserver
pkg_skill_badge --> pkg_skill
pkg_host_directory_picker_auto --> pkg_client_connection
pkg_host_directory_picker_auto --> pkg_client_ui_directory_picker_browse
pkg_host_directory_picker_auto --> pkg_client_ui_directory_picker_native
pkg_host_directory_picker_auto --> pkg_host_directory_picker_browse
pkg_host_directory_picker_auto --> pkg_host_directory_picker_native
pkg_host_directory_picker_auto --> pkg_host_webserver
pkg_host_frontend_static --> pkg_client_connection
pkg_host_frontend_static --> pkg_host_webserver
pkg_persona --> pkg_system_prompt
@@ -1562,7 +1563,6 @@ flowchart TD
| [`computer-use`](../packages/computer-use/computer-use) | `computer-use` | [`brand`](../packages/util/brand) |
| [`authorization`](../packages/credentials/authorization) | `credentials` | [`credentials`](../packages/credentials/credentials), [`llm`](../packages/llm/llm) |
| [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`home-paths`](../packages/util/home-paths), [`launch-environment`](../packages/util/launch-environment) |
| [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse), [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native), [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver) |
| [`anonymous-user-id`](../packages/identity/anonymous-user-id) | `identity` | [`brand`](../packages/util/brand), [`home-paths`](../packages/util/home-paths) |
| [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm) |
| [`storage-domain`](../packages/storage/storage-domain) | `storage` | [`storage`](../packages/storage/storage) |
@@ -1584,6 +1584,7 @@ flowchart TD
| [`experimental-inspector`](../packages/experimental/inspector) | `experimental` | [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver) |
| [`experimental-webworker-runtime`](../packages/experimental/webworker-runtime) | `experimental` | [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver) |
| [`skill-badge`](../packages/experimental/skill-badge) | `experimental` | [`skill`](../packages/skill/skill) |
| [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`client-connection`](../packages/client/connection), [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse), [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native), [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver) |
| [`host-frontend-static`](../packages/host/frontend-static) | `host` | [`client-connection`](../packages/client/connection), [`host-webserver`](../packages/host/webserver) |
| [`persona`](../packages/preset/persona) | `preset` | [`system-prompt`](../packages/core/system-prompt) |
| [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
+7 -6
View File
@@ -460,11 +460,6 @@ flowchart TD
pkg_credentials_local --> pkg_credentials
pkg_credentials_local --> pkg_home_paths
pkg_credentials_local --> pkg_launch_environment
pkg_host_directory_picker_auto --> pkg_client_ui_directory_picker_browse
pkg_host_directory_picker_auto --> pkg_client_ui_directory_picker_native
pkg_host_directory_picker_auto --> pkg_host_directory_picker_browse
pkg_host_directory_picker_auto --> pkg_host_directory_picker_native
pkg_host_directory_picker_auto --> pkg_host_webserver
pkg_anonymous_user_id --> pkg_brand
pkg_anonymous_user_id --> pkg_home_paths
pkg_lsp --> pkg_brand
@@ -501,6 +496,12 @@ flowchart TD
pkg_experimental_webworker_runtime --> pkg_client_modules
pkg_experimental_webworker_runtime --> pkg_host_webserver
pkg_skill_badge --> pkg_skill
pkg_host_directory_picker_auto --> pkg_client_connection
pkg_host_directory_picker_auto --> pkg_client_ui_directory_picker_browse
pkg_host_directory_picker_auto --> pkg_client_ui_directory_picker_native
pkg_host_directory_picker_auto --> pkg_host_directory_picker_browse
pkg_host_directory_picker_auto --> pkg_host_directory_picker_native
pkg_host_directory_picker_auto --> pkg_host_webserver
pkg_host_frontend_static --> pkg_client_connection
pkg_host_frontend_static --> pkg_host_webserver
pkg_persona --> pkg_system_prompt
@@ -1564,7 +1565,6 @@ flowchart TD
| [`computer-use`](../packages/computer-use/computer-use) | `computer-use` | [`brand`](../packages/util/brand) |
| [`authorization`](../packages/credentials/authorization) | `credentials` | [`credentials`](../packages/credentials/credentials), [`llm`](../packages/llm/llm) |
| [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`home-paths`](../packages/util/home-paths), [`launch-environment`](../packages/util/launch-environment) |
| [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse), [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native), [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver) |
| [`anonymous-user-id`](../packages/identity/anonymous-user-id) | `identity` | [`brand`](../packages/util/brand), [`home-paths`](../packages/util/home-paths) |
| [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm) |
| [`storage-domain`](../packages/storage/storage-domain) | `storage` | [`storage`](../packages/storage/storage) |
@@ -1586,6 +1586,7 @@ flowchart TD
| [`experimental-inspector`](../packages/experimental/inspector) | `experimental` | [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver) |
| [`experimental-webworker-runtime`](../packages/experimental/webworker-runtime) | `experimental` | [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver) |
| [`skill-badge`](../packages/experimental/skill-badge) | `experimental` | [`skill`](../packages/skill/skill) |
| [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`client-connection`](../packages/client/connection), [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse), [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native), [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver) |
| [`host-frontend-static`](../packages/host/frontend-static) | `host` | [`client-connection`](../packages/client/connection), [`host-webserver`](../packages/host/webserver) |
| [`persona`](../packages/preset/persona) | `preset` | [`system-prompt`](../packages/core/system-prompt) |
| [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
+2 -2
View File
@@ -18,8 +18,8 @@
en: 13b54969ff1d00a4
zh: 4d2ffff0445472ed
/deepseek-ai-dsh-client-connection/browser-authentication-and-request-trust:
en: ab52ef146d0b5b42
zh: 6314d50cfe5683e1
en: 29aba8e9c02bd273
zh: 24cb7cc5769f0304
/deepseek-ai-dsh-client-connection/connection-generation:
en: 116f94e44c0d87e7
zh: 59ef48984154d858
+1 -1
View File
@@ -40,7 +40,7 @@ Every Host RPC method and WebSocket stream requires one browser session; there i
The cookie signing secret is the owner-scoped `client-connection/browser-session` grant record in `ctx.credentials`. The local provider persists it in `$DSH_HOME/.credentials.yaml`; `BrowserAuth` loads or creates the record during Connection activation and retains the secret in memory, so request authentication is synchronous. Deleting or replacing the record takes effect on the next Connection activation. Cookies carry an absolute issue/expiry interval, defaulting to 30 days through `cookieMaxAgeDays`, and bind the normalized hostname plus port in both their deterministic name and signed payload. They are host-only, `Path=/`, `HttpOnly`, and `SameSite=Strict`; they deliberately omit `Secure` because the shipped server uses loopback HTTP.
Before authentication, every request still passes `src/api-request-trust.ts`. Its `Host` must be loopback or match a `trustedHosts` entry: exact on `host:port`, any port on port-less entries, both sides WHATWG-normalized. An attached `Origin` must equal that Host and `sec-fetch-site: cross-site` is refused. Malformed configured authorities fail plugin load. These checks defend DNS rebinding and cross-site browser requests; they never establish identity. A failed Host/Origin check returns 403, while a trusted but unauthenticated request returns 401. `dsh web --host 0.0.0.0` remains unsupported. Decision records: [browser request trust](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md) and [browser token authentication](../../../.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.md).
Before authentication, every request still passes `src/api-request-trust.ts`. Its `Host` must be loopback or match a `trustedHosts` entry: exact on `host:port`, any port on port-less entries, both sides WHATWG-normalized. An attached `Origin` must equal that Host and `sec-fetch-site: cross-site` is refused. Malformed configured authorities fail plugin load. `allowsRemoteAuthorities` reports whether `trustedHosts` permits a non-loopback authority. These checks defend DNS rebinding and cross-site browser requests; they never establish identity. A failed Host/Origin check returns 403, while a trusted but unauthenticated request returns 401. `dsh web --host 0.0.0.0` remains unsupported. Decision records: [browser request trust](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md) and [browser token authentication](../../../.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.md).
Every admitted request speaks for one Peer, the operator. `ctx.connection.operator` is that `PeerScope`: its `ctx` is a Cordis scope that owns connection-lifetime registrations and is disposed with the Connection. `ctx.connection.admit(request)` runs the trust and authentication checks and answers with the rejection status or the operator; the `/api` route and the Gateway's WebSocket upgrade admit through it, and every RPC handler receives the Peer of its call. `OperatorPeer` is exported so a composition without Connection, such as the Gateway's in-process carrier, owns an operator scope with the same contract.
+1 -1
View File
@@ -40,7 +40,7 @@ kind: "package-reference"
cookie 签名密钥是 `ctx.credentials` 中由 `client-connection/browser-session` 拥有的 grant 记录。本地提供方把它持久化到 `$DSH_HOME/.credentials.yaml`;`BrowserAuth` 在 Connection 激活期间加载或创建该记录,并把密钥留在内存中,因此请求认证同步执行。删除或替换该记录会在下一次 Connection 激活时生效。cookie 携带绝对签发与过期区间,`cookieMaxAgeDays` 默认设为 30 天,并在确定性名称与签名 payload 中同时绑定规范化 hostname 和 port。它是 host-only、`Path=/`、`HttpOnly`、`SameSite=Strict`;随附服务器使用 loopback HTTP,因此刻意不设置 `Secure`。
认证之前,每个请求仍经过 `src/api-request-trust.ts`。其 `Host` 必须是 loopback,或与 `trustedHosts` 条目匹配:带端口的 `host:port` 精确匹配,不带端口的条目匹配任意端口,两侧均经 WHATWG 归一化。若附带 `Origin`,它必须等于该 Host;`sec-fetch-site: cross-site` 一律拒绝。畸形配置 authority 会让插件加载失败。这些检查防御 DNS rebinding 与跨站浏览器请求,绝不建立身份。Host/Origin 校验失败返回 403;Host 可信但未认证的请求返回 401。`dsh web --host 0.0.0.0` 仍不受支持。决策记录:[浏览器请求信任](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.zh.md)与[浏览器令牌认证](../../../.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.zh.md)。
认证之前,每个请求仍经过 `src/api-request-trust.ts`。其 `Host` 必须是 loopback,或与 `trustedHosts` 条目匹配:带端口的 `host:port` 精确匹配,不带端口的条目匹配任意端口,两侧均经 WHATWG 归一化。若附带 `Origin`,它必须等于该 Host;`sec-fetch-site: cross-site` 一律拒绝。畸形配置 authority 会让插件加载失败。`allowsRemoteAuthorities` 报告 `trustedHosts` 是否允许非 loopback authority。这些检查防御 DNS rebinding 与跨站浏览器请求,绝不建立身份。Host/Origin 校验失败返回 403;Host 可信但未认证的请求返回 401。`dsh web --host 0.0.0.0` 仍不受支持。决策记录:[浏览器请求信任](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.zh.md)与[浏览器令牌认证](../../../.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.zh.md)。
每个被接纳的请求都代表同一个 Peer——操作者。`ctx.connection.operator` 就是这个 `PeerScope`:其 `ctx` 是拥有连接期注册的 Cordis scope,随 Connection 一起释放。`ctx.connection.admit(request)` 执行信任与认证检查,以拒绝状态或操作者作答;`/api` 路由与 Gateway 的 WebSocket 升级都经它接纳,每个 RPC 处理器都收到本次调用的 Peer。`OperatorPeer` 对外导出,供没有 Connection 的组合(例如 Gateway 的进程内载体)以同一约定拥有一个操作者 scope。
@@ -82,6 +82,16 @@ function isTrustedAuthority(hostUrl: URL, trustedHosts: readonly string[]): bool
})
}
/**
* Classify an authority already accepted by {@link assertTrustedAuthority}.
* @param entry - the configured authority, verbatim.
* @returns true unless the entry parses as a loopback hostname.
*/
export function isRemoteAuthority(entry: string): boolean {
const url = parseAuthority(entry)
return url === undefined || !isLoopbackHostname(url.hostname)
}
/**
* Decide whether one /api request may reach the RPC bridge.
* @param request - Node HTTP or Fetch request facts (headers).
+4 -1
View File
@@ -10,7 +10,7 @@ import {
} from './rpc.ts'
import { clientRequestSchema } from './rpc-schema.ts'
import { bridge } from './http-bridge.ts'
import { isTrustedApiRequest } from './api-request-trust.ts'
import { isRemoteAuthority, isTrustedApiRequest } from './api-request-trust.ts'
import { API_PATH } from './api-path.ts'
import type { BrowserAuth } from './browser-auth.ts'
import { OperatorPeer } from './operator-peer.ts'
@@ -66,6 +66,8 @@ export class HostConnectionService extends Service implements HostConnectionHand
private readonly interceptors = new Map<string, ConnectionRpcInterceptor>()
private readonly fetchRoutes = new Map<string, RegisteredFetchRoute>()
readonly allowsRemoteAuthorities: boolean
/**
* Provide the Host half over the active HTTP server.
* @param ctx - owning Connection plugin context.
@@ -80,6 +82,7 @@ export class HostConnectionService extends Service implements HostConnectionHand
super(ctx, 'connection')
this.operator = new OperatorPeer(ctx)
ctx.effect(() => () => this.operator.dispose(), 'client-connection: operator Peer')
this.allowsRemoteAuthorities = trustedHosts.some(isRemoteAuthority)
}
/** Generic channel registry scoped to the Context reading this service. */
+6
View File
@@ -198,6 +198,12 @@ export interface HostConnectionHandle {
/** The operator Peer every admitted request speaks for; its scope lives as long as Connection. */
readonly operator: PeerScope
/**
* Whether this deployment accepts authorities beyond loopback: true only
* when a validated `trustedHosts` entry names a non-loopback hostname.
*/
readonly allowsRemoteAuthorities: boolean
/**
* Compose exact Fetch routes and the shared-channel RPC interceptor.
* @param channel - shared channel mounted by Connection.
@@ -223,6 +223,22 @@ describe('connection node half', () => {
expect(ctx.get('connection')).toBeUndefined()
})
it('reports allowsRemoteAuthorities from validated trustedHosts only', async () => {
const cases: { trustedHosts: string[]; expected: boolean }[] = [
{ trustedHosts: ['127.0.0.1', 'localhost', '[::1]'], expected: false },
{ trustedHosts: ['harness.example'], expected: true },
{ trustedHosts: ['127.0.0.1', 'harness.example'], expected: true },
]
for (const { trustedHosts, expected } of cases) {
const deployment = await mounted({ trustedHosts })
try {
expect(deployment.connection.allowsRemoteAuthorities, trustedHosts.join(',')).toBe(expected)
} finally {
await deployment.dispose()
}
}
})
it('reserves enough default carrier capacity for the 200 MiB image batch', () => {
expect(DEFAULT_MAX_REQUEST_BODY_BYTES).toBe(300 * 1024 * 1024)
expect(DEFAULT_MAX_REQUEST_BODY_BYTES).toBeGreaterThan(Math.ceil(200 * 1024 * 1024 * 4 / 3) + 1024 * 1024)
@@ -758,6 +758,11 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
description: 'The operator Peer every admitted request speaks for; its scope lives as long as Connection.',
parameters: [],
},
{
signature: 'readonly allowsRemoteAuthorities: boolean',
description: 'Whether this deployment accepts authorities beyond loopback: true only when a validated `trustedHosts` entry names a non-loopback hostname.',
parameters: [],
},
{
signature: 'createSharedFetchHandler(channel: \'/api\'): ConnectionFetchHandler',
description: 'Compose exact Fetch routes and the shared-channel RPC interceptor.',
@@ -9,8 +9,8 @@
en: 919d44a5781ac8be
zh: 0e7a812a5af49125
/deepseek-ai-dsh-host-directory-picker-auto/summary:
en: e50d48e63294e9c5
zh: b53e3aeb1fa4b892
en: 3ff3bda883cccfc0
zh: a01af7bac5580514
/deepseek-ai-dsh-host-directory-picker-auto/table-of-contents:
en: d152484eb41ac6b4
zh: 09388d293f9be9cb
@@ -18,8 +18,8 @@
en: 9ed11c3e0a03da01
zh: ffa5e373dfc99bb8
/deepseek-ai-dsh-host-directory-picker-auto/use-this-package/how-the-choice-is-made:
en: b7705f6a53ed31f3
zh: 561d3b42170a513a
en: 8489d65845345ada
zh: d0b1f92cfa6aba9b
/deepseek-ai-dsh-host-directory-picker-auto/use-this-package/what-you-get:
en: 6571c17daecc1e29
zh: d70089d67e41a32e
@@ -36,8 +36,8 @@
en: abc8d3962e4580c3
zh: 4442dde69faf680f
/deepseek-ai-dsh-host-directory-picker-auto/understand-the-implementation/the-resolution-table:
en: 70aa594193336a2a
zh: 9ff39c2878e8f471
en: c08256b335ad92cf
zh: 1cbb1357bc9d649b
/deepseek-ai-dsh-host-directory-picker-auto/understand-the-implementation/source-map:
en: 43a0268a54380a72
zh: a8f8cd0c3f502e6b
@@ -51,8 +51,8 @@
en: ca75c51c89c2c9b0
zh: de54f3467ef3b9be
/deepseek-ai-dsh-host-directory-picker-auto/known-limitations-and-deferred-work:
en: 2caf1f412c050856
zh: 0e71bc66bc54bbb4
en: 1ae2375b3be24d5f
zh: 6bc8d84a5612bd75
/deepseek-ai-dsh-host-directory-picker-auto/known-limitations-and-deferred-work/dev-note:
en: e67f633dc6fe7773
zh: 3bb0c20c93f16f98
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
## Summary
`dsh-host-directory-picker-auto` picks the right directory-picking interaction for every boot: it resolves the host's situation once at boot and mounts the matching backend — [native](../directory-picker-native/README.md) or [browse](../directory-picker-browse/README.md) — together with its browser half, as real Loader entries in the in-memory root tree. The resolution is one pure boot-time sample: `native` requires a loopback-only bind, a non-SSH launch, and a servable display session; anything ambiguous resolves to `browse`, which works everywhere. Pinning an interaction means composing that backend directly. The mounted capability stays stable for the service lifetime, as the seam requires.
`dsh-host-directory-picker-auto` picks the right directory-picking interaction for every boot: it resolves the host's situation once at boot and mounts the matching backend — [native](../directory-picker-native/README.md) or [browse](../directory-picker-browse/README.md) — together with its browser half, as real Loader entries in the in-memory root tree. The resolution is one pure boot-time sample: `native` requires no remote-authority trust, a loopback-only bind, a non-SSH launch, and a servable display session; anything ambiguous resolves to `browse`, which works everywhere. Pinning an interaction means composing that backend directly. The mounted capability stays stable for the service lifetime, as the seam requires.
## Table of Contents
@@ -29,7 +29,7 @@ Compose this plugin instead of a concrete backend when the same composition must
### How the choice is made
`native` requires every signal that the operator can see the host display and the native backend can serve it: a loopback-only bind (read from the injected `webServer`; an all-interfaces bind admits remote browsers no OS chooser can reach), no SSH launch (the shared [launch-environment](../../util/launch-environment/README.md) predicate ignores project/user `.env` values and checks only inherited non-empty `SSH_CONNECTION`/`SSH_TTY`), and a servable display session — assumed on darwin and win32; on linux, `DISPLAY`/`WAYLAND_DISPLAY` plus a zenity or kdialog binary on `PATH`; never on any other platform. Anything ambiguous resolves to `browse`, which works everywhere.
`native` requires every signal that the operator can see the host display and the native backend can serve it: no remote-authority trust (`allowsRemoteAuthorities` is false — every validated `trustedHosts` entry names a loopback hostname), a loopback-only bind (read from the injected `webServer`; remote-authority trust or a non-loopback bind admits remote browsers no OS chooser can reach), no SSH launch (the shared [launch-environment](../../util/launch-environment/README.md) predicate ignores project/user `.env` values and checks only inherited non-empty `SSH_CONNECTION`/`SSH_TTY`), and a servable display session — assumed on darwin and win32; on linux, `DISPLAY`/`WAYLAND_DISPLAY` plus a zenity or kdialog binary on `PATH`; never on any other platform. Anything ambiguous resolves to `browse`, which works everywhere.
### What you get
@@ -59,6 +59,7 @@ The chooser is a pure decision plus a mount: `resolveDirectoryPickerBackend` sam
| Condition | Backend |
|---|---|
| A `trustedHosts` entry admits a non-loopback authority | `browse` |
| Bind host is not `127.0.0.1` | `browse` |
| `SSH_CONNECTION` or `SSH_TTY` present | `browse` |
| darwin or win32 | `native` |
@@ -108,6 +109,7 @@ These limits define when the boot-time sample can misjudge the host. They are cu
- **Detection infers operator location from launch context, which no launch-side signal can prove** — a tmux session detached from its SSH launch loses the `SSH_*` markers; a Darwin process outside an Aqua session still counts as displayed; and a workstation-local launch later reached through `ssh -L` arrives from `127.0.0.1`, resolves `native`, and opens the chooser on the unattended workstation. A wrong `native` choice degrades to the backend's existing retryable failure dialog, and composing `-browse` directly selects the safe interaction for such deployments.
- **The Linux chooser probe reads `PATH` only** — a zenity/kdialog reachable some other way (shell alias, non-PATH install) still resolves `browse`; installing either binary on `PATH` restores `native` eligibility at the next boot.
- **Boot-time only** — one resolution serves every client of the boot; per-connection adaptivity (native for a local browser, browse for a remote one, same server) would need a per-client capability and the wire advertisement the seam does not carry, and waits for a deployment that serves both at once.
- **A loopback-presenting proxy hides a remote browser** — a reverse proxy that rewrites both the request `Host` and `Origin` to a loopback authority (say `127.0.0.1:3080`), with no non-loopback `trustedHosts` entry, passes the browser-trust fence, so a remote browser still resolves `native` and opens the chooser on the unattended host display.
<a id="dev-note"></a>
### Dev Note
@@ -9,7 +9,7 @@ kind: "package-reference"
## 概述
`dsh-host-directory-picker-auto` 为每次启动选出正确的目录选择交互:它在启动时一次性判定宿主处境,并把匹配的后端——[原生](../directory-picker-native/README.zh.md)或[浏览](../directory-picker-browse/README.zh.md)——连同其 browser 半侧一起,作为真实的 Loader 条目挂进内存根树。判定是一次纯函数的启动时采样:`native` 要求仅回环绑定、非 SSH 启动与可服务的显示会话;任何含糊情形都判定为处处可用的 `browse`。固定某种交互就是直接组合那个后端。挂载的能力在服务生命周期内保持稳定,符合 seam 的要求。
`dsh-host-directory-picker-auto` 为每次启动选出正确的目录选择交互:它在启动时一次性判定宿主处境,并把匹配的后端——[原生](../directory-picker-native/README.zh.md)或[浏览](../directory-picker-browse/README.zh.md)——连同其 browser 半侧一起,作为真实的 Loader 条目挂进内存根树。判定是一次纯函数的启动时采样:`native` 要求无远程 authority 信任、仅回环绑定、非 SSH 启动与可服务的显示会话;任何含糊情形都判定为处处可用的 `browse`。固定某种交互就是直接组合那个后端。挂载的能力在服务生命周期内保持稳定,符合 seam 的要求。
## 目录
@@ -29,7 +29,7 @@ kind: "package-reference"
### 选择是如何作出的
`native` 要求「操作者看得到宿主屏幕、且原生后端能服务它」的全部信号:仅回环的绑定(从注入的 `webServer` 读取;全网卡绑定会接入任何 OS 选择器都触及不到的远程浏览器);非 SSH 启动(共用的 [launch-environment](../../util/launch-environment/README.zh.md) 判断忽略项目与用户 `.env` 中的值,只检查继承的非空 `SSH_CONNECTION`/`SSH_TTY`);以及可服务的显示会话——darwin 与 win32 上视为存在;linux 上要求 `DISPLAY`/`WAYLAND_DISPLAY`,外加 `PATH` 上有 zenity 或 kdialog 二进制;其余任何平台上都不成立。任何含糊情形都判定为处处可用的 `browse`。
`native` 要求「操作者看得到宿主屏幕、且原生后端能服务它」的全部信号:无远程 authority 信任(`allowsRemoteAuthorities` 为 false——每条通过校验的 `trustedHosts` 条目都只命名回环主机名);仅回环的绑定(从注入的 `webServer` 读取;远程 authority 信任或非回环绑定都会接入任何 OS 选择器都触及不到的远程浏览器);非 SSH 启动(共用的 [launch-environment](../../util/launch-environment/README.zh.md) 判断忽略项目与用户 `.env` 中的值,只检查继承的非空 `SSH_CONNECTION`/`SSH_TTY`);以及可服务的显示会话——darwin 与 win32 上视为存在;linux 上要求 `DISPLAY`/`WAYLAND_DISPLAY`,外加 `PATH` 上有 zenity 或 kdialog 二进制;其余任何平台上都不成立。任何含糊情形都判定为处处可用的 `browse`。
### 你会得到什么
@@ -59,6 +59,7 @@ kind: "package-reference"
| 条件 | 后端 |
|---|---|
| 某条 `trustedHosts` 条目信任非回环 authority | `browse` |
| 绑定宿主不是 `127.0.0.1` | `browse` |
| 存在 `SSH_CONNECTION` 或 `SSH_TTY` | `browse` |
| darwin 或 win32 | `native` |
@@ -108,6 +109,7 @@ kind: "package-reference"
- **探测是从启动上下文推断操作者位置,而任何启动侧信号都无法证明这一点**——从 SSH 启动中脱离的 tmux 会话会丢失 `SSH_*` 标记;Aqua 会话之外的 Darwin 进程仍被算作有显示;在工作站本地启动、之后经 `ssh -L` 访问时,请求会从 `127.0.0.1` 到达,系统会判定 `native`,并把选择器弹在无人值守的工作站上。错误的 `native` 选择会退化为后端既有的可重试失败对话框,而对这类部署,直接组合 `-browse` 即选择安全的交互。
- **Linux 选择器探查只读 `PATH`**——以其他途径可用的 zenity/kdialog(shell 别名、未装在 PATH 上)仍判定为 `browse`;把任一二进制装到 `PATH` 上,下次启动即恢复 `native` 资格。
- **仅在启动时判定**——一次判定服务本次启动的所有客户端;按连接自适应(同一台服务器,本地浏览器用 native、远程浏览器用 browse)需要按客户端的能力对象以及 seam 未携带的协议通告,等到出现同时服务两种形态的部署再做。
- **以 loopback authority 呈现的代理会藏起远程浏览器**——反向代理把请求 `Host` 和 `Origin` 一并改写为 loopback authority(如 `127.0.0.1:3080`),且没有任何非 loopback 的 `trustedHosts` 条目时,便能通过浏览器信任栅栏,于是远程浏览器仍判定为 `native`,选择器在无人值守的宿主屏幕上打开。
<a id="dev-note"></a>
### 开发备注
@@ -29,6 +29,7 @@
"peerDependencies": {
"@deepseek-ai/cordis": "workspace:~",
"@deepseek-ai/cordis-plugin-loader": "workspace:~",
"@deepseek-ai/dsh-client-connection": "workspace:*",
"@deepseek-ai/dsh-client-ui-directory-picker-browse": "workspace:*",
"@deepseek-ai/dsh-client-ui-directory-picker-native": "workspace:*",
"@deepseek-ai/dsh-host-directory-picker-browse": "workspace:*",
@@ -39,6 +40,7 @@
"@deepseek-ai/cordis": "workspace:~",
"@deepseek-ai/cordis-plugin-include": "workspace:~",
"@deepseek-ai/cordis-plugin-loader": "workspace:~",
"@deepseek-ai/dsh-client-connection": "workspace:*",
"@deepseek-ai/dsh-client-ui-directory-picker-browse": "workspace:*",
"@deepseek-ai/dsh-client-ui-directory-picker-native": "workspace:*",
"@deepseek-ai/dsh-host-directory-picker": "workspace:*",
@@ -1,19 +1,22 @@
/**
* Adaptive chooser of the directory-picker seam: resolves the host's
* situation once at boot (bind host, SSH launch, display session, Linux
* chooser binary) and mounts the matching interaction — `native` or `browse`
* — as real Loader entries in the in-memory root tree. Each interaction is a
* pair: the Host backend serving the seam capability and the client surface
* occupying ui-workspace's directory-flow holes. Both arrive as ordinary
* entries, so the surface is discovered exactly as a config-row's would be
* and one resolved choice still swaps both faces; pinning an interaction
* remains composing that pair directly instead of this row.
* situation once at boot (remote-authority trust, bind host, SSH launch,
* display session, chooser binary) and mounts the matching interaction —
* `native` or `browse` — as real Loader entries in the in-memory root tree.
* Each interaction is a pair: the Host backend serving the seam capability and
* the client surface occupying ui-workspace's directory-flow holes. Both
* arrive as ordinary entries, so the surface is discovered exactly as a
* config-row's would be and one resolved choice still swaps both faces;
* pinning an interaction remains composing that pair directly instead of this
* row.
* @module @deepseek-ai/dsh-host-directory-picker-auto
*/
import type { Context } from '@deepseek-ai/cordis'
// Empty type imports carry the `loader` and `webServer` Context merges for the reads below.
// Empty type imports carry the `connection`, `loader`, and `webServer` Context
// merges for the reads below.
import type {} from '@deepseek-ai/cordis-plugin-loader'
import type {} from '@deepseek-ai/dsh-client-connection'
import type {} from '@deepseek-ai/dsh-host-webserver'
import { launchedThroughSsh, launchEnvironmentOf } from '@deepseek-ai/dsh-launch-environment'
import { canExecute, hasLinuxChooserBinary } from './probe.ts'
@@ -26,8 +29,12 @@ export { resolveDirectoryPickerBackend } from './resolve.ts'
/** Cordis plugin name. */
export const name = 'directory-picker-auto'
/** Required services: the effective bind host (`webServer`) and the entry tree the backend mounts into (`loader`). */
export const inject = ['webServer', 'loader']
/**
* Required services: HTTP deployment settings (`webServer`), the Connection
* trust policy (`connection`), and the entry tree the backend mounts into
* (`loader`).
*/
export const inject = ['webServer', 'connection', 'loader']
/**
* Host backend package per resolved kind — fixed composition vocabulary, not a
@@ -57,11 +64,12 @@ export const SURFACE_PACKAGES: Record<DirectoryPickerBackendKind, string> = {
* surface as Loader entries; the effect's disposer removes both entries and
* joins their fibers' teardown, so unloading this plugin returns only after
* both faces of the mounted interaction (and their dependents) quiesced.
* @param ctx - cordis context carrying the injected `webServer` and `loader`.
* @param ctx - cordis context carrying the injected services.
*/
export async function apply(ctx: Context): Promise<void> {
const backend = resolveDirectoryPickerBackend({
bindHost: ctx.webServer.host,
allowsRemoteAuthorities: ctx.connection.allowsRemoteAuthorities,
platform: process.platform,
ssh: launchedThroughSsh(launchEnvironmentOf(ctx)),
env: process.env,
@@ -20,6 +20,11 @@ export type DirectoryPickerEnv = Readonly<
export interface DirectoryPickerHostFacts {
/** Effective webserver bind host (the schema's closed loopback/all-interfaces union). */
bindHost: HttpServerConfig['host']
/**
* Whether the Connection trust policy admits a non-loopback authority (a
* validated `trustedHosts` entry naming a remote hostname).
*/
allowsRemoteAuthorities: boolean
/** Host process platform. */
platform: NodeJS.Platform
/** SSH launch fact from the inherited process layer, independent of `.env` values. */
@@ -36,18 +41,19 @@ const present = (value: string | undefined): boolean => value !== undefined && v
/**
* Resolve which backend serves this boot. `native` requires every signal that
* the operator can see the host display and the native backend can serve it:
* a loopback-only bind (an all-interfaces bind admits remote browsers no OS
* chooser can reach), no SSH launch (under SSH port-forwarding the chooser
* would open on the unattended server), and a servable display session —
* assumed on darwin/win32, requiring `DISPLAY`/`WAYLAND_DISPLAY` plus a
* chooser binary on linux, and never true elsewhere (the native backend
* drives exactly darwin/win32/linux). Anything ambiguous resolves to
* no remote-authority trust, a loopback-only bind (remote-authority trust or
* a non-loopback bind admits remote browsers no OS chooser can reach), no SSH
* launch (under SSH port-forwarding the chooser would open on the unattended
* server), and a servable display session — assumed on darwin/win32,
* requiring `DISPLAY`/`WAYLAND_DISPLAY` plus a chooser binary on linux, and
* never true elsewhere (the native backend drives exactly darwin/win32/linux).
* Anything ambiguous resolves to
* `browse`, which works everywhere.
* @param facts - the sampled host facts.
* @returns the backend kind to mount.
*/
export function resolveDirectoryPickerBackend(facts: DirectoryPickerHostFacts): DirectoryPickerBackendKind {
if (facts.bindHost !== '127.0.0.1') return 'browse'
if (facts.allowsRemoteAuthorities || facts.bindHost !== '127.0.0.1') return 'browse'
if (facts.ssh) return 'browse'
if (facts.platform === 'darwin' || facts.platform === 'win32') return 'native'
if (facts.platform !== 'linux' || !facts.linuxChooser) return 'browse'
@@ -1,10 +1,11 @@
/**
* REAL-composition coverage: a test-only cordis.yml booted through the
* vendored Loader mounts the webserver row plus the adaptive chooser, and the
* assertions observe the durable outcome — which backend and surface entries
* the chooser mounted into the Loader store, the capability the seam then
* serves, and that disposing the chooser removes both mounted entries again
* (HMR safety), joining the backend's own teardown before the disposer settles.
* vendored Loader mounts the webserver row plus the adaptive chooser over a
* context providing the Connection trust policy, and the assertions observe
* the durable outcome — which backend and surface entries the chooser mounted
* into the Loader store, the capability the seam then serves, and that
* disposing the chooser removes both mounted entries again (HMR safety),
* joining the backend's own teardown before the disposer settles.
*/
import { chmodSync, mkdtempSync, writeFileSync } from 'node:fs'
@@ -90,10 +91,13 @@ afterEach(async () => {
renameControl.remainingFailures = 0
})
/** Write a two-row cordis.yml (webserver + chooser), then boot it through the real Loader. */
/**
* Write a two-row cordis.yml (webserver + chooser) over a context that
* provides the Connection trust policy, then boot it through the real Loader.
*/
async function loadComposition(
bindHost: '127.0.0.1' | '0.0.0.0',
options: { failSurface?: boolean; launchEnvironment?: LaunchEnvironmentSnapshot } = {},
options: { failSurface?: boolean; launchEnvironment?: LaunchEnvironmentSnapshot; remoteAuthorities?: boolean } = {},
): Promise<{ ctx: Context; configPath: string }> {
root = await mkdtemp(join(tmpdir(), 'dsh-directory-picker-auto-'))
const configPath = join(root, 'cordis.yml')
@@ -107,6 +111,7 @@ async function loadComposition(
].join('\n'))
context = new Context()
context.provide('connection', { allowsRemoteAuthorities: options.remoteAuthorities === true } as never)
if (options.launchEnvironment !== undefined) context.provide(DSH_LAUNCH_ENVIRONMENT_KEY, options.launchEnvironment)
context.baseUrl = pathToFileURL(root).href + '/'
await context.plugin(Loader)
@@ -234,10 +239,13 @@ describe('real Loader composition', () => {
expect(await readFile(configPath, 'utf8')).not.toContain(NATIVE)
})
it('mounts the browse backend under an SSH launch', { timeout: 60_000 }, async () => {
it.each([
{ signal: 'a trust policy that admits a remote authority', remoteAuthorities: true },
{ signal: 'an SSH launch', ssh: '10.0.0.2 55 10.0.0.9 22' },
])('mounts the browse backend under $signal even on an attended loopback host', { timeout: 60_000 }, async ({ remoteAuthorities, ssh }) => {
stubAttendedHost()
vi.stubEnv('SSH_CONNECTION', '10.0.0.2 55 10.0.0.9 22')
const { ctx } = await loadComposition('127.0.0.1')
if (ssh !== undefined) vi.stubEnv('SSH_CONNECTION', ssh)
const { ctx } = await loadComposition('127.0.0.1', { remoteAuthorities: remoteAuthorities ?? false })
expect(entryNames(ctx)).toContain(BROWSE)
expect(entryNames(ctx)).toContain(BROWSE_SURFACE)
@@ -9,6 +9,7 @@ import type { DirectoryPickerHostFacts } from '../src/resolve.ts'
/** Baseline facts that resolve to `native`; each case overrides one signal (darwin never consults `linuxChooser`). */
const attended: DirectoryPickerHostFacts = {
bindHost: '127.0.0.1',
allowsRemoteAuthorities: false,
platform: 'darwin',
ssh: false,
env: {},
@@ -25,6 +26,10 @@ describe('resolveDirectoryPickerBackend', () => {
expect(resolveDirectoryPickerBackend({ ...attended, bindHost: '0.0.0.0' })).toBe('browse')
})
it('resolves browse when the trust policy admits a remote authority, even on an attended loopback host', () => {
expect(resolveDirectoryPickerBackend({ ...attended, allowsRemoteAuthorities: true })).toBe('browse')
})
it('resolves browse under an SSH launch', () => {
expect(resolveDirectoryPickerBackend({ ...attended, ssh: true })).toBe('browse')
})
@@ -17,6 +17,9 @@
{
"path": "../../../vendor/loader"
},
{
"path": "../../client/connection/tsconfig.host.json"
},
{
"path": "../webserver"
},
+3
View File
@@ -10099,6 +10099,9 @@ importers:
'@deepseek-ai/cordis-plugin-loader':
specifier: workspace:~
version: link:../../../vendor/loader
'@deepseek-ai/dsh-client-connection':
specifier: workspace:*
version: link:../../client/connection
'@deepseek-ai/dsh-client-ui-directory-picker-browse':
specifier: workspace:*
version: link:../../client/ui-directory-picker-browse