From 969f7f3386c84cf27d3d26235d42571783e8f422 Mon Sep 17 00:00:00 2001 From: conoremclaughlin Date: Tue, 16 Jun 2026 15:18:26 -0700 Subject: [PATCH 1/4] feat: add default_project_id column to studios table Migration adds nullable FK to projects(id) with ON DELETE SET NULL. Supabase types updated to match. Co-Authored-By: Wren --- packages/api/src/data/supabase/types.ts | 3 +++ .../20260616221341_add_default_project_id_to_studios.sql | 9 +++++++++ 2 files changed, 12 insertions(+) create mode 100644 supabase/migrations/20260616221341_add_default_project_id_to_studios.sql diff --git a/packages/api/src/data/supabase/types.ts b/packages/api/src/data/supabase/types.ts index 1c54a5ea..1ee79592 100644 --- a/packages/api/src/data/supabase/types.ts +++ b/packages/api/src/data/supabase/types.ts @@ -3054,6 +3054,7 @@ export type Database = { branch: string; cleaned_at: string | null; created_at: string | null; + default_project_id: string | null; id: string; metadata: Json | null; permissions: Json; @@ -3078,6 +3079,7 @@ export type Database = { branch: string; cleaned_at?: string | null; created_at?: string | null; + default_project_id?: string | null; id?: string; metadata?: Json | null; permissions?: Json; @@ -3102,6 +3104,7 @@ export type Database = { branch?: string; cleaned_at?: string | null; created_at?: string | null; + default_project_id?: string | null; id?: string; metadata?: Json | null; permissions?: Json; diff --git a/supabase/migrations/20260616221341_add_default_project_id_to_studios.sql b/supabase/migrations/20260616221341_add_default_project_id_to_studios.sql new file mode 100644 index 00000000..f4fc2cde --- /dev/null +++ b/supabase/migrations/20260616221341_add_default_project_id_to_studios.sql @@ -0,0 +1,9 @@ +-- Add default_project_id to studios +-- When an SB creates a task group from a studio, it inherits this project +-- unless explicitly overridden or set to null. +ALTER TABLE studios + ADD COLUMN default_project_id uuid REFERENCES projects(id) ON DELETE SET NULL; + +CREATE INDEX idx_studios_default_project_id + ON studios (default_project_id) + WHERE default_project_id IS NOT NULL; From 44c9803cf62ae42942a08e74628d8ced9cb47a19 Mon Sep 17 00:00:00 2001 From: conoremclaughlin Date: Tue, 16 Jun 2026 15:18:38 -0700 Subject: [PATCH 2/4] feat: add projectName filter and studio default project inheritance - list_task_groups now accepts projectName as an alternative to projectId (resolves via projects.findByUserAndName) - Studios gain defaultProjectId field (create, update, get, list, adopt) - create_task_group inherits project from studio's defaultProjectId when no explicit projectId is provided and studioId is in request context Co-Authored-By: Wren --- .../data/repositories/studios.repository.ts | 7 ++++ packages/api/src/mcp/tools/studio-handlers.ts | 22 ++++++++++++ packages/api/src/mcp/tools/task-handlers.ts | 34 +++++++++++++++++-- 3 files changed, 60 insertions(+), 3 deletions(-) diff --git a/packages/api/src/data/repositories/studios.repository.ts b/packages/api/src/data/repositories/studios.repository.ts index 2d993b0e..e8433fab 100644 --- a/packages/api/src/data/repositories/studios.repository.ts +++ b/packages/api/src/data/repositories/studios.repository.ts @@ -29,6 +29,7 @@ export interface Studio { workType: string | null; slug: string | null; roleTemplate: string | null; + defaultProjectId: string | null; status: StudioStatus; metadata: Json; createdAt: string; @@ -49,6 +50,7 @@ export interface CreateStudioInput { purpose?: string; workType?: WorkType; roleTemplate?: string; + defaultProjectId?: string | null; metadata?: Json; } @@ -60,6 +62,7 @@ export interface UpdateStudioInput { roleTemplate?: string | null; worktreePath?: string; slug?: string | null; + defaultProjectId?: string | null; metadata?: Json; archivedAt?: string; cleanedAt?: string; @@ -95,6 +98,7 @@ export class StudiosRepository { workType: (row.work_type as string) || null, slug: (row.slug as string) || null, roleTemplate: (row.role_template as string) || null, + defaultProjectId: (row.default_project_id as string) || null, status: row.status as StudioStatus, metadata: (row.metadata as Json) || {}, createdAt: row.created_at as string, @@ -121,6 +125,7 @@ export class StudiosRepository { purpose: input.purpose, work_type: input.workType, role_template: input.roleTemplate, + default_project_id: input.defaultProjectId ?? null, slug: deriveStudioSlug(input.worktreePath), status: 'active', metadata: input.metadata || {}, @@ -276,6 +281,8 @@ export class StudiosRepository { if (input.roleTemplate !== undefined) updateData.role_template = input.roleTemplate; if (input.worktreePath !== undefined) updateData.worktree_path = input.worktreePath; if (input.slug !== undefined) updateData.slug = input.slug; + if (input.defaultProjectId !== undefined) + updateData.default_project_id = input.defaultProjectId; if (input.metadata !== undefined) updateData.metadata = input.metadata; if (input.archivedAt !== undefined) updateData.archived_at = input.archivedAt; if (input.cleanedAt !== undefined) updateData.cleaned_at = input.cleanedAt; diff --git a/packages/api/src/mcp/tools/studio-handlers.ts b/packages/api/src/mcp/tools/studio-handlers.ts index b7142067..582283aa 100644 --- a/packages/api/src/mcp/tools/studio-handlers.ts +++ b/packages/api/src/mcp/tools/studio-handlers.ts @@ -69,6 +69,11 @@ const createStudioSchema = userIdentifierBaseSchema.extend({ .string() .optional() .describe('Role template name used when creating the studio (e.g., "reviewer", "builder")'), + defaultProjectId: z + .string() + .uuid() + .optional() + .describe('Default project ID for task groups created in this studio'), skipGitOperations: z .boolean() .optional() @@ -113,6 +118,12 @@ const updateStudioSchema = userIdentifierBaseSchema.extend({ .boolean() .optional() .describe('If true, unlink the current session and set status to idle'), + defaultProjectId: z + .string() + .uuid() + .nullable() + .optional() + .describe('Default project ID for task groups created in this studio. Set to null to clear.'), routePatterns: z .array(z.string().max(200)) .optional() @@ -191,6 +202,7 @@ export async function handleCreateStudio(args: unknown, dataComposer: DataCompos baseBranch = 'main', sessionId, roleTemplate, + defaultProjectId, skipGitOperations = false, } = parsed; @@ -251,6 +263,7 @@ export async function handleCreateStudio(args: unknown, dataComposer: DataCompos purpose, workType, roleTemplate, + defaultProjectId, }); } catch (dbError) { // If DB insert fails but git succeeded, attempt cleanup @@ -293,6 +306,7 @@ export async function handleCreateStudio(args: unknown, dataComposer: DataCompos purpose: studio.purpose, workType: studio.workType, roleTemplate: studio.roleTemplate, + defaultProjectId: studio.defaultProjectId, status: studio.status, sessionId: studio.sessionId, createdAt: studio.createdAt, @@ -339,6 +353,7 @@ export async function handleListStudios(args: unknown, dataComposer: DataCompose status: w.status, workType: w.workType, roleTemplate: w.roleTemplate, + defaultProjectId: w.defaultProjectId, hasLinkedSession: !!w.sessionId, createdAt: w.createdAt, })), @@ -381,6 +396,7 @@ export async function handleGetStudio(args: unknown, dataComposer: DataComposer) purpose: studio.purpose, workType: studio.workType, roleTemplate: studio.roleTemplate, + defaultProjectId: studio.defaultProjectId, status: studio.status, sessionId: studio.sessionId, metadata: studio.metadata, @@ -406,6 +422,7 @@ export async function handleUpdateStudio(args: unknown, dataComposer: DataCompos slug, sessionId, unlinkSession, + defaultProjectId, routePatterns, } = parsed; const studiosRepo = dataComposer.repositories.studios; @@ -439,6 +456,9 @@ export async function handleUpdateStudio(args: unknown, dataComposer: DataCompos if (slug !== undefined) { updateObj.slug = slug; } + if (defaultProjectId !== undefined) { + updateObj.defaultProjectId = defaultProjectId; + } if (routePatterns !== undefined) { updateObj.routePatterns = routePatterns; } @@ -458,6 +478,7 @@ export async function handleUpdateStudio(args: unknown, dataComposer: DataCompos worktreePath: updated.worktreePath, purpose: updated.purpose, roleTemplate: updated.roleTemplate, + defaultProjectId: updated.defaultProjectId, status: updated.status, sessionId: updated.sessionId, updatedAt: updated.updatedAt, @@ -599,6 +620,7 @@ export async function handleAdoptStudio(args: unknown, dataComposer: DataCompose worktreePath: updated.worktreePath, purpose: updated.purpose, roleTemplate: updated.roleTemplate, + defaultProjectId: updated.defaultProjectId, status: updated.status, sessionId: updated.sessionId, updatedAt: updated.updatedAt, diff --git a/packages/api/src/mcp/tools/task-handlers.ts b/packages/api/src/mcp/tools/task-handlers.ts index f4afec5d..d28e6f40 100644 --- a/packages/api/src/mcp/tools/task-handlers.ts +++ b/packages/api/src/mcp/tools/task-handlers.ts @@ -1177,6 +1177,8 @@ export async function handleCreateTaskGroup( return mcpResponse({ success: false, error: 'User not found' }, true); } + let effectiveProjectId = args.projectId ?? null; + if (args.projectId) { const project = await dataComposer.repositories.projects.findById(args.projectId); if (!project) { @@ -1189,6 +1191,13 @@ export async function handleCreateTaskGroup( const agentId = getEffectiveAgentId(args.agentId); const reqCtx = getRequestContext(); + + if (!effectiveProjectId && reqCtx?.studioId) { + const studio = await dataComposer.repositories.studios.findById(reqCtx.studioId); + if (studio?.defaultProjectId) { + effectiveProjectId = studio.defaultProjectId; + } + } const sbId = await resolveIdentityIdForAgent( dataComposer, resolved.user.id, @@ -1199,7 +1208,7 @@ export async function handleCreateTaskGroup( const group = await dataComposer.repositories.taskGroups.create({ user_id: resolved.user.id, sb_id: sbId, - project_id: args.projectId ?? null, + project_id: effectiveProjectId, title: args.title, description: args.description, status: args.status, @@ -1424,7 +1433,11 @@ export const listTaskGroupsSchema = z.object({ .describe( 'Filter by one or more statuses: active, paused, completed, cancelled. Omit or pass empty array to include all statuses.' ), - projectId: z.string().uuid().optional().describe('Filter by project'), + projectId: z.string().uuid().optional().describe('Filter by project UUID'), + projectName: z + .string() + .optional() + .describe('Filter by project name (exact match). Alternative to projectId.'), sbId: z.string().uuid().optional().describe('Filter by agent identity UUID'), autonomousOnly: z.boolean().optional().default(false).describe('Only autonomous groups'), includeTaskCounts: z @@ -1448,9 +1461,24 @@ export async function handleListTaskGroups( // Empty array is treated the same as omitted: include all statuses. const statuses = args.statuses && args.statuses.length > 0 ? args.statuses : undefined; + let projectId = args.projectId; + if (!projectId && args.projectName) { + const project = await dataComposer.repositories.projects.findByUserAndName( + resolved.user.id, + args.projectName + ); + if (!project) { + return mcpResponse( + { success: false, error: `Project not found: ${args.projectName}` }, + true + ); + } + projectId = project.id; + } + const groups = await dataComposer.repositories.taskGroups.listByUser(resolved.user.id, { status: statuses, - projectId: args.projectId, + projectId, sbId: args.sbId, autonomousOnly: args.autonomousOnly, limit: args.limit, From 5e9f89e8362e3eaf0f3b4bcee429bba3ed163cc2 Mon Sep 17 00:00:00 2001 From: conoremclaughlin Date: Tue, 16 Jun 2026 15:47:11 -0700 Subject: [PATCH 3/4] fix: validate defaultProjectId ownership at studio and task group boundaries Lumen's review caught that defaultProjectId was accepted and inherited without checking it belongs to the resolved user. Since DB access is service-role, FK/RLS won't enforce that boundary. Fixes: - create_studio: validate defaultProjectId against user before insert - update_studio: validate string defaultProjectId against user (null = clear) - create_task_group: re-validate inherited studio.defaultProjectId against resolved user before writing to task_groups.project_id Tests (5 new, 91 total): - Inherits defaultProjectId from studio when valid - Skips inheritance when default project belongs to different user - Explicit projectId takes precedence over studio default - projectName resolves to projectId for list filtering - projectName returns error when project not found Co-Authored-By: Wren --- packages/api/src/mcp/tools/studio-handlers.ts | 16 +- .../api/src/mcp/tools/task-handlers.test.ts | 215 ++++++++++++++++++ packages/api/src/mcp/tools/task-handlers.ts | 7 +- 3 files changed, 236 insertions(+), 2 deletions(-) diff --git a/packages/api/src/mcp/tools/studio-handlers.ts b/packages/api/src/mcp/tools/studio-handlers.ts index 582283aa..5f1e54ce 100644 --- a/packages/api/src/mcp/tools/studio-handlers.ts +++ b/packages/api/src/mcp/tools/studio-handlers.ts @@ -206,6 +206,13 @@ export async function handleCreateStudio(args: unknown, dataComposer: DataCompos skipGitOperations = false, } = parsed; + if (defaultProjectId) { + const project = await dataComposer.repositories.projects.findById(defaultProjectId); + if (!project || project.user_id !== resolved.user.id) { + return errorResponse('defaultProjectId not found or does not belong to this user'); + } + } + // Resolve to the main worktree root (handles case where repoRoot is a linked worktree) const mainRoot = resolveMainWorktree(repoRoot); @@ -410,7 +417,7 @@ export async function handleGetStudio(args: unknown, dataComposer: DataComposer) export async function handleUpdateStudio(args: unknown, dataComposer: DataComposer) { const parsed = updateStudioSchema.parse(args); - await resolveUserOrThrow(parsed, dataComposer); + const resolved = await resolveUserOrThrow(parsed, dataComposer); const { studioId, @@ -433,6 +440,13 @@ export async function handleUpdateStudio(args: unknown, dataComposer: DataCompos return errorResponse(`Studio not found: ${studioId}`); } + if (typeof defaultProjectId === 'string') { + const project = await dataComposer.repositories.projects.findById(defaultProjectId); + if (!project || project.user_id !== resolved.user.id) { + return errorResponse('defaultProjectId not found or does not belong to this user'); + } + } + let updated; if (unlinkSession) { diff --git a/packages/api/src/mcp/tools/task-handlers.test.ts b/packages/api/src/mcp/tools/task-handlers.test.ts index 88e91c31..4b44a5db 100644 --- a/packages/api/src/mcp/tools/task-handlers.test.ts +++ b/packages/api/src/mcp/tools/task-handlers.test.ts @@ -91,6 +91,7 @@ function createMockDataComposer() { const mockProjectsRepo = { findById: vi.fn(), + findByUserAndName: vi.fn(), }; const mockMemoryRepo = { @@ -101,6 +102,10 @@ function createMockDataComposer() { logActivity: vi.fn().mockResolvedValue({ id: 'activity-1' }), }; + const mockStudiosRepo = { + findById: vi.fn(), + }; + // Minimal supabase client stub for identity/agent_identities lookups // and task_group_comments inserts/queries. const identityChain = { @@ -137,6 +142,7 @@ function createMockDataComposer() { tasks: mockTasksRepo, taskGroups: mockTaskGroupsRepo, projects: mockProjectsRepo, + studios: mockStudiosRepo, memory: mockMemoryRepo, activityStream: mockActivityStreamRepo, }, @@ -1325,6 +1331,166 @@ describe('handleCreateTaskGroup', () => { expect(data.error).toBe('User not found'); expect(dc.repositories.taskGroups.create).not.toHaveBeenCalled(); }); + + it('inherits defaultProjectId from studio when no projectId provided', async () => { + const { getRequestContext } = await import('../../utils/request-context'); + (getRequestContext as any).mockReturnValueOnce({ + studioId: 'studio-1', + }); + + dc.repositories.studios.findById.mockResolvedValue({ + id: 'studio-1', + defaultProjectId: 'proj-default', + }); + dc.repositories.projects.findById.mockResolvedValue({ + id: 'proj-default', + user_id: 'user-123', + name: 'Inkwell', + }); + dc.repositories.taskGroups.create.mockResolvedValue({ + id: 'grp-2', + user_id: 'user-123', + sb_id: null, + project_id: 'proj-default', + title: 'Inherited project group', + status: 'active', + priority: 'normal', + tags: [], + metadata: {}, + autonomous: false, + max_sessions: null, + sessions_used: 0, + context_summary: null, + next_run_after: null, + output_target: null, + output_status: null, + thread_key: null, + created_at: '2026-06-16T10:00:00Z', + updated_at: '2026-06-16T10:00:00Z', + }); + + const response = await handleCreateTaskGroup( + { + userId: 'user-123', + title: 'Inherited project group', + priority: 'normal', + status: 'active', + } as any, + dc as any + ); + + const data = parseResponse(response); + expect(data.success).toBe(true); + expect(dc.repositories.taskGroups.create).toHaveBeenCalledWith( + expect.objectContaining({ project_id: 'proj-default' }) + ); + }); + + it('skips studio default project if it belongs to a different user', async () => { + const { getRequestContext } = await import('../../utils/request-context'); + (getRequestContext as any).mockReturnValueOnce({ + studioId: 'studio-1', + }); + + dc.repositories.studios.findById.mockResolvedValue({ + id: 'studio-1', + defaultProjectId: 'proj-foreign', + }); + dc.repositories.projects.findById.mockResolvedValue({ + id: 'proj-foreign', + user_id: 'other-user-999', + name: 'Foreign Project', + }); + dc.repositories.taskGroups.create.mockResolvedValue({ + id: 'grp-3', + user_id: 'user-123', + sb_id: null, + project_id: null, + title: 'No project', + status: 'active', + priority: 'normal', + tags: [], + metadata: {}, + autonomous: false, + max_sessions: null, + sessions_used: 0, + context_summary: null, + next_run_after: null, + output_target: null, + output_status: null, + thread_key: null, + created_at: '2026-06-16T10:00:00Z', + updated_at: '2026-06-16T10:00:00Z', + }); + + const response = await handleCreateTaskGroup( + { + userId: 'user-123', + title: 'No project', + priority: 'normal', + status: 'active', + } as any, + dc as any + ); + + const data = parseResponse(response); + expect(data.success).toBe(true); + expect(dc.repositories.taskGroups.create).toHaveBeenCalledWith( + expect.objectContaining({ project_id: null }) + ); + }); + + it('explicit projectId takes precedence over studio default', async () => { + const { getRequestContext } = await import('../../utils/request-context'); + (getRequestContext as any).mockReturnValueOnce({ + studioId: 'studio-1', + }); + + dc.repositories.projects.findById.mockResolvedValue({ + id: '00000000-0000-0000-0000-000000000002', + user_id: 'user-123', + name: 'Explicit Project', + }); + dc.repositories.taskGroups.create.mockResolvedValue({ + id: 'grp-4', + user_id: 'user-123', + sb_id: null, + project_id: '00000000-0000-0000-0000-000000000002', + title: 'Explicit project group', + status: 'active', + priority: 'normal', + tags: [], + metadata: {}, + autonomous: false, + max_sessions: null, + sessions_used: 0, + context_summary: null, + next_run_after: null, + output_target: null, + output_status: null, + thread_key: null, + created_at: '2026-06-16T10:00:00Z', + updated_at: '2026-06-16T10:00:00Z', + }); + + const response = await handleCreateTaskGroup( + { + userId: 'user-123', + title: 'Explicit project group', + projectId: '00000000-0000-0000-0000-000000000002', + priority: 'normal', + status: 'active', + } as any, + dc as any + ); + + const data = parseResponse(response); + expect(data.success).toBe(true); + expect(dc.repositories.studios.findById).not.toHaveBeenCalled(); + expect(dc.repositories.taskGroups.create).toHaveBeenCalledWith( + expect.objectContaining({ project_id: '00000000-0000-0000-0000-000000000002' }) + ); + }); }); // ===================================================== @@ -2037,6 +2203,55 @@ describe('handleListTaskGroups', () => { expect(data.error).toBe('User not found'); expect(dc.repositories.taskGroups.listByUser).not.toHaveBeenCalled(); }); + + it('resolves projectName to projectId for filtering', async () => { + dc.repositories.projects.findByUserAndName.mockResolvedValue({ + id: 'proj-by-name', + user_id: 'user-123', + name: 'Inkwell', + }); + dc.repositories.taskGroups.listByUser.mockResolvedValue([]); + dc.repositories.taskGroups.taskCountsByGroup.mockResolvedValue({}); + + const response = await handleListTaskGroups( + { + userId: 'user-123', + projectName: 'Inkwell', + autonomousOnly: false, + includeTaskCounts: true, + limit: 200, + } as any, + dc as any + ); + + const data = parseResponse(response); + expect(data.success).toBe(true); + expect(dc.repositories.projects.findByUserAndName).toHaveBeenCalledWith('user-123', 'Inkwell'); + expect(dc.repositories.taskGroups.listByUser).toHaveBeenCalledWith( + 'user-123', + expect.objectContaining({ projectId: 'proj-by-name' }) + ); + }); + + it('returns error when projectName does not match any project', async () => { + dc.repositories.projects.findByUserAndName.mockResolvedValue(null); + + const response = await handleListTaskGroups( + { + userId: 'user-123', + projectName: 'Nonexistent', + autonomousOnly: false, + includeTaskCounts: true, + limit: 200, + } as any, + dc as any + ); + + const data = parseResponse(response); + expect(response.isError).toBe(true); + expect(data.error).toBe('Project not found: Nonexistent'); + expect(dc.repositories.taskGroups.listByUser).not.toHaveBeenCalled(); + }); }); // ===================================================== diff --git a/packages/api/src/mcp/tools/task-handlers.ts b/packages/api/src/mcp/tools/task-handlers.ts index d28e6f40..4868b73e 100644 --- a/packages/api/src/mcp/tools/task-handlers.ts +++ b/packages/api/src/mcp/tools/task-handlers.ts @@ -1195,7 +1195,12 @@ export async function handleCreateTaskGroup( if (!effectiveProjectId && reqCtx?.studioId) { const studio = await dataComposer.repositories.studios.findById(reqCtx.studioId); if (studio?.defaultProjectId) { - effectiveProjectId = studio.defaultProjectId; + const defaultProject = await dataComposer.repositories.projects.findById( + studio.defaultProjectId + ); + if (defaultProject && defaultProject.user_id === resolved.user.id) { + effectiveProjectId = studio.defaultProjectId; + } } } const sbId = await resolveIdentityIdForAgent( From eb33f3087732cf2c58c92047b9c85d1cf3ea5e19 Mon Sep 17 00:00:00 2001 From: conoremclaughlin Date: Tue, 16 Jun 2026 15:57:23 -0700 Subject: [PATCH 4/4] fix: add studio ownership guard in handleUpdateStudio Verify existing.userId === resolved.user.id before any update path (link, unlink, or field update). Without this, a caller who knows another user's studio UUID could modify it via service-role DB access. Uses the same opaque 'Studio not found' error for foreign studios to avoid leaking existence information. Co-Authored-By: Wren --- packages/api/src/mcp/tools/studio-handlers.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/api/src/mcp/tools/studio-handlers.ts b/packages/api/src/mcp/tools/studio-handlers.ts index 5f1e54ce..9a65092c 100644 --- a/packages/api/src/mcp/tools/studio-handlers.ts +++ b/packages/api/src/mcp/tools/studio-handlers.ts @@ -434,11 +434,14 @@ export async function handleUpdateStudio(args: unknown, dataComposer: DataCompos } = parsed; const studiosRepo = dataComposer.repositories.studios; - // Verify studio exists + // Verify studio exists and belongs to this user const existing = await studiosRepo.findById(studioId); if (!existing) { return errorResponse(`Studio not found: ${studioId}`); } + if (existing.userId !== resolved.user.id) { + return errorResponse(`Studio not found: ${studioId}`); + } if (typeof defaultProjectId === 'string') { const project = await dataComposer.repositories.projects.findById(defaultProjectId);