fix(code-runtime-python): bound the open-merge hold by the ledger budget

The review's critical: the open-merge branch accumulated the held fragment
before any ledger check, so a forged open flood could grow host memory without
touching logBudget. The held fragment is now bounded by the exact-cost walk
(jsonStringCostUpTo against the remaining budget; the closing frame's admit()
still bills the merged entry once), and the open field is registered in the
README wire-contract section and the fd-3 protocol note (en + zh). A forged
open-flood case asserts truncation to the marker under a 64-byte budget.
This commit is contained in:
Chinesezjc
2026-08-31 15:03:20 +08:00
committed by Tianyi Cui
parent 72691455e9
commit ea1d28a068
8 changed files with 45 additions and 11 deletions
@@ -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: 671506aafbad1b03bc66ae137a58a7b11a836f79
2026-07-31-code-runtime-python-fd3-protocol.zh.md: 1568f6366b4651fbb87fcfdfacdc274fa07b4d8e
2026-07-31-code-runtime-python-fd3-protocol.md: 992628b8fcadc8e09bd52c4f862542e2cc9f1acc
2026-07-31-code-runtime-python-fd3-protocol.zh.md: bd5e76171f860bc0ad05fa1980cda3da4f8c4dfe
@@ -24,7 +24,7 @@ The package ships the runtime alongside the protocol; it remains independently b
## 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.
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. The `log` frame's `open` flag marks an unterminated line committed by an explicit flush: the host holds it (bounded by the ledger budget) and appends the next frame to the same entry, so an explicit flush followed by more text reads back as one line rather than a fake newline. `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
@@ -24,7 +24,7 @@ Status: implemented
## 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 在与子进程相同的点停止捕获,而不是从自己的预算去推断。`log` 帧的 `open` 标志标记由显式 flush 提交的未结束行:宿主持有它(受账本预算约束)并把下一个帧追加到同一条目,因此显式 flush 后接更多文本读回为一行而不是假换行。`done.error.kind``exception``invalid-output``output-limit` 之一;wall/CPU 预算、abort、substrate 死亡都在 host 侧观测,不作为帧携带。
## Mirror alignment
@@ -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: c6ab48b5ccd4e386f9ad416dee64b090500594f6
README.zh.md: 637de914212be9b7e6a3b63e443d7252c092acf2
README.md: 97a4b6d4ccf51cf999dcef237aac1480a532bce6
README.zh.md: 649ca8ddd4c1ad02f27b8131c60f22511801c1e9
@@ -33,7 +33,7 @@ The package's default export is the `PythonCodeRuntime` plugin. Its public surfa
### The wire
Frames travel on the child's fd 3 as JSON-lines — one object per line — so stdout/stderr stay clear for the program's own output. Child → host: `boot-ack`, `call`, `log`, `done`. Host → child: `boot` (first frame, carrying every cap and the namespace declarations), `run` (after `boot-ack`, carrying only the program body), and one `reply` per `call`. A forged frame can carry both `value` and `error` on `done`, so a consumer must check `error` first and ignore `value` when it is set.
Frames travel on the child's fd 3 as JSON-lines — one object per line — so stdout/stderr stay clear for the program's own output. Child → host: `boot-ack`, `call`, `log`, `done`. Host → child: `boot` (first frame, carrying every cap and the namespace declarations), `run` (after `boot-ack`, carrying only the program body), and one `reply` per `call`. A forged frame can carry both `value` and `error` on `done`, so a consumer must check `error` first and ignore `value` when it is set. A `log` frame's `open` flag marks an unterminated line committed by an explicit flush: the host appends the next log frame to the same entry, so `print('a', end='', flush=True); print('b')` reads back as one `'ab'` entry rather than a fake newline.
### What can go wrong
@@ -33,7 +33,7 @@ kind: "package-reference"
### wire
帧在子进程 fd 3 上以 JSON-lines 传输——每行一个对象——因此 stdout/stderr 留给程序自己的输出。子进程 → 宿主:`boot-ack``call``log``done`。宿主 → 子进程:`boot`(首帧,携带全部上限与命名空间声明)、`run``boot-ack` 之后,只携带程序体)与每个 `call` 一个 `reply`。伪造帧可在 `done` 上同时携带 `value``error`,因此消费方必须先检查 `error`,在它存在时忽略 `value`
帧在子进程 fd 3 上以 JSON-lines 传输——每行一个对象——因此 stdout/stderr 留给程序自己的输出。子进程 → 宿主:`boot-ack``call``log``done`。宿主 → 子进程:`boot`(首帧,携带全部上限与命名空间声明)、`run``boot-ack` 之后,只携带程序体)与每个 `call` 一个 `reply`。伪造帧可在 `done` 上同时携带 `value``error`,因此消费方必须先检查 `error`,在它存在时忽略 `value``log` 帧的 `open` 标志标记由显式 flush 提交的未结束行:宿主把下一个 log 帧追加到同一条目,因此 `print('a', end='', flush=True); print('b')` 读回为一条 `'ab'` 条目而不是假换行。
### 可能出错的地方
@@ -1504,9 +1504,23 @@ export class PythonCodeRuntime extends CodeRuntime {
// An explicit flush of an unterminated line: hold it so the next
// frame appends to the SAME entry (print('a', end='', flush=True)
// followed by print('b') reads back as one 'ab' entry, not a fake
// newline). The residual is admitted by finish() if the run ends
// with it still open.
openLog = (openLog ?? '') + message.text
// newline). The held fragment is BOUNDED by the ledger budget via
// the exact-cost walk (a forged open flood would otherwise grow
// openLog without touching logBudget — the same unbounded-retention
// attack the ledger exists to stop). The cost is NOT billed here:
// the closing frame's admit() bills the whole merged entry once.
if (!logsTruncated) {
const merged = (openLog ?? '') + message.text
if (jsonStringCostUpTo(merged, logBudget - 1) === undefined) {
logsTruncated = true
logs.push(logTruncationMarker(this.config.maxLogBytes))
clearStray(strayOut)
clearStray(strayErr)
openLog = undefined
} else {
openLog = merged
}
}
return
}
admit((openLog ?? '') + message.text)
@@ -1878,6 +1878,26 @@ describe('PythonCodeRuntime — programs and bindings', () => {
expect(result.logs).toEqual(['committed'])
}, 15_000)
it('bounds a forged open-frame flood against the log budget', async () => {
// The open hold must be bounded by the ledger: without the exact-cost check
// a forged open flood would grow the held fragment without touching
// logBudget — unbounded host retention under a small budget. The flood now
// truncates to the marker like any over-budget log traffic.
const { runtime } = await setup({ maxLogBytes: 64 })
const result = await runtime.run({
program: [
'import os',
// 2000 forged open frames, each under the frame parse cap.
'for _ in range(2000):',
" os.write(3, b'{\"type\":\"log\",\"text\":\"a\",\"open\":true}\\n')",
'return "done"',
].join('\n'),
bindings: [],
})
expect(result.error).toBeUndefined()
expect(result.logs).toEqual([logTruncationMarker(64)])
}, 15_000)
it('keeps a float completion exact when the program mutates the decimal context', async () => {
// The float encoder's Decimal(repr(value)).normalize() used the process
// GLOBAL decimal context: a legitimate program setting