From a9f9f2808c33ec53545d00d1efdf5db394654c22 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 08:35:25 +0000 Subject: [PATCH 1/4] fix(automation)!: refuse a connector_action call whose input breaks the action's inputSchema before dispatch The connector_action executor now judges the evaluated input against the action's declared inputSchema (required, type with OpenAPI nullable, properties, items) after the value slots resolve and before the handler is called. A failing call fails the step with code VALIDATION_ERROR, names every missing or mistyped parameter by its dotted path, and sends nothing. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L6bhgX3dtTQwah2MjCjf5i --- .../builtin/connector-input-schema.test.ts | 245 ++++++++++++++++++ .../src/builtin/connector-nodes.ts | 108 ++++++++ 2 files changed, 353 insertions(+) create mode 100644 packages/services/service-automation/src/builtin/connector-input-schema.test.ts diff --git a/packages/services/service-automation/src/builtin/connector-input-schema.test.ts b/packages/services/service-automation/src/builtin/connector-input-schema.test.ts new file mode 100644 index 00000000000..95900788aaf --- /dev/null +++ b/packages/services/service-automation/src/builtin/connector-input-schema.test.ts @@ -0,0 +1,245 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * **A `connector_action` call is checked against the action's declared + * `inputSchema` before it is dispatched** (#27995). + * + * Before this, the schema was advisory: a call to an OpenAPI `getPet` with + * `input: {}` reached the remote URL although `petId` is declared `required`, + * and what came back depended on the remote. Pinned through the shipped + * `AutomationEngine` + builtin nodes, with the `inputSchema` shapes the three + * connector producers actually emit: + * + * 1. **Missing required → refused, nothing sent.** The step fails with + * `code: 'VALIDATION_ERROR'`, the error names every missing parameter by + * its dotted path and says how to fix it, and the handler is never called. + * 2. **Wrong type → refused, nothing sent.** The error names the parameter, + * the declared type and the type it got. The EVALUATED input is judged: a + * CEL envelope computing the wrong type is refused like a literal one. + * 3. **A valid call dispatches as before**, including one whose required + * value is computed by an envelope. + * 4. **Only the judged subset refuses.** `required`, `type` (with OpenAPI 3.0 + * `nullable`), `properties` and `items` are judged; keywords outside that + * subset (`$ref`, `oneOf`, `enum`, `format`, `additionalProperties`, …) + * never refuse a call, and an action with no `inputSchema` dispatches + * whatever it is given. + * 5. **The refusal is routable.** The input can come from run data, so the + * failure is a `runtime` one: a `fault` edge receives it, with + * `$error.code === 'VALIDATION_ERROR'`. + */ + +import { describe, expect, it } from 'vitest'; +import { AutomationEngine } from '../engine.js'; +import type { NodeExecutor } from '../engine.js'; +import { installBuiltinNodes } from './index.js'; +import type { Connector } from '@objectstack/spec/integration'; + +function silentLogger(): any { + return { info() {}, warn() {}, error() {}, debug() {}, child() { return silentLogger(); } }; +} +function ctx(): any { + return { logger: silentLogger(), getService() { return undefined; } }; +} + +/** The shape `connector-openapi`'s `buildInputSchema` emits for `GET /pets/{petId}?verbose=`. */ +const OPENAPI_GET_PET = { + type: 'object', + properties: { + path: { type: 'object', properties: { petId: { type: 'integer', format: 'int64' } }, required: ['petId'] }, + query: { type: 'object', properties: { verbose: { type: 'boolean' }, tag: { type: 'string', nullable: true } } }, + }, + required: ['path'], +}; + +/** An MCP `tools/list` schema, passed through verbatim by `connector-mcp`. */ +const MCP_ECHO = { + $schema: 'https://json-schema.org/draft/2020-12/schema', + type: 'object', + properties: { + text: { type: 'string', description: 'Text to upper-case' }, + mode: { enum: ['upper', 'lower'] }, + meta: { $ref: '#/$defs/meta' }, + tags: { type: 'array', items: { type: 'string' } }, + when: { type: 'string', format: 'date-time' }, + either: { oneOf: [{ type: 'string' }, { type: 'number' }] }, + }, + required: ['text'], + additionalProperties: false, +}; + +/** A hand-written code connector's schema (`connector-slack`'s `chat.postMessage`). */ +const SLACK_POST = { + type: 'object', + required: ['channel'], + properties: { + channel: { type: 'string' }, + text: { type: 'string' }, + blocks: { type: 'array' }, + }, +}; + +const CONNECTOR = { + name: 'vendor', + label: 'Vendor', + type: 'api', + authentication: { type: 'none' }, + actions: [ + { key: 'getPet', label: 'Get pet', inputSchema: OPENAPI_GET_PET }, + { key: 'echo', label: 'Echo', inputSchema: MCP_ECHO }, + { key: 'post', label: 'Post', inputSchema: SLACK_POST }, + { key: 'update', label: 'Update', inputSchema: { ...SLACK_POST, required: ['channel', 'ts'] } }, + { key: 'free', label: 'Free-form' }, + ], +} as Connector; + +type Seen = { code?: string; message?: string } | undefined; + +/** An engine with every builtin node, one connector recording each dispatch, and a fault-edge capture node. */ +function makeEngine(): { engine: AutomationEngine; sent: Array<{ action: string; input: unknown }>; faults: Seen[] } { + const engine = new AutomationEngine(silentLogger()); + installBuiltinNodes(engine, ctx()); + const sent: Array<{ action: string; input: unknown }> = []; + const handler = (action: string) => async (input: Record) => { + sent.push({ action, input }); + return { ok: true }; + }; + engine.registerConnector(CONNECTOR, { + getPet: handler('getPet'), + echo: handler('echo'), + post: handler('post'), + update: handler('update'), + free: handler('free'), + }); + const faults: Seen[] = []; + engine.registerNodeExecutor({ + type: 'capture_error', + async execute(_node, variables) { + faults.push(variables.get('$error') as Seen); + return { success: true }; + }, + } as NodeExecutor); + return { engine, sent, faults }; +} + +/** start → `connector_action` (vendor.ACTION, input) → end; `rec` is an input variable. */ +function flowWith(name: string, actionId: string, input: Record | undefined, opts: { fault?: boolean } = {}): any { + const nodes: any[] = [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'call', type: 'connector_action', label: 'Call', connectorConfig: { connectorId: 'vendor', actionId, ...(input ? { input } : {}) } }, + { id: 'end', type: 'end', label: 'End' }, + ]; + const edges: any[] = [{ id: 'e1', source: 'start', target: 'call' }, { id: 'e2', source: 'call', target: 'end' }]; + if (opts.fault) { + nodes.push({ id: 'handler', type: 'capture_error', label: 'Handler' }); + edges.push({ id: 'ef', source: 'call', target: 'handler', type: 'fault' }, { id: 'e3', source: 'handler', target: 'end' }); + } + return { + name, label: name, type: 'autolaunched', + variables: [{ name: 'rec', type: 'object', isInput: true }], + nodes, + edges, + }; +} + +async function run(engine: AutomationEngine, name: string, actionId: string, input: Record | undefined, opts: { fault?: boolean } = {}) { + engine.registerFlow(name, flowWith(name, actionId, input, opts)); + return engine.execute(name, { params: { rec: { id: 'cus_42', petId: 7, name: 'Rex' } } } as any); +} + +describe('1. a missing required parameter is refused before dispatch', () => { + it('OpenAPI `getPet` with `input: {}` fails naming `path`, and nothing is sent', async () => { + const { engine, sent, faults } = makeEngine(); + const result = await run(engine, 'no_path', 'getPet', {}, { fault: true }); + expect(sent).toEqual([]); + expect(faults).toHaveLength(1); + expect(faults[0]?.code).toBe('VALIDATION_ERROR'); + expect(faults[0]?.message).toContain("missing required 'path'"); + expect(result.success).toBe(true); + }); + + it('`input: { path: {} }` fails naming the nested `path.petId`, with the fix', async () => { + const { engine, sent } = makeEngine(); + const result = await run(engine, 'no_pet_id', 'getPet', { path: {} }); + expect(result.success).toBe(false); + expect(sent).toEqual([]); + expect(result.error).toContain("'vendor.getPet'"); + expect(result.error).toContain("missing required 'path.petId'"); + expect(result.error).toContain('connectorConfig.input'); + }); + + it('every missing parameter is named, not only the first', async () => { + const { engine, sent } = makeEngine(); + const result = await run(engine, 'two_missing', 'update', { text: 'hi' }); + expect(result.success).toBe(false); + expect(sent).toEqual([]); + expect(result.error).toContain("missing required 'channel'"); + expect(result.error).toContain("missing required 'ts'"); + }); + + it('a code connector\'s required `channel` is enforced the same way', async () => { + const { engine, sent } = makeEngine(); + const result = await run(engine, 'no_channel', 'post', { text: 'hi' }); + expect(result.success).toBe(false); + expect(sent).toEqual([]); + expect(result.error).toContain("missing required 'channel'"); + }); +}); + +describe('2. a parameter of the wrong type is refused before dispatch', () => { + it('a literal string for an `integer` path parameter names the parameter and both types', async () => { + const { engine, sent, faults } = makeEngine(); + await run(engine, 'bad_literal', 'getPet', { path: { petId: 'seven' } }, { fault: true }); + expect(sent).toEqual([]); + expect(faults[0]?.code).toBe('VALIDATION_ERROR'); + expect(faults[0]?.message).toContain("'path.petId' must be integer, got string"); + }); + + it('an envelope evaluating to the wrong type is judged on its evaluated value', async () => { + const { engine, sent } = makeEngine(); + const result = await run(engine, 'bad_envelope', 'getPet', { path: { petId: 1 }, query: { dialect: 'cel', source: '{"verbose": rec.name}' } }); + expect(result.success).toBe(false); + expect(sent).toEqual([]); + expect(result.error).toContain("'query.verbose' must be boolean, got string"); + }); + + it('an array item of the wrong type names its index', async () => { + const { engine, sent } = makeEngine(); + const result = await run(engine, 'bad_item', 'echo', { text: 'x', tags: ['a', 2] }); + expect(result.success).toBe(false); + expect(sent).toEqual([]); + expect(result.error).toContain("'tags[1]' must be string, got number"); + }); +}); + +describe('3. a valid call dispatches as before', () => { + it('a required value computed by an envelope reaches the handler', async () => { + const { engine, sent } = makeEngine(); + const result = await run(engine, 'ok_envelope', 'getPet', { path: { dialect: 'cel', source: '{"petId": rec.petId}' } }); + expect(result.success, result.error).toBe(true); + expect(sent).toEqual([{ action: 'getPet', input: { path: { petId: 7 } } }]); + }); + + it('OpenAPI 3.0 `nullable` admits null', async () => { + const { engine, sent } = makeEngine(); + const result = await run(engine, 'ok_nullable', 'getPet', { path: { petId: 1 }, query: { tag: null } }); + expect(result.success, result.error).toBe(true); + expect(sent).toHaveLength(1); + }); +}); + +describe('4. only the judged subset refuses', () => { + it('values outside `enum`/`format`/`$ref`/`oneOf`, and keys outside `properties`, still dispatch', async () => { + const { engine, sent } = makeEngine(); + const input = { text: 'x', mode: 'sideways', meta: 42, when: 'not a date', either: true, extra: 'kept' }; + const result = await run(engine, 'ok_unjudged', 'echo', input); + expect(result.success, result.error).toBe(true); + expect(sent).toEqual([{ action: 'echo', input }]); + }); + + it('an action with no `inputSchema` dispatches whatever it is given, an absent input included', async () => { + const { engine, sent } = makeEngine(); + const result = await run(engine, 'ok_free', 'free', undefined); + expect(result.success, result.error).toBe(true); + expect(sent).toEqual([{ action: 'free', input: {} }]); + }); +}); diff --git a/packages/services/service-automation/src/builtin/connector-nodes.ts b/packages/services/service-automation/src/builtin/connector-nodes.ts index 8550f1b6a3a..77ea9e1dbb8 100644 --- a/packages/services/service-automation/src/builtin/connector-nodes.ts +++ b/packages/services/service-automation/src/builtin/connector-nodes.ts @@ -116,6 +116,38 @@ export function registerConnectorNodes(engine: AutomationEngine, ctx: PluginCont // slot's, and nothing is sent upstream. const input = resolveValueSlotMap(engine, cfg.input, variables, 'input', context); + // #27995 — the action's declared `inputSchema` is a contract, not a + // hint: the EVALUATED input is judged against it here, after the + // value slots resolved and before the handler is called, so a call + // missing a required parameter (or carrying one of the wrong type) + // fails this step instead of reaching the remote API, whose answer + // to a malformed call is the remote's to choose. Read from the + // designer-facing descriptor — `inputSchema` is projected from the + // stored def unchanged, and it is the very schema an author picks + // the action by on `GET /connectors`, so what is enforced is what + // was shown. A `runtime` failure, not a guard: the input can come + // from run data, so a `fault` edge may handle it. + const inputSchema = engine + .getConnectorDescriptors() + .find((d) => d.name === cfg.connectorId) + ?.actions.find((a) => a.key === cfg.actionId)?.inputSchema; + const violations: string[] = []; + collectInputSchemaViolations(inputSchema, input, '', violations); + if (violations.length > 0) { + const shown = violations.slice(0, MAX_NAMED_VIOLATIONS); + const more = violations.length - shown.length; + return { + success: false, + code: 'VALIDATION_ERROR', + error: + `connector_action '${node.id}': input for '${cfg.connectorId}.${cfg.actionId}' ` + + `does not match the action's inputSchema — ${shown.join('; ')}` + + (more > 0 ? `; and ${more} more` : '') + + `. Nothing was sent. Fix connectorConfig.input so it satisfies the inputSchema ` + + `GET /api/v1/automation/connectors lists for this action.`, + }; + } + try { const output = await handler(input, handlerCtx); return { @@ -149,3 +181,79 @@ export function registerConnectorNodes(engine: AutomationEngine, ctx: PluginCont ctx.logger.info('[Connector] 1 built-in node executor registered (connector_action)'); } + +/** How many violations one refusal names before it summarises the rest as a count. */ +const MAX_NAMED_VIOLATIONS = 5; + +/** JSON Schema `type` names this check can judge; any other name leaves the value unjudged. */ +const JUDGED_TYPES = new Set(['string', 'number', 'integer', 'boolean', 'object', 'array', 'null']); + +function isPlainRecord(value: unknown): value is Record { + if (value === null || typeof value !== 'object' || Array.isArray(value)) return false; + const proto = Object.getPrototypeOf(value); + return proto === Object.prototype || proto === null; +} + +/** The value's JSON kind, or `undefined` for one with no JSON spelling to judge (a `Date`, a `Map`, …). */ +function jsonKindOf(value: unknown): string | undefined { + if (value === null) return 'null'; + if (Array.isArray(value)) return 'array'; + if (isPlainRecord(value)) return 'object'; + if (typeof value === 'bigint') return 'number'; + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') return typeof value; + return undefined; +} + +function matchesType(type: string, value: unknown, kind: string): boolean { + if (type === 'integer') return typeof value === 'bigint' || (typeof value === 'number' && Number.isInteger(value)); + return type === kind; +} + +/** + * Collect where `value` breaks `schema` (#27995), each as a sentence naming the + * parameter by its dotted path. Judges a deliberate SUBSET of JSON Schema — + * `required`, `type` (with OpenAPI 3.0 `nullable`), and descent through + * `properties` and `items` — because the schemas reaching here are not ours: + * an OpenAPI document's operation (which may keep an unresolved `$ref`), an + * MCP server's `tools/list` entry (any draft), or a code connector's literal. + * A keyword outside the subset (`$ref`, `oneOf`/`anyOf`/`allOf`, `enum`, + * `format`, bounds, `additionalProperties`, …) never refuses a call: the + * remote still judges those, as it did before. A missing piece is the narrow + * direction on purpose — a call this check refuses is one the declared + * schema itself says is wrong under every draft. + */ +function collectInputSchemaViolations(schema: unknown, value: unknown, path: string, out: string[]): void { + if (!isPlainRecord(schema)) return; + const kind = jsonKindOf(value); + if (kind === undefined) return; + const declared = typeof schema.type === 'string' ? [schema.type] : Array.isArray(schema.type) ? schema.type : []; + const types = declared.filter((t): t is string => typeof t === 'string'); + const judgeable = types.length > 0 && types.length === declared.length && types.every((t) => JUDGED_TYPES.has(t)); + if ( + judgeable + && !(kind === 'null' && schema.nullable === true) + && !types.some((t) => matchesType(t, value, kind)) + ) { + out.push(`'${path || 'input'}' must be ${types.join(' or ')}, got ${kind}`); + return; + } + if (kind === 'object') { + const record = value as Record; + if (Array.isArray(schema.required)) { + for (const key of schema.required) { + if (typeof key === 'string' && (!Object.prototype.hasOwnProperty.call(record, key) || record[key] === undefined)) { + out.push(`missing required '${path ? `${path}.${key}` : key}'`); + } + } + } + if (isPlainRecord(schema.properties)) { + for (const [key, sub] of Object.entries(schema.properties)) { + if (Object.prototype.hasOwnProperty.call(record, key) && record[key] !== undefined) { + collectInputSchemaViolations(sub, record[key], path ? `${path}.${key}` : key, out); + } + } + } + } else if (kind === 'array' && isPlainRecord(schema.items)) { + (value as unknown[]).forEach((item, i) => collectInputSchemaViolations(schema.items, item, `${path}[${i}]`, out)); + } +} From c75e9aa9af8d72b5ab01c12ac74d5782f60fe7e7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 08:35:56 +0000 Subject: [PATCH 2/4] chore(changeset): connector_action inputSchema check before dispatch Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L6bhgX3dtTQwah2MjCjf5i --- .../27995-connector-action-input-schema.md | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 .changeset/27995-connector-action-input-schema.md diff --git a/.changeset/27995-connector-action-input-schema.md b/.changeset/27995-connector-action-input-schema.md new file mode 100644 index 00000000000..d36051574a6 --- /dev/null +++ b/.changeset/27995-connector-action-input-schema.md @@ -0,0 +1,25 @@ +--- +'@objectstack/service-automation': patch +--- + +fix(service-automation)!: a `connector_action` call whose evaluated input breaks the action's declared `inputSchema` fails the step before dispatch, naming each missing or mistyped parameter, and nothing is sent (#27995) + +Clause-②: no (narrowing) +Metadata-protocol: none + + + +**BREAKING** (an accept-set narrowing at run time), shipped as `patch`: nothing a metadata author writes changes shape (`Metadata-protocol: none`), so the level follows the kind, and the banner and the ADR-0087 disposition stay. + +**FROM.** The `connector_action` node evaluated `connectorConfig.input` and handed it to the connector's handler unchecked. The action's declared `inputSchema` (built from the OpenAPI operation by `connector-openapi`, passed through from `tools/list` by `connector-mcp`, or written by a code connector such as `connector-slack`) was advisory: a call to an OpenAPI `getPet` with `input: {}` reached the remote URL although `petId` is declared `required`, and what came back was the remote's choice. + +**TO.** After the input's value slots are evaluated and before the handler is called, the evaluated input is judged against the action's `inputSchema`: + +- Judged: `required` at every object level reached, `type` (a type list, OpenAPI 3.0 `nullable: true`, and `integer` as a whole number), and descent through `properties` and array `items`. +- Not judged, so never refused here: `$ref`, `oneOf` / `anyOf` / `allOf`, `enum`, `format`, bounds, `pattern`, `additionalProperties`, and any `type` name outside the JSON Schema seven. The remote still judges those, as before. An action that declares no `inputSchema` dispatches whatever it is given. +- A failing call fails the step with `code: 'VALIDATION_ERROR'` on `$error`. The error names each problem by its dotted path (`missing required 'path.petId'`, `'query.verbose' must be boolean, got string`, `'tags[1]' must be string, got number`), says nothing was sent, and points at `GET /api/v1/automation/connectors`, which lists the action's schema. The handler is never called. +- The failure is a `runtime` one, not a guard refusal: the input can come from run data, so a `fault` edge or a `try_catch` region can handle it. + +**Who sees it.** A flow or agent whose `connector_action` input omits a parameter the action declares `required`, or sends one of a type the action does not declare: that step now fails before the call instead of the remote answering it. For example, an OpenAPI path parameter declared `type: integer` sent as the string `"7"` is refused, where the provider used to interpolate it into the path. + +**Fix.** Make `connectorConfig.input` satisfy the action's `inputSchema`: supply every required parameter, and send each one as the declared type (`{ path: { petId: 7 } }`, not `{ path: { petId: '7' } }`). A value computed by a CEL envelope is judged on what it evaluates to. From b2149dc0aeca8d76df2ba3cac3df973d003cba4e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 10:09:19 +0000 Subject: [PATCH 3/4] fix(automation): read null on an optional connector input as absent, prescribe the cast for text where a number is declared Contract review round 1 on the connector_action inputSchema check: null on an optional property no longer refuses (a provider that serializes the section drops it); a refusal of text where an integer, number or boolean is declared prescribes int()/double()/bool(); a missing required section also names the parameters it requires. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L6bhgX3dtTQwah2MjCjf5i --- .../27995-connector-action-input-schema.md | 8 +-- .../builtin/connector-input-schema.test.ts | 37 +++++++++- .../src/builtin/connector-nodes.ts | 67 ++++++++++++++----- 3 files changed, 88 insertions(+), 24 deletions(-) diff --git a/.changeset/27995-connector-action-input-schema.md b/.changeset/27995-connector-action-input-schema.md index d36051574a6..098464ab268 100644 --- a/.changeset/27995-connector-action-input-schema.md +++ b/.changeset/27995-connector-action-input-schema.md @@ -15,11 +15,11 @@ Metadata-protocol: none **TO.** After the input's value slots are evaluated and before the handler is called, the evaluated input is judged against the action's `inputSchema`: -- Judged: `required` at every object level reached, `type` (a type list, OpenAPI 3.0 `nullable: true`, and `integer` as a whole number), and descent through `properties` and array `items`. +- Judged: `required` at every object level reached, `type` (a type list, OpenAPI 3.0 `nullable: true`, and `integer` as a whole number), and descent through `properties` and array `items`. `null` on an optional property counts as absent, as a provider that serializes a section drops it; `null` on a required property is judged against its type. Text is never converted to a declared number or boolean. - Not judged, so never refused here: `$ref`, `oneOf` / `anyOf` / `allOf`, `enum`, `format`, bounds, `pattern`, `additionalProperties`, and any `type` name outside the JSON Schema seven. The remote still judges those, as before. An action that declares no `inputSchema` dispatches whatever it is given. -- A failing call fails the step with `code: 'VALIDATION_ERROR'` on `$error`. The error names each problem by its dotted path (`missing required 'path.petId'`, `'query.verbose' must be boolean, got string`, `'tags[1]' must be string, got number`), says nothing was sent, and points at `GET /api/v1/automation/connectors`, which lists the action's schema. The handler is never called. +- A failing call fails the step with `code: 'VALIDATION_ERROR'` on `$error`. The error names each problem by its dotted path (`missing required 'path' (with 'path.petId')`, `'query.verbose' must be boolean, got string`, `'tags[1]' must be string, got number`), says nothing was sent, and points at `GET /api/v1/automation/connectors`, which lists the action's schema. The handler is never called. - The failure is a `runtime` one, not a guard refusal: the input can come from run data, so a `fault` edge or a `try_catch` region can handle it. -**Who sees it.** A flow or agent whose `connector_action` input omits a parameter the action declares `required`, or sends one of a type the action does not declare: that step now fails before the call instead of the remote answering it. For example, an OpenAPI path parameter declared `type: integer` sent as the string `"7"` is refused, where the provider used to interpolate it into the path. +**Who sees it.** A flow or agent whose `connector_action` input omits a parameter the action declares `required`, or sends one of a type the action does not declare: that step now fails before the call instead of the remote answering it. For example, an OpenAPI path parameter declared `type: integer` sent as the string `"7"` (a text field holding an id is the usual source) is refused, where the provider used to interpolate it into the path; the error then says to cast it. -**Fix.** Make `connectorConfig.input` satisfy the action's `inputSchema`: supply every required parameter, and send each one as the declared type (`{ path: { petId: 7 } }`, not `{ path: { petId: '7' } }`). A value computed by a CEL envelope is judged on what it evaluates to. +**Fix.** Make `connectorConfig.input` satisfy the action's `inputSchema`: supply every required parameter, and send each one as the declared type (`{ path: { petId: 7 } }`, not `{ path: { petId: '7' } }`). A value computed by a CEL envelope is judged on what it evaluates to, so cast text to the declared type there: `{ path: { dialect: 'cel', source: '{"petId": int(record.vendor_id)}' } }`, with `double()` for a number and `bool()` for a boolean. diff --git a/packages/services/service-automation/src/builtin/connector-input-schema.test.ts b/packages/services/service-automation/src/builtin/connector-input-schema.test.ts index 95900788aaf..faa89752aa2 100644 --- a/packages/services/service-automation/src/builtin/connector-input-schema.test.ts +++ b/packages/services/service-automation/src/builtin/connector-input-schema.test.ts @@ -16,6 +16,10 @@ * 2. **Wrong type → refused, nothing sent.** The error names the parameter, * the declared type and the type it got. The EVALUATED input is judged: a * CEL envelope computing the wrong type is refused like a literal one. + * Text is not coerced to a declared number or boolean; the error says to + * cast it with `int()`/`double()`/`bool()`. `null` on a required + * parameter is refused (it would reach the wire as a placeholder); `null` + * on an optional one is read as absent, since a provider drops it. * 3. **A valid call dispatches as before**, including one whose required * value is computed by an envelope. * 4. **Only the judged subset refuses.** `required`, `type` (with OpenAPI 3.0 @@ -143,7 +147,7 @@ function flowWith(name: string, actionId: string, input: Record async function run(engine: AutomationEngine, name: string, actionId: string, input: Record | undefined, opts: { fault?: boolean } = {}) { engine.registerFlow(name, flowWith(name, actionId, input, opts)); - return engine.execute(name, { params: { rec: { id: 'cus_42', petId: 7, name: 'Rex' } } } as any); + return engine.execute(name, { params: { rec: { id: 'cus_42', petId: 7, idText: '7', name: 'Rex', missingFlag: null } } } as any); } describe('1. a missing required parameter is refused before dispatch', () => { @@ -153,7 +157,7 @@ describe('1. a missing required parameter is refused before dispatch', () => { expect(sent).toEqual([]); expect(faults).toHaveLength(1); expect(faults[0]?.code).toBe('VALIDATION_ERROR'); - expect(faults[0]?.message).toContain("missing required 'path'"); + expect(faults[0]?.message).toContain("missing required 'path' (with 'path.petId')"); expect(result.success).toBe(true); }); @@ -194,6 +198,28 @@ describe('2. a parameter of the wrong type is refused before dispatch', () => { expect(faults[0]?.message).toContain("'path.petId' must be integer, got string"); }); + it('text for an `integer` says to cast it, and an `int()` cast dispatches', async () => { + const { engine, sent } = makeEngine(); + const refused = await run(engine, 'text_id', 'getPet', { path: { dialect: 'cel', source: '{"petId": rec.idText}' } }); + expect(refused.success).toBe(false); + expect(sent).toEqual([]); + expect(refused.error).toContain("'path.petId' must be integer, got string"); + expect(refused.error).toContain('int()'); + const cast = await run(engine, 'cast_id', 'getPet', { path: { dialect: 'cel', source: '{"petId": int(rec.idText)}' } }); + expect(cast.success, cast.error).toBe(true); + expect(sent).toHaveLength(1); + expect(Number((sent[0].input as any).path.petId)).toBe(7); + }); + + it('`null` on a REQUIRED parameter is still refused: it would be sent as a placeholder', async () => { + const { engine, sent } = makeEngine(); + const result = await run(engine, 'null_required', 'getPet', { path: { petId: null } }); + expect(result.success).toBe(false); + expect(sent).toEqual([]); + expect(result.error).toContain("'path.petId' must be integer, got null"); + expect(result.error).not.toContain('int()'); + }); + it('an envelope evaluating to the wrong type is judged on its evaluated value', async () => { const { engine, sent } = makeEngine(); const result = await run(engine, 'bad_envelope', 'getPet', { path: { petId: 1 }, query: { dialect: 'cel', source: '{"verbose": rec.name}' } }); @@ -219,6 +245,13 @@ describe('3. a valid call dispatches as before', () => { expect(sent).toEqual([{ action: 'getPet', input: { path: { petId: 7 } } }]); }); + it('`null` on an OPTIONAL parameter is read as absent, whatever its declared type', async () => { + const { engine, sent } = makeEngine(); + const result = await run(engine, 'ok_null_optional', 'getPet', { path: { petId: 1 }, query: { dialect: 'cel', source: '{"verbose": rec.missingFlag}' } }); + expect(result.success, result.error).toBe(true); + expect(sent).toHaveLength(1); + }); + it('OpenAPI 3.0 `nullable` admits null', async () => { const { engine, sent } = makeEngine(); const result = await run(engine, 'ok_nullable', 'getPet', { path: { petId: 1 }, query: { tag: null } }); diff --git a/packages/services/service-automation/src/builtin/connector-nodes.ts b/packages/services/service-automation/src/builtin/connector-nodes.ts index 77ea9e1dbb8..79be2986da8 100644 --- a/packages/services/service-automation/src/builtin/connector-nodes.ts +++ b/packages/services/service-automation/src/builtin/connector-nodes.ts @@ -131,11 +131,11 @@ export function registerConnectorNodes(engine: AutomationEngine, ctx: PluginCont .getConnectorDescriptors() .find((d) => d.name === cfg.connectorId) ?.actions.find((a) => a.key === cfg.actionId)?.inputSchema; - const violations: string[] = []; + const violations: InputSchemaViolations = { lines: [], textForScalar: false }; collectInputSchemaViolations(inputSchema, input, '', violations); - if (violations.length > 0) { - const shown = violations.slice(0, MAX_NAMED_VIOLATIONS); - const more = violations.length - shown.length; + if (violations.lines.length > 0) { + const shown = violations.lines.slice(0, MAX_NAMED_VIOLATIONS); + const more = violations.lines.length - shown.length; return { success: false, code: 'VALIDATION_ERROR', @@ -144,7 +144,14 @@ export function registerConnectorNodes(engine: AutomationEngine, ctx: PluginCont + `does not match the action's inputSchema — ${shown.join('; ')}` + (more > 0 ? `; and ${more} more` : '') + `. Nothing was sent. Fix connectorConfig.input so it satisfies the inputSchema ` - + `GET /api/v1/automation/connectors lists for this action.`, + + `GET /api/v1/automation/connectors lists for this action.` + // Text is never converted here: the executor cannot tell + // a provider that serializes a section to strings from + // one that sends JSON, so the cast is the author's. + + (violations.textForScalar + ? ` A text value for an integer, number or boolean parameter is not converted: ` + + `cast it in a CEL envelope with int(), double() or bool().` + : ''), }; } @@ -182,6 +189,12 @@ export function registerConnectorNodes(engine: AutomationEngine, ctx: PluginCont ctx.logger.info('[Connector] 1 built-in node executor registered (connector_action)'); } +/** What one judgment found: each violation as a sentence, and whether any was text where a number or boolean is declared. */ +interface InputSchemaViolations { + lines: string[]; + textForScalar: boolean; +} + /** How many violations one refusal names before it summarises the rest as a count. */ const MAX_NAMED_VIOLATIONS = 5; @@ -220,9 +233,11 @@ function matchesType(type: string, value: unknown, kind: string): boolean { * `format`, bounds, `additionalProperties`, …) never refuses a call: the * remote still judges those, as it did before. A missing piece is the narrow * direction on purpose — a call this check refuses is one the declared - * schema itself says is wrong under every draft. + * schema itself says is wrong under every draft. `null` on an optional + * property counts as absent (see the property loop), and text is never + * coerced to the number or boolean a parameter declares. */ -function collectInputSchemaViolations(schema: unknown, value: unknown, path: string, out: string[]): void { +function collectInputSchemaViolations(schema: unknown, value: unknown, path: string, out: InputSchemaViolations): void { if (!isPlainRecord(schema)) return; const kind = jsonKindOf(value); if (kind === undefined) return; @@ -234,23 +249,39 @@ function collectInputSchemaViolations(schema: unknown, value: unknown, path: str && !(kind === 'null' && schema.nullable === true) && !types.some((t) => matchesType(t, value, kind)) ) { - out.push(`'${path || 'input'}' must be ${types.join(' or ')}, got ${kind}`); + out.lines.push(`'${path || 'input'}' must be ${types.join(' or ')}, got ${kind}`); + if (kind === 'string' && types.some((t) => t === 'integer' || t === 'number' || t === 'boolean')) { + out.textForScalar = true; + } return; } if (kind === 'object') { const record = value as Record; - if (Array.isArray(schema.required)) { - for (const key of schema.required) { - if (typeof key === 'string' && (!Object.prototype.hasOwnProperty.call(record, key) || record[key] === undefined)) { - out.push(`missing required '${path ? `${path}.${key}` : key}'`); - } + const properties = isPlainRecord(schema.properties) ? schema.properties : undefined; + const required = new Set( + Array.isArray(schema.required) ? schema.required.filter((k): k is string => typeof k === 'string') : [], + ); + for (const key of required) { + if (!Object.prototype.hasOwnProperty.call(record, key) || record[key] === undefined) { + const at = path ? `${path}.${key}` : key; + // A missing required SECTION names the parameters it requires + // too, so the first refusal already carries the parameter. + const sub = properties?.[key]; + const inner = isPlainRecord(sub) && Array.isArray(sub.required) + ? sub.required.filter((k): k is string => typeof k === 'string').map((k) => `'${at}.${k}'`) + : []; + out.lines.push(`missing required '${at}'${inner.length > 0 ? ` (with ${inner.join(', ')})` : ''}`); } } - if (isPlainRecord(schema.properties)) { - for (const [key, sub] of Object.entries(schema.properties)) { - if (Object.prototype.hasOwnProperty.call(record, key) && record[key] !== undefined) { - collectInputSchemaViolations(sub, record[key], path ? `${path}.${key}` : key, out); - } + if (properties) { + for (const [key, sub] of Object.entries(properties)) { + if (!Object.prototype.hasOwnProperty.call(record, key) || record[key] === undefined) continue; + // `null` on an OPTIONAL property is read as absent: a provider + // that serializes a section drops it, so the wire equals + // omission, which the schema permits. On a REQUIRED property + // it is still judged — it would be sent as a placeholder. + if (record[key] === null && !required.has(key)) continue; + collectInputSchemaViolations(sub, record[key], path ? `${path}.${key}` : key, out); } } } else if (kind === 'array' && isPlainRecord(schema.items)) { From 67857097902248135451130551725a26e0a33ce0 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 10:13:34 +0000 Subject: [PATCH 4/4] fix(connector-openapi): leave ignored and auth-supplied parameters out of an action's inputSchema The connector_action executor now refuses a call whose input breaks the action's inputSchema, so buildInputSchema stops demanding the Accept, Content-Type and Authorization header parameters (ignored per OAS 3.0.3) and the header or query parameter the connector's api-key auth fills. Pinned through a real boot on buildInputSchema's own output. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L6bhgX3dtTQwah2MjCjf5i --- .../27995-connector-action-input-schema.md | 3 + .../src/input-schema-dispatch.test.ts | 138 ++++++++++++++++++ .../src/openapi-connector.ts | 21 ++- 3 files changed, 160 insertions(+), 2 deletions(-) create mode 100644 packages/connectors/connector-openapi/src/input-schema-dispatch.test.ts diff --git a/.changeset/27995-connector-action-input-schema.md b/.changeset/27995-connector-action-input-schema.md index 098464ab268..ceaf98b7aea 100644 --- a/.changeset/27995-connector-action-input-schema.md +++ b/.changeset/27995-connector-action-input-schema.md @@ -1,5 +1,6 @@ --- '@objectstack/service-automation': patch +'@objectstack/connector-openapi': patch --- fix(service-automation)!: a `connector_action` call whose evaluated input breaks the action's declared `inputSchema` fails the step before dispatch, naming each missing or mistyped parameter, and nothing is sent (#27995) @@ -20,6 +21,8 @@ Metadata-protocol: none - A failing call fails the step with `code: 'VALIDATION_ERROR'` on `$error`. The error names each problem by its dotted path (`missing required 'path' (with 'path.petId')`, `'query.verbose' must be boolean, got string`, `'tags[1]' must be string, got number`), says nothing was sent, and points at `GET /api/v1/automation/connectors`, which lists the action's schema. The handler is never called. - The failure is a `runtime` one, not a guard refusal: the input can come from run data, so a `fault` edge or a `try_catch` region can handle it. +**`@objectstack/connector-openapi`.** So that the check never demands what a caller cannot supply, an OpenAPI action's `inputSchema` no longer lists the `Accept`, `Content-Type` and `Authorization` header parameters (OAS 3.0.3 §4.7.12.1 says they SHALL be ignored; the transport and `auth` set them), nor the header or query parameter the connector's `api-key` `auth` fills (`headerName`, default `X-API-Key`, or `paramName`). The credential reaches the request from `auth` as before; a flow never writes it into `connectorConfig.input`. `GET /api/v1/automation/connectors` lists the action without them. + **Who sees it.** A flow or agent whose `connector_action` input omits a parameter the action declares `required`, or sends one of a type the action does not declare: that step now fails before the call instead of the remote answering it. For example, an OpenAPI path parameter declared `type: integer` sent as the string `"7"` (a text field holding an id is the usual source) is refused, where the provider used to interpolate it into the path; the error then says to cast it. **Fix.** Make `connectorConfig.input` satisfy the action's `inputSchema`: supply every required parameter, and send each one as the declared type (`{ path: { petId: 7 } }`, not `{ path: { petId: '7' } }`). A value computed by a CEL envelope is judged on what it evaluates to, so cast text to the declared type there: `{ path: { dialect: 'cel', source: '{"petId": int(record.vendor_id)}' } }`, with `double()` for a number and `bool()` for a boolean. diff --git a/packages/connectors/connector-openapi/src/input-schema-dispatch.test.ts b/packages/connectors/connector-openapi/src/input-schema-dispatch.test.ts new file mode 100644 index 00000000000..9ae3a59f506 --- /dev/null +++ b/packages/connectors/connector-openapi/src/input-schema-dispatch.test.ts @@ -0,0 +1,138 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * **The `inputSchema` this package builds is the one the `connector_action` + * executor judges before dispatch** (#27995) — pinned on `buildInputSchema`'s + * real output, through a real boot (LiteKernel + AutomationServicePlugin + + * ConnectorOpenApiPlugin), with a recording `fetchImpl` standing in for the + * remote: + * + * 1. `getPet` with `input: {}` fails the step and nothing is sent. Before the + * check it reached the remote as the literal `/pets/%7BpetId%7D`. + * 2. `null` on an optional query parameter is read as absent: the call is + * sent, without that parameter, as omitting it would have been. + * 3. A header or query parameter the connector's `auth` supplies, and the + * `Authorization` header OAS 3.0.3 §4.7.12.1 says is ignored, are not + * demanded of the input although the document declares them required: + * the credential reaches the wire from `auth`, never from the flow. + */ + +import { describe, expect, it } from 'vitest'; +import { LiteKernel } from '@objectstack/core'; +import { AutomationServicePlugin, type AutomationEngine } from '@objectstack/service-automation'; +import { ConnectorOpenApiPlugin } from './connector-openapi-plugin.js'; +import type { RestAuth } from './openapi-connector.js'; + +const document = { + openapi: '3.0.3', + info: { title: 'Petstore', version: '1.0.0' }, + servers: [{ url: 'https://petstore.example.com' }], + paths: { + '/pets/{petId}': { + get: { + operationId: 'getPet', + parameters: [ + { name: 'petId', in: 'path', required: true, schema: { type: 'integer' } }, + { name: 'tag', in: 'query', schema: { type: 'string' } }, + { name: 'api_key', in: 'query', required: true, schema: { type: 'string' } }, + { name: 'X-Api-Key', in: 'header', required: true, schema: { type: 'string' } }, + { name: 'Authorization', in: 'header', required: true, schema: { type: 'string' } }, + ], + responses: { '200': { description: 'ok' } }, + }, + }, + }, +}; + +type Sent = { url: string; headers: Record }; + +/** Boot a kernel with one hand-wired OpenAPI connector `pets` over {@link document}, recording every request. */ +async function boot(auth: RestAuth): Promise<{ engine: AutomationEngine; sent: Sent[]; kernel: LiteKernel }> { + const sent: Sent[] = []; + const fetchImpl = (async (url: string, init: { headers?: Record }) => { + sent.push({ url, headers: { ...init?.headers } }); + return { status: 200, ok: true, headers: { get: () => 'application/json' }, json: async () => ({}), text: async () => '{}' }; + }) as unknown as typeof fetch; + const kernel = new LiteKernel({ logger: { level: 'silent' } } as never); + kernel.use(new AutomationServicePlugin()); + kernel.use({ + name: 'test.connector-metadata', + type: 'standard', + version: '1.0.0', + dependencies: ['com.objectstack.service-automation'], + async init(c: { registerService(name: string, service: unknown): void }) { + c.registerService('objectql', { registry: { listItems: () => [] } }); + }, + async start() {}, + } as never); + kernel.use(new ConnectorOpenApiPlugin({ document, name: 'pets', auth, fetchImpl } as never)); + await kernel.bootstrap(); + return { engine: kernel.getService('automation'), sent, kernel }; +} + +async function callGetPet(engine: AutomationEngine, name: string, input: Record) { + engine.registerFlow(name, { + name, label: name, type: 'autolaunched', + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'call', type: 'connector_action', label: 'Call', connectorConfig: { connectorId: 'pets', actionId: 'getPet', input } }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [{ id: 'e1', source: 'start', target: 'call' }, { id: 'e2', source: 'call', target: 'end' }], + } as never); + return engine.execute(name, {} as never); +} + +describe('connector-openapi inputSchema at the connector_action door (#27995)', () => { + it('1. getPet with input {} is refused naming the path parameter, and nothing is sent', async () => { + const { engine, sent, kernel } = await boot({ type: 'api-key', key: 'k-123', headerName: 'X-Api-Key' }); + try { + const result = await callGetPet(engine, 'empty', {}); + expect(result.success).toBe(false); + expect(result.error).toContain("missing required 'path' (with 'path.petId')"); + expect(sent).toEqual([]); + } finally { + await kernel.shutdown().catch(() => {}); + } + }); + + it('2. null on an optional query parameter is sent as an omission', async () => { + const { engine, sent, kernel } = await boot({ type: 'api-key', key: 'k-123', headerName: 'X-Api-Key' }); + try { + const result = await callGetPet(engine, 'null_tag', { path: { petId: 7 }, query: { tag: null, api_key: 'q' } }); + expect(result.success, result.error).toBe(true); + expect(sent).toHaveLength(1); + expect(new URL(sent[0].url).pathname).toBe('/pets/7'); + expect(new URL(sent[0].url).searchParams.has('tag')).toBe(false); + } finally { + await kernel.shutdown().catch(() => {}); + } + }); + + it('3a. an api-key HEADER from auth, and Authorization, are not demanded of the input', async () => { + const { engine, sent, kernel } = await boot({ type: 'api-key', key: 'k-123', headerName: 'X-Api-Key' }); + try { + const schema = engine.getConnectorDescriptors().find((d) => d.name === 'pets')?.actions[0]?.inputSchema as any; + expect(Object.keys(schema.properties)).not.toContain('header'); + const result = await callGetPet(engine, 'auth_header', { path: { petId: 7 }, query: { api_key: 'q' } }); + expect(result.success, result.error).toBe(true); + expect(sent[0].headers['X-Api-Key']).toBe('k-123'); + } finally { + await kernel.shutdown().catch(() => {}); + } + }); + + it('3b. an api-key QUERY parameter from auth is not demanded of the input', async () => { + const { engine, sent, kernel } = await boot({ type: 'api-key', key: 'k-456', paramName: 'api_key' }); + try { + const schema = engine.getConnectorDescriptors().find((d) => d.name === 'pets')?.actions[0]?.inputSchema as any; + expect(schema.properties.query.properties).not.toHaveProperty('api_key'); + expect(schema.properties.header.required).toEqual(['X-Api-Key']); + const result = await callGetPet(engine, 'auth_query', { path: { petId: 7 }, header: { 'X-Api-Key': 'h' } }); + expect(result.success, result.error).toBe(true); + expect(new URL(sent[0].url).searchParams.get('api_key')).toBe('k-456'); + } finally { + await kernel.shutdown().catch(() => {}); + } + }); +}); diff --git a/packages/connectors/connector-openapi/src/openapi-connector.ts b/packages/connectors/connector-openapi/src/openapi-connector.ts index 387838e0831..ba7fffc1c72 100644 --- a/packages/connectors/connector-openapi/src/openapi-connector.ts +++ b/packages/connectors/connector-openapi/src/openapi-connector.ts @@ -214,7 +214,7 @@ export function createOpenApiConnector(config: OpenApiConnectorConfig): OpenApiC key, label: op.summary ?? titleize(key), description: op.description, - inputSchema: buildInputSchema(op), + inputSchema: buildInputSchema(op, auth), outputSchema: buildOutputSchema(op), }); @@ -318,21 +318,38 @@ function toInfo(op: Op): OperationInfo { }; } +/** + * Header parameters OAS 3.0.3 §4.7.12.1 says SHALL be ignored, compared + * lower-cased: the transport sets `Content-Type`, and `applyAuth` sets + * `Authorization`, so a value the input supplied could never take effect. + */ +const IGNORED_HEADER_PARAMETERS = new Set(['accept', 'content-type', 'authorization']); + /** * Assemble the action inputSchema from an operation's parameters + requestBody. * Produces { type: 'object', properties: { path, query, header, body }, required } * where only non-empty sections are emitted. + * + * #27995 — the `connector_action` executor now refuses a call whose input + * breaks this schema, so it must not demand what the caller cannot supply: + * the ignored header parameters above, and the header or query parameter + * the connector's own `auth` fills (the credential never belongs in a + * flow's `connectorConfig.input`), are left out of it. */ -function buildInputSchema(op: OpenApiOperation): JsonSchema | undefined { +function buildInputSchema(op: OpenApiOperation, auth: RestAuth): JsonSchema | undefined { const sections: Record<'path' | 'query' | 'header', { props: Record; required: string[] }> = { path: { props: {}, required: [] }, query: { props: {}, required: [] }, header: { props: {}, required: [] }, }; + const authHeader = auth.type === 'api-key' && !auth.paramName ? (auth.headerName ?? 'X-API-Key').toLowerCase() : undefined; + const authQuery = auth.type === 'api-key' ? auth.paramName : undefined; for (const p of op.parameters ?? []) { if (!p || typeof p !== 'object' || '$ref' in p) continue; if (p.in !== 'path' && p.in !== 'query' && p.in !== 'header') continue; + if (p.in === 'header' && (IGNORED_HEADER_PARAMETERS.has(p.name.toLowerCase()) || p.name.toLowerCase() === authHeader)) continue; + if (p.in === 'query' && authQuery !== undefined && p.name === authQuery) continue; const sec = sections[p.in]; sec.props[p.name] = p.schema ?? (p.description ? { type: 'string', description: p.description } : { type: 'string' }); if (p.required) sec.required.push(p.name);