Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion e2e/scenarios/org-slug-routing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
});
}
Expand Down
28 changes: 22 additions & 6 deletions packages/core/sdk/src/executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>).oauthReauthRequiredAt;
return typeof at === "number" ? at : null;
const state = decoded as Record<string, unknown>;
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 => {
Expand Down Expand Up @@ -1787,7 +1797,11 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
b("name", "=", String(row.name)),
),
set: {
provider_state: { ...mergedState, oauthReauthRequiredAt: Date.now() },
provider_state: {
...mergedState,
oauthReauthRequiredAt: Date.now(),
oauthReauthRequiredDetail: detail,
},
last_health: health,
updated_at: new Date(),
},
Expand Down Expand Up @@ -1819,10 +1833,12 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
// dead connection re-sent its dead grant on every proactive cycle,
// indefinitely (owner.com's Datadog connections: 100+ identical
// rejections over two days, surfacing nothing).
if (oauthReauthRequiredAtFromProviderState(row.provider_state) !== null) {
const reauthState = oauthReauthRequiredStateFromProviderState(row.provider_state);
if (reauthState !== null) {
yield* Effect.annotateCurrentSpan({ "executor.oauth.refresh.skipped_known_dead": true });
return yield* reauth(
"The authorization server rejected this connection's refresh token (invalid_grant). Reconnect to continue.",
reauthState.detail ??
"The authorization server rejected this connection's refresh token (invalid_grant). Reconnect to continue.",
);
}

Expand Down
6 changes: 5 additions & 1 deletion packages/core/sdk/src/oauth-flow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1024,6 +1024,10 @@ describe("oauth token refresh in resolveConnectionValue", () => {
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 = () =>
Expand All @@ -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
Expand Down
36 changes: 35 additions & 1 deletion packages/plugins/openapi/src/sdk/extract.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<string, unknown> }).encoding),
),
Expand Down Expand Up @@ -184,6 +185,39 @@ const isJsonMediaType = (mediaType: string): boolean => {
const binaryStringSchema = (schema: Record<string, unknown>): 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<string, unknown> = {};
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<string, unknown>): "base64" | "base64url" =>
typeof schema.description === "string" &&
/base64url|base64-url|url[- ]safe/i.test(schema.description)
Expand Down
39 changes: 31 additions & 8 deletions packages/plugins/openapi/src/sdk/invoke.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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: <partType>` on this part. JSON types get the
// value JSON-stringified first so the blob body is valid JSON.
Expand Down Expand Up @@ -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;
}
Expand Down
76 changes: 76 additions & 0 deletions packages/plugins/openapi/src/sdk/non-json-body.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
Loading