From c3ef012dfe2acf7e2cba02560715ee5663477c32 Mon Sep 17 00:00:00 2001 From: ZP <3026418933@qq.com> Date: Wed, 26 Aug 2026 11:02:32 +0800 Subject: [PATCH] fix(desktop): provide a console for Windows ACL children --- ...8-15-windows-electron-acl-runner.i18n.yaml | 4 +- .../2026-08-15-windows-electron-acl-runner.md | 12 +-- ...26-08-15-windows-electron-acl-runner.zh.md | 12 +-- dsh-plugin-desktop/README.i18n.yaml | 4 +- dsh-plugin-desktop/README.md | 2 +- dsh-plugin-desktop/README.zh.md | 2 +- dsh-plugin-desktop/src/windows-acl-runner.ts | 2 + .../src/windows-console-host.ts | 41 ++++++++ .../tests/windows-pwsh-sandbox.spec.ts | 93 +++++++++++++++++++ 9 files changed, 154 insertions(+), 18 deletions(-) create mode 100644 dsh-plugin-desktop/src/windows-console-host.ts diff --git a/.agents/notes/implemented/architecture/2026-08-15-windows-electron-acl-runner.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-15-windows-electron-acl-runner.i18n.yaml index eff0d5cfa7..74699c6d4c 100644 --- a/.agents/notes/implemented/architecture/2026-08-15-windows-electron-acl-runner.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-15-windows-electron-acl-runner.i18n.yaml @@ -1,5 +1,5 @@ # Bilingual-pair consistency record: the git blob hash of each side as of the last # confirmed-consistent state. Both languages carry equal authority. Update both files # and re-record their hashes with git hash-object after editing either side. -2026-08-15-windows-electron-acl-runner.md: 634b7005ade246e022ffa65b9f86ca9da4d12fba -2026-08-15-windows-electron-acl-runner.zh.md: ccf3ca5cd1b5bcd5416ea02f66c56d21440c18ba +2026-08-15-windows-electron-acl-runner.md: f4f3e2ac595156586012f6eb3467b14944dcaa0f +2026-08-15-windows-electron-acl-runner.zh.md: f32bf878fe647e16d545aaaa0c307836ae9eeb2d diff --git a/.agents/notes/implemented/architecture/2026-08-15-windows-electron-acl-runner.md b/.agents/notes/implemented/architecture/2026-08-15-windows-electron-acl-runner.md index 634b7005ad..f4f3e2ac59 100644 --- a/.agents/notes/implemented/architecture/2026-08-15-windows-electron-acl-runner.md +++ b/.agents/notes/implemented/architecture/2026-08-15-windows-electron-acl-runner.md @@ -8,7 +8,7 @@ English | [中文](2026-08-15-windows-electron-acl-runner.zh.md) The upstream Windows sandbox composes the ACL runner argv as `[process.execPath, runner.js, ...]`. This is correct under the DSH CLI because `process.execPath` is Node. DSH Desktop boots the same Host plugins inside Electron, where the value is the Electron executable during development and the installed `DSH Desktop.exe` after packaging. Starting that executable with the runner path as an argument does not establish the plain-Node process expected by the upstream runner and can instead start another desktop application instance. -The Windows sandbox has no weaker automatic provider fallback. A broken runner therefore makes ordinary workspace-write PowerShell unavailable, while silently bypassing confinement would misrepresent the selected permission mode. The upstream native spawn also deliberately omits `CREATE_NO_WINDOW` and `CREATE_NEW_CONSOLE` because restricted children fail DLL initialization with either flag. That is correct for a console Host, but an Electron GUI Host has no console for the restricted PowerShell process to inherit, so Windows allocates a visible console window for each shell execution. +The Windows sandbox has no weaker automatic provider fallback. A broken runner therefore makes ordinary workspace-write PowerShell unavailable, while silently bypassing confinement would misrepresent the selected permission mode. The upstream native spawn also deliberately omits `CREATE_NO_WINDOW` and `CREATE_NEW_CONSOLE` because restricted children fail DLL initialization with either flag. That is correct for a console Host, but the Electron Node-mode runner starts without a console. On affected Windows environments, forcing the restricted console child to create its own console fails during DLL initialization with `0xC0000142`; environments where Windows creates that console successfully still depend on behavior that differs from the console-hosted CLI path. ## Decision @@ -16,17 +16,17 @@ The desktop package publishes `dsh-plugin-desktop/windows-pwsh-sandbox` as a Hos `DesktopWindowsPwshSandbox` extends the upstream `SandboxPwshExecutor` and uses its protected argv execution methods. It changes an invocation only when the platform is Windows, the Host is Electron, the executable equals `process.execPath`, and the next argument equals the resolved upstream Windows ACL runner. The rewritten argv inserts a private desktop trampoline between the executable and upstream runner. Direct PowerShell execution, including the explicit `danger-full-access` path, is passed through unchanged. -The adapted child receives a cloned environment with every case-insensitive `ELECTRON_RUN_AS_NODE` key removed and one `ELECTRON_RUN_AS_NODE=1` value added. The trampoline validates the exact upstream runner path, removes every form of that variable from its own environment, reconstructs the argv expected by the upstream module, and imports it. Restricted PowerShell and its descendants therefore do not inherit Electron's Node-mode switch. +The adapted child receives a cloned environment with every case-insensitive `ELECTRON_RUN_AS_NODE` key removed and one `ELECTRON_RUN_AS_NODE=1` value added. The trampoline validates the exact upstream runner path, removes every form of that variable from its own environment, and then checks its Windows console state. If no console window exists, it calls `AllocConsole()` while still unrestricted and immediately hides the allocated window with `ShowWindow(..., SW_HIDE)`. Allocation failure is fatal. Only after that host setup does the trampoline reconstruct the argv expected by the upstream module and import it. Restricted PowerShell and its descendants therefore inherit a real console without inheriting Electron's Node-mode switch. The Electron build explicitly enables the `runAsNode` fuse because this child-launch protocol depends on it. The trampoline preserves the upstream `windows-acl-run` failure signature and exits with code 127 when its own validation or import fails, so the existing sandbox layer continues to classify runner startup failures as unavailable and fail closed. -The deploy root pins the published rc.6 Windows ACL package through a repository-owned Yarn patch. Both its pipe-capture and inherited-stdio `STARTUPINFOW` values combine `STARTF_USESHOWWINDOW` with the existing `STARTF_USESTDHANDLES` and set `wShowWindow` to `SW_HIDE`. Creation flags remain unchanged, so the restricted child still owns or inherits a console and retains the upstream token, job, and stdio behavior while requesting a hidden initial state for a newly created console window. The patch is pinned to both direct and transitive rc.6 dependency descriptors; dependency tests require the sandbox provider and Desktop adapter to resolve the same patched package instance. +The deploy root pins the published rc.6 Windows ACL package through a repository-owned Yarn patch. Both its pipe-capture and inherited-stdio `STARTUPINFOW` values combine `STARTF_USESHOWWINDOW` with the existing `STARTF_USESTDHANDLES` and set `wShowWindow` to `SW_HIDE`. Creation flags remain unchanged, so the restricted child inherits the trampoline's console and retains the upstream token, job, and stdio behavior. The native show-state remains defense in depth but is not used as a substitute for host console allocation. The patch is pinned to both direct and transitive rc.6 dependency descriptors; dependency tests require the sandbox provider and Desktop adapter to resolve the same patched package instance. ## Verification -Unit tests cover the exact Windows Electron match, unchanged non-Windows, plain-Node, wrong-executable, wrong-runner, and direct-PowerShell invocations, case-insensitive environment removal, parent-environment isolation, trampoline rejection, public package exports, build entries, the enabled fuse, the exact Yarn patch resolution, both hidden show-state writes, and the absence of `CREATE_NO_WINDOW`. Profile tests verify the Windows replacement, inherited configuration and gate, unchanged macOS and Linux composition, unchanged subprocess and sandbox services, and preservation of explicitly disabled or third-party providers. +Unit tests cover the exact Windows Electron match, unchanged non-Windows, plain-Node, wrong-executable, wrong-runner, and direct-PowerShell invocations, case-insensitive environment removal, parent-environment isolation, trampoline rejection, existing-console reuse, console allocation and hiding, allocation failure, host-setup ordering, public package exports, build entries, the enabled fuse, the exact Yarn patch resolution, both hidden show-state writes, and the absence of `CREATE_NO_WINDOW`. Profile tests verify the Windows replacement, inherited configuration and gate, unchanged macOS and Linux composition, unchanged subprocess and sandbox services, and preservation of explicitly disabled or third-party providers. -The Host and Client compiler faces type-check independently. The package builds headlessly, the 197-node first-party runtime graph remains closed, and the Loader and complete-profile smokes pass. A headless Electron 43 Node-mode smoke executes the built trampoline and reaches the upstream ACL runner, which emits its signed missing-argument failure. Native Windows ACL confinement and the packaged `app.asar` path remain target-machine verification requirements. +The Host and Client compiler faces type-check independently. The package builds headlessly, the 201-node first-party runtime graph remains closed, and the Loader and complete-profile smokes pass. A headless Electron 43 Node-mode smoke executes the built trampoline and reaches the upstream ACL runner, which emits its signed missing-argument failure. Native Windows ACL confinement and the packaged `app.asar` path remain target-machine verification requirements. ## Alternatives considered @@ -46,4 +46,4 @@ The Host and Client compiler faces type-check independently. The package builds Both desktop presentation modes can use the ordinary upstream Windows PowerShell tool with its ACL confinement while the desktop remains an additive package. The adapter is intentionally narrow: changes to the upstream runner package, argv prefix, or provider identity fail closed instead of being guessed. -The installed executable intentionally retains Electron's RunAsNode capability for this internal child protocol. Windows release verification must execute read-only and workspace-write PowerShell commands from the packaged application and confirm the built trampoline, hidden console show state, native ACL setup, output capture, exit propagation, cancellation, and cleanup on the target operating system. +The installed executable intentionally retains Electron's RunAsNode capability for this internal child protocol. Windows release verification must execute read-only and workspace-write console commands from the packaged application and confirm that the GUI runner begins without a console, the built trampoline establishes a hidden inheritable console, native ACL setup remains effective, output is captured, exits propagate, cancellation works, and temporary ACL state is cleaned up on the target operating system. diff --git a/.agents/notes/implemented/architecture/2026-08-15-windows-electron-acl-runner.zh.md b/.agents/notes/implemented/architecture/2026-08-15-windows-electron-acl-runner.zh.md index ccf3ca5cd1..f32bf878fe 100644 --- a/.agents/notes/implemented/architecture/2026-08-15-windows-electron-acl-runner.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-15-windows-electron-acl-runner.zh.md @@ -8,7 +8,7 @@ Status: implemented 上游 Windows sandbox 会把 ACL runner argv 组合为 `[process.execPath, runner.js, ...]`。在 DSH CLI 下这是正确的,因为 `process.execPath` 是 Node。DSH Desktop 会在 Electron 内启动同一批 Host plugin,此时该值在开发环境中是 Electron executable,在打包后则是已安装的 `DSH Desktop.exe`。使用 runner 路径作为参数启动该 executable,并不会建立上游 runner 所预期的普通 Node 进程,反而可能启动另一个 desktop 应用实例。 -Windows sandbox 没有更弱的自动 provider fallback。因此,损坏的 runner 会让普通 workspace-write PowerShell 不可用;静默绕过 confinement 又会错误表示当前选择的权限模式。上游原生 spawn 还会有意省略 `CREATE_NO_WINDOW` 与 `CREATE_NEW_CONSOLE`,因为受限 child 使用任一 flag 都会在 DLL 初始化时失败。这对 console Host 是正确行为,但 Electron GUI Host 没有可供受限 PowerShell 继承的 console,因此 Windows 会为每次 shell 执行分配一个可见 console 窗口。 +Windows sandbox 没有更弱的自动 provider fallback。因此,损坏的 runner 会让普通 workspace-write PowerShell 不可用;静默绕过 confinement 又会错误表示当前选择的权限模式。上游原生 spawn 还会有意省略 `CREATE_NO_WINDOW` 与 `CREATE_NEW_CONSOLE`,因为受限 child 使用任一 flag 都会在 DLL 初始化时失败。这对 console Host 是正确行为,但 Electron Node-mode runner 启动时没有 console。在受影响的 Windows 环境中,受限 console child 被迫自行创建 console 时会在 DLL 初始化阶段以 `0xC0000142` 失败;即使某些环境能由 Windows 成功创建该 console,其行为仍与拥有 console 的 CLI 路径不同。 ## Decision @@ -16,17 +16,17 @@ Desktop package 会把 `dsh-plugin-desktop/windows-pwsh-sandbox` 发布为现有 `DesktopWindowsPwshSandbox` 会继承上游 `SandboxPwshExecutor`,并使用其受保护的 argv 执行方法。只有当平台为 Windows、Host 为 Electron、executable 等于 `process.execPath`,且下一个参数等于已解析的上游 Windows ACL runner 时,它才会改变调用。改写后的 argv 会在 executable 与上游 runner 之间插入私有 desktop trampoline。直接 PowerShell 执行,包括显式 `danger-full-access` 路径,都会原样通过。 -适配后的 child 会获得一份克隆环境,其中会移除每个不区分大小写的 `ELECTRON_RUN_AS_NODE` key,再加入唯一的 `ELECTRON_RUN_AS_NODE=1`。Trampoline 会校验精确的上游 runner 路径,从自己的环境中移除该变量的全部形式,重建上游 module 预期的 argv,然后导入该 module。因此,受限 PowerShell 及其后代不会继承 Electron 的 Node-mode switch。 +适配后的 child 会获得一份克隆环境,其中会移除每个不区分大小写的 `ELECTRON_RUN_AS_NODE` key,再加入唯一的 `ELECTRON_RUN_AS_NODE=1`。Trampoline 会校验精确的上游 runner 路径,从自己的环境中移除该变量的全部形式,然后检查 Windows console 状态。如果不存在 console 窗口,它会在仍未受限时调用 `AllocConsole()`,并立即通过 `ShowWindow(..., SW_HIDE)` 隐藏所分配的窗口;分配失败属于致命错误。完成这项 Host 设置后,trampoline 才会重建上游 module 预期的 argv 并导入该 module。因此,受限 PowerShell 及其后代会继承真实 console,同时不会继承 Electron 的 Node-mode switch。 Electron build 会显式启用 `runAsNode` fuse,因为该 child launch protocol 依赖此能力。Trampoline 会保留上游 `windows-acl-run` 失败签名,并在自身校验或导入失败时以代码 127 退出,因此现有 sandbox layer 会继续把 runner 启动失败分类为不可用并 fail closed。 -Deploy root 会通过仓库自有的 Yarn patch 固定已发布的 rc.6 Windows ACL package。它的 pipe capture 与 inherited-stdio 两组 `STARTUPINFOW` 值都会把 `STARTF_USESHOWWINDOW` 和现有的 `STARTF_USESTDHANDLES` 组合起来,并把 `wShowWindow` 设为 `SW_HIDE`。Creation flag 保持不变,因此受限 child 仍会拥有或继承 console,并保留上游 token、job 与 stdio 行为,同时为新创建的 console 窗口请求隐藏的初始状态。Patch 会同时固定直接与传递的 rc.6 dependency descriptor;依赖测试会要求 sandbox provider 与 Desktop adapter 解析到同一个 patched package instance。 +Deploy root 会通过仓库自有的 Yarn patch 固定已发布的 rc.6 Windows ACL package。它的 pipe capture 与 inherited-stdio 两组 `STARTUPINFOW` 值都会把 `STARTF_USESHOWWINDOW` 和现有的 `STARTF_USESTDHANDLES` 组合起来,并把 `wShowWindow` 设为 `SW_HIDE`。Creation flag 保持不变,因此受限 child 会继承 trampoline 的 console,并保留上游 token、job 与 stdio 行为。原生 show-state 仍作为纵深防护保留,但不会代替 Host console 分配。Patch 会同时固定直接与传递的 rc.6 dependency descriptor;依赖测试会要求 sandbox provider 与 Desktop adapter 解析到同一个 patched package instance。 ## Verification -单元测试覆盖精确的 Windows Electron match、不变的非 Windows、普通 Node、错误 executable、错误 runner 与直接 PowerShell 调用、不区分大小写的环境移除、父进程环境隔离、trampoline 拒绝、公开 package export、build entry、已启用 fuse、准确的 Yarn patch resolution、两处隐藏 show-state 写入,以及没有使用 `CREATE_NO_WINDOW`。Profile 测试验证 Windows 替换、继承的配置与 gate、不变的 macOS 和 Linux 组合、不变的 subprocess 与 sandbox service,以及显式禁用或第三方 provider 会被保留。 +单元测试覆盖精确的 Windows Electron match、不变的非 Windows、普通 Node、错误 executable、错误 runner 与直接 PowerShell 调用、不区分大小写的环境移除、父进程环境隔离、trampoline 拒绝、复用已有 console、console 分配与隐藏、分配失败、Host 设置顺序、公开 package export、build entry、已启用 fuse、准确的 Yarn patch resolution、两处隐藏 show-state 写入,以及没有使用 `CREATE_NO_WINDOW`。Profile 测试验证 Windows 替换、继承的配置与 gate、不变的 macOS 和 Linux 组合、不变的 subprocess 与 sandbox service,以及显式禁用或第三方 provider 会被保留。 -Host 与 Client compiler face 会独立通过 typecheck。Package 可以 headless build,197 节点的第一方 runtime graph 仍然闭合,Loader 与完整 profile smoke 均通过。一次 headless Electron 43 Node-mode smoke 会执行已构建 trampoline 并到达上游 ACL runner,后者会输出带签名的缺少参数错误。原生 Windows ACL confinement 与打包后的 `app.asar` 路径仍需要在目标机器上验证。 +Host 与 Client compiler face 会独立通过 typecheck。Package 可以 headless build,201 节点的第一方 runtime graph 仍然闭合,Loader 与完整 profile smoke 均通过。一次 headless Electron 43 Node-mode smoke 会执行已构建 trampoline 并到达上游 ACL runner,后者会输出带签名的缺少参数错误。原生 Windows ACL confinement 与打包后的 `app.asar` 路径仍需要在目标机器上验证。 ## Alternatives considered @@ -46,4 +46,4 @@ Host 与 Client compiler face 会独立通过 typecheck。Package 可以 headles 两种 desktop 呈现模式都可以使用普通上游 Windows PowerShell tool 及其 ACL confinement,desktop 仍保持为纯新增 package。该 adapter 有意保持窄范围:上游 runner package、argv prefix 或 provider 身份发生变化时会 fail closed,而不会猜测新行为。 -已安装 executable 会有意保留 Electron RunAsNode 能力,用于这个内部 child protocol。Windows release 验证必须从打包后的应用执行 read-only 与 workspace-write PowerShell 命令,并在目标操作系统上确认已构建 trampoline、隐藏 console show state、原生 ACL 设置、输出捕获、退出传播、取消与清理行为。 +已安装 executable 会有意保留 Electron RunAsNode 能力,用于这个内部 child protocol。Windows release 验证必须从打包后的应用执行 read-only 与 workspace-write console 命令,并在目标操作系统上确认 GUI runner 启动时没有 console、已构建 trampoline 会建立隐藏且可继承的 console、原生 ACL 设置仍然有效、输出能够捕获、退出能够传播、取消可用,并且临时 ACL 状态会被清理。 diff --git a/dsh-plugin-desktop/README.i18n.yaml b/dsh-plugin-desktop/README.i18n.yaml index b00aab5ffc..e77fc8194d 100644 --- a/dsh-plugin-desktop/README.i18n.yaml +++ b/dsh-plugin-desktop/README.i18n.yaml @@ -1,5 +1,5 @@ # Bilingual-pair consistency record: the git blob hash of each side as of the last # confirmed-consistent state. Both languages carry equal authority; after editing # either side, bring the other along and record both new Git blob hashes below. -README.md: f38f398a9c6bf84fd1b6711963e87b83a43479cf -README.zh.md: ec3de0797943e65e7647381d3ef5fb9329671d3f +README.md: c4a98885d1e210e5910c5826d8540d5dbfdae41c +README.zh.md: 71f0203d09c91423a0f3d3af6c944ea4ac0923c3 diff --git a/dsh-plugin-desktop/README.md b/dsh-plugin-desktop/README.md index f38f398a9c..c4a98885d1 100644 --- a/dsh-plugin-desktop/README.md +++ b/dsh-plugin-desktop/README.md @@ -61,7 +61,7 @@ On Windows, the launcher pins the browse directory-picker backend and keeps the On every desktop platform and in every presentation mode, one local folder can be dragged onto the left Workspace region. The isolated preload uses Electron `webUtils` only to resolve that operator-dropped `File`, after which the Client reuses the official `workspaces.create` and `startSession` flow. Ordinary files, multi-item drops, and internal Workspace or Session reordering do not trigger directory adoption; the Host remains responsible for path canonicalization, directory validation, and idempotent reuse of an existing Workspace. -Windows PowerShell keeps the upstream `pwsh-sandbox` behavior and Windows ACL confinement in every presentation mode. The launcher generation replaces only that Host provider with the `dsh-plugin-desktop/windows-pwsh-sandbox` subpath from this same package. For the exact upstream ACL-runner argv, the adapter launches the packaged Electron executable in Node mode through a private trampoline, removes the Node-mode variable before the restricted PowerShell process is created, and delegates all policy and failure handling back to the upstream runner. The desktop deploy root also pins a Yarn patch that combines `STARTF_USESHOWWINDOW` with the existing `STARTF_USESTDHANDLES` and `SW_HIDE` on both native restricted-process paths. This preserves captured stdio without suppressing console allocation and requests a hidden initial show state when Windows creates the GUI-hosted PowerShell process's first console window. It does not use the upstream-incompatible `CREATE_NO_WINDOW` or `CREATE_NEW_CONSOLE` flags. Direct `danger-full-access` PowerShell, macOS, and Linux execution are unchanged; there is no automatic unrestricted fallback when Windows confinement fails. +Windows PowerShell keeps the upstream `pwsh-sandbox` behavior and Windows ACL confinement in every presentation mode. The launcher generation replaces only that Host provider with the `dsh-plugin-desktop/windows-pwsh-sandbox` subpath from this same package. For the exact upstream ACL-runner argv, the adapter launches the packaged Electron executable in Node mode through a private trampoline. After validating the exact upstream runner and before importing it, the trampoline removes the Node-mode variable and ensures that its otherwise consoleless Windows process owns a hidden console. The restricted PowerShell process can then inherit that console instead of creating one while already running under the restricted token. Console allocation failure exits through the existing signed runner-failure path, and all ACL policy and subsequent failure handling remain delegated to the upstream runner. The desktop deploy root also retains its Yarn patch that combines `STARTF_USESHOWWINDOW` with the existing `STARTF_USESTDHANDLES` and `SW_HIDE` on both native restricted-process paths. It does not use the upstream-incompatible `CREATE_NO_WINDOW` or `CREATE_NEW_CONSOLE` flags. Direct `danger-full-access` PowerShell, macOS, and Linux execution are unchanged; there is no automatic unrestricted fallback when Windows confinement fails. ## Extended window mode diff --git a/dsh-plugin-desktop/README.zh.md b/dsh-plugin-desktop/README.zh.md index ec3de07979..71f0203d09 100644 --- a/dsh-plugin-desktop/README.zh.md +++ b/dsh-plugin-desktop/README.zh.md @@ -61,7 +61,7 @@ Cordis row 会在 profile 激活期间登记原生窗口参数。Launcher 只在 在所有桌面平台与所有呈现模式下,可以把一个本地文件夹拖到左侧工作区区域。隔离的 preload 只使用 Electron `webUtils` 解析这一个由操作者拖入的 `File`,Client 随后复用官方的 `workspaces.create` 与 `startSession` 流程。普通文件、多项拖入和内部工作区/会话排序不会触发目录接纳;Host 仍负责规范化路径、验证目录和复用已登记的工作区。 -在所有呈现模式下,Windows PowerShell 都会保留上游 `pwsh-sandbox` 行为与 Windows ACL confinement。Launcher generation 只会把该 Host provider 替换为同一 package 中的 `dsh-plugin-desktop/windows-pwsh-sandbox` 子路径。对于与上游 ACL runner 完全匹配的 argv,adapter 会让打包后的 Electron executable 通过私有 trampoline 以 Node 模式启动,在创建受限 PowerShell 进程前移除 Node-mode 环境变量,然后把全部 policy 与失败处理重新委托给上游 runner。Desktop deploy root 还会固定一个 Yarn patch,在两条原生受限进程路径上把 `STARTF_USESHOWWINDOW`、现有的 `STARTF_USESTDHANDLES` 与 `SW_HIDE` 组合起来。这会保留已捕获的 stdio 而不抑制 console 分配,并在 Windows 为 GUI Host 启动的 PowerShell 进程创建首个 console 窗口时,请求使用隐藏的初始显示状态。它不会使用与上游实现不兼容的 `CREATE_NO_WINDOW` 或 `CREATE_NEW_CONSOLE` flag。直接使用 `danger-full-access` 的 PowerShell、macOS 与 Linux 执行路径保持不变;Windows confinement 失败时不会自动回退到不受限执行。 +在所有呈现模式下,Windows PowerShell 都会保留上游 `pwsh-sandbox` 行为与 Windows ACL confinement。Launcher generation 只会把该 Host provider 替换为同一 package 中的 `dsh-plugin-desktop/windows-pwsh-sandbox` 子路径。对于与上游 ACL runner 完全匹配的 argv,adapter 会让打包后的 Electron executable 通过私有 trampoline 以 Node 模式启动。Trampoline 会先精确校验上游 runner,再在导入它之前移除 Node-mode 环境变量,并确保自身这个原本没有 console 的 Windows 进程拥有一个隐藏 console。受限 PowerShell 进程随后可以继承该 console,而不必在已经使用受限 token 时自行创建。Console 分配失败会通过现有带签名的 runner 失败路径退出;全部 ACL policy 与后续失败处理仍委托给上游 runner。Desktop deploy root 还会保留 Yarn patch,在两条原生受限进程路径上把 `STARTF_USESHOWWINDOW`、现有的 `STARTF_USESTDHANDLES` 与 `SW_HIDE` 组合起来。它不会使用与上游实现不兼容的 `CREATE_NO_WINDOW` 或 `CREATE_NEW_CONSOLE` flag。直接使用 `danger-full-access` 的 PowerShell、macOS 与 Linux 执行路径保持不变;Windows confinement 失败时不会自动回退到不受限执行。 ## 扩展窗口模式 diff --git a/dsh-plugin-desktop/src/windows-acl-runner.ts b/dsh-plugin-desktop/src/windows-acl-runner.ts index 5f9d4378ba..c1ed778cf2 100644 --- a/dsh-plugin-desktop/src/windows-acl-runner.ts +++ b/dsh-plugin-desktop/src/windows-acl-runner.ts @@ -7,6 +7,7 @@ import { WINDOWS_ACL_RELAY_PAYLOAD, windowsAclRelayEnvironmentValue, } from './windows-acl-relay.ts' +import { ensureWindowsConsoleHost } from './windows-console-host.ts' const RUN_AS_NODE = 'ELECTRON_RUN_AS_NODE' const RUNNER_FAILURE_EXIT = 127 @@ -31,6 +32,7 @@ async function main(): Promise { if (requestedRunner !== expectedRunner) { throw new Error('desktop trampoline received an unexpected ACL runner') } + ensureWindowsConsoleHost() const args = relay?.args ?? process.argv.slice(3) process.argv = [process.argv[0] as string, expectedRunner, ...args] await import(pathToFileURL(expectedRunner).href) diff --git a/dsh-plugin-desktop/src/windows-console-host.ts b/dsh-plugin-desktop/src/windows-console-host.ts new file mode 100644 index 0000000000..a17fc1e494 --- /dev/null +++ b/dsh-plugin-desktop/src/windows-console-host.ts @@ -0,0 +1,41 @@ +/** Console inheritance adapter for Windows ACL children launched from Electron. */ + +import { createRequire } from 'node:module' + +const SW_HIDE = 0 + +export interface WindowsConsoleHostApi { + getConsoleWindow: () => unknown + allocConsole: () => number + getLastError: () => number + showWindow: (window: unknown, command: number) => number +} + +export type WindowsConsoleHostApiLoader = () => WindowsConsoleHostApi + +function loadWindowsConsoleHostApi(): WindowsConsoleHostApi { + const koffi = createRequire(import.meta.url)('koffi') as typeof import('koffi').default + const kernel32 = koffi.load('kernel32.dll') + const user32 = koffi.load('user32.dll') + return { + getConsoleWindow: kernel32.func('void * __stdcall GetConsoleWindow()'), + allocConsole: kernel32.func('int __stdcall AllocConsole()'), + getLastError: kernel32.func('uint32 __stdcall GetLastError()'), + showWindow: user32.func('int __stdcall ShowWindow(void *, int)'), + } +} + +/** Ensure restricted console children can inherit a real console from the Desktop runner. */ +export function ensureWindowsConsoleHost( + platform: NodeJS.Platform = process.platform, + loadApi: WindowsConsoleHostApiLoader = loadWindowsConsoleHostApi, +): void { + if (platform !== 'win32') return + const api = loadApi() + if (api.getConsoleWindow() !== null) return + if (api.allocConsole() === 0) { + throw new Error(`could not allocate a console for the Windows ACL runner (Win32 ${api.getLastError()})`) + } + const window = api.getConsoleWindow() + if (window !== null) api.showWindow(window, SW_HIDE) +} diff --git a/dsh-plugin-desktop/tests/windows-pwsh-sandbox.spec.ts b/dsh-plugin-desktop/tests/windows-pwsh-sandbox.spec.ts index bcece1be4c..388941df5d 100644 --- a/dsh-plugin-desktop/tests/windows-pwsh-sandbox.spec.ts +++ b/dsh-plugin-desktop/tests/windows-pwsh-sandbox.spec.ts @@ -1,5 +1,10 @@ +import { readFileSync } from 'node:fs' import type { ShellExecSpec } from '@deepseek-ai/dsh-shell' import { afterEach, describe, expect, it, vi } from 'vitest' +import { + ensureWindowsConsoleHost, + type WindowsConsoleHostApi, +} from '../src/windows-console-host.ts' import { adaptWindowsAclExecution, desktopWindowsPwshConfig, @@ -15,6 +20,16 @@ import { const RUN_AS_NODE = 'ELECTRON_RUN_AS_NODE' +function consoleApi(overrides: Partial = {}): WindowsConsoleHostApi { + return { + getConsoleWindow: vi.fn(() => ({})), + allocConsole: vi.fn(() => 1), + getLastError: vi.fn(() => 0), + showWindow: vi.fn(() => 1), + ...overrides, + } +} + function shellSpec(env?: Record): ShellExecSpec { return { command: 'Write-Output ok', @@ -220,6 +235,17 @@ describe('Windows ACL runner trampoline', () => { vi.restoreAllMocks() }) + it('wires console allocation after runner validation and before the upstream import', () => { + const source = readFileSync(new URL('../src/windows-acl-runner.ts', import.meta.url), 'utf8') + const validation = source.indexOf('if (requestedRunner !== expectedRunner)') + const consoleHost = source.indexOf('ensureWindowsConsoleHost()') + const runnerImport = source.indexOf('await import(pathToFileURL(expectedRunner).href)') + + expect([validation, consoleHost, runnerImport]).not.toContain(-1) + expect(consoleHost).toBeGreaterThan(validation) + expect(consoleHost).toBeLessThan(runnerImport) + }) + it('removes Node mode from the target environment before rejecting an unexpected runner', async () => { process.argv = [process.execPath, 'windows-acl-runner.js', 'unexpected-runner.js'] process.env[RUN_AS_NODE] = '1' @@ -262,3 +288,70 @@ describe('Windows ACL runner trampoline', () => { )) }) }) + +describe('Windows ACL runner console host', () => { + it('does not load native APIs outside Windows', () => { + const loadApi = vi.fn(() => consoleApi()) + + ensureWindowsConsoleHost('darwin', loadApi) + + expect(loadApi).not.toHaveBeenCalled() + }) + + it('keeps an existing console unchanged', () => { + const api = consoleApi() + + ensureWindowsConsoleHost('win32', () => api) + + expect(api.allocConsole).not.toHaveBeenCalled() + expect(api.showWindow).not.toHaveBeenCalled() + }) + + it('allocates and hides a console for a consoleless Windows runner', () => { + const allocatedWindow = {} + const calls: string[] = [] + const api = consoleApi({ + getConsoleWindow: vi.fn(() => { + calls.push('get-console') + return calls.length === 1 ? null : allocatedWindow + }), + allocConsole: vi.fn(() => { + calls.push('allocate') + return 1 + }), + showWindow: vi.fn((window, command) => { + expect(window).toBe(allocatedWindow) + calls.push(`hide-${command}`) + return 1 + }), + }) + + ensureWindowsConsoleHost('win32', () => api) + + expect(calls).toEqual(['get-console', 'allocate', 'get-console', 'hide-0']) + }) + + it('fails closed with the native error when console allocation fails', () => { + const api = consoleApi({ + getConsoleWindow: vi.fn(() => null), + allocConsole: vi.fn(() => 0), + getLastError: vi.fn(() => 5), + }) + + expect(() => ensureWindowsConsoleHost('win32', () => api)).toThrow( + 'could not allocate a console for the Windows ACL runner (Win32 5)', + ) + expect(api.showWindow).not.toHaveBeenCalled() + }) + + it('accepts a successful allocation without a visible console window', () => { + const api = consoleApi({ + getConsoleWindow: vi.fn(() => null), + }) + + ensureWindowsConsoleHost('win32', () => api) + + expect(api.allocConsole).toHaveBeenCalledOnce() + expect(api.showWindow).not.toHaveBeenCalled() + }) +})