Skip to content

Commit d8a7eeb

Browse files
committed
Gate Search integration tools with AppConfig
1 parent efce9f2 commit d8a7eeb

8 files changed

Lines changed: 118 additions & 11 deletions

File tree

‎apps/sim/lib/core/config/feature-flags.ts‎

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -31,17 +31,17 @@ export type FeatureFlagContext = AppConfigGateContext
3131
/**
3232
* The single definition of a feature flag. Everything about a flag lives in one
3333
* place: its name (the registry key), a human-readable `description`, and the
34-
* `fallback` secret consulted when AppConfig isn't the source of truth (truthy ⇒ on
35-
* globally).
34+
* optional `fallback` secret consulted when AppConfig is not the source of truth.
35+
* A null fallback keeps the flag off outside AppConfig.
3636
*
3737
* Gating by workspace/org/user/admin is deliberately NOT part of a definition — it lives only
3838
* in the hosted AppConfig document, so no environment can grant access from a code
3939
* literal.
4040
*/
4141
interface FeatureFlagDefinition {
4242
description: string
43-
/** Env/secret key consulted when AppConfig isn't the source of truth. Truthy ⇒ on. */
44-
fallback: keyof typeof env
43+
/** Null means AppConfig-only; otherwise a truthy env/secret enables the fallback. */
44+
fallback: keyof typeof env | null
4545
}
4646

4747
/** The single registry of known flags. To add a flag, add one entry here. */
@@ -51,6 +51,12 @@ const FEATURE_FLAGS = {
5151
'Enable native macOS computer use in Mothership. Global on/off only; each device must also opt in.',
5252
fallback: 'MSHIP_COMPUTER_USE',
5353
},
54+
'mothership-search-integration-tools': {
55+
description:
56+
'Give Search Assistant read-only integration discovery, calls, and matching prompt ' +
57+
'instructions. Global AppConfig on/off only; disabled by default with no env fallback.',
58+
fallback: null,
59+
},
5460
'mothership-model-selector': {
5561
description:
5662
'Show the Mothership model selector, model-specific effort levels, and Fast for supported ' +
@@ -143,7 +149,7 @@ const FEATURE_FLAGS = {
143149

144150
/**
145151
* The closed set of known feature flags. Derived from the registry, so a flag
146-
* cannot exist — or be checked — without a definition (and its mandatory fallback).
152+
* cannot exist — or be checked — without a definition (and its explicit fallback policy).
147153
*/
148154
export type FeatureFlagName = keyof typeof FEATURE_FLAGS
149155

@@ -153,7 +159,7 @@ function fallbackFlags(): FeatureFlagsConfig {
153159
for (const [name, def] of Object.entries(FEATURE_FLAGS) as Array<
154160
[string, FeatureFlagDefinition]
155161
>) {
156-
flags[name] = { enabled: isTruthy(env[def.fallback]) }
162+
flags[name] = { enabled: def.fallback !== null && isTruthy(env[def.fallback]) }
157163
}
158164
return flags
159165
}

‎apps/sim/lib/mothership/chat/payload.test.ts‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ const {
1818
mockSearchApprovals,
1919
mockSecretNames,
2020
mockComputerUseAvailable,
21+
mockSearchIntegrationToolsEnabled,
2122
} = vi.hoisted(() => ({
2223
mockComputerUseAvailable: vi.fn(async () => false),
2324
mockCreateUserToolSchema: vi.fn(() => ({ type: 'object', properties: {} })),
@@ -28,12 +29,19 @@ const {
2829
mockTrackChatUpload: vi.fn(),
2930
mockSearchApprovals: vi.fn(async () => new Map<string, boolean>()),
3031
mockSecretNames: vi.fn(async () => ({ names: [] as string[] })),
32+
mockSearchIntegrationToolsEnabled: vi.fn(async () => true),
3133
}))
3234

3335
vi.mock('@/lib/computer-use/availability.server', () => ({
3436
isComputerUseAvailable: mockComputerUseAvailable,
3537
}))
3638

39+
vi.mock('@/lib/mothership/feature-flags', () => ({
40+
isSearchIntegrationToolsEnabled: mockSearchIntegrationToolsEnabled,
41+
}))
42+
43+
beforeEach(() => mockSearchIntegrationToolsEnabled.mockResolvedValue(true))
44+
3745
// The inventory reads nine application worlds; these suites exercise the request shape, not the reads.
3846
vi.mock('@/lib/mothership/application/execute-organization-secret-use-case', () => ({
3947
executeOrganizationSecretUseCase: mockSecretNames,
@@ -962,3 +970,29 @@ it('carries only enabled MCP IDs without eager catalog discovery while preservin
962970
expect(payload).not.toHaveProperty('mothershipTools')
963971
expect(payload.desktop).toMatchObject({ browser: true, terminal: true })
964972
})
973+
974+
/** The worker derives both its gateway tools and prompt instructions from this capability. */
975+
it.each([{ organizationId: 'org-1' }, { workspaceId: 'ws-1' }])(
976+
'switches Search integration capability per turn while preserving Build for %j',
977+
async (scope) => {
978+
for (const enabled of [true, false, true]) {
979+
mockSearchIntegrationToolsEnabled.mockResolvedValue(enabled)
980+
for (const mode of ['assistant', 'agent', 'plan']) {
981+
const payload = await buildCopilotRequestPayload(
982+
{
983+
message: 'Find a person',
984+
userId: 'person',
985+
userMessageId: 'message',
986+
mode,
987+
model: '',
988+
...scope,
989+
},
990+
{ selectedModel: '' }
991+
)
992+
expect(payload.integrationCatalog).toEqual(
993+
mode === 'assistant' && !enabled ? undefined : { mcpServerIds: [] }
994+
)
995+
}
996+
}
997+
}
998+
)

‎apps/sim/lib/mothership/chat/payload.ts‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import {
3030
import type { AssistantImageContent } from '@/lib/mothership/chat/assistant-images'
3131
import { buildUploadedFileContext } from '@/lib/mothership/chat/upload-context'
3232
import { buildWorkspaceInventory } from '@/lib/mothership/chat/workspace-inventory'
33+
import { isSearchIntegrationToolsEnabled } from '@/lib/mothership/feature-flags'
3334
import type { AssistantSearchLevel } from '@/lib/mothership/generated/assistant'
3435
import type { ChatRequest, ModelSelection } from '@/lib/mothership/generated/protocol'
3536
import type { VfsSnapshotV1 } from '@/lib/mothership/generated/vfs-snapshot-v1'
@@ -326,6 +327,7 @@ export async function buildCopilotRequestPayload(
326327
const isAssistant = effectiveMode === 'assistant'
327328
const computerUse =
328329
!isAssistant && params.computerUse === true && (await isComputerUseAvailable())
330+
const integrationGateway = !isAssistant || (await isSearchIntegrationToolsEnabled())
329331

330332
// Track uploaded files in the DB and build context tags instead of base64 inlining.
331333
// Tracking writes `workspace_files` rows, so it needs the same write grant the
@@ -468,9 +470,13 @@ export async function buildCopilotRequestPayload(
468470
messageId: userMessageId,
469471
...(chatId ? { chatId } : {}),
470472
...(allContexts.length > 0 ? { context: allContexts } : {}),
471-
integrationCatalog: {
472-
mcpServerIds: isAssistant ? [] : [...new Set(params.mcpServerIds ?? [])],
473-
},
473+
...(integrationGateway
474+
? {
475+
integrationCatalog: {
476+
mcpServerIds: isAssistant ? [] : [...new Set(params.mcpServerIds ?? [])],
477+
},
478+
}
479+
: {}),
474480
...(params.userTimezone ? { userTimezone: params.userTimezone } : {}),
475481
...(params.effort ? { effort: params.effort } : {}),
476482
...(params.modelSelection ? { modelSelection: params.modelSelection } : {}),

‎apps/sim/lib/mothership/feature-flags.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,8 @@ export function isMothershipModelSelectorEnabled(): Promise<boolean> {
99
export function isPlanModeEnabled(): Promise<boolean> {
1010
return isFeatureEnabled('mothership-plan-mode')
1111
}
12+
13+
/** One AppConfig gate controls Search integration discovery, execution, and prompt capability. */
14+
export function isSearchIntegrationToolsEnabled(): Promise<boolean> {
15+
return isFeatureEnabled('mothership-search-integration-tools')
16+
}

‎apps/sim/lib/mothership/integrations/application/catalog.test.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,15 @@ import {
1515

1616
const mocks = vi.hoisted(() => ({
1717
build: vi.fn(),
18+
flag: vi.fn(async () => true),
1819
mcp: vi.fn(),
1920
config: vi.fn(),
2021
banned: vi.fn(),
2122
target: vi.fn(),
2223
workspace: vi.fn(),
2324
listServers: vi.fn(),
2425
}))
26+
vi.mock('@/lib/mothership/feature-flags', () => ({ isSearchIntegrationToolsEnabled: mocks.flag }))
2527
vi.mock('@/lib/mcp/application/use-cases', () => ({
2628
listMcpServersUseCase: { execute: mocks.listServers },
2729
}))
@@ -68,6 +70,7 @@ function queueChat(mode = 'assistant', role = 'member') {
6870
beforeEach(() => {
6971
vi.clearAllMocks()
7072
resetDbChainMock()
73+
mocks.flag.mockResolvedValue(true)
7174
mocks.banned.mockResolvedValue([])
7275
mocks.config.mockResolvedValue(null)
7376
mocks.build.mockResolvedValue([...tools])
@@ -372,3 +375,14 @@ it('filters organization enabled servers to the authorized target before broad M
372375
})
373376
expect(mocks.mcp).toHaveBeenCalledWith('actor', 'workspace-1', ['mcp-abc'], undefined)
374377
})
378+
379+
it('removes previously discoverable Search operations when the runtime flag turns off', async () => {
380+
for (const enabled of [true, false, true]) {
381+
mocks.flag.mockResolvedValue(enabled)
382+
queueChat()
383+
const result = await readIntegrationCatalog.execute({ principal: principal(), input })
384+
expect(result.operations.map((operation) => operation.toolId)).toEqual(
385+
enabled ? ['gmail_send', 'slack_send'] : []
386+
)
387+
}
388+
})

‎apps/sim/lib/mothership/integrations/application/catalog.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { createCopilotChatPrincipal } from '@/lib/mothership/auth/application-de
2525
import { defineAuthorizedChatUseCase } from '@/lib/mothership/chat/application/authorized-chat-use-case'
2626
import { resolveOwnedChatContext } from '@/lib/mothership/chat/application/context'
2727
import { buildIntegrationToolSchemas, type ToolSchema } from '@/lib/mothership/chat/payload'
28+
import { isSearchIntegrationToolsEnabled } from '@/lib/mothership/feature-flags'
2829
import type {
2930
IntegrationCatalogRequest,
3031
IntegrationCatalogResponse,
@@ -166,6 +167,8 @@ const catalogUseCase = defineAuthorizedChatUseCase({
166167
delegation: { audience: INTEGRATION_CATALOG_AUDIENCE, isWithinScope: () => true },
167168
},
168169
async execute({ input, context }) {
170+
if (context.mode === 'assistant' && !(await isSearchIntegrationToolsEnabled()))
171+
return { total: 0, truncated: false, operations: [] }
169172
if (input.mcpExecution && context.organizationId)
170173
throw new OrchestrationError('forbidden', 'Executor catalogs require workspace agent scope')
171174
let workspaceId = context.workspaceId

‎apps/sim/lib/mothership/tool-executor/executor.test.ts‎

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import { createLogger } from '@sim/logger'
66
import { beforeEach, describe, expect, it, vi } from 'vitest'
77
import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry'
8+
import { slackGetUserTool } from '@/tools/slack/get_user'
89

910
const { getToolEntry, isKnownTool, isSimExecuted, isClientExecuted } = vi.hoisted(() => ({
1011
getToolEntry: vi.fn(),
@@ -13,8 +14,9 @@ const { getToolEntry, isKnownTool, isSimExecuted, isClientExecuted } = vi.hoiste
1314
isClientExecuted: vi.fn(),
1415
}))
1516

16-
const { executeAppTool, recordSecretUsage } = vi.hoisted(() => ({
17+
const { executeAppTool, recordSecretUsage, searchIntegrationToolsEnabled } = vi.hoisted(() => ({
1718
executeAppTool: vi.fn(),
19+
searchIntegrationToolsEnabled: vi.fn(async () => true),
1820
recordSecretUsage: vi.fn(),
1921
}))
2022

@@ -32,6 +34,15 @@ vi.mock('./router', () => ({
3234
isClientExecuted,
3335
}))
3436

37+
vi.mock('@/lib/mothership/feature-flags', () => ({
38+
isSearchIntegrationToolsEnabled: searchIntegrationToolsEnabled,
39+
}))
40+
beforeEach(() => searchIntegrationToolsEnabled.mockResolvedValue(true))
41+
42+
vi.mock('@/tools/metadata', () => ({
43+
getToolMetadata: (id: string) => (id === slackGetUserTool.id ? slackGetUserTool : undefined),
44+
}))
45+
3546
vi.mock('@/tools', () => ({
3647
executeTool: executeAppTool,
3748
}))
@@ -772,3 +783,29 @@ describe('organization direct tool targets', () => {
772783
expect(targets.environment).not.toHaveBeenCalled()
773784
})
774785
})
786+
787+
it.each([{ organizationId: 'org-1' }, { workspaceId: 'ws-1' }])(
788+
'stops a previously admitted Search integration call after flag revocation for %j',
789+
async (scope) => {
790+
isKnownTool.mockReturnValue(false)
791+
isClientExecuted.mockReturnValue(false)
792+
executeAppTool.mockResolvedValue({ success: true, output: { user: { id: 'U123' } } })
793+
for (const enabled of [true, false, true]) {
794+
searchIntegrationToolsEnabled.mockResolvedValue(enabled)
795+
const result = await executeTool(
796+
'slack_get_user',
797+
{ credentialId: 'own', userId: 'U123' },
798+
{
799+
userId: 'person',
800+
requestMode: 'assistant',
801+
...scope,
802+
}
803+
)
804+
expect(result).toEqual(
805+
enabled
806+
? { success: true, output: { user: { id: 'U123' } } }
807+
: { success: false, error: 'This operation is not available in Search Assistant.' }
808+
)
809+
}
810+
}
811+
)

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { withResourceOutboundScope } from '@/lib/core/network/resource-scope.ser
66
import { resolveInvocationWorkspace } from '@/lib/mothership/application/workspace-target'
77
import { ASSISTANT_TOOLS, isAssistantIntegrationTool } from '@/lib/mothership/assistant/tool-policy'
88
import { prepareCopilotEnvironmentContext } from '@/lib/mothership/environment-context'
9+
import { isSearchIntegrationToolsEnabled } from '@/lib/mothership/feature-flags'
910
import { projectToolErrorMessageForCopilot } from '@/lib/mothership/request/tools/resolved-secret-result'
1011
import { recordSecretUsage } from '@/lib/secrets/usage/record'
1112
import { executeTool as executeAppTool } from '@/tools'
@@ -136,7 +137,8 @@ async function executeBoundTool(
136137
if (
137138
context.requestMode === 'assistant' &&
138139
!ASSISTANT_TOOLS.has(toolId) &&
139-
!isAssistantIntegrationTool(getToolMetadata(toolId))
140+
(!isAssistantIntegrationTool(getToolMetadata(toolId)) ||
141+
!(await isSearchIntegrationToolsEnabled()))
140142
) {
141143
return {
142144
success: false,

0 commit comments

Comments
 (0)