refactor(sandbox): await cancellable preparation in process consumers
This commit is contained in:
1 parent
fc7f7ddda6
commit
caa69608fb
132 files changed
+2801
-426
No files matched your search
@@ -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/code-runtime/code-runtime-node/README.md
|
||||
README.md: a5285ae296b4f3380d8fc593f54f12fd68355a7a
|
||||
README.zh.md: 762e5912497d1ce3388305655514eb5faa2b4540
|
||||
README.md: d312ee329b0245c35ef25c6de9c516c34848f2ab
|
||||
README.zh.md: 7488532ec78622b0e6c197c7fea1a6ab9f813c67
|
||||
@@ -85,7 +85,7 @@ The host owns policy, deadlines, binding lookup and process cleanup. The child o
|
||||
|
||||
### Launch and control
|
||||
|
||||
The host strips erasable types, resolves the executable and bootstrap in the configured execution world, wraps the argv through `ctx.sandbox`, and spawns through `ctx.subprocess`. After adopting the inherited control channel, the child retains only executable-search, Windows system, and temporary paths in its OS environment and replaces the program-visible `process.env` with an empty dictionary. Windows ACL setup receives the parent's distinct `TEMP` and `TMP` values for shared grant locks, then replaces both with its private directory before starting the program. These native paths keep nested process creation and native temporary-file APIs functional. The heap limit uses Node argv or a provider-created `NODE_OPTIONS` value for packaged executables; ambient loader and inspector flags are discarded.
|
||||
The host strips erasable types, resolves the executable and bootstrap in the configured execution world, awaits argv confinement through `ctx.sandbox`, then spawns through `ctx.subprocess`. Cancellation is checked again after confinement, so a provider returning after cancellation cannot start the program. After adopting the inherited control channel, the child retains only executable-search, Windows system, and temporary paths in its OS environment and replaces the program-visible `process.env` with an empty dictionary. Windows ACL setup receives the parent's distinct `TEMP` and `TMP` values for shared grant locks, then replaces both with its private directory before starting the program. These native paths keep nested process creation and native temporary-file APIs functional. The heap limit uses Node argv or a provider-created `NODE_OPTIONS` value for packaged executables; ambient loader and inspector flags are discarded.
|
||||
|
||||
Length-framed JSON travels separately from stdout/stderr. The host bounds frames and queued writes, validates call identity and declared binding names before dispatch, and refuses invalid traffic. Output capture meters serialized logs plus the completion or diagnostic; fixed result-envelope fields and sandbox metadata are outside that ledger.
|
||||
|
||||
|
||||
@@ -85,7 +85,7 @@ Host 负责策略、截止时间、绑定查找和进程清理。子进程负责
|
||||
|
||||
### 启动与控制
|
||||
|
||||
Host 擦除可擦除类型,在配置的执行世界中解析可执行文件与 bootstrap,通过 `ctx.sandbox` 包装 argv,并通过 `ctx.subprocess` 启动。接管继承的控制通道后,子进程在 OS 环境中只保留可执行文件搜索路径、Windows 系统路径和临时路径,并将程序可见的 `process.env` 替换为空字典。Windows ACL 初始化接收父进程各自的 `TEMP` 和 `TMP` 值以使用共享授权锁,然后在启动程序前将二者替换为私有目录。这些原生路径使嵌套进程创建和原生临时文件 API 仍可正常工作。堆上限通过 Node argv 或为打包可执行文件由提供方构造的 `NODE_OPTIONS` 值传递;环境中的加载器和调试器标志会被丢弃。
|
||||
Host 擦除可擦除类型,在配置的执行世界中解析可执行文件与 bootstrap,通过 `ctx.sandbox` 等待 argv 限制准备完成,再通过 `ctx.subprocess` 启动。限制准备完成后会再次检查取消状态,因此提供方在取消后返回也无法启动程序。接管继承的控制通道后,子进程在 OS 环境中只保留可执行文件搜索路径、Windows 系统路径和临时路径,并将程序可见的 `process.env` 替换为空字典。Windows ACL 初始化接收父进程各自的 `TEMP` 和 `TMP` 值以使用共享授权锁,然后在启动程序前将二者替换为私有目录。这些原生路径使嵌套进程创建和原生临时文件 API 仍可正常工作。堆上限通过 Node argv 或为打包可执行文件由提供方构造的 `NODE_OPTIONS` 值传递;环境中的加载器和调试器标志会被丢弃。
|
||||
|
||||
带长度分帧的 JSON 与 stdout/stderr 分开传输。Host 限制帧与排队写入,在分派前验证调用身份和已声明的绑定名,并拒绝无效通信。输出捕获计量序列化日志加完成值或诊断;固定结果信封字段与沙箱元数据不计入该账本。
|
||||
|
||||
|
||||
@@ -220,7 +220,9 @@ export class NodeCodeRuntime extends CodeRuntime {
|
||||
const packaged = 'pkg' in process && this.config.bootstrapPath === undefined
|
||||
const heapFlag = `--max-old-space-size=${this.config.maxOldGenerationSizeMb}`
|
||||
const argv = [executable, ...packaged ? [] : [heapFlag], ...bootstrapArgs(this.ctx.fs, this.config, this.config.maxMessageBytes)]
|
||||
confined = policy.mode === 'danger-full-access' ? undefined : this.ctx.sandbox.confine(argv, { ...policy, mode: policy.mode })
|
||||
confined = policy.mode === 'danger-full-access' ? undefined : await this.ctx.sandbox.confine(argv, { ...policy, mode: policy.mode }, signal)
|
||||
// oxlint-disable-next-line typescript/no-unnecessary-condition -- Cancellation can settle during awaited confinement.
|
||||
if (settled) return await result.promise
|
||||
if (confined !== undefined) sandbox.enforcement = confined.enforcement
|
||||
// Native launchers need executable search and Windows system paths before the child installs its model environment.
|
||||
const env: NodeJS.ProcessEnv = Object.fromEntries(Object.keys(process.env)
|
||||
|
||||
@@ -138,21 +138,21 @@ describe('Node runtime host failures', () => {
|
||||
|
||||
it('fails a required unavailable sandbox before spawning', async () => {
|
||||
const h = await setup({}, 'read-only')
|
||||
vi.spyOn(h.ctx.sandbox, 'confine').mockImplementation(() => { throw new SandboxUnavailableError('read-only') })
|
||||
vi.spyOn(h.ctx.sandbox, 'confine').mockRejectedValue(new SandboxUnavailableError('read-only'))
|
||||
expect((await h.start()).error?.kind).toBe('sandbox-unavailable')
|
||||
expect(h.spawn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps partial enforcement separate from a successful program', async () => {
|
||||
const h = await setup({}, 'read-only')
|
||||
vi.spyOn(h.ctx.sandbox, 'confine').mockImplementation(argv => confinement([...argv]))
|
||||
vi.spyOn(h.ctx.sandbox, 'confine').mockImplementation(async argv => confinement([...argv]))
|
||||
h.onBoot(() => { h.emit({ type: 'done', value: encodeCodeJsonWire(42) }) })
|
||||
expect(await h.start()).toEqual({ logs: [], value: 42, sandbox: { mode: 'read-only', denied: false, enforcement: 'partial' } })
|
||||
})
|
||||
|
||||
it.each([['EACCES: blocked', true], ['EPERM: unrelated dialect', false]] as const)('uses only the selected denial dialect for %s', async (message, denied) => {
|
||||
const h = await setup({}, 'read-only')
|
||||
vi.spyOn(h.ctx.sandbox, 'confine').mockImplementation(argv => confinement([...argv]))
|
||||
vi.spyOn(h.ctx.sandbox, 'confine').mockImplementation(async argv => confinement([...argv]))
|
||||
h.onBoot(() => { h.emit({ type: 'done', error: { kind: 'exception', message } }) })
|
||||
const result = await h.start()
|
||||
expect(result.error).toEqual({ kind: 'exception', message })
|
||||
@@ -161,7 +161,7 @@ describe('Node runtime host failures', () => {
|
||||
|
||||
it('distinguishes fatal sandbox startup output from a program denial', async () => {
|
||||
const h = await setup({}, 'read-only')
|
||||
vi.spyOn(h.ctx.sandbox, 'confine').mockImplementation(argv => confinement([...argv]))
|
||||
vi.spyOn(h.ctx.sandbox, 'confine').mockImplementation(async argv => confinement([...argv]))
|
||||
h.onBoot(() => {
|
||||
h.stderr.write('sandbox-fatal: runner could not initialize')
|
||||
h.direct.resolve({ exitCode: 1, signal: null })
|
||||
@@ -211,6 +211,30 @@ describe('Node runtime host failures', () => {
|
||||
expect(h.spawn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not launch when confinement resolves after cancellation', async () => {
|
||||
const h = await setup({}, 'read-only')
|
||||
const entered = Promise.withResolvers<AbortSignal>()
|
||||
const response = Promise.withResolvers<ConfinedArgv>()
|
||||
vi.spyOn(h.ctx.sandbox, 'confine').mockImplementation((_argv, _policy, signal) => {
|
||||
entered.resolve(signal!)
|
||||
return response.promise
|
||||
})
|
||||
const controller = new AbortController()
|
||||
const pending = h.start({ ...request, signal: controller.signal }, NO_INITIAL_FRAME)
|
||||
try {
|
||||
const signal = await entered.promise
|
||||
expect(signal.aborted).toBe(false)
|
||||
controller.abort('confinement cancelled')
|
||||
expect(signal.aborted).toBe(true)
|
||||
response.resolve(confinement([process.execPath]))
|
||||
expect((await pending).error).toEqual({ kind: 'abort', message: 'confinement cancelled' })
|
||||
expect(h.spawn).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
response.resolve(confinement([process.execPath]))
|
||||
await pending
|
||||
}
|
||||
})
|
||||
|
||||
it('reports an early control EOF using the direct process result', async () => {
|
||||
const h = await setup()
|
||||
h.spawn.mockImplementation(() => {
|
||||
@@ -422,7 +446,7 @@ describe('Node runtime host failures', () => {
|
||||
it.each([true, false])('attributes a failed confined spawn only with runner evidence (%s)', async (runnerFailed) => {
|
||||
const h = await setup({}, 'read-only')
|
||||
const runner = '/sandbox-runner'
|
||||
vi.spyOn(h.ctx.sandbox, 'confine').mockImplementation(argv => confinement([runner, ...argv]))
|
||||
vi.spyOn(h.ctx.sandbox, 'confine').mockImplementation(async argv => confinement([runner, ...argv]))
|
||||
h.onBoot(() => {
|
||||
h.direct.reject(Object.assign(new Error('spawn rejected'), runnerFailed ? { code: 'ENOENT', path: runner, syscall: `spawn ${runner}` } : {}))
|
||||
})
|
||||
|
||||
@@ -15,7 +15,7 @@ const sandboxUsable = await (async () => {
|
||||
const probe = new Context()
|
||||
try {
|
||||
await probe.plugin(Sandbox, {})
|
||||
probe.sandbox.confine([process.execPath, '--version'], { mode: 'read-only', workspaceRoot: process.cwd() })
|
||||
await probe.sandbox.confine([process.execPath, '--version'], { mode: 'read-only', workspaceRoot: process.cwd() })
|
||||
return true
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof SandboxUnavailableError) return false
|
||||
@@ -255,15 +255,15 @@ describe('Node program process', () => {
|
||||
const confine = ctx.sandbox.confine.bind(ctx.sandbox)
|
||||
const policy = runtime.resolve({ program: '', bindings: [] }).sandboxPolicy
|
||||
if (policy === undefined || policy.mode === 'danger-full-access') throw new Error('expected confined policy')
|
||||
const wrapped = confine([process.execPath, '--version'], { ...policy, mode: policy.mode })
|
||||
const wrapped = await confine([process.execPath, '--version'], { ...policy, mode: policy.mode })
|
||||
const original = wrapped.argv[0]
|
||||
if (original === undefined) throw new Error('expected sandbox launcher')
|
||||
const executable = await ctx.subprocess.resolveExecutable(original)
|
||||
const alias = 'ptc-private-sandbox-launcher'
|
||||
await symlink(executable, join(root, alias))
|
||||
const previousPath = process.env.PATH
|
||||
const substitute = vi.spyOn(ctx.sandbox, 'confine').mockImplementation((argv, selected) => {
|
||||
const result = confine(argv, selected)
|
||||
const substitute = vi.spyOn(ctx.sandbox, 'confine').mockImplementation(async (argv, selected, signal) => {
|
||||
const result = await confine(argv, selected, signal)
|
||||
return { ...result, argv: [alias, ...result.argv.slice(1)] }
|
||||
})
|
||||
try {
|
||||
|
||||
Reference in new issue
Block a user