fix: migrate legacy 4-digit CompressionBlock boundary refs at load and fork transfer - #416
ranxianglei wants to merge 4 commits into
Conversation
…d fork transfer Pre-1.1.0 persisted states carry 4-digit message refs (m0001). Session load migrates the messageIds ref maps to canonical 5-digit form but never the CompressionBlock.startId/endId boundaries, so legacy sessions keep mixed-width refs forever: hide-consumed range-key dedup stops matching consumed compress calls, and fork transfers (which read the parent's raw persisted record, bypassing loadPruneMessagesState) copy 4-digit boundaries into otherwise canonical fork state. - add migrateMessageRef() to lib/message-ids.ts (4→5 digit; non-refs pass through) - apply in loadPruneMessagesState (own-session load) - apply in restoreForkCompressionState block translation (fork transfer) - 5 new tests with red/green proof (3 behavioral paths + pass-through guard) Related: #415
📦 Built Plugin ArtifactBranch: Option A — Install from npm PR tag (recommended)opencode plugin opencode-acp@pr-416 --globalEach push to this PR publishes a new version under the Option B — Install from GitHubopencode plugin "github:ranxianglei/opencode-acp#2026-09-17_legacy-block-ref-migration" --globalOption C — Download artifact
tar xzf opencode-acp-pr416.tgz
cp -r package/dist ~/.cache/opencode/packages/opencode-acp@latest/node_modules/opencode-acp/dist
This comment is automatically updated on each push. |
[bot] 🏷 Picking this up. This issue tracks the already-open PR #416 (head |
[bot] 🏷 Verification complete — PR #416 ( Code review (per file)
Independent local runs (fresh checkout of
CI & process
Remaining before merge: dual-agent review per §5.3 (code) + §5.6 (tests) — this verification counts as one agent's pass; one more independent agent review is required per policy. Then human merge (merges are human-only). 中文摘要:已独立核验 PR #416 —— 代码逐文件正确(含边界用例)、本地全量 1272 测试中 2 个失败在干净 master |
…aw boundary values Dual-agent review of PR #416 found that migrating only block boundaries would regress hideConsumedCompressCalls batch filtering for legacy (pre-1.1.0) sessions: pre-fix both sides were consistently 4-digit, and immutable historical tool inputs stay 4-digit forever. - rangeKey now normalizes both sides via migrateMessageRef, with non-string pass-through for legacy records that omit boundaries - restoreForkCompressionState guards typeof before migrating (raw JSON) - migrateMessageRef JSDoc documents inherited trim/lowercase semantics - new RED-confirmed regression test: migrated 5-digit block keys match legacy 4-digit tool inputs in batch filtering
[bot] 🏷 ## Dual-agent review record (§5.3 code + §5.6 tests) Two independent reviews were run against the original commit ( Code review: Request changes → fixed in
Test review: Approve
Post-fix verification: typecheck + build pass; full suite 1273 tests / 1271 pass / 2 fail — the 2 failures reproduce identically on clean master (sandbox environment incompatibility, unrelated to this change). Prettier clean on all touched lines (two files carry pre-existing master drift on unrelated lines, left untouched to keep the diff minimal). |
Issue #415 follow-up review found decompress's toFile validation was lexical-only (path.resolve + path.relative), so a symlink inside an allowed root could redirect the write outside it, and writeFile then followed the link. - new lib/compress/tofile-target.ts: resolveSafeToFileTarget with three fail-closed layers — lexical containment, physical containment via realpath of the deepest existing ancestor vs physical allowed roots, final-component symlink refusal - decompress.ts validates through it and writes via fs/promises open with O_NOFOLLOW (POSIX) and mode 0o600, closing the check-then-write window - 8 new tests in tests/decompress-tofile-symlink.test.ts incl. intermediate-dir symlink escape and final-component symlink cases; fixtures inject allowed dirs under $TMPDIR
…sue #415) Review of the round-2 decompress.toFile hardening (7a0d154) found: - CRITICAL: physicallyAllowed required containment in ALL allowed roots (every(!containsOrIs(...) === false) && some(...)). The defaults are two disjoint roots (os.tmpdir(), ~/.cache/opencode), so with default config every legitimate toFile target was rejected. Fixed to some(containsOrIs) — containment in at least one existing root suffices. Regression test: 'accepts targets under either of two disjoint allowed roots' (RED against the old expression; single-root fixtures made every/some agree). - Node 22 compat: FileHandle.writeFile() exists only in Node >= 23 while CI runs Node 22/24 — use handle.write(Buffer.from(...)) instead. - New test: intermediate-dir symlink escaping across multiple roots (link into a second allowed root stays legitimate; link into a third dir is rejected) — not expressible with single-root fixtures. - Docs: O_NOFOLLOW noted POSIX-only; hardlinks documented as out of scope. - Restored Prettier style on hand-reformatted lines (diff vs HEAD now functional-only); all touched files pass prettier --check. Verification: typecheck pass; build pass; decompress-tofile-symlink 10/10; full suite 1283 tests / 1281 pass / 2 fail — same 2 pre-existing sandbox failures as clean master (/tmp read-only in daemon sandboxes).
Addresses the applicable item of #415 (R4: "Legacy fork state normalizes parent message-ref maps but not four-digit CompressionBlock.startId/endId boundaries"). The other nine items of #415 were triaged as not applicable to this V1-only repo — see the issue comment with per-item evidence.
Problem
Pre-1.1.0 persisted states carry 4-digit message refs (
m0001). At session load,lib/state/state.tsmigrates themessageIdsref maps to canonical 5-digit form (m00001) and re-saves — butCompressionBlock.startId/endIdboundaries are never migrated:loadPruneMessagesStatepasses boundaries through verbatim, so a legacy session's saved file keeps mixed-width refs permanently.restoreForkCompressionStatereads the parent's raw persisted record (loadSessionStatedoes not normalize blocks) and spreads...block, copying 4-digit boundaries into otherwise canonical fork state.Impact is low severity (no functional breakage — boundary resolution uses raw IDs + blockId, never
byRef), but concrete:hideConsumedCompressCallsrange-key dedup (lib/compress/hide-consumed.ts:84) stops matching consumed compress calls for legacy blocks → minor context duplication; export/recap display carries inconsistent widths.Fix
lib/message-ids.ts: new exportedmigrateMessageRef(ref)— 4→5 digit via existingparseMessageRef/formatMessageRef; non-message values (empty strings,bNblock refs, free text, out-of-range indices) pass through unchanged.lib/state/utils.ts:loadPruneMessagesStatemigrates blockstartId/endId.lib/state/rebuild.ts:restoreForkCompressionStatenormalizes translated block boundaries.Tests
tests/legacy-block-boundary-migration.test.ts— 5 tests:migrateMessageRefunit coverage incl. edge cases (m00000, 6-digit,b3, whitespace/case)ensureSessionInitializedintegration with self-healing re-save assertionRed/green proof (§5.7.3): behavioral tests written first fail with
actual: 'm0001'vsexpected: 'm00001'at all three production paths; all green after the fix.Verification
06efd39(sandbox/tmpis read-only:soft-block.test.tsEACCES mkdir,inactive-block-decompress.test.tstoFile path guard); pre-existing environment incompatibility, unrelated to this change.Related: #415 (item R4); related code path introduced alongside #409.