fix(agents): gate owned-write publish on pre-append fingerprint (#86572)#86584
fix(agents): gate owned-write publish on pre-append fingerprint (#86572)#86584ubehera wants to merge 2 commits into
Conversation
|
Codex review: found issues before merge. Reviewed June 24, 2026, 3:07 PM ET / 19:07 UTC. Summary PR surface: Source +126, Tests +60. Total +186 across 5 files. Reproducibility: yes. source-level reproduction is high confidence. The PR proof exercises the message append interleave, and current main plus related reports show sibling runtime-owned session entry paths sharing the same released-lock fence invariant. Review metrics: 1 noteworthy metric.
Stored data model Root-cause cluster Members:
Proposal only: this assessment does not dispatch repair, suppress jobs, mutate sibling items, close, or merge anything. Merge readiness Overall follows the weaker of proof and patch quality, so missing proof can cap an otherwise strong patch. Rank-up moves:
Risk before merge
Maintainer options:
Next step before merge
Security Review findings
Review detailsBest possible solution: Rebase and integrate the trust gate through current owned session-file publication primitives for messages, compaction/custom entries, snapshot publication, and prompt-stream transcript writes, or explicitly defer this JSONL fence hardening to the SQLite migration. Do we have a high-confidence way to reproduce the issue? Yes, source-level reproduction is high confidence. The PR proof exercises the message append interleave, and current main plus related reports show sibling runtime-owned session entry paths sharing the same released-lock fence invariant. Is this the best way to solve the issue? No, not as submitted. The pre-append trust gate is a plausible interim fix, but the branch is stale and must be integrated with current main's broader owned session-file publication APIs or deliberately deferred. Full review comments:
Overall correctness: patch is incorrect AGENTS.md: found and applied where relevant. Codex review notes: model internal, reasoning high; reviewed against eac1d3349c29. Label changesLabel justifications:
Evidence reviewedPR surface: Source +126, Tests +60. Total +186 across 5 files. View PR surface stats
What I checked:
Likely related people:
What the crustacean ranks mean
Shiny media proof means a screenshot, video, or linked artifact directly shows the changed behavior. Runtime, network, CSP, and security claims still need visible diagnostics. How this review workflow works
|
|
@clawsweeper re-review PR body updated with harness output ( |
|
🦞🧹 I asked ClawSweeper to review this item again. Re-review progress:
|
|
ClawSweeper PR egg ✨ Hatched: 🌱 uncommon Gilded Proofling Hatch commandComment Hatchability rules:
Rarity: 🌱 uncommon. What is this egg doing here?
|
|
Heads-up: the The failing test is The same test, same line, same timeout fails on main CI run This PR touches Per AGENTS.md "If [a failing check is] unrelated on latest origin/main, say so with scoped proof" — flagging this so it doesn't become a maintainer-side speed bump. |
…gated) Replaces the prior refreshBeforeLock approach (91ba92c) per ClawSweeper review on PR openclaw#86584. The earlier design used a blanket refreshAfterOwnedSessionWrite() right before each external-hook lock acquisition; that approach could silently accept genuine external mutations because it advanced the fence without proving the new state was the lane's own write. New approach: route pi's _persist writes through the existing ownedSessionFileWrites machinery instead of blind fence refreshes. - Add EmbeddedAttemptSessionLockController.publishOwnedPostMessageWrite() (sync). Called from the onMessagePersisted callback right after pi's sessionManager.appendMessage -> _persist -> appendFileSync completes. Records the post-write fingerprint as an OWNED write in the process-shared ownedSessionFileWrites map so subsequent assertSessionFileFence calls accept the lane's own writes via the owned-write match path. - Trust-gate: publishOwnedPostMessageWrite only records the new state when the prior fenceFingerprint is in trustedSessionFileStates (set at releaseForPrompt time). External mutations that bypass the callback are never recorded as owned and continue to trip EmbeddedAttemptSessionTakeoverError correctly. - Revert installSessionExternalHookWriteLock's refreshBeforeLock param and the shared waitBeforeLock closure. Each installLockableFunction call goes back to waitBeforeLock: () => waitForSessionEventQueue(session) directly. - runEmbeddedAttempt's onMessagePersisted callback now calls sessionLockController.publishOwnedPostMessageWrite() instead of refreshAfterOwnedSessionWrite(). Tests (5 cases under "embedded attempt session lock lifecycle"): - "accepts pi-style writes published via publishOwnedPostMessageWrite before beforeToolCall fires" — positive case. - "trips takeover on a same-file external write that bypasses publishOwnedPostMessageWrite" — NEGATIVE case the bot explicitly required ("add a negative test for an external same-file append before a hook lock"). Verifies fail-closed invariant preserved. - "allows multiple prompt turns with pi-style writes published per turn" — multi-turn case, three iterations, no false takeover. - "does not classify a different session file's writes as owned by this controller" — cross-file isolation. - "does not hang when reacquireAfterPrompt rejects after a pi-style direct write" — abort/error path. 78 cases across 2 test files green; pnpm check:changed clean. Closes openclaw#86572.
|
Redesigned per ClawSweeper review on Change in approach:
Addresses bot guidance verbatim:
Proof: Three-scenario harness covers pre-fix bug, post-fix success, and the negative case. Verbatim output in the PR body. @clawsweeper re-review |
|
🦞🧹 I asked ClawSweeper to review this item again. Re-review progress:
|
|
CI status: This is a timing-sensitive flake in the cron-service batched-due-jobs path (uses a mutable All other checks pass on |
|
Force-pushed The bot's exact concern, addressed:
Fix shape:
Mixed-interleaving negative test added: it("trips takeover on a mixed external-then-pi append (publish refuses to launder external mutation)", async () => {
await controller.releaseForPrompt(); // F0 trusted
await fs.appendFile(sessionFile, '...external...', "utf8"); // F0 -> F1
const beforeWrite = readSessionFileFingerprintSync(sessionFile);// F1
await fs.appendFile(sessionFile, '...pi-after-external...'); // F1 -> F2
controller.publishOwnedPostMessageWrite(beforeWrite); // F1 NOT trusted: skip
await expect(session.agent.beforeToolCall()).rejects.toBeInstanceOf(
EmbeddedAttemptSessionTakeoverError, // fail closed
);
});Proof harness updated to a 4-scenario harness; Scenario D is the mixed-interleave fail-closed verification. All 4 PASS. Verbatim stdout in the PR body. 80 tests across the session-lock surface green (+1 from the new mixed-interleave test). 46 cases across the session-tool-result-guard surfaces also green — no regressions from the new @clawsweeper re-review |
|
🦞🧹 I asked ClawSweeper to review this item again. Re-review progress:
|
|
@clawsweeper re-review Force-pushed Added (
Harness Scenario C (pure external advance) is intentionally not duplicated — it is already covered by the existing Verification on the rebased branch (
No source behavior changed in this push — the diff vs the prior head is the rebase plus the three test cases. |
|
🦞🧹 I asked ClawSweeper to review this item again. Re-review progress:
|
|
Latest-head broad gate — Ran the current-head broad changed gate (the latest-head verification flagged in the last review) on node scripts/crabbox-wrapper.mjs run --provider local-container -- \
env CI=1 OPENCLAW_TEST_PROJECTS_PARALLEL=6 OPENCLAW_VITEST_MAX_WORKERS=1 \
OPENCLAW_VITEST_NO_OUTPUT_TIMEOUT_MS=900000 pnpm check:changedResult:
Test execution (separate from Proof note: this ran on a local-container Linux runner (Node 24, CI-matching), not the brokered runner. A maintainer can re-run the brokered broad gate for the canonical environment if desired. |
|
Timeline correction on the production corroboration attributed to me — my 30-event corpus is PRE-#87159 in our install, not "post-#87159 still firing." Thanks for carrying the production data. One clarification I should make before it weighs on the merge, because the current PR body reads our corpus as the class "continues firing in real installs after #87159 landed" / "post-#87159 code" — that isn't accurate for our install:
So our corpus documents the class while it was active pre-#87159; it isn't evidence that it persists after #87159. The "rate accelerating / spread widening" in my 05-28 comment was the tail of the pre-upgrade period and shouldn't be read as current. What our data can't do is corroborate that the same-lane path this PR targets is still firing post-#87159 — once the cross-lane boundary is closed, our production window can't isolate a same-lane-only race. So I'd weight this PR on its own source-level proof (the 4-scenario harness — A trips pre-fix, B clean post-fix, C/D fail-closed — independently demonstrates the same-lane race and the trust gate's fail-closed behavior), rather than on our corpus as a post-#87159 signal. The 30 anonymized session |
…t to publish path (openclaw#86572)
|
Thanks @htuyer121 — appreciate the careful correction, and you're right. I've updated the PR body accordingly: the 30-event corpus is now described as pre-#87159 (build 2026.5.26), and the "continues firing after #87159" framing is gone. The "Production context" section now leads with your correction — zero events over ~6 days post-#87159, and the point that a same-lane-only race can't be isolated from production once #87159 closes the cross-lane boundary — and explicitly weights the PR on its source-level proof rather than the corpus, per your recommendation. For clarity on what the PR now claims: defense-in-depth hardening of the same-lane publish boundary (fail-open unconditional fence refresh → fail-closed trust gate), not a fix for an actively-firing production class. The meaningful source-level scenario is the mixed-interleave case (an external mutation interleaved with the lane's own write being laundered into the fence), which the trust gate refuses to record. The offer of the anonymized |
|
@clawsweeper re-review One cleanup commit plus a PR-body correction since the last pass on 1. Removed dead This is upstream 2. Reworked the two tests that referenced it —
3. PR body corrected (no code impact) Removed the inaccurate "@htuyer121's corpus shows the class continues firing after #87159" framing per their correction (corpus is pre-#87159; zero events post-#87159). Reframed the headline as fail-open → fail-closed publish-boundary hardening, and added a Fix shape vs. #86572 note (publish boundary, not the proposed ALS-scope hoist) plus a Known limitation note (the narrow pre-append window, owned by #87159's file-ownership boundary). Verification on
Net −23 LOC (source −7, tests −16). No production behavior change vs the prior head — this commit removes a now-dead path that the fix had already superseded and refreshes its coverage onto the production seam. |
|
🦞🧹 I asked ClawSweeper to review this item again. Re-review progress:
|
|
Coverage note for the publish-boundary fix shape. A runtime-owned custom session entry can advance the same session file during the prompt-lock release window before the next message append. Observed ordering from a packaged [email protected] Docker gateway run:
If the owned-write publish path is applied only to sessionManager.appendMessage, the later user-message append captures a pre-write fingerprint that already includes the unpublished custom entry. The fail-closed trust gate then refuses to publish the message append, so the fence still trips. In this scenario, the publish-boundary coverage needs to include runtime-owned custom session entries as well as message entries, or those custom writes need an equivalent ownership/locking path so they cannot advance the session file unrecorded during the prompt-lock release window. |
|
This assigned pull request has been automatically marked as stale after being open for 27 days. |
|
Closing due to inactivity. |
|
Closing-state update (2026-07-18): this branch should remain closed rather than be rebased. Its message-only publish gate was incomplete against the later compaction/custom, snapshot, and prompt-stream ownership seams. Current If |
Summary
Converts the embedded session-takeover fence's post-write handling from a fail-open unconditional fence refresh into a fail-closed, trust-gated owned-write publish.
On current
main, after a lane's own message persists,onMessagePersisted → refreshAfterOwnedSessionWrite()unconditionally re-reads the session file and trusts whatever is on disk as the new fence baseline. If an external mutation interleaves betweenreleaseForPrompt(fence = F0) and the lane's own append (F1 → F2), that external mutation is silently laundered into the fence and the takeover detector never trips.This PR captures the file fingerprint immediately before the lane's append (
beforeMessagePersist) and records the post-write state as owned only when that pre-append fingerprint was trusted (matches the active fence, is a benign transcript-only advance, or is a previously-trusted state). Otherwise it refuses to record — so an interleaved external mutation can no longer be laundered, and the fence still trips.Closes #86572. Complementary to #87159: that PR closes the cross-lane file-ownership race; this PR closes the same-lane publish boundary. The two enforce orthogonal invariants.
Fix shape (vs. #86572's proposal)
#86572 proposed hoisting the
withOwnedSessionTranscriptWritesALS scope to spanagent.prompt(). This PR instead gates at the publish boundary (onMessagePersisted): pi's session writes already funnel through the guardedsessionManager.appendMessageseam, so the trust decision belongs in the controller that owns the fence, not in the ALS scope. This keeps the trust decision at the ownership boundary and avoids classifying unrelated listener writes as owned.Rebase note (force-pushed, head
43baac80c5)Rebased onto current
upstream/main, picking up:installSessionExternalHookWriteLock/installSessionEventWriteLock, moved hook locking into the controllerProduction context — corrected
So there is no current production signal for the specific same-lane path this PR hardens. Per the reporter's own recommendation, this PR should be weighed on its source-level proof, not on that corpus. This is defense-in-depth hardening of the publish boundary — not a fix for an actively-firing production class.
Real behavior proof
Behavior addressed: Converts the post-owned-write fence update from fail-open (unconditional refresh that can launder an interleaved external mutation into the fence) to fail-closed (record-as-owned only when the pre-append fingerprint was trusted). The behavioral delta versus current
mainis the mixed-interleave case (Scenario D):mainlaunders the external mutation into the fence; this PR refuses to, so the takeover still trips. Clean same-lane writes (Scenario B) behave identically tomain.Real environment tested: Source-level runtime harness on rebased branch
43baac80c5: macOS 26.5, Node 26.0.0, branched off currentupstream/main(post-Fix embedded session file ownership race #87159 + fix(agents): move session write lock into owned session runtime #87409); loads the patchedEmbeddedAttemptSessionLockControllerfrom source via dynamic import and drives four runtime scenarios against real temp session JSONL files viafs.appendFileandcontroller.withSessionWriteLock.Exact steps or command run after this patch:
git fetch origin && git checkout fix/session-takeover-refresh-before-hook-lock(confirm at43baac80c5), thennode --import tsx /Users/umank/code/openclaw-tickets/proof/run-refresh-before-hook-lock-proof.mjs /Users/umank/code/openclaw. Checked-in regression:node scripts/run-vitest.mjs src/agents/embedded-agent-runner/run/attempt.session-lock.test.ts.Evidence after fix: terminal capture from the rebased branch follows. Note: in the harness, Scenario A's "pre-fix" label means the controller is simply not informed of the write (neither
main'srefreshAfterOwnedSessionWritenor this PR'spublishOwnedPostMessageWriteis called) — it demonstrates the controller mechanics, not a literalmainreproduction. The behavioral delta overmainis Scenario D.Observed result after fix: Scenarios A/B/C demonstrate the gate mechanics; Scenario D is the behavioral improvement over
main—main's unconditional refresh would record the combined F2 (external + pi) state as the trusted fence and silently absorb the external mutation, whereas the trust gate sees the untrusted pre-append baseline (F1) and refuses to record, so the takeover fence still fires. The checked-in regression covers the publish / skip / mixed-interleave cases directly.What was not tested:
appendFileSync. A concurrent external writer appending benign-looking transcript JSONL inside that narrow window could still be absorbed. Preventing concurrent writers to the same session file is the responsibility of Fix embedded session file ownership race #87159's file-ownership boundary, not this publish-boundary gate.Compatibility / Risk
publishOwnedPostMessageWrite(beforeWrite)is an additive method onEmbeddedAttemptSessionLockController.beforeMessagePersistis an additive optional opt on the session guard. Backward compatible.Related
This PR is AI-assisted. Code authored with Claude.