diff --git a/ui/src/components/app-sidebar-session-catalog-live.ts b/ui/src/components/app-sidebar-session-catalog-live.ts index 83b53720a6ff..dacd9cebe59e 100644 --- a/ui/src/components/app-sidebar-session-catalog-live.ts +++ b/ui/src/components/app-sidebar-session-catalog-live.ts @@ -8,7 +8,7 @@ import { GatewayRequestError, type GatewayBrowserClient } from "../api/gateway.t import type { ApplicationGatewaySnapshot } from "../app/gateway.ts"; import { formatUiError } from "../lib/format-error.ts"; import { isAwaitingGatewayFailure } from "../lib/gateway-availability.ts"; -import { isGatewayMethodAdvertised } from "../lib/gateway-methods.ts"; +import { canCallGatewayMethod } from "../lib/gateway-methods.ts"; import { createSessionEventRefreshCoordinator } from "../lib/sessions/event-refresh-coordinator.ts"; import { normalizeAgentId } from "../lib/sessions/session-key.ts"; import { generateUUID } from "../lib/uuid.ts"; @@ -28,9 +28,8 @@ export function sessionCatalogListClient( ): GatewayBrowserClient | null { if ( !connected || - snapshot?.phase !== "connected" || - !snapshot.client || - isGatewayMethodAdvertised(snapshot, "sessions.catalog.list") !== true + !snapshot?.client || + !canCallGatewayMethod(snapshot, "sessions.catalog.list", "operator.read") ) { return null; } diff --git a/ui/src/components/app-sidebar.catalog-discovery.test.ts b/ui/src/components/app-sidebar.catalog-discovery.test.ts index 7681b90b3a81..7651672abac5 100644 --- a/ui/src/components/app-sidebar.catalog-discovery.test.ts +++ b/ui/src/components/app-sidebar.catalog-discovery.test.ts @@ -41,6 +41,7 @@ async function mountDiscovery( const gateway = createGatewayHarness(createTestGatewayClient(request)); gateway.publish({ hello: { + auth: { role: "operator", scopes: ["operator.read"] }, features: { methods: ["sessions.catalog.list"], events: ["sessions.catalog.changed"] }, } as ApplicationGatewaySnapshot["hello"], }); @@ -84,6 +85,7 @@ describe("AppSidebar hidden catalog discovery", () => { const gateway = createGatewayHarness(createTestGatewayClient(currentRequest)); gateway.publish({ hello: { + auth: { role: "operator", scopes: ["operator.read"] }, features: { methods: ["sessions.catalog.list"], events: ["sessions.catalog.changed"], diff --git a/ui/src/components/app-sidebar.catalog-events.test.ts b/ui/src/components/app-sidebar.catalog-events.test.ts index 78caa88f90e0..000d73a6200d 100644 --- a/ui/src/components/app-sidebar.catalog-events.test.ts +++ b/ui/src/components/app-sidebar.catalog-events.test.ts @@ -25,6 +25,7 @@ async function mountTab( const gateway = createGatewayHarness(createTestGatewayClient(request)); gateway.publish({ hello: { + auth: { role: "operator", scopes: ["operator.read"] }, features: { methods: ["sessions.catalog.list"], events }, } as ApplicationGatewaySnapshot["hello"], }); diff --git a/ui/src/components/app-sidebar.catalog-hidden-pages.test.ts b/ui/src/components/app-sidebar.catalog-hidden-pages.test.ts index 7dddb35cfc26..7d863e27fe3a 100644 --- a/ui/src/components/app-sidebar.catalog-hidden-pages.test.ts +++ b/ui/src/components/app-sidebar.catalog-hidden-pages.test.ts @@ -46,6 +46,7 @@ async function mountExpanded(request: ReturnType) { const gateway = createGatewayHarness({ request } as unknown as GatewayBrowserClient); gateway.publish({ hello: { + auth: { role: "operator", scopes: ["operator.read"] }, features: { methods: ["sessions.catalog.list"], events: ["sessions.catalog.changed"] }, } as ApplicationGatewaySnapshot["hello"], }); diff --git a/ui/src/components/app-sidebar.catalog-scope.test.ts b/ui/src/components/app-sidebar.catalog-scope.test.ts index ea57f8b17744..13df13f81365 100644 --- a/ui/src/components/app-sidebar.catalog-scope.test.ts +++ b/ui/src/components/app-sidebar.catalog-scope.test.ts @@ -13,6 +13,7 @@ import { TWO_AGENTS, } from "../test-helpers/app-sidebar.ts"; import { createTestGatewayClient } from "../test-helpers/gateway-client.ts"; +import { gatewayHelloForMethods } from "../test-helpers/gateway-methods.ts"; import "./app-sidebar.ts"; const requireRecord = createRequireRecord("object", "expected-label"); @@ -36,6 +37,7 @@ describe("AppSidebar catalog scope replacement", () => { const gateway = createGatewayHarness(client); gateway.publish({ hello: { + auth: { role: "operator", scopes: ["operator.read"] }, features: { methods: ["sessions.catalog.list"] }, } as ApplicationGatewaySnapshot["hello"], }); @@ -72,3 +74,86 @@ describe("AppSidebar catalog scope replacement", () => { } }); }); + +describe("AppSidebar catalog authority", () => { + it.each(["reply", "error", "page"] as const)( + "retires cached and in-flight %s data on same-client scope loss", + async (pendingKind) => { + vi.useFakeTimers(); + const pending = deferred>(); + const request = vi + .fn() + .mockResolvedValue( + catalogPage([{ threadId: "retained", name: "Retained catalog session" }], "page-2"), + ); + const gateway = createGatewayHarness(createTestGatewayClient(request)); + const hello = gatewayHelloForMethods(["sessions.catalog.list"]); + hello.features!.events = ["sessions.catalog.changed"]; + gateway.publish({ hello }); + const { sidebar, provider } = await mountSidebar( + gateway.gateway, + createSessions("main", ["agent:main:visitor-notes"]), + ); + try { + sidebar.connected = true; + await sidebar.updateComplete; + await vi.advanceTimersByTimeAsync(0); + await sidebar.updateComplete; + expect(sidebar.textContent).toContain("Retained catalog session"); + expect( + sidebar.querySelector('[data-session-key="agent:main:visitor-notes"]'), + ).not.toBeNull(); + request.mockReturnValueOnce(pending.promise); + const loading = + pendingKind === "page" + ? sidebar.sessionData.loadMoreSessionCatalog("codex") + : sidebar.sessionData.refreshSessionCatalogs(); + const progressId = request.mock.calls.at(-1)?.[1]?.progressId; + expect(request).toHaveBeenCalledTimes(2); + + gateway.publish({ + hello: { ...hello, auth: { role: "operator", scopes: ["operator.sessions.write"] } }, + }); + // No Lit render or promise turn separates authority loss from these events. + gateway.publishEvent("sessions.catalog.host", { + progressId, + agentId: "main", + catalog: catalogPage([{ threadId: "stale", name: "Late catalog session" }]).catalogs[0], + }); + gateway.publishEvent("sessions.catalog.changed", { agentId: "main" }); + if (pendingKind === "error") { + pending.reject(new Error("Stale catalog failure")); + } else { + pending.resolve(catalogPage([{ threadId: "stale", name: "Late catalog session" }])); + } + await loading; + await sidebar.updateComplete; + await vi.advanceTimersByTimeAsync(600_000); + await sidebar.updateComplete; + expect(sidebar.textContent).not.toContain("Retained catalog session"); + expect(sidebar.textContent).not.toContain("Late catalog session"); + expect(sidebar.querySelector(".sidebar-session-catalog-error")).toBeNull(); + expect(sidebar.sessionData.sessionCatalogs).toEqual([]); + expect(sidebar.sessionData.sessionCatalogPageDepths.size).toBe(0); + expect(sidebar.sessionData.sessionCatalogRevisions.size).toBe(0); + expect(sidebar.sessionData.loadingMoreSessionCatalogIds.size).toBe(0); + expect(sidebar.sessionData.sessionCatalogLive.timer).toBeNull(); + expect(request).toHaveBeenCalledTimes(2); + expect( + sidebar.querySelector('[data-session-key="agent:main:visitor-notes"]'), + ).not.toBeNull(); + + gateway.publish({ hello }); + await sidebar.updateComplete; + await vi.advanceTimersByTimeAsync(0); + await sidebar.updateComplete; + expect(request).toHaveBeenCalledTimes(3); + expect(sidebar.textContent).toContain("Retained catalog session"); + } finally { + pending.resolve(catalogPage([])); + provider.remove(); + vi.useRealTimers(); + } + }, + ); +}); diff --git a/ui/src/components/session-data-controller-catalog.ts b/ui/src/components/session-data-controller-catalog.ts index 934670b1e8b7..6451220b69d6 100644 --- a/ui/src/components/session-data-controller-catalog.ts +++ b/ui/src/components/session-data-controller-catalog.ts @@ -9,7 +9,7 @@ import type { } from "../../../packages/gateway-protocol/src/index.ts"; import type { GatewayBrowserClient } from "../api/gateway.ts"; import type { ApplicationContext } from "../app/context.ts"; -import { isGatewayMethodAdvertised } from "../lib/gateway-methods.ts"; +import { canCallGatewayMethod } from "../lib/gateway-methods.ts"; import { readSessionMethodScopeAccess } from "../lib/session-method-access.ts"; import { buildCatalogSessionKey, @@ -91,8 +91,11 @@ export function areSessionCatalogsSettled( const status = owner.sessionCatalogRefreshStatus; return ( !status.error && - (isGatewayMethodAdvertised(owner.context?.gateway.snapshot ?? {}, "sessions.catalog.list") !== - true || + (!canCallGatewayMethod( + owner.context?.gateway.snapshot, + "sessions.catalog.list", + "operator.read", + ) || (status.hasLoaded && !status.awaitingGateway && owner.sessionCatalogLive.requestGeneration === null && @@ -143,7 +146,7 @@ export function resolveSessionCatalogAgentId( // transient reconnect still preserves its rows until the replacement lands. if ( gateway?.phase === "connected" && - isGatewayMethodAdvertised(gateway, "sessions.catalog.list") === false + !canCallGatewayMethod(gateway, "sessions.catalog.list", "operator.read") ) { return null; } @@ -181,7 +184,7 @@ export function resolveSessionCatalogAgentId( } export function scheduleSessionCatalogRefresh(owner: SessionCatalogDataOwner): void { - if (document.visibilityState === "hidden") { + if (!visibleSessionCatalogClient(owner)) { owner.sessionCatalogLive.cancelScheduledRefreshes(); return; } @@ -385,6 +388,7 @@ export async function loadMoreSessionCatalog( hostIds?: readonly string[], discovering = false, ): Promise { + owner.synchronizeSessionScope(); if (owner.loadingMoreSessionCatalogIds.has(catalogId)) { return; } @@ -399,7 +403,7 @@ export async function loadMoreSessionCatalog( if (!catalog || Object.keys(cursors).length === 0) { return; } - const client = owner.context?.gateway.snapshot.client; + const client = owner.sessionCatalogGatewayClient(); const agentId = resolveSessionCatalogAgentId(owner); if ( !client || diff --git a/ui/src/components/session-data-controller-events.ts b/ui/src/components/session-data-controller-events.ts index 72b87c135e2e..2e56dca22857 100644 --- a/ui/src/components/session-data-controller-events.ts +++ b/ui/src/components/session-data-controller-events.ts @@ -2,6 +2,7 @@ import { SIDEBAR_SESSION_ROSTER_LIMIT } from "../../../src/shared/session-list-l import type { ApplicationContext } from "../app/context.ts"; import { readPresenceEntries, type PresencePayload } from "../app/user-profile.ts"; import type { AgentCapability } from "../lib/agents/index.ts"; +import { canCallGatewayMethod } from "../lib/gateway-methods.ts"; import { CATALOG_SESSION_CONTINUED_EVENT } from "../lib/sessions/catalog-key.ts"; import type { SessionCapability, @@ -227,11 +228,15 @@ export function subscribeSessionDataGatewayEvents( ): () => void { return gateway.subscribeEvents((event) => { if (event.event === "sessions.catalog.host") { - owner.handleSessionCatalogHostEvent(event.payload); + if (canCallGatewayMethod(gateway.snapshot, "sessions.catalog.list", "operator.read")) { + owner.handleSessionCatalogHostEvent(event.payload); + } return; } if (event.event === "sessions.catalog.changed") { - owner.handleSessionCatalogChanged(event.payload); + if (canCallGatewayMethod(gateway.snapshot, "sessions.catalog.list", "operator.read")) { + owner.handleSessionCatalogChanged(event.payload); + } return; } if (event.event === "presence") { diff --git a/ui/src/components/session-data-controller.ts b/ui/src/components/session-data-controller.ts index e47fa09560cd..7143f08898ad 100644 --- a/ui/src/components/session-data-controller.ts +++ b/ui/src/components/session-data-controller.ts @@ -16,7 +16,10 @@ import { hydrateSidebarChildSessions, retireStaleChildSessionRows, } from "./app-sidebar-child-session-data.ts"; -import { SessionCatalogLiveState } from "./app-sidebar-session-catalog-live.ts"; +import { + SessionCatalogLiveState, + sessionCatalogListClient, +} from "./app-sidebar-session-catalog-live.ts"; import type { SidebarSessionMutationScope, SidebarSessionsScrollState, @@ -224,7 +227,7 @@ export class SessionDataController implements ReactiveController, SessionCatalog } sessionCatalogGatewayClient(): GatewayBrowserClient | null { - return this.gatewayClient; + return sessionCatalogListClient(this.context?.gateway.snapshot, this.host.connected); } private synchronizeOwnerSessionCounts(): void { @@ -429,9 +432,10 @@ export class SessionDataController implements ReactiveController, SessionCatalog const available = isGatewayAvailable(gateway.snapshot); const becameAvailable = available && !this.gatewayAvailable; this.gatewayAvailable = available; - // Presence and auth snapshots must not retire this client's in-flight - // native or catalog pages unless its connection phase actually changes. + // Presence updates preserve in-flight pages, but a new authority projection + // can revoke catalog ownership without replacing the socket client. if (!sourceOrClientChanged && !connectionChanged) { + this.synchronizeSessionScope(); this.synchronizeOwnerSessionCounts(); const { awaitingGateway, error } = this.sessionCatalogRefreshStatus; const requesting = this.sessionCatalogLive.requestGeneration !== null; diff --git a/ui/src/e2e/sidebar-catalog-scope.e2e.test.ts b/ui/src/e2e/sidebar-catalog-scope.e2e.test.ts new file mode 100644 index 000000000000..8ac630af4579 --- /dev/null +++ b/ui/src/e2e/sidebar-catalog-scope.e2e.test.ts @@ -0,0 +1,67 @@ +import path from "node:path"; +import { expect, it } from "vitest"; +import type { SessionDataController } from "../components/session-data-controller.ts"; +import { createControlUiE2eArtifactDir } from "../test-helpers/control-ui-e2e-artifacts.ts"; +import { + defaultControlUiFeatureMethods, + installMockGateway, +} from "../test-helpers/control-ui-e2e.ts"; +import { createControlUiSessionRow } from "../test-helpers/control-ui-session-fixtures.ts"; +import { createControlUiE2eSuite } from "./control-ui-e2e-suite.test-support.ts"; + +const suite = createControlUiE2eSuite({ name: "Sidebar catalog scope" }); + +suite.define(() => { + it("discovers and opens ordinary guest sessions without requesting the external catalog", async () => { + const artifactDir = createControlUiE2eArtifactDir("sidebar-catalog-scope"); + const viewport = { width: 1440, height: 900 }; + const context = await suite.newBrowserContext({ + viewport, + recordVideo: { dir: artifactDir, size: viewport }, + }); + const page = await context.newPage(); + try { + const key = "agent:main:visitor-notes"; + const gateway = await installMockGateway(page, { + sessionKey: key, + operatorScopes: ["operator.sessions.write"], + featureMethods: [...defaultControlUiFeatureMethods, "sessions.catalog.list"], + sessions: [createControlUiSessionRow(key, "Visitor notes", Date.now())], + historyMessages: [ + { role: "assistant", content: [{ type: "text", text: "Welcome back." }] }, + ], + methodResponses: { + "sessions.catalog.list": { + __mockError: { code: "INVALID_REQUEST", message: "missing scope: operator.read" }, + }, + }, + }); + await page.goto(`${suite.server.baseUrl}new`); + const sidebar = page.locator("openclaw-app-sidebar"); + const session = sidebar.locator(`[data-session-key="${key}"]`).first(); + await session.waitFor({ state: "visible" }); + await page.waitForFunction(() => { + const data = document.querySelector( + "openclaw-app-sidebar", + )?.sessionData; + return ( + data && + (data.sessionCatalogAgentId === null || + data.sessionCatalogRefreshStatus.error !== null || + data.sessionCatalogRefreshStatus.hasLoaded) + ); + }); + await page.screenshot({ path: path.join(artifactDir, "sidebar.png") }); + expect((await gateway.getRequests("sessions.list")).length).toBeGreaterThan(0); + expect(await gateway.getRequests("sessions.catalog.list")).toEqual([]); + expect(await sidebar.getByRole("alert").count()).toBe(0); + await session.click(); + await page.getByText("Welcome back.", { exact: true }).waitFor(); + expect(await gateway.getRequests("sessions.catalog.list")).toEqual([]); + expect(await sidebar.getByRole("alert").count()).toBe(0); + await page.screenshot({ path: path.join(artifactDir, "opened-session.png") }); + } finally { + await suite.closeBrowserContext(context); + } + }); +}); diff --git a/ui/src/test-helpers/app-sidebar-cases/catalog-compat.ts b/ui/src/test-helpers/app-sidebar-cases/catalog-compat.ts index 9aa4dee72df8..0546dc6c72c3 100644 --- a/ui/src/test-helpers/app-sidebar-cases/catalog-compat.ts +++ b/ui/src/test-helpers/app-sidebar-cases/catalog-compat.ts @@ -13,6 +13,7 @@ describe("AppSidebar session catalog pagination", () => { const gateway = createGatewayHarness({ request } as unknown as GatewayBrowserClient); gateway.publish({ hello: { + auth: { role: "operator", scopes: ["operator.read"] }, features: { methods: ["sessions.catalog.list"] }, } as ApplicationGatewaySnapshot["hello"], }); @@ -81,6 +82,7 @@ describe("AppSidebar session catalog pagination", () => { const gateway = createGatewayHarness({ request } as unknown as GatewayBrowserClient); gateway.publish({ hello: { + auth: { role: "operator", scopes: ["operator.read"] }, features: { methods: ["sessions.catalog.list"] }, } as ApplicationGatewaySnapshot["hello"], }); @@ -170,6 +172,7 @@ describe("AppSidebar session catalog pagination", () => { const gateway = createGatewayHarness({ request } as unknown as GatewayBrowserClient); gateway.publish({ hello: { + auth: { role: "operator", scopes: ["operator.read"] }, features: { methods: ["sessions.catalog.list"] }, } as ApplicationGatewaySnapshot["hello"], }); @@ -245,6 +248,7 @@ describe("AppSidebar session catalog pagination", () => { const gateway = createGatewayHarness({ request } as unknown as GatewayBrowserClient); gateway.publish({ hello: { + auth: { role: "operator", scopes: ["operator.read"] }, features: { methods: ["sessions.catalog.list"] }, } as ApplicationGatewaySnapshot["hello"], }); @@ -291,6 +295,7 @@ describe("AppSidebar session catalog pagination", () => { const gateway = createGatewayHarness({ request } as unknown as GatewayBrowserClient); gateway.publish({ hello: { + auth: { role: "operator", scopes: ["operator.read"] }, features: { methods: ["sessions.catalog.list"] }, } as ApplicationGatewaySnapshot["hello"], }); diff --git a/ui/src/test-helpers/app-sidebar-cases/catalog-live-errors.ts b/ui/src/test-helpers/app-sidebar-cases/catalog-live-errors.ts index a207e000e936..b2e179edef4a 100644 --- a/ui/src/test-helpers/app-sidebar-cases/catalog-live-errors.ts +++ b/ui/src/test-helpers/app-sidebar-cases/catalog-live-errors.ts @@ -14,6 +14,7 @@ describe("AppSidebar session catalog request errors", () => { gateway.publish({ assistantAgentId: "roboclaw", hello: { + auth: { role: "operator", scopes: ["operator.read"] }, features: { methods: ["sessions.catalog.list"], events: ["sessions.catalog.changed"] }, } as ApplicationGatewaySnapshot["hello"], }); @@ -43,6 +44,7 @@ describe("AppSidebar session catalog request errors", () => { gateway.publish({ assistantAgentId: "roboclaw", hello: { + auth: { role: "operator", scopes: ["operator.read"] }, features: { methods: ["sessions.catalog.list"], events: ["sessions.catalog.changed"] }, } as ApplicationGatewaySnapshot["hello"], }); @@ -69,6 +71,7 @@ describe("AppSidebar session catalog request errors", () => { const gateway = createGatewayHarness({ request } as unknown as GatewayBrowserClient); gateway.publish({ hello: { + auth: { role: "operator", scopes: ["operator.read"] }, features: { methods: ["sessions.catalog.list"], events: ["sessions.catalog.changed"] }, } as ApplicationGatewaySnapshot["hello"], }); @@ -108,6 +111,7 @@ describe("AppSidebar session catalog request errors", () => { const gateway = createGatewayHarness({ request } as unknown as GatewayBrowserClient); gateway.publish({ hello: { + auth: { role: "operator", scopes: ["operator.read"] }, features: { methods: ["sessions.catalog.list"], events: ["sessions.catalog.changed"] }, } as ApplicationGatewaySnapshot["hello"], }); @@ -146,6 +150,7 @@ describe("AppSidebar session catalog request errors", () => { gateway.publish({ assistantAgentId: "roboclaw", hello: { + auth: { role: "operator", scopes: ["operator.read"] }, features: { methods: ["sessions.catalog.list"], events: ["sessions.catalog.changed"] }, } as ApplicationGatewaySnapshot["hello"], }); @@ -193,6 +198,7 @@ describe("AppSidebar session catalog request errors", () => { gateway.publish({ assistantAgentId: null, hello: { + auth: { role: "operator", scopes: ["operator.read"] }, features: { methods: ["sessions.catalog.list"], events: ["sessions.catalog.changed"] }, } as ApplicationGatewaySnapshot["hello"], }); @@ -224,6 +230,7 @@ describe("AppSidebar session catalog request errors", () => { gateway.publish({ suspensionPhase: "accepting", hello: { + auth: { role: "operator", scopes: ["operator.read"] }, features: { methods: ["sessions.catalog.list"], events: ["sessions.catalog.changed"] }, } as ApplicationGatewaySnapshot["hello"], }); @@ -284,6 +291,7 @@ describe("AppSidebar session catalog request errors", () => { gateway.publish({ suspensionPhase: "accepting", hello: { + auth: { role: "operator", scopes: ["operator.read"] }, features: { methods: ["sessions.catalog.list"], events: ["sessions.catalog.changed"] }, } as ApplicationGatewaySnapshot["hello"], }); diff --git a/ui/src/test-helpers/app-sidebar-cases/catalog-live-events.ts b/ui/src/test-helpers/app-sidebar-cases/catalog-live-events.ts index 05984ad4ea0a..e2c536bad0a2 100644 --- a/ui/src/test-helpers/app-sidebar-cases/catalog-live-events.ts +++ b/ui/src/test-helpers/app-sidebar-cases/catalog-live-events.ts @@ -15,6 +15,7 @@ describe("AppSidebar session catalog pagination", () => { const gateway = createGatewayHarness({ request } as unknown as GatewayBrowserClient); gateway.publish({ hello: { + auth: { role: "operator", scopes: ["operator.read"] }, features: { methods: ["sessions.catalog.list"], events: ["sessions.catalog.changed"] }, } as ApplicationGatewaySnapshot["hello"], }); @@ -46,6 +47,7 @@ describe("AppSidebar session catalog pagination", () => { const gateway = createGatewayHarness({ request } as unknown as GatewayBrowserClient); gateway.publish({ hello: { + auth: { role: "operator", scopes: ["operator.read"] }, features: { methods: ["sessions.catalog.list"], events: ["sessions.catalog.changed"] }, } as ApplicationGatewaySnapshot["hello"], }); @@ -101,6 +103,7 @@ describe("AppSidebar session catalog pagination", () => { const gateway = createGatewayHarness({ request } as unknown as GatewayBrowserClient); gateway.publish({ hello: { + auth: { role: "operator", scopes: ["operator.read"] }, features: { methods: ["sessions.catalog.list"], events: ["sessions.catalog.changed"] }, } as ApplicationGatewaySnapshot["hello"], }); @@ -164,6 +167,7 @@ describe("AppSidebar session catalog pagination", () => { const gateway = createGatewayHarness({ request } as unknown as GatewayBrowserClient); gateway.publish({ hello: { + auth: { role: "operator", scopes: ["operator.read"] }, features: { methods: ["sessions.catalog.list"], events: ["sessions.catalog.changed"] }, } as ApplicationGatewaySnapshot["hello"], }); @@ -207,6 +211,7 @@ describe("AppSidebar session catalog pagination", () => { const gateway = createGatewayHarness({ request } as unknown as GatewayBrowserClient); gateway.publish({ hello: { + auth: { role: "operator", scopes: ["operator.read"] }, features: { methods: ["sessions.catalog.list"], events: ["sessions.catalog.changed"] }, } as ApplicationGatewaySnapshot["hello"], }); @@ -257,6 +262,7 @@ describe("AppSidebar session catalog pagination", () => { const gateway = createGatewayHarness({ request } as unknown as GatewayBrowserClient); gateway.publish({ hello: { + auth: { role: "operator", scopes: ["operator.read"] }, features: { methods: ["sessions.catalog.list"], events: ["sessions.catalog.changed"] }, } as ApplicationGatewaySnapshot["hello"], }); @@ -276,6 +282,7 @@ describe("AppSidebar session catalog pagination", () => { gateway.publish({ phase: "connected", hello: { + auth: { role: "operator", scopes: ["operator.read"] }, features: { methods: ["sessions.catalog.list"], events: ["sessions.catalog.changed"] }, } as ApplicationGatewaySnapshot["hello"], }); diff --git a/ui/src/test-helpers/app-sidebar-cases/catalog-live.ts b/ui/src/test-helpers/app-sidebar-cases/catalog-live.ts index b194806943f6..c20d573b2e31 100644 --- a/ui/src/test-helpers/app-sidebar-cases/catalog-live.ts +++ b/ui/src/test-helpers/app-sidebar-cases/catalog-live.ts @@ -29,6 +29,7 @@ describe("AppSidebar session catalog pagination", () => { } as unknown as GatewayBrowserClient); previousGateway.publish({ hello: { + auth: { role: "operator", scopes: ["operator.read"] }, features: { methods: ["sessions.catalog.list"], events: ["sessions.catalog.changed"] }, } as ApplicationGatewaySnapshot["hello"], }); @@ -45,6 +46,7 @@ describe("AppSidebar session catalog pagination", () => { } as unknown as GatewayBrowserClient); currentGateway.publish({ hello: { + auth: { role: "operator", scopes: ["operator.read"] }, features: { methods: ["sessions.catalog.list"], events: ["sessions.catalog.changed"] }, } as ApplicationGatewaySnapshot["hello"], }); @@ -314,6 +316,7 @@ describe("AppSidebar session catalog pagination", () => { const gateway = createGatewayHarness({ request } as unknown as GatewayBrowserClient); gateway.publish({ hello: { + auth: { role: "operator", scopes: ["operator.read"] }, features: { methods: ["sessions.catalog.list"], events: ["sessions.catalog.changed"] }, } as ApplicationGatewaySnapshot["hello"], }); @@ -374,6 +377,7 @@ describe("AppSidebar session catalog pagination", () => { const gateway = createGatewayHarness({ request } as unknown as GatewayBrowserClient); gateway.publish({ hello: { + auth: { role: "operator", scopes: ["operator.read"] }, features: { methods: ["sessions.catalog.list"], events: ["sessions.catalog.changed"] }, } as ApplicationGatewaySnapshot["hello"], }); @@ -443,6 +447,7 @@ describe("AppSidebar session catalog pagination", () => { const gateway = createGatewayHarness({ request } as unknown as GatewayBrowserClient); gateway.publish({ hello: { + auth: { role: "operator", scopes: ["operator.read"] }, features: { methods: ["sessions.catalog.list"], events: ["sessions.catalog.changed"] }, } as ApplicationGatewaySnapshot["hello"], }); @@ -508,6 +513,7 @@ describe("AppSidebar session catalog pagination", () => { const gateway = createGatewayHarness({ request } as unknown as GatewayBrowserClient); gateway.publish({ hello: { + auth: { role: "operator", scopes: ["operator.read"] }, features: { methods: ["sessions.catalog.list"], events: ["sessions.catalog.changed"] }, } as ApplicationGatewaySnapshot["hello"], }); @@ -575,6 +581,7 @@ describe("AppSidebar session catalog pagination", () => { const gateway = createGatewayHarness({ request } as unknown as GatewayBrowserClient); gateway.publish({ hello: { + auth: { role: "operator", scopes: ["operator.read"] }, features: { methods: ["sessions.catalog.list"], events: ["sessions.catalog.changed"] }, } as ApplicationGatewaySnapshot["hello"], }); @@ -616,6 +623,7 @@ describe("AppSidebar session catalog pagination", () => { const gateway = createGatewayHarness({ request } as unknown as GatewayBrowserClient); gateway.publish({ hello: { + auth: { role: "operator", scopes: ["operator.read"] }, features: { methods: ["sessions.catalog.list"], events: ["sessions.catalog.changed"] }, } as ApplicationGatewaySnapshot["hello"], }); diff --git a/ui/src/test-helpers/app-sidebar-cases/catalog-ownership.ts b/ui/src/test-helpers/app-sidebar-cases/catalog-ownership.ts index c0f359c5566c..b21318e349a1 100644 --- a/ui/src/test-helpers/app-sidebar-cases/catalog-ownership.ts +++ b/ui/src/test-helpers/app-sidebar-cases/catalog-ownership.ts @@ -69,9 +69,14 @@ describe("AppSidebar session catalog ownership", () => { it.each([ { owner: "the selected agent", assistantAgentId: null }, { owner: "the advertised catalog capability", assistantAgentId: "main" }, + { + owner: "catalog read authority", + assistantAgentId: "main", + scopes: ["operator.sessions.write"], + }, ])( "retires catalog rows and creation after reconnect loses $owner", - async ({ assistantAgentId }) => { + async ({ assistantAgentId, scopes }) => { vi.useFakeTimers(); let provider: HTMLElement | undefined; try { @@ -128,7 +133,9 @@ describe("AppSidebar session catalog ownership", () => { gateway.publish({ phase: "connected", assistantAgentId, - hello: { ...catalogHello, features: { ...catalogHello.features, methods: [] } }, + hello: scopes + ? { ...catalogHello, auth: { role: "operator", scopes } } + : { ...catalogHello, features: { ...catalogHello.features, methods: [] } }, }); await sidebar.updateComplete; await vi.advanceTimersByTimeAsync(0); diff --git a/ui/src/test-helpers/app-sidebar-cases/catalog-page-hosts.ts b/ui/src/test-helpers/app-sidebar-cases/catalog-page-hosts.ts index 527c9718772b..8c89de2dc0ce 100644 --- a/ui/src/test-helpers/app-sidebar-cases/catalog-page-hosts.ts +++ b/ui/src/test-helpers/app-sidebar-cases/catalog-page-hosts.ts @@ -136,6 +136,7 @@ export function registerCatalogPageHostTests() { const gateway = createGatewayHarness({ request } as unknown as GatewayBrowserClient); gateway.publish({ hello: { + auth: { role: "operator", scopes: ["operator.read"] }, features: { methods: ["sessions.catalog.list"], events: ["sessions.catalog.changed"] }, } as ApplicationGatewaySnapshot["hello"], }); diff --git a/ui/src/test-helpers/app-sidebar-cases/catalog-pages.ts b/ui/src/test-helpers/app-sidebar-cases/catalog-pages.ts index fc02ec682282..679ff600c0de 100644 --- a/ui/src/test-helpers/app-sidebar-cases/catalog-pages.ts +++ b/ui/src/test-helpers/app-sidebar-cases/catalog-pages.ts @@ -33,6 +33,7 @@ describe("AppSidebar session catalog pagination", () => { }); gateway.publish({ hello: { + auth: { role: "operator", scopes: ["operator.read"] }, features: { methods: ["sessions.catalog.list"], events: ["sessions.catalog.changed"] }, } as ApplicationGatewaySnapshot["hello"], }); @@ -90,6 +91,7 @@ describe("AppSidebar session catalog pagination", () => { }); const gateway = createGatewayHarness({ request } as unknown as GatewayBrowserClient); const hello = { + auth: { role: "operator", scopes: ["operator.read"] }, features: { methods: ["sessions.catalog.list"], events: ["sessions.catalog.changed"] }, } as ApplicationGatewaySnapshot["hello"]; gateway.publish({ hello }); @@ -171,6 +173,7 @@ describe("AppSidebar session catalog pagination", () => { const gateway = createGatewayHarness({ request } as unknown as GatewayBrowserClient); gateway.publish({ hello: { + auth: { role: "operator", scopes: ["operator.read"] }, features: { methods: ["sessions.catalog.list"], events: ["sessions.catalog.changed"] }, } as ApplicationGatewaySnapshot["hello"], }); diff --git a/ui/src/test-helpers/app-sidebar-cases/catalog-reconnect.ts b/ui/src/test-helpers/app-sidebar-cases/catalog-reconnect.ts index ded24e1ba31a..4c1c91768d14 100644 --- a/ui/src/test-helpers/app-sidebar-cases/catalog-reconnect.ts +++ b/ui/src/test-helpers/app-sidebar-cases/catalog-reconnect.ts @@ -16,6 +16,7 @@ describe("AppSidebar catalog reconnect", () => { .mockResolvedValue(catalogPage([])); const gateway = createGatewayHarness({ request } as unknown as GatewayBrowserClient); const hello = { + auth: { role: "operator", scopes: ["operator.read"] }, features: { methods: ["sessions.catalog.list"], events: ["sessions.catalog.changed"] }, } as ApplicationGatewaySnapshot["hello"]; gateway.publish({ hello }); diff --git a/ui/src/test-helpers/app-sidebar-cases/catalog-terminal-owner.ts b/ui/src/test-helpers/app-sidebar-cases/catalog-terminal-owner.ts index 944464bc5995..0ac41fee828f 100644 --- a/ui/src/test-helpers/app-sidebar-cases/catalog-terminal-owner.ts +++ b/ui/src/test-helpers/app-sidebar-cases/catalog-terminal-owner.ts @@ -48,6 +48,7 @@ async function mountWithCatalog( const gateway = createGatewayHarness({ request } as unknown as GatewayBrowserClient); gateway.publish({ hello: { + auth: { role: "operator", scopes: ["operator.admin"] }, features: { methods: ["sessions.catalog.list"], events: ["sessions.catalog.changed"] }, } as ApplicationGatewaySnapshot["hello"], }); diff --git a/ui/src/test-helpers/app-sidebar-cases/interactions.ts b/ui/src/test-helpers/app-sidebar-cases/interactions.ts index bb1a61b3ec21..e8be739b860a 100644 --- a/ui/src/test-helpers/app-sidebar-cases/interactions.ts +++ b/ui/src/test-helpers/app-sidebar-cases/interactions.ts @@ -372,6 +372,7 @@ describe("AppSidebar catalog session rows", () => { const gateway = createGatewayHarness({ request } as unknown as GatewayBrowserClient); gateway.publish({ hello: { + auth: { role: "operator", scopes: ["operator.admin"] }, features: { methods: ["sessions.catalog.list"] }, } as ApplicationGatewaySnapshot["hello"], }); @@ -576,10 +577,11 @@ describe("AppSidebar catalog session rows", () => { const items = menu.querySelectorAll("wa-dropdown-item"); expect([...items].map((item) => item.getAttribute("value"))).toEqual([ "viewer", + "import", "terminal", "delete", ]); - expect(items[1]?.disabled).toBe(true); + expect(items[2]?.disabled).toBe(true); const menuButton = row.querySelector("[data-catalog-session-menu]"); expect(menuButton).not.toBeNull(); diff --git a/ui/src/test-helpers/app-sidebar.ts b/ui/src/test-helpers/app-sidebar.ts index a1022829f270..fe6602b481ab 100644 --- a/ui/src/test-helpers/app-sidebar.ts +++ b/ui/src/test-helpers/app-sidebar.ts @@ -640,6 +640,7 @@ export async function mountSessionCatalogSidebar(client: GatewayBrowserClient) { const gateway = createGatewayHarness(client); gateway.publish({ hello: { + auth: { role: "operator", scopes: ["operator.read"] }, features: { methods: ["sessions.catalog.list"], events: ["sessions.catalog.changed"] }, } as ApplicationGatewaySnapshot["hello"], });