fix: avoid sidebar catalog errors for session-only users (#162944)

Keep ordinary session browsing available without unauthorized external catalog requests, and retire catalog state when read authority is lost.

Fixes #162900.
This commit is contained in:
Shakker 2026-10-01 20:38:14 +01:00 • committed by GitHub
parent d77cdd2af6
commit 898f7f49b9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 231 additions and 19 deletions

View file

@ -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;
}

View file

@ -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"],

View file

@ -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"],
});

View file

@ -46,6 +46,7 @@ async function mountExpanded(request: ReturnType<typeof vi.fn>) {
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"],
});

View file

@ -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<ReturnType<typeof catalogPage>>();
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();
}
},
);
});

View file

@ -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<void> {
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 ||

View file

@ -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") {

View file

@ -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;

View file

@ -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<HTMLElement & { sessionData: SessionDataController }>(
"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);
}
});
});

View file

@ -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"],
});

View file

@ -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"],
});

View file

@ -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"],
});

View file

@ -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"],
});

View file

@ -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);

View file

@ -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"],
});

View file

@ -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"],
});

View file

@ -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 });

View file

@ -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"],
});

View file

@ -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<HTMLElement & { disabled: boolean }>("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<HTMLElement>("[data-catalog-session-menu]");
expect(menuButton).not.toBeNull();

View file

@ -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"],
});