Procházet zdrojové kódy

fix(code-runtime): bound interpreter version probe

Tianyi Cui před 1 měsícem
rodič
revize
d6bd5eb973

+ 2 - 2
.agents/notes/implemented/bug-fix/2026-07-31-code-runtime-python-settlement-fixes.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 .agents/notes/implemented/bug-fix/2026-07-31-code-runtime-python-settlement-fixes.md
-2026-07-31-code-runtime-python-settlement-fixes.md: 4e76c78e964608822ca5bed68870ee3f1df38911
-2026-07-31-code-runtime-python-settlement-fixes.zh.md: 6f0bc792ddb19e66f4918c8d8499ddf2846fed58
+2026-07-31-code-runtime-python-settlement-fixes.md: 26a5947b6602d56dc291f2f2e692743522580b12
+2026-07-31-code-runtime-python-settlement-fixes.zh.md: f2e7955a40b00f5b08017f44d2cf0529b2b31bfb

+ 1 - 1
.agents/notes/implemented/bug-fix/2026-07-31-code-runtime-python-settlement-fixes.md

@@ -70,7 +70,7 @@ Also in `src/index.ts`, `spawn` is called before the settlement Promise executor
 
 ### Interpreter selection and the child environment settle at load
 
-`pythonBin` resolves once at plugin load to an executable absolute path and is version-probed under the same scrubbed environment used for runs. The provider requires CPython 3.10 or newer and retains that exact path, so a later `PATH` or working-directory change cannot switch interpreters; an explicit path that is not an executable regular file, an unresolved basename, or an unsupported interpreter fails before `ctx.codeRuntime` registers. Each probe and run receives only `TMPDIR`: macOS system Python needs it to avoid emitting a startup warning into captured stderr, while credentials, `PATH`, `HOME`, and every other ambient host value remain unavailable to model code. If the validated executable disappears after activation, the ordinary spawn settlement still resolves `worker-exit`.
+`pythonBin` resolves once at plugin load to an executable absolute path and is version-probed under the same scrubbed environment used for runs. The provider requires CPython 3.10 or newer and retains that exact path, so a later `PATH` or working-directory change cannot switch interpreters; an explicit path that is not an executable regular file, an unresolved basename, or an unsupported interpreter fails before `ctx.codeRuntime` registers. The synchronous probe has a fixed five-second deadline and sends `SIGKILL` at that deadline, so a wrapper that ignores `SIGTERM` cannot block plugin load. Each probe and run receives only `TMPDIR`: macOS system Python needs it to avoid emitting a startup warning into captured stderr, while credentials, `PATH`, `HOME`, and every other ambient host value remain unavailable to model code. If the validated executable disappears after activation, the ordinary spawn settlement still resolves `worker-exit`.
 
 ### Stray pipe output is aggregated by line, not by transport chunk
 

+ 1 - 1
.agents/notes/implemented/bug-fix/2026-07-31-code-runtime-python-settlement-fixes.zh.md

@@ -70,7 +70,7 @@ unknown-binding 回复用 `JSON.stringify` 对完整的限幅 target(`global`
 
 ### 解释器选择与子进程环境在加载期固定
 
-`pythonBin` 在插件加载期解析为一个可执行绝对路径,并在与运行时相同的受限环境中完成版本探测。提供方要求 CPython 3.10 或更高版本并保留该确切路径,因此后续 `PATH` 或工作目录变化不能切换解释器;不是可执行普通文件的显式路径、无法解析的裸名或不受支持的解释器都会在 `ctx.codeRuntime` 注册前失败。每次探测与运行只接收 `TMPDIR`:macOS 系统 Python 需要它来避免向被捕获的 stderr 发出启动警告,而凭证、`PATH`、`HOME` 与其他宿主环境值均不会进入模型代码。若已校验的可执行文件在激活后消失,普通 spawn 结算仍 resolve 为 `worker-exit`。
+`pythonBin` 在插件加载期解析为一个可执行绝对路径,并在与运行时相同的受限环境中完成版本探测。提供方要求 CPython 3.10 或更高版本并保留该确切路径,因此后续 `PATH` 或工作目录变化不能切换解释器;不是可执行普通文件的显式路径、无法解析的裸名或不受支持的解释器都会在 `ctx.codeRuntime` 注册前失败。同步探测有固定的五秒期限,并在期限到达时发送 `SIGKILL`,因此忽略 `SIGTERM` 的包装脚本不能阻塞插件加载。每次探测与运行只接收 `TMPDIR`:macOS 系统 Python 需要它来避免向被捕获的 stderr 发出启动警告,而凭证、`PATH`、`HOME` 与其他宿主环境值均不会进入模型代码。若已校验的可执行文件在激活后消失,普通 spawn 结算仍 resolve 为 `worker-exit`。
 
 ### Stray pipe output is aggregated by line, not by transport chunk
 

+ 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: 45a246f9baf126333aabe6fd158ede0100ee1508
-config-catalog.zh.md: 5011b6e1f7be6d8e90c6eb968c993599e7eed28e
+config-catalog.md: 0e936de187dfe17c763f2b98fc88504ef857fb8f
+config-catalog.zh.md: a316fad83c62d70cfca76275ff35fb33b8f13a96

+ 3 - 2
docs/config-catalog.md

@@ -588,13 +588,14 @@ export interface Config {
   graceMs?: number
   /**
    * Absolute path, relative path, or basename of a CPython 3.10+ interpreter.
-   * Resolved and validated once at plugin load; a basename searches `PATH`.
+   * Resolved and validated once at plugin load under a five-second force-kill
+   * deadline; a basename searches `PATH`.
    */
   pythonBin?: string
 }
 ```
 
-Source: [`packages/experimental/code-runtime-python/src/index.ts:44`](../packages/experimental/code-runtime-python/src/index.ts)
+Source: [`packages/experimental/code-runtime-python/src/index.ts:43`](../packages/experimental/code-runtime-python/src/index.ts)
 
 <a id="deepseek-aidsh-experimental-inspector"></a>
 

+ 3 - 2
docs/config-catalog.zh.md

@@ -590,13 +590,14 @@ export interface Config {
   graceMs?: number
   /**
    * Absolute path, relative path, or basename of a CPython 3.10+ interpreter.
-   * Resolved and validated once at plugin load; a basename searches `PATH`.
+   * Resolved and validated once at plugin load under a five-second force-kill
+   * deadline; a basename searches `PATH`.
    */
   pythonBin?: string
 }
 ```
 
-来源:[`packages/experimental/code-runtime-python/src/index.ts:44`](../packages/experimental/code-runtime-python/src/index.ts)
+来源:[`packages/experimental/code-runtime-python/src/index.ts:43`](../packages/experimental/code-runtime-python/src/index.ts)
 
 <a id="deepseek-aidsh-experimental-inspector"></a>
 

+ 2 - 2
packages/experimental/code-runtime-python/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/experimental/code-runtime-python/README.md
-README.md: 8b596ec5e8bcb7a0d458fe11e61c3742b6efc823
-README.zh.md: a0035beec49a531d7ff37363921ecefa869877a6
+README.md: 117daeac38c329521e5334b41106b71c629a0efc
+README.zh.md: 0727c705a8eb024f068804c0a1af6e81c2e02d6b

+ 1 - 1
packages/experimental/code-runtime-python/README.md

@@ -29,7 +29,7 @@ Choose this private experimental package only in an explicit source-checkout com
 
 ### What you get
 
-The package's default export is the `PythonCodeRuntime` plugin. Its public surface also re-exports the host-side protocol vocabulary: `validateChildFrame` (rebuilds every inbound frame), the lossless-JSON codec and meters (`encodeJsonPlain`, `checkDoneValue`, `hasUnsafeIntegerToken`, `hasNonLosslessNumber`), `logTruncationMarker` (the shared truncation-marker text), plus `resolvePythonBin` (interpreter lookup against the current `PATH`), `readProcessStart` (process-start statistics for tests), and `detachResidual` (a test seam for the settled run's resource cleanup). Every cap is a validated `Config` field with a default: `cpuSeconds` (60), `maxWallMs` (600000), `addressSpaceMb` (512, not applied on Darwin), `maxLogBytes` (65536), `maxValueBytes` (32768), `graceMs` (3000), and `pythonBin` (`python3`, resolved, executable-checked, version-probed, and frozen at load). Each child receives only `TMPDIR`; ambient credentials, `PATH`, `HOME`, and other host state stay unavailable.
+The package's default export is the `PythonCodeRuntime` plugin. Its public surface also re-exports the host-side protocol vocabulary: `validateChildFrame` (rebuilds every inbound frame), the lossless-JSON codec and meters (`encodeJsonPlain`, `checkDoneValue`, `hasUnsafeIntegerToken`, `hasNonLosslessNumber`), `logTruncationMarker` (the shared truncation-marker text), plus `resolvePythonBin` (interpreter lookup against the current `PATH`), `readProcessStart` (process-start statistics for tests), and `detachResidual` (a test seam for the settled run's resource cleanup). Every cap is a validated `Config` field with a default: `cpuSeconds` (60), `maxWallMs` (600000), `addressSpaceMb` (512, not applied on Darwin), `maxLogBytes` (65536), `maxValueBytes` (32768), `graceMs` (3000), and `pythonBin` (`python3`, resolved, executable-checked, version-probed under a five-second force-kill deadline, and frozen at load). Each child receives only `TMPDIR`; ambient credentials, `PATH`, `HOME`, and other host state stay unavailable.
 
 ### The wire
 

+ 1 - 1
packages/experimental/code-runtime-python/README.zh.md

@@ -29,7 +29,7 @@ kind: "package-reference"
 
 ### 你得到什么
 
-包的默认导出是 `PythonCodeRuntime` 插件。其公开面还重新导出宿主侧协议词汇:`validateChildFrame`(重建每条入站帧)、无损 JSON codec 与计量器(`encodeJsonPlain`、`checkDoneValue`、`hasUnsafeIntegerToken`、`hasNonLosslessNumber`)、`logTruncationMarker`(共享截断标记文本),以及 `resolvePythonBin`(对照当前 `PATH` 的解释器查找)、`readProcessStart`(供测试用的进程启动统计)和 `detachResidual`(已结算运行的资源清理测试 seam)。每个上限都是带默认值并经校验的 `Config` 字段:`cpuSeconds`(60)、`maxWallMs`(600000)、`addressSpaceMb`(512,Darwin 上不生效)、`maxLogBytes`(65536)、`maxValueBytes`(32768)、`graceMs`(3000)与 `pythonBin`(`python3`,在加载期解析、检查可执行性、探测版本并固定)。每个子进程只接收 `TMPDIR`;环境中的凭证、`PATH`、`HOME` 与其他宿主状态均不可见。
+包的默认导出是 `PythonCodeRuntime` 插件。其公开面还重新导出宿主侧协议词汇:`validateChildFrame`(重建每条入站帧)、无损 JSON codec 与计量器(`encodeJsonPlain`、`checkDoneValue`、`hasUnsafeIntegerToken`、`hasNonLosslessNumber`)、`logTruncationMarker`(共享截断标记文本),以及 `resolvePythonBin`(对照当前 `PATH` 的解释器查找)、`readProcessStart`(供测试用的进程启动统计)和 `detachResidual`(已结算运行的资源清理测试 seam)。每个上限都是带默认值并经校验的 `Config` 字段:`cpuSeconds`(60)、`maxWallMs`(600000)、`addressSpaceMb`(512,Darwin 上不生效)、`maxLogBytes`(65536)、`maxValueBytes`(32768)、`graceMs`(3000)与 `pythonBin`(`python3`,在加载期解析、检查可执行性,在五秒强制终止期限内探测版本并固定)。每个子进程只接收 `TMPDIR`;环境中的凭证、`PATH`、`HOME` 与其他宿主状态均不可见。
 
 ### wire
 

+ 7 - 1
packages/experimental/code-runtime-python/src/index.ts

@@ -85,7 +85,8 @@ export interface Config {
   graceMs?: number
   /**
    * Absolute path, relative path, or basename of a CPython 3.10+ interpreter.
-   * Resolved and validated once at plugin load; a basename searches `PATH`.
+   * Resolved and validated once at plugin load under a five-second force-kill
+   * deadline; a basename searches `PATH`.
    */
   pythonBin?: string
 }
@@ -413,6 +414,8 @@ export function resolvePythonBin(bin: string): string | undefined {
       accessSync(candidate, fsConstants.X_OK)
       return statSync(candidate).isFile() ? candidate : undefined
     } catch {
+      // Missing, inaccessible, and non-stat-able candidates are ordinary
+      // lookup misses; the constructor reports the final load error.
       return undefined
     }
   }
@@ -457,6 +460,9 @@ function validatePythonBin(bin: string): void {
       encoding: 'utf8',
       env: pythonEnvironment(),
       timeout: PYTHON_PROBE_TIMEOUT_MS,
+      // The configured executable is outside our control. Force-kill it at the
+      // deadline so a wrapper that ignores SIGTERM cannot block plugin load.
+      killSignal: 'SIGKILL',
       maxBuffer: 1_024,
     }).trim()
   } catch (error: unknown) {

+ 19 - 5
packages/experimental/code-runtime-python/tests/boot-write-failure.spec.ts

@@ -13,11 +13,12 @@ import { Context } from 'cordis'
  * which is exactly the branch that regressed. The mock is confined to this file
  * so the real-subprocess suite in runtime.spec.ts is untouched.
  */
-const { spawnMock } = vi.hoisted(() => ({ spawnMock: vi.fn() }))
-vi.mock('node:child_process', async importOriginal => ({
-  ...(await importOriginal<typeof import('node:child_process')>()),
-  spawn: spawnMock,
-}))
+const { execFileSyncMock, spawnMock } = vi.hoisted(() => ({ execFileSyncMock: vi.fn(), spawnMock: vi.fn() }))
+vi.mock('node:child_process', async (importOriginal) => {
+  const original = await importOriginal<typeof import('node:child_process')>()
+  execFileSyncMock.mockImplementation(original.execFileSync)
+  return { ...original, execFileSync: execFileSyncMock, spawn: spawnMock }
+})
 
 const { PythonCodeRuntime } = await import('../src/index.ts')
 
@@ -44,6 +45,7 @@ function fakeChildWithThrowingFd3(): EventEmitter {
 }
 
 afterEach(() => {
+  execFileSyncMock.mockClear()
   spawnMock.mockReset()
 })
 
@@ -127,6 +129,18 @@ function fakeChildBackpressuredThenDestroyed(): { child: EventEmitter; proto: Pa
 }
 
 describe('PythonCodeRuntime — boot-write failure', () => {
+  it('force-kills a version probe that exceeds its load-time deadline', async () => {
+    const ctx = new Context()
+    const fiber = await ctx.plugin(PythonCodeRuntime)
+
+    expect(execFileSyncMock).toHaveBeenCalledWith(
+      expect.any(String),
+      expect.arrayContaining(['-I', '-c']),
+      expect.objectContaining({ timeout: 5_000, killSignal: 'SIGKILL' }),
+    )
+    await fiber.dispose()
+  })
+
   it('resolves a worker-exit when the fd-3 boot write throws (no TDZ ReferenceError)', async () => {
     // Before the fix, the boot-write block ran BEFORE `wallTimer`, `onAbort`,
     // and `live` were initialized, so its `finish()` (which clears `wallTimer`,