fix(sandbox): contain drain failure settlement

This commit is contained in:
pku-xht
2026-08-19 06:35:50 +08:00
parent 4605124732
commit 5375142827
18 changed files with 76 additions and 35 deletions
@@ -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/architecture/2026-08-19-shared-win32-process-primitives.md
2026-08-19-shared-win32-process-primitives.md: 8e5878ff23a49d9f0fbc4e62b9e3e6bccf5fe4ed
2026-08-19-shared-win32-process-primitives.zh.md: 90ca5b21227136febafa946d20acbe59630436fd
2026-08-19-shared-win32-process-primitives.md: 60e9b5b76154a4833979b014dffb4015cb0c5c36
2026-08-19-shared-win32-process-primitives.zh.md: 83bb71ddbe182084097c54325172a3f923b33138
@@ -14,7 +14,7 @@ The Windows ACL sandbox owns restricted-token, SID, DACL, grant, and workspace p
The Windows ACL sandbox remains the only owner of restricted-token creation, SID and DACL policy, grants, writable-path decisions, temporary-directory policy, and the public sandbox child result. It extends the shared binding context with policy-specific APIs, supplies the primary token, combines pipe drains and waits, and closes the caller-owned Job at its lifecycle boundary.
Every native allocation and HANDLE has one owner. A process operation frees its Koffi out-parameters and closes every pipe, thread, process, or Job handle acquired before a failure. Successful pipe creation returns the process plus stdout/stderr read handles to the sandbox. Inherited-stdio creation puts the kill-on-close Job in `STARTUPINFOEXW`, so the child is already Job-owned before any user code can run; attribute or creation failure therefore has one deterministic cleanup owner. The sandbox owns returned process, pipe, and Job handles until wait or disposal.
Every native allocation and HANDLE has one owner. A process operation frees its Koffi out-parameters and closes every pipe, thread, process, or Job handle acquired before a failure. Successful pipe creation returns the process plus stdout/stderr read handles to the sandbox; if either drain fails, sandbox settlement terminates the child before its synchronous wait, or closes the process handle and reports the termination failure without blocking. Inherited-stdio creation puts the kill-on-close Job in `STARTUPINFOEXW`, so the child is already Job-owned before any user code can run; attribute or creation failure therefore has one deterministic cleanup owner. The sandbox owns returned process, pipe, and Job handles until wait or disposal.
The package exports only operations used by the sandbox production path. Ordinary `CreateProcessW`, exact `applicationName`, parent-stdio release, and whole-Job settlement remain absent until an ordinary process consumer needs them. The package is a library, not a Cordis service or a public Windows SDK.
@@ -14,7 +14,7 @@ Windows ACL sandbox 拥有 restricted token、SID、DACL、grant 与 workspace p
Windows ACL sandbox 继续唯一拥有 restricted-token 创建、SID 与 DACL policy、grants、可写路径裁定、临时目录 policy 和公共 sandbox child result。它通过共享 binding context 扩展 policy-specific API,提供 primary token,组合 pipe drain 与 wait,并在自己的生命周期边界关闭调用方拥有的 Job。
每项 native allocation 与 HANDLE 都只有一个 owner。process operation 会释放 Koffi out-parameter,并在失败前关闭已经取得的每个 pipe、thread、process 或 Job handle。pipe 创建成功时,把 process 与 stdout/stderr read handles 返回给 sandbox。inherited-stdio 创建会把 kill-on-close Job 放进 `STARTUPINFOEXW`,因此 child 在任何用户代码运行前已经归属 Job;attribute 或创建失败都有唯一且确定的 cleanup owner。sandbox 在 wait 或 disposal 前拥有返回的 process、pipe 与 Job handles。
每项 native allocation 与 HANDLE 都只有一个 owner。process operation 会释放 Koffi out-parameter,并在失败前关闭已经取得的每个 pipe、thread、process 或 Job handle。pipe 创建成功时,把 process 与 stdout/stderr read handles 返回给 sandbox;任一 drain 失败时,sandbox settlement 会在同步 wait 前终止 child,若终止本身失败则关闭 process handle 并报告该失败,不阻塞事件循环。inherited-stdio 创建会把 kill-on-close Job 放进 `STARTUPINFOEXW`,因此 child 在任何用户代码运行前已经归属 Job;attribute 或创建失败都有唯一且确定的 cleanup owner。sandbox 在 wait 或 disposal 前拥有返回的 process、pipe 与 Job handles。
该包只导出 sandbox 生产路径已使用的操作。ordinary `CreateProcessW`、精确 `applicationName`、parent-stdio release 与 whole-Job settlement 在 ordinary process consumer 出现前保持缺席。该包是 library,不是 Cordis service 或公共 Windows SDK。
+1 -1
View File
@@ -17,7 +17,7 @@ packages/ @deepseek-ai/dsh-<pkg> workspaces at packages/<group>/<pkg>/
llm/ LLM capability: Service Definition/Consumer + DeepSeek providers
e2b/ E2B POC: sandbox + FS/subprocess adapters
shell/ bash capability: Service Definition + local/pwsh providers + shell Consumers
subprocess/ subprocess capability + local process-tree provider
subprocess/ subprocess capability + local process-tree provider + shared Win32 library
terminal/ persistent sessions
fs/ filesystem capability + policy
lsp/ language-server capability
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/README.md
README.md: a410d7148d14503a61edb9c4848b521552050ca4
README.zh.md: 780d1356f2095c7dcc526e5cdac8994267e4a460
README.md: defbd29942f7733919a9c65a1f8174148919b034
README.zh.md: f47584209d4d54eedec48f5f5c4e90479cf5c7d9
+1 -1
View File
@@ -19,7 +19,7 @@ Groups hold `packages/<group>/<pkg>/`; names stay `@deepseek-ai/dsh-<pkg>`. **Gr
| [`identity/`](identity/README.md) | Shared anonymous identity | Product — stable API |
| [`llm/`](llm/README.md) | LLM capability family: the abstract service + provider adapters | Product — stable API |
| [`e2b/`](e2b/README.md) | E2B providers | POC |
| [`subprocess/`](subprocess/README.md) | Subprocess capability family: Service Definition + local process-tree provider | Product — stable API |
| [`subprocess/`](subprocess/README.md) | Subprocess capability family: Service Definition, local process-tree provider, and shared Win32 process library | Product — stable API |
| [`shell/`](shell/README.md) | Bash capability family: executor seam, local impl, model-facing tool | Product — stable API |
| [`terminal/`](terminal/README.md) | Persistent PTY capability family: owner-scoped sessions, local implementation, and model-facing tools | Product — stable API |
| [`code-runtime/`](code-runtime/README.md) | Code-execution capability family: Service Definition + worker-thread provider + Code Mode Consumer | Product — stable API |
+1 -1
View File
@@ -19,7 +19,7 @@ npm scope 为 `@deepseek-ai/dsh-*`Cordis `Service` 子类和函数插件通
| [`identity/`](identity/README.md) | 共享匿名身份 | 产品:稳定 API |
| [`llm/`](llm/README.md) | LLM(大语言模型)能力系列:抽象服务 + 提供方适配器 | 产品:稳定 API |
| [`e2b/`](e2b/README.md) | E2B 提供方 | POC |
| [`subprocess/`](subprocess/README.md) | 子进程能力系列:Service Definition + 本地进程树提供方 | 产品:稳定 API |
| [`subprocess/`](subprocess/README.md) | 子进程能力系列:Service Definition本地进程树提供方与共享 Win32 进程库 | 产品:稳定 API |
| [`shell/`](shell/README.md) | Bash 能力系列:执行器 seam、本地实现、面向模型的工具 | 产品:稳定 API |
| [`terminal/`](terminal/README.md) | 持久 PTY 能力系列:限定所有者范围的会话、本地实现和面向模型的工具 | 产品:稳定 API |
| [`code-runtime/`](code-runtime/README.md) | 代码执行能力系列:Service Definition + worker 线程提供方 + Code Mode Consumer | 产品:稳定 API |
@@ -55,7 +55,7 @@ import * as abi from './win32-abi.ts'
export { AclWriteGrant } from './grant.ts'
export { assertTempRootOutsideWorkspace } from './path-boundary.ts'
export { tempWriteSid, workspaceWriteSid } from './workspace-sid.ts'
export { quoteArg, Win32Error } from '@deepseek-ai/dsh-win32-process'
export { Win32Error } from '@deepseek-ai/dsh-win32-process'
/** Construction options: the workspace/temp allowlists and their distinct SID identities. */
export interface AclSandboxOptions {
@@ -382,10 +382,11 @@ export class AclSandbox {
const native = spawnSandboxed(api, token, { command: options.command, args, cwd })
const stdout = drainPipe(api, native.stdoutRead)
const stderr = drainPipe(api, native.stderrRead)
// waitForExit is deliberately NOT started here: WaitForSingleObject blocks
// the thread and would starve the drains while the child is still running
// (pipe-buffer deadlock). The drains resolve only after the child closed
// its pipe ends — by then the wait returns immediately.
// WaitForSingleObject blocks the thread, so settlement starts it only after
// both drains settle. Successful drains mean the child closed its pipe ends
// and the wait returns immediately. A failed drain terminates the child
// before waiting, so a native pipe failure cannot pin the event loop on a
// still-running command.
let settlement: Promise<AclSandboxChildResult> | undefined
return {
pid: native.pid,
@@ -394,10 +395,20 @@ export class AclSandbox {
const failures = drains.flatMap<unknown>(outcome =>
outcome.status === 'rejected' ? [outcome.reason as unknown] : [])
let exitCode = 0
try {
exitCode = waitForExit(api, native.process)
} catch (error) {
failures.push(error)
if (failures.length > 0 && api.terminateProcess(native.process, 1) === 0) {
const terminationCode = api.getLastError()
try {
closeHandleChecked(api, native.process, 'piped child after drain failure')
} catch (error) {
failures.push(error)
}
failures.push(new Win32Error('TerminateProcess', terminationCode, `pid ${native.pid} after drain failure`))
} else {
try {
exitCode = waitForExit(api, native.process)
} catch (error) {
failures.push(error)
}
}
if (failures.length === 1) throw failures[0]
if (failures.length > 1) throw new AggregateError(failures, 'piped child settlement failed')
@@ -180,8 +180,9 @@ export interface RestrictingSidSet {
* `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACEs) is closed in both — documented in
* README. INTERACTIVE/LOCAL are absent from BOTH lists too — the host's
* Public tree grants write to INTERACTIVE, so removing it closes that
* escape. S-1-2-1 (console logon) is intentionally absent: see win32-abi.ts
* for the verified failure modes. FAILS CLOSED: any failure throws — never
* escape. S-1-2-1 (console logon) is intentionally absent: the package
* README's "Console isolation is unavailable" entry records the verified
* failure modes. FAILS CLOSED: any failure throws — never
* spawn unrestricted.
* @param api - the binding table.
* @param currentToken - the process token to restrict.
@@ -20,7 +20,11 @@ export const FILE_GENERIC_WRITE = 0x00120116
export const DELETE = 0x00010000
/** Delete or rename a directory child. */
export const FILE_DELETE_CHILD = 0x0040
/** Capability-SID access mask granting write, delete, and child deletion. */
/**
* Capability-SID access mask granting write, delete, and child deletion.
* WRITE_DAC and WRITE_OWNER stay excluded so a confined child cannot rewrite
* DACLs or take ownership to escape the allowlist.
*/
export const GRANT_MASK = (FILE_GENERIC_WRITE | DELETE | FILE_DELETE_CHILD) & ~STANDARD_RIGHTS_WRITE
/** Full access used in the restricted token default DACL. */
export const FILE_ALL_ACCESS = 0x1F01FF
@@ -9,7 +9,7 @@
import { describe, expect, it, vi } from 'vitest'
import koffi from 'koffi'
import { Win32Error, quoteArg } from '../src/index.ts'
import { Win32Error } from '../src/index.ts'
import {
allocBytes, decodePtrAt, getTempPath,
isInvalidHandle, sameSidAt,
@@ -17,7 +17,7 @@ import {
import type { NativePtr, Win32Bindings } from '../src/ffi.ts'
import * as abi from '../src/win32-abi.ts'
/** A stub whose formatMessageW writes real UTF-16 text (the errorText round-trip). */
/** A stub whose formatMessageW supplies text to the GetTempPath failure path. */
function formatApi(): { api: Win32Bindings; formatMessageW: ReturnType<typeof vi.fn> } {
const formatMessageW = vi.fn((_flags: number, _source: null, _id: number, _lang: number, buffer: Buffer, _size: number, _args: null) => {
const text = 'access denied'
@@ -75,10 +75,9 @@ describe('getTempPath', () => {
})
})
describe('public compatibility exports', () => {
it('keeps the sandbox Win32 error and quoting API', () => {
describe('public error export', () => {
it('keeps the sandbox Win32 error type', () => {
expect(new Win32Error('Probe', 5)).toBeInstanceOf(Error)
expect(quoteArg('a b')).toBe('"a b"')
})
})
@@ -448,6 +448,8 @@ describe('AclSandbox spawn', () => {
it('pipe spawn still closes the process after a drain failure', async () => {
const { api } = state.stubs as HappyStubs
api.getLastError = vi.fn(() => 5)
const terminateProcess = vi.fn(() => 1)
api.terminateProcess = terminateProcess
const waitForSingleObject = vi.fn(() => 0)
api.waitForSingleObject = waitForSingleObject
const workspace = scratch()
@@ -460,8 +462,24 @@ describe('AclSandbox spawn', () => {
expect.objectContaining({ api: 'PeekNamedPipe' }),
],
})
expect(terminateProcess).toHaveBeenCalledOnce()
expect(waitForSingleObject).toHaveBeenCalledOnce()
})
it('pipe spawn closes the process without waiting when termination after a drain failure fails', async () => {
const { api, closeHandle } = state.stubs as HappyStubs
api.getLastError = vi.fn(() => 5)
api.terminateProcess = vi.fn(() => 0)
const waitForSingleObject = vi.fn(() => { throw new Error('must not wait') })
api.waitForSingleObject = waitForSingleObject
const workspace = scratch()
const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-14-3', mode: 'workspace-write' })
await sandbox.init()
const child = sandbox.spawn({ command: 'probe.exe' })
await expect(child.wait()).rejects.toBeInstanceOf(AggregateError)
expect(waitForSingleObject).not.toHaveBeenCalled()
expect(closeHandle).toHaveBeenCalled()
})
})
describe('AclSandbox dispose', () => {
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/subprocess/win32-process/README.md
README.md: 601c095ea48fbf46f151ee69ad2e485881dcb4af
README.zh.md: 53a55ef58a404c52ded1d417ba4fa516a3092783
README.md: c3bc0d74c3c5a375d289e9a3e4037648341f4906
README.zh.md: 6763f2b24633d7dd250eecdafe5e1724ffa29fa6
+1 -1
View File
@@ -34,6 +34,6 @@ The package contributes no stable request prefix, so it does not invalidate mode
- **Windows-only native loading** — importing the generic types is portable, but resolving the binding table loads Windows DLLs and fails on other hosts. Cross-platform tests inject a binding table instead of loading native APIs.
- **No public process service** — the package intentionally does not wrap its primitives in Cordis or Node streams. A consumer must own its policy, async scheduling, output limits, cancellation, and final handle closure.
- **Inherited environment only** — process creation passes a null environment block. Callers that need environment changes must establish them before invoking the primitive or use their own runner process.
- **Inherited environment only** — process creation passes a null environment block. The sandbox establishes changes through `SetEnvironmentVariableW` first because passing an explicit block through Koffi makes `CreateProcessAsUserW` fail with `ERROR_INVALID_PARAMETER`. Other callers that need environment changes must establish them before invoking the primitive or use their own runner process.
- **Restricted-token consumer only** — ordinary `CreateProcessW`, exact `applicationName`, parent-stdio release, and whole-Job settlement are absent until an ordinary process consumer requires them.
- **Header evidence is architecture-specific** — the committed ABI probe and layout constants cover the repository's current 64-bit Windows targets. A new pointer width or incompatible Windows ABI requires updating the probe before support is claimed.
@@ -26,7 +26,7 @@ Windows ACL 沙箱在这些原语上增加 SID、DACL、grant、workspace 与公
没有直接影响。消费方决定进程输出是否进入工具结果或后续模型请求。
#### KV Cache effect
#### KV Cache 影响
本包不贡献稳定请求前缀,因此不会使模型 KV Cache 失效。
@@ -34,6 +34,6 @@ Windows ACL 沙箱在这些原语上增加 SID、DACL、grant、workspace 与公
- **仅在 Windows 原生加载** — 导入通用类型可跨平台进行,但解析绑定表会加载 Windows DLL,并在其他宿主失败。跨平台测试注入绑定表,不加载原生 API。
- **没有公共进程服务** — 本包刻意不把原语包装成 Cordis 或 Node streams。消费方必须拥有自己的策略、异步调度、输出上限、取消与最终句柄关闭。
- **只继承环境** — 进程创建传入空环境块。需要改写环境的调用方必须在调用原语前建立环境,或使用自己的 runner 进程。
- **只继承环境** — 进程创建传入空环境块。sandbox 会先通过 `SetEnvironmentVariableW` 建立改动,因为经 Koffi 传入显式环境块会使 `CreateProcessAsUserW``ERROR_INVALID_PARAMETER` 失败。其他需要改写环境的调用方必须在调用原语前建立环境,或使用自己的 runner 进程。
- **只有 restricted-token 消费方** — ordinary `CreateProcessW`、精确 `applicationName`、parent-stdio release 与 whole-Job settlement 在 ordinary process 消费方出现前均不提供。
- **header 证据限定架构** — 已提交的 ABI probe 与布局常量覆盖仓库当前 64 位 Windows 目标。支持新的指针宽度或不兼容 Windows ABI 前,必须先更新 probe。
@@ -19,7 +19,6 @@ export type {
export {
closeHandleChecked,
drainPipe,
quoteArg,
spawnInheritedJobProcess,
spawnPipedProcess,
waitForProcessExit,
@@ -142,6 +142,9 @@ function createRestrictedProcess(
startupInfo: NativePtr,
processInfo: NativePtr,
): number {
// The sandbox mutates its process environment before this call. Passing an
// explicit block through Koffi makes CreateProcessAsUserW reject the request
// with ERROR_INVALID_PARAMETER, so lpEnvironment remains NULL.
return api.createProcessAsUserW(
options.token,
null,
@@ -315,6 +318,10 @@ function createKillOnCloseJob(api: Win32ProcessBindings): NativePtr {
* @param api - active binding table.
* @param options - command, cwd, args, and restricted primary token.
* @returns caller-owned process and Job handles after successful creation.
* @remarks Node clears stdio handle inheritability at startup through
* uv_disable_stdio_inheritance. This operation temporarily restores the bits
* required by STARTF_USESTDHANDLES. Restoring them afterward is best-effort:
* failure must not replace the already-created child's outcome.
*/
export function spawnInheritedJobProcess(
api: Win32ProcessBindings,
@@ -371,7 +378,10 @@ export function spawnInheritedJobProcess(
api.closeHandle(job)
throw error
} finally {
for (const handle of enabled) api.setHandleInformation(handle, abi.HANDLE_FLAG_INHERIT, 0)
for (const handle of enabled) {
// The runner spawns nothing else; cleanup failure must not mask the child.
api.setHandleInformation(handle, abi.HANDLE_FLAG_INHERIT, 0)
}
}
if (created === 0) {
freeNative(processInfo)
@@ -1,6 +1,5 @@
import { describe, expect, it } from 'vitest'
import { quoteArg } from '../src/index.ts'
import { buildCommandLine } from '../src/process.ts'
import { buildCommandLine, quoteArg } from '../src/process.ts'
const isWin32 = process.platform === 'win32'