fix: register BlueBubbles webhook route before server-info fetch#45664
fix: register BlueBubbles webhook route before server-info fetch#45664techfitmaster wants to merge 4 commits intoopenclaw:mainfrom
Conversation
…cation Slack sends url_verification challenges WITHOUT a signature (by design). The HTTPReceiver was rejecting these requests because it verifies signatures on ALL requests, but url_verification is never signed. This fix intercepts incoming HTTP requests BEFORE they reach the receiver's signature verification: 1. Read the request body first 2. Check if it's a url_verification request 3. If yes, respond directly with the challenge (no signature needed) 4. If no, replay the body to the receiver using a PassThrough stream This allows HTTP webhook mode to complete the Slack handshake and activate properly, fixing the regression in v2026.3.2. Closes openclaw#39432
Handle file lock errors gracefully when .openclaw directory is synced via cloud storage services (OneDrive, Dropbox, Google Drive, etc.). Retry up to 3 times with exponential backoff before failing. Fixes openclaw#39446
fix: add retry logic for file lock errors (EBUSY, EACCES, EPERM)
Previously, monitorBlueBubblesProvider called fetchBlueBubblesServerInfo (up to 5 s timeout) *before* registering the webhook HTTP route. This created a window on every startup and after every crash-loop restart where POST /bluebubbles-webhook returned 404: the plugin HTTP registry had no matching entry yet so the request fell all the way through the gateway's stage pipeline. Fix: move registerBlueBubblesWebhookTarget to the top of the function, before the async server-info fetch, so the route is reachable as soon as the provider (re)starts. Also fix an off-by-one in server-channels restart accounting: when the crash-loop hit MAX_RESTART_ATTEMPTS + 1 it was reporting reconnectAttempts = MAX + 1 instead of MAX, causing the snapshot to show one extra attempt than the configured cap. Fixes openclaw#45620
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: c35ee4233e
ℹ️ 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".
| // Slack sends url_verification WITHOUT a signature (by design). | ||
| // We must intercept this BEFORE the receiver's signature verification. | ||
| // Read the body first to check the request type. | ||
| const body = await readRequestBody(req); |
There was a problem hiding this comment.
Enforce Slack body limits before reading request payload
The handler now calls readRequestBody(req) before installRequestBodyLimitGuard(...), so the webhook body is fully buffered without any size or timeout protection. A client can send a large/slow chunked request without Content-Length and bypass SLACK_WEBHOOK_MAX_BODY_BYTES and SLACK_WEBHOOK_BODY_TIMEOUT_MS, which means the limit guard only runs after the expensive read has already happened.
Useful? React with 👍 / 👎.
Greptile SummaryThis PR fixes a startup race condition in BlueBubbles (webhook route registered before server-info fetch), an off-by-one in crash-loop restart accounting, adds file-write retry logic for lock errors, and intercepts Slack's The first three changes are clean and correct. The Slack
Confidence Score: 2/5
Prompt To Fix All With AIThis is a comment left during a code review.
Path: src/slack/monitor/provider.ts
Line: 236
Comment:
**`readRequestBody` bypasses all size and timeout guards**
`readRequestBody(req)` buffers the entire network body into memory with no byte-size limit and no timeout. The `SLACK_WEBHOOK_MAX_BODY_BYTES` (1 MB) and `SLACK_WEBHOOK_BODY_TIMEOUT_MS` (30 s) protections are only applied to the `PassThrough` replay on `mockReq`, which already holds the pre-buffered data by the time `installRequestBodyLimitGuard` runs. This means:
1. **Memory exhaustion**: An attacker can send an arbitrarily large body that is fully buffered before any size check occurs. The guard will eventually trip and send a 413, but the damage (heap spike) has already happened.
2. **Timeout bypass**: A slow/stalling client can hold the HTTP connection open indefinitely during `readRequestBody`. The 30 s timeout on `mockReq` fires almost immediately (the PassThrough stream drains instantly), so it offers no protection against sluggish senders.
The pre-read is necessary to inspect the `type` field before Bolt's signature verification runs. The fix is to apply the limit guard *during* the initial body read instead of re-applying it to the replay stream:
```typescript
// Read with the same limits as the guard, then decide what to do
let rawBody: string;
try {
rawBody = await readRequestBodyWithLimit(req, {
maxBytes: SLACK_WEBHOOK_MAX_BODY_BYTES,
timeoutMs: SLACK_WEBHOOK_BODY_TIMEOUT_MS,
});
} catch (err) {
if (isRequestBodyLimitError(err)) {
// send the appropriate 4xx and return
...
}
throw err;
}
```
(Both `readRequestBodyWithLimit` and `isRequestBodyLimitError` are already exported from `../../infra/http-body.js`.)
How can I resolve this? If you propose a fix, please make it concise.
---
This is a comment left during a code review.
Path: src/slack/monitor/provider.ts
Line: 258-268
Comment:
**Incomplete stream proxy may silently break with Bolt upgrades**
`mockReq` overrides `on`, `read`, `removeListener`, and `emit` to delegate to `passThrough`, but all other `Readable`/`IncomingMessage` stream properties and methods still resolve via prototype to the original, already-consumed `req`. Notably:
- **`mockReq.readableEnded`** (a getter that reads `this._readableState.endEmitted`) will return `true` because `req._readableState.endEmitted` is `true` after `readRequestBody` drained the socket. If `@slack/bolt`'s `HTTPReceiver` or a middleware (e.g., `raw-body`) checks `req.readableEnded` before attaching a `'data'` listener, it may treat the body as already consumed and skip reading — even though `passThrough` still has the buffered data.
- `readable`, `readableLength`, `readableHighWaterMark`, `_readableState`, `resume()`, `pause()`, and `destroy()` all inherit from `req`, not from `passThrough`.
A safer approach is to construct `mockReq` from `passThrough` directly and copy only the HTTP-level properties from `req`, or use an explicit wrapper class. This avoids cross-stream property leakage and makes the proxy contract explicit.
How can I resolve this? If you propose a fix, please make it concise.
---
This is a comment left during a code review.
Path: src/infra/json-files.ts
Line: 80
Comment:
**Unreachable `throw lastError` after retry loop**
The `for` loop always exits via either `return` (on success) or `throw err` (inside the `catch` block, on non-retryable errors or the final attempt). It never exhausts the iteration counter without throwing, so this `throw lastError` is dead code. Additionally, `lastError` is typed as `Error | undefined`, so if this line were somehow reached it would throw `undefined`.
Consider removing the trailing `throw` or replacing the loop with a pattern that makes the termination condition more explicit:
```suggestion
}
// loop always returns or throws above; this line is unreachable
```
How can I resolve this? If you propose a fix, please make it concise.Last reviewed commit: c35ee42 |
| // Slack sends url_verification WITHOUT a signature (by design). | ||
| // We must intercept this BEFORE the receiver's signature verification. | ||
| // Read the body first to check the request type. | ||
| const body = await readRequestBody(req); |
There was a problem hiding this comment.
readRequestBody bypasses all size and timeout guards
readRequestBody(req) buffers the entire network body into memory with no byte-size limit and no timeout. The SLACK_WEBHOOK_MAX_BODY_BYTES (1 MB) and SLACK_WEBHOOK_BODY_TIMEOUT_MS (30 s) protections are only applied to the PassThrough replay on mockReq, which already holds the pre-buffered data by the time installRequestBodyLimitGuard runs. This means:
- Memory exhaustion: An attacker can send an arbitrarily large body that is fully buffered before any size check occurs. The guard will eventually trip and send a 413, but the damage (heap spike) has already happened.
- Timeout bypass: A slow/stalling client can hold the HTTP connection open indefinitely during
readRequestBody. The 30 s timeout onmockReqfires almost immediately (the PassThrough stream drains instantly), so it offers no protection against sluggish senders.
The pre-read is necessary to inspect the type field before Bolt's signature verification runs. The fix is to apply the limit guard during the initial body read instead of re-applying it to the replay stream:
// Read with the same limits as the guard, then decide what to do
let rawBody: string;
try {
rawBody = await readRequestBodyWithLimit(req, {
maxBytes: SLACK_WEBHOOK_MAX_BODY_BYTES,
timeoutMs: SLACK_WEBHOOK_BODY_TIMEOUT_MS,
});
} catch (err) {
if (isRequestBodyLimitError(err)) {
// send the appropriate 4xx and return
...
}
throw err;
}(Both readRequestBodyWithLimit and isRequestBodyLimitError are already exported from ../../infra/http-body.js.)
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/slack/monitor/provider.ts
Line: 236
Comment:
**`readRequestBody` bypasses all size and timeout guards**
`readRequestBody(req)` buffers the entire network body into memory with no byte-size limit and no timeout. The `SLACK_WEBHOOK_MAX_BODY_BYTES` (1 MB) and `SLACK_WEBHOOK_BODY_TIMEOUT_MS` (30 s) protections are only applied to the `PassThrough` replay on `mockReq`, which already holds the pre-buffered data by the time `installRequestBodyLimitGuard` runs. This means:
1. **Memory exhaustion**: An attacker can send an arbitrarily large body that is fully buffered before any size check occurs. The guard will eventually trip and send a 413, but the damage (heap spike) has already happened.
2. **Timeout bypass**: A slow/stalling client can hold the HTTP connection open indefinitely during `readRequestBody`. The 30 s timeout on `mockReq` fires almost immediately (the PassThrough stream drains instantly), so it offers no protection against sluggish senders.
The pre-read is necessary to inspect the `type` field before Bolt's signature verification runs. The fix is to apply the limit guard *during* the initial body read instead of re-applying it to the replay stream:
```typescript
// Read with the same limits as the guard, then decide what to do
let rawBody: string;
try {
rawBody = await readRequestBodyWithLimit(req, {
maxBytes: SLACK_WEBHOOK_MAX_BODY_BYTES,
timeoutMs: SLACK_WEBHOOK_BODY_TIMEOUT_MS,
});
} catch (err) {
if (isRequestBodyLimitError(err)) {
// send the appropriate 4xx and return
...
}
throw err;
}
```
(Both `readRequestBodyWithLimit` and `isRequestBodyLimitError` are already exported from `../../infra/http-body.js`.)
How can I resolve this? If you propose a fix, please make it concise.| const mockReq = Object.create(req); | ||
| mockReq.headers = { ...req.headers }; | ||
| mockReq.method = req.method; | ||
| mockReq.url = req.url; | ||
| mockReq.socket = req.socket; | ||
|
|
||
| // Replace the stream with our pre-loaded one | ||
| mockReq.read = passThrough.read.bind(passThrough); | ||
| mockReq.on = passThrough.on.bind(passThrough); | ||
| mockReq.removeListener = passThrough.removeListener.bind(passThrough); | ||
| mockReq.emit = passThrough.emit.bind(passThrough); |
There was a problem hiding this comment.
Incomplete stream proxy may silently break with Bolt upgrades
mockReq overrides on, read, removeListener, and emit to delegate to passThrough, but all other Readable/IncomingMessage stream properties and methods still resolve via prototype to the original, already-consumed req. Notably:
mockReq.readableEnded(a getter that readsthis._readableState.endEmitted) will returntruebecausereq._readableState.endEmittedistrueafterreadRequestBodydrained the socket. If@slack/bolt'sHTTPReceiveror a middleware (e.g.,raw-body) checksreq.readableEndedbefore attaching a'data'listener, it may treat the body as already consumed and skip reading — even thoughpassThroughstill has the buffered data.readable,readableLength,readableHighWaterMark,_readableState,resume(),pause(), anddestroy()all inherit fromreq, not frompassThrough.
A safer approach is to construct mockReq from passThrough directly and copy only the HTTP-level properties from req, or use an explicit wrapper class. This avoids cross-stream property leakage and makes the proxy contract explicit.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/slack/monitor/provider.ts
Line: 258-268
Comment:
**Incomplete stream proxy may silently break with Bolt upgrades**
`mockReq` overrides `on`, `read`, `removeListener`, and `emit` to delegate to `passThrough`, but all other `Readable`/`IncomingMessage` stream properties and methods still resolve via prototype to the original, already-consumed `req`. Notably:
- **`mockReq.readableEnded`** (a getter that reads `this._readableState.endEmitted`) will return `true` because `req._readableState.endEmitted` is `true` after `readRequestBody` drained the socket. If `@slack/bolt`'s `HTTPReceiver` or a middleware (e.g., `raw-body`) checks `req.readableEnded` before attaching a `'data'` listener, it may treat the body as already consumed and skip reading — even though `passThrough` still has the buffered data.
- `readable`, `readableLength`, `readableHighWaterMark`, `_readableState`, `resume()`, `pause()`, and `destroy()` all inherit from `req`, not from `passThrough`.
A safer approach is to construct `mockReq` from `passThrough` directly and copy only the HTTP-level properties from `req`, or use an explicit wrapper class. This avoids cross-stream property leakage and makes the proxy contract explicit.
How can I resolve this? If you propose a fix, please make it concise.| } finally { | ||
| await fs.rm(tmp, { force: true }).catch(() => undefined); | ||
| } | ||
| throw lastError; |
There was a problem hiding this comment.
Unreachable throw lastError after retry loop
The for loop always exits via either return (on success) or throw err (inside the catch block, on non-retryable errors or the final attempt). It never exhausts the iteration counter without throwing, so this throw lastError is dead code. Additionally, lastError is typed as Error | undefined, so if this line were somehow reached it would throw undefined.
Consider removing the trailing throw or replacing the loop with a pattern that makes the termination condition more explicit:
| throw lastError; | |
| } | |
| // loop always returns or throws above; this line is unreachable |
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/infra/json-files.ts
Line: 80
Comment:
**Unreachable `throw lastError` after retry loop**
The `for` loop always exits via either `return` (on success) or `throw err` (inside the `catch` block, on non-retryable errors or the final attempt). It never exhausts the iteration counter without throwing, so this `throw lastError` is dead code. Additionally, `lastError` is typed as `Error | undefined`, so if this line were somehow reached it would throw `undefined`.
Consider removing the trailing `throw` or replacing the loop with a pattern that makes the termination condition more explicit:
```suggestion
}
// loop always returns or throws above; this line is unreachable
```
How can I resolve this? If you propose a fix, please make it concise.|
Review: The core fix is correct — registering the webhook route before the async server-info fetch eliminates the 404 race window during restarts. Good reasoning in the comments. Issues:
The BlueBubbles webhook reordering and the |
Summary
monitorBlueBubblesProviderwas callingfetchBlueBubblesServerInfo(up to 5 s timeout) before registering the webhook HTTP route viaregisterBlueBubblesWebhookTarget. This created a gap on every startup and after every crash-loop restart where the plugin HTTP registry had no entry for/bluebubbles-webhook, so the request fell through all gateway stages and returned404 Not Found.Changes
extensions/bluebubbles/src/monitor.ts— MoveregisterBlueBubblesWebhookTargetto the top ofmonitorBlueBubblesProvider, before the asyncfetchBlueBubblesServerInfocall. The webhook route is now reachable immediately when the provider starts or restarts; server info is still fetched and cached for macOS version detection.src/gateway/server-channels.ts— Fix off-by-one in crash-loop restart accounting: when the loop hitMAX_RESTART_ATTEMPTS + 1it was settingreconnectAttempts = MAX + 1(e.g. 11) instead ofMAX(10). Now caps the reported value atMAX_RESTART_ATTEMPTS.Testing
extensions/bluebubbles/src/monitor.test.ts— 60 tests pass (no regressions in webhook parsing, auth, and routing)extensions/bluebubbles/src/monitor.webhook-route.test.ts— 1 test passesextensions/bluebubbles/src/monitor.webhook-auth.test.ts— 16 tests passsrc/gateway/server-channels.test.ts— all 4 tests pass (including the previously-failing "caps crash-loop restarts after max attempts")src/gateway/server.plugin-http-auth.test.ts— 21 tests passsrc/gateway/control-ui-routing.test.ts— 16 tests passFixes #45620