fix: grant host-authorized control ui owner access

This commit is contained in:
Shakker
2026-08-06 01:14:43 +01:00
parent 1e347e2c7a
commit c56ad42525
20 changed files with 461 additions and 72 deletions
+11 -7
View File
@@ -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,
};
}
+12 -10
View File
@@ -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");
+1 -1
View File
@@ -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",
);
});
+5 -2
View File
@@ -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",
},
});
});
+5
View File
@@ -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";
+164 -19
View File
@@ -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);
@@ -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;
}
@@ -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 ||
+25
View File
@@ -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 });
+8 -5
View File
@@ -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 =
+48 -1
View File
@@ -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"],
+35 -6
View File
@@ -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<string>(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<string>(
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
+28
View File
@@ -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<DeviceIdentity> => ({
@@ -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",
+13 -9
View File
@@ -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<ConnectParams["device"]>;
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,
+9 -1
View File
@@ -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");
+11 -1
View File
@@ -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 = () => {
+17 -3
View File
@@ -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();
}
+2
View File
@@ -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;
};
+3 -1
View File
@@ -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: "" });
});
});
+21
View File
@@ -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<UiSettings>) => {
@@ -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
? {