openclaw - ✅(Solved) Fix [Bug]: WhatsApp credentials leak across `--profile` boundaries [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#64555Fetched 2026-04-11 06:14:26
View on GitHub
Comments
1
Participants
2
Timeline
5
Reactions
0
Author
Timeline (top)
labeled ×2commented ×1cross-referenced ×1referenced ×1

When running OpenClaw with --profile <name> to create an isolated profile directory (~/.openclaw-<name>/), the WhatsApp plugin writes Baileys credentials to the main gateway's ~/.openclaw/credentials/whatsapp/default/ directory instead of the profile-isolated ~/.openclaw-<name>/credentials/whatsapp/default/.

This breaks the isolation guarantee that --profile is supposed to provide, and in a multi-tenant SaaS deployment (the documented "Multiple Gateways with Isolated Profiles" pattern) it can cause:

  1. Session bleeding — one tenant's paired WhatsApp session becomes visible to another tenant that gets provisioned later.
  2. Ghost "already linked" stateweb.login.start returns "WhatsApp is already linked (+<number>)" on a freshly-provisioned profile because the Baileys runtime reads from the main credentials directory and finds a stale creds.json there.
  3. Stream 440 conflict storms — when both the main gateway and an isolated profile gateway are running, they both try to use the same creds.json and fight each other on the WhatsApp Web WebSocket, producing an endless loop of status=440 Unknown Stream Errored (conflict) and restored corrupted WhatsApp creds.json from backup warnings.

Error Message

{"module":"web-session","credsPath":"/home/openclaw/.openclaw/credentials/whatsapp/default/creds.json","msg":"restored corrupted WhatsApp creds.json from backup"} {"module":"web-reconnect","status":440,"error":"status=440 Unknown Stream Errored (conflict)"} {"module":"web-inbound","error":"Error: rate-overlimit"}

Root Cause

  1. Session bleeding — one tenant's paired WhatsApp session becomes visible to another tenant that gets provisioned later.
  2. Ghost "already linked" stateweb.login.start returns "WhatsApp is already linked (+<number>)" on a freshly-provisioned profile because the Baileys runtime reads from the main credentials directory and finds a stale creds.json there.
  3. Stream 440 conflict storms — when both the main gateway and an isolated profile gateway are running, they both try to use the same creds.json and fight each other on the WhatsApp Web WebSocket, producing an endless loop of status=440 Unknown Stream Errored (conflict) and restored corrupted WhatsApp creds.json from backup warnings.

Fix Action

Workaround

Setting OPENCLAW_OAUTH_DIR explicitly in the profile's systemd unit file forces the path regardless of module init order:

[Service] Environment=OPENCLAW_STATE_DIR=/home/openclaw/.openclaw-<profile> Environment=OPENCLAW_OAUTH_DIR=/home/openclaw/.openclaw-<profile>/credentials Environment=OPENCLAW_CONFIG_PATH=/home/openclaw/.openclaw-<profile>/openclaw.json

After systemctl --user daemon-reload and restart, creds.json lands in the profile dir and stays isolated. Verified: the WhatsApp session pairs cleanly via openclaw --profile <profile> channels login and all 800+ Baileys state files end up under ~/.openclaw-<profile>/credentials/whatsapp/default/.

PR fix notes

PR #64563: fix(whatsapp): lazy default auth dir for profile state (#64555)

Description (problem / solution / changelog)

Summary

  • Problem: The bundled WhatsApp extension exposed WA_WEB_AUTH_DIR as a value computed at module load. If the light runtime loaded before OPENCLAW_STATE_DIR / --profile env was authoritative, Baileys creds could resolve under the default state dir instead of the active profile dir (see #64555).
  • Why it matters: Multi-tenant and --profile deployments rely on isolated credential paths; writing or reading the wrong creds.json breaks isolation and can cause 440 conflict loops when two gateways share one store.
  • What changed: Resolve the default web auth directory lazily (same coercion pattern as LazyWebChannelAuthDir in src/channel-web.ts) and route OAuth root resolution in accounts.ts through a single helper that calls resolveOAuthDir with the process env explicitly.
  • What did NOT change: No new env vars, no doctor/docs-only changes, and no behavior change for callers that already pass explicit authDir paths.

Change Type (select all)

  • Bug fix
  • Feature
  • Refactor required for the fix
  • Docs
  • Security hardening
  • Chore/infra

Scope (select all touched areas)

  • Gateway / orchestration
  • Skills / tool execution
  • Auth / tokens
  • Memory / storage
  • Integrations
  • API / contracts
  • UI / DX
  • CI/CD / infra

Linked Issue/PR

  • Closes #64555
  • Related #
  • This PR fixes a bug or regression

Root Cause (if applicable)

  • Root cause: WA_WEB_AUTH_DIR was initialized eagerly at module evaluation time via resolveDefaultWebAuthDir(), so the first resolved path could be pinned before profile/state env was guaranteed for that load sequence.
  • Missing detection / guardrail: No unit test asserted that the exported default path stayed under OPENCLAW_STATE_DIR after a fresh import with env set.
  • Contributing context (if known): Core already lazy-resolves the web channel auth dir in src/channel-web.ts; the plugin export remained eager.

Regression Test Plan (if applicable)

  • Coverage level that should have caught this:
    • Unit test
    • Seam / integration test
    • End-to-end test
    • Existing coverage already sufficient
  • Target test or file: extensions/whatsapp/src/auth-store.lazy-dir.test.ts
  • Scenario the test should lock in: After OPENCLAW_STATE_DIR is set, a fresh dynamic import of auth-store yields String(WA_WEB_AUTH_DIR) under that state dir, and listWhatsAppAuthDirs includes the same root.
  • Why this is the smallest reliable guardrail: It fails if the export becomes eager again or if accounts diverges from the OAuth root used for the default web dir.
  • Existing test that already covers this (if any): extensions/whatsapp/src/accounts.whatsapp-auth.test.ts (OAuth override via OPENCLAW_OAUTH_DIR).
  • If no new test is added, why not: N/A (tests added).

User-visible / Behavior Changes

None for correctly configured single-profile installs. Profile and OPENCLAW_STATE_DIR isolation for WhatsApp default auth should match the active env consistently.

Diagram (if applicable)

N/A

Security Impact (required)

  • New permissions/capabilities? (No)
  • Secrets/tokens handling changed? (No)
  • New/changed network calls? (No)
  • Command/tool execution surface changed? (No)
  • Data access scope changed? (Yes — credentials are read/written under the correct profile state directory instead of possibly defaulting to the main state dir when the lazy export was wrong.)

Repro + Verification

Environment

  • OS: macOS / Linux (CI)
  • Runtime/container: Node 22+
  • Model/provider: N/A
  • Integration/channel (if any): WhatsApp (bundled extension)

Steps

  1. Set OPENCLAW_STATE_DIR to an isolated directory.
  2. Dynamically import extensions/whatsapp/src/auth-store.js (or use gateway after profile env).
  3. Coerce WA_WEB_AUTH_DIR to string and confirm it lives under \$OPENCLAW_STATE_DIR/credentials/whatsapp/default``.

Expected

Default Baileys auth path is under the active state dir.

Actual

Matches expected after this change; covered by new unit tests.

Evidence

  • Failing test/log before + passing after
  • Trace/log snippets
  • Screenshot/recording
  • Perf numbers (if relevant)

Human Verification (required)

  • Verified scenarios: pnpm test on extensions/whatsapp/src/auth-store.lazy-dir.test.ts and accounts.whatsapp-auth.test.ts; pnpm exec oxfmt --check on touched sources.
  • Edge cases checked: OPENCLAW_STATE_DIR isolation path alignment with listWhatsAppAuthDirs.
  • What I did not verify: Full gateway pairing against live WhatsApp (not required for this path-resolution fix).

Review Conversations

  • I replied to or resolved every bot review conversation I addressed in this PR.
  • I left unresolved only the conversations that still need reviewer or maintainer judgment.

Compatibility / Migration

  • Backward compatible? (Yes)
  • Config/env changes? (No)
  • Migration needed? (No)

Risks and Mitigations

  • Risk: Callers relying on typeof WA_WEB_AUTH_DIR === \"string\" could see object at runtime (same pattern as core LazyWebChannelAuthDir).
    • Mitigation: Export remains cast as string for TypeScript; runtime stringification uses String() / coercion like existing web channel surface.

Changed files

  • CHANGELOG.md (modified, +1/-0)
  • extensions/whatsapp/src/accounts.ts (modified, +7/-3)
  • extensions/whatsapp/src/auth-store.lazy-dir.test.ts (added, +52/-0)
  • extensions/whatsapp/src/auth-store.ts (modified, +24/-1)
  • src/channel-web.ts (modified, +7/-1)
  • src/channels/channels-misc.test.ts (modified, +41/-0)

Code Example

# Start from a clean state: no profile dir, no main creds
rm -rf ~/.openclaw-testcase
rm -rf ~/.openclaw/credentials/whatsapp/default

# Create an isolated profile
openclaw --profile testcase onboard \
  --non-interactive --accept-risk --flow quickstart \
  --workspace ~/.openclaw-testcase/workspace \
  --auth-choice openai-api-key --openai-api-key sk-... \
  --gateway-port 18900 --gateway-bind loopback \
  --gateway-auth token --skip-channels --skip-health

# Install and start as a systemd service
openclaw --profile testcase gateway install
openclaw --profile testcase gateway start

# Attempt WhatsApp login via the gateway
openclaw --profile testcase gateway call web.login.start \
  --params '{"accountId":"default"}' --json

# Observe where creds.json landed
find ~/.openclaw-testcase/credentials ~/.openclaw/credentials -name creds.json -printf '%p %s %TY-%Tm-%Td %TT\n' 2>/dev/null

---

$ cat /proc/$(pgrep openclaw-gatewa)/environ | tr '\0' '\n' | grep OPENCLAW
OPENCLAW_STATE_DIR=/home/openclaw/.openclaw-sec-hilton-junior-bee9f2c4
OPENCLAW_CONFIG_PATH=/home/openclaw/.openclaw-sec-hilton-junior-bee9f2c4/openclaw.json
OPENCLAW_PROFILE=sec-hilton-junior-bee9f2c4
...

---

{"module":"web-session","credsPath":"/home/openclaw/.openclaw/credentials/whatsapp/default/creds.json","msg":"restored corrupted WhatsApp creds.json from backup"}
{"module":"web-reconnect","status":440,"error":"status=440 Unknown Stream Errored (conflict)"}
{"module":"web-inbound","error":"Error: rate-overlimit"}

---

## Where the resolution likely goes wrong

Initial investigation points at `extensions/whatsapp/src/accounts.ts:47`:


export function listWhatsAppAuthDirs(cfg: OpenClawConfig): string[] {
  const oauthDir = resolveOAuthDir();   // ← no env arg passed
  ...
}


`resolveOAuthDir()` in `src/config/paths.ts:236` has a default of `process.env`, but if this function is imported and the result memoized at module-load time (before the profile's `OPENCLAW_STATE_DIR` is applied to `process.env` by `applyCliProfileEnv()` at `src/cli/profile.ts:110`), the resolved path will be wrong.

A similar pattern exists in `resolveDefaultAuthDir()` and `resolveLegacyAuthDir()` in the same file:


function resolveDefaultAuthDir(accountId: string): string {
  return path.join(resolveOAuthDir(), "whatsapp", normalizeAccountId(accountId));
}

function resolveLegacyAuthDir(): string {
  return resolveOAuthDir();
}


These do not accept a config/context argument and rely entirely on the ambient environment at call time.

## Workaround

Setting `OPENCLAW_OAUTH_DIR` explicitly in the profile's systemd unit file forces the path regardless of module init order:


[Service]
Environment=OPENCLAW_STATE_DIR=/home/openclaw/.openclaw-<profile>
Environment=OPENCLAW_OAUTH_DIR=/home/openclaw/.openclaw-<profile>/credentials
Environment=OPENCLAW_CONFIG_PATH=/home/openclaw/.openclaw-<profile>/openclaw.json


After `systemctl --user daemon-reload` and restart, `creds.json` lands in the profile dir and stays isolated. Verified: the WhatsApp session pairs cleanly via `openclaw --profile <profile> channels login` and all 800+ Baileys state files end up under `~/.openclaw-<profile>/credentials/whatsapp/default/`.

## Suggested fix

1. **Pass the config/env explicitly.** `listWhatsAppAuthDirs(cfg)`, `resolveDefaultAuthDir(accountId)`, and `resolveLegacyAuthDir()` should accept an explicit `env` or `stateDir` argument and thread it through to `resolveOAuthDir()`, instead of relying on `process.env` at the moment of the (possibly lazy) call.
2. **Avoid top-level caching.** Any module that does `const OAUTH_DIR = resolveOAuthDir()` at load time is a latent isolation bug under `--profile`. These should be deferred or invoked with the runtime env.
3. **Doctor check.** `openclaw --profile <name> doctor` should warn when it detects `credsPath` outside the profile's state dir, pointing at the workaround above until the fix lands.
4. **Consider documenting `OPENCLAW_OAUTH_DIR`** as a supported isolation mechanism so SaaS operators don't have to infer it from source.
RAW_BUFFERClick to expand / collapse

Bug type

Behavior bug (incorrect output/state without crash)

Beta release blocker

No

Summary

Component: extensions/whatsapp (Baileys web provider) + core state dir resolution Severity: High — breaks SaaS isolation; one customer's WhatsApp session can end up in another's profile directory. Version observed: openclaw 2026.4.9 Workaround available: Yes — set OPENCLAW_OAUTH_DIR explicitly in the profile's systemd unit.

Summary

When running OpenClaw with --profile <name> to create an isolated profile directory (~/.openclaw-<name>/), the WhatsApp plugin writes Baileys credentials to the main gateway's ~/.openclaw/credentials/whatsapp/default/ directory instead of the profile-isolated ~/.openclaw-<name>/credentials/whatsapp/default/.

This breaks the isolation guarantee that --profile is supposed to provide, and in a multi-tenant SaaS deployment (the documented "Multiple Gateways with Isolated Profiles" pattern) it can cause:

  1. Session bleeding — one tenant's paired WhatsApp session becomes visible to another tenant that gets provisioned later.
  2. Ghost "already linked" stateweb.login.start returns "WhatsApp is already linked (+<number>)" on a freshly-provisioned profile because the Baileys runtime reads from the main credentials directory and finds a stale creds.json there.
  3. Stream 440 conflict storms — when both the main gateway and an isolated profile gateway are running, they both try to use the same creds.json and fight each other on the WhatsApp Web WebSocket, producing an endless loop of status=440 Unknown Stream Errored (conflict) and restored corrupted WhatsApp creds.json from backup warnings.

Steps to reproduce

# Start from a clean state: no profile dir, no main creds
rm -rf ~/.openclaw-testcase
rm -rf ~/.openclaw/credentials/whatsapp/default

# Create an isolated profile
openclaw --profile testcase onboard \
  --non-interactive --accept-risk --flow quickstart \
  --workspace ~/.openclaw-testcase/workspace \
  --auth-choice openai-api-key --openai-api-key sk-... \
  --gateway-port 18900 --gateway-bind loopback \
  --gateway-auth token --skip-channels --skip-health

# Install and start as a systemd service
openclaw --profile testcase gateway install
openclaw --profile testcase gateway start

# Attempt WhatsApp login via the gateway
openclaw --profile testcase gateway call web.login.start \
  --params '{"accountId":"default"}' --json

# Observe where creds.json landed
find ~/.openclaw-testcase/credentials ~/.openclaw/credentials -name creds.json -printf '%p %s %TY-%Tm-%Td %TT\n' 2>/dev/null

Observed: creds.json (partial Baileys init state — noiseKey, registrationId, etc.) appears in ~/.openclaw/credentials/whatsapp/default/creds.json instead of ~/.openclaw-testcase/credentials/whatsapp/default/creds.json.

Expected: The freshly-created profile dir should receive the credentials, since it's running with OPENCLAW_STATE_DIR=~/.openclaw-testcase/ in its environment (verified via cat /proc/<gateway-pid>/environ).

Expected behavior

Evidence that the environment is set correctly

$ cat /proc/$(pgrep openclaw-gatewa)/environ | tr '\0' '\n' | grep OPENCLAW
OPENCLAW_STATE_DIR=/home/openclaw/.openclaw-sec-hilton-junior-bee9f2c4
OPENCLAW_CONFIG_PATH=/home/openclaw/.openclaw-sec-hilton-junior-bee9f2c4/openclaw.json
OPENCLAW_PROFILE=sec-hilton-junior-bee9f2c4
...

The gateway process has the correct OPENCLAW_STATE_DIR, and resolveStateDir() in src/config/paths.ts:65 does respect it. But something in the WhatsApp plugin's module initialization is caching the resolved oauthDir before the environment override takes effect, or is resolving the path via a code path that never consults the environment.

Actual behavior

Evidence from logs

From /tmp/openclaw/openclaw-2026-04-11.log, produced by the gateway running under profile sec-hilton-junior-bee9f2c4:

{"module":"web-session","credsPath":"/home/openclaw/.openclaw/credentials/whatsapp/default/creds.json","msg":"restored corrupted WhatsApp creds.json from backup"}
{"module":"web-reconnect","status":440,"error":"status=440 Unknown Stream Errored (conflict)"}
{"module":"web-inbound","error":"Error: rate-overlimit"}

The credsPath should be ~/.openclaw-sec-hilton-junior-bee9f2c4/..., but it's the main ~/.openclaw/....

OpenClaw version

2026.4.9

Operating system

Ubuntu 24.04

Install method

npm

Model

openai

Provider / routing chain

none

Additional provider/model setup details

No response

Logs, screenshots, and evidence

## Where the resolution likely goes wrong

Initial investigation points at `extensions/whatsapp/src/accounts.ts:47`:


export function listWhatsAppAuthDirs(cfg: OpenClawConfig): string[] {
  const oauthDir = resolveOAuthDir();   // ← no env arg passed
  ...
}


`resolveOAuthDir()` in `src/config/paths.ts:236` has a default of `process.env`, but if this function is imported and the result memoized at module-load time (before the profile's `OPENCLAW_STATE_DIR` is applied to `process.env` by `applyCliProfileEnv()` at `src/cli/profile.ts:110`), the resolved path will be wrong.

A similar pattern exists in `resolveDefaultAuthDir()` and `resolveLegacyAuthDir()` in the same file:


function resolveDefaultAuthDir(accountId: string): string {
  return path.join(resolveOAuthDir(), "whatsapp", normalizeAccountId(accountId));
}

function resolveLegacyAuthDir(): string {
  return resolveOAuthDir();
}


These do not accept a config/context argument and rely entirely on the ambient environment at call time.

## Workaround

Setting `OPENCLAW_OAUTH_DIR` explicitly in the profile's systemd unit file forces the path regardless of module init order:


[Service]
Environment=OPENCLAW_STATE_DIR=/home/openclaw/.openclaw-<profile>
Environment=OPENCLAW_OAUTH_DIR=/home/openclaw/.openclaw-<profile>/credentials
Environment=OPENCLAW_CONFIG_PATH=/home/openclaw/.openclaw-<profile>/openclaw.json


After `systemctl --user daemon-reload` and restart, `creds.json` lands in the profile dir and stays isolated. Verified: the WhatsApp session pairs cleanly via `openclaw --profile <profile> channels login` and all 800+ Baileys state files end up under `~/.openclaw-<profile>/credentials/whatsapp/default/`.

## Suggested fix

1. **Pass the config/env explicitly.** `listWhatsAppAuthDirs(cfg)`, `resolveDefaultAuthDir(accountId)`, and `resolveLegacyAuthDir()` should accept an explicit `env` or `stateDir` argument and thread it through to `resolveOAuthDir()`, instead of relying on `process.env` at the moment of the (possibly lazy) call.
2. **Avoid top-level caching.** Any module that does `const OAUTH_DIR = resolveOAuthDir()` at load time is a latent isolation bug under `--profile`. These should be deferred or invoked with the runtime env.
3. **Doctor check.** `openclaw --profile <name> doctor` should warn when it detects `credsPath` outside the profile's state dir, pointing at the workaround above until the fix lands.
4. **Consider documenting `OPENCLAW_OAUTH_DIR`** as a supported isolation mechanism so SaaS operators don't have to infer it from source.

Impact and severity

For single-user, single-profile deployments this is invisible (the main profile just writes to its own dir). For multi-tenant SaaS (~/.openclaw-<tenant>/ pattern), it's a hard blocker: any tenant that provisions after another tenant has paired WhatsApp will inherit that tenant's session state, and the gateways of both tenants will fight over the same Baileys credentials on the Meta WebSocket.

Additional information

No response

extent analysis

TL;DR

Set OPENCLAW_OAUTH_DIR explicitly in the profile's systemd unit file to force the correct credentials path for WhatsApp plugin.

Guidance

  • Identify the systemd unit file for the affected profile and add the OPENCLAW_OAUTH_DIR environment variable, pointing it to the profile's credentials directory.
  • Verify that the OPENCLAW_STATE_DIR environment variable is correctly set in the systemd unit file, ensuring it matches the intended profile directory.
  • Consider implementing the suggested fix to pass the config/env explicitly to resolveOAuthDir() and avoid top-level caching to prevent similar issues in the future.
  • Run openclaw --profile <name> doctor to check for any warnings related to credsPath outside the profile's state dir.

Example

# Example systemd unit file modification
[Service]
Environment=OPENCLAW_STATE_DIR=/home/openclaw/.openclaw-<profile>
Environment=OPENCLAW_OAUTH_DIR=/home/openclaw/.openclaw-<profile>/credentials
Environment=OPENCLAW_CONFIG_PATH=/home/openclaw/.openclaw-<profile>/openclaw.json

Notes

This workaround may not be suitable for all deployments, especially those with complex profile management or custom systemd configurations. The suggested fix should be prioritized to ensure proper isolation and prevent session bleeding.

Recommendation

Apply the workaround by setting OPENCLAW_OAUTH_DIR explicitly in the profile's systemd unit file, as this provides an immediate solution to the issue. The suggested fix should be implemented in the long term to prevent similar problems.

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 [Bug]: WhatsApp credentials leak across `--profile` boundaries [1 pull requests, 1 comments, 2 participants]