diff --git a/.dockerignore b/.dockerignore index 1dea9f1ec..61a7c1262 100644 --- a/.dockerignore +++ b/.dockerignore @@ -5,7 +5,12 @@ node_modules **/node_modules dist **/dist +apps/web/dist +*.tsbuildinfo *.log +.DS_Store +e2e .env .env.* !.env.example +!**/.env.example diff --git a/.env.example b/.env.example index 1b6c19c04..171f7755c 100644 --- a/.env.example +++ b/.env.example @@ -27,11 +27,15 @@ HUB_ALLOW_GIT_INSIDE_WORK_TREE=1 # HUB_ADMIN_PASSWORD=password123 # 32 bytes hex each (openssl rand -hex 32). Generate your own outside a laptop. +# BETTER_AUTH_SECRET= CREDENTIAL_ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000 PRINCIPAL_KEY_ENCRYPTION_KEY=1111111111111111111111111111111111111111111111111111111111111111 # Kept separate from the hub's key so a sidecar compromise cannot read the hub's database. SIDECAR_CREDENTIAL_ENCRYPTION_KEY=2222222222222222222222222222222222222222222222222222222222222222 +# Built web client directory served by the hub in single-origin deployments (set in Dockerfile). +# WEB_DIST_DIR= + # Max tool-package tarball the registry accepts. Default 10 MiB. # HUB_MAX_TARBALL_BYTES=10485760 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..ce5e7dd83 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,7 @@ +FROM oven/bun:1.4.2 +WORKDIR /app +COPY . . +RUN bun install --frozen-lockfile && bun run --cwd apps/web build +ENV WEB_DIST_DIR=/app/apps/web/dist +EXPOSE 3000 +CMD ["bun", "apps/hub/src/index.ts"] diff --git a/IMPLEMENTATION.md b/IMPLEMENTATION.md index f8c6e3a65..c901407e6 100644 --- a/IMPLEMENTATION.md +++ b/IMPLEMENTATION.md @@ -43,15 +43,6 @@ surfaced tools, qwen2.5:7b does not, so deferral needs 14b or better. ## Deployment -Deployment mechanics are not yet settled: - -- Target hosting platform and process topology (one hub, how many - sidecars, where they run) are undecided. Sidecars run as child - processes of the hub; there is no other provisioner backend. -- Secrets management for `CREDENTIAL_ENCRYPTION_KEY` and - `PRINCIPAL_KEY_ENCRYPTION_KEY` in a real deployment is undecided. -- Migration/rollout ordering across `hub`, `sidecar`, and `web` on deploy - is undecided. -- Backup/restore for `HUB_DATA_DIR` and the database is undecided. - -Do not assume any of the above from this document alone. +The root `Dockerfile` builds a single-origin production image on `oven/bun:1.4.2`: it installs workspace dependencies, builds `apps/web` (which also bundles `@corbits/worker`), sets `WEB_DIST_DIR=/app/apps/web/dist`, and starts `apps/hub/src/index.ts` on port `3000`. When `WEB_DIST_DIR` (or `PORTAL_DIR`) is set, the hub serves the compiled web bundle and SPA fallback alongside `/api/*`, `/status`, and `/openapi.json`, and derives `DB_HOST`/`DB_PORT`/`DB_USER`/`DB_PASSWORD`/`DB_NAME` from `DATABASE_URL`. Sidecars run as child processes of the hub (`apps/sidecar/src/index.ts`) with durable state under `HUB_DATA_DIR`. + +`compose.yml` pairs that image with `pgvector/pgvector:pg17` and persistent volumes for Postgres and `HUB_DATA_DIR`. See [docs/SELF_HOST.md](docs/SELF_HOST.md) for Docker Compose, cloud container hosts, required secrets, and backups. diff --git a/README.md b/README.md index 06c99f773..0a0e9dea8 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,20 @@ worker builds whatever workflows, tools, or skills a job needs. ## Run it +### Docker Compose + +Run Postgres 17 (with `pgvector`) and the hub (serving the web client on port 3000) in containers: + +```sh +cp .env.example .env # set the encryption keys outside a laptop: openssl rand -hex 32 each +docker compose up --build -d +docker compose logs -f hub +``` + +Open `http://localhost:3000`. For cloud deployment, environment variables, and backups, see [docs/SELF_HOST.md](docs/SELF_HOST.md). + +### Local development + Needs [Bun](https://bun.sh) (version in `.bun-version`) and Postgres 17 with pgvector. `docker compose -f compose.test.yml up -d` gives you the same Postgres CI uses. @@ -45,6 +59,7 @@ bun run test:e2e # end-to-end suites, need DATABASE_URL ## Read next - [PRODUCT.md](PRODUCT.md): what a workbench, its worker, tools, and skills are. +- [docs/SELF_HOST.md](docs/SELF_HOST.md): self-hosting with Docker Compose or any container host. - [ARCHITECTURE.md](ARCHITECTURE.md) and [IMPLEMENTATION.md](IMPLEMENTATION.md): how the hub, sidecar, and web client are built. - [docs/local-dev.md](docs/local-dev.md): running locally with Ollama, memory embeddings, and OAuth providers. - [docs/GLOSSARY.md](docs/GLOSSARY.md): product-term to platform-term mapping. diff --git a/apps/hub/src/index.ts b/apps/hub/src/index.ts index dba8d5205..ca2602964 100644 --- a/apps/hub/src/index.ts +++ b/apps/hub/src/index.ts @@ -1,3 +1,12 @@ import { createHubServer } from "./server"; +import { populateDbEnvFromDatabaseUrl, withStaticWeb } from "./static-web"; -export default await createHubServer(); +populateDbEnvFromDatabaseUrl(); + +const server = await createHubServer(); +const webDistDir = process.env["WEB_DIST_DIR"] ?? process.env["PORTAL_DIR"]; + +export default { + ...server, + fetch: withStaticWeb(webDistDir, server.fetch), +}; diff --git a/apps/hub/src/static-web.test.ts b/apps/hub/src/static-web.test.ts new file mode 100644 index 000000000..32b6fefee --- /dev/null +++ b/apps/hub/src/static-web.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { + createStaticWebHandler, + isStaticWebRequest, + populateDbEnvFromDatabaseUrl, + withStaticWeb, +} from "./static-web"; + +function makeDistDir(): string { + const dir = mkdtempSync(path.join(tmpdir(), "workbench-static-web-")); + mkdirSync(path.join(dir, "assets"), { recursive: true }); + writeFileSync(path.join(dir, "index.html"), '
INDEX
'); + writeFileSync(path.join(dir, "assets", "app-abc123.js"), "console.log('ok');"); + return dir; +} + +describe("static web serving", () => { + test("serves hashed assets with immutable Cache-Control and SPA routes with index.html", async () => { + const dir = makeDistDir(); + const serve = createStaticWebHandler(dir); + + const assetRes = await serve(new Request("http://localhost:3000/assets/app-abc123.js")); + expect(assetRes.status).toBe(200); + expect(assetRes.headers.get("cache-control")).toBe("public, max-age=31536000, immutable"); + expect(await assetRes.text()).toBe("console.log('ok');"); + + const spaRes = await serve(new Request("http://localhost:3000/benches/default/threads/123")); + expect(spaRes.status).toBe(200); + expect(spaRes.headers.get("cache-control")).toBe("no-cache"); + expect(await spaRes.text()).toContain("INDEX"); + }); + + test("returns 404 for missing asset files with an extension instead of index.html", async () => { + const dir = makeDistDir(); + const serve = createStaticWebHandler(dir); + + const missing = await serve(new Request("http://localhost:3000/assets/missing.js")); + expect(missing.status).toBe(404); + }); + + test("never serves files outside the dist directory on path traversal", async () => { + const dir = makeDistDir(); + writeFileSync(path.join(tmpdir(), "outside-secret.txt"), "TOP_SECRET"); + const serve = createStaticWebHandler(dir); + + const escaped = await serve(new Request("http://localhost:3000/%2e%2e%2foutside-secret.txt")); + expect(escaped.status).toBe(200); + expect(await escaped.text()).toContain("INDEX"); + }); + + test("delegates /api/*, /status, /openapi.json, and non-GET/HEAD requests to the hub", async () => { + const dir = makeDistDir(); + const wrapped = withStaticWeb(dir, () => Response.json({ hub: true })); + + expect(isStaticWebRequest(new Request("http://localhost:3000/api/me"))).toBe(false); + expect(isStaticWebRequest(new Request("http://localhost:3000/status"))).toBe(false); + expect(isStaticWebRequest(new Request("http://localhost:3000/openapi.json"))).toBe(false); + expect( + isStaticWebRequest(new Request("http://localhost:3000/benches", { method: "POST" })), + ).toBe(false); + + const apiRes = await wrapped(new Request("http://localhost:3000/api/me"), undefined); + expect(await apiRes.json()).toEqual({ hub: true }); + + const rootRes = await wrapped(new Request("http://localhost:3000/"), undefined); + expect(await rootRes.text()).toContain("INDEX"); + }); + + test("populates DB_* variables from DATABASE_URL without overwriting explicit overrides", () => { + const env: Record = { + DATABASE_URL: "postgres://workbench:secret%40pass@postgres:5433/workbench_prod", + DB_HOST: "explicit-host", + }; + populateDbEnvFromDatabaseUrl(env); + expect(env["DB_HOST"]).toBe("explicit-host"); + expect(env["DB_PORT"]).toBe("5433"); + expect(env["DB_USER"]).toBe("workbench"); + expect(env["DB_PASSWORD"]).toBe("secret@pass"); + expect(env["DB_NAME"]).toBe("workbench_prod"); + }); +}); diff --git a/apps/hub/src/static-web.ts b/apps/hub/src/static-web.ts new file mode 100644 index 000000000..8f3d1ed4f --- /dev/null +++ b/apps/hub/src/static-web.ts @@ -0,0 +1,107 @@ +import { existsSync } from "node:fs"; +import path from "node:path"; + +const HUB_API_PREFIXES = ["/api/", "/status", "/openapi.json"]; + +/** + * Derive `DB_HOST`, `DB_PORT`, `DB_USER`, `DB_PASSWORD`, and `DB_NAME` from + * `DATABASE_URL` when `DATABASE_URL` is set and the individual variables are + * not, so container and self-hosted deployments only need to set `DATABASE_URL`. + */ +export function populateDbEnvFromDatabaseUrl( + env: Record = process.env, +): void { + const databaseUrl = env["DATABASE_URL"]; + if (databaseUrl === undefined || databaseUrl.trim() === "") return; + let url: URL; + try { + url = new URL(databaseUrl); + } catch { + throw new Error(`DATABASE_URL is not a valid URL: ${JSON.stringify(databaseUrl)}`); + } + env["DB_HOST"] ??= url.hostname; + env["DB_PORT"] ??= url.port === "" ? "5432" : url.port; + if (url.username !== "") { + env["DB_USER"] ??= decodeURIComponent(url.username); + } + if (url.password !== "") { + env["DB_PASSWORD"] ??= decodeURIComponent(url.password); + } + const dbName = url.pathname.replace(/^\//, ""); + if (dbName !== "") { + env["DB_NAME"] ??= dbName; + } +} + +export function isStaticWebRequest(req: Request): boolean { + if (req.method !== "GET" && req.method !== "HEAD") return false; + const { pathname } = new URL(req.url); + if (pathname === "/api") return false; + return !HUB_API_PREFIXES.some((prefix) => pathname.startsWith(prefix)); +} + +export function createStaticWebHandler(distDir: string): (req: Request) => Promise { + const root = path.resolve(distDir); + const indexPath = path.resolve(root, "index.html"); + if (!existsSync(indexPath)) { + throw new Error(`WEB_DIST_DIR has no index.html: ${root}`); + } + + function resolveAssetPath(pathname: string): string | null { + try { + const decoded = decodeURIComponent(pathname); + if (decoded.includes("\0")) return null; + const candidate = path.resolve(root, `.${decoded}`); + if (candidate === root || candidate === indexPath) return indexPath; + return candidate.startsWith(root + path.sep) ? candidate : null; + } catch { + return null; + } + } + + return async function serveStaticWeb(req: Request): Promise { + const { pathname } = new URL(req.url); + const candidate = resolveAssetPath(pathname); + if (candidate !== null && candidate !== indexPath) { + const file = Bun.file(candidate); + if (await file.exists()) { + const cacheControl = pathname.startsWith("/assets/") + ? "public, max-age=31536000, immutable" + : "public, max-age=0, must-revalidate"; + return new Response(req.method === "HEAD" ? null : file, { + headers: { + ...(file.type !== "" ? { "Content-Type": file.type } : {}), + "Cache-Control": cacheControl, + }, + }); + } + if (path.extname(pathname) !== "") { + return new Response("Not Found", { + status: 404, + headers: { "Content-Type": "text/plain; charset=utf-8" }, + }); + } + } + + const indexFile = Bun.file(indexPath); + return new Response(req.method === "HEAD" ? null : indexFile, { + headers: { + "Content-Type": "text/html; charset=utf-8", + "Cache-Control": "no-cache", + }, + }); + }; +} + +export function withStaticWeb( + distDir: string | undefined, + next: (req: Request, server: Server) => Response | Promise, +): (req: Request, server: Server) => Response | Promise { + const trimmed = distDir?.trim(); + if (trimmed === undefined || trimmed === "") return next; + const serveStatic = createStaticWebHandler(trimmed); + return function fetchWithStaticWeb(req: Request, server: Server) { + if (isStaticWebRequest(req)) return serveStatic(req); + return next(req, server); + }; +} diff --git a/compose.yml b/compose.yml new file mode 100644 index 000000000..ace7cca6e --- /dev/null +++ b/compose.yml @@ -0,0 +1,37 @@ +services: + postgres: + image: pgvector/pgvector:pg17 + command: postgres -c idle_in_transaction_session_timeout=10min + environment: + POSTGRES_USER: workbench + POSTGRES_PASSWORD: workbench + POSTGRES_DB: workbench + volumes: + - postgres:/var/lib/postgresql/data + healthcheck: + test: ["CMD", "pg_isready", "-U", "workbench", "-d", "workbench"] + interval: 2s + timeout: 5s + retries: 5 + restart: unless-stopped + + hub: + build: . + env_file: .env + environment: + DATABASE_URL: postgres://workbench:workbench@postgres:5432/workbench + HUB_DATA_DIR: /data + PORT: "3000" + BETTER_AUTH_BASE_URL: http://localhost:3000 + volumes: + - hub-data:/data + ports: + - "3000:3000" + depends_on: + postgres: + condition: service_healthy + restart: unless-stopped + +volumes: + postgres: + hub-data: diff --git a/docs/SELF_HOST.md b/docs/SELF_HOST.md new file mode 100644 index 000000000..5622dec7b --- /dev/null +++ b/docs/SELF_HOST.md @@ -0,0 +1,137 @@ +# Self-host + +How to run Corbits Workbench yourself: what you need, how to run the container image, first sign-in setup, and every setting. Local development is in [local-dev.md](local-dev.md). + +## What you need + +To try it on your machine with [Docker Compose](#docker-compose): + +- Docker. +- One inference provider connected after sign-in: an API key (Anthropic, OpenAI, Google, xAI, OpenRouter, or any OpenAI-compatible endpoint), a subscription login (Codex or xAI), or a local [Ollama](https://ollama.com) instance with no key. + +A real deployment also needs: + +- Postgres 17 with `pgvector` (`pgvector/pgvector:pg17`), required by `@corbits/memory`. +- One machine or container for the hub, built from the root `Dockerfile`, with a persistent volume for `HUB_DATA_DIR` and a public HTTPS domain. When `WEB_DIST_DIR` is set (as the `Dockerfile` sets it), the hub serves the built web client, the API (`/api/...`), and inbound webhooks (`/api/hooks/...`) on that single origin. + +Size the hub for its sidecars: the hub spawns local sidecars as Bun child processes (`apps/sidecar/src/index.ts`), one per active workflow allocation. + +## Run it + +### Docker Compose + +`compose.yml` runs Postgres 17 (with `pgvector`) and the hub at `http://localhost:3000`. In `.env`, set the four 32-byte hex secrets; Compose sets `DATABASE_URL`, `HUB_DATA_DIR`, `PORT`, and `BETTER_AUTH_BASE_URL`, and applies the [database role](#database-role) timeout. + +```sh +cp .env.example .env +# Generate four distinct 32-byte hex secrets (openssl rand -hex 32 each): +# CREDENTIAL_ENCRYPTION_KEY, PRINCIPAL_KEY_ENCRYPTION_KEY, +# SIDECAR_CREDENTIAL_ENCRYPTION_KEY, BETTER_AUTH_SECRET +docker compose up --build -d +docker compose logs -f hub +``` + +Open `http://localhost:3000` once the hub logs `Starting server on port 3000`. + +### Railway + +1. Create a project and add a Postgres service with the `pgvector` extension enabled (for example the `pgvector/pgvector:pg17` image). +2. Add the `hub` service from this repository. It builds the root `Dockerfile`. +3. Generate a public domain on port `3000` and mount a persistent volume at `/data`. +4. Set the environment variables on `hub` (each secret is a distinct `openssl rand -hex 32`): + + ```sh + railway variable set --service hub \ + BETTER_AUTH_SECRET= \ + CREDENTIAL_ENCRYPTION_KEY= \ + PRINCIPAL_KEY_ENCRYPTION_KEY= \ + SIDECAR_CREDENTIAL_ENCRYPTION_KEY= \ + 'DATABASE_URL=${{Postgres.DATABASE_URL}}' \ + BETTER_AUTH_BASE_URL=https:// \ + HUB_PUBLIC_URL=https:// \ + HUB_DATA_DIR=/data \ + PORT=3000 + ``` + +5. Apply the [database role](#database-role) setting on Postgres. + +### Render or any other container host + +1. Provision Postgres 17 with `pgvector`. Add `?sslmode=require` to `DATABASE_URL` when reached over the public internet. +2. Build and run the root `Dockerfile` with a persistent disk mounted at `/data`. +3. Set the [required variables](#required) and any optional ones. +4. Terminate TLS in front of the hub on port `3000`. + +### Database role + +On every Postgres outside `compose.yml`, set `idle_in_transaction_session_timeout` on the hub's database role so wedged transactions release their locks after ten minutes: + +```sql +alter role set idle_in_transaction_session_timeout = '10min'; +``` + +## First sign-in in the app + +1. **Sign up.** Open the hub URL and create your account. Your first sign-in creates your workbench (a plain Interchange tenant) and deploys the default worker definition (`@corbits/worker`). +2. **Connect an inference provider.** Onboarding prompts for one provider—paste an API key (Anthropic, OpenAI, Google, xAI, OpenRouter), sign in with Codex or xAI, or point at a local Ollama URL (`http://localhost:11434/v1`). Provider keys live encrypted in the hub's vault and are never read from environment variables. +3. **Start a thread.** Message the workbench worker to run tasks, author workflows, or connect MCP servers and skills. + +## Configuration + +`.env.example` lists every supported variable. Secrets are runtime variables, never build arguments. + +### Required + +| Variable | Value | +| --- | --- | +| `DATABASE_URL` | `postgres://` or `postgresql://` connection string. The hub derives `DB_HOST`, `DB_PORT`, `DB_USER`, `DB_PASSWORD`, and `DB_NAME` from it when those individual variables are unset. | +| `HUB_DATA_DIR` | Directory for durable git repositories, workflow assets, and sidecar state. Mount a persistent volume here (e.g. `/data`). | +| `CREDENTIAL_ENCRYPTION_KEY` | 32-byte hex key (`openssl rand -hex 32`) that seals tenant credentials at rest. | +| `PRINCIPAL_KEY_ENCRYPTION_KEY` | 32-byte hex key (`openssl rand -hex 32`) that seals per-principal Ed25519 signing keys at rest. | +| `SIDECAR_CREDENTIAL_ENCRYPTION_KEY` | 32-byte hex key (`openssl rand -hex 32`) that sidecars use to seal their local run credential store, kept separate from the hub keys. | +| `BETTER_AUTH_SECRET` | 32-byte hex secret (`openssl rand -hex 32`) used by Better Auth to sign session cookies in production. | +| `BETTER_AUTH_BASE_URL` | Public URL users open in the browser (for example `http://localhost:3000` or `https://workbench.example.com`). Sign-up rejects requests from other origins with `Invalid origin`. | + +### Web UI and networking + +| Variable | Default | Effect | +| --- | --- | --- | +| `PORT` | `3000` | HTTP port the hub listens on. | +| `WEB_DIST_DIR` | unset | Path to the built `apps/web/dist` bundle. Set to `/app/apps/web/dist` in the `Dockerfile` so the hub serves the SPA and static assets on the same origin. (`PORTAL_DIR` is accepted as an alias.) | +| `HUB_PUBLIC_URL` | `http://127.0.0.1:` | Public hub origin that OAuth callbacks (such as MCP server OAuth logins) redirect back to. Set to your `https://` origin in production. | +| `HUB_SIDECAR_WEBSOCKET_URL` | `ws://127.0.0.1:/api/sidecars/ws` | WebSocket URL local sidecars dial back to. | +| `HUB_MAX_TARBALL_BYTES` | `10485760` | Maximum tool-package tarball size accepted by the hub registry (bytes). | + +### Memory plane (`@corbits/memory`) + +Optional. When `EMBED_BASE_URL` and `OLLAMA_BASE_URL` are both unset, the hub boots normally without mounting vector recall. + +| Variable | Default | Effect | +| --- | --- | --- | +| `EMBED_BASE_URL` | falls back to `OLLAMA_BASE_URL` | OpenAI-compatible or Ollama embeddings endpoint URL. | +| `EMBED_MODEL` | `nomic-embed-text` | Embedding model name. | +| `EMBED_API_KEY` | unset | Bearer key for the embeddings endpoint, if required. | +| `RERANK_BASE_URL`, `RERANK_MODEL` | unset | Optional Text Embeddings Inference reranker; set both or neither. | +| `RERANK_API_KEY` | unset | Bearer key for the reranker endpoint, if required. | + +### Sign-in and OAuth adapters + +| Variable | Default | Effect | +| --- | --- | --- | +| `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET` | unset | Enable "Continue with Google" sign-in. Set callback URI to `/api/auth/callback/google`. | +| `SIDECAR_ADAPTER_MANIFEST` | Codex + xAI adapters (see `.env.example`) | JSON array of `{provider, specifier, export}` adapters loaded by sidecars for non-stock providers. | + +### Web UI hosted separately + +If you host `apps/web/dist` on a static CDN instead of serving it from the hub via `WEB_DIST_DIR`: + +- Unset `WEB_DIST_DIR` on the hub. +- Proxy `/api/*` from the web host to the hub's origin so requests stay same-origin, and set `BETTER_AUTH_BASE_URL` to the public URL users open in their browser. + +## Backups + +Back up all three together: + +- **Postgres**: tenants, accounts, principals, grants, mailbox threads, artifacts, cron schedules, and encrypted credentials. +- **The `HUB_DATA_DIR` volume**: git repositories for workflow definitions, run history, and sidecar state. +- **The encryption keys** (`CREDENTIAL_ENCRYPTION_KEY`, `PRINCIPAL_KEY_ENCRYPTION_KEY`, `SIDECAR_CREDENTIAL_ENCRYPTION_KEY`, and `BETTER_AUTH_SECRET`): store them separately from the database backup. A database backup without the keys that sealed its vault cannot decrypt stored credentials or signing keys.