diff --git a/src/gateway/server-methods/session-catalog-visibility.test.ts b/src/gateway/server-methods/session-catalog-visibility.test.ts index 99cc0a1937db..5f0eddc26a96 100644 --- a/src/gateway/server-methods/session-catalog-visibility.test.ts +++ b/src/gateway/server-methods/session-catalog-visibility.test.ts @@ -10,7 +10,7 @@ type TestPluginRegistry = Omit & { type TestClient = { connect: { scopes: string[] }; connId?: string; - authenticatedUserProfile: { profileId: string }; + authenticatedUserProfile?: { profileId: string }; }; const hoisted = vi.hoisted(() => ({ @@ -42,6 +42,10 @@ function client(profileId: string, scopes = ["operator.read", "operator.write"]) return { connect: { scopes }, authenticatedUserProfile: { profileId } }; } +function unprofiledClient(scopes = ["operator.read", "operator.write"]): TestClient { + return { connect: { scopes } }; +} + function session(threadId: string, sessionKey?: string) { return { threadId, @@ -197,6 +201,57 @@ describe("session catalog caller visibility", () => { expect(archive).not.toHaveBeenCalled(); }); + it("hides every row from an unprofiled multi-identity caller", async () => { + hoisted.hasMultipleSessionSharingIdentities.mockReturnValue(true); + const listedHost = host([session("unadopted-thread")]); + hoisted.activeRegistry.sessionCatalogs = [ + { provider: provider({ list: vi.fn(async () => [listedHost]) }) }, + ]; + + const listed = await call("sessions.catalog.list", {}, unprofiledClient()); + + expect(listed).toHaveBeenCalledWith(true, { + catalogs: [ + expect.objectContaining({ + hosts: [expect.objectContaining({ sessions: [] })], + }), + ], + }); + }); + + it("rejects reads for an unprofiled multi-identity caller", async () => { + hoisted.hasMultipleSessionSharingIdentities.mockReturnValue(true); + const read = vi.fn(async () => ({ + hostId: "gateway:local", + threadId: "unadopted-thread", + items: [{ type: "userMessage" as const, text: "private host history" }], + })); + hoisted.activeRegistry.sessionCatalogs = [ + { + provider: provider({ + list: vi.fn(async () => [host([session("unadopted-thread")])]), + read, + }), + }, + ]; + + const transcript = await call( + "sessions.catalog.read", + { catalogId: "codex", hostId: "gateway:local", threadId: "unadopted-thread" }, + unprofiledClient(), + ); + + expect(transcript).toHaveBeenCalledWith( + false, + undefined, + expect.objectContaining({ + code: ErrorCodes.FORBIDDEN, + message: "session catalog thread is not visible to this caller", + }), + ); + expect(read).not.toHaveBeenCalled(); + }); + it.each([ { label: "admin", multiple: true, scopes: ["operator.admin"] }, { label: "solo Gateway", multiple: false, scopes: ["operator.read"] }, @@ -314,12 +369,14 @@ describe("session catalog caller visibility", () => { host([ session("alpha-thread", "agent:main:alpha"), session("beta-thread", "agent:main:beta"), + session("unadopted-thread"), ]), ]); hoisted.activeRegistry.sessionCatalogs = [{ provider: provider({ list }) }]; const config = {}; const alpha = await call("sessions.catalog.list", {}, client("profile-alpha"), config); + const unprofiled = await call("sessions.catalog.list", {}, unprofiledClient(), config); const beta = await call("sessions.catalog.list", {}, client("profile-beta"), config); const rows = (respond: ReturnType) => respond.mock.calls[0]?.[1]?.catalogs[0]?.hosts[0]?.sessions.map( @@ -327,7 +384,8 @@ describe("session catalog caller visibility", () => { ); expect(rows(alpha)).toEqual(["alpha-thread"]); + expect(rows(unprofiled)).toEqual([]); expect(rows(beta)).toEqual(["beta-thread"]); - expect(list).toHaveBeenCalledTimes(2); + expect(list).toHaveBeenCalledTimes(3); }); }); diff --git a/src/gateway/server-methods/session-catalog-visibility.ts b/src/gateway/server-methods/session-catalog-visibility.ts index f610484a4bd2..e855cf690496 100644 --- a/src/gateway/server-methods/session-catalog-visibility.ts +++ b/src/gateway/server-methods/session-catalog-visibility.ts @@ -9,11 +9,10 @@ import { ADMIN_SCOPE, authorizeOperatorScopesForRequiredScope } from "../method- import { createSessionCatalogRequestEntrySnapshot } from "./session-catalog-entry-snapshot.js"; import type { GatewayClient } from "./types.js"; -type SessionCatalogVisibility = { - cacheKey: string; - profileId?: string; - restricted: boolean; -}; +type SessionCatalogVisibility = + | { cacheKey: string; kind: "unrestricted" } + | { cacheKey: string; kind: "restricted-unprofiled" } + | { cacheKey: string; kind: "restricted-owner"; ownerProfileId: string }; export function resolveSessionCatalogVisibility( client: GatewayClient | null, @@ -22,26 +21,31 @@ export function resolveSessionCatalogVisibility( const admin = authorizeOperatorScopesForRequiredScope(ADMIN_SCOPE, scopes).allowed; const multipleIdentities = hasMultipleSessionSharingIdentities(); const profileId = client?.authenticatedUserProfile?.profileId; - return { - cacheKey: JSON.stringify({ admin, multipleIdentities, profileId: profileId ?? null }), - ...(profileId ? { profileId } : {}), - restricted: multipleIdentities && !admin, - }; + const cacheKey = JSON.stringify({ admin, multipleIdentities, profileId: profileId ?? null }); + if (!multipleIdentities || admin) { + return { cacheKey, kind: "unrestricted" }; + } + return profileId + ? { cacheKey, kind: "restricted-owner", ownerProfileId: profileId } + : { cacheKey, kind: "restricted-unprofiled" }; } export function filterSessionCatalogHost( host: SessionCatalogHost, visibility: SessionCatalogVisibility, ): SessionCatalogHost { - if (!visibility.restricted) { + if (visibility.kind === "unrestricted") { return host; } + if (visibility.kind === "restricted-unprofiled") { + return { ...host, sessions: [] }; + } return { ...host, sessions: host.sessions.filter((session) => { // No sessionKey means the provider cannot link this host-owned CLI row to an adopted // OpenClaw session. Keep it private from non-admin callers on multi-identity Gateways. - return session.createdActor?.id === visibility.profileId; + return session.createdActor?.id === visibility.ownerProfileId; }), }; } @@ -56,9 +60,12 @@ export async function isSessionCatalogThreadVisible(params: { threadId: string; visibility: SessionCatalogVisibility; }): Promise { - if (!params.visibility.restricted) { + if (params.visibility.kind === "unrestricted") { return true; } + if (params.visibility.kind === "restricted-unprofiled") { + return false; + } const requestEntries = createSessionCatalogRequestEntrySnapshot({ cfg: params.config, fallbackAgentId: params.fallbackAgentId, @@ -80,7 +87,7 @@ export async function isSessionCatalogThreadVisible(params: { const projected = requestEntries.projectHostCreatedActors(host); const session = projected.sessions.find((candidate) => candidate.threadId === params.threadId); if (session) { - return session.createdActor?.id === params.visibility.profileId; + return session.createdActor?.id === params.visibility.ownerProfileId; } const nextCursor = host.nextCursor; if (!nextCursor || seenCursors.has(nextCursor)) {