Skip to content

Commit 8d3e751

Browse files
committed
feat(memory): authorize scoped access and durable cleanup
1 parent 7a35c68 commit 8d3e751

18 files changed

Lines changed: 1339 additions & 32 deletions

File tree

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { resolveMemoryAccessContract } from '@/lib/api/contracts/mothership-memories'
2+
import {
3+
defineInternalJsonRoute,
4+
internalOrchestrationErrorPolicy,
5+
internalRateLimits,
6+
} from '@/lib/api/server/routes'
7+
import { internalCopilotAuth } from '@/lib/mothership/auth/internal'
8+
import {
9+
MEMORY_ACCESS_AUDIENCE,
10+
resolveMemoryAccess,
11+
resolveMemoryAccessOperation,
12+
} from '@/lib/mothership/memories/application/resolve-access'
13+
14+
export const POST = defineInternalJsonRoute({
15+
contract: resolveMemoryAccessContract,
16+
auth: internalCopilotAuth(MEMORY_ACCESS_AUDIENCE, { organization: true }),
17+
operation: resolveMemoryAccessOperation,
18+
rateLimit: internalRateLimits.none({
19+
reason:
20+
'Private worker preflight rechecks live memory authority without reading or writing note content.',
21+
}),
22+
errorPolicy: internalOrchestrationErrorPolicy,
23+
mapInput: ({ body }) => body,
24+
useCase: resolveMemoryAccess,
25+
})

‎apps/sim/lib/api/contracts/mothership-assistant-tools.ts‎

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -404,12 +404,26 @@ export const listWorkspacesResultSchema = z.object({
404404
nextCursor: z.uuid().nullable(),
405405
storedNotes: z
406406
.array(
407-
z.object({
408-
id: z.string(),
409-
kind: z.enum(['user_pref', 'workspace_fact']),
410-
content: z.string(),
411-
updatedAt: z.iso.datetime(),
412-
})
407+
z
408+
.object({
409+
id: z.string(),
410+
content: z.string(),
411+
updatedAt: z.iso.datetime(),
412+
by: z.enum(['this user', 'another workspace member', 'another user']).optional(),
413+
chat: z.enum(['this chat', 'another chat']).optional(),
414+
})
415+
.and(
416+
z.union([
417+
z.object({ kind: z.enum(['user_pref', 'workspace_fact']) }),
418+
z.object({
419+
scope: z.enum(['personal', 'project', 'workspace']),
420+
visibility: z.enum(['private', 'shared']),
421+
projectId: z.uuid().nullable(),
422+
workspaceId: z.uuid().nullable(),
423+
revision: z.number().int().positive(),
424+
}),
425+
])
426+
)
413427
)
414428
.optional(),
415429
})
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { defineRouteContract } from '@/lib/api/contracts/types'
2+
import { MemoryAccessInput, MemoryAccessResult } from '@/lib/mothership/generated/memory-access'
3+
4+
export const resolveMemoryAccessContract = defineRouteContract({
5+
method: 'POST',
6+
path: '/api/mothership/memories/access',
7+
body: MemoryAccessInput,
8+
response: { mode: 'json', schema: MemoryAccessResult },
9+
})

‎apps/sim/lib/core/outbox/handlers.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import {
3232
import { KNOWLEDGE_DOCUMENT_RECOVERY_OUTBOX_EVENT } from '@/lib/knowledge/documents/processing-recovery-event'
3333
import { KNOWLEDGE_STORAGE_CLEANUP_EVENT } from '@/lib/knowledge/documents/storage-cleanup-event'
3434
import { INBOX_CLEANUP_EVENT } from '@/lib/mothership/inbox/cleanup-event'
35+
import { MEMORY_CLEANUP_EVENT } from '@/lib/mothership/memories/cleanup-event'
3536
import { ORGANIZATION_RESOURCE_CLEANUP_EVENT } from '@/lib/organizations/resource-cleanup-event'
3637
import {
3738
PROJECT_FILE_DOCUMENT_RETIRE_EVENT,
@@ -67,6 +68,15 @@ import { FORK_CONTENT_COPY_EVENT } from '@/ee/workspace-forking/application/cont
6768
* export of the module as used.
6869
*/
6970
export const OUTBOX_HANDLER_GROUPS: readonly LazyOutboxHandlerGroup[] = [
71+
{
72+
events: [MEMORY_CLEANUP_EVENT],
73+
load: async () => {
74+
const { memoryCleanupOutboxHandlers } = await import(
75+
'@/lib/mothership/memories/cleanup-outbox'
76+
)
77+
return memoryCleanupOutboxHandlers
78+
},
79+
},
7080
{
7181
events: [SLACK_SEARCH_TURN_EVENT],
7282
load: async () => {

‎apps/sim/lib/data-drains/sources/copilot-chats.ts‎

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { copilotChats, copilotMessages } from '@sim/db/schema'
2+
import { isRecordLike, omit } from '@sim/utils/object'
23
import { and, asc, eq, inArray, isNull, or, sql } from 'drizzle-orm'
4+
import { persistedContentBlockSchema } from '@/lib/api/contracts/copilot-messages'
35
import { assertKnownSizeWithinLimit } from '@/lib/core/utils/stream-limits'
46
import { DATA_DRAIN_LIMITS } from '@/lib/data-drains/limits'
57
import {
@@ -11,6 +13,7 @@ import {
1113
} from '@/lib/data-drains/sources/cursor'
1214
import { readBoundedSourcePage, workspaceInOrganization } from '@/lib/data-drains/sources/helpers'
1315
import type { Cursor, DrainSource, SourcePageInput } from '@/lib/data-drains/types'
16+
import { stripToolResultOutput } from '@/lib/mothership/chat/persisted-message'
1417

1518
/**
1619
* The transcript no longer lives on `copilot_chats.messages` — it is assembled
@@ -145,6 +148,33 @@ async function* pages(input: SourcePageInput): AsyncIterable<CopilotChatRow[]> {
145148
}
146149
}
147150

151+
/** Tool payloads can contain private notes even when their mutation failed or targeted a shared note. */
152+
function projectExportMessage(message: unknown): unknown {
153+
if (!isRecordLike(message) || !Array.isArray(message.contentBlocks)) return message
154+
return {
155+
...message,
156+
contentBlocks: message.contentBlocks.map((block: unknown) => {
157+
if (!isRecordLike(block) || !isRecordLike(block.toolCall)) return block
158+
if (block.toolCall.name !== 'remember') {
159+
if (!isRecordLike(block.toolCall.result) || !('output' in block.toolCall.result))
160+
return block
161+
const parsed = persistedContentBlockSchema.safeParse(block)
162+
if (!parsed.success) return { ...block, toolCall: omit(block.toolCall, ['result']) }
163+
const projected = stripToolResultOutput({ contentBlocks: [parsed.data] }).contentBlocks[0]
164+
return { ...block, toolCall: { ...block.toolCall, result: projected.toolCall?.result } }
165+
}
166+
return {
167+
type: 'tool',
168+
toolCall: {
169+
name: 'remember',
170+
...(typeof block.toolCall.id === 'string' ? { id: block.toolCall.id } : {}),
171+
...(typeof block.toolCall.state === 'string' ? { state: block.toolCall.state } : {}),
172+
},
173+
}
174+
}),
175+
}
176+
}
177+
148178
export const copilotChatsSource: DrainSource<CopilotChatRow> = {
149179
type: 'copilot_chats',
150180
displayName: 'Chats',
@@ -157,7 +187,7 @@ export const copilotChatsSource: DrainSource<CopilotChatRow> = {
157187
workspaceId: row.workspaceId,
158188
type: row.type,
159189
title: row.title,
160-
messages: row.messages,
190+
messages: row.messages.map(projectExportMessage),
161191
model: row.model,
162192
conversationId: row.conversationId,
163193
previewYaml: row.previewYaml,

‎apps/sim/lib/data-drains/sources/coverage.integration.ts‎

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,81 @@ describe('Drain source coverage and payload safety in PostgreSQL', () => {
113113
expect(runs).toEqual([runId])
114114
})
115115

116+
it('omits memory payloads from exports without changing owner history or visible replies', async () => {
117+
const messageId = generateId()
118+
const content = {
119+
role: 'assistant',
120+
content: 'Visible reply',
121+
contentBlocks: [
122+
{ type: 'text', content: 'Visible reply' },
123+
{
124+
type: 'tool',
125+
toolCall: {
126+
id: 'discovery',
127+
name: 'list_workspaces',
128+
state: 'success',
129+
result: {
130+
success: true,
131+
output: {
132+
stdout: JSON.stringify({ storedNotes: [{ content: 'hidden-memory-discovery' }] }),
133+
},
134+
},
135+
},
136+
},
137+
...['private', 'shared', 'unknown'].map((visibility) => ({
138+
type: 'tool',
139+
content: 'hidden-memory-content',
140+
error: 'hidden-memory-error',
141+
toolCall: {
142+
id: visibility,
143+
name: 'remember',
144+
state: 'success',
145+
params: { visibility, content: 'hidden-memory-args' },
146+
result: { success: true, output: 'hidden-memory-result' },
147+
activityDescription: 'hidden-memory-activity',
148+
display: { title: 'hidden-memory-title' },
149+
},
150+
})),
151+
{
152+
type: 'tool',
153+
toolCall: {
154+
id: 'other',
155+
name: 'read',
156+
state: 'success',
157+
params: { path: 'visible-path' },
158+
},
159+
},
160+
],
161+
}
162+
await runtime.db.insert(runtime.schema.copilotMessages).values({
163+
id: messageId,
164+
messageId,
165+
chatId,
166+
role: 'assistant',
167+
content,
168+
createdAt: old,
169+
})
170+
try {
171+
const exported: unknown[] = []
172+
for await (const rows of runtime.copilotChatsSource.pages(input())) {
173+
exported.push(...rows.map((row) => runtime.copilotChatsSource.serialize(row)))
174+
}
175+
const wire = JSON.stringify(exported)
176+
expect(wire).not.toContain('hidden-memory')
177+
expect(wire).toContain('Visible reply')
178+
expect(wire).toContain('visible-path')
179+
const [stored] = await runtime.db
180+
.select({ content: runtime.schema.copilotMessages.content })
181+
.from(runtime.schema.copilotMessages)
182+
.where(runtime.eq(runtime.schema.copilotMessages.id, messageId))
183+
expect(stored.content).toEqual(content)
184+
} finally {
185+
await runtime.db
186+
.delete(runtime.schema.copilotMessages)
187+
.where(runtime.eq(runtime.schema.copilotMessages.id, messageId))
188+
}
189+
})
190+
116191
it('exports correlation metadata without internal controller tokens or billing receipts', async () => {
117192
const [row] = await runtime.db
118193
.select()

‎apps/sim/lib/mothership/chat/persisted-message.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,9 @@ export interface PersistedMessage {
139139
* reference when there is nothing to strip, preserving memoized identity for
140140
* read-side consumers.
141141
*/
142-
export function stripToolResultOutput(message: PersistedMessage): PersistedMessage {
142+
export function stripToolResultOutput<T extends Pick<PersistedMessage, 'contentBlocks'>>(
143+
message: T
144+
): T {
143145
if (!message.contentBlocks?.length) return message
144146
let changed = false
145147
const contentBlocks = message.contentBlocks.map((block) => {
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
// GENERATED — do not edit. Source of truth: mothership worker packages/contracts/src/memory-access.ts
2+
// Regenerate with `bun run contracts:sync` in the worker.
3+
4+
import { z } from "zod";
5+
6+
const permissionsSchema = z.strictObject({
7+
read: z.literal(true),
8+
privateMutation: z.literal(true),
9+
sharedMutation: z.boolean(),
10+
});
11+
const projectSchema = z.strictObject({
12+
scope: z.literal("project"),
13+
id: z.uuid(),
14+
name: z.string(),
15+
permissions: permissionsSchema,
16+
});
17+
18+
export const MemoryAccessInput = z.strictObject({
19+
target: z.discriminatedUnion("scope", [
20+
z.strictObject({ scope: z.literal("personal") }),
21+
z.strictObject({ scope: z.literal("workspace"), workspaceId: z.uuid() }),
22+
z.strictObject({ scope: z.literal("project"), projectId: z.uuid() }),
23+
]),
24+
action: z.enum(["read", "private_mutation", "shared_mutation"]),
25+
});
26+
export type MemoryAccessInput = z.infer<typeof MemoryAccessInput>;
27+
28+
export const MemoryAccessResult = z.strictObject({
29+
success: z.literal(true),
30+
version: z.literal(1),
31+
actor: z.strictObject({ userId: z.string().min(1), chatId: z.uuid() }),
32+
target: z.discriminatedUnion("scope", [
33+
z.strictObject({
34+
scope: z.literal("personal"),
35+
permissions: permissionsSchema.extend({ sharedMutation: z.literal(false) }),
36+
}),
37+
projectSchema,
38+
z.strictObject({
39+
scope: z.literal("workspace"),
40+
id: z.uuid(),
41+
name: z.string(),
42+
permissions: permissionsSchema,
43+
currentProject: projectSchema.nullable(),
44+
}),
45+
]),
46+
});
47+
export type MemoryAccessResult = z.infer<typeof MemoryAccessResult>;
48+
49+
export const MemoryCleanupInput = z.strictObject({
50+
version: z.literal(1),
51+
target: z.discriminatedUnion("kind", [
52+
z.strictObject({ kind: z.literal("private_owner"), userId: z.string().min(1).max(200) }),
53+
z.strictObject({ kind: z.literal("workspace"), workspaceId: z.uuid() }),
54+
z.strictObject({ kind: z.literal("project"), projectId: z.uuid() }),
55+
]),
56+
});
57+
export type MemoryCleanupInput = z.infer<typeof MemoryCleanupInput>;
58+
export const MemoryCleanupResult = z.strictObject({ success: z.literal(true) });

‎apps/sim/lib/mothership/generated/sim-assistant-tools.generated.ts‎

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -424,12 +424,26 @@ export const listWorkspacesResultSchema = z.object({
424424
nextCursor: z.uuid().nullable(),
425425
storedNotes: z
426426
.array(
427-
z.object({
428-
id: z.string(),
429-
kind: z.enum(['user_pref', 'workspace_fact']),
430-
content: z.string(),
431-
updatedAt: z.iso.datetime(),
432-
})
427+
z
428+
.object({
429+
id: z.string(),
430+
content: z.string(),
431+
updatedAt: z.iso.datetime(),
432+
by: z.enum(['this user', 'another workspace member', 'another user']).optional(),
433+
chat: z.enum(['this chat', 'another chat']).optional(),
434+
})
435+
.and(
436+
z.union([
437+
z.object({ kind: z.enum(['user_pref', 'workspace_fact']) }),
438+
z.object({
439+
scope: z.enum(['personal', 'project', 'workspace']),
440+
visibility: z.enum(['private', 'shared']),
441+
projectId: z.uuid().nullable(),
442+
workspaceId: z.uuid().nullable(),
443+
revision: z.number().int().positive(),
444+
}),
445+
])
446+
)
433447
)
434448
.optional(),
435449
})

‎apps/sim/lib/mothership/generated/sim-transport.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
import { z } from "zod";
55
import { IntegrationCatalogRequest } from "./integration-catalog";
6+
import { MemoryAccessInput } from "./memory-access";
67
import { RunControlRequest } from "./run-control";
78
import { TaskWakeRequest, WorkflowWatchRequest } from "./tasks";
89

@@ -26,6 +27,7 @@ export type SimScope = z.infer<typeof SimScope>;
2627

2728
/** Only idempotent controls use the idle channel; tool effects use the run event log. */
2829
export const SimControlOperation = z.discriminatedUnion("kind", [
30+
z.object({ kind: z.literal("memory_access"), input: MemoryAccessInput }),
2931
z.object({ kind: z.literal("run_control"), input: RunControlRequest }),
3032
z.object({ kind: z.literal("workflow_status"), input: WorkflowWatchRequest }),
3133
z.object({ kind: z.literal("wake"), input: TaskWakeRequest }),

0 commit comments

Comments
 (0)