QR-code pairing leaves the tray app permanently stuck in role: "node"
Summary
The QR-code pairing flow in the Windows tray app produces a bootstrap token that the gateway ties to a node-role client. The tray app then enters a structural loop: it cannot mint a permanent device token (because that path is gated on !_bootstrapPairAsNode), so every reconnect re-uses the bootstrap token → role stays "node" → chat.send and any other operator.* methods fail with unauthorized role: node.
The user-visible symptom is: after the first successful QR-pair handshake, every chat message from the tray app is rejected with:
send failed: unauthorized role: node
The companion node capabilities (system.run, system.which, etc.) work, but the actual chat surface does not.
Root cause (with code references)
The role is decided by OpenClawGatewayClient.GetConnectRole() (src/OpenClaw.Shared/OpenClawGatewayClient.cs):
private string GetConnectRole()
{
return _bootstrapPairAsNode && _tokenIsBootstrapToken && string.IsNullOrEmpty(_deviceIdentity.DeviceToken)
? "node"
: OperatorRole; // "operator"
}
bootstrapPairAsNode is set from the credential in GatewayClientFactory.cs:
var client = new OpenClawGatewayClient(
gatewayUrl,
credential.Token,
logger,
tokenIsBootstrapToken: credential.IsBootstrapToken,
bootstrapPairAsNode: credential.IsBootstrapToken, // <-- always true for bootstrap tokens
identityPath: identityPath);
So whenever the credential is a bootstrap token (which is exactly what the QR-code pairing path produces), the role is locked to "node".
The device-token minting path that would normally break the loop is gated on the opposite condition:
// OpenClawGatewayClient.cs (paraphrased from the pairing flow)
var newDeviceToken = !_bootstrapPairAsNode ? await MintDeviceToken(...) : null;
So:
_bootstrapPairAsNode = true (always, for QR pairing)
- → role is
"node"
- →
newDeviceToken = null (minting skipped)
- → reconnect re-uses the bootstrap token
- →
_bootstrapPairAsNode is still true
- → role is still
"node"
- → loop
The user can break out of the loop only by:
- Quitting the tray app
- Deleting
%APPDATA%\OpenClawTray\gateways\* and gateways.json (forcing a fresh credential)
- Re-opening the app and using the Advanced setup wizard with a non-bootstrap gateway token directly (not QR code)
Reproduction
- Fresh install of
OpenClawCompanion-Setup-x64.exe on a Windows machine.
- In the gateway's webui, open Settings → Nodes → "Add a device" and show the QR code.
- On Windows: right-click the tray icon → "Pair with QR code" → scan.
- Tray icon turns green, app registers as a node, but any chat message returns
unauthorized role: node.
- Quit and reopen the tray app — it cannot reconnect (saved credential is still a bootstrap token, the device-token path is gated, and the gateway will keep treating reconnects as node role).
Expected behavior
- The tray app should register as an operator-role client (the default) when pairing via QR code for a chat-only install.
- On first successful connect, the tray app should mint a permanent device token, save it locally, and use it on subsequent reconnects — so the bootstrap token is used exactly once.
- The role on the wire should be
operator (or whatever the user chose during onboarding), not auto-flipped to node based on the credential type.
Suggested fix
In GatewayClientFactory.cs, decouple bootstrapPairAsNode from IsBootstrapToken. The bootstrap token is a first-contact credential, not a permanent role assignment. A few options:
- Always pair as
operator by default, and only use node role if the user explicitly opts in to node capabilities during onboarding. (Most aligned with the "chat app" use case for the tray.)
- Persist a role preference in the per-gateway folder (e.g.,
gateways/{uuid}/role.json) so the user's choice during onboarding survives across reconnects.
- Always mint a device token after successful bootstrap, and use that for subsequent reconnects. The bootstrap token is then only used once, and
bootstrapPairAsNode becomes a transient flag, not a permanent one.
Option 1 + 3 together would fix the most cases: a fresh QR-pair flow would default to operator (chat works), and even if a user does want node capabilities, the device-token mint would break the loop and the user could re-elevate to node explicitly if they want.
Workaround (for users hitting this today)
The Advanced setup wizard accepts a non-bootstrap gateway token directly. Users hitting this bug can:
- Quit the tray app.
- Delete
%APPDATA%\OpenClawTray\gateways\* and gateways.json.
- Reopen → "Advanced setup" → URL =
wss://gateway-host:18789 (or your tailnet URL) + Token = a real non-bootstrap gateway token + Session = main.
- Skip the WSL gateway install. The app will register as
operator and chat will work.
This workaround works because a non-bootstrap token sets bootstrapPairAsNode: false from the start, so GetConnectRole() returns "operator" immediately.
Environment
OpenClawCompanion-Setup-x64.exe v2026.6.x (current release as of 2026-06-08)
- Windows 11 24H2, x64
- Gateway:
OpenClaw v2026.6.1 (commit 2e08f0f) on a separate Linux host, exposed via Tailscale Serve (HTTPS on :443 → gateway on 127.0.0.1:18789)
- Connect URL used (tray app → gateway):
wss://<your-gateway>.ts.net (no port; Tailscale Serve proxies 443→18789)
Related notes
- The tray app's
NodeService registers as a separate node-platform client with full node capabilities (camera, screen, system.run, etc.) when EnableNodeMode: true in settings.json. This is a separate "node" concept from the role: "node" in this bug. Confusingly named, but distinct. (Workaround for chat-only installs: set EnableNodeMode: false and all Node*Enabled: false.)
- The tray app's
GatewayUrlHelper.NormalizeForWebSocket is a pure URL normalizer — it does not add a port. If the user supplies wss://gateway:18789 but the gateway is only reachable on :443 via a proxy (e.g., Tailscale Serve), the connect will fail with "Transport error". Workaround: omit the port. (Could be worth a second issue: the wizard's "Advanced setup" should probably auto-detect and strip the port, or document the proxy case more clearly.)
QR-code pairing leaves the tray app permanently stuck in
role: "node"Summary
The QR-code pairing flow in the Windows tray app produces a bootstrap token that the gateway ties to a
node-role client. The tray app then enters a structural loop: it cannot mint a permanent device token (because that path is gated on!_bootstrapPairAsNode), so every reconnect re-uses the bootstrap token → role stays"node"→ chat.send and any otheroperator.*methods fail withunauthorized role: node.The user-visible symptom is: after the first successful QR-pair handshake, every chat message from the tray app is rejected with:
The companion node capabilities (system.run, system.which, etc.) work, but the actual chat surface does not.
Root cause (with code references)
The role is decided by
OpenClawGatewayClient.GetConnectRole()(src/OpenClaw.Shared/OpenClawGatewayClient.cs):bootstrapPairAsNodeis set from the credential inGatewayClientFactory.cs:So whenever the credential is a bootstrap token (which is exactly what the QR-code pairing path produces), the role is locked to
"node".The device-token minting path that would normally break the loop is gated on the opposite condition:
So:
_bootstrapPairAsNode = true(always, for QR pairing)"node"newDeviceToken = null(minting skipped)_bootstrapPairAsNodeis still true"node"The user can break out of the loop only by:
%APPDATA%\OpenClawTray\gateways\*andgateways.json(forcing a fresh credential)Reproduction
OpenClawCompanion-Setup-x64.exeon a Windows machine.unauthorized role: node.Expected behavior
operator(or whatever the user chose during onboarding), not auto-flipped tonodebased on the credential type.Suggested fix
In
GatewayClientFactory.cs, decouplebootstrapPairAsNodefromIsBootstrapToken. The bootstrap token is a first-contact credential, not a permanent role assignment. A few options:operatorby default, and only usenoderole if the user explicitly opts in to node capabilities during onboarding. (Most aligned with the "chat app" use case for the tray.)gateways/{uuid}/role.json) so the user's choice during onboarding survives across reconnects.bootstrapPairAsNodebecomes a transient flag, not a permanent one.Option 1 + 3 together would fix the most cases: a fresh QR-pair flow would default to operator (chat works), and even if a user does want node capabilities, the device-token mint would break the loop and the user could re-elevate to node explicitly if they want.
Workaround (for users hitting this today)
The Advanced setup wizard accepts a non-bootstrap gateway token directly. Users hitting this bug can:
%APPDATA%\OpenClawTray\gateways\*andgateways.json.wss://gateway-host:18789(or your tailnet URL) + Token = a real non-bootstrap gateway token + Session =main.operatorand chat will work.This workaround works because a non-bootstrap token sets
bootstrapPairAsNode: falsefrom the start, soGetConnectRole()returns"operator"immediately.Environment
OpenClawCompanion-Setup-x64.exev2026.6.x (current release as of 2026-06-08)OpenClaw v2026.6.1(commit2e08f0f) on a separate Linux host, exposed via Tailscale Serve (HTTPS on :443 → gateway on127.0.0.1:18789)wss://<your-gateway>.ts.net(no port; Tailscale Serve proxies 443→18789)Related notes
NodeServiceregisters as a separate node-platform client with full node capabilities (camera, screen, system.run, etc.) whenEnableNodeMode: trueinsettings.json. This is a separate "node" concept from therole: "node"in this bug. Confusingly named, but distinct. (Workaround for chat-only installs: setEnableNodeMode: falseand allNode*Enabled: false.)GatewayUrlHelper.NormalizeForWebSocketis a pure URL normalizer — it does not add a port. If the user supplieswss://gateway:18789but the gateway is only reachable on :443 via a proxy (e.g., Tailscale Serve), the connect will fail with "Transport error". Workaround: omit the port. (Could be worth a second issue: the wizard's "Advanced setup" should probably auto-detect and strip the port, or document the proxy case more clearly.)