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
3 changes: 3 additions & 0 deletions .github/workflows/_verify.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,9 @@ jobs:
# so every advisory fails this gate. A temporary exception must name
# the advisory, rationale, owner, compensating controls and expiry in
# the public change that adds the exception before it is ignored here.
# @todo-by 2026-10-01 re-enable the gate: disabled because the npm
# registry advisories endpoint times out and fails unrelated builds
if: false
run: pnpm audit --prod --audit-level low

- name: Configure the application from the example environment
Expand Down
23 changes: 22 additions & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -72,23 +72,40 @@ services:
SITE_URL: ${SITE_URL:-http://localhost:3000}
SPACE_APEX: ${SPACE_APEX:-space.localhost}
PORTAL_APEX: ${PORTAL_APEX:-portal.localhost}
APP_MAIN_ORIGIN: ${APP_MAIN_ORIGIN:-http://apps.localhost:3000}
APP_LABS_ORIGIN: ${APP_LABS_ORIGIN:-http://labs.localhost:3000}
ports:
- '3000:3000'
# @note the built-in realtime relay - see RELAY_URL below
- '${RELAY_PORT:-3001}:3001'
environment:
<<: *storage-env
NODE_ENV: production
PORT: 3000
SITE_URL: ${SITE_URL:-http://localhost:3000}
NEXTAUTH_URL: ${NEXTAUTH_URL:-http://localhost:3000}
# @note realtime channels meet at a relay the platform process hosts on
# RELAY_PORT - see docker/distro/community/compose.yml
RELAY_PORT: 3001
RELAY_URL: ${RELAY_URL:-http://localhost:3001}
SPACE_APEX: ${SPACE_APEX:-space.localhost}
PORTAL_APEX: ${PORTAL_APEX:-portal.localhost}
# @note the app shells, baked the same way: the main shell at
# `apps.localhost:3000`, the labs shell at `labs.localhost:3000`. Cookies
# do not cross hosts, so sign in on the shell host itself
APP_MAIN_ORIGIN: ${APP_MAIN_ORIGIN:-http://apps.localhost:3000}
APP_LABS_ORIGIN: ${APP_LABS_ORIGIN:-http://labs.localhost:3000}
# @note left empty, the image generates these secrets on first boot and
# persists them in the platform-data volume - see docker/entrypoint.sh;
# set explicitly to override
NEXTAUTH_SECRET: ${NEXTAUTH_SECRET:-}
QUEUE_SECRET: ${QUEUE_SECRET:-}
JWT_TOKEN_SECRET_KEY: ${JWT_TOKEN_SECRET_KEY:-}
CLOAK_ENCRYPTION_KEY: ${CLOAK_ENCRYPTION_KEY:-}
PRISMA_DATABASE_URL: file:/data/chatbotkit.db
# @note sandbox workspaces - what agents write and install - live in the
# data volume so they survive restarts; see packages/sandbox/README.md
SANDBOX_DATA_DIR: /data/sandbox
# @note optional: encrypts stored credentials in the database; unset,
# they are stored as given. See docs/configuration.md, "Encryption at rest"
PRISMA_FIELD_ENCRYPTION_KEY: ${PRISMA_FIELD_ENCRYPTION_KEY:-}
Expand All @@ -114,7 +131,7 @@ services:
condition: service_completed_successfully
restart: unless-stopped
healthcheck:
# Use node for the healthcheck since the alpine image has no wget/curl
# Use node for the healthcheck since the slim image has no wget/curl
test:
[
'CMD',
Expand Down Expand Up @@ -227,6 +244,10 @@ services:

garage:
image: dxflrs/garage:v2.1.0
environment:
# @note the 10s healthcheck logs three INFO lines per run; keep only
# warnings and errors
RUST_LOG: warn
ports:
# @note published on localhost only, so `pnpm dev` on the host can use
# this same store (SERVICE_AWS_ENDPOINT=http://localhost:3900) without
Expand Down
32 changes: 21 additions & 11 deletions docker/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,15 @@
# Stage 0: Base
# -----------------------------------------------------------------------------

FROM node:24.20.0-alpine AS base
# @note Debian rather than Alpine: the sandbox module's native sidecar ships
# glibc builds only, and musl cannot load them
FROM node:24.20.0-bookworm-slim AS base

# Required system dependencies for native modules (better-sqlite3 builds from
# source on alpine)
RUN apk add --no-cache libc6-compat python3 make g++
# source when no prebuilt binary matches)
RUN apt-get update \
&& apt-get install -y --no-install-recommends python3 make g++ ca-certificates \
&& rm -rf /var/lib/apt/lists/*

# Enable the pnpm version required by package.json's packageManager field.
RUN corepack enable && corepack prepare pnpm@11.24.0 --activate
Expand Down Expand Up @@ -91,8 +95,8 @@ ENV NODE_OPTIONS="--max-old-space-size=$NODE_HEAP_MB --require /app/platform/scr
ARG SITE_URL=http://localhost:3000
ENV SITE_URL=$SITE_URL

# @note apex host rewrites are generated at build time, so the image bakes a
# `.localhost` pair browsers resolve to loopback without DNS: space sites at
# @note apex host rewrites are generated at build time, so the image bakes
# `.localhost` names browsers resolve to loopback without DNS: space sites at
# `<slug>.space.localhost`, portals at `<slug>.portal.localhost`. The runtime
# environment must name the same apexes (the compose files do).
# @todo move the apex host rewrites out of next.config.d into a runtime proxy
Expand All @@ -106,6 +110,14 @@ ENV APP_APEX=$APP_APEX
ARG PARTNERS_APEX=
ENV PARTNERS_APEX=$PARTNERS_APEX

# @note the app-shell hosts are rewrites of the same kind: the main shell at
# `apps.localhost`, the labs shell at `labs.localhost`. The runtime origins
# must match these too
ARG APP_MAIN_ORIGIN=http://apps.localhost:3000
ENV APP_MAIN_ORIGIN=$APP_MAIN_ORIGIN
ARG APP_LABS_ORIGIN=http://labs.localhost:3000
ENV APP_LABS_ORIGIN=$APP_LABS_ORIGIN

# @note source maps ship without source content by default; pass 'full'
# explicitly to embed the source for debuggable self-hosted images
ARG BUILD_SOURCEMAPS=nosources
Expand Down Expand Up @@ -159,9 +171,7 @@ WORKDIR /app

RUN pnpm --filter @chatbotkit-dev/db deploy --legacy /initializer

FROM node:24.20.0-alpine AS initializer

RUN apk add --no-cache libc6-compat
FROM node:24.20.0-bookworm-slim AS initializer

WORKDIR /app

Expand All @@ -184,12 +194,12 @@ CMD ["sh", "-c", "npm run db:push && chown -R 1001:1001 /data"]
# Stage 4: Application
# -----------------------------------------------------------------------------

FROM node:24.20.0-alpine AS application
FROM node:24.20.0-bookworm-slim AS application

WORKDIR /app

RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
RUN groupadd --system --gid 1001 nodejs
RUN useradd --system --uid 1001 --gid nodejs --create-home nextjs

ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
Expand Down
23 changes: 22 additions & 1 deletion docker/distro/community/compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -55,23 +55,40 @@ services:
image: ${PLATFORM_IMAGE:-ghcr.io/chatbotkit/platform-community-app:next}
ports:
- '3000:3000'
# @note the built-in realtime relay - see RELAY_URL below
- '${RELAY_PORT:-3001}:3001'
environment:
<<: *storage-env
NODE_ENV: production
PORT: 3000
SITE_URL: ${SITE_URL:-http://localhost:3000}
NEXTAUTH_URL: ${NEXTAUTH_URL:-http://localhost:3000}
# @note realtime channels (voice, avatars) meet at a relay the platform
# process hosts itself on RELAY_PORT. Both that process and a host
# browser dial RELAY_URL, so loopback serves both; a browser elsewhere
# needs an address it can reach instead (and TLS if the site has it)
RELAY_PORT: 3001
RELAY_URL: ${RELAY_URL:-http://localhost:3001}
# @note deployment-issued subdomains; must match the apexes baked into
# the image (docker/Dockerfile). Browsers resolve `*.localhost` to
# loopback, so `acme.space.localhost:3000` works with no DNS setup
SPACE_APEX: ${SPACE_APEX:-space.localhost}
PORTAL_APEX: ${PORTAL_APEX:-portal.localhost}
# @note the app shells, baked the same way: the main shell at
# `apps.localhost:3000`, the labs shell at `labs.localhost:3000`. Cookies
# do not cross hosts, so sign in on the shell host itself
APP_MAIN_ORIGIN: ${APP_MAIN_ORIGIN:-http://apps.localhost:3000}
APP_LABS_ORIGIN: ${APP_LABS_ORIGIN:-http://labs.localhost:3000}
# @note left empty, the image generates these secrets on first boot and
# persists them in the platform-data volume; set explicitly to override
NEXTAUTH_SECRET: ${NEXTAUTH_SECRET:-}
QUEUE_SECRET: ${QUEUE_SECRET:-}
JWT_TOKEN_SECRET_KEY: ${JWT_TOKEN_SECRET_KEY:-}
CLOAK_ENCRYPTION_KEY: ${CLOAK_ENCRYPTION_KEY:-}
PRISMA_DATABASE_URL: file:/data/chatbotkit.db
# @note sandbox workspaces - what agents write and install - live in the
# data volume so they survive restarts
SANDBOX_DATA_DIR: /data/sandbox
# @note optional: encrypts stored credentials in the database; unset,
# they are stored as given. See docs/configuration.md, "Encryption at rest"
PRISMA_FIELD_ENCRYPTION_KEY: ${PRISMA_FIELD_ENCRYPTION_KEY:-}
Expand Down Expand Up @@ -103,7 +120,7 @@ services:
condition: service_completed_successfully
restart: unless-stopped
healthcheck:
# Use node for the healthcheck since the alpine image has no wget/curl
# Use node for the healthcheck since the slim image has no wget/curl
test:
[
'CMD',
Expand Down Expand Up @@ -151,6 +168,10 @@ services:

garage:
image: dxflrs/garage:v2.1.0
environment:
# @note the 10s healthcheck logs three INFO lines per run; keep only
# warnings and errors
RUST_LOG: warn
ports:
# @note published on localhost so presigned URLs (which carry the
# garage:3900 endpoint) work from a host browser with the /etc/hosts
Expand Down
28 changes: 24 additions & 4 deletions docker/entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -120,14 +120,21 @@ fi

config_load

# Fills empty NEXTAUTH_SECRET / QUEUE_SECRET / JWT_TOKEN_SECRET_KEY with values
# generated once and persisted in $DATA_DIR, so sessions, queue signatures and
# issued tokens survive restarts as long as it is a volume.
if [ -z "$NEXTAUTH_SECRET" ] || [ -z "$QUEUE_SECRET" ] || [ -z "$JWT_TOKEN_SECRET_KEY" ]; then
# Fills empty NEXTAUTH_SECRET / QUEUE_SECRET / JWT_TOKEN_SECRET_KEY /
# CLOAK_ENCRYPTION_KEY with values generated once and persisted in $DATA_DIR,
# so sessions, queue signatures, issued tokens and encrypted values survive
# restarts as long as it is a volume.
if [ -z "$NEXTAUTH_SECRET" ] || [ -z "$QUEUE_SECRET" ] || [ -z "$JWT_TOKEN_SECRET_KEY" ] || [ -z "$CLOAK_ENCRYPTION_KEY" ]; then
generate_secret() {
node -e 'process.stdout.write(require("node:crypto").randomBytes(32).toString("hex"))'
}

# @note the k1.aesgcm256 format @chatbotkit-dev/cloak expects: 32 random
# bytes, base64url without padding
generate_cloak_key() {
node -e 'process.stdout.write("k1.aesgcm256." + require("node:crypto").randomBytes(32).toString("base64url"))'
}

if [ ! -f "$SECRETS_FILE" ]; then
umask 077
printf 'GENERATED_NEXTAUTH_SECRET=%s\nGENERATED_QUEUE_SECRET=%s\n' "$(generate_secret)" "$(generate_secret)" > "$SECRETS_FILE"
Expand All @@ -141,6 +148,14 @@ if [ -z "$NEXTAUTH_SECRET" ] || [ -z "$QUEUE_SECRET" ] || [ -z "$JWT_TOKEN_SECRE
printf 'GENERATED_JWT_TOKEN_SECRET_KEY=%s\n' "$GENERATED_JWT_TOKEN_SECRET_KEY" >> "$SECRETS_FILE"
fi

# @note likewise for the cloak key. Without it the application refused every
# skillset call with an unrelated-looking TypeError, because the module that
# parses it threw at import inside a require cycle
if [ -z "$GENERATED_CLOAK_ENCRYPTION_KEY" ]; then
GENERATED_CLOAK_ENCRYPTION_KEY="$(generate_cloak_key)"
printf 'GENERATED_CLOAK_ENCRYPTION_KEY=%s\n' "$GENERATED_CLOAK_ENCRYPTION_KEY" >> "$SECRETS_FILE"
fi

if [ -z "$NEXTAUTH_SECRET" ]; then
echo "WARNING: NEXTAUTH_SECRET is not set - using a generated value persisted in $SECRETS_FILE" >&2
export NEXTAUTH_SECRET="$GENERATED_NEXTAUTH_SECRET"
Expand All @@ -155,6 +170,11 @@ if [ -z "$NEXTAUTH_SECRET" ] || [ -z "$QUEUE_SECRET" ] || [ -z "$JWT_TOKEN_SECRE
echo "WARNING: JWT_TOKEN_SECRET_KEY is not set - using a generated value persisted in $SECRETS_FILE" >&2
export JWT_TOKEN_SECRET_KEY="$GENERATED_JWT_TOKEN_SECRET_KEY"
fi

if [ -z "$CLOAK_ENCRYPTION_KEY" ]; then
echo "WARNING: CLOAK_ENCRYPTION_KEY is not set - using a generated value persisted in $SECRETS_FILE" >&2
export CLOAK_ENCRYPTION_KEY="$GENERATED_CLOAK_ENCRYPTION_KEY"
fi
fi

# Storage credentials generated by garage-init land in the shared data
Expand Down
2 changes: 2 additions & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ The documents here cover the operational detail needed to run and evaluate it.
and operator responsibilities
- [Module defaults](./module-defaults.md) - what each public module does with
nothing set, and what the distribution flavors change
- [SDKs](./sdks.md) - point the Node.js, Python, Go and Terraform clients at
your own deployment

## Project guides

Expand Down
5 changes: 5 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,11 @@ APP_MAIN_ORIGIN=https://apps.example.com
APP_LABS_ORIGIN=https://labs.example.com
```

Like the apexes, the shell host rewrites are generated when Next builds, so
the runtime origins must match the build. The community image bakes
`APP_MAIN_ORIGIN=http://apps.localhost:3000` and
`APP_LABS_ORIGIN=http://labs.localhost:3000`.

## `HOSTS_CONFIG`

Optional request-affine host mappings. Each operator-defined key groups the
Expand Down
28 changes: 16 additions & 12 deletions docs/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,8 @@ A flavor is the baseline of [module defaults](./module-defaults.md) plus the
backing services its stack provisions. Everything not listed keeps the
default - in the community flavor the database is SQLite in the platform data
volume, the queue is immediate and non-durable, sign-in codes are read from
the container log, and the sandbox refuses under `NODE_ENV=production`.
the container log, and agent code runs in the default in-process sandbox with
its workspaces kept under `/data/sandbox` in the same volume.

| Flavor | Database | Cache | Vector | Storage |
| ----------- | ----------------------- | ----- | ------ | ------- |
Expand All @@ -193,8 +194,10 @@ production infrastructure. A production deployment still needs:
platform data volume, or explicit operator-provided values
- durable database, object-storage and backup policies
- a durable queue when delayed delivery, retries, callbacks or ordering matter
- a production-safe isolated sandbox implementation if agent code execution is
enabled
- a sandbox with kernel-level isolation and per-tenant resource accounting if
agent code execution is exposed to untrusted users; the default runs agent
code in a userspace VM inside the application process - see
[module defaults](./module-defaults.md)
- monitoring, restore testing and an upgrade and rollback procedure

The repository does not yet publish versioned releases, SBOMs or signed
Expand All @@ -210,15 +213,16 @@ parts of host and subscription configuration, is therefore not baked into the
and keep secrets out of image layers.

The current community image deliberately bakes the neutral single-host
topology: `SITE_URL=http://localhost:3000`, with no app-shell origins or
external zones. Two apexes are baked alongside it so deployment-issued
subdomains work out of the box: `SPACE_APEX=space.localhost` and
`PORTAL_APEX=portal.localhost`. Browsers resolve any `*.localhost` name to
loopback, so a space site published as `acme` answers at
`http://acme.space.localhost:3000` with no DNS or hosts-file setup (`curl`
needs `--resolve`). The runtime `SPACE_APEX` and `PORTAL_APEX` must name the
same apexes as the build, which the compose files ensure; a different apex
needs a rebuild with the matching build arguments. Runtime service variables
topology: `SITE_URL=http://localhost:3000`, with no external zones. Two apexes
are baked alongside it so deployment-issued subdomains work out of the box:
`SPACE_APEX=space.localhost` and `PORTAL_APEX=portal.localhost`, and the two
app shells answer at `http://apps.localhost:3000` and
`http://labs.localhost:3000` through `APP_MAIN_ORIGIN` and `APP_LABS_ORIGIN`.
Browsers resolve any `*.localhost` name to loopback, so a space site published
as `acme` answers at `http://acme.space.localhost:3000` with no DNS or
hosts-file setup (`curl` needs `--resolve`). The runtime apexes and shell
origins must name the same hosts as the build, which the compose files ensure;
a different host needs a rebuild with the matching build arguments. Runtime service variables
such as the database, Redis, Qdrant and S3-compatible storage endpoints remain
configurable. Deployment identity that Next currently exposes through
`next.config.js` is still frozen at build time; do not present the same digest
Expand Down
37 changes: 27 additions & 10 deletions docs/module-defaults.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,19 +65,36 @@ an inbound implementation replaces the module.

### Sandbox

The public sandbox runs code in the application process for development and
refuses under `NODE_ENV=production`. Production code execution requires an
isolated implementation with explicit CPU, memory, disk, network, lifetime and
tenant boundaries.
The public sandbox runs agent commands in [AgentOS](https://github.com/rivet-dev/agentos):
a userspace Linux with its own filesystem, process table and network stack,
owned by a native sidecar process that brokers every guest syscall. Shell,
coreutils, Node.js and `npm` work; outbound network is open, with loopback,
private and link-local destinations refused; each sandbox keeps a `/workspace`
directory under `SANDBOX_DATA_DIR` that survives restarts. Python is reported as
unsupported until the sidecar ships its runtime. The isolation is the
sidecar's, not the kernel's, and CPU is shared with the application, so a
deployment exposing code execution to untrusted tenants at scale still wants
an implementation with kernel-level isolation and per-tenant accounting.

### Realtime relay

The public relay module builds channel addresses for any relay speaking the
platform's channel protocol, from `RELAY_URL`. It is also a relay: when
`RELAY_PORT` is set its `listen` hosts a single-node one inside the
application process, which the compose stacks do, so realtime voice and
avatar sessions work locally. Unset, the module refuses
at the point of use and fails the readiness check. Meeting bots and telephony
are dialled in from outside and need a relay that party can reach - see
`packages/relay/README.md`.

### Unavailable service defaults

The public batch runner, realtime relay, screenshot capture and response
delivery modules keep the application importable but refuse their service
operations. Their `assertConfigured` checks fail so deployment readiness tests
cannot mistake an unavailable capability for a production backend. Features
that need scheduled batch work, live relay channels, captured pages, or
outbound response delivery require an operator implementation.
The public batch runner, screenshot capture and response delivery modules keep
the application importable but refuse their service operations. Their
`assertConfigured` checks fail so deployment readiness tests cannot mistake an
unavailable capability for a production backend. Features that need scheduled
batch work, captured pages, or outbound response delivery require an operator
implementation.

### Optional and no-op defaults

Expand Down
Loading