diff --git a/src/resources/extensions/sf/sm-client.js b/src/resources/extensions/sf/sm-client.js index c5fb3c3cf..0f2b89739 100644 --- a/src/resources/extensions/sf/sm-client.js +++ b/src/resources/extensions/sf/sm-client.js @@ -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 }; } diff --git a/src/resources/extensions/sf/tests/sm-client.test.ts b/src/resources/extensions/sf/tests/sm-client.test.ts index 36aeb33c6..d882293d3 100644 --- a/src/resources/extensions/sf/tests/sm-client.test.ts +++ b/src/resources/extensions/sf/tests/sm-client.test.ts @@ -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; + 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 }), );