fix(memory): gate SM recall by tenant scope
This commit is contained in:
parent
0c7aaafa00
commit
7c3d9bd3bf
2 changed files with 105 additions and 2 deletions
|
|
@ -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
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue