From 4308f91e8894a4b279a0905abf4b7f4cbbd2b16e Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Tue, 11 Aug 2026 15:11:45 +0800 Subject: [PATCH] refactor(bundle): fold the Windows shell platform layer into the base rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Entry \disabled\ interpolation makes the launcher's separate platform layer unnecessary: the base bundle's cordis.patch.yml now gates both shell stacks on its own rows — bash-sandbox/tool-bash disable on win32, and their twins pwsh-sandbox/tool-pwsh mount only there with the inverted expression — so exactly one shell stack mounts per host from one shared patch file. windows.cordis.patch.yml and the launcher's windows-shell.ts injection (boot, live recomposition, config dumps) are deleted, with the workspace-constraints entry and the dsh-base exports/files entries following. The windows-shell spec pins the effective per-platform roster through the real bundle layers, and base.spec pins the four symmetric gates. The superseded active notes are updated and cross-linked; the loader note records the fold itself. --- ...26-07-20-dsh-cli-personal-config.i18n.yaml | 4 +- .../2026-07-20-dsh-cli-personal-config.md | 2 +- .../2026-07-20-dsh-cli-personal-config.zh.md | 2 +- ...7-26-code-dispatch-ui-foundation.i18n.yaml | 4 +- .../2026-07-26-code-dispatch-ui-foundation.md | 2 +- ...26-07-26-code-dispatch-ui-foundation.zh.md | 2 +- .../2026-08-01-windows-pwsh-default.i18n.yaml | 4 +- .../2026-08-01-windows-pwsh-default.md | 16 +- .../2026-08-01-windows-pwsh-default.zh.md | 16 +- ...der-entry-disabled-interpolation.i18n.yaml | 4 +- ...-11-loader-entry-disabled-interpolation.md | 8 +- ...-loader-entry-disabled-interpolation.zh.md | 8 +- apps/cli/composition.md | 6 + .../agent-presets/code/agent.cordis.yml | 6 +- .../agent-presets/cordis/agent.cordis.yml | 6 +- .../agent-presets/standard/agent.cordis.yml | 6 +- apps/cli/src/dump-config.ts | 7 - apps/cli/src/profile-boot.ts | 18 +- apps/cli/src/windows-shell.ts | 52 ------ apps/cli/tests/windows-shell.spec.ts | 166 +++++++----------- packages/bundle/base/README.md | 2 +- packages/bundle/base/README.zh.md | 2 +- packages/bundle/base/cordis.patch.yml | 17 ++ packages/bundle/base/package.json | 2 - packages/bundle/base/tests/base.spec.ts | 58 +++--- packages/bundle/base/windows.cordis.patch.yml | 31 ---- scripts/check-workspace-constraints.ts | 5 +- 27 files changed, 173 insertions(+), 283 deletions(-) delete mode 100644 apps/cli/src/windows-shell.ts delete mode 100644 packages/bundle/base/windows.cordis.patch.yml diff --git a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.i18n.yaml b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.i18n.yaml index 9d13851a0d..4ec2811550 100644 --- a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.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-20-dsh-cli-personal-config.md -2026-07-20-dsh-cli-personal-config.md: ed04725e92848bbab550a27ef2f4c021536f765e -2026-07-20-dsh-cli-personal-config.zh.md: cc97987f803f7fb513e94ce0ce079558f5e3dc75 +2026-07-20-dsh-cli-personal-config.md: c910d28e2c616b1348e95d0a8731a9b47fc03edf +2026-07-20-dsh-cli-personal-config.zh.md: 161169af11c2c054ac5b1ae3df74fe963e57aec5 diff --git a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md index ed04725e92..c910d28e2c 100644 --- a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md +++ b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md @@ -6,7 +6,7 @@ English | [中文](2026-07-20-dsh-cli-personal-config.zh.md) ## Problem -A developer's own preferences — which provider and model the TUI uses, personal credentials, a private adapter route — had nowhere to live except edits to committed files. Pointing the TUI demo at a personal Anthropic-proxy Opus route meant patching `examples/tui-agent/cordis.yml` and `.env` in the working tree, which risks committing secrets and repeats per checkout. There was also no installable command: running the agent in an arbitrary project directory required invoking the repo's demo script from the repo root. Loader metadata is static, so "conditional composition uses overlays" (AGENTS.md) — but overlays only existed as committed sibling files, not as a machine-level layer. +A developer's own preferences — which provider and model the TUI uses, personal credentials, a private adapter route — had nowhere to live except edits to committed files. Pointing the TUI demo at a personal Anthropic-proxy Opus route meant patching `examples/tui-agent/cordis.yml` and `.env` in the working tree, which risks committing secrets and repeats per checkout. There was also no installable command: running the agent in an arbitrary project directory required invoking the repo's demo script from the repo root. Loader metadata is static except the entry `disabled` field (see the [loader `disabled` interpolation decision](../process/2026-08-11-loader-entry-disabled-interpolation.md)), so "conditional composition uses overlays" (AGENTS.md) — but overlays only existed as committed sibling files, not as a machine-level layer. ## Decision diff --git a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.zh.md b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.zh.md index cc97987f80..161169af11 100644 --- a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.zh.md +++ b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.zh.md @@ -6,7 +6,7 @@ Status: implemented ## Problem -开发者自己的偏好——TUI 使用哪个提供方和模型、个人凭证、私有的适配器路由——除了改动已提交的文件之外无处安放。要把 TUI 示例指向个人的 Anthropic 代理 Opus 路由,只能在工作区里改 `examples/tui-agent/cordis.yml` 和 `.env`,既有提交密钥的风险,又要在每个 checkout 里重复一遍。也没有可安装的命令:想在任意项目目录里运行这个 agent,必须回到仓库根目录调用示例脚本。Loader 元数据是静态的,所以「条件组合使用 overlay」(AGENTS.md)——但 overlay 此前只以已提交的同级文件形式存在,没有机器级的层。 +开发者自己的偏好——TUI 使用哪个提供方和模型、个人凭证、私有的适配器路由——除了改动已提交的文件之外无处安放。要把 TUI 示例指向个人的 Anthropic 代理 Opus 路由,只能在工作区里改 `examples/tui-agent/cordis.yml` 和 `.env`,既有提交密钥的风险,又要在每个 checkout 里重复一遍。也没有可安装的命令:想在任意项目目录里运行这个 agent,必须回到仓库根目录调用示例脚本。Loader 元数据是静态的——条目 `disabled` 字段除外(见 [loader `disabled` 插值决策](../process/2026-08-11-loader-entry-disabled-interpolation.md))——所以「条件组合使用 overlay」(AGENTS.md);但 overlay 此前只以已提交的同级文件形式存在,没有机器级的层。 ## Decision diff --git a/.agents/notes/implemented/feature/2026-07-26-code-dispatch-ui-foundation.i18n.yaml b/.agents/notes/implemented/feature/2026-07-26-code-dispatch-ui-foundation.i18n.yaml index 4e2d35175f..49952e51cf 100644 --- a/.agents/notes/implemented/feature/2026-07-26-code-dispatch-ui-foundation.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-26-code-dispatch-ui-foundation.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-26-code-dispatch-ui-foundation.md -2026-07-26-code-dispatch-ui-foundation.md: 4115a1898de7d2cce01346c3f005fcd19c325f4c -2026-07-26-code-dispatch-ui-foundation.zh.md: aeb57b93d781163dd0a4747ac03053c65deda1db +2026-07-26-code-dispatch-ui-foundation.md: f98e919c2459327c8a42f31bea1fd115ce78b361 +2026-07-26-code-dispatch-ui-foundation.zh.md: a35864b3e2e2e2facb8d464d175a1e8f4d160ec1 diff --git a/.agents/notes/implemented/feature/2026-07-26-code-dispatch-ui-foundation.md b/.agents/notes/implemented/feature/2026-07-26-code-dispatch-ui-foundation.md index 4115a1898d..f98e919c24 100644 --- a/.agents/notes/implemented/feature/2026-07-26-code-dispatch-ui-foundation.md +++ b/.agents/notes/implemented/feature/2026-07-26-code-dispatch-ui-foundation.md @@ -16,7 +16,7 @@ Three changes, one per obstacle: 1. **`run_code` gains a required `description` parameter** (bash's exact contract: active voice, 5-10 words, shown in the UI; whitespace-only rejected at execute). `presentCall` now titles the card with the description and moves the program to `rawInput`. The prompt-side cost is a few tokens per call; the return is that every surface — TUI card, ACP title, web row — gets a human-readable label without parsing TypeScript. 2. **`tool/code-dispatch` logs the sub-call's complete model-facing outcome** — `content: ContentBlock[]` + `isError`, the `tool/result` vocabulary — replacing `resultSummary` and deleting the summarize/cwd-normalization machinery outright. A UI renders a sub-call through the identical code path as a native result, including error text and non-text blocks. The event stays log-only (`deriveMessages()` ignores it): nothing about model context changes. -3. **`DSH_TOOLS_MODE` env var on the `dsh` config tree** (`native`|`code`|`both`; unset keeps the schema default): the `tools` row reads it via `!!js`, and the worker code runtime is mounted unconditionally (Loader metadata is static, so no conditional row exists; a native boot only registers the service — workers spawn per run). This is an explicitly temporary configuration hook: per-session tool-mode selection owned by the web UI is the design goal, and the env var dies when that lands. +3. **`DSH_TOOLS_MODE` env var on the `dsh` config tree** (`native`|`code`|`both`; unset keeps the schema default): the `tools` row reads it via `!!js`, and the worker code runtime is mounted unconditionally (Loader metadata was static when this shipped — no conditional row existed; the later [`disabled` interpolation decision](../process/2026-08-11-loader-entry-disabled-interpolation.md) makes one possible but changes nothing here — a native boot only registers the service, workers spawn per run). This is an explicitly temporary configuration hook: per-session tool-mode selection owned by the web UI is the design goal, and the env var dies when that lands. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-07-26-code-dispatch-ui-foundation.zh.md b/.agents/notes/implemented/feature/2026-07-26-code-dispatch-ui-foundation.zh.md index aeb57b93d7..a35864b3e2 100644 --- a/.agents/notes/implemented/feature/2026-07-26-code-dispatch-ui-foundation.zh.md +++ b/.agents/notes/implemented/feature/2026-07-26-code-dispatch-ui-foundation.zh.md @@ -16,7 +16,7 @@ Status: implemented 1. **`run_code` 新增必填的 `description` 参数**(与 bash 完全相同的约定:主动语态、5-10 个词、展示在 UI 中;仅含空白的取值在执行时被拒绝)。`presentCall` 现在以该 description 作为卡片标题,并把程序文本移入 `rawInput`。提示词侧的成本是每次调用多出几个 token;换来的是每个表面——TUI 卡片、ACP(Agent Client Protocol)标题、Web 行——都无需解析 TypeScript 就能获得可供人阅读的标签。 2. **`tool/code-dispatch` 记录子调用面向模型的完整结果**(`content: ContentBlock[]` 加 `isError`,即 `tool/result` 的词汇),取代 `resultSummary`,并把摘要与 cwd 归一化机制彻底删除。UI 渲染子调用走的代码路径与渲染原生结果完全相同,包括错误文本和非文本块。该事件保持仅日志(`deriveMessages()` 忽略它):模型上下文没有任何变化。 -3. **`dsh` 配置树上的 `DSH_TOOLS_MODE` 环境变量**(`native`|`code`|`both`;未设置时保持 schema 默认值):`tools` 行通过 `!!js` 读取它,worker 代码运行时则无条件挂载(loader 元数据是静态的,因此不存在条件行;native 启动只是注册该服务,worker 要到每次运行时才 spawn)。这是一个明确标注为临时的配置钩子:设计目标是让 Web UI 拥有按会话的工具模式选择,该目标落地后,这个环境变量随即退役。 +3. **`dsh` 配置树上的 `DSH_TOOLS_MODE` 环境变量**(`native`|`code`|`both`;未设置时保持 schema 默认值):`tools` 行通过 `!!js` 读取它,worker 代码运行时则无条件挂载(本项交付时 loader 元数据仍是静态的,因此不存在条件行;后来的 [`disabled` 插值决策](../process/2026-08-11-loader-entry-disabled-interpolation.md) 让条件行成为可能,但此处不变——native 启动只是注册该服务,worker 要到每次运行时才 spawn)。这是一个明确标注为临时的配置钩子:设计目标是让 Web UI 拥有按会话的工具模式选择,该目标落地后,这个环境变量随即退役。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.i18n.yaml b/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.i18n.yaml index 49b713f534..49b47465e0 100644 --- a/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-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-08-01-windows-pwsh-default.md -2026-08-01-windows-pwsh-default.md: f0da86e52bcdd53a10b60164d7cc12261cfc5c49 -2026-08-01-windows-pwsh-default.zh.md: 41a6429eab8f86a8960ac4aa372aeacfda4661c4 +2026-08-01-windows-pwsh-default.md: 5f1b1e4046bdbd1cc433080c5f8814a3ae9f9a3d +2026-08-01-windows-pwsh-default.zh.md: 0bbbc815813ea208b73441ae4911a94e4fde7a91 diff --git a/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.md b/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.md index f0da86e52b..5f1b1e4046 100644 --- a/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.md +++ b/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.md @@ -12,9 +12,8 @@ The harness's shipped execution profile is bash-first on every platform. Windows Windows hosts booting a shipped profile (`dsh web`, `dsh --profile headless`, one-shot tasks) get the PowerShell stack by default; POSIX hosts are unchanged. -- **The platform layer is a data file, not a roster rewrite.** `@deepseek-ai/dsh-base` ships [`windows.cordis.patch.yml`](../../../../packages/bundle/base/windows.cordis.patch.yml) alongside its universal `cordis.patch.yml`: it disables `bash-sandbox`/`tool-bash` (the POSIX-only executor and its dialect tool) and inserts `pwsh-local`/`tool-pwsh`. Windows has no OS sandbox runner (landlock/bwrap/seatbelt are POSIX-only), so the layer drops the sandbox stack entirely — `sandbox`, `sandbox-policy`, and `fs-sandbox` are disabled and the unconfined `dsh-fs-local` provides `ctx.fs` — and degrades to danger-full-access: `permission`/`ui-permission` leave the roster (dsh-permission requires a confining executor — presets bundle a sandbox mode the unconfined executor cannot honor; see its constructor guard — and the client knob would advertise a boundary that does not exist), and the `approval` service is disabled — nothing in the Windows roster asks for approval, so the model is never told approval exists or that asks are auto-rejected. Keeping fs-only path rules would be theater: the unconfined shell can bypass them with one command, so the honest Windows posture is full access rather than a boundary only the fs tools pretend to enforce. -- **The launcher injects the layer by platform.** `apps/cli/src/windows-shell.ts` resolves it from the base bundle layer's `packageDir` between the bundle layers and the user layers on `win32` hosts, in every composition path (boot, config-only HMR recomposition, config dumps). Overriding the shipped default is a composition decision: a Windows host that prefers the bash stack — or confinement — re-enables the bash rows through its profile or home `cordis.patch.yml`. Custom profiles without the base bundle are skipped (they own their shell stack); a base bundle that ships no Windows shell patch fails loud. -- **Module resolution is restored for cold starts.** The profiles-rework CLI dropped the pwsh packages from `apps/cli`'s dependency closure, so `healProfilesModuleFallback` never linked them into `$DSH_HOME/profiles/node_modules` and a fresh Windows host could not resolve the inserted rows. `apps/cli` and `dsh-base` re-declare `dsh-pwsh-local`/`dsh-tool-pwsh`, and `dsh-base` also declares `dsh-fs-local`; the base bundle lists every row plugin as a dependency by house style. +- **The base patch gates both shell stacks on its own rows** (the [loader `disabled` interpolation](../process/2026-08-11-loader-entry-disabled-interpolation.md) note records the mechanism and the platform-layer fold): `bash-sandbox`/`tool-bash` carry `disabled: !!js process.platform === 'win32'` (bash has no Windows runner), and their twins `pwsh-sandbox`/`tool-pwsh` mount only on win32 with the inverted expression — one shared patch file, exactly one shell stack per host. The confined pwsh stack runs over the ACL restricted-token runner, and the permission surface stays exactly as on POSIX (the [Windows ACL restricted-token sandbox](2026-08-08-windows-acl-restricted-token-sandbox.md) note owns that roster). Overriding the shipped default is a composition decision: a Windows host that prefers the bash stack or an unconfined pwsh executor overrides these rows through its profile or home `cordis.patch.yml` (the bash-restore recipe must be complete: disable `pwsh-sandbox`/`tool-pwsh` AND re-enable `bash-sandbox`/`tool-bash` — both executor families register the same `bash` service, so an incomplete recipe fails loud at load) — composition config is the one override channel. The separate `windows.cordis.patch.yml` layer and the launcher's `apps/cli/src/windows-shell.ts` injection are deleted; the layer existed only because entry metadata was static. +- **Module resolution is restored for cold starts.** The profiles-rework CLI dropped the pwsh packages from `apps/cli`'s dependency closure, so `healProfilesModuleFallback` never linked them into `$DSH_HOME/profiles/node_modules` and a fresh Windows host could not resolve the pwsh rows. `apps/cli` and `dsh-base` declare `dsh-pwsh-sandbox`/`dsh-tool-pwsh`; the base bundle lists every row plugin as a dependency by house style. The pwsh GUI rendering shipped earlier with the [pwsh UI presentation matches bash decision](2026-08-05-pwsh-ui-bash-parity.md); the [pwsh tool bash parity decision](2026-08-02-pwsh-tool-bash-parity.md) ships the tool's surface. Nothing in this decision changes POSIX behavior. @@ -32,13 +31,12 @@ The pwsh GUI rendering shipped earlier with the [pwsh UI presentation matches ba ## Consequences -- A Windows host running a shipped `dsh` surface gets `pwsh` as its shell tool and PowerShell as the `ctx.bash` executor without configuration; `bash` is absent from the model-visible roster there (its tool row is disabled). -- Windows has no sandbox at all: the fs tools run unconfined (`dsh-fs-local`), the approval service is absent (nothing asks for approval, and the model is never told approval exists), and the permission switcher is gone. The model-visible posture is honest full access rather than a boundary the shell can bypass. -- POSIX hosts are unchanged: the platform layer never applies, and the bash stack remains the universal `cordis.patch.yml` rows. -- Windows hosts that prefer the bash stack (e.g. with WSL/Git-Bash on PATH) override the shipped default through their profile or home `cordis.patch.yml` — disabling `pwsh-local`/`tool-pwsh` and re-enabling `bash-sandbox`/`tool-bash` (both executors register the same `bash` service, so an incomplete recipe fails loud at load) — composition config is the one override channel. +- A Windows host running a shipped `dsh` surface gets the confined `pwsh` as its shell tool and PowerShell as the `ctx.bash` executor without configuration; `bash` is absent from the model-visible roster there (its tool row is disabled). +- POSIX hosts mount the bash stack as before; the pwsh rows sit disabled in their composition, because the one shared patch file lists both stacks and each row gates itself. +- A Windows host that prefers the bash stack (e.g. with WSL/Git-Bash on PATH) overrides the shipped rows through its profile or home `cordis.patch.yml` — disabling `pwsh-sandbox`/`tool-pwsh` and re-enabling `bash-sandbox`/`tool-bash` (both executors register the same `bash` service, so an incomplete recipe fails loud at load) — composition config is the one override channel. ## Verification -- Unit: `apps/cli/tests/windows-shell.spec.ts` pins the win32 default, the custom-profile skip, and the missing-patch failure with the platform injected, and composes the REAL shipped bundle layers (dsh-base + dsh-web-app resolved from the app installation) through the boot's patch algorithm to assert the win32 danger-full-access roster and the base-only-profile warning; `packages/bundle/base/tests/base.spec.ts` pins the shipped Windows patch file shape (disables, inserts, and the absent approval service). -- Keyless: a win32 `dsh --profile --dump-config` shows the pwsh rows with `windows.cordis.patch.yml` provenance and the bash rows disabled; the POSIX dump (CI Linux) is unchanged. +- Unit: `apps/cli/tests/windows-shell.spec.ts` composes the REAL shipped bundle layers (dsh-base + dsh-web-app resolved from the app installation) through the boot's patch algorithm and pins the effective per-platform roster — the win32 pwsh roster, the POSIX bash roster, and the base-only profile — plus the preset-level tool-bash gate and the cold-start resolution closure; `packages/bundle/base/tests/base.spec.ts` pins the four shell rows' symmetric `!!js` platform gates and that no separate platform patch ships. +- Keyless: a `dsh --profile --dump-config` shows both stacks in the one shared patch layer, with each row's own `disabled` expression deciding the roster at mount. - The real-composition smoke boots the web profile on win32 with the pwsh stack mounted (the exact roster this note describes). diff --git a/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.zh.md b/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.zh.md index 41a6429eab..0bbbc81581 100644 --- a/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.zh.md +++ b/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.zh.md @@ -12,9 +12,8 @@ harness 交付的执行画像在每个平台都是 bash 优先。Windows 主机 启动交付 profile(`dsh web`、`dsh --profile headless`、一次性任务)的 Windows 主机默认获得 PowerShell 栈;POSIX 主机不变。 -- **平台层是数据文件,不是清单重写。** `@deepseek-ai/dsh-base` 随通用 `cordis.patch.yml` 一起交付 [`windows.cordis.patch.yml`](../../../../packages/bundle/base/windows.cordis.patch.yml):它禁用 `bash-sandbox`/`tool-bash`(仅 POSIX 的执行器及其方言工具)并插入 `pwsh-local`/`tool-pwsh`。Windows 上没有 OS 级 sandbox runner(landlock/bwrap/seatbelt 均为 POSIX 专属),因此该层整体移除 sandbox 栈——`sandbox`、`sandbox-policy`、`fs-sandbox` 被禁用,由不限权的 `dsh-fs-local` 提供 `ctx.fs`——并完全退化为 danger-full-access:`permission`/`ui-permission` 离开清单(dsh-permission 要求有限权能力的执行器——preset 捆绑的是无限制执行器无法兑现的 sandbox 模式;见其构造函数守卫——客户端旋钮会宣传一个并不存在的边界),`approval` 服务也被禁用——Windows 清单里没有任何动作需要审批,模型也不会被告知"审批存在"或"请求会被自动拒绝"。保留仅限 fs 的路径规则是摆设:不限权的 shell 一条命令即可绕过,因此诚实的 Windows 姿态是全权访问,而不是一个只有 fs 工具假装执行的边界。 -- **启动器按平台注入该层。** `apps/cli/src/windows-shell.ts` 在 `win32` 主机上从 base bundle 层的 `packageDir` 解析它,置于 bundle 层与用户层之间,覆盖所有组合路径(启动、config-only HMR 重组合、配置转储)。覆盖交付默认是组合决策:偏好 bash 栈(或偏好有限权)的 Windows 主机通过其 profile 或 home 的 `cordis.patch.yml` 重新启用 bash 行。未挂 base bundle 的自定义 profile 被跳过(它们自己拥有 shell 栈);base bundle 缺 `windows.cordis.patch.yml` 时 fail loud。 -- **冷启动的模块解析已恢复。** profiles 重构把 pwsh 包从 `apps/cli` 的依赖闭包中删掉了,`healProfilesModuleFallback` 因此从未把它们链接进 `$DSH_HOME/profiles/node_modules`,新 Windows 主机解析不到插入的行。`apps/cli` 与 `dsh-base` 重新声明 `dsh-pwsh-local`/`dsh-tool-pwsh`,`dsh-base` 还声明 `dsh-fs-local`;按仓库惯例,base bundle 把每个行插件都列为依赖。 +- **base patch 在自身行上按平台门控两个 shell 栈**([loader `disabled` 插值](../process/2026-08-11-loader-entry-disabled-interpolation.md) note 记录了该机制与平台层折叠):`bash-sandbox`/`tool-bash` 携带 `disabled: !!js process.platform === 'win32'`(bash 没有 Windows runner),它们的孪生行 `pwsh-sandbox`/`tool-pwsh` 以取反的表达式仅在 win32 挂载——同一份 patch 文件,每个宿主恰好挂载一个 shell 栈。受限 pwsh 栈运行在 ACL 受限令牌 runner 之上,权限面与 POSIX 完全一致([Windows ACL 受限令牌沙箱](2026-08-08-windows-acl-restricted-token-sandbox.md) note 拥有该清单)。覆盖交付默认是组合决策:偏好 bash 栈或不限权 pwsh 执行器的 Windows 主机通过其 profile 或 home 的 `cordis.patch.yml` 覆盖这些行(bash 恢复配方必须完整:禁用 `pwsh-sandbox`/`tool-pwsh` 并重新启用 `bash-sandbox`/`tool-bash`——两个执行器家族注册同一个 `bash` 服务,配方不完整会在加载时 fail loud)——组合配置是唯一的覆盖通道。独立的 `windows.cordis.patch.yml` 层与启动器的 `apps/cli/src/windows-shell.ts` 注入已删除;该层只因条目元数据是静态的而存在。 +- **冷启动的模块解析已恢复。** profiles 重构把 pwsh 包从 `apps/cli` 的依赖闭包中删掉了,`healProfilesModuleFallback` 因此从未把它们链接进 `$DSH_HOME/profiles/node_modules`,新 Windows 主机解析不到 pwsh 行。`apps/cli` 与 `dsh-base` 声明 `dsh-pwsh-sandbox`/`dsh-tool-pwsh`;按仓库惯例,base bundle 把每个行插件都列为依赖。 pwsh GUI 渲染已随 [pwsh UI 呈现与 bash 对齐决策](2026-08-05-pwsh-ui-bash-parity.md) 先行交付;[pwsh 工具与 bash 对齐决策](2026-08-02-pwsh-tool-bash-parity.md) 交付了工具表面。本决策不改变任何 POSIX 行为。 @@ -32,13 +31,12 @@ pwsh GUI 渲染已随 [pwsh UI 呈现与 bash 对齐决策](2026-08-05-pwsh-ui-b ## 后果 -- 运行交付版 `dsh` 表面的 Windows 主机无需配置即获得 `pwsh` 作为 shell 工具、PowerShell 作为 `ctx.bash` 执行器;那里的模型可见清单中没有 `bash`(其工具行被禁用)。 -- Windows 上没有任何沙箱:fs 工具不限权运行(`dsh-fs-local`)、`approval` 服务不存在(没有任何动作需要审批,模型也不会被告知审批存在)、权限切换器消失。模型可见的姿态是诚实的全权访问,而不是一个 shell 可以绕过的边界。 -- POSIX 主机不变:平台层永不生效,bash 栈仍是通用 `cordis.patch.yml` 的行。 -- 偏好 bash 栈的 Windows 主机(例如 PATH 上有 WSL/Git-Bash 时)通过其 profile 或 home 的 `cordis.patch.yml` 覆盖交付默认——禁用 `pwsh-local`/`tool-pwsh` 并重新启用 `bash-sandbox`/`tool-bash`(两个执行器注册同一个 `bash` 服务,配方不完整会在加载时 fail loud)——组合配置是唯一的覆盖通道。 +- 运行交付版 `dsh` 表面的 Windows 主机无需配置即获得受限 `pwsh` 作为 shell 工具、PowerShell 作为 `ctx.bash` 执行器;那里的模型可见清单中没有 `bash`(其工具行被禁用)。 +- POSIX 主机如常挂载 bash 栈;pwsh 行以其自身的门控表达式处于禁用状态——同一份共享 patch 文件列出两个栈,每个行自己决定挂载。 +- 偏好 bash 栈的 Windows 主机(例如 PATH 上有 WSL/Git-Bash 时)通过其 profile 或 home 的 `cordis.patch.yml` 覆盖交付行——禁用 `pwsh-sandbox`/`tool-pwsh` 并重新启用 `bash-sandbox`/`tool-bash`(两个执行器注册同一个 `bash` 服务,配方不完整会在加载时 fail loud)——组合配置是唯一的覆盖通道。 ## 验证 -- 单元:`apps/cli/tests/windows-shell.spec.ts` 以平台注入固定 win32 默认、自定义 profile 跳过与缺文件失败,并通过启动所用的 patch 算法组合真实交付的 bundle 层(从应用安装解析的 dsh-base + dsh-web-app)断言 win32 danger-full-access 清单与 base-only profile 警告;`packages/bundle/base/tests/base.spec.ts` 固定交付的 Windows patch 文件形状(禁用、插入与缺席的 approval 服务)。 -- Keyless:win32 上的 `dsh --profile --dump-config` 显示带 `windows.cordis.patch.yml` 出处的 pwsh 行、被禁用的 bash 行;POSIX 转储(CI Linux)不变。 +- 单元:`apps/cli/tests/windows-shell.spec.ts` 通过启动所用的 patch 算法组合真实交付的 bundle 层(从应用安装解析的 dsh-base + dsh-web-app),固定每个平台的有效清单——win32 pwsh 清单、POSIX bash 清单与 base-only profile——外加预设级 tool-bash 门控与冷启动解析闭包;`packages/bundle/base/tests/base.spec.ts` 固定四个 shell 行的对称 `!!js` 平台门控,并断言不再交付独立的平台 patch。 +- Keyless:`dsh --profile --dump-config` 在同一份共享 patch 层中显示两个栈,每个行以自己的 `disabled` 表达式在挂载时决定清单。 - 真实组合冒烟在 win32 上启动 web profile,pwsh 栈挂载成功(即本笔记描述的确切清单)。 diff --git a/.agents/notes/implemented/process/2026-08-11-loader-entry-disabled-interpolation.i18n.yaml b/.agents/notes/implemented/process/2026-08-11-loader-entry-disabled-interpolation.i18n.yaml index 1d51763b47..0a40f082d8 100644 --- a/.agents/notes/implemented/process/2026-08-11-loader-entry-disabled-interpolation.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-11-loader-entry-disabled-interpolation.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/process/2026-08-11-loader-entry-disabled-interpolation.md -2026-08-11-loader-entry-disabled-interpolation.md: d2ddbc7d00c8f3e493912f826ee573f355b3b71d -2026-08-11-loader-entry-disabled-interpolation.zh.md: cc139202e4ea79dfbae1c89cef5d7eda4ab2011f +2026-08-11-loader-entry-disabled-interpolation.md: c916c6b667fc85f68e79e33edf5a1a63921b2d71 +2026-08-11-loader-entry-disabled-interpolation.zh.md: b5f5a527dcdfe2a906001e31aa4a32884e67f3ab diff --git a/.agents/notes/implemented/process/2026-08-11-loader-entry-disabled-interpolation.md b/.agents/notes/implemented/process/2026-08-11-loader-entry-disabled-interpolation.md index d2ddbc7d00..c916c6b667 100644 --- a/.agents/notes/implemented/process/2026-08-11-loader-entry-disabled-interpolation.md +++ b/.agents/notes/implemented/process/2026-08-11-loader-entry-disabled-interpolation.md @@ -6,18 +6,20 @@ English | [中文](2026-08-11-loader-entry-disabled-interpolation.zh.md) ## Problem -The Windows platform layer (`packages/bundle/base/windows.cordis.patch.yml`) disables `tool-bash` on win32, but the shipped presets each mount a `tool-bash` row. Preset rows compose last, so the same-id row re-enabled the tool on Windows — the session had both `tool-bash` (PowerShell-backed) and `tool-pwsh`, silently, because no spec pinned the composed preset layer. Entry metadata had no conditional mechanism: `!!js` interpolates only under plugin `config`, and [postmortem 0002](../../../../docs/postmortem/0002-js-expression-disabled-filesystem-tools.md) documents that `disabled: !!js ...` stays a truthy expression object, disabling the row everywhere. +The Windows platform layer (then a separate `packages/bundle/base/windows.cordis.patch.yml`, since folded into the base patch — see Decision) disabled `tool-bash` on win32, but the shipped presets each mount a `tool-bash` row. Preset rows compose last, so the same-id row re-enabled the tool on Windows — the session had both `tool-bash` (PowerShell-backed) and `tool-pwsh`, silently, because no spec pinned the composed preset layer. Entry metadata had no conditional mechanism: `!!js` interpolates only under plugin `config`, and [postmortem 0002](../../../../docs/postmortem/0002-js-expression-disabled-filesystem-tools.md) documents that `disabled: !!js ...` stays a truthy expression object, disabling the row everywhere. ## Decision The Loader interpolates the entry `disabled` field (`vendor/loader/src/config/entry.ts`): a `!!js` expression evaluates against the loader context at every mount decision. `disabled` is the only interpolated metadata field; `id`, `name`, `group`, and `inject` stay static. The raw node stays in the options, so write-back keeps the `!!js` form. The shipped presets (standard, code, cordis) gate `tool-bash` with `disabled: !!js process.platform === 'win32'`, and `verify-cordis-config` now allows expressions in `disabled` only. +The mechanism completes the platform-layer fold: the base bundle's `cordis.patch.yml` gates both shell stacks on its own rows — `bash-sandbox`/`tool-bash` carry `disabled: !!js process.platform === 'win32'`, and their twins `pwsh-sandbox`/`tool-pwsh` mount only on win32 with the inverted expression. The launcher's separate Windows platform layer (`windows.cordis.patch.yml` plus `apps/cli/src/windows-shell.ts` and its injection into boot, live recomposition, and config dumps) is deleted — the layer existed only because entry metadata was static, and with `disabled` interpolated the condition lives on the row it governs. + ## Alternatives considered **A declarative `platform` field on the row.** Static and gate-checkable, but a second composition mechanism beside `!!js`, and platform is only today's condition. -**Preset-level platform overlays.** Rejected: the condition belongs on the row it governs. +**Preset-level platform overlays.** Rejected: the condition belongs on the row it governs — the same principle folds the launcher's separate Windows platform layer into the base rows. ## Consequences -A row can gate itself on platform or environment; a bad expression fails loud at boot. Every other metadata field remains literal and the gate keeps rejecting expressions there — the postmortem-0002 hazard is closed for `disabled` by evaluation, not prohibition. The `minimal` preset's missing win32 PTY stack is a preset-metadata follow-up. +A row can gate itself on platform or environment; a bad expression fails loud at boot. Every other metadata field remains literal and the gate keeps rejecting expressions there — the postmortem-0002 hazard is closed for `disabled` by evaluation, not prohibition. The Windows shell swap moved from a launcher-injected patch layer to the base bundle's own rows: win32 mounts the confined pwsh stack, POSIX carries the pwsh rows disabled, and one shared patch file serves both rosters — the [Windows pwsh default](../feature/2026-08-01-windows-pwsh-default.md) note's layer mechanism is superseded. The `minimal` preset's missing win32 PTY stack is a preset-metadata follow-up. diff --git a/.agents/notes/implemented/process/2026-08-11-loader-entry-disabled-interpolation.zh.md b/.agents/notes/implemented/process/2026-08-11-loader-entry-disabled-interpolation.zh.md index cc139202e4..b5f5a527dc 100644 --- a/.agents/notes/implemented/process/2026-08-11-loader-entry-disabled-interpolation.zh.md +++ b/.agents/notes/implemented/process/2026-08-11-loader-entry-disabled-interpolation.zh.md @@ -6,18 +6,20 @@ Status: implemented ## 问题 -Windows 平台层(`packages/bundle/base/windows.cordis.patch.yml`)在 win32 上禁用 `tool-bash`,但 shipped 预设各自挂载了一行 `tool-bash`。预设行最后组合,同名行在 Windows 上重新启用了该工具——会话同时拥有 `tool-bash`(PowerShell 后端)与 `tool-pwsh`,且是静默的,因为没有 spec pin 组合后的预设层。条目元数据没有条件机制:`!!js` 只在插件 `config` 下插值,[postmortem 0002](../../../../docs/postmortem/0002-js-expression-disabled-filesystem-tools.md) 记录了 `disabled: !!js ...` 保持真值表达式对象、在所有平台上禁用该行的事故。 +Windows 平台层(当时是独立的 `packages/bundle/base/windows.cordis.patch.yml`,现已折入 base patch——见「决策」)在 win32 上禁用 `tool-bash`,但 shipped 预设各自挂载了一行 `tool-bash`。预设行最后组合,同名行在 Windows 上重新启用了该工具——会话同时拥有 `tool-bash`(PowerShell 后端)与 `tool-pwsh`,且是静默的,因为没有 spec pin 组合后的预设层。条目元数据没有条件机制:`!!js` 只在插件 `config` 下插值,[postmortem 0002](../../../../docs/postmortem/0002-js-expression-disabled-filesystem-tools.md) 记录了 `disabled: !!js ...` 保持真值表达式对象、在所有平台上禁用该行的事故。 ## 决策 Loader 插值条目 `disabled` 字段(`vendor/loader/src/config/entry.ts`):`!!js` 表达式在每次挂载决策时基于 loader 上下文求值。`disabled` 是唯一被插值的元数据字段;`id`、`name`、`group`、`inject` 保持静态。原始节点保留在 options 中,写回保持 `!!js` 形式。shipped 预设(standard、code、cordis)用 `disabled: !!js process.platform === 'win32'` 门控 `tool-bash`,`verify-cordis-config` 现在只允许 `disabled` 中的表达式。 +该机制补全了平台层折叠:base bundle 的 `cordis.patch.yml` 在自身行上按平台门控两个 shell 栈——`bash-sandbox`/`tool-bash` 携带 `disabled: !!js process.platform === 'win32'`,它们的孪生行 `pwsh-sandbox`/`tool-pwsh` 以取反的表达式仅在 win32 挂载。启动器的独立 Windows 平台层(`windows.cordis.patch.yml` 以及 `apps/cli/src/windows-shell.ts` 及其注入到 boot、live 重组合、config dump 的逻辑)被删除——该层只因条目元数据是静态的而存在,`disabled` 可插值后条件就落在它所治理的行上。 + ## 备选方案 **行上的声明式 `platform` 字段。** 静态且可被门禁检查,但它是 `!!js` 之外的第二种组合机制,且平台只是今天的条件。 -**预设级平台 overlay。** 被否:条件应当属于它所治理的行。 +**预设级平台 overlay。** 被否:条件应当属于它所治理的行——同一原则把启动器独立的 Windows 平台层折入 base 行。 ## 后果 -行可以按平台或环境门控自身;错误的表达式在启动时响亮失败。其余元数据字段保持字面值,门禁继续拒绝那里的表达式——`disabled` 上的 postmortem-0002 隐患以「求值」而非「禁止」关闭。`minimal` 预设缺失的 win32 PTY 栈是预设元数据的后续工作。 +行可以按平台或环境门控自身;错误的表达式在启动时响亮失败。其余元数据字段保持字面值,门禁继续拒绝那里的表达式——`disabled` 上的 postmortem-0002 隐患以「求值」而非「禁止」关闭。Windows shell 栈的切换从启动器注入的 patch 层移到 base bundle 自身的行上:win32 挂载受限 pwsh 栈,POSIX 携带被禁用的 pwsh 行,同一份 patch 文件服务两种阵容——[Windows 默认 pwsh](../feature/2026-08-01-windows-pwsh-default.md) note 的层机制已被取代。`minimal` 预设缺失的 win32 PTY 栈是预设元数据的后续工作。 diff --git a/apps/cli/composition.md b/apps/cli/composition.md index 303903566b..3e08e97b37 100644 --- a/apps/cli/composition.md +++ b/apps/cli/composition.md @@ -60,6 +60,8 @@ flowchart LR cfg --> plugin_dsh_base_sandbox_policy plugin_dsh_base_bash_sandbox["bash-sandbox
@deepseek-ai/dsh-bash-sandbox"] cfg --> plugin_dsh_base_bash_sandbox + plugin_dsh_base_pwsh_sandbox["pwsh-sandbox
@deepseek-ai/dsh-pwsh-sandbox"] + cfg --> plugin_dsh_base_pwsh_sandbox plugin_dsh_base_approval["approval
@deepseek-ai/dsh-user-approval"] cfg --> plugin_dsh_base_approval plugin_dsh_base_permission["permission
@deepseek-ai/dsh-permission"] @@ -68,6 +70,8 @@ flowchart LR cfg --> plugin_dsh_base_bash_env plugin_dsh_base_tool_bash["tool-bash
@deepseek-ai/dsh-tool-bash"] cfg --> plugin_dsh_base_tool_bash + plugin_dsh_base_tool_pwsh["tool-pwsh
@deepseek-ai/dsh-tool-pwsh"] + cfg --> plugin_dsh_base_tool_pwsh plugin_dsh_base_tool_tasks["tool-tasks
@deepseek-ai/dsh-tool-tasks"] cfg --> plugin_dsh_base_tool_tasks plugin_dsh_base_fs_policy["fs-policy
@deepseek-ai/dsh-fs-policy"] @@ -194,10 +198,12 @@ flowchart LR | `sandbox` | `@deepseek-ai/dsh-sandbox-local` | | `sandbox-policy` | `@deepseek-ai/dsh-sandbox-policy` | | `bash-sandbox` | `@deepseek-ai/dsh-bash-sandbox` | +| `pwsh-sandbox` | `@deepseek-ai/dsh-pwsh-sandbox` | | `approval` | `@deepseek-ai/dsh-user-approval` | | `permission` | `@deepseek-ai/dsh-permission` | | `bash-env` | `@deepseek-ai/dsh-bash-env` | | `tool-bash` | `@deepseek-ai/dsh-tool-bash` | +| `tool-pwsh` | `@deepseek-ai/dsh-tool-pwsh` | | `tool-tasks` | `@deepseek-ai/dsh-tool-tasks` | | `fs-policy` | `@deepseek-ai/dsh-fs-policy` | | `tool-fs` | `@deepseek-ai/dsh-tool-fs` | diff --git a/apps/cli/config/agent-presets/code/agent.cordis.yml b/apps/cli/config/agent-presets/code/agent.cordis.yml index ffcad54e59..6a7750de11 100644 --- a/apps/cli/config/agent-presets/code/agent.cordis.yml +++ b/apps/cli/config/agent-presets/code/agent.cordis.yml @@ -46,11 +46,11 @@ # the criterion for host-plane ownership — injection resolves before any session # exists, so there is no agent to key by. Behind a preset realm those variables # never reached the model's shell at all. `tool-bash` consumes the host registry -# from here; the executor behind it is host-plane too, where the sandbox policy -# owns it. +# from here; the executor behind it (`bash-sandbox`) is host-plane too, where the +# sandbox policy owns it. - id: tool-bash name: '@deepseek-ai/dsh-tool-bash' - # POSIX-only: the Windows platform layer swaps the bash stack for the pwsh stack. + # POSIX-only: the base composition swaps the bash stack for the pwsh stack on win32. disabled: !!js process.platform === 'win32' # ── filesystem ────────────────────────────────────────────────────────────── diff --git a/apps/cli/config/agent-presets/cordis/agent.cordis.yml b/apps/cli/config/agent-presets/cordis/agent.cordis.yml index 4afda44ad1..a2e7be3fc7 100644 --- a/apps/cli/config/agent-presets/cordis/agent.cordis.yml +++ b/apps/cli/config/agent-presets/cordis/agent.cordis.yml @@ -40,11 +40,11 @@ # the criterion for host-plane ownership — injection resolves before any session # exists, so there is no agent to key by. Behind a preset realm those variables # never reached the model's shell at all. `tool-bash` consumes the host registry -# from here; the executor behind it is host-plane too, where the sandbox policy -# owns it. +# from here; the executor behind it (`bash-sandbox`) is host-plane too, where the +# sandbox policy owns it. - id: tool-bash name: '@deepseek-ai/dsh-tool-bash' - # POSIX-only: the Windows platform layer swaps the bash stack for the pwsh stack. + # POSIX-only: the base composition swaps the bash stack for the pwsh stack on win32. disabled: !!js process.platform === 'win32' # ── filesystem ────────────────────────────────────────────────────────────── diff --git a/apps/cli/config/agent-presets/standard/agent.cordis.yml b/apps/cli/config/agent-presets/standard/agent.cordis.yml index 86c8a2b724..12fdeaadeb 100644 --- a/apps/cli/config/agent-presets/standard/agent.cordis.yml +++ b/apps/cli/config/agent-presets/standard/agent.cordis.yml @@ -39,11 +39,11 @@ # the criterion for host-plane ownership — injection resolves before any session # exists, so there is no agent to key by. Behind a preset realm those variables # never reached the model's shell at all. `tool-bash` consumes the host registry -# from here; the executor behind it is host-plane too, where the sandbox policy -# owns it. +# from here; the executor behind it (`bash-sandbox`) is host-plane too, where the +# sandbox policy owns it. - id: tool-bash name: '@deepseek-ai/dsh-tool-bash' - # POSIX-only: the Windows platform layer swaps the bash stack for the pwsh stack. + # POSIX-only: the base composition swaps the bash stack for the pwsh stack on win32. disabled: !!js process.platform === 'win32' # ── filesystem ────────────────────────────────────────────────────────────── diff --git a/apps/cli/src/dump-config.ts b/apps/cli/src/dump-config.ts index d06bddbc6f..1754eb4efd 100644 --- a/apps/cli/src/dump-config.ts +++ b/apps/cli/src/dump-config.ts @@ -15,7 +15,6 @@ import { type ConfigDumpLayer, } from '@deepseek-ai/dsh-app-boot' import { homePatchPath, prepareProfile, PROFILE_ROOT_FILENAME } from './profile-boot.ts' -import { resolveWindowsShellLayer } from './windows-shell.ts' const NAME = 'dsh' @@ -34,12 +33,6 @@ export function runDumpConfig(profile: string, defaultOnly: boolean, patches: re label: layer.packageName, patches: layer.patches, })) - // The win32 shell platform layer rides between bundles and user layers, - // exactly where the boot applies it. - const windowsShellLayer = resolveWindowsShellLayer(process.platform, loaded.layers, NAME) - if (windowsShellLayer !== undefined) { - layers.push({ label: windowsShellLayer.label, patches: windowsShellLayer.patches }) - } if (!defaultOnly) { if (existsSync(loaded.patchPath)) { layers.push({ label: loaded.patchPath, patches: loaded.patches }) diff --git a/apps/cli/src/profile-boot.ts b/apps/cli/src/profile-boot.ts index f3c356f199..510d5ef7b5 100644 --- a/apps/cli/src/profile-boot.ts +++ b/apps/cli/src/profile-boot.ts @@ -40,7 +40,6 @@ import { DSH_ENVIRONMENT_KEY, type EnvironmentSnapshot } from '@deepseek-ai/dsh- import { provideCmdline } from '@deepseek-ai/dsh-cmdline' import type { HeadlessIo } from '@deepseek-ai/dsh-headless' import { createProcessShutdown, type ProcessShutdown } from './process-shutdown.ts' -import { resolveWindowsShellLayer } from './windows-shell.ts' const NAME = 'dsh' @@ -114,8 +113,6 @@ interface ComposedProfile { profile: Profile /** Bundle layers concatenated — the part below the user layers on a live reload. */ bundlePatches: PatchOptions[] - /** The win32 shell platform layer (the base bundle's `windows.cordis.patch.yml`), between bundles and user layers. */ - windowsShellPatches: PatchOptions[] /** The home-level user layer (`$DSH_HOME/cordis.patch.yml`), applied after the profile's own. */ homePatches: PatchOptions[] /** Layers above the user layers on a live reload: `--patch` overlays and the telemetry switch. */ @@ -131,7 +128,6 @@ interface ComposedProfile { function allPatches(composed: ComposedProfile): PatchOptions[] { return [ ...composed.bundlePatches, - ...composed.windowsShellPatches, ...composed.profile.patches, ...composed.homePatches, ...composed.overlays, @@ -140,10 +136,10 @@ function allPatches(composed: ComposedProfile): PatchOptions[] { /** * Load `name` and compose its effective patch stack: bundle layers in - * `dsh.profile.bundles` order, the win32 shell platform layer (when the host - * is Windows), the profile's user layer, the home-level user layer - * (`$DSH_HOME/cordis.patch.yml` — machine-local preferences that apply to - * every profile, so it outranks the per-profile layer), `--patch` overlays, + * `dsh.profile.bundles` order (the base bundle gates the shell stacks by + * platform on its own rows), the profile's user layer, the home-level user + * layer (`$DSH_HOME/cordis.patch.yml` — machine-local preferences that apply + * to every profile, so it outranks the per-profile layer), `--patch` overlays, * then the telemetry switch. * @param name - the profile name. * @param patchFiles - `--patch` overlay paths, in argv order. @@ -157,9 +153,8 @@ function composeProfile( const homePatches = loadOptionalPatches(NAME, homePatchPath()) ?? [] const overlays = patchFiles.flatMap(file => loadOverlayPatches(NAME, resolve(file))) const bundlePatches = profile.layers.flatMap(layer => layer.patches) - const windowsShellPatches = resolveWindowsShellLayer(process.platform, profile.layers, NAME)?.patches ?? [] const rows = new Map() - for (const row of composeEntries([bundlePatches, windowsShellPatches, profile.patches, homePatches, overlays])) { + for (const row of composeEntries([bundlePatches, profile.patches, homePatches, overlays])) { if (typeof row.id === 'string') rows.set(row.id, row) } const composedOverlays = [...overlays] @@ -178,7 +173,7 @@ function composeProfile( } const telemetryPatch = resolveTelemetryPatch(process.env.DSH_TELEMETRY_DISABLED, rows.has(TELEMETRY_ROW_ID)) if (telemetryPatch !== undefined) composedOverlays.push(telemetryPatch) - return { profile, bundlePatches, windowsShellPatches, homePatches, overlays: composedOverlays, rows } + return { profile, bundlePatches, homePatches, overlays: composedOverlays, rows } } /** Options for {@link runProfile}. */ @@ -241,7 +236,6 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con // removing the override could never revert the row to the bundle default. const composeLive = (): PatchOptions[] => structuredClone([ ...composed.bundlePatches, - ...composed.windowsShellPatches, ...loadOptionalPatches(NAME, composed.profile.patchPath) ?? [], ...loadOptionalPatches(NAME, homePatchPath()) ?? [], ...composed.overlays, diff --git a/apps/cli/src/windows-shell.ts b/apps/cli/src/windows-shell.ts deleted file mode 100644 index 1a9ca719f8..0000000000 --- a/apps/cli/src/windows-shell.ts +++ /dev/null @@ -1,52 +0,0 @@ -/** - * The Windows shell platform layer: on win32 hosts the shipped profile - * compositions swap the POSIX-only bash stack for the sandbox-confined - * PowerShell stack (`@deepseek-ai/dsh-pwsh-sandbox` + - * `@deepseek-ai/dsh-tool-pwsh`). The layer is the base bundle's - * `windows.cordis.patch.yml`, injected by the launcher between the bundle - * layers and the user layers so a user patch can still override it — the - * only override channel is composition config, like every other roster - * decision. POSIX hosts never receive the layer. - * @module @deepseek-ai/dsh/windows-shell - */ - -import { join } from 'node:path' -import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include' -import { loadOverlayPatches, type ProfileLayer } from '@deepseek-ai/dsh-app-boot' - -/** The base bundle whose package carries the Windows shell patch. */ -export const BASE_BUNDLE = '@deepseek-ai/dsh-base' - -/** The Windows shell patch filename inside the base bundle package. */ -export const WINDOWS_SHELL_PATCH_FILENAME = 'windows.cordis.patch.yml' - -/** One Windows shell platform layer: its patch file and parsed patches. */ -export interface WindowsShellLayer { - /** The patch file path, used as the config-dump provenance label. */ - label: string - /** The parsed patch entries, applied after the bundle layers. */ - patches: PatchOptions[] -} - -/** - * Resolve the Windows shell platform layer for a profile composition. - * @param platform - the host platform (`process.platform` at call sites). - * @param layers - the profile's bundle layers, in application order. - * @param binName - the diagnostic prefix on thrown errors (`dsh`). - * @returns the pwsh layer on win32, else `undefined`. A custom profile that - * mounts no base bundle is skipped (it owns its shell stack); a base - * bundle whose Windows shell patch is missing fails loud in - * {@link loadOverlayPatches} — the shipped package always carries it, so - * a miss is a broken installation. - */ -export function resolveWindowsShellLayer( - platform: NodeJS.Platform, - layers: readonly ProfileLayer[], - binName: string, -): WindowsShellLayer | undefined { - if (platform !== 'win32') return undefined - const base = layers.find(layer => layer.packageName === BASE_BUNDLE) - if (base === undefined) return undefined - const label = join(base.packageDir, WINDOWS_SHELL_PATCH_FILENAME) - return { label, patches: loadOverlayPatches(binName, label) } -} diff --git a/apps/cli/tests/windows-shell.spec.ts b/apps/cli/tests/windows-shell.spec.ts index 2dcd34cf91..4109cab68e 100644 --- a/apps/cli/tests/windows-shell.spec.ts +++ b/apps/cli/tests/windows-shell.spec.ts @@ -1,76 +1,40 @@ +/** + * The shipped shell composition: the base bundle gates both shell stacks by + * platform on its own rows (`disabled: !!js process.platform`), so exactly + * one shell stack mounts per host and no separate platform layer exists — + * the launcher applies nothing beyond the bundle layers. The spec composes + * the REAL shipped bundle layers (dsh-base + dsh-web-app resolved from the + * app installation anchor) through the boot's patch algorithm and pins the + * effective per-platform roster, the preset-level gate that keeps tool-bash + * out of win32 sessions, and the cold-start resolution closure for the pwsh + * rows' bare plugin names. + */ + import { afterEach, describe, expect, it } from 'vitest' -import { mkdtempSync, writeFileSync, rmSync, mkdirSync, readFileSync } from 'node:fs' +import { mkdtempSync, rmSync, readFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import yaml from 'js-yaml' import { entryListSchema } from '@deepseek-ai/cordis-plugin-include' import { evaluate } from '@deepseek-ai/cordis-plugin-loader' -import type { ProfileLayer } from '@deepseek-ai/dsh-app-boot' import { composeEntries, initProfile, loadProfile, PROFILES_DIR } from '@deepseek-ai/dsh-app-boot' -import { - BASE_BUNDLE, - resolveWindowsShellLayer, - WINDOWS_SHELL_PATCH_FILENAME, -} from '../src/windows-shell.ts' -const WINDOWS_PATCH = `- id: bash-sandbox - disabled: true -- insert: - - id: pwsh-sandbox - name: '@deepseek-ai/dsh-pwsh-sandbox' -` - -/** One fake bundle layer rooted in a temp directory. */ -function fakeLayer(packageName: string, dir: string): ProfileLayer { - return { packageName, packageDir: dir, patchPath: join(dir, 'cordis.patch.yml'), patches: [] } -} - -/** A base bundle layer whose package carries the Windows shell patch. */ -function baseLayerWithPatch(dir: string): ProfileLayer { - writeFileSync(join(dir, WINDOWS_SHELL_PATCH_FILENAME), WINDOWS_PATCH) - return fakeLayer(BASE_BUNDLE, dir) -} - -describe('resolveWindowsShellLayer', () => { - let base: string - afterEach(() => { if (base !== undefined) rmSync(base, { recursive: true, force: true }) }) - const tempBase = (): string => { - base = mkdtempSync(join(tmpdir(), 'dsh-windows-shell-')) - return base +/** + * The effective disabled state of one composed row on one platform: a `!!js` + * expression evaluates with a platform-scoped context (the `with` scope + * shadows the global `process`) so both outcomes pin on every host; a plain + * boolean is the value itself. + */ +function disabledOn(row: { disabled?: unknown }, platform: 'win32' | 'linux'): boolean { + const value = row.disabled + if (value !== null && typeof value === 'object' && '__jsExpr' in value) { + return Boolean(evaluate({ process: { platform } }, (value as { __jsExpr: string }).__jsExpr)) } + return value === true +} - it('never applies on POSIX hosts', () => { - expect(resolveWindowsShellLayer('linux', [baseLayerWithPatch(tempBase())], 'dsh')).toBeUndefined() - expect(resolveWindowsShellLayer('darwin', [baseLayerWithPatch(tempBase())], 'dsh')).toBeUndefined() - }) - - it('defaults Windows hosts to the pwsh platform layer', () => { - const layer = resolveWindowsShellLayer('win32', [baseLayerWithPatch(tempBase())], 'dsh') - expect(layer).toBeDefined() - expect(layer?.label.endsWith(WINDOWS_SHELL_PATCH_FILENAME)).toBe(true) - expect(layer?.patches).toEqual([ - { id: 'bash-sandbox', disabled: true }, - { insert: [{ id: 'pwsh-sandbox', name: '@deepseek-ai/dsh-pwsh-sandbox' }] }, - ]) - }) - - it('skips custom profiles without a base bundle', () => { - const other = fakeLayer('@deepseek-ai/dsh-custom', tempBase()) - expect(resolveWindowsShellLayer('win32', [other], 'dsh')).toBeUndefined() - }) - - it('fails loud when the base bundle ships no Windows shell patch', () => { - const base = tempBase() - mkdirSync(base, { recursive: true }) - // The overlay loader owns the fail-loud contract: the caller named this - // file, so its absence is a misconfiguration, not "no overlay". - expect(() => resolveWindowsShellLayer('win32', [fakeLayer(BASE_BUNDLE, base)], 'dsh')) - .toThrow(/dsh: failed to read overlay .*windows\.cordis\.patch\.yml/) - }) -}) - -describe('the shipped Windows composition (real bundle layers)', () => { +describe('the shipped shell composition (real bundle layers)', () => { let home: string afterEach(() => { if (home !== undefined) rmSync(home, { recursive: true, force: true }) }) // The app installation anchor, mirroring profile-boot.ts: the bundle layers @@ -78,66 +42,64 @@ describe('the shipped Windows composition (real bundle layers)', () => { // suite composes the shipped patch files, not test fixtures. const anchor = fileURLToPath(new URL('../package.json', import.meta.url)) - it('composes the win32 confined roster through the real patch layers', () => { + it('composes the confined pwsh roster on win32 and the bash roster on POSIX from the same rows', () => { home = mkdtempSync(join(tmpdir(), 'dsh-windows-home-')) initProfile(join(home, PROFILES_DIR, 'web'), ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app']) const profile = loadProfile('dsh', 'web', anchor, home) const warnings: string[] = [] - const win32 = resolveWindowsShellLayer('win32', profile.layers, 'dsh') - expect(win32).toBeDefined() const rows = composeEntries( - [...profile.layers.map(layer => layer.patches), win32!.patches], + profile.layers.map(layer => layer.patches), message => warnings.push(message), ) const byId = new Map(rows.map(row => [row.id, row])) - // Only the POSIX bash stack leaves the roster: the permission surface - // (sandbox/sandbox-policy/fs-sandbox, permission, approval) stays enabled - // exactly as on POSIX — the confined pwsh executor is what changes. - for (const id of ['bash-sandbox', 'tool-bash']) { - expect(byId.get(id)?.disabled, `row ${id}`).toBe(true) + // One shared patch set, two rosters: the shell stacks gate themselves. + for (const id of ['bash-sandbox', 'pwsh-sandbox', 'tool-pwsh']) { + expect(byId.has(id), `row ${id}`).toBe(true) } + expect(disabledOn(byId.get('bash-sandbox')!, 'win32'), 'bash-sandbox on win32').toBe(true) + expect(disabledOn(byId.get('bash-sandbox')!, 'linux'), 'bash-sandbox on linux').toBe(false) + expect(disabledOn(byId.get('pwsh-sandbox')!, 'win32'), 'pwsh-sandbox on win32').toBe(false) + expect(disabledOn(byId.get('pwsh-sandbox')!, 'linux'), 'pwsh-sandbox on linux').toBe(true) + expect(disabledOn(byId.get('tool-pwsh')!, 'win32'), 'tool-pwsh on win32').toBe(false) + expect(disabledOn(byId.get('tool-pwsh')!, 'linux'), 'tool-pwsh on linux').toBe(true) + // The Web surface owns the host tool-bash row on every platform: sessions + // mount their own preset rows instead. + expect(byId.get('tool-bash')?.disabled).toBe(true) + // The permission surface never moves: the sandbox/policy rows, the + // permission switcher, fs-sandbox, and the approval service stay enabled + // exactly as on POSIX — the confined pwsh executor is what changes. for (const id of ['permission', 'ui-permission', 'sandbox', 'sandbox-policy', 'fs-sandbox', 'approval']) { expect(byId.get(id)?.disabled, `row ${id}`).not.toBe(true) } - for (const id of ['pwsh-sandbox', 'tool-pwsh']) { - expect(byId.has(id), `inserted row ${id}`).toBe(true) - } // The launcher's cold-start module fallback BFS-links the apps/cli - // dependency closure into the profile's node_modules (the pwsh-local - // precedent), so every inserted bare plugin must resolve from there. + // dependency closure into the profile's node_modules, so every bare + // plugin name in the base patch must resolve from there. const cliManifest = JSON.parse(readFileSync(anchor, 'utf8')) as { dependencies?: Record } for (const name of ['@deepseek-ai/dsh-pwsh-sandbox', '@deepseek-ai/dsh-tool-pwsh']) { expect(cliManifest.dependencies?.[name], `cold-start closure must reach ${name}`).toBeDefined() } - // The patch touches only base-owned rows plus inserts, so the full web - // profile composes without any no-match warning. expect(warnings).toEqual([]) }) - it('leaves POSIX untouched and base-only profiles compose without warnings', () => { + it('base-only profiles carry both stacks with the same platform gating', () => { home = mkdtempSync(join(tmpdir(), 'dsh-windows-home-')) - initProfile(join(home, PROFILES_DIR, 'web'), ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app']) - const profile = loadProfile('dsh', 'web', anchor, home) - // POSIX: no platform layer, the bash stack stays enabled. - const posixRows = composeEntries(profile.layers.map(layer => layer.patches)) - const posixById = new Map(posixRows.map(row => [row.id, row])) - expect(posixById.get('bash-sandbox')?.disabled).not.toBe(true) - expect(posixById.has('pwsh-local')).toBe(false) - expect(posixById.has('pwsh-sandbox')).toBe(false) - - // A base-only custom profile (the DEFAULT_PROFILE_BUNDLES template): the - // patch touches only base-owned rows (bash-sandbox/tool-bash) plus its - // inserts, so the composition produces no no-match warning. initProfile(join(home, PROFILES_DIR, 'base-only'), ['@deepseek-ai/dsh-base']) - const baseOnly = loadProfile('dsh', 'base-only', anchor, home) - const baseWarnings: string[] = [] - const win32 = resolveWindowsShellLayer('win32', baseOnly.layers, 'dsh') - expect(win32).toBeDefined() - composeEntries( - [...baseOnly.layers.map(layer => layer.patches), win32!.patches], - message => baseWarnings.push(message), + const profile = loadProfile('dsh', 'base-only', anchor, home) + const warnings: string[] = [] + const rows = composeEntries( + profile.layers.map(layer => layer.patches), + message => warnings.push(message), ) - expect(baseWarnings).toEqual([]) + const byId = new Map(rows.map(row => [row.id, row])) + for (const id of ['bash-sandbox', 'tool-bash', 'pwsh-sandbox', 'tool-pwsh']) { + expect(byId.has(id), `row ${id}`).toBe(true) + } + // No web overlay: the tool rows keep their own gating too. + expect(disabledOn(byId.get('tool-bash')!, 'win32'), 'tool-bash on win32').toBe(true) + expect(disabledOn(byId.get('tool-bash')!, 'linux'), 'tool-bash on linux').toBe(false) + expect(disabledOn(byId.get('tool-pwsh')!, 'win32'), 'tool-pwsh on win32').toBe(false) + expect(disabledOn(byId.get('tool-pwsh')!, 'linux'), 'tool-pwsh on linux').toBe(true) + expect(warnings).toEqual([]) }) }) @@ -155,9 +117,9 @@ describe('shipped agent presets keep tool-bash off the win32 roster', () => { )) if (row === undefined) throw new TypeError(`preset ${preset} must mount tool-bash`) expect(row.disabled).toMatchObject({ __jsExpr: expect.any(String) as string }) - // The platform patch disables the host's tool-bash row on win32; the - // preset row must not re-enable it there. Evaluate the shipped expression - // with a platform-scoped context (the `with` scope shadows the global + // The base patch gates the host tool-bash row on win32; the preset row + // must not re-enable it there. Evaluate the shipped expression with a + // platform-scoped context (the `with` scope shadows the global // `process`) so both outcomes pin on every host. const expression = (row.disabled as { __jsExpr: string }).__jsExpr expect(Boolean(evaluate({ process: { platform: 'win32' } }, expression))).toBe(true) diff --git a/packages/bundle/base/README.md b/packages/bundle/base/README.md index 8b0db20274..bd38f39f58 100644 --- a/packages/bundle/base/README.md +++ b/packages/bundle/base/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) The shared dsh core as a profile bundle: [`cordis.patch.yml`](cordis.patch.yml) inserts every base plugin row — model adapters, the shared [`agent-default-model`](../../core/agent-default-model/README.md) selection, tools, persistence, policy, settings/credentials, telemetry, and host-level subagent providers — over the empty profile root, as the first layer of every profile's `dsh.profile.bundles` list. Codex and Claude Code providers load dormant; Agent Presets independently decide whether their agent contributes either model-facing delegation tool. Later bundle layers (e.g. [`dsh-web-app`](../web-app/README.md)) and the user's profile `cordis.patch.yml` override these rows by id; a patch replaces a row's whole `config`, so mode-specific values live in mode bundles, not here. The package has no runtime API; the profile composer resolves the patch through the `dsh.bundle.patch` manifest field, never through code. -Windows hosts booting a shipped profile additionally receive [`windows.cordis.patch.yml`](windows.cordis.patch.yml): it disables the POSIX-only bash stack (`bash-sandbox`/`tool-bash`) and inserts the sandbox-confined PowerShell stack (`@deepseek-ai/dsh-pwsh-sandbox`, `@deepseek-ai/dsh-tool-pwsh`). The permission surface stays exactly as on POSIX: `sandbox`/`sandbox-policy` enforce the file-effect policy through the Windows ACL restricted-token runner (the win32 chain of `dsh-sandbox-local` → `@deepseek-ai/dsh-sandbox-windows-acl`), the permission switcher and the approval service run unchanged, and `fs-sandbox` keeps fencing `ctx.fs` writes — mounting `dsh-fs-local` alongside it would double-register `ctx.fs` and fail the load. The launcher applies the layer between the bundle layers and the user layers on win32 hosts; a Windows host that prefers the unconfined local pwsh executor or full access overrides these rows through its profile or home `cordis.patch.yml` (the bash-restore recipe must be complete: disable `pwsh-sandbox`/`tool-pwsh` AND re-enable `bash-sandbox`/`tool-bash` — both executor families register the same `bash` service, so an incomplete recipe fails loud at load). POSIX hosts never receive it. +The patch gates both shell stacks by platform on its own rows: `bash-sandbox`/`tool-bash` carry `disabled: !!js process.platform === 'win32'` (bash has no Windows runner), and their twins `pwsh-sandbox`/`tool-pwsh` mount on win32 only with the inverted expression — one shared patch file, exactly one shell stack per host. The permission surface stays exactly as on POSIX: `sandbox`/`sandbox-policy` enforce the file-effect policy through the Windows ACL restricted-token runner (the win32 chain of `dsh-sandbox-local` → `@deepseek-ai/dsh-sandbox-windows-acl`), the permission switcher and the approval service run unchanged, and `fs-sandbox` keeps fencing `ctx.fs` writes — mounting `dsh-fs-local` alongside it would double-register `ctx.fs` and fail the load. A Windows host that prefers the unconfined local pwsh executor or full access overrides these rows through its profile or home `cordis.patch.yml` (the bash-restore recipe must be complete: disable `pwsh-sandbox`/`tool-pwsh` AND re-enable `bash-sandbox`/`tool-bash` — both executor families register the same `bash` service, so an incomplete recipe fails loud at load). POSIX hosts see the pwsh rows disabled. The row set and its rationale are documented inline in the patch file; the [generated composition graph](../../../apps/cli/composition.md) renders it. diff --git a/packages/bundle/base/README.zh.md b/packages/bundle/base/README.zh.md index ac5ab10a52..2c6ff8513b 100644 --- a/packages/bundle/base/README.zh.md +++ b/packages/bundle/base/README.zh.md @@ -4,7 +4,7 @@ 以 profile 组合包形式交付的共享 dsh 核心:[`cordis.patch.yml`](cordis.patch.yml) 在空的 profile 根之上插入全部基础插件行——模型适配器、共享的 [`agent-default-model`](../../core/agent-default-model/README.md) 选择、工具、持久化、策略、settings/credentials、遥测与宿主级 subagent provider——作为每个 profile 的 `dsh.profile.bundles` 列表中的第一层。Codex 与 Claude Code provider 以休眠状态加载;Agent Preset 分别决定自己的 agent 是否贡献任一面向模型的委派工具。后续的组合包层(例如 [`dsh-web-app`](../web-app/README.md))和用户 profile 的 `cordis.patch.yml` 按 id 覆盖这些行;patch 会替换目标行的整个 `config`,因此模式专属的值放在各模式组合包中,而不是这里。该包没有运行时 API;profile 组合器通过 manifest(元数据清单)的 `dsh.bundle.patch` 字段解析 patch,绝不通过代码。 -启动交付 profile 的 Windows 主机还会额外收到 [`windows.cordis.patch.yml`](windows.cordis.patch.yml):它禁用仅 POSIX 的 bash 栈(`bash-sandbox`/`tool-bash`),并插入沙盒受限的 PowerShell 栈(`@deepseek-ai/dsh-pwsh-sandbox`、`@deepseek-ai/dsh-tool-pwsh`)。权限面与 POSIX 完全一致:`sandbox`/`sandbox-policy` 通过 Windows ACL 受限令牌 runner(`dsh-sandbox-local` 的 win32 链 → `@deepseek-ai/dsh-sandbox-windows-acl`)执行文件效果策略,权限切换器与 approval 服务原样运行,`fs-sandbox` 继续围栏 `ctx.fs` 写入——在其旁再挂载 `dsh-fs-local` 会重复注册 `ctx.fs` 并在加载时失败。启动器在 win32 主机上把该层应用于 bundle 层与用户层之间;偏好不限权本地 pwsh 执行器或完整访问的 Windows 主机通过其 profile 或 home 的 `cordis.patch.yml` 覆盖这些行(bash 恢复配方必须完整:禁用 `pwsh-sandbox`/`tool-pwsh` 并重新启用 `bash-sandbox`/`tool-bash`——两个执行器家族注册同一个 `bash` 服务,配方不完整会在加载时 fail loud)。POSIX 主机永远不会收到它。 +patch 在自身上按平台门控两个 shell 栈:`bash-sandbox`/`tool-bash` 携带 `disabled: !!js process.platform === 'win32'`(bash 没有 Windows runner),它们的孪生行 `pwsh-sandbox`/`tool-pwsh` 以取反的表达式仅在 win32 挂载——同一份 patch 文件,每个宿主恰好挂载一个 shell 栈。权限面与 POSIX 完全一致:`sandbox`/`sandbox-policy` 通过 Windows ACL 受限令牌 runner(`dsh-sandbox-local` 的 win32 链 → `@deepseek-ai/dsh-sandbox-windows-acl`)执行文件效果策略,权限切换器与 approval 服务原样运行,`fs-sandbox` 继续围栏 `ctx.fs` 写入——在其旁再挂载 `dsh-fs-local` 会重复注册 `ctx.fs` 并在加载时失败。偏好不限权本地 pwsh 执行器或完整访问的 Windows 主机通过其 profile 或 home 的 `cordis.patch.yml` 覆盖这些行(bash 恢复配方必须完整:禁用 `pwsh-sandbox`/`tool-pwsh` 并重新启用 `bash-sandbox`/`tool-bash`——两个执行器家族注册同一个 `bash` 服务,配方不完整会在加载时 fail loud)。POSIX 主机看到的是被禁用的 pwsh 行。 行集合及其设计依据以行内注释写在 patch 文件里;[生成的组合图](../../../apps/cli/composition.md)负责渲染它。 diff --git a/packages/bundle/base/cordis.patch.yml b/packages/bundle/base/cordis.patch.yml index 2fb5ff10d3..4b8696f506 100644 --- a/packages/bundle/base/cordis.patch.yml +++ b/packages/bundle/base/cordis.patch.yml @@ -170,11 +170,22 @@ mode: !!js process.env.DSH_PERMISSION_MODE ?? 'workspace-write' workspaceRoot: !!js process.cwd() + # POSIX shell executor and its win32 twin: bash has no Windows runner, so + # each row gates itself on platform and exactly one shell stack mounts per + # host (both executors register the same 'bash' service). - id: bash-sandbox name: '@deepseek-ai/dsh-bash-sandbox' + disabled: !!js process.platform === 'win32' config: timeoutMs: 60000 + # The confined PowerShell executor, mounted on win32 only. Its config keeps + # the schema defaults (a 120s timeout); the 60s pin above is the bash + # executor's own knob, not a shared shell policy. + - id: pwsh-sandbox + name: '@deepseek-ai/dsh-pwsh-sandbox' + disabled: !!js process.platform !== 'win32' + - id: approval name: '@deepseek-ai/dsh-user-approval' config: @@ -197,8 +208,14 @@ - id: bash-env name: '@deepseek-ai/dsh-bash-env' + # The dialect tools gate with their executors: one shell tool per host. - id: tool-bash name: '@deepseek-ai/dsh-tool-bash' + disabled: !!js process.platform === 'win32' + + - id: tool-pwsh + name: '@deepseek-ai/dsh-tool-pwsh' + disabled: !!js process.platform !== 'win32' - id: tool-tasks name: '@deepseek-ai/dsh-tool-tasks' diff --git a/packages/bundle/base/package.json b/packages/bundle/base/package.json index 655de66ec0..e6251833b2 100644 --- a/packages/bundle/base/package.json +++ b/packages/bundle/base/package.json @@ -23,7 +23,6 @@ "default": "./lib/invariant.js" }, "./cordis.patch.yml": "./cordis.patch.yml", - "./windows.cordis.patch.yml": "./windows.cordis.patch.yml", "./src/*": "./src/*", "./package.json": "./package.json" }, @@ -31,7 +30,6 @@ "lib/index.js", "lib/invariant.js", "cordis.patch.yml", - "windows.cordis.patch.yml", "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", diff --git a/packages/bundle/base/tests/base.spec.ts b/packages/bundle/base/tests/base.spec.ts index ba93c37a3c..5026039a42 100644 --- a/packages/bundle/base/tests/base.spec.ts +++ b/packages/bundle/base/tests/base.spec.ts @@ -3,12 +3,13 @@ * field must name a real, parseable patch list. */ -import { readFileSync } from 'node:fs' +import { existsSync, readFileSync } from 'node:fs' import { fileURLToPath } from 'node:url' import { resolve } from 'node:path' import { describe, expect, it } from 'vitest' import * as yaml from 'js-yaml' import { entryListSchema } from '@deepseek-ai/cordis-plugin-include' +import { evaluate } from '@deepseek-ai/cordis-plugin-loader' describe('dsh-base bundle', () => { it('declares a parseable patch list through the dsh.bundle.patch manifest field', () => { @@ -39,34 +40,37 @@ describe('dsh-base bundle', () => { }) }) - it('ships the Windows platform layer as the confined pwsh roster over the ACL runner chain', () => { + it('gates each shell stack by platform with a symmetric disabled expression', () => { const root = fileURLToPath(new URL('..', import.meta.url)) const parsed = yaml.load( - readFileSync(resolve(root, 'windows.cordis.patch.yml'), 'utf8'), + readFileSync(resolve(root, 'cordis.patch.yml'), 'utf8'), { schema: entryListSchema }, - ) as { - id?: string - disabled?: boolean - insert?: { id?: string; name?: string }[] - config?: { policy?: string } - }[] - const disables = parsed - .filter(patch => patch.disabled === true) - .map(patch => patch.id) - // Only the POSIX bash stack is disabled: the Windows roster confines the - // pwsh executor through the ACL runner chain, so the sandbox/policy rows, - // the permission switcher, fs-sandbox, and the approval service all stay - // enabled exactly as on POSIX — only the shell is swapped. - expect(disables).toEqual(['bash-sandbox', 'tool-bash']) - const inserted = parsed - .flatMap(patch => patch.insert ?? []) - .map(row => row.id) - expect(inserted).toEqual(['pwsh-sandbox', 'tool-pwsh']) - // The patch no longer touches the permission/approval surface at all. - expect(parsed.find(patch => patch.id === 'approval')).toBeUndefined() - expect(parsed.find(patch => patch.id === 'permission')).toBeUndefined() - expect(parsed.find(patch => patch.id === 'sandbox')).toBeUndefined() - expect(parsed.find(patch => patch.id === 'sandbox-policy')).toBeUndefined() - expect(parsed.find(patch => patch.id === 'fs-sandbox')).toBeUndefined() + ) + if (!Array.isArray(parsed)) throw new TypeError('base patch must parse to a patch list') + const rows = parsed.flatMap((patch): Record[] => + typeof patch === 'object' && patch !== null + ? (patch as { insert?: Record[] }).insert ?? [] + : [], + ) + // Symmetric gating: each stack's executor and tool rows carry the same + // platform fact, inverted between the bash and pwsh twins, so exactly one + // shell stack mounts per host. Evaluate with a platform-scoped context + // (the `with` scope shadows the global `process`) so both outcomes pin on + // every host. + for (const [id, win32, linux] of [ + ['bash-sandbox', true, false], + ['tool-bash', true, false], + ['pwsh-sandbox', false, true], + ['tool-pwsh', false, true], + ] as const) { + const row = rows.find(candidate => candidate.id === id) + if (row === undefined) throw new Error(`base patch must mount ${id}`) + const expression = (row.disabled as { __jsExpr?: string } | undefined)?.__jsExpr + if (expression === undefined) throw new Error(`${id} must gate on a !!js disabled expression`) + expect(Boolean(evaluate({ process: { platform: 'win32' } }, expression)), `${id} on win32`).toBe(win32) + expect(Boolean(evaluate({ process: { platform: 'linux' } }, expression)), `${id} on linux`).toBe(linux) + } + // The platform layer folded into these rows: no separate patch file ships. + expect(existsSync(resolve(root, 'windows.cordis.patch.yml'))).toBe(false) }) }) diff --git a/packages/bundle/base/windows.cordis.patch.yml b/packages/bundle/base/windows.cordis.patch.yml deleted file mode 100644 index 6db6a57098..0000000000 --- a/packages/bundle/base/windows.cordis.patch.yml +++ /dev/null @@ -1,31 +0,0 @@ -# The dsh-base Windows platform layer: applied by the dsh launcher on win32 -# hosts, between the bundle layers and the user layers. Windows confines -# through the ACL restricted-token runner (the win32 chain of -# dsh-sandbox-local → @deepseek-ai/dsh-sandbox-windows-acl), so the shipped -# stack is the SANDBOXED PowerShell executor plus the full permission -# surface: sandbox/sandbox-policy enforce the file-effect policy, the -# permission switcher and the approval service run exactly as on POSIX, and -# the fs row stays the base's sandboxed provider (fs-sandbox) — mounting -# dsh-fs-local alongside it would double-register ctx.fs and fail the load. -# Only the POSIX bash -# stack (bash-sandbox/tool-bash) is disabled — bash has no Windows runner. -# A Windows host that prefers the unconfined local pwsh executor or full -# access overrides these rows through its profile or home cordis.patch.yml. -# The bash-restore recipe must be complete: disable pwsh-sandbox and -# tool-pwsh AND re-enable bash-sandbox and tool-bash — both executor -# families register the same 'bash' service, so re-enabling the bash rows -# while pwsh-sandbox stays inserted fails loud at load on a duplicate -# registration. - -- id: bash-sandbox - disabled: true - -- id: tool-bash - disabled: true - -- insert: - - id: pwsh-sandbox - name: '@deepseek-ai/dsh-pwsh-sandbox' - - - id: tool-pwsh - name: '@deepseek-ai/dsh-tool-pwsh' diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts index cfc10fda49..54890cf2be 100644 --- a/scripts/check-workspace-constraints.ts +++ b/scripts/check-workspace-constraints.ts @@ -129,9 +129,8 @@ function workspaceManifests(): WorkspaceManifest[] { } const packageFileExtras: Readonly> = { - // Profile bundles publish their dsh.bundle.patch layer beside the lib; - // dsh-base also ships the win32 shell platform layer the launcher reads. - '@deepseek-ai/dsh-base': ['cordis.patch.yml', 'windows.cordis.patch.yml'], + // Profile bundles publish their dsh.bundle.patch layer beside the lib. + '@deepseek-ai/dsh-base': ['cordis.patch.yml'], '@deepseek-ai/dsh-web-app': ['cordis.patch.yml'], '@deepseek-ai/dsh-headless': ['cordis.patch.yml'], '@deepseek-ai/dsh-client-ui-theme': ['lib/styles'],