fix: keep session catalog mirroring within isolated profiles (#129638)

* fix: bind catalog HOME policy in active facade

* fix: report isolated catalog mirroring
This commit is contained in:
Josh Avant
2026-08-25 17:31:38 -07:00
committed by GitHub
parent 6a246f70d1
commit 49f4240118
4 changed files with 90 additions and 2 deletions
+24
View File
@@ -55,6 +55,7 @@ function fakeCatalog(params: {
hostKind?: string;
onList?: () => unknown;
onRead?: (threadId: string) => unknown;
processHomeFallbackAllowed?: boolean;
}): ActiveSessionCatalog {
const host: SessionCatalogHost = {
hostId: "gateway:local",
@@ -77,6 +78,7 @@ function fakeCatalog(params: {
pluginId: params.id,
id: params.id,
label: params.id,
processHomeFallbackAllowed: params.processHomeFallbackAllowed ?? true,
list: async () => {
await params.onList?.();
return [host];
@@ -815,6 +817,28 @@ describe("createBeamMirrorRunner", () => {
expect(sent).toHaveLength(0);
});
it("warns once when profile isolation disables process-HOME fallback", async () => {
const warnings: string[] = [];
const catalog = fakeCatalog({
id: "claude",
sessions: [],
processHomeFallbackAllowed: false,
});
const runner = createBeamMirrorRunner({
runtime: fakeRuntime(mirrorConfig({ catalogs: ["claude"] })),
logger: { warn: (message) => warnings.push(message), info: () => {} },
now: () => NOW,
listCatalogs: () => [catalog],
});
await runner.tick();
await runner.tick();
expect(warnings).toEqual([
"beam mirror process-HOME fallback disabled: isolated state; only explicit catalog roots can be mirrored",
]);
});
it("sends one completed upload when a session leaves the active window", async () => {
const sent: SentRequest[] = [];
const recency = NOW - 60_000;
+10
View File
@@ -281,6 +281,7 @@ export function createBeamMirrorRunner(params: {
const controller = new AbortController();
const { signal } = controller;
let lastWarnAt = 0;
let warnedProcessHomeIsolation = false;
let redirectBlockedEndpoint: string | undefined;
let activeTick: Promise<void> | undefined;
let stopPromise: Promise<void> | undefined;
@@ -458,6 +459,15 @@ export function createBeamMirrorRunner(params: {
// would otherwise re-mirror each other's rows forever.
catalog.id !== "beam" && mirror.catalogs.includes(catalog.id),
);
if (
!warnedProcessHomeIsolation &&
catalogs.some((catalog) => !catalog.processHomeFallbackAllowed)
) {
warnedProcessHomeIsolation = true;
params.logger.warn(
"beam mirror process-HOME fallback disabled: isolated state; only explicit catalog roots can be mirrored",
);
}
const catalogById = new Map(catalogs.map((catalog) => [catalog.id, catalog]));
const candidates: BeamMirrorCandidate[] = [];
for (const catalog of catalogs) {
@@ -17,6 +17,7 @@ const hoisted = vi.hoisted(() => ({
vi.mock("../../plugins/runtime.js", () => ({
getActivePluginRegistry: () => hoisted.activeRegistry,
getActivePluginSessionExtensionRegistry: () => hoisted.activeRegistry,
requireActivePluginRegistry: () => hoisted.activeRegistry,
}));
vi.mock("../../config/sessions/session-accessor.js", async (importOriginal) => ({
@@ -25,6 +26,7 @@ vi.mock("../../config/sessions/session-accessor.js", async (importOriginal) => (
}));
const { sessionCatalogHandlers } = await import("./session-catalog.js");
const { listActiveSessionCatalogs } = await import("../../plugins/session-catalog-active.js");
function provider(
id: string,
@@ -121,6 +123,52 @@ describe("session catalog Gateway HOME isolation", () => {
);
});
it("binds HOME isolation into the internal read-only catalog facade", async () => {
const localHost = {
hostId: "gateway:local",
label: "Local",
kind: "gateway" as const,
connected: true,
sessions: [],
};
const list = vi.fn(async (request: { allowProcessHomeFallback?: boolean }) =>
request.allowProcessHomeFallback === false ? [] : [localHost],
);
const read = vi.fn(async (request: Parameters<SessionCatalogProvider["read"]>[0]) => {
if (request.allowProcessHomeFallback === false) {
throw new Error("local Test sessions are unavailable in isolated state");
}
return { hostId: request.hostId, threadId: request.threadId, items: [] };
});
hoisted.activeRegistry.sessionCatalogs = [{ provider: provider("test", { list, read }) }];
await withProfile(undefined, async () => {
const [catalog] = listActiveSessionCatalogs();
expect(catalog?.processHomeFallbackAllowed).toBe(true);
await expect(catalog?.list({})).resolves.toEqual([localHost]);
await expect(
catalog?.read({ hostId: "gateway:local", threadId: "known-thread" }),
).resolves.toMatchObject({ threadId: "known-thread" });
});
await withProfile("dev", async () => {
const [catalog] = listActiveSessionCatalogs();
expect(catalog?.processHomeFallbackAllowed).toBe(false);
await expect(catalog?.list({})).resolves.toEqual([]);
await expect(
catalog?.read({ hostId: "gateway:local", threadId: "known-thread" }),
).rejects.toThrow("local Test sessions are unavailable in isolated state");
});
expect(list.mock.calls.map(([request]) => request.allowProcessHomeFallback)).toEqual([
true,
false,
]);
expect(read.mock.calls.map(([request]) => request.allowProcessHomeFallback)).toEqual([
true,
false,
]);
});
it.each([
["continue", "continueSession", {}],
["archive", "archive", { confirmNoOtherRunner: true }],
+8 -2
View File
@@ -1,3 +1,4 @@
import { allowsProcessHomeSessionScan } from "../config/paths.js";
import { getActivePluginSessionExtensionRegistry } from "./runtime.js";
import type { SessionCatalogProvider } from "./session-catalog.js";
@@ -5,6 +6,7 @@ export type ActiveSessionCatalog = {
pluginId: string;
id: string;
label: string;
processHomeFallbackAllowed: boolean;
list: SessionCatalogProvider["list"];
read: SessionCatalogProvider["read"];
};
@@ -16,13 +18,17 @@ export type ActiveSessionCatalog = {
*/
export function listActiveSessionCatalogs(): ActiveSessionCatalog[] {
const registrations = getActivePluginSessionExtensionRegistry()?.sessionCatalogs ?? [];
const allowProcessHomeFallback = allowsProcessHomeSessionScan();
return registrations
.map(({ pluginId, provider }) => ({
pluginId,
id: provider.id,
label: provider.label,
list: provider.list.bind(provider),
read: provider.read.bind(provider),
processHomeFallbackAllowed: allowProcessHomeFallback,
list: (params: Parameters<SessionCatalogProvider["list"]>[0]) =>
provider.list({ ...params, allowProcessHomeFallback }),
read: (params: Parameters<SessionCatalogProvider["read"]>[0]) =>
provider.read({ ...params, allowProcessHomeFallback }),
}))
.toSorted((left, right) => left.id.localeCompare(right.id));
}