Skip to content

Commit 3a2f4e5

Browse files
feat(custom-blocks): add deploy_custom_block copilot tool (#5532)
* feat(custom-blocks): add deploy_custom_block copilot tool * feat(copilot): send workspace entitlements to the mothership * chore(copilot): sync tool catalog — plan-neutral deploy trigger text * refactor(copilot): extract entitlements registry with add-an-entitlement recipe * fix(custom-blocks): review fixes — undeploy without enterprise, array bounds, whitespace name * fix(custom-blocks): enforce per-item field limits from the REST contract * fix(custom-blocks): enterprise gate applies to first publish only, matching REST * chore(copilot): sync tool catalog — deploy_custom_block requires name
1 parent fca5f10 commit 3a2f4e5

13 files changed

Lines changed: 1297 additions & 148 deletions

File tree

apps/sim/app/api/mothership/execute/route.ts

Lines changed: 28 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { checkInternalAuth } from '@/lib/auth/hybrid'
88
import { buildIntegrationToolSchemas } from '@/lib/copilot/chat/payload'
99
import { processContextsServer } from '@/lib/copilot/chat/process-contents'
1010
import { generateWorkspaceContext } from '@/lib/copilot/chat/workspace-context'
11+
import { computeWorkspaceEntitlements } from '@/lib/copilot/entitlements'
1112
import {
1213
MothershipStreamV1EventType,
1314
MothershipStreamV1TextChannel,
@@ -141,25 +142,32 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
141142
const lastUserMessage = messages.filter((m) => m.role === 'user').at(-1)?.content
142143
// double-cast-allowed: the contract validates contexts as open kind/label objects; processContextsServer narrows on `kind` at runtime
143144
const agentMentions = contexts as unknown as ChatContext[] | undefined
144-
const [workspaceContext, integrationTools, userSkillTool, userPermission, agentContexts] =
145-
await Promise.all([
146-
generateWorkspaceContext(workspaceId, userId),
147-
buildIntegrationToolSchemas(userId, messageId, undefined, workspaceId),
148-
buildUserSkillTool(workspaceId),
149-
getUserEntityPermissions(userId, 'workspace', workspaceId).catch(() => null),
150-
processContextsServer(
151-
agentMentions,
152-
userId,
153-
lastUserMessage,
154-
workspaceId,
155-
effectiveChatId
156-
).catch((error) => {
157-
reqLogger.warn('Failed to resolve agent contexts for execution', {
158-
error: toError(error).message,
159-
})
160-
return []
161-
}),
162-
])
145+
const [
146+
workspaceContext,
147+
integrationTools,
148+
userSkillTool,
149+
userPermission,
150+
entitlements,
151+
agentContexts,
152+
] = await Promise.all([
153+
generateWorkspaceContext(workspaceId, userId),
154+
buildIntegrationToolSchemas(userId, messageId, undefined, workspaceId),
155+
buildUserSkillTool(workspaceId),
156+
getUserEntityPermissions(userId, 'workspace', workspaceId).catch(() => null),
157+
computeWorkspaceEntitlements(workspaceId, userId),
158+
processContextsServer(
159+
agentMentions,
160+
userId,
161+
lastUserMessage,
162+
workspaceId,
163+
effectiveChatId
164+
).catch((error) => {
165+
reqLogger.warn('Failed to resolve agent contexts for execution', {
166+
error: toError(error).message,
167+
})
168+
return []
169+
}),
170+
])
163171
const requestPayload: Record<string, unknown> = {
164172
messages,
165173
responseFormat,
@@ -182,6 +190,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
182190
...(integrationTools.length > 0 ? { integrationTools } : {}),
183191
...(userSkillTool ? { mothershipTools: [userSkillTool] } : {}),
184192
...(userPermission ? { userPermission } : {}),
193+
...(entitlements.length > 0 ? { entitlements } : {}),
185194
}
186195

187196
let allowExplicitAbort = true

apps/sim/lib/copilot/chat/payload.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,4 +237,34 @@ describe('buildCopilotRequestPayload', () => {
237237
})
238238
)
239239
})
240+
241+
it('passes entitlements through and omits the field when empty', async () => {
242+
const withEntitlements = await buildCopilotRequestPayload(
243+
{
244+
message: 'publish as a block',
245+
userId: 'user-1',
246+
userMessageId: 'msg-1',
247+
mode: 'agent',
248+
model: 'claude-opus-4-8',
249+
workspaceId: 'ws-1',
250+
entitlements: ['custom-blocks'],
251+
},
252+
{ selectedModel: 'claude-opus-4-8' }
253+
)
254+
expect(withEntitlements).toEqual(expect.objectContaining({ entitlements: ['custom-blocks'] }))
255+
256+
const withoutEntitlements = await buildCopilotRequestPayload(
257+
{
258+
message: 'publish as a block',
259+
userId: 'user-1',
260+
userMessageId: 'msg-1',
261+
mode: 'agent',
262+
model: 'claude-opus-4-8',
263+
workspaceId: 'ws-1',
264+
entitlements: [],
265+
},
266+
{ selectedModel: 'claude-opus-4-8' }
267+
)
268+
expect(withoutEntitlements).not.toHaveProperty('entitlements')
269+
})
240270
})

apps/sim/lib/copilot/chat/payload.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ interface BuildPayloadParams {
4141
workspaceContext?: string
4242
vfs?: VfsSnapshotV1
4343
userPermission?: string
44+
/** Plan/flag-gated org capabilities (e.g. "custom-blocks") the mothership gates tools/prompts on. */
45+
entitlements?: string[]
4446
userTimezone?: string
4547
userMetadata?: {
4648
name?: string
@@ -387,6 +389,7 @@ export async function buildCopilotRequestPayload(
387389
...(params.workspaceContext ? { workspaceContext: params.workspaceContext } : {}),
388390
...(params.vfs ? { vfs: params.vfs } : {}),
389391
...(params.userPermission ? { userPermission: params.userPermission } : {}),
392+
...(params.entitlements?.length ? { entitlements: params.entitlements } : {}),
390393
...(params.userTimezone ? { userTimezone: params.userTimezone } : {}),
391394
...(params.userMetadata &&
392395
(params.userMetadata.name || params.userMetadata.email || params.userMetadata.timezone)

apps/sim/lib/copilot/chat/post.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { finalizeAssistantTurn } from '@/lib/copilot/chat/terminal-state'
2525
import { generateWorkspaceSnapshot } from '@/lib/copilot/chat/workspace-context'
2626
import { chatPubSub } from '@/lib/copilot/chat-status'
2727
import { COPILOT_REQUEST_MODES } from '@/lib/copilot/constants'
28+
import { computeWorkspaceEntitlements } from '@/lib/copilot/entitlements'
2829
import {
2930
CopilotChatFinalizeOutcome,
3031
CopilotChatPersistOutcome,
@@ -175,6 +176,7 @@ type UnifiedChatBranch =
175176
contexts: Array<{ type: string; content: string; tag?: string; path?: string }>
176177
fileAttachments?: UnifiedChatRequest['fileAttachments']
177178
userPermission?: string
179+
entitlements?: string[]
178180
userTimezone?: string
179181
userMetadata?: { name?: string; email?: string; timezone?: string }
180182
workflowId: string
@@ -212,6 +214,7 @@ type UnifiedChatBranch =
212214
contexts: Array<{ type: string; content: string; tag?: string; path?: string }>
213215
fileAttachments?: UnifiedChatRequest['fileAttachments']
214216
userPermission?: string
217+
entitlements?: string[]
215218
userTimezone?: string
216219
userMetadata?: { name?: string; email?: string; timezone?: string }
217220
workspaceContext?: string
@@ -624,6 +627,7 @@ async function resolveBranch(params: {
624627
workspaceContext: payloadParams.workspaceContext,
625628
vfs: payloadParams.vfs,
626629
userPermission: payloadParams.userPermission,
630+
entitlements: payloadParams.entitlements,
627631
userTimezone: payloadParams.userTimezone,
628632
userMetadata: payloadParams.userMetadata,
629633
},
@@ -679,6 +683,7 @@ async function resolveBranch(params: {
679683
workspaceContext: payloadParams.workspaceContext,
680684
vfs: payloadParams.vfs,
681685
userPermission: payloadParams.userPermission,
686+
entitlements: payloadParams.entitlements,
682687
userTimezone: payloadParams.userTimezone,
683688
userMetadata: payloadParams.userMetadata,
684689
includeMothershipTools: true,
@@ -899,6 +904,9 @@ export async function handleUnifiedChatPost(req: NextRequest) {
899904
}
900905
)
901906
: Promise.resolve(null)
907+
const entitlementsPromise = workspaceId
908+
? computeWorkspaceEntitlements(workspaceId, authenticatedUserId)
909+
: Promise.resolve([])
902910
// Wrap the pre-LLM prep work in spans so the trace waterfall shows
903911
// where time is going between "request received" and "llm.stream
904912
// opens". Previously these ran bare under the root and inflated the
@@ -953,10 +961,11 @@ export async function handleUnifiedChatPost(req: NextRequest) {
953961
activeOtelRoot.context
954962
)
955963

956-
const [agentContexts, userPermission, workspaceSnapshot, , executionContext] =
964+
const [agentContexts, userPermission, entitlements, workspaceSnapshot, , executionContext] =
957965
await Promise.all([
958966
agentContextsPromise,
959967
userPermissionPromise,
968+
entitlementsPromise,
960969
workspaceContextPromise,
961970
persistUserMessagePromise,
962971
executionContextPromise,
@@ -991,6 +1000,7 @@ export async function handleUnifiedChatPost(req: NextRequest) {
9911000
contexts: agentContexts,
9921001
fileAttachments: body.fileAttachments,
9931002
userPermission: userPermission ?? undefined,
1003+
entitlements,
9941004
userTimezone: body.userTimezone,
9951005
userMetadata,
9961006
workflowId: branch.workflowId,
@@ -1012,6 +1022,7 @@ export async function handleUnifiedChatPost(req: NextRequest) {
10121022
contexts: agentContexts,
10131023
fileAttachments: body.fileAttachments,
10141024
userPermission: userPermission ?? undefined,
1025+
entitlements,
10151026
userTimezone: body.userTimezone,
10161027
userMetadata,
10171028
workspaceContext,
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { createLogger } from '@sim/logger'
2+
import { getErrorMessage } from '@sim/utils/errors'
3+
import { LRUCache } from 'lru-cache'
4+
import { isCustomBlocksEligible } from '@/lib/workflows/custom-blocks/operations'
5+
6+
const logger = createLogger('CopilotEntitlements')
7+
8+
/**
9+
* Cross-repo contract: the mothership (Go) matches these exact strings against
10+
* its `core.Entitlement*` constants to gate agent surfaces.
11+
*/
12+
export const CUSTOM_BLOCKS_ENTITLEMENT = 'custom-blocks'
13+
14+
/**
15+
* Workspace entitlements — plan/flag-gated org capabilities sent to the
16+
* mothership as the chat payload's `entitlements` array. The Go side hides the
17+
* matching tools, skills, and prompt sections when an entitlement is absent, so
18+
* a non-entitled org's agents never hear of the feature.
19+
*
20+
* Adding an entitlement:
21+
* 1. Add the kebab-case name and a fail-closed evaluator here. Every payload
22+
* site (interactive chat, headless execute, inbox) picks it up automatically.
23+
* 2. Go repo: add the matching `Entitlement*` constant in `internal/core` and
24+
* gate surfaces declaratively — `RequiredEntitlement` on tool definitions,
25+
* `entitlement:` frontmatter on skills, a conditional section in
26+
* `BuildAgentEnvelope`, or a variant swap in `Capabilities`.
27+
* 3. Keep enforcement in sim: the Go gating is advertisement-only (the payload
28+
* is forgeable), so the sim-side tool handler must re-check the same
29+
* predicate at execution time.
30+
*/
31+
const ENTITLEMENT_EVALUATORS: Record<
32+
string,
33+
(workspaceId: string, userId?: string) => Promise<boolean>
34+
> = {
35+
[CUSTOM_BLOCKS_ENTITLEMENT]: isCustomBlocksEligible,
36+
}
37+
38+
const entitlementsCache = new LRUCache<string, Promise<string[]>>({
39+
max: 500,
40+
ttl: 5_000,
41+
})
42+
43+
/**
44+
* The entitlements to send to the mothership for a request in this workspace.
45+
* Each evaluator fails closed (an error means the entitlement is absent).
46+
* Cached briefly so the several per-message callers collapse to one evaluation.
47+
*/
48+
export function computeWorkspaceEntitlements(
49+
workspaceId: string,
50+
userId?: string
51+
): Promise<string[]> {
52+
const cacheKey = `${workspaceId}:${userId ?? ''}`
53+
const cached = entitlementsCache.get(cacheKey)
54+
if (cached) return cached
55+
56+
const promise = Promise.all(
57+
Object.entries(ENTITLEMENT_EVALUATORS).map(async ([name, evaluate]) => {
58+
try {
59+
return (await evaluate(workspaceId, userId)) ? name : null
60+
} catch (error) {
61+
logger.warn('Entitlement evaluation failed; treating as absent', {
62+
entitlement: name,
63+
workspaceId,
64+
error: getErrorMessage(error),
65+
})
66+
return null
67+
}
68+
})
69+
).then((names) => names.filter((name): name is string => name !== null))
70+
entitlementsCache.set(cacheKey, promise)
71+
return promise
72+
}

0 commit comments

Comments
 (0)