From 040e0f5a6eff862dacefc08f6e98ed0aba0e805e Mon Sep 17 00:00:00 2001 From: Shakker Date: Sun, 2 Aug 2026 06:57:25 +0100 Subject: [PATCH] fix: disable unauthorized session controls --- ui/src/app/app-host-native-shell.test.ts | 69 ++++++++++++++++--- ui/src/app/app-shell-chrome.ts | 9 +++ ui/src/app/app-shell-view.ts | 3 + .../app-sidebar-session-catalog-render.ts | 14 +++- .../app-sidebar-session-list-render.ts | 22 +++++- .../app-sidebar-session-row-render.ts | 12 +++- .../app-sidebar-session-section-header.ts | 12 +++- ui/src/components/macos-titlebar-controls.ts | 5 +- ui/src/pages/sessions/view.test.ts | 50 ++++++++++++++ ui/src/pages/sessions/view.ts | 14 +++- .../app-sidebar-cases/section-reordering.ts | 36 +++++++++- 11 files changed, 221 insertions(+), 25 deletions(-) diff --git a/ui/src/app/app-host-native-shell.test.ts b/ui/src/app/app-host-native-shell.test.ts index f6f07fdb25f2..3b170be90bb4 100644 --- a/ui/src/app/app-host-native-shell.test.ts +++ b/ui/src/app/app-host-native-shell.test.ts @@ -31,11 +31,33 @@ type TestWebKitWindow = Window & { type MacosTitlebarControlsState = HTMLElement & { navCollapsed: boolean; historyOnly: boolean; + newSessionDisabledReason?: string; onOpenPalette?: () => void; onOpenNewSession?: () => void; updateComplete: Promise; }; +function nativeSessionContext( + navigate: ReturnType, + selectedId: string, + options: { methods?: string[]; scopes?: string[] } = {}, +): ApplicationContext { + return { + navigate, + agentSelection: { state: { selectedId } }, + gateway: { + snapshot: { + client: {}, + phase: "connected", + hello: { + auth: { role: "operator", scopes: options.scopes ?? ["operator.write"] }, + features: { methods: options.methods ?? ["sessions.create"] }, + }, + }, + }, + } as unknown as ApplicationContext; +} + afterEach(() => { resetAppHostTestGlobals(); }); @@ -114,10 +136,7 @@ describe("OpenClaw native shell", () => { value: { openPalette, togglePalette }, }); shell.runtime = { - context: { - navigate, - agentSelection: { state: { selectedId: "agent/a" } }, - } as unknown as ApplicationContext, + context: nativeSessionContext(navigate, "agent/a"), }; shell.handleNativeOpenSearch(); const toggleEvent = new CustomEvent("openclaw:native-toggle-search", { cancelable: true }); @@ -156,6 +175,26 @@ describe("OpenClaw native shell", () => { controls.remove(); }); + it("disables the native titlebar new-session control with its access reason", async () => { + const onOpenNewSession = vi.fn(); + const controls = document.createElement( + "openclaw-macos-titlebar-controls", + ) as unknown as MacosTitlebarControlsState; + controls.navCollapsed = true; + controls.newSessionDisabledReason = "Operator write access is required."; + controls.onOpenNewSession = onOpenNewSession; + document.body.append(controls); + await controls.updateComplete; + + const button = controls.querySelector( + ".macos-titlebar-controls__new-session", + ); + expect(button?.disabled).toBe(true); + button?.click(); + expect(onOpenNewSession).not.toHaveBeenCalled(); + controls.remove(); + }); + it("retains a native new-session request until a context exists", () => { const navigate = vi.fn(); const shell = document.createElement("openclaw-app-shell") as unknown as ShellNavigationState; @@ -163,16 +202,30 @@ describe("OpenClaw native shell", () => { shell.handleNativeNewSession(); shell.runtime = { - context: { - navigate, - agentSelection: { state: { selectedId: "main" } }, - } as unknown as ApplicationContext, + context: nativeSessionContext(navigate, "main"), }; shell.handleNativeNewSession(); expect(navigate).toHaveBeenCalledExactlyOnceWith("new-session", { search: "?agent=main" }); }); + it("does not start a native session without exact sessions.create access", () => { + for (const options of [ + { methods: ["sessions.list"], scopes: ["operator.write"] }, + { methods: ["sessions.create"], scopes: ["operator.read"] }, + ]) { + const navigate = vi.fn(); + const shell = document.createElement("openclaw-app-shell") as unknown as ShellNavigationState; + shell.runtime = { + context: nativeSessionContext(navigate, "main", options), + }; + + shell.handleNativeNewSession(); + + expect(navigate).not.toHaveBeenCalled(); + } + }); + it("navigates valid native Dashboard paths and acknowledges them", () => { const navigate = vi.fn(); const shell = document.createElement("openclaw-app-shell") as unknown as ShellNavigationState; diff --git a/ui/src/app/app-shell-chrome.ts b/ui/src/app/app-shell-chrome.ts index c4de745a3860..212eb9f80645 100644 --- a/ui/src/app/app-shell-chrome.ts +++ b/ui/src/app/app-shell-chrome.ts @@ -20,6 +20,7 @@ import { import type { BoardFace } from "../lib/board/settings.ts"; import { isGatewayMethodAdvertised } from "../lib/gateway-methods.ts"; import { resolveAsciiShortcutKey } from "../lib/keyboard-shortcuts.ts"; +import { readSessionMethodAccess } from "../lib/session-method-access.ts"; import { isTerminalAvailable } from "../lib/terminal-availability.ts"; import type { ShellRouteState } from "./app-host-route-state.ts"; import type { ApplicationContext, ApplicationNavigationOptions } from "./context.ts"; @@ -235,6 +236,14 @@ export class ShellChromeOwner { host.pendingNativeNewSession = true; return; } + if ( + !readSessionMethodAccess(context.gateway.snapshot, { + method: "sessions.create", + params: {}, + }).allowed + ) { + return; + } host.openNewSession(context.agentSelection.state.selectedId ?? ""); }; diff --git a/ui/src/app/app-shell-view.ts b/ui/src/app/app-shell-view.ts index 7a2fb45a9ab5..e09d33b7c2d6 100644 --- a/ui/src/app/app-shell-view.ts +++ b/ui/src/app/app-shell-view.ts @@ -297,6 +297,9 @@ export function renderApplicationShell(host: ShellViewHost) { .historyOnly=${settingsTakeover} .canGoBack=${host.nativeHistoryState.canGoBack} .canGoForward=${host.nativeHistoryState.canGoForward} + .newSessionDisabledReason=${newSessionAccess.allowed + ? undefined + : newSessionAccess.reason} .onToggleSidebar=${() => host.toggleNavigationSurface()} .onOpenPalette=${() => host.openPalette()} .onOpenNewSession=${() => host.handleNativeNewSession()} diff --git a/ui/src/components/app-sidebar-session-catalog-render.ts b/ui/src/components/app-sidebar-session-catalog-render.ts index 403159f4af93..1b813a5bd9e3 100644 --- a/ui/src/components/app-sidebar-session-catalog-render.ts +++ b/ui/src/components/app-sidebar-session-catalog-render.ts @@ -56,6 +56,7 @@ type SessionCatalogGroupsParams = { onLoadMore: (catalogId: string) => void; onOpenNewSession?: (agentId: string, target?: NewSessionTarget) => void; newSessionDisabledReason?: string; + sectionDragDisabledReason?: string; onNavigate?: (routeId: NavigationRouteId, options?: ApplicationNavigationOptions) => void; catalogOpenTarget: "viewer" | "terminal"; terminalAvailable: boolean; @@ -159,12 +160,19 @@ export function renderSessionCatalogGroups(params: SessionCatalogGroupsParams) {
params.onSectionDragOver(event, sectionId)} - @dragleave=${(event: DragEvent) => params.onSectionDragLeave(event, sectionId)} - @drop=${(event: DragEvent) => params.onSectionDrop(event, sectionId)} + @dragover=${params.sectionDragDisabledReason + ? nothing + : (event: DragEvent) => params.onSectionDragOver(event, sectionId)} + @dragleave=${params.sectionDragDisabledReason + ? nothing + : (event: DragEvent) => params.onSectionDragLeave(event, sectionId)} + @drop=${params.sectionDragDisabledReason + ? nothing + : (event: DragEvent) => params.onSectionDrop(event, sectionId)} > ${renderSidebarSessionSectionHeader({ sectionId, + disabledReason: params.sectionDragDisabledReason, onStartDrag: params.onStartSectionDrag, onFinishDrag: params.onFinishSectionDrag, content: html` diff --git a/ui/src/components/app-sidebar-session-list-render.ts b/ui/src/components/app-sidebar-session-list-render.ts index daeb2ba5b00d..da412b359940 100644 --- a/ui/src/components/app-sidebar-session-list-render.ts +++ b/ui/src/components/app-sidebar-session-list-render.ts @@ -81,6 +81,10 @@ function renderSessionSection(params: { collapsed && section.rows.some((row) => rowDemandsVisibility(row, RowVisibilityReason.Attention)); const newSessionAccess = host.readNewSessionAccess(); + const groupWriteAccess = host.readSessionMutationAccess({ + method: "sessions.groups.put", + requiredScope: "operator.write", + }); const sectionClass = [ "sidebar-recent-sessions__group", `sidebar-recent-sessions__group--zone-${zone}`, @@ -101,12 +105,19 @@ function renderSessionSection(params: {
host.sectionDragOver(event, section.id, group)} - @dragleave=${(event: DragEvent) => host.sectionDragLeave(event, section.id, group)} - @drop=${(event: DragEvent) => host.sectionDrop(event, section.id, group)} + @dragover=${groupWriteAccess.allowed + ? (event: DragEvent) => host.sectionDragOver(event, section.id, group) + : nothing} + @dragleave=${groupWriteAccess.allowed + ? (event: DragEvent) => host.sectionDragLeave(event, section.id, group) + : nothing} + @drop=${groupWriteAccess.allowed + ? (event: DragEvent) => host.sectionDrop(event, section.id, group) + : nothing} > ${renderSidebarSessionSectionHeader({ sectionId: section.id, + disabledReason: groupWriteAccess.allowed ? undefined : groupWriteAccess.reason, onStartDrag: (sectionId) => host.startSidebarSectionDrag(sectionId), onFinishDrag: () => host.finishSidebarSectionDrag(), onContextMenu: group @@ -305,6 +316,10 @@ function renderSessionCatalog(params: { }) { const { host, snapshot, catalog, renderer } = params; const newSessionAccess = host.readNewSessionAccess(); + const groupWriteAccess = host.readSessionMutationAccess({ + method: "sessions.groups.put", + requiredScope: "operator.write", + }); return html` ${renderer({ catalogs: [catalog], @@ -344,6 +359,7 @@ function renderSessionCatalog(params: { onLoadMore: (catalogId) => void host.sessionData.loadMoreSessionCatalog(catalogId), onOpenNewSession: (agentId, target) => host.requestOpenNewSession(agentId, target), newSessionDisabledReason: newSessionAccess.allowed ? undefined : newSessionAccess.reason, + sectionDragDisabledReason: groupWriteAccess.allowed ? undefined : groupWriteAccess.reason, onNavigate: host.onNavigate, catalogOpenTarget: snapshot.catalogOpenTarget, terminalAvailable: snapshot.terminalAvailable, diff --git a/ui/src/components/app-sidebar-session-row-render.ts b/ui/src/components/app-sidebar-session-row-render.ts index d5f6f5bb0340..78ca11a2d652 100644 --- a/ui/src/components/app-sidebar-session-row-render.ts +++ b/ui/src/components/app-sidebar-session-row-render.ts @@ -217,13 +217,19 @@ export function renderRecentSession(params: { .filter(Boolean) .join(" "); const childrenExpanded = host.isSessionChildrenExpanded(session); + const groupWriteAccess = host.readSessionMutationAccess({ + method: "sessions.groups.put", + requiredScope: "operator.write", + }); + const rowDraggable = !session.isChild && groupWriteAccess.allowed; const row = html`
{ if (event.dataTransfer) { @@ -231,7 +237,7 @@ export function renderRecentSession(params: { host.startSessionDrag(session); } }} - @dragend=${session.isChild + @dragend=${!rowDraggable ? nothing : () => { host.finishSessionDrag(); diff --git a/ui/src/components/app-sidebar-session-section-header.ts b/ui/src/components/app-sidebar-session-section-header.ts index efea171cf9cf..a4708c1ea73f 100644 --- a/ui/src/components/app-sidebar-session-section-header.ts +++ b/ui/src/components/app-sidebar-session-section-header.ts @@ -4,14 +4,18 @@ import { writeSidebarSectionDragData } from "../lib/sessions/drag.ts"; export function renderSidebarSessionSectionHeader(params: { sectionId: string; content: TemplateResult; + disabledReason?: string; onStartDrag: (sectionId: string) => void; onFinishDrag: () => void; onContextMenu?: (event: MouseEvent) => void; }) { return html`