fix(sessions): fail closed without catalog profile (#123438)

This commit is contained in:
Peter Steinberger
2026-08-13 20:14:03 -07:00
committed by GitHub
parent 1e6de9a481
commit 502bdb2bb7
2 changed files with 81 additions and 16 deletions
@@ -10,7 +10,7 @@ type TestPluginRegistry = Omit<PluginRegistry, "sessionCatalogs"> & {
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<typeof vi.fn>) =>
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);
});
});
@@ -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<boolean> {
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)) {