fix: honor operator scopes in session controls

This commit is contained in:
Shakker
2026-08-02 05:42:51 +01:00
parent 2c96af3b76
commit 4e484da052
12 changed files with 613 additions and 69 deletions
+8
View File
@@ -101,6 +101,14 @@ export abstract class AppSidebarBase extends OpenClawLightDomContentsElement {
});
}
readSessionMutationAccess(request: {
method: string;
params?: unknown;
requiredScope?: "operator.write" | "operator.admin";
}): SessionMethodAccess {
return readSessionMethodAccess(this.connected ? this.context?.gateway.snapshot : null, request);
}
requestOpenNewSession(agentId: string, target?: NewSessionTarget): void {
if (this.readNewSessionAccess().allowed) {
if (target) {
@@ -25,6 +25,7 @@ export function renderSidebarSessionGroupMenu(params: {
menu: SidebarSessionGroupMenuState | null;
trigger: HTMLElement | null;
connected: boolean;
disabledReason?: string;
onAction: (action: SidebarSessionGroupMenuAction, group: string) => void;
onClose: (restoreFocus: boolean) => void;
}) {
@@ -45,7 +46,10 @@ export function renderSidebarSessionGroupMenu(params: {
@wa-select=${(event: CustomEvent<{ item: { value?: string } }>) => {
event.preventDefault();
const value = event.detail.item.value;
if (value === "rename-group" || value === "new-group" || value === "delete-group") {
if (
!params.disabledReason &&
(value === "rename-group" || value === "new-group" || value === "delete-group")
) {
params.onAction(value, menu.group);
}
}}
@@ -65,12 +69,18 @@ export function renderSidebarSessionGroupMenu(params: {
<wa-dropdown-item
class="session-menu__item"
value="rename-group"
?disabled=${!params.connected}
?disabled=${!params.connected || Boolean(params.disabledReason)}
title=${params.disabledReason ?? nothing}
>
<span slot="icon" class="session-menu__icon" aria-hidden="true">${icons.edit}</span>
<span class="session-menu__text">${t("sessionsView.renameGroupMenu")}</span>
</wa-dropdown-item>
<wa-dropdown-item class="session-menu__item" value="new-group">
<wa-dropdown-item
class="session-menu__item"
value="new-group"
?disabled=${!params.connected || Boolean(params.disabledReason)}
title=${params.disabledReason ?? nothing}
>
<span slot="icon" class="session-menu__icon" aria-hidden="true">${icons.folder}</span>
<span class="session-menu__text">${t("sessionsView.newGroup")}</span>
</wa-dropdown-item>
@@ -79,7 +89,8 @@ export function renderSidebarSessionGroupMenu(params: {
class="session-menu__item session-menu__item--destructive"
value="delete-group"
variant="danger"
?disabled=${!params.connected}
?disabled=${!params.connected || Boolean(params.disabledReason)}
title=${params.disabledReason ?? nothing}
>
<span slot="icon" class="session-menu__icon" aria-hidden="true">${icons.trash}</span>
<span class="session-menu__text">${t("sessionsView.deleteGroupMenu")}</span>
@@ -114,6 +114,11 @@ export interface SessionListHost {
toggleSection(sectionId: string): void;
openNewSession(): void;
readNewSessionAccess(): import("../lib/session-method-access.ts").SessionMethodAccess;
readSessionMutationAccess(request: {
method: string;
params?: unknown;
requiredScope?: "operator.write" | "operator.admin";
}): import("../lib/session-method-access.ts").SessionMethodAccess;
requestOpenNewSession(agentId: string, target?: NewSessionTarget): void;
setVisibleSessionLimit(sectionId: string, limit: number): void;
clearSessionSelection(): void;
@@ -149,6 +154,10 @@ export function renderRecentSession(params: {
display?: CatalogBackingSessionDisplay;
}) {
const { host, session, display } = params;
const pinAccess = host.readSessionMutationAccess({
method: "sessions.patch",
params: { key: session.key, pinned: !session.pinned },
});
const label = display?.label ?? session.label;
const { subtitle, narration } = resolveSidebarSessionSubtitle({
session,
@@ -346,9 +355,9 @@ export function renderRecentSession(params: {
class="session-action session-action--pin"
data-sidebar-session-pin="true"
type="button"
title=${pinLabel}
title=${pinAccess.allowed ? pinLabel : pinAccess.reason}
aria-label=${pinLabel}
?disabled=${!host.connected}
?disabled=${!pinAccess.allowed}
@click=${() => host.toggleSessionPin(session)}
>
${icons.pin}
+30 -1
View File
@@ -3,7 +3,7 @@
import { html, render } from "lit";
import { afterEach, describe, expect, it, vi } from "vitest";
import "./session-menu.ts";
import type { SessionMenuAction, SessionMenuWork } from "./session-menu.ts";
import type { SessionMenuAction, SessionMenuActionKind, SessionMenuWork } from "./session-menu.ts";
type SessionMenuData = {
label: string;
@@ -43,6 +43,7 @@ async function mountMenu(
trigger?: HTMLElement | null;
onAction?: (action: SessionMenuAction) => void;
onClose?: () => void;
actionDisabledReasons?: Partial<Record<SessionMenuActionKind, string>>;
} = {},
): Promise<SessionMenuElement> {
const container = document.createElement("div");
@@ -64,6 +65,7 @@ async function mountMenu(
.anchor=${{ x: 100, y: 100 }}
.trigger=${options.trigger ?? null}
.disabled=${false}
.actionDisabledReasons=${options.actionDisabledReasons ?? {}}
.forkDisabled=${false}
.archiveAllowed=${options.archiveAllowed ?? true}
.cloudWorkerStopAllowed=${options.cloudWorkerStopAllowed ?? false}
@@ -115,6 +117,33 @@ async function openIconPicker(menu: SessionMenuElement) {
}
describe("session menu", () => {
it("disables only denied mutation actions and ignores forced selection", async () => {
const onAction = vi.fn<(action: SessionMenuAction) => void>();
const menu = await mountMenu({
onAction,
actionDisabledReasons: {
delete: "This action requires operator.admin access.",
"toggle-pin": "This action requires operator.write access.",
},
});
const openChat = menuItem(menu, "Open chat");
const pin = menuItem(menu, "Pin thread");
const deleteItem = menuItem(menu, "Delete…");
expect(openChat.disabled).toBe(false);
expect(pin.disabled).toBe(true);
expect(pin.getAttribute("title")).toBe("This action requires operator.write access.");
expect(deleteItem.disabled).toBe(true);
deleteItem.dispatchEvent(
new CustomEvent("wa-select", {
bubbles: true,
composed: true,
detail: { item: { value: "delete" } },
}),
);
expect(onAction).not.toHaveBeenCalled();
});
it("shows when the session was last active", async () => {
const menu = await mountMenu({ lastActive: "57d" });
+49 -13
View File
@@ -53,6 +53,8 @@ export type SessionMenuAction =
| { kind: "stop-cloud-worker" }
| { kind: "delete" };
export type SessionMenuActionKind = SessionMenuAction["kind"];
const EMPTY_SESSION: SessionMenuData = {
label: "",
pinned: false,
@@ -72,6 +74,9 @@ class SessionMenu extends OpenClawLightDomElement {
@property({ attribute: false }) anchor: { x: number; y: number } = { x: 0, y: 0 };
@property({ attribute: false }) trigger: HTMLElement | null = null;
@property({ attribute: false }) disabled = false;
@property({ attribute: false }) actionDisabledReasons: Partial<
Record<SessionMenuActionKind, string>
> = {};
@property({ attribute: false }) forkDisabled = false;
// Guards both Archive and Delete: hosts pass canArchiveSessionRow() so agent
// main sessions and active runs stay protected from casual retirement.
@@ -100,10 +105,21 @@ class SessionMenu extends OpenClawLightDomElement {
}
private runAction(action: SessionMenuAction) {
if (this.actionDisabledReasons[action.kind]) {
return;
}
this.onClose();
this.onAction(action);
}
private actionDisabled(kind: SessionMenuActionKind, extra = false): boolean {
return this.disabled || extra || Boolean(this.actionDisabledReasons[kind]);
}
private actionTitle(kind: SessionMenuActionKind): string | typeof nothing {
return this.actionDisabledReasons[kind] ?? nothing;
}
private readonly handleSelect = (event: CustomEvent<{ item: { value?: string } }>) => {
event.preventDefault();
const value = event.detail.item.value;
@@ -217,6 +233,7 @@ class SessionMenu extends OpenClawLightDomElement {
const takeDigit = () => (nextDigit <= 9 ? String(nextDigit++) : null);
const entry = (label: string, checked: boolean, value: string, radio = true) => {
const digit = takeDigit();
const actionKind = value === "new-group" ? "new-group" : "move-to-group";
return html`
<wa-dropdown-item
slot="submenu"
@@ -227,7 +244,8 @@ class SessionMenu extends OpenClawLightDomElement {
${radio ? ref((element) => syncDropdownItemRadio(element, checked)) : nothing}
data-shortcut=${digit ?? nothing}
aria-keyshortcuts=${digit ?? nothing}
?disabled=${this.disabled}
?disabled=${this.actionDisabled(actionKind)}
title=${this.actionTitle(actionKind)}
>
<span class="session-menu__text">${label}</span>
${radio && checked
@@ -324,7 +342,7 @@ class SessionMenu extends OpenClawLightDomElement {
aria-label=${id}
aria-checked=${String(selected)}
title=${id}
?disabled=${this.disabled}
?disabled=${this.actionDisabled("set-icon")}
@click=${() => this.runAction({ kind: "set-icon", icon: value })}
>
${resolveSessionIcon(value)}
@@ -339,7 +357,7 @@ class SessionMenu extends OpenClawLightDomElement {
maxlength="16"
aria-label=${t("sessionsView.customEmoji")}
placeholder="🦞"
?disabled=${this.disabled}
?disabled=${this.actionDisabled("set-icon")}
@keydown=${(event: KeyboardEvent) => {
if (event.key !== "Enter") {
return;
@@ -361,7 +379,7 @@ class SessionMenu extends OpenClawLightDomElement {
<button
type="button"
class="session-menu__remove-icon"
?disabled=${this.disabled || !currentIcon}
?disabled=${this.actionDisabled("set-icon", !currentIcon)}
@click=${() => this.runAction({ kind: "set-icon", icon: null })}
>
${t("sessionsView.removeIcon")}
@@ -433,7 +451,8 @@ class SessionMenu extends OpenClawLightDomElement {
value="toggle-pin"
data-shortcut="p"
aria-keyshortcuts="P"
?disabled=${this.disabled || session.archived}
?disabled=${this.actionDisabled("toggle-pin", session.archived)}
title=${this.actionTitle("toggle-pin")}
>
<span slot="icon" class="session-menu__icon" aria-hidden="true"
>${session.pinned ? icons.pinOff : icons.pin}</span
@@ -448,7 +467,8 @@ class SessionMenu extends OpenClawLightDomElement {
<wa-dropdown-item
class="session-menu__item"
value="change-icon"
?disabled=${this.disabled}
?disabled=${this.actionDisabled("set-icon")}
title=${this.actionTitle("set-icon")}
>
<span slot="icon" class="session-menu__icon" aria-hidden="true"
>${icons.spark}</span
@@ -461,7 +481,8 @@ class SessionMenu extends OpenClawLightDomElement {
value="toggle-unread"
data-shortcut="u"
aria-keyshortcuts="U"
?disabled=${this.disabled}
?disabled=${this.actionDisabled("toggle-unread")}
title=${this.actionTitle("toggle-unread")}
>
<span slot="icon" class="session-menu__icon" aria-hidden="true"
>${session.unread ? icons.eye : icons.circle}</span
@@ -485,7 +506,8 @@ class SessionMenu extends OpenClawLightDomElement {
value="rename"
data-shortcut="r"
aria-keyshortcuts="R"
?disabled=${this.disabled}
?disabled=${this.actionDisabled("rename")}
title=${this.actionTitle("rename")}
>
<span slot="icon" class="session-menu__icon" aria-hidden="true"
>${icons.edit}</span
@@ -498,7 +520,8 @@ class SessionMenu extends OpenClawLightDomElement {
value="fork"
data-shortcut="f"
aria-keyshortcuts="F"
?disabled=${this.disabled || this.forkDisabled}
?disabled=${this.actionDisabled("fork", this.forkDisabled)}
title=${this.actionTitle("fork")}
>
<span slot="icon" class="session-menu__icon" aria-hidden="true"
>${icons.copy}</span
@@ -528,7 +551,11 @@ class SessionMenu extends OpenClawLightDomElement {
</wa-dropdown-item>
`
: nothing}
<wa-dropdown-item class="session-menu__item" ?disabled=${this.disabled}>
<wa-dropdown-item
class="session-menu__item"
?disabled=${this.actionDisabled("move-to-group")}
title=${this.actionTitle("move-to-group")}
>
<span slot="icon" class="session-menu__icon" aria-hidden="true"
>${icons.folder}</span
>
@@ -546,7 +573,8 @@ class SessionMenu extends OpenClawLightDomElement {
class="session-menu__item session-menu__item--destructive"
value="stop-cloud-worker"
variant="danger"
?disabled=${this.disabled}
?disabled=${this.actionDisabled("stop-cloud-worker")}
title=${this.actionTitle("stop-cloud-worker")}
>
<span slot="icon" class="session-menu__icon" aria-hidden="true"
>${icons.stop}</span
@@ -560,7 +588,11 @@ class SessionMenu extends OpenClawLightDomElement {
value="toggle-archived"
data-shortcut="a"
aria-keyshortcuts="A"
?disabled=${this.disabled || (!session.archived && !this.archiveAllowed)}
?disabled=${this.actionDisabled(
"toggle-archived",
!session.archived && !this.archiveAllowed,
)}
title=${this.actionTitle("toggle-archived")}
>
<span slot="icon" class="session-menu__icon" aria-hidden="true"
>${session.archived ? icons.archiveRestore : icons.archive}</span
@@ -580,7 +612,11 @@ class SessionMenu extends OpenClawLightDomElement {
variant="danger"
data-shortcut="d"
aria-keyshortcuts="D"
?disabled=${this.disabled || !(session.archived || this.archiveAllowed)}
?disabled=${this.actionDisabled(
"delete",
!(session.archived || this.archiveAllowed),
)}
title=${this.actionTitle("delete")}
>
<span slot="icon" class="session-menu__icon" aria-hidden="true"
>${icons.trash}</span
@@ -1,5 +1,6 @@
import type { ReactiveControllerHost } from "lit";
import { t } from "../i18n/index.ts";
import { readSessionMethodAccess } from "../lib/session-method-access.ts";
import {
moveSessionSection,
normalizeSessionSectionOrder,
@@ -47,6 +48,23 @@ export interface SessionOrganizerControllerHost extends ReactiveControllerHost {
sidebarSessionStatusFilter(): SidebarSessionStatusFilter;
}
function requireSessionMutationAccess(
host: SessionOrganizerControllerHost,
scope: SidebarSessionMutationScope,
request: {
method: string;
params?: unknown;
requiredScope?: "operator.write" | "operator.admin";
},
): boolean {
const access = readSessionMethodAccess(scope.gateway.snapshot, request);
if (access.allowed) {
return true;
}
host.sessionData.publishSessionMutationError(scope, access.reason);
return false;
}
export async function patchSession(
host: SessionOrganizerControllerHost,
session: SidebarRecentSession,
@@ -58,6 +76,16 @@ export async function patchSession(
return "stale";
}
const agentId = sessionRowAgentId(session, scope);
const requestParams = {
key: session.key,
...patch,
agentId,
};
if (
!requireSessionMutationAccess(host, scope, { method: "sessions.patch", params: requestParams })
) {
return "failed";
}
try {
const patched = await scope.sessions.patch(session.key, patch, {
agentId,
@@ -258,15 +286,19 @@ export async function deleteSessionsBatch(
if (!host.sessionData.isSessionMutationScopeCurrent(scope)) {
return;
}
const requests = rows.map((row) => ({
key: row.key,
agentId: parseAgentSessionKey(row.key)?.agentId ?? scope.selectedAgentId,
deleteTranscript: true,
...(row.archived === true ? { archivedOnly: true } : {}),
}));
for (const params of requests) {
if (!requireSessionMutationAccess(host, scope, { method: "sessions.delete", params })) {
return;
}
}
try {
const result = await scope.sessions.deleteMany(
rows.map((row) => ({
key: row.key,
agentId: parseAgentSessionKey(row.key)?.agentId ?? scope.selectedAgentId,
deleteTranscript: true,
...(row.archived === true ? { archivedOnly: true } : {}),
})),
);
const result = await scope.sessions.deleteMany(requests);
if (!host.sessionData.isSessionMutationScopeCurrent(scope)) {
return;
}
@@ -357,6 +389,14 @@ async function rememberSessionGroup(
if (!host.sessionData.isSessionMutationScopeCurrent(scope)) {
return "stale";
}
if (
!requireSessionMutationAccess(host, scope, {
method: "sessions.groups.put",
requiredScope: "operator.write",
})
) {
return "failed";
}
try {
await scope.sessions.groupsPut([...groups, name]);
return host.sessionData.isSessionMutationScopeCurrent(scope) ? "completed" : "stale";
@@ -404,6 +444,14 @@ export async function renameSessionGroup(
if (!host.sessionData.isSessionMutationScopeCurrent(scope)) {
return false;
}
if (
!requireSessionMutationAccess(host, scope, {
method: "sessions.groups.rename",
requiredScope: "operator.write",
})
) {
return false;
}
try {
const outcome = await scope.sessions.groupsRename(group, next);
return outcome === "completed" && host.sessionData.isSessionMutationScopeCurrent(scope);
@@ -421,6 +469,14 @@ export async function deleteSessionGroup(
if (!host.sessionData.isSessionMutationScopeCurrent(scope)) {
return false;
}
if (
!requireSessionMutationAccess(host, scope, {
method: "sessions.groups.delete",
requiredScope: "operator.write",
})
) {
return false;
}
try {
const outcome = await scope.sessions.groupsDelete(group);
return outcome === "completed" && host.sessionData.isSessionMutationScopeCurrent(scope);
@@ -440,6 +496,14 @@ export async function reorderSidebarSection(
if (!host.sessionData.isSessionMutationScopeCurrent(scope)) {
return;
}
if (
!requireSessionMutationAccess(host, scope, {
method: "sessions.groups.put",
requiredScope: "operator.write",
})
) {
return;
}
try {
// knownSessionGroups() is the full discovered set (gateway catalog plus
// row-discovered categories), so normalize only prunes deleted groups.
@@ -491,12 +555,18 @@ export async function forkSession(
return;
}
const agentId = parseAgentSessionKey(session.key)?.agentId ?? scope.selectedAgentId;
const createParams = {
parentSessionKey: session.key,
fork: true,
agentId,
};
if (
!requireSessionMutationAccess(host, scope, { method: "sessions.create", params: createParams })
) {
return;
}
try {
const key = await scope.sessions.create({
parentSessionKey: session.key,
fork: true,
agentId,
});
const key = await scope.sessions.create(createParams);
if (!host.sessionData.isSessionMutationScopeCurrent(scope)) {
return;
}
@@ -529,6 +599,14 @@ export async function stopCloudWorker(
return;
}
const agentId = parseAgentSessionKey(session.key)?.agentId ?? scope.selectedAgentId;
if (
!requireSessionMutationAccess(host, scope, {
method: "sessions.reclaim",
requiredScope: "operator.admin",
})
) {
return;
}
try {
await scope.client.request(
"sessions.reclaim",
@@ -556,12 +634,21 @@ export async function deleteSession(
return;
}
const agentId = parseAgentSessionKey(session.key)?.agentId ?? scope.selectedAgentId;
const deleteParams = {
agentId,
deleteTranscript: true,
...(session.archived === true ? { archivedOnly: true } : {}),
};
if (
!requireSessionMutationAccess(host, scope, {
method: "sessions.delete",
params: { key: session.key, ...deleteParams },
})
) {
return;
}
try {
const outcome = await scope.sessions.delete(session.key, {
agentId,
deleteTranscript: true,
...(session.archived === true ? { archivedOnly: true } : {}),
});
const outcome = await scope.sessions.delete(session.key, deleteParams);
if (!host.sessionData.isSessionMutationScopeCurrent(scope)) {
return;
}
+83
View File
@@ -1,11 +1,14 @@
import { html, nothing } from "lit";
import { keyed } from "lit/directives/keyed.js";
import { DEFAULT_SIDEBAR_ENTRIES, serializeSidebarEntry } from "../app-navigation.ts";
import type { RouteId } from "../app-route-paths.ts";
import type { ApplicationContext } from "../app/context.ts";
import { readPresenceEntries, resolveCurrentSelfUser } from "../app/user-profile.ts";
import { normalizeAgentLabel } from "../lib/agents/display.ts";
import { openEditor } from "../lib/editor-links.ts";
import { isGatewayMethodAdvertised } from "../lib/gateway-methods.ts";
import { openExternalUrlSafe } from "../lib/open-external-url.ts";
import { readSessionMethodAccess } from "../lib/session-method-access.ts";
import {
canArchiveSessionRow,
normalizeAgentId,
@@ -18,12 +21,82 @@ import {
renderSidebarSessionGroupMenu,
renderSidebarSessionSortMenu,
} from "./app-sidebar-session-menu-renderers.ts";
import type { SidebarRecentSession } from "./app-sidebar-session-types.ts";
import type { SessionMenuAction } from "./session-menu.ts";
import type {
SidebarMenusController,
SidebarMenusControllerHost,
} from "./sidebar-menus-controller.ts";
function sessionMenuActionDisabledReasons(
snapshot: ApplicationContext<RouteId>["gateway"]["snapshot"] | undefined,
session: SidebarRecentSession,
batchRows: readonly SidebarRecentSession[] | null,
): Partial<Record<SessionMenuAction["kind"], string>> {
const reason = (request: {
method: string;
params?: unknown;
requiredScope?: "operator.write" | "operator.admin";
}) => {
const access = readSessionMethodAccess(snapshot, request);
return access.allowed ? undefined : access.reason;
};
const patchReason = reason({
method: "sessions.patch",
params: { key: session.key, label: null },
});
const groupReason = reason({
method: "sessions.groups.put",
requiredScope: "operator.write",
});
const deleteRows = batchRows ?? [session];
const deleteReason = deleteRows
.map((row) =>
reason({
method: "sessions.delete",
params: { key: row.key, ...(row.archived ? { archivedOnly: true } : {}) },
}),
)
.find((value): value is string => Boolean(value));
return {
...(patchReason
? {
"toggle-pin": patchReason,
"set-icon": patchReason,
"toggle-unread": patchReason,
rename: patchReason,
"move-to-group": patchReason,
"toggle-archived": patchReason,
}
: {}),
...(groupReason || patchReason ? { "new-group": groupReason ?? patchReason } : {}),
...(deleteReason ? { delete: deleteReason } : {}),
...(batchRows
? {}
: {
...(reason({
method: "sessions.create",
params: { parentSessionKey: session.key, fork: true },
})
? {
fork: reason({
method: "sessions.create",
params: { parentSessionKey: session.key, fork: true },
}),
}
: {}),
...(reason({ method: "sessions.reclaim", requiredScope: "operator.admin" })
? {
"stop-cloud-worker": reason({
method: "sessions.reclaim",
requiredScope: "operator.admin",
}),
}
: {}),
}),
};
}
export function renderSidebarCustomizeMenuForController(controller: SidebarMenusController) {
const { host } = controller;
const position = controller.customizeMenuPosition;
@@ -172,6 +245,11 @@ export function renderSidebarSessionMenuForController(controller: SidebarMenusCo
.anchor=${menu}
.trigger=${controller.sessionMenuTrigger}
.disabled=${!host.connected}
.actionDisabledReasons=${sessionMenuActionDisabledReasons(
context?.gateway.snapshot,
session,
batchRows,
)}
.forkDisabled=${host.sessionData.sessionsLoading || session.modelSelectionLocked}
.archiveAllowed=${archiveAllowed}
.cloudWorkerStopAllowed=${Boolean(
@@ -253,10 +331,15 @@ export function renderSidebarSessionMenuForController(controller: SidebarMenusCo
export function renderSidebarSessionGroupMenuForController(controller: SidebarMenusController) {
const { host } = controller;
const menu = controller.sessionGroupMenu;
const groupAccess = readSessionMethodAccess(host.sessionDataContext?.gateway.snapshot, {
method: "sessions.groups.put",
requiredScope: "operator.write",
});
return renderSidebarSessionGroupMenu({
menu,
trigger: controller.sessionGroupMenuTrigger,
connected: host.connected,
disabledReason: groupAccess.allowed ? undefined : groupAccess.reason,
onAction: (action, group) => {
controller.closeSessionGroupMenu({ restoreFocus: true });
switch (action) {
@@ -0,0 +1,72 @@
import { expect, it } from "vitest";
import {
createSessionManagementE2eSuite,
installMockGateway,
sessionRow,
sessionsListResponse,
} from "./session-management.test-support.ts";
const suite = createSessionManagementE2eSuite();
const archived = sessionRow(
"agent:main:archived-write-scope",
"Archived write scope",
Date.parse("2026-08-01T12:00:00.000Z"),
{ archived: true },
);
async function openArchivedPage(operatorScopes: string[]) {
const context = await suite.browser.newContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
const gateway = await installMockGateway(page, {
featureMethods: ["chat.metadata", "chat.startup", "sessions.delete"],
operatorScopes,
sessionArchiveFiltering: true,
methodResponses: {
"sessions.delete": { deleted: true },
"sessions.list": sessionsListResponse([archived]),
},
});
await page.goto(`${suite.server.baseUrl}sessions?status=archived`);
const deleteAll = page.getByRole("button", { name: /Delete all archived/ });
await deleteAll.waitFor();
return { context, deleteAll, gateway, page };
}
suite.define(() => {
it("lets write-scoped operators delete archived sessions with archivedOnly", async () => {
const { context, deleteAll, gateway, page } = await openArchivedPage([
"operator.read",
"operator.write",
]);
try {
page.on("dialog", (dialog) => void dialog.accept());
await expect.poll(() => deleteAll.isEnabled()).toBe(true);
await deleteAll.click();
await expect(gateway.waitForRequest("sessions.delete")).resolves.toMatchObject({
params: {
archivedOnly: true,
deleteTranscript: true,
key: archived.key,
},
});
} finally {
await context.close();
}
});
it("keeps archived deletion disabled for read-scoped operators", async () => {
const { context, deleteAll, gateway } = await openArchivedPage(["operator.read"]);
try {
await expect.poll(() => deleteAll.isDisabled()).toBe(true);
await deleteAll.click({ force: true });
expect(await gateway.getRequests("sessions.delete")).toHaveLength(0);
} finally {
await context.close();
}
});
});
@@ -3,6 +3,7 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { GatewayBrowserClient } from "../../api/gateway.ts";
import type { SessionsListResult } from "../../api/types.ts";
import type { ApplicationGatewaySnapshot } from "../../app/context.ts";
import type { SessionCapability } from "../../lib/sessions/index.ts";
import {
createContext,
@@ -31,9 +32,15 @@ describe("sessions page archived deletion", () => {
preservedWorktrees: [],
})),
});
const { gateway } = createGateway({} as GatewayBrowserClient);
const mutableGateway = createGateway({} as GatewayBrowserClient);
mutableGateway.emit({
hello: {
auth: { role: "operator", scopes: ["operator.read", "operator.write"] },
features: { methods: ["sessions.delete"] },
} as ApplicationGatewaySnapshot["hello"],
});
const page = await createRenderedPage(
createContext(gateway, sessions),
createContext(mutableGateway.gateway, sessions),
{
count: 2,
sessions: [
@@ -68,6 +75,28 @@ describe("sessions page archived deletion", () => {
]);
});
it("does not let write-scoped operators delete an active session", async () => {
const key = "agent:main:active";
const sessions = createSessions();
const mutableGateway = createGateway({} as GatewayBrowserClient);
mutableGateway.emit({
hello: {
auth: { role: "operator", scopes: ["operator.read", "operator.write"] },
features: { methods: ["sessions.delete"] },
} as ApplicationGatewaySnapshot["hello"],
});
const page = await createRenderedPage(createContext(mutableGateway.gateway, sessions), {
count: 1,
sessions: [{ key, archived: false }],
} as SessionsListResult);
vi.spyOn(window, "confirm").mockReturnValue(true);
await page.deleteSessionFromMenu({ key, archived: false } as SessionsListResult["sessions"][0]);
expect(sessions.deleteMany).not.toHaveBeenCalled();
expect(page.error).toBe("This action requires operator.admin access.");
});
it("aborts delete-all when an enumeration page fails", async () => {
const sessions = createSessions({
list: vi.fn(async () => null) as unknown as SessionCapability["list"],
+177 -18
View File
@@ -14,7 +14,11 @@ import { applicationContext, type ApplicationContext } from "../../app/context.t
import { hasOperatorWriteAccess } from "../../app/operator-access.ts";
import { renderAgentScopeControl } from "../../components/agent-scope-control.ts";
import { fetchSessionMenuWork } from "../../components/session-menu-work.ts";
import type { SessionMenuAction, SessionMenuWork } from "../../components/session-menu.ts";
import type {
SessionMenuAction,
SessionMenuActionKind,
SessionMenuWork,
} from "../../components/session-menu.ts";
import "../../components/session-menu.ts";
import { isStoppableCloudWorkerPlacement } from "../../components/session-row-badges.ts";
import { renderSessionsHubHeader } from "../../components/sessions-hub-header.ts";
@@ -25,6 +29,7 @@ import { openEditor } from "../../lib/editor-links.ts";
import { isGatewayMethodAdvertised } from "../../lib/gateway-methods.ts";
import { openExternalUrlSafe } from "../../lib/open-external-url.ts";
import { isWorkboardEnabledInConfigSnapshot } from "../../lib/plugin-activation.ts";
import { readSessionMethodAccess } from "../../lib/session-method-access.ts";
import {
scopedSessionPullRequestKey,
SESSION_PULL_REQUESTS_SUBSCRIBE_METHOD,
@@ -405,6 +410,90 @@ class SessionsPage extends OpenClawLightDomElement {
);
}
private mutationDisabledReason(request: {
method: string;
params?: unknown;
requiredScope?: "operator.write" | "operator.admin";
}): string | undefined {
const access = readSessionMethodAccess(this.context?.gateway.snapshot, request);
return access.allowed ? undefined : access.reason;
}
private requireMutationAccess(
scope: SessionsPageRequestScope,
request: {
method: string;
params?: unknown;
requiredScope?: "operator.write" | "operator.admin";
},
): boolean {
const access = readSessionMethodAccess(scope.gateway.snapshot, request);
if (access.allowed) {
return true;
}
this.error = access.reason;
return false;
}
private selectedDeleteDisabledReason(): string | undefined {
const rowsByKey = new Map(this.result?.sessions.map((row) => [row.key, row]) ?? []);
for (const key of this.selectedKeys) {
const row = rowsByKey.get(key);
const reason = this.mutationDisabledReason({
method: "sessions.delete",
params: {
key,
...(row?.archived === true ? { archivedOnly: true } : {}),
},
});
if (reason) {
return reason;
}
}
return undefined;
}
private sessionMenuActionDisabledReasons(
row: GatewaySessionRow,
): Partial<Record<SessionMenuActionKind, string>> {
const patchReason = this.mutationDisabledReason({
method: "sessions.patch",
params: { key: row.key, label: null },
});
const groupReason = this.mutationDisabledReason({
method: "sessions.groups.put",
requiredScope: "operator.write",
});
const forkReason = this.mutationDisabledReason({
method: "sessions.create",
params: { parentSessionKey: row.key, fork: true },
});
const reclaimReason = this.mutationDisabledReason({
method: "sessions.reclaim",
requiredScope: "operator.admin",
});
const deleteReason = this.mutationDisabledReason({
method: "sessions.delete",
params: { key: row.key, ...(row.archived === true ? { archivedOnly: true } : {}) },
});
return {
...(patchReason
? {
"toggle-pin": patchReason,
"set-icon": patchReason,
"toggle-unread": patchReason,
rename: patchReason,
"move-to-group": patchReason,
"toggle-archived": patchReason,
}
: {}),
...(groupReason || patchReason ? { "new-group": groupReason ?? patchReason } : {}),
...(forkReason ? { fork: forkReason } : {}),
...(reclaimReason ? { "stop-cloud-worker": reclaimReason } : {}),
...(deleteReason ? { delete: deleteReason } : {}),
};
}
private applyRouteData() {
const data = this.routeData;
const context = this.context;
@@ -712,16 +801,20 @@ class SessionsPage extends OpenClawLightDomElement {
if (!scope) {
return;
}
const requests = rows.map((row) => ({
key: row.key,
agentId: this.sessionAgentId(row.key, scope.context),
...options,
...(row.archived === true ? { archivedOnly: true } : {}),
}));
for (const params of requests) {
if (!this.requireMutationAccess(scope, { method: "sessions.delete", params })) {
return;
}
}
this.sessionMutationPending = true;
try {
const result = await scope.sessions.deleteMany(
rows.map((row) => ({
key: row.key,
agentId: this.sessionAgentId(row.key, scope.context),
...options,
...(row.archived === true ? { archivedOnly: true } : {}),
})),
);
const result = await scope.sessions.deleteMany(requests);
if (!this.isRequestScopeCurrent(scope)) {
return;
}
@@ -860,7 +953,13 @@ class SessionsPage extends OpenClawLightDomElement {
return;
}
const scope = this.captureRequestScope();
if (!scope) {
if (
!scope ||
!this.requireMutationAccess(scope, {
method: "sessions.reclaim",
requiredScope: "operator.admin",
})
) {
return;
}
const agentId = parseAgentSessionKey(row.key)?.agentId;
@@ -899,6 +998,15 @@ class SessionsPage extends OpenClawLightDomElement {
private async rememberCustomGroup(name: string) {
const scope = this.captureRequestScope();
if (
scope &&
!this.requireMutationAccess(scope, {
method: "sessions.groups.put",
requiredScope: "operator.write",
})
) {
return;
}
await rememberSessionCustomGroup({
name,
knownCategories: this.knownCategories(),
@@ -959,9 +1067,18 @@ class SessionsPage extends OpenClawLightDomElement {
if (!scope) {
return "stale";
}
const agentId = this.sessionAgentId(key, scope.context);
if (
!this.requireMutationAccess(scope, {
method: "sessions.patch",
params: { key, ...patch, ...(agentId ? { agentId } : {}) },
})
) {
return "failed";
}
try {
const patched = await scope.sessions.patch(key, patch, {
agentId: this.sessionAgentId(key, scope.context),
agentId,
});
if (!this.isRequestScopeCurrent(scope)) {
return "stale";
@@ -1016,12 +1133,16 @@ class SessionsPage extends OpenClawLightDomElement {
return;
}
const agentId = this.sessionAgentId(key, scope.context);
const createParams = {
parentSessionKey: key,
fork: true,
...(agentId ? { agentId } : {}),
};
if (!this.requireMutationAccess(scope, { method: "sessions.create", params: createParams })) {
return;
}
try {
const forkedKey = await scope.sessions.create({
parentSessionKey: key,
fork: true,
...(agentId ? { agentId } : {}),
});
const forkedKey = await scope.sessions.create(createParams);
if (!this.isRequestScopeCurrent(scope)) {
return;
}
@@ -1096,7 +1217,13 @@ class SessionsPage extends OpenClawLightDomElement {
return;
}
const scope = this.captureRequestScope();
if (!scope) {
if (
!scope ||
!this.requireMutationAccess(scope, {
method: "sessions.compaction.branch",
requiredScope: "operator.write",
})
) {
return;
}
this.checkpointBusyKey = checkpointId;
@@ -1135,7 +1262,13 @@ class SessionsPage extends OpenClawLightDomElement {
return;
}
const scope = this.captureRequestScope();
if (!scope) {
if (
!scope ||
!this.requireMutationAccess(scope, {
method: "sessions.compaction.restore",
requiredScope: "operator.admin",
})
) {
return;
}
this.checkpointBusyKey = checkpointId;
@@ -1251,6 +1384,7 @@ class SessionsPage extends OpenClawLightDomElement {
.anchor=${menu}
.trigger=${this.sessionMenuTrigger}
.disabled=${this.loading}
.actionDisabledReasons=${this.sessionMenuActionDisabledReasons(row)}
.forkDisabled=${row.modelSelectionLocked === true}
.archiveAllowed=${archiveAllowed}
.cloudWorkerStopAllowed=${isStoppableCloudWorkerPlacement(row.placement) &&
@@ -1390,6 +1524,31 @@ class SessionsPage extends OpenClawLightDomElement {
checkpointLoadingKey: this.checkpointLoadingKey,
checkpointBusyKey: this.checkpointBusyKey,
checkpointErrorByKey: this.checkpointErrorByKey,
patchWriteDisabledReason: this.mutationDisabledReason({
method: "sessions.patch",
params: { key: "", label: null },
}),
patchAdminDisabledReason: this.mutationDisabledReason({
method: "sessions.patch",
params: { key: "", thinkingLevel: null },
}),
groupWriteDisabledReason: this.mutationDisabledReason({
method: "sessions.groups.put",
requiredScope: "operator.write",
}),
deleteArchivedDisabledReason: this.mutationDisabledReason({
method: "sessions.delete",
params: { key: "", archivedOnly: true, deleteTranscript: true },
}),
checkpointBranchDisabledReason: this.mutationDisabledReason({
method: "sessions.compaction.branch",
requiredScope: "operator.write",
}),
checkpointRestoreDisabledReason: this.mutationDisabledReason({
method: "sessions.compaction.restore",
requiredScope: "operator.admin",
}),
deleteSelectedDisabledReason: this.selectedDeleteDisabledReason(),
onFiltersChange: (next) => this.updateFilters(next),
onClearFilters: () => {
this.activeMinutes = "";
+31 -10
View File
@@ -98,6 +98,13 @@ export type SessionsProps = {
checkpointLoadingKey: string | null;
checkpointBusyKey: string | null;
checkpointErrorByKey: Record<string, string>;
patchWriteDisabledReason?: string;
patchAdminDisabledReason?: string;
groupWriteDisabledReason?: string;
deleteArchivedDisabledReason?: string;
checkpointBranchDisabledReason?: string;
checkpointRestoreDisabledReason?: string;
deleteSelectedDisabledReason?: string;
onFiltersChange: (next: {
activeMinutes: string;
limit: string;
@@ -1027,7 +1034,10 @@ export function renderSessions(props: SessionsProps) {
? html`
<button
class="btn danger"
?disabled=${props.loading || archivedCount === 0}
?disabled=${props.loading ||
archivedCount === 0 ||
Boolean(props.deleteArchivedDisabledReason)}
title=${props.deleteArchivedDisabledReason ?? nothing}
@click=${props.onDeleteAllArchived}
>
${icons.trash} ${t("sessionsView.deleteAllArchived")}
@@ -1207,7 +1217,12 @@ function renderSessionsTable(props: SessionsProps, ctx: SessionsTableContext) {
</label>
${props.groupBy === "category"
? html`
<button class="btn btn--sm" @click=${() => props.onRequestNewCategory()}>
<button
class="btn btn--sm"
?disabled=${Boolean(props.groupWriteDisabledReason)}
title=${props.groupWriteDisabledReason ?? nothing}
@click=${() => props.onRequestNewCategory()}
>
${icons.plus} ${t("sessionsView.newGroup")}
</button>
`
@@ -1223,7 +1238,8 @@ function renderSessionsTable(props: SessionsProps, ctx: SessionsTableContext) {
</button>
<button
class="btn btn--sm danger"
?disabled=${props.loading}
?disabled=${props.loading || Boolean(props.deleteSelectedDisabledReason)}
title=${props.deleteSelectedDisabledReason ?? nothing}
@click=${props.onDeleteSelected}
>
${icons.trash} ${t("sessionsView.deleteSelected")}
@@ -1650,7 +1666,8 @@ function renderSessionDetailsRow(params: {
<input
class="settings-input"
.value=${row.label ?? ""}
?disabled=${props.loading}
?disabled=${props.loading || Boolean(props.patchWriteDisabledReason)}
title=${props.patchWriteDisabledReason ?? nothing}
placeholder=${t("sessionsView.optionalPlaceholder")}
@change=${(e: Event) => {
const value =
@@ -1661,14 +1678,14 @@ function renderSessionDetailsRow(params: {
</label>
${renderOverrideSelect({
label: t("sessionsView.thinking"),
disabled: props.loading,
disabled: props.loading || Boolean(props.patchAdminDisabledReason),
options: thinkLevels,
current: thinking,
onChange: (value) => props.onPatch(row.key, { thinkingLevel: value || null }),
})}
${renderOverrideSelect({
label: t("sessionsView.fast"),
disabled: props.loading,
disabled: props.loading || Boolean(props.patchAdminDisabledReason),
options: fastLevels,
current: fastMode,
onChange: (value) =>
@@ -1678,14 +1695,14 @@ function renderSessionDetailsRow(params: {
})}
${renderOverrideSelect({
label: t("sessionsView.verbose"),
disabled: props.loading,
disabled: props.loading || Boolean(props.patchAdminDisabledReason),
options: verboseLevels,
current: verbose,
onChange: (value) => props.onPatch(row.key, { verboseLevel: value || null }),
})}
${renderOverrideSelect({
label: t("sessionsView.reasoning"),
disabled: props.loading,
disabled: props.loading || Boolean(props.patchAdminDisabledReason),
options: reasoningLevels.map((level) => ({
value: level,
label: level || t("sessionsView.inherit"),
@@ -1750,7 +1767,9 @@ function renderSessionDetailsRow(params: {
<div class="session-checkpoint-card__actions">
<button
class="btn btn--sm"
?disabled=${props.checkpointBusyKey === checkpoint.checkpointId}
?disabled=${props.checkpointBusyKey === checkpoint.checkpointId ||
Boolean(props.checkpointBranchDisabledReason)}
title=${props.checkpointBranchDisabledReason ?? nothing}
@click=${() =>
props.onBranchFromCheckpoint(row.key, checkpoint.checkpointId)}
>
@@ -1758,7 +1777,9 @@ function renderSessionDetailsRow(params: {
</button>
<button
class="btn btn--sm"
?disabled=${props.checkpointBusyKey === checkpoint.checkpointId}
?disabled=${props.checkpointBusyKey === checkpoint.checkpointId ||
Boolean(props.checkpointRestoreDisabledReason)}
title=${props.checkpointRestoreDisabledReason ?? nothing}
@click=${() =>
props.onRestoreCheckpoint(row.key, checkpoint.checkpointId)}
>
@@ -47,7 +47,7 @@ describe("AppSidebar section reordering", () => {
if (options.withCatalog) {
gateway.publish({
hello: {
features: { methods: ["sessions.catalog.list"] },
features: { methods: ["sessions.catalog.list", "sessions.groups.put"] },
} as ApplicationGatewaySnapshot["hello"],
});
}