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
+494
View File
@@ -0,0 +1,494 @@
# Deploying Omnigent
Omnigent ships several ways to deploy the server, organized by
target platform. Pick the one that matches your environment.
Deploying buys you a stable URL: sessions become reachable from any device,
including your phone (the web UI is built for mobile), and teammates can
join. The server is the coordination point; your code and model keys stay on
the machines that register as hosts (see [Execution model](#execution-model)).
## Deploy in one click
No local tooling needed. Pick a platform, click the button, and your
Omnigent server is live with HTTPS in a few minutes.
| Platform | Button | Docs |
|---|---|---|
| **Render** | [![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](https://render.com/deploy?repo=https://github.com/omnigent-ai/omnigent) | [`render/README.md`](render/README.md) |
| **Railway** | *(button pending; see below)* | [`railway/README.md`](railway/README.md) |
<!-- TODO(oss-release): publish the Railway template at railway.com/new/template
once the repo is public, then replace the Railway row above with:
[![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/deploy/<template-id>)
Steps: railway.com/new/template → point at public repo → add Postgres plugin
→ publish → copy the deploy URL → update this file and deploy/railway/README.md. -->
Both provision a managed Postgres database automatically and default to the
built-in `accounts` auth provider, so a fresh deploy is multi-user with no
external IdP. First boot auto-creates an admin (password in the service
logs); invite teammates from the web UI. Prefer your own IdP? Switch to OIDC
after deploy by setting the `OMNIGENT_OIDC_*` vars (auth stays enabled; the
issuer is what flips the mode); see the platform README for both
walkthroughs.
**Three more platforms** are supported with a little more setup (not a single
button): **Fly.io** (`fly deploy`, or its web-UI Launch), **Hugging Face
Spaces** (a demo-grade Docker Space), and **Modal** (`modal deploy`, an
always-on web server with a durable artifact Volume). See the menu below.
Fly and HF Spaces can run on the **SQLite lite tier** with no database to
provision (see [Database: Postgres or SQLite](#database-postgres-or-sqlite));
Modal needs a bring-your-own Postgres.
---
```
deploy/
├── README.md ← (this file) the menu
├── render/ ← Render 1-click deploy
│ └── README.md
├── railway/ ← Railway 1-click deploy
│ └── README.md
├── fly/ ← Fly.io (CLI `fly deploy`, or web-UI Launch)
│ ├── fly.toml
│ └── README.md
├── hf-spaces/ ← Hugging Face Spaces (demo-grade Docker Space)
│ ├── Dockerfile
│ └── README.md
├── modal/ ← Modal (`modal deploy`, always-on, durable Volume)
│ ├── modal_app.py
│ └── README.md
├── cloudflare/ ← Cloudflare Containers + D1 + R2 (serverless, scale-to-zero)
│ ├── Dockerfile server image + D1 dialect
│ ├── src/index.js the Worker that fronts the container
│ ├── wrangler.jsonc
│ └── README.md
├── trycloudflare/ ← Cloudflare quick tunnel (public URL for a LOCAL server)
│ └── README.md
├── tailscale/ ← Tailscale (private access from phone/tablet/laptop
│ └── README.md via tailnet; Funnel for cloud sandbox dial-back)
├── daytona/ ← Daytona sandbox-provider guide + the Cloudflare
│ ├── wrangler.toml Worker egress relay for its free tier; NOT a
│ ├── src/index.js server deploy target. See its README.md.
│ └── README.md
├── islo/ ← Islo sandbox-provider guide (gateway credential
│ └── README.md injection); NOT a server deploy target.
├── e2b/ ← E2B sandbox-provider guide (boots from a pre-built
│ └── README.md E2B template); NOT a server deploy target.
├── openshell/ ← NVIDIA OpenShell sandbox-provider guide (self-hosted
│ └── README.md gRPC gateway, on-prem/air-gapped); NOT a server target.
├── databricks/ ← Databricks Apps (Lakebase + UC Volumes)
│ ├── databricks.yml bundle declarative config
│ ├── deploy.py build + `bundle deploy`/`run` orchestrator
│ ├── src/app.py app entrypoint (Lakebase + UC Volumes)
│ └── README.md
└── docker/ ← common Docker image + compose stack
├── Dockerfile multi-stage slim image (node web build → python builder → runtime)
├── docker-compose.yaml omnigent + postgres for any Docker host
├── entrypoint.py
├── .env.example
├── README.md
└── SKILL.md
```
## Pick your target
| If you want to … | Use | Where to look |
|---|---|---|
| **Deploy from a browser (no local tools)** | **Render or Railway** | Buttons above: [Render](render/README.md) · [Railway](railway/README.md) |
| Try the server on your laptop | Docker compose | [`docker/README.md`](docker/README.md): `./bootstrap.sh` to mint the `.env` secrets, then `docker compose up -d` |
| Run on any host you already have (VPS, home server, on-prem) | Docker compose | [`docker/README.md`](docker/README.md): copy the compose stack, `./bootstrap.sh`, then `docker compose up -d` |
| Deploy to Fly.io | Fly | [`fly/README.md`](fly/README.md): `fly deploy`, SQLite on a volume |
| Deploy to Modal (durable artifact Volume) | Modal | [`modal/README.md`](modal/README.md): `modal deploy`, BYO Neon Postgres |
| Deploy serverless (scale-to-zero, no VM/Postgres to manage) | Cloudflare Containers + D1 + R2 | [`cloudflare/README.md`](cloudflare/README.md): `wrangler deploy` |
| Stand up a quick demo (no DB to provision) | HF Spaces | [`hf-spaces/README.md`](hf-spaces/README.md): Docker Space, SQLite |
| Share a server running on your **laptop**: demo it to teammates, or let remote runners & cloud sandboxes connect back to it (nothing to deploy) | Cloudflare quick tunnel | `cloudflared tunnel --url http://localhost:6767` |
| Access your server privately from **your phone, tablet, or other personal devices** without exposing it to the internet | Tailscale | [`tailscale/README.md`](tailscale/README.md): `tailscale serve https / http://localhost:8000` |
| Cloud Run / Kubernetes / other | Docker image | [`docker/README.md`](docker/README.md), then point your platform at the image |
| Deploy on a Databricks workspace (Lakebase + UC Volumes), self-managed | Databricks Apps | [`databricks/README.md`](databricks/README.md): uses Asset Bundles |
> **On Databricks?** The fully managed
> [Omnigent on Databricks](https://docs.databricks.com/aws/en/omnigent/)
> (Beta) is the recommended path: Databricks operates the server for
> you, wired to workspace identity, Foundation Models, AI Gateway, and
> MLflow Tracing. Enable the **Omnigent** preview in your workspace
> settings. The self-managed Databricks Apps bundle above is for when
> you need control the managed service does not expose yet.
All non-Databricks deploy paths share the same image (`docker/Dockerfile`): a
slim Python container running the FastAPI / WebSocket coordinator, with Postgres
or SQLite as the datastore. The Databricks Apps path uses a separate entrypoint
(`databricks/src/app.py`) that swaps Postgres for Lakebase (managed PostgreSQL)
and the artifact store for UC Volumes.
## Database: Postgres or SQLite
The server supports two database backends, both first-class (same schema, same
migrations; pick per `DATABASE_URL`):
- **Postgres**: the default and the production answer. Required for more than
one server instance. **Managed and auto-provisioned on deploy** on Render and
Railway. On platforms without a managed database (HF Spaces, Modal, or Fly
if you want Postgres over volume-SQLite), bring your own. The quickest is
**Neon**:
create one at [pg.new](https://pg.new) and set the connection string as
`DATABASE_URL`. Any `postgres://` / `postgresql://` URL works (pooled or
direct); the entrypoint normalizes it to the psycopg3 dialect automatically.
- **SQLite**: a zero-dependency "lite tier" for demos and single-instance
deploys, with no database to provision. The `.db` file lives on the
platform's persistent disk/volume (Render disk, Fly volume, Railway volume)
and survives restarts there; on Hugging Face free Spaces the disk is
ephemeral, so SQLite data resets on restart, and on Modal the Volume's
eventual-consistency semantics don't suit a live `.db` file, so skip the
SQLite tier there. Set
`DATABASE_URL=sqlite:////data/artifacts/chat.db`. Tradeoff: single instance
only, no managed backups.
**Who provisions the database.** Render and Railway create the Postgres *as part
of the deploy* (one step; it's owned by your platform account). Platforms
without a managed DB don't: there you either run on SQLite (zero setup,
ephemeral on HF) or bring an owned Postgres like Neon (a one-time signup, then
persistent). A deploy can't auto-provision a *persistent* database for you;
persistence requires an owned account, and that's the one step that can't be
automated away.
**First boot against a remote Postgres is slow.** Migrations run over the
network on the first boot (~1 minute on Neon, vs near-instant for local SQLite);
subsequent boots are fast. Make sure the platform's healthcheck grace tolerates
it: Render and Railway do by default; on Fly, raise `grace_period` if you use a
remote DB.
**Memory floor:** the server's working set is ~512 MB1 GB. Render Starter
(512 MB), Railway (usage-scaled), and HF Spaces clear it automatically; Fly's
256 MB default does not, so the Fly config pins a 1 GB machine, and the
Modal app pins `memory=1024` for the same reason.
## Execution model
Omnigent runs in two pieces that talk to each other over a
WebSocket tunnel:
- **Server**: the FastAPI app you deploy here. Handles HTTP / SSE
routes, terminal-attach WebSockets, persistence, web UI.
- **Runner (host)**: a Python subprocess that runs on the **user's
machine** (laptop, dev container, etc.). Dials in to the server
via `WS /v1/runner/tunnel`, executes the LLM loop + tools locally,
streams events back.
The deploy options here are all about the server. Runners aren't
deployed; every user launches one on their own machine with
`omnigent run … --server <url>` or `omnigent claude --server <url>`.
This separation is why the server image is small (no `tmux`, no
harness SDKs, no LLM API keys in the image) and why no agent code
runs inside it.
## Connect your laptop
Once the server is up, sign in from your machine. The token is reused by
`run`, `attach`, and `host`:
```bash
omnigent login https://your-host
```
`login` detects the server's auth mode automatically. Built-in accounts,
OIDC, header-auth proxies, and Databricks-hosted servers (a Databricks App
or a workspace API path) all work with the same command; for Databricks it
runs `databricks auth login` against the right workspace for you (requires
the `databricks` extra).
Then register the machine as a host, so sessions created in the web UI can
run on it:
```bash
omnigent host https://your-host
```
Or point a one-off run at the server directly:
```bash
omnigent run path/to/agent.yaml --server https://your-host
```
## Run hosts in cloud sandboxes
Don't want a laptop to be the host? Run the host in a cloud sandbox instead.
**From the CLI (Modal, Daytona, Islo, or E2B).** Install the provider extra when
needed (`pip install 'omnigent[modal]'`, `'omnigent[daytona]'`, or
`'omnigent[e2b]'`; Islo uses the built-in HTTP client), authenticate
(`modal token new`, `DAYTONA_API_KEY`, `ISLO_API_KEY`, or `E2B_API_KEY`), then:
```bash
omnigent sandbox create --provider modal # or --provider daytona / islo / e2b
omnigent sandbox connect --provider modal --sandbox-id <id> --server https://your-host
```
> [!NOTE]
> Modal caps sandbox lifetime at 24 hours. Re-run `create` + `connect` to
> roll the host onto a fresh sandbox. Daytona and Islo have no Omnigent-imposed
> lifetime cap; Daytona free-tier orgs restrict egress to an allowlist; see
> [`daytona/README.md`](daytona/README.md) for the relay workaround. E2B
> shares Modal's 24-hour cap **and** boots from a pre-built E2B *template*
> rather than a registry image — build it once first; see
> [`e2b/README.md`](e2b/README.md).
**Server-managed (Modal, Daytona, Islo, or E2B).** With *managed hosts*, creating a
session with `"host_type": "managed"` (e.g.
`POST /v1/sessions {"agent_id": ..., "host_type": "managed"}`) makes the
server provision a sandbox, start a host in it, and run the session there.
No laptop, no CLI steps per session; the sandbox is terminated when the
session is deleted. Configuration is a `sandbox:` section in the server
config (`omnigent server -c config.yaml`, or `<data_dir>/config.yaml`):
```yaml
sandbox:
provider: modal
server_url: https://your-host # public URL sandboxes dial back to
```
Modal credentials come from the server's environment (`MODAL_TOKEN_ID` /
`MODAL_TOKEN_SECRET`, or a mounted `~/.modal.toml`), not the config file.
Daytona reads `DAYTONA_API_KEY`; Islo reads `ISLO_API_KEY` (and optional
`ISLO_BASE_URL`); E2B reads `E2B_API_KEY` from the server environment.
Each sandbox authenticates back with a server-minted, per-launch token, so
no user credentials ever enter the sandbox.
**The host image.** Sandboxes boot from the official prebaked host image
(`ghcr.io/omnigent-ai/omnigent-host:latest`, published by CI from the `host`
target of [`docker/Dockerfile`](docker/Dockerfile)), so the host starts in
seconds instead of installing Omnigent at boot. The image ships the
coding-harness CLIs (`claude`, `codex`, `pi`, `kiro-cli`), so agents on any harness run
in the sandbox with nothing extra to install. To run sandboxes from your own
image instead (a fork, or extra tooling baked in), build the same `host`
target and point the config at it:
```bash
docker build -f docker/Dockerfile --target host \
-t docker.io/<you>/omnigent-host:latest .
docker push docker.io/<you>/omnigent-host:latest
```
```yaml
sandbox:
provider: modal
server_url: https://your-host
modal:
image: docker.io/<you>/omnigent-host:latest
```
For private registries, set `OMNIGENT_MODAL_REGISTRY_SECRET` on the server
to the name of a Modal secret holding `REGISTRY_USERNAME` /
`REGISTRY_PASSWORD`; for CLI-launched sandboxes, `OMNIGENT_MODAL_HOST_IMAGE`
(or `OMNIGENT_DAYTONA_HOST_IMAGE` / `OMNIGENT_ISLO_HOST_IMAGE`) overrides the
image ref.
**LLM credentials for managed sessions.** A fresh sandbox has no API keys.
Park your provider credentials in a [Modal secret](https://modal.com/secrets)
and list it in the config. Its env vars are injected into every managed
sandbox, and the in-sandbox host forwards the standard harness credential
vars (`ANTHROPIC_API_KEY`, `ANTHROPIC_AUTH_TOKEN`, `ANTHROPIC_BASE_URL`,
`CLAUDE_CODE_OAUTH_TOKEN`, `CODEX_ACCESS_TOKEN`, `OPENAI_API_KEY`,
`OPENAI_BASE_URL`, `GEMINI_API_KEY`, plus their `OMNIGENT_`-prefixed
aliases) to its runners:
```bash
modal secret create omnigent-llm \
OMNIGENT_ANTHROPIC_API_KEY=sk-ant-… OPENAI_API_KEY=sk-…
```
Prefer `OMNIGENT_ANTHROPIC_API_KEY` for Claude Code API-key auth. Omnigent
resolves it into Claude Code's `apiKeyHelper`, avoiding a raw
`ANTHROPIC_API_KEY` in the Claude CLI process.
```yaml
sandbox:
provider: modal
server_url: https://your-host
modal:
secrets: [omnigent-llm]
```
For Daytona and Islo, list server environment variable names under
`sandbox.daytona.env` or `sandbox.islo.env`; the launcher copies the current
server env values into each sandbox:
```yaml
sandbox:
provider: islo
server_url: https://your-host
islo:
env: [OPENAI_API_KEY, GIT_TOKEN]
```
Using a **Claude subscription** instead of an API key? Run
`claude setup-token` on your own machine and store the resulting long-lived
token as `CLAUDE_CODE_OAUTH_TOKEN` in the secret. A **ChatGPT
Business/Enterprise plan** works the same way via a
[Codex access token](https://developers.openai.com/codex/enterprise/access-tokens)
stored as `CODEX_ACCESS_TOKEN`. For gateway setups or other env vars beyond
the standard set, add `OMNIGENT_RUNNER_ENV_PASSTHROUGH=NAME1,NAME2` to the
secret to name the extra vars the host should forward to runners.
**Private repositories.** Managed sessions can clone a repository as the
session workspace; for private ones, store an HTTPS token as `GIT_TOKEN` in
a Modal secret (GitLab: add `GIT_USERNAME=oauth2`). The host image's git
credential helper picks it up for the clone and for the agent's later
fetch/push.
The full Modal guide (CLI sandboxes, custom images, LLM and git credentials,
troubleshooting) lives at [`modal/README.md`](modal/README.md); the Daytona
guide lives at [`daytona/README.md`](daytona/README.md); the Islo guide
(including its gateway credential-injection model) lives at
[`islo/README.md`](islo/README.md).
## Auth
Auth is driven by a single switch, `OMNIGENT_AUTH_ENABLED`. The framework
default (a bare local `omnigent server`) leaves it off: single-user
`header` mode, no login. The containerized deploys here (Docker / HF / Render /
Railway / Modal / Fly) set `OMNIGENT_AUTH_ENABLED=1` by default in their
entrypoints,
since a network-exposed instance should be authenticated. With the switch on,
the mode is chosen by your config: supply the `OMNIGENT_OIDC_*` vars and you
get `oidc`, otherwise you get the built-in `accounts` flow.
`OMNIGENT_AUTH_PROVIDER` is an explicit escape hatch that pins the mode and
overrides this auto-selection.
| Mode | When to use | What's needed |
|---|---|---|
| `accounts` (deploy default) | Standalone deploy, no external IdP: built-in username/password with first-user-is-admin bootstrap and UI-based invites. Opt in with `OMNIGENT_AUTH_ENABLED=1` (and no OIDC vars). | Set `OMNIGENT_ACCOUNTS_COOKIE_SECRET` (or let `bootstrap.sh` mint it) and `OMNIGENT_ACCOUNTS_BASE_URL` (public URL). On first boot, set the admin password via the web Create-admin form, the terminal prompt, or `--admin-password` / `OMNIGENT_ACCOUNTS_INIT_ADMIN_PASSWORD`. |
| `oidc` | Standalone deploy with your own IdP: server handles the full login flow | Set `OMNIGENT_AUTH_ENABLED=1` and the `OMNIGENT_OIDC_*` env vars; the presence of `OMNIGENT_OIDC_ISSUER` selects OIDC (or pin `OMNIGENT_AUTH_PROVIDER=oidc`). Requires HTTPS (the session cookie uses the `__Host-` prefix). |
| `header` | Behind an existing SSO proxy (oauth2-proxy, AWS ALB OIDC, Cloudflare Access, Tailscale Funnel, …) that injects an identity header | The default when `OMNIGENT_AUTH_ENABLED` is off; or pin `OMNIGENT_AUTH_PROVIDER=header`. Reads `X-Forwarded-Email` by default; set `OMNIGENT_AUTH_HEADER` for proxies that use another name (e.g. `Cf-Access-Authenticated-User-Email`), and `OMNIGENT_AUTH_HEADER_STRIP_PREFIX=accounts.google.com:` for Google IAP. Proxy MUST strip any inbound copy of the header from clients. Missing headers are always rejected. |
> [!NOTE]
> **Managed sandboxes need `header`/`oidc` or single-user auth.** Each session's
> runner dials back with the *user's* identity, which the built-in `accounts` mode
> (the deploy default above) can't supply over the runner WebSocket — it returns
> `403` even though the host connects. Framework-level; applies to every sandbox
> provider (Modal / Daytona / Islo / Kubernetes / …).
### Single sign-on (OIDC)
The built-in `accounts` flow needs no setup beyond the deploy itself. To let
your team sign in with the accounts they already have (Google, GitHub, Okta,
Microsoft), point the server at your identity provider. In `docker/.env` (or
your platform's env settings):
```dotenv
# Auth is already on (OMNIGENT_AUTH_ENABLED=1) by default in the deploys here.
# Adding an OIDC issuer flips the mode to single sign-on. No extra flag.
OMNIGENT_OIDC_ISSUER=https://accounts.google.com # or https://github.com / your Okta / Entra URL
OMNIGENT_DOMAIN=agents.yourcompany.com # your server's domain
OMNIGENT_OIDC_CLIENT_ID=…
OMNIGENT_OIDC_CLIENT_SECRET=…
```
```bash
docker compose up -d # restart to apply
```
Your team signs in with their existing accounts, and there are no passwords
for you to manage. Nothing else about the app changes.
> [!TIP]
> The only outside step is creating an app with your provider (e.g. Google
> Cloud Console, or GitHub → Settings → Developer settings) to get the client
> ID and secret. Set its **callback URL** to `https://<your-domain>/auth/callback`.
**Decide who's allowed in**, in your server config (`/data/config.yaml`):
```yaml
allowed_domains: [yourcompany.com] # only your company's emails can sign in
admins: [you@yourcompany.com] # who can manage members
```
> [!TIP]
> Need to let in one outsider, say a contractor on a personal account? Set
> `OMNIGENT_OIDC_ALLOW_INVITES=1` and send them a one-time invite link,
> instead of opening up the whole allowlist.
**Already have a team on built-in accounts?** One command brings everyone
across when you switch, so they keep their sessions and admin rights:
```bash
omnigent debug migrate-accounts-to-oidc <database-url> --domain yourcompany.com
```
For the provider-specific walkthroughs (GitHub OAuth, Google Workspace,
generic OIDC), see
[`docker/README.md#multi-user-mode-oidc`](docker/README.md#multi-user-mode-oidc).
### Header mode (X-Forwarded-Email)
> [!WARNING]
> Don't deploy a shared server in header-auth mode unless you run a trusted
> reverse proxy.
`header` mode (`OMNIGENT_AUTH_PROVIDER=header`) takes the caller's identity
from a trusted request header — `X-Forwarded-Email` by default. It exists for
deployments that sit behind an SSO proxy (oauth2-proxy, Cloudflare Access, an
ALB/OIDC listener, Databricks Apps) that authenticates the user and injects
that header on every request.
Proxies that authenticate with a different header name set
`OMNIGENT_AUTH_HEADER` to that name instead of standing up an extra hop to
rename it. For example, behind **Cloudflare Access** (which provides the
authenticated email in `Cf-Access-Authenticated-User-Email`):
```dotenv
OMNIGENT_AUTH_PROVIDER=header
OMNIGENT_AUTH_HEADER=Cf-Access-Authenticated-User-Email
```
Some proxies namespace the identity they inject. **Google IAP** forwards the
email in `X-Goog-Authenticated-User-Email` prefixed with `accounts.google.com:`
(e.g. `accounts.google.com:user@example.com`). Set
`OMNIGENT_AUTH_HEADER_STRIP_PREFIX` to drop that prefix and recover the bare
email:
```dotenv
OMNIGENT_AUTH_PROVIDER=header
OMNIGENT_AUTH_HEADER=X-Goog-Authenticated-User-Email
OMNIGENT_AUTH_HEADER_STRIP_PREFIX=accounts.google.com:
```
In header mode **the server trusts whatever that header says**. If no proxy
sets it, requests are rejected (`401`) rather than silently sharing one
identity. But a *misconfigured* proxy is still dangerous: if the proxy
doesn't **strip** any client-supplied copy of the identity header before
forwarding, anyone can impersonate anyone by sending the header themselves.
Getting this wrong exposes every user's sessions, conversation history, tool
output, and files to every other caller.
**For almost everyone, use built-in `accounts` (the default in these
deploys) or `oidc`**; both authenticate users at the server with no proxy to
get right. Only choose `header` when you already operate a proxy you trust
to set and sanitize the identity header, and read
[`docker/README.md#header-proxy-mode-for-deploys-behind-an-existing-sso-proxy`](docker/README.md#header-proxy-mode-for-deploys-behind-an-existing-sso-proxy)
first.
## Adding a new deploy target
Drop a new subdirectory under `deploy/<target>/` with a `README.md`
and `SKILL.md`. If the new target uses the existing Docker image,
your work is mostly platform-specific glue (a `fly.toml`, a Cloud
Run service.yaml, a Helm chart, an HF Spaces config) plus a README
that explains how to point that platform at `docker/Dockerfile`.
Update this top-level README with a row in the table above.
+160
View File
@@ -0,0 +1,160 @@
# Omnigent on BoxLite
[BoxLite](https://github.com/boxlite-ai/boxlite) is an embeddable
micro-VM + OCI runtime ("SQLite for sandboxing"). It runs each Omnigent
host inside its own lightweight VM (its own kernel — KVM on Linux,
Hypervisor.framework on macOS) booted from a standard OCI image.
The boxlite provider is **server-managed only**: the server provisions a
box automatically when a session is created with `"host_type":
"managed"`, starts `omnigent host` inside it, and removes it when the
session is deleted. (There is no `omnigent sandbox create` CLI bootstrap
for boxlite yet — see [Limitations](#limitations).)
A single `boxlite` provider spans **both** runtime targets, chosen by
config:
- **Local** (default — no `cloud:` block): BoxLite is embedded in the
Omnigent server process. **No daemon, no `boxlite serve`, no root.**
Boxes are micro-VMs on the server host itself, so that host needs
hardware virtualization. The first local, hardware-isolated,
persistent runner — no cloud account required.
- **Cloud** (a `cloud:` block with `endpoint`): a thin REST client to a
remote `boxlite serve` pool. Boxes run on the pool; the server reaches
them over HTTP. Same role as the Modal / Daytona providers, self-hosted.
The two modes are configured by mutually-exclusive `local:` / `cloud:`
sub-blocks (see [Server configuration](#server-configuration)).
Boxes boot from the official prebaked host image, so startup is seconds
once the image is cached locally (the first boot from a given image
pulls it, which can take a few minutes).
## Prerequisites
```bash
pip install 'omnigent[boxlite]' # installs the boxlite SDK extra
```
**Local mode** additionally needs hardware virtualization on the
*server host*:
- **Linux:** KVM enabled and accessible — `/dev/kvm` must exist and the
server user must be in the `kvm` group.
- **macOS (Apple Silicon):** Hypervisor.framework, always available.
**Cloud mode** needs a reachable `boxlite serve` endpoint; the server
host needs no virtualization.
## Server configuration
Add a `sandbox:` block to your server config (`omnigent server -c …` /
`OMNIGENT_CONFIG` / `<data_dir>/config.yaml`).
### Local micro-VMs (no cloud account)
```yaml
sandbox:
provider: boxlite
server_url: https://omnigent.example.com # the in-box host dials this back
```
`provider` + `server_url` is a complete config: the image defaults to
the official prebaked host image and boxes run locally.
### Cloud (remote `boxlite serve` pool)
```yaml
sandbox:
provider: boxlite
server_url: https://omnigent.example.com
boxlite:
image: docker.io/me/omnigent-host:latest # optional, shared; default: official
env: [OPENAI_API_KEY, GIT_TOKEN] # optional, shared; SERVER env var NAMES
cloud:
endpoint: https://boxlite.example.com:8100 # selects CLOUD mode
```
`local:` and `cloud:` are **mutually exclusive** — a session runs in exactly
one mode. Provider credentials are **not** in this file (12-factor): in cloud
mode the API key is read from `BOXLITE_API_KEY` in the server environment.
### Local runtime customization (data dir, private host image)
Local mode embeds the boxlite runtime, so you can point it at a specific
data directory and give it credentials to pull a **private** host image
(the local analog of the cloud providers' registry secrets):
```yaml
sandbox:
provider: boxlite
server_url: https://omnigent.example.com
boxlite:
image: ghcr.io/acme/omnigent-host:latest # shared
local: # LOCAL mode block (mutually exclusive with `cloud`)
home_dir: /data/boxlite # runtime state + image cache (default ~/.boxlite)
registry:
host: ghcr.io
username_env: GHCR_USER # NAME of a server env var (not the value)
password_env: GHCR_PAT
# token_env: GHCR_TOKEN # bearer-token alternative
# transport: https # or "http"
# skip_verify: false
```
The `local:` block applies to local mode only and is mutually exclusive with
`cloud:`. When `local:` is omitted (or empty) the launcher uses the zero-config
`Boxlite.default()` runtime. Registry credentials are read from the named server
env vars at provision time — values never live in the config file.
> **Security:** `transport: https` (the default) and `skip_verify: false` keep
> the registry pull encrypted and certificate-verified. `transport: http` sends
> the pull credentials in **cleartext**, and `skip_verify: true` disables TLS
> verification — use them only on a trusted local network. Likewise, a cloud
> `endpoint` with an `http://` scheme ships `BOXLITE_API_KEY` in cleartext;
> prefer `https://`.
### Environment variables
| Variable | Purpose |
|----------|---------|
| `BOXLITE_API_KEY` | API key for the remote `boxlite serve` (cloud mode only). |
| `OMNIGENT_BOXLITE_HOST_IMAGE` | Override the host image (alternative to `sandbox.boxlite.image`). |
| `OMNIGENT_BOXLITE_SANDBOX_ENV` | Comma-separated SERVER env var names to inject into boxes (alternative to `sandbox.boxlite.env`). |
The `env` names resolve to their values from the **server's own
environment** at provision time — typically the harness LLM credentials
(`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, gateway base URLs) and
`GIT_TOKEN` the in-box host forwards to runners. Names only, so secret
values never live in the config file.
## How it works
1. The server provisions a box from the prebaked host image
(`runtime.create(BoxOptions(image=…, auto_remove=False))`). Boxes are
persistent — the managed-session machinery owns teardown.
2. Network defaults to full egress, so the in-box host can reach
`server_url`.
3. The server runs `omnigent host` inside the box (over `box.exec`) with
a one-time launch token in its environment; the host dials back over
a WebSocket tunnel and registers. From there the session rides the
same host/runner machinery every Omnigent host uses — the agent's
runner, tools, and shell all execute inside the box.
4. On sandbox death (a crash, or you `boxlite rm` it), the durable host
identity survives and the next message relaunches a fresh box
generation.
Inspect running boxes with the CLI (`boxlite list`, `boxlite logs <id>`);
the in-box host logs to `/tmp/omnigent-host.log`.
## Limitations
- **Managed-only.** The `omnigent sandbox create` / `connect` CLI
bootstrap (local wheel shipping + in-sandbox App OAuth) is not
implemented for boxlite. Use the server-managed flow above. (Adding
CLI bootstrap later is straightforward — the async `Box.copy_into`
supports file shipping; the sync SDK wrapper does not, which is why
the launcher uses the async API.)
- **Network policy.** Boxes get full outbound egress by default. If your
deployment needs an allowlist, that's a follow-up on `BoxOptions`'
network spec.
+8
View File
@@ -0,0 +1,8 @@
# Trim the Docker build context. `wrangler deploy` builds the container image
# from this directory, but the Dockerfile only needs sitecustomize.py — keep
# node deps, wrangler state, and Python caches out of the context sent to the
# Docker daemon.
node_modules/
.wrangler/
__pycache__/
*.pyc
+32
View File
@@ -0,0 +1,32 @@
# Omnigent server on Cloudflare Containers, backed by D1 (database) and R2
# (artifact store via omnigent's native S3 backend). Derived from the official
# server image; adds the Cloudflare D1 SQLAlchemy dialect + a behavior shim, and
# boto3 for the S3/R2 artifact store.
#
# No FUSE mount: the server writes artifacts straight to R2 over the S3 API,
# selected with ``OMNIGENT_ARTIFACT_URI=s3://<bucket>`` (set by the Worker).
#
# Requires an omnigent-server image that includes the S3 artifact backend —
# i.e. the ``deploy/docker/entrypoint.py`` change that ships alongside this
# directory. (Until that lands in the published image, build a server image
# from this branch.)
FROM ghcr.io/omnigent-ai/omnigent-server:latest
ARG SITE=/opt/venv/lib/python3.12/site-packages
USER root
# Cloudflare D1 SQLAlchemy dialect + boto3 (for the S3/R2 artifact store).
RUN /opt/venv/bin/pip install --no-cache-dir \
"sqlalchemy-cloudflare-d1==0.3.10" "boto3>=1.30,<2"
# Shim: re-register the D1 dialect as a proper SQLite subclass (auto-loaded).
COPY sitecustomize.py ${SITE}/sitecustomize.py
RUN /opt/venv/bin/python -c "\
import sitecustomize; \
from sqlalchemy import create_engine; \
from sqlalchemy.dialects.sqlite.base import SQLiteDialect; \
from alembic.ddl.impl import _impls; \
e = create_engine('cloudflare_d1://a:b@c'); \
assert isinstance(e.dialect, SQLiteDialect); \
assert 'cloudflare_d1' in _impls; \
print('D1 dialect + shim OK')"
+170
View File
@@ -0,0 +1,170 @@
# Omnigent on Cloudflare (Containers + D1 + R2)
Run the Omnigent server on **Cloudflare Containers**, with **D1** as the
database and **R2** as the durable artifact store. This is the serverless,
scale-to-zero option: no VM or Postgres to manage, a public `*.workers.dev`
URL (or your domain), and the container sleeps when idle.
> [!NOTE]
> This is **not** the same as [`deploy/trycloudflare/`](../trycloudflare/),
> which is a quick tunnel that exposes a server running on **your laptop**.
> Here the server itself runs **on Cloudflare**.
> [!NOTE]
> This path uses a small SQLAlchemy dialect shim (`sitecustomize.py`) because
> Cloudflare D1 isn't yet first-class in Omnigent. It works end to end — it's how
> this directory was validated — and the normal on-boot migrations run unmodified.
> The R2 artifact store, by contrast, already uses a first-class backend
> (`S3ArtifactStore`) added alongside this directory.
## How it works
```
HTTPS / WebSocket
browser ───────────────► Worker (src/index.js)
│ getContainer("singleton").fetch(req)
Container ──► the omnigent server (port 8000)
(1 instance) │ │
DATABASE_URL ───────┘ │ S3 API (boto3)
cloudflare_d1://… ▼
│ OMNIGENT_ARTIFACT_URI
▼ s3://omnigent-artifacts
Cloudflare D1 │
(SQLite, the DB) ▼
Cloudflare R2
(artifact store)
```
- **Worker** — a thin front that proxies every request to **one** container
instance (Omnigent keeps an in-memory runner registry, so it's single-replica).
- **Container** — the official `ghcr.io/omnigent-ai/omnigent-server` image plus
the D1 SQLAlchemy dialect, a shim that re-registers it as a proper SQLite
dialect, and `boto3` (this directory's `Dockerfile`).
- **D1** is the database. The server reaches it through the
`sqlalchemy-cloudflare-d1` dialect, which speaks D1's HTTP API — so
`DATABASE_URL` is `cloudflare_d1://<account>:<api-token>@<database-id>`.
- **R2** is the artifact store. Cloudflare container disk is **ephemeral**, so
artifacts (agent bundles, user files) go to R2 over its **S3 API** via
Omnigent's native `S3ArtifactStore`, selected with
`OMNIGENT_ARTIFACT_URI=s3://<bucket>`. No FUSE mount, no sidecar.
## What's in here
| File | Purpose |
|---|---|
| `Dockerfile` | derived image: server + D1 dialect + shim + boto3 |
| `sitecustomize.py` | shim re-registering `cloudflare_d1` as a SQLite dialect (auto-loaded) |
| `src/index.js` | the Worker that proxies to the container |
| `wrangler.jsonc` | Worker + Container + Durable Object config |
| `package.json` | `wrangler` + `@cloudflare/containers` |
## Prerequisites
- A Cloudflare account on the **Workers Paid** plan — Containers require it.
- **Docker** running locally (`wrangler deploy` builds the image).
- **Node** (for `wrangler`).
- `wrangler login` (or a `CLOUDFLARE_API_TOKEN`).
```bash
cd deploy/cloudflare
npm install
npx wrangler login
```
## Deploy
### 1. Create the D1 database
```bash
npx wrangler d1 create omnigent
# note the "database_id" it prints — call it <DATABASE_ID>
```
### 2. Create the R2 bucket
```bash
npx wrangler r2 bucket create omnigent-artifacts
```
### 3. A D1 API token (for `DATABASE_URL`)
The dialect authenticates to D1's REST API with a Cloudflare **API token**.
Create one at **dash.cloudflare.com → My Profile → API Tokens → Create Token →
Custom**, with permission **Account → D1 → Edit**. Your `DATABASE_URL` is then:
```
cloudflare_d1://<ACCOUNT_ID>:<D1_API_TOKEN>@<DATABASE_ID>
```
### 4. R2 S3 credentials (for the artifact store)
The artifact store uses R2's **S3 API**, which needs an Access Key ID + Secret
Access Key. Create them at **dash.cloudflare.com → R2 → Manage R2 API Tokens →
Create API Token → Object Read & Write**. It shows an **Access Key ID** and
**Secret Access Key** once — save both.
<details>
<summary>Alternative: derive S3 keys from an existing API token</summary>
Any API token with R2 permissions can be used as S3 credentials without minting
a separate R2 token ([docs](https://developers.cloudflare.com/r2/api/tokens/)):
**Access Key ID** = the token's *id*, **Secret Access Key** = `sha256(token value)`.
```bash
python3 -c 'import hashlib,sys; print(hashlib.sha256(sys.argv[1].encode()).hexdigest())' "<TOKEN_VALUE>"
```
</details>
### 5. Configure and set secrets
In `wrangler.jsonc`, set `AWS_ENDPOINT_URL_S3` to your account's R2 endpoint
(`https://<ACCOUNT_ID>.r2.cloudflarestorage.com`). Then set the four secrets:
```bash
# DATABASE_URL — the cloudflare_d1:// string from step 3
npx wrangler secret put DATABASE_URL
# Session cookie secret — any 64-hex string
openssl rand -hex 32 | npx wrangler secret put OMNIGENT_ACCOUNTS_COOKIE_SECRET
# R2 S3 credentials from step 4
npx wrangler secret put AWS_ACCESS_KEY_ID
npx wrangler secret put AWS_SECRET_ACCESS_KEY
```
### 6. Deploy
```bash
npx wrangler deploy
# -> https://omnigent.<your-subdomain>.workers.dev
```
The container cold-starts on the first request (~10s), then stays warm:
```bash
curl https://omnigent.<your-subdomain>.workers.dev/health # {"status":"ok"}
```
On a brand-new D1, the **first** boot runs all migrations before the server
starts listening (~1 minute against D1's REST API), so the first few requests
may return a 5xx while it migrates — just retry. Later boots are fast.
### 7. First admin + connect a host
Open the URL and the Setup screen claims the first admin (username + password).
Then connect a machine to actually run agents (the server is just the control
plane):
```bash
omnigent login https://omnigent.<your-subdomain>.workers.dev
omnigent host --server https://omnigent.<your-subdomain>.workers.dev
```
## Verifying durability
The point of R2 is that state survives the ephemeral container. To prove it,
note your data, force a fresh container (`npx wrangler deploy` again, or let it
idle to sleep), and confirm it's still there — agents still load, sessions still
exist. The database lives in D1 and the artifacts in R2; the container holds
nothing durable.
+16
View File
@@ -0,0 +1,16 @@
{
"name": "omnigent-cloudflare",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"deploy": "wrangler deploy",
"tail": "wrangler tail"
},
"devDependencies": {
"wrangler": "^4.0.0"
},
"dependencies": {
"@cloudflare/containers": "^0.3.0"
}
}
+118
View File
@@ -0,0 +1,118 @@
"""Auto-loaded shim that makes Omnigent work against Cloudflare D1.
D1 is SQLite reached over an HTTP REST API. The third-party
``sqlalchemy-cloudflare-d1`` dialect subclasses the *generic* ``DefaultDialect``
and then hand-reimplements SQLite's SQL compilation and schema reflection —
incompletely. That breaks DDL (a composite primary key emits two ``PRIMARY
KEY`` clauses, which D1 rejects; reserved words like ``key`` go unquoted) and
migrations (``get_unique_constraints`` is unimplemented; ``get_foreign_keys``
drops keys reflection needs).
The fix is to subclass SQLAlchemy's real ``SQLiteDialect`` and keep only the
*transport*: the HTTP DBAPI, the URL parser, and the D1 type processors (which
base64-encode blobs and ISO-format dates for the JSON REST API — SQLite's file
DBAPI does this natively, D1's API does not). Everything above the transport —
DDL compiler, type compiler, identifier quoting, and full reflection — is then
inherited from SQLite, correctly. This is the change that belongs upstream in
the dialect package (just change its base class); until it ships, sitecustomize
re-registers a corrected dialect here. Python imports ``sitecustomize`` at
interpreter startup, so it runs before Omnigent builds an engine.
Two small adaptations remain, both expressing facts about D1 rather than SQLite
shortcomings:
* **Alembic.** Its DDL-impl registry (``alembic.ddl.impl._impls``) is keyed by
``dialect.name`` with no inheritance fallback, so the (correctly named)
``cloudflare_d1`` dialect ``KeyError``s in ``MigrationContext.__init__``.
Register SQLite's impl under that name.
* **No ``temp`` schema.** D1 exposes a single ``main`` schema and forbids the
``temp`` schema (``SQLITE_AUTH``). SQLite's reflection probes ``temp``
(``PRAGMA temp.*``, ``sqlite_temp_master``, ``PRAGMA database_list``); the
three touchpoints are overridden to read only ``main``.
"""
import sys
# ── Alembic: register a DDL impl for the cloudflare_d1 name ──────────────
try:
from alembic.ddl.sqlite import SQLiteImpl
class CloudflareD1Impl(SQLiteImpl): # auto-registers via __dialect__
__dialect__ = "cloudflare_d1"
except Exception as exc: # noqa: BLE001 -- defensive: never block server startup
print(f"[d1-shim] could not register Alembic impl: {exc}", file=sys.stderr)
# ── Dialect: re-register cloudflare_d1 as a real SQLite subclass ─────────
try:
from sqlalchemy import exc as _sa_exc
from sqlalchemy.dialects import registry
from sqlalchemy.dialects.sqlite.base import SQLiteDialect
from sqlalchemy_cloudflare_d1.dialect import CloudflareD1Dialect as _UpstreamD1
# SQLiteDialect.__init__ reads self.dbapi.sqlite_version_info to gate
# features. D1 runs a modern SQLite; advertise it (the live version is also
# read in _get_server_version_info below).
_D1_SQLITE_VERSION = (3, 45, 0)
_dbapi = _UpstreamD1.import_dbapi()
if not hasattr(_dbapi, "sqlite_version_info"):
_dbapi.sqlite_version_info = _D1_SQLITE_VERSION
_dbapi.sqlite_version = ".".join(str(p) for p in _D1_SQLITE_VERSION)
class CloudflareD1Dialect(SQLiteDialect):
"""The cloudflare_d1 dialect: SQLite behavior over D1's HTTP transport."""
name = "cloudflare_d1"
driver = "httpx"
default_paramstyle = "qmark"
supports_statement_cache = True
# D1's JSON REST API needs the transport type processors (blob->base64,
# date/time->ISO); layer them over SQLite's defaults.
colspecs = {**SQLiteDialect.colspecs, **_UpstreamD1.colspecs} # noqa: RUF012
# ── transport (from the upstream dialect) ──
@classmethod
def import_dbapi(cls):
return _UpstreamD1.import_dbapi()
create_connect_args = _UpstreamD1.create_connect_args
# D1 has no isolation levels — keep these no-ops so SQLite's
# PRAGMA read_uncommitted machinery never runs over the REST API.
def get_isolation_level(self, dbapi_connection): # noqa: ARG002
return None
def set_isolation_level(self, dbapi_connection, level):
pass
def _get_server_version_info(self, connection):
try:
v = connection.exec_driver_sql("SELECT sqlite_version()").scalar()
return tuple(int(x) for x in str(v).split("."))
except Exception: # noqa: BLE001
return _D1_SQLITE_VERSION
# ── D1 has a single "main" schema and forbids "temp" ──
def get_schema_names(self, connection, **kw): # noqa: ARG002
return ["main"]
def _get_table_sql(self, connection, table_name, schema=None, **kw): # noqa: ARG002
schema_expr = f"{self.identifier_preparer.quote_identifier(schema)}." if schema else ""
s = (
f"SELECT sql FROM {schema_expr}sqlite_master "
"WHERE name = ? AND type in ('table', 'view')"
)
value = connection.exec_driver_sql(s, (table_name,)).scalar()
if value is None and not self._is_sys_table(table_name):
raise _sa_exc.NoSuchTableError(f"{schema_expr}{table_name}")
return value
def _get_table_pragma(self, connection, pragma, table_name, schema=None):
quote = self.identifier_preparer.quote_identifier
prefix = f"{quote(schema)}." if schema is not None else "main."
cursor = connection.exec_driver_sql(f"PRAGMA {prefix}{pragma}({quote(table_name)})")
return cursor.fetchall() if not cursor._soft_closed else []
registry.register("cloudflare_d1", __name__, "CloudflareD1Dialect")
except Exception as exc: # noqa: BLE001 -- defensive: never block server startup
print(f"[d1-shim] could not register D1 dialect: {exc}", file=sys.stderr)
+41
View File
@@ -0,0 +1,41 @@
// Worker that fronts the Omnigent container and proxies all HTTP (and
// WebSocket) traffic to it. Omnigent needs a SINGLE server instance (in-memory
// runner registry), so every request routes to one fixed container instance.
import { Container, getContainer } from "@cloudflare/containers";
export class OmnigentServer extends Container {
// Port the omnigent server listens on inside the container.
defaultPort = 8000;
// Keep the container warm so D1-backed sessions don't cold-start constantly.
sleepAfter = "30m";
constructor(ctx, env) {
super(ctx, env);
// Env passed into the container. Secrets (DATABASE_URL, the cookie secret,
// the AWS_* R2 keys) come from `wrangler secret put`; the rest are plain
// vars in wrangler.jsonc.
this.envVars = {
DATABASE_URL: env.DATABASE_URL,
OMNIGENT_ACCOUNTS_COOKIE_SECRET: env.OMNIGENT_ACCOUNTS_COOKIE_SECRET,
OMNIGENT_AUTH_ENABLED: "1",
OMNIGENT_AUTH_PROVIDER: "accounts",
OMNIGENT_ACCOUNTS_AUTO_OPEN: "0",
HOST: "0.0.0.0",
PORT: "8000",
// Artifact store -> R2 over the S3 API (omnigent's native S3 backend).
// OMNIGENT_ARTIFACT_URI selects it; AWS_* point boto3 at R2.
OMNIGENT_ARTIFACT_URI: env.OMNIGENT_ARTIFACT_URI,
AWS_ENDPOINT_URL_S3: env.AWS_ENDPOINT_URL_S3,
AWS_DEFAULT_REGION: "auto",
AWS_ACCESS_KEY_ID: env.AWS_ACCESS_KEY_ID,
AWS_SECRET_ACCESS_KEY: env.AWS_SECRET_ACCESS_KEY,
};
}
}
export default {
async fetch(request, env) {
// One shared instance for the whole app (single-replica requirement).
return await getContainer(env.OMNIGENT, "singleton").fetch(request);
},
};
+36
View File
@@ -0,0 +1,36 @@
{
// Omnigent server on Cloudflare Containers, backed by D1 (database) and
// R2 (artifact store, via omnigent's native S3 backend over the R2 S3 API).
// Full walkthrough: deploy/cloudflare/README.md.
"name": "omnigent",
"main": "src/index.js",
"compatibility_date": "2026-06-01",
// Built from ./Dockerfile (omnigent-server + D1 dialect + boto3). Single
// replica only (in-memory runner registry) — do NOT raise max_instances.
// "basic" = 0.25 vCPU / 1 GiB; bump to "standard" for faster cold starts.
"containers": [
{
"class_name": "OmnigentServer",
"image": "./Dockerfile",
"instance_type": "basic",
"max_instances": 1
}
],
"durable_objects": {
"bindings": [{ "class_name": "OmnigentServer", "name": "OMNIGENT" }]
},
"migrations": [
{ "tag": "v1", "new_sqlite_classes": ["OmnigentServer"] }
],
// Non-secret config. Replace <YOUR_CLOUDFLARE_ACCOUNT_ID> with your account ID
// (it's in the R2 S3 endpoint). DATABASE_URL, OMNIGENT_ACCOUNTS_COOKIE_SECRET,
// AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY are SECRETS — set them with
// `wrangler secret put` (see README).
"vars": {
"OMNIGENT_ARTIFACT_URI": "s3://omnigent-artifacts",
"AWS_ENDPOINT_URL_S3": "https://<YOUR_CLOUDFLARE_ACCOUNT_ID>.r2.cloudflarestorage.com"
}
}
+297
View File
@@ -0,0 +1,297 @@
# CoreWeave Sandbox provider
[CoreWeave Sandbox](https://docs.coreweave.com/products/sandboxes) gives you
disposable cloud machines for running Omnigent hosts, two ways:
- **CLI-launched**: `omnigent sandbox create` / `connect` provisions a sandbox
from your terminal, ships your local checkout into it, and registers it as a
host with your server.
- **Server-managed**: the server provisions a sandbox automatically when a
session is created with `"host_type": "managed"` and terminates it when the
session is deleted.
The launcher wraps the official
[`cwsandbox`](https://github.com/coreweave/cwsandbox-client) Python SDK, gated
behind the `cwsandbox` extra and imported lazily — same posture as the Modal and
Daytona launchers. Sandboxes boot from the official prebaked host image, so
startup is seconds.
Two traits shape the rest of this guide:
- **No local port forward.** CoreWeave Sandbox can't forward a sandbox→laptop
callback port, so the interactive in-sandbox `omnigent login` / App OAuth step
is skipped automatically (as on Modal and Daytona) — fine for token/OIDC-auth
servers.
- **No egress by default.** CW Sandbox blocks outbound traffic unless asked; the
launcher requests `egress_mode: internet` so the host can reach your server and
the agent can reach its model endpoint.
```bash
pip install 'omnigent[cwsandbox]'
```
## Prerequisites
Create a CoreWeave Sandbox API key and make it available where the launcher
runs — your shell for the CLI flow, the **server** process for managed sandboxes
(12-factor; never in config files):
```bash
export CWSANDBOX_API_KEY=... # CoreWeave Sandbox API key
export CWSANDBOX_BASE_URL=https://api.cwsandbox.com # optional (this is the default)
```
## The host image
Sandboxes boot from `ghcr.io/omnigent-ai/omnigent-host:latest`, published by CI
from the `host` target of [`deploy/docker/Dockerfile`](../docker/Dockerfile)
with Omnigent and its dependencies preinstalled — including the coding-harness
CLIs (`claude`, `codex`, `pi`, `kiro-cli`), so agents on any harness run without an
in-sandbox install.
To use a different image (a fork, or extra tooling baked in), build the same
target and push it anywhere CoreWeave can pull from:
```bash
docker build -f deploy/docker/Dockerfile --target host \
--platform linux/amd64 \
-t docker.io/<you>/omnigent-host:latest .
docker push docker.io/<you>/omnigent-host:latest
```
Then point Omnigent at it — `OMNIGENT_CWSANDBOX_HOST_IMAGE` for the CLI flow, or
`sandbox.cwsandbox.image` in the server config for the managed flow.
> [!NOTE]
> Building on Apple Silicon? Pass `--platform linux/amd64` — sandboxes run
> x86_64.
## CLI-launched sandboxes
Provision a sandbox and ship your local checkout into it:
```bash
omnigent sandbox create --provider cwsandbox --server https://your-host
```
This pulls the host image, builds wheels from your local checkout, and overlays
them on top — so the sandbox runs *your* code, not whatever the image was built
from. Then register it as a host with your server:
```bash
omnigent sandbox connect --provider cwsandbox \
--sandbox-id <id-printed-by-create> \
--server https://your-host
```
`connect` runs `omnigent host` inside the sandbox and holds the connection open
in your terminal — Ctrl-C tears it down. New sessions targeting that host now run
in the sandbox.
Running multiple sandboxes against one server? Pass a unique `--host-name
<label>` to each `connect` — the server keys hosts on (owner, name), and
sandboxes that share a hostname collide.
Sandboxes are disposable. When your code changes, create a new one.
To inject LLM/git credentials into a CLI-launched sandbox, set
`OMNIGENT_CWSANDBOX_SANDBOX_ENV` in your shell to a comma-separated list of
variable names (e.g. `ANTHROPIC_API_KEY,GIT_TOKEN`) before running `create` — the
named variables are copied from your environment into the sandbox at provision
time. A listed name that is **not** set fails the launch loudly (it would
otherwise surface much later as an opaque harness auth failure inside the
sandbox).
### Connecting to an authenticated server
`connect` runs `omnigent host` inside the sandbox, and that host must present
credentials when it dials back to a server that requires authentication. The
interactive `omnigent login` browser flow can't run inside a sandbox (no callback
port forward), so inject the keys for the relevant server instead — name them in
`OMNIGENT_CWSANDBOX_SANDBOX_ENV` before `create`:
```bash
export OMNIGENT_CWSANDBOX_SANDBOX_ENV=DATABRICKS_HOST,DATABRICKS_TOKEN
omnigent sandbox create --provider cwsandbox --server https://your-host
```
The in-sandbox host mints a fresh bearer token from those credentials on every
connect and reconnect. For a Databricks-fronted server, inject `DATABRICKS_HOST`
plus either `DATABRICKS_TOKEN` (a PAT) or `DATABRICKS_CLIENT_ID` /
`DATABRICKS_CLIENT_SECRET` (an OAuth service principal — re-minting keeps a
long-lived sandbox connected past any single token's expiry).
A server with no authentication on the host tunnel needs none of this, and
neither do [server-managed sandboxes](#server-managed-sandboxes) — those
authenticate with a server-minted per-launch token automatically.
## Server-managed sandboxes
Add a `sandbox:` section to the server config (`omnigent server -c config.yaml`,
or `<data_dir>/config.yaml`):
```yaml
sandbox:
provider: cwsandbox
server_url: https://your-host # public URL sandboxes dial back to
```
`provider` + `server_url` is a complete config. `server_url` **must be reachable
from CoreWeave** — the host inside the sandbox opens an outbound WebSocket to it,
not `localhost`. For local testing, expose your server with a tunnel
(`cloudflared` / `ngrok`) and point `server_url` at the tunnel URL. The server
itself needs `CWSANDBOX_API_KEY` (and optional `CWSANDBOX_BASE_URL`) in its
environment.
Sessions created with `host_type: "managed"` (the API call or the Web UI's New
Sandbox option) then run on a fresh CW sandbox; the create returns immediately
and provisioning happens in the background, exactly like the [Modal managed
flow](../modal/README.md#server-managed-sandboxes) — including repository
workspaces, the first-message rendezvous, and dead-sandbox relaunch.
```bash
curl -X POST https://your-host/v1/sessions \
-H "Content-Type: application/json" \
-d '{"agent_id": "agent_...", "host_type": "managed"}'
```
Each managed sandbox authenticates back with a server-minted, per-launch token;
no user credentials enter the sandbox for the server connection.
Optional `cwsandbox:` settings:
```yaml
sandbox:
provider: cwsandbox
server_url: https://your-host
cwsandbox:
image: docker.io/<you>/omnigent-host:latest # default: official image
env: [OPENAI_API_KEY, ANTHROPIC_API_KEY, GIT_TOKEN] # server env var NAMES to inject
```
### Managed hosts and server auth
How the dial-back authenticates depends on how the **server** does auth, and
there is one interaction worth knowing before you deploy. A managed sandbox opens
two kinds of connections back to the server: the **host tunnel**, which the
per-launch token authenticates directly (always works), and one **runner tunnel**
per session, opened by the runner subprocess — which authenticates with whatever
server credential it can resolve, **not** the per-launch host token.
The consequence:
- **Header / OIDC-proxy auth, or single-user (no-auth) servers** — the runner
tunnel needs no extra identity, so managed hosts work out of the box.
- **The built-in `accounts` provider (`OMNIGENT_AUTH_ENABLED=1`)** — the runner
tunnel additionally requires a *user* identity, which the per-launch host token
does not carry, so the runner dial-back is refused (`403`) even though the host
tunnel connects. This is a framework-level managed-host interaction shared by
**all** sandbox providers (Modal / Daytona / Islo / cwsandbox), not specific to
cwsandbox.
So for a managed cwsandbox deployment, front the server with **header or OIDC
auth** (a reverse proxy / IdP injects the user identity on every request,
including the runner WebSocket — see
[`deploy/README.md#auth`](../README.md#auth)), or run it single-user. The
`accounts` provider is fine for CLI-launched hosts (you `omnigent login`, and
that token is what the in-sandbox host forwards), but not yet for the managed
runner dial-back.
## Model credentials (LLM keys)
A fresh sandbox has no model credentials. Name the variables to inject in
`OMNIGENT_CWSANDBOX_SANDBOX_ENV` (CLI) or `sandbox.cwsandbox.env` (managed); the
launcher copies the value from the launching environment into the sandbox, and
the in-sandbox host forwards the standard harness credential vars to its runners:
```bash
export ANTHROPIC_API_KEY=sk-ant-… # on the server (managed) or in your shell (CLI)
```
```yaml
sandbox:
provider: cwsandbox
server_url: https://your-host
cwsandbox:
env: [ANTHROPIC_API_KEY]
```
Which variables to inject — providers, gateways, subscriptions, git — is
identical to Modal; see the [variable table and per-plan
recipes](../modal/README.md#llm-credentials-for-managed-sandboxes) and [git
credentials](../modal/README.md#git-credentials-private-repositories). For a
Claude **subscription** specifically, run `claude setup-token` on your own
machine (one-time browser auth) and inject the resulting long-lived token as
`CLAUDE_CODE_OAUTH_TOKEN`. For env vars beyond the standard set, inject
`OMNIGENT_RUNNER_ENV_PASSTHROUGH=NAME1,NAME2`.
## Git credentials (private repositories)
Inject an HTTPS token as `GIT_TOKEN` (GitLab: add `GIT_USERNAME=oauth2`) via
`OMNIGENT_CWSANDBOX_SANDBOX_ENV` / `sandbox.cwsandbox.env`. The host image's git
credential helper answers HTTPS auth from it for both the launch-time clone and
the agent's later `fetch` / `push`, writing nothing to disk. Use HTTPS repository
URLs. Details by provider match the [Modal git
guide](../modal/README.md#git-credentials-private-repositories).
## Security considerations
- **Injected credentials live in CoreWeave's control plane.** The launcher passes
`sandbox.cwsandbox.env` values to the CoreWeave API as sandbox environment
variables, so a third party holds whatever you inject (LLM keys, `GIT_TOKEN`)
for the sandbox's life. Prefer **scoped, short-lived** credentials: a
fine-grained PAT limited to the repos a session needs, a gateway token over a
root provider key.
- **All managed sandboxes share one CoreWeave org + `CWSANDBOX_API_KEY`.**
Cross-user isolation rides on CoreWeave's sandbox boundaries, and the shared key
can enumerate and delete any sandbox — the same single-tenant-org shape as the
Modal and Daytona providers. Scope the org to this workload and nothing else.
- **The launch token's lifetime tracks the sandbox lifetime.** CW Sandbox's
lifetime is operator-overridable (`OMNIGENT_CWSANDBOX_MAX_LIFETIME_S`, 24h
default), so the per-launch host token TTL is derived from it — always above the
cap, so a live sandbox can re-authenticate across reconnects while a leaked
token can't outlive the sandbox it came from. A relaunch mints a fresh one.
## Notes / limits
- Sandboxes are reaped at `max_lifetime_seconds` (24h default; override with
`OMNIGENT_CWSANDBOX_MAX_LIFETIME_S`). The managed launch-token TTL is set above
that so reconnects keep working.
- Egress defaults to none on CW Sandbox; the launcher requests `egress_mode:
internet` so the host can reach the server and the agent can reach its model
endpoint.
## Troubleshooting
- **"managed host did not come online within 120s"** — the server waits up to two
minutes for the in-sandbox host to register. If it times out, check that
`server_url` is publicly reachable from CoreWeave, then inspect the in-sandbox
host log: `/tmp/omnigent-host.log`.
- **Slow first launch** — the first launch from a given image waits on a cold
registry pull before the sandbox is ready; subsequent launches reuse the cached
image and start in seconds.
- **Agent has no credentials** — verify the injected var names match the
forwarded set (or are named in `OMNIGENT_RUNNER_ENV_PASSTHROUGH`), and that each
name was actually set in the launching environment.
## Environment variable reference
| Variable | Where it's read | Purpose |
|---|---|---|
| `CWSANDBOX_API_KEY` | CLI machine / server | CoreWeave Sandbox API credentials (required) |
| `CWSANDBOX_BASE_URL` | CLI machine / server | Non-default CW Sandbox API endpoint (default `https://api.cwsandbox.com`) |
| `OMNIGENT_CWSANDBOX_HOST_IMAGE` | CLI machine / server | Override the host image ref (`sandbox.cwsandbox.image` takes precedence for managed) |
| `OMNIGENT_CWSANDBOX_SANDBOX_ENV` | CLI machine / server | Comma-separated launcher-side env var names to inject (`sandbox.cwsandbox.env` takes precedence for managed) |
| `OMNIGENT_CWSANDBOX_MAX_LIFETIME_S` | CLI machine / server | Sandbox lifetime cap in seconds (default 24h); also derives the managed launch-token TTL |
| `OMNIGENT_RUNNER_ENV_PASSTHROUGH` | inside the sandbox (injected) | Extra env var names the host forwards to runners |
| `GIT_TOKEN` / `GIT_USERNAME` | inside the sandbox (injected) | HTTPS credentials for private repository clone / fetch / push |
## Smoke test
Validate the API primitives directly (no Omnigent or SDK install needed — stdlib
+ curl only):
```bash
export CWSANDBOX_API_KEY=...
python tests/e2e/integrations/deploy/cwsandbox/smoke_test.py
```
+294
View File
@@ -0,0 +1,294 @@
# Deploying Omnigent on Databricks Apps
This directory deploys the Omnigent server to
[Databricks Apps](https://docs.databricks.com/aws/en/dev-tools/databricks-apps/)
via [Databricks Asset Bundles](https://docs.databricks.com/aws/en/dev-tools/bundles/):
- **Lakebase** (managed PostgreSQL) — the database for every store.
- **UC Volumes** — the artifact store for agent bundles and executor
storage snapshots.
> **Most Databricks users want the managed offering instead.**
> [Omnigent on Databricks](https://docs.databricks.com/aws/en/omnigent/)
> (Beta) runs the server for you, wired to workspace identity,
> Foundation Models, AI Gateway, and MLflow Tracing out of the box.
> Enable the **Omnigent** preview in your workspace settings and follow
> the quickstart there. Use this directory only when you need to
> self-manage the deployment: the managed service is not in your region
> yet, or you need control it does not expose today (custom YAML
> policies, bring-your-own provider API keys, custom egress controls).
The orchestrator at `deploy.py` builds the wheels, generates an app
`pyproject.toml` + `uv.lock`, and then runs
`databricks bundle deploy` + `bundle run` against the bundle config
in `databricks.yml`. App config (Lakebase, UC volume) lives
declaratively in `databricks.yml` — adding or removing a resource is
a YAML edit, not a Python SDK call.
Runs unchanged from a laptop. Re-runnable; every step is idempotent.
## Prerequisites
1. A Databricks workspace with Databricks Apps, Lakebase, and UC
Volumes enabled.
2. The [Databricks CLI](https://docs.databricks.com/aws/en/dev-tools/cli/install.md)
installed and authenticated. Either a CLI profile
(`DATABRICKS_CONFIG_PROFILE=<profile>`) or env-based auth
(`DATABRICKS_HOST` + `DATABRICKS_CLIENT_ID` + `DATABRICKS_CLIENT_SECRET`).
3. The repo's local venv with the `databricks` extra:
`uv sync --extra databricks` (use `uv`, not global pip).
4. Permissions to create or use:
- a Lakebase project (one per app — do not share with other apps);
- a UC volume whose parent catalog/schema can grant access to the
app service principal;
- (optional) Databricks secrets for LLM API keys.
Set your workspace URL in `databricks.yml` under
`targets.prod.workspace.host` (it ships as a `https://example.databricks.com`
placeholder; DAB reads `workspace.host` before resolving variables, so it
must be a literal).
## One-time bootstrap
### 1. Lakebase project (one per app — never share)
Reusing a shared autoscaling project causes the migrate-on-boot hook
to fail with "permission denied for table agents" because the tables
are owned by whoever ran migrations first. Always start fresh:
```python
from databricks.sdk import WorkspaceClient
from databricks.sdk.service.postgres import Project
wc = WorkspaceClient(profile="<your-profile>")
wc.postgres.create_project(project=Project(), project_id="omnigent")
branch = "projects/omnigent/branches/production"
endpoint = f"{branch}/endpoints/primary"
import time
for _ in range(120):
ep = wc.postgres.get_endpoint(name=endpoint)
if ep.status and ep.status.current_state == "ACTIVE":
break
time.sleep(5)
else:
raise TimeoutError(endpoint)
database = next(iter(wc.postgres.list_databases(parent=branch)))
print("database resource path:", database.name)
```
### 2. UC Volumes
```sql
CREATE SCHEMA IF NOT EXISTS main.omnigent;
CREATE VOLUME IF NOT EXISTS main.omnigent.artifacts;
```
The `artifacts` volume is referenced declaratively in `databricks.yml`
(the app resource) via `--var volume_name=…`.
### 3. First deploy — creates the app and its service principal
Run the [Deploy](#deploy) command once. The first
`databricks bundle deploy` creates the app and provisions its service
principal (SP). This first pass will **not** pass its `/health` check
yet: the SP has no Lakebase schema grants, so the migrate-on-boot hook
fails with `permission denied for schema public`. That's expected —
the next step grants those privileges.
### 4. Grant the app SP Lakebase privileges
Now that the app (and its SP) exist, grant the SP the public schema
privileges Alembic needs, then re-run the deploy:
```bash
python deploy/databricks/grant_sp_perms.py \
--app-name omnigent \
--lakebase-endpoint projects/omnigent/branches/production/endpoints/primary \
--database databricks_postgres \
--profile <your-profile>
```
> [!NOTE]
> Lakebase uses two spellings for the same database. The **resource
> path** uses a hyphenated slug — `…/databases/databricks-postgres`
> (what `deploy.py --lakebase-database` and `databricks.yml` want) —
> while the underlying **PostgreSQL database name** uses an underscore,
> `databricks_postgres` (what `grant_sp_perms.py --database` and the
> app's `PGDATABASE` use). Pass each form where shown.
After this one-time grant, re-running the deploy boots the app cleanly
and `/health` returns 200. Subsequent redeploys are a single
`deploy.py` invocation.
## Deploy
```bash
uv run python deploy/databricks/deploy.py \
--app-name omnigent \
--profile <your-profile> \
--lakebase-branch projects/omnigent/branches/production \
--lakebase-database projects/omnigent/branches/production/databases/databricks-postgres \
--volume-name main.omnigent.artifacts
```
The script builds wheels, classifies them by size, copies wheels into
`src/`, regenerates `src/pyproject.toml` and `src/uv.lock`, runs
`databricks bundle deploy --target prod`, runs
`databricks bundle run omnigent --target prod`, and polls `/health`
with backoff until 200.
All Omnigent wheels must fit under the Databricks Apps source
snapshot limit (10 MB). If a wheel exceeds it, rebuild with
`--skip-web-ui` or reduce the wheel size; uv lockfiles cannot point at
UC Volume wheel paths because `uv lock` validates path sources locally.
Re-running is safe — every step is idempotent.
> [!TIP]
> To lock against a private PyPI mirror or proxy instead of public
> PyPI, set `UV_INDEX_URL` before running `deploy.py`.
## Smoke check
`deploy.py` polls `/health` automatically. To check other endpoints:
```bash
TOKEN="$(databricks auth token <your-profile> --output json \
| python -c 'import json, sys; print(json.load(sys.stdin)["access_token"])')"
curl --http1.1 -fsS \
-H "Authorization: Bearer ${TOKEN}" \
https://<app>.databricksapps.com/health
```
## How it works
### Authentication
The app runs as a Databricks service principal. Credentials are
managed automatically:
- **Lakebase** — OAuth tokens generated via
`WorkspaceClient.postgres.generate_database_credential()`, injected
into every new SQLAlchemy connection via a class-level
`do_connect` event hook in `src/app.py`.
- **UC Volumes** — Workspace credentials used by the Databricks SDK
(ambient in Apps).
- **TUI / API access** — Browser-based OAuth using the
`databricks-cli` OIDC client with PKCE.
The Databricks Apps proxy injects `X-Forwarded-Email` on every
request, so the app pins `OMNIGENT_AUTH_PROVIDER=header` (see
`src/app.py`).
> [!IMPORTANT]
> Header auth trusts the `X-Forwarded-Email` header verbatim. This is
> safe **only** because the Databricks Apps platform terminates auth at
> its proxy, strips any client-supplied copy of the header, and the app
> port is never reachable except through that proxy. Don't expose the
> app process directly (e.g. a port forward or alternate ingress that
> bypasses the proxy): a caller who can set the header themselves could
> then impersonate any user. If you front the app with anything other
> than the standard Apps proxy, ensure it sanitizes the header too.
### Token lifecycle
Lakebase OAuth tokens expire after 60 minutes. The SQLAlchemy
connection pool recycles connections every 5 minutes by default
(configurable via `AP_POOL_RECYCLE_SECONDS`), ensuring fresh tokens
on new connections.
### Storage
| Component | Backend | Purpose |
|---|---|---|
| Agent specs, tasks, conversations | Lakebase (PostgreSQL) | Durable metadata |
| Agent bundles, executor snapshots | UC Volumes | Binary blob storage |
| DBOS workflow state | Lakebase (same DB) | Workflow recovery |
| Executor working dirs | Local ephemeral disk | Cache (restored from UC Volumes) |
## Configuration reference
Environment variables read by `src/app.py`:
| Variable | Source | Description |
|---|---|---|
| `PGHOST` | Databricks runtime | Lakebase hostname |
| `PGPORT` | Databricks runtime | Lakebase port (default 5432) |
| `PGDATABASE` | Databricks runtime | Lakebase database name |
| `PGUSER` | Databricks runtime | Lakebase user (service principal) |
| `PGSSLMODE` | Databricks runtime | SSL mode (default `require`) |
| `AP_LAKEBASE_ENDPOINT` | app resource `valueFrom: postgres` | Lakebase endpoint for token generation |
| `AP_ARTIFACT_VOLUME_PATH` | app resource `valueFrom: artifact_volume` | UC Volume path for artifacts |
| `DATABRICKS_APP_PORT` | Databricks runtime | App port (default 8000) |
| `AP_POOL_RECYCLE_SECONDS` | Optional | Connection pool recycle interval (default 300) |
## Multi-app safety — one bundle, many apps
The same bundle directory can deploy many apps (one per `--app-name`).
Terraform can only delete or replace what is tracked in the state it
loads, so the blast radius of a deploy is exactly that state file.
- **Remote state is per app.** `targets.<t>.workspace.root_path` ends
in `${var.app_name}`, so `--app-name X` reads and writes state only
under `.bundle/omnigent/X`. A deploy of X cannot mutate app Y.
- **The app resource's `name` is `${var.app_name}`.** If the loaded
state tracks app X but you pass `app_name=Y`, terraform sees a name
change and plans a **destroy of X + create of Y**. Never bind the
bundle resource to one app and then deploy with a different
`--app-name`.
- The **local** cache at `deploy/databricks/.databricks/bundle/<target>/`
is per-*target*. Before deploying a *different* app on a target
you've used before, drop it: `rm -rf deploy/databricks/.databricks/bundle/<target>`
(it's only a cache; the per-app remote state is the source of truth).
If a `bundle deploy` plan ever shows a delete or replace of a
`databricks_app`, abort and re-check the bind and `--app-name`
routine redeploys only ever update in place.
## Common deploy modes
```bash
# Iterate without rebuilding wheels (reuses dist/; useful when you only
# changed app.py / app.yaml). Skips the clean-tree check.
uv run python deploy/databricks/deploy.py --skip-build --allow-dirty ...
# API-only deploy (drops the SPA from the main wheel).
uv run python deploy/databricks/deploy.py --skip-web-ui ...
```
## Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| Deploy refuses: "working tree has uncommitted changes" / "HEAD is not at origin/main" | Clean-tree assertion | Commit/stash, `git checkout main && git pull`, or pass `--allow-dirty` |
| `bundle deploy` fails: "Resource already managed by Terraform" | App already bound to another bundle directory | Run from that directory, or unbind: `databricks bundle deployment unbind omnigent` |
| `bundle deploy` fails: "An app with the same name already exists" | App exists but isn't bound to this bundle (or a stale per-target local cache from a *different* app made `deploy.py` skip the bind) | `rm -rf deploy/databricks/.databricks/bundle/<target>`, then bind: `databricks bundle deployment bind omnigent <app-name> --target <target> --auto-approve --var ...` |
| App fails "Error installing packages"; `/logz` shows "Ignoring existing lockfile due to … exclude newer …" then a PyPI fetch timeout | The Apps runtime pins a global uv `exclude-newer` cutoff; a lock generated without the matching option is re-resolved in-container, where PyPI is unreachable | Read the cutoff from `/logz` ("change of exclude newer timestamp from X to Y") and redeploy with `UV_EXCLUDE_NEWER=<cutoff>` in the environment |
| `permission denied for table agents` | Lakebase tables owned by wrong user | Connect as the owner and `DROP TABLE … CASCADE`; redeploy |
| `schema "dbos" already exists` | Same for the DBOS schema | `DROP SCHEMA dbos CASCADE` and redeploy |
| `permission denied for schema public` | App SP missing schema grants | Run `grant_sp_perms.py` (one-time) |
| `Field 'spec.role' cannot be empty` | Lakebase requires explicit role for extra databases | Use the project's default database; don't create extras |
| Deploy refuses because a wheel is over 10 MB | uv app payload requires local wheel path sources | Rebuild with `--skip-web-ui` or reduce wheel size |
| App starts cleanly but the first agent request 403s on the artifact volume | App SP has `WRITE_VOLUME` on the leaf but no `USE_CATALOG` / `USE_SCHEMA` on the parents | `deploy.py` grants both automatically — for a fresh catalog, redeploy or grant manually via `databricks grants update` |
## Files in this directory
| File | Purpose |
|---|---|
| `deploy.py` | Orchestrator. Single entry point. |
| `databricks.yml` | DAB bundle config. Declares the app + its resources. |
| `build.sh` | Cleans static, builds the web UI, builds three wheels. |
| `grant_sp_perms.py` | One-time Lakebase `public` schema grant for the app SP. |
| `src/app.py` | The app process. SQLAlchemy `do_connect` token hook + Alembic-on-boot + uvicorn. |
| `src/app.yaml` | App startup config — command + env-var wiring. |
| `src/pyproject.toml` / `src/uv.lock` | Regenerated per deploy; not committed (they pin the per-deploy wheel version). |
## See also
- [`databricks.yml`](./databricks.yml) — DAB bundle config.
- [Databricks Apps docs](https://docs.databricks.com/aws/en/dev-tools/databricks-apps/).
- [Databricks Asset Bundles docs](https://docs.databricks.com/aws/en/dev-tools/bundles/).
+50
View File
@@ -0,0 +1,50 @@
#!/usr/bin/env bash
# Build the wheels and optional web UI assets needed for a Databricks Apps
# deployment of Omnigent.
#
# Inputs:
# SKIP_WEB_UI=1 Skip the web SPA build for API-only deployments.
#
# Outputs:
# dist/omnigent-<version>-py3-none-any.whl
# dist/omnigent_client-<version>-py3-none-any.whl
# dist/omnigent_ui_sdk-<version>-py3-none-any.whl
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# This file lives at deploy/databricks/ — two levels deep — so the repo root is
# two parents up.
REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
cd "${REPO_ROOT}"
# Each Vite build emits uniquely-hashed JS chunk filenames. Without a
# sweep, orphaned chunks from prior builds accumulate in the static
# dir, end up in the main wheel, and push it over the 10 MB Workspace
# upload cap. Always start from a clean slate.
echo "==> Cleaning stale static assets and build outputs"
rm -rf omnigent/server/static/web-ui dist build omnigent.egg-info
if [[ "${SKIP_WEB_UI:-}" != "1" ]]; then
echo "==> Building web SPA into omnigent/server/static/web-ui/"
cd web
npm install
npm run build
cd "${REPO_ROOT}"
else
echo "==> SKIP_WEB_UI=1: skipping web build"
fi
echo "==> Building omnigent-client wheel"
uv build --wheel --out-dir dist/ sdks/python-client/
echo "==> Building omnigent-ui-sdk wheel"
uv build --wheel --out-dir dist/ sdks/ui/
echo "==> Building omnigent wheel"
uv build --wheel --out-dir dist/ .
echo ""
echo "Built wheels:"
ls -1 dist/
+84
View File
@@ -0,0 +1,84 @@
# Databricks Asset Bundle config for the Omnigent Databricks App.
#
# Every knob the app exposes lives here. deploy.py wraps `databricks
# bundle deploy` + `bundle run`; per-app values are passed as `--var`
# pairs (see deploy.py / README.md).
bundle:
name: omnigent
variables:
app_name:
description: "Databricks App name (e.g. omnigent)."
lakebase_branch:
description: "projects/<app>/branches/production"
lakebase_database:
description: "projects/<app>/branches/production/databases/databricks-postgres"
volume_name:
description: "<catalog>.<schema>.<volume> for artifact storage"
otel_table_schema:
description: >
UC schema (catalog.schema) holding the OTel destination tables.
The platform writes to <schema>.otel_logs, otel_metrics, otel_spans.
default: main.omnigent_logs
resources:
apps:
# Resource key must match _BUNDLE_RESOURCE_KEY in deploy.py. The app's
# display name comes from ${var.app_name}.
omnigent:
name: ${var.app_name}
description: "Omnigent server backed by Lakebase + UC Volumes."
source_code_path: ./src
# compute_size is intentionally NOT declared here. Terraform's
# databricks_app provider uses an apps update API that rejects
# compute_size changes ("not supported in this update API"). We
# set it out-of-band via apps.create_update in deploy.py before
# the bundle deploy runs.
config:
command: ["opentelemetry-instrument", "python", "app.py"]
env:
- name: AP_LAKEBASE_ENDPOINT
value_from: postgres
- name: AP_ARTIFACT_VOLUME_PATH
value_from: artifact_volume
- name: OTEL_TRACES_SAMPLER
value: 'always_on'
resources:
- name: postgres
postgres:
branch: ${var.lakebase_branch}
database: ${var.lakebase_database}
permission: CAN_CONNECT_AND_CREATE
- name: artifact_volume
uc_securable:
securable_full_name: ${var.volume_name}
securable_type: VOLUME
permission: WRITE_VOLUME
targets:
# One target per workspace. workspace.host must be literal (DAB reads it
# before resolving vars). deploy.py selects via --target. To deploy to a
# different workspace, add another target block here and pass
# `deploy.py --target <name>`.
prod:
mode: production
workspace:
host: https://example.databricks.com # ← your workspace URL
# Isolate state per app so one bundle can deploy multiple apps.
root_path: /Workspace/Users/${workspace.current_user.userName}/.bundle/${bundle.name}/${var.app_name}
resources:
apps:
omnigent:
# Optional: Databricks Apps platform OTel export. The platform
# auto-injects OTEL_EXPORTER_OTLP_ENDPOINT into the app container
# so any OTLP emitter (including telemetry.init() in src/app.py)
# routes to the configured UC tables. The export API rejects tables
# in default storage ("Cannot export App telemetry to tables in
# default storage"), so this requires a workspace with a custom
# storage location — drop this block if yours has none.
telemetry_export_destinations:
- unity_catalog:
logs_table: ${var.otel_table_schema}.otel_logs
metrics_table: ${var.otel_table_schema}.otel_metrics
traces_table: ${var.otel_table_schema}.otel_spans
+933
View File
@@ -0,0 +1,933 @@
#!/usr/bin/env python3
"""Deploy Omnigent to a Databricks App via Databricks Asset Bundles.
End-to-end orchestrator that wraps `databricks bundle deploy` +
`databricks bundle run`. The build pieces (version stamp, wheel
build, uv lock generation, stale-wheel sweep) stay in Python; the
deploy itself is a DAB so the app's resource definition (Lakebase,
UC volume) lives declaratively in ``databricks.yml``.
Runs unchanged from a laptop or from CI. Re-runnable;
every step is idempotent.
Usage example:
python deploy/databricks/deploy.py \\
--app-name omnigent --profile <your-profile> \\
--lakebase-branch projects/omnigent/branches/production \\
--lakebase-database \\
projects/omnigent/branches/production/databases/databricks-postgres \\
--volume-name main.omnigent.artifacts
See ``README.md`` in the same directory for the full guide,
including first-time infrastructure setup.
"""
from __future__ import annotations
import argparse
import os
import re
import shutil
import subprocess
import sys
import time
from collections.abc import Iterable
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from databricks.sdk import WorkspaceClient
_WORKSPACE_WHEEL_LIMIT_BYTES = 10 * 1024 * 1024
_APP_REQUIRES_PYTHON = ">=3.12,<3.13"
# Public PyPI by default. Set UV_INDEX_URL to lock against a private mirror or
# proxy instead (see run_uv_lock).
_UV_DEFAULT_INDEX_URL = "https://pypi.org/simple"
# Leaving these in the env when we hand off to the CLI/SDK can
# silently route us to the wrong workspace, or upload code under the
# wrong account. The deploy must use --profile / DATABRICKS_HOST +
# DATABRICKS_CLIENT_ID explicitly.
_ENV_VARS_TO_CLEAR = (
"DATABRICKS_TOKEN",
"ANTHROPIC_API_KEY",
"CODEX",
"CLAUDE_CODE",
)
# Must match the `resources.apps.<key>` and `bundle.name` in databricks.yml.
_BUNDLE_RESOURCE_KEY = "omnigent"
_WHEEL_PREFIXES = ("omnigent-", "omnigent_client-", "omnigent_ui_sdk-")
def _log(msg: str) -> None:
print(f"[deploy] {msg}", flush=True)
def _repo_root() -> Path:
# deploy/databricks/deploy.py → repo root is two parents up.
return Path(__file__).resolve().parents[2]
def _deploy_dir() -> Path:
return Path(__file__).resolve().parent
def _src_dir() -> Path:
return _deploy_dir() / "src"
def _pyproject_paths() -> list[Path]:
root = _repo_root()
return [
root / "pyproject.toml",
root / "sdks" / "python-client" / "pyproject.toml",
root / "sdks" / "ui" / "pyproject.toml",
]
def _read_base_version() -> str:
"""Read the base version from the top-level pyproject.toml.
The three pyprojects share the same version; we only need to read
one. The base value is the on-disk version; deploys append a
`.postN` suffix so pip's wheel cache treats every deploy as a
distinct release.
"""
text = (_repo_root() / "pyproject.toml").read_text()
match = re.search(r'^version\s*=\s*"([^"]+)"', text, re.MULTILINE)
if not match:
raise RuntimeError("could not find version in pyproject.toml")
return match.group(1)
def _compute_deploy_version(base: str, explicit: str | None) -> str:
if explicit:
# Caller knows what they want — let it through after a sanity check.
if not re.match(r"^\d+(\.\d+)*(\.dev\d+|\.post\d+|[+\-][\w.]+)?$", explicit):
raise SystemExit(f"--version {explicit!r} is not a recognizable PEP 440 version")
return explicit
# Strip any existing `.postN` / `.devN` suffix so we don't stack
# them if a prior deploy left pyproject.toml dirty (or someone
# committed the bumped value). Without this, `0.1.0.post<old>`
# would become `0.1.0.post<old>.post<new>` which isn't valid
# PEP 440 and fails the wheel build.
base = re.sub(r"(\.post\d+|\.dev\d+)+$", "", base)
# Post-release, not dev: pip treats `.dev` as a pre-release and
# ignores it when resolving `>=` constraints, so a deploy that
# bumps via `.dev` clashes with `omnigent-ui-sdk` declaring
# `omnigent-client>=0.1.0`. `.post` is a final release and
# sorts strictly above the base.
return f"{base}.post{int(time.time())}"
def set_version_in_pyproject(path: Path, new_version: str) -> str:
"""Rewrite the version line and lockstep sibling pins in a pyproject.
The release process pins the sibling SDK packages in lockstep
(e.g. ``"omnigent-client==0.1.0rc2"``). Stamping only the
``version = "..."`` line would leave those exact pins pointing at
the unstamped base version, which no built wheel carries — so the
app-level ``uv lock`` becomes unsatisfiable. Rewrite both.
:param path: Pyproject file to rewrite, e.g.
``<repo>/sdks/ui/pyproject.toml``.
:param new_version: Stamped deploy version, e.g. ``"0.1.0rc2.post123"``.
:returns: The original file text, for restore after the build.
"""
original = path.read_text()
updated, count = re.subn(
r'(?m)^version\s*=\s*"[^"]+"',
f'version = "{new_version}"',
original,
count=1,
)
if count != 1:
raise RuntimeError(f"could not rewrite version in {path}")
# The lockstep graph is circular: omnigent pins both SDKs, the
# client pins omnigent back, and the ui-sdk pins the client. All
# three names must be stamped or the resolver still dead-ends.
updated = re.sub(
r'"(omnigent(?:-client|-ui-sdk)?)==[^"]+"',
rf'"\1=={new_version}"',
updated,
)
path.write_text(updated)
return original
def _stamp_versions(new_version: str) -> dict[Path, str]:
"""Stamp `new_version` into all three pyprojects. Returns originals for restore."""
backups: dict[Path, str] = {}
for path in _pyproject_paths():
backups[path] = set_version_in_pyproject(path, new_version)
return backups
def _restore_versions(backups: dict[Path, str]) -> None:
for path, original in backups.items():
path.write_text(original)
def _clean_build_artifacts() -> None:
"""Remove stale build outputs.
Old Vite bundles in ``omnigent/server/static/web-ui/``
accumulate uniquely-hashed JS chunk filenames between builds.
Without this sweep, orphan files get bundled into the main wheel
and push it over the 10 MB Workspace upload limit.
"""
root = _repo_root()
targets = [
root / "omnigent" / "server" / "static" / "web-ui",
root / "dist",
root / "build",
root / "omnigent.egg-info",
]
for target in targets:
if target.exists():
_log(f"cleaning {target.relative_to(root)}")
shutil.rmtree(target)
def _build_wheels(skip_web_ui: bool) -> list[Path]:
"""Invoke build.sh and return the resulting wheel paths."""
root = _repo_root()
build_sh = _deploy_dir() / "build.sh"
env = os.environ.copy()
if skip_web_ui:
env["SKIP_WEB_UI"] = "1"
_log(f"$ {build_sh}" + (" (SKIP_WEB_UI=1)" if skip_web_ui else ""))
subprocess.run([str(build_sh)], cwd=root, env=env, check=True)
wheels = sorted((root / "dist").glob("*.whl"))
if not wheels:
raise RuntimeError("build.sh produced no wheels")
return wheels
@dataclass(frozen=True)
class _ClassifiedWheels:
"""Result of sorting built wheels by size for upload routing.
:param main: The top-level ``omnigent`` wheel — always uploaded
with the ``[databricks]`` extra.
:param small: Wheels ≤ 10 MB. Uploaded into the bundle's
``source_code_path`` and referenced by relative path.
:param oversize: Wheels > 10 MB. These cannot be used by the
uv-based app payload because ``uv lock`` validates local path
sources before the bundle is synced.
"""
main: Path
small: list[Path]
oversize: list[Path]
def _classify_wheels(wheels: Iterable[Path]) -> _ClassifiedWheels:
main_wheel = next(w for w in wheels if w.name.startswith("omnigent-"))
small: list[Path] = []
oversize: list[Path] = []
for wheel in wheels:
if wheel.stat().st_size <= _WORKSPACE_WHEEL_LIMIT_BYTES:
small.append(wheel)
else:
oversize.append(wheel)
return _ClassifiedWheels(main=main_wheel, small=small, oversize=oversize)
def _wheel_version(wheel: Path, prefix: str) -> str:
"""Extract a deploy version from an Omnigent wheel filename.
:param wheel: Built wheel path, e.g.
``dist/omnigent-0.1.0.post123-py3-none-any.whl``.
:param prefix: Expected wheel filename prefix, e.g. ``"omnigent-"``.
:returns: Version embedded in the wheel filename, e.g.
``"0.1.0.post123"``.
:raises RuntimeError: If the wheel filename does not match the
deploy script's expected wheel naming convention.
"""
pattern = rf"^{re.escape(prefix)}(?P<version>.+)-py3-none-any\.whl$"
match = re.match(pattern, wheel.name)
if not match:
raise RuntimeError(f"unexpected wheel name for {prefix}: {wheel.name}")
return match.group("version")
def _derive_deploy_version_from_wheels(wheels: list[Path]) -> str:
"""Derive the deploy version from reused ``dist/`` wheels.
:param wheels: Wheel files from ``dist/``.
:returns: Shared wheel version, e.g. ``"0.1.0.post123"``.
:raises RuntimeError: If any required wheel is missing or the
wheel versions do not match.
"""
expected_prefixes = ("omnigent-", "omnigent_client-", "omnigent_ui_sdk-")
versions = []
for prefix in expected_prefixes:
matching = [wheel for wheel in wheels if wheel.name.startswith(prefix)]
if len(matching) != 1:
raise RuntimeError(f"expected exactly one {prefix} wheel, found {len(matching)}")
versions.append(_wheel_version(matching[0], prefix))
if len(set(versions)) != 1:
raise RuntimeError(f"wheel versions do not match: {versions}")
return versions[0]
def _sweep_local_src_wheels(keep: set[str]) -> None:
"""Delete Omnigent wheels from src/ whose filename is not in `keep`.
Old deploys accumulate wheels here. Databricks Apps installs the
source directory as a project, so stale wheels can keep local path
sources or lockfile entries alive if we leave them around. Trim to
exactly the wheels we just built.
"""
src = _src_dir()
if not src.exists():
return
for entry in src.iterdir():
if not entry.is_file() or entry.suffix != ".whl":
continue
if not any(entry.name.startswith(p) for p in _WHEEL_PREFIXES):
continue
if entry.name in keep:
continue
_log(f"removing stale local wheel {entry.relative_to(_repo_root())}")
entry.unlink()
def _toml_string(value: str) -> str:
"""Return ``value`` encoded as a TOML basic string.
:param value: String to encode for generated TOML, e.g.
``"./omnigent-0.1.0-py3-none-any.whl"``.
:returns: TOML string literal with quotes and backslashes escaped.
"""
return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"'
def _wheel_source_path(wheel: Path) -> str:
"""Return the path Databricks Apps should use for a wheel source.
:param wheel: Built wheel path, e.g.
``dist/omnigent-0.1.0-py3-none-any.whl``.
:returns: Relative source path for bundled wheels.
:raises SystemExit: If ``wheel`` is oversize.
"""
if wheel.stat().st_size <= _WORKSPACE_WHEEL_LIMIT_BYTES:
return f"./{wheel.name}"
raise SystemExit(
f"wheel {wheel.name} is over 10 MB; uv-based Databricks Apps "
"deploys require all wheels in the app source snapshot"
)
def _uv_source_lines(
main_wheel: Path,
small_wheels: list[Path],
oversize_wheels: list[Path],
) -> list[str]:
"""Build ``[tool.uv.sources]`` lines for the three deploy wheels.
:param main_wheel: Top-level ``omnigent`` wheel path, e.g.
``dist/omnigent-0.1.0-py3-none-any.whl``.
:param small_wheels: Wheels copied into the app source directory.
:param oversize_wheels: Wheels too large for the app source directory.
:returns: TOML lines mapping package names to wheel paths.
"""
wheels = {wheel.name: wheel for wheel in [*small_wheels, *oversize_wheels]}
if main_wheel.name not in wheels:
raise RuntimeError(f"main wheel {main_wheel.name} was not classified for deployment")
source_lines = []
for package_name, wheel_prefix in (
("omnigent", "omnigent-"),
("omnigent-client", "omnigent_client-"),
("omnigent-ui-sdk", "omnigent_ui_sdk-"),
):
wheel = next(wheel for name, wheel in wheels.items() if name.startswith(wheel_prefix))
source = _wheel_source_path(wheel)
source_lines.append(f"{package_name} = {{ path = {_toml_string(source)} }}")
return source_lines
def build_uv_pyproject(
main_wheel: Path,
small_wheels: list[Path],
oversize_wheels: list[Path],
deploy_version: str,
) -> str:
"""Compose the app-level ``pyproject.toml`` for Databricks Apps.
:param main_wheel: Top-level ``omnigent`` wheel path, e.g.
``dist/omnigent-0.1.0-py3-none-any.whl``.
:param small_wheels: Wheels copied into the app source directory.
:param oversize_wheels: Wheels too large for the app source directory.
:param deploy_version: Version stamped into the wheels, e.g.
``"0.1.0.post123"``.
:returns: Complete TOML text for ``src/pyproject.toml``.
"""
source_lines = _uv_source_lines(main_wheel, small_wheels, oversize_wheels)
dependencies = [
f'"omnigent[databricks]=={deploy_version}"',
f'"omnigent-client=={deploy_version}"',
f'"omnigent-ui-sdk=={deploy_version}"',
]
return (
"[project]\n"
'name = "omnigent-databricks-app"\n'
'version = "0.0.0"\n'
f"requires-python = {_toml_string(_APP_REQUIRES_PYTHON)}\n"
"dependencies = [\n"
+ "".join(f" {dependency},\n" for dependency in dependencies)
+ "]\n\n"
"[tool.uv.sources]\n" + "\n".join(source_lines) + "\n"
)
def run_uv_lock(src: Path) -> None:
"""Generate ``uv.lock`` for the Databricks Apps source directory.
:param src: App source directory containing ``pyproject.toml``,
e.g. ``deploy/databricks/src``.
"""
# Honor a caller-supplied UV_INDEX_URL (e.g. a private mirror or proxy);
# otherwise default to public PyPI. UV_INDEX / UV_DEFAULT_INDEX are dropped
# so a stray value in the shell can't shadow the index we lock against.
index_url = os.environ.get("UV_INDEX_URL") or _UV_DEFAULT_INDEX_URL
env = os.environ.copy()
env.pop("UV_INDEX", None)
env.pop("UV_DEFAULT_INDEX", None)
env["UV_INDEX_URL"] = index_url
_log(f"uv lock --python 3.12 --index-url {index_url}")
subprocess.run(
["uv", "lock", "--python", "3.12", "--index-url", index_url],
cwd=src,
env=env,
check=True,
)
def write_uv_dependency_files(
src: Path,
main_wheel: Path,
small_wheels: list[Path],
oversize_wheels: list[Path],
deploy_version: str,
) -> None:
"""Write the uv dependency files Databricks Apps should install.
:param src: App source directory, e.g. ``deploy/databricks/src``.
:param main_wheel: Top-level ``omnigent`` wheel path, e.g.
``dist/omnigent-0.1.0-py3-none-any.whl``.
:param small_wheels: Wheels copied into ``src``.
:param oversize_wheels: Wheels too large for ``src``.
:param deploy_version: Version stamped into the wheels, e.g.
``"0.1.0.post123"``.
"""
requirements = src / "requirements.txt"
if requirements.exists():
_log(f"removing {requirements}; Databricks Apps must use uv")
requirements.unlink()
pyproject = build_uv_pyproject(
main_wheel,
small_wheels,
oversize_wheels,
deploy_version,
)
(src / "pyproject.toml").write_text(pyproject)
_log("src/pyproject.toml:\n" + pyproject)
run_uv_lock(src)
def _smoke_check(wc: WorkspaceClient, app_url: str) -> None:
"""Poll /health on the running app and fail if it never returns 200.
``databricks bundle run`` returns as soon as the app start is
signalled, but uvicorn takes a few extra seconds to bind. Retry
up to a minute to ride out the warm-up; surface the most recent
error if it never goes green.
"""
import urllib.error
import urllib.request
token = wc.config.authenticate()["Authorization"].removeprefix("Bearer ").strip()
url = f"{app_url.rstrip('/')}/health"
last_err: str = ""
for attempt in range(12):
req = urllib.request.Request(url, headers={"Authorization": f"Bearer {token}"})
try:
with urllib.request.urlopen(req, timeout=30) as resp:
body = resp.read().decode()
if resp.status == 200:
_log(f"/health ok: {body!r}")
return
last_err = f"HTTP {resp.status}: {body!r}"
except urllib.error.HTTPError as exc:
last_err = f"HTTP {exc.code}: {exc.reason}"
except urllib.error.URLError as exc:
last_err = f"URLError: {exc.reason}"
if attempt < 11:
time.sleep(5)
raise RuntimeError(f"/health did not return 200 within 60s; last: {last_err}")
def _assert_clean_tree(skip: bool) -> None:
"""Refuse to deploy if the working tree has uncommitted changes or
HEAD is not at origin/main.
Pass ``--allow-dirty`` to override. Reason: deploys are intended
to come from a known commit on main, so the deployed app code is
reproducible from git history. A dirty tree means whatever we
deploy is not reachable from any commit, which is the cause of
nearly every "wait, what's actually live?" debugging session.
"""
root = _repo_root()
if skip:
_log("--allow-dirty: skipping clean-tree assertion")
return
status = subprocess.run(
["git", "status", "--porcelain"],
cwd=root,
check=True,
capture_output=True,
text=True,
).stdout
if status.strip():
raise SystemExit(
"working tree has uncommitted changes:\n"
+ status
+ "\ncommit or stash, or pass --allow-dirty to override."
)
subprocess.run(["git", "fetch", "origin", "main", "--quiet"], cwd=root, check=True)
head = subprocess.run(
["git", "rev-parse", "HEAD"],
cwd=root,
check=True,
capture_output=True,
text=True,
).stdout.strip()
main = subprocess.run(
["git", "rev-parse", "origin/main"],
cwd=root,
check=True,
capture_output=True,
text=True,
).stdout.strip()
if head != main:
raise SystemExit(
f"HEAD ({head}) is not at origin/main ({main}); "
f"rebase or pass --allow-dirty to override."
)
_log(f"clean tree at origin/main {head[:12]}")
def _parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--app-name",
required=True,
help="Databricks App name, e.g. 'omnigent'.",
)
parser.add_argument(
"--lakebase-branch",
required=True,
help=("Full Lakebase branch resource path, e.g. 'projects/omnigent/branches/production'."),
)
parser.add_argument(
"--lakebase-database",
required=True,
help=(
"Full Lakebase database resource path, e.g. "
"'projects/omnigent/branches/production/databases/databricks-postgres'."
),
)
parser.add_argument(
"--volume-name",
required=True,
help=(
"UC Volume full name (catalog.schema.volume) for artifact storage, "
"e.g. 'main.omnigent.artifacts'."
),
)
parser.add_argument(
"--compute-size",
default="LARGE",
choices=["SMALL", "MEDIUM", "LARGE"],
help="App compute size. Pinned via databricks.yml so it doesn't drift.",
)
parser.add_argument(
"--otel-table-schema",
default="main.omnigent_logs",
help=(
"UC schema (catalog.schema) holding the OTel destination tables. "
"The Databricks Apps platform writes logs/metrics/spans to "
"<schema>.otel_{logs,metrics,spans}."
),
)
parser.add_argument(
"--target",
default="prod",
help=(
"DAB target from databricks.yml selecting the destination "
"workspace, e.g. 'prod'. The authenticating identity "
"(--profile or env) must belong to the target's workspace."
),
)
parser.add_argument(
"--profile",
default=None,
help=(
"Databricks CLI profile to authenticate with. Omit when running "
"with env-based auth (DATABRICKS_HOST + DATABRICKS_CLIENT_ID + "
"DATABRICKS_CLIENT_SECRET)."
),
)
parser.add_argument(
"--version",
default=None,
help=(
"Explicit PEP 440 version to stamp into pyprojects for this "
"deploy. Default: <base-version>.post<unix-ts>."
),
)
parser.add_argument(
"--skip-build",
action="store_true",
help="Reuse existing dist/ wheels — skip the npm + uv build.",
)
parser.add_argument(
"--skip-web-ui",
action="store_true",
help="Build without the SPA (API-only deploy).",
)
parser.add_argument(
"--app-url",
default=None,
help=(
"Override the smoke-check URL. By default the script reads "
"the URL from the App resource returned by the SDK."
),
)
parser.add_argument(
"--no-smoke-check",
action="store_true",
help="Skip the post-deploy /health check.",
)
parser.add_argument(
"--keep-version-bump",
action="store_true",
help=(
"Don't restore pyproject.toml files after build. Useful "
"when you want the bumped version to land in a commit "
"(e.g. CI auto-tagged release builds)."
),
)
parser.add_argument(
"--allow-dirty",
action="store_true",
help=(
"Skip the assertion that the working tree is clean and at "
"origin/main. Deploys are normally required to be from a "
"known commit on main."
),
)
return parser.parse_args()
def _clear_env_vars() -> None:
for name in _ENV_VARS_TO_CLEAR:
if name in os.environ:
_log(f"unsetting {name} to avoid leaking into the SDK")
del os.environ[name]
def _ensure_bound(args: argparse.Namespace) -> None:
"""Bind the bundle to the named app if it exists but is unbound.
If the app already exists in the workspace and the bundle has no
Terraform state for it (always true on a fresh checkout),
``bundle deploy`` fails with "An app with the same name already
exists". The fix is ``bundle deployment bind``, which adopts the
existing app into the bundle's state.
There's no clean way to ask "is the bundle already bound to this
app" — ``bundle summary`` reflects what's *declared* in the YAML,
not what's tracked by Terraform. So we just always try to bind
when the app exists and treat "already managed by Terraform" as
success.
"""
# Late-import so --help works without the SDK.
from databricks.sdk import WorkspaceClient
from databricks.sdk.errors.platform import NotFound
wc = WorkspaceClient(profile=args.profile) if args.profile else WorkspaceClient()
try:
wc.apps.get(name=args.app_name)
except NotFound:
_log(f"app {args.app_name!r} not found; bundle deploy will create it")
return
_log(f"binding bundle resource {_BUNDLE_RESOURCE_KEY!r} → app {args.app_name!r}")
result = subprocess.run(
[
"databricks",
"bundle",
"deployment",
"bind",
_BUNDLE_RESOURCE_KEY,
args.app_name,
"--target",
args.target,
"--auto-approve",
*_profile_arg(args),
*_bundle_vars(args),
],
cwd=_deploy_dir(),
capture_output=True,
text=True,
)
if result.returncode == 0:
return
combined = "".join(stream for stream in (result.stdout, result.stderr) if stream)
if "already managed" in combined.lower() or "already bound" in combined.lower():
_log("bundle already bound to this app; continuing")
return
sys.stderr.write(combined)
raise SystemExit(f"bundle deployment bind failed (exit {result.returncode})")
def _ensure_compute_size(
wc: WorkspaceClient,
app_name: str,
desired: str,
) -> None:
"""Resize the app to `desired` if it's not already there.
The bundle's Terraform databricks_app resource can't update
compute_size on an existing app (the old apps update API rejects
it). The newer ``apps.create_update`` endpoint can. Run that here
so the subsequent bundle deploy sees no diff and doesn't error.
"""
from databricks.sdk.errors.platform import NotFound
from databricks.sdk.service.apps import App, ComputeSize
try:
current = wc.apps.get(name=app_name)
except NotFound:
_log(f"app {app_name!r} not found; bundle deploy will create at {desired}")
return
current_value = current.compute_size.value if current.compute_size else None
if current_value == desired:
_log(f"compute_size already {desired}; skipping resize")
return
_log(f"resizing app {app_name!r}: {current_value}{desired}")
wc.apps.create_update_and_wait(
app_name=app_name,
update_mask="compute_size",
app=App(name=app_name, compute_size=ComputeSize(desired)),
)
def _bundle_vars(args: argparse.Namespace) -> list[str]:
"""CLI args to pass to `databricks bundle` as --var pairs."""
return [
"--var",
f"app_name={args.app_name}",
"--var",
f"lakebase_branch={args.lakebase_branch}",
"--var",
f"lakebase_database={args.lakebase_database}",
"--var",
f"volume_name={args.volume_name}",
"--var",
f"otel_table_schema={args.otel_table_schema}",
]
def _profile_arg(args: argparse.Namespace) -> list[str]:
return ["--profile", args.profile] if args.profile else []
def _ensure_app_sp_uc_traversal(
args: argparse.Namespace,
app_sp: str | None,
) -> None:
"""Grant USE_CATALOG + USE_SCHEMA to the app SP on the volume's parents.
Apps' ``uc_securable`` only grants the leaf (WRITE_VOLUME); the
SP can boot but 403s on first volume read if the parent catalog
doesn't grant USE to ``account users``. Idempotent.
"""
if not app_sp:
_log("app SP not resolved yet; skipping UC traversal grants")
return
parts = args.volume_name.split(".")
if len(parts) != 3:
raise SystemExit(f"--volume-name {args.volume_name!r} must be catalog.schema.volume")
catalog, schema_only, _ = parts
schema_fqn = f"{catalog}.{schema_only}"
import json as _json
for kind, fqn, priv in (
("catalog", catalog, "USE_CATALOG"),
("schema", schema_fqn, "USE_SCHEMA"),
):
_log(f"granting {priv} on {kind} {fqn} → app SP {app_sp}")
payload = _json.dumps({"changes": [{"principal": app_sp, "add": [priv]}]})
subprocess.run(
[
"databricks",
"grants",
"update",
kind,
fqn,
*_profile_arg(args),
"--json",
payload,
],
check=True,
capture_output=True,
text=True,
)
def main() -> int:
args = _parse_args()
_clear_env_vars()
_assert_clean_tree(skip=args.allow_dirty)
base_version = _read_base_version()
deploy_version = _compute_deploy_version(base_version, args.version)
_log(f"deploy version: {deploy_version} (base: {base_version})")
backups: dict[Path, str] = {}
try:
if not args.skip_build:
_clean_build_artifacts()
backups = _stamp_versions(deploy_version)
wheels = _build_wheels(skip_web_ui=args.skip_web_ui)
else:
dist = _repo_root() / "dist"
wheels = sorted(dist.glob("*.whl"))
if not wheels:
raise SystemExit("--skip-build was set but dist/ has no wheels to redeploy")
wheel_version = _derive_deploy_version_from_wheels(wheels)
if args.version and args.version != wheel_version:
raise SystemExit(
f"--version {args.version!r} does not match reused wheel "
f"version {wheel_version!r}"
)
deploy_version = wheel_version
_log(f"deploy version from reused wheels: {deploy_version}")
_log(f"reusing wheels: {[w.name for w in wheels]}")
finally:
if backups and not args.keep_version_bump:
_restore_versions(backups)
classified = _classify_wheels(wheels)
for wheel in wheels:
size_mb = wheel.stat().st_size / 1024 / 1024
_log(f" {wheel.name} {size_mb:.2f} MB")
if classified.oversize:
raise SystemExit(
"uv-based Databricks Apps deploys require all Omnigent wheels "
"to fit in the app source snapshot. Rebuild with --skip-web-ui "
"or reduce wheel size; UC Volume wheel paths are not used "
"because uv lock validates path sources locally."
)
# Late-import the SDK so `--help` works without it installed.
from databricks.sdk import WorkspaceClient
wc = WorkspaceClient(profile=args.profile) if args.profile else WorkspaceClient()
# 1) Prep the bundle's source_code_path (src/) — sweep stale
# wheels locally, then copy the new small wheels in.
src = _src_dir()
src.mkdir(parents=True, exist_ok=True)
_sweep_local_src_wheels(keep={w.name for w in classified.small})
for wheel in classified.small:
dest = src / wheel.name
_log(f"copy {wheel.name}{dest.relative_to(_repo_root())}")
shutil.copy2(wheel, dest)
# 2) Generate pyproject.toml + uv.lock. Remove requirements.txt
# first because Databricks Apps gives it precedence over uv.
write_uv_dependency_files(
src,
classified.main,
classified.small,
classified.oversize,
deploy_version,
)
# 4) Bind the bundle to the existing app (if any).
_ensure_bound(args)
# 4a) Reconcile compute_size out-of-band. Terraform's databricks_app
# update path doesn't support compute_size changes ("not supported
# in this update API"). The new SDK apps.create_update endpoint
# does — call it ourselves so the subsequent bundle deploy sees no
# diff. Skipped when already matching.
_ensure_compute_size(wc, args.app_name, args.compute_size)
# 5) databricks bundle deploy --target <target> (syncs src/ to the
# bundle workspace folder and creates/updates the app resource).
_log(f"databricks bundle deploy --target {args.target}")
subprocess.run(
[
"databricks",
"bundle",
"deploy",
"--target",
args.target,
*_profile_arg(args),
*_bundle_vars(args),
],
cwd=_deploy_dir(),
check=True,
)
# 5) databricks bundle run <key> --target <target> (starts/restarts
# the app with the just-deployed source).
_log(f"databricks bundle run {_BUNDLE_RESOURCE_KEY} --target {args.target}")
subprocess.run(
[
"databricks",
"bundle",
"run",
_BUNDLE_RESOURCE_KEY,
"--target",
args.target,
*_profile_arg(args),
*_bundle_vars(args),
],
cwd=_deploy_dir(),
check=True,
)
# 6) Resolve URL + smoke-check.
app = wc.apps.get(name=args.app_name)
app_url = args.app_url or app.url
# 7) Grant the app SP USE_CATALOG + USE_SCHEMA on the volume's
# parents (Apps' uc_securable resource only grants the leaf).
_ensure_app_sp_uc_traversal(args, app.service_principal_client_id)
if not args.no_smoke_check:
if not app_url:
raise SystemExit("no app URL available for smoke check")
_smoke_check(wc, app_url)
_log(f"done. app: {app_url}")
return 0
if __name__ == "__main__":
sys.exit(main())
+179
View File
@@ -0,0 +1,179 @@
"""
Grant a Databricks App service principal Lakebase schema privileges.
Run this after ``wc.apps.create`` creates the app service principal and
before ``wc.apps.deploy`` starts the app. The app needs these grants so
Alembic can create and migrate Omnigent tables on first boot.
"""
from __future__ import annotations
import argparse
import sys
from typing import Protocol, cast
import psycopg
from databricks.sdk import WorkspaceClient
class _GrantArgs(Protocol):
"""
Parsed CLI arguments for the grant helper.
:param app_name: Databricks App name, e.g. ``"omnigent"``.
:param lakebase_endpoint: Full Lakebase endpoint resource path, e.g.
``"projects/omnigent/branches/production/endpoints/primary"``.
:param database: PostgreSQL database name, e.g.
``"databricks_postgres"``.
:param profile: Optional Databricks CLI profile name, e.g.
``"<your-profile>"``.
"""
app_name: str
lakebase_endpoint: str
database: str
profile: str | None
def _parse_args() -> _GrantArgs:
"""
Parse command-line arguments for the grant helper.
:returns: Parsed CLI arguments.
"""
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--app-name",
required=True,
help="Databricks App name, e.g. 'omnigent'.",
)
parser.add_argument(
"--lakebase-endpoint",
required=True,
help=(
"Full Lakebase endpoint resource path, e.g. "
"'projects/omnigent/branches/production/endpoints/primary'."
),
)
parser.add_argument(
"--database",
required=True,
help="PostgreSQL database name, e.g. 'databricks_postgres'.",
)
parser.add_argument(
"--profile",
default=None,
help="Optional Databricks CLI profile name, e.g. '<your-profile>'.",
)
return cast(_GrantArgs, parser.parse_args())
def _grant_sql(sp_uuid: str) -> str:
"""
Build the GRANT statement block for an app service principal.
:param sp_uuid: App service principal client ID, e.g.
``"00000000-0000-0000-0000-000000000000"``. Lakebase creates a
PostgreSQL role with this identifier when the app is created.
:returns: SQL statements granting schema, table, and sequence
privileges in the PostgreSQL ``public`` schema.
"""
escaped = sp_uuid.replace('"', '""')
quoted = f'"{escaped}"'
return f"""
GRANT ALL ON SCHEMA public TO {quoted};
GRANT ALL ON ALL TABLES IN SCHEMA public TO {quoted};
GRANT ALL ON ALL SEQUENCES IN SCHEMA public TO {quoted};
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO {quoted};
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO {quoted};
"""
def _resolve_endpoint_host(wc: WorkspaceClient, endpoint_name: str) -> str | None:
"""
Resolve the Lakebase endpoint hostname.
:param wc: Databricks workspace client.
:param endpoint_name: Full Lakebase endpoint resource path, e.g.
``"projects/omnigent/branches/production/endpoints/primary"``.
:returns: Endpoint hostname, or ``None`` when the endpoint is not ready.
"""
endpoint = wc.postgres.get_endpoint(name=endpoint_name)
if endpoint.status is None or endpoint.status.hosts is None:
return None
return endpoint.status.hosts.host
def _build_conn_params(
wc: WorkspaceClient, host: str, database: str, endpoint_name: str
) -> dict[str, str]:
"""
Build psycopg connection params using a short-lived Lakebase OAuth token.
Returned as keyword params (not a hand-built conninfo string) so the
token — which we don't control the contents of — is never string-
interpolated into a DSN where whitespace or ``key=value`` metacharacters
could be mis-parsed.
:param wc: Databricks workspace client.
:param host: Lakebase endpoint hostname, e.g.
``"example.database.cloud.databricks.com"``.
:param database: PostgreSQL database name, e.g.
``"databricks_postgres"``.
:param endpoint_name: Full Lakebase endpoint resource path, e.g.
``"projects/omnigent/branches/production/endpoints/primary"``.
:returns: psycopg connection keyword params for the current user.
"""
cred = wc.postgres.generate_database_credential(endpoint=endpoint_name)
pg_user = wc.current_user.me().user_name
return {
"host": host,
"port": "5432",
"dbname": database,
"user": pg_user,
"password": cred.token,
"sslmode": "require",
}
def main() -> int:
"""
Resolve the app service principal and apply Lakebase grants.
:returns: Process exit code, ``0`` on success and ``1`` when the app
or Lakebase endpoint is not ready.
"""
args = _parse_args()
wc = WorkspaceClient(profile=args.profile) if args.profile else WorkspaceClient()
app = wc.apps.get(name=args.app_name)
sp_uuid = app.service_principal_client_id
if not sp_uuid:
print(
f"ERROR: app '{args.app_name}' has no service_principal_client_id "
"yet; wait for wc.apps.create() to finish.",
file=sys.stderr,
)
return 1
host = _resolve_endpoint_host(wc, args.lakebase_endpoint)
if not host:
print(
f"ERROR: endpoint '{args.lakebase_endpoint}' has no hostname "
"in status; wait for the endpoint to become ACTIVE.",
file=sys.stderr,
)
return 1
print(f"==> Granting public schema privileges to app SP {sp_uuid}")
params = _build_conn_params(wc, host, args.database, args.lakebase_endpoint)
with psycopg.connect(autocommit=True, **params) as conn, conn.cursor() as cur:
cur.execute(_grant_sql(sp_uuid))
print("Done. The app can create and migrate Omnigent tables on first boot.")
return 0
if __name__ == "__main__":
sys.exit(main())
+228
View File
@@ -0,0 +1,228 @@
"""Databricks Apps entry point for omnigent.
Starts omnigent with Lakebase (managed PostgreSQL) as the
database and UC Volumes as the artifact store.
"""
from __future__ import annotations
import logging
import os
import sys
import threading
import time
import traceback
logging.basicConfig(level=logging.INFO, stream=sys.stderr, force=True)
logger = logging.getLogger("omnigent-app")
# ── Lakebase token cache ──────────────────────────────────
#
# Lakebase tokens are valid for ~60 minutes. The previous design
# minted a fresh token on every new physical Postgres connection
# inside SQLAlchemy's ``do_connect`` event hook — a synchronous
# Databricks SDK HTTPS round-trip costing 100300 ms per call.
# Under the 200-runner load test that meant ~20 mints/minute as
# the pool churned overflow connections, with each mint blocking
# whatever thread (sometimes the asyncio event-loop thread) was
# establishing the connection.
#
# This cache mints once per endpoint and reuses the token across
# all subsequent ``do_connect`` calls until the TTL expires. 50
# minutes leaves a 10-minute safety margin before Lakebase rejects
# the token. Concurrent first-time mints are NOT serialized — we
# release the lock around the SDK call so a thundering herd of
# initial connections all mints once each (worst case) rather than
# waiting on a single in-flight mint. The cache is then populated
# atomically with whichever mint finishes first; the late losers
# just overwrite with their own (equally-valid) token.
_TOKEN_TTL_SECONDS = 50 * 60
_token_cache: dict[str, tuple[str, float]] = {}
_token_cache_lock = threading.Lock()
try:
import sqlalchemy
from databricks.sdk import WorkspaceClient
# ── Configuration ──────────────────────────────────────────
# Required env vars — injected by Databricks Apps runtime from
# the resources declared in databricks.yml / app.yaml.
LAKEBASE_ENDPOINT = os.environ["AP_LAKEBASE_ENDPOINT"]
VOLUME_PATH = os.environ["AP_ARTIFACT_VOLUME_PATH"]
PGHOST = os.environ["PGHOST"]
PGDATABASE = os.environ["PGDATABASE"]
PGUSER = os.environ["PGUSER"]
# Optional with documented defaults.
# Databricks Apps expects the app to listen on DATABRICKS_APP_PORT
# (8000 by convention) — deliberately decoupled from the CLI's
# local-server default (6767 in host/local_server.py).
PORT = int(os.environ.get("DATABRICKS_APP_PORT", "8000"))
PGPORT = os.environ.get("PGPORT", "5432")
PGSSLMODE = os.environ.get("PGSSLMODE", "require")
# Recycle DB connections before Lakebase 60-min token expiry.
# 300s (5 min) is conservative — well under the 60-min token TTL.
POOL_RECYCLE_SECONDS = int(os.environ.get("AP_POOL_RECYCLE_SECONDS", "300"))
logger.info(
"Config: PGHOST=%s PGDATABASE=%s PGUSER=%s VOLUME=%s PORT=%d",
PGHOST,
PGDATABASE,
PGUSER,
VOLUME_PATH,
PORT,
)
# ── Lakebase token injection ──────────────────────────────
_workspace_client = WorkspaceClient()
def _get_cached_token(endpoint: str) -> str:
"""Return a cached Lakebase token for ``endpoint``, minting if needed.
Fast path: cached token whose expiry is in the future is returned
without contacting the workspace. Slow path: mint a new token via
the Databricks SDK (synchronous HTTPS). The mint runs OUTSIDE the
cache lock so multiple concurrent first-time mints don't serialize
behind one another — the last winner writes the cache, which is
safe since every minted token is independently valid for ~60 min.
:param endpoint: Lakebase endpoint resource name, e.g.
``"projects/foo/branches/production/endpoints/primary"``.
:returns: A Lakebase database credential token.
:raises RuntimeError: If the SDK returns a credential with no token.
"""
now = time.monotonic()
with _token_cache_lock:
cached = _token_cache.get(endpoint)
if cached is not None and cached[1] > now:
return cached[0]
credential = _workspace_client.postgres.generate_database_credential(
endpoint=endpoint,
)
if credential.token is None:
raise RuntimeError("Lakebase credential response did not include a token")
with _token_cache_lock:
_token_cache[endpoint] = (credential.token, now + _TOKEN_TTL_SECONDS)
return credential.token
# SQLAlchemy fixes the signature of do_connect; the dialect /
# conn_rec / cargs args aren't used here, but they have to be
# named so the hook accepts them. Underscore prefix tells the
# linter we know they're unused.
@sqlalchemy.event.listens_for(sqlalchemy.engine.Engine, "do_connect")
def _inject_lakebase_credentials(_dialect, _conn_rec, _cargs, cparams):
if cparams.get("host") != PGHOST:
return
cparams["password"] = _get_cached_token(LAKEBASE_ENDPOINT)
cparams["sslmode"] = PGSSLMODE
# ── Start omnigent ─────────────────────────────────────
import tempfile
from pathlib import Path
import uvicorn
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.app import create_app
from omnigent.server.auth import create_auth_provider
# OTel: the Databricks Apps platform auto-injects
# OTEL_EXPORTER_OTLP_ENDPOINT when `telemetry_export_destinations`
# is set on the app — telemetry.init() picks that up and routes
# OTLP to the platform collector, which writes to the configured
# UC tables. No-op if neither OTEL nor MLflow env vars are set.
telemetry.init()
from omnigent.stores.agent_store.sqlalchemy_store import SqlAlchemyAgentStore
from omnigent.stores.artifact_store.databricks_volumes import (
DatabricksVolumesArtifactStore,
)
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,
)
from omnigent.stores.policy_store.sqlalchemy_store import SqlAlchemyPolicyStore
DB_URI = f"postgresql+psycopg://{PGUSER}@{PGHOST}:{PGPORT}/{PGDATABASE}"
ARTIFACT_URI = f"dbfs:{VOLUME_PATH}"
CACHE_DIR = Path(tempfile.mkdtemp(prefix="ap_cache_"))
logger.info("DB_URI: %s", DB_URI[:80])
logger.info("ARTIFACT_URI: %s", ARTIFACT_URI)
# The app SP owns the tables — run any pending Alembic upgrades
# before the stores boot, since the verify-schema check refuses
# to start a stale DB. Idempotent: a no-op when the DB is at head.
from omnigent.db.utils import _run_migrations as _run_alembic_upgrade
_migration_engine = sqlalchemy.create_engine(DB_URI)
try:
_run_alembic_upgrade(_migration_engine, DB_URI)
finally:
_migration_engine.dispose()
agent_store = SqlAlchemyAgentStore(DB_URI)
file_store = SqlAlchemyFileStore(DB_URI)
conversation_store = SqlAlchemyConversationStore(DB_URI)
artifact_store = DatabricksVolumesArtifactStore(ARTIFACT_URI)
file_comment_store = SqlAlchemyCommentStore(DB_URI)
permission_store = SqlAlchemyPermissionStore(DB_URI)
policy_store = SqlAlchemyPolicyStore(DB_URI)
host_store = HostStore(DB_URI)
agent_cache = AgentCache(artifact_store=artifact_store, cache_dir=CACHE_DIR)
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=file_comment_store,
policy_store=policy_store,
)
# The Databricks Apps proxy injects ``X-Forwarded-Email`` on
# every request, so we run in header mode. Header is the
# framework default, but pin it explicitly so the hosted product
# keeps its existing behavior regardless of any ambient
# OMNIGENT_AUTH_ENABLED in the deploy env (an explicit
# provider always wins over the enable switch).
os.environ.setdefault("OMNIGENT_AUTH_PROVIDER", "header")
auth_provider = create_auth_provider()
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=file_comment_store,
permission_store=permission_store,
policy_store=policy_store,
host_store=host_store,
auth_provider=auth_provider,
)
if __name__ == "__main__":
logger.info("Starting omnigent on 0.0.0.0:%d", PORT)
uvicorn.run(app, host="0.0.0.0", port=PORT)
except Exception: # noqa: BLE001 — startup catch-all; we want every failure logged
logger.error("FATAL: omnigent failed to start:\n%s", traceback.format_exc())
# Keep the process alive briefly so logs can be captured
import time
time.sleep(30)
sys.exit(1)
+8
View File
@@ -0,0 +1,8 @@
command: ["opentelemetry-instrument", "python", "app.py"]
env:
- name: AP_LAKEBASE_ENDPOINT
valueFrom: postgres
- name: AP_ARTIFACT_VOLUME_PATH
valueFrom: artifact_volume
- name: OTEL_TRACES_SAMPLER
value: 'always_on'
+310
View File
@@ -0,0 +1,310 @@
# Omnigent on Daytona
[Daytona](https://www.daytona.io) sandboxes give you disposable cloud
machines for running Omnigent hosts, two ways:
- **CLI-launched**: `omnigent sandbox create` / `connect` provisions a
sandbox from your terminal, ships your local checkout into it, and
registers it as a host with your server.
- **Server-managed**: the server provisions a sandbox automatically
when a session is created with `"host_type": "managed"` and
terminates it when the session is deleted.
Sandboxes boot from the official prebaked host image, so startup is
seconds once Daytona has cached the image as an internal snapshot —
the very first launch from a given image takes a few minutes while
Daytona pulls and snapshots it.
> This directory also contains the source of the **free-tier egress
> relay** (`wrangler.toml`, `src/index.js`) — a Cloudflare Worker that
> lets Daytona Tier 1/2 sandboxes reach your server through Daytona's
> egress firewall. See
> [Free-tier relay setup](#free-tier-relay-setup-tier-12). It is NOT
> a server deploy target.
## Prerequisites
```bash
pip install 'omnigent[daytona]' # installs the daytona SDK extra
```
> [!IMPORTANT]
> **Egress on Daytona is allowlisted, which shapes how you run hosts
> (CLI-launched and managed alike).** Daytona
> [Tier 1/2 organizations](https://www.daytona.io/docs/en/limits/)
> permit outbound traffic only to a
> [fixed allowlist](https://www.daytona.io/docs/en/network-limits) of
> public domains (git hosts, package managers, the major AI provider
> APIs) that org admins **cannot modify**. Two consequences:
>
> 1. The in-sandbox host's dial-back to your Omnigent `server_url` is
> blocked unless that URL is on the allowlist — otherwise the
> launch times out with "managed host did not come online".
> 2. The agent's LLM calls only work against an **allowlisted model
> endpoint** (`api.openai.com`, `api.anthropic.com`, …). A private
> or gateway endpoint is blocked the same way.
>
> **Two ways to satisfy this:**
>
> - **Tier 3+** (a $500 *usage top-up* — prepaid sandbox credit, not a
> fee) lifts the egress restriction entirely: point `server_url` at
> your real server and use any model endpoint, no relay. Best for
> teams already on Daytona; cleanest security posture (end-to-end
> TLS, no middlebox).
> - **Free tier (Tier 1/2) via an allowlisted relay** — `*.workers.dev`
> passes the firewall, so a tiny Cloudflare Worker that reverse-
> proxies to your server lets the dial-back through; route any
> non-allowlisted model endpoint through a second Worker the same
> way. **Verified working end-to-end on Tier 1.** This inserts a
> TLS-terminating middlebox, so read
> [Security considerations](#security-considerations) first. See
> [Free-tier relay setup](#free-tier-relay-setup-tier-12) below.
>
> If you're evaluating cloud sandboxes from scratch and don't want to
> run a relay, [Modal](../modal/README.md#sandboxes-for-runner-hosts)
> has full egress on its entry tier.
Create an API key in the [Daytona dashboard](https://app.daytona.io)
(Dashboard → Keys) and make it available where the launcher runs —
your shell for the CLI flow, the **server** process for managed
sandboxes:
```bash
export DAYTONA_API_KEY=dtn_…
# Optional: a non-default API endpoint or target region
# export DAYTONA_API_URL=https://app.daytona.io/api
# export DAYTONA_TARGET=us
```
## CLI-launched sandboxes
Provision a sandbox and ship your local checkout into it:
```bash
omnigent sandbox create --provider daytona
```
This pulls the host image, builds wheels from your local checkout, and
overlays them on top — so the sandbox runs *your* code, not whatever
the image was built from. Then register it as a host with your server:
```bash
omnigent sandbox connect --provider daytona \
--sandbox-id <id-printed-by-create> \
--server https://your-host
```
`connect` runs `omnigent host` inside the sandbox (over a PTY session)
and holds the connection open in your terminal — Ctrl-C tears it down.
New sessions targeting that host now run in the sandbox.
Running multiple sandboxes against one server? Pass a unique
`--host-name <label>` to each `connect` — the server keys hosts on
(owner, name), and sandboxes that share a hostname collide.
Sandboxes are disposable. When your code changes, create a new one —
and delete the old one (Daytona sandboxes have no lifetime cap, and
the CLI flow disables idle auto-stop, so abandoned sandboxes keep
billing until removed via the
[dashboard](https://app.daytona.io) or `daytona sandbox delete`).
> [!NOTE]
> On free-tier (Tier 1/2) organizations the `--server` URL must pass
> the egress allowlist or the in-sandbox `omnigent host` can't dial
> back — see the tier note above and the
> [relay setup](#free-tier-relay-setup-tier-12).
To inject LLM/git credentials into a CLI-launched sandbox, set
`OMNIGENT_DAYTONA_SANDBOX_ENV` in your shell to a comma-separated list
of variable names (e.g. `ANTHROPIC_API_KEY,GIT_TOKEN`) before running
`create` — the named variables are copied from your environment into
the sandbox at provision time.
## Server-managed sandboxes
Add a `sandbox:` section to the server config (`omnigent server -c
config.yaml`, or `<data_dir>/config.yaml`):
```yaml
sandbox:
provider: daytona
server_url: https://your-host # public URL sandboxes dial back to
```
`server_url` must be reachable *from Daytona's cloud* — a public HTTPS
URL, not `localhost`. Sessions created with `host_type: "managed"`
(the API call or the Web UI's New Sandbox option) then run on a fresh
Daytona sandbox; the create returns immediately and provisioning
happens in the background, exactly like the [Modal managed
flow](../modal/README.md#server-managed-sandboxes) — including
repository workspaces, the first-message rendezvous, and dead-sandbox
relaunch.
Optional `daytona:` settings:
```yaml
sandbox:
provider: daytona
server_url: https://your-host
daytona:
image: docker.io/<you>/omnigent-host:latest # default: official image
env: [OPENAI_API_KEY, ANTHROPIC_API_KEY, GIT_TOKEN]
```
## Credentials for the sandbox (LLM keys, git tokens)
Daytona has no provider-side named-secret store to attach at sandbox
creation, so credentials are injected as environment variables instead:
`sandbox.daytona.env` lists the **names** of variables to copy from the
**server's own environment** into every sandbox at provision time.
Values never live in the config file — set them where the server runs:
```bash
export OPENAI_API_KEY=sk-… # on the server
export GIT_TOKEN=github_pat_… # private-repo clone/fetch/push
```
```yaml
sandbox:
provider: daytona
server_url: https://your-host
daytona:
env: [OPENAI_API_KEY, GIT_TOKEN]
```
A listed name that is **not** set in the server's environment fails the
launch loudly (it would otherwise surface much later as an opaque
harness auth failure inside the sandbox).
Which variables to inject — providers, gateways, subscriptions, git —
is identical to Modal; see the [variable table and per-plan
recipes](../modal/README.md#llm-credentials-for-managed-sandboxes) and
[git credentials](../modal/README.md#git-credentials-private-repositories).
The in-sandbox host forwards the same standard set to its runners, and
`OMNIGENT_RUNNER_ENV_PASSTHROUGH` (as an injected variable) names any
extras.
The same env-injection also carries **credentials for connecting to
the server itself**, for a host that authenticates its dial-back with
user credentials instead of a launch token. Managed launches never
need this: the server injects a per-launch host token automatically.
But a [CLI-launched](#cli-launched-sandboxes) host does when the
server requires authentication — inject the keys for the relevant
server, e.g. `DATABRICKS_HOST` + `DATABRICKS_TOKEN` (or
`DATABRICKS_CLIENT_ID` / `DATABRICKS_CLIENT_SECRET`) for a
Databricks-fronted server, by naming them in
`OMNIGENT_DAYTONA_SANDBOX_ENV` before `create` — and the in-sandbox
host mints fresh bearer tokens from them on every reconnect. See
[Connecting to an authenticated
server](../modal/README.md#connecting-to-an-authenticated-server) in
the Modal guide.
> [!NOTE]
> On the **free tier**, the agent's model endpoint must also be
> allowlisted (`api.openai.com`, `api.anthropic.com`, …). A private or
> gateway endpoint is firewalled — route it through a second relay (see
> below) and inject the relay's `*.workers.dev` URL as `OPENAI_BASE_URL`
> / `ANTHROPIC_BASE_URL`.
## Free-tier relay setup (Tier 1/2)
Daytona free-tier (Tier 1/2) sandboxes can only reach an
[allowlisted set of domains](https://www.daytona.io/docs/en/network-limits);
`*.workers.dev` is on it. The ready-to-deploy Cloudflare Worker in this
directory lives there and transparently reverse-proxies every request —
plain HTTP and WebSocket upgrades — to your real Omnigent server, so a
managed host's dial-back (the host tunnel WS, the runner tunnel WS, and
plain HTTP) reaches the server through the firewall.
```bash
npm i -g wrangler # or use npx
wrangler login # one-time, free, no credit card
cd deploy/daytona
wrangler deploy --var UPSTREAM_URL:https://your-omnigent-server
# → https://omnigent-daytona-relay.<your-subdomain>.workers.dev
```
Point `sandbox.daytona.server_url` at the printed `*.workers.dev` URL.
For a non-allowlisted model endpoint, deploy a second copy
(`name = "omnigent-llm-relay"`, `UPSTREAM_URL` = your gateway) and
inject its URL as `OPENAI_BASE_URL` via `sandbox.daytona.env`.
**This path is verified end-to-end on a real Daytona Tier 1 org**
(managed create → host dial-back through the relay → runner → real LLM
turn → teardown). Read the security trade-off below before relying on
it.
## Security considerations
- **Injected credentials live in Daytona's control plane.** Daytona has
no named-secret store, so `sandbox.daytona.env` values are sent to
Daytona's API as literal sandbox env vars and stored in sandbox
metadata — a third party now holds whatever you inject (LLM keys,
`GIT_TOKEN`). Prefer **scoped, short-lived** credentials: a
fine-grained PAT limited to the repos a session needs, a gateway
token over a root provider key. (Modal's launcher attaches named
Modal secrets instead, so its values stay in Modal's secret store —
a stronger posture; this is the main security difference between the
two providers.)
- **All managed sandboxes share one Daytona org + API key.**
Cross-user isolation between Omnigent users rides entirely on
Daytona's sandbox boundaries, and the shared org key can enumerate
and delete any user's sandbox. Same single-tenant-org shape as the
Modal provider; scope the org to this workload and nothing else.
- **The launch token's lifetime is 7 days.** Daytona sandboxes have no
platform lifetime cap, so the per-launch host token must outlive a
long-running sandbox across tunnel reconnects — a longer window than
Modal's ~25h. A leaked token is replayable against the server for
that window; a relaunch mints a fresh one. Deployments injecting
their own launcher can set a shorter `token_ttl_s` on
`ManagedSandboxConfig` if their sandboxes are short-lived.
- **The Tier 1/2 relay workaround is a TLS-terminating MITM.** A relay
on an allowlisted wildcard domain (`*.vercel.app` / `*.workers.dev`)
must be an L7 service — it terminates TLS and re-originates, so it
sees the host launch token and all tunnel payload (runner frames,
tool output, file contents) in plaintext at its edge. Only use a
relay you fully control, with logging off; never a shared/public
one. The direct-egress (Tier 3) path keeps the tunnel end-to-end TLS
with no middlebox and is the right choice for any
security-sensitive deployment.
## Troubleshooting
- **"managed host did not come online within 120s"** — on Tier 1/2
organizations this is almost always the egress firewall blocking the
host's dial-back to `server_url` (see the tier note above). Verify
with `curl <server_url>/health` inside a sandbox. On Tier 3+, check
`/tmp/omnigent-host.log` inside the sandbox.
- **Slow first launch** — the initial create from a new image builds a
Daytona snapshot (minutes); subsequent launches are seconds.
- **"Organization is suspended: Please verify your email address"** —
complete email verification in the
[dashboard](https://app.daytona.io/dashboard/limits) (signing up via
GitHub/Google SSO arrives pre-verified).
## Lifecycle notes
- **No platform lifetime cap.** Unlike Modal's 24-hour limit, Daytona
sandboxes run until deleted. Omnigent disables Daytona's 15-minute
idle auto-stop at provision time (a session host must survive gaps
between turns); the sandbox is deleted when its session is deleted,
and the dead-sandbox relaunch path replaces one that crashed or was
deleted out-of-band.
- **First launch per image is slow.** Daytona builds an internal
snapshot from the image on first use (minutes for the ~1.4 GiB host
image); subsequent launches reuse it (seconds).
- **Custom images** work like Modal's: build the `host` target of
[`deploy/docker/Dockerfile`](../docker/Dockerfile)
(`--platform linux/amd64`) and push it to any registry Daytona can
pull from, then set `sandbox.daytona.image` or
`OMNIGENT_DAYTONA_HOST_IMAGE`.
## Environment variable reference
| Variable | Where it's read | Purpose |
|---|---|---|
| `DAYTONA_API_KEY` | CLI machine / server | Daytona API credentials (required) |
| `DAYTONA_API_URL` | CLI machine / server | Non-default Daytona API endpoint |
| `DAYTONA_TARGET` | CLI machine / server | Target region for new sandboxes |
| `OMNIGENT_DAYTONA_HOST_IMAGE` | CLI machine / server | Override the host image ref (`sandbox.daytona.image` takes precedence) |
| `OMNIGENT_DAYTONA_SANDBOX_ENV` | CLI machine / server | Comma-separated launcher-side env var names to inject (`sandbox.daytona.env` takes precedence for managed) |
+30
View File
@@ -0,0 +1,30 @@
// Omnigent <-> Daytona egress relay.
//
// Daytona Tier 1/2 sandboxes can only reach an allowlisted set of public
// domains; *.workers.dev is on that list. This Worker lives there and
// transparently reverse-proxies EVERY request (plain HTTP and WebSocket
// upgrades alike) to the real Omnigent server, so the in-sandbox host's
// dial-back — the host tunnel WS, the runner tunnel WS, and the host's
// plain HTTP calls — all reach the server through the firewall.
//
// SECURITY: this terminates TLS and re-originates, so it can see the
// per-launch host token and tunnel payload. Deploy only a relay you
// control; see README.md (this directory) "Security considerations".
export default {
async fetch(request, env) {
if (!env.UPSTREAM_URL) {
return new Response("relay misconfigured: UPSTREAM_URL unset", { status: 500 });
}
const upstream = new URL(env.UPSTREAM_URL);
const incoming = new URL(request.url);
// Preserve path + query; swap only the origin to the real server.
upstream.pathname = incoming.pathname;
upstream.search = incoming.search;
// Reconstruct the request against the upstream origin. Passing the
// original `request` as init copies method, headers (incl.
// X-Omnigent-Host-Token and the WebSocket Upgrade), and body.
// Cloudflare proxies the WebSocket handshake through fetch().
const proxied = new Request(upstream.toString(), request);
return fetch(proxied);
},
};
+16
View File
@@ -0,0 +1,16 @@
# Cloudflare Worker: egress relay for Omnigent managed hosts on Daytona
# free tier (Tier 1/2). Lives on the allowlisted *.workers.dev domain
# and reverse-proxies to your real Omnigent server, so the in-sandbox
# host's dial-back gets through Daytona's egress firewall. See
# README.md (this directory) "Free-tier relay setup".
#
# Deploy:
# npx wrangler deploy --var UPSTREAM_URL:https://your-omnigent-server
name = "omnigent-daytona-relay"
main = "src/index.js"
compatibility_date = "2025-01-01"
[vars]
# The real Omnigent server this relay forwards to. Override at deploy
# time with --var UPSTREAM_URL:... (do NOT commit a real URL here).
UPSTREAM_URL = "https://your-omnigent-server.example.com"
+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()
+278
View File
@@ -0,0 +1,278 @@
# Omnigent on E2B
[E2B](https://e2b.dev) sandboxes give you disposable cloud machines for
running Omnigent hosts, two ways:
- **CLI-launched**: `omnigent sandbox create` / `connect` provisions a
sandbox from your terminal, ships your local checkout into it, and
registers it as a host with your server.
- **Server-managed**: the server provisions a sandbox automatically when
a session is created with `"host_type": "managed"` and terminates it
when the session is deleted.
> [!IMPORTANT]
> **E2B boots from a pre-built *template*, not a registry image.** Unlike
> the Modal / Daytona / CoreWeave launchers — which pull
> `ghcr.io/omnigent-ai/omnigent-host` directly — E2B cannot start an
> arbitrary registry image at create time. You must first build the
> Omnigent host image into an E2B template (a one-time step, below); the
> launcher's `template` field then names *that template*, not a
> `ghcr.io/...` reference. This is the one real difference from the other
> sandbox providers. This directory is **not** a server deploy target.
## Prerequisites
```bash
pip install 'omnigent[e2b]' # installs the e2b SDK extra
npm i -g @e2b/cli # the E2B CLI, for building the template
```
Create an API key in the [E2B dashboard](https://e2b.dev/dashboard) and
make it available where the launcher runs — your shell for the CLI flow,
the **server** process for managed sandboxes:
```bash
export E2B_API_KEY=e2b_…
e2b auth login # one-time, authenticates the E2B CLI too
```
> [!NOTE]
> **Lifetime is capped and cannot be disabled.** An E2B sandbox carries a
> single timeout (default 5 minutes; account maximum **24 h on Pro, 1 h on
> Hobby**) with no "never expire" option. Omnigent requests the 24 h
> maximum at creation, but E2B **rejects** (does not clamp) a request above
> the account cap, so `provision` automatically **retries clamped to the
> account's maximum** (e.g. 1 h on Hobby) — verified live. Set
> `OMNIGENT_E2B_MAX_LIFETIME_S` to request a specific lifetime and skip the
> retry. A managed session outliving the cap relies on the dead-sandbox
> relaunch path (same posture as Modal's 24 h limit), so a **Pro account**
> is recommended for anything beyond short demos.
## Build the host template (one time)
E2B builds a template from a Dockerfile whose base image must be
**Debian-based** and **single-stage**. The Omnigent host image
(`python:slim`, Debian) satisfies both — so the template Dockerfile is a
one-liner that layers nothing on top of the published image:
```bash
mkdir -p omnigent-e2b && cd omnigent-e2b
cat > e2b.Dockerfile <<'EOF'
# Single-stage, Debian-based — both E2B requirements. The host image
# already bakes the full omnigent install plus git / tmux / curl, so
# nothing else is needed here.
FROM ghcr.io/omnigent-ai/omnigent-host:latest
EOF
e2b template build --name omnigent-host --dockerfile e2b.Dockerfile
```
`omnigent-host` is the default template name the launcher looks for
([`DEFAULT_E2B_TEMPLATE`](../../omnigent/onboarding/sandboxes/e2b.py)), so
a deployment that uses that name needs no further config. Use a different
name (or pin a `:sha-<short>` host image) and point the launcher at it
with `sandbox.e2b.template` / `OMNIGENT_E2B_TEMPLATE`.
To run your own host image, build the `host` target of
[`deploy/docker/Dockerfile`](../docker/Dockerfile)
(`--platform linux/amd64`), push it anywhere E2B can pull from, and `FROM`
that ref in `e2b.Dockerfile` instead. Rebuild the template whenever the
host image changes (the CLI flow still overlays your *local* wheels on
top per-sandbox, so day-to-day code changes don't need a template
rebuild).
## CLI-launched sandboxes
Provision a sandbox and ship your local checkout into it:
```bash
omnigent sandbox create --provider e2b
```
This starts a sandbox from the `omnigent-host` template, builds wheels
from your local checkout, and overlays them on top — so the sandbox runs
*your* code, not whatever the template was built from. Then register it
as a host with your server:
```bash
omnigent sandbox connect --provider e2b \
--sandbox-id <id-printed-by-create> \
--server https://your-host
```
`connect` runs `omnigent host` inside the sandbox and holds the
connection open in your terminal — Ctrl-C tears it down (and kills the
remote process; E2B exposes a real kill handle). New sessions targeting
that host now run in the sandbox.
Running multiple sandboxes against one server? Pass a unique
`--host-name <label>` to each `connect` — the server keys hosts on
(owner, name), and sandboxes that share a hostname collide.
Sandboxes are disposable. When your code changes, create a new one — and
delete the old one (via the [dashboard](https://e2b.dev/dashboard) or
`e2b sandbox kill <id>`), though E2B also reaps it automatically at its
timeout.
To inject LLM/git credentials into a CLI-launched sandbox, set
`OMNIGENT_E2B_SANDBOX_ENV` in your shell to a comma-separated list of
variable names (e.g. `ANTHROPIC_API_KEY,GIT_TOKEN`) before running
`create` — the named variables are copied from your environment into the
sandbox at provision time.
> [!NOTE]
> E2B has no local→sandbox port forward (it exposes sandbox ports
> *outward* via public URLs only). The interactive in-sandbox
> `omnigent login` / App OAuth step is therefore skipped automatically
> (as on Modal / Daytona): use E2B with servers that don't require
> in-sandbox App auth, or authenticate via injected credentials (below).
## Server-managed sandboxes
Add a `sandbox:` section to the server config (`omnigent server -c
config.yaml`, or `<data_dir>/config.yaml`):
```yaml
sandbox:
provider: e2b
server_url: https://your-host # public URL sandboxes dial back to
```
`server_url` must be reachable *from E2B's cloud* — a public HTTPS URL,
not `localhost`. Sessions created with `host_type: "managed"` (the API
call or the Web UI's New Sandbox option) then run on a fresh E2B sandbox;
the create returns immediately and provisioning happens in the
background, exactly like the [Modal managed
flow](../modal/README.md#server-managed-sandboxes) — including repository
workspaces, the first-message rendezvous, and dead-sandbox relaunch.
Optional `e2b:` settings:
```yaml
sandbox:
provider: e2b
server_url: https://your-host
e2b:
template: omnigent-host # E2B template NAME (default: omnigent-host)
env: [OPENAI_API_KEY, ANTHROPIC_API_KEY, GIT_TOKEN]
```
> [!NOTE]
> `sandbox.e2b.template` is an **E2B template name** (built above), not a
> registry image reference — the field that holds a `ghcr.io/...` ref on
> the other providers. Omit it to use the default `omnigent-host`
> template.
## Credentials for the sandbox (LLM keys, git tokens)
`sandbox.e2b.env` lists the **names** of variables to copy from the
**server's own environment** into every sandbox at provision time (passed
to `Sandbox.create(envs=…)`). Values never live in the config file — set
them where the server runs:
```bash
export OPENAI_API_KEY=sk-… # on the server
export GIT_TOKEN=github_pat_… # private-repo clone/fetch/push
```
```yaml
sandbox:
provider: e2b
server_url: https://your-host
e2b:
env: [OPENAI_API_KEY, GIT_TOKEN]
```
A listed name that is **not** set in the server's environment fails the
launch loudly (it would otherwise surface much later as an opaque harness
auth failure inside the sandbox).
Which variables to inject — providers, gateways, subscriptions, git — is
identical to Modal; see the [variable table and per-plan
recipes](../modal/README.md#llm-credentials-for-managed-sandboxes) and
[git credentials](../modal/README.md#git-credentials-private-repositories).
The in-sandbox host forwards the same standard set to its runners, and
`OMNIGENT_RUNNER_ENV_PASSTHROUGH` (as an injected variable) names any
extras.
The same env-injection also carries **credentials for connecting to the
server itself**, for a host that authenticates its dial-back with user
credentials instead of a launch token. Managed launches never need this:
the server injects a per-launch host token automatically. But a
[CLI-launched](#cli-launched-sandboxes) host does when the server requires
authentication — name the keys (e.g. `DATABRICKS_HOST` +
`DATABRICKS_TOKEN`) in `OMNIGENT_E2B_SANDBOX_ENV` before `create`. See
[Connecting to an authenticated
server](../modal/README.md#connecting-to-an-authenticated-server) in the
Modal guide.
## Security considerations
- **Injected credentials reach E2B's control plane.** `sandbox.e2b.env`
values are sent to E2B's API as literal sandbox env vars. Prefer
**scoped, short-lived** credentials: a fine-grained PAT limited to the
repos a session needs, a gateway token over a root provider key.
(Modal's launcher attaches named Modal secrets, so its values stay in
Modal's secret store — a stronger posture; same trade-off as the
Daytona provider.)
- **All managed sandboxes share one E2B account + API key.** Cross-user
isolation between Omnigent users rides on E2B's sandbox boundaries, and
the shared key can enumerate and kill any user's sandbox. Scope the
account to this workload.
- **The launch token's lifetime is ~25 h, derived from the *requested*
lifetime.** E2B sandboxes share Modal's 24 h hard cap, so the per-launch
host token outlives the sandbox by an hour to re-authenticate the tunnel
across reconnects. The TTL is computed from `OMNIGENT_E2B_MAX_LIFETIME_S`
(default 24 h) at server start, so it bounds the *longest possible*
sandbox. On a capped account where `provision` clamps the sandbox shorter
(e.g. Hobby's 1 h), the token over-covers the now-shorter sandbox — safe
for re-auth, but a leaked token is replayable for the full window. To
tighten this, **set `OMNIGENT_E2B_MAX_LIFETIME_S` to your account cap** so
the token TTL tracks the granted lifetime (or set a shorter `token_ttl_s`
on a directly-constructed `ManagedSandboxConfig`). A relaunch mints a
fresh token.
- **Sandbox URLs are public by default.** E2B exposes sandbox ports via
public `*.e2b.app` URLs; Omnigent never opens one (the host dials *out*
to your server), but be aware nothing in a sandbox should bind a
service expecting it to be private without E2B's access-token gating.
## Troubleshooting
- **"E2B sandbox creation failed: template '…' is unavailable"** — the
host image was never built into an E2B template, or the name doesn't
match. Run the [template build](#build-the-host-template-one-time) with
`--name omnigent-host` (or set `sandbox.e2b.template` to your name).
- **"managed host did not come online within 120s"** — the sandbox
couldn't dial back to `server_url`. Confirm it's a public HTTPS URL
reachable from E2B's cloud (not `localhost`), and check
`/tmp/omnigent-host.log` inside the sandbox.
- **Sandbox stops after ~1 hour** — you're on a Hobby account (1 h cap);
`provision` auto-clamps to it (you'll see a one-line warning). Upgrade
to Pro for the 24 h maximum, or expect the dead-sandbox relaunch path to
re-provision on the next message.
## Lifecycle notes
- **Hard lifetime cap, no idle-stop disable.** `provision` requests
`OMNIGENT_E2B_MAX_LIFETIME_S` (default the 24 h Pro maximum); E2B rejects
a request above the account cap, so creation retries clamped to it (e.g.
1 h on Hobby). `keep_alive` re-extends a live sandbox on reconnect, but
there is no never-expire option — a managed session past the cap is
replaced by the dead-sandbox relaunch path (same as Modal).
- **Templates, not registry images.** See
[Build the host template](#build-the-host-template-one-time). Resources
(vCPU / memory) are fixed when the template is built — pass
`--cpu-count` / `--memory-mb` to `e2b template build` — not at sandbox
create time.
- **Custom images** require rebuilding the template: `FROM` your image in
`e2b.Dockerfile` and `e2b template build` it, then set
`sandbox.e2b.template` / `OMNIGENT_E2B_TEMPLATE`.
## Environment variable reference
| Variable | Where it's read | Purpose |
|---|---|---|
| `E2B_API_KEY` | CLI machine / server | E2B API credentials (required) |
| `OMNIGENT_E2B_TEMPLATE` | CLI machine / server | E2B template name to provision from (`sandbox.e2b.template` takes precedence; default `omnigent-host`) |
| `OMNIGENT_E2B_SANDBOX_ENV` | CLI machine / server | Comma-separated launcher-side env var names to inject (`sandbox.e2b.env` takes precedence for managed) |
| `OMNIGENT_E2B_MAX_LIFETIME_S` | CLI machine / server | Requested sandbox lifetime in seconds (default 24 h); creation auto-clamps to the account cap if exceeded |
+108
View File
@@ -0,0 +1,108 @@
# Omnigent on Fly.io
Deploy Omnigent to Fly.io. Fly pulls the prebuilt image, runs it next to a
persistent volume, and serves it over HTTPS on `*.fly.dev`.
> **Fly is CLI-first.** There's no embeddable one-click button like Render's;
> you deploy with `fly deploy` (or, with one extra config tweak, Fly's web-UI
> Launch — see below). Both are validated.
## What gets provisioned
- **omnigent** — a machine that pulls `ghcr.io/omnigent-ai/omnigent-server`,
served on `https://<app>.fly.dev`.
- **artifact_data** — a persistent volume mounted at `/data/artifacts`, holding
the artifact store, the minted cookie secret, and (by default) the SQLite
database.
The default `fly.toml` uses **SQLite on the volume** — no separate database
app. That's persistent across restarts and fine for a single instance. For
multi-instance, point `DATABASE_URL` at a Postgres URL instead (see below).
## Deploy (CLI — the primary path)
Built-in `accounts` auth (multi-user, no external IdP) is the default.
```bash
# from the repo root
fly apps create <your-app> # globally unique name
fly volumes create artifact_data --size 1 --region iad -a <your-app> # match fly.toml region
fly deploy -c deploy/fly/fly.toml -a <your-app>
```
Then:
1. **Memory**`fly.toml` pins a **1 GB** machine (`[[vm]] memory = "1gb"`).
The server idles around ~275 MB RSS, so Fly's 256 MB default OOM-loops.
Keep it at 1 GB (or `fly scale memory 1024 -a <your-app>` if you changed it).
2. **Admin password** prints once in the first-boot logs:
```bash
fly logs -a <your-app>
```
Look for `Created initial admin account ... password: <generated>` (also
written to `/data/admin-credentials` on the volume).
3. Open `https://<your-app>.fly.dev`, log in as `admin`. The cookie secret and
base URL (`FLY_APP_NAME` -> `<app>.fly.dev`) are handled automatically.
## Deploy (Fly web-UI Launch)
Fly's web Launch *builds* an image and pushes it to Fly's own registry — it has
no "deploy this external image" mode, so the default `[build] image = ...`
404s there. To use the web UI, switch `fly.toml` to build the one-line shim:
```toml
[build]
dockerfile = "deploy/docker/Dockerfile.prebuilt"
```
The shim is `FROM ghcr.io/omnigent-ai/omnigent-server` with nothing added, so
Fly **pulls the prebuilt image and re-tags it** — no source rebuild. Launch
still won't auto-create the `artifact_data` volume or bump memory, so create
the volume (above) and confirm 1 GB after Launch finishes.
## Use Postgres instead of SQLite
For multiple instances or managed backups, use Postgres instead of the volume
SQLite. Two options:
- **Fly Postgres:**
```bash
fly postgres create
fly postgres attach <pg-app-name> -a <your-app> # sets DATABASE_URL as a secret
```
- **Neon (serverless Postgres):** create one at [pg.new](https://pg.new) (sign
in to keep it), then `fly secrets set DATABASE_URL='postgres://...' -a <your-app>`.
Either way, remove the `DATABASE_URL = "sqlite:..."` line from `[env]` so the
attached/secret value wins. The entrypoint normalizes the `postgres://` URL
automatically.
> **Bump the healthcheck grace for a remote DB.** The first boot against an
> external Postgres (Neon) runs migrations over the network and takes ~1 minute;
> the volume-SQLite default is near-instant. If you switch to a remote DB, raise
> `grace_period` in the `[[http_service.checks]]` block (20s -> ~90s) so Fly
> doesn't kill the machine mid-migration on the first deploy.
## Use your own IdP instead (OIDC)
Switch the provider with `fly secrets set` (OIDC requires HTTPS, which Fly
provides on `*.fly.dev`):
```bash
fly secrets set \
OMNIGENT_AUTH_PROVIDER=oidc \
OMNIGENT_OIDC_ISSUER=https://github.com \
OMNIGENT_OIDC_CLIENT_ID=<client-id> \
OMNIGENT_OIDC_CLIENT_SECRET=<client-secret> \
OMNIGENT_OIDC_REDIRECT_URI=https://<your-app>.fly.dev/auth/callback \
OMNIGENT_OIDC_COOKIE_SECRET=$(openssl rand -hex 32) \
-a <your-app>
```
For Google Workspace, also set `OMNIGENT_OIDC_ALLOWED_DOMAINS` to restrict
logins to your domain.
## Cost
A `shared-cpu-1x` 1 GB machine plus a 1 GB volume runs a few dollars a month
for a lightly loaded instance. Add a Postgres app only if you move off SQLite.
+52
View File
@@ -0,0 +1,52 @@
# Fly.io config for Omnigent. Deploy with the CLI from this directory:
# fly deploy -c deploy/fly/fly.toml
# Pulls the prebuilt image directly (the fast CLI path). Pairs with SQLite on a
# persistent volume by default — no separate database app. Full walkthrough
# (volume, memory, and the web-UI Launch path): deploy/fly/README.md.
app = "omnigent" # change to your app name
primary_region = "iad" # pick a region: fly platform regions
[build]
image = "ghcr.io/omnigent-ai/omnigent-server:latest"
# For the Fly web-UI Launch (which always builds + pushes to registry.fly.io
# and has no external-image mode), swap the line above for:
# dockerfile = "deploy/docker/Dockerfile.prebuilt"
[env]
HOST = "0.0.0.0"
PORT = "8000"
ARTIFACT_DIR = "/data/artifacts"
OMNIGENT_ADMIN_CREDENTIALS_PATH = "/data/admin-credentials"
OMNIGENT_AUTH_PROVIDER = "accounts"
# SQLite on the persistent volume — no separate Postgres app needed. For
# multi-instance, point DATABASE_URL at a Postgres URL (here or via
# `fly secrets set`); the entrypoint normalizes postgres:// automatically.
DATABASE_URL = "sqlite:////data/artifacts/chat.db"
# Cookie secret is minted on the volume (persists); base URL auto-detected
# from FLY_APP_NAME (-> <app>.fly.dev).
[http_service]
internal_port = 8000
force_https = true
auto_stop_machines = false
auto_start_machines = true
min_machines_running = 1
[[http_service.checks]]
path = "/health"
interval = "30s"
timeout = "5s"
grace_period = "20s"
# Persistent artifact + SQLite store. Create the volume first:
# fly volumes create artifact_data --size 1 --region <region>
[mounts]
source = "artifact_data"
destination = "/data/artifacts"
# The server idles around ~275 MB RSS, so the 256 MB default machine OOM-loops.
# 1 GB gives headroom for active sessions.
[[vm]]
size = "shared-cpu-1x"
memory = "1gb"
+4
View File
@@ -0,0 +1,4 @@
# Hugging Face Spaces (Docker SDK) entrypoint. HF builds a Dockerfile at the
# Space repo root and runs it, routing to the `app_port` in the README
# front-matter. This shim just pulls the prebuilt image.
FROM ghcr.io/omnigent-ai/omnigent-server:latest
+62
View File
@@ -0,0 +1,62 @@
# Omnigent on Hugging Face Spaces
> **Demo-grade target.** On the free tier, Space storage is **ephemeral** —
> data (and the SQLite DB) reset on restart. Good for kicking the tires, not for
> keeping state. For persistence, add HF's paid persistent-storage, or point
> `DATABASE_URL` at an external Postgres.
HF Spaces (Docker SDK) builds a Dockerfile at the Space repo root and runs it.
The shim here just pulls the prebuilt image. **No external database needed**
the server runs on a SQLite file (a first-class backend), so a demo Space is two
files plus two secrets.
## Setup
1. Create a **Docker** Space on Hugging Face.
2. Add these two files at the Space repo root:
- the `Dockerfile` from this directory (pulls the image), and
- a `README.md` starting with this front-matter (HF reads it):
```yaml
---
title: Omnigent
emoji: 🤖
colorFrom: indigo
colorTo: blue
sdk: docker
app_port: 8000
---
```
3. In the Space **Settings -> Variables and secrets**, set:
| Name | Kind | Value |
|---|---|---|
| `PORT` | variable | `8000` (pin it so the app and `app_port` agree) |
| `HOST` | variable | `0.0.0.0` |
| `DATABASE_URL` | variable | `sqlite:////data/artifacts/chat.db` |
| `OMNIGENT_ACCOUNTS_COOKIE_SECRET` | secret | `openssl rand -hex 32` (pin it: ephemeral disk would otherwise drop sessions on restart) |
4. The Space builds + boots. Admin password is in the Space **Logs** on first
boot. The base URL is auto-detected from `SPACE_HOST`, so it needs no manual
set.
5. **Log in via the direct URL** `https://<user>-<space>.hf.space` in its own
tab — not HF's embedded preview. The session cookie is `SameSite=Lax`, which
browsers won't send inside HF's cross-origin iframe, so logging in from the
embedded view loops back to `/login`. The direct URL is top-level
(same-site), so login sticks. Make the Space **Public** so the direct URL
isn't gated.
## Want persistence / multi-user later?
SQLite on a free Space is ephemeral (resets on restart). For data that survives,
swap `DATABASE_URL` for an **owned** external Postgres — the fastest is Neon:
1. Go to [pg.new](https://pg.new) and create a free Postgres. **Sign in to keep
it** — an unclaimed instant database is throwaway and expires.
2. Copy the connection string and set it as the `DATABASE_URL` Space secret
(replacing the SQLite value). The entrypoint normalizes `postgres://`
automatically; the pooled or direct connection string both work.
That makes data survive restarts and supports more than one instance. Note the
**first boot takes ~1 minute** while migrations run against the remote database
(subsequent boots are fast), so don't be alarmed if the Space sits in "Building
/ Starting" for a bit.
+491
View File
@@ -0,0 +1,491 @@
# Omnigent on Islo
[Islo](https://islo.dev) sandboxes give you disposable cloud machines for
running Omnigent hosts, two ways:
- **CLI-launched**: `omnigent sandbox create` / `connect` provisions a
sandbox from your terminal, ships your local checkout into it, and
registers it as a host with your server.
- **Server-managed**: the server provisions a sandbox automatically when
a session is created with `"host_type": "managed"` and terminates it
when the session is deleted.
Sandboxes boot from the official prebaked host image, so startup is
seconds. Unlike Modal and Daytona, the Islo launcher talks to the Islo
HTTP API directly through `httpx` (already an Omnigent dependency), so
there is **no provider SDK extra to install** — just an API key.
What makes Islo different from the other providers, and shapes the rest
of this guide:
- **A credential gateway.** Islo can inject LLM/API keys into a sandbox's
outbound traffic at the network layer, so the raw key never reaches the
sandbox process. This is a first-class, recommended path for model
credentials (see [Model credentials](#model-credentials-llm-keys)) and
has no equivalent on Modal or Daytona.
- **No local port forward.** Islo can't forward a sandbox→laptop callback
port, so the interactive in-sandbox `omnigent login` / App OAuth step is
skipped automatically (as on Modal and Daytona).
- **No lifetime cap.** Islo sandboxes run until deleted (like Daytona,
unlike Modal's 24 h).
## Prerequisites
Install the [Islo CLI](https://docs.islo.dev) and create an API key, then
make it available where the launcher runs — your shell for the CLI flow,
the **server** process for managed sandboxes:
```bash
curl -fsSL https://islo.dev/install.sh | sh # install the islo CLI
islo login # browser OAuth (one-time)
islo api-key create omnigent --show # prints an islo_key_… value
export ISLO_API_KEY=islo_key_…
# Optional: a non-default API endpoint
# export ISLO_BASE_URL=https://api.islo.dev
```
`ISLO_API_KEY` is exchanged for a short-lived session token at
`POST /auth/token`; the token is cached until shortly before expiry. The
key is the only required credential — no SDK, no `~/.config` file.
> [!NOTE]
> **Islo cannot forward a local callback port into the sandbox.** The
> interactive `omnigent login` browser flow (and the in-sandbox App OAuth
> callback) needs a sandbox→laptop port forward, which Islo doesn't
> provide — so the CLI skips that step automatically, exactly as it does
> for Modal and Daytona. For a server that requires authentication, inject
> the credentials instead (see
> [Connecting to an authenticated server](#connecting-to-an-authenticated-server)).
## The host image
Sandboxes boot from `ghcr.io/omnigent-ai/omnigent-host:latest`, published
by CI from the `host` target of
[`deploy/docker/Dockerfile`](../docker/Dockerfile) with Omnigent and its
dependencies preinstalled — including the coding-harness CLIs (`claude`,
`codex`, `pi`, `kiro-cli`), so agents on any harness run without an in-sandbox
install.
To use a different image (a fork, or extra tooling baked in), build the
same target and push it anywhere Islo can pull from:
```bash
docker build -f deploy/docker/Dockerfile --target host \
--platform linux/amd64 \
-t docker.io/<you>/omnigent-host:latest .
docker push docker.io/<you>/omnigent-host:latest
```
Then point Omnigent at it — `OMNIGENT_ISLO_HOST_IMAGE` for the CLI flow,
or `sandbox.islo.image` in the server config for the managed flow. For a
private registry, configure the pull credentials on the Islo side (Islo
pulls the image, not Omnigent).
> [!IMPORTANT]
> **Native terminals need `bubblewrap`.** The `claude-native` /
> `codex-native` / `kiro-native` / `pi` harnesses wrap each agent terminal in a bubblewrap
> (`bwrap`) OS-sandbox, and on Linux that isolation is mandatory and
> fail-loud — a host image without the `bwrap` binary makes those terminals
> fail to start (`linux_bwrap sandbox requires the 'bwrap' binary on PATH`).
> The `host` Dockerfile target installs `bubblewrap`; if you bring your own
> image, install it there too. See [Troubleshooting](#troubleshooting).
## CLI-launched sandboxes
Provision a sandbox and ship your local checkout into it:
```bash
omnigent sandbox create --provider islo
```
This pulls the host image, builds wheels from your local checkout, and
overlays them on top — so the sandbox runs *your* code, not whatever the
image was built from. Then register it as a host with your server:
```bash
omnigent sandbox connect --provider islo \
--sandbox-id <id-printed-by-create> \
--server https://your-host
```
`connect` runs `omnigent host` inside the sandbox and holds the
connection open in your terminal — Ctrl-C tears it down. New sessions
targeting that host now run in the sandbox.
Running multiple sandboxes against one server? Pass a unique
`--host-name <label>` to each `connect` — the server keys hosts on
(owner, name), and sandboxes that share a hostname collide.
Sandboxes are disposable. When your code changes, create a new one — and
delete the old one (Islo sandboxes have no lifetime cap, so an abandoned
sandbox keeps billing until removed via `islo rm <id>` or the
[dashboard](https://app.islo.dev)).
To inject LLM/git credentials into a CLI-launched sandbox, set
`OMNIGENT_ISLO_SANDBOX_ENV` in your shell to a comma-separated list of
variable names (e.g. `ANTHROPIC_API_KEY,GIT_TOKEN`) before running
`create` — the named variables are copied from your environment into the
sandbox at provision time. A listed name that is **not** set fails the
launch loudly (it would otherwise surface much later as an opaque harness
auth failure inside the sandbox).
### Connecting to an authenticated server
`connect` runs `omnigent host` inside the sandbox, and that host must
present credentials when it dials back to a server that requires
authentication. The interactive `omnigent login` browser flow can't run
inside an Islo sandbox (no callback port forward), so inject the keys for
the relevant server instead — name them in `OMNIGENT_ISLO_SANDBOX_ENV`
before `create`:
```bash
export OMNIGENT_ISLO_SANDBOX_ENV=DATABRICKS_HOST,DATABRICKS_TOKEN
omnigent sandbox create --provider islo
```
The in-sandbox host mints a fresh bearer token from those credentials on
every connect and reconnect. For a Databricks-fronted server, inject
`DATABRICKS_HOST` plus either `DATABRICKS_TOKEN` (a PAT) or
`DATABRICKS_CLIENT_ID` / `DATABRICKS_CLIENT_SECRET` (an OAuth service
principal — re-minting keeps a long-lived sandbox connected past any
single token's expiry).
A server with no authentication on the host tunnel needs none of this,
and neither do [server-managed sandboxes](#server-managed-sandboxes) —
those authenticate with a server-minted per-launch token automatically.
## Server-managed sandboxes
Add a `sandbox:` section to the server config (`omnigent server -c
config.yaml`, or `<data_dir>/config.yaml`):
```yaml
sandbox:
provider: islo
server_url: https://your-host # public URL sandboxes dial back to
```
`server_url` must be reachable *from Islo's cloud* — a public HTTPS URL,
not `localhost`. The server itself needs `ISLO_API_KEY` (and optional
`ISLO_BASE_URL`) in its environment. Sessions created with
`host_type: "managed"` (the API call or the Web UI's New Sandbox option)
then run on a fresh Islo sandbox; the create returns immediately and
provisioning happens in the background, exactly like the [Modal managed
flow](../modal/README.md#server-managed-sandboxes) — including repository
workspaces, the first-message rendezvous, and dead-sandbox relaunch.
```bash
curl -X POST https://your-host/v1/sessions \
-H "Content-Type: application/json" \
-d '{"agent_id": "agent_...", "host_type": "managed"}'
```
Each managed sandbox authenticates back with a server-minted, per-launch
token (7-day TTL — see [Lifecycle](#lifecycle-notes)); no user
credentials enter the sandbox for the server connection.
### Managed hosts and server auth
How the dial-back authenticates depends on how the **server** does auth,
and there is one interaction worth knowing before you deploy. A managed
sandbox opens two kinds of connections back to the server:
- the **host tunnel** (`/v1/hosts/<id>/tunnel`), which the per-launch
token authenticates directly — the server mints it, scopes it to one
host, and resolves the owner from it. This always works.
- one **runner tunnel** per session (`/v1/runners/<token>/tunnel`), opened
by the runner subprocess the host spawns. The runner authenticates with
*whatever server credential it can resolve* — a proxy-injected identity
(header / OIDC), or a stored `omnigent login` token (local hosts only; a
fresh managed sandbox has none) — **not** the per-launch host token.
The consequence:
- **Header / OIDC-proxy auth, or single-user (no-auth) servers** — the
runner tunnel needs no extra identity, so managed hosts work out of the
box. **Verified end-to-end on a single-user server**: a session created
with `host_type: "managed"` provisioned an Islo sandbox from the bwrap
image, the launcher cleared the seeded `apiKeyHelper`, the host *and*
runner tunnels connected, and a native Claude terminal ran on the
injected `CLAUDE_CODE_OAUTH_TOKEN` subscription.
- **The built-in `accounts` provider (`OMNIGENT_AUTH_ENABLED=1`)** — the
runner tunnel additionally requires a *user* identity, which the
per-launch host token does not carry, so the runner dial-back is refused
(`403`) even though the host tunnel connects. This is a framework-level
managed-host interaction shared by **all** sandbox providers (Modal /
Daytona / Islo), not specific to Islo.
So for a managed Islo deployment, front the server with **header or OIDC
auth** (a reverse proxy / IdP injects the user identity on every request,
including the runner WebSocket — see
[`deploy/README.md#auth`](../README.md#auth)), or run it single-user. The
`accounts` provider is fine for CLI-launched hosts (you `omnigent login`,
and that token is what the in-sandbox host forwards), but not yet for the
managed runner dial-back.
Optional `islo:` settings:
```yaml
sandbox:
provider: islo
server_url: https://your-host
islo:
image: docker.io/<you>/omnigent-host:latest # default: official image
env: [OPENAI_API_KEY, GIT_TOKEN] # copy from server env
base_url: https://api.islo.dev # non-default API endpoint
gateway_profile: default # Islo gateway for egress + credential injection
snapshot_name: warm-host # boot from a prebaked snapshot
workdir: /root/workspace # sandbox working directory
vcpus: 2
memory_mb: 4096
disk_gb: 20
```
## Model credentials (LLM keys)
A fresh sandbox has no model credentials of your own. Islo offers **two
distinct ways** to give the agent a model — and they interact, so pick one
deliberately per harness.
### Option A — Islo gateway integration (recommended)
This is the Islo-native path with no Modal/Daytona equivalent. Islo
[gateways](https://docs.islo.dev/cli/gateways) "automatically attach API
keys, tokens, and secrets to outbound requests" at the **network layer**
*"credentials never reach the sandbox process."* You connect a provider
once, server-side, and every sandbox picks it up:
```bash
islo login --tool claude # OAuth-connect Anthropic (alias: --tool anthropic)
islo login --tool openai # …and/or OpenAI
islo status # shows connected integrations
```
This is **not Claude-specific** — it's how Islo supplies model
credentials to *every* harness. Islo pre-seeds each sandbox with a
**phantom** placeholder key (`islo_phantom_…`) in whatever location the
harness reads, per provider:
| Harness | Phantom key location | Provider endpoint |
|---|---|---|
| Claude Code (`claude-native`, `claude-sdk`, `pi`) | `apiKeyHelper` in `~/.claude/settings.json` | `api.anthropic.com` |
| Codex / OpenAI agents | `OPENAI_API_KEY` env var | `api.openai.com` |
The harness sends that placeholder to its provider endpoint; the gateway
intercepts the request and swaps it for your connected credential before
forwarding. The raw key never lands in the sandbox, and the connection is
team-wide — other members don't need their own. (We observed both phantom
keys pre-seeded in a single sandbox.)
These integrations connect **provider API keys** (per-token billing), not
plan/subscription auth — `--tool claude` gives an Anthropic API key, not a
Claude Pro/Max subscription; `--tool openai` gives an OpenAI API key, not
a ChatGPT plan. To use a subscription or plan token on any harness (a
Claude Pro/Max token, a Codex access token), use
[Option B](#option-b--omnigent-env-injection-your-own-key-or-a-subscription).
> [!IMPORTANT]
> If `islo status` shows **"No integrations connected"** for a provider,
> its phantom key resolves to nothing — the harness falls back to a failing
> request (Claude reports "API Usage Billing" and retries). Connect the
> integration for each provider whose harness you use.
#### Path A under managed hosts
This is where the gateway shines: when the **server** launches sandboxes,
you configure **no model credential on the Omnigent side at all**. The
flow:
```
admin (once): islo login --tool claude → connects Anthropic to the Islo ACCOUNT
server ──ISLO_API_KEY──▶ Islo API "create sandbox" ──▶ sandbox under that account
│ Islo pre-seeds the
│ phantom apiKeyHelper
agent's claude → api.anthropic.com (phantom key) ──▶ Islo gateway swaps in the real key
```
The Omnigent server only ever holds `ISLO_API_KEY` — the credential it
uses to *create* sandboxes. Because every managed sandbox is created under
that Islo account, and integrations are connected at the **account/team**
level, each one inherits the connected Claude credential through the
gateway automatically. The only Omnigent-side knob is which gateway a
managed sandbox uses:
```yaml
sandbox:
provider: islo
server_url: https://your-host
islo:
gateway_profile: default # the Islo gateway carrying the connected integration
```
Two consequences worth internalizing:
- **No model secret lives in the Omnigent server's config or
environment** — nothing to leak there. Contrast [Option B under managed
hosts](#option-b--omnigent-env-injection-your-own-key-or-a-subscription),
where the key sits in `sandbox.islo.env` (copied from the server's env
into each sandbox).
- **The integration must be connected on the same Islo account the
server's `ISLO_API_KEY` belongs to.** If your server runs under a
dedicated service/CI Islo account, run `islo login --tool claude` while
authenticated as *that* account — not a personal laptop login.
### Option B — Omnigent env injection (your own key or a subscription)
Bring your own credential by naming it in `OMNIGENT_ISLO_SANDBOX_ENV`
(CLI) or `sandbox.islo.env` (managed); the launcher copies the value from
the launching environment into the sandbox, and the in-sandbox host
forwards the standard harness credential vars to its runners:
| Variable | Enables |
|---|---|
| `ANTHROPIC_API_KEY` | Claude models on the Anthropic API |
| `ANTHROPIC_AUTH_TOKEN`, `ANTHROPIC_BASE_URL` | Anthropic-compatible gateways (LiteLLM, Bedrock/Vertex bridges, corporate proxies) |
| `CLAUDE_CODE_OAUTH_TOKEN` | claude-code with a Claude **subscription** (no API key) |
| `OPENAI_API_KEY` / `OPENAI_BASE_URL` | OpenAI or any OpenAI-compatible endpoint (OpenRouter, vLLM, Ollama, …) |
| `CODEX_ACCESS_TOKEN` | codex with a ChatGPT Business/Enterprise workspace |
| `GEMINI_API_KEY` | Gemini on the Google AI API |
The full per-plan recipes (subscriptions, gateways, open-source models)
are identical to Modal — see the [variable table and
recipes](../modal/README.md#llm-credentials-for-managed-sandboxes). For a
Claude **subscription** specifically, run `claude setup-token` on your own
machine (one-time browser auth) and inject the resulting long-lived token
as `CLAUDE_CODE_OAUTH_TOKEN`. For env vars beyond the standard set, inject
`OMNIGENT_RUNNER_ENV_PASSTHROUGH=NAME1,NAME2`.
> [!NOTE]
> **Your injected Claude credential automatically wins over Islo's phantom
> helper.** Islo seeds an `apiKeyHelper` into every sandbox, and Claude Code
> would normally prefer it over a `CLAUDE_CODE_OAUTH_TOKEN`/`ANTHROPIC_API_KEY`
> in the environment. So when you inject one of those, the Islo launcher
> strips the seeded `apiKeyHelper` at provision time — for **both**
> CLI-launched and server-managed sandboxes — leaving your credential as the
> sole auth path. No manual step, nothing to run inside the sandbox.
Codex/OpenAI needs nothing special either — its phantom is the
`OPENAI_API_KEY` env var, which your injected `OPENAI_API_KEY` simply
overrides.
### Choosing between A and B
| | Option A — gateway | Option B — env injection |
|---|---|---|
| Credential | provider **API key** (Anthropic, OpenAI, …) | your API key **or** subscription/plan token |
| Billing | per-token API | API key, or your subscription/plan |
| Key in sandbox? | **No** (gateway injects out-of-band) | Yes (in the sandbox env) |
| Scope | account/team-wide, all sandboxes | per-sandbox |
| Managed-host config | just `gateway_profile`; no secret on server | key in `sandbox.islo.env` |
| Best for | **managed / production**, API-key billing | your own **subscription** or key; CLI or managed |
| Catch | needs a connected integration | the injected var must be set where the launcher runs |
Pick **one per harness** — connect Islo's integration *or* inject your own
credential, never both. Both work in either launch flow: the launcher
strips Islo's seeded `apiKeyHelper` automatically when you inject a Claude
credential. The same two patterns apply to Codex/OpenAI (`islo login --tool
openai`, or inject `OPENAI_API_KEY` / `CODEX_ACCESS_TOKEN`).
### Git credentials (private repositories)
Inject an HTTPS token as `GIT_TOKEN` (GitLab: add `GIT_USERNAME=oauth2`)
via `OMNIGENT_ISLO_SANDBOX_ENV` / `sandbox.islo.env`. The host image's git
credential helper answers HTTPS auth from it for both the launch-time
clone and the agent's later `fetch` / `push`, writing nothing to disk. Use
HTTPS repository URLs. Details by provider match the [Modal git
guide](../modal/README.md#git-credentials-private-repositories).
## Security considerations
- **Path A keeps the model key out of the sandbox — a real advantage.**
With the gateway, the agent process only ever sees the phantom
placeholder; the real key is injected at Islo's network edge. A
prompt-injected or compromised agent can't exfiltrate a key it never
holds. This is the strongest credential posture of the three providers
for model keys, and the reason to prefer Option A where API-key billing
is acceptable.
- **The gateway terminates TLS to inject.** Credential injection means
Islo's gateway sits in the path of the agent's outbound LLM traffic and
re-originates it — so that traffic (prompts, completions, tool output
sent to the model) is visible at Islo's edge. Acceptable for most teams,
but weigh it for highly sensitive workloads, and scope the
`gateway_profile` to exactly the egress you intend.
- **Option B puts the key in the sandbox.** A subscription token or API key
named in `sandbox.islo.env` is copied into the sandbox environment at
provision time and lives there for the sandbox's life. Prefer scoped,
short-lived credentials, and rely on the per-terminal `bwrap` sandbox
(below) to keep the agent away from it.
- **The agent terminal is sandboxed away from those secrets.** Native
harness terminals run under a bubblewrap OS-sandbox that masks dotfiles
(`~/.ssh`, `~/.aws`, the injected `~/.omnigent` server token) and pins
the agent to its workspace — defense-in-depth *inside* the Islo sandbox,
independent of Islo's own isolation. This is why the image must ship
`bwrap` (see [the host image](#the-host-image)).
- **All managed sandboxes share one Islo org + `ISLO_API_KEY`.**
Cross-user isolation rides on Islo's sandbox boundaries, and the shared
org key can enumerate and delete any sandbox — the same single-tenant-org
shape as the Modal and Daytona providers. Scope the org to this workload.
- **The launch token's lifetime is 7 days.** Islo sandboxes have no
platform lifetime cap, so the per-launch host token must outlive a
long-running sandbox across reconnects (a longer replay window than
Modal's ~24 h; same as Daytona). A relaunch mints a fresh one.
## Lifecycle notes
- **No platform lifetime cap.** Unlike Modal's 24-hour limit, Islo
sandboxes run until deleted. The managed flow deletes a sandbox when its
session is deleted, and the dead-sandbox relaunch path replaces one that
crashed or was removed out-of-band. CLI-launched sandboxes you delete
yourself (`islo rm <id>`).
- **Resources.** Sandboxes default to 2 vCPUs and 4 GiB of memory;
override per managed launch with `vcpus` / `memory_mb` / `disk_gb`.
- **Warm starts.** Set `sandbox.islo.snapshot_name` to boot from a
prebaked Islo snapshot instead of a cold image pull.
- **Provider-side lifecycle** (list / status / delete / stop) — use the
`islo` CLI (`islo ls`, `islo rm <id>`) or the
[dashboard](https://app.islo.dev) directly.
## Cost
Islo bills usage with no seat licenses or idle fees: ~$0.07/CPU-hour,
~$0.04/GB-hour of memory, ~$0.0007/GB-hour of disk — about $0.25/hour for
the default 2 vCPU / 4 GiB sandbox while it runs. New accounts get $50 of
free credits. Rates: [islo.dev](https://islo.dev).
## Troubleshooting
- **Native Claude/Codex terminal fails with `linux_bwrap sandbox requires
the 'bwrap' binary on PATH`.** The native harnesses wrap each agent
terminal in a bubblewrap OS-sandbox; the host image must ship
`bubblewrap`. The `host` Dockerfile target installs it — rebuild from a
current image, or for a one-off on a CLI-launched sandbox run
`apt-get install -y bubblewrap` inside it.
- **Claude shows "API Usage Billing" / "both `CLAUDE_CODE_OAUTH_TOKEN` and
`apiKeyHelper` set."** You injected your own Claude credential (Option B)
but Islo's phantom `apiKeyHelper` is still present — the launcher strips it
automatically at provision, so this means the strip didn't run: confirm the
credential is named in `OMNIGENT_ISLO_SANDBOX_ENV` / `sandbox.islo.env` (the
signal the launcher keys on), and check the provision log for the
"clearing Islo's seeded apiKeyHelper" line.
- **Requests retry then fail with no obvious error.** `islo status` shows
no connected integration, so the phantom `apiKeyHelper` resolves to
nothing. Connect one (Option A) or switch to Option B.
- **"managed host did not come online within 120s."** Check that
`server_url` is publicly reachable from Islo's cloud, then inspect the
in-sandbox host log: `~/.omnigent/logs/host-runner/*.log`.
- **Agent has no credentials.** Verify the injected var names match the
forwarded set above (or are named in `OMNIGENT_RUNNER_ENV_PASSTHROUGH`),
and that each name was actually set in the launching environment.
## Environment variable reference
| Variable | Where it's read | Purpose |
|---|---|---|
| `ISLO_API_KEY` | CLI machine / server | Islo API credentials (required) |
| `ISLO_BASE_URL` | CLI machine / server | Non-default Islo API endpoint (default `https://api.islo.dev`) |
| `OMNIGENT_ISLO_HOST_IMAGE` | CLI machine / server | Override the host image ref (`sandbox.islo.image` takes precedence for managed) |
| `OMNIGENT_ISLO_SANDBOX_ENV` | CLI machine / server | Comma-separated launcher-side env var names to inject (`sandbox.islo.env` takes precedence for managed) |
| `OMNIGENT_RUNNER_ENV_PASSTHROUGH` | inside the sandbox (injected) | Extra env var names the host forwards to runners |
| `GIT_TOKEN` / `GIT_USERNAME` | inside the sandbox (injected) | HTTPS credentials for private repository clone / fetch / push |
+339
View File
@@ -0,0 +1,339 @@
# Omnigent on Kubernetes
Deploy Omnigent to any Kubernetes cluster using Kustomize. The manifests pull
the prebuilt image and set up a persistent volume and health checks. They also
include an Ingress so you can serve the app over HTTPS at a public web address,
but that part is optional — it only matters when people need to reach the server
over the internet, and it pulls in two extra add-ons (ingress-nginx and
cert-manager). For local or dev use, ignore it and connect with `kubectl
port-forward` (see [Verify the deployment](#verify-the-deployment)).
## What gets provisioned
- **Deployment** — single-replica pod running
`ghcr.io/omnigent-ai/omnigent-server`, served on port 8000.
- **Service** — ClusterIP on port 80 → 8000.
- **Ingress** *(optional)* — serves the app over HTTPS at a public web address,
using cert-manager for the certificate. Skip it if the server isn't going on
the internet.
- **PVC** — 10 Gi volume at `/data/artifacts` for the artifact store, minted
cookie secret, and admin credentials.
- **ConfigMap + Secret** — environment config and database credentials.
## Prerequisites
- A Kubernetes cluster (1.25+)
- `kubectl` with Kustomize support (`kubectl kustomize` or standalone `kustomize`)
- A PostgreSQL database (managed or in-cluster — see below)
- *Only if you're putting the server on a public web address:* an ingress
controller (e.g. ingress-nginx) and cert-manager
### Install cluster add-ons for ingress and cert management (optional)
Skip this unless you're putting the server on a public web address. (For local
or dev use you'll reach it with `kubectl port-forward`, or you can let your own
load balancer or proxy handle HTTPS instead.) Otherwise, if your cluster doesn't
already have an ingress controller and cert-manager, install them (pin the
versions to taste):
```bash
# ingress-nginx — use the provider manifest that matches your cluster
# (this is the kind one; for EKS/GKE/AKS use that provider's manifest or Helm chart):
kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/main/deploy/static/provider/kind/deploy.yaml
# cert-manager:
kubectl apply -f https://github.com/cert-manager/cert-manager/releases/latest/download/cert-manager.yaml
# wait until both are ready:
kubectl wait -n ingress-nginx --for=condition=Ready pod \
-l app.kubernetes.io/component=controller --timeout=180s
kubectl wait -n cert-manager --for=condition=Available deployment --all --timeout=180s
```
### Create a cert-manager issuer (optional)
Skip this unless you're using the Ingress. cert-manager fetches the HTTPS
certificate for the Ingress from a `ClusterIssuer` named `letsencrypt-prod`
(the `cert-manager.io/cluster-issuer` annotation in `base/ingress.yaml`). That
issuer is **not** shipped here — create one before deploying, or change the
annotation to match an issuer you already have. Two common choices:
```yaml
# Production — real certificates from Let's Encrypt
# (needs a public domain and an Ingress reachable from the internet):
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-prod
spec:
acme:
server: https://acme-v02.api.letsencrypt.org/directory
email: you@example.com
privateKeySecretRef:
name: letsencrypt-prod
solvers:
- http01:
ingress:
ingressClassName: nginx
```
```yaml
# Local / dev — self-signed (no public DNS needed; browsers will warn):
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-prod
spec:
selfSigned: {}
```
Apply your chosen issuer with `kubectl apply -f <file>`. Without it, cert-manager
logs `IssuerNotFound` and no certificate is issued (the server still runs — only
TLS is affected).
## Deploy with an external database
Use this path when you have a managed Postgres (RDS, Cloud SQL, Neon, etc.).
1. **Edit the secret** — set your real `DATABASE_URL` and generate a cookie
secret:
```bash
# deploy/kubernetes/base/secret.yaml
DATABASE_URL: "postgresql+psycopg://user:pass@your-db-host:5432/omnigent"
OMNIGENT_ACCOUNTS_COOKIE_SECRET: "$(openssl rand -hex 32)"
```
2. **Set your domain** *(skip if you're not using the Ingress)* — replace
`omnigent.example.com` in `base/ingress.yaml` with your domain, and make sure
the `letsencrypt-prod` ClusterIssuer exists (see
[Create a cert-manager issuer](#create-a-cert-manager-issuer)).
3. **Apply:**
```bash
kubectl kustomize deploy/kubernetes/base/ | kubectl apply -f -
```
4. **Create the first admin.** Open the app (via your Ingress host, or
port-forward for a quick check — see
[Verify the deployment](#verify-the-deployment)). With the default `accounts`
provider the first visitor claims the instance: the Setup screen prompts for
a username + password, and whoever finishes it first becomes the admin.
## Deploy with in-cluster Postgres
The `overlays/postgres/` overlay adds a single-replica Postgres 16 StatefulSet
with its own 10 Gi PVC. Good for dev/testing clusters.
1. **Edit secrets** — in `overlays/postgres/secret-patch.yaml`, replace
`changeme` with real passwords:
```bash
POSTGRES_PASSWORD: "<strong-password>"
DATABASE_URL: "postgresql+psycopg://omnigent:<strong-password>@postgres:5432/omnigent"
OMNIGENT_ACCOUNTS_COOKIE_SECRET: "$(openssl rand -hex 32)"
```
2. **Set your domain** *(skip if you're not using the Ingress)* — edit the
hostname in `base/ingress.yaml`, and make sure the `letsencrypt-prod`
ClusterIssuer exists (see
[Create a cert-manager issuer](#create-a-cert-manager-issuer)).
3. **Apply:**
```bash
kubectl kustomize deploy/kubernetes/overlays/postgres/ | kubectl apply -f -
```
## Deploy with OpenShell sandboxes
The `overlays/openshell/` overlay configures the server to provision
[NVIDIA OpenShell](https://github.com/NVIDIA/OpenShell) sandboxes for managed
sessions, and includes RBAC for the
[kubernetes-sigs/agent-sandbox](https://github.com/kubernetes-sigs/agent-sandbox)
CRD when the gateway uses a Kubernetes compute driver.
1. **Edit the configmap patch** — set `OMNIGENT_SANDBOX_SERVER_URL` to the
public URL sandboxes will dial back to, and optionally set `OPENSHELL_GATEWAY`
to a specific gateway name:
```bash
# deploy/kubernetes/overlays/openshell/configmap-patch.yaml
OMNIGENT_SANDBOX_SERVER_URL: "https://omnigent.example.com"
OPENSHELL_GATEWAY: "my-gateway"
```
2. **Edit secrets** — in `overlays/openshell/secret-patch.yaml`, set the
database URL, cookie secret, and the LLM API keys your harness needs:
```bash
DATABASE_URL: "postgresql+psycopg://omnigent:<password>@your-db-host:5432/omnigent"
OMNIGENT_ACCOUNTS_COOKIE_SECRET: "$(openssl rand -hex 32)"
ANTHROPIC_API_KEY: "sk-ant-..."
```
3. **Gateway access** — the server pod needs to reach the OpenShell gateway's
gRPC endpoint. If the gateway runs in-cluster, make sure the NetworkPolicy
allows it (the included policy allows all egress on 443 — tighten to taste).
If the gateway stores its config/TLS material in a Secret, create
`openshell-gateway-config` in the `omnigent` namespace and the deployment
mounts it at `~/.config/openshell`.
4. **Install the agent-sandbox CRD** *(optional)* — if the OpenShell gateway
delegates to the kubernetes-sigs/agent-sandbox controller:
```bash
kubectl apply -f https://raw.githubusercontent.com/kubernetes-sigs/agent-sandbox/main/config/crd/bases/sandbox.agent.k8s.io_agentsandboxes.yaml
```
The overlay's RBAC already grants the server's ServiceAccount permission to
manage `AgentSandbox` resources.
5. **Apply:**
```bash
kubectl kustomize deploy/kubernetes/overlays/openshell/ | kubectl apply -f -
```
For OpenShell + in-cluster Postgres, layer the postgres overlay on top (compose
both bases in a new kustomization, or apply the postgres StatefulSet separately).
See [Network egress policy](../openshell/README.md#network-egress-policy) for
the sandbox-side egress allow-list (server URL + LLM provider hosts).
## Building a UBI image (Red Hat / OpenShift)
For RHEL and OpenShift environments that require UBI-compliant containers, use
the UBI variant of the Dockerfile. It uses Red Hat Universal Base Image 9
(`ubi9/python-312`, `ubi9/nodejs-20`) and runs the server as non-root (UID 1001)
by default — compatible with OpenShift's `restricted-v2` SCC out of the box.
```bash
# from the repo root
docker build -t omnigent-server:ubi -f deploy/docker/Dockerfile.ubi .
```
Then reference the image in the OpenShift overlay by patching the Deployment
or pointing your image stream at it.
## Deploy on Red Hat OpenShift
The `overlays/openshift/` overlay replaces the Ingress with an OpenShift Route
(edge TLS, managed by the platform) and adds a `restricted-v2`-compatible
SecurityContext. No ingress controller or cert-manager add-ons needed.
1. **Edit the secret** in `base/secret.yaml` (same as the external-database
path above).
2. **Set your route hostname** — replace `omnigent.apps.example.com` in
`overlays/openshift/route.yaml` with your cluster's apps domain.
3. **Apply:**
```bash
kubectl kustomize deploy/kubernetes/overlays/openshift/ | oc apply -f -
```
For in-cluster Postgres on OpenShift, use `overlays/openshift-postgres/`
instead — it combines the Postgres StatefulSet, OpenShift Route, and restricted
security contexts:
```bash
# edit overlays/openshift-postgres/secret-patch.yaml with real passwords first
kubectl kustomize deploy/kubernetes/overlays/openshift-postgres/ | oc apply -f -
```
## On-demand sandbox runners
The `overlays/sandbox-runners/` overlay turns on the **`kubernetes`** managed
sandbox provider: a `host_type: managed` session spawns one runner Pod that runs
`omnigent host` as its entrypoint and dials back over the launch-token tunnel. It
adds a dedicated runner namespace, a least-privilege server SA (scoped Pod +
Secret rights, **no `pods/exec`**), and the `sandbox:` server config. The
overlay swaps in the official `omnigent-server-kubernetes` image variant, which
adds the `kubernetes` client extra the provider imports (the base server image
omits it). See `overlays/sandbox-runners/README.md` for the full guide.
```bash
kubectl apply -k deploy/kubernetes/overlays/sandbox-runners
# then create the omnigent-creds harness Secret (see the overlay README)
```
**Credentials & auth** — two separate concerns, don't conflate:
- **Server auth.** Front the server with `header`/`oidc` auth or run single-user;
the built-in `accounts` mode refuses the per-session runner dial-back (`403`),
a framework-level limit shared by all sandbox providers — see [Auth](../README.md#auth).
- **Model keys** (`ANTHROPIC_API_KEY` / `CLAUDE_CODE_OAUTH_TOKEN` / `OPENAI_API_KEY`
/ `GIT_TOKEN` / …) ride the `omnigent-creds` Secret projected into every runner Pod.
Both are detailed in
[`overlays/sandbox-runners/README.md`](overlays/sandbox-runners/README.md#server-auth-managed-hosts).
## Verify the deployment
Check the rollout and reach the server without a public domain:
```bash
kubectl get pods -n omnigent # omnigent (and, with the overlay, postgres) → Running
kubectl rollout status deploy/omnigent -n omnigent
kubectl logs -n omnigent deploy/omnigent # server logs
# Port-forward the Service and open the app locally:
kubectl port-forward -n omnigent svc/omnigent 8000:80
# → http://localhost:8000 (health check: curl localhost:8000/health → {"status":"ok"})
```
The first boot runs database migrations before the server starts listening; the
pod may restart once if the liveness probe fires during that window (see
[Resource sizing](#resource-sizing)).
To test the Ingress itself instead of port-forwarding, point its hostname at a
domain that already resolves to localhost — `omnigent.localtest.me` or
`<node-ip>.sslip.io` — use the self-signed issuer above, and reach it through the
ingress controller's published port.
## Next steps: connect a host
The server is the control plane — agents run on **hosts** that register with it.
A brand-new deployment has none, so connect at least one machine:
```bash
omnigent login https://omnigent.example.com # authenticate the CLI
omnigent host --server https://omnigent.example.com # register this machine
```
The host then appears in the web UI when you start a new chat. See the
[main README](../../README.md) for the full host/auth reference.
## Use your own IdP instead (OIDC) — optional
Optional. The default `accounts` provider (username + password) works out of the
box; use this only to delegate authentication to an external OIDC provider. Add
OIDC env vars to the secret:
```bash
kubectl create secret generic omnigent-oidc -n omnigent \
--from-literal=OMNIGENT_AUTH_PROVIDER=oidc \
--from-literal=OMNIGENT_OIDC_ISSUER=https://github.com \
--from-literal=OMNIGENT_OIDC_CLIENT_ID=<client-id> \
--from-literal=OMNIGENT_OIDC_CLIENT_SECRET=<client-secret> \
--from-literal=OMNIGENT_OIDC_REDIRECT_URI=https://omnigent.example.com/auth/callback \
--from-literal=OMNIGENT_OIDC_COOKIE_SECRET=$(openssl rand -hex 32)
```
Then add `envFrom: [{secretRef: {name: omnigent-oidc}}]` to the Deployment
container spec (or merge the values into `omnigent-secrets`).
## Resource sizing
The server idles around ~275 MB RSS. The manifests request 512 Mi and limit at
1 Gi — adjust to taste. The first boot against a remote Postgres runs
migrations and takes ~1 minute; bump the liveness `initialDelaySeconds` to
~90s if you see the pod get killed during the first deploy.
## Scaling
The server uses an in-memory runner registry, so **only one replica is
supported**. Do not increase `replicas` unless the architecture is changed to
use a shared registry (e.g. Redis).
+14
View File
@@ -0,0 +1,14 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: omnigent-config
labels:
app: omnigent
data:
HOST: "0.0.0.0"
PORT: "8000"
ARTIFACT_DIR: "/data/artifacts"
OMNIGENT_ADMIN_CREDENTIALS_PATH: "/data/admin-credentials"
OMNIGENT_AUTH_ENABLED: "1"
OMNIGENT_AUTH_PROVIDER: "accounts"
OMNIGENT_ACCOUNTS_AUTO_OPEN: "0"
+55
View File
@@ -0,0 +1,55 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: omnigent
labels:
app: omnigent
spec:
replicas: 1
selector:
matchLabels:
app: omnigent
template:
metadata:
labels:
app: omnigent
spec:
containers:
- name: omnigent
image: ghcr.io/omnigent-ai/omnigent-server:latest
ports:
- containerPort: 8000
name: http
envFrom:
- configMapRef:
name: omnigent-config
- secretRef:
name: omnigent-secrets
volumeMounts:
- name: artifacts
mountPath: /data/artifacts
resources:
requests:
memory: "512Mi"
cpu: "250m"
limits:
memory: "1Gi"
cpu: "1000m"
livenessProbe:
httpGet:
path: /health
port: http
initialDelaySeconds: 20
periodSeconds: 30
timeoutSeconds: 5
readinessProbe:
httpGet:
path: /health
port: http
initialDelaySeconds: 10
periodSeconds: 10
timeoutSeconds: 5
volumes:
- name: artifacts
persistentVolumeClaim:
claimName: omnigent-artifacts
+23
View File
@@ -0,0 +1,23 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: omnigent
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
ingressClassName: nginx
tls:
- hosts:
- omnigent.example.com
secretName: omnigent-tls
rules:
- host: omnigent.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: omnigent
port:
name: http
+13
View File
@@ -0,0 +1,13 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: omnigent
resources:
- namespace.yaml
- deployment.yaml
- service.yaml
- ingress.yaml
- pvc.yaml
- configmap.yaml
- secret.yaml
+4
View File
@@ -0,0 +1,4 @@
apiVersion: v1
kind: Namespace
metadata:
name: omnigent
+12
View File
@@ -0,0 +1,12 @@
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: omnigent-artifacts
labels:
app: omnigent
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 10Gi
+12
View File
@@ -0,0 +1,12 @@
apiVersion: v1
kind: Secret
metadata:
name: omnigent-secrets
labels:
app: omnigent
type: Opaque
stringData:
# Replace with your actual database connection string.
DATABASE_URL: "postgresql+psycopg://omnigent:changeme@your-db-host:5432/omnigent"
# Generate with: openssl rand -hex 32
OMNIGENT_ACCOUNTS_COOKIE_SECRET: "changeme"
+15
View File
@@ -0,0 +1,15 @@
apiVersion: v1
kind: Service
metadata:
name: omnigent
labels:
app: omnigent
spec:
type: ClusterIP
selector:
app: omnigent
ports:
- port: 80
targetPort: http
protocol: TCP
name: http
@@ -0,0 +1,10 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: omnigent-config
data:
# Point the server at the sandbox config file mounted by the overlay.
OMNIGENT_CONFIG: "/data/server-config.yaml"
# Env var names the host forwards to runner subprocesses — required so
# the runner inherits the sandbox's proxy vars for egress.
OMNIGENT_RUNNER_ENV_PASSTHROUGH: "https_proxy,http_proxy,HTTPS_PROXY,HTTP_PROXY,NO_PROXY,no_proxy"
@@ -0,0 +1,26 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: omnigent
spec:
template:
spec:
serviceAccountName: omnigent
containers:
- name: omnigent
volumeMounts:
- name: openshell-gateway
mountPath: /home/omnigent/.config/openshell
readOnly: true
- name: server-config
mountPath: /data/server-config.yaml
subPath: config.yaml
readOnly: true
volumes:
- name: openshell-gateway
secret:
secretName: openshell-gateway-config
optional: true
- name: server-config
configMap:
name: omnigent-server-config
@@ -0,0 +1,36 @@
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: omnigent-to-openshell-gateway
labels:
app: omnigent
spec:
podSelector:
matchLabels:
app: omnigent
policyTypes:
- Egress
egress:
# DNS — required for any name resolution (kube-dns / CoreDNS).
- ports:
- protocol: UDP
port: 53
- protocol: TCP
port: 53
# Database — the base config connects via DATABASE_URL on port 5432.
# Tighten the CIDR to your database host or use a namespaceSelector
# if the database runs in-cluster.
- ports:
- protocol: TCP
port: 5432
to:
- ipBlock:
cidr: 0.0.0.0/0
# OpenShell gateway gRPC — replace the CIDR with your gateway's
# actual endpoint to restrict egress to only that host.
- ports:
- protocol: TCP
port: 443
to:
- ipBlock:
cidr: 0.0.0.0/0
@@ -0,0 +1,23 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: omnigent
resources:
- ../../base
- server-config.yaml
- gateway-networkpolicy.yaml
- sandbox-rbac.yaml
- sandbox-clusterrole.yaml
- sandbox-clusterrolebinding.yaml
# Use the server image variant that includes the openshell SDK extra
# (built by CI with OMNIGENT_EXTRAS=openshell).
images:
- name: ghcr.io/omnigent-ai/omnigent-server
newName: ghcr.io/omnigent-ai/omnigent-server-openshell
patches:
- path: configmap-patch.yaml
- path: secret-patch.yaml
- path: deployment-patch.yaml
@@ -0,0 +1,29 @@
# When the OpenShell gateway uses a Kubernetes compute driver (or
# kubernetes-sigs/agent-sandbox as the backend), it needs permissions to
# manage sandbox pods. This ClusterRole grants the minimal set; bind it
# to the gateway's ServiceAccount in its own namespace.
#
# If the gateway runs OUTSIDE the cluster (e.g. a remote Docker driver),
# these RBAC resources are unused — the overlay includes them so the
# in-cluster path works without a second overlay.
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: openshell-sandbox-manager
labels:
app: omnigent
rules:
- apiGroups: [""]
resources: ["pods", "pods/exec", "pods/log"]
verbs: ["create", "get", "list", "watch", "delete"]
- apiGroups: [""]
resources: ["configmaps", "secrets"]
verbs: ["create", "get", "delete"]
- apiGroups: [""]
resources: ["services"]
verbs: ["create", "get", "delete"]
# kubernetes-sigs/agent-sandbox CRD — if the gateway delegates sandbox
# lifecycle to the AgentSandbox controller, it needs these permissions.
- apiGroups: ["sandbox.agent.k8s.io"]
resources: ["agentsandboxes", "agentsandboxes/status"]
verbs: ["create", "get", "list", "watch", "update", "patch", "delete"]
@@ -0,0 +1,20 @@
# Bind the sandbox-manager ClusterRole to the OpenShell GATEWAY's
# ServiceAccount — the gateway is the component that calls the
# Kubernetes API to manage sandbox pods/CRDs, not the Omnigent server.
#
# Replace the subject below with your gateway's actual ServiceAccount
# name and namespace.
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: openshell-sandbox-manager
labels:
app: omnigent
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: openshell-sandbox-manager
subjects:
- kind: ServiceAccount
name: openshell-gateway
namespace: openshell-system
@@ -0,0 +1,6 @@
apiVersion: v1
kind: ServiceAccount
metadata:
name: omnigent
labels:
app: omnigent
@@ -0,0 +1,16 @@
apiVersion: v1
kind: Secret
metadata:
name: omnigent-secrets
type: Opaque
stringData:
DATABASE_URL: "postgresql+psycopg://omnigent:changeme@your-db-host:5432/omnigent"
# Generate with: openssl rand -hex 32
OMNIGENT_ACCOUNTS_COOKIE_SECRET: "changeme"
# --- LLM credentials injected into OpenShell sandboxes ------------------
# The server reads these from env at provision time and injects them
# into sandboxes via the sandbox.openshell.env list in config.yaml.
# Set the ones your harness needs.
ANTHROPIC_API_KEY: ""
OPENAI_API_KEY: ""
GEMINI_API_KEY: ""
@@ -0,0 +1,26 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: omnigent-server-config
labels:
app: omnigent
data:
config.yaml: |
# Server config consumed by load_server_config() via OMNIGENT_CONFIG.
# Only the sandbox section is required here — secrets (DATABASE_URL,
# cookie secret, LLM keys) stay in the environment per 12-factor.
sandbox:
provider: openshell
# Public URL that sandboxes dial back to (must be reachable from the
# sandbox network namespace).
server_url: https://omnigent.example.com
openshell:
# SERVER env var NAMES whose values are injected into sandboxes
# at provision time — this is how LLM keys reach the sandbox.
env:
- ANTHROPIC_API_KEY
- OPENAI_API_KEY
- GEMINI_API_KEY
# Gateway name resolved by the SDK's from_active_cluster(); omit
# to use the active gateway from ~/.config/openshell/active_gateway.
# cluster: my-gateway
@@ -0,0 +1,18 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: omnigent
spec:
template:
spec:
securityContext:
runAsNonRoot: true
containers:
- name: omnigent
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
seccompProfile:
type: RuntimeDefault
@@ -0,0 +1,24 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: omnigent
resources:
- ../../base
- statefulset.yaml
- service.yaml
- route.yaml
patches:
- path: deployment-patch.yaml
- path: secret-patch.yaml
- path: postgres-deployment-patch.yaml
- target:
kind: Ingress
name: omnigent
patch: |
$patch: delete
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: omnigent
@@ -0,0 +1,18 @@
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: postgres
spec:
template:
spec:
securityContext:
runAsNonRoot: true
containers:
- name: postgres
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
seccompProfile:
type: RuntimeDefault
@@ -0,0 +1,17 @@
apiVersion: route.openshift.io/v1
kind: Route
metadata:
name: omnigent
labels:
app: omnigent
spec:
host: omnigent.apps.example.com
to:
kind: Service
name: omnigent
weight: 100
port:
targetPort: http
tls:
termination: edge
insecureEdgeTerminationPolicy: Redirect
@@ -0,0 +1,9 @@
apiVersion: v1
kind: Secret
metadata:
name: omnigent-secrets
type: Opaque
stringData:
DATABASE_URL: "postgresql+psycopg://omnigent:changeme@postgres:5432/omnigent"
POSTGRES_PASSWORD: "changeme"
OMNIGENT_ACCOUNTS_COOKIE_SECRET: "changeme"
@@ -0,0 +1,17 @@
apiVersion: v1
kind: Service
metadata:
name: postgres
labels:
app: omnigent-postgres
component: postgres
spec:
type: ClusterIP
selector:
app: omnigent-postgres
component: postgres
ports:
- port: 5432
targetPort: postgres
protocol: TCP
name: postgres
@@ -0,0 +1,66 @@
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: postgres
labels:
app: omnigent-postgres
component: postgres
spec:
serviceName: postgres
replicas: 1
selector:
matchLabels:
app: omnigent-postgres
component: postgres
template:
metadata:
labels:
app: omnigent-postgres
component: postgres
spec:
containers:
- name: postgres
image: postgres:16-alpine
ports:
- containerPort: 5432
name: postgres
env:
- name: POSTGRES_DB
value: omnigent
- name: POSTGRES_USER
value: omnigent
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: omnigent-secrets
key: POSTGRES_PASSWORD
volumeMounts:
- name: postgres-data
mountPath: /var/lib/postgresql/data
resources:
requests:
memory: "256Mi"
cpu: "100m"
limits:
memory: "512Mi"
cpu: "500m"
readinessProbe:
exec:
command: ["pg_isready", "-U", "omnigent"]
periodSeconds: 10
timeoutSeconds: 5
livenessProbe:
exec:
command: ["pg_isready", "-U", "omnigent"]
initialDelaySeconds: 15
periodSeconds: 30
timeoutSeconds: 5
volumeClaimTemplates:
- metadata:
name: postgres-data
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 10Gi
@@ -0,0 +1,18 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: omnigent
spec:
template:
spec:
securityContext:
runAsNonRoot: true
containers:
- name: omnigent
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
seccompProfile:
type: RuntimeDefault
@@ -0,0 +1,20 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: omnigent
resources:
- ../../base
- route.yaml
patches:
- path: deployment-patch.yaml
- target:
kind: Ingress
name: omnigent
patch: |
$patch: delete
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: omnigent
@@ -0,0 +1,17 @@
apiVersion: route.openshift.io/v1
kind: Route
metadata:
name: omnigent
labels:
app: omnigent
spec:
host: omnigent.apps.example.com
to:
kind: Service
name: omnigent
weight: 100
port:
targetPort: http
tls:
termination: edge
insecureEdgeTerminationPolicy: Redirect
@@ -0,0 +1,12 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: omnigent
resources:
- ../../base
- statefulset.yaml
- service.yaml
patches:
- path: secret-patch.yaml
@@ -0,0 +1,9 @@
apiVersion: v1
kind: Secret
metadata:
name: omnigent-secrets
type: Opaque
stringData:
DATABASE_URL: "postgresql+psycopg://omnigent:changeme@postgres:5432/omnigent"
POSTGRES_PASSWORD: "changeme"
OMNIGENT_ACCOUNTS_COOKIE_SECRET: "changeme"
@@ -0,0 +1,17 @@
apiVersion: v1
kind: Service
metadata:
name: postgres
labels:
app: omnigent-postgres
component: postgres
spec:
type: ClusterIP
selector:
app: omnigent-postgres
component: postgres
ports:
- port: 5432
targetPort: postgres
protocol: TCP
name: postgres
@@ -0,0 +1,66 @@
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: postgres
labels:
app: omnigent-postgres
component: postgres
spec:
serviceName: postgres
replicas: 1
selector:
matchLabels:
app: omnigent-postgres
component: postgres
template:
metadata:
labels:
app: omnigent-postgres
component: postgres
spec:
containers:
- name: postgres
image: postgres:16-alpine
ports:
- containerPort: 5432
name: postgres
env:
- name: POSTGRES_DB
value: omnigent
- name: POSTGRES_USER
value: omnigent
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: omnigent-secrets
key: POSTGRES_PASSWORD
volumeMounts:
- name: postgres-data
mountPath: /var/lib/postgresql/data
resources:
requests:
memory: "256Mi"
cpu: "100m"
limits:
memory: "512Mi"
cpu: "500m"
readinessProbe:
exec:
command: ["pg_isready", "-U", "omnigent"]
periodSeconds: 10
timeoutSeconds: 5
livenessProbe:
exec:
command: ["pg_isready", "-U", "omnigent"]
initialDelaySeconds: 15
periodSeconds: 30
timeoutSeconds: 5
volumeClaimTemplates:
- metadata:
name: postgres-data
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 10Gi
@@ -0,0 +1,161 @@
# Kubernetes sandbox runners (on-demand host Pods)
This Kustomize overlay turns on the **`kubernetes`** managed-sandbox provider: a
`host_type: managed` session spawns one **runner Pod** that runs `omnigent host`
as its container entrypoint and dials back to the server over the existing
launch-token tunnel. It layers the RBAC + config the provider needs onto the
base server deployment.
## Launch model: entrypoint-as-host
The runner Pod's container command **is** the host. An **init container**
prepares the workspace (`mkdir` + optional `git clone`); the **main container**
then runs `omnigent host` under a tiny PID-1 reaper. The host re-parents runner
processes to PID 1, which the reaper reaps; SIGTERM is forwarded for graceful
shutdown.
The launch token is delivered through a **per-Pod Kubernetes Secret** referenced
by the Pod's `secretKeyRef` — it never enters the Pod spec, a command line, or
an audit log. The launcher creates that Secret at provision and deletes it
alongside the Pod at terminate.
Because the host is **never started by `exec`-ing into an already-running
container**, this provider needs **no `pods/exec` grant** — and avoids the
exec-into-running-container class of runtime issues entirely. The server SA's
rights are the minimum the launcher calls: create/get/delete Pods, get
`pods/log` (start-failure diagnostics only), create/delete Secrets (the per-Pod
token), and list events.
## Two-namespace, least-blast-radius design
| Namespace | Holds |
|---|---|
| `omnigent` | the server, its DB/PVC, its Secrets, the `omnigent-server` SA |
| `omnigent-sandboxes` | runner Pods, the per-Pod token Secrets, the harness-creds Secret, the powerless `omnigent-runner` SA, the scoped Role + RoleBinding |
The server SA's Pod/Secret rights are a **namespaced Role** bound (cross-namespace)
to `omnigent-sandboxes` only — so a compromised server can manage runner Pods but
**cannot** delete the server/DB Pods, read the server's Secrets, or execute
commands inside any Pod. The runner namespace enforces Pod Security `restricted`;
the generated runner Pod is already restricted-compliant (non-root uid 1000, drop
`ALL` caps, `seccompProfile: RuntimeDefault`, no privilege escalation).
## Prerequisites
1. **A server image built with the `kubernetes` extra.** The overlay's
`images:` block already points at the official `omnigent-server-kubernetes`
variant, which includes it — nothing to build. If you self-build instead,
keep `kubernetes` in `OMNIGENT_EXTRAS` (see `deploy/docker`) or
`_ensure_sdk()` fails every launch, and point `images:` at your build.
2. **Harness credentials.** The runners read their LLM / git credentials from a
Secret named by `secret_name` (default `omnigent-creds`); you create it out of
band after applying the overlay — see step 2 of **Apply**. It is deliberately
not checked in; for production prefer a sealed-secret / external-secrets Secret.
## Apply
```sh
# 1. RBAC, the runner namespace, the server sandbox config, and the Deployment patch.
kubectl apply -k deploy/kubernetes/overlays/sandbox-runners
# 2. The harness-credentials Secret the runners read — created out of band, like
# the OIDC secret in ../../README.md. Add only the keys your agents use.
kubectl create secret generic omnigent-creds -n omnigent-sandboxes \
--from-literal=ANTHROPIC_API_KEY=sk-ant-... \
--from-literal=OPENAI_API_KEY=sk-...
```
Step 1 creates the runner namespace, both ServiceAccounts, the scoped Role +
RoleBinding, and the server `sandbox:` config, and patches the server Deployment
to run as `omnigent-server` with the config mounted. Step 2 supplies the model /
git credentials — see [Model credentials](#model-credentials-llm-keys) and
[Git credentials](#git-credentials-private-repositories) below for which keys to
set (and a sealed-secret / external-secrets operator for production).
> **The `secret_name` Secret must exist before the first managed launch.** Its
> `envFrom` is non-optional, so a runner Pod whose Secret is missing never starts
> — it stalls in `CreateContainerConfigError` rather than launching without
> credentials. Create it (step 2) right after the `kubectl apply -k` in step 1.
## Server auth (managed hosts)
There are two kinds of credential here: the **server-connection** auth below, and
the **model** keys in the next section — keep them separate.
A managed sandbox opens two connections back to the server. The **host tunnel** is
authenticated by the per-launch token directly — the per-Pod token Secret, always
works. But each session's **runner tunnel**, opened by the runner the host spawns,
authenticates with whatever *server* credential it can resolve — **not** the host
token. So how you front the server matters:
- **Header / OIDC-proxy auth, or single-user (no-auth) servers** — the runner
tunnel needs no extra identity; managed hosts work out of the box. (Verified
end-to-end on a header-auth server: a `host_type: managed` session launched a
runner Pod and ran a Claude turn on an injected `CLAUDE_CODE_OAUTH_TOKEN`.)
- **The built-in `accounts` provider (`OMNIGENT_AUTH_ENABLED=1`)** — the runner
tunnel additionally requires a *user* identity, which the per-launch host token
does not carry, so the runner dial-back is refused (`403`) even though the host
tunnel connects. This is a framework-level managed-host interaction shared by
**all** sandbox providers (Modal / Daytona / Islo / …), not specific to Kubernetes.
So front the server with **header or OIDC auth** — a reverse proxy / IdP injects
the user identity on every request, including the runner WebSocket (see
[`deploy/README.md`](../../../README.md#auth)) — or run it single-user.
## Model credentials (LLM keys)
A fresh runner Pod has no model keys. They ride the **`omnigent-creds` Secret**
(`secret_name`, projected into every Pod via `envFrom`) created in [Apply](#apply);
the in-sandbox host forwards the standard harness credential vars to its runners.
Which variables to inject — first-party APIs, gateways (`*_BASE_URL`),
subscriptions — is identical to Modal; see the [variable table and per-plan
recipes](../../../modal/README.md#llm-credentials-for-managed-sandboxes). For a
Claude **subscription**, run `claude setup-token` on your own machine (one-time
browser auth) and inject the long-lived token as `CLAUDE_CODE_OAUTH_TOKEN`. For
env vars beyond the standard harness set, also set
`OMNIGENT_RUNNER_ENV_PASSTHROUGH=NAME1,NAME2`.
## Git credentials (private repositories)
Inject an HTTPS token as `GIT_TOKEN` (GitLab: add `GIT_USERNAME=oauth2`) into the
`omnigent-creds` Secret. The host image's git credential helper answers HTTPS auth
from it for both the launch-time clone and the agent's later `fetch` / `push`,
writing nothing to disk — use HTTPS repository URLs. Details by provider match the
[Modal git guide](../../../modal/README.md#git-credentials-private-repositories).
## Configuration (`sandbox-config.yaml`)
| Key | Meaning |
|---|---|
| `server_url` | URL the runner Pod's host dials back to (in-cluster service DNS by default). |
| `namespace` | Runner-Pod namespace (defaults to `omnigent-sandboxes`). |
| `secret_name` | Harness-creds Secret projected into every Pod via `envFrom`. |
| `service_account` | ServiceAccount the runner Pods run as (powerless). |
| `image` | Optional runner image override (defaults to the official multi-arch amd64/arm64 host image). |
| `env` | Optional list of SERVER env-var names to inject as literal Pod env (prefer `secret_name` for credentials). |
| `node_selector` | Optional extra node labels, merged with a default `kubernetes.io/arch: amd64` — set that key to `arm64` to schedule runners on arm64 nodes. (arm64 note: the CEL policy module is unavailable there — `cel-expr-python` ships no aarch64 wheel — and degrades gracefully.) |
| `resources` | Optional `requests` / `limits` (`cpu` / `memory`) override. |
| `in_cluster` | Optional cluster-config source: `true` (in-cluster SA only), `false` (kubeconfig only), omit (try in-cluster, then kubeconfig). |
| `kubeconfig` | Optional kubeconfig path for the out-of-cluster fallback (env: `OMNIGENT_KUBERNETES_KUBECONFIG`). |
## Troubleshooting
- **Launch fails fast with a clear reason.** When a Pod can't schedule, pull its
image, or clone its repo, the launch error carries the diagnosis — recent Pod
events and a tail of the failed container's log (e.g. the `git clone` error
from the init container). No need to catch the Pod before it's reaped.
- **Inspect a stuck launch:** `kubectl describe pod <pod> -n omnigent-sandboxes`
and `kubectl logs <pod> -n omnigent-sandboxes -c host` (or `-c workspace-prep`
for the clone step).
- **403 on launch:** the server SA is missing the Role — re-apply this overlay
and confirm the cross-namespace RoleBinding subject namespace is `omnigent`.
- **Runner Pod stuck in `CreateContainerConfigError`:** the `secret_name` Secret
(`omnigent-creds`) doesn't exist in the runner namespace — its `envFrom` is
non-optional, so the Pod can't start. Create it (see [Apply](#apply)).
- **Host comes online but the session hangs / 403s on the first message:** the
server is using the built-in `accounts` provider, which doesn't support the
managed runner dial-back — see [Server auth](#server-auth-managed-hosts) (use
header/OIDC auth, or run single-user).
- **401 / "could not load Kubernetes configuration":** out of cluster, the server
can't find a kubeconfig — set `kubeconfig` (or `OMNIGENT_KUBERNETES_KUBECONFIG`),
or unset `in_cluster: true` if it isn't actually running in the cluster.
@@ -0,0 +1,31 @@
---
# Patch the base server Deployment so the kubernetes sandbox provider works:
# 1. Run as the `omnigent-server` ServiceAccount (bound by rolebinding.yaml to
# the pods + secrets Role in role.yaml) so the launcher can manage runner
# Pods and their per-launch token Secrets.
# 2. Mount the sandbox config.yaml and point OMNIGENT_CONFIG at it so the
# server parses the `sandbox: provider: kubernetes` section.
#
# NOTE: the server image must include the `kubernetes` extra
# (`pip install 'omnigent[kubernetes]'`) for the provider to import the client.
apiVersion: apps/v1
kind: Deployment
metadata:
name: omnigent
spec:
template:
spec:
serviceAccountName: omnigent-server
containers:
- name: omnigent
env:
- name: OMNIGENT_CONFIG
value: /etc/omnigent/config.yaml
volumeMounts:
- name: sandbox-config
mountPath: /etc/omnigent
readOnly: true
volumes:
- name: sandbox-config
configMap:
name: omnigent-sandbox-config
@@ -0,0 +1,30 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
# On-demand runner-Pod sandbox provider, layered on the base server deploy.
# Two-namespace split (server in `omnigent`, runners in `omnigent-sandboxes`) — see
# README.md. No overlay-wide `namespace:`; each resource sets its own explicitly.
resources:
- ../../base
- serviceaccount-server.yaml # SA in omnigent (server runs as this)
- namespace-sandboxes.yaml # the dedicated omnigent-sandboxes namespace
- serviceaccount-runner.yaml # runner SA in omnigent-sandboxes
- role.yaml # Role in omnigent-sandboxes (scoped Pod + Secret rights)
- rolebinding.yaml # cross-ns binding: server SA → runner-ns Role
- sandbox-config.yaml # server config (omnigent); points at runner ns
# The harness-credentials Secret (sandbox.kubernetes.secret_name, default
# omnigent-creds) is NOT checked in — create it out of band like the base
# OIDC secret (see README.md "Apply"). Prefer sealed-secrets/external-secrets.
# Use the server image variant that includes the kubernetes client extra
# (built by CI with OMNIGENT_EXTRAS=kubernetes). The base image omits it, so a
# managed launch there fails to import the client. Self-builds must keep
# `kubernetes` in OMNIGENT_EXTRAS (see deploy/docker); point newName at such a
# build here if you use one.
images:
- name: ghcr.io/omnigent-ai/omnigent-server
newName: ghcr.io/omnigent-ai/omnigent-server-kubernetes
patches:
- path: deployment-patch.yaml
@@ -0,0 +1,24 @@
---
# Dedicated namespace for the on-demand agent-runner Pods.
# Kept SEPARATE from the server namespace (`omnigent`) so the server SA's Pod
# create/get/delete + Secret create/delete rights — scoped to this namespace via
# role.yaml + rolebinding.yaml — can never reach the server/DB Pods or the
# server's Secrets. The runner SA, the harness-credentials Secret, the Role and
# the RoleBinding all live here; only the omnigent-server SA stays in `omnigent`.
apiVersion: v1
kind: Namespace
metadata:
name: omnigent-sandboxes
labels:
app.kubernetes.io/name: omnigent
app.kubernetes.io/component: runner
# Pod Security Admission: enforce the `restricted` standard in the runner
# namespace. Defense-in-depth — even though the server SA holds pods/create
# here, it cannot create a privileged / hostPath / host-namespace Pod. The
# launcher's generated runner Pod is already restricted-compliant
# (runAsNonRoot, drop ALL caps, seccompProfile RuntimeDefault, no privilege
# escalation), so enforcement does not reject legitimate runners.
pod-security.kubernetes.io/enforce: restricted
pod-security.kubernetes.io/enforce-version: latest
pod-security.kubernetes.io/warn: restricted
pod-security.kubernetes.io/audit: restricted
@@ -0,0 +1,47 @@
---
# Namespaced Role granting the server EXACTLY what the entrypoint-as-host
# launcher calls — nothing more (no watch, no pods/exec). Lives in the DEDICATED
# runner namespace `omnigent-sandboxes` and is bound to the omnigent-server SA
# (in `omnigent`) via the cross-namespace rolebinding.yaml. Because the grant is
# a namespaced Role here, it can ONLY ever touch objects in `omnigent-sandboxes`,
# never the server/DB Pods or Secrets in `omnigent`.
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: omnigent-sandbox-manager
namespace: omnigent-sandboxes
labels:
app.kubernetes.io/name: omnigent
app.kubernetes.io/component: server
rules:
# Manage the lifecycle of runner Pods, scoped to exactly what the launcher
# calls: provision_managed_host() creates them, the pod-start wait reads them
# (read_namespaced_pod is a `get`), and terminate() deletes them. The launcher
# never watches Pods, so `watch` is omitted. NOTE: there is deliberately NO
# `pods/exec` grant — the host runs as the Pod's OWN entrypoint
# (`omnigent host` under a PID-1 reaper), so the server never execs into a
# running container. Dropping exec removes the most powerful grant a
# compromised server could abuse (arbitrary in-Pod command execution).
- apiGroups: [""]
resources: ["pods"]
verbs: ["create", "get", "delete"]
# Start-failure diagnostics ONLY: when a Pod won't start, the launcher tails
# the failed container's log (e.g. the init container's `git clone` error) so
# the launch error names WHAT failed instead of a generic timeout. Read-only.
- apiGroups: [""]
resources: ["pods/log"]
verbs: ["get"]
# The per-launch token rides a per-Pod Secret (referenced by the Pod's
# `secretKeyRef`), so the launch token never enters the Pod spec or any
# audit-logged surface. The launcher creates that Secret at provision and
# deletes it alongside the Pod at terminate — hence create + delete (no get:
# the launcher never reads Secrets back, and the harness-credentials Secret is
# operator-managed, not touched here).
- apiGroups: [""]
resources: ["secrets"]
verbs: ["create", "delete"]
# Surface scheduler/kubelet events (FailedScheduling, Failed pull, …) in the
# provider's error messages when a Pod won't become ready.
- apiGroups: [""]
resources: ["events"]
verbs: ["list"]
@@ -0,0 +1,25 @@
---
# CROSS-NAMESPACE binding: this RoleBinding lives in the runner namespace
# `omnigent-sandboxes` and grants the omnigent-sandbox-manager Role (also in
# `omnigent-sandboxes`) to the omnigent-server SA, which lives in the SEPARATE
# server namespace `omnigent`. A RoleBinding may reference a subject in another
# namespace, so the server gets runner-Pod management rights in
# `omnigent-sandboxes` ONLY — never in its own namespace. The subject namespace
# is set explicitly (NOT injected) so the binding is unambiguous. The runner SA
# is deliberately absent — runner Pods need no API access.
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: omnigent-sandbox-manager
namespace: omnigent-sandboxes
labels:
app.kubernetes.io/name: omnigent
app.kubernetes.io/component: server
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: omnigent-sandbox-manager
subjects:
- kind: ServiceAccount
name: omnigent-server
namespace: omnigent
@@ -0,0 +1,39 @@
---
# ConfigMap supplying the server's `sandbox:` section. The managed-sandbox backend
# is read from the OMNIGENT_CONFIG file (not the env-based omnigent-config
# ConfigMap); deployment-patch.yaml mounts this at /etc/omnigent and points
# OMNIGENT_CONFIG at it.
apiVersion: v1
kind: ConfigMap
metadata:
name: omnigent-sandbox-config
namespace: omnigent # mounted by the server Pod
labels:
app.kubernetes.io/name: omnigent
app.kubernetes.io/component: server
data:
config.yaml: |
sandbox:
provider: kubernetes
# URL the runner Pod's host dials back to. In-cluster service DNS (the base
# Service listens on port 80) is simplest; use your ingress URL if runner
# Pods must reach the server through it.
server_url: http://omnigent.omnigent.svc.cluster.local
kubernetes:
# Runner-Pod namespace (secret_name / service_account resolve here).
namespace: omnigent-sandboxes
# Harness LLM/git creds projected into every Pod via envFrom. Created out
# of band (see README "Apply") — separate from the per-Pod launch-token Secret.
secret_name: omnigent-creds
# ServiceAccount the runner Pods run as (deliberately powerless).
service_account: omnigent-runner
# ── all optional below ──
# image: ghcr.io/your-org/omnigent-host:latest # default: official multi-arch (amd64/arm64) host image
# env: [PROXY_URL] # SERVER env vars injected as literal Pod env (prefer secret_name for creds)
# node_selector: # extra node labels; default kubernetes.io/arch: amd64, override to arm64 to run there
# disktype: ssd
# resources: # runner Pod sizing (defaults: 0.5-2 cpu / 1-4Gi)
# requests: {cpu: "500m", memory: "1Gi"}
# limits: {cpu: "2", memory: "4Gi"}
# in_cluster: true # config source: true=in-cluster SA only, false=kubeconfig only, omit=try both
# kubeconfig: /path/to/config # out-of-cluster kubeconfig (env: OMNIGENT_KUBERNETES_KUBECONFIG)
@@ -0,0 +1,14 @@
---
# The runner SA is intentionally powerless — it is bound to no Role/ClusterRole.
# Runner Pods set automountServiceAccountToken: false regardless, so its token
# is never even mounted; this SA is a policy handle (NetworkPolicy, PodSecurity,
# quotas can target runner Pods distinctly from the server), not an access grant.
apiVersion: v1
kind: ServiceAccount
metadata:
name: omnigent-runner
namespace: omnigent-sandboxes
labels:
app.kubernetes.io/name: omnigent
app.kubernetes.io/component: runner
automountServiceAccountToken: false
@@ -0,0 +1,31 @@
---
# RBAC for the kubernetes sandbox provider (on-demand runner Pods).
#
# TWO-NAMESPACE, LEAST-BLAST-RADIUS DESIGN: the server, its DB/PVC and its
# Secrets live in `omnigent`; the runner Pods live in a SEPARATE
# `omnigent-sandboxes` namespace. The server SA's pods create/get/delete +
# secrets create/delete rights are scoped (via a Role + RoleBinding) to
# `omnigent-sandboxes` ONLY — so a compromised server can manage RUNNER Pods but
# CANNOT delete the server/DB Pods, read the server's Secrets, nor (because
# there is no pods/exec grant) execute commands inside any Pod.
#
# omnigent-server — the Omnigent server runs as this SA (this file; lives in
# `omnigent`). deployment-patch.yaml sets
# serviceAccountName: omnigent-server. The launcher uses its
# in-cluster token to manage runner Pods + their token
# Secrets in `omnigent-sandboxes`.
# omnigent-runner — the runner Pods run as this SA (serviceaccount-runner.yaml,
# lives in `omnigent-sandboxes`); it has NO API rights.
#
# Role (role.yaml) + RoleBinding (rolebinding.yaml) live in `omnigent-sandboxes`;
# the RoleBinding subject is the omnigent-server SA in `omnigent` (a
# cross-namespace binding — see rolebinding.yaml). Scope is a namespaced Role
# (NOT a ClusterRole) so the grant can never reach beyond the runner namespace.
apiVersion: v1
kind: ServiceAccount
metadata:
name: omnigent-server
namespace: omnigent
labels:
app.kubernetes.io/name: omnigent
app.kubernetes.io/component: server
+462
View File
@@ -0,0 +1,462 @@
# Omnigent on Modal
[Modal](https://modal.com) plays two distinct roles for Omnigent:
1. **[Server deploy target](#deploying-the-server)** — run the
Omnigent server itself on Modal as a single always-on web server
(`modal_app.py` in this directory).
2. **[Sandbox provider](#sandboxes-for-runner-hosts)** — disposable
cloud machines for running Omnigent *hosts*, so sessions execute in
the cloud instead of on your laptop.
The two are independent: you can deploy the server anywhere and still
use Modal sandboxes for hosts, or vice versa.
## Deploying the server
Run the Omnigent server on Modal as a single always-on web server.
`modal_app.py` pulls the standard server image and launches the same
Docker entrypoint every other platform uses; Modal provides the HTTPS
URL, log streaming, and a persistent Volume for the artifact store —
uploaded agent bundles survive restarts and redeploys here, unlike on
Heroku or Cloudflare.
### Prerequisites
- A Modal account and the CLI: `pip install modal && modal setup`.
No Docker needed locally — Modal's builders pull the image.
- A Postgres database. Modal has no managed Postgres — the fastest is
**Neon**: create one at [pg.new](https://pg.new) and copy the
connection string.
### Deploy
```bash
# 1. One secret bundle with the three required values. The app URL is
# deterministic: https://<workspace>--omnigent-server.modal.run
# (your workspace name is shown by `modal profile current`).
modal secret create omnigent-deploy \
DATABASE_URL='postgres://…neon.tech/…' \
OMNIGENT_ACCOUNTS_COOKIE_SECRET="$(openssl rand -hex 32)" \
OMNIGENT_ACCOUNTS_BASE_URL='https://<workspace>--omnigent-server.modal.run'
# 2. Ship it.
modal deploy deploy/modal/modal_app.py
```
`modal deploy` prints the live URL — if it differs from what you guessed
in step 1 (e.g. a non-default Modal environment adds a suffix), update
the secret and redeploy.
The first boot runs DB migrations over the network (~1 minute on Neon).
**Get the admin password:** the first boot prints it to the app log:
```bash
modal app logs omnigent
```
```
✓ Created initial admin account (accounts auth provider).
password: <generated>
```
Log in as the admin and invite teammates from **Members** in the web UI.
> To set a known admin password instead, add
> `OMNIGENT_ACCOUNTS_INIT_ADMIN_PASSWORD=<password>` to the
> `omnigent-deploy` secret before the first deploy.
### Modal-specific caveats
- **2 MiB WebSocket message cap.** Modal's ingress limits WebSocket
messages to 2 MiB each, well below the runner tunnel's own 100 MiB
allowance. Normal streaming traffic (events, terminal frames) is far
smaller, but a single very large tool payload over the tunnel can
fail on this platform.
- **Connections reset at the 24 h input timeout.** A proxied WebSocket
occupies one Modal function input, and inputs are capped at 24 hours
— so a tunnel lives at most a day before being cut. Runners
auto-reconnect (0.510 s jittered backoff).
- **One always-on container by design.** `min_containers=1` /
`max_containers=1` in `modal_app.py`: the runner registry is
in-memory, so traffic must land on a single container, and
scale-to-zero would kill live tunnels. Don't raise `max_containers`
expecting horizontal scaling.
- **No SQLite tier.** The artifact Volume is durable but is not a place
for a SQLite database (eventual-consistency semantics); use Postgres.
### Use your own IdP instead (OIDC)
Add the OIDC values to the `omnigent-deploy` secret (Modal secrets are
key-value bundles; `modal secret create` with the same name replaces it)
and redeploy:
```bash
modal secret create omnigent-deploy \
DATABASE_URL='…' \
OMNIGENT_AUTH_PROVIDER=oidc \
OMNIGENT_OIDC_ISSUER='https://github.com' \
OMNIGENT_OIDC_CLIENT_ID='…' \
OMNIGENT_OIDC_CLIENT_SECRET='…' \
OMNIGENT_OIDC_REDIRECT_URI='https://<workspace>--omnigent-server.modal.run/auth/callback' \
OMNIGENT_OIDC_COOKIE_SECRET="$(openssl rand -hex 32)"
```
The IdP registration steps (GitHub / Google / Okta callback URLs, domain
allow-listing) are identical to the other platforms — see
[`deploy/render/README.md`](../render/README.md#use-your-own-idp-instead-oidc).
### Custom domain
Pass `custom_domains=["omnigent.example.com"]` to `@modal.web_server`
in `modal_app.py` (requires a paid Modal plan), point your DNS at Modal
per the printed instructions, and update `OMNIGENT_ACCOUNTS_BASE_URL`
(or the OIDC redirect URI) to match.
### Upgrading
`modal deploy deploy/modal/modal_app.py` again — Modal re-resolves
`ghcr.io/omnigent-ai/omnigent-server:latest`, so a redeploy is an
upgrade. The rollout replaces the container; runners reconnect.
### Cost
Modal bills actual usage: memory at ~$0.008/GiB-hour and CPU by the
cycle (so an idle server's CPU line is small). An always-on 1 GiB
instance runs roughly **$68/month**, which fits inside the Starter
plan's **$30/month of free credits** — making this effectively free for
a lightly loaded server. Rates: [modal.com/pricing](https://modal.com/pricing).
## Sandboxes for runner hosts
Modal sandboxes give you disposable cloud machines for running
Omnigent hosts — no laptop tethered to a session, no VM to babysit.
There are two ways to use them:
1. **CLI-launched sandboxes** — you provision a sandbox from your
terminal and register it as a host with your server. Good for
development and for running your local checkout's code in the cloud.
2. **Server-managed sandboxes** — the server provisions a sandbox
automatically when a session is created with
`"host_type": "managed"`, and terminates it when the session is
deleted. Good for production deployments where users shouldn't have
to think about hosts at all.
Both boot from the official prebaked host image, so startup is seconds,
not minutes.
### Sandbox prerequisites
```bash
pip install 'omnigent[modal]' # installs the modal SDK extra
modal token new # one-time browser auth with Modal
```
`modal token new` writes `~/.modal.toml`. Anywhere Omnigent needs to
talk to Modal (your laptop for the CLI flow, the server for the managed
flow), Modal credentials must be available — either that file or the
`MODAL_TOKEN_ID` / `MODAL_TOKEN_SECRET` environment variables.
### The host image
Sandboxes boot from `ghcr.io/omnigent-ai/omnigent-host:latest`, an image
published by CI from the `host` target of
[`deploy/docker/Dockerfile`](../docker/Dockerfile) with Omnigent
and its dependencies preinstalled — including the coding-harness CLIs
(`claude`, `codex`, `pi`, `kiro-cli`), so agents on any harness run without an
in-sandbox install.
To use a different image (a fork, or extra tooling baked in), build the
same target and push it anywhere Modal can pull from:
```bash
docker build -f deploy/docker/Dockerfile --target host \
-t docker.io/<you>/omnigent-host:latest .
docker push docker.io/<you>/omnigent-host:latest
```
Then point Omnigent at it — `OMNIGENT_MODAL_HOST_IMAGE` for the CLI
flow, or `sandbox.modal.image` in the server config for the managed
flow (see below). For private registries, set
`OMNIGENT_MODAL_REGISTRY_SECRET` to the name of a
[Modal secret](https://modal.com/secrets) containing
`REGISTRY_USERNAME` / `REGISTRY_PASSWORD`.
> [!NOTE]
> Building on Apple Silicon? Pass `--platform linux/amd64` — Modal
> sandboxes run x86_64.
### CLI-launched sandboxes
Provision a sandbox and ship your local checkout into it:
```bash
omnigent sandbox create --provider modal
```
This pulls the host image, builds wheels from your local checkout, and
overlays them on top — so the sandbox runs *your* code, not whatever
the image was built from. Then register it as a host with your server:
```bash
omnigent sandbox connect --provider modal \
--sandbox-id <id-printed-by-create> \
--server https://your-host
```
`connect` runs `omnigent host` inside the sandbox and holds the
connection open in your terminal — Ctrl-C tears it down. New sessions
targeting that host now run in the sandbox.
Running multiple sandboxes against one server? Pass a unique
`--host-name <label>` to each `connect` — the server keys hosts on
(owner, name), and sandboxes that share a hostname collide.
Sandboxes are disposable. When your code changes, create a new one.
> [!NOTE]
> Modal caps sandbox lifetime at 24 hours (a platform hard limit).
> Re-run `create` + `connect` to roll the host onto a fresh sandbox.
For provider-side lifecycle (list / status / terminate), use Modal's
own tooling — the [Modal dashboard](https://modal.com/sandboxes) or the
`modal` CLI.
### Connecting to an authenticated server
`connect` runs `omnigent host` inside the sandbox, and that host must
present credentials when it dials back to a server that requires
authentication. The interactive `omnigent login` browser flow can't
run inside a sandbox, so inject the keys for the relevant server
instead: park them in a [Modal secret](https://modal.com/secrets) and
name it in `OMNIGENT_MODAL_SANDBOX_SECRETS` (comma-separated) before
running `create`:
```bash
modal secret create omnigent-server-auth \
DATABRICKS_HOST=https://example.databricks.com \
DATABRICKS_TOKEN=<your-pat>
export OMNIGENT_MODAL_SANDBOX_SECRETS=omnigent-server-auth
omnigent sandbox create --provider modal
```
The in-sandbox host mints a fresh bearer token from those credentials
on every connect and reconnect. For a server fronted by Databricks
authentication, inject `DATABRICKS_HOST` plus either
`DATABRICKS_TOKEN` (a PAT) or `DATABRICKS_CLIENT_ID` /
`DATABRICKS_CLIENT_SECRET` (an OAuth service principal — re-minting
keeps a long-lived sandbox connected past any single token's expiry).
A server with no authentication on the host tunnel needs none of this,
and neither do [server-managed sandboxes](#server-managed-sandboxes) —
those authenticate with a server-minted per-launch token automatically.
(The same env var also carries LLM / git credentials for CLI-launched
sandboxes — any secret named in `OMNIGENT_MODAL_SANDBOX_SECRETS` lands
in the sandbox environment, exactly like `sandbox.modal.secrets` does
for managed launches.)
### Server-managed sandboxes
With managed hosts, the server does all of the above per session.
Add a `sandbox:` section to the server config (`omnigent server -c
config.yaml`, or `<data_dir>/config.yaml`):
```yaml
sandbox:
provider: modal
server_url: https://your-host # public URL sandboxes dial back to
```
`server_url` must be reachable *from Modal's cloud* — a public HTTPS
URL, not `localhost`. The server itself needs Modal credentials in its
environment (`MODAL_TOKEN_ID` / `MODAL_TOKEN_SECRET`, or a mounted
`~/.modal.toml`).
Now create sessions with `host_type: "managed"`:
```bash
curl -X POST https://your-host/v1/sessions \
-H "Content-Type: application/json" \
-d '{"agent_id": "agent_...", "host_type": "managed"}'
```
The create returns immediately; the server provisions a fresh sandbox
in the background, starts a host in it, and binds the session once the
host comes online (`host_id` / `workspace` appear on
`GET /v1/sessions/{id}` when it does). A message posted before then
waits for the launch to settle, so you can send the first prompt right
away. Deleting the session terminates the sandbox and removes the
host. Each sandbox authenticates back with a server-minted, per-launch
token — no user credentials ever enter the sandbox.
Optional `modal:` settings:
```yaml
sandbox:
provider: modal
server_url: https://your-host
modal:
image: docker.io/<you>/omnigent-host:latest # default: official image
secrets: [omnigent-llm] # Modal secrets to inject
```
### LLM credentials for managed sandboxes
A fresh sandbox has no API keys. Park your provider credentials in a
[Modal secret](https://modal.com/secrets) and list it under
`sandbox.modal.secrets` — its env vars are injected into every managed
sandbox, and the in-sandbox host forwards the standard harness
credential vars to its runners:
```bash
modal secret create omnigent-llm \
OMNIGENT_ANTHROPIC_API_KEY=sk-ant-… OPENAI_API_KEY=sk-…
```
The forwarded set covers the variables the harnesses themselves
resolve — and it reaches well beyond the first-party APIs. The
`*_BASE_URL` variables redirect each harness to *any* compatible
endpoint, so the same mechanism covers frontier providers, gateways
like [OpenRouter](https://openrouter.ai) and
[LiteLLM](https://docs.litellm.ai), and self-hosted open-source models:
| Variable | Enables |
|---|---|
| `OMNIGENT_ANTHROPIC_API_KEY` or `ANTHROPIC_API_KEY` | Claude models on the Anthropic API (claude-sdk, pi, claude-code harnesses). Prefer the `OMNIGENT_` form for Claude Code so the raw `ANTHROPIC_API_KEY` env var is not present in the CLI process. |
| `ANTHROPIC_AUTH_TOKEN`, `ANTHROPIC_BASE_URL` | Anthropic-compatible gateways — point claude-code at a LiteLLM proxy, a Bedrock/Vertex bridge, or a corporate gateway |
| `CLAUDE_CODE_OAUTH_TOKEN` | claude-code with a Claude subscription (no API key) |
| `OPENAI_API_KEY` | OpenAI models on the OpenAI API (codex, openai-agents harnesses) |
| `OPENAI_BASE_URL` | Any OpenAI-compatible endpoint — the de-facto standard API of the open-model ecosystem. Gateways (OpenRouter, LiteLLM), hosted open-weights providers (Together, Fireworks, Groq), or self-hosted vLLM / Ollama — this is how Llama, Qwen, DeepSeek, and friends plug in |
| `CODEX_ACCESS_TOKEN` | codex with a ChatGPT Business/Enterprise workspace |
| `GEMINI_API_KEY` | Gemini models on the Google AI API |
Common setups:
- **Claude with an API key** — put `OMNIGENT_ANTHROPIC_API_KEY` in the secret.
Omnigent resolves it into Claude Code's `apiKeyHelper`; do not also set
`ANTHROPIC_API_KEY` unless you are okay with Claude Code detecting the raw
custom key env var.
- **Claude with a subscription** — run `claude setup-token` on your own
machine (one-time browser auth) and store the resulting long-lived
token as `CLAUDE_CODE_OAUTH_TOKEN`.
- **Codex with an API key** — put `OPENAI_API_KEY` in the secret.
- **Codex with a ChatGPT Business/Enterprise plan** — mint a
[Codex access token](https://developers.openai.com/codex/enterprise/access-tokens)
in the ChatGPT admin console (a workspace admin must grant the
permission) and store it as `CODEX_ACCESS_TOKEN`.
- **Codex with a ChatGPT Plus/Pro plan** — there is no headless token
for personal plans. Codex stores personal-plan auth in
`~/.codex/auth.json` with effectively single-use refresh tokens, so
copies of that file across machines invalidate each other — it can't
be injected into disposable sandboxes via a shared secret. Use an
API key or `codex login --device-auth` inside a long-lived sandbox
instead (device-code login must first be enabled in ChatGPT →
Settings → Security).
- **Gateways and open-source models** — set `OPENAI_BASE_URL` to the
endpoint plus its key as `OPENAI_API_KEY` (e.g.
`OPENAI_BASE_URL=https://openrouter.ai/api/v1` with an OpenRouter
key, or your own vLLM server's URL). Anthropic-side gateways work
the same way via `ANTHROPIC_BASE_URL` + `ANTHROPIC_AUTH_TOKEN`.
For env vars beyond the standard set, add
`OMNIGENT_RUNNER_ENV_PASSTHROUGH=NAME1,NAME2` to the secret — the
host forwards the named extras to its runners.
To check what actually landed in a sandbox, exec into it with Modal's
CLI and inspect the environment:
```bash
modal shell <sandbox-id> # interactive shell in the sandbox
env | grep -E 'ANTHROPIC|OPENAI|GIT'
```
### Git credentials (private repositories)
Sandboxes clone repository workspaces anonymously by default, which
covers public repositories only. For private repositories — both the
clone the server runs at session create and the `git fetch` / `git
push` the agent runs later — put an HTTPS token in a Modal secret as
`GIT_TOKEN`:
```bash
modal secret create omnigent-git GIT_TOKEN=github_pat_…
```
and list the secret under `sandbox.modal.secrets` (multiple secrets
compose, so keeping git and LLM credentials in separate secrets is
fine):
```yaml
sandbox:
provider: modal
server_url: https://your-host
modal:
secrets: [omnigent-llm, omnigent-git]
```
The host image ships a git credential helper that answers HTTPS
authentication from `GIT_TOKEN`, so nothing is written to disk and no
URL ever embeds the token. Details by provider:
- **GitHub** — use a [fine-grained personal access
token](https://github.com/settings/personal-access-tokens) scoped to
the repositories the sandbox needs (Contents: read, or read/write if
the agent pushes). The default auth username (`x-access-token`)
is already correct.
- **GitLab** — create a project or personal access token with
`read_repository` / `write_repository` and add
`GIT_USERNAME=oauth2` to the secret.
- **Other HTTPS remotes** — any server accepting basic auth works;
set `GIT_USERNAME` if it requires a specific username.
Use HTTPS repository URLs (`https://github.com/org/repo`) for private
workspaces — SSH URLs (`git@github.com:…`) would need a key and
known-hosts setup inside the sandbox, which the managed flow does not
provide.
The token is forwarded host→runner (like the LLM credentials above),
so the agent's own git commands authenticate the same way the
launch-time clone did. If the agent should also create commits, bake
or configure `user.name` / `user.email` via your agent's instructions
or a custom image.
### Environment variable reference
| Variable | Where it's read | Purpose |
|---|---|---|
| `MODAL_TOKEN_ID` / `MODAL_TOKEN_SECRET` | CLI machine / server | Modal API credentials (alternative to `~/.modal.toml`) |
| `OMNIGENT_MODAL_HOST_IMAGE` | CLI machine / server | Override the host image ref (`sandbox.modal.image` takes precedence for managed) |
| `OMNIGENT_MODAL_REGISTRY_SECRET` | CLI machine / server | Modal secret name with `REGISTRY_USERNAME` / `REGISTRY_PASSWORD` for private image pulls |
| `OMNIGENT_MODAL_SANDBOX_SECRETS` | CLI machine / server | Comma-separated Modal secret names to inject (`sandbox.modal.secrets` takes precedence for managed) |
| `OMNIGENT_RUNNER_ENV_PASSTHROUGH` | inside the sandbox (set via a Modal secret) | Extra env var names the host forwards to runners |
| `GIT_TOKEN` | inside the sandbox (set via a Modal secret) | HTTPS token for private repository clone / fetch / push |
| `GIT_USERNAME` | inside the sandbox (set via a Modal secret) | Auth username paired with `GIT_TOKEN` (default `x-access-token`; GitLab uses `oauth2`) |
All of the above are supported public configuration. The variables the
managed launcher itself sets inside the sandbox —
`OMNIGENT_HOST_TOKEN`, `OMNIGENT_HOST_ID`, `OMNIGENT_HOST_NAME`
are internal plumbing (server-minted per launch) and are never set by
users.
### Limits and troubleshooting
- **24-hour lifetime.** Modal hard-caps sandbox lifetime at 24 hours.
CLI flow: re-run `create` + `connect`. Managed flow: nothing to do —
when the sandbox dies, the next message to the session provisions a
fresh one under the same host (the session binding survives; a
repository workspace is re-cloned). Uncommitted workspace changes
die with the sandbox, so push work you care about.
- **Resources.** Sandboxes are created with 2 CPUs and 4 GiB of
memory.
- **Managed launch hangs then fails.** The server waits up to two
minutes for the in-sandbox host to come online. If it times out,
check that `server_url` is publicly reachable from Modal, then
inspect the host log inside the sandbox: `/tmp/omnigent-host.log`.
- **Image pull failures.** Private image without
`OMNIGENT_MODAL_REGISTRY_SECRET` set, or a secret missing
`REGISTRY_USERNAME` / `REGISTRY_PASSWORD`.
- **Agent has no credentials.** Verify the Modal secret is listed in
`sandbox.modal.secrets` and its var names match the forwarded set
above (or are named in `OMNIGENT_RUNNER_ENV_PASSTHROUGH`).
+71
View File
@@ -0,0 +1,71 @@
"""Modal deploy glue for the Omnigent server.
Runs the standard server image (``ghcr.io/omnigent-ai/omnigent-server``)
as a single always-on Modal web server, proxying HTTP / SSE / WebSocket
traffic to the same Docker entrypoint every other container platform
uses (``deploy/docker/entrypoint.py``). See README.md for the
walkthrough.
"""
import subprocess
import modal
# The CI-built server image — ships the gitignored web UI bundle that a
# source build can't produce (same reason every other platform pulls it;
# see deploy/docker/Dockerfile.prebuilt). Modal injects its client
# runtime into the image's Python at image-build time.
SERVER_IMAGE = "ghcr.io/omnigent-ai/omnigent-server:latest"
# The image's uvicorn port (deploy/docker/Dockerfile: EXPOSE 8000).
SERVER_PORT = 8000
# First boot runs DB migrations over the network (~1 minute on Neon);
# 300 s leaves comfortable headroom before Modal declares startup failed.
STARTUP_TIMEOUT_S = 300
app = modal.App("omnigent")
# Persists uploaded agent bundles / artifacts across container restarts
# and redeploys — unlike Heroku / Cloudflare Containers, the artifact
# store is durable here.
artifacts = modal.Volume.from_name("omnigent-artifacts", create_if_missing=True)
@app.function(
image=modal.Image.from_registry(SERVER_IMAGE),
# DATABASE_URL, OMNIGENT_ACCOUNTS_COOKIE_SECRET, and
# OMNIGENT_ACCOUNTS_BASE_URL — created in the README's step 1.
secrets=[modal.Secret.from_name("omnigent-deploy")],
volumes={"/data/artifacts": artifacts},
# One always-on container: the runner registry lives in server
# memory, so traffic must not be spread across containers
# (max_containers), and scale-to-zero would tear down live runner
# tunnels (min_containers).
min_containers=1,
max_containers=1,
# The server's working-set floor (~512 MB1 GB; see deploy/README.md's
# memory-floor note) — Modal's defaults sit below it.
cpu=1.0,
memory=1024,
# Each proxied request / WebSocket holds one Modal "input" for its
# lifetime, and an input ends when this timeout lapses — so use the
# platform maximum (24 h). Runners auto-reconnect after the cut.
timeout=24 * 60 * 60,
)
# Every in-flight request / SSE stream / WebSocket holds one input on the
# single container, so this is the simultaneous-connection budget; 1000
# comfortably covers a small team's runners + browser tabs + terminals.
@modal.concurrent(max_inputs=1000)
@modal.web_server(port=SERVER_PORT, startup_timeout=STARTUP_TIMEOUT_S)
def server() -> None:
"""
Launch the standard Docker entrypoint and let Modal proxy to it.
``@modal.web_server`` forwards HTTP / SSE / WebSocket traffic to
``SERVER_PORT`` once the process starts listening. Running the
entrypoint as a subprocess (rather than importing the FastAPI app
into Modal's own ASGI runner) keeps this deploy on the exact same
code path as every other container platform: migrations, store
wiring, auth defaults, and uvicorn flags like the runner tunnel's
``ws_max_size`` all come from ``deploy/docker/entrypoint.py``.
"""
subprocess.Popen(["python", "/app/entrypoint.py"])
+397
View File
@@ -0,0 +1,397 @@
# Omnigent on NVIDIA OpenShell
[NVIDIA OpenShell](https://github.com/NVIDIA/OpenShell) is a self-hosted sandbox
provider. Omnigent connects to an OpenShell **gateway** with the official
[`openshell`](https://pypi.org/project/openshell/) Python SDK and asks that
gateway to create, execute in, and delete sandboxes on the gateway's configured
compute driver.
This guide covers the Omnigent-specific OpenShell setup:
- install the `openshell` extra;
- select a working OpenShell gateway;
- use an OpenShell-compatible Omnigent host image;
- configure CLI-launched or server-managed sandboxes.
```bash
pip install 'omnigent[openshell]'
```
Omnigent uses OpenShell two ways:
- **CLI-launched**: `omnigent sandbox create` / `connect` provisions a sandbox
from your terminal, ships your local checkout into it, and registers it as a
host with your server.
- **Server-managed**: the server provisions a sandbox automatically when a
session is created with `"host_type": "managed"` and terminates it when the
session is deleted.
This is a sandbox-provider guide, not a server deploy target.
Two traits shape the rest of this guide:
- **gRPC, and a gateway you select — not an API key.** Omnigent connects through
the OpenShell gateway you've made active with `openshell gateway select`. The
SDK's `from_active_cluster()` resolves that gateway's endpoint, TLS material,
and OIDC token from `$OPENSHELL_GATEWAY` / `~/.config/openshell/active_gateway`.
There is no base-URL or token knob in Omnigent — gateway setup and auth are an
OpenShell concern.
- **No local port forward.** OpenShell has no sandbox→laptop callback path, so
the interactive in-sandbox `omnigent login` / App OAuth step is skipped
automatically (as on Modal, Daytona, and CoreWeave) — fine for token/OIDC-auth
servers.
## Prerequisites
You need a **running OpenShell gateway** with a compute driver, made active on
the machine the launcher runs on. Installing and operating the gateway is an
OpenShell concern — follow the
[OpenShell docs](https://docs.nvidia.com/openshell). Install the runtime + CLI:
```bash
curl -LsSf https://raw.githubusercontent.com/NVIDIA/OpenShell/main/install.sh | sh
```
(Apple Silicon macOS installs the Homebrew formula; Linux installs the deb/rpm.)
> [!IMPORTANT]
> **The gateway host must be amd64 Linux.** OpenShell's supervisor
> (Landlock/seccomp/netns) does not run reliably under emulation — on an arm64
> host (e.g. Apple Silicon via colima) the sandbox never reaches READY. The
> official host image now publishes multi-arch (amd64 + arm64), but its arm64
> variant omits `cel-expr-python` (no linux-arm64 wheel — CEL policies degrade to
> unavailable there), so the amd64 variant is the one to run with OpenShell. On an
> Apple-Silicon laptop, point the gateway at a remote **amd64 Linux** box (and the
> server at that gateway) rather than the local Docker VM.
### Minimal local Docker gateway (for trying it out)
For a quick local test, run one OpenShell gateway backed by your local Docker
daemon. The gateway needs a signing key so sandbox containers can authenticate
back to it; the helper script creates that key, writes the gateway config, starts
the gateway, registers it with the OpenShell CLI, and waits for `openshell status`
to report `Connected`.
Make sure Docker is running first. If you use colima, set `DOCKER_HOST` before
running the script:
```bash
export DOCKER_HOST=unix://$HOME/.colima/default/docker.sock
```
Then start and register the gateway:
```bash
deploy/openshell/start-local-docker-gateway.sh
```
The script writes local development state under `~/.openshell-local` and leaves
gateway logs at `~/.openshell-local/gateway.log`.
For a real deployment, run the gateway behind TLS with OIDC or mTLS (see the
OpenShell docs), then `openshell gateway add <https-url>` and `openshell gateway
login`; the SDK picks up the TLS/OIDC material from the gateway metadata
automatically — Omnigent needs no extra configuration.
> [!WARNING]
> `allow_unauthenticated_users = true` and `--disable-tls` are local-development
> conveniences. Don't expose such a gateway on a network.
## The host image
Sandboxes boot from `ghcr.io/omnigent-ai/omnigent-host:latest`, published by CI
from the `host` target of [`deploy/docker/Dockerfile`](../docker/Dockerfile) with
Omnigent and its dependencies preinstalled — including the coding-harness CLIs
(`claude`, `codex`, `pi`, `kiro-cli`), so agents on any harness run without an in-sandbox
install. OpenShell injects its own supervisor as the container entrypoint.
The `host` target also carries the two things OpenShell's image contract requires
(and which are inert for the root-based providers): a non-root **`sandbox`
user/group** and **`iproute2`/`nftables`** for the per-sandbox network namespace.
A custom image used with OpenShell must include both, or the supervisor refuses to
start. (The launcher handles the remaining non-root detail — pinning each exec's
cwd and `$HOME` to `/home/sandbox` — so the image's `/root` default still works
for the other providers.)
Before using an image with OpenShell, smoke-test that contract from the same
Docker daemon the gateway uses:
```bash
docker run --rm --entrypoint sh ghcr.io/omnigent-ai/omnigent-host:latest \
-lc 'id sandbox && command -v ip && command -v nft'
```
To use a different image (a fork, or extra tooling baked in), run the build from
an Omnigent repository checkout on an amd64 Docker-capable machine, then push it
where the gateway's driver can pull from:
```bash
docker build -f deploy/docker/Dockerfile --target host \
--platform linux/amd64 \
-t docker.io/<you>/omnigent-host:latest .
docker push docker.io/<you>/omnigent-host:latest
```
Then point Omnigent at it with `OMNIGENT_OPENSHELL_HOST_IMAGE`.
> [!NOTE]
> **Air-gapped?** Pre-load the host image (and OpenShell's supervisor image) into
> the registry or host the gateway pulls from — the first launch from an uncached
> image otherwise waits on a registry pull.
## CLI-launched sandboxes
With a gateway selected, provision a sandbox and ship your local checkout into
it:
```bash
omnigent sandbox create --provider openshell --server https://your-host
```
This creates a sandbox from the host image, builds wheels from your local
checkout, and overlays them on top — so the sandbox runs *your* code, not
whatever the image was built from. Then register it as a host with your server:
```bash
omnigent sandbox connect --provider openshell \
--sandbox-id <id-printed-by-create> \
--server https://your-host
```
`connect` runs `omnigent host` inside the sandbox and holds the connection open
in your terminal — Ctrl-C tears it down (stopping the in-sandbox host). New
sessions targeting that host now run in the sandbox. Pass a unique `--host-name
<label>` per sandbox when connecting several to one server (the server keys hosts
on (owner, name)). Sandboxes are disposable; when your code changes, create a new
one.
To inject LLM/git credentials into the sandbox, set `OMNIGENT_OPENSHELL_SANDBOX_ENV`
in your shell to a comma-separated list of variable names before running
`create` — the named variables are copied from your environment into the sandbox
at provision time. A listed name that is **not** set fails the launch loudly (it
would otherwise surface much later as an opaque harness auth failure inside the
sandbox):
```bash
export OMNIGENT_OPENSHELL_SANDBOX_ENV=ANTHROPIC_API_KEY,GIT_TOKEN
omnigent sandbox create --provider openshell --server https://your-host
```
## Server-managed sandboxes
Add a `sandbox:` section to the server config (`omnigent server -c config.yaml`,
or `<data_dir>/config.yaml`):
```yaml
sandbox:
provider: openshell
server_url: https://your-host # public URL sandboxes dial back to
```
`provider` + `server_url` is a complete config. Sessions created with
`host_type: "managed"` (the API call or the Web UI's New Sandbox option) then run
on a fresh OpenShell sandbox; the create returns immediately and provisioning
happens in the background, exactly like the [Modal managed
flow](../modal/README.md#server-managed-sandboxes). Each managed sandbox
authenticates back with a server-minted, per-launch token — no user credentials
enter the sandbox for the server connection.
```bash
curl -X POST https://your-host/v1/sessions \
-H "Content-Type: application/json" \
-d '{"agent_id": "agent_...", "host_type": "managed"}'
```
Unlike the cloud providers, OpenShell needs no API key in the server environment —
the **server process** must instead have OpenShell gateway access: it connects
with the same `from_active_cluster()` resolution as the CLI, so select a gateway
with `openshell gateway select` (or set `OPENSHELL_GATEWAY` /
`sandbox.openshell.cluster`) where the server runs. `server_url` must be reachable
**from the sandbox** — and because OpenShell is deny-by-default on egress, that
reachability is not automatic; see [Network egress policy](#network-egress-policy).
Optional `openshell:` settings:
```yaml
sandbox:
provider: openshell
server_url: https://your-host
openshell:
image: docker.io/<you>/omnigent-host:latest # default: official image
env: [OPENAI_API_KEY, ANTHROPIC_API_KEY, GIT_TOKEN] # server env var NAMES to inject
cluster: my-gateway # default: active gateway
```
How the managed dial-back interacts with the server's auth mode is a
framework-level behavior shared by all providers; see
[`deploy/cwsandbox/README.md`](../cwsandbox/README.md#managed-hosts-and-server-auth).
## Network egress policy
This is the part of an OpenShell deployment most likely to trip you up. OpenShell
is **deny-by-default**: every sandbox runs in its own network namespace with all
egress forced through a policy proxy, and anything not explicitly allowed is
blocked (the in-sandbox `https_proxy` returns `403`). The agent and host run with
*no* outbound access until the sandbox policy grants it. The policy is resolved
from `/etc/openshell/policy.yaml` baked into the image, or set per-sandbox; see
the [OpenShell policy schema](https://docs.nvidia.com/openshell). A managed host
needs egress to:
- **the server URL** (`server_url`) — the host and runner dial it back over a
WebSocket tunnel; without it the host can connect but the runner never registers;
- **the LLM provider host** — the agent's model calls originate *inside* the
sandbox (e.g. `*.googleapis.com` for Gemini, `api.anthropic.com` for Claude,
`api.openai.com` for OpenAI);
- **tokenizer/asset hosts** some harnesses fetch on first use, e.g.
`*.blob.core.windows.net` (the openai-agents harness downloads the `tiktoken`
encoding).
A minimal `network_policies` block (in the image's `policy.yaml`) looks like:
```yaml
network_policies:
server:
endpoints: [{ host: "your-host.example.com", port: 443, tls: skip }]
binaries: [{ path: /** }]
llm:
endpoints: [{ host: "*.googleapis.com", port: 443, tls: skip }]
binaries: [{ path: /** }]
```
> [!IMPORTANT]
> **Forward the proxy vars to the runner.** The host inherits the sandbox's
> `https_proxy`/`http_proxy`, but the runner subprocess it spawns does **not** —
> so the runner fails with `Temporary failure in name resolution` even though the
> host connected. Inject `OMNIGENT_RUNNER_ENV_PASSTHROUGH` naming the proxy vars so
> the host forwards them:
> ```yaml
> sandbox:
> openshell:
> env: [OMNIGENT_RUNNER_ENV_PASSTHROUGH, …] # value set in the server env:
> # OMNIGENT_RUNNER_ENV_PASSTHROUGH=https_proxy,http_proxy,HTTPS_PROXY,HTTP_PROXY,NO_PROXY,no_proxy
> ```
> [!TIP]
> For LLM traffic specifically, OpenShell recommends its **inference routing**
> over allow-listing the provider host directly, so a stolen key can't be used to
> reach the provider from inside the sandbox. The allow-list above is the simplest
> path to get a turn working; inference routing is the hardened one.
## Model credentials (LLM keys)
A fresh sandbox has no model credentials. Name the variables to inject in
`OMNIGENT_OPENSHELL_SANDBOX_ENV`; the launcher copies the value from your
environment into the sandbox, and the in-sandbox host forwards the standard
harness credential vars (`ANTHROPIC_API_KEY`, `CLAUDE_CODE_OAUTH_TOKEN`,
`OPENAI_API_KEY`, `OPENAI_BASE_URL`, `GEMINI_API_KEY`, …) to its runners.
```bash
export ANTHROPIC_API_KEY=sk-ant-…
export OMNIGENT_OPENSHELL_SANDBOX_ENV=ANTHROPIC_API_KEY
```
Which variables to inject — providers, gateways, subscriptions — is identical to
the other providers; see the [Modal variable table and per-plan
recipes](../modal/README.md#llm-credentials-for-managed-sandboxes). For a Claude
**subscription**, run `claude setup-token` on your own machine (one-time browser
auth) and inject the resulting `CLAUDE_CODE_OAUTH_TOKEN`. For env vars beyond the
standard set, inject `OMNIGENT_RUNNER_ENV_PASSTHROUGH=NAME1,NAME2`.
> [!TIP]
> OpenShell can also enforce credential and egress policy at the sandbox boundary
> via its declarative YAML policy (a gateway-side feature, independent of
> Omnigent). See the [OpenShell policy docs](https://docs.nvidia.com/openshell).
## Git credentials (private repositories)
Inject an HTTPS token as `GIT_TOKEN` (GitLab: add `GIT_USERNAME=oauth2`) via
`OMNIGENT_OPENSHELL_SANDBOX_ENV`. The host image's git credential helper answers
HTTPS auth from it for both the launch-time clone and the agent's later `fetch` /
`push`, writing nothing to disk. Use HTTPS repository URLs. Details by provider
match the [Modal git guide](../modal/README.md#git-credentials-private-repositories).
## How it works
- **Connection.** `OpenShellSandboxLauncher` builds a `SandboxClient` via
`from_active_cluster()` and calls the gateway over gRPC: `CreateSandbox` +
`wait_ready` to provision, `ExecSandbox` to run commands, `DeleteSandbox` to
terminate.
- **File shipping.** OpenShell exposes command execution but no upload RPC, so
`put` streams the file's bytes to `cat` over the exec channel's stdin (the same
approach NVIDIA's own LangChain backend uses). Wheels are shipped this way, then
installed with the shared host-image overlay command.
- **Sandbox identity.** OpenShell assigns each sandbox a petname (e.g.
`touched-urial`); that name is the handle Omnigent prints and reuses. The
requested `--name` is advisory.
- **Non-root execution.** OpenShell runs the agent as the `sandbox` user, so the
launcher pins every exec's cwd and `$HOME` to `/home/sandbox` (the image keeps
`/root` as its default for the root-based providers).
- **Long-lived host.** OpenShell terminates an exec's process tree the moment the
exec returns, so the in-sandbox host can't be detached with the usual
`setsid nohup … &` (it gets reaped instantly). The launcher instead runs it as a
foreground exec held open on a daemon thread for the session's lifetime.
## Troubleshooting
- **`docker sandboxes require gateway JWT auth; configure [openshell.gateway.gateway_jwt]`**
— the Docker driver needs a gateway-minted sandbox JWT. Generate the Ed25519
key material and add the `[openshell.gateway.gateway_jwt]` block as shown in
[Minimal local Docker gateway](#minimal-local-docker-gateway-for-trying-it-out),
then restart the gateway.
- **`No OpenShell server configured` / `Could not connect to an OpenShell gateway`**
— no gateway is active. Run `openshell gateway select <name>` (or set
`OPENSHELL_GATEWAY`), and confirm with `openshell status`.
- **Sandbox stuck in `Provisioning`** — usually a slow first image pull. Confirm
the gateway's Docker daemon can pull the host image (`docker pull <image>` from
the same `DOCKER_HOST`); pre-pull it to cache. On colima, make sure the gateway
was started with `DOCKER_HOST` pointed at colima's socket — `/var/run/docker.sock`
may point at a different (stopped) Docker.
- **Agent has no credentials** — verify the injected var names match the forwarded
set (or are named in `OMNIGENT_RUNNER_ENV_PASSTHROUGH`), and that each name was
actually set in the launching environment.
- **Host registers but the runner never comes online / runner log shows
`Temporary failure in name resolution`** — the runner subprocess isn't getting
the sandbox's proxy vars. Forward them with `OMNIGENT_RUNNER_ENV_PASSTHROUGH`
(see [Network egress policy](#network-egress-policy)).
- **Turn fails reaching the model, or proxy returns `403`** — the destination
isn't in the sandbox's egress allow-list. Add the LLM host (and any
tokenizer/asset host) to `network_policies` (see
[Network egress policy](#network-egress-policy)).
- **Sandbox container restarts / `sandbox user 'sandbox' not found` or
`trusted ip helper not found`** — the image isn't OpenShell-compatible. Use the
official host image (or include the `sandbox` user + `iproute2` in your custom
one); see [The host image](#the-host-image).
## Environment variable reference
| Variable | Where it's read | Purpose |
|---|---|---|
| `OPENSHELL_GATEWAY` | CLI machine / server | Gateway name to use; overrides `~/.config/openshell/active_gateway` (read by the SDK). `sandbox.openshell.cluster` takes precedence for managed. |
| `OMNIGENT_OPENSHELL_HOST_IMAGE` | CLI machine | Override the host image ref (default `ghcr.io/omnigent-ai/omnigent-host:latest`); `sandbox.openshell.image` is the managed equivalent |
| `OMNIGENT_OPENSHELL_SANDBOX_ENV` | CLI machine | Comma-separated launcher-side env var names to inject into the sandbox; `sandbox.openshell.env` is the managed equivalent |
| `OMNIGENT_RUNNER_ENV_PASSTHROUGH` | inside the sandbox (injected) | Extra env var names the host forwards to runners |
| `GIT_TOKEN` / `GIT_USERNAME` | inside the sandbox (injected) | HTTPS credentials for private repository clone / fetch / push |
## Validation
Exercised end-to-end against a live OpenShell gateway on an **amd64 Linux** host
(Docker driver, the official host image):
- **Launcher primitives** — provision → run (`echo` / `uname`) → put (file upload
over exec stdin) → verify → terminate, plus `exec_foreground` (the `connect`
primitive) streaming output and propagating exit codes; the gateway logs the
matching `CreateSandbox` / `ExecSandbox` / `DeleteSandbox` RPCs.
- **Full server-managed session** — a `host_type:"managed"` session drove the
server to provision a sandbox on the gateway, start `omnigent host` in it (held
foreground exec), dial back over the tunnel, register, spawn the runner, and
complete a real agent turn (a Gemini model via the openai-agents harness) — the
agent's reply came back from inside the sandbox.
Unit tests (a faked SDK / launcher, no gateway needed) cover provision, run, file
upload, foreground streaming, attach, terminate, env passthrough, error handling,
and the managed-config parsing:
```bash
pip install -e '.[openshell,dev]'
pytest tests/onboarding/sandboxes/test_openshell.py tests/server/test_managed_hosts.py
```
+61
View File
@@ -0,0 +1,61 @@
#!/usr/bin/env bash
set -euo pipefail
OPENSHELL_LOCAL_DIR="${OPENSHELL_LOCAL_DIR:-$HOME/.openshell-local}"
OPENSHELL_GATEWAY_PORT="${OPENSHELL_GATEWAY_PORT:-17670}"
OPENSHELL_GATEWAY_ENDPOINT="http://127.0.0.1:${OPENSHELL_GATEWAY_PORT}"
JWT_DIR="${OPENSHELL_LOCAL_DIR}/jwt"
GATEWAY_CONFIG="${OPENSHELL_LOCAL_DIR}/gateway.toml"
GATEWAY_LOG="${OPENSHELL_LOCAL_DIR}/gateway.log"
mkdir -p "${JWT_DIR}"
if [[ ! -f "${JWT_DIR}/signing.pem" ]]; then
openssl genpkey -algorithm ed25519 -out "${JWT_DIR}/signing.pem"
fi
if [[ ! -f "${JWT_DIR}/public.pem" ]]; then
openssl pkey -in "${JWT_DIR}/signing.pem" -pubout -out "${JWT_DIR}/public.pem"
fi
printf 'local-dev\n' > "${JWT_DIR}/kid"
cat > "${GATEWAY_CONFIG}" <<TOML
[openshell.gateway.gateway_jwt]
signing_key_path = "${JWT_DIR}/signing.pem"
public_key_path = "${JWT_DIR}/public.pem"
kid_path = "${JWT_DIR}/kid"
gateway_id = "openshell"
ttl_secs = 0
[openshell.gateway.auth]
allow_unauthenticated_users = true
TOML
if pgrep -a -x openshell-gateway | grep -F -- "--port ${OPENSHELL_GATEWAY_PORT}" >/dev/null; then
echo "OpenShell gateway already appears to be running on port ${OPENSHELL_GATEWAY_PORT}."
else
: > "${GATEWAY_LOG}"
nohup openshell-gateway --config "${GATEWAY_CONFIG}" \
--disable-tls --drivers docker --port "${OPENSHELL_GATEWAY_PORT}" \
> "${GATEWAY_LOG}" 2>&1 &
fi
echo "Waiting for OpenShell gateway to listen on ${OPENSHELL_GATEWAY_ENDPOINT}..."
for _ in {1..60}; do
if grep -q 'Server listening' "${GATEWAY_LOG}" 2>/dev/null; then
break
fi
sleep 1
done
if ! grep -q 'Server listening' "${GATEWAY_LOG}" 2>/dev/null; then
echo "OpenShell gateway did not become ready. Last log lines:" >&2
tail -50 "${GATEWAY_LOG}" >&2 || true
exit 1
fi
openshell gateway add "${OPENSHELL_GATEWAY_ENDPOINT}" --local || true
openshell status
echo "Gateway log: ${GATEWAY_LOG}"
+155
View File
@@ -0,0 +1,155 @@
# Omnigent on Railway
Deploy Omnigent to Railway. Railway pulls the pre-built image, runs it next to
a managed Postgres, and serves it over HTTPS on `*.up.railway.app`.
> **Railway is not yet a true one-click.** Unlike Render's `render.yaml` (fully
> declarative — Postgres, port, and env all wired automatically), a bare
> `railway.toml` leaves several things to wire by hand (steps below). A real
> one-click experience needs a **published Railway template** that pre-wires the
> Postgres reference, `HOST`, and target port — tracked as a follow-up. Until
> then, use the manual steps here. (Render is the smoother path today.)
<!-- TODO(oss-release): publish a Railway template (pre-wiring Postgres + HOST +
port) and add the button:
[![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/deploy/<template-id>) -->
## What gets provisioned
- **omnigent** — web service that pulls `ghcr.io/omnigent-ai/omnigent-server`
via `deploy/docker/Dockerfile.prebuilt`, served on `https://<project>.up.railway.app`.
- **Postgres** — Railway-managed PostgreSQL plugin you add to the project.
Railway links its `DATABASE_URL` into the app as a reference to the database
instance's variable (largely automatic), but the value can lag on the first
deploy — see step 2.
Artifact storage uses the container's local filesystem by default (ephemeral
across redeploys). For persistence, add a Railway Volume mounted at
`/data/artifacts`.
> **Optional: external Neon Postgres.** Instead of the Railway plugin, you can
> point `DATABASE_URL` at a Neon database ([pg.new](https://pg.new)) — e.g. for
> Neon's serverless scale-to-zero or branching. Tradeoff: you lose the
> integrated provisioning (a separate signup + connection string) and add some
> cross-provider latency, so the Railway plugin stays the simpler default.
## Setup (built-in accounts — the default)
Defaults to the `accounts` auth provider: multi-user, no external IdP. The
steps below are validated end-to-end:
1. **Deploy from the repo** — New Project → Deploy from GitHub repo → this repo.
Railway reads `railway.toml` and pulls the image. **Add a Postgres plugin**
to the project.
2. **Database** — Railway links the Postgres `DATABASE_URL` into the app as a
reference to the db instance's variable (largely automatic when you add the
plugin). If the first deploy errors with `DATABASE_URL is required`, the
reference value simply hadn't propagated yet — **redeploy** and it resolves.
(To confirm, the app service should have a `DATABASE_URL` variable
referencing the Postgres service, e.g. `${{Postgres.DATABASE_URL}}`.)
3. **Get the admin password** from the first-boot **Deploy logs** (printed once;
idempotent — later boots don't reprint):
```
✓ Created initial admin account (accounts auth provider).
password: <generated>
```
It's also written to `/data/admin-credentials`.
4. Open the URL, log in as `admin`, invite teammates from **Members**.
> **`HOST` is handled automatically.** Railway injects `HOST=[::]`, which a
> socket bind can't use and which Railway's IPv4 edge can't reach; the
> entrypoint detects Railway and coerces it to `0.0.0.0`, so no manual `HOST`
> variable is needed. If the generated domain returns "Application failed to
> respond," Railway's port auto-detect picked the wrong port — open
> Settings → Networking and set the domain's target port to the `PORT` Railway
> injected (shown in the boot log as `Uvicorn running on …:<port>`).
> The cookie secret is auto-minted and `OMNIGENT_ACCOUNTS_BASE_URL` is
> auto-detected from `RAILWAY_PUBLIC_DOMAIN`, so those don't need setting. To
> pin a known admin password, set `OMNIGENT_ACCOUNTS_INIT_ADMIN_PASSWORD`
> before first boot.
## Use your own IdP instead (OIDC)
Prefer GitHub / Google / Okta login over built-in accounts? Switch the provider
in the service Variables. OIDC requires HTTPS — Railway provides it
automatically on `*.up.railway.app`. If you set a custom domain, point it at
your project before completing these steps.
### GitHub OAuth (simplest to register)
1. Go to `github.com/settings/developers` → **New OAuth App**.
- Homepage URL: `https://<project>.up.railway.app`
- Authorization callback URL: `https://<project>.up.railway.app/auth/callback`
- Click **Register application**, then **Generate a new client secret**.
2. In your Railway project, open the **omnigent** service → **Variables**
and add:
| Variable | Value |
|---|---|
| `OMNIGENT_AUTH_PROVIDER` | `oidc` |
| `OMNIGENT_OIDC_ISSUER` | `https://github.com` |
| `OMNIGENT_OIDC_CLIENT_ID` | your GitHub OAuth client ID |
| `OMNIGENT_OIDC_CLIENT_SECRET` | your GitHub OAuth client secret |
| `OMNIGENT_OIDC_REDIRECT_URI` | `https://<project>.up.railway.app/auth/callback` |
| `OMNIGENT_OIDC_COOKIE_SECRET` | output of `openssl rand -hex 32` |
3. Railway redeploys automatically. Visit the URL — you'll be redirected to
GitHub to log in.
### Google Workspace
| Variable | Value |
|---|---|
| `OMNIGENT_AUTH_PROVIDER` | `oidc` |
| `OMNIGENT_OIDC_ISSUER` | `https://accounts.google.com` |
| `OMNIGENT_OIDC_CLIENT_ID` | `…apps.googleusercontent.com` |
| `OMNIGENT_OIDC_CLIENT_SECRET` | your client secret |
| `OMNIGENT_OIDC_REDIRECT_URI` | `https://<project>.up.railway.app/auth/callback` |
| `OMNIGENT_OIDC_COOKIE_SECRET` | output of `openssl rand -hex 32` |
| `OMNIGENT_OIDC_ALLOWED_DOMAINS` | `example.com` (critical — see note below) |
> **Important:** Without `OMNIGENT_OIDC_ALLOWED_DOMAINS`, any Google account
> can log in when the OAuth consent screen is "External." Always restrict to
> your domain.
### Generic OIDC (Okta, Auth0, Keycloak, Entra ID)
Set `OMNIGENT_OIDC_ISSUER` to your IdP's base URL (the one that publishes
`/.well-known/openid-configuration`). The rest of the variables are the same
as above.
## Custom domain
In your Railway project, open **Settings** → **Domains** → **Add domain**.
Point your DNS A/AAAA record at the Railway-assigned address. Railway
provisions a Let's Encrypt cert automatically.
Update `OMNIGENT_OIDC_REDIRECT_URI` to use the custom domain after DNS
propagates.
## Upgrading
Railway redeploys automatically when a new image tag is pushed to GHCR
(if you've configured a webhook) or on demand:
1. In the Railway dashboard, open the **omnigent** service.
2. Click **Deploy** → **Latest** to pull the newest `:latest` image.
## Cost
Railway Hobby plan: ~$5/month base + per-minute CPU/memory usage. A lightly
loaded Omnigent instance (few concurrent users) typically stays under
$1015/month total including the Postgres plugin.
## Publishing the template
One-time setup done by the repo owner after the repository is public:
1. Go to `railway.com/new/template` and click **Create template**.
2. Point it at `github.com/omnigent-ai/omnigent`.
3. Select the **Postgres** plugin.
4. Pre-fill default env vars with descriptions for the optional OIDC fields.
5. Click **Publish**. Copy the generated deploy URL and update the badge at the
top of this file and in `deploy/README.md`.
+153
View File
@@ -0,0 +1,153 @@
# Omnigent on Render
Deploy Omnigent to Render in one click. Render provisions the app and a
managed Postgres database, assigns an HTTPS URL on `*.onrender.com`, and
handles SSL automatically. No local tooling required.
[![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](https://render.com/deploy?repo=https://github.com/omnigent-ai/omnigent)
> **Note:** The button points at the public repo `github.com/omnigent-ai/omnigent`.
> It goes live once that repo **and** the `ghcr.io/omnigent-ai/omnigent-server`
> package are public; until then it only works if you connect Render to the
> (private) repo in the dashboard first.
## What gets provisioned
The `render.yaml` blueprint at the repo root defines:
- **omnigent** (Starter web service) — pulls the pre-built image
`ghcr.io/omnigent-ai/omnigent-server:latest` (CI-built; ships the web UI
bundle), served on `https://omnigent-<hash>.onrender.com`. While the GHCR
package is private, add a Render registry credential and reference it from
`render.yaml` (`image.creds`); once public, the pull is anonymous.
- **omnigent-db** (`basic-256mb` managed Postgres) — `DATABASE_URL` is injected
into the service automatically
- **artifact-data** (10 GB persistent disk) — mounted at `/data` so server
config, first-boot credentials, cookie secrets, and agent artifacts survive
redeploys. Artifacts live under `/data/artifacts`.
## Quickstart (built-in accounts — the default)
The blueprint defaults to the built-in `accounts` auth provider: multi-user
out of the box, no external IdP, and **no env vars to fill in** — the server
mints its own cookie secret and auto-detects its public URL from Render.
1. Click the Deploy to Render button above → **Apply**. Wait ~35 min for the
image pull + health check.
2. **Get the admin password:** open the service → **Logs** and find the
first-boot block:
```
✓ Created initial admin account (accounts auth provider).
password: <generated>
```
(also written to `/data/admin-credentials` on the disk; printed once).
3. Open your `https://<service>.onrender.com` URL, log in as the admin, and
invite teammates from **Members** in the web UI.
> To set a known admin password instead of the generated one, add
> `OMNIGENT_ACCOUNTS_INIT_ADMIN_PASSWORD` in the dashboard before first boot.
## Use your own IdP instead (OIDC)
Prefer to delegate login to GitHub / Google / Okta instead of built-in
accounts? Switch the provider after the initial deploy. HTTPS is provided
automatically by Render.
### GitHub OAuth (simplest to register)
1. Go to `github.com/settings/developers` → **New OAuth App**.
- Homepage URL: `https://omnigent-<hash>.onrender.com`
- Authorization callback URL:
`https://omnigent-<hash>.onrender.com/auth/callback`
- Click **Register application**, then **Generate a new client secret**.
2. In the Render dashboard, open the **omnigent** service → **Environment**
and add / update these variables:
| Variable | Value |
|---|---|
| `OMNIGENT_AUTH_PROVIDER` | `oidc` |
| `OMNIGENT_OIDC_ISSUER` | `https://github.com` |
| `OMNIGENT_OIDC_CLIENT_ID` | your GitHub OAuth client ID |
| `OMNIGENT_OIDC_CLIENT_SECRET` | your GitHub OAuth client secret |
| `OMNIGENT_OIDC_REDIRECT_URI` | `https://omnigent-<hash>.onrender.com/auth/callback` |
Also add `OMNIGENT_OIDC_COOKIE_SECRET` = a 64-hex-char value from
`openssl rand -hex 32` — OIDC mode requires it and validates it as hex.
3. Click **Save Changes**. Render redeploys automatically. Visit the URL —
you'll be redirected to GitHub to log in.
### Google Workspace
| Variable | Value |
|---|---|
| `OMNIGENT_AUTH_PROVIDER` | `oidc` |
| `OMNIGENT_OIDC_ISSUER` | `https://accounts.google.com` |
| `OMNIGENT_OIDC_CLIENT_ID` | `…apps.googleusercontent.com` |
| `OMNIGENT_OIDC_CLIENT_SECRET` | your client secret |
| `OMNIGENT_OIDC_REDIRECT_URI` | `https://omnigent-<hash>.onrender.com/auth/callback` |
| `OMNIGENT_OIDC_ALLOWED_DOMAINS` | `example.com` (critical — see note below) |
> **Important:** Without `OMNIGENT_OIDC_ALLOWED_DOMAINS`, any Google account
> can log in when the OAuth consent screen is "External." Always restrict to
> your domain.
### Generic OIDC (Okta, Auth0, Keycloak, Entra ID)
Set `OMNIGENT_OIDC_ISSUER` to your IdP's base URL (the one that publishes
`/.well-known/openid-configuration`). The rest of the variables are the same
as above.
## Custom domain
In the Render dashboard, open the **omnigent** service → **Settings** →
**Custom Domains** → **Add Custom Domain**. Point your DNS CNAME at the
Render-assigned address. Render provisions a Let's Encrypt cert automatically.
Update `OMNIGENT_OIDC_REDIRECT_URI` to use the custom domain after DNS
propagates.
## Upgrading
Render redeploys automatically when a new commit lands on the connected branch
(if auto-deploy is enabled), or manually:
1. In the Render dashboard, open the **omnigent** service.
2. Click **Manual Deploy** → **Deploy latest commit**.
## Cost
Render: ~$7/month for the Starter web service + ~$6/month for the `basic-256mb`
managed Postgres. Total ~$13/month for a lightly loaded instance. Bump the
Postgres plan (`basic-1gb`, …) for more storage.
> **Note:** the web service needs a paid (Starter+) instance because of the
> persistent artifact disk, and Render's free Postgres plans expire — so a paid
> DB tier (`basic-256mb`) is the persistent default here.
> **Memory:** the Starter web service (512 MB) clears the server's ~512 MB1 GB
> working set. Don't drop below it.
## Cheaper: SQLite on the disk (lite tier)
For a single-instance deploy you can skip the managed Postgres entirely and run
on **SQLite on the persistent disk** — it survives redeploys (the disk does) and
saves the ~$6/month DB cost. SQLite is a first-class backend; the tradeoff is
single-instance only (no horizontal scaling) and no managed backups, so keep
Postgres for production / multi-instance.
To use it, drop the `databases:` block from `render.yaml` and replace the
`DATABASE_URL` env var with a path on the disk:
```yaml
- key: DATABASE_URL
value: sqlite:////data/artifacts/chat.db
```
> **Or an external Neon Postgres.** You can point `DATABASE_URL` at a Neon
> database ([pg.new](https://pg.new)) instead of the managed Render one — e.g.
> to use Neon's free *persistent* tier rather than Render's paid DB. Tradeoff:
> you lose the integrated auto-provisioning (a separate signup + connection
> string) and add some cross-provider latency, so the managed Render Postgres
> stays the simpler default.
+122
View File
@@ -0,0 +1,122 @@
# Omnigent on Tailscale
[Tailscale](https://tailscale.com) gives every device on your network a
stable private hostname (`<machine>.ts.net`) and connects them peer-to-peer
over WireGuard — no port forwarding, no firewall rules. This makes it easy
to access a server running on your laptop from your phone, tablet, or any
other device you own.
> [!NOTE]
> This is not a cloud deploy. Tailscale is a networking layer, not a hosting
> service — you still run the server yourself (laptop, VPS, home server).
> If you want the server to stay up when your laptop closes, deploy to a
> cloud platform (see [../README.md](../README.md)) and use Tailscale just
> for private access.
## Prerequisites
- Tailscale installed on your server machine and every client device.
All signed in to the same Tailscale account.
- Omnigent server running locally (e.g. `omnigent server` or
`docker compose up -d` from `deploy/docker/`).
## Tailnet-only access (phone / tablet / remote laptop)
Expose the local server over HTTPS to every device on your tailnet:
```bash
tailscale serve https / http://localhost:8000
```
Tailscale issues a TLS certificate for `https://<machine>.ts.net` and
proxies traffic to `localhost:8000`. No other device on the internet can
reach it.
Set two environment variables on the server before starting it:
```dotenv
# Trust the Tailscale origin so WebSocket handshakes and multipart
# uploads are accepted from the browser on your phone/tablet.
OMNIGENT_WS_ALLOWED_ORIGINS=https://<machine>.ts.net
# Public base URL — used to build the correct __Host- cookie prefix
# and any invite / magic-link URLs.
OMNIGENT_ACCOUNTS_BASE_URL=https://<machine>.ts.net
```
Without `OMNIGENT_WS_ALLOWED_ORIGINS` the browser will get WebSocket close
code `4403` and an HTTP 403 *"Forbidden: this endpoint requires a trusted
Origin header"* on chat and file uploads. Without `OMNIGENT_ACCOUNTS_BASE_URL`
session cookies won't use the `__Host-` prefix and invite links resolve to
the wrong host.
**With Docker Compose** (`deploy/docker/`), add both lines to your `.env`:
```bash
# generate and edit .env if you haven't already
cp deploy/docker/.env.example deploy/docker/.env
# add to .env:
OMNIGENT_WS_ALLOWED_ORIGINS=https://<machine>.ts.net
OMNIGENT_ACCOUNTS_BASE_URL=https://<machine>.ts.net
```
Then restart:
```bash
docker compose up -d
```
Open `https://<machine>.ts.net` on any device on your tailnet.
## Cloud sandbox hosts and Tailscale Funnel
Cloud sandbox providers (Modal, Daytona, E2B, …) run the Omnigent host
process *inside* a remote container. That host dials **out** to
`server_url` over WebSocket to receive work — so it needs to reach the
server from the sandbox provider's cloud network, not just from your
tailnet.
A server behind plain `tailscale serve` is only reachable from your
tailnet. **Tailscale Funnel** fixes this: it makes a specific port
reachable from the public internet while keeping the same
`<machine>.ts.net` hostname.
```bash
tailscale funnel 8000
```
Then point the sandbox config at the public Tailscale URL:
```yaml
# config.yaml (or /data/config.yaml in Docker)
sandbox:
provider: modal # or daytona, e2b, …
server_url: https://<machine>.ts.net
```
> [!IMPORTANT]
> Funnel makes the server reachable from the public internet, so enable
> auth before turning it on:
>
> ```dotenv
> OMNIGENT_AUTH_ENABLED=1
> OMNIGENT_ACCOUNTS_BASE_URL=https://<machine>.ts.net
> ```
>
> See [Auth](../README.md#auth) for the full setup.
## Summary
| Goal | Command | Reachable from |
|---|---|---|
| Access from devices on your tailnet | `tailscale serve https / http://localhost:8000` | Tailnet only |
| Cloud sandbox hosts + tailnet | `tailscale funnel 8000` | Public internet + tailnet |
## Environment variable reference
| Variable | Purpose |
|---|---|
| `OMNIGENT_WS_ALLOWED_ORIGINS` | Comma-separated origin allowlist. Set to `https://<machine>.ts.net` to trust the Tailscale origin for WebSocket and multipart routes. |
| `OMNIGENT_ACCOUNTS_BASE_URL` | Public base URL. Used for session cookie security (`__Host-` prefix) and invite / magic-link URLs. |
| `OMNIGENT_AUTH_ENABLED` | `1` to require login. Recommended when using Tailscale Funnel (public internet exposure). |