Skip to content

Commit 264bc74

Browse files
fix(custom-blocks): require curated outputs instead of exposing the whole result
1 parent 8c8ffc1 commit 264bc74

8 files changed

Lines changed: 166 additions & 38 deletions

File tree

apps/sim/blocks/custom/build-config.test.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,15 +94,18 @@ describe('buildCustomBlockConfig', () => {
9494
expect(findSub(config, 'docs')?.multiple).toBe(true)
9595
})
9696

97-
it('exposes the full result and hides plumbing when no outputs are curated', () => {
97+
it('advertises no data fields — and no whole-result fallback — without curation', () => {
9898
const config = buildCustomBlockConfig(row, fields, { icon })
99+
// Curation is required at publish, so an uncurated row exposes only the
100+
// system fields. `result` must not come back: it would advertise the child's
101+
// raw terminal state (agent toolCalls/thinking, nested workflow ids).
99102
expect(Object.keys(config.outputs).sort()).toEqual([
100103
'error',
101104
'errorRef',
102105
'errorType',
103-
'result',
104106
'success',
105107
])
108+
expect(config.outputs.result).toBeUndefined()
106109
expect(config.outputs.childWorkflowId).toBeUndefined()
107110
expect(config.outputs.childTraceSpans).toBeUndefined()
108111
})

apps/sim/blocks/custom/build-config.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -212,12 +212,12 @@ function buildOutputs(exposed: CustomBlockOutput[] | undefined): BlockConfig['ou
212212
errorType: { type: 'string', description: 'Machine-readable failure class' },
213213
errorRef: { type: 'string', description: 'Opaque reference to the failed run' },
214214
}
215-
if (exposed && exposed.length > 0) {
216-
for (const out of exposed) {
217-
outputs[out.name] = { type: 'json', description: `Output: ${out.path}` }
218-
}
219-
} else {
220-
outputs.result = { type: 'json', description: 'Workflow execution result' }
215+
// No whole-`result` fallback: curation is required at publish, so every
216+
// consumer-visible field is one the publisher chose. A legacy row with no
217+
// curated outputs advertises no data fields and fails loudly at invocation
218+
// rather than silently reverting to exposing the child's raw terminal state.
219+
for (const out of exposed ?? []) {
220+
outputs[out.name] = { type: 'json', description: `Output: ${out.path}` }
221221
}
222222
return outputs
223223
}

apps/sim/executor/handlers/workflow/workflow-handler.test.ts

Lines changed: 32 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -456,7 +456,7 @@ describe('WorkflowBlockHandler', () => {
456456
workflowId: 'source-workflow-id',
457457
organizationId: 'org-1',
458458
ownerUserId: 'owner-9',
459-
exposedOutputs: [],
459+
exposedOutputs: [{ blockId: 'b1', path: 'content', name: 'answer' }],
460460
requiredInputIds: [],
461461
})
462462
mockGetPersonalAndWorkspaceEnv.mockResolvedValue({
@@ -519,7 +519,7 @@ describe('WorkflowBlockHandler', () => {
519519
workflowId: 'source-workflow-id',
520520
organizationId: 'org-1',
521521
ownerUserId: 'owner-9',
522-
exposedOutputs: [],
522+
exposedOutputs: [{ blockId: 'b1', path: 'content', name: 'answer' }],
523523
requiredInputIds: [],
524524
})
525525
mockGetPersonalAndWorkspaceEnv.mockResolvedValue({
@@ -620,7 +620,7 @@ describe('WorkflowBlockHandler', () => {
620620
workflowId: 'source-workflow-id',
621621
organizationId: 'org-1',
622622
ownerUserId: 'owner-9',
623-
exposedOutputs: [],
623+
exposedOutputs: [{ blockId: 'b1', path: 'content', name: 'answer' }],
624624
requiredInputIds: [],
625625
})
626626
mockGetPersonalAndWorkspaceEnv.mockResolvedValue({
@@ -1038,7 +1038,7 @@ describe('WorkflowBlockHandler', () => {
10381038
workflowId: 'source-workflow-id',
10391039
organizationId: 'org-1',
10401040
ownerUserId: 'owner-9',
1041-
exposedOutputs: [],
1041+
exposedOutputs: [{ blockId: 'b1', path: 'content', name: 'answer' }],
10421042
requiredInputIds: [],
10431043
})
10441044
mockGetPersonalAndWorkspaceEnv.mockResolvedValue({
@@ -1285,6 +1285,26 @@ describe('WorkflowBlockHandler', () => {
12851285
expect(error.childWorkflowName).toBe('Published Block')
12861286
})
12871287

1288+
it('fails loudly on a legacy row with no curated outputs', async () => {
1289+
// Curation is required at publish; a pre-rule row must not silently fall
1290+
// back to exposing the child's raw terminal state.
1291+
mockGetCustomBlockAuthority.mockResolvedValue({
1292+
workflowId: 'source-workflow-id',
1293+
organizationId: 'org-1',
1294+
ownerUserId: 'owner-9',
1295+
exposedOutputs: [],
1296+
requiredInputIds: [],
1297+
})
1298+
1299+
const error = await handler
1300+
.execute(customBlockContext(), customBlock(), {})
1301+
.catch((e: any) => e)
1302+
1303+
expect(error.consumerFacing.errorType).toBe('unavailable')
1304+
expect(error.message).toContain('re-publish')
1305+
expect(mockExecutorExecute).not.toHaveBeenCalled()
1306+
})
1307+
12881308
it('classifies an unavailable block so consumers can branch on it', async () => {
12891309
mockGetCustomBlockAuthority.mockResolvedValue(null)
12901310

@@ -1328,7 +1348,7 @@ describe('WorkflowBlockHandler', () => {
13281348
workflowId: 'source-workflow-id',
13291349
organizationId: 'org-1',
13301350
ownerUserId: 'owner-9',
1331-
exposedOutputs: [],
1351+
exposedOutputs: [{ blockId: 'b1', path: 'content', name: 'answer' }],
13321352
requiredInputIds: ['field-1'],
13331353
})
13341354
mockFetch.mockImplementation(async (url: unknown) => {
@@ -1429,13 +1449,15 @@ describe('WorkflowBlockHandler', () => {
14291449
expect(result.cost).toBeUndefined()
14301450
})
14311451

1432-
it('exposes the whole child result when no outputs are curated', () => {
1452+
it('never dumps the child result when no outputs are curated', () => {
1453+
// Curation is required at publish and guarded at invocation, so this path
1454+
// is unreachable in production — but it must not fall back to exposing the
1455+
// terminal block's raw state (agent toolCalls/thinking, nested workflow
1456+
// ids) if it is ever reached.
14331457
const result = (handler as any).projectCustomBlockOutput(childResult, [])
14341458

1435-
expect(result).toEqual({
1436-
success: true,
1437-
result: { data: 'whole result' },
1438-
})
1459+
expect(result).toEqual({ success: true })
1460+
expect((result as any).result).toBeUndefined()
14391461
})
14401462
})
14411463
})

apps/sim/executor/handlers/workflow/workflow-handler.ts

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,22 @@ export class WorkflowBlockHandler implements BlockHandler {
219219
loadUserId = authority.ownerUserId
220220
exposedOutputs = authority.exposedOutputs
221221
requiredInputIds = authority.requiredInputIds
222+
223+
// Curation is required at publish, so this only trips on a row that
224+
// predates that rule. Fail loudly rather than fall back to exposing the
225+
// child's raw terminal state, which is what the boundary exists to stop.
226+
if (exposedOutputs.length === 0) {
227+
throw this.buildBoundaryFailure(
228+
new BoundarySafeError({
229+
errorType: 'unavailable',
230+
message:
231+
'This custom block exposes no outputs. Its publisher must re-publish it with at least one output selected.',
232+
}),
233+
block,
234+
instanceId,
235+
undefined
236+
)
237+
}
222238
}
223239

224240
if (!workflowId) {
@@ -1126,19 +1142,20 @@ export class WorkflowBlockHandler implements BlockHandler {
11261142
}
11271143

11281144
/**
1129-
* Shape a custom block's successful output. With curated `exposedOutputs`, each
1130-
* maps a child block output (blockId + dot-path, read from the child's per-block
1131-
* logs) to a named top-level field. With none, exposes the child's whole
1132-
* `result`. Never leaks child workflow id/name/trace spans — nor its cost, which
1145+
* Shape a custom block's successful output: each curated `exposedOutput` maps a
1146+
* child block output (blockId + dot-path, read from the child's per-block logs)
1147+
* to a named top-level field.
1148+
*
1149+
* Curation is required at publish, so there is no whole-`result` fallback —
1150+
* that path would hand the consumer the terminal block's raw state, including
1151+
* an agent's `toolCalls`/`thinkingContent`/`cost` or a nested workflow block's
1152+
* identifiers. Never leaks child workflow id/name/trace spans, nor cost, which
11331153
* is billed to the source workspace by the child's own logging session.
11341154
*/
11351155
private projectCustomBlockOutput(
11361156
executionResult: ExecutionResult,
11371157
exposedOutputs: CustomBlockOutput[]
11381158
): BlockOutput {
1139-
if (exposedOutputs.length === 0) {
1140-
return { success: true, result: executionResult.output ?? {} }
1141-
}
11421159
const logs = executionResult.logs ?? []
11431160
const output: Record<string, unknown> = {}
11441161
for (const { blockId, path, name } of exposedOutputs) {

apps/sim/lib/api/contracts/custom-blocks.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -102,8 +102,17 @@ export const publishCustomBlockBodySchema = z.object({
102102
iconUrl: iconUrlSchema.optional(),
103103
/** Per-input placeholder hints keyed by Start field id; the field set itself is always derived from the deployment. */
104104
inputs: z.array(inputPlaceholderSchema).max(50).optional(),
105-
/** Curated outputs; omit/empty to expose the child's whole result. */
106-
exposedOutputs: z.array(exposedOutputWriteSchema).max(50).optional(),
105+
/**
106+
* Curated outputs. REQUIRED: every field a consumer receives must be one the
107+
* publisher explicitly chose. There is deliberately no "expose everything"
108+
* fallback — the terminal block's raw state carries execution metadata
109+
* (an agent's `toolCalls`, `providerTiming.thinkingContent`, `cost`; a nested
110+
* workflow block's ids) that would cross the invocation boundary unchosen.
111+
*/
112+
exposedOutputs: z
113+
.array(exposedOutputWriteSchema)
114+
.min(1, 'Select at least one output to expose to consumers')
115+
.max(50),
107116
})
108117

109118
export type PublishCustomBlockBody = z.input<typeof publishCustomBlockBodySchema>
@@ -120,7 +129,12 @@ export const updateCustomBlockBodySchema = z
120129
/** A URL (https or internal serve path) sets/replaces the icon; `null` clears it (default icon). */
121130
iconUrl: iconUrlSchema.nullable().optional(),
122131
inputs: z.array(inputPlaceholderSchema).max(50).optional(),
123-
exposedOutputs: z.array(exposedOutputWriteSchema).max(50).optional(),
132+
/** Omit to leave the curated outputs unchanged; never settable to empty. */
133+
exposedOutputs: z
134+
.array(exposedOutputWriteSchema)
135+
.min(1, 'Select at least one output to expose to consumers')
136+
.max(50)
137+
.optional(),
124138
})
125139
.refine((v) => Object.keys(v).length > 0, { message: 'At least one field is required' })
126140

apps/sim/lib/copilot/tools/handlers/deployment/custom-block.test.ts

Lines changed: 43 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -341,7 +341,11 @@ describe('executeDeployCustomBlock', () => {
341341
publishCustomBlockMock.mockResolvedValue(publishedBlock)
342342

343343
const result = await executeDeployCustomBlock(
344-
{ name: 'Enrich Lead', iconUrl: 'files/icon.png' },
344+
{
345+
name: 'Enrich Lead',
346+
exposedOutputs: [{ blockId: 'b1', path: 'content', name: 'answer' }],
347+
iconUrl: 'files/icon.png',
348+
},
345349
context
346350
)
347351

@@ -364,7 +368,11 @@ describe('executeDeployCustomBlock', () => {
364368
publishCustomBlockMock.mockResolvedValue(publishedBlock)
365369

366370
const result = await executeDeployCustomBlock(
367-
{ name: 'Enrich Lead', iconUrl: 'https://example.com/icon.png' },
371+
{
372+
name: 'Enrich Lead',
373+
exposedOutputs: [{ blockId: 'b1', path: 'content', name: 'answer' }],
374+
iconUrl: 'https://example.com/icon.png',
375+
},
368376
context
369377
)
370378

@@ -379,7 +387,11 @@ describe('executeDeployCustomBlock', () => {
379387
listWorkspaceFilesMock.mockResolvedValue([])
380388

381389
const result = await executeDeployCustomBlock(
382-
{ name: 'Enrich Lead', iconUrl: 'files/missing.png' },
390+
{
391+
name: 'Enrich Lead',
392+
exposedOutputs: [{ blockId: 'b1', path: 'content', name: 'answer' }],
393+
iconUrl: 'files/missing.png',
394+
},
383395
context
384396
)
385397

@@ -390,22 +402,34 @@ describe('executeDeployCustomBlock', () => {
390402

391403
it('rejects non-https icon URL schemes on pass-through', async () => {
392404
const dataUri = await executeDeployCustomBlock(
393-
{ name: 'Enrich Lead', iconUrl: 'data:image/svg+xml;base64,PHN2Zy8+' },
405+
{
406+
name: 'Enrich Lead',
407+
exposedOutputs: [{ blockId: 'b1', path: 'content', name: 'answer' }],
408+
iconUrl: 'data:image/svg+xml;base64,PHN2Zy8+',
409+
},
394410
context
395411
)
396412
expect(dataUri.success).toBe(false)
397413
expect(dataUri.error).toContain('https')
398414

399415
const plainHttp = await executeDeployCustomBlock(
400-
{ name: 'Enrich Lead', iconUrl: 'http://example.com/icon.png' },
416+
{
417+
name: 'Enrich Lead',
418+
exposedOutputs: [{ blockId: 'b1', path: 'content', name: 'answer' }],
419+
iconUrl: 'http://example.com/icon.png',
420+
},
401421
context
402422
)
403423
expect(plainHttp.success).toBe(false)
404424
expect(publishCustomBlockMock).not.toHaveBeenCalled()
405425

406426
publishCustomBlockMock.mockResolvedValue(publishedBlock)
407427
const servePath = await executeDeployCustomBlock(
408-
{ name: 'Enrich Lead', iconUrl: '/api/files/serve/workspace-logos%2Ficon.png' },
428+
{
429+
name: 'Enrich Lead',
430+
exposedOutputs: [{ blockId: 'b1', path: 'content', name: 'answer' }],
431+
iconUrl: '/api/files/serve/workspace-logos%2Ficon.png',
432+
},
409433
context
410434
)
411435
expect(servePath.success).toBe(true)
@@ -417,7 +441,11 @@ describe('executeDeployCustomBlock', () => {
417441
])
418442

419443
const result = await executeDeployCustomBlock(
420-
{ name: 'Enrich Lead', iconUrl: 'files/notes.pdf' },
444+
{
445+
name: 'Enrich Lead',
446+
exposedOutputs: [{ blockId: 'b1', path: 'content', name: 'answer' }],
447+
iconUrl: 'files/notes.pdf',
448+
},
421449
context
422450
)
423451

@@ -434,4 +462,12 @@ describe('executeDeployCustomBlock', () => {
434462
expect(result.success).toBe(false)
435463
expect(result.error).toContain('organization')
436464
})
465+
466+
it('refuses to publish without curated outputs', async () => {
467+
const result = await executeDeployCustomBlock({ name: 'Enrich Lead' }, context)
468+
469+
expect(result.success).toBe(false)
470+
expect(result.error).toContain('exposedOutputs is required')
471+
expect(publishCustomBlockMock).not.toHaveBeenCalled()
472+
})
437473
})

apps/sim/lib/copilot/tools/handlers/deployment/custom-block.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,17 @@ export async function executeDeployCustomBlock(
258258
'Workflow must be deployed before publishing as a custom block. Use deploy_api first.',
259259
}
260260
}
261+
// Curation is required on publish: every consumer-visible field must be one
262+
// the publisher chose, since there is no whole-`result` fallback.
263+
const exposedOutputs = params.exposedOutputs
264+
if (!exposedOutputs || exposedOutputs.length === 0) {
265+
return {
266+
success: false,
267+
error:
268+
'exposedOutputs is required: select at least one workflow output to expose to consumers',
269+
}
270+
}
271+
261272
const block = await publishCustomBlock({
262273
organizationId,
263274
workspaceId,
@@ -267,7 +278,7 @@ export async function executeDeployCustomBlock(
267278
description: description ?? '',
268279
iconUrl,
269280
inputs: params.inputs,
270-
exposedOutputs: params.exposedOutputs,
281+
exposedOutputs,
271282
})
272283
recordAudit({
273284
workspaceId,

0 commit comments

Comments
 (0)