* 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.
Unified · Split
| @@ -1,5 +1,5 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | import { Data, Effect, Schema } from "effect"; | 2 | import { Data, Effect, Option, Schema } from "effect"; |
| 3 | 3 | ||
| 4 | import { ElicitationResponse, createExecutor, definePlugin } from "@executor-js/sdk"; | 4 | import { ElicitationResponse, createExecutor, definePlugin } from "@executor-js/sdk"; |
| 5 | import { makeTestConfig } from "@executor-js/sdk/testing"; | 5 | import { 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. | ||
| 28 | class FakeOpenApiInvocationError extends Data.TaggedError("OpenApiInvocationError")<{ | ||
| 29 | readonly message: string; | ||
| 30 | readonly statusCode: Option.Option<number>; | ||
| 31 | readonly cause?: unknown; | ||
| 32 | }> {} | ||
| 33 | |||
| 23 | const leakyPlugin = definePlugin(() => ({ | 34 | const 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 | |||
| 179 | describe("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 | }); | ||
| @@ -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. | ||
| 161 | const 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 | |||
| 151 | const credentialResolutionToolFailure = (input: { | 174 | const 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 | }; |
| @@ -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?.approvalDescription | 2959 | const message = annotations?.approvalDescription |
| 2950 | ? annotations.approvalDescription | 2960 | ? annotations.approvalDescription |
| 2951 | : policyForcesApproval && policy.pattern | 2961 | : 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` is | 3179 | // Resolve every named credential input (`variable → value`); `value` is |
| @@ -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, |
| @@ -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. */ | ||
| 412 | export 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 this | 418 | /** Called when the executor removes / refreshes a connection owned by this |
| 410 | * plugin's integration — plugin-side cleanup or re-resolution only; the | 419 | * 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>; |
| @@ -28,7 +28,7 @@ import { · 27 unmodified lines | |||
| 28 | streamOperationBindingsFromStructure, | 28 | streamOperationBindingsFromStructure, |
| 29 | } from "./extract"; | 29 | } from "./extract"; |
| 30 | import { compileToolDefinitions, type ToolDefinition } from "./definitions"; | 30 | import { compileToolDefinitions, type ToolDefinition } from "./definitions"; |
| 31 | import { annotationsForOperation, invokeWithLayer } from "./invoke"; | 31 | import { annotationsForOperation, buildRequest, invokeWithLayer } from "./invoke"; |
| 32 | import { parse, type ParsedDocument } from "./parse"; | 32 | import { parse, type ParsedDocument } from "./parse"; |
| 33 | import { parseEntry, structuralSplit, type KeepPathItem, type SpecStructure } from "./split"; | 33 | import { parseEntry, structuralSplit, type KeepPathItem, type SpecStructure } from "./split"; |
| 34 | import { type OpenapiStore, type StoredOperation } from "./store"; | 34 | import { 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). */ | ||
| 655 | export 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 | |||
| 648 | export const resolveOpenApiBackedAnnotations = (input: { | 669 | export const resolveOpenApiBackedAnnotations = (input: { |
| 649 | readonly ctx: PluginCtx<OpenapiStore>; | 670 | readonly ctx: PluginCtx<OpenapiStore>; |
| 650 | readonly integration: string; | 671 | readonly integration: string; |
| @@ -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"; |
| 15 | export { invoke, invokeWithLayer, annotationsForOperation } from "./invoke"; | 15 | export { invoke, invokeWithLayer, buildRequest, annotationsForOperation } from "./invoke"; |
| 16 | export { | 16 | export { |
| 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"; |
| @@ -643,25 +643,22 @@ const applyRequestBody = ( · 642 unmodified lines | |||
| 643 | }; | 643 | }; |
| 644 | 644 | ||
| 645 | // --------------------------------------------------------------------------- | 645 | // --------------------------------------------------------------------------- |
| 646 | // Public API — invoke a single operation | 646 | // 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 | ||
| 649 | export const invoke = Effect.fn("OpenApi.invoke")(function* ( | 656 | export 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 | |||
| 815 | export 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) => |
| @@ -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* () { |
| @@ -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"; |
| 48 | import { resolveServerUrl } from "./openapi-utils"; | 49 | import { 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, |