Skip to content

Commit 5fc67cd

Browse files
committed
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.
1 parent 745deac commit 5fc67cd

19 files changed

Lines changed: 559 additions & 36 deletions

‎apps/cloud/executor.config.ts‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,10 +37,15 @@ interface CloudPluginDeps {
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‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,9 @@
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‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import {
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
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‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,12 @@ import {
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}`;
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
8593
adapter,
8694
blobs,
8795
plugins,
96+
httpClientLayer: testHttpClientLayer,
8897
onElicitation: "accept-all",
8998
});
9099
});

‎apps/cloud/src/services/executor.ts‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,13 @@
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 = (
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 = (
8289
adapter,
8390
blobs,
8491
plugins,
92+
httpClientLayer,
8593
onElicitation: "accept-all",
8694
});
8795
});

‎apps/cloud/src/services/mcp-oauth.node.test.ts‎

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,10 @@ import { createServer, type Server } from "node:http";
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 () => {
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‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,24 +9,27 @@ import { ScopeId, SecretId } from "@executor-js/sdk";
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)", () => {
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‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -373,6 +373,26 @@ describe("sources api (HTTP)", () => {
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(

0 commit comments

Comments
 (0)