Skip to content

Commit b17e77a

Browse files
authored
Require approval for setup-code device pairing [AI] (#81292)
* fix: require approval for setup-code bootstrap pairing * addressing review-skill * addressing codex review * addressing codex review * addressing codex review * addressing codex review * addressing codex review * addressing ci * addressing ci * docs: add changelog entry for PR merge
1 parent 05bef5d commit b17e77a

26 files changed

Lines changed: 753 additions & 336 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai
66

77
### Fixes
88

9+
- Require approval for setup-code device pairing [AI]. (#81292) Thanks @pgondhi987.
910
- Docker: pin setup-time container paths so stale host `.env` OpenClaw paths cannot leak into Linux containers. Fixes #80381. (#81105) Thanks @brokemac79.
1011
- Channels/WeCom: refresh the official onboarding install to `@wecom/wecom-openclaw-plugin@2026.5.7` and update existing managed npm installs instead of failing on the package directory. Fixes #79884. (#80390) Thanks @brokemac79.
1112
- Control UI/WebChat: keep short assistant replies clear of in-bubble copy/open action buttons by applying the existing reserved action spacing in the grouped chat renderer. Fixes #79509. (#81244) Thanks @JARVIS-Glasses.

apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1612,15 +1612,6 @@ internal fun resolveOperatorSessionConnectAuth(
16121612
)
16131613
}
16141614

1615-
val explicitBootstrapToken = auth.bootstrapToken?.trim()?.takeIf { it.isNotEmpty() }
1616-
if (explicitBootstrapToken != null) {
1617-
return NodeRuntime.GatewayConnectAuth(
1618-
token = null,
1619-
bootstrapToken = explicitBootstrapToken,
1620-
password = null,
1621-
)
1622-
}
1623-
16241615
return null
16251616
}
16261617

apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt

Lines changed: 43 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ data class GatewayConnectErrorDetails(
6464
val code: String?,
6565
val canRetryWithDeviceToken: Boolean,
6666
val recommendedNextStep: String?,
67+
val pauseReconnect: Boolean? = null,
6768
val reason: String? = null,
6869
)
6970

@@ -736,6 +737,7 @@ class GatewaySession(
736737
code = it["code"].asStringOrNull(),
737738
canRetryWithDeviceToken = it["canRetryWithDeviceToken"].asBooleanOrNull() == true,
738739
recommendedNextStep = it["recommendedNextStep"].asStringOrNull(),
740+
pauseReconnect = it["pauseReconnect"].asBooleanOrNull(),
739741
reason = it["reason"].asStringOrNull(),
740742
)
741743
}
@@ -1040,20 +1042,17 @@ class GatewaySession(
10401042
detailCode == "AUTH_TOKEN_MISMATCH"
10411043
}
10421044

1043-
private fun shouldPauseReconnectAfterAuthFailure(error: ErrorShape): Boolean =
1044-
when (error.details?.code) {
1045-
"AUTH_TOKEN_MISSING",
1046-
"AUTH_BOOTSTRAP_TOKEN_INVALID",
1047-
"AUTH_PASSWORD_MISSING",
1048-
"AUTH_PASSWORD_MISMATCH",
1049-
"AUTH_RATE_LIMITED",
1050-
"PAIRING_REQUIRED",
1051-
"CONTROL_UI_DEVICE_IDENTITY_REQUIRED",
1052-
"DEVICE_IDENTITY_REQUIRED",
1053-
-> true
1054-
"AUTH_TOKEN_MISMATCH" -> deviceTokenRetryBudgetUsed && !pendingDeviceTokenRetry
1055-
else -> false
1056-
}
1045+
private fun shouldPauseReconnectAfterAuthFailure(error: ErrorShape): Boolean {
1046+
val target = desired
1047+
return shouldPauseGatewayReconnectAfterAuthFailure(
1048+
error = error,
1049+
hasBootstrapToken = target?.bootstrapToken?.trim()?.isNotEmpty() == true,
1050+
role = target?.options?.role,
1051+
scopes = target?.options?.scopes ?: emptyList(),
1052+
deviceTokenRetryBudgetUsed = deviceTokenRetryBudgetUsed,
1053+
pendingDeviceTokenRetry = pendingDeviceTokenRetry,
1054+
)
1055+
}
10571056

10581057
private fun shouldClearStoredDeviceTokenAfterRetry(error: ErrorShape): Boolean = error.details?.code == "AUTH_DEVICE_TOKEN_MISMATCH"
10591058

@@ -1068,6 +1067,36 @@ class GatewaySession(
10681067
}
10691068
}
10701069

1070+
internal fun shouldPauseGatewayReconnectAfterAuthFailure(
1071+
error: GatewaySession.ErrorShape,
1072+
hasBootstrapToken: Boolean,
1073+
role: String?,
1074+
scopes: List<String>,
1075+
deviceTokenRetryBudgetUsed: Boolean,
1076+
pendingDeviceTokenRetry: Boolean,
1077+
): Boolean =
1078+
when (error.details?.code) {
1079+
"AUTH_TOKEN_MISSING",
1080+
"AUTH_BOOTSTRAP_TOKEN_INVALID",
1081+
"AUTH_PASSWORD_MISSING",
1082+
"AUTH_PASSWORD_MISMATCH",
1083+
"AUTH_RATE_LIMITED",
1084+
"CONTROL_UI_DEVICE_IDENTITY_REQUIRED",
1085+
"DEVICE_IDENTITY_REQUIRED",
1086+
-> true
1087+
"PAIRING_REQUIRED" ->
1088+
!(
1089+
hasBootstrapToken &&
1090+
role?.trim() == "node" &&
1091+
scopes.isEmpty() &&
1092+
error.details.reason == "not-paired" &&
1093+
(error.details.pauseReconnect == false ||
1094+
error.details.recommendedNextStep == "wait_then_retry")
1095+
)
1096+
"AUTH_TOKEN_MISMATCH" -> deviceTokenRetryBudgetUsed && !pendingDeviceTokenRetry
1097+
else -> false
1098+
}
1099+
10711100
internal fun buildGatewayWebSocketUrl(
10721101
host: String,
10731102
port: Int,

apps/android/app/src/test/java/ai/openclaw/app/GatewayBootstrapAuthTest.kt

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -29,14 +29,14 @@ import java.util.UUID
2929
@Config(sdk = [34])
3030
class GatewayBootstrapAuthTest {
3131
@Test
32-
fun connectsOperatorSessionWhenOnlyBootstrapAuthExists() {
33-
assertTrue(
32+
fun doesNotConnectOperatorSessionWhenOnlyBootstrapAuthExists() {
33+
assertFalse(
3434
shouldConnectOperatorSession(
3535
NodeRuntime.GatewayConnectAuth(token = "", bootstrapToken = "bootstrap-1", password = ""),
3636
storedOperatorToken = "",
3737
),
3838
)
39-
assertTrue(
39+
assertFalse(
4040
shouldConnectOperatorSession(
4141
NodeRuntime.GatewayConnectAuth(token = null, bootstrapToken = "bootstrap-1", password = null),
4242
storedOperatorToken = null,
@@ -84,17 +84,14 @@ class GatewayBootstrapAuthTest {
8484
}
8585

8686
@Test
87-
fun resolveOperatorSessionConnectAuthUsesBootstrapWhenNoStoredOperatorTokenExists() {
87+
fun resolveOperatorSessionConnectAuthIgnoresBootstrapWhenNoStoredOperatorTokenExists() {
8888
val resolved =
8989
resolveOperatorSessionConnectAuth(
9090
auth = NodeRuntime.GatewayConnectAuth(token = null, bootstrapToken = "bootstrap-1", password = null),
9191
storedOperatorToken = null,
9292
)
9393

94-
assertEquals(
95-
NodeRuntime.GatewayConnectAuth(token = null, bootstrapToken = "bootstrap-1", password = null),
96-
resolved,
97-
)
94+
assertNull(resolved)
9895
}
9996

10097
@Test
@@ -174,7 +171,7 @@ class GatewayBootstrapAuthTest {
174171

175172
assertEquals("fp-1", prefs.loadGatewayTlsFingerprint(endpoint.stableId))
176173
assertEquals("setup-bootstrap-token", desiredBootstrapToken(runtime, "nodeSession"))
177-
assertEquals("setup-bootstrap-token", desiredBootstrapToken(runtime, "operatorSession"))
174+
assertNull(desiredBootstrapToken(runtime, "operatorSession"))
178175
}
179176

180177
@Test
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
package ai.openclaw.app.gateway
2+
3+
import org.junit.Assert.assertFalse
4+
import org.junit.Assert.assertTrue
5+
import org.junit.Test
6+
7+
class GatewaySessionReconnectTest {
8+
@Test
9+
fun bootstrapNodePairingRequiredKeepsReconnectActive() {
10+
val error =
11+
GatewaySession.ErrorShape(
12+
code = "NOT_PAIRED",
13+
message = "pairing required",
14+
details =
15+
GatewayConnectErrorDetails(
16+
code = "PAIRING_REQUIRED",
17+
canRetryWithDeviceToken = false,
18+
recommendedNextStep = "wait_then_retry",
19+
pauseReconnect = false,
20+
reason = "not-paired",
21+
),
22+
)
23+
24+
assertFalse(
25+
shouldPauseGatewayReconnectAfterAuthFailure(
26+
error = error,
27+
hasBootstrapToken = true,
28+
role = "node",
29+
scopes = emptyList(),
30+
deviceTokenRetryBudgetUsed = false,
31+
pendingDeviceTokenRetry = false,
32+
),
33+
)
34+
}
35+
36+
@Test
37+
fun bootstrapNodePairingRequiredWithoutRetryHintPausesReconnect() {
38+
val error =
39+
GatewaySession.ErrorShape(
40+
code = "NOT_PAIRED",
41+
message = "pairing required",
42+
details =
43+
GatewayConnectErrorDetails(
44+
code = "PAIRING_REQUIRED",
45+
canRetryWithDeviceToken = false,
46+
recommendedNextStep = null,
47+
reason = "not-paired",
48+
),
49+
)
50+
51+
assertTrue(
52+
shouldPauseGatewayReconnectAfterAuthFailure(
53+
error = error,
54+
hasBootstrapToken = true,
55+
role = "node",
56+
scopes = emptyList(),
57+
deviceTokenRetryBudgetUsed = false,
58+
pendingDeviceTokenRetry = false,
59+
),
60+
)
61+
}
62+
63+
@Test
64+
fun nonBootstrapPairingRequiredStillPausesReconnect() {
65+
val error =
66+
GatewaySession.ErrorShape(
67+
code = "NOT_PAIRED",
68+
message = "pairing required",
69+
details =
70+
GatewayConnectErrorDetails(
71+
code = "PAIRING_REQUIRED",
72+
canRetryWithDeviceToken = false,
73+
recommendedNextStep = "wait_then_retry",
74+
reason = "not-paired",
75+
),
76+
)
77+
78+
assertTrue(
79+
shouldPauseGatewayReconnectAfterAuthFailure(
80+
error = error,
81+
hasBootstrapToken = false,
82+
role = "node",
83+
scopes = emptyList(),
84+
deviceTokenRetryBudgetUsed = false,
85+
pendingDeviceTokenRetry = false,
86+
),
87+
)
88+
}
89+
90+
@Test
91+
fun bootstrapRoleUpgradeStillPausesReconnect() {
92+
val error =
93+
GatewaySession.ErrorShape(
94+
code = "NOT_PAIRED",
95+
message = "pairing required",
96+
details =
97+
GatewayConnectErrorDetails(
98+
code = "PAIRING_REQUIRED",
99+
canRetryWithDeviceToken = false,
100+
recommendedNextStep = null,
101+
reason = "role-upgrade",
102+
),
103+
)
104+
105+
assertTrue(
106+
shouldPauseGatewayReconnectAfterAuthFailure(
107+
error = error,
108+
hasBootstrapToken = true,
109+
role = "node",
110+
scopes = emptyList(),
111+
deviceTokenRetryBudgetUsed = false,
112+
pendingDeviceTokenRetry = false,
113+
),
114+
)
115+
}
116+
}

docs/channels/pairing.md

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -123,12 +123,10 @@ The setup code is a base64-encoded JSON payload that contains:
123123

124124
That bootstrap token carries the built-in pairing bootstrap profile:
125125

126-
- primary handed-off `node` token stays `scopes: []`
127-
- any handed-off `operator` token stays bounded to the bootstrap allowlist:
128-
`operator.approvals`, `operator.read`, `operator.talk.secrets`, `operator.write`
129-
- bootstrap scope checks are role-prefixed, not one flat scope pool:
130-
operator scope entries only satisfy operator requests, and non-operator roles
131-
must still request scopes under their own role prefix
126+
- the built-in setup profile allows only the `node` role
127+
- after approval, the handed-off `node` token stays `scopes: []`
128+
- the built-in setup-code flow does not hand off an `operator` token
129+
- operator access requires a separate approved operator pairing or token flow
132130
- later token rotation/revocation remains bounded by both the device's approved
133131
role contract and the caller session's operator scopes
134132

docs/channels/telegram.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -457,7 +457,7 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
457457
- `/pair approve` when there is only one pending request
458458
- `/pair approve latest` for most recent
459459

460-
The setup code carries a short-lived bootstrap token. Built-in bootstrap handoff keeps the primary node token at `scopes: []`; any handed-off operator token stays bounded to `operator.approvals`, `operator.read`, `operator.talk.secrets`, and `operator.write`. Bootstrap scope checks are role-prefixed, so that operator allowlist only satisfies operator requests; non-operator roles still need scopes under their own role prefix.
460+
The setup code carries a short-lived bootstrap token. Built-in setup-code bootstrap is node-only: the first connect creates a pending node request, and after approval the Gateway returns a durable node token with `scopes: []`. It does not return a handed-off operator token; operator access requires a separate approved operator pairing or token flow.
461461

462462
If a device retries with changed auth details (for example role/scopes/public key), the previous pending request is superseded and the new request uses a different `requestId`. Re-run `/pair pending` before approving.
463463

docs/cli/qr.md

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,8 @@ openclaw qr --url wss://gateway.example/ws
3535

3636
- `--token` and `--password` are mutually exclusive.
3737
- The setup code itself now carries an opaque short-lived `bootstrapToken`, not the shared gateway token/password.
38-
- In the built-in node/operator bootstrap flow, the primary node token still lands with `scopes: []`.
39-
- If bootstrap handoff also issues an operator token, it stays bounded to the bootstrap allowlist: `operator.approvals`, `operator.read`, `operator.talk.secrets`, `operator.write`.
40-
- Bootstrap scope checks are role-prefixed. That operator allowlist only satisfies operator requests; non-operator roles still need scopes under their own role prefix.
38+
- Built-in setup-code bootstrap is node-only. After approval, the primary node token lands with `scopes: []`.
39+
- The built-in setup-code flow does not return a handed-off operator token; operator access requires a separate approved operator pairing or token flow.
4140
- Mobile pairing fails closed for Tailscale/public `ws://` gateway URLs. Private LAN addresses and `.local` Bonjour hosts remain supported over `ws://`, but Tailscale/public mobile routes should use Tailscale Serve/Funnel or a `wss://` gateway URL.
4241
- With `--remote`, OpenClaw requires either `gateway.remote.url` or
4342
`gateway.tailscale.mode=serve|funnel`.

docs/gateway/protocol.md

Lines changed: 19 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -147,32 +147,24 @@ When a device token is issued, `hello-ok` also includes:
147147
}
148148
```
149149

150-
During trusted bootstrap handoff, `hello-ok.auth` may also include additional
151-
bounded role entries in `deviceTokens`:
150+
Built-in QR/setup-code bootstrap is node-only. After the owner approves the
151+
pending node request, `hello-ok.auth` includes the primary node token:
152152

153153
```json
154154
{
155155
"auth": {
156156
"deviceToken": "",
157157
"role": "node",
158-
"scopes": [],
159-
"deviceTokens": [
160-
{
161-
"deviceToken": "",
162-
"role": "operator",
163-
"scopes": ["operator.approvals", "operator.read", "operator.talk.secrets", "operator.write"]
164-
}
165-
]
158+
"scopes": []
166159
}
167160
}
168161
```
169162

170-
For the built-in node/operator bootstrap flow, the primary node token stays
171-
`scopes: []` and any handed-off operator token stays bounded to the bootstrap
172-
operator allowlist (`operator.approvals`, `operator.read`,
173-
`operator.talk.secrets`, `operator.write`). Bootstrap scope checks stay
174-
role-prefixed: operator entries only satisfy operator requests, and non-operator
175-
roles still need scopes under their own role prefix.
163+
The built-in setup-code flow does not include additional `deviceTokens` entries
164+
or hand off an operator token. Client authors should treat the optional
165+
`hello-ok.auth.deviceTokens` field as legacy/custom bootstrap extension data:
166+
persist it only when present on a trusted transport, and do not require it for
167+
built-in pairing.
176168

177169
### Node example
178170

@@ -694,9 +686,17 @@ rather than the pre-handshake defaults.
694686
`AUTH_TOKEN_MISMATCH` retry is gated to **trusted endpoints only**
695687
loopback, or `wss://` with a pinned `tlsFingerprint`. Public `wss://`
696688
without pinning does not qualify.
697-
- Additional `hello-ok.auth.deviceTokens` entries are bootstrap handoff tokens.
698-
Persist them only when the connect used bootstrap auth on a trusted transport
699-
such as `wss://` or loopback/local pairing.
689+
- Built-in setup-code bootstrap returns only the primary node
690+
`hello-ok.auth.deviceToken`; clients must not expect an additional operator
691+
token in `hello-ok.auth.deviceTokens`.
692+
- While built-in setup-code bootstrap is waiting for approval, `PAIRING_REQUIRED`
693+
details include `recommendedNextStep: "wait_then_retry"`, `retryable: true`,
694+
and `pauseReconnect: false`. Clients should keep reconnecting with the same
695+
bootstrap token until the request is approved or the token becomes invalid.
696+
- If an older or custom trusted bootstrap flow includes optional
697+
`hello-ok.auth.deviceTokens` entries, persist them only when the connect used
698+
bootstrap auth on a trusted transport such as `wss://` or loopback/local
699+
pairing.
700700
- If a client supplies an **explicit** `deviceToken` or explicit `scopes`, that
701701
caller-requested scope set remains authoritative; cached scopes are only
702702
reused when the client is reusing the stored per-device token.

docs/help/faq.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1459,7 +1459,7 @@ lives on the [Models FAQ](/help/faq-models).
14591459
- On `AUTH_TOKEN_MISMATCH`, trusted clients can attempt one bounded retry with a cached device token when the gateway returns retry hints (`canRetryWithDeviceToken=true`, `recommendedNextStep=retry_with_device_token`).
14601460
- That cached-token retry now reuses the cached approved scopes stored with the device token. Explicit `deviceToken` / explicit `scopes` callers still keep their requested scope set instead of inheriting cached scopes.
14611461
- Outside that retry path, connect auth precedence is explicit shared token/password first, then explicit `deviceToken`, then stored device token, then bootstrap token.
1462-
- Bootstrap token scope checks are role-prefixed. The built-in bootstrap operator allowlist only satisfies operator requests; node or other non-operator roles still need scopes under their own role prefix.
1462+
- Built-in setup-code bootstrap is node-only. After approval, it returns a node device token with `scopes: []` and does not return a handed-off operator token.
14631463

14641464
Fix:
14651465

0 commit comments

Comments
 (0)