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:
co-authored by
Claude Fable 5
parent
b9fdec9173
commit
447f7039d8
@@ -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(
|
||||||
|
|||||||
@@ -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()),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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]
|
||||||
|
|||||||
Reference in New Issue
Block a user