History

Add hosted outbound HTTP policy

Route hosted plugin outbound requests through a shared Effect HttpClient layer that blocks local/private/metadata targets and re-checks redirects. Thread the executor-provided HTTP client through OpenAPI, GraphQL, MCP probing, and OAuth discovery, keep local harnesses on the normal fetch client, and split the restored security coverage into the relevant existing suites. Fix cloud migration scripts to invoke the local Drizzle binary under op run.

Rhys Sullivan committed 6 months ago

Commit 5fc67cd1 parent 745deac19 files · +559 −36Browse files

Unified · Split

19 files
  1. apps/cloud/executor.config.ts +7 −2
  2. apps/cloud/package.json +3 −3
  3. apps/cloud/src/mcp-session.e2e.node.test.ts +2 −0
  4. apps/cloud/src/services/__test-harness__/api-harness.ts +11 −2
  5. apps/cloud/src/services/executor.ts +9 −1
  6. apps/cloud/src/services/mcp-oauth.node.test.ts +63 −2
  7. apps/cloud/src/services/secrets-api.node.test.ts +6 −3
  8. apps/cloud/src/services/sources-api.node.test.ts +20 −0
  9. apps/cloud/src/services/tenant-isolation.node.test.ts +179 −5
  10. packages/core/sdk/src/executor.ts +6 −2
  11. packages/core/sdk/src/hosted-http-client.test.ts +69 −0
  12. packages/core/sdk/src/hosted-http-client.ts +135 −0
  13. packages/core/sdk/src/index.ts +7 −0
  14. packages/core/sdk/src/oauth-service.ts +8 −3
  15. packages/core/sdk/src/plugin.ts +2 −0
  16. packages/plugins/graphql/src/sdk/plugin.ts +6 −5
  17. packages/plugins/mcp/src/sdk/plugin.ts +17 −2
  18. packages/plugins/mcp/src/sdk/probe-shape.ts +3 −2
  19. packages/plugins/openapi/src/sdk/plugin.ts +6 −4
apps/cloud/executor.config.ts+7−2
@@ -37,10 +37,15 @@ interface CloudPluginDeps { · 36 unmodified lines
3737
3838 export default defineExecutorConfig({
3939 dialect: "pg",
40− plugins: ({ workosCredentials, workosVaultClient }: CloudPluginDeps = {}) =>
40+ plugins: ({
41+ workosCredentials,
42+ workosVaultClient,
43+ }: CloudPluginDeps = {}) =>
4144 [
4245 openApiPlugin(),
43− mcpPlugin({ dangerouslyAllowStdioMCP: false }),
46+ mcpPlugin({
47+ dangerouslyAllowStdioMCP: false,
48+ }),
4449 graphqlPlugin(),
4550 workosVaultPlugin({
4651 credentials: workosCredentials ?? { apiKey: "", clientId: "" },
apps/cloud/package.json+3−3
@@ -11,9 +11,9 @@ · 10 unmodified lines
1111 "db:schema": "node --import jiti/register ../../packages/core/cli/src/index.ts generate --config ./executor.config.ts --output ./src/services/executor-schema.ts",
1212 "db:generate": "drizzle-kit generate",
1313 "db:studio": "drizzle-kit studio",
14− "db:studio:prod": "op run --env-file=.env.production -- drizzle-kit studio",
15− "db:migrate:prod": "op run --env-file=.env.production -- drizzle-kit migrate",
16− "db:migrate:dev": "op run --env-file=.env.op -- drizzle-kit migrate",
14+ "db:studio:prod": "op run --env-file=.env.production -- bun --bun ../../node_modules/.bun/node_modules/drizzle-kit/bin.cjs studio",
15+ "db:migrate:prod": "op run --env-file=.env.production -- bun --bun ../../node_modules/.bun/node_modules/drizzle-kit/bin.cjs migrate",
16+ "db:migrate:dev": "op run --env-file=.env.op -- bun --bun ../../node_modules/.bun/node_modules/drizzle-kit/bin.cjs migrate",
1717 "build": "node scripts/build.mjs",
1818 "preview": "vite preview",
1919 "deploy": "op run --env-file=.env.production -- bun run build && op run --env-file=.env.production -- wrangler deploy",
apps/cloud/src/mcp-session.e2e.node.test.ts+2−0
@@ -33,6 +33,7 @@ import { · 32 unmodified lines
3333 createExecutor,
3434 definePlugin,
3535 } from "@executor-js/sdk";
36+import { FetchHttpClient } from "effect/unstable/http";
3637 import { makePostgresAdapter, makePostgresBlobStore } from "@executor-js/storage-postgres";
3738 import { makeTestWorkOSVaultClient } from "@executor-js/plugin-workos-vault/testing";
3839 import executorConfig from "../executor.config";
@@ -117,6 +118,7 @@ const buildScopedExecutor = (scopeId: string, scopeName: string, options: BuildO · 78 unmodified lines
117118 adapter,
118119 blobs,
119120 plugins,
121+ httpClientLayer: FetchHttpClient.layer,
120122 onElicitation: "accept-all",
121123 });
122124 });
apps/cloud/src/services/__test-harness__/api-harness.ts+11−2
@@ -26,7 +26,12 @@ import { · 25 unmodified lines
2626 } from "@executor-js/api/server";
2727 import { createExecutionEngine } from "@executor-js/execution";
2828 import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs";
29−import { Scope, ScopeId, collectSchemas, createExecutor } from "@executor-js/sdk";
29+import {
30+ Scope,
31+ ScopeId,
32+ collectSchemas,
33+ createExecutor,
34+} from "@executor-js/sdk";
3035 import { makePostgresAdapter, makePostgresBlobStore } from "@executor-js/storage-postgres";
3136 import { makeTestWorkOSVaultClient } from "@executor-js/plugin-workos-vault/testing";
3237
@@ -61,7 +66,10 @@ const defaultUserFor = (orgId: string) => `default_user_${orgId}`; · 28 unmodified lines
6166 // ---------------------------------------------------------------------------
6267
6368 const fakeVault = makeTestWorkOSVaultClient();
64−const testPlugins = executorConfig.plugins({ workosVaultClient: fakeVault });
69+const testPlugins = executorConfig.plugins({
70+ workosVaultClient: fakeVault,
71+});
72+const testHttpClientLayer = FetchHttpClient.layer;
6573
6674 const createTestScopedExecutor = (userId: string, orgId: string, orgName: string) =>
6775 Effect.gen(function* () {
@@ -85,6 +93,7 @@ const createTestScopedExecutor = (userId: string, orgId: string, orgName: string · 17 unmodified lines
8593 adapter,
8694 blobs,
8795 plugins,
96+ httpClientLayer: testHttpClientLayer,
8897 onElicitation: "accept-all",
8998 });
9099 });
apps/cloud/src/services/executor.ts+9−1
@@ -10,7 +10,13 @@ · 9 unmodified lines
1010
1111 import { Effect } from "effect";
1212
13−import { Scope, ScopeId, collectSchemas, createExecutor } from "@executor-js/sdk";
13+import {
14+ Scope,
15+ ScopeId,
16+ collectSchemas,
17+ createExecutor,
18+ makeHostedHttpClientLayer,
19+} from "@executor-js/sdk";
1420 import { makePostgresAdapter, makePostgresBlobStore } from "@executor-js/storage-postgres";
1521
1622 import { env } from "cloudflare:workers";
@@ -58,6 +64,7 @@ export const createScopedExecutor = ( · 41 unmodified lines
5864 const { db } = yield* DbService;
5965
6066 const plugins = orgPlugins();
67+ const httpClientLayer = makeHostedHttpClientLayer();
6168 const schema = collectSchemas(plugins);
6269 const adapter = makePostgresAdapter({ db, schema });
6370 const blobs = makePostgresBlobStore({ db });
@@ -82,6 +89,7 @@ export const createScopedExecutor = ( · 18 unmodified lines
8289 adapter,
8390 blobs,
8491 plugins,
92+ httpClientLayer,
8593 onElicitation: "accept-all",
8694 });
8795 });
apps/cloud/src/services/mcp-oauth.node.test.ts+63−2
@@ -27,10 +27,10 @@ import { createServer, type Server } from "node:http"; · 26 unmodified lines
2727 import type { AddressInfo } from "node:net";
2828 import { createHash, randomBytes } from "node:crypto";
2929
30−import { Effect, Option, Schema } from "effect";
30+import { Effect, Option, Result, Schema } from "effect";
3131 import { ScopeId } from "@executor-js/sdk";
3232
33−import { asUser, testUserOrgScopeId } from "./__test-harness__/api-harness";
33+import { asOrg, asUser, testUserOrgScopeId } from "./__test-harness__/api-harness";
3434
3535 // ---------------------------------------------------------------------------
3636 // Fake OAuth + MCP server
@@ -262,6 +262,67 @@ afterAll(async () => { · 225 unmodified lines
262262 // ---------------------------------------------------------------------------
263263
264264 describe("mcp oauth end-to-end (node pool, real OAuth + MCP server)", () => {
265+ it.effect(
266+ "start rejects a redirectUrl on a different origin before discovery",
267+ () =>
268+ Effect.gen(function* () {
269+ const org = `org_${crypto.randomUUID()}`;
270+ const scopeId = ScopeId.make(org);
271+ const start = Date.now();
272+
273+ const result = yield* asOrg(org, (client) =>
274+ client.oauth.start({
275+ params: { scopeId },
276+ payload: {
277+ endpoint: "https://example.test/api",
278+ redirectUrl: "https://other.example/cb",
279+ connectionId: "conn-foreign-redirect",
280+ tokenScope: String(scopeId),
281+ pluginId: "mcp",
282+ strategy: { kind: "dynamic-dcr" },
283+ },
284+ }),
285+ ).pipe(Effect.result);
286+
287+ expect(Result.isFailure(result)).toBe(true);
288+ expect(Date.now() - start).toBeLessThan(1000);
289+ }),
290+ { timeout: 30_000 },
291+ );
292+
293+ it.effect(
294+ "start rejects non-http redirectUrl schemes",
295+ () =>
296+ Effect.gen(function* () {
297+ const org = `org_${crypto.randomUUID()}`;
298+ const scopeId = ScopeId.make(org);
299+
300+ for (const redirectUrl of [
301+ "javascript:alert(1)",
302+ "data:text/html,<script>alert(1)</script>",
303+ ]) {
304+ const start = Date.now();
305+ const result = yield* asOrg(org, (client) =>
306+ client.oauth.start({
307+ params: { scopeId },
308+ payload: {
309+ endpoint: "https://example.test/api",
310+ redirectUrl,
311+ connectionId: `conn-${crypto.randomUUID().slice(0, 8)}`,
312+ tokenScope: String(scopeId),
313+ pluginId: "mcp",
314+ strategy: { kind: "dynamic-dcr" },
315+ },
316+ }),
317+ ).pipe(Effect.result);
318+
319+ expect(Result.isFailure(result)).toBe(true);
320+ expect(Date.now() - start).toBeLessThan(1000);
321+ }
322+ }),
323+ { timeout: 30_000 },
324+ );
325+
265326 it.effect(
266327 "startOAuth → authorize → completeOAuth writes tokens at the invoker scope",
267328 () =>
apps/cloud/src/services/secrets-api.node.test.ts+6−3
@@ -9,24 +9,27 @@ import { ScopeId, SecretId } from "@executor-js/sdk"; · 8 unmodified lines
99 import { asOrg, fetchForOrg, TEST_BASE_URL } from "./__test-harness__/api-harness";
1010
1111 describe("secrets api (HTTP)", () => {
12− it.effect("set → list → status round-trips a new secret without exposing plaintext", () =>
12+ it.effect("set → list → status returns secret metadata", () =>
1313 Effect.gen(function* () {
1414 const org = `org_${crypto.randomUUID()}`;
1515 const id = `sec_${crypto.randomUUID().slice(0, 8)}`;
1616
17+ const secretValue = "sk-test-abc";
1718 const setRef = yield* asOrg(org, (client) =>
1819 client.secrets.set({
1920 params: { scopeId: ScopeId.make(org) },
20− payload: { id: SecretId.make(id), name: "My API Token", value: "sk-test-abc" },
21+ payload: { id: SecretId.make(id), name: "My API Token", value: secretValue },
2122 }),
2223 );
2324 expect(setRef.id).toBe(id);
2425 expect(setRef.scopeId).toBe(org);
26+ expect(JSON.stringify(setRef)).not.toContain(secretValue);
2527
2628 const list = yield* asOrg(org, (client) =>
2729 client.secrets.list({ params: { scopeId: ScopeId.make(org) } }),
2830 );
2931 expect(list.find((s) => s.id === id)?.name).toBe("My API Token");
32+ expect(JSON.stringify(list)).not.toContain(secretValue);
3033
3134 const status = yield* asOrg(org, (client) =>
3235 client.secrets.status({
@@ -37,7 +40,7 @@ describe("secrets api (HTTP)", () => { · 4 unmodified lines
3740 }),
3841 );
3942
40− it.effect("does not expose a plaintext resolve endpoint", () =>
43+ it.effect("resolve is not available through the public API", () =>
4144 Effect.gen(function* () {
4245 const org = `org_${crypto.randomUUID()}`;
4346 const id = `sec_${crypto.randomUUID().slice(0, 8)}`;
apps/cloud/src/services/sources-api.node.test.ts+20−0
@@ -373,6 +373,26 @@ describe("sources api (HTTP)", () => { · 372 unmodified lines
373373 }),
374374 );
375375
376+ it.effect("openapi.addSpec accepts a public HTTP baseUrl in local mode", () =>
377+ Effect.gen(function* () {
378+ const org = `org_${crypto.randomUUID()}`;
379+
380+ const result = yield* asOrg(org, (client) =>
381+ client.openapi.addSpec({
382+ params: { scopeId: ScopeId.make(org) },
383+ payload: {
384+ targetScope: ScopeId.make(org),
385+ spec: MINIMAL_OPENAPI_SPEC,
386+ namespace: `ns_${crypto.randomUUID().replace(/-/g, "_")}`,
387+ baseUrl: "http://example.com",
388+ },
389+ }),
390+ );
391+
392+ expect(result.toolCount).toBe(1);
393+ }),
394+ );
395+
376396 it.effect("added OpenAPI source can be listed, inspected, and invoked through execution", () =>
377397 Effect.gen(function* () {
378398 const server = yield* Effect.acquireRelease(
apps/cloud/src/services/tenant-isolation.node.test.ts+179−5
@@ -3,9 +3,9 @@ · 2 unmodified lines
33 // on the full cloud module graph.
44
55 import { describe, expect, it } from "@effect/vitest";
6−import { Effect } from "effect";
6+import { Effect, Result } from "effect";
77
8−import { ScopeId, SecretId } from "@executor-js/sdk";
8+import { ConnectionId, ScopeId, SecretId } from "@executor-js/sdk";
99
1010 import { asOrg } from "./__test-harness__/api-harness";
1111
@@ -23,7 +23,80 @@ const MINIMAL_OPENAPI_SPEC = JSON.stringify({ · 11 unmodified lines
2323 });
2424
2525 describe("tenant isolation (HTTP)", () => {
26− it.effect("sources.list does not leak across orgs", () =>
26+ it.effect("write requests cannot target another org scope", () =>
27+ Effect.gen(function* () {
28+ const orgA = `org_${crypto.randomUUID()}`;
29+ const orgB = `org_${crypto.randomUUID()}`;
30+
31+ const result = yield* asOrg(orgB, (client) =>
32+ client.secrets.set({
33+ params: { scopeId: ScopeId.make(orgA) },
34+ payload: {
35+ id: SecretId.make("planted"),
36+ name: "planted-by-org-b",
37+ value: "should-be-rejected",
38+ },
39+ }),
40+ ).pipe(Effect.result);
41+
42+ expect(Result.isFailure(result)).toBe(true);
43+
44+ const orgASecrets = yield* asOrg(orgA, (client) =>
45+ client.secrets.list({ params: { scopeId: ScopeId.make(orgA) } }),
46+ );
47+ expect(orgASecrets.map((s) => s.id)).not.toContain("planted");
48+ }),
49+ );
50+
51+ it.effect("read requests with another org scope still use the caller stack", () =>
52+ Effect.gen(function* () {
53+ const orgA = `org_${crypto.randomUUID()}`;
54+ const orgB = `org_${crypto.randomUUID()}`;
55+ const idA = `sec_a_${crypto.randomUUID().slice(0, 8)}`;
56+
57+ yield* asOrg(orgA, (client) =>
58+ client.secrets.set({
59+ params: { scopeId: ScopeId.make(orgA) },
60+ payload: { id: SecretId.make(idA), name: "org-a only", value: "v" },
61+ }),
62+ );
63+
64+ const result = yield* asOrg(orgB, (client) =>
65+ client.secrets.list({ params: { scopeId: ScopeId.make(orgA) } }),
66+ );
67+ expect(result.map((s) => s.id)).not.toContain(idA);
68+ }),
69+ );
70+
71+ it.effect("delete requests cannot remove another org secret", () =>
72+ Effect.gen(function* () {
73+ const orgA = `org_${crypto.randomUUID()}`;
74+ const orgB = `org_${crypto.randomUUID()}`;
75+ const idA = `sec_a_${crypto.randomUUID().slice(0, 8)}`;
76+
77+ yield* asOrg(orgA, (client) =>
78+ client.secrets.set({
79+ params: { scopeId: ScopeId.make(orgA) },
80+ payload: { id: SecretId.make(idA), name: "org-a only", value: "v" },
81+ }),
82+ );
83+
84+ yield* asOrg(orgB, (client) =>
85+ client.secrets.remove({
86+ params: { scopeId: ScopeId.make(orgA), secretId: SecretId.make(idA) },
87+ }),
88+ ).pipe(Effect.result);
89+
90+ const status = yield* asOrg(orgA, (client) =>
91+ client.secrets.status({
92+ params: { scopeId: ScopeId.make(orgA), secretId: SecretId.make(idA) },
93+ }),
94+ );
95+ expect(status.status).toBe("resolved");
96+ }),
97+ );
98+
99+ it.effect("sources.list is scoped to the caller org", () =>
27100 Effect.gen(function* () {
28101 const orgA = `org_${crypto.randomUUID()}`;
29102 const orgB = `org_${crypto.randomUUID()}`;
@@ -47,7 +120,7 @@ describe("tenant isolation (HTTP)", () => { · 17 unmodified lines
47120 }),
48121 );
49122
50− it.effect("tools.list does not leak across orgs", () =>
123+ it.effect("tools.list is scoped to the caller org", () =>
51124 Effect.gen(function* () {
52125 const orgA = `org_${crypto.randomUUID()}`;
53126 const orgB = `org_${crypto.randomUUID()}`;
@@ -68,6 +141,9 @@ describe("tenant isolation (HTTP)", () => { · 14 unmodified lines
68141 client.tools.list({ params: { scopeId: ScopeId.make(orgB) } }),
69142 );
70143 expect(orgBTools.map((t) => t.sourceId)).not.toContain(namespaceA);
144+ for (const id of orgBTools.map((t) => t.id)) {
145+ expect(id).not.toContain(namespaceA);
146+ }
71147 }),
72148 );
73149
@@ -98,7 +174,7 @@ describe("tenant isolation (HTTP)", () => { · 24 unmodified lines
98174 }),
99175 );
100176
101− it.effect("secrets.list does not leak across orgs", () =>
177+ it.effect("secrets.list is scoped to the caller org", () =>
102178 Effect.gen(function* () {
103179 const orgA = `org_${crypto.randomUUID()}`;
104180 const orgB = `org_${crypto.randomUUID()}`;
@@ -168,6 +244,104 @@ describe("tenant isolation (HTTP)", () => { · 63 unmodified lines
168244 }),
169245 );
170246
247+ it.effect("secret usages are scoped to the caller stack", () =>
248+ Effect.gen(function* () {
249+ const orgA = `org_${crypto.randomUUID()}`;
250+ const orgB = `org_${crypto.randomUUID()}`;
251+ const namespaceA = `a_${crypto.randomUUID().replace(/-/g, "_")}`;
252+ const secretIdA = SecretId.make(`sec_a_${crypto.randomUUID().slice(0, 8)}`);
253+
254+ yield* asOrg(orgA, (client) =>
255+ Effect.gen(function* () {
256+ yield* client.secrets.set({
257+ params: { scopeId: ScopeId.make(orgA) },
258+ payload: { id: secretIdA, name: "org-a token", value: "v" },
259+ });
260+ yield* client.openapi.addSpec({
261+ params: { scopeId: ScopeId.make(orgA) },
262+ payload: {
263+ targetScope: ScopeId.make(orgA),
264+ spec: MINIMAL_OPENAPI_SPEC,
265+ namespace: namespaceA,
266+ headers: {
267+ Authorization: {
268+ kind: "binding",
269+ slot: "auth:token",
270+ prefix: "Bearer ",
271+ },
272+ },
273+ },
274+ });
275+ yield* client.openapi.setSourceBinding({
276+ params: { scopeId: ScopeId.make(orgA) },
277+ payload: {
278+ sourceId: namespaceA,
279+ sourceScope: ScopeId.make(orgA),
280+ scope: ScopeId.make(orgA),
281+ slot: "auth:token",
282+ value: { kind: "secret", secretId: secretIdA },
283+ },
284+ });
285+ }),
286+ );
287+
288+ const usages = yield* asOrg(orgB, (client) =>
289+ client.secrets.usages({
290+ params: { scopeId: ScopeId.make(orgB), secretId: secretIdA },
291+ }),
292+ );
293+
294+ expect(usages).toEqual([]);
295+ }),
296+ );
297+
298+ it.effect("connection usages are scoped to the caller stack", () =>
299+ Effect.gen(function* () {
300+ const orgA = `org_${crypto.randomUUID()}`;
301+ const orgB = `org_${crypto.randomUUID()}`;
302+ const namespaceA = `a_${crypto.randomUUID().replace(/-/g, "_")}`;
303+ const connectionIdA = ConnectionId.make(`conn_a_${crypto.randomUUID().slice(0, 8)}`);
304+
305+ yield* asOrg(orgA, (client) =>
306+ Effect.gen(function* () {
307+ yield* client.openapi.addSpec({
308+ params: { scopeId: ScopeId.make(orgA) },
309+ payload: {
310+ targetScope: ScopeId.make(orgA),
311+ spec: MINIMAL_OPENAPI_SPEC,
312+ namespace: namespaceA,
313+ headers: {
314+ Authorization: {
315+ kind: "binding",
316+ slot: "auth:conn",
317+ prefix: "Bearer ",
318+ },
319+ },
320+ },
321+ });
322+ yield* client.openapi.setSourceBinding({
323+ params: { scopeId: ScopeId.make(orgA) },
324+ payload: {
325+ sourceId: namespaceA,
326+ sourceScope: ScopeId.make(orgA),
327+ scope: ScopeId.make(orgA),
328+ slot: "auth:conn",
329+ value: { kind: "connection", connectionId: connectionIdA },
330+ },
331+ });
332+ }),
333+ ).pipe(Effect.result);
334+
335+ const usages = yield* asOrg(orgB, (client) =>
336+ client.connections.usages({
337+ params: { scopeId: ScopeId.make(orgB), connectionId: connectionIdA },
338+ }),
339+ ).pipe(Effect.result);
340+
341+ if (Result.isSuccess(usages)) expect(usages.success).toEqual([]);
342+ }),
343+ );
344+
171345 it.effect("updating a same-namespace OpenAPI source in one org does not mutate another org", () =>
172346 Effect.gen(function* () {
173347 const orgA = `org_${crypto.randomUUID()}`;
packages/core/sdk/src/executor.ts+6−2
@@ -1,4 +1,5 @@
1−import { Context, Deferred, Effect, Option, Result, Schema, Semaphore } from "effect";
1+import { Context, Deferred, Effect, Layer, Option, Result, Schema, Semaphore } from "effect";
2+import { FetchHttpClient, type HttpClient } from "effect/unstable/http";
23 import { generateKeyBetween } from "fractional-indexing";
34 import {
45 StorageError,
@@ -341,6 +342,7 @@ export interface ExecutorConfig<TPlugins extends readonly AnyPlugin[] = []> { · 336 unmodified lines
341342 * an options arg.
342343 */
343344 readonly onElicitation: OnElicitation;
345+ readonly httpClientLayer?: Layer.Layer<HttpClient.HttpClient>;
344346 }
345347
346348 // ---------------------------------------------------------------------------
@@ -2456,7 +2458,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = []> · 2109 unmodified lines
24562458 usagesForConnection: credentialBindingUsagesForConnection,
24572459 };
24582460
2459− const oauthBundle = makeOAuth2Service({
2461+ const oauthBundle = makeOAuth2Service({
24602462 adapter: core,
24612463 rawAdapter: adapter,
24622464 secretsGet: (id) =>
@@ -2465,6 +2467,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = []> · 2 unmodified lines
24652467 ),
24662468 secretsSet: (input) => secretsSet(input),
24672469 connectionsCreate: (input) => connectionsCreate(input),
2470+ httpClientLayer: config.httpClientLayer,
24682471 });
24692472 connectionProviders.set(oauthBundle.connectionProvider.key, oauthBundle.connectionProvider);
24702473
@@ -2502,6 +2505,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = []> · 31 unmodified lines
25022505 const ctx: PluginCtx<unknown> = {
25032506 scopes,
25042507 storage,
2508+ httpClientLayer: config.httpClientLayer ?? FetchHttpClient.layer,
25052509 core: {
25062510 sources: {
25072511 register: (input: SourceInput) =>
packages/core/sdk/src/hosted-http-client.test.ts+69−0
@@ -0,0 +1,69 @@
1+import { describe, expect, it } from "@effect/vitest";
2+import { Effect, Result } from "effect";
3+import { HttpClient, HttpClientRequest } from "effect/unstable/http";
4+
5+import {
6+ HostedOutboundRequestBlocked,
7+ makeHostedHttpClientLayer,
8+ validateHostedOutboundUrl,
9+} from "./hosted-http-client";
10+
11+describe("hosted outbound HTTP client", () => {
12+ it.effect("allows public HTTP and HTTPS URLs", () =>
13+ Effect.gen(function* () {
14+ yield* validateHostedOutboundUrl("https://example.com/openapi.json");
15+ yield* validateHostedOutboundUrl("http://example.com/graphql");
16+ }),
17+ );
18+
19+ it.effect("rejects local and private network URLs", () =>
20+ Effect.gen(function* () {
21+ for (const url of [
22+ "http://localhost:3000",
23+ "http://127.0.0.1:3000",
24+ "http://10.0.0.1/openapi.json",
25+ "http://172.16.0.1/graphql",
26+ "http://192.168.1.10/mcp",
27+ "http://169.254.169.254/latest/meta-data/",
28+ ]) {
29+ const result = yield* validateHostedOutboundUrl(url).pipe(Effect.result);
30+ expect(Result.isFailure(result)).toBe(true);
31+ if (Result.isFailure(result)) {
32+ expect(result.failure).toBeInstanceOf(HostedOutboundRequestBlocked);
33+ }
34+ }
35+ }),
36+ );
37+
38+ it.effect("can allow local network URLs explicitly", () =>
39+ Effect.gen(function* () {
40+ yield* validateHostedOutboundUrl("http://127.0.0.1:3000", {
41+ allowLocalNetwork: true,
42+ });
43+ }),
44+ );
45+
46+ it.effect("checks redirected URLs before following them", () =>
47+ Effect.gen(function* () {
48+ let calls = 0;
49+ const fakeFetch: typeof globalThis.fetch = (async (input) => {
50+ calls++;
51+ const url = input instanceof Request ? input.url : String(input);
52+ if (url === "https://public.example/start") {
53+ return new Response(null, {
54+ status: 302,
55+ headers: { location: "http://127.0.0.1:3000/internal" },
56+ });
57+ }
58+ return new Response("unexpected", { status: 200 });
59+ }) as typeof globalThis.fetch;
60+ const result = yield* Effect.gen(function* () {
61+ const client = yield* HttpClient.HttpClient;
62+ return yield* client.execute(HttpClientRequest.get("https://public.example/start"));
63+ }).pipe(Effect.provide(makeHostedHttpClientLayer({ fetch: fakeFetch })), Effect.result);
64+
65+ expect(Result.isFailure(result)).toBe(true);
66+ expect(calls).toBe(1);
67+ }),
68+ );
69+});
packages/core/sdk/src/hosted-http-client.ts+135−0
@@ -0,0 +1,135 @@
1+import { Effect, Layer, Schema } from "effect";
2+import { FetchHttpClient, HttpClient } from "effect/unstable/http";
3+
4+export class HostedOutboundRequestBlocked extends Schema.TaggedErrorClass<HostedOutboundRequestBlocked>()(
5+ "HostedOutboundRequestBlocked",
6+ {
7+ url: Schema.String,
8+ reason: Schema.String,
9+ },
10+) {}
11+
12+export interface HostedHttpClientOptions {
13+ readonly allowLocalNetwork?: boolean;
14+ readonly maxRedirects?: number;
15+ readonly fetch?: typeof globalThis.fetch;
16+}
17+
18+const parseIpv4 = (hostname: string): readonly [number, number, number, number] | null => {
19+ const parts = hostname.split(".");
20+ if (parts.length !== 4) return null;
21+ const parsed: number[] = [];
22+ for (const part of parts) {
23+ if (!/^\d+$/.test(part)) return null;
24+ const value = Number(part);
25+ if (!Number.isInteger(value) || value < 0 || value > 255) return null;
26+ parsed.push(value);
27+ }
28+ return parsed as [number, number, number, number];
29+};
30+
31+const isPrivateIpv4 = ([a, b]: readonly [number, number, number, number]): boolean =>
32+ a === 0 ||
33+ a === 10 ||
34+ a === 127 ||
35+ (a === 169 && b === 254) ||
36+ (a === 172 && b >= 16 && b <= 31) ||
37+ (a === 192 && b === 168);
38+
39+const isBlockedMetadataHostname = (hostname: string): boolean => {
40+ const normalized = hostname.toLowerCase();
41+ return (
42+ normalized === "metadata.google.internal" ||
43+ normalized === "metadata" ||
44+ normalized === "instance-data" ||
45+ normalized === "169.254.169.254"
46+ );
47+};
48+
49+const isLocalOrPrivateHostname = (hostname: string): boolean => {
50+ const normalized = hostname.toLowerCase().replace(/^\[|\]$/g, "");
51+ if (normalized === "localhost" || normalized.endsWith(".localhost")) return true;
52+ const ipv4 = parseIpv4(normalized);
53+ if (ipv4) return isPrivateIpv4(ipv4);
54+ return (
55+ normalized === "::1" ||
56+ normalized.startsWith("fe80:") ||
57+ normalized.startsWith("fc") ||
58+ normalized.startsWith("fd")
59+ );
60+};
61+
62+export const validateHostedOutboundUrl = (
63+ value: string,
64+ options: HostedHttpClientOptions = {},
65+): Effect.Effect<void, HostedOutboundRequestBlocked> =>
66+ Effect.gen(function* () {
67+ const url = yield* Effect.try({
68+ try: () => new URL(value),
69+ catch: () =>
70+ new HostedOutboundRequestBlocked({
71+ url: value,
72+ reason: "URL is invalid",
73+ }),
74+ });
75+
76+ if (url.protocol !== "http:" && url.protocol !== "https:") {
77+ return yield* new HostedOutboundRequestBlocked({
78+ url: value,
79+ reason: "Only HTTP and HTTPS outbound requests are allowed",
80+ });
81+ }
82+
83+ if (isBlockedMetadataHostname(url.hostname)) {
84+ return yield* new HostedOutboundRequestBlocked({
85+ url: value,
86+ reason: "Metadata service addresses are not allowed",
87+ });
88+ }
89+
90+ if (!options.allowLocalNetwork && isLocalOrPrivateHostname(url.hostname)) {
91+ return yield* new HostedOutboundRequestBlocked({
92+ url: value,
93+ reason: "Local and private network addresses are not allowed",
94+ });
95+ }
96+ });
97+
98+const guardFetch = (
99+ fetch: typeof globalThis.fetch,
100+ options: HostedHttpClientOptions,
101+): typeof globalThis.fetch =>
102+ (async (input, init) => {
103+ const maxRedirects = options.maxRedirects ?? 10;
104+ let current: Parameters<typeof globalThis.fetch>[0] | URL = input;
105+ for (let redirects = 0; redirects <= maxRedirects; redirects++) {
106+ const url = current instanceof Request ? current.url : String(current);
107+ Effect.runSync(validateHostedOutboundUrl(url, options));
108+ const response = await fetch(current, { ...init, redirect: "manual" });
109+ if (
110+ response.status >= 300 &&
111+ response.status < 400 &&
112+ response.headers.has("location") &&
113+ redirects < maxRedirects
114+ ) {
115+ current = new URL(response.headers.get("location")!, url).toString();
116+ continue;
117+ }
118+ return response;
119+ }
120+ return fetch(current, { ...init, redirect: "manual" });
121+ }) as typeof globalThis.fetch;
122+
123+export const makeHostedHttpClientLayer = (
124+ options: HostedHttpClientOptions = {},
125+): Layer.Layer<HttpClient.HttpClient> =>
126+ FetchHttpClient.layer.pipe(
127+ Layer.provide(
128+ options.fetch
129+ ? Layer.succeed(FetchHttpClient.Fetch)(guardFetch(options.fetch, options))
130+ : Layer.effect(
131+ FetchHttpClient.Fetch,
132+ Effect.map(Effect.service(FetchHttpClient.Fetch), (fetch) => guardFetch(fetch, options)),
133+ ),
134+ ),
135+ );
packages/core/sdk/src/index.ts+7−0
@@ -232,6 +232,13 @@ export { · 231 unmodified lines
232232 export { makeOAuth2Service, type OAuthServiceDeps } from "./oauth-service";
233233 export type { ScopedDBAdapter, ScopedTypedAdapter } from "./scoped-adapter";
234234
235+export {
236+ HostedOutboundRequestBlocked,
237+ makeHostedHttpClientLayer,
238+ validateHostedOutboundUrl,
239+ type HostedHttpClientOptions,
240+} from "./hosted-http-client";
241+
235242 export {
236243 OAuthDiscoveryError,
237244 OAuthAuthorizationServerMetadataSchema,
packages/core/sdk/src/oauth-service.ts+8−3
@@ -35,7 +35,7 @@ · 34 unmodified lines
3535 // every strategy because refresh semantics are strategy-independent.
3636 // ---------------------------------------------------------------------------
3737
38−import { Duration, Effect, Option, Schema } from "effect";
38+import { Duration, Effect, Layer, Option, Schema } from "effect";
3939 import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http";
4040
4141 import type { StorageFailure, TypedAdapter } from "@executor-js/storage-core";
@@ -183,6 +183,8 @@ export interface OAuthServiceDeps { · 141 unmodified lines
183183 readonly newSessionId?: () => string;
184184 /** `Date.now()` substitute — tests override to drive TTL behavior. */
185185 readonly now?: () => number;
186+ /** Outbound HTTP client used for OAuth metadata/DCR probes. */
187+ readonly httpClientLayer?: Layer.Layer<HttpClient.HttpClient>;
186188 }
187189
188190 const defaultSessionId = (): string => {
@@ -225,6 +227,7 @@ export const makeOAuth2Service = ( · 36 unmodified lines
225227 ): { readonly service: OAuthService; readonly connectionProvider: ConnectionProvider } => {
226228 const now = deps.now ?? (() => Date.now());
227229 const newSessionId = deps.newSessionId ?? defaultSessionId;
230+ const httpClientLayer = deps.httpClientLayer;
228231
229232 // -------------------------------------------------------------------
230233 // probe
@@ -232,6 +235,7 @@ export const makeOAuth2Service = ( · 1 unmodified line
232235 const probe = (input: OAuthProbeInput): Effect.Effect<OAuthProbeResult, OAuthProbeError> =>
233236 Effect.gen(function* () {
234237 const resource = yield* discoverProtectedResourceMetadata(input.endpoint, {
238+ httpClientLayer,
235239 resourceHeaders: input.headers,
236240 resourceQueryParams: input.queryParams,
237241 }).pipe(
@@ -257,7 +261,7 @@ export const makeOAuth2Service = ( · 19 unmodified lines
257261 })();
258262
259263 const authServer = authorizationServerUrl
260− ? yield* discoverAuthorizationServerMetadata(authorizationServerUrl).pipe(
264+ ? yield* discoverAuthorizationServerMetadata(authorizationServerUrl, { httpClientLayer }).pipe(
261265 Effect.catchTag("OAuthDiscoveryError", () => Effect.succeed(null)),
262266 )
263267 : null;
@@ -312,7 +316,7 @@ export const makeOAuth2Service = ( · 48 unmodified lines
312316 response.headers["www-authenticate"] ?? response.headers["WWW-Authenticate"];
313317 return !!wwwAuth && /^\s*bearer\b/i.test(wwwAuth);
314318 }).pipe(
315− Effect.provide(FetchHttpClient.layer),
319+ Effect.provide(httpClientLayer ?? FetchHttpClient.layer),
316320 Effect.catch(() => Effect.succeed(false)),
317321 );
318322
@@ -344,6 +348,7 @@ export const makeOAuth2Service = ( · 25 unmodified lines
344348 scopes: strategy.scopes,
345349 },
346350 {
351+ httpClientLayer,
347352 resourceHeaders: input.headers,
348353 resourceQueryParams: input.queryParams,
349354 },
packages/core/sdk/src/plugin.ts+2−0
@@ -1,4 +1,5 @@
11 import type { Context, Effect, Layer } from "effect";
2+import type { HttpClient } from "effect/unstable/http";
23 import type { HttpApiGroup } from "effect/unstable/httpapi";
34 import type { DBSchema, StorageFailure } from "@executor-js/storage-core";
45
@@ -102,6 +103,7 @@ export interface PluginCtx<TStore = unknown> { · 97 unmodified lines
102103 */
103104 readonly scopes: readonly Scope[];
104105 readonly storage: TStore;
106+ readonly httpClientLayer: Layer.Layer<HttpClient.HttpClient>;
105107
106108 readonly core: {
107109 readonly sources: {
packages/plugins/graphql/src/sdk/plugin.ts+6−5
@@ -1,6 +1,6 @@
11 import { Effect, Option, Schema } from "effect";
22 import type { Layer } from "effect";
3−import { FetchHttpClient, HttpClient } from "effect/unstable/http";
3+import { HttpClient } from "effect/unstable/http";
44
55 import { GraphqlGroup } from "../api/group";
66 import { GraphqlExtensionService, GraphqlHandlers } from "../api/handlers";
@@ -916,15 +916,14 @@ const makeGraphqlExtension = ( · 909 unmodified lines
916916 export type GraphqlPluginExtension = ReturnType<typeof makeGraphqlExtension>;
917917
918918 export const graphqlPlugin = definePlugin((options?: GraphqlPluginOptions) => {
919− const httpClientLayer = options?.httpClientLayer ?? FetchHttpClient.layer;
920−
921919 return {
922920 id: "graphql" as const,
923921 packageName: "@executor-js/plugin-graphql",
924922 schema: graphqlSchema,
925923 storage: (deps): GraphqlStore => makeDefaultGraphqlStore(deps),
926924
927− extension: (ctx) => makeGraphqlExtension(ctx, httpClientLayer, options?.configFile),
925+ extension: (ctx) =>
926+ makeGraphqlExtension(ctx, options?.httpClientLayer ?? ctx.httpClientLayer, options?.configFile),
928927
929928 staticSources: (self) => [
930929 {
@@ -969,6 +968,7 @@ export const graphqlPlugin = definePlugin((options?: GraphqlPluginOptions) => { · 38 unmodified lines
969968
970969 invokeTool: ({ ctx, toolRow, args }) =>
971970 Effect.gen(function* () {
971+ const httpClientLayer = options?.httpClientLayer ?? ctx.httpClientLayer;
972972 // toolRow.scope_id is the resolved owning scope of the tool
973973 // (innermost-wins from the executor's stack). The matching
974974 // graphql_operation + graphql_source rows live at the same
@@ -1081,8 +1081,9 @@ export const graphqlPlugin = definePlugin((options?: GraphqlPluginOptions) => { · 106 unmodified lines
10811081
10821082 usagesForConnection: () => Effect.succeed([]),
10831083
1084− detect: ({ url }) =>
1084+ detect: ({ ctx, url }) =>
10851085 Effect.gen(function* () {
1086+ const httpClientLayer = options?.httpClientLayer ?? ctx.httpClientLayer;
10861087 const trimmed = url.trim();
10871088 if (!trimmed) return null;
10881089 const parsed = yield* Effect.try({
packages/plugins/mcp/src/sdk/plugin.ts+17−2
@@ -1,4 +1,15 @@
1−import { Duration, Effect, Exit, Option, Predicate, Result, Scope, ScopedCache } from "effect";
1+import {
2+ Duration,
3+ Effect,
4+ Exit,
5+ Layer,
6+ Option,
7+ Predicate,
8+ Result,
9+ Scope,
10+ ScopedCache,
11+} from "effect";
12+import type { HttpClient } from "effect/unstable/http";
213
314 import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js";
415
@@ -848,6 +859,7 @@ export interface McpPluginOptions { · 843 unmodified lines
848859 * `process.env`. Only enable for trusted single-user contexts.
849860 */
850861 readonly dangerouslyAllowStdioMCP?: boolean;
862+ readonly httpClientLayer?: Layer.Layer<HttpClient.HttpClient>;
851863 /** If provided, source add/remove is mirrored to executor.jsonc
852864 * (best-effort — file errors are logged, not raised). */
853865 readonly configFile?: ConfigFileSink;
@@ -937,6 +949,7 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 83 unmodified lines
937949 storage: (deps): McpBindingStore => makeMcpStore(deps),
938950
939951 extension: (ctx) => {
952+ const httpClientLayer = options?.httpClientLayer ?? ctx.httpClientLayer;
940953 const probeEndpoint = (input: string | McpProbeEndpointInput) =>
941954 Effect.gen(function* () {
942955 const endpoint = typeof input === "string" ? input : input.endpoint;
@@ -994,6 +1007,7 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 51 unmodified lines
9941007 // probe and be misclassified as MCP. The shape probe rejects
9951008 // anything whose initialize response isn't 2xx or 401+Bearer.
9961009 const shape = yield* probeMcpEndpointShape(trimmed, {
1010+ httpClientLayer,
9971011 headers: probeHeaders,
9981012 queryParams: probeQueryParams,
9991013 });
@@ -1575,6 +1589,7 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 575 unmodified lines
15751589
15761590 detect: ({ ctx, url }) =>
15771591 Effect.gen(function* () {
1592+ const httpClientLayer = options?.httpClientLayer ?? ctx.httpClientLayer;
15781593 const trimmed = url.trim();
15791594 if (!trimmed) return null;
15801595
@@ -1610,7 +1625,7 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 29 unmodified lines
16101625
16111626 // host publishes RFC 9728 + 8414 metadata) would be classified
16121627 // as MCP whenever the cross-plugin detector fans out to us.
1613− const shape = yield* probeMcpEndpointShape(trimmed);
1628+ const shape = yield* probeMcpEndpointShape(trimmed, { httpClientLayer });
16141629 if (shape.kind !== "mcp") return null;
16151630
16161631 // Confirm OAuth metadata is actually reachable. The shape
packages/plugins/mcp/src/sdk/probe-shape.ts+3−2
@@ -30,7 +30,7 @@ · 29 unmodified lines
3030 // round-trip, no DCR — every non-MCP endpoint exits here.
3131 // ---------------------------------------------------------------------------
3232
33−import { Data, Duration, Effect, Option, Schema } from "effect";
33+import { Data, Duration, Effect, Layer, Option, Schema } from "effect";
3434 import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http";
3535
3636 /** MCP initialize request body used as the shape probe. Any real MCP
@@ -97,6 +97,7 @@ export type McpShapeProbeResult = · 60 unmodified lines
9797 export interface ProbeOptions {
9898 /** Abort the request after this many ms. Default 8000. */
9999 readonly timeoutMs?: number;
100+ readonly httpClientLayer?: Layer.Layer<HttpClient.HttpClient>;
100101 readonly headers?: Record<string, string>;
101102 readonly queryParams?: Record<string, string>;
102103 }
@@ -191,7 +192,7 @@ export const probeMcpEndpointShape = ( · 88 unmodified lines
191192 reason: `unexpected status ${postResponse.status} for initialize`,
192193 } as const;
193194 }).pipe(
194− Effect.provide(FetchHttpClient.layer),
195+ Effect.provide(options.httpClientLayer ?? FetchHttpClient.layer),
195196 Effect.mapError(
196197 (cause) =>
197198 new ProbeTransportError({
packages/plugins/openapi/src/sdk/plugin.ts+6−4
@@ -1,6 +1,6 @@
11 import { Effect, Option, Predicate, Schema } from "effect";
22 import type { Layer } from "effect";
3−import { FetchHttpClient, HttpClient } from "effect/unstable/http";
3+import { HttpClient } from "effect/unstable/http";
44
55 import { OpenApiGroup } from "../api/group";
66 import { OpenApiExtensionService, OpenApiHandlers } from "../api/handlers";
@@ -754,8 +754,6 @@ const toOpenApiSourceConfig = ( · 747 unmodified lines
754754 const isHttpUrl = (s: string): boolean => s.startsWith("http://") || s.startsWith("https://");
755755
756756 export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => {
757− const httpClientLayer = options?.httpClientLayer ?? FetchHttpClient.layer;
758−
759757 type RebuildInput = {
760758 readonly specText: string;
761759 readonly scope: string;
@@ -902,6 +900,7 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 140 unmodified lines
902900 // than surface a 500 through the unwhitelisted error channel.
903901 const refreshSourceInternal = (ctx: PluginCtx<OpenapiStore>, sourceId: string, scope: string) =>
904902 Effect.gen(function* () {
903+ const httpClientLayer = options?.httpClientLayer ?? ctx.httpClientLayer;
905904 const existing = yield* ctx.storage.getSource(sourceId, scope);
906905 if (!existing) return;
907906 const effective = yield* resolveEffectiveSourceConfig(ctx, existing);
@@ -938,6 +937,7 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 30 unmodified lines
938937 storage: (deps): OpenapiStore => makeDefaultOpenapiStore(deps),
939938
940939 extension: (ctx) => {
940+ const httpClientLayer = options?.httpClientLayer ?? ctx.httpClientLayer;
941941 const addSpecInternal = (config: OpenApiSpecConfig) =>
942942 Effect.gen(function* () {
943943 // Resolve URL → text and parse BEFORE opening a transaction.
@@ -1173,6 +1173,7 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 229 unmodified lines
11731173
11741174 invokeTool: ({ ctx, toolRow, args }) =>
11751175 Effect.gen(function* () {
1176+ const httpClientLayer = options?.httpClientLayer ?? ctx.httpClientLayer;
11761177 // toolRow.scope_id is the resolved owning scope of the tool
11771178 // (innermost-wins from the executor's stack). The matching
11781179 // openapi_operation + openapi_source rows live at the same
@@ -1313,8 +1314,9 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 134 unmodified lines
13131314 // means stale UI state, which is worth surfacing to the caller.
13141315 refreshSource: ({ ctx, sourceId, scope }) => refreshSourceInternal(ctx, sourceId, scope),
13151316
1316− detect: ({ url }) =>
1317+ detect: ({ ctx, url }) =>
13171318 Effect.gen(function* () {
1319+ const httpClientLayer = options?.httpClientLayer ?? ctx.httpClientLayer;
13181320 const trimmed = url.trim();
13191321 if (!trimmed) return null;
13201322 const parsed = yield* Effect.try({