Skip to content

QR-code pairing leaves the tray app permanently stuck in role: "node" #720

@Valkster70

Description

@Valkster70

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:

  1. Quitting the tray app
  2. Deleting %APPDATA%\OpenClawTray\gateways\* and gateways.json (forcing a fresh credential)
  3. Re-opening the app and using the Advanced setup wizard with a non-bootstrap gateway token directly (not QR code)

Reproduction

  1. Fresh install of OpenClawCompanion-Setup-x64.exe on a Windows machine.
  2. In the gateway's webui, open Settings → Nodes → "Add a device" and show the QR code.
  3. On Windows: right-click the tray icon → "Pair with QR code" → scan.
  4. Tray icon turns green, app registers as a node, but any chat message returns unauthorized role: node.
  5. 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:

  1. 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.)
  2. 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.
  3. 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:

  1. Quit the tray app.
  2. Delete %APPDATA%\OpenClawTray\gateways\* and gateways.json.
  3. Reopen → "Advanced setup" → URL = wss://gateway-host:18789 (or your tailnet URL) + Token = a real non-bootstrap gateway token + Session = main.
  4. 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.)

Metadata

Metadata

Assignees

No one assigned

    Labels

    P1Urgent regression or broken agent/channel workflow affecting real users now.clawsweeper:fix-shape-clearClawSweeper found a clear likely implementation shape for this issue.clawsweeper:queueable-fixClawSweeper marked this issue as an existing queue_fix_pr work candidate.clawsweeper:source-reproClawSweeper found a high-confidence source-level issue reproduction.impact:auth-providerThis issue is about auth, provider routing, model choice, or SecretRef resolution.impact:message-lossThis issue is about lost, duplicated, misrouted, or suppressed channel messages.issue-rating: 🦞 diamond lobsterVery strong issue quality with high-confidence source-level or clear reproduction.

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions