Procházet zdrojové kódy

fix(ptc): preserve Electron Node mode when launching programs

07akioni před 2 dny
rodič
revize
a139e6e9e1

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-11-sandboxed-node-ptc-runtime.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # 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:
 # 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-sandboxed-node-ptc-runtime.md
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-11-sandboxed-node-ptc-runtime.md
-2026-09-11-sandboxed-node-ptc-runtime.md: 242b3cfedb49b7ab60c47c6ee03005ddb821bb33
-2026-09-11-sandboxed-node-ptc-runtime.zh.md: d1ab47550e49129ee3e1fefbdfa54cda397374a3
+2026-09-11-sandboxed-node-ptc-runtime.md: 4db1920abc188af0e27ad38d6d88ac04998d3844
+2026-09-11-sandboxed-node-ptc-runtime.zh.md: 6adce26536804f4c66e013689c2f807daa81a359

+ 2 - 0
.agents/notes/implemented/architecture/2026-09-11-sandboxed-node-ptc-runtime.md

@@ -14,6 +14,8 @@ The [PTC foundation](../feature/2026-06-15-ptc.md) remains responsible for regis
 
 
 `dsh-ptc-runtime-node` runs each program in one fresh Node process. The host resolves execution choices, confines the launch through the same `ctx.sandbox` provider as Bash, and gives process lifetime to `ctx.subprocess`. The child evaluates erasable TypeScript with direct Node APIs, an empty model environment and host-provided asynchronous bindings. No worker or persistent kernel remains inside this provider.
 `dsh-ptc-runtime-node` runs each program in one fresh Node process. The host resolves execution choices, confines the launch through the same `ctx.sandbox` provider as Bash, and gives process lifetime to `ctx.subprocess`. The child evaluates erasable TypeScript with direct Node APIs, an empty model environment and host-provided asynchronous bindings. No worker or persistent kernel remains inside this provider.
 
 
+The native startup allowlist preserves `ELECTRON_RUN_AS_NODE` through launch and child initialization while model-visible `process.env` stays empty. Desktop uses Electron as its Node executable; removing this selector launches Electron's application path instead of the PTC bootstrap. Sandbox permission changes cannot repair that launch mismatch. The macOS Desktop regression uses real Electron to verify binding writes, direct workspace writes, and rejection of writes outside the workspace under restricted policy. It requires an installed Electron binary; ordinary runtime tests cover environment filtering without that dependency.
+
 ### Resolved inputs and policy
 ### Resolved inputs and policy
 
 
 `PtcRuntime.resolve(request)` validates supported options and supplies a complete `PtcRunSpec`; `run(spec)` does not introduce defaults. PTC passes the calling Session's cwd and resolved standing policy. Direct runtime callers receive deployment defaults through the same resolver. The filesystem and subprocess providers share one execution world, and bootstrap paths cross through the filesystem's explicit host-file mapping or a configured preinstalled bootstrap.
 `PtcRuntime.resolve(request)` validates supported options and supplies a complete `PtcRunSpec`; `run(spec)` does not introduce defaults. PTC passes the calling Session's cwd and resolved standing policy. Direct runtime callers receive deployment defaults through the same resolver. The filesystem and subprocess providers share one execution world, and bootstrap paths cross through the filesystem's explicit host-file mapping or a configured preinstalled bootstrap.

+ 2 - 0
.agents/notes/implemented/architecture/2026-09-11-sandboxed-node-ptc-runtime.zh.md

@@ -14,6 +14,8 @@ Node worker 隔离 JavaScript 状态,但不应用调用 Session 的 OS 沙箱
 
 
 `dsh-ptc-runtime-node` 在一个全新 Node 进程中运行每个程序。Host 解析执行选择,通过与 Bash 相同的 `ctx.sandbox` 提供方约束启动,并将进程生命周期交给 `ctx.subprocess`。子进程以直接 Node API、空模型环境和 Host 提供的异步绑定求值可擦除 TypeScript。本提供方不保留 worker 或持久内核。
 `dsh-ptc-runtime-node` 在一个全新 Node 进程中运行每个程序。Host 解析执行选择,通过与 Bash 相同的 `ctx.sandbox` 提供方约束启动,并将进程生命周期交给 `ctx.subprocess`。子进程以直接 Node API、空模型环境和 Host 提供的异步绑定求值可擦除 TypeScript。本提供方不保留 worker 或持久内核。
 
 
+原生启动白名单在启动和子进程初始化期间保留 `ELECTRON_RUN_AS_NODE`,而模型可见的 `process.env` 仍为空。桌面端使用 Electron 作为 Node 可执行文件;删除此选择变量会启动 Electron 应用路径,而不是 PTC bootstrap。更改沙箱权限无法修复这一启动模式不匹配。macOS 桌面端回归测试使用真实 Electron 验证绑定写入、直接工作区写入以及受限策略对工作区外写入的拒绝。该测试需要已安装的 Electron 二进制文件;普通运行时测试无需此依赖即可覆盖环境过滤。
+
 ### 已解析输入与策略
 ### 已解析输入与策略
 
 
 `PtcRuntime.resolve(request)` 验证支持的选项并补全 `PtcRunSpec`;`run(spec)` 不引入默认值。PTC 传入调用 Session 的 cwd 与已解析常设策略。直接运行时调用方通过同一解析器取得部署默认值。文件系统与子进程提供方共享一个执行世界,bootstrap 路径通过文件系统的显式宿主文件映射或配置的预安装 bootstrap 传递。
 `PtcRuntime.resolve(request)` 验证支持的选项并补全 `PtcRunSpec`;`run(spec)` 不引入默认值。PTC 传入调用 Session 的 cwd 与已解析常设策略。直接运行时调用方通过同一解析器取得部署默认值。文件系统与子进程提供方共享一个执行世界,bootstrap 路径通过文件系统的显式宿主文件映射或配置的预安装 bootstrap 传递。

+ 43 - 0
apps/desktop/tests/ptc-runtime.spec.ts

@@ -0,0 +1,43 @@
+import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
+import { existsSync } from 'node:fs'
+import { createRequire } from 'node:module'
+import { homedir } from 'node:os'
+import { dirname, join } from 'node:path'
+import { Context } from '@deepseek-ai/cordis'
+import { expect, it, onTestFinished, vi } from 'vitest'
+import { mountRuntime } from '../../../packages/ptc-runtime/ptc-runtime-node/tests/setup.ts'
+
+const require = createRequire(import.meta.url)
+const electronInstalled = existsSync(join(dirname(require.resolve('electron')), 'path.txt'))
+
+// Desktop's downloaded Electron binary is not installed by ordinary workspace dependency setup.
+it.skipIf(process.platform !== 'darwin' || !electronInstalled).each(['workspace-write', 'danger-full-access'] as const)('runs PTC writes under Electron with %s', async (mode) => {
+  const electron = require('electron') as string
+  const root = await mkdtemp(join(homedir(), '.dsh-electron-ptc-'))
+  const ctx = new Context()
+  onTestFinished(async () => {
+    try { await ctx.fiber.dispose() } finally {
+      vi.unstubAllEnvs()
+      await rm(root, { recursive: true, force: true })
+    }
+  })
+  const cwd = join(root, 'workspace')
+  await mkdir(cwd)
+  vi.stubEnv('ELECTRON_RUN_AS_NODE', '1')
+  vi.stubEnv('DSH_TEST_RUNTIME_SECRET', 'must-not-inherit')
+  const runtime = await mountRuntime(ctx, { nodeExecutable: electron }, { mode, workspaceRoot: cwd })
+  const result = await runtime.run(runtime.resolve({
+    program: `await tools.write({});
+      const fs = await import('node:fs/promises');
+      await fs.writeFile('direct.txt', 'direct');
+      let outside;
+      try { await fs.writeFile('../outside.txt', 'outside'); outside = true } catch (error) { if (error.code !== 'EPERM' && error.code !== 'EACCES') throw error; outside = false }
+      return { electron: Boolean(process.versions.electron), env: { ...process.env }, outside };`,
+    bindings: [{ global: 'tools', functions: { write: async () => { await writeFile(join(cwd, 'note.txt'), 'hello'); return null } } }],
+  }))
+  expect(result.error).toBeUndefined()
+  expect(result.value).toEqual({ electron: true, env: {}, outside: mode === 'danger-full-access' })
+  expect(await readFile(join(cwd, 'note.txt'), 'utf8')).toBe('hello')
+  expect(await readFile(join(cwd, 'direct.txt'), 'utf8')).toBe('direct')
+  expect(existsSync(join(root, 'outside.txt'))).toBe(mode === 'danger-full-access')
+})

+ 2 - 2
packages/ptc-runtime/ptc-runtime-node/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # 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:
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/ptc-runtime/ptc-runtime-node/README.md
 #   pnpm run verify-translation-pairing --write packages/ptc-runtime/ptc-runtime-node/README.md
-README.md: f8f7b5535ba8ed1594b898d410df04e24f5dc8ef
-README.zh.md: dd4e33e70f05ae3917f128e33bf26af5d1a915c4
+README.md: b8047c5ec359bf0e7b55bc484c097dcdf14f137e
+README.zh.md: d2d56a2c4b9798a1eb3296ada20d87cad2ba1f1b

+ 1 - 1
packages/ptc-runtime/ptc-runtime-node/README.md

@@ -85,7 +85,7 @@ The host owns policy, deadlines, binding lookup and process cleanup. The child o
 
 
 ### Launch and control
 ### Launch and control
 
 
-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.
+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 plus Electron's `ELECTRON_RUN_AS_NODE` selector 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 host also preserves the Electron selector when launching the child so the Desktop executable runs the Node bootstrap. 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. The child flushes its terminal frame and keeps the control channel open until the host closes it. After submitting that frame, it ignores later binding replies and sends no further program control messages. Output capture meters serialized logs plus the completion or diagnostic; fixed result-envelope fields and sandbox metadata are outside that ledger.
 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. The child flushes its terminal frame and keeps the control channel open until the host closes it. After submitting that frame, it ignores later binding replies and sends no further program control messages. Output capture meters serialized logs plus the completion or diagnostic; fixed result-envelope fields and sandbox metadata are outside that ledger.
 
 

+ 1 - 1
packages/ptc-runtime/ptc-runtime-node/README.zh.md

@@ -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 系统路径、临时路径以及 Electron 的 `ELECTRON_RUN_AS_NODE` 选择变量,并将程序可见的 `process.env` 替换为空字典。Windows ACL 初始化接收父进程各自的 `TEMP` 和 `TMP` 值以使用共享授权锁,然后在启动程序前将二者替换为私有目录。这些原生路径使嵌套进程创建和原生临时文件 API 仍可正常工作。Host 启动子进程时也保留 Electron 选择变量,使桌面端可执行文件运行 Node bootstrap。堆上限通过 Node argv 或为打包可执行文件由提供方构造的 `NODE_OPTIONS` 值传递;环境中的加载器和调试器标志会被丢弃。
 
 
 带长度分帧的 JSON 与 stdout/stderr 分开传输。Host 限制帧与排队写入,在分派前验证调用身份和已声明的绑定名,并拒绝无效通信。子进程刷新终态帧后仍保持控制通道打开,直到 Host 关闭通道。提交终态帧后,子进程忽略后续绑定回复,不再发送程序控制消息。输出捕获计量序列化日志加完成值或诊断;固定结果信封字段与沙箱元数据不计入该账本。
 带长度分帧的 JSON 与 stdout/stderr 分开传输。Host 限制帧与排队写入,在分派前验证调用身份和已声明的绑定名,并拒绝无效通信。子进程刷新终态帧后仍保持控制通道打开,直到 Host 关闭通道。提交终态帧后,子进程忽略后续绑定回复,不再发送程序控制消息。输出捕获计量序列化日志加完成值或诊断;固定结果信封字段与沙箱元数据不计入该账本。
 
 

+ 2 - 2
packages/ptc-runtime/ptc-runtime-node/src/environment.ts

@@ -1,4 +1,4 @@
 /** Startup variables required by native executables before model evaluation. */
 /** Startup variables required by native executables before model evaluation. */
 
 
-/** Native executable search, Windows system paths, and sandbox temporary paths retained in the OS environment. */
-export const STARTUP_ENVIRONMENT_NAMES: ReadonlySet<string> = new Set(['PATH', 'PATHEXT', 'SYSTEMROOT', 'WINDIR', 'TEMP', 'TMP'])
+/** Native paths and Electron's Node-mode selector retained in the OS environment. */
+export const STARTUP_ENVIRONMENT_NAMES: ReadonlySet<string> = new Set(['PATH', 'PATHEXT', 'SYSTEMROOT', 'WINDIR', 'TEMP', 'TMP', 'ELECTRON_RUN_AS_NODE'])

+ 2 - 0
packages/ptc-runtime/ptc-runtime-node/tests/host-failures.spec.ts

@@ -484,12 +484,14 @@ describe('Node runtime host failures', () => {
     onTestFinished(() => { vi.unstubAllEnvs() })
     onTestFinished(() => { vi.unstubAllEnvs() })
     vi.stubEnv('TEMP', 'fixture-temp-first')
     vi.stubEnv('TEMP', 'fixture-temp-first')
     vi.stubEnv('TMP', 'fixture-tmp-second')
     vi.stubEnv('TMP', 'fixture-tmp-second')
+    vi.stubEnv('ELECTRON_RUN_AS_NODE', '1')
     vi.stubEnv('DSH_TEST_RUNTIME_SECRET', 'must-not-inherit')
     vi.stubEnv('DSH_TEST_RUNTIME_SECRET', 'must-not-inherit')
     h.onBoot(() => { h.emit({ type: 'done' }) })
     h.onBoot(() => { h.emit({ type: 'done' }) })
     expect((await h.start()).error).toBeUndefined()
     expect((await h.start()).error).toBeUndefined()
     const env = h.spawn.mock.calls[0]?.[0].env ?? {}
     const env = h.spawn.mock.calls[0]?.[0].env ?? {}
     expect(Object.hasOwn(env, 'TEMP')).toBe(false)
     expect(Object.hasOwn(env, 'TEMP')).toBe(false)
     expect(Object.hasOwn(env, 'TMP')).toBe(false)
     expect(Object.hasOwn(env, 'TMP')).toBe(false)
+    expect(Object.hasOwn(env, 'ELECTRON_RUN_AS_NODE')).toBe(false)
     expect(Object.hasOwn(env, 'DSH_TEST_RUNTIME_SECRET')).toBe(true)
     expect(Object.hasOwn(env, 'DSH_TEST_RUNTIME_SECRET')).toBe(true)
     expect(env.DSH_TEST_RUNTIME_SECRET).toBeUndefined()
     expect(env.DSH_TEST_RUNTIME_SECRET).toBeUndefined()
   })
   })

+ 1 - 1
packages/ptc-runtime/ptc-runtime-node/tests/runtime.spec.ts

@@ -59,7 +59,7 @@ describe('Node program process', () => {
     expect(value.env).toEqual([])
     expect(value.env).toEqual([])
     expect(value.status).toBe(0)
     expect(value.status).toBe(0)
     expect(value.error).toBeNull()
     expect(value.error).toBeNull()
-    const nativeKeys = ['PATH', 'PATHEXT', 'SYSTEMROOT', 'WINDIR', 'TEMP', 'TMP']
+    const nativeKeys = ['PATH', 'PATHEXT', 'SYSTEMROOT', 'WINDIR', 'TEMP', 'TMP', 'ELECTRON_RUN_AS_NODE']
     // CoreFoundation initializes this entry independently when a macOS child starts.
     // CoreFoundation initializes this entry independently when a macOS child starts.
     if (process.platform === 'darwin') nativeKeys.push('__CF_USER_TEXT_ENCODING')
     if (process.platform === 'darwin') nativeKeys.push('__CF_USER_TEXT_ENCODING')
     expect(value.childKeys.filter(key => !nativeKeys.includes(key.toUpperCase()))).toEqual([])
     expect(value.childKeys.filter(key => !nativeKeys.includes(key.toUpperCase()))).toEqual([])

+ 2 - 0
snapshots/session/ptc-node-workspace/snapshot.yml

@@ -10,6 +10,8 @@ replay:
   override: true
   override: true
 platform: posix
 platform: posix
 permission: workspace-write
 permission: workspace-write
+environment:
+  ELECTRON_RUN_AS_NODE: '1'
 workspace:
 workspace:
   final: true
   final: true
   parent: outside-temp
   parent: outside-temp