fix(mcp): bound pendingClaudePermissions / pendingApprovals via TTL sweeper + close clear#71648
Conversation
Greptile SummaryThis PR adds a TTL sweeper, Confidence Score: 5/5Safe to merge — targeted bug fix with no public API changes, thorough test coverage, and no logic errors found. No P0 or P1 findings. The sweeper correctly self-terminates when both maps drain (restoring lazy-init), close() clears both maps and nulls the interval, and post-close guards prevent ghost writes. Deletion-during-iteration is safe for JS Map. All eight test scenarios exercise the relevant edge cases including the fallback-recalculation regression. No files require special attention. Reviews (3): Last reviewed commit: "test(mcp): keep channel-bridge test view..." | Re-trigger Greptile |
|
Thanks for the catch — applied the self-terminate path in @greptile review and provide confidence score |
|
Codex review: needs maintainer review before merge. Reviewed May 30, 2026, 4:36 PM ET / 20:36 UTC. Summary PR surface: Source +57, Tests +232. Total +289 across 3 files. Reproducibility: yes. Current main stores both pending maps without TTL or close-clear, and the PR body includes a wall-clock probe showing base retained 100 pending Claude permissions while the patched branch drained them after the production TTL. Review metrics: 1 noteworthy metric.
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 detailsBest possible solution: Merge the bounded cleanup after maintainers explicitly accept the retention windows, with cap/FIFO and session-binding ordering tracked separately. Do we have a high-confidence way to reproduce the issue? Yes. Current main stores both pending maps without TTL or close-clear, and the PR body includes a wall-clock probe showing base retained 100 pending Claude permissions while the patched branch drained them after the production TTL. Is this the best way to solve the issue? Mostly yes. A lazy sweeper plus close cleanup is the narrow repair for slow retention leaks, but the hardcoded retention windows and deferred cap/FIFO protection need maintainer acceptance rather than automated repair. AGENTS.md: found and applied where relevant. Codex review notes: model gpt-5.5, reasoning high; reviewed against c80ec4332580. Label changesLabel changes:
Label justifications:
Evidence reviewedPR surface: Source +57, Tests +232. Total +289 across 3 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
|
|
Pushed The previous CI run on Fix: @greptile review and provide confidence score |
eef0be2 to
a9b70b7
Compare
…ile P2) Greptile P2 review on PR openclaw#71648: the sweep interval kept firing every 5min in perpetuity once both maps emptied — `.unref()` made it safe but silently abandoned the lazy-init invariant. ensurePendingSweeper() would no-op on later sets because pendingSweepInterval stayed non-null. sweepPendingExpired() now clearInterval()s and nulls out pendingSweepInterval when both pending maps are empty after a sweep, restoring the lazy-init contract: a fresh set after drain re-arms the interval via the same ensurePendingSweeper() path. New regression test covers (1) drain triggers self-terminate + vi.getTimerCount()===0, (2) subsequent set re-arms the interval.
bc04d6a to
528782a
Compare
|
@clawsweeper review |
…ile P2) Greptile P2 review on PR openclaw#71648: the sweep interval kept firing every 5min in perpetuity once both maps emptied — `.unref()` made it safe but silently abandoned the lazy-init invariant. ensurePendingSweeper() would no-op on later sets because pendingSweepInterval stayed non-null. sweepPendingExpired() now clearInterval()s and nulls out pendingSweepInterval when both pending maps are empty after a sweep, restoring the lazy-init contract: a fresh set after drain re-arms the interval via the same ensurePendingSweeper() path. New regression test covers (1) drain triggers self-terminate + vi.getTimerCount()===0, (2) subsequent set re-arms the interval.
526aec7 to
eb69de7
Compare
|
Replaced the The probe builds both
Production TTL/interval constants are unchanged; the probe uses them as-is. No code change in this update - PR description only. @clawsweeper review |
|
🦞🧹 I asked ClawSweeper to review this item again. Re-review progress:
|
|
ClawSweeper PR egg ✨ Hatched: 🥚 common Frosted Shellbean Hatch commandComment Hatchability rules:
Rarity: 🥚 common. What is this egg doing here?
|
|
Heads up: this PR needs to be updated against current |
|
🦞🧹 I asked ClawSweeper to review this item again. Re-review progress:
|
…ile P2) Greptile P2 review on PR openclaw#71648: the sweep interval kept firing every 5min in perpetuity once both maps emptied — `.unref()` made it safe but silently abandoned the lazy-init invariant. ensurePendingSweeper() would no-op on later sets because pendingSweepInterval stayed non-null. sweepPendingExpired() now clearInterval()s and nulls out pendingSweepInterval when both pending maps are empty after a sweep, restoring the lazy-init contract: a fresh set after drain re-arms the interval via the same ensurePendingSweeper() path. New regression test covers (1) drain triggers self-terminate + vi.getTimerCount()===0, (2) subsequent set re-arms the interval.
8457a44 to
2d6158b
Compare
|
Rebased onto the current No diff changes: the same three commits replay cleanly (upstream did not touch @clawsweeper review |
|
🦞🧹 I asked ClawSweeper to review this item again. Re-review progress:
|
|
The previous automated review reported an execution failure ("Review failed before ClawSweeper could summarize") rather than a verdict on the change. It landed during a transient CI window where the repo automation was rate-limited (the Re-requesting a clean review now that the window has passed. Locally on the rebased head ( @clawsweeper review |
|
🦞👀 Command router queued. I will update this comment with the next step. Re-review progress:
|
…clear OpenClawChannelBridge held two instance-bound Maps (pendingClaudePermissions L50, pendingApprovals L51) that lacked TTL/sweeper, close-clear, and cap — while the sibling collections in the same class (queue cap=1000, pendingWaiters close-clear + per-waiter setTimeout) were all bounded. Long-running `openclaw mcp serve` processes accumulated entries on missed Claude permission replies and on gateway WebSocket drops that silenced *.approval.resolved frames. Fix scope (one-thing-per-PR): - Wrap entries in PendingClaudePermissionEntry / PendingApprovalEntry with internal createdAtMs / trackedAtMs (instance bookkeeping, decoupled from payload-supplied fields so sweeper fallback is stable). - Lazy-start a 5-min sweepPendingExpired() interval on first set, .unref()'d so it never keeps the process alive. - TTLs: 1h for pendingClaudePermissions (createdAtMs-anchored), respects payload.expiresAtMs for pendingApprovals (falls back to trackedAtMs+30min when the server omits it). - close() now clears both Maps and clearInterval()s the sweeper. - handleClaudePermissionRequest / trackApproval early-return when this.closed to prevent post-close ghost writes. Cap/FIFO is intentionally split to a follow-up PR (one-thing-per-PR; sweeper covers the slow leak; cap addresses adversarial bursts). Tests: - New src/mcp/channel-bridge.test.ts (7 it blocks): TTL sweep for both Maps, default-TTL fallback, both-payload-fields-undefined case, close clears + vi.getTimerCount()===0, no-op-after-close, lazy-init guard. - channel-server.test.ts:366 fixture synced to wrapper shape. AI-assisted (openclaw-audit pipeline). Closes openclaw#71646. cross-review: - 5-agent post-harness primary_decision=proceed (real=4 + fix-insufficient=1 scope-down → applied: cap deferred). - 3-agent pre-pr round 1: critical-devil flagged 4 issues → fixed in this commit. - 3-agent pre-pr round 2: 3/3 real-problem-real-fix → proceed_to_pr. Upstream-dup check clean: 6w channel-bridge.ts commits all refactor/seam axis; PR openclaw#56420 (sessionKey binding) orthogonal but touches the same set() lines — rebase order may matter.
…ile P2) Greptile P2 review on PR openclaw#71648: the sweep interval kept firing every 5min in perpetuity once both maps emptied — `.unref()` made it safe but silently abandoned the lazy-init invariant. ensurePendingSweeper() would no-op on later sets because pendingSweepInterval stayed non-null. sweepPendingExpired() now clearInterval()s and nulls out pendingSweepInterval when both pending maps are empty after a sweep, restoring the lazy-init contract: a fresh set after drain re-arms the interval via the same ensurePendingSweeper() path. New regression test covers (1) drain triggers self-terminate + vi.getTimerCount()===0, (2) subsequent set re-arms the interval.
tsgo (used by check + check-test-types in CI) was reducing `OpenClawChannelBridge & BridgeInternals` to `never` because pendingClaudePermissions, pendingApprovals, and pendingSweepInterval exist on both constituents — private on the class, public on the test shape — and TypeScript collapses such intersections. Result: every member access on `bridge` in channel-bridge.test.ts (50+ sites) errored with TS2339 "Property X does not exist on type 'never'", even though the test file was structurally fine. CI surfaced this on the pull/71648/merge ref but the same failure reproduces on the PR head alone. Fix: - makeBridge() now returns BridgeInternals only, via `as unknown as`, so no intersection of public/private same-name members occurs. - BridgeInternals additionally declares the public methods we exercise (handleClaudePermissionRequest, close) so callers stay typed. No production changes; types-only test-helper edit. The 8 regression tests still pass against the merged channel-bridge.ts.
2d6158b to
d8b1a41
Compare
|
Verification before merge: Behavior addressed: bounded MCP channel bridge pending Claude permission and approval tracking; listPendingApprovals now sweeps expired approvals before reporting them.
|
Summary
OpenClawChannelBridge(src/mcp/channel-bridge.ts) holds two instance-bound pending Maps —pendingClaudePermissions(L50) andpendingApprovals(L51) — that lack TTL/sweeper, close-clear, and cap. Sibling collections in the same class (queuecap=1000 at L355,pendingWaitersclose-clear at L148 + per-waitersetTimeoutfallback at L265) are all bounded; only these two are not.openclaw mcp serveaccumulates entries on (a) missed Claude permission replies (operator typos / cross-channel responses /--dangerously-skip-permissions) and (b) gateway WebSocket drops that silence*.approval.resolvedframes (onCloseat L122-124 is reject-only, no missed-event catchup).sweepPendingExpired()interval,.unref()'d so it never keeps the process alive. Per-Map TTL — 1h for Claude permissions (anchored atcreatedAtMs), respectspayload.expiresAtMsfor approvals (falls back totrackedAtMs + 30minwhen absent).close()now clears both Maps and stops the sweeper. Handler entry early-returns whenthis.closedto prevent post-close ghost writes.listPendingApprovalsshape, message protocols, or any other class collection.cap/FIFO is intentionally split to a follow-up PR (one-thing-per-PR; sweeper covers the slow leak, cap addresses adversarial bursts).Change Type (select all)
Scope (select all touched areas)
Linked Issue/PR
Closes #71646.
Root Cause
Upstream commit history shows
channel-bridge.tsevolving across refactor/seam-split commits over the last 6 weeks, but no commit added a TTL/sweep/cap guard for these two pending Maps. The asymmetry is structural:queue(Array, L48)QUEUE_LIMIT=1000while-shiftpendingWaiters(Set, L49)close()clears (L148)setTimeout(L265)pendingClaudePermissions(Map, L50)pendingApprovals(Map, L51)expiresAtMsstored at L382 but never drives a timerpendingClaudePermissionsonly deletes when the operator reply matches/^(yes|no)\s+([a-km-z]{5})$/iANDhas(requestId)is true (L455-459) — a single conditional-edge cleanup.pendingApprovalsonly deletes on the matching*.resolvedframe (L389) — another single conditional-edge. No primary unconditional cleanup path exists.Regression Test Plan
New file
src/mcp/channel-bridge.test.ts(7 it blocks, fake timers, no production-branch divergence):pendingClaudePermissionsevicted by sweeper at TTL boundarypendingApprovalsevicted atexpiresAtMspendingApprovalsevicted at default TTL fallback whenexpiresAtMsis absentpendingApprovalsevicted even when bothcreatedAtMsandexpiresAtMsare absent (regression test for thenow-anchored fallback bug caught in pre-PR review)close()clears both Maps, stops the sweeper interval, andvi.getTimerCount() === 0handleClaudePermissionRequestis a no-op afterclose()— prevents post-close ghost writeschannel-server.test.ts:366fixture updated to the new{request, createdAtMs}wrapper shape.User-visible / Behavior Changes
openclaw mcp servesees pending Map sizes converge to a steady state instead of growing monotonically.permission_requestno longer keep the entry forever; it expires after 1h and the nextpermission_requestfor the same tool starts fresh.expiresAtMs(ortrackedAtMs + 30minif the gateway omittedexpiresAtMs).Security Impact
None. The fix is purely about bounded memory growth in an opt-in long-running daemon. No auth / scope / token / sandbox surface touched. CODEOWNERS protected paths (
/src/cron/service/jobs.ts,/src/cron/stagger.ts,/src/agents/*auth*,/src/agents/sandbox*) untouched.Repro + Verification
Environment
Steps
Expected
All green. New file 7/7. Existing channel-server 6/6. Unit project 1575/1575 (was 1573 pre-fix; +2 from new file's count vs prior file count).
Actual
Evidence
src/mcp/channel-bridge.ts:50-51— pre-fix declarations of the two leaking Mapssrc/mcp/channel-bridge.ts:136-152— pre-fixclose()body (onlypendingWaiters.clear())src/mcp/channel-bridge.ts:355-356— pre-existingqueuecap pattern (sibling reference)src/mcp/channel-bridge.ts:265-267— pre-existing per-waitersetTimeout(sibling reference)Human Verification
I confirm I have reviewed every diff hunk, ran the build/check/test suites locally, and the regression tests fail on
upstream/main(no fix) and pass on this branch (with fix).Review Conversations
This is a fresh PR. No prior review threads.
Compatibility / Migration
Fully backwards-compatible. No public API, no protocol field, no config surface changed. The internal Map value shape (
{request, createdAtMs}wrapper,{approval, trackedAtMs}wrapper) is private — only the test that previously cast throughRecord<string, unknown>was updated to match.Risks and Mitigations
PENDING_CLAUDE_PERMISSION_TTL_MS=1h,PENDING_APPROVAL_DEFAULT_TTL_MS=30min,PENDING_SWEEP_INTERVAL_MS=5min) are hardcoded.Mitigation: chosen for low operator-disruption (
1his longer than typical permission-think time;30minmatches existingexpiresAtMsfield intent). Happy to migrate to a config surface in a follow-up if requested.set()line inhandleClaudePermissionRequest.Mitigation: line-level rebase only; semantic axes are orthogonal (binding vs leak). Whichever PR merges first, the other rebases cleanly.
cap/FIFO is not in this PR — adversarial bursts within a single sweep tick (5min) could still grow large in pathological cases.Mitigation: deferred to a follow-up PR (one-thing-per-PR). Sweeper closes the slow leak that motivated the report; cap is a separate concern.
🤖 AI-Assisted (openclaw-audit pipeline). 5-agent post-harness cross-review (proceed: 4 real + 1 fix-insufficient → cap deferred to follow-up). 3-agent pre-PR cross-review round 1 caught a fallback-recalculation bug in the original sweeper (
createdAtMs ?? nowre-anchored expiry on every tick) → fixed viatrackedAtMsinstance bookkeeping; round 2 cleared 3/3 real-problem-real-fix.Real behavior proof
Live Node.js measurement of bridge.pendingClaudePermissions.size with a real setTimeout sleep across the production 1-hour TTL boundary: