Windows: sandboxed pwsh spawns show a new console window per command (no parent console in windows-acl runner) #5532
Replies: 3 comments
|
Implementasi siap di fork: branch
Commit: (Token CLI saya tidak berizin membuat cross-repo PR ke repo ini, jadi saya serahkan pembuatan PR lewat tautan di atas.) |
|
Since opening a cross-repo PR from this fork is blocked at the account level, attaching the complete unified diff here so it can be reviewed/applied directly. Commit: Apply on the monorepo: Or for the packaged DSH Desktop runtime (same three files under diff --git a/packages/sandbox/sandbox-windows-acl/src/ffi.ts b/packages/sandbox/sandbox-windows-acl/src/ffi.ts
index 983f595..e1bd18c 100644
--- a/packages/sandbox/sandbox-windows-acl/src/ffi.ts
+++ b/packages/sandbox/sandbox-windows-acl/src/ffi.ts
@@ -73,6 +73,9 @@ export interface Win32Bindings extends Win32ProcessBindings {
getTempPathW(length: number, buffer: Buffer): number
setEnvironmentVariableW(name: string, value: string): number
setConsoleCtrlHandler(handler: null, add: number): number
+ allocConsole(): number
+ getConsoleWindow(): NativePtr
+ showWindow(hWnd: NativePtr, nCmdShow: number): number
createFileW(
fileName: string,
desiredAccess: number,
@@ -221,6 +224,7 @@ let cached: Win32Bindings | undefined
function bindings(): Win32Bindings {
if (cached !== undefined) return cached
+ const user32 = koffi.load('user32.dll')
cached = extendWin32ProcessBindings(({ kernel32, advapi32, bind }) => ({
openProcess: bind(kernel32, 'OpenProcess', PVOID, ['uint32', 'int', 'uint32']),
openProcessToken: bind(advapi32, 'OpenProcessToken', 'int', [PVOID, 'uint32', PPVOID]),
@@ -250,6 +254,9 @@ function bindings(): Win32Bindings {
getTempPathW: bind(kernel32, 'GetTempPathW', 'uint32', ['uint32', PVOID]),
setEnvironmentVariableW: bind(kernel32, 'SetEnvironmentVariableW', 'int', ['str16', 'str16']),
setConsoleCtrlHandler: bind(kernel32, 'SetConsoleCtrlHandler', 'int', [PVOID, 'int']),
+ allocConsole: bind(kernel32, 'AllocConsole', 'int', []),
+ getConsoleWindow: bind(kernel32, 'GetConsoleWindow', PVOID, []),
+ showWindow: bind(user32, 'ShowWindow', 'int', [PVOID, 'int']),
createFileW: bind(kernel32, 'CreateFileW', PVOID, [
'str16', 'uint32', 'uint32', PVOID, 'uint32', 'uint32', PVOID,
]),
diff --git a/packages/sandbox/sandbox-windows-acl/src/runner.ts b/packages/sandbox/sandbox-windows-acl/src/runner.ts
index 8d5fe35..0080aeb 100644
--- a/packages/sandbox/sandbox-windows-acl/src/runner.ts
+++ b/packages/sandbox/sandbox-windows-acl/src/runner.ts
@@ -47,7 +47,7 @@
import { existsSync, mkdtempSync, rmSync, statSync } from 'node:fs'
import { join } from 'node:path'
-import { win32 } from './ffi.ts'
+import { isNullPtr, win32 } from './ffi.ts'
import { AclSandbox, assertTempRootOutsideWorkspace } from './index.ts'
import { tempWriteSid, workspaceWriteSid } from './workspace-sid.ts'
@@ -138,6 +138,23 @@ async function main(): Promise<number> {
fail(`SetConsoleCtrlHandler failed (Win32 ${api.getLastError()})`)
}
+ // Hidden console host for confined children. This runner is a Node process
+ // normally launched without a console, so every console-subsystem child it
+ // spawns (pwsh, cmd, …) would allocate a brand-new console window per
+ // invocation — visible as a flashing terminal on the user's desktop.
+ // CREATE_NO_WINDOW cannot be used under the restricted token (children die
+ // with STATUS_DLL_INIT_FAILED, see win32-abi.ts), so instead we attach ONE
+ // console here and hide it: children inherit that single console and never
+ // open a window of their own. Best-effort — if console allocation fails the
+ // spawn still proceeds (children simply create their own consoles as today).
+ if (isNullPtr(api.getConsoleWindow())) {
+ api.allocConsole()
+ }
+ const consoleWindow = api.getConsoleWindow()
+ if (!isNullPtr(consoleWindow)) {
+ api.showWindow(consoleWindow, 0 /* SW_HIDE */)
+ }
+
let ownedTempDir: string | undefined
let sandbox: AclSandbox | undefined
let initialized = false
diff --git a/packages/subprocess/subprocess-local/src/spawn.ts b/packages/subprocess/subprocess-local/src/spawn.ts
index 433ba01..44d5638 100644
--- a/packages/subprocess/subprocess-local/src/spawn.ts
+++ b/packages/subprocess/subprocess-local/src/spawn.ts
@@ -358,6 +358,9 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter
// `detached` gives teardown a tree root on POSIX (its own process group);
// Windows terminates by root pid through taskkill /T instead.
detached: platform !== 'win32',
+ // Never surface a console window for background helper processes; callers
+ // that intentionally attach a visible terminal use spawnTerminal instead.
+ windowsHide: platform === 'win32',
})
const collectStream = (mode: SubprocessOutputMode, stream: Readable | null, label: string): OutputCollector | undefined => {
|
|
在 repo 当前 master( 1. 三个绑定确实缺失(需加到 AllocConsole: () => int // kernel32, BOOL
GetConsoleWindow: () => NativePtr // kernel32, HWND
ShowWindow: (hwnd: NativePtr, nCmdShow: int) => int // user32, BOOL2. 插入点干净。 3. 一个小取舍值得写进理由。 一句话:方案正确、缺的绑定位置确认、插入点干净、避开 0xC0000142 的设计是对的。唯一值得补的注释是第 3 点的"inherit-visible-console 是否要 hide"取舍。你可以按你贴的 diff 提交;若为保险,把第 3 点的保守写法一起带上。 |
Uh oh!
There was an error while loading. Please reload this page.
title: "Windows: sandboxed pwsh spawns show a new console window per command (no parent console in windows-acl runner)"
labels: [bug, windows, sandbox]
中文简述:Windows 上,
@deepseek-ai/dsh-sandbox-windows-acl的 runner 在受限令牌下为每次命令创建了新的可见控制台窗口(pwsh 是 console 程序且 runner 自身没有控制台)。已定位根因并给出建议修复,见下。上游对应桌面端 issue:#16 (bruc3van/dsh-desktop#16)。Summary
On Windows, every agent command executed through the sandbox (
pwsh -NoLogo -NoProfile -NonInteractive -Command …) flashes a visible PowerShell console window. The number of windows equals the number of tool invocations.Affected packages (this monorepo):
@deepseek-ai/dsh-sandbox-windows-acl—packages/sandbox/sandbox-windows-acl@deepseek-ai/dsh-subprocess-local—packages/subprocess/subprocess-local(related spawn seam)Root cause
The windows-acl runner (
lib/runner.js) spawns the confined child with piped stdio viaCreateProcessAsUserWunder a restricted token, withoutCREATE_NO_WINDOW/CREATE_NEW_CONSOLE. The bundled code inlib/types-*.jsdocuments why these flags were omitted:Because the runner process itself has no attached console, every console-subsystem child (
pwsh) must allocate a brand-new console, and Windows surfaces each one as a visible window.windowsHide: trueon the outer Nodechild_process.spawn(indsh-subprocess-local) does not help: it only hides the node runner, not the nativeCreateProcessAsUserWpath below it.Proposed fix
Do not use
CREATE_NO_WINDOW(keeps the restricted-token constraint). Instead give the runner one hidden console so confined children inherit that console instead of creating new visible ones:Expose console bindings (kernel32/user32) in the Win32 binding layer used by
sandbox-windows-acl:AllocConsole()→intGetConsoleWindow()→HWNDShowWindow(HWND, int)→int(SW_HIDE = 0)In
lib/runner.js(source:src/runner.ts), right after the existingSetConsoleCtrlHandlercall and before spawning children:dsh-subprocess-local, addwindowsHide: trueon Windows for the plain (non-terminal)spawn()so every non-sandboxed spawn is also windowless:Why this works
STATUS_DLL_INIT_FAILEDfailure mode forCREATE_NO_WINDOWunder the restricted token is avoided.Verification performed on the packaged build
resources/dsh-runtime/node_modules/@deepseek-ai/dsh-sandbox-windows-acl/lib/{runner.js,types-CNjZgO4h.js}anddsh-subprocess-local/lib/index.js), backup of originals retained.pwshcommands in sequence produced no visible console windows (user-confirmed); command output, exit codes and both sandbox modes (read-only / workspace-write) behaved unchanged.Notes
@deepseek-ai/dsh-win32-process(workspace dependency ofsandbox-windows-acl) — the fix should add the three console functions there and call them from the runner.All reactions