Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -115,3 +115,4 @@ scratch/
# Throwaway UX prototype (not part of the app)
ux-demo/

.vercel
29 changes: 29 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion apps/docs/docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
},
{
"group": "Hosted",
"pages": ["hosted/cloud", "hosted/docker", "hosted/cloudflare"]
"pages": ["hosted/cloud", "hosted/docker", "hosted/vercel", "hosted/cloudflare"]
},
{
"group": "Concepts",
Expand Down
11 changes: 11 additions & 0 deletions apps/docs/hosted/docker.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 dir>/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
Expand All @@ -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 dir>/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. |
Expand Down
70 changes: 70 additions & 0 deletions apps/docs/hosted/vercel.mdx
Original file line number Diff line number Diff line change
@@ -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=<at-least-32-random-characters>
EXECUTOR_SECRET_KEY=<random-encryption-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.

<Warning>
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.
</Warning>

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
```
16 changes: 16 additions & 0 deletions apps/host-selfhost/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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 dir>/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
Expand Down
9 changes: 7 additions & 2 deletions apps/host-selfhost/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
85 changes: 65 additions & 20 deletions apps/host-selfhost/src/app.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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: {
Expand All @@ -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 },
Expand Down
Loading
Loading