"""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, HostLaunchRunnerFrame, HostListDirFrame, encode_host_frame, ) 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.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 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 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) 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 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, ) -> 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``. :returns: A FastAPI router with host endpoints. """ 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. """ # 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.owner, "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, } ) 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. :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.owner != 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.owner, "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, "runners": [], } @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.owner != 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 HTTPException(status_code=409, detail="host is offline") 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": ""}``. :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.owner != 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 HTTPException(status_code=409, detail="host is offline") 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.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.owner != 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 HTTPException(status_code=409, detail="host is offline") 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