Skip to content

fix(anolisa): exclude Python bytecode caches from adapter bundle digests - #2276

Open
ikunkun-sys wants to merge 1 commit into
alibaba:mainfrom
ikunkun-sys:fix/2252-pycache-bundle-drift
Open

fix(anolisa): exclude Python bytecode caches from adapter bundle digests#2276
ikunkun-sys wants to merge 1 commit into
alibaba:mainfrom
ikunkun-sys:fix/2252-pycache-bundle-drift

Conversation

@ikunkun-sys

@ikunkun-sys ikunkun-sys commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator

Problem

Fixes #2252.

Link-mode adapters (qwencode, codex) execute the resource root in place, so the first hook run writes hooks/__pycache__/*.pyc into the very tree the enable-time digest sealed. adapter status re-hashes the tree, sees the bytecode caches, and flips a healthy adapter to degraded / resource bundle changed since enable — running the installed hooks is enough to break the adapter's own health report.

Change

Minimal mitigation, following the narrow exclusion direction #2252 itself proposes ("让 bundle digest 明确排除可安全识别的运行时派生文件"):

  • is_python_bytecode (shared predicate): __pycache__/ directories and stray *.pyc files are skipped by every bundle digest — util::digest_tree (cosh/codex/claude-code/qoder/qwencode), the openclaw and hermes private copies, and the system-update source snapshot (digest_bundle_tree).
  • Seal semantics marker + legacy compatibility (from review): new seals are written as sha256/2:<hex> and always compare under the semantics that wrote them, so verdicts stay deterministic under any bytecode churn. Legacy seals (bare sha256:, bytecode included — notably the [anolisa] sec-core Hook 生成 __pycache__ 导致 bundle drift 误报 #2252 re-enable workaround population) are matched when the tree reproduces them under either semantics; on bytecode-free trees a mismatch is still decidably Changed. When a legacy seal's caches have churned, the original cache bytes are unrecoverable, so all comparison sites (verify_seal) report a distinct Unknown with re-enable to refresh the seal — never a false Degraded, never a fake Healthy. One re-enable reseals with the marker. Persisting the reseal automatically (status is read-only and lock-free) is recorded in [anolisa] Track adapter source revision for copy-mode adapters (same-version content changes) #2279.
  • Nothing else changes: the manifest, hook sources, and every other managed file stay digested, so real tampering and same-version content changes remain detectable exactly as before.

Known residual, deliberately accepted for the stopgap: a planted .pyc is loadable by CPython yet no longer digested. That, together with the broader staleness redesign (version-based receipts, copy-vs-source verification for copy-mode adapters), is tracked in #2279 as the formal follow-up.

Testing

  • cargo test --workspace green; clippy/fmt clean.
  • digest_tree_ignores_python_bytecode_caches (unit): bytecode caches don't change the digest; source edits still do.
  • codex_status_stays_healthy_when_hooks_write_bytecode_caches (e2e): enable → hook run writes __pycache__ into the executed root → status stays Healthy with ResourceBundleMatches=True.
  • codex_status_accepts_receipts_sealed_with_pre_exclusion_digests (e2e) + verify_seal_handles_legacy_receipts_and_cache_churn (unit): a legacy-sealed receipt stays Healthy on an unchanged tree; bytecode regeneration (cache A → B) yields Unknown + re-enable hint, never a false Degraded; after re-enable the v2 seal shrugs off further churn and a managed-file edit degrades.

@github-actions github-actions Bot added the component:anolisa src/anolisa label Aug 6, 2026

@qoderai qoderai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] SourceVersionMatches 条件在旧收据 / 缺版本场景下的文案和引导略弱,建议在 Unknown 分支对“旧收据但当前版本已解析”给出更清晰的重新启用提示。

[P1] 组件更新后 adapter_actions 的版本比较路径依赖 StateStore 中的安装记录,需确认 RPM 原地升级等所有版本变更流程都在写 state 时同步更新,否则可能出现未提示重新启用的漏报。

[P2] Manager 侧将 SourceVersionMatches = Unknown 时统一把 Healthy 降级为 Unknown,可能在部分环境里让 adapter 永远无法达到 Healthy;建议只在“明确读取当前版本失败”的情况下执行该降级,旧收据或未声明版本的场景保留 driver 的 Healthy 判定。


🤖 Generated by QoderView workflow run

Comment thread src/anolisa/crates/anolisa-core/src/adapter/manager.rs Outdated
Comment thread src/anolisa/crates/anolisa-cli/src/commands/tier1/update.rs Outdated
Comment thread src/anolisa/crates/anolisa-core/src/adapter/manager.rs Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a45a30b528

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/anolisa/crates/anolisa-core/src/adapter/manager.rs Outdated

@kongche-jbw kongche-jbw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review baseline: 4ec67cfef3377b32e6d8125d9533825901fdeffc...a45a30b52834bff03b269a325f0e6da7cdb41edc

[P1] Do not use the component version as the adapter source identity

Location: src/anolisa/crates/anolisa-core/src/adapter/claim.rs:135

source_freshness returns Current whenever the two version strings match, while this PR removes
every resource-content check. After enable, editing a managed hook such as hooks/pii_text.py, or
installing an RPM revision that replaces adapter files without changing component.version, leaves
SourceVersionMatches=true and can keep adapter status healthy even though executable source
changed. The inverse also occurs: a component version bump with byte-identical adapter files is
marked degraded, although the unchanged-bundle update path intentionally emits no action.

Possible direction: persist and compare an adapter source revision, or a digest of immutable
delivered files that excludes declared runtime outputs. Add regression coverage proving
__pycache__ stays healthy while a same-version managed-file change degrades.

@ikunkun-sys

ikunkun-sys commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator Author

@kongche-jbw Thanks — responding to the three parts of your review:

Same-version content changes (edited hook, RPM revision with unchanged component.version) no longer degrade status. This is a deliberate scope decision rather than an oversight. Content integrity of delivered files is the install layer's domain, where the platform already has per-file authority: OwnedFile.sha256 records for raw installs and rpm -V for RPM. The receipt digest this PR removes could not serve as a tamper check anyway — it lives in the same user-writable installed.toml an attacker who can edit hooks can also edit, so it was self-attestation, not a boundary. Folding a weaker duplicate of file integrity into adapter staleness is what produced #2252: any answer derived from re-reading the tree has to decide what in the tree is signal, and that classification belongs to the layer that owns the file inventory. A follow-up that surfaces install-layer integrity results in adapter status would be a good addition, but as a reference to that machinery, not a second hash.

Version bump with byte-identical adapter files now prompts re-enable. Intentional. The receipt asserts "this enable corresponds to component@X"; once the installed component is Y, that assertion is stale regardless of whether the adapter bytes moved — registration/config state may still differ, and re-enable is cheap and idempotent, re-recording the version. The system-update path answers a different question ("did this transaction change the bundle source") and intentionally stays byte-based; the two paths disagreeing on this edge is the semantic difference, not an inconsistency.

Regression coverage: added in 36935e8cosh_status_stays_healthy_when_runtime_writes_into_resource_root pins that __pycache__ in the resource root keeps the adapter healthy, and codex_status_detects_out_of_band_contract_version_change pins that a contract version change (even with the snapshot stale) degrades. The "same-version managed-file change degrades" half is out of scope per the first point.

@ikunkun-sys
ikunkun-sys force-pushed the fix/2252-pycache-bundle-drift branch from 9d5ae09 to 2393d34 Compare August 6, 2026 13:10

@kongche-jbw kongche-jbw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review baseline: 4ec67cfef3377b32e6d8125d9533825901fdeffc...2393d34b9b5a9f965937b033e0adec52e264c181

[P1] Preserve an adapter-source revision for copied bundles

Location: src/anolisa/crates/anolisa-core/src/adapter/claim.rs:135

source_freshness treats equal component.version values as current and all drivers now stop
checking source contents. For copy-mode drivers, this is not covered by install-layer integrity:
cosh copies the bundle into its extension directory at enable. If an RPM release changes that
bundle without changing semantic component.version, rpm -V verifies the new package source
while the copied extension remains old; status still reports SourceVersionMatches=true and
Healthy.

Possible direction: persist a revision for immutable delivered adapter files (excluding runtime
outputs) or a package/source revision, and add a production-path test where same-version source
changes leave the copied destination stale.

[P1] Resolve the live version from the provenance-selected datadir

Location: src/anolisa/crates/anolisa-core/src/adapter/manager.rs:3558

current_contract_version searches datadir_roots in their original order. A supported system
layout orders the local datadir before the packaged datadir, and existing tests explicitly allow a
stale local contract alongside a snapshot whose provenance points at the package contract. In that
layout, a package update from 0.6 to 0.7 still returns the stale local 0.6; a 0.6 receipt stays
True and healthy. The new regression test uses a single datadir, so it does not cover this case.

Possible direction: prioritize the datadir selected by snapshot provenance/contract origin at both
enable and status, then add the two-root out-of-band update case.

@ikunkun-sys
ikunkun-sys force-pushed the fix/2252-pycache-bundle-drift branch from 2393d34 to dc97c93 Compare August 6, 2026 13:35
@ikunkun-sys

ikunkun-sys commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator Author

@kongche-jbw Responding to the two P1s from the second review round:

[P1] Resolve the live version from the provenance-selected datadir — valid, fixed in 36935e8. current_contract_version now searches the datadir roots through prioritize_datadir_root with the root named by the snapshot's provenance sidecar (the same ordering resolve_resource_root already uses), so a stale leftover contract in the earlier-ordered local datadir can no longer mask the packaged contract the component installs from. Regression test codex_out_of_band_change_detected_behind_stale_local_contract builds exactly the two-root layout you describe — local and packaged both at 0.6.0, provenance pointing at the packaged root, out-of-band bump of only the packaged contract to 0.7.0 — and I verified it fails without the prioritization and passes with it.

[P1] Preserve an adapter-source revision for copied bundles — the gap is real; I'd like to scope it as a follow-up rather than fold it into this PR. You're right that my earlier install-layer argument doesn't cover the copy-mode case: after a same-version package re-release, rpm -V verifies the new source files while the copied extension keeps running the old ones, and nothing flags it — and the old source digest did catch this (for copy-mode drivers the digested tree is never executed, so it wasn't even exposed to the #2252 false positive). That sensitivity is traded away here, and I won't pretend otherwise.

The reason I'd still keep it out of this PR: a sound replacement needs per-file vocabulary, not a bolt-on. Re-digesting the source only for copy-mode drivers forks staleness semantics per driver, and any whole-tree digest that later gets reused for a link-mode driver resurrects #2252. The design that covers it properly is a manifest-aware revision of the declared immutable delivered files (runtime outputs excluded by declaration, in the spirit of RPM's %ghost), which also enables the strictly stronger check for copy-mode: verifying the copy against the source (subset semantics — delivered files must match, runtime extras in the copy ignored), something the old enable-time seal never did either. That's a schema and manifest change worth its own review. If that works for you I'll open a tracking issue with this analysis; meanwhile this PR keeps the regression surface at: same-version re-release + copy-mode + out-of-band, with anolisa-mediated updates and version-bumping releases fully covered.

@ikunkun-sys

Copy link
Copy Markdown
Collaborator Author

Follow-up filed as #2279 (adapter source revision for copy-mode adapters, with the copy-vs-source subset check as the proposed direction). This PR stays scoped to the #2252 false positive and version-based staleness; the copy-mode same-version gap is tracked there with acceptance tests spelled out.

@kongche-jbw kongche-jbw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review baseline: 1110be279bbc0dadfcd36a796f3a251070ced4cd...dc97c93210d147e87cc816008001690e8ab5e862

[P1] Preserve source identity when the version is unchanged

src/anolisa/crates/anolisa-core/src/adapter/claim.rs:135 treats matching version strings as
current after all drivers stop comparing delivered contents. For Cosh,
src/anolisa/crates/anolisa-core/src/adapter/cosh.rs:239 copies the bundle at enable, while status
only checks marker and manifest presence. A same-version RPM or raw re-release can therefore update
the source while the framework keeps executing old copied files, yet SourceVersionMatches=true
and the adapter stays healthy. This also drops the original issue's requirement that managed source
and manifest changes remain detectable.

Possible direction: persist a revision or digest for declared immutable adapter inputs and compare
the copy against the source, excluding declared runtime outputs. Add a production-path regression
where __pycache__ stays healthy but a same-version managed-file change degrades.

@ikunkun-sys

Copy link
Copy Markdown
Collaborator Author

@kongche-jbw This round's P1 is the same copy-mode gap as the previous round — it is now tracked in #2279 (filed just before this review landed), with exactly the direction proposed here: a revision over declared immutable adapter inputs, the copy-vs-source comparison excluding declared runtime outputs, and the paired regressions (__pycache__ stays healthy / same-version managed-file change degrades) written down as acceptance criteria.

On the new point about #2252's own requirement ("真正的源码、manifest 或受管理资源变化仍应被检测"): what stays detectable after this PR — every ANOLISA-mediated update (the version moves, so both adapter status and the post-update actions flag the receipt), out-of-band package upgrades whose contract version moves (delivery-contract + provenance-prioritized resolution, added in this PR's review rounds), and missing/dangling framework-side state via the per-driver conditions. What moves to #2279 — content changes shipped without a version change. On link-mode drivers those never leave stale code running (the resource root is executed in place); on copy-mode drivers they do, which is precisely the scope #2279 carries. Folding a tree digest back in for only some drivers inside this PR is the path by which #2252's false positive returns, so the per-file design gets its own review there.

@kongche-jbw kongche-jbw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review baseline: 1110be279bbc0dadfcd36a796f3a251070ced4cd...dc97c93210d147e87cc816008001690e8ab5e862

[P1] Preserve source identity for copied adapters

At src/anolisa/crates/anolisa-core/src/adapter/claim.rs:135, equal
component.version values are current after all drivers stop checking source contents.
Cosh copies the bundle only during enable at
src/anolisa/crates/anolisa-core/src/adapter/cosh.rs:239. A same-version RPM/raw
re-release can therefore replace the source while the framework continues running its old copy,
yet status remains healthy. Filing #2279 documents the regression but does not satisfy #2252's
requirement that managed source changes remain detectable in this change.

Possible direction: retain a revision for declared immutable inputs on copy-mode adapters and
compare source with the installed copy, excluding declared runtime outputs. Add a regression where
__pycache__ stays healthy while a same-version managed-file change degrades.

kongche-jbw commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator

Accepted. I’m okay treating the copy-mode same-version source-revision gap as a non-blocking follow-up in #2279 for this PR. The issue captures the regression surface and the paired acceptance tests clearly. This acceptance is scoped to that gap.

One P1 remains in the new live-version path. At src/anolisa/crates/anolisa-core/src/adapter/manager.rs:3568, resolve_component_contract(...).ok() collapses every live datadir ContractError into the snapshot fallback, including parse and I/O errors. With a valid 0.6 snapshot/receipt and a malformed or unreadable live delivery contract, current_contract_version returns 0.6 and status can report SourceVersionMatches=true / Healthy instead of an unknown or degraded source state.

The new two-root test covers a valid provenance sidecar. Pre-provenance snapshots remain ambiguous: content matching can select a stale local 0.6 contract before the packaged root, so an out-of-band packaged update to 0.7 is still masked. I consider these part of #2276’s new live-version detection and would address them here rather than under the copy-mode follow-up.

@ikunkun-sys
ikunkun-sys force-pushed the fix/2252-pycache-bundle-drift branch from dc97c93 to 36935e8 Compare August 6, 2026 14:27
@ikunkun-sys

Copy link
Copy Markdown
Collaborator Author

@kongche-jbw Implemented in 36935e8 — the copy-mode gap is now closed inside this PR, and with a stronger check than the pre-PR digest:

Mechanism. util::copy_divergence(source, copy) compares the currently delivered source tree against the installed copy with subset semantics: every file the delivery ships must have a byte-identical counterpart in the copy; extra files in the copy (runtime-derived state such as __pycache__, ownership markers) are structurally ignored — no exclusion lists, no schema or manifest changes. cosh's TreePresent condition is upgraded from presence checks to this content check (tri-state: divergence → False/degraded with the offending files named and a re-enable hint; unreadable source → Unknown, never a guessed verdict), and hermes gets the same condition for its copied plugin directory. This verifies the artifact the framework actually executes, so it catches both directions the old enable-time source digest could not fully cover: a copy lagging a same-version source re-release, and a tampered/edited copy (the old digest never looked at the copy at all).

Your two acceptance criteria, as tests:

  • cosh_status_degrades_when_copied_extension_diverges_from_source — a same-version managed-file change in the source degrades with the file named in the reason; re-enable reconciles back to Healthy; tampering the copied file degrades again.
  • cosh_status_ignores_runtime_outputs_in_the_executed_copy__pycache__ written into the executed copy stays Healthy.
  • Plus codex_status_stays_healthy_when_runtime_writes_into_resource_root pinning [anolisa] sec-core Hook 生成 __pycache__ 导致 bundle drift 误报 #2252's original link-mode shape (runtime writes into the in-place-executed root stay Healthy; link-mode staleness remains version-based, since there the executed tree is the delivery and content checks against it are exactly what caused [anolisa] sec-core Hook 生成 __pycache__ 导致 bundle drift 误报 #2252).

#2279 narrows to what genuinely needs new vocabulary: sealed revisions for declared immutable inputs, drivers whose copies live in framework-internal locations ANOLISA cannot address (claude_code/openclaw/qoder CLI installs), and hermes skill directories (status resolves no manifest data today).

Copy link
Copy Markdown
Collaborator

Re-reviewed 36935e8c. The new copy-vs-source check covers changed bytes while ignoring runtime-only files, and the PR's CI plus the local core test/fmt/clippy gates are green. I still see three blocking issues:

  1. [P1] Live contract failures are still masked. current_contract_version at manager.rs:3568 still calls resolve_component_contract(...).ok(), so a live datadir ParseError or I/O error falls back to the stale snapshot. I added a local regression assertion that corrupts the live RPM contract after enable; status still reports Healthy.

  2. [P1] The pre-provenance multi-datadir ambiguity is unchanged. Content matching can select a stale local 0.6 contract that is identical to the snapshot, prioritize that root, and hide a packaged contract that has moved to 0.7.

  3. [P1] The new Hermes check compares trees with different layouts. copy_bundle_excluding_skills intentionally omits skills/ from the plugin copy, but status calls copy_divergence(&ctx.resource_root, &plugin_dir) over the complete source root. A supported Hermes plugin bundle with embedded/declared skills—including the shape exercised by only_declared_skills_are_planned_and_claimed—will therefore become Degraded immediately after enable. The comparison needs to use the same filtered source domain as the copy operation, with separate handling for skill resources.

One non-blocking residual remains under the already accepted #2279 follow-up: subset semantics cannot detect a managed source file being deleted, because the stale file is then only an “extra” in the executed copy. A local regression assertion confirms this still reports Healthy. I am okay keeping that revision-identity case in #2279 as agreed, but the statement that the copy-mode gap is fully closed should be narrowed accordingly.

@kongche-jbw kongche-jbw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review baseline: e5a212f9ab3e115e860698e059209e1c275201fd...36935e8ce11c029699e179d1ffa2eaffe66b7f0e

[P1] Detect files removed from copied bundles

Location: src/anolisa/crates/anolisa-core/src/adapter/util.rs:25

copy_divergence walks only the current source and ignores every extra path in the executed
copy. If a same-version release removes hooks/old.py, the Cosh or Hermes copy keeps executing
that stale file, yet the comparison is empty and status can remain Healthy. This leaves a common
direction of the copy-mode staleness gap undetected.

Possible direction: retain the managed delivered-file set or an equivalent source revision while
excluding declared runtime outputs. Add a production-path regression that removes a delivered
file after enable and expects status to degrade.

[P1] Do not treat malformed live contracts as absent

Location: src/anolisa/crates/anolisa-core/src/adapter/manager.rs:3568

The .ok() collapses ParseError and Io together with an unavailable live contract. With a
0.6 receipt/snapshot and a malformed or unreadable live 0.7 delivery contract, this falls back to
the snapshot's 0.6 and can report SourceVersionMatches=True and Healthy. A broken live source
must not be accepted as proof that the recorded version is current.

Possible direction: fall back only for ContractError::Unavailable; surface parse/read failures
as an unknown source version. Add the malformed-live-contract status regression.

[P1] Avoid choosing an ambiguous pre-provenance contract

Location: src/anolisa/crates/anolisa-core/src/adapter/manager.rs:3567

For a snapshot without a provenance sidecar, content matching can select the first local 0.6
contract when both local and packaged roots initially contain 0.6. If only the packaged contract
later moves to 0.7, that stale local root remains preferred and the 0.6 receipt stays Healthy. The
new two-root regression always writes provenance, so pre-upgrade snapshots remain uncovered.

Possible direction: treat a pre-provenance multi-root match as ambiguous or derive authority from
the installed backend. Add the same two-root out-of-band update test without the sidecar.

[P1] Compare Hermes against the files it actually copies

Location: src/anolisa/crates/anolisa-core/src/adapter/hermes.rs:315

Hermes enable deliberately omits the source skills/ subtree from the plugin copy, but status
passes the entire resource root to copy_divergence. A valid plugin adapter with bundled skills
therefore reports those intentionally separate files as missing and degrades immediately after a
successful enable.

Possible direction: compare the same source projection used by copy_bundle_excluding_skills
and verify skill resources separately. Add an enable-to-status test for a Hermes plugin that also
declares a bundled skill.

@ikunkun-sys
ikunkun-sys force-pushed the fix/2252-pycache-bundle-drift branch from 36935e8 to d7995e3 Compare August 6, 2026 14:59
@ikunkun-sys ikunkun-sys changed the title fix(anolisa): detect adapter staleness by component version, not tree digest fix(anolisa): exclude Python bytecode caches from adapter bundle digests Aug 6, 2026
@ikunkun-sys

Copy link
Copy Markdown
Collaborator Author

Scope change (d7995e3): this PR has been reduced to the minimal mitigation for #2252 — bundle digests now skip __pycache__/ and *.pyc (the narrowly-scoped runtime-derived exclusion the issue itself lists as an acceptable direction). Digest semantics are otherwise untouched: manifest, hook sources, and all other managed files remain digested, so same-version content changes and tampering stay detectable exactly as before.

The version-staleness redesign previously on this branch (and the copy-vs-source verification it grew during review) is withdrawn from this PR and parked as the formal follow-up under #2279 — the review findings raised here (out-of-band contract resolution, copy-mode removal direction, multi-root provenance, hermes skills projection) are all recorded there and remain valid against that design. Earlier review threads on this PR refer to the withdrawn revisions.

Known residual of the stopgap, stated plainly: a planted .pyc is loadable yet undigested; #2279's per-file design is the answer to that class.

Copy link
Copy Markdown
Collaborator

Thanks for the scope reset. I confirmed that the previous version-staleness/copy-mode findings no longer apply to the current five-file diff and are now captured in #2279. One P1 remains, specific to the minimal digest change:

[P1] Preserve compatibility with receipts sealed by the old digest semantics

bundle_digest is stored only as sha256:<hex>, with no algorithm/version marker. Before this PR, a user who re-enabled an adapter while __pycache__ already existed recorded a digest that included those files. After upgrading ANOLISA to d7995e3d, the same unchanged tree is re-digested with bytecode excluded; AdapterClaim::bundle_match compares the two values directly and reports Changed, so status becomes Degraded.

I reproduced this through the production codex enable/status path by replacing the newly written receipt digest with the pre-PR digest of the same tree containing hooks/__pycache__/hook_config.cpython-311.pyc. No file changed afterward, but status returned Degraded.

Please version the new digest semantics (for example, a new digest prefix/field) and handle legacy receipts explicitly. A regression should cover a pre-PR receipt sealed while bytecode already exists, followed by status under the new implementation.

The explicitly accepted undigested-loadable-.pyc residual remains non-blocking under #2279; I found no other current-diff blockers.

@ikunkun-sys
ikunkun-sys force-pushed the fix/2252-pycache-bundle-drift branch from d7995e3 to ca3bb68 Compare August 6, 2026 15:18
@ikunkun-sys

Copy link
Copy Markdown
Collaborator Author

@kongche-jbw Valid catch — and the population it hits is exactly the users who re-enabled as the #2252 workaround (their receipts sealed the tree with bytecode included). Fixed in ca3bb68.

Mechanism: instead of versioning the digest format, the comparison accepts a match under either semantics — digest_matches(recorded, root) first digests with bytecode excluded (current sealing), then falls back to the pre-exclusion algorithm (all files). This is sound without a format marker: a SHA-256 digest is a binding commitment to one tree state, so a tree that reproduces the recorded value under some semantics is the sealed tree, and a genuinely changed tree reproduces it under neither — no false Matched is possible short of a hash collision. New receipts always seal with the new semantics; legacy receipts converge to it on the next re-enable. Every comparison site (AdapterClaim::bundle_match and all seven drivers' bundle conditions) goes through the shared helper, so no format needs parsing forever and no migration is required.

Regressions added:

  • digest_matches_accepts_receipts_sealed_with_pre_exclusion_semantics (unit): a bytecode-bearing tree matches its pre-exclusion seal and its new seal; a source edit fails both.
  • codex_status_accepts_receipts_sealed_with_pre_exclusion_digests (production enable/status path, per your repro): pycache present at enable, receipt digest rewritten to the pre-exclusion value of the same tree → status stays Healthy; a genuine file change still fails ResourceBundleMatches.

Also updated the PR description accordingly.

Copy link
Copy Markdown
Collaborator

The dual-semantics fallback fixes only the case where the legacy-sealed bytecode remains byte-for-byte unchanged. It still misses the normal #2252 path after upgrade.

A legacy receipt records source + cache A. If a later hook run regenerates only that .pyc to cache B:

  • the new digest excludes bytecode, so it cannot equal the old digest that included cache A;
  • the legacy digest includes cache B, so it cannot equal the old digest that included cache A.

digest_matches therefore returns false and status degrades, even though no managed source or manifest changed.

I added the missing step to codex_status_accepts_receipts_sealed_with_pre_exclusion_digests: after the first Healthy assertion, rewrite only hooks/__pycache__/hook_config.cpython-311.pyc, then expect Healthy. The production status path fails with left: Degraded, right: Healthy.

The current test checks only an unchanged legacy tree before modifying README, so it does not cover cache regeneration. Because the legacy digest cannot be transformed back into the old bytecode-excluded digest, accepting either current-tree digest does not provide ongoing compatibility. This still needs explicit semantic versioning plus migration/re-enable handling (or another honest legacy transition), and the regression must include cache A -> cache B.

@kongche-jbw kongche-jbw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review baseline: e5a212f9ab3e115e860698e059209e1c275201fd...ca3bb68ed68da4b17880b59de1ac9b2d91172947

[P1] Persist legacy seals under the new digest semantics

At src/anolisa/crates/anolisa-core/src/adapter/util.rs:59, a successful legacy fallback is
returned only as true, so the receipt remains sealed with the old all-files digest. A first
post-upgrade status can therefore be Healthy, but if a later hook adds another .pyc or
regenerates one, neither the bytecode-excluding digest nor the legacy digest matches and
ResourceBundleMatches becomes false. This preserves the #2252 false positive for legacy
receipts after their next runtime cache change.

Possible direction: distinguish a legacy match and persist the new bytecode-excluding seal during
the upgrade/status migration. Add a production-path regression for legacy receipt -> successful
status/migration -> new .pyc -> Healthy, while a managed source edit still degrades.

Link-mode adapters (qwencode, codex) execute the resource root in place,
so the first hook run writes hooks/__pycache__/*.pyc into the tree the
enable-time digest sealed and flips a healthy adapter to degraded. Skip
__pycache__/ directories and *.pyc files in every bundle digest — the
narrow runtime-derived exclusion alibaba#2252 itself proposes. Manifest, hook
sources, and all other managed files stay digested, so real tampering
and same-version changes remain detectable.

New seals carry an explicit semantics marker (sha256/2:), so future
verdicts are deterministic. Legacy seals (bytecode included, e.g. the
re-enable workaround population) match when the tree reproduces them
under either semantics; when their bytecode caches have churned the old
cache bytes are unrecoverable, so status reports Unknown with a
re-enable hint instead of a false degrade, and one re-enable reseals
with the marker. The broader staleness redesign is tracked in alibaba#2279.

Closes alibaba#2252

Signed-off-by: ikunkun-sys <jiawa.syx@alibaba-inc.com>
@ikunkun-sys
ikunkun-sys force-pushed the fix/2252-pycache-bundle-drift branch from ca3bb68 to 61aa45c Compare August 6, 2026 15:37
@ikunkun-sys

Copy link
Copy Markdown
Collaborator Author

@kongche-jbw Your cache A → B extension of the repro is correct — ca3bb68 only rescued byte-identical caches. Fixed in 61aa45c, taking your semantic-versioning direction with one deliberate deviation on the migration half:

What 61aa45c does.

  • New seals carry an explicit marker (sha256/2:<hex>). A marked seal always compares under exactly the semantics that wrote it, so every post-upgrade enable is deterministic under any amount of cache churn.
  • Legacy seals (bare sha256:): matched when the tree reproduces them under either semantics (unchanged trees — including the re-enable-workaround population — stay Healthy). When the tree currently carries no bytecode, both semantics hash the same files, so a mismatch is still decidably Changed — drift detection is retained wherever it is decidable.
  • Legacy seal + cache churn (your case): the seal committed to cache A, which no longer exists anywhere — churn and drift are mathematically indistinguishable from the recorded value alone. The verdict is a distinct LegacyUndecidable, surfaced as ResourceBundleMatches=Unknown with reason sealed by an earlier ANOLISA release and runtime caches changed since; re-enable to refresh the seal. Never a false Degraded; deliberately also not a fake Healthy, which would silently disable drift detection for exactly those receipts.

Why not persist the reseal during status. AdapterManager::status is contractually read-only and runs without the install lock; rewriting receipts from that path either races a concurrent enable or turns a query into a mutation. A load-time migration would put resource-tree IO into state loading. Both are architecture changes beyond this stopgap, so the reseal-on-verified-match migration is recorded in #2279 — meanwhile a single explicit adapter enable performs the same migration under the lock, and the Unknown reason says so verbatim.

Regressions now cover your exact flow: codex_status_accepts_receipts_sealed_with_pre_exclusion_digests — legacy seal → Healthy on the unchanged tree → cache A→B rewrite → not Degraded, condition Unknown with the re-enable hint → re-enable → cache churn again → Healthy → managed-file edit → Degraded. Unit test verify_seal_handles_legacy_receipts_and_cache_churn additionally pins the v2-marker determinism and the bytecode-free decidable case.

@kongche-jbw kongche-jbw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review baseline: 39a6460871a0089c9e746862ae8eb3e45a4fe5f4...61aa45c7aa3365252f0adab8e6192e8ef3e7c36a

[P1] Mark OpenClaw and Hermes seals with the v2 prefix

At src/anolisa/crates/anolisa-core/src/adapter/openclaw.rs:2602 and
src/anolisa/crates/anolisa-core/src/adapter/hermes.rs:823, the private digest
helpers now exclude bytecode but still serialize the seal as bare sha256:.
verify_seal interprets that prefix as a pre-upgrade, bytecode-including seal.
For a newly enabled OpenClaw or Hermes adapter whose resource tree contains
__pycache__, changing any managed source file makes neither recomputed digest
match, so the verifier returns LegacyUndecidable instead of Changed. The
bundle condition becomes Unknown and stale_enabled_claims emits no re-enable
action, masking real drift while claiming that a new receipt came from an
earlier release.

Possible direction: use the shared digest_tree or emit sha256/2: from both
private helpers. Add OpenClaw and Hermes production-path regressions that assert
the new receipt prefix and that managed-file drift stays False/Changed when
__pycache__ is present.

@Forrest-ly Forrest-ly left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

总体评价

PR 核心思路正确:在所有 bundle digest 路径排除 Python 字节码缓存,并通过语义版本化 seal(sha256/2:)确保旧收据的向后兼容。测试覆盖较为充分,但 OpenClaw 和 Hermes 的 enable 路径存在一个阻塞性 bug,导致新启用的适配器在有 __pycache__ 时立刻进入 Unknown 状态。

审查结论

request changes

详细意见

🔴 必须修改(阻塞合并)

  • [src/anolisa/crates/anolisa-core/src/adapter/openclaw.rs 私有 digest_tree(约第 2590 行)] enable 写入 bare sha256: 前缀,verify_seal 将其视为旧收据。OpenClaw 的私有 digest_tree 在排除字节码后仍然输出 sha256:{hex}(旧语义前缀),而 bundle_match_condition 已改用 super::util::verify_seal。这意味着:新 enable 时若资源树含 __pycache__,写入的是旧格式 seal;首次 status 调用时 verify_seal 走旧收据分支,当前 bytecode-excluded 摘要 ≠ 旧 bytecode-included 摘要,且树中有 __pycache__,因此返回 LegacyUndecidable,状态变为 Unknown 并提示 re-enable——即全新 enable 的适配器立即自降级。修复方向:将私有 digest_tree 改为调用 super::util::digest_tree(已携带 sha256/2: 前缀且排除字节码),或将输出格式改为 format!("{}{:x}", super::util::SEAL_PREFIX, hasher.finalize())。这与 kongche-jbw 在最新 review(commit 61aa45c)中指出的问题一致,本次审查予以确认并标注同一阻塞点。

  • [src/anolisa/crates/anolisa-core/src/adapter/hermes.rs 私有 digest_tree(约第 810 行)] 同上,Hermes 私有 digest_tree 也仍输出 bare sha256: 前缀。修复方式同 OpenClaw:使用共享的 super::util::digest_tree 或显式输出 SEAL_PREFIX。需要增加 OpenClaw 和 Hermes 的 enable-to-status 回归测试,断言新 seal 前缀为 sha256/2:__pycache__ 存在时 ResourceBundleMatches=True

🟡 建议修改(不阻塞但推荐)

  • [src/anolisa/crates/anolisa-core/src/adapter/util.rs:59] 遗留收据成功匹配后未自动迁移 sealkongche-jbw 先前指出;本次 commit 仍未处理)。legacy fallback 返回 SealVerdict::Matched 后,receipt 中的 bundle_digest 仍保留旧的 sha256: 格式。下次 hook 运行写入新 .pyc 后,LegacyUndecidable 就会出现,用户仍需手动 re-enable。建议在 verify_seal 中区分 legacy match 与 v2 match,或在调用方检测到 legacy match 时自动将 seal 升级写回 receipt,并补充 "legacy receipt → 首次成功 status → 新 .pyc 写入 → 仍 Healthy"的端到端回归测试。PR body 中将此问题 defer 到 #2279,接受程度视迭代周期而定。

  • [src/anolisa/crates/anolisa-core/src/adapter/util.rs:65verify_seal 未识别前缀分支)] 对于完全无法识别的 seal 前缀,当前直接返回 Some(SealVerdict::Changed),而非 None(Unknown)。这里 Changed 意味着"树被确定性地改变了",但实际上我们只是无法解析 seal 格式,无法做出确定性判断。可考虑改为返回 None 或新增 SealVerdict::UnrecognizedFormat,以区分"可证明改变"与"无法判断"。当前选择虽保守(不会假 Healthy),但会产生 false degraded。

🟢 值得肯定

  • is_python_bytecode 谓词提取为共享公共函数,所有 digest 路径(包括 digest_bundle_tree)统一调用,排除逻辑一致,无遗漏。
  • sha256/2: 语义版本化设计干净:新 seal 与旧 seal 在字节层面可区分,verify_seal 的两路处理逻辑清晰。
  • 测试覆盖了核心路径:digest_tree_ignores_python_bytecode_cachesverify_seal_handles_legacy_receipts_and_cache_churn、以及两个完整的 codex e2e 场景。遗留收据兼容路径有端到端覆盖,信心较高。
  • PR body 对已知残留风险(planted .pyc)和后续工作(#2279)有明确记录,不掩盖问题。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[anolisa] sec-core Hook 生成 __pycache__ 导致 bundle drift 误报

3 participants