diff --git a/CHANGELOG.md b/CHANGELOG.md index 46c76d282c5..1aefd866ff4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Require approval for setup-code device pairing [AI]. (#81292) Thanks @pgondhi987. - Docker: pin setup-time container paths so stale host `.env` OpenClaw paths cannot leak into Linux containers. Fixes #80381. (#81105) Thanks @brokemac79. - 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. - 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. diff --git a/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt b/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt index 42e7ab614d9..a5d1933a134 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt @@ -1612,15 +1612,6 @@ internal fun resolveOperatorSessionConnectAuth( ) } - val explicitBootstrapToken = auth.bootstrapToken?.trim()?.takeIf { it.isNotEmpty() } - if (explicitBootstrapToken != null) { - return NodeRuntime.GatewayConnectAuth( - token = null, - bootstrapToken = explicitBootstrapToken, - password = null, - ) - } - return null } diff --git a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt index a08c820d3e9..5e678566388 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt @@ -64,6 +64,7 @@ data class GatewayConnectErrorDetails( val code: String?, val canRetryWithDeviceToken: Boolean, val recommendedNextStep: String?, + val pauseReconnect: Boolean? = null, val reason: String? = null, ) @@ -736,6 +737,7 @@ class GatewaySession( code = it["code"].asStringOrNull(), canRetryWithDeviceToken = it["canRetryWithDeviceToken"].asBooleanOrNull() == true, recommendedNextStep = it["recommendedNextStep"].asStringOrNull(), + pauseReconnect = it["pauseReconnect"].asBooleanOrNull(), reason = it["reason"].asStringOrNull(), ) } @@ -1040,20 +1042,17 @@ class GatewaySession( detailCode == "AUTH_TOKEN_MISMATCH" } - private fun shouldPauseReconnectAfterAuthFailure(error: ErrorShape): Boolean = - when (error.details?.code) { - "AUTH_TOKEN_MISSING", - "AUTH_BOOTSTRAP_TOKEN_INVALID", - "AUTH_PASSWORD_MISSING", - "AUTH_PASSWORD_MISMATCH", - "AUTH_RATE_LIMITED", - "PAIRING_REQUIRED", - "CONTROL_UI_DEVICE_IDENTITY_REQUIRED", - "DEVICE_IDENTITY_REQUIRED", - -> true - "AUTH_TOKEN_MISMATCH" -> deviceTokenRetryBudgetUsed && !pendingDeviceTokenRetry - else -> false - } + private fun shouldPauseReconnectAfterAuthFailure(error: ErrorShape): Boolean { + val target = desired + return shouldPauseGatewayReconnectAfterAuthFailure( + error = error, + hasBootstrapToken = target?.bootstrapToken?.trim()?.isNotEmpty() == true, + role = target?.options?.role, + scopes = target?.options?.scopes ?: emptyList(), + deviceTokenRetryBudgetUsed = deviceTokenRetryBudgetUsed, + pendingDeviceTokenRetry = pendingDeviceTokenRetry, + ) + } private fun shouldClearStoredDeviceTokenAfterRetry(error: ErrorShape): Boolean = error.details?.code == "AUTH_DEVICE_TOKEN_MISMATCH" @@ -1068,6 +1067,36 @@ class GatewaySession( } } +internal fun shouldPauseGatewayReconnectAfterAuthFailure( + error: GatewaySession.ErrorShape, + hasBootstrapToken: Boolean, + role: String?, + scopes: List, + deviceTokenRetryBudgetUsed: Boolean, + pendingDeviceTokenRetry: Boolean, +): Boolean = + when (error.details?.code) { + "AUTH_TOKEN_MISSING", + "AUTH_BOOTSTRAP_TOKEN_INVALID", + "AUTH_PASSWORD_MISSING", + "AUTH_PASSWORD_MISMATCH", + "AUTH_RATE_LIMITED", + "CONTROL_UI_DEVICE_IDENTITY_REQUIRED", + "DEVICE_IDENTITY_REQUIRED", + -> true + "PAIRING_REQUIRED" -> + !( + hasBootstrapToken && + role?.trim() == "node" && + scopes.isEmpty() && + error.details.reason == "not-paired" && + (error.details.pauseReconnect == false || + error.details.recommendedNextStep == "wait_then_retry") + ) + "AUTH_TOKEN_MISMATCH" -> deviceTokenRetryBudgetUsed && !pendingDeviceTokenRetry + else -> false + } + internal fun buildGatewayWebSocketUrl( host: String, port: Int, diff --git a/apps/android/app/src/test/java/ai/openclaw/app/GatewayBootstrapAuthTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/GatewayBootstrapAuthTest.kt index 54f31879b24..2f284c65d59 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/GatewayBootstrapAuthTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/GatewayBootstrapAuthTest.kt @@ -29,14 +29,14 @@ import java.util.UUID @Config(sdk = [34]) class GatewayBootstrapAuthTest { @Test - fun connectsOperatorSessionWhenOnlyBootstrapAuthExists() { - assertTrue( + fun doesNotConnectOperatorSessionWhenOnlyBootstrapAuthExists() { + assertFalse( shouldConnectOperatorSession( NodeRuntime.GatewayConnectAuth(token = "", bootstrapToken = "bootstrap-1", password = ""), storedOperatorToken = "", ), ) - assertTrue( + assertFalse( shouldConnectOperatorSession( NodeRuntime.GatewayConnectAuth(token = null, bootstrapToken = "bootstrap-1", password = null), storedOperatorToken = null, @@ -84,17 +84,14 @@ class GatewayBootstrapAuthTest { } @Test - fun resolveOperatorSessionConnectAuthUsesBootstrapWhenNoStoredOperatorTokenExists() { + fun resolveOperatorSessionConnectAuthIgnoresBootstrapWhenNoStoredOperatorTokenExists() { val resolved = resolveOperatorSessionConnectAuth( auth = NodeRuntime.GatewayConnectAuth(token = null, bootstrapToken = "bootstrap-1", password = null), storedOperatorToken = null, ) - assertEquals( - NodeRuntime.GatewayConnectAuth(token = null, bootstrapToken = "bootstrap-1", password = null), - resolved, - ) + assertNull(resolved) } @Test @@ -174,7 +171,7 @@ class GatewayBootstrapAuthTest { assertEquals("fp-1", prefs.loadGatewayTlsFingerprint(endpoint.stableId)) assertEquals("setup-bootstrap-token", desiredBootstrapToken(runtime, "nodeSession")) - assertEquals("setup-bootstrap-token", desiredBootstrapToken(runtime, "operatorSession")) + assertNull(desiredBootstrapToken(runtime, "operatorSession")) } @Test diff --git a/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewaySessionReconnectTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewaySessionReconnectTest.kt new file mode 100644 index 00000000000..439ff0a410f --- /dev/null +++ b/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewaySessionReconnectTest.kt @@ -0,0 +1,116 @@ +package ai.openclaw.app.gateway + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class GatewaySessionReconnectTest { + @Test + fun bootstrapNodePairingRequiredKeepsReconnectActive() { + val error = + GatewaySession.ErrorShape( + code = "NOT_PAIRED", + message = "pairing required", + details = + GatewayConnectErrorDetails( + code = "PAIRING_REQUIRED", + canRetryWithDeviceToken = false, + recommendedNextStep = "wait_then_retry", + pauseReconnect = false, + reason = "not-paired", + ), + ) + + assertFalse( + shouldPauseGatewayReconnectAfterAuthFailure( + error = error, + hasBootstrapToken = true, + role = "node", + scopes = emptyList(), + deviceTokenRetryBudgetUsed = false, + pendingDeviceTokenRetry = false, + ), + ) + } + + @Test + fun bootstrapNodePairingRequiredWithoutRetryHintPausesReconnect() { + val error = + GatewaySession.ErrorShape( + code = "NOT_PAIRED", + message = "pairing required", + details = + GatewayConnectErrorDetails( + code = "PAIRING_REQUIRED", + canRetryWithDeviceToken = false, + recommendedNextStep = null, + reason = "not-paired", + ), + ) + + assertTrue( + shouldPauseGatewayReconnectAfterAuthFailure( + error = error, + hasBootstrapToken = true, + role = "node", + scopes = emptyList(), + deviceTokenRetryBudgetUsed = false, + pendingDeviceTokenRetry = false, + ), + ) + } + + @Test + fun nonBootstrapPairingRequiredStillPausesReconnect() { + val error = + GatewaySession.ErrorShape( + code = "NOT_PAIRED", + message = "pairing required", + details = + GatewayConnectErrorDetails( + code = "PAIRING_REQUIRED", + canRetryWithDeviceToken = false, + recommendedNextStep = "wait_then_retry", + reason = "not-paired", + ), + ) + + assertTrue( + shouldPauseGatewayReconnectAfterAuthFailure( + error = error, + hasBootstrapToken = false, + role = "node", + scopes = emptyList(), + deviceTokenRetryBudgetUsed = false, + pendingDeviceTokenRetry = false, + ), + ) + } + + @Test + fun bootstrapRoleUpgradeStillPausesReconnect() { + val error = + GatewaySession.ErrorShape( + code = "NOT_PAIRED", + message = "pairing required", + details = + GatewayConnectErrorDetails( + code = "PAIRING_REQUIRED", + canRetryWithDeviceToken = false, + recommendedNextStep = null, + reason = "role-upgrade", + ), + ) + + assertTrue( + shouldPauseGatewayReconnectAfterAuthFailure( + error = error, + hasBootstrapToken = true, + role = "node", + scopes = emptyList(), + deviceTokenRetryBudgetUsed = false, + pendingDeviceTokenRetry = false, + ), + ) + } +} diff --git a/docs/channels/pairing.md b/docs/channels/pairing.md index 9482d770ae7..7102e3643a8 100644 --- a/docs/channels/pairing.md +++ b/docs/channels/pairing.md @@ -123,12 +123,10 @@ The setup code is a base64-encoded JSON payload that contains: That bootstrap token carries the built-in pairing bootstrap profile: -- primary handed-off `node` token stays `scopes: []` -- any handed-off `operator` token stays bounded to the bootstrap allowlist: - `operator.approvals`, `operator.read`, `operator.talk.secrets`, `operator.write` -- bootstrap scope checks are role-prefixed, not one flat scope pool: - operator scope entries only satisfy operator requests, and non-operator roles - must still request scopes under their own role prefix +- the built-in setup profile allows only the `node` role +- after approval, the handed-off `node` token stays `scopes: []` +- the built-in setup-code flow does not hand off an `operator` token +- operator access requires a separate approved operator pairing or token flow - later token rotation/revocation remains bounded by both the device's approved role contract and the caller session's operator scopes diff --git a/docs/channels/telegram.md b/docs/channels/telegram.md index 0e2b4b6ef8f..46b6d9304cc 100644 --- a/docs/channels/telegram.md +++ b/docs/channels/telegram.md @@ -457,7 +457,7 @@ curl "https://api.telegram.org/bot/getUpdates" - `/pair approve` when there is only one pending request - `/pair approve latest` for most recent - 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. + 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. 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. diff --git a/docs/cli/qr.md b/docs/cli/qr.md index 25742309116..52be232d987 100644 --- a/docs/cli/qr.md +++ b/docs/cli/qr.md @@ -35,9 +35,8 @@ openclaw qr --url wss://gateway.example/ws - `--token` and `--password` are mutually exclusive. - The setup code itself now carries an opaque short-lived `bootstrapToken`, not the shared gateway token/password. -- In the built-in node/operator bootstrap flow, the primary node token still lands with `scopes: []`. -- If bootstrap handoff also issues an operator token, it stays bounded to the bootstrap allowlist: `operator.approvals`, `operator.read`, `operator.talk.secrets`, `operator.write`. -- Bootstrap scope checks are role-prefixed. That operator allowlist only satisfies operator requests; non-operator roles still need scopes under their own role prefix. +- Built-in setup-code bootstrap is node-only. After approval, the primary node token lands with `scopes: []`. +- 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. - 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. - With `--remote`, OpenClaw requires either `gateway.remote.url` or `gateway.tailscale.mode=serve|funnel`. diff --git a/docs/gateway/protocol.md b/docs/gateway/protocol.md index 05f2b6a97cb..4a8a6a94ef0 100644 --- a/docs/gateway/protocol.md +++ b/docs/gateway/protocol.md @@ -147,32 +147,24 @@ When a device token is issued, `hello-ok` also includes: } ``` -During trusted bootstrap handoff, `hello-ok.auth` may also include additional -bounded role entries in `deviceTokens`: +Built-in QR/setup-code bootstrap is node-only. After the owner approves the +pending node request, `hello-ok.auth` includes the primary node token: ```json { "auth": { "deviceToken": "…", "role": "node", - "scopes": [], - "deviceTokens": [ - { - "deviceToken": "…", - "role": "operator", - "scopes": ["operator.approvals", "operator.read", "operator.talk.secrets", "operator.write"] - } - ] + "scopes": [] } } ``` -For the built-in node/operator bootstrap flow, the primary node token stays -`scopes: []` and any handed-off operator token stays bounded to the bootstrap -operator allowlist (`operator.approvals`, `operator.read`, -`operator.talk.secrets`, `operator.write`). Bootstrap scope checks stay -role-prefixed: operator entries only satisfy operator requests, and non-operator -roles still need scopes under their own role prefix. +The built-in setup-code flow does not include additional `deviceTokens` entries +or hand off an operator token. Client authors should treat the optional +`hello-ok.auth.deviceTokens` field as legacy/custom bootstrap extension data: +persist it only when present on a trusted transport, and do not require it for +built-in pairing. ### Node example @@ -694,9 +686,17 @@ rather than the pre-handshake defaults. `AUTH_TOKEN_MISMATCH` retry is gated to **trusted endpoints only** — loopback, or `wss://` with a pinned `tlsFingerprint`. Public `wss://` without pinning does not qualify. -- Additional `hello-ok.auth.deviceTokens` entries are bootstrap handoff tokens. - Persist them only when the connect used bootstrap auth on a trusted transport - such as `wss://` or loopback/local pairing. +- Built-in setup-code bootstrap returns only the primary node + `hello-ok.auth.deviceToken`; clients must not expect an additional operator + token in `hello-ok.auth.deviceTokens`. +- While built-in setup-code bootstrap is waiting for approval, `PAIRING_REQUIRED` + details include `recommendedNextStep: "wait_then_retry"`, `retryable: true`, + and `pauseReconnect: false`. Clients should keep reconnecting with the same + bootstrap token until the request is approved or the token becomes invalid. +- If an older or custom trusted bootstrap flow includes optional + `hello-ok.auth.deviceTokens` entries, persist them only when the connect used + bootstrap auth on a trusted transport such as `wss://` or loopback/local + pairing. - If a client supplies an **explicit** `deviceToken` or explicit `scopes`, that caller-requested scope set remains authoritative; cached scopes are only reused when the client is reusing the stored per-device token. diff --git a/docs/help/faq.md b/docs/help/faq.md index 62d0b12a62f..64701416678 100644 --- a/docs/help/faq.md +++ b/docs/help/faq.md @@ -1459,7 +1459,7 @@ lives on the [Models FAQ](/help/faq-models). - 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`). - 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. - Outside that retry path, connect auth precedence is explicit shared token/password first, then explicit `deviceToken`, then stored device token, then bootstrap token. - - 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. + - 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. Fix: diff --git a/src/gateway/client.test.ts b/src/gateway/client.test.ts index fe7ed4fc6c4..1a1fbe8eb7e 100644 --- a/src/gateway/client.test.ts +++ b/src/gateway/client.test.ts @@ -1184,6 +1184,37 @@ describe("GatewayClient connect auth payload", () => { }); }); + it("keeps reconnecting on PAIRING_REQUIRED when retry hints keep reconnect active", async () => { + vi.useFakeTimers(); + const onReconnectPaused = vi.fn(); + const client = new GatewayClient({ + url: "ws://127.0.0.1:18789", + bootstrapToken: "setup-bootstrap-token", + role: "node", + scopes: [], + onReconnectPaused, + }); + + try { + const { ws: ws1, connect: firstConnect } = startClientAndConnect({ client }); + emitConnectFailure(ws1, firstConnect.id, { + code: "PAIRING_REQUIRED", + reason: "not-paired", + recommendedNextStep: "wait_then_retry", + pauseReconnect: false, + }); + + await vi.advanceTimersByTimeAsync(999); + expect(wsInstances).toHaveLength(1); + await vi.advanceTimersByTimeAsync(1); + expect(wsInstances).toHaveLength(2); + expect(onReconnectPaused).not.toHaveBeenCalled(); + } finally { + client.stop(); + vi.useRealTimers(); + } + }); + it("clears stale stored device tokens and does not reconnect on AUTH_DEVICE_TOKEN_MISMATCH", async () => { loadDeviceAuthTokenMock.mockReturnValue({ token: "stored-device-token", diff --git a/src/gateway/client.ts b/src/gateway/client.ts index 07a02033dca..a8d1efe7905 100644 --- a/src/gateway/client.ts +++ b/src/gateway/client.ts @@ -38,6 +38,7 @@ import { formatConnectErrorMessage, readConnectErrorDetailCode, readConnectErrorRecoveryAdvice, + readPairingConnectErrorDetails, type ConnectErrorRecoveryAdvice, } from "./protocol/connect-error-details.js"; import { @@ -222,6 +223,7 @@ export class GatewayClient { private deviceTokenRetryBudgetUsed = false; private pendingStartupReconnectDelayMs: number | null = null; private pendingConnectErrorDetailCode: string | null = null; + private pendingConnectErrorDetails: unknown = null; // Track last tick to detect silent stalls. private lastTick: number | null = null; private tickIntervalMs = 30_000; @@ -337,7 +339,9 @@ export class GatewayClient { ws.on("close", (code, reason) => { const reasonText = rawDataToString(reason); const connectErrorDetailCode = this.pendingConnectErrorDetailCode; + const connectErrorDetails = this.pendingConnectErrorDetails; this.pendingConnectErrorDetailCode = null; + this.pendingConnectErrorDetails = null; if (this.ws === ws) { this.ws = null; } @@ -369,7 +373,12 @@ export class GatewayClient { } } this.flushPendingErrors(new Error(`gateway closed (${code}): ${reasonText}`)); - if (this.shouldPauseReconnectAfterAuthFailure(connectErrorDetailCode)) { + if ( + this.shouldPauseReconnectAfterAuthFailure({ + detailCode: connectErrorDetailCode, + details: connectErrorDetails, + }) + ) { this.opts.onReconnectPaused?.({ code, reason: reasonText, @@ -428,6 +437,7 @@ export class GatewayClient { this.deviceTokenRetryBudgetUsed = false; this.pendingStartupReconnectDelayMs = null; this.pendingConnectErrorDetailCode = null; + this.pendingConnectErrorDetails = null; this.clearReconnectTimer(); if (this.tickTimer) { clearInterval(this.tickTimer); @@ -576,6 +586,7 @@ export class GatewayClient { this.deviceTokenRetryBudgetUsed = false; this.pendingStartupReconnectDelayMs = null; this.pendingConnectErrorDetailCode = null; + this.pendingConnectErrorDetails = null; const authInfo = helloOk?.auth; if (authInfo?.deviceToken && this.opts.deviceIdentity) { storeDeviceAuthToken({ @@ -598,6 +609,8 @@ export class GatewayClient { .catch((err) => { this.pendingConnectErrorDetailCode = err instanceof GatewayClientRequestError ? readConnectErrorDetailCode(err.details) : null; + this.pendingConnectErrorDetails = + err instanceof GatewayClientRequestError ? err.details : null; const shouldRetryWithDeviceToken = this.shouldRetryWithStoredDeviceToken({ error: err, explicitGatewayToken: normalizeOptionalString(this.opts.token), @@ -682,10 +695,22 @@ export class GatewayClient { }; } - private shouldPauseReconnectAfterAuthFailure(detailCode: string | null): boolean { + private shouldPauseReconnectAfterAuthFailure(params: { + detailCode: string | null; + details?: unknown; + }): boolean { + const { detailCode, details } = params; if (!detailCode) { return false; } + const pairingDetails = readPairingConnectErrorDetails(details); + if ( + detailCode === ConnectErrorDetailCodes.PAIRING_REQUIRED && + (pairingDetails?.pauseReconnect === false || + pairingDetails?.recommendedNextStep === "wait_then_retry") + ) { + return false; + } if ( detailCode === ConnectErrorDetailCodes.AUTH_TOKEN_MISSING || detailCode === ConnectErrorDetailCodes.AUTH_BOOTSTRAP_TOKEN_INVALID || diff --git a/src/gateway/protocol/connect-error-details.test.ts b/src/gateway/protocol/connect-error-details.test.ts index 0373d1b14ad..2d80ab8a746 100644 --- a/src/gateway/protocol/connect-error-details.test.ts +++ b/src/gateway/protocol/connect-error-details.test.ts @@ -74,12 +74,18 @@ describe("pairing connect details", () => { buildPairingConnectErrorDetails({ reason: ConnectPairingRequiredReasons.NOT_PAIRED, requestId: "req-123", + recommendedNextStep: "wait_then_retry", + retryable: true, + pauseReconnect: false, }), ).toEqual({ code: "PAIRING_REQUIRED", reason: "not-paired", requestId: "req-123", remediationHint: "Approve this device from the pending pairing requests.", + recommendedNextStep: "wait_then_retry", + retryable: true, + pauseReconnect: false, }); }); diff --git a/src/gateway/protocol/connect-error-details.ts b/src/gateway/protocol/connect-error-details.ts index 06d3d7a90a9..62d0e402ae3 100644 --- a/src/gateway/protocol/connect-error-details.ts +++ b/src/gateway/protocol/connect-error-details.ts @@ -60,6 +60,9 @@ export type PairingConnectErrorDetails = { reason?: ConnectPairingRequiredReason; requestId?: string; remediationHint?: string; + recommendedNextStep?: ConnectRecoveryNextStep; + retryable?: boolean; + pauseReconnect?: boolean; deviceId?: string; requestedRole?: string; requestedScopes?: string[]; @@ -245,6 +248,9 @@ function createPairingConnectErrorDetails(params: { reason?: ConnectPairingRequiredReason; requestId?: string; remediationHint?: string; + recommendedNextStep?: ConnectRecoveryNextStep; + retryable?: boolean; + pauseReconnect?: boolean; deviceId?: string; requestedRole?: string; requestedScopes?: string[]; @@ -256,6 +262,9 @@ function createPairingConnectErrorDetails(params: { ...(params.reason ? { reason: params.reason } : {}), ...(params.requestId ? { requestId: params.requestId } : {}), ...(params.remediationHint ? { remediationHint: params.remediationHint } : {}), + ...(params.recommendedNextStep ? { recommendedNextStep: params.recommendedNextStep } : {}), + ...(params.retryable !== undefined ? { retryable: params.retryable } : {}), + ...(params.pauseReconnect !== undefined ? { pauseReconnect: params.pauseReconnect } : {}), ...(params.deviceId ? { deviceId: params.deviceId } : {}), ...(params.requestedRole ? { requestedRole: params.requestedRole } : {}), ...(params.requestedScopes ? { requestedScopes: params.requestedScopes } : {}), @@ -300,6 +309,9 @@ export function buildPairingConnectErrorDetails(params: { reason: ConnectPairingRequiredReason | undefined; requestId?: string; remediationHint?: string; + recommendedNextStep?: ConnectRecoveryNextStep; + retryable?: boolean; + pauseReconnect?: boolean; deviceId?: string; requestedRole?: string; requestedScopes?: string[]; @@ -319,6 +331,9 @@ export function buildPairingConnectErrorDetails(params: { reason: params.reason, requestId, remediationHint, + recommendedNextStep: params.recommendedNextStep, + retryable: params.retryable, + pauseReconnect: params.pauseReconnect, deviceId, requestedRole, requestedScopes, @@ -349,6 +364,9 @@ export function readPairingConnectErrorDetails( reason?: unknown; requestId?: unknown; remediationHint?: unknown; + recommendedNextStep?: unknown; + retryable?: unknown; + pauseReconnect?: unknown; deviceId?: unknown; requestedRole?: unknown; requestedScopes?: unknown; @@ -359,6 +377,12 @@ export function readPairingConnectErrorDetails( const requestId = normalizePairingConnectRequestId(raw.requestId); const remediationHint = normalizeOptionalString(raw.remediationHint) ?? buildPairingConnectRemediationHint(reason); + const normalizedNextStep = normalizeOptionalString(raw.recommendedNextStep) ?? ""; + const recommendedNextStep = CONNECT_RECOVERY_NEXT_STEP_VALUES.has( + normalizedNextStep as ConnectRecoveryNextStep, + ) + ? (normalizedNextStep as ConnectRecoveryNextStep) + : undefined; const deviceId = normalizeOptionalString(raw.deviceId); const requestedRole = normalizeOptionalString(raw.requestedRole); const requestedScopes = normalizeStringArray(raw.requestedScopes); @@ -368,6 +392,9 @@ export function readPairingConnectErrorDetails( reason, requestId, remediationHint, + recommendedNextStep, + retryable: typeof raw.retryable === "boolean" ? raw.retryable : undefined, + pauseReconnect: typeof raw.pauseReconnect === "boolean" ? raw.pauseReconnect : undefined, deviceId, requestedRole, requestedScopes, diff --git a/src/gateway/reconnect-gating.test.ts b/src/gateway/reconnect-gating.test.ts index 1930e9641da..62fee08a13e 100644 --- a/src/gateway/reconnect-gating.test.ts +++ b/src/gateway/reconnect-gating.test.ts @@ -63,6 +63,21 @@ describe("isNonRecoverableAuthError", () => { ); }); + it("allows reconnect for PAIRING_REQUIRED when retry hints keep reconnect active", () => { + expect( + isNonRecoverableAuthError({ + code: "connect_failed", + message: "auth failed", + details: { + code: ConnectErrorDetailCodes.PAIRING_REQUIRED, + reason: "not-paired", + recommendedNextStep: "wait_then_retry", + pauseReconnect: false, + }, + }), + ).toBe(false); + }); + it("allows reconnect for AUTH_TOKEN_MISMATCH (device-token fallback flow)", () => { // Browser client can queue a single trusted-device retry after shared token mismatch. // Blocking reconnect on mismatch here would skip that bounded recovery attempt. diff --git a/src/gateway/server.auth.control-ui.suite.ts b/src/gateway/server.auth.control-ui.suite.ts index 4cbc63e1a8a..0576ec828fe 100644 --- a/src/gateway/server.auth.control-ui.suite.ts +++ b/src/gateway/server.auth.control-ui.suite.ts @@ -41,14 +41,6 @@ function expectArrayIncludes(actual: unknown, expectedValues: string[]): void { } } -function expectArrayExcludes(actual: unknown, deniedValues: string[]): void { - expect(Array.isArray(actual)).toBe(true); - const values = actual as unknown[]; - for (const denied of deniedValues) { - expect(values).not.toContain(denied); - } -} - export function registerControlUiAndPairingSuite(): void { const trustedProxyControlUiCases: Array<{ name: string; @@ -1036,11 +1028,11 @@ export function registerControlUiAndPairingSuite(): void { } }); - test("auto-approves fresh node bootstrap pairing from qr setup code", async () => { + test("requires approval before qr setup code returns a durable node token", async () => { const { issueDeviceBootstrapToken, verifyDeviceBootstrapToken } = await import("../infra/device-bootstrap.js"); const { publicKeyRawBase64UrlFromPem } = await import("../infra/device-identity.js"); - const { getPairedDevice, listDevicePairing, verifyDeviceToken } = + const { approveDevicePairing, getPairedDevice, listDevicePairing, verifyDeviceToken } = await import("../infra/device-pairing.js"); const { server, port, prevToken } = await startControlUiServer("secret"); @@ -1066,8 +1058,47 @@ export function registerControlUiAndPairingSuite(): void { client, deviceIdentityPath: identityPath, }); - expect(initial.ok).toBe(true); - const initialPayload = initial.payload as + expect(initial.ok).toBe(false); + expect(initial.error?.message ?? "").toContain("pairing required"); + const initialDetails = initial.error?.details as + | { + code?: string; + pauseReconnect?: boolean; + recommendedNextStep?: string; + retryable?: boolean; + } + | undefined; + expect(initialDetails?.code).toBe(ConnectErrorDetailCodes.PAIRING_REQUIRED); + expect(initialDetails?.recommendedNextStep).toBe("wait_then_retry"); + expect(initialDetails?.retryable).toBe(true); + expect(initialDetails?.pauseReconnect).toBe(false); + + const pendingAfterInitial = await listDevicePairing(); + const pendingForDevice = pendingAfterInitial.pending.filter( + (entry) => entry.deviceId === identity.deviceId, + ); + expect(pendingForDevice).toHaveLength(1); + expect(pendingForDevice[0]?.role).toBe("node"); + expect(pendingForDevice[0]?.roles).toEqual(["node"]); + expect(await getPairedDevice(identity.deviceId)).toBeNull(); + expect( + await approveDevicePairing(pendingForDevice[0]?.requestId ?? "", { + callerScopes: ["operator.pairing"], + }), + ).toMatchObject({ status: "approved" }); + wsBootstrap.close(); + + const wsApproved = await openWs(port, REMOTE_BOOTSTRAP_HEADERS); + const approvedConnect = await connectReq(wsApproved, { + skipDefaultAuth: true, + bootstrapToken: issued.token, + role: "node", + scopes: [], + client, + deviceIdentityPath: identityPath, + }); + expect(approvedConnect.ok).toBe(true); + const approvedPayload = approvedConnect.payload as | { type?: string; auth?: { @@ -1082,61 +1113,32 @@ export function registerControlUiAndPairingSuite(): void { }; } | undefined; - expect(initialPayload?.type).toBe("hello-ok"); - const issuedDeviceToken = initialPayload?.auth?.deviceToken; - const issuedOperatorToken = initialPayload?.auth?.deviceTokens?.find( - (entry) => entry.role === "operator", - )?.deviceToken; - if (!issuedDeviceToken || !issuedOperatorToken) { - throw new Error("expected issued device and operator tokens"); + expect(approvedPayload?.type).toBe("hello-ok"); + const issuedDeviceToken = approvedPayload?.auth?.deviceToken; + if (!issuedDeviceToken) { + throw new Error("expected issued device token"); } - expect(initialPayload?.auth?.role).toBe("node"); - expect(initialPayload?.auth?.scopes ?? []).toEqual([]); - expect(initialPayload?.auth?.deviceTokens?.some((entry) => entry.role === "node")).toBe( - false, - ); - const operatorBootstrapScopes = initialPayload?.auth?.deviceTokens?.find( - (entry) => entry.role === "operator", - )?.scopes; - expectArrayIncludes(operatorBootstrapScopes, [ - "operator.approvals", - "operator.read", - "operator.talk.secrets", - "operator.write", - ]); - expectArrayExcludes(operatorBootstrapScopes, [ - "node.camera", - "node.display", - "node.exec", - "node.voice", - ]); - expectArrayExcludes(operatorBootstrapScopes, ["operator.admin", "operator.pairing"]); + expect(approvedPayload?.auth?.role).toBe("node"); + expect(approvedPayload?.auth?.scopes ?? []).toEqual([]); + expect(approvedPayload?.auth?.deviceTokens ?? []).toEqual([]); const afterBootstrap = await listDevicePairing(); expect( afterBootstrap.pending.filter((entry) => entry.deviceId === identity.deviceId), ).toEqual([]); const paired = await getPairedDevice(identity.deviceId); - expectArrayIncludes(paired?.roles, ["node", "operator"]); - expectArrayIncludes(paired?.approvedScopes, [ - "operator.approvals", - "operator.read", - "operator.talk.secrets", - "operator.write", - ]); + expect(paired?.roles).toEqual(["node"]); + expect(paired?.approvedScopes).toEqual([]); expect(paired?.tokens?.node?.token).toBe(issuedDeviceToken); - expect(paired?.tokens?.operator?.token).toBe(issuedOperatorToken); - if (!issuedDeviceToken || !issuedOperatorToken) { - throw new Error("expected hello-ok auth.deviceTokens for bootstrap onboarding"); - } + expect(paired?.tokens?.operator).toBeUndefined(); await new Promise((resolve) => { - if (wsBootstrap.readyState === WebSocket.CLOSED) { + if (wsApproved.readyState === WebSocket.CLOSED) { resolve(); return; } - wsBootstrap.once("close", () => resolve()); - wsBootstrap.close(); + wsApproved.once("close", () => resolve()); + wsApproved.close(); }); const wsReplay = await openWs(port, REMOTE_BOOTSTRAP_HEADERS); @@ -1187,7 +1189,7 @@ export function registerControlUiAndPairingSuite(): void { await expect( verifyDeviceToken({ deviceId: identity.deviceId, - token: issuedOperatorToken, + token: issuedDeviceToken, role: "operator", scopes: [ "operator.approvals", @@ -1196,7 +1198,76 @@ export function registerControlUiAndPairingSuite(): void { "operator.write", ], }), - ).resolves.toEqual({ ok: true }); + ).resolves.toEqual({ ok: false, reason: "token-missing" }); + } finally { + await server.close(); + restoreGatewayToken(prevToken); + } + }); + + test("rejected qr setup code cannot recreate pending node pairing", async () => { + const { issueDeviceBootstrapToken } = await import("../infra/device-bootstrap.js"); + const { listDevicePairing, rejectDevicePairing } = await import("../infra/device-pairing.js"); + const { server, port, prevToken } = await startControlUiServer("secret"); + const { identityPath, identity } = await createOperatorIdentityFixture( + "openclaw-bootstrap-node-reject-", + ); + const client = { + id: "openclaw-ios", + version: "2026.3.30", + platform: "iOS 26.3.1", + mode: "node", + deviceFamily: "iPhone", + }; + + try { + const issued = await issueDeviceBootstrapToken(); + const wsInitial = await openWs(port, REMOTE_BOOTSTRAP_HEADERS); + const initial = await connectReq(wsInitial, { + skipDefaultAuth: true, + bootstrapToken: issued.token, + role: "node", + scopes: [], + client, + deviceIdentityPath: identityPath, + }); + expect(initial.ok).toBe(false); + expect( + initial.error?.details as { code?: string; pauseReconnect?: boolean } | undefined, + ).toMatchObject({ + code: ConnectErrorDetailCodes.PAIRING_REQUIRED, + pauseReconnect: false, + }); + wsInitial.close(); + + const pending = (await listDevicePairing()).pending.find( + (entry) => entry.deviceId === identity.deviceId, + ); + if (!pending) { + throw new Error("expected pending bootstrap pairing request"); + } + await expect(rejectDevicePairing(pending.requestId)).resolves.toEqual({ + requestId: pending.requestId, + deviceId: identity.deviceId, + }); + + const wsRetry = await openWs(port, REMOTE_BOOTSTRAP_HEADERS); + const retry = await connectReq(wsRetry, { + skipDefaultAuth: true, + bootstrapToken: issued.token, + role: "node", + scopes: [], + client, + deviceIdentityPath: identityPath, + }); + expect(retry.ok).toBe(false); + expect((retry.error?.details as { code?: string } | undefined)?.code).toBe( + ConnectErrorDetailCodes.AUTH_BOOTSTRAP_TOKEN_INVALID, + ); + wsRetry.close(); + expect( + (await listDevicePairing()).pending.filter((entry) => entry.deviceId === identity.deviceId), + ).toEqual([]); } finally { await server.close(); restoreGatewayToken(prevToken); @@ -1205,6 +1276,7 @@ export function registerControlUiAndPairingSuite(): void { test("does not consume bootstrap token when node reconcile fails before hello-ok", async () => { const { issueDeviceBootstrapToken } = await import("../infra/device-bootstrap.js"); + const { approveDevicePairing, listDevicePairing } = await import("../infra/device-pairing.js"); const reconcileModule = await import("./node-connect-reconcile.js"); const reconcileSpy = vi .spyOn(reconcileModule, "reconcileNodePairingOnConnect") @@ -1228,6 +1300,25 @@ export function registerControlUiAndPairingSuite(): void { }, }); + const wsInitial = await openWs(port, REMOTE_BOOTSTRAP_HEADERS); + const initial = await connectReq(wsInitial, { + skipDefaultAuth: true, + bootstrapToken: issued.token, + role: "node", + scopes: [], + client: nodeClient, + deviceIdentityPath: identityPath, + }); + expect(initial.ok).toBe(false); + wsInitial.close(); + const pending = (await listDevicePairing()).pending.find( + (entry) => entry.clientId === nodeClient.id, + ); + if (!pending) { + throw new Error("expected pending bootstrap pairing request"); + } + await approveDevicePairing(pending.requestId, { callerScopes: ["operator.pairing"] }); + const wsFail = await openWs(port, REMOTE_BOOTSTRAP_HEADERS); await expect( connectReq(wsFail, { diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index b427d6ee64b..07eff9dc86c 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -3,10 +3,10 @@ import os from "node:os"; import type { RawData, WebSocket } from "ws"; import { getRuntimeConfig } from "../../../config/io.js"; import { - getBoundDeviceBootstrapProfile, getDeviceBootstrapTokenProfile, redeemDeviceBootstrapTokenProfile, revokeDeviceBootstrapToken, + restoreDeviceBootstrapToken, verifyDeviceBootstrapToken, } from "../../../infra/device-bootstrap.js"; import { @@ -14,7 +14,6 @@ import { normalizeDevicePublicKeyBase64Url, } from "../../../infra/device-identity.js"; import { - approveBootstrapDevicePairing, approveDevicePairing, ensureDeviceToken, getPairedDevice, @@ -42,10 +41,6 @@ import { loadVoiceWakeConfig } from "../../../infra/voicewake.js"; import { rawDataToString } from "../../../infra/ws.js"; import { logRejectedLargePayload } from "../../../logging/diagnostic-payload.js"; import type { createSubsystemLogger } from "../../../logging/subsystem.js"; -import { - resolveBootstrapProfileScopesForRole, - type DeviceBootstrapProfile, -} from "../../../shared/device-bootstrap-profile.js"; import { roleScopesAllow } from "../../../shared/operator-scope-compat.js"; import { isBrowserOperatorUiClient, @@ -900,9 +895,6 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar authMethod === "bootstrap-token" && bootstrapTokenCandidate ? await getDeviceBootstrapTokenProfile({ token: bootstrapTokenCandidate }) : null; - let boundBootstrapProfile: DeviceBootstrapProfile | null = null; - let handoffBootstrapProfile: DeviceBootstrapProfile | null = null; - const trustedProxyAuthOk = isTrustedProxyControlUiOperatorAuth({ isControlUi, role, @@ -997,21 +989,6 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar allowedScopes: pairedScopes, }); }; - if ( - boundBootstrapProfile === null && - authMethod === "bootstrap-token" && - reason === "not-paired" && - role === "node" && - scopes.length === 0 && - !existingPairedDevice && - bootstrapTokenCandidate - ) { - boundBootstrapProfile = await getBoundDeviceBootstrapProfile({ - token: bootstrapTokenCandidate, - deviceId: device.id, - publicKey: devicePublicKey, - }); - } const allowSilentExistingNonOperatorPairing = !( existingPairedDevice && role !== "operator" ); @@ -1039,30 +1016,14 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar autoApproveCidrs: configSnapshot.gateway?.nodes?.pairing?.autoApproveCidrs, }, ); - const allowSilentBootstrapPairing = - authMethod === "bootstrap-token" && - reason === "not-paired" && - role === "node" && - scopes.length === 0 && - !existingPairedDevice && - boundBootstrapProfile !== null; - const bootstrapProfileForSilentApproval = allowSilentBootstrapPairing - ? boundBootstrapProfile - : null; - const bootstrapPairingRoles = bootstrapProfileForSilentApproval - ? Array.from(new Set([role, ...bootstrapProfileForSilentApproval.roles])) - : undefined; const pairing = await requestDevicePairing({ deviceId: device.id, publicKey: devicePublicKey, ...clientPairingMetadata, - ...(bootstrapPairingRoles ? { roles: bootstrapPairingRoles } : {}), silent: reason === "scope-upgrade" ? false - : allowSilentLocalPairing || - allowSilentBootstrapPairing || - allowSilentTrustedCidrsNodePairing, + : allowSilentLocalPairing || allowSilentTrustedCidrsNodePairing, }); const context = buildRequestContext(); let approved: Awaited> | undefined; @@ -1083,18 +1044,10 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar return replacementPending?.requestId; }; if (pairing.request.silent === true) { - approved = bootstrapProfileForSilentApproval - ? await approveBootstrapDevicePairing( - pairing.request.requestId, - bootstrapProfileForSilentApproval, - ) - : await approveDevicePairing(pairing.request.requestId, { - callerScopes: scopes, - }); + approved = await approveDevicePairing(pairing.request.requestId, { + callerScopes: scopes, + }); if (approved?.status === "approved") { - if (bootstrapProfileForSilentApproval) { - handoffBootstrapProfile = bootstrapProfileForSilentApproval; - } logGateway.info( `device pairing auto-approved device=${approved.device.deviceId} role=${approved.device.role ?? "unknown"}`, ); @@ -1143,9 +1096,22 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar ? existingPairedDevice.scopes : [] : []; + const retryAfterBootstrapPairingApproval = + authMethod === "bootstrap-token" && + reason === "not-paired" && + role === "node" && + scopes.length === 0 && + !existingPairedDevice; const pairingErrorDetails = buildPairingConnectErrorDetails({ reason, requestId: recoveryRequestId, + ...(retryAfterBootstrapPairingApproval + ? { + recommendedNextStep: "wait_then_retry", + retryable: true, + pauseReconnect: false, + } + : {}), deviceId: device.id, requestedRole: role, requestedScopes: scopes, @@ -1290,50 +1256,6 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar shouldIssueDeviceToken && device && hasServerApprovedDeviceTokenBaseline ? await ensureDeviceToken({ deviceId: device.id, role, scopes }) : null; - const bootstrapDeviceTokens: Array<{ - deviceToken: string; - role: string; - scopes: string[]; - issuedAtMs: number; - }> = []; - if (deviceToken) { - bootstrapDeviceTokens.push({ - deviceToken: deviceToken.token, - role: deviceToken.role, - scopes: deviceToken.scopes, - issuedAtMs: deviceToken.rotatedAtMs ?? deviceToken.createdAtMs, - }); - } - if (device && handoffBootstrapProfile) { - const bootstrapProfileForHello = handoffBootstrapProfile as DeviceBootstrapProfile; - for (const bootstrapRole of bootstrapProfileForHello.roles) { - if (bootstrapDeviceTokens.some((entry) => entry.role === bootstrapRole)) { - continue; - } - const bootstrapRoleScopes = - bootstrapRole === "operator" - ? resolveBootstrapProfileScopesForRole( - bootstrapRole, - bootstrapProfileForHello.scopes, - ) - : []; - const extraToken = await ensureDeviceToken({ - deviceId: device.id, - role: bootstrapRole, - scopes: bootstrapRoleScopes, - }); - if (!extraToken) { - continue; - } - bootstrapDeviceTokens.push({ - deviceToken: extraToken.token, - role: extraToken.role, - scopes: extraToken.scopes, - issuedAtMs: extraToken.rotatedAtMs ?? extraToken.createdAtMs, - }); - } - } - if (role === "node") { const reconciliation = await reconcileNodePairingOnConnect({ cfg: getRuntimeConfig(), @@ -1561,9 +1483,6 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar ? { deviceToken: deviceToken.token, issuedAtMs: deviceToken.rotatedAtMs ?? deviceToken.createdAtMs, - ...(bootstrapDeviceTokens.length > 1 - ? { deviceTokens: bootstrapDeviceTokens.slice(1) } - : {}), } : {}), }, @@ -1574,25 +1493,12 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar }, }; - try { - await sendFrame({ type: "res", id: frame.id, ok: true, payload: helloOk }); - } catch (err) { - setCloseCause("hello-send-failed", { error: formatForLog(err) }); - close(); - return; - } + let revokedBootstrapTokenRecord: + | Awaited>["record"] + | undefined; if (authMethod === "bootstrap-token" && bootstrapTokenCandidate && device) { try { - if (handoffBootstrapProfile) { - const revoked = await revokeDeviceBootstrapToken({ - token: bootstrapTokenCandidate, - }); - if (!revoked.removed) { - logGateway.warn( - `bootstrap token revoke skipped after device-token handoff device=${device.id}`, - ); - } - } else if (issuedBootstrapProfile) { + if (issuedBootstrapProfile) { const redemption = await redeemDeviceBootstrapTokenProfile({ token: bootstrapTokenCandidate, role, @@ -1606,6 +1512,8 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar logGateway.warn( `bootstrap token revoke skipped after profile redemption device=${device.id}`, ); + } else { + revokedBootstrapTokenRecord = revoked.record; } } } @@ -1615,6 +1523,22 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar ); } } + try { + await sendFrame({ type: "res", id: frame.id, ok: true, payload: helloOk }); + } catch (err) { + if (revokedBootstrapTokenRecord) { + try { + await restoreDeviceBootstrapToken({ record: revokedBootstrapTokenRecord }); + } catch (restoreErr) { + logGateway.warn( + `bootstrap token restore after hello-send failure failed device=${device?.id ?? "unknown"}: ${formatForLog(restoreErr)}`, + ); + } + } + setCloseCause("hello-send-failed", { error: formatForLog(err) }); + close(); + return; + } logWs("out", "hello-ok", { connId, methods: gatewayMethods.length, diff --git a/src/infra/device-bootstrap.test.ts b/src/infra/device-bootstrap.test.ts index 4d478cecc36..1b12c95fc76 100644 --- a/src/infra/device-bootstrap.test.ts +++ b/src/infra/device-bootstrap.test.ts @@ -71,8 +71,8 @@ describe("device bootstrap tokens", () => { expect(parsed[issued.token]?.ts).toBe(Date.now()); expect(parsed[issued.token]?.issuedAtMs).toBe(Date.now()); expect(parsed[issued.token]?.profile).toEqual({ - roles: ["node", "operator"], - scopes: ["operator.approvals", "operator.read", "operator.talk.secrets", "operator.write"], + roles: ["node"], + scopes: [], }); }); @@ -151,8 +151,8 @@ describe("device bootstrap tokens", () => { await expect(getDeviceBootstrapTokenProfile({ baseDir, token: issued.token })).resolves.toEqual( { - roles: ["node", "operator"], - scopes: ["operator.approvals", "operator.read", "operator.talk.secrets", "operator.write"], + roles: ["node"], + scopes: [], }, ); await expect(getDeviceBootstrapTokenProfile({ baseDir, token: "invalid" })).resolves.toBeNull(); @@ -172,7 +172,7 @@ describe("device bootstrap tokens", () => { }), ).resolves.toEqual({ recorded: true, - fullyRedeemed: false, + fullyRedeemed: true, }); await expect( @@ -180,18 +180,7 @@ describe("device bootstrap tokens", () => { role: "operator", scopes: ["operator.approvals", "operator.read", "operator.write", "operator.talk.secrets"], }), - ).resolves.toEqual({ ok: true }); - await expect( - redeemDeviceBootstrapTokenProfile({ - baseDir, - token: issued.token, - role: "operator", - scopes: ["operator.approvals", "operator.read", "operator.write", "operator.talk.secrets"], - }), - ).resolves.toEqual({ - recorded: true, - fullyRedeemed: true, - }); + ).resolves.toEqual({ ok: false, reason: "bootstrap_token_invalid" }); }); it("clears outstanding bootstrap tokens on demand", async () => { @@ -316,9 +305,15 @@ describe("device bootstrap tokens", () => { expect(raw).toContain(issued.token); }); - it("allows operator scope subsets within the issued bootstrap profile", async () => { + it("allows operator scope subsets within an explicitly issued bootstrap profile", async () => { const baseDir = await createTempDir(); - const issued = await issueDeviceBootstrapToken({ baseDir }); + const issued = await issueDeviceBootstrapToken({ + baseDir, + profile: { + roles: ["operator"], + scopes: ["operator.read"], + }, + }); await expect( verifyBootstrapToken(baseDir, issued.token, { @@ -537,8 +532,8 @@ describe("device bootstrap tokens", () => { baseDir, }), ).resolves.toEqual({ - roles: ["node", "operator"], - scopes: ["operator.approvals", "operator.read", "operator.talk.secrets", "operator.write"], + roles: ["node"], + scopes: [], }); }); diff --git a/src/infra/device-bootstrap.ts b/src/infra/device-bootstrap.ts index 5fa5a136f63..b5992e986ba 100644 --- a/src/infra/device-bootstrap.ts +++ b/src/infra/device-bootstrap.ts @@ -288,6 +288,36 @@ export async function revokeDeviceBootstrapToken(params: { }); } +export async function revokeDeviceBootstrapTokensForDevice(params: { + deviceId: string; + publicKey: string; + baseDir?: string; +}): Promise<{ removed: number }> { + return await withLock(async () => { + const deviceId = params.deviceId.trim(); + const publicKey = normalizeBootstrapPublicKey(params.publicKey); + if (!deviceId || !publicKey) { + return { removed: 0 }; + } + const state = await loadState(params.baseDir); + let removed = 0; + for (const [tokenKey, record] of Object.entries(state)) { + const recordPublicKey = + typeof record.publicKey === "string" + ? normalizeBootstrapPublicKey(record.publicKey) + : undefined; + if (record.deviceId?.trim() === deviceId && recordPublicKey === publicKey) { + delete state[tokenKey]; + removed += 1; + } + } + if (removed > 0) { + await persistState(state, params.baseDir); + } + return { removed }; + }); +} + export async function restoreDeviceBootstrapToken(params: { record: DeviceBootstrapTokenRecord; baseDir?: string; diff --git a/src/infra/device-pairing.test.ts b/src/infra/device-pairing.test.ts index 2d37b9822c2..bc856ccf8c3 100644 --- a/src/infra/device-pairing.test.ts +++ b/src/infra/device-pairing.test.ts @@ -14,6 +14,7 @@ import { listDevicePairing, removePairedDevice, requestDevicePairing, + rejectDevicePairing, revokeDeviceToken, rotateDeviceToken, updatePairedDeviceMetadata, @@ -640,6 +641,48 @@ describe("device pairing tokens", () => { expect(paired?.tokens?.operator?.scopes).toEqual(["operator.read"]); }); + test("rejecting a bootstrap-bound pending request revokes the bootstrap token", async () => { + const baseDir = await makeDevicePairingDir(); + const issued = await issueDeviceBootstrapToken({ baseDir }); + + await expect( + verifyDeviceBootstrapToken({ + token: issued.token, + deviceId: "bootstrap-reject-device", + publicKey: "bootstrap-reject-public-key", + role: "node", + scopes: [], + baseDir, + }), + ).resolves.toEqual({ ok: true }); + + const pending = await requestDevicePairing( + { + deviceId: "bootstrap-reject-device", + publicKey: "bootstrap-reject-public-key", + role: "node", + roles: ["node"], + scopes: [], + }, + baseDir, + ); + + await expect(rejectDevicePairing(pending.request.requestId, baseDir)).resolves.toEqual({ + requestId: pending.request.requestId, + deviceId: "bootstrap-reject-device", + }); + await expect( + verifyDeviceBootstrapToken({ + token: issued.token, + deviceId: "bootstrap-reject-device", + publicKey: "bootstrap-reject-public-key", + role: "node", + scopes: [], + baseDir, + }), + ).resolves.toEqual({ ok: false, reason: "bootstrap_token_invalid" }); + }); + test("fails closed for operator approvals when caller scopes are omitted", async () => { const baseDir = await makeDevicePairingDir(); const request = await requestDevicePairing( @@ -989,14 +1032,14 @@ describe("device pairing tokens", () => { expect(paired?.tokens?.node?.scopes).toStrictEqual([]); }); - test("bootstrap pairing seeds node and operator device tokens explicitly", async () => { + test("bootstrap pairing seeds only the requested node token by default", async () => { const baseDir = await makeDevicePairingDir(); const request = await requestDevicePairing( { deviceId: "bootstrap-device-1", publicKey: "bootstrap-public-key-1", role: "node", - roles: ["node", "operator"], + roles: ["node"], scopes: [], silent: true, }, @@ -1011,28 +1054,20 @@ describe("device pairing tokens", () => { expectRecordFields(approved, "approved result", { status: "approved" }); const paired = await getPairedDevice("bootstrap-device-1", baseDir); - expectArrayIncludesAll(paired?.roles, ["node", "operator"], "paired roles"); - expectArrayIncludesAll( - paired?.approvedScopes, - PAIRING_SETUP_BOOTSTRAP_PROFILE.scopes, - "paired approved scopes", - ); + expect(paired?.roles).toEqual(["node"]); + expect(paired?.approvedScopes).toStrictEqual([]); expect(paired?.tokens?.node?.scopes).toStrictEqual([]); - expectArrayIncludesAll( - paired?.tokens?.operator?.scopes, - PAIRING_SETUP_BOOTSTRAP_PROFILE.scopes, - "operator token scopes", - ); + expect(paired?.tokens?.operator).toBeUndefined(); }); - test("bootstrap-issued operator tokens accept handoff scopes and reject admin or pairing", async () => { + test("default bootstrap pairing does not issue operator tokens", async () => { const baseDir = await makeDevicePairingDir(); const request = await requestDevicePairing( { deviceId: "bootstrap-device-operator-default", publicKey: "bootstrap-public-key-operator-default", role: "node", - roles: ["node", "operator"], + roles: ["node"], scopes: [], silent: true, }, @@ -1047,34 +1082,56 @@ describe("device pairing tokens", () => { expectRecordFields(approved, "approved result", { status: "approved" }); const paired = await getPairedDevice("bootstrap-device-operator-default", baseDir); - const operatorToken = requireToken(paired?.tokens?.operator?.token); + const nodeToken = requireToken(paired?.tokens?.node?.token); await expect( verifyDeviceToken({ deviceId: "bootstrap-device-operator-default", - token: operatorToken, + token: nodeToken, role: "operator", scopes: ["operator.approvals", "operator.read", "operator.write"], baseDir, }), + ).resolves.toEqual({ ok: false, reason: "token-missing" }); + }); + + test("bootstrap node approval preserves existing operator token scopes", async () => { + const baseDir = await makeDevicePairingDir(); + await setupPairedOperatorDevice(baseDir, ["operator.admin"]); + const before = await getPairedDevice("device-1", baseDir); + const operatorToken = requireToken(before?.tokens?.operator?.token); + + const request = await requestDevicePairing( + { + deviceId: "device-1", + publicKey: "public-key-1", + role: "node", + roles: ["node"], + scopes: [], + silent: true, + }, + baseDir, + ); + + const approved = await approveBootstrapDevicePairing( + request.request.requestId, + PAIRING_SETUP_BOOTSTRAP_PROFILE, + baseDir, + ); + expectRecordFields(approved, "approved result", { status: "approved" }); + + const paired = await getPairedDevice("device-1", baseDir); + expect(paired?.approvedScopes).toEqual(["operator.admin"]); + expect(paired?.tokens?.operator?.token).toBe(operatorToken); + expect(paired?.tokens?.node?.scopes).toStrictEqual([]); + await expect( + verifyDeviceToken({ + deviceId: "device-1", + token: operatorToken, + role: "operator", + scopes: ["operator.read"], + baseDir, + }), ).resolves.toEqual({ ok: true }); - await expect( - verifyDeviceToken({ - deviceId: "bootstrap-device-operator-default", - token: operatorToken, - role: "operator", - scopes: ["operator.admin"], - baseDir, - }), - ).resolves.toEqual({ ok: false, reason: "scope-mismatch" }); - await expect( - verifyDeviceToken({ - deviceId: "bootstrap-device-operator-default", - token: operatorToken, - role: "operator", - scopes: ["operator.pairing"], - baseDir, - }), - ).resolves.toEqual({ ok: false, reason: "scope-mismatch" }); }); test("bootstrap pairing keeps operator token scopes operator-only", async () => { @@ -1085,7 +1142,7 @@ describe("device pairing tokens", () => { publicKey: "bootstrap-public-key-operator-scope", role: "node", roles: ["node", "operator"], - scopes: [], + scopes: ["node.exec", "operator.read", "operator.write"], silent: true, }, baseDir, @@ -1114,7 +1171,13 @@ describe("device pairing tokens", () => { publicKey: "bootstrap-public-key-bounded-baseline", role: "node", roles: ["node", "operator"], - scopes: [], + scopes: [ + "node.exec", + "operator.approvals", + "operator.read", + "operator.talk.secrets", + "operator.write", + ], silent: true, }, baseDir, @@ -1164,23 +1227,23 @@ describe("device pairing tokens", () => { test("bootstrap pairing sanitizes merged legacy baseline scopes", async () => { const baseDir = await makeDevicePairingDir(); + const bootstrapProfile = { + roles: ["node", "operator"], + scopes: ["operator.approvals", "operator.read", "operator.talk.secrets", "operator.write"], + }; const first = await requestDevicePairing( { deviceId: "bootstrap-device-legacy-baseline", publicKey: "bootstrap-public-key-legacy-baseline", role: "node", roles: ["node", "operator"], - scopes: [], + scopes: bootstrapProfile.scopes, silent: true, }, baseDir, ); - await approveBootstrapDevicePairing( - first.request.requestId, - PAIRING_SETUP_BOOTSTRAP_PROFILE, - baseDir, - ); + await approveBootstrapDevicePairing(first.request.requestId, bootstrapProfile, baseDir); await mutatePairedDevice(baseDir, "bootstrap-device-legacy-baseline", (device) => { device.approvedScopes = ["operator.admin"]; device.scopes = ["operator.admin"]; @@ -1192,20 +1255,20 @@ describe("device pairing tokens", () => { publicKey: "bootstrap-public-key-legacy-baseline-rotated", role: "node", roles: ["node", "operator"], - scopes: [], + scopes: bootstrapProfile.scopes, silent: true, }, baseDir, ); const approved = await approveBootstrapDevicePairing( repair.request.requestId, - PAIRING_SETUP_BOOTSTRAP_PROFILE, + bootstrapProfile, baseDir, ); expectRecordFields(approved, "approved result", { status: "approved" }); const paired = await getPairedDevice("bootstrap-device-legacy-baseline", baseDir); - expect(paired?.approvedScopes).toEqual(PAIRING_SETUP_BOOTSTRAP_PROFILE.scopes); + expect(paired?.approvedScopes).toEqual(bootstrapProfile.scopes); await expect( ensureDeviceToken({ deviceId: "bootstrap-device-legacy-baseline", diff --git a/src/infra/device-pairing.ts b/src/infra/device-pairing.ts index 219499d8219..1e3ee274bb7 100644 --- a/src/infra/device-pairing.ts +++ b/src/infra/device-pairing.ts @@ -10,6 +10,7 @@ import { resolveScopeOutsideRequestedRoles, roleScopesAllow, } from "../shared/operator-scope-compat.js"; +import { revokeDeviceBootstrapTokensForDevice } from "./device-bootstrap.js"; import { createAsyncLock, pruneExpiredPending, @@ -19,7 +20,6 @@ import { resolvePairingPaths, writeJson, } from "./pairing-files.js"; -import { rejectPendingPairingRequest } from "./pairing-pending.js"; import { generatePairingToken, verifyPairingToken } from "./pairing-token.js"; export type DevicePairingPendingRequest = { @@ -430,6 +430,27 @@ function resolveRoleScopedDeviceTokenScopes(role: string, scopes: string[] | und return normalized.filter((scope) => !scope.startsWith(OPERATOR_SCOPE_PREFIX)); } +function preserveRoleScopedApprovalScopes(role: string, scopes: string[] | undefined): string[] { + if (!Array.isArray(scopes)) { + return []; + } + const out = new Set(); + for (const scope of scopes) { + const trimmed = scope.trim(); + if (!trimmed) { + continue; + } + const belongsToRole = + role === OPERATOR_ROLE + ? trimmed.startsWith(OPERATOR_SCOPE_PREFIX) + : !trimmed.startsWith(OPERATOR_SCOPE_PREFIX); + if (belongsToRole) { + out.add(trimmed); + } + } + return [...out]; +} + function resolveApprovedTokenScopes(params: { role: string; pending: DevicePairingPendingRequest; @@ -692,9 +713,6 @@ export async function approveBootstrapDevicePairing( bootstrapProfile: DeviceBootstrapProfile, baseDir?: string, ): Promise { - // QR bootstrap handoff is an explicit trust path: it can seed the bounded - // node/operator baseline from the verified bootstrap profile without routing - // operator scope approval through the generic interactive approval checker. const approvedRoles = mergeRoles(bootstrapProfile.roles) ?? []; const approvedScopes = resolveBootstrapProfileScopesForRoles( approvedRoles, @@ -725,28 +743,26 @@ export async function approveBootstrapDevicePairing( const now = Date.now(); const existing = state.pairedByDeviceId[pending.deviceId]; - const roles = mergeRoles( - existing?.roles, - existing?.role, - pending.roles, - pending.role, - approvedRoles, - ); - const nextApprovedScopes = mergeScopes( - existing?.approvedScopes ?? existing?.scopes, - pending.scopes, - approvedScopes, - ); - const sanitizedApprovedScopes = resolveBootstrapProfileScopesForRoles( - approvedRoles, - nextApprovedScopes ?? [], + const grantedRoles = requestedRoles; + const grantedScopes = resolveBootstrapProfileScopesForRoles(grantedRoles, pending.scopes ?? []); + const grantedRoleSet = new Set(grantedRoles); + const preservedExistingScopes = (mergeRoles(existing?.roles, existing?.role) ?? []).flatMap( + (existingRole) => + grantedRoleSet.has(existingRole) + ? [] + : preserveRoleScopedApprovalScopes( + existingRole, + existing?.approvedScopes ?? existing?.scopes, + ), ); + const roles = mergeRoles(existing?.roles, existing?.role, pending.roles, pending.role); + const nextApprovedScopes = mergeScopes(preservedExistingScopes, grantedScopes); const tokens = existing?.tokens ? { ...existing.tokens } : {}; - for (const roleForToken of approvedRoles) { + for (const roleForToken of grantedRoles) { const existingToken = tokens[roleForToken]; const tokenScopes = roleForToken === OPERATOR_ROLE - ? resolveBootstrapProfileScopesForRole(roleForToken, approvedScopes) + ? resolveBootstrapProfileScopesForRole(roleForToken, grantedScopes) : []; tokens[roleForToken] = buildDeviceAuthToken({ role: roleForToken, @@ -767,8 +783,8 @@ export async function approveBootstrapDevicePairing( clientMode: pending.clientMode, role: pending.role, roles, - scopes: sanitizedApprovedScopes, - approvedScopes: sanitizedApprovedScopes, + scopes: nextApprovedScopes, + approvedScopes: nextApprovedScopes, remoteIp: pending.remoteIp, tokens, createdAtMs: existing?.createdAtMs ?? now, @@ -786,17 +802,19 @@ export async function rejectDevicePairing( baseDir?: string, ): Promise<{ requestId: string; deviceId: string } | null> { return await withLock(async () => { - return await rejectPendingPairingRequest< - DevicePairingPendingRequest, - DevicePairingStateFile, - "deviceId" - >({ - requestId, - idKey: "deviceId", - loadState: () => loadState(baseDir), - persistState: (state) => persistState(state, baseDir, "pending"), - getId: (pending: DevicePairingPendingRequest) => pending.deviceId, + const state = await loadState(baseDir); + const pending = state.pendingById[requestId]; + if (!pending) { + return null; + } + delete state.pendingById[requestId]; + await persistState(state, baseDir, "pending"); + await revokeDeviceBootstrapTokensForDevice({ + deviceId: pending.deviceId, + publicKey: pending.publicKey, + baseDir, }); + return { requestId, deviceId: pending.deviceId }; }); } diff --git a/src/pairing/setup-code.test.ts b/src/pairing/setup-code.test.ts index c27ac23f77e..c8adb27e601 100644 --- a/src/pairing/setup-code.test.ts +++ b/src/pairing/setup-code.test.ts @@ -91,8 +91,8 @@ describe("pairing setup code", () => { expect(issueDeviceBootstrapTokenMock).toHaveBeenCalledWith({ baseDir: undefined, profile: { - roles: ["node", "operator"], - scopes: ["operator.approvals", "operator.read", "operator.talk.secrets", "operator.write"], + roles: ["node"], + scopes: [], }, }); if (params.url) { diff --git a/src/shared/device-bootstrap-profile.test.ts b/src/shared/device-bootstrap-profile.test.ts index 61d71cf2fd5..72462196589 100644 --- a/src/shared/device-bootstrap-profile.test.ts +++ b/src/shared/device-bootstrap-profile.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "vitest"; import { BOOTSTRAP_HANDOFF_OPERATOR_SCOPES, + PAIRING_SETUP_BOOTSTRAP_PROFILE, normalizeDeviceBootstrapHandoffProfile, resolveBootstrapProfileScopesForRole, resolveBootstrapProfileScopesForRoles, @@ -56,7 +57,14 @@ describe("device bootstrap profile", () => { }); }); - test("bootstrap handoff operator allowlist stays aligned with pairing setup profile", () => { + test("default setup profile is node-only", () => { + expect(PAIRING_SETUP_BOOTSTRAP_PROFILE).toEqual({ + roles: ["node"], + scopes: [], + }); + }); + + test("bootstrap handoff operator allowlist stays bounded", () => { expect([...BOOTSTRAP_HANDOFF_OPERATOR_SCOPES]).toEqual([ "operator.approvals", "operator.read", diff --git a/src/shared/device-bootstrap-profile.ts b/src/shared/device-bootstrap-profile.ts index 48c713dacd3..30cc673c12f 100644 --- a/src/shared/device-bootstrap-profile.ts +++ b/src/shared/device-bootstrap-profile.ts @@ -20,8 +20,8 @@ export const BOOTSTRAP_HANDOFF_OPERATOR_SCOPES = [ const BOOTSTRAP_HANDOFF_OPERATOR_SCOPE_SET = new Set(BOOTSTRAP_HANDOFF_OPERATOR_SCOPES); export const PAIRING_SETUP_BOOTSTRAP_PROFILE: DeviceBootstrapProfile = { - roles: ["node", "operator"], - scopes: [...BOOTSTRAP_HANDOFF_OPERATOR_SCOPES], + roles: ["node"], + scopes: [], }; export function resolveBootstrapProfileScopesForRole( diff --git a/ui/src/ui/gateway.node.test.ts b/ui/src/ui/gateway.node.test.ts index 8d82a93fe17..a160a4a73e4 100644 --- a/ui/src/ui/gateway.node.test.ts +++ b/ui/src/ui/gateway.node.test.ts @@ -903,6 +903,44 @@ describe("GatewayBrowserClient", () => { vi.useRealTimers(); }); + it("keeps reconnecting on PAIRING_REQUIRED when retry hints keep reconnect active", async () => { + useNodeFakeTimers(); + localStorage.clear(); + + const client = new GatewayBrowserClient({ + url: "ws://127.0.0.1:18789", + token: "setup-token", + }); + + const { ws: ws1, connectFrame: connect } = await startConnect(client); + + ws1.emitMessage({ + type: "res", + id: connect.id, + ok: false, + error: { + code: "INVALID_REQUEST", + message: "unauthorized", + details: { + code: "PAIRING_REQUIRED", + reason: "not-paired", + recommendedNextStep: "wait_then_retry", + pauseReconnect: false, + }, + }, + }); + await expectSocketClosed(ws1); + ws1.emitClose(4008, "connect failed"); + + await vi.advanceTimersByTimeAsync(799); + expect(wsInstances).toHaveLength(1); + await vi.advanceTimersByTimeAsync(1); + expect(wsInstances).toHaveLength(2); + + client.stop(); + vi.useRealTimers(); + }); + it("clears stale stored device tokens and does not reconnect on AUTH_DEVICE_TOKEN_MISMATCH", async () => { useNodeFakeTimers(); diff --git a/ui/src/ui/gateway.ts b/ui/src/ui/gateway.ts index 6f836d39db1..02e93a3baee 100644 --- a/ui/src/ui/gateway.ts +++ b/ui/src/ui/gateway.ts @@ -10,6 +10,7 @@ import { formatConnectErrorMessage, readConnectErrorRecoveryAdvice, readConnectErrorDetailCode, + readPairingConnectErrorDetails, } from "../../../src/gateway/protocol/connect-error-details.js"; import { isRetryableGatewayStartupUnavailableError, @@ -71,6 +72,14 @@ export function resolveGatewayErrorDetailCode( return readConnectErrorDetailCode(error?.details); } +function shouldContinueReconnectForPairingRequired(details: unknown): boolean { + const pairingDetails = readPairingConnectErrorDetails(details); + return ( + pairingDetails?.pauseReconnect === false || + pairingDetails?.recommendedNextStep === "wait_then_retry" + ); +} + /** * Auth errors that won't resolve without user action — don't auto-reconnect. * @@ -84,6 +93,12 @@ export function isNonRecoverableAuthError(error: GatewayErrorInfo | undefined): return false; } const code = resolveGatewayErrorDetailCode(error); + if ( + code === ConnectErrorDetailCodes.PAIRING_REQUIRED && + shouldContinueReconnectForPairingRequired(error.details) + ) { + return false; + } return ( code === ConnectErrorDetailCodes.AUTH_TOKEN_MISSING || code === ConnectErrorDetailCodes.AUTH_BOOTSTRAP_TOKEN_INVALID ||