diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-code-runtime-python-settlement-fixes.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-31-code-runtime-python-settlement-fixes.i18n.yaml index 05ed50f637..34a16685a4 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-code-runtime-python-settlement-fixes.i18n.yaml +++ b/.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 diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-code-runtime-python-settlement-fixes.md b/.agents/notes/implemented/bug-fix/2026-07-31-code-runtime-python-settlement-fixes.md index 4e76c78e96..26a5947b66 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-code-runtime-python-settlement-fixes.md +++ b/.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 diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-code-runtime-python-settlement-fixes.zh.md b/.agents/notes/implemented/bug-fix/2026-07-31-code-runtime-python-settlement-fixes.zh.md index 6f0bc792dd..f2e7955a40 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-code-runtime-python-settlement-fixes.zh.md +++ b/.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 diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index de2624fd29..60ae2fd7ce 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: 45a246f9baf126333aabe6fd158ede0100ee1508 -config-catalog.zh.md: 5011b6e1f7be6d8e90c6eb968c993599e7eed28e +config-catalog.md: 0e936de187dfe17c763f2b98fc88504ef857fb8f +config-catalog.zh.md: a316fad83c62d70cfca76275ff35fb33b8f13a96 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 45a246f9ba..0e936de187 100644 --- a/docs/config-catalog.md +++ b/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) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 5011b6e1f7..a316fad83c 100644 --- a/docs/config-catalog.zh.md +++ b/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) diff --git a/packages/experimental/code-runtime-python/README.i18n.yaml b/packages/experimental/code-runtime-python/README.i18n.yaml index d5ca0fced2..7308573210 100644 --- a/packages/experimental/code-runtime-python/README.i18n.yaml +++ b/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 diff --git a/packages/experimental/code-runtime-python/README.md b/packages/experimental/code-runtime-python/README.md index 8b596ec5e8..117daeac38 100644 --- a/packages/experimental/code-runtime-python/README.md +++ b/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 diff --git a/packages/experimental/code-runtime-python/README.zh.md b/packages/experimental/code-runtime-python/README.zh.md index a0035beec4..0727c705a8 100644 --- a/packages/experimental/code-runtime-python/README.zh.md +++ b/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 diff --git a/packages/experimental/code-runtime-python/src/index.ts b/packages/experimental/code-runtime-python/src/index.ts index acf0ea6aae..9826b6b1d3 100644 --- a/packages/experimental/code-runtime-python/src/index.ts +++ b/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) { diff --git a/packages/experimental/code-runtime-python/tests/boot-write-failure.spec.ts b/packages/experimental/code-runtime-python/tests/boot-write-failure.spec.ts index f5499b9a37..759d0a440d 100644 --- a/packages/experimental/code-runtime-python/tests/boot-write-failure.spec.ts +++ b/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()), - spawn: spawnMock, -})) +const { execFileSyncMock, spawnMock } = vi.hoisted(() => ({ execFileSyncMock: vi.fn(), spawnMock: vi.fn() })) +vi.mock('node:child_process', async (importOriginal) => { + const original = await importOriginal() + 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`,