#Docker: Token mismatch: autogenerated token: macOS 26.2
1 messages · Page 1 of 1 (latest)
In the Docker flow the Gateway token usually comes from OPENCLAW_GATEWAY_TOKEN passed via Docker Compose, not from whatever you typed into ~/.openclaw/openclaw.json.
A couple easy ways this ends up “mismatched” even after nuking containers:
1) Compose is not reading the .env you think it is
docker-setup.sh writes the generated token into <repo>/.env and docker-compose.yml injects it via ${OPENCLAW_GATEWAY_TOKEN}.
But Docker Compose only auto-loads .env from the project working dir (and shell env vars override it). So if you run docker compose ... from a different directory (or with -f /path/to/docker-compose.yml), you can accidentally start the gateway with a different token than the CLI container uses.
Verify what Compose is actually using (from the repo root):
cd /path/to/openclaw-repo
# what token is in the repo .env
grep '^OPENCLAW_GATEWAY_TOKEN=' .env
# what docker compose resolves it to (this is the important one)
docker compose config | grep -n 'OPENCLAW_GATEWAY_TOKEN'
Also verify the runtime env inside each container:
docker compose exec openclaw-gateway sh -lc 'echo "$OPENCLAW_GATEWAY_TOKEN"'
docker compose run --rm openclaw-cli sh -lc 'echo "$OPENCLAW_GATEWAY_TOKEN"'
Those two outputs must match exactly.
If they don’t, fix by either:
- always running compose from the repo root, or
- using
--project-directory /path/to/openclaw-repo, or - explicitly unsetting any host override (next point).
2) A host OPENCLAW_GATEWAY_TOKEN env var is overriding the generated one
In docker-setup.sh, the token is only generated if the env var is empty:
if [[ -z "${OPENCLAW_GATEWAY_TOKEN:-}" ]]; then ... generate ...
So if you still have OPENCLAW_GATEWAY_TOKEN exported in your shell from the earlier native install/testing, it will “win”.
Check on your Mac:
env | grep OPENCLAW_GATEWAY_TOKEN
If you see it, do:
unset OPENCLAW_GATEWAY_TOKEN
Then re-run ./docker-setup.sh (or restart your compose stack).
3) Make sure you’re looking at the real config path in the container
With the compose file, the config is mounted at:
/home/node/.openclaw/openclaw.json
If you looked at /home/node/.openclaw.json (no directory), that’s a different file and not what the gateway uses.
About the Control UI “keys mismatch”
Two common causes:
- You’re opening the UI on a LAN IP over plain HTTP (not
127.0.0.1). Browsers treat that as an insecure context and WebCrypto/device identity can break. Try openinghttp://127.0.0.1:18789/from the Mac first. - Or you have stale browser storage (from the previous install). Try an incognito window / clear site data for that origin.
Docs that match this flow:
- Docker: https://docs.openclaw.ai/install/docker
- Control UI / insecure HTTP note: https://docs.openclaw.ai/web/control-ui
If you paste the output of:
docker compose config | grep -n OPENCLAW_GATEWAY_TOKEN
- and the two container
echo "$OPENCLAW_GATEWAY_TOKEN"commands above
…I can tell you exactly where the “other” token is coming from (without you sharing the full token value).
Step 1: value in .env is the same as in ~/.openclaw/openclaw.json and docker compose outputs for echoing OPENCLAW_GATEWAY_TOKEN are the same
Step 2: nothing is set inside the macOS / parent env
Step 3: confirmed that the correct openclaw.json is mounted in the container
Step 4: docker compose config | grep -n OPENCLAW_GATEWAY_TOKEN : outputs the same secret for the gateway and the cli
If Compose config + container env + mounted openclaw.json all agree on the token, then the remaining “token mismatch” cases I’ve seen boil down to one of these:
A) Invisible characters (CRLF / trailing whitespace) in the token
This is very hard to spot with echo, but it will fail safeEqual().
Run these and compare len + repr (they should be identical):
# 1) What the gateway process sees as env
docker compose exec openclaw-gateway python3 - <<'PY'
import os
t=os.environ.get("OPENCLAW_GATEWAY_TOKEN","")
print("gw env len:", len(t))
print("gw env repr:", repr(t))
PY
# 2) What the CLI container sees as env
docker compose run --rm openclaw-cli python3 - <<'PY'
import os
t=os.environ.get("OPENCLAW_GATEWAY_TOKEN","")
print("cli env len:", len(t))
print("cli env repr:", repr(t))
PY
# 3) What config contains (mounted file)
docker compose run --rm openclaw-cli python3 - <<'PY'
import json, pathlib
cfg=json.loads(pathlib.Path("/home/node/.openclaw/openclaw.json").read_text())
t=cfg.get("gateway",{}).get("auth",{}).get("token","")
print("cfg len:", len(t))
print("cfg repr:", repr(t))
PY
If you see '\r' at the end (or any extra spaces), that’s the whole bug.
B) CLI is sending a different token than OPENCLAW_GATEWAY_TOKEN (remote-mode footgun)
Even if env is set, the CLI can end up using gateway.remote.token (if gateway.mode / remote URL is set).
Two quick checks:
- What’s your gateway config block (tokens redacted)?
gateway.modegateway.remote.urlgateway.remote.tokengateway.auth.modegateway.auth.token
- Force the CLI to use exactly the token + URL you intend (bypasses config selection):
docker compose run --rm openclaw-cli gateway status \
--url ws://openclaw-gateway:18789 \
--token "$OPENCLAW_GATEWAY_TOKEN"
If this works, the token itself is fine and we should look specifically at gateway.remote.* vs gateway.auth.* in openclaw.json.
Separate: Control UI “keys mismatch”
That message is usually device identity / WebCrypto context, not the gateway token.
Two things to try:
- Make sure you open
http://127.0.0.1:18789/(localhost is a secure context; LAN IP is not). - Clear site data for the dashboard origin (localStorage) and reload.
If after that you see “pairing required (1008)”, approve it via:
docker compose run --rm openclaw-cli devices list
docker compose run --rm openclaw-cli devices approve <requestId>
If you paste the len/repr outputs (they don’t reveal the token itself beyond showing \r/whitespace) + the result of the forced gateway status --url ... --token ..., I can tell which branch you’re in.
That’s phishing / scam spam (the solanamobile.in/claim link + “access code” drop/claim bait).
- Don’t click it, don’t enter codes, don’t connect any wallet.
- Best action: delete the message and report the account/message to Discord.
If anyone already clicked/connected a wallet: disconnect immediately and revoke any wallet approvals/permissions, and consider the machine/browser profile potentially compromised.
docker compose run --rm openclaw-cli gateway status
--url ws://openclaw-gateway:18789
--token "$OPENCLAW_GATEWAY_TOKEN"
*** This works - i get back RPC probe: ok ***
This does not work: docker compose run --rm openclaw-cli devices list I get the error:
[openclaw] CLI failed: Error: gateway closed (1006 abnormal closure (no close frame)): no close reason
Gateway target: ws://127.0.0.1:18789
Source: local loopback
Config: /home/node/.openclaw/openclaw.json
Bind: lan
at Object.onClose (file:///app/dist/call-BTbA5OB4.js:262:10)
at WebSocket.<anonymous> (file:///app/dist/client-BYVbRnuQ.js:1408:23)
at WebSocket.emit (node:events:519:28)
at WebSocket.emitClose (/app/node_modules/.pnpm/ws@8.19.0/node_modules/ws/lib/websocket.js:263:12)
at emitErrorAndClose (/app/node_modules/.pnpm/ws@8.19.0/node_modules/ws/lib/websocket.js:1047:13)
at ClientRequest.<anonymous> (/app/node_modules/.pnpm/ws@8.19.0/node_modules/ws/lib/websocket.js:886:5)
at ClientRequest.emit (node:events:519:28)
at emitErrorEvent (node:_http_client:107:11)
at Socket.socketErrorListener (node:_http_client:574:5)
at Socket.emit (node:events:519:28)
Nice — that pretty much proves the Gateway token is fine and the gateway container is behaving.
What’s “broken” is just the defaults the CLI picks inside the openclaw-cli container (it tends to probe local loopback ws://127.0.0.1:18789 unless you give it a remote URL). In Docker, the gateway is not on the CLI container’s loopback — it’s at the service DNS name openclaw-gateway.
Make it work without having to pass --url/--token every time
Set the CLI default remote target in config:
docker compose run --rm openclaw-cli config set gateway.remote.url "ws://openclaw-gateway:18789"
docker compose run --rm openclaw-cli config set gateway.remote.token "$OPENCLAW_GATEWAY_TOKEN"
Now commands that do RPC should “just work”, e.g.:
docker compose run --rm openclaw-cli status
docker compose run --rm openclaw-cli gateway probe
docker compose run --rm openclaw-cli dashboard --no-open
One gotcha
This writes into the mounted ~/.openclaw/openclaw.json, so if you also run a native openclaw on macOS, it may start trying to use ws://openclaw-gateway:18789 too (which won’t resolve on the host). If you want to keep host + docker totally separate, run the docker flow with a dedicated state dir (e.g. OPENCLAW_CONFIG_DIR=~/.openclaw-docker, OPENCLAW_WORKSPACE_DIR=...) and rerun docker-setup.sh.
If you run the two config set commands above and still see “token mismatch”, paste docker compose run --rm openclaw-cli config get gateway.remote.token (redact most of it) and the exact CLI command that errors — but I think this will fix it.
For the failed request, gatweway log says:
2026-02-07T18:54:07.008Z [ws] closed before connect conn=616720f2-5b7d-44ce-a51c-31b7a118ada5 remote=192.168.65.1 fwd=n/a origin=http://localhost:18789 host=localhost:18789 ua=Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36 code=1008 reason=unauthorized: gateway token mismatch (open the dashboard URL and paste the token in Control UI settings)
That error isn’t a token problem anymore — it’s the CLI container trying to talk to its own loopback:
Gateway target: ws://127.0.0.1:18789- inside
openclaw-cli,127.0.0.1≠the gateway container ⇒ you get the 1006 close.
Quick fix (same pattern as your working command)
docker compose run --rm openclaw-cli devices list \
--url ws://openclaw-gateway:18789 \
--token "$OPENCLAW_GATEWAY_TOKEN"
(and approval, if needed)
docker compose run --rm openclaw-cli devices approve <requestId> \
--url ws://openclaw-gateway:18789 \
--token "$OPENCLAW_GATEWAY_TOKEN"
Make devices list work without --url every time
Set the default remote gateway URL/token in config, then confirm it stuck:
docker compose run --rm openclaw-cli config set gateway.remote.url "ws://openclaw-gateway:18789"
docker compose run --rm openclaw-cli config set gateway.remote.token "$OPENCLAW_GATEWAY_TOKEN"
docker compose run --rm openclaw-cli config get gateway.remote.url
docker compose run --rm openclaw-cli config get gateway.remote.token # redact when pasting
After that, docker compose run --rm openclaw-cli devices list should stop defaulting to ws://127.0.0.1:18789.
If it still prints “Source: local loopback” after gateway.remote.url is set, paste the output of:
config get gateway.remote.url- the first ~20 lines of
devices listerror header (no token)
and I’ll point to the exact selection rule it’s hitting.
That gateway log line is super useful: it’s the browser Control UI connection (Chrome) getting rejected with:
unauthorized: gateway token mismatch …
So at that moment, the browser was definitely sending a token, but not the one the gateway is currently configured with.
Two common “gotchas” in Docker setups:
1) localhost vs 127.0.0.1 are different browser origins
The Control UI stores the token in localStorage, keyed by origin.
So if you previously opened (and saved an old token) at:
http://localhost:18789/
…but later you paste the correct token while visiting:
http://127.0.0.1:18789/
…you’ll still get a mismatch when you go back to localhost (and vice‑versa). Your log shows:
origin=http://localhost:18789
So fix it on that exact origin.
Fast fix: open in an Incognito window:
http://localhost:18789/?token=<your-token>
(or clear site data for http://localhost:18789 and reload).
2) You may simply have a stale token stored from earlier attempts
Even if you’ve wiped ~/.openclaw, your browser may still have the old token saved. Clear it:
Chrome → Site settings for localhost:18789 → Clear data (or DevTools → Application → Storage → Clear site data).
After token is fixed, expect pairing (maybe)
Because the gateway sees the Control UI coming from remote=192.168.65.1 (Docker bridge), it may treat it as non-local and require device pairing.
If you then see “pairing required”, run (with your working URL/token pattern):
docker compose run --rm openclaw-cli devices list \
--url ws://openclaw-gateway:18789 \
--token "$OPENCLAW_GATEWAY_TOKEN"
docker compose run --rm openclaw-cli devices approve <requestId> \
--url ws://openclaw-gateway:18789 \
--token "$OPENCLAW_GATEWAY_TOKEN"
One note: don’t paste the full token in Discord; redact it if you need to show it.
If you tell me which URL you’re using in the browser (localhost vs 127.0.0.1 vs LAN IP), I can tell you exactly which origin’s storage you need to clear.
After updating gateway.remote.url and gateway.remote.token as shown in your example, I still run into errors:
- docker compose run --rm openclaw-cli status : unreachable (connect failed: connect ECONNREFUSED 127.0.0.1:18789)
- docker compose run --rm openclaw-cli gateway probe :
Discovery (this machine)
Found 0 gateways via Bonjour (local.)
Tip: if the gateway is remote, mDNS won’t cross networks; use Wide-Area Bonjour (split DNS) or SSH tunnels.
Targets
Remote (configured, inactive) ws://openclaw-gateway:18789
Connect: ok (28ms) · RPC: ok
Gateway: 86f5564e1932 (172.18.0.2) · linux 6.12.67-linuxkit · app unknown
Wide-area discovery: unknown
Local loopback ws://127.0.0.1:18789
Connect: failed - connect failed: connect ECONNREFUSED 127.0.0.1:18789
The quick fix works: docker compose run --rm openclaw-cli devices list
--url ws://openclaw-gateway:18789
--token "$OPENCLAW_GATEWAY_TOKEN"
That output is actually consistent with how the CLI is written:
gateway.remote.urlis only used automatically whengateway.mode = "remote".- If
gateway.modeis"local"(which it must be for the gateway container to start), then the CLI defaults to local loopbackws://127.0.0.1:18789for commands likeopenclaw statusandopenclaw devices ….
That’s why gateway probe can show the remote target (it probes multiple targets), but openclaw status still tries loopback and fails.
What to do (pick one)
Option 1: Keep using --url/--token for RPC commands (simplest)
This is valid and what you already proved works:
docker compose run --rm openclaw-cli devices list \
--url ws://openclaw-gateway:18789 \
--token "$OPENCLAW_GATEWAY_TOKEN"
Same for other RPC-y commands.
Option 2: Give the CLI container its own config in remote mode (best UX)
You can’t flip gateway.mode to remote in the shared openclaw.json because the gateway container will refuse to start. So instead, point the CLI at a different config file via OPENCLAW_CONFIG_PATH.
- Create a dedicated CLI config inside the mounted state dir:
docker compose run --rm openclaw-cli sh -lc 'cat > /home/node/.openclaw/openclaw-cli.json <<JSON
{
gateway: {
mode: "remote",
remote: {
url: "ws://openclaw-gateway:18789",
token: "'"$OPENCLAW_GATEWAY_TOKEN"'"
}
}
}
JSON'
- Run CLI commands with that config:
docker compose run --rm -e OPENCLAW_CONFIG_PATH=/home/node/.openclaw/openclaw-cli.json openclaw-cli status
docker compose run --rm -e OPENCLAW_CONFIG_PATH=/home/node/.openclaw/openclaw-cli.json openclaw-cli devices list
If you like this, you can bake it into docker-compose.yml for openclaw-cli by adding:
OPENCLAW_CONFIG_PATH: /home/node/.openclaw/openclaw-cli.json
Why gateway probe says “Remote (configured, inactive)”
That “inactive” bit is just because your shared config still has gateway.mode: "local" (correct for the gateway container). It’s not an error.
If you want, paste the first ~10 lines of docker compose run --rm -e OPENCLAW_CONFIG_PATH=... openclaw-cli status and I’ll confirm it’s now using config gateway.remote.url instead of local loopback.
Perfect — that means everything is healthy; it was just the CLI defaulting to ws://127.0.0.1:18789 inside the CLI container.
If you want a clean “no flags” workflow, the durable fix is the separate CLI config in remote mode (Option 2 from my last message), because the shared openclaw.json has to stay gateway.mode: "local" for the gateway container to start.
If this solved the thread, please mark the solution so Answer Overflow indexes it:
Right‑click the solution message → Apps → ✅ Mark Solution.
Remote url and token all look good. But docker compose run --rm openclaw-cli devices list is still throwing the same exception. It bis still pointing to Gateway target: ws://127.0.0.1:18789 . docker compose config get gateway.remote.url gives an error: no such service: get. devices list error is:
[openclaw] CLI failed: Error: gateway closed (1006 abnormal closure (no close frame)): no close reason
Gateway target: ws://127.0.0.1:18789
Source: local loopback
Config: /home/node/.openclaw/openclaw.json
Bind: lan
at Object.onClose (file:///app/dist/call-BTbA5OB4.js:262:10)
at WebSocket.<anonymous> (file:///app/dist/client-BYVbRnuQ.js:1408:23)
at WebSocket.emit (node:events:519:28)
at WebSocket.emitClose (/app/node_modules/.pnpm/ws@8.19.0/node_modules/ws/lib/websocket.js:263:12)
at emitErrorAndClose (/app/node_modules/.pnpm/ws@8.19.0/node_modules/ws/lib/websocket.js:1047:13)
at ClientRequest.<anonymous> (/app/node_modules/.pnpm/ws@8.19.0/node_modules/ws/lib/websocket.js:886:5)
at ClientRequest.emit (node:events:519:28)
at emitErrorEvent (node:_http_client:107:11)
at Socket.socketErrorListener (node:_http_client:574:5)
at Socket.emit (node:events:519:28)
So - problem is not fully solved yet.
Yep — that’s expected with the current config, and it explains why it’s still hitting loopback.
What’s happening
openclaw-cli will only use gateway.remote.url automatically when:
gateway.mode = "remote"
If gateway.mode is "local", then commands like devices list use the local target:
ws://127.0.0.1:18789
Inside the CLI container, 127.0.0.1:18789 is the CLI container itself (not the gateway container), so you get ECONNREFUSED / 1006.
Your gateway probe output already proves this:
- “Remote (configured, inactive) … RPC ok”
- “Local loopback … connect refused”
“Inactive” there just means gateway.mode isn’t remote.
Also: docker compose config get … is not a thing
That’s why you got no such service: get. The command you want is:
docker compose run --rm openclaw-cli config get gateway.remote.url
Fix options
Option A (keep simple): always pass --url/--token for RPC commands
You already confirmed this works:
docker compose run --rm openclaw-cli devices list \
--url ws://openclaw-gateway:18789 \
--token "$OPENCLAW_GATEWAY_TOKEN"
Option B (best UX): give the CLI container its own config in remote mode
You can’t flip the shared /home/node/.openclaw/openclaw.json to gateway.mode: "remote" because the gateway container needs gateway.mode: "local" to start.
So create a second config file just for the CLI:
docker compose run --rm openclaw-cli sh -lc 'cat > /home/node/.openclaw/openclaw-cli.json <<JSON
{
gateway: {
mode: "remote",
remote: {
url: "ws://openclaw-gateway:18789",
token: "'"$OPENCLAW_GATEWAY_TOKEN"'"
}
}
}
JSON'
Now run CLI commands using that config:
docker compose run --rm \
-e OPENCLAW_CONFIG_PATH=/home/node/.openclaw/openclaw-cli.json \
openclaw-cli devices list
If that works, you can make it permanent by adding this env var to the openclaw-cli service in docker-compose.yml (or your docker-compose.extra.yml):
environment:
OPENCLAW_CONFIG_PATH: /home/node/.openclaw/openclaw-cli.json
Quick sanity check
Run these and paste the output (no secrets):
docker compose run --rm openclaw-cli config get gateway.mode
docker compose run --rm \
-e OPENCLAW_CONFIG_PATH=/home/node/.openclaw/openclaw-cli.json \
openclaw-cli config get gateway.mode
You should see local for the shared config, and remote for the CLI config.
- docker compose run --rm openclaw-cli config get gateway.remote.url gives ws://openclaw-gateway:18789
- option B works but is clunky - so not preferred. option B is even more clunky. Please provide an option that updates docker-compose.yml only with the changes neede to point the cli to the remote gateway
Right — gateway.remote.url being set is not enough for most CLI commands, because the CLI will still treat itself as “local mode” and default to ws://127.0.0.1:18789 unless gateway.mode="remote" (or you pass --url).
If you want this to work without flags and without a separate CLI config, the clean Docker-ish fix is:
Make openclaw-cli share the gateway container’s network namespace
Then ws://127.0.0.1:18789 inside openclaw-cli actually reaches the gateway.
Minimal docker-compose.yml change
Add this under the openclaw-cli: service:
openclaw-cli:
network_mode: "service:openclaw-gateway"
depends_on:
- openclaw-gateway
That’s it.
Then apply + test
Make sure the gateway is up, then run:
docker compose up -d openclaw-gateway
docker compose run --rm openclaw-cli devices list
docker compose run --rm openclaw-cli status
They should stop trying (and failing) to hit the CLI container’s own loopback.
Notes:
- This doesn’t change tokens/auth at all — it only fixes the “127.0.0.1 points at the wrong container” problem.
docker compose config get ...indeed isn’t a compose command;openclaw config get ...is.
If you want, paste your current openclaw-cli: service block and I’ll point out exactly where to insert the 2 lines.
Perfect - this works! Is there a way to update the onboard script to auto-update docker-compose.yml with this if the user chooses lan instead of local loopback ?
Yes — and I’d actually do it in a way that doesn’t mutate the repo’s docker-compose.yml, because people hate installers that dirty working trees.
Why this is needed
Most CLI RPC commands (like devices list, status) default to ws://127.0.0.1:18789 unless gateway.mode="remote" or --url is provided. Inside Docker, that loopback points at the CLI container, not the gateway container — so sharing the network namespace (network_mode: service:openclaw-gateway) is the cleanest fix.
Best implementation approach
Update docker-setup.sh to always generate a docker-compose.extra.yml that adds:
services:
openclaw-cli:
network_mode: "service:openclaw-gateway"
depends_on:
- openclaw-gateway
…and then have the script print the correct compose invocation (it already does this when it generates an extra file for mounts/volumes).
This avoids editing docker-compose.yml at all, keeps the repo clean, and makes docker compose run --rm openclaw-cli … behave the way users intuitively expect.
One tradeoff to be aware of
With network_mode: service:openclaw-gateway, the CLI container requires the gateway container to exist/running (which is usually true right after docker-setup.sh, but it’s worth noting in docs).
Next step
This is a great candidate for a GitHub issue (or PR if you’re up for it):
https://github.com/openclaw/openclaw/issues
If you file it, include:
- “CLI container defaults to ws://127.0.0.1:18789; fails in Docker”
- “Fix: openclaw-cli
network_mode: service:openclaw-gateway” - “Prefer generating docker-compose.extra.yml to avoid dirty repo”
I'll file an issue. Can you gove me a url to this discussion do I can include it in the issue details?
I can’t generate a public URL from my side, but you can grab a permalink to any message in the thread:
Desktop Discord
- Right‑click the thread starter message (or my “network_mode” message)
- Copy Message Link
- Paste that into the GitHub issue
It’ll look like:
https://discord.com/channels/<serverId>/<channelId>/<messageId>
Mobile
- Long‑press the message → Copy Message Link
If you want “the whole discussion”, link the thread starter message (first message in the thread) — that’s usually the best anchor.
ok thx
If you want, paste the issue link after you file it and I can sanity‑check the wording/steps (and suggest a minimal patch diff for docker-setup.sh).
Thanks - here is the link: https://github.com/openclaw/openclaw/issues/11456#issue-3911209850
Nice — the issue looks solid and captures the core bug + the working fix clearly.
A couple small tweaks you could add (optional, but helps maintainers reason about it fast):
-
Call out why
gateway.remote.urldoesn’t help here- Most CLI RPC commands (like
status,devices list) default to local loopback unlessgateway.mode="remote"or--urlis passed. - In Docker, loopback in
openclaw-cliis the CLI container, not the gateway container →ECONNREFUSED.
- Most CLI RPC commands (like
-
Suggest the “keep repo clean” approach
Instead of editingdocker-compose.yml, havedocker-setup.shgenerate a smalldocker-compose.extra.yml(it already does this for mounts) that adds:services: openclaw-cli: network_mode: "service:openclaw-gateway" depends_on:
- openclaw-gateway
That avoids dirtying the repo checkout and still fixes the “127.0.0.1” default cleanly.
- (Minor) In “Steps to reproduce”, you might explicitly say:
- run
docker compose up -d openclaw-gateway - then
docker compose run --rm openclaw-cli status - observe it tries
ws://127.0.0.1:18789
- run
But honestly it’s already good enough as-is. If you open a PR later, that extra-compose approach will probably be welcomed.
Great suggestions! Please re-review now that I made the suggested changes
Looks much better — the issue now captures the “why gateway.remote.url doesn’t help” + the “keep repo clean via extra compose file” angle. Only a few nits I’d fix for clarity/accuracy:
- One bullet is incomplete
- Your repro step currently ends with:
Observe it tries ws://127.0.0.1:18789 - even if - I’d change that to something like:
Observe it still tries ws://127.0.0.1:18789 (local loopback) even though gateway.remote.url is set to ws://openclaw-gateway:18789.
- Filename mismatch
- The repo/script uses
docker-compose.extra.yml(dot), notdocker-compose-extra.yml(dash). - Suggest updating that in the “Fix” section so maintainers can grep/find it quickly.
- Tiny wording contradiction
- This sentence: “This continues the practice of dirtying the repo checkout … and fixes this problem.”
I think you meant the opposite: the extra file approach avoids dirtying the repo checkout.
Optional (but helpful): in the fix section, explicitly say where docker-setup.sh would add it (it already generates docker-compose.extra.yml for mounts/volumes), so the change is: “always generate it (or generate it when needed) and include network_mode: service:openclaw-gateway for openclaw-cli.”
Otherwise: nailed it.
I dont want to suggest a fix design unless I am the one doing the PR - the devs are quite competent 🙂 - please re-review the other changes
Your non-design changes are good — the issue now clearly documents:
- the symptom (
openclaw-cli status/devices listfailing) - that
gateway.remote.urlwas set and still the CLI targetsws://127.0.0.1:18789 - that the gateway itself is reachable (via an explicit
--url ws://openclaw-gateway:18789probe /gateway probe)
A few purely editorial/clarity nits (no “fix design” implied):
- Incomplete repro line
You’ve got:
“Observe it tries ws://127.0.0.1:18789 - even if”
Finish that sentence (e.g. “even if gateway.remote.url is set to ws://openclaw-gateway:18789”).
- Make the “Actual behavior” more concrete
Instead of “Step 4 errors out”, include the 2–3 key lines from the CLI error header (no secrets), since it’s self-diagnosing:
Gateway target: ws://127.0.0.1:18789
Source: local loopbackconnect ECONNREFUSED 127.0.0.1:18789
- “Fix” vs “Workaround” labeling
If you want to avoid prescribing a solution, consider renaming that section to Workaround and state what you did to confirm the root cause (e.g. “sharing network namespace makes loopback point at the gateway; passing--urlalso works”). Maintainers can then choose the implementation approach.
Everything else reads clean and should be actionable for them.
Please re-review, the "even if" dangling sentence is already fixed, Also fixed the Step 4 errors and updated to point to sep 6.
This version reads clean now. The repro is no longer ambiguous, and pointing “Expected/Actual” at Step 6 is much clearer.
Only two remaining nits (optional, but they increase the odds of a quick fix):
- Actual behavior should include the concrete error line
Right now it says it “tries ws://127…” (true), but maintainers love seeing the exact failure mode. I’d paste just these 2–3 lines from the CLI output (no secrets):
Gateway target: ws://127.0.0.1:18789Source: local loopbackconnect ECONNREFUSED 127.0.0.1:18789(or the 1006 abnormal closure)
- Section title “Fix”
If you want to avoid prescribing implementation, rename “Fix” → Workaround / Suggested change.
But the text you have is already pretty measured and doesn’t feel bossy.
Everything else looks solid, including the corrected “not dirtying the repo checkout” wording and the docker-compose.extra.yml filename.
Changes made, recheck
Much better — this is now “maintainer-friendly”:
- Clear minimal repro
- Explicitly states
gateway.remote.urlis set - Includes the key failure excerpt (
Gateway target… Source… ECONNREFUSED…) - “Suggested change” wording is a good tone (non-prescriptive)
Two tiny formatting nits (optional):
- Your code block is starting with
_```` (looks like markdown got an extra underscore). If you remove the_` it’ll render cleanly. - In “docker-compose.extra.yml changes”, indent those YAML lines as a fenced code block for readability, e.g.
services: openclaw-cli: network_mode: "service:openclaw-gateway" depends_on: - openclaw-gateway
But content-wise: it’s solid now.
Changes made, recheck
Yep, this is basically good now — the only remaining cleanup is just formatting:
- There’s still a dangling underscore at the end of the ECONNREFUSED line:
connect ECONNREFUSED 127.0.0.1:18789_
Remove that_so it doesn’t look like emphasis markup.
- Consider putting the 3 “Gateway target / Source / ECONNREFUSED” lines into a fenced code block so GitHub preserves spacing:
Gateway target: ws://127.0.0.1:18789
Source: local loopback
connect ECONNREFUSED 127.0.0.1:18789
- Minor YAML readability (optional): indent it as proper YAML under
services:in a fencedymlblock. But even as-is, maintainers will understand.
Content-wise: âś… clear, reproducible, and actionable.
Changes made, recheck
Looks good now — the underscore is gone and the key error lines are present and readable.
Only purely cosmetic improvements left (totally optional):
- Wrap the “Gateway target / Source / ECONNREFUSED” snippet in a fenced code block so GitHub doesn’t add leading spaces.
- Indent the suggested
docker-compose.extra.ymlsnippet as proper YAML (or fence it as ```yml).
But content-wise it’s clear and actionable as-is.
how do you do fenced code block in markdown?
Use triple backticks on their own lines:
```text
Gateway target: ws://127.0.0.1:18789
Source: local loopback
connect ECONNREFUSED 127.0.0.1:18789
```
You can also specify a language for syntax highlighting, e.g.:
```yml
services:
openclaw-cli:
network_mode: "service:openclaw-gateway"
depends_on:
- openclaw-gateway
```
End the block with the same triple backticks.
This goofed up the formatting. The issue looks okay - I am no longer touching it - do a final sanity read
Sanity read: it’s totally fine as-is. Nothing important is broken.
- Summary is clear.
- Repro steps are concrete and minimal.
- You explicitly mention
gateway.remote.urlis set but the CLI still targetsws://127.0.0.1:18789. - The key error excerpt (
Gateway target / Source / ECONNREFUSED) is present. - “Suggested change” is phrased reasonably and includes the tested workaround.
- Reference link is included.
Only minor cosmetic stuff remains (the YAML snippet isn’t fenced/indented), but it’s still readable and won’t block maintainers from understanding or acting on it.
Okay - the cli issues are resolved - but control UI still says token missing now
“token missing” in the Control UI basically means: the browser isn’t sending any token yet (localStorage is empty for that origin), so the gateway closes the WS handshake.
Do this from the repo root:
- Generate a tokenized Control UI link (recommended)
docker compose run --rm openclaw-cli dashboard --no-open
Open the URL it prints (it’ll include ?token=...). After first load, the UI stores it.
- Watch out for
localhostvs127.0.0.1
The token is stored per-origin, so:
http://localhost:18789andhttp://127.0.0.1:18789do not share storage.
If you switch between them, you’ll need to paste the token again (or use the tokenized URL for the one you’re on).
- After the token is accepted, you may still hit pairing required (because the gateway sees the browser as “remote” from inside Docker). Approve it:
docker compose run --rm openclaw-cli devices list
docker compose run --rm openclaw-cli devices approve <requestId>
If you tell me which exact URL you’re opening in the browser (localhost vs 127.0.0.1), I can tell you which one to fix/clear.
docker compose run --rm openclaw-cli dashboard --no-open does not provide a tokenized url
I hand copied the token into an incognito mode chrome browser - still getting the same error: token missing
The exact error is: disconnected (1008): unauthorized: gateway token missing (open the dashboard URL and paste the token in Control UI settings)
“token missing” means the browser Control UI is not sending any token at all in the WS handshake (different from “token mismatch”, which means it sent the wrong one).
Given two clues:
dashboard --no-openis not printing?token=...- even incognito still says token missing
…I’d first confirm the CLI container actually sees the token, because openclaw dashboard literally does:
token = gateway.auth.token ?? OPENCLAW_GATEWAY_TOKEN
1) Verify the CLI container can see the token (no secret leakage)
Run these and paste just the numbers:
docker compose run --rm openclaw-cli sh -lc 'node -e "console.log(\"env token len:\", (process.env.OPENCLAW_GATEWAY_TOKEN||\"\").length)"'
docker compose run --rm openclaw-cli sh -lc 'node -e "const fs=require(\"fs\"); const c=JSON.parse(fs.readFileSync(\"/home/node/.openclaw/openclaw.json\",\"utf8\")); const t=(c.gateway?.auth?.token||\"\"); console.log(\"cfg token len:\", String(t).length)"'
Then re-run:
docker compose run --rm openclaw-cli dashboard --no-open
If the token len is non-zero, that command should print a tokenized URL.
2) Force the tokenized URL manually (bypasses UI settings)
On your Mac, open this exactly (replace <token> with the real token):
http://127.0.0.1:18789/?token=<token>
Important: localhost and 127.0.0.1 are different origins for localStorage, so stick to one.
3) If it still says “token missing”, grab the gateway’s log line for that attempt
Run:
docker compose logs -f openclaw-gateway
Try to connect once, and paste the single log line that contains reason=unauthorized: (it won’t include the token, just the reason).
That’ll tell us whether the UI truly isn’t sending auth.token, or if you’re accidentally hitting a different gateway instance / URL.
That specific message means the Control UI’s WebSocket connect frame is arriving with no auth.token at all (not “wrong token”, literally missing).
Two things to check, because they line up exactly with what you’re seeing:
1) dashboard --no-open not printing ?token=... means the CLI container thinks the token is empty
openclaw dashboard builds the URL from:
gateway.auth.tokenin/home/node/.openclaw/openclaw.json, or- env
OPENCLAW_GATEWAY_TOKEN
So please run these (they only output lengths):
docker compose run --rm openclaw-cli sh -lc 'node -e "console.log((process.env.OPENCLAW_GATEWAY_TOKEN||\"\").length)"'
docker compose run --rm openclaw-cli sh -lc 'node -e "const fs=require(\"fs\");const c=JSON.parse(fs.readFileSync(\"/home/node/.openclaw/openclaw.json\",\"utf8\"));console.log(String(c?.gateway?.auth?.token||\"\").length)"'
If both are 0, that explains everything: the browser has nothing valid to send.
2) If the token exists, force-load it via URL param (this bypasses “paste & save” UI issues)
On your Mac, open exactly (pick one and stick to it; don’t mix localhost and 127.0.0.1):
http://localhost:18789/?token=<TOKEN>
orhttp://127.0.0.1:18789/?token=<TOKEN>
That query param is handled by the Control UI and should populate localStorage automatically.
Quick confirmation (no token leakage)
After loading that URL, open DevTools Console and run:
JSON.parse(localStorage.getItem("openclaw.control.settings.v1")||"{}").token?.length
You should see a non-zero number (usually 64). If it’s undefined/0, the token never got stored for that origin.
If you paste:
- the output of the two “length” commands
- and the output of
docker compose run --rm openclaw-cli dashboard --no-open(it shouldn’t include secrets if it’s currently missing the token)
…I can tell which branch you’re in and what to fix next.
both commands say token length is 48. But - the openclaw-cli dashboard command does not return a token.
Here is the output:
Dashboard URL: http://127.0.0.1:18789/
Copy to clipboard unavailable.
No GUI detected. Open from your computer:
ssh -N -L 18789:127.0.0.1:18789 user@<host>
Then open:
http://localhost:18789/
Docs:
https://docs.openclaw.ai/gateway/remote
https://docs.openclaw.ai/web/control-ui
I used safari to make sure that there is no browser side issues - and safari gives the same control ui error
If the gateway says:
unauthorized: gateway token missing
then the Control UI is not sending any token at all in the WS connect frame. (So this is a browser-side storage/input issue, not a gateway-side “wrong token” issue.)
Two weird-but-important signals in your last message:
- both env + config token lengths are 48 (so a token exists)
- but
docker compose run --rm openclaw-cli dashboard --no-open“does not return a token”
That strongly suggests the dashboard command is printing a URL without ?token=, i.e. it thinks token="". Let’s disambiguate with one concrete check:
1) Paste the exact output of the dashboard command (redact if needed)
Run:
docker compose run --rm openclaw-cli dashboard --no-open
Please paste the output line(s). If it contains ?token=..., just redact the token value.
If it does not contain ?token=, that’s a real bug/misread we can chase.
2) Bypass “paste token” entirely: open a tokenized URL you generate yourself
On your machine (not in Discord), run this to print a tokenized URL:
docker compose run --rm openclaw-cli sh -lc 'echo "http://127.0.0.1:18789/?token=$OPENCLAW_GATEWAY_TOKEN"'
Copy/paste that URL into Chrome as-is.
That should auto-store the token (Control UI reads ?token= on load and saves it).
Quick verification in the browser (no token leakage)
Open DevTools Console and run:
JSON.parse(localStorage.getItem("openclaw.control.settings.v1")||"{}").token?.length
If it’s non-zero, the UI has a token stored; if it’s undefined/0, the UI never saved it for that origin.
3) Make sure you’re not mixing localhost and 127.0.0.1
Token is stored per-origin. If you set it on http://127.0.0.1:18789 but then open http://localhost:18789, it’ll look “missing” again.
Send:
- the redacted
dashboard --no-openoutput - which URL you’re opening (
localhostor127.0.0.1) - the
localStorage ... token?.lengthresult
…and we can pin down exactly where it’s getting dropped.
The key clue is this: the CLI’s openclaw dashboard will only print a ?token=... URL if it can see a token in either:
gateway.auth.tokenin the config file, orprocess.env.OPENCLAW_GATEWAY_TOKEN
(source: dashboardCommand() in dist/config-guard-*.js — it builds authedUrl = token ? httpUrl + "?token=..." : httpUrl)
So if docker compose run --rm openclaw-cli dashboard --no-open prints no ?token=..., then inside the openclaw-cli container the token is empty — and that very naturally leads to the Control UI connecting with no token → gateway log: unauthorized: gateway token missing.
1) Quick confirm (inside the CLI container)
Run:
docker compose run --rm openclaw-cli sh -lc 'node -p "process.env.OPENCLAW_GATEWAY_TOKEN?.length || 0"'
docker compose run --rm openclaw-cli sh -lc 'node -p "process.env.OPENCLAW_CONFIG_PATH || \"(unset)\""'
docker compose run --rm openclaw-cli sh -lc 'ls -la /home/node/.openclaw/openclaw.json 2>/dev/null || true'
docker compose run --rm openclaw-cli dashboard --no-open
What I expect right now (given your symptom): token length prints 0, and the dashboard URL is not tokenized.
2) The “minimal compose change” that usually fixes this
Remember: Docker Compose .env is used for variable substitution, but it does not automatically inject vars into every container. Each service needs environment: or env_file:.
So make sure both openclaw-gateway and openclaw-cli get the token env, e.g. in your extra compose overlay:
services:
openclaw-cli:
environment:
OPENCLAW_GATEWAY_TOKEN: ${OPENCLAW_GATEWAY_TOKEN}
openclaw-gateway:
environment:
OPENCLAW_GATEWAY_TOKEN: ${OPENCLAW_GATEWAY_TOKEN}
After that, openclaw-cli dashboard --no-open should print:
Dashboard URL: http://127.0.0.1:18789/?token=...
Open that exact link once; the UI will store the token in localStorage (openclaw.control.settings.v1) and strip it from the URL.
3) If it still says “token missing” after opening the tokenized URL
Two common gotchas to check next:
A) Origin mismatch (localhost vs 127.0.0.1)
Token is stored per origin. If you open http://localhost:18789/ but your token was saved under http://127.0.0.1:18789/, the UI can look “logged out”.
Pick one and stick to it.
B) You’re accessing via LAN IP over plain HTTP
If you open http://<LAN-IP>:18789/, that’s a non-secure context (not “localhost”), and browsers often block WebCrypto → Control UI device identity can’t be generated. Docs say the fix is HTTPS (Tailscale Serve) or (if you accept the downgrade) set:
{
gateway: { controlUi: { allowInsecureAuth: true } }
}
What I need from you
Paste (redacting the token value if present) the output of:
docker compose run --rm openclaw-cli sh -lc 'node -p "process.env.OPENCLAW_GATEWAY_TOKEN?.length || 0"'
docker compose run --rm openclaw-cli dashboard --no-open
That will tell us immediately whether we’re still failing before the browser even gets a token.
-
docker compose run --rm openclaw-cli dashboard --no-open returns:
Dashboard URL: http://127.0.0.1:18789/
Copy to clipboard unavailable.
No GUI detected. Open from your computer:
ssh -N -L 18789:127.0.0.1:18789 user@<host>
Then open:
http://localhost:18789/
Docs:
https://docs.openclaw.ai/gateway/remote
https://docs.openclaw.ai/web/control-ui -
Pasting the generated URL still gives the same error in chrome: URL: http://127.0.0.1:18789/?token=<redacted> ERROR: disconnected (1008): unauthorized: gateway token missing (open the dashboard URL and paste the token in Control UI settings)
-
Confirmed that URL is 127.0.0.1 not localhost
How do I get the localstorage info to send the localstorage ... token?.length result ?
In Chrome, open DevTools on the Control UI tab (Cmd+Opt+I) and run this in the Console:
const raw = localStorage.getItem("openclaw.control.settings.v1");
console.log("raw?", !!raw, "rawLen", raw?.length ?? 0);
const s = JSON.parse(raw || "{}");
console.log({ gatewayUrl: s.gatewayUrl, tokenLen: s.token?.length ?? 0 });
// If you want to copy the full JSON string (redact token before pasting anywhere):
// copy(raw)
That tokenLen is the one we care about. The Control UI stores the token as token inside that JSON blob (openclaw.control.settings.v1).
If you prefer the UI instead of console:
- DevTools → Application tab
- Local Storage →
http://127.0.0.1:18789 - Click
openclaw.control.settings.v1and copy the value (redact the token)
Also: the fact that openclaw-cli dashboard --no-open prints no ?token= strongly suggests the CLI container itself isn’t seeing OPENCLAW_GATEWAY_TOKEN / gateway.auth.token. Ideally dashboard should print the tokenized URL automatically — but the localStorage check above will tell us whether the browser UI actually stored/sent a token or not.
In the browser dev console, running JSON.parse(localStorage.getItem("openclaw.control.settings.v1")||"{}").token?.length responds with "undefined"
undefined there means the Control UI currently has no token saved at all (if it were saved as an empty string you’d get 0, not undefined).
Next step is to figure out whether:
- the
openclaw.control.settings.v1entry is missing, or - it exists but doesn’t include a
tokenfield, or - localStorage writes are failing in that browser context.
Have them run these exact lines in the same DevTools console on http://127.0.0.1:18789:
// 1) Confirm origin + secure context
location.origin
isSecureContext
// 2) Do we even have the key?
Object.keys(localStorage).filter(k => k.includes("openclaw"))
localStorage.getItem("openclaw.control.settings.v1")
// 3) Can localStorage write at all?
localStorage.setItem("__oc_test", "1")
localStorage.getItem("__oc_test")
localStorage.removeItem("__oc_test")
If localStorage.getItem("openclaw.control.settings.v1") is null
Then the tokenized URL was not persisted. Quick workaround: manually set it and reload:
const key = "openclaw.control.settings.v1";
const s = JSON.parse(localStorage.getItem(key) || "{}");
s.gatewayUrl = `ws://${location.host}`;
s.token = "<PASTE_TOKEN_HERE>"; // don’t paste this into Discord later
s.sessionKey = s.sessionKey || "main";
s.lastActiveSessionKey = s.lastActiveSessionKey || s.sessionKey;
localStorage.setItem(key, JSON.stringify(s));
location.reload();
After reload, re-check:
JSON.parse(localStorage.getItem("openclaw.control.settings.v1")||"{}").token?.length
You should see 48 (or whatever your token length is).
Also important: why is openclaw-cli dashboard --no-open not printing ?token=...?
That command only prints a tokenized URL if the CLI container can see the token (env or config). Ask them to share length only:
docker compose run --rm openclaw-cli sh -lc 'node -p "process.env.OPENCLAW_GATEWAY_TOKEN?.length || 0"'
If that prints 0, the CLI container isn’t getting the token env, which explains the non-tokenized dashboard URL output.
openclaw.control.settings.v1 is missing. After reload, token length returned 48 as expected. docker compose run --rm openclaw-cli sh -lc 'node -p "process.env.OPENCLAW_GATEWAY_TOKEN?.length || 0"' also returned 48
Nice — that fully explains the gateway-side “token missing”: the Control UI literally had no token persisted, so it was connecting with auth omitted.
Two important takeaways / next steps:
-
Stick to one origin (localhost vs 127.0.0.1).
Tokens are stored per-origin in localStorage. If you ever pasted the token onhttp://localhost:18789/but later openedhttp://127.0.0.1:18789/, you’ll see exactly what you saw:openclaw.control.settings.v1missing (and therefore token missing).
Now that you’ve set it on127.0.0.1, keep usinghttp://127.0.0.1:18789/(or repeat the same process onlocalhostand then always uselocalhost). -
Re-test the actual connection now.
Reload the page and hit Connect again. If it still fails, the next most likely outcomes are:
- pairing required (you’ll see a 1008 with “pairing required”) → approve via
openclaw devices list+openclaw devices approve <requestId> - token mismatch (stale device token) → clear the device token cache and retry:
localStorage.removeItem("openclaw.device.auth.v1") // optional “nuclear” reset for the UI identity: localStorage.removeItem("openclaw-device-identity-v1")
location.reload()
One lingering weirdness: openclaw dashboard --no-open previously printed no ?token=, even though OPENCLAW_GATEWAY_TOKEN is present (len 48). If it still prints a plain URL, I strongly suspect your config has gateway.auth.token set to an empty string, which wins over the env var (because the CLI uses cfgToken ?? envToken). Quick check (don’t paste the raw token back to Discord—just confirm whether it’s empty/non-empty):
docker compose run --rm openclaw-cli sh -lc 'openclaw config get gateway.auth.token | wc -c'
If that number is basically 1/0 (just newline), fix by unsetting it (so env is used) or setting it to the real token.
Tell me what error you get after reconnecting now (or if it finally goes “connected”).
I finally see "Health OK" on the ui and it is connected without any visible errors. But - opencli dashboard --no-open still does not show the tokenized url. But
docker compose run --rm openclaw-cli config get gateway.auth.token | wc -c returns 49
Awesome — if the UI is showing Health OK and staying connected, the “token missing” problem is solved (it was literally: token never got saved to openclaw.control.settings.v1 for that origin).
About openclaw dashboard --no-open still not printing ?token=… even though gateway.auth.token is present (49 bytes via wc -c = ~48 + newline):
Most useful next debug: show the dashboard output with token safely redacted
Run this and paste the output:
docker compose run --rm openclaw-cli sh -lc \
'openclaw dashboard --no-open | sed -E "s/(\\?token=)[^[:space:]]+/\\1<redacted>/g"'
If the command is producing a tokenized URL but it’s getting wrapped / missed, this will make it obvious without leaking the token.
If it still shows no ?token=…
Then openclaw dashboard is acting as if token === "" at runtime. In that case, I’d want these two lines (also safe to paste):
docker compose run --rm openclaw-cli sh -lc 'openclaw --version'
docker compose run --rm openclaw-cli sh -lc 'openclaw config get gateway.auth.token | wc -c'
The most likely explanations then are:
dashboardis reading a different config path/profile thanconfig get, ordashboardis getting an invalid config snapshot and falling back to{}(so it never sees the token), even thoughconfig getcan still print the raw value.
But first, let’s see what that redacted dashboard output looks like.
docker compose run --rm openclaw-cli sh -lc
'openclaw dashboard --no-open | sed -E "s/(\?token=)[^[:space:]]+/\1<redacted>/g"'. - this does not return a token.
Version is 2026.2.4
docker compose run --rm openclaw-cli sh -lc 'openclaw config get gateway.auth.token | wc -c' returns 49
Great — Health OK + connected means the Control UI auth path is now good (it was literally “no token persisted for that origin”, so the UI was connecting with auth omitted).
For openclaw dashboard --no-open still not printing a ?token= link even though you do have a token:
What’s almost certainly happening
dashboard builds the token like:
token = cfg.gateway.auth.token ?? process.env.OPENCLAW_GATEWAY_TOKEN ?? ""
So if your config file contains gateway.auth.token: "" (empty string), that wins (because "" is not null/undefined), and the env var is ignored — resulting in a non-tokenized URL.
Meanwhile openclaw config get gateway.auth.token uses the loaded/merged config (which may be filling from env), so it can still show a 48‑char token.
Confirm what’s actually in the file (not the merged config)
Run:
docker compose run --rm openclaw-cli sh -lc \
'node -p "JSON.stringify(require(\"/home/node/.openclaw/openclaw.json\").gateway?.auth?.token)"'
If that prints "", that explains everything.
Fix options (pick one)
Option A (preferred if you want token only in env): unset the empty config value
docker compose run --rm openclaw-cli sh -lc 'openclaw config unset gateway.auth.token'
Now openclaw dashboard --no-open should fall back to OPENCLAW_GATEWAY_TOKEN and print ?token=....
Option B (store token in config):
docker compose run --rm openclaw-cli sh -lc \
'openclaw config set gateway.auth.token "'"$OPENCLAW_GATEWAY_TOKEN"'"'
Option C (no config changes, just generate the link):
docker compose run --rm openclaw-cli sh -lc \
'echo "http://127.0.0.1:18789/?token=$OPENCLAW_GATEWAY_TOKEN"'
If you paste the output of that node -p JSON.stringify(...) line (it’ll be either "" or a quoted token string), I can tell you which branch you’re on for sure.
ocker compose run --rm openclaw-cli sh -lc
'node -p "JSON.stringify(require("/home/node/.openclaw/openclaw.json").gateway?.auth?.token)"' prints the token.
Nice — if the UI shows Health OK and stays connected, you’re done on the auth side. Root cause really was: no openclaw.control.settings.v1 for that origin → UI sent no token → “token missing”.
About openclaw dashboard --no-open still printing a non-tokenized URL even though the token is definitely in gateway.auth.token:
Practical workaround (gets you a tokenized URL every time)
Run this locally (don’t paste the output back into Discord):
docker compose run --rm openclaw-cli sh -lc '
TOKEN="$(openclaw config get gateway.auth.token)";
echo "http://127.0.0.1:18789/?token=$TOKEN"
'
That’s equivalent to what openclaw dashboard should be printing.
If you want to help nail the bug down
This prints what the config-snapshot API (the same one dashboard uses) thinks the token length is + which config path it read:
docker compose run --rm openclaw-cli sh -lc '
node -e "import(\"openclaw/dist/config-DxJyvt4o.js\").then(async m => {
const snap = await m.a();
console.log(\"configPath=\", snap.path);
console.log(\"snapTokenLen=\", (snap.config.gateway?.auth?.token||\"\").length);
console.log(\"envTokenLen=\", (process.env.OPENCLAW_GATEWAY_TOKEN||\"\").length);
})"
'
If snapTokenLen shows 48 but openclaw dashboard still prints no ?token=, that’s a clean repro for a CLI bug (worth dropping into the existing GH issue thread).
The workaround does print a tokenized url. But visiting the url gives a token mismatch error. In the browser, under openclaw.control.settings.v1, there is a different token than the one passed in the url.
That symptom (“URL ?token=... mismatches, and openclaw.control.settings.v1.token is different”) almost always means you’ve got two different “gateway tokens” in play, plus the UI may also have a device token cached.
There are 3 separate pieces of auth state in the browser:
openclaw.control.settings.v1→ stores the gateway token you pasted / got from?token=...openclaw.device.auth.v1→ stores the device token (issued by the gateway after a successful connect)openclaw-device-identity-v1→ the browser’s device identity
If you ever connected once, the UI can later connect using the device token, even if the “gateway token” in openclaw.control.settings.v1 is stale. Opening a URL with a different ?token= can then get confusing fast.
Step 1: Find the token the gateway container actually expects (share only hash + length)
Run inside the gateway container (do NOT paste the raw token into Discord):
docker compose exec openclaw-gateway sh -lc '
node -e "
const crypto=require(\"crypto\");
const cfg=require(\"/home/node/.openclaw/openclaw.json\").gateway?.auth?.token||\"\";
const env=process.env.OPENCLAW_GATEWAY_TOKEN||\"\";
const h=s=>crypto.createHash(\"sha256\").update(s).digest(\"hex\").slice(0,12);
console.log(\"cfg\", cfg.length, h(cfg));
console.log(\"env\", env.length, h(env));
"
'
Ideally cfg and env match (same hash prefix). If they don’t, that’s the source of “token mismatch”.
Step 2: On the tab that shows token mismatch, hash what the browser thinks the token is
In DevTools Console on that exact tab:
(async () => {
const s = JSON.parse(localStorage.getItem("openclaw.control.settings.v1") || "{}");
const t = s.token || "";
const d = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(t));
const h = [...new Uint8Array(d)].map(b => b.toString(16).padStart(2,"0")).join("").slice(0,12);
console.log("origin", location.origin);
console.log("ui settings token", t.length, h);
const dev = JSON.parse(localStorage.getItem("openclaw.device.auth.v1") || "{}");
console.log("deviceTokenLen", dev?.tokens?.operator?.token?.length);
})();
Fast fix (once you know the correct gateway token)
In the browser, wipe the stale UI state and re-seed from the correct ?token= URL:
localStorage.removeItem("openclaw.control.settings.v1");
localStorage.removeItem("openclaw.device.auth.v1");
localStorage.removeItem("openclaw-device-identity-v1"); // optional but helpful
location.reload();
Then open one canonical URL (same origin you’ll keep using, e.g. 127.0.0.1):
http://127.0.0.1:18789/?token=<CANONICAL_TOKEN>
If you paste back the hash prefixes + lengths only from Step 1 + Step 2, we can tell immediately which token is wrong (gateway env vs gateway config vs browser-stored).
How do I clear the browser cache for 127.0.0.1
For Chrome (and most Chromium browsers), the clean way is to clear site data for that origin (this wipes localStorage, cache, service workers, etc.):
Option A — via DevTools (fastest)
- Open
http://127.0.0.1:18789/ - DevTools (
Cmd+Opt+I) - Application tab
- Left sidebar: Storage → Clear site data
- Make sure it targets
http://127.0.0.1:18789 - Click Clear site data
- Make sure it targets
- Reload the page.
Option B — via Chrome settings (GUI)
- Open
chrome://settings/siteData - Search for
127.0.0.1 - Remove the entry for
127.0.0.1(andlocalhosttoo if you ever used it) - Reload
http://127.0.0.1:18789/
Option C — “surgical” from DevTools console (just OpenClaw keys)
On http://127.0.0.1:18789/, run:
localStorage.removeItem("openclaw.control.settings.v1");
localStorage.removeItem("openclaw.device.auth.v1");
localStorage.removeItem("openclaw-device-identity-v1"); // optional but often helpful
location.reload();
After clearing, open the tokenized URL again (and stick to either 127.0.0.1 or localhost, not both).
After doing the above, I now get gateway token missing error
That’s expected after “Clear site data” — you wiped the Control UI’s saved token (openclaw.control.settings.v1), so opening the plain URL will connect with no auth → “gateway token missing”.
Fix (re-seed the token once)
Open a tokenized URL once (same origin you’ll keep using):
- Generate the URL from the container (don’t paste the token back into Discord):
docker compose run --rm openclaw-cli sh -lc '
TOKEN="$(openclaw config get gateway.auth.token)";
echo "http://127.0.0.1:18789/?token=$TOKEN"
'
-
Open that printed URL in the browser.
-
Verify it persisted:
JSON.parse(localStorage.getItem("openclaw.control.settings.v1")||"{}").token?.length
You should see 48.
Important: don’t mix origins
If you sometimes open http://localhost:18789/ and sometimes http://127.0.0.1:18789/, they have separate storage, so one will look “missing” again. Pick one and stick to it.
If you re-seed and you get token mismatch again
That means the token you’re using doesn’t match what the gateway you’re hitting expects (often: you’re hitting a different gateway instance). In that case, tell me whether the mismatch happens when you open the printed URL above, and we’ll do the quick “hash-prefix” check (safe to share) on the gateway container to confirm which token it expects.
I dont think that is the problem. When I tried to run:
docker compose exec openclaw-cli sh -lc '
node -e "import("openclaw/dist/config-DxJyvt4o.js").then(async m => {
const snap = await m.a();
console.log("configPath=", snap.path);
console.log("snapTokenLen=", (snap.config.gateway?.auth?.token||"").length);
console.log("envTokenLen=", (process.env.OPENCLAW_GATEWAY_TOKEN||"").length);
})"
'
I got the error: Error [ERR_PACKAGE_PATH_NOT_EXPORTED]: Package subpath './dist/config-DxJyvt4o.js' is not defined by "exports" in /app/package.json imported from /app/[eval]
I cannot find the file - config-DxJyvt4o.js - in the repo root? Is this a generated file? If so - which script is responsible for generating it?
Yep — config-DxJyvt4o.js is not a source file in the repo. It’s a build artifact chunk inside the published package’s dist/ folder, and the DxJyvt4o part is a content hash, so the filename changes between builds.
Why your import("openclaw/dist/config-DxJyvt4o.js") fails
OpenClaw’s package.json has an "exports" map that only exposes:
openclaw→./dist/index.jsopenclaw/plugin-sdkopenclaw/cli-entry
Everything else under dist/ is intentionally not importable by package subpath, so Node throws ERR_PACKAGE_PATH_NOT_EXPORTED.
Where the file actually is
Inside the installed package:
ls -1 node_modules/openclaw/dist/config-*.js
# or (depending on how the image is built)
ls -1 /app/node_modules/openclaw/dist/config-*.js
What generates it
In the repo, it’s produced by the normal build:
pnpm build(runstsdown, which creates those hasheddist/*.jschunks)- also runs during
prepackbefore publishing to npm (pnpm build && pnpm ui:build)
So: generated by the build pipeline, not committed source.
You don’t need that file to fix your current problem
After clearing site data, “gateway token missing” just means “no token saved yet”.
The “token mismatch” you’re seeing now strongly suggests the token you’re using in the URL is not the one the running gateway container expects (CLI container config/env ≠gateway container config/env, or the gateway wasn’t restarted after a token change).
Fast check (safe: share only lengths/hashes, not the token):
docker compose exec openclaw-gateway sh -lc '
node -e "
const crypto=require(\"crypto\");
const cfg=require(\"/home/node/.openclaw/openclaw.json\").gateway?.auth?.token||\"\";
const env=process.env.OPENCLAW_GATEWAY_TOKEN||\"\";
const h=s=>crypto.createHash(\"sha256\").update(s).digest(\"hex\").slice(0,12);
console.log(\"gateway cfg\", cfg.length, h(cfg));
console.log(\"gateway env\", env.length, h(env));
"
'
Then do the same in openclaw-cli and compare. If the hash differs between containers, that’s your mismatch.
Once the gateway-side token is confirmed, open exactly:
http://127.0.0.1:18789/?token=<THE_GATEWAY_CONTAINER_TOKEN>
and the UI will persist it back into openclaw.control.settings.v1.
The hashes match. When i go to the tokenized URL however, the openclaw.control.settings.v1 key in localstorage is not being created/set
If you just did Clear site data, then “gateway token missing” is the default state again: the UI has no saved token, so it connects with auth omitted.
The only way openclaw.control.settings.v1 gets created is:
- you open the page with
?token=...and the UI successfully reads it, or - you paste the token in the UI Settings panel (then it saves it).
So if you are opening http://127.0.0.1:18789/?token=... and the key still isn’t created, one of these is happening:
1) The ?token=... isn’t actually in the navigation URL you’re loading
The Control UI strips ?token=... from the address bar very quickly (via history.replaceState), so you can’t rely on what you see after load.
Check what was actually requested:
Chrome DevTools → Network → click the first request (the document) → confirm the Request URL includes ?token=....
If it doesn’t, your copy/paste/click step is dropping the query string (common if you click a “redacted” link in a chat app).
2) localStorage writes are blocked for that origin
On the http://127.0.0.1:18789/ tab, run:
localStorage.setItem("__oc_test", "1")
localStorage.getItem("__oc_test")
- If this throws or returns null → your browser is blocking site storage for 127.0.0.1 (site settings / privacy).
- If it works → localStorage is fine, meaning the issue is (1) above (token isn’t being seen).
3) Quick, reliable workaround (doesn’t depend on URL parsing)
Just set the UI token manually and reload:
const key = "openclaw.control.settings.v1";
const s = JSON.parse(localStorage.getItem(key) || "{}");
s.gatewayUrl = `ws://${location.host}`;
s.token = "<PASTE_TOKEN_HERE>";
s.sessionKey = s.sessionKey || "main";
s.lastActiveSessionKey = s.lastActiveSessionKey || s.sessionKey;
localStorage.setItem(key, JSON.stringify(s));
location.reload();
Then confirm:
JSON.parse(localStorage.getItem("openclaw.control.settings.v1")||"{}").token?.length
You should see 48.
Important reminder
Don’t mix these, or you’ll keep “losing” the token:
http://127.0.0.1:18789vshttp://localhost:18789vshttp://127.0.0.1:18790(different port = different storage)
If you tell me whether the Network “Request URL” actually contains ?token=..., I can tell you immediately which branch you’re in.
The request URL has the token and uses 127.0.0.1 ip, not localhost
If the document request really has ?token=... and you still end up with “gateway token missing” and no openclaw.control.settings.v1, then the UI isn’t persisting settings — either because localStorage is blocked or because the page is redirecting and dropping the query before the app code runs.
Do these two quick checks on http://127.0.0.1:18789/:
1) Confirm localStorage is actually writable
In DevTools Console:
try {
localStorage.setItem("__oc_test", "1");
console.log("ls ok:", localStorage.getItem("__oc_test"));
localStorage.removeItem("__oc_test");
} catch (e) {
console.error("localStorage write failed:", e);
}
- If this throws (SecurityError / Quota / “access denied”): Chrome is blocking site storage.
- Fix: click the site icon (left of the URL) → Site settings → allow Cookies and site data for
127.0.0.1.
- Fix: click the site icon (left of the URL) → Site settings → allow Cookies and site data for
2) Confirm there is no redirect for the HTML document
In Network, click the Document request and verify:
- Status Code = 200 (not 301/302/307/308)
- If it is a redirect, open the final URL (the one that returns 200) and append the token to that URL (redirects often drop the query).
Fast workaround (always works): paste token in the UI settings
Even while disconnected, the Control UI should let you open Settings and paste the token, then hit Connect. That will create openclaw.control.settings.v1.
Nuclear-but-simple workaround: set it manually then reload
const key = "openclaw.control.settings.v1";
const s = JSON.parse(localStorage.getItem(key) || "{}");
s.gatewayUrl = `ws://${location.host}`;
s.token = "<PASTE_TOKEN_HERE>";
s.sessionKey = s.sessionKey || "main";
s.lastActiveSessionKey = s.lastActiveSessionKey || s.sessionKey;
localStorage.setItem(key, JSON.stringify(s));
location.reload();
If you paste back the result of (1) (whether localStorage write works + any error text) and whether the document request is 200 or a redirect, we’ll know exactly which path it is.
- Localstorage is writable, 2) response code for tokenized URL is 200
If (a) the document request URL really contains ?token=..., (b) it’s a 200, and (c) localStorage is writable, then the Control UI should create openclaw.control.settings.v1 before it even tries to connect.
So if it still doesn’t, let’s inspect what the app sees at runtime (and also rule out “another tab overwrote it”).
On the same http://127.0.0.1:18789/?token=... tab, run this in DevTools Console after the page finishes loading:
(() => {
const navUrl = performance.getEntriesByType("navigation")[0]?.name || "";
const navTokenLen = new URL(navUrl).searchParams.get("token")?.length;
const href = location.href;
const stored = localStorage.getItem("openclaw.control.settings.v1");
const app = document.querySelector("openclaw-app");
const appTokenLen = app?.settings?.token?.length;
console.log({ navTokenLen, href, hasStoredKey: !!stored, storedLen: stored?.length, appTokenLen });
})();
Interpretation:
- If
navTokenLenis48butappTokenLenis0/undefinedandhasStoredKeyisfalse, then the app is not applying the URL token at startup (bug or something odd in that build). - If
hasStoredKeybriefly appears and then disappears, that often means another already-open Control UI tab (same origin) is overwriting settings. Close all OpenClaw tabs/windows for127.0.0.1+localhost, then try again in a fresh tab.
Immediate “just make it work” fix
Since localStorage is writable, you can seed it manually (this bypasses any URL parsing weirdness):
const key = "openclaw.control.settings.v1";
const s = JSON.parse(localStorage.getItem(key) || "{}");
s.gatewayUrl = `ws://${location.host}`;
s.token = "<PASTE_TOKEN_HERE>";
s.sessionKey = s.sessionKey || "main";
s.lastActiveSessionKey = s.lastActiveSessionKey || s.sessionKey;
localStorage.setItem(key, JSON.stringify(s));
location.reload();
Then verify:
JSON.parse(localStorage.getItem("openclaw.control.settings.v1")||"{}").token?.length
If you paste the output object from the first diagnostic snippet ({ navTokenLen, href, hasStoredKey, storedLen, appTokenLen }), we can tell exactly which branch you’re in.