refactor(sandbox): await cancellable process preparation

This commit is contained in:
Tianyi Cui committed 2026-09-12 15:03:03 +08:00
1 parent 72226bd061
commit 32de4412dc
11 files changed
+93 -94

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 .agents/notes/implemented/architecture/2026-09-11-posix-ssh-runtime.md
2026-09-11-posix-ssh-runtime.md: cdb2a50d410c55e739fbf4a71216f7ce2b6751df
2026-09-11-posix-ssh-runtime.zh.md: 263aedfd115a8b0ec4904619e14078408334321d
2026-09-11-posix-ssh-runtime.md: 50d69b731a6deac8cf7c9afd42ffa3d3138b979e
2026-09-11-posix-ssh-runtime.zh.md: 9ddeec2a5e7627c77b4cf75ae09150c183c6d681
@@ -14,6 +14,8 @@ SSH supplies authenticated byte channels and per-channel flow control, but an or
A deployment-owned OpenSSH alias connects the local Harness to an installed POSIX helper. Filesystem, subprocess and sandbox providers share that helper; the Harness retains Cordis objects, model transport, permissions, callbacks and Session persistence. Terminal methods remain asynchronous. Provider paths describe the execution world without a separate local/remote flag.
Confinement is asynchronous and cancellable: the running helper resolves each policy through its loaded sandbox provider before the subprocess provider receives literal argv. `ShellExecutor.start()` resolves a `Promise<ShellProcess>` after preparation. Generic job admission remains synchronous; tool-owned `JobHooks` begin asynchronous shell preparation after preflight, cancel pending preparation and join any late process handle.
Private administrative RPC and each program stream use independent SSH channels. A remote reservation creates the requested stream endpoints before the payload starts. Stdout and stderr cannot carry administrative replies, and pausing one output channel does not consume fd 7’s flow-control window. This trades a larger remote helper for explicit binary transport and bounded stream retention.
Each stream receives a fresh 256-bit TLS pre-shared key through private administrative RPC. TLS 1.2 with `PSK-AES256-GCM-SHA384` authenticates both endpoints and protects subsequent bytes before the stream can publish. The key never travels as a stream preface. Socket permissions alone are insufficient: file-effect confinement can permit same-user connections or pathname replacement in writable temporary directories. No administrative Unix listener or stream secret is exposed to the payload.
@@ -14,6 +14,8 @@ SSH 提供经过认证的字节通道及逐通道流量控制,但普通 exec
部署方持有的 OpenSSH 主机别名将本地 Harness 连接到已安装的 POSIX 辅助程序。文件系统、子进程及沙箱提供方共享该辅助程序;Harness 保留 Cordis 对象、模型传输、权限、回调与 Session 持久化。终端方法保持异步。提供方路径描述执行环境,无需额外的本地/远端标记。
限制解析支持异步与取消:正在运行的辅助程序通过已加载的沙箱提供方解析每次策略,然后子进程提供方取得可直接执行的 argv。`ShellExecutor.start()` 在准备完成后返回 `Promise<ShellProcess>`。通用任务准入保持同步;由工具负责的 `JobHooks` 在预检后启动异步 shell 准备,取消待完成的准备并等待任何延迟出现的进程句柄结束。
私有管理 RPC 与各条程序流使用独立 SSH 通道。远端预留操作在程序启动前创建请求的流端点。stdout 与 stderr 无法承载管理回复,暂停一条输出通道也不会占用 fd 7 的流控窗口。这用更大的远端辅助程序换取显式二进制传输与有界流保留。
每条流通过私有管理 RPC 获得新的 256 位 TLS 预共享密钥。TLS 1.2 配合 `PSK-AES256-GCM-SHA384` 在流发布前认证两端并保护后续字节。密钥绝不作为流前缀传输。仅靠套接字权限不足:文件效果限制可能允许同用户连接,或允许在可写临时目录中替换路径名。程序不会获得管理 Unix 监听端点或流密钥。
+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/ssh/sandbox-ssh/README.md
README.md: e7ec7eb90e1befde48335a891d3afbb232b3a760
README.zh.md: 41e1aa2689d9d06494351dd5ab9aeefc75435fc3
README.md: e8f6cb8d47288e92fe8082ae23d550dff344324d
README.zh.md: 694ff99cb0a5df0c45392de0211df2dfa223070d
+2 -2
View File
@@ -25,7 +25,7 @@ English | [中文](README.zh.md)
<a id="use-this-package"></a>
## Use this package
Mount this provider with the shared [`SSH connection`](../ssh/README.md), SSH filesystem and SSH subprocess providers. It has no package-specific configuration. The service verifies the remote backend before the composition becomes ready, then exposes synchronous `confine()` using those established facts.
Mount this provider with the shared [`SSH connection`](../ssh/README.md), SSH filesystem and SSH subprocess providers. It has no package-specific configuration. Await `confine(argv, policy, signal)` to resolve each policy and command through the running remote helper.
Pass a complete `read-only` or `workspace-write` policy. The workspace is interpreted and canonicalized on the remote host. Consumers bypass `confine()` for `danger-full-access`; the connection does not invent an additional local/remote policy flag.
@@ -37,7 +37,7 @@ Pass a complete `read-only` or `workspace-write` policy. The workspace is interp
<details>
<summary>Implementation internals — click to expand</summary>
The wrapper invokes the installed helper’s private confinement path, which rechecks the selected runner, enforcement level and denial signatures before running the payload. A changed backend refuses execution rather than returning stale enforcement facts. The subprocess provider remains responsible for fd 7 transport and managed process lifetime.
The running helper canonicalizes the policy root and asks its loaded sandbox provider for the enforcing argv, enforcement level and runner-failure evidence. The SSH subprocess provider executes that returned argv directly and owns fd 7 transport and managed process lifetime. Cancellation or an unavailable backend rejects before the caller receives a command to launch.
</details>
+2 -2
View File
@@ -25,7 +25,7 @@ kind: "package-reference"
<a id="use-this-package"></a>
## 使用本包
将本提供方与共享 [SSH 连接](../ssh/README.zh.md)、SSH 文件系统及 SSH 子进程提供方一同挂载。本包没有专用配置。服务在组合就绪前验证远端后端,再基于已确认事实提供同步 `confine()`。
将本提供方与共享 [SSH 连接](../ssh/README.zh.md)、SSH 文件系统及 SSH 子进程提供方一同挂载。本包没有专用配置。等待 `confine(argv, policy, signal)`,由正在运行的远端辅助程序解析每次调用的策略与命令。
传入完整的 `read-only` 或 `workspace-write` 策略。工作区路径在远端主机解释并规范化。消费方在 `danger-full-access` 模式下绕过 `confine()`;连接不额外引入本地/远端策略标记。
@@ -37,7 +37,7 @@ kind: "package-reference"
<details>
<summary>实现细节 — 点击展开</summary>
包装命令调用已安装辅助程序的私有限制入口,在执行程序前重新核对所选运行器、执行完整性等级及拒绝特征。后端发生变化时拒绝执行,避免返回过期的执行信息。fd 7 传输及托管进程生命周期仍由子进程提供方负责。
正在运行的辅助程序规范化策略根路径,再向已加载的沙箱提供方获取强制执行限制的 argv、执行完整性等级与运行器失败证据。SSH 子进程提供方直接执行返回的 argv,并负责 fd 7 传输及托管进程生命周期。取消请求或后端不可用时,会在调用方取得可启动命令之前拒绝请求。
</details>
+17 -24
View File
@@ -1,5 +1,4 @@
/** Remote argv wrapper that selects and applies the sandbox on the SSH host. */
import { Service } from '@deepseek-ai/cordis'
import { SandboxProvider, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox'
import type { ConfinedArgv, SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
import type {} from '@deepseek-ai/dsh-ssh'
@@ -7,32 +6,26 @@ import { z } from 'zod'
const factsSchema = z.object({ argv: z.array(z.string()).min(1), enforcement: z.enum(['full', 'partial']), denialSignatures: z.array(z.string()), runnerFailureRules: z.array(z.object({ allowedExitCodes: z.array(z.number().int()).optional(), fatalSignatures: z.array(z.string()), informationalLines: z.array(z.string()).optional() }).strict()) }).strict()
/** Synchronous wrapping uses verified remote backend facts established before the composition becomes ready. */
/** Resolve each confinement request on the same host as its filesystem and subprocess providers. */
export class SshSandboxProvider extends SandboxProvider {
static inject = ['ssh']
private facts: z.infer<typeof factsSchema> | undefined
private node: string | undefined
/** Verify the remote backend before exposing synchronous argv wrapping. */
async [Service.init](): Promise<void> {
const hello = await this.ctx.ssh.ready
this.node = hello.node
this.facts = await this.ctx.ssh.request('sandbox', { argv: ['true'], policy: { mode: 'read-only', workspaceRoot: hello.workspace } }, factsSchema)
}
override confine(argv: readonly string[], policy: SandboxPolicy): ConfinedArgv {
if (this.facts === undefined || this.node === undefined) throw new SandboxUnavailableError(policy.mode, 'SSH sandbox has not established its remote backend')
const request = {
policy,
runner: this.facts.argv[0],
enforcement: this.facts.enforcement,
denialSignatures: this.facts.denialSignatures,
}
return {
argv: [this.node, this.ctx.ssh.helperPath, '--confine', JSON.stringify(request), '--', ...argv],
enforcement: this.facts.enforcement,
denialSignatures: this.facts.denialSignatures,
runnerFailureRules: [...this.facts.runnerFailureRules.map(rule => ({ fatalSignatures: rule.fatalSignatures, ...(rule.allowedExitCodes === undefined ? {} : { allowedExitCodes: rule.allowedExitCodes }), ...(rule.informationalLines === undefined ? {} : { informationalLines: rule.informationalLines }) })), { allowedExitCodes: [127], fatalSignatures: ['dsh-ssh-sandbox: '] }],
override async confine(argv: readonly string[], policy: SandboxPolicy, signal?: AbortSignal): Promise<ConfinedArgv> {
signal?.throwIfAborted()
try {
const confined = await this.ctx.ssh.request('sandbox', { argv, policy }, factsSchema, signal)
signal?.throwIfAborted()
return {
...confined,
runnerFailureRules: confined.runnerFailureRules.map(rule => ({
fatalSignatures: rule.fatalSignatures,
...(rule.allowedExitCodes === undefined ? {} : { allowedExitCodes: rule.allowedExitCodes }),
...(rule.informationalLines === undefined ? {} : { informationalLines: rule.informationalLines }),
})),
}
} catch (error) {
signal?.throwIfAborted()
throw new SandboxUnavailableError(policy.mode, error instanceof Error ? error.message : String(error))
}
}
}
+53 -52
View File
@@ -1,3 +1,4 @@
/** Each confinement request resolves remotely before any subprocess receives its argv. */
import { Context, Service } from '@deepseek-ai/cordis'
import { SandboxUnavailableError, type SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
import { describe, expect, it, onTestFinished, vi } from 'vitest'
@@ -11,78 +12,78 @@ const completeFacts = {
}
async function setup(raw: unknown = completeFacts) {
const ready = Promise.withResolvers<{ node: string; workspace: string }>()
const entered = Promise.withResolvers<undefined>()
const dispatch = vi.fn(async (_method: string, _params: unknown) => { entered.resolve(undefined); return raw })
const dispatch = vi.fn(async (_method: string, _params: unknown, _signal?: AbortSignal) => raw)
class Connection extends Service {
readonly helperPath = '/opt/dsh/helper.js'
readonly ready = ready.promise
constructor(ctx: Context) { super(ctx, 'ssh') }
async request<T>(method: string, params: unknown, result: z.ZodType<T>): Promise<T> {
return result.parse(await dispatch(method, params))
async request<T>(method: string, params: unknown, result: z.ZodType<T>, signal?: AbortSignal): Promise<T> {
return result.parse(await dispatch(method, params, signal))
}
}
const ctx = new Context()
const connection = await ctx.plugin(Connection)
const fiber = ctx.plugin(SshSandboxProvider)
onTestFinished(async () => {
ready.resolve({ node: '/usr/bin/node', workspace: '/remote/work' })
await fiber.dispose()
await connection.dispose()
})
return { ctx, fiber, ready, dispatch, entered }
const fiber = await ctx.plugin(SshSandboxProvider)
onTestFinished(async () => { await fiber.dispose(); await connection.dispose() })
return { ctx, dispatch }
}
describe('SSH sandbox provider', () => {
it('refuses wrapping before remote backend readiness and then carries verified facts', async () => {
it('awaits remote policy resolution and returns the literal enforcing argv', async () => {
const state = await setup()
expect(() => state.ctx.sandbox.confine(['/usr/bin/node', '-e', '42'], policy)).toThrow(SandboxUnavailableError)
expect(state.dispatch).not.toHaveBeenCalled()
state.ready.resolve({ node: '/usr/bin/node', workspace: '/remote/work' })
await state.fiber
expect(state.dispatch).toHaveBeenCalledWith('sandbox', {
argv: ['true'], policy: { mode: 'read-only', workspaceRoot: '/remote/work' },
})
const result = Promise.withResolvers<typeof completeFacts>()
state.dispatch.mockReturnValueOnce(result.promise)
const argv = ['/usr/bin/node', '-e', 'console.log("shell $() ; quotes")']
const confined = state.ctx.sandbox.confine(argv, policy)
expect(confined.argv.slice(0, 3)).toEqual(['/usr/bin/node', '/opt/dsh/helper.js', '--confine'])
expect(JSON.parse(confined.argv[3]!)).toEqual({
policy, runner: '/usr/bin/bwrap', enforcement: 'full', denialSignatures: ['EROFS', 'EACCES'],
})
expect(confined.argv.slice(4)).toEqual(['--', ...argv])
expect(confined.enforcement).toBe('full')
expect(confined.denialSignatures).toEqual(['EROFS', 'EACCES'])
expect(confined.runnerFailureRules).toEqual([
...completeFacts.runnerFailureRules, { allowedExitCodes: [127], fatalSignatures: ['dsh-ssh-sandbox: '] },
])
expect(state.dispatch).toHaveBeenCalledTimes(1)
const controller = new AbortController()
let settled = false
const pending = state.ctx.sandbox.confine(argv, policy, controller.signal).then((value) => { settled = true; return value })
await Promise.resolve()
expect(settled).toBe(false)
expect(state.dispatch).toHaveBeenCalledWith('sandbox', { argv, policy }, controller.signal)
const response = { ...completeFacts, argv: ['/usr/bin/bwrap', '--', ...argv] }
result.resolve(response)
expect(await pending).toEqual(response)
})
it('preserves partial enforcement and omitted runner-rule fields', async () => {
const state = await setup({ ...completeFacts, enforcement: 'partial', runnerFailureRules: [{ fatalSignatures: ['runner unavailable'] }] })
state.ready.resolve({ node: '/usr/bin/node', workspace: '/remote/work' })
await state.fiber
const confined = state.ctx.sandbox.confine(['true'], policy)
expect(confined.enforcement).toBe('partial')
expect(confined.runnerFailureRules[0]).toEqual({ fatalSignatures: ['runner unavailable'] })
it('obtains current backend facts for every execution policy', async () => {
const state = await setup()
expect((await state.ctx.sandbox.confine(['true'], policy)).enforcement).toBe('full')
const partial = { ...completeFacts, argv: ['/opt/landlock', '--', 'true'], enforcement: 'partial', runnerFailureRules: [{ fatalSignatures: ['runner unavailable'] }] }
state.dispatch.mockResolvedValueOnce(partial)
expect(await state.ctx.sandbox.confine(['true'], { ...policy, mode: 'read-only' })).toEqual(partial)
expect(state.dispatch).toHaveBeenCalledTimes(2)
})
it.each([null, { ...completeFacts, enforcement: 'unknown' }, { ...completeFacts, runnerFailureRules: [{ fatalSignatures: 1 }] }])(
'refuses malformed backend observations during service readiness', async (raw) => {
it.each([null, { ...completeFacts, argv: [] }, { ...completeFacts, enforcement: 'unknown' }, { ...completeFacts, runnerFailureRules: [{ fatalSignatures: 1 }] }])(
'refuses malformed backend observations before exposing argv', async (raw) => {
const state = await setup(raw)
const rejected = expect(state.fiber).rejects.toThrow()
state.ready.resolve({ node: '/usr/bin/node', workspace: '/remote/work' })
await rejected
expect(() => state.ctx.sandbox.confine(['true'], policy)).toThrow()
await expect(state.ctx.sandbox.confine(['true'], policy)).rejects.toBeInstanceOf(SandboxUnavailableError)
expect(state.dispatch).toHaveBeenCalledTimes(1)
},
)
it('propagates remote backend discovery failure before publishing a wrapper', async () => {
it.each([new Error('remote disconnected'), 'remote disconnected'])('reports unavailable confinement without fallback or replay', async (error) => {
const state = await setup()
state.dispatch.mockRejectedValueOnce(new Error('remote sandbox unavailable'))
const rejected = expect(state.fiber).rejects.toThrow('remote sandbox unavailable')
state.ready.resolve({ node: '/usr/bin/node', workspace: '/remote/work' })
await rejected
state.dispatch.mockRejectedValueOnce(error)
await expect(state.ctx.sandbox.confine(['true'], policy)).rejects.toMatchObject({ name: 'SandboxUnavailableError', code: 'SANDBOX_UNAVAILABLE' })
expect(state.dispatch).toHaveBeenCalledTimes(1)
})
it('does not send an already-cancelled confinement request', async () => {
const state = await setup()
const reason = new Error('cancel before confinement')
await expect(state.ctx.sandbox.confine(['true'], policy, AbortSignal.abort(reason))).rejects.toBe(reason)
expect(state.dispatch).not.toHaveBeenCalled()
})
it('preserves cancellation while remote resolution is pending', async () => {
const state = await setup()
const controller = new AbortController()
const reason = new Error('cancel during confinement')
state.dispatch.mockImplementationOnce((_method, _params, signal) => new Promise((_resolve, reject) => {
signal?.addEventListener('abort', () => { reject(signal.reason) }, { once: true })
}))
const pending = state.ctx.sandbox.confine(['true'], policy, controller.signal)
const rejected = expect(pending).rejects.toBe(reason)
controller.abort(reason)
await rejected
})
})
+1 -1
View File
@@ -200,7 +200,7 @@ async function confine(): Promise<void> {
const { policy } = request
if (policy.mode === 'danger-full-access' || process.argv[4] !== '--') throw new Error('Invalid remote sandbox invocation')
const workspaceRoot = runtime.ctx.fs.processPath(await runtime.ctx.fs.resolve(policy.workspaceRoot))
const wrapped = runtime.ctx.sandbox.confine(process.argv.slice(5), { ...policy, workspaceRoot } as SandboxPolicy)
const wrapped = await runtime.ctx.sandbox.confine(process.argv.slice(5), { ...policy, workspaceRoot } as SandboxPolicy)
if (wrapped.argv[0] !== request.runner || wrapped.enforcement !== request.enforcement
|| JSON.stringify(wrapped.denialSignatures) !== JSON.stringify(request.denialSignatures)) {
throw new Error('Remote sandbox backend changed; this connection cannot attest the requested confinement')
+8 -7
View File
@@ -71,20 +71,21 @@ describe.skipIf(!enabled)('POSIX SSH runtime acceptance', () => {
stdio: { stdin: 'ignore', stdout: 'pipe', stderr: 'pipe', control: 'pipe' },
}, preparedSchema)
const endpoint = prepared.streams.control!
const attack = 'const net=require(\'node:net\');const s=net.createConnection(process.argv[1]);let connected=false;let bytes=0;s.on(\'connect\',()=>{connected=true;const v=Buffer.from(JSON.stringify({type:\'request\',id:\'forged\',method:\'process.start\',params:{}}));const h=Buffer.alloc(4);h.writeUInt32BE(v.length);s.write(Buffer.concat([h,v]))});s.on(\'data\',b=>{bytes+=b.length});s.on(\'error\',()=>{});s.on(\'close\',()=>process.stdout.write(JSON.stringify({connected,bytes})));'
const attack = "let connected=false,secure=false;const s=require('node:tls').connect({path:process.argv[1],ciphers:'PSK-AES256-GCM-SHA384',minVersion:'TLSv1.2',maxVersion:'TLSv1.2',pskCallback:()=>({identity:'dsh-stream',psk:Buffer.alloc(32)}),checkServerIdentity:()=>undefined});s.on('connect',()=>{connected=true});s.on('secureConnect',()=>{secure=true});s.on('error',()=>{});s.on('close',()=>process.stdout.write(JSON.stringify({connected,secure})));"
const attacker = test.ctx.subprocess.spawn({
argv: test.ctx.sandbox.confine([test.hello.node, '-e', attack, endpoint.path], {
argv: (await test.ctx.sandbox.confine([test.hello.node, '-e', attack, endpoint.path], {
mode: 'read-only', workspaceRoot: test.root,
}).argv,
})).argv,
cwd: test.root, stdio: { stdin: 'ignore', stdout: { maxBytes: 4096 }, stderr: { maxBytes: 4096 } }, graceMs: 500,
})
const observed = await processResult(attacker)
expect(observed.exitCode).toBe(0)
expect(JSON.parse(observed.stdout as string)).toEqual({ connected: true, bytes: 0 })
expect(JSON.parse(observed.stdout as string)).toEqual({ connected: true, secure: false })
const sockets = await Promise.all(Object.entries(prepared.streams).map(async ([name, value]) =>
[name, await test.ctx.ssh.connectStream(value)] as const))
const output: Buffer[] = []
for (const [name, socket] of sockets) {
socket.end()
socket.on('data', (chunk) => { if (name === 'stdout') output.push(Buffer.from(chunk)) })
socket.resume()
}
@@ -113,7 +114,7 @@ describe.skipIf(!enabled)('POSIX SSH runtime acceptance', () => {
for await (const chunk of await ctx.fs.streamText(target)) chunks.push(chunk)
expect(chunks.join('')).toBe('beta\n')
const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: root }
const argv = ctx.sandbox.confine(['/bin/bash', '-c', 'cat text.txt; printf "shell\n" > text.txt'], policy).argv
const argv = (await ctx.sandbox.confine(['/bin/bash', '-c', 'cat text.txt; printf "shell\n" > text.txt'], policy)).argv
const result = await processResult(ctx.subprocess.spawn({
argv, cwd: root, stdio: { stdin: 'ignore', stdout: { maxBytes: 4096 }, stderr: { maxBytes: 4096 } }, graceMs: 500,
}))
@@ -138,7 +139,7 @@ describe.skipIf(!enabled)('POSIX SSH runtime acceptance', () => {
const test = await setup()
try {
const code = 'const fs=require(\'node:fs\');const s=new(require(\'node:net\').Socket)({fd:7,readable:true,writable:true,allowHalfOpen:true});let a=[];s.on(\'data\',b=>a.push(b));s.on(\'end\',()=>{let denied;try{fs.writeFileSync(\'forbidden\',\'bad\')}catch(e){denied=e.code}process.stdout.write(JSON.stringify({pid:process.pid,denied,cgroup:process.platform===\'linux\'?fs.readFileSync(\'/proc/self/cgroup\',\'utf8\'):\'\'}));process.stderr.write(\'stderr\');s.end(Buffer.concat(a))});'
const argv = test.ctx.sandbox.confine([test.hello.node, '-e', code], { mode: 'read-only', workspaceRoot: test.root }).argv
const argv = (await test.ctx.sandbox.confine([test.hello.node, '-e', code], { mode: 'read-only', workspaceRoot: test.root })).argv
const handle = test.ctx.subprocess.spawn({
argv, cwd: test.root,
stdio: { stdin: 'ignore', stdout: { maxBytes: 4096 }, stderr: { maxBytes: 4096 }, control: 'pipe' }, graceMs: 1000,
@@ -166,7 +167,7 @@ describe.skipIf(!enabled)('POSIX SSH runtime acceptance', () => {
try {
const code = 'const s=new(require(\'node:net\').Socket)({fd:7,readable:true,writable:true});const b=Buffer.alloc(65536,120);let n=0;function pump(){while(n<10000){n++;if(!process.stdout.write(b))return}}process.stdout.on(\'drain\',pump);s.on(\'data\',v=>s.write(v));pump();setInterval(()=>{},1000);'
handle = test.ctx.subprocess.spawn({
argv: test.ctx.sandbox.confine([test.hello.node, '-e', code], { mode: 'workspace-write', workspaceRoot: test.root }).argv,
argv: (await test.ctx.sandbox.confine([test.hello.node, '-e', code], { mode: 'workspace-write', workspaceRoot: test.root })).argv,
cwd: test.root, stdio: { stdin: 'ignore', stdout: 'pipe', stderr: { maxBytes: 1024 }, control: 'pipe' }, graceMs: 500,
})
const reply = once(handle.control!, 'data')
+2 -2
View File
@@ -32,7 +32,7 @@ describe.skipIf(process.platform === 'win32')('SSH stream pathname replacement',
const endpoint = prepared.streams.control!
const capture = `${root}/captured-tls`
const relay = 'const fs=require(\'node:fs\'),net=require(\'node:net\'),p=process.argv[1];fs.renameSync(p,p+\'.saved\');net.createServer({allowHalfOpen:true},front=>{const back=net.createConnection({path:p+\'.saved\',allowHalfOpen:true});front.on(\'data\',v=>fs.appendFileSync(process.argv[2],v));back.on(\'data\',v=>fs.appendFileSync(process.argv[2],v));front.on(\'error\',()=>back.destroy());back.on(\'error\',()=>front.destroy());front.pipe(back).pipe(front)}).listen(p,()=>process.stdout.write(\'rebound\\n\'));'
const confined = ctx.sandbox.confine([process.execPath, '-e', relay, endpoint.path, capture], {
const confined = await ctx.sandbox.confine([process.execPath, '-e', relay, endpoint.path, capture], {
mode: 'workspace-write', workspaceRoot: root,
})
attacker = ctx.subprocess.spawn({
@@ -48,7 +48,7 @@ describe.skipIf(process.platform === 'win32')('SSH stream pathname replacement',
const secured = await authenticateStream(raw, value.capability, 5000)
sockets.push(secured)
if (name === 'control') control = secured
else secured.resume()
else { secured.end(); secured.resume() }
}
const secret = `private-process-payload-${randomUUID()}`
const received: Buffer[] = []