Files
omnigent-ai--omnigent/deploy
Edwin He 4b6779febb feat(auth): refreshable credential for unattended host daemons (#4743)
* feat(auth): refreshable credential for unattended host daemons

Implements login-issued refresh grants so unattended hosts can renew
their session tokens instead of crashing when the initial JWT expires.

- Server: OIDC callback persists refresh material and issues a login-scoped
  renewal grant (30-day TTL, reuses device-grant store/rotation machinery)
- CLI: load_token() calls refresh_stored_token() on expiry, mints a fresh
  session JWT from the grant via POST /oauth/token
- Host: treats post-connection 401/403 as retryable-with-reauth (attempts
  token refresh before failing); improves error text for expired tokens
- Auth: login grants (no scope) bypass the delegated-token allowlist,
  keeping full authority; delegated tokens stay scope-restricted
- Tests: new coverage for refresh cycles, OIDC mode token router, env
  override of grant lifetime

Fixes OMNI-1127 / closes #1953.

Co-authored-by: Isaac
Signed-off-by: Edwin He <41037314+Edwinhe03@users.noreply.github.com>

* fix(auth): address review findings for refreshable host credential

Fixes P0-P3 security and robustness issues discovered in review:

P0 (FEATURE-BREAKING): create_redeemed_grant called self._session() with
no query_name, causing TypeError and breaking the entire login-grant
feature. Now uses "insert_redeemed_device_grant" per CLAUDE.md conventions.

P1 (SECURITY): LoginRequest.issue_refresh was a client-controllable bool,
allowing XSS/form-hijack to obtain 30-day unattended credentials via
browser login. Removed the field entirely; browser /auth/login now NEVER
issues refresh material — only CLI/device flows do (server-side enforcement).

P2.1 (ROBUSTNESS): _check_cookie dropped isinstance(grant_id, str) guard,
allowing malformed grant_id claims to reach _grant_revoked(). Restored guard.

P2.2 (ROBUSTNESS): _store_entry assumed token file was dict but _load_entry
guards with isinstance(data, dict). Mirror the guard on write to prevent
TypeError on corrupt files. Treat non-dict as empty (fail-safe).

P3.1 (CLEANUP): load_token's expiry warning said "attempting automatic
refresh" but load_token never refreshes. Reworded to reflect actual behavior.

P3.2 (CLEANUP): _make_client_secret_gate was built twice (once in
create_device_auth_router, again in included create_oauth_token_router),
duplicating env reads and logs. Pass gate as parameter to avoid rebuild.

P3.3 (CLEANUP): _grant_max_lifetime_seconds() re-parsed os.environ on every
refresh/purge. Now called once at router mount, captured in closure.

Added regression tests:
- test_redeemed_grant_persistence_regression: grant row must be created
  (catches P0 TypeError)
- test_browser_login_never_issues_refresh_token: browser login must NOT
  return refresh_token (catches P1 client-controllable flow)

Follow-up (test reconciliation + lint, from running the suite):
- Re-point the login-grant round-trip and session-authority tests to mint
  via issue_login_grant (the CLI/device path) now that browser /auth/login
  no longer returns refresh material.
- Fix pre-existing runner-entry test lag: load_token mocks now accept the
  min_remaining_seconds kwarg the factory passes, and drop a stray
  OMNIGENT_RUNNER_DELEGATED_AUTH that contradicted a test's documented
  no-delegation scenario.
- Fix a latent NameError: _grant_max_lifetime was referenced in
  create_device_auth_router but only bound in create_oauth_token_router;
  resolve it once at mount in the device router too.
- Remove the now-dead device_grant_store wiring from the accounts auth
  router (its only use was the removed issue_refresh path).

Co-authored-by: Isaac
Signed-off-by: Edwin He <41037314+Edwinhe03@users.noreply.github.com>

* fix(host): escalate to a re-auth prompt on sustained post-connect auth rejection

A host rejected with 401/403 after it has already connected retries forever, so a transient VPN or proxy drop self-heals. Until now it only logged "check your VPN/network", so a permanently-rejected credential (a revoked or expired grant) looped silently and never told the operator to re-authenticate. After a sustained streak it now escalates with a louder warning plus a stderr line naming the omnigent login command, re-emitted periodically. It stays retryable and never fatal, so a recoverable daemon is not killed.

Co-authored-by: Isaac
Signed-off-by: Edwin He <41037314+Edwinhe03@users.noreply.github.com>

* test(cli): use one import style in the refresh test

Drop the mixed import omnigent.cli_auth + from-import inside test_refresh_survives_unwritable_state_dir; call store_token/refresh_stored_token via the ca alias. Resolves the code-quality bot finding on the PR.

Co-authored-by: Isaac
Signed-off-by: Edwin He <41037314+Edwinhe03@users.noreply.github.com>

* fix(cli): drop dead accounts issue_refresh and cover the refresh-factory wiring

Accounts /auth/login issues no refresh material (only the OIDC CLI-ticket flow does), so the accounts-login POST no longer sends the ignored issue_refresh field, and its misleading "older servers ignore it" comment is removed. Updates the CLI accounts-login test that asserted the field.

Also adds a runner-entry test proving the load->refresh->fallback auth-token factory returns the refreshed token when the stored OIDC token has lapsed but a refresh grant is present — the integration that actually keeps an unattended host alive, previously only unit-tested at the refresh function itself.

Co-authored-by: Isaac
Signed-off-by: Edwin He <41037314+Edwinhe03@users.noreply.github.com>

* fix(server): mount login-grant token router only for UnifiedAuthProvider

The elif that mounts /oauth/token for login grants when the device flow is off only checked for a grant store, leaving auth_provider typed as the base AuthProvider (pyrefly bad-argument-type at the create_oauth_token_router call) and — for a non-Unified custom provider with a store — a latent runtime failure in _resolve_signing_config. Guard the branch on isinstance(auth_provider, UnifiedAuthProvider), matching the sibling device-flow branch.

Co-authored-by: Isaac
Signed-off-by: Edwin He <41037314+Edwinhe03@users.noreply.github.com>

* test(e2e): accept min_remaining_seconds in managed-runner load_token mocks

The auth-token factory now calls load_token(url, min_remaining_seconds=...); two managed-runner e2e tests monkeypatched load_token with a lambda that rejected the kwarg, raising TypeError. Accept **_kw, matching the runner-entry unit mocks.

Co-authored-by: Isaac
Signed-off-by: Edwin He <41037314+Edwinhe03@users.noreply.github.com>

---------

Signed-off-by: Edwin He <41037314+Edwinhe03@users.noreply.github.com>
2026-08-20 19:14:41 -07:00
..

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).

Deploy in one click

No local tooling needed. Pick a platform, click the button, and your Omnigent server is live with HTTPS in a few minutes.

Platform Button Docs
Render Deploy to Render render/README.md
Railway (button pending; see below) 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); 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
│
├── blaxel/            ← Blaxel sandbox-provider guide + managed-host config;
│   └── README.md         NOT a server deploy target.
│
├── 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 · Railway
Try the server on your laptop Docker compose 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: copy the compose stack, ./bootstrap.sh, then docker compose up -d
Deploy to Fly.io Fly fly/README.md: fly deploy, SQLite on a volume
Deploy to Modal (durable artifact Volume) Modal 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: wrangler deploy
Stand up a quick demo (no DB to provision) HF Spaces 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 serve https / http://localhost:8000
Cloud Run / Kubernetes / other Docker image 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: uses Asset Bundles

On Databricks? The fully managed Omnigent on Databricks (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 and set the connection string as DATABASE_URL. Any postgres:// / postgresql:// URL works (pooled or direct); the entrypoint normalizes it to the psycopg3 dialect automatically.
  • SQLite: a zero-dependency "lite tier" for demos and single-instance deploys, with no database to provision. The .db file lives on the platform's persistent disk/volume (Render disk, Fly volume, Railway volume) and survives restarts there; on Hugging Face free Spaces the disk is ephemeral, so SQLite data resets on restart, and on Modal the Volume's eventual-consistency semantics don't suit a live .db file, so skip the SQLite tier there. Set DATABASE_URL=sqlite:////data/artifacts/chat.db. Tradeoff: single instance only, no managed backups.

Who provisions the database. Render and Railway create the Postgres as part of the deploy (one step; it's owned by your platform account). Platforms without a managed DB don't: there you either run on SQLite (zero setup, ephemeral on HF) or bring an owned Postgres like Neon (a one-time signup, then persistent). A deploy can't auto-provision a persistent database for you; persistence requires an owned account, and that's the one step that can't be automated away.

First boot against a remote Postgres is slow. Migrations run over the network on the first boot (~1 minute on Neon, vs near-instant for local SQLite); subsequent boots are fast. Make sure the platform's healthcheck grace tolerates it: Render and Railway do by default; on Fly, raise grace_period if you use a remote DB.

Memory floor: the server's working set is ~512 MB1 GB. Render Starter (512 MB), Railway (usage-scaled), and HF Spaces clear it automatically; Fly's 256 MB default does not, so the Fly config pins a 1 GB machine, and the Modal app pins memory=1024 for the same reason.

Serving: put an HTTP/2 proxy in front for many concurrent views

Each open session in the web UI holds a long-lived streaming HTTP response (GET /v1/sessions/{id}/stream, text/event-stream) for as long as the view is on screen. Over HTTP/1.1 browsers cap concurrent connections at ~6 per origin, and every open stream occupies one of those slots. Open a handful of windows or tabs against the same server and the pool fills with held-open streams — then every other request the UI makes (sending a message, the session list, /health, auth) queues behind the cap and never fires. The symptom is the whole UI appearing to freeze across all windows while the server itself is idle; in DevTools → Network the stuck requests sit in Stalled/Queued, not "waiting for server".

Fix: serve over HTTP/2. HTTP/2 multiplexes every stream over one connection, so the per-origin cap stops mattering. uvicorn (the server's ASGI server) speaks HTTP/1.1 only, so HTTP/2 comes from a reverse proxy that terminates TLS in front of it — which most real deploys already have:

  • The bundled Caddy overlay (docker/docker-compose.https.yaml) gives you this for free — Caddy negotiates HTTP/2 (and HTTP/3) over TLS via ALPN with no extra config. See docker/README.md.
  • The one-click platforms (Render, Railway, Fly, Cloudflare) and managed Databricks terminate TLS with HTTP/2 at their edge, so they're already covered.

The gap is only when you expose the raw :8000 HTTP/1.1 port directly to browsers (e.g. docker compose up with no proxy, reached over plain HTTP). That's fine for a single window; put a proxy in front once you or your team routinely open several.

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:

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:

omnigent host https://your-host

Or point a one-off run at the server directly:

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, Blaxel, Islo, or E2B). Install the provider extra when needed (pip install 'omnigent[modal]', 'omnigent[daytona]', 'omnigent[blaxel]', or 'omnigent[e2b]'; Islo uses the built-in HTTP client). Authenticate with modal token new, DAYTONA_API_KEY, Blaxel's BL_WORKSPACE and BL_API_KEY, ISLO_API_KEY, or E2B_API_KEY. Then run:

omnigent sandbox create --provider modal     # or --provider daytona / blaxel / 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 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.

Server-managed (Modal, Daytona, Blaxel, 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):

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. Blaxel reads BL_WORKSPACE and BL_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. Most 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), so the host starts in seconds instead of installing Omnigent at boot. The image ships the coding-harness CLIs (claude, codex, pi, kiro-cli). Blaxel uses blaxel/omnigent-host:latest, which combines this host runtime with Blaxel's required sandbox-api. E2B uses its provider template. To use a custom image instead, build the same host target and point the provider config at it:

docker build -f docker/Dockerfile --target host \
  -t docker.io/<you>/omnigent-host:latest .
docker push docker.io/<you>/omnigent-host:latest
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 and REGISTRY_PASSWORD. For CLI-launched sandboxes, OMNIGENT_MODAL_HOST_IMAGE, OMNIGENT_DAYTONA_HOST_IMAGE, OMNIGENT_BLAXEL_HOST_IMAGE, or OMNIGENT_ISLO_HOST_IMAGE overrides the image.

LLM credentials for managed sessions. A fresh sandbox has no API keys. Park your provider credentials in a Modal secret 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:

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.

sandbox:
  provider: modal
  server_url: https://your-host
  modal:
    secrets: [omnigent-llm]

For Daytona, Blaxel, and Islo, list server environment variable names under sandbox.daytona.env, sandbox.blaxel.env, or sandbox.islo.env. The launcher copies the current server values into each sandbox:

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 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.

See the modal, daytona, blaxel, and islo guides for provider setup and troubleshooting.

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):

# 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=…
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):

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:

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.

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):

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:

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 first.

Branding (white-labeling)

Customize the app name, landing heading, and logos with a branding: block in the server config (omnigent server -c config.yaml, or <data_dir>/config.yaml/data/config.yaml in the Docker stack). Takes effect on the next server start.

branding:
  app_name: "Acme Agent"        # tab title, sidebar wordmark, login screen
  heading: "How can I help?"     # landing hero; "" hides it, omit to keep the default
  logo:                          # a bare string sets `main`; or per-variant:
    main: logo.png               # branding-assets/logo.png
    loading: loading.webp        # working indicator (falls back to main)
    favicon: favicon.png         # browser-tab icon
  powered_by: true               # "Powered by Omnigent" credit; false to hide

Logo files must live under a dedicated branding-assets/ directory beside the config file (for example, /data/branding-assets/logo.png). PNG, JPEG, GIF, WebP, and ICO files up to 5 MiB are accepted only after full decoder validation. ICO files must contain only PNG-backed entries; every directory entry is bounded and decoded independently, while DIB/BMP-backed entries are rejected. Malformed, truncated, oversized, overlapping, trailing-payload, SVG, symlinked, escaped, and non-image files are ignored. Images are also bounded to 4096 pixels per side, 128 frames, 16 megapixels per frame, and 64 megapixels across all decoded frames. The values are served over the unauthenticated GET /v1/info and GET /v1/branding/logo/<variant> endpoints so the login screen is branded before sign-in. Any unset field keeps its built-in default, so a partial block is fine.

The small "Powered by Omnigent" credit under the landing composer appears only once you set custom branding; powered_by: false hides it even then. It always shows the Omnigent mascot, never your logo.

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.