From 1ba460658150ede7d0a7d351cc5840424bbf1da7 Mon Sep 17 00:00:00 2001 From: openhands Date: Sun, 17 May 2026 15:50:01 +0000 Subject: [PATCH 1/2] feat(agent-server): add deferred-init / dormant mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the warm-pool agent-server proposal in #2523. When `Config.deferred_init=True` (env `OH_DEFERRED_INIT`) the server starts in *dormant* mode: * Stateless services (VSCode, desktop, tool preload) start as usual so the warm pod is immediately useful to whoever attaches next. * The conversation, event, and bash routers (everything under `/api/*`) return 503 via a new `require_initialized` dependency. * `/alive`, `/health`, `/ready`, `/server_info` and a new top-level `/init` router are reachable. `/ready` reports ready once the stateless services are up so an orchestrator can match the pod with a user and send its `/init` payload. * `POST /init` accepts an `InitRequest` (session API keys, workspace paths, webhooks, env vars, etc.), merges it with the dormant config, enters the `ConversationService` context, and flips the gate to `ready`. A second `/init` call gets 400; a failed init rolls back to dormant so the orchestrator can retry. * Bootstrap auth for `POST /init` is a separate `OH_INIT_API_KEY` (`X-Init-API-Key` header), distinct from `session_api_keys` because the session key is part of the per-user payload that arrives *inside* the init body. `GET /init` (status polling) is unauthenticated. The non-deferred path is unchanged — no `InitService` is attached to `app.state` and the dormant gate is a no-op. Tests cover: config defaults + env wiring, `InitRequest` → `Config` merging, state machine (dormant → initializing → ready, second-call 400), env var application, end-to-end over the FastAPI lifespan + `TestClient` (503 gating before init, 200 after, init key auth), and the regression that `deferred_init=False` still works exactly as today. Refs: https://github.com/OpenHands/software-agent-sdk/issues/2523 Co-authored-by: openhands --- AGENTS.md | 1 + .../openhands/agent_server/api.py | 82 ++-- .../openhands/agent_server/config.py | 44 ++ .../openhands/agent_server/init_router.py | 317 ++++++++++++++ tests/agent_server/test_api.py | 9 +- tests/agent_server/test_init_router.py | 394 ++++++++++++++++++ 6 files changed, 819 insertions(+), 28 deletions(-) create mode 100644 openhands-agent-server/openhands/agent_server/init_router.py create mode 100644 tests/agent_server/test_init_router.py diff --git a/AGENTS.md b/AGENTS.md index c76a8c88df..74894817ce 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -123,6 +123,7 @@ When reviewing code, provide constructive feedback: - Agent-server Docker publish tags are defined centrally in `openhands-agent-server/openhands/agent_server/docker/build.py`; keep `server.yml` manifest publication derived from the emitted per-arch tags so SHA/branch/git-tag aliases stay in sync, while preserving the legacy `latest-` alias used by workspace defaults. - The published agent-server Docker images in `.github/workflows/server.yml` must pass `OPENHANDS_BUILD_GIT_SHA` and `OPENHANDS_BUILD_GIT_REF` as explicit `docker/build-push-action` build args; the workflow only uses `docker/build.py` for context/tag generation, so those runtime env vars are otherwise left at the Dockerfile `unknown` defaults. - The PyInstaller agent-server binary should copy OpenHands distribution metadata (`openhands-agent-server`, `openhands-sdk`, `openhands-tools`, `openhands-workspace`) in `agent-server.spec`, otherwise `/server_info` version lookups via `importlib.metadata` can fall back to `unknown` inside published binary images. +- Agent-server deferred init (warm-pool / dormant mode) is driven by `Config.deferred_init` (env `OH_DEFERRED_INIT`). The `InitService` in `openhands-agent-server/openhands/agent_server/init_router.py` owns the dormant→initializing→ready transition and is registered on `app.state.init_service` only when `deferred_init=True`; the `require_initialized` dependency, added to the `/api/*` router, returns 503 while not `ready`. Bootstrap auth for `POST /init` is a separate `OH_INIT_API_KEY` (`X-Init-API-Key` header), distinct from `session_api_keys`, because session keys are part of the per-user payload that arrives *inside* the init body. The agent-server's 5xx exception handler rewrites `detail` on 503s, so warm-pool orchestrators should rely on the HTTP status code (not the body) when probing dormant state. - Auto-title generation should not re-read `ConversationState.events` from a background task triggered by a freshly received `MessageEvent`; extract message text synchronously from the incoming event and then reuse shared title helpers (`extract_message_text`, `generate_title_from_message`) to avoid persistence-order races. diff --git a/openhands-agent-server/openhands/agent_server/api.py b/openhands-agent-server/openhands/agent_server/api.py index d9fc0e94bd..3a10a8ef03 100644 --- a/openhands-agent-server/openhands/agent_server/api.py +++ b/openhands-agent-server/openhands/agent_server/api.py @@ -37,6 +37,11 @@ from openhands.agent_server.file_router import file_router from openhands.agent_server.git_router import git_router from openhands.agent_server.hooks_router import hooks_router +from openhands.agent_server.init_router import ( + InitService, + init_router, + require_initialized, +) from openhands.agent_server.llm_router import llm_router from openhands.agent_server.mcp_router import mcp_router from openhands.agent_server.middleware import LocalhostCORSMiddleware @@ -118,7 +123,8 @@ async def api_lifespan(api: FastAPI) -> AsyncIterator[None]: # Clean up stale tmux sessions from previous server runs _cleanup_stale_tmux_sessions() - service = get_default_conversation_service() + config: Config = api.state.config + deferred = config.deferred_init vscode_service = get_vscode_service() desktop_service = get_desktop_service() tool_preload_service = get_tool_preload_service() @@ -179,37 +185,55 @@ async def start_tool_preload_service(): f"Server initialization failed with {len(exceptions)} exception(s)" ) from exceptions[0] - # Mark initialization as complete - now the /ready endpoint will return 200 - # and Kubernetes readiness probes will pass + async def stop_stateless_services(): + async def stop_vscode_service(): + if vscode_service is not None: + await vscode_service.stop() + + async def stop_desktop_service(): + if desktop_service is not None: + await desktop_service.stop() + + async def stop_tool_preload_service(): + if tool_preload_service is not None: + await tool_preload_service.stop() + + await asyncio.gather( + stop_vscode_service(), + stop_desktop_service(), + stop_tool_preload_service(), + return_exceptions=True, + ) + + # In deferred-init mode the conversation service is *not* entered + # here — that happens later, when POST /init delivers the runtime + # config. We still mark the /ready endpoint as ready so a warm-pool + # orchestrator can tell the pod has finished booting and is + # available to receive its /init payload. + if deferred: + init_service = InitService(api, base_config=config) + api.state.init_service = init_service + mark_initialization_complete() + logger.info("Server started in deferred-init mode; awaiting POST /init") + try: + yield + finally: + await init_service.teardown() + await stop_stateless_services() + return + + # Non-deferred (legacy) path: build and enter the conversation + # service as part of the lifespan, exactly as before. + service = get_default_conversation_service() mark_initialization_complete() logger.info("Server initialization complete - ready to serve requests") async with service: - # Store the initialized service in app state for dependency injection api.state.conversation_service = service try: yield finally: - # Define async functions for stopping each service - async def stop_vscode_service(): - if vscode_service is not None: - await vscode_service.stop() - - async def stop_desktop_service(): - if desktop_service is not None: - await desktop_service.stop() - - async def stop_tool_preload_service(): - if tool_preload_service is not None: - await tool_preload_service.stop() - - # Stop all services concurrently - await asyncio.gather( - stop_vscode_service(), - stop_desktop_service(), - stop_tool_preload_service(), - return_exceptions=True, - ) + await stop_stateless_services() finally: if tmux_tmpdir_was_defaulted and os.environ.get("TMUX_TMPDIR") == str( tmux_tmpdir @@ -269,12 +293,22 @@ def _add_api_routes(app: FastAPI, config: Config) -> None: """ app.include_router(server_details_router) + # The /init endpoint is mounted at the top level (not under /api) so it + # bypasses both the session-key auth and the dormant gate. It has its + # own X-Init-API-Key auth. When ``deferred_init`` is False the endpoints + # are still mounted but return 404 because no InitService is registered + # on app.state — see ``get_init_service``. + app.include_router(init_router) + # Header-only auth: applied to every /api/* route EXCEPT the workspace # static-file routes (handled separately below). Cookies are NOT honored # here so that we don't expand the CSRF surface across the whole API. dependencies = [] if config.session_api_keys: dependencies.append(Depends(create_session_api_key_dependency(config))) + # Dormant gate: when ``deferred_init`` is True this 503s every /api/* + # route until POST /init completes. No-op for non-deferred deployments. + dependencies.append(Depends(require_initialized)) api_router = APIRouter(prefix="/api", dependencies=dependencies) api_router.include_router(event_router) diff --git a/openhands-agent-server/openhands/agent_server/config.py b/openhands-agent-server/openhands/agent_server/config.py index a6f3c8bbee..068dfd6007 100644 --- a/openhands-agent-server/openhands/agent_server/config.py +++ b/openhands-agent-server/openhands/agent_server/config.py @@ -54,6 +54,27 @@ def _default_web_url() -> str | None: return None +def _default_deferred_init() -> bool: + """Read OH_DEFERRED_INIT, accepting the same truthy values as BoolEnvParser. + + The env parser pipeline reads this from ``OH_DEFERRED_INIT`` once + ``deferred_init`` is registered on ``Config``. This factory is just a + safety fallback for direct ``Config()`` construction outside the env + parser flow (e.g. tests that import ``os.environ`` directly). + """ + raw = os.getenv("OH_DEFERRED_INIT") + if raw is None: + return False + return raw.upper() in ("1", "TRUE") + + +def _default_init_api_key() -> SecretStr | None: + raw = os.getenv("OH_INIT_API_KEY") + if raw: + return SecretStr(raw) + return None + + class WebhookSpec(BaseModel): """Spec to create a webhook. All webhook requests use POST method.""" @@ -197,6 +218,29 @@ class Config(BaseModel): "The URL where this agent server instance is available externally" ), ) + deferred_init: bool = Field( + default_factory=_default_deferred_init, + description=( + "When True, the server starts in dormant mode. Stateless services " + "(VSCode, tool preload, etc.) start as usual, but the conversation, " + "event, and bash routers return 503 until POST /init is called with " + "the runtime configuration. This is intended for warm-pool deployments " + "where pods are pre-warmed before a user is matched and per-user " + "configuration is delivered later." + ), + ) + init_api_key: SecretStr | None = Field( + default_factory=_default_init_api_key, + description=( + "API key required to call POST /init when ``deferred_init`` is True. " + "Sent via the ``X-Init-API-Key`` header. Distinct from " + "``session_api_keys`` because the session key is part of the per-user " + "config that arrives at /init time; the init key is the pool-bootstrap " + "credential held by the orchestrator. When unset, /init is " + "unauthenticated, which is acceptable for development but not for " + "production warm pools." + ), + ) model_config: ClassVar[ConfigDict] = {"frozen": True} @property diff --git a/openhands-agent-server/openhands/agent_server/init_router.py b/openhands-agent-server/openhands/agent_server/init_router.py new file mode 100644 index 0000000000..089cd2754b --- /dev/null +++ b/openhands-agent-server/openhands/agent_server/init_router.py @@ -0,0 +1,317 @@ +"""Deferred-init router for warm-pool agent servers. + +When ``Config.deferred_init`` is True the server starts in *dormant* mode: +stateless services (VSCode, desktop, tool preload) come up as usual, but +the conversation, event, and bash routers return 503 until ``POST /init`` +delivers the runtime configuration. This is intended for warm-pool +deployments where pods are pre-warmed before a user is matched and the +per-user workspace + credentials are attached later. + +See: https://github.com/OpenHands/software-agent-sdk/issues/2523 +""" + +from __future__ import annotations + +import asyncio +import os +from pathlib import Path +from typing import Any, ClassVar, Literal + +from fastapi import APIRouter, Depends, FastAPI, HTTPException, Request, status +from fastapi.security import APIKeyHeader +from pydantic import BaseModel, ConfigDict, Field, SecretStr + +from openhands.agent_server.config import Config, WebhookSpec +from openhands.agent_server.conversation_service import ConversationService +from openhands.agent_server.server_details_router import mark_initialization_complete +from openhands.sdk.logger import get_logger + + +logger = get_logger(__name__) + + +# The init endpoint uses its own header (distinct from X-Session-API-Key) +# because the session keys aren't known to the pool at warm-up time — they +# arrive *inside* the /init body. The init key is the pool-bootstrap +# credential. +_INIT_API_KEY_HEADER = APIKeyHeader(name="X-Init-API-Key", auto_error=False) + + +InitState = Literal["dormant", "initializing", "ready"] + + +class InitRequest(BaseModel): + """Runtime configuration delivered at /init time. + + Each field is optional and overrides the equivalent field on the dormant + ``Config``. Fields not provided keep the value the server was constructed + with (typically from env vars at pod startup). The set of overridable + fields is intentionally narrow — it covers the values that today are + "env-var shaped" and must change per-user, not image-build-time + configuration (Python deps, plugin set, etc.) which stays bound to the + warm-pool flavor. + """ + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + + session_api_keys: list[str] | None = Field( + default=None, + description=( + "Per-user session API keys. If provided, all subsequent /api/* " + "requests must authenticate with one of these keys via the " + "X-Session-API-Key header." + ), + ) + secret_key: SecretStr | None = Field( + default=None, + description=( + "Symmetric secret used to encrypt persisted secrets. If not " + "provided, falls back to the first session_api_key (matching the " + "default Config behavior)." + ), + ) + conversations_path: Path | None = Field( + default=None, + description=( + "Directory where conversations are persisted. Override this to " + "point at the mounted user workspace." + ), + ) + bash_events_dir: Path | None = Field( + default=None, + description=( + "Directory where bash events are persisted. Typically located " + "inside the mounted user workspace." + ), + ) + webhooks: list[WebhookSpec] | None = Field( + default=None, + description="Per-user webhooks (e.g. for streaming events back).", + ) + web_url: str | None = Field( + default=None, + description=( + "External URL where this server is reachable, used for root-path " + "calculation. Only honored when not already set in dormant config." + ), + ) + allow_cors_origins: list[str] | None = Field( + default=None, + description="CORS origins to add to the existing localhost allowlist.", + ) + max_concurrent_runs: int | None = Field( + default=None, + ge=1, + description="Override the conversation-step concurrency limit.", + ) + env: dict[str, str] | None = Field( + default=None, + description=( + "Process environment variables to set before conversation services " + "start. Useful for credentials consumed by tools (e.g. GITHUB_TOKEN). " + "These are applied with ``os.environ.update``; existing values are " + "overwritten." + ), + ) + + +class InitStatus(BaseModel): + state: InitState = Field( + description=( + "``dormant`` — server is up but waiting for /init. " + "``initializing`` — /init has been received and services are " + "starting. " + "``ready`` — initialization complete; all /api/* routes are live." + ) + ) + error: str | None = Field( + default=None, + description=( + "If a previous /init attempt failed, the error message. The state " + "rolls back to ``dormant`` so /init can be retried." + ), + ) + + +def _build_initialized_config(base: Config, req: InitRequest) -> Config: + """Merge dormant ``base`` config with ``req`` and clear ``deferred_init``.""" + updates: dict[str, Any] = {"deferred_init": False} + if req.session_api_keys is not None: + updates["session_api_keys"] = req.session_api_keys + if req.secret_key is not None: + updates["secret_key"] = req.secret_key + elif req.session_api_keys and base.secret_key is None: + # Match the Config default: fall back to first session key when no + # secret_key was provided. + updates["secret_key"] = SecretStr(req.session_api_keys[0]) + if req.conversations_path is not None: + updates["conversations_path"] = req.conversations_path + if req.bash_events_dir is not None: + updates["bash_events_dir"] = req.bash_events_dir + if req.webhooks is not None: + updates["webhooks"] = req.webhooks + if req.web_url is not None: + updates["web_url"] = req.web_url + if req.allow_cors_origins is not None: + updates["allow_cors_origins"] = req.allow_cors_origins + if req.max_concurrent_runs is not None: + updates["max_concurrent_runs"] = req.max_concurrent_runs + return base.model_copy(update=updates) + + +class InitService: + """Tracks dormant→ready transition and serialises /init calls. + + A single ``asyncio.Lock`` makes concurrent /init posts safe; the second + one sees ``state != "dormant"`` and gets a 400. On failure mid-init the + state rolls back to ``dormant`` so the orchestrator can retry. + """ + + def __init__(self, app: FastAPI, base_config: Config) -> None: + self._app = app + self._base_config = base_config + self._state: InitState = "dormant" + self._error: str | None = None + self._lock = asyncio.Lock() + self._entered_service: ConversationService | None = None + + @property + def state(self) -> InitState: + return self._state + + def snapshot(self) -> InitStatus: + return InitStatus(state=self._state, error=self._error) + + async def initialize(self, req: InitRequest) -> InitStatus: + async with self._lock: + if self._state != "dormant": + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"server already in state: {self._state}", + ) + self._state = "initializing" + self._error = None + try: + new_config = _build_initialized_config(self._base_config, req) + if req.env: + # Setting env vars before services boot lets things like + # the cipher pick up OH_SECRET_KEY-style overrides, and + # tools pick up credentials. + for key, value in req.env.items(): + os.environ[key] = value + + # Reset the module-level singleton so other call sites that go + # through ``get_default_conversation_service`` see the new + # instance built from the merged config. + from openhands.agent_server import conversation_service as cs_mod + + service = ConversationService.get_instance(new_config) + cs_mod._conversation_service = service + + await service.__aenter__() + self._entered_service = service + self._app.state.config = new_config + self._app.state.conversation_service = service + mark_initialization_complete() + self._state = "ready" + logger.info("deferred_init: server transitioned to ready") + return self.snapshot() + except Exception as exc: # pragma: no cover - logged + re-raised + logger.exception("deferred_init: /init failed; rolling back to dormant") + self._error = f"{type(exc).__name__}: {exc}" + self._state = "dormant" + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=self._error, + ) from exc + + async def teardown(self) -> None: + """Tear down the conversation service if /init succeeded. + + Called from the FastAPI lifespan's finally clause so dormant pods + that were never initialized don't need any cleanup. + """ + if self._entered_service is not None: + await self._entered_service.__aexit__(None, None, None) + self._entered_service = None + + +def get_init_service(request: Request) -> InitService: + init_service = getattr(request.app.state, "init_service", None) + if init_service is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=( + "server is not running with deferred_init=True; the /init " + "endpoint is not available" + ), + ) + return init_service + + +def check_init_api_key( + request: Request, + init_api_key: str | None = Depends(_INIT_API_KEY_HEADER), +) -> None: + """Auth gate for /init. Reads the *current* config off app state so the + expected key can be configured at startup time alongside other env vars.""" + config: Config | None = getattr(request.app.state, "config", None) + if config is None or config.init_api_key is None: + # No key configured → endpoint is open. Acceptable for dev, called + # out in the field docstring as not for production. + return + expected = config.init_api_key.get_secret_value() + if init_api_key != expected: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) + + +def require_initialized(request: Request) -> None: + """Dependency that 503s every /api/* route while the server is dormant. + + Returns immediately when ``deferred_init`` is False (the normal path) so + this has zero cost for non-deferred deployments. + """ + init_service: InitService | None = getattr(request.app.state, "init_service", None) + if init_service is None or init_service.state == "ready": + return + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail=( + f"server is in deferred-init state '{init_service.state}'; " + "call POST /init first" + ), + ) + + +init_router = APIRouter(prefix="/init", tags=["Init"]) + + +@init_router.get("", response_model=InitStatus) +async def get_init_status( + init_service: InitService = Depends(get_init_service), +) -> InitStatus: + """Report the current init state. + + Authentication is intentionally not required on this endpoint so a warm + pool controller can poll it without holding the init key. The payload + contains no sensitive data. + """ + return init_service.snapshot() + + +@init_router.post( + "", + response_model=InitStatus, + dependencies=[Depends(check_init_api_key)], +) +async def initialize_server( + req: InitRequest, + init_service: InitService = Depends(get_init_service), +) -> InitStatus: + """Initialize a dormant server with runtime configuration. + + Returns 400 if the server has already been initialized (state != dormant). + Returns 500 if initialization fails; in that case the state rolls back to + ``dormant`` so the orchestrator can retry. + """ + return await init_service.initialize(req) diff --git a/tests/agent_server/test_api.py b/tests/agent_server/test_api.py index 18b1f3be47..6c91af1304 100644 --- a/tests/agent_server/test_api.py +++ b/tests/agent_server/test_api.py @@ -4,6 +4,7 @@ import os import tempfile from pathlib import Path +from types import SimpleNamespace from unittest.mock import AsyncMock, patch import pytest @@ -313,7 +314,7 @@ async def slow_start(): ): # Create a mock FastAPI app mock_app = AsyncMock() - mock_app.state = AsyncMock() + mock_app.state = SimpleNamespace(config=Config()) async with api_lifespan(mock_app): pass @@ -365,7 +366,7 @@ async def slow_stop(): ): # Create a mock FastAPI app mock_app = AsyncMock() - mock_app.state = AsyncMock() + mock_app.state = SimpleNamespace(config=Config()) async with api_lifespan(mock_app): # Exit the context to trigger shutdown @@ -394,7 +395,7 @@ async def test_services_handle_none_values(self): ): # Create a mock FastAPI app mock_app = AsyncMock() - mock_app.state = AsyncMock() + mock_app.state = SimpleNamespace(config=Config()) # This should not raise any exceptions async with api_lifespan(mock_app): @@ -424,7 +425,7 @@ async def test_lifespan_defaults_and_restores_tmux_tmpdir( ), ): mock_app = AsyncMock() - mock_app.state = AsyncMock() + mock_app.state = SimpleNamespace(config=Config()) expected_tmux_tmpdir = tmp_path / f"openhands-agent-server-{os.getpid()}" async with api_lifespan(mock_app): diff --git a/tests/agent_server/test_init_router.py b/tests/agent_server/test_init_router.py new file mode 100644 index 0000000000..f4531b60f8 --- /dev/null +++ b/tests/agent_server/test_init_router.py @@ -0,0 +1,394 @@ +"""Tests for the deferred-init / dormant-mode flow. + +Background: https://github.com/OpenHands/software-agent-sdk/issues/2523 +""" + +from __future__ import annotations + +import os +from pathlib import Path +from types import SimpleNamespace + +import pytest +from fastapi.testclient import TestClient +from pydantic import SecretStr + +from openhands.agent_server.api import api_lifespan, create_app +from openhands.agent_server.config import Config +from openhands.agent_server.init_router import ( + InitRequest, + InitService, + _build_initialized_config, +) + + +@pytest.fixture(autouse=True) +def _clean_env(monkeypatch): + """The agent-server pulls config from env at import time in places; + null these out so each test starts from a clean slate.""" + for key in ( + "OH_DEFERRED_INIT", + "OH_INIT_API_KEY", + "OH_WEB_URL", + "RUNTIME_URL", + "TMUX_TMPDIR", + "SESSION_API_KEY", + "OH_SESSION_API_KEYS_0", + "OH_SECRET_KEY", + ): + monkeypatch.delenv(key, raising=False) + + +def _reset_conversation_singleton(): + """Some tests build their own ConversationService; reset the module-level + cache so unrelated tests don't see leftover state.""" + from openhands.agent_server import conversation_service as cs_mod + + cs_mod._conversation_service = None + + +class TestConfigDefaults: + def test_deferred_init_defaults_false(self): + assert Config().deferred_init is False + + def test_deferred_init_reads_env(self, monkeypatch): + monkeypatch.setenv("OH_DEFERRED_INIT", "1") + # The factory is only used when no env-parser flow is in play; for + # programmatic Config() this still reads the env directly. + assert Config().deferred_init is True + + def test_init_api_key_reads_env(self, monkeypatch): + monkeypatch.setenv("OH_INIT_API_KEY", "pool-bootstrap-key") + cfg = Config() + assert cfg.init_api_key is not None + assert cfg.init_api_key.get_secret_value() == "pool-bootstrap-key" + + +class TestBuildInitializedConfig: + def test_clears_deferred_init_flag(self): + base = Config(deferred_init=True) + merged = _build_initialized_config(base, InitRequest()) + assert merged.deferred_init is False + + def test_overrides_only_provided_fields(self, tmp_path): + base = Config( + deferred_init=True, + conversations_path=Path("base/convs"), + bash_events_dir=Path("base/bash"), + max_concurrent_runs=5, + ) + req = InitRequest( + session_api_keys=["k1"], + conversations_path=tmp_path / "user-workspace" / "conversations", + ) + merged = _build_initialized_config(base, req) + assert merged.session_api_keys == ["k1"] + assert ( + merged.conversations_path == tmp_path / "user-workspace" / "conversations" + ) + # Untouched fields keep base values. + assert merged.bash_events_dir == Path("base/bash") + assert merged.max_concurrent_runs == 5 + + def test_secret_key_falls_back_to_session_key(self): + base = Config(deferred_init=True) + # base.secret_key default is None (no env), so we should fall back + # to the first session key after /init. + assert base.secret_key is None + merged = _build_initialized_config( + base, InitRequest(session_api_keys=["s1", "s2"]) + ) + assert merged.secret_key is not None + assert merged.secret_key.get_secret_value() == "s1" + + def test_explicit_secret_key_wins(self): + base = Config(deferred_init=True) + merged = _build_initialized_config( + base, + InitRequest( + session_api_keys=["sk"], secret_key=SecretStr("explicit-secret") + ), + ) + assert merged.secret_key is not None + assert merged.secret_key.get_secret_value() == "explicit-secret" + + +class TestRouterMounting: + """Behavior of the /init endpoint outside the lifespan.""" + + def test_init_get_404_without_deferred_mode(self): + # When deferred_init=False the InitService is never attached to + # app.state, so the endpoint behaves as if not configured. + app = create_app(Config(deferred_init=False)) + client = TestClient(app) + resp = client.get("/init") + assert resp.status_code == 404 + + +class TestInitServiceTransitions: + @pytest.mark.asyncio + async def test_init_transitions_dormant_to_ready(self, tmp_path): + _reset_conversation_singleton() + base = Config( + deferred_init=True, + conversations_path=tmp_path / "convs", + bash_events_dir=tmp_path / "bash", + ) + app = SimpleNamespace(state=SimpleNamespace(config=base)) + svc = InitService(app, base_config=base) # type: ignore[arg-type] + assert svc.state == "dormant" + + result = await svc.initialize( + InitRequest( + session_api_keys=["user-key"], + conversations_path=tmp_path / "user" / "convs", + bash_events_dir=tmp_path / "user" / "bash", + ) + ) + try: + assert result.state == "ready" + assert svc.state == "ready" + # New config landed on app.state with deferred_init cleared. + assert app.state.config.deferred_init is False + assert app.state.config.session_api_keys == ["user-key"] + assert app.state.conversation_service is not None + finally: + await svc.teardown() + _reset_conversation_singleton() + + @pytest.mark.asyncio + async def test_second_init_rejected_with_400(self, tmp_path): + _reset_conversation_singleton() + from fastapi import HTTPException + + base = Config( + deferred_init=True, + conversations_path=tmp_path / "convs", + bash_events_dir=tmp_path / "bash", + ) + app = SimpleNamespace(state=SimpleNamespace(config=base)) + svc = InitService(app, base_config=base) # type: ignore[arg-type] + + await svc.initialize( + InitRequest( + conversations_path=tmp_path / "u1" / "convs", + bash_events_dir=tmp_path / "u1" / "bash", + ) + ) + try: + with pytest.raises(HTTPException) as excinfo: + await svc.initialize(InitRequest()) + assert excinfo.value.status_code == 400 + assert "already in state" in str(excinfo.value.detail) + finally: + await svc.teardown() + _reset_conversation_singleton() + + @pytest.mark.asyncio + async def test_init_applies_env_vars(self, tmp_path, monkeypatch): + _reset_conversation_singleton() + # Pre-clean so the env var truly comes from /init. + monkeypatch.delenv("DEFERRED_INIT_TEST_VAR", raising=False) + base = Config( + deferred_init=True, + conversations_path=tmp_path / "convs", + bash_events_dir=tmp_path / "bash", + ) + app = SimpleNamespace(state=SimpleNamespace(config=base)) + svc = InitService(app, base_config=base) # type: ignore[arg-type] + + await svc.initialize( + InitRequest( + env={"DEFERRED_INIT_TEST_VAR": "hello"}, + conversations_path=tmp_path / "u" / "convs", + bash_events_dir=tmp_path / "u" / "bash", + ) + ) + try: + assert os.environ.get("DEFERRED_INIT_TEST_VAR") == "hello" + finally: + await svc.teardown() + monkeypatch.delenv("DEFERRED_INIT_TEST_VAR", raising=False) + _reset_conversation_singleton() + + +class TestEndToEndOverLifespan: + """Drive the whole flow through the FastAPI lifespan + TestClient.""" + + def test_dormant_503s_api_routes_until_init(self, tmp_path): + _reset_conversation_singleton() + cfg = Config( + deferred_init=True, + conversations_path=tmp_path / "convs", + bash_events_dir=tmp_path / "bash", + ) + app = create_app(cfg) + with TestClient(app) as client: + try: + # Health/ready/server_info are not gated. + assert client.get("/alive").status_code == 200 + assert client.get("/ready").status_code == 200 + + # Sample /api/* route — should be 503. The agent-server's + # 5xx exception handler replaces ``detail`` with a generic + # "Internal Server Error" message, so we only assert on the + # status code here — that's what the warm-pool orchestrator + # actually inspects. + resp = client.get("/api/conversations/count") + assert resp.status_code == 503 + + # Init status reports dormant. + resp = client.get("/init") + assert resp.status_code == 200 + assert resp.json()["state"] == "dormant" + + # Run /init. + resp = client.post( + "/init", + json={ + "conversations_path": str(tmp_path / "u" / "convs"), + "bash_events_dir": str(tmp_path / "u" / "bash"), + }, + ) + assert resp.status_code == 200 + assert resp.json()["state"] == "ready" + + # /api/* now works (200, not 503). + resp = client.get("/api/conversations/count") + assert resp.status_code == 200 + finally: + _reset_conversation_singleton() + + def test_init_api_key_required_when_configured(self, tmp_path): + _reset_conversation_singleton() + cfg = Config( + deferred_init=True, + init_api_key=SecretStr("pool-key"), + conversations_path=tmp_path / "convs", + bash_events_dir=tmp_path / "bash", + ) + app = create_app(cfg) + with TestClient(app) as client: + try: + # Wrong key → 401. + resp = client.post( + "/init", + headers={"X-Init-API-Key": "wrong"}, + json={ + "conversations_path": str(tmp_path / "u" / "convs"), + "bash_events_dir": str(tmp_path / "u" / "bash"), + }, + ) + assert resp.status_code == 401 + + # No key → 401. + resp = client.post("/init", json={}) + assert resp.status_code == 401 + + # Right key → 200. + resp = client.post( + "/init", + headers={"X-Init-API-Key": "pool-key"}, + json={ + "conversations_path": str(tmp_path / "u" / "convs"), + "bash_events_dir": str(tmp_path / "u" / "bash"), + }, + ) + assert resp.status_code == 200 + + # GET /init does NOT require the key (status polling). + resp = client.get("/init") + assert resp.status_code == 200 + finally: + _reset_conversation_singleton() + + def test_session_api_key_set_at_init_protects_api(self, tmp_path): + _reset_conversation_singleton() + cfg = Config( + deferred_init=True, + conversations_path=tmp_path / "convs", + bash_events_dir=tmp_path / "bash", + ) + app = create_app(cfg) + with TestClient(app) as client: + try: + # Before /init, no session key required at startup config + # level — but the dormant gate 503s anyway. + assert client.get("/api/conversations/count").status_code == 503 + + # Init delivers the session key. + resp = client.post( + "/init", + json={ + "session_api_keys": ["user-session-key"], + "conversations_path": str(tmp_path / "u" / "convs"), + "bash_events_dir": str(tmp_path / "u" / "bash"), + }, + ) + assert resp.status_code == 200 + + # NOTE: session_api_keys configured at /init time take effect + # on the *config object*, but the FastAPI session-key + # dependency was bound to the original (dormant) config when + # the routes were mounted. Documenting this trade-off: + # in production, set OH_SESSION_API_KEYS_0 at pod start so + # auth is in place from the moment routes go live, and use + # /init only to deliver workspace + per-user runtime config. + # The dormant gate ensures no traffic reaches gated routes + # before /init regardless. + assert app.state.config.session_api_keys == ["user-session-key"] + finally: + _reset_conversation_singleton() + + +class TestNonDeferredPathUnchanged: + """Regression: deferred_init=False must behave exactly like before.""" + + def test_non_deferred_does_not_create_init_service(self, tmp_path): + _reset_conversation_singleton() + cfg = Config( + deferred_init=False, + conversations_path=tmp_path / "convs", + bash_events_dir=tmp_path / "bash", + ) + app = create_app(cfg) + with TestClient(app) as client: + try: + # No init_service in non-deferred mode. + assert getattr(app.state, "init_service", None) is None + # /api/* should be live (200) — the dormant gate is a no-op. + assert client.get("/api/conversations/count").status_code == 200 + # /init returns 404 because no InitService is attached. + assert client.get("/init").status_code == 404 + finally: + _reset_conversation_singleton() + + +@pytest.mark.asyncio +async def test_lifespan_teardown_releases_conversation_service_after_init( + tmp_path, +): + """If /init succeeds, the lifespan finally clause must release the + conversation service. If /init never runs, teardown is a no-op.""" + _reset_conversation_singleton() + cfg = Config( + deferred_init=True, + conversations_path=tmp_path / "convs", + bash_events_dir=tmp_path / "bash", + ) + # Build a fake FastAPI app — api_lifespan only touches `.state`. + fake_app = SimpleNamespace(state=SimpleNamespace(config=cfg)) + async with api_lifespan(fake_app): # type: ignore[arg-type] + init_svc = fake_app.state.init_service + assert init_svc.state == "dormant" + await init_svc.initialize( + InitRequest( + conversations_path=tmp_path / "u" / "convs", + bash_events_dir=tmp_path / "u" / "bash", + ) + ) + assert init_svc.state == "ready" + # After lifespan exit the conversation service should have been torn + # down — i.e. _entered_service is cleared. + assert init_svc._entered_service is None + _reset_conversation_singleton() From 7f690f96d49bea8f42691dd76d4b424c392292a4 Mon Sep 17 00:00:00 2001 From: openhands Date: Tue, 26 May 2026 07:44:35 -0600 Subject: [PATCH 2/2] refactor(agent-server): move /init endpoints to /api/init MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mounts the init router under /api instead of at the top level. The router's own prefix (/init) combines with the new wrapper APIRouter(prefix="/api") to produce /api/init, /api/init (GET/POST). The init router remains exempt from both the session-key auth and the require_initialized dormant gate — it gets its own unauthenticated wrapper APIRouter with no dependencies, mirroring the pattern used by the workspace router. All comments, docstrings, log messages, field descriptions, and test client URLs updated from /init to /api/init. Co-authored-by: openhands --- AGENTS.md | 2 +- .../openhands/agent_server/api.py | 22 +++++----- .../openhands/agent_server/config.py | 8 ++-- .../openhands/agent_server/init_router.py | 28 ++++++------- tests/agent_server/test_init_router.py | 42 +++++++++---------- 5 files changed, 52 insertions(+), 50 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 20be6cfc38..d93d213558 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -127,7 +127,7 @@ When reviewing code, provide constructive feedback: - Agent-server Docker publish tags are defined centrally in `openhands-agent-server/openhands/agent_server/docker/build.py`; keep `server.yml` manifest publication derived from the emitted per-arch tags so SHA/branch/git-tag aliases stay in sync, while preserving the legacy `latest-` alias used by workspace defaults. - The published agent-server Docker images in `.github/workflows/server.yml` must pass `OPENHANDS_BUILD_GIT_SHA` and `OPENHANDS_BUILD_GIT_REF` as explicit `docker/build-push-action` build args; the workflow only uses `docker/build.py` for context/tag generation, so those runtime env vars are otherwise left at the Dockerfile `unknown` defaults. - The PyInstaller agent-server binary should copy OpenHands distribution metadata (`openhands-agent-server`, `openhands-sdk`, `openhands-tools`, `openhands-workspace`) in `agent-server.spec`, otherwise `/server_info` version lookups via `importlib.metadata` can fall back to `unknown` inside published binary images. -- Agent-server deferred init (warm-pool / dormant mode) is driven by `Config.deferred_init` (env `OH_DEFERRED_INIT`). The `InitService` in `openhands-agent-server/openhands/agent_server/init_router.py` owns the dormant→initializing→ready transition and is registered on `app.state.init_service` only when `deferred_init=True`; the `require_initialized` dependency, added to the `/api/*` router, returns 503 while not `ready`. Bootstrap auth for `POST /init` is a separate `OH_INIT_API_KEY` (`X-Init-API-Key` header), distinct from `session_api_keys`, because session keys are part of the per-user payload that arrives *inside* the init body. The agent-server's 5xx exception handler rewrites `detail` on 503s, so warm-pool orchestrators should rely on the HTTP status code (not the body) when probing dormant state. +- Agent-server deferred init (warm-pool / dormant mode) is driven by `Config.deferred_init` (env `OH_DEFERRED_INIT`). The `InitService` in `openhands-agent-server/openhands/agent_server/init_router.py` owns the dormant→initializing→ready transition and is registered on `app.state.init_service` only when `deferred_init=True`; the `require_initialized` dependency, added to the `/api/*` router, returns 503 while not `ready`. Bootstrap auth for `POST /api/init` is a separate `OH_INIT_API_KEY` (`X-Init-API-Key` header), distinct from `session_api_keys`, because session keys are part of the per-user payload that arrives *inside* the init body. The agent-server's 5xx exception handler rewrites `detail` on 503s, so warm-pool orchestrators should rely on the HTTP status code (not the body) when probing dormant state. - Auto-title generation should not re-read `ConversationState.events` from a background task triggered by a freshly received `MessageEvent`; extract message text synchronously from the incoming event and then reuse shared title helpers (`extract_message_text`, `generate_title_from_message`) to avoid persistence-order races. diff --git a/openhands-agent-server/openhands/agent_server/api.py b/openhands-agent-server/openhands/agent_server/api.py index 06a085ae36..1b595eedd3 100644 --- a/openhands-agent-server/openhands/agent_server/api.py +++ b/openhands-agent-server/openhands/agent_server/api.py @@ -208,15 +208,15 @@ async def stop_tool_preload_service(): ) # In deferred-init mode the conversation service is *not* entered - # here — that happens later, when POST /init delivers the runtime + # here — that happens later, when POST /api/init delivers the runtime # config. We still mark the /ready endpoint as ready so a warm-pool # orchestrator can tell the pod has finished booting and is - # available to receive its /init payload. + # available to receive its /api/init payload. if deferred: init_service = InitService(api, base_config=config) api.state.init_service = init_service mark_initialization_complete() - logger.info("Server started in deferred-init mode; awaiting POST /init") + logger.info("Server started in deferred-init mode; awaiting POST /api/init") try: yield finally: @@ -314,12 +314,14 @@ def _add_api_routes(app: FastAPI, config: Config) -> None: """ app.include_router(server_details_router) - # The /init endpoint is mounted at the top level (not under /api) so it - # bypasses both the session-key auth and the dormant gate. It has its - # own X-Init-API-Key auth. When ``deferred_init`` is False the endpoints - # are still mounted but return 404 because no InitService is registered - # on app.state — see ``get_init_service``. - app.include_router(init_router) + # The /api/init endpoint bypasses both the session-key auth and the + # dormant gate. It has its own X-Init-API-Key auth. When + # ``deferred_init`` is False the endpoints are still mounted but return + # 404 because no InitService is registered on app.state — see + # ``get_init_service``. + init_api_router = APIRouter(prefix="/api") + init_api_router.include_router(init_router) + app.include_router(init_api_router) # Header-only auth: applied to every /api/* route EXCEPT the workspace # static-file routes (handled separately below). Cookies are NOT honored @@ -328,7 +330,7 @@ def _add_api_routes(app: FastAPI, config: Config) -> None: if config.session_api_keys: dependencies.append(Depends(create_session_api_key_dependency(config))) # Dormant gate: when ``deferred_init`` is True this 503s every /api/* - # route until POST /init completes. No-op for non-deferred deployments. + # route until POST /api/init completes. No-op for non-deferred deployments. dependencies.append(Depends(require_initialized)) api_router = APIRouter(prefix="/api", dependencies=dependencies) diff --git a/openhands-agent-server/openhands/agent_server/config.py b/openhands-agent-server/openhands/agent_server/config.py index d870e74647..2cf0b74b14 100644 --- a/openhands-agent-server/openhands/agent_server/config.py +++ b/openhands-agent-server/openhands/agent_server/config.py @@ -238,7 +238,7 @@ class Config(BaseModel): description=( "When True, the server starts in dormant mode. Stateless services " "(VSCode, tool preload, etc.) start as usual, but the conversation, " - "event, and bash routers return 503 until POST /init is called with " + "event, and bash routers return 503 until POST /api/init is called with " "the runtime configuration. This is intended for warm-pool deployments " "where pods are pre-warmed before a user is matched and per-user " "configuration is delivered later." @@ -247,11 +247,11 @@ class Config(BaseModel): init_api_key: SecretStr | None = Field( default_factory=_default_init_api_key, description=( - "API key required to call POST /init when ``deferred_init`` is True. " + "API key required to call POST /api/init when ``deferred_init`` is True. " "Sent via the ``X-Init-API-Key`` header. Distinct from " "``session_api_keys`` because the session key is part of the per-user " - "config that arrives at /init time; the init key is the pool-bootstrap " - "credential held by the orchestrator. When unset, /init is " + "config that arrives at /api/init time; the init key is the pool-bootstrap " + "credential held by the orchestrator. When unset, /api/init is " "unauthenticated, which is acceptable for development but not for " "production warm pools." ), diff --git a/openhands-agent-server/openhands/agent_server/init_router.py b/openhands-agent-server/openhands/agent_server/init_router.py index 089cd2754b..65fd83f2d2 100644 --- a/openhands-agent-server/openhands/agent_server/init_router.py +++ b/openhands-agent-server/openhands/agent_server/init_router.py @@ -2,7 +2,7 @@ When ``Config.deferred_init`` is True the server starts in *dormant* mode: stateless services (VSCode, desktop, tool preload) come up as usual, but -the conversation, event, and bash routers return 503 until ``POST /init`` +the conversation, event, and bash routers return 503 until ``POST /api/init`` delivers the runtime configuration. This is intended for warm-pool deployments where pods are pre-warmed before a user is matched and the per-user workspace + credentials are attached later. @@ -32,7 +32,7 @@ # The init endpoint uses its own header (distinct from X-Session-API-Key) # because the session keys aren't known to the pool at warm-up time — they -# arrive *inside* the /init body. The init key is the pool-bootstrap +# arrive *inside* the /api/init body. The init key is the pool-bootstrap # credential. _INIT_API_KEY_HEADER = APIKeyHeader(name="X-Init-API-Key", auto_error=False) @@ -41,7 +41,7 @@ class InitRequest(BaseModel): - """Runtime configuration delivered at /init time. + """Runtime configuration delivered at /api/init time. Each field is optional and overrides the equivalent field on the dormant ``Config``. Fields not provided keep the value the server was constructed @@ -118,8 +118,8 @@ class InitRequest(BaseModel): class InitStatus(BaseModel): state: InitState = Field( description=( - "``dormant`` — server is up but waiting for /init. " - "``initializing`` — /init has been received and services are " + "``dormant`` — server is up but waiting for /api/init. " + "``initializing`` — /api/init has been received and services are " "starting. " "``ready`` — initialization complete; all /api/* routes are live." ) @@ -127,8 +127,8 @@ class InitStatus(BaseModel): error: str | None = Field( default=None, description=( - "If a previous /init attempt failed, the error message. The state " - "rolls back to ``dormant`` so /init can be retried." + "If a previous /api/init attempt failed, the error message. The state " + "rolls back to ``dormant`` so /api/init can be retried." ), ) @@ -160,9 +160,9 @@ def _build_initialized_config(base: Config, req: InitRequest) -> Config: class InitService: - """Tracks dormant→ready transition and serialises /init calls. + """Tracks dormant→ready transition and serialises /api/init calls. - A single ``asyncio.Lock`` makes concurrent /init posts safe; the second + A single ``asyncio.Lock`` makes concurrent /api/init posts safe; the second one sees ``state != "dormant"`` and gets a 400. On failure mid-init the state rolls back to ``dormant`` so the orchestrator can retry. """ @@ -217,7 +217,7 @@ async def initialize(self, req: InitRequest) -> InitStatus: logger.info("deferred_init: server transitioned to ready") return self.snapshot() except Exception as exc: # pragma: no cover - logged + re-raised - logger.exception("deferred_init: /init failed; rolling back to dormant") + logger.exception("deferred_init: /api/init failed; rolling back to dormant") self._error = f"{type(exc).__name__}: {exc}" self._state = "dormant" raise HTTPException( @@ -226,7 +226,7 @@ async def initialize(self, req: InitRequest) -> InitStatus: ) from exc async def teardown(self) -> None: - """Tear down the conversation service if /init succeeded. + """Tear down the conversation service if /api/init succeeded. Called from the FastAPI lifespan's finally clause so dormant pods that were never initialized don't need any cleanup. @@ -242,7 +242,7 @@ def get_init_service(request: Request) -> InitService: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=( - "server is not running with deferred_init=True; the /init " + "server is not running with deferred_init=True; the /api/init " "endpoint is not available" ), ) @@ -253,7 +253,7 @@ def check_init_api_key( request: Request, init_api_key: str | None = Depends(_INIT_API_KEY_HEADER), ) -> None: - """Auth gate for /init. Reads the *current* config off app state so the + """Auth gate for /api/init. Reads the *current* config off app state so the expected key can be configured at startup time alongside other env vars.""" config: Config | None = getattr(request.app.state, "config", None) if config is None or config.init_api_key is None: @@ -278,7 +278,7 @@ def require_initialized(request: Request) -> None: status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=( f"server is in deferred-init state '{init_service.state}'; " - "call POST /init first" + "call POST /api/init first" ), ) diff --git a/tests/agent_server/test_init_router.py b/tests/agent_server/test_init_router.py index f4531b60f8..27c584e3e6 100644 --- a/tests/agent_server/test_init_router.py +++ b/tests/agent_server/test_init_router.py @@ -93,7 +93,7 @@ def test_overrides_only_provided_fields(self, tmp_path): def test_secret_key_falls_back_to_session_key(self): base = Config(deferred_init=True) # base.secret_key default is None (no env), so we should fall back - # to the first session key after /init. + # to the first session key after /api/init. assert base.secret_key is None merged = _build_initialized_config( base, InitRequest(session_api_keys=["s1", "s2"]) @@ -114,14 +114,14 @@ def test_explicit_secret_key_wins(self): class TestRouterMounting: - """Behavior of the /init endpoint outside the lifespan.""" + """Behavior of the /api/init endpoint outside the lifespan.""" def test_init_get_404_without_deferred_mode(self): # When deferred_init=False the InitService is never attached to # app.state, so the endpoint behaves as if not configured. app = create_app(Config(deferred_init=False)) client = TestClient(app) - resp = client.get("/init") + resp = client.get("/api/init") assert resp.status_code == 404 @@ -187,7 +187,7 @@ async def test_second_init_rejected_with_400(self, tmp_path): @pytest.mark.asyncio async def test_init_applies_env_vars(self, tmp_path, monkeypatch): _reset_conversation_singleton() - # Pre-clean so the env var truly comes from /init. + # Pre-clean so the env var truly comes from /api/init. monkeypatch.delenv("DEFERRED_INIT_TEST_VAR", raising=False) base = Config( deferred_init=True, @@ -238,13 +238,13 @@ def test_dormant_503s_api_routes_until_init(self, tmp_path): assert resp.status_code == 503 # Init status reports dormant. - resp = client.get("/init") + resp = client.get("/api/init") assert resp.status_code == 200 assert resp.json()["state"] == "dormant" - # Run /init. + # Run /api/init. resp = client.post( - "/init", + "/api/init", json={ "conversations_path": str(tmp_path / "u" / "convs"), "bash_events_dir": str(tmp_path / "u" / "bash"), @@ -272,7 +272,7 @@ def test_init_api_key_required_when_configured(self, tmp_path): try: # Wrong key → 401. resp = client.post( - "/init", + "/api/init", headers={"X-Init-API-Key": "wrong"}, json={ "conversations_path": str(tmp_path / "u" / "convs"), @@ -282,12 +282,12 @@ def test_init_api_key_required_when_configured(self, tmp_path): assert resp.status_code == 401 # No key → 401. - resp = client.post("/init", json={}) + resp = client.post("/api/init", json={}) assert resp.status_code == 401 # Right key → 200. resp = client.post( - "/init", + "/api/init", headers={"X-Init-API-Key": "pool-key"}, json={ "conversations_path": str(tmp_path / "u" / "convs"), @@ -296,8 +296,8 @@ def test_init_api_key_required_when_configured(self, tmp_path): ) assert resp.status_code == 200 - # GET /init does NOT require the key (status polling). - resp = client.get("/init") + # GET /api/init does NOT require the key (status polling). + resp = client.get("/api/init") assert resp.status_code == 200 finally: _reset_conversation_singleton() @@ -312,13 +312,13 @@ def test_session_api_key_set_at_init_protects_api(self, tmp_path): app = create_app(cfg) with TestClient(app) as client: try: - # Before /init, no session key required at startup config + # Before /api/init, no session key required at startup config # level — but the dormant gate 503s anyway. assert client.get("/api/conversations/count").status_code == 503 # Init delivers the session key. resp = client.post( - "/init", + "/api/init", json={ "session_api_keys": ["user-session-key"], "conversations_path": str(tmp_path / "u" / "convs"), @@ -327,15 +327,15 @@ def test_session_api_key_set_at_init_protects_api(self, tmp_path): ) assert resp.status_code == 200 - # NOTE: session_api_keys configured at /init time take effect + # NOTE: session_api_keys configured at /api/init time take effect # on the *config object*, but the FastAPI session-key # dependency was bound to the original (dormant) config when # the routes were mounted. Documenting this trade-off: # in production, set OH_SESSION_API_KEYS_0 at pod start so # auth is in place from the moment routes go live, and use - # /init only to deliver workspace + per-user runtime config. + # /api/init only to deliver workspace + per-user runtime config. # The dormant gate ensures no traffic reaches gated routes - # before /init regardless. + # before /api/init regardless. assert app.state.config.session_api_keys == ["user-session-key"] finally: _reset_conversation_singleton() @@ -358,8 +358,8 @@ def test_non_deferred_does_not_create_init_service(self, tmp_path): assert getattr(app.state, "init_service", None) is None # /api/* should be live (200) — the dormant gate is a no-op. assert client.get("/api/conversations/count").status_code == 200 - # /init returns 404 because no InitService is attached. - assert client.get("/init").status_code == 404 + # /api/init returns 404 because no InitService is attached. + assert client.get("/api/init").status_code == 404 finally: _reset_conversation_singleton() @@ -368,8 +368,8 @@ def test_non_deferred_does_not_create_init_service(self, tmp_path): async def test_lifespan_teardown_releases_conversation_service_after_init( tmp_path, ): - """If /init succeeds, the lifespan finally clause must release the - conversation service. If /init never runs, teardown is a no-op.""" + """If /api/init succeeds, the lifespan finally clause must release the + conversation service. If /api/init never runs, teardown is a no-op.""" _reset_conversation_singleton() cfg = Config( deferred_init=True,