diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b4864f95b2..77bebe96f94 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,6 +62,7 @@ Docs: https://docs.openclaw.ai - Mobile pairing/device approval: mint both node and operator device tokens when one approval grants merged roles, so mixed mobile bootstrap pairings stop reconnecting as operator-only and showing the node offline. (#60208) Thanks @obviyus. - Agents/tool policy: stop `tools.profile` warnings from flagging runtime-gated baseline core tools as unknown when the coding profile is missing tools like `code_execution`, `x_search`, `image`, or `image_generate`, while still warning on explicit extra allowlist entries. Thanks @vincentkoc. - Sessions/resolution: collapse alias-duplicate session-id matches before scoring, keep distinct structural ties ambiguous, and prefer current-store reuse when resolving equal cross-store duplicates so follow-up turns stop dropping or duplicating sessions on timestamp ties. +- Mobile pairing/bootstrap: keep setup bootstrap tokens alive through the initial node auto-pair so the same QR bootstrap token can finish operator approval, then revoke it after the full issued profile connects successfully. (#60221) Thanks @obviyus. ## 2026.4.2 diff --git a/src/gateway/server.auth.control-ui.suite.ts b/src/gateway/server.auth.control-ui.suite.ts index 9b257b58163..feaff761b71 100644 --- a/src/gateway/server.auth.control-ui.suite.ts +++ b/src/gateway/server.auth.control-ui.suite.ts @@ -710,7 +710,7 @@ export function registerControlUiAndPairingSuite(): void { restoreGatewayToken(prevToken); }); - test("auto-approves fresh node bootstrap pairing from qr setup code", async () => { + test("auto-approves fresh node-only bootstrap pairing and revokes the token after connect", async () => { const { issueDeviceBootstrapToken } = await import("../infra/device-bootstrap.js"); const { getPairedDevice, listDevicePairing } = await import("../infra/device-pairing.js"); const { server, ws, port, prevToken } = await startServerWithClient("secret"); @@ -728,7 +728,12 @@ export function registerControlUiAndPairingSuite(): void { }; try { - const issued = await issueDeviceBootstrapToken(); + const issued = await issueDeviceBootstrapToken({ + profile: { + roles: ["node"], + scopes: [], + }, + }); const wsBootstrap = await openWs(port, REMOTE_BOOTSTRAP_HEADERS); const initial = await connectReq(wsBootstrap, { skipDefaultAuth: true, @@ -800,6 +805,108 @@ export function registerControlUiAndPairingSuite(): void { } }); + test("keeps setup bootstrap tokens valid until operator approval completes", async () => { + const { issueDeviceBootstrapToken } = await import("../infra/device-bootstrap.js"); + const { approveDevicePairing, getPairedDevice, listDevicePairing } = + await import("../infra/device-pairing.js"); + const { server, ws, port, prevToken } = await startServerWithClient("secret"); + ws.close(); + + const { identityPath, identity, client } = await createOperatorIdentityFixture( + "openclaw-bootstrap-setup-profile-", + ); + const nodeClient = { + ...client, + id: "openclaw-android", + mode: "node", + }; + const operatorClient = { + ...client, + id: "openclaw-android", + mode: "ui", + }; + const operatorScopes = ["operator.read", "operator.write", "operator.talk.secrets"]; + + try { + const issued = await issueDeviceBootstrapToken(); + + const wsNode = await openWs(port, REMOTE_BOOTSTRAP_HEADERS); + const nodeConnect = await connectReq(wsNode, { + skipDefaultAuth: true, + bootstrapToken: issued.token, + role: "node", + scopes: [], + client: nodeClient, + deviceIdentityPath: identityPath, + }); + expect(nodeConnect.ok).toBe(true); + wsNode.close(); + + const pairedAfterNode = await getPairedDevice(identity.deviceId); + expect(pairedAfterNode?.roles).toEqual(expect.arrayContaining(["node"])); + + const wsOperatorPending = await openWs(port, REMOTE_BOOTSTRAP_HEADERS); + const operatorPending = await connectReq(wsOperatorPending, { + skipDefaultAuth: true, + bootstrapToken: issued.token, + role: "operator", + scopes: operatorScopes, + client: operatorClient, + deviceIdentityPath: identityPath, + }); + expect(operatorPending.ok).toBe(false); + expect((operatorPending.error?.details as { code?: string } | undefined)?.code).toBe( + ConnectErrorDetailCodes.PAIRING_REQUIRED, + ); + wsOperatorPending.close(); + + const pending = (await listDevicePairing()).pending.filter( + (entry) => entry.deviceId === identity.deviceId, + ); + expect(pending).toHaveLength(1); + const pendingRequest = pending[0]; + if (!pendingRequest) { + throw new Error("expected pending pairing request"); + } + await approveDevicePairing(pendingRequest.requestId, { + callerScopes: pendingRequest.scopes ?? ["operator.admin"], + }); + + const wsOperatorApproved = await openWs(port, REMOTE_BOOTSTRAP_HEADERS); + const operatorApproved = await connectReq(wsOperatorApproved, { + skipDefaultAuth: true, + bootstrapToken: issued.token, + role: "operator", + scopes: operatorScopes, + client: operatorClient, + deviceIdentityPath: identityPath, + }); + expect(operatorApproved.ok).toBe(true); + wsOperatorApproved.close(); + + const pairedAfterOperator = await getPairedDevice(identity.deviceId); + expect(pairedAfterOperator?.roles).toEqual(expect.arrayContaining(["node", "operator"])); + + const wsReplay = await openWs(port, REMOTE_BOOTSTRAP_HEADERS); + const replay = await connectReq(wsReplay, { + skipDefaultAuth: true, + bootstrapToken: issued.token, + role: "operator", + scopes: operatorScopes, + client: operatorClient, + deviceIdentityPath: identityPath, + }); + expect(replay.ok).toBe(false); + expect((replay.error?.details as { code?: string } | undefined)?.code).toBe( + ConnectErrorDetailCodes.AUTH_BOOTSTRAP_TOKEN_INVALID, + ); + wsReplay.close(); + } finally { + await server.close(); + restoreGatewayToken(prevToken); + } + }); + test("requires approval for bootstrap-auth role upgrades on already-paired devices", async () => { const { issueDeviceBootstrapToken } = await import("../infra/device-bootstrap.js"); const { approveDevicePairing, getPairedDevice, listDevicePairing, requestDevicePairing } = diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index 639e58fadb4..b3880df267e 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -3,6 +3,7 @@ import os from "node:os"; import type { WebSocket } from "ws"; import { loadConfig } from "../../../config/config.js"; import { + getDeviceBootstrapTokenProfile, revokeDeviceBootstrapToken, verifyDeviceBootstrapToken, } from "../../../infra/device-bootstrap.js"; @@ -31,6 +32,7 @@ import { upsertPresence } from "../../../infra/system-presence.js"; import { loadVoiceWakeConfig } from "../../../infra/voicewake.js"; import { rawDataToString } from "../../../infra/ws.js"; import type { createSubsystemLogger } from "../../../logging/subsystem.js"; +import type { DeviceBootstrapProfile } from "../../../shared/device-bootstrap-profile.js"; import { roleScopesAllow } from "../../../shared/operator-scope-compat.js"; import { isBrowserOperatorUiClient, @@ -144,6 +146,44 @@ function resolvePinnedClientMetadata(params: { }; } +function resolveBootstrapProfileScopes(role: string, scopes: readonly string[]): string[] { + if (role === "operator") { + return scopes.filter((scope) => scope.startsWith("operator.")); + } + return scopes.filter((scope) => !scope.startsWith("operator.")); +} + +function pairedDeviceSatisfiesBootstrapProfile( + pairedDevice: Awaited>, + bootstrapProfile: DeviceBootstrapProfile, +): boolean { + if (!pairedDevice) { + return false; + } + const approvedScopes = Array.isArray(pairedDevice.approvedScopes) + ? pairedDevice.approvedScopes + : Array.isArray(pairedDevice.scopes) + ? pairedDevice.scopes + : []; + for (const bootstrapRole of bootstrapProfile.roles) { + if (!hasEffectivePairedDeviceRole(pairedDevice, bootstrapRole)) { + return false; + } + const requestedScopes = resolveBootstrapProfileScopes(bootstrapRole, bootstrapProfile.scopes); + if ( + requestedScopes.length > 0 && + !roleScopesAllow({ + role: bootstrapRole, + requestedScopes, + allowedScopes: approvedScopes, + }) + ) { + return false; + } + } + return true; +} + export function attachGatewayWsMessageHandler(params: { socket: WebSocket; upgradeReq: IncomingMessage; @@ -696,6 +736,10 @@ export function attachGatewayWsMessageHandler(params: { rejectUnauthorized(authResult); return; } + const bootstrapProfile = + authMethod === "bootstrap-token" && bootstrapTokenCandidate + ? await getDeviceBootstrapTokenProfile({ token: bootstrapTokenCandidate }) + : null; const trustedProxyAuthOk = isTrustedProxyControlUiOperatorAuth({ isControlUi, @@ -791,9 +835,9 @@ export function attachGatewayWsMessageHandler(params: { isWebchat, reason, }); - // QR bootstrap onboarding is node-only and single-use. When a fresh device presents - // a valid bootstrap token for the baseline node profile, complete pairing in the same - // handshake so iOS does not get stuck retrying with an already-consumed bootstrap token. + // Bootstrap setup can silently pair the first fresh node connect. Keep the token alive + // until the issued profile is fully redeemed so follow-up operator connects can still + // present the same bootstrap token while approval is pending. const allowSilentBootstrapPairing = authMethod === "bootstrap-token" && reason === "not-paired" && @@ -832,16 +876,6 @@ export function attachGatewayWsMessageHandler(params: { callerScopes: scopes, }); if (approved?.status === "approved") { - if (allowSilentBootstrapPairing && bootstrapTokenCandidate) { - const revoked = await revokeDeviceBootstrapToken({ - token: bootstrapTokenCandidate, - }); - if (!revoked.removed) { - logGateway.warn( - `bootstrap token revoke skipped after silent auto-approval device=${approved.device.deviceId}`, - ); - } - } logGateway.info( `device pairing auto-approved device=${approved.device.deviceId} role=${approved.device.role ?? "unknown"}`, ); @@ -991,6 +1025,22 @@ export function attachGatewayWsMessageHandler(params: { const deviceToken = device ? await ensureDeviceToken({ deviceId: device.id, role, scopes }) : null; + if ( + authMethod === "bootstrap-token" && + bootstrapProfile && + bootstrapTokenCandidate && + device && + pairedDeviceSatisfiesBootstrapProfile(await getPairedDevice(device.id), bootstrapProfile) + ) { + const revoked = await revokeDeviceBootstrapToken({ + token: bootstrapTokenCandidate, + }); + if (!revoked.removed) { + logGateway.warn( + `bootstrap token revoke skipped after profile redemption device=${device.id}`, + ); + } + } if (role === "node") { const reconciliation = await reconcileNodePairingOnConnect({ diff --git a/src/infra/device-bootstrap.test.ts b/src/infra/device-bootstrap.test.ts index 9344c0db3a0..2733560f866 100644 --- a/src/infra/device-bootstrap.test.ts +++ b/src/infra/device-bootstrap.test.ts @@ -5,6 +5,7 @@ import { createTrackedTempDirs } from "../test-utils/tracked-temp-dirs.js"; import { clearDeviceBootstrapTokens, DEVICE_BOOTSTRAP_TOKEN_TTL_MS, + getDeviceBootstrapTokenProfile, issueDeviceBootstrapToken, revokeDeviceBootstrapToken, verifyDeviceBootstrapToken, @@ -93,6 +94,19 @@ describe("device bootstrap tokens", () => { }); }); + it("loads the issued bootstrap profile for a valid token", async () => { + const baseDir = await createTempDir(); + const issued = await issueDeviceBootstrapToken({ baseDir }); + + await expect(getDeviceBootstrapTokenProfile({ baseDir, token: issued.token })).resolves.toEqual( + { + roles: ["node", "operator"], + scopes: ["operator.read", "operator.talk.secrets", "operator.write"], + }, + ); + await expect(getDeviceBootstrapTokenProfile({ baseDir, token: "invalid" })).resolves.toBeNull(); + }); + it("clears outstanding bootstrap tokens on demand", async () => { const baseDir = await createTempDir(); const first = await issueDeviceBootstrapToken({ baseDir }); diff --git a/src/infra/device-bootstrap.ts b/src/infra/device-bootstrap.ts index ede8daf9f8e..beb49df4a39 100644 --- a/src/infra/device-bootstrap.ts +++ b/src/infra/device-bootstrap.ts @@ -169,6 +169,23 @@ export async function revokeDeviceBootstrapToken(params: { }); } +export async function getDeviceBootstrapTokenProfile(params: { + token: string; + baseDir?: string; +}): Promise { + return await withLock(async () => { + const providedToken = params.token.trim(); + if (!providedToken) { + return null; + } + const state = await loadState(params.baseDir); + const found = Object.values(state).find((candidate) => + verifyPairingToken(providedToken, candidate.token), + ); + return found ? resolvePersistedBootstrapProfile(found) : null; + }); +} + export async function verifyDeviceBootstrapToken(params: { token: string; deviceId: string;