Skip to content
Open
18 changes: 16 additions & 2 deletions packages/core/src/memory/adapters/storage/in-memory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,14 @@
import { deepClone } from "@voltagent/internal/utils";
import type { UIMessage } from "ai";
import type { OperationContext } from "../../../agent/types";
import { ConversationAlreadyExistsError, ConversationNotFoundError } from "../../errors";
import {
ConversationAlreadyExistsError,
ConversationNotFoundError,
ConversationOwnershipMismatchError,
} from "../../errors";
import type {
Conversation,
ConversationMutationOptions,
ConversationQueryOptions,
ConversationStepRecord,
CreateConversationInput,
Expand Down Expand Up @@ -424,12 +429,17 @@ export class InMemoryStorageAdapter implements StorageAdapter {
async updateConversation(
id: string,
updates: Partial<Omit<Conversation, "id" | "createdAt" | "updatedAt">>,
options?: ConversationMutationOptions,
): Promise<Conversation> {
const conversation = this.conversations.get(id);
if (!conversation) {
throw new ConversationNotFoundError(id);
}

if (options?.expectedUserId !== undefined && conversation.userId !== options.expectedUserId) {
throw new ConversationOwnershipMismatchError(id);
}

const updatedConversation: Conversation = {
...conversation,
...updates,
Expand All @@ -443,12 +453,16 @@ export class InMemoryStorageAdapter implements StorageAdapter {
/**
* Delete a conversation
*/
async deleteConversation(id: string): Promise<void> {
async deleteConversation(id: string, options?: ConversationMutationOptions): Promise<void> {
const conversation = this.conversations.get(id);
if (!conversation) {
throw new ConversationNotFoundError(id);
}

if (options?.expectedUserId !== undefined && conversation.userId !== options.expectedUserId) {
throw new ConversationOwnershipMismatchError(id);
}

// Delete conversation
this.conversations.delete(id);

Expand Down
13 changes: 13 additions & 0 deletions packages/core/src/memory/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,19 @@ export class ConversationNotFoundError extends MemoryV2Error {
}
}

/**
* Error thrown when a guarded conversation mutation no longer matches the expected owner
*/
export class ConversationOwnershipMismatchError extends MemoryV2Error {
constructor(conversationId: string) {
super(`Conversation ownership mismatch: ${conversationId}`, "CONVERSATION_OWNERSHIP_MISMATCH", {
conversationId,
});
this.name = "ConversationOwnershipMismatchError";
Object.setPrototypeOf(this, ConversationOwnershipMismatchError.prototype);
}
}

/**
* Error thrown when trying to create a conversation that already exists
*/
Expand Down
30 changes: 24 additions & 6 deletions packages/core/src/memory/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,14 @@ import type { UIMessage } from "ai";
import type { z } from "zod";
import type { OperationContext } from "../agent/types";
import { AiSdkEmbeddingAdapter } from "./adapters/embedding/ai-sdk";
import { EmbeddingAdapterNotConfiguredError, VectorAdapterNotConfiguredError } from "./errors";
import {
ConversationOwnershipMismatchError,
EmbeddingAdapterNotConfiguredError,
VectorAdapterNotConfiguredError,
} from "./errors";
import type {
Conversation,
ConversationMutationOptions,
ConversationQueryOptions,
ConversationStepRecord,
CreateConversationInput,
Expand Down Expand Up @@ -280,19 +285,32 @@ export class Memory {
async updateConversation(
id: string,
updates: Partial<Omit<Conversation, "id" | "createdAt" | "updatedAt">>,
options?: ConversationMutationOptions,
): Promise<Conversation> {
return this.storage.updateConversation(id, updates);
return this.storage.updateConversation(id, updates, options);
}

/**
* Delete a conversation
*/
async deleteConversation(id: string): Promise<void> {
async deleteConversation(id: string, options?: ConversationMutationOptions): Promise<void> {
let conversation: Conversation | null = null;

if (this.vector || options?.expectedUserId !== undefined) {
conversation = await this.storage.getConversation(id);
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
}

if (
options?.expectedUserId !== undefined &&
conversation &&
conversation.userId !== options.expectedUserId
) {
throw new ConversationOwnershipMismatchError(id);
}

// If vector adapter is configured, delete associated vectors
if (this.vector) {
try {
// Try to get the conversation first to get userId
const conversation = await this.storage.getConversation(id);
if (conversation) {
// Get all messages to find vector IDs
const messages = await this.storage.getMessages(conversation.userId, id);
Expand All @@ -307,7 +325,7 @@ export class Memory {
}
}

return this.storage.deleteConversation(id);
return this.storage.deleteConversation(id, options);
}

// ============================================================================
Expand Down
7 changes: 6 additions & 1 deletion packages/core/src/memory/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,10 @@ export type ConversationQueryOptions = {
orderDirection?: "ASC" | "DESC";
};

export type ConversationMutationOptions = {
expectedUserId?: string;
};

/**
* Options for getting messages
*/
Expand Down Expand Up @@ -443,8 +447,9 @@ export interface StorageAdapter {
updateConversation(
id: string,
updates: Partial<Omit<Conversation, "id" | "createdAt" | "updatedAt">>,
options?: ConversationMutationOptions,
): Promise<Conversation>;
deleteConversation(id: string): Promise<void>;
deleteConversation(id: string, options?: ConversationMutationOptions): Promise<void>;
Comment thread
coderabbitai[bot] marked this conversation as resolved.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Persistent storage adapters ignore the new expectedUserId option, so the ownership check is not enforced at the storage boundary for update/delete operations. Updating every adapter to apply the user ID atomically, or enforcing the check centrally before delegation, would prevent a race or direct Memory caller from mutating another user’s conversation.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/core/src/memory/types.ts, line 452:

<comment>Persistent storage adapters ignore the new `expectedUserId` option, so the ownership check is not enforced at the storage boundary for update/delete operations. Updating every adapter to apply the user ID atomically, or enforcing the check centrally before delegation, would prevent a race or direct `Memory` caller from mutating another user’s conversation.</comment>

<file context>
@@ -443,8 +447,9 @@ export interface StorageAdapter {
+    options?: ConversationMutationOptions,
   ): Promise<Conversation>;
-  deleteConversation(id: string): Promise<void>;
+  deleteConversation(id: string, options?: ConversationMutationOptions): Promise<void>;
 
   saveConversationSteps?(steps: ConversationStepRecord[]): Promise<void>;
</file context>


saveConversationSteps?(steps: ConversationStepRecord[]): Promise<void>;
getConversationSteps?(
Expand Down
239 changes: 239 additions & 0 deletions packages/server-core/src/handlers/memory.handlers.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,239 @@
import {
ConversationOwnershipMismatchError,
InMemoryStorageAdapter,
Memory,
} from "@voltagent/core";
import type { Agent, Logger, ServerProviderDeps, VoltOpsClient } from "@voltagent/core";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
handleDeleteMemoryConversation,
handleGetMemoryConversation,
handleListMemoryConversationMessages,
handleListMemoryConversations,
handleUpdateMemoryConversation,
} from "./memory.handlers";

function createAgentWithMemory(agentId: string, agentName: string, memory: Memory): Agent {
return {
getFullState: () => ({
id: agentId,
name: agentName,
instructions: "",
status: "idle",
model: "test-model",
tools: [],
subAgents: [],
memory: {},
}),
getMemory: () => memory,
} as unknown as Agent;
}

function createDepsWithAgents(agents: Agent[]): ServerProviderDeps {
const logger: Logger = {
trace: vi.fn(),
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
fatal: vi.fn(),
child: vi.fn().mockReturnThis(),
level: "info",
silent: vi.fn(),
} as unknown as Logger;

return {
agentRegistry: {
getAgent: vi.fn((agentId: string) =>
agents.find((agent) => agent.getFullState().id === agentId),
),
getAllAgents: vi.fn().mockReturnValue(agents),
getAgentCount: vi.fn().mockReturnValue(agents.length),
removeAgent: vi.fn(),
registerAgent: vi.fn(),
getGlobalVoltOpsClient: vi.fn().mockReturnValue(undefined as unknown as VoltOpsClient),
getGlobalLogger: vi.fn().mockReturnValue(logger),
},
workflowRegistry: {
getWorkflow: vi.fn(),
getWorkflowsForApi: vi.fn().mockReturnValue([]),
getWorkflowDetailForApi: vi.fn(),
getWorkflowCount: vi.fn().mockReturnValue(0),
on: vi.fn(),
off: vi.fn(),
activeExecutions: new Map(),
resumeSuspendedWorkflow: vi.fn(),
},
triggerRegistry: {
list: vi.fn().mockReturnValue([]),
register: vi.fn(),
registerMany: vi.fn(),
get: vi.fn(),
getByPath: vi.fn(),
unregister: vi.fn(),
clear: vi.fn(),
} as any,
logger,
} as unknown as ServerProviderDeps;
}

describe("memory handlers ownership checks", () => {
let memory: Memory;
let deps: ServerProviderDeps;
const agentId = "agent-1";
const ownerUserId = "user-alice";
const otherUserId = "user-bob";
const conversationId = "conv-private";
const otherConversationId = "conv-bob";

beforeEach(async () => {
memory = new Memory({
storage: new InMemoryStorageAdapter(),
});

await memory.createConversation({
id: conversationId,
resourceId: agentId,
userId: ownerUserId,
title: "Alice Private",
metadata: {},
});

await memory.createConversation({
id: otherConversationId,
resourceId: agentId,
userId: otherUserId,
title: "Bob Private",
metadata: {},
});

await memory.addMessage(
{
id: "msg-1",
role: "user",
parts: [{ type: "text", text: "Confidential" }],
},
ownerUserId,
conversationId,
);

deps = createDepsWithAgents([createAgentWithMemory(agentId, "Agent One", memory)]);
});

it("rejects reading a conversation owned by a different authenticated user", async () => {
const result = await handleGetMemoryConversation(deps, conversationId, {
agentId,
requestingUserId: otherUserId,
});

expect(result.success).toBe(false);
expect(result.httpStatus).toBe(403);
});

it("rejects reading a conversation when the authenticated identity is empty", async () => {
const result = await handleGetMemoryConversation(deps, conversationId, {
agentId,
requestingUserId: "",
});

expect(result.success).toBe(false);
expect(result.httpStatus).toBe(403);
});

it("lists conversations for the authenticated user instead of a client-supplied userId", async () => {
const result = await handleListMemoryConversations(deps, {
agentId,
userId: ownerUserId,
requestingUserId: otherUserId,
});

expect(result.success).toBe(true);
if (!result.success) return;
expect(result.data.total).toBe(1);
expect(result.data.conversations).toHaveLength(1);
expect(result.data.conversations[0]?.id).toBe(otherConversationId);
expect(result.data.conversations[0]?.userId).toBe(otherUserId);
});

it("rejects listing messages for a conversation owned by a different authenticated user", async () => {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
const result = await handleListMemoryConversationMessages(deps, conversationId, {
agentId,
requestingUserId: otherUserId,
});

expect(result.success).toBe(false);
expect(result.httpStatus).toBe(403);
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

it("rejects updating a conversation owned by a different authenticated user", async () => {
const result = await handleUpdateMemoryConversation(deps, conversationId, {
agentId,
requestingUserId: otherUserId,
title: "Bob title",
} as any);

expect(result.success).toBe(false);
expect(result.httpStatus).toBe(403);

const conversation = await memory.getConversation(conversationId);
expect(conversation?.title).toBe("Alice Private");
});

it("returns forbidden when a guarded storage update no longer affects the owner", async () => {
const updateSpy = vi
.spyOn(memory, "updateConversation")
.mockRejectedValueOnce(new ConversationOwnershipMismatchError(conversationId));

const result = await handleUpdateMemoryConversation(deps, conversationId, {
agentId,
requestingUserId: ownerUserId,
title: "Updated title",
});

expect(result.success).toBe(false);
expect(result.httpStatus).toBe(403);
expect(updateSpy).toHaveBeenCalledWith(
conversationId,
{ title: "Updated title" },
{ expectedUserId: ownerUserId },
);
});

it("rejects deleting a conversation owned by a different authenticated user", async () => {
const result = await handleDeleteMemoryConversation(deps, conversationId, {
agentId,
requestingUserId: otherUserId,
});

expect(result.success).toBe(false);
expect(result.httpStatus).toBe(403);

await expect(memory.getConversation(conversationId)).resolves.not.toBeNull();
});

it("returns forbidden when a guarded storage delete no longer affects the owner", async () => {
const deleteSpy = vi
.spyOn(memory, "deleteConversation")
.mockRejectedValueOnce(new ConversationOwnershipMismatchError(conversationId));

const result = await handleDeleteMemoryConversation(deps, conversationId, {
agentId,
requestingUserId: ownerUserId,
});

expect(result.success).toBe(false);
expect(result.httpStatus).toBe(403);
expect(deleteSpy).toHaveBeenCalledWith(conversationId, { expectedUserId: ownerUserId });
});

it("allows the owning authenticated user to manage the conversation", async () => {
const result = await handleGetMemoryConversation(deps, conversationId, {
agentId,
requestingUserId: ownerUserId,
});

expect(result.success).toBe(true);
if (!result.success) return;
expect(result.data.conversation.userId).toBe(ownerUserId);
});
});
Loading