Skip to content
28 changes: 28 additions & 0 deletions .changeset/27995-connector-action-input-schema.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
---
'@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)

Clause-②: no (narrowing)
Metadata-protocol: none

<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped, and no export, type or error code is removed or renamed: `ConnectorActionSchema.inputSchema` and `connectorConfig.input` keep their shapes, and the refusal reuses the existing `VALIDATION_ERROR` code of the platform vocabulary. What narrows is the run-time dispatch of one node: a call the action's own declared schema says is wrong is no longer sent upstream. Nothing stored is rewritten or re-judged at rest, so there is nothing for `objectstack migrate meta` to rewrite and no tombstone. -->

**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`. `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' (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.
Original file line number Diff line number Diff line change
@@ -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<string, string> };

/** 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<string, string> }) => {
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<AutomationEngine>('automation'), sent, kernel };
}

async function callGetPet(engine: AutomationEngine, name: string, input: Record<string, unknown>) {
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(() => {});
}
});
});
21 changes: 19 additions & 2 deletions packages/connectors/connector-openapi/src/openapi-connector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
});

Expand Down Expand Up @@ -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<string, JsonSchema>; 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);
Expand Down
Loading
Loading