mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-25 08:52:12 +00:00
Fix Control UI operator.read scope handling (#53110)
Preserve Control UI scopes through the device-auth bypass path, normalize implied operator device-auth scopes, ignore cached under-scoped operator tokens, and degrade read-backed main pages gracefully when a connection truly lacks operator.read. Co-authored-by: Val Alexander <68980965+BunsDev@users.noreply.github.com>
This commit is contained in:
@@ -409,6 +409,35 @@ export function registerControlUiAndPairingSuite(): void {
|
||||
}
|
||||
});
|
||||
|
||||
test("preserves requested control ui scopes when dangerouslyDisableDeviceAuth bypasses device identity", async () => {
|
||||
testState.gatewayControlUi = { dangerouslyDisableDeviceAuth: true };
|
||||
testState.gatewayAuth = { mode: "token", token: "secret" };
|
||||
const prevToken = process.env.OPENCLAW_GATEWAY_TOKEN;
|
||||
process.env.OPENCLAW_GATEWAY_TOKEN = "secret";
|
||||
try {
|
||||
await withGatewayServer(async ({ port }) => {
|
||||
const ws = await openWs(port, { origin: originForPort(port) });
|
||||
const res = await connectReq(ws, {
|
||||
token: "secret",
|
||||
scopes: ["operator.read"],
|
||||
client: {
|
||||
...CONTROL_UI_CLIENT,
|
||||
},
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
|
||||
const health = await rpcReq(ws, "health");
|
||||
expect(health.ok).toBe(true);
|
||||
|
||||
const talk = await rpcReq(ws, "chat.history", { sessionKey: "main", limit: 1 });
|
||||
expect(talk.ok).toBe(true);
|
||||
ws.close();
|
||||
});
|
||||
} finally {
|
||||
restoreGatewayToken(prevToken);
|
||||
}
|
||||
});
|
||||
|
||||
test("device token auth matrix", async () => {
|
||||
const { server, ws, port, prevToken } = await startServerWithClient("secret");
|
||||
const { deviceToken, deviceIdentityPath } = await ensurePairedDeviceTokenForCurrentIdentity(ws);
|
||||
|
||||
@@ -542,7 +542,8 @@ export function attachGatewayWsMessageHandler(params: {
|
||||
if (
|
||||
!device &&
|
||||
(decision.kind !== "allow" ||
|
||||
(!preserveInsecureLocalControlUiScopes &&
|
||||
(!controlUiAuthPolicy.allowBypass &&
|
||||
!preserveInsecureLocalControlUiScopes &&
|
||||
(authMethod === "token" || authMethod === "password" || trustedProxyAuthOk)))
|
||||
) {
|
||||
clearUnboundScopes();
|
||||
|
||||
@@ -21,4 +21,16 @@ describe("shared/device-auth", () => {
|
||||
"z.scope",
|
||||
]);
|
||||
});
|
||||
|
||||
it("expands implied operator scopes for stored device auth", () => {
|
||||
expect(normalizeDeviceAuthScopes(["operator.write"])).toEqual([
|
||||
"operator.read",
|
||||
"operator.write",
|
||||
]);
|
||||
expect(normalizeDeviceAuthScopes(["operator.admin"])).toEqual([
|
||||
"operator.admin",
|
||||
"operator.read",
|
||||
"operator.write",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,5 +26,11 @@ export function normalizeDeviceAuthScopes(scopes: string[] | undefined): string[
|
||||
out.add(trimmed);
|
||||
}
|
||||
}
|
||||
if (out.has("operator.admin")) {
|
||||
out.add("operator.read");
|
||||
out.add("operator.write");
|
||||
} else if (out.has("operator.write")) {
|
||||
out.add("operator.read");
|
||||
}
|
||||
return [...out].toSorted();
|
||||
}
|
||||
|
||||
@@ -2,6 +2,10 @@ import type { GatewayBrowserClient } from "../gateway.ts";
|
||||
import type { AgentsListResult, ToolsCatalogResult } from "../types.ts";
|
||||
import { saveConfig } from "./config.ts";
|
||||
import type { ConfigState } from "./config.ts";
|
||||
import {
|
||||
formatMissingOperatorReadScopeMessage,
|
||||
isMissingOperatorReadScopeError,
|
||||
} from "./scope-errors.ts";
|
||||
|
||||
export type AgentsState = {
|
||||
client: GatewayBrowserClient | null;
|
||||
@@ -38,7 +42,12 @@ export async function loadAgents(state: AgentsState) {
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
state.agentsError = String(err);
|
||||
if (isMissingOperatorReadScopeError(err)) {
|
||||
state.agentsList = null;
|
||||
state.agentsError = formatMissingOperatorReadScopeMessage("agent list");
|
||||
} else {
|
||||
state.agentsError = String(err);
|
||||
}
|
||||
} finally {
|
||||
state.agentsLoading = false;
|
||||
}
|
||||
@@ -76,7 +85,9 @@ export async function loadToolsCatalog(state: AgentsState, agentId: string) {
|
||||
return;
|
||||
}
|
||||
state.toolsCatalogResult = null;
|
||||
state.toolsCatalogError = String(err);
|
||||
state.toolsCatalogError = isMissingOperatorReadScopeError(err)
|
||||
? formatMissingOperatorReadScopeMessage("tools catalog")
|
||||
: String(err);
|
||||
} finally {
|
||||
if (state.toolsCatalogLoadingAgentId === resolvedAgentId) {
|
||||
state.toolsCatalogLoadingAgentId = null;
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { ChannelsStatusSnapshot } from "../types.ts";
|
||||
import type { ChannelsState } from "./channels.types.ts";
|
||||
import {
|
||||
formatMissingOperatorReadScopeMessage,
|
||||
isMissingOperatorReadScopeError,
|
||||
} from "./scope-errors.ts";
|
||||
|
||||
export type { ChannelsState };
|
||||
|
||||
@@ -20,7 +24,12 @@ export async function loadChannels(state: ChannelsState, probe: boolean) {
|
||||
state.channelsSnapshot = res;
|
||||
state.channelsLastSuccess = Date.now();
|
||||
} catch (err) {
|
||||
state.channelsError = String(err);
|
||||
if (isMissingOperatorReadScopeError(err)) {
|
||||
state.channelsSnapshot = null;
|
||||
state.channelsError = formatMissingOperatorReadScopeMessage("channel status");
|
||||
} else {
|
||||
state.channelsError = String(err);
|
||||
}
|
||||
} finally {
|
||||
state.channelsLoading = false;
|
||||
}
|
||||
|
||||
@@ -630,4 +630,27 @@ describe("loadChatHistory", () => {
|
||||
expect(state.chatLoading).toBe(false);
|
||||
expect(state.lastError).toBeNull();
|
||||
});
|
||||
|
||||
it("shows a targeted message when chat history is unauthorized", async () => {
|
||||
const request = vi.fn().mockRejectedValue(
|
||||
new GatewayRequestError({
|
||||
code: "PERMISSION_DENIED",
|
||||
message: "not allowed",
|
||||
details: { code: "AUTH_UNAUTHORIZED" },
|
||||
}),
|
||||
);
|
||||
const state = createState({
|
||||
connected: true,
|
||||
client: { request } as unknown as ChatState["client"],
|
||||
chatMessages: [{ role: "assistant", content: [{ type: "text", text: "old" }] }],
|
||||
chatThinkingLevel: "high",
|
||||
});
|
||||
|
||||
await loadChatHistory(state);
|
||||
|
||||
expect(state.chatMessages).toEqual([]);
|
||||
expect(state.chatThinkingLevel).toBeNull();
|
||||
expect(state.lastError).toContain("operator.read");
|
||||
expect(state.chatLoading).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,10 @@ import { formatConnectError } from "../connect-error.ts";
|
||||
import type { GatewayBrowserClient } from "../gateway.ts";
|
||||
import type { ChatAttachment } from "../ui-types.ts";
|
||||
import { generateUUID } from "../uuid.ts";
|
||||
import {
|
||||
formatMissingOperatorReadScopeMessage,
|
||||
isMissingOperatorReadScopeError,
|
||||
} from "./scope-errors.ts";
|
||||
|
||||
const SILENT_REPLY_PATTERN = /^\s*NO_REPLY\s*$/;
|
||||
|
||||
@@ -87,7 +91,13 @@ export async function loadChatHistory(state: ChatState) {
|
||||
state.chatStream = null;
|
||||
state.chatStreamStartedAt = null;
|
||||
} catch (err) {
|
||||
state.lastError = String(err);
|
||||
if (isMissingOperatorReadScopeError(err)) {
|
||||
state.chatMessages = [];
|
||||
state.chatThinkingLevel = null;
|
||||
state.lastError = formatMissingOperatorReadScopeMessage("existing chat history");
|
||||
} else {
|
||||
state.lastError = String(err);
|
||||
}
|
||||
} finally {
|
||||
state.chatLoading = false;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,10 @@ import type {
|
||||
} from "../types.ts";
|
||||
import { CRON_CHANNEL_LAST } from "../ui-types.ts";
|
||||
import type { CronFormState } from "../ui-types.ts";
|
||||
import {
|
||||
formatMissingOperatorReadScopeMessage,
|
||||
isMissingOperatorReadScopeError,
|
||||
} from "./scope-errors.ts";
|
||||
|
||||
export type CronFieldKey =
|
||||
| "name"
|
||||
@@ -183,7 +187,12 @@ export async function loadCronStatus(state: CronState) {
|
||||
const res = await state.client.request<CronStatus>("cron.status", {});
|
||||
state.cronStatus = res;
|
||||
} catch (err) {
|
||||
state.cronError = String(err);
|
||||
if (isMissingOperatorReadScopeError(err)) {
|
||||
state.cronStatus = null;
|
||||
state.cronError = formatMissingOperatorReadScopeMessage("cron status");
|
||||
} else {
|
||||
state.cronError = String(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import type { GatewayBrowserClient } from "../gateway.ts";
|
||||
import type { LogEntry, LogLevel } from "../types.ts";
|
||||
import {
|
||||
formatMissingOperatorReadScopeMessage,
|
||||
isMissingOperatorReadScopeError,
|
||||
} from "./scope-errors.ts";
|
||||
|
||||
export type LogsState = {
|
||||
client: GatewayBrowserClient | null;
|
||||
@@ -140,7 +144,12 @@ export async function loadLogs(state: LogsState, opts?: { reset?: boolean; quiet
|
||||
state.logsTruncated = Boolean(payload.truncated);
|
||||
state.logsLastFetchAt = Date.now();
|
||||
} catch (err) {
|
||||
state.logsError = String(err);
|
||||
if (isMissingOperatorReadScopeError(err)) {
|
||||
state.logsEntries = [];
|
||||
state.logsError = formatMissingOperatorReadScopeMessage("logs");
|
||||
} else {
|
||||
state.logsError = String(err);
|
||||
}
|
||||
} finally {
|
||||
if (!opts?.quiet) {
|
||||
state.logsLoading = false;
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import type { GatewayBrowserClient } from "../gateway.ts";
|
||||
import type { PresenceEntry } from "../types.ts";
|
||||
import {
|
||||
formatMissingOperatorReadScopeMessage,
|
||||
isMissingOperatorReadScopeError,
|
||||
} from "./scope-errors.ts";
|
||||
|
||||
export type PresenceState = {
|
||||
client: GatewayBrowserClient | null;
|
||||
@@ -30,7 +34,13 @@ export async function loadPresence(state: PresenceState) {
|
||||
state.presenceStatus = "No presence payload.";
|
||||
}
|
||||
} catch (err) {
|
||||
state.presenceError = String(err);
|
||||
if (isMissingOperatorReadScopeError(err)) {
|
||||
state.presenceEntries = [];
|
||||
state.presenceStatus = null;
|
||||
state.presenceError = formatMissingOperatorReadScopeMessage("instance presence");
|
||||
} else {
|
||||
state.presenceError = String(err);
|
||||
}
|
||||
} finally {
|
||||
state.presenceLoading = false;
|
||||
}
|
||||
|
||||
20
ui/src/ui/controllers/scope-errors.ts
Normal file
20
ui/src/ui/controllers/scope-errors.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { ConnectErrorDetailCodes } from "../../../../src/gateway/protocol/connect-error-details.js";
|
||||
import { GatewayRequestError, resolveGatewayErrorDetailCode } from "../gateway.ts";
|
||||
|
||||
export function isMissingOperatorReadScopeError(err: unknown): boolean {
|
||||
if (!(err instanceof GatewayRequestError)) {
|
||||
return false;
|
||||
}
|
||||
// AUTH_UNAUTHORIZED is the current server signal for scope failures in RPC responses.
|
||||
// The message-based fallback below catches cases where no detail code is set.
|
||||
if (detailCode === ConnectErrorDetailCodes.AUTH_UNAUTHORIZED) {
|
||||
return true;
|
||||
}
|
||||
// RPC scope failures do not yet expose a dedicated structured detail code.
|
||||
// Fall back to the current gateway message until the protocol surfaces one.
|
||||
return err.message.includes("missing scope: operator.read");
|
||||
}
|
||||
|
||||
export function formatMissingOperatorReadScopeMessage(feature: string): string {
|
||||
return `This connection is missing operator.read, so ${feature} cannot be loaded yet.`;
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
import { toNumber } from "../format.ts";
|
||||
import type { GatewayBrowserClient } from "../gateway.ts";
|
||||
import type { SessionsListResult } from "../types.ts";
|
||||
import {
|
||||
formatMissingOperatorReadScopeMessage,
|
||||
isMissingOperatorReadScopeError,
|
||||
} from "./scope-errors.ts";
|
||||
|
||||
export type SessionsState = {
|
||||
client: GatewayBrowserClient | null;
|
||||
@@ -62,7 +66,12 @@ export async function loadSessions(
|
||||
state.sessionsResult = res;
|
||||
}
|
||||
} catch (err) {
|
||||
state.sessionsError = String(err);
|
||||
if (isMissingOperatorReadScopeError(err)) {
|
||||
state.sessionsResult = null;
|
||||
state.sessionsError = formatMissingOperatorReadScopeMessage("sessions");
|
||||
} else {
|
||||
state.sessionsError = String(err);
|
||||
}
|
||||
} finally {
|
||||
state.sessionsLoading = false;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,10 @@ import { getSafeLocalStorage } from "../../local-storage.ts";
|
||||
import type { GatewayBrowserClient } from "../gateway.ts";
|
||||
import type { SessionsUsageResult, CostUsageSummary, SessionUsageTimeSeries } from "../types.ts";
|
||||
import type { SessionLogEntry } from "../views/usage.ts";
|
||||
import {
|
||||
formatMissingOperatorReadScopeMessage,
|
||||
isMissingOperatorReadScopeError,
|
||||
} from "./scope-errors.ts";
|
||||
|
||||
export type UsageState = {
|
||||
client: GatewayBrowserClient | null;
|
||||
@@ -242,7 +246,13 @@ export async function loadUsage(
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
state.usageError = toErrorMessage(err);
|
||||
if (isMissingOperatorReadScopeError(err)) {
|
||||
state.usageResult = null;
|
||||
state.usageCostSummary = null;
|
||||
state.usageError = formatMissingOperatorReadScopeMessage("usage");
|
||||
} else {
|
||||
state.usageError = toErrorMessage(err);
|
||||
}
|
||||
} finally {
|
||||
state.usageLoading = false;
|
||||
}
|
||||
|
||||
@@ -263,6 +263,27 @@ describe("GatewayBrowserClient", () => {
|
||||
expect(signedPayload).toContain("|stored-device-token|nonce-1");
|
||||
});
|
||||
|
||||
it("ignores cached operator device tokens that do not include read access", async () => {
|
||||
localStorage.clear();
|
||||
storeDeviceAuthToken({
|
||||
deviceId: "device-1",
|
||||
role: "operator",
|
||||
token: "under-scoped-device-token",
|
||||
scopes: [],
|
||||
});
|
||||
|
||||
const client = new GatewayBrowserClient({
|
||||
url: "ws://127.0.0.1:18789",
|
||||
});
|
||||
|
||||
const { connectFrame } = await startConnect(client);
|
||||
|
||||
expect(connectFrame.method).toBe("connect");
|
||||
expect(connectFrame.params?.auth?.token).toBeUndefined();
|
||||
const signedPayload = signDevicePayloadMock.mock.calls[0]?.[1];
|
||||
expect(signedPayload).not.toContain("under-scoped-device-token");
|
||||
});
|
||||
|
||||
it("retries once with device token after token mismatch when shared token is explicit", async () => {
|
||||
vi.useFakeTimers();
|
||||
const client = new GatewayBrowserClient({
|
||||
|
||||
@@ -562,10 +562,17 @@ export class GatewayBrowserClient {
|
||||
private selectConnectAuth(params: { role: string; deviceId: string }): SelectedConnectAuth {
|
||||
const explicitGatewayToken = this.opts.token?.trim() || undefined;
|
||||
const authPassword = this.opts.password?.trim() || undefined;
|
||||
const storedToken = loadDeviceAuthToken({
|
||||
const storedEntry = loadDeviceAuthToken({
|
||||
deviceId: params.deviceId,
|
||||
role: params.role,
|
||||
})?.token;
|
||||
});
|
||||
const storedScopes = storedEntry?.scopes ?? [];
|
||||
const storedTokenCanRead =
|
||||
params.role !== CONTROL_UI_OPERATOR_ROLE ||
|
||||
storedScopes.includes("operator.read") ||
|
||||
storedScopes.includes("operator.write") ||
|
||||
storedScopes.includes("operator.admin");
|
||||
const storedToken = storedTokenCanRead ? storedEntry?.token : undefined;
|
||||
const shouldUseDeviceRetryToken =
|
||||
this.pendingDeviceTokenRetry &&
|
||||
Boolean(explicitGatewayToken) &&
|
||||
|
||||
Reference in New Issue
Block a user