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
9 changes: 6 additions & 3 deletions packages/core/api/src/server/scoped-executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ import {
type Executor,
type StorageFailure,
} from "@executor-js/sdk";
import { makeHostedHttpClientLayer } from "@executor-js/sdk/host-internal";
import { makeHostedFetch, makeHostedHttpClientLayer } from "@executor-js/sdk/host-internal";

import { DbProvider } from "./executor-fuma-db";

Expand Down Expand Up @@ -241,9 +241,11 @@ export const makeScopedExecutor = <
});

const plugins = pluginsFactory();
const httpClientLayer = makeHostedHttpClientLayer({
const hostedHttpOptions = {
allowLocalNetwork: config.allowLocalNetwork,
});
};
const httpClientLayer = makeHostedHttpClientLayer(hostedHttpOptions);
const hostedFetch = makeHostedFetch(hostedHttpOptions);

// The org id is the tenant (catalog partition); the account id is the acting
// subject (drives `owner: "user"` rows). `organizationName` is no longer part
Expand All @@ -255,6 +257,7 @@ export const makeScopedExecutor = <
blobs,
plugins,
httpClientLayer,
fetch: hostedFetch,
onElicitation: "accept-all",
redirectUri,
coreTools: {
Expand Down
9 changes: 9 additions & 0 deletions packages/core/sdk/src/executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,11 @@ export interface ExecutorConfig<TPlugins extends readonly AnyPlugin[] = readonly
*/
readonly onElicitation: OnElicitation;
readonly httpClientLayer?: Layer.Layer<HttpClient.HttpClient>;
/**
* Fetch API implementation for dependencies that cannot consume `httpClientLayer`.
* Prefer `httpClientLayer` for normal SDK and plugin HTTP.
*/
readonly fetch?: typeof globalThis.fetch;
/**
* The OAuth callback URL (`${webBaseUrl}/oauth/callback`) the host serves and
* sends to providers. There is NO localhost default: omit it (or pass
Expand Down Expand Up @@ -1455,6 +1460,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
scopes: grantedScopes,
resource: clientRow.resource ? String(clientRow.resource) : undefined,
endpointUrlPolicy: config.oauthEndpointUrlPolicy,
fetch: config.fetch,
}).pipe(
// A client_credentials failure is never a rotated-refresh-token
// problem, so do NOT map invalid_grant → reauth. Surface as a
Expand Down Expand Up @@ -1487,6 +1493,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
// (MCP servers require this on refresh).
resource: clientRow.resource ? String(clientRow.resource) : undefined,
endpointUrlPolicy: config.oauthEndpointUrlPolicy,
fetch: config.fetch,
}).pipe(
Effect.mapError((cause) =>
cause.error === "invalid_grant"
Expand Down Expand Up @@ -1913,6 +1920,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
.resolveTools({
integration: rowToIntegration(integrationRow),
config: decodeJsonColumn(integrationRow.config),
httpClientLayer: runtime.ctx.httpClientLayer,
connection: ref,
template: existingRow ? AuthTemplateSlug.make(existingRow.template) : null,
storage: runtime.storage,
Expand Down Expand Up @@ -3079,6 +3087,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
}),
),
httpClientLayer: config.httpClientLayer,
fetch: config.fetch,
endpointUrlPolicy: config.oauthEndpointUrlPolicy,
// EXPLICIT — no localhost default. When a caller omits `redirectUri` the
// OAuth service receives `null` and redirect-requiring flows fail loudly
Expand Down
11 changes: 7 additions & 4 deletions packages/core/sdk/src/host-internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,12 @@
// exists so the host layer (`@executor-js/api/server`) can reach SDK-resident
// host machinery without that machinery polluting the plugin-author surface:
//
// - `makeHostedHttpClientLayer` / `HostedOutboundRequestBlocked`: the hosted
// HTTP client builder. `validateHostedOutboundUrl` is consumed by
// `createExecutor`'s built-in `fetch` tool (the SSRF guard), which is why
// the module stays in the SDK rather than moving wholesale to the host.
// - `makeHostedHttpClientLayer` / `makeHostedFetch` /
// `HostedOutboundRequestBlocked`: the hosted HTTP client builder plus the
// guarded Fetch API adapter for libraries that require fetch. The shared
// outbound URL guard is consumed by `createExecutor`'s built-in `fetch` tool
// (the SSRF guard), which is why the module stays in the SDK rather than
// moving wholesale to the host.
// - `createExecutorFumaDb` + its types: the pure, driver-agnostic FumaDB
// assembly. It stays in the SDK because the SDK's own sqlite test backend
// builds its handle with it; the host layer re-exports it (and pairs it
Expand All @@ -22,6 +24,7 @@

export {
HostedOutboundRequestBlocked,
makeHostedFetch,
makeHostedHttpClientLayer,
type HostedHttpClientOptions,
} from "./hosted-http-client";
Expand Down
17 changes: 17 additions & 0 deletions packages/core/sdk/src/hosted-http-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { HttpClient, HttpClientRequest } from "effect/unstable/http";

import {
type HostedHostnameResolver,
makeHostedFetch,
makeHostedHttpClientLayer,
validateHostedOutboundUrl,
} from "./hosted-http-client";
Expand Down Expand Up @@ -95,6 +96,22 @@ describe("hosted outbound HTTP client", () => {
}),
);

it("applies the DNS guard to fetch callers", async () => {
let calls = 0;
const hostedFetch = makeHostedFetch({
fetch: (async () => {
calls++;
return new Response("unexpected", { status: 200 });
}) as typeof globalThis.fetch,
resolveHostname: async () => [{ address: "10.0.0.20", family: 4 }],
});

await expect(hostedFetch("https://api.example/token")).rejects.toMatchObject({
_tag: "HostedOutboundRequestBlocked",
});
expect(calls).toBe(0);
});

it.effect("checks redirected URLs before following them", () =>
Effect.gen(function* () {
let calls = 0;
Expand Down
4 changes: 4 additions & 0 deletions packages/core/sdk/src/hosted-http-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,10 @@ const guardFetch = (
return await underlying(current, { ...currentInit, redirect: "manual" });
}) as typeof globalThis.fetch;

export const makeHostedFetch = (options: HostedHttpClientOptions = {}): typeof globalThis.fetch =>
// oxlint-disable-next-line executor/no-raw-fetch -- boundary: exposes a guarded Fetch API adapter for libraries that require fetch
guardFetch(options.fetch ?? globalThis.fetch, options);

export const makeHostedHttpClientLayer = (
options: HostedHttpClientOptions = {},
): Layer.Layer<HttpClient.HttpClient> =>
Expand Down
43 changes: 43 additions & 0 deletions packages/core/sdk/src/oauth-helpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -608,6 +608,49 @@ describe("exchangeAuthorizationCode", () => {
});

describe("exchangeClientCredentials", () => {
it.effect("routes token grant requests through the injected fetch", () =>
withTokenEndpoint(tokenResponse(validRefreshBody), ({ tokenUrl }) =>
Effect.gen(function* () {
const seen: Array<{ url: string; method: string | undefined }> = [];
const customFetch: typeof globalThis.fetch = (async (input, init) => {
seen.push({
url: input instanceof Request ? input.url : String(input),
method: init?.method,
});
// oxlint-disable-next-line executor/no-raw-fetch -- boundary: test fetch adapter delegates to the local token endpoint
return fetch(input, init);
}) as typeof globalThis.fetch;

yield* exchangeAuthorizationCode({
tokenUrl,
clientId: "cid",
redirectUrl: "https://app.example.com/cb",
codeVerifier: "verifier",
code: "abc",
fetch: customFetch,
});
yield* exchangeClientCredentials({
tokenUrl,
clientId: "cid",
clientSecret: "secret",
fetch: customFetch,
});
yield* refreshAccessToken({
tokenUrl,
clientId: "cid",
refreshToken: "old",
fetch: customFetch,
});

expect(seen).toEqual([
{ url: tokenUrl, method: "POST" },
{ url: tokenUrl, method: "POST" },
{ url: tokenUrl, method: "POST" },
]);
}),
),
);

it.effect("rejects unsupported token URL schemes before exchange", () =>
Effect.gen(function* () {
const exit = yield* Effect.exit(
Expand Down
28 changes: 25 additions & 3 deletions packages/core/sdk/src/oauth-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -417,10 +417,14 @@ const oauth4webapiRequestOptions = (
targetUrl: string,
timeoutMs: number | undefined,
endpointUrlPolicy: OAuthEndpointUrlPolicy = {},
customFetch?: typeof globalThis.fetch,
): Record<string, unknown> => {
const options: Record<string, unknown> = {
signal: AbortSignal.timeout(timeoutMs ?? OAUTH2_DEFAULT_TIMEOUT_MS),
};
if (customFetch) {
(options as { [oauth.customFetch]?: typeof globalThis.fetch })[oauth.customFetch] = customFetch;
}
if (
isLoopbackHttpUrl(targetUrl) ||
(URL.canParse(targetUrl) &&
Expand Down Expand Up @@ -510,6 +514,7 @@ export type ExchangeAuthorizationCodeInput = {
readonly resource?: string;
readonly timeoutMs?: number;
readonly endpointUrlPolicy?: OAuthEndpointUrlPolicy;
readonly fetch?: typeof globalThis.fetch;
};

export const exchangeAuthorizationCode = (
Expand Down Expand Up @@ -545,7 +550,12 @@ export const exchangeAuthorizationCode = (
clientAuth,
"authorization_code",
params,
oauth4webapiRequestOptions(input.tokenUrl, input.timeoutMs, input.endpointUrlPolicy),
oauth4webapiRequestOptions(
input.tokenUrl,
input.timeoutMs,
input.endpointUrlPolicy,
input.fetch,
),
);
return await processTokenEndpointResponse(as, client, response);
},
Expand All @@ -568,6 +578,7 @@ export type ExchangeClientCredentialsInput = {
readonly resource?: string;
readonly timeoutMs?: number;
readonly endpointUrlPolicy?: OAuthEndpointUrlPolicy;
readonly fetch?: typeof globalThis.fetch;
};

export const exchangeClientCredentials = (
Expand All @@ -593,7 +604,12 @@ export const exchangeClientCredentials = (
client,
clientAuth,
params,
oauth4webapiRequestOptions(input.tokenUrl, input.timeoutMs, input.endpointUrlPolicy),
oauth4webapiRequestOptions(
input.tokenUrl,
input.timeoutMs,
input.endpointUrlPolicy,
input.fetch,
),
);
const result = await oauth.processClientCredentialsResponse(as, client, response);
return tokenResponseFrom(result);
Expand Down Expand Up @@ -621,6 +637,7 @@ export type RefreshAccessTokenInput = {
readonly resource?: string;
readonly timeoutMs?: number;
readonly endpointUrlPolicy?: OAuthEndpointUrlPolicy;
readonly fetch?: typeof globalThis.fetch;
};

export const refreshAccessToken = (
Expand Down Expand Up @@ -652,7 +669,12 @@ export const refreshAccessToken = (
clientAuth,
input.refreshToken,
{
...oauth4webapiRequestOptions(input.tokenUrl, input.timeoutMs, input.endpointUrlPolicy),
...oauth4webapiRequestOptions(
input.tokenUrl,
input.timeoutMs,
input.endpointUrlPolicy,
input.fetch,
),
additionalParameters,
},
);
Expand Down
4 changes: 4 additions & 0 deletions packages/core/sdk/src/oauth-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ export interface OAuthServiceDeps {
template: AuthTemplateSlug,
) => Effect.Effect<readonly string[], StorageFailure>;
readonly httpClientLayer?: Layer.Layer<HttpClient.HttpClient>;
readonly fetch?: typeof globalThis.fetch;
readonly endpointUrlPolicy?: OAuthEndpointUrlPolicy;
/**
* The OAuth callback URL (`${webBaseUrl}${mountPrefix}/oauth/callback`) the host
Expand Down Expand Up @@ -341,6 +342,7 @@ const validateClientEndpoints = (

export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => {
const httpClientLayer = deps.httpClientLayer ?? FetchHttpClient.layer;
const fetch = deps.fetch;
// EXPLICIT — no localhost default. `null` means this executor has no OAuth
// callback; redirect-requiring flows fail loudly via `requireRedirectUri`.
const redirectUri = deps.redirectUri;
Expand Down Expand Up @@ -678,6 +680,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => {
scopes: requestedScopes,
resource: client.resource ?? undefined,
endpointUrlPolicy: deps.endpointUrlPolicy,
fetch,
}).pipe(
Effect.mapError(
(cause) =>
Expand Down Expand Up @@ -854,6 +857,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => {
code: input.code,
resource: client.resource ?? undefined,
endpointUrlPolicy: deps.endpointUrlPolicy,
fetch,
}).pipe(
Effect.mapError(
(cause) =>
Expand Down
1 change: 1 addition & 0 deletions packages/core/sdk/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,7 @@ export interface ResolveToolsInput<TStore = unknown> {
* facades (e.g. a content-addressed spec blob) instead of inlining them
* in `config`. */
readonly storage: TStore;
readonly httpClientLayer: Layer.Layer<HttpClient.HttpClient>;
/** The connection whose tools are being resolved. */
readonly connection: ConnectionRef;
/** Which of the integration's declared auth methods the connection binds
Expand Down
27 changes: 27 additions & 0 deletions packages/plugins/google/src/sdk/discovery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { buildToolTypeScriptPreview } from "@executor-js/sdk/core";
import {
convertGoogleDiscoveryBundleToOpenApi,
convertGoogleDiscoveryToOpenApi,
isGoogleDiscoveryUrl,
normalizeGoogleDiscoveryUrl,
} from "./discovery";
import { extract, parse } from "@executor-js/plugin-openapi";

Expand Down Expand Up @@ -44,6 +46,31 @@ const ConvertedSpec = Schema.Struct({

const decodeConvertedSpec = Schema.decodeUnknownSync(Schema.fromJsonString(ConvertedSpec));

it("accepts only supported HTTPS Google Discovery endpoints", () => {
expect(
normalizeGoogleDiscoveryUrl("https://www.googleapis.com/discovery/v1/apis/gmail/v1/rest/"),
).toBe("https://www.googleapis.com/discovery/v1/apis/gmail/v1/rest");
expect(
normalizeGoogleDiscoveryUrl("https://chat.googleapis.com/$discovery/rest?version=v1"),
).toBe("https://www.googleapis.com/discovery/v1/apis/chat/v1/rest");

expect(isGoogleDiscoveryUrl("https://www.googleapis.com/discovery/v1/apis/gmail/v1/rest")).toBe(
true,
);
expect(isGoogleDiscoveryUrl("https://evilgoogleapis.com/discovery/v1/apis/gmail/v1/rest")).toBe(
false,
);
expect(isGoogleDiscoveryUrl("http://www.googleapis.com/discovery/v1/apis/gmail/v1/rest")).toBe(
false,
);
expect(
isGoogleDiscoveryUrl("https://www.googleapis.com/discovery/v1/apis/gmail/v1/rest?next=x"),
).toBe(false);
expect(
isGoogleDiscoveryUrl("https://token@www.googleapis.com/discovery/v1/apis/gmail/v1/rest"),
).toBe(false);
});

const normalizeOpenApiRefsForPreview = (node: unknown): unknown => {
if (node == null || typeof node !== "object") return node;
if (Array.isArray(node)) return node.map(normalizeOpenApiRefsForPreview);
Expand Down
Loading
Loading