Skip to content

Commit 947f720

Browse files
committed
fix(mothership): expose and validate tool attachment identities
1 parent 34a2281 commit 947f720

16 files changed

Lines changed: 571 additions & 16 deletions

‎apps/sim/lib/api/contracts/mothership-catalog.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,14 @@ import { v2BlockDetailSchema, v2BlockFieldSchema } from '@/lib/api/contracts/v2/
44
/** Internal discovery adds compact catalog hints without changing the public v2 response. */
55
const mothershipBlockFieldSchema = v2BlockFieldSchema.extend({
66
valueSchema: z.record(z.string(), z.unknown()).optional(),
7+
toolBinding: z
8+
.object({
9+
selectionMode: z.enum(['explicit', 'additive']),
10+
discovery: z.array(z.string()),
11+
naming: z.string(),
12+
access: z.string(),
13+
})
14+
.optional(),
715
optionsAvailability: z.string().optional(),
816
options: z
917
.array(

‎apps/sim/lib/mothership/agent-cli/curation.test.ts‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,27 @@ describe('curateBlockDetail', () => {
9595
expect(await curateBlockDetail(input, viewer)).toBe(input)
9696
})
9797

98+
it.each(['agent', 'mothership'])(
99+
'publishes the %s attachment contract on demand only',
100+
async (id) => {
101+
const original = { ...blockDetail(), id, inputSchema: [{ id: 'tools', type: 'tool-input' }] }
102+
const result = await curateBlockDetail(ok(JSON.stringify(original)), viewer)
103+
const detail = mothershipBlockDetailSchema.parse(JSON.parse(result.stdout))
104+
const field = detail.inputSchema[0]
105+
expect(field?.valueSchema).toMatchObject({
106+
type: 'array',
107+
items: { anyOf: expect.any(Array) },
108+
})
109+
expect(field?.toolBinding?.selectionMode).toBe(id === 'agent' ? 'explicit' : 'additive')
110+
expect(field?.toolBinding?.naming).toContain(
111+
id === 'agent' ? 'operations[operation].toolId' : 'call_integration_tool'
112+
)
113+
expect(v2BlockDetailSchema.parse(detail).inputSchema[0]).not.toHaveProperty('toolBinding')
114+
expect(v2BlockDetailSchema.parse(detail).inputSchema[0]).not.toHaveProperty('valueSchema')
115+
expect(original.inputSchema[0]).not.toHaveProperty('valueSchema')
116+
}
117+
)
118+
98119
it.each(['start_trigger', 'api_trigger', 'input_trigger', 'human_in_the_loop'])(
99120
'publishes the input editor value contract for %s without changing v2',
100121
async (id) => {

‎apps/sim/lib/mothership/agent-cli/curation.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { type V2BlockDetail, v2BlockDetailSchema } from '@/lib/api/contracts/v2/
1212
import { projectBlockOutputs } from '@/lib/catalog/projection/block-detail'
1313
import { resolveDeniedBlockOperations } from '@/lib/integrations/tool-projection'
1414
import { withModelHints } from '@/lib/mothership/agent-cli/model-hints'
15+
import { withToolBindingHints } from '@/lib/mothership/agent-cli/tool-binding-hints'
1516
import { agentCliFail } from '@/lib/mothership/agent-cli/types'
1617
import type { AgentCliRawResult } from '@/lib/mothership/generated/agent-cli'
1718
import { createToolAccessGate } from '@/lib/permission-groups/operation-access'
@@ -95,6 +96,7 @@ export async function curateBlockDetail(
9596
}
9697
: detail
9798
)
99+
enriched = withToolBindingHints(enriched)
98100
if (detail.toolIds.includes('table_query_rows_v2')) {
99101
const workspace = await resolveActiveWorkspaceApplicationContext(viewer.workspaceId)
100102
enriched = {
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { z } from 'zod'
2+
import type { MothershipBlockDetail } from '@/lib/api/contracts/mothership-catalog'
3+
import { getToolBindingAuthoringSchema } from '@/lib/workflows/tool-input/authoring'
4+
5+
/** Publish only the selected block's attachment contract, not the integration tool catalog. */
6+
export function withToolBindingHints(detail: MothershipBlockDetail): MothershipBlockDetail {
7+
const schema = getToolBindingAuthoringSchema(detail.id)
8+
if (!schema) return detail
9+
const { $schema: _version, ...valueSchema } = z.toJSONSchema(schema)
10+
const isMothership = detail.id === 'mothership'
11+
return {
12+
...detail,
13+
inputSchema: detail.inputSchema.map((field) =>
14+
field.type !== 'tool-input'
15+
? field
16+
: {
17+
...field,
18+
valueSchema,
19+
toolBinding: {
20+
selectionMode: isMothership ? 'additive' : 'explicit',
21+
discovery: [
22+
...(!isMothership
23+
? [
24+
'blocks get <type> --operation <operation>',
25+
'custom-tools list',
26+
'custom-tools get <id>',
27+
]
28+
: []),
29+
'mcp-servers list',
30+
'mcp-servers tools list <serverId>',
31+
],
32+
naming: isMothership
33+
? 'Use the exact MCP toolName returned by discovery. Sim Chat discovers the callable toolId and invokes it through call_integration_tool. An attachment title/name cannot rename the operation.'
34+
: 'Integration operations use operations[operation].toolId from blocks get (not the UI toolName/title). Custom tools use custom_ followed by the saved definition title or inline title. MCP tools use a server-qualified ID derived from the discovered toolName. Duplicate bindings receive provider-generated suffixes. Do not override title, name, toolId or functionName when attaching a saved tool. Inline custom definitions require title = schema.function.name.',
35+
access: isMothership
36+
? 'Selections add MCP operations to available integration access; this is not an integration allowlist. Empty tools does not disable integrations. Fixed MCP arguments, force mode and variable permission mode are not supported by this block. Actual calls remain subject to execution permissions and MCP policies.'
37+
: 'This array defines the selected tools, subject to execution permissions. For integration, inline custom and individual MCP bindings, params fixes author-supplied values; remaining eligible parameters are model-supplied. Saved custom-tool references do not retain fixed arguments. auto lets the model choose, force requires a call, none disables the binding. Runtime helpers such as skill loading are separate. Labels do not enforce approvals or permissions.',
38+
},
39+
}
40+
),
41+
}
42+
}

‎apps/sim/lib/workflows/application/apply-workflow-operations.test.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -243,7 +243,7 @@ describe('applyWorkflowOperations', () => {
243243
})
244244

245245
expect(mocks.normalizeState).toHaveBeenCalledWith(emptyGraph)
246-
expect(mocks.applyOperations).toHaveBeenCalledWith(emptyGraph, operations, null)
246+
expect(mocks.applyOperations).toHaveBeenCalledWith(emptyGraph, operations, null, false)
247247
expect(mocks.replace).toHaveBeenCalledTimes(1)
248248
expect(result.graph.blocks).toEqual(graphWithAddedBlock.blocks)
249249
})
@@ -539,7 +539,7 @@ describe('applyWorkflowOperations', () => {
539539
input: { workflowId: 'workflow-1', operations, baseGraph },
540540
})
541541
expect(mocks.loadNormalized).not.toHaveBeenCalled()
542-
expect(mocks.applyOperations).toHaveBeenCalledWith(baseGraph, operations, null)
542+
expect(mocks.applyOperations).toHaveBeenCalledWith(baseGraph, operations, null, true)
543543

544544
vi.clearAllMocks()
545545
mocks.resolveContext.mockResolvedValue(context)
@@ -566,7 +566,7 @@ describe('applyWorkflowOperations', () => {
566566
input: { workflowId: 'workflow-1', operations, baseGraph },
567567
})
568568
expect(mocks.loadNormalized).toHaveBeenCalledWith('workflow-1')
569-
expect(mocks.applyOperations).not.toHaveBeenCalledWith(baseGraph, operations, null)
569+
expect(mocks.applyOperations).not.toHaveBeenCalledWith(baseGraph, operations, null, true)
570570
})
571571

572572
it('applies the block enablement slice and declines a locked block as a skipped item', async () => {

‎apps/sim/lib/workflows/application/apply-workflow-operations.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -305,7 +305,12 @@ export const applyWorkflowOperations = defineAuthorizedWorkflowUseCase({
305305
skippedItems,
306306
mintedBlockIds,
307307
} = await withBlockVisibility(blockVisibility, async () =>
308-
applyOperationsToWorkflowState(baseGraph, filteredOperations, permissionConfig)
308+
applyOperationsToWorkflowState(
309+
baseGraph,
310+
filteredOperations,
311+
permissionConfig,
312+
principal.kind === 'delegated' && principal.serviceId === 'copilot'
313+
)
309314
)
310315
validationErrors.push(...credentialErrors)
311316

‎apps/sim/lib/workflows/application/replace-workflow-state.test.ts‎

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ const mocks = vi.hoisted(() => ({
1616
assertIdsUnclaimed: vi.fn(),
1717
validate: vi.fn(),
1818
needsRedeployment: vi.fn(),
19+
loadNormalized: vi.fn(),
1920
}))
2021

2122
vi.mock('@sim/audit', () => ({
@@ -52,11 +53,15 @@ vi.mock('@/lib/workflows/sanitization/validation', () => ({
5253
vi.mock('@/lib/workflows/deployment-status', () => ({
5354
checkNeedsRedeployment: mocks.needsRedeployment,
5455
}))
56+
vi.mock('@/lib/workflows/persistence/utils', () => ({
57+
loadWorkflowFromNormalizedTables: mocks.loadNormalized,
58+
}))
5559

5660
import { OrchestrationError } from '@/lib/core/orchestration/types'
5761
import { replaceWorkflowState } from '@/lib/workflows/application/replace-workflow-state'
5862
import { REFERENCES_UNCHECKED_NOTE } from '@/lib/workflows/editing/lint-report'
5963
import { validateInputsForBlock } from '@/lib/workflows/editing/validation'
64+
import { AgentBlock } from '@/blocks/blocks/agent'
6065
import { ExaBlock } from '@/blocks/blocks/exa'
6166
import { getBlock } from '@/blocks/registry'
6267

@@ -93,7 +98,7 @@ describe('replaceWorkflowState', () => {
9398
beforeEach(() => {
9499
vi.clearAllMocks()
95100
vi.mocked(getBlock).mockImplementation((type) =>
96-
type === 'exa' ? ExaBlock : defaultGetBlock?.(type)
101+
type === 'exa' ? ExaBlock : type === 'agent' ? AgentBlock : defaultGetBlock?.(type)
97102
)
98103
mocks.resolveContext.mockResolvedValue(context)
99104
mocks.resolvePermission.mockResolvedValue('write')
@@ -110,6 +115,7 @@ describe('replaceWorkflowState', () => {
110115
})
111116
mocks.collectGraphIds.mockReturnValue({ blockIds: ['block-1'], edgeIds: [], subflowIds: [] })
112117
mocks.assertIdsUnclaimed.mockResolvedValue(undefined)
118+
mocks.loadNormalized.mockResolvedValue({ blocks: {}, edges: [], loops: {}, parallels: {} })
113119
})
114120

115121
/**
@@ -502,6 +508,71 @@ describe('replaceWorkflowState', () => {
502508
})
503509
})
504510

511+
describe('Mothership attachment identity on state replacement', () => {
512+
const copilotPrincipal = {
513+
kind: 'delegated' as const,
514+
serviceId: 'copilot',
515+
subjectUserId: 'user-1',
516+
workspaceId: 'workspace-1',
517+
delegationId: 'tool-call-1',
518+
audience: 'sim:workflows',
519+
issuedAt: new Date('2026-01-01T00:00:00Z'),
520+
expiresAt: new Date('2099-01-01T00:00:00Z'),
521+
}
522+
const block = {
523+
...BLOCK,
524+
type: 'agent',
525+
subBlocks: {
526+
tools: {
527+
id: 'tools',
528+
type: 'tool-input' as const,
529+
value: [{ type: 'exa', operation: 'exa_search', title: 'Existing label' }],
530+
},
531+
},
532+
}
533+
const replacement = { ...input, blocks: { [BLOCK.id]: block } }
534+
535+
it.each([false, true])('rejects new aliases before persistence (dryRun=%s)', async (dryRun) => {
536+
await expect(
537+
replaceWorkflowState.execute({
538+
principal: copilotPrincipal,
539+
input: { ...replacement, dryRun },
540+
})
541+
).rejects.toThrow('attachment names are read-only')
542+
expect(mocks.replace).not.toHaveBeenCalled()
543+
expect(mocks.notify).not.toHaveBeenCalled()
544+
})
545+
546+
it('preserves an existing label when replacing the graph and editing other fields', async () => {
547+
mocks.loadNormalized.mockResolvedValue({
548+
blocks: { [BLOCK.id]: block },
549+
edges: [],
550+
loops: {},
551+
parallels: {},
552+
})
553+
await expect(
554+
replaceWorkflowState.execute({
555+
principal: copilotPrincipal,
556+
input: { ...replacement, blocks: { [BLOCK.id]: { ...block, name: 'Updated Agent' } } },
557+
})
558+
).resolves.toMatchObject({ dryRun: false })
559+
expect(mocks.replace).toHaveBeenCalledWith(
560+
expect.objectContaining({
561+
state: expect.objectContaining({
562+
blocks: { [BLOCK.id]: { ...block, name: 'Updated Agent' } },
563+
}),
564+
})
565+
)
566+
})
567+
568+
it('leaves ordinary authoring unchanged and avoids loading the graph', async () => {
569+
await expect(
570+
replaceWorkflowState.execute({ principal: sessionPrincipal, input: replacement })
571+
).resolves.toMatchObject({ dryRun: false })
572+
expect(mocks.loadNormalized).not.toHaveBeenCalled()
573+
})
574+
})
575+
505576
/**
506577
* A replace stores blocks and their tool wiring wholesale, and the policies
507578
* deciding which of those a member may add take a human subject. A workspace

‎apps/sim/lib/workflows/application/replace-workflow-state.ts‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,12 @@ import {
2727
collectWorkflowGraphIds,
2828
replaceWorkflowNormalizedState,
2929
} from '@/lib/workflows/persistence/replace-normalized-state'
30+
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
3031
import { validateWorkflowState } from '@/lib/workflows/sanitization/validation'
32+
import {
33+
getToolBindingAuthoringSchema,
34+
validateToolBindingAuthoring,
35+
} from '@/lib/workflows/tool-input/authoring'
3136
import { getBlock } from '@/blocks/registry'
3237

3338
const logger = createLogger('ReplaceWorkflowState')
@@ -127,13 +132,36 @@ export const replaceWorkflowState = defineAuthorizedWorkflowUseCase({
127132

128133
/** Use registry control types, never the caller's subblock type, just as operation edits do. */
129134
const blocks = structuredClone(sanitized.blocks) as Record<string, BlockState>
135+
const enforceToolBindings =
136+
principal.kind === 'delegated' &&
137+
principal.serviceId === 'copilot' &&
138+
Object.values(blocks).some((block) => getToolBindingAuthoringSchema(block.type))
139+
const previous = enforceToolBindings
140+
? await loadWorkflowFromNormalizedTables(context.workflowId)
141+
: undefined
142+
if (enforceToolBindings && !previous) {
143+
throw new OrchestrationError(
144+
'validation',
145+
'Cannot validate tool edits without the saved workflow state'
146+
)
147+
}
130148
for (const [blockId, block] of Object.entries(blocks)) {
131149
const config = getBlock(block.type)
132150
if (!config) continue
133151
const fields = new Map(config.subBlocks.map((field) => [field.id, field]))
134152
for (const [fieldId, stored] of Object.entries(block.subBlocks ?? {})) {
135153
const field = fields.get(fieldId)
136154
if (!field) continue
155+
if (enforceToolBindings && field.type === 'tool-input') {
156+
const savedBlock = previous?.blocks[blockId]
157+
const error = validateToolBindingAuthoring(
158+
block.type,
159+
stored.value,
160+
savedBlock?.type === block.type ? savedBlock.subBlocks[fieldId]?.value : undefined
161+
)
162+
if (error)
163+
throw new OrchestrationError('validation', `Block ${block.name || blockId}: ${error}`)
164+
}
137165
const result = validateValueForSubBlockType(
138166
field,
139167
stored.value,

‎apps/sim/lib/workflows/editing/builders.ts‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -158,14 +158,21 @@ export function createBlockFromParams(
158158
parentId?: string,
159159
errorsCollector?: ValidationError[],
160160
permissionConfig?: PermissionGroupConfig | null,
161-
skippedItems?: SkippedItem[]
161+
skippedItems?: SkippedItem[],
162+
enforceToolBindingContract = false
162163
): any {
163164
const blockConfig = getBlock(params.type)
164165

165166
// Validate inputs against block configuration
166167
let validatedInputs: Record<string, any> | undefined
167168
if (params.inputs) {
168-
const result = validateInputsForBlock(params.type, params.inputs, blockId)
169+
const result = validateInputsForBlock(
170+
params.type,
171+
params.inputs,
172+
blockId,
173+
{},
174+
enforceToolBindingContract
175+
)
169176
validatedInputs = result.validInputs
170177
if (errorsCollector && result.errors.length > 0) {
171178
errorsCollector.push(...result.errors)

‎apps/sim/lib/workflows/editing/engine.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,8 @@ function orderOperations(operations: EditWorkflowOperation[]): EditWorkflowOpera
159159
export function applyOperationsToWorkflowState(
160160
workflowState: Record<string, unknown>,
161161
operations: EditWorkflowOperation[],
162-
permissionConfig: PermissionGroupConfig | null = null
162+
permissionConfig: PermissionGroupConfig | null = null,
163+
enforceToolBindingContract = false
163164
): ApplyOperationsResult {
164165
// Deep clone the workflow state to avoid mutations
165166
const modifiedState = structuredClone(workflowState)
@@ -186,6 +187,7 @@ export function applyOperationsToWorkflowState(
186187
})
187188

188189
const ctx: OperationContext = {
190+
enforceToolBindingContract,
189191
modifiedState,
190192
skippedItems,
191193
validationErrors,

0 commit comments

Comments
 (0)