fix(cron): refuse keyless implicit isolated cron delivery inherited from shared agent-main bucket#91685
Conversation
|
Codex review: passed. Reviewed June 19, 2026, 2:31 AM ET / 06:31 UTC. Summary PR surface: Source +57, Tests +496. Total +553 across 8 files. Reproducibility: yes. from source inspection: current resolver can inherit the shared agent-main last target for keyless implicit cron delivery, and dispatch hard-fails or sends based on that resolved target; I did not run live Matrix reproduction in this read-only review. Review metrics: 2 noteworthy metrics.
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. Risk before merge
Maintainer options:
Next step before merge
Security Review detailsBest possible solution: Land this only if maintainers intentionally accept fail-closed keyless implicit cron delivery, while keeping #91613 open for the broader restart replay hardening. Do we have a high-confidence way to reproduce the issue? Yes from source inspection: current resolver can inherit the shared agent-main last target for keyless implicit cron delivery, and dispatch hard-fails or sends based on that resolved target; I did not run live Matrix reproduction in this read-only review. Is this the best way to solve the issue? Mostly yes: the patch is the narrowest resolver-level guard for the demonstrated keyless inherited path, but the broader #91613 restart queue problem still needs maintainer-owned follow-up. AGENTS.md: found and applied where relevant. Codex review notes: model internal, reasoning high; reviewed against 5d6ac2308657. Label changesLabel changes:
Label justifications:
Evidence reviewedPR surface: Source +57, Tests +496. Total +553 across 8 files. View PR surface stats
Acceptance criteria:
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
|
…rom shared agent-main bucket A keyless/implicit isolated cron (no sessionKey/sessionTarget, delivery.channel=last, no delivery.to) resolved its target from the SHARED agent:<id>:main bucket's lastTo, a last-writer-wins value across every conversation the agent handles. Its reply drained to whichever room last wrote main (the wrong room), and the durable delivery queue replayed it there after a restart. resolveDeliveryTarget now returns ok:false for the narrowed keyless-inherited case (keyless only; compared after the allowFrom reroute), so the delivery dispatch !ok gate, the failure-notification path, and the delivery preview all honor the refusal through one canonical path instead of a flag each caller must remember to check. The dispatch gate refuses the send without reaching the durable enqueue, so recovery has nothing to replay. resolveCronDeliveryContext resolves the job's own session identity (sessionTarget over sessionKey, matching delivery preview and the failure-notification path) so a session-scoped cron is not misread as keyless. Adds resolver refusal plus negative (allowFrom-rerouted / keyed / channel-only) tests and an integration test proving the keyless case is refused with no main-session awareness leak. This refuses a keyless cron that previously delivered to main's last room even in a single-conversation deployment; such crons must now pin delivery.channel/delivery.to or run from a session that carries its own delivery context. The agent turn still runs and its reply is then dropped with a logged delivery-target error (consistent with how other unresolvable cron targets are handled today). Surfaced for the needs-product-decision.
83dfb00 to
88b6412
Compare
… is refused The keyless-implicit refusal path (resolveDeliveryTarget -> ok:false) returns from dispatchCronDelivery before reaching the direct/silent delivery paths that retire a deleteAfterRun cron session, so a one-shot cron hitting the fail-closed branch left its session and transcript behind. Call cleanupDirectCronSessionIfNeeded() before both returns in the !ok branch; it is a no-op for non-deleteAfterRun and non-cron sessions. Add regression tests covering the non-best-effort (status error) and best-effort (status ok) refusal paths.
8ee9b65 to
44214d2
Compare
…cron-delivery-identity # Conflicts: # src/cron/isolated-agent/delivery-target.test.ts
|
/clownfish automerge |
|
Clownfish is on the reef for this PR. 🐠 I tagged A maintainer can call |
|
🦞🧹
Draft PRs stay fix-only until GitHub marks them ready for review. Pause with Automerge progress:
|
…rom shared agent-main bucket (openclaw#91685) Summary: - The PR changes isolated cron delivery resolution to reject keyless implicit delivery inherited from the shar ... targets into delivery context resolution, and cleans up direct cron sessions on unresolved delivery exits. - PR surface: Source +57, Tests +496. Total +553 across 8 files. - Reproducibility: yes. from source inspection: current resolver can inherit the shared agent-main last target ... ls or sends based on that resolved target; I did not run live Matrix reproduction in this read-only review. Automerge notes: - PR branch already contained follow-up commit before automerge: fix(cron): clean up deleteAfterRun session when keyless cron delivery… - PR branch already contained follow-up commit before automerge: Merge remote-tracking branch 'upstream/main' into fix/91613-isolated-… - PR branch already contained follow-up commit before automerge: Merge upstream main into fix/91613-isolated-cron-delivery-identity - PR branch already contained follow-up commit before automerge: chore: retrigger PR CI after upstream base fix Validation: - ClawSweeper review passed for head f129375. - Required merge gates passed before the squash merge. Prepared head SHA: f129375 Review: openclaw#91685 (comment) Co-authored-by: nxmxbbd <32288+nxmxbbd@users.noreply.github.com>
…ion id Isolated cron runs carry a per-run :run:<id> session scope (openclaw#91685) rendered verbatim into the cached system-prompt Runtime line, re-busting byte-exact prefix caching for the tool catalog after it every run (openclaw#96677, openclaw#43148 class). buildRuntimeLine now renders the stable base session key and drops the per-run id the run scope duplicates; parseCronRunScopeSuffix is gated to the isolated-cron key shape so a :run: segment in any other session key is never truncated.
…enclaw#96677) Isolated cron and heartbeat runs include a per-run UUID in the session key (e.g. :run:e3f4a5b6-...). Since buildRuntimeLine embeds the full sessionKey verbatim into the cached system prefix, every invocation produces a different Runtime line — breaking prompt prefix caching for all content after it, including the tool catalog. Strip :run:<uuid> segments from the session key at render time so the Runtime line is byte-stable across cron/heartbeat invocations. This restores prefix caching and avoids ~10x cost increase on metered providers. Regression: openclaw#91685 added per-run session key isolation which correctly prevents cross-run delivery leakage, but rendered the sessionKey into the cached prompt without cache-aware stripping. Fixes openclaw#96677. Co-Authored-By: Claude <noreply@anthropic.com>
…enclaw#96677) Isolated cron and heartbeat runs include a per-run UUID in the session key (e.g. :run:e3f4a5b6-...). Since buildRuntimeLine embeds the full sessionKey verbatim into the cached system prefix, every invocation produces a different Runtime line — breaking prompt prefix caching for all content after it, including the tool catalog. Strip :run:<uuid> segments from the session key at render time so the Runtime line is byte-stable across cron/heartbeat invocations. This restores prefix caching and avoids ~10x cost increase on metered providers. Regression: openclaw#91685 added per-run session key isolation which correctly prevents cross-run delivery leakage, but rendered the sessionKey into the cached prompt without cache-aware stripping. Fixes openclaw#96677. Co-Authored-By: Claude <noreply@anthropic.com>
…ion id Isolated cron runs carry a per-run :run:<id> session scope (openclaw#91685) rendered verbatim into the cached system-prompt Runtime line, re-busting byte-exact prefix caching for the tool catalog after it every run (openclaw#96677, openclaw#43148 class). buildRuntimeLine now renders the stable base session key and drops the per-run id the run scope duplicates; parseCronRunScopeSuffix is gated to the isolated-cron key shape so a :run: segment in any other session key is never truncated.
…ion id (#96686) * fix(agents): isolated cron busts prompt prefix cache via per-run session id Isolated cron runs carry a per-run :run:<id> session scope (#91685) rendered verbatim into the cached system-prompt Runtime line, re-busting byte-exact prefix caching for the tool catalog after it every run (#96677, #43148 class). buildRuntimeLine now renders the stable base session key and drops the per-run id the run scope duplicates; parseCronRunScopeSuffix is gated to the isolated-cron key shape so a :run: segment in any other session key is never truncated. * fix(agents): preserve mixed-case cron run markers * test(agents): cover rotated cron session identity --------- Co-authored-by: Peter Steinberger <steipete@gmail.com>
…ion id (openclaw#96686) * fix(agents): isolated cron busts prompt prefix cache via per-run session id Isolated cron runs carry a per-run :run:<id> session scope (openclaw#91685) rendered verbatim into the cached system-prompt Runtime line, re-busting byte-exact prefix caching for the tool catalog after it every run (openclaw#96677, openclaw#43148 class). buildRuntimeLine now renders the stable base session key and drops the per-run id the run scope duplicates; parseCronRunScopeSuffix is gated to the isolated-cron key shape so a :run: segment in any other session key is never truncated. * fix(agents): preserve mixed-case cron run markers * test(agents): cover rotated cron session identity --------- Co-authored-by: Peter Steinberger <steipete@gmail.com>
…ion id (openclaw#96686) * fix(agents): isolated cron busts prompt prefix cache via per-run session id Isolated cron runs carry a per-run :run:<id> session scope (openclaw#91685) rendered verbatim into the cached system-prompt Runtime line, re-busting byte-exact prefix caching for the tool catalog after it every run (openclaw#96677, openclaw#43148 class). buildRuntimeLine now renders the stable base session key and drops the per-run id the run scope duplicates; parseCronRunScopeSuffix is gated to the isolated-cron key shape so a :run: segment in any other session key is never truncated. * fix(agents): preserve mixed-case cron run markers * test(agents): cover rotated cron session identity --------- Co-authored-by: Peter Steinberger <steipete@gmail.com>
Summary
A keyless / implicit isolated cron — no
sessionKey, nosessionTarget,delivery.channel=last, nodelivery.to— resolves its delivery target from the sharedagent:<id>:mainsession bucket'slastTo. That value is last-writer-wins across every conversation the agent handles, so the cron's reply is delivered to whichever room last wrotemain, and the durable delivery queue replays it there after a restart (#91613).This change makes
resolveDeliveryTargetreturnok:falsefor that case instead of returning the inherited target.Root cause
resolveDeliveryTarget(src/cron/isolated-agent/delivery-target.ts) finds no per-job target and no own session/delivery context, and falls back to the sharedagent:<id>:mainentry, returning that bucket'slastTo. The condition ismain === mainEntrywith no stored or thread context (usedSharedMainFallback).recoverPendingDeliveries) replays a stored entry'stoverbatim with no staleness, boot-id, or room gate, so the resolved target survives a restart.toit is given; the divergence is at resolution.A keyless cron carries no turn-source and no persisted creator-session, so there is no provenance to anchor the inherited target against.
Change
resolveDeliveryTargetreturnsok:falsefor the keyless-inherited case. Its four consumers — delivery dispatch, the failure-notification path (src/cron/delivery.ts), the run delivery context, and the delivery preview — already branch onok:false, so each refuses uniformly. The dispatch!okgate refuses the send before the durable enqueue, so recovery has no entry to replay.Predicate, evaluated after the
allowFromreroute:resolveCronDeliveryContextresolves the job's session identity throughresolveCronDeliverySessionKey(sessionTarget then sessionKey), matching the existing delivery-preview and failure-notification callers, so asessionTarget-scoped cron resolves through its own session rather than the keyless path.Prior art in this repo
turnToMatchesSession, and fix(reply): preserve sessions_send external routes #88803 preservedsessions_sendroutes via inter-session provenance. A timer-fired keyless cron has no inbound turn and no persisted creator-session to match against, so this change fails closed on the ambiguity instead of matching.resolveDeliveryTargetalready returnsok:falsefor other unresolvable targets; this adds one case to that existing contract rather than a new field or mechanism.requesterSessionKey/requesterOrigin. A keyless cron stores no equivalent, so the same match-on-provenance approach is not available without adding that field and a migration; this change does not add them.!okbranch so an implicit announce-to-lastcron with no resolvable channel finishes as a silent success, while keeping a hard failure when a channel resolved but its target did not. The keyless-inherited refusal added here fires only after a channel has resolved, so it always returnsok:falsewith the channel populated and only the target withheld — it falls into that branch's channel-resolved-but-failed case and stays a hard failure under either merge order. The separate no-resolvable-channel case (a differentok:falsereturn inresolveDeliveryTarget, channelundefined) is not reached by this predicate and is the silent path fix(cron): keep no-channel implicit cron runs successful instead of failing delivery (#56078) #91399 governs.Behavior change
A keyless cron that previously delivered to the shared bucket's last room is now refused, including in a single-conversation deployment where that room was the intended target. Such a cron must set
delivery.channelanddelivery.to, or run from a session that carries its own delivery context; the error names the cause. A channel-only keyless cron ({channel, to:undefined}) whosetostill resolves from the bucket is also refused. A keyed cron whose thread entry is missing is unaffected — the predicate is keyless-only.Real behavior proof
server_name, loopback bind only, federation disabled). Two rooms: A (intended) and B (wrong). The sharedagent-mainbucket'slastTois set to room B.resolveDeliveryTargetfor a keyless implicit cron against the poisoned bucket; enqueue only if it returnsok:true; runrecoverPendingDeliveriesover the on-disk queue against the live homeserver; read both rooms over the Matrix client-server API. (2) failure notice — drive the patchedsendFailureNotificationAnnounce(src/cron/delivery.ts) for the same keyless target with the leaf send primitive (sendDurableMessageBatch) wired to perform a real send to whatever room it is handed; read room B over the client-server API.Before this change, the same harness drained a queued reply to room B: a per-run marker appeared in room B and never in room A.
The send primitive was wired to perform a real send to whatever room it received, so a send would have reached room B; it was never called.
!okgate; no message reaches the wrong room and no durable entry is written. The failure notice for the same cron resolvesok:falseand is logged rather than delivered, so the wrong room receives neither the reply nor the failure notice.Regression tests
ok:false; explicitto, keyed cron, andallowFrom-rerouted-to-allowed-peer still deliver; channel-only-keyless → refused.resolveCronDeliveryContextpassesok:falsethrough and resolvessessionTargetinto the resolver's session key.error(errorKind: delivery-target), does not deliver, and queues no main-session awareness event.ok:false; the awareness-suppression path they also exercised remains covered by an existing dispatch test.ok:false) keyless cron withdeleteAfterRunretires its session viasessions.deletebefore returning, in both the non-best-effort (statuserror) and best-effort (statusok) paths, so a one-shot cron does not leak its session or transcript on the fail-closed path.src/cronsuite after this conflict repair: 106 files / 1075 tests pass.Security and compatibility
ok:falseresolver result variant already existed.Notes
AI-assisted; the contributor reviewed the change and is responsible for it.