History

Surface OpenAPI argument errors to the caller instead of redacting them (#1243)

* Surface pre-flight OpenAPI invocation errors as expected tool failures Pre-flight OpenApiInvocationError raises (missing/unresolved path params, missing or malformed request body) happen before any HTTP request and their messages are generated locally from the spec, but the dispatcher only classified ToolNotFoundError, ToolBlockedError, and validation-issue ToolInvocationError as expected failures. These caller-argument errors fell into the defect-redaction branch, so the model saw an opaque "Internal tool error [id]" with no hint that the fix is renaming or adding an argument. Classify them as invalid_tool_arguments with the real message, keyed on the pre-flight shape: statusCode None and no cause. Transport failures (None + cause carrying the raw client error) and post-response failures (Some status) keep flowing through redaction. * Validate OpenAPI args before pausing for approval Approval elicitation ran before any argument validation, so a call with a missing required path parameter or request body paused for user approval and then failed on the exact pre-flight check the approval could never satisfy (observed with queryCreate: approved, then "Missing required request body"). Add an optional validateToolArgs plugin hook that core calls only when the call is about to pause for approval (same predicate enforceApproval applies, now shared as approvalRequired). The openapi plugin implements it by running the request-building pass extracted from invoke (buildRequest: path params, request body, base64 payloads) and discarding the built request, so the validation semantics are exactly the ones invocation applies rather than a parallel re-implementation. Legacy rows without a persisted binding skip validation instead of re-parsing the spec.

Rhys Sullivan committed 4 months ago

Commit e39dbcf1 parent fd16b2b10 files · +312 −18Browse files

Unified · Split

10 files
  1. packages/core/execution/src/tool-invoker.leak.test.ts +142 −1
  2. packages/core/execution/src/tool-invoker.ts +32 −1
  3. packages/core/sdk/src/executor.ts +22 −2
  4. packages/core/sdk/src/index.ts +1 −0
  5. packages/core/sdk/src/plugin.ts +19 −0
  6. packages/plugins/openapi/src/sdk/backing.ts +22 −1
  7. packages/plugins/openapi/src/sdk/index.ts +2 −1
  8. packages/plugins/openapi/src/sdk/invoke.ts +34 −12
  9. packages/plugins/openapi/src/sdk/plugin.test.ts +34 −0
  10. packages/plugins/openapi/src/sdk/plugin.ts +4 −0
packages/core/execution/src/tool-invoker.leak.test.ts+142−1
@@ -1,5 +1,5 @@
1import { describe, expect, it } from "@effect/vitest";1import { describe, expect, it } from "@effect/vitest";
2import { Data, Effect, Schema } from "effect";2import { Data, Effect, Option, Schema } from "effect";
3 3
4import { ElicitationResponse, createExecutor, definePlugin } from "@executor-js/sdk";4import { ElicitationResponse, createExecutor, definePlugin } from "@executor-js/sdk";
5import { makeTestConfig } from "@executor-js/sdk/testing";5import { makeTestConfig } from "@executor-js/sdk/testing";
@@ -20,6 +20,17 @@ class FakePluginInvocationError extends Data.TaggedError("PluginInvocationError" · 14 unmodified lines
20 readonly cause: unknown;20 readonly cause: unknown;
21}> {}21}> {}
22 22
23// Mirrors the openapi plugin's OpenApiInvocationError shape (the dispatcher
24// matches it structurally by tag, not by class identity, since this package
25// does not depend on the plugin). Pre-flight raises carry statusCode: None
26// and no cause and must surface verbatim; transport/post-response failures
27// must stay opaque.
28class FakeOpenApiInvocationError extends Data.TaggedError("OpenApiInvocationError")<{
29 readonly message: string;
30 readonly statusCode: Option.Option<number>;
31 readonly cause?: unknown;
32}> {}
33
23const leakyPlugin = definePlugin(() => ({34const leakyPlugin = definePlugin(() => ({
24 id: "leaky-test" as const,35 id: "leaky-test" as const,
25 storage: () => ({}),36 storage: () => ({}),
@@ -66,6 +77,59 @@ const leakyPlugin = definePlugin(() => ({ · 40 unmodified lines
66 ),77 ),
67 ),78 ),
68 },79 },
80 {
81 name: "failsPreflightPathParam",
82 description: "",
83 inputSchema: EmptyInputSchema,
84 handler: () =>
85 Effect.fail(
86 new FakeOpenApiInvocationError({
87 message: "Missing required path parameter: datasourceId",
88 statusCode: Option.none(),
89 }),
90 ),
91 },
92 {
93 name: "failsPreflightBody",
94 description: "",
95 inputSchema: EmptyInputSchema,
96 handler: () =>
97 Effect.fail(
98 new FakeOpenApiInvocationError({
99 message: "Missing required request body",
100 statusCode: Option.none(),
101 }),
102 ),
103 },
104 {
105 name: "failsOpenApiTransport",
106 description: "",
107 inputSchema: EmptyInputSchema,
108 handler: () =>
109 Effect.fail(
110 new FakeOpenApiInvocationError({
111 message: "HTTP request failed",
112 statusCode: Option.none(),
113 cause: {
114 _tag: "RequestError",
115 request: { url: "https://internal.service.local/v1?token=tok-456" },
116 },
117 }),
118 ),
119 },
120 {
121 name: "failsOpenApiPostResponse",
122 description: "",
123 inputSchema: EmptyInputSchema,
124 handler: () =>
125 Effect.fail(
126 new FakeOpenApiInvocationError({
127 message: "Failed to read response body",
128 statusCode: Option.some(502),
129 cause: { _tag: "ResponseError", note: "internal parser detail" },
130 }),
131 ),
132 },
69 ],133 ],
70 },134 },
71 ],135 ],
@@ -111,3 +175,80 @@ describe("internal-error leak audit (opaque defects)", () => { · 39 unmodified lines
111 }),175 }),
112 );176 );
113});177});
178
179describe("openapi pre-flight invocation errors (expected failures)", () => {
180 it.effect("missing path parameter surfaces verbatim as invalid_tool_arguments", () =>
181 Effect.gen(function* () {
182 const executor = yield* createExecutor(makeTestConfig({ plugins: [leakyPlugin()] as const }));
183 const invoker = makeExecutorToolInvoker(executor, {
184 invokeOptions: { onElicitation: acceptAll },
185 });
186
187 const result = yield* invoker.invoke({ path: "leaky.failsPreflightPathParam", args: {} });
188 expect(result).toEqual({
189 ok: false,
190 error: {
191 code: "invalid_tool_arguments",
192 message: "Missing required path parameter: datasourceId",
193 },
194 });
195 }),
196 );
197
198 it.effect("missing request body surfaces verbatim as invalid_tool_arguments", () =>
199 Effect.gen(function* () {
200 const executor = yield* createExecutor(makeTestConfig({ plugins: [leakyPlugin()] as const }));
201 const invoker = makeExecutorToolInvoker(executor, {
202 invokeOptions: { onElicitation: acceptAll },
203 });
204
205 const result = yield* invoker.invoke({ path: "leaky.failsPreflightBody", args: {} });
206 expect(result).toEqual({
207 ok: false,
208 error: {
209 code: "invalid_tool_arguments",
210 message: "Missing required request body",
211 },
212 });
213 }),
214 );
215
216 it.effect("transport failure (statusCode None but cause present) stays opaque", () =>
217 Effect.gen(function* () {
218 const executor = yield* createExecutor(makeTestConfig({ plugins: [leakyPlugin()] as const }));
219 const invoker = makeExecutorToolInvoker(executor, {
220 invokeOptions: { onElicitation: acceptAll },
221 });
222
223 const err = yield* Effect.flip(
224 invoker.invoke({ path: "leaky.failsOpenApiTransport", args: {} }),
225 );
226 expect(err).toBeInstanceOf(ExecutionToolError);
227 // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: leak test inspects the rendered message to assert it is the opaque generic
228 const msg = (err as { message: string }).message;
229 expect(msg).toMatch(/^Internal tool error \[[0-9a-f]{8}\]$/);
230 expect(msg).not.toContain("internal.service.local");
231 expect(msg).not.toContain("tok-456");
232 expect(msg).not.toContain("HTTP request failed");
233 }),
234 );
235
236 it.effect("post-response failure (statusCode Some) stays opaque", () =>
237 Effect.gen(function* () {
238 const executor = yield* createExecutor(makeTestConfig({ plugins: [leakyPlugin()] as const }));
239 const invoker = makeExecutorToolInvoker(executor, {
240 invokeOptions: { onElicitation: acceptAll },
241 });
242
243 const err = yield* Effect.flip(
244 invoker.invoke({ path: "leaky.failsOpenApiPostResponse", args: {} }),
245 );
246 expect(err).toBeInstanceOf(ExecutionToolError);
247 // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: leak test inspects the rendered message to assert it is the opaque generic
248 const msg = (err as { message: string }).message;
249 expect(msg).toMatch(/^Internal tool error \[[0-9a-f]{8}\]$/);
250 expect(msg).not.toContain("Failed to read response body");
251 expect(msg).not.toContain("internal parser detail");
252 }),
253 );
254});
packages/core/execution/src/tool-invoker.ts+32−1
@@ -148,6 +148,29 @@ const validationIssues = (value: unknown): readonly unknown[] | null => { · 147 unmodified lines
148 return Array.isArray(issues) ? issues : null;148 return Array.isArray(issues) ? issues : null;
149};149};
150 150
151// Pre-flight OpenAPI invocation failures (missing/unresolved path params,
152// missing or malformed request body) are caller-argument errors raised before
153// any HTTP request is sent. Their messages are generated locally from the
154// spec (no upstream data, no transport internals), so they are safe to
155// surface, and actionable: the fix is renaming or adding an argument.
156// Discriminator: pre-flight raises carry `statusCode: None` and no `cause`.
157// Transport failures share `statusCode: None` but wrap the raw client error
158// (which can hold internal URLs) as `cause`, and post-response failures carry
159// a status code; both stay on the opaque-defect path. Matched structurally
160// because this package does not depend on the openapi plugin.
161const openApiPreflightMessage = (value: unknown): string | null => {
162 if (!Predicate.isTagged(value, "OpenApiInvocationError")) return null;
163 const err = value as {
164 readonly message?: unknown;
165 readonly statusCode?: unknown;
166 readonly cause?: unknown;
167 };
168 if (err.cause !== undefined) return null;
169 if (!Predicate.isTagged(err.statusCode, "None")) return null;
170 // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: structural match on the openapi plugin's tagged error; the pre-flight message is locally generated (no upstream data) and is the payload being surfaced
171 return typeof err.message === "string" && err.message.length > 0 ? err.message : null;
172};
173
151const credentialResolutionToolFailure = (input: {174const credentialResolutionToolFailure = (input: {
152 readonly label: string;175 readonly label: string;
153 readonly message: string;176 readonly message: string;
@@ -186,7 +209,8 @@ const expectedToolFailure = (value: unknown): ToolError | null => { · 32 unmodified lines
186 };209 };
187 }210 }
188 if (Predicate.isTagged(value, "ToolInvocationError")) {211 if (Predicate.isTagged(value, "ToolInvocationError")) {
189 const issues = validationIssues((value as { readonly cause?: unknown }).cause);212 const cause = (value as { readonly cause?: unknown }).cause;
213 const issues = validationIssues(cause);
190 if (issues) {214 if (issues) {
191 return {215 return {
192 code: "invalid_tool_arguments",216 code: "invalid_tool_arguments",
@@ -194,6 +218,13 @@ const expectedToolFailure = (value: unknown): ToolError | null => { · 1 unmodified line
194 details: { issues },218 details: { issues },
195 };219 };
196 }220 }
221 const preflight = openApiPreflightMessage(cause);
222 if (preflight) {
223 return {
224 code: "invalid_tool_arguments",
225 message: preflight,
226 };
227 }
197 }228 }
198 return null;229 return null;
199};230};
packages/core/sdk/src/executor.ts+22−2
@@ -2935,6 +2935,17 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 2934 unmodified lines
2935 });2935 });
2936 };2936 };
2937 2937
2938 // The single source of truth for "will enforceApproval pause this call".
2939 // Read before pre-approval arg validation so the extra validation pass
2940 // only runs for calls that would otherwise burn a user approval.
2941 const approvalRequired = (
2942 annotations: ToolAnnotations | undefined,
2943 policy: EffectivePolicy,
2944 ): boolean => {
2945 if (policy.action === "approve") return false;
2946 return policy.action === "require_approval" || annotations?.requiresApproval === true;
2947 };
2948
2938 const enforceApproval = (2949 const enforceApproval = (
2939 annotations: ToolAnnotations | undefined,2950 annotations: ToolAnnotations | undefined,
2940 address: ToolAddress,2951 address: ToolAddress,
@@ -2943,9 +2954,8 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 2 unmodified lines
2943 handler: ElicitationHandler,2954 handler: ElicitationHandler,
2944 ) =>2955 ) =>
2945 Effect.gen(function* () {2956 Effect.gen(function* () {
2946 if (policy.action === "approve") return;2957 if (!approvalRequired(annotations, policy)) return;
2947 const policyForcesApproval = policy.action === "require_approval";2958 const policyForcesApproval = policy.action === "require_approval";
2948 if (!policyForcesApproval && !annotations?.requiresApproval) return;
2949 const message = annotations?.approvalDescription2959 const message = annotations?.approvalDescription
2950 ? annotations.approvalDescription2960 ? annotations.approvalDescription
2951 : policyForcesApproval && policy.pattern2961 : policyForcesApproval && policy.pattern
@@ -3154,6 +3164,16 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 202 unmodified lines
3154 .pipe(wrapInvocationError);3164 .pipe(wrapInvocationError);
3155 resolvedAnnotations = map[String(parsed.tool)] ?? annotations;3165 resolvedAnnotations = map[String(parsed.tool)] ?? annotations;
3156 }3166 }
3167 // When this call is about to pause for approval, validate args
3168 // first: a call that can only fail (missing required path param /
3169 // body) must be rejected here, not after the user grants an approval
3170 // that then goes to waste. Non-pausing calls skip this — invokeTool
3171 // raises the identical failure moments later without the extra pass.
3172 if (approvalRequired(resolvedAnnotations, policy) && runtime.plugin.validateToolArgs) {
3173 yield* runtime.plugin
3174 .validateToolArgs({ ctx: runtime.ctx, toolRow: row, args })
3175 .pipe(wrapInvocationError);
3176 }
3157 yield* enforceApproval(resolvedAnnotations, address, args, policy, handler);3177 yield* enforceApproval(resolvedAnnotations, address, args, policy, handler);
3158 3178
3159 // Resolve every named credential input (`variable → value`); `value` is3179 // Resolve every named credential input (`variable → value`); `value` is
packages/core/sdk/src/index.ts+1−0
@@ -288,6 +288,7 @@ export { · 287 unmodified lines
288 type StaticToolInput,288 type StaticToolInput,
289 type ConfigureIntegrationHandlerInput,289 type ConfigureIntegrationHandlerInput,
290 type InvokeToolInput,290 type InvokeToolInput,
291 type ValidateToolArgsInput,
291 type ConnectionLifecycleInput,292 type ConnectionLifecycleInput,
292 type IntegrationConfigureDecl,293 type IntegrationConfigureDecl,
293 type IntegrationConfigureSchema,294 type IntegrationConfigureSchema,
packages/core/sdk/src/plugin.ts+19−0
@@ -406,6 +406,15 @@ export interface InvokeToolInput<TStore = unknown> { · 405 unmodified lines
406 readonly elicit: Elicit;406 readonly elicit: Elicit;
407}407}
408 408
409/** Input for `validateToolArgs` — no credential/elicit: validation runs
410 * before approval enforcement and credential resolution, so it must depend
411 * on nothing but the tool row and the caller's args. */
412export interface ValidateToolArgsInput<TStore = unknown> {
413 readonly ctx: PluginCtx<TStore>;
414 readonly toolRow: ToolInvocationRow;
415 readonly args: unknown;
416}
417
409/** Called when the executor removes / refreshes a connection owned by this418/** Called when the executor removes / refreshes a connection owned by this
410 * plugin's integration — plugin-side cleanup or re-resolution only; the419 * plugin's integration — plugin-side cleanup or re-resolution only; the
411 * executor handles the core tool rows. */420 * executor handles the core tool rows. */
@@ -522,6 +531,16 @@ export interface PluginSpec< · 110 unmodified lines
522 * address. The plugin applies `input.credential` to the outbound request. */531 * address. The plugin applies `input.credential` to the outbound request. */
523 readonly invokeTool?: (input: InvokeToolInput<TStore>) => Effect.Effect<unknown, unknown>;532 readonly invokeTool?: (input: InvokeToolInput<TStore>) => Effect.Effect<unknown, unknown>;
524 533
534 /** Validate a dynamic tool's args before the executor enforces approval,
535 * so a call guaranteed to fail is rejected instead of pausing for an
536 * approval the user can only waste. Fail with the same error `invokeTool`
537 * would raise for those args; succeed (void) when the args would reach the
538 * wire. Must be side-effect free: no credential use, no elicitation, no
539 * outbound request. Omit to skip pre-approval validation. */
540 readonly validateToolArgs?: (
541 input: ValidateToolArgsInput<TStore>,
542 ) => Effect.Effect<void, unknown>;
543
525 /** Bulk resolve annotations for a set of tool rows under one connection. */544 /** Bulk resolve annotations for a set of tool rows under one connection. */
526 readonly resolveAnnotations?: (input: {545 readonly resolveAnnotations?: (input: {
527 readonly ctx: PluginCtx<TStore>;546 readonly ctx: PluginCtx<TStore>;
packages/plugins/openapi/src/sdk/backing.ts+22−1
@@ -28,7 +28,7 @@ import { · 27 unmodified lines
28 streamOperationBindingsFromStructure,28 streamOperationBindingsFromStructure,
29} from "./extract";29} from "./extract";
30import { compileToolDefinitions, type ToolDefinition } from "./definitions";30import { compileToolDefinitions, type ToolDefinition } from "./definitions";
31import { annotationsForOperation, invokeWithLayer } from "./invoke";31import { annotationsForOperation, buildRequest, invokeWithLayer } from "./invoke";
32import { parse, type ParsedDocument } from "./parse";32import { parse, type ParsedDocument } from "./parse";
33import { parseEntry, structuralSplit, type KeepPathItem, type SpecStructure } from "./split";33import { parseEntry, structuralSplit, type KeepPathItem, type SpecStructure } from "./split";
34import { type OpenapiStore, type StoredOperation } from "./store";34import { type OpenapiStore, type StoredOperation } from "./store";
@@ -645,6 +645,27 @@ export const invokeOpenApiBackedTool = (input: { · 610 unmodified lines
645 });645 });
646 });646 });
647 647
648/** Pre-approval argument validation: run the exact request-building pass
649 * `invoke` runs (path params, request body, base64 payloads) and discard the
650 * built request. Fails with the same pre-flight OpenApiInvocationError the
651 * real invocation would raise, so a call guaranteed to fail is rejected
652 * before the executor pauses it for approval. Legacy rows without a
653 * persisted binding skip validation (re-parsing the spec here would repeat
654 * invokeTool's heavy fallback for no user-visible gain). */
655export const validateOpenApiBackedToolArgs = (input: {
656 readonly ctx: PluginCtx<OpenapiStore>;
657 readonly toolRow: { readonly integration: string; readonly name: string };
658 readonly args: unknown;
659}) =>
660 Effect.gen(function* () {
661 const operation = yield* input.ctx.storage.getOperation(
662 input.toolRow.integration,
663 input.toolRow.name,
664 );
665 if (!operation) return;
666 yield* buildRequest(operation.binding, (input.args ?? {}) as Record<string, unknown>, {});
667 });
668
648export const resolveOpenApiBackedAnnotations = (input: {669export const resolveOpenApiBackedAnnotations = (input: {
649 readonly ctx: PluginCtx<OpenapiStore>;670 readonly ctx: PluginCtx<OpenapiStore>;
650 readonly integration: string;671 readonly integration: string;
packages/plugins/openapi/src/sdk/index.ts+2−1
@@ -12,7 +12,7 @@ export { · 11 unmodified lines
12 type ByteRange,12 type ByteRange,
13 type KeepPathItem,13 type KeepPathItem,
14} from "./split";14} from "./split";
15export { invoke, invokeWithLayer, annotationsForOperation } from "./invoke";15export { invoke, invokeWithLayer, buildRequest, annotationsForOperation } from "./invoke";
16export {16export {
17 buildDefsJsonStreaming,17 buildDefsJsonStreaming,
18 compileAndPersistOpenApiOperations,18 compileAndPersistOpenApiOperations,
@@ -28,6 +28,7 @@ export { · 9 unmodified lines
28 openApiToolDefsFromCompiled,28 openApiToolDefsFromCompiled,
29 resolveOpenApiBackedAnnotations,29 resolveOpenApiBackedAnnotations,
30 resolveOpenApiBackedTools,30 resolveOpenApiBackedTools,
31 validateOpenApiBackedToolArgs,
31 type CompiledOpenApiSpec,32 type CompiledOpenApiSpec,
32 type OpenApiPersistResult,33 type OpenApiPersistResult,
33} from "./backing";34} from "./backing";
packages/plugins/openapi/src/sdk/invoke.ts+34−12
@@ -643,25 +643,22 @@ const applyRequestBody = ( · 642 unmodified lines
643};643};
644 644
645// ---------------------------------------------------------------------------645// ---------------------------------------------------------------------------
646// Public API — invoke a single operation646// Public API — build the outbound request for an operation
647//
648// The full pre-flight phase of an invocation: read/validate caller args
649// (path params, request body, base64 payloads) and construct the
650// HttpClientRequest. Pure — nothing is sent. Exposed separately so the
651// executor's approval flow can validate args BEFORE raising an approval
652// elicitation, guaranteeing the validation semantics are exactly what
653// `invoke` applies (one code path, not a parallel re-implementation).
647// ---------------------------------------------------------------------------654// ---------------------------------------------------------------------------
648 655
649export const invoke = Effect.fn("OpenApi.invoke")(function* (656export const buildRequest = Effect.fn("OpenApi.buildRequest")(function* (
650 operation: OperationBinding,657 operation: OperationBinding,
651 args: Record<string, unknown>,658 args: Record<string, unknown>,
652 resolvedHeaders: Record<string, string>,659 resolvedHeaders: Record<string, string>,
653 sourceQueryParams: Record<string, string> = {},660 sourceQueryParams: Record<string, string> = {},
654) {661) {
655 const client = yield* HttpClient.HttpClient;
656
657 yield* Effect.annotateCurrentSpan({
658 "http.method": operation.method.toUpperCase(),
659 "http.route": operation.pathTemplate,
660 "plugin.openapi.method": operation.method.toUpperCase(),
661 "plugin.openapi.path_template": operation.pathTemplate,
662 "plugin.openapi.headers.resolved_count": Object.keys(resolvedHeaders).length,
663 });
664
665 const resolvedPath = yield* resolvePath(operation.pathTemplate, args, operation.parameters);662 const resolvedPath = yield* resolvePath(operation.pathTemplate, args, operation.parameters);
666 663
667 const path = resolvedPath.startsWith("/") ? resolvedPath : `/${resolvedPath}`;664 const path = resolvedPath.startsWith("/") ? resolvedPath : `/${resolvedPath}`;
@@ -808,6 +805,31 @@ export const invoke = Effect.fn("OpenApi.invoke")(function* ( · 140 unmodified lines
808 805
809 request = applyHeaders(request, resolvedHeaders);806 request = applyHeaders(request, resolvedHeaders);
810 807
808 return request;
809});
810
811// ---------------------------------------------------------------------------
812// Public API — invoke a single operation
813// ---------------------------------------------------------------------------
814
815export const invoke = Effect.fn("OpenApi.invoke")(function* (
816 operation: OperationBinding,
817 args: Record<string, unknown>,
818 resolvedHeaders: Record<string, string>,
819 sourceQueryParams: Record<string, string> = {},
820) {
821 const client = yield* HttpClient.HttpClient;
822
823 yield* Effect.annotateCurrentSpan({
824 "http.method": operation.method.toUpperCase(),
825 "http.route": operation.pathTemplate,
826 "plugin.openapi.method": operation.method.toUpperCase(),
827 "plugin.openapi.path_template": operation.pathTemplate,
828 "plugin.openapi.headers.resolved_count": Object.keys(resolvedHeaders).length,
829 });
830
831 const request = yield* buildRequest(operation, args, resolvedHeaders, sourceQueryParams);
832
811 const response = yield* client.execute(request).pipe(833 const response = yield* client.execute(request).pipe(
812 Effect.mapError(834 Effect.mapError(
813 (err) =>835 (err) =>
packages/plugins/openapi/src/sdk/plugin.test.ts+34−0
@@ -565,6 +565,40 @@ describe("OpenAPI Plugin", () => { · 564 unmodified lines
565 ),565 ),
566 );566 );
567 567
568 it.effect("rejects invalid args before raising the approval elicitation", () =>
569 Effect.scoped(
570 Effect.gen(function* () {
571 const server = yield* servePluginTestApi();
572 const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));
573
574 const conn = yield* addOpenApiTestConnection(executor, server, { slug: "test" });
575 const calls = { count: 0 };
576 // createItem requires approval (POST) and a request body. Omitting
577 // the body must fail pre-flight validation WITHOUT consuming an
578 // approval: the user would otherwise approve a call that can only
579 // fail.
580 const failure = yield* executor
581 .execute(
582 conn.address("items.createItem"),
583 {},
584 {
585 onElicitation: () =>
586 Effect.sync(() => {
587 calls.count++;
588 return { action: "accept" as const, content: {} };
589 }),
590 },
591 )
592 .pipe(Effect.flip);
593
594 expect(calls.count).toBe(0);
595 expect(Predicate.isTagged(failure, "ToolInvocationError")).toBe(true);
596 // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: asserts the exact caller-facing message the pre-flight failure carries
597 expect((failure as { message: string }).message).toBe("Missing required request body");
598 }),
599 ),
600 );
601
568 it.effect("describes OpenAPI invocation results payload-first with http meta beside data", () =>602 it.effect("describes OpenAPI invocation results payload-first with http meta beside data", () =>
569 Effect.scoped(603 Effect.scoped(
570 Effect.gen(function* () {604 Effect.gen(function* () {
packages/plugins/openapi/src/sdk/plugin.ts+4−0
@@ -44,6 +44,7 @@ import { · 43 unmodified lines
44 openApiStoredOperationsFromCompiled,44 openApiStoredOperationsFromCompiled,
45 resolveOpenApiBackedAnnotations,45 resolveOpenApiBackedAnnotations,
46 resolveOpenApiBackedTools,46 resolveOpenApiBackedTools,
47 validateOpenApiBackedToolArgs,
47} from "./backing";48} from "./backing";
48import { resolveServerUrl } from "./openapi-utils";49import { resolveServerUrl } from "./openapi-utils";
49 50
@@ -1031,6 +1032,9 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 981 unmodified lines
1031 });1032 });
1032 },1033 },
1033 1034
1035 validateToolArgs: ({ ctx: validateCtx, toolRow, args }) =>
1036 validateOpenApiBackedToolArgs({ ctx: validateCtx, toolRow, args }),
1037
1034 resolveAnnotations: ({ ctx: annotationsCtx, integration, toolRows }) =>1038 resolveAnnotations: ({ ctx: annotationsCtx, integration, toolRows }) =>
1035 resolveOpenApiBackedAnnotations({1039 resolveOpenApiBackedAnnotations({
1036 ctx: annotationsCtx,1040 ctx: annotationsCtx,