Skip to content

Commit d77aae7

Browse files
committed
Restore Mothership Slack bot connection from stored secrets
1 parent 138a5d1 commit d77aae7

11 files changed

Lines changed: 365 additions & 10 deletions

File tree

‎apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-tool-event.test.ts‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ vi.mock(
1818
import type { PersistedStreamEventEnvelope } from '@/lib/mothership/request/session/contract'
1919
import type { FilePreviewSession } from '@/lib/mothership/request/session/file-preview-session-contract'
2020
import { toStreamBatchEvent } from '@/lib/mothership/request/session/types'
21+
import { oauthCredentialKeys } from '@/hooks/queries/oauth/oauth-credentials'
22+
import { workspaceCredentialKeys } from '@/hooks/queries/utils/credential-keys'
23+
import { selectorQueryRoots } from '@/hooks/queries/utils/selector-keys'
2124
import { dispatchStreamEvent } from './dispatch-stream-event'
2225
import { createStreamLoopContext, type StreamLoopContext } from './stream-context'
2326
import { makeStreamLoopDeps, ref } from './stream-test-helpers'
@@ -84,6 +87,38 @@ function toolNode(ctx: StreamLoopContext, id: string): ToolNode {
8487
}
8588

8689
describe('tool events (dispatch → model + side effects)', () => {
90+
it.each([false, true])(
91+
'refreshes credential lists and selectors after Slack connection (replay=%s)',
92+
(replay) => {
93+
const deps = makeStreamLoopDeps()
94+
const ctx = createStreamLoopContext(deps)
95+
dispatchStreamEvent(ctx, toolCall('slack', 'connect_slack_bot'))
96+
expect(deps.queryClient.invalidateQueries).not.toHaveBeenCalled()
97+
const result = toolResult('slack', true, 'connect_slack_bot')
98+
dispatchStreamEvent(ctx, replay ? toStreamBatchEvent(result).event : result)
99+
expect(deps.queryClient.invalidateQueries).toHaveBeenCalledWith({
100+
queryKey: workspaceCredentialKeys.lists(),
101+
})
102+
expect(deps.queryClient.invalidateQueries).toHaveBeenCalledWith({
103+
queryKey: oauthCredentialKeys.lists(),
104+
})
105+
expect(deps.queryClient.invalidateQueries).toHaveBeenCalledWith({
106+
queryKey: selectorQueryRoots.selectors,
107+
})
108+
expect(deps.queryClient.invalidateQueries).toHaveBeenCalledWith({
109+
queryKey: selectorQueryRoots.workflowSearchReplace,
110+
})
111+
}
112+
)
113+
114+
it('does not refresh credentials after a failed Slack connection', () => {
115+
const deps = makeStreamLoopDeps()
116+
const ctx = createStreamLoopContext(deps)
117+
dispatchStreamEvent(ctx, toolCall('slack', 'connect_slack_bot'))
118+
dispatchStreamEvent(ctx, toolResult('slack', false, 'connect_slack_bot'))
119+
expect(deps.queryClient.invalidateQueries).not.toHaveBeenCalled()
120+
})
121+
87122
it('replays a completed file write without reopening its tab or preview', () => {
88123
const onResourceEvent = vi.fn()
89124
const deps = makeStreamLoopDeps({ onResourceEventRef: ref(onResourceEvent) })

‎apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-tool-event.ts‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,11 @@ import {
44
MothershipStreamV1ToolPhase,
55
MothershipStreamV1ToolStatus,
66
} from '@/lib/mothership/generated/mothership-stream-v1'
7-
import { ApplyFileEdit, PrepareFileEdit } from '@/lib/mothership/generated/tool-catalog-v1'
7+
import {
8+
ApplyFileEdit,
9+
ConnectSlackBot,
10+
PrepareFileEdit,
11+
} from '@/lib/mothership/generated/tool-catalog-v1'
812
import type { PersistedStreamEventEnvelope } from '@/lib/mothership/request/session/contract'
913
import {
1014
extractResourcesFromToolResult,
@@ -26,8 +30,11 @@ import {
2630
type ToolNode,
2731
} from '@/app/workspace/[workspaceId]/home/hooks/stream/turn-model'
2832
import { deploymentKeys } from '@/hooks/queries/deployments'
33+
import { oauthCredentialKeys } from '@/hooks/queries/oauth/oauth-credentials'
34+
import { workspaceCredentialKeys } from '@/hooks/queries/utils/credential-keys'
2935
import { folderKeys } from '@/hooks/queries/utils/folder-keys'
3036
import { invalidateWorkflowLists } from '@/hooks/queries/utils/invalidate-workflow-lists'
37+
import { invalidateSelectorQueries } from '@/hooks/queries/utils/selector-keys'
3138

3239
type ToolEvent = Extract<PersistedStreamEventEnvelope, { type: 'tool' }>
3340

@@ -66,6 +73,11 @@ function runToolResultSideEffects(ctx: StreamLoopContext, node: ToolNode, replay
6673
if (FOLDER_TOOL_NAMES.has(name) && isSuccess) {
6774
deps.queryClient.invalidateQueries({ queryKey: folderKeys.list(deps.workspaceId) })
6875
}
76+
if (name === ConnectSlackBot.id && isSuccess) {
77+
void deps.queryClient.invalidateQueries({ queryKey: workspaceCredentialKeys.lists() })
78+
void deps.queryClient.invalidateQueries({ queryKey: oauthCredentialKeys.lists() })
79+
void invalidateSelectorQueries(deps.queryClient)
80+
}
6981
if (WORKFLOW_MUTATION_TOOL_NAMES.has(name) && isSuccess) {
7082
// `rm` archives, so the archived list moves too — and the shared helper also
7183
// refreshes the workflow selector lists that `@`-mentions and pickers read.

‎apps/sim/lib/credentials/application/operations.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,7 @@ export const credentialOperations = {
127127
minimumRole: 'write',
128128
workspaceApiKey: 'deny',
129129
capability: 'integrations.manage',
130-
principalKinds: ['session', 'personal_api_key', 'oauth_access_token'],
130+
...HUMAN_AND_COPILOT_PRINCIPALS,
131131
}),
132132
read: defineCredentialOperation(
133133
defineWorkspaceOperation({

‎apps/sim/lib/credentials/application/service-account.test.ts‎

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ const mocks = vi.hoisted(() => ({
1515
getActor: vi.fn(),
1616
deleteRecord: vi.fn(),
1717
capture: vi.fn(),
18+
environment: vi.fn(),
1819
}))
1920

2021
vi.mock('@sim/audit', () => auditMock)
@@ -47,11 +48,13 @@ vi.mock('@/lib/credentials/access', () => ({
4748
},
4849
}))
4950
vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mocks.capture }))
51+
vi.mock('@/lib/environment/utils', () => ({ getEffectiveDecryptedEnv: mocks.environment }))
5052

5153
import {
5254
createServiceAccountCredentialUseCase,
5355
deleteCredentialUseCase,
5456
} from '@/lib/credentials/application/service-account'
57+
import { executeCopilotCredentialUseCase } from '@/lib/mothership/application/execute-credential-use-case'
5558

5659
const WORKSPACE_ID = 'workspace-1'
5760
const workspace = {
@@ -106,6 +109,7 @@ describe('credential service-account application operations', () => {
106109
providerId: 'zoom-service-account',
107110
available: true,
108111
})
112+
mocks.environment.mockResolvedValue({ SIGNING: 'secret-signing', BOT: 'secret-bot' })
109113
})
110114

111115
it('rejects workspace keys before canonical loading on create', async () => {
@@ -158,6 +162,111 @@ describe('credential service-account application operations', () => {
158162
)
159163
})
160164

165+
const storedInput = {
166+
workspaceId: WORKSPACE_ID,
167+
displayName: 'Support bot',
168+
storedSlackSecrets: { signingSecretEnvVar: 'SIGNING', botTokenEnvVar: 'BOT' },
169+
}
170+
const copilotContext = {
171+
userId: 'user-1',
172+
workspaceId: WORKSPACE_ID,
173+
toolCallId: 'tool-1',
174+
copilotToolExecution: true,
175+
}
176+
const connectStored = (context = copilotContext, input = storedInput) =>
177+
executeCopilotCredentialUseCase(context, createServiceAccountCredentialUseCase, input)
178+
179+
it('resolves stored Slack secrets as the delegated user and audits only credential metadata', async () => {
180+
await connectStored()
181+
expect(mocks.environment).toHaveBeenCalledWith('user-1', WORKSPACE_ID)
182+
expect(mocks.create).toHaveBeenCalledWith(
183+
expect.objectContaining({
184+
workspaceId: WORKSPACE_ID,
185+
userId: 'user-1',
186+
providerId: 'slack-custom-bot',
187+
signingSecret: 'secret-signing',
188+
botToken: 'secret-bot',
189+
displayName: 'Support bot',
190+
})
191+
)
192+
expect(mocks.create.mock.calls[0][0]).not.toHaveProperty('storedSlackSecrets')
193+
expect(auditMockFns.mockRecordAudit).toHaveBeenCalledWith(
194+
expect.objectContaining({
195+
actorId: 'user-1',
196+
metadata: expect.objectContaining({
197+
operation: 'credentials.service_accounts.create',
198+
actor: expect.objectContaining({
199+
kind: 'delegated',
200+
serviceId: 'copilot',
201+
subjectUserId: 'user-1',
202+
}),
203+
}),
204+
})
205+
)
206+
expect(JSON.stringify(auditMockFns.mockRecordAudit.mock.calls)).not.toContain('secret-signing')
207+
expect(JSON.stringify(mocks.capture.mock.calls)).not.toContain('secret-bot')
208+
})
209+
210+
it('rejects forged execution context before canonical loading', async () => {
211+
await expect(async () =>
212+
connectStored({ ...copilotContext, copilotToolExecution: false })
213+
).rejects.toThrow('trusted')
214+
expect(mocks.loadWorkspace).not.toHaveBeenCalled()
215+
expect(mocks.environment).not.toHaveBeenCalled()
216+
})
217+
218+
it('rejects cross-workspace delegation before resolving stored secrets', async () => {
219+
mocks.loadWorkspace.mockResolvedValueOnce({ ...workspace, workspaceId: 'other' })
220+
await expect(
221+
connectStored(copilotContext, { ...storedInput, workspaceId: 'other' })
222+
).rejects.toMatchObject({ code: 'forbidden' })
223+
expect(mocks.environment).not.toHaveBeenCalled()
224+
expect(mocks.create).not.toHaveBeenCalled()
225+
})
226+
227+
it('checks current write permission before resolving stored secrets', async () => {
228+
mocks.resolvePermission.mockResolvedValue('read')
229+
await expect(connectStored()).rejects.toMatchObject({ code: 'forbidden' })
230+
expect(mocks.environment).not.toHaveBeenCalled()
231+
expect(mocks.create).not.toHaveBeenCalled()
232+
})
233+
234+
it('checks provider visibility before resolving stored secrets', async () => {
235+
mocks.requireProvider.mockImplementationOnce(() => {
236+
throw new OrchestrationError('conflict', 'Provider hidden')
237+
})
238+
await expect(connectStored()).rejects.toMatchObject({ code: 'conflict' })
239+
expect(mocks.environment).not.toHaveBeenCalled()
240+
expect(mocks.create).not.toHaveBeenCalled()
241+
})
242+
243+
it('reports missing names without sending any partial credential to the provider', async () => {
244+
mocks.environment.mockResolvedValue({ SIGNING: 'secret-signing' })
245+
await expect(connectStored()).rejects.toThrow('Stored secrets unavailable: BOT')
246+
expect(mocks.create).not.toHaveBeenCalled()
247+
expect(auditMockFns.mockRecordAudit).not.toHaveBeenCalled()
248+
})
249+
250+
it('does not let delegated callers supply inline credential values', async () => {
251+
await expect(
252+
executeCopilotCredentialUseCase(copilotContext, createServiceAccountCredentialUseCase, {
253+
workspaceId: WORKSPACE_ID,
254+
providerId: 'slack-custom-bot',
255+
signingSecret: 'raw-signing',
256+
botToken: 'raw-token',
257+
})
258+
).rejects.toThrow('must reference existing Sim secrets')
259+
expect(mocks.environment).not.toHaveBeenCalled()
260+
expect(mocks.create).not.toHaveBeenCalled()
261+
})
262+
263+
it('does not duplicate creation audit or analytics when the primitive reuses a credential', async () => {
264+
mocks.create.mockResolvedValue({ success: true, credential, created: false })
265+
expect((await connectStored()).created).toBe(false)
266+
expect(auditMockFns.mockRecordAudit).not.toHaveBeenCalled()
267+
expect(mocks.capture).not.toHaveBeenCalled()
268+
})
269+
161270
it('rejects service-account providers hidden by workspace policy', async () => {
162271
mocks.requireProvider.mockImplementation(() => {
163272
throw new OrchestrationError(

‎apps/sim/lib/credentials/application/service-account.ts‎

Lines changed: 50 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { ForbiddenOperationError } from '@/lib/core/application/forbidden'
55
import { OrchestrationError } from '@/lib/core/orchestration/types'
66
import { HttpError } from '@/lib/core/utils/http-error'
77
import { getCredentialActorContext, requireOrdinaryCredentialType } from '@/lib/credentials/access'
8+
import { credentialDelegationPolicy } from '@/lib/credentials/application/authorization'
89
import {
910
defineAuthorizedCredentialUseCase,
1011
requireManageableCredentialType,
@@ -21,13 +22,24 @@ import {
2122
deleteCredentialRecord,
2223
} from '@/lib/credentials/orchestration'
2324
import type { CredentialRow } from '@/lib/credentials/queries'
25+
import { getEffectiveDecryptedEnv } from '@/lib/environment/utils'
26+
import { SLACK_CUSTOM_BOT_PROVIDER_ID } from '@/lib/oauth/types'
2427
import { captureServerEvent } from '@/lib/posthog/server'
2528
import { loadActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context'
2629

27-
export type CreateServiceAccountInput = Omit<
28-
CreateServiceAccountCredentialParams,
29-
'userId' | 'request'
30-
>
30+
type InlineServiceAccountInput = Omit<CreateServiceAccountCredentialParams, 'userId' | 'request'>
31+
32+
export interface StoredSlackBotCredentialInput {
33+
workspaceId: string
34+
displayName: string
35+
description?: string
36+
storedSlackSecrets: {
37+
signingSecretEnvVar: string
38+
botTokenEnvVar: string
39+
}
40+
}
41+
42+
export type CreateServiceAccountInput = InlineServiceAccountInput | StoredSlackBotCredentialInput
3143

3244
export interface CreateServiceAccountResult {
3345
credential: CredentialRow
@@ -53,14 +65,44 @@ export const createServiceAccountCredentialUseCase = defineAuthorizedWorkspaceUs
5365
if (!context) throw new OrchestrationError('not_found', 'Workspace not found')
5466
return context
5567
},
56-
authorizationOptions: {},
68+
authorizationOptions: { delegation: credentialDelegationPolicy },
5769
async execute({ principal, input, context, request }): Promise<CreateServiceAccountResult> {
70+
const stored = 'storedSlackSecrets' in input
71+
if (principal.kind === 'delegated' && !stored) {
72+
throw new OrchestrationError('validation', 'Copilot must reference existing Sim secrets')
73+
}
5874
const catalog = await listCredentialProviderCatalog(principal, context)
59-
requireAvailableServiceAccountCredentialProvider(catalog, input.providerId)
75+
const providerId = stored ? SLACK_CUSTOM_BOT_PROVIDER_ID : input.providerId
76+
requireAvailableServiceAccountCredentialProvider(catalog, providerId)
77+
const userId = requirePrincipalSubjectUserId(principal)
78+
let credentialInput: InlineServiceAccountInput
79+
if (stored) {
80+
const { signingSecretEnvVar, botTokenEnvVar } = input.storedSlackSecrets
81+
const environment = await getEffectiveDecryptedEnv(userId, context.workspaceId)
82+
const missing = [signingSecretEnvVar, botTokenEnvVar].filter(
83+
(name) => !Object.hasOwn(environment, name) || !environment[name]
84+
)
85+
if (missing.length) {
86+
throw new OrchestrationError(
87+
'validation',
88+
`Stored secrets unavailable: ${missing.join(', ')}`
89+
)
90+
}
91+
credentialInput = {
92+
workspaceId: context.workspaceId,
93+
providerId,
94+
displayName: input.displayName,
95+
description: input.description,
96+
signingSecret: environment[signingSecretEnvVar],
97+
botToken: environment[botTokenEnvVar],
98+
}
99+
} else {
100+
credentialInput = input
101+
}
60102
const result = await createServiceAccountCredential({
61-
...input,
103+
...credentialInput,
62104
workspaceId: context.workspaceId,
63-
userId: requirePrincipalSubjectUserId(principal),
105+
userId,
64106
request,
65107
})
66108
if (!result.success) {
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { CREDENTIAL_DELEGATION_AUDIENCE } from '@/lib/credentials/application/authorization'
2+
import { credentialOperations } from '@/lib/credentials/application/operations'
3+
import { createCopilotApplicationAdapter } from '@/lib/mothership/application/application-adapter'
4+
import { COPILOT_APPLICATION_DELEGATION_TTL_MS } from '@/lib/mothership/auth/application-delegation'
5+
6+
export const executeCopilotCredentialUseCase = createCopilotApplicationAdapter({
7+
domain: 'credential',
8+
delegation: {
9+
audience: CREDENTIAL_DELEGATION_AUDIENCE,
10+
ttlMs: COPILOT_APPLICATION_DELEGATION_TTL_MS,
11+
createDelegationId: (context) => `copilot-tool:${context.toolCallId}`,
12+
},
13+
operations: credentialOperations,
14+
})

‎apps/sim/lib/mothership/generated/protocol.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,14 @@ export const ToolActivity = z
3232
});
3333
export type ToolActivity = z.infer<typeof ToolActivity>;
3434

35+
/** Existing Sim secret names; plaintext credentials never enter this tool's arguments. */
36+
export const ConnectSlackBotInputSchema = z.strictObject({
37+
displayName: z.string().trim().min(1).max(255),
38+
description: z.string().max(1_000).optional(),
39+
signingSecretEnvVar: z.string().trim().min(1).max(1_024),
40+
botTokenEnvVar: z.string().trim().min(1).max(1_024),
41+
});
42+
3543
/** Activity acknowledged through a completed leg, scoped to one emitter's lifetime. */
3644
export interface StreamActivityReceipt {
3745
emitterId: string;

‎apps/sim/lib/mothership/tool-executor/register-handlers.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
import { createLogger } from '@sim/logger'
22
import {
33
CancelWorkflowRun,
4+
ConnectSlackBot,
45
GenerateApiKey,
56
RunBlock,
67
RunFromBlock,
78
RunWorkflow,
89
RunWorkflowUntilBlock,
910
} from '@/lib/mothership/generated/tool-catalog-v1'
11+
import { executeConnectSlackBot } from '@/lib/mothership/tools/handlers/connect-slack-bot'
1012
import { createServerToolHandler } from '@/lib/mothership/tools/registry/server-tool-adapter'
1113
import { getRegisteredServerToolNames } from '@/lib/mothership/tools/server/router'
1214
import { executeFunctionExecute } from '../tools/handlers/function-execute'
@@ -53,6 +55,7 @@ function buildHandlerMap(): Record<string, ToolHandler> {
5355
return {
5456
[CancelWorkflowRun.id]: h(executeCancelWorkflowRun),
5557
[GenerateApiKey.id]: h(executeGenerateApiKey),
58+
[ConnectSlackBot.id]: executeConnectSlackBot,
5659
[RunWorkflow.id]: h(executeRunWorkflow),
5760
[RunWorkflowUntilBlock.id]: h(executeRunWorkflowUntilBlock),
5861
[RunFromBlock.id]: h(executeRunFromBlock),

0 commit comments

Comments
 (0)