Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,12 @@ node_modules
**/node_modules
dist
**/dist
apps/web/dist
*.tsbuildinfo
*.log
.DS_Store
e2e
.env
.env.*
!.env.example
!**/.env.example
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
7 changes: 7 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
15 changes: 3 additions & 12 deletions IMPLEMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
11 changes: 10 additions & 1 deletion apps/hub/src/index.ts
Original file line number Diff line number Diff line change
@@ -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),
};
84 changes: 84 additions & 0 deletions apps/hub/src/static-web.test.ts
Original file line number Diff line number Diff line change
@@ -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"), '<!doctype html><div id="root">INDEX</div>');
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<string, string | undefined> = {
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");
});
});
107 changes: 107 additions & 0 deletions apps/hub/src/static-web.ts
Original file line number Diff line number Diff line change
@@ -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<string, string | undefined> = 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<Response> {
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<Response> {
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<Server>(
distDir: string | undefined,
next: (req: Request, server: Server) => Response | Promise<Response>,
): (req: Request, server: Server) => Response | Promise<Response> {
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);
};
}
37 changes: 37 additions & 0 deletions compose.yml
Original file line number Diff line number Diff line change
@@ -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:
Loading
Loading