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:
Val Alexander 2026-03-23 14:57:21 -05:00 committed by GitHub
parent 99c84294f3
commit 3e2b3bd2c5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 208 additions and 12 deletions

View File

@ -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);

View File

@ -542,7 +542,8 @@ export function attachGatewayWsMessageHandler(params: {
if (
!device &&
(decision.kind !== "allow" ||
(!preserveInsecureLocalControlUiScopes &&
(!controlUiAuthPolicy.allowBypass &&
!preserveInsecureLocalControlUiScopes &&
(authMethod === "token" || authMethod === "password" || trustedProxyAuthOk)))
) {
clearUnboundScopes();

View File

@ -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",
]);
});
});

View File

@ -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();
}

View File

@ -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;

View File

@ -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;
}

View File

@ -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);
});
});

View File

@ -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;
}

View File

@ -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);
}
}
}

View File

@ -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;

View File

@ -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;
}

View 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.`;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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({

View File

@ -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) &&