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
50 changes: 26 additions & 24 deletions packages/core/sdk/src/executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -782,19 +782,28 @@ const missingOAuthScopesFromProviderState = (value: unknown): readonly string[]
: [];
};

/** The definitive refresh rejection recorded on `provider_state`, or null.
* Set when the AS rejects the grant itself (RFC 6749 invalid_grant — retrying
* cannot change the verdict); cleared by the reconnect mint, which rewrites
* `provider_state` wholesale. While set, refresh attempts are skipped. */
const decodeOAuthReauthRequiredProviderState = Schema.decodeUnknownOption(
Schema.Struct({
oauthReauthRequiredAt: Schema.Number,
oauthReauthRequiredDetail: Schema.optional(Schema.String),
}),
);
/** Epoch ms of the definitive refresh rejection recorded on `provider_state`,
* or null. Set when the AS rejects the grant itself (RFC 6749 invalid_grant —
* retrying cannot change the verdict); cleared by the reconnect mint, which
* 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. */
type OAuthReauthRequiredState = {
readonly at: number;
readonly detail: string | null;
};

const oauthReauthRequiredFromProviderState = (value: unknown) =>
Option.getOrNull(decodeOAuthReauthRequiredProviderState(decodeJsonColumn(value)));
const oauthReauthRequiredStateFromProviderState = (
value: unknown,
): OAuthReauthRequiredState | null => {
const decoded = decodeJsonColumn(value);
if (decoded == null || typeof decoded !== "object" || Array.isArray(decoded)) return 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 => {
const owner = row.owner as Owner;
Expand Down Expand Up @@ -1823,20 +1832,13 @@ 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).
const reauthState = oauthReauthRequiredFromProviderState(row.provider_state);
const reauthState = oauthReauthRequiredStateFromProviderState(row.provider_state);
if (reauthState !== null) {
yield* Effect.annotateCurrentSpan({ "executor.oauth.refresh.skipped_known_dead": true });
const recordedHealth = Option.getOrNull(decodeLastHealth(row.last_health));
const recordedDetail =
reauthState.oauthReauthRequiredDetail ??
(recordedHealth?.status === "expired" ? recordedHealth.detail : undefined);
const detail =
recordedDetail === undefined
? "The authorization server rejected this connection's refresh token (invalid_grant). Reconnect to continue."
: recordedDetail.endsWith("Reconnect to continue.")
? recordedDetail
: `${recordedDetail} Reconnect to continue.`;
return yield* reauth(detail);
return yield* reauth(
reauthState.detail ??
"The authorization server rejected this connection's refresh token (invalid_grant). Reconnect to continue.",
);
}

// Load the backing app by the owner STORED on the connection (a Personal
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 @@ -1097,6 +1097,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 @@ -1114,7 +1118,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