mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 20:05:46 -06:00
fix(sessions): fail closed without catalog profile (#123438)
This commit is contained in:
committed by
GitHub
parent
1e6de9a481
commit
502bdb2bb7
@@ -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)) {
|
||||
|
||||
Reference in New Issue
Block a user