chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:12:00 +08:00
commit 3de48288cb
2986 changed files with 1131193 additions and 0 deletions
+197
View File
@@ -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
+23
View File
@@ -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
}
+390
View File
@@ -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"]
+65
View File
@@ -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/
+7
View File
@@ -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}
+176
View File
@@ -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"]
+299
View File
@@ -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
+87
View File
@@ -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.
+101
View File
@@ -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."
+49
View File
@@ -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
+45
View File
@@ -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:
+144
View File
@@ -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:
+402
View File
@@ -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()