Skip to content
Merged
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
162 changes: 162 additions & 0 deletions e2e/scenarios/openapi-unknown-args.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import { randomBytes } from "node:crypto";
import { createServer } from "node:http";

import { expect } from "@effect/vitest";
import { Effect } from "effect";
import { composePluginApi } from "@executor-js/api/server";
import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared";

import { scenario } from "../src/scenario";
import { Api, Target } from "../src/services";

const api = composePluginApi([openApiHttpPlugin()] as const);

const serveRecordingUpstream = () =>
Effect.acquireRelease(
Effect.callback<{
readonly url: string;
readonly requests: () => readonly string[];
readonly close: () => void;
}>((resume) => {
const recorded: string[] = [];
const server = createServer((request, response) => {
recorded.push(request.url ?? "");
response.writeHead(200, { "content-type": "application/json" });
response.end(JSON.stringify({ id: "2", name: "Gadget" }));
});
server.listen(0, "127.0.0.1", () => {
const address = server.address();
const port = typeof address === "object" && address ? address.port : 0;
resume(
Effect.succeed({
url: `http://127.0.0.1:${port}`,
requests: () => [...recorded],
close: () => {
server.close();
server.closeAllConnections();
},
}),
);
});
}),
(server) => Effect.sync(server.close),
);

const itemsSpec = (baseUrl: string): string =>
JSON.stringify({
openapi: "3.0.3",
info: { title: "Items API", version: "1.0.0" },
servers: [{ url: baseUrl }],
paths: {
"/items/{itemId}": {
get: {
operationId: "getItem",
summary: "Fetch one item",
parameters: [{ name: "itemId", in: "path", required: true, schema: { type: "string" } }],
responses: { "200": { description: "the item" } },
},
},
},
});

const invokeByAddressCode = (address: string, args: unknown) => `
const segments = ${JSON.stringify(address)}.split(".").slice(1);
let node = tools;
for (const segment of segments) node = node[segment];
const result = await node(${JSON.stringify(args)});
return JSON.stringify(result);
`;

type ToolEnvelope = {
readonly ok: boolean;
readonly error?: {
readonly code?: string;
readonly message?: string;
};
};

scenario(
"OpenAPI: unknown tool arguments fail locally before any upstream request",
{},
Effect.scoped(
Effect.gen(function* () {
const target = yield* Target;
const { client: makeClient } = yield* Api;
const identity = yield* target.newIdentity();
const client = yield* makeClient(api, identity);
const upstream = yield* serveRecordingUpstream();
const slug = `openapi_unknown_args_${randomBytes(4).toString("hex")}`;

yield* Effect.ensuring(
Effect.gen(function* () {
yield* client.openapi.addSpec({
payload: {
spec: { kind: "blob", value: itemsSpec(upstream.url) },
slug,
baseUrl: upstream.url,
authenticationTemplate: [
{
slug: "apiKey",
type: "apiKey",
headers: { authorization: ["Bearer ", { type: "variable", name: "token" }] },
},
],
},
});
yield* client.connections.create({
payload: {
owner: "org",
name: ConnectionName.make("main"),
integration: IntegrationSlug.make(slug),
template: AuthTemplateSlug.make("apiKey"),
value: "tok_unknown_args",
},
});

const tools = yield* client.tools.list({ query: {} });
const address = tools
.filter((tool) => String(tool.integration) === slug)
.map((tool) => String(tool.address))
.find((candidate) => candidate.endsWith("getItem"));
expect(address, "the getItem operation is available").toBeDefined();

const executed = yield* client.executions.execute({
payload: {
code: invokeByAddressCode(address!, { itemId: "2", doesNotExist: "nope" }),
autoApprove: true,
},
});
expect(executed.status, "the sandbox returns the tool failure as a value").toBe(
"completed",
);
const outcome = JSON.parse(executed.text) as ToolEnvelope;
expect(outcome.ok, "the unknown argument fails the tool call").toBe(false);
expect(outcome.error?.code, "the failure is classified as invalid arguments").toBe(
"invalid_tool_arguments",
);
expect(
outcome.error?.message,
"the failure names the argument the caller must remove",
).toContain("doesNotExist");
expect(
upstream.requests(),
"argument validation stops the call before upstream dispatch",
).toEqual([]);
}),
Effect.gen(function* () {
yield* client.connections
.remove({
params: {
owner: "org",
integration: IntegrationSlug.make(slug),
name: ConnectionName.make("main"),
},
})
.pipe(Effect.ignore);
yield* client.openapi.removeSpec({ params: { slug } }).pipe(Effect.ignore);
}),
);
}),
),
);
2 changes: 1 addition & 1 deletion packages/plugins/openapi/src/sdk/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export class OpenApiExtractionError extends Schema.TaggedErrorClass<OpenApiExtra
export class OpenApiInvocationError extends Data.TaggedError("OpenApiInvocationError")<{
readonly message: string;
readonly statusCode: Option.Option<number>;
readonly reason?: "response_headers_timeout";
readonly reason?: "response_headers_timeout" | "unknown_arguments";
readonly cause?: unknown;
}> {}

Expand Down
69 changes: 69 additions & 0 deletions packages/plugins/openapi/src/sdk/invoke.ts
Original file line number Diff line number Diff line change
Expand Up @@ -835,12 +835,64 @@ const applyRequestBody = (
// `invoke` applies (one code path, not a parallel re-implementation).
// ---------------------------------------------------------------------------

const acceptedArgumentNames = (operation: OperationBinding): readonly string[] => {
const accepted = new Set<string>();

for (const param of operation.parameters) {
accepted.add(param.name);
for (const container of CONTAINER_KEYS[param.location] ?? []) accepted.add(container);
}

for (const match of operation.pathTemplate.matchAll(/\{([^{}]+)\}/g)) {
if (match[1]) accepted.add(match[1]);
}

if (Option.isSome(operation.requestBody)) {
const requestBody = operation.requestBody.value;
const contents = Option.getOrUndefined(requestBody.contents);
accepted.add("body");
accepted.add("input");
if (
isOctetStream(requestBody.contentType) ||
contents?.some((content) => isOctetStream(content.contentType))
) {
accepted.add("bodyBase64");
}
if (contents && contents.length > 1) accepted.add("contentType");
}

const servers = operation.servers ?? [];
const hasServerVariables = servers.some(
(server) => Object.keys(Option.getOrUndefined(server.variables) ?? {}).length > 0,
);
if (servers.length > 1 || hasServerVariables) accepted.add("server");

return [...accepted];
};

export const buildRequest = Effect.fn("OpenApi.buildRequest")(function* (
operation: OperationBinding,
args: Record<string, unknown>,
resolvedHeaders: Record<string, string>,
integrationQueryParams: Record<string, string> = {},
) {
const accepted = acceptedArgumentNames(operation);
const acceptedSet = new Set(accepted);
const unknown = Object.keys(args).filter((name) => !acceptedSet.has(name));
if (unknown.length > 0) {
const label = unknown.length === 1 ? "Unknown argument" : "Unknown arguments";
const names = unknown.map((name) => JSON.stringify(name)).join(", ");
const acceptedMessage =
accepted.length > 0
? `This operation accepts: ${accepted.join(", ")}.`
: "This operation accepts no arguments.";
return yield* new OpenApiInvocationError({
message: `${label} ${names}. ${acceptedMessage}`,
statusCode: Option.none(),
reason: "unknown_arguments",
});
}

const resolvedPath = yield* resolvePath(operation.pathTemplate, args, operation.parameters);

const path = resolvedPath.startsWith("/") ? resolvedPath : `/${resolvedPath}`;
Expand Down Expand Up @@ -870,6 +922,14 @@ export const buildRequest = Effect.fn("OpenApi.buildRequest")(function* (
request = HttpClientRequest.setHeader(request, param.name, String(value));
}

const cookieValues: string[] = [];
for (const param of operation.parameters) {
if (param.location !== "cookie") continue;
const value = readParamValue(args, param);
if (value === undefined || value === null) continue;
cookieValues.push(`${param.name}=${primitiveToString(value)}`);
}

if (Option.isSome(operation.requestBody)) {
const rb = operation.requestBody.value;
const contentsOpt = Option.getOrUndefined(rb.contents);
Expand Down Expand Up @@ -986,6 +1046,15 @@ export const buildRequest = Effect.fn("OpenApi.buildRequest")(function* (
}

request = applyHeaders(request, resolvedHeaders);
if (cookieValues.length > 0) {
const existingCookie = request.headers.cookie;
const serializedCookies = cookieValues.join("; ");
request = HttpClientRequest.setHeader(
request,
"Cookie",
existingCookie ? `${existingCookie}; ${serializedCookies}` : serializedCookies,
);
}

return request;
});
Expand Down
Loading
Loading