From bf3c6e1b7ddc44b8d19d29300092bc97947e15f1 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 22 Aug 2026 15:35:25 -0700 Subject: [PATCH 1/5] fix(copilot): enforce delegated workspace scope in query_logs and set_environment_variables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A model-supplied workspaceId (or a workflowId in another workspace) could steer both tools to any workspace the acting principal can reach, bypassing the asserted-vs-context workspace comparison the rest of the Copilot tool surface enforces. Both now resolve through requireCopilotWorkspace — moved to a shared module — so an asserted workspace may only re-state the chat's execution workspace, and the default-workspace fallback is removed so a missing scope fails closed. --- .../copilot/tools/handlers/vfs-mutate.test.ts | 3 + .../lib/copilot/tools/handlers/vfs-mutate.ts | 2 +- .../server/files/file-folder-application.ts | 19 ------ .../tools/server/files/file-folders.ts | 6 +- .../user/set-environment-variables.test.ts | 67 ++++++++++++++----- .../server/user/set-environment-variables.ts | 25 ++++--- .../tools/server/workflow/query-logs.test.ts | 30 +++++++++ .../tools/server/workflow/query-logs.ts | 13 ++-- .../copilot/tools/server/workspace-scope.ts | 23 +++++++ 9 files changed, 128 insertions(+), 60 deletions(-) create mode 100644 apps/sim/lib/copilot/tools/server/workspace-scope.ts diff --git a/apps/sim/lib/copilot/tools/handlers/vfs-mutate.test.ts b/apps/sim/lib/copilot/tools/handlers/vfs-mutate.test.ts index 2d2d6880dbd..cfe51fc9d23 100644 --- a/apps/sim/lib/copilot/tools/handlers/vfs-mutate.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/vfs-mutate.test.ts @@ -86,6 +86,9 @@ vi.mock('@/lib/copilot/tools/server/files/file-folder-application', () => ({ ...(fileId ? { resourceScope: { fileId } } : {}), })), ensureCopilotFileFolderPath: mocks.ensureCopilotFileFolderPath, +})) + +vi.mock('@/lib/copilot/tools/server/workspace-scope', () => ({ requireCopilotWorkspace: vi.fn((context) => context.workspaceId), })) diff --git a/apps/sim/lib/copilot/tools/handlers/vfs-mutate.ts b/apps/sim/lib/copilot/tools/handlers/vfs-mutate.ts index 2be4f984ac2..0a65249ebef 100644 --- a/apps/sim/lib/copilot/tools/handlers/vfs-mutate.ts +++ b/apps/sim/lib/copilot/tools/handlers/vfs-mutate.ts @@ -11,7 +11,7 @@ import { } from '@/lib/copilot/application/execute-workflow-use-case' import { messageForCopilotTableError } from '@/lib/copilot/auth/table-delegation' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' -import { requireCopilotWorkspace } from '@/lib/copilot/tools/server/files/file-folder-application' +import { requireCopilotWorkspace } from '@/lib/copilot/tools/server/workspace-scope' import { decodeVfsPathSegments, encodeVfsPathSegments } from '@/lib/copilot/vfs/path-utils' import { asOrchestrationError } from '@/lib/core/orchestration/types' import { PlatformEvents } from '@/lib/core/telemetry' diff --git a/apps/sim/lib/copilot/tools/server/files/file-folder-application.ts b/apps/sim/lib/copilot/tools/server/files/file-folder-application.ts index 539e6b2a308..d613e7dbca5 100644 --- a/apps/sim/lib/copilot/tools/server/files/file-folder-application.ts +++ b/apps/sim/lib/copilot/tools/server/files/file-folder-application.ts @@ -1,27 +1,8 @@ import { executeCopilotFileUseCase } from '@/lib/copilot/application/execute-file-use-case' import type { CopilotFileDelegationContext } from '@/lib/copilot/auth/file-delegation' -import { OrchestrationError } from '@/lib/core/orchestration/types' import { findWorkspaceFileFolderIdByPath } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' import { createWorkspaceFileFolderOperation } from '@/lib/workspace-files/application/workspace-file-folders' -/** - * Returns the execution workspace only. Model-provided workspace ids may assert the same value, - * but can never select a different workspace or trigger a default-workspace fallback. - */ -export function requireCopilotWorkspace( - context: CopilotFileDelegationContext, - assertedWorkspaceId?: string -): string { - if (!context.workspaceId) throw new Error('Copilot execution workspace is required') - if (assertedWorkspaceId && assertedWorkspaceId !== context.workspaceId) { - throw new OrchestrationError( - 'validation', - 'Workspace ID does not match the Copilot execution workspace' - ) - } - return context.workspaceId -} - /** Creates missing parent folders through the shared folder application operation. */ export async function ensureCopilotFileFolderPath( context: CopilotFileDelegationContext, diff --git a/apps/sim/lib/copilot/tools/server/files/file-folders.ts b/apps/sim/lib/copilot/tools/server/files/file-folders.ts index e48c743ec41..071b46c5713 100644 --- a/apps/sim/lib/copilot/tools/server/files/file-folders.ts +++ b/apps/sim/lib/copilot/tools/server/files/file-folders.ts @@ -9,10 +9,8 @@ import { type BaseServerTool, type ServerToolContext, } from '@/lib/copilot/tools/server/base-tool' -import { - ensureCopilotFileFolderPath, - requireCopilotWorkspace, -} from '@/lib/copilot/tools/server/files/file-folder-application' +import { ensureCopilotFileFolderPath } from '@/lib/copilot/tools/server/files/file-folder-application' +import { requireCopilotWorkspace } from '@/lib/copilot/tools/server/workspace-scope' import { decodeVfsPathSegments } from '@/lib/copilot/vfs/path-utils' import { asOrchestrationError, OrchestrationError } from '@/lib/core/orchestration/types' import { diff --git a/apps/sim/lib/copilot/tools/server/user/set-environment-variables.test.ts b/apps/sim/lib/copilot/tools/server/user/set-environment-variables.test.ts index d6e6bc12c53..878ec6697fa 100644 --- a/apps/sim/lib/copilot/tools/server/user/set-environment-variables.test.ts +++ b/apps/sim/lib/copilot/tools/server/user/set-environment-variables.test.ts @@ -15,13 +15,11 @@ afterAll(resetEnvironmentUtilsMock) const { ensureWorkflowAccessMock, ensureWorkspaceAccessMock, - getDefaultWorkspaceIdMock, listCredentialsMock, performUpdateCredentialMock, } = vi.hoisted(() => ({ ensureWorkflowAccessMock: vi.fn(), ensureWorkspaceAccessMock: vi.fn(), - getDefaultWorkspaceIdMock: vi.fn(), listCredentialsMock: vi.fn(), performUpdateCredentialMock: vi.fn(), })) @@ -37,7 +35,6 @@ vi.mock('@/lib/credentials/orchestration', () => ({ vi.mock('@/lib/copilot/tools/handlers/access', () => ({ ensureWorkflowAccess: ensureWorkflowAccessMock, ensureWorkspaceAccess: ensureWorkspaceAccessMock, - getDefaultWorkspaceId: getDefaultWorkspaceIdMock, })) import { setEnvironmentVariablesServerTool } from './set-environment-variables' @@ -49,7 +46,6 @@ describe('setEnvironmentVariablesServerTool', () => { workflow: { id: 'wf-1', workspaceId: 'ws-from-workflow' }, }) ensureWorkspaceAccessMock.mockResolvedValue(undefined) - getDefaultWorkspaceIdMock.mockResolvedValue('ws-default') upsertPersonalEnvVarsMock.mockResolvedValue({ added: ['API_KEY'], updated: [] }) upsertWorkspaceEnvVarsMock.mockResolvedValue(['API_KEY']) listCredentialsMock.mockResolvedValue({ @@ -97,22 +93,59 @@ describe('setEnvironmentVariablesServerTool', () => { expect(result.scope).toBe('personal') }) - it('falls back to the default workspace when none is in context', async () => { - await setEnvironmentVariablesServerTool.execute( - { - variables: [{ name: 'API_KEY', value: 'secret' }], - }, - { - userId: 'user-1', - } + it('fails closed when the context carries no workspace', async () => { + await expect( + setEnvironmentVariablesServerTool.execute( + { variables: [{ name: 'API_KEY', value: 'secret' }] }, + { userId: 'user-1' } + ) + ).rejects.toThrow('Copilot execution workspace is required') + + expect(upsertWorkspaceEnvVarsMock).not.toHaveBeenCalled() + }) + + it('accepts a workspaceId that re-asserts the execution workspace', async () => { + const result = await setEnvironmentVariablesServerTool.execute( + { workspaceId: 'ws-1', variables: [{ name: 'API_KEY', value: 'secret' }] }, + { userId: 'user-1', workspaceId: 'ws-1' } ) - expect(getDefaultWorkspaceIdMock).toHaveBeenCalledWith('user-1') - expect(upsertWorkspaceEnvVarsMock).toHaveBeenCalledWith( - 'ws-default', - { API_KEY: 'secret' }, - 'user-1' + expect(upsertWorkspaceEnvVarsMock).toHaveBeenCalledWith('ws-1', { API_KEY: 'secret' }, 'user-1') + expect(result.workspaceId).toBe('ws-1') + }) + + it('rejects a workspaceId that names a different workspace', async () => { + await expect( + setEnvironmentVariablesServerTool.execute( + { workspaceId: 'ws-other', variables: [{ name: 'API_KEY', value: 'secret' }] }, + { userId: 'user-1', workspaceId: 'ws-1' } + ) + ).rejects.toThrow('Workspace ID does not match the Copilot execution workspace') + + expect(upsertWorkspaceEnvVarsMock).not.toHaveBeenCalled() + }) + + it('resolves the workspace from a workflow in the execution workspace', async () => { + ensureWorkflowAccessMock.mockResolvedValue({ workflow: { id: 'wf-1', workspaceId: 'ws-1' } }) + + await setEnvironmentVariablesServerTool.execute( + { workflowId: 'wf-1', variables: [{ name: 'API_KEY', value: 'secret' }] }, + { userId: 'user-1', workspaceId: 'ws-1' } ) + + expect(ensureWorkflowAccessMock).toHaveBeenCalledWith('wf-1', 'user-1', 'write') + expect(upsertWorkspaceEnvVarsMock).toHaveBeenCalledWith('ws-1', { API_KEY: 'secret' }, 'user-1') + }) + + it('rejects a workflowId whose workspace differs from the execution workspace', async () => { + await expect( + setEnvironmentVariablesServerTool.execute( + { workflowId: 'wf-1', variables: [{ name: 'API_KEY', value: 'secret' }] }, + { userId: 'user-1', workspaceId: 'ws-1' } + ) + ).rejects.toThrow('Workspace ID does not match the Copilot execution workspace') + + expect(upsertWorkspaceEnvVarsMock).not.toHaveBeenCalled() }) it('describes a workspace secret through the credential update handler, never rewriting its value', async () => { diff --git a/apps/sim/lib/copilot/tools/server/user/set-environment-variables.ts b/apps/sim/lib/copilot/tools/server/user/set-environment-variables.ts index 3016fc0c171..ef004ef1765 100644 --- a/apps/sim/lib/copilot/tools/server/user/set-environment-variables.ts +++ b/apps/sim/lib/copilot/tools/server/user/set-environment-variables.ts @@ -1,12 +1,9 @@ import { createLogger } from '@sim/logger' import { z } from 'zod' import { SetEnvironmentVariables } from '@/lib/copilot/generated/tool-catalog-v1' -import { - ensureWorkflowAccess, - ensureWorkspaceAccess, - getDefaultWorkspaceId, -} from '@/lib/copilot/tools/handlers/access' +import { ensureWorkflowAccess, ensureWorkspaceAccess } from '@/lib/copilot/tools/handlers/access' import type { BaseServerTool, ServerToolContext } from '@/lib/copilot/tools/server/base-tool' +import { requireCopilotWorkspace } from '@/lib/copilot/tools/server/workspace-scope' import { OrchestrationError } from '@/lib/core/orchestration/types' import { performUpdateCredential } from '@/lib/credentials/orchestration' import { listVisibleWorkspaceCredentials } from '@/lib/credentials/queries' @@ -153,6 +150,12 @@ async function describeSecrets(params: { return { described, failures } } +/** + * Workspace secrets always land in the chat's delegated workspace. Model-supplied + * `workspaceId`/`workflowId` may only re-assert that workspace — never select a + * different one the acting user happens to access, and never fall back to a + * default workspace when the scope is missing. + */ async function resolveWorkspaceId( params: SetEnvironmentVariablesParams, context: ServerToolContext | undefined, @@ -166,16 +169,12 @@ async function resolveWorkspaceId( `Workflow ${params.workflowId} is not associated with a workspace` ) } - return workflow.workspaceId - } - - const workspaceId = params.workspaceId ?? context?.workspaceId - if (workspaceId) { - await ensureWorkspaceAccess(workspaceId, userId, 'write') - return workspaceId + return requireCopilotWorkspace(context ?? {}, workflow.workspaceId) } - return getDefaultWorkspaceId(userId) + const workspaceId = requireCopilotWorkspace(context ?? {}, params.workspaceId) + await ensureWorkspaceAccess(workspaceId, userId, 'write') + return workspaceId } export const setEnvironmentVariablesServerTool: BaseServerTool< diff --git a/apps/sim/lib/copilot/tools/server/workflow/query-logs.test.ts b/apps/sim/lib/copilot/tools/server/workflow/query-logs.test.ts index d4c5541ab29..b4c69f850af 100644 --- a/apps/sim/lib/copilot/tools/server/workflow/query-logs.test.ts +++ b/apps/sim/lib/copilot/tools/server/workflow/query-logs.test.ts @@ -215,6 +215,36 @@ describe('queryLogsServerTool', () => { expect(result.error).toContain('missing') }) + it('accepts a workspaceId that re-asserts the execution workspace', async () => { + listLogsMock.mockResolvedValue({ data: [], nextCursor: null, total: 0 }) + + await queryLogsServerTool.execute({ view: 'list', workspaceId: 'ws-1' } as any, ctx) + + expect(listLogsMock).toHaveBeenCalledTimes(1) + expect(listLogsMock.mock.calls[0][0].workspaceId).toBe('ws-1') + }) + + it('rejects a workspaceId that names a different workspace', async () => { + await expect( + queryLogsServerTool.execute({ view: 'list', workspaceId: 'ws-other' } as any, ctx) + ).rejects.toThrow('Workspace ID does not match the Copilot execution workspace') + + expect(listLogsMock).not.toHaveBeenCalled() + }) + + it('fails closed when the context carries no workspace', async () => { + await expect( + queryLogsServerTool.execute( + { view: 'list', workspaceId: 'ws-1' } as any, + { + userId: 'user-1', + } as any + ) + ).rejects.toThrow('Copilot execution workspace is required') + + expect(listLogsMock).not.toHaveBeenCalled() + }) + it('throws when unauthenticated', async () => { await expect( queryLogsServerTool.execute({ view: 'overview', executionId: 'exec-1' } as any, {} as any) diff --git a/apps/sim/lib/copilot/tools/server/workflow/query-logs.ts b/apps/sim/lib/copilot/tools/server/workflow/query-logs.ts index 3dc0fbf14d2..4a4859ad3df 100644 --- a/apps/sim/lib/copilot/tools/server/workflow/query-logs.ts +++ b/apps/sim/lib/copilot/tools/server/workflow/query-logs.ts @@ -2,7 +2,7 @@ import { createLogger } from '@sim/logger' import { z } from 'zod' import { QueryLogs } from '@/lib/copilot/generated/tool-catalog-v1' import type { BaseServerTool, ServerToolContext } from '@/lib/copilot/tools/server/base-tool' -import { OrchestrationError } from '@/lib/core/orchestration/types' +import { requireCopilotWorkspace } from '@/lib/copilot/tools/server/workspace-scope' import { collectLargeValueExecutionIds, collectLargeValueKeys, @@ -118,12 +118,13 @@ const queryLogsArgsSchema = z.preprocess((value) => { type QueryLogsArgs = z.infer +/** + * Logs are always read from the chat's delegated workspace. A model-supplied + * `workspaceId` may only re-assert that workspace — it can never select a + * different one, even one the acting user could otherwise access. + */ function resolveWorkspaceId(args: QueryLogsArgs, context?: ServerToolContext): string { - const workspaceId = args.workspaceId ?? context?.workspaceId - if (!workspaceId) { - throw new OrchestrationError('validation', 'workspaceId is required') - } - return workspaceId + return requireCopilotWorkspace(context ?? {}, args.workspaceId) } function buildLogViewContext( diff --git a/apps/sim/lib/copilot/tools/server/workspace-scope.ts b/apps/sim/lib/copilot/tools/server/workspace-scope.ts new file mode 100644 index 00000000000..ec4c3be1232 --- /dev/null +++ b/apps/sim/lib/copilot/tools/server/workspace-scope.ts @@ -0,0 +1,23 @@ +import { OrchestrationError } from '@/lib/core/orchestration/types' + +interface CopilotWorkspaceScopeContext { + workspaceId?: string +} + +/** + * Returns the execution workspace only. Model-provided workspace ids may assert the same value, + * but can never select a different workspace or trigger a default-workspace fallback. + */ +export function requireCopilotWorkspace( + context: CopilotWorkspaceScopeContext, + assertedWorkspaceId?: string +): string { + if (!context.workspaceId) throw new Error('Copilot execution workspace is required') + if (assertedWorkspaceId && assertedWorkspaceId !== context.workspaceId) { + throw new OrchestrationError( + 'validation', + 'Workspace ID does not match the Copilot execution workspace' + ) + } + return context.workspaceId +} From e5c61c247df2f1e74e4dbcb0176b544fd23b3a01 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 22 Aug 2026 16:00:53 -0700 Subject: [PATCH 2/5] fix(copilot): apply the workspace-scope guard across every model-steerable copilot surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends requireCopilotWorkspace to the remaining copilot tools that resolved their target workspace from model-supplied arguments: get_credentials (a workflowId could steer the credential listing to any workspace the user can access) and publish_custom_block (a workflowId could deploy/undeploy custom blocks from another workspace's workflow). The handlers already protected downstream by the application adapter (create workflow, generate API key, list/create workspace MCP servers) now use the same guard so a mismatch is rejected uniformly at the surface, and the getDefaultWorkspaceId fallback is deleted entirely — no copilot path picks a workspace for the model anymore. --- apps/sim/lib/copilot/tools/handlers/access.ts | 14 --------- .../handlers/deployment/custom-block.test.ts | 15 +++++++++ .../tools/handlers/deployment/custom-block.ts | 9 ++++-- .../tools/handlers/deployment/manage.ts | 11 ++----- .../copilot/tools/handlers/vfs-mutate.test.ts | 2 -- .../tools/handlers/workflow/mutations.test.ts | 16 ++++++---- .../tools/handlers/workflow/mutations.ts | 8 ++--- .../tools/server/user/get-credentials.test.ts | 31 +++++++++++++++++++ .../tools/server/user/get-credentials.ts | 6 +++- 9 files changed, 73 insertions(+), 39 deletions(-) diff --git a/apps/sim/lib/copilot/tools/handlers/access.ts b/apps/sim/lib/copilot/tools/handlers/access.ts index 129837c6841..c01b3a716fc 100644 --- a/apps/sim/lib/copilot/tools/handlers/access.ts +++ b/apps/sim/lib/copilot/tools/handlers/access.ts @@ -2,7 +2,6 @@ import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/work import { OrchestrationError } from '@/lib/core/orchestration/types' import type { getWorkflowById } from '@/lib/workflows/utils' import { checkWorkspaceAccess, type WorkspaceAccess } from '@/lib/workspaces/permissions/utils' -import { listAccessibleWorkspaceRowsForUser } from '@/lib/workspaces/utils' type WorkflowRecord = NonNullable>> @@ -40,19 +39,6 @@ export async function ensureWorkflowAccess( return { workflow: result.workflow, workspaceId: result.workflow.workspaceId } } -export async function getDefaultWorkspaceId(userId: string): Promise { - const accessibleRows = await listAccessibleWorkspaceRowsForUser(userId) - const mostRecent = accessibleRows - .map((row) => row.workspace) - .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())[0] - - if (!mostRecent) { - throw new Error('No workspace found for user') - } - - return mostRecent.id -} - export async function ensureWorkspaceAccess( workspaceId: string, userId: string, diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.test.ts b/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.test.ts index 1be8a3f514f..6d838f08276 100644 --- a/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.test.ts @@ -157,6 +157,21 @@ describe('executeDeployCustomBlock', () => { }) }) + it('rejects a workflowId whose workspace differs from the execution workspace', async () => { + ensureWorkflowAccessMock.mockResolvedValue({ + workflow: { id: 'wf-other', workspaceId: 'ws-other', name: 'Other', isDeployed: true }, + }) + + const result = await executeDeployCustomBlock( + { workflowId: 'wf-other', name: 'Enrich Lead' }, + context + ) + + expect(result.success).toBe(false) + expect(result.error).toContain('does not match the Copilot execution workspace') + expect(publishCustomBlockMock).not.toHaveBeenCalled() + }) + it('returns a clean admin-permission error when workflow access is denied', async () => { ensureWorkflowAccessMock.mockRejectedValue(new Error('Unauthorized workflow access')) diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.ts b/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.ts index f1c1403fc89..0142a1db18f 100644 --- a/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.ts +++ b/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.ts @@ -9,8 +9,10 @@ import { resolveCopilotWorkspaceFileReference, } from '@/lib/copilot/application/execute-file-use-case' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' +import { requireCopilotWorkspace } from '@/lib/copilot/tools/server/workspace-scope' import { canonicalizeVfsPath } from '@/lib/copilot/vfs/path-utils' import { isFeatureEnabled } from '@/lib/core/config/feature-flags' +import { OrchestrationError } from '@/lib/core/orchestration/types' import { buildStorageKeySegment } from '@/lib/uploads/core/storage-key' import { uploadFile } from '@/lib/uploads/core/storage-service' import { isImageFileType } from '@/lib/uploads/utils/file-utils' @@ -147,10 +149,11 @@ export async function executeDeployCustomBlock( error: "Managing a custom block requires admin permission on the workflow's workspace", } } - const workspaceId = workflowRecord.workspaceId - if (!workspaceId) { + const rawWorkspaceId = workflowRecord.workspaceId + if (!rawWorkspaceId) { return { success: false, error: 'Workflow must belong to a workspace' } } + const workspaceId = requireCopilotWorkspace(context, rawWorkspaceId) const ws = await getWorkspaceWithOwner(workspaceId) const organizationId = ws?.organizationId @@ -303,7 +306,7 @@ export async function executeDeployCustomBlock( }) return { success: true, output: { ...customBlockOutput(block, 'deploy'), updated: false } } } catch (error) { - if (error instanceof CustomBlockValidationError) { + if (error instanceof CustomBlockValidationError || error instanceof OrchestrationError) { return { success: false, error: error.message } } logger.error('Custom block deployment failed', { error }) diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/manage.ts b/apps/sim/lib/copilot/tools/handlers/deployment/manage.ts index f8768765337..fb1ef460ad9 100644 --- a/apps/sim/lib/copilot/tools/handlers/deployment/manage.ts +++ b/apps/sim/lib/copilot/tools/handlers/deployment/manage.ts @@ -5,6 +5,7 @@ import { messageForCopilotWorkflowError, } from '@/lib/copilot/application/execute-workflow-use-case' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' +import { requireCopilotWorkspace } from '@/lib/copilot/tools/server/workspace-scope' import { generateRequestId } from '@/lib/core/utils/request' import { createWorkflowMcpDeploymentServer, @@ -138,10 +139,7 @@ export async function executeListWorkspaceMcpServers( context: ExecutionContext ): Promise { try { - const workspaceId = params.workspaceId || context.workspaceId - if (!workspaceId) { - return { success: false, error: 'workspaceId is required' } - } + const workspaceId = requireCopilotWorkspace(context, params.workspaceId || undefined) const result = await executeCopilotMcpServerUseCase(context, listWorkflowMcpDeployments, { workspaceId, }) @@ -163,10 +161,7 @@ export async function executeCreateWorkspaceMcpServer( context: ExecutionContext ): Promise { try { - const workspaceId = params.workspaceId || context.workspaceId - if (!workspaceId) { - return { success: false, error: 'workspaceId is required' } - } + const workspaceId = requireCopilotWorkspace(context, params.workspaceId || undefined) const name = params.name?.trim() if (!name) { diff --git a/apps/sim/lib/copilot/tools/handlers/vfs-mutate.test.ts b/apps/sim/lib/copilot/tools/handlers/vfs-mutate.test.ts index cfe51fc9d23..0cdc9b340ac 100644 --- a/apps/sim/lib/copilot/tools/handlers/vfs-mutate.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/vfs-mutate.test.ts @@ -11,7 +11,6 @@ import { fileOperations } from '@/lib/workspace-files/application/operations' const mocks = vi.hoisted(() => ({ ensureWorkspaceAccess: vi.fn(), ensureWorkflowAccess: vi.fn(), - getDefaultWorkspaceId: vi.fn(), getWorkspaceFileByName: vi.fn(), resolveWorkspaceFileReference: vi.fn(), findWorkspaceFileFolderIdByPath: vi.fn(), @@ -57,7 +56,6 @@ vi.mock('@sim/db', () => ({ ...dbChainMock, ...schemaMock })) vi.mock('@/lib/copilot/tools/handlers/access', () => ({ ensureWorkspaceAccess: mocks.ensureWorkspaceAccess, ensureWorkflowAccess: mocks.ensureWorkflowAccess, - getDefaultWorkspaceId: mocks.getDefaultWorkspaceId, })) vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ diff --git a/apps/sim/lib/copilot/tools/handlers/workflow/mutations.test.ts b/apps/sim/lib/copilot/tools/handlers/workflow/mutations.test.ts index 49939478d95..37f6860601b 100644 --- a/apps/sim/lib/copilot/tools/handlers/workflow/mutations.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/workflow/mutations.test.ts @@ -7,7 +7,6 @@ import type { ExecutionContext } from '@/lib/copilot/request/types' const { mocks } = vi.hoisted(() => ({ mocks: { apiKey: vi.fn(), - defaultWorkspace: vi.fn(), executeWorkflowUseCase: vi.fn(), hasExecutionResult: vi.fn(), }, @@ -23,10 +22,6 @@ vi.mock('@/lib/copilot/application/execute-api-key-use-case', () => ({ executeCopilotApiKeyUseCase: mocks.apiKey, })) -vi.mock('@/lib/copilot/tools/handlers/access', () => ({ - getDefaultWorkspaceId: mocks.defaultWorkspace, -})) - vi.mock('@/lib/workflows/sanitization/json-sanitizer', () => ({ sanitizeForCopilot: vi.fn((state) => state), })) @@ -61,7 +56,6 @@ const context = { describe('workflow mutation Copilot adapters', () => { beforeEach(() => { vi.clearAllMocks() - mocks.defaultWorkspace.mockResolvedValue('workspace-1') mocks.hasExecutionResult.mockReturnValue(false) }) @@ -93,6 +87,16 @@ describe('workflow mutation Copilot adapters', () => { ) }) + it('rejects a create-workflow workspaceId that names a different workspace', async () => { + const result = await executeCreateWorkflow( + { name: 'New Workflow', workspaceId: 'workspace-other' }, + context + ) + + expect(result.success).toBe(false) + expect(mocks.executeWorkflowUseCase).not.toHaveBeenCalled() + }) + it('calls the compound variable command once', async () => { mocks.executeWorkflowUseCase.mockResolvedValue({ updated: 2 }) const operations = [ diff --git a/apps/sim/lib/copilot/tools/handlers/workflow/mutations.ts b/apps/sim/lib/copilot/tools/handlers/workflow/mutations.ts index 27a00427759..2c5bedd7b31 100644 --- a/apps/sim/lib/copilot/tools/handlers/workflow/mutations.ts +++ b/apps/sim/lib/copilot/tools/handlers/workflow/mutations.ts @@ -7,6 +7,7 @@ import { messageForCopilotWorkflowError, } from '@/lib/copilot/application/execute-workflow-use-case' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' +import { requireCopilotWorkspace } from '@/lib/copilot/tools/server/workspace-scope' import { decodeVfsPathSegments, encodeVfsPathSegments } from '@/lib/copilot/vfs/path-utils' import { PlatformEvents } from '@/lib/core/telemetry' import { createWorkflow } from '@/lib/workflows/application/create-workflow' @@ -25,7 +26,6 @@ import { import { sanitizeForCopilot } from '@/lib/workflows/sanitization/json-sanitizer' import { hasExecutionResult } from '@/executor/utils/errors' import type { WorkflowState } from '@/stores/workflows/workflow/types' -import { getDefaultWorkspaceId } from '../access' function stripBinaryFields(value: unknown): unknown { if (value === null || value === undefined) return value @@ -152,8 +152,7 @@ export async function executeCreateWorkflow( if (name.length > 200) { return { success: false, error: 'Workflow name must be 200 characters or less' } } - const workspaceId = - params?.workspaceId || context.workspaceId || (await getDefaultWorkspaceId(context.userId)) + const workspaceId = requireCopilotWorkspace(context, params?.workspaceId || undefined) const folderPath = typeof params?.folderPath === 'string' ? params.folderPath.trim() : '' const folderId = @@ -363,8 +362,7 @@ export async function executeGenerateApiKey( return { success: false, error: 'API key name must be 200 characters or less' } } - const workspaceId = - params.workspaceId || context.workspaceId || (await getDefaultWorkspaceId(context.userId)) + const workspaceId = requireCopilotWorkspace(context, params.workspaceId || undefined) assertWorkflowMutationNotAborted(context) const result = await executeCopilotApiKeyUseCase(context, createCopilotWorkspaceApiKey, { diff --git a/apps/sim/lib/copilot/tools/server/user/get-credentials.test.ts b/apps/sim/lib/copilot/tools/server/user/get-credentials.test.ts index b1937a86e71..cabf986de60 100644 --- a/apps/sim/lib/copilot/tools/server/user/get-credentials.test.ts +++ b/apps/sim/lib/copilot/tools/server/user/get-credentials.test.ts @@ -25,6 +25,7 @@ const { getUserPermissionConfigMock, getAccessibleOAuthCredentialsMock, checkWorkspaceAccessMock, + verifyWorkflowAccessMock, } = vi.hoisted(() => ({ getAllOAuthServicesMock: vi.fn(), decodeJwtMock: vi.fn(), @@ -33,6 +34,7 @@ const { getUserPermissionConfigMock: vi.fn(), getAccessibleOAuthCredentialsMock: vi.fn(), checkWorkspaceAccessMock: vi.fn(), + verifyWorkflowAccessMock: vi.fn(), })) const getPersonalAndWorkspaceEnvMock = environmentUtilsMockFns.mockGetPersonalAndWorkspaceEnv @@ -93,6 +95,11 @@ vi.mock('jose', () => ({ decodeJwt: decodeJwtMock, })) +vi.mock('@/lib/copilot/auth/permissions', () => ({ + verifyWorkflowAccess: verifyWorkflowAccessMock, + createPermissionError: (action: string) => `Permission denied: ${action}`, +})) + import { getCredentialsServerTool } from './get-credentials' /** @@ -385,6 +392,30 @@ describe('getCredentialsServerTool', () => { expect(result.oauth.connected.credentials).toEqual([]) }) + it('resolves the workspace from a workflow in the execution workspace', async () => { + verifyWorkflowAccessMock.mockResolvedValue({ hasAccess: true, workspaceId: 'workspace-1' }) + getUserPermissionConfigMock.mockResolvedValue({ allowedIntegrations: null }) + + await getCredentialsServerTool.execute( + { workflowId: 'wf-1' }, + { userId: 'user-1', workspaceId: 'workspace-1' } + ) + + expect(verifyWorkflowAccessMock).toHaveBeenCalledWith('user-1', 'wf-1') + expect(getUserPermissionConfigMock).toHaveBeenCalledWith('user-1', 'workspace-1') + }) + + it('rejects a workflowId whose workspace differs from the execution workspace', async () => { + verifyWorkflowAccessMock.mockResolvedValue({ hasAccess: true, workspaceId: 'workspace-other' }) + + await expect( + getCredentialsServerTool.execute( + { workflowId: 'wf-other' }, + { userId: 'user-1', workspaceId: 'workspace-1' } + ) + ).rejects.toThrow('Workspace ID does not match the Copilot execution workspace') + }) + it('rejects unauthenticated callers without touching the database', async () => { await expect(getCredentialsServerTool.execute({}, undefined)).rejects.toThrow( 'Authentication required' diff --git a/apps/sim/lib/copilot/tools/server/user/get-credentials.ts b/apps/sim/lib/copilot/tools/server/user/get-credentials.ts index 38eeb5f0dea..209776150c2 100644 --- a/apps/sim/lib/copilot/tools/server/user/get-credentials.ts +++ b/apps/sim/lib/copilot/tools/server/user/get-credentials.ts @@ -6,6 +6,7 @@ import { eq } from 'drizzle-orm' import { decodeJwt } from 'jose' import { createPermissionError, verifyWorkflowAccess } from '@/lib/copilot/auth/permissions' import type { BaseServerTool } from '@/lib/copilot/tools/server/base-tool' +import { requireCopilotWorkspace } from '@/lib/copilot/tools/server/workspace-scope' import { getAllowedIntegrationsFromEnv } from '@/lib/core/config/env-flags' import { OrchestrationError } from '@/lib/core/orchestration/types' import { getAccessibleOAuthCredentials } from '@/lib/credentials/environment' @@ -54,7 +55,10 @@ export const getCredentialsServerTool: BaseServerTool throw new OrchestrationError('forbidden', errorMessage) } - workspaceId = wId + // A model-supplied workflowId may only re-assert the chat's workspace — + // it can never steer the credential listing to another workspace. A + // legacy workflow with no workspace contributes no workspace scope. + workspaceId = wId ? requireCopilotWorkspace(context, wId) : undefined } const userId = authenticatedUserId From f2fbf8124aef42585a533f61cb663a5ad5764f42 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 22 Aug 2026 16:07:45 -0700 Subject: [PATCH 3/5] refactor(copilot): classify both workspace-scope guard branches and drop call-site boilerplate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit requireCopilotWorkspace now accepts an undefined context and throws a classified OrchestrationError for the missing-workspace branch too, so every caller drops the 'context ?? {}' and '|| undefined' coercions and one instanceof covers the guard. query_logs inlines its now-one-line wrapper, get_credentials drops the workspace-less special case (a workflow with no workspace asserts nothing), and publish_custom_block handles the guard locally instead of widening its catch-all — keeping its deliberate assume-not-published guidance for unrelated failures. --- .../tools/handlers/deployment/custom-block.ts | 15 ++++++++++----- .../copilot/tools/handlers/deployment/manage.ts | 4 ++-- .../copilot/tools/handlers/workflow/mutations.ts | 4 ++-- .../copilot/tools/server/user/get-credentials.ts | 5 ++--- .../server/user/set-environment-variables.ts | 4 ++-- .../copilot/tools/server/workflow/query-logs.ts | 14 ++++---------- .../lib/copilot/tools/server/workspace-scope.ts | 6 ++++-- 7 files changed, 26 insertions(+), 26 deletions(-) diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.ts b/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.ts index 0142a1db18f..a5412504355 100644 --- a/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.ts +++ b/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.ts @@ -12,7 +12,6 @@ import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/typ import { requireCopilotWorkspace } from '@/lib/copilot/tools/server/workspace-scope' import { canonicalizeVfsPath } from '@/lib/copilot/vfs/path-utils' import { isFeatureEnabled } from '@/lib/core/config/feature-flags' -import { OrchestrationError } from '@/lib/core/orchestration/types' import { buildStorageKeySegment } from '@/lib/uploads/core/storage-key' import { uploadFile } from '@/lib/uploads/core/storage-service' import { isImageFileType } from '@/lib/uploads/utils/file-utils' @@ -149,11 +148,17 @@ export async function executeDeployCustomBlock( error: "Managing a custom block requires admin permission on the workflow's workspace", } } - const rawWorkspaceId = workflowRecord.workspaceId - if (!rawWorkspaceId) { + if (!workflowRecord.workspaceId) { return { success: false, error: 'Workflow must belong to a workspace' } } - const workspaceId = requireCopilotWorkspace(context, rawWorkspaceId) + // A model-supplied workflowId may only re-assert the chat's workspace — it + // can never publish or unpublish a custom block in another workspace. + let workspaceId: string + try { + workspaceId = requireCopilotWorkspace(context, workflowRecord.workspaceId) + } catch (error) { + return { success: false, error: toError(error).message } + } const ws = await getWorkspaceWithOwner(workspaceId) const organizationId = ws?.organizationId @@ -306,7 +311,7 @@ export async function executeDeployCustomBlock( }) return { success: true, output: { ...customBlockOutput(block, 'deploy'), updated: false } } } catch (error) { - if (error instanceof CustomBlockValidationError || error instanceof OrchestrationError) { + if (error instanceof CustomBlockValidationError) { return { success: false, error: error.message } } logger.error('Custom block deployment failed', { error }) diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/manage.ts b/apps/sim/lib/copilot/tools/handlers/deployment/manage.ts index fb1ef460ad9..156d83d2a8a 100644 --- a/apps/sim/lib/copilot/tools/handlers/deployment/manage.ts +++ b/apps/sim/lib/copilot/tools/handlers/deployment/manage.ts @@ -139,7 +139,7 @@ export async function executeListWorkspaceMcpServers( context: ExecutionContext ): Promise { try { - const workspaceId = requireCopilotWorkspace(context, params.workspaceId || undefined) + const workspaceId = requireCopilotWorkspace(context, params.workspaceId) const result = await executeCopilotMcpServerUseCase(context, listWorkflowMcpDeployments, { workspaceId, }) @@ -161,7 +161,7 @@ export async function executeCreateWorkspaceMcpServer( context: ExecutionContext ): Promise { try { - const workspaceId = requireCopilotWorkspace(context, params.workspaceId || undefined) + const workspaceId = requireCopilotWorkspace(context, params.workspaceId) const name = params.name?.trim() if (!name) { diff --git a/apps/sim/lib/copilot/tools/handlers/workflow/mutations.ts b/apps/sim/lib/copilot/tools/handlers/workflow/mutations.ts index 2c5bedd7b31..164574a84cb 100644 --- a/apps/sim/lib/copilot/tools/handlers/workflow/mutations.ts +++ b/apps/sim/lib/copilot/tools/handlers/workflow/mutations.ts @@ -152,7 +152,7 @@ export async function executeCreateWorkflow( if (name.length > 200) { return { success: false, error: 'Workflow name must be 200 characters or less' } } - const workspaceId = requireCopilotWorkspace(context, params?.workspaceId || undefined) + const workspaceId = requireCopilotWorkspace(context, params?.workspaceId) const folderPath = typeof params?.folderPath === 'string' ? params.folderPath.trim() : '' const folderId = @@ -362,7 +362,7 @@ export async function executeGenerateApiKey( return { success: false, error: 'API key name must be 200 characters or less' } } - const workspaceId = requireCopilotWorkspace(context, params.workspaceId || undefined) + const workspaceId = requireCopilotWorkspace(context, params.workspaceId) assertWorkflowMutationNotAborted(context) const result = await executeCopilotApiKeyUseCase(context, createCopilotWorkspaceApiKey, { diff --git a/apps/sim/lib/copilot/tools/server/user/get-credentials.ts b/apps/sim/lib/copilot/tools/server/user/get-credentials.ts index 209776150c2..abcd92dcf3b 100644 --- a/apps/sim/lib/copilot/tools/server/user/get-credentials.ts +++ b/apps/sim/lib/copilot/tools/server/user/get-credentials.ts @@ -56,9 +56,8 @@ export const getCredentialsServerTool: BaseServerTool } // A model-supplied workflowId may only re-assert the chat's workspace — - // it can never steer the credential listing to another workspace. A - // legacy workflow with no workspace contributes no workspace scope. - workspaceId = wId ? requireCopilotWorkspace(context, wId) : undefined + // it can never steer the credential listing to another workspace. + workspaceId = requireCopilotWorkspace(context, wId) } const userId = authenticatedUserId diff --git a/apps/sim/lib/copilot/tools/server/user/set-environment-variables.ts b/apps/sim/lib/copilot/tools/server/user/set-environment-variables.ts index ef004ef1765..1bb017bf297 100644 --- a/apps/sim/lib/copilot/tools/server/user/set-environment-variables.ts +++ b/apps/sim/lib/copilot/tools/server/user/set-environment-variables.ts @@ -169,10 +169,10 @@ async function resolveWorkspaceId( `Workflow ${params.workflowId} is not associated with a workspace` ) } - return requireCopilotWorkspace(context ?? {}, workflow.workspaceId) + return requireCopilotWorkspace(context, workflow.workspaceId) } - const workspaceId = requireCopilotWorkspace(context ?? {}, params.workspaceId) + const workspaceId = requireCopilotWorkspace(context, params.workspaceId) await ensureWorkspaceAccess(workspaceId, userId, 'write') return workspaceId } diff --git a/apps/sim/lib/copilot/tools/server/workflow/query-logs.ts b/apps/sim/lib/copilot/tools/server/workflow/query-logs.ts index 4a4859ad3df..deec0b7a47e 100644 --- a/apps/sim/lib/copilot/tools/server/workflow/query-logs.ts +++ b/apps/sim/lib/copilot/tools/server/workflow/query-logs.ts @@ -118,15 +118,6 @@ const queryLogsArgsSchema = z.preprocess((value) => { type QueryLogsArgs = z.infer -/** - * Logs are always read from the chat's delegated workspace. A model-supplied - * `workspaceId` may only re-assert that workspace — it can never select a - * different one, even one the acting user could otherwise access. - */ -function resolveWorkspaceId(args: QueryLogsArgs, context?: ServerToolContext): string { - return requireCopilotWorkspace(context ?? {}, args.workspaceId) -} - function buildLogViewContext( detail: { workflowId: string | null @@ -177,7 +168,10 @@ export const queryLogsServerTool: BaseServerTool = { throw new Error('Unauthorized access') } const userId = context.userId - const workspaceId = resolveWorkspaceId(args, context) + // Logs are always read from the chat's delegated workspace. A model-supplied + // `workspaceId` may only re-assert that workspace — it can never select a + // different one, even one the acting user could otherwise access. + const workspaceId = requireCopilotWorkspace(context, args.workspaceId) if (args.view === 'list') { const { view: _view, title: _title, ...rest } = args diff --git a/apps/sim/lib/copilot/tools/server/workspace-scope.ts b/apps/sim/lib/copilot/tools/server/workspace-scope.ts index ec4c3be1232..9676195d80d 100644 --- a/apps/sim/lib/copilot/tools/server/workspace-scope.ts +++ b/apps/sim/lib/copilot/tools/server/workspace-scope.ts @@ -9,10 +9,12 @@ interface CopilotWorkspaceScopeContext { * but can never select a different workspace or trigger a default-workspace fallback. */ export function requireCopilotWorkspace( - context: CopilotWorkspaceScopeContext, + context: CopilotWorkspaceScopeContext | undefined, assertedWorkspaceId?: string ): string { - if (!context.workspaceId) throw new Error('Copilot execution workspace is required') + if (!context?.workspaceId) { + throw new OrchestrationError('validation', 'Copilot execution workspace is required') + } if (assertedWorkspaceId && assertedWorkspaceId !== context.workspaceId) { throw new OrchestrationError( 'validation', From 165d96dc8e4e80b4eaa451215788703ddd3645ab Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 22 Aug 2026 16:09:52 -0700 Subject: [PATCH 4/5] chore(copilot): drop call-site comments that restate the workspace-scope guard's TSDoc --- apps/sim/lib/copilot/tools/handlers/deployment/custom-block.ts | 2 -- apps/sim/lib/copilot/tools/server/user/get-credentials.ts | 2 -- apps/sim/lib/copilot/tools/server/workflow/query-logs.ts | 3 --- 3 files changed, 7 deletions(-) diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.ts b/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.ts index a5412504355..73d55588ac8 100644 --- a/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.ts +++ b/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.ts @@ -151,8 +151,6 @@ export async function executeDeployCustomBlock( if (!workflowRecord.workspaceId) { return { success: false, error: 'Workflow must belong to a workspace' } } - // A model-supplied workflowId may only re-assert the chat's workspace — it - // can never publish or unpublish a custom block in another workspace. let workspaceId: string try { workspaceId = requireCopilotWorkspace(context, workflowRecord.workspaceId) diff --git a/apps/sim/lib/copilot/tools/server/user/get-credentials.ts b/apps/sim/lib/copilot/tools/server/user/get-credentials.ts index abcd92dcf3b..059be3c0e13 100644 --- a/apps/sim/lib/copilot/tools/server/user/get-credentials.ts +++ b/apps/sim/lib/copilot/tools/server/user/get-credentials.ts @@ -55,8 +55,6 @@ export const getCredentialsServerTool: BaseServerTool throw new OrchestrationError('forbidden', errorMessage) } - // A model-supplied workflowId may only re-assert the chat's workspace — - // it can never steer the credential listing to another workspace. workspaceId = requireCopilotWorkspace(context, wId) } diff --git a/apps/sim/lib/copilot/tools/server/workflow/query-logs.ts b/apps/sim/lib/copilot/tools/server/workflow/query-logs.ts index deec0b7a47e..a832bb3d8ae 100644 --- a/apps/sim/lib/copilot/tools/server/workflow/query-logs.ts +++ b/apps/sim/lib/copilot/tools/server/workflow/query-logs.ts @@ -168,9 +168,6 @@ export const queryLogsServerTool: BaseServerTool = { throw new Error('Unauthorized access') } const userId = context.userId - // Logs are always read from the chat's delegated workspace. A model-supplied - // `workspaceId` may only re-assert that workspace — it can never select a - // different one, even one the acting user could otherwise access. const workspaceId = requireCopilotWorkspace(context, args.workspaceId) if (args.view === 'list') { From 2009fcae1ee83ff7b11dd411be3afcb9bdcb930d Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 22 Aug 2026 16:19:35 -0700 Subject: [PATCH 5/5] test(copilot): type the new query-logs scope tests instead of casting to any --- .../tools/server/workflow/query-logs.test.ts | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/apps/sim/lib/copilot/tools/server/workflow/query-logs.test.ts b/apps/sim/lib/copilot/tools/server/workflow/query-logs.test.ts index b4c69f850af..984432e7ebd 100644 --- a/apps/sim/lib/copilot/tools/server/workflow/query-logs.test.ts +++ b/apps/sim/lib/copilot/tools/server/workflow/query-logs.test.ts @@ -36,9 +36,17 @@ vi.mock('@/lib/execution/payloads/large-execution-value', () => ({ collectLargeValueKeys: vi.fn(() => []), })) +import type { ServerToolContext } from '@/lib/copilot/tools/server/base-tool' import { queryLogsServerTool } from './query-logs' -const ctx = { userId: 'user-1', workspaceId: 'ws-1' } +const ctx: ServerToolContext = { userId: 'user-1', workspaceId: 'ws-1' } + +type QueryLogsArgs = Parameters[0] + +/** Fully-typed list-view args with the schema's defaulted fields spelled out. */ +function listArgs(overrides: Partial>): QueryLogsArgs { + return { view: 'list', limit: 100, sortBy: 'date', sortOrder: 'desc', ...overrides } +} function detail(overrides: Record = {}) { return { @@ -218,7 +226,7 @@ describe('queryLogsServerTool', () => { it('accepts a workspaceId that re-asserts the execution workspace', async () => { listLogsMock.mockResolvedValue({ data: [], nextCursor: null, total: 0 }) - await queryLogsServerTool.execute({ view: 'list', workspaceId: 'ws-1' } as any, ctx) + await queryLogsServerTool.execute(listArgs({ workspaceId: 'ws-1' }), ctx) expect(listLogsMock).toHaveBeenCalledTimes(1) expect(listLogsMock.mock.calls[0][0].workspaceId).toBe('ws-1') @@ -226,7 +234,7 @@ describe('queryLogsServerTool', () => { it('rejects a workspaceId that names a different workspace', async () => { await expect( - queryLogsServerTool.execute({ view: 'list', workspaceId: 'ws-other' } as any, ctx) + queryLogsServerTool.execute(listArgs({ workspaceId: 'ws-other' }), ctx) ).rejects.toThrow('Workspace ID does not match the Copilot execution workspace') expect(listLogsMock).not.toHaveBeenCalled() @@ -234,12 +242,7 @@ describe('queryLogsServerTool', () => { it('fails closed when the context carries no workspace', async () => { await expect( - queryLogsServerTool.execute( - { view: 'list', workspaceId: 'ws-1' } as any, - { - userId: 'user-1', - } as any - ) + queryLogsServerTool.execute(listArgs({ workspaceId: 'ws-1' }), { userId: 'user-1' }) ).rejects.toThrow('Copilot execution workspace is required') expect(listLogsMock).not.toHaveBeenCalled()