diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-code-runtime-python-settlement-fixes.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-31-code-runtime-python-settlement-fixes.i18n.yaml index 39e4643d5a..fe0febd99d 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-code-runtime-python-settlement-fixes.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-31-code-runtime-python-settlement-fixes.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-31-code-runtime-python-settlement-fixes.md -2026-07-31-code-runtime-python-settlement-fixes.md: d4415f1458eea1de8e0e9a02f24ca838f31bf392 -2026-07-31-code-runtime-python-settlement-fixes.zh.md: ca50aadd6874ed62a4b57eea145b01d55dfe1bc0 +2026-07-31-code-runtime-python-settlement-fixes.md: 161a21baede3074bb84c6e17c206d3baa1aa0582 +2026-07-31-code-runtime-python-settlement-fixes.zh.md: ecd52edb80cd03b4dd061ade858e35041cbd0309 diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-code-runtime-python-settlement-fixes.md b/.agents/notes/implemented/bug-fix/2026-07-31-code-runtime-python-settlement-fixes.md index d4415f1458..161a21baed 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-code-runtime-python-settlement-fixes.md +++ b/.agents/notes/implemented/bug-fix/2026-07-31-code-runtime-python-settlement-fixes.md @@ -74,7 +74,7 @@ One residual write-path copy is fixed alongside, independent of the config gate: ### The completion value and error are pre-encoded at their validation point -In [`py/bootstrap.py`](../../../../packages/code-runtime/code-runtime-python/py/bootstrap.py), `_done_with_value` now returns the whole terminal frame as a PRE-ENCODED JSON string on the success path: the admitted value is serialized once here, at the validation point inside `_run`'s `try`, as `'{"type": "done", "value": ' + _encode_json_plain(value) + "}"`. The program can keep mutating a returned list/dict from a daemon thread or signal handler after it returns, so a second traversal held at a later point would be a TOCTOU — a concurrent mutation into a non-JSON type would let that later encode throw outside the settlement handler and downgrade a settled run host-side to `worker-exit`. Serializing once, inside the `try` that wraps this call, closes the window: if a concurrent mutation makes the encode throw, the exception handler classifies it as an `exception`, and once the string is produced the frame is written verbatim with no further touching of the live value. `_run` binds the `_done_with_value` ENTRY NAME into a local (`done_with_value_bound`) before the program runs, and `_done_with_value` itself binds `_check_done_value` and `_encode_json_plain` as DEF-TIME default arguments — so a `__main__` rebind of the entry name or those two names after model execution cannot rewrite a legitimate success into an `exception`. The log ledgers (host `logBudget` and child `_remaining`) start ONE byte below the budget, reserving the serialized outer-array envelope (two brackets and n-1 commas over n entries' separators), so a result that exactly exhausts the ledger still serializes within the configured cap; the truncation marker remains envelope, not payload. The constructor rejects a `maxLogBytes` below 64 (the smallest budget with one byte of room for the truncation marker's own serialized form); `maxValueBytes` keeps only the positive-integer requirement, since a completion can be a single byte and the done-frame envelope is seam protocol cost. The marker remains envelope, so a truncated run with admitted entries serializes to at most `maxLogBytes + marker + envelope` (recorded in the package README). A rebind of a transitive dep the encoder reaches (e.g. `_dump_scalar`/`_dump_string`/`json`/`io` — a non-exhaustive set) can still, which is registered as an accepted residual in the package README. +In [`py/bootstrap.py`](../../../../packages/code-runtime/code-runtime-python/py/bootstrap.py), `_done_with_value` now returns the whole terminal frame as a PRE-ENCODED JSON string on the success path: the admitted value is serialized once here, at the validation point inside `_run`'s `try`, as `'{"type": "done", "value": ' + _encode_json_plain(value) + "}"`. The program can keep mutating a returned list/dict from a daemon thread or signal handler after it returns, so a second traversal held at a later point would be a TOCTOU — a concurrent mutation into a non-JSON type would let that later encode throw outside the settlement handler and downgrade a settled run host-side to `worker-exit`. Serializing once, inside the `try` that wraps this call, closes the window: if a concurrent mutation makes the encode throw, the exception handler classifies it as an `exception`, and once the string is produced the frame is written verbatim with no further touching of the live value. `_run` binds the `_done_with_value` ENTRY NAME into a local (`done_with_value_bound`) before the program runs, and `_done_with_value` itself binds `_check_done_value` and `_encode_json_plain` as DEF-TIME default arguments — so a `__main__` rebind of the entry name or those two names after model execution cannot rewrite a legitimate success into an `exception`. The log ledgers (host `logBudget` and child `_remaining`) start ONE byte below the budget, reserving the serialized outer-array envelope (two brackets and n-1 commas over n entries' separators), so a result that exactly exhausts the ledger still serializes within the configured cap; the truncation marker remains envelope, not payload. The constructor rejects a `maxLogBytes` below 64 (the smallest budget with one byte of room for the truncation marker's own serialized form); `maxValueBytes` keeps only the positive-integer requirement, since a completion can be a single byte and the done-frame envelope is seam protocol cost. The marker remains envelope, so a truncated run with admitted entries serializes to at most `maxLogBytes + marker + envelope` (recorded in the package README). A rebind of a transitive dep the encoder reaches (e.g. `_dump_scalar`/`_dump_string`/`json`/`io` — a non-exhaustive set) can still make the encode throw and downgrade a success to an `exception`, which is registered as an accepted residual in the package README. `send_done` (a local function inside `_run`) writes the pre-encoded string through a BOUND `channel.write_encoded`, and encodes a dict error frame through a bound `_encode_json_plain` before writing it — it never calls `channel.send_sync`, whose body re-resolves `self.write_encoded` and the module-level `_encode_json_plain` at call time. `_encode_json_plain` and `channel.write_encoded` are bound into locals before the program runs, for the same reason `flush_out`/`flush_err`/`safe_model_traceback` are: the program runs as `__main__`, so `import __main__; __main__.ProtocolChannel.send_sync = boom` or `__main__._encode_json_plain = boom` would otherwise re-resolve the send/encode to a rebranded callable at call time and, when that replacement raises, skip the `done` frame and downgrade a settled verdict to a host-side `worker-exit`. diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-code-runtime-python-settlement-fixes.zh.md b/.agents/notes/implemented/bug-fix/2026-07-31-code-runtime-python-settlement-fixes.zh.md index ca50aadd68..ecd52edb80 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-code-runtime-python-settlement-fixes.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-31-code-runtime-python-settlement-fixes.zh.md @@ -74,7 +74,7 @@ Status: implemented ### 完成值与错误在其校验点处预编码 -在 [`py/bootstrap.py`](../../../../packages/code-runtime/code-runtime-python/py/bootstrap.py) 中,`_done_with_value` 现在会在成功路径上把整个终止帧作为一个已预编码的 JSON 字符串返回:被准入的值在这里、在 `_run` 的 `try` 之内的校验点处恰好序列化一次,即 `'{"type": "done", "value": ' + _encode_json_plain(value) + "}"`。程序在返回之后仍可能从 daemon 线程或信号处理器继续变异它返回的 list/dict,因此在一个更晚的点上做第二次遍历会构成一次 TOCTOU——如果一次变异让一个并发变异的后续编码在结算处理器之外抛出,就会把一次已结算的运行在宿主侧降级成 `worker-exit`。在这里、在包裹该调用的 `try` 之内恰好序列化一次,就关上了这个窗口:如果一次并发变异导致编码抛出,异常处理器会把它如实分类为 `exception`;一旦字符串产生出来,该帧就会被逐字写走、不再触碰任何活对象。`_run` 在程序运行前把 `_done_with_value` 的入口名绑成局部(`done_with_value_bound`),而 `_done_with_value` 自身把 `_check_done_value` 与 `_encode_json_plain` 绑定为 def 期默认参数——因此模型执行后对入口名或这两个名字的 `__main__` 重绑无法把一个合法成功改写为 `exception`。日志账本(宿主 `logBudget` 与子进程 `_remaining`)从预算低 1 字节起算,预留序列化外层数组的外壳(两条括号与 n-1 个逗号,覆盖 n 条目的分隔符),因此恰好耗尽账本的结果序列化后仍在配置上限之内。构造器拒绝低于 64 的 `maxLogBytes`(能为截断标记自身序列化形式留出一字节余量的最小预算);`maxValueBytes` 只保留正整数要求,因为完成值可以只有一字节、且 done 帧外壳是 seam 协议成本。标记仍是 envelope,因此带已放行条目的截断运行序列化后至多为 `maxLogBytes + marker + envelope`(已记录在包 README)。但编码器到达的一个传递依赖(例如 `_dump_scalar`/`_dump_string`/`json`/`io`——非穷举清单)重绑仍可,这在包 README 中被登记为已接受残余。 +在 [`py/bootstrap.py`](../../../../packages/code-runtime/code-runtime-python/py/bootstrap.py) 中,`_done_with_value` 现在会在成功路径上把整个终止帧作为一个已预编码的 JSON 字符串返回:被准入的值在这里、在 `_run` 的 `try` 之内的校验点处恰好序列化一次,即 `'{"type": "done", "value": ' + _encode_json_plain(value) + "}"`。程序在返回之后仍可能从 daemon 线程或信号处理器继续变异它返回的 list/dict,因此在一个更晚的点上做第二次遍历会构成一次 TOCTOU——如果一次变异让一个并发变异的后续编码在结算处理器之外抛出,就会把一次已结算的运行在宿主侧降级成 `worker-exit`。在这里、在包裹该调用的 `try` 之内恰好序列化一次,就关上了这个窗口:如果一次并发变异导致编码抛出,异常处理器会把它如实分类为 `exception`;一旦字符串产生出来,该帧就会被逐字写走、不再触碰任何活对象。`_run` 在程序运行前把 `_done_with_value` 的入口名绑成局部(`done_with_value_bound`),而 `_done_with_value` 自身把 `_check_done_value` 与 `_encode_json_plain` 绑定为 def 期默认参数——因此模型执行后对入口名或这两个名字的 `__main__` 重绑无法把一个合法成功改写为 `exception`。日志账本(宿主 `logBudget` 与子进程 `_remaining`)从预算低 1 字节起算,预留序列化外层数组的外壳(两条括号与 n-1 个逗号,覆盖 n 条目的分隔符),因此恰好耗尽账本的结果序列化后仍在配置上限之内。构造器拒绝低于 64 的 `maxLogBytes`(能为截断标记自身序列化形式留出一字节余量的最小预算);`maxValueBytes` 只保留正整数要求,因为完成值可以只有一字节、且 done 帧外壳是 seam 协议成本。标记仍是 envelope,因此带已放行条目的截断运行序列化后至多为 `maxLogBytes + marker + envelope`(已记录在包 README)。但编码器到达的一个传递依赖(例如 `_dump_scalar`/`_dump_string`/`json`/`io`——非穷举清单)重绑仍可让编码抛出、把成功降级为 `exception`,这在包 README 中被登记为已接受残余。 `send_done`(`_run` 内部的一个局部函数)通过绑定的 `channel.write_encoded` 写出已预编码的字符串,并在写之前用绑定的 `_encode_json_plain` 编码一个 dict 错误帧——它绝不经 `channel.send_sync`,因为后者的函数体会在调用时刻重新解析 `self.write_encoded` 和模块级的 `_encode_json_plain`。`_encode_json_plain` 与 `channel.write_encoded` 在程序运行前就被绑定进局部变量,理由与 `flush_out`/`flush_err`/`safe_model_traceback` 被绑定相同:程序以 `__main__` 运行,因此 `import __main__; __main__.ProtocolChannel.send_sync = boom` 或 `__main__._encode_json_plain = boom` 本会在调用时刻把发送/编码重新解析成被替换的可调用对象,当该替换抛出时跳过 `done` 帧、把已结算的结论降级成宿主侧的 `worker-exit`。 diff --git a/packages/code-runtime/code-runtime-python/README.i18n.yaml b/packages/code-runtime/code-runtime-python/README.i18n.yaml index 28bd40823e..c0dc2791ee 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: 25211993e1ebac7b940d4f373391819c15cab5c0 -README.zh.md: ca260b8c8d543545ce927f2c48e4676b34622786 +README.md: fbb37d36523d12357a30c319409afc69ce64e487 +README.zh.md: 10141bc634e523b1fa079280039b0e5eda7e45d8 diff --git a/packages/code-runtime/code-runtime-python/README.md b/packages/code-runtime/code-runtime-python/README.md index 25211993e1..fbb37d3652 100644 --- a/packages/code-runtime/code-runtime-python/README.md +++ b/packages/code-runtime/code-runtime-python/README.md @@ -36,7 +36,7 @@ No direct invalidation; the named consumer owns any request-prefix changes. - **The cross-language guard covers executed values and frame field sets, not field types** — `tests/protocol-mirror.e2e.ts` compares `PROTOCOL_FD`, the log truncation marker, and each `TypedDict`'s required and optional fields against a real `python3`. Comparing field types across TypeScript and Python has no mechanical equivalent here, so review plus the backend's real-subprocess suite owns type-level drift. - **`RLIMIT_AS` is not enforced on macOS** — the dyld shared cache mapped into every process at exec exceeds any practical address-space cap, and the kernel rejects the `setrlimit` call, so `addressSpaceMb` is skipped there. `cpuSeconds` and `maxWallMs` still bound every run. -- **PID-reuse protection is inert on macOS** — `readProcessStart` reads `/proc//stat`, which Darwin does not provide, so the identity re-check that guards `killGroup` against signalling a recycled pgid always passes there; the guard degrades to the pre-existing behavior rather than paying a `ps` fork on a teardown path. The process-group teardown and the `closeDeadline` bound still contain the run. +- **PID-reuse protection is inert on macOS** — `readProcessStart` reads `/proc//stat`, which Darwin does not provide, so the identity re-check that guards `killGroup` against signalling a recycled pgid always passes there; `killGroup` signals the pgid without the identity re-check on macOS rather than paying a `ps` fork on a teardown path. The process-group teardown and the `closeDeadline` bound still contain the run. - **A truncated log's serialized array runs to `maxLogBytes` plus the marker.** The truncation marker is envelope, not payload — it rides uncharged so it can always be emitted — and the outer-array envelope is reserved one byte in the ledger. A truncated run with admitted entries therefore serializes its `logs` array to at most `maxLogBytes + marker + 1`; the marker alone fits any admissible budget (the 64-byte floor guarantees it). - **A descendant that calls `setsid()` / `start_new_session=True` escapes teardown.** Termination signals the child's process group with `kill(-pid)`; a descendant that moves itself into a fresh session is no longer in that group and no signal reaches it. If it also releases the inherited stdout/stderr/fd-3 pipes, the leader's `close` still settles the run, and after the `closeDeadline` bound the fiber goes quiescent while that orphan keeps running. This is the containment boundary, not a security one — model code has bash-equivalent trust, and a bash tool can `setsid` away just the same. Reaching such an orphan would require tracking every descendant pid (as the bash-local backend's process-inspector does) and is deferred; the process-group teardown reaps everything that stays in the group. - **A combined log-and-value peak is not modelled by the load gate.** Each budget is checked against `addressSpaceMb` on its own. A model daemon thread that keeps writing while the completion value is metered and framed can refill the log pending toward `maxLogBytes` during that window, so the two peaks add in a way no gate admits or rejects. A gate over `(maxLogBytes + maxValueBytes)` was considered and deferred: its discriminating case cannot be scheduled deterministically under `RLIMIT_AS`, so the gate would only prove its own arithmetic. When the combined peak is reached the run dies as `worker-exit` -- containment holds and only the failure classification is degraded. diff --git a/packages/code-runtime/code-runtime-python/README.zh.md b/packages/code-runtime/code-runtime-python/README.zh.md index ca260b8c8d..10141bc634 100644 --- a/packages/code-runtime/code-runtime-python/README.zh.md +++ b/packages/code-runtime/code-runtime-python/README.zh.md @@ -36,7 +36,7 @@ host 与 CPython 子进程在子进程的 fd 3 上交换一个无版本号的 JS - **跨语言 guard 覆盖执行值与帧字段集,但不覆盖字段类型** —— `tests/protocol-mirror.e2e.ts` 使用真实 `python3` 比较 `PROTOCOL_FD`、日志截断标记,以及每个 `TypedDict` 的必填和可选字段。跨 TypeScript 与 Python 比较字段类型在此没有机械等价物,因此类型级漂移由 review 加后端真子进程套件负责。 - **`RLIMIT_AS` 在 macOS 上不施加** —— 在 exec 时映射进每个进程的 dyld 共享缓存超过任何实际的地址空间上限,内核会拒绝该 `setrlimit` 调用,故 `addressSpaceMb` 在那里被跳过。`cpuSeconds` 与 `maxWallMs` 仍约束每一次运行。 -- **PID 复用防护在 macOS 上失效** —— `readProcessStart` 读取 `/proc//stat`,Darwin 不提供它,因此防止 `killGroup` 对已回收的 pgid 发信号的同一性复检在那里恒通过;该防护退化为既有行为,而非在拆卸路径上付出一次 `ps` fork。进程组拆卸与 `closeDeadline` 上界仍约束该次运行。 +- **PID 复用防护在 macOS 上失效** —— `readProcessStart` 读取 `/proc//stat`,Darwin 不提供它,因此防止 `killGroup` 对已回收的 pgid 发信号的同一性复检在那里恒通过;`killGroup` 在 macOS 上不经同一性复检直接对 pgid 发信号,而非在拆卸路径上付出一次 `ps` fork。进程组拆卸与 `closeDeadline` 上界仍约束该次运行。 - **截断日志的序列化数组会到 `maxLogBytes` 加标记为止。** 截断标记是 envelope 而非 payload——它不计费地随行,因此总能发出——而外层数组外壳在账本中预留了一字节。因此带已放行条目的截断运行,其 `logs` 数组序列化后至多为 `maxLogBytes + marker + 1`;标记单独能放进任何可接受的预算(64 字节下限保证这一点)。 - **调用 `setsid()` / `start_new_session=True` 的后代会逃出 teardown。** 终止是用 `kill(-pid)` 向子进程的进程组发信号;一个把自己移入新会话的后代已不在该进程组内,任何信号都到不了它。若它同时释放了继承而来的 stdout/stderr/fd-3 管道,leader 的 `close` 仍会结算该次运行,在 `closeDeadline` 到界之后 fiber 变为完全停稳,而那个孤儿仍在运行。这是 containment 边界,而非安全边界——模型代码具有等同 bash 的信任级别,一个 bash 工具同样能 `setsid` 逃逸。要够到这样的孤儿需要追踪每一个后代 pid(如 bash-local 后端的 process-inspector 所做),此项已推迟;进程组 teardown 会回收所有留在组内的进程。 - **日志与完成值的叠加峰值未被加载门建模。** 每项预算都是各自对照 `addressSpaceMb` 检查的。模型的 daemon 线程可以在完成值被计量并分帧的窗口内持续写入、把日志 pending 重填到接近 `maxLogBytes`,于是两个峰值以任何门都不曾放行也不曾拒绝的方式相加。对 `(maxLogBytes + maxValueBytes)` 设门的方案经评估后推迟:它的判别用例无法在 `RLIMIT_AS` 之下确定性地构造出来,因此该门只能证明自己的算术。叠加峰值被触及时该次运行死为 `worker-exit`——containment 仍然成立,只是失败分类失真。 diff --git a/packages/code-runtime/code-runtime-python/src/index.ts b/packages/code-runtime/code-runtime-python/src/index.ts index 17a45f1203..104320011f 100644 --- a/packages/code-runtime/code-runtime-python/src/index.ts +++ b/packages/code-runtime/code-runtime-python/src/index.ts @@ -357,9 +357,10 @@ function messageOf(error: unknown): string { * Linux reads field 22 of `/proc//stat` (starttime in clock ticks); the * field is positional after the comm field's closing parenthesis, which is * parsed from the LAST such character because a process name may contain one. - * Darwin has no `/proc`, so the caller gets `undefined` there and the guard - * degrades to the pre-existing behavior rather than paying a `ps` fork on a - * teardown path. Any read failure is `undefined` for the same reason: this + * Darwin has no `/proc`, so the caller gets `undefined` there and `killGroup` + * signals the pgid without the identity re-check rather than paying a `ps` + * fork on a teardown path. Any read failure is `undefined` for the same + * reason: this * hardens a narrow race and must never be the thing that breaks teardown. * @param pid - the process to read. * @returns its start time, or undefined when unavailable. diff --git a/packages/code-runtime/code-runtime-python/tests/runtime.spec.ts b/packages/code-runtime/code-runtime-python/tests/runtime.spec.ts index b490a190a5..887013ca01 100644 --- a/packages/code-runtime/code-runtime-python/tests/runtime.spec.ts +++ b/packages/code-runtime/code-runtime-python/tests/runtime.spec.ts @@ -408,8 +408,9 @@ describe('PythonCodeRuntime — process identity', () => { // undefined and the guard is inert). expect(readProcessStart(0)).toBeUndefined() } else { - // Darwin has no /proc: the reader reports undefined, and `killGroup` then - // keeps its pre-existing behavior instead of paying a `ps` fork per signal. + // Darwin has no /proc: the reader reports undefined, and `killGroup` + // signals the pgid without the identity re-check instead of paying a `ps` + // fork per signal. expect(own).toBeUndefined() } })