fix: disable unauthorized session controls

This commit is contained in:
Shakker
2026-08-02 06:57:25 +01:00
parent efaf46c677
commit 040e0f5a6e
11 changed files with 221 additions and 25 deletions
+61 -8
View File
@@ -31,11 +31,33 @@ type TestWebKitWindow = Window & {
type MacosTitlebarControlsState = HTMLElement & {
navCollapsed: boolean;
historyOnly: boolean;
newSessionDisabledReason?: string;
onOpenPalette?: () => void;
onOpenNewSession?: () => void;
updateComplete: Promise<boolean>;
};
function nativeSessionContext(
navigate: ReturnType<typeof vi.fn>,
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<HTMLButtonElement>(
".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;
+9
View File
@@ -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 ?? "");
};
+3
View File
@@ -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()}
@@ -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) {
<div
class=${sectionClass}
data-session-section=${sectionId}
@dragover=${(event: DragEvent) => 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`
@@ -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: {
<div
class=${sectionClass}
data-session-section=${section.id}
@dragover=${(event: DragEvent) => 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,
@@ -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`
<div
class=${rowClass}
data-session-key=${session.key}
role="listitem"
draggable=${session.isChild ? "false" : "true"}
@dragstart=${session.isChild
draggable=${rowDraggable ? "true" : "false"}
title=${!session.isChild && !groupWriteAccess.allowed ? groupWriteAccess.reason : nothing}
@dragstart=${!rowDraggable
? nothing
: (event: DragEvent) => {
if (event.dataTransfer) {
@@ -231,7 +237,7 @@ export function renderRecentSession(params: {
host.startSessionDrag(session);
}
}}
@dragend=${session.isChild
@dragend=${!rowDraggable
? nothing
: () => {
host.finishSessionDrag();
@@ -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`
<div
class="sidebar-recent-sessions__head sidebar-recent-sessions__head--draggable"
draggable="true"
class="sidebar-recent-sessions__head ${params.disabledReason
? ""
: "sidebar-recent-sessions__head--draggable"}"
draggable=${params.disabledReason ? "false" : "true"}
title=${params.disabledReason ?? nothing}
@mousedown=${(event: MouseEvent) => {
const header = event.currentTarget as HTMLElement;
header.toggleAttribute(
@@ -23,6 +27,10 @@ export function renderSidebarSessionSectionHeader(params: {
(event.currentTarget as HTMLElement).removeAttribute("data-section-drag-blocked");
}}
@dragstart=${(event: DragEvent) => {
if (params.disabledReason) {
event.preventDefault();
return;
}
const header = event.currentTarget as HTMLElement;
const startedFromButton =
Boolean((event.target as HTMLElement).closest("button")) ||
+4 -1
View File
@@ -11,6 +11,7 @@ class MacosTitlebarControls extends OpenClawLightDomContentsElement {
@property({ attribute: false }) historyOnly = false;
@property({ attribute: false }) canGoBack = false;
@property({ attribute: false }) canGoForward = false;
@property({ attribute: false }) newSessionDisabledReason?: string;
@property({ attribute: false }) onToggleSidebar?: () => void;
@property({ attribute: false }) onOpenPalette?: () => void;
@property({ attribute: false }) onOpenNewSession?: () => void;
@@ -54,9 +55,11 @@ class MacosTitlebarControls extends OpenClawLightDomContentsElement {
${this.navCollapsed
? this.renderButton({
// While the sidebar rail is collapsed, this mirrors the native
// ⌘N item and stays deliberately free of connection state.
// new-session item and its current Gateway authorization.
label: t("chat.runControls.newSession"),
tooltip: this.newSessionDisabledReason,
icon: icons.plus,
disabled: Boolean(this.newSessionDisabledReason),
onClick: this.onOpenNewSession,
className: "macos-titlebar-controls__new-session",
})
+50
View File
@@ -549,6 +549,49 @@ describe("sessions view", () => {
expect(onAssignCategory).toHaveBeenCalledWith("agent:main:main", "Research");
});
it("disables category assignment controls without group write access", async () => {
const container = document.createElement("div");
const onAssignCategory = vi.fn();
const reason = "Operator write access is required.";
render(
renderSessions({
...buildProps(
buildMultiResult([
{ key: "agent:main:main", kind: "direct", updatedAt: 1, category: "Research" },
]),
),
groupBy: "category",
knownCategories: ["Research"],
groupWriteDisabledReason: reason,
onAssignCategory,
}),
container,
);
await Promise.resolve();
const select = container.querySelector<HTMLSelectElement>(
'select[aria-label="Move thread to a group"]',
);
expect(select?.disabled).toBe(true);
expect(select?.title).toBe(reason);
if (select) {
select.value = "";
select.dispatchEvent(new Event("change", { bubbles: true }));
}
const headerRow = container.querySelector(".session-group-row");
const drop = new Event("drop", { bubbles: true, cancelable: true });
Object.defineProperty(drop, "dataTransfer", {
value: {
types: ["application/x-openclaw-session-key"],
getData: () => "agent:main:main",
},
});
headerRow?.dispatchEvent(drop);
expect(onAssignCategory).not.toHaveBeenCalled();
});
it("opens the session menu from the kebab and row context menu", async () => {
const container = document.createElement("div");
const onOpenSessionMenu = vi.fn();
@@ -1236,6 +1279,7 @@ describe("sessions view", () => {
}),
),
expandedSessionKey: "agent:main:main",
patchAdminDisabledReason: "Operator admin access is required.",
checkpointItemsByKey: {
"agent:main:main": [
{
@@ -1294,6 +1338,12 @@ describe("sessions view", () => {
(label) => label.textContent?.trim(),
),
).toEqual(["Label", "Thinking", "Fast", "Verbose", "Reasoning"]);
for (const select of overridesSection?.querySelectorAll<HTMLSelectElement>(
".session-override-field select",
) ?? []) {
expect(select.disabled).toBe(true);
expect(select.title).toBe("Operator admin access is required.");
}
expect(
compactionSection?.querySelector(".session-details-panel__eyebrow")?.textContent?.trim(),
+12 -2
View File
@@ -813,7 +813,7 @@ function setDropTargetActive(event: DragEvent, active: boolean) {
}
function categoryDropHandlers(props: SessionsProps, category: string | null) {
if (props.groupBy !== "category") {
if (props.groupBy !== "category" || props.groupWriteDisabledReason) {
return { dragover: nothing, dragleave: nothing, drop: nothing } as const;
}
const carriesSessionKey = (event: DragEvent) =>
@@ -878,10 +878,14 @@ function renderCategoryCell(row: GatewaySessionRow, props: SessionsProps) {
return html`
<td>
<select
?disabled=${props.loading}
?disabled=${props.loading || Boolean(props.groupWriteDisabledReason)}
title=${props.groupWriteDisabledReason ?? nothing}
aria-label=${t("sessionsView.moveToGroup")}
class="session-group-select"
@change=${(e: Event) => {
if (props.groupWriteDisabledReason) {
return;
}
const select = e.target as HTMLSelectElement;
if (select.value === NEW_GROUP_OPTION) {
// The page prompts for a name and patches; restore until the refresh lands.
@@ -945,6 +949,7 @@ function renderFilterToggle(params: {
function renderOverrideSelect(params: {
label: string;
disabled: boolean;
disabledReason?: string;
options: readonly { value: string; label: string }[];
current: string;
onChange: (value: string) => void;
@@ -955,6 +960,7 @@ function renderOverrideSelect(params: {
<select
class="settings-select"
?disabled=${params.disabled}
title=${params.disabledReason ?? nothing}
@change=${(e: Event) => params.onChange((e.target as HTMLSelectElement).value)}
>
${params.options.map(
@@ -1679,6 +1685,7 @@ function renderSessionDetailsRow(params: {
${renderOverrideSelect({
label: t("sessionsView.thinking"),
disabled: props.loading || Boolean(props.patchAdminDisabledReason),
disabledReason: props.patchAdminDisabledReason,
options: thinkLevels,
current: thinking,
onChange: (value) => props.onPatch(row.key, { thinkingLevel: value || null }),
@@ -1686,6 +1693,7 @@ function renderSessionDetailsRow(params: {
${renderOverrideSelect({
label: t("sessionsView.fast"),
disabled: props.loading || Boolean(props.patchAdminDisabledReason),
disabledReason: props.patchAdminDisabledReason,
options: fastLevels,
current: fastMode,
onChange: (value) =>
@@ -1696,6 +1704,7 @@ function renderSessionDetailsRow(params: {
${renderOverrideSelect({
label: t("sessionsView.verbose"),
disabled: props.loading || Boolean(props.patchAdminDisabledReason),
disabledReason: props.patchAdminDisabledReason,
options: verboseLevels,
current: verbose,
onChange: (value) => props.onPatch(row.key, { verboseLevel: value || null }),
@@ -1703,6 +1712,7 @@ function renderSessionDetailsRow(params: {
${renderOverrideSelect({
label: t("sessionsView.reasoning"),
disabled: props.loading || Boolean(props.patchAdminDisabledReason),
disabledReason: props.patchAdminDisabledReason,
options: reasoningLevels.map((level) => ({
value: level,
label: level || t("sessionsView.inherit"),
@@ -38,16 +38,22 @@ describe("AppSidebar section reordering", () => {
async function mountWithGroups(
groups: string[],
sectionOrder: string[] = [],
options: { withCatalog?: boolean } = {},
options: { withCatalog?: boolean; scopes?: string[] } = {},
) {
const request = vi
.fn()
.mockResolvedValue(catalogPage([{ threadId: "thread-codex", name: "Codex thread" }]));
const gateway = createGatewayHarness({ request } as unknown as GatewayBrowserClient);
if (options.withCatalog) {
if (options.withCatalog || options.scopes) {
gateway.publish({
hello: {
features: { methods: ["sessions.catalog.list", "sessions.groups.put"] },
...(options.scopes ? { auth: { role: "operator", scopes: options.scopes } } : {}),
features: {
methods: [
...(options.withCatalog ? ["sessions.catalog.list"] : []),
"sessions.groups.put",
],
},
} as ApplicationGatewaySnapshot["hello"],
});
}
@@ -129,6 +135,30 @@ describe("AppSidebar section reordering", () => {
}
});
it("disables section and row dragging without group write access", async () => {
const { sidebar, harness } = await mountWithGroups(["Alpha"], [], {
scopes: ["operator.read"],
});
const header = groupHeader(sidebar, "category:Alpha");
const row = sidebar.querySelector('[data-session-key="agent:main:plain"]');
expect(header.getAttribute("draggable")).toBe("false");
expect(header.getAttribute("title")).toBeTruthy();
expect(row?.getAttribute("draggable")).toBe("false");
expect(row?.getAttribute("title")).toBeTruthy();
const dataTransfer = createDataTransferStub();
dispatchDragEvent(header, "dragstart", dataTransfer);
const threadsSection = sidebar.querySelector('[data-session-section="ungrouped"]');
if (!threadsSection) {
throw new Error("expected Threads section");
}
dispatchDragEvent(threadsSection, "drop", dataTransfer);
expect(dataTransfer.types).toEqual([]);
expect(harness.groupsPut).not.toHaveBeenCalled();
});
it("does not start a section drag from a header action button", async () => {
const { sidebar } = await mountWithGroups([]);
const dataTransfer = createDataTransferStub();