From e0f22aeaad680f0f96c49821a39c851e4e969ed7 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Fri, 31 Jul 2026 18:51:03 +0800 Subject: [PATCH 01/32] feat(code-runtime-python): add the fd-3 frame protocol Introduce @deepseek-ai/dsh-code-runtime-python with the versionless JSON-lines protocol between the Node host and the CPython subprocess: the host-side hostile-frame codec (validateChildFrame, encodeJsonPlain, checkDoneValue, hasUnsafeIntegerToken, hasNonLosslessNumber, logTruncationMarker) and the Python-side wire-vocabulary mirror (py/protocol.py). This is the protocol layer of the code-runtime-python stack, split from #436 and based on the multi-language seam extension. The PythonCodeRuntime implementation and its Python JSON codec land in the backend-core PR on top of this branch. Ship the minimal buildable package skeleton (package.json, tsconfig, tsdown, barrel index, invariant companion, bilingual README) because the workspace-constraint, coverage, and invariant-topology gates require the package to exist and build the moment its directory does; the backend-core PR extends those files rather than creating them. Align py/protocol.py with src/protocol.ts (the round-12 review of #436 found LogMessage.truncated, DoneMessage.error.kind, and Namespace.errorClass stale) and guard the two runtime-executed surfaces (PROTOCOL_FD and the log truncation marker) with a real-python3 cross-language mirror e2e test. --- ...code-runtime-python-fd3-protocol.i18n.yaml | 6 + ...-07-31-code-runtime-python-fd3-protocol.md | 43 ++ ...-31-code-runtime-python-fd3-protocol.zh.md | 43 ++ docs/config-catalog.md | 1 + docs/module-graph.md | 4 + knip.json | 10 + .../code-runtime-python/README.i18n.yaml | 6 + .../code-runtime-python/README.md | 24 + .../code-runtime-python/README.zh.md | 24 + .../code-runtime-python/package.json | 39 ++ .../code-runtime-python/py/protocol.py | 126 ++++++ .../code-runtime-python/src/index.ts | 20 + .../code-runtime-python/src/invariant.ts | 30 ++ .../code-runtime-python/src/protocol.ts | 420 ++++++++++++++++++ .../tests/protocol-mirror.e2e.ts | 60 +++ .../tests/protocol.spec.ts | 239 ++++++++++ .../code-runtime-python/tsconfig.json | 21 + .../code-runtime-python/tsdown.config.ts | 16 + pnpm-lock.yaml | 12 + scripts/check-workspace-constraints.ts | 2 + .../verify-package-readme-model-experience.ts | 1 + tsconfig.host.json | 1 + 22 files changed, 1148 insertions(+) create mode 100644 .agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.md create mode 100644 .agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.zh.md create mode 100644 packages/code-runtime/code-runtime-python/README.i18n.yaml create mode 100644 packages/code-runtime/code-runtime-python/README.md create mode 100644 packages/code-runtime/code-runtime-python/README.zh.md create mode 100644 packages/code-runtime/code-runtime-python/package.json create mode 100644 packages/code-runtime/code-runtime-python/py/protocol.py create mode 100644 packages/code-runtime/code-runtime-python/src/index.ts create mode 100644 packages/code-runtime/code-runtime-python/src/invariant.ts create mode 100644 packages/code-runtime/code-runtime-python/src/protocol.ts create mode 100644 packages/code-runtime/code-runtime-python/tests/protocol-mirror.e2e.ts create mode 100644 packages/code-runtime/code-runtime-python/tests/protocol.spec.ts create mode 100644 packages/code-runtime/code-runtime-python/tsconfig.json create mode 100644 packages/code-runtime/code-runtime-python/tsdown.config.ts diff --git a/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.i18n.yaml new file mode 100644 index 0000000000..bd811f506e --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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/architecture/2026-07-31-code-runtime-python-fd3-protocol.md +2026-07-31-code-runtime-python-fd3-protocol.md: 32cc80278af6b5f894c8d972854dae8c92ac63b7 +2026-07-31-code-runtime-python-fd3-protocol.zh.md: e7cf551b1dc84656c1eaf49280052c732839942b diff --git a/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.md b/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.md new file mode 100644 index 0000000000..32cc80278a --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.md @@ -0,0 +1,43 @@ +# Agent Note: the code-runtime-python fd-3 frame protocol + +Status: implemented + +English | [中文](2026-07-31-code-runtime-python-fd3-protocol.zh.md) + +## Problem + +The CPython code-runtime backend (`@deepseek-ai/dsh-code-runtime-python`, arriving across a PR stack) runs each model program in a fresh `python3 -I` subprocess and bridges binding calls and completion values over the child's fd 3. That channel needs a wire protocol both sides agree on, and the host cannot trust it: model code has full access to fd 3 and can forge any frame, so every inbound frame is hostile input the host must validate and rebuild before reading. The protocol also has to carry lossless JSON without the depth limit `JSON.stringify`/`json.dumps` impose, because the seam's `CodeJsonValue` is depth-unbounded. + +This layer of the stack delivers only that protocol, so the large `PythonCodeRuntime` implementation and its real-subprocess integration suite land on a reviewed wire contract instead of arriving fused with it. The parent stack splits [#436](https://github.com/deepseek-harness/deepseek-harness/pull/436) — a 9000-line single PR — into reviewable layers; this is the protocol layer, based on the [seam extension](2026-07-31-code-runtime-portable-identifier-seam.md). + +## Decision + +`src/protocol.ts` is the host side of the wire vocabulary and its hostile-frame codec: + +- **`validateChildFrame`** shape-validates and REBUILDS every inbound frame. The compile-time union means nothing on fd 3 — a forged frame can carry `null`, poisoned fields, or omit required ones — so each accepted frame is reconstructed field by field: forged extras never ride along, a non-finite call id can never be echoed into a reply, and junk returns `undefined` to be dropped rather than throwing in the host's message handler. +- **`encodeJsonPlain` / `checkDoneValue` / `hasUnsafeIntegerToken` / `hasNonLosslessNumber`** are the lossless-JSON codec and meters. They traverse iteratively (an explicit stack, not recursion) so a deep value below the byte budget crosses intact; `checkDoneValue` folds byte-metering and number-losslessness into one bounded walk that rejects an over-budget payload BEFORE enqueuing its children, keeping a forged below-frame-ceiling value from forcing a hundreds-of-megabytes host allocation. Beyond-safe-range integral doubles serialize through `BigInt` digits so the exact integer crosses, not `String()`'s rounded form. +- **`logTruncationMarker`** produces the in-band marker text a log ledger emits when it exhausts its byte budget. + +`py/protocol.py` mirrors the message shapes as `TypedDict`s and re-declares the two surfaces both sides EXECUTE against — `PROTOCOL_FD = 3` and `log_truncation_marker` — with byte-identical text. + +The package skeleton (`package.json`, `tsconfig.json`, `tsdown.config.ts`, `src/index.ts`, `src/invariant.ts`, README triplet) ships here rather than in a later stack layer: `check-workspace-constraints` reads every `packages//` package.json unconditionally, and the coverage and invariant-topology gates require the package to exist and build the moment its directory does. The later backend-core PR extends `src/index.ts` with `PythonCodeRuntime` and grows `package.json`'s dependencies; because it bases on this branch, those are edits, not conflicts. + +## Wire contract + +Frames are JSON-lines on fd 3, one object per line, leaving stdout/stderr free for the program's own output. Child → host: `boot-ack`, `call`, `log`, `done`. Host → child: `boot` (first frame), `run` (after `boot-ack`), and one `reply` per `call`. The `log` frame's `truncated` flag marks the frame that IS the child ledger's own truncation marker, so the host stops capturing at the same point the child did instead of inferring it from its own budget. `done.error.kind` is one of `exception`, `invalid-output`, `output-limit`; wall/CPU budgets, aborts, and substrate death are observed host-side, not carried as frames. + +## Mirror alignment + +Round-12 review of #436 found `py/protocol.py` stale against `src/protocol.ts` in three declarations — `LogMessage` lacked `truncated`, `DoneMessage.error` lacked `kind`, and `Namespace` lacked the optional `errorClass`. This PR aligns all three when lifting the file, so the stale mirror is not carried forward. Because the declarations are `TypedDict`s (no runtime enforcement on the trusted Python side), an automated guard covers only what both sides execute: `tests/protocol-mirror.e2e.ts` spawns a real `python3`, reads `PROTOCOL_FD` and `log_truncation_marker` from `py/protocol.py`, and asserts they equal the TypeScript constants across several byte budgets. + +## Alternatives considered + +**Move the Python JSON codec (`_encode_json_plain` / `_decode_json_plain`) into `py/protocol.py` for cross-side symmetry with `protocol.ts`.** Rejected. The repository's "prefer symmetry for parallel values" rule points at genuinely parallel values; these are not. The host-side codec in `protocol.ts` validates HOSTILE input and is self-contained. The Python codec produces output on the TRUSTED side and is coupled to bootstrap-internal helpers (`_Emit`, `_dump_scalar`/`_dump_string`/`_dump_float`, `LogBuffer`'s cost accounting, `_check_done_value`, `_lossless_json_violation`); lifting only the two entry points would drag that web into `protocol.py` or create a `bootstrap.py` ↔ `protocol.py` import cycle. The real cross-side parallel is "host validates inbound (`protocol.ts`) ↔ child trusts host and emits (`bootstrap.py`)", and that symmetry is preserved: `protocol.py` stays the pure wire-vocabulary mirror it is on the TS side. The Python codec stays in `bootstrap.py`, delivered by the backend-core PR. + +**Defer the package skeleton to the backend-core PR that "owns" package.json.** Rejected: the workspace-constraint, coverage, and invariant-topology gates fail the instant the `code-runtime-python` directory exists without a buildable package. A stacked split cannot create source files in a package that does not yet compile. + +## Consequences + +Bought: the fd-3 protocol and its hostile-input codec land as a self-contained, fully unit-covered layer, and the py/ts mirror drift the round-12 review found is fixed with an executing guard against its recurrence. The backend-core PR builds on a reviewed wire contract. + +Cost: `src/index.ts` and `package.json` are introduced minimally here and edited (not created) by the backend-core PR. The `TypedDict` shapes in `py/protocol.py` beyond the two executed surfaces remain guarded by review plus the backend's real-subprocess suite, not by the mirror e2e test — an inherent limit of comparing type declarations across languages. diff --git a/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.zh.md b/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.zh.md new file mode 100644 index 0000000000..e7cf551b1d --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.zh.md @@ -0,0 +1,43 @@ +# Agent Note: the code-runtime-python fd-3 frame protocol + +Status: implemented + +[English](2026-07-31-code-runtime-python-fd3-protocol.md) | 中文 + +## Problem + +CPython code-runtime 后端(`@deepseek-ai/dsh-code-runtime-python`,分多个 PR 落地)在一个全新的 `python3 -I` 子进程里运行每个模型程序,并把 binding 调用和完成值通过子进程的 fd 3 桥接。这条通道需要两侧一致的 wire protocol,而 host 不能信任它:模型代码对 fd 3 有完全访问权、可以伪造任意帧,所以每个入站帧都是 host 必须先校验并重建才能读取的敌意输入。协议还必须承载无深度限制的 lossless JSON,因为 seam 的 `CodeJsonValue` 深度无界,而 `JSON.stringify`/`json.dumps` 都有递归深度限制。 + +本层只交付这个协议,使得庞大的 `PythonCodeRuntime` 实现及其真子进程集成测试能落在一个已 review 的 wire contract 之上,而不是与它揉在一起到达。父 stack 把 [#436](https://github.com/deepseek-harness/deepseek-harness/pull/436)——一个 9000 行的单一 PR——拆成可 review 的层;本 PR 是协议层,base 是 [seam 扩展](2026-07-31-code-runtime-portable-identifier-seam.zh.md)。 + +## Decision + +`src/protocol.ts` 是 wire vocabulary 的 host 侧及其敌意帧编解码: + +- **`validateChildFrame`** 对每个入站帧做形状校验并重建。编译期 union 在 fd 3 上毫无意义——伪造帧可携带 `null`、被污染的字段,或省略必需字段——所以每个被接受的帧都逐字段重建:伪造的额外字段绝不随行,非有限的 call id 绝不会被回显进 reply,垃圾返回 `undefined` 被丢弃,而不是在 host 的 message handler 里抛错。 +- **`encodeJsonPlain` / `checkDoneValue` / `hasUnsafeIntegerToken` / `hasNonLosslessNumber`** 是 lossless-JSON 编解码器与计量器。它们迭代遍历(显式栈,非递归),使低于字节预算的深层值能完整穿越;`checkDoneValue` 把字节计量和数字无损性折进一次有界遍历,在把子节点入栈之前就拒绝超预算 payload,防止一个低于帧上限的伪造值迫使 host 分配数百 MB。超出安全范围的整数型 double 通过 `BigInt` 数字序列化,穿越的是精确整数而非 `String()` 的舍入形式。 +- **`logTruncationMarker`** 产出日志 ledger 耗尽字节预算时发出的带内标记文本。 + +`py/protocol.py` 用 `TypedDict` 镜像消息形状,并重新声明两侧都会 EXECUTE 的两个面——`PROTOCOL_FD = 3` 与 `log_truncation_marker`——文本逐字节一致。 + +包骨架(`package.json`、`tsconfig.json`、`tsdown.config.ts`、`src/index.ts`、`src/invariant.ts`、README 三件套)在此交付,而非放到后续 stack 层:`check-workspace-constraints` 无条件读取每个 `packages//` 的 package.json,coverage 与 invariant-topology gate 也要求包在其目录出现的那一刻即存在且可构建。后续的 backend-core PR 会用 `PythonCodeRuntime` 扩展 `src/index.ts` 并增补 `package.json` 的依赖;因为它 base 在本分支上,那些是编辑,不是冲突。 + +## Wire contract + +帧是 fd 3 上的 JSON-lines,每行一个对象,让 stdout/stderr 空出给程序自己的输出。Child → host:`boot-ack`、`call`、`log`、`done`。Host → child:`boot`(首帧)、`run`(在 `boot-ack` 之后)、以及每个 `call` 对应一个 `reply`。`log` 帧的 `truncated` 标志标记那个本身就是子进程 ledger 截断标记的帧,使 host 在与子进程相同的点停止捕获,而不是从自己的预算去推断。`done.error.kind` 是 `exception`、`invalid-output`、`output-limit` 之一;wall/CPU 预算、abort、substrate 死亡都在 host 侧观测,不作为帧携带。 + +## Mirror alignment + +#436 的 round-12 review 发现 `py/protocol.py` 相对 `src/protocol.ts` 有三处声明陈旧——`LogMessage` 缺 `truncated`、`DoneMessage.error` 缺 `kind`、`Namespace` 缺可选的 `errorClass`。本 PR 在搬运该文件时对齐了这三处,不把陈旧镜像带过来。由于这些声明是 `TypedDict`(在受信任的 Python 侧无运行时强制),自动化 guard 只覆盖两侧都会执行的部分:`tests/protocol-mirror.e2e.ts` 启动一个真实 `python3`,从 `py/protocol.py` 读取 `PROTOCOL_FD` 与 `log_truncation_marker`,并在若干字节预算下断言它们等于 TypeScript 常量。 + +## Alternatives considered + +**把 Python JSON codec(`_encode_json_plain` / `_decode_json_plain`)挪进 `py/protocol.py` 以与 `protocol.ts` 跨侧对称。** 拒绝。仓库的 "prefer symmetry for parallel values" 规则指向真正平行的值;这两者不是。`protocol.ts` 里的 host 侧 codec 校验的是敌意输入,自包含。Python codec 在受信任侧产出输出,且耦合于 bootstrap 内部 helper(`_Emit`、`_dump_scalar`/`_dump_string`/`_dump_float`、`LogBuffer` 的成本核算、`_check_done_value`、`_lossless_json_violation`);只把两个入口挪过去会把这一整片拖进 `protocol.py`,或制造 `bootstrap.py` ↔ `protocol.py` 的 import 环。真正的跨侧平行是 "host 校验入站(`protocol.ts`) ↔ child 信任 host 并发出(`bootstrap.py`)",这个对称性被保留:`protocol.py` 保持它在 TS 侧一样的纯 wire-vocabulary 镜像定位。Python codec 留在 `bootstrap.py`,由 backend-core PR 交付。 + +**把包骨架推迟到"拥有" package.json 的 backend-core PR。** 拒绝:workspace-constraint、coverage、invariant-topology gate 会在 `code-runtime-python` 目录一存在而包不可构建时立即失败。stacked 拆分无法在一个尚不能编译的包里创建源文件。 + +## Consequences + +收获:fd-3 协议及其敌意输入 codec 作为自包含、unit 全覆盖的一层落地,round-12 review 发现的 py/ts 镜像漂移被修复,并有一个执行中的 guard 防其复发。backend-core PR 建立在已 review 的 wire contract 之上。 + +代价:`src/index.ts` 与 `package.json` 在此以最小形态引入,并由 backend-core PR 编辑(而非创建)。`py/protocol.py` 中两个可执行面之外的 `TypedDict` 形状仍由 review 加后端真子进程套件守护,而非 mirror e2e 测试——这是跨语言比较类型声明的固有局限。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 13ebcc5b32..5f2b9ac346 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2574,6 +2574,7 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them. - `@deepseek-ai/dsh-client-ui-slots` ([`packages/client/ui-slots/src/index.ts`](../packages/client/ui-slots/src/index.ts)) - `@deepseek-ai/dsh-client-web` ([`packages/client/web/src/index.ts`](../packages/client/web/src/index.ts)) - `@deepseek-ai/dsh-client-web-react` ([`packages/client/web-react/src/index.ts`](../packages/client/web-react/src/index.ts)) +- `@deepseek-ai/dsh-code-runtime-python` ([`packages/code-runtime/code-runtime-python/src/index.ts`](../packages/code-runtime/code-runtime-python/src/index.ts)) - `@deepseek-ai/dsh-helper` ([`packages/sdk/helper/src/index.ts`](../packages/sdk/helper/src/index.ts)) - `@deepseek-ai/dsh-hook-protocol` ([`packages/hooks/hook-protocol/src/index.ts`](../packages/hooks/hook-protocol/src/index.ts)) - `@deepseek-ai/dsh-jsonrpc-demo` ([`packages/examples/jsonrpc-demo/src/index.ts`](../packages/examples/jsonrpc-demo/src/index.ts)) diff --git a/docs/module-graph.md b/docs/module-graph.md index a50b658e2a..3884c4b58b 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -184,6 +184,7 @@ flowchart TD end subgraph group_code_runtime["packages/code-runtime"] pkg_code_runtime["code-runtime"] + pkg_code_runtime_python["code-runtime-python"] pkg_code_runtime_worker["code-runtime-worker"] end subgraph group_context["packages/context"] @@ -333,6 +334,8 @@ flowchart TD pkg_client_ui_trajectory --> pkg_client_runtime pkg_client_ui_trajectory --> pkg_client_ui_primitives pkg_client_ui_trajectory --> pkg_invariants + pkg_code_runtime_python --> pkg_code_runtime + pkg_code_runtime_python --> pkg_invariants pkg_credentials --> pkg_brand pkg_credentials --> pkg_invariants pkg_frontend_static --> pkg_host_webserver @@ -1147,6 +1150,7 @@ flowchart TD | [`client-test-runtime`](../packages/client/test-runtime) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`host-apiproxy`](../packages/host/apiproxy), [`invariants`](../packages/support/invariants) | | [`client-ui-settings`](../packages/client/ui-settings) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`invariants`](../packages/support/invariants) | +| [`code-runtime-python`](../packages/code-runtime/code-runtime-python) | `code-runtime` | [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/support/invariants) | | [`credentials`](../packages/credentials/credentials) | `credentials` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | | [`frontend-static`](../packages/host/frontend-static) | `host` | [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | | [`helper`](../packages/sdk/helper) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess) | diff --git a/knip.json b/knip.json index 6dc4b56dcd..a09e4b37da 100644 --- a/knip.json +++ b/knip.json @@ -360,6 +360,16 @@ "tests/**/*.ts" ] }, + "packages/code-runtime/code-runtime-python": { + "entry": [ + "tests/**/*.spec.ts", + "tests/**/*.e2e.ts" + ], + "project": [ + "src/**/*.ts", + "tests/**/*.ts" + ] + }, "packages/llm/llm-deepseek": { "entry": [ "tests/**/*.spec.ts", diff --git a/packages/code-runtime/code-runtime-python/README.i18n.yaml b/packages/code-runtime/code-runtime-python/README.i18n.yaml new file mode 100644 index 0000000000..d13849f8b0 --- /dev/null +++ b/packages/code-runtime/code-runtime-python/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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-python/README.md +README.md: 8a394f18f8e27addf0f4a7530cbdb31b9d629bb9 +README.zh.md: 1c246c952492eb574b6fc6c6bc6c76fffcabe01e diff --git a/packages/code-runtime/code-runtime-python/README.md b/packages/code-runtime/code-runtime-python/README.md new file mode 100644 index 0000000000..8a394f18f8 --- /dev/null +++ b/packages/code-runtime/code-runtime-python/README.md @@ -0,0 +1,24 @@ +# @deepseek-ai/dsh-code-runtime-python + +English | [中文](README.zh.md) + +CPython-subprocess implementation of the [`@deepseek-ai/dsh-code-runtime`](../code-runtime/README.md) seam. Companion to [`@deepseek-ai/dsh-code-runtime-worker`](../code-runtime-worker/README.md); trades the Node worker thread for a fresh `python3` subprocess so model code is Python instead of TypeScript. + +This package is built up across the code-runtime-python PR stack. This layer ships the wire protocol; the `PythonCodeRuntime` implementation that drives a `python3 -I` process over it lands on top of it. + +## Wire protocol + +The host and the CPython subprocess exchange a versionless, JSON-lines protocol on the child's fd 3 — one JSON object per line, leaving stdout/stderr free for the program's own output. `src/protocol.ts` is the host side; `py/protocol.py` mirrors its message shapes and the shared truncation-marker text on the Python side. + +- **fd 3, not stdout** — Node pins the channel positionally with `stdio: ['pipe','pipe','pipe','pipe']`; the Python bootstrap reads the same `PROTOCOL_FD` constant. JSON-lines framing. +- **Host treats every inbound frame as hostile** — model code has full access to fd 3 and can post anything through it, so `validateChildFrame` shape-validates and REBUILDS each frame before the host reads it: forged extra fields never ride along, a non-number call id can never be echoed into a reply, and junk drops to `undefined` rather than throwing in the host's message handler. The Python side trusts host replies (the host is not model-controlled). +- **Lossless-JSON crossing** — completion values and binding arguments cross as exact JSON. `encodeJsonPlain` serializes a `JSON.parse`-produced value without recursion, so a deep value below the byte budget crosses intact instead of dying on `JSON.stringify`'s stack limit; `checkDoneValue` meters a forged completion value's byte length AND number losslessness in one bounded traversal that rejects an over-budget payload before enqueuing its children; `hasUnsafeIntegerToken` reads the raw frame text to catch an integer token that `JSON.parse` would silently round; `hasNonLosslessNumber` rejects a non-finite or negative-zero number in unbounded `call.args`. Beyond-safe-range integral doubles serialize through `BigInt` digits so the exact integer crosses, not the rounded `String()` form. +- **Shared truncation marker** — `logTruncationMarker(maxBytes)` produces byte-identical text on both sides, so a truncated log run reads the same however the cap was hit. The `log` frame's `truncated` flag distinguishes the child ledger's own marker from program output. + +## Model Experience + +Indirectly, through Code Mode in [`dsh-tools`](../../core/tools/README.md), which renders this backend's exact completion value when it fits (or an explicit `invalid-output` / `output-limit` failure), plus the exact `[dsh-code-runtime-python] log capture truncated at bytes` log marker, into a retained `run_code` result. + +#### KV Cache effect + +No direct invalidation; the named consumer owns any request-prefix changes. diff --git a/packages/code-runtime/code-runtime-python/README.zh.md b/packages/code-runtime/code-runtime-python/README.zh.md new file mode 100644 index 0000000000..1c246c9524 --- /dev/null +++ b/packages/code-runtime/code-runtime-python/README.zh.md @@ -0,0 +1,24 @@ +# @deepseek-ai/dsh-code-runtime-python + +[English](README.md) | 中文 + +[`@deepseek-ai/dsh-code-runtime`](../code-runtime/README.md) seam 的 CPython 子进程实现。与 [`@deepseek-ai/dsh-code-runtime-worker`](../code-runtime-worker/README.md) 配套;以全新的 `python3` 子进程取代 Node worker 线程,让模型代码从 TypeScript 换成 Python。 + +本包分多个 code-runtime-python PR 逐层搭建。本层交付 wire protocol;在其之上驱动 `python3 -I` 进程的 `PythonCodeRuntime` 实现随后落地。 + +## Wire protocol + +host 与 CPython 子进程在子进程的 fd 3 上交换一个无版本号的 JSON-lines 协议——每行一个 JSON 对象,让 stdout/stderr 空出给程序自己的输出。`src/protocol.ts` 是 host 侧;`py/protocol.py` 在 Python 侧镜像其帧词汇与共享的截断标记文本。 + +- **fd 3,而非 stdout** —— Node 通过 `stdio: ['pipe','pipe','pipe','pipe']` 按位置钉住通道;Python bootstrap 读取相同的 `PROTOCOL_FD` 常量。JSON-lines 帧。 +- **host 把每个入站帧当作敌意输入** —— 模型代码对 fd 3 有完全访问权、可通过它发送任意内容,所以 `validateChildFrame` 在 host 读取前对每个帧做形状校验并重建:伪造的额外字段绝不随行,非数字的 call id 绝不会被回显进 reply,垃圾降为 `undefined` 被丢弃,而不是在 host 的 message handler 里抛错。Python 侧信任 host 回复(host 不受模型控制)。 +- **lossless-JSON 穿越** —— 完成值与 binding 参数以精确 JSON 穿越。`encodeJsonPlain` 无递归地序列化一个 `JSON.parse` 产出的值,使低于字节预算的深层值能完整穿越,而不是死在 `JSON.stringify` 的栈限制上;`checkDoneValue` 在一次有界遍历中同时计量伪造完成值的字节长度与数字无损性,在把子节点入栈之前就拒绝超预算 payload;`hasUnsafeIntegerToken` 读取原始帧文本,捕获 `JSON.parse` 会静默舍入的整数 token;`hasNonLosslessNumber` 拒绝无字节上限的 `call.args` 中的非有限数或负零。超出安全范围的整数型 double 通过 `BigInt` 数字序列化,穿越的是精确整数而非 `String()` 的舍入形式。 +- **共享截断标记** —— `logTruncationMarker(maxBytes)` 在两侧产出逐字节一致的文本,使被截断的日志运行无论从哪侧触达上限都读起来一致。`log` 帧的 `truncated` 标志把子进程 ledger 自身的标记与程序输出区分开。 + +## Model Experience + +Indirectly, through Code Mode in [`dsh-tools`](../../core/tools/README.md), which renders this backend's exact completion value when it fits (or an explicit `invalid-output` / `output-limit` failure), plus the exact `[dsh-code-runtime-python] log capture truncated at bytes` log marker, into a retained `run_code` result. + +#### KV Cache effect + +No direct invalidation; the named consumer owns any request-prefix changes. diff --git a/packages/code-runtime/code-runtime-python/package.json b/packages/code-runtime/code-runtime-python/package.json new file mode 100644 index 0000000000..dc72d0c749 --- /dev/null +++ b/packages/code-runtime/code-runtime-python/package.json @@ -0,0 +1,39 @@ +{ + "name": "@deepseek-ai/dsh-code-runtime-python", + "description": "CPython subprocess implementation of the DeepSeek Harness code-execution seam", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "py/**/*.py", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-code-runtime": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "devDependencies": { + "@deepseek-ai/dsh-code-runtime": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/code-runtime/code-runtime-python/py/protocol.py b/packages/code-runtime/code-runtime-python/py/protocol.py new file mode 100644 index 0000000000..0445726cac --- /dev/null +++ b/packages/code-runtime/code-runtime-python/py/protocol.py @@ -0,0 +1,126 @@ +"""Wire protocol vocabulary for the Python side of dsh-code-runtime-python. + +Mirrors ``src/protocol.ts``. Frames travel on fd 3 as JSON-lines (one JSON +object per line). The host validates every inbound frame; this side trusts +host replies. +""" + +from __future__ import annotations + +from typing import Any, Literal, TypedDict, Union + +# The protocol fd from the child's perspective. Node passes +# ``stdio: [pipe, pipe, pipe, pipe]`` so the fourth entry (fd 3) is the +# framed-JSON channel; stdout/stderr stay clear for the program's own output. +PROTOCOL_FD = 3 + + +class BootMessage(TypedDict): + """Host → child, first frame on fd 3. Carries every cap and the namespaces.""" + + type: Literal["boot"] + cpuSeconds: int + addressSpaceBytes: int + maxLogBytes: int + maxValueBytes: int + namespaces: list["Namespace"] + + +class ErrorClass(TypedDict): + """A namespace's program-visible exception class: rejected calls raise its + instances carrying the failed member name on ``memberNameProperty``.""" + + name: str + memberNameProperty: str + + +class Namespace(TypedDict, total=False): + """One binding namespace declaration: the global name, its function names, + and an optional program-visible ``errorClass`` for rejected calls.""" + + global_: str # required; renamed on the wire: JSON field is ``global`` (Python keyword collision) + names: list[str] # required + errorClass: ErrorClass # optional — mirrors the TS `errorClass?` + + +class RunMessage(TypedDict): + """Host → child, sent after ``boot-ack``. Carries only the program body.""" + + type: Literal["run"] + program: str + + +class BootAckMessage(TypedDict): + """Child → host: resource limits applied, ready for the run message.""" + + type: Literal["boot-ack"] + + +class CallMessage(TypedDict): + """Child → host: one bridged binding call from the model program.""" + + type: Literal["call"] + id: int + global_: str # wire field is ``global`` + name: str + args: Any + + +class LogMessage(TypedDict, total=False): + """Child → host: one captured text chunk, streamed eagerly. + + ``truncated`` is set only on the frame that IS the child ledger's truncation + marker (not program output), so the host stops capturing at the same point + the child did — mirrors the TS `truncated?`. + """ + + type: Literal["log"] # required + text: str # required + truncated: bool # optional + + +class DoneErrorField(TypedDict): + """Child → host: the failure carried on a ``done`` frame. ``kind`` is one of + the three the host validates; ``message`` is the traceback or diagnostic.""" + + kind: Literal["exception", "invalid-output", "output-limit"] + message: str + + +class DoneMessage(TypedDict, total=False): + """Child → host: the program settled. ``value`` and ``error`` are optional per the TS mirror.""" + + type: Literal["done"] # required — TypedDict(total=False) allows this via a required subclass in Py 3.11+; MVP keeps it flat + value: Any + error: DoneErrorField + + +ChildToHost = Union[BootAckMessage, CallMessage, LogMessage, DoneMessage] + + +class ReplyOk(TypedDict): + type: Literal["reply"] + id: int + ok: Literal[True] + value: Any + + +class ReplyErr(TypedDict): + type: Literal["reply"] + id: int + ok: Literal[False] + message: str + + +ReplyMessage = Union[ReplyOk, ReplyErr] +HostToChild = ReplyMessage + + +def log_truncation_marker(max_bytes: int) -> str: + """Return the in-band marker for a log ledger that exhausted its budget. + + Byte-identical text on both sides of the wire so a truncated run reads the + same however the cap was hit. + """ + + return f"[dsh-code-runtime-python] log capture truncated at {max_bytes} bytes" diff --git a/packages/code-runtime/code-runtime-python/src/index.ts b/packages/code-runtime/code-runtime-python/src/index.ts new file mode 100644 index 0000000000..625576f220 --- /dev/null +++ b/packages/code-runtime/code-runtime-python/src/index.ts @@ -0,0 +1,20 @@ +/** + * CPython subprocess code runtime for the DeepSeek Harness code-execution seam. + * + * This layer of the package ships the versionless fd-3 wire protocol between the + * Node host and the CPython subprocess; the `PythonCodeRuntime` implementation + * that drives a `python3 -I` process over it lands on top of this seam. The + * protocol's host-side codec and hostile-frame validators are re-exported so the + * runtime and its tests share one wire vocabulary. + * @module @deepseek-ai/dsh-code-runtime-python + */ + +export type { BootMessage, ChildToHost, ReplyMessage } from './protocol.ts' +export { + checkDoneValue, + encodeJsonPlain, + hasNonLosslessNumber, + hasUnsafeIntegerToken, + logTruncationMarker, + validateChildFrame, +} from './protocol.ts' diff --git a/packages/code-runtime/code-runtime-python/src/invariant.ts b/packages/code-runtime/code-runtime-python/src/invariant.ts new file mode 100644 index 0000000000..48441ad875 --- /dev/null +++ b/packages/code-runtime/code-runtime-python/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-code-runtime-python`. + * @module @deepseek-ai/dsh-code-runtime-python/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-code-runtime-python' + +/** Cordis companion plugin name. */ +export const name = 'code-runtime-python-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: this process-boundary implementation exposes no same-process event relation; + * the fd-3 protocol and real-subprocess integration tests cover it. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/code-runtime/code-runtime-python/src/protocol.ts b/packages/code-runtime/code-runtime-python/src/protocol.ts new file mode 100644 index 0000000000..c935a1153a --- /dev/null +++ b/packages/code-runtime/code-runtime-python/src/protocol.ts @@ -0,0 +1,420 @@ +/** + * Versionless, JSON-lines wire protocol between the Node host and the CPython subprocess. Frames + * travel on the child's fd 3 (one JSON object per line), leaving stdout/stderr free for the + * program's own output. Host treats every inbound frame as hostile because model code can post + * anything through the same fd; the Python bootstrap trusts host replies. + * @module @deepseek-ai/dsh-code-runtime-python/src/protocol + */ + +// The protocol channel is fd 3 from the child's perspective — the host pins it +// positionally via `stdio: ['pipe','pipe','pipe','pipe']` (index.ts), and the +// Python bootstrap reads the same constant from its own protocol.py. + +/** + * What the host sends immediately after spawn, as the first line on fd 3. The + * Python bootstrap reads this, applies resource limits, then waits for the + * subsequent run frame. Separated from the run so the run message stays + * pure model input. + */ +export interface BootMessage { + type: 'boot' + /** RLIMIT_CPU seconds; the Python bootstrap sets this on itself before executing model code. */ + cpuSeconds: number + /** RLIMIT_AS bytes; caps address space so a runaway allocation fails cleanly. */ + addressSpaceBytes: number + /** Shared byte budget for captured log text (Python-side ledger). */ + maxLogBytes: number + /** Byte cap for the rendered completion value. */ + maxValueBytes: number + /** + * The namespaces to materialize inside the program (globals + names; + * functions stay host-side). `errorClass` asks the bootstrap to mint a + * program-visible exception class under that global: rejected calls raise + * its instances carrying the member name on `memberNameProperty`. + */ + namespaces: { global: string; names: string[]; errorClass?: { name: string; memberNameProperty: string } }[] +} + +// The run request `{ type: 'run', program }` follows BootMessage once the +// child acknowledges with `boot-ack`; the host sends it as an inline literal +// (it carries only the model's program body — caps and bindings crossed on boot). + +/** Python → host: acknowledges boot completed and resource limits are in place. */ +interface BootAckMessage { + type: 'boot-ack' +} + +/** Python → host: one bridged binding call (`await tools.name(args)` inside the program). */ +interface CallMessage { + type: 'call' + /** Python-issued correlation id; the host answers each id at most once and ignores duplicates. */ + id: number + /** The namespace global the call targets. */ + global: string + /** The function name within the namespace. */ + name: string + /** The JSON-safe argument the model program passed. */ + args: unknown +} + +/** + * Python → host: captured text, streamed eagerly so output survives a + * mid-run termination (RLIMIT_CPU, SIGTERM/SIGKILL, host wall-timeout). + */ +interface LogMessage { + type: 'log' + text: string + /** + * Set when this frame IS the child ledger's truncation marker rather than + * program output. The two ledgers can exhaust at different points — one + * child entry larger than `maxLogBytes` sends only the marker while the host + * ledger is still nearly empty — so the host cannot infer the child's state + * from its own budget, and comparing the text against the marker string + * would also honour a program that printed that string itself. Carrying it + * as a field lets the host stop capturing at the same point the child did + * and keeps exactly one marker in `logs`. + */ + truncated?: boolean +} + +/** + * Python → host: the program settled. `error` carries a program exception + * (traceback text), an `invalid-output` (completion value was not lossless + * JSON), or an `output-limit` (serialized completion exceeded the configured + * cap); wall/CPU budgets, aborts, and substrate death are observed host-side. + * `value` is present only on a clean completion that produced one, and crosses + * as exact lossless JSON — never substituted or truncated. + */ +interface DoneMessage { + type: 'done' + value?: unknown + error?: { kind: 'exception' | 'invalid-output' | 'output-limit'; message: string } +} + +/** + * Every message the Python side sends. The member interfaces stay module- + * private: consumers match on the union's discriminant; the host sends the + * boot and run frames as inline literals. + */ +export type ChildToHost = BootAckMessage | CallMessage | LogMessage | DoneMessage + +/** Host → Python: the answer to one {@link CallMessage}. */ +export type ReplyMessage = + | { type: 'reply'; id: number; ok: true; value: unknown } + | { type: 'reply'; id: number; ok: false; message: string } + +/** + * The in-band marker text announcing that log capture stopped at the byte + * budget. Shared wire vocabulary: the Python-side LogBuffer emits it when ITS + * ledger exhausts, and the host emits identical text when its own ledger drops + * a frame first (forged fd-3 traffic, stray stdout bytes) — a truncated run + * reads the same however the cap was hit. + * @param maxBytes - the configured `maxLogBytes` the marker names. + * @returns the marker line. + */ +export function logTruncationMarker(maxBytes: number): string { + return `[dsh-code-runtime-python] log capture truncated at ${maxBytes} bytes` +} + +/** + * Serialize one JSON-parse-produced value without recursion. `JSON.stringify` + * recurses per nesting level and throws `RangeError` a few thousand levels + * deep, but the seam's `CodeJsonValue` has no depth limit — an honest deep + * completion or binding resolution below the byte budget must cross intact + * (the worker backend's wire is equally stack-safe). Callers must pass a value + * produced by `JSON.parse` (or equally JSON-plain): only `null`, finite + * numbers, booleans, strings, dense arrays, and plain objects — this encoder + * validates nothing. Output is byte-identical to compact `JSON.stringify`. + * @param value - a JSON-plain value (e.g. straight from `JSON.parse`). + * @returns the compact JSON encoding. + */ +export function encodeJsonPlain(value: unknown): string { + type Task = { text: string } | { value: unknown } + const chunks: string[] = [] + const tasks: Task[] = [{ value }] + for (let task = tasks.pop(); task !== undefined; task = tasks.pop()) { + if ('text' in task) { + chunks.push(task.text) + continue + } + const current = task.value + if (typeof current === 'string') { + chunks.push(JSON.stringify(current)) + } else if (Array.isArray(current)) { + chunks.push('[') + tasks.push({ text: ']' }) + for (let index = current.length - 1; index >= 0; index--) { + if (index < current.length - 1) tasks.push({ text: ',' }) + tasks.push({ value: current[index] }) + } + } else if (typeof current === 'object' && current !== null) { + const record = current as Record + chunks.push('{') + tasks.push({ text: '}' }) + const keys = Object.keys(record) + for (let index = keys.length - 1; index >= 0; index--) { + const key = keys[index] as string + if (index < keys.length - 1) tasks.push({ text: ',' }) + tasks.push({ value: record[key] }) + tasks.push({ text: `${JSON.stringify(key)}:` }) + } + } else { + chunks.push(scalarJson(current)) + } + } + return chunks.join('') +} + +/** + * One scalar (null, boolean, finite number) as JSON text. A beyond-safe-range + * integral double needs BigInt digits: `String(2 ** 60)` emits the ROUNDED + * `...847000` form, and echoing that to the child would silently change the + * integer the seam promised to carry losslessly — `BigInt(2 ** 60)` prints the + * exact `...846976` the double actually holds. + * @param current - a JSON-plain scalar (JSON.parse emits nothing else). + * @returns its JSON encoding. + */ +function scalarJson(current: unknown): string { + if (typeof current === 'number' && Number.isInteger(current) && !Number.isSafeInteger(current)) { + return BigInt(current).toString() + } + return String(current) +} + +/** + * Meter a forged done value's compact-JSON byte length AND its number + * losslessness in one bounded traversal, stopping the instant `maxBytes` is + * crossed. A forged `done.value` arrives straight off fd 3 and can sit anywhere + * below the 256 MiB frame ceiling while `maxValueBytes` defaults to 32 KiB. The + * previous split — an unbounded `hasNonLosslessNumber` scan in + * {@link validateChildFrame} followed by a separate byte meter — pushed every + * member of a wide flat payload onto a scan stack before any cap check ran, so + * a below-ceiling forgery could still force a hundreds-of-megabytes host + * allocation. Folding both jobs here rejects over-budget BEFORE enqueuing an + * array's or object's children, keeping the traversal O(cap). A non-lossless + * number (non-finite, negative zero) is caught only when the value fits the + * budget — an over-budget value is rejected regardless, so the distinction is + * moot. Same JSON-plain precondition and traversal shape as + * {@link encodeJsonPlain}; per-scalar encoding delegates to `JSON.stringify`. + * @param value - a JSON-plain value (e.g. straight from `JSON.parse`). + * @param maxBytes - the completion-value budget in bytes. + * @returns `{ ok: true, bytes }` with the exact serialized size, or + * `{ ok: false, reason }` — `over-budget` once the size exceeds `maxBytes`, + * `non-lossless` on a non-finite or negative-zero number. + */ +export function checkDoneValue(value: unknown, maxBytes: number): { ok: true; bytes: number } | { ok: false; reason: 'over-budget' | 'non-lossless' } { + let bytes = 0 + const stack: unknown[] = [value] + while (stack.length > 0) { + const current = stack.pop() + if (typeof current === 'number') { + if (!Number.isFinite(current) || Object.is(current, -0)) return { ok: false, reason: 'non-lossless' } + bytes += Buffer.byteLength(scalarJson(current), 'utf8') + } else if (typeof current === 'string') { + // Lower-bound BEFORE materializing the escaped form: every UTF-16 code + // unit is at least one UTF-8 byte plus the two quotes, so a huge or + // control-heavy forged string (whose escaped copy expands severalfold) + // is rejected without allocating that copy. + if (bytes + current.length + 2 > maxBytes) return { ok: false, reason: 'over-budget' } + bytes += Buffer.byteLength(JSON.stringify(current), 'utf8') + } else if (Array.isArray(current)) { + // Brackets plus one comma per gap; elements add themselves. Reject + // BEFORE enqueuing children: every element serializes to at least one + // byte, so a forged flat array below the frame ceiling but far above + // the budget fails here without growing the host stack by millions of + // entries first. + bytes += 2 + (current.length > 1 ? current.length - 1 : 0) + if (bytes + current.length > maxBytes) return { ok: false, reason: 'over-budget' } + for (const item of current) stack.push(item) + } else if (typeof current === 'object' && current !== null) { + const record = current as Record + // Count own keys WITHOUT Object.entries/Object.keys: either would + // allocate one slot (entries: one pair array) per member before the + // bound below could run, recreating the spike the bound exists to stop. + let count = 0 + for (const key in record) if (Object.hasOwn(record, key)) count += 1 + bytes += 2 + (count > 1 ? count - 1 : 0) + // Same pre-enqueue bound: each entry contributes its quoted key (>= 2 + // bytes), the colon, and a >= 1-byte value. + if (bytes + count * 4 > maxBytes) return { ok: false, reason: 'over-budget' } + for (const key in record) { + if (!Object.hasOwn(record, key)) continue + // The same string lower bound, before escaping the key. + if (bytes + key.length + 3 > maxBytes) return { ok: false, reason: 'over-budget' } + bytes += Buffer.byteLength(JSON.stringify(key), 'utf8') + 1 + stack.push(record[key]) + } + } else { + bytes += Buffer.byteLength(scalarJson(current), 'utf8') + } + if (bytes > maxBytes) return { ok: false, reason: 'over-budget' } + } + return { ok: true, bytes } +} + +/** + * Whether a raw JSON line contains an integer token that would lose precision + * as a JavaScript number. `JSON.parse` silently rounds such a token + * (`9007199254740993` becomes `...992`) BEFORE any validation can see it, so + * the check must read the source text; a beyond-safe-range token whose double + * parse round-trips exactly (`2**53`, `2**60`) is lossless and passes. The scan walks the line skipping string literals (a digit run + * inside a string is data, not a number token) and tests every number token + * in plain integer form — no fraction or exponent, which parse as doubles by + * intent. A reviver cannot do this job: the reviver walk recurses per nesting + * level and would reintroduce the depth limit `encodeJsonPlain` removes. + * @param line - the raw UTF-8 text of one JSON-lines frame. + * @returns true when an unsafe integer token is present outside strings. + */ +export function hasUnsafeIntegerToken(line: string): boolean { + for (let index = 0; index < line.length; index++) { + const char = line[index] + if (char === '"') { + // Skip the string literal, honoring backslash escapes. + for (index++; index < line.length; index++) { + if (line[index] === '\\') index++ + else if (line[index] === '"') break + } + continue + } + if (char === '-' || (char !== undefined && char >= '0' && char <= '9')) { + let end = index + 1 + while (end < line.length) { + const c = line[end] as string + if ((c >= '0' && c <= '9') || c === '.' || c === 'e' || c === 'E' || c === '+' || c === '-') end++ + else break + } + const token = line.slice(index, end) + // Beyond the safe range an integer token is still lossless IFF the + // double parse round-trips exactly (2**53 does; 2**53+1 rounds) — the + // canonical boundary accepts every JS-double-exact value, so only a + // genuinely rounding token marks the frame as forged. + if (/^-?\d+$/.test(token)) { + const parsed = Number(token) + // A token that parses to Infinity is trivially lossy; a finite + // beyond-safe-range one is lossy only when the BigInt round-trip + // disagrees. + if (!Number.isFinite(parsed)) return true + if (!Number.isSafeInteger(parsed) && BigInt(token) !== BigInt(parsed)) return true + } + index = end - 1 + } + } + return false +} + +/** + * Lazily yield one plain object's own enumerable property values. A generator + * (not `Object.values`/`Object.entries`) because {@link hasNonLosslessNumber} + * traverses breadth it cannot bound: those helpers copy the whole member list + * up front, so a wide forged object would cost a second full-breadth + * allocation before a single value is examined. + * @param record - a JSON-parse-produced object. + * @yields each own enumerable property value, in key order. + */ +function* ownValues(record: object): Generator { + for (const key in record) { + if (Object.hasOwn(record, key)) yield (record as Record)[key] + } +} + +/** + * Whether a JSON.parse-produced value contains a number outside lossless + * JSON: non-finite (`1e400` parses to `Infinity`) or negative zero (`-0.0` + * parses to JS `-0`, whose sign bit a re-serialization drops). The honest + * child's validator rejects these before sending, so a frame carrying one is + * forged. + * + * Runs on `call.args`, which — unlike a completion value — has NO seam byte + * cap, so there is no budget to reject a wide payload against the way + * {@link checkDoneValue} does. The traversal therefore holds ONE cursor per + * NESTING LEVEL (an array or {@link ownValues} iterator) instead of one entry + * per member: a forged flat `args` just below the 256 MiB frame ceiling would + * otherwise push tens of millions of stack entries — and `Object.values` would + * copy each object's full breadth — allocating hundreds of megabytes beyond + * what `JSON.parse` already holds. Iterative either way, so a deep frame + * cannot overflow the host stack. + * @param value - a JSON-parse-produced value from an fd-3 frame. + * @returns true when any contained number is non-finite or negative zero. + */ +export function hasNonLosslessNumber(value: unknown): boolean { + const cursors: Iterator[] = [[value].values()] + while (cursors.length > 0) { + // The loop condition guarantees a top cursor. + const cursor = cursors.at(-1) as Iterator + const step = cursor.next() + if (step.done === true) { + cursors.pop() + continue + } + const current = step.value + if (typeof current === 'number') { + if (!Number.isFinite(current) || Object.is(current, -0)) return true + } else if (Array.isArray(current)) { + cursors.push((current as unknown[]).values()) + } else if (typeof current === 'object' && current !== null) { + cursors.push(ownValues(current)) + } + } + return false +} + +/** + * Runtime shape gate for inbound fd-3 traffic. Model code has full access to + * fd 3 and can post anything — `null`, primitives, poisoned fields — so the + * compile-time union means nothing here: every field is validated and REBUILT + * before the host reads it (forged extras never ride along; a non-number id + * can never be echoed into a reply). Junk returns `undefined` and is dropped + * so a throw in the host's `message` handler cannot crash the host process. + * @param raw - one JSON-parsed frame from fd 3. + * @returns the rebuilt frame, or `undefined` to drop it silently. + */ +export function validateChildFrame(raw: unknown): ChildToHost | undefined { + if (typeof raw !== 'object' || raw === null) return undefined + const m = raw as Record + switch (m.type) { + case 'boot-ack': + return { type: 'boot-ack' } + case 'log': + if (typeof m.text !== 'string') return undefined + // Rebuilt, not passed through: a forged `truncated` of any other type + // would reach the host as a truthy value and silence capture for the + // rest of the run. Only the literal `true` counts. + return { type: 'log', text: m.text, ...m.truncated === true ? { truncated: true } : {} } + case 'call': { + // The id must be a finite number: it is echoed verbatim into the reply + // frame, and a forged `1e400` id (Infinity after JSON.parse) would make + // the reply unencodable as strict JSON. + if (typeof m.id !== 'number' || !Number.isFinite(m.id) || typeof m.global !== 'string' || typeof m.name !== 'string') return undefined + // A forged frame can omit `args` entirely; rebuilding it as `undefined` + // would invoke the binding with a non-JSON value, bypassing the + // lossless-JSON argument boundary. Any PRESENT value is JSON-plain by + // construction (the frame came from JSON.parse), so presence is the + // whole check. + if (!Object.hasOwn(m, 'args')) return undefined + // JSON.parse yields Infinity for 1e400 and preserves -0; both are + // outside lossless JSON, and the honest child never sends them. + if (hasNonLosslessNumber(m.args)) return undefined + return { type: 'call', id: m.id, global: m.global, name: m.name, args: m.args } + } + case 'done': { + // The value passes through untouched here: scanning it for non-lossless + // numbers would push every member of a wide forged payload before any + // byte cap runs. The done handler's bounded `checkDoneValue` folds the + // losslessness check into the metered traversal, rejecting over-budget + // before it enqueues children. + const err = m.error + if (err === undefined) { + return m.value === undefined ? { type: 'done' } : { type: 'done', value: m.value } + } + if (typeof err !== 'object' || err === null) return undefined + const { kind, message } = err as Record + if (typeof message !== 'string') return undefined + if (kind !== 'exception' && kind !== 'invalid-output' && kind !== 'output-limit') return undefined + return m.value === undefined + ? { type: 'done', error: { kind, message } } + : { type: 'done', value: m.value, error: { kind, message } } + } + default: + return undefined + } +} diff --git a/packages/code-runtime/code-runtime-python/tests/protocol-mirror.e2e.ts b/packages/code-runtime/code-runtime-python/tests/protocol-mirror.e2e.ts new file mode 100644 index 0000000000..9ec1091286 --- /dev/null +++ b/packages/code-runtime/code-runtime-python/tests/protocol-mirror.e2e.ts @@ -0,0 +1,60 @@ +import { execFile } from 'node:child_process' +import { existsSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { promisify } from 'node:util' +import { describe, expect, it } from 'vitest' +import { logTruncationMarker } from '../src/protocol.ts' + +/** + * Cross-language mirror check for the two protocol surfaces the host and the + * CPython subprocess share at runtime, spawning a real `python3` to read them + * from `py/protocol.py`. `src/protocol.ts` and `py/protocol.py` declare the same + * frame vocabulary on two sides of the wire; the only values both sides EXECUTE + * against are `PROTOCOL_FD` (the fd the channel is pinned to) and the log + * truncation marker text (emitted verbatim by whichever ledger exhausts first), + * so a drift there silently corrupts a live run. Self-skips when no `python3` is + * on PATH — CI provides one; the pure-TS `protocol.spec.ts` covers the host + * codec unconditionally. + */ + +const execFileAsync = promisify(execFile) +const pyDir = fileURLToPath(new URL('../py', import.meta.url)) + +async function hasPython3(): Promise { + try { + await execFileAsync('python3', ['--version']) + return true + } catch { + return false + } +} + +const python3Available = await hasPython3() + +describe.skipIf(!python3Available)('protocol.py mirrors protocol.ts at runtime', () => { + it('agrees on PROTOCOL_FD and the log truncation marker across byte budgets', async () => { + const budgets = [1, 65536, 1048576] + const probe = [ + 'import json, sys', + `sys.path.insert(0, ${JSON.stringify(pyDir)})`, + 'from protocol import PROTOCOL_FD, log_truncation_marker', + `budgets = ${JSON.stringify(budgets)}`, + 'print(json.dumps({', + ' "fd": PROTOCOL_FD,', + ' "markers": [log_truncation_marker(b) for b in budgets],', + '}))', + ].join('\n') + const { stdout } = await execFileAsync('python3', ['-I', '-c', probe]) + const seen = JSON.parse(stdout) as { fd: number; markers: string[] } + // fd 3 is the wire contract, not a tunable: index.ts pins it positionally. + expect(seen.fd).toBe(3) + expect(seen.markers).toEqual(budgets.map(budget => logTruncationMarker(budget))) + }) +}) + +it('names the py/ directory that ships with the package', () => { + // The package.json `files` list ships `py/**/*.py`; the mirror test resolves + // the marker source relative to the built package, so the directory must exist + // beside the tests even when python3 is absent from the runner. + expect(existsSync(pyDir)).toBe(true) +}) diff --git a/packages/code-runtime/code-runtime-python/tests/protocol.spec.ts b/packages/code-runtime/code-runtime-python/tests/protocol.spec.ts new file mode 100644 index 0000000000..d3782b6c95 --- /dev/null +++ b/packages/code-runtime/code-runtime-python/tests/protocol.spec.ts @@ -0,0 +1,239 @@ +import { describe, expect, it } from 'vitest' +import { checkDoneValue, encodeJsonPlain, hasNonLosslessNumber, hasUnsafeIntegerToken, logTruncationMarker, validateChildFrame } from '../src/index.ts' + +describe('logTruncationMarker', () => { + it('names the configured byte budget', () => { + expect(logTruncationMarker(65536)).toBe('[dsh-code-runtime-python] log capture truncated at 65536 bytes') + expect(logTruncationMarker(1)).toBe('[dsh-code-runtime-python] log capture truncated at 1 bytes') + }) +}) + +describe('validateChildFrame', () => { + it('rebuilds boot-ack frames without extra fields', () => { + expect(validateChildFrame({ type: 'boot-ack' })).toEqual({ type: 'boot-ack' }) + // Forged extras never ride along. + expect(validateChildFrame({ type: 'boot-ack', extra: 'x' })).toEqual({ type: 'boot-ack' }) + }) + + it('rebuilds log frames when the text field is a string', () => { + expect(validateChildFrame({ type: 'log', text: 'hi' })).toEqual({ type: 'log', text: 'hi' }) + // Non-string text drops. + expect(validateChildFrame({ type: 'log', text: 42 })).toBeUndefined() + expect(validateChildFrame({ type: 'log' })).toBeUndefined() + }) + + it('rebuilds call frames with a numeric id, string global, and string name', () => { + expect(validateChildFrame({ type: 'call', id: 1, global: 'tools', name: 'echo', args: { x: 1 } })) + .toEqual({ type: 'call', id: 1, global: 'tools', name: 'echo', args: { x: 1 } }) + // A frame with NO args key drops whole: rebuilding it as `undefined` + // would invoke the binding with a non-JSON value, bypassing the + // lossless-JSON argument boundary. Any present value is JSON-plain by + // construction (frames arrive via JSON.parse), so null passes. + expect(validateChildFrame({ type: 'call', id: 2, global: 'tools', name: 'echo' })).toBeUndefined() + expect(validateChildFrame({ type: 'call', id: 2, global: 'tools', name: 'echo', args: null })) + .toEqual({ type: 'call', id: 2, global: 'tools', name: 'echo', args: null }) + // A missing/mistyped required field drops. + expect(validateChildFrame({ type: 'call', id: '1', global: 'tools', name: 'echo' })).toBeUndefined() + expect(validateChildFrame({ type: 'call', id: 1, global: 7, name: 'echo' })).toBeUndefined() + expect(validateChildFrame({ type: 'call', id: 1, global: 'tools' })).toBeUndefined() + }) + + it('rebuilds done frames with optional value/error', () => { + expect(validateChildFrame({ type: 'done' })).toEqual({ type: 'done' }) + expect(validateChildFrame({ type: 'done', value: 42 })).toEqual({ type: 'done', value: 42 }) + expect(validateChildFrame({ type: 'done', error: { kind: 'exception', message: 'boom' } })) + .toEqual({ type: 'done', error: { kind: 'exception', message: 'boom' } }) + expect(validateChildFrame({ type: 'done', error: { kind: 'invalid-output', message: 'lossy' } })) + .toEqual({ type: 'done', error: { kind: 'invalid-output', message: 'lossy' } }) + expect(validateChildFrame({ type: 'done', error: { kind: 'output-limit', message: 'big' } })) + .toEqual({ type: 'done', error: { kind: 'output-limit', message: 'big' } }) + expect(validateChildFrame({ type: 'done', value: 1, error: { kind: 'exception', message: 'boom' } })) + .toEqual({ type: 'done', value: 1, error: { kind: 'exception', message: 'boom' } }) + // A `value: undefined` field is dropped (JSON never carries it, but a forged + // shape might; the rebuild coalesces to the absent case). + expect(validateChildFrame({ type: 'done', value: undefined })).toEqual({ type: 'done' }) + // A missing or unrecognized kind drops the frame: the child always sends + // one of the three, so anything else is a forgery. + expect(validateChildFrame({ type: 'done', error: { message: 'boom' } })).toBeUndefined() + expect(validateChildFrame({ type: 'done', error: { kind: 'timeout', message: 'x' } })).toBeUndefined() + }) + + it('rejects malformed done frames', () => { + // error must be an object. + expect(validateChildFrame({ type: 'done', error: 'boom' })).toBeUndefined() + expect(validateChildFrame({ type: 'done', error: null })).toBeUndefined() + // error.message must be a string. + expect(validateChildFrame({ type: 'done', error: {} })).toBeUndefined() + expect(validateChildFrame({ type: 'done', error: { message: 42 } })).toBeUndefined() + }) + + it('drops non-object inputs and unknown types silently', () => { + expect(validateChildFrame(null)).toBeUndefined() + expect(validateChildFrame(undefined)).toBeUndefined() + expect(validateChildFrame(42)).toBeUndefined() + expect(validateChildFrame('str')).toBeUndefined() + expect(validateChildFrame({})).toBeUndefined() + expect(validateChildFrame({ type: 'unknown' })).toBeUndefined() + }) + + it('drops CALL frames whose args are non-finite or negative zero', () => { + // JSON.parse turns 1e400 into Infinity and preserves -0; the honest child + // rejects both before sending, so a call frame carrying one is forged. + expect(validateChildFrame({ type: 'call', id: 1, global: 'tools', name: 'x', args: { n: Infinity } })).toBeUndefined() + expect(validateChildFrame({ type: 'call', id: Infinity, global: 'tools', name: 'x', args: null })).toBeUndefined() + // Plain zero and ordinary floats pass. + expect(validateChildFrame({ type: 'call', id: 1, global: 'tools', name: 'x', args: [0, 1.5] })) + .toEqual({ type: 'call', id: 1, global: 'tools', name: 'x', args: [0, 1.5] }) + }) + + it('passes DONE values through untouched — losslessness is metered later', () => { + // validateChildFrame no longer scans done.value: an unbounded scan would + // push every member of a wide forged payload before any byte cap ran. The + // done handler's checkDoneValue folds losslessness into the metered walk. + expect(validateChildFrame({ type: 'done', value: Infinity })).toEqual({ type: 'done', value: Infinity }) + expect(validateChildFrame({ type: 'done', value: [{ x: -0 }] })).toEqual({ type: 'done', value: [{ x: -0 }] }) + expect(validateChildFrame({ type: 'done', value: [0, 1.5] })).toEqual({ type: 'done', value: [0, 1.5] }) + }) +}) + +describe('lossless-number scan', () => { + it('finds non-finite and negative-zero numbers at any depth, iteratively', () => { + expect(hasNonLosslessNumber(Infinity)).toBe(true) + expect(hasNonLosslessNumber(-Infinity)).toBe(true) + expect(hasNonLosslessNumber(NaN)).toBe(true) + expect(hasNonLosslessNumber(-0)).toBe(true) + expect(hasNonLosslessNumber({ a: [1, { b: -0 }] })).toBe(true) + expect(hasNonLosslessNumber({ a: [0, 1.5, 'x', null, true] })).toBe(false) + // Deep nesting must not overflow the stack. + let deep: unknown = 0 + for (let i = 0; i < 100000; i++) deep = [deep] + expect(hasNonLosslessNumber(deep)).toBe(false) + }) + + it('walks wide arrays and objects one member at a time', () => { + // `call.args` carries no seam byte cap, so a wide forged payload has no + // budget to be rejected against — the walk must hold one cursor per + // NESTING LEVEL, not one entry per member, or a flat payload just below + // the 256 MiB frame ceiling would allocate tens of millions of stack + // entries (and `Object.values` a second full-breadth copy). Observable + // through the boundary: a wide payload whose per-member cost the old shape + // would have paid still scans, and a violation ANYWHERE in it is found + // wherever it sits. + const wideArray = new Array(2_000_000).fill(0) as unknown[] + expect(hasNonLosslessNumber(wideArray)).toBe(false) + // Last element, so the cursor must run the whole breadth lazily. + wideArray[wideArray.length - 1] = -0 + expect(hasNonLosslessNumber(wideArray)).toBe(true) + const wideObject: Record = {} + for (let i = 0; i < 200_000; i++) wideObject[`k${i}`] = i + expect(hasNonLosslessNumber(wideObject)).toBe(false) + wideObject.last = Infinity + expect(hasNonLosslessNumber(wideObject)).toBe(true) + // Interleaved nesting: a per-level cursor must resume its parent after a + // child level ends, so a violation after a nested container is still seen. + expect(hasNonLosslessNumber([[1], { a: 2 }, NaN])).toBe(true) + }) + + it('scans only own enumerable properties', () => { + // The per-level cursor filters own keys (a prototype-carrying frame is + // impossible off JSON.parse, but the filter is what keeps the walk equal + // to what the encoder would serialize). + const withProto = Object.create({ inherited: -0 }) as Record + withProto.own = 1 + expect(hasNonLosslessNumber(withProto)).toBe(false) + }) +}) + +describe('unsafe-integer token scan', () => { + it('flags integer tokens outside the safe range, skipping strings and float forms', () => { + expect(hasUnsafeIntegerToken('{"v":9007199254740993}')).toBe(true) + // Exact beyond-safe-range tokens are lossless and pass (2**53, 2**64). + expect(hasUnsafeIntegerToken('{"v":9007199254740992}')).toBe(false) + expect(hasUnsafeIntegerToken('{"v":18446744073709551616}')).toBe(false) + // A token that parses to Infinity is trivially lossy. + expect(hasUnsafeIntegerToken(`{"v":${'9'.repeat(400)}}`)).toBe(true) + expect(hasUnsafeIntegerToken('{"v":-9007199254740993}')).toBe(true) + expect(hasUnsafeIntegerToken('{"v":9007199254740991}')).toBe(false) + expect(hasUnsafeIntegerToken('{"v":"9007199254740993"}')).toBe(false) + expect(hasUnsafeIntegerToken(String.raw`{"v":"esc\"9007199254740993"}`)).toBe(false) + expect(hasUnsafeIntegerToken('{"v":9007199254740993.0}')).toBe(false) + expect(hasUnsafeIntegerToken('{"v":9e99}')).toBe(false) + }) +}) + +describe('checkDoneValue', () => { + it('matches the exact encoded size and rejects one byte over', () => { + const cases: unknown[] = [null, true, false, 0, -1.5, 'a"b\\', [], {}, [1, 'x', null], { a: [1, 2], b: { c: 'd' } }] + for (const value of cases) { + const exact = Buffer.byteLength(JSON.stringify(value), 'utf8') + expect(checkDoneValue(value, exact), JSON.stringify(value)).toEqual({ ok: true, bytes: exact }) + expect(checkDoneValue(value, exact - 1), JSON.stringify(value)).toEqual({ ok: false, reason: 'over-budget' }) + expect(encodeJsonPlain(value)).toBe(JSON.stringify(value)) + } + }) + + it('stops early on a huge value instead of measuring it whole', () => { + const huge = { data: 'x'.repeat(1_000_000), tail: 'y' } + expect(checkDoneValue(huge, 1024)).toEqual({ ok: false, reason: 'over-budget' }) + // A forged flat array below the frame ceiling must fail BEFORE its + // elements are enqueued — the pre-enqueue bound keeps the walk O(cap). + const flat = new Array(10_000_000).fill(0) + expect(checkDoneValue(flat, 1024)).toEqual({ ok: false, reason: 'over-budget' }) + // Same bound for a wide object: braces+commas fit the cap, but the + // per-entry lower bound (quoted key + colon + value) does not, so it fails + // before any key is metered or any value enqueued. + const wide: Record = {} + for (let i = 0; i < 10; i++) wide[`k${i}`] = i + expect(checkDoneValue(wide, 12)).toEqual({ ok: false, reason: 'over-budget' }) + }) + + it('rejects an over-budget string on its length before escaping it', () => { + // A control-heavy forged string escapes to ~6x its length; the walk must + // refuse it on the cheap `length + 2` lower bound so the escaped copy is + // never allocated. Observable through the boundary: a string whose LENGTH + // already exceeds the cap fails even though every character is 1 byte. + expect(checkDoneValue(''.repeat(4096), 1024)).toEqual({ ok: false, reason: 'over-budget' }) + // The bound is a lower bound, never a false rejection: a string that fits + // exactly still passes with its exact escaped size. + expect(checkDoneValue('', 8)).toEqual({ ok: true, bytes: 8 }) + expect(checkDoneValue('', 7)).toEqual({ ok: false, reason: 'over-budget' }) + // Same lower bound for keys, checked before the key is escaped. + expect(checkDoneValue({ [''.repeat(4096)]: 1 }, 1024)).toEqual({ ok: false, reason: 'over-budget' }) + }) + + it('meters only own enumerable keys', () => { + // The walk counts keys with a `for...in` + hasOwn pass rather than + // Object.keys/entries (which allocate per member before the bound). A + // prototype-carrying forgery is impossible off JSON.parse, but the own-key + // filter is what keeps the count equal to the encoder's. + const withProto = Object.create({ inherited: 'x' }) as Record + withProto.own = 1 + expect(checkDoneValue(withProto, 1024)).toEqual({ ok: true, bytes: Buffer.byteLength('{"own":1}', 'utf8') }) + }) + + it('rejects non-finite and negative-zero numbers at any depth as non-lossless', () => { + expect(checkDoneValue(Infinity, 1024)).toEqual({ ok: false, reason: 'non-lossless' }) + expect(checkDoneValue(-Infinity, 1024)).toEqual({ ok: false, reason: 'non-lossless' }) + expect(checkDoneValue(NaN, 1024)).toEqual({ ok: false, reason: 'non-lossless' }) + expect(checkDoneValue(-0, 1024)).toEqual({ ok: false, reason: 'non-lossless' }) + expect(checkDoneValue({ a: [1, { b: -0 }] }, 1024)).toEqual({ ok: false, reason: 'non-lossless' }) + // An ordinary finite value within budget passes with its exact byte count. + const clean = { a: [0, 1.5, 'x', null, true] } + expect(checkDoneValue(clean, 1024)).toEqual({ ok: true, bytes: Buffer.byteLength(JSON.stringify(clean), 'utf8') }) + }) + + it('meters deep nesting iteratively without overflowing the stack', () => { + let deep: unknown = 0 + for (let i = 0; i < 100_000; i++) deep = [deep] + // 100000 '[' + '0' + 100000 ']' = 200001 bytes. + expect(checkDoneValue(deep, 1_000_000)).toEqual({ ok: true, bytes: 200_001 }) + }) + + it('emits exact digits for beyond-safe integral doubles', () => { + // String(2**60) prints the ROUNDED ...847000; echoing that to the child + // would change the integer. BigInt digits give the exact ...846976. + const v = JSON.parse('[1152921504606846976]') as unknown + expect(encodeJsonPlain(v)).toBe('[1152921504606846976]') + expect(checkDoneValue(v, 100)).toEqual({ ok: true, bytes: Buffer.byteLength('[1152921504606846976]', 'utf8') }) + }) +}) diff --git a/packages/code-runtime/code-runtime-python/tsconfig.json b/packages/code-runtime/code-runtime-python/tsconfig.json new file mode 100644 index 0000000000..9966c8ca8a --- /dev/null +++ b/packages/code-runtime/code-runtime-python/tsconfig.json @@ -0,0 +1,21 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/code-runtime/code-runtime-python/tsdown.config.ts b/packages/code-runtime/code-runtime-python/tsdown.config.ts new file mode 100644 index 0000000000..df5bdeae1e --- /dev/null +++ b/packages/code-runtime/code-runtime-python/tsdown.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from 'tsdown' + +/** + * Single ESM bundle. The Python-side code is not TypeScript and ships verbatim + * under `py/` (whitelisted in package.json `files`) — no build step needed. + */ +export default defineConfig({ + entry: ['lib/types/index.js', 'lib/types/invariant.js'], + outDir: 'lib', + format: ['esm'], + platform: 'node', + target: 'es2024', + fixedExtension: false, + dts: false, + clean: false, +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8f34b3587b..072d5674a3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2337,6 +2337,18 @@ importers: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis + packages/code-runtime/code-runtime-python: + devDependencies: + '@deepseek-ai/dsh-code-runtime': + specifier: workspace:^ + version: link:../code-runtime + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + cordis: + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + packages/code-runtime/code-runtime-worker: dependencies: schemastery: diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts index e0b9344cdf..0b0069ffde 100644 --- a/scripts/check-workspace-constraints.ts +++ b/scripts/check-workspace-constraints.ts @@ -107,6 +107,8 @@ const packageFileExtras: Readonly> = { '@deepseek-ai/dsh-web-app': ['cordis.patch.yml'], '@deepseek-ai/dsh-headless': ['cordis.patch.yml'], '@deepseek-ai/dsh-client-ui-theme': ['lib/styles'], + // The CPython bootstrap ships as source .py files the host spawns by path. + '@deepseek-ai/dsh-code-runtime-python': ['py/**/*.py'], '@deepseek-ai/dsh-helper': ['lib/assets'], '@deepseek-ai/dsh-pty-local': ['scripts/ensure-spawn-helper.mjs'], '@deepseek-ai/dsh-scripts': [ diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 316a4233de..e873b3df78 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -47,6 +47,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/bash/pwsh-local': { kind: 'indirect', reason: 'The executor backend delegates model rendering to dsh-tool-pwsh.' }, 'packages/code-runtime/code-runtime': { kind: 'indirect', reason: 'The service interface delegates model rendering to Code Mode in dsh-tools.' }, 'packages/code-runtime/code-runtime-worker': { kind: 'indirect', reason: 'The worker backend delegates model rendering to Code Mode in dsh-tools.' }, + 'packages/code-runtime/code-runtime-python': { kind: 'indirect', reason: 'The CPython subprocess backend delegates model rendering to Code Mode in dsh-tools.' }, 'packages/typert/registry': { kind: 'none', reason: 'Runtime type registry; consumers (cordis_inspect, wire faces, gates) own any model-visible projection of registry contents.' }, 'packages/typert/loader': { kind: 'none', reason: 'Loader integration only registers generated artifacts; consumers own any model-visible projection.' }, 'packages/client/hmr': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, diff --git a/tsconfig.host.json b/tsconfig.host.json index 5772905a9e..8962016791 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -154,6 +154,7 @@ { "path": "./packages/pty/tool-bash-persistent" }, { "path": "./packages/pty/tool-pty" }, { "path": "./packages/code-runtime/code-runtime" }, + { "path": "./packages/code-runtime/code-runtime-python" }, { "path": "./packages/code-runtime/code-runtime-worker" }, { "path": "./packages/llm/llm-deepseek" }, { "path": "./packages/llm/llm-pi-ai" }, From 034e4f2d3f96d4f2e3a2cf338cbd5bc0aef4cc9b Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Fri, 31 Jul 2026 19:03:18 +0800 Subject: [PATCH 02/32] fix(code-runtime): match DUNDER_MEMBER to its distinct-pair contract The seam's dunder-member test (added in the base seam PR) asserts `DUNDER_MEMBER.test('____')` is true and `test('__')` is false, but the regex `/^__.+__$/` rejected `____`: the `.+` demanded a non-empty middle, while `____` is two adjacent `__` pairs with an empty middle. Widen to `/^__.*__$/` so a name with distinct leading and trailing `__` pairs matches whether or not it has a middle, and align the JSDoc. Regenerate the cordis service catalog for the merged seam source line. --- docs/cordis-catalog/services.md | 2 +- packages/code-runtime/code-runtime/src/index.ts | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index b9a0bc965a..36bbd7e3d4 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -398,7 +398,7 @@ abstract run(request: CodeRunRequest): Promise Types: [CodeRunRequest](../core-data-structures/code-runtime.md) · [CodeRunResult](../core-data-structures/code-runtime.md) -Source: [`packages/code-runtime/code-runtime/src/index.ts:104`](../../packages/code-runtime/code-runtime/src/index.ts) +Source: [`packages/code-runtime/code-runtime/src/index.ts:105`](../../packages/code-runtime/code-runtime/src/index.ts) ## `ctx.commands` — `CommandService` diff --git a/packages/code-runtime/code-runtime/src/index.ts b/packages/code-runtime/code-runtime/src/index.ts index 3428b5c0e4..1f4c1ad287 100644 --- a/packages/code-runtime/code-runtime/src/index.ts +++ b/packages/code-runtime/code-runtime/src/index.ts @@ -59,10 +59,11 @@ export const RESERVED_ERROR_MEMBERS: ReadonlySet = new Set([ ]) /** - * Dunder form (`__x__`, non-empty middle): object-protocol slots in Python, + * Dunder form (`__x__`, distinct leading and trailing `__` pairs, so at least + * four characters; the middle may be empty): object-protocol slots in Python, * refused as {@link RESERVED_ERROR_MEMBERS | error members} on every backend. */ -export const DUNDER_MEMBER = /^__.+__$/ +export const DUNDER_MEMBER = /^__.*__$/ /** * Reserved words of every portable target language (ECMAScript ∪ Python), From b3e29e7af55da7603f78a5c9718b0dc15a6f23f5 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Fri, 31 Jul 2026 19:11:27 +0800 Subject: [PATCH 03/32] fix(code-runtime-python): satisfy static gates for the protocol-only layer - Drop the unused @deepseek-ai/dsh-code-runtime dependency: this layer imports nothing from the seam (protocol.ts has no imports; the invariant companion uses only cordis and dsh-invariants). The backend-core PR re-adds it when PythonCodeRuntime consumes the seam. Fixes knip. - Point the Agent Note's cross-reference to the seam note at the English target on both language sides, per the bilingual-pairing contract (only the language switcher flips to .zh.md). Re-record the sidecar. - Add the Known Limitations section both READMEs require, covering the cross-language guard's scope and the deferred runtime implementation. - Regenerate the module graph for the dropped dependency edge. --- .../2026-07-31-code-runtime-python-fd3-protocol.i18n.yaml | 2 +- .../2026-07-31-code-runtime-python-fd3-protocol.zh.md | 2 +- docs/module-graph.md | 4 ++-- packages/code-runtime/code-runtime-python/README.i18n.yaml | 4 ++-- packages/code-runtime/code-runtime-python/README.md | 5 +++++ packages/code-runtime/code-runtime-python/README.zh.md | 5 +++++ packages/code-runtime/code-runtime-python/package.json | 2 -- pnpm-lock.yaml | 3 --- 8 files changed, 16 insertions(+), 11 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.i18n.yaml index bd811f506e..b67d7e9349 100644 --- a/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.md 2026-07-31-code-runtime-python-fd3-protocol.md: 32cc80278af6b5f894c8d972854dae8c92ac63b7 -2026-07-31-code-runtime-python-fd3-protocol.zh.md: e7cf551b1dc84656c1eaf49280052c732839942b +2026-07-31-code-runtime-python-fd3-protocol.zh.md: ea8df78826dabf64c0132dc61952c533481e1444 diff --git a/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.zh.md b/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.zh.md index e7cf551b1d..ea8df78826 100644 --- a/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.zh.md @@ -8,7 +8,7 @@ Status: implemented CPython code-runtime 后端(`@deepseek-ai/dsh-code-runtime-python`,分多个 PR 落地)在一个全新的 `python3 -I` 子进程里运行每个模型程序,并把 binding 调用和完成值通过子进程的 fd 3 桥接。这条通道需要两侧一致的 wire protocol,而 host 不能信任它:模型代码对 fd 3 有完全访问权、可以伪造任意帧,所以每个入站帧都是 host 必须先校验并重建才能读取的敌意输入。协议还必须承载无深度限制的 lossless JSON,因为 seam 的 `CodeJsonValue` 深度无界,而 `JSON.stringify`/`json.dumps` 都有递归深度限制。 -本层只交付这个协议,使得庞大的 `PythonCodeRuntime` 实现及其真子进程集成测试能落在一个已 review 的 wire contract 之上,而不是与它揉在一起到达。父 stack 把 [#436](https://github.com/deepseek-harness/deepseek-harness/pull/436)——一个 9000 行的单一 PR——拆成可 review 的层;本 PR 是协议层,base 是 [seam 扩展](2026-07-31-code-runtime-portable-identifier-seam.zh.md)。 +本层只交付这个协议,使得庞大的 `PythonCodeRuntime` 实现及其真子进程集成测试能落在一个已 review 的 wire contract 之上,而不是与它揉在一起到达。父 stack 把 [#436](https://github.com/deepseek-harness/deepseek-harness/pull/436)——一个 9000 行的单一 PR——拆成可 review 的层;本 PR 是协议层,base 是 [seam 扩展](2026-07-31-code-runtime-portable-identifier-seam.md)。 ## Decision diff --git a/docs/module-graph.md b/docs/module-graph.md index 3884c4b58b..1706f229b7 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -302,6 +302,7 @@ flowchart TD pkg_client_web --> pkg_invariants pkg_client_web_react --> pkg_invariants pkg_code_runtime --> pkg_invariants + pkg_code_runtime_python --> pkg_invariants pkg_jsonrpc_demo --> pkg_invariants pkg_host_apiproxy --> pkg_invariants pkg_host_directory_picker --> pkg_invariants @@ -334,8 +335,6 @@ flowchart TD pkg_client_ui_trajectory --> pkg_client_runtime pkg_client_ui_trajectory --> pkg_client_ui_primitives pkg_client_ui_trajectory --> pkg_invariants - pkg_code_runtime_python --> pkg_code_runtime - pkg_code_runtime_python --> pkg_invariants pkg_credentials --> pkg_brand pkg_credentials --> pkg_invariants pkg_frontend_static --> pkg_host_webserver @@ -1135,6 +1134,7 @@ flowchart TD | [`client-web`](../packages/client/web) | `client` | [`invariants`](../packages/support/invariants) | | [`client-web-react`](../packages/client/web-react) | `client` | [`invariants`](../packages/support/invariants) | | [`code-runtime`](../packages/code-runtime/code-runtime) | `code-runtime` | [`invariants`](../packages/support/invariants) | +| [`code-runtime-python`](../packages/code-runtime/code-runtime-python) | `code-runtime` | [`invariants`](../packages/support/invariants) | | [`jsonrpc-demo`](../packages/examples/jsonrpc-demo) | `examples` | [`invariants`](../packages/support/invariants) | | [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`invariants`](../packages/support/invariants) | | [`host-directory-picker`](../packages/host/directory-picker) | `host` | [`invariants`](../packages/support/invariants) | diff --git a/packages/code-runtime/code-runtime-python/README.i18n.yaml b/packages/code-runtime/code-runtime-python/README.i18n.yaml index d13849f8b0..100b2b8f9f 100644 --- a/packages/code-runtime/code-runtime-python/README.i18n.yaml +++ b/packages/code-runtime/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/code-runtime/code-runtime-python/README.md -README.md: 8a394f18f8e27addf0f4a7530cbdb31b9d629bb9 -README.zh.md: 1c246c952492eb574b6fc6c6bc6c76fffcabe01e +README.md: f68a45a5420469555eeaf9f88463fbda547d1a1d +README.zh.md: 439d4ef87a26202cc6b537651fa0b768c28637a4 diff --git a/packages/code-runtime/code-runtime-python/README.md b/packages/code-runtime/code-runtime-python/README.md index 8a394f18f8..f68a45a542 100644 --- a/packages/code-runtime/code-runtime-python/README.md +++ b/packages/code-runtime/code-runtime-python/README.md @@ -22,3 +22,8 @@ Indirectly, through Code Mode in [`dsh-tools`](../../core/tools/README.md), whic #### KV Cache effect No direct invalidation; the named consumer owns any request-prefix changes. + +## Known Limitations and Deferred Work + +- **The cross-language guard covers only the two runtime-executed surfaces** — `PROTOCOL_FD` and the log truncation marker. The `TypedDict` frame shapes in `py/protocol.py` mirror `src/protocol.ts` by review, not by an automated check: comparing type declarations across TypeScript and Python has no mechanical equivalent here, so a future shape drift is caught by review plus the backend's real-subprocess suite rather than this package's tests. +- **The `PythonCodeRuntime` implementation and its Python-side JSON codec are not in this layer** — they ship in the backend-core PR on top of this branch; `src/index.ts` re-exports only the protocol vocabulary until then. diff --git a/packages/code-runtime/code-runtime-python/README.zh.md b/packages/code-runtime/code-runtime-python/README.zh.md index 1c246c9524..439d4ef87a 100644 --- a/packages/code-runtime/code-runtime-python/README.zh.md +++ b/packages/code-runtime/code-runtime-python/README.zh.md @@ -22,3 +22,8 @@ Indirectly, through Code Mode in [`dsh-tools`](../../core/tools/README.md), whic #### KV Cache effect No direct invalidation; the named consumer owns any request-prefix changes. + +## Known Limitations and Deferred Work + +- **跨语言 guard 只覆盖两个运行时执行的面** —— `PROTOCOL_FD` 与日志截断标记。`py/protocol.py` 中的 `TypedDict` 帧形状靠 review 而非自动化检查来镜像 `src/protocol.ts`:跨 TypeScript 与 Python 比较类型声明在此无机械等价物,故未来的形状漂移由 review 加后端真子进程套件捕获,而非本包的测试。 +- **`PythonCodeRuntime` 实现与 Python 侧 JSON codec 不在本层** —— 它们在基于本分支的 backend-core PR 中交付;在那之前 `src/index.ts` 只 re-export 协议词汇。 diff --git a/packages/code-runtime/code-runtime-python/package.json b/packages/code-runtime/code-runtime-python/package.json index dc72d0c749..c94beb2997 100644 --- a/packages/code-runtime/code-runtime-python/package.json +++ b/packages/code-runtime/code-runtime-python/package.json @@ -27,12 +27,10 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-code-runtime": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { - "@deepseek-ai/dsh-code-runtime": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "cordis": "^4.0.0-rc.7" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 072d5674a3..915d150584 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2339,9 +2339,6 @@ importers: packages/code-runtime/code-runtime-python: devDependencies: - '@deepseek-ai/dsh-code-runtime': - specifier: workspace:^ - version: link:../code-runtime '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants From f0d669883fb4a498927c7a4c923721ecb50be22c Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Fri, 31 Jul 2026 19:20:28 +0800 Subject: [PATCH 04/32] fix(code-runtime-python): close coverage gap and tighten the wire mirror MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Cover the log-frame `truncated` rebuild branch: assert a literal-true flag rides along and any other value (1, string, false) is dropped, closing the protocol.ts branch the coverage gate flagged. - Correct encodeJsonPlain's JSDoc: it matches compact JSON.stringify EXCEPT on a beyond-safe-range integral double, where it emits the exact BigInt digits (`...846976`) rather than the rounded `...847000` — the divergence the "emits exact digits" test pins. - Declare py/protocol.py's `global`-bearing frames (Namespace, CallMessage) with functional TypedDict syntax so they carry the real wire key instead of a `global_` attribute the wire never sends, and split optional-field messages (Namespace/LogMessage/DoneMessage) into a required base plus a total=False subclass so `type` and other required fields cannot be dropped. Widen HostToChild to include the boot and run frames the host sends before replies. - Reword the mirror e2e's py/ directory assertion to describe the source-tree layout it actually checks. --- .../code-runtime-python/py/protocol.py | 76 +++++++++++-------- .../code-runtime-python/src/protocol.ts | 6 +- .../tests/protocol-mirror.e2e.ts | 7 +- .../tests/protocol.spec.ts | 12 +++ 4 files changed, 65 insertions(+), 36 deletions(-) diff --git a/packages/code-runtime/code-runtime-python/py/protocol.py b/packages/code-runtime/code-runtime-python/py/protocol.py index 0445726cac..e227cd7c53 100644 --- a/packages/code-runtime/code-runtime-python/py/protocol.py +++ b/packages/code-runtime/code-runtime-python/py/protocol.py @@ -3,6 +3,13 @@ Mirrors ``src/protocol.ts``. Frames travel on fd 3 as JSON-lines (one JSON object per line). The host validates every inbound frame; this side trusts host replies. + +The wire uses the JSON key ``global`` (a Python keyword), so the frame +``TypedDict``s that carry it are declared with the functional syntax rather than +class bodies: a class attribute cannot be named ``global``, and a ``global_`` +attribute would describe a key the wire never sends. Optional-field messages +pair a required base with a ``total=False`` subclass so a required field such as +``type`` cannot be dropped while ``value``/``error``/``truncated`` stay optional. """ from __future__ import annotations @@ -15,17 +22,6 @@ from typing import Any, Literal, TypedDict, Union PROTOCOL_FD = 3 -class BootMessage(TypedDict): - """Host → child, first frame on fd 3. Carries every cap and the namespaces.""" - - type: Literal["boot"] - cpuSeconds: int - addressSpaceBytes: int - maxLogBytes: int - maxValueBytes: int - namespaces: list["Namespace"] - - class ErrorClass(TypedDict): """A namespace's program-visible exception class: rejected calls raise its instances carrying the failed member name on ``memberNameProperty``.""" @@ -34,13 +30,27 @@ class ErrorClass(TypedDict): memberNameProperty: str -class Namespace(TypedDict, total=False): - """One binding namespace declaration: the global name, its function names, - and an optional program-visible ``errorClass`` for rejected calls.""" +# ``global`` is a Python keyword, so the required part is declared functionally +# to hold the real wire key; ``errorClass`` is optional per the TS `errorClass?`. +_NamespaceRequired = TypedDict("_NamespaceRequired", {"global": str, "names": "list[str]"}) - global_: str # required; renamed on the wire: JSON field is ``global`` (Python keyword collision) - names: list[str] # required - errorClass: ErrorClass # optional — mirrors the TS `errorClass?` + +class Namespace(_NamespaceRequired, total=False): + """One binding namespace declaration: the ``global`` name, its function + ``names``, and an optional program-visible ``errorClass`` for rejected calls.""" + + errorClass: ErrorClass + + +class BootMessage(TypedDict): + """Host → child, first frame on fd 3. Carries every cap and the namespaces.""" + + type: Literal["boot"] + cpuSeconds: int + addressSpaceBytes: int + maxLogBytes: int + maxValueBytes: int + namespaces: "list[Namespace]" class RunMessage(TypedDict): @@ -56,17 +66,17 @@ class BootAckMessage(TypedDict): type: Literal["boot-ack"] -class CallMessage(TypedDict): - """Child → host: one bridged binding call from the model program.""" - - type: Literal["call"] - id: int - global_: str # wire field is ``global`` - name: str - args: Any +# ``global`` wire key: whole message declared functionally, all fields required. +CallMessage = TypedDict( + "CallMessage", + {"type": Literal["call"], "id": int, "global": str, "name": str, "args": Any}, +) -class LogMessage(TypedDict, total=False): +_LogMessageRequired = TypedDict("_LogMessageRequired", {"type": Literal["log"], "text": str}) + + +class LogMessage(_LogMessageRequired, total=False): """Child → host: one captured text chunk, streamed eagerly. ``truncated`` is set only on the frame that IS the child ledger's truncation @@ -74,9 +84,7 @@ class LogMessage(TypedDict, total=False): the child did — mirrors the TS `truncated?`. """ - type: Literal["log"] # required - text: str # required - truncated: bool # optional + truncated: bool class DoneErrorField(TypedDict): @@ -87,10 +95,12 @@ class DoneErrorField(TypedDict): message: str -class DoneMessage(TypedDict, total=False): +_DoneMessageRequired = TypedDict("_DoneMessageRequired", {"type": Literal["done"]}) + + +class DoneMessage(_DoneMessageRequired, total=False): """Child → host: the program settled. ``value`` and ``error`` are optional per the TS mirror.""" - type: Literal["done"] # required — TypedDict(total=False) allows this via a required subclass in Py 3.11+; MVP keeps it flat value: Any error: DoneErrorField @@ -113,7 +123,9 @@ class ReplyErr(TypedDict): ReplyMessage = Union[ReplyOk, ReplyErr] -HostToChild = ReplyMessage +# The host sends ``boot`` and ``run`` before any ``reply``, so the child-facing +# inbound union covers all three, not replies alone. +HostToChild = Union[BootMessage, RunMessage, ReplyMessage] def log_truncation_marker(max_bytes: int) -> str: diff --git a/packages/code-runtime/code-runtime-python/src/protocol.ts b/packages/code-runtime/code-runtime-python/src/protocol.ts index c935a1153a..d73f92bdd6 100644 --- a/packages/code-runtime/code-runtime-python/src/protocol.ts +++ b/packages/code-runtime/code-runtime-python/src/protocol.ts @@ -124,7 +124,11 @@ export function logTruncationMarker(maxBytes: number): string { * (the worker backend's wire is equally stack-safe). Callers must pass a value * produced by `JSON.parse` (or equally JSON-plain): only `null`, finite * numbers, booleans, strings, dense arrays, and plain objects — this encoder - * validates nothing. Output is byte-identical to compact `JSON.stringify`. + * validates nothing. Output matches compact `JSON.stringify` byte for byte + * EXCEPT on an integral double beyond the safe range, where {@link scalarJson} + * emits the exact integer's BigInt digits rather than `JSON.stringify`'s rounded + * spelling (`1152921504606846976`, not `...847000`) so the seam's lossless-JSON + * promise holds across the wire. * @param value - a JSON-plain value (e.g. straight from `JSON.parse`). * @returns the compact JSON encoding. */ diff --git a/packages/code-runtime/code-runtime-python/tests/protocol-mirror.e2e.ts b/packages/code-runtime/code-runtime-python/tests/protocol-mirror.e2e.ts index 9ec1091286..d79a659c09 100644 --- a/packages/code-runtime/code-runtime-python/tests/protocol-mirror.e2e.ts +++ b/packages/code-runtime/code-runtime-python/tests/protocol-mirror.e2e.ts @@ -53,8 +53,9 @@ describe.skipIf(!python3Available)('protocol.py mirrors protocol.ts at runtime', }) it('names the py/ directory that ships with the package', () => { - // The package.json `files` list ships `py/**/*.py`; the mirror test resolves - // the marker source relative to the built package, so the directory must exist - // beside the tests even when python3 is absent from the runner. + // Resolves py/ relative to this test file; the same directory ships in the + // package.json `files` whitelist (`py/**/*.py`). The tests/ directory itself + // is not published — this asserts the source-tree layout the mirror test + // depends on, so it holds even when python3 is absent from the runner. expect(existsSync(pyDir)).toBe(true) }) diff --git a/packages/code-runtime/code-runtime-python/tests/protocol.spec.ts b/packages/code-runtime/code-runtime-python/tests/protocol.spec.ts index d3782b6c95..89ad14eae6 100644 --- a/packages/code-runtime/code-runtime-python/tests/protocol.spec.ts +++ b/packages/code-runtime/code-runtime-python/tests/protocol.spec.ts @@ -22,6 +22,18 @@ describe('validateChildFrame', () => { expect(validateChildFrame({ type: 'log' })).toBeUndefined() }) + it('carries a log frame truncation flag only for the literal true', () => { + // The child's own ledger marker sets `truncated: true`; the host rebuilds + // it so it stops capturing at the same point. + expect(validateChildFrame({ type: 'log', text: 'x', truncated: true })) + .toEqual({ type: 'log', text: 'x', truncated: true }) + // Any other truthy or non-boolean value is a forgery and is dropped from + // the rebuild — otherwise it would silence capture for the rest of the run. + expect(validateChildFrame({ type: 'log', text: 'x', truncated: 1 })).toEqual({ type: 'log', text: 'x' }) + expect(validateChildFrame({ type: 'log', text: 'x', truncated: 'yes' })).toEqual({ type: 'log', text: 'x' }) + expect(validateChildFrame({ type: 'log', text: 'x', truncated: false })).toEqual({ type: 'log', text: 'x' }) + }) + it('rebuilds call frames with a numeric id, string global, and string name', () => { expect(validateChildFrame({ type: 'call', id: 1, global: 'tools', name: 'echo', args: { x: 1 } })) .toEqual({ type: 'call', id: 1, global: 'tools', name: 'echo', args: { x: 1 } }) From 98ebe1315d5a264e30989c845373d961dc7e7495 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Fri, 31 Jul 2026 19:22:50 +0800 Subject: [PATCH 05/32] fix(code-runtime-python): reject -0 call ids and document done value/error - Drop a CALL frame whose id is negative zero: it passes Number.isFinite but the reply re-serializes it as `0`, colliding with a real call id `0`. The honest child never issues `-0`. - Document that validateChildFrame preserves a forged done frame's value and error together on purpose, so consumers must check error before value. --- .../code-runtime-python/src/protocol.ts | 14 ++++++++++---- .../code-runtime-python/tests/protocol.spec.ts | 10 ++++++++++ 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/packages/code-runtime/code-runtime-python/src/protocol.ts b/packages/code-runtime/code-runtime-python/src/protocol.ts index d73f92bdd6..29c790eaec 100644 --- a/packages/code-runtime/code-runtime-python/src/protocol.ts +++ b/packages/code-runtime/code-runtime-python/src/protocol.ts @@ -82,8 +82,11 @@ interface LogMessage { * (traceback text), an `invalid-output` (completion value was not lossless * JSON), or an `output-limit` (serialized completion exceeded the configured * cap); wall/CPU budgets, aborts, and substrate death are observed host-side. - * `value` is present only on a clean completion that produced one, and crosses - * as exact lossless JSON — never substituted or truncated. + * From the honest child `value` is present only on a clean completion that + * produced one, and crosses as exact lossless JSON — never substituted or + * truncated. A forged frame CAN carry both `value` and `error`; + * {@link validateChildFrame} preserves both rather than guessing which to drop, + * so a consumer MUST check `error` first and ignore `value` when it is set. */ interface DoneMessage { type: 'done' @@ -387,8 +390,11 @@ export function validateChildFrame(raw: unknown): ChildToHost | undefined { case 'call': { // The id must be a finite number: it is echoed verbatim into the reply // frame, and a forged `1e400` id (Infinity after JSON.parse) would make - // the reply unencodable as strict JSON. - if (typeof m.id !== 'number' || !Number.isFinite(m.id) || typeof m.global !== 'string' || typeof m.name !== 'string') return undefined + // the reply unencodable as strict JSON. Negative zero is rejected too: + // it passes `Number.isFinite`, but the reply re-serializes it as `0` + // (`JSON.stringify({id:-0})` is `{"id":0}`), colliding with a real call + // whose id is `0` — the honest child never issues `-0`. + if (typeof m.id !== 'number' || !Number.isFinite(m.id) || Object.is(m.id, -0) || typeof m.global !== 'string' || typeof m.name !== 'string') return undefined // A forged frame can omit `args` entirely; rebuilding it as `undefined` // would invoke the binding with a non-JSON value, bypassing the // lossless-JSON argument boundary. Any PRESENT value is JSON-plain by diff --git a/packages/code-runtime/code-runtime-python/tests/protocol.spec.ts b/packages/code-runtime/code-runtime-python/tests/protocol.spec.ts index 89ad14eae6..dc0a01d47d 100644 --- a/packages/code-runtime/code-runtime-python/tests/protocol.spec.ts +++ b/packages/code-runtime/code-runtime-python/tests/protocol.spec.ts @@ -98,6 +98,16 @@ describe('validateChildFrame', () => { .toEqual({ type: 'call', id: 1, global: 'tools', name: 'x', args: [0, 1.5] }) }) + it('drops a CALL frame whose id is negative zero', () => { + // `-0` passes Number.isFinite, but the reply re-serializes it as `0` + // (JSON.stringify({id:-0}) === '{"id":0}'), so a forged `-0` id would + // collide with a real call whose id is `0`. The honest child never sends it. + expect(validateChildFrame({ type: 'call', id: -0, global: 'tools', name: 'x', args: null })).toBeUndefined() + // Plain positive zero is a legitimate id and passes. + expect(validateChildFrame({ type: 'call', id: 0, global: 'tools', name: 'x', args: null })) + .toEqual({ type: 'call', id: 0, global: 'tools', name: 'x', args: null }) + }) + it('passes DONE values through untouched — losslessness is metered later', () => { // validateChildFrame no longer scans done.value: an unbounded scan would // push every member of a wide forged payload before any byte cap ran. The From 8a77f201f2e5f27a4da880d77a2b9c26f3d10f03 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Fri, 31 Jul 2026 22:56:28 +0800 Subject: [PATCH 06/32] fix(code-runtime): keep the DUNDER_MEMBER fix line-neutral in the seam The base seam branch still carries the buggy /^__.+__$/ (rejects `____`, which its own reserved.spec asserts must match), so this stacked branch must keep the /^__.*__$/ correction to stay green. Reword the JSDoc to the same line count as the base so the CodeRuntime class does not shift, leaving the cordis services catalog anchor identical to the base and confining this branch's footprint on the seam file to the single regex character. --- docs/cordis-catalog/services.md | 2 +- packages/code-runtime/code-runtime/src/index.ts | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 36bbd7e3d4..b9a0bc965a 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -398,7 +398,7 @@ abstract run(request: CodeRunRequest): Promise Types: [CodeRunRequest](../core-data-structures/code-runtime.md) · [CodeRunResult](../core-data-structures/code-runtime.md) -Source: [`packages/code-runtime/code-runtime/src/index.ts:105`](../../packages/code-runtime/code-runtime/src/index.ts) +Source: [`packages/code-runtime/code-runtime/src/index.ts:104`](../../packages/code-runtime/code-runtime/src/index.ts) ## `ctx.commands` — `CommandService` diff --git a/packages/code-runtime/code-runtime/src/index.ts b/packages/code-runtime/code-runtime/src/index.ts index 1f4c1ad287..3555dbfa23 100644 --- a/packages/code-runtime/code-runtime/src/index.ts +++ b/packages/code-runtime/code-runtime/src/index.ts @@ -59,9 +59,8 @@ export const RESERVED_ERROR_MEMBERS: ReadonlySet = new Set([ ]) /** - * Dunder form (`__x__`, distinct leading and trailing `__` pairs, so at least - * four characters; the middle may be empty): object-protocol slots in Python, - * refused as {@link RESERVED_ERROR_MEMBERS | error members} on every backend. + * Dunder form (`__…__`, two `__` pairs with an optionally empty middle): object-protocol + * slots in Python, refused as {@link RESERVED_ERROR_MEMBERS | error members} on every backend. */ export const DUNDER_MEMBER = /^__.*__$/ From 31506dec2dcf17b23582abc5c76e43a7ff6379ac Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Sun, 2 Aug 2026 14:04:33 +0800 Subject: [PATCH 07/32] fix(code-runtime-python): correct Chinese translation quality MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Translate README.zh.md's Model Experience body and KV Cache line, which were left verbatim in English. - Convert half-width punctuation to full-width across the README.zh.md Known Limitations bullets and the entire Agent Note Chinese side, per docs/i18n translation-rules.md Typography (MUST use ,。:()in Chinese prose). - Re-record both README and Agent Note i18n.yaml pairing hashes. - Reword the workspace-constraints extra-files comment: this layer's py/ ships only the wire-protocol mirror; the spawned bootstrap arrives later. --- ...code-runtime-python-fd3-protocol.i18n.yaml | 2 +- ...-31-code-runtime-python-fd3-protocol.zh.md | 26 +++++++++---------- .../code-runtime-python/README.i18n.yaml | 2 +- .../code-runtime-python/README.zh.md | 8 +++--- 4 files changed, 19 insertions(+), 19 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.i18n.yaml index b67d7e9349..9ca001afdc 100644 --- a/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.md 2026-07-31-code-runtime-python-fd3-protocol.md: 32cc80278af6b5f894c8d972854dae8c92ac63b7 -2026-07-31-code-runtime-python-fd3-protocol.zh.md: ea8df78826dabf64c0132dc61952c533481e1444 +2026-07-31-code-runtime-python-fd3-protocol.zh.md: 24bb9dbb7b8df03c5c82c551449f49b4d306f248 diff --git a/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.zh.md b/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.zh.md index ea8df78826..24bb9dbb7b 100644 --- a/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.zh.md @@ -6,38 +6,38 @@ Status: implemented ## Problem -CPython code-runtime 后端(`@deepseek-ai/dsh-code-runtime-python`,分多个 PR 落地)在一个全新的 `python3 -I` 子进程里运行每个模型程序,并把 binding 调用和完成值通过子进程的 fd 3 桥接。这条通道需要两侧一致的 wire protocol,而 host 不能信任它:模型代码对 fd 3 有完全访问权、可以伪造任意帧,所以每个入站帧都是 host 必须先校验并重建才能读取的敌意输入。协议还必须承载无深度限制的 lossless JSON,因为 seam 的 `CodeJsonValue` 深度无界,而 `JSON.stringify`/`json.dumps` 都有递归深度限制。 +CPython code-runtime 后端(`@deepseek-ai/dsh-code-runtime-python`,分多个 PR 落地)在一个全新的 `python3 -I` 子进程里运行每个模型程序,并把 binding 调用和完成值通过子进程的 fd 3 桥接。这条通道需要两侧一致的 wire protocol,而 host 不能信任它:模型代码对 fd 3 有完全访问权、可以伪造任意帧,所以每个入站帧都是 host 必须先校验并重建才能读取的敌意输入。协议还必须承载无深度限制的 lossless JSON,因为 seam 的 `CodeJsonValue` 深度无界,而 `JSON.stringify`/`json.dumps` 都有递归深度限制。 -本层只交付这个协议,使得庞大的 `PythonCodeRuntime` 实现及其真子进程集成测试能落在一个已 review 的 wire contract 之上,而不是与它揉在一起到达。父 stack 把 [#436](https://github.com/deepseek-harness/deepseek-harness/pull/436)——一个 9000 行的单一 PR——拆成可 review 的层;本 PR 是协议层,base 是 [seam 扩展](2026-07-31-code-runtime-portable-identifier-seam.md)。 +本层只交付这个协议,使得庞大的 `PythonCodeRuntime` 实现及其真子进程集成测试能落在一个已 review 的 wire contract 之上,而不是与它揉在一起到达。父 stack 把 [#436](https://github.com/deepseek-harness/deepseek-harness/pull/436)——一个 9000 行的单一 PR——拆成可 review 的层;本 PR 是协议层,base 是 [seam 扩展](2026-07-31-code-runtime-portable-identifier-seam.md)。 ## Decision -`src/protocol.ts` 是 wire vocabulary 的 host 侧及其敌意帧编解码: +`src/protocol.ts` 是 wire vocabulary 的 host 侧及其敌意帧编解码: -- **`validateChildFrame`** 对每个入站帧做形状校验并重建。编译期 union 在 fd 3 上毫无意义——伪造帧可携带 `null`、被污染的字段,或省略必需字段——所以每个被接受的帧都逐字段重建:伪造的额外字段绝不随行,非有限的 call id 绝不会被回显进 reply,垃圾返回 `undefined` 被丢弃,而不是在 host 的 message handler 里抛错。 -- **`encodeJsonPlain` / `checkDoneValue` / `hasUnsafeIntegerToken` / `hasNonLosslessNumber`** 是 lossless-JSON 编解码器与计量器。它们迭代遍历(显式栈,非递归),使低于字节预算的深层值能完整穿越;`checkDoneValue` 把字节计量和数字无损性折进一次有界遍历,在把子节点入栈之前就拒绝超预算 payload,防止一个低于帧上限的伪造值迫使 host 分配数百 MB。超出安全范围的整数型 double 通过 `BigInt` 数字序列化,穿越的是精确整数而非 `String()` 的舍入形式。 +- **`validateChildFrame`** 对每个入站帧做形状校验并重建。编译期 union 在 fd 3 上毫无意义——伪造帧可携带 `null`、被污染的字段,或省略必需字段——所以每个被接受的帧都逐字段重建:伪造的额外字段绝不随行,非有限的 call id 绝不会被回显进 reply,垃圾返回 `undefined` 被丢弃,而不是在 host 的 message handler 里抛错。 +- **`encodeJsonPlain` / `checkDoneValue` / `hasUnsafeIntegerToken` / `hasNonLosslessNumber`** 是 lossless-JSON 编解码器与计量器。它们迭代遍历(显式栈,非递归),使低于字节预算的深层值能完整穿越;`checkDoneValue` 把字节计量和数字无损性折进一次有界遍历,在把子节点入栈之前就拒绝超预算 payload,防止一个低于帧上限的伪造值迫使 host 分配数百 MB。超出安全范围的整数型 double 通过 `BigInt` 数字序列化,穿越的是精确整数而非 `String()` 的舍入形式。 - **`logTruncationMarker`** 产出日志 ledger 耗尽字节预算时发出的带内标记文本。 -`py/protocol.py` 用 `TypedDict` 镜像消息形状,并重新声明两侧都会 EXECUTE 的两个面——`PROTOCOL_FD = 3` 与 `log_truncation_marker`——文本逐字节一致。 +`py/protocol.py` 用 `TypedDict` 镜像消息形状,并重新声明两侧都会 EXECUTE 的两个面——`PROTOCOL_FD = 3` 与 `log_truncation_marker`——文本逐字节一致。 -包骨架(`package.json`、`tsconfig.json`、`tsdown.config.ts`、`src/index.ts`、`src/invariant.ts`、README 三件套)在此交付,而非放到后续 stack 层:`check-workspace-constraints` 无条件读取每个 `packages//` 的 package.json,coverage 与 invariant-topology gate 也要求包在其目录出现的那一刻即存在且可构建。后续的 backend-core PR 会用 `PythonCodeRuntime` 扩展 `src/index.ts` 并增补 `package.json` 的依赖;因为它 base 在本分支上,那些是编辑,不是冲突。 +包骨架(`package.json`、`tsconfig.json`、`tsdown.config.ts`、`src/index.ts`、`src/invariant.ts`、README 三件套)在此交付,而非放到后续 stack 层:`check-workspace-constraints` 无条件读取每个 `packages//` 的 package.json,coverage 与 invariant-topology gate 也要求包在其目录出现的那一刻即存在且可构建。后续的 backend-core PR 会用 `PythonCodeRuntime` 扩展 `src/index.ts` 并增补 `package.json` 的依赖;因为它 base 在本分支上,那些是编辑,不是冲突。 ## Wire contract -帧是 fd 3 上的 JSON-lines,每行一个对象,让 stdout/stderr 空出给程序自己的输出。Child → host:`boot-ack`、`call`、`log`、`done`。Host → child:`boot`(首帧)、`run`(在 `boot-ack` 之后)、以及每个 `call` 对应一个 `reply`。`log` 帧的 `truncated` 标志标记那个本身就是子进程 ledger 截断标记的帧,使 host 在与子进程相同的点停止捕获,而不是从自己的预算去推断。`done.error.kind` 是 `exception`、`invalid-output`、`output-limit` 之一;wall/CPU 预算、abort、substrate 死亡都在 host 侧观测,不作为帧携带。 +帧是 fd 3 上的 JSON-lines,每行一个对象,让 stdout/stderr 空出给程序自己的输出。Child → host:`boot-ack`、`call`、`log`、`done`。Host → child:`boot`(首帧)、`run`(在 `boot-ack` 之后)、以及每个 `call` 对应一个 `reply`。`log` 帧的 `truncated` 标志标记那个本身就是子进程 ledger 截断标记的帧,使 host 在与子进程相同的点停止捕获,而不是从自己的预算去推断。`done.error.kind` 是 `exception`、`invalid-output`、`output-limit` 之一;wall/CPU 预算、abort、substrate 死亡都在 host 侧观测,不作为帧携带。 ## Mirror alignment -#436 的 round-12 review 发现 `py/protocol.py` 相对 `src/protocol.ts` 有三处声明陈旧——`LogMessage` 缺 `truncated`、`DoneMessage.error` 缺 `kind`、`Namespace` 缺可选的 `errorClass`。本 PR 在搬运该文件时对齐了这三处,不把陈旧镜像带过来。由于这些声明是 `TypedDict`(在受信任的 Python 侧无运行时强制),自动化 guard 只覆盖两侧都会执行的部分:`tests/protocol-mirror.e2e.ts` 启动一个真实 `python3`,从 `py/protocol.py` 读取 `PROTOCOL_FD` 与 `log_truncation_marker`,并在若干字节预算下断言它们等于 TypeScript 常量。 +#436 的 round-12 review 发现 `py/protocol.py` 相对 `src/protocol.ts` 有三处声明陈旧——`LogMessage` 缺 `truncated`、`DoneMessage.error` 缺 `kind`、`Namespace` 缺可选的 `errorClass`。本 PR 在搬运该文件时对齐了这三处,不把陈旧镜像带过来。由于这些声明是 `TypedDict`(在受信任的 Python 侧无运行时强制),自动化 guard 只覆盖两侧都会执行的部分:`tests/protocol-mirror.e2e.ts` 启动一个真实 `python3`,从 `py/protocol.py` 读取 `PROTOCOL_FD` 与 `log_truncation_marker`,并在若干字节预算下断言它们等于 TypeScript 常量。 ## Alternatives considered -**把 Python JSON codec(`_encode_json_plain` / `_decode_json_plain`)挪进 `py/protocol.py` 以与 `protocol.ts` 跨侧对称。** 拒绝。仓库的 "prefer symmetry for parallel values" 规则指向真正平行的值;这两者不是。`protocol.ts` 里的 host 侧 codec 校验的是敌意输入,自包含。Python codec 在受信任侧产出输出,且耦合于 bootstrap 内部 helper(`_Emit`、`_dump_scalar`/`_dump_string`/`_dump_float`、`LogBuffer` 的成本核算、`_check_done_value`、`_lossless_json_violation`);只把两个入口挪过去会把这一整片拖进 `protocol.py`,或制造 `bootstrap.py` ↔ `protocol.py` 的 import 环。真正的跨侧平行是 "host 校验入站(`protocol.ts`) ↔ child 信任 host 并发出(`bootstrap.py`)",这个对称性被保留:`protocol.py` 保持它在 TS 侧一样的纯 wire-vocabulary 镜像定位。Python codec 留在 `bootstrap.py`,由 backend-core PR 交付。 +**把 Python JSON codec(`_encode_json_plain` / `_decode_json_plain`)挪进 `py/protocol.py` 以与 `protocol.ts` 跨侧对称。** 拒绝。仓库的 “prefer symmetry for parallel values” 规则指向真正平行的值;这两者不是。`protocol.ts` 里的 host 侧 codec 校验的是敌意输入,自包含。Python codec 在受信任侧产出输出,且耦合于 bootstrap 内部 helper(`_Emit`、`_dump_scalar`/`_dump_string`/`_dump_float`、`LogBuffer` 的成本核算、`_check_done_value`、`_lossless_json_violation`);只把两个入口挪过去会把这一整片拖进 `protocol.py`,或制造 `bootstrap.py` ↔ `protocol.py` 的 import 环。真正的跨侧平行是 “host 校验入站(`protocol.ts`) ↔ child 信任 host 并发出(`bootstrap.py`)”,这个对称性被保留:`protocol.py` 保持它在 TS 侧一样的纯 wire-vocabulary 镜像定位。Python codec 留在 `bootstrap.py`,由 backend-core PR 交付。 -**把包骨架推迟到"拥有" package.json 的 backend-core PR。** 拒绝:workspace-constraint、coverage、invariant-topology gate 会在 `code-runtime-python` 目录一存在而包不可构建时立即失败。stacked 拆分无法在一个尚不能编译的包里创建源文件。 +**把包骨架推迟到“拥有” package.json 的 backend-core PR。** 拒绝:workspace-constraint、coverage、invariant-topology gate 会在 `code-runtime-python` 目录一存在而包不可构建时立即失败。stacked 拆分无法在一个尚不能编译的包里创建源文件。 ## Consequences -收获:fd-3 协议及其敌意输入 codec 作为自包含、unit 全覆盖的一层落地,round-12 review 发现的 py/ts 镜像漂移被修复,并有一个执行中的 guard 防其复发。backend-core PR 建立在已 review 的 wire contract 之上。 +收获:fd-3 协议及其敌意输入 codec 作为自包含、unit 全覆盖的一层落地,round-12 review 发现的 py/ts 镜像漂移被修复,并有一个执行中的 guard 防其复发。backend-core PR 建立在已 review 的 wire contract 之上。 -代价:`src/index.ts` 与 `package.json` 在此以最小形态引入,并由 backend-core PR 编辑(而非创建)。`py/protocol.py` 中两个可执行面之外的 `TypedDict` 形状仍由 review 加后端真子进程套件守护,而非 mirror e2e 测试——这是跨语言比较类型声明的固有局限。 +代价:`src/index.ts` 与 `package.json` 在此以最小形态引入,并由 backend-core PR 编辑(而非创建)。`py/protocol.py` 中两个可执行面之外的 `TypedDict` 形状仍由 review 加后端真子进程套件守护,而非 mirror e2e 测试——这是跨语言比较类型声明的固有局限。 diff --git a/packages/code-runtime/code-runtime-python/README.i18n.yaml b/packages/code-runtime/code-runtime-python/README.i18n.yaml index 100b2b8f9f..158140a4cb 100644 --- a/packages/code-runtime/code-runtime-python/README.i18n.yaml +++ b/packages/code-runtime/code-runtime-python/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/code-runtime/code-runtime-python/README.md README.md: f68a45a5420469555eeaf9f88463fbda547d1a1d -README.zh.md: 439d4ef87a26202cc6b537651fa0b768c28637a4 +README.zh.md: fe7927e8f8c488ed6a7e6b9e5cdc76bc9dd3609c diff --git a/packages/code-runtime/code-runtime-python/README.zh.md b/packages/code-runtime/code-runtime-python/README.zh.md index 439d4ef87a..fe7927e8f8 100644 --- a/packages/code-runtime/code-runtime-python/README.zh.md +++ b/packages/code-runtime/code-runtime-python/README.zh.md @@ -17,13 +17,13 @@ host 与 CPython 子进程在子进程的 fd 3 上交换一个无版本号的 JS ## Model Experience -Indirectly, through Code Mode in [`dsh-tools`](../../core/tools/README.md), which renders this backend's exact completion value when it fits (or an explicit `invalid-output` / `output-limit` failure), plus the exact `[dsh-code-runtime-python] log capture truncated at bytes` log marker, into a retained `run_code` result. +经由 [`dsh-tools`](../../core/tools/README.md) 里的 Code Mode 间接生效:Code Mode 把本后端的精确完成值(放得下时)或一个明确的 `invalid-output` / `output-limit` 失败,连同精确的 `[dsh-code-runtime-python] log capture truncated at bytes` 日志标记,渲染进一个保留的 `run_code` 结果。 #### KV Cache effect -No direct invalidation; the named consumer owns any request-prefix changes. +无直接失效;具名消费者拥有任何请求前缀的变更。 ## Known Limitations and Deferred Work -- **跨语言 guard 只覆盖两个运行时执行的面** —— `PROTOCOL_FD` 与日志截断标记。`py/protocol.py` 中的 `TypedDict` 帧形状靠 review 而非自动化检查来镜像 `src/protocol.ts`:跨 TypeScript 与 Python 比较类型声明在此无机械等价物,故未来的形状漂移由 review 加后端真子进程套件捕获,而非本包的测试。 -- **`PythonCodeRuntime` 实现与 Python 侧 JSON codec 不在本层** —— 它们在基于本分支的 backend-core PR 中交付;在那之前 `src/index.ts` 只 re-export 协议词汇。 +- **跨语言 guard 只覆盖两个运行时执行的面** —— `PROTOCOL_FD` 与日志截断标记。`py/protocol.py` 中的 `TypedDict` 帧形状靠 review 而非自动化检查来镜像 `src/protocol.ts`:跨 TypeScript 与 Python 比较类型声明在此无机械等价物,故未来的形状漂移由 review 加后端真子进程套件捕获,而非本包的测试。 +- **`PythonCodeRuntime` 实现与 Python 侧 JSON codec 不在本层** —— 它们在基于本分支的 backend-core PR 中交付;在那之前 `src/index.ts` 只 re-export 协议词汇。 From 104cd5f9755ba30c483077872fbcc5c852c00566 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Sun, 2 Aug 2026 14:16:46 +0800 Subject: [PATCH 08/32] fix(code-runtime-python): bound checkDoneValue object metering in O(cap) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The object branch counted every own key before applying the size bound, so a forged done.value with millions of keys and a small cap forced an O(frame) walk — contradicting the O(cap) guarantee the comment promised and able to block the host event loop. Bail mid-count the instant the running minimum encoding (braces + 4 bytes/entry + commas) crosses maxBytes, and drop the now -redundant post-count check the loop subsumes. Add a Proxy-based test proving a 2M-key object enumerates fewer than 1000 keys under a 64-byte cap. Also correct the checkDoneValue JSDoc: per-scalar byte length is measured via scalarJson (exact BigInt digits for beyond-safe integers), not JSON.stringify. --- .../code-runtime-python/src/protocol.ts | 26 +++++++++++++------ .../tests/protocol.spec.ts | 15 +++++++++++ 2 files changed, 33 insertions(+), 8 deletions(-) diff --git a/packages/code-runtime/code-runtime-python/src/protocol.ts b/packages/code-runtime/code-runtime-python/src/protocol.ts index 29c790eaec..10d13a211c 100644 --- a/packages/code-runtime/code-runtime-python/src/protocol.ts +++ b/packages/code-runtime/code-runtime-python/src/protocol.ts @@ -202,7 +202,10 @@ function scalarJson(current: unknown): string { * number (non-finite, negative zero) is caught only when the value fits the * budget — an over-budget value is rejected regardless, so the distinction is * moot. Same JSON-plain precondition and traversal shape as - * {@link encodeJsonPlain}; per-scalar encoding delegates to `JSON.stringify`. + * {@link encodeJsonPlain}; per-scalar byte length is measured through + * {@link scalarJson} (matching the encoder, so a beyond-safe-range integer + * meters its exact BigInt digits, not `JSON.stringify`'s rounded spelling) and + * `JSON.stringify` for strings. * @param value - a JSON-plain value (e.g. straight from `JSON.parse`). * @param maxBytes - the completion-value budget in bytes. * @returns `{ ok: true, bytes }` with the exact serialized size, or @@ -235,15 +238,22 @@ export function checkDoneValue(value: unknown, maxBytes: number): { ok: true; by for (const item of current) stack.push(item) } else if (typeof current === 'object' && current !== null) { const record = current as Record - // Count own keys WITHOUT Object.entries/Object.keys: either would - // allocate one slot (entries: one pair array) per member before the - // bound below could run, recreating the spike the bound exists to stop. + // Count own keys WITHOUT Object.entries/Object.keys (either allocates one + // slot per member up front), AND bail mid-count the instant the minimum + // encoding exceeds the budget: braces (+2), each entry a quoted key + // (>= 2 bytes) + colon + >= 1-byte value (>= 4 bytes), and a comma per + // gap. A forged wide object with millions of keys and a small cap must + // fail in O(cap), not walk its whole breadth first. `bytes` still holds + // the pre-object total throughout this loop. let count = 0 - for (const key in record) if (Object.hasOwn(record, key)) count += 1 + for (const key in record) { + if (!Object.hasOwn(record, key)) continue + count += 1 + if (bytes + 2 + count * 4 + (count - 1) > maxBytes) return { ok: false, reason: 'over-budget' } + } + // The loop's final iteration already proved the whole object's lower + // bound fits, so no separate post-count check is needed here. bytes += 2 + (count > 1 ? count - 1 : 0) - // Same pre-enqueue bound: each entry contributes its quoted key (>= 2 - // bytes), the colon, and a >= 1-byte value. - if (bytes + count * 4 > maxBytes) return { ok: false, reason: 'over-budget' } for (const key in record) { if (!Object.hasOwn(record, key)) continue // The same string lower bound, before escaping the key. diff --git a/packages/code-runtime/code-runtime-python/tests/protocol.spec.ts b/packages/code-runtime/code-runtime-python/tests/protocol.spec.ts index dc0a01d47d..f0e06af723 100644 --- a/packages/code-runtime/code-runtime-python/tests/protocol.spec.ts +++ b/packages/code-runtime/code-runtime-python/tests/protocol.spec.ts @@ -207,6 +207,21 @@ describe('checkDoneValue', () => { const wide: Record = {} for (let i = 0; i < 10; i++) wide[`k${i}`] = i expect(checkDoneValue(wide, 12)).toEqual({ ok: false, reason: 'over-budget' }) + // A forged object with millions of keys and a small cap must reject in + // O(cap): the key COUNT loop itself bails once the running minimum encoding + // (braces + 4 bytes/entry + commas) crosses the budget, rather than walking + // the whole breadth before checking. Observable as a bounded key subset: + // build a Proxy whose ownKeys would yield far more than the cap admits and + // assert the metered walk never enumerates past it. + let enumerated = 0 + const millionKeys = new Proxy({}, { + ownKeys() { return Array.from({ length: 2_000_000 }, (_unused, i) => `k${i}`) }, + getOwnPropertyDescriptor() { enumerated += 1; return { enumerable: true, configurable: true, value: 0 } }, + }) + expect(checkDoneValue(millionKeys, 64)).toEqual({ ok: false, reason: 'over-budget' }) + // With cap 64, at most ~16 entries (4 bytes each) can fit before the bound + // trips, so the walk enumerates far fewer than the 2,000,000 declared keys. + expect(enumerated).toBeLessThan(1000) }) it('rejects an over-budget string on its length before escaping it', () => { From 9dc9113ed7665c2b1d65f91b3e2c5d3603eacaab Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Sun, 2 Aug 2026 14:18:17 +0800 Subject: [PATCH 09/32] fix(code-runtime): adopt the base seam's DUNDER_MEMBER resolution The base seam branch resolved its DUNDER_MEMBER inconsistency by keeping /^__.+__$/ and asserting `____` (empty middle between two `__` pairs) does not match. Drop this branch's earlier /^__.*__$/ stopgap so the seam file is byte-identical to its base: the earlier change only existed because the base was self-inconsistent, and the base now owns a coherent decision. --- packages/code-runtime/code-runtime/src/index.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/code-runtime/code-runtime/src/index.ts b/packages/code-runtime/code-runtime/src/index.ts index 3555dbfa23..3428b5c0e4 100644 --- a/packages/code-runtime/code-runtime/src/index.ts +++ b/packages/code-runtime/code-runtime/src/index.ts @@ -59,10 +59,10 @@ export const RESERVED_ERROR_MEMBERS: ReadonlySet = new Set([ ]) /** - * Dunder form (`__…__`, two `__` pairs with an optionally empty middle): object-protocol - * slots in Python, refused as {@link RESERVED_ERROR_MEMBERS | error members} on every backend. + * Dunder form (`__x__`, non-empty middle): object-protocol slots in Python, + * refused as {@link RESERVED_ERROR_MEMBERS | error members} on every backend. */ -export const DUNDER_MEMBER = /^__.*__$/ +export const DUNDER_MEMBER = /^__.+__$/ /** * Reserved words of every portable target language (ECMAScript ∪ Python), From ae8070d799e626be71eb2917475df94b5a443402 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Sun, 2 Aug 2026 14:29:32 +0800 Subject: [PATCH 10/32] fix(code-runtime-python): stop overclaiming O(cap) object metering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit checkDoneValue cannot bound object width sublinearly: JS has no lazy own-key iterator (for...in materializes the key set), and done.value is already JSON.parse'd before the check runs, so the frame's width is paid upstream. The genuine width bound is the host's fixed 256 MiB fd-3 receive buffer (a later stack layer). Reword the JSDoc and branch comments to claim only what holds — the traversal caps the INCREMENTAL allocation the check would add (escaped strings, enqueued children, per-key stringify) and refuses over-budget before those secondary allocations — and drop the mid-count micro-check that JS cannot honor. Replace the Proxy test (whose ownKeys allocated a 2M array, proving nothing) with assertions that an over-budget string/array/object is refused before its escaped copy or child enqueue. --- .../code-runtime-python/src/protocol.ts | 58 +++++++++---------- .../tests/protocol.spec.ts | 31 ++++------ 2 files changed, 37 insertions(+), 52 deletions(-) diff --git a/packages/code-runtime/code-runtime-python/src/protocol.ts b/packages/code-runtime/code-runtime-python/src/protocol.ts index 10d13a211c..a2aaecb5be 100644 --- a/packages/code-runtime/code-runtime-python/src/protocol.ts +++ b/packages/code-runtime/code-runtime-python/src/protocol.ts @@ -189,20 +189,23 @@ function scalarJson(current: unknown): string { } /** - * Meter a forged done value's compact-JSON byte length AND its number - * losslessness in one bounded traversal, stopping the instant `maxBytes` is - * crossed. A forged `done.value` arrives straight off fd 3 and can sit anywhere - * below the 256 MiB frame ceiling while `maxValueBytes` defaults to 32 KiB. The - * previous split — an unbounded `hasNonLosslessNumber` scan in - * {@link validateChildFrame} followed by a separate byte meter — pushed every - * member of a wide flat payload onto a scan stack before any cap check ran, so - * a below-ceiling forgery could still force a hundreds-of-megabytes host - * allocation. Folding both jobs here rejects over-budget BEFORE enqueuing an - * array's or object's children, keeping the traversal O(cap). A non-lossless - * number (non-finite, negative zero) is caught only when the value fits the - * budget — an over-budget value is rejected regardless, so the distinction is - * moot. Same JSON-plain precondition and traversal shape as - * {@link encodeJsonPlain}; per-scalar byte length is measured through + * Meter a `JSON.parse`-produced done value's compact-JSON byte length AND its + * number losslessness in one traversal, stopping the instant `maxBytes` is + * crossed. This bounds the INCREMENTAL allocation the check itself would add on + * top of the already-parsed value — the escaped-string copy, the enqueued + * children, the per-key `JSON.stringify` — not the parse that produced `value`. + * That upstream width is bounded separately: the host reads fd 3 into a fixed + * 256 MiB receive buffer (a later stack layer), so `value` cannot already be + * larger than that when it reaches here, while `maxValueBytes` defaults to + * 32 KiB. The traversal rejects over-budget BEFORE materializing a string's + * escaped form or enqueuing an array's/object's children, so a below-ceiling + * forgery cannot force those secondary allocations. Object key COUNTING is + * unavoidably O(keys) — JS has no lazy own-key iterator, and the parse already + * built the key set — but the check still refuses the per-entry work before the + * enqueue loop. A non-lossless number (non-finite, negative zero) is caught only + * when the value fits the budget — an over-budget value is rejected regardless, + * so the distinction is moot. Same JSON-plain precondition and traversal shape + * as {@link encodeJsonPlain}; per-scalar byte length is measured through * {@link scalarJson} (matching the encoder, so a beyond-safe-range integer * meters its exact BigInt digits, not `JSON.stringify`'s rounded spelling) and * `JSON.stringify` for strings. @@ -230,30 +233,23 @@ export function checkDoneValue(value: unknown, maxBytes: number): { ok: true; by } else if (Array.isArray(current)) { // Brackets plus one comma per gap; elements add themselves. Reject // BEFORE enqueuing children: every element serializes to at least one - // byte, so a forged flat array below the frame ceiling but far above - // the budget fails here without growing the host stack by millions of - // entries first. + // byte, so a forged flat array far above the budget fails here without + // pushing its elements onto the host stack. (The array itself is already + // materialized by the upstream parse; this only bounds the extra stack.) bytes += 2 + (current.length > 1 ? current.length - 1 : 0) if (bytes + current.length > maxBytes) return { ok: false, reason: 'over-budget' } for (const item of current) stack.push(item) } else if (typeof current === 'object' && current !== null) { const record = current as Record - // Count own keys WITHOUT Object.entries/Object.keys (either allocates one - // slot per member up front), AND bail mid-count the instant the minimum - // encoding exceeds the budget: braces (+2), each entry a quoted key - // (>= 2 bytes) + colon + >= 1-byte value (>= 4 bytes), and a comma per - // gap. A forged wide object with millions of keys and a small cap must - // fail in O(cap), not walk its whole breadth first. `bytes` still holds - // the pre-object total throughout this loop. + // Count own keys with for...in + hasOwn. This IS O(keys) — JS has no lazy + // own-key iterator and the parse already built the key set — so the count + // cannot be sublinear; what the bound below buys is refusing the per-entry + // work (key escaping, value enqueue) before it runs. Each entry costs at + // least a quoted key (>= 2 bytes) + colon + >= 1-byte value. let count = 0 - for (const key in record) { - if (!Object.hasOwn(record, key)) continue - count += 1 - if (bytes + 2 + count * 4 + (count - 1) > maxBytes) return { ok: false, reason: 'over-budget' } - } - // The loop's final iteration already proved the whole object's lower - // bound fits, so no separate post-count check is needed here. + for (const key in record) if (Object.hasOwn(record, key)) count += 1 bytes += 2 + (count > 1 ? count - 1 : 0) + if (bytes + count * 4 > maxBytes) return { ok: false, reason: 'over-budget' } for (const key in record) { if (!Object.hasOwn(record, key)) continue // The same string lower bound, before escaping the key. diff --git a/packages/code-runtime/code-runtime-python/tests/protocol.spec.ts b/packages/code-runtime/code-runtime-python/tests/protocol.spec.ts index f0e06af723..b57ae178c6 100644 --- a/packages/code-runtime/code-runtime-python/tests/protocol.spec.ts +++ b/packages/code-runtime/code-runtime-python/tests/protocol.spec.ts @@ -194,34 +194,23 @@ describe('checkDoneValue', () => { } }) - it('stops early on a huge value instead of measuring it whole', () => { + it('rejects an over-budget value before its secondary allocations', () => { + // A huge string is refused on the cheap length lower bound, before its + // escaped copy is built. const huge = { data: 'x'.repeat(1_000_000), tail: 'y' } expect(checkDoneValue(huge, 1024)).toEqual({ ok: false, reason: 'over-budget' }) - // A forged flat array below the frame ceiling must fail BEFORE its - // elements are enqueued — the pre-enqueue bound keeps the walk O(cap). + // A flat array far above the budget fails on the brackets+length bound, + // before its elements are pushed onto the traversal stack. (The array is + // already materialized by the upstream parse; this only avoids the extra + // per-element stack growth.) const flat = new Array(10_000_000).fill(0) expect(checkDoneValue(flat, 1024)).toEqual({ ok: false, reason: 'over-budget' }) - // Same bound for a wide object: braces+commas fit the cap, but the - // per-entry lower bound (quoted key + colon + value) does not, so it fails - // before any key is metered or any value enqueued. + // A wide object: braces+commas fit the cap, but the per-entry lower bound + // (quoted key + colon + value = count*4) does not, so it fails before any + // key is escaped or any value enqueued. const wide: Record = {} for (let i = 0; i < 10; i++) wide[`k${i}`] = i expect(checkDoneValue(wide, 12)).toEqual({ ok: false, reason: 'over-budget' }) - // A forged object with millions of keys and a small cap must reject in - // O(cap): the key COUNT loop itself bails once the running minimum encoding - // (braces + 4 bytes/entry + commas) crosses the budget, rather than walking - // the whole breadth before checking. Observable as a bounded key subset: - // build a Proxy whose ownKeys would yield far more than the cap admits and - // assert the metered walk never enumerates past it. - let enumerated = 0 - const millionKeys = new Proxy({}, { - ownKeys() { return Array.from({ length: 2_000_000 }, (_unused, i) => `k${i}`) }, - getOwnPropertyDescriptor() { enumerated += 1; return { enumerable: true, configurable: true, value: 0 } }, - }) - expect(checkDoneValue(millionKeys, 64)).toEqual({ ok: false, reason: 'over-budget' }) - // With cap 64, at most ~16 entries (4 bytes each) can fit before the bound - // trips, so the walk enumerates far fewer than the 2,000,000 declared keys. - expect(enumerated).toBeLessThan(1000) }) it('rejects an over-budget string on its length before escaping it', () => { From b4487485c2abca310e6d42a991dc802ac4d46430 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Sun, 2 Aug 2026 16:30:34 +0800 Subject: [PATCH 11/32] docs(code-runtime-python): correct ownValues allocation claim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ownValues' JSDoc claimed the generator avoids a "second full-breadth allocation before a single value is examined", but for...in still materializes the key-name enumeration when the loop starts — the same JS limitation the checkDoneValue rewrite now acknowledges. What the generator genuinely saves is the extra VALUE array Object.values/Object.entries would copy; state that precisely rather than implying sublinear startup. --- .../code-runtime/code-runtime-python/src/protocol.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/code-runtime/code-runtime-python/src/protocol.ts b/packages/code-runtime/code-runtime-python/src/protocol.ts index a2aaecb5be..11c87ac7d8 100644 --- a/packages/code-runtime/code-runtime-python/src/protocol.ts +++ b/packages/code-runtime/code-runtime-python/src/protocol.ts @@ -318,9 +318,12 @@ export function hasUnsafeIntegerToken(line: string): boolean { /** * Lazily yield one plain object's own enumerable property values. A generator * (not `Object.values`/`Object.entries`) because {@link hasNonLosslessNumber} - * traverses breadth it cannot bound: those helpers copy the whole member list - * up front, so a wide forged object would cost a second full-breadth - * allocation before a single value is examined. + * walks breadth it cannot bound: those helpers copy the whole VALUE (or + * key/value pair) list into a fresh array up front, so a wide object would cost + * that second full-breadth allocation before a single value is examined. The + * `for...in` here does not make the walk sublinear — V8 still materializes the + * key-name enumeration when the loop starts — but it avoids the extra value + * array, yielding each value straight off the already-parsed object. * @param record - a JSON-parse-produced object. * @yields each own enumerable property value, in key order. */ From 69796d214cba38e5512cf140a23f1134da586798 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Sun, 2 Aug 2026 16:49:11 +0800 Subject: [PATCH 12/32] fix(code-runtime-python): remove NUL bytes and sync the Agent Note metering claim - Replace four raw U+0000 bytes in protocol.spec.ts string literals with the \0 escape so the source stays plain text (a bare NUL makes text tools treat the file as binary); the runtime value is unchanged, so the bytes:8 NUL-escape assertion still holds. - Sync the Agent Note (both languages) with the corrected checkDoneValue contract: the walk bounds only the incremental allocation it would add, not the frame width, which is already parsed and capped upstream by the host's fd-3 receive buffer. Drop the "prevents a hundreds-of-MB allocation" overclaim that the code JSDoc already retracted. Re-record the note i18n pairing. --- ...code-runtime-python-fd3-protocol.i18n.yaml | 4 ++-- ...-07-31-code-runtime-python-fd3-protocol.md | 2 +- ...-31-code-runtime-python-fd3-protocol.zh.md | 2 +- .../tests/protocol.spec.ts | 20 ++++++++++--------- 4 files changed, 15 insertions(+), 13 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.i18n.yaml index 9ca001afdc..33df2bf488 100644 --- a/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.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/architecture/2026-07-31-code-runtime-python-fd3-protocol.md -2026-07-31-code-runtime-python-fd3-protocol.md: 32cc80278af6b5f894c8d972854dae8c92ac63b7 -2026-07-31-code-runtime-python-fd3-protocol.zh.md: 24bb9dbb7b8df03c5c82c551449f49b4d306f248 +2026-07-31-code-runtime-python-fd3-protocol.md: 142f6aaf8093ec3e76249fecb40a6fbb13d80500 +2026-07-31-code-runtime-python-fd3-protocol.zh.md: 5a371240bb816379d50cc6331f0c6971cf37209a diff --git a/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.md b/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.md index 32cc80278a..142f6aaf80 100644 --- a/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.md +++ b/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.md @@ -15,7 +15,7 @@ This layer of the stack delivers only that protocol, so the large `PythonCodeRun `src/protocol.ts` is the host side of the wire vocabulary and its hostile-frame codec: - **`validateChildFrame`** shape-validates and REBUILDS every inbound frame. The compile-time union means nothing on fd 3 — a forged frame can carry `null`, poisoned fields, or omit required ones — so each accepted frame is reconstructed field by field: forged extras never ride along, a non-finite call id can never be echoed into a reply, and junk returns `undefined` to be dropped rather than throwing in the host's message handler. -- **`encodeJsonPlain` / `checkDoneValue` / `hasUnsafeIntegerToken` / `hasNonLosslessNumber`** are the lossless-JSON codec and meters. They traverse iteratively (an explicit stack, not recursion) so a deep value below the byte budget crosses intact; `checkDoneValue` folds byte-metering and number-losslessness into one bounded walk that rejects an over-budget payload BEFORE enqueuing its children, keeping a forged below-frame-ceiling value from forcing a hundreds-of-megabytes host allocation. Beyond-safe-range integral doubles serialize through `BigInt` digits so the exact integer crosses, not `String()`'s rounded form. +- **`encodeJsonPlain` / `checkDoneValue` / `hasUnsafeIntegerToken` / `hasNonLosslessNumber`** are the lossless-JSON codec and meters. They traverse iteratively (an explicit stack, not recursion) so a deep value below the byte budget crosses intact; `checkDoneValue` folds byte-metering and number-losslessness into one walk that rejects an over-budget payload before the INCREMENTAL work it would otherwise add — the escaped-string copy, the enqueued children, the per-key `JSON.stringify`. It does not re-bound the frame's own width: `done.value` is already `JSON.parse`'d when the check runs, so the payload's size is paid upstream and capped there by the host's fixed fd-3 receive buffer (a later stack layer), not here. Beyond-safe-range integral doubles serialize through `BigInt` digits so the exact integer crosses, not `String()`'s rounded form. - **`logTruncationMarker`** produces the in-band marker text a log ledger emits when it exhausts its byte budget. `py/protocol.py` mirrors the message shapes as `TypedDict`s and re-declares the two surfaces both sides EXECUTE against — `PROTOCOL_FD = 3` and `log_truncation_marker` — with byte-identical text. diff --git a/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.zh.md b/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.zh.md index 24bb9dbb7b..5a371240bb 100644 --- a/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.zh.md @@ -15,7 +15,7 @@ CPython code-runtime 后端(`@deepseek-ai/dsh-code-runtime-python`,分多个 `src/protocol.ts` 是 wire vocabulary 的 host 侧及其敌意帧编解码: - **`validateChildFrame`** 对每个入站帧做形状校验并重建。编译期 union 在 fd 3 上毫无意义——伪造帧可携带 `null`、被污染的字段,或省略必需字段——所以每个被接受的帧都逐字段重建:伪造的额外字段绝不随行,非有限的 call id 绝不会被回显进 reply,垃圾返回 `undefined` 被丢弃,而不是在 host 的 message handler 里抛错。 -- **`encodeJsonPlain` / `checkDoneValue` / `hasUnsafeIntegerToken` / `hasNonLosslessNumber`** 是 lossless-JSON 编解码器与计量器。它们迭代遍历(显式栈,非递归),使低于字节预算的深层值能完整穿越;`checkDoneValue` 把字节计量和数字无损性折进一次有界遍历,在把子节点入栈之前就拒绝超预算 payload,防止一个低于帧上限的伪造值迫使 host 分配数百 MB。超出安全范围的整数型 double 通过 `BigInt` 数字序列化,穿越的是精确整数而非 `String()` 的舍入形式。 +- **`encodeJsonPlain` / `checkDoneValue` / `hasUnsafeIntegerToken` / `hasNonLosslessNumber`** 是 lossless-JSON 编解码器与计量器。它们迭代遍历(显式栈,非递归),使低于字节预算的深层值能完整穿越;`checkDoneValue` 把字节计量和数字无损性折进一次遍历,在它本会新增的 INCREMENTAL 工作之前就拒绝超预算 payload——转义串副本、入栈子节点、逐 key 的 `JSON.stringify`。它不会重新约束帧自身的宽度:`done.value` 在检查运行时已被 `JSON.parse`,故 payload 的尺寸是上游代价,由 host 固定的 fd-3 接收缓冲(后续 stack 层)在那里封顶,而非本函数。超出安全范围的整数型 double 通过 `BigInt` 数字序列化,穿越的是精确整数而非 `String()` 的舍入形式。 - **`logTruncationMarker`** 产出日志 ledger 耗尽字节预算时发出的带内标记文本。 `py/protocol.py` 用 `TypedDict` 镜像消息形状,并重新声明两侧都会 EXECUTE 的两个面——`PROTOCOL_FD = 3` 与 `log_truncation_marker`——文本逐字节一致。 diff --git a/packages/code-runtime/code-runtime-python/tests/protocol.spec.ts b/packages/code-runtime/code-runtime-python/tests/protocol.spec.ts index b57ae178c6..b674c8cf49 100644 --- a/packages/code-runtime/code-runtime-python/tests/protocol.spec.ts +++ b/packages/code-runtime/code-runtime-python/tests/protocol.spec.ts @@ -214,17 +214,19 @@ describe('checkDoneValue', () => { }) it('rejects an over-budget string on its length before escaping it', () => { - // A control-heavy forged string escapes to ~6x its length; the walk must - // refuse it on the cheap `length + 2` lower bound so the escaped copy is - // never allocated. Observable through the boundary: a string whose LENGTH - // already exceeds the cap fails even though every character is 1 byte. - expect(checkDoneValue(''.repeat(4096), 1024)).toEqual({ ok: false, reason: 'over-budget' }) + // A control-heavy forged string escapes to ~6x its length (each NUL becomes + // the 6-character `\u0000`); the walk must refuse it on the cheap + // `length + 2` lower bound so the escaped copy is never allocated. Observable + // through the boundary: a string whose LENGTH already exceeds the cap fails + // even though every source character is one UTF-16 code unit. + expect(checkDoneValue('\0'.repeat(4096), 1024)).toEqual({ ok: false, reason: 'over-budget' }) // The bound is a lower bound, never a false rejection: a string that fits - // exactly still passes with its exact escaped size. - expect(checkDoneValue('', 8)).toEqual({ ok: true, bytes: 8 }) - expect(checkDoneValue('', 7)).toEqual({ ok: false, reason: 'over-budget' }) + // exactly still passes with its exact escaped size — one NUL serializes to + // `"\u0000"`, i.e. two quotes plus the 6-character escape = 8 bytes. + expect(checkDoneValue('\0', 8)).toEqual({ ok: true, bytes: 8 }) + expect(checkDoneValue('\0', 7)).toEqual({ ok: false, reason: 'over-budget' }) // Same lower bound for keys, checked before the key is escaped. - expect(checkDoneValue({ [''.repeat(4096)]: 1 }, 1024)).toEqual({ ok: false, reason: 'over-budget' }) + expect(checkDoneValue({ ['\0'.repeat(4096)]: 1 }, 1024)).toEqual({ ok: false, reason: 'over-budget' }) }) it('meters only own enumerable keys', () => { From 8cf253a470d30f1218ccf7d9c03985976102e530 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Sun, 2 Aug 2026 17:01:56 +0800 Subject: [PATCH 13/32] docs(code-runtime-python): sync README metering claim with code and note MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both README sides still described checkDoneValue as "one bounded traversal / 一次有界遍历" — the same overclaim already retracted in the code JSDoc and the Agent Note. Reword both to match: the walk bounds only the incremental allocation it adds (escaped-string copy, enqueued children, per-key stringify); the frame's own width is parsed upstream and capped by the host's fd-3 receive buffer, not re-bounded here. Re-record README.i18n.yaml. --- packages/code-runtime/code-runtime-python/README.i18n.yaml | 4 ++-- packages/code-runtime/code-runtime-python/README.md | 2 +- packages/code-runtime/code-runtime-python/README.zh.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/code-runtime/code-runtime-python/README.i18n.yaml b/packages/code-runtime/code-runtime-python/README.i18n.yaml index 158140a4cb..f096202ad7 100644 --- a/packages/code-runtime/code-runtime-python/README.i18n.yaml +++ b/packages/code-runtime/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/code-runtime/code-runtime-python/README.md -README.md: f68a45a5420469555eeaf9f88463fbda547d1a1d -README.zh.md: fe7927e8f8c488ed6a7e6b9e5cdc76bc9dd3609c +README.md: 62e899941ac8eadb2046b1bffae0a3c9c6e14308 +README.zh.md: 5a7aa880830bccc4ebd647d7b009ec7e4f0d6307 diff --git a/packages/code-runtime/code-runtime-python/README.md b/packages/code-runtime/code-runtime-python/README.md index f68a45a542..62e899941a 100644 --- a/packages/code-runtime/code-runtime-python/README.md +++ b/packages/code-runtime/code-runtime-python/README.md @@ -12,7 +12,7 @@ The host and the CPython subprocess exchange a versionless, JSON-lines protocol - **fd 3, not stdout** — Node pins the channel positionally with `stdio: ['pipe','pipe','pipe','pipe']`; the Python bootstrap reads the same `PROTOCOL_FD` constant. JSON-lines framing. - **Host treats every inbound frame as hostile** — model code has full access to fd 3 and can post anything through it, so `validateChildFrame` shape-validates and REBUILDS each frame before the host reads it: forged extra fields never ride along, a non-number call id can never be echoed into a reply, and junk drops to `undefined` rather than throwing in the host's message handler. The Python side trusts host replies (the host is not model-controlled). -- **Lossless-JSON crossing** — completion values and binding arguments cross as exact JSON. `encodeJsonPlain` serializes a `JSON.parse`-produced value without recursion, so a deep value below the byte budget crosses intact instead of dying on `JSON.stringify`'s stack limit; `checkDoneValue` meters a forged completion value's byte length AND number losslessness in one bounded traversal that rejects an over-budget payload before enqueuing its children; `hasUnsafeIntegerToken` reads the raw frame text to catch an integer token that `JSON.parse` would silently round; `hasNonLosslessNumber` rejects a non-finite or negative-zero number in unbounded `call.args`. Beyond-safe-range integral doubles serialize through `BigInt` digits so the exact integer crosses, not the rounded `String()` form. +- **Lossless-JSON crossing** — completion values and binding arguments cross as exact JSON. `encodeJsonPlain` serializes a `JSON.parse`-produced value without recursion, so a deep value below the byte budget crosses intact instead of dying on `JSON.stringify`'s stack limit; `checkDoneValue` meters a forged completion value's byte length AND number losslessness in one traversal that rejects an over-budget payload before the incremental work it would add (escaped-string copy, enqueued children, per-key `JSON.stringify`) — the frame's own width is already parsed and capped upstream by the host's fd-3 receive buffer, not re-bounded here; `hasUnsafeIntegerToken` reads the raw frame text to catch an integer token that `JSON.parse` would silently round; `hasNonLosslessNumber` rejects a non-finite or negative-zero number in unbounded `call.args`. Beyond-safe-range integral doubles serialize through `BigInt` digits so the exact integer crosses, not the rounded `String()` form. - **Shared truncation marker** — `logTruncationMarker(maxBytes)` produces byte-identical text on both sides, so a truncated log run reads the same however the cap was hit. The `log` frame's `truncated` flag distinguishes the child ledger's own marker from program output. ## Model Experience diff --git a/packages/code-runtime/code-runtime-python/README.zh.md b/packages/code-runtime/code-runtime-python/README.zh.md index fe7927e8f8..5a7aa88083 100644 --- a/packages/code-runtime/code-runtime-python/README.zh.md +++ b/packages/code-runtime/code-runtime-python/README.zh.md @@ -12,7 +12,7 @@ host 与 CPython 子进程在子进程的 fd 3 上交换一个无版本号的 JS - **fd 3,而非 stdout** —— Node 通过 `stdio: ['pipe','pipe','pipe','pipe']` 按位置钉住通道;Python bootstrap 读取相同的 `PROTOCOL_FD` 常量。JSON-lines 帧。 - **host 把每个入站帧当作敌意输入** —— 模型代码对 fd 3 有完全访问权、可通过它发送任意内容,所以 `validateChildFrame` 在 host 读取前对每个帧做形状校验并重建:伪造的额外字段绝不随行,非数字的 call id 绝不会被回显进 reply,垃圾降为 `undefined` 被丢弃,而不是在 host 的 message handler 里抛错。Python 侧信任 host 回复(host 不受模型控制)。 -- **lossless-JSON 穿越** —— 完成值与 binding 参数以精确 JSON 穿越。`encodeJsonPlain` 无递归地序列化一个 `JSON.parse` 产出的值,使低于字节预算的深层值能完整穿越,而不是死在 `JSON.stringify` 的栈限制上;`checkDoneValue` 在一次有界遍历中同时计量伪造完成值的字节长度与数字无损性,在把子节点入栈之前就拒绝超预算 payload;`hasUnsafeIntegerToken` 读取原始帧文本,捕获 `JSON.parse` 会静默舍入的整数 token;`hasNonLosslessNumber` 拒绝无字节上限的 `call.args` 中的非有限数或负零。超出安全范围的整数型 double 通过 `BigInt` 数字序列化,穿越的是精确整数而非 `String()` 的舍入形式。 +- **lossless-JSON 穿越** —— 完成值与 binding 参数以精确 JSON 穿越。`encodeJsonPlain` 无递归地序列化一个 `JSON.parse` 产出的值,使低于字节预算的深层值能完整穿越,而不是死在 `JSON.stringify` 的栈限制上;`checkDoneValue` 在一次遍历中同时计量伪造完成值的字节长度与数字无损性,在它本会新增的增量工作之前就拒绝超预算 payload(转义串副本、入栈子节点、逐 key 的 `JSON.stringify`)——帧自身的宽度已被上游 `JSON.parse` 支付、由 host 的 fd-3 接收缓冲封顶,并非在此重新约束;`hasUnsafeIntegerToken` 读取原始帧文本,捕获 `JSON.parse` 会静默舍入的整数 token;`hasNonLosslessNumber` 拒绝无字节上限的 `call.args` 中的非有限数或负零。超出安全范围的整数型 double 通过 `BigInt` 数字序列化,穿越的是精确整数而非 `String()` 的舍入形式。 - **共享截断标记** —— `logTruncationMarker(maxBytes)` 在两侧产出逐字节一致的文本,使被截断的日志运行无论从哪侧触达上限都读起来一致。`log` 帧的 `truncated` 标志把子进程 ledger 自身的标记与程序输出区分开。 ## Model Experience From 146a9d9f61155b517fa840b890a8a8b617f27b46 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Sun, 2 Aug 2026 17:12:52 +0800 Subject: [PATCH 14/32] fix(code-runtime-python): make checkDoneValue over-budget precedence order-independent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit checkDoneValue returned non-lossless the instant it hit a non-finite/negative- zero number, before finishing the budget metering. A value that is BOTH over- budget and non-lossless then classified by member order: `["", 1e400]` gave non-lossless while `[1e400, ""]` gave over-budget — the same value, two verdicts — which would drive the consumer to emit invalid-output vs output-limit non-deterministically, contradicting the JSDoc promise that an over-budget value is rejected as over-budget regardless. Record the number violation in a flag and let metering finish; return non-lossless only once the whole value is confirmed within budget. Add a regression test asserting both member orders classify as over-budget. --- .../code-runtime-python/src/protocol.ts | 13 +++++++++++-- .../code-runtime-python/tests/protocol.spec.ts | 14 ++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/packages/code-runtime/code-runtime-python/src/protocol.ts b/packages/code-runtime/code-runtime-python/src/protocol.ts index 11c87ac7d8..94489b8904 100644 --- a/packages/code-runtime/code-runtime-python/src/protocol.ts +++ b/packages/code-runtime/code-runtime-python/src/protocol.ts @@ -217,12 +217,19 @@ function scalarJson(current: unknown): string { */ export function checkDoneValue(value: unknown, maxBytes: number): { ok: true; bytes: number } | { ok: false; reason: 'over-budget' | 'non-lossless' } { let bytes = 0 + // A non-lossless number is recorded, not returned on sight: over-budget must + // win regardless of where in the value each violation sits, so the whole + // metering finishes first. Otherwise `["", 1e400]` and `[1e400, + // ""]` — the same over-budget value in two member orders — would + // classify differently (non-lossless vs over-budget), and the JSDoc promises + // an over-budget value is rejected as over-budget regardless. + let nonLossless = false const stack: unknown[] = [value] while (stack.length > 0) { const current = stack.pop() if (typeof current === 'number') { - if (!Number.isFinite(current) || Object.is(current, -0)) return { ok: false, reason: 'non-lossless' } - bytes += Buffer.byteLength(scalarJson(current), 'utf8') + if (!Number.isFinite(current) || Object.is(current, -0)) nonLossless = true + else bytes += Buffer.byteLength(scalarJson(current), 'utf8') } else if (typeof current === 'string') { // Lower-bound BEFORE materializing the escaped form: every UTF-16 code // unit is at least one UTF-8 byte plus the two quotes, so a huge or @@ -262,6 +269,8 @@ export function checkDoneValue(value: unknown, maxBytes: number): { ok: true; by } if (bytes > maxBytes) return { ok: false, reason: 'over-budget' } } + // The whole value fit the budget; a recorded number violation is the verdict. + if (nonLossless) return { ok: false, reason: 'non-lossless' } return { ok: true, bytes } } diff --git a/packages/code-runtime/code-runtime-python/tests/protocol.spec.ts b/packages/code-runtime/code-runtime-python/tests/protocol.spec.ts index b674c8cf49..a33b9e905b 100644 --- a/packages/code-runtime/code-runtime-python/tests/protocol.spec.ts +++ b/packages/code-runtime/code-runtime-python/tests/protocol.spec.ts @@ -250,6 +250,20 @@ describe('checkDoneValue', () => { expect(checkDoneValue(clean, 1024)).toEqual({ ok: true, bytes: Buffer.byteLength(JSON.stringify(clean), 'utf8') }) }) + it('classifies an over-budget value as over-budget regardless of member order', () => { + // A value that is BOTH over-budget and non-lossless must reject as + // over-budget whichever member the walk reaches first — the non-lossless + // number is recorded and metering finishes, so the two orders below (the + // same value) cannot classify differently. Cap 100 with a 1000-char string. + const big = 'x'.repeat(1000) + expect(checkDoneValue([big, Infinity], 100)).toEqual({ ok: false, reason: 'over-budget' }) + expect(checkDoneValue([Infinity, big], 100)).toEqual({ ok: false, reason: 'over-budget' }) + // A non-lossless number that DOES fit the budget still rejects as + // non-lossless (the recorded violation is the verdict once the whole value + // is confirmed within budget). + expect(checkDoneValue([Infinity], 100)).toEqual({ ok: false, reason: 'non-lossless' }) + }) + it('meters deep nesting iteratively without overflowing the stack', () => { let deep: unknown = 0 for (let i = 0; i < 100_000; i++) deep = [deep] From 8a60b5f0056ec3636742c7c270c52fb3385cb749 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Sun, 2 Aug 2026 17:45:11 +0800 Subject: [PATCH 15/32] feat(code-runtime-python): make the TypedDict wire mirror an executable gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address the two standing review suggestions in this layer rather than deferring them to PR #4: - Extend tests/protocol-mirror.e2e.ts to read each py/protocol.py TypedDict's required/optional key set and assert it against the wire field names src/protocol.ts declares (global included, via functional TypedDict). The round-12 class of drift — a renamed/dropped field, or one side making a field optional the other requires — now fails a test instead of relying on review. Field types remain review-guarded (no mechanical TS/Python equivalent). - Drop the forward references to PR #4's internal mechanisms from this layer's prose: the "256 MiB frame ceiling" figure and the "(index.ts)" fd-3 pinning citation become an abstract "host-side inbound frame-size cap" so the JSDoc, spec, README, and Agent Note describe only what this layer owns. Update both README sides and the Agent Note (both languages) to state the mirror is now executable, and re-record their i18n pairings. --- ...code-runtime-python-fd3-protocol.i18n.yaml | 4 +- ...-07-31-code-runtime-python-fd3-protocol.md | 4 +- ...-31-code-runtime-python-fd3-protocol.zh.md | 4 +- .../code-runtime-python/README.i18n.yaml | 4 +- .../code-runtime-python/README.md | 2 +- .../code-runtime-python/README.zh.md | 2 +- .../code-runtime-python/src/protocol.ts | 21 ++++--- .../tests/protocol-mirror.e2e.ts | 60 ++++++++++++++++--- .../tests/protocol.spec.ts | 4 +- 9 files changed, 76 insertions(+), 29 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.i18n.yaml index 33df2bf488..f716091e5b 100644 --- a/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.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/architecture/2026-07-31-code-runtime-python-fd3-protocol.md -2026-07-31-code-runtime-python-fd3-protocol.md: 142f6aaf8093ec3e76249fecb40a6fbb13d80500 -2026-07-31-code-runtime-python-fd3-protocol.zh.md: 5a371240bb816379d50cc6331f0c6971cf37209a +2026-07-31-code-runtime-python-fd3-protocol.md: fff3ed7a6e42cfc5372c7c8a3124a33ebcacab32 +2026-07-31-code-runtime-python-fd3-protocol.zh.md: 88a6d493b35b976abf3676dd00182da1270c4fec diff --git a/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.md b/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.md index 142f6aaf80..fff3ed7a6e 100644 --- a/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.md +++ b/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.md @@ -28,7 +28,7 @@ Frames are JSON-lines on fd 3, one object per line, leaving stdout/stderr free f ## Mirror alignment -Round-12 review of #436 found `py/protocol.py` stale against `src/protocol.ts` in three declarations — `LogMessage` lacked `truncated`, `DoneMessage.error` lacked `kind`, and `Namespace` lacked the optional `errorClass`. This PR aligns all three when lifting the file, so the stale mirror is not carried forward. Because the declarations are `TypedDict`s (no runtime enforcement on the trusted Python side), an automated guard covers only what both sides execute: `tests/protocol-mirror.e2e.ts` spawns a real `python3`, reads `PROTOCOL_FD` and `log_truncation_marker` from `py/protocol.py`, and asserts they equal the TypeScript constants across several byte budgets. +Round-12 review of #436 found `py/protocol.py` stale against `src/protocol.ts` in three declarations — `LogMessage` lacked `truncated`, `DoneMessage.error` lacked `kind`, and `Namespace` lacked the optional `errorClass`. This PR aligns all three when lifting the file, so the stale mirror is not carried forward. To keep it aligned, `tests/protocol-mirror.e2e.ts` spawns a real `python3` and asserts, against `src/protocol.ts`: `PROTOCOL_FD` and `log_truncation_marker` (the two surfaces both sides execute), and each `TypedDict`'s required/optional wire field set — so a renamed or dropped field, or one side making a field optional the other requires (exactly the round-12 drift), fails the test. Field *types* are not compared across the language boundary; that residue stays with review. ## Alternatives considered @@ -40,4 +40,4 @@ Round-12 review of #436 found `py/protocol.py` stale against `src/protocol.ts` i Bought: the fd-3 protocol and its hostile-input codec land as a self-contained, fully unit-covered layer, and the py/ts mirror drift the round-12 review found is fixed with an executing guard against its recurrence. The backend-core PR builds on a reviewed wire contract. -Cost: `src/index.ts` and `package.json` are introduced minimally here and edited (not created) by the backend-core PR. The `TypedDict` shapes in `py/protocol.py` beyond the two executed surfaces remain guarded by review plus the backend's real-subprocess suite, not by the mirror e2e test — an inherent limit of comparing type declarations across languages. +Cost: `src/index.ts` and `package.json` are introduced minimally here and edited (not created) by the backend-core PR. The mirror e2e compares field NAMES and required/optional-ness across the two sides but not field TYPES — comparing type declarations across TypeScript and Python has no mechanical equivalent, so that residue stays with review plus the backend's real-subprocess suite. diff --git a/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.zh.md b/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.zh.md index 5a371240bb..88a6d493b3 100644 --- a/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.zh.md @@ -28,7 +28,7 @@ CPython code-runtime 后端(`@deepseek-ai/dsh-code-runtime-python`,分多个 ## Mirror alignment -#436 的 round-12 review 发现 `py/protocol.py` 相对 `src/protocol.ts` 有三处声明陈旧——`LogMessage` 缺 `truncated`、`DoneMessage.error` 缺 `kind`、`Namespace` 缺可选的 `errorClass`。本 PR 在搬运该文件时对齐了这三处,不把陈旧镜像带过来。由于这些声明是 `TypedDict`(在受信任的 Python 侧无运行时强制),自动化 guard 只覆盖两侧都会执行的部分:`tests/protocol-mirror.e2e.ts` 启动一个真实 `python3`,从 `py/protocol.py` 读取 `PROTOCOL_FD` 与 `log_truncation_marker`,并在若干字节预算下断言它们等于 TypeScript 常量。 +#436 的 round-12 review 发现 `py/protocol.py` 相对 `src/protocol.ts` 有三处声明陈旧——`LogMessage` 缺 `truncated`、`DoneMessage.error` 缺 `kind`、`Namespace` 缺可选的 `errorClass`。本 PR 在搬运该文件时对齐了这三处,不把陈旧镜像带过来。为持续保持对齐,`tests/protocol-mirror.e2e.ts` 启动一个真实 `python3`,对照 `src/protocol.ts` 断言:`PROTOCOL_FD` 与 `log_truncation_marker`(两侧都会执行的面),以及每个 `TypedDict` 的必填/可选 wire 字段集——于是字段被重命名或删除、或一侧把另一侧要求的字段改成可选(正是 round-12 那类漂移),测试即失败。字段的*类型*不跨语言边界比较,那部分残留留给 review。 ## Alternatives considered @@ -40,4 +40,4 @@ CPython code-runtime 后端(`@deepseek-ai/dsh-code-runtime-python`,分多个 收获:fd-3 协议及其敌意输入 codec 作为自包含、unit 全覆盖的一层落地,round-12 review 发现的 py/ts 镜像漂移被修复,并有一个执行中的 guard 防其复发。backend-core PR 建立在已 review 的 wire contract 之上。 -代价:`src/index.ts` 与 `package.json` 在此以最小形态引入,并由 backend-core PR 编辑(而非创建)。`py/protocol.py` 中两个可执行面之外的 `TypedDict` 形状仍由 review 加后端真子进程套件守护,而非 mirror e2e 测试——这是跨语言比较类型声明的固有局限。 +代价:`src/index.ts` 与 `package.json` 在此以最小形态引入,并由 backend-core PR 编辑(而非创建)。mirror e2e 比较两侧的字段名与必填/可选性,但不比较字段类型——跨 TypeScript 与 Python 比较类型声明无机械等价物,那部分残留留给 review 加后端真子进程套件。 diff --git a/packages/code-runtime/code-runtime-python/README.i18n.yaml b/packages/code-runtime/code-runtime-python/README.i18n.yaml index f096202ad7..4d7725dafc 100644 --- a/packages/code-runtime/code-runtime-python/README.i18n.yaml +++ b/packages/code-runtime/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/code-runtime/code-runtime-python/README.md -README.md: 62e899941ac8eadb2046b1bffae0a3c9c6e14308 -README.zh.md: 5a7aa880830bccc4ebd647d7b009ec7e4f0d6307 +README.md: d0491c478d04a8436199bc23fd79917e8c019b1c +README.zh.md: 63d7d38ee05b561e60a3adf387c5c1292c37a7a2 diff --git a/packages/code-runtime/code-runtime-python/README.md b/packages/code-runtime/code-runtime-python/README.md index 62e899941a..d0491c478d 100644 --- a/packages/code-runtime/code-runtime-python/README.md +++ b/packages/code-runtime/code-runtime-python/README.md @@ -25,5 +25,5 @@ No direct invalidation; the named consumer owns any request-prefix changes. ## Known Limitations and Deferred Work -- **The cross-language guard covers only the two runtime-executed surfaces** — `PROTOCOL_FD` and the log truncation marker. The `TypedDict` frame shapes in `py/protocol.py` mirror `src/protocol.ts` by review, not by an automated check: comparing type declarations across TypeScript and Python has no mechanical equivalent here, so a future shape drift is caught by review plus the backend's real-subprocess suite rather than this package's tests. +- **The cross-language guard covers the runtime-executed surfaces and the frame field shapes** — `tests/protocol-mirror.e2e.ts` spawns a real `python3` and asserts, against `src/protocol.ts`, both `PROTOCOL_FD` / the log truncation marker text AND each `TypedDict`'s required/optional wire field set in `py/protocol.py`. What it does not compare is the field *types* (e.g. that `cpuSeconds` is an `int` on both sides): comparing type declarations across TypeScript and Python has no mechanical equivalent here, so a type-level drift is still caught by review plus the backend's real-subprocess suite rather than this package's tests. - **The `PythonCodeRuntime` implementation and its Python-side JSON codec are not in this layer** — they ship in the backend-core PR on top of this branch; `src/index.ts` re-exports only the protocol vocabulary until then. diff --git a/packages/code-runtime/code-runtime-python/README.zh.md b/packages/code-runtime/code-runtime-python/README.zh.md index 5a7aa88083..63d7d38ee0 100644 --- a/packages/code-runtime/code-runtime-python/README.zh.md +++ b/packages/code-runtime/code-runtime-python/README.zh.md @@ -25,5 +25,5 @@ host 与 CPython 子进程在子进程的 fd 3 上交换一个无版本号的 JS ## Known Limitations and Deferred Work -- **跨语言 guard 只覆盖两个运行时执行的面** —— `PROTOCOL_FD` 与日志截断标记。`py/protocol.py` 中的 `TypedDict` 帧形状靠 review 而非自动化检查来镜像 `src/protocol.ts`:跨 TypeScript 与 Python 比较类型声明在此无机械等价物,故未来的形状漂移由 review 加后端真子进程套件捕获,而非本包的测试。 +- **跨语言 guard 覆盖运行时执行的面与帧字段形状** —— `tests/protocol-mirror.e2e.ts` 启动一个真实 `python3`,对照 `src/protocol.ts` 断言 `PROTOCOL_FD` / 日志截断标记文本,以及 `py/protocol.py` 中每个 `TypedDict` 的必填/可选 wire 字段集。它不比较字段的*类型*(例如 `cpuSeconds` 两侧都是 `int`):跨 TypeScript 与 Python 比较类型声明在此无机械等价物,故类型级漂移仍由 review 加后端真子进程套件捕获,而非本包的测试。 - **`PythonCodeRuntime` 实现与 Python 侧 JSON codec 不在本层** —— 它们在基于本分支的 backend-core PR 中交付;在那之前 `src/index.ts` 只 re-export 协议词汇。 diff --git a/packages/code-runtime/code-runtime-python/src/protocol.ts b/packages/code-runtime/code-runtime-python/src/protocol.ts index 94489b8904..80eda4ab8d 100644 --- a/packages/code-runtime/code-runtime-python/src/protocol.ts +++ b/packages/code-runtime/code-runtime-python/src/protocol.ts @@ -7,8 +7,9 @@ */ // The protocol channel is fd 3 from the child's perspective — the host pins it -// positionally via `stdio: ['pipe','pipe','pipe','pipe']` (index.ts), and the -// Python bootstrap reads the same constant from its own protocol.py. +// positionally via `stdio: ['pipe','pipe','pipe','pipe']` when it spawns the +// child, and the Python bootstrap reads the same constant from its own +// protocol.py. /** * What the host sends immediately after spawn, as the first line on fd 3. The @@ -194,12 +195,13 @@ function scalarJson(current: unknown): string { * crossed. This bounds the INCREMENTAL allocation the check itself would add on * top of the already-parsed value — the escaped-string copy, the enqueued * children, the per-key `JSON.stringify` — not the parse that produced `value`. - * That upstream width is bounded separately: the host reads fd 3 into a fixed - * 256 MiB receive buffer (a later stack layer), so `value` cannot already be - * larger than that when it reaches here, while `maxValueBytes` defaults to - * 32 KiB. The traversal rejects over-budget BEFORE materializing a string's - * escaped form or enqueuing an array's/object's children, so a below-ceiling - * forgery cannot force those secondary allocations. Object key COUNTING is + * That upstream width is bounded separately, by the host-side cap on inbound + * fd-3 frame size before `JSON.parse` runs (owned by the runtime that reads the + * channel), so `value` cannot be arbitrarily large when it reaches here, while + * `maxValueBytes` defaults to 32 KiB. The traversal rejects over-budget BEFORE + * materializing a string's escaped form or enqueuing an array's/object's + * children, so a forgery within that frame cap cannot force those secondary + * allocations. Object key COUNTING is * unavoidably O(keys) — JS has no lazy own-key iterator, and the parse already * built the key set — but the check still refuses the per-entry work before the * enqueue loop. A non-lossless number (non-finite, negative zero) is caught only @@ -353,7 +355,8 @@ function* ownValues(record: object): Generator { * cap, so there is no budget to reject a wide payload against the way * {@link checkDoneValue} does. The traversal therefore holds ONE cursor per * NESTING LEVEL (an array or {@link ownValues} iterator) instead of one entry - * per member: a forged flat `args` just below the 256 MiB frame ceiling would + * per member: a forged flat `args` at the top of the host's inbound frame-size + * cap would * otherwise push tens of millions of stack entries — and `Object.values` would * copy each object's full breadth — allocating hundreds of megabytes beyond * what `JSON.parse` already holds. Iterative either way, so a deep frame diff --git a/packages/code-runtime/code-runtime-python/tests/protocol-mirror.e2e.ts b/packages/code-runtime/code-runtime-python/tests/protocol-mirror.e2e.ts index d79a659c09..3a191ddbb2 100644 --- a/packages/code-runtime/code-runtime-python/tests/protocol-mirror.e2e.ts +++ b/packages/code-runtime/code-runtime-python/tests/protocol-mirror.e2e.ts @@ -6,13 +6,14 @@ import { describe, expect, it } from 'vitest' import { logTruncationMarker } from '../src/protocol.ts' /** - * Cross-language mirror check for the two protocol surfaces the host and the - * CPython subprocess share at runtime, spawning a real `python3` to read them - * from `py/protocol.py`. `src/protocol.ts` and `py/protocol.py` declare the same - * frame vocabulary on two sides of the wire; the only values both sides EXECUTE - * against are `PROTOCOL_FD` (the fd the channel is pinned to) and the log - * truncation marker text (emitted verbatim by whichever ledger exhausts first), - * so a drift there silently corrupts a live run. Self-skips when no `python3` is + * Cross-language mirror check between `src/protocol.ts` and `py/protocol.py`, + * spawning a real `python3` to read the Python side. Two things are asserted: + * the runtime surfaces both sides EXECUTE against — `PROTOCOL_FD` and the log + * truncation marker text, where a drift silently corrupts a live run — and the + * per-frame wire field sets (required/optional keys of each `TypedDict`), which + * turns the otherwise review-only shape mirror into an executable check that + * catches the round-12 kind of drift (a renamed/dropped field, or one side + * making a field optional the other requires). Self-skips when no `python3` is * on PATH — CI provides one; the pure-TS `protocol.spec.ts` covers the host * codec unconditionally. */ @@ -46,10 +47,53 @@ describe.skipIf(!python3Available)('protocol.py mirrors protocol.ts at runtime', ].join('\n') const { stdout } = await execFileAsync('python3', ['-I', '-c', probe]) const seen = JSON.parse(stdout) as { fd: number; markers: string[] } - // fd 3 is the wire contract, not a tunable: index.ts pins it positionally. + // fd 3 is the wire contract, not a tunable: the host pins it positionally + // when it spawns the child. expect(seen.fd).toBe(3) expect(seen.markers).toEqual(budgets.map(budget => logTruncationMarker(budget))) }) + + it('agrees on every frame type\'s wire field set between the TS and Python declarations', async () => { + // Turn the TypedDict mirror from a review-only obligation into an executable + // check: read each Python TypedDict's required/optional key sets and assert + // them against the wire field names the TS side declares. `global` is the + // reserved-keyword key the Python side carries via functional TypedDict — + // catching exactly the round-12 kind of drift (a renamed/dropped field, an + // optional field the other side made required). + const probe = [ + 'import json, sys', + `sys.path.insert(0, ${JSON.stringify(pyDir)})`, + 'import protocol as p', + 'def keys(td): return {"required": sorted(td.__required_keys__), "optional": sorted(td.__optional_keys__)}', + 'print(json.dumps({', + ' "BootMessage": keys(p.BootMessage),', + ' "Namespace": keys(p.Namespace),', + ' "RunMessage": keys(p.RunMessage),', + ' "BootAckMessage": keys(p.BootAckMessage),', + ' "CallMessage": keys(p.CallMessage),', + ' "LogMessage": keys(p.LogMessage),', + ' "DoneErrorField": keys(p.DoneErrorField),', + ' "DoneMessage": keys(p.DoneMessage),', + ' "ErrorClass": keys(p.ErrorClass),', + '}))', + ].join('\n') + const { stdout } = await execFileAsync('python3', ['-I', '-c', probe]) + const seen = JSON.parse(stdout) as Record + // The wire field sets each frame carries, mirroring src/protocol.ts. `global` + // is the JSON key `CallMessage`/`Namespace` send (a Python keyword, declared + // functionally on the Python side). + expect(seen).toEqual({ + BootMessage: { required: ['addressSpaceBytes', 'cpuSeconds', 'maxLogBytes', 'maxValueBytes', 'namespaces', 'type'], optional: [] }, + Namespace: { required: ['global', 'names'], optional: ['errorClass'] }, + RunMessage: { required: ['program', 'type'], optional: [] }, + BootAckMessage: { required: ['type'], optional: [] }, + CallMessage: { required: ['args', 'global', 'id', 'name', 'type'], optional: [] }, + LogMessage: { required: ['text', 'type'], optional: ['truncated'] }, + DoneErrorField: { required: ['kind', 'message'], optional: [] }, + DoneMessage: { required: ['type'], optional: ['error', 'value'] }, + ErrorClass: { required: ['memberNameProperty', 'name'], optional: [] }, + }) + }) }) it('names the py/ directory that ships with the package', () => { diff --git a/packages/code-runtime/code-runtime-python/tests/protocol.spec.ts b/packages/code-runtime/code-runtime-python/tests/protocol.spec.ts index a33b9e905b..465aa06a21 100644 --- a/packages/code-runtime/code-runtime-python/tests/protocol.spec.ts +++ b/packages/code-runtime/code-runtime-python/tests/protocol.spec.ts @@ -135,8 +135,8 @@ describe('lossless-number scan', () => { it('walks wide arrays and objects one member at a time', () => { // `call.args` carries no seam byte cap, so a wide forged payload has no // budget to be rejected against — the walk must hold one cursor per - // NESTING LEVEL, not one entry per member, or a flat payload just below - // the 256 MiB frame ceiling would allocate tens of millions of stack + // NESTING LEVEL, not one entry per member, or a flat payload at the top of + // the host's inbound frame-size cap would allocate tens of millions of stack // entries (and `Object.values` a second full-breadth copy). Observable // through the boundary: a wide payload whose per-member cost the old shape // would have paid still scans, and a violation ANYWHERE in it is found From be839a8e53c92f06a27f0b48be07cea5c710b87d Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Sun, 2 Aug 2026 18:02:42 +0800 Subject: [PATCH 16/32] fix(code-runtime-python): count non-lossless bytes and bind the mirror gate to TS types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two gaps from the previous round's fixes: - checkDoneValue flagged a non-lossless number but skipped counting its encoded bytes, so a value over budget ONLY through that number classified as non-lossless instead of over-budget (e.g. [Infinity] at cap 3, whose encoding is 10 bytes). Count the scalar's bytes even when flagging, so the budget check wins as the JSDoc promises. Add cap-3 regression cases. - The mirror e2e compared the Python TypedDict keys against a hand-written constant, so a field change on the TS side alone would not fail it, and the reply frames were not probed at all. Introduce WIRE_FRAME_FIELDS in protocol.ts, bound to each frame interface's key set via `satisfies` (a renamed/removed field breaks typecheck — verified), and drive the mirror test from it, now covering ReplyOk/ReplyErr too. The test therefore fails on one-sided drift from either language. --- .../code-runtime-python/src/protocol.ts | 65 ++++++++++++++++++- .../tests/protocol-mirror.e2e.ts | 49 ++++++-------- .../tests/protocol.spec.ts | 6 ++ 3 files changed, 89 insertions(+), 31 deletions(-) diff --git a/packages/code-runtime/code-runtime-python/src/protocol.ts b/packages/code-runtime/code-runtime-python/src/protocol.ts index 80eda4ab8d..c53f2c9dd3 100644 --- a/packages/code-runtime/code-runtime-python/src/protocol.ts +++ b/packages/code-runtime/code-runtime-python/src/protocol.ts @@ -107,6 +107,64 @@ export type ReplyMessage = | { type: 'reply'; id: number; ok: true; value: unknown } | { type: 'reply'; id: number; ok: false; message: string } +/** + * Shape of one {@link WIRE_FRAME_FIELDS} entry, parameterised by that frame's + * key union `K`. `required` and `optional` are arrays of `K`, so listing a name + * no frame declares — a typo or a renamed field — fails typecheck. (A field + * ADDED to an interface but omitted here is caught at runtime instead: the + * mirror test asserts the Python `TypedDict` keys equal these exact sets, and + * the Python side would carry the new field.) `K` is `PropertyKey` so a bare + * `keyof Interface` binds without narrowing. + */ +type FrameFields = { + required: readonly K[] + optional: readonly K[] +} + +/** + * The wire field names of each frame, split into required and optional keys, as + * a RUNTIME value the cross-language mirror test asserts `py/protocol.py`'s + * `TypedDict`s against. The `satisfies` clause binds each entry to its frame + * interface's own key set, so listing a name no frame declares fails + * typecheck — the mirror test therefore depends on the TS declarations above, + * not a hand-copied list. `global` is the JSON key {@link CallMessage} and the + * namespace declaration send (a reserved word the Python side carries via a + * functional `TypedDict`); inline sub-shapes (the namespace entry in + * {@link BootMessage}, the error field in {@link DoneMessage}, the reply + * variants) list their keys literally. + */ +export const WIRE_FRAME_FIELDS = { + BootMessage: { required: ['addressSpaceBytes', 'cpuSeconds', 'maxLogBytes', 'maxValueBytes', 'namespaces', 'type'], optional: [] }, + Namespace: { required: ['global', 'names'], optional: ['errorClass'] }, + RunMessage: { required: ['program', 'type'], optional: [] }, + BootAckMessage: { required: ['type'], optional: [] }, + CallMessage: { required: ['args', 'global', 'id', 'name', 'type'], optional: [] }, + LogMessage: { required: ['text', 'type'], optional: ['truncated'] }, + DoneErrorField: { required: ['kind', 'message'], optional: [] }, + DoneMessage: { required: ['type'], optional: ['error', 'value'] }, + ErrorClass: { required: ['name', 'memberNameProperty'], optional: [] }, + ReplyOk: { required: ['id', 'ok', 'type', 'value'], optional: [] }, + ReplyErr: { required: ['id', 'message', 'ok', 'type'], optional: [] }, +} satisfies { + // Frames with a top-level interface bind to its keys; `global` is already the + // member name on the TS side of `CallMessage`. Frames sent as inline literals + // or nested shapes (the run frame, the namespace entry, the done error field, + // ErrorClass, and the two reply variants) have no standalone interface, so + // their keys are listed literally. + BootMessage: FrameFields + Namespace: FrameFields<'global' | 'names' | 'errorClass'> + RunMessage: FrameFields<'type' | 'program'> + BootAckMessage: FrameFields + CallMessage: FrameFields + LogMessage: FrameFields + DoneErrorField: FrameFields<'kind' | 'message'> + DoneMessage: FrameFields + ErrorClass: FrameFields<'name' | 'memberNameProperty'> + ReplyOk: FrameFields<'type' | 'id' | 'ok' | 'value'> + ReplyErr: FrameFields<'type' | 'id' | 'ok' | 'message'> +} + + /** * The in-band marker text announcing that log capture stopped at the byte * budget. Shared wire vocabulary: the Python-side LogBuffer emits it when ITS @@ -230,8 +288,13 @@ export function checkDoneValue(value: unknown, maxBytes: number): { ok: true; by while (stack.length > 0) { const current = stack.pop() if (typeof current === 'number') { + // Flag a non-lossless number but keep counting its encoded bytes: a value + // that is BOTH non-lossless and over-budget must classify as over-budget + // (the loop's byte check below wins), so the byte count cannot skip the + // offending number. `scalarJson` gives the same spelling a legit scalar + // would meter. if (!Number.isFinite(current) || Object.is(current, -0)) nonLossless = true - else bytes += Buffer.byteLength(scalarJson(current), 'utf8') + bytes += Buffer.byteLength(scalarJson(current), 'utf8') } else if (typeof current === 'string') { // Lower-bound BEFORE materializing the escaped form: every UTF-16 code // unit is at least one UTF-8 byte plus the two quotes, so a huge or diff --git a/packages/code-runtime/code-runtime-python/tests/protocol-mirror.e2e.ts b/packages/code-runtime/code-runtime-python/tests/protocol-mirror.e2e.ts index 3a191ddbb2..985581a601 100644 --- a/packages/code-runtime/code-runtime-python/tests/protocol-mirror.e2e.ts +++ b/packages/code-runtime/code-runtime-python/tests/protocol-mirror.e2e.ts @@ -3,7 +3,7 @@ import { existsSync } from 'node:fs' import { fileURLToPath } from 'node:url' import { promisify } from 'node:util' import { describe, expect, it } from 'vitest' -import { logTruncationMarker } from '../src/protocol.ts' +import { logTruncationMarker, WIRE_FRAME_FIELDS } from '../src/protocol.ts' /** * Cross-language mirror check between `src/protocol.ts` and `py/protocol.py`, @@ -56,43 +56,32 @@ describe.skipIf(!python3Available)('protocol.py mirrors protocol.ts at runtime', it('agrees on every frame type\'s wire field set between the TS and Python declarations', async () => { // Turn the TypedDict mirror from a review-only obligation into an executable // check: read each Python TypedDict's required/optional key sets and assert - // them against the wire field names the TS side declares. `global` is the - // reserved-keyword key the Python side carries via functional TypedDict — - // catching exactly the round-12 kind of drift (a renamed/dropped field, an - // optional field the other side made required). + // them against WIRE_FRAME_FIELDS — the TS-side source of truth bound to the + // frame interfaces by `satisfies` in protocol.ts, so a rename or a removed + // field on the TS side breaks typecheck and an added field breaks this + // comparison (the Python side would carry it). Covers the reply frames too. + // `global` is the reserved-keyword wire key the Python side carries via a + // functional TypedDict. This catches the round-12 kind of drift on EITHER + // side of the wire. + const pyNames = Object.keys(WIRE_FRAME_FIELDS) const probe = [ 'import json, sys', `sys.path.insert(0, ${JSON.stringify(pyDir)})`, 'import protocol as p', 'def keys(td): return {"required": sorted(td.__required_keys__), "optional": sorted(td.__optional_keys__)}', - 'print(json.dumps({', - ' "BootMessage": keys(p.BootMessage),', - ' "Namespace": keys(p.Namespace),', - ' "RunMessage": keys(p.RunMessage),', - ' "BootAckMessage": keys(p.BootAckMessage),', - ' "CallMessage": keys(p.CallMessage),', - ' "LogMessage": keys(p.LogMessage),', - ' "DoneErrorField": keys(p.DoneErrorField),', - ' "DoneMessage": keys(p.DoneMessage),', - ' "ErrorClass": keys(p.ErrorClass),', - '}))', + `names = ${JSON.stringify(pyNames)}`, + 'print(json.dumps({n: keys(getattr(p, n)) for n in names}))', ].join('\n') const { stdout } = await execFileAsync('python3', ['-I', '-c', probe]) const seen = JSON.parse(stdout) as Record - // The wire field sets each frame carries, mirroring src/protocol.ts. `global` - // is the JSON key `CallMessage`/`Namespace` send (a Python keyword, declared - // functionally on the Python side). - expect(seen).toEqual({ - BootMessage: { required: ['addressSpaceBytes', 'cpuSeconds', 'maxLogBytes', 'maxValueBytes', 'namespaces', 'type'], optional: [] }, - Namespace: { required: ['global', 'names'], optional: ['errorClass'] }, - RunMessage: { required: ['program', 'type'], optional: [] }, - BootAckMessage: { required: ['type'], optional: [] }, - CallMessage: { required: ['args', 'global', 'id', 'name', 'type'], optional: [] }, - LogMessage: { required: ['text', 'type'], optional: ['truncated'] }, - DoneErrorField: { required: ['kind', 'message'], optional: [] }, - DoneMessage: { required: ['type'], optional: ['error', 'value'] }, - ErrorClass: { required: ['memberNameProperty', 'name'], optional: [] }, - }) + // Normalize the TS source of truth to the same sorted shape Python reports. + const expected = Object.fromEntries( + Object.entries(WIRE_FRAME_FIELDS).map(([name, sets]) => [ + name, + { required: [...sets.required].sort(), optional: [...sets.optional].sort() }, + ]), + ) + expect(seen).toEqual(expected) }) }) diff --git a/packages/code-runtime/code-runtime-python/tests/protocol.spec.ts b/packages/code-runtime/code-runtime-python/tests/protocol.spec.ts index 465aa06a21..2459a15e87 100644 --- a/packages/code-runtime/code-runtime-python/tests/protocol.spec.ts +++ b/packages/code-runtime/code-runtime-python/tests/protocol.spec.ts @@ -262,6 +262,12 @@ describe('checkDoneValue', () => { // non-lossless (the recorded violation is the verdict once the whole value // is confirmed within budget). expect(checkDoneValue([Infinity], 100)).toEqual({ ok: false, reason: 'non-lossless' }) + // The non-lossless number's OWN encoded bytes still count toward the budget, + // so a value whose only over-budget contribution is the non-lossless number + // itself is classified over-budget, not non-lossless. `[Infinity]` encodes + // as the 10-byte `[Infinity]`; at cap 3 the byte check wins. + expect(checkDoneValue([Infinity], 3)).toEqual({ ok: false, reason: 'over-budget' }) + expect(checkDoneValue(Infinity, 3)).toEqual({ ok: false, reason: 'over-budget' }) }) it('meters deep nesting iteratively without overflowing the stack', () => { From 4d49406bd5f7f6af0f73bf62079d6895653a6c96 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Sun, 2 Aug 2026 18:24:41 +0800 Subject: [PATCH 17/32] fix(code-runtime-python): bind the wire-field mirror to TS required/optional keys The previous mirror binding (FrameFields) only checked membership: it could not see a TS-side optionality flip (truncated? -> truncated leaves keyof unchanged) or a field added on one side, so the "depends on the TS declaration" claim was overstated. - Promote the inline frame shapes (Namespace, ErrorClass, DoneErrorField, RunMessage, and the two Reply variants) to named interfaces so every frame binds uniformly. - Derive FrameFields from RequiredKeys/OptionalKeys, so `required` and `optional` each accept only that side's keys. An optionality flip or a rename now fails typecheck (verified: flipping LogMessage.truncated to required errors at the constant). - Enumerate EVERY public TypedDict in py/protocol.py in the mirror e2e (not a name list taken from the TS side) and assert both the frame roster and each frame's required/optional sets by exact equality, so a frame or field present on only one side of the wire fails the test. --- .../code-runtime-python/src/protocol.ts | 132 ++++++++++++------ .../tests/protocol-mirror.e2e.ts | 27 ++-- 2 files changed, 103 insertions(+), 56 deletions(-) diff --git a/packages/code-runtime/code-runtime-python/src/protocol.ts b/packages/code-runtime/code-runtime-python/src/protocol.ts index c53f2c9dd3..62966f9ba0 100644 --- a/packages/code-runtime/code-runtime-python/src/protocol.ts +++ b/packages/code-runtime/code-runtime-python/src/protocol.ts @@ -11,6 +11,26 @@ // child, and the Python bootstrap reads the same constant from its own // protocol.py. +/** + * One binding namespace declaration inside a {@link BootMessage}. `global` is + * the program-visible name the namespace is materialized under; `errorClass`, + * when present, asks the bootstrap to mint a program-visible exception class. + */ +export interface Namespace { + global: string + names: string[] + errorClass?: ErrorClass +} + +/** + * A namespace's program-visible exception class: rejected calls raise its + * instances carrying the failed member name on `memberNameProperty`. + */ +export interface ErrorClass { + name: string + memberNameProperty: string +} + /** * What the host sends immediately after spawn, as the first line on fd 3. The * Python bootstrap reads this, applies resource limits, then waits for the @@ -29,16 +49,16 @@ export interface BootMessage { maxValueBytes: number /** * The namespaces to materialize inside the program (globals + names; - * functions stay host-side). `errorClass` asks the bootstrap to mint a - * program-visible exception class under that global: rejected calls raise - * its instances carrying the member name on `memberNameProperty`. + * functions stay host-side). See {@link Namespace}. */ - namespaces: { global: string; names: string[]; errorClass?: { name: string; memberNameProperty: string } }[] + namespaces: Namespace[] } -// The run request `{ type: 'run', program }` follows BootMessage once the -// child acknowledges with `boot-ack`; the host sends it as an inline literal -// (it carries only the model's program body — caps and bindings crossed on boot). +/** Host → Python: sent after `boot-ack`; carries only the model's program body. */ +export interface RunMessage { + type: 'run' + program: string +} /** Python → host: acknowledges boot completed and resource limits are in place. */ interface BootAckMessage { @@ -78,6 +98,12 @@ interface LogMessage { truncated?: boolean } +/** The failure carried on a {@link DoneMessage}: one of three kinds plus text. */ +export interface DoneErrorField { + kind: 'exception' | 'invalid-output' | 'output-limit' + message: string +} + /** * Python → host: the program settled. `error` carries a program exception * (traceback text), an `invalid-output` (completion value was not lossless @@ -92,7 +118,7 @@ interface LogMessage { interface DoneMessage { type: 'done' value?: unknown - error?: { kind: 'exception' | 'invalid-output' | 'output-limit'; message: string } + error?: DoneErrorField } /** @@ -102,36 +128,57 @@ interface DoneMessage { */ export type ChildToHost = BootAckMessage | CallMessage | LogMessage | DoneMessage +/** Host → Python: successful answer to one {@link CallMessage}. */ +export interface ReplyOk { + type: 'reply' + id: number + ok: true + value: unknown +} + +/** Host → Python: failed answer to one {@link CallMessage}. */ +export interface ReplyErr { + type: 'reply' + id: number + ok: false + message: string +} + /** Host → Python: the answer to one {@link CallMessage}. */ -export type ReplyMessage = - | { type: 'reply'; id: number; ok: true; value: unknown } - | { type: 'reply'; id: number; ok: false; message: string } +export type ReplyMessage = ReplyOk | ReplyErr + +/** The required (non-optional) keys of `T`, as string literals. */ +type RequiredKeys = { [K in keyof T]-?: object extends Pick ? never : K }[keyof T] & string +/** The optional keys of `T`, as string literals. */ +type OptionalKeys = { [K in keyof T]-?: object extends Pick ? K : never }[keyof T] & string /** - * Shape of one {@link WIRE_FRAME_FIELDS} entry, parameterised by that frame's - * key union `K`. `required` and `optional` are arrays of `K`, so listing a name - * no frame declares — a typo or a renamed field — fails typecheck. (A field - * ADDED to an interface but omitted here is caught at runtime instead: the - * mirror test asserts the Python `TypedDict` keys equal these exact sets, and - * the Python side would carry the new field.) `K` is `PropertyKey` so a bare - * `keyof Interface` binds without narrowing. + * Shape of one {@link WIRE_FRAME_FIELDS} entry, derived from frame interface + * `T`. Every element of `required` must be one of `T`'s required keys and every + * element of `optional` one of `T`'s optional keys — so a renamed field, or an + * optionality flip (`truncated?` → `truncated`, which moves the name between the + * two arrays' element types), fails typecheck. Completeness in the other + * direction (every declared key actually appears, and no frame exists on only + * one side of the wire) is enforced at runtime by the mirror test, which + * compares these arrays to the Python `TypedDict`'s + * `__required_keys__`/`__optional_keys__` by exact set equality over the full + * frame roster. */ -type FrameFields = { - required: readonly K[] - optional: readonly K[] +type FrameFields = { + required: readonly RequiredKeys[] + optional: readonly OptionalKeys[] } /** * The wire field names of each frame, split into required and optional keys, as * a RUNTIME value the cross-language mirror test asserts `py/protocol.py`'s * `TypedDict`s against. The `satisfies` clause binds each entry to its frame - * interface's own key set, so listing a name no frame declares fails - * typecheck — the mirror test therefore depends on the TS declarations above, - * not a hand-copied list. `global` is the JSON key {@link CallMessage} and the - * namespace declaration send (a reserved word the Python side carries via a - * functional `TypedDict`); inline sub-shapes (the namespace entry in - * {@link BootMessage}, the error field in {@link DoneMessage}, the reply - * variants) list their keys literally. + * interface via {@link FrameFields}, which derives the required/optional key + * sets FROM the interface — so a renamed, removed, or optionality-flipped field + * on the TS side fails typecheck, and the mirror test catches a Python-side + * divergence at runtime. `global` is the JSON key {@link CallMessage} and + * {@link Namespace} send (a reserved word the Python side carries via a + * functional `TypedDict`). */ export const WIRE_FRAME_FIELDS = { BootMessage: { required: ['addressSpaceBytes', 'cpuSeconds', 'maxLogBytes', 'maxValueBytes', 'namespaces', 'type'], optional: [] }, @@ -142,26 +189,21 @@ export const WIRE_FRAME_FIELDS = { LogMessage: { required: ['text', 'type'], optional: ['truncated'] }, DoneErrorField: { required: ['kind', 'message'], optional: [] }, DoneMessage: { required: ['type'], optional: ['error', 'value'] }, - ErrorClass: { required: ['name', 'memberNameProperty'], optional: [] }, + ErrorClass: { required: ['memberNameProperty', 'name'], optional: [] }, ReplyOk: { required: ['id', 'ok', 'type', 'value'], optional: [] }, ReplyErr: { required: ['id', 'message', 'ok', 'type'], optional: [] }, } satisfies { - // Frames with a top-level interface bind to its keys; `global` is already the - // member name on the TS side of `CallMessage`. Frames sent as inline literals - // or nested shapes (the run frame, the namespace entry, the done error field, - // ErrorClass, and the two reply variants) have no standalone interface, so - // their keys are listed literally. - BootMessage: FrameFields - Namespace: FrameFields<'global' | 'names' | 'errorClass'> - RunMessage: FrameFields<'type' | 'program'> - BootAckMessage: FrameFields - CallMessage: FrameFields - LogMessage: FrameFields - DoneErrorField: FrameFields<'kind' | 'message'> - DoneMessage: FrameFields - ErrorClass: FrameFields<'name' | 'memberNameProperty'> - ReplyOk: FrameFields<'type' | 'id' | 'ok' | 'value'> - ReplyErr: FrameFields<'type' | 'id' | 'ok' | 'message'> + BootMessage: FrameFields + Namespace: FrameFields + RunMessage: FrameFields + BootAckMessage: FrameFields + CallMessage: FrameFields + LogMessage: FrameFields + DoneErrorField: FrameFields + DoneMessage: FrameFields + ErrorClass: FrameFields + ReplyOk: FrameFields + ReplyErr: FrameFields } diff --git a/packages/code-runtime/code-runtime-python/tests/protocol-mirror.e2e.ts b/packages/code-runtime/code-runtime-python/tests/protocol-mirror.e2e.ts index 985581a601..9eb0c4f742 100644 --- a/packages/code-runtime/code-runtime-python/tests/protocol-mirror.e2e.ts +++ b/packages/code-runtime/code-runtime-python/tests/protocol-mirror.e2e.ts @@ -55,22 +55,24 @@ describe.skipIf(!python3Available)('protocol.py mirrors protocol.ts at runtime', it('agrees on every frame type\'s wire field set between the TS and Python declarations', async () => { // Turn the TypedDict mirror from a review-only obligation into an executable - // check: read each Python TypedDict's required/optional key sets and assert - // them against WIRE_FRAME_FIELDS — the TS-side source of truth bound to the - // frame interfaces by `satisfies` in protocol.ts, so a rename or a removed - // field on the TS side breaks typecheck and an added field breaks this - // comparison (the Python side would carry it). Covers the reply frames too. - // `global` is the reserved-keyword wire key the Python side carries via a - // functional TypedDict. This catches the round-12 kind of drift on EITHER - // side of the wire. - const pyNames = Object.keys(WIRE_FRAME_FIELDS) + // check: enumerate EVERY TypedDict in py/protocol.py (public names carrying + // __required_keys__) and assert both the frame roster and each frame's + // required/optional key sets against WIRE_FRAME_FIELDS — the TS-side source + // of truth bound to the frame interfaces by `satisfies` in protocol.ts. + // Together this catches drift on EITHER side of the wire: a TS rename or + // optionality flip breaks typecheck; a Python frame added, removed, or with + // a changed field set breaks this comparison. `global` is the reserved- + // keyword wire key the Python side carries via a functional TypedDict. const probe = [ 'import json, sys', `sys.path.insert(0, ${JSON.stringify(pyDir)})`, 'import protocol as p', 'def keys(td): return {"required": sorted(td.__required_keys__), "optional": sorted(td.__optional_keys__)}', - `names = ${JSON.stringify(pyNames)}`, - 'print(json.dumps({n: keys(getattr(p, n)) for n in names}))', + // Every public TypedDict in the module — not a name list from the TS side, + // so a Python-only extra frame is visible here. + 'frames = {n: keys(v) for n, v in vars(p).items()' + + ' if not n.startswith("_") and hasattr(v, "__required_keys__")}', + 'print(json.dumps(frames))', ].join('\n') const { stdout } = await execFileAsync('python3', ['-I', '-c', probe]) const seen = JSON.parse(stdout) as Record @@ -81,6 +83,9 @@ describe.skipIf(!python3Available)('protocol.py mirrors protocol.ts at runtime', { required: [...sets.required].sort(), optional: [...sets.optional].sort() }, ]), ) + // Same frame roster on both sides (catches a frame present on only one), + // then identical field sets per frame. + expect(Object.keys(seen).sort()).toEqual(Object.keys(expected).sort()) expect(seen).toEqual(expected) }) }) From adba2e305a9e8fb4f250601cc28d6c7c4d566c7b Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Sun, 2 Aug 2026 18:49:07 +0800 Subject: [PATCH 18/32] fix(code-runtime-python): make the wire-field binding exhaustive over interface keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The array-based FrameFields only checked that listed names were members of the frame's keys, so a field added to a TS interface (e.g. LogMessage.seq?) left the existing arrays a valid subset — typecheck passed, and since the constant and Python both lacked the field the mirror test passed too. The JSDoc's claim that runtime covered this was false. Replace it with WIRE_FRAME_FIELD_ROLES, a per-frame map keyed by field name (`Record, 'required'> & Record, 'optional'>`), so every interface key MUST appear with a matching required/optional tag: an added field, a removed field, a rename, or an optionality flip all fail typecheck at the roles map (verified). WIRE_FRAME_FIELDS is projected from it as the sorted arrays the mirror test still compares to the Python TypedDicts. Also drop the `export` added to the promoted frame interfaces (Namespace, ErrorClass, RunMessage, DoneErrorField, ReplyOk, ReplyErr) — nothing outside protocol.ts imports them, so the barrel surface is unchanged and knip stays clean. --- .../code-runtime-python/src/protocol.ts | 118 ++++++++++-------- 1 file changed, 65 insertions(+), 53 deletions(-) diff --git a/packages/code-runtime/code-runtime-python/src/protocol.ts b/packages/code-runtime/code-runtime-python/src/protocol.ts index 62966f9ba0..5d8bd2d555 100644 --- a/packages/code-runtime/code-runtime-python/src/protocol.ts +++ b/packages/code-runtime/code-runtime-python/src/protocol.ts @@ -16,7 +16,7 @@ * the program-visible name the namespace is materialized under; `errorClass`, * when present, asks the bootstrap to mint a program-visible exception class. */ -export interface Namespace { +interface Namespace { global: string names: string[] errorClass?: ErrorClass @@ -26,7 +26,7 @@ export interface Namespace { * A namespace's program-visible exception class: rejected calls raise its * instances carrying the failed member name on `memberNameProperty`. */ -export interface ErrorClass { +interface ErrorClass { name: string memberNameProperty: string } @@ -55,7 +55,7 @@ export interface BootMessage { } /** Host → Python: sent after `boot-ack`; carries only the model's program body. */ -export interface RunMessage { +interface RunMessage { type: 'run' program: string } @@ -99,7 +99,7 @@ interface LogMessage { } /** The failure carried on a {@link DoneMessage}: one of three kinds plus text. */ -export interface DoneErrorField { +interface DoneErrorField { kind: 'exception' | 'invalid-output' | 'output-limit' message: string } @@ -129,7 +129,7 @@ interface DoneMessage { export type ChildToHost = BootAckMessage | CallMessage | LogMessage | DoneMessage /** Host → Python: successful answer to one {@link CallMessage}. */ -export interface ReplyOk { +interface ReplyOk { type: 'reply' id: number ok: true @@ -137,7 +137,7 @@ export interface ReplyOk { } /** Host → Python: failed answer to one {@link CallMessage}. */ -export interface ReplyErr { +interface ReplyErr { type: 'reply' id: number ok: false @@ -153,58 +153,70 @@ type RequiredKeys = { [K in keyof T]-?: object extends Pick ? never : K type OptionalKeys = { [K in keyof T]-?: object extends Pick ? K : never }[keyof T] & string /** - * Shape of one {@link WIRE_FRAME_FIELDS} entry, derived from frame interface - * `T`. Every element of `required` must be one of `T`'s required keys and every - * element of `optional` one of `T`'s optional keys — so a renamed field, or an - * optionality flip (`truncated?` → `truncated`, which moves the name between the - * two arrays' element types), fails typecheck. Completeness in the other - * direction (every declared key actually appears, and no frame exists on only - * one side of the wire) is enforced at runtime by the mirror test, which - * compares these arrays to the Python `TypedDict`'s - * `__required_keys__`/`__optional_keys__` by exact set equality over the full - * frame roster. + * Whether each key of frame `T` is a `'required'` or `'optional'` wire field. + * Because it is `Record`, an entry MUST list every key — a field + * added to the interface without a corresponding entry fails typecheck — and + * `keyof T`-typed keys reject a name no frame declares. The `'required'` / + * `'optional'` tag must match the field's actual optionality (checked by + * {@link WIRE_FRAME_FIELDS}'s per-entry assertions), so an optionality flip is + * caught too. This is the exhaustive counterpart the array form could not + * express (a subset array satisfied it silently). */ -type FrameFields = { - required: readonly RequiredKeys[] - optional: readonly OptionalKeys[] +type FrameFieldRoles = Record, 'required'> & Record, 'optional'> + +/** + * Each frame's wire fields tagged by required/optional, keyed by field name so + * the mapping is exhaustive over the frame interface (see + * {@link FrameFieldRoles}). Bound to the interfaces by `satisfies` below, this + * is the single source of truth the cross-language mirror test derives its + * expectations from; {@link WIRE_FRAME_FIELDS} projects it to sorted + * required/optional arrays for the comparison. `global` is the JSON key + * {@link CallMessage} and {@link Namespace} send (a reserved word the Python + * side carries via a functional `TypedDict`). + */ +const WIRE_FRAME_FIELD_ROLES = { + BootMessage: { type: 'required', cpuSeconds: 'required', addressSpaceBytes: 'required', maxLogBytes: 'required', maxValueBytes: 'required', namespaces: 'required' }, + Namespace: { global: 'required', names: 'required', errorClass: 'optional' }, + RunMessage: { type: 'required', program: 'required' }, + BootAckMessage: { type: 'required' }, + CallMessage: { type: 'required', id: 'required', global: 'required', name: 'required', args: 'required' }, + LogMessage: { type: 'required', text: 'required', truncated: 'optional' }, + DoneErrorField: { kind: 'required', message: 'required' }, + DoneMessage: { type: 'required', value: 'optional', error: 'optional' }, + ErrorClass: { name: 'required', memberNameProperty: 'required' }, + ReplyOk: { type: 'required', id: 'required', ok: 'required', value: 'required' }, + ReplyErr: { type: 'required', id: 'required', ok: 'required', message: 'required' }, +} as const satisfies { + BootMessage: FrameFieldRoles + Namespace: FrameFieldRoles + RunMessage: FrameFieldRoles + BootAckMessage: FrameFieldRoles + CallMessage: FrameFieldRoles + LogMessage: FrameFieldRoles + DoneErrorField: FrameFieldRoles + DoneMessage: FrameFieldRoles + ErrorClass: FrameFieldRoles + ReplyOk: FrameFieldRoles + ReplyErr: FrameFieldRoles } /** - * The wire field names of each frame, split into required and optional keys, as - * a RUNTIME value the cross-language mirror test asserts `py/protocol.py`'s - * `TypedDict`s against. The `satisfies` clause binds each entry to its frame - * interface via {@link FrameFields}, which derives the required/optional key - * sets FROM the interface — so a renamed, removed, or optionality-flipped field - * on the TS side fails typecheck, and the mirror test catches a Python-side - * divergence at runtime. `global` is the JSON key {@link CallMessage} and - * {@link Namespace} send (a reserved word the Python side carries via a - * functional `TypedDict`). + * The wire field names of each frame, split into sorted required and optional + * key arrays — the shape the cross-language mirror test compares against + * `py/protocol.py`'s `TypedDict` `__required_keys__`/`__optional_keys__`. + * Projected from {@link WIRE_FRAME_FIELD_ROLES}, so it inherits that mapping's + * exhaustive, optionality-checked binding to the frame interfaces: a TS-side + * field add, remove, rename, or optionality flip fails typecheck at the roles + * map, and a Python-side divergence fails the mirror test at runtime. */ -export const WIRE_FRAME_FIELDS = { - BootMessage: { required: ['addressSpaceBytes', 'cpuSeconds', 'maxLogBytes', 'maxValueBytes', 'namespaces', 'type'], optional: [] }, - Namespace: { required: ['global', 'names'], optional: ['errorClass'] }, - RunMessage: { required: ['program', 'type'], optional: [] }, - BootAckMessage: { required: ['type'], optional: [] }, - CallMessage: { required: ['args', 'global', 'id', 'name', 'type'], optional: [] }, - LogMessage: { required: ['text', 'type'], optional: ['truncated'] }, - DoneErrorField: { required: ['kind', 'message'], optional: [] }, - DoneMessage: { required: ['type'], optional: ['error', 'value'] }, - ErrorClass: { required: ['memberNameProperty', 'name'], optional: [] }, - ReplyOk: { required: ['id', 'ok', 'type', 'value'], optional: [] }, - ReplyErr: { required: ['id', 'message', 'ok', 'type'], optional: [] }, -} satisfies { - BootMessage: FrameFields - Namespace: FrameFields - RunMessage: FrameFields - BootAckMessage: FrameFields - CallMessage: FrameFields - LogMessage: FrameFields - DoneErrorField: FrameFields - DoneMessage: FrameFields - ErrorClass: FrameFields - ReplyOk: FrameFields - ReplyErr: FrameFields -} +export const WIRE_FRAME_FIELDS: Record = + Object.fromEntries( + Object.entries(WIRE_FRAME_FIELD_ROLES).map(([frame, roles]) => { + const required = Object.keys(roles).filter(key => (roles as Record)[key] === 'required').sort() + const optional = Object.keys(roles).filter(key => (roles as Record)[key] === 'optional').sort() + return [frame, { required, optional }] + }), + ) as Record /** From 4de8c914c9e4c7f11d3f370c2f006be0d9f655d1 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Sun, 2 Aug 2026 19:10:12 +0800 Subject: [PATCH 19/32] refactor(code-runtime-python): export PROTOCOL_FD and tidy the mirror binding Address the remaining review findings on the wire-mirror layer: - Export PROTOCOL_FD from protocol.ts as the TS-side source of truth the host wires, and assert the Python constant against it in the mirror e2e instead of a bare literal 3, so an fd drift on either side is caught. - Correct the FrameFieldRoles JSDoc to point at the actual assertion site (WIRE_FRAME_FIELD_ROLES's satisfies clause, not WIRE_FRAME_FIELDS). - Drop the redundant explicit type annotation on WIRE_FRAME_FIELDS (the trailing `as` cast already types it; Object.fromEntries returns an index signature). - Refresh the mirror-test comment to describe the roles-map binding (a TS-side add/remove/rename/optionality-flip fails typecheck; a Python-side change fails the comparison). --- .../code-runtime-python/src/protocol.ts | 24 ++++++++++++------- .../tests/protocol-mirror.e2e.ts | 21 ++++++++-------- 2 files changed, 26 insertions(+), 19 deletions(-) diff --git a/packages/code-runtime/code-runtime-python/src/protocol.ts b/packages/code-runtime/code-runtime-python/src/protocol.ts index 5d8bd2d555..01d73d04e8 100644 --- a/packages/code-runtime/code-runtime-python/src/protocol.ts +++ b/packages/code-runtime/code-runtime-python/src/protocol.ts @@ -6,10 +6,16 @@ * @module @deepseek-ai/dsh-code-runtime-python/src/protocol */ -// The protocol channel is fd 3 from the child's perspective — the host pins it -// positionally via `stdio: ['pipe','pipe','pipe','pipe']` when it spawns the -// child, and the Python bootstrap reads the same constant from its own -// protocol.py. +/** + * The framed-JSON channel's file descriptor from the child's perspective. The + * host pins it positionally when it spawns the child (`stdio` index 3, i.e. + * `['pipe','pipe','pipe','pipe']`), and the Python bootstrap reads the same + * number from its own `protocol.py`. Exported as the single TS-side source of + * truth: the host wiring uses it, and the cross-language mirror test asserts the + * Python constant equals it, so a drift on either side breaks the boot channel + * loudly rather than silently. + */ +export const PROTOCOL_FD = 3 /** * One binding namespace declaration inside a {@link BootMessage}. `global` is @@ -157,10 +163,10 @@ type OptionalKeys = { [K in keyof T]-?: object extends Pick ? K : never * Because it is `Record`, an entry MUST list every key — a field * added to the interface without a corresponding entry fails typecheck — and * `keyof T`-typed keys reject a name no frame declares. The `'required'` / - * `'optional'` tag must match the field's actual optionality (checked by - * {@link WIRE_FRAME_FIELDS}'s per-entry assertions), so an optionality flip is - * caught too. This is the exhaustive counterpart the array form could not - * express (a subset array satisfied it silently). + * `'optional'` tag must match the field's actual optionality (checked by the + * `satisfies FrameFieldRoles<…>` clause on {@link WIRE_FRAME_FIELD_ROLES}), so + * an optionality flip is caught too. This is the exhaustive counterpart the + * array form could not express (a subset array satisfied it silently). */ type FrameFieldRoles = Record, 'required'> & Record, 'optional'> @@ -209,7 +215,7 @@ const WIRE_FRAME_FIELD_ROLES = { * field add, remove, rename, or optionality flip fails typecheck at the roles * map, and a Python-side divergence fails the mirror test at runtime. */ -export const WIRE_FRAME_FIELDS: Record = +export const WIRE_FRAME_FIELDS = Object.fromEntries( Object.entries(WIRE_FRAME_FIELD_ROLES).map(([frame, roles]) => { const required = Object.keys(roles).filter(key => (roles as Record)[key] === 'required').sort() diff --git a/packages/code-runtime/code-runtime-python/tests/protocol-mirror.e2e.ts b/packages/code-runtime/code-runtime-python/tests/protocol-mirror.e2e.ts index 9eb0c4f742..ca28feb8ee 100644 --- a/packages/code-runtime/code-runtime-python/tests/protocol-mirror.e2e.ts +++ b/packages/code-runtime/code-runtime-python/tests/protocol-mirror.e2e.ts @@ -3,7 +3,7 @@ import { existsSync } from 'node:fs' import { fileURLToPath } from 'node:url' import { promisify } from 'node:util' import { describe, expect, it } from 'vitest' -import { logTruncationMarker, WIRE_FRAME_FIELDS } from '../src/protocol.ts' +import { logTruncationMarker, PROTOCOL_FD, WIRE_FRAME_FIELDS } from '../src/protocol.ts' /** * Cross-language mirror check between `src/protocol.ts` and `py/protocol.py`, @@ -47,9 +47,9 @@ describe.skipIf(!python3Available)('protocol.py mirrors protocol.ts at runtime', ].join('\n') const { stdout } = await execFileAsync('python3', ['-I', '-c', probe]) const seen = JSON.parse(stdout) as { fd: number; markers: string[] } - // fd 3 is the wire contract, not a tunable: the host pins it positionally - // when it spawns the child. - expect(seen.fd).toBe(3) + // Assert against the TS-side PROTOCOL_FD export (the value the host wires), + // not a bare literal, so a drift on either side of the wire is caught here. + expect(seen.fd).toBe(PROTOCOL_FD) expect(seen.markers).toEqual(budgets.map(budget => logTruncationMarker(budget))) }) @@ -57,12 +57,13 @@ describe.skipIf(!python3Available)('protocol.py mirrors protocol.ts at runtime', // Turn the TypedDict mirror from a review-only obligation into an executable // check: enumerate EVERY TypedDict in py/protocol.py (public names carrying // __required_keys__) and assert both the frame roster and each frame's - // required/optional key sets against WIRE_FRAME_FIELDS — the TS-side source - // of truth bound to the frame interfaces by `satisfies` in protocol.ts. - // Together this catches drift on EITHER side of the wire: a TS rename or - // optionality flip breaks typecheck; a Python frame added, removed, or with - // a changed field set breaks this comparison. `global` is the reserved- - // keyword wire key the Python side carries via a functional TypedDict. + // required/optional key sets against WIRE_FRAME_FIELDS — projected from the + // WIRE_FRAME_FIELD_ROLES map that `satisfies` binds exhaustively to the + // frame interfaces in protocol.ts. Together this catches drift on EITHER + // side of the wire: a TS-side field add, remove, rename, or optionality flip + // fails typecheck at the roles map; a Python frame added, removed, or with a + // changed field set fails this comparison. `global` is the reserved-keyword + // wire key the Python side carries via a functional TypedDict. const probe = [ 'import json, sys', `sys.path.insert(0, ${JSON.stringify(pyDir)})`, From f9ab1edc68516b6db4a78def334bcf6b5525f5ce Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Mon, 3 Aug 2026 11:07:58 +0800 Subject: [PATCH 20/32] fix(code-runtime-python): meter escaped string bytes without allocating, bind frame roster to the unions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two review findings on checkDoneValue's metering and the wire-mirror binding: - The string/key byte check used a decoded-length lower bound and then called JSON.stringify, which materializes the ~6x escaped copy before the over-budget check — the hundreds-of-MB spike the metered walk exists to avoid. Add jsonStringBytesUpTo, a non-allocating scan that computes the exact escaped UTF-8 size (matching JSON.stringify byte for byte, including surrogate pairs vs lone surrogates) and bails the instant it crosses the remaining budget; use it for both string values and object keys. - The frame roster in WIRE_FRAME_FIELD_ROLES was hand-written, so a frame added to ChildToHost/ReplyMessage without a roster entry slipped past. Introduce WireFrameShapes (name -> interface) as the canonical roster the roles map is bound against, plus a WireFrameShapesCoverUnions compile-time assertion that every message-union member appears in it (verified: adding a frame to a union without a WireFrameShapes entry fails typecheck). --- .../code-runtime-python/src/protocol.ts | 130 +++++++++++++----- .../tests/protocol.spec.ts | 37 +++-- 2 files changed, 124 insertions(+), 43 deletions(-) diff --git a/packages/code-runtime/code-runtime-python/src/protocol.ts b/packages/code-runtime/code-runtime-python/src/protocol.ts index 01d73d04e8..d5b1ffe4c5 100644 --- a/packages/code-runtime/code-runtime-python/src/protocol.ts +++ b/packages/code-runtime/code-runtime-python/src/protocol.ts @@ -170,15 +170,44 @@ type OptionalKeys = { [K in keyof T]-?: object extends Pick ? K : never */ type FrameFieldRoles = Record, 'required'> & Record, 'optional'> +interface WireFrameShapes { + BootMessage: BootMessage + Namespace: Namespace + RunMessage: RunMessage + BootAckMessage: BootAckMessage + CallMessage: CallMessage + LogMessage: LogMessage + DoneErrorField: DoneErrorField + DoneMessage: DoneMessage + ErrorClass: ErrorClass + ReplyOk: ReplyOk + ReplyErr: ReplyErr +} + +/** + * Compile-time proof that {@link WireFrameShapes} lists every frame carried on a + * message union: the union of the frame types (`ChildToHost`, the reply + * variants, and the host-to-child boot/run frames) must be assignable to the + * union of the roster's value types. Adding a frame to a union without a + * `WireFrameShapes` entry makes this alias `false`, so the assignment below + * fails to compile — closing the whole-frame drift the field-level binding + * alone could not see. Nested shapes (`Namespace`, `ErrorClass`, + * `DoneErrorField`) are not union members; they are covered by the roles + * `satisfies` and the mirror e2e's roster comparison. + */ +type WireFrameShapesCoverUnions = + [ChildToHost | ReplyMessage | BootMessage | RunMessage] extends [WireFrameShapes[keyof WireFrameShapes]] ? true : false +const _wireFrameShapesCoverUnions: WireFrameShapesCoverUnions = true +void _wireFrameShapesCoverUnions + /** * Each frame's wire fields tagged by required/optional, keyed by field name so - * the mapping is exhaustive over the frame interface (see - * {@link FrameFieldRoles}). Bound to the interfaces by `satisfies` below, this - * is the single source of truth the cross-language mirror test derives its - * expectations from; {@link WIRE_FRAME_FIELDS} projects it to sorted - * required/optional arrays for the comparison. `global` is the JSON key - * {@link CallMessage} and {@link Namespace} send (a reserved word the Python - * side carries via a functional `TypedDict`). + * the mapping is exhaustive over the frame interface (see {@link FrameFieldRoles}) + * across the whole {@link WireFrameShapes} roster. Bound to the interfaces by + * `satisfies` below; {@link WIRE_FRAME_FIELDS} projects it to sorted + * required/optional arrays for the cross-language mirror comparison. `global` is + * the JSON key {@link CallMessage} and {@link Namespace} send (a reserved word + * the Python side carries via a functional `TypedDict`). */ const WIRE_FRAME_FIELD_ROLES = { BootMessage: { type: 'required', cpuSeconds: 'required', addressSpaceBytes: 'required', maxLogBytes: 'required', maxValueBytes: 'required', namespaces: 'required' }, @@ -192,19 +221,7 @@ const WIRE_FRAME_FIELD_ROLES = { ErrorClass: { name: 'required', memberNameProperty: 'required' }, ReplyOk: { type: 'required', id: 'required', ok: 'required', value: 'required' }, ReplyErr: { type: 'required', id: 'required', ok: 'required', message: 'required' }, -} as const satisfies { - BootMessage: FrameFieldRoles - Namespace: FrameFieldRoles - RunMessage: FrameFieldRoles - BootAckMessage: FrameFieldRoles - CallMessage: FrameFieldRoles - LogMessage: FrameFieldRoles - DoneErrorField: FrameFieldRoles - DoneMessage: FrameFieldRoles - ErrorClass: FrameFieldRoles - ReplyOk: FrameFieldRoles - ReplyErr: FrameFieldRoles -} +} as const satisfies { [K in keyof WireFrameShapes]: FrameFieldRoles } /** * The wire field names of each frame, split into sorted required and optional @@ -307,6 +324,53 @@ function scalarJson(current: unknown): string { return String(current) } +/** + * Exact UTF-8 byte length of one string's compact JSON form (quotes + escapes), + * computed by a single non-allocating scan that stops the instant the running + * total exceeds `maxBytes`. Used instead of `Buffer.byteLength(JSON.stringify(s))` + * so a control-heavy forged string — whose escaped copy expands up to ~6x — is + * rejected BEFORE that copy is materialized: `JSON.stringify` would allocate the + * full escaped form first, the very hundreds-of-MB spike the metered traversal + * exists to avoid. Mirrors `JSON.stringify`'s escaping byte-for-byte: `"` and + * `\` and the five short C0 escapes cost 2, other C0 controls `\uXXXX` cost 6, a + * valid surrogate pair is one astral code point emitted as raw 4-byte UTF-8, a + * LONE surrogate becomes `\uXXXX` at 6, and any other code point costs its raw + * UTF-8 width. + * @param text - the string to meter. + * @param maxBytes - largest serialized size the caller can still admit. + * @returns the exact serialized byte length, or `undefined` once it exceeds `maxBytes`. + */ +function jsonStringBytesUpTo(text: string, maxBytes: number): number | undefined { + let bytes = 2 // the two quotes + if (bytes > maxBytes) return undefined + for (let index = 0; index < text.length; index++) { + const code = text.charCodeAt(index) + if (code === 0x22 || code === 0x5c || code === 0x08 || code === 0x09 || code === 0x0a || code === 0x0c || code === 0x0d) { + bytes += 2 // `\"` `\\` `\b` `\t` `\n` `\f` `\r` + } else if (code < 0x20) { + bytes += 6 // other C0 controls: `\uXXXX` + } else if (code < 0x80) { + bytes += 1 + } else if (code < 0x800) { + bytes += 2 + } else if (code >= 0xd800 && code <= 0xdbff && index + 1 < text.length) { + const next = text.charCodeAt(index + 1) + if (next >= 0xdc00 && next <= 0xdfff) { + bytes += 4 // valid high+low pair: one astral code point, raw 4-byte UTF-8 + index++ + } else { + bytes += 6 // lone high surrogate: `\uXXXX` + } + } else if (code >= 0xd800 && code <= 0xdfff) { + bytes += 6 // lone surrogate (unpaired high at end, or any low): `\uXXXX` + } else { + bytes += 3 // other BMP code point + } + if (bytes > maxBytes) return undefined + } + return bytes +} + /** * Meter a `JSON.parse`-produced done value's compact-JSON byte length AND its * number losslessness in one traversal, stopping the instant `maxBytes` is @@ -325,10 +389,11 @@ function scalarJson(current: unknown): string { * enqueue loop. A non-lossless number (non-finite, negative zero) is caught only * when the value fits the budget — an over-budget value is rejected regardless, * so the distinction is moot. Same JSON-plain precondition and traversal shape - * as {@link encodeJsonPlain}; per-scalar byte length is measured through + * as {@link encodeJsonPlain}; a number's byte length is measured through * {@link scalarJson} (matching the encoder, so a beyond-safe-range integer * meters its exact BigInt digits, not `JSON.stringify`'s rounded spelling) and - * `JSON.stringify` for strings. + * a string's/key's through {@link jsonStringBytesUpTo} (the exact escaped size, + * scanned without allocating the escaped copy). * @param value - a JSON-plain value (e.g. straight from `JSON.parse`). * @param maxBytes - the completion-value budget in bytes. * @returns `{ ok: true, bytes }` with the exact serialized size, or @@ -356,12 +421,13 @@ export function checkDoneValue(value: unknown, maxBytes: number): { ok: true; by if (!Number.isFinite(current) || Object.is(current, -0)) nonLossless = true bytes += Buffer.byteLength(scalarJson(current), 'utf8') } else if (typeof current === 'string') { - // Lower-bound BEFORE materializing the escaped form: every UTF-16 code - // unit is at least one UTF-8 byte plus the two quotes, so a huge or - // control-heavy forged string (whose escaped copy expands severalfold) - // is rejected without allocating that copy. - if (bytes + current.length + 2 > maxBytes) return { ok: false, reason: 'over-budget' } - bytes += Buffer.byteLength(JSON.stringify(current), 'utf8') + // Meter the escaped form WITHOUT allocating it: jsonStringBytesUpTo scans + // and bails the instant the running cost crosses the remaining budget, so + // a control-heavy forgery (escaped copy up to ~6x) never materializes that + // copy the way `JSON.stringify` would. + const stringBytes = jsonStringBytesUpTo(current, maxBytes - bytes) + if (stringBytes === undefined) return { ok: false, reason: 'over-budget' } + bytes += stringBytes } else if (Array.isArray(current)) { // Brackets plus one comma per gap; elements add themselves. Reject // BEFORE enqueuing children: every element serializes to at least one @@ -384,9 +450,11 @@ export function checkDoneValue(value: unknown, maxBytes: number): { ok: true; by if (bytes + count * 4 > maxBytes) return { ok: false, reason: 'over-budget' } for (const key in record) { if (!Object.hasOwn(record, key)) continue - // The same string lower bound, before escaping the key. - if (bytes + key.length + 3 > maxBytes) return { ok: false, reason: 'over-budget' } - bytes += Buffer.byteLength(JSON.stringify(key), 'utf8') + 1 + // Meter the key's escaped form without allocating it (same reason as the + // string branch), then add the colon separator. `+ 1` for the `:`. + const keyBytes = jsonStringBytesUpTo(key, maxBytes - bytes) + if (keyBytes === undefined) return { ok: false, reason: 'over-budget' } + bytes += keyBytes + 1 stack.push(record[key]) } } else { diff --git a/packages/code-runtime/code-runtime-python/tests/protocol.spec.ts b/packages/code-runtime/code-runtime-python/tests/protocol.spec.ts index 2459a15e87..98715ef030 100644 --- a/packages/code-runtime/code-runtime-python/tests/protocol.spec.ts +++ b/packages/code-runtime/code-runtime-python/tests/protocol.spec.ts @@ -213,20 +213,33 @@ describe('checkDoneValue', () => { expect(checkDoneValue(wide, 12)).toEqual({ ok: false, reason: 'over-budget' }) }) - it('rejects an over-budget string on its length before escaping it', () => { - // A control-heavy forged string escapes to ~6x its length (each NUL becomes - // the 6-character `\u0000`); the walk must refuse it on the cheap - // `length + 2` lower bound so the escaped copy is never allocated. Observable - // through the boundary: a string whose LENGTH already exceeds the cap fails - // even though every source character is one UTF-16 code unit. - expect(checkDoneValue('\0'.repeat(4096), 1024)).toEqual({ ok: false, reason: 'over-budget' }) - // The bound is a lower bound, never a false rejection: a string that fits - // exactly still passes with its exact escaped size — one NUL serializes to - // `"\u0000"`, i.e. two quotes plus the 6-character escape = 8 bytes. + it('meters a string\'s exact escaped size without allocating it', () => { + // A control-heavy string that fits by DECODED length but not once escaped + // must still reject: 200 NULs are 200 UTF-16 units (would pass a naive + // length bound against cap 1024) but escape to 200*6 + 2 = 1202 bytes. + // jsonStringBytesUpTo scans and bails before the escaped copy is built. + expect(checkDoneValue('\0'.repeat(200), 1024)).toEqual({ ok: false, reason: 'over-budget' }) + // Exact-size acceptance, no false rejection: one NUL serializes to a + // 6-char \\uXXXX escape, so with the two quotes = 8 bytes. expect(checkDoneValue('\0', 8)).toEqual({ ok: true, bytes: 8 }) expect(checkDoneValue('\0', 7)).toEqual({ ok: false, reason: 'over-budget' }) - // Same lower bound for keys, checked before the key is escaped. - expect(checkDoneValue({ ['\0'.repeat(4096)]: 1 }, 1024)).toEqual({ ok: false, reason: 'over-budget' }) + // Multi-byte and astral characters meter at their raw UTF-8 width (a valid + // surrogate pair is 4 bytes, matching JSON.stringify), not a 6-byte escape. + expect(checkDoneValue('\u00e9', 4)).toEqual({ ok: true, bytes: 4 }) // 2 quotes + 2-byte UTF-8 + expect(checkDoneValue('\u{1f600}', 6)).toEqual({ ok: true, bytes: 6 }) // 2 quotes + 4-byte UTF-8 + expect(checkDoneValue('\u{1f600}', 5)).toEqual({ ok: false, reason: 'over-budget' }) + // A lone surrogate escapes to \\uXXXX = 6, so with quotes = 8. + expect(checkDoneValue('\ud800', 8)).toEqual({ ok: true, bytes: 8 }) + // A high surrogate followed by a NON-low character is a lone surrogate (6-byte + // escape) plus that character: `\ud800` + `a` = 2 quotes + 6 + 1 = 9. + expect(checkDoneValue('\ud800a', 9)).toEqual({ ok: true, bytes: 9 }) + // A BMP 3-byte code point (CJK) meters at its raw UTF-8 width: 2 quotes + 3. + expect(checkDoneValue('中', 5)).toEqual({ ok: true, bytes: 5 }) + // Same non-allocating meter for object keys, before the value is enqueued. + expect(checkDoneValue({ ['\0'.repeat(200)]: 1 }, 1024)).toEqual({ ok: false, reason: 'over-budget' }) + // A string reached with less than the two quotes' worth of budget is refused + // immediately (even the empty escaped form does not fit). + expect(checkDoneValue('x', 1)).toEqual({ ok: false, reason: 'over-budget' }) }) it('meters only own enumerable keys', () => { From 4674d8fa92748bb7296d207746176477361ab291 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Mon, 3 Aug 2026 11:53:14 +0800 Subject: [PATCH 21/32] fix(code-runtime-python): verify union<->roster both ways, stop pycache writes, refresh metering prose MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address the latest review round: - WireFrameShapesCoverUnions checked only union ⊆ roster, so removing a frame from a message union (e.g. dropping ReplyErr from ReplyMessage) left the check true while the public TS union diverged from the wire. Replace it with a bidirectional equivalence between MessageFrames and the roster's message-frame value types (nested Namespace/ErrorClass/DoneErrorField excluded): both a frame added to a union without a roster entry and a frame removed from a union now fail typecheck (both verified). - The mirror e2e's python3 probes imported protocol.py without -B, writing py/__pycache__/*.pyc into the (un-ignored) source tree. Add -B to both. - Refresh the metering prose (checkDoneValue JSDoc + README both sides + Agent Note both sides): the incremental-work list no longer says "per-key JSON.stringify" now that jsonStringBytesUpTo scans without stringifying; re-record the README and Agent Note i18n pairings. --- ...code-runtime-python-fd3-protocol.i18n.yaml | 4 +- ...-07-31-code-runtime-python-fd3-protocol.md | 2 +- ...-31-code-runtime-python-fd3-protocol.zh.md | 2 +- .../code-runtime-python/README.i18n.yaml | 4 +- .../code-runtime-python/README.md | 2 +- .../code-runtime-python/README.zh.md | 2 +- .../code-runtime-python/src/protocol.ts | 43 ++++++++++++------- .../tests/protocol-mirror.e2e.ts | 4 +- 8 files changed, 38 insertions(+), 25 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.i18n.yaml index f716091e5b..1e886e4c31 100644 --- a/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.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/architecture/2026-07-31-code-runtime-python-fd3-protocol.md -2026-07-31-code-runtime-python-fd3-protocol.md: fff3ed7a6e42cfc5372c7c8a3124a33ebcacab32 -2026-07-31-code-runtime-python-fd3-protocol.zh.md: 88a6d493b35b976abf3676dd00182da1270c4fec +2026-07-31-code-runtime-python-fd3-protocol.md: c497ebdcd26f174f3b8bfa325ef404b057d83bb8 +2026-07-31-code-runtime-python-fd3-protocol.zh.md: b4747199a584d62ee4b61df98e487d60b0028d78 diff --git a/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.md b/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.md index fff3ed7a6e..c497ebdcd2 100644 --- a/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.md +++ b/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.md @@ -15,7 +15,7 @@ This layer of the stack delivers only that protocol, so the large `PythonCodeRun `src/protocol.ts` is the host side of the wire vocabulary and its hostile-frame codec: - **`validateChildFrame`** shape-validates and REBUILDS every inbound frame. The compile-time union means nothing on fd 3 — a forged frame can carry `null`, poisoned fields, or omit required ones — so each accepted frame is reconstructed field by field: forged extras never ride along, a non-finite call id can never be echoed into a reply, and junk returns `undefined` to be dropped rather than throwing in the host's message handler. -- **`encodeJsonPlain` / `checkDoneValue` / `hasUnsafeIntegerToken` / `hasNonLosslessNumber`** are the lossless-JSON codec and meters. They traverse iteratively (an explicit stack, not recursion) so a deep value below the byte budget crosses intact; `checkDoneValue` folds byte-metering and number-losslessness into one walk that rejects an over-budget payload before the INCREMENTAL work it would otherwise add — the escaped-string copy, the enqueued children, the per-key `JSON.stringify`. It does not re-bound the frame's own width: `done.value` is already `JSON.parse`'d when the check runs, so the payload's size is paid upstream and capped there by the host's fixed fd-3 receive buffer (a later stack layer), not here. Beyond-safe-range integral doubles serialize through `BigInt` digits so the exact integer crosses, not `String()`'s rounded form. +- **`encodeJsonPlain` / `checkDoneValue` / `hasUnsafeIntegerToken` / `hasNonLosslessNumber`** are the lossless-JSON codec and meters. They traverse iteratively (an explicit stack, not recursion) so a deep value below the byte budget crosses intact; `checkDoneValue` folds byte-metering and number-losslessness into one walk that rejects an over-budget payload before the INCREMENTAL work it would otherwise add — a non-allocating escaped-size scan (`jsonStringBytesUpTo`) and the enqueued children. It does not re-bound the frame's own width: `done.value` is already `JSON.parse`'d when the check runs, so the payload's size is paid upstream and capped there by the host's fixed fd-3 receive buffer (a later stack layer), not here. Beyond-safe-range integral doubles serialize through `BigInt` digits so the exact integer crosses, not `String()`'s rounded form. - **`logTruncationMarker`** produces the in-band marker text a log ledger emits when it exhausts its byte budget. `py/protocol.py` mirrors the message shapes as `TypedDict`s and re-declares the two surfaces both sides EXECUTE against — `PROTOCOL_FD = 3` and `log_truncation_marker` — with byte-identical text. diff --git a/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.zh.md b/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.zh.md index 88a6d493b3..b4747199a5 100644 --- a/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.zh.md @@ -15,7 +15,7 @@ CPython code-runtime 后端(`@deepseek-ai/dsh-code-runtime-python`,分多个 `src/protocol.ts` 是 wire vocabulary 的 host 侧及其敌意帧编解码: - **`validateChildFrame`** 对每个入站帧做形状校验并重建。编译期 union 在 fd 3 上毫无意义——伪造帧可携带 `null`、被污染的字段,或省略必需字段——所以每个被接受的帧都逐字段重建:伪造的额外字段绝不随行,非有限的 call id 绝不会被回显进 reply,垃圾返回 `undefined` 被丢弃,而不是在 host 的 message handler 里抛错。 -- **`encodeJsonPlain` / `checkDoneValue` / `hasUnsafeIntegerToken` / `hasNonLosslessNumber`** 是 lossless-JSON 编解码器与计量器。它们迭代遍历(显式栈,非递归),使低于字节预算的深层值能完整穿越;`checkDoneValue` 把字节计量和数字无损性折进一次遍历,在它本会新增的 INCREMENTAL 工作之前就拒绝超预算 payload——转义串副本、入栈子节点、逐 key 的 `JSON.stringify`。它不会重新约束帧自身的宽度:`done.value` 在检查运行时已被 `JSON.parse`,故 payload 的尺寸是上游代价,由 host 固定的 fd-3 接收缓冲(后续 stack 层)在那里封顶,而非本函数。超出安全范围的整数型 double 通过 `BigInt` 数字序列化,穿越的是精确整数而非 `String()` 的舍入形式。 +- **`encodeJsonPlain` / `checkDoneValue` / `hasUnsafeIntegerToken` / `hasNonLosslessNumber`** 是 lossless-JSON 编解码器与计量器。它们迭代遍历(显式栈,非递归),使低于字节预算的深层值能完整穿越;`checkDoneValue` 把字节计量和数字无损性折进一次遍历,在它本会新增的 INCREMENTAL 工作之前就拒绝超预算 payload——先做非分配的转义尺寸扫描(`jsonStringBytesUpTo`),再入栈子节点。它不会重新约束帧自身的宽度:`done.value` 在检查运行时已被 `JSON.parse`,故 payload 的尺寸是上游代价,由 host 固定的 fd-3 接收缓冲(后续 stack 层)在那里封顶,而非本函数。超出安全范围的整数型 double 通过 `BigInt` 数字序列化,穿越的是精确整数而非 `String()` 的舍入形式。 - **`logTruncationMarker`** 产出日志 ledger 耗尽字节预算时发出的带内标记文本。 `py/protocol.py` 用 `TypedDict` 镜像消息形状,并重新声明两侧都会 EXECUTE 的两个面——`PROTOCOL_FD = 3` 与 `log_truncation_marker`——文本逐字节一致。 diff --git a/packages/code-runtime/code-runtime-python/README.i18n.yaml b/packages/code-runtime/code-runtime-python/README.i18n.yaml index 4d7725dafc..ec194b5b9a 100644 --- a/packages/code-runtime/code-runtime-python/README.i18n.yaml +++ b/packages/code-runtime/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/code-runtime/code-runtime-python/README.md -README.md: d0491c478d04a8436199bc23fd79917e8c019b1c -README.zh.md: 63d7d38ee05b561e60a3adf387c5c1292c37a7a2 +README.md: 606d153eb899925ae274133b3728d317607a17e2 +README.zh.md: f4387d6b20994781b8e64d31da7319badb56aacd diff --git a/packages/code-runtime/code-runtime-python/README.md b/packages/code-runtime/code-runtime-python/README.md index d0491c478d..606d153eb8 100644 --- a/packages/code-runtime/code-runtime-python/README.md +++ b/packages/code-runtime/code-runtime-python/README.md @@ -12,7 +12,7 @@ The host and the CPython subprocess exchange a versionless, JSON-lines protocol - **fd 3, not stdout** — Node pins the channel positionally with `stdio: ['pipe','pipe','pipe','pipe']`; the Python bootstrap reads the same `PROTOCOL_FD` constant. JSON-lines framing. - **Host treats every inbound frame as hostile** — model code has full access to fd 3 and can post anything through it, so `validateChildFrame` shape-validates and REBUILDS each frame before the host reads it: forged extra fields never ride along, a non-number call id can never be echoed into a reply, and junk drops to `undefined` rather than throwing in the host's message handler. The Python side trusts host replies (the host is not model-controlled). -- **Lossless-JSON crossing** — completion values and binding arguments cross as exact JSON. `encodeJsonPlain` serializes a `JSON.parse`-produced value without recursion, so a deep value below the byte budget crosses intact instead of dying on `JSON.stringify`'s stack limit; `checkDoneValue` meters a forged completion value's byte length AND number losslessness in one traversal that rejects an over-budget payload before the incremental work it would add (escaped-string copy, enqueued children, per-key `JSON.stringify`) — the frame's own width is already parsed and capped upstream by the host's fd-3 receive buffer, not re-bounded here; `hasUnsafeIntegerToken` reads the raw frame text to catch an integer token that `JSON.parse` would silently round; `hasNonLosslessNumber` rejects a non-finite or negative-zero number in unbounded `call.args`. Beyond-safe-range integral doubles serialize through `BigInt` digits so the exact integer crosses, not the rounded `String()` form. +- **Lossless-JSON crossing** — completion values and binding arguments cross as exact JSON. `encodeJsonPlain` serializes a `JSON.parse`-produced value without recursion, so a deep value below the byte budget crosses intact instead of dying on `JSON.stringify`'s stack limit; `checkDoneValue` meters a forged completion value's byte length AND number losslessness in one traversal that rejects an over-budget payload before the incremental work it would add (a non-allocating escaped-size scan, then enqueued children) — the frame's own width is already parsed and capped upstream by the host's fd-3 receive buffer, not re-bounded here; `hasUnsafeIntegerToken` reads the raw frame text to catch an integer token that `JSON.parse` would silently round; `hasNonLosslessNumber` rejects a non-finite or negative-zero number in unbounded `call.args`. Beyond-safe-range integral doubles serialize through `BigInt` digits so the exact integer crosses, not the rounded `String()` form. - **Shared truncation marker** — `logTruncationMarker(maxBytes)` produces byte-identical text on both sides, so a truncated log run reads the same however the cap was hit. The `log` frame's `truncated` flag distinguishes the child ledger's own marker from program output. ## Model Experience diff --git a/packages/code-runtime/code-runtime-python/README.zh.md b/packages/code-runtime/code-runtime-python/README.zh.md index 63d7d38ee0..f4387d6b20 100644 --- a/packages/code-runtime/code-runtime-python/README.zh.md +++ b/packages/code-runtime/code-runtime-python/README.zh.md @@ -12,7 +12,7 @@ host 与 CPython 子进程在子进程的 fd 3 上交换一个无版本号的 JS - **fd 3,而非 stdout** —— Node 通过 `stdio: ['pipe','pipe','pipe','pipe']` 按位置钉住通道;Python bootstrap 读取相同的 `PROTOCOL_FD` 常量。JSON-lines 帧。 - **host 把每个入站帧当作敌意输入** —— 模型代码对 fd 3 有完全访问权、可通过它发送任意内容,所以 `validateChildFrame` 在 host 读取前对每个帧做形状校验并重建:伪造的额外字段绝不随行,非数字的 call id 绝不会被回显进 reply,垃圾降为 `undefined` 被丢弃,而不是在 host 的 message handler 里抛错。Python 侧信任 host 回复(host 不受模型控制)。 -- **lossless-JSON 穿越** —— 完成值与 binding 参数以精确 JSON 穿越。`encodeJsonPlain` 无递归地序列化一个 `JSON.parse` 产出的值,使低于字节预算的深层值能完整穿越,而不是死在 `JSON.stringify` 的栈限制上;`checkDoneValue` 在一次遍历中同时计量伪造完成值的字节长度与数字无损性,在它本会新增的增量工作之前就拒绝超预算 payload(转义串副本、入栈子节点、逐 key 的 `JSON.stringify`)——帧自身的宽度已被上游 `JSON.parse` 支付、由 host 的 fd-3 接收缓冲封顶,并非在此重新约束;`hasUnsafeIntegerToken` 读取原始帧文本,捕获 `JSON.parse` 会静默舍入的整数 token;`hasNonLosslessNumber` 拒绝无字节上限的 `call.args` 中的非有限数或负零。超出安全范围的整数型 double 通过 `BigInt` 数字序列化,穿越的是精确整数而非 `String()` 的舍入形式。 +- **lossless-JSON 穿越** —— 完成值与 binding 参数以精确 JSON 穿越。`encodeJsonPlain` 无递归地序列化一个 `JSON.parse` 产出的值,使低于字节预算的深层值能完整穿越,而不是死在 `JSON.stringify` 的栈限制上;`checkDoneValue` 在一次遍历中同时计量伪造完成值的字节长度与数字无损性,在它本会新增的增量工作之前就拒绝超预算 payload(先做非分配的转义尺寸扫描,再入栈子节点)——帧自身的宽度已被上游 `JSON.parse` 支付、由 host 的 fd-3 接收缓冲封顶,并非在此重新约束;`hasUnsafeIntegerToken` 读取原始帧文本,捕获 `JSON.parse` 会静默舍入的整数 token;`hasNonLosslessNumber` 拒绝无字节上限的 `call.args` 中的非有限数或负零。超出安全范围的整数型 double 通过 `BigInt` 数字序列化,穿越的是精确整数而非 `String()` 的舍入形式。 - **共享截断标记** —— `logTruncationMarker(maxBytes)` 在两侧产出逐字节一致的文本,使被截断的日志运行无论从哪侧触达上限都读起来一致。`log` 帧的 `truncated` 标志把子进程 ledger 自身的标记与程序输出区分开。 ## Model Experience diff --git a/packages/code-runtime/code-runtime-python/src/protocol.ts b/packages/code-runtime/code-runtime-python/src/protocol.ts index d5b1ffe4c5..b9caca620c 100644 --- a/packages/code-runtime/code-runtime-python/src/protocol.ts +++ b/packages/code-runtime/code-runtime-python/src/protocol.ts @@ -185,20 +185,32 @@ interface WireFrameShapes { } /** - * Compile-time proof that {@link WireFrameShapes} lists every frame carried on a - * message union: the union of the frame types (`ChildToHost`, the reply - * variants, and the host-to-child boot/run frames) must be assignable to the - * union of the roster's value types. Adding a frame to a union without a - * `WireFrameShapes` entry makes this alias `false`, so the assignment below - * fails to compile — closing the whole-frame drift the field-level binding - * alone could not see. Nested shapes (`Namespace`, `ErrorClass`, - * `DoneErrorField`) are not union members; they are covered by the roles - * `satisfies` and the mirror e2e's roster comparison. + * The frames carried on a message union: everything the host and child send as + * a top-level frame (`ChildToHost`, the two reply variants, and the host→child + * boot/run frames). The nested shapes `Namespace`, `ErrorClass`, and + * `DoneErrorField` are fields of other frames, not frames themselves, so they + * are excluded here and covered only by the roles `satisfies` and the mirror e2e. */ -type WireFrameShapesCoverUnions = - [ChildToHost | ReplyMessage | BootMessage | RunMessage] extends [WireFrameShapes[keyof WireFrameShapes]] ? true : false -const _wireFrameShapesCoverUnions: WireFrameShapesCoverUnions = true -void _wireFrameShapesCoverUnions +type MessageFrames = ChildToHost | ReplyMessage | BootMessage | RunMessage +/** The roster's value types minus the three nested (non-frame) shapes. */ +type RosterMessageFrames = Exclude + +/** + * Compile-time proof that {@link WireFrameShapes}'s message-frame entries are + * EXACTLY the frames on the message unions — checked BOTH directions. Forward + * (`MessageFrames extends RosterMessageFrames`) catches a frame added to a union + * without a roster entry; reverse (`RosterMessageFrames extends MessageFrames`) + * catches a frame removed from a union while the roster still lists it (e.g. + * dropping `ReplyErr` from `ReplyMessage`). Either divergence makes an alias + * `false`, failing the assignment below. Type-only; the `const`s emit nothing + * meaningful at runtime. + */ +type UnionCoversRoster = [MessageFrames] extends [RosterMessageFrames] ? true : false +type RosterCoversUnion = [RosterMessageFrames] extends [MessageFrames] ? true : false +const _unionCoversRoster: UnionCoversRoster = true +const _rosterCoversUnion: RosterCoversUnion = true +void _unionCoversRoster +void _rosterCoversUnion /** * Each frame's wire fields tagged by required/optional, keyed by field name so @@ -375,8 +387,9 @@ function jsonStringBytesUpTo(text: string, maxBytes: number): number | undefined * Meter a `JSON.parse`-produced done value's compact-JSON byte length AND its * number losslessness in one traversal, stopping the instant `maxBytes` is * crossed. This bounds the INCREMENTAL allocation the check itself would add on - * top of the already-parsed value — the escaped-string copy, the enqueued - * children, the per-key `JSON.stringify` — not the parse that produced `value`. + * top of the already-parsed value — the enqueued children (and, in the previous + * implementation, an escaped-string copy that {@link jsonStringBytesUpTo} now + * avoids) — not the parse that produced `value`. * That upstream width is bounded separately, by the host-side cap on inbound * fd-3 frame size before `JSON.parse` runs (owned by the runtime that reads the * channel), so `value` cannot be arbitrarily large when it reaches here, while diff --git a/packages/code-runtime/code-runtime-python/tests/protocol-mirror.e2e.ts b/packages/code-runtime/code-runtime-python/tests/protocol-mirror.e2e.ts index ca28feb8ee..be1a822a3b 100644 --- a/packages/code-runtime/code-runtime-python/tests/protocol-mirror.e2e.ts +++ b/packages/code-runtime/code-runtime-python/tests/protocol-mirror.e2e.ts @@ -45,7 +45,7 @@ describe.skipIf(!python3Available)('protocol.py mirrors protocol.ts at runtime', ' "markers": [log_truncation_marker(b) for b in budgets],', '}))', ].join('\n') - const { stdout } = await execFileAsync('python3', ['-I', '-c', probe]) + const { stdout } = await execFileAsync('python3', ['-I', '-B', '-c', probe]) const seen = JSON.parse(stdout) as { fd: number; markers: string[] } // Assert against the TS-side PROTOCOL_FD export (the value the host wires), // not a bare literal, so a drift on either side of the wire is caught here. @@ -75,7 +75,7 @@ describe.skipIf(!python3Available)('protocol.py mirrors protocol.ts at runtime', + ' if not n.startswith("_") and hasattr(v, "__required_keys__")}', 'print(json.dumps(frames))', ].join('\n') - const { stdout } = await execFileAsync('python3', ['-I', '-c', probe]) + const { stdout } = await execFileAsync('python3', ['-I', '-B', '-c', probe]) const seen = JSON.parse(stdout) as Record // Normalize the TS source of truth to the same sorted shape Python reports. const expected = Object.fromEntries( From 203bfca0ea3683bc41b70b32c4ec8c61ef98f8e5 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Mon, 3 Aug 2026 14:58:52 +0800 Subject: [PATCH 22/32] docs(code-runtime-python): trim metering prose and cover encodeJsonPlain depth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Drop the review-history narrative from checkDoneValue's JSDoc (the "in the previous implementation … now avoids" clause); state the current contract only. - Assert encodeJsonPlain on the same 100k-deep value the metering test uses: its headline contract is stack-safety (JSON.stringify would throw), but no test exercised the encoder on a deep value. --- packages/code-runtime/code-runtime-python/src/protocol.ts | 6 +++--- .../code-runtime/code-runtime-python/tests/protocol.spec.ts | 6 +++++- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/code-runtime/code-runtime-python/src/protocol.ts b/packages/code-runtime/code-runtime-python/src/protocol.ts index b9caca620c..a164750a64 100644 --- a/packages/code-runtime/code-runtime-python/src/protocol.ts +++ b/packages/code-runtime/code-runtime-python/src/protocol.ts @@ -387,9 +387,9 @@ function jsonStringBytesUpTo(text: string, maxBytes: number): number | undefined * Meter a `JSON.parse`-produced done value's compact-JSON byte length AND its * number losslessness in one traversal, stopping the instant `maxBytes` is * crossed. This bounds the INCREMENTAL allocation the check itself would add on - * top of the already-parsed value — the enqueued children (and, in the previous - * implementation, an escaped-string copy that {@link jsonStringBytesUpTo} now - * avoids) — not the parse that produced `value`. + * top of the already-parsed value — the enqueued children; strings and keys are + * metered by {@link jsonStringBytesUpTo} without allocating an escaped copy — + * not the parse that produced `value`. * That upstream width is bounded separately, by the host-side cap on inbound * fd-3 frame size before `JSON.parse` runs (owned by the runtime that reads the * channel), so `value` cannot be arbitrarily large when it reaches here, while diff --git a/packages/code-runtime/code-runtime-python/tests/protocol.spec.ts b/packages/code-runtime/code-runtime-python/tests/protocol.spec.ts index 98715ef030..7f50f6df1c 100644 --- a/packages/code-runtime/code-runtime-python/tests/protocol.spec.ts +++ b/packages/code-runtime/code-runtime-python/tests/protocol.spec.ts @@ -283,11 +283,15 @@ describe('checkDoneValue', () => { expect(checkDoneValue(Infinity, 3)).toEqual({ ok: false, reason: 'over-budget' }) }) - it('meters deep nesting iteratively without overflowing the stack', () => { + it('meters and encodes deep nesting iteratively without overflowing the stack', () => { let deep: unknown = 0 for (let i = 0; i < 100_000; i++) deep = [deep] // 100000 '[' + '0' + 100000 ']' = 200001 bytes. expect(checkDoneValue(deep, 1_000_000)).toEqual({ ok: true, bytes: 200_001 }) + // encodeJsonPlain's headline contract is the same stack-safety (JSON.stringify + // recurses per level and throws RangeError a few thousand deep), so exercise + // it on the same 100k-deep value — JSON.stringify would throw here. + expect(encodeJsonPlain(deep)).toBe(`${'['.repeat(100_000)}0${']'.repeat(100_000)}`) }) it('emits exact digits for beyond-safe integral doubles', () => { From 32d6444a2cc0cd152657685e840654f1c17c7cbf Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Wed, 5 Aug 2026 15:41:36 +0800 Subject: [PATCH 23/32] fix(code-runtime-python): align package files with the publication gate --- packages/code-runtime/code-runtime-python/package.json | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/code-runtime/code-runtime-python/package.json b/packages/code-runtime/code-runtime-python/package.json index c94beb2997..8649dca00b 100644 --- a/packages/code-runtime/code-runtime-python/package.json +++ b/packages/code-runtime/code-runtime-python/package.json @@ -21,9 +21,7 @@ "lib/index.js", "lib/invariant.js", "py/**/*.py", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { From 5dad49f4db9a48550bea4bff92c2ee39423c1845 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Fri, 7 Aug 2026 15:37:19 +0800 Subject: [PATCH 24/32] docs(code-runtime-python): name the roster aliases by subset direction, align metering prose Rename UnionCoversRoster/RosterCoversUnion to UnionSubsetOfRoster/RosterSubsetOfUnion so the names read in the same direction as their extends clauses, share the python3 -I -B flags between the two mirror probes, and align the README and Agent Note prose with the checkDoneValue JSDoc: the escaped-size scan is the metering itself, not deferred work. Regenerate docs/module-graph.md, which listed code-runtime-python twice. --- ...-07-31-code-runtime-python-fd3-protocol.i18n.yaml | 4 ++-- .../2026-07-31-code-runtime-python-fd3-protocol.md | 2 +- ...2026-07-31-code-runtime-python-fd3-protocol.zh.md | 2 +- docs/module-graph.md | 1 - .../code-runtime-python/README.i18n.yaml | 4 ++-- packages/code-runtime/code-runtime-python/README.md | 2 +- .../code-runtime/code-runtime-python/README.zh.md | 2 +- .../code-runtime/code-runtime-python/src/protocol.ts | 12 ++++++------ .../code-runtime-python/tests/protocol-mirror.e2e.ts | 7 +++++-- 9 files changed, 19 insertions(+), 17 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.i18n.yaml index 1e886e4c31..aa04585e3a 100644 --- a/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.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/architecture/2026-07-31-code-runtime-python-fd3-protocol.md -2026-07-31-code-runtime-python-fd3-protocol.md: c497ebdcd26f174f3b8bfa325ef404b057d83bb8 -2026-07-31-code-runtime-python-fd3-protocol.zh.md: b4747199a584d62ee4b61df98e487d60b0028d78 +2026-07-31-code-runtime-python-fd3-protocol.md: 5f9600a3f658df907d68ae695d42154009947fbd +2026-07-31-code-runtime-python-fd3-protocol.zh.md: dc3ae7cdfe1daf6e2ab1326e353c1bdbf9833175 diff --git a/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.md b/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.md index c497ebdcd2..5f9600a3f6 100644 --- a/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.md +++ b/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.md @@ -15,7 +15,7 @@ This layer of the stack delivers only that protocol, so the large `PythonCodeRun `src/protocol.ts` is the host side of the wire vocabulary and its hostile-frame codec: - **`validateChildFrame`** shape-validates and REBUILDS every inbound frame. The compile-time union means nothing on fd 3 — a forged frame can carry `null`, poisoned fields, or omit required ones — so each accepted frame is reconstructed field by field: forged extras never ride along, a non-finite call id can never be echoed into a reply, and junk returns `undefined` to be dropped rather than throwing in the host's message handler. -- **`encodeJsonPlain` / `checkDoneValue` / `hasUnsafeIntegerToken` / `hasNonLosslessNumber`** are the lossless-JSON codec and meters. They traverse iteratively (an explicit stack, not recursion) so a deep value below the byte budget crosses intact; `checkDoneValue` folds byte-metering and number-losslessness into one walk that rejects an over-budget payload before the INCREMENTAL work it would otherwise add — a non-allocating escaped-size scan (`jsonStringBytesUpTo`) and the enqueued children. It does not re-bound the frame's own width: `done.value` is already `JSON.parse`'d when the check runs, so the payload's size is paid upstream and capped there by the host's fixed fd-3 receive buffer (a later stack layer), not here. Beyond-safe-range integral doubles serialize through `BigInt` digits so the exact integer crosses, not `String()`'s rounded form. +- **`encodeJsonPlain` / `checkDoneValue` / `hasUnsafeIntegerToken` / `hasNonLosslessNumber`** are the lossless-JSON codec and meters. They traverse iteratively (an explicit stack, not recursion) so a deep value below the byte budget crosses intact; `checkDoneValue` folds byte-metering and number-losslessness into one walk that rejects an over-budget payload before the INCREMENTAL work it would otherwise add — the enqueued children; strings and keys are metered by a non-allocating escaped-size scan (`jsonStringBytesUpTo`), so the escaped copy is never materialized. It does not re-bound the frame's own width: `done.value` is already `JSON.parse`'d when the check runs, so the payload's size is paid upstream and capped there by the host's fixed fd-3 receive buffer (a later stack layer), not here. Beyond-safe-range integral doubles serialize through `BigInt` digits so the exact integer crosses, not `String()`'s rounded form. - **`logTruncationMarker`** produces the in-band marker text a log ledger emits when it exhausts its byte budget. `py/protocol.py` mirrors the message shapes as `TypedDict`s and re-declares the two surfaces both sides EXECUTE against — `PROTOCOL_FD = 3` and `log_truncation_marker` — with byte-identical text. diff --git a/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.zh.md b/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.zh.md index b4747199a5..dc3ae7cdfe 100644 --- a/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.zh.md @@ -15,7 +15,7 @@ CPython code-runtime 后端(`@deepseek-ai/dsh-code-runtime-python`,分多个 `src/protocol.ts` 是 wire vocabulary 的 host 侧及其敌意帧编解码: - **`validateChildFrame`** 对每个入站帧做形状校验并重建。编译期 union 在 fd 3 上毫无意义——伪造帧可携带 `null`、被污染的字段,或省略必需字段——所以每个被接受的帧都逐字段重建:伪造的额外字段绝不随行,非有限的 call id 绝不会被回显进 reply,垃圾返回 `undefined` 被丢弃,而不是在 host 的 message handler 里抛错。 -- **`encodeJsonPlain` / `checkDoneValue` / `hasUnsafeIntegerToken` / `hasNonLosslessNumber`** 是 lossless-JSON 编解码器与计量器。它们迭代遍历(显式栈,非递归),使低于字节预算的深层值能完整穿越;`checkDoneValue` 把字节计量和数字无损性折进一次遍历,在它本会新增的 INCREMENTAL 工作之前就拒绝超预算 payload——先做非分配的转义尺寸扫描(`jsonStringBytesUpTo`),再入栈子节点。它不会重新约束帧自身的宽度:`done.value` 在检查运行时已被 `JSON.parse`,故 payload 的尺寸是上游代价,由 host 固定的 fd-3 接收缓冲(后续 stack 层)在那里封顶,而非本函数。超出安全范围的整数型 double 通过 `BigInt` 数字序列化,穿越的是精确整数而非 `String()` 的舍入形式。 +- **`encodeJsonPlain` / `checkDoneValue` / `hasUnsafeIntegerToken` / `hasNonLosslessNumber`** 是 lossless-JSON 编解码器与计量器。它们迭代遍历(显式栈,非递归),使低于字节预算的深层值能完整穿越;`checkDoneValue` 把字节计量和数字无损性折进一次遍历,在它本会新增的 INCREMENTAL 工作之前就拒绝超预算 payload——即入栈子节点;字符串与 key 由非分配的转义尺寸扫描(`jsonStringBytesUpTo`)计量,从不物化转义副本。它不会重新约束帧自身的宽度:`done.value` 在检查运行时已被 `JSON.parse`,故 payload 的尺寸是上游代价,由 host 固定的 fd-3 接收缓冲(后续 stack 层)在那里封顶,而非本函数。超出安全范围的整数型 double 通过 `BigInt` 数字序列化,穿越的是精确整数而非 `String()` 的舍入形式。 - **`logTruncationMarker`** 产出日志 ledger 耗尽字节预算时发出的带内标记文本。 `py/protocol.py` 用 `TypedDict` 镜像消息形状,并重新声明两侧都会 EXECUTE 的两个面——`PROTOCOL_FD = 3` 与 `log_truncation_marker`——文本逐字节一致。 diff --git a/docs/module-graph.md b/docs/module-graph.md index 1706f229b7..1795ec431b 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -1150,7 +1150,6 @@ flowchart TD | [`client-test-runtime`](../packages/client/test-runtime) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`host-apiproxy`](../packages/host/apiproxy), [`invariants`](../packages/support/invariants) | | [`client-ui-settings`](../packages/client/ui-settings) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`invariants`](../packages/support/invariants) | -| [`code-runtime-python`](../packages/code-runtime/code-runtime-python) | `code-runtime` | [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/support/invariants) | | [`credentials`](../packages/credentials/credentials) | `credentials` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | | [`frontend-static`](../packages/host/frontend-static) | `host` | [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | | [`helper`](../packages/sdk/helper) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess) | diff --git a/packages/code-runtime/code-runtime-python/README.i18n.yaml b/packages/code-runtime/code-runtime-python/README.i18n.yaml index ec194b5b9a..72754b0fc6 100644 --- a/packages/code-runtime/code-runtime-python/README.i18n.yaml +++ b/packages/code-runtime/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/code-runtime/code-runtime-python/README.md -README.md: 606d153eb899925ae274133b3728d317607a17e2 -README.zh.md: f4387d6b20994781b8e64d31da7319badb56aacd +README.md: e7ca08e3e42d368e1b46f4dd52ae7dc1040e6d76 +README.zh.md: df0488b469daca6f33254ce2233b10e0c7138ed5 diff --git a/packages/code-runtime/code-runtime-python/README.md b/packages/code-runtime/code-runtime-python/README.md index 606d153eb8..e7ca08e3e4 100644 --- a/packages/code-runtime/code-runtime-python/README.md +++ b/packages/code-runtime/code-runtime-python/README.md @@ -12,7 +12,7 @@ The host and the CPython subprocess exchange a versionless, JSON-lines protocol - **fd 3, not stdout** — Node pins the channel positionally with `stdio: ['pipe','pipe','pipe','pipe']`; the Python bootstrap reads the same `PROTOCOL_FD` constant. JSON-lines framing. - **Host treats every inbound frame as hostile** — model code has full access to fd 3 and can post anything through it, so `validateChildFrame` shape-validates and REBUILDS each frame before the host reads it: forged extra fields never ride along, a non-number call id can never be echoed into a reply, and junk drops to `undefined` rather than throwing in the host's message handler. The Python side trusts host replies (the host is not model-controlled). -- **Lossless-JSON crossing** — completion values and binding arguments cross as exact JSON. `encodeJsonPlain` serializes a `JSON.parse`-produced value without recursion, so a deep value below the byte budget crosses intact instead of dying on `JSON.stringify`'s stack limit; `checkDoneValue` meters a forged completion value's byte length AND number losslessness in one traversal that rejects an over-budget payload before the incremental work it would add (a non-allocating escaped-size scan, then enqueued children) — the frame's own width is already parsed and capped upstream by the host's fd-3 receive buffer, not re-bounded here; `hasUnsafeIntegerToken` reads the raw frame text to catch an integer token that `JSON.parse` would silently round; `hasNonLosslessNumber` rejects a non-finite or negative-zero number in unbounded `call.args`. Beyond-safe-range integral doubles serialize through `BigInt` digits so the exact integer crosses, not the rounded `String()` form. +- **Lossless-JSON crossing** — completion values and binding arguments cross as exact JSON. `encodeJsonPlain` serializes a `JSON.parse`-produced value without recursion, so a deep value below the byte budget crosses intact instead of dying on `JSON.stringify`'s stack limit; `checkDoneValue` meters a forged completion value's byte length AND number losslessness in one traversal that rejects an over-budget payload before the incremental work it would add (the enqueued children; strings and keys are metered by a non-allocating escaped-size scan, so the escaped copy is never materialized) — the frame's own width is already parsed and capped upstream by the host's fd-3 receive buffer, not re-bounded here; `hasUnsafeIntegerToken` reads the raw frame text to catch an integer token that `JSON.parse` would silently round; `hasNonLosslessNumber` rejects a non-finite or negative-zero number in unbounded `call.args`. Beyond-safe-range integral doubles serialize through `BigInt` digits so the exact integer crosses, not the rounded `String()` form. - **Shared truncation marker** — `logTruncationMarker(maxBytes)` produces byte-identical text on both sides, so a truncated log run reads the same however the cap was hit. The `log` frame's `truncated` flag distinguishes the child ledger's own marker from program output. ## Model Experience diff --git a/packages/code-runtime/code-runtime-python/README.zh.md b/packages/code-runtime/code-runtime-python/README.zh.md index f4387d6b20..df0488b469 100644 --- a/packages/code-runtime/code-runtime-python/README.zh.md +++ b/packages/code-runtime/code-runtime-python/README.zh.md @@ -12,7 +12,7 @@ host 与 CPython 子进程在子进程的 fd 3 上交换一个无版本号的 JS - **fd 3,而非 stdout** —— Node 通过 `stdio: ['pipe','pipe','pipe','pipe']` 按位置钉住通道;Python bootstrap 读取相同的 `PROTOCOL_FD` 常量。JSON-lines 帧。 - **host 把每个入站帧当作敌意输入** —— 模型代码对 fd 3 有完全访问权、可通过它发送任意内容,所以 `validateChildFrame` 在 host 读取前对每个帧做形状校验并重建:伪造的额外字段绝不随行,非数字的 call id 绝不会被回显进 reply,垃圾降为 `undefined` 被丢弃,而不是在 host 的 message handler 里抛错。Python 侧信任 host 回复(host 不受模型控制)。 -- **lossless-JSON 穿越** —— 完成值与 binding 参数以精确 JSON 穿越。`encodeJsonPlain` 无递归地序列化一个 `JSON.parse` 产出的值,使低于字节预算的深层值能完整穿越,而不是死在 `JSON.stringify` 的栈限制上;`checkDoneValue` 在一次遍历中同时计量伪造完成值的字节长度与数字无损性,在它本会新增的增量工作之前就拒绝超预算 payload(先做非分配的转义尺寸扫描,再入栈子节点)——帧自身的宽度已被上游 `JSON.parse` 支付、由 host 的 fd-3 接收缓冲封顶,并非在此重新约束;`hasUnsafeIntegerToken` 读取原始帧文本,捕获 `JSON.parse` 会静默舍入的整数 token;`hasNonLosslessNumber` 拒绝无字节上限的 `call.args` 中的非有限数或负零。超出安全范围的整数型 double 通过 `BigInt` 数字序列化,穿越的是精确整数而非 `String()` 的舍入形式。 +- **lossless-JSON 穿越** —— 完成值与 binding 参数以精确 JSON 穿越。`encodeJsonPlain` 无递归地序列化一个 `JSON.parse` 产出的值,使低于字节预算的深层值能完整穿越,而不是死在 `JSON.stringify` 的栈限制上;`checkDoneValue` 在一次遍历中同时计量伪造完成值的字节长度与数字无损性,在它本会新增的增量工作之前就拒绝超预算 payload(即入栈子节点;字符串与 key 由非分配的转义尺寸扫描计量,从不物化转义副本)——帧自身的宽度已被上游 `JSON.parse` 支付、由 host 的 fd-3 接收缓冲封顶,并非在此重新约束;`hasUnsafeIntegerToken` 读取原始帧文本,捕获 `JSON.parse` 会静默舍入的整数 token;`hasNonLosslessNumber` 拒绝无字节上限的 `call.args` 中的非有限数或负零。超出安全范围的整数型 double 通过 `BigInt` 数字序列化,穿越的是精确整数而非 `String()` 的舍入形式。 - **共享截断标记** —— `logTruncationMarker(maxBytes)` 在两侧产出逐字节一致的文本,使被截断的日志运行无论从哪侧触达上限都读起来一致。`log` 帧的 `truncated` 标志把子进程 ledger 自身的标记与程序输出区分开。 ## Model Experience diff --git a/packages/code-runtime/code-runtime-python/src/protocol.ts b/packages/code-runtime/code-runtime-python/src/protocol.ts index a164750a64..ede2274c32 100644 --- a/packages/code-runtime/code-runtime-python/src/protocol.ts +++ b/packages/code-runtime/code-runtime-python/src/protocol.ts @@ -205,12 +205,12 @@ type RosterMessageFrames = Exclude { try { @@ -45,7 +48,7 @@ describe.skipIf(!python3Available)('protocol.py mirrors protocol.ts at runtime', ' "markers": [log_truncation_marker(b) for b in budgets],', '}))', ].join('\n') - const { stdout } = await execFileAsync('python3', ['-I', '-B', '-c', probe]) + const { stdout } = await execFileAsync('python3', [...python3Flags, '-c', probe]) const seen = JSON.parse(stdout) as { fd: number; markers: string[] } // Assert against the TS-side PROTOCOL_FD export (the value the host wires), // not a bare literal, so a drift on either side of the wire is caught here. @@ -75,7 +78,7 @@ describe.skipIf(!python3Available)('protocol.py mirrors protocol.ts at runtime', + ' if not n.startswith("_") and hasattr(v, "__required_keys__")}', 'print(json.dumps(frames))', ].join('\n') - const { stdout } = await execFileAsync('python3', ['-I', '-B', '-c', probe]) + const { stdout } = await execFileAsync('python3', [...python3Flags, '-c', probe]) const seen = JSON.parse(stdout) as Record // Normalize the TS source of truth to the same sorted shape Python reports. const expected = Object.fromEntries( From ea1b4946142831810c47de68be7b96a46c3e9b98 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Tue, 11 Aug 2026 14:44:39 +0800 Subject: [PATCH 25/32] docs(code-runtime-python): drop forward references this layer does not own Two comments described facts that belong to later layers of the stack: - The workspace-constraints whitelist comment described a bootstrap the host spawns by path. This layer's py/ holds only protocol.py, the wire-vocabulary mirror, and nothing here spawns it. State what the whitelist entry actually covers: the Python source ships as-is rather than built. - checkDoneValue's JSDoc claimed maxValueBytes "defaults to 32 KiB". This package defines no config and no default; maxValueBytes is a required boot frame field. Name it as the budget instead, so the prose cannot drift when the owning implementation picks a default. Comment-only; the bound argument is unchanged. --- packages/code-runtime/code-runtime-python/src/protocol.ts | 5 +++-- scripts/check-workspace-constraints.ts | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/code-runtime/code-runtime-python/src/protocol.ts b/packages/code-runtime/code-runtime-python/src/protocol.ts index ede2274c32..049bb38cc0 100644 --- a/packages/code-runtime/code-runtime-python/src/protocol.ts +++ b/packages/code-runtime/code-runtime-python/src/protocol.ts @@ -392,8 +392,9 @@ function jsonStringBytesUpTo(text: string, maxBytes: number): number | undefined * not the parse that produced `value`. * That upstream width is bounded separately, by the host-side cap on inbound * fd-3 frame size before `JSON.parse` runs (owned by the runtime that reads the - * channel), so `value` cannot be arbitrarily large when it reaches here, while - * `maxValueBytes` defaults to 32 KiB. The traversal rejects over-budget BEFORE + * channel), so `value` cannot be arbitrarily large when it reaches here. The + * budget is the `maxValueBytes` the boot frame carries — a required wire field + * with no default at this layer. The traversal rejects over-budget BEFORE * materializing a string's escaped form or enqueuing an array's/object's * children, so a forgery within that frame cap cannot force those secondary * allocations. Object key COUNTING is diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts index 0b0069ffde..b575b48860 100644 --- a/scripts/check-workspace-constraints.ts +++ b/scripts/check-workspace-constraints.ts @@ -107,7 +107,7 @@ const packageFileExtras: Readonly> = { '@deepseek-ai/dsh-web-app': ['cordis.patch.yml'], '@deepseek-ai/dsh-headless': ['cordis.patch.yml'], '@deepseek-ai/dsh-client-ui-theme': ['lib/styles'], - // The CPython bootstrap ships as source .py files the host spawns by path. + // The CPython side ships as source .py files, published as-is rather than built. '@deepseek-ai/dsh-code-runtime-python': ['py/**/*.py'], '@deepseek-ai/dsh-helper': ['lib/assets'], '@deepseek-ai/dsh-pty-local': ['scripts/ensure-spawn-helper.mjs'], From 26bcff8ab490ca321f031a961b92bcfb432480d2 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Tue, 11 Aug 2026 15:46:24 +0800 Subject: [PATCH 26/32] docs(pre-push-checks): diagnose absent CI runs as a merge conflict MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A CONFLICTING PR gets no pull_request workflow runs, so `gh pr checks` reports "no checks reported" and the runs API returns total_count 0. That looks like a dropped GitHub event, and the reflex fixes for one — empty commits, draft/ready toggles, revert-and-restore bounces — all leave the count at zero while adding junk history to the branch. Record the mergeability check as the first diagnostic step, name the conflict as the cause, and point at `git merge-tree` for the conflicting paths. --- .agents/skills/dsh-pre-push-checks/SKILL.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.agents/skills/dsh-pre-push-checks/SKILL.md b/.agents/skills/dsh-pre-push-checks/SKILL.md index dd04cf9b33..2bc000431e 100644 --- a/.agents/skills/dsh-pre-push-checks/SKILL.md +++ b/.agents/skills/dsh-pre-push-checks/SKILL.md @@ -112,4 +112,12 @@ gh pr checks Report pending checks as pending. Inspect failures before attributing them to the branch or the environment. +When `gh pr checks` reports "no checks reported" and `/actions/runs?head_sha=` returns `total_count: 0`, read mergeability before suspecting the push or a dropped GitHub event: + +```sh +gh pr view --json mergeable,mergeStateStatus +``` + +GitHub creates no `pull_request` workflow runs while a PR is `CONFLICTING`/`DIRTY`, so the absent signal is the conflict, not infrastructure. Resolving the conflict is the only fix; empty commits, `--allow-empty` pushes, draft/ready toggles, and revert-and-restore bounces all leave `total_count` at zero and add junk history. Confirm the conflicting paths with `git merge-tree --write-tree HEAD origin/` when the branch cannot be merged locally yet. + For `gh stack sync`, use the post-sync validation sequence instead of pretending the ordinary order was possible. From 3deb60a13c4b24a1db166b5872ea7ee5e9466472 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Tue, 11 Aug 2026 16:04:34 +0800 Subject: [PATCH 27/32] docs: carry the new package into the generated docs' Chinese pairs Regenerating docs/module-graph.md and docs/config-catalog.md during the master merge added code-runtime-python entries to the English sides only, leaving both pairs out of sync with their recorded consistent state. Add the matching Chinese entries and re-record the pairing. --- docs/config-catalog.i18n.yaml | 4 ++-- docs/config-catalog.zh.md | 1 + docs/module-graph.i18n.yaml | 4 ++-- docs/module-graph.zh.md | 3 +++ 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 098c91c804..9452d4ea29 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: 911255077833354351b08bd2800f2116510ca3c0 -config-catalog.zh.md: d3141ab389cb1b8f60b88d504e2598ab1938decc +config-catalog.md: 7b661a0b2c2c42fa5cd84514669ae09b8dacd63f +config-catalog.zh.md: 51d80eb7006f1bb6789393c0a99b0c75126bf61a diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index d3141ab389..51d80eb700 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -2822,6 +2822,7 @@ export interface Config { - `@deepseek-ai/dsh-client-web`([`packages/client/web/src/index.ts`](../packages/client/web/src/index.ts)) - `@deepseek-ai/dsh-client-web-react`([`packages/client/web-react/src/index.ts`](../packages/client/web-react/src/index.ts)) - `@deepseek-ai/dsh-cmdline`([`packages/boot/cmdline/src/index.ts`](../packages/boot/cmdline/src/index.ts)) +- `@deepseek-ai/dsh-code-runtime-python`([`packages/code-runtime/code-runtime-python/src/index.ts`](../packages/code-runtime/code-runtime-python/src/index.ts)) - `@deepseek-ai/dsh-environment`([`packages/util/environment/src/index.ts`](../packages/util/environment/src/index.ts)) - `@deepseek-ai/dsh-helper`([`packages/scaffold/helper/src/index.ts`](../packages/scaffold/helper/src/index.ts)) - `@deepseek-ai/dsh-hook-protocol`([`packages/hooks/hook-protocol/src/index.ts`](../packages/hooks/hook-protocol/src/index.ts)) diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index f14bf8cea2..f410a5221e 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.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/module-graph.md -module-graph.md: 59e22a8b82a210dd66f6e2186f0b827a541e00cc -module-graph.zh.md: 00a433ebaeabba4ce0e39919c3e0fe817608e718 +module-graph.md: 8e320f9a9b58db1b7731695d3e1eb1de6d9fcbb4 +module-graph.zh.md: 007342ee7481a7ffcf0bd875eedca70fdb17d26b diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 00a433ebae..007342ee74 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -179,6 +179,7 @@ flowchart TD end subgraph group_code_runtime["packages/code-runtime"] pkg_code_runtime["code-runtime"] + pkg_code_runtime_python["code-runtime-python"] pkg_code_runtime_worker["code-runtime-worker"] end subgraph group_context["packages/context"] @@ -327,6 +328,7 @@ flowchart TD pkg_client_web --> pkg_invariants pkg_client_web_react --> pkg_invariants pkg_code_runtime --> pkg_invariants + pkg_code_runtime_python --> pkg_invariants pkg_e2b --> pkg_invariants pkg_jsonrpc_demo --> pkg_invariants pkg_host_directory_picker --> pkg_invariants @@ -1290,6 +1292,7 @@ flowchart TD | [`client-web`](../packages/client/web) | `client` | [`invariants`](../packages/support/invariants) | | [`client-web-react`](../packages/client/web-react) | `client` | [`invariants`](../packages/support/invariants) | | [`code-runtime`](../packages/code-runtime/code-runtime) | `code-runtime` | [`invariants`](../packages/support/invariants) | +| [`code-runtime-python`](../packages/code-runtime/code-runtime-python) | `code-runtime` | [`invariants`](../packages/support/invariants) | | [`e2b`](../packages/e2b/e2b) | `e2b` | [`invariants`](../packages/support/invariants) | | [`jsonrpc-demo`](../packages/examples/jsonrpc-demo) | `examples` | [`invariants`](../packages/support/invariants) | | [`host-directory-picker`](../packages/host/directory-picker) | `host` | [`invariants`](../packages/support/invariants) | From a9117995e130948882b934b859c1fe61bd4a5415 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Wed, 12 Aug 2026 02:13:54 +0800 Subject: [PATCH 28/32] fix(code-runtime-python): match the released root version master cut 0.0.1-rc.2 while this branch was open. The version bump touched every existing package but not this new one, so check-workspace-constraints rejected the mismatch and took the required "all checks passed" job down with it. --- packages/code-runtime/code-runtime-python/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/code-runtime/code-runtime-python/package.json b/packages/code-runtime/code-runtime-python/package.json index 00d60c7c50..f20771c1b0 100644 --- a/packages/code-runtime/code-runtime-python/package.json +++ b/packages/code-runtime/code-runtime-python/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-code-runtime-python", "description": "CPython subprocess implementation of the DeepSeek Harness code-execution seam", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, From a95171b0842e2b4088cb1017eb466a730b026b78 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Mon, 17 Aug 2026 17:33:55 +0800 Subject: [PATCH 29/32] fix(code-runtime-python): declare the MIT license the package gate requires master added verify-dsh-package-licenses while this branch was open: every repository-owned DSH package must declare "license": "MIT". This package carried BSD-3-Clause from its creation, so the gate failed and took the required "node 24 / static" lane down with it. --- packages/code-runtime/code-runtime-python/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/code-runtime/code-runtime-python/package.json b/packages/code-runtime/code-runtime-python/package.json index f79e0584ef..2b7734dc94 100644 --- a/packages/code-runtime/code-runtime-python/package.json +++ b/packages/code-runtime/code-runtime-python/package.json @@ -30,7 +30,7 @@ "py/**/*.py", "lib/types/**/*.d.ts" ], - "license": "BSD-3-Clause", + "license": "MIT", "peerDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/cordis": "workspace:^" From 88336074693c330ee8905da0e3b001908dfd7632 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Mon, 17 Aug 2026 17:46:40 +0800 Subject: [PATCH 30/32] docs(code-runtime-python): state the package's current surface, not its stack position docs/AGENTS.md:38 keeps PRs, commits, and stack positions out of durable prose. Both README sides described where this layer sits in a PR stack and what a later PR would add, which goes stale the moment the backend lands. Describe what the package owns instead: the wire protocol, with an exported surface that carries no subprocess execution path. Re-record README.i18n.yaml. --- packages/code-runtime/code-runtime-python/README.i18n.yaml | 4 ++-- packages/code-runtime/code-runtime-python/README.md | 4 ++-- packages/code-runtime/code-runtime-python/README.zh.md | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/code-runtime/code-runtime-python/README.i18n.yaml b/packages/code-runtime/code-runtime-python/README.i18n.yaml index d511b98ed7..7071bd66a6 100644 --- a/packages/code-runtime/code-runtime-python/README.i18n.yaml +++ b/packages/code-runtime/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/code-runtime/code-runtime-python/README.md -README.md: a87b52ea6352392952a5833c2e88dfe44779efce -README.zh.md: 62f7e654bc431a7e9b0975541590e8b2780dac0c +README.md: 1fc19b89b751ce5063d6937d588b96f2f36ae69e +README.zh.md: 0351001308541b41ba519b70a61d25c48de73a4b diff --git a/packages/code-runtime/code-runtime-python/README.md b/packages/code-runtime/code-runtime-python/README.md index a87b52ea63..1fc19b89b7 100644 --- a/packages/code-runtime/code-runtime-python/README.md +++ b/packages/code-runtime/code-runtime-python/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) CPython-subprocess implementation of the [`@deepseek-ai/dsh-code-runtime`](../code-runtime/README.md) seam. Companion to [`@deepseek-ai/dsh-code-runtime-worker-thread`](../code-runtime-worker-thread/README.md); trades the Node worker thread for a fresh `python3` subprocess so model code is Python instead of TypeScript. -This package is built up across the code-runtime-python PR stack. This layer ships the wire protocol; the `PythonCodeRuntime` implementation that drives a `python3 -I` process over it lands on top of it. +The package owns the wire protocol for that seam: the host-side frame codec and the Python-side mirror of the same message vocabulary. ## Wire protocol @@ -26,4 +26,4 @@ No direct invalidation; the named consumer owns any request-prefix changes. ## Known Limitations and Deferred Work - **The cross-language guard covers the runtime-executed surfaces and the frame field shapes** — `tests/protocol-mirror.e2e.ts` spawns a real `python3` and asserts, against `src/protocol.ts`, both `PROTOCOL_FD` / the log truncation marker text AND each `TypedDict`'s required/optional wire field set in `py/protocol.py`. What it does not compare is the field *types* (e.g. that `cpuSeconds` is an `int` on both sides): comparing type declarations across TypeScript and Python has no mechanical equivalent here, so a type-level drift is still caught by review plus the backend's real-subprocess suite rather than this package's tests. -- **The `PythonCodeRuntime` implementation and its Python-side JSON codec are not in this layer** — they ship in the backend-core PR on top of this branch; `src/index.ts` re-exports only the protocol vocabulary until then. +- **`src/index.ts` exports the protocol vocabulary only** — the package carries no subprocess execution path and no Python-side JSON codec, so nothing here spawns `python3` outside the mirror test. diff --git a/packages/code-runtime/code-runtime-python/README.zh.md b/packages/code-runtime/code-runtime-python/README.zh.md index 62f7e654bc..0351001308 100644 --- a/packages/code-runtime/code-runtime-python/README.zh.md +++ b/packages/code-runtime/code-runtime-python/README.zh.md @@ -4,7 +4,7 @@ [`@deepseek-ai/dsh-code-runtime`](../code-runtime/README.md) seam 的 CPython 子进程实现。与 [`@deepseek-ai/dsh-code-runtime-worker-thread`](../code-runtime-worker-thread/README.md) 配套;以全新的 `python3` 子进程取代 Node worker 线程,让模型代码从 TypeScript 换成 Python。 -本包分多个 code-runtime-python PR 逐层搭建。本层交付 wire protocol;在其之上驱动 `python3 -I` 进程的 `PythonCodeRuntime` 实现随后落地。 +本包持有该 seam 的 wire protocol:host 侧的帧编解码,以及 Python 侧对同一套消息词汇的镜像。 ## Wire protocol @@ -26,4 +26,4 @@ host 与 CPython 子进程在子进程的 fd 3 上交换一个无版本号的 JS ## Known Limitations and Deferred Work - **跨语言 guard 覆盖运行时执行的面与帧字段形状** —— `tests/protocol-mirror.e2e.ts` 启动一个真实 `python3`,对照 `src/protocol.ts` 断言 `PROTOCOL_FD` / 日志截断标记文本,以及 `py/protocol.py` 中每个 `TypedDict` 的必填/可选 wire 字段集。它不比较字段的*类型*(例如 `cpuSeconds` 两侧都是 `int`):跨 TypeScript 与 Python 比较类型声明在此无机械等价物,故类型级漂移仍由 review 加后端真子进程套件捕获,而非本包的测试。 -- **`PythonCodeRuntime` 实现与 Python 侧 JSON codec 不在本层** —— 它们在基于本分支的 backend-core PR 中交付;在那之前 `src/index.ts` 只 re-export 协议词汇。 +- **`src/index.ts` 只导出协议词汇** —— 本包不含子进程执行路径,也不含 Python 侧的 JSON codec,因此除 mirror 测试之外没有任何地方会启动 `python3`。 From d1700c8a011b497d839ace9aadae7810993699db Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Mon, 17 Aug 2026 18:07:24 +0800 Subject: [PATCH 31/32] docs(code-runtime-python): scope the module JSDoc to the current contract The barrel's module comment described where a later implementation would sit relative to this seam, which docs/AGENTS.md:38 keeps out of durable prose. State what the module exports instead. --- packages/code-runtime/code-runtime-python/src/index.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/code-runtime/code-runtime-python/src/index.ts b/packages/code-runtime/code-runtime-python/src/index.ts index 625576f220..8a3e99f2d1 100644 --- a/packages/code-runtime/code-runtime-python/src/index.ts +++ b/packages/code-runtime/code-runtime-python/src/index.ts @@ -1,11 +1,10 @@ /** * CPython subprocess code runtime for the DeepSeek Harness code-execution seam. * - * This layer of the package ships the versionless fd-3 wire protocol between the - * Node host and the CPython subprocess; the `PythonCodeRuntime` implementation - * that drives a `python3 -I` process over it lands on top of this seam. The - * protocol's host-side codec and hostile-frame validators are re-exported so the - * runtime and its tests share one wire vocabulary. + * The package owns the versionless fd-3 wire protocol between the Node host and + * the CPython subprocess. The protocol's host-side codec and hostile-frame + * validators are re-exported so every consumer of the wire shares one + * vocabulary. * @module @deepseek-ai/dsh-code-runtime-python */ From 3057f3bb1b5b92cf5b84f815bcd0fc05a0d4cba0 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Mon, 17 Aug 2026 18:32:18 +0800 Subject: [PATCH 32/32] docs(code-runtime-python): state the empty invariant's real reason MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit packages/AGENTS.md:18 requires a package-specific "No runtime invariant:" reason on an empty installer. This one described a process-boundary implementation and real-subprocess integration tests that the package does not carry — it ships the wire-protocol codec and its Python mirror, covered by protocol.spec.ts and protocol-mirror.e2e.ts. --- packages/code-runtime/code-runtime-python/src/invariant.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/code-runtime/code-runtime-python/src/invariant.ts b/packages/code-runtime/code-runtime-python/src/invariant.ts index 99a25ac594..6f616bc5c0 100644 --- a/packages/code-runtime/code-runtime-python/src/invariant.ts +++ b/packages/code-runtime/code-runtime-python/src/invariant.ts @@ -15,8 +15,9 @@ export const name = 'code-runtime-python-invariant' export const inject = ['invariants'] /** - * No runtime invariant: this process-boundary implementation exposes no same-process event relation; - * the fd-3 protocol and real-subprocess integration tests cover it. + * No runtime invariant: this package ships only the fd-3 wire-protocol codec and its Python mirror, + * exposing no runtime event sequence or mutable data relation; `protocol.spec.ts` and + * `protocol-mirror.e2e.ts` cover the protocol's behavior. */ const install: InvariantInstaller = () => {}