Skip to content

fix(cron): preserve deferred heartbeat target override#69021

Merged
obviyus merged 8 commits intoopenclaw:mainfrom
obviyus:fix/cron-deferred-heartbeat-target
Apr 19, 2026
Merged

fix(cron): preserve deferred heartbeat target override#69021
obviyus merged 8 commits intoopenclaw:mainfrom
obviyus:fix/cron-deferred-heartbeat-target

Conversation

@obviyus
Copy link
Copy Markdown
Contributor

@obviyus obviyus commented Apr 19, 2026

Keep the main-session cron heartbeat override when a wake is queued or retried instead of dropping back to the default heartbeat target.

Adds regressions for deferred main-session cron wakes and the wake-queue retry path.

@openclaw-barnacle openclaw-barnacle Bot added size: S maintainer Maintainer-authored PR labels Apr 19, 2026
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Apr 19, 2026

Greptile Summary

This PR fixes a bug where the heartbeat: { target: "last" } override set by main-session cron jobs was dropped when the wake was deferred or retried. The fix threads pendingWake.heartbeat through retry re-queues in heartbeat-wake.ts, explicitly forwards params.heartbeat in the wakeHandler in heartbeat-runner.ts, and adds the missing heartbeat: { target: "last" } arg to the requestHeartbeatNow fallback calls in timer.ts. New regression tests cover all three affected paths.

Confidence Score: 5/5

Safe to merge — the fix is minimal, well-scoped, and covered by targeted regression tests.

No P0 or P1 issues found. The retry preservation logic in heartbeat-wake.ts is correct: both the requests-in-flight path (lines 176–181) and the error-catch path (lines 187–193) now forward heartbeat to queuePendingWakeReason. The wakeHandler forwarding in heartbeat-runner.ts and the two requestHeartbeatNow callsites in timer.ts are all consistent. Test coverage directly exercises every changed path.

No files require special attention.

Reviews (1): Last reviewed commit: "fix(cron): preserve deferred heartbeat t..." | Re-trigger Greptile

@obviyus obviyus self-assigned this Apr 19, 2026
Copy link
Copy Markdown

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

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: 01d908a15a

ℹ️ 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/infra/heartbeat-runner.ts Outdated
Copy link
Copy Markdown

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

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: 5a3255a816

ℹ️ 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/cron/service/timer.ts
Comment thread src/infra/heartbeat-wake.ts
@openclaw-barnacle openclaw-barnacle Bot added gateway Gateway runtime size: M and removed size: S labels Apr 19, 2026
@aisle-research-bot
Copy link
Copy Markdown

aisle-research-bot Bot commented Apr 19, 2026

🔒 Aisle Security Analysis

We found 3 potential security issue(s) in this PR:

# Severity Title
1 🟡 Medium Heartbeat delivery target override can leak across coalesced wakes ("last" target persists)
2 🟡 Medium Prototype pollution via merging untrusted heartbeat override object
3 🟡 Medium Cron-triggered heartbeat forces delivery to session's "last" route (potential cross-user data exposure)
1. 🟡 Heartbeat delivery target override can leak across coalesced wakes ("last" target persists)
Property Value
Severity Medium
CWE CWE-200
Location src/infra/heartbeat-wake.ts:117-127

Description

queuePendingWakeReason() coalesces wake requests by a key derived only from agentId and sessionKey. When multiple wakes for the same key are merged, the code carries forward a previous heartbeat override if the newer wake omits heartbeat.

This can unintentionally apply heartbeat.target: "last" (or any other override) to a later wake of a different reason (e.g., interval/retry), causing heartbeats to be delivered to the last conversation/channel instead of the configured destination.

Impact:

  • A wake that was intended to target a safe/expected destination can inherit target:"last" from an earlier wake.
  • This can result in unexpected outbound message delivery to a prior chat/recipient, which is an information disclosure risk.

Vulnerable code:

const merged =
  (next.heartbeat ?? previous.heartbeat)
    ? { ...next, heartbeat: next.heartbeat ?? previous.heartbeat }
    : next;

The coalescing key does not include heartbeat fields, so an override can persist across different wake reasons under the same agentId::sessionKey key.

Recommendation

Ensure heartbeat overrides do not unintentionally persist across coalesced wakes.

Options:

  1. Scope the coalescing key to heartbeat override (recommended if overrides must not mix):
function getWakeTargetKey(params: { agentId?: string; sessionKey?: string; heartbeatTarget?: string }) {
  const agentId = normalizeWakeTarget(params.agentId);
  const sessionKey = normalizeWakeTarget(params.sessionKey);
  const hbTarget = normalizeOptionalString(params.heartbeatTarget) ?? "";
  return `${agentId ?? ""}::${sessionKey ?? ""}::${hbTarget}`;
}
  1. Do not inherit previous.heartbeat when the new wake omits it (treat omitted as “no override”):
// keep next.heartbeat as-is; do not fall back to previous
pendingWakes.set(wakeTargetKey, next);
  1. If inheritance is desired only for retries of the same wake, restrict merging to retry paths (e.g., when reason is retry), or require explicit opt-in.

Also consider normalizing/validating heartbeat.target (allowlist) at intake to prevent unexpected target values from being carried around.

2. 🟡 Prototype pollution via merging untrusted heartbeat override object
Property Value
Severity Medium
CWE CWE-1321
Location src/infra/heartbeat-runner.ts:1413-1415

Description

The heartbeat wake path merges a caller-provided heartbeat object into an existing config using object spread.

  • params.heartbeat / opts.heartbeat can be influenced by external callers (cron/gateway/runtime callers).
  • Using { ...base, ...override } allows special keys like __proto__, constructor, or prototype to mutate the resulting object's prototype.
  • This is a classic prototype-pollution sink in JavaScript/TypeScript and can lead to unpredictable behavior (property lookups affected across the app) and potential security impacts depending on subsequent object usage.

Vulnerable code:

const resolveRequestedHeartbeat = (heartbeat?: HeartbeatConfig) =>
  requestedHeartbeat ? { ...heartbeat, ...requestedHeartbeat } : heartbeat;

Recommendation

Harden merges of externally-provided objects:

  1. Allowlist expected keys and ignore everything else.
  2. Reject dangerous keys (__proto__, prototype, constructor) before merging.

Example:

function sanitizeHeartbeatOverride(input: unknown): { target?: string } | undefined {
  if (!input || typeof input !== "object") return undefined;
  const rec = input as Record<string, unknown>;// block prototype-pollution keys explicitly
  if ("__proto__" in rec || "constructor" in rec || "prototype" in rec) return undefined;
  const out: { target?: string } = {};
  if (typeof rec.target === "string") out.target = rec.target;
  return out;
}

const safeOverride = sanitizeHeartbeatOverride(params?.heartbeat);
const resolveRequestedHeartbeat = (heartbeat?: HeartbeatConfig) =>
  safeOverride ? { ...heartbeat, ...safeOverride } : heartbeat;

Apply the same pattern anywhere heartbeat overrides are merged (e.g., the gateway cron adapter).

3. 🟡 Cron-triggered heartbeat forces delivery to session's "last" route (potential cross-user data exposure)
Property Value
Severity Medium
CWE CWE-200
Location src/cron/service/timer.ts:1206-1262

Description

Main-session cron jobs now always wake/execute heartbeats with an override heartbeat: { target: "last" }.

This is risky because heartbeat.target="last" resolves delivery using the session entry’s stored lastChannel/lastTo (see resolveHeartbeatDeliveryTargetresolveSessionDeliveryTarget), meaning the heartbeat output is sent to whoever last interacted with that shared session.

If the agent’s main session is shared by multiple users/chats (common for a single agent main session), a cron-triggered heartbeat can be delivered to an unintended recipient (the most recent user), leaking:

  • cron/heartbeat-produced content
  • any workspace/agent state surfaced by the heartbeat prompt
  • system-event summaries consumed during the heartbeat run

Vulnerable code (new behavior):

heartbeatResult = await state.deps.runHeartbeatOnce({
  reason,
  agentId: job.agentId,
  sessionKey: targetMainSessionKey,
  heartbeat: { target: "last" },
});
...
state.deps.requestHeartbeatNow({
  reason,
  agentId: job.agentId,
  sessionKey: targetMainSessionKey,
  heartbeat: { target: "last" },
});

Because this override is unconditional for main-session cron jobs, it can also bypass an agent configuration that intentionally sets heartbeat.target: "none" to prevent outbound heartbeat delivery.

Recommendation

Avoid forcing heartbeat.target="last" for cron-triggered heartbeats unless you can guarantee correct recipient scoping.

Options:

  1. Respect agent config and only set the override when explicitly configured for that job:
const heartbeatOverride = job.heartbeatTargetOverride
  ? { target: job.heartbeatTargetOverride }
  : undefined;

state.deps.requestHeartbeatNow({ reason, agentId: job.agentId, sessionKey: targetMainSessionKey, heartbeat: heartbeatOverride });
  1. Require an explicit, unambiguous delivery context (channel/to/accountId) bound to the cron job, rather than using session "last".

  2. If "last" is required, ensure the session model is per-user/per-sender for cron delivery (separate session keys per recipient), and validate the sessionKey belongs to the intended recipient before sending.


Analyzed PR: #69021 at commit 8894b22

Last updated on: 2026-04-19T17:22:07Z

@obviyus obviyus merged commit 1d4e431 into openclaw:main Apr 19, 2026
30 checks passed
@obviyus
Copy link
Copy Markdown
Contributor Author

obviyus commented Apr 19, 2026

Landed on main.

Mquarmoc pushed a commit to Mquarmoc/openclaw that referenced this pull request Apr 20, 2026
* test(cron): cover deferred heartbeat target preservation

* fix(cron): preserve deferred heartbeat target override

* test(cron): update timer expectation for deferred heartbeat target

* fix(cron): preserve agent heartbeat config for targeted wakes

* test(cron): use wake request type in scheduler helper

* fix(cron): forward heartbeat overrides through gateway wake adapter

* fix(cron): preserve coalesced wake heartbeat overrides

* fix: preserve deferred cron heartbeat target (openclaw#69021)
lovewanwan pushed a commit to lovewanwan/openclaw that referenced this pull request Apr 28, 2026
* test(cron): cover deferred heartbeat target preservation

* fix(cron): preserve deferred heartbeat target override

* test(cron): update timer expectation for deferred heartbeat target

* fix(cron): preserve agent heartbeat config for targeted wakes

* test(cron): use wake request type in scheduler helper

* fix(cron): forward heartbeat overrides through gateway wake adapter

* fix(cron): preserve coalesced wake heartbeat overrides

* fix: preserve deferred cron heartbeat target (openclaw#69021)
ogt-redknie pushed a commit to ogt-redknie/OPENX that referenced this pull request May 2, 2026
* test(cron): cover deferred heartbeat target preservation

* fix(cron): preserve deferred heartbeat target override

* test(cron): update timer expectation for deferred heartbeat target

* fix(cron): preserve agent heartbeat config for targeted wakes

* test(cron): use wake request type in scheduler helper

* fix(cron): forward heartbeat overrides through gateway wake adapter

* fix(cron): preserve coalesced wake heartbeat overrides

* fix: preserve deferred cron heartbeat target (openclaw#69021)
github-actions Bot pushed a commit to Desicool/openclaw that referenced this pull request May 9, 2026
* test(cron): cover deferred heartbeat target preservation

* fix(cron): preserve deferred heartbeat target override

* test(cron): update timer expectation for deferred heartbeat target

* fix(cron): preserve agent heartbeat config for targeted wakes

* test(cron): use wake request type in scheduler helper

* fix(cron): forward heartbeat overrides through gateway wake adapter

* fix(cron): preserve coalesced wake heartbeat overrides

* fix: preserve deferred cron heartbeat target (openclaw#69021)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

gateway Gateway runtime maintainer Maintainer-authored PR size: M

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant