chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,197 @@
|
||||
# Copy to .env and edit. docker-compose reads .env automatically.
|
||||
|
||||
# ── Postgres ─────────────────────────────────────────────
|
||||
# Required. The omnigent container connects via the docker network
|
||||
# as host=postgres, so this password is internal-only — but still set
|
||||
# something non-trivial. Run `./bootstrap.sh` to auto-generate, or
|
||||
# replace the placeholder manually.
|
||||
POSTGRES_PASSWORD=change-me-please
|
||||
|
||||
# Optional — defaults below match the compose file.
|
||||
# POSTGRES_USER=omnigent
|
||||
# POSTGRES_DB=omnigent
|
||||
|
||||
# ── Server ───────────────────────────────────────────────
|
||||
# Host port the omnigent container is published on. Default 8000.
|
||||
# OMNIGENT_PORT=8000
|
||||
|
||||
# ── Image ────────────────────────────────────────────────
|
||||
# The compose stack pulls a pre-built image from GHCR (built by CI on
|
||||
# every main-branch merge). Default: ghcr.io/omnigent-ai/omnigent-server.
|
||||
#
|
||||
# While the GHCR package is private, authenticate the pull first:
|
||||
# echo $GHCR_TOKEN | docker login ghcr.io -u <user> --password-stdin
|
||||
# (token needs read:packages). Once the package is public, no login needed.
|
||||
#
|
||||
# Pin to a specific commit's image (`sha-abc1234`) for reproducible
|
||||
# deploys, or leave as `latest` for rolling.
|
||||
# OMNIGENT_IMAGE=ghcr.io/omnigent-ai/omnigent-server
|
||||
# OMNIGENT_IMAGE_TAG=latest
|
||||
|
||||
# ── Auth ─────────────────────────────────────────────────
|
||||
# Default deploy is single-user, no auth, identity = "local".
|
||||
# Leave everything below as-is unless you're standing up a shared
|
||||
# instance.
|
||||
#
|
||||
# A) Built-in accounts (DEFAULT — no env needed for laptop testing).
|
||||
# First boot auto-creates an admin user (named after the OS
|
||||
# user, falling back to "admin"), prints the password to
|
||||
# `docker compose logs omnigent`, and saves it to
|
||||
# /data/admin-credentials on the persistent volume. Admin
|
||||
# invites teammates via the web UI Members page.
|
||||
# For any deploy behind a public domain you MUST set
|
||||
# OMNIGENT_ACCOUNTS_BASE_URL — see below.
|
||||
#
|
||||
# B) Native OIDC (for shops with an existing IdP).
|
||||
# Set the OMNIGENT_OIDC_* vars below (at minimum
|
||||
# OMNIGENT_OIDC_ISSUER) — with auth enabled, the presence of
|
||||
# an issuer switches mode B on automatically, no separate
|
||||
# provider flag needed. The server handles login itself
|
||||
# (/auth/login, /auth/callback, /auth/logout) with a signed
|
||||
# session cookie.
|
||||
#
|
||||
# C) Header proxy (for deploys behind oauth2-proxy, AWS ALB
|
||||
# OIDC, Cloudflare Access, Databricks Apps, Tailscale Funnel,
|
||||
# etc.). Set OMNIGENT_AUTH_PROVIDER=header. The proxy is
|
||||
# responsible for injecting the identity header on every request
|
||||
# and stripping any inbound copy from the client. The header is
|
||||
# X-Forwarded-Email by default; set OMNIGENT_AUTH_HEADER for
|
||||
# proxies that use another name, e.g.
|
||||
# Cf-Access-Authenticated-User-Email for Cloudflare Access.
|
||||
# For proxies that namespace the value (Google IAP forwards
|
||||
# X-Goog-Authenticated-User-Email as accounts.google.com:<email>)
|
||||
# set OMNIGENT_AUTH_HEADER_STRIP_PREFIX=accounts.google.com: to
|
||||
# recover the bare email.
|
||||
#
|
||||
# OMNIGENT_AUTH_ENABLED is the master switch (defaults to 1 in
|
||||
# docker-compose.yaml). Set it to 0 to disable auth entirely (header
|
||||
# mode, single "local" user). OMNIGENT_AUTH_PROVIDER is the explicit
|
||||
# escape hatch — set it to accounts/oidc/header to force a mode and
|
||||
# bypass the issuer-based auto-selection above.
|
||||
# OMNIGENT_AUTH_PROVIDER=accounts # uncomment to force a specific mode
|
||||
|
||||
# ── Accounts (active when auth is on and no OIDC issuer is set) ───
|
||||
# 32-byte hex cookie secret. `./bootstrap.sh` mints this on
|
||||
# first run; or generate manually with `openssl rand -hex 32`.
|
||||
# OMNIGENT_ACCOUNTS_COOKIE_SECRET=<64-hex-chars>
|
||||
#
|
||||
# Public base URL where users reach the server. Used to build
|
||||
# magic-redeem URLs and to decide whether session cookies use
|
||||
# the secure __Host- prefix. MUST be the URL the browser sees
|
||||
# (i.e. behind your Caddy / ALB / Cloudflare), not the
|
||||
# omnigent:8000 container address.
|
||||
# OMNIGENT_ACCOUNTS_BASE_URL=https://omnigent.example.com
|
||||
#
|
||||
# Optional: pre-seed the initial admin password instead of the
|
||||
# auto-generated one. Useful for headless / CI deploys where
|
||||
# the operator can't read `docker compose logs`.
|
||||
# OMNIGENT_ACCOUNTS_INIT_ADMIN_PASSWORD=
|
||||
#
|
||||
# Optional: session/invite/magic TTLs. Defaults shown.
|
||||
# OMNIGENT_ACCOUNTS_SESSION_TTL_HOURS=8
|
||||
# OMNIGENT_ACCOUNTS_INVITE_TTL_HOURS=72
|
||||
# OMNIGENT_ACCOUNTS_MAGIC_TTL_MINUTES=10
|
||||
#
|
||||
# Optional: skip the auto-open-browser step on first boot.
|
||||
# Default is to open; set to 0 for headless / SSH deploys.
|
||||
# Inside Docker the browser open is always a no-op since the
|
||||
# server has no display, but the value also disables the
|
||||
# stderr "open this URL" announcement when 0.
|
||||
# OMNIGENT_ACCOUNTS_AUTO_OPEN=1
|
||||
|
||||
# ── OIDC (active when auth is on and OMNIGENT_OIDC_ISSUER is set) ──
|
||||
# 32-byte random cookie secret in hex. `./bootstrap.sh` mints this
|
||||
# for you on first run; or generate manually with `openssl rand -hex 32`.
|
||||
# OMNIGENT_OIDC_COOKIE_SECRET=<64-hex-chars>
|
||||
#
|
||||
# Redirect URI: you do NOT set OMNIGENT_OIDC_REDIRECT_URI directly — set
|
||||
# OMNIGENT_DOMAIN (which the Caddy HTTPS overlay also uses) and the server
|
||||
# derives https://<OMNIGENT_DOMAIN>/auth/callback. Register that exact URL
|
||||
# as the callback in your IdP app. (Raw-IP/no-domain deploys: add an
|
||||
# OMNIGENT_OIDC_REDIRECT_URI passthrough line to docker-compose.yaml — the
|
||||
# domain stack is the supported path.)
|
||||
|
||||
# ── Example: GitHub OAuth ────────────────────────────────
|
||||
# 1. Register an OAuth app at https://github.com/settings/developers
|
||||
# - Authorization callback URL: https://<OMNIGENT_DOMAIN>/auth/callback
|
||||
# 2. Copy the client id + secret here. Setting the issuer (with auth
|
||||
# enabled) is what selects OIDC mode — no provider flag needed.
|
||||
# OMNIGENT_DOMAIN=omnigent.example.com
|
||||
# OMNIGENT_OIDC_ISSUER=https://github.com
|
||||
# OMNIGENT_OIDC_CLIENT_ID=Iv1.abc123…
|
||||
# OMNIGENT_OIDC_CLIENT_SECRET=…
|
||||
|
||||
# ── Example: Google Workspace ────────────────────────────
|
||||
# 1. Create OAuth credentials at console.cloud.google.com → APIs & Services → Credentials.
|
||||
# - Application type: Web application
|
||||
# - Authorized redirect URI: https://<OMNIGENT_DOMAIN>/auth/callback
|
||||
# 2. Restrict logins to your company domain(s) via ALLOWED_DOMAINS.
|
||||
# OMNIGENT_DOMAIN=omnigent.example.com
|
||||
# OMNIGENT_OIDC_ISSUER=https://accounts.google.com
|
||||
# OMNIGENT_OIDC_CLIENT_ID=…apps.googleusercontent.com
|
||||
# OMNIGENT_OIDC_CLIENT_SECRET=…
|
||||
# OMNIGENT_OIDC_ALLOWED_DOMAINS=example.com,subsidiary.example.com
|
||||
|
||||
# ── Example: Generic OIDC (Okta, Auth0, Keycloak, Entra) ─
|
||||
# Issuer just needs to publish /.well-known/openid-configuration.
|
||||
# OMNIGENT_DOMAIN=omnigent.example.com
|
||||
# OMNIGENT_OIDC_ISSUER=https://your-tenant.okta.com
|
||||
# OMNIGENT_OIDC_CLIENT_ID=…
|
||||
# OMNIGENT_OIDC_CLIENT_SECRET=…
|
||||
# OMNIGENT_OIDC_SCOPES=openid email profile # default — override only if needed
|
||||
|
||||
# ── Optional OIDC tuning ─────────────────────────────────
|
||||
# OMNIGENT_OIDC_SESSION_TTL_HOURS=8
|
||||
# OMNIGENT_OIDC_LOGOUT_REDIRECT_URI=https://omnigent.example.com/
|
||||
#
|
||||
# Skip the email_verified claim check on id_tokens. Some IdPs (e.g.
|
||||
# Okta without custom API Access Management) omit the claim for
|
||||
# directory-provisioned users, which otherwise fails login with
|
||||
# "Could not determine user email". Only enable when the issuer is a
|
||||
# trusted enterprise directory — it makes any signed email claim the
|
||||
# user's identity. Off by default.
|
||||
# OMNIGENT_OIDC_SKIP_EMAIL_VERIFICATION=1
|
||||
|
||||
# ── Server config file (admins, allowed domains, …) ──────
|
||||
# Non-secret settings live in a YAML config file — the same one
|
||||
# `omnigent server -c` reads. Default location is <data_dir>/config.yaml
|
||||
# (so /data/config.yaml in this compose stack); override with:
|
||||
# OMNIGENT_CONFIG=/data/config.yaml
|
||||
# See config.yaml.example for the full set (admins, allowed_domains,
|
||||
# artifact_location, policy_modules, …). Secrets stay HERE in .env.
|
||||
#
|
||||
# Admins: OIDC doesn't tell us who's an operator, so name them in the
|
||||
# config's `admins:` list (canonical). A listed identity is promoted to
|
||||
# admin on login (email for OIDC, username for accounts). Promotion is
|
||||
# ADDITIVE — removing an entry never demotes (demote from the Members
|
||||
# page). For a no-restart change you can also append to the
|
||||
# <data_dir>/admins file (union'd with config); override its path with
|
||||
# OMNIGENT_ADMIN_LIST_PATH.
|
||||
#
|
||||
# Allowed domains: the config's `allowed_domains:` list, OMNIGENT_OIDC_
|
||||
# ALLOWED_DOMAINS (env), and a <data_dir>/allowed_domains file are all
|
||||
# UNION'd. Admin-listed and invited identities bypass the domain check.
|
||||
# Override the file path with OMNIGENT_OIDC_ALLOWED_DOMAINS_PATH.
|
||||
|
||||
# ── OIDC invites (opt-in) ────────────────────────────────
|
||||
# Off by default. When on, an admin can mint a single-use invite link
|
||||
# (POST /auth/invite) that pre-authorizes whoever redeems it — letting
|
||||
# one external collaborator in past ALLOWED_DOMAINS without widening
|
||||
# the allowlist for everyone. The pre-authorization is recorded and
|
||||
# persists for that email's future logins.
|
||||
# OMNIGENT_OIDC_ALLOW_INVITES=1
|
||||
|
||||
# ── Switching accounts → OIDC (one-time data migration) ──
|
||||
# accounts keys users by username (alice); OIDC by email
|
||||
# (alice@example.com). Before flipping AUTH_PROVIDER, remap identities
|
||||
# so admin + permissions carry over (dry run first, then --commit):
|
||||
# omnigent accounts migrate-to-oidc <DB_URL> --domain example.com
|
||||
# omnigent accounts migrate-to-oidc <DB_URL> --domain example.com --commit
|
||||
# Then add your email to the config `admins:` (or /data/admins) and set
|
||||
# AUTH_PROVIDER=oidc.
|
||||
|
||||
# ── Build-time ───────────────────────────────────────────
|
||||
# Override only if your network blocks pypi.org (e.g. behind a
|
||||
# corporate proxy). Don't ship a built image that bakes a private
|
||||
# index — the layer will reference unreachable URLs for other users.
|
||||
# PYPI_INDEX_URL=https://pypi.org/simple
|
||||
@@ -0,0 +1,23 @@
|
||||
# Caddy config for the docker-compose.https.yaml overlay.
|
||||
#
|
||||
# Caddy auto-provisions a Let's Encrypt cert for {$OMNIGENT_DOMAIN}
|
||||
# (HTTP-01 challenge) and reverse-proxies to the omnigent service
|
||||
# over the internal docker network — the omnigent container is no
|
||||
# longer directly exposed to the host.
|
||||
#
|
||||
# No ACME email is required: Let's Encrypt allows anonymous account
|
||||
# registration, so the cert issues without one. If you WANT expiry /
|
||||
# renewal notices, add a global email option above the site block:
|
||||
#
|
||||
# {
|
||||
# email you@example.com
|
||||
# }
|
||||
#
|
||||
# (Earlier versions had `{ email {$OMNIGENT_ACME_EMAIL} }` here, which
|
||||
# hard-failed at startup when the var was unset — `email` with no
|
||||
# argument is a Caddyfile parse error. Omitting the block avoids that.)
|
||||
|
||||
{$OMNIGENT_DOMAIN} {
|
||||
encode zstd gzip
|
||||
reverse_proxy omnigent:8000
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
# Omnigent images: server (default target) + host (`--target host`).
|
||||
#
|
||||
# The default/final target builds the server image (external-runner
|
||||
# mode): the FastAPI / WebSocket coordinator — HTTP + SSE routes, the
|
||||
# runner WebSocket tunnel acceptor, and the SQLAlchemy stores. It does
|
||||
# NOT execute agent harnesses — runners run on the user's local machine
|
||||
# and dial in via WS /v1/runner/tunnel.
|
||||
#
|
||||
# As a consequence: no tmux, no git, no harness SDK runtime
|
||||
# requirements in the final image. The omnigent package is still
|
||||
# installed in full (its pyproject pulls claude-agent-sdk +
|
||||
# openai-agents transitively), so the bits are on disk; they are
|
||||
# simply never imported because HarnessProcessManager is never
|
||||
# invoked in this deployment mode.
|
||||
#
|
||||
# The `host` target is the inverse: a prebaked Omnigent HOST image for
|
||||
# remote sandboxes (e.g. `omnigent sandbox create --provider modal`)
|
||||
# and server-launched managed hosts. It bakes the full omnigent
|
||||
# install plus the tools a host needs at runtime — git (workspaces /
|
||||
# worktrees), tmux (terminal sessions spawned by native harnesses),
|
||||
# and the coding-harness CLIs (claude / codex / pi, via Node; kiro-cli via
|
||||
# Kiro's installer) — and
|
||||
# skips everything server-only: no SPA bundle, no psycopg, no
|
||||
# uvicorn entrypoint. Modal's `Image.from_registry` requirements shape
|
||||
# it: `python` + `pip` on $PATH, and CMD-only (an ENTRYPOINT that
|
||||
# doesn't exec its args would block Modal's own runtime from running).
|
||||
#
|
||||
# Both images publish multi-arch (linux/amd64 + linux/arm64) — see
|
||||
# .github/workflows/oss-publish-images.yml — so they run natively on
|
||||
# arm64 hosts (Apple Silicon laptops, arm64 clusters) as well as amd64.
|
||||
# Nothing below is arch-specific: the python/node base images are
|
||||
# multi-arch and apt/pip/npm/COPY-from-node all resolve per-arch under
|
||||
# buildx. Amd64-only consumers (Modal, Daytona, CoreWeave) keep pulling
|
||||
# the amd64 variant from the manifest list, unchanged.
|
||||
#
|
||||
# Build (from repo root):
|
||||
#
|
||||
# docker build -t omnigent-server:latest \
|
||||
# -f deploy/docker/Dockerfile .
|
||||
#
|
||||
# docker build -t omnigent-host:latest --target host \
|
||||
# -f deploy/docker/Dockerfile .
|
||||
#
|
||||
# Behind corporate package proxies (the host target also installs the
|
||||
# harness CLIs from npm, so it needs the npm proxy too):
|
||||
#
|
||||
# docker build -t omnigent-server:latest \
|
||||
# -f deploy/docker/Dockerfile \
|
||||
# --build-arg PYPI_INDEX_URL=https://pypi-proxy.example.com/simple \
|
||||
# --build-arg NPM_CONFIG_REGISTRY=https://npm-proxy.example.com .
|
||||
#
|
||||
# Or just use the bundled compose file (Postgres included):
|
||||
#
|
||||
# cd deploy/docker && docker compose up -d
|
||||
|
||||
# Must satisfy pyproject requires-python (>=3.12); 3.11 fails dependency resolution.
|
||||
ARG PYTHON_VERSION=3.12
|
||||
ARG NODE_VERSION=20
|
||||
|
||||
# ── Web UI builder ──────────────────────────────────────
|
||||
# Builds the web SPA so `docker build` works from a clean checkout —
|
||||
# no separate `cd web && npm run build` step, no "SPA bundle missing"
|
||||
# hard-fail. vite.config emits to ../omnigent/server/static/web-ui
|
||||
# (relative to web/), so from /web/web the bundle lands at
|
||||
# /web/omnigent/server/static/web-ui, which the server builder overlays.
|
||||
# Server-only: the host target never reaches this stage.
|
||||
#
|
||||
# Behind a corporate npm proxy, pass it through:
|
||||
# --build-arg NPM_CONFIG_REGISTRY=https://npm-proxy.example.com
|
||||
FROM node:${NODE_VERSION}-slim AS web-builder
|
||||
ARG NPM_CONFIG_REGISTRY=
|
||||
ENV NPM_CONFIG_REGISTRY=${NPM_CONFIG_REGISTRY}
|
||||
WORKDIR /web/web
|
||||
# Manifests first so the install layer caches across pure source edits.
|
||||
COPY web/package.json web/package-lock.json ./
|
||||
RUN npm install --no-audit --no-fund
|
||||
COPY web/ ./
|
||||
RUN npm run build
|
||||
|
||||
# ── Python builder (shared: server + host) ──────────────
|
||||
# Installs the package (and its transitive native-extension deps) into
|
||||
# a virtualenv the runtime stages copy verbatim. Keeps build-essential
|
||||
# out of the shipped images. Deliberately SPA-free and psycopg-free so
|
||||
# the host target can build without node; the server-only additions
|
||||
# live in server-builder below.
|
||||
FROM python:${PYTHON_VERSION}-slim AS builder
|
||||
|
||||
ARG PYPI_INDEX_URL=https://pypi.org/simple
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PIP_DISABLE_PIP_VERSION_CHECK=1
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends build-essential \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN pip install --index-url ${PYPI_INDEX_URL} --no-cache-dir uv
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
# Manifests + SDK path-deps first so the install layer caches across
|
||||
# pure source edits.
|
||||
COPY pyproject.toml setup.py ./
|
||||
# License + third-party attribution travel into the published images (the host
|
||||
# and runtime targets COPY /build below), since the image redistributes the
|
||||
# dependency bytes baked in by the install step.
|
||||
COPY LICENSE NOTICE ./
|
||||
COPY sdks/ ./sdks/
|
||||
COPY omnigent/ ./omnigent/
|
||||
# The built-in polly agent's packaged bundle (omnigent/resources/examples/
|
||||
# polly) is a symlink into the top-level examples/ tree. Ship examples/ so the
|
||||
# symlink resolves at boot; without it the new-session picker silently drops
|
||||
# polly (the seeder skips it when its bundle is absent). ~120K of YAML.
|
||||
COPY examples/ ./examples/
|
||||
|
||||
# Standalone venv we can copy out as a single directory. VIRTUAL_ENV
|
||||
# tells uv to install into this venv (not the slim image's system
|
||||
# Python) — without it, uv falls back to system Python and the
|
||||
# copied /opt/venv lands in the runtime stage empty.
|
||||
RUN python -m venv /opt/venv
|
||||
ENV VIRTUAL_ENV=/opt/venv \
|
||||
PATH="/opt/venv/bin:${PATH}"
|
||||
|
||||
# Install omnigent. Editable install (-e) is required because
|
||||
# pyproject.toml declares the sibling SDKs as editable path deps via
|
||||
# [tool.uv.sources]. The runtime stages preserve /build/ so the venv's
|
||||
# .pth references stay valid.
|
||||
RUN uv pip install --no-cache-dir --index-url ${PYPI_INDEX_URL} -e .
|
||||
|
||||
# ── Server builder ──────────────────────────────────────
|
||||
# Server-only additions on top of the shared builder: the SPA bundle
|
||||
# and the Postgres driver.
|
||||
FROM builder AS server-builder
|
||||
|
||||
ARG PYPI_INDEX_URL=https://pypi.org/simple
|
||||
|
||||
# Overlay the SPA bundle built in the web-builder stage, so a clean
|
||||
# checkout (with no prebuilt bundle on the host) still produces a
|
||||
# complete image. This replaces the old "prebuild or hard-fail" check.
|
||||
COPY --from=web-builder /web/omnigent/server/static/web-ui ./omnigent/server/static/web-ui
|
||||
RUN test -f ./omnigent/server/static/web-ui/index.html \
|
||||
|| (echo "ERROR: SPA bundle missing after web-builder stage — check the web build." && exit 1)
|
||||
|
||||
# psycopg[binary] is not a baseline dep — pulled in by the
|
||||
# [databricks] extra in pyproject — so add it explicitly here.
|
||||
RUN uv pip install --no-cache-dir --index-url ${PYPI_INDEX_URL} 'psycopg[binary]>=3.1,<4'
|
||||
|
||||
# Optional managed-sandbox provider extras for the SERVER (the launcher imports
|
||||
# the provider SDK — e.g. the kubernetes client for `sandbox.provider:
|
||||
# kubernetes`). Off by default; the runner host image needs none of these. Build
|
||||
# with `--build-arg OMNIGENT_EXTRAS=kubernetes` (comma-separate for several).
|
||||
ARG OMNIGENT_EXTRAS=
|
||||
RUN if [ -n "${OMNIGENT_EXTRAS}" ]; then \
|
||||
uv pip install --no-cache-dir --index-url ${PYPI_INDEX_URL} -e ".[${OMNIGENT_EXTRAS}]"; \
|
||||
fi
|
||||
|
||||
# ── Node alias stage ─────────────────────────────────────
|
||||
# Pure alias so the host stage can COPY node out of a version-pinned
|
||||
# image; contributes no layers of its own.
|
||||
FROM node:${NODE_VERSION}-slim AS node-runtime
|
||||
|
||||
# ── Host runtime (`--target host`) ──────────────────────
|
||||
# Prebaked Omnigent host for remote sandboxes: full omnigent install,
|
||||
# git + tmux for harness runtime needs, procps + lsof so the antigravity-native
|
||||
# executor can discover agy's connect-RPC port (antigravity_native_rpc.py uses
|
||||
# `pgrep` in _list_agy_pids to find agy processes — with a /proc-scan fallback if
|
||||
# pgrep is absent — then `lsof` in resolve_language_server_port to read their
|
||||
# loopback ports; lsof has NO fallback, so a missing lsof silently yields "no
|
||||
# port" and breaks web-turn injection), bubblewrap to OS-sandbox the
|
||||
# native harness terminals (mandatory and fail-loud on Linux), curl + CA
|
||||
# certificates for outbound HTTPS and in-sandbox downloads, plus the
|
||||
# coding-harness CLIs (claude / codex / pi / kiro-cli) so claude-sdk,
|
||||
# claude-native, codex, pi, and kiro-native agents can run in managed
|
||||
# sandboxes without an in-sandbox install. No SPA, no psycopg, no server
|
||||
# entrypoint.
|
||||
#
|
||||
# Also carries two additions required only by the NVIDIA OpenShell provider
|
||||
# (deploy/openshell/README.md) and inert for the root-based providers
|
||||
# (Modal / Daytona / CoreWeave):
|
||||
# - iproute2 / nftables: OpenShell puts each sandbox in its own network
|
||||
# namespace and routes egress through a policy proxy; the supervisor
|
||||
# shells out to `ip` to build that netns and refuses to start a sandbox
|
||||
# without it. The other providers create no namespaces, so it sits unused.
|
||||
# - a non-root `sandbox` user/group: OpenShell drops privileges to a user
|
||||
# literally named `sandbox` before running the agent (defense in depth)
|
||||
# and fails closed if it is absent. The other providers run as root and
|
||||
# never reference it.
|
||||
FROM python:${PYTHON_VERSION}-slim AS host
|
||||
ARG NPM_CONFIG_REGISTRY=
|
||||
ENV NPM_CONFIG_REGISTRY=${NPM_CONFIG_REGISTRY}
|
||||
|
||||
# IS_SANDBOX: devcontainer-convention flag consulted by Claude Code —
|
||||
# it refuses --dangerously-skip-permissions under root without it, and
|
||||
# sandbox containers run as root. The host forwards it to runners (see
|
||||
# _RUNNER_ENV_ALLOWLIST in omnigent/host/connect.py) so the
|
||||
# claude-sdk harness can start. Only this image sets it; laptops never
|
||||
# have it.
|
||||
ENV PYTHONUNBUFFERED=1 \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
PATH="/opt/venv/bin:${PATH}" \
|
||||
IS_SANDBOX=1
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
git tmux procps lsof bubblewrap curl ca-certificates unzip \
|
||||
iproute2 nftables \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# OpenShell-required non-root user (see the header note). UID/GID in the high
|
||||
# range per OpenShell's bring-your-own-container guidance, so that without
|
||||
# user-namespace remapping the sandbox user maps to an unprivileged, unused
|
||||
# host id. Unused by the root-based providers.
|
||||
RUN groupadd -g 1000660000 sandbox \
|
||||
&& useradd -m -d /sandbox -u 1000660000 -g sandbox sandbox
|
||||
|
||||
# Git credential helper for private repositories over HTTPS: answers
|
||||
# `git credential get` from GIT_TOKEN / GIT_USERNAME in the
|
||||
# environment (injected via Modal secrets — see deploy/modal/README.md
|
||||
# "Git credentials"), so the managed launch's repository clone AND the
|
||||
# agent's later fetch/push authenticate without writing credentials to
|
||||
# disk. Emits nothing when GIT_TOKEN is unset, leaving anonymous
|
||||
# clones of public repositories untouched. GIT_USERNAME defaults to
|
||||
# x-access-token (GitHub's token-auth username; GitLab users set
|
||||
# GIT_USERNAME=oauth2). --system so it applies to any sandbox user.
|
||||
RUN git config --system credential.helper \
|
||||
'!f() { [ "$1" = get ] || return 0; [ -n "$GIT_TOKEN" ] || return 0; printf "username=%s\npassword=%s\n" "${GIT_USERNAME:-x-access-token}" "$GIT_TOKEN"; }; f'
|
||||
|
||||
# Node runtime for the harness CLIs, copied from the official image
|
||||
# (same Debian base as python-slim, so the binary is ABI-compatible)
|
||||
# rather than apt — keeps the version pinned to NODE_VERSION and skips
|
||||
# nodesource setup. npm/npx are symlinks into npm's node_modules.
|
||||
# (`node-runtime` is the alias stage below — COPY --from does not
|
||||
# expand build args, so the FROM line does the ${NODE_VERSION} part.)
|
||||
COPY --from=node-runtime /usr/local/bin/node /usr/local/bin/node
|
||||
COPY --from=node-runtime /usr/local/lib/node_modules /usr/local/lib/node_modules
|
||||
RUN ln -s /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm \
|
||||
&& ln -s /usr/local/lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx
|
||||
|
||||
# The harness CLI set mirrors omnigent/onboarding/harness_install.py
|
||||
# (the binary/package map `omnigent setup` installs from) — keep the
|
||||
# two in sync. Unpinned on purpose: the official image is rebuilt by
|
||||
# CI, so it tracks the same "latest" a laptop install would get.
|
||||
RUN npm install -g --no-audit --no-fund \
|
||||
@anthropic-ai/claude-code \
|
||||
@openai/codex \
|
||||
@earendil-works/pi-coding-agent \
|
||||
&& npm cache clean --force
|
||||
|
||||
# Kiro CLI is not published as an npm package, and its installer has NO version
|
||||
# flag — `curl …/install | bash` always fetches `latest`, so the image was
|
||||
# non-deterministic. The kiro-native harness is behaviorally coupled to a
|
||||
# specific kiro-cli build (Escape-interrupt leaves an empty composer, the
|
||||
# bracketed-paste multi-line path, the session-JSONL layout — all verified
|
||||
# against 2.10.0; grep `kiro-cli 2.10.0`). So pin it the same way as `agy` below:
|
||||
# fetch the immutable per-arch zip from the versioned CDN path and verify its
|
||||
# sha256, run the package's own (network-free) install.sh, then copy the binaries
|
||||
# onto a system PATH dir every sandbox user shares. A trailing `kiro-cli
|
||||
# --version` check asserts the unpacked binary really is the pinned version — a
|
||||
# cheap sanity guard atop the sha256. To adopt a new kiro-cli: re-verify the
|
||||
# coupled behavior, then bump KIRO_CLI_VERSION + both
|
||||
# SHA256s (the `sha256` fields in
|
||||
# https://prod.download.cli.kiro.dev/stable/latest/manifest.json). Keep in sync
|
||||
# with deploy/docker/Dockerfile.ubi.
|
||||
ARG KIRO_CLI_VERSION=2.10.0
|
||||
ARG KIRO_CLI_SHA256_AMD64=be9d8b6d7c44f93a83ca22466043d98ad058e6ed3c12fffd068f3fb8a60b3b70
|
||||
ARG KIRO_CLI_SHA256_ARM64=0afb37399b9e2847c2f2e3f5d9052c8bc52bbf1e30401ea284a602661bce34bc
|
||||
RUN set -eu; \
|
||||
case "$(uname -m)" in \
|
||||
x86_64) asset="kirocli-x86_64-linux.zip"; sha="$KIRO_CLI_SHA256_AMD64" ;; \
|
||||
aarch64) asset="kirocli-aarch64-linux.zip"; sha="$KIRO_CLI_SHA256_ARM64" ;; \
|
||||
*) echo "ERROR: unsupported arch '$(uname -m)' for kiro-cli" >&2; exit 1 ;; \
|
||||
esac; \
|
||||
curl -fsSL -o /tmp/kiro.zip "https://prod.download.cli.kiro.dev/stable/${KIRO_CLI_VERSION}/${asset}"; \
|
||||
echo "${sha} /tmp/kiro.zip" | sha256sum -c -; \
|
||||
unzip -q /tmp/kiro.zip -d /tmp/kiro; \
|
||||
KIRO_CLI_SKIP_SETUP=1 sh /tmp/kiro/kirocli/install.sh; \
|
||||
install -m 0755 /root/.local/bin/kiro-cli /usr/local/bin/kiro-cli; \
|
||||
if [ -f /root/.local/bin/kiro-cli-chat ]; then \
|
||||
install -m 0755 /root/.local/bin/kiro-cli-chat /usr/local/bin/kiro-cli-chat; \
|
||||
fi; \
|
||||
rm -rf /tmp/kiro /tmp/kiro.zip; \
|
||||
installed="$(/usr/local/bin/kiro-cli --version 2>&1 | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -n1)"; \
|
||||
[ "$installed" = "$KIRO_CLI_VERSION" ] || { \
|
||||
echo "ERROR: kiro-cli reports '${installed:-<none>}', expected '$KIRO_CLI_VERSION'." >&2; exit 1; }; \
|
||||
echo "kiro-cli ${KIRO_CLI_VERSION} pinned (sha256 verified)"
|
||||
# Antigravity CLI (`agy`) — the antigravity-native harness shells out to `agy`
|
||||
# on the host, launching it in a tmux pane (see omnigent/antigravity_native*.py),
|
||||
# so a managed host image must carry it. It is NOT an npm package
|
||||
# (harness_install.py lists agy as a non-npm, installer-script harness), so it
|
||||
# can't join the `npm install -g` set above. The tarball holds a single
|
||||
# self-contained ``antigravity`` binary; install it as ``agy`` on a system PATH
|
||||
# dir every user shares (its bootstrapper default ~/.local/bin is per-user and
|
||||
# off the venv PATH). ``test -x`` fails the build loudly if the layout changes.
|
||||
#
|
||||
# Version + integrity pin: the native harness is behaviorally coupled to a
|
||||
# specific agy build (out-of-order transcript writes, connect-RPC quirks, and TUI
|
||||
# injection are all verified against 1.0.10 — grep ``agy 1.0.10`` under
|
||||
# omnigent/antigravity_native*). The official ``install.sh`` bootstrapper has NO
|
||||
# version flag — it always fetches the LATEST build from an auto-updater manifest
|
||||
# and old builds are not retained at any stable, reconstructable URL — so it
|
||||
# cannot pin anything. Instead we fetch the exact, immutable per-arch release
|
||||
# asset from GitHub and verify its SHA256: this both holds the verified version
|
||||
# AND fails the build if the bytes ever change underneath us, which is the actual
|
||||
# supply-chain control (a version-string check alone is not). To adopt a new agy:
|
||||
# re-verify the coupled behavior, then bump AGY_VERSION and both SHA256s (from
|
||||
# https://github.com/google-antigravity/antigravity-cli/releases).
|
||||
ARG AGY_VERSION=1.0.10
|
||||
ARG AGY_SHA256_AMD64=6547cf9a37227f26004fa4b805418b1df96f54c57b9723ca7d10864d2610bb0f
|
||||
ARG AGY_SHA256_ARM64=4674fabc3681221e54c90d15077c9a97a25ea71222001dabe44bf1576e888593
|
||||
RUN set -eu; \
|
||||
arch="$(dpkg --print-architecture)"; \
|
||||
case "$arch" in \
|
||||
amd64) asset="agy_cli_linux_x64.tar.gz"; sha="$AGY_SHA256_AMD64" ;; \
|
||||
arm64) asset="agy_cli_linux_arm64.tar.gz"; sha="$AGY_SHA256_ARM64" ;; \
|
||||
*) echo "ERROR: unsupported arch '$arch' for agy" >&2; exit 1 ;; \
|
||||
esac; \
|
||||
url="https://github.com/google-antigravity/antigravity-cli/releases/download/${AGY_VERSION}/${asset}"; \
|
||||
curl -fsSL -o /tmp/agy.tar.gz "$url"; \
|
||||
echo "${sha} /tmp/agy.tar.gz" | sha256sum -c -; \
|
||||
tar -xzf /tmp/agy.tar.gz -C /tmp antigravity; \
|
||||
install -m 0755 /tmp/antigravity /usr/local/bin/agy; \
|
||||
rm -f /tmp/agy.tar.gz /tmp/antigravity; \
|
||||
test -x /usr/local/bin/agy; \
|
||||
installed="$(/usr/local/bin/agy --version 2>&1 | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -n1)"; \
|
||||
if [ "$installed" != "$AGY_VERSION" ]; then \
|
||||
echo "ERROR: agy reports '${installed:-<unparseable>}', expected '$AGY_VERSION'." >&2; \
|
||||
exit 1; \
|
||||
fi; \
|
||||
echo "agy ${AGY_VERSION} pinned (sha256 verified)"
|
||||
|
||||
# Copy the venv and source tree. The editable install's .pth files reference
|
||||
# /build/omnigent and /build/sdks/* -- both denied by the k8s Landlock LSM
|
||||
# policy. Re-install without -e so the package bytes land in the venv's
|
||||
# site-packages and imports no longer require /build at runtime.
|
||||
COPY --from=builder /opt/venv /opt/venv
|
||||
COPY --from=builder /build /build
|
||||
RUN pip install --no-cache-dir /build /build/sdks/python-client /build/sdks/ui \
|
||||
&& ! grep -R --include='*.pth' --include='*.egg-link' -nE '/build(/|$)' /opt/venv/lib/python*/site-packages
|
||||
|
||||
# Sandbox launchers exec commands through `bash -lc`, and Debian's
|
||||
# /etc/profile unconditionally resets PATH for login shells — the ENV
|
||||
# PATH above would silently drop off and `omnigent` / `pip` would
|
||||
# resolve to the system Python instead of the venv. profile.d snippets
|
||||
# are sourced after that reset, so this puts the venv back in front.
|
||||
RUN echo 'export PATH="/opt/venv/bin:${PATH}"' > /etc/profile.d/omnigent-venv.sh
|
||||
|
||||
WORKDIR /root
|
||||
|
||||
# CMD only — Modal's `Image.from_registry` (and the sandbox launchers
|
||||
# generally) supply their own entrypoint command; an ENTRYPOINT here
|
||||
# would hijack it. The hold-open default mirrors what `omnigent
|
||||
# sandbox create` runs so a bare `docker run` behaves the same way.
|
||||
CMD ["sleep", "infinity"]
|
||||
|
||||
# ── Server runtime (default target) ─────────────────────
|
||||
FROM python:${PYTHON_VERSION}-slim AS runtime
|
||||
|
||||
ENV PYTHONUNBUFFERED=1 \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
PATH="/opt/venv/bin:${PATH}"
|
||||
|
||||
# curl + ca-certificates only — for the HEALTHCHECK and for outbound
|
||||
# HTTPS to LLM gateways / external services.
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends curl ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Preserve /build/ in the runtime stage — the venv's editable install
|
||||
# .pth files reference /build/omnigent and /build/sdks/* by absolute
|
||||
# path. Copying these to /app/ would break the import paths silently.
|
||||
COPY --from=server-builder /opt/venv /opt/venv
|
||||
COPY --from=server-builder /build /build
|
||||
COPY deploy/docker/entrypoint.py /app/entrypoint.py
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# /data is mounted as a persistent volume by docker-compose for the
|
||||
# artifact store.
|
||||
RUN mkdir -p /data/artifacts
|
||||
|
||||
ENV PORT=8000 \
|
||||
HOST=0.0.0.0 \
|
||||
ARTIFACT_DIR=/data/artifacts
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
||||
CMD curl -fsS "http://127.0.0.1:${PORT}/health" || exit 1
|
||||
|
||||
CMD ["python", "/app/entrypoint.py"]
|
||||
@@ -0,0 +1,65 @@
|
||||
# Keep the build context lean — only the bits the image actually needs.
|
||||
|
||||
# VCS + IDE
|
||||
.git
|
||||
.gitignore
|
||||
.github
|
||||
.vscode
|
||||
.idea
|
||||
|
||||
# Python build outputs
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
*.egg-info/
|
||||
build/
|
||||
dist/
|
||||
.venv/
|
||||
venv/
|
||||
|
||||
# Node build outputs. Critical: without this, a local `web/node_modules/`
|
||||
# (left over from `npm install` on the host) would be copied into the
|
||||
# build context and overlay the freshly-installed node_modules from the
|
||||
# Dockerfile's `npm ci` step — breaking `npm run build` with
|
||||
# "tsc: not found" if the host install was incomplete or wrong-platform.
|
||||
node_modules/
|
||||
|
||||
# Test + dev artifacts
|
||||
tests/
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
*.log
|
||||
|
||||
# Local databases / large blobs that may be in the workdir
|
||||
*.db
|
||||
*.sqlite
|
||||
mlflow.db
|
||||
conv_*
|
||||
|
||||
# web/ IS copied into the build context — the web-builder stage in
|
||||
# the Dockerfile runs `npm run build` against it to produce the SPA
|
||||
# bundle. The node_modules exclusion above keeps the host's install
|
||||
# from overlaying the container's.
|
||||
loadtest/
|
||||
demos/
|
||||
scripts/
|
||||
dev/
|
||||
designs/
|
||||
skills/
|
||||
.claude/
|
||||
|
||||
# Docs that don't belong in the runtime image
|
||||
TODO.md
|
||||
AGENTS.md
|
||||
openapi.json
|
||||
|
||||
# Databricks-Apps-specific deploy (Lakebase, UC Volumes, bundle config).
|
||||
# The OSS Docker entrypoint at deploy/docker/entrypoint.py IS copied.
|
||||
# The AWS Terraform/boto3 helpers at deploy/aws/ are deploy-time tooling,
|
||||
# not runtime — exclude them too.
|
||||
deploy/databricks/
|
||||
deploy/aws/
|
||||
@@ -0,0 +1,7 @@
|
||||
# Pulls the CI-built server image instead of building from source — the web UI
|
||||
# bundle is gitignored, so a plain `docker build` would fail. For platforms that
|
||||
# only run `docker build` (Railway, HF Spaces). Override the tag via
|
||||
# --build-arg OMNIGENT_IMAGE=ghcr.io/omnigent-ai/omnigent-server:vX.Y.Z.
|
||||
|
||||
ARG OMNIGENT_IMAGE=ghcr.io/omnigent-ai/omnigent-server:latest
|
||||
FROM ${OMNIGENT_IMAGE}
|
||||
@@ -0,0 +1,176 @@
|
||||
# Omnigent UBI images: server (default target) + host (`--target host`).
|
||||
#
|
||||
# Red Hat Universal Base Image (UBI 9) variant of the standard Dockerfile,
|
||||
# for RHEL/OpenShift environments that require UBI-compliant containers.
|
||||
# Same two-target structure: `runtime` (server) and `host` (sandbox).
|
||||
#
|
||||
# Build (from repo root):
|
||||
#
|
||||
# docker build -t omnigent-server:ubi \
|
||||
# -f deploy/docker/Dockerfile.ubi .
|
||||
#
|
||||
# docker build -t omnigent-host:ubi --target host \
|
||||
# -f deploy/docker/Dockerfile.ubi .
|
||||
|
||||
ARG PYTHON_VERSION=3.12
|
||||
ARG NODE_VERSION=20
|
||||
|
||||
# ── Web UI builder ──────────────────────────────────────
|
||||
FROM registry.access.redhat.com/ubi9/nodejs-${NODE_VERSION} AS web-builder
|
||||
ARG NPM_CONFIG_REGISTRY=
|
||||
ENV NPM_CONFIG_REGISTRY=${NPM_CONFIG_REGISTRY}
|
||||
|
||||
USER 0
|
||||
WORKDIR /web/web
|
||||
COPY web/package.json web/package-lock.json ./
|
||||
RUN npm install --no-audit --no-fund
|
||||
COPY web/ ./
|
||||
RUN npm run build
|
||||
|
||||
# ── Python builder (shared: server + host) ──────────────
|
||||
FROM registry.access.redhat.com/ubi9/python-312 AS builder
|
||||
|
||||
ARG PYPI_INDEX_URL=https://pypi.org/simple
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PIP_DISABLE_PIP_VERSION_CHECK=1
|
||||
|
||||
USER 0
|
||||
|
||||
RUN dnf install -y --nodocs gcc gcc-c++ make python3-devel \
|
||||
&& dnf clean all
|
||||
|
||||
RUN pip install --index-url ${PYPI_INDEX_URL} --no-cache-dir uv
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
COPY pyproject.toml setup.py ./
|
||||
COPY LICENSE NOTICE ./
|
||||
COPY sdks/ ./sdks/
|
||||
COPY omnigent/ ./omnigent/
|
||||
COPY examples/ ./examples/
|
||||
|
||||
RUN python -m venv /opt/venv
|
||||
ENV VIRTUAL_ENV=/opt/venv \
|
||||
PATH="/opt/venv/bin:${PATH}"
|
||||
|
||||
RUN uv pip install --no-cache-dir --index-url ${PYPI_INDEX_URL} -e .
|
||||
|
||||
# ── Server builder ──────────────────────────────────────
|
||||
FROM builder AS server-builder
|
||||
|
||||
ARG PYPI_INDEX_URL=https://pypi.org/simple
|
||||
|
||||
COPY --from=web-builder /web/omnigent/server/static/web-ui ./omnigent/server/static/web-ui
|
||||
RUN test -f ./omnigent/server/static/web-ui/index.html \
|
||||
|| (echo "ERROR: SPA bundle missing after web-builder stage — check the web build." && exit 1)
|
||||
|
||||
RUN uv pip install --no-cache-dir --index-url ${PYPI_INDEX_URL} 'psycopg[binary]>=3.1,<4'
|
||||
|
||||
# ── Node alias stage ─────────────────────────────────────
|
||||
FROM registry.access.redhat.com/ubi9/nodejs-${NODE_VERSION} AS node-runtime
|
||||
|
||||
# ── Host runtime (`--target host`) ──────────────────────
|
||||
FROM registry.access.redhat.com/ubi9/python-312 AS host
|
||||
ARG NPM_CONFIG_REGISTRY=
|
||||
ENV NPM_CONFIG_REGISTRY=${NPM_CONFIG_REGISTRY}
|
||||
|
||||
ENV PYTHONUNBUFFERED=1 \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
PATH="/opt/venv/bin:${PATH}" \
|
||||
IS_SANDBOX=1
|
||||
|
||||
USER 0
|
||||
|
||||
# curl-minimal and ca-certificates are preinstalled in UBI9.
|
||||
RUN dnf install -y --nodocs git tmux unzip \
|
||||
&& dnf clean all
|
||||
|
||||
RUN git config --system credential.helper \
|
||||
'!f() { [ "$1" = get ] || return 0; [ -n "$GIT_TOKEN" ] || return 0; printf "username=%s\npassword=%s\n" "${GIT_USERNAME:-x-access-token}" "$GIT_TOKEN"; }; f'
|
||||
|
||||
# Node runtime from the UBI Node image.
|
||||
COPY --from=node-runtime /usr/bin/node /usr/local/bin/node
|
||||
COPY --from=node-runtime /usr/lib/node_modules /usr/local/lib/node_modules
|
||||
RUN ln -sf /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm \
|
||||
&& ln -sf /usr/local/lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx
|
||||
|
||||
RUN npm install -g --no-audit --no-fund \
|
||||
@anthropic-ai/claude-code \
|
||||
@openai/codex \
|
||||
@earendil-works/pi-coding-agent \
|
||||
&& npm cache clean --force
|
||||
|
||||
# Kiro CLI is not published as an npm package and its installer has no version
|
||||
# flag (always fetches `latest`). Pin it by fetching the immutable per-arch zip
|
||||
# from the versioned CDN path + verifying sha256, then running the package's own
|
||||
# install.sh and copying the binaries onto the global PATH. The `--version` check
|
||||
# asserts the binary is the pinned version (a sanity guard atop the sha256). See
|
||||
# the fuller rationale in deploy/docker/Dockerfile — keep KIRO_CLI_VERSION + both
|
||||
# SHA256s in sync.
|
||||
ARG KIRO_CLI_VERSION=2.10.0
|
||||
ARG KIRO_CLI_SHA256_AMD64=be9d8b6d7c44f93a83ca22466043d98ad058e6ed3c12fffd068f3fb8a60b3b70
|
||||
ARG KIRO_CLI_SHA256_ARM64=0afb37399b9e2847c2f2e3f5d9052c8bc52bbf1e30401ea284a602661bce34bc
|
||||
RUN set -eu; \
|
||||
case "$(uname -m)" in \
|
||||
x86_64) asset="kirocli-x86_64-linux.zip"; sha="$KIRO_CLI_SHA256_AMD64" ;; \
|
||||
aarch64) asset="kirocli-aarch64-linux.zip"; sha="$KIRO_CLI_SHA256_ARM64" ;; \
|
||||
*) echo "ERROR: unsupported arch '$(uname -m)' for kiro-cli" >&2; exit 1 ;; \
|
||||
esac; \
|
||||
curl -fsSL -o /tmp/kiro.zip "https://prod.download.cli.kiro.dev/stable/${KIRO_CLI_VERSION}/${asset}"; \
|
||||
echo "${sha} /tmp/kiro.zip" | sha256sum -c -; \
|
||||
unzip -q /tmp/kiro.zip -d /tmp/kiro; \
|
||||
KIRO_CLI_SKIP_SETUP=1 sh /tmp/kiro/kirocli/install.sh; \
|
||||
install -m 0755 /root/.local/bin/kiro-cli /usr/local/bin/kiro-cli; \
|
||||
if [ -f /root/.local/bin/kiro-cli-chat ]; then \
|
||||
install -m 0755 /root/.local/bin/kiro-cli-chat /usr/local/bin/kiro-cli-chat; \
|
||||
fi; \
|
||||
rm -rf /tmp/kiro /tmp/kiro.zip; \
|
||||
installed="$(/usr/local/bin/kiro-cli --version 2>&1 | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -n1)"; \
|
||||
[ "$installed" = "$KIRO_CLI_VERSION" ] || { \
|
||||
echo "ERROR: kiro-cli reports '${installed:-<none>}', expected '$KIRO_CLI_VERSION'." >&2; exit 1; }; \
|
||||
echo "kiro-cli ${KIRO_CLI_VERSION} pinned (sha256 verified)"
|
||||
|
||||
COPY --from=builder /opt/venv /opt/venv
|
||||
COPY --from=builder /build /build
|
||||
|
||||
RUN echo 'export PATH="/opt/venv/bin:${PATH}"' > /etc/profile.d/omnigent-venv.sh
|
||||
|
||||
WORKDIR /root
|
||||
CMD ["sleep", "infinity"]
|
||||
|
||||
# ── Server runtime (default target) ─────────────────────
|
||||
FROM registry.access.redhat.com/ubi9/python-312 AS runtime
|
||||
|
||||
ENV PYTHONUNBUFFERED=1 \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
PATH="/opt/venv/bin:${PATH}"
|
||||
|
||||
USER 0
|
||||
|
||||
# curl-minimal and ca-certificates are preinstalled in UBI9;
|
||||
# installing full curl would conflict with curl-minimal.
|
||||
|
||||
|
||||
COPY --from=server-builder /opt/venv /opt/venv
|
||||
COPY --from=server-builder /build /build
|
||||
COPY deploy/docker/entrypoint.py /app/entrypoint.py
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN mkdir -p /data/artifacts \
|
||||
&& chown -R 1001:0 /data \
|
||||
&& chmod -R g=u /data
|
||||
|
||||
ENV PORT=8000 \
|
||||
HOST=0.0.0.0 \
|
||||
ARTIFACT_DIR=/data/artifacts
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
USER 1001
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
||||
CMD curl -fsS "http://127.0.0.1:${PORT}/health" || exit 1
|
||||
|
||||
CMD ["python", "/app/entrypoint.py"]
|
||||
@@ -0,0 +1,299 @@
|
||||
# Omnigent — docker-compose stack
|
||||
|
||||
Run the server as a self-contained Docker stack on any host: your
|
||||
laptop, a VPS, an EC2 instance, a home server, anywhere `docker
|
||||
compose` runs.
|
||||
|
||||
The stack:
|
||||
- `postgres` — persistent DB on a Docker volume
|
||||
- `omnigent` — the server image (built from `../Dockerfile`)
|
||||
|
||||
Auth is in-process — the server has both header-proxy and native
|
||||
OIDC modes built in (see [Multi-user mode](#multi-user-mode-oidc)
|
||||
below). There is no separate auth-proxy container.
|
||||
|
||||
## Quickstart (single-user)
|
||||
|
||||
```bash
|
||||
cd deploy/docker
|
||||
./bootstrap.sh # mints POSTGRES_PASSWORD + cookie secret into .env
|
||||
docker compose up -d
|
||||
docker compose logs -f omnigent # ctrl-c when boot is clean
|
||||
```
|
||||
|
||||
`bootstrap.sh` is idempotent — re-running it leaves already-set secrets
|
||||
alone. If you prefer to manage `.env` yourself, just `cp .env.example
|
||||
.env` and edit `POSTGRES_PASSWORD` (and `OMNIGENT_OIDC_COOKIE_SECRET`
|
||||
if you're enabling OIDC) by hand.
|
||||
|
||||
Server is on http://localhost:8000. The web UI prints the CLI command
|
||||
to launch a local runner against it. From your laptop:
|
||||
|
||||
```bash
|
||||
omnigent run path/to/agent.yaml --server http://localhost:8000
|
||||
```
|
||||
|
||||
Reset everything (drops the DB and the artifact store):
|
||||
|
||||
```bash
|
||||
docker compose down -v
|
||||
```
|
||||
|
||||
## Multi-user mode (accounts — default)
|
||||
|
||||
Built-in accounts auth: no IdP to register, no proxy to host.
|
||||
This is the default — `docker compose up -d` brings it up with no
|
||||
extra env wiring. First boot creates an admin user (named after the
|
||||
operator's OS user, falling back to `admin` in headless containers)
|
||||
with a random password that lands in the container logs and on the
|
||||
persistent volume at `/data/admin-credentials`.
|
||||
|
||||
For any deploy reachable through a public domain, also set the
|
||||
external URL so invite links resolve correctly:
|
||||
|
||||
```bash
|
||||
# Add to .env (bootstrap.sh already minted the cookie secret for you):
|
||||
OMNIGENT_ACCOUNTS_BASE_URL=https://omnigent.example.com
|
||||
|
||||
docker compose up -d
|
||||
docker compose logs omnigent | grep -A4 "Created initial admin"
|
||||
```
|
||||
|
||||
Copy the random `password` from the log line into the web UI's
|
||||
login form, then:
|
||||
|
||||
- Click your username in the top-right → **Members** → **Invite member**.
|
||||
- Share the single-use URL with the teammate; they pick their own
|
||||
username and password when they redeem it.
|
||||
- Sign-out lives in the same account menu.
|
||||
|
||||
Headless deploy (CI, Cloud Run, etc.) where you can't read the
|
||||
logs? Pre-seed the password:
|
||||
|
||||
```bash
|
||||
OMNIGENT_ACCOUNTS_INIT_ADMIN_PASSWORD=<your-strong-password>
|
||||
```
|
||||
|
||||
The persistent password file is at `/data/admin-credentials` on
|
||||
the `artifact-data` volume — survives `docker compose restart`,
|
||||
deleted by `docker compose down -v`.
|
||||
|
||||
## Multi-user mode (OIDC)
|
||||
|
||||
Single-user mode trusts everyone who reaches the port and uses the
|
||||
identity `"local"` for all requests. For a shared deploy, the server
|
||||
has native OIDC support — it handles the full
|
||||
login flow itself (`/auth/login`, `/auth/callback`, `/auth/logout`)
|
||||
with a signed session cookie. No extra container, no Caddy basic-auth
|
||||
shim, no oauth2-proxy.
|
||||
|
||||
### Walkthrough: GitHub OAuth (easiest to register)
|
||||
|
||||
1. **Register the OAuth app.** Go to
|
||||
https://github.com/settings/developers → New OAuth App. Set the
|
||||
callback to `https://<your-host>/auth/callback` (HTTPS is
|
||||
strongly recommended; GitHub permits HTTP for testing but warns).
|
||||
|
||||
2. **Mint a cookie secret.** `./bootstrap.sh` already did this on the
|
||||
quickstart path — `OMNIGENT_OIDC_COOKIE_SECRET` is set in your
|
||||
`.env`. If you skipped it, run `openssl rand -hex 32` and paste the
|
||||
value yourself.
|
||||
|
||||
3. **Edit `.env`:**
|
||||
```bash
|
||||
OMNIGENT_AUTH_PROVIDER=oidc
|
||||
OMNIGENT_OIDC_ISSUER=https://github.com
|
||||
OMNIGENT_OIDC_CLIENT_ID=Iv1.abc123…
|
||||
OMNIGENT_OIDC_CLIENT_SECRET=…
|
||||
OMNIGENT_OIDC_REDIRECT_URI=https://omnigent.example.com/auth/callback
|
||||
# OMNIGENT_OIDC_COOKIE_SECRET is already set by bootstrap.sh — leave it alone.
|
||||
```
|
||||
|
||||
4. **Bring it up.**
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
The server will fail loud at startup if any required OIDC env var
|
||||
is missing — check `docker compose logs omnigent` if it doesn't
|
||||
come up.
|
||||
|
||||
5. **Visit the URL** → you should be redirected to GitHub to log in,
|
||||
then back to the web UI with a `__Host-ap_session` cookie set.
|
||||
|
||||
### Walkthrough: Google Workspace (with domain allowlist)
|
||||
|
||||
```bash
|
||||
OMNIGENT_AUTH_PROVIDER=oidc
|
||||
OMNIGENT_OIDC_ISSUER=https://accounts.google.com
|
||||
OMNIGENT_OIDC_CLIENT_ID=…apps.googleusercontent.com
|
||||
OMNIGENT_OIDC_CLIENT_SECRET=…
|
||||
OMNIGENT_OIDC_REDIRECT_URI=https://omnigent.example.com/auth/callback
|
||||
OMNIGENT_OIDC_COOKIE_SECRET=<64-hex-chars>
|
||||
OMNIGENT_OIDC_ALLOWED_DOMAINS=example.com,subsidiary.example.com
|
||||
```
|
||||
|
||||
`ALLOWED_DOMAINS` is critical when the OAuth consent screen is
|
||||
"External" — without it, any Google account on the planet can log in.
|
||||
|
||||
### Generic OIDC (Okta, Auth0, Keycloak, Entra ID)
|
||||
|
||||
Any IdP that publishes `/.well-known/openid-configuration` works.
|
||||
Set `OMNIGENT_OIDC_ISSUER` to the base URL; the server fetches
|
||||
discovery at startup.
|
||||
|
||||
```bash
|
||||
OMNIGENT_AUTH_PROVIDER=oidc
|
||||
OMNIGENT_OIDC_ISSUER=https://your-tenant.okta.com
|
||||
OMNIGENT_OIDC_CLIENT_ID=…
|
||||
OMNIGENT_OIDC_CLIENT_SECRET=…
|
||||
OMNIGENT_OIDC_REDIRECT_URI=https://omnigent.example.com/auth/callback
|
||||
OMNIGENT_OIDC_COOKIE_SECRET=<64-hex-chars>
|
||||
```
|
||||
|
||||
### HTTPS for the callback URL
|
||||
|
||||
Most IdPs require HTTPS for non-localhost redirect URIs, and the
|
||||
session cookie uses the `__Host-` prefix which browsers only
|
||||
accept over HTTPS. Three options:
|
||||
|
||||
1. **Use the bundled Caddy overlay** (easiest — any VPS / EC2 / home
|
||||
server with a public domain):
|
||||
|
||||
```bash
|
||||
# In .env:
|
||||
OMNIGENT_DOMAIN=omnigent.example.com
|
||||
OMNIGENT_ACME_EMAIL=you@example.com # optional, for Let's Encrypt notices
|
||||
|
||||
# Point DNS A/AAAA records at the host, then:
|
||||
docker compose -f docker-compose.yaml -f docker-compose.https.yaml up -d
|
||||
```
|
||||
|
||||
Caddy auto-provisions and renews a Let's Encrypt cert; the
|
||||
omnigent container stops being directly exposed and only :80 +
|
||||
:443 are published. Requires Docker Compose 2.24+ for the overlay's
|
||||
`!reset` directive. See `Caddyfile` for the (3-line) config.
|
||||
|
||||
2. **Behind an existing reverse proxy** — point your proxy at
|
||||
`omnigent:8000` over the docker network (or `127.0.0.1:8000`
|
||||
from the host). Examples: AWS ALB with ACM cert, Cloudflare in
|
||||
"Full" SSL mode, Fly.io / Cloud Run / Render platform certs.
|
||||
|
||||
## Header-proxy mode (for deploys behind an existing SSO proxy)
|
||||
|
||||
If you already have oauth2-proxy, Databricks Apps, AWS ALB OIDC,
|
||||
Cloudflare Access, Tailscale Funnel, or any other proxy that injects
|
||||
an identity header, set `OMNIGENT_AUTH_PROVIDER=header`. The
|
||||
server will reject requests without the header.
|
||||
|
||||
```bash
|
||||
OMNIGENT_AUTH_PROVIDER=header
|
||||
```
|
||||
|
||||
The header read is `X-Forwarded-Email` by default. Proxies that use
|
||||
a different header name set `OMNIGENT_AUTH_HEADER` to point the
|
||||
server at it — for example, Cloudflare Access supplies the
|
||||
authenticated email in `Cf-Access-Authenticated-User-Email`:
|
||||
|
||||
```bash
|
||||
OMNIGENT_AUTH_PROVIDER=header
|
||||
OMNIGENT_AUTH_HEADER=Cf-Access-Authenticated-User-Email
|
||||
```
|
||||
|
||||
Some proxies namespace the value they inject. Google IAP forwards the
|
||||
email in `X-Goog-Authenticated-User-Email` prefixed with
|
||||
`accounts.google.com:`; set `OMNIGENT_AUTH_HEADER_STRIP_PREFIX` to drop
|
||||
it and recover the bare email:
|
||||
|
||||
```bash
|
||||
OMNIGENT_AUTH_PROVIDER=header
|
||||
OMNIGENT_AUTH_HEADER=X-Goog-Authenticated-User-Email
|
||||
OMNIGENT_AUTH_HEADER_STRIP_PREFIX=accounts.google.com:
|
||||
```
|
||||
|
||||
**Security note:** in this mode the proxy is responsible for
|
||||
stripping any inbound copy of the identity header from the client
|
||||
request — otherwise any visitor can spoof an identity. The server
|
||||
trusts whatever value reaches it.
|
||||
|
||||
## Environment variables
|
||||
|
||||
| Variable | Default | Purpose |
|
||||
|---|---|---|
|
||||
| `POSTGRES_PASSWORD` | *required* | DB password for the bundled Postgres container. |
|
||||
| `POSTGRES_USER` / `POSTGRES_DB` | `omnigent` | DB user + database name. |
|
||||
| `OMNIGENT_PORT` | `8000` | Host port the server is published on. |
|
||||
| `OMNIGENT_AUTH_ENABLED` | `1` (in compose) | Master auth switch. `1` → accounts (or oidc if `OMNIGENT_OIDC_ISSUER` is set); `0` → single-user local mode (every request is the shared `local` user — local dev only, never shared deploys). |
|
||||
| `OMNIGENT_AUTH_PROVIDER` | unset | Escape hatch to pin a mode explicitly: `header` / `accounts` / `oidc`. Overrides the `AUTH_ENABLED` auto-selection. |
|
||||
| `OMNIGENT_AUTH_HEADER` | `X-Forwarded-Email` | Header-mode only: name of the trusted identity header. Set for proxies that use another name, e.g. `Cf-Access-Authenticated-User-Email` (Cloudflare Access). |
|
||||
| `OMNIGENT_AUTH_HEADER_STRIP_PREFIX` | unset (strip nothing) | Header-mode only: prefix removed from the identity header value. Set to `accounts.google.com:` for Google IAP's `X-Goog-Authenticated-User-Email`. |
|
||||
| `OMNIGENT_OIDC_*` | unset | OIDC config — required in oidc mode (issuer set, or `AUTH_PROVIDER=oidc`). See `.env.example`. |
|
||||
| `PYPI_INDEX_URL` | `https://pypi.org/simple` | Build-time PyPI index — override only behind a corporate proxy. |
|
||||
|
||||
`DATABASE_URL` and `ARTIFACT_DIR` are computed by compose and
|
||||
injected into the container.
|
||||
|
||||
## Host image (`--target host`)
|
||||
|
||||
The same Dockerfile publishes a second image: the official Omnigent
|
||||
**host** image, which remote sandboxes boot from so they start in
|
||||
seconds instead of paying an in-sandbox dependency install. It bakes
|
||||
the full omnigent install (all three packages + deps, `python` and
|
||||
`pip` on PATH), `git` (workspaces / worktrees), `tmux` (terminal
|
||||
sessions spawned by native harnesses), and the coding-harness CLIs —
|
||||
`claude`, `codex`, `pi`, and `kiro-cli`, with the runtime they need — so
|
||||
claude-sdk / claude-native / codex / pi / kiro-native agents run in sandboxes
|
||||
without an in-sandbox install. None of the server-only bits are
|
||||
included (no SPA bundle, no psycopg, no uvicorn entrypoint).
|
||||
|
||||
CI publishes it next to the server image, with the same tag scheme:
|
||||
|
||||
- `ghcr.io/omnigent-ai/omnigent-host:latest` — tracks main HEAD
|
||||
(the default for `omnigent sandbox create --provider modal`)
|
||||
- `ghcr.io/omnigent-ai/omnigent-host:sha-<short>` — immutable
|
||||
per-commit pin
|
||||
- `ghcr.io/omnigent-ai/omnigent-host:vX.Y.Z` — release tags
|
||||
|
||||
Build it locally from the repo root:
|
||||
|
||||
```bash
|
||||
docker build -t omnigent-host:latest --target host \
|
||||
-f deploy/docker/Dockerfile .
|
||||
```
|
||||
|
||||
### Using it with the Modal sandbox provider
|
||||
|
||||
`omnigent sandbox create --provider modal` boots sandboxes from
|
||||
`ghcr.io/omnigent-ai/omnigent-host:latest` by default. Your local
|
||||
checkout's wheels are still built and overlaid on top at create time
|
||||
(`pip install --force-reinstall --no-deps`), so the sandbox runs
|
||||
exactly your code — the baked image just supplies the dependency
|
||||
tree. A checkout that adds a brand-new dependency needs that package
|
||||
installed manually in the sandbox until the official image rebuilds
|
||||
with it.
|
||||
|
||||
Two environment variables tune the pull:
|
||||
|
||||
| Variable | Purpose |
|
||||
|---|---|
|
||||
| `OMNIGENT_MODAL_HOST_IMAGE` | Override the image ref, e.g. an org-internal copy (`ghcr.io/<your-org>/omnigent-host:latest`) or a `:sha-<short>` pin. |
|
||||
| `OMNIGENT_MODAL_REGISTRY_SECRET` | Name of a [Modal secret](https://modal.com/secrets) holding registry credentials for private pulls. Create it with keys `REGISTRY_USERNAME` (your registry username) and `REGISTRY_PASSWORD` (for GHCR: a personal access token with `read:packages`). Unset = anonymous pull. |
|
||||
|
||||
### Using it with the Daytona sandbox provider
|
||||
|
||||
The same host image backs Daytona-managed sessions (server config
|
||||
`sandbox.provider: daytona`; Daytona is managed-only — there is no
|
||||
`omnigent sandbox create --provider daytona` CLI flow). Daytona ingests
|
||||
the registry image into an internal snapshot on first use (the first
|
||||
launch from a given image takes minutes; later launches reuse the
|
||||
snapshot and take seconds). Override the ref with
|
||||
`OMNIGENT_DAYTONA_HOST_IMAGE` or the server config's
|
||||
`sandbox.daytona.image`. See
|
||||
[`deploy/daytona/README.md`](../daytona/README.md) for the
|
||||
full provider guide (credentials, the free-tier egress relay, and
|
||||
security considerations).
|
||||
|
||||
## Related design docs
|
||||
|
||||
- `designs/OIDC_AUTH.md` — full native OIDC design
|
||||
- `designs/SESSIONS_AUTH.md` — `AuthProvider` contract + permission system
|
||||
@@ -0,0 +1,87 @@
|
||||
---
|
||||
name: deploy-docker-compose
|
||||
description: Run the Omnigent server as a Docker compose stack (server + Postgres) on any Docker host — your laptop, a VPS, EC2 by hand, or as the base layer of any container-platform deploy. Invoke when the user wants to build the image, bring up the compose stack, debug the stack on a host they already have, or extend the stack for a new platform.
|
||||
---
|
||||
|
||||
# Run Omnigent as a Docker compose stack
|
||||
|
||||
The `Dockerfile` here is the single image used by every non-Databricks
|
||||
deploy path. It bundles the FastAPI server + a pre-built web SPA
|
||||
into a slim Python runtime. The compose file pairs it with Postgres
|
||||
and exposes the server on port 8000.
|
||||
|
||||
The image is "external runner only" — it does NOT include `tmux`,
|
||||
the harness SDKs, or anything that would let it execute agent code
|
||||
in-process. Runners live on user machines and dial in via the
|
||||
WebSocket tunnel. This keeps the image small (~250 MB), the security
|
||||
boundary clean (server doesn't execute user code), and the deploy
|
||||
shape consistent across hosts.
|
||||
|
||||
The same Dockerfile also has a `host` target — the prebaked Omnigent
|
||||
HOST image (`omnigent-host`) that remote sandboxes boot from
|
||||
(`omnigent sandbox create --provider modal`, server-launched managed
|
||||
hosts). It is the inverse profile: full omnigent install plus git +
|
||||
tmux, no SPA, no psycopg, no server entrypoint. Both images are
|
||||
published by the same workflows with the same `:sha-<short>` /
|
||||
`:latest` / `:vX.Y.Z` tag scheme. See the "Host image" section in
|
||||
`README.md` here.
|
||||
|
||||
## TL;DR — bring it up
|
||||
|
||||
```bash
|
||||
cd deploy/docker
|
||||
cp .env.example .env # edit POSTGRES_PASSWORD at minimum
|
||||
docker compose up -d --build
|
||||
docker compose logs -f omnigent # Ctrl-C when you see "Uvicorn running"
|
||||
```
|
||||
|
||||
Server is on http://localhost:8000.
|
||||
|
||||
## Files
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `Dockerfile` | Multi-stage build with two final targets. `web-builder` (node:20) runs `npm install && npm run build` on `web/`. `builder` (python:3.12) installs omnigent into `/opt/venv`; `server-builder` overlays the SPA bundle from `web-builder` and adds psycopg. The default target (`runtime`) copies the venv + `/build/` from `server-builder` and runs `entrypoint.py`. `--target host` builds the host image instead (from `builder`: omnigent + git/tmux, no SPA/psycopg/entrypoint). |
|
||||
| `Dockerfile.dockerignore` | BuildKit-aware exclude. Trims `deploy/databricks/`, `deploy/aws/`, tests, dev tooling — keeps the build context small. |
|
||||
| `entrypoint.py` | Server process entrypoint. Reads `DATABASE_URL`, runs Alembic migrations, builds the SQLAlchemy stores, calls `create_app()`, runs uvicorn. Single source of truth for what env vars the container respects. |
|
||||
| `docker-compose.yaml` | Two services: `postgres` (16-alpine, persistent volume) and `omnigent` (built from the Dockerfile, depends on postgres healthcheck). Build context is `../..` (repo root). |
|
||||
| `.env.example` | Documents every env var the compose file passes through: `POSTGRES_PASSWORD`, `OMNIGENT_PORT`, all the `OMNIGENT_AUTH_*` and `OMNIGENT_OIDC_*` vars. |
|
||||
| `README.md` | Customer-facing quickstart + the OIDC walkthrough (GitHub OAuth, Google Workspace, generic OIDC). |
|
||||
|
||||
## Iterating on the image
|
||||
|
||||
```bash
|
||||
# Force a clean rebuild after a Dockerfile or source change
|
||||
docker compose build --no-cache omnigent
|
||||
|
||||
# Reset everything (drops the DB + artifact volumes)
|
||||
docker compose down -v
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
`POSTGRES_PASSWORD` is only honored on first init of the data volume.
|
||||
If you change it in `.env`, you need `docker compose down -v` before
|
||||
`up -d` or the server will fail to authenticate against the existing
|
||||
cluster.
|
||||
|
||||
## Common debugging
|
||||
|
||||
| Symptom | Likely cause | First check |
|
||||
|---|---|---|
|
||||
| Root URL returns `{"service":"omnigent",…}` instead of the SPA | npm build didn't produce the bundle inside the container | `docker compose exec omnigent ls /build/omnigent/server/static/web-ui/` — empty = the `web-builder` stage didn't run cleanly. Rebuild with `--no-cache`. |
|
||||
| `ModuleNotFoundError: No module named 'uvicorn'` at startup | venv copy didn't pick up the install | Sanity-check the Dockerfile's `VIRTUAL_ENV=/opt/venv` is set before the `uv pip install` calls. |
|
||||
| `psycopg.OperationalError: password authentication failed` | `POSTGRES_PASSWORD` changed in `.env` after the data volume was initialized | `docker compose down -v` then `up -d` (wipes the DB). |
|
||||
| Web UI loads but new chats hang forever | Expected — runners are external. The UI's landing page prints the CLI command to launch a runner. |
|
||||
|
||||
## Extending to a new platform
|
||||
|
||||
Cloud Run, Fly.io, Render, k8s, HF Spaces — they all consume the
|
||||
same image. The platform-specific bit is the manifest (`fly.toml`,
|
||||
`service.yaml`, Helm chart, Spaces config) and any platform-managed
|
||||
TLS / DB wiring. Put that under `deploy/<platform>/` next to
|
||||
`docker/`, with its own README + SKILL.
|
||||
|
||||
## Related skills + docs
|
||||
|
||||
- [`deploy/README.md`](../README.md) — the deploy-options menu.
|
||||
- `designs/OIDC_AUTH.md` — full native OIDC design.
|
||||
Executable
+101
@@ -0,0 +1,101 @@
|
||||
#!/usr/bin/env bash
|
||||
# First-run helper: ensures deploy/docker/.env exists with the two
|
||||
# required secrets (POSTGRES_PASSWORD, OMNIGENT_OIDC_COOKIE_SECRET)
|
||||
# generated for you, instead of making the user run `openssl rand -hex 32`
|
||||
# twice. Safe to re-run — never overwrites existing non-default values.
|
||||
#
|
||||
# Usage:
|
||||
# cd deploy/docker
|
||||
# ./bootstrap.sh
|
||||
# docker compose up -d
|
||||
#
|
||||
# Idempotency:
|
||||
# - If .env doesn't exist, copies .env.example → .env first.
|
||||
# - If POSTGRES_PASSWORD is unset, empty, or still the example
|
||||
# placeholder ("change-me-please"), mints a fresh random value.
|
||||
# - If OMNIGENT_OIDC_COOKIE_SECRET is unset OR commented out,
|
||||
# uncomments + sets it to a fresh 64-hex-char value. (Even if
|
||||
# you're not using OIDC today, having the secret ready means
|
||||
# enabling it later is a one-line edit.)
|
||||
# - Already-customized values are left alone.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
if [[ ! -f .env ]]; then
|
||||
cp .env.example .env
|
||||
echo "→ created .env from .env.example"
|
||||
fi
|
||||
|
||||
# openssl is the dependency we already document for cookie-secret
|
||||
# generation; bail loud if missing rather than papering over with a
|
||||
# weaker source.
|
||||
if ! command -v openssl >/dev/null 2>&1; then
|
||||
echo "ERROR: openssl not found on PATH (needed to generate secrets)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# In-place edit helper that works on both GNU sed and BSD/macOS sed.
|
||||
sed_inplace() {
|
||||
if sed --version >/dev/null 2>&1; then
|
||||
sed -i "$@"
|
||||
else
|
||||
sed -i '' "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
set_or_replace_kv() {
|
||||
local key="$1" value="$2"
|
||||
if grep -qE "^${key}=" .env; then
|
||||
sed_inplace "s|^${key}=.*|${key}=${value}|" .env
|
||||
elif grep -qE "^# *${key}=" .env; then
|
||||
sed_inplace "s|^# *${key}=.*|${key}=${value}|" .env
|
||||
else
|
||||
printf '\n%s=%s\n' "$key" "$value" >> .env
|
||||
fi
|
||||
}
|
||||
|
||||
current_value() {
|
||||
local key="$1"
|
||||
grep -E "^${key}=" .env | head -n 1 | cut -d= -f2- || true
|
||||
}
|
||||
|
||||
pg_current=$(current_value POSTGRES_PASSWORD)
|
||||
if [[ -z "$pg_current" || "$pg_current" == "change-me-please" ]]; then
|
||||
set_or_replace_kv POSTGRES_PASSWORD "$(openssl rand -hex 16)"
|
||||
echo "→ generated POSTGRES_PASSWORD"
|
||||
else
|
||||
echo "→ POSTGRES_PASSWORD already set, leaving alone"
|
||||
fi
|
||||
|
||||
cookie_current=$(current_value OMNIGENT_OIDC_COOKIE_SECRET)
|
||||
if [[ -z "$cookie_current" || "$cookie_current" == "<64-hex-chars>" ]]; then
|
||||
set_or_replace_kv OMNIGENT_OIDC_COOKIE_SECRET "$(openssl rand -hex 32)"
|
||||
echo "→ generated OMNIGENT_OIDC_COOKIE_SECRET"
|
||||
else
|
||||
echo "→ OMNIGENT_OIDC_COOKIE_SECRET already set, leaving alone"
|
||||
fi
|
||||
|
||||
# Same generation logic for the accounts cookie secret. The two
|
||||
# secrets are independent — OIDC and accounts modes are mutually
|
||||
# exclusive in a single deploy, but having both pre-minted means
|
||||
# the operator can switch modes by editing OMNIGENT_AUTH_PROVIDER
|
||||
# without re-running bootstrap.
|
||||
accounts_cookie_current=$(current_value OMNIGENT_ACCOUNTS_COOKIE_SECRET)
|
||||
if [[ -z "$accounts_cookie_current" || "$accounts_cookie_current" == "<64-hex-chars>" ]]; then
|
||||
set_or_replace_kv OMNIGENT_ACCOUNTS_COOKIE_SECRET "$(openssl rand -hex 32)"
|
||||
echo "→ generated OMNIGENT_ACCOUNTS_COOKIE_SECRET"
|
||||
else
|
||||
echo "→ OMNIGENT_ACCOUNTS_COOKIE_SECRET already set, leaving alone"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "✓ deploy/docker/.env is ready. Next:"
|
||||
echo " docker compose up -d && docker compose logs omnigent"
|
||||
echo
|
||||
echo " Accounts mode is the default — the first-boot admin password"
|
||||
echo " lands in the logs and in /data/admin-credentials on the"
|
||||
echo " persistent volume. For any public-domain deploy also set:"
|
||||
echo " OMNIGENT_ACCOUNTS_BASE_URL=<your public URL>"
|
||||
echo " in .env so invite links resolve to the right host."
|
||||
@@ -0,0 +1,49 @@
|
||||
# Omnigent server config — non-secret settings in one file.
|
||||
#
|
||||
# Copy to the persistent volume as `/data/config.yaml` (the default the
|
||||
# server looks for), or point OMNIGENT_CONFIG at an explicit path. The
|
||||
# server (Docker entrypoint, Databricks Apps, and `omnigent server -c`)
|
||||
# reads the same keys — so a hosted deploy keeps its settings here just
|
||||
# like a laptop does, instead of a pile of env vars.
|
||||
#
|
||||
# cp config.yaml.example /path/to/your/volume/config.yaml
|
||||
# # then edit; takes effect on the next server start
|
||||
#
|
||||
# ── Secrets do NOT go here ──────────────────────────────────────────
|
||||
# DATABASE_URL, the session cookie secret, and the OIDC client secret
|
||||
# stay in the environment (.env / bootstrap.sh / your platform). This
|
||||
# file is operator-editable and often world-readable on the box — keep
|
||||
# credentials out of it.
|
||||
|
||||
# Admin roster (canonical). Identities promoted to admin on login —
|
||||
# email in OIDC mode, username in accounts mode. Union'd with the
|
||||
# runtime-editable `<data_dir>/admins` file (edit that for a no-restart
|
||||
# change). Additive: removing an entry here does not demote on its own.
|
||||
admins:
|
||||
- alice@example.com
|
||||
- bob@example.com
|
||||
|
||||
# OIDC access control: only these email domains may sign in (union'd
|
||||
# with OMNIGENT_OIDC_ALLOWED_DOMAINS and the `<data_dir>/allowed_domains`
|
||||
# file). Admin-listed and invited identities bypass this. Omit / empty
|
||||
# to allow any authenticated IdP user (the OSS default).
|
||||
allowed_domains:
|
||||
- example.com
|
||||
|
||||
# Artifact store location (defaults to /data/artifacts in the Docker
|
||||
# stack). database_uri may also live here, but for Docker the compose
|
||||
# provides DATABASE_URL via env (it carries the password) — leave it
|
||||
# unset here unless you self-manage the connection string.
|
||||
# artifact_location: /data/artifacts
|
||||
# database_uri: postgresql+psycopg://user:pw@host:5432/omnigent
|
||||
|
||||
# Extra Python modules scanned for POLICY_REGISTRY lists at startup.
|
||||
# policy_modules:
|
||||
# - myorg.policies.safety
|
||||
|
||||
# Copy-at-spawn limits. When a parent agent forwards files to a subagent,
|
||||
# the server copies them through the destination session. These bound a
|
||||
# single copy request so it can't spike shared-server memory; omit to use
|
||||
# the built-in defaults (20 files / 256 MiB total).
|
||||
# copy_max_files: 20
|
||||
# copy_max_total_bytes: 268435456
|
||||
@@ -0,0 +1,45 @@
|
||||
# Optional Caddy overlay — adds auto-HTTPS (Let's Encrypt) in front
|
||||
# of the omnigent container. Use this when you're deploying on a
|
||||
# VPS/EC2/home server with a public domain pointed at it and you
|
||||
# don't already have a reverse proxy.
|
||||
#
|
||||
# Usage:
|
||||
#
|
||||
# # 1. Edit .env and set:
|
||||
# # OMNIGENT_DOMAIN=omnigent.example.com
|
||||
# # (No ACME email needed — Let's Encrypt registers anonymously.
|
||||
# # To get expiry notices, add a global `email` block to Caddyfile.)
|
||||
# #
|
||||
# # 2. Point DNS A/AAAA records for the domain at this host.
|
||||
# #
|
||||
# # 3. Bring it up with BOTH compose files:
|
||||
# docker compose -f docker-compose.yaml -f docker-compose.https.yaml up -d
|
||||
#
|
||||
# Caddy auto-provisions and renews the TLS cert via Let's Encrypt
|
||||
# (HTTP-01 challenge on :80). The omnigent container stops being
|
||||
# directly exposed — only :80 and :443 from Caddy are published.
|
||||
|
||||
services:
|
||||
omnigent:
|
||||
# Drop the direct 8000 publish; Caddy proxies in over the docker network.
|
||||
ports: !reset []
|
||||
|
||||
caddy:
|
||||
image: caddy:2-alpine
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- omnigent
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
- "443:443/udp"
|
||||
environment:
|
||||
OMNIGENT_DOMAIN: ${OMNIGENT_DOMAIN:?set OMNIGENT_DOMAIN in .env}
|
||||
volumes:
|
||||
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
||||
- caddy-data:/data
|
||||
- caddy-config:/config
|
||||
|
||||
volumes:
|
||||
caddy-data:
|
||||
caddy-config:
|
||||
@@ -0,0 +1,144 @@
|
||||
# Omnigent server + Postgres (external-runner mode).
|
||||
#
|
||||
# Quickstart (single-user dev):
|
||||
#
|
||||
# cd deploy/docker
|
||||
# cp .env.example .env # edit POSTGRES_PASSWORD at minimum
|
||||
# docker compose up -d
|
||||
# open http://localhost:8000 # web UI; start a local runner per the prompt
|
||||
#
|
||||
# Auth modes (OMNIGENT_AUTH_PROVIDER):
|
||||
# - accounts (DEFAULT) — built-in accounts, no IdP needed. First
|
||||
# boot prints the admin password to `docker compose logs` and
|
||||
# saves it to /data/admin-credentials. Set
|
||||
# OMNIGENT_ACCOUNTS_BASE_URL for any deploy reachable behind
|
||||
# a public domain (defaults to http://<HOST>:<PORT> otherwise).
|
||||
# - oidc — bring your own IdP. Set OMNIGENT_OIDC_* vars — see
|
||||
# .env.example for full walkthroughs.
|
||||
# - header — for deploys behind a proxy that injects
|
||||
# X-Forwarded-Email (Databricks Apps, oauth2-proxy, etc).
|
||||
|
||||
name: omnigent
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: ${POSTGRES_DB:-omnigent}
|
||||
POSTGRES_USER: ${POSTGRES_USER:-omnigent}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-omnigent} -d ${POSTGRES_DB:-omnigent}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
omnigent:
|
||||
# Pre-built image published to GHCR on every main-branch merge.
|
||||
# `docker compose pull` fetches the latest; pin OMNIGENT_IMAGE_TAG
|
||||
# to a sha-<short> or vX.Y.Z tag for reproducible deploys.
|
||||
image: ${OMNIGENT_IMAGE:-ghcr.io/omnigent-ai/omnigent-server}:${OMNIGENT_IMAGE_TAG:-latest}
|
||||
# Local-build fallback for forks / offline / dev iterations. Used
|
||||
# only when the image isn't already pulled AND you run
|
||||
# `docker compose up --build` explicitly. The published image in
|
||||
# CI is built from this same Dockerfile.
|
||||
build:
|
||||
# Repo root, reached from deploy/docker/ → ../..
|
||||
context: ../..
|
||||
dockerfile: deploy/docker/Dockerfile
|
||||
args:
|
||||
PYPI_INDEX_URL: ${PYPI_INDEX_URL:-https://pypi.org/simple}
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
DATABASE_URL: postgresql+psycopg://${POSTGRES_USER:-omnigent}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-omnigent}
|
||||
ARTIFACT_DIR: /data/artifacts
|
||||
HOST: 0.0.0.0
|
||||
PORT: "8000"
|
||||
# Pin the admin-credentials path to the persistent volume so
|
||||
# the file survives container restarts. Empty/unset would
|
||||
# write to /root/.omnigent/ inside the ephemeral container.
|
||||
OMNIGENT_ADMIN_CREDENTIALS_PATH: /data/admin-credentials
|
||||
|
||||
# ── Auth ─────────────────────────────────────────
|
||||
# OMNIGENT_AUTH_ENABLED is the single auth switch. "1" (the
|
||||
# default here) turns on multi-user auth; with the OMNIGENT_OIDC_*
|
||||
# vars below UNSET it selects the built-in accounts flow (username +
|
||||
# password + first-user-is-admin bootstrap), and with them SET it
|
||||
# selects the native OIDC login flow instead. Set it to "0" to opt
|
||||
# out entirely and run single-user local mode, which treats every
|
||||
# request as the single "local" user with no login — handy for local
|
||||
# dev (many web clients + servers on one machine), NEVER for shared
|
||||
# deploys: everyone who can reach the port is the same user.
|
||||
OMNIGENT_AUTH_ENABLED: "${OMNIGENT_AUTH_ENABLED:-1}"
|
||||
# Escape hatch: pin the identity source explicitly. Overrides the
|
||||
# AUTH_ENABLED-based resolution above. Leave unset unless you need
|
||||
# to force a specific mode:
|
||||
# "accounts" — built-in username + password bootstrap
|
||||
# "oidc" — native login flow, OIDC cookie auth
|
||||
# "header" — read X-Forwarded-Email from a trusted proxy;
|
||||
# requests missing the header are rejected (401)
|
||||
OMNIGENT_AUTH_PROVIDER: "${OMNIGENT_AUTH_PROVIDER:-}"
|
||||
|
||||
# OIDC config — consumed when OIDC mode is active (AUTH_ENABLED=1
|
||||
# with OMNIGENT_OIDC_ISSUER set, or AUTH_PROVIDER=oidc). Required
|
||||
# in that mode; the server fails loud at startup if any are
|
||||
# missing. See .env.example for descriptions + provider examples.
|
||||
OMNIGENT_OIDC_ISSUER: "${OMNIGENT_OIDC_ISSUER:-}"
|
||||
OMNIGENT_OIDC_CLIENT_ID: "${OMNIGENT_OIDC_CLIENT_ID:-}"
|
||||
OMNIGENT_OIDC_CLIENT_SECRET: "${OMNIGENT_OIDC_CLIENT_SECRET:-}"
|
||||
OMNIGENT_OIDC_COOKIE_SECRET: "${OMNIGENT_OIDC_COOKIE_SECRET:-}"
|
||||
OMNIGENT_OIDC_SCOPES: "${OMNIGENT_OIDC_SCOPES:-}"
|
||||
OMNIGENT_OIDC_SESSION_TTL_HOURS: "${OMNIGENT_OIDC_SESSION_TTL_HOURS:-8}"
|
||||
OMNIGENT_OIDC_ALLOWED_DOMAINS: "${OMNIGENT_OIDC_ALLOWED_DOMAINS:-}"
|
||||
OMNIGENT_OIDC_LOGOUT_REDIRECT_URI: "${OMNIGENT_OIDC_LOGOUT_REDIRECT_URI:-}"
|
||||
# Skip the email_verified id_token check — for IdPs (e.g. Okta
|
||||
# without API Access Management) that omit the claim for
|
||||
# directory-provisioned users. Off unless set; see .env.example.
|
||||
OMNIGENT_OIDC_SKIP_EMAIL_VERIFICATION: "${OMNIGENT_OIDC_SKIP_EMAIL_VERIFICATION:-}"
|
||||
# Opt-in OIDC invites (admin pre-authorizes one off-domain email).
|
||||
# Off unless set. The admin list (/data/admins) and the optional
|
||||
# allowed-domains file (/data/allowed_domains) need no env var —
|
||||
# they default to the data dir set by OMNIGENT_ADMIN_CREDENTIALS_PATH.
|
||||
OMNIGENT_OIDC_ALLOW_INVITES: "${OMNIGENT_OIDC_ALLOW_INVITES:-}"
|
||||
# Public domain — the single source for the OIDC redirect URI: the
|
||||
# server derives it as https://<domain>/auth/callback (set the
|
||||
# matching callback in your IdP app). Also consumed by the Caddy
|
||||
# HTTPS overlay. There is intentionally NO OMNIGENT_OIDC_REDIRECT_URI
|
||||
# passthrough here — one knob, no http/https mismatch. (A raw-IP /
|
||||
# no-domain deploy that needs an explicit redirect must add an
|
||||
# `OMNIGENT_OIDC_REDIRECT_URI: "${OMNIGENT_OIDC_REDIRECT_URI:-}"`
|
||||
# line back to this block — the domain stack is the supported path.)
|
||||
OMNIGENT_DOMAIN: "${OMNIGENT_DOMAIN:-}"
|
||||
|
||||
# Accounts config — consumed in accounts mode (AUTH_ENABLED=1 with
|
||||
# no OIDC issuer, the default here; or AUTH_PROVIDER=accounts).
|
||||
# COOKIE_SECRET is minted by `./bootstrap.sh` on first
|
||||
# run if you don't pass one. BASE_URL defaults to
|
||||
# http://<HOST>:<PORT> from the request — set it explicitly for
|
||||
# any deploy reachable through a public domain. See .env.example.
|
||||
OMNIGENT_ACCOUNTS_COOKIE_SECRET: "${OMNIGENT_ACCOUNTS_COOKIE_SECRET:-}"
|
||||
OMNIGENT_ACCOUNTS_BASE_URL: "${OMNIGENT_ACCOUNTS_BASE_URL:-}"
|
||||
OMNIGENT_ACCOUNTS_INIT_ADMIN_PASSWORD: "${OMNIGENT_ACCOUNTS_INIT_ADMIN_PASSWORD:-}"
|
||||
OMNIGENT_ACCOUNTS_SESSION_TTL_HOURS: "${OMNIGENT_ACCOUNTS_SESSION_TTL_HOURS:-8}"
|
||||
OMNIGENT_ACCOUNTS_INVITE_TTL_HOURS: "${OMNIGENT_ACCOUNTS_INVITE_TTL_HOURS:-72}"
|
||||
OMNIGENT_ACCOUNTS_MAGIC_TTL_MINUTES: "${OMNIGENT_ACCOUNTS_MAGIC_TTL_MINUTES:-10}"
|
||||
# Browser auto-open is a no-op inside the container (no
|
||||
# display); the value also gates whether the stderr "open
|
||||
# this URL" announcement fires. Off for Docker by default
|
||||
# so the operator's logs aren't cluttered with a URL they
|
||||
# can't click.
|
||||
OMNIGENT_ACCOUNTS_AUTO_OPEN: "${OMNIGENT_ACCOUNTS_AUTO_OPEN:-0}"
|
||||
volumes:
|
||||
- artifact-data:/data
|
||||
ports:
|
||||
- "${OMNIGENT_PORT:-8000}:8000"
|
||||
|
||||
volumes:
|
||||
postgres-data:
|
||||
artifact-data:
|
||||
@@ -0,0 +1,402 @@
|
||||
"""OSS Docker entrypoint for the Omnigent server.
|
||||
|
||||
Mirrors ``deploy/databricks/src/app.py`` (the Databricks Apps entrypoint) but
|
||||
configured for a plain Postgres database and a local-filesystem
|
||||
artifact store. Intended to run inside the image built by
|
||||
``deploy/docker/Dockerfile``.
|
||||
|
||||
Execution mode: external runners only. The server accepts runner
|
||||
WebSocket connections at ``/v1/runner/tunnel`` and never spawns
|
||||
harness subprocesses on its own. Users run ``omnigent run … --server
|
||||
<url>`` on their own machine; that runner dials in.
|
||||
|
||||
Importing this module has **no side effects**: configuration loading,
|
||||
DB migrations, store construction, and app building all live inside
|
||||
``build_app()`` / ``run_migrations()`` / ``main()``. Nothing connects
|
||||
to a database, reads config, or builds the app until ``main()`` runs —
|
||||
which the ``if __name__ == "__main__":`` block (i.e. the Docker
|
||||
``CMD ["python", "/app/entrypoint.py"]``) invokes. This keeps the
|
||||
module importable for testing / tooling without a live database.
|
||||
|
||||
Configuration is via environment variables:
|
||||
|
||||
DATABASE_URL Required. SQLAlchemy URL. Both PaaS-style URLs
|
||||
(``postgresql://user:pw@host:5432/db``,
|
||||
``postgres://...``) and the explicit psycopg3
|
||||
form (``postgresql+psycopg://...``) are accepted;
|
||||
the prefix is normalized automatically.
|
||||
ARTIFACT_DIR Directory for the local artifact store.
|
||||
Defaults to ``/data/artifacts`` (the volume
|
||||
mount point used by docker-compose).
|
||||
HOST, PORT Bind address. Default ``0.0.0.0:8000``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import traceback
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from fastapi import FastAPI
|
||||
|
||||
from omnigent.stores.artifact_store import ArtifactStore
|
||||
|
||||
logging.basicConfig(level=logging.INFO, stream=sys.stderr, force=True)
|
||||
logger = logging.getLogger("omnigent-docker")
|
||||
|
||||
# Defaults live as module-level constants — the Dockerfile and
|
||||
# docker-compose.yaml both also set these, so the values here just
|
||||
# document the contract for anyone running entrypoint.py outside the
|
||||
# image. Source: deploy/docker/Dockerfile (ENV block) and
|
||||
# deploy/docker/docker-compose.yaml.
|
||||
_DEFAULT_HOST = "0.0.0.0"
|
||||
# Pinned to 8000 by design (container/platform convention) — deliberately
|
||||
# decoupled from the CLI's local-server default (6767 in host/local_server.py).
|
||||
_DEFAULT_PORT = "8000"
|
||||
_DEFAULT_ARTIFACT_DIR = "/data/artifacts"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _ResolvedConfig:
|
||||
"""Configuration resolved before migrations and app construction."""
|
||||
|
||||
cfg: dict[str, Any]
|
||||
database_url: str
|
||||
artifact_dir: Path
|
||||
# When set (an ``s3://bucket[/prefix]`` URI), the artifact store is remote
|
||||
# (S3/R2) and ``artifact_dir`` is only a local scratch dir (cookie secret,
|
||||
# on-disk cache). ``None`` -> the local filesystem store at ``artifact_dir``.
|
||||
artifact_store_uri: str | None
|
||||
host: str
|
||||
port: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _BuiltApp:
|
||||
"""The FastAPI app plus resolved bind settings.
|
||||
|
||||
``_resolve_config`` handles config loading, database URL normalization,
|
||||
artifact directory setup, auth defaults, and HOST/PORT resolution before
|
||||
migrations run. ``main()`` then runs migrations explicitly and calls
|
||||
``build_app`` to construct the app from that resolved config. Returning
|
||||
HOST/PORT with the app keeps the handoff to ``uvicorn.run`` explicit
|
||||
without requiring a second config-resolution pass.
|
||||
"""
|
||||
|
||||
app: FastAPI
|
||||
host: str
|
||||
port: int
|
||||
|
||||
|
||||
def run_migrations(database_url: str) -> None:
|
||||
"""Run the Alembic upgrade against ``database_url``.
|
||||
|
||||
The SQLAlchemy stores refuse to start on a stale schema, so this
|
||||
runs before any store boots. Creates a throwaway engine, upgrades,
|
||||
and disposes it.
|
||||
"""
|
||||
import sqlalchemy
|
||||
|
||||
from omnigent.db.utils import _run_migrations as _run_alembic_upgrade
|
||||
|
||||
migration_engine = sqlalchemy.create_engine(database_url)
|
||||
try:
|
||||
_run_alembic_upgrade(migration_engine, database_url)
|
||||
finally:
|
||||
migration_engine.dispose()
|
||||
|
||||
|
||||
def _resolve_config() -> _ResolvedConfig:
|
||||
"""Load config and resolve startup settings before migrations run."""
|
||||
|
||||
from omnigent.db.utils import normalize_database_url
|
||||
from omnigent.server.paas_env import detect_base_url, resolve_bind_host
|
||||
from omnigent.server.server_config import load_server_config
|
||||
|
||||
# ── Configuration ────────────────────────────────────────
|
||||
# Non-secret settings come from a YAML config file (default
|
||||
# <data_dir>/config.yaml, e.g. /data/config.yaml on the volume, or
|
||||
# OMNIGENT_CONFIG) — the same experience a laptop gets from
|
||||
# `omnigent server -c`. Secrets stay in the environment:
|
||||
# DATABASE_URL (carries the password) and the cookie / OIDC secrets.
|
||||
cfg = load_server_config()
|
||||
|
||||
# DATABASE_URL is env-first (compose/PaaS inject it; it's a secret),
|
||||
# with `database_uri:` in the config as a fallback for self-managed DBs.
|
||||
database_url = os.environ.get("DATABASE_URL") or cfg.get("database_uri")
|
||||
if not database_url:
|
||||
raise RuntimeError(
|
||||
"DATABASE_URL is required (env), or set `database_uri:` in the server config. "
|
||||
"Accepted forms: "
|
||||
"'postgresql+psycopg://user:pw@host:5432/omnigent' (explicit psycopg3), "
|
||||
"or the 'postgres://' / 'postgresql://' URLs emitted by Railway, Render, etc."
|
||||
)
|
||||
# Normalize PaaS-style URLs (postgres:// or postgresql://) to the
|
||||
# psycopg3 dialect specifier that SQLAlchemy requires.
|
||||
database_url = normalize_database_url(database_url)
|
||||
|
||||
# App settings are config-first, env fallback, then the built-in default.
|
||||
artifact_dir = Path(
|
||||
cfg.get("artifact_location") or os.environ.get("ARTIFACT_DIR") or _DEFAULT_ARTIFACT_DIR
|
||||
)
|
||||
# resolve_bind_host strips the bracketed IPv6 form some platforms inject
|
||||
# ("[::]") and coerces Railway's IPv6 wildcard to IPv4 (its edge is v4-only).
|
||||
host = resolve_bind_host(
|
||||
cfg.get("host") or os.environ.get("HOST"), os.environ, default=_DEFAULT_HOST
|
||||
)
|
||||
port = int(cfg.get("port") or os.environ.get("PORT") or _DEFAULT_PORT)
|
||||
artifact_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Optional remote artifact store (S3 / Cloudflare R2 / MinIO / …). When set,
|
||||
# the artifact STORE is remote and durable; artifact_dir stays local for the
|
||||
# cookie secret and on-disk cache. Mirrors how DATABASE_URL selects the DB.
|
||||
artifact_store_uri = cfg.get("artifact_store_uri") or os.environ.get("OMNIGENT_ARTIFACT_URI")
|
||||
if artifact_store_uri and not artifact_store_uri.startswith("s3://"):
|
||||
raise RuntimeError(
|
||||
"OMNIGENT_ARTIFACT_URI (or `artifact_store_uri:` in config) must be an "
|
||||
f"'s3://bucket[/prefix]' URI, got: {artifact_store_uri!r}"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Config: HOST=%s PORT=%d DB=%s ARTIFACTS=%s",
|
||||
host,
|
||||
port,
|
||||
database_url.split("@", 1)[-1] if "@" in database_url else database_url,
|
||||
artifact_store_uri or artifact_dir,
|
||||
)
|
||||
|
||||
# Containerized / remote deploys default to authenticated auth.
|
||||
# The framework-wide default (a bare local `omnigent server`) is
|
||||
# single-user header mode with no login, but a Docker / HF / PaaS
|
||||
# instance is typically network-exposed, so we opt it into the
|
||||
# multi-user login flow here — accounts by default, or OIDC if the
|
||||
# operator supplied OMNIGENT_OIDC_* config. An operator can still
|
||||
# force header/oidc/accounts via OMNIGENT_AUTH_PROVIDER, or turn
|
||||
# auth off with OMNIGENT_AUTH_ENABLED=0.
|
||||
os.environ.setdefault("OMNIGENT_AUTH_ENABLED", "1")
|
||||
|
||||
# Kill-switch ergonomics: OMNIGENT_AUTH_ENABLED=0 means "no login,
|
||||
# single-user local container" (the documented local-dev posture).
|
||||
# Header mode now fails closed on a missing X-Forwarded-Email,
|
||||
# so without this marker a no-auth container would
|
||||
# 401 every request — nothing injects the header. Only the implicit
|
||||
# kill-switch path gets the marker: an EXPLICIT
|
||||
# OMNIGENT_AUTH_PROVIDER=header deploy declared a header-injecting
|
||||
# proxy and must stay strict.
|
||||
from omnigent.server.auth import env_var_is_truthy
|
||||
|
||||
# Compose passes OMNIGENT_AUTH_PROVIDER as "" when unset
|
||||
# ("${VAR:-}"): empty and missing both mean "not explicitly pinned".
|
||||
_raw_auth_provider = os.environ.get("OMNIGENT_AUTH_PROVIDER")
|
||||
_auth_provider_explicit = bool(_raw_auth_provider and _raw_auth_provider.strip())
|
||||
if not _auth_provider_explicit and not env_var_is_truthy("OMNIGENT_AUTH_ENABLED"):
|
||||
os.environ.setdefault("OMNIGENT_LOCAL_SINGLE_USER", "1")
|
||||
|
||||
# Accounts mode ergonomics: when the operator hasn't set them, supply the
|
||||
# two required vars (cookie secret + base URL) so a 1-click / `docker
|
||||
# compose up` deploy works with zero config. Gate on the *resolved*
|
||||
# selection so an explicit header/oidc deploy (or AUTH_ENABLED=0)
|
||||
# doesn't mint accounts secrets it never reads.
|
||||
from omnigent.server.auth import resolve_auth_source
|
||||
|
||||
if resolve_auth_source() == "accounts":
|
||||
from omnigent.server.accounts_secret import load_or_generate_cookie_secret
|
||||
|
||||
# Empty-check, not setdefault: compose passes these as empty strings
|
||||
# ("${VAR:-}"), which setdefault would leave in place — defeating the default.
|
||||
if not os.environ.get("OMNIGENT_ACCOUNTS_COOKIE_SECRET"):
|
||||
os.environ["OMNIGENT_ACCOUNTS_COOKIE_SECRET"] = load_or_generate_cookie_secret(
|
||||
artifact_dir
|
||||
)
|
||||
if not os.environ.get("OMNIGENT_ACCOUNTS_BASE_URL"):
|
||||
# Auto-detect the public URL from the PaaS env (Render / Railway /
|
||||
# Fly / HF Spaces) so a 1-click deploy needs zero manual config;
|
||||
# falls back to the bind address for local / Docker / EC2.
|
||||
os.environ["OMNIGENT_ACCOUNTS_BASE_URL"] = detect_base_url(
|
||||
os.environ, host=host, port=port
|
||||
)
|
||||
|
||||
return _ResolvedConfig(
|
||||
cfg=cfg,
|
||||
database_url=database_url,
|
||||
artifact_dir=artifact_dir,
|
||||
artifact_store_uri=artifact_store_uri,
|
||||
host=host,
|
||||
port=port,
|
||||
)
|
||||
|
||||
|
||||
def _select_artifact_store(resolved_config: _ResolvedConfig) -> ArtifactStore:
|
||||
"""
|
||||
Pick the artifact store implementation from the resolved config.
|
||||
|
||||
An ``s3://bucket[/prefix]`` ``artifact_store_uri`` selects the remote,
|
||||
durable :class:`~omnigent.stores.artifact_store.s3.S3ArtifactStore` (AWS S3,
|
||||
Cloudflare R2, MinIO, …), which survives an ephemeral or multi-replica
|
||||
deploy. Otherwise the local-filesystem store at ``artifact_dir`` is used.
|
||||
Mirrors how ``DATABASE_URL`` selects the database backend.
|
||||
|
||||
:param resolved_config: The resolved startup configuration.
|
||||
:returns: The selected
|
||||
:class:`~omnigent.stores.artifact_store.ArtifactStore`.
|
||||
"""
|
||||
from omnigent.stores.artifact_store.local import LocalArtifactStore
|
||||
|
||||
if resolved_config.artifact_store_uri:
|
||||
from omnigent.stores.artifact_store.s3 import S3ArtifactStore
|
||||
|
||||
return S3ArtifactStore(resolved_config.artifact_store_uri)
|
||||
return LocalArtifactStore(str(resolved_config.artifact_dir))
|
||||
|
||||
|
||||
def build_app(resolved_config: _ResolvedConfig | None = None) -> _BuiltApp:
|
||||
"""Resolve config if needed, wire the stores, and build the app.
|
||||
|
||||
This function intentionally does not run migrations; ``main()`` runs
|
||||
them explicitly after config resolution and before store construction.
|
||||
"""
|
||||
from omnigent.server.app import create_app
|
||||
from omnigent.server.server_config import config_str_list
|
||||
|
||||
if resolved_config is None:
|
||||
resolved_config = _resolve_config()
|
||||
|
||||
cfg = resolved_config.cfg
|
||||
database_url = resolved_config.database_url
|
||||
artifact_dir = resolved_config.artifact_dir
|
||||
|
||||
# ── Stores ───────────────────────────────────────────────
|
||||
|
||||
from omnigent.runtime import init as init_runtime
|
||||
from omnigent.runtime import telemetry
|
||||
from omnigent.runtime.agent_cache import AgentCache
|
||||
from omnigent.runtime.caps import RuntimeCaps
|
||||
from omnigent.server.managed_hosts import parse_sandbox_config
|
||||
from omnigent.stores.agent_store.sqlalchemy_store import SqlAlchemyAgentStore
|
||||
from omnigent.stores.comment_store.sqlalchemy_store import (
|
||||
SqlAlchemyCommentStore,
|
||||
)
|
||||
from omnigent.stores.conversation_store.sqlalchemy_store import (
|
||||
SqlAlchemyConversationStore,
|
||||
)
|
||||
from omnigent.stores.file_store.sqlalchemy_store import SqlAlchemyFileStore
|
||||
from omnigent.stores.host_store import HostStore
|
||||
from omnigent.stores.permission_store.sqlalchemy_store import (
|
||||
SqlAlchemyPermissionStore,
|
||||
)
|
||||
|
||||
telemetry.init()
|
||||
|
||||
agent_store = SqlAlchemyAgentStore(database_url)
|
||||
file_store = SqlAlchemyFileStore(database_url)
|
||||
conversation_store = SqlAlchemyConversationStore(database_url)
|
||||
comment_store = SqlAlchemyCommentStore(database_url)
|
||||
permission_store = SqlAlchemyPermissionStore(database_url)
|
||||
host_store = HostStore(database_url)
|
||||
# Fail startup loud on a malformed `sandbox:` section (an operator
|
||||
# typo should not surface as a runtime 502 on the first managed
|
||||
# session); the startup catch-all below logs it.
|
||||
sandbox_config = parse_sandbox_config(cfg.get("sandbox"))
|
||||
artifact_store = _select_artifact_store(resolved_config)
|
||||
|
||||
agent_cache = AgentCache(
|
||||
artifact_store=artifact_store,
|
||||
cache_dir=artifact_dir / ".cache",
|
||||
)
|
||||
|
||||
init_runtime(
|
||||
agent_cache=agent_cache,
|
||||
caps=RuntimeCaps(),
|
||||
agent_store=agent_store,
|
||||
file_store=file_store,
|
||||
conversation_store=conversation_store,
|
||||
artifact_store=artifact_store,
|
||||
comment_store=comment_store,
|
||||
)
|
||||
|
||||
# Build the auth provider from the live env (header/oidc/accounts).
|
||||
# Accounts mode also needs an AccountStore explicitly wired — the
|
||||
# entrypoint constructs it here rather than letting create_app do
|
||||
# so internally, so this same code path can opt out by passing
|
||||
# None for non-accounts deploys (matching the structural
|
||||
# contract used on the hosted product).
|
||||
from omnigent.server.auth import UnifiedAuthProvider as _UAP
|
||||
from omnigent.server.auth import create_auth_provider
|
||||
|
||||
auth_provider = create_auth_provider()
|
||||
account_store = None
|
||||
if isinstance(auth_provider, _UAP) and auth_provider._source == "accounts":
|
||||
from omnigent.server.accounts_store import SqlAlchemyAccountStore
|
||||
|
||||
account_store = SqlAlchemyAccountStore(database_url)
|
||||
|
||||
app = create_app(
|
||||
agent_store=agent_store,
|
||||
file_store=file_store,
|
||||
conversation_store=conversation_store,
|
||||
artifact_store=artifact_store,
|
||||
agent_cache=agent_cache,
|
||||
comment_store=comment_store,
|
||||
permission_store=permission_store,
|
||||
host_store=host_store,
|
||||
auth_provider=auth_provider,
|
||||
account_store=account_store,
|
||||
# Non-secret auth settings from the config file (admins are the
|
||||
# canonical, declarative roster; allowed_domains gates OIDC). Both
|
||||
# union with their runtime-editable files under <data_dir>.
|
||||
admins=config_str_list(cfg.get("admins")),
|
||||
allowed_domains=config_str_list(cfg.get("allowed_domains")),
|
||||
sandbox_config=sandbox_config,
|
||||
)
|
||||
|
||||
return _BuiltApp(app=app, host=resolved_config.host, port=resolved_config.port)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Boot the server: build the app and hand it to uvicorn.
|
||||
|
||||
Wraps the whole boot in the startup catch-all so any failure
|
||||
(config, migrations, store wiring) lands in the container logs and
|
||||
the process holds open briefly for log capture before exiting
|
||||
non-zero — the orchestrator then restarts us.
|
||||
"""
|
||||
try:
|
||||
resolved_config = _resolve_config()
|
||||
|
||||
# ── Migrations ───────────────────────────────────────────
|
||||
# Alembic upgrade runs before the stores boot — the SQLAlchemy
|
||||
# stores refuse to start on a stale schema.
|
||||
run_migrations(resolved_config.database_url)
|
||||
|
||||
resolved = build_app(resolved_config)
|
||||
|
||||
import uvicorn
|
||||
|
||||
from omnigent.runner.transports.ws_tunnel.limits import (
|
||||
RUNNER_TUNNEL_MAX_MESSAGE_BYTES,
|
||||
)
|
||||
|
||||
logger.info("Starting omnigent server on %s:%d", resolved.host, resolved.port)
|
||||
uvicorn.run(
|
||||
resolved.app,
|
||||
host=resolved.host,
|
||||
port=resolved.port,
|
||||
ws_max_size=RUNNER_TUNNEL_MAX_MESSAGE_BYTES,
|
||||
)
|
||||
except Exception: # noqa: BLE001 — startup catch-all so failures land in logs
|
||||
logger.error("FATAL: omnigent server failed to start:\n%s", traceback.format_exc())
|
||||
# Keep the process alive briefly so the container log capture has time
|
||||
# to flush before the orchestrator restarts us.
|
||||
import time # deferred — keeps module inert
|
||||
|
||||
time.sleep(30)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user