Skip to content

Commit 2df2c23

Browse files
committed
feat: support stateless Vercel hosting
1 parent 7207347 commit 2df2c23

34 files changed

Lines changed: 1239 additions & 124 deletions

‎.gitignore‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,3 +115,4 @@ scratch/
115115
# Throwaway UX prototype (not part of the app)
116116
ux-demo/
117117

118+
.vercel

‎Dockerfile‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# Vercel container build for the self-hosted app. Vercel containers have no
2+
# persistent filesystem or process affinity, so this image selects remote
3+
# libSQL storage and request-isolated MCP behavior through environment config.
4+
5+
FROM oven/bun:1 AS build
6+
WORKDIR /app
7+
COPY . .
8+
RUN bun install --frozen-lockfile
9+
RUN bun run apps/host-selfhost/scripts/package-runtime.ts \
10+
&& cd apps/host-selfhost \
11+
&& bun run build
12+
13+
FROM gcr.io/distroless/cc-debian12 AS runtime
14+
WORKDIR /app
15+
LABEL org.opencontainers.image.source="https://github.com/UsefulSoftwareCo/executor" \
16+
org.opencontainers.image.description="Vercel deployment of self-hosted Executor" \
17+
org.opencontainers.image.licenses="MIT"
18+
ENV NODE_ENV=production \
19+
EXECUTOR_HOST=0.0.0.0 \
20+
PORT=80 \
21+
EXECUTOR_DATA_DIR=/tmp/executor \
22+
EXECUTOR_MCP_MODE=stateless \
23+
EXECUTOR_REQUIRE_MANAGED_SECRETS=true
24+
COPY --from=build /usr/local/bin/bun /usr/local/bin/bun
25+
COPY --from=build /app/.selfhost-runtime /app
26+
COPY --from=build /app/apps/host-selfhost/dist /app/apps/host-selfhost/dist
27+
WORKDIR /app/apps/host-selfhost
28+
EXPOSE 80
29+
CMD ["bun", "run", "dist-server/serve.js"]

‎README.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ client.
2929
- **Governed by policy.** Each tool is allowed, gated behind approval, or
3030
blocked, with sensible defaults derived from the spec.
3131
- **Run it your way.** Local CLI, a desktop app, hosted Executor Cloud, or
32-
self-hosted on Docker or Cloudflare. Same functionality, different packaging.
32+
self-hosted on Docker, Vercel, or Cloudflare. Same core, different packaging.
3333

3434
## How it works
3535

@@ -77,6 +77,7 @@ Every form exposes the same functionality, just packaged differently.
7777
| **CLI** | A headless or server environment. Runs a local background service. | [CLI](https://executor.sh/docs/local/cli) |
7878
| **Desktop app** | A regular desktop (Mac, Windows, Linux). The same runtime, as a native app. | [Desktop](https://executor.sh/docs/local/desktop) |
7979
| **Self-host (Docker)** | Your own infrastructure, full control. | [Docker](https://executor.sh/docs/hosted/docker) |
80+
| **Self-host (Vercel)** | A stateless container deployment backed by remote libSQL. | [Vercel](https://executor.sh/docs/hosted/vercel) |
8081
| **Self-host (Cloudflare)** | Deploy as a Cloudflare Worker. | [Cloudflare](https://executor.sh/docs/hosted/cloudflare) |
8182

8283
## Connect an agent over MCP

‎apps/docs/docs.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@
3333
},
3434
{
3535
"group": "Hosted",
36-
"pages": ["hosted/cloud", "hosted/docker", "hosted/cloudflare"]
36+
"pages": ["hosted/cloud", "hosted/docker", "hosted/vercel", "hosted/cloudflare"]
3737
},
3838
{
3939
"group": "Concepts",

‎apps/docs/hosted/docker.mdx‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,12 @@ Back it up by snapshotting that volume (or copying `/data`, primarily `data.db`)
4848
`EXECUTOR_DATA_DIR` (default `/data`) sets the directory, and `EXECUTOR_DB_PATH`
4949
(default `<data dir>/data.db`) sets the database file.
5050

51+
For a host without a persistent filesystem, set `EXECUTOR_DB_URL` and optionally
52+
`EXECUTOR_DB_AUTH_TOKEN` to use remote libSQL. The aliases
53+
`TURSO_DATABASE_URL` and `TURSO_AUTH_TOKEN` are also recognized. Remote storage
54+
requires explicit `BETTER_AUTH_SECRET` and `EXECUTOR_SECRET_KEY` values because
55+
generated keys cannot be recovered from an ephemeral data directory.
56+
5157
## Environment variables
5258

5359
Everything is optional: a bare run boots a working instance. The defaults below are
@@ -59,6 +65,11 @@ the container defaults.
5965
| `EXECUTOR_HOST` | `0.0.0.0` | Bind address. The image binds all interfaces. |
6066
| `EXECUTOR_DATA_DIR` | `/data` | Directory holding the database and generated keys. |
6167
| `EXECUTOR_DB_PATH` | `<data dir>/data.db` | SQLite database file. |
68+
| `EXECUTOR_DB_URL` | unset | Remote libSQL URL. Takes precedence over `EXECUTOR_DB_PATH`. |
69+
| `EXECUTOR_DB_AUTH_TOKEN` | unset | Optional remote libSQL authentication token. |
70+
| `TURSO_DATABASE_URL` | unset | Alias for `EXECUTOR_DB_URL`, used by the Vercel Turso integration. |
71+
| `TURSO_AUTH_TOKEN` | unset | Alias for `EXECUTOR_DB_AUTH_TOKEN`, used by the Vercel Turso integration. |
72+
| `EXECUTOR_MCP_MODE` | `stateful` | Set `stateless` only on request-isolated hosts; disables resume and browser approval. |
6273
| `EXECUTOR_WEB_BASE_URL` | auto (`http://localhost:4788`) | Public URL browsers use. Required behind a domain or TLS (see below). |
6374
| `BETTER_AUTH_SECRET` | generated, persisted in `/data` | Session secret (32+ chars). Rotating it signs everyone out. |
6475
| `EXECUTOR_SECRET_KEY` | generated, persisted in `/data` | Master key encrypting stored secrets. Set it to manage it yourself. |

‎apps/docs/hosted/vercel.mdx‎

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
---
2+
title: Self Hosted Vercel
3+
description: "Deploy Executor as a stateless Vercel container with remote libSQL storage."
4+
---
5+
6+
Executor can run on Vercel from the repository-root `Dockerfile`. The
7+
repository's `vercel.json` defines the container service and routes all public traffic to it.
8+
Vercel Functions do not preserve a local filesystem or route later requests to
9+
the same process, so this deployment uses remote libSQL for durable data and a
10+
stateless MCP transport.
11+
12+
## Prerequisites
13+
14+
- A Vercel account with Docker deployments available.
15+
- A remote libSQL database. The Turso integration from the Vercel Marketplace
16+
provides one and injects its connection variables automatically.
17+
- The [Executor repo](https://github.com/UsefulSoftwareCo/executor) in your Git
18+
provider account.
19+
20+
## Create the project
21+
22+
Import the repository into Vercel. Keep the repository root as the project root
23+
and select the **Services** framework preset. The checked-in `vercel.json`
24+
selects the container runtime, points it at `Dockerfile`, and routes
25+
requests to it.
26+
27+
Add the Turso integration to the project from the Vercel Marketplace. Executor
28+
reads the injected `TURSO_DATABASE_URL` and `TURSO_AUTH_TOKEN` variables. A
29+
different libSQL provider can use `EXECUTOR_DB_URL` and
30+
`EXECUTOR_DB_AUTH_TOKEN` instead.
31+
32+
Add two stable random secrets to every environment:
33+
34+
```bash
35+
BETTER_AUTH_SECRET=<at-least-32-random-characters>
36+
EXECUTOR_SECRET_KEY=<random-encryption-key>
37+
```
38+
39+
The production URL is detected from Vercel's system environment variables. Set
40+
`EXECUTOR_WEB_BASE_URL` explicitly if you use a custom domain or disable those
41+
system variables.
42+
43+
Deploy the project. Open its URL and create the first owner account, or set
44+
`EXECUTOR_BOOTSTRAP_ADMIN_EMAIL` and `EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD` before
45+
the first deploy for a headless setup.
46+
47+
## Stateless MCP behavior
48+
49+
The Vercel image sets `EXECUTOR_MCP_MODE=stateless`. Every MCP POST creates a
50+
fresh server and does not issue an `mcp-session-id`, which makes ordinary tool
51+
listing and execution safe across cold starts and scale-out.
52+
53+
<Warning>
54+
Paused executions, the `resume` tool, browser approval, and server-initiated SSE require a live
55+
session and are unavailable in stateless mode. An execution that reaches an approval or
56+
authentication interaction returns a clear error. Change that tool's policy to allow, complete
57+
integration authentication in the web console first, or use the Docker or Cloudflare host when
58+
resumable MCP sessions are required.
59+
</Warning>
60+
61+
The web console and HTTP API persist through remote libSQL and remain available
62+
normally.
63+
64+
## Connect an agent
65+
66+
Point any streamable-HTTP MCP client at the deployed `/mcp` URL:
67+
68+
```bash
69+
npx add-mcp https://your-project.vercel.app/mcp --transport http --name executor
70+
```

‎apps/host-selfhost/.env.example‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,22 @@
88
# rejected. Behind a reverse proxy / TLS, set this to your public https URL.
99
# EXECUTOR_WEB_BASE_URL=https://executor.example.com
1010

11+
# --- Database ---------------------------------------------------------------
12+
# By default Executor stores SQLite at <data dir>/data.db. For platforms with
13+
# no persistent filesystem, point it at remote libSQL instead. The Vercel Turso
14+
# integration injects the TURSO_* aliases automatically.
15+
# EXECUTOR_DB_PATH=/data/data.db
16+
# EXECUTOR_DB_URL=libsql://your-database.turso.io
17+
# EXECUTOR_DB_AUTH_TOKEN=
18+
# TURSO_DATABASE_URL=libsql://your-database.turso.io
19+
# TURSO_AUTH_TOKEN=
20+
21+
# --- MCP serving ------------------------------------------------------------
22+
# Stateful keeps live sessions and browser approvals in this process. Stateless
23+
# creates a fresh server per POST for request-isolated platforms such as Vercel;
24+
# resume and browser approval are unavailable in that mode.
25+
# EXECUTOR_MCP_MODE=stateful
26+
1127
# --- Session secret -----------------------------------------------------------
1228
# Generated and persisted under the data volume on first boot if unset. Set this
1329
# to manage it yourself (must be at least 32 characters). Rotating it signs every

‎apps/host-selfhost/README.md‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@
22

33
The single-container, self-hostable Executor server: the typed API, the MCP
44
server, Better Auth (cookie / bearer / API-key + MCP OAuth), QuickJS code
5-
execution, and the web UI — all in one process over a libSQL (SQLite) file. No
6-
external database, worker, or proxy.
5+
execution, and the web UI, all in one process. It uses a local libSQL (SQLite)
6+
file by default and can use a remote libSQL database on request-isolated
7+
platforms.
78

89
## Run it
910

@@ -35,6 +36,10 @@ See [`.env.example`](./.env.example) for optional settings (most importantly
3536
[Self-Hosting guide](../../docs/self-hosting/guide.mdx) for first-run, inviting
3637
people, backups, reverse-proxy setup, and upgrades.
3738

39+
For Vercel, deploy the repository-root `Dockerfile` and connect a Turso
40+
database. See the [Vercel guide](../docs/hosted/vercel.mdx) for required secrets
41+
and the stateless MCP limitations.
42+
3843
## Develop
3944

4045
```bash

‎apps/host-selfhost/src/app.ts‎

Lines changed: 65 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { HttpApiSwagger } from "effect/unstable/httpapi";
22
import { HttpEffect, HttpRouter } from "effect/unstable/http";
33
import { Effect, Layer } from "effect";
4+
import { createClient } from "@libsql/client";
45

56
import { composePluginApi, ExecutorApp, textFailureStrategy } from "@executor-js/api/server";
67

@@ -13,6 +14,7 @@ import { makeSelfHostSystemApiLayer } from "./system/handlers";
1314
import { selfHostAccountMiddleware } from "./account";
1415
import { loadConfig, SELF_HOST_NAMESPACE, SELF_HOST_SCHEMA_VERSION } from "./config";
1516
import { createSelfHostDb, SelfHostDb, SelfHostDbProvider } from "./db/self-host-db";
17+
import { withSelfHostBootLock } from "./db/boot-lock";
1618
import {
1719
SelfHostCodeExecutorProvider,
1820
SelfHostHostConfig,
@@ -49,29 +51,59 @@ export interface MakeSelfHostAppOptions {
4951
}
5052

5153
export const makeSelfHostApp = async (options: MakeSelfHostAppOptions = {}) => {
52-
const config = loadConfig();
53-
54-
// ---- eager async boot: the shared libSQL handle -----------------------
55-
const dbHandle = await createSelfHostDb({
56-
path: options.dbPath ?? config.dbPath,
57-
namespace: SELF_HOST_NAMESPACE,
58-
version: SELF_HOST_SCHEMA_VERSION,
54+
const databaseOverride = options.dbPath
55+
? ({ kind: "file", path: options.dbPath } as const)
56+
: undefined;
57+
// An explicit test database is an isolation boundary: do not parse ambient
58+
// remote/stateless settings before replacing them with the throwaway file.
59+
const config = loadConfig({
60+
...(databaseOverride ? { database: databaseOverride, mcpMode: "stateful" } : {}),
5961
});
62+
const database = config.database;
63+
64+
const bootDatabase = async () => {
65+
// ---- eager async boot: the shared libSQL handle ---------------------
66+
const dbHandle = await createSelfHostDb({
67+
database,
68+
namespace: SELF_HOST_NAMESPACE,
69+
version: SELF_HOST_SCHEMA_VERSION,
70+
});
6071

61-
// Boot-time data migrations: each registry entry runs once and is stamped
62-
// in the `data_migration` ledger; stamped entries are skipped without
63-
// touching the data.
64-
await Effect.runPromise(runSqliteDataMigrations(dbHandle.client, selfHostDataMigrations));
72+
// Boot-time data migrations: each registry entry runs once and is stamped
73+
// in the `data_migration` ledger; stamped entries are skipped without
74+
// touching the data.
75+
await Effect.runPromise(runSqliteDataMigrations(dbHandle.client, selfHostDataMigrations));
76+
77+
// ---- auth providers -------------------------------------------------
78+
// Better Auth: cookie/bearer/api-key identity + /api/auth handler + account
79+
// API + MCP OAuth seam, all over the shared libSQL handle.
80+
return { dbHandle, ...(await resolveAuthProviders(dbHandle, config)) };
81+
};
6582

66-
// ---- auth providers ---------------------------------------------------
67-
// Better Auth: cookie/bearer/api-key identity + /api/auth handler + account
68-
// API + MCP OAuth seam, all over the shared libSQL handle.
69-
const { identityLayer, authHandler, betterAuth } = await resolveAuthProviders(dbHandle);
83+
// Vercel and similar hosts can cold-start multiple containers against one
84+
// remote database. Serialize executor schema creation, data migrations, and
85+
// first-run auth seeding with a renewable DB lease. The dedicated lock client
86+
// keeps heartbeats outside any migration transaction on the app's client.
87+
const remoteDatabase = database.kind === "remote" ? database : null;
88+
const { dbHandle, identityLayer, authHandler, betterAuth } = remoteDatabase
89+
? await (async () => {
90+
const lockClient = createClient({
91+
url: remoteDatabase.url,
92+
...(remoteDatabase.authToken ? { authToken: remoteDatabase.authToken } : {}),
93+
});
94+
// oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: the dedicated boot-lock connection must close on success and failure
95+
try {
96+
return await withSelfHostBootLock(lockClient, bootDatabase);
97+
} finally {
98+
lockClient.close();
99+
}
100+
})()
101+
: await bootDatabase();
70102

71103
// ---- the in-process MCP serving seams (+ shutdown hook) ----------------
72104
// Pass the pinned public origin so browser-approval URLs are reachable behind
73105
// a reverse proxy (not the internal 127.0.0.1 bind from the request URL).
74-
const mcp = makeSelfHostMcpSeams(dbHandle, betterAuth, config.webBaseUrl);
106+
const mcp = makeSelfHostMcpSeams(dbHandle, betterAuth, config.webBaseUrl, config.mcpMode);
75107

76108
// CLI device-login discovery (`executor login`). Points the CLI at Better
77109
// Auth's device endpoints; `requestFormat: "json"` because those endpoints
@@ -99,7 +131,10 @@ export const makeSelfHostApp = async (options: MakeSelfHostAppOptions = {}) => {
99131
db: SelfHostDbProvider,
100132
engine: { codeExecutor: SelfHostCodeExecutorProvider }, // decorator defaults to no-op (no metering)
101133
mcp: { auth: mcp.auth, sessions: mcp.sessions, reporter: mcp.reporter },
102-
plugins: { provider: SelfHostPluginsProvider, config: SelfHostHostConfig },
134+
plugins: {
135+
provider: SelfHostPluginsProvider,
136+
config: SelfHostHostConfig,
137+
},
103138
errorCapture: ErrorCaptureLive,
104139
},
105140
extensions: {
@@ -116,11 +151,21 @@ export const makeSelfHostApp = async (options: MakeSelfHostAppOptions = {}) => {
116151
// session-cookie-gated, delegating to the in-process MCP store.
117152
HttpRouter.add("*", "/api/mcp-sessions/*", HttpEffect.fromWebHandler(mcp.approvalHandler)),
118153
// App-local admin (invite-code) API, served under /api/admin/*.
119-
makeSelfHostAdminApiLayer({ betterAuth, db: dbHandle, mountPrefix: "/api" }),
154+
makeSelfHostAdminApiLayer({
155+
betterAuth,
156+
db: dbHandle,
157+
mountPrefix: "/api",
158+
}),
120159
// Public system API: /api/health + /api/setup-status (unauthenticated).
121-
makeSelfHostSystemApiLayer({ betterAuth, db: dbHandle, mountPrefix: "/api" }),
160+
makeSelfHostSystemApiLayer({
161+
betterAuth,
162+
db: dbHandle,
163+
mountPrefix: "/api",
164+
}),
122165
// Swagger UI at /docs, over the /api-prefixed spec (matches the served paths).
123-
HttpApiSwagger.layer(composePluginApi(selfHostPlugins).prefix("/api"), { path: "/docs" }),
166+
HttpApiSwagger.layer(composePluginApi(selfHostPlugins).prefix("/api"), {
167+
path: "/docs",
168+
}),
124169
],
125170
},
126171
config: { mountPrefix: "/api", failure: textFailureStrategy },

0 commit comments

Comments
 (0)