From 3e2b3bd2c5722f7413af77a20fb9ade107a23f72 Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Mon, 23 Mar 2026 14:57:21 -0500 Subject: [PATCH] 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> --- src/gateway/server.auth.control-ui.suite.ts | 29 +++++++++++++++++++ .../server/ws-connection/message-handler.ts | 3 +- src/shared/device-auth.test.ts | 12 ++++++++ src/shared/device-auth.ts | 6 ++++ ui/src/ui/controllers/agents.ts | 15 ++++++++-- ui/src/ui/controllers/channels.ts | 11 ++++++- ui/src/ui/controllers/chat.test.ts | 23 +++++++++++++++ ui/src/ui/controllers/chat.ts | 12 +++++++- ui/src/ui/controllers/cron.ts | 11 ++++++- ui/src/ui/controllers/logs.ts | 11 ++++++- ui/src/ui/controllers/presence.ts | 12 +++++++- ui/src/ui/controllers/scope-errors.ts | 20 +++++++++++++ ui/src/ui/controllers/sessions.ts | 11 ++++++- ui/src/ui/controllers/usage.ts | 12 +++++++- ui/src/ui/gateway.node.test.ts | 21 ++++++++++++++ ui/src/ui/gateway.ts | 11 +++++-- 16 files changed, 208 insertions(+), 12 deletions(-) create mode 100644 ui/src/ui/controllers/scope-errors.ts diff --git a/src/gateway/server.auth.control-ui.suite.ts b/src/gateway/server.auth.control-ui.suite.ts index 804babd4d24..c860674592c 100644 --- a/src/gateway/server.auth.control-ui.suite.ts +++ b/src/gateway/server.auth.control-ui.suite.ts @@ -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); diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index 9f3a24f925f..f6c3847fece 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -542,7 +542,8 @@ export function attachGatewayWsMessageHandler(params: { if ( !device && (decision.kind !== "allow" || - (!preserveInsecureLocalControlUiScopes && + (!controlUiAuthPolicy.allowBypass && + !preserveInsecureLocalControlUiScopes && (authMethod === "token" || authMethod === "password" || trustedProxyAuthOk))) ) { clearUnboundScopes(); diff --git a/src/shared/device-auth.test.ts b/src/shared/device-auth.test.ts index d3018f5ba0a..267441d38c1 100644 --- a/src/shared/device-auth.test.ts +++ b/src/shared/device-auth.test.ts @@ -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", + ]); + }); }); diff --git a/src/shared/device-auth.ts b/src/shared/device-auth.ts index d093be0124a..fd5071be56a 100644 --- a/src/shared/device-auth.ts +++ b/src/shared/device-auth.ts @@ -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(); } diff --git a/ui/src/ui/controllers/agents.ts b/ui/src/ui/controllers/agents.ts index 706c3192271..ca02843ef57 100644 --- a/ui/src/ui/controllers/agents.ts +++ b/ui/src/ui/controllers/agents.ts @@ -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; diff --git a/ui/src/ui/controllers/channels.ts b/ui/src/ui/controllers/channels.ts index 22f5de15883..cf4c44ce7d9 100644 --- a/ui/src/ui/controllers/channels.ts +++ b/ui/src/ui/controllers/channels.ts @@ -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; } diff --git a/ui/src/ui/controllers/chat.test.ts b/ui/src/ui/controllers/chat.test.ts index ba102fe0919..d437c180e73 100644 --- a/ui/src/ui/controllers/chat.test.ts +++ b/ui/src/ui/controllers/chat.test.ts @@ -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); + }); }); diff --git a/ui/src/ui/controllers/chat.ts b/ui/src/ui/controllers/chat.ts index f2fccf57f92..ed8754403a3 100644 --- a/ui/src/ui/controllers/chat.ts +++ b/ui/src/ui/controllers/chat.ts @@ -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; } diff --git a/ui/src/ui/controllers/cron.ts b/ui/src/ui/controllers/cron.ts index c6073a8e626..f38f4c5b06d 100644 --- a/ui/src/ui/controllers/cron.ts +++ b/ui/src/ui/controllers/cron.ts @@ -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("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); + } } } diff --git a/ui/src/ui/controllers/logs.ts b/ui/src/ui/controllers/logs.ts index 90c2edcf00a..434ccbf7698 100644 --- a/ui/src/ui/controllers/logs.ts +++ b/ui/src/ui/controllers/logs.ts @@ -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; diff --git a/ui/src/ui/controllers/presence.ts b/ui/src/ui/controllers/presence.ts index 99bcb233cc6..dfd152481e0 100644 --- a/ui/src/ui/controllers/presence.ts +++ b/ui/src/ui/controllers/presence.ts @@ -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; } diff --git a/ui/src/ui/controllers/scope-errors.ts b/ui/src/ui/controllers/scope-errors.ts new file mode 100644 index 00000000000..8a744f7b37f --- /dev/null +++ b/ui/src/ui/controllers/scope-errors.ts @@ -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.`; +} diff --git a/ui/src/ui/controllers/sessions.ts b/ui/src/ui/controllers/sessions.ts index 2f647c578e6..4821f521b31 100644 --- a/ui/src/ui/controllers/sessions.ts +++ b/ui/src/ui/controllers/sessions.ts @@ -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; } diff --git a/ui/src/ui/controllers/usage.ts b/ui/src/ui/controllers/usage.ts index 5862bd82e72..43bf8621066 100644 --- a/ui/src/ui/controllers/usage.ts +++ b/ui/src/ui/controllers/usage.ts @@ -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; } diff --git a/ui/src/ui/gateway.node.test.ts b/ui/src/ui/gateway.node.test.ts index b7ac079e9f8..4872d889173 100644 --- a/ui/src/ui/gateway.node.test.ts +++ b/ui/src/ui/gateway.node.test.ts @@ -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({ diff --git a/ui/src/ui/gateway.ts b/ui/src/ui/gateway.ts index 28d107d6aaf..00f63a37767 100644 --- a/ui/src/ui/gateway.ts +++ b/ui/src/ui/gateway.ts @@ -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) &&