fix(bluebubbles): dedupe inbound webhooks across restarts (#19176, #12053)#66230
fix(bluebubbles): dedupe inbound webhooks across restarts (#19176, #12053)#66230omarshahine wants to merge 1 commit intomainfrom
Conversation
🔒 Aisle Security AnalysisWe found 5 potential security issue(s) in this PR:
1. 🟡 Symlink-following arbitrary file overwrite in Windows atomic write fallback (copyFile)
Description
This becomes reachable in this change because Impact:
Vulnerable code: await fs.copyFile(tempPath, filePath);RecommendationHarden the Windows fallback to avoid following symlinks/hardlinks at the destination. Options:
import fs from "node:fs/promises";
// before copyFile
try {
const st = await fs.lstat(filePath);
if (st.isSymbolicLink()) {
throw new Error(`Refusing to write through symlink: ${filePath}`);
}
} catch (e: any) {
if (e?.code !== "ENOENT") throw e;
}
await fs.copyFile(tempPath, filePath, fs.constants.COPYFILE_FICLONE_FORCE);
Additionally consider validating that the state directory is owned by the current user and not group/world-writable when running in multi-user environments. 2. 🟡 Potential disk I/O & CPU Denial-of-Service from per-message persistent dedupe commits (50k-entry JSON rewrite)
DescriptionThe new BlueBubbles inbound dedupe persists every successfully-processed inbound message GUID to a per-account JSON file, with a high cap ( In
Because the file is rewritten in full, once the dedupe file grows towards 50k keys, each new unique inbound GUID forces O(n) JSON serialization and a full file write. A remote party who can trigger many inbound messages (and thus many distinct GUIDs) can significantly increase disk I/O and CPU usage, degrading the service (persistent-storage DoS). Vulnerable flow (high level):
Vulnerable code (new usage/config): const FILE_MAX_ENTRIES = 50_000;
...
return createClaimableDedupe({
ttlMs: DEDUP_TTL_MS,
memoryMaxSize: MEMORY_MAX_SIZE,
fileMaxEntries: FILE_MAX_ENTRIES,
resolveFilePath: resolveNamespaceFilePath,
});// called for each inbound message
await claim.finalize();Underlying expensive operation (existing implementation): const { value } = await readJsonFileWithFallback(path, {});
...
pruneData(data, now, ttlMs, fileMaxEntries);
await writeJsonFileAtomically(path, data);RecommendationReduce the DoS surface by limiting write amplification and bounding per-message work:
Example: batch commits to disk (conceptual): // Pseudocode: buffer commits in memory, flush at most once per 1s
const pending = new Map<string, number>();
setInterval(() => flush(pending), 1000);
function record(key: string) {
pending.set(key, Date.now());
}If immediate persistence is required, consider a storage format supporting incremental updates (SQLite with an index; LMDB; or a compact binary keyset) rather than whole-file JSON rewrites. 3. 🟡 Insecure temporary state directory selection via NODE_ENV/VITEST can redirect persistent dedupe storage into world-writable tmp
Description
If a non-test deployment is misconfigured to run with
Vulnerable code: function resolveStateDirFromEnv(env: NodeJS.ProcessEnv = process.env): string {
if (env.VITEST || env.NODE_ENV === "test") {
const name = "openclaw-vitest-" + process.pid;
return path.join(os.tmpdir(), name);
}
return resolveStateDir(env);
}RecommendationAvoid using a world-writable temp directory for persistent state based on generic environment flags. Recommended fixes (pick one):
function resolveStateDirFromEnv(env: NodeJS.ProcessEnv = process.env): string {
// Only test runner should set OPENCLAW_STATE_DIR to a temp dir.
return resolveStateDir(env);
}
import fs from "node:fs/promises";
async function resolveStateDirFromEnv(env: NodeJS.ProcessEnv = process.env): Promise<string> {
if (env.OPENCLAW_TEST_TMP_STATE === "1") {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-vitest-"));
// Optionally: verify realpath(dir) starts with realpath(os.tmpdir())
return dir;
}
return resolveStateDir(env);
}Also consider validating that the resolved state dir is owned by the current user and not group/world-writable before writing state files. 4. 🔵 Log forging via unsanitized error string in BlueBubbles reply failure logging
DescriptionThe BlueBubbles reply pipeline logs Although Vulnerable code: runtime.error?.(`BlueBubbles ${info.kind} reply failed: ${String(err)}`);RecommendationSanitize any potentially untrusted values before logging, including error objects. Example fix (reuse the existing sanitizer): runtime.error?.(
`BlueBubbles ${sanitizeForLog(info.kind)} reply failed: ${sanitizeForLog(err)}`,
);Optionally, prefer structured logging APIs that keep fields separate from message formatting to reduce injection risk. 5. 🔵 Sensitive identifier disclosure in verbose logs (inbound dedupe drop/finalize paths)
Description
Vulnerable code: logVerbose(
core,
runtime,
`drop: ${claim.kind} inbound key=${sanitizeForLog(dedupeKey ?? "")} sender=${sanitizeForLog(message.senderId)}`,
);While RecommendationAvoid logging full message GUIDs and sender identifiers. Options (choose one or combine):
function redactId(value: unknown): string {
const s = String(value ?? "");
if (s.length <= 8) return "[redacted]";
return `${s.slice(0, 4)}…${s.slice(-4)}`;
}
logVerbose(core, runtime, `drop: ${claim.kind} inbound key=${redactId(dedupeKey)} sender=${redactId(message.senderId)}`);
import { createHash } from "node:crypto";
function hashForLog(value: unknown): string {
return createHash("sha256").update(String(value ?? ""), "utf8").digest("hex").slice(0, 12);
}
logVerbose(core, runtime, `drop: ${claim.kind} inbound keyHash=${hashForLog(dedupeKey)} senderHash=${hashForLog(message.senderId)}`);
Analyzed PR: #66230 at commit Last updated on: 2026-04-14T21:10:14Z |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 45def7dd83
ℹ️ About Codex in GitHub
Codex has been enabled to automatically 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 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
Greptile SummaryThis PR adds a persistent file-backed GUID dedupe to the BlueBubbles inbound path using the existing Confidence Score: 5/5
Prompt To Fix All With AIThis is a comment left during a code review.
Path: extensions/bluebubbles/src/inbound-dedupe.ts
Line: 24-28
Comment:
**Misleading comment about "scoped dedupe test"**
The inline comment says "Stable-per-pid so the scoped dedupe test can observe persistence," but no test in `inbound-dedupe.test.ts` actually exercises the persistent (file-backed) path — all five tests install the memory-only impl via `_resetBlueBubblesInboundDedupForTest`. The VITEST branch here is only ever reached at module-load time before the first reset, so the pid-based dir is never populated during tests. Consider either dropping the aspiration from the comment or noting that this isolation applies to the initial module-load construction only.
How can I resolve this? If you propose a fix, please make it concise.Reviews (2): Last reviewed commit: "fix(bluebubbles): dedupe inbound webhook..." | Re-trigger Greptile |
8dad605 to
3ff4cb0
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 3ff4cb05d0
ℹ️ About Codex in GitHub
Codex has been enabled to automatically 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 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
- Switch to claim/commit/release pattern via createClaimableDedupe so a thrown error after claim releases the GUID (lets a later replay retry instead of being permanently blocked for the 7-day TTL). Addresses the Feishu-style two-phase claim pattern noted in the Greptile review. - Cap GUID length at 512 chars and reject oversize values before persisting, preventing on-disk bloat from malformed/hostile payloads (security review: CWE-400). - Sanitize guid/sender/error strings in verbose log lines to strip control characters, avoiding log forging (security review: CWE-117). - Drop the unused OPENCLAW_STATE_DIR stubbing from the scoped test; the memory-only impl does not touch disk. Add coverage for oversize GUID rejection and the release/retry path.
3ff4cb0 to
50af391
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 50af391f44
ℹ️ About Codex in GitHub
Codex has been enabled to automatically 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 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
…66230 review - Resolve thread rwF8: finalize the inbound dedupe claim only when reply dispatch actually succeeded. Reply-delivery errors surface through the dispatcher's onError callback, not as thrown exceptions, so a plain try/catch cannot detect them. Thread a dedupeSignal through processMessageAfterDedupe, flip the flag in dispatcher onError, and have the outer wrapper release the claim on failure so BB MessagePoller replays on a later reconnect can retry the send (and the user is not silently denied their reply for 7 days after a transient send error). - Resolve thread rkzI: align the dedupe key with the debouncer's buildKey logic. BlueBubbles sends URL-preview / sticker 'balloon' events with a different messageId than the originating text message; when balloonBundleId + associatedMessageGuid are present, dedupe against the underlying associatedMessageGuid so balloon-first vs text-first delivery order cannot produce two distinct dedupe keys for the same logical message.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: dc71d95275
ℹ️ About Codex in GitHub
Codex has been enabled to automatically 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 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: e14a9dfa5c
ℹ️ About Codex in GitHub
Codex has been enabled to automatically 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 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
- Switch to claim/commit/release pattern via createClaimableDedupe so a thrown error after claim releases the GUID (lets a later replay retry instead of being permanently blocked for the 7-day TTL). Addresses the Feishu-style two-phase claim pattern noted in the Greptile review. - Cap GUID length at 512 chars and reject oversize values before persisting, preventing on-disk bloat from malformed/hostile payloads (security review: CWE-400). - Sanitize guid/sender/error strings in verbose log lines to strip control characters, avoiding log forging (security review: CWE-117). - Drop the unused OPENCLAW_STATE_DIR stubbing from the scoped test; the memory-only impl does not touch disk. Add coverage for oversize GUID rejection and the release/retry path.
…66230 review - Resolve thread rwF8: finalize the inbound dedupe claim only when reply dispatch actually succeeded. Reply-delivery errors surface through the dispatcher's onError callback, not as thrown exceptions, so a plain try/catch cannot detect them. Thread a dedupeSignal through processMessageAfterDedupe, flip the flag in dispatcher onError, and have the outer wrapper release the claim on failure so BB MessagePoller replays on a later reconnect can retry the send (and the user is not silently denied their reply for 7 days after a transient send error). - Resolve thread rkzI: align the dedupe key with the debouncer's buildKey logic. BlueBubbles sends URL-preview / sticker 'balloon' events with a different messageId than the originating text message; when balloonBundleId + associatedMessageGuid are present, dedupe against the underlying associatedMessageGuid so balloon-first vs text-first delivery order cannot produce two distinct dedupe keys for the same logical message.
Reviewer raised a P1 on #66230: preferring associatedMessageGuid for every inbound message is wrong because that field doubles as reply linkage. extractReplyMetadata in monitor-normalize.ts falls back to associatedMessageGuid for non-reaction messages, so two distinct user replies to the same parent message would share a single dedupe key — and once the first finalized, every later reply to that same parent would be silently dropped as a 'duplicate' for the full 7-day TTL. Revert the gate to the debouncer's original predicate (balloonBundleId && associatedMessageGuid) so only the balloon + underlying-text coalescing case uses associatedMessageGuid. Regular replies now correctly dedupe by their own messageId. Known narrower tradeoff: combineDebounceEntries clears balloonBundleId on merged entries, so a post-merge balloon+text message falls back to messageId here. If a later MessagePoller replay merges the two webhooks in a different order it may produce a different messageId and bypass dedupe for that one message. That window is much narrower than the alternative — which silently drops real user replies — and is documented in the comment.
e14a9df to
d5525d9
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: d5525d9160
ℹ️ About Codex in GitHub
Codex has been enabled to automatically 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 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
…uard Two related fixes after CI + Codex feedback on #66230: - P2 (Codex): the plugin was resolving OPENCLAW_STATE_DIR verbatim so values like '~/state' were not home-expanded, and legacy-vs-new state dir fallback diverged from the rest of OpenClaw. Use the shared resolveStateDir helper from openclaw/plugin-sdk/state-paths so the BlueBubbles dedupe file lands alongside the rest of state across all supported OPENCLAW_STATE_DIR / OPENCLAW_HOME configurations. - CI (temp-path-guard + lint:tmp:no-random-messaging): the vitest branch used path.join(os.tmpdir(), `openclaw-vitest-${pid}`) — a dynamic template literal passed directly as a path.join argument, which the security guard flags. Store the name in a local and pass it as a plain variable (matching the guard's allowed 'segment' fixture) while keeping identical runtime behavior.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 116e0d275e
ℹ️ About Codex in GitHub
Codex has been enabled to automatically 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 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
Codex P2 on #66230: dispatcher.onError fires for tool, block, and final kinds, but only 'final' represents the user-visible answer. Dispatcher continues past tool/block failures and may still deliver final cleanly. Releasing the dedupe claim for non-terminal failures would invite a replay that re-runs tool side effects and resends already-delivered chunks. Gate dedupeSignal.deliveryFailed = true on info.kind === 'final'.
0b233e4 to
88c67f1
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 88c67f1963
ℹ️ About Codex in GitHub
Codex has been enabled to automatically 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 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| if (info.kind === "final") { | ||
| dedupeSignal.deliveryFailed = true; | ||
| } |
There was a problem hiding this comment.
Handle block-only send failures before finalizing dedupe
onError only flips dedupeSignal.deliveryFailed for final payloads, but the shared reply flow explicitly allows successful block-streaming turns with no final payload (src/auto-reply/reply/dispatch-from-config.ts notes this at the block-streaming path). In those runs, a failed BlueBubbles block send leaves deliveryFailed false, so processMessage still calls claim.finalize() and permanently dedupes the inbound message even though user-visible delivery failed. That drops later replay retries for the TTL window and can lose replies after transient send errors.
Useful? React with 👍 / 👎.
88c67f1 to
cd00811
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: cd00811112
ℹ️ About Codex in GitHub
Codex has been enabled to automatically 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 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| // a remembered GUID, and the file-backed store survives gateway restarts. | ||
| const DEDUP_TTL_MS = 7 * 24 * 60 * 60 * 1_000; | ||
| const MEMORY_MAX_SIZE = 5_000; | ||
| const FILE_MAX_ENTRIES = 50_000; |
There was a problem hiding this comment.
Preserve 7-day replay coverage in dedupe capacity
Increase or make configurable this per-account cap, because createPersistentDedupe evicts oldest keys when fileMaxEntries is exceeded even if those keys are still within TTL (src/plugin-sdk/persistent-dedupe.ts pruneData). With DEDUP_TTL_MS set to 7 days but FILE_MAX_ENTRIES fixed at 50,000, higher-volume accounts can churn through more than 50k messages in a week, causing still-recent GUIDs to be dropped and replayed webhooks after restart to bypass dedupe and re-run message handling.
Useful? React with 👍 / 👎.
…rt (#66721) Adds a per-account startup catchup pass that queries BB Server for messages delivered since a persisted cursor and re-feeds each through the existing processMessage pipeline. Fixes the missed-message hole documented in #66721: BB's WebhookService is fire-and-forget on POST failure and MessagePoller does not replay webhook-receiver recovery, so inbound messages delivered while the gateway was down/wedged/restarting were permanently lost. Design - New `extensions/bluebubbles/src/catchup.ts`: - `fetchBlueBubblesMessagesSince(sinceMs, limit, opts)` calls `/api/v1/message/query` with `{after, sort:"ASC", with:[chat, chat.participants, attachment]}` so replays carry the same shape `normalizeWebhookMessage` already handles on live dispatch. - `loadBlueBubblesCatchupCursor` / `saveBlueBubblesCatchupCursor` persist a single `{lastSeenMs, updatedAt}` per account at `<stateDir>/bluebubbles/catchup/<accountId>__<hash>.json`, using the plugin-sdk's atomic JSON helpers. File layout mirrors the inbound-dedupe store from #66230. - `runBlueBubblesCatchup(target)` orchestrates: clamp config, skip recent runs (<30s), clamp window to `maxAgeMinutes`, fetch, filter `isFromMe` and pre-cursor records, dispatch to `processMessage`, advance cursor to `nowMs` on success. - `monitor.ts`: after the webhook target registers, fire catchup as a background task; errors are logged but never block readiness. - `config-schema.ts`: new optional `catchup` block (`enabled`, `maxAgeMinutes`, `perRunLimit`, `firstRunLookbackMinutes`), defaults on with 2h lookback / 50 msg cap / 30-min first-run lookback. Safety - Goes through the same `processMessage` path webhooks use, so auth, allowlist, pairing, and downstream agent dispatch all apply unchanged. - Dedupes against #66230's persistent inbound GUID cache, so a webhook delivery that already succeeded cannot be reprocessed by catchup. - Never dispatches `isFromMe` records (double-checked before and after normalization) so the agent's own sends cannot enter the inbound path. - Cursor is only advanced on a successful query; a failed fetch leaves the prior cursor in place so the next run retries the same window. - 30s minimum interval between runs prevents churn on rolling restarts. - Hard ceilings: 12h max lookback, 500 messages per run. Validation - New scoped tests in `extensions/bluebubbles/src/catchup.test.ts` (14 cases covering cursor round-trip, account scoping, FS-unsafe account IDs, firstRunLookback, maxAge clamp, enabled:false, rapid restart skip, isFromMe filter, query failure preserving cursor, per-message failure isolation, and pre-cursor defense-in-depth). - Full BlueBubbles suite (403/403) and `pnpm check` green. - End-to-end verified on macOS with a live BB Server 1.9.x: stop gateway, send N messages to monitored threads (verified 3/3 ECONNREFUSED in BB's `main.log`), restart gateway; catchup queried and re-POSTed all N missed messages through `processMessage`, and subsequent boots were no-ops (empty delta). Retires the reference implementation at `openclaw-agents/lobster/scripts/bb-catchup.sh` in the Lobster workspace, which has been running this exact shape for ~4 weeks. Closes #66721. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…kew gate Addresses Codex review findings on PR #66760: - P1: Avoid committing catchup cursor after replay failures Previously the cursor advanced to nowMs unconditionally, so a transient processMessage failure during startup catchup permanently dropped the failing record — the next sweep queried only `after nowMs` and never retried it. Now we track the earliest timestamp where processMessage threw and hold the cursor just before it (still clamped to >= the prior cursor and <= nowMs), so retries pick up exactly the failed records on the next run. The inbound-dedupe cache from #66230 keeps successfully-replayed messages from being re-processed on retry. Normalize failures (record didn't yield a usable NormalizedWebhookMessage) are still treated as permanent skips, so a malformed payload doesn't wedge catchup forever. - P2: Skip min-interval gate when stored cursor is in the future If the host clock jumped backwards (NTP correction, manual adjust), `nowMs - existing.lastSeenMs` was negative, which still satisfies `< MIN_INTERVAL_MS`, so catchup returned null repeatedly until wall time caught up. Added `nowMs >= existing.lastSeenMs` precondition so future-dated cursors stop disabling catchup. Tests: 3 new cases (cursor-held-on-failure, clamp-to-prior-cursor, clock-skew-bypass-gate); BB suite remains 408/408. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…2053) BlueBubbles MessagePoller replays its ~1-week lookback window as new-message webhooks after BB Server restart or reconnect. Without persistent dedup, the gateway re-replies to messages it already handled before the restart. Add a persistent file-backed GUID dedupe (TTL=7d, matching BB's lookback window) at the top of processMessage, using the same createClaimableDedupe SDK primitive as Feishu. The on-disk store at ~/.openclaw/bluebubbles/inbound-dedupe/<account>.json survives gateway restarts. Claim/finalize/release semantics ensure transient delivery failures release the GUID so a later replay can retry, while successful deliveries are committed and block future replays. Fixes #19176, #12053.
4d63af6 to
82bce26
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 82bce2647e
ℹ️ About Codex in GitHub
Codex has been enabled to automatically 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 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| throw error; | ||
| } | ||
| if (claim.kind === "claimed") { | ||
| if (signal.deliveryFailed) { |
There was a problem hiding this comment.
Handle pairing reply failures before finalizing dedupe
This finalize path runs whenever dedupeSignal.deliveryFailed is false, but pairing-challenge send failures do not set that signal. In the pairing branch, issueChallenge catches sendPairingReply exceptions and only invokes onReplyError (src/pairing/pairing-challenge.ts), so processMessageAfterDedupe can return successfully even when the pairing message was never delivered. The GUID is then committed here, which makes subsequent BlueBubbles replays look like duplicates for 7 days and prevents the user from receiving a retry pairing code after transient send failures.
Useful? React with 👍 / 👎.
|
Closing to reopen as a fresh PR — GitHub stopped firing |
…66810 The inbound-dedupe PR was reopened as #66810 (#66230 closed without merging at 21:10:28Z, #66810 created 7s later on the same lobster/bb-inbound-dedupe branch). Updating code comments and the catchup CHANGELOG entry to point at the live PR. The branch dependency chain (this PR stacked on lobster/bb-inbound-dedupe) is unchanged. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…66816 The inbound-dedupe PR was reopened again as #66816 (closed-without-merge trail: #66230 → #66810 → #66816). The branch was force-pushed and the new PR uses the parallel `fix/bb-inbound-dedupe` branch. Updating code comments and the catchup CHANGELOG entry to point at the live PR. Stacking on top of the dedupe branch will be addressed in a follow-up rebase. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…rt (#66721) Adds a per-account startup catchup pass that queries BB Server for messages delivered since a persisted cursor and re-feeds each through the existing processMessage pipeline. Fixes the missed-message hole documented in #66721: BB's WebhookService is fire-and-forget on POST failure and MessagePoller does not replay webhook-receiver recovery, so inbound messages delivered while the gateway was down/wedged/restarting were permanently lost. Design - New `extensions/bluebubbles/src/catchup.ts`: - `fetchBlueBubblesMessagesSince(sinceMs, limit, opts)` calls `/api/v1/message/query` with `{after, sort:"ASC", with:[chat, chat.participants, attachment]}` so replays carry the same shape `normalizeWebhookMessage` already handles on live dispatch. - `loadBlueBubblesCatchupCursor` / `saveBlueBubblesCatchupCursor` persist a single `{lastSeenMs, updatedAt}` per account at `<stateDir>/bluebubbles/catchup/<accountId>__<hash>.json`, using the plugin-sdk's atomic JSON helpers. File layout mirrors the inbound-dedupe store from #66230. - `runBlueBubblesCatchup(target)` orchestrates: clamp config, skip recent runs (<30s), clamp window to `maxAgeMinutes`, fetch, filter `isFromMe` and pre-cursor records, dispatch to `processMessage`, advance cursor to `nowMs` on success. - `monitor.ts`: after the webhook target registers, fire catchup as a background task; errors are logged but never block readiness. - `config-schema.ts`: new optional `catchup` block (`enabled`, `maxAgeMinutes`, `perRunLimit`, `firstRunLookbackMinutes`), defaults on with 2h lookback / 50 msg cap / 30-min first-run lookback. Safety - Goes through the same `processMessage` path webhooks use, so auth, allowlist, pairing, and downstream agent dispatch all apply unchanged. - Dedupes against #66230's persistent inbound GUID cache, so a webhook delivery that already succeeded cannot be reprocessed by catchup. - Never dispatches `isFromMe` records (double-checked before and after normalization) so the agent's own sends cannot enter the inbound path. - Cursor is only advanced on a successful query; a failed fetch leaves the prior cursor in place so the next run retries the same window. - 30s minimum interval between runs prevents churn on rolling restarts. - Hard ceilings: 12h max lookback, 500 messages per run. Validation - New scoped tests in `extensions/bluebubbles/src/catchup.test.ts` (14 cases covering cursor round-trip, account scoping, FS-unsafe account IDs, firstRunLookback, maxAge clamp, enabled:false, rapid restart skip, isFromMe filter, query failure preserving cursor, per-message failure isolation, and pre-cursor defense-in-depth). - Full BlueBubbles suite (403/403) and `pnpm check` green. - End-to-end verified on macOS with a live BB Server 1.9.x: stop gateway, send N messages to monitored threads (verified 3/3 ECONNREFUSED in BB's `main.log`), restart gateway; catchup queried and re-POSTed all N missed messages through `processMessage`, and subsequent boots were no-ops (empty delta). Retires the reference implementation at `openclaw-agents/lobster/scripts/bb-catchup.sh` in the Lobster workspace, which has been running this exact shape for ~4 weeks. Closes #66721. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…kew gate Addresses Codex review findings on PR #66760: - P1: Avoid committing catchup cursor after replay failures Previously the cursor advanced to nowMs unconditionally, so a transient processMessage failure during startup catchup permanently dropped the failing record — the next sweep queried only `after nowMs` and never retried it. Now we track the earliest timestamp where processMessage threw and hold the cursor just before it (still clamped to >= the prior cursor and <= nowMs), so retries pick up exactly the failed records on the next run. The inbound-dedupe cache from #66230 keeps successfully-replayed messages from being re-processed on retry. Normalize failures (record didn't yield a usable NormalizedWebhookMessage) are still treated as permanent skips, so a malformed payload doesn't wedge catchup forever. - P2: Skip min-interval gate when stored cursor is in the future If the host clock jumped backwards (NTP correction, manual adjust), `nowMs - existing.lastSeenMs` was negative, which still satisfies `< MIN_INTERVAL_MS`, so catchup returned null repeatedly until wall time caught up. Added `nowMs >= existing.lastSeenMs` precondition so future-dated cursors stop disabling catchup. Tests: 3 new cases (cursor-held-on-failure, clamp-to-prior-cursor, clock-skew-bypass-gate); BB suite remains 408/408. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Summary
BlueBubbles `MessagePoller` keeps a ~1-week lookback and re-fires `new-message` webhooks after BB Server restart or reconnection. With no sequence number or ack in the BB webhook protocol, the gateway previously had no way to recognize replays and would happily re-reply to messages it had already handled before the restart — producing duplicated outbound messages, and confusing replies to stale inbound that the user had already moved on from (see #19176, #12053).
This PR adds a persistent, file-backed inbound dedupe keyed by message GUID, modeled after the same `createPersistentDedupe` pattern used by the Feishu plugin.
Why this approach
Other channels with monotonic sequence IDs (Telegram's `update_id`, Matrix's sync token, Discord's gateway sequence) can dedupe natively via protocol. BlueBubbles does not expose anything like that, so an identity-based persistent dedupe at the message layer is the closest equivalent that fits how BB actually delivers webhooks.
Interaction with edit events (`updated-message`)
PR #52277 raised a related concern: if dedupe keys are GUID-only, a legitimate `updated-message` event would share a GUID with its original `new-message` and get dropped as a duplicate.
In the current codebase this cannot happen: `monitor.ts` routes `updated-message` payloads differently — without a reaction they are dropped at the webhook layer ("ignored without reaction"), and with a reaction they flow through `processReaction`, not `processMessage`. Our dedupe sits inside `processMessage`, so only `new-message` events are gated. Edits can't collide today.
If the separate work in #52277 lands and begins routing text-edit bodies into `processMessage`, the dedupe key will need to expand to include event type and edit metadata (e.g. `guid + eventType + (dateEdited||"")`) so an edit is treated as a distinct key. That's a straightforward forward-compatible change when it's needed.
Validation
Credits
Re-creates and improves on the focused fix from #31159 by @dashhuang — same behavioral goal (drop stale BB webhook replays), implemented as a persistent on-disk dedupe so it actually survives the gateway-restart case that drives the bug, and without the module-global mutable state that made the original patch need test-reset plumbing.
Fixes #19176, #12053.
Test plan