fix(memory): gate SM recall by tenant scope

This commit is contained in:
Mikael Hugo 2026-05-15 17:46:51 +02:00
parent 0c7aaafa00
commit 7c3d9bd3bf
2 changed files with 105 additions and 2 deletions

View file

@ -21,6 +21,38 @@
* SM is opt-in: set SM_ENABLED=true to activate. Disabled by default.
*/
import { getErrorMessage } from "./error-utils.js";
/**
* Resolve the explicit tenant/project boundary for Singularity Memory calls.
*
* Purpose: prevent `SM_ENABLED=true` from becoming an implicit all-tenant
* cross-project recall switch.
*
* Consumer: SM sync/query operations before sending data to the memory server.
*/
export function resolveSmScope(opts = {}) {
const tenantId =
typeof opts.tenantId === "string" && opts.tenantId.trim()
? opts.tenantId.trim()
: (
process.env.SM_TENANT_ID ||
process.env.SINGULARITY_MEMORY_TENANT_ID ||
""
).trim();
const projectId =
typeof opts.projectId === "string" && opts.projectId.trim()
? opts.projectId.trim()
: (
process.env.SM_PROJECT_ID ||
process.env.SINGULARITY_MEMORY_PROJECT_ID ||
""
).trim();
return {
tenantId: tenantId || null,
projectId: projectId || null,
};
}
export async function initializeSmClient() {
// SM is opt-in — disabled unless explicitly enabled
if (process.env.SM_ENABLED !== "true") {
@ -89,6 +121,14 @@ export async function syncMemoryToSm(memory, opts = {}) {
opts.smAddr ||
process.env.SINGULARITY_MEMORY_ADDR ||
"http://localhost:8080";
const scope = resolveSmScope(opts);
if (!scope.tenantId) {
return {
queued: false,
reason:
"SM tenant scope missing (set SM_TENANT_ID or SINGULARITY_MEMORY_TENANT_ID)",
};
}
// Queue sync in background (fire-and-forget)
setImmediate(async () => {
@ -101,6 +141,8 @@ export async function syncMemoryToSm(memory, opts = {}) {
body: JSON.stringify({
memory,
source: "sf", // Identify source as SF instance
tenantId: scope.tenantId,
...(scope.projectId ? { projectId: scope.projectId } : {}),
}),
signal: AbortSignal.timeout(5000),
});
@ -135,6 +177,10 @@ export async function querySmMemories(query, opts = {}) {
process.env.SINGULARITY_MEMORY_ADDR ||
"http://localhost:8080";
const limit = opts.limit || 5; // Cross-project recall limit (smaller than local)
const scope = resolveSmScope(opts);
if (!scope.tenantId) {
return [];
}
try {
const response = await fetch(`${addr}/v1/memories/query`, {
@ -142,7 +188,12 @@ export async function querySmMemories(query, opts = {}) {
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ query, limit }),
body: JSON.stringify({
query,
limit,
tenantId: scope.tenantId,
...(scope.projectId ? { projectId: scope.projectId } : {}),
}),
signal: AbortSignal.timeout(5000),
});
@ -173,6 +224,7 @@ export function getSmStatus() {
return {
configured: true,
addr,
tenantConfigured: Boolean(resolveSmScope().tenantId),
status: "unknown", // Status should be checked via initializeSmClient
};
}

View file

@ -9,16 +9,20 @@
* - Environment variable controls
*/
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
describe("SM Client", () => {
let originalEnv: Record<string, string | undefined>;
let originalFetch: typeof globalThis.fetch;
beforeEach(() => {
originalEnv = { ...process.env };
originalFetch = globalThis.fetch;
});
afterEach(() => {
globalThis.fetch = originalFetch;
vi.restoreAllMocks();
// Restore env
for (const key of Object.keys(process.env)) {
if (!(key in originalEnv)) {
@ -193,6 +197,52 @@ describe("SM Client", () => {
expect(Array.isArray(result)).toBe(true);
});
it("when_sm_enabled_without_tenant_scope_returns_empty_without_fetch", async () => {
process.env.SM_ENABLED = "true";
delete process.env.SM_TENANT_ID;
delete process.env.SINGULARITY_MEMORY_TENANT_ID;
const fetchMock = vi.fn();
globalThis.fetch = fetchMock;
const { querySmMemories } = await import("../sm-client.js");
const result = await querySmMemories("cross-project", {
smConnected: true,
limit: 3,
});
expect(result).toEqual([]);
expect(fetchMock).not.toHaveBeenCalled();
});
it("when_tenant_scope_configured_sends_tenant_and_project_filters", async () => {
process.env.SM_ENABLED = "true";
process.env.SM_TENANT_ID = "tenant-a";
process.env.SM_PROJECT_ID = "project-b";
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ memories: [{ id: "mem-1", content: "hit" }] }),
});
globalThis.fetch = fetchMock;
const { querySmMemories } = await import("../sm-client.js");
const result = await querySmMemories("cross-project", {
smConnected: true,
limit: 3,
});
expect(result).toEqual([{ id: "mem-1", content: "hit" }]);
expect(fetchMock).toHaveBeenCalledTimes(1);
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
expect(body).toEqual({
query: "cross-project",
limit: 3,
tenantId: "tenant-a",
projectId: "project-b",
});
});
});
describe("getSmStatus", () => {
@ -220,6 +270,7 @@ describe("SM Client", () => {
expect(status).toEqual(
expect.objectContaining({
configured: true,
tenantConfigured: false,
status: "unknown", // Actual connection status requires initializeSmClient
}),
);