mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-08-29 04:26:38 +00:00
fix(sandbox): contain drain failure settlement
This commit is contained in:
+2
-2
@@ -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
|
||||
|
||||
+1
-1
@@ -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.
|
||||
|
||||
|
||||
+1
-1
@@ -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。
|
||||
|
||||
|
||||
@@ -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,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
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
Reference in New Issue
Block a user