2614 lines
108 KiB
Python
2614 lines
108 KiB
Python
"""FastAPI application — main entry point for the omnigent server."""
|
|
|
|
import asyncio
|
|
import logging
|
|
import mimetypes
|
|
import os
|
|
import re
|
|
import tarfile
|
|
import uuid
|
|
from collections.abc import AsyncIterator, Awaitable, Callable
|
|
from contextlib import asynccontextmanager, suppress
|
|
from pathlib import Path
|
|
from typing import Any, Protocol
|
|
|
|
from fastapi import FastAPI, Query, Request
|
|
from fastapi.responses import FileResponse, JSONResponse
|
|
from fastapi.staticfiles import StaticFiles
|
|
from starlette.exceptions import HTTPException as StarletteHTTPException
|
|
from starlette.middleware.gzip import GZipMiddleware
|
|
from starlette.responses import Response
|
|
from starlette.routing import Mount, Route
|
|
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
|
|
|
from omnigent._platform import resolve_repo_symlink
|
|
from omnigent.errors import ErrorCode, OmnigentError
|
|
from omnigent.harness_plugins import (
|
|
ANTIGRAVITY_NATIVE_CODING_AGENT,
|
|
CLAUDE_NATIVE_CODING_AGENT,
|
|
CODEX_NATIVE_CODING_AGENT,
|
|
CURSOR_NATIVE_CODING_AGENT,
|
|
GOOSE_NATIVE_CODING_AGENT,
|
|
HERMES_NATIVE_CODING_AGENT,
|
|
KIMI_NATIVE_CODING_AGENT,
|
|
KIRO_NATIVE_CODING_AGENT,
|
|
OPENCODE_NATIVE_CODING_AGENT,
|
|
PI_NATIVE_CODING_AGENT,
|
|
QWEN_NATIVE_CODING_AGENT,
|
|
)
|
|
from omnigent.resources import examples as _examples_resources
|
|
from omnigent.runtime import (
|
|
get_terminal_registry,
|
|
set_harness_process_manager,
|
|
set_runner_router,
|
|
set_runner_ws_factory,
|
|
)
|
|
from omnigent.runtime.agent_cache import AgentCache
|
|
from omnigent.runtime.harnesses.process_manager import HarnessProcessManager
|
|
from omnigent.server.auth import AuthProvider, SharingMode
|
|
from omnigent.server.managed_hosts import ManagedSandboxConfig
|
|
from omnigent.server.mcp_pool import ServerMcpPool
|
|
from omnigent.server.performance_metrics import (
|
|
ServerMetricsOtelPublisher,
|
|
ServerPerformanceMetrics,
|
|
publish_server_metrics_periodically,
|
|
set_request_duration_for_access_log,
|
|
set_request_id_for_access_log,
|
|
set_request_session_id_for_access_log,
|
|
set_request_user_agent_for_access_log,
|
|
)
|
|
from omnigent.server.routes.builtin_agents import create_builtin_agents_router
|
|
from omnigent.server.routes.comments import create_comments_router
|
|
from omnigent.server.routes.default_policies import create_default_policies_router
|
|
from omnigent.server.routes.harnesses import create_harnesses_router
|
|
from omnigent.server.routes.policy_registry import create_policy_registry_router
|
|
from omnigent.server.routes.runner_tunnel import create_runner_tunnel_router
|
|
from omnigent.server.routes.session_mcp_servers import create_session_mcp_servers_router
|
|
from omnigent.server.routes.session_policies import create_session_policies_router
|
|
from omnigent.server.routes.sessions import (
|
|
SessionLiveness,
|
|
create_sessions_router,
|
|
set_server_runner_router,
|
|
)
|
|
from omnigent.server.routes.sharing import create_sharing_router
|
|
from omnigent.server.routes.terminal_attach import create_terminal_attach_router
|
|
from omnigent.server.ws_origin import WebSocketOriginMiddleware
|
|
from omnigent.stores import (
|
|
AgentStore,
|
|
ArtifactStore,
|
|
ConversationStore,
|
|
FileStore,
|
|
)
|
|
from omnigent.stores.comment_store import CommentStore
|
|
from omnigent.stores.conversation_store import SessionConnectivity
|
|
from omnigent.stores.host_store import HostStore
|
|
from omnigent.stores.permission_store import PermissionStore
|
|
from omnigent.stores.policy_store import PolicyStore
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _server_version() -> str:
|
|
"""Return the server version exposed to clients.
|
|
|
|
Reads :data:`omnigent.version.VERSION`, the single source of truth shared
|
|
with the CLI and the host/runner hello frames.
|
|
"""
|
|
from omnigent.version import VERSION
|
|
|
|
return VERSION
|
|
|
|
|
|
def _register_web_mimetypes() -> None:
|
|
"""Pin Content-Type for web UI assets regardless of the OS MIME registry.
|
|
|
|
Starlette's ``StaticFiles`` derives ``Content-Type`` from
|
|
``mimetypes.guess_type``. On Windows that consults the registry, where
|
|
``.js`` is frequently mapped to ``text/plain`` — so the browser refuses to
|
|
execute the SPA's ES modules ("Loading module … was blocked because of a
|
|
disallowed MIME type"). Registering the web types explicitly makes the
|
|
bundled UI serve correctly on every platform and removes the dependency on
|
|
a machine's registry configuration.
|
|
"""
|
|
for ext, ctype in (
|
|
(".js", "text/javascript"),
|
|
(".mjs", "text/javascript"),
|
|
(".css", "text/css"),
|
|
(".json", "application/json"),
|
|
(".map", "application/json"),
|
|
(".wasm", "application/wasm"),
|
|
(".svg", "image/svg+xml"),
|
|
# Python's mimetypes DB has no ``.webmanifest`` entry, so without this
|
|
# Starlette serves the PWA manifest as ``application/octet-stream`` and
|
|
# browsers silently refuse to install the app.
|
|
(".webmanifest", "application/manifest+json"),
|
|
):
|
|
mimetypes.add_type(ctype, ext)
|
|
|
|
|
|
_register_web_mimetypes()
|
|
|
|
# Default: the SPA bundled into the installed wheel's package data. A deploy
|
|
# that ships the SPA outside the wheel (e.g. as loose files in the app source
|
|
# tree, to keep the wheel under a per-file size cap) can point here instead via
|
|
# OMNIGENT_WEB_UI_DIST, without rebuilding or repackaging.
|
|
_WEB_UI_DIST = Path(
|
|
os.environ.get("OMNIGENT_WEB_UI_DIST") or (Path(__file__).parent / "static" / "web-ui")
|
|
)
|
|
# Static explainer served at "/" when no web UI bundle is present (an API-only
|
|
# build, or an install that skipped the web UI). Kept as a file rather than an
|
|
# inline string so it doesn't clutter the app definition; it's pure static
|
|
# markup with no interpolation. Shipped via package-data in pyproject.toml.
|
|
_API_ONLY_LANDING_HTML = Path(__file__).parent / "static" / "api_only_landing.html"
|
|
_WEB_UI_HTML_CACHE_CONTROL = "no-cache"
|
|
_WEB_UI_ASSET_CACHE_CONTROL = "public, max-age=31536000, immutable"
|
|
_WEB_UI_STATIC_CACHE_CONTROL = "public, max-age=3600"
|
|
_WEB_UI_API_FALLBACK_PREFIXES = frozenset({"api", "auth", "health", "v1"})
|
|
_WEB_UI_GZIP_MINIMUM_SIZE = 1024
|
|
_CLAUDE_NATIVE_AGENT_NAME = CLAUDE_NATIVE_CODING_AGENT.agent_name
|
|
_CODEX_NATIVE_AGENT_NAME = CODEX_NATIVE_CODING_AGENT.agent_name
|
|
_PI_NATIVE_AGENT_NAME = PI_NATIVE_CODING_AGENT.agent_name
|
|
_OPENCODE_NATIVE_AGENT_NAME = OPENCODE_NATIVE_CODING_AGENT.agent_name
|
|
_CURSOR_NATIVE_AGENT_NAME = CURSOR_NATIVE_CODING_AGENT.agent_name
|
|
_KIRO_NATIVE_AGENT_NAME = KIRO_NATIVE_CODING_AGENT.agent_name
|
|
_GOOSE_NATIVE_AGENT_NAME = GOOSE_NATIVE_CODING_AGENT.agent_name
|
|
_HERMES_NATIVE_AGENT_NAME = HERMES_NATIVE_CODING_AGENT.agent_name
|
|
_ANTIGRAVITY_NATIVE_AGENT_NAME = ANTIGRAVITY_NATIVE_CODING_AGENT.agent_name
|
|
_QWEN_NATIVE_AGENT_NAME = QWEN_NATIVE_CODING_AGENT.agent_name
|
|
_KIMI_NATIVE_AGENT_NAME = KIMI_NATIVE_CODING_AGENT.agent_name
|
|
_DEBBY_AGENT_NAME = "debby"
|
|
_POLLY_AGENT_NAME = "polly"
|
|
_UNMATCHED_ROUTE_TEMPLATE = "<unmatched>"
|
|
_SESSION_PATH_RE = re.compile(r"/v1/sessions/([^/]+)")
|
|
# polly's and debby's multi-file bundles are packaged under
|
|
# omnigent.resources.examples (see pyproject package-data), so they resolve
|
|
# in both a repo checkout and an installed wheel. The presence check in each
|
|
# seeder is a safety net.
|
|
# resolve_repo_symlink dereferences the packaged symlink on a no-symlink
|
|
# Windows checkout (where Git leaves it as a stub text file); a no-op elsewhere.
|
|
_DEBBY_BUNDLE_SOURCE = resolve_repo_symlink(Path(_examples_resources.__file__).parent / "debby")
|
|
_POLLY_BUNDLE_SOURCE = resolve_repo_symlink(Path(_examples_resources.__file__).parent / "polly")
|
|
|
|
|
|
class _FastAPICallNext(Protocol):
|
|
"""
|
|
Protocol for FastAPI's middleware continuation callable.
|
|
"""
|
|
|
|
def __call__(self, request: Request) -> Awaitable[Response]:
|
|
"""
|
|
Execute the next middleware or route handler.
|
|
|
|
:param request: Incoming FastAPI request.
|
|
:returns: Awaitable that resolves to the downstream response.
|
|
"""
|
|
...
|
|
|
|
|
|
class _WebSocketMetricsMiddleware:
|
|
"""
|
|
ASGI middleware that tracks accepted WebSocket connections.
|
|
|
|
:param app: Downstream ASGI app.
|
|
:param metrics: Process-local server metrics tracker.
|
|
"""
|
|
|
|
def __init__(self, app: ASGIApp, metrics: ServerPerformanceMetrics) -> None:
|
|
"""
|
|
Initialize the middleware.
|
|
|
|
:param app: Downstream ASGI app.
|
|
:param metrics: Process-local server metrics tracker.
|
|
"""
|
|
self._app = app
|
|
self._metrics = metrics
|
|
|
|
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
"""
|
|
Track an accepted WebSocket for the lifetime of its ASGI scope.
|
|
|
|
:param scope: ASGI connection scope, e.g. type ``"websocket"``.
|
|
:param receive: ASGI receive callable.
|
|
:param send: ASGI send callable.
|
|
"""
|
|
if scope["type"] != "websocket":
|
|
await self._app(scope, receive, send)
|
|
return
|
|
|
|
counted = False
|
|
|
|
async def send_with_metrics(message: Message) -> None:
|
|
"""
|
|
Count the connection when the route accepts the handshake.
|
|
|
|
:param message: ASGI message emitted by the downstream app,
|
|
e.g. ``{"type": "websocket.accept"}``.
|
|
"""
|
|
nonlocal counted
|
|
if not counted and message["type"] == "websocket.accept":
|
|
self._metrics.websocket_connected()
|
|
counted = True
|
|
await send(message)
|
|
|
|
try:
|
|
await self._app(scope, receive, send_with_metrics)
|
|
finally:
|
|
if counted:
|
|
self._metrics.websocket_disconnected()
|
|
|
|
|
|
def request_route_template_for_metrics(request: Request) -> str:
|
|
"""
|
|
Return the low-cardinality route template for metrics attributes.
|
|
|
|
Prefer the matched Starlette/FastAPI route template over the raw
|
|
URL path so request IDs embedded in paths do not become metric
|
|
label values.
|
|
|
|
:param request: Incoming FastAPI request, e.g. ``GET /health``.
|
|
:returns: Route template such as ``"/v1/sessions/{session_id}"``,
|
|
or ``"<unmatched>"`` if no matched route is available.
|
|
"""
|
|
route = request.scope.get("route")
|
|
if isinstance(route, Route | Mount):
|
|
return route.path
|
|
return _UNMATCHED_ROUTE_TEMPLATE
|
|
|
|
|
|
def _request_status_code_for_metrics(
|
|
status_code: int | None,
|
|
*,
|
|
failed: bool,
|
|
) -> int | None:
|
|
"""
|
|
Return the HTTP status code to attach to request duration metrics.
|
|
|
|
:param status_code: Response status code observed from
|
|
``call_next``.
|
|
:param failed: Whether the request raised before returning a
|
|
response.
|
|
:returns: The observed status code, ``500`` for an exception
|
|
before response creation, or ``None`` if unavailable.
|
|
"""
|
|
if status_code is not None:
|
|
return status_code
|
|
if failed:
|
|
return 500
|
|
return None
|
|
|
|
|
|
# MCP startup warming moved to runner; see designs/RUNNER_MCP.md.
|
|
|
|
|
|
def _normalize_tarinfo(tarinfo: tarfile.TarInfo) -> tarfile.TarInfo:
|
|
"""
|
|
Strip nondeterministic metadata from a tar member header.
|
|
|
|
The built-in bundle builders tar a materialized directory whose
|
|
files carry install-time metadata — a fresh wheel install on each
|
|
deploy stamps new mtimes (and may differ in mode bits) even when
|
|
the spec content is identical. Zeroing mtime/ownership and pinning
|
|
a canonical mode makes the tarball a pure function of file paths +
|
|
content, so the bundle's SHA-256 only moves when the spec actually
|
|
changes. That is what lets the seeder skip no-op refreshes across
|
|
redeploys instead of bumping the version every boot. Mode is safe
|
|
to canonicalize because extraction uses ``set_attrs=False`` (see
|
|
``omnigent.spec.tar_utils.extract_safe``), so on-disk permissions
|
|
don't depend on it.
|
|
|
|
:param tarinfo: The tar member header to normalize.
|
|
:returns: The same header with mtime, ownership, and mode pinned.
|
|
"""
|
|
tarinfo.mtime = 0
|
|
tarinfo.uid = 0
|
|
tarinfo.gid = 0
|
|
tarinfo.uname = ""
|
|
tarinfo.gname = ""
|
|
tarinfo.mode = 0o755 if tarinfo.isdir() else 0o644
|
|
return tarinfo
|
|
|
|
|
|
def _tar_gz_dir(bundle_dir: Path) -> bytes:
|
|
"""
|
|
Pack *bundle_dir* into a deterministic gzipped tarball.
|
|
|
|
Identical directory content always yields identical bytes (gzip
|
|
mtime pinned to 0, tar member metadata normalized via
|
|
:func:`_normalize_tarinfo`), so the result is safe to
|
|
content-address.
|
|
|
|
:param bundle_dir: Materialized bundle directory to archive,
|
|
e.g. a temp dir containing ``config.yaml``.
|
|
:returns: Reproducible gzipped tarball bytes suitable for the
|
|
artifact store.
|
|
"""
|
|
import gzip
|
|
import io
|
|
|
|
buf = io.BytesIO()
|
|
with (
|
|
gzip.GzipFile(fileobj=buf, mode="wb", mtime=0) as gz,
|
|
tarfile.open(fileobj=gz, mode="w") as tf,
|
|
):
|
|
# tarfile.add recurses in sorted(os.listdir) order, so member order
|
|
# is stable across machines; _normalize_tarinfo zeroes the metadata.
|
|
tf.add(str(bundle_dir), arcname=".", filter=_normalize_tarinfo)
|
|
return buf.getvalue()
|
|
|
|
|
|
def _ensure_builtin_agent(
|
|
agent_store: AgentStore,
|
|
artifact_store: ArtifactStore,
|
|
agent_cache: Any,
|
|
*,
|
|
name: str,
|
|
bundle_bytes: bytes,
|
|
) -> None:
|
|
"""
|
|
Register or refresh a built-in template agent from its bundle.
|
|
|
|
Content-aware and idempotent. The agent row is keyed by *name*;
|
|
its ``bundle_location`` is content-addressed
|
|
(``"{agent_id}/{sha256}"``):
|
|
|
|
- **No existing row** → create it.
|
|
- **Row exists, content hash differs** → store the new bundle and
|
|
update the row in place (keeps the ``agent_id`` stable so task
|
|
history isn't cascade-deleted; bumps ``version`` so the runner's
|
|
version-keyed spec cache re-fetches), then warm-swap the cache.
|
|
- **Row exists, content hash matches** → evict the local cache so
|
|
the next load re-fetches from ``bundle_location``, then return.
|
|
|
|
The evict on the matching-hash path matters because
|
|
:meth:`AgentCache.load` is keyed by ``agent_id`` and trusts its
|
|
in-memory / on-disk entry without checking ``bundle_location``: a
|
|
replica that boots with a cache lagging the (already-current) DB
|
|
row — or a prior boot whose ``replace`` failed after ``update``
|
|
succeeded — would otherwise keep serving the stale spec.
|
|
|
|
This replaces the old seed-once behavior, which skipped on row
|
|
existence and so served a stale spec after the wheel shipped a new
|
|
one. Mirrors the upsert in :func:`omnigent.cli._register_yaml_bundle`.
|
|
|
|
:param agent_store: Store for agent metadata.
|
|
:param artifact_store: Store for agent bundles.
|
|
:param agent_cache: Cache for loaded agent specs; exposes
|
|
``replace`` and ``evict``.
|
|
:param name: Built-in agent's unique name, e.g. ``"polly"``.
|
|
:param bundle_bytes: Freshly built gzipped tarball of the spec.
|
|
"""
|
|
import hashlib
|
|
|
|
from omnigent.db.utils import builtin_agent_id
|
|
|
|
bundle_hash = hashlib.sha256(bundle_bytes).hexdigest()
|
|
existing = agent_store.get_by_name(name)
|
|
if existing is not None:
|
|
new_loc = f"{existing.id}/{bundle_hash}"
|
|
if existing.bundle_location == new_loc:
|
|
# Row current; evict so a lagging replica's stale cache reloads the bundle.
|
|
agent_cache.evict(existing.id)
|
|
return
|
|
artifact_store.put(new_loc, bundle_bytes)
|
|
agent_store.update(existing.id, new_loc)
|
|
# Warm-swap, not evict: a bare evict leaves the stale on-disk cache tier.
|
|
# Built-ins are operator-authored template agents, so ${VAR} may expand
|
|
# against the server env.
|
|
agent_cache.replace(existing.id, new_loc, bundle_bytes, expand_env=True)
|
|
_logger.info(
|
|
"Refreshed built-in %s agent %s to bundle %s",
|
|
name,
|
|
existing.id,
|
|
bundle_hash[:12],
|
|
)
|
|
return
|
|
|
|
# Name-derived (not random) so it survives a per-pod reseed; see builtin_agent_id.
|
|
agent_id = builtin_agent_id(name)
|
|
bundle_key = f"{agent_id}/{bundle_hash}"
|
|
artifact_store.put(bundle_key, bundle_bytes)
|
|
agent_store.create(agent_id, name, bundle_key)
|
|
agent_cache.evict(agent_id)
|
|
_logger.info("Registered built-in %s agent as %s", name, agent_id)
|
|
|
|
|
|
def _ensure_default_agents(
|
|
agent_store: AgentStore,
|
|
artifact_store: ArtifactStore,
|
|
agent_cache: Any,
|
|
) -> None:
|
|
"""
|
|
Register all built-in agents that should always be available.
|
|
|
|
Called on every server lifespan startup. Each helper is
|
|
content-aware via :func:`_ensure_builtin_agent`: it creates the
|
|
agent if missing and refreshes it in place when the packaged
|
|
bundle changed, so a redeploy picks up a new spec instead of
|
|
serving the row seeded on first boot.
|
|
|
|
:param agent_store: Store for agent metadata.
|
|
:param artifact_store: Store for agent bundles.
|
|
:param agent_cache: Cache for loaded agent specs.
|
|
"""
|
|
_ensure_default_claude_agent(agent_store, artifact_store, agent_cache)
|
|
_ensure_default_codex_agent(agent_store, artifact_store, agent_cache)
|
|
_ensure_default_pi_agent(agent_store, artifact_store, agent_cache)
|
|
_ensure_default_opencode_agent(agent_store, artifact_store, agent_cache)
|
|
_ensure_default_cursor_agent(agent_store, artifact_store, agent_cache)
|
|
_ensure_default_kiro_agent(agent_store, artifact_store, agent_cache)
|
|
_ensure_default_goose_agent(agent_store, artifact_store, agent_cache)
|
|
_ensure_default_hermes_agent(agent_store, artifact_store, agent_cache)
|
|
_ensure_default_antigravity_agent(agent_store, artifact_store, agent_cache)
|
|
_ensure_default_qwen_agent(agent_store, artifact_store, agent_cache)
|
|
_ensure_default_kimi_native_agent(agent_store, artifact_store, agent_cache)
|
|
_ensure_default_debby_agent(agent_store, artifact_store, agent_cache)
|
|
_ensure_default_polly_agent(agent_store, artifact_store, agent_cache)
|
|
_ensure_extra_builtin_agents(agent_store, artifact_store, agent_cache)
|
|
|
|
|
|
# Env var listing extra built-in agent specs to seed at startup, in addition
|
|
# to the packaged claude-native-ui / codex-native-ui / polly set. Each
|
|
# ``os.pathsep``-separated entry is a path to an agent spec (single-file
|
|
# YAML or a bundle dir); it is registered as a built-in (``session_id NULL``)
|
|
# under the spec path's stem (file) or directory name. Lets a deployment —
|
|
# or an e2e fixture — ship custom always-available agents (e.g. a plain
|
|
# claude-sdk chat agent that a fork can switch into).
|
|
_EXTRA_BUILTIN_AGENTS_ENV = "OMNIGENT_BUILTIN_AGENT_DIRS"
|
|
|
|
|
|
def _ensure_extra_builtin_agents(
|
|
agent_store: AgentStore,
|
|
artifact_store: ArtifactStore,
|
|
agent_cache: Any,
|
|
) -> None:
|
|
"""
|
|
Seed extra built-in agents named by :data:`_EXTRA_BUILTIN_AGENTS_ENV`.
|
|
|
|
No-op when the env var is unset. Each entry is materialized into a
|
|
bundle, tarballed, and registered via :func:`_ensure_builtin_agent`
|
|
(content-aware / idempotent), so a redeploy refreshes a changed spec.
|
|
The built-in's name is the entry path's stem (single-file spec) or
|
|
directory name (bundle dir).
|
|
|
|
Unlike the packaged ``_ensure_default_*`` helpers, this reads
|
|
operator-supplied paths that may be wrong in a deployment (typo, stale
|
|
mount). A bad entry is logged and skipped — one misconfigured extra
|
|
agent must never block server startup (the packaged built-ins still
|
|
seed). Mirrors the best-effort spec-load in :func:`_to_agent_object`.
|
|
|
|
:param agent_store: Store for agent metadata.
|
|
:param artifact_store: Store for agent bundles.
|
|
:param agent_cache: Cache for loaded agent specs.
|
|
"""
|
|
import tempfile
|
|
|
|
from omnigent.spec import materialize_bundle
|
|
|
|
raw = os.environ.get(_EXTRA_BUILTIN_AGENTS_ENV, "").strip()
|
|
if not raw:
|
|
return
|
|
for entry in raw.split(os.pathsep):
|
|
entry = entry.strip()
|
|
if not entry:
|
|
continue
|
|
source = Path(entry)
|
|
try:
|
|
name = source.stem if source.is_file() else source.name
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
bundle_dir = materialize_bundle(source, Path(tmpdir) / "bundle")
|
|
bundle_bytes = _tar_gz_dir(bundle_dir)
|
|
_ensure_builtin_agent(
|
|
agent_store,
|
|
artifact_store,
|
|
agent_cache,
|
|
name=name,
|
|
bundle_bytes=bundle_bytes,
|
|
)
|
|
except Exception: # a bad operator path must not block server startup
|
|
_logger.exception(
|
|
"Failed to register extra built-in agent from %r (%s); skipping. Check %s.",
|
|
str(source),
|
|
"does not exist" if not source.exists() else "invalid spec/bundle",
|
|
_EXTRA_BUILTIN_AGENTS_ENV,
|
|
)
|
|
continue
|
|
_logger.info("Registered extra built-in agent %r from %s", name, source)
|
|
|
|
|
|
def _build_claude_native_bundle() -> bytes:
|
|
"""
|
|
Build a gzipped tarball of the claude-native-ui agent spec.
|
|
|
|
:returns: Gzipped tarball bytes suitable for the artifact store.
|
|
"""
|
|
import tempfile
|
|
|
|
from omnigent.claude_native import _materialize_claude_agent_spec
|
|
from omnigent.spec import materialize_bundle
|
|
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
spec_path = _materialize_claude_agent_spec(Path(tmpdir))
|
|
bundle_dir = materialize_bundle(spec_path, Path(tmpdir) / "bundle")
|
|
return _tar_gz_dir(bundle_dir)
|
|
|
|
|
|
def _ensure_default_claude_agent(
|
|
agent_store: AgentStore,
|
|
artifact_store: ArtifactStore,
|
|
agent_cache: Any,
|
|
) -> None:
|
|
"""
|
|
Register or refresh the claude-native-ui agent.
|
|
|
|
Called during server lifespan startup so the Web UI can create
|
|
host-launched sessions without requiring a prior CLI-initiated
|
|
session. Content-aware via :func:`_ensure_builtin_agent`: a new
|
|
wheel with a changed spec refreshes the row in place rather than
|
|
being ignored.
|
|
|
|
:param agent_store: Store for agent metadata.
|
|
:param artifact_store: Store for agent bundles.
|
|
:param agent_cache: Cache for loaded agent specs.
|
|
"""
|
|
_ensure_builtin_agent(
|
|
agent_store,
|
|
artifact_store,
|
|
agent_cache,
|
|
name=_CLAUDE_NATIVE_AGENT_NAME,
|
|
bundle_bytes=_build_claude_native_bundle(),
|
|
)
|
|
|
|
|
|
def _build_codex_native_bundle() -> bytes:
|
|
"""
|
|
Build a gzipped tarball of the codex-native-ui agent spec.
|
|
|
|
:returns: Gzipped tarball bytes suitable for the artifact store.
|
|
"""
|
|
import tempfile
|
|
|
|
from omnigent.codex_native import _materialize_codex_agent_spec
|
|
from omnigent.spec import materialize_bundle
|
|
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
spec_path = _materialize_codex_agent_spec(Path(tmpdir), model=None)
|
|
bundle_dir = materialize_bundle(spec_path, Path(tmpdir) / "bundle")
|
|
return _tar_gz_dir(bundle_dir)
|
|
|
|
|
|
def _ensure_default_codex_agent(
|
|
agent_store: AgentStore,
|
|
artifact_store: ArtifactStore,
|
|
agent_cache: Any,
|
|
) -> None:
|
|
"""
|
|
Register or refresh the codex-native-ui agent.
|
|
|
|
Called during server lifespan startup so the Web UI can offer
|
|
Codex as a built-in agent alongside Claude. Content-aware via
|
|
:func:`_ensure_builtin_agent`: a new wheel with a changed spec
|
|
refreshes the row in place rather than being ignored.
|
|
|
|
:param agent_store: Store for agent metadata.
|
|
:param artifact_store: Store for agent bundles.
|
|
:param agent_cache: Cache for loaded agent specs.
|
|
"""
|
|
_ensure_builtin_agent(
|
|
agent_store,
|
|
artifact_store,
|
|
agent_cache,
|
|
name=_CODEX_NATIVE_AGENT_NAME,
|
|
bundle_bytes=_build_codex_native_bundle(),
|
|
)
|
|
|
|
|
|
def _build_opencode_native_bundle() -> bytes:
|
|
"""
|
|
Build a gzipped tarball of the opencode-native-ui agent spec.
|
|
|
|
:returns: Gzipped tarball bytes suitable for the artifact store.
|
|
"""
|
|
import tempfile
|
|
|
|
from omnigent.opencode_native import _materialize_opencode_agent_spec
|
|
from omnigent.spec import materialize_bundle
|
|
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
spec_path = _materialize_opencode_agent_spec(Path(tmpdir), model=None)
|
|
bundle_dir = materialize_bundle(spec_path, Path(tmpdir) / "bundle")
|
|
return _tar_gz_dir(bundle_dir)
|
|
|
|
|
|
def _ensure_default_opencode_agent(
|
|
agent_store: AgentStore,
|
|
artifact_store: ArtifactStore,
|
|
agent_cache: Any,
|
|
) -> None:
|
|
"""
|
|
Register or refresh the opencode-native-ui agent.
|
|
|
|
Called during server lifespan startup so the Web UI can offer OpenCode
|
|
as a built-in agent alongside Claude / Codex / Pi. Content-aware via
|
|
:func:`_ensure_builtin_agent`: a new wheel with a changed spec refreshes
|
|
the row in place rather than being ignored.
|
|
|
|
:param agent_store: Store for agent metadata.
|
|
:param artifact_store: Store for agent bundles.
|
|
:param agent_cache: Cache for loaded agent specs.
|
|
"""
|
|
_ensure_builtin_agent(
|
|
agent_store,
|
|
artifact_store,
|
|
agent_cache,
|
|
name=_OPENCODE_NATIVE_AGENT_NAME,
|
|
bundle_bytes=_build_opencode_native_bundle(),
|
|
)
|
|
|
|
|
|
def _build_pi_native_bundle() -> bytes:
|
|
"""
|
|
Build a gzipped tarball of the pi-native-ui agent spec.
|
|
|
|
:returns: Gzipped tarball bytes suitable for the artifact store.
|
|
"""
|
|
import tempfile
|
|
|
|
from omnigent.pi_native import _materialize_pi_agent_spec
|
|
from omnigent.spec import materialize_bundle
|
|
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
spec_path = _materialize_pi_agent_spec(Path(tmpdir))
|
|
bundle_dir = materialize_bundle(spec_path, Path(tmpdir) / "bundle")
|
|
return _tar_gz_dir(bundle_dir)
|
|
|
|
|
|
def _ensure_default_pi_agent(
|
|
agent_store: AgentStore,
|
|
artifact_store: ArtifactStore,
|
|
agent_cache: Any,
|
|
) -> None:
|
|
"""
|
|
Register or refresh the pi-native-ui agent.
|
|
|
|
Called during server lifespan startup so the Web UI can offer Pi as a
|
|
built-in native-terminal agent. Content-aware via
|
|
:func:`_ensure_builtin_agent`: a new wheel with a changed spec refreshes
|
|
the row in place rather than being ignored.
|
|
|
|
:param agent_store: Store for agent metadata.
|
|
:param artifact_store: Store for agent bundles.
|
|
:param agent_cache: Cache for loaded agent specs.
|
|
"""
|
|
_ensure_builtin_agent(
|
|
agent_store,
|
|
artifact_store,
|
|
agent_cache,
|
|
name=_PI_NATIVE_AGENT_NAME,
|
|
bundle_bytes=_build_pi_native_bundle(),
|
|
)
|
|
|
|
|
|
def _build_cursor_native_bundle() -> bytes:
|
|
"""
|
|
Build a gzipped tarball of the cursor-native-ui agent spec.
|
|
|
|
:returns: Gzipped tarball bytes suitable for the artifact store.
|
|
"""
|
|
import tempfile
|
|
|
|
from omnigent.cursor_native import _materialize_cursor_agent_spec
|
|
from omnigent.spec import materialize_bundle
|
|
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
spec_path = _materialize_cursor_agent_spec(Path(tmpdir))
|
|
bundle_dir = materialize_bundle(spec_path, Path(tmpdir) / "bundle")
|
|
return _tar_gz_dir(bundle_dir)
|
|
|
|
|
|
def _ensure_default_cursor_agent(
|
|
agent_store: AgentStore,
|
|
artifact_store: ArtifactStore,
|
|
agent_cache: Any,
|
|
) -> None:
|
|
"""
|
|
Register or refresh the cursor-native-ui agent.
|
|
|
|
Called during server lifespan startup so the Web UI offers Cursor as a
|
|
built-in native-terminal agent on every deployment (not only after the
|
|
``omnigent cursor`` CLI first registers it). Content-aware via
|
|
:func:`_ensure_builtin_agent`.
|
|
|
|
:param agent_store: Store for agent metadata.
|
|
:param artifact_store: Store for agent bundles.
|
|
:param agent_cache: Cache for loaded agent specs.
|
|
"""
|
|
_ensure_builtin_agent(
|
|
agent_store,
|
|
artifact_store,
|
|
agent_cache,
|
|
name=_CURSOR_NATIVE_AGENT_NAME,
|
|
bundle_bytes=_build_cursor_native_bundle(),
|
|
)
|
|
|
|
|
|
def _build_kiro_native_bundle() -> bytes:
|
|
"""Build a gzipped tarball of the kiro-native-ui agent spec."""
|
|
import tempfile
|
|
|
|
from omnigent.kiro_native import _materialize_kiro_agent_spec
|
|
from omnigent.spec import materialize_bundle
|
|
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
spec_path = _materialize_kiro_agent_spec(Path(tmpdir), model=None)
|
|
bundle_dir = materialize_bundle(spec_path, Path(tmpdir) / "bundle")
|
|
return _tar_gz_dir(bundle_dir)
|
|
|
|
|
|
def _ensure_default_kiro_agent(
|
|
agent_store: AgentStore,
|
|
artifact_store: ArtifactStore,
|
|
agent_cache: Any,
|
|
) -> None:
|
|
"""Register or refresh the kiro-native-ui agent."""
|
|
_ensure_builtin_agent(
|
|
agent_store,
|
|
artifact_store,
|
|
agent_cache,
|
|
name=_KIRO_NATIVE_AGENT_NAME,
|
|
bundle_bytes=_build_kiro_native_bundle(),
|
|
)
|
|
|
|
|
|
def _build_goose_native_bundle() -> bytes:
|
|
"""Build a gzipped tarball of the goose-native-ui agent spec."""
|
|
import tempfile
|
|
|
|
from omnigent.goose_native import _materialize_goose_agent_spec
|
|
from omnigent.spec import materialize_bundle
|
|
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
spec_path = _materialize_goose_agent_spec(Path(tmpdir))
|
|
bundle_dir = materialize_bundle(spec_path, Path(tmpdir) / "bundle")
|
|
return _tar_gz_dir(bundle_dir)
|
|
|
|
|
|
def _ensure_default_goose_agent(
|
|
agent_store: AgentStore,
|
|
artifact_store: ArtifactStore,
|
|
agent_cache: Any,
|
|
) -> None:
|
|
"""Register or refresh the goose-native-ui agent."""
|
|
_ensure_builtin_agent(
|
|
agent_store,
|
|
artifact_store,
|
|
agent_cache,
|
|
name=_GOOSE_NATIVE_AGENT_NAME,
|
|
bundle_bytes=_build_goose_native_bundle(),
|
|
)
|
|
|
|
|
|
def _build_hermes_native_bundle() -> bytes:
|
|
"""Build a gzipped tarball of the hermes-native-ui agent spec."""
|
|
import tempfile
|
|
|
|
from omnigent.hermes_native import _materialize_hermes_agent_spec
|
|
from omnigent.spec import materialize_bundle
|
|
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
spec_path = _materialize_hermes_agent_spec(Path(tmpdir))
|
|
bundle_dir = materialize_bundle(spec_path, Path(tmpdir) / "bundle")
|
|
return _tar_gz_dir(bundle_dir)
|
|
|
|
|
|
def _ensure_default_hermes_agent(
|
|
agent_store: AgentStore,
|
|
artifact_store: ArtifactStore,
|
|
agent_cache: Any,
|
|
) -> None:
|
|
"""Register or refresh the hermes-native-ui agent."""
|
|
_ensure_builtin_agent(
|
|
agent_store,
|
|
artifact_store,
|
|
agent_cache,
|
|
name=_HERMES_NATIVE_AGENT_NAME,
|
|
bundle_bytes=_build_hermes_native_bundle(),
|
|
)
|
|
|
|
|
|
def _ensure_default_antigravity_agent(
|
|
agent_store: AgentStore,
|
|
artifact_store: ArtifactStore,
|
|
agent_cache: Any,
|
|
) -> None:
|
|
"""
|
|
Register or refresh the antigravity-native-ui agent.
|
|
|
|
Called during server lifespan startup so the Web UI can offer Antigravity
|
|
as a built-in native-terminal agent (the ``agy`` TUI), alongside Claude
|
|
Code / Codex / Pi. Content-aware via :func:`_ensure_builtin_agent`: a new
|
|
wheel with a changed spec refreshes the row in place rather than being
|
|
ignored.
|
|
|
|
:param agent_store: Store for agent metadata.
|
|
:param artifact_store: Store for agent bundles.
|
|
:param agent_cache: Cache for loaded agent specs.
|
|
"""
|
|
_ensure_builtin_agent(
|
|
agent_store,
|
|
artifact_store,
|
|
agent_cache,
|
|
name=_ANTIGRAVITY_NATIVE_AGENT_NAME,
|
|
bundle_bytes=_build_antigravity_native_bundle(),
|
|
)
|
|
|
|
|
|
def _build_antigravity_native_bundle() -> bytes:
|
|
"""
|
|
Build a gzipped tarball of the antigravity-native-ui agent spec.
|
|
|
|
:returns: Gzipped tarball bytes suitable for the artifact store.
|
|
"""
|
|
import tempfile
|
|
|
|
from omnigent.antigravity_native import _materialize_antigravity_agent_spec
|
|
from omnigent.spec import materialize_bundle
|
|
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
spec_path = _materialize_antigravity_agent_spec(Path(tmpdir))
|
|
bundle_dir = materialize_bundle(spec_path, Path(tmpdir) / "bundle")
|
|
return _tar_gz_dir(bundle_dir)
|
|
|
|
|
|
def _build_qwen_native_bundle() -> bytes:
|
|
"""
|
|
Build a gzipped tarball of the qwen-native-ui agent spec.
|
|
|
|
:returns: Gzipped tarball bytes suitable for the artifact store.
|
|
"""
|
|
import tempfile
|
|
|
|
from omnigent.qwen_native import _materialize_qwen_agent_spec
|
|
from omnigent.spec import materialize_bundle
|
|
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
spec_path = _materialize_qwen_agent_spec(Path(tmpdir))
|
|
bundle_dir = materialize_bundle(spec_path, Path(tmpdir) / "bundle")
|
|
return _tar_gz_dir(bundle_dir)
|
|
|
|
|
|
def _ensure_default_qwen_agent(
|
|
agent_store: AgentStore,
|
|
artifact_store: ArtifactStore,
|
|
agent_cache: Any,
|
|
) -> None:
|
|
"""
|
|
Register or refresh the qwen-native-ui agent.
|
|
|
|
Called during server lifespan startup so the Web UI offers Qwen Code as a
|
|
built-in native-terminal agent on every deployment (not only after the
|
|
``omnigent qwen`` CLI first registers it). Content-aware via
|
|
:func:`_ensure_builtin_agent`.
|
|
|
|
:param agent_store: Store for agent metadata.
|
|
:param artifact_store: Store for agent bundles.
|
|
:param agent_cache: Cache for loaded agent specs.
|
|
"""
|
|
_ensure_builtin_agent(
|
|
agent_store,
|
|
artifact_store,
|
|
agent_cache,
|
|
name=_QWEN_NATIVE_AGENT_NAME,
|
|
bundle_bytes=_build_qwen_native_bundle(),
|
|
)
|
|
|
|
|
|
def _build_kimi_native_bundle() -> bytes:
|
|
"""
|
|
Build a gzipped tarball of the kimi-native-ui agent spec.
|
|
|
|
:returns: Gzipped tarball bytes suitable for the artifact store.
|
|
"""
|
|
import tempfile
|
|
|
|
from omnigent.kimi_native import _materialize_kimi_agent_spec
|
|
from omnigent.spec import materialize_bundle
|
|
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
spec_path = _materialize_kimi_agent_spec(Path(tmpdir))
|
|
bundle_dir = materialize_bundle(spec_path, Path(tmpdir) / "bundle")
|
|
return _tar_gz_dir(bundle_dir)
|
|
|
|
|
|
def _ensure_default_kimi_native_agent(
|
|
agent_store: AgentStore,
|
|
artifact_store: ArtifactStore,
|
|
agent_cache: Any,
|
|
) -> None:
|
|
"""
|
|
Register or refresh the kimi-native-ui agent.
|
|
|
|
Called during server lifespan startup so the Web UI offers Kimi as a
|
|
built-in native-terminal agent on every deployment (not only after the
|
|
``omnigent kimi`` CLI first registers it). Content-aware via
|
|
:func:`_ensure_builtin_agent`.
|
|
|
|
:param agent_store: Store for agent metadata.
|
|
:param artifact_store: Store for agent bundles.
|
|
:param agent_cache: Cache for loaded agent specs.
|
|
"""
|
|
_ensure_builtin_agent(
|
|
agent_store,
|
|
artifact_store,
|
|
agent_cache,
|
|
name=_KIMI_NATIVE_AGENT_NAME,
|
|
bundle_bytes=_build_kimi_native_bundle(),
|
|
)
|
|
|
|
|
|
def _build_debby_bundle() -> bytes:
|
|
"""
|
|
Build a gzipped tarball of the ``examples/debby`` agent bundle.
|
|
|
|
debby is a multi-file image (``config.yaml`` plus ``agents/`` and
|
|
``skills/`` subdirectories), so the source is the directory itself
|
|
rather than a generated single YAML.
|
|
|
|
:returns: Gzipped tarball bytes suitable for the artifact store.
|
|
"""
|
|
import tempfile
|
|
|
|
from omnigent.spec import materialize_bundle
|
|
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
bundle_dir = materialize_bundle(_DEBBY_BUNDLE_SOURCE, Path(tmpdir) / "bundle")
|
|
return _tar_gz_dir(bundle_dir)
|
|
|
|
|
|
def _ensure_default_debby_agent(
|
|
agent_store: AgentStore,
|
|
artifact_store: ArtifactStore,
|
|
agent_cache: Any,
|
|
) -> None:
|
|
"""
|
|
Register the debby brainstorming agent if its bundle ships here.
|
|
|
|
Called during server lifespan startup so the Web UI's new-session
|
|
picker can offer debby as a host-launchable card next to Claude
|
|
Code, Codex, and polly. When the bundle is absent (generic
|
|
deployment that didn't package it), seeding is skipped so no card
|
|
is offered for an agent that can't be launched here — same pattern
|
|
as :func:`_ensure_default_polly_agent`. Content-aware via
|
|
:func:`_ensure_builtin_agent`: when a new wheel ships a changed
|
|
debby spec, the existing row is refreshed in place instead of
|
|
being ignored.
|
|
|
|
:param agent_store: Store for agent metadata.
|
|
:param artifact_store: Store for agent bundles.
|
|
:param agent_cache: Cache for loaded agent specs.
|
|
"""
|
|
if not (_DEBBY_BUNDLE_SOURCE / "config.yaml").is_file():
|
|
_logger.debug(
|
|
"debby bundle not found at %s; skipping seed",
|
|
_DEBBY_BUNDLE_SOURCE,
|
|
)
|
|
return
|
|
|
|
_ensure_builtin_agent(
|
|
agent_store,
|
|
artifact_store,
|
|
agent_cache,
|
|
name=_DEBBY_AGENT_NAME,
|
|
bundle_bytes=_build_debby_bundle(),
|
|
)
|
|
|
|
|
|
def _build_polly_bundle() -> bytes:
|
|
"""
|
|
Build a gzipped tarball of the ``examples/polly`` agent bundle.
|
|
|
|
polly is a multi-file image (``config.yaml`` plus ``agents/`` and
|
|
``skills/`` subdirectories), so the source is the directory itself
|
|
rather than a generated single YAML.
|
|
|
|
:returns: Gzipped tarball bytes suitable for the artifact store.
|
|
"""
|
|
import tempfile
|
|
|
|
from omnigent.spec import materialize_bundle
|
|
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
bundle_dir = materialize_bundle(_POLLY_BUNDLE_SOURCE, Path(tmpdir) / "bundle")
|
|
return _tar_gz_dir(bundle_dir)
|
|
|
|
|
|
def _ensure_default_polly_agent(
|
|
agent_store: AgentStore,
|
|
artifact_store: ArtifactStore,
|
|
agent_cache: Any,
|
|
) -> None:
|
|
"""
|
|
Register the polly orchestrator agent if its bundle ships here.
|
|
|
|
polly is the multi-agent coding orchestrator (successor to the
|
|
deleted nessie example); seeding it lets the Web UI's new-session
|
|
picker offer it as a host-launchable card next to Claude Code and
|
|
Codex. When the bundle is absent (generic deployment that didn't
|
|
package it), seeding is skipped so no card is offered for an agent
|
|
that can't be launched here — mirroring the ``_WEB_UI_DIST``
|
|
"asset present → enable feature" pattern. Content-aware via
|
|
:func:`_ensure_builtin_agent`: when a new wheel ships a changed
|
|
polly spec, the existing row is refreshed in place instead of
|
|
being ignored.
|
|
|
|
:param agent_store: Store for agent metadata.
|
|
:param artifact_store: Store for agent bundles.
|
|
:param agent_cache: Cache for loaded agent specs.
|
|
"""
|
|
if not (_POLLY_BUNDLE_SOURCE / "config.yaml").is_file():
|
|
_logger.debug(
|
|
"polly bundle not found at %s; skipping seed",
|
|
_POLLY_BUNDLE_SOURCE,
|
|
)
|
|
return
|
|
|
|
_ensure_builtin_agent(
|
|
agent_store,
|
|
artifact_store,
|
|
agent_cache,
|
|
name=_POLLY_AGENT_NAME,
|
|
bundle_bytes=_build_polly_bundle(),
|
|
)
|
|
|
|
|
|
def create_app(
|
|
agent_store: AgentStore,
|
|
file_store: FileStore,
|
|
conversation_store: ConversationStore,
|
|
artifact_store: ArtifactStore,
|
|
agent_cache: AgentCache,
|
|
runner_tunnel_tokens: frozenset[str] | None = None,
|
|
comment_store: CommentStore | None = None,
|
|
policy_store: PolicyStore | None = None,
|
|
permission_store: PermissionStore | None = None,
|
|
auth_provider: AuthProvider | None = None,
|
|
host_store: HostStore | None = None,
|
|
account_store: Any | None = None, # SqlAlchemyAccountStore — accounts mode only
|
|
extra_routers: list[tuple[Any, str, list[str]]] | None = None,
|
|
policy_modules: list[str] | None = None,
|
|
admins: list[str] | None = None,
|
|
allowed_domains: list[str] | None = None,
|
|
sandbox_config: ManagedSandboxConfig | None = None,
|
|
sharing_mode: SharingMode | Callable[[], SharingMode] | None = None,
|
|
public_sharing: bool | Callable[[], bool] | None = None,
|
|
) -> FastAPI:
|
|
"""
|
|
Build and return the FastAPI application with all routes mounted.
|
|
|
|
Stores and cache are injected here and passed to route factories.
|
|
Each dependency is forwarded to the router factories that need it;
|
|
the app itself only wires them together.
|
|
|
|
:param agent_store: Store for agent CRUD operations.
|
|
:param file_store: Store for uploaded-file metadata.
|
|
:param conversation_store: Store for conversation and
|
|
conversation-item persistence.
|
|
:param artifact_store: Store for binary blobs (agent bundles,
|
|
file content).
|
|
:param agent_cache: Cache for loaded agent specs and working
|
|
directories.
|
|
:param runner_tunnel_tokens: Optional allow-list of binding
|
|
tokens accepted by the runner WebSocket tunnel route, e.g.
|
|
``frozenset({"uA6Zz..."})``. ``None`` accepts any
|
|
token-bound runner id, which is the shared remote-server
|
|
behavior.
|
|
:param comment_store: Store for per-conversation review comments.
|
|
:param policy_store: Store for server-persisted policies
|
|
(session-scoped and server-wide defaults). ``None``
|
|
disables both the session policy and default policy
|
|
CRUD endpoints.
|
|
:param permission_store: Store for session-level access grants.
|
|
``None`` disables permission checks (all access allowed).
|
|
:param auth_provider: Pre-constructed auth provider for
|
|
identity resolution. ``None`` disables auth (anonymous
|
|
access). **Required** when ``permission_store`` is
|
|
provided — raises ``ValueError`` otherwise. Callers
|
|
construct the provider via ``create_auth_provider()``
|
|
or a custom implementation.
|
|
:param host_store: Store for host registrations. ``None``
|
|
disables host connectivity features (list hosts, launch
|
|
runners on remote hosts).
|
|
:param policy_modules: Additional dotted module paths to
|
|
scan for ``POLICY_REGISTRY`` lists at startup, e.g.
|
|
``["myorg.policies.safety"]``. Sourced from the server
|
|
config's ``policy_modules`` key. ``None`` scans only
|
|
the built-in modules.
|
|
:param admins: Admin identities from the server config's
|
|
``admins:`` key, e.g. ``["alice@example.com"]``. Union'd with
|
|
the runtime-editable ``<data_dir>/admins`` file; a matching
|
|
identity is promoted to admin on login.
|
|
:param allowed_domains: Allowed email domains from the server
|
|
config's ``allowed_domains:`` key (OIDC), e.g. ``["example.com"]``.
|
|
Union'd with ``OMNIGENT_OIDC_ALLOWED_DOMAINS`` and the
|
|
runtime-editable domains file.
|
|
:param sandbox_config: Parsed ``sandbox:`` section of the server
|
|
config — which provider to provision managed hosts
|
|
(``host_type="managed"`` sessions) from and the URL they dial
|
|
back to. ``None`` disables managed hosts (a
|
|
``host_type="managed"`` create fails with a clear error).
|
|
Managed-host credentials live on the ``hosts`` table, so no
|
|
extra store is wired.
|
|
:param sharing_mode: Server policy for creating new session
|
|
permission grants (see :class:`SharingMode`): ``ON`` allows
|
|
grants at any level plus public/workspace read, ``READ_ONLY``
|
|
caps grants at read (edit/manage rejected with 403),
|
|
``RESTRICTED_READ_ONLY`` additionally blocks sharing a session
|
|
whose working directory is a home or root directory, and ``OFF``
|
|
rejects all new grants (403). Only *new* grants are gated —
|
|
revoke/list, self-ownership grants, and existing grants are
|
|
unaffected in every mode. Accepts a static :class:`SharingMode`,
|
|
a zero-arg callable resolved per request (for deployments that
|
|
flip the policy at runtime), or ``None`` — which defaults from
|
|
the ``OMNIGENT_SHARING_MODE`` env var
|
|
(``on``/``read_only``/``restricted_read_only``/``off``), failing
|
|
open to ``ON`` when unset or unrecognized. Reported by
|
|
``GET /v1/info`` as ``sharing_mode`` so the web app can gate its
|
|
Share controls to match.
|
|
:param public_sharing: Whether public (anyone-with-the-link) read
|
|
access may be granted — i.e. whether the ``__public__`` grant is
|
|
allowed. Orthogonal to ``sharing_mode``: a server can keep normal
|
|
user-to-user sharing on while disabling public links. When
|
|
disabled, granting ``__public__`` is rejected (403) and the Share
|
|
modal hides the "Public access" toggle; existing public grants
|
|
are unaffected. Accepts a static bool, a zero-arg callable
|
|
resolved per request, or ``None`` — which defaults from the
|
|
``OMNIGENT_PUBLIC_SHARING`` env var (enabled unless explicitly
|
|
falsy — ``0``/``false``/``no``/``off``), failing open to enabled
|
|
when unset. Reported by ``GET /v1/info`` as
|
|
``public_sharing_enabled``.
|
|
:returns: A fully configured :class:`FastAPI` application.
|
|
:raises ValueError: If ``permission_store`` is provided
|
|
without an ``auth_provider``.
|
|
"""
|
|
if permission_store is not None and auth_provider is None:
|
|
raise ValueError("auth_provider is required when permission_store is provided")
|
|
|
|
# First-boot admin bootstrap for the accounts auth provider.
|
|
# Runs before any route is mounted so the login page is never
|
|
# served against an empty user table (avoids the Immich-style
|
|
# land-grab race — see designs/oss-cuj/01-research-summary.md
|
|
# §2.2.1). Guarded on (a) accounts source active, (b)
|
|
# auth_provider wired in, and (c) account_store passed in.
|
|
#
|
|
# account_store is an EXPLICIT parameter (not constructed in
|
|
# here) so the internal hosted product can opt out of accounts
|
|
# persistence entirely by passing None — even when it happens
|
|
# to deploy with the accounts code on disk. Without this gate
|
|
# the create_app factory would force every consumer to carry
|
|
# an AccountStore, defeating the whole "accounts is opt-in"
|
|
# framing.
|
|
_bootstrap_result = None # populated below for the lifespan hook
|
|
if auth_provider is not None and account_store is not None:
|
|
from omnigent.server.auth import UnifiedAuthProvider
|
|
|
|
if isinstance(auth_provider, UnifiedAuthProvider) and auth_provider._source == "accounts":
|
|
from omnigent.server.accounts_bootstrap import bootstrap_admin
|
|
|
|
_accounts_cfg = auth_provider._accounts_config
|
|
_bootstrap_result = bootstrap_admin(
|
|
account_store,
|
|
init_admin_password=_accounts_cfg.init_admin_password,
|
|
base_url=_accounts_cfg.base_url,
|
|
session_ttl_hours=_accounts_cfg.session_ttl_hours,
|
|
cookie_secret=_accounts_cfg.cookie_secret,
|
|
)
|
|
|
|
from omnigent.runner.routing import RunnerRouter
|
|
from omnigent.runner.transports.ws_tunnel.registry import TunnelRegistry
|
|
from omnigent.server.host_registry import HostRegistry, RunnerExitReports
|
|
|
|
tunnel_registry = TunnelRegistry()
|
|
runner_router = RunnerRouter(
|
|
registry=tunnel_registry,
|
|
conversation_store=conversation_store,
|
|
)
|
|
host_registry = HostRegistry()
|
|
# Shared between the host tunnel (which records ``host.runner_exited``
|
|
# reports from daemons) and the runner status endpoint (which surfaces
|
|
# them to clients waiting for a launched runner to connect).
|
|
runner_exit_reports = RunnerExitReports()
|
|
# AP-server-side MCP proxy pool. Manages connections to agents'
|
|
# external MCP servers on behalf of the
|
|
# ``POST /v1/sessions/{id}/mcp`` endpoint. Created here (before the
|
|
# lifespan and before the sessions router is registered) so the same
|
|
# object is closed by the lifespan and held by the router closure.
|
|
# ``ServerMcpPool.__init__`` is synchronous and safe to call outside
|
|
# a running event loop.
|
|
_mcp_pool = ServerMcpPool()
|
|
server_metrics = ServerPerformanceMetrics()
|
|
server_metrics_otel = ServerMetricsOtelPublisher()
|
|
|
|
@asynccontextmanager
|
|
async def _lifespan(
|
|
app_inst: FastAPI,
|
|
) -> AsyncIterator[None]:
|
|
"""FastAPI lifespan: start/stop the harness process manager
|
|
and tear down the tmux terminal registry on shutdown.
|
|
|
|
On startup: construct + start the
|
|
:class:`HarnessProcessManager` and stash it on
|
|
``app.state.harness_process_manager`` for workflow
|
|
dispatch to use when routing through the harness contract
|
|
(see ``designs/SERVER_HARNESS_CONTRACT.md`` §Process
|
|
management).
|
|
|
|
On shutdown: shut down the harness process manager (which
|
|
terminates every per-conversation runner subprocess and
|
|
cleans up the per-AP-instance dir) and close every live
|
|
tmux terminal in the :class:`TerminalRegistry`. Terminal
|
|
cleanup is best-effort with per-instance timeouts; see
|
|
``designs/OMNIGENT_TERMINAL_BRIDGE.md`` §4.4.
|
|
|
|
:param app_inst: The FastAPI app, used to attach
|
|
per-AP state via ``app_inst.state.*``.
|
|
"""
|
|
# Bump AnyIO default thread limiter from 40 → 200; every
|
|
# ``asyncio.to_thread`` and FastAPI sync route grabs one.
|
|
from anyio import to_thread as _to_thread
|
|
|
|
_to_thread.current_default_thread_limiter().total_tokens = 200
|
|
|
|
# Apply OMNIGENT_LOG_LEVEL to the omnigent namespace after
|
|
# uvicorn's dictConfig runs (dictConfig resets existing handlers,
|
|
# making a pre-run basicConfig call ineffective).
|
|
import os as _os
|
|
|
|
_log_level_name = _os.environ.get("OMNIGENT_LOG_LEVEL", "INFO").upper()
|
|
logging.getLogger("omnigent").setLevel(getattr(logging, _log_level_name, logging.INFO))
|
|
|
|
harness_pm = HarnessProcessManager()
|
|
await harness_pm.start()
|
|
# Store on both ``app.state`` (canonical, accessible from
|
|
# routes) AND a runtime-module global (workflows access it
|
|
# via ``get_harness_process_manager()`` because workflows
|
|
# can't easily receive non-serializable args).
|
|
app_inst.state.harness_process_manager = harness_pm
|
|
set_harness_process_manager(harness_pm)
|
|
|
|
set_runner_router(runner_router)
|
|
|
|
# Wake a blocked sub-agent's immediate parent: hooks
|
|
# ``pending_elicitations.record_publish`` to post a ``[System: …]``
|
|
# notice to the parent's ``/events``. Uninstalled at teardown so a
|
|
# fresh app instance doesn't inherit a prior run's observer (matters
|
|
# for multi-app test setups).
|
|
from omnigent.server.routes.sessions import (
|
|
configure_subagent_block_notifier,
|
|
)
|
|
|
|
_uninstall_subagent_block_notifier = configure_subagent_block_notifier(
|
|
conversation_store,
|
|
runner_router,
|
|
)
|
|
|
|
from omnigent.runner.resource_registry import (
|
|
SessionResourceRegistry,
|
|
)
|
|
from omnigent.runtime import set_resource_registry
|
|
|
|
resource_reg = SessionResourceRegistry(
|
|
terminal_registry=get_terminal_registry(),
|
|
)
|
|
set_resource_registry(resource_reg)
|
|
|
|
# Install the tunnel-backed WS factory so browser terminal
|
|
# attach can proxy frames over the same persistent WebSocket
|
|
# the runner already uses for HTTP.
|
|
from omnigent.server._runner_ws_tunnel import make_tunnel_ws_factory
|
|
|
|
set_runner_ws_factory(make_tunnel_ws_factory(runner_router, tunnel_registry))
|
|
|
|
# MCP execution moved to the runner (designs/RUNNER_MCP.md);
|
|
# SessionFilesystemRegistry moved to the runner. Both
|
|
# warmup blocks deleted here.
|
|
|
|
_ensure_default_agents(agent_store, artifact_store, agent_cache)
|
|
|
|
# Populate the policy registry (builtins + user-configured
|
|
# modules) so GET /v1/policy-registry serves the catalog.
|
|
from omnigent.policies.registry import load_registry
|
|
|
|
load_registry(extra_modules=policy_modules)
|
|
|
|
# Accounts first-run: open the browser after uvicorn has bound
|
|
# the port. bootstrap_admin sets open_url to the loopback base
|
|
# URL on a needs-setup boot so the browser lands on the
|
|
# Create-admin form. Gated on (a) bootstrap asked for an open,
|
|
# and (b) the auto-open env var is truthy (default ON; CLI
|
|
# passes OMNIGENT_ACCOUNTS_AUTO_OPEN=0 for --no-open). Broad
|
|
# try so a missing display / browser never blocks startup.
|
|
if _bootstrap_result is not None and _bootstrap_result.open_url:
|
|
from omnigent.server.auth import env_var_is_truthy
|
|
|
|
if env_var_is_truthy("OMNIGENT_ACCOUNTS_AUTO_OPEN", default=True):
|
|
import webbrowser
|
|
|
|
try:
|
|
webbrowser.open(_bootstrap_result.open_url)
|
|
except Exception as exc: # noqa: BLE001
|
|
_logger.warning(
|
|
"accounts: auto-open browser failed (%s) — open the "
|
|
"server URL in a browser instead",
|
|
exc,
|
|
)
|
|
|
|
metrics_publish_task = asyncio.create_task(
|
|
publish_server_metrics_periodically(
|
|
server_metrics,
|
|
otel_publisher=server_metrics_otel,
|
|
)
|
|
)
|
|
try:
|
|
yield
|
|
finally:
|
|
metrics_publish_task.cancel()
|
|
with suppress(asyncio.CancelledError):
|
|
await metrics_publish_task
|
|
# Stop in-flight background managed-sandbox launches so a
|
|
# slow provision doesn't outlive the ASGI shutdown (the
|
|
# sandbox itself, if already provisioned, is reaped by the
|
|
# provider lifetime cap — see the hook's docstring).
|
|
from omnigent.server.routes.sessions import cancel_managed_launch_tasks
|
|
|
|
await cancel_managed_launch_tasks()
|
|
_uninstall_subagent_block_notifier()
|
|
set_resource_registry(None)
|
|
set_runner_ws_factory(None)
|
|
set_runner_router(None)
|
|
await runner_router.aclose()
|
|
|
|
set_harness_process_manager(None)
|
|
await harness_pm.shutdown()
|
|
await get_terminal_registry().shutdown()
|
|
# Shut down all AP-side MCP connections opened by the proxy
|
|
# endpoint. Best-effort — individual close failures are logged
|
|
# inside shutdown_all().
|
|
await _mcp_pool.shutdown_all()
|
|
|
|
app = FastAPI(title="Omnigent Server", lifespan=_lifespan)
|
|
from omnigent.runtime import telemetry
|
|
|
|
telemetry.instrument_fastapi_app(app)
|
|
# Expose the registry on app.state so integration tests and
|
|
# diagnostics can verify that the production app wires the route
|
|
# and WSTunnelTransport to the same session registry.
|
|
app.state.tunnel_registry = tunnel_registry
|
|
app.state.runner_router = runner_router
|
|
app.state.host_registry = host_registry
|
|
app.state.host_store = host_store
|
|
app.state.sandbox_config = sandbox_config
|
|
# Admin roster: the config ``admins:`` list (canonical) union'd with the
|
|
# runtime-editable ``<data_dir>/admins`` file. Built once here so BOTH the
|
|
# admin-gated auth routes AND ``/v1/me``'s is_admin computation consult the
|
|
# same source — otherwise an identity listed in the file but not yet
|
|
# promoted (``promote_if_listed`` runs at login) would be authorized by the
|
|
# routes yet see no admin chrome. The file portion lazily reloads on mtime
|
|
# change (no restart).
|
|
from omnigent.server.admin_list import load_admin_list
|
|
|
|
admin_list = load_admin_list(extra=frozenset(admins or ()))
|
|
# Session-sharing policy, normalized to a per-request callable, plus a
|
|
# ``sharing_mode_writable`` flag gating the admin ``PUT /v1/sharing``
|
|
# endpoint.
|
|
#
|
|
# ``None`` (the OSS default): ``OMNIGENT_SHARING_MODE`` sets the boot
|
|
# default, but an admin-set override file (``<data_dir>/sharing_mode``,
|
|
# written from Settings → Sharing) takes precedence when present — read per
|
|
# request so a change applies without a restart. Editable here.
|
|
#
|
|
# A static value or a callable (managed/embedded deploys, e.g. a Databricks
|
|
# SAFE flag) is authoritative and NOT editable via the admin endpoint.
|
|
if sharing_mode is None:
|
|
from omnigent.server.sharing_settings import read_sharing_mode_override
|
|
|
|
_sharing_env_default = SharingMode.coerce(os.environ.get("OMNIGENT_SHARING_MODE"))
|
|
|
|
def _resolve_sharing_mode() -> SharingMode:
|
|
override = read_sharing_mode_override()
|
|
return override if override is not None else _sharing_env_default
|
|
|
|
app.state.sharing_mode = _resolve_sharing_mode
|
|
app.state.sharing_mode_writable = True
|
|
elif callable(sharing_mode):
|
|
_sharing_callable = sharing_mode
|
|
app.state.sharing_mode = lambda: SharingMode.coerce(_sharing_callable())
|
|
app.state.sharing_mode_writable = False
|
|
else:
|
|
_sharing_static = SharingMode.coerce(sharing_mode)
|
|
app.state.sharing_mode = lambda: _sharing_static
|
|
app.state.sharing_mode_writable = False
|
|
# Public (anyone-with-the-link) access policy, same shape as sharing_mode
|
|
# above and independent of it. ``None`` reads ``OMNIGENT_PUBLIC_SHARING``
|
|
# (default enabled) with a ``<data_dir>/public_sharing`` file override,
|
|
# editable from the admin panel; a static bool or callable is authoritative
|
|
# and not editable there.
|
|
if public_sharing is None:
|
|
from omnigent.server.sharing_settings import (
|
|
public_sharing_env_default,
|
|
read_public_sharing_override,
|
|
)
|
|
|
|
_public_env_default = public_sharing_env_default()
|
|
|
|
def _resolve_public_sharing() -> bool:
|
|
override = read_public_sharing_override()
|
|
return override if override is not None else _public_env_default
|
|
|
|
app.state.public_sharing = _resolve_public_sharing
|
|
app.state.public_sharing_writable = True
|
|
elif callable(public_sharing):
|
|
_public_callable = public_sharing
|
|
app.state.public_sharing = lambda: bool(_public_callable())
|
|
app.state.public_sharing_writable = False
|
|
else:
|
|
_public_static = bool(public_sharing)
|
|
app.state.public_sharing = lambda: _public_static
|
|
app.state.public_sharing_writable = False
|
|
# Tracks in-flight background managed-host launches (POST
|
|
# /v1/sessions returns before the sandbox exists) so a message
|
|
# racing the provision can rendezvous instead of failing with
|
|
# "no runner bound". Always wired — cheap, and post_event probes
|
|
# it regardless of whether managed hosts are configured.
|
|
from omnigent.server.managed_hosts import ManagedLaunchTracker
|
|
|
|
app.state.managed_launches = ManagedLaunchTracker()
|
|
app.state.server_metrics = server_metrics
|
|
app.state.server_metrics_otel = server_metrics_otel
|
|
app.add_middleware(_WebSocketMetricsMiddleware, metrics=server_metrics)
|
|
# CSWSH guard: reject cross-origin WebSocket handshakes before any
|
|
# route accepts them. Added after the metrics middleware so it is the
|
|
# outermost WS middleware — a forbidden origin is closed without even
|
|
# reaching the metrics counter (which only counts on accept anyway).
|
|
app.add_middleware(WebSocketOriginMiddleware)
|
|
# Give the tool-policy ASK gate (which forwards the native-terminal
|
|
# approval popup from a parked-gate background task, off any
|
|
# request/route closure) the runner router so it can reach the bound
|
|
# runner.
|
|
set_server_runner_router(runner_router)
|
|
|
|
@app.middleware("http")
|
|
async def _record_server_metrics(
|
|
request: Request,
|
|
call_next: _FastAPICallNext,
|
|
) -> Response:
|
|
"""
|
|
Count each HTTP request and enrich access logs.
|
|
|
|
Generates a per-request correlation ID, captures the
|
|
``User-Agent`` header and session ID from the URL path, and
|
|
stores them in context variables for the Uvicorn access
|
|
formatter.
|
|
|
|
:param request: Incoming FastAPI request, e.g. ``GET /health``.
|
|
:param call_next: FastAPI middleware continuation that executes
|
|
the matched route and returns its response.
|
|
:returns: The downstream route response.
|
|
"""
|
|
request_id = uuid.uuid4().hex
|
|
set_request_id_for_access_log(request_id)
|
|
set_request_user_agent_for_access_log(
|
|
request.headers.get("user-agent"),
|
|
)
|
|
session_match = _SESSION_PATH_RE.search(request.url.path)
|
|
set_request_session_id_for_access_log(
|
|
session_match.group(1) if session_match else None,
|
|
)
|
|
|
|
failed = False
|
|
status_code: int | None = None
|
|
started_at = server_metrics.request_started()
|
|
try:
|
|
response = await call_next(request)
|
|
status_code = response.status_code
|
|
response.headers["X-Request-Id"] = request_id
|
|
return response
|
|
except Exception:
|
|
failed = True
|
|
raise
|
|
finally:
|
|
request_failed = failed or (status_code is not None and status_code >= 500)
|
|
duration_seconds = server_metrics.request_finished(
|
|
started_at=started_at,
|
|
failed=request_failed,
|
|
)
|
|
set_request_duration_for_access_log(duration_seconds)
|
|
route = request_route_template_for_metrics(request)
|
|
metrics_status_code = _request_status_code_for_metrics(
|
|
status_code,
|
|
failed=failed,
|
|
)
|
|
server_metrics_otel.record_request_duration(
|
|
duration_seconds=duration_seconds,
|
|
failed=request_failed,
|
|
method=request.method,
|
|
route=route,
|
|
status_code=metrics_status_code,
|
|
)
|
|
|
|
@app.exception_handler(OmnigentError)
|
|
async def _handle_omnigent_error(
|
|
request: Request,
|
|
exc: OmnigentError,
|
|
) -> JSONResponse:
|
|
"""
|
|
Convert application errors to structured JSON responses.
|
|
|
|
:param request: The incoming request (unused — FastAPI signature requirement).
|
|
:param exc: The application error.
|
|
:returns: A JSON response with the error code and message.
|
|
"""
|
|
if exc.http_status >= 500:
|
|
_logger.error("Internal error: %s", exc.message, exc_info=True)
|
|
elif exc.http_status == 400 and request.url.path.endswith("/policies/evaluate"):
|
|
_logger.warning(
|
|
"Policy evaluate rejected 400 on %s: %s", request.url.path, exc.message
|
|
)
|
|
return JSONResponse(
|
|
status_code=exc.http_status,
|
|
content={"error": {"code": exc.code, "message": exc.message}},
|
|
)
|
|
|
|
@app.exception_handler(Exception)
|
|
async def _handle_unhandled_exception(
|
|
request: Request, # noqa: ARG001 — FastAPI exception-handler signature requires (request, exc); we only use exc
|
|
exc: Exception,
|
|
) -> JSONResponse:
|
|
"""
|
|
Catch-all for unhandled exceptions (e.g. database
|
|
OperationalError). Returns the standard JSON error schema
|
|
so clients always get a consistent response format.
|
|
|
|
:param request: The incoming request (unused — FastAPI signature requirement).
|
|
:param exc: The unhandled exception.
|
|
:returns: A 500 JSON response with ``internal_error`` code.
|
|
"""
|
|
_logger.error("Unhandled exception: %s", exc, exc_info=True)
|
|
return JSONResponse(
|
|
status_code=500,
|
|
content={
|
|
"error": {
|
|
"code": ErrorCode.INTERNAL_ERROR,
|
|
"message": "An internal error occurred.",
|
|
},
|
|
},
|
|
)
|
|
|
|
def _host_is_online(host_id: str) -> bool:
|
|
"""
|
|
Return whether ``host_id`` is currently online, cross-replica.
|
|
|
|
Reads from the ``hosts`` DB table (the cross-replica source of
|
|
truth, written by the tunnel endpoint on connect/disconnect)
|
|
rather than the per-replica :class:`HostRegistry`. If the host
|
|
is connected to replica B and the request lands on replica A,
|
|
A's registry won't know about it — the DB will. Mirrors the
|
|
same change made in ``routes/hosts.py`` for ``GET /v1/hosts``.
|
|
|
|
When ``host_store`` was not supplied (host support not wired),
|
|
fall back to the local registry: in that configuration no row
|
|
is ever written so the DB would return ``None``.
|
|
|
|
A persisted ``"online"`` status is not trusted on its own: a
|
|
host that crashed without a graceful disconnect never runs
|
|
``set_offline`` and stays ``"online"`` forever. ``is_online``
|
|
also requires the host's last-seen to be fresh
|
|
(``HOST_LIVENESS_TTL_S``), so a stale row reads as offline.
|
|
|
|
:param host_id: Host identifier from the session row.
|
|
:returns: ``True`` when the host is online and was seen
|
|
recently.
|
|
"""
|
|
if host_store is None:
|
|
return host_registry.get(host_id) is not None
|
|
return host_store.is_online(host_id)
|
|
|
|
def _bulk_hosts_online(host_ids: list[str]) -> set[str]:
|
|
"""
|
|
Return the subset of ``host_ids`` that are currently online.
|
|
|
|
Bulk variant of :func:`_host_is_online` for the sidebar
|
|
online-dot batch path: one ``SELECT ... IN (...)`` over the
|
|
hosts table instead of a per-host query. Same cross-replica
|
|
DB-backed liveness gate; falls back to the per-replica
|
|
registry when ``host_store`` is not wired.
|
|
|
|
:param host_ids: Host identifiers to check, e.g.
|
|
``["host_abc123", "host_def456"]``. Empty input returns
|
|
an empty set.
|
|
:returns: The set of ids whose host is online and fresh.
|
|
"""
|
|
if not host_ids:
|
|
return set()
|
|
if host_store is None:
|
|
return {h for h in host_ids if host_registry.get(h) is not None}
|
|
return host_store.online_host_ids(host_ids)
|
|
|
|
def _bulk_host_versions(host_ids: list[str]) -> dict[str, str]:
|
|
"""
|
|
Map each requested host_id to the version from its live hello frame.
|
|
|
|
Resolved from the in-memory host registry only: the host version
|
|
isn't persisted to the hosts table, so a host connected to another
|
|
replica (multi-replica ``host_store`` deploys) is absent here and
|
|
the caller reports ``host_version=None`` for that session — the info
|
|
popover then simply omits the host version. Single-server /
|
|
single-replica deploys (the common case) resolve it fully. The
|
|
registry lookup is an in-memory dict read, so this stays off the
|
|
DB hot path the surrounding bulk liveness query optimizes.
|
|
|
|
:param host_ids: Bound host identifiers to resolve, e.g.
|
|
``["host_abc123"]``. Empty input returns an empty map.
|
|
:returns: ``{host_id: version}`` for every id with a live local
|
|
tunnel; ids without one are absent.
|
|
"""
|
|
versions: dict[str, str] = {}
|
|
for host_id in set(host_ids):
|
|
host_conn = host_registry.get(host_id)
|
|
if host_conn is not None:
|
|
versions[host_id] = host_conn.hello.version
|
|
return versions
|
|
|
|
def _session_liveness(sid: str) -> SessionLiveness:
|
|
"""
|
|
Resolve strict runner + host liveness for one session.
|
|
|
|
Single-id wrapper around :func:`_bulk_session_liveness`. See
|
|
that function for the full liveness semantics.
|
|
|
|
:param sid: Session/conversation id, e.g. ``"conv_abc123"``.
|
|
:returns: The :class:`SessionLiveness` pair for ``sid``. An id
|
|
with no conversation row resolves to
|
|
``runner_online=True`` (no runner ⇒ reachable) and
|
|
``host_online=None`` (no host binding).
|
|
"""
|
|
return _bulk_session_liveness([sid]).get(
|
|
sid, SessionLiveness(runner_online=True, host_online=None)
|
|
)
|
|
|
|
def _bulk_session_liveness(ids: list[str]) -> dict[str, SessionLiveness]:
|
|
"""
|
|
Bulk strict-liveness check with a fixed, small number of SQL
|
|
queries.
|
|
|
|
Resolves every session's connectivity in two batch reads —
|
|
one over the conversations table (runner/host binding, via
|
|
:meth:`ConversationStore.get_session_connectivity`) and one
|
|
over the hosts table (:meth:`HostStore.online_host_ids`) —
|
|
rather than the per-session ``get_conversation`` + per-host
|
|
``is_online`` fan-out the sidebar poll used to drive. That
|
|
fan-out was ``O(n)`` synchronous Lakebase round-trips per
|
|
``/health`` call (~3 per offline session); for the sidebar's
|
|
20-session batch it serialized ~60 queries on the request,
|
|
and because ``/health`` ran them on the event loop it blocked
|
|
every other in-flight request. See :func:`_session_liveness`
|
|
for the single-id wrapper.
|
|
|
|
``runner_online`` is **strict**: ``True`` iff a runner tunnel
|
|
is currently registered for the session
|
|
(:func:`_runner_up`). It deliberately does **not** fold in
|
|
host-relaunch optimism — a dead runner on a live host reads
|
|
``runner_online=False`` here, paired with ``host_online=True``
|
|
so the open-session view can offer "send a message to wake
|
|
the runner" without misreporting reachability. ``host_online``
|
|
is populated from the same online-hosts batch: ``True`` when
|
|
the session's ``host_id`` is online and fresh, ``False`` when
|
|
a ``host_id`` is set but offline/stale, and ``None`` when the
|
|
session has no ``host_id`` (CLI / local). Liveness is purely
|
|
"is the tunnel up / is the host fresh" — there is no longer a
|
|
deliberate-stop marker that forces a session offline (Stop is
|
|
non-sticky: it drops the runner tunnel, which is reflected here
|
|
as ``runner_online=False``, and the next message relaunches on
|
|
a live host).
|
|
|
|
:param ids: Session/conversation ids to check, e.g.
|
|
``["conv_abc123", "conv_def456"]``.
|
|
:returns: Mapping ``session_id -> SessionLiveness``. Ids with
|
|
no conversation row default to
|
|
``SessionLiveness(runner_online=True, host_online=None)``
|
|
(mirrors the legacy single-session path, which treated a
|
|
missing row as reachable).
|
|
"""
|
|
connectivity = conversation_store.get_session_connectivity(ids)
|
|
|
|
def _runner_up(conn: SessionConnectivity) -> bool:
|
|
"""A bound runner whose tunnel is currently registered."""
|
|
return conn.runner_id is not None and tunnel_registry.get(conn.runner_id) is not None
|
|
|
|
# Resolve host liveness for every bound host in one query, so
|
|
# ``host_online`` can be reported even when the runner tunnel is
|
|
# up (the open-session view shows host state regardless).
|
|
host_ids_to_check = {
|
|
conn.host_id for conn in connectivity.values() if conn.host_id is not None
|
|
}
|
|
online_hosts = _bulk_hosts_online(list(host_ids_to_check))
|
|
host_versions = _bulk_host_versions(list(host_ids_to_check))
|
|
result: dict[str, SessionLiveness] = {}
|
|
for sid in ids:
|
|
conn = connectivity.get(sid)
|
|
if conn is None:
|
|
# No conversation row — treat as reachable with no host
|
|
# binding, matching the legacy single-session behavior.
|
|
result[sid] = SessionLiveness(runner_online=True, host_online=None)
|
|
continue
|
|
if conn.host_id is None:
|
|
host_online: bool | None = None
|
|
host_version: str | None = None
|
|
else:
|
|
host_online = conn.host_id in online_hosts
|
|
host_version = host_versions.get(conn.host_id)
|
|
if conn.runner_id is None:
|
|
# No runner binding: an in-process executor (or a session
|
|
# not yet dispatched) is reachable — EXCEPT an unbound fork
|
|
# of a session that had a working directory, which must
|
|
# rebind a host + directory first. Reporting it offline
|
|
# routes the first message into the directory picker instead
|
|
# of dropping it against a runner that can't start.
|
|
runner_online = not conn.needs_workspace
|
|
else:
|
|
# Strict: reachable only if the runner tunnel is up. No
|
|
# host-relaunch optimism — host state lives in host_online.
|
|
runner_online = _runner_up(conn)
|
|
result[sid] = SessionLiveness(
|
|
runner_online=runner_online,
|
|
host_online=host_online,
|
|
host_version=host_version,
|
|
)
|
|
return result
|
|
|
|
@app.get("/health")
|
|
async def health(
|
|
session_id: str | None = Query(default=None),
|
|
session_ids: str | None = Query(default=None),
|
|
) -> dict[str, Any]:
|
|
"""
|
|
Liveness check with optional session-scoped runner status.
|
|
|
|
Without session params, returns ``{"status": "ok"}`` (bare
|
|
liveness). With ``session_id``, adds a single ``session``
|
|
object. With ``session_ids`` (comma-separated), adds a
|
|
``sessions`` dict keyed by id — used by the sidebar to
|
|
batch-check all visible sessions in one request. The batch
|
|
path runs a single SQL ``IN`` query, not N per-id round-trips.
|
|
|
|
Each per-session object carries both ``runner_online`` (strict
|
|
runner reachability) and ``host_online`` (host tunnel live, or
|
|
``None`` when the session has no host binding) — see
|
|
:class:`~omnigent.server.routes.sessions.SessionLiveness`.
|
|
|
|
:param session_id: Optional single session id, e.g.
|
|
``"conv_abc123"``.
|
|
:param session_ids: Optional comma-separated session ids
|
|
for batch lookup, e.g.
|
|
``"conv_abc,conv_def,conv_ghi"``.
|
|
:returns: ``{"status": "ok"}`` with optional ``session``
|
|
and/or ``sessions`` fields. Each session object has shape
|
|
``{"runner_online": bool, "host_online": bool | None,
|
|
"host_version": str | None}`` (the single ``session``
|
|
object also includes its ``id``). ``host_version`` is the
|
|
bound host's reported version, or ``None`` when there's no
|
|
host binding / the version isn't resolvable on this replica.
|
|
"""
|
|
result: dict[str, Any] = {"status": "ok"}
|
|
batch_ids = [s.strip() for s in session_ids.split(",") if s.strip()] if session_ids else []
|
|
# Resolve every requested id (single + batch) in ONE lookup. The
|
|
# online-dot lookups hit the database (conversations + hosts
|
|
# tables) and MUST run in a worker thread, not inline on the
|
|
# event loop: the sidebar polls this endpoint every 1-2s with
|
|
# every visible session id, and a single-worker uvicorn deploy
|
|
# shares one event loop across all requests. Running the blocking
|
|
# psycopg calls inline serialized every concurrent request behind
|
|
# them (a trivial conversation load queued for seconds). See
|
|
# ``_bulk_session_liveness`` for the query-count reduction.
|
|
all_ids = ([session_id] if session_id is not None else []) + batch_ids
|
|
liveness = await asyncio.to_thread(_bulk_session_liveness, all_ids) if all_ids else {}
|
|
# Missing ids default to reachable / no-host, matching the bulk
|
|
# lookup's own missing-row terminal.
|
|
_missing = SessionLiveness(runner_online=True, host_online=None)
|
|
if session_id is not None:
|
|
single = liveness.get(session_id, _missing)
|
|
result["session"] = {
|
|
"id": session_id,
|
|
"runner_online": single.runner_online,
|
|
"host_online": single.host_online,
|
|
"host_version": single.host_version,
|
|
}
|
|
if session_ids is not None:
|
|
result["sessions"] = {
|
|
sid: {
|
|
"runner_online": (sl := liveness.get(sid, _missing)).runner_online,
|
|
"host_online": sl.host_online,
|
|
"host_version": sl.host_version,
|
|
}
|
|
for sid in batch_ids
|
|
}
|
|
return result
|
|
|
|
@app.get("/api/version")
|
|
async def version() -> dict[str, str]:
|
|
"""
|
|
Return the installed omnigent package version.
|
|
|
|
Used by the web UI to include version info in bug reports.
|
|
|
|
:returns: ``{"version": "<semver string>"}``,
|
|
e.g. ``{"version": "0.1.0"}``.
|
|
"""
|
|
return {"version": _server_version()}
|
|
|
|
@app.get("/v1/info")
|
|
async def info() -> dict[str, bool | str | None]:
|
|
"""Runtime capabilities probe for the SPA + CLI.
|
|
|
|
Returned at app boot by the frontend (and by ``omnigent
|
|
login`` when it needs to choose between flows). Drives
|
|
conditional route registration and chrome on the SPA side
|
|
— when ``accounts_enabled`` is false, the SPA never
|
|
registers ``/login``, ``/register``, ``/members`` and
|
|
never renders the AccountMenu, so the bundle behaves
|
|
identically to a pre-PR-2008 build for header / OIDC
|
|
deploys (in particular, the internal hosted product that
|
|
syncs from this repo).
|
|
|
|
Authentication: this endpoint is intentionally UNAUTHED
|
|
so the SPA can probe it before holding a session cookie.
|
|
It exposes no sensitive state — only the active auth
|
|
source, the login URL, whether first-run admin setup is
|
|
still pending (``needs_setup``), coarse capability
|
|
booleans (``databricks_features``,
|
|
``managed_sandboxes_enabled``), the short sandbox
|
|
provider name (``sandbox_provider``) the web UI labels the
|
|
new-session sandbox option with, and the installed
|
|
``server_version`` (already public via ``/api/version``).
|
|
"""
|
|
from omnigent.server.auth import UnifiedAuthProvider
|
|
|
|
accounts_enabled = (
|
|
isinstance(auth_provider, UnifiedAuthProvider) and auth_provider._source == "accounts"
|
|
)
|
|
login_url = getattr(auth_provider, "login_url", None)
|
|
# needs_setup drives the SPA's first-run "Create admin" form:
|
|
# true only in accounts mode while no password-having account
|
|
# exists yet. Same predicate bootstrap_admin uses, computed
|
|
# live so it flips to false the instant /auth/setup (or any
|
|
# login) creates the first admin. Exposing it is safe — it's a
|
|
# boolean about whether setup is pending, not a secret.
|
|
needs_setup = False
|
|
if accounts_enabled and account_store is not None:
|
|
needs_setup = not any(u.has_password for u in account_store.list_users())
|
|
# databricks_features gates the Databricks-deployment-only UI hints
|
|
# (the "Databricks Lakebox" connect tab). True only when the internal
|
|
# lakebox launcher module is present — it is excluded from the OSS
|
|
# export, so an OSS build reports False and the SPA shows the clean,
|
|
# provider-agnostic hints. find_spec is side-effect-free (no import).
|
|
import importlib.util
|
|
|
|
databricks_features = (
|
|
importlib.util.find_spec("omnigent.onboarding.sandboxes.lakebox") is not None
|
|
)
|
|
# managed_sandboxes_enabled gates the web UI's sandbox
|
|
# option on the new-session screen: true only when a `sandbox:`
|
|
# config is wired AND its provider can actually serve a managed
|
|
# launch (staged providers parse but reject at launch — they
|
|
# must not advertise the option).
|
|
managed_sandboxes_enabled = (
|
|
sandbox_config is not None and sandbox_config.managed_launch_supported
|
|
)
|
|
# sandbox_provider names the backing provider (e.g. "modal",
|
|
# "islo") so the web UI can label the option per provider
|
|
# ("Modal Sandbox" / "Islo Sandbox") instead of the
|
|
# generic "New Sandbox". Only surfaced when the option is
|
|
# actually offered; None when no provider is named (embedding
|
|
# configs may leave it unset) so the UI keeps the generic label.
|
|
sandbox_provider = sandbox_config.provider if managed_sandboxes_enabled else None
|
|
# sharing_mode is the server's session-sharing policy
|
|
# (on/read_only/off), surfaced so the web app can hide the Share
|
|
# control (off) or restrict it to read-only (read_only) in lockstep
|
|
# with the server-side grant gate.
|
|
sharing_mode = app.state.sharing_mode()
|
|
# public_sharing_enabled: whether the __public__ (anyone-with-the-link)
|
|
# grant is allowed. Independent of sharing_mode — drives whether the
|
|
# Share modal shows the "Public access" toggle.
|
|
public_sharing_enabled = app.state.public_sharing()
|
|
# server_version is the installed omnigent package version (same
|
|
# source as /api/version), surfaced so the web UI can show it in the
|
|
# session info popover alongside the per-session host version.
|
|
# smart_routing_enabled: true when the server can route — either
|
|
# a RoutingClient is explicitly configured (OMNIGENT_SMART_ROUTING=1
|
|
# + llm: config) or the managed deployment registered a
|
|
# policy_llm_connection_factory (which means it has LLM capability
|
|
# and will supply its own RoutingClient).
|
|
try:
|
|
from omnigent.runtime._globals import _caps
|
|
|
|
smart_routing_enabled = _caps is not None and (
|
|
_caps.routing_client is not None or _caps.policy_llm_connection_factory is not None
|
|
)
|
|
except ImportError:
|
|
smart_routing_enabled = False
|
|
return {
|
|
"accounts_enabled": accounts_enabled,
|
|
"login_url": login_url,
|
|
"needs_setup": needs_setup,
|
|
"databricks_features": databricks_features,
|
|
"managed_sandboxes_enabled": managed_sandboxes_enabled,
|
|
"sandbox_provider": sandbox_provider,
|
|
"sharing_mode": sharing_mode.value,
|
|
"public_sharing_enabled": public_sharing_enabled,
|
|
"server_version": _server_version(),
|
|
"smart_routing_enabled": smart_routing_enabled,
|
|
}
|
|
|
|
@app.get("/v1/me", response_model=None) # Union return type (dict | JSONResponse)
|
|
async def me(request: Request) -> dict[str, str | bool | None] | JSONResponse:
|
|
"""Return the current user's identity.
|
|
|
|
Reads the user from the auth provider (same logic that
|
|
session routes use). The frontend calls this on load to
|
|
discover who it is.
|
|
|
|
Also returns ``is_admin`` — the mode-agnostic admin signal
|
|
(the shared ``users.is_admin`` column, set by the admin-list
|
|
promotion at login). The SPA gates admin chrome on it in
|
|
EVERY mode, including OIDC/SSO where the accounts-only
|
|
``/auth/me`` endpoint does not exist.
|
|
|
|
When OIDC is active and the user is unauthenticated,
|
|
returns 401 with a ``login_url`` so the frontend knows
|
|
where to redirect.
|
|
|
|
:param request: The incoming FastAPI request.
|
|
:returns: ``{"user_id": "alice@example.com", "is_admin": true}``,
|
|
``{"user_id": null, "is_admin": false}`` if unauthenticated
|
|
in header mode, or 401 with ``login_url`` in OIDC mode.
|
|
"""
|
|
user_id: str | None = None
|
|
if auth_provider is not None:
|
|
user_id = auth_provider.get_user_id(request)
|
|
login_url = getattr(auth_provider, "login_url", None)
|
|
if user_id is None and login_url is not None:
|
|
return JSONResponse(
|
|
status_code=401,
|
|
content={"user_id": None, "login_url": login_url},
|
|
)
|
|
# Mirror the admin check the auth routes use
|
|
# (``permission_store.is_admin(caller) or admin_list.is_admin(caller)``)
|
|
# so the SPA's admin chrome never under-reports relative to what the
|
|
# endpoints actually authorize — e.g. for an identity added to the
|
|
# admin-list file who hasn't re-logged-in yet (so ``promote_if_listed``
|
|
# hasn't flipped the DB flag).
|
|
is_admin = user_id is not None and (
|
|
(permission_store is not None and permission_store.is_admin(user_id))
|
|
or admin_list.is_admin(user_id)
|
|
)
|
|
return {"user_id": user_id, "is_admin": is_admin}
|
|
|
|
app.include_router(
|
|
create_sessions_router(
|
|
conversation_store,
|
|
agent_store,
|
|
file_store=file_store,
|
|
artifact_store=artifact_store,
|
|
runner_router=runner_router,
|
|
auth_provider=auth_provider,
|
|
permission_store=permission_store,
|
|
agent_cache=agent_cache,
|
|
mcp_pool=_mcp_pool,
|
|
# Lets WS /v1/sessions/updates fold runner + host liveness into
|
|
# its pushes so the web app can drop its GET /health poll.
|
|
liveness_lookup=_bulk_session_liveness,
|
|
# Lets GET /sessions and WS /sessions/updates carry the
|
|
# per-session comments fingerprint so the web app refreshes
|
|
# its comment list on external mutations.
|
|
comment_store=comment_store,
|
|
# Same allow-list the tunnel router gets: authorizes runner
|
|
# writes to the policy-owned cost_control.* session labels.
|
|
runner_tunnel_tokens=runner_tunnel_tokens,
|
|
# Lets the session snapshot surface a crashed runner's cause
|
|
# (host.runner_exited) as last_task_error so a reload still
|
|
# renders the error banner after the live push is gone.
|
|
runner_exit_reports=runner_exit_reports,
|
|
),
|
|
prefix="/v1",
|
|
tags=["sessions"],
|
|
)
|
|
# Read-only built-in agent discovery (designs/BUILTIN_AGENTS.md).
|
|
# Successor to the removed GET /api/agents list; lists only
|
|
# built-in (session_id IS NULL) agents for the new-session picker.
|
|
app.include_router(
|
|
create_builtin_agents_router(
|
|
agent_store,
|
|
agent_cache,
|
|
auth_provider=auth_provider,
|
|
),
|
|
prefix="/v1",
|
|
tags=["agents"],
|
|
)
|
|
app.include_router(
|
|
create_harnesses_router(auth_provider=auth_provider),
|
|
prefix="/v1",
|
|
tags=["harnesses"],
|
|
)
|
|
app.include_router(
|
|
create_terminal_attach_router(
|
|
auth_provider=auth_provider,
|
|
permission_store=permission_store,
|
|
conversation_store=conversation_store,
|
|
),
|
|
prefix="/v1",
|
|
tags=["terminals"],
|
|
)
|
|
app.include_router(
|
|
create_session_mcp_servers_router(
|
|
conversation_store,
|
|
agent_store,
|
|
artifact_store,
|
|
agent_cache,
|
|
runner_router=runner_router,
|
|
auth_provider=auth_provider,
|
|
permission_store=permission_store,
|
|
),
|
|
prefix="/v1",
|
|
tags=["session_mcp_servers"],
|
|
)
|
|
if comment_store is not None:
|
|
app.include_router(
|
|
create_comments_router(
|
|
comment_store,
|
|
auth_provider=auth_provider,
|
|
permission_store=permission_store,
|
|
conversation_store=conversation_store,
|
|
),
|
|
prefix="/v1",
|
|
tags=["comments"],
|
|
)
|
|
if policy_store is not None:
|
|
app.include_router(
|
|
create_session_policies_router(
|
|
policy_store,
|
|
conversation_store,
|
|
auth_provider=auth_provider,
|
|
permission_store=permission_store,
|
|
),
|
|
prefix="/v1",
|
|
tags=["session_policies"],
|
|
)
|
|
app.include_router(
|
|
create_default_policies_router(
|
|
policy_store,
|
|
auth_provider=auth_provider,
|
|
permission_store=permission_store,
|
|
),
|
|
prefix="/v1",
|
|
tags=["default_policies"],
|
|
)
|
|
app.include_router(
|
|
create_policy_registry_router(auth_provider=auth_provider),
|
|
prefix="/v1",
|
|
tags=["policy_registry"],
|
|
)
|
|
# Admin control for the server-wide sharing settings. Always mounted (the
|
|
# handlers self-gate on admin); PUT is a no-op-reject unless this server
|
|
# resolves the setting from the editable file-backed default.
|
|
app.include_router(
|
|
create_sharing_router(
|
|
auth_provider=auth_provider,
|
|
permission_store=permission_store,
|
|
),
|
|
prefix="/v1",
|
|
tags=["sharing"],
|
|
)
|
|
|
|
# ── Tunnel lifecycle callbacks (Step 8.5 crash recovery) ───
|
|
async def _on_runner_disconnect(runner_id: str) -> None:
|
|
"""Mark sessions pinned to *this* runner as offline.
|
|
|
|
Filters by ``runner_id`` against ``conversation_store`` so a
|
|
disconnect on one runner does not flip every cached session
|
|
(e.g. sessions owned by other runners on the same server, or
|
|
sessions left in the module-level cache by earlier tests on
|
|
the same xdist worker) to ``"failed"``. The cache is updated
|
|
in lockstep with the publish so the list endpoint stays
|
|
coherent.
|
|
|
|
:param runner_id: The disconnected runner's id.
|
|
"""
|
|
from omnigent.server.routes.sessions import (
|
|
_publish_status,
|
|
_session_status_cache,
|
|
)
|
|
|
|
# Newest-wins guard: a superseded tunnel's teardown fires this
|
|
# hook after a fresh tunnel for the same ``runner_id`` already
|
|
# registered (``TunnelRegistry.register`` retires the old
|
|
# session, whose helper tasks then error out and run this
|
|
# teardown). Marking the runner's sessions ``failed`` here would
|
|
# clobber the live tunnel's recovery: reconnect-recovery
|
|
# (``_on_runner_connect`` -> ``_publish_runner_recovered_status``)
|
|
# may have just cleared a stale ``runner_disconnected`` failure,
|
|
# and this stale disconnect would silently re-fail the session.
|
|
# If a live tunnel is registered for this runner, the runner is
|
|
# NOT offline, so skip. Mirrors the registry's own
|
|
# generation-guarded ``deregister``.
|
|
if tunnel_registry.get(runner_id) is not None:
|
|
_logger.info(
|
|
"Runner %s disconnect superseded by a live tunnel; skipping offline-marking",
|
|
runner_id,
|
|
)
|
|
return
|
|
|
|
# Direct by-runner lookup: read-after-write consistent (the
|
|
# listing path may be served from an eventually-consistent
|
|
# search index in alternate store backends) and
|
|
# O(sessions-on-this-runner) instead of a 500-row scan.
|
|
# Archived sessions are included by construction — an archived
|
|
# session can still be runner-bound, and skipping it here would
|
|
# leave it stuck "running" forever.
|
|
affected = [
|
|
c.id
|
|
for c in await asyncio.to_thread(
|
|
conversation_store.list_conversations_by_runner_id, runner_id
|
|
)
|
|
]
|
|
_logger.warning(
|
|
"Runner %s disconnected; marking %d session(s) offline",
|
|
runner_id,
|
|
len(affected),
|
|
)
|
|
for session_id in affected:
|
|
_session_status_cache[session_id] = "failed"
|
|
_publish_status(session_id, "failed")
|
|
|
|
async def _on_runner_exited(runner_id: str, error: str) -> None:
|
|
"""Mark a crashed runner's session(s) failed and push the cause.
|
|
|
|
Fired by the host tunnel when a daemon reports
|
|
``host.runner_exited`` — the only failure signal for a runner
|
|
that died before connecting its tunnel (so ``_on_runner_disconnect``
|
|
never fires for it). Mirrors that callback's by-runner lookup,
|
|
but carries the daemon-composed error onto the ``session.status:
|
|
failed`` event so the open view surfaces the cause immediately
|
|
instead of spinning on "starting" until a timeout.
|
|
|
|
:param runner_id: The crashed runner's id.
|
|
:param error: Human-readable cause from the daemon (exit code +
|
|
log tail), e.g. ``"runner process exited with code 1 ..."``.
|
|
"""
|
|
from omnigent.server.routes.sessions import (
|
|
_publish_status,
|
|
_session_status_cache,
|
|
)
|
|
from omnigent.server.schemas import ErrorDetail
|
|
|
|
affected = [
|
|
c.id
|
|
for c in await asyncio.to_thread(
|
|
conversation_store.list_conversations_by_runner_id, runner_id
|
|
)
|
|
]
|
|
_logger.warning(
|
|
"Runner %s reported crashed; marking %d session(s) failed: %s",
|
|
runner_id,
|
|
len(affected),
|
|
error,
|
|
)
|
|
detail = ErrorDetail(code="runner_failed_to_start", message=error)
|
|
for session_id in affected:
|
|
_session_status_cache[session_id] = "failed"
|
|
_publish_status(session_id, "failed", error=detail)
|
|
|
|
async def _on_runner_connect(runner_id: str) -> None:
|
|
"""Re-assign sessions and restart SSE relays on reconnect.
|
|
|
|
Resolves the runner client per-session via
|
|
``runner_router.client_for_session_resources``. The legacy
|
|
``get_runner_client()`` returns ``None`` in multi-runner
|
|
deployments where only ``set_runner_router`` is wired, so
|
|
routing must go through the router.
|
|
|
|
:param runner_id: The reconnecting runner's id.
|
|
"""
|
|
from omnigent.server.routes.sessions import (
|
|
_ensure_runner_relay,
|
|
_publish_runner_recovered_status,
|
|
)
|
|
|
|
# Direct by-runner lookup instead of list-everything-and-filter:
|
|
# the listing path may be backed by an eventually-consistent
|
|
# search index in alternate store backends, which cannot see a
|
|
# session created seconds ago — exactly the window this callback
|
|
# runs in for a host-spawned runner. Missing the session here
|
|
# means create_session never reaches the runner and the
|
|
# claude-native terminal is never bootstrapped. Archived
|
|
# sessions are included by construction (their relays must
|
|
# restart on reconnect like any other).
|
|
convs = await asyncio.to_thread(
|
|
conversation_store.list_conversations_by_runner_id, runner_id
|
|
)
|
|
_logger.info(
|
|
"_on_runner_connect: runner=%s, %d bound session(s)",
|
|
runner_id,
|
|
len(convs),
|
|
)
|
|
for conv in convs:
|
|
_logger.info(
|
|
"_on_runner_connect: matched %s (agent=%s)",
|
|
conv.id,
|
|
conv.agent_id,
|
|
)
|
|
try:
|
|
routed = runner_router.client_for_session_resources(conv.id)
|
|
except OmnigentError:
|
|
_logger.exception(
|
|
"Failed to resolve runner client for session %s on reconnect",
|
|
conv.id,
|
|
)
|
|
continue
|
|
if not conv.agent_id:
|
|
# The runner's create_session requires agent_id (it 400s
|
|
# without one), so don't send a request it rejects by
|
|
# contract. The old list path filtered these rows out via
|
|
# has_agent_id=True; the by-runner lookup returns them, and
|
|
# the relay restart below still applies — the session is
|
|
# runner-bound regardless of having an agent.
|
|
_logger.debug(
|
|
"_on_runner_connect: skipping session-init POST for %s (no agent_id)",
|
|
conv.id,
|
|
)
|
|
else:
|
|
try:
|
|
await routed.client.post(
|
|
"/v1/sessions",
|
|
json={
|
|
"session_id": conv.id,
|
|
"agent_id": conv.agent_id,
|
|
},
|
|
timeout=10.0,
|
|
)
|
|
except Exception:
|
|
_logger.exception(
|
|
"Failed to re-assign session %s on reconnect",
|
|
conv.id,
|
|
)
|
|
_ensure_runner_relay(
|
|
conv.id,
|
|
runner_id,
|
|
routed.client,
|
|
conversation_store,
|
|
)
|
|
# A reconnect can land the runner back on an idle session with
|
|
# no new turn (a transient WS blip; the runner process
|
|
# survived). The disconnect left the session marked failed with
|
|
# persisted ``runner_disconnected`` labels, and without a
|
|
# ``running`` edge nothing clears them — the Subagents panel
|
|
# keeps the grey "Disconnected" dot until the next user
|
|
# message. Clearing on reconnect drops it as soon as the runner
|
|
# is reachable again. The helper self-guards: it only clears a
|
|
# session whose persisted failure is ``runner_disconnected``, so
|
|
# a genuine task failure survives the reconnect untouched.
|
|
await _publish_runner_recovered_status(
|
|
conv.id, conversation_store, require_disconnect_code=True
|
|
)
|
|
|
|
def _resolve_managed_runner_owner(runner_id: str) -> str | None:
|
|
"""Owner for a server-managed sandbox runner, by its bound session.
|
|
|
|
Managed runners authenticate with a server-minted binding token,
|
|
not a user session, so the runner tunnel cannot resolve their
|
|
owner from the handshake. The server wrote ``runner_id`` onto the
|
|
session row at launch (``replace_runner_id``), so the bound
|
|
conversation's owner is authoritative — the runner-side analog of
|
|
the host tunnel's ``resolve_launch_token``.
|
|
|
|
:param runner_id: Token-bound runner id from the tunnel handshake.
|
|
:returns: The session owner's user id, or ``None`` when no session
|
|
is bound to this runner (the handshake is then refused).
|
|
"""
|
|
for conv in conversation_store.list_conversations_by_runner_id(runner_id):
|
|
owner = conversation_store.get_session_owner(conv.id)
|
|
if owner is not None:
|
|
return owner
|
|
return None
|
|
|
|
# WS tunnel endpoint for runners (RUNNER.md §2-3).
|
|
app.include_router(
|
|
create_runner_tunnel_router(
|
|
tunnel_registry,
|
|
allowed_tunnel_tokens=runner_tunnel_tokens,
|
|
on_runner_disconnect=_on_runner_disconnect,
|
|
on_runner_connect=_on_runner_connect,
|
|
auth_provider=auth_provider,
|
|
runner_exit_reports=runner_exit_reports,
|
|
resolve_managed_runner_owner=_resolve_managed_runner_owner,
|
|
),
|
|
prefix="/v1",
|
|
tags=["runners"],
|
|
)
|
|
|
|
# Host tunnel + REST endpoints (DAEMON_API.md). Mounted only when a
|
|
# host_store is configured: the routers call host_store on every
|
|
# request, so mounting them with host_store=None would fail each
|
|
# connection with an AttributeError swallowed by the tunnel's broad
|
|
# except (a hidden failure). No host_store = host support is simply
|
|
# not enabled (host connects get 404), rather than silently broken.
|
|
if host_store is not None:
|
|
from omnigent.server.routes.host_tunnel import create_host_tunnel_router
|
|
from omnigent.server.routes.hosts import create_hosts_router
|
|
|
|
app.include_router(
|
|
create_host_tunnel_router(
|
|
host_registry,
|
|
host_store,
|
|
auth_provider=auth_provider,
|
|
runner_exit_reports=runner_exit_reports,
|
|
on_runner_exited=_on_runner_exited,
|
|
),
|
|
prefix="/v1",
|
|
tags=["hosts"],
|
|
)
|
|
app.include_router(
|
|
create_hosts_router(
|
|
host_registry,
|
|
host_store,
|
|
conversation_store,
|
|
auth_provider=auth_provider,
|
|
permission_store=permission_store,
|
|
agent_store=agent_store,
|
|
agent_cache=agent_cache,
|
|
),
|
|
prefix="/v1",
|
|
tags=["hosts"],
|
|
)
|
|
|
|
# Mount the auth router that matches the active provider. OIDC and
|
|
# accounts share the /auth prefix but expose different endpoints
|
|
# under it (OIDC: /login, /callback, /logout, /cli-login, /cli-poll;
|
|
# accounts: /login POST, /logout, /me, /invite, /register, /magic,
|
|
# /magic/redeem, /users, /users/{id}/reset, /users/me/password).
|
|
# Must be registered BEFORE the SPA static mount because the SPA's
|
|
# HTML5-history fallback catches all unmatched extensionless paths.
|
|
if auth_provider is not None and getattr(auth_provider, "login_url", None):
|
|
from omnigent.server.auth import UnifiedAuthProvider
|
|
|
|
# ``admin_list`` is built once near app creation (see above) so the
|
|
# auth routes and ``/v1/me`` share one roster. Consulted on each login
|
|
# to promote listed identities — the only admin path for OIDC, and an
|
|
# additive convenience for accounts.
|
|
if (
|
|
isinstance(auth_provider, UnifiedAuthProvider)
|
|
and auth_provider._source == "accounts"
|
|
and account_store is not None
|
|
):
|
|
from omnigent.server.routes.accounts_auth import (
|
|
create_accounts_auth_router,
|
|
)
|
|
|
|
app.include_router(
|
|
create_accounts_auth_router(
|
|
auth_provider, account_store, admin_list, permission_store
|
|
),
|
|
prefix="/auth",
|
|
tags=["auth"],
|
|
)
|
|
else:
|
|
from omnigent.server.routes.auth import create_auth_router
|
|
|
|
# OIDC invites are opt-in (OMNIGENT_OIDC_ALLOW_INVITES) and
|
|
# need the token/invited-email store. Construct one on the
|
|
# shared DB when enabled and the caller didn't pass one —
|
|
# OIDC deploys don't otherwise wire an account store.
|
|
oidc_account_store = account_store
|
|
_oidc_cfg = getattr(auth_provider, "_oidc_config", None)
|
|
if (
|
|
oidc_account_store is None
|
|
and _oidc_cfg is not None
|
|
and getattr(_oidc_cfg, "allow_invites", False)
|
|
and permission_store is not None
|
|
):
|
|
from omnigent.server.accounts_store import SqlAlchemyAccountStore
|
|
|
|
oidc_account_store = SqlAlchemyAccountStore(permission_store.storage_location)
|
|
|
|
app.include_router(
|
|
create_auth_router(
|
|
auth_provider,
|
|
permission_store,
|
|
admin_list,
|
|
oidc_account_store,
|
|
allowed_domains=frozenset(allowed_domains or ()) or None,
|
|
),
|
|
prefix="/auth",
|
|
tags=["auth"],
|
|
)
|
|
|
|
# Mount the built web SPA at "/" if a build is present. The SPA is
|
|
# built into ``omnigent/server/static/web-ui/`` by ``web/``'s Vite
|
|
# build (see ``web/vite.config.ts`` ``build.outDir``). The mount is
|
|
# registered AFTER all API routers so router routes win on overlap.
|
|
# Skipping the mount when no build is present keeps API-only
|
|
# deployments working (and ``/`` 404s cleanly instead of exploding at
|
|
# startup).
|
|
#
|
|
# `_SPAStaticFiles` adds an HTML5-history fallback: any path that
|
|
# doesn't match a real file falls through to ``index.html``, which
|
|
# lets client-side routes like ``/c/<conversation_id>`` survive a
|
|
# browser refresh. Plain ``StaticFiles(html=True)`` only serves
|
|
# ``index.html`` for the literal root and directory paths, so a
|
|
# refresh on ``/c/abc`` would 404.
|
|
# Extra routers injected by callers (e.g. test fixtures that
|
|
# mount legacy routes). Registered BEFORE the SPA static-files
|
|
# mount so FastAPI resolves them before the catch-all fallback.
|
|
if extra_routers:
|
|
for router, prefix, tags in extra_routers:
|
|
app.include_router(router, prefix=prefix, tags=tags)
|
|
|
|
web_ui_dist = _WEB_UI_DIST
|
|
web_ui_present = web_ui_dist.is_dir() and (web_ui_dist / "index.html").is_file()
|
|
if web_ui_present:
|
|
app.mount(
|
|
"/",
|
|
_RangeAwareGZipMiddleware(
|
|
_SPAStaticFiles(directory=web_ui_dist, html=True),
|
|
minimum_size=_WEB_UI_GZIP_MINIMUM_SIZE,
|
|
),
|
|
name="web-ui",
|
|
)
|
|
else:
|
|
# No SPA bundle (API-only build, or an install that skipped the web
|
|
# UI). The "/" route isn't used for anything else, so just always serve
|
|
# a short HTML explainer there with a 200 — no content negotiation. A
|
|
# normal install bundles the UI and the static mount above owns "/", so
|
|
# this only applies to API-only servers.
|
|
|
|
@app.get("/", include_in_schema=False)
|
|
async def root() -> FileResponse:
|
|
"""Serve the API-only landing page (no web UI bundle present)."""
|
|
return FileResponse(_API_ONLY_LANDING_HTML, media_type="text/html")
|
|
|
|
return app
|
|
|
|
|
|
class _SPAStaticFiles(StaticFiles):
|
|
"""``StaticFiles`` with an SPA history fallback.
|
|
|
|
React Router's client-side routes (e.g. ``/c/abc123``) need to
|
|
survive a browser refresh — landing on them directly should return
|
|
the SPA shell, which then boots and resolves the route on the
|
|
client. Plain ``StaticFiles(html=True)`` only serves ``index.html``
|
|
for the literal root and directory paths, so a refresh on
|
|
``/c/abc`` would 404.
|
|
|
|
The fallback is gated by an API-prefix and extension check: unmatched
|
|
``/v1`` / ``/api`` / ``/auth`` / ``/health`` paths return a JSON 404,
|
|
and a path with a file extension (``.js``, ``.css``, ``.png``,
|
|
``.woff2``, …) returns the static 404 verbatim. Other extensionless
|
|
paths fall back to ``index.html``.
|
|
"""
|
|
|
|
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
# The mount is at "/" so it catches *every* unmatched path —
|
|
# including WebSocket upgrades, which Starlette's StaticFiles
|
|
# asserts against (raises ``AssertionError`` mid-handshake). A
|
|
# WS request landing here means no router matched it (e.g. a
|
|
# client targeting an endpoint that doesn't exist on this build
|
|
# of the server). Reject cleanly with code 1011 instead of
|
|
# crashing the ASGI worker.
|
|
if scope["type"] == "websocket":
|
|
await send({"type": "websocket.close", "code": 1011, "reason": "no such endpoint"})
|
|
return
|
|
await super().__call__(scope, receive, send)
|
|
|
|
async def get_response(self, path: str, scope: Scope) -> Response: # type: ignore[override]
|
|
served_path = path
|
|
try:
|
|
response = await super().get_response(path, scope)
|
|
except StarletteHTTPException as exc:
|
|
if exc.status_code == 404 and _is_web_ui_api_fallback_path(path):
|
|
return JSONResponse(
|
|
status_code=404,
|
|
content={
|
|
"error": {
|
|
"code": ErrorCode.NOT_FOUND,
|
|
"message": "Not found",
|
|
}
|
|
},
|
|
)
|
|
if exc.status_code == 404 and "." not in path.rsplit("/", 1)[-1]:
|
|
served_path = "index.html"
|
|
response = await super().get_response("index.html", scope)
|
|
else:
|
|
raise
|
|
return _apply_web_ui_cache_headers(response, served_path)
|
|
|
|
|
|
def _is_web_ui_api_fallback_path(path: str) -> bool:
|
|
"""
|
|
Return whether an unmatched static path belongs to the API namespace.
|
|
|
|
The web UI is mounted at ``/`` and receives every unmatched request after
|
|
the routers. Without this guard, an unknown API route such as
|
|
``/v1/sessions/x/codex_goal`` is served the SPA shell as ``200 text/html``,
|
|
which makes browser clients fail with a JSON parse error instead of a
|
|
route-level 404.
|
|
|
|
:param path: Static mount-relative path, e.g. ``"v1/sessions/x"``.
|
|
:returns: True for paths that should never fall back to ``index.html``.
|
|
"""
|
|
first_segment = path.lstrip("/").split("/", 1)[0]
|
|
return first_segment in _WEB_UI_API_FALLBACK_PREFIXES
|
|
|
|
|
|
class _RangeAwareGZipMiddleware(GZipMiddleware):
|
|
"""
|
|
Gzip middleware that leaves ranged static-file responses unencoded.
|
|
|
|
HTTP range metadata is defined over the selected representation. If
|
|
Starlette's generic gzip middleware compresses a ``206`` response,
|
|
the ``Content-Range`` header still describes the unencoded file
|
|
while the body bytes are gzip-encoded. The web UI only needs gzip
|
|
for normal full-file static asset fetches, so requests carrying a
|
|
``Range`` header bypass compression entirely.
|
|
|
|
:param app: Static-file ASGI app to wrap.
|
|
:param minimum_size: Minimum response body size to compress, e.g.
|
|
``1024``.
|
|
:param compresslevel: gzip compression level to pass through to
|
|
Starlette.
|
|
"""
|
|
|
|
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
"""
|
|
Compress ordinary static responses and pass range requests through.
|
|
|
|
:param scope: ASGI request scope, e.g. type ``"http"``.
|
|
:param receive: ASGI receive callable.
|
|
:param send: ASGI send callable.
|
|
:returns: None.
|
|
"""
|
|
if scope["type"] == "http" and any(
|
|
key.lower() == b"range" for key, _value in scope["headers"]
|
|
):
|
|
await self.app(scope, receive, send)
|
|
return
|
|
await super().__call__(scope, receive, send)
|
|
|
|
|
|
def _apply_web_ui_cache_headers(response: Response, path: str) -> Response:
|
|
"""
|
|
Apply browser cache policy for the bundled web UI static mount.
|
|
|
|
The SPA shell must be revalidated so a deployment can point clients
|
|
at new chunk names, while Vite emits content-hashed files under
|
|
``assets/`` that are safe to cache immutably. Other static files
|
|
get a modest cache lifetime because they may not be fingerprinted.
|
|
|
|
:param response: Response produced by Starlette's static-file
|
|
handler.
|
|
:param path: Static mount-relative path that was served, e.g.
|
|
``"assets/index-AbCd1234.js"`` or ``"index.html"`` for an
|
|
SPA history fallback.
|
|
:returns: The same response with ``Cache-Control`` set.
|
|
"""
|
|
content_type = response.headers.get("content-type")
|
|
media_type = content_type.partition(";")[0].lower() if content_type is not None else None
|
|
if path.startswith("assets/"):
|
|
response.headers["Cache-Control"] = _WEB_UI_ASSET_CACHE_CONTROL
|
|
elif path in {"sw.js", "version.json"}:
|
|
# The service worker and the version sentinel it precaches must
|
|
# revalidate on every load, or the HTTP cache could mask a deploy for up
|
|
# to an hour and defeat prompt-to-reload.
|
|
response.headers["Cache-Control"] = _WEB_UI_HTML_CACHE_CONTROL
|
|
elif media_type == "text/html" or path in {"", ".", "index.html"}:
|
|
response.headers["Cache-Control"] = _WEB_UI_HTML_CACHE_CONTROL
|
|
else:
|
|
response.headers["Cache-Control"] = _WEB_UI_STATIC_CACHE_CONTROL
|
|
return response
|