* 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>
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 | 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. Anypostgres:///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
.dbfile 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.dbfile, so skip the SQLite tier there. SetDATABASE_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.
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. Seedocker/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+connectto 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; seedaytona/README.mdfor 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; seee2b/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/oidcor single-user auth. Each session's runner dials back with the user's identity, which the built-inaccountsmode (the deploy default above) can't supply over the runner WebSocket — it returns403even 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=1and 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.