diff --git a/packages/tools/package.json b/packages/tools/package.json index 05f6b6f26..441b5399c 100644 --- a/packages/tools/package.json +++ b/packages/tools/package.json @@ -1,14 +1,14 @@ { "name": "@supermemory/tools", "type": "module", - "version": "2.3.0", + "version": "2.3.1", "description": "Memory tools for AI SDK, OpenAI, Voltagent and Mastra with supermemory", "scripts": { "build": "tsdown", "dev": "tsdown --watch --ignore-watch .turbo", "check-types": "tsc --noEmit", "test": "vitest --testTimeout 100000", - "test:unit": "vitest run --testTimeout 100000 src/tools-shared.test.ts src/tool-operations.test.ts src/claude-memory.test.ts test/with-supermemory/unit.test.ts test/with-supermemory/conversation-conversion.test.ts test/openai-middleware.unit.test.ts test/mastra/unit.test.ts test/voltagent.unit.test.ts", + "test:unit": "vitest run --testTimeout 100000 src/tools-shared.test.ts src/tool-operations.test.ts src/document-delete-scope.test.ts src/claude-memory.test.ts test/with-supermemory/unit.test.ts test/with-supermemory/conversation-conversion.test.ts test/openai-middleware.unit.test.ts test/mastra/unit.test.ts test/voltagent.unit.test.ts", "test:watch": "vitest --watch --testTimeout 100000" }, "dependencies": { diff --git a/packages/tools/src/ai-sdk.ts b/packages/tools/src/ai-sdk.ts index 97d6a36bf..6a9adb844 100644 --- a/packages/tools/src/ai-sdk.ts +++ b/packages/tools/src/ai-sdk.ts @@ -10,6 +10,7 @@ import { clampSearchLimit, deleteDocumentByIdentifier, getContainerTags, + resolveConfiguredContainerTag, } from "./tools-shared" import { forgetMemoryRequest } from "./shared/forget-memory" import type { SupermemoryToolsConfig } from "./types" @@ -146,7 +147,7 @@ export const getProfileTool = ( }), execute: async ({ containerTag, query }) => { try { - const tag = containerTag || containerTags[0] + const tag = resolveConfiguredContainerTag(containerTags, containerTag) const response = await client.profile({ containerTag: tag, @@ -199,7 +200,7 @@ export const documentListTool = ( execute: async ({ containerTag, limit, page }) => { try { const scopeTags: [string, ...string[]] = containerTag - ? [containerTag] + ? [resolveConfiguredContainerTag(containerTags, containerTag)] : containerTags const response = await client.documents.list({ @@ -248,7 +249,7 @@ export const documentDeleteTool = ( execute: async ({ documentId, containerTag }) => { try { const scopeTags: [string, ...string[]] = containerTag - ? [containerTag] + ? [resolveConfiguredContainerTag(containerTags, containerTag)] : containerTags await deleteDocumentByIdentifier(client, documentId, scopeTags) @@ -339,12 +340,12 @@ export const memoryForgetTool = ( } } - const tag = containerTag || containerTags[0] + const tag = resolveConfiguredContainerTag(containerTags, containerTag) await forgetMemoryRequest( apiKey, { - containerTag: tag as string, + containerTag: tag, ...(memoryId && { id: memoryId }), ...(memoryContent && { content: memoryContent }), ...(reason && { reason }), diff --git a/packages/tools/src/document-delete-scope.test.ts b/packages/tools/src/document-delete-scope.test.ts new file mode 100644 index 000000000..e209a41c5 --- /dev/null +++ b/packages/tools/src/document-delete-scope.test.ts @@ -0,0 +1,139 @@ +import { beforeEach, describe, expect, it, vi } from "vitest" + +const documentsDeleteBulk = vi.fn() +const documentsGet = vi.fn() +const documentsList = vi.fn() + +vi.mock("supermemory", () => { + return { + default: class MockSupermemory { + documents = { + deleteBulk: documentsDeleteBulk, + get: documentsGet, + list: documentsList, + } + }, + } +}) + +import * as aiSdk from "./ai-sdk" +import * as openAi from "./openai/tools" + +const API_KEY = "sm_test_key" + +type ToolExecutionResult = { success: boolean; error?: string } +type ToolWithExecute = { + execute: (args: Record) => Promise +} + +function executeTool( + tool: unknown, + args: Record, +): Promise { + return (tool as ToolWithExecute).execute(args) +} + +beforeEach(() => { + documentsDeleteBulk.mockReset().mockResolvedValue({ + success: true, + deletedCount: 1, + errors: [], + }) + documentsGet.mockReset() + documentsList.mockReset().mockResolvedValue({ + memories: [], + pagination: { currentPage: 1, totalItems: 0, totalPages: 0 }, + }) +}) + +describe("documentDelete configured container scope", () => { + it("ai-sdk refuses a model-supplied tag outside the configured scope", async () => { + documentsGet.mockResolvedValue({ + id: "doc_in_tenant_b", + customId: "doc_in_tenant_b", + containerTags: ["tenant-b"], + }) + + const result = await executeTool( + aiSdk.documentDeleteTool(API_KEY, { containerTags: ["tenant-a"] }), + { documentId: "doc_in_tenant_b", containerTag: "tenant-b" }, + ) + + expect(result.success).toBe(false) + expect(result.error).toContain("outside the configured scope") + expect(documentsGet).not.toHaveBeenCalled() + expect(documentsList).not.toHaveBeenCalled() + expect(documentsDeleteBulk).not.toHaveBeenCalled() + }) + + it("openai refuses a model-supplied tag outside the configured scope", async () => { + documentsGet.mockResolvedValue({ + id: "doc_in_tenant_b", + customId: "doc_in_tenant_b", + containerTags: ["tenant-b"], + }) + + const documentDelete = openAi.createDocumentDeleteFunction(API_KEY, { + containerTags: ["tenant-a"], + }) + const result = await documentDelete({ + documentId: "doc_in_tenant_b", + containerTag: "tenant-b", + }) + + expect(result.success).toBe(false) + expect(result.error).toContain("outside the configured scope") + expect(documentsGet).not.toHaveBeenCalled() + expect(documentsList).not.toHaveBeenCalled() + expect(documentsDeleteBulk).not.toHaveBeenCalled() + }) + + it("rejects tags outside an implicit project scope", async () => { + const result = await executeTool( + aiSdk.documentDeleteTool(API_KEY, { projectId: "alpha" }), + { documentId: "doc_1", containerTag: "tenant-b" }, + ) + + expect(result.success).toBe(false) + expect(result.error).toContain("outside the configured scope") + expect(documentsDeleteBulk).not.toHaveBeenCalled() + }) + + it("allows selecting another explicitly configured tag", async () => { + documentsGet.mockResolvedValue({ + id: "doc_in_tenant_b", + customId: "doc_in_tenant_b", + containerTags: ["tenant-b"], + }) + + const result = await executeTool( + aiSdk.documentDeleteTool(API_KEY, { + containerTags: ["tenant-a", "tenant-b"], + }), + { documentId: "doc_in_tenant_b", containerTag: "tenant-b" }, + ) + + expect(result.success).toBe(true) + expect(documentsDeleteBulk).toHaveBeenCalledWith({ + ids: ["doc_in_tenant_b"], + }) + }) + + it("keeps the configured union when no tag is supplied", async () => { + documentsGet.mockResolvedValue({ + id: "doc_in_tenant_a", + customId: "doc_in_tenant_a", + containerTags: ["tenant-a"], + }) + + const result = await executeTool( + aiSdk.documentDeleteTool(API_KEY, { containerTags: ["tenant-a"] }), + { documentId: "doc_in_tenant_a" }, + ) + + expect(result.success).toBe(true) + expect(documentsDeleteBulk).toHaveBeenCalledWith({ + ids: ["doc_in_tenant_a"], + }) + }) +}) diff --git a/packages/tools/src/openai/tools.ts b/packages/tools/src/openai/tools.ts index 1be290849..7a432dc6a 100644 --- a/packages/tools/src/openai/tools.ts +++ b/packages/tools/src/openai/tools.ts @@ -9,6 +9,7 @@ import { clampSearchLimit, deleteDocumentByIdentifier, getContainerTags, + resolveConfiguredContainerTag, } from "../tools-shared" import { forgetMemoryRequest } from "../shared/forget-memory" import type { SupermemoryToolsConfig } from "../types" @@ -333,7 +334,7 @@ export function createGetProfileFunction( query?: string }): Promise { try { - const tag = containerTag || containerTags[0] + const tag = resolveConfiguredContainerTag(containerTags, containerTag) const response = await client.profile({ containerTag: tag, @@ -374,7 +375,7 @@ export function createDocumentListFunction( }): Promise { try { const scopeTags: [string, ...string[]] = containerTag - ? [containerTag] + ? [resolveConfiguredContainerTag(containerTags, containerTag)] : containerTags const response = await client.documents.list({ @@ -415,7 +416,7 @@ export function createDocumentDeleteFunction( }): Promise { try { const scopeTags: [string, ...string[]] = containerTag - ? [containerTag] + ? [resolveConfiguredContainerTag(containerTags, containerTag)] : containerTags await deleteDocumentByIdentifier(client, documentId, scopeTags) @@ -502,12 +503,12 @@ export function createMemoryForgetFunction( } } - const tag = containerTag || containerTags[0] + const tag = resolveConfiguredContainerTag(containerTags, containerTag) await forgetMemoryRequest( apiKey, { - containerTag: tag as string, + containerTag: tag, ...(memoryId && { id: memoryId }), ...(memoryContent && { content: memoryContent }), ...(reason && { reason }), diff --git a/packages/tools/src/tool-operations.test.ts b/packages/tools/src/tool-operations.test.ts index 8efef91d1..280d34061 100644 --- a/packages/tools/src/tool-operations.test.ts +++ b/packages/tools/src/tool-operations.test.ts @@ -5,6 +5,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest" const documentsDeleteBulk = vi.fn() const documentsGet = vi.fn() const documentsList = vi.fn() +const profileRequest = vi.fn() const clientAdd = vi.fn() const clientSearch = vi.fn() const clientOptions: unknown[] = [] @@ -15,6 +16,7 @@ vi.mock("supermemory", () => { constructor(options: unknown) { clientOptions.push(options) } + profile = profileRequest add = clientAdd search = clientSearch documents = { @@ -34,9 +36,15 @@ import * as openAi from "./openai/tools" const API_KEY = "sm_test_key" -type ToolWithExecute = { execute: (args: Record) => unknown } +type ToolExecutionResult = { success: boolean; error?: string } +type ToolWithExecute = { + execute: (args: Record) => Promise +} -function executeTool(tool: unknown, args: Record) { +function executeTool( + tool: unknown, + args: Record, +): Promise { return (tool as ToolWithExecute).execute(args) } @@ -55,6 +63,10 @@ beforeEach(() => { memories: [{ id: "doc_1", title: "Doc one" }], pagination: { currentPage: 1, totalItems: 1, totalPages: 1 }, }) + profileRequest.mockReset().mockResolvedValue({ + profile: { static: [], dynamic: [] }, + searchResults: { results: [] }, + }) clientAdd.mockReset().mockResolvedValue({ id: "doc_new" }) clientSearch.mockReset().mockResolvedValue({ results: [] }) clientOptions.length = 0 @@ -112,12 +124,96 @@ describe("searchMemories", () => { }) }) +describe("configured container scope", () => { + it("rejects out-of-scope tags across both tool surfaces before I/O", async () => { + const config = { containerTags: ["tenant-a"] } + const fetchMock = vi.fn() + vi.stubGlobal("fetch", fetchMock) + + const results: ToolExecutionResult[] = await Promise.all([ + executeTool(aiSdk.getProfileTool(API_KEY, config), { + containerTag: "tenant-b", + }), + openAi.createGetProfileFunction( + API_KEY, + config, + )({ + containerTag: "tenant-b", + }), + executeTool(aiSdk.documentListTool(API_KEY, config), { + containerTag: "tenant-b", + }), + openAi.createDocumentListFunction( + API_KEY, + config, + )({ + containerTag: "tenant-b", + }), + executeTool(aiSdk.memoryForgetTool(API_KEY, config), { + containerTag: "tenant-b", + memoryId: "mem_1", + }), + openAi.createMemoryForgetFunction( + API_KEY, + config, + )({ + containerTag: "tenant-b", + memoryId: "mem_1", + }), + ]) + + expect(results).toHaveLength(6) + for (const result of results) { + expect(result.success).toBe(false) + expect(result.error).toContain("outside the configured scope") + } + expect(profileRequest).not.toHaveBeenCalled() + expect(documentsList).not.toHaveBeenCalled() + expect(fetchMock).not.toHaveBeenCalled() + }) + + it("allows selecting another explicitly configured tag", async () => { + const getProfile = openAi.createGetProfileFunction(API_KEY, { + containerTags: ["tenant-a", "tenant-b"], + }) + + const result = await getProfile({ containerTag: "tenant-b" }) + + expect(result.success).toBe(true) + expect(profileRequest).toHaveBeenCalledWith({ + containerTag: "tenant-b", + }) + }) + + it("does not let model input override implicit or project scopes", async () => { + const implicitResult = await openAi.createDocumentListFunction(API_KEY)({ + containerTag: "tenant-b", + }) + const projectResult = await executeTool( + aiSdk.getProfileTool(API_KEY, { projectId: "alpha" }), + { containerTag: "tenant-b" }, + ) + + expect(implicitResult.success).toBe(false) + expect(implicitResult.error).toContain("outside the configured scope") + expect(projectResult.success).toBe(false) + expect(projectResult.error).toContain("outside the configured scope") + expect(documentsList).not.toHaveBeenCalled() + expect(profileRequest).not.toHaveBeenCalled() + }) + + it("fails closed when the configured scope is empty", () => { + expect(() => + openAi.createGetProfileFunction(API_KEY, { containerTags: [] }), + ).toThrow("at least one non-empty containerTag") + expect(profileRequest).not.toHaveBeenCalled() + }) +}) + describe("documentDelete", () => { it("ai-sdk variant passes the document id string to the SDK", async () => { const tool = aiSdk.documentDeleteTool(API_KEY) - const result = (await executeTool(tool, { documentId: "doc_123" })) as { - success: boolean - } + const result = await executeTool(tool, { documentId: "doc_123" }) expect(result.success).toBe(true) expect(documentsGet).toHaveBeenCalledWith("doc_123") @@ -209,9 +305,9 @@ describe("memoryForget", () => { containerTags: ["user_2"], }) - const result = (await executeTool(tool, { + const result = await executeTool(tool, { memoryContent: "stale fact", - })) as { success: boolean } + }) expect(result.success).toBe(true) const [, init] = fetchMock.mock.calls[0] as [string, RequestInit] diff --git a/packages/tools/src/tools-shared.test.ts b/packages/tools/src/tools-shared.test.ts index 3cc070ba7..d92c7defb 100644 --- a/packages/tools/src/tools-shared.test.ts +++ b/packages/tools/src/tools-shared.test.ts @@ -5,6 +5,7 @@ import { clampSearchLimit, deduplicateMemoriesForMode, getContainerTags, + resolveConfiguredContainerTag, } from "./tools-shared" describe("clampSearchLimit", () => { @@ -58,6 +59,32 @@ describe("getContainerTags", () => { }) }) +describe("resolveConfiguredContainerTag", () => { + it("defaults to the first configured tag", () => { + expect(resolveConfiguredContainerTag(["tenant-a", "tenant-b"])).toBe( + "tenant-a", + ) + }) + + it("allows selection within a multi-tag scope", () => { + expect( + resolveConfiguredContainerTag(["tenant-a", "tenant-b"], "tenant-b"), + ).toBe("tenant-b") + }) + + it("rejects tags outside the configured scope", () => { + expect(() => + resolveConfiguredContainerTag(["tenant-a"], "tenant-b"), + ).toThrow('Container tag "tenant-b" is outside the configured scope') + }) + + it("rejects an empty configured scope", () => { + expect(() => resolveConfiguredContainerTag([])).toThrow( + "require at least one configured container tag", + ) + }) +}) + describe("deduplicateMemoriesForMode", () => { // The profile is not injected in "query" mode, so a memory that is both a // profile fact and a search hit must survive in the search results — diff --git a/packages/tools/src/tools-shared.ts b/packages/tools/src/tools-shared.ts index f4d150ca1..2b9baca73 100644 --- a/packages/tools/src/tools-shared.ts +++ b/packages/tools/src/tools-shared.ts @@ -125,6 +125,31 @@ export function getContainerTags(config?: { return [...CONTAINER_TAG_CONSTANTS.defaultTags] } +// A model-supplied tag may only pick one of the developer-configured tags, never widen the scope. +export function resolveConfiguredContainerTag( + configuredTags: readonly string[], + requestedTag?: string, +): string { + const defaultTag = configuredTags[0] + if (defaultTag === undefined) { + throw new Error( + "Supermemory tools require at least one configured container tag.", + ) + } + + if (!requestedTag) { + return defaultTag + } + + if (!configuredTags.includes(requestedTag)) { + throw new Error( + `Container tag "${requestedTag}" is outside the configured scope.`, + ) + } + + return requestedTag +} + /** Delete exactly one document by its internal ID. */ export async function deleteDocumentById( client: Supermemory,