Files
omnigent-ai--omnigent/omnigent/server/routes/hosts.py
T
Zeyi (Rice) Fan b9d53f0a96 feat(server): add deployment-wide release feature flags (#4775)
## Related issue

Follow-up to #4673.

## Summary

- Add a typed, default-off release-feature registry driven by one comma-separated `OMNIGENT_FEATURES` environment variable, with strict validation and lifecycle metadata.
- Gate the web Usage route/navigation and page-only report enrichment while preserving the existing `GET /v1/usage` CLI API.
- Migrate web-driven harness installation to the same immutable startup snapshot and wire rollout configuration across Docker, Kubernetes, Render, Railway, and Databricks.

ELI5: the server reads one list of enabled features when it starts, enforces that same list on backend routes, and tells the web app which controls and pages to show.

```text
OMNIGENT_FEATURES
        |
        v
  FeatureFlags snapshot
     /             \
backend gates    GET /v1/info
                       |
                       v
                 frontend gates
```

## Test Plan

- `uv run pytest tests/server/test_feature_flags.py tests/host/test_local_server.py tests/server/integration/test_utility_endpoints.py tests/server/integration/test_hosts_install_harness.py tests/server/integration/test_hosts_store_credential.py tests/server/routes/test_usage_report.py tests/server/test_openapi_drift.py -q`
- `cd web && pnpm vitest run src/lib/capabilities.test.ts src/lib/harnessSetup.test.ts src/App.test.tsx src/shell/Sidebar.test.tsx`
- `uv run pytest tests/e2e_ui/sessions/test_usage_page_feature.py -q`
- `uv run python scripts/dump_openapi.py --check`
- `pre-commit run --files <changed files>`
- Verified default-off and enabled Usage route/sidebar behavior, strict unknown-feature rejection, legacy CLI usage compatibility, and harness route enforcement.

## Demo

- Default off: the updated visual baselines show the original sidebar without the Usage row.
- Enabled Usage page: https://github.com/user-attachments/assets/8385d4f0-47ad-430f-bf2c-06c35af6c499

## Type of change

- [ ] Bug fix
- [x] Feature
- [x] UI / frontend change
- [ ] Refactor / chore
- [x] Docs
- [ ] Test / CI
- [ ] Breaking change

## Test coverage

- [x] Unit tests added / updated
- [x] Integration tests added / updated
- [x] E2E tests added / updated
- [x] Manual verification completed
- [x] Existing tests cover this change
- [ ] Not applicable

## Coverage notes

Manually reviewed the default-off visual output and verified that the Usage route is absent while the capability is disabled. Targeted backend and frontend tests cover both flag states, capability parsing, startup snapshots, and harness enforcement.

## Changelog

Usage and web-driven harness setup can now be enabled per deployment with `OMNIGENT_FEATURES`.

Signed-off-by: Zeyi (Rice) Fan <zeyi.f@databricks.com>
2026-08-14 10:57:20 -07:00

1573 lines
67 KiB
Python

"""REST API routes for hosts (``/v1/hosts``).
Provides endpoints for listing connected hosts and launching
runners on them. The Web UI uses these to let users pick a host
for a new session and trigger runner spawning.
Per ``designs/DAEMON_API.md``, host registration is persisted in
the ``hosts`` DB table, which is the cross-replica source of
truth for ``status``. The in-memory ``HostRegistry`` is
per-replica and is used here only when a route needs the live
``HostConnection`` on the current replica (e.g. proxying a
``host.list_dir`` frame). The list/get endpoints answer purely
from the DB so a host connected to replica B reads back as
``"online"`` from replica A.
"""
from __future__ import annotations
import asyncio
import logging
import secrets
from typing import Any
from fastapi import APIRouter, HTTPException, Query, Request
from pydantic import BaseModel
from omnigent.db.utils import now_epoch
from omnigent.entities import Conversation
from omnigent.errors import ErrorCode, OmnigentError
from omnigent.harness_aliases import canonicalize_harness
from omnigent.host.frames import (
HARNESS_NOT_CONFIGURED_ERROR_CODE,
HostCreateDirFrame,
HostDetectCredentialsFrame,
HostInstallHarnessFrame,
HostLaunchRunnerFrame,
HostListDirFrame,
HostStoreSecretFrame,
encode_host_frame,
optional_str_bool_map,
)
from omnigent.onboarding.harness_install import (
ui_credential_configurable_harnesses,
ui_install_key,
ui_installable_harnesses,
)
from omnigent.runner.identity import token_bound_runner_id
from omnigent.runtime.agent_cache import AgentCache
from omnigent.server.auth import AuthProvider
from omnigent.server.feature_flags import Feature, FeatureFlags, resolve_feature_flags
from omnigent.server.host_registry import HostConnection, HostRegistry
from omnigent.server.routes._auth_helpers import require_user
from omnigent.server.routes._host_launch import resolve_host_launch
from omnigent.server.schemas import SessionGitOptions
from omnigent.stores import AgentStore, ConversationStore
from omnigent.stores.host_store import Host, HostStore, host_is_live
from omnigent.stores.permission_store import PermissionStore
_logger = logging.getLogger(__name__)
_LAUNCH_RESULT_TIMEOUT_S = 30.0
# Per-call timeout for host.list_dir round-trips. Listing is a single
# scandir + sort on the host side; 5s is generous for transient
# network slowness without making the picker feel hung.
_LIST_DIR_TIMEOUT_S = 5.0
_LIST_DIR_DEFAULT_LIMIT = 20
_LIST_DIR_MAX_LIMIT = 1000
# Per-call timeout for host.create_dir round-trips. mkdir is a single
# fast syscall on the host side; 5s matches list_dir and is generous
# for transient network slowness without making the picker feel hung.
_CREATE_DIR_TIMEOUT_S = 5.0
_MODEL_OPTIONS_TIMEOUT_S = 15.0
# Per-call timeout for host.install_harness round-trips. The host runs
# `npm install -g <pkg>` — install_harness_cli caps that subprocess at 300s —
# then recomputes readiness and sends the result back over the tunnel. The
# server must wait comfortably longer than the 300s subprocess ceiling, not
# just a hair over it: a cold npm install can run near the full cap, and the
# readiness recompute + tunnel round-trip add more on top. 420s (300s + 2min
# headroom) keeps a genuine slow install from timing out at the server while
# the host is still succeeding — a "504 but actually installed" outcome.
_INSTALL_HARNESS_TIMEOUT_S = 420.0
def _host_absent_error(host: Host) -> OmnigentError:
"""Classify a "host not on this replica" miss for a host-scoped route.
Every ``/v1/hosts/{id}/*`` route reaches the host over its live tunnel in
the local (this-replica) ``HostRegistry``. When replicas are sharded by
host, a request keyed to ``host_id`` can land on a replica that doesn't
hold the tunnel — the same wrong-replica case ``RunnerRouter`` handles for
runner dispatch (see ``_runner_absent_code``). Tell the two apart using the
host record the caller already loaded:
- still **live** (online + fresh heartbeat) → up on some replica, just not
here → :data:`~ErrorCode.WRONG_REPLICA` (400) so the client re-addresses
WITHOUT the key.
- otherwise → genuinely offline → ``CONFLICT`` (409).
:param host: The host's persistent record (owner-checked by the caller).
:returns: The ``OmnigentError`` to raise; the global handler maps its code
to the HTTP status and the ``{"error": {"code": ...}}`` body the
client's re-address matches on.
"""
if host_is_live(host):
return OmnigentError("host is on another replica", code=ErrorCode.WRONG_REPLICA)
return OmnigentError("host is offline", code=ErrorCode.CONFLICT)
async def _proxy_model_options(
*,
host_registry: HostRegistry,
host_conn: HostConnection,
harness: str,
) -> dict[str, Any]:
"""Ask a host for the model catalog it would use for a new session."""
from omnigent.server.routes._host_model_options import request_host_model_options
try:
return await request_host_model_options(
host_registry=host_registry,
host_conn=host_conn,
harness=harness,
timeout_s=_MODEL_OPTIONS_TIMEOUT_S,
)
except ConnectionError as exc:
raise HTTPException(
status_code=502,
detail=f"host '{host_conn.host_id}' connection lost",
) from exc
except asyncio.TimeoutError as exc:
raise HTTPException(
status_code=504,
detail=(
f"host '{host_conn.host_id}' did not resolve model options within "
f"{_MODEL_OPTIONS_TIMEOUT_S:.0f}s"
),
) from exc
async def _proxy_list_dir(
*,
host_registry: HostRegistry,
host_conn: HostConnection,
path: str,
limit: int,
after: str | None,
before: str | None,
) -> dict[str, Any]:
"""
Send a ``host.list_dir`` frame and await the result.
Mirrors the structure of the workspace validator's
``_ask_host_stat``: enqueue the frame, register a future on
the host connection's ``pending_list_dirs`` map, await with a
timeout, and clean up in a finally block. The host's WS
receive loop in ``host_tunnel.py`` resolves the future when
the result frame arrives.
:param host_registry: Server-side registry; used to enqueue
the outbound frame on the host's send queue.
:param host_conn: Live host connection.
:param path: Absolute or tilde-prefixed path. The host
expands ``~`` itself.
:param limit: Max entries per page; clamped by the route.
:param after: Optional forward-pagination cursor (entry path).
:param before: Optional backward-pagination cursor.
:returns: Dict with the result fields:
``status`` (``"ok"`` or ``"failed"``), ``entries`` (list
of dicts), ``has_more`` (bool), ``error`` (string or
``None``).
:raises HTTPException: 504 on timeout, 502 on connection drop
or unexpected I/O failure on the host.
"""
request_id = secrets.token_hex(8)
loop = asyncio.get_running_loop()
future: asyncio.Future[dict[str, Any]] = loop.create_future()
host_conn.pending_list_dirs[request_id] = future
frame = encode_host_frame(
HostListDirFrame(
request_id=request_id,
path=path,
limit=limit,
after=after,
before=before,
)
)
try:
try:
host_registry.send_text(host_conn, frame)
except ConnectionError as exc:
raise HTTPException(
status_code=502,
detail=f"host '{host_conn.host_id}' connection lost",
) from exc
try:
return await asyncio.wait_for(future, timeout=_LIST_DIR_TIMEOUT_S)
except asyncio.TimeoutError as exc:
raise HTTPException(
status_code=504,
detail=(
f"host '{host_conn.host_id}' did not respond to list_dir "
f"within {_LIST_DIR_TIMEOUT_S:.0f}s"
),
) from exc
finally:
# Cleanup runs on every path so a cancelled caller doesn't
# leave an orphan in the pending dict.
host_conn.pending_list_dirs.pop(request_id, None)
async def _proxy_create_dir(
*,
host_registry: HostRegistry,
host_conn: HostConnection,
path: str,
) -> dict[str, Any]:
"""
Send a ``host.create_dir`` frame and await the result.
Mirrors :func:`_proxy_list_dir`: enqueue the frame, register a
future on the host connection's ``pending_create_dirs`` map, await
with a timeout, and clean up in a finally block. The host's WS
receive loop in ``host_tunnel.py`` resolves the future when the
result frame arrives.
:param host_registry: Server-side registry; used to enqueue the
outbound frame on the host's send queue.
:param host_conn: Live host connection.
:param path: Absolute or tilde-prefixed directory to create. The
host expands ``~`` itself.
:returns: Dict with the result fields: ``status`` (``"ok"`` or
``"failed"``), ``path`` (created absolute path or ``None``),
``error`` (string or ``None``).
:raises HTTPException: 504 on timeout, 502 on connection drop.
"""
request_id = secrets.token_hex(8)
loop = asyncio.get_running_loop()
future: asyncio.Future[dict[str, Any]] = loop.create_future()
host_conn.pending_create_dirs[request_id] = future
frame = encode_host_frame(
HostCreateDirFrame(
request_id=request_id,
path=path,
)
)
try:
try:
host_registry.send_text(host_conn, frame)
except ConnectionError as exc:
raise HTTPException(
status_code=502,
detail=f"host '{host_conn.host_id}' connection lost",
) from exc
try:
return await asyncio.wait_for(future, timeout=_CREATE_DIR_TIMEOUT_S)
except asyncio.TimeoutError as exc:
raise HTTPException(
status_code=504,
detail=(
f"host '{host_conn.host_id}' did not respond to create_dir "
f"within {_CREATE_DIR_TIMEOUT_S:.0f}s"
),
) from exc
finally:
# Cleanup runs on every path so a cancelled caller doesn't
# leave an orphan in the pending dict.
host_conn.pending_create_dirs.pop(request_id, None)
async def _proxy_install_harness(
*,
host_registry: HostRegistry,
host_conn: HostConnection,
harness: str,
) -> dict[str, Any]:
"""
Send a ``host.install_harness`` frame and await the result.
Mirrors :func:`_proxy_create_dir`: register a future on the host
connection's ``pending_installs`` map, enqueue the frame, await with a
timeout, and clean up in a finally block. ``host_tunnel.py``'s receive
loop resolves the future when the result frame arrives.
:param host_registry: Server-side registry; used to enqueue the outbound
frame on the host's send queue.
:param host_conn: Live host connection.
:param harness: The UI harness identifier to install, e.g. ``"claude"``.
:returns: Dict with the result fields: ``status`` (``"ok"`` /
``"failed"``), ``configured_harnesses`` (the refreshed readiness map or
``None``), ``gateway_inference`` (the refreshed per-harness
AI-Gateway-backed inference map or ``None``), ``error`` (string or
``None``).
:raises HTTPException: 504 on timeout, 502 on connection drop.
"""
request_id = secrets.token_hex(8)
loop = asyncio.get_running_loop()
future: asyncio.Future[dict[str, Any]] = loop.create_future()
host_conn.pending_installs[request_id] = future
frame = encode_host_frame(
HostInstallHarnessFrame(
request_id=request_id,
harness=harness,
)
)
try:
try:
host_registry.send_text(host_conn, frame)
except ConnectionError as exc:
raise HTTPException(
status_code=502,
detail=f"host '{host_conn.host_id}' connection lost",
) from exc
try:
return await asyncio.wait_for(future, timeout=_INSTALL_HARNESS_TIMEOUT_S)
except asyncio.TimeoutError as exc:
raise HTTPException(
status_code=504,
detail=(
f"host '{host_conn.host_id}' did not respond to install_harness "
f"within {_INSTALL_HARNESS_TIMEOUT_S:.0f}s"
),
) from exc
finally:
# Cleanup runs on every path so a cancelled caller doesn't
# leave an orphan in the pending dict.
host_conn.pending_installs.pop(request_id, None)
# The credential write is local keychain/file I/O on the host — fast, unlike the
# npm install — so a short timeout is plenty and surfaces a hung host quickly.
_STORE_SECRET_TIMEOUT_S = 30.0
async def _proxy_store_secret(
*,
host_registry: HostRegistry,
host_conn: HostConnection,
frame: HostStoreSecretFrame,
) -> dict[str, Any]:
"""Forward a ``host.store_secret`` frame and await the result.
Mirrors :func:`_proxy_install_harness`: register a future on the host
connection's ``pending_secret_writes`` map, enqueue the frame, await with a
timeout, and clean up in a finally block. The server never inspects,
persists, or logs the secret in *frame* — it is a pass-through to the host
daemon, which does the write on the runner.
:param host_registry: Server-side registry; used to enqueue the frame.
:param host_conn: Live host connection.
:param frame: The store-secret frame to forward (carries the secret).
:returns: Dict with ``status`` / ``configured_harnesses`` /
``gateway_inference`` / ``error``.
:raises HTTPException: 504 on timeout, 502 on connection drop.
"""
request_id = frame.request_id
loop = asyncio.get_running_loop()
future: asyncio.Future[dict[str, Any]] = loop.create_future()
host_conn.pending_secret_writes[request_id] = future
try:
try:
host_registry.send_text(host_conn, encode_host_frame(frame))
except ConnectionError as exc:
raise HTTPException(
status_code=502,
detail=f"host '{host_conn.host_id}' connection lost",
) from exc
try:
return await asyncio.wait_for(future, timeout=_STORE_SECRET_TIMEOUT_S)
except asyncio.TimeoutError as exc:
raise HTTPException(
status_code=504,
detail=(
f"host '{host_conn.host_id}' did not respond to store_secret "
f"within {_STORE_SECRET_TIMEOUT_S:.0f}s"
),
) from exc
finally:
host_conn.pending_secret_writes.pop(request_id, None)
async def _proxy_detect_credentials(
*,
host_registry: HostRegistry,
host_conn: HostConnection,
) -> dict[str, Any]:
"""Forward a ``host.detect_credentials`` frame and await the result.
Mirrors :func:`_proxy_store_secret`. The result carries only non-secret
descriptors (family + source label + env var name).
:param host_registry: Server-side registry; used to enqueue the frame.
:param host_conn: Live host connection.
:returns: Dict with a ``credentials`` list of non-secret descriptors.
:raises HTTPException: 504 on timeout, 502 on connection drop.
"""
request_id = secrets.token_hex(8)
loop = asyncio.get_running_loop()
future: asyncio.Future[dict[str, Any]] = loop.create_future()
host_conn.pending_credential_detects[request_id] = future
frame = encode_host_frame(HostDetectCredentialsFrame(request_id=request_id))
try:
try:
host_registry.send_text(host_conn, frame)
except ConnectionError as exc:
raise HTTPException(
status_code=502,
detail=f"host '{host_conn.host_id}' connection lost",
) from exc
try:
return await asyncio.wait_for(future, timeout=_STORE_SECRET_TIMEOUT_S)
except asyncio.TimeoutError as exc:
raise HTTPException(
status_code=504,
detail=(
f"host '{host_conn.host_id}' did not respond to detect_credentials "
f"within {_STORE_SECRET_TIMEOUT_S:.0f}s"
),
) from exc
finally:
host_conn.pending_credential_detects.pop(request_id, None)
class CreateDirectoryRequest(BaseModel):
"""Request body for ``POST /v1/hosts/{host_id}/directories``.
:param path: Absolute path of the directory to create on the host
machine, e.g. ``"/Users/corey/projects/new-app"``, or a
tilde-prefixed path (``"~/scratch"``) the host expands against
its own process owner. Missing parents are created.
"""
path: str
class StoreHarnessCredentialRequest(BaseModel):
"""Request body for ``POST /v1/hosts/{id}/harnesses/{harness}/credential``.
Carries the credential in the body (never the URL). The secret field is
optional so the ``adopt`` kind — which references an existing host env var
by name rather than sending a value — can omit it.
:param kind: ``"key"`` (a vendor API key), ``"gateway"`` (a compatible
proxy at ``base_url``), or ``"adopt"`` (reference host env ``env_var``).
:param secret: The API key / gateway token for ``key`` / ``gateway``;
``None`` for ``adopt``.
:param base_url: The gateway base URL, required for ``kind="gateway"``.
:param default_model: Optional family default model id to pin.
:param wire_api: Optional OpenAI wire protocol (``"chat"`` / ``"responses"``).
:param env_var: For ``kind="adopt"``, the host env var to reference.
"""
kind: str
secret: str | None = None
base_url: str | None = None
default_model: str | None = None
wire_api: str | None = None
env_var: str | None = None
class LaunchRunnerRequest(BaseModel):
"""Request body for ``POST /v1/hosts/{host_id}/runners``.
:param session_id: Session to bind the new runner to, e.g.
``"conv_abc123"``.
:param workspace: Absolute path on the host machine to use
as the runner's working directory, e.g.
``"/Users/corey/projects/frontend"``. When ``git`` is set,
this is interpreted as the source repository directory and
the runner starts in the created worktree instead.
:param git: Optional git worktree options. In create mode the
server creates a worktree for a new branch off ``workspace`` on
the host and binds the runner to it (the fork-resume path;
mirrors ``POST /v1/sessions``). In bind mode
(``existing_worktree=True``) ``workspace`` already IS a
worktree — no worktree is created; ``branch_name`` is recorded
as the session's ``git_branch`` for display and opt-in cleanup.
``None`` binds ``workspace`` directly. ``host_id`` is always
present (it is in the path), so no host check is needed here.
"""
session_id: str
workspace: str
git: SessionGitOptions | None = None
async def _resolve_agent_spec_cwd(
conv: Conversation,
agent_store: AgentStore,
agent_cache: AgentCache,
) -> str | None:
"""
Read the bound agent's ``os_env.cwd`` for workspace-boundary checks.
:param conv: The session/conversation a runner is launching for.
:param agent_store: Store to resolve ``conv.agent_id`` to an agent.
:param agent_cache: Cache to load the agent's parsed spec.
:returns: The agent's ``os_env.cwd`` (absolute or relative), or
``None`` when the session has no agent, no bundle, or no
``os_env`` block (headless / unconstrained boundary).
"""
if conv.agent_id is None:
return None
agent = await asyncio.to_thread(agent_store.get, conv.agent_id)
if agent is None or agent.bundle_location is None:
return None
loaded = await asyncio.to_thread(agent_cache.load, agent.id, agent.bundle_location)
os_env = getattr(loaded.spec, "os_env", None)
return getattr(os_env, "cwd", None) if os_env is not None else None
async def _resolve_agent_harness(
conv: Conversation,
agent_store: AgentStore,
agent_cache: AgentCache,
) -> str | None:
"""
Read the bound agent's canonical harness for the launch frame.
Mirrors :func:`_resolve_agent_spec_cwd` — same resolution chain,
different spec field. The harness rides on the
``host.launch_runner`` frame so the host can refuse an
unconfigured harness before spawning.
:param conv: The session/conversation a runner is launching for.
:param agent_store: Store to resolve ``conv.agent_id`` to an agent.
:param agent_cache: Cache to load the agent's parsed spec.
:returns: The canonical harness id, e.g. ``"claude-sdk"``, or
``None`` when the session has no agent or no bundle (the host
then skips the configuration check — fail open).
"""
if conv.agent_id is None:
return None
agent = await asyncio.to_thread(agent_store.get, conv.agent_id)
if agent is None or agent.bundle_location is None:
return None
loaded = await asyncio.to_thread(agent_cache.load, agent.id, agent.bundle_location)
return canonicalize_harness(loaded.spec.executor.harness_kind)
def create_hosts_router(
host_registry: HostRegistry,
host_store: HostStore,
conversation_store: ConversationStore,
*,
auth_provider: AuthProvider | None = None,
permission_store: PermissionStore | None = None,
agent_store: AgentStore | None = None,
agent_cache: AgentCache | None = None,
feature_flags: FeatureFlags | None = None,
) -> APIRouter:
"""Build the router for host REST endpoints.
Mounted with ``prefix="/v1"`` so paths are ``/v1/hosts/...``.
:param host_registry: In-memory registry of live host
connections on this replica.
:param host_store: Persistent store for host registrations.
:param conversation_store: Conversation store for reading and
updating session rows (runner_id, host_id).
:param auth_provider: Optional auth provider for user identity.
:param permission_store: Session permission store, used to verify
the caller owns the session a runner is launched for. ``None``
disables the session-owner check (single-user/local).
:param agent_store: Agent store used to resolve a session's agent
for workspace-boundary validation on runner launch (W6). When
``None`` (non-production wiring), the boundary check is skipped;
:func:`omnigent.server.app.create_app` always supplies it.
:param agent_cache: Agent-spec cache used to read the agent's
``os_env.cwd`` boundary. Paired with ``agent_store``.
:param feature_flags: Immutable deployment release-feature snapshot.
When omitted, resolves ``OMNIGENT_FEATURES`` at router construction.
:returns: A FastAPI router with host endpoints.
"""
flags = feature_flags or resolve_feature_flags()
router = APIRouter()
@router.get("/hosts")
async def list_hosts(request: Request) -> dict[str, list[dict[str, Any]]]:
"""List all hosts owned by the authenticated user.
Returns both online and offline hosts, with live runner
information for online hosts.
:param request: The incoming request (for auth).
:returns: ``{"hosts": [...]}`` with host details — ``host_id``,
``name``, ``owner``, ``status``, ``sandbox_provider``,
``configured_harnesses``, and ``gateway_inference`` (``None`` when
no connected host has reported it to this replica).
"""
# require_user: unauthenticated callers 401. user_id is None
# only when auth is disabled entirely — there the single-user
# server's hosts are owned by the reserved "local" user.
user_id = require_user(request, auth_provider)
if user_id is None:
hosts = await asyncio.to_thread(host_store.list_hosts, "local")
else:
hosts = await asyncio.to_thread(host_store.list_hosts, user_id)
# One clock for the whole batch so every host is classified
# against a consistent "now" (host_is_live's documented idiom).
now = now_epoch()
result: list[dict[str, Any]] = []
for host in hosts:
# Status comes from the DB, not host_registry. The registry
# is per-replica; if a host is connected to replica B and
# this request lands on replica A, A's registry won't know
# about it. The hosts table is the cross-replica source of
# truth — written by the tunnel endpoint on the replica
# that owns the connection (upsert_on_connect / set_offline).
# A stored "online" is only trusted if the host was seen
# recently: a crashed host never runs set_offline and would
# otherwise show as online forever in the picker.
result.append(
{
"host_id": host.host_id,
"name": host.name,
"owner": host.user_id,
"status": "online" if host_is_live(host, now=now) else "offline",
# Non-None marks a server-managed sandbox host (e.g.
# "modal"). Clients use it to hide sandbox-backed
# hosts from manual host pickers — they are launch
# targets the server creates on demand, not
# user-connectable machines.
"sandbox_provider": host.sandbox_provider,
"configured_harnesses": host.configured_harnesses,
# Held in memory from the host's connect handshake, not the
# hosts row. ``None`` means this replica has no report yet —
# emitted as-is so a client can tell "unknown" from "not
# gateway-backed".
"gateway_inference": host_registry.gateway_inference(host.host_id),
}
)
return {"hosts": result}
@router.get("/hosts/{host_id}")
async def get_host(request: Request, host_id: str) -> dict[str, Any]:
"""Get details for a single host.
:param request: The incoming request (for auth).
:param host_id: Host identifier, e.g.
``"host_a1b2c3d4..."``.
:returns: Host details dict — the ``list_hosts`` fields (including
``gateway_inference``, ``None`` when unreported) plus ``runners``.
:raises HTTPException: 404 if the host does not exist.
"""
# require_user: with an auth provider configured, an
# unauthenticated caller must get 401 here — get_user_id would
# return None and the ownership check below would be skipped,
# exposing another user's host. user_id is None only when auth
# is disabled entirely (single-user server).
user_id = require_user(request, auth_provider)
host = await asyncio.to_thread(host_store.get_host, host_id)
if host is None:
raise HTTPException(status_code=404, detail="host not found")
if user_id is not None and host.user_id != user_id:
raise HTTPException(status_code=403, detail="not your host")
# Status comes from the DB so the answer is consistent across
# replicas, gated on the liveness freshness window — see
# list_hosts above for the full rationale.
return {
"host_id": host.host_id,
"name": host.name,
"owner": host.user_id,
"status": "online" if host_is_live(host) else "offline",
# Same semantics as list_hosts: non-None marks a
# server-managed sandbox host (e.g. "modal").
"sandbox_provider": host.sandbox_provider,
"configured_harnesses": host.configured_harnesses,
# Same semantics as list_hosts: reported on connect and held in
# memory, so ``None`` is "no report on this replica yet".
"gateway_inference": host_registry.gateway_inference(host.host_id),
"runners": [],
}
@router.get("/hosts/{host_id}/harnesses/{harness}/model-options")
async def get_host_model_options(
request: Request,
host_id: str,
harness: str,
) -> dict[str, list[dict[str, Any]]]:
"""Return pre-launch model choices resolved by the selected host.
A preview of the host's ambient default catalog, not a binding
snapshot: launch re-resolves with the session's agent spec, and the
in-session picker reflects that launch snapshot once the runner is up.
"""
user_id = require_user(request, auth_provider)
host = await asyncio.to_thread(host_store.get_host, host_id)
if host is None:
raise HTTPException(status_code=404, detail="host not found")
if user_id is not None and host.user_id != user_id:
raise HTTPException(status_code=403, detail="not your host")
conn = host_registry.get(host.host_id)
if conn is None:
raise _host_absent_error(host)
result = await _proxy_model_options(
host_registry=host_registry,
host_conn=conn,
harness=canonicalize_harness(harness) or harness,
)
if result.get("status") != "ok":
raise HTTPException(
status_code=502,
detail=str(result.get("error") or "host model-options lookup failed"),
)
models = result.get("models")
return {"models": models if isinstance(models, list) else []}
@router.post("/hosts/{host_id}/runners")
async def launch_runner(
request: Request,
host_id: str,
body: LaunchRunnerRequest,
) -> dict[str, Any]:
"""Launch a runner on a host for a session.
Generates a binding token, writes the expected runner_id
to the session row, sends the launch command to the host,
and waits for the host's acknowledgement.
:param request: The incoming request (for auth).
:param host_id: Target host, e.g. ``"host_a1b2c3d4..."``.
:param body: Launch request with ``session_id`` and
``workspace``.
:returns: ``{"runner_id": ..., "status": "launching"}``.
:raises HTTPException: 404 if host not found, 409 if host
offline, 403 if caller doesn't own the host, 400 if
session already has a runner.
"""
# require_user: resolve_host_launch skips its ownership checks
# for user_id=None (the auth-disabled single-user case), so an
# unauthenticated caller slipping through as None could launch
# a runner on another user's host. 401 instead.
user_id = require_user(request, auth_provider)
# Authorize against BOTH the host and the session before
# spawning anything (see _host_launch for the threat model).
target = await asyncio.to_thread(
resolve_host_launch,
user_id=user_id,
host_id=host_id,
session_id=body.session_id,
host_store=host_store,
host_registry=host_registry,
conversation_store=conversation_store,
permission_store=permission_store,
)
conn = target.conn
# W6: validate the requested workspace against the agent's
# os_env.cwd sandbox boundary BEFORE binding — the same check
# POST /v1/sessions enforces. Without it, an owner could bind a
# workspace outside the agent's declared boundary via this
# shortcut and escape the sandbox. validate_workspace also
# canonicalizes the path (realpath) for storage. Skipped only
# when the router was wired without an agent cache (non-prod
# test wiring); create_app always supplies one.
workspace = body.workspace
if agent_store is not None and agent_cache is not None:
from omnigent.server.routes._workspace_validation import (
WorkspaceValidationError,
validate_workspace,
)
spec_cwd = await _resolve_agent_spec_cwd(target.conv, agent_store, agent_cache)
try:
workspace = await validate_workspace(
host_registry=host_registry,
host_id=host_id,
workspace=body.workspace,
spec_cwd=spec_cwd,
host_name_for_errors=target.host.name,
)
except WorkspaceValidationError as exc:
raise HTTPException(status_code=400, detail=exc.message) from exc
else:
_logger.warning(
"launch_runner: workspace boundary validation skipped for "
"session %s (router built without an agent cache)",
body.session_id,
)
# Optional git worktree: when the caller asks to branch, create a
# worktree off the validated source repo and bind the runner to
# the worktree path instead (the fork-resume path; mirrors
# POST /v1/sessions). Created BEFORE the atomic runner bind so a
# lost CAS or a failed launch can roll it back, leaving no orphan
# worktree on the host.
git_branch: str | None = None
# CreatedWorktree | None — set ONLY when Omnigent creates a worktree
# (create mode). Left None in bind mode so the rollback below never
# force-removes the user's pre-existing worktree.
worktree = None
if body.git is not None:
from omnigent.host.git_worktree import (
WorktreeError,
validate_branch_name,
)
# Shared by both modes — the host never runs git in bind mode, so
# the server is the only gate on the name there.
try:
validate_branch_name(body.git.branch_name)
except WorktreeError as exc:
raise HTTPException(status_code=400, detail=exc.message) from exc
if body.git.existing_worktree:
# Binding to a pre-existing worktree: no worktree is created,
# but record its branch so the sidebar shows it and the opt-in
# delete flow can offer to remove it.
git_branch = body.git.branch_name
else:
from omnigent.server.routes._host_worktree import (
WorktreeHostUnavailableError,
WorktreeProxyError,
create_worktree_on_host,
)
try:
worktree = await create_worktree_on_host(
host_registry=host_registry,
host_conn=conn,
repo_path=workspace,
branch_name=body.git.branch_name,
base_branch=body.git.base_branch,
)
except WorktreeHostUnavailableError as exc:
# Host offline / unresponsive — infra, not user input.
raise HTTPException(status_code=409, detail=exc.message) from exc
except WorktreeProxyError as exc:
# Host-reported git failure (dup branch, bad base, not a
# repo) — user-correctable input.
raise HTTPException(status_code=400, detail=exc.message) from exc
workspace = worktree.worktree_path
git_branch = worktree.branch
async def _rollback_worktree() -> None:
"""
Best-effort removal of the worktree created above.
Called when the runner bind or launch fails after the
worktree was created, so a failed request leaves no orphan
worktree (and no orphan branch) on the host. Never raises —
a cleanup failure is logged and the original error still
propagates.
"""
if worktree is None:
return
from omnigent.server.routes._host_worktree import (
WorktreeProxyError,
remove_worktree_on_host,
)
try:
await remove_worktree_on_host(
host_registry=host_registry,
host_conn=conn,
worktree_path=worktree.worktree_path,
branch=worktree.branch,
delete_branch=True,
)
except WorktreeProxyError:
_logger.warning(
"Best-effort worktree rollback failed for session %s (%s)",
body.session_id,
worktree.worktree_path,
exc_info=True,
)
async def _rollback_failed_launch() -> None:
"""
Undo a failed launch *after* the runner was atomically bound.
Fully unbinds the session — NULLs ``runner_id`` plus the
``host_id`` / ``workspace`` / ``git_branch`` persisted by the
``set_host_id`` call below — and rolls back any worktree
created for this launch. Clearing the binding (not just
``runner_id``) keeps the DB consistent with the host's actual
state: the worktree is gone, so the row must not keep pointing
at it, and a retry that omits a worktree starts from a clean
slate rather than inheriting a stale ``git_branch`` (which
``set_host_id`` cannot clear). ``POST /hosts/{id}/runners`` only
binds a previously-unbound clone (the fork-resume picker), so a
full unbind restores the true pre-call state. Used only on the
post-bind failure paths; the lost-CAS path must NOT clear the
binding because it belongs to the concurrent winner, not us.
"""
await asyncio.to_thread(conversation_store.clear_host_binding, body.session_id)
await _rollback_worktree()
binding_token = secrets.token_urlsafe(32)
runner_id = token_bound_runner_id(binding_token)
# Atomic bind (UPDATE ... WHERE runner_id IS NULL): only one
# concurrent launch can bind an unbound session; a second (or an
# already-bound session) gets False. Closes the TOCTOU.
bound = await asyncio.to_thread(
conversation_store.set_runner_id,
body.session_id,
runner_id,
)
if not bound:
await _rollback_worktree()
raise HTTPException(
status_code=400,
detail="session already has a runner bound",
)
# Persist the validated, canonical workspace (the worktree path
# when a worktree was created) alongside host_id, plus git_branch
# when branching, so the conversation row satisfies
# ck_conversations_workspace_required_for_host. ``workspace`` is the
# realpath returned by validate_workspace (W6), or body.workspace
# verbatim only in non-production wiring without an agent cache.
await asyncio.to_thread(
conversation_store.set_host_id,
body.session_id,
host_id,
workspace,
git_branch,
)
request_id = secrets.token_hex(8)
future: asyncio.Future[dict[str, str | None]] = asyncio.get_running_loop().create_future()
conn.pending_launches[request_id] = future
# Resolve the agent's harness so the host can refuse an
# unconfigured one before spawning (mirrors POST /v1/sessions).
# None — no agent cache wired, or no resolvable agent — skips
# the host-side check.
harness: str | None = None
if agent_store is not None and agent_cache is not None:
harness = await _resolve_agent_harness(target.conv, agent_store, agent_cache)
launch_frame = encode_host_frame(
HostLaunchRunnerFrame(
request_id=request_id,
binding_token=binding_token,
workspace=workspace,
session_id=body.session_id,
harness=harness,
)
)
try:
host_registry.send_text(conn, launch_frame)
except ConnectionError:
conn.pending_launches.pop(request_id, None)
await _rollback_failed_launch()
raise HTTPException(
status_code=409,
detail="host connection was replaced",
) from None
try:
result = await asyncio.wait_for(
future,
timeout=_LAUNCH_RESULT_TIMEOUT_S,
)
except asyncio.TimeoutError:
conn.pending_launches.pop(request_id, None)
await _rollback_failed_launch()
raise HTTPException(
status_code=504,
detail="host did not respond to launch request",
) from None
if result.get("status") == "failed":
await _rollback_failed_launch()
if result.get("error_code") == HARNESS_NOT_CONFIGURED_ERROR_CODE:
# Categorical refusal: the harness isn't configured on
# the host, so a retry can't succeed without user action
# (`omnigent setup` on the host machine). Surface the
# specific code (412) instead of the generic 502.
raise OmnigentError(
f"host failed to launch runner: {result.get('error')}",
code=ErrorCode.HARNESS_NOT_CONFIGURED,
)
raise HTTPException(
status_code=502,
detail=f"host failed to launch runner: {result.get('error')}",
)
return {
"runner_id": runner_id,
"status": "launching",
}
@router.get("/hosts/{host_id}/filesystem")
async def list_host_filesystem_root(
request: Request,
host_id: str,
limit: int = Query(default=_LIST_DIR_DEFAULT_LIMIT, ge=1, le=_LIST_DIR_MAX_LIMIT),
after: str | None = Query(default=None),
before: str | None = Query(default=None),
) -> dict[str, Any]:
"""
List the contents of the host daemon's home directory.
Empty trailing path → forward ``~`` to the host (the host
expands against its own process owner). Used by the
Web UI's directory picker to show the "root" view.
:param request: FastAPI request (for auth).
:param host_id: Host identifier, e.g.
``"host_a1b2c3d4..."``.
:param limit: Max entries per page.
:param after: Optional forward pagination cursor (entry
path), e.g. ``"/Users/corey/projects/m"``.
:param before: Optional backward pagination cursor.
:returns: ``{"object": "list", "data": [...], "has_more": bool}``
mirroring the existing session-scoped filesystem
endpoint shape.
:raises HTTPException: 404 if host not found, 403 if not
owned by caller, 409 if host is offline, 504 on host
timeout, 502 on host I/O failure.
"""
return await _list_host_filesystem(
request=request,
host_id=host_id,
path="~",
limit=limit,
after=after,
before=before,
)
@router.get("/hosts/{host_id}/filesystem/{path:path}")
async def list_host_filesystem(
request: Request,
host_id: str,
path: str,
limit: int = Query(default=_LIST_DIR_DEFAULT_LIMIT, ge=1, le=_LIST_DIR_MAX_LIMIT),
after: str | None = Query(default=None),
before: str | None = Query(default=None),
) -> dict[str, Any]:
"""
List the contents of a directory on a host.
Used by the Web UI's directory picker (and stat-style
existence checks) to render the host's filesystem before
any runner exists. Owner-scoped: only the host owner can
browse. NOT scoped to a session — this endpoint exposes
the entire host filesystem to the authenticated host owner
per ``designs/SESSION_WORKSPACE_SELECTION.md`` "Security
surface".
:param request: FastAPI request (for auth).
:param host_id: Host identifier.
:param path: Absolute path on the host (e.g.
``"/Users/corey/universe"``) OR a tilde-prefixed
path (``"~/foo"``). The host expands ``~`` itself.
FastAPI's ``:path`` converter strips the leading
``/`` from the URL, so we re-add it for absolute paths.
:param limit: Max entries per page.
:param after: Optional forward pagination cursor.
:param before: Optional backward pagination cursor.
:returns: ``{"object": "list", "data": [...], "has_more": bool}``.
:raises HTTPException: 404 (host or path missing), 403
(not owner), 409 (offline), 400 (path validation),
504 (timeout), 502 (host I/O).
"""
# FastAPI's :path converter strips the leading slash from
# the URL match. Re-add it unless the path is tilde-prefixed
# (~/foo stays tilde-prefixed; /Users/x becomes Users/x → /Users/x).
if not path.startswith("~"):
path = "/" + path
return await _list_host_filesystem(
request=request,
host_id=host_id,
path=path,
limit=limit,
after=after,
before=before,
)
async def _list_host_filesystem(
*,
request: Request,
host_id: str,
path: str,
limit: int,
after: str | None,
before: str | None,
) -> dict[str, Any]:
"""
Shared implementation for the filesystem endpoints.
Authorizes (owner check), looks up the live host, validates
the path shape, sends ``host.list_dir``, and returns the
result in the runner-compatible response shape.
:param request: FastAPI request (for auth).
:param host_id: Host identifier.
:param path: Already-normalized path (absolute or tilde).
:param limit: Max entries.
:param after: Forward cursor.
:param before: Backward cursor.
:returns: Listing dict with ``object``, ``data``, ``has_more``.
:raises HTTPException: See per-route docstrings for codes.
"""
# require_user: unauthenticated callers 401 instead of slipping
# past the owner check below as None (see get_host above).
user_id = require_user(request, auth_provider)
# Owner check: load the host record, fail with 404 if it
# doesn't exist (don't leak existence to non-owners), fail
# with 403 only when an authenticated caller doesn't own it.
host = await asyncio.to_thread(host_store.get_host, host_id)
if host is None:
raise HTTPException(status_code=404, detail="host not found")
if user_id is not None and host.user_id != user_id:
raise HTTPException(status_code=403, detail="not your host")
if "\x00" in path:
raise HTTPException(
status_code=400,
detail="path must not contain NUL bytes",
)
conn = host_registry.get(host.host_id)
if conn is None:
raise _host_absent_error(host)
result = await _proxy_list_dir(
host_registry=host_registry,
host_conn=conn,
path=path,
limit=limit,
after=after,
before=before,
)
if result.get("status") == "failed":
# Unexpected I/O failure on the host.
raise HTTPException(
status_code=502,
detail=f"host list_dir failed: {result.get('error') or 'unknown error'}",
)
# Missing path (host returned ok with an error message) maps
# to 404 so the Web UI can distinguish "browse a path that
# doesn't exist" from "host is broken".
if result.get("error") and not result.get("entries"):
raise HTTPException(
status_code=404,
detail=str(result.get("error")),
)
# Shape mirrors GET /v1/sessions/{id}/resources/environments/default/filesystem
# so the Web UI can reuse fetchWorkspaceDirectory etc.
return {
"object": "list",
"data": result.get("entries", []),
"has_more": bool(result.get("has_more", False)),
}
@router.post("/hosts/{host_id}/directories")
async def create_host_directory(
request: Request,
host_id: str,
body: CreateDirectoryRequest,
) -> dict[str, Any]:
"""
Create a new directory on a host.
Backs the Web UI workspace picker's "New folder" action so a
user can make a fresh directory to start a session in without
dropping to a terminal. Owner-scoped exactly like the
filesystem browse endpoints (``GET /v1/hosts/{id}/filesystem``):
only the host owner can create directories, and — like browse —
this is NOT scoped to a session. The workspace-boundary check
still runs at session-create time, so creating a directory here
does not by itself grant an agent access to it.
:param request: FastAPI request (for auth).
:param host_id: Host identifier, e.g. ``"host_a1b2c3d4..."``.
:param body: Request body carrying the absolute (or
tilde-prefixed) ``path`` to create.
:returns: ``{"object": "directory", "path": "<created abs path>"}``.
:raises HTTPException: 404 if host not found, 403 if not owned
by caller, 409 if host is offline or the directory could not
be created (already exists / permission denied), 400 on path
validation, 504 on host timeout, 502 on host I/O failure.
"""
# require_user: unauthenticated callers 401 instead of slipping
# past the owner check below as None.
user_id = require_user(request, auth_provider)
host = await asyncio.to_thread(host_store.get_host, host_id)
if host is None:
raise HTTPException(status_code=404, detail="host not found")
if user_id is not None and host.user_id != user_id:
raise HTTPException(status_code=403, detail="not your host")
path = body.path
if not path.strip():
raise HTTPException(status_code=400, detail="path must not be empty")
if "\x00" in path:
raise HTTPException(
status_code=400,
detail="path must not contain NUL bytes",
)
# Absolute or tilde-prefixed only — the host needs a path it can
# resolve on its own; a relative path has no stable meaning here.
if not path.startswith(("/", "~")):
raise HTTPException(
status_code=400,
detail="path must be absolute or tilde-prefixed",
)
conn = host_registry.get(host.host_id)
if conn is None:
raise _host_absent_error(host)
result = await _proxy_create_dir(
host_registry=host_registry,
host_conn=conn,
path=path,
)
if result.get("status") == "failed":
# Unexpected I/O failure on the host.
raise HTTPException(
status_code=502,
detail=f"host create_dir failed: {result.get('error') or 'unknown error'}",
)
# Expected filesystem error (already exists / permission denied /
# parent is a file) → 409 Conflict with the host's message, so
# the picker can show "directory already exists" inline.
if result.get("error"):
raise HTTPException(
status_code=409,
detail=str(result.get("error")),
)
return {
"object": "directory",
"path": result.get("path"),
}
@router.post("/hosts/{host_id}/harnesses/{harness}/install")
async def install_host_harness(
request: Request,
host_id: str,
harness: str,
) -> dict[str, Any]:
"""
Install a missing, npm-installable harness CLI onto a host.
Backs the Web UI's New Chat dialog "Install" action so a user can
install a harness the connected host is missing without dropping to a
terminal. Owner-scoped like the other host actions: only the host owner
may install onto it. Scoped to the UI-installable allowlist (claude,
codex, pi, opencode, qwen) — curl/brew and interactive-auth harnesses
are refused. The whole route is gated behind
``harness_install`` in ``OMNIGENT_FEATURES`` (default off): when
disabled it returns 404 so the feature is invisible until opted in.
Concurrent requests for the same (host, harness) coalesce onto one
in-flight install so a double-click can't fire two global npm installs.
:param request: FastAPI request (for auth).
:param host_id: Host identifier, e.g. ``"host_a1b2c3d4..."``.
:param harness: Harness identifier to install, e.g. ``"claude"``.
:returns: ``{"object": "harness_install", "harness": ...,
"configured_harnesses": {...}, "gateway_inference": {...} | None}``
— the host's refreshed readiness map so the UI can flip the badge
without a reconnect, plus its refreshed gateway-inference map
(``None`` when the host didn't report one).
:raises HTTPException: 404 when the feature is disabled or the host is
unknown, 400 when the harness is not UI-installable, 403 when the
caller is not the host owner, 409 when the host is offline, 502 on
a host-side install failure, 504 on host timeout.
"""
# A disabled route is indistinguishable from a non-existent one, so
# the feature is fully dark until the deployment opts in.
if not flags.enabled(Feature.HARNESS_INSTALL):
raise HTTPException(status_code=404, detail="not found")
# Allowlist (400) is checked before the ownership check (403) so error
# codes can't be used to enumerate host ownership. Never trust the
# client: the server is the source of truth for what is installable.
if harness not in ui_installable_harnesses():
raise HTTPException(
status_code=400,
detail=f"harness {harness!r} is not installable from the UI",
)
# require_user: unauthenticated callers 401 instead of slipping past
# the owner check below as None.
user_id = require_user(request, auth_provider)
host = await asyncio.to_thread(host_store.get_host, host_id)
if host is None:
raise HTTPException(status_code=404, detail="host not found")
if user_id is not None and host.user_id != user_id:
raise HTTPException(status_code=403, detail="not your host")
conn = host_registry.get(host.host_id)
if conn is None:
raise _host_absent_error(host)
# Coalesce concurrent installs of the same harness FAMILY onto one
# in-flight request so a double-click (or `codex` + `codex-native`, which
# resolve to the same npm package) can't launch two global npm installs
# (npm's global writes aren't race-safe). Keyed on the resolved install
# key, not the raw spelling. The map lives on the connection, so it's
# discarded when the host disconnects.
#
# Cleanup is tied to the task's completion (add_done_callback), not the
# awaiter, and every caller awaits under a shield: if this request is
# cancelled (client disconnect) mid-install, the shared task keeps
# running to completion and stays in the map, so a follow-up request
# coalesces onto it instead of starting a second npm install.
install_key = ui_install_key(harness) or harness
existing = conn.inflight_installs.get(install_key)
if existing is None:
task = asyncio.create_task(
_proxy_install_harness(
host_registry=host_registry,
host_conn=conn,
harness=harness,
)
)
conn.inflight_installs[install_key] = task
task.add_done_callback(lambda _t: conn.inflight_installs.pop(install_key, None))
existing = task
result = await asyncio.shield(existing)
if result.get("status") == "failed":
raise HTTPException(
status_code=502,
detail=f"host install failed: {result.get('error') or 'unknown error'}",
)
# An install can flip gateway backing (a freshly installed CLI now
# resolves the workspace gateway), so take the map the host just
# recomputed instead of waiting for its next readiness push.
# Decoded through the same tolerant reader the tunnel path uses: this
# is a host-supplied reply body, so a non-mapping is "unknown", not a
# 500 out of ``dict(...)``.
installed_gateway = optional_str_bool_map(result, "gateway_inference")
if installed_gateway is not None:
host_registry.record_gateway_inference(host.host_id, installed_gateway)
return {
"object": "harness_install",
"harness": harness,
"configured_harnesses": result.get("configured_harnesses") or {},
# Passed through as-is: ``None`` is "unknown", not "none backed".
"gateway_inference": installed_gateway,
}
@router.post("/hosts/{host_id}/harnesses/{harness}/credential")
async def store_host_harness_credential(
request: Request,
host_id: str,
harness: str,
body: StoreHarnessCredentialRequest,
) -> dict[str, Any]:
"""
Write a harness provider credential onto a connected host.
Backs the Web UI setup dialog's "Add a credential" action so a user can
configure a Claude / Codex / Pi credential on a connected host without a
terminal. Owner-scoped, allowlisted, and gated behind
``harness_install`` release feature exactly like the install route
(404 when disabled). The host daemon does the write with the same
non-interactive core the ``omnigent setup`` wizard uses.
Security: the server is an authz'd pass-through — it validates
ownership + the allowlist and forwards the secret over the (TLS) tunnel;
it never persists the secret or logs it. The secret rides in the request
body (not the URL), and the frame field is redaction-named so it never
lands on a telemetry span.
:param request: FastAPI request (for auth).
:param host_id: Host identifier.
:param harness: Harness being configured, e.g. ``"claude"``.
:param body: The credential payload (kind + secret / gateway / adopt).
:returns: ``{"object": "harness_credential", "harness": ...,
"configured_harnesses": {...}, "gateway_inference": {...} | None}``
— refreshed readiness so the UI can flip the badge without a
reconnect, plus the refreshed gateway-inference map (``None`` when
the host didn't report one).
:raises HTTPException: 404 when disabled or host unknown, 400 when the
harness isn't UI-configurable or the body is invalid, 403 when not
the owner, 409 when offline, 502 on host-side failure, 504 on
timeout.
"""
if not flags.enabled(Feature.HARNESS_INSTALL):
raise HTTPException(status_code=404, detail="not found")
# Allowlist before ownership (403) so error codes can't enumerate
# ownership. Gate on the credential-CONFIGURABLE set (Claude/Codex/Pi),
# not merely installable — opencode/qwen are installable but env-auth,
# so the host can't write a credential for them. Rejecting here gives a
# clean 400 instead of forwarding a frame the host bounces as a 502.
if harness not in ui_credential_configurable_harnesses():
raise HTTPException(
status_code=400,
detail=f"harness {harness!r} is not configurable from the UI",
)
if body.kind not in ("key", "gateway", "adopt"):
raise HTTPException(status_code=400, detail=f"unknown credential kind {body.kind!r}")
user_id = require_user(request, auth_provider)
host = await asyncio.to_thread(host_store.get_host, host_id)
if host is None:
raise HTTPException(status_code=404, detail="host not found")
if user_id is not None and host.user_id != user_id:
raise HTTPException(status_code=403, detail="not your host")
conn = host_registry.get(host.host_id)
if conn is None:
raise _host_absent_error(host)
frame = HostStoreSecretFrame(
request_id=secrets.token_hex(8),
harness=harness,
kind=body.kind,
secret_value=body.secret,
base_url=body.base_url,
default_model=body.default_model,
wire_api=body.wire_api,
env_var=body.env_var,
)
# Serialize credential writes to this host: the daemon's write is a
# non-atomic load→merge→save of config.yaml (twice — entry, then
# default), so two overlapping writes (a double-click, or key + gateway
# in quick succession) could interleave and clobber a sibling providers:
# entry. The lock lives on the connection, so it's discarded when the
# host disconnects.
async with conn.credential_write_lock:
result = await _proxy_store_secret(
host_registry=host_registry,
host_conn=conn,
frame=frame,
)
if result.get("status") == "failed":
# The host's reason is non-secret (validation / write failure).
raise HTTPException(
status_code=502,
detail=f"host credential write failed: {result.get('error') or 'unknown error'}",
)
# Pointing a family at the workspace gateway is exactly what this write
# does, so record the recomputed map now rather than on the host's next
# readiness push.
# Same tolerant decode as the install route above.
written_gateway = optional_str_bool_map(result, "gateway_inference")
if written_gateway is not None:
host_registry.record_gateway_inference(host.host_id, written_gateway)
return {
"object": "harness_credential",
"harness": harness,
"configured_harnesses": result.get("configured_harnesses") or {},
# Passed through as-is: ``None`` is "unknown", not "none backed".
"gateway_inference": written_gateway,
}
@router.get("/hosts/{host_id}/credentials/detected")
async def detect_host_credentials(
request: Request,
host_id: str,
) -> dict[str, Any]:
"""List adoptable credentials already present on a connected host.
Backs the setup dialog's "adopt an existing credential" affordance: the
host reports which UI-auth-family credentials it already has as
NON-secret descriptors (family + source label + env var name), so the UI
can offer a one-click "Use it". Owner-scoped and flag-gated like the
credential-write route (404 when disabled). Never returns a secret value.
:param request: FastAPI request (for auth).
:param host_id: Host identifier.
:returns: ``{"object": "detected_credentials", "credentials": [...]}``.
:raises HTTPException: 404 when disabled or host unknown, 403 when not
the owner, 409 when offline, 502/504 on host failure/timeout.
"""
if not flags.enabled(Feature.HARNESS_INSTALL):
raise HTTPException(status_code=404, detail="not found")
user_id = require_user(request, auth_provider)
host = await asyncio.to_thread(host_store.get_host, host_id)
if host is None:
raise HTTPException(status_code=404, detail="host not found")
if user_id is not None and host.user_id != user_id:
raise HTTPException(status_code=403, detail="not your host")
conn = host_registry.get(host.host_id)
if conn is None:
raise _host_absent_error(host)
result = await _proxy_detect_credentials(host_registry=host_registry, host_conn=conn)
return {
"object": "detected_credentials",
"credentials": result.get("credentials") or [],
}
@router.get("/hosts/{host_id}/worktrees")
async def list_host_worktrees(
request: Request,
host_id: str,
path: str = Query(...),
) -> dict[str, Any]:
"""
List the git worktrees of a repository on a host.
Used by the Web UI's new-session worktree picker to show the
worktrees a session can start in directly. Owner-scoped exactly
like the filesystem browse endpoints; NOT scoped to a session.
A path that is not a git repository is reported as 400 so the
picker can quietly fall back to "no worktrees".
:param request: FastAPI request (for auth).
:param host_id: Host identifier, e.g. ``"host_a1b2c3d4..."``.
:param path: Absolute path inside the repo on the host to list
worktrees for, e.g. ``"/Users/alice/myrepo"``.
:returns: ``{"object": "list", "data": [{path, branch,
is_main, detached}, ...]}`` (main first).
:raises HTTPException: 404 if host not found, 403 if not owned
by caller, 409 if host is offline/unresponsive, 400 on path
validation or a non-git path.
"""
from omnigent.server.routes._host_worktree import (
WorktreeHostUnavailableError,
WorktreeProxyError,
list_worktrees_on_host,
)
# require_user: unauthenticated callers 401 instead of slipping
# past the owner check below as None.
user_id = require_user(request, auth_provider)
host = await asyncio.to_thread(host_store.get_host, host_id)
if host is None:
raise HTTPException(status_code=404, detail="host not found")
if user_id is not None and host.user_id != user_id:
raise HTTPException(status_code=403, detail="not your host")
if not path.strip():
raise HTTPException(status_code=400, detail="path must not be empty")
if "\x00" in path:
raise HTTPException(status_code=400, detail="path must not contain NUL bytes")
conn = host_registry.get(host.host_id)
if conn is None:
raise _host_absent_error(host)
try:
worktrees = await list_worktrees_on_host(
host_registry=host_registry,
host_conn=conn,
repo_path=path,
)
except WorktreeHostUnavailableError as exc:
raise HTTPException(status_code=409, detail=exc.message) from exc
except WorktreeProxyError as exc:
# Not a git repo / git failure — user-correctable; the picker
# treats this as "no worktrees here".
raise HTTPException(status_code=400, detail=exc.message) from exc
return {"object": "list", "data": worktrees}
return router