Просмотр исходного кода

fix(host): address ds-review-bot v7 on the adaptive picker chooser

- resolve.ts: gate the display branch on linux (the native backend drives
  exactly darwin/win32/linux) and require a zenity/kdialog binary on PATH,
  probed once at boot (new probe.ts, injected predicate for tests); type
  bindHost as the webserver schema's closed union.
- index.ts: the disposer now joins the removed entry's fiber teardown so
  unloading the chooser settles only after the backend quiesced; export
  BACKEND_PACKAGES as the runtime-string source of truth.
- verify-cordis-config: a composition mounting -auto must declare both
  backends as dependencies (negative-tested), since keyless Linux CI only
  ever resolves browse and would hide a dropped -native dep.
- apps/web scaffold: pin -browse via disable+insert (goldens are
  interaction-specific); fix the stale workspace-flow comment.
- docs/module-graph.md regenerated; README + Agent Note document the
  ssh -L shape, the PATH-only probe, and the new gate (zh pairs re-paired).
- composition spec: assert teardown quiescence without a loader await,
  cover external entry removal, and await the loader's self-dispose
  disabled-persist so it cannot race temp-dir teardown.
creatixchu 2 месяцев назад
Родитель
Сommit
f0f897ef02

+ 2 - 2
.agents/notes/implemented/feature/2026-07-29-directory-picker-adaptive-default.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-29-directory-picker-adaptive-default.md
-2026-07-29-directory-picker-adaptive-default.md: ae5748259f162503300360d6cca43bec996afdb6
-2026-07-29-directory-picker-adaptive-default.zh.md: acabeb80604b473289fa441e6d9d913e4a8f87d0
+2026-07-29-directory-picker-adaptive-default.md: 7ff6529bb8e445f63343b1019ac520f56b19d5e4
+2026-07-29-directory-picker-adaptive-default.zh.md: a2a2d8ec4c91a347eedfc3aa3413b091ee847934

+ 3 - 2
.agents/notes/implemented/feature/2026-07-29-directory-picker-adaptive-default.md

@@ -10,7 +10,7 @@ The [directory-picker seam](../architecture/2026-07-28-directory-picker-capabili
 
 ## Decision
 
-A third sibling package, **`dsh-host-directory-picker-auto`**: a node-half-only *chooser* that owns no picking code and no UI. Its `apply` samples the host facts exactly once at boot — bind host from the injected `httpServer` (a new `host` getter mirrors the existing `port`), `SSH_CONNECTION`/`SSH_TTY`, platform, `DISPLAY`/`WAYLAND_DISPLAY` — resolves them through one exported pure function, and mounts the chosen dual-face backend with `ctx.loader.create({name})` into the Loader's **in-memory root tree**; the effect's disposer removes the entry again. `native` requires every attended-host signal (loopback bind ∧ no SSH markers ∧ display session, assumed on darwin/win32); anything ambiguous resolves to `browse`, which works everywhere. `apps/cli` now mounts `-auto` as its `directory-picker` row; composing `-native` or `-browse` directly remains the pin.
+A third sibling package, **`dsh-host-directory-picker-auto`**: a node-half-only *chooser* that owns no picking code and no UI. Its `apply` samples the host facts exactly once at boot — bind host from the injected `httpServer` (a new `host` getter mirrors the existing `port`), `SSH_CONNECTION`/`SSH_TTY`, platform, `DISPLAY`/`WAYLAND_DISPLAY`, and a `PATH` probe for a Linux chooser binary (zenity/kdialog) — resolves them through one exported pure function, and mounts the chosen dual-face backend with `ctx.loader.create({name})` into the Loader's **in-memory root tree**; the effect's disposer removes the entry and joins the backend fiber's teardown (`remove()` alone only starts it), so unloading the chooser settles only after the backend quiesced. `native` requires every attended-and-servable signal: loopback bind ∧ no SSH markers ∧ a display session the native backend can drive — assumed on darwin/win32, requiring `DISPLAY`/`WAYLAND_DISPLAY` plus a chooser binary on linux, and never true elsewhere (the native backend supports exactly darwin/win32/linux). Anything ambiguous resolves to `browse`, which works everywhere. `apps/cli` now mounts `-auto` as its `directory-picker` row; composing `-native` or `-browse` directly remains the pin.
 
 Why entry-level mounting is the load-bearing mechanism: the client module table (`dsh-client-modules`) reconciles **Loader entries** reactively over `internal/plugin`, so a backend mounted as a real entry gets its browser half discovered exactly as a config-row's would be — the seam's one-row-swaps-both-faces invariant survives adaptivity with zero duplicated client code. The dev HMR row (`AppCLIEntry`) is the mechanism precedent. Root-tree targeting matters: the root tree's `write()` is a no-op, so the resolved row can never be persisted back into `cordis.yml` (the Include subtree *does* write).
 
@@ -23,7 +23,8 @@ Why entry-level mounting is the load-bearing mechanism: the client module table
 
 ## Consequences
 
-- The shipped web GUI adapts out of the box: attended local host → OS chooser; SSH launch, all-interfaces bind, or headless host → in-app browser. Detection is a heuristic (a detached tmux session loses `SSH_*`; a non-Aqua darwin process still counts as displayed) — a wrong `native` choice degrades to the backend's existing retryable failure dialog, and composing `-browse` pins the safe interaction.
+- The shipped web GUI adapts out of the box: attended local host → OS chooser; SSH launch, all-interfaces bind, headless host, unsupported platform, or Linux without a chooser binary → in-app browser. Detection infers operator location from launch context, which no launch-side signal can prove: a detached tmux session loses `SSH_*`; a non-Aqua darwin process still counts as displayed; and the `ssh -L` shape (a workstation-local launch later reached through a forwarded port, arriving from `127.0.0.1`) resolves `native` and opens the chooser on the unattended workstation — per-connection adaptivity could not fix that last case either. A wrong `native` choice degrades to the backend's existing retryable failure dialog; deployments in these shapes compose `-browse` directly.
+- The chooser mounts backends by runtime string (`BACKEND_PACKAGES`, exported), which yml-row scanning cannot see; `verify-cordis-config` therefore requires every composition mounting `-auto` to declare both backends as dependencies, so keyless Linux CI (which only ever resolves `browse`) cannot hide a dropped `-native` dependency. The shipped-tree web e2e/snapshot lane (`apps/web/tests/scaffold.ts`) pins `-browse` by disable+insert patch — its goldens are interaction-specific and must not depend on the host running the suite.
 - One resolution per boot keeps the seam's capability-stability contract; per-connection shapes remain out of scope until a deployment demands them.
 - Mounting the chooser **and** a backend row together fails loud (duplicate `directoryPicker` service; duplicate flow in the `single` holes).
 - The host typecheck aggregate now references the two backend projects (declarations only, node entries carry no client merge) so the chooser's REAL-composition test can mount them — the mirror of the client aggregate's `webserver` reference.

+ 3 - 2
.agents/notes/implemented/feature/2026-07-29-directory-picker-adaptive-default.zh.md

@@ -10,7 +10,7 @@
 
 ## 决策
 
-第三个同级包 **`dsh-host-directory-picker-auto`**:一个只有 node 半侧的*选择器*,不持有任何选取代码,也没有 UI。它的 `apply` 在启动时恰好采样一次宿主事实——从注入的 `httpServer` 读绑定宿主(新增的 `host` getter 与既有的 `port` 对称)、`SSH_CONNECTION`/`SSH_TTY`、平台、`DISPLAY`/`WAYLAND_DISPLAY`——经由一个导出的纯函数判定,再用 `ctx.loader.create({name})` 把选中的双面后端挂进 Loader 的**内存根树**;该 effect 的 disposer 会再次移除该条目。`native` 要求全部有人值守宿主信号(回环绑定 ∧ 无 SSH 标记 ∧ 显示会话,darwin/win32 上视为存在);任何含糊情形都判定为处处可用的 `browse`。`apps/cli` 现在把 `-auto` 挂为它的 `directory-picker` 行;直接组合 `-native` 或 `-browse` 仍是固定交互的方式。
+第三个同级包 **`dsh-host-directory-picker-auto`**:一个只有 node 半侧的*选择器*,不持有任何选取代码,也没有 UI。它的 `apply` 在启动时恰好采样一次宿主事实——从注入的 `httpServer` 读绑定宿主(新增的 `host` getter 与既有的 `port` 对称)、`SSH_CONNECTION`/`SSH_TTY`、平台、`DISPLAY`/`WAYLAND_DISPLAY`、以及对 Linux 选择器二进制(zenity/kdialog)的一次 `PATH` 探查——经由一个导出的纯函数判定,再用 `ctx.loader.create({name})` 把选中的双面后端挂进 Loader 的**内存根树**;该 effect 的 disposer 会移除该条目并汇入后端 fiber 的拆卸(单靠 `remove()` 只是启动拆卸),因此卸载选择器要到后端静止之后才落定。`native` 要求全部“有人值守且可服务”信号:回环绑定 ∧ 无 SSH 标记 ∧ native 后端能驱动的显示会话——darwin/win32 上视为存在,linux 上要求 `DISPLAY`/`WAYLAND_DISPLAY` 外加一个选择器二进制,其余平台一律不成立(native 后端恰好支持 darwin/win32/linux)。任何含糊情形都判定为处处可用的 `browse`。`apps/cli` 现在把 `-auto` 挂为它的 `directory-picker` 行;直接组合 `-native` 或 `-browse` 仍是固定交互的方式。
 
 条目级挂载之所以是承重机制:client 模块表(`dsh-client-modules`)基于 `internal/plugin` 对 **Loader 条目**做响应式协调,因此以真实条目挂载的后端,其 browser half 被发现的方式与配置行完全相同——seam 的“一行同时换两面”不变式在自适应下依然成立,且没有一行重复的 client 代码。开发环境的 HMR 行(`AppCLIEntry`)是该机制的先例。瞄准根树很关键:根树的 `write()` 是 no-op,因此判定出的行绝不会被持久化回 `cordis.yml`(Include 子树*会*写回)。
 
@@ -23,7 +23,8 @@
 
 ## 后果
 
-- 随附的 web GUI 开箱即自适应:有人值守的本地宿主 → OS 选择器;SSH 启动、全网卡绑定或无头宿主 → 应用内浏览器。探测是启发式的(脱离的 tmux 会话会丢失 `SSH_*`;非 Aqua 的 darwin 进程仍被算作有显示)——错误的 `native` 选择会退化为后端既有的可重试失败对话框,组合 `-browse` 即固定住安全的交互。
+- 随附的 web GUI 开箱即自适应:有人值守的本地宿主 → OS 选择器;SSH 启动、全网卡绑定、无头宿主、不支持的平台,或没有选择器二进制的 Linux → 应用内浏览器。探测是从启动上下文推断操作者位置,而任何启动侧信号都无法证明这一点:脱离的 tmux 会话会丢失 `SSH_*`;非 Aqua 的 darwin 进程仍被算作有显示;而 `ssh -L` 形态(在工作站本地启动、之后经转发端口访问,从 `127.0.0.1` 到达)会判定 `native`,把选择器弹在无人值守的工作站上——即便按连接自适应也修不了最后这一情形。错误的 `native` 选择会退化为后端既有的可重试失败对话框;处于这些形态的部署直接组合 `-browse`。
+- 选择器按运行时字符串(已导出的 `BACKEND_PACKAGES`)挂载后端,yml 行扫描看不到这一点;因此 `verify-cordis-config` 要求每个挂载 `-auto` 的组合把两个后端都声明为依赖,使无密钥的 Linux CI(它永远只会判定出 `browse`)无法掩盖被丢掉的 `-native` 依赖。随附树的 web e2e/快照通道(`apps/web/tests/scaffold.ts`)以 disable+insert 补丁固定 `-browse`——其 golden 是交互特定的,绝不能依赖运行该套件的宿主。
 - 每次启动只判定一次,维持 seam 的能力稳定性契约;按连接的形态在有部署提出需求前仍不在范围内。
 - 同时挂载选择器**和**某个后端行会大声失败(重复的 `directoryPicker` 服务;`single` 洞中的重复流程)。
 - host 类型检查聚合现在引用两个后端项目(仅声明,node 入口不携带 client 合并),使选择器的 REAL-composition 测试能挂载它们——与 client 聚合对 `webserver` 的引用互为镜像。

+ 8 - 0
apps/web/tests/scaffold.ts

@@ -175,6 +175,14 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
     { id: 'workspace-context', disabled: true },
     { id: 'session-title-llm', disabled: true },
     { id: 'webserver', config: { host: '127.0.0.1', port: 0, distIndex: DIST_INDEX } },
+    // The shipped directory-picker row is the -auto chooser, which resolves
+    // the interaction from the RUNNING host (display, SSH launch, bind). The
+    // lane's goldens are interaction-specific (workspace-management drives
+    // the in-app browse dialog), so pin -browse deterministically on every
+    // host: patch `name` is an assertion, not an override, hence the
+    // disable+insert pair.
+    { id: 'directory-picker', disabled: true },
+    { insert: [{ id: 'directory-picker-browse', name: '@deepseek-ai/dsh-host-directory-picker-browse' }] },
     ...options.toolsMode === undefined ? [] : [{ id: 'tools', config: { mode: options.toolsMode } }],
     ...options.cordisTools === true
       ? [{ insert: [{ id: 'tool-cordis', name: 'cordis:tool-cordis' }] }]

+ 2 - 1
apps/web/tests/workspace-flow.snapshot.ts

@@ -38,7 +38,8 @@ const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [
   },
   { id: '@deepseek-ai/dsh-client-ui-trajectory', dir: 'ui-trajectory', url: '/plugins/ui-trajectory.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-conversation'] },
   // Dual-face host package: its browser half fills the directory-flow holes
-  // (the same composition row apps/cli mounts for the node-side backend).
+  // (the backend row apps/cli's -auto chooser resolves to on headless hosts,
+  // and the one the web scaffold pins for interaction-stable goldens).
   {
     id: '@deepseek-ai/dsh-host-directory-picker-browse',
     dir: '../host/directory-picker-browse',

+ 6 - 0
docs/module-graph.md

@@ -188,6 +188,7 @@ flowchart TD
   subgraph group_host["packages/host"]
     pkg_host_apiproxy["host-apiproxy"]
     pkg_host_directory_picker["host-directory-picker"]
+    pkg_host_directory_picker_auto["host-directory-picker-auto"]
     pkg_host_directory_picker_browse["host-directory-picker-browse"]
     pkg_host_directory_picker_native["host-directory-picker-native"]
     pkg_host_webserver["host-webserver"]
@@ -436,6 +437,10 @@ flowchart TD
   pkg_code_runtime_worker --> pkg_invariants
   pkg_code_runtime_worker --> pkg_session
   pkg_code_runtime_worker --> pkg_timeout
+  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_directory_picker_auto --> pkg_invariants
   pkg_lsp_local --> pkg_brand
   pkg_lsp_local --> pkg_invariants
   pkg_lsp_local --> pkg_llm
@@ -1041,6 +1046,7 @@ flowchart TD
 | [`client-ui-command`](../packages/client/ui-command) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
 | [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/support/invariants) |
 | [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | `code-runtime` | [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) |
+| [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) |
 | [`lsp-local`](../packages/lsp/lsp-local) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
 | [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) |
 | [`sandbox-policy`](../packages/sandbox/sandbox-policy) | `sandbox` | [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) |

+ 2 - 2
packages/host/directory-picker-auto/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-auto/README.md
-README.md: 73692d1fb5af1e7b23b2a99d0a5cd48507f68ce5
-README.zh.md: 5bc9ced01f86d021db256df2c9e69e97ec6a7ab1
+README.md: 10d1784590b79fdfef3cf6683d389182cd8437b6
+README.zh.md: 86ec9f2c3a87557e86038ce7d3f89887c5bb3546

+ 3 - 2
packages/host/directory-picker-auto/README.md

@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
 
 The **adaptive chooser** of the [directory-picker seam](../directory-picker/README.md): a node-half-only plugin that resolves the host's situation once at boot and mounts the matching dual-face backend — [`-native`](../directory-picker-native/README.md) or [`-browse`](../directory-picker-browse/README.md) — as a real Loader entry in the in-memory root tree (never persisted to a config file; the root tree's `write()` is a no-op). Because the backend arrives as an ordinary entry, its browser half is discovered by the client module table exactly as a config-row's would be, so the seam's one-row-swaps-both-faces invariant holds for the resolved choice. Unloading the chooser removes the entry again, unloading both faces with it.
 
-Resolution is one pure boot-time sample (`resolveDirectoryPickerBackend`), exported for reuse and tests. `native` requires every signal that the operator can see the host display: a loopback-only bind (read from the injected `httpServer`; an all-interfaces bind admits remote browsers no OS chooser can reach), no SSH launch (`SSH_CONNECTION`/`SSH_TTY` unset or blank — under SSH port-forwarding the chooser would open on the unattended server), and a display session (assumed on darwin/win32; `DISPLAY`/`WAYLAND_DISPLAY` elsewhere). Anything ambiguous resolves to `browse`, which works everywhere. The sample happens exactly once per boot so the mounted capability stays stable for the service lifetime, as the seam requires. Pinning an interaction is not a config field here — compose the `-native` or `-browse` row directly instead of this one, the seam's documented swap point; mounting the chooser **and** a backend row together fails loud (duplicate `directoryPicker` service, duplicate client flow in the `single` holes).
+Resolution is one pure boot-time sample (`resolveDirectoryPickerBackend`), exported for reuse and tests. `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 `httpServer`; an all-interfaces bind admits remote browsers no OS chooser can reach), no SSH launch (`SSH_CONNECTION`/`SSH_TTY` unset or blank — under SSH port-forwarding the chooser would open on the unattended server), and a servable display session — assumed on darwin/win32; on linux `DISPLAY`/`WAYLAND_DISPLAY` plus a zenity or kdialog binary on `PATH` (the probe is one more boot-time fact); never on any other platform, since the native backend drives exactly darwin/win32/linux. Anything ambiguous resolves to `browse`, which works everywhere. The sample happens exactly once per boot so the mounted capability stays stable for the service lifetime, as the seam requires. Pinning an interaction is not a config field here — compose the `-native` or `-browse` row directly instead of this one, the seam's documented swap point; mounting the chooser **and** a backend row together fails loud (duplicate `directoryPicker` service, duplicate client flow in the `single` holes).
 
 ## Model Experience
 
@@ -16,5 +16,6 @@ None; this package neither assembles nor sends a provider request.
 
 ## Known Limitations and Deferred Work
 
-- **Detection is a heuristic, not a proof** — a tmux session detached from its SSH launch loses the `SSH_*` markers, and a darwin process outside an Aqua session still counts as displayed; a wrong `native` choice degrades to the backend's existing retryable failure dialog, and composing `-browse` directly pins the safe interaction.
+- **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 the `ssh -L` shape (a workstation-local launch later reached through a forwarded port, which 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 pins 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 deliberately deleted, and waits for a deployment that serves both at once.

+ 3 - 2
packages/host/directory-picker-auto/README.zh.md

@@ -4,7 +4,7 @@
 
 [目录选择 seam](../directory-picker/README.md) 的**自适应选择器**:一个只有 node 半侧的插件,在启动时一次性判定宿主处境,并把匹配的双面后端——[`-native`](../directory-picker-native/README.md) 或 [`-browse`](../directory-picker-browse/README.md)——作为真实的 Loader 条目挂进内存根树(绝不持久化到配置文件;根树的 `write()` 是 no-op)。由于后端以普通条目的形式到达,其 browser half 被 client 模块表发现的方式与配置行完全相同,因此对判定出的选择,seam 的“一行同时换两面”不变式依然成立。卸载该选择器会再次移除该条目,连同两面一起卸载。
 
-判定是一次纯函数的启动时采样(`resolveDirectoryPickerBackend`),已导出供复用与测试。`native` 要求“操作者看得到宿主屏幕”的全部信号:仅回环的绑定(从注入的 `httpServer` 读取;全网卡绑定会接入任何 OS 选择器都触及不到的远程浏览器);非 SSH 启动(`SSH_CONNECTION`/`SSH_TTY` 未设置或为空——SSH 端口转发下选择器会弹在无人值守的服务器上);以及存在显示会话(darwin/win32 上视为存在,其余平台看 `DISPLAY`/`WAYLAND_DISPLAY`)。任何含糊情形都判定为处处可用的 `browse`。采样每次启动恰好发生一次,因此挂载的能力在服务生命周期内保持稳定,符合 seam 的要求。固定某种交互在这里不是配置字段——直接组合 `-native` 或 `-browse` 行来替代本行,那才是 seam 文档化的切换点;同时挂载选择器**和**某个后端行会大声失败(重复的 `directoryPicker` 服务、`single` 洞中的重复 client 流程)。
+判定是一次纯函数的启动时采样(`resolveDirectoryPickerBackend`),已导出供复用与测试。`native` 要求“操作者看得到宿主屏幕、且 native 后端能服务它”的全部信号:仅回环的绑定(从注入的 `httpServer` 读取;全网卡绑定会接入任何 OS 选择器都触及不到的远程浏览器);非 SSH 启动(`SSH_CONNECTION`/`SSH_TTY` 未设置或为空——SSH 端口转发下选择器会弹在无人值守的服务器上);以及可服务的显示会话——darwin/win32 上视为存在;linux 上要求 `DISPLAY`/`WAYLAND_DISPLAY`,外加 `PATH` 上有 zenity 或 kdialog 二进制(该探查是又一项启动时事实);其余任何平台上都不成立,因为 native 后端驱动的平台恰为 darwin/win32/linux。任何含糊情形都判定为处处可用的 `browse`。采样每次启动恰好发生一次,因此挂载的能力在服务生命周期内保持稳定,符合 seam 的要求。固定某种交互在这里不是配置字段——直接组合 `-native` 或 `-browse` 行来替代本行,那才是 seam 文档化的切换点;同时挂载选择器**和**某个后端行会大声失败(重复的 `directoryPicker` 服务、`single` 洞中的重复 client 流程)。
 
 ## 模型体验
 
@@ -16,5 +16,6 @@
 
 ## 已知限制与延期工作
 
-- **探测是启发式,不是证明**——从 SSH 启动中脱离的 tmux 会话会丢失 `SSH_*` 标记,Aqua 会话之外的 darwin 进程也仍被算作有显示;错误的 `native` 选择会退化为后端既有的可重试失败对话框,而直接组合 `-browse` 即固定住安全的交互。
+- **探测是从启动上下文推断操作者位置,而任何启动侧信号都无法证明这一点**——从 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 有意删除的 wire 广播,等到出现同时服务两种形态的部署再做。

+ 31 - 12
packages/host/directory-picker-auto/src/index.ts

@@ -1,12 +1,12 @@
 /**
  * Adaptive chooser of the directory-picker seam: resolves the host's
- * situation once at boot (bind host, SSH launch, display session) and mounts
- * the matching dual-face backend — `-native` or `-browse` — as a real Loader
- * entry in the in-memory root tree. Because the backend arrives as an
- * ordinary entry, its browser half is discovered exactly as a config-row's
- * would be, so the seam's one-row-swaps-both-faces invariant holds for the
- * resolved choice; pinning an interaction remains composing that backend row
- * directly instead of this one.
+ * situation once at boot (bind host, SSH launch, display session, Linux
+ * chooser binary) and mounts the matching dual-face backend — `-native` or
+ * `-browse` — as a real Loader entry in the in-memory root tree. Because the
+ * backend arrives as an ordinary entry, its browser half is discovered
+ * exactly as a config-row's would be, so the seam's one-row-swaps-both-faces
+ * invariant holds for the resolved choice; pinning an interaction remains
+ * composing that backend row directly instead of this one.
  * @module @deepseek-ai/dsh-host-directory-picker-auto
  */
 
@@ -14,9 +14,11 @@ import type { Context } from 'cordis'
 // Empty type imports carry the `loader` and `httpServer` Context merges for the reads below.
 import type {} from '@cordisjs/plugin-loader'
 import type {} from '@deepseek-ai/dsh-host-webserver'
+import { canExecute, hasLinuxChooserBinary } from './probe.ts'
 import type { DirectoryPickerBackendKind } from './resolve.ts'
 import { resolveDirectoryPickerBackend } from './resolve.ts'
 
+export { canExecute, hasLinuxChooserBinary } from './probe.ts'
 export type { DirectoryPickerBackendKind, DirectoryPickerEnv, DirectoryPickerHostFacts } from './resolve.ts'
 export { resolveDirectoryPickerBackend } from './resolve.ts'
 
@@ -25,16 +27,22 @@ export const name = 'directory-picker-auto'
 /** Required services: the effective bind host (`httpServer`) and the entry tree the backend mounts into (`loader`). */
 export const inject = ['httpServer', 'loader']
 
-/** Backend package per resolved kind — fixed composition vocabulary, not a tunable. */
-const BACKEND_PACKAGES: Record<DirectoryPickerBackendKind, string> = {
+/**
+ * Backend package per resolved kind — fixed composition vocabulary, not a
+ * tunable. Exported because the reference is a runtime string the static
+ * config gate cannot see in a yml row: `verify-cordis-config` requires every
+ * app composing this chooser to declare both values as dependencies.
+ */
+export const BACKEND_PACKAGES: Record<DirectoryPickerBackendKind, string> = {
   native: '@deepseek-ai/dsh-host-directory-picker-native',
   browse: '@deepseek-ai/dsh-host-directory-picker-browse',
 }
 
 /**
  * Resolve the backend from one boot-time sample and mount it as a Loader
- * entry; the effect's disposer removes the entry, so unloading this plugin
- * unloads both faces of the mounted backend with it.
+ * entry; the effect's disposer removes the entry and joins the backend
+ * fiber's teardown, so unloading this plugin returns only after both faces
+ * of the mounted backend (and their dependents) quiesced.
  * @param ctx - cordis context carrying the injected `httpServer` and `loader`.
  */
 export async function apply(ctx: Context): Promise<void> {
@@ -42,11 +50,22 @@ export async function apply(ctx: Context): Promise<void> {
     bindHost: ctx.httpServer.host,
     platform: process.platform,
     env: process.env,
+    linuxChooser: hasLinuxChooserBinary(process.env.PATH, canExecute),
   })
   await ctx.effect(async () => {
     // Root-tree create: the Loader root is in-memory (write() is a no-op), so
     // the mounted row can never be persisted back into a config file.
     const id = await ctx.loader.create({ name: BACKEND_PACKAGES[backend] })
-    return () => { ctx.loader.remove(id) }
+    return async () => {
+      // Tree teardown (group.stop) can have removed the entry already;
+      // nothing is left to unmount or await then.
+      const entry = ctx.loader.store[id]
+      if (entry === undefined) return
+      const fiber = entry.fiber
+      ctx.loader.remove(id)
+      // remove() only starts the fiber's dispose; join it so the chooser's
+      // unload signals completion only after the backend quiesced.
+      await fiber?.dispose()
+    }
   }, 'directory-picker-auto: backend entry')
 }

+ 44 - 0
packages/host/directory-picker-auto/src/probe.ts

@@ -0,0 +1,44 @@
+/**
+ * PATH probe for the native backend's Linux chooser binaries: one boot-time
+ * sampled fact for the resolver, so an attended Linux host without
+ * zenity/kdialog keeps the working `browse` interaction instead of a backend
+ * whose every pick fails.
+ * @module @deepseek-ai/dsh-host-directory-picker-auto/probe
+ */
+
+import { accessSync, constants } from 'node:fs'
+import { delimiter, join } from 'node:path'
+
+/** The chooser binaries the native backend can drive on Linux (zenity, KDialog fallback). */
+const LINUX_CHOOSER_BINARIES = ['zenity', 'kdialog'] as const
+
+/**
+ * Whether the current process may execute the candidate path.
+ * @param candidate - absolute or PATH-joined file path.
+ * @returns true only for an existing executable file.
+ */
+export function canExecute(candidate: string): boolean {
+  try {
+    accessSync(candidate, constants.X_OK)
+  } catch {
+    // Absent or non-executable candidate — the only signals accessSync(X_OK) emits.
+    return false
+  }
+  return true
+}
+
+/**
+ * Scan a PATH value for one of the native backend's Linux chooser binaries.
+ * @param pathValue - the `PATH` environment value (absent or empty scans nothing).
+ * @param isExecutable - executability predicate ({@link canExecute} in production; injected for deterministic tests).
+ * @returns whether any PATH directory holds an executable chooser binary.
+ */
+export function hasLinuxChooserBinary(pathValue: string | undefined, isExecutable: (candidate: string) => boolean): boolean {
+  for (const dir of (pathValue ?? '').split(delimiter)) {
+    if (dir === '') continue
+    for (const name of LINUX_CHOOSER_BINARIES) {
+      if (isExecutable(join(dir, name))) return true
+    }
+  }
+  return false
+}

+ 15 - 8
packages/host/directory-picker-auto/src/resolve.ts

@@ -6,6 +6,8 @@
  * @module @deepseek-ai/dsh-host-directory-picker-auto/resolve
  */
 
+import type { Config as HttpServerConfig } from '@deepseek-ai/dsh-host-webserver'
+
 /** Concrete interaction backend the resolver chooses between. */
 export type DirectoryPickerBackendKind = 'native' | 'browse'
 
@@ -16,12 +18,14 @@ export type DirectoryPickerEnv = Readonly<
 
 /** Host facts the backend choice is a pure function of, sampled once at boot. */
 export interface DirectoryPickerHostFacts {
-  /** Effective webserver bind host (`127.0.0.1` or `0.0.0.0`). */
-  bindHost: string
+  /** Effective webserver bind host (the schema's closed loopback/all-interfaces union). */
+  bindHost: HttpServerConfig['host']
   /** Host process platform. */
   platform: NodeJS.Platform
   /** Environment sample; SSH marks a remote operator, DISPLAY/WAYLAND_DISPLAY a Linux display. */
   env: DirectoryPickerEnv
+  /** Whether a Linux chooser binary the native backend can drive (zenity/kdialog) is on PATH; consulted only when `platform` is linux. */
+  linuxChooser: boolean
 }
 
 /** An env value counts only when set and non-blank (an empty export is "unset" by shell convention). */
@@ -29,12 +33,14 @@ 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: 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 display session (assumed on darwin/win32, `DISPLAY`/
- * `WAYLAND_DISPLAY` elsewhere). Anything ambiguous resolves to `browse`,
- * which works everywhere.
+ * 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
+ * `browse`, which works everywhere.
  * @param facts - the sampled host facts.
  * @returns the backend kind to mount.
  */
@@ -42,5 +48,6 @@ export function resolveDirectoryPickerBackend(facts: DirectoryPickerHostFacts):
   if (facts.bindHost !== '127.0.0.1') return 'browse'
   if (present(facts.env.SSH_CONNECTION) || present(facts.env.SSH_TTY)) return 'browse'
   if (facts.platform === 'darwin' || facts.platform === 'win32') return 'native'
+  if (facts.platform !== 'linux' || !facts.linuxChooser) return 'browse'
   return present(facts.env.DISPLAY) || present(facts.env.WAYLAND_DISPLAY) ? 'native' : 'browse'
 }

+ 47 - 10
packages/host/directory-picker-auto/tests/loader-composition.spec.ts

@@ -3,10 +3,12 @@
  * vendored Loader mounts the webserver row plus the adaptive chooser, and the
  * assertions observe the durable outcome — which backend entry the chooser
  * mounted into the Loader store, the capability the seam then serves, and
- * that disposing the chooser removes the mounted entry again (HMR safety).
+ * that disposing the chooser removes the mounted entry again (HMR safety),
+ * joining the backend's own teardown before the disposer settles.
  */
 
-import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
+import { chmodSync, mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
+import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { pathToFileURL } from 'node:url'
@@ -25,21 +27,27 @@ const NATIVE = '@deepseek-ai/dsh-host-directory-picker-native'
 const BROWSE = '@deepseek-ai/dsh-host-directory-picker-browse'
 
 let root: string | undefined
+let fakeBin: string | undefined
 let context: Context | undefined
 
 afterEach(async () => {
   vi.unstubAllEnvs()
   await context?.fiber.dispose()
   context = undefined
-  if (root !== undefined) await rm(root, { recursive: true, force: true })
+  for (const dir of [root, fakeBin]) {
+    // maxRetries absorbs teardown stragglers (e.g. an unawaited fiber's late
+    // file handle) that can otherwise race the recursive scan into ENOTEMPTY.
+    if (dir !== undefined) await rm(dir, { recursive: true, force: true, maxRetries: 3, retryDelay: 50 })
+  }
   root = undefined
+  fakeBin = undefined
 })
 
 /** Write a dist fixture and a two-row cordis.yml (webserver + chooser), then boot it through the real Loader. */
 async function loadComposition(bindHost: '127.0.0.1' | '0.0.0.0'): Promise<{ ctx: Context; configPath: string }> {
   root = await mkdtemp(join(tmpdir(), 'dsh-directory-picker-auto-'))
   const dist = join(root, 'dist')
-  await mkdir(dist)
+  mkdirSync(dist)
   const distIndex = join(dist, 'index.html')
   await writeFile(distIndex, '<head></head><body>shell</body>')
   const configPath = join(root, 'cordis.yml')
@@ -83,17 +91,26 @@ function entryNames(ctx: Context): string[] {
   return [...ctx.loader.entries()].map(entry => entry.options.name)
 }
 
-/** Force every signal of an attended host: no SSH launch, a display on any platform. */
+/**
+ * Force every signal of an attended host on any platform: no SSH launch, a
+ * display, and a PATH holding one executable chooser binary so the real
+ * probe resolves identically on hosts with and without zenity/kdialog.
+ */
 function stubAttendedHost(): void {
+  fakeBin = mkdtempSync(join(tmpdir(), 'dsh-picker-bin-'))
+  const zenity = join(fakeBin, 'zenity')
+  writeFileSync(zenity, '#!/bin/sh\n')
+  chmodSync(zenity, 0o755)
+  vi.stubEnv('PATH', fakeBin)
   vi.stubEnv('SSH_CONNECTION', '')
   vi.stubEnv('SSH_TTY', '')
   vi.stubEnv('DISPLAY', ':0')
 }
 
 describe('real Loader composition', () => {
-  // Real-Loader composition resolves workspace packages through tsx at test
-  // time; first resolution after the host/client program split is slow enough
-  // to trip the default 5s budget on cold caches.
+  // The 60s budget covers this file's static imports (webserver plus both
+  // backend node halves through tsx), which dominate on cold caches; the
+  // Loader itself resolves nothing here — `loader.internal` is a module map.
   it('mounts the native backend for an attended loopback host and unmounts it on disposal', { timeout: 60_000 }, async () => {
     stubAttendedHost()
     const { ctx, configPath } = await loadComposition('127.0.0.1')
@@ -110,12 +127,19 @@ describe('real Loader composition', () => {
     // booted config file must never gain the resolved backend row.
     expect(await readFile(configPath, 'utf8')).not.toContain(NATIVE)
 
-    // HMR safety: disposing the chooser's fiber removes the entry it created.
+    // HMR safety: disposing the chooser's fiber removes the entry it created,
+    // and the disposer joins the backend's teardown — the service is gone the
+    // moment dispose() settles, with no further loader await.
     const autoEntry = [...ctx.loader.entries()].find(entry => entry.options.name === AUTO)!
     await autoEntry.fiber!.dispose()
-    await ctx.loader.await()
     expect(entryNames(ctx)).not.toContain(NATIVE)
     expect(ctx.get('directoryPicker')).toBeUndefined()
+    // Self-disposing an include-tree entry persists `disabled: true` (loader
+    // behavior, not the chooser's); await that debounced write so it cannot
+    // race the temp-dir removal, and pin that the persisted row is the
+    // chooser itself — the resolved backend still never reaches the file.
+    await expect.poll(async () => await readFile(configPath, 'utf8')).toContain('disabled: true')
+    expect(await readFile(configPath, 'utf8')).not.toContain(NATIVE)
   })
 
   it('mounts the browse backend under an SSH launch', { timeout: 60_000 }, async () => {
@@ -136,4 +160,17 @@ describe('real Loader composition', () => {
     expect(entryNames(ctx)).toContain(BROWSE)
     expect(entryNames(ctx)).not.toContain(NATIVE)
   })
+
+  it('tolerates the mounted entry being removed by the tree before the chooser unloads', { timeout: 60_000 }, async () => {
+    stubAttendedHost()
+    const { ctx, configPath } = await loadComposition('127.0.0.1')
+
+    const backendEntry = [...ctx.loader.entries()].find(entry => entry.options.name === NATIVE)!
+    ctx.loader.remove(backendEntry.id)
+    const autoEntry = [...ctx.loader.entries()].find(entry => entry.options.name === AUTO)!
+    await expect(autoEntry.fiber!.dispose()).resolves.not.toThrow()
+    expect(entryNames(ctx)).not.toContain(NATIVE)
+    // Same self-dispose persistence as above: let the write land before teardown.
+    await expect.poll(async () => await readFile(configPath, 'utf8')).toContain('disabled: true')
+  })
 })

+ 61 - 7
packages/host/directory-picker-auto/tests/resolve.spec.ts

@@ -1,12 +1,17 @@
-import { describe, expect, it } from 'vitest'
+import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { delimiter, join } from 'node:path'
+import { afterEach, describe, expect, it } from 'vitest'
+import { canExecute, hasLinuxChooserBinary } from '../src/probe.ts'
 import { resolveDirectoryPickerBackend } from '../src/resolve.ts'
 import type { DirectoryPickerHostFacts } from '../src/resolve.ts'
 
-/** Baseline facts that resolve to `native`; each case overrides one signal. */
+/** Baseline facts that resolve to `native`; each case overrides one signal (darwin never consults `linuxChooser`). */
 const attended: DirectoryPickerHostFacts = {
   bindHost: '127.0.0.1',
   platform: 'darwin',
   env: {},
+  linuxChooser: false,
 }
 
 describe('resolveDirectoryPickerBackend', () => {
@@ -24,14 +29,63 @@ describe('resolveDirectoryPickerBackend', () => {
     expect(resolveDirectoryPickerBackend({ ...attended, env: { SSH_TTY: '/dev/pts/3' } })).toBe('browse')
   })
 
-  it('requires a display session on platforms without an implied one', () => {
-    expect(resolveDirectoryPickerBackend({ ...attended, platform: 'linux' })).toBe('browse')
-    expect(resolveDirectoryPickerBackend({ ...attended, platform: 'linux', env: { DISPLAY: ':0' } })).toBe('native')
-    expect(resolveDirectoryPickerBackend({ ...attended, platform: 'linux', env: { WAYLAND_DISPLAY: 'wayland-1' } })).toBe('native')
+  it('requires a display session and a chooser binary on linux', () => {
+    const linux: DirectoryPickerHostFacts = { ...attended, platform: 'linux', linuxChooser: true }
+    expect(resolveDirectoryPickerBackend(linux)).toBe('browse')
+    expect(resolveDirectoryPickerBackend({ ...linux, env: { DISPLAY: ':0' } })).toBe('native')
+    expect(resolveDirectoryPickerBackend({ ...linux, env: { WAYLAND_DISPLAY: 'wayland-1' } })).toBe('native')
+    expect(resolveDirectoryPickerBackend({ ...linux, env: { DISPLAY: ':0' }, linuxChooser: false })).toBe('browse')
+  })
+
+  it('resolves browse on platforms the native backend cannot serve, display or not', () => {
+    expect(resolveDirectoryPickerBackend({ ...attended, platform: 'freebsd', env: { DISPLAY: ':0' }, linuxChooser: true })).toBe('browse')
+    expect(resolveDirectoryPickerBackend({ ...attended, platform: 'openbsd', env: { WAYLAND_DISPLAY: 'wayland-1' } })).toBe('browse')
   })
 
   it('treats blank env exports as unset', () => {
     expect(resolveDirectoryPickerBackend({ ...attended, env: { SSH_CONNECTION: '', SSH_TTY: '' } })).toBe('native')
-    expect(resolveDirectoryPickerBackend({ ...attended, platform: 'linux', env: { DISPLAY: '', WAYLAND_DISPLAY: '' } })).toBe('browse')
+    expect(resolveDirectoryPickerBackend({
+      ...attended, platform: 'linux', linuxChooser: true, env: { DISPLAY: '', WAYLAND_DISPLAY: '' },
+    })).toBe('browse')
+  })
+})
+
+let probeRoot: string | undefined
+
+afterEach(() => {
+  if (probeRoot !== undefined) rmSync(probeRoot, { recursive: true, force: true })
+  probeRoot = undefined
+})
+
+describe('hasLinuxChooserBinary', () => {
+  it('finds a chooser binary in any PATH segment, skipping empty segments', () => {
+    const seen: string[] = []
+    const path = ['', '/opt/none', '/usr/local/bin'].join(delimiter)
+    const found = hasLinuxChooserBinary(path, (candidate) => {
+      seen.push(candidate)
+      return candidate === join('/usr/local/bin', 'kdialog')
+    })
+    expect(found).toBe(true)
+    expect(seen).toEqual([
+      join('/opt/none', 'zenity'), join('/opt/none', 'kdialog'),
+      join('/usr/local/bin', 'zenity'), join('/usr/local/bin', 'kdialog'),
+    ])
+  })
+
+  it('reports absence when no segment holds a chooser binary', () => {
+    expect(hasLinuxChooserBinary(['/a', '/b'].join(delimiter), () => false)).toBe(false)
+    expect(hasLinuxChooserBinary('', () => true)).toBe(false)
+    expect(hasLinuxChooserBinary(undefined, () => true)).toBe(false)
+  })
+})
+
+describe('canExecute', () => {
+  it('accepts an executable file and rejects an absent one', () => {
+    probeRoot = mkdtempSync(join(tmpdir(), 'dsh-picker-probe-'))
+    const binary = join(probeRoot, 'zenity')
+    writeFileSync(binary, '#!/bin/sh\n')
+    chmodSync(binary, 0o755)
+    expect(canExecute(binary)).toBe(true)
+    expect(canExecute(join(probeRoot, 'kdialog'))).toBe(false)
   })
 })

+ 23 - 3
scripts/verify-cordis-config.ts

@@ -30,6 +30,20 @@ interface PluginReference {
 
 const root = resolve(import.meta.dirname, '..')
 const metadataFields = ['id', 'name', 'group', 'disabled', 'inject', 'intercept', 'isolate'] as const
+
+/** The adaptive directory-picker chooser package (mounts a backend row at boot). */
+const CHOOSER_PACKAGE = '@deepseek-ai/dsh-host-directory-picker-auto'
+
+/**
+ * The backends the chooser mounts by runtime string (mirror of its exported
+ * `BACKEND_PACKAGES`), invisible to yml-row scanning: a composition mounting
+ * the chooser must resolve both, or keyless Linux CI (which only ever
+ * resolves `browse`) hides a dropped `-native` dependency until a macOS boot.
+ */
+const CHOOSER_BACKEND_PACKAGES = [
+  '@deepseek-ai/dsh-host-directory-picker-native',
+  '@deepseek-ai/dsh-host-directory-picker-browse',
+]
 const jsExprType = new yaml.Type('tag:yaml.org,2002:js', {
   kind: 'scalar',
   resolve: data => typeof data === 'string',
@@ -134,12 +148,18 @@ function missingPluginDependencies(
   manifestPath: string,
 ): string[] {
   const requiredPackages = new Map<string, Set<string>>()
+  const require = (packageName: string, file: string): void => {
+    const locations = requiredPackages.get(packageName) ?? new Set<string>()
+    locations.add(file)
+    requiredPackages.set(packageName, locations)
+  }
   for (const reference of references) {
     const packageName = packageNameFromSpecifier(reference.name)
     if (packageName === undefined) continue
-    const locations = requiredPackages.get(packageName) ?? new Set<string>()
-    locations.add(reference.file)
-    requiredPackages.set(packageName, locations)
+    require(packageName, reference.file)
+    if (packageName === CHOOSER_PACKAGE) {
+      for (const backend of CHOOSER_BACKEND_PACKAGES) require(backend, reference.file)
+    }
   }
   return [...requiredPackages].flatMap(([packageName, locations]) => packageName in dependencies
     ? []