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