diff --git a/ui/src/lib/sessions/index.test.ts b/ui/src/lib/sessions/index.test.ts index 92e0d7644cb3..2dc4347e8e49 100644 --- a/ui/src/lib/sessions/index.test.ts +++ b/ui/src/lib/sessions/index.test.ts @@ -1,5 +1,5 @@ // @vitest-environment node -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { GatewayRequestError, type GatewayBrowserClient, @@ -7,6 +7,7 @@ import { type GatewayHelloOk, } from "../../api/gateway.ts"; import type { SessionsListResult } from "../../api/types.ts"; +import { createStorageMock } from "../../test-helpers/storage.ts"; import { waitForFast } from "../../test-helpers/wait-for.ts"; import { createSessionCapability, reconcileSessionRunTerminal } from "./index.ts"; @@ -30,7 +31,11 @@ function deferred() { return { promise, reject, resolve }; } -function createGatewayHarness(client: GatewayBrowserClient, featureMethods?: string[]) { +function createGatewayHarness( + client: GatewayBrowserClient, + featureMethods?: string[], + scopes?: string[], +) { let snapshot: { client: GatewayBrowserClient | null; phase: "connected" | "reconnecting"; @@ -45,7 +50,10 @@ function createGatewayHarness(client: GatewayBrowserClient, featureMethods?: str hello: featureMethods === undefined ? null - : ({ features: { methods: featureMethods } } as GatewayHelloOk), + : ({ + ...(scopes ? { auth: { role: "operator", scopes } } : {}), + features: { methods: featureMethods }, + } as GatewayHelloOk), }; const listeners = new Set<(next: typeof snapshot) => void>(); const eventListeners = new Set<(event: GatewayEventFrame) => void>(); @@ -81,6 +89,10 @@ function createGatewayHarness(client: GatewayBrowserClient, featureMethods?: str }; } +afterEach(() => { + vi.unstubAllGlobals(); +}); + function sessionChangedEvent(key: string): GatewayEventFrame { return { type: "event", @@ -170,6 +182,61 @@ describe("createSessionCapability", () => { sessions.dispose(); }); + it("does not migrate legacy browser groups without operator.write", async () => { + vi.stubGlobal("localStorage", createStorageMock()); + localStorage.setItem("openclaw:sessions:custom-groups", JSON.stringify(["Research"])); + const request = vi.fn(async (method: string) => { + if (method === "sessions.groups.list") { + return { groups: [] }; + } + throw new Error(`Unexpected request: ${method}`); + }); + const client = { request } as unknown as GatewayBrowserClient; + const { gateway } = createGatewayHarness( + client, + ["sessions.groups.list", "sessions.groups.put"], + ["operator.read"], + ); + const sessions = createSessionCapability(gateway); + + await sessions.groupsLoad(); + + expect(request).toHaveBeenCalledOnce(); + expect(request).toHaveBeenCalledWith("sessions.groups.list", {}); + expect(localStorage.getItem("openclaw:sessions:custom-groups")).toBe( + JSON.stringify(["Research"]), + ); + sessions.dispose(); + }); + + it("migrates legacy browser groups with operator.write", async () => { + vi.stubGlobal("localStorage", createStorageMock()); + localStorage.setItem("openclaw:sessions:custom-groups", JSON.stringify(["Research"])); + const request = vi.fn(async (method: string) => { + if (method === "sessions.groups.list") { + return { groups: [] }; + } + if (method === "sessions.groups.put") { + return { groups: [{ name: "Research" }] }; + } + throw new Error(`Unexpected request: ${method}`); + }); + const client = { request } as unknown as GatewayBrowserClient; + const { gateway } = createGatewayHarness( + client, + ["sessions.groups.list", "sessions.groups.put"], + ["operator.write"], + ); + const sessions = createSessionCapability(gateway); + + await sessions.groupsLoad(); + + expect(request).toHaveBeenCalledWith("sessions.groups.put", { names: ["Research"] }); + expect(sessions.state.groups).toEqual(["Research"]); + expect(localStorage.getItem("openclaw:sessions:custom-groups")).toBeNull(); + sessions.dispose(); + }); + it("publishes state.error when group rename is rejected", async () => { const request = vi.fn(async (method: string) => { if (method === "sessions.groups.rename") { diff --git a/ui/src/lib/sessions/session-group-catalog.ts b/ui/src/lib/sessions/session-group-catalog.ts index 53ae2bcece93..adde83a0c7a6 100644 --- a/ui/src/lib/sessions/session-group-catalog.ts +++ b/ui/src/lib/sessions/session-group-catalog.ts @@ -1,5 +1,6 @@ import { getSafeLocalStorage } from "../../local-storage.ts"; import { isGatewayMethodAdvertised } from "../gateway-methods.ts"; +import { readSessionMethodAccess } from "../session-method-access.ts"; import { readSessionCustomGroupNames, readSidebarSectionOrder } from "./custom-groups.ts"; import type { SessionConnectionOwner, @@ -93,7 +94,11 @@ export function createSessionGroupCatalog(host: SessionGroupCatalogHost) { let sectionOrder = readSidebarSectionOrder(listed); // Browser-local catalogs predate the gateway store and migrate exactly once. const legacy = readLegacyStoredGroups(); - if (names.length === 0 && legacy.length > 0) { + const legacyMigrationAccess = readSessionMethodAccess(host.snapshot(), { + method: "sessions.groups.put", + requiredScope: "operator.write", + }); + if (names.length === 0 && legacy.length > 0 && legacyMigrationAccess.allowed) { const put = await scope.client.request("sessions.groups.put", { names: legacy }); if (!host.connection.isCurrent(scope) || generation !== loadGeneration) { return; @@ -101,7 +106,7 @@ export function createSessionGroupCatalog(host: SessionGroupCatalogHost) { names = readSessionCustomGroupNames(put); sectionOrder = readSidebarSectionOrder(put); } - if (legacy.length > 0) { + if (legacy.length > 0 && legacyMigrationAccess.allowed) { try { getSafeLocalStorage()?.removeItem(LEGACY_GROUPS_STORAGE_KEY); } catch {