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.
Unified · Split
| @@ -37,10 +37,15 @@ interface CloudPluginDeps { · 36 unmodified lines | |||
| 37 | 37 | ||
| 38 | export default defineExecutorConfig({ | 38 | export default defineExecutorConfig({ |
| 39 | dialect: "pg", | 39 | dialect: "pg", |
| 40 | plugins: ({ workosCredentials, workosVaultClient }: CloudPluginDeps = {}) => | 40 | plugins: ({ |
| 41 | workosCredentials, | ||
| 42 | workosVaultClient, | ||
| 43 | }: CloudPluginDeps = {}) => | ||
| 41 | [ | 44 | [ |
| 42 | openApiPlugin(), | 45 | openApiPlugin(), |
| 43 | mcpPlugin({ dangerouslyAllowStdioMCP: false }), | 46 | mcpPlugin({ |
| 47 | dangerouslyAllowStdioMCP: false, | ||
| 48 | }), | ||
| 44 | graphqlPlugin(), | 49 | graphqlPlugin(), |
| 45 | workosVaultPlugin({ | 50 | workosVaultPlugin({ |
| 46 | credentials: workosCredentials ?? { apiKey: "", clientId: "" }, | 51 | credentials: workosCredentials ?? { apiKey: "", clientId: "" }, |
| @@ -11,9 +11,9 @@ · 10 unmodified lines | |||
| 11 | "db:schema": "node --import jiti/register ../../packages/core/cli/src/index.ts generate --config ./executor.config.ts --output ./src/services/executor-schema.ts", | 11 | "db:schema": "node --import jiti/register ../../packages/core/cli/src/index.ts generate --config ./executor.config.ts --output ./src/services/executor-schema.ts", |
| 12 | "db:generate": "drizzle-kit generate", | 12 | "db:generate": "drizzle-kit generate", |
| 13 | "db:studio": "drizzle-kit studio", | 13 | "db:studio": "drizzle-kit studio", |
| 14 | "db:studio:prod": "op run --env-file=.env.production -- drizzle-kit studio", | 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 -- drizzle-kit migrate", | 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 -- drizzle-kit migrate", | 16 | "db:migrate:dev": "op run --env-file=.env.op -- bun --bun ../../node_modules/.bun/node_modules/drizzle-kit/bin.cjs migrate", |
| 17 | "build": "node scripts/build.mjs", | 17 | "build": "node scripts/build.mjs", |
| 18 | "preview": "vite preview", | 18 | "preview": "vite preview", |
| 19 | "deploy": "op run --env-file=.env.production -- bun run build && op run --env-file=.env.production -- wrangler deploy", | 19 | "deploy": "op run --env-file=.env.production -- bun run build && op run --env-file=.env.production -- wrangler deploy", |
| @@ -33,6 +33,7 @@ import { · 32 unmodified lines | |||
| 33 | createExecutor, | 33 | createExecutor, |
| 34 | definePlugin, | 34 | definePlugin, |
| 35 | } from "@executor-js/sdk"; | 35 | } from "@executor-js/sdk"; |
| 36 | import { FetchHttpClient } from "effect/unstable/http"; | ||
| 36 | import { makePostgresAdapter, makePostgresBlobStore } from "@executor-js/storage-postgres"; | 37 | import { makePostgresAdapter, makePostgresBlobStore } from "@executor-js/storage-postgres"; |
| 37 | import { makeTestWorkOSVaultClient } from "@executor-js/plugin-workos-vault/testing"; | 38 | import { makeTestWorkOSVaultClient } from "@executor-js/plugin-workos-vault/testing"; |
| 38 | import executorConfig from "../executor.config"; | 39 | import executorConfig from "../executor.config"; |
| @@ -117,6 +118,7 @@ const buildScopedExecutor = (scopeId: string, scopeName: string, options: BuildO · 78 unmodified lines | |||
| 117 | adapter, | 118 | adapter, |
| 118 | blobs, | 119 | blobs, |
| 119 | plugins, | 120 | plugins, |
| 121 | httpClientLayer: FetchHttpClient.layer, | ||
| 120 | onElicitation: "accept-all", | 122 | onElicitation: "accept-all", |
| 121 | }); | 123 | }); |
| 122 | }); | 124 | }); |
| @@ -26,7 +26,12 @@ import { · 25 unmodified lines | |||
| 26 | } from "@executor-js/api/server"; | 26 | } from "@executor-js/api/server"; |
| 27 | import { createExecutionEngine } from "@executor-js/execution"; | 27 | import { createExecutionEngine } from "@executor-js/execution"; |
| 28 | import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs"; | 28 | 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"; | ||
| 30 | import { makePostgresAdapter, makePostgresBlobStore } from "@executor-js/storage-postgres"; | 35 | import { makePostgresAdapter, makePostgresBlobStore } from "@executor-js/storage-postgres"; |
| 31 | import { makeTestWorkOSVaultClient } from "@executor-js/plugin-workos-vault/testing"; | 36 | import { makeTestWorkOSVaultClient } from "@executor-js/plugin-workos-vault/testing"; |
| 32 | 37 | ||
| @@ -61,7 +66,10 @@ const defaultUserFor = (orgId: string) => `default_user_${orgId}`; · 28 unmodified lines | |||
| 61 | // --------------------------------------------------------------------------- | 66 | // --------------------------------------------------------------------------- |
| 62 | 67 | ||
| 63 | const fakeVault = makeTestWorkOSVaultClient(); | 68 | const fakeVault = makeTestWorkOSVaultClient(); |
| 64 | const testPlugins = executorConfig.plugins({ workosVaultClient: fakeVault }); | 69 | const testPlugins = executorConfig.plugins({ |
| 70 | workosVaultClient: fakeVault, | ||
| 71 | }); | ||
| 72 | const testHttpClientLayer = FetchHttpClient.layer; | ||
| 65 | 73 | ||
| 66 | const createTestScopedExecutor = (userId: string, orgId: string, orgName: string) => | 74 | const createTestScopedExecutor = (userId: string, orgId: string, orgName: string) => |
| 67 | Effect.gen(function* () { | 75 | Effect.gen(function* () { |
| @@ -85,6 +93,7 @@ const createTestScopedExecutor = (userId: string, orgId: string, orgName: string · 17 unmodified lines | |||
| 85 | adapter, | 93 | adapter, |
| 86 | blobs, | 94 | blobs, |
| 87 | plugins, | 95 | plugins, |
| 96 | httpClientLayer: testHttpClientLayer, | ||
| 88 | onElicitation: "accept-all", | 97 | onElicitation: "accept-all", |
| 89 | }); | 98 | }); |
| 90 | }); | 99 | }); |
| @@ -10,7 +10,13 @@ · 9 unmodified lines | |||
| 10 | 10 | ||
| 11 | import { Effect } from "effect"; | 11 | import { Effect } from "effect"; |
| 12 | 12 | ||
| 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"; | ||
| 14 | import { makePostgresAdapter, makePostgresBlobStore } from "@executor-js/storage-postgres"; | 20 | import { makePostgresAdapter, makePostgresBlobStore } from "@executor-js/storage-postgres"; |
| 15 | 21 | ||
| 16 | import { env } from "cloudflare:workers"; | 22 | import { env } from "cloudflare:workers"; |
| @@ -58,6 +64,7 @@ export const createScopedExecutor = ( · 41 unmodified lines | |||
| 58 | const { db } = yield* DbService; | 64 | const { db } = yield* DbService; |
| 59 | 65 | ||
| 60 | const plugins = orgPlugins(); | 66 | const plugins = orgPlugins(); |
| 67 | const httpClientLayer = makeHostedHttpClientLayer(); | ||
| 61 | const schema = collectSchemas(plugins); | 68 | const schema = collectSchemas(plugins); |
| 62 | const adapter = makePostgresAdapter({ db, schema }); | 69 | const adapter = makePostgresAdapter({ db, schema }); |
| 63 | const blobs = makePostgresBlobStore({ db }); | 70 | const blobs = makePostgresBlobStore({ db }); |
| @@ -82,6 +89,7 @@ export const createScopedExecutor = ( · 18 unmodified lines | |||
| 82 | adapter, | 89 | adapter, |
| 83 | blobs, | 90 | blobs, |
| 84 | plugins, | 91 | plugins, |
| 92 | httpClientLayer, | ||
| 85 | onElicitation: "accept-all", | 93 | onElicitation: "accept-all", |
| 86 | }); | 94 | }); |
| 87 | }); | 95 | }); |
| @@ -27,10 +27,10 @@ import { createServer, type Server } from "node:http"; · 26 unmodified lines | |||
| 27 | import type { AddressInfo } from "node:net"; | 27 | import type { AddressInfo } from "node:net"; |
| 28 | import { createHash, randomBytes } from "node:crypto"; | 28 | import { createHash, randomBytes } from "node:crypto"; |
| 29 | 29 | ||
| 30 | import { Effect, Option, Schema } from "effect"; | 30 | import { Effect, Option, Result, Schema } from "effect"; |
| 31 | import { ScopeId } from "@executor-js/sdk"; | 31 | import { ScopeId } from "@executor-js/sdk"; |
| 32 | 32 | ||
| 33 | import { asUser, testUserOrgScopeId } from "./__test-harness__/api-harness"; | 33 | import { asOrg, asUser, testUserOrgScopeId } from "./__test-harness__/api-harness"; |
| 34 | 34 | ||
| 35 | // --------------------------------------------------------------------------- | 35 | // --------------------------------------------------------------------------- |
| 36 | // Fake OAuth + MCP server | 36 | // Fake OAuth + MCP server |
| @@ -262,6 +262,67 @@ afterAll(async () => { · 225 unmodified lines | |||
| 262 | // --------------------------------------------------------------------------- | 262 | // --------------------------------------------------------------------------- |
| 263 | 263 | ||
| 264 | describe("mcp oauth end-to-end (node pool, real OAuth + MCP server)", () => { | 264 | 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 | |||
| 265 | it.effect( | 326 | it.effect( |
| 266 | "startOAuth → authorize → completeOAuth writes tokens at the invoker scope", | 327 | "startOAuth → authorize → completeOAuth writes tokens at the invoker scope", |
| 267 | () => | 328 | () => |
| @@ -9,24 +9,27 @@ import { ScopeId, SecretId } from "@executor-js/sdk"; · 8 unmodified lines | |||
| 9 | import { asOrg, fetchForOrg, TEST_BASE_URL } from "./__test-harness__/api-harness"; | 9 | import { asOrg, fetchForOrg, TEST_BASE_URL } from "./__test-harness__/api-harness"; |
| 10 | 10 | ||
| 11 | describe("secrets api (HTTP)", () => { | 11 | 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", () => |
| 13 | Effect.gen(function* () { | 13 | Effect.gen(function* () { |
| 14 | const org = `org_${crypto.randomUUID()}`; | 14 | const org = `org_${crypto.randomUUID()}`; |
| 15 | const id = `sec_${crypto.randomUUID().slice(0, 8)}`; | 15 | const id = `sec_${crypto.randomUUID().slice(0, 8)}`; |
| 16 | 16 | ||
| 17 | const secretValue = "sk-test-abc"; | ||
| 17 | const setRef = yield* asOrg(org, (client) => | 18 | const setRef = yield* asOrg(org, (client) => |
| 18 | client.secrets.set({ | 19 | client.secrets.set({ |
| 19 | params: { scopeId: ScopeId.make(org) }, | 20 | 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 }, |
| 21 | }), | 22 | }), |
| 22 | ); | 23 | ); |
| 23 | expect(setRef.id).toBe(id); | 24 | expect(setRef.id).toBe(id); |
| 24 | expect(setRef.scopeId).toBe(org); | 25 | expect(setRef.scopeId).toBe(org); |
| 26 | expect(JSON.stringify(setRef)).not.toContain(secretValue); | ||
| 25 | 27 | ||
| 26 | const list = yield* asOrg(org, (client) => | 28 | const list = yield* asOrg(org, (client) => |
| 27 | client.secrets.list({ params: { scopeId: ScopeId.make(org) } }), | 29 | client.secrets.list({ params: { scopeId: ScopeId.make(org) } }), |
| 28 | ); | 30 | ); |
| 29 | expect(list.find((s) => s.id === id)?.name).toBe("My API Token"); | 31 | expect(list.find((s) => s.id === id)?.name).toBe("My API Token"); |
| 32 | expect(JSON.stringify(list)).not.toContain(secretValue); | ||
| 30 | 33 | ||
| 31 | const status = yield* asOrg(org, (client) => | 34 | const status = yield* asOrg(org, (client) => |
| 32 | client.secrets.status({ | 35 | client.secrets.status({ |
| @@ -37,7 +40,7 @@ describe("secrets api (HTTP)", () => { · 4 unmodified lines | |||
| 37 | }), | 40 | }), |
| 38 | ); | 41 | ); |
| 39 | 42 | ||
| 40 | it.effect("does not expose a plaintext resolve endpoint", () => | 43 | it.effect("resolve is not available through the public API", () => |
| 41 | Effect.gen(function* () { | 44 | Effect.gen(function* () { |
| 42 | const org = `org_${crypto.randomUUID()}`; | 45 | const org = `org_${crypto.randomUUID()}`; |
| 43 | const id = `sec_${crypto.randomUUID().slice(0, 8)}`; | 46 | const id = `sec_${crypto.randomUUID().slice(0, 8)}`; |
| @@ -373,6 +373,26 @@ describe("sources api (HTTP)", () => { · 372 unmodified lines | |||
| 373 | }), | 373 | }), |
| 374 | ); | 374 | ); |
| 375 | 375 | ||
| 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 | |||
| 376 | it.effect("added OpenAPI source can be listed, inspected, and invoked through execution", () => | 396 | it.effect("added OpenAPI source can be listed, inspected, and invoked through execution", () => |
| 377 | Effect.gen(function* () { | 397 | Effect.gen(function* () { |
| 378 | const server = yield* Effect.acquireRelease( | 398 | const server = yield* Effect.acquireRelease( |
| @@ -3,9 +3,9 @@ · 2 unmodified lines | |||
| 3 | // on the full cloud module graph. | 3 | // on the full cloud module graph. |
| 4 | 4 | ||
| 5 | import { describe, expect, it } from "@effect/vitest"; | 5 | import { describe, expect, it } from "@effect/vitest"; |
| 6 | import { Effect } from "effect"; | 6 | import { Effect, Result } from "effect"; |
| 7 | 7 | ||
| 8 | import { ScopeId, SecretId } from "@executor-js/sdk"; | 8 | import { ConnectionId, ScopeId, SecretId } from "@executor-js/sdk"; |
| 9 | 9 | ||
| 10 | import { asOrg } from "./__test-harness__/api-harness"; | 10 | import { asOrg } from "./__test-harness__/api-harness"; |
| 11 | 11 | ||
| @@ -23,7 +23,80 @@ const MINIMAL_OPENAPI_SPEC = JSON.stringify({ · 11 unmodified lines | |||
| 23 | }); | 23 | }); |
| 24 | 24 | ||
| 25 | describe("tenant isolation (HTTP)", () => { | 25 | 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", () => | ||
| 27 | Effect.gen(function* () { | 100 | Effect.gen(function* () { |
| 28 | const orgA = `org_${crypto.randomUUID()}`; | 101 | const orgA = `org_${crypto.randomUUID()}`; |
| 29 | const orgB = `org_${crypto.randomUUID()}`; | 102 | const orgB = `org_${crypto.randomUUID()}`; |
| @@ -47,7 +120,7 @@ describe("tenant isolation (HTTP)", () => { · 17 unmodified lines | |||
| 47 | }), | 120 | }), |
| 48 | ); | 121 | ); |
| 49 | 122 | ||
| 50 | it.effect("tools.list does not leak across orgs", () => | 123 | it.effect("tools.list is scoped to the caller org", () => |
| 51 | Effect.gen(function* () { | 124 | Effect.gen(function* () { |
| 52 | const orgA = `org_${crypto.randomUUID()}`; | 125 | const orgA = `org_${crypto.randomUUID()}`; |
| 53 | const orgB = `org_${crypto.randomUUID()}`; | 126 | const orgB = `org_${crypto.randomUUID()}`; |
| @@ -68,6 +141,9 @@ describe("tenant isolation (HTTP)", () => { · 14 unmodified lines | |||
| 68 | client.tools.list({ params: { scopeId: ScopeId.make(orgB) } }), | 141 | client.tools.list({ params: { scopeId: ScopeId.make(orgB) } }), |
| 69 | ); | 142 | ); |
| 70 | expect(orgBTools.map((t) => t.sourceId)).not.toContain(namespaceA); | 143 | 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 | } | ||
| 71 | }), | 147 | }), |
| 72 | ); | 148 | ); |
| 73 | 149 | ||
| @@ -98,7 +174,7 @@ describe("tenant isolation (HTTP)", () => { · 24 unmodified lines | |||
| 98 | }), | 174 | }), |
| 99 | ); | 175 | ); |
| 100 | 176 | ||
| 101 | it.effect("secrets.list does not leak across orgs", () => | 177 | it.effect("secrets.list is scoped to the caller org", () => |
| 102 | Effect.gen(function* () { | 178 | Effect.gen(function* () { |
| 103 | const orgA = `org_${crypto.randomUUID()}`; | 179 | const orgA = `org_${crypto.randomUUID()}`; |
| 104 | const orgB = `org_${crypto.randomUUID()}`; | 180 | const orgB = `org_${crypto.randomUUID()}`; |
| @@ -168,6 +244,104 @@ describe("tenant isolation (HTTP)", () => { · 63 unmodified lines | |||
| 168 | }), | 244 | }), |
| 169 | ); | 245 | ); |
| 170 | 246 | ||
| 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 | |||
| 171 | it.effect("updating a same-namespace OpenAPI source in one org does not mutate another org", () => | 345 | it.effect("updating a same-namespace OpenAPI source in one org does not mutate another org", () => |
| 172 | Effect.gen(function* () { | 346 | Effect.gen(function* () { |
| 173 | const orgA = `org_${crypto.randomUUID()}`; | 347 | const orgA = `org_${crypto.randomUUID()}`; |
| @@ -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"; | ||
| 2 | import { generateKeyBetween } from "fractional-indexing"; | 3 | import { generateKeyBetween } from "fractional-indexing"; |
| 3 | import { | 4 | import { |
| 4 | StorageError, | 5 | StorageError, |
| @@ -341,6 +342,7 @@ export interface ExecutorConfig<TPlugins extends readonly AnyPlugin[] = []> { · 336 unmodified lines | |||
| 341 | * an options arg. | 342 | * an options arg. |
| 342 | */ | 343 | */ |
| 343 | readonly onElicitation: OnElicitation; | 344 | readonly onElicitation: OnElicitation; |
| 345 | readonly httpClientLayer?: Layer.Layer<HttpClient.HttpClient>; | ||
| 344 | } | 346 | } |
| 345 | 347 | ||
| 346 | // --------------------------------------------------------------------------- | 348 | // --------------------------------------------------------------------------- |
| @@ -2456,7 +2458,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = []> · 2109 unmodified lines | |||
| 2456 | usagesForConnection: credentialBindingUsagesForConnection, | 2458 | usagesForConnection: credentialBindingUsagesForConnection, |
| 2457 | }; | 2459 | }; |
| 2458 | 2460 | ||
| 2459 | const oauthBundle = makeOAuth2Service({ | 2461 | const oauthBundle = makeOAuth2Service({ |
| 2460 | adapter: core, | 2462 | adapter: core, |
| 2461 | rawAdapter: adapter, | 2463 | rawAdapter: adapter, |
| 2462 | secretsGet: (id) => | 2464 | secretsGet: (id) => |
| @@ -2465,6 +2467,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = []> · 2 unmodified lines | |||
| 2465 | ), | 2467 | ), |
| 2466 | secretsSet: (input) => secretsSet(input), | 2468 | secretsSet: (input) => secretsSet(input), |
| 2467 | connectionsCreate: (input) => connectionsCreate(input), | 2469 | connectionsCreate: (input) => connectionsCreate(input), |
| 2470 | httpClientLayer: config.httpClientLayer, | ||
| 2468 | }); | 2471 | }); |
| 2469 | connectionProviders.set(oauthBundle.connectionProvider.key, oauthBundle.connectionProvider); | 2472 | connectionProviders.set(oauthBundle.connectionProvider.key, oauthBundle.connectionProvider); |
| 2470 | 2473 | ||
| @@ -2502,6 +2505,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = []> · 31 unmodified lines | |||
| 2502 | const ctx: PluginCtx<unknown> = { | 2505 | const ctx: PluginCtx<unknown> = { |
| 2503 | scopes, | 2506 | scopes, |
| 2504 | storage, | 2507 | storage, |
| 2508 | httpClientLayer: config.httpClientLayer ?? FetchHttpClient.layer, | ||
| 2505 | core: { | 2509 | core: { |
| 2506 | sources: { | 2510 | sources: { |
| 2507 | register: (input: SourceInput) => | 2511 | register: (input: SourceInput) => |
| @@ -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 | }); | ||
| @@ -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 | ); | ||
| @@ -232,6 +232,13 @@ export { · 231 unmodified lines | |||
| 232 | export { makeOAuth2Service, type OAuthServiceDeps } from "./oauth-service"; | 232 | export { makeOAuth2Service, type OAuthServiceDeps } from "./oauth-service"; |
| 233 | export type { ScopedDBAdapter, ScopedTypedAdapter } from "./scoped-adapter"; | 233 | export type { ScopedDBAdapter, ScopedTypedAdapter } from "./scoped-adapter"; |
| 234 | 234 | ||
| 235 | export { | ||
| 236 | HostedOutboundRequestBlocked, | ||
| 237 | makeHostedHttpClientLayer, | ||
| 238 | validateHostedOutboundUrl, | ||
| 239 | type HostedHttpClientOptions, | ||
| 240 | } from "./hosted-http-client"; | ||
| 241 | |||
| 235 | export { | 242 | export { |
| 236 | OAuthDiscoveryError, | 243 | OAuthDiscoveryError, |
| 237 | OAuthAuthorizationServerMetadataSchema, | 244 | OAuthAuthorizationServerMetadataSchema, |
| @@ -35,7 +35,7 @@ · 34 unmodified lines | |||
| 35 | // every strategy because refresh semantics are strategy-independent. | 35 | // every strategy because refresh semantics are strategy-independent. |
| 36 | // --------------------------------------------------------------------------- | 36 | // --------------------------------------------------------------------------- |
| 37 | 37 | ||
| 38 | import { Duration, Effect, Option, Schema } from "effect"; | 38 | import { Duration, Effect, Layer, Option, Schema } from "effect"; |
| 39 | import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"; | 39 | import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"; |
| 40 | 40 | ||
| 41 | import type { StorageFailure, TypedAdapter } from "@executor-js/storage-core"; | 41 | import type { StorageFailure, TypedAdapter } from "@executor-js/storage-core"; |
| @@ -183,6 +183,8 @@ export interface OAuthServiceDeps { · 141 unmodified lines | |||
| 183 | readonly newSessionId?: () => string; | 183 | readonly newSessionId?: () => string; |
| 184 | /** `Date.now()` substitute — tests override to drive TTL behavior. */ | 184 | /** `Date.now()` substitute — tests override to drive TTL behavior. */ |
| 185 | readonly now?: () => number; | 185 | readonly now?: () => number; |
| 186 | /** Outbound HTTP client used for OAuth metadata/DCR probes. */ | ||
| 187 | readonly httpClientLayer?: Layer.Layer<HttpClient.HttpClient>; | ||
| 186 | } | 188 | } |
| 187 | 189 | ||
| 188 | const defaultSessionId = (): string => { | 190 | const defaultSessionId = (): string => { |
| @@ -225,6 +227,7 @@ export const makeOAuth2Service = ( · 36 unmodified lines | |||
| 225 | ): { readonly service: OAuthService; readonly connectionProvider: ConnectionProvider } => { | 227 | ): { readonly service: OAuthService; readonly connectionProvider: ConnectionProvider } => { |
| 226 | const now = deps.now ?? (() => Date.now()); | 228 | const now = deps.now ?? (() => Date.now()); |
| 227 | const newSessionId = deps.newSessionId ?? defaultSessionId; | 229 | const newSessionId = deps.newSessionId ?? defaultSessionId; |
| 230 | const httpClientLayer = deps.httpClientLayer; | ||
| 228 | 231 | ||
| 229 | // ------------------------------------------------------------------- | 232 | // ------------------------------------------------------------------- |
| 230 | // probe | 233 | // probe |
| @@ -232,6 +235,7 @@ export const makeOAuth2Service = ( · 1 unmodified line | |||
| 232 | const probe = (input: OAuthProbeInput): Effect.Effect<OAuthProbeResult, OAuthProbeError> => | 235 | const probe = (input: OAuthProbeInput): Effect.Effect<OAuthProbeResult, OAuthProbeError> => |
| 233 | Effect.gen(function* () { | 236 | Effect.gen(function* () { |
| 234 | const resource = yield* discoverProtectedResourceMetadata(input.endpoint, { | 237 | const resource = yield* discoverProtectedResourceMetadata(input.endpoint, { |
| 238 | httpClientLayer, | ||
| 235 | resourceHeaders: input.headers, | 239 | resourceHeaders: input.headers, |
| 236 | resourceQueryParams: input.queryParams, | 240 | resourceQueryParams: input.queryParams, |
| 237 | }).pipe( | 241 | }).pipe( |
| @@ -257,7 +261,7 @@ export const makeOAuth2Service = ( · 19 unmodified lines | |||
| 257 | })(); | 261 | })(); |
| 258 | 262 | ||
| 259 | const authServer = authorizationServerUrl | 263 | const authServer = authorizationServerUrl |
| 260 | ? yield* discoverAuthorizationServerMetadata(authorizationServerUrl).pipe( | 264 | ? yield* discoverAuthorizationServerMetadata(authorizationServerUrl, { httpClientLayer }).pipe( |
| 261 | Effect.catchTag("OAuthDiscoveryError", () => Effect.succeed(null)), | 265 | Effect.catchTag("OAuthDiscoveryError", () => Effect.succeed(null)), |
| 262 | ) | 266 | ) |
| 263 | : null; | 267 | : null; |
| @@ -312,7 +316,7 @@ export const makeOAuth2Service = ( · 48 unmodified lines | |||
| 312 | response.headers["www-authenticate"] ?? response.headers["WWW-Authenticate"]; | 316 | response.headers["www-authenticate"] ?? response.headers["WWW-Authenticate"]; |
| 313 | return !!wwwAuth && /^\s*bearer\b/i.test(wwwAuth); | 317 | return !!wwwAuth && /^\s*bearer\b/i.test(wwwAuth); |
| 314 | }).pipe( | 318 | }).pipe( |
| 315 | Effect.provide(FetchHttpClient.layer), | 319 | Effect.provide(httpClientLayer ?? FetchHttpClient.layer), |
| 316 | Effect.catch(() => Effect.succeed(false)), | 320 | Effect.catch(() => Effect.succeed(false)), |
| 317 | ); | 321 | ); |
| 318 | 322 | ||
| @@ -344,6 +348,7 @@ export const makeOAuth2Service = ( · 25 unmodified lines | |||
| 344 | scopes: strategy.scopes, | 348 | scopes: strategy.scopes, |
| 345 | }, | 349 | }, |
| 346 | { | 350 | { |
| 351 | httpClientLayer, | ||
| 347 | resourceHeaders: input.headers, | 352 | resourceHeaders: input.headers, |
| 348 | resourceQueryParams: input.queryParams, | 353 | resourceQueryParams: input.queryParams, |
| 349 | }, | 354 | }, |
| @@ -1,4 +1,5 @@ | |||
| 1 | import type { Context, Effect, Layer } from "effect"; | 1 | import type { Context, Effect, Layer } from "effect"; |
| 2 | import type { HttpClient } from "effect/unstable/http"; | ||
| 2 | import type { HttpApiGroup } from "effect/unstable/httpapi"; | 3 | import type { HttpApiGroup } from "effect/unstable/httpapi"; |
| 3 | import type { DBSchema, StorageFailure } from "@executor-js/storage-core"; | 4 | import type { DBSchema, StorageFailure } from "@executor-js/storage-core"; |
| 4 | 5 | ||
| @@ -102,6 +103,7 @@ export interface PluginCtx<TStore = unknown> { · 97 unmodified lines | |||
| 102 | */ | 103 | */ |
| 103 | readonly scopes: readonly Scope[]; | 104 | readonly scopes: readonly Scope[]; |
| 104 | readonly storage: TStore; | 105 | readonly storage: TStore; |
| 106 | readonly httpClientLayer: Layer.Layer<HttpClient.HttpClient>; | ||
| 105 | 107 | ||
| 106 | readonly core: { | 108 | readonly core: { |
| 107 | readonly sources: { | 109 | readonly sources: { |
| @@ -1,6 +1,6 @@ | |||
| 1 | import { Effect, Option, Schema } from "effect"; | 1 | import { Effect, Option, Schema } from "effect"; |
| 2 | import type { Layer } from "effect"; | 2 | import type { Layer } from "effect"; |
| 3 | import { FetchHttpClient, HttpClient } from "effect/unstable/http"; | 3 | import { HttpClient } from "effect/unstable/http"; |
| 4 | 4 | ||
| 5 | import { GraphqlGroup } from "../api/group"; | 5 | import { GraphqlGroup } from "../api/group"; |
| 6 | import { GraphqlExtensionService, GraphqlHandlers } from "../api/handlers"; | 6 | import { GraphqlExtensionService, GraphqlHandlers } from "../api/handlers"; |
| @@ -916,15 +916,14 @@ const makeGraphqlExtension = ( · 909 unmodified lines | |||
| 916 | export type GraphqlPluginExtension = ReturnType<typeof makeGraphqlExtension>; | 916 | export type GraphqlPluginExtension = ReturnType<typeof makeGraphqlExtension>; |
| 917 | 917 | ||
| 918 | export const graphqlPlugin = definePlugin((options?: GraphqlPluginOptions) => { | 918 | export const graphqlPlugin = definePlugin((options?: GraphqlPluginOptions) => { |
| 919 | const httpClientLayer = options?.httpClientLayer ?? FetchHttpClient.layer; | ||
| 920 | |||
| 921 | return { | 919 | return { |
| 922 | id: "graphql" as const, | 920 | id: "graphql" as const, |
| 923 | packageName: "@executor-js/plugin-graphql", | 921 | packageName: "@executor-js/plugin-graphql", |
| 924 | schema: graphqlSchema, | 922 | schema: graphqlSchema, |
| 925 | storage: (deps): GraphqlStore => makeDefaultGraphqlStore(deps), | 923 | storage: (deps): GraphqlStore => makeDefaultGraphqlStore(deps), |
| 926 | 924 | ||
| 927 | extension: (ctx) => makeGraphqlExtension(ctx, httpClientLayer, options?.configFile), | 925 | extension: (ctx) => |
| 926 | makeGraphqlExtension(ctx, options?.httpClientLayer ?? ctx.httpClientLayer, options?.configFile), | ||
| 928 | 927 | ||
| 929 | staticSources: (self) => [ | 928 | staticSources: (self) => [ |
| 930 | { | 929 | { |
| @@ -969,6 +968,7 @@ export const graphqlPlugin = definePlugin((options?: GraphqlPluginOptions) => { · 38 unmodified lines | |||
| 969 | 968 | ||
| 970 | invokeTool: ({ ctx, toolRow, args }) => | 969 | invokeTool: ({ ctx, toolRow, args }) => |
| 971 | Effect.gen(function* () { | 970 | Effect.gen(function* () { |
| 971 | const httpClientLayer = options?.httpClientLayer ?? ctx.httpClientLayer; | ||
| 972 | // toolRow.scope_id is the resolved owning scope of the tool | 972 | // toolRow.scope_id is the resolved owning scope of the tool |
| 973 | // (innermost-wins from the executor's stack). The matching | 973 | // (innermost-wins from the executor's stack). The matching |
| 974 | // graphql_operation + graphql_source rows live at the same | 974 | // graphql_operation + graphql_source rows live at the same |
| @@ -1081,8 +1081,9 @@ export const graphqlPlugin = definePlugin((options?: GraphqlPluginOptions) => { · 106 unmodified lines | |||
| 1081 | 1081 | ||
| 1082 | usagesForConnection: () => Effect.succeed([]), | 1082 | usagesForConnection: () => Effect.succeed([]), |
| 1083 | 1083 | ||
| 1084 | detect: ({ url }) => | 1084 | detect: ({ ctx, url }) => |
| 1085 | Effect.gen(function* () { | 1085 | Effect.gen(function* () { |
| 1086 | const httpClientLayer = options?.httpClientLayer ?? ctx.httpClientLayer; | ||
| 1086 | const trimmed = url.trim(); | 1087 | const trimmed = url.trim(); |
| 1087 | if (!trimmed) return null; | 1088 | if (!trimmed) return null; |
| 1088 | const parsed = yield* Effect.try({ | 1089 | const parsed = yield* Effect.try({ |
| @@ -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"; | ||
| 2 | 13 | ||
| 3 | import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"; | 14 | import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"; |
| 4 | 15 | ||
| @@ -848,6 +859,7 @@ export interface McpPluginOptions { · 843 unmodified lines | |||
| 848 | * `process.env`. Only enable for trusted single-user contexts. | 859 | * `process.env`. Only enable for trusted single-user contexts. |
| 849 | */ | 860 | */ |
| 850 | readonly dangerouslyAllowStdioMCP?: boolean; | 861 | readonly dangerouslyAllowStdioMCP?: boolean; |
| 862 | readonly httpClientLayer?: Layer.Layer<HttpClient.HttpClient>; | ||
| 851 | /** If provided, source add/remove is mirrored to executor.jsonc | 863 | /** If provided, source add/remove is mirrored to executor.jsonc |
| 852 | * (best-effort — file errors are logged, not raised). */ | 864 | * (best-effort — file errors are logged, not raised). */ |
| 853 | readonly configFile?: ConfigFileSink; | 865 | readonly configFile?: ConfigFileSink; |
| @@ -937,6 +949,7 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 83 unmodified lines | |||
| 937 | storage: (deps): McpBindingStore => makeMcpStore(deps), | 949 | storage: (deps): McpBindingStore => makeMcpStore(deps), |
| 938 | 950 | ||
| 939 | extension: (ctx) => { | 951 | extension: (ctx) => { |
| 952 | const httpClientLayer = options?.httpClientLayer ?? ctx.httpClientLayer; | ||
| 940 | const probeEndpoint = (input: string | McpProbeEndpointInput) => | 953 | const probeEndpoint = (input: string | McpProbeEndpointInput) => |
| 941 | Effect.gen(function* () { | 954 | Effect.gen(function* () { |
| 942 | const endpoint = typeof input === "string" ? input : input.endpoint; | 955 | const endpoint = typeof input === "string" ? input : input.endpoint; |
| @@ -994,6 +1007,7 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 51 unmodified lines | |||
| 994 | // probe and be misclassified as MCP. The shape probe rejects | 1007 | // probe and be misclassified as MCP. The shape probe rejects |
| 995 | // anything whose initialize response isn't 2xx or 401+Bearer. | 1008 | // anything whose initialize response isn't 2xx or 401+Bearer. |
| 996 | const shape = yield* probeMcpEndpointShape(trimmed, { | 1009 | const shape = yield* probeMcpEndpointShape(trimmed, { |
| 1010 | httpClientLayer, | ||
| 997 | headers: probeHeaders, | 1011 | headers: probeHeaders, |
| 998 | queryParams: probeQueryParams, | 1012 | queryParams: probeQueryParams, |
| 999 | }); | 1013 | }); |
| @@ -1575,6 +1589,7 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 575 unmodified lines | |||
| 1575 | 1589 | ||
| 1576 | detect: ({ ctx, url }) => | 1590 | detect: ({ ctx, url }) => |
| 1577 | Effect.gen(function* () { | 1591 | Effect.gen(function* () { |
| 1592 | const httpClientLayer = options?.httpClientLayer ?? ctx.httpClientLayer; | ||
| 1578 | const trimmed = url.trim(); | 1593 | const trimmed = url.trim(); |
| 1579 | if (!trimmed) return null; | 1594 | if (!trimmed) return null; |
| 1580 | 1595 | ||
| @@ -1610,7 +1625,7 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 29 unmodified lines | |||
| 1610 | 1625 | ||
| 1611 | // host publishes RFC 9728 + 8414 metadata) would be classified | 1626 | // host publishes RFC 9728 + 8414 metadata) would be classified |
| 1612 | // as MCP whenever the cross-plugin detector fans out to us. | 1627 | // as MCP whenever the cross-plugin detector fans out to us. |
| 1613 | const shape = yield* probeMcpEndpointShape(trimmed); | 1628 | const shape = yield* probeMcpEndpointShape(trimmed, { httpClientLayer }); |
| 1614 | if (shape.kind !== "mcp") return null; | 1629 | if (shape.kind !== "mcp") return null; |
| 1615 | 1630 | ||
| 1616 | // Confirm OAuth metadata is actually reachable. The shape | 1631 | // Confirm OAuth metadata is actually reachable. The shape |
| @@ -30,7 +30,7 @@ · 29 unmodified lines | |||
| 30 | // round-trip, no DCR — every non-MCP endpoint exits here. | 30 | // round-trip, no DCR — every non-MCP endpoint exits here. |
| 31 | // --------------------------------------------------------------------------- | 31 | // --------------------------------------------------------------------------- |
| 32 | 32 | ||
| 33 | import { Data, Duration, Effect, Option, Schema } from "effect"; | 33 | import { Data, Duration, Effect, Layer, Option, Schema } from "effect"; |
| 34 | import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"; | 34 | import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"; |
| 35 | 35 | ||
| 36 | /** MCP initialize request body used as the shape probe. Any real MCP | 36 | /** MCP initialize request body used as the shape probe. Any real MCP |
| @@ -97,6 +97,7 @@ export type McpShapeProbeResult = · 60 unmodified lines | |||
| 97 | export interface ProbeOptions { | 97 | export interface ProbeOptions { |
| 98 | /** Abort the request after this many ms. Default 8000. */ | 98 | /** Abort the request after this many ms. Default 8000. */ |
| 99 | readonly timeoutMs?: number; | 99 | readonly timeoutMs?: number; |
| 100 | readonly httpClientLayer?: Layer.Layer<HttpClient.HttpClient>; | ||
| 100 | readonly headers?: Record<string, string>; | 101 | readonly headers?: Record<string, string>; |
| 101 | readonly queryParams?: Record<string, string>; | 102 | readonly queryParams?: Record<string, string>; |
| 102 | } | 103 | } |
| @@ -191,7 +192,7 @@ export const probeMcpEndpointShape = ( · 88 unmodified lines | |||
| 191 | reason: `unexpected status ${postResponse.status} for initialize`, | 192 | reason: `unexpected status ${postResponse.status} for initialize`, |
| 192 | } as const; | 193 | } as const; |
| 193 | }).pipe( | 194 | }).pipe( |
| 194 | Effect.provide(FetchHttpClient.layer), | 195 | Effect.provide(options.httpClientLayer ?? FetchHttpClient.layer), |
| 195 | Effect.mapError( | 196 | Effect.mapError( |
| 196 | (cause) => | 197 | (cause) => |
| 197 | new ProbeTransportError({ | 198 | new ProbeTransportError({ |
| @@ -1,6 +1,6 @@ | |||
| 1 | import { Effect, Option, Predicate, Schema } from "effect"; | 1 | import { Effect, Option, Predicate, Schema } from "effect"; |
| 2 | import type { Layer } from "effect"; | 2 | import type { Layer } from "effect"; |
| 3 | import { FetchHttpClient, HttpClient } from "effect/unstable/http"; | 3 | import { HttpClient } from "effect/unstable/http"; |
| 4 | 4 | ||
| 5 | import { OpenApiGroup } from "../api/group"; | 5 | import { OpenApiGroup } from "../api/group"; |
| 6 | import { OpenApiExtensionService, OpenApiHandlers } from "../api/handlers"; | 6 | import { OpenApiExtensionService, OpenApiHandlers } from "../api/handlers"; |
| @@ -754,8 +754,6 @@ const toOpenApiSourceConfig = ( · 747 unmodified lines | |||
| 754 | const isHttpUrl = (s: string): boolean => s.startsWith("http://") || s.startsWith("https://"); | 754 | const isHttpUrl = (s: string): boolean => s.startsWith("http://") || s.startsWith("https://"); |
| 755 | 755 | ||
| 756 | export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { | 756 | export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { |
| 757 | const httpClientLayer = options?.httpClientLayer ?? FetchHttpClient.layer; | ||
| 758 | |||
| 759 | type RebuildInput = { | 757 | type RebuildInput = { |
| 760 | readonly specText: string; | 758 | readonly specText: string; |
| 761 | readonly scope: string; | 759 | readonly scope: string; |
| @@ -902,6 +900,7 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 140 unmodified lines | |||
| 902 | // than surface a 500 through the unwhitelisted error channel. | 900 | // than surface a 500 through the unwhitelisted error channel. |
| 903 | const refreshSourceInternal = (ctx: PluginCtx<OpenapiStore>, sourceId: string, scope: string) => | 901 | const refreshSourceInternal = (ctx: PluginCtx<OpenapiStore>, sourceId: string, scope: string) => |
| 904 | Effect.gen(function* () { | 902 | Effect.gen(function* () { |
| 903 | const httpClientLayer = options?.httpClientLayer ?? ctx.httpClientLayer; | ||
| 905 | const existing = yield* ctx.storage.getSource(sourceId, scope); | 904 | const existing = yield* ctx.storage.getSource(sourceId, scope); |
| 906 | if (!existing) return; | 905 | if (!existing) return; |
| 907 | const effective = yield* resolveEffectiveSourceConfig(ctx, existing); | 906 | const effective = yield* resolveEffectiveSourceConfig(ctx, existing); |
| @@ -938,6 +937,7 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 30 unmodified lines | |||
| 938 | storage: (deps): OpenapiStore => makeDefaultOpenapiStore(deps), | 937 | storage: (deps): OpenapiStore => makeDefaultOpenapiStore(deps), |
| 939 | 938 | ||
| 940 | extension: (ctx) => { | 939 | extension: (ctx) => { |
| 940 | const httpClientLayer = options?.httpClientLayer ?? ctx.httpClientLayer; | ||
| 941 | const addSpecInternal = (config: OpenApiSpecConfig) => | 941 | const addSpecInternal = (config: OpenApiSpecConfig) => |
| 942 | Effect.gen(function* () { | 942 | Effect.gen(function* () { |
| 943 | // Resolve URL → text and parse BEFORE opening a transaction. | 943 | // Resolve URL → text and parse BEFORE opening a transaction. |
| @@ -1173,6 +1173,7 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 229 unmodified lines | |||
| 1173 | 1173 | ||
| 1174 | invokeTool: ({ ctx, toolRow, args }) => | 1174 | invokeTool: ({ ctx, toolRow, args }) => |
| 1175 | Effect.gen(function* () { | 1175 | Effect.gen(function* () { |
| 1176 | const httpClientLayer = options?.httpClientLayer ?? ctx.httpClientLayer; | ||
| 1176 | // toolRow.scope_id is the resolved owning scope of the tool | 1177 | // toolRow.scope_id is the resolved owning scope of the tool |
| 1177 | // (innermost-wins from the executor's stack). The matching | 1178 | // (innermost-wins from the executor's stack). The matching |
| 1178 | // openapi_operation + openapi_source rows live at the same | 1179 | // openapi_operation + openapi_source rows live at the same |
| @@ -1313,8 +1314,9 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 134 unmodified lines | |||
| 1313 | // means stale UI state, which is worth surfacing to the caller. | 1314 | // means stale UI state, which is worth surfacing to the caller. |
| 1314 | refreshSource: ({ ctx, sourceId, scope }) => refreshSourceInternal(ctx, sourceId, scope), | 1315 | refreshSource: ({ ctx, sourceId, scope }) => refreshSourceInternal(ctx, sourceId, scope), |
| 1315 | 1316 | ||
| 1316 | detect: ({ url }) => | 1317 | detect: ({ ctx, url }) => |
| 1317 | Effect.gen(function* () { | 1318 | Effect.gen(function* () { |
| 1319 | const httpClientLayer = options?.httpClientLayer ?? ctx.httpClientLayer; | ||
| 1318 | const trimmed = url.trim(); | 1320 | const trimmed = url.trim(); |
| 1319 | if (!trimmed) return null; | 1321 | if (!trimmed) return null; |
| 1320 | const parsed = yield* Effect.try({ | 1322 | const parsed = yield* Effect.try({ |