Skip to content

Hot-enabling channels.whatsapp.enabled silently reverts to false when WhatsApp plugin is not loaded #78404

Description

@sukhdeepjohar

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.

Metadata

Metadata

Assignees

Labels

P1High-priority user-facing bug, regression, or broken workflow.clawsweeper:linked-pr-openClawSweeper found an open linked pull request for this issue.clawsweeper:needs-product-decisionClawSweeper marked this issue as needing a product or behavior decision.clawsweeper:no-new-fix-prClawSweeper does not recommend queueing a new automated fix PR for this issue.clawsweeper:source-reproClawSweeper found a high-confidence source-level issue reproduction.impact:auth-providerAuth, provider routing, model choice, or SecretRef resolution may break.impact:data-lossCan lose, corrupt, or silently drop user/session/config data.impact:message-lossChannel message delivery can be lost, duplicated, or misrouted.issue-rating: 🦞 diamond lobsterVery strong issue quality with high-confidence source-level or clear reproduction.maturity:stableIssue affects a taxonomy feature currently scored M4/M5.

Type

No type

Fields

Priority

None yet

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions