From e0a102c8b3ef2d4a460c604874d3e0d72862563a Mon Sep 17 00:00:00 2001 From: BittuBarnwal7479 Date: Tue, 4 Aug 2026 20:49:31 +0530 Subject: [PATCH 1/3] fix: OpenAPI multipart file field uploads --- packages/plugins/openapi/src/sdk/extract.ts | 36 ++++++++- packages/plugins/openapi/src/sdk/invoke.ts | 39 ++++++++-- .../openapi/src/sdk/non-json-body.test.ts | 76 +++++++++++++++++++ 3 files changed, 142 insertions(+), 9 deletions(-) diff --git a/packages/plugins/openapi/src/sdk/extract.ts b/packages/plugins/openapi/src/sdk/extract.ts index 956e923e8..e6084da7b 100644 --- a/packages/plugins/openapi/src/sdk/extract.ts +++ b/packages/plugins/openapi/src/sdk/extract.ts @@ -1,4 +1,5 @@ import { Effect, Option } from "effect"; +import { ToolFileJsonSchema } from "@executor-js/sdk/core"; import { planToolPaths, type OperationPathInput, type PlannedToolPath } from "./definitions"; import { OpenApiExtractionError } from "./errors"; @@ -135,7 +136,7 @@ const extractRequestBody = ( const contents = declaredContents(body.content).map(({ mediaType, media }) => MediaBinding.make({ contentType: mediaType, - schema: Option.fromNullishOr(media.schema), + schema: Option.fromNullishOr(multipartFileInputSchema(media.schema, mediaType)), encoding: Option.fromNullishOr( buildEncodingRecord((media as { encoding?: Record }).encoding), ), @@ -184,6 +185,39 @@ const isJsonMediaType = (mediaType: string): boolean => { const binaryStringSchema = (schema: Record): boolean => stringType(schema) && (schema.format === "binary" || schema.format === "byte"); +const isMultipartMediaType = (mediaType: string): boolean => + normalizedMediaType(mediaType) === "multipart/form-data"; + +const multipartFileInputSchema = (schema: unknown, mediaType: string): unknown => { + if (!isMultipartMediaType(mediaType)) return schema; + + const rewrite = (node: unknown): unknown => { + if (Array.isArray(node)) { + let changed = false; + const out = node.map((item) => { + const next = rewrite(item); + if (next !== item) changed = true; + return next; + }); + return changed ? out : node; + } + + if (!isRecord(node)) return node; + if (binaryStringSchema(node)) return ToolFileJsonSchema; + + let changed = false; + const out: Record = {}; + for (const [key, value] of Object.entries(node)) { + const next = rewrite(value); + if (next !== value) changed = true; + out[key] = next; + } + return changed ? out : node; + }; + + return rewrite(schema); +}; + const base64EncodingFromDescription = (schema: Record): "base64" | "base64url" => typeof schema.description === "string" && /base64url|base64-url|url[- ]safe/i.test(schema.description) diff --git a/packages/plugins/openapi/src/sdk/invoke.ts b/packages/plugins/openapi/src/sdk/invoke.ts index 57e21b1f3..ca699c2f4 100644 --- a/packages/plugins/openapi/src/sdk/invoke.ts +++ b/packages/plugins/openapi/src/sdk/invoke.ts @@ -1,6 +1,6 @@ import { Effect, Exit, Fiber, Layer, Option, Schema, Stream } from "effect"; import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; -import type { ToolFileValue } from "@executor-js/sdk/core"; +import { isToolFile, type ToolFileValue } from "@executor-js/sdk/core"; import { OpenApiInvocationError } from "./errors"; import { isNdjsonMediaType, NDJSON_MEDIA_TYPES, resolveServerUrl } from "./openapi-utils"; @@ -588,6 +588,21 @@ const toArrayBuffer = (bytes: Uint8Array): ArrayBuffer => { return copy; }; +const formPartFromToolFile = ( + file: ToolFileValue, + contentTypeOverride?: string, +): Blob | File | null => { + const bytes = base64ToUint8Array(file.data); + if (!bytes) return null; + + const type = contentTypeOverride ?? file.mimeType; + const body = toArrayBuffer(bytes); + if (typeof File !== "undefined") { + return new File([body], file.name ?? "file", { type }); + } + return new Blob([body], { type }); +}; + // --------------------------------------------------------------------------- // OpenAPI 3.x encoding — per-property style/explode/allowReserved/contentType // for multipart/form-data and application/x-www-form-urlencoded bodies. @@ -709,6 +724,12 @@ const coerceFormDataRecord = ( ? Option.getOrUndefined(encoding[key]!.contentType) : undefined; + if (isToolFile(raw)) { + const filePart = formPartFromToolFile(raw, partType); + out[key] = (filePart ?? JSON.stringify(raw)) as FormDataCoercible; + continue; + } + // Explicit per-part content type: wrap in a typed Blob so the framer // emits `Content-Type: ` on this part. JSON types get the // value JSON-stringified first so the blob body is valid JSON. @@ -738,13 +759,15 @@ const coerceFormDataRecord = ( } if (Array.isArray(raw)) { out[key] = raw.map((v) => - typeof v === "string" || - typeof v === "number" || - typeof v === "boolean" || - v instanceof Blob || - (typeof File !== "undefined" && v instanceof File) - ? (v as FormDataCoercible) - : JSON.stringify(v), + isToolFile(v) + ? (formPartFromToolFile(v, partType) ?? JSON.stringify(v)) + : typeof v === "string" || + typeof v === "number" || + typeof v === "boolean" || + v instanceof Blob || + (typeof File !== "undefined" && v instanceof File) + ? (v as FormDataCoercible) + : JSON.stringify(v), ) as FormDataCoercible; continue; } diff --git a/packages/plugins/openapi/src/sdk/non-json-body.test.ts b/packages/plugins/openapi/src/sdk/non-json-body.test.ts index 3d85b787b..521307674 100644 --- a/packages/plugins/openapi/src/sdk/non-json-body.test.ts +++ b/packages/plugins/openapi/src/sdk/non-json-body.test.ts @@ -185,6 +185,82 @@ describe("OpenAPI non-JSON request body dispatch", () => { }), ); + it.effect("multipart/form-data: binary file fields use ToolFile and real file parts", () => + Effect.gen(function* () { + const { server, captured } = yield* startEchoServer({ + name: "upload", + path: "/upload", + payload: ObjectBody.pipe(HttpApiSchema.asMultipart()), + transformSpec: replaceRequestBodyContent( + "/upload", + "post", + { + "multipart/form-data": { + schema: { + type: "object", + properties: { + document: { + type: "string", + format: "binary", + description: "PDF document to upload.", + }, + title: { type: "string" }, + }, + required: ["document"], + }, + }, + }, + { document: { contentType: "application/pdf" } }, + ), + }); + + const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); + const conn = yield* addOpenApiTestConnection(executor, server, { slug: "paperless" }); + + const schema = yield* executor.tools.schema(conn.address("body.upload")); + expect(schema?.inputSchema).toMatchObject({ + properties: { + body: { + properties: { + document: { + properties: { + _tag: { enum: ["ToolFile"] }, + data: { contentEncoding: "base64" }, + }, + }, + }, + }, + }, + }); + + const pdfBytes = Buffer.from("%PDF-1.4\nexecutor upload test\n"); + yield* executor.execute(conn.address("body.upload"), { + body: { + document: { + _tag: "ToolFile", + name: "invoice.pdf", + mimeType: "application/pdf", + encoding: "base64", + data: pdfBytes.toString("base64"), + byteLength: pdfBytes.byteLength, + }, + title: "Invoice", + }, + }); + + expect(captured.contentType).toMatch(/^multipart\/form-data; boundary=/); + const body = captured.body.toString("utf8"); + expect(body).toContain('name="document"; filename="invoice.pdf"'); + expect(body).toMatch( + /name="document"; filename="invoice\.pdf"[\s\S]*?Content-Type: application\/pdf/, + ); + expect(body).toContain("%PDF-1.4"); + expect(body).toContain('name="title"'); + expect(body).toContain("Invoice"); + expect(body).not.toContain("[object Object]"); + }), + ); + it.effect("application/xml: string body passes through with xml content-type", () => Effect.gen(function* () { const { server, captured } = yield* startEchoServer({ From b6bb52639cac7d0d6bcde118a6061b3fffe3982e Mon Sep 17 00:00:00 2001 From: BittuBarnwal7479 Date: Tue, 4 Aug 2026 22:02:26 +0530 Subject: [PATCH 2/3] fix: preserve OAuth invalid grant detail --- packages/core/sdk/src/executor.ts | 28 +++++++++++++++++++----- packages/core/sdk/src/oauth-flow.test.ts | 6 ++++- 2 files changed, 27 insertions(+), 7 deletions(-) diff --git a/packages/core/sdk/src/executor.ts b/packages/core/sdk/src/executor.ts index fb831260b..89946481b 100644 --- a/packages/core/sdk/src/executor.ts +++ b/packages/core/sdk/src/executor.ts @@ -789,11 +789,21 @@ const missingOAuthScopesFromProviderState = (value: unknown): readonly string[] * rewrites `provider_state` wholesale. While set, refresh attempts are * skipped: the pre-fix behavior re-sent a known-dead grant to the AS every * proactive cycle, forever, and surfaced nothing to the user. */ -const oauthReauthRequiredAtFromProviderState = (value: unknown): number | null => { +type OAuthReauthRequiredState = { + readonly at: number; + readonly detail: string | null; +}; + +const oauthReauthRequiredStateFromProviderState = ( + value: unknown, +): OAuthReauthRequiredState | null => { const decoded = decodeJsonColumn(value); if (decoded == null || typeof decoded !== "object" || Array.isArray(decoded)) return null; - const at = (decoded as Record).oauthReauthRequiredAt; - return typeof at === "number" ? at : null; + const state = decoded as Record; + const at = state.oauthReauthRequiredAt; + if (typeof at !== "number") return null; + const detail = state.oauthReauthRequiredDetail; + return { at, detail: typeof detail === "string" ? detail : null }; }; const rowToConnection = (row: ConnectionRow): Connection => { @@ -1787,7 +1797,11 @@ export const createExecutor = { expect( (row?.provider_state as { oauthReauthRequiredAt?: number } | null)?.oauthReauthRequiredAt, ).toEqual(expect.any(Number)); + expect( + (row?.provider_state as { oauthReauthRequiredDetail?: string } | null) + ?.oauthReauthRequiredDetail, + ).toContain("Grant revoked"); expect(row?.last_health).toMatchObject({ status: "expired" }); const grantRequests = () => @@ -1041,7 +1045,7 @@ describe("oauth token refresh in resolveConnectionValue", () => { const second = yield* Effect.flip( executor.execute(ToolAddress.make("tools.acme.org.main.whoami"), {}), ); - expect(JSON.stringify(second)).toContain("Reconnect"); + expect(JSON.stringify(second)).toContain("Grant revoked"); expect(yield* grantRequests()).toBe(sentBefore); // Reconnecting mints a fresh grant and re-arms refresh: the marker is From 01755060e53303a8d54c07d99deeb27a7a85504d Mon Sep 17 00:00:00 2001 From: BittuBarnwal7479 Date: Wed, 5 Aug 2026 16:46:41 +0530 Subject: [PATCH 3/3] test: avoid idle wait in org slug routing e2e --- e2e/scenarios/org-slug-routing.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2e/scenarios/org-slug-routing.test.ts b/e2e/scenarios/org-slug-routing.test.ts index a39b021f4..937eb646b 100644 --- a/e2e/scenarios/org-slug-routing.test.ts +++ b/e2e/scenarios/org-slug-routing.test.ts @@ -52,7 +52,7 @@ scenario( // legitimately does not. if (!target.name.startsWith("selfhost")) { await step("An unknown org slug is a wrong address, not a redirect", async () => { - await page.goto("/zz-no-such-org/policies", { waitUntil: "networkidle" }); + await page.goto("/zz-no-such-org/policies", { waitUntil: "domcontentloaded" }); await page.getByText("Page not found").waitFor({ timeout: 30_000 }); }); }