Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/selfhost-cimd-dcr-fallback.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"executor": patch
---

Allow self-hosted deployments whose CIMD document is unreachable by OAuth servers to use DCR for automatic MCP and discovered OpenAPI connections with `EXECUTOR_OAUTH_CIMD_ENABLED=false`. Unsetting the variable restores CIMD for new connections without rewriting integration settings, including legacy OpenAPI templates.
35 changes: 18 additions & 17 deletions apps/docs/hosted/docker.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -53,23 +53,24 @@ Back it up by snapshotting that volume (or copying `/data`, primarily `data.db`)
Everything is optional: a bare run boots a working instance. The defaults below are
the container defaults.

| Variable | Default | Purpose |
| ----------------------------------- | ------------------------------- | ----------------------------------------------------------------------------------------------- |
| `PORT` | `4788` | HTTP port the server listens on. |
| `EXECUTOR_HOST` | `0.0.0.0` | Bind address. The image binds all interfaces. |
| `EXECUTOR_DATA_DIR` | `/data` | Directory holding the database and generated keys. |
| `EXECUTOR_DB_PATH` | `<data dir>/data.db` | SQLite database file. |
| `EXECUTOR_WEB_BASE_URL` | auto (`http://localhost:4788`) | Public URL browsers use. Required behind a domain or TLS (see below). |
| `EXECUTOR_TRUSTED_ORIGINS` | unset | Comma-separated browser aliases allowed to authenticate without changing the public URL. |
| `BETTER_AUTH_SECRET` | generated, persisted in `/data` | Session secret (32+ chars). Rotating it signs everyone out. |
| `EXECUTOR_SECRET_KEY` | generated, persisted in `/data` | Master key encrypting stored secrets. Set it to manage it yourself. |
| `EXECUTOR_BOOTSTRAP_ADMIN_EMAIL` | unset | Pre-create the admin headlessly (with the password below); skips browser first-run. |
| `EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD` | unset | Password for the bootstrap admin. |
| `EXECUTOR_BOOTSTRAP_ADMIN_NAME` | `Admin` | Display name for the bootstrap admin. |
| `EXECUTOR_ORG_NAME` | `Default` | Display name of the single org every user joins. |
| `EXECUTOR_ORG_SLUG` | `default` | URL slug for that org. |
| `EXECUTOR_ALLOW_LOCAL_NETWORK` | `false` | Allow sandboxed code to reach loopback / private addresses. Keep off unless you trust the code. |
| `EXECUTOR_DISABLE_AUTH_RATE_LIMIT` | `false` | Turn off sign-in rate limiting. Only when a proxy or WAF in front of Executor limits instead. |
| Variable | Default | Purpose |
| ----------------------------------- | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `PORT` | `4788` | HTTP port the server listens on. |
| `EXECUTOR_HOST` | `0.0.0.0` | Bind address. The image binds all interfaces. |
| `EXECUTOR_DATA_DIR` | `/data` | Directory holding the database and generated keys. |
| `EXECUTOR_DB_PATH` | `<data dir>/data.db` | SQLite database file. |
| `EXECUTOR_WEB_BASE_URL` | auto (`http://localhost:4788`) | Public URL browsers use. Required behind a domain or TLS (see below). |
| `EXECUTOR_TRUSTED_ORIGINS` | unset | Comma-separated browser aliases allowed to authenticate without changing the public URL. |
| `BETTER_AUTH_SECRET` | generated, persisted in `/data` | Session secret (32+ chars). Rotating it signs everyone out. |
| `EXECUTOR_SECRET_KEY` | generated, persisted in `/data` | Master key encrypting stored secrets. Set it to manage it yourself. |
| `EXECUTOR_BOOTSTRAP_ADMIN_EMAIL` | unset | Pre-create the admin headlessly (with the password below); skips browser first-run. |
| `EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD` | unset | Password for the bootstrap admin. |
| `EXECUTOR_BOOTSTRAP_ADMIN_NAME` | `Admin` | Display name for the bootstrap admin. |
| `EXECUTOR_ORG_NAME` | `Default` | Display name of the single org every user joins. |
| `EXECUTOR_ORG_SLUG` | `default` | URL slug for that org. |
| `EXECUTOR_ALLOW_LOCAL_NETWORK` | `false` | Allow sandboxed code to reach loopback / private addresses. Keep off unless you trust the code. |
| `EXECUTOR_OAUTH_CIMD_ENABLED` | `true` | Set `false` when upstream authorization servers cannot reach this instance's OAuth Client ID Metadata Document (CIMD); automatic connects then try Dynamic Client Registration (DCR) when available. Only exact `true` or `false` are accepted; any other value (including empty, uppercase, or whitespace-padded values) prevents startup. |
| `EXECUTOR_DISABLE_AUTH_RATE_LIMIT` | `false` | Turn off sign-in rate limiting. Only when a proxy or WAF in front of Executor limits instead. |

Tracing is configured separately, and off unless you turn it on — see
[Tracing](/hosted/tracing).
Expand Down
4 changes: 4 additions & 0 deletions apps/host-selfhost/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@
# default — adversarial generated code should not reach your internal network.
# EXECUTOR_ALLOW_LOCAL_NETWORK=false

# OAuth Client ID Metadata Document capability. For values and deployment
# guidance, see ../docs/hosted/docker.mdx#environment-variables.
# EXECUTOR_OAUTH_CIMD_ENABLED=true

# --- Auth rate limiting -------------------------------------------------------
# Sign-in attempts are rate-limited per client IP. Without a trusted proxy
# header every caller shares one bucket. Set the exact string "true" only when
Expand Down
14 changes: 14 additions & 0 deletions apps/host-selfhost/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,11 @@ export interface SelfHostConfig {
* re-sync, leaving stale-marking and config revision as the only triggers.
*/
readonly toolsSyncTtlMs: number | null | undefined;
/**
* Resolved `EXECUTOR_OAUTH_CIMD_ENABLED`; see apps/docs/hosted/docker.mdx.
* Passed to `ExecutorConfig.oauthClientIdMetadataDocumentEnabled`.
*/
readonly oauthCimdEnabled: boolean;
}

export const resolveDataDir = (): string =>
Expand Down Expand Up @@ -207,6 +212,7 @@ export const loadConfig = (): SelfHostConfig => {
sso: resolveSso(),
mcpSessionIdleTtlMs: resolveMcpSessionIdleTtlMs(),
toolsSyncTtlMs: resolveToolsSyncTtlMs(),
oauthCimdEnabled: resolveOauthCimdEnabled(),
};
};

Expand Down Expand Up @@ -266,6 +272,14 @@ const resolveSso = (): SsoConfig | undefined => {
return { providerId, providerName, discoveryUrl, clientId, clientSecret, allowedDomains };
};

const resolveOauthCimdEnabled = (): boolean => {
const raw = process.env.EXECUTOR_OAUTH_CIMD_ENABLED;
if (raw === undefined || raw === "true") return true;
if (raw === "false") return false;
// oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: refuse to boot on a malformed operator knob
throw new Error(`EXECUTOR_OAUTH_CIMD_ENABLED ${JSON.stringify(raw)} must be "true" or "false"`);
};

// A malformed value is refused rather than silently ignored: an operator who
// sets the knob and typos it should find out at boot, not by watching a
// runaway execution use the 5-minute default.
Expand Down
1 change: 1 addition & 0 deletions apps/host-selfhost/src/execution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ export const SelfHostHostConfig: Layer.Layer<HostConfig> = Layer.sync(HostConfig
webBaseUrl: config.webBaseUrl,
oauthCallbackPath: "/api/oauth/callback",
toolsSyncTtlMs: config.toolsSyncTtlMs,
oauthClientIdMetadataDocumentEnabled: config.oauthCimdEnabled,
onIntegrationChange: (event) =>
selfHostAnalytics.record(
event.kind === "added" ? "integration_added" : "integration_removed",
Expand Down
32 changes: 32 additions & 0 deletions apps/host-selfhost/src/executor-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@ import executorConfig from "../executor.config";
const ENV_NAME = "EXECUTOR_ALLOW_STDIO_MCP";
const SECRET_ENV_NAME = "EXECUTOR_SECRET_KEY";
const TTL_ENV_NAME = "EXECUTOR_TOOLS_SYNC_TTL_MS";
const CIMD_ENV_NAME = "EXECUTOR_OAUTH_CIMD_ENABLED";
const originalValue = process.env[ENV_NAME];
const originalSecret = process.env[SECRET_ENV_NAME];
const originalTtl = process.env[TTL_ENV_NAME];
const originalCimd = process.env[CIMD_ENV_NAME];
const RATE_LIMIT_ENV_NAME = "EXECUTOR_DISABLE_AUTH_RATE_LIMIT";
const originalRateLimit = process.env[RATE_LIMIT_ENV_NAME];

Expand All @@ -32,6 +34,11 @@ afterEach(() => {
} else {
process.env[TTL_ENV_NAME] = originalTtl;
}
if (originalCimd === undefined) {
delete process.env[CIMD_ENV_NAME];
} else {
process.env[CIMD_ENV_NAME] = originalCimd;
}
if (originalRateLimit === undefined) {
delete process.env[RATE_LIMIT_ENV_NAME];
} else {
Expand Down Expand Up @@ -120,6 +127,31 @@ test("a negative tools-sync TTL refuses to boot", () => {
expect(() => loadConfig()).toThrow(/must not be negative/);
});

test("CIMD serving is enabled when the knob is unset", () => {
delete process.env[CIMD_ENV_NAME];
expect(loadConfig().oauthCimdEnabled).toBe(true);
});

test("CIMD serving is disabled by false", () => {
process.env[CIMD_ENV_NAME] = "false";
expect(loadConfig().oauthCimdEnabled).toBe(false);
});

test("CIMD serving is enabled by true", () => {
process.env[CIMD_ENV_NAME] = "true";
expect(loadConfig().oauthCimdEnabled).toBe(true);
});

test.each(["disabled", "TRUE", "FALSE", "", " ", " true", "true ", " false", "false "])(
"a malformed CIMD serving knob (%j) refuses to boot",
(raw) => {
process.env[CIMD_ENV_NAME] = raw;
expect(() => loadConfig()).toThrow(
`EXECUTOR_OAUTH_CIMD_ENABLED ${JSON.stringify(raw)} must be "true" or "false"`,
);
},
);

test("auth rate limiting stays on unless the opt-out is exactly true", () => {
delete process.env[RATE_LIMIT_ENV_NAME];
expect(loadConfig().authRateLimit).toBe(true);
Expand Down
61 changes: 61 additions & 0 deletions apps/host-selfhost/src/oauth-cimd-capability.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";

import { afterAll, beforeAll, expect, test } from "@effect/vitest";
import { Effect } from "effect";

import { serveOAuthTestServer } from "@executor-js/sdk/testing";

// Config reads the environment, so set the knob (and allow the loopback test
// AS through the hosted HTTP client) before importing the app graph.
process.env.EXECUTOR_DATA_DIR = mkdtempSync(join(tmpdir(), "eh-cimd-"));
process.env.EXECUTOR_OAUTH_CIMD_ENABLED = "false";
process.env.EXECUTOR_ALLOW_LOCAL_NETWORK = "true";

let handler!: (request: Request) => Promise<Response>;
let dispose: () => Promise<void> = async () => {};

beforeAll(async () => {
const { makeSelfHostTestApp, singleAdminIdentityLayer } = await import("./testing/test-app");
const app = await makeSelfHostTestApp({
identity: singleAdminIdentityLayer({
userId: "admin",
organizationId: "default-org",
organizationName: "Default",
}),
});
handler = app.handler;
dispose = app.dispose;
});

afterAll(() => dispose());

test("POST /api/oauth/probe hides CIMD when the deployment cannot serve the document", async () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const server = yield* serveOAuthTestServer({
clientIdMetadataDocumentSupported: true,
});
const res = yield* Effect.promise(() =>
handler(
new Request("http://localhost/api/oauth/probe", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ url: server.mcpResourceUrl }),
}),
),
);
expect(res.status).toBe(200);
const body = yield* Effect.promise(() => res.json());
expect(body).toEqual(
expect.objectContaining({
clientIdMetadataDocumentSupported: false,
registrationEndpoint: server.registrationEndpoint,
}),
);
}),
),
);
});
Loading
Loading