Преглед изворни кода

fix(code-runtime): separate model environment from native startup paths

Tianyi Cui пре 1 месец
родитељ
комит
98d3af0683

+ 2 - 2
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

+ 1 - 1
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)
 
 <a id="deepseek-aidsh-compaction-basic"></a>
 

+ 1 - 1
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)
 
 <a id="deepseek-aidsh-compaction-basic"></a>
 

+ 2 - 2
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

+ 1 - 1
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.
 

+ 1 - 1
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 限制帧与排队写入,在分派前验证调用身份和已声明的绑定名,并拒绝无效通信。输出捕获计量序列化日志加完成值或诊断;固定结果信封字段与沙箱元数据不计入该账本。
 

+ 4 - 0
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<string> = new Set(['PATH', 'PATHEXT', 'SYSTEMROOT', 'WINDIR'])

+ 3 - 3
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<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,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 })

+ 6 - 1
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<void> {
   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<ProgramBootData>()
   const listeners: Array<(message: ReplyMessage) => void> = []
   let started = false

+ 6 - 0
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<string, unknown>[] = []
   const peer = new JsonChannel(host, 4096, (raw) => {
     const message = raw as Record<string, unknown>
@@ -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)
 })

+ 1 - 1
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);`

+ 18 - 1
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)