fix(code-runtime): preserve native startup paths and preview packaging
This commit is contained in:
1 parent
ca7f3a523d
commit
a6f7eeadc6
10 files changed
+53
-19
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: 02064e378310c2d571caca61912f6b599329714e
|
||||
README.zh.md: d23df399936ec4f20e1dc6c802c6832f32a4ab42
|
||||
README.md: 671f6f1ad1bc6cb89bc0ca827fc6f0cc99d903a1
|
||||
README.zh.md: 654afc6108c1ad51efed1e89369eb55112f59676
|
||||
@@ -83,7 +83,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`. The child adopts the inherited control channel and clears its environment before evaluating the program. Explicit Node arguments avoid inheriting host loader or inspector flags.
|
||||
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`. Native launchers retain executable-search and Windows system paths during startup. The child adopts the inherited control channel and clears every environment entry before evaluating the program. Explicit Node arguments avoid inheriting host loader or inspector flags.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -83,7 +83,7 @@ Host 负责策略、截止时间、绑定查找和进程清理。子进程负责
|
||||
|
||||
### 启动与控制
|
||||
|
||||
Host 擦除可擦除类型,在配置的执行世界中解析可执行文件与 bootstrap,通过 `ctx.sandbox` 包装 argv,并通过 `ctx.subprocess` 启动。子进程接管继承的控制通道,在求值程序前清空环境。显式 Node 参数避免继承 Host 的加载器或调试器标志。
|
||||
Host 擦除可擦除类型,在配置的执行世界中解析可执行文件与 bootstrap,通过 `ctx.sandbox` 包装 argv,并通过 `ctx.subprocess` 启动。原生启动器在启动期间保留可执行文件搜索路径和 Windows 系统路径。子进程接管继承的控制通道,在求值程序前清空全部环境变量。显式 Node 参数避免继承 Host 的加载器或调试器标志。
|
||||
|
||||
带长度分帧的 JSON 与 stdout/stderr 分开传输。Host 限制帧与排队写入,在分派前验证调用身份和已声明的绑定名,并拒绝无效通信。输出捕获计量序列化日志加完成值或诊断;固定结果信封字段与沙箱元数据不计入该账本。
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
/** Confined Node programs with host-owned bindings, output limits, and managed process cleanup. */
|
||||
import { stripTypeScriptTypes } from 'node:module'
|
||||
import { isAbsolute } from 'node:path'
|
||||
import { StringDecoder } from 'node:string_decoder'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import z from '@deepseek-ai/schemastery'
|
||||
import { CodeRuntime } from '@deepseek-ai/dsh-code-runtime'
|
||||
@@ -44,6 +43,7 @@ type ResolvedConfig = Required<Omit<Config, 'bootstrapPath'>> & Pick<Config, 'bo
|
||||
interface LiveRun { controller: AbortController; finished: Promise<void> }
|
||||
const STRIP_PREFIX = 'async function __dsh_program__() {\n'
|
||||
const STRIP_SUFFIX = '\n}'
|
||||
const STARTUP_ENVIRONMENT = new Set(['PATH', 'PATHEXT', 'SYSTEMROOT', 'WINDIR'])
|
||||
|
||||
function messageOf(error: unknown): string { return error instanceof Error ? error.message : String(error) }
|
||||
function record(value: unknown): value is Record<string, unknown> { return typeof value === 'object' && value !== null && !Array.isArray(value) }
|
||||
@@ -213,7 +213,10 @@ export class NodeCodeRuntime extends CodeRuntime {
|
||||
const argv = [executable, `--max-old-space-size=${this.config.maxOldGenerationSizeMb}`, ...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 })
|
||||
if (confined !== undefined) sandbox.enforcement = confined.enforcement
|
||||
const env: NodeJS.ProcessEnv = Object.fromEntries(Object.keys(process.env).map(key => [key, undefined]))
|
||||
// Native launchers need executable search and Windows system paths until the child clears its environment.
|
||||
const env: NodeJS.ProcessEnv = Object.fromEntries(Object.keys(process.env)
|
||||
.filter(key => !STARTUP_ENVIRONMENT.has(key.toUpperCase()))
|
||||
.map(key => [key, undefined]))
|
||||
if ('pkg' in process && this.config.bootstrapPath === undefined) env.DSH_CODE_RUNTIME_NODE = '1'
|
||||
handle = this.ctx.subprocess.spawn({ argv: confined?.argv ?? argv, cwd: spec.cwd, env, stdio: { stdin: 'ignore', stdout: 'pipe', stderr: 'pipe', control: 'pipe' }, graceMs: this.config.graceMs, signal })
|
||||
const launched = handle
|
||||
@@ -228,24 +231,24 @@ export class NodeCodeRuntime extends CodeRuntime {
|
||||
finish({ kind: 'output-limit', message: `outer output exceeded ${this.config.maxOutputBytes} bytes` })
|
||||
}
|
||||
}
|
||||
const stdoutDecoder = new StringDecoder('utf8')
|
||||
const stderrDecoder = new StringDecoder('utf8')
|
||||
const stdoutDecoder = new TextDecoder('utf-8', { ignoreBOM: true })
|
||||
const stderrDecoder = new TextDecoder('utf-8', { ignoreBOM: true })
|
||||
handle.stdout?.on('data', (chunk: Buffer) => {
|
||||
const text = stdoutDecoder.write(chunk)
|
||||
const text = stdoutDecoder.decode(chunk, { stream: true })
|
||||
if (text.length > 0) admit(text)
|
||||
})
|
||||
handle.stdout?.on('end', () => {
|
||||
const text = stdoutDecoder.end()
|
||||
const text = stdoutDecoder.decode()
|
||||
if (text.length > 0) admit(text)
|
||||
})
|
||||
handle.stdout?.on('error', (error: Error) => { finish({ kind: 'worker-exit', message: messageOf(error) }) })
|
||||
handle.stderr?.on('data', (chunk: Buffer) => {
|
||||
const text = stderrDecoder.write(chunk)
|
||||
const text = stderrDecoder.decode(chunk, { stream: true })
|
||||
stderr = (stderr + text).slice(-this.config.maxOutputBytes)
|
||||
if (text.length > 0) admit(text)
|
||||
})
|
||||
handle.stderr?.on('end', () => {
|
||||
const text = stderrDecoder.end()
|
||||
const text = stderrDecoder.decode()
|
||||
if (text.length > 0) admit(text)
|
||||
})
|
||||
handle.stderr?.on('error', (error: Error) => { finish({ kind: 'worker-exit', message: messageOf(error) }) })
|
||||
|
||||
@@ -2,4 +2,4 @@
|
||||
import { openInheritedControlChannel } from '@deepseek-ai/dsh-subprocess/control'
|
||||
import { runNodeMain } from './process.ts'
|
||||
|
||||
await runNodeMain(openInheritedControlChannel(), Number(process.argv[2]), process)
|
||||
void runNodeMain(openInheritedControlChannel(), Number(process.argv[2]), process)
|
||||
@@ -376,6 +376,7 @@ describe('Node runtime host failures', () => {
|
||||
const h = await setup()
|
||||
h.onBoot(() => {
|
||||
for (const stream of [h.stdout, h.stderr]) {
|
||||
stream.write(Buffer.from([0xef, 0xbb, 0xbf]))
|
||||
stream.write(Buffer.from([0xe2]))
|
||||
stream.write(Buffer.from([0x82, 0xac]))
|
||||
stream.end(Buffer.from([0xe2]))
|
||||
@@ -384,6 +385,7 @@ describe('Node runtime host failures', () => {
|
||||
})
|
||||
const result = await h.start()
|
||||
expect(result.error).toBeUndefined()
|
||||
expect(result.logs.filter(text => text === '\uFEFF')).toHaveLength(2)
|
||||
expect(result.logs.filter(text => text === '€')).toHaveLength(2)
|
||||
expect(result.logs.filter(text => text === '�')).toHaveLength(2)
|
||||
})
|
||||
@@ -436,6 +438,7 @@ describe('Node runtime host failures', () => {
|
||||
expect((await h.start()).error).toBeUndefined()
|
||||
const spec = h.spawn.mock.calls[0]?.[0]
|
||||
expect(spec?.env?.DSH_CODE_RUNTIME_NODE).toBe('1')
|
||||
expect(Object.hasOwn(spec?.env ?? {}, 'PATH')).toBe(false)
|
||||
expect(spec?.argv.at(-1)).toBe('134217728')
|
||||
expect(Object.entries(spec?.env ?? {}).filter(([, value]) => value !== undefined)).toEqual([['DSH_CODE_RUNTIME_NODE', '1']])
|
||||
} finally {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { mkdtemp, mkdir, readFile, rm, symlink } from 'node:fs/promises'
|
||||
import { homedir } from 'node:os'
|
||||
import { createServer, type Socket } from 'node:net'
|
||||
import { join } from 'node:path'
|
||||
import { delimiter, join } from 'node:path'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { describe, expect, it, onTestFinished } from 'vitest'
|
||||
import { describe, expect, it, onTestFinished, vi } from 'vitest'
|
||||
import type { CodeBindingFunction, CodeBindingNamespace, CodeRunRequest } from '@deepseek-ai/dsh-code-runtime'
|
||||
import type { Config } from '../src/index.ts'
|
||||
import { mountRuntime } from './setup.ts'
|
||||
@@ -175,6 +175,34 @@ describe('Node program process', () => {
|
||||
await expect(readFile(path)).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
})
|
||||
|
||||
it.skipIf(process.platform === 'win32')('starts a confining launcher found only through the execution PATH', async () => {
|
||||
const { ctx, runtime, run, root } = await setup({}, 'read-only')
|
||||
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)
|
||||
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)
|
||||
return { ...result, argv: [alias, ...result.argv.slice(1)] }
|
||||
})
|
||||
try {
|
||||
process.env.PATH = `${root}${delimiter}${previousPath ?? ''}`
|
||||
const result = await run({ program: 'return { env: Object.keys(process.env) };', bindings: [] })
|
||||
expect(result.error).toBeUndefined()
|
||||
expect(result.value).toEqual({ env: [] })
|
||||
} finally {
|
||||
substitute.mockRestore()
|
||||
if (previousPath === undefined) delete process.env.PATH
|
||||
else process.env.PATH = previousPath
|
||||
}
|
||||
})
|
||||
|
||||
it('permits workspace writes and denies a symlink to a sibling outside it', async () => {
|
||||
const { run, cwd, root } = await setup({}, 'workspace-write')
|
||||
const target = join(cwd, 'allowed.txt')
|
||||
|
||||
Reference in new issue
Block a user