fix(webhooks): stop the generic webhook publishing a closed output schema (#6939)

* fix(webhooks): stop the generic webhook publishing a closed output schema

Declaring outputs on the generic webhook trigger did not add three reference
completions — it made those three the only legal fields on the block.

`collectBlockData` registers any non-empty output declaration as an exhaustive
schema, and `resolveBlockReference` then throws `InvalidFieldError` for any
reference outside it that resolves to `undefined`. A generic webhook receives
whatever the caller sends, so every workflow reading a body field started
failing the moment a delivery omitted that field, instead of resolving to
`undefined` and letting the condition evaluate falsy as it always had.

Revert the declaration to `{}` and record why it has to stay that way. The
request metadata is still merged into the workflow input by the provider's
`formatInput`; it is only undeclared, which is what keeps the shape open.
Offering these as editor completions needs a way to mark outputs as hints
rather than a closed schema — a change to `getRegistrySchema`, not to this list.

Pins the behavior at the executor level rather than on the trigger config,
since the config assertion is what passed while the block was broken.

* chore(audits): re-record the workspace module-graph baseline

`check:tool-registry-boundary` fails on CI for any branch right now: the
knowledge page measures 2255 modules against a 2209 baseline, one module past
the max(25, 2%) allowance. It passes locally at 2253, which is why it only
shows up in CI — the two platforms resolve a couple of modules differently, and
the route happened to sit inside that gap.

The drift is not from any one change. 26 of the 34 recorded routes have grown
since the baseline was last written, by up to +44. Measuring this branch's
route with and without its own diff gives 2253 either way, so it contributes
nothing; it is just the branch that happened to cross the line.

Re-records all 34 entries, which is what the script prescribes. No gateway was
added or removed on any route — only the module counts moved — so the boundary
this audit exists to protect is unchanged and the first assertion, that the
tool registry stays out of every workspace page graph, still passes.

Worth a separate look at why the workspace pages have grown this much; this
commit only stops a stale number from blocking unrelated work.

* chore(tests): give the block-data test helper an explicit return type
This commit is contained in:
Waleed
2026-08-21 11:32:57 -07:00
committed by GitHub
parent 00d8a3fbfe
commit 6b7fd1a88d
3 changed files with 106 additions and 58 deletions
@@ -0,0 +1,77 @@
/**
* @vitest-environment node
*/
import { describe, expect, it, vi } from 'vitest'
import { getBlockSchema } from '@/executor/utils/block-data'
import { resolveBlockReference } from '@/executor/utils/block-reference'
import type { SerializedBlock } from '@/serializer/types'
/**
* These assertions are about what the real block registry publishes, so the global stub which
* returns one mock block with no outputs would make every case here pass vacuously.
*/
vi.unmock('@/blocks/registry')
function triggerBlock(type: string, params: Record<string, unknown> = {}): SerializedBlock {
return {
id: 'trigger-1',
metadata: { id: type, name: 'webhook1', category: 'triggers' },
position: { x: 0, y: 0 },
config: { tool: '', params },
inputs: {},
outputs: {},
enabled: true,
} as unknown as SerializedBlock
}
function resolve(
pathParts: string[],
schema: ReturnType<typeof getBlockSchema>
): ReturnType<typeof resolveBlockReference> {
return resolveBlockReference(
'webhook1',
pathParts,
{
blockNameMapping: { webhook1: 'trigger-1' },
blockData: { 'trigger-1': { query: { env: 'prod' } } },
blockOutputSchemas: schema ? { 'trigger-1': schema } : {},
} as never,
{} as never
)
}
describe('generic webhook output schema', () => {
/**
* A generic webhook receives whatever the caller sends, so it must publish no schema at all.
* `collectBlockData` registers any non-empty output declaration as exhaustive, which turns
* every unlisted field into a hard `InvalidFieldError` rather than an absent value.
*/
it('publishes no output schema, leaving the block shape open', () => {
expect(getBlockSchema(triggerBlock('generic_webhook'))).toBeUndefined()
})
it.each([
[{}, 'no flags set'],
[{ acceptOtherMethods: true, exposeRequestHeaders: true }, 'both request-metadata flags on'],
])('stays open with %o (%s)', (params) => {
expect(getBlockSchema(triggerBlock('generic_webhook', params))).toBeUndefined()
})
/**
* The production regression this pins: a Slack interactive payload reaching a workflow that
* reads `actions.0.selected_option.value`. When a delivery omits the field the reference must
* resolve to `undefined` so the condition simply evaluates falsy not abort the run.
*/
it('resolves an absent body field to undefined instead of throwing', () => {
const schema = getBlockSchema(triggerBlock('generic_webhook'))
expect(() => resolve(['actions', '0', 'selected_option', 'value'], schema)).not.toThrow()
expect(resolve(['actions', '0', 'selected_option', 'value'], schema)?.value).toBeUndefined()
})
it('still resolves request metadata the provider merges into the input', () => {
const schema = getBlockSchema(triggerBlock('generic_webhook'))
expect(resolve(['query', 'env'], schema)?.value).toBe('prod')
})
})
+16 -33
View File
@@ -13,11 +13,13 @@ function setupInstructions(): string {
}
describe('genericWebhookTrigger', () => {
it('declares the request metadata so it can be referenced from later blocks', () => {
expect(Object.keys(genericWebhookTrigger.outputs)).toEqual(['method', 'query', 'headers'])
expect(genericWebhookTrigger.outputs.method.type).toBe('string')
expect(genericWebhookTrigger.outputs.query.type).toBe('object')
expect(genericWebhookTrigger.outputs.headers.type).toBe('object')
/**
* Declaring outputs here does not add editor completions the executor reads the same list as
* an exhaustive schema and rejects every field outside it. See
* `executor/utils/block-data.test.ts` for the behavior this protects.
*/
it('declares no outputs, because the caller decides the payload shape', () => {
expect(genericWebhookTrigger.outputs).toEqual({})
})
/**
@@ -40,40 +42,21 @@ describe('genericWebhookTrigger', () => {
expect(instructions).toContain('GET, PUT, PATCH and DELETE')
})
it('names every reserved key the input can carry', () => {
const instructions = setupInstructions()
for (const key of Object.keys(genericWebhookTrigger.outputs)) {
expect(instructions).toContain(`"${key}"`)
/**
* Named explicitly rather than derived from `outputs`, which is intentionally empty deriving
* it would make this assertion vacuous.
*/
it.each(['method', 'query', 'headers'])(
'names the reserved "%s" key the input can carry',
(key) => {
expect(setupInstructions()).toContain(`"${key}"`)
}
})
)
it('names the switch that exposes headers rather than promising them', () => {
expect(setupInstructions()).toContain('"Expose Request Headers"')
})
/**
* Two of the three outputs only exist once a switch is on, so they are conditioned on it: the
* reference dropdown must not offer a field the running webhook will not send.
*/
it.each([
['method', 'acceptOtherMethods'],
['headers', 'exposeRequestHeaders'],
])('gates the %s output on the switch that produces it', (key, field) => {
expect(genericWebhookTrigger.outputs[key].condition).toEqual({
field,
value: [true, 'true'],
})
})
/**
* Query parameters are the one key that is not opt-in, so offering them unconditionally is
* correct gating them on a switch that does not exist would hide them entirely.
*/
it('offers query unconditionally', () => {
expect(genericWebhookTrigger.outputs.query.condition).toBeUndefined()
})
/**
* Auth is header-based, so a plain link cannot carry it. Saying so is the difference between a
* user disabling auth knowingly and discovering it after publishing an open trigger URL.
+13 -25
View File
@@ -152,33 +152,21 @@ export const genericWebhookTrigger: TriggerConfig = {
],
/**
* Body fields stay undeclared because a generic webhook receives whatever JSON the caller
* sends. The request metadata below is known ahead of time, so it can be offered for reference.
* Deliberately empty, and it must stay that way.
*
* `method` and `headers` are conditioned on the switch that produces them, so the reference
* dropdown never offers a field the running webhook will not send. Both truthy forms are
* matched because a YAML- or Copilot-authored workflow can write the string rather than the
* boolean the same tolerance `isProviderConfigFlagEnabled` applies at delivery time.
* A generic webhook receives whatever the caller sends, so its output shape is unknowable. The
* executor treats any non-empty output declaration as an exhaustive schema: `collectBlockData`
* registers it, and `resolveBlockReference` then throws `InvalidFieldError` for any reference
* outside it that resolves to `undefined`. Declaring `method`, `query` and `headers` here
* therefore did not add three completions it made those three the *only* legal fields, and
* every workflow reading a body field failed the moment a delivery omitted it.
*
* The metadata is still merged into the input at delivery time by the generic provider's
* `formatInput`; it is only undeclared, which is what keeps the block's shape open. Offering
* these as editor completions needs a way to mark outputs as hints rather than a closed schema,
* which is a change to `getRegistrySchema`, not to this list.
*/
outputs: {
method: {
type: 'string',
description:
'HTTP method of the request. Yields to a body field of the same name if the caller sends one.',
condition: { field: 'acceptOtherMethods', value: [true, 'true'] },
},
query: {
type: 'object',
description:
'Query parameters from the request URL, when it has any. Yields to a body field of the same name if the caller sends one.',
},
headers: {
type: 'object',
description:
'Request headers, excluding the ones that carry credentials. Yields to a body field of the same name if the caller sends one.',
condition: { field: 'exposeRequestHeaders', value: [true, 'true'] },
},
},
outputs: {},
webhook: {
method: 'POST',