openclaw - ✅(Solved) Fix Hot-enabling channels.whatsapp.enabled silently reverts to false when WhatsApp plugin is not loaded [1 pull requests, 1 comments, 2 participants]

Official PRs (…)
ON THIS PAGE

Recommended Tools

×6

Utilities matched from this issue’s tags and category — try them while you read without losing context.

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…
GitHub stats
openclaw/openclaw#78404Fetched 2026-05-07 03:37:20
View on GitHub
Comments
1
Participants
2
Timeline
6
Reactions
2
Timeline (top)
mentioned ×2subscribed ×2commented ×1cross-referenced ×1

Error Message

Related but distinct: #77508 (missing/disabled channel preflight error wording) and #70333 (auto-enable writes enabled keys back, causing churn). Neither covers the symmetric case here, where an external channels.whatsapp.enabled=true write is silently reverted while the WhatsApp plugin is absent from the running registry. The wedge is silent. No error, no warn log, no signal that the integrator is using the wrong pattern. The only escape is a full container restart with enabled=true already on disk at cold boot. 2. Refuse or warn on unsupported hot-enable. When the config watcher sees channels.whatsapp.enabled: false -> true but the WhatsApp plugin is absent from the running registry and channels.whatsapp is a no-op reload prefix, return a clear warning/restart requirement instead of silently accepting a no-op.

Root Cause

One trigger is verified, and one write-origin hypothesis remains open.

Fix Action

Fix / Workaround

Important nuance verified against current source: resolvePersistCandidateForWrite() does preserve an externally edited source value if the later nextConfig does not touch that path. If both inputs to createMergePatch() reflect the post-orchestrator disk state, the patch should be empty for channels.whatsapp.enabled. The demotion therefore requires either a later gateway write whose nextConfig already carries stale channels.whatsapp.enabled=false, or an untraced normalization/write path that reintroduces that stale value before persistence. That matches the observed audit trail boundary: the demotion appears alongside gateway-side config writes, but source review alone has not identified which writer first supplies the false value.

Workaround we shipped

  • extensions/whatsapp/src/shared.ts:222 — noopPrefixes opt-out (commit ba79d903137, 2026-03-17).
  • extensions/whatsapp/src/shared.ts:227isEnabled predicate gating channel runtime startup.
  • src/config/plugin-auto-enable.shared.ts:703-732disableImplicitPreferredOverPlugin writes plugins.entries.<id>.enabled=false, not channels.<id>.enabled=false.
  • src/config/plugin-auto-enable.shared.ts:734-744isBuiltInChannelAlreadyEnabled short-circuit predicate.
  • src/config/plugin-auto-enable.shared.ts:768-810registerPluginEntry only enables built-in channels.
  • src/config/plugin-auto-enable.shared.ts:829-875materializeConfiguredPluginEntryAllowlist can add plugin allowlist entries; it is not a channel-disable writer.
  • src/config/plugin-auto-enable.shared.ts:903-981materializePluginAutoEnableCandidatesInternal candidate loop.
  • src/config/io.write-prepare.ts:16-46, 256-275createMergePatch / resolvePersistCandidateForWrite; originally hypothesized as the demotion site, but only causal if nextConfig already carries stale enabled=false or another path reintroduces it before persistence.
  • src/config/io.ts:1970-2035, 2157-2183, 2429-2505 — gateway config write path, audit record creation, runtime-derived source projection, and write notifications.
  • src/config/io.audit.ts:134-141, 344-460 — config-audit JSONL infrastructure that proves a gateway-side write boundary but not, by itself, which caller supplied the false value.
  • src/config/runtime-snapshot.ts:132-145, 266-304 — pinned runtime/source snapshot state that later writes can use.
  • src/gateway/server-startup-config.ts:83-119 — startup auto-enable persistence path.
  • src/gateway/server.impl.ts:610-635 — control UI allowed-origin seeding candidate write path observed near the production size oscillation.
  • src/gateway/server-startup-config.ts:241-295, 371-384 — gateway auth/bootstrap override paths that can feed startup config writes.
  • src/gateway/server-methods/config-write-flow.ts:216-230 — RPC/control-plane config write path candidate.
  • src/cli/gateway-cli/run-loop.ts:494, 520 — SIGUSR1 listener and params.start() that re-runs the pipeline.
  • src/gateway/server-methods/channels.ts:368-405channels.start RPC handler that returns INVALID_REQUEST: invalid channels.start channel when the plugin isn't registered.
  • aa76cf43f0 — upstream commit adding the channels.start RPC the CLI calls after pairing.

PR fix notes

PR #78414: gateway: restart when config enables an unloaded channel plugin

Description (problem / solution / changelog)

Summary

  • Detect channels.<id>.enabled activation transitions during config reload and queue a gateway restart when the corresponding channel plugin is not loaded in the running registry.
  • Closes the silent-wedge described in #78404, where channels.whatsapp.enabled=true written to disk while WhatsApp is absent from the plugin registry was treated as a no-op (because of the WhatsApp plugin's noopPrefixes: ["channels.whatsapp"] opt-out) and channels.start then failed.
  • Behavior is conservative: only restart on false/missing -> true transitions for unloaded plugins, and respect existing reload opt-outs (gateway.reload.mode="off", afterWrite: { mode: "none" }).

Fixes #78404.

Bug / behavior fixed

External integrators that flip channels.<id>.enabled from false to true on a running gateway today get:

  1. The config watcher observes channels.whatsapp.*.
  2. WhatsApp's noopPrefixes makes the change a hot/no-op reload — the plugin registry is not rebuilt.
  3. The running registry still lacks the WhatsApp channel plugin.
  4. gateway.channels.start { channel: "whatsapp" } fails with INVALID_REQUEST: invalid channels.start channel.
  5. Subsequent gateway-side config writes can persist the stale runtime view (enabled=false) back over disk, making it look like silent demotion.

After this PR:

  • false -> true activation while plugin is unloaded → gateway logs a warning and schedules a restart.
  • After restart, startup plugin discovery sees channels.whatsapp.enabled=true, loads WhatsApp, and channels.start resolves the channel plugin.

Why not full hot-load

For loaded channel plugins (e.g., Telegram with configPrefixes: ["channels.telegram"]), hot reload already works and is unchanged. This PR only addresses the case where the config edit changes the startup plugin set — a plugin that wasn't in the cold-boot registry. Adding a plugin to the live registry would touch plugin startup discovery, gateway method registration, plugin services, and channel lifecycle in one change. Restart-on-activation is the smallest safe behavior fix; full dynamic hot-load can come as a follow-up.

Implementation

src/gateway/config-reload.ts:

  • New collectEnabledUnloadedChannelPlugins({ previousConfig, nextConfig }) walks nextConfig.channels, lowercases keys, and returns sorted channel ids whose enabled flipped to true while getLoadedChannelPlugin(channelId) returns undefined.
  • New guard inside applySnapshot() runs after writer opt-outs (followUp.mode === "none"), the noop-plan early return, and settings.mode === "off". If the guard fires, it appends a human-readable reason ("channels.<id> activation requires restart (<id> plugin not loaded)") to plan.restartReasons, merges in followUp.reason if the writer also asked for restart, logs a warning, and calls queueRestart().
  • Helper addRestartReasons reused by both the new guard and the existing followUp.requiresRestart branch.

Precedence (top wins):

  1. followUp.mode === "none" → return (writer opt-out)
  2. isNoopReloadPlan(plan) && !followUp.requiresRestart → return
  3. settings.mode === "off" → return (reload disabled)
  4. NEW unloaded-channel activation → restart
  5. followUp.requiresRestart → restart
  6. settings.mode === "restart" → restart
  7. plan.restartGateway → in mode=hot log "hot mode ignoring", else restart
  8. else hot reload

Tests

src/gateway/config-reload.test.ts — 8 new cases covering: unloaded activation queues restart; gateway.reload.mode="off" suppresses the guard; missing previous channel block + next enabled=true triggers the guard; loaded channel activation does NOT force restart; non-activation edits (e.g., allowFrom change) do not trigger the guard; disable transition (true → false) does not trigger the guard; multiple unloaded activations are reported in deterministic sorted order; in-process writer-requested restart reason is preserved alongside the unloaded-channel reason.

Real behavior proof

Behavior or issue addressed: Hot-enable of channels.whatsapp.enabled: false → true on a running gateway whose plugin registry does not include WhatsApp at cold boot. Without this fix, the change was classified as a channels.whatsapp no-op reload (per the plugin's noopPrefixes) and the gateway never loaded WhatsApp; subsequent channels.start calls failed. Filed as #78404.

Real environment tested: Local gateway run on macOS (Darwin 25.3.0, Node 24.7.0) using the openclaw CLI built from this branch (commit 8ec3c8a, OpenClaw 2026.5.4). Isolated HOME=/tmp/oc-proof-XXXX so the run did not touch the real openclaw state directory; gateway bound to port 29789 instead of the default 18789; no production credentials, no provider keys, no real WhatsApp account.

Exact steps or command run after this patch:

  1. git checkout fix/restart-on-unloaded-channel-enable && pnpm install
  2. mkdir -p /tmp/oc-proof-3e7h/.openclaw and write openclaw.json containing gateway.mode=local, gateway.reload.mode=hot, channels.telegram.enabled=false (no channels.whatsapp block at all).
  3. Start gateway in background: HOME=/tmp/oc-proof-3e7h OPENCLAW_GATEWAY_PORT=29789 pnpm openclaw gateway run --port 29789 --verbose --ws-log compact > gateway.log 2>&1 &
  4. Confirm boot plugin list does NOT include whatsapp.
  5. Externally write channels.whatsapp = { enabled: true, selfChatMode: true, dmPolicy: "allowlist", allowFrom: ["placeholder"] } via a plain fs.writeFileSync (the same shape downstream integrators use).
  6. Tail gateway.log and confirm the new guard fires, the gateway logs the unloaded-channel warning, and exits via SIGUSR1 to its supervisor.
  7. Restart the gateway with the same config. Confirm whatsapp is now in the boot plugin list.

Evidence after fix: live terminal output captured from the gateway log file at /tmp/oc-proof-3e7h/gateway.log and gateway-after-restart.log during the steps above. ANSI colour codes stripped, host names redacted to <host>.

Pre-write boot, before the activation flip — whatsapp absent from the plugin list:

2026-05-06T19:01:54.237+08:00 [plugins] loaded 8 plugin(s) (8 attempted) in 224.6ms
2026-05-06T19:01:54.284+08:00 [gateway] http server listening (8 plugins: acpx, bonjour, browser, device-pair, file-transfer, memory-core, phone-control, talk-voice; 3.2s)
2026-05-06T19:01:54.606+08:00 [gateway] ready

External write of channels.whatsapp.enabled=true then triggers the new guard log line and the SIGUSR1 restart path:

2026-05-06T19:03:06.536+08:00 [reload] config change detected; evaluating reload (channels.whatsapp)
2026-05-06T19:03:06.538+08:00 [reload] config change enables unloaded channel plugin(s): whatsapp; scheduling gateway restart
2026-05-06T19:03:06.539+08:00 [reload] config change requires gateway restart (channels.whatsapp, channels.whatsapp activation requires restart (whatsapp plugin not loaded))
2026-05-06T19:03:06.541+08:00 [gateway] signal SIGUSR1 received
2026-05-06T19:03:06.547+08:00 [gateway] received SIGUSR1; restarting
2026-05-06T19:03:06.548+08:00 [plugins] [hooks] running gateway_stop (1 handlers)
2026-05-06T19:03:06.562+08:00 [shutdown] started: gateway restarting
2026-05-06T19:03:06.608+08:00 [shutdown] completed cleanly in 48ms
2026-05-06T19:03:06.610+08:00 [gateway] restart mode: full process restart (supervisor restart)

Post-restart boot, with whatsapp now in the loaded plugin list:

2026-05-06T19:04:30.402+08:00 [plugins] loaded 9 plugin(s) (9 attempted) in 856.8ms
2026-05-06T19:04:30.445+08:00 [gateway] http server listening (9 plugins: acpx, bonjour, browser, device-pair, file-transfer, memory-core, phone-control, talk-voice, whatsapp; 2.0s)

Observed result after fix: the activation transition was detected, the new warning config change enables unloaded channel plugin(s): whatsapp; scheduling gateway restart was logged, the gateway restarted via SIGUSR1, and the post-restart plugin list grew from 8 plugins to 9 plugins (… whatsapp). End-to-end behaviour matches the expected fix described in the issue.

What was not tested: end-to-end pairing through web.login.start / gateway.channels.start against a real WhatsApp account — this run had no Baileys credentials and confirmed only the activation-restart path. Production deployment to a wedged downstream bot was also not part of this run; the fix can be deployed and verified against real gateway.channels.start traffic in a follow-up if the maintainers want that proof too.

Verification commands

pnpm test src/gateway/config-reload.test.ts
pnpm exec oxfmt --check --threads=1 src/gateway/config-reload.ts src/gateway/config-reload.test.ts CHANGELOG.md

Both green locally on the branch.

Compatibility

This changes behavior for a narrow config transition: enabling a channel whose plugin is not loaded now restarts the gateway. That is intentional because the config change alters plugin registry membership. Existing hot reload behavior for loaded plugins is unchanged.

Explicit reload opt-outs remain authoritative. gateway.reload.mode="off" still skips live reload/restart handling, including unloaded-channel activation. In-process writers that declare afterWrite: { mode: "none" } keep their explicit no-follow-up behavior; external file edits from the watcher do not carry that opt-out and use the new guard.

Risk

  • A live config edit that previously appeared to be no-op now restarts the gateway.
  • If startup plugin discovery itself fails to load the activated channel after restart (for example, missing dependency or runtime error), the gateway boots without that plugin and the channel remains unregistered.

Mitigation

  • The restart only fires on an activation transition to enabled=true for an unloaded plugin.
  • The warning explains why restart is required.
  • Users who need zero restart can pre-enable the channel at cold boot.
  • Post-restart with the activation already on disk, subsequent config-file events with unchanged content yield an empty diffConfigPaths and applySnapshot returns early, so a failed plugin load does not trigger a restart loop. Repeated rapid toggles are deduped within a single gateway lifetime by the existing restartQueued flag.

Alternatives considered

  • Full dynamic hot-load: re-run startup plugin discovery, register gateway methods, start plugin services, and start the channel without restarting. More seamless but touches multiple hot runtime boundaries at once. Better as a follow-up design PR.
  • Warning-only: only warn (or add a config-audit suspicious tag) when channels.<id>.enabled=true is later demoted. Useful instrumentation, but does not fix the user-visible wedge.
  • Startup-only validation: fail startup if config says channels.<id>.enabled=true but the plugin will not load. Loud, but does not fix the live edit path and may be too strict for configs with historical drift.

AI-assisted: yes. I reviewed the implementation, tests, and behavior matrix and understand the change.

Changed files

  • CHANGELOG.md (modified, +1/-0)
  • src/gateway/config-reload.test.ts (modified, +288/-1)
  • src/gateway/config-reload.ts (modified, +70/-8)

Code Example

cfg.channels.whatsapp = {
  enabled: true,                    // <-- the flip
  selfChatMode: true,
  dmPolicy: "allowlist",
  allowFrom: ["placeholder"],
};
writeFileSync(configPath, JSON.stringify(cfg, null, 2));

---

35c35
<       "enabled": true,        ← orchestrator wrote
---
>       "enabled": false,       ← gateway flipped it back; other 3 keys preserved
195c195
<     "lastTouchedAt": "2026-05-04T13:35:15.932Z"
---
>     "lastTouchedAt": "2026-05-04T13:42:16.461Z"

---

{"ts":"2026-05-04T13:32:30.947Z","pid":14,"argv":["/usr/local/bin/node","/app/openclaw.mjs","gateway"],"actor":"config-io","event":"config.write","previousBytes":4818,"nextBytes":4693,"result":"rename","previousHash":"17f0...","nextHash":"c3e3..."}
{"ts":"2026-05-04T13:32:35.537Z","pid":14,"argv":["/usr/local/bin/node","/app/openclaw.mjs","gateway"],"actor":"config-io","event":"config.write","previousBytes":4693,"nextBytes":4820,"result":"rename"}
... (7 more) ...

---

[ws] ⇄ res ✗ channels.start ... errorCode=INVALID_REQUEST errorMessage=invalid channels.start channel

---

reload: { configPrefixes: ["web"], noopPrefixes: ["channels.whatsapp"] },
gatewayMethods: ["web.login.start", "web.login.wait"],
RAW_BUFFERClick to expand / collapse

Affects: OpenClaw v2026.5.2 (also reproducible on v2026.5.4-beta.1; relevant code paths unchanged in v2026.5.4 release, though we have not separately re-verified the demotion on v2026.5.4). Related but distinct: #77508 (missing/disabled channel preflight error wording) and #70333 (auto-enable writes enabled keys back, causing churn). Neither covers the symmetric case here, where an external channels.whatsapp.enabled=true write is silently reverted while the WhatsApp plugin is absent from the running registry.

TL;DR

The OpenClaw gateway can silently ignore or overwrite externally-written channels.whatsapp.enabled=true when the WhatsApp plugin isn't already in the running gateway's plugin registry. Combined with the WhatsApp plugin's intentional noopPrefixes: ["channels.whatsapp"] opt-out at extensions/whatsapp/src/shared.ts:222, downstream integrators that try to enable WhatsApp at runtime get an unrecoverable wedge: their disk write reads enabled=true momentarily, the plugin never enters the registry, and a later gateway config write can persist the stale runtime view (enabled=false) back over disk. gateway.channels.start then fails because the channel plugin is still absent.

The wedge is silent. No error, no warn log, no signal that the integrator is using the wrong pattern. The only escape is a full container restart with enabled=true already on disk at cold boot.

Repro on a production fleet

Setup

  • A test bot deployed as Telegram-only on v2026.5.2: cold-boot config (/data/deployments/<id>/openclaw.json) had channels.whatsapp.enabled=false.
  • The gateway's container PID 14 is /app/openclaw.mjs gateway.

Action

User clicks "Link WhatsApp" in our dashboard. An external orchestrator (a sidecar process that owns the deployed config file) writes the config:

cfg.channels.whatsapp = {
  enabled: true,                    // <-- the flip
  selfChatMode: true,
  dmPolicy: "allowlist",
  allowFrom: ["placeholder"],
};
writeFileSync(configPath, JSON.stringify(cfg, null, 2));

This is a plain writeFileSync (truncate + rewrite). chokidar in the gateway picks it up.

What we expected

The gateway reload pipeline observes channels.whatsapp.enabled=true, loads the WhatsApp plugin into the registry on the next iteration of run-loop.ts, and the channel runtime starts (or stays idle if no creds). Subsequent web.login.start / channels.start RPCs work end-to-end.

What actually happens

Five SIGUSR1-keyed reload events fire on the test bot between 13:22 and 13:42 UTC. After each one, the gateway emits a config.write audit entry and the file mtime advances. The disk content alternates between two shapes (4693 ↔ 4820 bytes; the +127 byte oscillation is the gateway.controlUi.allowedOrigins block being added/removed — separate quirk).

On every reload the gateway reverts channels.whatsapp.enabled from true back to false. The other 3 keys are preserved verbatim.

Direct content diff between the orchestrator's last write (4818 bytes) and the gateway's subsequent rewrite (4820 bytes):

35c35
<       "enabled": true,        ← orchestrator wrote
---
>       "enabled": false,       ← gateway flipped it back; other 3 keys preserved
195c195
<     "lastTouchedAt": "2026-05-04T13:35:15.932Z"
---
>     "lastTouchedAt": "2026-05-04T13:42:16.461Z"

Audit log entries from /data/deployments/<id>/logs/config-audit.jsonl (one entry per gateway-side write, 9 total during the click sequence):

{"ts":"2026-05-04T13:32:30.947Z","pid":14,"argv":["/usr/local/bin/node","/app/openclaw.mjs","gateway"],"actor":"config-io","event":"config.write","previousBytes":4818,"nextBytes":4693,"result":"rename","previousHash":"17f0...","nextHash":"c3e3..."}
{"ts":"2026-05-04T13:32:35.537Z","pid":14,"argv":["/usr/local/bin/node","/app/openclaw.mjs","gateway"],"actor":"config-io","event":"config.write","previousBytes":4693,"nextBytes":4820,"result":"rename"}
... (7 more) ...

After the cycle settles, channels.whatsapp.enabled=false is what's persisted. The plugin remains absent from loadPluginLookUpTable's output, the boot line still shows 9 plugins (... telegram) instead of 10 plugins (... telegram, whatsapp), and:

[ws] ⇄ res ✗ channels.start ... errorCode=INVALID_REQUEST errorMessage=invalid channels.start channel

is emitted when the OpenClaw login CLI calls gateway.channels.start { channel: "whatsapp", accountId: "default" } per aa76cf43f0 (fix(whatsapp): stabilize auth state and reconcile local runtime after CLI login).

What does work (the asymmetric comparator)

A comparator bot whose cold-boot disk had channels.whatsapp.enabled=true from deploy time loads the WhatsApp plugin at process start. On that bot, channels.whatsapp.enabled stays true across reloads — no clobber. Boot line 10 plugins (... whatsapp), audit log shows two writes at process start (controlUi seed + auto-enable) and zero subsequent writes over 16+ hours of uptime.

The asymmetry is on cold-boot state, not on runtime behavior. Hot-reloading channels.whatsapp.enabled: false → true on a running gateway is unsupported in v2026.5.2.

Root cause

One trigger is verified, and one write-origin hypothesis remains open.

1. noopPrefixes opt-out at extensions/whatsapp/src/shared.ts:222

reload: { configPrefixes: ["web"], noopPrefixes: ["channels.whatsapp"] },
gatewayMethods: ["web.login.start", "web.login.wait"],

This was introduced in commit ba79d903137e35c4089cd7e98610eb11731ebb0f (2026-03-17, "refactor(whatsapp): share plugin base config") — a refactor commit with no explanatory message. Likely rationale: protect Baileys auth state from churn caused by config-edit-driven reloads. Telegram does NOT opt out (configPrefixes: ["channels.telegram"]), which is why Telegram channel-config edits hot-reload correctly.

Effect: on channels.whatsapp.* writes, the gateway emits [reload] channels.whatsapp.enabled changed then no-ops (src/gateway/server-reload-handlers.ts). Plugin registry is not consulted for re-discovery.

2. Exact demoting write origin remains unverified

The reconcile pass in src/config/plugin-auto-enable.shared.ts enumerates candidates from resolveConfiguredPluginAutoEnableCandidates. For each candidate:

  • isPluginDenied / isPluginExplicitlyDisabled → skip.
  • shouldSkipPreferredPluginAutoEnable → call disableImplicitPreferredOverPlugin (writes plugins.entries.<id>.enabled = false, never channels.<id>.enabled).
  • alreadyEnabled short-circuit (line 953) — for built-ins, checks channels.<id>.enabled === true AND !allowMissing. If both true, skip.
  • Otherwise: registerPluginEntry writes channels.<id>.enabled = true.

registerPluginEntry only enables. disableImplicitPreferredOverPlugin only writes plugin entries, never channel entries. There is no code path in this file that sets channels.<id>.enabled = false.

The clobber comes from a later config-write path, not from the auto-enable candidate loop itself. When the WhatsApp plugin isn't in the running gateway's registry, the external channels.whatsapp.* edit is classified as a no-op reload and does not rebuild the plugin registry. The gateway may still hold a pinned runtime/source config snapshot from cold boot where channels.whatsapp.enabled=false. A subsequent in-process gateway write (for example control UI origin seeding, generated auth/config metadata, startup/reload config persistence, pre-validation normalization, or another managed config write) could build its nextConfig from stale state or otherwise reintroduce enabled=false. When that write is persisted, the on-disk value flips back to false. Subsequent reloads then find enabled=false again, and the cycle repeats.

Important nuance verified against current source: resolvePersistCandidateForWrite() does preserve an externally edited source value if the later nextConfig does not touch that path. If both inputs to createMergePatch() reflect the post-orchestrator disk state, the patch should be empty for channels.whatsapp.enabled. The demotion therefore requires either a later gateway write whose nextConfig already carries stale channels.whatsapp.enabled=false, or an untraced normalization/write path that reintroduces that stale value before persistence. That matches the observed audit trail boundary: the demotion appears alongside gateway-side config writes, but source review alone has not identified which writer first supplies the false value.

Why the enabled=true write succeeds momentarily then loses

The writeFileSync is not atomic — Node's plain truncate-and-rewrite can produce torn states. But that's not the actual problem. The problem is stale runtime/source config ownership: the running gateway does not load the plugin on channels.whatsapp.* edits, and later managed config writes can persist the old runtime snapshot back to disk. Atomicity would not make the plugin enter the registry or make channels.start succeed.

Suggested fixes (any one closes the wedge for downstream integrators)

  1. Make the stale overwrite observable. Add instrumentation/audit at the config-write boundary so the next repro can identify the exact caller and diff that first changes channels.<id>.enabled=true back to false while that channel plugin is not registered.
  2. Refuse or warn on unsupported hot-enable. When the config watcher sees channels.whatsapp.enabled: false -> true but the WhatsApp plugin is absent from the running registry and channels.whatsapp is a no-op reload prefix, return a clear warning/restart requirement instead of silently accepting a no-op.
  3. Document the pattern. Add a section to docs/channels/whatsapp.md describing the noopPrefixes opt-out and the recommended workflow for downstream integrators: write channels.whatsapp.enabled=true before cold boot if they need runtime web.login.start / channels.start, or restart the gateway after changing that flag from false to true.
  4. (Most invasive) Make hot-reload symmetric for built-in channels: when channels.<id>.enabled: false → true is observed at reload time, pull the corresponding plugin into the registry on the same iteration. This would align WhatsApp's behavior with Telegram's configPrefixes: ["channels.telegram"] pattern.

A PR for (2) is in preparation and will follow this issue.

Workaround we shipped

Always set channels.whatsapp.enabled=true in the deployed config regardless of the user's channel selection. The plugin loads at every cold boot, the gateway's applyPluginAutoEnable alreadyEnabled short-circuit at src/config/plugin-auto-enable.shared.ts:953-957 fires on subsequent reloads, and the demotion stops happening. Migration for already-wedged bots: one-time docker restart after the config change lands, so cold-boot state matches the new shape.

Side effect on telegram-only bots: the WhatsApp channel runtime starts at cold boot and idles on 401 (no creds), generating [health-monitor] [whatsapp:default] restarting (reason: stopped) log lines every ~10-15 minutes and pushing Docker State.Health=unhealthy (because /readyz returns 503 with whatsapp in failing[]).

What we tried that didn't work (and why)

  1. plugins.entries.whatsapp = { enabled: true } alone. Source analysis (hasNonDisabledPluginEntry + materializeConfiguredPluginEntryAllowlist) suggested this should preregister the plugin without enabling the channel. Live test: boot line stayed at 9 plugins, no whatsapp. The actual gate at src/plugins/gateway-startup-plugin-ids.ts:355-362 checks hasConfiguredStartupChannel against listPotentialEnabledChannelIds, which only emits whatsapp when channels.whatsapp has a meaningful (non-enabled) key AND enabled=true. plugins.entries is consulted later for the per-plugin gate but doesn't drive built-in channel discovery.

  2. channels.whatsapp.enabled=true + channels.whatsapp.accounts.default.enabled=false. Source said the channel runtime is gated separately by account.enabled && cfg.web?.enabled !== false (extensions/whatsapp/src/shared.ts:227). Live test confirmed that part: plugin loaded (10 plugins), channel runtime stayed idle, no spam. But on a Link click, the CLI's web.login.start paired successfully and gateway.channels.start returned ✓ (2109ms) — yet the channel runtime did NOT actually start. creds.json ended up with registered: false, no [whatsapp] Listening for personal WhatsApp inbound messages, no Baileys process. End-to-end, the bot wasn't actually receiving messages. Suppressing the runtime via accounts.default.enabled=false also suppresses the post-pairing channel-start path.

  3. Container restart on channel_type transition or first Link click. Reliable but heavyweight (~100s wait per Link click + Telegram interruption during the restart). Retired in favor of SIGUSR1 then upstream gateway.channels.start RPC — both of which work only when the plugin is already in the registry. Since pinning enabled=true at deploy guarantees that, restoring the heavyweight restart isn't needed. The "always enable" approach is strictly simpler.

References

  • extensions/whatsapp/src/shared.ts:222 — noopPrefixes opt-out (commit ba79d903137, 2026-03-17).
  • extensions/whatsapp/src/shared.ts:227isEnabled predicate gating channel runtime startup.
  • src/config/plugin-auto-enable.shared.ts:703-732disableImplicitPreferredOverPlugin writes plugins.entries.<id>.enabled=false, not channels.<id>.enabled=false.
  • src/config/plugin-auto-enable.shared.ts:734-744isBuiltInChannelAlreadyEnabled short-circuit predicate.
  • src/config/plugin-auto-enable.shared.ts:768-810registerPluginEntry only enables built-in channels.
  • src/config/plugin-auto-enable.shared.ts:829-875materializeConfiguredPluginEntryAllowlist can add plugin allowlist entries; it is not a channel-disable writer.
  • src/config/plugin-auto-enable.shared.ts:903-981materializePluginAutoEnableCandidatesInternal candidate loop.
  • src/config/io.write-prepare.ts:16-46, 256-275createMergePatch / resolvePersistCandidateForWrite; originally hypothesized as the demotion site, but only causal if nextConfig already carries stale enabled=false or another path reintroduces it before persistence.
  • src/config/io.ts:1970-2035, 2157-2183, 2429-2505 — gateway config write path, audit record creation, runtime-derived source projection, and write notifications.
  • src/config/io.audit.ts:134-141, 344-460 — config-audit JSONL infrastructure that proves a gateway-side write boundary but not, by itself, which caller supplied the false value.
  • src/config/runtime-snapshot.ts:132-145, 266-304 — pinned runtime/source snapshot state that later writes can use.
  • src/gateway/server-startup-config.ts:83-119 — startup auto-enable persistence path.
  • src/gateway/server.impl.ts:610-635 — control UI allowed-origin seeding candidate write path observed near the production size oscillation.
  • src/gateway/server-startup-config.ts:241-295, 371-384 — gateway auth/bootstrap override paths that can feed startup config writes.
  • src/gateway/server-methods/config-write-flow.ts:216-230 — RPC/control-plane config write path candidate.
  • src/cli/gateway-cli/run-loop.ts:494, 520 — SIGUSR1 listener and params.start() that re-runs the pipeline.
  • src/gateway/server-methods/channels.ts:368-405channels.start RPC handler that returns INVALID_REQUEST: invalid channels.start channel when the plugin isn't registered.
  • aa76cf43f0 — upstream commit adding the channels.start RPC the CLI calls after pairing.

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING

openclaw - ✅(Solved) Fix Hot-enabling channels.whatsapp.enabled silently reverts to false when WhatsApp plugin is not loaded [1 pull requests, 1 comments, 2 participants]