From 7d22a16adb212f175607dfd5371980e065724b36 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 4 Apr 2026 22:28:49 +0900 Subject: [PATCH] fix: bound bootstrap handoff token scopes --- CHANGELOG.md | 4 ++- .../GatewayNodeSessionTests.swift | 10 ++++-- docs/gateway/protocol.md | 5 +++ src/gateway/server.auth.control-ui.suite.ts | 8 +++++ .../server/ws-connection/message-handler.ts | 12 +++++-- src/infra/device-pairing.test.ts | 30 +++++++++++++++++ src/infra/device-pairing.ts | 10 ++++-- src/shared/device-bootstrap-profile.test.ts | 33 +++++++++++++++++++ src/shared/device-bootstrap-profile.ts | 23 ++++++++++++- 9 files changed, 127 insertions(+), 8 deletions(-) create mode 100644 src/shared/device-bootstrap-profile.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index c1712911284..038078e725c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -103,7 +103,9 @@ Docs: https://docs.openclaw.ai - Synology Chat/security: default low-level HTTPS helper TLS verification to on so helper/API defaults match the shipped safe account default, and only explicit `allowInsecureSsl: true` opts out. - Android/canvas security: require exact normalized A2UI URL matches before forwarding canvas bridge actions, rejecting query mismatches and descendant paths while still allowing fragment-only A2UI navigation. - Cron: send failure notifications through the job's primary delivery channel using the same session context as successful delivery when no explicit `failureDestination` is configured. (#60622) Thanks @artwalker. -- Gateway/auth: serialize async shared-secret auth attempts per client so concurrent Tailscale-capable failures cannot overrun the intended auth rate-limit budget. Thanks @Telecaster2147.- Doctor/config: compare normalized `talk` configs by deep structural equality instead of key-order-sensitive serialization so `openclaw doctor --fix` stops repeatedly reporting/applying no-op `talk.provider/providers` normalization. (#59911) Thanks @ejames-dev. +- Mobile pairing/bootstrap: keep QR bootstrap handoff tokens bounded to the mobile-safe contract so node handoff stays unscoped and operator handoff drops mixed `node.*`, `operator.admin`, and `operator.pairing` scopes. +- Gateway/auth: serialize async shared-secret auth attempts per client so concurrent Tailscale-capable failures cannot overrun the intended auth rate-limit budget. Thanks @Telecaster2147. +- Doctor/config: compare normalized `talk` configs by deep structural equality instead of key-order-sensitive serialization so `openclaw doctor --fix` stops repeatedly reporting/applying no-op `talk.provider/providers` normalization. (#59911) Thanks @ejames-dev. - Gateway/device auth: reuse cached device-token scopes only for cached-token reconnects, while keeping explicit `deviceToken` scope requests and empty-cache fallbacks intact so reconnects preserve `operator.read` without breaking explicit auth flows. (#46032) Thanks @caicongyang. ## 2026.4.2 diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayNodeSessionTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayNodeSessionTests.swift index f9959190761..e892e0a2409 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayNodeSessionTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayNodeSessionTests.swift @@ -275,8 +275,10 @@ struct GatewayNodeSessionTests { "deviceToken": "operator-device-token", "role": "operator", "scopes": [ + "node.exec", "operator.admin", "operator.approvals", + "operator.pairing", "operator.read", "operator.talk.secrets", "operator.write", @@ -315,8 +317,12 @@ struct GatewayNodeSessionTests { #expect(nodeEntry.token == "node-device-token") #expect(nodeEntry.scopes == []) #expect(operatorEntry.token == "operator-device-token") - #expect(operatorEntry.scopes.contains("operator.approvals")) - #expect(!operatorEntry.scopes.contains("operator.admin")) + #expect(operatorEntry.scopes == [ + "operator.approvals", + "operator.read", + "operator.talk.secrets", + "operator.write", + ]) await gateway.disconnect() } diff --git a/docs/gateway/protocol.md b/docs/gateway/protocol.md index 4691ce07096..4a7ae94f64b 100644 --- a/docs/gateway/protocol.md +++ b/docs/gateway/protocol.md @@ -109,6 +109,11 @@ bounded role entries in `deviceTokens`: } ``` +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`). + ### Node example ```json diff --git a/src/gateway/server.auth.control-ui.suite.ts b/src/gateway/server.auth.control-ui.suite.ts index db221764f71..2cbdbfe276b 100644 --- a/src/gateway/server.auth.control-ui.suite.ts +++ b/src/gateway/server.auth.control-ui.suite.ts @@ -780,6 +780,14 @@ export function registerControlUiAndPairingSuite(): void { "operator.write", ]), ); + expect( + initialPayload?.auth?.deviceTokens?.find((entry) => entry.role === "operator")?.scopes, + ).not.toEqual( + expect.arrayContaining(["node.camera", "node.display", "node.exec", "node.voice"]), + ); + expect( + initialPayload?.auth?.deviceTokens?.find((entry) => entry.role === "operator")?.scopes, + ).not.toEqual(expect.arrayContaining(["operator.admin", "operator.pairing"])); const afterBootstrap = await listDevicePairing(); expect( diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index ca4c8230f26..260115571b5 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -34,7 +34,10 @@ 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 { + resolveBootstrapProfileScopesForRole, + type DeviceBootstrapProfile, +} from "../../../shared/device-bootstrap-profile.js"; import { roleScopesAllow } from "../../../shared/operator-scope-compat.js"; import { isBrowserOperatorUiClient, @@ -1071,7 +1074,12 @@ export function attachGatewayWsMessageHandler(params: { continue; } const bootstrapRoleScopes = - bootstrapRole === "operator" ? bootstrapProfileForHello.scopes : []; + bootstrapRole === "operator" + ? resolveBootstrapProfileScopesForRole( + bootstrapRole, + bootstrapProfileForHello.scopes, + ) + : []; const extraToken = await ensureDeviceToken({ deviceId: device.id, role: bootstrapRole, diff --git a/src/infra/device-pairing.test.ts b/src/infra/device-pairing.test.ts index 0aced1b8513..4aad232ae68 100644 --- a/src/infra/device-pairing.test.ts +++ b/src/infra/device-pairing.test.ts @@ -626,6 +626,36 @@ describe("device pairing tokens", () => { ); }); + test("bootstrap pairing keeps operator token scopes operator-only", async () => { + const baseDir = await mkdtemp(join(tmpdir(), "openclaw-device-pairing-")); + const request = await requestDevicePairing( + { + deviceId: "bootstrap-device-operator-scope", + publicKey: "bootstrap-public-key-operator-scope", + role: "node", + roles: ["node", "operator"], + scopes: [], + silent: true, + }, + baseDir, + ); + + await expect( + approveBootstrapDevicePairing( + request.request.requestId, + { + roles: ["node", "operator"], + scopes: ["node.exec", "operator.pairing", "operator.read", "operator.write"], + }, + baseDir, + ), + ).resolves.toEqual(expect.objectContaining({ status: "approved" })); + + const paired = await getPairedDevice("bootstrap-device-operator-scope", baseDir); + expect(paired?.tokens?.operator?.scopes).toEqual(["operator.read", "operator.write"]); + expect(paired?.tokens?.node?.scopes).toEqual([]); + }); + test("verifies token and rejects mismatches", async () => { const { baseDir, token } = await setupOperatorToken(["operator.read"]); diff --git a/src/infra/device-pairing.ts b/src/infra/device-pairing.ts index 02d47582537..28d40bca9fb 100644 --- a/src/infra/device-pairing.ts +++ b/src/infra/device-pairing.ts @@ -1,6 +1,9 @@ import { randomUUID } from "node:crypto"; import { normalizeDeviceAuthScopes } from "../shared/device-auth.js"; -import type { DeviceBootstrapProfile } from "../shared/device-bootstrap-profile.js"; +import { + resolveBootstrapProfileScopesForRole, + type DeviceBootstrapProfile, +} from "../shared/device-bootstrap-profile.js"; import { resolveMissingRequestedScope, roleScopesAllow } from "../shared/operator-scope-compat.js"; import { createAsyncLock, @@ -643,7 +646,10 @@ export async function approveBootstrapDevicePairing( const tokens = existing?.tokens ? { ...existing.tokens } : {}; for (const roleForToken of approvedRoles) { const existingToken = tokens[roleForToken]; - const tokenScopes = roleForToken === OPERATOR_ROLE ? approvedScopes : []; + const tokenScopes = + roleForToken === OPERATOR_ROLE + ? resolveBootstrapProfileScopesForRole(roleForToken, approvedScopes) + : []; tokens[roleForToken] = buildDeviceAuthToken({ role: roleForToken, scopes: tokenScopes, diff --git a/src/shared/device-bootstrap-profile.test.ts b/src/shared/device-bootstrap-profile.test.ts new file mode 100644 index 00000000000..8d8e2345fc5 --- /dev/null +++ b/src/shared/device-bootstrap-profile.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, test } from "vitest"; +import { + BOOTSTRAP_HANDOFF_OPERATOR_SCOPES, + resolveBootstrapProfileScopesForRole, +} from "./device-bootstrap-profile.js"; + +describe("device bootstrap profile", () => { + test("bounds bootstrap handoff scopes by role", () => { + expect( + resolveBootstrapProfileScopesForRole("operator", [ + "node.exec", + "operator.admin", + "operator.approvals", + "operator.pairing", + "operator.read", + "operator.write", + ]), + ).toEqual(["operator.approvals", "operator.read", "operator.write"]); + + expect( + resolveBootstrapProfileScopesForRole("node", ["node.exec", "operator.approvals"]), + ).toEqual([]); + }); + + test("bootstrap handoff operator allowlist stays aligned with pairing setup profile", () => { + expect([...BOOTSTRAP_HANDOFF_OPERATOR_SCOPES]).toEqual([ + "operator.approvals", + "operator.read", + "operator.talk.secrets", + "operator.write", + ]); + }); +}); diff --git a/src/shared/device-bootstrap-profile.ts b/src/shared/device-bootstrap-profile.ts index 3dbebbe8f9b..f1bd7721611 100644 --- a/src/shared/device-bootstrap-profile.ts +++ b/src/shared/device-bootstrap-profile.ts @@ -10,11 +10,32 @@ export type DeviceBootstrapProfileInput = { scopes?: readonly string[]; }; +export const BOOTSTRAP_HANDOFF_OPERATOR_SCOPES = [ + "operator.approvals", + "operator.read", + "operator.talk.secrets", + "operator.write", +] as const; + +const BOOTSTRAP_HANDOFF_OPERATOR_SCOPE_SET = new Set(BOOTSTRAP_HANDOFF_OPERATOR_SCOPES); + export const PAIRING_SETUP_BOOTSTRAP_PROFILE: DeviceBootstrapProfile = { roles: ["node", "operator"], - scopes: ["operator.approvals", "operator.read", "operator.talk.secrets", "operator.write"], + scopes: [...BOOTSTRAP_HANDOFF_OPERATOR_SCOPES], }; +export function resolveBootstrapProfileScopesForRole( + role: string, + scopes: readonly string[], +): string[] { + const normalizedRole = normalizeDeviceAuthRole(role); + const normalizedScopes = normalizeDeviceAuthScopes(Array.from(scopes)); + if (normalizedRole === "operator") { + return normalizedScopes.filter((scope) => BOOTSTRAP_HANDOFF_OPERATOR_SCOPE_SET.has(scope)); + } + return []; +} + function normalizeBootstrapRoles(roles: readonly string[] | undefined): string[] { if (!Array.isArray(roles)) { return [];