From 2df2c2318fadfe699dd9b587aa46c15ceb4b6ed2 Mon Sep 17 00:00:00 2001 From: mrzmyr Date: Fri, 17 Jul 2026 22:19:41 +0200 Subject: [PATCH] feat: support stateless Vercel hosting --- .gitignore | 1 + Dockerfile | 29 +++ README.md | 3 +- apps/docs/docs.json | 2 +- apps/docs/hosted/docker.mdx | 11 + apps/docs/hosted/vercel.mdx | 70 ++++++ apps/host-selfhost/.env.example | 16 ++ apps/host-selfhost/README.md | 9 +- apps/host-selfhost/src/app.ts | 85 +++++-- apps/host-selfhost/src/auth/better-auth.ts | 38 ++- apps/host-selfhost/src/auth/index.ts | 6 +- apps/host-selfhost/src/config.node.test.ts | 144 +++++++++++ apps/host-selfhost/src/config.ts | 208 ++++++++++++---- apps/host-selfhost/src/db/boot-lock.test.ts | 83 +++++++ apps/host-selfhost/src/db/boot-lock.ts | 229 ++++++++++++++++++ apps/host-selfhost/src/db/self-host-db.ts | 48 ++-- apps/host-selfhost/src/execution.ts | 4 +- apps/host-selfhost/src/index.ts | 9 +- .../src/integrations-mcp.test.ts | 2 +- apps/host-selfhost/src/integrations.test.ts | 2 +- apps/host-selfhost/src/mcp/index.ts | 4 +- apps/host-selfhost/src/mcp/session-store.ts | 36 ++- .../src/secrets-integration.test.ts | 2 +- apps/host-selfhost/src/testing/test-app.ts | 20 +- packages/hosts/mcp/package.json | 4 + packages/hosts/mcp/src/envelope.test.ts | 19 ++ packages/hosts/mcp/src/envelope.ts | 11 + .../hosts/mcp/src/in-memory-session-store.ts | 1 + packages/hosts/mcp/src/seams.ts | 2 + .../mcp/src/stateless-session-store.test.ts | 99 ++++++++ .../hosts/mcp/src/stateless-session-store.ts | 79 ++++++ packages/hosts/mcp/src/tool-server.test.ts | 41 ++++ packages/hosts/mcp/src/tool-server.ts | 31 ++- vercel.json | 15 ++ 34 files changed, 1239 insertions(+), 124 deletions(-) create mode 100644 Dockerfile create mode 100644 apps/docs/hosted/vercel.mdx create mode 100644 apps/host-selfhost/src/config.node.test.ts create mode 100644 apps/host-selfhost/src/db/boot-lock.test.ts create mode 100644 apps/host-selfhost/src/db/boot-lock.ts create mode 100644 packages/hosts/mcp/src/stateless-session-store.test.ts create mode 100644 packages/hosts/mcp/src/stateless-session-store.ts create mode 100644 vercel.json diff --git a/.gitignore b/.gitignore index 4c3f79186..b750dd41a 100644 --- a/.gitignore +++ b/.gitignore @@ -115,3 +115,4 @@ scratch/ # Throwaway UX prototype (not part of the app) ux-demo/ +.vercel diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..b58c5d4ad --- /dev/null +++ b/Dockerfile @@ -0,0 +1,29 @@ +# Vercel container build for the self-hosted app. Vercel containers have no +# persistent filesystem or process affinity, so this image selects remote +# libSQL storage and request-isolated MCP behavior through environment config. + +FROM oven/bun:1 AS build +WORKDIR /app +COPY . . +RUN bun install --frozen-lockfile +RUN bun run apps/host-selfhost/scripts/package-runtime.ts \ + && cd apps/host-selfhost \ + && bun run build + +FROM gcr.io/distroless/cc-debian12 AS runtime +WORKDIR /app +LABEL org.opencontainers.image.source="https://github.com/UsefulSoftwareCo/executor" \ + org.opencontainers.image.description="Vercel deployment of self-hosted Executor" \ + org.opencontainers.image.licenses="MIT" +ENV NODE_ENV=production \ + EXECUTOR_HOST=0.0.0.0 \ + PORT=80 \ + EXECUTOR_DATA_DIR=/tmp/executor \ + EXECUTOR_MCP_MODE=stateless \ + EXECUTOR_REQUIRE_MANAGED_SECRETS=true +COPY --from=build /usr/local/bin/bun /usr/local/bin/bun +COPY --from=build /app/.selfhost-runtime /app +COPY --from=build /app/apps/host-selfhost/dist /app/apps/host-selfhost/dist +WORKDIR /app/apps/host-selfhost +EXPOSE 80 +CMD ["bun", "run", "dist-server/serve.js"] diff --git a/README.md b/README.md index e66ea111d..c6156fc7e 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ client. - **Governed by policy.** Each tool is allowed, gated behind approval, or blocked, with sensible defaults derived from the spec. - **Run it your way.** Local CLI, a desktop app, hosted Executor Cloud, or - self-hosted on Docker or Cloudflare. Same functionality, different packaging. + self-hosted on Docker, Vercel, or Cloudflare. Same core, different packaging. ## How it works @@ -77,6 +77,7 @@ Every form exposes the same functionality, just packaged differently. | **CLI** | A headless or server environment. Runs a local background service. | [CLI](https://executor.sh/docs/local/cli) | | **Desktop app** | A regular desktop (Mac, Windows, Linux). The same runtime, as a native app. | [Desktop](https://executor.sh/docs/local/desktop) | | **Self-host (Docker)** | Your own infrastructure, full control. | [Docker](https://executor.sh/docs/hosted/docker) | +| **Self-host (Vercel)** | A stateless container deployment backed by remote libSQL. | [Vercel](https://executor.sh/docs/hosted/vercel) | | **Self-host (Cloudflare)** | Deploy as a Cloudflare Worker. | [Cloudflare](https://executor.sh/docs/hosted/cloudflare) | ## Connect an agent over MCP diff --git a/apps/docs/docs.json b/apps/docs/docs.json index 563d26402..b1854410a 100644 --- a/apps/docs/docs.json +++ b/apps/docs/docs.json @@ -33,7 +33,7 @@ }, { "group": "Hosted", - "pages": ["hosted/cloud", "hosted/docker", "hosted/cloudflare"] + "pages": ["hosted/cloud", "hosted/docker", "hosted/vercel", "hosted/cloudflare"] }, { "group": "Concepts", diff --git a/apps/docs/hosted/docker.mdx b/apps/docs/hosted/docker.mdx index 9de4d394b..1227f12c7 100644 --- a/apps/docs/hosted/docker.mdx +++ b/apps/docs/hosted/docker.mdx @@ -48,6 +48,12 @@ Back it up by snapshotting that volume (or copying `/data`, primarily `data.db`) `EXECUTOR_DATA_DIR` (default `/data`) sets the directory, and `EXECUTOR_DB_PATH` (default `/data.db`) sets the database file. +For a host without a persistent filesystem, set `EXECUTOR_DB_URL` and optionally +`EXECUTOR_DB_AUTH_TOKEN` to use remote libSQL. The aliases +`TURSO_DATABASE_URL` and `TURSO_AUTH_TOKEN` are also recognized. Remote storage +requires explicit `BETTER_AUTH_SECRET` and `EXECUTOR_SECRET_KEY` values because +generated keys cannot be recovered from an ephemeral data directory. + ## Environment variables Everything is optional: a bare run boots a working instance. The defaults below are @@ -59,6 +65,11 @@ the container defaults. | `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.db` | SQLite database file. | +| `EXECUTOR_DB_URL` | unset | Remote libSQL URL. Takes precedence over `EXECUTOR_DB_PATH`. | +| `EXECUTOR_DB_AUTH_TOKEN` | unset | Optional remote libSQL authentication token. | +| `TURSO_DATABASE_URL` | unset | Alias for `EXECUTOR_DB_URL`, used by the Vercel Turso integration. | +| `TURSO_AUTH_TOKEN` | unset | Alias for `EXECUTOR_DB_AUTH_TOKEN`, used by the Vercel Turso integration. | +| `EXECUTOR_MCP_MODE` | `stateful` | Set `stateless` only on request-isolated hosts; disables resume and browser approval. | | `EXECUTOR_WEB_BASE_URL` | auto (`http://localhost:4788`) | Public URL browsers use. Required behind a domain or TLS (see below). | | `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. | diff --git a/apps/docs/hosted/vercel.mdx b/apps/docs/hosted/vercel.mdx new file mode 100644 index 000000000..0d6c25a3d --- /dev/null +++ b/apps/docs/hosted/vercel.mdx @@ -0,0 +1,70 @@ +--- +title: Self Hosted Vercel +description: "Deploy Executor as a stateless Vercel container with remote libSQL storage." +--- + +Executor can run on Vercel from the repository-root `Dockerfile`. The +repository's `vercel.json` defines the container service and routes all public traffic to it. +Vercel Functions do not preserve a local filesystem or route later requests to +the same process, so this deployment uses remote libSQL for durable data and a +stateless MCP transport. + +## Prerequisites + +- A Vercel account with Docker deployments available. +- A remote libSQL database. The Turso integration from the Vercel Marketplace + provides one and injects its connection variables automatically. +- The [Executor repo](https://github.com/UsefulSoftwareCo/executor) in your Git + provider account. + +## Create the project + +Import the repository into Vercel. Keep the repository root as the project root +and select the **Services** framework preset. The checked-in `vercel.json` +selects the container runtime, points it at `Dockerfile`, and routes +requests to it. + +Add the Turso integration to the project from the Vercel Marketplace. Executor +reads the injected `TURSO_DATABASE_URL` and `TURSO_AUTH_TOKEN` variables. A +different libSQL provider can use `EXECUTOR_DB_URL` and +`EXECUTOR_DB_AUTH_TOKEN` instead. + +Add two stable random secrets to every environment: + +```bash +BETTER_AUTH_SECRET= +EXECUTOR_SECRET_KEY= +``` + +The production URL is detected from Vercel's system environment variables. Set +`EXECUTOR_WEB_BASE_URL` explicitly if you use a custom domain or disable those +system variables. + +Deploy the project. Open its URL and create the first owner account, or set +`EXECUTOR_BOOTSTRAP_ADMIN_EMAIL` and `EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD` before +the first deploy for a headless setup. + +## Stateless MCP behavior + +The Vercel image sets `EXECUTOR_MCP_MODE=stateless`. Every MCP POST creates a +fresh server and does not issue an `mcp-session-id`, which makes ordinary tool +listing and execution safe across cold starts and scale-out. + + + Paused executions, the `resume` tool, browser approval, and server-initiated SSE require a live + session and are unavailable in stateless mode. An execution that reaches an approval or + authentication interaction returns a clear error. Change that tool's policy to allow, complete + integration authentication in the web console first, or use the Docker or Cloudflare host when + resumable MCP sessions are required. + + +The web console and HTTP API persist through remote libSQL and remain available +normally. + +## Connect an agent + +Point any streamable-HTTP MCP client at the deployed `/mcp` URL: + +```bash +npx add-mcp https://your-project.vercel.app/mcp --transport http --name executor +``` diff --git a/apps/host-selfhost/.env.example b/apps/host-selfhost/.env.example index 659751596..411073378 100644 --- a/apps/host-selfhost/.env.example +++ b/apps/host-selfhost/.env.example @@ -8,6 +8,22 @@ # rejected. Behind a reverse proxy / TLS, set this to your public https URL. # EXECUTOR_WEB_BASE_URL=https://executor.example.com +# --- Database --------------------------------------------------------------- +# By default Executor stores SQLite at /data.db. For platforms with +# no persistent filesystem, point it at remote libSQL instead. The Vercel Turso +# integration injects the TURSO_* aliases automatically. +# EXECUTOR_DB_PATH=/data/data.db +# EXECUTOR_DB_URL=libsql://your-database.turso.io +# EXECUTOR_DB_AUTH_TOKEN= +# TURSO_DATABASE_URL=libsql://your-database.turso.io +# TURSO_AUTH_TOKEN= + +# --- MCP serving ------------------------------------------------------------ +# Stateful keeps live sessions and browser approvals in this process. Stateless +# creates a fresh server per POST for request-isolated platforms such as Vercel; +# resume and browser approval are unavailable in that mode. +# EXECUTOR_MCP_MODE=stateful + # --- Session secret ----------------------------------------------------------- # Generated and persisted under the data volume on first boot if unset. Set this # to manage it yourself (must be at least 32 characters). Rotating it signs every diff --git a/apps/host-selfhost/README.md b/apps/host-selfhost/README.md index b9176ea24..bd0b0a864 100644 --- a/apps/host-selfhost/README.md +++ b/apps/host-selfhost/README.md @@ -2,8 +2,9 @@ The single-container, self-hostable Executor server: the typed API, the MCP server, Better Auth (cookie / bearer / API-key + MCP OAuth), QuickJS code -execution, and the web UI — all in one process over a libSQL (SQLite) file. No -external database, worker, or proxy. +execution, and the web UI, all in one process. It uses a local libSQL (SQLite) +file by default and can use a remote libSQL database on request-isolated +platforms. ## Run it @@ -35,6 +36,10 @@ See [`.env.example`](./.env.example) for optional settings (most importantly [Self-Hosting guide](../../docs/self-hosting/guide.mdx) for first-run, inviting people, backups, reverse-proxy setup, and upgrades. +For Vercel, deploy the repository-root `Dockerfile` and connect a Turso +database. See the [Vercel guide](../docs/hosted/vercel.mdx) for required secrets +and the stateless MCP limitations. + ## Develop ```bash diff --git a/apps/host-selfhost/src/app.ts b/apps/host-selfhost/src/app.ts index c4a080f3b..54003a488 100644 --- a/apps/host-selfhost/src/app.ts +++ b/apps/host-selfhost/src/app.ts @@ -1,6 +1,7 @@ import { HttpApiSwagger } from "effect/unstable/httpapi"; import { HttpEffect, HttpRouter } from "effect/unstable/http"; import { Effect, Layer } from "effect"; +import { createClient } from "@libsql/client"; import { composePluginApi, ExecutorApp, textFailureStrategy } from "@executor-js/api/server"; @@ -13,6 +14,7 @@ import { makeSelfHostSystemApiLayer } from "./system/handlers"; import { selfHostAccountMiddleware } from "./account"; import { loadConfig, SELF_HOST_NAMESPACE, SELF_HOST_SCHEMA_VERSION } from "./config"; import { createSelfHostDb, SelfHostDb, SelfHostDbProvider } from "./db/self-host-db"; +import { withSelfHostBootLock } from "./db/boot-lock"; import { SelfHostCodeExecutorProvider, SelfHostHostConfig, @@ -49,29 +51,59 @@ export interface MakeSelfHostAppOptions { } export const makeSelfHostApp = async (options: MakeSelfHostAppOptions = {}) => { - const config = loadConfig(); - - // ---- eager async boot: the shared libSQL handle ----------------------- - const dbHandle = await createSelfHostDb({ - path: options.dbPath ?? config.dbPath, - namespace: SELF_HOST_NAMESPACE, - version: SELF_HOST_SCHEMA_VERSION, + const databaseOverride = options.dbPath + ? ({ kind: "file", path: options.dbPath } as const) + : undefined; + // An explicit test database is an isolation boundary: do not parse ambient + // remote/stateless settings before replacing them with the throwaway file. + const config = loadConfig({ + ...(databaseOverride ? { database: databaseOverride, mcpMode: "stateful" } : {}), }); + const database = config.database; + + const bootDatabase = async () => { + // ---- eager async boot: the shared libSQL handle --------------------- + const dbHandle = await createSelfHostDb({ + database, + namespace: SELF_HOST_NAMESPACE, + version: SELF_HOST_SCHEMA_VERSION, + }); - // Boot-time data migrations: each registry entry runs once and is stamped - // in the `data_migration` ledger; stamped entries are skipped without - // touching the data. - await Effect.runPromise(runSqliteDataMigrations(dbHandle.client, selfHostDataMigrations)); + // Boot-time data migrations: each registry entry runs once and is stamped + // in the `data_migration` ledger; stamped entries are skipped without + // touching the data. + await Effect.runPromise(runSqliteDataMigrations(dbHandle.client, selfHostDataMigrations)); + + // ---- auth providers ------------------------------------------------- + // Better Auth: cookie/bearer/api-key identity + /api/auth handler + account + // API + MCP OAuth seam, all over the shared libSQL handle. + return { dbHandle, ...(await resolveAuthProviders(dbHandle, config)) }; + }; - // ---- auth providers --------------------------------------------------- - // Better Auth: cookie/bearer/api-key identity + /api/auth handler + account - // API + MCP OAuth seam, all over the shared libSQL handle. - const { identityLayer, authHandler, betterAuth } = await resolveAuthProviders(dbHandle); + // Vercel and similar hosts can cold-start multiple containers against one + // remote database. Serialize executor schema creation, data migrations, and + // first-run auth seeding with a renewable DB lease. The dedicated lock client + // keeps heartbeats outside any migration transaction on the app's client. + const remoteDatabase = database.kind === "remote" ? database : null; + const { dbHandle, identityLayer, authHandler, betterAuth } = remoteDatabase + ? await (async () => { + const lockClient = createClient({ + url: remoteDatabase.url, + ...(remoteDatabase.authToken ? { authToken: remoteDatabase.authToken } : {}), + }); + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: the dedicated boot-lock connection must close on success and failure + try { + return await withSelfHostBootLock(lockClient, bootDatabase); + } finally { + lockClient.close(); + } + })() + : await bootDatabase(); // ---- the in-process MCP serving seams (+ shutdown hook) ---------------- // Pass the pinned public origin so browser-approval URLs are reachable behind // a reverse proxy (not the internal 127.0.0.1 bind from the request URL). - const mcp = makeSelfHostMcpSeams(dbHandle, betterAuth, config.webBaseUrl); + const mcp = makeSelfHostMcpSeams(dbHandle, betterAuth, config.webBaseUrl, config.mcpMode); // CLI device-login discovery (`executor login`). Points the CLI at Better // Auth's device endpoints; `requestFormat: "json"` because those endpoints @@ -99,7 +131,10 @@ export const makeSelfHostApp = async (options: MakeSelfHostAppOptions = {}) => { db: SelfHostDbProvider, engine: { codeExecutor: SelfHostCodeExecutorProvider }, // decorator defaults to no-op (no metering) mcp: { auth: mcp.auth, sessions: mcp.sessions, reporter: mcp.reporter }, - plugins: { provider: SelfHostPluginsProvider, config: SelfHostHostConfig }, + plugins: { + provider: SelfHostPluginsProvider, + config: SelfHostHostConfig, + }, errorCapture: ErrorCaptureLive, }, extensions: { @@ -116,11 +151,21 @@ export const makeSelfHostApp = async (options: MakeSelfHostAppOptions = {}) => { // session-cookie-gated, delegating to the in-process MCP store. HttpRouter.add("*", "/api/mcp-sessions/*", HttpEffect.fromWebHandler(mcp.approvalHandler)), // App-local admin (invite-code) API, served under /api/admin/*. - makeSelfHostAdminApiLayer({ betterAuth, db: dbHandle, mountPrefix: "/api" }), + makeSelfHostAdminApiLayer({ + betterAuth, + db: dbHandle, + mountPrefix: "/api", + }), // Public system API: /api/health + /api/setup-status (unauthenticated). - makeSelfHostSystemApiLayer({ betterAuth, db: dbHandle, mountPrefix: "/api" }), + makeSelfHostSystemApiLayer({ + betterAuth, + db: dbHandle, + mountPrefix: "/api", + }), // Swagger UI at /docs, over the /api-prefixed spec (matches the served paths). - HttpApiSwagger.layer(composePluginApi(selfHostPlugins).prefix("/api"), { path: "/docs" }), + HttpApiSwagger.layer(composePluginApi(selfHostPlugins).prefix("/api"), { + path: "/docs", + }), ], }, config: { mountPrefix: "/api", failure: textFailureStrategy }, diff --git a/apps/host-selfhost/src/auth/better-auth.ts b/apps/host-selfhost/src/auth/better-auth.ts index 329c5a8e9..c68240a04 100644 --- a/apps/host-selfhost/src/auth/better-auth.ts +++ b/apps/host-selfhost/src/auth/better-auth.ts @@ -6,7 +6,7 @@ import { type Client } from "@libsql/client"; import { LibsqlDialect, type LibsqlDialectConfig } from "@libsql/kysely-libsql"; import { Context } from "effect"; -import { loadConfig } from "../config"; +import type { SelfHostConfig } from "../config"; import { seedOrgAndAdmin } from "./seed"; import { consumeInviteCode, ensureInviteCodeTable, findRedeemableCode } from "./invites"; @@ -61,8 +61,12 @@ const SIGNUP_PATH = "/sign-up/email"; // session/user shapes (activeOrganizationId, role, createUser, ...). // --------------------------------------------------------------------------- -const makeAuthOptions = (client: Client, getOrganizationId: () => string, gate?: SignupGate) => { - const config = loadConfig(); +const makeAuthOptions = ( + client: Client, + getOrganizationId: () => string, + config: SelfHostConfig, + gate?: SignupGate, +) => { // Always resolved (generated + persisted when no env is set); this guards only // an explicitly-set env secret that is too weak. const secret = config.authSecret; @@ -180,7 +184,11 @@ const makeAuthOptions = (client: Client, getOrganizationId: () => string, gate?: // First user into an empty org becomes its owner (no code). if (await orgHasNoMembers(gate)) { await auth.api.addMember({ - body: { userId: user.id, role: "owner", organizationId: gate.organizationId }, + body: { + userId: user.id, + role: "owner", + organizationId: gate.organizationId, + }, }); return; } @@ -228,7 +236,10 @@ const inviteCodeFrom = (context: { body?: unknown }): string | undefined => { // gate logic next to the writes. export const countOrgMembers = (auth: Auth, organizationId: string): Promise => auth.$context.then(({ adapter }) => - adapter.count({ model: "member", where: [{ field: "organizationId", value: organizationId }] }), + adapter.count({ + model: "member", + where: [{ field: "organizationId", value: organizationId }], + }), ); // True when the single org has no members yet — the unclaimed first-run state. @@ -238,8 +249,12 @@ const orgHasNoMembers = async (gate: SignupGate): Promise => { return (await countOrgMembers(auth, gate.organizationId)) === 0; }; -const createAuthInstance = (client: Client, getOrganizationId: () => string, gate?: SignupGate) => - betterAuth(makeAuthOptions(client, getOrganizationId, gate)); +const createAuthInstance = ( + client: Client, + getOrganizationId: () => string, + config: SelfHostConfig, + gate?: SignupGate, +) => betterAuth(makeAuthOptions(client, getOrganizationId, config, gate)); export type Auth = ReturnType; @@ -277,9 +292,10 @@ export class BetterAuth extends Context.Service()( * connection and one WAL. The seed also uses it directly for its two * idempotency reads against the auth tables Better Auth just migrated. */ -export const buildBetterAuth = async (client: Client): Promise => { - const config = loadConfig(); - +export const buildBetterAuth = async ( + client: Client, + config: SelfHostConfig, +): Promise => { // The org id is resolved by the seed below, AFTER this instance is built; the // session-pin hook and the gate read it through these late-bound accessors // (no session is created during the seed, so the empty initial id is never @@ -295,7 +311,7 @@ export const buildBetterAuth = async (client: Client): Promise getAuth: () => auth, }; - auth = createAuthInstance(client, () => orgRef.id, gate); + auth = createAuthInstance(client, () => orgRef.id, config, gate); // `runMigrations()` flows through the LibsqlDialect and is idempotent. await (await auth.$context).runMigrations(); await ensureInviteCodeTable(client); diff --git a/apps/host-selfhost/src/auth/index.ts b/apps/host-selfhost/src/auth/index.ts index bf9a4b583..2ec3be713 100644 --- a/apps/host-selfhost/src/auth/index.ts +++ b/apps/host-selfhost/src/auth/index.ts @@ -2,7 +2,7 @@ import { Layer } from "effect"; import { IdentityProvider } from "@executor-js/api/server"; -import { loadConfig } from "../config"; +import type { SelfHostConfig } from "../config"; import type { SelfHostDbHandle } from "../db/self-host-db"; import { BetterAuth, buildBetterAuth, type BetterAuthHandle } from "./better-auth"; import { betterAuthIdentityLayer } from "./identity"; @@ -37,8 +37,9 @@ export interface ResolvedAuthProviders { export const resolveAuthProviders = async ( dbHandle: SelfHostDbHandle, + config: SelfHostConfig, ): Promise => { - const betterAuth = await buildBetterAuth(dbHandle.client); + const betterAuth = await buildBetterAuth(dbHandle.client, config); const betterAuthLayer = Layer.succeed(BetterAuth)(betterAuth); // The consent redirect from Better Auth's authorize only carries the opaque @@ -58,7 +59,6 @@ export const resolveAuthProviders = async ( // authorize so a connecting client is gated on /mcp-consent rather than // silently granted a token (see ./force-mcp-consent), and enrich the // resulting consent redirect with the registered client name. - const config = loadConfig(); const authHandler = async (request: Request): Promise => { const response = await betterAuth.handler(withForcedMcpConsent(request)); // Turn Better Auth's bare 403 "Invalid origin" into a setup instruction — diff --git a/apps/host-selfhost/src/config.node.test.ts b/apps/host-selfhost/src/config.node.test.ts new file mode 100644 index 000000000..2d707a931 --- /dev/null +++ b/apps/host-selfhost/src/config.node.test.ts @@ -0,0 +1,144 @@ +import { expect, test } from "@effect/vitest"; + +import { + assertManagedSecretsConfigured, + loadConfig, + resolveAuthSecret, + resolveDatabaseConfig, + resolveMcpMode, + resolveSecretKey, +} from "./config"; + +test("database config defaults to a file in the data directory", () => { + expect(resolveDatabaseConfig({ EXECUTOR_DATA_DIR: "/var/executor" })).toEqual({ + kind: "file", + path: "/var/executor/data.db", + }); +}); + +test("Executor remote database variables take precedence over Turso aliases", () => { + expect( + resolveDatabaseConfig({ + EXECUTOR_DB_URL: "libsql://executor.example.com", + EXECUTOR_DB_AUTH_TOKEN: "executor-token", + TURSO_DATABASE_URL: "libsql://turso.example.com", + TURSO_AUTH_TOKEN: "turso-token", + EXECUTOR_DB_PATH: "/ignored.db", + }), + ).toEqual({ + kind: "remote", + url: "libsql://executor.example.com", + authToken: "executor-token", + }); +}); + +test("Turso marketplace variables configure remote libSQL", () => { + expect( + resolveDatabaseConfig({ + TURSO_DATABASE_URL: "libsql://executor-example.turso.io", + TURSO_AUTH_TOKEN: "turso-token", + }), + ).toEqual({ + kind: "remote", + url: "libsql://executor-example.turso.io", + authToken: "turso-token", + }); +}); + +test("blank Executor variables do not shadow Turso marketplace variables", () => { + expect( + resolveDatabaseConfig({ + EXECUTOR_DB_URL: " ", + EXECUTOR_DB_AUTH_TOKEN: "", + TURSO_DATABASE_URL: "libsql://executor-example.turso.io", + TURSO_AUTH_TOKEN: "turso-token", + }), + ).toEqual({ + kind: "remote", + url: "libsql://executor-example.turso.io", + authToken: "turso-token", + }); +}); + +test("remote database credentials fail fast when incomplete or invalid", () => { + expect(() => resolveDatabaseConfig({ TURSO_AUTH_TOKEN: "orphaned-token" })).toThrow( + /requires.*URL/, + ); + expect(() => resolveDatabaseConfig({ EXECUTOR_DB_URL: "not a URL" })).toThrow(/absolute URL/); + expect(() => resolveDatabaseConfig({ EXECUTOR_DB_URL: "file:///tmp/data.db" })).toThrow( + /must use libsql/, + ); +}); + +test("MCP mode defaults to stateful and validates explicit stateless mode", () => { + expect(resolveMcpMode({})).toBe("stateful"); + expect(resolveMcpMode({ EXECUTOR_MCP_MODE: "stateless" })).toBe("stateless"); + expect(() => resolveMcpMode({ EXECUTOR_MCP_MODE: "serverless" })).toThrow(/stateful.*stateless/); +}); + +test("stateless mode requires a remote database", () => { + expect(() => resolveDatabaseConfig({ EXECUTOR_MCP_MODE: "stateless" })).toThrow( + /database.*required.*stateless/i, + ); + expect( + resolveDatabaseConfig({ + EXECUTOR_MCP_MODE: "stateless", + TURSO_DATABASE_URL: "libsql://executor-example.turso.io", + }), + ).toEqual({ + kind: "remote", + url: "libsql://executor-example.turso.io", + }); +}); + +test("explicit local app overrides ignore ambient remote and stateless database settings", () => { + const database = { kind: "file", path: "/tmp/executor-test.db" } as const; + const config = loadConfig({ + env: { + EXECUTOR_DB_URL: "not a URL", + EXECUTOR_MCP_MODE: "stateless", + BETTER_AUTH_SECRET: "test-auth-secret-with-at-least-32-characters", + }, + database, + mcpMode: "stateful", + }); + + expect(config.database).toEqual(database); + expect(config.mcpMode).toBe("stateful"); +}); + +test("managed-secret guard fails closed on request-isolated hosts", () => { + expect(() => + assertManagedSecretsConfigured({ + EXECUTOR_REQUIRE_MANAGED_SECRETS: "true", + }), + ).toThrow(/BETTER_AUTH_SECRET.*EXECUTOR_SECRET_KEY/); + expect(() => + assertManagedSecretsConfigured({ + EXECUTOR_REQUIRE_MANAGED_SECRETS: "true", + BETTER_AUTH_SECRET: "session-secret", + EXECUTOR_SECRET_KEY: "encryption-key", + }), + ).not.toThrow(); + expect(() => assertManagedSecretsConfigured({})).not.toThrow(); + expect(() => assertManagedSecretsConfigured({ EXECUTOR_MCP_MODE: "stateless" })).toThrow( + /BETTER_AUTH_SECRET.*EXECUTOR_SECRET_KEY/, + ); + expect(() => + assertManagedSecretsConfigured({}, "stateful", { + kind: "remote", + url: "libsql://executor-example.turso.io", + }), + ).toThrow(/BETTER_AUTH_SECRET.*EXECUTOR_SECRET_KEY/); +}); + +test("custom env overrides resolve secrets independently", () => { + expect(resolveAuthSecret({ BETTER_AUTH_SECRET: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" })).toBe( + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + ); + expect(resolveAuthSecret({ BETTER_AUTH_SECRET: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" })).toBe( + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + ); + expect(resolveSecretKey({ EXECUTOR_SECRET_KEY: "first-key" })).toBe("first-key"); + expect(resolveSecretKey({ EXECUTOR_SECRET_KEY: "second-key" })).toBe("second-key"); +}); diff --git a/apps/host-selfhost/src/config.ts b/apps/host-selfhost/src/config.ts index e0cd282d5..7198c9066 100644 --- a/apps/host-selfhost/src/config.ts +++ b/apps/host-selfhost/src/config.ts @@ -18,12 +18,24 @@ import { export const SELF_HOST_NAMESPACE = "executor_selfhost"; export const SELF_HOST_SCHEMA_VERSION = "1.0.0"; +export type SelfHostDatabaseConfig = + | { readonly kind: "file"; readonly path: string } + | { + readonly kind: "remote"; + readonly url: string; + readonly authToken?: string; + }; + +export type SelfHostMcpMode = "stateful" | "stateless"; + export interface SelfHostConfig { /** Bind address. Defaults to loopback. */ readonly host: string; readonly port: number; - /** Absolute path to the SQLite database file. */ - readonly dbPath: string; + /** Local SQLite file or remote libSQL database. */ + readonly database: SelfHostDatabaseConfig; + /** Stateful by default; stateless is intended for request-isolated platforms. */ + readonly mcpMode: SelfHostMcpMode; /** Public base URL used by core tools that build absolute links. */ readonly webBaseUrl: string; /** @@ -45,36 +57,62 @@ export interface SelfHostConfig { readonly orgSlug: string; } -export const resolveDataDir = (): string => - process.env.EXECUTOR_DATA_DIR ?? join(process.cwd(), ".executor-selfhost"); +export const resolveDataDir = (env: NodeJS.ProcessEnv = process.env): string => + env.EXECUTOR_DATA_DIR ?? join(process.cwd(), ".executor-selfhost"); let cachedSecretKey: string | undefined; +/** + * Request-isolated hosts cannot persist generated keys between instances. + * Their image sets this guard so a missing managed key fails before boot. + */ +export const assertManagedSecretsConfigured = ( + env: NodeJS.ProcessEnv = process.env, + mcpMode: SelfHostMcpMode = resolveMcpMode(env), + database?: SelfHostDatabaseConfig, +): void => { + const requiresManagedSecrets = + env.EXECUTOR_REQUIRE_MANAGED_SECRETS === "true" || + mcpMode === "stateless" || + database?.kind === "remote"; + if (!requiresManagedSecrets) return; + + const authSecret = (env.BETTER_AUTH_SECRET ?? env.AUTH_SECRET)?.trim(); + if (!authSecret || !env.EXECUTOR_SECRET_KEY?.trim()) { + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: ephemeral hosts must not generate instance-local encryption or session keys + throw new Error( + "BETTER_AUTH_SECRET (or AUTH_SECRET) and EXECUTOR_SECRET_KEY are required on this host", + ); + } +}; + /** * Master key for the encrypted secret provider. Prefers EXECUTOR_SECRET_KEY; * otherwise generates and persists a random key under the data dir on first * boot (so a single-container deploy is encrypted-by-default without manual * setup). Memoized so repeated per-request reads are cheap. */ -export const resolveSecretKey = (): string => { - if (cachedSecretKey) return cachedSecretKey; - const fromEnv = process.env.EXECUTOR_SECRET_KEY?.trim(); +export const resolveSecretKey = (env: NodeJS.ProcessEnv = process.env): string => { + const cache = env === process.env; + if (cache && cachedSecretKey) return cachedSecretKey; + const fromEnv = env.EXECUTOR_SECRET_KEY?.trim(); if (fromEnv) { - cachedSecretKey = fromEnv; + if (cache) cachedSecretKey = fromEnv; return fromEnv; } - const keyPath = join(resolveDataDir(), "secret.key"); + const keyPath = join(resolveDataDir(env), "secret.key"); if (existsSync(keyPath)) { - cachedSecretKey = readFileSync(keyPath, "utf8").trim(); - return cachedSecretKey; + const stored = readFileSync(keyPath, "utf8").trim(); + if (cache) cachedSecretKey = stored; + return stored; } - mkdirSync(resolveDataDir(), { recursive: true }); + mkdirSync(resolveDataDir(env), { recursive: true }); const generated = randomBytes(32).toString("base64"); writeFileSync(keyPath, generated, { mode: 0o600 }); console.warn( `[executor] generated a secret-encryption key at ${keyPath}. Set EXECUTOR_SECRET_KEY to manage it explicitly (and to keep secrets readable across data-dir changes).`, ); - cachedSecretKey = generated; + if (cache) cachedSecretKey = generated; return generated; }; @@ -86,30 +124,92 @@ let cachedAuthSecret: string | undefined; * first boot (so a single-container deploy boots with no env and keeps sessions * valid across restarts). Memoized; mirrors {@link resolveSecretKey}. */ -export const resolveAuthSecret = (): string => { - if (cachedAuthSecret) return cachedAuthSecret; - const fromEnv = (process.env.BETTER_AUTH_SECRET ?? process.env.AUTH_SECRET)?.trim(); +export const resolveAuthSecret = (env: NodeJS.ProcessEnv = process.env): string => { + const cache = env === process.env; + if (cache && cachedAuthSecret) return cachedAuthSecret; + const fromEnv = (env.BETTER_AUTH_SECRET ?? env.AUTH_SECRET)?.trim(); if (fromEnv) { - cachedAuthSecret = fromEnv; + if (cache) cachedAuthSecret = fromEnv; return fromEnv; } - const keyPath = join(resolveDataDir(), "auth-secret.key"); + const keyPath = join(resolveDataDir(env), "auth-secret.key"); if (existsSync(keyPath)) { - cachedAuthSecret = readFileSync(keyPath, "utf8").trim(); - return cachedAuthSecret; + const stored = readFileSync(keyPath, "utf8").trim(); + if (cache) cachedAuthSecret = stored; + return stored; } - mkdirSync(resolveDataDir(), { recursive: true }); + mkdirSync(resolveDataDir(env), { recursive: true }); const generated = randomBytes(32).toString("base64"); writeFileSync(keyPath, generated, { mode: 0o600 }); console.warn( `[executor] generated a session secret at ${keyPath}. Set BETTER_AUTH_SECRET to manage it explicitly (rotating it signs everyone out).`, ); - cachedAuthSecret = generated; + if (cache) cachedAuthSecret = generated; return generated; }; let warnedNoPublicUrl = false; +const REMOTE_DATABASE_PROTOCOLS = new Set(["libsql:", "https:", "http:", "wss:", "ws:"]); + +/** Resolve the local SQLite or remote libSQL connection from environment variables. */ +export const resolveDatabaseConfig = ( + env: NodeJS.ProcessEnv = process.env, +): SelfHostDatabaseConfig => { + const executorUrl = env.EXECUTOR_DB_URL?.trim() || undefined; + const executorAuthToken = env.EXECUTOR_DB_AUTH_TOKEN?.trim() || undefined; + const tursoUrl = env.TURSO_DATABASE_URL?.trim() || undefined; + const tursoAuthToken = env.TURSO_AUTH_TOKEN?.trim() || undefined; + const url = executorUrl ?? tursoUrl; + const authToken = executorUrl ? executorAuthToken : tursoAuthToken; + + if ((executorAuthToken && !executorUrl) || (tursoAuthToken && !tursoUrl)) { + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: incomplete database credentials must fail before boot + throw new Error( + "EXECUTOR_DB_AUTH_TOKEN or TURSO_AUTH_TOKEN requires EXECUTOR_DB_URL or TURSO_DATABASE_URL", + ); + } + + if (url) { + let protocol: string; + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: operator-provided database URL is validated once at startup + try { + protocol = new URL(url).protocol; + } catch { + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: invalid database URL must fail before boot + throw new Error("EXECUTOR_DB_URL or TURSO_DATABASE_URL must be an absolute URL"); + } + if (!REMOTE_DATABASE_PROTOCOLS.has(protocol)) { + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: unsupported database transport must fail before boot + throw new Error( + "EXECUTOR_DB_URL or TURSO_DATABASE_URL must use libsql, https, http, wss, or ws", + ); + } + return authToken ? { kind: "remote", url, authToken } : { kind: "remote", url }; + } + + if (resolveMcpMode(env) === "stateless") { + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: request-isolated hosts must not silently persist state to ephemeral storage + throw new Error( + "EXECUTOR_DB_URL or TURSO_DATABASE_URL is required when EXECUTOR_MCP_MODE=stateless", + ); + } + + const dataDir = env.EXECUTOR_DATA_DIR ?? join(process.cwd(), ".executor-selfhost"); + return { + kind: "file", + path: env.EXECUTOR_DB_PATH ?? join(dataDir, "data.db"), + }; +}; + +/** Resolve the MCP serving mode, rejecting silent typos at startup. */ +export const resolveMcpMode = (env: NodeJS.ProcessEnv = process.env): SelfHostMcpMode => { + const mode = env.EXECUTOR_MCP_MODE?.trim() ?? "stateful"; + if (mode === "stateful" || mode === "stateless") return mode; + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: unsupported serving mode must fail before boot + throw new Error('EXECUTOR_MCP_MODE must be either "stateful" or "stateless"'); +}; + // The public origin used to build absolute links (OAuth redirects, MCP OAuth // metadata, the connect-card URL). Priority via the shared resolver: an explicit // EXECUTOR_WEB_BASE_URL, then a platform-injected origin (zero-config on @@ -117,45 +217,69 @@ let warnedNoPublicUrl = false; // from the request `Host` — that's spoofable and would let host-header injection // poison those links (the request origin is only trusted for the CSRF/ // `trustedOrigins` check, which is same-origin-safe; see better-auth.ts). -const resolveWebBaseUrl = (port: number): string => { +const resolveWebBaseUrl = (port: number, env: NodeJS.ProcessEnv = process.env): string => { const resolved = resolvePublicOrigin({ - explicit: process.env.EXECUTOR_WEB_BASE_URL, - env: process.env, + explicit: env.EXECUTOR_WEB_BASE_URL, + env, }); if (resolved) return resolved; const fallback = `http://localhost:${port}`; // A deployed instance with no detectable origin mints localhost links — warn // once (unless local dev/test) so the operator sets the variable. - if (!warnedNoPublicUrl && shouldWarnMissingPublicOrigin(process.env.NODE_ENV)) { + if (!warnedNoPublicUrl && shouldWarnMissingPublicOrigin(env.NODE_ENV)) { warnedNoPublicUrl = true; - console.warn(missingPublicOriginWarning({ varName: "EXECUTOR_WEB_BASE_URL", fallback })); + console.warn( + missingPublicOriginWarning({ + varName: "EXECUTOR_WEB_BASE_URL", + fallback, + }), + ); } return fallback; }; -export const loadConfig = (): SelfHostConfig => { - const port = Number.parseInt(process.env.PORT ?? "4788", 10); - const dataDir = resolveDataDir(); +export interface LoadSelfHostConfigOptions { + readonly env?: NodeJS.ProcessEnv; + readonly database?: SelfHostDatabaseConfig; + readonly mcpMode?: SelfHostMcpMode; +} + +export const loadHostConfig = ( + env: NodeJS.ProcessEnv = process.env, +): Pick => { + const port = Number.parseInt(env.PORT ?? "4788", 10); return { - host: process.env.EXECUTOR_HOST ?? "127.0.0.1", + host: env.EXECUTOR_HOST ?? "127.0.0.1", port, - dbPath: process.env.EXECUTOR_DB_PATH ?? join(dataDir, "data.db"), - webBaseUrl: resolveWebBaseUrl(port), - allowLocalNetwork: process.env.EXECUTOR_ALLOW_LOCAL_NETWORK === "true", - authSecret: resolveAuthSecret(), - bootstrapAdminEmail: process.env.EXECUTOR_BOOTSTRAP_ADMIN_EMAIL, - bootstrapAdminPassword: process.env.EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD, - bootstrapAdminName: process.env.EXECUTOR_BOOTSTRAP_ADMIN_NAME ?? "Admin", - organizationName: process.env.EXECUTOR_ORG_NAME ?? "Default", - orgSlug: resolveOrgSlug(), + webBaseUrl: resolveWebBaseUrl(port, env), + allowLocalNetwork: env.EXECUTOR_ALLOW_LOCAL_NETWORK === "true", + }; +}; + +export const loadConfig = (options: LoadSelfHostConfigOptions = {}): SelfHostConfig => { + const env = options.env ?? process.env; + const host = loadHostConfig(env); + const mcpMode = options.mcpMode ?? resolveMcpMode(env); + const database = options.database ?? resolveDatabaseConfig(env); + assertManagedSecretsConfigured(env, mcpMode, database); + return { + ...host, + database, + mcpMode, + authSecret: resolveAuthSecret(env), + bootstrapAdminEmail: env.EXECUTOR_BOOTSTRAP_ADMIN_EMAIL, + bootstrapAdminPassword: env.EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD, + bootstrapAdminName: env.EXECUTOR_BOOTSTRAP_ADMIN_NAME ?? "Admin", + organizationName: env.EXECUTOR_ORG_NAME ?? "Default", + orgSlug: resolveOrgSlug(env), }; }; // The org slug doubles as a URL segment (`//policies`), so an // operator-set value must fit the shared grammar and avoid reserved root // segments (api, mcp, login, …) — a colliding slug would shadow real routes. -const resolveOrgSlug = (): string => { - const slug = process.env.EXECUTOR_ORG_SLUG; +const resolveOrgSlug = (env: NodeJS.ProcessEnv = process.env): string => { + const slug = env.EXECUTOR_ORG_SLUG; if (!slug) return "default"; if (!isValidOrgSlug(slug) && slug !== "default") { // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: a colliding org slug would shadow app routes; refuse to boot diff --git a/apps/host-selfhost/src/db/boot-lock.test.ts b/apps/host-selfhost/src/db/boot-lock.test.ts new file mode 100644 index 000000000..d1046e5e6 --- /dev/null +++ b/apps/host-selfhost/src/db/boot-lock.test.ts @@ -0,0 +1,83 @@ +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { createClient } from "@libsql/client"; +import { expect, test } from "@effect/vitest"; + +import { withSelfHostBootLock } from "./boot-lock"; + +test("serializes concurrent startup work across database clients", async () => { + const databasePath = join(mkdtempSync(join(tmpdir(), "executor-boot-lock-")), "lock.db"); + const firstClient = createClient({ url: `file:${databasePath}` }); + const secondClient = createClient({ url: `file:${databasePath}` }); + let active = 0; + let maximumActive = 0; + + const run = (client: typeof firstClient) => + withSelfHostBootLock( + client, + async () => { + active += 1; + maximumActive = Math.max(maximumActive, active); + await new Promise((resolve) => setTimeout(resolve, 30)); + active -= 1; + }, + { leaseMs: 300, pollMs: 5, waitTimeoutMs: 1_000 }, + ); + + await Promise.all([run(firstClient), run(secondClient)]); + + expect(maximumActive).toBe(1); + firstClient.close(); + secondClient.close(); +}); + +test("takes over an expired startup lease", async () => { + const client = createClient({ url: ":memory:" }); + await client.execute( + "CREATE TABLE executor_boot_lock (name TEXT PRIMARY KEY, owner TEXT NOT NULL, expires_at INTEGER NOT NULL)", + ); + await client.execute({ + sql: "INSERT INTO executor_boot_lock (name, owner, expires_at) VALUES (?, ?, ?)", + args: ["startup", "crashed-container", Date.now() - 1], + }); + + const result = await withSelfHostBootLock(client, async () => "recovered", { + leaseMs: 300, + pollMs: 5, + takeoverGraceMs: 20, + waitTimeoutMs: 100, + }); + + expect(result).toBe("recovered"); + client.close(); +}); + +test("keeps ownership while boot work holds SQLite's writer lock", async () => { + const databasePath = join(mkdtempSync(join(tmpdir(), "executor-boot-busy-")), "lock.db"); + const lockClient = createClient({ url: `file:${databasePath}` }); + const bootClient = createClient({ url: `file:${databasePath}` }); + const lockOptions = { + leaseMs: 180, + pollMs: 10, + takeoverGraceMs: 80, + waitTimeoutMs: 1_000, + } as const; + + const result = await withSelfHostBootLock( + lockClient, + async () => { + await bootClient.execute("BEGIN IMMEDIATE"); + await new Promise((resolve) => setTimeout(resolve, 160)); + await bootClient.execute("COMMIT"); + await new Promise((resolve) => setTimeout(resolve, 40)); + return "completed"; + }, + lockOptions, + ); + + expect(result).toBe("completed"); + lockClient.close(); + bootClient.close(); +}); diff --git a/apps/host-selfhost/src/db/boot-lock.ts b/apps/host-selfhost/src/db/boot-lock.ts new file mode 100644 index 000000000..7fcf9ecc9 --- /dev/null +++ b/apps/host-selfhost/src/db/boot-lock.ts @@ -0,0 +1,229 @@ +import { randomUUID } from "node:crypto"; + +import type { Client } from "@libsql/client"; + +// Remote self-host deployments can cold-start several containers against the +// same database. Schema/data migrations and first-run auth seeding must remain +// single-writer, so remote boots take a short, renewable database lease around +// that whole critical section. A crashed container's lease expires and another +// container can recover without operator intervention. + +const LOCK_TABLE = "executor_boot_lock"; +const LOCK_NAME = "startup"; +const LEASE_EXPIRY_SAFETY_MS = 1_000; +const TAKEOVER_GRACE_MS = 2_000; + +export interface SelfHostBootLockOptions { + readonly leaseMs?: number; + readonly pollMs?: number; + readonly takeoverGraceMs?: number; + readonly waitTimeoutMs?: number; +} + +export class SelfHostBootLockError extends Error { + override readonly name = "SelfHostBootLockError"; +} + +const terminateBeforeLeaseExpiry = (cause: unknown): never => { + console.error( + "[executor] Cannot safely renew the shared database startup lock; terminating this container before another boot can acquire it.", + cause, + ); + // oxlint-disable-next-line no-process-exit -- boundary: fail-stop preserves exclusive migration ownership when the distributed lease cannot be renewed + process.exit(1); +}; + +const sleep = (durationMs: number): Promise => + new Promise((resolve) => setTimeout(resolve, durationMs)); + +const isDatabaseBusy = (cause: unknown): boolean => { + let current: unknown = cause; + const seen = new Set(); + while (typeof current === "object" && current !== null && !seen.has(current)) { + seen.add(current); + if ("code" in current && (current as { readonly code?: unknown }).code === "SQLITE_BUSY") { + return true; + } + // oxlint-disable-next-line executor/no-instanceof-error -- boundary: libSQL exposes driver failures as unknown values and SQLITE_BUSY is identified from the adapter's Error chain + if (current instanceof Error) { + if (/SQLITE_BUSY|database is locked/i.test(current.message)) return true; + current = current.cause; + continue; + } + return false; + } + return false; +}; + +export const withSelfHostBootLock = async ( + client: Pick, + run: () => Promise, + options: SelfHostBootLockOptions = {}, +): Promise => { + const leaseMs = options.leaseMs ?? 30_000; + const pollMs = options.pollMs ?? 250; + const takeoverGraceMs = options.takeoverGraceMs ?? TAKEOVER_GRACE_MS; + const waitTimeoutMs = options.waitTimeoutMs ?? 120_000; + const leaseExpirySafetyMs = Math.min( + LEASE_EXPIRY_SAFETY_MS, + Math.max(1, Math.floor(leaseMs / 4)), + Math.max(1, Math.floor(takeoverGraceMs / 4)), + ); + const owner = randomUUID(); + const waitDeadline = Date.now() + waitTimeoutMs; + + await client.execute( + `CREATE TABLE IF NOT EXISTS ${LOCK_TABLE} (` + + "name TEXT PRIMARY KEY, owner TEXT NOT NULL, expires_at INTEGER NOT NULL)", + ); + + let leaseExpiresAt = 0; + let observedExpiredLease: + | { readonly owner: string; readonly expiresAt: number; observedAt: number } + | undefined; + while (leaseExpiresAt === 0) { + const now = Date.now(); + const candidateExpiry = now + leaseMs; + const existing = await client.execute({ + sql: `SELECT owner, expires_at FROM ${LOCK_TABLE} WHERE name = ?`, + args: [LOCK_NAME], + }); + const row = existing.rows[0]; + + if (!row) { + const acquired = await client.execute({ + sql: `INSERT INTO ${LOCK_TABLE} (name, owner, expires_at) VALUES (?, ?, ?) ON CONFLICT(name) DO NOTHING`, + args: [LOCK_NAME, owner, candidateExpiry], + }); + if (acquired.rowsAffected > 0) { + leaseExpiresAt = candidateExpiry; + break; + } + observedExpiredLease = undefined; + } else { + const currentOwner = typeof row.owner === "string" ? row.owner : ""; + const currentExpiry = Number(row.expires_at); + if (!currentOwner || !Number.isFinite(currentExpiry)) { + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: malformed lock state cannot safely coordinate startup + throw new SelfHostBootLockError("The shared database startup lock is malformed"); + } + + if (currentExpiry > now) { + observedExpiredLease = undefined; + } else { + if ( + !observedExpiredLease || + observedExpiredLease.owner !== currentOwner || + observedExpiredLease.expiresAt !== currentExpiry + ) { + observedExpiredLease = { + owner: currentOwner, + expiresAt: currentExpiry, + observedAt: now, + }; + } + + const expiredLease = observedExpiredLease; + if (now - expiredLease.observedAt >= takeoverGraceMs) { + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: a current owner may temporarily hold SQLite's writer lock while migrating + try { + const acquired = await client.execute({ + sql: + `UPDATE ${LOCK_TABLE} SET owner = ?, expires_at = ? ` + + "WHERE name = ? AND owner = ? AND expires_at = ?", + args: [owner, candidateExpiry, LOCK_NAME, currentOwner, currentExpiry], + }); + if (acquired.rowsAffected > 0) { + leaseExpiresAt = candidateExpiry; + break; + } + observedExpiredLease = undefined; + } catch (cause) { + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: non-busy libSQL adapter failures must retain their original type and stack + if (!isDatabaseBusy(cause)) throw cause; + // The current boot is still inside a write transaction. Start a + // fresh grace window when the writer becomes reachable again so + // its queued heartbeat has priority over takeover. + expiredLease.observedAt = Date.now(); + } + } + } + } + if (now >= waitDeadline) { + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: startup cannot continue without exclusive migration and seed ownership + throw new SelfHostBootLockError( + `Timed out after ${waitTimeoutMs}ms waiting for the shared database startup lock`, + ); + } + await sleep(pollMs); + } + + let stopped = false; + let latestRenewalError: unknown; + let renewal = Promise.resolve(); + let expiryTimer: ReturnType | undefined; + let heartbeatTimer: ReturnType | undefined; + const armExpiryGuard = (safetyDeadline = leaseExpiresAt + takeoverGraceMs) => { + clearTimeout(expiryTimer); + const durationMs = Math.max(0, safetyDeadline - Date.now() - leaseExpirySafetyMs); + expiryTimer = setTimeout( + () => + terminateBeforeLeaseExpiry( + latestRenewalError ?? + new SelfHostBootLockError("The shared database startup lease expired"), + ), + durationMs, + ); + }; + armExpiryGuard(); + + const renewEveryMs = Math.max(1, Math.floor(leaseMs / 3)); + const scheduleRenewal = (delayMs: number) => { + if (stopped) return; + heartbeatTimer = setTimeout(() => { + renewal = (async () => { + const nextExpiry = Date.now() + leaseMs; + const renewed = await client.execute({ + sql: `UPDATE ${LOCK_TABLE} SET expires_at = ? WHERE name = ? AND owner = ?`, + args: [nextExpiry, LOCK_NAME, owner], + }); + if (renewed.rowsAffected === 0) { + terminateBeforeLeaseExpiry( + new SelfHostBootLockError("Lost the shared database startup lock during boot"), + ); + } + leaseExpiresAt = nextExpiry; + latestRenewalError = undefined; + armExpiryGuard(); + })().then( + () => scheduleRenewal(renewEveryMs), + (cause: unknown) => { + latestRenewalError = cause; + if (isDatabaseBusy(cause)) { + // A SQLite writer lock also blocks takeover. Keep retrying quickly, + // and mirror the grace window contenders must observe after their + // own failed takeover write. + armExpiryGuard(Date.now() + takeoverGraceMs); + } + scheduleRenewal(pollMs); + }, + ); + }, delayMs); + heartbeatTimer.unref?.(); + }; + scheduleRenewal(renewEveryMs); + + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: the database lease must always be released when boot succeeds or fails + try { + return await run(); + } finally { + stopped = true; + clearTimeout(heartbeatTimer); + clearTimeout(expiryTimer); + await renewal; + await client.execute({ + sql: `DELETE FROM ${LOCK_TABLE} WHERE name = ? AND owner = ?`, + args: [LOCK_NAME, owner], + }); + } +}; diff --git a/apps/host-selfhost/src/db/self-host-db.ts b/apps/host-selfhost/src/db/self-host-db.ts index fde91f89b..2b0521c8e 100644 --- a/apps/host-selfhost/src/db/self-host-db.ts +++ b/apps/host-selfhost/src/db/self-host-db.ts @@ -19,7 +19,11 @@ import { } from "@executor-js/api/server"; import type { FumaDb, FumaTables } from "@executor-js/sdk"; -import { SELF_HOST_NAMESPACE, SELF_HOST_SCHEMA_VERSION } from "../config"; +import { + SELF_HOST_NAMESPACE, + SELF_HOST_SCHEMA_VERSION, + type SelfHostDatabaseConfig, +} from "../config"; // --------------------------------------------------------------------------- // SQLite executor DB factory, inline (like apps/local's sqlite-fumadb.ts and @@ -57,10 +61,10 @@ export interface SelfHostDbHandle { readonly fuma: FumaDB[]>; readonly drizzle: LibSQLDatabase>; /** - * The libSQL client for this handle's `file:` URL. Better Auth's LibsqlDialect + * The libSQL client for this handle's local or remote URL. Better Auth's LibsqlDialect * is built on THIS client (one shared connection — see better-auth.ts), and * the seed reads Better Auth's tables through it too. `url` is retained for - * callers that still need the `file:` string (diagnostics, edge swap). + * callers that need the connection URL for diagnostics. */ readonly client: Client; readonly url: string; @@ -71,30 +75,40 @@ export interface CreateSqliteExecutorDbOptions( options: CreateSqliteExecutorDbOptions, ): Promise> => { const version = options.version ?? SELF_HOST_SCHEMA_VERSION; - if (options.path !== ":memory:") { - mkdirSync(dirname(options.path), { recursive: true }); + if (options.database.kind === "file" && options.database.path !== ":memory:") { + mkdirSync(dirname(options.database.path), { recursive: true }); } - const url = toLibsqlFileUrl(options.path); - const client = createClient({ url }); + const url = + options.database.kind === "file" + ? toLibsqlFileUrl(options.database.path) + : options.database.url; + const client = createClient({ + url, + ...(options.database.kind === "remote" && options.database.authToken + ? { authToken: options.database.authToken } + : {}), + }); // Connection PRAGMAs. This is the ONE libSQL connection for the process — // drizzle (executor tables) and Better Auth's LibsqlDialect both run on this // same client (see better-auth.ts), so these apply to every query, auth // included. WAL is a file-level mode; foreign_keys/busy_timeout/synchronous // are connection-level and set once here. await client.execute("PRAGMA foreign_keys = ON"); - await client.execute("PRAGMA journal_mode = WAL"); - // Survive concurrent writes from the multi-user HTTP server, and trade - // fsync-per-commit for fsync-per-checkpoint (durable under WAL). - await client.execute("PRAGMA busy_timeout = 5000"); - await client.execute("PRAGMA synchronous = NORMAL"); + if (options.database.kind === "file") { + await client.execute("PRAGMA journal_mode = WAL"); + // Survive concurrent writes from the multi-user HTTP server, and trade + // fsync-per-commit for fsync-per-checkpoint (durable under WAL). + await client.execute("PRAGMA busy_timeout = 5000"); + await client.execute("PRAGMA synchronous = NORMAL"); + } const schema = createDrizzleRuntimeSchemaFromTables({ tables: options.tables, @@ -142,22 +156,22 @@ export class SelfHostDb extends Context.Service()( ) {} export interface SelfHostDbLayerOptions { - readonly path: string; + readonly database: SelfHostDatabaseConfig; readonly namespace?: string; readonly version?: string; } /** * Open the self-host DB with the full plugin table set. Used both by the layer - * and by the composition root (which needs the raw handle eagerly so Better - * Auth can open its own libSQL connection to the same `file:` URL). + * and by the composition root, which needs the raw handle eagerly so Better + * Auth can share its libSQL client. */ export const createSelfHostDb = (options: SelfHostDbLayerOptions): Promise => createSqliteExecutorDb({ tables: collectTables(), namespace: options.namespace ?? SELF_HOST_NAMESPACE, version: options.version ?? SELF_HOST_SCHEMA_VERSION, - path: options.path, + database: options.database, }); // Shared DbProvider seam (P2a). The self-host handle keeps its libSQL driver, diff --git a/apps/host-selfhost/src/execution.ts b/apps/host-selfhost/src/execution.ts index c7ffbbf23..09a32ad97 100644 --- a/apps/host-selfhost/src/execution.ts +++ b/apps/host-selfhost/src/execution.ts @@ -12,7 +12,7 @@ import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs"; import executorConfig from "../executor.config"; import { SelfHostDb, SelfHostDbProvider } from "./db/self-host-db"; -import { loadConfig } from "./config"; +import { loadHostConfig } from "./config"; // --------------------------------------------------------------------------- // Self-host execution-stack seams. @@ -49,7 +49,7 @@ export const SelfHostPluginsProvider: Layer.Layer = Layer.succe ); export const SelfHostHostConfig: Layer.Layer = Layer.sync(HostConfig, () => { - const config = loadConfig(); + const config = loadHostConfig(); return { allowLocalNetwork: config.allowLocalNetwork, webBaseUrl: config.webBaseUrl, diff --git a/apps/host-selfhost/src/index.ts b/apps/host-selfhost/src/index.ts index ee92a0b34..edc3c8b4a 100644 --- a/apps/host-selfhost/src/index.ts +++ b/apps/host-selfhost/src/index.ts @@ -5,5 +5,12 @@ export { type SelfHostApiHandler, type MakeSelfHostAppOptions, } from "./app"; -export { loadConfig, type SelfHostConfig } from "./config"; +export { + loadConfig, + resolveDatabaseConfig, + resolveMcpMode, + type SelfHostConfig, + type SelfHostDatabaseConfig, + type SelfHostMcpMode, +} from "./config"; export { BetterAuth, buildBetterAuth, betterAuthIdentityLayer } from "./auth"; diff --git a/apps/host-selfhost/src/integrations-mcp.test.ts b/apps/host-selfhost/src/integrations-mcp.test.ts index e29fcbbf4..52fcf5ea9 100644 --- a/apps/host-selfhost/src/integrations-mcp.test.ts +++ b/apps/host-selfhost/src/integrations-mcp.test.ts @@ -69,7 +69,7 @@ const addOrgIntegration = async (organizationId: string): Promise => { // the server. Org-owned connections (and their per-connection tools) are // shared across every member of the tenant. const seedDb = await createSelfHostDb({ - path: dbPath, + database: { kind: "file", path: dbPath }, namespace: "executor_selfhost", version: "1.0.0", }); diff --git a/apps/host-selfhost/src/integrations.test.ts b/apps/host-selfhost/src/integrations.test.ts index fd77778b1..67f8a77d0 100644 --- a/apps/host-selfhost/src/integrations.test.ts +++ b/apps/host-selfhost/src/integrations.test.ts @@ -33,7 +33,7 @@ let dbHandle: Awaited> | undefined; beforeAll(async () => { dbHandle = await createSelfHostDb({ - path: join(dataDir, "data.db"), + database: { kind: "file", path: join(dataDir, "data.db") }, namespace: "executor_selfhost", version: "1.0.0", }); diff --git a/apps/host-selfhost/src/mcp/index.ts b/apps/host-selfhost/src/mcp/index.ts index 52287518c..372530fad 100644 --- a/apps/host-selfhost/src/mcp/index.ts +++ b/apps/host-selfhost/src/mcp/index.ts @@ -10,6 +10,7 @@ import type { import { BetterAuth, type BetterAuthHandle } from "../auth/better-auth"; import type { SelfHostDbHandle } from "../db/self-host-db"; +import type { SelfHostMcpMode } from "../config"; import { selfHostMcpAuth } from "./auth"; import { makeSelfHostMcpSessionStore, @@ -133,8 +134,9 @@ export const makeSelfHostMcpSeams = ( dbHandle: SelfHostDbHandle, betterAuth: BetterAuthHandle, webBaseUrl?: string, + mode: SelfHostMcpMode = "stateful", ): SelfHostMcpSeams => { - const sessionStore = makeSelfHostMcpSessionStore(dbHandle, webBaseUrl); + const sessionStore = makeSelfHostMcpSessionStore(dbHandle, webBaseUrl, mode); const auth: Layer.Layer = selfHostMcpAuth.pipe( Layer.provide(Layer.succeed(BetterAuth)(betterAuth)), ); diff --git a/apps/host-selfhost/src/mcp/session-store.ts b/apps/host-selfhost/src/mcp/session-store.ts index ff005cf62..026ca5438 100644 --- a/apps/host-selfhost/src/mcp/session-store.ts +++ b/apps/host-selfhost/src/mcp/session-store.ts @@ -2,12 +2,17 @@ import { Layer } from "effect"; import { makeConsoleMcpErrorReporter, makeMcpBuildServer } from "@executor-js/api/server"; import type { McpErrorReporter } from "@executor-js/host-mcp"; +import { McpSessionStore } from "@executor-js/host-mcp"; import { - inMemoryMcpSessionsLayer, makeInMemoryMcpSessionStore, type InMemoryMcpSessionStore, } from "@executor-js/host-mcp/in-memory-session-store"; +import { + makeStatelessMcpSessionStore, + type StatelessMcpSessionStore, +} from "@executor-js/host-mcp/stateless-session-store"; +import type { SelfHostMcpMode } from "../config"; import { ErrorCaptureLive } from "../observability"; import { SelfHostDb, type SelfHostDbHandle } from "../db/self-host-db"; import { SelfHostExecutionStackLayer } from "../execution"; @@ -24,6 +29,13 @@ import { SelfHostExecutionStackLayer } from "../execution"; export { McpEngineBuildError } from "@executor-js/host-mcp/in-memory-session-store"; +export type SelfHostMcpSessionStore = + | InMemoryMcpSessionStore + | (StatelessMcpSessionStore & { + readonly handlePausedRequest: () => Promise; + readonly handleApprovalRequest: () => Promise; + }); + /** * Build the in-process session store (plus its `close()` hook) over the DB * handle. `webBaseUrl` is the pinned public origin so browser-approval URLs use @@ -32,16 +44,24 @@ export { McpEngineBuildError } from "@executor-js/host-mcp/in-memory-session-sto export const makeSelfHostMcpSessionStore = ( db: SelfHostDbHandle, webBaseUrl?: string, -): InMemoryMcpSessionStore => - makeInMemoryMcpSessionStore( - makeMcpBuildServer( - SelfHostExecutionStackLayer.pipe(Layer.provide(Layer.succeed(SelfHostDb)(db))), - ), - { webBaseUrl }, + mode: SelfHostMcpMode = "stateful", +): SelfHostMcpSessionStore => { + const buildServer = makeMcpBuildServer( + SelfHostExecutionStackLayer.pipe(Layer.provide(Layer.succeed(SelfHostDb)(db))), ); + if (mode === "stateless") { + return { + ...makeStatelessMcpSessionStore(buildServer), + handlePausedRequest: () => Promise.resolve(null), + handleApprovalRequest: () => Promise.resolve(null), + }; + } + return makeInMemoryMcpSessionStore(buildServer, { webBaseUrl }); +}; /** The `McpSessionStore` envelope seam over a freshly built in-process store. */ -export const selfHostMcpSessions = inMemoryMcpSessionsLayer; +export const selfHostMcpSessions = (built: SelfHostMcpSessionStore): Layer.Layer => + Layer.succeed(McpSessionStore)(built.store); /** Route 500-defects through the host's console `ErrorCapture`. */ export const selfHostMcpReporter: Layer.Layer = diff --git a/apps/host-selfhost/src/secrets-integration.test.ts b/apps/host-selfhost/src/secrets-integration.test.ts index 29b309053..8e098c98f 100644 --- a/apps/host-selfhost/src/secrets-integration.test.ts +++ b/apps/host-selfhost/src/secrets-integration.test.ts @@ -37,7 +37,7 @@ let dbHandle: Awaited> | undefined; beforeAll(async () => { dbHandle = await createSelfHostDb({ - path: dbPath, + database: { kind: "file", path: dbPath }, namespace: "executor_selfhost", version: "1.0.0", }); diff --git a/apps/host-selfhost/src/testing/test-app.ts b/apps/host-selfhost/src/testing/test-app.ts index ddc20bbc8..bee3990a9 100644 --- a/apps/host-selfhost/src/testing/test-app.ts +++ b/apps/host-selfhost/src/testing/test-app.ts @@ -1,3 +1,5 @@ +import { join } from "node:path"; + import { HttpApiSwagger } from "effect/unstable/httpapi"; import { Effect, Layer } from "effect"; @@ -24,7 +26,7 @@ import { SelfHostPluginsProvider, } from "../execution"; import executorConfig from "../../executor.config"; -import { loadConfig, SELF_HOST_NAMESPACE, SELF_HOST_SCHEMA_VERSION } from "../config"; +import { resolveDataDir, SELF_HOST_NAMESPACE, SELF_HOST_SCHEMA_VERSION } from "../config"; import { makeSelfHostMcpSessionStore, selfHostMcpReporter, @@ -138,7 +140,10 @@ const stubMcpAuth: Layer.Layer = Layer return { discoveryRoutes: [ { path: PROTECTED_RESOURCE_METADATA_PATH, handler: notFoundResponse }, - { path: AUTHORIZATION_SERVER_METADATA_PATH, handler: notFoundResponse }, + { + path: AUTHORIZATION_SERVER_METADATA_PATH, + handler: notFoundResponse, + }, ], resourceMetadataUrl: resourceMetadataUrlFor, authenticate: (request: Request): Effect.Effect => @@ -180,10 +185,11 @@ export interface SelfHostTestHandler { export const makeSelfHostTestApp = async ( options: MakeSelfHostTestAppOptions, ): Promise => { - const config = loadConfig(); - const dbHandle = await createSelfHostDb({ - path: options.dbPath ?? config.dbPath, + database: { + kind: "file", + path: options.dbPath ?? process.env.EXECUTOR_DB_PATH ?? join(resolveDataDir(), "data.db"), + }, namespace: SELF_HOST_NAMESPACE, version: SELF_HOST_SCHEMA_VERSION, }); @@ -221,7 +227,9 @@ export const makeSelfHostTestApp = async ( }, extensions: { routes: [ - HttpApiSwagger.layer(composePluginApi(selfHostPlugins).prefix("/api"), { path: "/docs" }), + HttpApiSwagger.layer(composePluginApi(selfHostPlugins).prefix("/api"), { + path: "/docs", + }), ], }, config: { mountPrefix: "/api", failure: textFailureStrategy }, diff --git a/packages/hosts/mcp/package.json b/packages/hosts/mcp/package.json index 31d591afa..9ec4863e9 100644 --- a/packages/hosts/mcp/package.json +++ b/packages/hosts/mcp/package.json @@ -16,6 +16,10 @@ "types": "./src/in-memory-session-store.ts", "default": "./src/in-memory-session-store.ts" }, + "./stateless-session-store": { + "types": "./src/stateless-session-store.ts", + "default": "./src/stateless-session-store.ts" + }, "./browser-approval": { "types": "./src/browser-approval.ts", "default": "./src/browser-approval.ts" diff --git a/packages/hosts/mcp/src/envelope.test.ts b/packages/hosts/mcp/src/envelope.test.ts index 9940f10e4..a5ccce969 100644 --- a/packages/hosts/mcp/src/envelope.test.ts +++ b/packages/hosts/mcp/src/envelope.test.ts @@ -110,6 +110,25 @@ describe("McpServingRoutes envelope", () => { expect(response.headers.get("access-control-allow-headers") ?? "").toContain("authorization"); }); + it("answers a stateless client's post-init SSE probe with 405", async () => { + const StatelessStoreLive = Layer.succeed(McpSessionStore)({ + supportsServerSentEvents: false, + dispatch: (): Effect.Effect => + Effect.die("stateless GET probe should not dispatch"), + dispose: () => Effect.void, + }); + const handler = buildHandler(StatelessStoreLive, McpErrorReporterNoop); + const response = await handler( + new Request("https://host.test/mcp", { + method: "GET", + headers: { authorization: "Bearer x", accept: "text/event-stream" }, + }), + ); + + expect(response.status).toBe(405); + expect(response.headers.get("allow")).toBe("POST, DELETE, OPTIONS"); + }); + it("renders 500 -32603 + CORS and fires the reporter on an orchestration defect", async () => { const reported = await Effect.runPromise(Ref.make>([])); const RecordingReporter = Layer.succeed(McpErrorReporter)({ diff --git a/packages/hosts/mcp/src/envelope.ts b/packages/hosts/mcp/src/envelope.ts index 1803048b9..599ff5ed1 100644 --- a/packages/hosts/mcp/src/envelope.ts +++ b/packages/hosts/mcp/src/envelope.ts @@ -235,6 +235,17 @@ const mcpDispatch = (resource: McpResource) => // an engine for a bare GET/DELETE. if (!sessionId) { if (request.method === "GET") { + if (store.supportsServerSentEvents === false) { + return HttpServerResponse.raw( + new Response(null, { + status: 405, + headers: { + allow: "POST, DELETE, OPTIONS", + "access-control-allow-origin": "*", + }, + }), + ); + } return HttpServerResponse.raw( jsonRpcResponse(400, -32000, "mcp-session-id header required for SSE"), ); diff --git a/packages/hosts/mcp/src/in-memory-session-store.ts b/packages/hosts/mcp/src/in-memory-session-store.ts index e2d233e8c..8f53425b7 100644 --- a/packages/hosts/mcp/src/in-memory-session-store.ts +++ b/packages/hosts/mcp/src/in-memory-session-store.ts @@ -65,6 +65,7 @@ export interface BuiltMcpServer { /** The browser-mode wiring the store hands a build call when a session opts in. */ export interface McpBuildServerOptions { readonly resource?: McpResource; + readonly stateless?: true; readonly elicitationMode?: | { readonly mode: "browser"; readonly approvalUrl: (executionId: string) => string } | { readonly mode: "model" } diff --git a/packages/hosts/mcp/src/seams.ts b/packages/hosts/mcp/src/seams.ts index 12e713dd9..2f025aa6b 100644 --- a/packages/hosts/mcp/src/seams.ts +++ b/packages/hosts/mcp/src/seams.ts @@ -248,6 +248,8 @@ export type McpDispatchResult = Response | "not-found" | "forbidden"; export class McpSessionStore extends Context.Service< McpSessionStore, { + /** Whether bare GET requests may open an SSE stream for a stateful session. */ + readonly supportsServerSentEvents?: boolean; /** * Serve one `/mcp` request end to end. Owns create (no session id + POST * initialize), forward (session id present), and ownership (cross-bearer -> diff --git a/packages/hosts/mcp/src/stateless-session-store.test.ts b/packages/hosts/mcp/src/stateless-session-store.test.ts new file mode 100644 index 000000000..31112ed30 --- /dev/null +++ b/packages/hosts/mcp/src/stateless-session-store.test.ts @@ -0,0 +1,99 @@ +import { expect, test } from "@effect/vitest"; +import { Effect } from "effect"; +import type { ExecutionEngine } from "@executor-js/execution"; + +import { createExecutorMcpServer } from "./tool-server"; +import type { McpBuildServer } from "./in-memory-session-store"; +import { makeStatelessMcpSessionStore } from "./stateless-session-store"; +import type { Principal } from "./seams"; + +const engine: ExecutionEngine = { + execute: () => Effect.succeed({ result: "ok" }), + executeWithPause: () => Effect.succeed({ status: "completed", result: { result: "ok" } }), + resume: () => Effect.succeed(null), + getPausedExecution: () => Effect.succeed(null), + pausedExecutionCount: () => Effect.succeed(0), + hasPausedExecutions: () => Effect.succeed(false), + getDescription: Effect.succeed("test executor"), +}; + +const principal: Principal = { + accountId: "account", + organizationId: "organization", + organizationName: "Organization", + email: "test@example.com", + name: "Test", + avatarUrl: null, + roles: ["admin"], +}; + +const request = (body: unknown): Request => + new Request("https://executor.example.com/mcp", { + method: "POST", + headers: { + accept: "application/json, text/event-stream", + "content-type": "application/json", + "mcp-protocol-version": "2025-03-26", + }, + body: JSON.stringify(body), + }); + +const buildServer: McpBuildServer = (_principal, options) => + createExecutorMcpServer({ engine, ...options }).pipe( + Effect.map((mcpServer) => ({ mcpServer, engine })), + ); + +test("stateless MCP handles independent POSTs without issuing a session id", async () => { + const sessions = makeStatelessMcpSessionStore(buildServer); + + const initialized = await Effect.runPromise( + sessions.store.dispatch({ + request: request({ + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2025-03-26", + capabilities: {}, + clientInfo: { name: "test", version: "1" }, + }, + }), + principal, + resource: { kind: "default" }, + sessionId: null, + method: "POST", + }), + ); + expect(initialized).toBeInstanceOf(Response); + if (!(initialized instanceof Response)) return; + expect(initialized.status).toBe(200); + expect(initialized.headers.get("mcp-session-id")).toBeNull(); + + const listed = await Effect.runPromise( + sessions.store.dispatch({ + request: request({ jsonrpc: "2.0", id: 2, method: "tools/list", params: {} }), + principal, + resource: { kind: "default" }, + sessionId: null, + method: "POST", + }), + ); + expect(listed).toBeInstanceOf(Response); + if (!(listed instanceof Response)) return; + expect(listed.status).toBe(200); + expect(JSON.stringify(await listed.json())).toContain("execute"); +}); + +test("stateless MCP rejects session-addressed requests", async () => { + const sessions = makeStatelessMcpSessionStore(buildServer); + const result = await Effect.runPromise( + sessions.store.dispatch({ + request: request({ jsonrpc: "2.0", id: 3, method: "tools/list", params: {} }), + principal, + resource: { kind: "default" }, + sessionId: "stale-session", + method: "POST", + }), + ); + expect(result).toBe("not-found"); +}); diff --git a/packages/hosts/mcp/src/stateless-session-store.ts b/packages/hosts/mcp/src/stateless-session-store.ts new file mode 100644 index 000000000..0a13a819d --- /dev/null +++ b/packages/hosts/mcp/src/stateless-session-store.ts @@ -0,0 +1,79 @@ +import { Effect, Layer } from "effect"; +import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"; + +import { jsonRpcErrorBody } from "./envelope"; +import type { McpBuildServer } from "./in-memory-session-store"; +import { McpSessionStore, defaultMcpResource, type McpDispatchResult } from "./seams"; + +/** A request-isolated MCP store for hosts that cannot retain process memory. */ +export interface StatelessMcpSessionStore { + readonly store: McpSessionStore["Service"]; + readonly close: () => Promise; +} + +const buildFailureResponse = (): Response => + jsonRpcErrorBody(500, -32603, "Internal server error", { cors: false }); + +/** + * Build a fresh MCP server and transport for every POST. The transport omits a + * session id, so clients never attempt to address process-local state on a + * later request. Stateful resume and browser approval are therefore unavailable. + */ +export const makeStatelessMcpSessionStore = ( + buildServer: McpBuildServer, +): StatelessMcpSessionStore => { + const dispatch: McpSessionStore["Service"]["dispatch"] = ({ + request, + principal, + resource, + sessionId, + }): Effect.Effect => { + if (sessionId) return Effect.succeed("not-found"); + + return buildServer(principal, { + resource: resource ?? defaultMcpResource, + stateless: true, + elicitationMode: { mode: "model" }, + }).pipe( + Effect.flatMap(({ mcpServer }) => + Effect.promise(async () => { + const transport = new WebStandardStreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + enableJsonResponse: true, + }); + await mcpServer.connect(transport); + // JSON mode lets us materialize the response before closing the + // request-scoped server, avoiding a dangling stream after teardown. + const response = await transport.handleRequest(request); + const body = await response.arrayBuffer(); + return new Response(body.byteLength === 0 ? null : body, { + status: response.status, + statusText: response.statusText, + headers: response.headers, + }); + }).pipe( + Effect.ensuring( + Effect.ignore( + Effect.tryPromise({ try: () => mcpServer.close(), catch: () => undefined }), + ), + ), + ), + ), + Effect.catchTag("McpEngineBuildError", () => Effect.succeed(buildFailureResponse())), + ); + }; + + return { + store: { + supportsServerSentEvents: false, + dispatch, + dispose: () => Effect.void, + }, + close: () => Promise.resolve(), + }; +}; + +/** Layer wrapping a stateless store for the shared MCP serving envelope. */ +export const statelessMcpSessionsLayer = ( + built: StatelessMcpSessionStore, +): Layer.Layer => Layer.succeed(McpSessionStore)(built.store); diff --git a/packages/hosts/mcp/src/tool-server.test.ts b/packages/hosts/mcp/src/tool-server.test.ts index ef224cabd..ccb22eb34 100644 --- a/packages/hosts/mcp/src/tool-server.test.ts +++ b/packages/hosts/mcp/src/tool-server.test.ts @@ -66,6 +66,7 @@ const withClient = async ( | "pausedExecutionHooks" | "pausedExecutionLeaseMs" | "resumeFallback" + | "stateless" >, ) => { const mcpServer = await Effect.runPromise(createExecutorMcpServer({ engine, ...config })); @@ -960,6 +961,46 @@ describe("MCP host server — client without elicitation (pause/resume)", () => }); }); + it("stateless mode hides resume and fails a paused execution clearly", async () => { + const resumeCalls: Array<{ executionId: string; action: string }> = []; + const engine = makeStubEngine({ + executeWithPause: () => + Effect.succeed( + makePausedResult( + "exec_stateless", + FormElicitation.make({ message: "Need approval", requestedSchema: {} }), + ), + ), + resume: (executionId, response) => + Effect.sync(() => { + resumeCalls.push({ executionId, action: response.action }); + return { status: "completed", result: { result: null } }; + }), + }); + + await withClient( + engine, + NO_CAPS, + async (client) => { + const { tools } = await client.listTools(); + expect(tools.map((tool) => tool.name)).not.toContain("resume"); + + const result = await client.callTool({ + name: "execute", + arguments: { code: "pause-me" }, + }); + expect(result.isError).toBe(true); + expect(textOf(result)).toContain("stateless"); + expect(result.structuredContent).toMatchObject({ + status: "interaction_unavailable", + reason: "stateless_mcp", + }); + expect(resumeCalls).toEqual([{ executionId: "exec_stateless", action: "cancel" }]); + }, + { stateless: true }, + ); + }); + it("browser approval mode requires user approval before resume", async () => { let resumeCalled = false; const engine = makeStubEngine({ diff --git a/packages/hosts/mcp/src/tool-server.ts b/packages/hosts/mcp/src/tool-server.ts index 07daf0e23..6dec294f3 100644 --- a/packages/hosts/mcp/src/tool-server.ts +++ b/packages/hosts/mcp/src/tool-server.ts @@ -82,6 +82,8 @@ type SharedMcpServerConfig = { * Enable verbose MCP capability / elicitation debug logging. */ readonly debug?: boolean; + /** Disable tools and outcomes that require state to survive another request. */ + readonly stateless?: true; /** * Controls how elicitation is handled for this MCP connection. The default * is model-managed resume, where paused executions expose interaction @@ -122,9 +124,7 @@ type SharedMcpServerConfig = { export type ExecutorMcpServerConfig = | (ExecutionEngineConfig & SharedMcpServerConfig) - | ({ readonly engine: ExecutionEngine } & SharedMcpServerConfig) - | (ExecutionEngineConfig & SharedMcpServerConfig & { readonly stateless: true }) - | ({ readonly engine: ExecutionEngine; readonly stateless: true } & SharedMcpServerConfig); + | ({ readonly engine: ExecutionEngine } & SharedMcpServerConfig); export type BrowserApprovalStore = { readonly takeResponse: (executionId: string) => Effect.Effect; @@ -594,6 +594,16 @@ const missingExecutionResult = (executionId: string): McpToolResult => const alreadySettledResult = (executionId: string): McpToolResult => resumeUnavailableResult({ status: "execution_already_settled", executionId }); +const statelessPauseUnsupportedResult = (): McpToolResult => { + const text = + "This execution requires an interaction, but this MCP endpoint is stateless and cannot resume paused executions. Change the relevant policy to allow, or use a stateful Executor host."; + return { + content: [{ type: "text", text }], + structuredContent: { status: "interaction_unavailable", reason: "stateless_mcp" }, + isError: true, + }; +}; + const fallbackOutcomeResult = ( executionId: string, outcome: ResumeFallbackOutcome, @@ -677,6 +687,7 @@ export const createExecutorMcpServer = ( // re-enter Effect-land at each callback edge. const context = yield* Effect.context(); const debugEnabled = config.debug ?? readDebugDefault(); + const stateless = "stateless" in config && config.stateless === true; const debugLog = (event: string, data: Record) => { if (!debugEnabled) return; // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: debug logging must tolerate non-serializable SDK capability snapshots @@ -788,6 +799,13 @@ export const createExecutorMcpServer = ( : undefined, }); if (outcome.status === "paused") { + if (stateless) { + // A pausable execution owns a detached sandbox fiber. Stateless + // hosts cannot expose resume, so cancel it before returning the + // unsupported-interaction result instead of orphaning that fiber. + yield* engine.resume(outcome.execution.id, { action: "cancel" }).pipe(Effect.ignore); + return statelessPauseUnsupportedResult(); + } const deadline = pauseDeadline(); yield* Effect.annotateCurrentSpan({ "mcp.execute.paused": true, @@ -970,7 +988,7 @@ export const createExecutorMcpServer = ( ); yield* Effect.sync(() => { - if (elicitationMode.mode === "native") { + if (elicitationMode.mode === "native" || stateless) { return undefined; } @@ -1019,19 +1037,20 @@ export const createExecutorMcpServer = ( ); yield* Effect.sync(() => { + const resumeEnabled = !stateless && elicitationMode.mode !== "native"; console.error( "[executor] MCP session mode", JSON.stringify({ ...capabilitySnapshot(server), elicitationMode: elicitationMode.mode, - resumeEnabled: elicitationMode.mode !== "native", + resumeEnabled, }), ); debugLog("tool.visibility", { clientCapabilities: server.server.getClientCapabilities() ?? null, elicitationSupport: getElicitationSupport(server), elicitationMode: elicitationMode.mode, - resumeEnabled: elicitationMode.mode !== "native", + resumeEnabled, }); }).pipe(Effect.withSpan("mcp.host.sync_tool_availability")); diff --git a/vercel.json b/vercel.json new file mode 100644 index 000000000..e82f32682 --- /dev/null +++ b/vercel.json @@ -0,0 +1,15 @@ +{ + "services": { + "app": { + "root": ".", + "runtime": "container", + "entrypoint": "Dockerfile" + } + }, + "rewrites": [ + { + "source": "/(.*)", + "destination": { "type": "service", "service": "app" } + } + ] +}