mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix: grant host-authorized control ui owner access
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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 = () => {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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: "" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
? {
|
||||
|
||||
Reference in New Issue
Block a user