From 291fdbb58945a421d67ed3a84372533dddf785eb Mon Sep 17 00:00:00 2001 From: Marcus Chandra Date: Mon, 12 Oct 2026 12:37:27 -0700 Subject: [PATCH 1/5] feat(memory): authorize scoped access and durable cleanup --- .../api/mothership/memories/access/route.ts | 25 + .../contracts/mothership-assistant-tools.ts | 26 +- .../lib/api/contracts/mothership-memories.ts | 9 + apps/sim/lib/core/outbox/handlers.ts | 10 + .../lib/data-drains/sources/copilot-chats.ts | 32 +- .../sources/coverage.integration.ts | 75 +++ .../lib/mothership/chat/persisted-message.ts | 4 +- .../lib/mothership/generated/memory-access.ts | 58 ++ .../sim-assistant-tools.generated.ts | 26 +- .../lib/mothership/generated/sim-transport.ts | 2 + .../application/resolve-access.integration.ts | 538 ++++++++++++++++++ .../memories/application/resolve-access.ts | 254 +++++++++ .../lib/mothership/memories/cleanup-event.ts | 1 + .../lib/mothership/memories/cleanup-outbox.ts | 85 +++ .../memories/cleanup.integration.ts | 156 +++++ apps/sim/lib/mothership/transport/control.ts | 23 +- .../files/application/authorization.ts | 16 +- apps/sim/lib/users/account-deletion.ts | 7 + 18 files changed, 1327 insertions(+), 20 deletions(-) create mode 100644 apps/sim/app/api/mothership/memories/access/route.ts create mode 100644 apps/sim/lib/api/contracts/mothership-memories.ts create mode 100644 apps/sim/lib/mothership/generated/memory-access.ts create mode 100644 apps/sim/lib/mothership/memories/application/resolve-access.integration.ts create mode 100644 apps/sim/lib/mothership/memories/application/resolve-access.ts create mode 100644 apps/sim/lib/mothership/memories/cleanup-event.ts create mode 100644 apps/sim/lib/mothership/memories/cleanup-outbox.ts create mode 100644 apps/sim/lib/mothership/memories/cleanup.integration.ts diff --git a/apps/sim/app/api/mothership/memories/access/route.ts b/apps/sim/app/api/mothership/memories/access/route.ts new file mode 100644 index 00000000000..d9c1ac59e3e --- /dev/null +++ b/apps/sim/app/api/mothership/memories/access/route.ts @@ -0,0 +1,25 @@ +import { resolveMemoryAccessContract } from '@/lib/api/contracts/mothership-memories' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, +} from '@/lib/api/server/routes' +import { internalCopilotAuth } from '@/lib/mothership/auth/internal' +import { + MEMORY_ACCESS_AUDIENCE, + resolveMemoryAccess, + resolveMemoryAccessOperation, +} from '@/lib/mothership/memories/application/resolve-access' + +export const POST = defineInternalJsonRoute({ + contract: resolveMemoryAccessContract, + auth: internalCopilotAuth(MEMORY_ACCESS_AUDIENCE, { organization: true }), + operation: resolveMemoryAccessOperation, + rateLimit: internalRateLimits.none({ + reason: + 'Private worker preflight rechecks live memory authority without reading or writing note content.', + }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ body }) => body, + useCase: resolveMemoryAccess, +}) diff --git a/apps/sim/lib/api/contracts/mothership-assistant-tools.ts b/apps/sim/lib/api/contracts/mothership-assistant-tools.ts index 60a9db7177d..50e5cc740c0 100644 --- a/apps/sim/lib/api/contracts/mothership-assistant-tools.ts +++ b/apps/sim/lib/api/contracts/mothership-assistant-tools.ts @@ -404,12 +404,26 @@ export const listWorkspacesResultSchema = z.object({ nextCursor: z.uuid().nullable(), storedNotes: z .array( - z.object({ - id: z.string(), - kind: z.enum(['user_pref', 'workspace_fact']), - content: z.string(), - updatedAt: z.iso.datetime(), - }) + z + .object({ + id: z.string(), + content: z.string(), + updatedAt: z.iso.datetime(), + by: z.enum(['this user', 'another workspace member', 'another user']).optional(), + chat: z.enum(['this chat', 'another chat']).optional(), + }) + .and( + z.union([ + z.object({ kind: z.enum(['user_pref', 'workspace_fact']) }), + z.object({ + scope: z.enum(['personal', 'project', 'workspace']), + visibility: z.enum(['private', 'shared']), + projectId: z.uuid().nullable(), + workspaceId: z.uuid().nullable(), + revision: z.number().int().positive(), + }), + ]) + ) ) .optional(), }) diff --git a/apps/sim/lib/api/contracts/mothership-memories.ts b/apps/sim/lib/api/contracts/mothership-memories.ts new file mode 100644 index 00000000000..6423a606db2 --- /dev/null +++ b/apps/sim/lib/api/contracts/mothership-memories.ts @@ -0,0 +1,9 @@ +import { defineRouteContract } from '@/lib/api/contracts/types' +import { MemoryAccessInput, MemoryAccessResult } from '@/lib/mothership/generated/memory-access' + +export const resolveMemoryAccessContract = defineRouteContract({ + method: 'POST', + path: '/api/mothership/memories/access', + body: MemoryAccessInput, + response: { mode: 'json', schema: MemoryAccessResult }, +}) diff --git a/apps/sim/lib/core/outbox/handlers.ts b/apps/sim/lib/core/outbox/handlers.ts index 433c908200c..77c088f6b52 100644 --- a/apps/sim/lib/core/outbox/handlers.ts +++ b/apps/sim/lib/core/outbox/handlers.ts @@ -33,6 +33,7 @@ import { import { KNOWLEDGE_DOCUMENT_RECOVERY_OUTBOX_EVENT } from '@/lib/knowledge/documents/processing-recovery-event' import { KNOWLEDGE_STORAGE_CLEANUP_EVENT } from '@/lib/knowledge/documents/storage-cleanup-event' import { INBOX_CLEANUP_EVENT } from '@/lib/mothership/inbox/cleanup-event' +import { MEMORY_CLEANUP_EVENT } from '@/lib/mothership/memories/cleanup-event' import { ORGANIZATION_RESOURCE_CLEANUP_EVENT } from '@/lib/organizations/resource-cleanup-event' import { PROJECT_FILE_DOCUMENT_RETIRE_EVENT, @@ -68,6 +69,15 @@ import { FORK_CONTENT_COPY_EVENT } from '@/ee/workspace-forking/application/cont * export of the module as used. */ export const OUTBOX_HANDLER_GROUPS: readonly LazyOutboxHandlerGroup[] = [ + { + events: [MEMORY_CLEANUP_EVENT], + load: async () => { + const { memoryCleanupOutboxHandlers } = await import( + '@/lib/mothership/memories/cleanup-outbox' + ) + return memoryCleanupOutboxHandlers + }, + }, { events: [SLACK_SEARCH_TURN_EVENT], load: async () => { diff --git a/apps/sim/lib/data-drains/sources/copilot-chats.ts b/apps/sim/lib/data-drains/sources/copilot-chats.ts index 636bfaedd9e..79e29d46fcd 100644 --- a/apps/sim/lib/data-drains/sources/copilot-chats.ts +++ b/apps/sim/lib/data-drains/sources/copilot-chats.ts @@ -1,5 +1,7 @@ import { copilotChats, copilotMessages } from '@sim/db/schema' +import { isRecordLike, omit } from '@sim/utils/object' import { and, asc, eq, inArray, isNull, or, sql } from 'drizzle-orm' +import { persistedContentBlockSchema } from '@/lib/api/contracts/copilot-messages' import { assertKnownSizeWithinLimit } from '@/lib/core/utils/stream-limits' import { DATA_DRAIN_LIMITS } from '@/lib/data-drains/limits' import { @@ -11,6 +13,7 @@ import { } from '@/lib/data-drains/sources/cursor' import { readBoundedSourcePage, workspaceInOrganization } from '@/lib/data-drains/sources/helpers' import type { Cursor, DrainSource, SourcePageInput } from '@/lib/data-drains/types' +import { stripToolResultOutput } from '@/lib/mothership/chat/persisted-message' /** * The transcript no longer lives on `copilot_chats.messages` — it is assembled @@ -141,6 +144,33 @@ async function* pages(input: SourcePageInput): AsyncIterable { } } +/** Tool payloads can contain private notes even when their mutation failed or targeted a shared note. */ +function projectExportMessage(message: unknown): unknown { + if (!isRecordLike(message) || !Array.isArray(message.contentBlocks)) return message + return { + ...message, + contentBlocks: message.contentBlocks.map((block: unknown) => { + if (!isRecordLike(block) || !isRecordLike(block.toolCall)) return block + if (block.toolCall.name !== 'remember') { + if (!isRecordLike(block.toolCall.result) || !('output' in block.toolCall.result)) + return block + const parsed = persistedContentBlockSchema.safeParse(block) + if (!parsed.success) return { ...block, toolCall: omit(block.toolCall, ['result']) } + const projected = stripToolResultOutput({ contentBlocks: [parsed.data] }).contentBlocks[0] + return { ...block, toolCall: { ...block.toolCall, result: projected.toolCall?.result } } + } + return { + type: 'tool', + toolCall: { + name: 'remember', + ...(typeof block.toolCall.id === 'string' ? { id: block.toolCall.id } : {}), + ...(typeof block.toolCall.state === 'string' ? { state: block.toolCall.state } : {}), + }, + } + }), + } +} + export const copilotChatsSource: DrainSource = { type: 'copilot_chats', displayName: 'Chats', @@ -153,7 +183,7 @@ export const copilotChatsSource: DrainSource = { workspaceId: row.workspaceId, type: row.type, title: row.title, - messages: row.messages, + messages: row.messages.map(projectExportMessage), model: row.model, conversationId: row.conversationId, previewYaml: row.previewYaml, diff --git a/apps/sim/lib/data-drains/sources/coverage.integration.ts b/apps/sim/lib/data-drains/sources/coverage.integration.ts index e22770f9cd5..ea9bcf05f88 100644 --- a/apps/sim/lib/data-drains/sources/coverage.integration.ts +++ b/apps/sim/lib/data-drains/sources/coverage.integration.ts @@ -113,6 +113,81 @@ describe('Drain source coverage and payload safety in PostgreSQL', () => { expect(runs).toEqual([runId]) }) + it('omits memory payloads from exports without changing owner history or visible replies', async () => { + const messageId = generateId() + const content = { + role: 'assistant', + content: 'Visible reply', + contentBlocks: [ + { type: 'text', content: 'Visible reply' }, + { + type: 'tool', + toolCall: { + id: 'discovery', + name: 'list_workspaces', + state: 'success', + result: { + success: true, + output: { + stdout: JSON.stringify({ storedNotes: [{ content: 'hidden-memory-discovery' }] }), + }, + }, + }, + }, + ...['private', 'shared', 'unknown'].map((visibility) => ({ + type: 'tool', + content: 'hidden-memory-content', + error: 'hidden-memory-error', + toolCall: { + id: visibility, + name: 'remember', + state: 'success', + params: { visibility, content: 'hidden-memory-args' }, + result: { success: true, output: 'hidden-memory-result' }, + activityDescription: 'hidden-memory-activity', + display: { title: 'hidden-memory-title' }, + }, + })), + { + type: 'tool', + toolCall: { + id: 'other', + name: 'read', + state: 'success', + params: { path: 'visible-path' }, + }, + }, + ], + } + await runtime.db.insert(runtime.schema.copilotMessages).values({ + id: messageId, + messageId, + chatId, + role: 'assistant', + content, + createdAt: old, + }) + try { + const exported: unknown[] = [] + for await (const rows of runtime.copilotChatsSource.pages(input())) { + exported.push(...rows.map((row) => runtime.copilotChatsSource.serialize(row))) + } + const wire = JSON.stringify(exported) + expect(wire).not.toContain('hidden-memory') + expect(wire).toContain('Visible reply') + expect(wire).toContain('visible-path') + const [stored] = await runtime.db + .select({ content: runtime.schema.copilotMessages.content }) + .from(runtime.schema.copilotMessages) + .where(runtime.eq(runtime.schema.copilotMessages.id, messageId)) + expect(stored.content).toEqual(content) + } finally { + await runtime.db + .delete(runtime.schema.copilotMessages) + .where(runtime.eq(runtime.schema.copilotMessages.id, messageId)) + } + }) + it('exports correlation metadata without internal controller tokens or billing receipts', async () => { const [row] = await runtime.db .select() diff --git a/apps/sim/lib/mothership/chat/persisted-message.ts b/apps/sim/lib/mothership/chat/persisted-message.ts index fbf1a76af8a..cd1a5f34528 100644 --- a/apps/sim/lib/mothership/chat/persisted-message.ts +++ b/apps/sim/lib/mothership/chat/persisted-message.ts @@ -141,7 +141,9 @@ export interface PersistedMessage { * reference when there is nothing to strip, preserving memoized identity for * read-side consumers. */ -export function stripToolResultOutput(message: PersistedMessage): PersistedMessage { +export function stripToolResultOutput>( + message: T +): T { if (!message.contentBlocks?.length) return message let changed = false const contentBlocks = message.contentBlocks.map((block) => { diff --git a/apps/sim/lib/mothership/generated/memory-access.ts b/apps/sim/lib/mothership/generated/memory-access.ts new file mode 100644 index 00000000000..0a06c2384ab --- /dev/null +++ b/apps/sim/lib/mothership/generated/memory-access.ts @@ -0,0 +1,58 @@ +// GENERATED — do not edit. Source of truth: mothership worker packages/contracts/src/memory-access.ts +// Regenerate with `bun run contracts:sync` in the worker. + +import { z } from "zod"; + +const permissionsSchema = z.strictObject({ + read: z.literal(true), + privateMutation: z.literal(true), + sharedMutation: z.boolean(), +}); +const projectSchema = z.strictObject({ + scope: z.literal("project"), + id: z.uuid(), + name: z.string(), + permissions: permissionsSchema, +}); + +export const MemoryAccessInput = z.strictObject({ + target: z.discriminatedUnion("scope", [ + z.strictObject({ scope: z.literal("personal") }), + z.strictObject({ scope: z.literal("workspace"), workspaceId: z.uuid() }), + z.strictObject({ scope: z.literal("project"), projectId: z.uuid() }), + ]), + action: z.enum(["read", "private_mutation", "shared_mutation"]), +}); +export type MemoryAccessInput = z.infer; + +export const MemoryAccessResult = z.strictObject({ + success: z.literal(true), + version: z.literal(1), + actor: z.strictObject({ userId: z.string().min(1), chatId: z.uuid() }), + target: z.discriminatedUnion("scope", [ + z.strictObject({ + scope: z.literal("personal"), + permissions: permissionsSchema.extend({ sharedMutation: z.literal(false) }), + }), + projectSchema, + z.strictObject({ + scope: z.literal("workspace"), + id: z.uuid(), + name: z.string(), + permissions: permissionsSchema, + currentProject: projectSchema.nullable(), + }), + ]), +}); +export type MemoryAccessResult = z.infer; + +export const MemoryCleanupInput = z.strictObject({ + version: z.literal(1), + target: z.discriminatedUnion("kind", [ + z.strictObject({ kind: z.literal("private_owner"), userId: z.string().min(1).max(200) }), + z.strictObject({ kind: z.literal("workspace"), workspaceId: z.uuid() }), + z.strictObject({ kind: z.literal("project"), projectId: z.uuid() }), + ]), +}); +export type MemoryCleanupInput = z.infer; +export const MemoryCleanupResult = z.strictObject({ success: z.literal(true) }); diff --git a/apps/sim/lib/mothership/generated/sim-assistant-tools.generated.ts b/apps/sim/lib/mothership/generated/sim-assistant-tools.generated.ts index f09e343936b..2fe72fdb685 100644 --- a/apps/sim/lib/mothership/generated/sim-assistant-tools.generated.ts +++ b/apps/sim/lib/mothership/generated/sim-assistant-tools.generated.ts @@ -424,12 +424,26 @@ export const listWorkspacesResultSchema = z.object({ nextCursor: z.uuid().nullable(), storedNotes: z .array( - z.object({ - id: z.string(), - kind: z.enum(['user_pref', 'workspace_fact']), - content: z.string(), - updatedAt: z.iso.datetime(), - }) + z + .object({ + id: z.string(), + content: z.string(), + updatedAt: z.iso.datetime(), + by: z.enum(['this user', 'another workspace member', 'another user']).optional(), + chat: z.enum(['this chat', 'another chat']).optional(), + }) + .and( + z.union([ + z.object({ kind: z.enum(['user_pref', 'workspace_fact']) }), + z.object({ + scope: z.enum(['personal', 'project', 'workspace']), + visibility: z.enum(['private', 'shared']), + projectId: z.uuid().nullable(), + workspaceId: z.uuid().nullable(), + revision: z.number().int().positive(), + }), + ]) + ) ) .optional(), }) diff --git a/apps/sim/lib/mothership/generated/sim-transport.ts b/apps/sim/lib/mothership/generated/sim-transport.ts index 2bbdaf3afd4..9dfc3b293bc 100644 --- a/apps/sim/lib/mothership/generated/sim-transport.ts +++ b/apps/sim/lib/mothership/generated/sim-transport.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import { IntegrationCatalogRequest } from "./integration-catalog"; +import { MemoryAccessInput } from "./memory-access"; import { MemoryScopeRequest } from "./memory-scope"; import { RunControlRequest } from "./run-control"; import { TaskWakeRequest, WorkflowWatchRequest } from "./tasks"; @@ -29,6 +30,7 @@ export type SimScope = z.infer; /** Only idempotent controls use the idle channel; tool effects use the run event log. */ export const SimControlOperation = z.discriminatedUnion("kind", [ + z.object({ kind: z.literal("memory_access"), input: MemoryAccessInput }), z.object({ kind: z.literal("memory_scope"), input: MemoryScopeRequest }), z.object({ kind: z.literal("run_control"), input: RunControlRequest }), z.object({ kind: z.literal("workflow_status"), input: WorkflowWatchRequest }), diff --git a/apps/sim/lib/mothership/memories/application/resolve-access.integration.ts b/apps/sim/lib/mothership/memories/application/resolve-access.integration.ts new file mode 100644 index 00000000000..0ab22cb1d09 --- /dev/null +++ b/apps/sim/lib/mothership/memories/application/resolve-access.integration.ts @@ -0,0 +1,538 @@ +import { db } from '@sim/db' +import { + copilotChats, + member, + organization, + permissionGroup, + permissionGroupWorkspace, + permissions, + project, + user, + workspace, +} from '@sim/db/schema' +import { deleteWorkspaceFixture, insertWorkspaceFixture } from '@sim/db/testing/workspace-fixtures' +import { featureFlagsMock, featureFlagsMockFns } from '@sim/testing/mocks/feature-flags.mock' +import { generateId } from '@sim/utils/id' +import { and, eq, inArray } from 'drizzle-orm' +import { NextRequest } from 'next/server' +import { afterAll, beforeAll, beforeEach, expect, it, vi } from 'vitest' +import { + createTrustedCopilotPrincipal, + createTrustedOrganizationCopilotPrincipal, +} from '@/lib/mothership/auth/application-delegation' +import { MemoryAccessResult } from '@/lib/mothership/generated/memory-access' +import { + MEMORY_ACCESS_AUDIENCE, + resolveMemoryAccess, +} from '@/lib/mothership/memories/application/resolve-access' +import { executeSimControl } from '@/lib/mothership/transport/control' +import { splitForkProject } from '@/lib/projects/membership' +import { POST } from '@/app/api/mothership/memories/access/route' + +vi.mock('@/lib/core/config/feature-flags', () => featureFlagsMock) + +beforeEach(() => { + featureFlagsMockFns.mockIsFeatureEnabled.mockImplementation(async (flag) => flag === 'projects') +}) + +const actorId = generateId() +const payerId = generateId() +const organizationId = generateId() +const projectId = generateId() +const workspaceId = generateId() +const siblingId = generateId() +const workspaceChatId = generateId() +const organizationChatId = generateId() + +function principal(organizationChat = false) { + const options = { audience: MEMORY_ACCESS_AUDIENCE, ttlMs: 60_000 } + return organizationChat + ? createTrustedOrganizationCopilotPrincipal( + { userId: actorId, organizationId, chatId: organizationChatId, delegationId: generateId() }, + options + ) + : createTrustedCopilotPrincipal( + { userId: actorId, workspaceId, chatId: workspaceChatId, delegationId: generateId() }, + options + ) +} + +beforeAll(async () => { + await db.insert(user).values( + [actorId, payerId].map((id) => ({ + id, + name: 'Memory access fixture', + email: `${id}@memory.test`, + emailVerified: true, + createdAt: new Date(), + updatedAt: new Date(), + })) + ) + await db.insert(organization).values({ + id: organizationId, + name: 'Memory fixture', + slug: organizationId, + createdAt: new Date(), + }) + await db.insert(member).values([ + { id: generateId(), organizationId, userId: payerId, role: 'owner', createdAt: new Date() }, + { id: generateId(), organizationId, userId: actorId, role: 'member', createdAt: new Date() }, + ]) + await insertWorkspaceFixture( + db, + [workspaceId, siblingId].map((id) => ({ + id, + name: 'Environment', + ownerId: payerId, + billedAccountUserId: payerId, + organizationId, + workspaceMode: 'organization' as const, + projectId, + })) + ) + await db.insert(permissions).values({ + id: generateId(), + userId: actorId, + entityType: 'workspace', + entityId: workspaceId, + permissionType: 'read', + }) + await db.insert(copilotChats).values([ + { + id: workspaceChatId, + userId: actorId, + workspaceId, + type: 'mothership', + config: { conversationMode: 'agent' }, + }, + { + id: organizationChatId, + userId: actorId, + organizationId, + type: 'mothership', + config: { conversationMode: 'agent' }, + }, + ]) +}) + +afterAll(async () => { + await db + .delete(copilotChats) + .where(inArray(copilotChats.id, [workspaceChatId, organizationChatId])) + await deleteWorkspaceFixture(db, inArray(workspace.id, [workspaceId, siblingId])) + await db.delete(project).where(eq(project.id, projectId)) + await db.delete(organization).where(eq(organization.id, organizationId)) + await db.delete(user).where(inArray(user.id, [actorId, payerId])) + await db.$client.end() +}) + +it('denies every Project memory action on both transports when projects is disabled', async () => { + featureFlagsMockFns.mockIsFeatureEnabled.mockResolvedValue(false) + for (const organizationChat of [false, true]) { + const chatId = organizationChat ? organizationChatId : workspaceChatId + for (const action of ['read', 'private_mutation', 'shared_mutation'] as const) { + const input = { target: { scope: 'project' as const, projectId }, action } + const response = await POST( + new NextRequest('http://localhost/api/mothership/memories/access', { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-api-key': 'isolated-integration-fixture-internal-secret', + 'x-mothership-user-id': actorId, + 'x-mothership-chat-id': chatId, + ...(organizationChat + ? { 'x-mothership-organization-id': organizationId } + : { 'x-mothership-workspace-id': workspaceId }), + }, + body: JSON.stringify(input), + }) + ) + expect(response.status).toBe(503) + const checkpoint = await executeSimControl({ + id: generateId(), + expiresAt: Date.now() + 60_000, + scope: { + userId: actorId, + chatId, + ...(organizationChat ? { organizationId } : { workspaceId }), + }, + operation: { kind: 'memory_access', input }, + }) + expect(checkpoint.status).toBe(503) + expect(JSON.parse(checkpoint.body)).toEqual({ + error: 'Projects are not enabled on this deployment', + }) + } + } +}) + +it('keeps personal and workspace memories usable without Project context when projects is disabled', async () => { + featureFlagsMockFns.mockIsFeatureEnabled.mockResolvedValue(false) + for (const organizationChat of [false, true]) { + for (const action of ['read', 'private_mutation'] as const) { + const personal = await resolveMemoryAccess.execute({ + principal: principal(organizationChat), + input: { target: { scope: 'personal' }, action }, + }) + expect(personal.target).toEqual({ + scope: 'personal', + permissions: { read: true, privateMutation: true, sharedMutation: false }, + }) + const result = await resolveMemoryAccess.execute({ + principal: principal(organizationChat), + input: { target: { scope: 'workspace', workspaceId }, action }, + }) + expect(MemoryAccessResult.parse(result).target).toEqual({ + scope: 'workspace', + id: workspaceId, + name: 'Environment', + permissions: { read: true, privateMutation: true, sharedMutation: false }, + currentProject: null, + }) + } + await expect( + resolveMemoryAccess.execute({ + principal: principal(organizationChat), + input: { target: { scope: 'workspace', workspaceId }, action: 'shared_mutation' }, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + } + await db + .update(permissions) + .set({ permissionType: 'write' }) + .where(and(eq(permissions.userId, actorId), eq(permissions.entityId, workspaceId))) + try { + for (const organizationChat of [false, true]) { + const result = await resolveMemoryAccess.execute({ + principal: principal(organizationChat), + input: { target: { scope: 'workspace', workspaceId }, action: 'shared_mutation' }, + }) + expect(result.target).toMatchObject({ + currentProject: null, + permissions: { sharedMutation: true }, + }) + } + } finally { + await db + .update(permissions) + .set({ permissionType: 'read' }) + .where(and(eq(permissions.userId, actorId), eq(permissions.entityId, workspaceId))) + } +}) + +it('lets a read-only end user mutate private notes through either chat and keeps Project shared writes denied', async () => { + for (const organizationChat of [false, true]) { + const result = await resolveMemoryAccess.execute({ + principal: principal(organizationChat), + input: { target: { scope: 'workspace', workspaceId }, action: 'private_mutation' }, + }) + expect(result.actor.userId).toBe(actorId) + expect(result.target).toMatchObject({ + scope: 'workspace', + id: workspaceId, + permissions: { privateMutation: true, sharedMutation: false }, + currentProject: { id: projectId, permissions: { read: true, sharedMutation: false } }, + }) + await expect( + resolveMemoryAccess.execute({ + principal: principal(organizationChat), + input: { target: { scope: 'project', projectId }, action: 'shared_mutation' }, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + } +}) + +it('does not turn write in one environment into shared Project write', async () => { + await db + .update(permissions) + .set({ permissionType: 'write' }) + .where(and(eq(permissions.userId, actorId), eq(permissions.entityId, workspaceId))) + try { + const result = await resolveMemoryAccess.execute({ + principal: principal(), + input: { target: { scope: 'workspace', workspaceId }, action: 'shared_mutation' }, + }) + expect(result.target).toMatchObject({ + permissions: { sharedMutation: true }, + currentProject: { permissions: { sharedMutation: false } }, + }) + await db.insert(permissions).values({ + id: generateId(), + userId: actorId, + entityType: 'workspace', + entityId: siblingId, + permissionType: 'write', + }) + const shared = await resolveMemoryAccess.execute({ + principal: principal(true), + input: { target: { scope: 'project', projectId }, action: 'shared_mutation' }, + }) + expect(shared.target.permissions.sharedMutation).toBe(true) + } finally { + await db + .delete(permissions) + .where(and(eq(permissions.userId, actorId), eq(permissions.entityId, siblingId))) + await db + .update(permissions) + .set({ permissionType: 'read' }) + .where(and(eq(permissions.userId, actorId), eq(permissions.entityId, workspaceId))) + } +}) + +it('rejects wrong actor, audience, expired delegation and another workspace target', async () => { + const input = { target: { scope: 'workspace' as const, workspaceId }, action: 'read' as const } + for (const altered of [ + { ...principal(), subjectUserId: payerId }, + { ...principal(), audience: 'sim:workspaces' }, + { ...principal(), expiresAt: new Date(0) }, + ]) + await expect(resolveMemoryAccess.execute({ principal: altered, input })).rejects.toBeInstanceOf( + Error + ) + await expect( + resolveMemoryAccess.execute({ + principal: principal(), + input: { ...input, target: { scope: 'workspace', workspaceId: siblingId } }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) +}) + +it('denies a lost scoped grant while personal-only organization context remains usable', async () => { + await db + .delete(permissions) + .where(and(eq(permissions.userId, actorId), eq(permissions.entityId, workspaceId))) + try { + await expect( + resolveMemoryAccess.execute({ + principal: principal(true), + input: { target: { scope: 'project', projectId }, action: 'private_mutation' }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + const result = await resolveMemoryAccess.execute({ + principal: principal(true), + input: { target: { scope: 'personal' }, action: 'private_mutation' }, + }) + expect(result.actor.userId).toBe(actorId) + expect(result.target.scope).toBe('personal') + } finally { + await db.insert(permissions).values({ + id: generateId(), + userId: actorId, + entityType: 'workspace', + entityId: workspaceId, + permissionType: 'read', + }) + } +}) + +it('applies the Files capability to shared Project writes without blocking private notes', async () => { + const groupId = generateId() + await db + .update(permissions) + .set({ permissionType: 'write' }) + .where(and(eq(permissions.userId, actorId), eq(permissions.entityId, workspaceId))) + await db.insert(permissions).values({ + id: generateId(), + userId: actorId, + entityType: 'workspace', + entityId: siblingId, + permissionType: 'write', + }) + await db.insert(permissionGroup).values({ + id: groupId, + organizationId, + createdBy: payerId, + name: 'Files denied', + config: { hideFilesTab: true }, + membershipMode: 'inherit', + }) + await db.insert(permissionGroupWorkspace).values({ + id: generateId(), + permissionGroupId: groupId, + workspaceId: siblingId, + organizationId, + }) + try { + const result = await resolveMemoryAccess.execute({ + principal: principal(true), + input: { target: { scope: 'project', projectId }, action: 'private_mutation' }, + }) + expect(result.target.permissions).toEqual({ + read: true, + privateMutation: true, + sharedMutation: false, + }) + await expect( + resolveMemoryAccess.execute({ + principal: principal(true), + input: { target: { scope: 'project', projectId }, action: 'shared_mutation' }, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + } finally { + await db.delete(permissionGroup).where(eq(permissionGroup.id, groupId)) + await db + .delete(permissions) + .where(and(eq(permissions.userId, actorId), eq(permissions.entityId, siblingId))) + await db + .update(permissions) + .set({ permissionType: 'read' }) + .where(and(eq(permissions.userId, actorId), eq(permissions.entityId, workspaceId))) + } +}) + +it('rejects cross-organization authority and organization non-agent mode', async () => { + const otherId = generateId() + await db + .insert(organization) + .values({ id: otherId, name: 'Other organization', slug: otherId, createdAt: new Date() }) + await db.update(member).set({ organizationId: otherId }).where(eq(member.userId, actorId)) + await db + .update(copilotChats) + .set({ organizationId: otherId }) + .where(eq(copilotChats.id, organizationChatId)) + try { + const otherPrincipal = { ...principal(true), organizationId: otherId } + await expect( + resolveMemoryAccess.execute({ + principal: otherPrincipal, + input: { target: { scope: 'project', projectId }, action: 'read' }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + await expect( + resolveMemoryAccess.execute({ + principal: otherPrincipal, + input: { target: { scope: 'workspace', workspaceId }, action: 'read' }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + } finally { + await db + .update(copilotChats) + .set({ organizationId }) + .where(eq(copilotChats.id, organizationChatId)) + await db.update(member).set({ organizationId }).where(eq(member.userId, actorId)) + await db.delete(organization).where(eq(organization.id, otherId)) + } + await db + .update(copilotChats) + .set({ config: { conversationMode: 'assistant' } }) + .where(eq(copilotChats.id, organizationChatId)) + try { + await expect( + resolveMemoryAccess.execute({ + principal: principal(true), + input: { target: { scope: 'project', projectId }, action: 'read' }, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + } finally { + await db + .update(copilotChats) + .set({ config: { conversationMode: 'agent' } }) + .where(eq(copilotChats.id, organizationChatId)) + } +}) + +it('serves the typed route using authenticated headers and rejects body identity overrides', async () => { + const input = { target: { scope: 'workspace', workspaceId }, action: 'private_mutation' } + const request = (body: unknown, authenticated = true) => + new NextRequest('http://localhost/api/mothership/memories/access', { + method: 'POST', + headers: { + 'content-type': 'application/json', + ...(authenticated ? { 'x-api-key': 'isolated-integration-fixture-internal-secret' } : {}), + 'x-mothership-user-id': actorId, + 'x-mothership-workspace-id': workspaceId, + 'x-mothership-chat-id': workspaceChatId, + }, + body: JSON.stringify(body), + }) + expect((await POST(request(input, false))).status).toBe(401) + expect((await POST(request({ ...input, userId: payerId }))).status).toBe(400) + expect( + (await POST(request({ ...input, target: { scope: 'workspace', workspaceId, projectId } }))) + .status + ).toBe(400) + for (const projectsEnabled of [true, false, true]) { + featureFlagsMockFns.mockIsFeatureEnabled.mockImplementation( + async (flag) => flag === 'projects' && projectsEnabled + ) + const response = await POST(request(input)) + expect(response.status).toBe(200) + expect(MemoryAccessResult.parse(await response.json())).toMatchObject({ + success: true, + version: 1, + actor: { userId: actorId, chatId: workspaceChatId }, + target: { currentProject: projectsEnabled ? { id: projectId } : null }, + }) + } +}) + +it('resolves detach to the new Project and rejects the former Project from the workspace chat', async () => { + const detachedProject = await db.transaction(async (tx) => { + const id = await splitForkProject(tx, workspaceId) + await tx + .update(workspace) + .set({ forkedFromWorkspaceId: null }) + .where(eq(workspace.id, workspaceId)) + return id + }) + try { + const result = await resolveMemoryAccess.execute({ + principal: principal(), + input: { target: { scope: 'workspace', workspaceId }, action: 'read' }, + }) + expect(result.target).toMatchObject({ currentProject: { id: detachedProject } }) + await expect( + resolveMemoryAccess.execute({ + principal: principal(), + input: { target: { scope: 'project', projectId }, action: 'read' }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + } finally { + await db.transaction(async (tx) => { + await tx.update(workspace).set({ projectId }).where(eq(workspace.id, workspaceId)) + await tx.delete(project).where(eq(project.id, detachedProject)) + }) + } +}) + +it('uses identical current authority over checkpoint transport for workspace and organization chats', async () => { + for (const [organizationChat, projectsEnabled] of [ + [false, true], + [false, false], + [true, true], + [true, false], + ]) { + featureFlagsMockFns.mockIsFeatureEnabled.mockImplementation( + async (flag) => flag === 'projects' && projectsEnabled + ) + const scope = { + userId: actorId, + chatId: organizationChat ? organizationChatId : workspaceChatId, + ...(organizationChat ? { organizationId } : { workspaceId }), + } + const result = await executeSimControl({ + id: generateId(), + expiresAt: Date.now() + 60_000, + scope, + operation: { + kind: 'memory_access', + input: { target: { scope: 'workspace', workspaceId }, action: 'private_mutation' }, + }, + }) + expect(result.status).toBe(200) + expect(MemoryAccessResult.parse(JSON.parse(result.body))).toMatchObject({ + actor: { userId: actorId, chatId: scope.chatId }, + target: { id: workspaceId, currentProject: projectsEnabled ? { id: projectId } : null }, + }) + const denied = await executeSimControl({ + id: generateId(), + expiresAt: Date.now() + 60_000, + scope, + operation: { + kind: 'memory_access', + input: { target: { scope: 'project', projectId }, action: 'shared_mutation' }, + }, + }) + expect(denied.status).toBe(projectsEnabled ? 403 : 503) + } +}) diff --git a/apps/sim/lib/mothership/memories/application/resolve-access.ts b/apps/sim/lib/mothership/memories/application/resolve-access.ts new file mode 100644 index 00000000000..b6b54417f0b --- /dev/null +++ b/apps/sim/lib/mothership/memories/application/resolve-access.ts @@ -0,0 +1,254 @@ +import type { OrganizationDelegatedPrincipal, SubjectDelegatedPrincipal } from '@sim/auth/principal' +import { db } from '@sim/db' +import { copilotChats, workspace } from '@sim/db/schema' +import { and, eq, isNull } from 'drizzle-orm' +import { defineWorkspaceOperation } from '@/lib/core/application' +import { authorizeOrganizationOperation } from '@/lib/core/application/organization-authorization' +import { defineOrganizationOperation } from '@/lib/core/application/organization-operation' +import { + InsufficientWorkspacePermissionsError, + requireCurrentHumanRole, +} from '@/lib/core/application/workspace-authorization' +import { isFeatureEnabled } from '@/lib/core/config/feature-flags' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import type { DbTransaction } from '@/lib/db/types' +import { defineAuthorizedChatUseCase } from '@/lib/mothership/chat/application/authorized-chat-use-case' +import { resolveOwnedChatContext } from '@/lib/mothership/chat/application/context' +import { conversationModeSelection } from '@/lib/mothership/chat/intent' +import type { + MemoryAccessInput, + MemoryAccessResult, +} from '@/lib/mothership/generated/memory-access' +import { assertWorkspaceCapability } from '@/lib/permission-groups/capability-assertions' +import { PermissionGroupCapabilityError } from '@/lib/permission-groups/capability-error' +import { acquirePermissionGroupOrgLock } from '@/lib/permission-groups/locks' +import { loadProjectAccess } from '@/lib/projects/application/authorization' +import { + requireCurrentCopilotProjectInvocation, + requireProjectFileOwnerCapabilities, + requireProjectFileOwnerRole, +} from '@/lib/projects/files/application/authorization' +import { lockWorkspaceProject } from '@/lib/projects/membership' +import { requireProjectApiEnabled } from '@/lib/projects/rollout.server' +import { requireCurrentFileSubject } from '@/lib/workspace-files/application/subject' + +export const MEMORY_ACCESS_AUDIENCE = 'sim:memory-access' +export const resolveMemoryAccessOperation = defineWorkspaceOperation({ + id: 'mothership.memories.resolve_access', + minimumRole: 'read', + workspaceApiKey: 'deny', + capability: 'copilot.use', + principalKinds: ['delegated'], + delegatedServices: ['copilot'], +}) +const organizationOperation = defineOrganizationOperation({ + id: resolveMemoryAccessOperation.id, + minimumRole: 'member', + capability: 'copilot.use', + principalKinds: ['organization_delegated'], + delegatedServices: ['copilot'], + delegationAudience: MEMORY_ACCESS_AUDIENCE, +}) +type MemoryPrincipal = SubjectDelegatedPrincipal | OrganizationDelegatedPrincipal + +async function workspaceAccess(tx: DbTransaction, userId: string, workspaceId: string) { + const [target] = await tx + .select({ + workspaceId: workspace.id, + name: workspace.name, + workspaceOrganizationId: workspace.organizationId, + allowPersonalApiKeys: workspace.allowPersonalApiKeys, + }) + .from(workspace) + .where(and(eq(workspace.id, workspaceId), isNull(workspace.archivedAt))) + .for('share') + .limit(1) + if (!target) throw new OrchestrationError('not_found', 'Workspace not found') + await requireCurrentHumanRole(userId, target, 'read', { executor: tx }) + await assertWorkspaceCapability( + userId, + workspaceId, + 'copilot.use', + target.workspaceOrganizationId, + tx + ) + let sharedMutation = true + try { + await requireCurrentHumanRole(userId, target, 'write', { executor: tx }) + } catch (error) { + if (!(error instanceof InsufficientWorkspacePermissionsError)) throw error + sharedMutation = false + } + return { target, sharedMutation } +} + +async function projectAccess( + tx: DbTransaction, + principal: MemoryPrincipal, + chatId: string, + projectId: string, + scope: { organizationId: string | null; workspaceId?: string } +): Promise> { + const access = await loadProjectAccess(tx, principal.subjectUserId, { + projectId, + ...(scope.organizationId ? { organizationId: scope.organizationId } : {}), + ...(scope.workspaceId ? { workspaceId: scope.workspaceId } : {}), + }) + if (scope.organizationId !== access.record.organizationId) + throw new OrchestrationError('not_found', 'Project not found in this conversation') + if (access.record.organizationId) + await acquirePermissionGroupOrgLock(tx, access.record.organizationId) + await requireCurrentCopilotProjectInvocation( + tx, + { + subjectUserId: principal.subjectUserId, + invocation: { kind: 'chat', chatId }, + }, + access, + scope + ) + const owner = await requireProjectFileOwnerRole(tx, principal, access, 'read') + let sharedMutation = owner.canWrite + if (sharedMutation) { + try { + await requireProjectFileOwnerCapabilities(tx, principal, access, 'files.use') + } catch (error) { + if (!(error instanceof PermissionGroupCapabilityError)) throw error + sharedMutation = false + } + } + return { + scope: 'project', + id: access.record.id, + name: access.record.name, + permissions: { read: true, privateMutation: true, sharedMutation }, + } +} + +/** Current Sim authority only; Mothership must re-resolve before each read or mutation. */ +const resolveAccess = defineAuthorizedChatUseCase({ + operation: resolveMemoryAccessOperation, + organizationOperation, + async resolveContext({ principal }: { principal: MemoryPrincipal; input: MemoryAccessInput }) { + const chatId = + principal.resourceScope && 'chatId' in principal.resourceScope + ? principal.resourceScope.chatId + : undefined + const now = Date.now() + if ( + principal.serviceId !== 'copilot' || + principal.audience !== MEMORY_ACCESS_AUDIENCE || + !chatId || + !Number.isFinite(principal.issuedAt.getTime()) || + !Number.isFinite(principal.expiresAt.getTime()) || + principal.issuedAt.getTime() > now || + principal.expiresAt.getTime() <= now || + principal.expiresAt.getTime() - principal.issuedAt.getTime() > 60_000 + ) + throw new OrchestrationError('forbidden', 'Memory delegation is no longer valid') + return resolveOwnedChatContext(principal, chatId) + }, + authorizationOptions: { + delegation: { + audience: MEMORY_ACCESS_AUDIENCE, + isWithinScope: (principal, context) => principal.resourceScope?.chatId === context.chatId, + }, + }, + async execute({ principal, context, input }): Promise { + return db.transaction(async (tx) => { + await requireCurrentFileSubject(tx, principal) + const [chat] = await tx + .select({ + userId: copilotChats.userId, + workspaceId: copilotChats.workspaceId, + organizationId: copilotChats.organizationId, + type: copilotChats.type, + mode: conversationModeSelection, + }) + .from(copilotChats) + .where(and(eq(copilotChats.id, context.chatId), isNull(copilotChats.deletedAt))) + .for('share') + .limit(1) + if ( + !chat || + chat.userId !== principal.subjectUserId || + chat.workspaceId !== (context.workspaceId ?? null) || + chat.organizationId !== (context.organizationId ?? null) || + (chat.organizationId && chat.type !== 'mothership') + ) + throw new OrchestrationError('not_found', 'Chat not found') + if (chat.organizationId) { + if (chat.mode !== 'agent' && chat.mode !== 'plan') + throw new OrchestrationError( + 'forbidden', + 'Memory access requires organization agent mode' + ) + await authorizeOrganizationOperation( + principal, + organizationOperation, + { organizationId: chat.organizationId }, + { executor: tx } + ) + } + const origin = + chat.workspaceId && input.target.scope !== 'personal' + ? await lockWorkspaceProject(tx, chat.workspaceId) + : null + if (chat.workspaceId) await workspaceAccess(tx, principal.subjectUserId, chat.workspaceId) + const scope = { + organizationId: chat.organizationId ?? origin?.organizationId ?? null, + ...(chat.workspaceId ? { workspaceId: chat.workspaceId } : {}), + } + let target: MemoryAccessResult['target'] + if (input.target.scope === 'personal') { + target = { + scope: 'personal', + permissions: { read: true, privateMutation: true, sharedMutation: false }, + } + } else if (input.target.scope === 'project') { + await requireProjectApiEnabled() + if (chat.workspaceId && origin?.id !== input.target.projectId) + throw new OrchestrationError('not_found', 'Project not found in this conversation') + target = await projectAccess(tx, principal, context.chatId, input.target.projectId, scope) + } else { + if (chat.workspaceId && chat.workspaceId !== input.target.workspaceId) + throw new OrchestrationError('not_found', 'Workspace not found in this conversation') + const membership = chat.workspaceId + ? origin + : await lockWorkspaceProject(tx, input.target.workspaceId) + if (!membership || membership.organizationId !== scope.organizationId) + throw new OrchestrationError('not_found', 'Workspace not found in this conversation') + const access = await workspaceAccess(tx, principal.subjectUserId, input.target.workspaceId) + if (access.target.workspaceOrganizationId !== scope.organizationId) + throw new OrchestrationError('not_found', 'Workspace not found in this conversation') + const currentProject = (await isFeatureEnabled('projects')) + ? await projectAccess(tx, principal, context.chatId, membership.id, scope) + : null + target = { + scope: 'workspace', + id: access.target.workspaceId, + name: access.target.name, + permissions: { read: true, privateMutation: true, sharedMutation: access.sharedMutation }, + currentProject, + } + } + if (input.action === 'shared_mutation' && !target.permissions.sharedMutation) + throw new OrchestrationError('forbidden', 'Shared memory mutation is not permitted') + return { + success: true, + version: 1, + actor: { userId: principal.subjectUserId, chatId: context.chatId }, + target, + } + }) + }, +}) + +/** Authorization-only callers resolve the same requested target and permission as execution. */ +export const resolveMemoryAccess = { + operation: resolveMemoryAccessOperation, + execute: resolveAccess.execute, + async authorize(args: Parameters[0]): Promise { + await resolveAccess.execute(args) + }, +} diff --git a/apps/sim/lib/mothership/memories/cleanup-event.ts b/apps/sim/lib/mothership/memories/cleanup-event.ts new file mode 100644 index 00000000000..2ec04541059 --- /dev/null +++ b/apps/sim/lib/mothership/memories/cleanup-event.ts @@ -0,0 +1 @@ +export const MEMORY_CLEANUP_EVENT = 'mothership.memory.cleanup' diff --git a/apps/sim/lib/mothership/memories/cleanup-outbox.ts b/apps/sim/lib/mothership/memories/cleanup-outbox.ts new file mode 100644 index 00000000000..d4aad7446e9 --- /dev/null +++ b/apps/sim/lib/mothership/memories/cleanup-outbox.ts @@ -0,0 +1,85 @@ +import { createHash } from 'node:crypto' +import type { db } from '@sim/db' +import { z } from 'zod' +import { env } from '@/lib/core/config/env' +import { enqueueOutboxEvent, type OutboxHandlerRegistry } from '@/lib/core/outbox/service' +import { SIM_AGENT_API_URL } from '@/lib/mothership/constants' +import { MemoryCleanupInput, MemoryCleanupResult } from '@/lib/mothership/generated/memory-access' +import { MEMORY_CLEANUP_EVENT } from '@/lib/mothership/memories/cleanup-event' +import { fetchGo } from '@/lib/mothership/request/go/fetch' +import { mothershipRequestHeaders } from '@/lib/mothership/request/headers' +import { getMothershipSourceEnvHeaders } from '@/lib/mothership/server/agent-url' + +const cleanupPayloadSchema = z.strictObject({ + request: MemoryCleanupInput, + baseUrl: z.url(), + credentialFingerprint: z.string().nullable(), + sourceHeaders: z.record(z.string(), z.string()), +}) + +function destinations(): string[] { + return [ + ...new Set( + [SIM_AGENT_API_URL, env.COPILOT_DEV_URL, env.COPILOT_STAGING_URL, env.COPILOT_PROD_URL] + .filter((value): value is string => Boolean(value)) + .filter((value) => value.startsWith('https://') || value.startsWith('http://')) + .map((value) => value.replace(/\/+$/, '')) + ), + ] +} + +function credentialFingerprint(): string | null { + return env.COPILOT_API_KEY ? createHash('sha256').update(env.COPILOT_API_KEY).digest('hex') : null +} + +/** Captures configured routes before deletion; notes can outlive a user's environment selection. */ +export async function enqueueMemoryCleanup( + executor: Pick, + targets: MemoryCleanupInput['target'][] +): Promise { + const fingerprint = credentialFingerprint() + const sourceHeaders = getMothershipSourceEnvHeaders() + for (const baseUrl of destinations()) { + for (const target of targets) { + await enqueueOutboxEvent( + executor, + MEMORY_CLEANUP_EVENT, + cleanupPayloadSchema.parse({ + request: { version: 1, target }, + baseUrl, + credentialFingerprint: fingerprint, + sourceHeaders, + }) + ) + } + } +} + +export const memoryCleanupOutboxHandlers = { + [MEMORY_CLEANUP_EVENT]: async (rawPayload, context) => { + const payload = cleanupPayloadSchema.parse(rawPayload) + if ( + !payload.credentialFingerprint || + payload.credentialFingerprint !== credentialFingerprint() + ) { + throw new Error('Memory cleanup credential binding changed; operator reconciliation required') + } + if ( + !destinations().includes(payload.baseUrl) || + JSON.stringify(payload.sourceHeaders) !== JSON.stringify(getMothershipSourceEnvHeaders()) + ) { + throw new Error( + 'Memory cleanup destination binding changed; operator reconciliation required' + ) + } + const response = await fetchGo(`${payload.baseUrl}/api/memories/cleanup`, { + method: 'POST', + headers: mothershipRequestHeaders(), + body: JSON.stringify(payload.request), + signal: AbortSignal.any([context.signal, AbortSignal.timeout(10_000)]), + redirect: 'error', + }) + if (!response.ok) throw new Error(`Memory cleanup failed (${response.status})`) + MemoryCleanupResult.parse(await response.json()) + }, +} satisfies OutboxHandlerRegistry diff --git a/apps/sim/lib/mothership/memories/cleanup.integration.ts b/apps/sim/lib/mothership/memories/cleanup.integration.ts new file mode 100644 index 00000000000..211f13bd464 --- /dev/null +++ b/apps/sim/lib/mothership/memories/cleanup.integration.ts @@ -0,0 +1,156 @@ +import { createServer } from 'node:http' +import { db } from '@sim/db' +import { outboxEvent, permissions, project, user, workspace } from '@sim/db/schema' +import { deleteWorkspaceFixture, insertWorkspaceFixture } from '@sim/db/testing/workspace-fixtures' +import { generateId } from '@sim/utils/id' +import { toRecord } from '@sim/utils/object' +import { and, eq, inArray, sql } from 'drizzle-orm' +import { afterAll, beforeAll, expect, it } from 'vitest' + +const userId = generateId() +const workspaceId = generateId() +const projectId = generateId() +const received: { body: unknown; key: string | string[] | undefined }[] = [] +let fail = true +const server = createServer(async (req, res) => { + let body = '' + for await (const chunk of req) body += chunk + received.push({ body: JSON.parse(body), key: req.headers['x-api-key'] }) + res.writeHead(fail ? 503 : 200, { 'content-type': 'application/json' }) + res.end(JSON.stringify({ success: !fail })) +}) +let runtime: { + deletion: typeof import('@/lib/users/account-deletion') + cleanup: typeof import('@/lib/mothership/memories/cleanup-outbox') + outbox: typeof import('@/lib/core/outbox/service') + event: typeof import('@/lib/mothership/memories/cleanup-event') +} +let eventIds: string[] = [] + +beforeAll(async () => { + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + const address = server.address() + if (!address || typeof address === 'string') throw new Error('Missing fixture address') + process.env.SIM_AGENT_API_URL = `http://127.0.0.1:${address.port}` + process.env.COPILOT_API_KEY = 'memory-cleanup-fixture-key' + const [deletion, cleanup, outbox, event] = await Promise.all([ + import('@/lib/users/account-deletion'), + import('@/lib/mothership/memories/cleanup-outbox'), + import('@/lib/core/outbox/service'), + import('@/lib/mothership/memories/cleanup-event'), + ]) + runtime = { deletion, cleanup, outbox, event } + await db.insert(user).values({ + id: userId, + name: 'Cleanup fixture', + email: `${userId}@memory.test`, + emailVerified: true, + createdAt: new Date(), + updatedAt: new Date(), + }) + await insertWorkspaceFixture(db, { + id: workspaceId, + projectId, + name: 'Cleanup fixture', + ownerId: userId, + billedAccountUserId: userId, + }) + await db.insert(permissions).values({ + id: generateId(), + userId, + entityType: 'workspace', + entityId: workspaceId, + permissionType: 'admin', + }) +}, 60_000) + +afterAll(async () => { + if (eventIds.length) await db.delete(outboxEvent).where(inArray(outboxEvent.id, eventIds)) + await deleteWorkspaceFixture(db, eq(workspace.id, workspaceId)) + await db.delete(project).where(eq(project.id, projectId)) + await db.delete(user).where(eq(user.id, userId)) + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())) + ) +}) + +it('rolls cleanup back with deletion and retries committed account/target erasure after the account is gone', async () => { + await expect( + db.transaction(async (tx) => { + await runtime.cleanup.enqueueMemoryCleanup(tx, [{ kind: 'private_owner', userId }]) + throw new Error('rollback fixture') + }) + ).rejects.toThrow('rollback fixture') + const pending = () => + db + .select() + .from(outboxEvent) + .where( + and( + eq(outboxEvent.eventType, runtime.event.MEMORY_CLEANUP_EVENT), + sql`(${outboxEvent.payload}->'request'->'target'->>'userId' = ${userId} + OR ${outboxEvent.payload}->'request'->'target'->>'workspaceId' = ${workspaceId} + OR ${outboxEvent.payload}->'request'->'target'->>'projectId' = ${projectId})` + ) + ) + expect(await pending()).toEqual([]) + + await runtime.deletion.deleteUserAccount(userId) + expect(await db.select({ id: user.id }).from(user).where(eq(user.id, userId))).toEqual([]) + expect( + await db.select({ id: workspace.id }).from(workspace).where(eq(workspace.id, workspaceId)) + ).toEqual([]) + expect( + await db.select({ id: project.id }).from(project).where(eq(project.id, projectId)) + ).toEqual([]) + const events = await pending() + eventIds = events.map((event) => event.id) + expect(events).toHaveLength(3) + expect(JSON.stringify(events)).not.toContain('memory-cleanup-fixture-key') + expect(received).toHaveLength(0) + for (const event of events) { + await runtime.outbox.processOutboxEventById( + event.id, + runtime.cleanup.memoryCleanupOutboxHandlers + ) + } + expect( + (await pending()).every((event) => event.status === 'pending' && event.attempts === 1) + ).toBe(true) + fail = false + await db + .update(outboxEvent) + .set({ availableAt: new Date(0) }) + .where(inArray(outboxEvent.id, eventIds)) + for (const event of events) { + await runtime.outbox.processOutboxEventById( + event.id, + runtime.cleanup.memoryCleanupOutboxHandlers + ) + } + expect((await pending()).every((event) => event.status === 'completed')).toBe(true) + expect(received.slice(3).map((request) => request.body)).toEqual( + expect.arrayContaining([ + { version: 1, target: { kind: 'private_owner', userId } }, + { version: 1, target: { kind: 'workspace', workspaceId } }, + { version: 1, target: { kind: 'project', projectId } }, + ]) + ) + expect(received.every((request) => request.key === 'memory-cleanup-fixture-key')).toBe(true) + + const first = events[0] + await db + .update(outboxEvent) + .set({ + status: 'pending', + availableAt: new Date(0), + payload: { ...toRecord(first.payload), credentialFingerprint: 'changed-credential' }, + }) + .where(and(eq(outboxEvent.id, first.id), eq(outboxEvent.status, 'completed'))) + const before = received.length + await runtime.outbox.processOutboxEventById(first.id, runtime.cleanup.memoryCleanupOutboxHandlers) + expect(received).toHaveLength(before) + const [blocked] = await db.select().from(outboxEvent).where(eq(outboxEvent.id, first.id)) + expect(blocked.status).toBe('pending') + expect(blocked.lastError).toContain('credential binding changed') +}, 30_000) diff --git a/apps/sim/lib/mothership/transport/control.ts b/apps/sim/lib/mothership/transport/control.ts index e52a7e67d3c..5be9f39167e 100644 --- a/apps/sim/lib/mothership/transport/control.ts +++ b/apps/sim/lib/mothership/transport/control.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { asOrchestrationError, statusForOrchestrationError } from '@/lib/core/orchestration/types' +import { HttpError } from '@/lib/core/utils/http-error' import { createTrustedCopilotPrincipal, createTrustedOrganizationCopilotPrincipal, @@ -12,6 +13,10 @@ import { INTEGRATION_CATALOG_AUDIENCE, readIntegrationCatalog, } from '@/lib/mothership/integrations/application/catalog' +import { + MEMORY_ACCESS_AUDIENCE, + resolveMemoryAccess, +} from '@/lib/mothership/memories/application/resolve-access' import { MEMORY_SCOPE_AUDIENCE, readMemoryScope, @@ -45,7 +50,9 @@ export async function executeSimControl(request: SimControlRequest): Promise, + principal: Pick< + Extract, + 'subjectUserId' | 'invocation' + >, access: Awaited>, scope: Awaited> ) { @@ -187,7 +195,7 @@ export async function requireCurrentCopilotProjectInvocation( /** Shared owner policy keeps compound copies subject to the same live Project edit rule. */ export async function requireProjectFileOwnerRole( tx: DbTransaction, - principal: ProjectFilePrincipal, + principal: ProjectFilePrincipal | SubjectDelegatedPrincipal | OrganizationDelegatedPrincipal, access: Awaited>, accessMode: 'read' | 'write' ): Promise { @@ -217,7 +225,7 @@ export async function requireProjectFileOwnerRole( /** Credential and Files capability checks apply to every currently visible environment. */ export async function requireProjectFileOwnerCapabilities( tx: DbTransaction, - principal: ProjectFilePrincipal, + principal: ProjectFilePrincipal | SubjectDelegatedPrincipal | OrganizationDelegatedPrincipal, access: Awaited>, capability: ProjectFileOperation['capability'] ): Promise { diff --git a/apps/sim/lib/users/account-deletion.ts b/apps/sim/lib/users/account-deletion.ts index 4d18bec33ca..ca0c883f95f 100644 --- a/apps/sim/lib/users/account-deletion.ts +++ b/apps/sim/lib/users/account-deletion.ts @@ -40,6 +40,7 @@ import { import { OrchestrationError } from '@/lib/core/orchestration/types' import type { DbOrTx } from '@/lib/db/types' import { type StoppedRun, stopLiveRunsTx } from '@/lib/mothership/async-runs/repository' +import { enqueueMemoryCleanup } from '@/lib/mothership/memories/cleanup-outbox' import { deliverRunStops } from '@/lib/mothership/request/application/stop-delivery' import { getMothershipBaseURL } from '@/lib/mothership/server/agent-url' import { @@ -985,6 +986,12 @@ export async function deleteUserAccount(userId: string): Promise ({ kind: 'workspace' as const, workspaceId })), + ...projectIdsToDelete.map((projectId) => ({ kind: 'project' as const, projectId })), + ]) + try { await tx.delete(user).where(eq(user.id, userId)) } catch (error) { From 9e4cd91288cd934e43cbd156348c8267fd1192c3 Mon Sep 17 00:00:00 2001 From: Marcus Chandra Date: Wed, 14 Oct 2026 21:06:45 -0700 Subject: [PATCH 2/5] refactor(memory): distinguish scoped note access controls Rename the scoped-note control kind from memory_access to memory_notes_access so it is distinct from graph memory_scope. The HTTP path, delegation audience sim:memory-access, and semantic operation id are unchanged. --- .../api/mothership/memories/access/route.ts | 16 ++--- .../lib/api/contracts/mothership-memories.ts | 11 ++-- ...emory-access.ts => memory-notes-access.ts} | 10 +-- .../lib/mothership/generated/sim-transport.ts | 4 +- .../application/resolve-access.integration.ts | 62 +++++++++---------- .../memories/application/resolve-access.ts | 39 +++++++----- .../lib/mothership/memories/cleanup-outbox.ts | 5 +- apps/sim/lib/mothership/transport/control.ts | 48 +++++++------- 8 files changed, 104 insertions(+), 91 deletions(-) rename apps/sim/lib/mothership/generated/{memory-access.ts => memory-notes-access.ts} (85%) diff --git a/apps/sim/app/api/mothership/memories/access/route.ts b/apps/sim/app/api/mothership/memories/access/route.ts index d9c1ac59e3e..86b538b70ae 100644 --- a/apps/sim/app/api/mothership/memories/access/route.ts +++ b/apps/sim/app/api/mothership/memories/access/route.ts @@ -1,4 +1,4 @@ -import { resolveMemoryAccessContract } from '@/lib/api/contracts/mothership-memories' +import { resolveMemoryNotesAccessContract } from '@/lib/api/contracts/mothership-memories' import { defineInternalJsonRoute, internalOrchestrationErrorPolicy, @@ -6,20 +6,20 @@ import { } from '@/lib/api/server/routes' import { internalCopilotAuth } from '@/lib/mothership/auth/internal' import { - MEMORY_ACCESS_AUDIENCE, - resolveMemoryAccess, - resolveMemoryAccessOperation, + MEMORY_NOTES_ACCESS_AUDIENCE, + resolveMemoryNotesAccess, + resolveMemoryNotesAccessOperation, } from '@/lib/mothership/memories/application/resolve-access' export const POST = defineInternalJsonRoute({ - contract: resolveMemoryAccessContract, - auth: internalCopilotAuth(MEMORY_ACCESS_AUDIENCE, { organization: true }), - operation: resolveMemoryAccessOperation, + contract: resolveMemoryNotesAccessContract, + auth: internalCopilotAuth(MEMORY_NOTES_ACCESS_AUDIENCE, { organization: true }), + operation: resolveMemoryNotesAccessOperation, rateLimit: internalRateLimits.none({ reason: 'Private worker preflight rechecks live memory authority without reading or writing note content.', }), errorPolicy: internalOrchestrationErrorPolicy, mapInput: ({ body }) => body, - useCase: resolveMemoryAccess, + useCase: resolveMemoryNotesAccess, }) diff --git a/apps/sim/lib/api/contracts/mothership-memories.ts b/apps/sim/lib/api/contracts/mothership-memories.ts index 6423a606db2..4e05c298fe4 100644 --- a/apps/sim/lib/api/contracts/mothership-memories.ts +++ b/apps/sim/lib/api/contracts/mothership-memories.ts @@ -1,9 +1,12 @@ import { defineRouteContract } from '@/lib/api/contracts/types' -import { MemoryAccessInput, MemoryAccessResult } from '@/lib/mothership/generated/memory-access' +import { + MemoryNotesAccessInput, + MemoryNotesAccessResult, +} from '@/lib/mothership/generated/memory-notes-access' -export const resolveMemoryAccessContract = defineRouteContract({ +export const resolveMemoryNotesAccessContract = defineRouteContract({ method: 'POST', path: '/api/mothership/memories/access', - body: MemoryAccessInput, - response: { mode: 'json', schema: MemoryAccessResult }, + body: MemoryNotesAccessInput, + response: { mode: 'json', schema: MemoryNotesAccessResult }, }) diff --git a/apps/sim/lib/mothership/generated/memory-access.ts b/apps/sim/lib/mothership/generated/memory-notes-access.ts similarity index 85% rename from apps/sim/lib/mothership/generated/memory-access.ts rename to apps/sim/lib/mothership/generated/memory-notes-access.ts index 0a06c2384ab..b4989b14822 100644 --- a/apps/sim/lib/mothership/generated/memory-access.ts +++ b/apps/sim/lib/mothership/generated/memory-notes-access.ts @@ -1,4 +1,4 @@ -// GENERATED — do not edit. Source of truth: mothership worker packages/contracts/src/memory-access.ts +// GENERATED — do not edit. Source of truth: mothership worker packages/contracts/src/memory-notes-access.ts // Regenerate with `bun run contracts:sync` in the worker. import { z } from "zod"; @@ -15,7 +15,7 @@ const projectSchema = z.strictObject({ permissions: permissionsSchema, }); -export const MemoryAccessInput = z.strictObject({ +export const MemoryNotesAccessInput = z.strictObject({ target: z.discriminatedUnion("scope", [ z.strictObject({ scope: z.literal("personal") }), z.strictObject({ scope: z.literal("workspace"), workspaceId: z.uuid() }), @@ -23,9 +23,9 @@ export const MemoryAccessInput = z.strictObject({ ]), action: z.enum(["read", "private_mutation", "shared_mutation"]), }); -export type MemoryAccessInput = z.infer; +export type MemoryNotesAccessInput = z.infer; -export const MemoryAccessResult = z.strictObject({ +export const MemoryNotesAccessResult = z.strictObject({ success: z.literal(true), version: z.literal(1), actor: z.strictObject({ userId: z.string().min(1), chatId: z.uuid() }), @@ -44,7 +44,7 @@ export const MemoryAccessResult = z.strictObject({ }), ]), }); -export type MemoryAccessResult = z.infer; +export type MemoryNotesAccessResult = z.infer; export const MemoryCleanupInput = z.strictObject({ version: z.literal(1), diff --git a/apps/sim/lib/mothership/generated/sim-transport.ts b/apps/sim/lib/mothership/generated/sim-transport.ts index 9dfc3b293bc..c0b8695bb35 100644 --- a/apps/sim/lib/mothership/generated/sim-transport.ts +++ b/apps/sim/lib/mothership/generated/sim-transport.ts @@ -3,7 +3,7 @@ import { z } from "zod"; import { IntegrationCatalogRequest } from "./integration-catalog"; -import { MemoryAccessInput } from "./memory-access"; +import { MemoryNotesAccessInput } from "./memory-notes-access"; import { MemoryScopeRequest } from "./memory-scope"; import { RunControlRequest } from "./run-control"; import { TaskWakeRequest, WorkflowWatchRequest } from "./tasks"; @@ -30,7 +30,7 @@ export type SimScope = z.infer; /** Only idempotent controls use the idle channel; tool effects use the run event log. */ export const SimControlOperation = z.discriminatedUnion("kind", [ - z.object({ kind: z.literal("memory_access"), input: MemoryAccessInput }), + z.object({ kind: z.literal("memory_notes_access"), input: MemoryNotesAccessInput }), z.object({ kind: z.literal("memory_scope"), input: MemoryScopeRequest }), z.object({ kind: z.literal("run_control"), input: RunControlRequest }), z.object({ kind: z.literal("workflow_status"), input: WorkflowWatchRequest }), diff --git a/apps/sim/lib/mothership/memories/application/resolve-access.integration.ts b/apps/sim/lib/mothership/memories/application/resolve-access.integration.ts index 0ab22cb1d09..df1ce828e56 100644 --- a/apps/sim/lib/mothership/memories/application/resolve-access.integration.ts +++ b/apps/sim/lib/mothership/memories/application/resolve-access.integration.ts @@ -20,10 +20,10 @@ import { createTrustedCopilotPrincipal, createTrustedOrganizationCopilotPrincipal, } from '@/lib/mothership/auth/application-delegation' -import { MemoryAccessResult } from '@/lib/mothership/generated/memory-access' +import { MemoryNotesAccessResult } from '@/lib/mothership/generated/memory-notes-access' import { - MEMORY_ACCESS_AUDIENCE, - resolveMemoryAccess, + MEMORY_NOTES_ACCESS_AUDIENCE, + resolveMemoryNotesAccess, } from '@/lib/mothership/memories/application/resolve-access' import { executeSimControl } from '@/lib/mothership/transport/control' import { splitForkProject } from '@/lib/projects/membership' @@ -45,7 +45,7 @@ const workspaceChatId = generateId() const organizationChatId = generateId() function principal(organizationChat = false) { - const options = { audience: MEMORY_ACCESS_AUDIENCE, ttlMs: 60_000 } + const options = { audience: MEMORY_NOTES_ACCESS_AUDIENCE, ttlMs: 60_000 } return organizationChat ? createTrustedOrganizationCopilotPrincipal( { userId: actorId, organizationId, chatId: organizationChatId, delegationId: generateId() }, @@ -156,7 +156,7 @@ it('denies every Project memory action on both transports when projects is disab chatId, ...(organizationChat ? { organizationId } : { workspaceId }), }, - operation: { kind: 'memory_access', input }, + operation: { kind: 'memory_notes_access', input }, }) expect(checkpoint.status).toBe(503) expect(JSON.parse(checkpoint.body)).toEqual({ @@ -170,7 +170,7 @@ it('keeps personal and workspace memories usable without Project context when pr featureFlagsMockFns.mockIsFeatureEnabled.mockResolvedValue(false) for (const organizationChat of [false, true]) { for (const action of ['read', 'private_mutation'] as const) { - const personal = await resolveMemoryAccess.execute({ + const personal = await resolveMemoryNotesAccess.execute({ principal: principal(organizationChat), input: { target: { scope: 'personal' }, action }, }) @@ -178,11 +178,11 @@ it('keeps personal and workspace memories usable without Project context when pr scope: 'personal', permissions: { read: true, privateMutation: true, sharedMutation: false }, }) - const result = await resolveMemoryAccess.execute({ + const result = await resolveMemoryNotesAccess.execute({ principal: principal(organizationChat), input: { target: { scope: 'workspace', workspaceId }, action }, }) - expect(MemoryAccessResult.parse(result).target).toEqual({ + expect(MemoryNotesAccessResult.parse(result).target).toEqual({ scope: 'workspace', id: workspaceId, name: 'Environment', @@ -191,7 +191,7 @@ it('keeps personal and workspace memories usable without Project context when pr }) } await expect( - resolveMemoryAccess.execute({ + resolveMemoryNotesAccess.execute({ principal: principal(organizationChat), input: { target: { scope: 'workspace', workspaceId }, action: 'shared_mutation' }, }) @@ -203,7 +203,7 @@ it('keeps personal and workspace memories usable without Project context when pr .where(and(eq(permissions.userId, actorId), eq(permissions.entityId, workspaceId))) try { for (const organizationChat of [false, true]) { - const result = await resolveMemoryAccess.execute({ + const result = await resolveMemoryNotesAccess.execute({ principal: principal(organizationChat), input: { target: { scope: 'workspace', workspaceId }, action: 'shared_mutation' }, }) @@ -222,7 +222,7 @@ it('keeps personal and workspace memories usable without Project context when pr it('lets a read-only end user mutate private notes through either chat and keeps Project shared writes denied', async () => { for (const organizationChat of [false, true]) { - const result = await resolveMemoryAccess.execute({ + const result = await resolveMemoryNotesAccess.execute({ principal: principal(organizationChat), input: { target: { scope: 'workspace', workspaceId }, action: 'private_mutation' }, }) @@ -234,7 +234,7 @@ it('lets a read-only end user mutate private notes through either chat and keeps currentProject: { id: projectId, permissions: { read: true, sharedMutation: false } }, }) await expect( - resolveMemoryAccess.execute({ + resolveMemoryNotesAccess.execute({ principal: principal(organizationChat), input: { target: { scope: 'project', projectId }, action: 'shared_mutation' }, }) @@ -248,7 +248,7 @@ it('does not turn write in one environment into shared Project write', async () .set({ permissionType: 'write' }) .where(and(eq(permissions.userId, actorId), eq(permissions.entityId, workspaceId))) try { - const result = await resolveMemoryAccess.execute({ + const result = await resolveMemoryNotesAccess.execute({ principal: principal(), input: { target: { scope: 'workspace', workspaceId }, action: 'shared_mutation' }, }) @@ -263,7 +263,7 @@ it('does not turn write in one environment into shared Project write', async () entityId: siblingId, permissionType: 'write', }) - const shared = await resolveMemoryAccess.execute({ + const shared = await resolveMemoryNotesAccess.execute({ principal: principal(true), input: { target: { scope: 'project', projectId }, action: 'shared_mutation' }, }) @@ -286,11 +286,11 @@ it('rejects wrong actor, audience, expired delegation and another workspace targ { ...principal(), audience: 'sim:workspaces' }, { ...principal(), expiresAt: new Date(0) }, ]) - await expect(resolveMemoryAccess.execute({ principal: altered, input })).rejects.toBeInstanceOf( - Error - ) + await expect( + resolveMemoryNotesAccess.execute({ principal: altered, input }) + ).rejects.toBeInstanceOf(Error) await expect( - resolveMemoryAccess.execute({ + resolveMemoryNotesAccess.execute({ principal: principal(), input: { ...input, target: { scope: 'workspace', workspaceId: siblingId } }, }) @@ -303,12 +303,12 @@ it('denies a lost scoped grant while personal-only organization context remains .where(and(eq(permissions.userId, actorId), eq(permissions.entityId, workspaceId))) try { await expect( - resolveMemoryAccess.execute({ + resolveMemoryNotesAccess.execute({ principal: principal(true), input: { target: { scope: 'project', projectId }, action: 'private_mutation' }, }) ).rejects.toMatchObject({ code: 'not_found' }) - const result = await resolveMemoryAccess.execute({ + const result = await resolveMemoryNotesAccess.execute({ principal: principal(true), input: { target: { scope: 'personal' }, action: 'private_mutation' }, }) @@ -353,7 +353,7 @@ it('applies the Files capability to shared Project writes without blocking priva organizationId, }) try { - const result = await resolveMemoryAccess.execute({ + const result = await resolveMemoryNotesAccess.execute({ principal: principal(true), input: { target: { scope: 'project', projectId }, action: 'private_mutation' }, }) @@ -363,7 +363,7 @@ it('applies the Files capability to shared Project writes without blocking priva sharedMutation: false, }) await expect( - resolveMemoryAccess.execute({ + resolveMemoryNotesAccess.execute({ principal: principal(true), input: { target: { scope: 'project', projectId }, action: 'shared_mutation' }, }) @@ -393,13 +393,13 @@ it('rejects cross-organization authority and organization non-agent mode', async try { const otherPrincipal = { ...principal(true), organizationId: otherId } await expect( - resolveMemoryAccess.execute({ + resolveMemoryNotesAccess.execute({ principal: otherPrincipal, input: { target: { scope: 'project', projectId }, action: 'read' }, }) ).rejects.toMatchObject({ code: 'not_found' }) await expect( - resolveMemoryAccess.execute({ + resolveMemoryNotesAccess.execute({ principal: otherPrincipal, input: { target: { scope: 'workspace', workspaceId }, action: 'read' }, }) @@ -418,7 +418,7 @@ it('rejects cross-organization authority and organization non-agent mode', async .where(eq(copilotChats.id, organizationChatId)) try { await expect( - resolveMemoryAccess.execute({ + resolveMemoryNotesAccess.execute({ principal: principal(true), input: { target: { scope: 'project', projectId }, action: 'read' }, }) @@ -457,7 +457,7 @@ it('serves the typed route using authenticated headers and rejects body identity ) const response = await POST(request(input)) expect(response.status).toBe(200) - expect(MemoryAccessResult.parse(await response.json())).toMatchObject({ + expect(MemoryNotesAccessResult.parse(await response.json())).toMatchObject({ success: true, version: 1, actor: { userId: actorId, chatId: workspaceChatId }, @@ -476,13 +476,13 @@ it('resolves detach to the new Project and rejects the former Project from the w return id }) try { - const result = await resolveMemoryAccess.execute({ + const result = await resolveMemoryNotesAccess.execute({ principal: principal(), input: { target: { scope: 'workspace', workspaceId }, action: 'read' }, }) expect(result.target).toMatchObject({ currentProject: { id: detachedProject } }) await expect( - resolveMemoryAccess.execute({ + resolveMemoryNotesAccess.execute({ principal: principal(), input: { target: { scope: 'project', projectId }, action: 'read' }, }) @@ -515,12 +515,12 @@ it('uses identical current authority over checkpoint transport for workspace and expiresAt: Date.now() + 60_000, scope, operation: { - kind: 'memory_access', + kind: 'memory_notes_access', input: { target: { scope: 'workspace', workspaceId }, action: 'private_mutation' }, }, }) expect(result.status).toBe(200) - expect(MemoryAccessResult.parse(JSON.parse(result.body))).toMatchObject({ + expect(MemoryNotesAccessResult.parse(JSON.parse(result.body))).toMatchObject({ actor: { userId: actorId, chatId: scope.chatId }, target: { id: workspaceId, currentProject: projectsEnabled ? { id: projectId } : null }, }) @@ -529,7 +529,7 @@ it('uses identical current authority over checkpoint transport for workspace and expiresAt: Date.now() + 60_000, scope, operation: { - kind: 'memory_access', + kind: 'memory_notes_access', input: { target: { scope: 'project', projectId }, action: 'shared_mutation' }, }, }) diff --git a/apps/sim/lib/mothership/memories/application/resolve-access.ts b/apps/sim/lib/mothership/memories/application/resolve-access.ts index b6b54417f0b..e36779a44be 100644 --- a/apps/sim/lib/mothership/memories/application/resolve-access.ts +++ b/apps/sim/lib/mothership/memories/application/resolve-access.ts @@ -16,9 +16,9 @@ import { defineAuthorizedChatUseCase } from '@/lib/mothership/chat/application/a import { resolveOwnedChatContext } from '@/lib/mothership/chat/application/context' import { conversationModeSelection } from '@/lib/mothership/chat/intent' import type { - MemoryAccessInput, - MemoryAccessResult, -} from '@/lib/mothership/generated/memory-access' + MemoryNotesAccessInput, + MemoryNotesAccessResult, +} from '@/lib/mothership/generated/memory-notes-access' import { assertWorkspaceCapability } from '@/lib/permission-groups/capability-assertions' import { PermissionGroupCapabilityError } from '@/lib/permission-groups/capability-error' import { acquirePermissionGroupOrgLock } from '@/lib/permission-groups/locks' @@ -32,9 +32,10 @@ import { lockWorkspaceProject } from '@/lib/projects/membership' import { requireProjectApiEnabled } from '@/lib/projects/rollout.server' import { requireCurrentFileSubject } from '@/lib/workspace-files/application/subject' -export const MEMORY_ACCESS_AUDIENCE = 'sim:memory-access' -export const resolveMemoryAccessOperation = defineWorkspaceOperation({ +export const MEMORY_NOTES_ACCESS_AUDIENCE = 'sim:memory-access' +export const resolveMemoryNotesAccessOperation = defineWorkspaceOperation({ id: 'mothership.memories.resolve_access', + contentDelivery: 'none', minimumRole: 'read', workspaceApiKey: 'deny', capability: 'copilot.use', @@ -42,12 +43,13 @@ export const resolveMemoryAccessOperation = defineWorkspaceOperation({ delegatedServices: ['copilot'], }) const organizationOperation = defineOrganizationOperation({ - id: resolveMemoryAccessOperation.id, + id: resolveMemoryNotesAccessOperation.id, + contentDelivery: resolveMemoryNotesAccessOperation.contentDelivery, minimumRole: 'member', capability: 'copilot.use', principalKinds: ['organization_delegated'], delegatedServices: ['copilot'], - delegationAudience: MEMORY_ACCESS_AUDIENCE, + delegationAudience: MEMORY_NOTES_ACCESS_AUDIENCE, }) type MemoryPrincipal = SubjectDelegatedPrincipal | OrganizationDelegatedPrincipal @@ -88,7 +90,7 @@ async function projectAccess( chatId: string, projectId: string, scope: { organizationId: string | null; workspaceId?: string } -): Promise> { +): Promise> { const access = await loadProjectAccess(tx, principal.subjectUserId, { projectId, ...(scope.organizationId ? { organizationId: scope.organizationId } : {}), @@ -127,9 +129,14 @@ async function projectAccess( /** Current Sim authority only; Mothership must re-resolve before each read or mutation. */ const resolveAccess = defineAuthorizedChatUseCase({ - operation: resolveMemoryAccessOperation, + operation: resolveMemoryNotesAccessOperation, organizationOperation, - async resolveContext({ principal }: { principal: MemoryPrincipal; input: MemoryAccessInput }) { + async resolveContext({ + principal, + }: { + principal: MemoryPrincipal + input: MemoryNotesAccessInput + }) { const chatId = principal.resourceScope && 'chatId' in principal.resourceScope ? principal.resourceScope.chatId @@ -137,7 +144,7 @@ const resolveAccess = defineAuthorizedChatUseCase({ const now = Date.now() if ( principal.serviceId !== 'copilot' || - principal.audience !== MEMORY_ACCESS_AUDIENCE || + principal.audience !== MEMORY_NOTES_ACCESS_AUDIENCE || !chatId || !Number.isFinite(principal.issuedAt.getTime()) || !Number.isFinite(principal.expiresAt.getTime()) || @@ -150,11 +157,11 @@ const resolveAccess = defineAuthorizedChatUseCase({ }, authorizationOptions: { delegation: { - audience: MEMORY_ACCESS_AUDIENCE, + audience: MEMORY_NOTES_ACCESS_AUDIENCE, isWithinScope: (principal, context) => principal.resourceScope?.chatId === context.chatId, }, }, - async execute({ principal, context, input }): Promise { + async execute({ principal, context, input }): Promise { return db.transaction(async (tx) => { await requireCurrentFileSubject(tx, principal) const [chat] = await tx @@ -199,7 +206,7 @@ const resolveAccess = defineAuthorizedChatUseCase({ organizationId: chat.organizationId ?? origin?.organizationId ?? null, ...(chat.workspaceId ? { workspaceId: chat.workspaceId } : {}), } - let target: MemoryAccessResult['target'] + let target: MemoryNotesAccessResult['target'] if (input.target.scope === 'personal') { target = { scope: 'personal', @@ -245,8 +252,8 @@ const resolveAccess = defineAuthorizedChatUseCase({ }) /** Authorization-only callers resolve the same requested target and permission as execution. */ -export const resolveMemoryAccess = { - operation: resolveMemoryAccessOperation, +export const resolveMemoryNotesAccess = { + operation: resolveMemoryNotesAccessOperation, execute: resolveAccess.execute, async authorize(args: Parameters[0]): Promise { await resolveAccess.execute(args) diff --git a/apps/sim/lib/mothership/memories/cleanup-outbox.ts b/apps/sim/lib/mothership/memories/cleanup-outbox.ts index d4aad7446e9..69ee0835832 100644 --- a/apps/sim/lib/mothership/memories/cleanup-outbox.ts +++ b/apps/sim/lib/mothership/memories/cleanup-outbox.ts @@ -4,7 +4,10 @@ import { z } from 'zod' import { env } from '@/lib/core/config/env' import { enqueueOutboxEvent, type OutboxHandlerRegistry } from '@/lib/core/outbox/service' import { SIM_AGENT_API_URL } from '@/lib/mothership/constants' -import { MemoryCleanupInput, MemoryCleanupResult } from '@/lib/mothership/generated/memory-access' +import { + MemoryCleanupInput, + MemoryCleanupResult, +} from '@/lib/mothership/generated/memory-notes-access' import { MEMORY_CLEANUP_EVENT } from '@/lib/mothership/memories/cleanup-event' import { fetchGo } from '@/lib/mothership/request/go/fetch' import { mothershipRequestHeaders } from '@/lib/mothership/request/headers' diff --git a/apps/sim/lib/mothership/transport/control.ts b/apps/sim/lib/mothership/transport/control.ts index 5be9f39167e..31e4338195a 100644 --- a/apps/sim/lib/mothership/transport/control.ts +++ b/apps/sim/lib/mothership/transport/control.ts @@ -14,8 +14,8 @@ import { readIntegrationCatalog, } from '@/lib/mothership/integrations/application/catalog' import { - MEMORY_ACCESS_AUDIENCE, - resolveMemoryAccess, + MEMORY_NOTES_ACCESS_AUDIENCE, + resolveMemoryNotesAccess, } from '@/lib/mothership/memories/application/resolve-access' import { MEMORY_SCOPE_AUDIENCE, @@ -50,17 +50,17 @@ export async function executeSimControl(request: SimControlRequest): Promise Date: Thu, 15 Oct 2026 16:56:02 -0700 Subject: [PATCH 3/5] chore(audits): exempt Mothership contract copies from the relative-import rule contracts:sync copies these files byte for byte from the worker, whose sibling imports are relative, so they cannot use the app's aliases. Exempt only files in lib/mothership/generated that carry the sync header, and drop their baseline entries. --- .../check-source-conventions.baseline.json | 9 ++------- scripts/check-source-conventions.test.ts | 20 +++++++++++++++++++ scripts/check-source-conventions.ts | 13 +++++++++++- 3 files changed, 34 insertions(+), 8 deletions(-) diff --git a/scripts/check-source-conventions.baseline.json b/scripts/check-source-conventions.baseline.json index de07d0e8aba..77a252cfe29 100644 --- a/scripts/check-source-conventions.baseline.json +++ b/scripts/check-source-conventions.baseline.json @@ -165,9 +165,9 @@ "apps/sim/lib/core/async-jobs/types.test.ts": 1, "apps/sim/lib/core/config/cost-multiplier.ts": 1, "apps/sim/lib/core/config/env-flags.ts": 3, + "apps/sim/lib/core/rate-limiter/hosted-key/queue.test.ts": 1, "apps/sim/lib/core/rate-limiter/hosted-key/rate-limiter.test.ts": 3, "apps/sim/lib/core/rate-limiter/hosted-key/rate-limiter.ts": 2, - "apps/sim/lib/core/rate-limiter/hosted-key/queue.test.ts": 1, "apps/sim/lib/core/rate-limiter/rate-limiter.test.ts": 2, "apps/sim/lib/core/rate-limiter/rate-limiter.ts": 2, "apps/sim/lib/core/rate-limiter/route-helpers.test.ts": 1, @@ -207,11 +207,6 @@ "apps/sim/lib/mothership/chat/selection-context.test.ts": 1, "apps/sim/lib/mothership/chat/sim-key-redaction.test.ts": 1, "apps/sim/lib/mothership/chat/terminal-state.test.ts": 1, - "apps/sim/lib/mothership/generated/agent-cli.ts": 2, - "apps/sim/lib/mothership/generated/assistant.ts": 1, - "apps/sim/lib/mothership/generated/protocol.ts": 3, - "apps/sim/lib/mothership/generated/resources.ts": 1, - "apps/sim/lib/mothership/generated/sim-transport.ts": 4, "apps/sim/lib/mothership/request/go/inline-images.test.ts": 1, "apps/sim/lib/mothership/request/handlers/complete.ts": 2, "apps/sim/lib/mothership/request/handlers/error.ts": 2, @@ -280,9 +275,9 @@ "apps/sim/lib/pptx-renderer/shapes/presets.ts": 1, "apps/sim/lib/table/workflow-columns.ts": 1, "apps/sim/lib/tools/falai-pricing.test.ts": 1, - "apps/sim/lib/uploads/contexts/workspace/track-chat-upload.test.ts": 1, "apps/sim/lib/uploads/contexts/workspace/file-manager-errors.test.ts": 1, "apps/sim/lib/uploads/contexts/workspace/file-manager.test.ts": 1, + "apps/sim/lib/uploads/contexts/workspace/track-chat-upload.test.ts": 1, "apps/sim/lib/webhooks/providers/table.ts": 1, "apps/sim/lib/webhooks/providers/whatsapp.test.ts": 1, "apps/sim/lib/workflows/comparison/normalize.test.ts": 1, diff --git a/scripts/check-source-conventions.test.ts b/scripts/check-source-conventions.test.ts index 205c04f2131..c832ca7b418 100644 --- a/scripts/check-source-conventions.test.ts +++ b/scripts/check-source-conventions.test.ts @@ -103,6 +103,26 @@ describe('relative-import', () => { hits('packages/utils/src/object.ts', "import { a } from './a'", 'relative-import') ).toEqual([]) }) + + it('exempts a Mothership contract copy only where contracts:sync writes it', () => { + const copy = [ + '// GENERATED — do not edit. Source of truth: mothership worker packages/contracts/src/sim-transport.ts', + "import { MemoryNotesAccessInput } from './memory-notes-access'", + ].join('\n') + expect( + hits('apps/sim/lib/mothership/generated/sim-transport.ts', copy, 'relative-import') + ).toEqual([]) + expect(hits('apps/sim/lib/feature/service.ts', copy, 'relative-import')).toEqual([ + './memory-notes-access', + ]) + expect( + hits( + 'apps/sim/lib/mothership/generated/handwritten.ts', + "import { a } from './a'", + 'relative-import' + ) + ).toEqual(['./a']) + }) }) describe('text-size-literal', () => { diff --git a/scripts/check-source-conventions.ts b/scripts/check-source-conventions.ts index e31a065e8b5..0f7ad113d9d 100644 --- a/scripts/check-source-conventions.ts +++ b/scripts/check-source-conventions.ts @@ -9,6 +9,8 @@ * import whose every binding the barrel then exports (`export { X }`, or renamed with * `export const Y = X`). A path that leaves the folder (`../x`, * `./../x`), a side-effect import, and an import the barrel keeps for itself are all flagged. + * A verbatim Mothership contract copy in `lib/mothership/generated/` is exempt: its imports mirror + * the worker source it is synced from, so it cannot use the app's aliases. * - `text-size-literal` (`.claude/rules/sim-styling.md` "Tailwind" 3): an `apps/sim` string or * template literal sets type size with `text-[Npx]` where the documented text scale has a token * for that size (`text-[13px]` → `text-small`). The literal also drops the token's line-height. @@ -522,6 +524,15 @@ function findBareRouteHandlers(file: string, program: Node): Violation[] { return violations } +const CONTRACT_COPY_DIR = 'apps/sim/lib/mothership/generated/' +const CONTRACT_COPY_HEADER = + '// GENERATED — do not edit. Source of truth: mothership worker packages/contracts/' + +/** A file `contracts:sync` copies byte for byte from the Mothership worker. */ +function isContractCopy(file: string, source: string): boolean { + return file.startsWith(CONTRACT_COPY_DIR) && source.startsWith(CONTRACT_COPY_HEADER) +} + /** Unit, integration and live tests, plus fixtures: they bind real boundaries on purpose. */ function isTestFile(file: string): boolean { return /\.(?:test|spec|integration)\.tsx?$/.test(file) || file.includes('/__fixtures__/') @@ -533,7 +544,7 @@ export function findViolations(file: string, source: string): Violation[] { const test = isTestFile(file) const isApi = file.startsWith('apps/sim/app/api/') && !test // Cheap textual gates; each is a superset of what its rule can match, and the AST decides. - const wantsRelative = /['"`]\.\.?\//.test(source) + const wantsRelative = !isContractCopy(file, source) && /['"`]\.\.?\//.test(source) const wantsTextSize = !test && /text-\[\d/.test(source) const wantsStyle = !test && file.endsWith('.tsx') && /\bstyle\s*=\s*\{/.test(source) const wantsDb = isApi && source.includes('@sim/db') From e42d58a2fd3c0db2dd3729a58313a1bcb2d4d664 Mon Sep 17 00:00:00 2001 From: Marcus Chandra Date: Thu, 15 Oct 2026 17:06:34 -0700 Subject: [PATCH 4/5] fix(memory): read-lock note authorization, redact legacy tool calls, skip keyless cleanup Project note checks took the Project lock exclusively while already holding it shared, so overlapping workspace checks could deadlock; they now read under the shared Project lock and the permission-group read lock, as Project Files does. Exports also redact remember calls stored in the legacy top-level toolCalls list. A Sim without COPILOT_API_KEY never reached a worker, so it queues no memory cleanup it could not deliver. --- .../lib/data-drains/sources/copilot-chats.ts | 20 ++++++-- .../sources/coverage.integration.ts | 17 +++++++ .../application/resolve-access.integration.ts | 13 +++++ .../memories/application/resolve-access.ts | 19 ++++--- .../memories/cleanup-outbox.test.ts | 50 +++++++++++++++++++ .../lib/mothership/memories/cleanup-outbox.ts | 7 ++- 6 files changed, 115 insertions(+), 11 deletions(-) create mode 100644 apps/sim/lib/mothership/memories/cleanup-outbox.test.ts diff --git a/apps/sim/lib/data-drains/sources/copilot-chats.ts b/apps/sim/lib/data-drains/sources/copilot-chats.ts index 79e29d46fcd..b79062b33d2 100644 --- a/apps/sim/lib/data-drains/sources/copilot-chats.ts +++ b/apps/sim/lib/data-drains/sources/copilot-chats.ts @@ -144,12 +144,26 @@ async function* pages(input: SourcePageInput): AsyncIterable { } } +/** A message written before content blocks keeps its tool calls in a top-level `toolCalls` list. */ +function projectLegacyToolCall(call: unknown): unknown { + if (!isRecordLike(call) || call.name !== 'remember') return call + return { + name: 'remember', + ...(typeof call.id === 'string' ? { id: call.id } : {}), + ...(typeof call.status === 'string' ? { status: call.status } : {}), + } +} + /** Tool payloads can contain private notes even when their mutation failed or targeted a shared note. */ function projectExportMessage(message: unknown): unknown { - if (!isRecordLike(message) || !Array.isArray(message.contentBlocks)) return message + if (!isRecordLike(message)) return message + const projected = Array.isArray(message.toolCalls) + ? { ...message, toolCalls: message.toolCalls.map(projectLegacyToolCall) } + : message + if (!Array.isArray(projected.contentBlocks)) return projected return { - ...message, - contentBlocks: message.contentBlocks.map((block: unknown) => { + ...projected, + contentBlocks: projected.contentBlocks.map((block: unknown) => { if (!isRecordLike(block) || !isRecordLike(block.toolCall)) return block if (block.toolCall.name !== 'remember') { if (!isRecordLike(block.toolCall.result) || !('output' in block.toolCall.result)) diff --git a/apps/sim/lib/data-drains/sources/coverage.integration.ts b/apps/sim/lib/data-drains/sources/coverage.integration.ts index ea9bcf05f88..45b3bdb826c 100644 --- a/apps/sim/lib/data-drains/sources/coverage.integration.ts +++ b/apps/sim/lib/data-drains/sources/coverage.integration.ts @@ -118,6 +118,22 @@ describe('Drain source coverage and payload safety in PostgreSQL', () => { const content = { role: 'assistant', content: 'Visible reply', + toolCalls: [ + { + id: 'legacy', + name: 'remember', + status: 'success', + params: { content: 'hidden-memory-legacy-args' }, + result: 'hidden-memory-legacy-result', + error: 'hidden-memory-legacy-error', + }, + { + id: 'legacy-read', + name: 'read', + status: 'success', + params: { path: 'visible-legacy-path' }, + }, + ], contentBlocks: [ { type: 'text', content: 'Visible reply' }, { @@ -176,6 +192,7 @@ describe('Drain source coverage and payload safety in PostgreSQL', () => { expect(wire).not.toContain('hidden-memory') expect(wire).toContain('Visible reply') expect(wire).toContain('visible-path') + expect(wire).toContain('visible-legacy-path') const [stored] = await runtime.db .select({ content: runtime.schema.copilotMessages.content }) .from(runtime.schema.copilotMessages) diff --git a/apps/sim/lib/mothership/memories/application/resolve-access.integration.ts b/apps/sim/lib/mothership/memories/application/resolve-access.integration.ts index df1ce828e56..9473a1b62ee 100644 --- a/apps/sim/lib/mothership/memories/application/resolve-access.integration.ts +++ b/apps/sim/lib/mothership/memories/application/resolve-access.integration.ts @@ -242,6 +242,19 @@ it('lets a read-only end user mutate private notes through either chat and keeps } }) +it('resolves overlapping workspace checks that share one Project without deadlocking', async () => { + const results = await Promise.all( + Array.from({ length: 6 }, (_, index) => + resolveMemoryNotesAccess.execute({ + principal: principal(index % 2 === 1), + input: { target: { scope: 'workspace', workspaceId }, action: 'read' }, + }) + ) + ) + for (const result of results) + expect(result.target).toMatchObject({ scope: 'workspace', currentProject: { id: projectId } }) +}) + it('does not turn write in one environment into shared Project write', async () => { await db .update(permissions) diff --git a/apps/sim/lib/mothership/memories/application/resolve-access.ts b/apps/sim/lib/mothership/memories/application/resolve-access.ts index e36779a44be..db6b6592676 100644 --- a/apps/sim/lib/mothership/memories/application/resolve-access.ts +++ b/apps/sim/lib/mothership/memories/application/resolve-access.ts @@ -21,7 +21,7 @@ import type { } from '@/lib/mothership/generated/memory-notes-access' import { assertWorkspaceCapability } from '@/lib/permission-groups/capability-assertions' import { PermissionGroupCapabilityError } from '@/lib/permission-groups/capability-error' -import { acquirePermissionGroupOrgLock } from '@/lib/permission-groups/locks' +import { acquirePermissionGroupOrgReadLock } from '@/lib/permission-groups/locks' import { loadProjectAccess } from '@/lib/projects/application/authorization' import { requireCurrentCopilotProjectInvocation, @@ -91,15 +91,20 @@ async function projectAccess( projectId: string, scope: { organizationId: string | null; workspaceId?: string } ): Promise> { - const access = await loadProjectAccess(tx, principal.subjectUserId, { - projectId, - ...(scope.organizationId ? { organizationId: scope.organizationId } : {}), - ...(scope.workspaceId ? { workspaceId: scope.workspaceId } : {}), - }) + const access = await loadProjectAccess( + tx, + principal.subjectUserId, + { + projectId, + ...(scope.organizationId ? { organizationId: scope.organizationId } : {}), + ...(scope.workspaceId ? { workspaceId: scope.workspaceId } : {}), + }, + 'shared' + ) if (scope.organizationId !== access.record.organizationId) throw new OrchestrationError('not_found', 'Project not found in this conversation') if (access.record.organizationId) - await acquirePermissionGroupOrgLock(tx, access.record.organizationId) + await acquirePermissionGroupOrgReadLock(tx, access.record.organizationId) await requireCurrentCopilotProjectInvocation( tx, { diff --git a/apps/sim/lib/mothership/memories/cleanup-outbox.test.ts b/apps/sim/lib/mothership/memories/cleanup-outbox.test.ts new file mode 100644 index 00000000000..d0aef75e15d --- /dev/null +++ b/apps/sim/lib/mothership/memories/cleanup-outbox.test.ts @@ -0,0 +1,50 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockEnv, mockEnqueue } = vi.hoisted(() => ({ + mockEnv: { COPILOT_API_KEY: undefined as string | undefined } as Record< + string, + string | undefined + >, + mockEnqueue: vi.fn(), +})) + +vi.mock('@/lib/core/config/env', () => ({ env: mockEnv })) +vi.mock('@/lib/core/outbox/service', () => ({ enqueueOutboxEvent: mockEnqueue })) +vi.mock('@/lib/mothership/constants', () => ({ SIM_AGENT_API_URL: 'https://agent.example.test' })) +vi.mock('@/lib/mothership/request/go/fetch', () => ({ fetchGo: vi.fn() })) +vi.mock('@/lib/mothership/request/headers', () => ({ mothershipRequestHeaders: () => ({}) })) +vi.mock('@/lib/mothership/server/agent-url', () => ({ getMothershipSourceEnvHeaders: () => ({}) })) + +import { enqueueMemoryCleanup } from '@/lib/mothership/memories/cleanup-outbox' + +const executor = { insert: vi.fn() } +const targets = [ + { kind: 'private_owner' as const, userId: 'user-1' }, + { kind: 'workspace' as const, workspaceId: '00000000-0000-4000-8000-000000000001' }, +] + +describe('enqueueMemoryCleanup', () => { + beforeEach(() => { + vi.clearAllMocks() + mockEnv.COPILOT_API_KEY = undefined + }) + + it('queues nothing when this Sim has no worker credential', async () => { + await enqueueMemoryCleanup(executor, targets) + expect(mockEnqueue).not.toHaveBeenCalled() + }) + + it('queues one credential-bound event per destination and target', async () => { + mockEnv.COPILOT_API_KEY = 'configured-key' + await enqueueMemoryCleanup(executor, targets) + expect(mockEnqueue).toHaveBeenCalledTimes(targets.length) + for (const [, , payload] of mockEnqueue.mock.calls) + expect(payload).toMatchObject({ + baseUrl: 'https://agent.example.test', + credentialFingerprint: expect.any(String), + }) + }) +}) diff --git a/apps/sim/lib/mothership/memories/cleanup-outbox.ts b/apps/sim/lib/mothership/memories/cleanup-outbox.ts index 69ee0835832..3dbaa16ef55 100644 --- a/apps/sim/lib/mothership/memories/cleanup-outbox.ts +++ b/apps/sim/lib/mothership/memories/cleanup-outbox.ts @@ -35,12 +35,17 @@ function credentialFingerprint(): string | null { return env.COPILOT_API_KEY ? createHash('sha256').update(env.COPILOT_API_KEY).digest('hex') : null } -/** Captures configured routes before deletion; notes can outlive a user's environment selection. */ +/** + * Captures configured routes before deletion; notes can outlive a user's environment selection. + * Without `COPILOT_API_KEY` this Sim never reached a worker, so it holds no notes to erase and + * queues nothing rather than an event no delivery could authenticate. + */ export async function enqueueMemoryCleanup( executor: Pick, targets: MemoryCleanupInput['target'][] ): Promise { const fingerprint = credentialFingerprint() + if (!fingerprint) return const sourceHeaders = getMothershipSourceEnvHeaders() for (const baseUrl of destinations()) { for (const target of targets) { From 9ae7c1a1a16edd8a985b0a1f898f5abb611a5a17 Mon Sep 17 00:00:00 2001 From: Marcus Chandra Date: Thu, 15 Oct 2026 18:34:36 -0700 Subject: [PATCH 5/5] test(memory): use the shared env and outbox mocks for cleanup enqueue --- .../memories/cleanup-outbox.test.ts | 37 ++++++++++--------- 1 file changed, 19 insertions(+), 18 deletions(-) diff --git a/apps/sim/lib/mothership/memories/cleanup-outbox.test.ts b/apps/sim/lib/mothership/memories/cleanup-outbox.test.ts index d0aef75e15d..57603f42054 100644 --- a/apps/sim/lib/mothership/memories/cleanup-outbox.test.ts +++ b/apps/sim/lib/mothership/memories/cleanup-outbox.test.ts @@ -1,25 +1,21 @@ /** * @vitest-environment node */ +import { resetEnvMock, setEnv } from '@sim/testing/mocks/env.mock' +import { mothershipAgentUrlMock } from '@sim/testing/mocks/mothership-agent-url.mock' +import { mothershipGoFetchMock } from '@sim/testing/mocks/mothership-go-fetch.mock' +import { outboxServiceMock, outboxServiceMockFns } from '@sim/testing/mocks/outbox-service.mock' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockEnv, mockEnqueue } = vi.hoisted(() => ({ - mockEnv: { COPILOT_API_KEY: undefined as string | undefined } as Record< - string, - string | undefined - >, - mockEnqueue: vi.fn(), -})) - -vi.mock('@/lib/core/config/env', () => ({ env: mockEnv })) -vi.mock('@/lib/core/outbox/service', () => ({ enqueueOutboxEvent: mockEnqueue })) +vi.mock('@/lib/core/outbox/service', () => outboxServiceMock) vi.mock('@/lib/mothership/constants', () => ({ SIM_AGENT_API_URL: 'https://agent.example.test' })) -vi.mock('@/lib/mothership/request/go/fetch', () => ({ fetchGo: vi.fn() })) +vi.mock('@/lib/mothership/request/go/fetch', () => mothershipGoFetchMock) vi.mock('@/lib/mothership/request/headers', () => ({ mothershipRequestHeaders: () => ({}) })) -vi.mock('@/lib/mothership/server/agent-url', () => ({ getMothershipSourceEnvHeaders: () => ({}) })) +vi.mock('@/lib/mothership/server/agent-url', () => mothershipAgentUrlMock) import { enqueueMemoryCleanup } from '@/lib/mothership/memories/cleanup-outbox' +const { mockEnqueueOutboxEvent } = outboxServiceMockFns const executor = { insert: vi.fn() } const targets = [ { kind: 'private_owner' as const, userId: 'user-1' }, @@ -28,20 +24,25 @@ const targets = [ describe('enqueueMemoryCleanup', () => { beforeEach(() => { - vi.clearAllMocks() - mockEnv.COPILOT_API_KEY = undefined + resetEnvMock() + setEnv({ + COPILOT_API_KEY: undefined, + COPILOT_DEV_URL: undefined, + COPILOT_STAGING_URL: undefined, + COPILOT_PROD_URL: undefined, + }) }) it('queues nothing when this Sim has no worker credential', async () => { await enqueueMemoryCleanup(executor, targets) - expect(mockEnqueue).not.toHaveBeenCalled() + expect(mockEnqueueOutboxEvent).not.toHaveBeenCalled() }) it('queues one credential-bound event per destination and target', async () => { - mockEnv.COPILOT_API_KEY = 'configured-key' + setEnv({ COPILOT_API_KEY: 'configured-key' }) await enqueueMemoryCleanup(executor, targets) - expect(mockEnqueue).toHaveBeenCalledTimes(targets.length) - for (const [, , payload] of mockEnqueue.mock.calls) + expect(mockEnqueueOutboxEvent).toHaveBeenCalledTimes(targets.length) + for (const [, , payload] of mockEnqueueOutboxEvent.mock.calls) expect(payload).toMatchObject({ baseUrl: 'https://agent.example.test', credentialFingerprint: expect.any(String),