Files
rohitg00--agentmemory/test/cross-project-isolation.test.ts
wehub-resource-sync 979fb22d7c
CI / test (20, macos-latest) (push) Waiting to run
CI / test (20, ubuntu-latest) (push) Waiting to run
CI / test (22, macos-latest) (push) Waiting to run
CI / test (22, ubuntu-latest) (push) Waiting to run
chore: import upstream snapshot with attribution
2026-07-13 13:01:18 +08:00

285 lines
9.6 KiB
TypeScript

import { describe, it, expect, beforeEach, vi } from "vitest";
vi.mock("../src/logger.js", () => ({
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
}));
vi.mock("../src/state/keyed-mutex.js", () => ({
withKeyedLock: <T>(_key: string, fn: () => Promise<T>) => fn(),
}));
vi.mock("../src/functions/audit.js", () => ({
recordAudit: vi.fn(),
}));
vi.mock("../src/functions/access-tracker.js", () => ({
recordAccessBatch: vi.fn(),
deleteAccessLog: vi.fn(),
}));
vi.mock("../src/config.js", () => ({
getAgentId: () => undefined,
isAgentScopeIsolated: () => false,
}));
import { registerRememberFunction } from "../src/functions/remember.js";
import { registerSearchFunction, getSearchIndex, setIndexPersistence } from "../src/functions/search.js";
import { registerEnrichFunction } from "../src/functions/enrich.js";
import { KV } from "../src/state/schema.js";
import type { Session } from "../src/types.js";
function makeMockKV() {
const store = new Map<string, Map<string, unknown>>();
return {
get: async <T>(scope: string, key: string): Promise<T | null> =>
(store.get(scope)?.get(key) as T) ?? null,
set: async <T>(scope: string, key: string, data: T): Promise<T> => {
if (!store.has(scope)) store.set(scope, new Map());
store.get(scope)!.set(key, data);
return data;
},
delete: async (scope: string, key: string): Promise<void> => {
store.get(scope)?.delete(key);
},
list: async <T>(scope: string): Promise<T[]> => {
const entries = store.get(scope);
return entries ? (Array.from(entries.values()) as T[]) : [];
},
};
}
function makeMockSdk() {
const functions = new Map<string, Function>();
const triggerOverrides = new Map<string, Function>();
return {
registerFunction: (id: string, handler: Function) => {
functions.set(id, handler);
},
registerTrigger: () => {},
trigger: async (
idOrInput: string | { function_id: string; payload: unknown },
data?: unknown,
) => {
const id = typeof idOrInput === "string" ? idOrInput : idOrInput.function_id;
const payload = typeof idOrInput === "string" ? data : (idOrInput as { payload: unknown }).payload;
if (triggerOverrides.has(id)) return triggerOverrides.get(id)!(payload);
const fn = functions.get(id);
if (!fn) throw new Error(`No function registered: ${id}`);
return fn(payload);
},
overrideTrigger: (id: string, handler: Function) => {
triggerOverrides.set(id, handler);
},
};
}
async function seedSessions(kv: ReturnType<typeof makeMockKV>) {
const apiSession: Session = {
id: "sess-api",
project: "api",
cwd: "/srv/api",
startedAt: new Date().toISOString(),
status: "active",
observationCount: 0,
};
const webSession: Session = {
id: "sess-web",
project: "web",
cwd: "/srv/web",
startedAt: new Date().toISOString(),
status: "active",
observationCount: 0,
};
await kv.set(KV.sessions, apiSession.id, apiSession);
await kv.set(KV.sessions, webSession.id, webSession);
}
describe("cross-project isolation — end-to-end", () => {
let sdk: ReturnType<typeof makeMockSdk>;
let kv: ReturnType<typeof makeMockKV>;
beforeEach(async () => {
sdk = makeMockSdk();
kv = makeMockKV();
// Disable index persistence in tests so no file I/O occurs.
setIndexPersistence(null);
// Clear the singleton BM25 index between tests.
getSearchIndex().clear();
// Register all three functions against the shared KV.
registerRememberFunction(sdk as never, kv as never);
registerSearchFunction(sdk as never, kv as never);
// Enrich calls mem::search internally; wire the file-context trigger as a no-op.
registerEnrichFunction(sdk as never, kv as never);
sdk.overrideTrigger("mem::file-context", async () => ({ context: "" }));
await seedSessions(kv);
});
it("bug memory scoped to api does not appear in enrich context for web project", async () => {
await sdk.trigger("mem::remember", {
content: "express-jwt throws 401 when Authorization header has extra whitespace. Call .trim() before passing to middleware.",
type: "bug",
files: ["src/middleware/auth.ts"],
project: "api",
});
const result = await sdk.trigger("mem::enrich", {
sessionId: "sess-web",
files: ["src/middleware/auth.ts"],
project: "web",
}) as { context: string };
expect(result.context).not.toContain("agentmemory-past-errors");
expect(result.context).not.toContain("express-jwt");
});
it("bug memory scoped to api appears in enrich context for api project", async () => {
await sdk.trigger("mem::remember", {
content: "express-jwt throws 401 when Authorization header has extra whitespace. Call .trim() before passing to middleware.",
type: "bug",
files: ["src/middleware/auth.ts"],
project: "api",
});
const result = await sdk.trigger("mem::enrich", {
sessionId: "sess-api",
files: ["src/middleware/auth.ts"],
project: "api",
}) as { context: string };
expect(result.context).toContain("agentmemory-past-errors");
expect(result.context).toContain("express-jwt");
});
it("bug memory scoped to api is excluded from search results for web project", async () => {
await sdk.trigger("mem::remember", {
content: "express-jwt throws 401 when Authorization header has extra whitespace",
type: "bug",
files: ["src/middleware/auth.ts"],
project: "api",
});
// Force index rebuild so the freshly saved memory is indexed.
getSearchIndex().clear();
const result = await sdk.trigger("mem::search", {
query: "express-jwt whitespace",
project: "web",
}) as { results: Array<{ observation: { title: string; narrative?: string } }> };
const titles = result.results.map((r) => r.observation.title);
expect(titles.join(" ")).not.toContain("express-jwt");
});
it("bug memory scoped to api is included in search results for api project", async () => {
await sdk.trigger("mem::remember", {
content: "express-jwt throws 401 when Authorization header has extra whitespace",
type: "bug",
files: ["src/middleware/auth.ts"],
project: "api",
});
// Force index rebuild so the freshly saved memory is indexed.
getSearchIndex().clear();
const result = await sdk.trigger("mem::search", {
query: "express-jwt whitespace",
project: "api",
}) as { results: Array<{ observation: { title: string; narrative?: string } }> };
const combined = result.results
.map((r) => `${r.observation.title} ${r.observation.narrative ?? ""}`)
.join(" ");
expect(combined).toContain("express-jwt");
});
it("two projects with overlapping filenames see only their own bug memories", async () => {
await sdk.trigger("mem::remember", {
content: "express-jwt Authorization header whitespace causes 401",
type: "bug",
files: ["src/middleware/auth.ts"],
project: "api",
});
await sdk.trigger("mem::remember", {
content: "nextauth cookie domain mismatch breaks SSO on subdomains",
type: "bug",
files: ["src/middleware/auth.ts"],
project: "web",
});
const apiEnrich = await sdk.trigger("mem::enrich", {
sessionId: "sess-api",
files: ["src/middleware/auth.ts"],
project: "api",
}) as { context: string };
expect(apiEnrich.context).toContain("express-jwt");
expect(apiEnrich.context).not.toContain("nextauth");
const webEnrich = await sdk.trigger("mem::enrich", {
sessionId: "sess-web",
files: ["src/middleware/auth.ts"],
project: "web",
}) as { context: string };
expect(webEnrich.context).toContain("nextauth");
expect(webEnrich.context).not.toContain("express-jwt");
});
it("unscoped (legacy) bug memory is visible to both projects", async () => {
await sdk.trigger("mem::remember", {
content: "generic auth middleware always validates content-type header",
type: "bug",
files: ["src/middleware/auth.ts"],
// no project — legacy / unscoped
});
const apiResult = await sdk.trigger("mem::enrich", {
sessionId: "sess-api",
files: ["src/middleware/auth.ts"],
project: "api",
}) as { context: string };
const webResult = await sdk.trigger("mem::enrich", {
sessionId: "sess-web",
files: ["src/middleware/auth.ts"],
project: "web",
}) as { context: string };
expect(apiResult.context).toContain("generic auth middleware");
expect(webResult.context).toContain("generic auth middleware");
});
it("memories from different projects do not supersede each other via Jaccard dedup", async () => {
const sharedContent = "jwt token must be trimmed before validation in the middleware layer";
await sdk.trigger("mem::remember", {
content: sharedContent,
type: "bug",
files: ["src/middleware/auth.ts"],
project: "api",
});
// Save nearly identical content under a different project.
const result = await sdk.trigger("mem::remember", {
content: sharedContent,
type: "bug",
files: ["src/middleware/auth.ts"],
project: "web",
}) as { memory: { id: string; project: string } };
// Both memories must survive as independent latest entries.
const memories = await kv.list(KV.memories) as Array<{ isLatest: boolean; project: string }>;
const latestByProject = memories.filter((m) => m.isLatest);
const projects = latestByProject.map((m) => m.project);
expect(projects).toContain("api");
expect(projects).toContain("web");
expect(result.memory.project).toBe("web");
});
});