Compose: production README, env knobs, first-owner bootstrap — deployed & live

Made the Docker Compose route turn-key for a real self-host, then stood
the whole stack up and drove a live chat through it.

- First-owner bootstrap (cm-auth::bootstrap_owner): a fresh local-auth
  install has no users and no signup route, so the initial Owner +
  workspace are provisioned ONCE from CLAWMATES_BOOTSTRAP_* env on first
  boot — idempotent, never clobbers an existing install (keys on 'any
  workspace exists'). Two real-Postgres tests (creates + signs in;
  second call is a no-op). Wired into server boot, guarded on a
  non-empty password
- deploy/compose/README.md: full production bring-up — services, the
  security topology, every config knob, Anthropic vs local-LLM, the
  broker-key backup, ops, and TLS/SSE proxy notes
- .env.example fleshed out (bootstrap, LLM, auth mode, OTLP); compose
  uses optional env_file so only the knobs you set are injected (unset
  options never override clawmates.toml with empty strings)
- volume-init one-shot chowns the broker's named volumes so the non-root
  scratch broker can write its socket + generated master key

Deployed locally and verified end to end: all 5 containers healthy,
broker generated its key, server bootstrapped owner@…, login + /api/user/me
work, and a real message streamed a live Anthropic response through the
gateway. Captured screenshots of login, workspace home, chat, and the
Computer panel.

166 Rust tests (+2 bootstrap).

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
Omar Sobh
2026-06-10 14:06:15 -05:00
co-authored by Claude Fable 5
parent b9fdec9173
commit 447f7039d8
7 changed files with 318 additions and 0 deletions
+23
View File
@@ -74,6 +74,29 @@ async fn run() -> Result<(), String> {
e2e::seed(&pool).await?; e2e::seed(&pool).await?;
} }
// First-owner bootstrap (self-hosted local-auth installs). Provisions
// the initial workspace + Owner from env exactly once; a no-op on
// every later boot. CLAWMATES_BOOTSTRAP_OWNER_PASSWORD is the trigger.
if let Some(password) = std::env::var("CLAWMATES_BOOTSTRAP_OWNER_PASSWORD")
.ok()
.filter(|p| !p.is_empty())
{
let email = std::env::var("CLAWMATES_BOOTSTRAP_OWNER_EMAIL")
.unwrap_or_else(|_| "[email protected]".into());
let workspace = std::env::var("CLAWMATES_BOOTSTRAP_WORKSPACE")
.unwrap_or_else(|_| "My Workspace".into());
let credits = std::env::var("CLAWMATES_BOOTSTRAP_CREDITS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(1250);
let created = cm_auth::bootstrap_owner(&pool, &workspace, &email, &password, credits)
.await
.map_err(|e| format!("bootstrap owner: {e}"))?;
if created {
println!("clawmates-server: bootstrapped first owner {email} in '{workspace}'");
}
}
let provider = build_provider(&config)?; let provider = build_provider(&config)?;
let blob: std::sync::Arc<dyn cm_files::BlobStore> = match config.storage.backend { let blob: std::sync::Arc<dyn cm_files::BlobStore> = match config.storage.backend {
cm_config::StorageBackend::Local => std::sync::Arc::new(cm_files::LocalBlobStore::new( cm_config::StorageBackend::Local => std::sync::Arc::new(cm_files::LocalBlobStore::new(
+67
View File
@@ -0,0 +1,67 @@
//! First-owner bootstrap for self-hosted (local-auth) deployments.
//!
//! There is no public signup route — a Clawmates workspace is invite-only
//! once it exists. But a brand-new install has nobody to invite anyone,
//! so the first Owner is provisioned from configuration on first boot.
//! The operation keys on "does ANY workspace exist yet", so it runs
//! exactly once no matter how many times the server restarts.
use cm_domain::{Role, User, UserId, Workspace, WorkspaceId};
use crate::{AuthError, AuthService};
/// Provisions the initial workspace + Owner if the install is empty.
/// Returns `true` when it created them, `false` when a workspace already
/// existed (the common restart case). Never modifies an existing install.
pub async fn bootstrap_owner(
pool: &sqlx::PgPool,
workspace_name: &str,
owner_email: &str,
owner_password: &str,
starter_credits: i64,
) -> Result<bool, AuthError> {
let existing: i64 = sqlx::query_scalar("SELECT count(*) FROM workspaces")
.fetch_one(pool)
.await?;
if existing > 0 {
return Ok(false);
}
let workspace = Workspace {
id: WorkspaceId::new(),
name: workspace_name.to_owned(),
plan: "team".into(),
};
cm_db::repo::workspaces::insert(pool, &workspace)
.await
.map_err(map_db)?;
let owner = User {
id: UserId::new(),
workspace_id: workspace.id,
email: owner_email.to_owned(),
role: Role::Owner,
display_name: owner_email.split('@').next().unwrap_or("owner").to_owned(),
created_at: time::OffsetDateTime::now_utc(),
};
cm_db::repo::users::insert(pool, &owner)
.await
.map_err(map_db)?;
AuthService::new(pool.clone())
.set_password(owner.id, owner_password)
.await?;
if starter_credits > 0 {
cm_db::repo::credits::add_lot(pool, workspace.id, starter_credits, "bootstrap")
.await
.map_err(map_db)?;
}
Ok(true)
}
fn map_db(error: cm_db::DbError) -> AuthError {
match error {
cm_db::DbError::Other(e) => AuthError::Db(e),
other => AuthError::Hashing(other.to_string()),
}
}
+2
View File
@@ -5,10 +5,12 @@
//! appliance mode for air-gapped installs; OIDC SSO shares the same session //! appliance mode for air-gapped installs; OIDC SSO shares the same session
//! storage and `AuthedUser` output. //! storage and `AuthedUser` output.
mod bootstrap;
mod jwt; mod jwt;
mod service; mod service;
mod token; mod token;
pub use bootstrap::bootstrap_owner;
pub use jwt::{ExternalClaims, JwtError, JwtVerifier}; pub use jwt::{ExternalClaims, JwtError, JwtVerifier};
pub use service::{AuthError, AuthService, AuthedUser, SESSION_TTL}; pub use service::{AuthError, AuthService, AuthedUser, SESSION_TTL};
pub use token::SessionToken; pub use token::SessionToken;
+70
View File
@@ -0,0 +1,70 @@
//! Production first-owner bootstrap: a fresh local-auth deployment has no
//! users and no signup route, so the very first owner is provisioned once
//! from configuration. Idempotent — never clobbers an existing install.
use cm_auth::{bootstrap_owner, AuthService, AuthedUser};
use cm_domain::Role;
#[tokio::test]
async fn bootstrap_creates_the_first_owner_and_workspace_once() {
let pool = cm_testkit::test_pool().await;
// First boot: provisions the workspace + owner with a usable password.
let created = bootstrap_owner(&pool, "Acme", "[email protected]", "s3cret-pw", 1250)
.await
.unwrap();
assert!(created, "first call provisions");
let auth = AuthService::new(pool.clone());
let token = auth
.login_local("[email protected]", "s3cret-pw")
.await
.expect("the bootstrapped owner can sign in");
let AuthedUser { role, .. } = auth.authenticate(token.secret()).await.unwrap();
assert_eq!(role, Role::Owner);
// The starter credit grant landed.
let user = cm_db::repo::users::find_by_email(&pool, "[email protected]")
.await
.unwrap();
assert_eq!(
cm_db::repo::credits::balance(&pool, user.workspace_id)
.await
.unwrap(),
1250
);
}
#[tokio::test]
async fn bootstrap_is_idempotent_and_never_clobbers_an_existing_install() {
let pool = cm_testkit::test_pool().await;
assert!(
bootstrap_owner(&pool, "Acme", "[email protected]", "pw-one", 100)
.await
.unwrap()
);
// Re-running (e.g. every container restart) is a no-op: no second
// workspace, no password reset, no duplicate owner.
let created = bootstrap_owner(&pool, "Acme", "[email protected]", "pw-two", 100)
.await
.unwrap();
assert!(!created, "second call is a no-op");
let auth = AuthService::new(pool.clone());
assert!(
auth.login_local("[email protected]", "pw-two")
.await
.is_err(),
"a second owner is never created"
);
assert!(
auth.login_local("[email protected]", "pw-one").await.is_ok(),
"the original owner is untouched"
);
let workspaces: i64 = sqlx::query_scalar("SELECT count(*) FROM workspaces")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(workspaces, 1);
}
+34
View File
@@ -1,3 +1,37 @@
# Copy to .env and set real values before `docker compose up`. # Copy to .env and set real values before `docker compose up`.
# These are overlaid onto clawmates.toml at runtime (CLAWMATES_* env wins).
# --- Required ---------------------------------------------------------------
# Postgres superuser password (also used in the server/broker DSN).
POSTGRES_PASSWORD=change-me POSTGRES_PASSWORD=change-me
# Image tag to run. `latest` after a local `docker compose build`, or the
# release version (e.g. 0.1.0) when installing from a signed bundle.
CLAWMATES_VERSION=latest CLAWMATES_VERSION=latest
# --- First-owner bootstrap (local auth) -------------------------------------
# A fresh install has no users and no public signup. Set a password here and
# the server provisions this Owner + workspace ONCE on first boot, then never
# again (safe to leave set across restarts). Remove/blank to disable.
CLAWMATES_BOOTSTRAP_OWNER_EMAIL=[email protected]
CLAWMATES_BOOTSTRAP_OWNER_PASSWORD=
CLAWMATES_BOOTSTRAP_WORKSPACE=My Workspace
CLAWMATES_BOOTSTRAP_CREDITS=1250
# --- LLM --------------------------------------------------------------------
# Default config (clawmates.toml) uses an OpenAI-compatible endpoint at
# http://local-llm:8000/v1 — point base_url at your vLLM/Ollama/llama.cpp.
# To use Anthropic instead, set llm.provider = "anthropic" in clawmates.toml
# and provide the key here (uncomment):
# ANTHROPIC_API_KEY=sk-ant-...
# --- Auth mode (optional) ---------------------------------------------------
# clawmates.toml defaults to mode = "local". For Clerk, set mode = "clerk"
# and auth.issuer_url there, then supply the frontend keys (see docs/clerk.md):
# AUTH_MODE=clerk
# CLERK_PUBLISHABLE_KEY=pk_live_...
# CLERK_SECRET_KEY=sk_live_...
# --- Observability (optional) -----------------------------------------------
# Export traces to an OTLP/HTTP collector. Unset = logs only, no network.
# CLAWMATES_TELEMETRY__OTLP_ENDPOINT=http://otel-collector:4318
+98
View File
@@ -0,0 +1,98 @@
# Clawmates on Docker Compose
A single-node deployment of the whole platform — the same images the
Kubernetes/Helm path uses, with the same §15 security topology. This is
the air-gapped appliance route; it is equally usable as a simple self-host.
## What runs
| Service | Role |
|---|---|
| `postgres` | database (named volume `pgdata`) |
| `broker` | the secret broker — credentials never leave it; reachable only over a private socket volume shared with the server |
| `socket-proxy` | allow-listed Docker API so the server can spawn agent sandboxes and **nothing else** |
| `server` | API + streaming gateway + agent runtime + scheduler |
| `frontend` | the Next.js web app |
Networks `core`, `secrets_net`, `sandbox_net`, and `engine_net` are all
`internal: true`; only `edge` is published. Agent sandboxes run with no
network at all (egress is the browser container's alone).
## Quick start
```bash
cd deploy/compose
cp .env.example .env
# Edit .env: set POSTGRES_PASSWORD and CLAWMATES_BOOTSTRAP_OWNER_PASSWORD.
# Either build the images locally...
docker compose build
# ...or load them from a signed release bundle (see deploy/airgapped/install.sh).
docker compose up -d
```
The server **self-migrates** on boot and, if `CLAWMATES_BOOTSTRAP_OWNER_PASSWORD`
is set, provisions the first Owner + workspace once (a no-op on every later
boot). Then open:
- **App** → http://localhost:3000
- Sign in with `CLAWMATES_BOOTSTRAP_OWNER_EMAIL` / `…_PASSWORD`.
- API health → http://localhost:8080/healthz
## Configuration
Host- and secret-specific values come from `.env` (overlaid onto
`clawmates.toml` at runtime — `CLAWMATES_*` env always wins). The knobs:
| Concern | Where | Notes |
|---|---|---|
| DB password | `.env` `POSTGRES_PASSWORD` | also forms the server/broker DSN |
| Image tag | `.env` `CLAWMATES_VERSION` | `latest` after a local build, or a release version |
| First owner | `.env` `CLAWMATES_BOOTSTRAP_*` | password set = bootstrap on first boot |
| LLM | `clawmates.toml` `[llm]` | `openai_compat` (default, point `base_url` at vLLM/Ollama/llama.cpp) or `anthropic` (+ `ANTHROPIC_API_KEY` in `.env`) |
| Auth | `clawmates.toml` `[auth]` + `.env` `AUTH_MODE` | `local` (default) or `clerk` (see [docs/clerk.md](../../docs/clerk.md)) |
| Tracing | `.env` `CLAWMATES_TELEMETRY__OTLP_ENDPOINT` | unset = logs only, no egress |
### Using Anthropic instead of a local model
In `clawmates.toml`:
```toml
[llm]
provider = "anthropic"
model = "claude-sonnet-4-6"
```
and set `ANTHROPIC_API_KEY` in `.env`.
## The broker master key — back it up
On first boot the broker generates its encryption key into the `broker_key`
volume and logs a reminder. **Every stored credential is unrecoverable
without it.** Back it up:
```bash
docker compose cp broker:/etc/clawmates-broker/broker.key ./broker.key.backup
```
## Operations
```bash
docker compose ps # status
docker compose logs -f server # follow server logs
docker compose pull && docker compose up -d # upgrade to a new CLAWMATES_VERSION
docker compose down # stop (keeps volumes/data)
docker compose down -v # stop AND delete all data
```
## Production notes
- Put a TLS-terminating reverse proxy in front of ports 3000/8080; the SSE
gateway needs response buffering **off** (the Helm ingress sets this; nginx:
`proxy_buffering off;` with long read timeouts).
- `socket-proxy` mounts the Docker socket read-only and exposes only the
container lifecycle verbs — verified by `crates/cm-sandbox/tests/socket_proxy.rs`.
- The full bring-up is exercised end to end by `scripts/rehearse-install.sh`
(verify a signed bundle → `docker load` → `compose up` → assert the login
page serves), which also runs in the release pipeline.
+24
View File
@@ -45,6 +45,18 @@ services:
timeout: 3s timeout: 3s
retries: 12 retries: 12
# Named volumes mount root-owned, but the broker runs as uid 10001 from
# a scratch image (no shell to chown itself). This one-shot prepares the
# socket + key volumes, then exits.
volume-init:
image: busybox:1.36
command: ["sh", "-c", "chown -R 10001:10001 /run/clawmates /etc/clawmates-broker"]
user: "0:0"
volumes:
- broker_run:/run/clawmates
- broker_key:/etc/clawmates-broker
restart: "no"
# The secret broker: separate process, separate image; credentials # The secret broker: separate process, separate image; credentials
# never leave it. Reachable only via the shared unix socket volume. # never leave it. Reachable only via the shared unix socket volume.
broker: broker:
@@ -64,6 +76,8 @@ services:
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
volume-init:
condition: service_completed_successfully
# Allow-listed Docker API (§15 blast-radius cap): the server can # Allow-listed Docker API (§15 blast-radius cap): the server can
# create/exec/stop/remove sandbox containers and NOTHING else — no # create/exec/stop/remove sandbox containers and NOTHING else — no
@@ -88,6 +102,12 @@ services:
context: ../.. context: ../..
dockerfile: images/server.Dockerfile dockerfile: images/server.Dockerfile
restart: unless-stopped restart: unless-stopped
# Operator knobs (bootstrap owner, LLM provider/key overrides, OTLP) come
# from .env — only the keys you actually set are injected, so unset
# options stay absent rather than overriding clawmates.toml with "".
env_file:
- path: .env
required: false
environment: environment:
CLAWMATES_CONFIG: /etc/clawmates/clawmates.toml CLAWMATES_CONFIG: /etc/clawmates/clawmates.toml
CLAWMATES_DATABASE__URL: postgres://postgres:${POSTGRES_PASSWORD:?set in .env}@postgres:5432/clawmates CLAWMATES_DATABASE__URL: postgres://postgres:${POSTGRES_PASSWORD:?set in .env}@postgres:5432/clawmates
@@ -108,6 +128,10 @@ services:
context: ../.. context: ../..
dockerfile: images/frontend.Dockerfile dockerfile: images/frontend.Dockerfile
restart: unless-stopped restart: unless-stopped
# AUTH_MODE + CLERK_* (when running Clerk) come from .env; see docs/clerk.md.
env_file:
- path: .env
required: false
environment: environment:
API_ORIGIN: http://server:8080 API_ORIGIN: http://server:8080
networks: [edge] networks: [edge]