From b52fe11c53d200b3038e4e9f92d863bdbbf52152 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Mon, 8 Jun 2026 14:03:08 +0000
Subject: [PATCH] fix(connection): operator client always connects as operator,
never node role
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
GatewayClientFactory created the operator (chat) client with
bootstrapPairAsNode: credential.IsBootstrapToken. For bootstrap tokens
(QR-code and setup-code pairing) this set bootstrapPairAsNode: true, which
caused GetConnectRole() to return "node" — permanently locking the tray app
in node role with no device-token upgrade path. Every reconnect re-used the
bootstrap token, GetConnectRole() returned "node" again, and chat.send was
rejected with "unauthorized role: node".
Fix: set bootstrapPairAsNode: false unconditionally for the operator client.
The operator (chat) client should always connect as "operator" regardless of
credential type. The node-platform client has its own creation path
(NodeConnector) which sets bootstrapPairAsNode as needed.
Updated GatewayClientFactoryTests to reflect the corrected behaviour and
added a regression test in OpenClawGatewayClientTests covering the
bootstrap-token operator-role case.
Closes #720
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.../GatewayClientFactory.cs | 6 ++++-
.../GatewayClientFactoryTests.cs | 10 ++++++--
.../OpenClawGatewayClientTests.cs | 25 +++++++++++++++++++
3 files changed, 38 insertions(+), 3 deletions(-)
diff --git a/src/OpenClaw.Connection/GatewayClientFactory.cs b/src/OpenClaw.Connection/GatewayClientFactory.cs
index 5ea0c59d..9802579a 100644
--- a/src/OpenClaw.Connection/GatewayClientFactory.cs
+++ b/src/OpenClaw.Connection/GatewayClientFactory.cs
@@ -14,12 +14,16 @@ public IGatewayClientLifecycle Create(
string identityPath,
IOpenClawLogger logger)
{
+ // bootstrapPairAsNode is always false for the operator (chat) client: the operator
+ // client must connect as "operator" role regardless of credential type so that chat
+ // works immediately after QR-code or setup-code pairing. The node-platform client
+ // has its own creation path (NodeConnector) which sets bootstrapPairAsNode as needed.
var client = new OpenClawGatewayClient(
gatewayUrl,
credential.Token,
logger,
tokenIsBootstrapToken: credential.IsBootstrapToken,
- bootstrapPairAsNode: credential.IsBootstrapToken,
+ bootstrapPairAsNode: false,
identityPath: identityPath);
return new GatewayClientLifecycleAdapter(client);
diff --git a/tests/OpenClaw.Connection.Tests/GatewayClientFactoryTests.cs b/tests/OpenClaw.Connection.Tests/GatewayClientFactoryTests.cs
index 73b6a2b8..318d31e4 100644
--- a/tests/OpenClaw.Connection.Tests/GatewayClientFactoryTests.cs
+++ b/tests/OpenClaw.Connection.Tests/GatewayClientFactoryTests.cs
@@ -6,8 +6,13 @@ namespace OpenClaw.Connection.Tests;
public sealed class GatewayClientFactoryTests
{
+ ///
+ /// Regression test for issue #720: the operator (chat) client must connect as "operator"
+ /// role even when the credential is a bootstrap token. Setting bootstrapPairAsNode: false
+ /// for the operator client ensures chat works immediately after QR-code or setup-code pairing.
+ ///
[Fact]
- public void Create_BootstrapCredential_PairsAsNode()
+ public void Create_BootstrapCredential_PairsAsOperator()
{
var tempDir = Path.Combine(Path.GetTempPath(), "openclaw-gateway-factory-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(tempDir);
@@ -20,7 +25,8 @@ public void Create_BootstrapCredential_PairsAsNode()
tempDir,
NullLogger.Instance);
- Assert.Equal("node", GetConnectRole(lifecycle.DataClient));
+ // Operator client always connects as "operator", even with a bootstrap token.
+ Assert.Equal("operator", GetConnectRole(lifecycle.DataClient));
}
finally
{
diff --git a/tests/OpenClaw.Shared.Tests/OpenClawGatewayClientTests.cs b/tests/OpenClaw.Shared.Tests/OpenClawGatewayClientTests.cs
index ea9d62bc..8dba7368 100644
--- a/tests/OpenClaw.Shared.Tests/OpenClawGatewayClientTests.cs
+++ b/tests/OpenClaw.Shared.Tests/OpenClawGatewayClientTests.cs
@@ -598,6 +598,31 @@ public void BootstrapNodeHandoff_FreshDevice_RequestsNodeRoleWithoutScopes()
Assert.False(auth.ContainsKey("deviceToken"));
}
+ ///
+ /// Regression test for issue #720: the operator (chat) client must connect as "operator"
+ /// role even when the credential is a bootstrap token (QR-code or setup-code pairing).
+ /// GatewayClientFactory always passes bootstrapPairAsNode: false for the operator client.
+ ///
+ [Fact]
+ public void OperatorClient_WithBootstrapToken_ConnectsAsOperatorRole()
+ {
+ // bootstrapPairAsNode: false — this is what GatewayClientFactory now passes for the operator client.
+ var helper = new GatewayClientTestHelper(
+ tokenIsBootstrapToken: true,
+ bootstrapPairAsNode: false,
+ identityPath: CreateTempIdentityPath());
+ helper.SetDeviceTokenForTest(null);
+
+ var auth = helper.BuildAuthPayload();
+
+ // Must request "operator" role so chat works immediately after QR-code pairing.
+ Assert.Equal("operator", helper.GetConnectRole());
+ // Must send bootstrapToken (not plain token) so the gateway performs bootstrap pairing.
+ Assert.Equal("test-token", auth["bootstrapToken"]);
+ Assert.False(auth.ContainsKey("token"));
+ Assert.False(auth.ContainsKey("deviceToken"));
+ }
+
[Fact]
public void BootstrapNodeHandoff_HelloOkWithNodeRole_DoesNotStorePrimaryNodeTokenAsOperator()
{