From c56ad42525afa8acd4656140a8d8e8f7255072bb Mon Sep 17 00:00:00 2001 From: Shakker Date: Thu, 6 Aug 2026 01:14:43 +0100 Subject: [PATCH] fix: grant host-authorized control ui owner access --- src/commands/control-ui-handoff.ts | 18 +- src/commands/dashboard.links.test.ts | 22 ++- src/commands/dashboard.test.ts | 2 +- src/commands/dashboard/json.test.ts | 7 +- src/gateway/control-ui-contract.ts | 5 + src/gateway/server.auth.control-ui.suite.ts | 183 ++++++++++++++++-- .../ws-connection/connect-device-metadata.ts | 25 +++ .../ws-connection/connect-device-pairing.ts | 24 ++- src/infra/device-bootstrap.test.ts | 25 +++ src/infra/device-bootstrap.ts | 13 +- src/shared/device-bootstrap-profile.test.ts | 49 ++++- src/shared/device-bootstrap-profile.ts | 41 +++- ui/src/api/gateway.node.test.ts | 28 +++ ui/src/api/gateway.ts | 22 ++- ui/src/app/bootstrap.test.ts | 10 +- ui/src/app/bootstrap.ts | 12 +- ui/src/app/gateway-store.ts | 20 +- ui/src/app/gateway.ts | 2 + ui/src/app/settings.node.test.ts | 4 +- ui/src/app/startup-settings.ts | 21 ++ 20 files changed, 461 insertions(+), 72 deletions(-) diff --git a/src/commands/control-ui-handoff.ts b/src/commands/control-ui-handoff.ts index 52f16d3afad4..0288d4e85b9a 100644 --- a/src/commands/control-ui-handoff.ts +++ b/src/commands/control-ui-handoff.ts @@ -7,6 +7,10 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import { resolveSecretInputRef } from "../config/types.secrets.js"; import { resolveGatewayAuthToken } from "../gateway/auth-token-resolution.js"; import { resolveGatewayAuth } from "../gateway/auth.js"; +import { + CONTROL_UI_BOOTSTRAP_PROFILE_FRAGMENT_PARAM, + CONTROL_UI_OWNER_BOOTSTRAP_PROFILE_HINT, +} from "../gateway/control-ui-contract.js"; import { CONTROL_UI_ASSETS_BUILD_TIMEOUT_MS } from "../infra/control-ui-assets.js"; import { issueDeviceBootstrapToken } from "../infra/device-bootstrap.js"; import { readResponseTextSnippet } from "../infra/http-body.js"; @@ -15,7 +19,7 @@ import { isSameProcessSpecificIpv4WithLoopbackListeners } from "../infra/ports-f import { inspectPortUsage } from "../infra/ports-inspect.js"; import { normalizeFingerprint } from "../infra/tls/fingerprint.js"; import { loadGatewayTlsRuntime } from "../infra/tls/gateway.js"; -import { BOOTSTRAP_HANDOFF_OPERATOR_SCOPES } from "../shared/device-bootstrap-profile.js"; +import { CONTROL_UI_OWNER_BOOTSTRAP_PROFILE } from "../shared/device-bootstrap-profile.js"; import { sleep } from "../utils.js"; import { resolveControlUiLinks } from "./onboard-helpers.js"; @@ -143,14 +147,14 @@ export async function issueControlUiBrowserHandoff(httpUrl: string): Promise<{ expiresAtMs: number; }> { const issued = await issueDeviceBootstrapToken({ - profile: { - roles: ["operator"], - scopes: BOOTSTRAP_HANDOFF_OPERATOR_SCOPES, - purpose: "control-ui", - }, + profile: CONTROL_UI_OWNER_BOOTSTRAP_PROFILE, + }); + const fragment = new URLSearchParams({ + bootstrapToken: issued.token, + [CONTROL_UI_BOOTSTRAP_PROFILE_FRAGMENT_PARAM]: CONTROL_UI_OWNER_BOOTSTRAP_PROFILE_HINT, }); return { - browserUrl: `${httpUrl}#bootstrapToken=${encodeURIComponent(issued.token)}`, + browserUrl: `${httpUrl}#${fragment.toString()}`, expiresAtMs: issued.expiresAtMs, }; } diff --git a/src/commands/dashboard.links.test.ts b/src/commands/dashboard.links.test.ts index eb54d227206d..bc0b970208b5 100644 --- a/src/commands/dashboard.links.test.ts +++ b/src/commands/dashboard.links.test.ts @@ -143,20 +143,22 @@ describe("dashboardCommand", () => { profile: { roles: ["operator"], scopes: [ + "operator.admin", "operator.approvals", + "operator.pairing", "operator.questions", "operator.read", "operator.talk.secrets", "operator.write", ], - purpose: "control-ui", + purpose: "control-ui-owner", }, }); expect(copyToClipboardMock).toHaveBeenCalledWith( - "http://127.0.0.1:18789/#bootstrapToken=browser-bootstrap", + "http://127.0.0.1:18789/#bootstrapToken=browser-bootstrap&bootstrapProfile=owner", ); expect(openUrlMock).toHaveBeenCalledWith( - "http://127.0.0.1:18789/#bootstrapToken=browser-bootstrap", + "http://127.0.0.1:18789/#bootstrapToken=browser-bootstrap&bootstrapProfile=owner", ); expect(runtime.log).toHaveBeenCalledWith( "Opened in your browser. Keep that tab to control OpenClaw.", @@ -174,10 +176,10 @@ describe("dashboardCommand", () => { // Clipboard and browser receive only the short-lived browser bootstrap. expect(copyToClipboardMock).toHaveBeenCalledWith( - "http://127.0.0.1:18789/#bootstrapToken=browser-bootstrap", + "http://127.0.0.1:18789/#bootstrapToken=browser-bootstrap&bootstrapProfile=owner", ); expect(openUrlMock).toHaveBeenCalledWith( - "http://127.0.0.1:18789/#bootstrapToken=browser-bootstrap", + "http://127.0.0.1:18789/#bootstrapToken=browser-bootstrap&bootstrapProfile=owner", ); // The logged output must never contain the token — it flows into @@ -323,7 +325,7 @@ describe("dashboardCommand", () => { await dashboardCommand(runtime); expect(copyToClipboardMock).toHaveBeenCalledWith( - "http://127.0.0.1:18789/#bootstrapToken=browser-bootstrap", + "http://127.0.0.1:18789/#bootstrapToken=browser-bootstrap&bootstrapProfile=owner", ); expectNoLogWith("Token auto-auth unavailable"); expectNoLogWith("missing env var"); @@ -344,10 +346,10 @@ describe("dashboardCommand", () => { await dashboardCommand(runtime); expect(copyToClipboardMock).toHaveBeenCalledWith( - "http://127.0.0.1:18789/#bootstrapToken=browser-bootstrap", + "http://127.0.0.1:18789/#bootstrapToken=browser-bootstrap&bootstrapProfile=owner", ); expect(openUrlMock).toHaveBeenCalledWith( - "http://127.0.0.1:18789/#bootstrapToken=browser-bootstrap", + "http://127.0.0.1:18789/#bootstrapToken=browser-bootstrap&bootstrapProfile=owner", ); expectNoLogWith("Token auto-auth is disabled for SecretRef-managed"); expectNoLogWith("Token auto-auth unavailable"); @@ -362,10 +364,10 @@ describe("dashboardCommand", () => { await dashboardCommand(runtime); expect(copyToClipboardMock).toHaveBeenCalledWith( - "http://127.0.0.1:18789/#bootstrapToken=browser-bootstrap", + "http://127.0.0.1:18789/#bootstrapToken=browser-bootstrap&bootstrapProfile=owner", ); expect(openUrlMock).toHaveBeenCalledWith( - "http://127.0.0.1:18789/#bootstrapToken=browser-bootstrap", + "http://127.0.0.1:18789/#bootstrapToken=browser-bootstrap&bootstrapProfile=owner", ); expectNoLogWith("Token auto-auth unavailable"); expectNoLogWith("Token auto-auth is disabled for SecretRef-managed"); diff --git a/src/commands/dashboard.test.ts b/src/commands/dashboard.test.ts index 931a0bc77dfa..594a2734034f 100644 --- a/src/commands/dashboard.test.ts +++ b/src/commands/dashboard.test.ts @@ -205,7 +205,7 @@ describe("dashboardCommand bind selection", () => { tlsEnabled: false, }); expect(mocks.copyToClipboard).toHaveBeenCalledWith( - "http://127.0.0.1:18789/#bootstrapToken=browser-bootstrap", + "http://127.0.0.1:18789/#bootstrapToken=browser-bootstrap&bootstrapProfile=owner", ); }); diff --git a/src/commands/dashboard/json.test.ts b/src/commands/dashboard/json.test.ts index e35a97546425..652ebc79d3b9 100644 --- a/src/commands/dashboard/json.test.ts +++ b/src/commands/dashboard/json.test.ts @@ -144,7 +144,8 @@ describe("dashboardCommand --json", () => { wsUrl: "ws://127.0.0.1:18789", port: 18789, tokenIncluded: true, - browserUrl: "http://127.0.0.1:18789/#bootstrapToken=browser-bootstrap", + browserUrl: + "http://127.0.0.1:18789/#bootstrapToken=browser-bootstrap&bootstrapProfile=owner", browserBootstrapExpiresAtMs: 123_456, }, 0, @@ -159,13 +160,15 @@ describe("dashboardCommand --json", () => { profile: { roles: ["operator"], scopes: [ + "operator.admin", "operator.approvals", + "operator.pairing", "operator.questions", "operator.read", "operator.talk.secrets", "operator.write", ], - purpose: "control-ui", + purpose: "control-ui-owner", }, }); }); diff --git a/src/gateway/control-ui-contract.ts b/src/gateway/control-ui-contract.ts index 0e8e2241ba71..be8da057b9ba 100644 --- a/src/gateway/control-ui-contract.ts +++ b/src/gateway/control-ui-contract.ts @@ -3,6 +3,11 @@ /** HTTP path for the Control UI bootstrap config payload. */ export const CONTROL_UI_BOOTSTRAP_CONFIG_PATH = "/control-ui-config.json"; +/** Fragment marker selecting the host-authorized browser-owner bootstrap profile. */ +export const CONTROL_UI_BOOTSTRAP_PROFILE_FRAGMENT_PARAM = "bootstrapProfile"; +export const CONTROL_UI_OWNER_BOOTSTRAP_PROFILE_HINT = "owner"; +export type ControlUiBootstrapProfileHint = typeof CONTROL_UI_OWNER_BOOTSTRAP_PROFILE_HINT; + /** Authenticated same-origin prefix for plugin manifest/catalog icon bytes. */ export const CONTROL_UI_PLUGIN_ICON_PATH_PREFIX = "/__openclaw__/plugin-icon"; diff --git a/src/gateway/server.auth.control-ui.suite.ts b/src/gateway/server.auth.control-ui.suite.ts index 2a22eca90920..e061e01f1220 100644 --- a/src/gateway/server.auth.control-ui.suite.ts +++ b/src/gateway/server.auth.control-ui.suite.ts @@ -1830,11 +1830,11 @@ export function registerControlUiAndPairingSuite(): void { } }); - test("silently approves control ui operator bootstrap tokens with control-ui purpose", async () => { + test("silently approves host-authorized control ui owner bootstrap tokens", async () => { const { issueDeviceBootstrapToken } = await import("../infra/device-bootstrap.js"); const { getPairedDevice, listDevicePairing, verifyDeviceToken } = await import("../infra/device-pairing.js"); - const { BOOTSTRAP_HANDOFF_OPERATOR_SCOPES } = + const { CONTROL_UI_OWNER_BOOTSTRAP_OPERATOR_SCOPES, CONTROL_UI_OWNER_BOOTSTRAP_PROFILE } = await import("../shared/device-bootstrap-profile.js"); const { resolveSharedGatewaySessionGeneration } = await import("./server/ws-shared-generation.js"); @@ -1847,11 +1847,7 @@ export function registerControlUiAndPairingSuite(): void { try { const issued = await issueDeviceBootstrapToken({ - profile: { - roles: ["operator"], - scopes: BOOTSTRAP_HANDOFF_OPERATOR_SCOPES, - purpose: "control-ui", - }, + profile: CONTROL_UI_OWNER_BOOTSTRAP_PROFILE, }); const wsBootstrap = await openWs(port, { origin: "https://localhost", @@ -1861,7 +1857,7 @@ export function registerControlUiAndPairingSuite(): void { skipDefaultAuth: true, bootstrapToken: issued.token, role: "operator", - scopes: [...BOOTSTRAP_HANDOFF_OPERATOR_SCOPES], + scopes: [...CONTROL_UI_OWNER_BOOTSTRAP_OPERATOR_SCOPES], client: CONTROL_UI_CLIENT, deviceIdentityPath: identityPath, }); @@ -1878,11 +1874,12 @@ export function registerControlUiAndPairingSuite(): void { | undefined; expect(payload?.type).toBe("hello-ok"); expect(payload?.auth?.role).toBe("operator"); - expect(payload?.auth?.scopes).toEqual([...BOOTSTRAP_HANDOFF_OPERATOR_SCOPES]); + expect(payload?.auth?.scopes).toEqual([...CONTROL_UI_OWNER_BOOTSTRAP_OPERATOR_SCOPES]); const deviceToken = payload?.auth?.deviceToken; if (!deviceToken) { - throw new Error("expected control ui operator device token"); + throw new Error("expected control ui owner device token"); } + expect((await rpcReq(wsBootstrap, "set-heartbeats", { enabled: false })).ok).toBe(true); wsBootstrap.close(); const pending = (await listDevicePairing()).pending.filter( @@ -1891,7 +1888,7 @@ export function registerControlUiAndPairingSuite(): void { expect(pending).toEqual([]); const paired = await getPairedDevice(identity.deviceId); expect(paired?.roles).toEqual(["operator"]); - expect(paired?.approvedScopes).toEqual([...BOOTSTRAP_HANDOFF_OPERATOR_SCOPES]); + expect(paired?.approvedScopes).toEqual([...CONTROL_UI_OWNER_BOOTSTRAP_OPERATOR_SCOPES]); const wsReload = await openWs(port, { origin: "https://localhost", "x-forwarded-for": "203.0.113.50", @@ -1900,7 +1897,7 @@ export function registerControlUiAndPairingSuite(): void { skipDefaultAuth: true, deviceToken, role: "operator", - scopes: [...BOOTSTRAP_HANDOFF_OPERATOR_SCOPES], + scopes: [...CONTROL_UI_OWNER_BOOTSTRAP_OPERATOR_SCOPES], client: CONTROL_UI_CLIENT, deviceIdentityPath: identityPath, }); @@ -1920,7 +1917,7 @@ export function registerControlUiAndPairingSuite(): void { deviceId: identity.deviceId, token: deviceToken, role: "operator", - scopes: [...BOOTSTRAP_HANDOFF_OPERATOR_SCOPES], + scopes: [...CONTROL_UI_OWNER_BOOTSTRAP_OPERATOR_SCOPES], requiredSharedGatewaySessionGeneration: sharedGatewaySessionGeneration, }), ).resolves.toEqual({ @@ -1935,16 +1932,40 @@ export function registerControlUiAndPairingSuite(): void { deviceId: identity.deviceId, token: deviceToken, role: "operator", - scopes: [...BOOTSTRAP_HANDOFF_OPERATOR_SCOPES], + scopes: [...CONTROL_UI_OWNER_BOOTSTRAP_OPERATOR_SCOPES], requiredSharedGatewaySessionGeneration: "rotated-generation", }), ).resolves.toEqual({ ok: false, reason: "issuer-generation-stale" }); + } finally { + await server.close(); + restoreGatewayToken(prevToken); + } + }); - const wsReplay = await openWs(port, { - origin: "https://localhost", - "x-forwarded-for": "203.0.113.50", + test("keeps generic control ui bootstrap tokens on the bounded profile", async () => { + const { issueDeviceBootstrapToken } = await import("../infra/device-bootstrap.js"); + const { getPairedDevice, listDevicePairing } = await import("../infra/device-pairing.js"); + const { BOOTSTRAP_HANDOFF_OPERATOR_SCOPES } = + await import("../shared/device-bootstrap-profile.js"); + testState.gatewayControlUi = { allowedOrigins: ["https://localhost"] }; + const { server, port, prevToken } = await startControlUiServer("secret"); + const { identityPath, identity } = await createOperatorIdentityFixture( + "openclaw-bootstrap-control-ui-bounded-", + ); + + try { + const issued = await issueDeviceBootstrapToken({ + profile: { + roles: ["operator"], + scopes: BOOTSTRAP_HANDOFF_OPERATOR_SCOPES, + purpose: "control-ui", + }, }); - const replay = await connectReq(wsReplay, { + const wsBootstrap = await openWs(port, { + origin: "https://localhost", + "x-forwarded-for": "203.0.113.51", + }); + const initial = await connectReq(wsBootstrap, { skipDefaultAuth: true, bootstrapToken: issued.token, role: "operator", @@ -1952,11 +1973,135 @@ export function registerControlUiAndPairingSuite(): void { client: CONTROL_UI_CLIENT, deviceIdentityPath: identityPath, }); + expect(initial.ok).toBe(true); + const auth = ( + initial.payload as + | { + auth?: { + scopes?: string[]; + }; + } + | undefined + )?.auth; + expect(auth?.scopes).toEqual([...BOOTSTRAP_HANDOFF_OPERATOR_SCOPES]); + expect(auth?.scopes).not.toContain("operator.admin"); + expect(auth?.scopes).not.toContain("operator.pairing"); + const adminMutation = await rpcReq(wsBootstrap, "set-heartbeats", { enabled: false }); + expect(adminMutation.ok).toBe(false); + expect(adminMutation.error?.message ?? "").toContain("missing scope"); + wsBootstrap.close(); + + expect( + (await listDevicePairing()).pending.filter((entry) => entry.deviceId === identity.deviceId), + ).toEqual([]); + expect((await getPairedDevice(identity.deviceId))?.approvedScopes).toEqual([ + ...BOOTSTRAP_HANDOFF_OPERATOR_SCOPES, + ]); + } finally { + await server.close(); + restoreGatewayToken(prevToken); + } + }); + + test("silently upgrades the same control ui key with a host-authorized bootstrap", async () => { + const { issueDeviceBootstrapToken } = await import("../infra/device-bootstrap.js"); + const { getPairedDevice, listDevicePairing } = await import("../infra/device-pairing.js"); + const { CONTROL_UI_OWNER_BOOTSTRAP_OPERATOR_SCOPES, CONTROL_UI_OWNER_BOOTSTRAP_PROFILE } = + await import("../shared/device-bootstrap-profile.js"); + const { identity, identityPath } = await seedApprovedOperatorReadPairing({ + identityPrefix: "openclaw-control-ui-owner-upgrade-", + clientId: CONTROL_UI_CLIENT.id, + clientMode: CONTROL_UI_CLIENT.mode, + displayName: "control-ui-owner-upgrade", + platform: CONTROL_UI_CLIENT.platform, + }); + const before = await getPairedDevice(identity.deviceId); + const previousToken = before?.tokens?.operator?.token; + if (!previousToken) { + throw new Error("expected limited operator token"); + } + + testState.gatewayControlUi = { allowedOrigins: ["https://localhost"] }; + const { server, port, prevToken } = await startControlUiServer("secret"); + const { identityPath: secondIdentityPath } = await createOperatorIdentityFixture( + "openclaw-control-ui-owner-upgrade-second-browser-", + ); + + try { + const issued = await issueDeviceBootstrapToken({ + profile: CONTROL_UI_OWNER_BOOTSTRAP_PROFILE, + }); + const wsUpgrade = await openWs(port, { + origin: "https://localhost", + "x-forwarded-for": "203.0.113.52", + }); + const upgraded = await connectReq(wsUpgrade, { + skipDefaultAuth: true, + bootstrapToken: issued.token, + role: "operator", + scopes: [...CONTROL_UI_OWNER_BOOTSTRAP_OPERATOR_SCOPES], + client: CONTROL_UI_CLIENT, + deviceIdentityPath: identityPath, + }); + expect(upgraded.ok).toBe(true); + const auth = ( + upgraded.payload as + | { + auth?: { + deviceToken?: string; + scopes?: string[]; + }; + } + | undefined + )?.auth; + const upgradedToken = auth?.deviceToken; + if (!upgradedToken) { + throw new Error("expected upgraded operator token"); + } + expect(upgradedToken).not.toBe(previousToken); + expect(auth?.scopes).toEqual([...CONTROL_UI_OWNER_BOOTSTRAP_OPERATOR_SCOPES]); + expect((await rpcReq(wsUpgrade, "set-heartbeats", { enabled: false })).ok).toBe(true); + wsUpgrade.close(); + + expect( + (await listDevicePairing()).pending.filter((entry) => entry.deviceId === identity.deviceId), + ).toEqual([]); + const paired = await getPairedDevice(identity.deviceId); + expect(paired?.approvedScopes).toEqual([...CONTROL_UI_OWNER_BOOTSTRAP_OPERATOR_SCOPES]); + expect(paired?.tokens?.operator?.token).toBe(upgradedToken); + + const wsReload = await openWs(port, { + origin: "https://localhost", + "x-forwarded-for": "203.0.113.52", + }); + const reload = await connectReq(wsReload, { + skipDefaultAuth: true, + deviceToken: upgradedToken, + role: "operator", + scopes: [...CONTROL_UI_OWNER_BOOTSTRAP_OPERATOR_SCOPES], + client: CONTROL_UI_CLIENT, + deviceIdentityPath: identityPath, + }); + expect(reload.ok).toBe(true); + wsReload.close(); + + const wsSecondBrowser = await openWs(port, { + origin: "https://localhost", + "x-forwarded-for": "203.0.113.53", + }); + const replay = await connectReq(wsSecondBrowser, { + skipDefaultAuth: true, + bootstrapToken: issued.token, + role: "operator", + scopes: [...CONTROL_UI_OWNER_BOOTSTRAP_OPERATOR_SCOPES], + client: CONTROL_UI_CLIENT, + deviceIdentityPath: secondIdentityPath, + }); expect(replay.ok).toBe(false); expect((replay.error?.details as { code?: string } | undefined)?.code).toBe( ConnectErrorDetailCodes.AUTH_BOOTSTRAP_TOKEN_INVALID, ); - wsReplay.close(); + wsSecondBrowser.close(); } finally { await server.close(); restoreGatewayToken(prevToken); diff --git a/src/gateway/server/ws-connection/connect-device-metadata.ts b/src/gateway/server/ws-connection/connect-device-metadata.ts index 18eb5e515d05..1c2c2a6d5bf8 100644 --- a/src/gateway/server/ws-connection/connect-device-metadata.ts +++ b/src/gateway/server/ws-connection/connect-device-metadata.ts @@ -6,6 +6,8 @@ import { import { hasEffectivePairedDeviceRole, type PairedDevice } from "../../../infra/device-pairing.js"; import { BOOTSTRAP_HANDOFF_OPERATOR_SCOPES, + CONTROL_UI_OWNER_BOOTSTRAP_PROFILE, + deviceBootstrapProfilesEqual, isMobilePairingSetupBootstrapProfile, isVoiceNodePairingSetupBootstrapProfile, resolveBootstrapProfileScopesForRole, @@ -69,11 +71,34 @@ export function isSetupCodeHandoffBootstrapClient(params: { ); } +/** Match the exact host-issued browser-owner profile and its closed requested scope set. */ +export function isControlUiOwnerBootstrapProfile(params: { + profile: DeviceBootstrapProfile | null; + requestedScopes: readonly string[]; +}): params is { profile: DeviceBootstrapProfile; requestedScopes: readonly string[] } { + const { profile, requestedScopes } = params; + return Boolean( + profile && + deviceBootstrapProfilesEqual(profile, CONTROL_UI_OWNER_BOOTSTRAP_PROFILE) && + deviceBootstrapProfilesEqual( + { + roles: ["operator"], + scopes: requestedScopes, + purpose: CONTROL_UI_OWNER_BOOTSTRAP_PROFILE.purpose, + }, + CONTROL_UI_OWNER_BOOTSTRAP_PROFILE, + ), + ); +} + export function isControlUiOperatorBootstrapProfile(params: { profile: DeviceBootstrapProfile | null; requestedScopes: readonly string[]; }): params is { profile: DeviceBootstrapProfile; requestedScopes: readonly string[] } { const { profile, requestedScopes } = params; + if (isControlUiOwnerBootstrapProfile(params)) { + return true; + } if (!profile || profile.purpose !== "control-ui") { return false; } diff --git a/src/gateway/server/ws-connection/connect-device-pairing.ts b/src/gateway/server/ws-connection/connect-device-pairing.ts index 41a9fb2ca488..0ab491ca7dd1 100644 --- a/src/gateway/server/ws-connection/connect-device-pairing.ts +++ b/src/gateway/server/ws-connection/connect-device-pairing.ts @@ -35,6 +35,7 @@ import { formatForLog } from "../../ws-log.js"; import { truncateCloseReason } from "../close-reason.js"; import { resolveTrustedProxyControlUiScopes } from "./connect-admission.js"; import { + isControlUiOwnerBootstrapProfile, isControlUiOperatorBootstrapProfile, isMobileNodeBootstrapConnect, isSetupCodeHandoffBootstrapClient, @@ -301,7 +302,7 @@ export async function authorizeGatewayConnectDevice( (isSetupCodeMobileNodeConnect || (isControlUi && role === "operator"))) || (reason === "scope-upgrade" && Boolean(existingPairedDevice) && - isSetupCodeMobileNodeConnect); + (isSetupCodeMobileNodeConnect || (isControlUi && role === "operator"))); const boundBootstrapProfile = authMethod === "bootstrap-token" && bootstrapTokenCandidate && @@ -322,10 +323,19 @@ export async function authorizeGatewayConnectDevice( const setupCodeHandoffBootstrapProfile = allowSetupCodeHandoffBootstrapPairing ? boundBootstrapProfile : null; - const allowControlUiOperatorBootstrapPairing = isControlUiOperatorBootstrapProfile({ - profile: boundBootstrapProfile, - requestedScopes: scopes, - }); + const allowControlUiOwnerBootstrapPairing = + reason === "scope-upgrade" && + isControlUiOwnerBootstrapProfile({ + profile: boundBootstrapProfile, + requestedScopes: scopes, + }); + const allowControlUiOperatorBootstrapPairing = + (reason === "not-paired" && + isControlUiOperatorBootstrapProfile({ + profile: boundBootstrapProfile, + requestedScopes: scopes, + })) || + allowControlUiOwnerBootstrapPairing; const controlUiOperatorBootstrapProfile = allowControlUiOperatorBootstrapPairing ? boundBootstrapProfile : null; @@ -373,7 +383,9 @@ export async function authorizeGatewayConnectDevice( } : {}), silent: - reason === "scope-upgrade" && !allowSetupCodeHandoffBootstrapPairing + reason === "scope-upgrade" && + !allowSetupCodeHandoffBootstrapPairing && + !allowControlUiOwnerBootstrapPairing ? false : allowSilentLocalPairing || allowSilentTrustedCidrsNodePairing || diff --git a/src/infra/device-bootstrap.test.ts b/src/infra/device-bootstrap.test.ts index a5d54161088b..7f2a992f368a 100644 --- a/src/infra/device-bootstrap.test.ts +++ b/src/infra/device-bootstrap.test.ts @@ -4,6 +4,10 @@ import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { resetLogger, setLoggerOverride } from "../logging.js"; import { flushLogger } from "../logging/logger.js"; +import { + CONTROL_UI_OWNER_BOOTSTRAP_OPERATOR_SCOPES, + CONTROL_UI_OWNER_BOOTSTRAP_PROFILE, +} from "../shared/device-bootstrap-profile.js"; import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js"; import { createTrackedTempDirs } from "../test-utils/tracked-temp-dirs.js"; import { @@ -331,6 +335,27 @@ describe("device bootstrap tokens", () => { ).resolves.toEqual({ ok: true }); }); + it("requires the exact closed browser-owner profile before binding", async () => { + const baseDir = await createTempDir(); + const issued = await issueDeviceBootstrapToken({ + baseDir, + profile: CONTROL_UI_OWNER_BOOTSTRAP_PROFILE, + }); + + await expect( + verifyBootstrapToken(baseDir, issued.token, { + role: "operator", + scopes: ["operator.admin"], + }), + ).resolves.toEqual({ ok: false, reason: "bootstrap_token_invalid" }); + await expect( + verifyBootstrapToken(baseDir, issued.token, { + role: "operator", + scopes: [...CONTROL_UI_OWNER_BOOTSTRAP_OPERATOR_SCOPES], + }), + ).resolves.toEqual({ ok: true }); + }); + it("rejects cross-role scope escalation (node role requesting operator scopes)", async () => { const baseDir = await createTempDir(); const issued = await issueDeviceBootstrapToken({ baseDir }); diff --git a/src/infra/device-bootstrap.ts b/src/infra/device-bootstrap.ts index 11271e7e520d..23fd9997d7f9 100644 --- a/src/infra/device-bootstrap.ts +++ b/src/infra/device-bootstrap.ts @@ -5,6 +5,7 @@ import { } from "@openclaw/normalization-core/number-coercion"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { + CONTROL_UI_OWNER_BOOTSTRAP_PROFILE, deviceBootstrapProfilesEqual, normalizeDeviceBootstrapHandoffProfile, normalizeDeviceBootstrapProfile, @@ -404,10 +405,17 @@ export async function verifyDeviceBootstrapToken(params: { return { ok: false, reason: "bootstrap_token_invalid" }; } const allowedProfile = resolvePersistedBootstrapProfile(record); + const requestedProfile = resolveRequestedBootstrapProfile({ + role, + scopes: params.scopes, + purpose: allowedProfile.purpose, + }); // Fail closed for any attempt to redeem the token outside the issued // role/scope allowlist before binding it to a concrete device identity. if ( allowedProfile.roles.length === 0 || + (deviceBootstrapProfilesEqual(allowedProfile, CONTROL_UI_OWNER_BOOTSTRAP_PROFILE) && + !deviceBootstrapProfilesEqual(requestedProfile, CONTROL_UI_OWNER_BOOTSTRAP_PROFILE)) || !bootstrapProfileAllowsRequest({ allowedProfile, requestedRole: role, @@ -416,11 +424,6 @@ export async function verifyDeviceBootstrapToken(params: { ) { return { ok: false, reason: "bootstrap_token_invalid" }; } - const requestedProfile = resolveRequestedBootstrapProfile({ - role, - scopes: params.scopes, - purpose: allowedProfile.purpose, - }); const boundDeviceId = record.deviceId?.trim(); const boundPublicKey = diff --git a/src/shared/device-bootstrap-profile.test.ts b/src/shared/device-bootstrap-profile.test.ts index 8a1ec7499a3d..09a4d02e7648 100644 --- a/src/shared/device-bootstrap-profile.test.ts +++ b/src/shared/device-bootstrap-profile.test.ts @@ -2,6 +2,8 @@ import { describe, expect, test } from "vitest"; import { BOOTSTRAP_HANDOFF_OPERATOR_SCOPES, + CONTROL_UI_OWNER_BOOTSTRAP_OPERATOR_SCOPES, + CONTROL_UI_OWNER_BOOTSTRAP_PROFILE, FULL_ACCESS_PAIRING_SETUP_BOOTSTRAP_PROFILE, NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE, PAIRING_SETUP_BOOTSTRAP_PROFILE, @@ -77,7 +79,33 @@ describe("device bootstrap profile", () => { }); }); - test("allows admin only for the closed full-mobile purpose", () => { + test("allows admin only for closed full-access purposes", () => { + expect( + normalizeDeviceBootstrapHandoffProfile({ + roles: ["operator"], + scopes: [ + "operator.admin", + "operator.approvals", + "operator.pairing", + "operator.read", + "operator.talk.secrets", + "operator.write", + ], + purpose: "control-ui-owner", + }), + ).toEqual({ + roles: ["operator"], + scopes: [ + "operator.admin", + "operator.approvals", + "operator.pairing", + "operator.read", + "operator.talk.secrets", + "operator.write", + ], + purpose: "control-ui-owner", + }); + expect( normalizeDeviceBootstrapHandoffProfile({ roles: ["node", "operator"], @@ -117,6 +145,25 @@ describe("device bootstrap profile", () => { }); }); + test("browser-owner profile carries the exact full Control UI handoff", () => { + expect(CONTROL_UI_OWNER_BOOTSTRAP_PROFILE).toEqual({ + roles: ["operator"], + scopes: [ + "operator.admin", + "operator.approvals", + "operator.pairing", + "operator.questions", + "operator.read", + "operator.talk.secrets", + "operator.write", + ], + purpose: "control-ui-owner", + }); + expect([...CONTROL_UI_OWNER_BOOTSTRAP_OPERATOR_SCOPES]).toEqual( + CONTROL_UI_OWNER_BOOTSTRAP_PROFILE.scopes, + ); + }); + test("existing setup profile preserves the bounded operator handoff", () => { expect(PAIRING_SETUP_BOOTSTRAP_PROFILE).toEqual({ roles: ["node", "operator"], diff --git a/src/shared/device-bootstrap-profile.ts b/src/shared/device-bootstrap-profile.ts index d45e8145496e..bb8f59aa4555 100644 --- a/src/shared/device-bootstrap-profile.ts +++ b/src/shared/device-bootstrap-profile.ts @@ -2,7 +2,11 @@ import { normalizeDeviceAuthRole, normalizeDeviceAuthScopes } from "./device-auth.js"; /** Closed purpose codes carried by specialized bootstrap tokens. */ -export type DeviceBootstrapPurpose = "control-ui" | "mobile-full" | "voice-node"; +export type DeviceBootstrapPurpose = + | "control-ui" + | "control-ui-owner" + | "mobile-full" + | "voice-node"; /** Normalized roles/scopes carried by a bootstrap token during device handoff. */ export type DeviceBootstrapProfile = { @@ -29,6 +33,21 @@ export const BOOTSTRAP_HANDOFF_OPERATOR_SCOPES = [ const BOOTSTRAP_HANDOFF_OPERATOR_SCOPE_SET = new Set(BOOTSTRAP_HANDOFF_OPERATOR_SCOPES); +/** Full browser-owner scopes allowed only by the host-issued Control UI profile. */ +export const CONTROL_UI_OWNER_BOOTSTRAP_OPERATOR_SCOPES = [ + "operator.admin", + "operator.approvals", + "operator.pairing", + "operator.questions", + "operator.read", + "operator.talk.secrets", + "operator.write", +] as const; + +const CONTROL_UI_OWNER_BOOTSTRAP_OPERATOR_SCOPE_SET = new Set( + CONTROL_UI_OWNER_BOOTSTRAP_OPERATOR_SCOPES, +); + /** Full native-mobile operator scopes allowed only by the closed mobile setup profile. */ const MOBILE_FULL_ACCESS_OPERATOR_SCOPES = [ "operator.admin", @@ -47,6 +66,13 @@ export const PAIRING_SETUP_BOOTSTRAP_PROFILE: DeviceBootstrapProfile = { scopes: [...BOOTSTRAP_HANDOFF_OPERATOR_SCOPES], }; +/** Full browser-owner profile issued only by dashboard and graphical onboarding. */ +export const CONTROL_UI_OWNER_BOOTSTRAP_PROFILE: DeviceBootstrapProfile = { + roles: ["operator"], + scopes: [...CONTROL_UI_OWNER_BOOTSTRAP_OPERATOR_SCOPES], + purpose: "control-ui-owner", +}; + /** Full native-mobile setup profile for explicitly authorized setup surfaces. */ export const FULL_ACCESS_PAIRING_SETUP_BOOTSTRAP_PROFILE: DeviceBootstrapProfile = { roles: ["node", "operator"], @@ -129,11 +155,13 @@ export function resolveBootstrapProfileScopesForRole( const normalizedScopes = normalizeDeviceAuthScopes(Array.from(scopes)); if (normalizedRole === "operator") { const allowedScopes = - purpose === "mobile-full" - ? MOBILE_FULL_ACCESS_OPERATOR_SCOPE_SET - : purpose === "voice-node" - ? VOICE_NODE_OPERATOR_SCOPE_SET - : BOOTSTRAP_HANDOFF_OPERATOR_SCOPE_SET; + purpose === "control-ui-owner" + ? CONTROL_UI_OWNER_BOOTSTRAP_OPERATOR_SCOPE_SET + : purpose === "mobile-full" + ? MOBILE_FULL_ACCESS_OPERATOR_SCOPE_SET + : purpose === "voice-node" + ? VOICE_NODE_OPERATOR_SCOPE_SET + : BOOTSTRAP_HANDOFF_OPERATOR_SCOPE_SET; return normalizedScopes.filter((scope) => allowedScopes.has(scope)); } return []; @@ -201,6 +229,7 @@ export function normalizeDeviceBootstrapProfile( ): DeviceBootstrapProfile { const purpose = input?.purpose === "control-ui" || + input?.purpose === "control-ui-owner" || input?.purpose === "mobile-full" || input?.purpose === "voice-node" ? input.purpose diff --git a/ui/src/api/gateway.node.test.ts b/ui/src/api/gateway.node.test.ts index cc2d27625a93..b1e187820a0f 100644 --- a/ui/src/api/gateway.node.test.ts +++ b/ui/src/api/gateway.node.test.ts @@ -39,6 +39,15 @@ const CONTROL_UI_BOOTSTRAP_OPERATOR_SCOPES = [ "operator.talk.secrets", "operator.write", ] as const; +const CONTROL_UI_OWNER_BOOTSTRAP_OPERATOR_SCOPES = [ + "operator.admin", + "operator.approvals", + "operator.pairing", + "operator.questions", + "operator.read", + "operator.talk.secrets", + "operator.write", +] as const; const loadOrCreateDeviceIdentityMock = vi.hoisted(() => vi.fn( async (): Promise => ({ @@ -529,6 +538,25 @@ describe("GatewayBrowserClient", () => { }); }); + it("requests the exact owner profile for host-authorized bootstrap auth", async () => { + const client = new GatewayBrowserClient({ + url: "wss://gateway.example", + bootstrapToken: "boot-owner", + bootstrapProfile: "owner", + }); + + const { connectFrame } = await startConnect(client); + + expect(connectFrame.params?.auth?.bootstrapToken).toBe("boot-owner"); + expect(connectFrame.params?.scopes).toEqual([...CONTROL_UI_OWNER_BOOTSTRAP_OPERATOR_SCOPES]); + const [, signedPayload] = requireFirstSignCall(); + expectSignedPayloadFields(signedPayload, { + scopes: [...CONTROL_UI_OWNER_BOOTSTRAP_OPERATOR_SCOPES], + token: "boot-owner", + nonce: "nonce-1", + }); + }); + it("adds the current Control UI protocol to bare protocol mismatch errors", () => { const error = new GatewayRequestError({ code: "INVALID_REQUEST", diff --git a/ui/src/api/gateway.ts b/ui/src/api/gateway.ts index b89f3021b1c1..8eafc7b7d295 100644 --- a/ui/src/api/gateway.ts +++ b/ui/src/api/gateway.ts @@ -32,6 +32,14 @@ import { } from "@openclaw/gateway-client/browser"; // Control UI module implements gateway behavior. import { formatErrorMessage } from "@openclaw/normalization-core"; +import { + CONTROL_UI_OWNER_BOOTSTRAP_PROFILE_HINT, + type ControlUiBootstrapProfileHint, +} from "../../../src/gateway/control-ui-contract.js"; +import { + BOOTSTRAP_HANDOFF_OPERATOR_SCOPES, + CONTROL_UI_OWNER_BOOTSTRAP_OPERATOR_SCOPES, +} from "../../../src/shared/device-bootstrap-profile.js"; import { redactToolDetail } from "../lib/browser-redact.ts"; import { clearDeviceAuthToken, @@ -146,14 +154,6 @@ const CONTROL_UI_OPERATOR_SCOPES = [ "operator.pairing", ] as const; -const CONTROL_UI_BOOTSTRAP_OPERATOR_SCOPES = [ - "operator.approvals", - "operator.questions", - "operator.read", - "operator.talk.secrets", - "operator.write", -] as const; - type GatewayConnectDevice = NonNullable; type GatewayConnectClientInfo = ConnectParams["client"]; @@ -170,6 +170,7 @@ export type GatewayBrowserClientOptions = { url: string; token?: string; bootstrapToken?: string; + bootstrapProfile?: ControlUiBootstrapProfileHint; password?: string; clientName?: GatewayClientName; clientVersion?: string; @@ -458,7 +459,9 @@ export class GatewayBrowserClient { } const scopes = resolveGatewayConnectScopes({ requestedScopes: selectedAuth.authBootstrapToken - ? [...CONTROL_UI_BOOTSTRAP_OPERATOR_SCOPES] + ? this.opts.bootstrapProfile === CONTROL_UI_OWNER_BOOTSTRAP_PROFILE_HINT + ? [...CONTROL_UI_OWNER_BOOTSTRAP_OPERATOR_SCOPES] + : [...BOOTSTRAP_HANDOFF_OPERATOR_SCOPES] : undefined, usingStoredDeviceToken: selectedAuth.usingStoredDeviceToken, storedScopes: selectedAuth.storedScopes, @@ -511,6 +514,7 @@ export class GatewayBrowserClient { this.pendingDeviceTokenRetry = false; this.deviceTokenRetryBudgetUsed = false; this.opts.bootstrapToken = undefined; + this.opts.bootstrapProfile = undefined; if (hello?.auth?.deviceToken && plan.deviceIdentity) { storeDeviceAuthToken({ deviceId: plan.deviceIdentity.deviceId, diff --git a/ui/src/app/bootstrap.test.ts b/ui/src/app/bootstrap.test.ts index 48b115234391..9e1baa3e49a7 100644 --- a/ui/src/app/bootstrap.test.ts +++ b/ui/src/app/bootstrap.test.ts @@ -446,9 +446,10 @@ describe("normalizeInitialApplicationLocation", () => { it.each([ { name: "bootstrap token on the deferred default landing", - initialUrl: "/?keep=yes#bootstrapToken=boot-default&tab=keep", + initialUrl: "/?keep=yes#bootstrapToken=boot-default&bootstrapProfile=owner&tab=keep", expectedUrl: "/?keep=yes#tab=keep", expectedBootstrapToken: "boot-default", + expectedBootstrapProfile: "owner", expectedToken: "", expectedDocumentMode: null, }, @@ -457,6 +458,7 @@ describe("normalizeInitialApplicationLocation", () => { initialUrl: "/operator/settings/appearance?keep=yes#tab=keep&bootstrapToken=boot-route", expectedUrl: "/operator/settings/appearance?keep=yes#tab=keep", expectedBootstrapToken: "boot-route", + expectedBootstrapProfile: undefined, expectedToken: "", expectedDocumentMode: null, }, @@ -465,6 +467,7 @@ describe("normalizeInitialApplicationLocation", () => { initialUrl: "/approve/exec%3A1?keep=yes#bootstrapToken=boot-approval&tab=keep", expectedUrl: "/approve/exec%3A1?keep=yes#tab=keep", expectedBootstrapToken: "boot-approval", + expectedBootstrapProfile: undefined, expectedToken: "", expectedDocumentMode: { kind: "approval", approvalId: "exec:1" }, }, @@ -473,6 +476,7 @@ describe("normalizeInitialApplicationLocation", () => { initialUrl: "/settings/appearance?keep=yes&password=discard#token=shared-fragment&tab=keep", expectedUrl: "/settings/appearance?keep=yes#tab=keep", expectedBootstrapToken: "", + expectedBootstrapProfile: undefined, expectedToken: "shared-fragment", expectedDocumentMode: null, }, @@ -481,6 +485,7 @@ describe("normalizeInitialApplicationLocation", () => { initialUrl: "/settings/appearance?keep=yes&token=shared-query#password=discard&tab=keep", expectedUrl: "/settings/appearance?keep=yes#tab=keep", expectedBootstrapToken: "", + expectedBootstrapProfile: undefined, expectedToken: "shared-query", expectedDocumentMode: null, }, @@ -508,6 +513,9 @@ describe("normalizeInitialApplicationLocation", () => { expect(runtime.context.gateway.connection.bootstrapToken).toBe( testCase.expectedBootstrapToken, ); + expect(runtime.context.gateway.connection.bootstrapProfile).toBe( + testCase.expectedBootstrapProfile, + ); expect(runtime.context.gateway.connection.token).toBe(testCase.expectedToken); expect(runtime.documentMode).toEqual(testCase.expectedDocumentMode); expect(runtime.context.gateway.snapshot.phase).toBe("stopped"); diff --git a/ui/src/app/bootstrap.ts b/ui/src/app/bootstrap.ts index 12515b61cd8e..fe2e8d8b1ca0 100644 --- a/ui/src/app/bootstrap.ts +++ b/ui/src/app/bootstrap.ts @@ -276,7 +276,13 @@ export function bootstrapApplication( startup.password ?? "", startup.pendingBootstrapToken ?? "", undefined, - { persistDefaultConnectionSettings: documentMode === null, basePath }, + { + persistDefaultConnectionSettings: documentMode === null, + basePath, + ...(startup.pendingBootstrapProfile + ? { bootstrapProfile: startup.pendingBootstrapProfile } + : {}), + }, ); const agents = createAgentCapability(gateway); const startupLifecycle = createStartupLifecycle(); @@ -358,6 +364,9 @@ export function bootstrapApplication( gatewayUrl: startup.pendingGatewayUrl, token: startup.pendingGatewayToken ?? "", bootstrapToken: startup.pendingBootstrapToken ?? "", + ...(startup.pendingBootstrapProfile + ? { bootstrapProfile: startup.pendingBootstrapProfile } + : {}), } : null; let lastPostConnectClient: GatewayBrowserClient | null = null; @@ -413,6 +422,7 @@ export function bootstrapApplication( gatewayUrl: pending.gatewayUrl, token: pending.token, bootstrapToken: pending.bootstrapToken, + bootstrapProfile: pending.bootstrapProfile, }); }; const cancelPendingGatewayConnection = () => { diff --git a/ui/src/app/gateway-store.ts b/ui/src/app/gateway-store.ts index 47fd12e8f78f..7b6a9069200c 100644 --- a/ui/src/app/gateway-store.ts +++ b/ui/src/app/gateway-store.ts @@ -1,3 +1,4 @@ +import type { ControlUiBootstrapProfileHint } from "../../../src/gateway/control-ui-contract.js"; // Control UI module owns the application gateway store: the reactive // snapshot around GatewayBrowserClient consumed by the app shell. import type { EventLogEntry } from "../api/event-log.ts"; @@ -66,7 +67,11 @@ export function createApplicationGateway( initialPassword = "", initialBootstrapToken = "", createClient: GatewayClientFactory = defaultClientFactory, - options: { persistDefaultConnectionSettings?: boolean; basePath?: string } = {}, + options: { + persistDefaultConnectionSettings?: boolean; + basePath?: string; + bootstrapProfile?: ControlUiBootstrapProfileHint; + } = {}, ): ApplicationGateway { let settings = initialSettings; let persistConnectionSettings = options.persistDefaultConnectionSettings !== false; @@ -74,6 +79,7 @@ export function createApplicationGateway( gatewayUrl: settings.gatewayUrl, token: settings.token, bootstrapToken: initialBootstrapToken, + ...(options.bootstrapProfile ? { bootstrapProfile: options.bootstrapProfile } : {}), password: initialPassword, }; let snapshot: ApplicationGatewaySnapshot = { @@ -263,7 +269,14 @@ export function createApplicationGateway( const connect = (overrides: ApplicationGatewayConnectOptions = {}) => { stopped = false; const { sessionKey: requestedSessionKey, ...connectionOverrides } = overrides; - const nextConnection = { ...connection, ...connectionOverrides }; + const nextConnection = { + ...connection, + ...connectionOverrides, + ...(connectionOverrides.bootstrapToken !== undefined && + connectionOverrides.bootstrapProfile === undefined + ? { bootstrapProfile: undefined } + : {}), + }; const hasRequestedSessionKey = requestedSessionKey !== undefined; const nextSessionKey = hasRequestedSessionKey ? requestedSessionKey.trim() @@ -312,6 +325,7 @@ export function createApplicationGateway( bootstrapToken: nextConnection.bootstrapToken.trim() ? nextConnection.bootstrapToken : undefined, + bootstrapProfile: nextConnection.bootstrapProfile, password: nextConnection.password.trim() ? nextConnection.password : undefined, clientName: "openclaw-control-ui", clientVersion: CONTROL_UI_BUILD_INFO.version ?? "dev", @@ -330,7 +344,7 @@ export function createApplicationGateway( }), options.basePath, ); - connection = { ...connection, bootstrapToken: "" }; + connection = { ...connection, bootstrapToken: "", bootstrapProfile: undefined }; if (persistConnectionSettings) { settings = loadSettings(); } diff --git a/ui/src/app/gateway.ts b/ui/src/app/gateway.ts index e275676d4f14..801c967efc68 100644 --- a/ui/src/app/gateway.ts +++ b/ui/src/app/gateway.ts @@ -1,3 +1,4 @@ +import type { ControlUiBootstrapProfileHint } from "../../../src/gateway/control-ui-contract.js"; import type { EventLogEntry } from "../api/event-log.ts"; import type { GatewayBrowserClient, GatewayEventListener, GatewayHelloOk } from "../api/gateway.ts"; import type { AuthenticatedUser } from "./user-profile.ts"; @@ -27,6 +28,7 @@ export type ApplicationGatewayConnection = { gatewayUrl: string; token: string; bootstrapToken: string; + bootstrapProfile?: ControlUiBootstrapProfileHint; password: string; }; diff --git a/ui/src/app/settings.node.test.ts b/ui/src/app/settings.node.test.ts index e9bbb7f7be33..c36fe556afe0 100644 --- a/ui/src/app/settings.node.test.ts +++ b/ui/src/app/settings.node.test.ts @@ -80,12 +80,13 @@ describe("resolveApplicationStartupSettings", () => { const startup = resolveApplicationStartupSettings(makeSettings("wss://gateway.example"), { pathname: "/", search: "", - hash: "#gatewayUrl=wss%3A%2F%2Fgateway.example&bootstrapToken=boot-123", + hash: "#gatewayUrl=wss%3A%2F%2Fgateway.example&bootstrapToken=boot-123&bootstrapProfile=owner", }); expect(startup.pendingGatewayUrl).toBeNull(); expect(startup.pendingGatewayToken).toBeNull(); expect(startup.pendingBootstrapToken).toBe("boot-123"); + expect(startup.pendingBootstrapProfile).toBe("owner"); expect(startup.settings.token).toBe(""); expect(startup.location).toEqual({ pathname: "/", search: "", hash: "" }); }); @@ -100,6 +101,7 @@ describe("resolveApplicationStartupSettings", () => { expect(startup.pendingGatewayUrl).toBe("wss://gateway-b.example"); expect(startup.pendingGatewayToken).toBeNull(); expect(startup.pendingBootstrapToken).toBe("boot-456"); + expect(startup.pendingBootstrapProfile).toBeNull(); expect(startup.location).toEqual({ pathname: "/dash", search: "", hash: "" }); }); }); diff --git a/ui/src/app/startup-settings.ts b/ui/src/app/startup-settings.ts index 43c08f483367..805d936d412e 100644 --- a/ui/src/app/startup-settings.ts +++ b/ui/src/app/startup-settings.ts @@ -1,4 +1,9 @@ // Control UI startup settings resolve native auth handoff and URL parameters. +import { + CONTROL_UI_BOOTSTRAP_PROFILE_FRAGMENT_PARAM, + CONTROL_UI_OWNER_BOOTSTRAP_PROFILE_HINT, + type ControlUiBootstrapProfileHint, +} from "../../../src/gateway/control-ui-contract.js"; import { inferBasePathFromPathname, sessionRouteNamespaceFromPath } from "../app-route-paths.ts"; import { normalizeOptionalString } from "../lib/string-coerce.ts"; import type { UiSettings } from "./settings.ts"; @@ -21,6 +26,7 @@ type ApplicationStartupSettings = { pendingGatewayUrl: string | null; pendingGatewayToken: string | null; pendingBootstrapToken: string | null; + pendingBootstrapProfile: ControlUiBootstrapProfileHint | null; queryTokenUsed: boolean; location: ApplicationStartupLocation; changed: boolean; @@ -42,6 +48,7 @@ export function resolveApplicationStartupSettings( let pendingGatewayUrl: string | null = null; let pendingGatewayToken: string | null = null; let pendingBootstrapToken: string | null = null; + let pendingBootstrapProfile: ControlUiBootstrapProfileHint | null = null; let queryTokenUsed = false; const updateSettings = (patch: Partial) => { @@ -83,6 +90,7 @@ export function resolveApplicationStartupSettings( pendingGatewayUrl, pendingGatewayToken, pendingBootstrapToken, + pendingBootstrapProfile, queryTokenUsed, location, changed, @@ -104,6 +112,10 @@ export function resolveApplicationStartupSettings( const token = normalizeOptionalString(hashToken ?? queryToken); const hasBootstrapTokenParam = hashParams.has("bootstrapToken"); const bootstrapToken = normalizeOptionalString(hashParams.get("bootstrapToken")); + const hasBootstrapProfileParam = hashParams.has(CONTROL_UI_BOOTSTRAP_PROFILE_FRAGMENT_PARAM); + const bootstrapProfile = normalizeOptionalString( + hashParams.get(CONTROL_UI_BOOTSTRAP_PROFILE_FRAGMENT_PARAM), + ); const sessionPath = sessionRouteNamespaceFromPath( location.pathname, inferBasePathFromPathname(location.pathname), @@ -134,9 +146,17 @@ export function resolveApplicationStartupSettings( if (hasBootstrapTokenParam) { pendingBootstrapToken = bootstrapToken ?? null; + pendingBootstrapProfile = + bootstrapToken && bootstrapProfile === CONTROL_UI_OWNER_BOOTSTRAP_PROFILE_HINT + ? CONTROL_UI_OWNER_BOOTSTRAP_PROFILE_HINT + : null; hashParams.delete("bootstrapToken"); shouldCleanUrl = true; } + if (hasBootstrapProfileParam) { + hashParams.delete(CONTROL_UI_BOOTSTRAP_PROFILE_FRAGMENT_PARAM); + shouldCleanUrl = true; + } if (shouldResetSessionForToken) { updateSettings({ @@ -175,6 +195,7 @@ export function resolveApplicationStartupSettings( pendingGatewayUrl, pendingGatewayToken, pendingBootstrapToken, + pendingBootstrapProfile, queryTokenUsed, location: shouldCleanUrl ? {