diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 70c5148ed5..bf047f88a3 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -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 docs/config-catalog.md -config-catalog.md: 0683f66963b27d859e0ba37474d1913ada295df3 -config-catalog.zh.md: e246d9e5942f7fb195449fae055e7a73f12b879f +config-catalog.md: da6b091110a6e4dc7afb8955ea223cdf537c00ea +config-catalog.zh.md: cc2bd99c13ce37a2d8c13c9abf6930e250c73cf0 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 0683f66963..da6b091110 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -435,7 +435,7 @@ export interface LaunchConfig { } ``` -Source: [`packages/code-runtime/code-runtime-node/src/index.ts:25`](../packages/code-runtime/code-runtime-node/src/index.ts) +Source: [`packages/code-runtime/code-runtime-node/src/index.ts:26`](../packages/code-runtime/code-runtime-node/src/index.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index e246d9e594..cc2bd99c13 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -437,7 +437,7 @@ export interface LaunchConfig { } ``` -来源: [`packages/code-runtime/code-runtime-node/src/index.ts:25`](../packages/code-runtime/code-runtime-node/src/index.ts) +来源: [`packages/code-runtime/code-runtime-node/src/index.ts:26`](../packages/code-runtime/code-runtime-node/src/index.ts) diff --git a/packages/code-runtime/code-runtime-node/README.i18n.yaml b/packages/code-runtime/code-runtime-node/README.i18n.yaml index 74c45b74f7..3d67a086c0 100644 --- a/packages/code-runtime/code-runtime-node/README.i18n.yaml +++ b/packages/code-runtime/code-runtime-node/README.i18n.yaml @@ -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: 671f6f1ad1bc6cb89bc0ca827fc6f0cc99d903a1 -README.zh.md: 654afc6108c1ad51efed1e89369eb55112f59676 +README.md: d0d395922b26e4eea84a27a08757abe71ef99866 +README.zh.md: 1c052adee04ceeab2bcbd3dfe391a5424beb0daa diff --git a/packages/code-runtime/code-runtime-node/README.md b/packages/code-runtime/code-runtime-node/README.md index 671f6f1ad1..d0d395922b 100644 --- a/packages/code-runtime/code-runtime-node/README.md +++ b/packages/code-runtime/code-runtime-node/README.md @@ -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`. 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. +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 and Windows system paths in its OS environment and replaces the program-visible `process.env` with an empty dictionary. The retained native paths keep nested Windows process creation functional. 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. diff --git a/packages/code-runtime/code-runtime-node/README.zh.md b/packages/code-runtime/code-runtime-node/README.zh.md index 654afc6108..1c052adee0 100644 --- a/packages/code-runtime/code-runtime-node/README.zh.md +++ b/packages/code-runtime/code-runtime-node/README.zh.md @@ -83,7 +83,7 @@ Host 负责策略、截止时间、绑定查找和进程清理。子进程负责 ### 启动与控制 -Host 擦除可擦除类型,在配置的执行世界中解析可执行文件与 bootstrap,通过 `ctx.sandbox` 包装 argv,并通过 `ctx.subprocess` 启动。原生启动器在启动期间保留可执行文件搜索路径和 Windows 系统路径。子进程接管继承的控制通道,在求值程序前清空全部环境变量。显式 Node 参数避免继承 Host 的加载器或调试器标志。 +Host 擦除可擦除类型,在配置的执行世界中解析可执行文件与 bootstrap,通过 `ctx.sandbox` 包装 argv,并通过 `ctx.subprocess` 启动。接管继承的控制通道后,子进程在 OS 环境中只保留可执行文件搜索路径和 Windows 系统路径,并将程序可见的 `process.env` 替换为空字典。保留的原生路径使嵌套 Windows 进程创建仍可正常工作。显式 Node 参数避免继承 Host 的加载器或调试器标志。 带长度分帧的 JSON 与 stdout/stderr 分开传输。Host 限制帧与排队写入,在分派前验证调用身份和已声明的绑定名,并拒绝无效通信。输出捕获计量序列化日志加完成值或诊断;固定结果信封字段与沙箱元数据不计入该账本。 diff --git a/packages/code-runtime/code-runtime-node/src/environment.ts b/packages/code-runtime/code-runtime-node/src/environment.ts new file mode 100644 index 0000000000..590fd6f726 --- /dev/null +++ b/packages/code-runtime/code-runtime-node/src/environment.ts @@ -0,0 +1,4 @@ +/** Startup variables required by native executables before model evaluation. */ + +/** Native executable search and Windows system paths retained in the OS environment. */ +export const STARTUP_ENVIRONMENT_NAMES: ReadonlySet = new Set(['PATH', 'PATHEXT', 'SYSTEMROOT', 'WINDIR']) diff --git a/packages/code-runtime/code-runtime-node/src/index.ts b/packages/code-runtime/code-runtime-node/src/index.ts index c391ce8f7c..a38cbdad41 100644 --- a/packages/code-runtime/code-runtime-node/src/index.ts +++ b/packages/code-runtime/code-runtime-node/src/index.ts @@ -18,6 +18,7 @@ import { bootstrapArgs } from './launch.ts' import type { LaunchConfig } from './launch.ts' import { OutputLedger } from './output-ledger.ts' import { drainOutput } from './output-stream.ts' +import { STARTUP_ENVIRONMENT_NAMES } from './environment.ts' import { decodeCodeJsonWire, encodeCodeJsonWire } from './json-wire.ts' import type { ProgramBootData } from './protocol.ts' @@ -43,7 +44,6 @@ type ResolvedConfig = Required> & Pick } 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 { return typeof value === 'object' && value !== null && !Array.isArray(value) } @@ -213,9 +213,9 @@ 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 - // Native launchers need executable search and Windows system paths until the child clears its environment. + // 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) - .filter(key => !STARTUP_ENVIRONMENT.has(key.toUpperCase())) + .filter(key => !STARTUP_ENVIRONMENT_NAMES.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 }) diff --git a/packages/code-runtime/code-runtime-node/src/process.ts b/packages/code-runtime/code-runtime-node/src/process.ts index b9b81deb7a..2eec2dd6ea 100644 --- a/packages/code-runtime/code-runtime-node/src/process.ts +++ b/packages/code-runtime/code-runtime-node/src/process.ts @@ -2,6 +2,7 @@ import type { Duplex } from 'node:stream' import { JsonChannel } from './channel.ts' import { runProgram } from './bootstrap.ts' +import { STARTUP_ENVIRONMENT_NAMES } from './environment.ts' import type { PatchableStream } from './bootstrap.ts' import type { ReplyMessage, ProgramBootData, ProgramToHost } from './protocol.ts' @@ -22,7 +23,11 @@ export interface ProgramProcess { */ export async function runNodeMain(stream: Duplex, maxMessageBytes: number, processState: ProgramProcess): Promise { if (!Number.isSafeInteger(maxMessageBytes) || maxMessageBytes <= 0 || maxMessageBytes > 0xffff_ffff) throw new Error('invalid control message limit') - for (const key of Object.keys(processState.env)) Reflect.deleteProperty(processState.env, key) + for (const key of Object.keys(processState.env)) { + if (!STARTUP_ENVIRONMENT_NAMES.has(key.toUpperCase())) Reflect.deleteProperty(processState.env, key) + } + // Windows native process creation still needs SystemRoot in the OS environment. + processState.env = Object.create(null) as NodeJS.ProcessEnv const boot = Promise.withResolvers() const listeners: Array<(message: ReplyMessage) => void> = [] let started = false diff --git a/packages/code-runtime/code-runtime-node/tests/process-main.spec.ts b/packages/code-runtime/code-runtime-node/tests/process-main.spec.ts index d9f6efecba..da1044b0ec 100644 --- a/packages/code-runtime/code-runtime-node/tests/process-main.spec.ts +++ b/packages/code-runtime/code-runtime-node/tests/process-main.spec.ts @@ -20,6 +20,9 @@ function processState(): ProgramProcess { return { env: { FIXTURE_SECRET: 'test' it('clears process environment, dispatches a binding reply and flushes the terminal frame', async () => { const { child, host } = endpoints() const state = processState() + const nativeEnvironment = state.env + nativeEnvironment.SystemRoot = 'C:\\Windows' + nativeEnvironment.PATH = '/native/bin' const messages: Record[] = [] const peer = new JsonChannel(host, 4096, (raw) => { const message = raw as Record @@ -30,6 +33,9 @@ it('clears process environment, dispatches a binding reply and flushes the termi onTestFinished(() => { peer.close() }) await runNodeMain(child, 4096, state) expect(state.env).toEqual({}) + expect(state.env).not.toBe(nativeEnvironment) + expect(Object.getPrototypeOf(state.env)).toBeNull() + expect(nativeEnvironment).toEqual({ SystemRoot: 'C:\\Windows', PATH: '/native/bin' }) expect(state.exitCode).toBeUndefined() expect(decodeCodeJsonWire(messages.find(message => message.type === 'done')?.value)).toBe(42) }) diff --git a/packages/code-runtime/code-runtime-node/tests/process.spec.ts b/packages/code-runtime/code-runtime-node/tests/process.spec.ts index 99561cad36..d18ba98e11 100644 --- a/packages/code-runtime/code-runtime-node/tests/process.spec.ts +++ b/packages/code-runtime/code-runtime-node/tests/process.spec.ts @@ -11,7 +11,7 @@ import { decodeCodeJsonWire, encodeCodeJsonWire } from '../src/json-wire.ts' it('boots an unbuilt source closure outside the workspace and exchanges tool replies', async () => { const directory = await mkdtemp(join(tmpdir(), 'dsh-node-source-')) onTestFinished(async () => { await rm(directory, { recursive: true, force: true }) }) - for (const file of ['process.ts', 'bootstrap.ts', 'channel.ts', 'json-wire.ts', 'output-json.ts', 'protocol.ts']) { + for (const file of ['process.ts', 'bootstrap.ts', 'channel.ts', 'json-wire.ts', 'output-json.ts', 'protocol.ts', 'environment.ts']) { await copyFile(new URL(`../src/${file}`, import.meta.url), join(directory, file)) } const source = `import {Socket} from 'node:net';import {runNodeMain} from ${JSON.stringify(pathToFileURL(join(directory, 'process.ts')).href)};await runNodeMain(new Socket({fd:7,readable:true,writable:true}),100000,process);` diff --git a/packages/code-runtime/code-runtime-node/tests/runtime.spec.ts b/packages/code-runtime/code-runtime-node/tests/runtime.spec.ts index ad2b2f5d72..86cb7d5dec 100644 --- a/packages/code-runtime/code-runtime-node/tests/runtime.spec.ts +++ b/packages/code-runtime/code-runtime-node/tests/runtime.spec.ts @@ -33,6 +33,23 @@ describe('Node program process', () => { expect(result.sandbox).toEqual({ mode: 'danger-full-access', denied: false }) }) + it('keeps native startup paths available for nested Node creation with an empty model environment', async () => { + const { run } = await setup() + const result = await run({ + program: 'const {spawnSync}=await import("node:child_process"); const child=spawnSync(process.execPath,["-e","process.stdout.write(JSON.stringify(Object.keys(process.env)))"],{encoding:"utf8"}); return {env:Object.keys(process.env),status:child.status,error:child.error?.message ?? null,childKeys:JSON.parse(child.stdout || "[]")};', + bindings: [], + }) + expect(result.error).toBeUndefined() + const value = result.value as { env: string[]; status: number | null; error: string | null; childKeys: string[] } + expect(value.env).toEqual([]) + expect(value.status).toBe(0) + expect(value.error).toBeNull() + const nativeKeys = ['PATH', 'PATHEXT', 'SYSTEMROOT', 'WINDIR'] + // CoreFoundation initializes this entry independently when a macOS child starts. + if (process.platform === 'darwin') nativeKeys.push('__CF_USER_TEXT_ENCODING') + expect(value.childKeys.filter(key => !nativeKeys.includes(key.toUpperCase()))).toEqual([]) + }) + it('returns binding values and preserves typed binding rejection', async () => { const { run } = await setup() const result = await run({ @@ -180,7 +197,7 @@ 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) + const wrapped = 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)