diff --git a/README.md b/README.md index 02a4572..343371f 100644 --- a/README.md +++ b/README.md @@ -94,23 +94,25 @@ The trigger and the lever are decoupled through a single one-line file, `~/.holo - **Reading it (the lever):** every turn records its own `started_at` and, while running, polls the file ~4×/s. It acts **only if the file's timestamp is newer than its `started_at`** — then it pauses, then cancels at the next action boundary. - **Clearing it:** the file is *never deleted*. It's cleared by time — the next turn starts later, so a leftover request is automatically stale and can't kill it. This is also why a `holo stop` fired *before* a run begins is ignored: nothing was running to stop. -`holo stop --force` is the exception to the step-bounded model: it reads the runtime's pid file (`~/.holo/agent-pid-`) and SIGKILLs the process directly, so it doesn't wait for an action boundary. +`holo stop --force` is the exception to the step-bounded model: it discovers the runtime through the hai-agents SDK (`LocalRuntime.attach(...).force_kill()`) and SIGKILLs the process directly, so it doesn't wait for an action boundary. For one release it also honours pre-SDK pid files (`~/.holo/agent-pid-`) so an upgrade mid-session can still be stopped. ## Use from Python -`holo_desktop.agent_client` is the same client every CLI surface is built on: it spawns (or attaches to) the `hai-agent-runtime` binary on loopback and drives sessions over the agent API. +`holo_desktop.agent_client` is the same client every CLI surface is built on: it spawns (or attaches to) the `hai-agent-runtime` binary on loopback via the hai-agents local runtime and drives sessions over the agent API. ```python import asyncio -from holo_desktop.agent_client import AgentApiClient, SpawnConfig, ensure_running +from holo_desktop.agent_client import AgentApiClient, SpawnConfig, ensure_local_runtime from holo_desktop.agent_client.requests import build_session_request +from holo_desktop.settings import load_holo_settings async def main() -> None: - daemon = await ensure_running(SpawnConfig(port=18795)) + settings = load_holo_settings() + runtime = await asyncio.to_thread(ensure_local_runtime, SpawnConfig(port=18795), settings=settings) try: - async with AgentApiClient(daemon.base_url, daemon.token) as client: + async with AgentApiClient(runtime) as client: request = build_session_request( task="Tell me how many unread emails I have", max_steps=None, max_time_s=None ) @@ -119,7 +121,8 @@ async def main() -> None: print(event.type) print(stream.answer) finally: - await daemon.aclose() + if runtime.owned: + await asyncio.to_thread(runtime.shutdown) asyncio.run(main()) diff --git a/examples/expense_report/README.md b/examples/expense_report/README.md index 74b9a9d..f05adf7 100644 --- a/examples/expense_report/README.md +++ b/examples/expense_report/README.md @@ -13,7 +13,7 @@ holo-desktop-cli is a thin client: the agent lives behind the closed `hai-agent- ```mermaid flowchart LR cli["expense-report-demo run"] --> setup["stage files + launch apps
(open / osascript)"] - setup --> rt["session.Runtime
ensure_running(SpawnConfig)"] + setup --> rt["session.Runtime
ensure_local_runtime(SpawnConfig)"] rt --> bin["hai-agent-runtime
(closed binary, loopback HTTP)"] bin --> events["events.jsonl + TaskResult"] events --> verify["verifiers
(openpyxl, AppleScript)"] @@ -24,7 +24,7 @@ flowchart LR macOS only for now. Requires: - Python >= 3.12 and [uv](https://docs.astral.sh/uv/). -- The `hai-agent-runtime` binary (holo-desktop-cli's launcher resolves a managed install under `~/.holo/runtime/` or `PATH`; running `holo run "hi"` once installs it). +- The `hai-agent-runtime` binary (the `hai-agents` SDK resolves a verified managed install or a binary on `PATH`; running `holo run "hi"` once installs it). - An H Company API key (`holo login`, or `HAI_API_KEY` in `~/.holo/.env`). Not needed with `--fake` or `--base-url`. - [LibreOffice](https://www.libreoffice.org/download/download/) installed at `/Applications/LibreOffice.app` (bundle id `org.libreoffice.script`). @@ -72,7 +72,7 @@ Teardown quarantines the staged files into `~/.holo/runs/expense_report-quaranti ``` src/expense_report_demo/ - session.py # Runtime (ensure_running + AgentApiClient), TaskResult, events.jsonl persistence + session.py # Runtime (ensure_local_runtime + AgentApiClient), TaskResult, events.jsonl persistence apps.py # launch_app, wait_for_app, activate_app, kill_all (open/osascript) metrics.py # Stat, StepStats, Metrics, read_events (over TrajectoryEvent JSONL) fixtures.py # OSWorld HF downloader, sha256 cache, TOML pin diff --git a/examples/expense_report/src/expense_report_demo/cli.py b/examples/expense_report/src/expense_report_demo/cli.py index e252cc9..3c48549 100644 --- a/examples/expense_report/src/expense_report_demo/cli.py +++ b/examples/expense_report/src/expense_report_demo/cli.py @@ -10,7 +10,7 @@ from typing import Annotated import tyro -from holo_desktop.agent_client.launcher import port_from_env +from holo_desktop.agent_client.sdk_runtime import port_from_env from holo_desktop.cli.bootstrap import load_holo_env from holo_desktop.settings import load_holo_settings diff --git a/examples/expense_report/src/expense_report_demo/demos/runner.py b/examples/expense_report/src/expense_report_demo/demos/runner.py index f56a450..6a06732 100644 --- a/examples/expense_report/src/expense_report_demo/demos/runner.py +++ b/examples/expense_report/src/expense_report_demo/demos/runner.py @@ -27,7 +27,6 @@ from statistics import fmean from typing import Literal -from holo_desktop.agent_client import runtime_log_path, runtime_log_tail from holo_desktop.agent_client.event_timings import StepTiming, StepTimingsSummary, render_step_timings from holo_desktop.killswitch import ( KILL_SWITCH_ARMED_HINT, @@ -101,6 +100,7 @@ class DemoRun(BaseModel): answer: str | None verify_checks: list[VerifyCheck] | None events_path: str + runtime_log_path: str | None metrics: Metrics | None @@ -144,6 +144,7 @@ def run_demo( session_id: str | None = None verify_checks: list[VerifyCheck] | None = None session_ran = False + runtime_log_path: str | None = None run_id = started_at.strftime("%Y%m%d-%H%M%S") events_path = out_root / demo.slug / run_id / "events.jsonl" @@ -155,6 +156,7 @@ def run_demo( if setup_fn is not None: setup_fn(demo) with Runtime(holo_kwargs.runtime_config()) as runtime: + runtime_log_path = str(runtime.log_path) if runtime.log_path is not None else None for spec in demo.pre_launch: _launch_one(spec) apps.activate_app(demo.focus_bundle_id) @@ -221,6 +223,7 @@ def run_demo( answer=answer, verify_checks=verify_checks, events_path=str(events_path), + runtime_log_path=runtime_log_path, metrics=metrics, ) _persist(run, out_root) @@ -361,9 +364,15 @@ def _print_failure_detail(run: DemoRun) -> None: """On a non-ok run, echo the error and the runtime's stderr tail so the cause isn't buried.""" if run.error: print(f" [error] {run.error}", file=sys.stderr) - log_path = runtime_log_path(run.holo_kwargs.port) + if run.runtime_log_path is None: + return + log_path = Path(run.runtime_log_path) print(f" [error] runtime log: {log_path}", file=sys.stderr) - tail = runtime_log_tail(run.holo_kwargs.port) + try: + tail = log_path.read_text(encoding="utf-8", errors="replace") + except OSError as exc: + print(f" [runtime] log unreadable: {exc}", file=sys.stderr) + return for line in tail.splitlines()[-_LOG_TAIL_LINES:]: print(f" [runtime] {line}", file=sys.stderr) @@ -392,5 +401,6 @@ def _dry_run( answer=None, verify_checks=None, events_path="", + runtime_log_path=None, metrics=None, ) diff --git a/examples/expense_report/src/expense_report_demo/session.py b/examples/expense_report/src/expense_report_demo/session.py index 2b58ba8..5cffbf5 100644 --- a/examples/expense_report/src/expense_report_demo/session.py +++ b/examples/expense_report/src/expense_report_demo/session.py @@ -16,7 +16,8 @@ from typing import Literal from agp_types import TrajectoryEvent, TrajectoryStatus -from holo_desktop.agent_client import AgentApiClient, AgentDaemon, SpawnConfig, ensure_running +from hai_agents.local import LocalRuntime +from holo_desktop.agent_client import AgentApiClient, SpawnConfig, ensure_local_runtime from holo_desktop.agent_client.events import is_policy_event from holo_desktop.agent_client.session_runner import Session, TurnOutcome, run_turn from holo_desktop.cli.bootstrap import load_holo_env, require_api_key @@ -63,7 +64,7 @@ class Runtime: def __init__(self, config: RuntimeConfig) -> None: self._config = config - self._daemon: AgentDaemon | None = None + self._runtime: LocalRuntime | None = None def __enter__(self) -> Runtime: # Per-request HTTP logs are implementation detail, not run output. @@ -72,16 +73,14 @@ def __enter__(self) -> Runtime: settings = load_holo_settings() if not self._config.fake: require_api_key(explicit_base_url=self._config.base_url, settings=settings) - self._daemon = asyncio.run( - ensure_running( - SpawnConfig( - port=self._config.port, - model=self._config.model, - base_url=self._config.base_url, - fake=self._config.fake, - ), - settings=settings, - ) + self._runtime = ensure_local_runtime( + SpawnConfig( + port=self._config.port, + model=self._config.model, + base_url=self._config.base_url, + fake=self._config.fake, + ), + settings=settings, ) return self @@ -91,21 +90,26 @@ def __exit__( exc: BaseException | None, tb: TracebackType | None, ) -> None: - daemon = self._daemon - self._daemon = None - if daemon is not None: - asyncio.run(daemon.aclose()) + runtime = self._runtime + self._runtime = None + if runtime is not None and runtime.owned: + runtime.shutdown() + + @property + def log_path(self) -> Path | None: + """Runtime stderr log, when the SDK owns or discovered one.""" + return self._runtime.log_path if self._runtime is not None else None def run_task( self, *, task: str, max_steps: int, max_time_s: float, events_path: Path, expand_feed: bool ) -> TaskResult: """Run one session to end-of-turn, persisting every event to `events_path`.""" - daemon = self._daemon - if daemon is None: + runtime = self._runtime + if runtime is None: raise RuntimeError("Runtime.run_task called outside the Runtime context") return asyncio.run( _drive( - daemon, + runtime, task=task, max_steps=max_steps, max_time_s=max_time_s, @@ -134,7 +138,7 @@ def _project_result(outcome: TurnOutcome, *, n_steps: int) -> TaskResult: async def _drive( - daemon: AgentDaemon, + runtime: LocalRuntime, *, task: str, max_steps: int, @@ -145,7 +149,7 @@ async def _drive( events_path.parent.mkdir(parents=True, exist_ok=True) feed = LiveFeed(Console(stderr=True), expand=expand_feed) n_steps = 0 - async with AgentApiClient(daemon.base_url, daemon.token) as client: + async with AgentApiClient(runtime) as client: session = Session() with events_path.open("w", encoding="utf-8") as sink: diff --git a/examples/expense_report/tests/conftest.py b/examples/expense_report/tests/conftest.py index 2fd4719..7d092c2 100644 --- a/examples/expense_report/tests/conftest.py +++ b/examples/expense_report/tests/conftest.py @@ -1,7 +1,7 @@ """Shared test fixtures for the expense-report demo suite. `fake_agent_server` stands up a minimal agent-API on a free loopback port so -the real `port_from_env` / `require_api_key` / `ensure_running` wiring runs +the real `port_from_env` / `require_api_key` / `ensure_local_runtime` wiring runs end-to-end without the `hai-agent-runtime` binary. A dropped `settings=` on any of those calls then surfaces as a loud `TypeError`. """ @@ -52,13 +52,22 @@ def do_GET(self) -> None: self.end_headers() elif "/changes" in self.path: self._json(_COMPLETED_CHANGES) + elif self.path.endswith("/status"): + self._json({"status": "completed"}) else: self.send_response(404) self.end_headers() def do_POST(self) -> None: if self.path.endswith("/sessions"): - self._json({"id": "fake-session"}) + self._json( + { + "id": "fake-session", + "request": {"agent": "holo"}, + "status": {"status": "pending"}, + "created_at": "2026-07-13T00:00:00Z", + } + ) else: self.send_response(404) self.end_headers() diff --git a/examples/expense_report/tests/test_runner_persistence.py b/examples/expense_report/tests/test_runner_persistence.py index d0a310e..9cbb8bd 100644 --- a/examples/expense_report/tests/test_runner_persistence.py +++ b/examples/expense_report/tests/test_runner_persistence.py @@ -121,6 +121,7 @@ def test_persist_writes_task_json_and_appends_csv(tmp_path: Path) -> None: answer="hello world", verify_checks=checks, events_path="runs/test_demo/20260612-120000/events.jsonl", + runtime_log_path=None, metrics=None, ) @@ -160,6 +161,8 @@ class _FakeRuntime: def __init__(self, *_args: object, **_kwargs: object) -> None: ... + log_path = Path("fake-runtime.log") + def __enter__(self) -> _FakeRuntime: return self diff --git a/examples/expense_report/tests/test_session.py b/examples/expense_report/tests/test_session.py index 579e533..350130f 100644 --- a/examples/expense_report/tests/test_session.py +++ b/examples/expense_report/tests/test_session.py @@ -10,21 +10,18 @@ import json import shutil import socket -import sys from pathlib import Path import pytest from agp_types import TrajectoryStatus -from holo_desktop.agent_client import launcher, runtime_install +from hai_agents.local.install import installed_binary +from hai_agents.local.manifest import BINARY_NAME, PINNED_RUNTIME_VERSION from holo_desktop.agent_client.session_runner import TurnOutcome from holo_desktop.settings import AUTH_TOKEN_ENV from expense_report_demo.session import Runtime, RuntimeConfig, TaskResult, _project_result -_RUNTIME_AVAILABLE = ( - shutil.which("hai-agent-runtime") is not None - or runtime_install.installed_binary(runtime_install.PINNED_RUNTIME_VERSION) is not None -) +_RUNTIME_AVAILABLE = shutil.which(BINARY_NAME) is not None or installed_binary(PINNED_RUNTIME_VERSION) is not None needs_runtime = pytest.mark.skipif(not _RUNTIME_AVAILABLE, reason="hai-agent-runtime binary not installed") @@ -82,13 +79,10 @@ def test_runtime_attaches_to_fake_server_non_fake( monkeypatch: pytest.MonkeyPatch, fake_agent_server: int, tmp_path: Path ) -> None: """Non-fake `Runtime` against a fake agent server: exercises the real - `require_api_key(settings=)` and `ensure_running(settings=)` wiring without + `require_api_key(settings=)` and `ensure_local_runtime(settings=)` wiring without the runtime binary.""" monkeypatch.setenv(AUTH_TOKEN_ENV, "test-token") monkeypatch.setenv("HAI_API_KEY", "test-key") # satisfy require_api_key without interactive login - # Crash-only stub: attaching to the live server must never fall through to spawning a binary. - monkeypatch.setattr(launcher, "resolve_command", lambda **_: [sys.executable, "-c", "raise SystemExit(2)"]) - config = RuntimeConfig(port=fake_agent_server, model=None, base_url=None, fake=False) with Runtime(config) as runtime: result = runtime.run_task( diff --git a/pyproject.toml b/pyproject.toml index 7852770..6ad1a9f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,8 @@ dependencies = [ # Agent-API wire contract (Pydantic specs + agp_types) for talking to the closed # hai-agent-runtime binary; the wheel vendors agent_interface + agp_types as the only source of the spec types. "hai-agent-api>=0.1.15", + # SDK local mode (plan 002): owns runtime download/spawn/auth/discovery; Holo delegates lifecycle to it. + "hai-agents[local]>=0.1", # CLI / serve (A2A) / acp / mcp adapter surface. "a2a-sdk[http-server]>=1.0.3", "agent-client-protocol>=0.10", @@ -78,6 +80,12 @@ dev = [ [tool.uv.workspace] members = ["examples/expense_report"] +[tool.uv.sources] +# SHIP-GATE: replace with the published hai-agents[local] version before merge. +# Review-time override onto the public plan-002 branch so every CI runner can +# resolve the same SDK implementation. uv.lock pins the resolved commit. +hai-agents = { git = "https://github.com/hcompai/hai-agents-python.git", branch = "claude/sdk-local-mode" } + [tool.hatch.build.targets.wheel] packages = ["src/holo_desktop"] diff --git a/scripts/bump_runtime.py b/scripts/bump_runtime.py deleted file mode 100644 index bb63ae6..0000000 --- a/scripts/bump_runtime.py +++ /dev/null @@ -1,98 +0,0 @@ -"""Rewrite the pinned hai-agent-runtime version + per-platform sha256 in runtime_install.py.""" - -from __future__ import annotations - -import argparse -import re -import sys -from dataclasses import dataclass -from pathlib import Path - -# Stdlib only on purpose: this runs as `python scripts/bump_runtime.py` in a -# checkout with no dependencies installed, so a third-party import would break -# the bump step. -RUNTIME_INSTALL = Path(__file__).parents[1] / "src" / "holo_desktop" / "agent_client" / "runtime_install.py" -_SHA256_RE = re.compile(r"^[0-9a-f]{64}$") -_MANIFEST_FILENAME_RE = re.compile(r'"hai-agent-runtime-([^".]+)\.zip"') - - -@dataclass(frozen=True) -class RuntimeBump: - version: str - shas: dict[str, str] # platform key (e.g. darwin-arm64) -> sha256 hex - - def __post_init__(self) -> None: - for platform, sha in self.shas.items(): - if not _SHA256_RE.fullmatch(sha): - raise ValueError(f"{platform}: {sha!r} is not a lowercase 64-char sha256") - - -def _filename_for(platform: str) -> str: - return f"hai-agent-runtime-{platform}.zip" - - -def _manifest_platforms(source: str) -> set[str]: - """Platform keys that have a published artifact literal in `source`.""" - return set(_MANIFEST_FILENAME_RE.findall(source)) - - -def apply_bump(source: str, bump: RuntimeBump) -> str: - """Return `source` with PINNED_RUNTIME_VERSION and the manifest digests replaced; raises if any anchor is missing.""" - published = _manifest_platforms(source) - extra = bump.shas.keys() - published - if extra: - raise ValueError(f"no manifest entry for platform(s): {sorted(extra)}") - # The version is a single literal feeding every derived URL, so any published - # platform left without a fresh sha would keep a stale digest at the new - # version's URL and fail verification on download. Refuse the partial bump. - missing = published - bump.shas.keys() - if missing: - raise ValueError(f"missing sha for published platform(s): {sorted(missing)}") - - updated, count = re.subn( - r'PINNED_RUNTIME_VERSION = "[^"]*"', - f'PINNED_RUNTIME_VERSION = "{bump.version}"', - source, - ) - if count != 1: - raise ValueError(f"expected exactly one PINNED_RUNTIME_VERSION assignment, found {count}") - - for platform, sha in bump.shas.items(): - filename = _filename_for(platform) - pattern = re.compile(rf'("{re.escape(filename)}",\s*")[0-9a-fA-F]{{64}}(")') - updated, count = pattern.subn(rf"\g<1>{sha}\g<2>", updated) - if count != 1: - raise ValueError(f"expected exactly one sha256 literal for {filename}, found {count}") - return updated - - -def _parse_args(argv: list[str]) -> RuntimeBump: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--version", required=True) - parser.add_argument( - "--sha", - action="append", - required=True, - metavar="PLATFORM=SHA256", - help="per-platform digest, e.g. darwin-arm64= (repeatable)", - ) - args = parser.parse_args(argv) - shas: dict[str, str] = {} - for entry in args.sha: - platform, _, sha = entry.partition("=") - if not platform or not sha: - parser.error(f"--sha must be PLATFORM=SHA256, got {entry!r}") - shas[platform] = sha.lower() - return RuntimeBump(version=args.version, shas=shas) - - -def main(argv: list[str]) -> int: - bump = _parse_args(argv) - source = RUNTIME_INSTALL.read_text() - RUNTIME_INSTALL.write_text(apply_bump(source, bump)) - print(f"bumped runtime to {bump.version} ({', '.join(sorted(bump.shas))})") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main(sys.argv[1:])) diff --git a/src/holo_desktop/agent_client/__init__.py b/src/holo_desktop/agent_client/__init__.py index 04cc971..3cf3157 100644 --- a/src/holo_desktop/agent_client/__init__.py +++ b/src/holo_desktop/agent_client/__init__.py @@ -1,20 +1,16 @@ -"""Thin client to the hai-agent-runtime binary.""" +"""Thin client to the hai-agent-runtime binary (lifecycle delegated to hai-agents local mode).""" from holo_desktop.agent_client.client import AgentApiClient, SessionStream -from holo_desktop.agent_client.launcher import ( - AgentDaemon, +from holo_desktop.agent_client.sdk_runtime import ( SpawnConfig, - ensure_running, - runtime_log_path, - runtime_log_tail, + ensure_local_runtime, + ensure_local_runtime_from_env, ) __all__ = [ "AgentApiClient", - "AgentDaemon", "SessionStream", "SpawnConfig", - "ensure_running", - "runtime_log_path", - "runtime_log_tail", + "ensure_local_runtime", + "ensure_local_runtime_from_env", ] diff --git a/src/holo_desktop/agent_client/client.py b/src/holo_desktop/agent_client/client.py index 66cb798..665e734 100644 --- a/src/holo_desktop/agent_client/client.py +++ b/src/holo_desktop/agent_client/client.py @@ -1,48 +1,74 @@ -"""Async HTTP client for the hai-agent-runtime agent-API surface.""" +"""SessionApi adapter over the hai-agents SDK, bound to one local runtime. + +The agent-API wire protocol (tagged message bodies, 204-vs-status terminal +detection) lives in hai-agents now; this module only maps session_runner's +structural SessionApi/EventStream protocols onto AsyncClient.local(...) and +its AsyncSessionHandles. Two normalizations stay Holo-owned because the +frozen runner/render code depends on them: + +- SDK events (typed Fern payloads) flatten back to ``agp_types.TrajectoryEvent`` + with dict ``data`` so events.py / feed / acp / mcp keep parsing them; +- SDK ``ApiError`` translates to ``httpx.HTTPStatusError`` so the runner's + dead-session recreate path (404/409/410) keeps firing. +""" from __future__ import annotations +import contextlib import logging from collections.abc import AsyncIterator from types import TracebackType +from typing import TYPE_CHECKING import httpx -from agent_interface.definition import UserMessageEvent -from agent_interface.specs.session import SessionRequest, SessionStatus -from agp_types import TrajectoryChanges, TrajectoryEvent, TrajectoryStatus +from agent_interface.specs.session import SessionRequest +from agp_types import TrajectoryEvent, TrajectoryStatus +from hai_agents import AsyncClient +from hai_agents.core.api_error import ApiError -logger = logging.getLogger(__name__) +if TYPE_CHECKING: + from hai_agents import AsyncSessionHandle + from hai_agents.local import LocalRuntime -API_PREFIX = "/api/v2" -# Long-poll window per request; modest to stay under the server's cap and keep Ctrl+C responsive. -POLL_WAIT_S = 10 +logger = logging.getLogger(__name__) _KNOWN_STATUSES = frozenset(s.value for s in TrajectoryStatus) -def _coerce_status(payload: object) -> object: - if isinstance(payload, dict): - status = payload.get("status") - if isinstance(status, str) and status not in _KNOWN_STATUSES: - # End the turn instead of mapping to a non-terminal status: an unknown *terminal* status - # would otherwise never trip end-of-turn and the stream would poll until external limits hit. - logger.warning("unrecognized session status %r from runtime; ending turn as failed", status) - patched = {**payload, "status": TrajectoryStatus.FAILED.value} - if not patched.get("error"): - patched["error"] = f"runtime reported unrecognized session status {status!r}" - return patched - return payload +def _as_httpx_error(exc: ApiError, method: str, url: str) -> httpx.HTTPStatusError: + """Rehydrate an SDK ApiError as the httpx error shape the frozen session_runner expects.""" + request = httpx.Request(method, url) + response = httpx.Response(exc.status_code or 500, request=request, text=str(exc.body or "")) + return httpx.HTTPStatusError(str(exc), request=request, response=response) + + +@contextlib.asynccontextmanager +async def _api_errors_as_httpx(method: str, url: str) -> AsyncIterator[None]: + try: + yield + except ApiError as exc: + raise _as_httpx_error(exc, method, url) from exc + + +def _as_trajectory_event(raw: object) -> TrajectoryEvent: + """Flatten one SDK SessionEvent (typed pydantic payloads) into the agp wire model.""" + if isinstance(raw, TrajectoryEvent): + return raw + if isinstance(raw, dict): + return TrajectoryEvent.model_validate(raw) + dump = getattr(raw, "model_dump", None) + return TrajectoryEvent.model_validate(dump() if callable(dump) else raw) class AgentApiClient: - """Authenticated async client bound to one agent-API base URL.""" + """The session_runner.SessionApi surface, backed by hai-agents local mode.""" - def __init__(self, base_url: str, token: str, *, timeout: float = 60.0) -> None: - self._http = httpx.AsyncClient( - base_url=f"{base_url}{API_PREFIX}", - headers={"Authorization": f"Bearer {token}"}, - timeout=httpx.Timeout(timeout, connect=5.0), - ) + def __init__(self, runtime: LocalRuntime) -> None: + self._client = AsyncClient.local(runtime=runtime) + self._base_url = runtime.base_url + # session_runner drives sessions by id string; handles are per-process, + # which matches every caller (run/serve/mcp/acp hold one client per process). + self._handles: dict[str, AsyncSessionHandle] = {} async def __aenter__(self) -> AgentApiClient: return self @@ -56,100 +82,88 @@ async def __aexit__( await self.aclose() async def aclose(self) -> None: - await self._http.aclose() + # The generated AsyncClient exposes no aclose; close its underlying httpx client. + closer = getattr(self._client, "aclose", None) + if closer is not None: + await closer() + return + wrapper = getattr(self._client, "_client_wrapper", None) + http = getattr(getattr(wrapper, "httpx_client", None), "httpx_client", None) + if http is not None: + await http.aclose() + + def _handle(self, session_id: str) -> AsyncSessionHandle: + handle = self._handles.get(session_id) + if handle is None: + raise RuntimeError(f"unknown agent-API session {session_id!r}; sessions are per-process") + return handle + + def _url(self, suffix: str) -> str: + return f"{self._base_url}/api/v2{suffix}" async def create_session(self, request: SessionRequest) -> str: """Create a session and return its id.""" - resp = await self._http.post("/sessions", json=request.model_dump(mode="json", exclude_none=True)) - resp.raise_for_status() - return str(resp.json()["id"]) - - async def get_changes( - self, session_id: str, from_index: int, *, wait_for_seconds: int, include_events: bool - ) -> TrajectoryChanges | None: - """One long-poll for changes since ``from_index``; ``None`` when nothing arrived (204).""" - resp = await self._http.get( - f"/sessions/{session_id}/changes", - params={"from_index": from_index, "wait_for_seconds": wait_for_seconds, "include_events": include_events}, - ) - if resp.status_code == 204: - return None - resp.raise_for_status() - return TrajectoryChanges.model_validate(_coerce_status(resp.json())) - - async def get_status(self, session_id: str) -> SessionStatus: - """Live session status; authoritative for terminal detection (``/changes`` 204s past the tail).""" - resp = await self._http.get(f"/sessions/{session_id}/status") - resp.raise_for_status() - return SessionStatus.model_validate(_coerce_status(resp.json())) + # start_session takes create_session kwargs; the dump is the exact wire + # body the pre-SDK client POSTed, so requests.py output passes through unmodified. + async with _api_errors_as_httpx("POST", self._url("/sessions")): + handle = await self._client.start_session(**request.model_dump(mode="json", exclude_none=True)) + self._handles[handle.id] = handle + return handle.id async def send_message(self, session_id: str, text: str) -> None: - # Body is a server-side tagged union, so the typed model's "type" discriminator is required. - body = UserMessageEvent(message=text).model_dump(mode="json") - resp = await self._http.post(f"/sessions/{session_id}/messages", json=body) - resp.raise_for_status() + async with _api_errors_as_httpx("POST", self._url(f"/sessions/{session_id}/messages")): + await self._handle(session_id).send_message(text) async def pause(self, session_id: str) -> None: """Freeze the agent after its current step; pairs with cancel for a responsive stop.""" - resp = await self._http.post(f"/sessions/{session_id}/pause") - resp.raise_for_status() + async with _api_errors_as_httpx("POST", self._url(f"/sessions/{session_id}/pause")): + await self._handle(session_id).pause() async def cancel(self, session_id: str) -> None: - resp = await self._http.delete(f"/sessions/{session_id}") - if resp.status_code not in (200, 204): - resp.raise_for_status() + async with _api_errors_as_httpx("DELETE", self._url(f"/sessions/{session_id}")): + await self._handle(session_id).cancel() def stream(self, session_id: str, *, from_index: int = 0) -> SessionStream: - return SessionStream(self, session_id, from_index=from_index) - - -def _is_end_of_turn(status: TrajectoryStatus) -> bool: - """Terminal, or IDLE (interactive session answered and awaits the next task).""" - return status.is_terminal or status == TrajectoryStatus.IDLE + return SessionStream(self._handle(session_id), self._url(f"/sessions/{session_id}"), from_index=from_index) class SessionStream: - """Tails a session's events to end-of-turn, accumulating the final projection.""" + """EventStream projection over AsyncSessionHandle.stream(); field names are the runner's contract.""" - def __init__(self, client: AgentApiClient, session_id: str, *, from_index: int = 0) -> None: - self._client = client - self._session_id = session_id + def __init__(self, handle: AsyncSessionHandle, url: str, *, from_index: int = 0) -> None: + self._handle = handle + self._url = url self.next_index = from_index self.answer: str | dict[str, object] | None = None self.status: TrajectoryStatus | None = None self.error: str | None = None async def events(self) -> AsyncIterator[TrajectoryEvent]: - """Yield each new ``TrajectoryEvent`` until the session reaches end-of-turn.""" - while True: - changes = await self._client.get_changes( - self._session_id, self.next_index, wait_for_seconds=POLL_WAIT_S, include_events=True - ) - if changes is not None: - self.next_index += len(changes.new_events) - if changes.answer is not None: - self.answer = changes.answer - # status + error are one coherent snapshot: track the latest so a recovered - # session does not finish carrying a stale failure from an earlier batch. - self.status = changes.status - self.error = changes.error - for event in changes.new_events: - yield event - if _is_end_of_turn(changes.status): - return - continue - status = await self._client.get_status(self._session_id) - if _is_end_of_turn(status.status): - await self._finalize(status) - return - - async def _finalize(self, status: SessionStatus) -> None: - """Adopt the authoritative status and pick up the final answer the 204 hid from us.""" - self.status = status.status - self.error = status.error - if self.answer is not None: + """Yield each new event until end-of-turn (SDK-owned), then adopt the terminal projection.""" + async with _api_errors_as_httpx("GET", self._url): + async for event in self._handle.stream(from_index=self.next_index): + self.next_index += 1 + yield _as_trajectory_event(event) + status = await self._handle.status() + self._adopt_status(status.status, status.error) + # Pick up the final answer the event tail may not carry (the old _finalize behavior). + final = await self._handle.changes(from_index=0, include_events=False, wait_for_seconds=0) + if final is not None: + self.answer = final.answer + self.error = self.error or final.error + + def _adopt_status(self, raw: object, error: str | None) -> None: + """Adopt the authoritative status, hardening against statuses this client doesn't know. + + Rehomed from the old client's _coerce_status: an unknown status ends the + turn as FAILED with an explanatory error instead of crashing the projection + or reporting a phantom success. + """ + value = raw.value if isinstance(raw, TrajectoryStatus) else raw + if isinstance(value, str) and value in _KNOWN_STATUSES: + self.status = TrajectoryStatus(value) + self.error = error return - final = await self._client.get_changes(self._session_id, 0, wait_for_seconds=0, include_events=False) - if final is not None: - self.answer = final.answer - self.error = self.error or final.error + logger.warning("unrecognized session status %r from runtime; ending turn as failed", raw) + self.status = TrajectoryStatus.FAILED + self.error = error or f"runtime reported unrecognized session status {raw!r}" diff --git a/src/holo_desktop/agent_client/launcher.py b/src/holo_desktop/agent_client/launcher.py deleted file mode 100644 index f03fe41..0000000 --- a/src/holo_desktop/agent_client/launcher.py +++ /dev/null @@ -1,491 +0,0 @@ -"""Spawn, health-check and stop the hai-agent-runtime binary on loopback.""" - -from __future__ import annotations - -import asyncio -import contextlib -import logging -import os -import secrets -import shutil -import signal -import subprocess -from dataclasses import dataclass -from pathlib import Path - -import httpx -from pydantic import BaseModel - -from holo_desktop.agent_client import runtime_install -from holo_desktop.agent_client.model_gateway import PRODUCTION_GATEWAY_URL -from holo_desktop.settings import ( - AGENT_API_DEFAULT_PORT as _AGENT_API_DEFAULT_PORT, -) -from holo_desktop.settings import ( - AUTH_TOKEN_ENV, - PORT_ENV, - RUNTIME_BASE_URL_ENV, - HoloSettings, - load_holo_settings, -) - -logger = logging.getLogger(__name__) - -LOOPBACK_HOST = "127.0.0.1" -MODELS_API_BASE_URL_ENV = "HAI_BASE_URL" -AGENT_API_DEFAULT_PORT = _AGENT_API_DEFAULT_PORT -SPAWN_TIMEOUT_S = 45.0 -# Disabled by default: without a Datadog Agent, ddtrace only adds noise and a shutdown flush hang. -DDTRACE_DEFAULT_OFF: dict[str, str] = { - "DD_TRACE_ENABLED": "false", - "DD_LLMOBS_ENABLED": "false", -} -# stderr goes to a file, not a pipe: nobody drains a pipe after spawn, so the buffer would fill and block. -LOG_DIR = Path.home() / ".holo" / "logs" -LOG_TAIL_CHARS = 4000 -TOKEN_DIR = Path.home() / ".holo" - - -def apply_hosted_gateway_default(env: dict[str, str]) -> None: - """Default hosted runtime calls to the production gateway unless the caller chose another gateway.""" - if not env.get(MODELS_API_BASE_URL_ENV, "").strip(): - env[MODELS_API_BASE_URL_ENV] = PRODUCTION_GATEWAY_URL - - -def token_file_path(port: int) -> Path: - """Where a spawner publishes its generated bearer token for other local clients.""" - return TOKEN_DIR / f"agent-token-{port}" - - -def pid_file_path(port: int) -> Path: - """Where a spawner publishes the runtime pid so ``holo stop --force`` can signal it.""" - return TOKEN_DIR / f"agent-pid-{port}" - - -def read_pid_file(port: int) -> int | None: - """The spawned runtime's pid for ``port``, or None when no readable pid file exists.""" - try: - return int(pid_file_path(port).read_text(encoding="utf-8").strip()) - except (FileNotFoundError, ValueError): - return None - except OSError as exc: - logger.warning("could not read pid file %s: %s", pid_file_path(port), exc) - return None - - -def discover_runtime_pids(port: int | None) -> list[int]: - """Pids of spawned runtimes from pid files: one ``port``, or every spawned runtime when None. - - Gotcha: a runtime that exits uncleanly (crash/SIGKILL) leaves its pid file behind, so a later - ``holo stop --force`` can SIGKILL a recycled pid. There is no proof-of-identity check yet; the - robust fix (match the process start time) is tracked as follow-up. - """ - if port is not None: - pid = read_pid_file(port) - return [pid] if pid is not None else [] - pids: list[int] = [] - for path in sorted(TOKEN_DIR.glob("agent-pid-*")): - try: - pids.append(int(path.read_text(encoding="utf-8").strip())) - except (OSError, ValueError): - continue - return pids - - -def _killpg_posix(pid: int, sig: int) -> bool: - """Send ``sig`` to ``pid``'s process group; False if the process/group is already gone.""" - try: - os.killpg(os.getpgid(pid), sig) - except (OSError, ProcessLookupError): - return False - return True - - -def kill_runtime_by_pid(pid: int) -> bool: - """Force-kill the runtime's process group by pid; False if it was already gone.""" - if os.name == "posix": - return _killpg_posix(pid, signal.SIGKILL) - try: - subprocess.run(["taskkill", "/F", "/T", "/PID", str(pid)], check=True, capture_output=True) - except (OSError, subprocess.CalledProcessError): - return False - return True - - -def runtime_log_path(port: int) -> Path: - """Where the runtime spawned on `port` writes its stderr.""" - return LOG_DIR / f"hai-agent-runtime-{port}.log" - - -def runtime_log_tail(port: int) -> str: - """Last chunk of the runtime's stderr log for `port`; a placeholder when missing/empty.""" - return _log_tail(runtime_log_path(port)) - - -@dataclass -class AgentDaemon: - """A reachable agent-API server: where it is, how to authenticate, and (if ours) the process.""" - - base_url: str - token: str - proc: subprocess.Popen[bytes] | None - # Set only on the spawner that published a generated token; attachers never own the file. - token_file: Path | None - # From /health; None when the server does not report one. - runtime_version: str | None - # Set only on the spawner; published so `holo stop --force` can find the runtime from another process. - pid_file: Path | None = None - - async def aclose(self) -> None: - """Stop the daemon if we spawned it; no-op if we attached to an existing one.""" - if self.proc is not None: - await _graceful_stop(self.proc) - if self.token_file is not None: - self.token_file.unlink(missing_ok=True) - if self.pid_file is not None: - self.pid_file.unlink(missing_ok=True) - - -def runtime_child_env(extra: dict[str, str], *, settings: HoloSettings) -> dict[str, str]: - """Inherited env for the runtime child, plus `extra`; drops the portal key when a custom inference base URL is set.""" - env = {**DDTRACE_DEFAULT_OFF, **os.environ, **extra} - runtime_base_url = (extra.get(RUNTIME_BASE_URL_ENV) or settings.runtime.base_url or "").strip() - # A custom base URL points the runtime at a self-hosted endpoint; the portal HAI_API_KEY must not leak to it. - if runtime_base_url: - env[RUNTIME_BASE_URL_ENV] = runtime_base_url - env.pop("HAI_API_KEY", None) - else: - env.pop(RUNTIME_BASE_URL_ENV, None) - apply_hosted_gateway_default(env) - return env - - -def port_from_env(*, settings: HoloSettings) -> int: - """Agent-API port from ``HAI_AGENT_RUNTIME_PORT``, falling back to :data:`AGENT_API_DEFAULT_PORT`.""" - return settings.runtime.port - - -class SpawnConfig(BaseModel): - """Spawn-time knobs for the binary; they only reach a freshly spawned process (see :func:`ensure_running`).""" - - port: int - model: str | None = None - base_url: str | None = None - fake: bool = False - fast: bool = False - # None leaves the binary's own default (~/.holo/runs). - runs_dir: Path | None = None - # Explicit CLI config must not be silently ignored on attach; env-derived config attaches best-effort. - require_fresh_for_config: bool = True - - -def spawn_config_from_env(*, settings: HoloSettings) -> SpawnConfig: - """:class:`SpawnConfig` purely from ``HAI_AGENT_RUNTIME_*`` env (stdio servers: mcp/acp).""" - runtime = settings.runtime - return SpawnConfig( - port=runtime.port, - model=runtime.model, - base_url=runtime.base_url, - require_fresh_for_config=False, - fake=runtime.fake, - fast=runtime.fast, - # HAI_AGENT_RUNTIME_RUNS_DIR reaches the spawned binary via inherited env. - runs_dir=None, - ) - - -async def ensure_running_from_env() -> AgentDaemon: - """``ensure_running`` configured purely from ``HAI_AGENT_RUNTIME_*`` env (stdio servers: mcp/acp).""" - settings = load_holo_settings() - return await ensure_running(spawn_config_from_env(settings=settings), settings=settings) - - -def resolve_command(*, settings: HoloSettings) -> list[str]: - """Resolve the runtime command: PATH > managed install > download-on-first-run; raises otherwise.""" - found = shutil.which("hai-agent-runtime") - if found: - logger.info("resolved hai-agent-runtime from PATH: %s", found) - return [found] - managed = runtime_install.installed_binary(runtime_install.PINNED_RUNTIME_VERSION) - if managed is not None: - logger.info( - "resolved hai-agent-runtime from managed install v%s: %s", runtime_install.PINNED_RUNTIME_VERSION, managed - ) - return [str(managed)] - # Resolve before prompting so an unsupported platform fails before the user approves a doomed download. - artifact = runtime_install.pinned_artifact(settings=settings.install) - if not runtime_install.confirm_download(): - raise RuntimeError( - "hai-agent-runtime not found: not on PATH, no managed install under " - f"{runtime_install.RUNTIME_DIR}, and the download was declined. " - "Re-run and accept the download, or put hai-agent-runtime on PATH." - ) - installed = runtime_install.install_runtime(artifact) - logger.info( - "resolved hai-agent-runtime from fresh download v%s: %s", runtime_install.PINNED_RUNTIME_VERSION, installed - ) - return [str(installed)] - - -@dataclass -class HealthProbe: - """A 200 from /health; `version` when the body reports one.""" - - version: str | None - - -async def probe_health(base_url: str) -> HealthProbe | None: - """None when unreachable/unhealthy; otherwise the probe with a best-effort version.""" - try: - async with httpx.AsyncClient(timeout=2.0) as client: - response = await client.get(f"{base_url}/health") - except httpx.HTTPError: - return None - if response.status_code != 200: - return None - try: - payload = response.json() - except ValueError: - return HealthProbe(version=None) - version = payload.get("version") if isinstance(payload, dict) else None - return HealthProbe(version=version if isinstance(version, str) else None) - - -def _check_runtime_version(version: str | None) -> None: - """Warn (not fail) on a client/runtime version skew; PATH/override dev binaries stay usable.""" - if version is not None and version != runtime_install.PINNED_RUNTIME_VERSION: - logger.warning( - "hai-agent-runtime version skew: server reports %s, this client pins %s; " - "wire-contract drift may cause subtle failures", - version, - runtime_install.PINNED_RUNTIME_VERSION, - ) - - -async def ensure_running(config: SpawnConfig, *, settings: HoloSettings) -> AgentDaemon: - """Return a reachable :class:`AgentDaemon`, attaching to or spawning the binary.""" - server_url = f"http://{LOOPBACK_HOST}:{config.port}" - env_token = settings.runtime.api_token or "" - - probe = await probe_health(server_url) - if probe is not None: - valued = (("--model", config.model), ("--base-url", config.base_url), ("--runs-dir", config.runs_dir)) - requested = [f"{name} {value}" for name, value in valued if value] - requested += [name for name, on in (("--fake", config.fake), ("--fast", config.fast)) if on] - if config.require_fresh_for_config and requested: - flags = " ".join(requested) - raise RuntimeError( - f"An agent server is already running at {server_url} and keeps the configuration " - f"it was started with, so '{flags}' would be silently ignored. " - "Stop that server, or pass a different --port to spawn a fresh one with your flags." - ) - token = env_token or _read_token_file(config.port) - if not token: - raise RuntimeError( - f"An agent server is already running at {server_url} but no credentials were found: " - f"{AUTH_TOKEN_ENV} is not set and {token_file_path(config.port)} does not exist, " - "so this client cannot authenticate. Export the token or stop that server." - ) - _check_runtime_version(probe.version) - return AgentDaemon( - base_url=server_url, token=token, proc=None, token_file=None, runtime_version=probe.version, pid_file=None - ) - - token = env_token or secrets.token_urlsafe(32) - # Publish generated tokens before health so a client racing our /health probe can authenticate; - # env tokens never touch disk. - token_file = None if env_token else _write_owner_only(token_file_path(config.port), token) - try: - proc, runtime_version = await _spawn(config=config, token=token, settings=settings) - except BaseException: - if token_file is not None: - _unlink_token_if_ours(token_file, token) - raise - _check_runtime_version(runtime_version) - return AgentDaemon( - base_url=server_url, - token=token, - proc=proc, - token_file=token_file, - runtime_version=runtime_version, - pid_file=_write_pid_file(config.port, proc.pid), - ) - - -# Heuristic markers of macOS TCC failures in the binary's stderr. -PERMISSION_ERROR_HINTS = ( - "accessibility", - "screen recording", - "screencapture", - "could not create image from display", - "tcc", - "not permitted", - "permission", -) - - -def text_suggests_permissions(text: str) -> bool: - """True when ``text`` (stderr tail, session error, ...) looks like a macOS permission failure.""" - lowered = text.lower() - return any(hint in lowered for hint in PERMISSION_ERROR_HINTS) - - -def log_tail_suggests_permissions(port: int) -> bool: - """True when the runtime's recent stderr looks like a macOS permission failure.""" - path = runtime_log_path(port) - try: - data = path.read_bytes()[-8192:] - except OSError: - return False - return text_suggests_permissions(data.decode("utf-8", errors="replace")) - - -def _read_token_file(port: int) -> str: - try: - return token_file_path(port).read_text(encoding="utf-8").strip() - except FileNotFoundError: - return "" - except OSError as exc: - logger.warning("could not read token file %s: %s", token_file_path(port), exc) - return "" - - -def _write_owner_only(path: Path, content: str) -> Path: - """Write `content` to `path` owner-only, refusing a pre-existing symlink at the path. - - For files whose contents drive a privileged action — the bearer token, and the runtime pid that - `holo stop --force` SIGKILLs: O_NOFOLLOW refuses a planted symlink and 0o600 keeps them owner-only - from the first byte, so neither can be steered into authenticating or killing the wrong target. - """ - path.parent.mkdir(parents=True, exist_ok=True) - with contextlib.suppress(OSError): - path.parent.chmod(0o700) # owner-only ~/.holo; no-op on Windows - flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC | getattr(os, "O_NOFOLLOW", 0) - fd = os.open(path, flags, 0o600) - with os.fdopen(fd, "w", encoding="utf-8") as fh: - if os.name == "posix": - os.fchmod(fd, 0o600) # enforce owner-only even if the file pre-existed - fh.write(content) - return path - - -def _write_pid_file(port: int, pid: int) -> Path | None: - """Best-effort publish of the runtime pid; None when it could not be written.""" - try: - return _write_owner_only(pid_file_path(port), str(pid)) - except OSError as exc: - logger.warning("could not write pid file %s: %s", pid_file_path(port), exc) - return None - - -def _unlink_token_if_ours(path: Path, token: str) -> None: - """Remove our token file, but never one a concurrent spawner already overwrote with its own token.""" - try: - if path.read_text(encoding="utf-8").strip() == token: - path.unlink(missing_ok=True) - except OSError: - pass - - -async def _spawn( - *, config: SpawnConfig, token: str, settings: HoloSettings -) -> tuple[subprocess.Popen[bytes], str | None]: - """Spawn the binary and wait for health; returns the process and its reported version.""" - server_url = f"http://{LOOPBACK_HOST}:{config.port}" - extra = {AUTH_TOKEN_ENV: token, PORT_ENV: str(config.port)} - if config.fake: - extra["HAI_AGENT_RUNTIME_FAKE"] = "1" - if config.fast: - extra["HAI_AGENT_RUNTIME_FAST"] = "1" - if config.model: - extra["HAI_AGENT_RUNTIME_MODEL"] = config.model - if config.base_url: - extra["HAI_AGENT_RUNTIME_BASE_URL"] = config.base_url - if config.runs_dir: - # A quoted "~/..." bypasses shell expansion; never hand the binary a literal tilde. - extra["HAI_AGENT_RUNTIME_RUNS_DIR"] = str(config.runs_dir.expanduser()) - env = runtime_child_env(extra, settings=settings) - - # resolve_command may download the runtime on first run; keep that off the event loop. - cmd = await asyncio.to_thread(resolve_command, settings=settings) - log_path = runtime_log_path(config.port) - log_path.parent.mkdir(parents=True, exist_ok=True) - logger.info("spawning agent binary: %s (stderr -> %s)", " ".join(cmd), log_path) - # Own process group so we can reap grandchildren (e.g. desktop helpers) the binary may spawn: - # POSIX gets its own session; Windows a new process group. Each kwarg is a no-op on the other OS. - with log_path.open("wb") as log_file: # child inherits the fd; the parent handle can close right away - proc = subprocess.Popen( - cmd, - stdout=subprocess.DEVNULL, - stderr=log_file, - env=env, - start_new_session=os.name == "posix", - creationflags=0 if os.name == "posix" else getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0), - ) - - loop = asyncio.get_running_loop() - deadline = loop.time() + SPAWN_TIMEOUT_S - try: - while True: - probe = await probe_health(server_url) - if probe is not None: - logger.info("agent binary ready (pid %d)", proc.pid) - return proc, probe.version - if proc.poll() is not None: - raise RuntimeError( - f"hai-agent-runtime exited with code {proc.returncode}: {_log_tail(log_path)} (full log: {log_path})" - ) - if loop.time() >= deadline: - raise RuntimeError( - f"hai-agent-runtime did not become healthy within {SPAWN_TIMEOUT_S:.0f}s (see {log_path})" - ) - await asyncio.sleep(0.25) - except BaseException: - # Covers CancelledError too (Ctrl+C mid-spawn): never leak the child we just started. - _terminate(proc) - raise - - -def _log_tail(path: Path) -> str: - try: - text = path.read_text(encoding="utf-8", errors="replace").strip() - except OSError: - return "(stderr log unreadable)" - if not text: - return "(no stderr output)" - return text[-LOG_TAIL_CHARS:] - - -def _signal_runtime(proc: subprocess.Popen[bytes], *, force: bool) -> bool: - """Signal the runtime's whole process group (posix) or just the process; False if already gone.""" - if os.name == "posix": - return _killpg_posix(proc.pid, signal.SIGKILL if force else signal.SIGTERM) - try: - if force: - proc.kill() - else: - proc.terminate() - except (OSError, ProcessLookupError): - return False - return True - - -def _terminate(proc: subprocess.Popen[bytes]) -> None: - if not _signal_runtime(proc, force=False): - return - try: - proc.wait(timeout=2.0) - return - except subprocess.TimeoutExpired: - pass - if _signal_runtime(proc, force=True): - try: - proc.wait(timeout=2.0) - except subprocess.TimeoutExpired: - logger.warning("hai-agent-runtime (pid %d) did not exit after forced kill", proc.pid) - - -async def _graceful_stop(proc: subprocess.Popen[bytes]) -> None: - if proc.poll() is not None: - return - await asyncio.to_thread(_terminate, proc) diff --git a/src/holo_desktop/agent_client/legacy_state.py b/src/holo_desktop/agent_client/legacy_state.py new file mode 100644 index 0000000..1a81835 --- /dev/null +++ b/src/holo_desktop/agent_client/legacy_state.py @@ -0,0 +1,79 @@ +"""One-release compatibility with pre-SDK ~/.holo cross-process state. + +Pre-SDK holo published ~/.holo/agent-token- and ~/.holo/agent-pid-; +the SDK owns discovery state now. This module only lets an upgraded holo stop a +runtime a pre-SDK holo spawned. DELETE this module (and its call sites in +stop.py/doctor.py) in the release after SDK local mode ships. +""" + +from __future__ import annotations + +import logging +import os +import signal +import subprocess +from pathlib import Path + +logger = logging.getLogger(__name__) + +TOKEN_DIR = Path.home() / ".holo" + + +def pid_file_path(port: int) -> Path: + """Where a spawner publishes the runtime pid so ``holo stop --force`` can signal it.""" + return TOKEN_DIR / f"agent-pid-{port}" + + +def read_pid_file(port: int) -> int | None: + """The spawned runtime's pid for ``port``, or None when no readable pid file exists.""" + try: + return int(pid_file_path(port).read_text(encoding="utf-8").strip()) + except (FileNotFoundError, ValueError): + return None + except OSError as exc: + logger.warning("could not read pid file %s: %s", pid_file_path(port), exc) + return None + + +def discover_runtime_pids(port: int | None) -> list[int]: + """Pids of spawned runtimes from pid files: one ``port``, or every spawned runtime when None. + + Gotcha: a runtime that exits uncleanly (crash/SIGKILL) leaves its pid file behind, so a later + ``holo stop --force`` can SIGKILL a recycled pid. There is no proof-of-identity check yet; the + robust fix (match the process start time) is tracked as follow-up. + """ + if port is not None: + pid = read_pid_file(port) + return [pid] if pid is not None else [] + pids: list[int] = [] + for path in sorted(TOKEN_DIR.glob("agent-pid-*")): + try: + pids.append(int(path.read_text(encoding="utf-8").strip())) + except (OSError, ValueError): + continue + return pids + + +def _killpg_posix(pid: int, sig: int) -> bool: + """Send ``sig`` to ``pid``'s process group; False if the process/group is already gone.""" + try: + os.killpg(os.getpgid(pid), sig) + except (OSError, ProcessLookupError): + return False + return True + + +def kill_runtime_by_pid(pid: int) -> bool: + """Force-kill the runtime's process group by pid; False if it was already gone.""" + if os.name == "posix": + return _killpg_posix(pid, signal.SIGKILL) + try: + subprocess.run(["taskkill", "/F", "/T", "/PID", str(pid)], check=True, capture_output=True) + except (OSError, subprocess.CalledProcessError): + return False + return True + + +def legacy_force_kill(port: int | None) -> list[int]: + """SIGKILL every pre-SDK runtime pid file records for ``port`` (all ports when None).""" + return [pid for pid in discover_runtime_pids(port) if kill_runtime_by_pid(pid)] diff --git a/src/holo_desktop/agent_client/permissions.py b/src/holo_desktop/agent_client/permissions.py new file mode 100644 index 0000000..a43d552 --- /dev/null +++ b/src/holo_desktop/agent_client/permissions.py @@ -0,0 +1,51 @@ +"""macOS TCC heuristics and the first-run walkthrough marker (product UX Holo keeps). + +The SDK owns spawning and exposes runtime.log_path; Holo keeps deciding what a +permission failure looks like and when to run the first-run restart walkthrough. +""" + +from __future__ import annotations + +from pathlib import Path + +# Heuristic markers of macOS TCC failures in the binary's stderr. +PERMISSION_ERROR_HINTS = ( + "accessibility", + "screen recording", + "screencapture", + "could not create image from display", + "tcc", + "not permitted", + "permission", +) + +# One marker, not version-keyed: grants re-prompt per binary path anyway, and the +# walkthrough retry also triggers off the log-keyword heuristic regardless of the marker. +FIRST_RUN_MARKER = Path.home() / ".holo" / ".first-run-complete" + + +def text_suggests_permissions(text: str) -> bool: + """True when ``text`` (stderr tail, session error, ...) looks like a macOS permission failure.""" + lowered = text.lower() + return any(hint in lowered for hint in PERMISSION_ERROR_HINTS) + + +def log_tail_suggests_permissions(log_path: Path | None) -> bool: + """True when the runtime's recent stderr looks like a macOS permission failure.""" + if log_path is None: + return False + try: + data = log_path.read_bytes()[-8192:] + except OSError: + return False + return text_suggests_permissions(data.decode("utf-8", errors="replace")) + + +def first_run_pending() -> bool: + """True until a task completes against an SDK-managed runtime on this machine.""" + return not FIRST_RUN_MARKER.exists() + + +def mark_first_run_complete() -> None: + FIRST_RUN_MARKER.parent.mkdir(parents=True, exist_ok=True) + FIRST_RUN_MARKER.touch() diff --git a/src/holo_desktop/agent_client/runtime_install.py b/src/holo_desktop/agent_client/runtime_install.py deleted file mode 100644 index 3c7847b..0000000 --- a/src/holo_desktop/agent_client/runtime_install.py +++ /dev/null @@ -1,265 +0,0 @@ -"""Download-on-first-run install of the hai-agent-runtime binary.""" - -from __future__ import annotations - -import hashlib -import logging -import os -import platform -import shutil -import sys -import tempfile -import zipfile -from collections.abc import Callable, Iterator -from contextlib import contextmanager -from pathlib import Path -from urllib.parse import urlsplit - -import httpx -from pydantic import BaseModel -from rich.console import Console -from rich.progress import BarColumn, DownloadColumn, Progress, TransferSpeedColumn -from rich.prompt import Confirm - -from holo_desktop.settings import DOWNLOAD_SHA256_ENV, DOWNLOAD_URL_ENV, RuntimeInstallSettings - -logger = logging.getLogger(__name__) - -PINNED_RUNTIME_VERSION = "0.1.8" -RUNTIME_DIR = Path.home() / ".holo" / "runtime" -# Artifacts live under an immutable, version-scoped prefix, so a CDN edge can never serve stale bytes. -RUNTIME_CDN_BASE = "https://assets.hcompanyprod.fr/hai-agent-runtime" -BINARY_NAME = "hai-agent-runtime.exe" if os.name == "nt" else "hai-agent-runtime" -# Guard value: published manifest entries must never use it, since every download would fail verification. -PLACEHOLDER_SHA256 = "0" * 64 -_DOWNLOAD_TIMEOUT = httpx.Timeout(30.0, read=600.0) -# Generous ceiling (the runtime is hundreds of MB); guards against a lying/absent Content-Length filling the disk. -MAX_DOWNLOAD_BYTES = 1024 * 1024 * 1024 -_LOOPBACK_HOSTS = frozenset({"127.0.0.1", "localhost", "::1"}) - - -def _require_secure_url(url: str) -> None: - """Allow https anywhere, plain http only against loopback (test/ops overrides); reject the rest.""" - parsed = urlsplit(url) - if parsed.scheme == "https" or (parsed.scheme == "http" and parsed.hostname in _LOOPBACK_HOSTS): - return - raise RuntimeError(f"refusing insecure hai-agent-runtime download URL (need https): {url}") - - -class RuntimeArtifactUnavailable(NotImplementedError): - """Raised when the current platform has no published managed runtime artifact.""" - - -class RuntimeArtifact(BaseModel): - url: str - sha256: str - - -def _artifact(filename: str, sha256: str) -> RuntimeArtifact: - """A published release file resolved to its pinned, version-scoped CDN URL.""" - return RuntimeArtifact(url=f"{RUNTIME_CDN_BASE}/{PINNED_RUNTIME_VERSION}/{filename}", sha256=sha256) - - -MANIFEST: dict[str, RuntimeArtifact] = { - "darwin-arm64": _artifact( - "hai-agent-runtime-darwin-arm64.zip", - "1aed0055898116732aee031dc4a1235782b2909ee51e0367e2d50bb3be6671c9", - ), - "windows-x86_64": _artifact( - "hai-agent-runtime-windows-x86_64.zip", - "4e6b2bcd42af2bb6b22197fcde947327497f5c62fd60d48bc9037730d80dc691", - ), -} - -UNIMPLEMENTED_PLATFORMS: dict[str, str] = { - "darwin-x86_64": "hai-agent-runtime is not published for macOS Intel yet", - "linux-x86_64": "hai-agent-runtime is not published for Linux yet", -} - - -def platform_key() -> str: - if sys.platform == "darwin": - system = "darwin" - elif sys.platform.startswith("linux"): - system = "linux" - elif sys.platform == "win32": - system = "windows" - else: - raise RuntimeError(f"unsupported platform for hai-agent-runtime: {sys.platform}") - machine = platform.machine().lower() - arch = {"arm64": "arm64", "aarch64": "arm64", "x86_64": "x86_64", "amd64": "x86_64"}.get(machine) - if arch is None: - raise RuntimeError(f"unsupported architecture for hai-agent-runtime: {machine}") - return f"{system}-{arch}" - - -def pinned_artifact(*, settings: RuntimeInstallSettings) -> RuntimeArtifact: - """Artifact to install: env override (tests/ops) or the pinned per-platform manifest entry.""" - if settings.download_url: - if not settings.download_sha256: - raise RuntimeError( - f"{DOWNLOAD_URL_ENV} is set but {DOWNLOAD_SHA256_ENV} is not; refusing an unverified download" - ) - return RuntimeArtifact(url=settings.download_url, sha256=settings.download_sha256) - key = platform_key() - if key in UNIMPLEMENTED_PLATFORMS: - raise RuntimeArtifactUnavailable( - f"{UNIMPLEMENTED_PLATFORMS[key]}; put hai-agent-runtime on PATH, " - f"or set {DOWNLOAD_URL_ENV} + {DOWNLOAD_SHA256_ENV} to a trusted build" - ) - artifact = MANIFEST.get(key) - if artifact is None: - raise RuntimeError(f"no hai-agent-runtime release artifact for platform {key}") - if artifact.sha256 == PLACEHOLDER_SHA256: - raise RuntimeError( - f"hai-agent-runtime v{PINNED_RUNTIME_VERSION} has no published artifact for {key} yet; " - f"put hai-agent-runtime on PATH, or set {DOWNLOAD_URL_ENV} + {DOWNLOAD_SHA256_ENV} to a trusted build" - ) - return artifact - - -def _find_binary(root: Path) -> Path | None: - direct = root / BINARY_NAME - if direct.is_file(): - return direct - # macOS app-bundle shape: /.app/Contents/MacOS/hai-agent-runtime - for candidate in sorted(root.glob("*.app/Contents/MacOS/hai-agent-runtime")): - if candidate.is_file(): - return candidate - return None - - -def installed_binary(version: str) -> Path | None: - """The managed install's executable for `version`, or None if absent/incomplete.""" - return _find_binary(RUNTIME_DIR / version) - - -FIRST_RUN_MARKER = ".first-run-complete" - - -def first_run_pending(version: str) -> bool: - """True until a task completes against the managed install (including before it is downloaded).""" - return not (RUNTIME_DIR / version / FIRST_RUN_MARKER).exists() - - -def mark_first_run_complete(version: str) -> None: - version_dir = RUNTIME_DIR / version - version_dir.mkdir(parents=True, exist_ok=True) - (version_dir / FIRST_RUN_MARKER).touch() - - -def confirm_download() -> bool: - """One-line TTY confirmation (default yes); non-TTY contexts (mcp/acp hosts) download without asking.""" - if not (sys.stdin.isatty() and sys.stderr.isatty()): - logger.info("hai-agent-runtime %s not found; downloading to %s", PINNED_RUNTIME_VERSION, RUNTIME_DIR) - return True - console = Console(stderr=True) - console.print(f"[bold]hai-agent-runtime[/bold] v{PINNED_RUNTIME_VERSION} is not installed.") - return Confirm.ask(f"Download it to [cyan]{RUNTIME_DIR}[/cyan]?", default=True, console=console) - - -def install_runtime(artifact: RuntimeArtifact) -> Path: - """Download, sha256-verify, and atomically install `artifact` as the pinned runtime; returns the executable path.""" - RUNTIME_DIR.mkdir(parents=True, exist_ok=True) - version_dir = RUNTIME_DIR / PINNED_RUNTIME_VERSION - - # Stage on the same filesystem as the final location so os.replace stays atomic. - with tempfile.TemporaryDirectory(dir=RUNTIME_DIR, prefix=".staging-") as staging_str: - staging = Path(staging_str) - download_path = staging / "artifact" - actual_sha256 = _download_to(artifact.url, download_path) - if actual_sha256 != artifact.sha256.lower(): - raise RuntimeError( - f"hai-agent-runtime download failed sha256 verification: expected {artifact.sha256}, " - f"got {actual_sha256} (url: {artifact.url})" - ) - - staged_version = staging / "version" - staged_version.mkdir() - if artifact.url.endswith(".zip"): - # Contents are sha256-verified above, so extraction is trusted. - with zipfile.ZipFile(download_path) as archive: - archive.extractall(staged_version) - else: - shutil.move(download_path, staged_version / BINARY_NAME) - - binary = _find_binary(staged_version) - if binary is None: - raise RuntimeError(f"downloaded artifact contains no hai-agent-runtime executable (url: {artifact.url})") - binary.chmod(0o755) # zipfile does not preserve the exec bit - - try: - os.replace(staged_version, version_dir) - except OSError: - # Target occupied: a concurrent installer won the race, or a half-finished dir is in the way. - existing = installed_binary(PINNED_RUNTIME_VERSION) - if existing is not None: - logger.info("hai-agent-runtime %s already installed by a concurrent run", PINNED_RUNTIME_VERSION) - return existing - shutil.rmtree(version_dir, ignore_errors=True) - os.replace(staged_version, version_dir) - - installed = installed_binary(PINNED_RUNTIME_VERSION) - assert installed is not None, "atomic rename just published the staged install" - logger.info("installed hai-agent-runtime %s at %s", PINNED_RUNTIME_VERSION, installed) - return installed - - -def ensure_managed_runtime(*, settings: RuntimeInstallSettings, assume_yes: bool = False) -> Path: - """Return the pinned managed runtime, downloading it when absent and approved.""" - existing = installed_binary(PINNED_RUNTIME_VERSION) - if existing is not None: - return existing - artifact = pinned_artifact(settings=settings) - if not assume_yes and not confirm_download(): - raise RuntimeError("hai-agent-runtime download declined") - return install_runtime(artifact) - - -def _download_to(url: str, dest: Path) -> str: - """Stream `url` into `dest`; returns the sha256 hex digest of the bytes written.""" - _require_secure_url(url) - digest = hashlib.sha256() - written = 0 - try: - with ( - httpx.Client(follow_redirects=True, timeout=_DOWNLOAD_TIMEOUT) as client, - client.stream("GET", url) as response, - ): - _require_secure_url(str(response.url)) # a redirect must not downgrade to plain http - if response.status_code != 200: - raise RuntimeError(f"hai-agent-runtime download failed: HTTP {response.status_code} from {url}") - total = int(response.headers.get("Content-Length", "0")) or None - if total is not None and total > MAX_DOWNLOAD_BYTES: - raise RuntimeError(f"hai-agent-runtime download too large: {total} bytes exceeds {MAX_DOWNLOAD_BYTES}") - with dest.open("wb") as fh, _progress(url, total) as advance: - for chunk in response.iter_bytes(): - written += len(chunk) - if written > MAX_DOWNLOAD_BYTES: - raise RuntimeError(f"hai-agent-runtime download exceeded {MAX_DOWNLOAD_BYTES} bytes; aborting") - digest.update(chunk) - fh.write(chunk) - advance(len(chunk)) - except httpx.HTTPError as exc: - raise RuntimeError(f"hai-agent-runtime download failed: {exc} (url: {url})") from exc - return digest.hexdigest() - - -@contextmanager -def _progress(url: str, total: int | None) -> Iterator[Callable[[int], None]]: - """Rich progress bar on a TTY; plain log lines otherwise.""" - if not sys.stderr.isatty(): - logger.info("downloading hai-agent-runtime from %s", url) - yield lambda _n: None - logger.info("download complete") - return - - with Progress( - "[progress.description]{task.description}", - BarColumn(), - DownloadColumn(), - TransferSpeedColumn(), - console=Console(stderr=True), - ) as progress: - task_id = progress.add_task("hai-agent-runtime", total=total) - yield lambda n: progress.update(task_id, advance=n) diff --git a/src/holo_desktop/agent_client/sdk_runtime.py b/src/holo_desktop/agent_client/sdk_runtime.py new file mode 100644 index 0000000..194f2c3 --- /dev/null +++ b/src/holo_desktop/agent_client/sdk_runtime.py @@ -0,0 +1,138 @@ +"""Bridge Holo's spawn-time configuration into the hai-agents local runtime.""" + +from __future__ import annotations + +import logging +import os +from pathlib import Path + +from hai_agents.local import LocalRuntime +from pydantic import BaseModel + +from holo_desktop.agent_client.model_gateway import PRODUCTION_GATEWAY_URL +from holo_desktop.settings import ( + RUNTIME_BASE_URL_ENV, + HoloSettings, + load_holo_settings, +) + +logger = logging.getLogger(__name__) + +LOOPBACK_HOST = "127.0.0.1" +MODELS_API_BASE_URL_ENV = "HAI_BASE_URL" +SPAWN_TIMEOUT_S = 45.0 +# Disabled by default: without a Datadog Agent, ddtrace only adds noise and a shutdown flush hang. +DDTRACE_DEFAULT_OFF: dict[str, str] = { + "DD_TRACE_ENABLED": "false", + "DD_LLMOBS_ENABLED": "false", +} + + +def apply_hosted_gateway_default(env: dict[str, str]) -> None: + """Default hosted runtime calls to the production gateway unless the caller chose another gateway.""" + if not env.get(MODELS_API_BASE_URL_ENV, "").strip(): + env[MODELS_API_BASE_URL_ENV] = PRODUCTION_GATEWAY_URL + + +def runtime_child_env(extra: dict[str, str], *, settings: HoloSettings) -> dict[str, str]: + """Inherited env for the runtime child, plus `extra`; drops the portal key when a custom inference base URL is set.""" + env = {**DDTRACE_DEFAULT_OFF, **os.environ, **extra} + runtime_base_url = (extra.get(RUNTIME_BASE_URL_ENV) or settings.runtime.base_url or "").strip() + # A custom base URL points the runtime at a self-hosted endpoint; the portal HAI_API_KEY must not leak to it. + if runtime_base_url: + env[RUNTIME_BASE_URL_ENV] = runtime_base_url + env.pop("HAI_API_KEY", None) + else: + env.pop(RUNTIME_BASE_URL_ENV, None) + apply_hosted_gateway_default(env) + return env + + +def port_from_env(*, settings: HoloSettings) -> int: + """Agent-API port from ``HAI_AGENT_RUNTIME_PORT``, falling back to the default port.""" + return settings.runtime.port + + +class SpawnConfig(BaseModel): + """Spawn-time knobs for the binary; they only reach a freshly spawned process.""" + + port: int + model: str | None = None + base_url: str | None = None + fake: bool = False + fast: bool = False + # None leaves the binary's own default (~/.holo/runs). + runs_dir: Path | None = None + # Explicit CLI config must not be silently ignored on attach; env-derived config attaches best-effort. + require_fresh_for_config: bool = True + + +def spawn_config_from_env(*, settings: HoloSettings) -> SpawnConfig: + """:class:`SpawnConfig` purely from ``HAI_AGENT_RUNTIME_*`` env (stdio servers: mcp/acp).""" + runtime = settings.runtime + return SpawnConfig( + port=runtime.port, + model=runtime.model, + base_url=runtime.base_url, + require_fresh_for_config=False, + fake=runtime.fake, + fast=runtime.fast, + # HAI_AGENT_RUNTIME_RUNS_DIR reaches the spawned binary via inherited env. + runs_dir=None, + ) + + +def _runtime_flags_env(config: SpawnConfig) -> dict[str, str]: + """The HAI_AGENT_RUNTIME_* deltas a fresh spawn needs (auth token + port are SDK-owned).""" + extra: dict[str, str] = {} + if config.fake: + extra["HAI_AGENT_RUNTIME_FAKE"] = "1" + if config.fast: + extra["HAI_AGENT_RUNTIME_FAST"] = "1" + if config.model: + extra["HAI_AGENT_RUNTIME_MODEL"] = config.model + if config.base_url: + extra[RUNTIME_BASE_URL_ENV] = config.base_url + if config.runs_dir: + # A quoted "~/..." bypasses shell expansion; never hand the binary a literal tilde. + extra["HAI_AGENT_RUNTIME_RUNS_DIR"] = str(config.runs_dir.expanduser()) + return extra + + +def _reject_ignored_flags(config: SpawnConfig, base_url: str) -> None: + """Preserved ensure_running guard: a running runtime keeps the config it was started with.""" + valued = (("--model", config.model), ("--base-url", config.base_url), ("--runs-dir", config.runs_dir)) + requested = [f"{name} {value}" for name, value in valued if value] + requested += [name for name, on in (("--fake", config.fake), ("--fast", config.fast)) if on] + if config.require_fresh_for_config and requested: + flags = " ".join(requested) + raise RuntimeError( + f"An agent server is already running at {base_url} and keeps the configuration " + f"it was started with, so '{flags}' would be silently ignored. " + "Stop that server, or pass a different --port to spawn a fresh one with your flags." + ) + + +def ensure_local_runtime(config: SpawnConfig, *, settings: HoloSettings) -> LocalRuntime: + """Attach to a running runtime on ``config.port`` or spawn one through the SDK. + + inherit_env=False makes spawn_env the complete child environment used verbatim + (plan 002 contract), so runtime_child_env keeps owning the os.environ merge, the + ddtrace defaults, the hosted-gateway default, and the self-hosted HAI_API_KEY strip. + """ + existing = LocalRuntime.attach(port=config.port) + if existing is not None: + _reject_ignored_flags(config, existing.base_url) + return existing + return LocalRuntime.ensure_started( + port=config.port, + spawn_env=runtime_child_env(_runtime_flags_env(config), settings=settings), + inherit_env=False, + timeout_s=SPAWN_TIMEOUT_S, + ) + + +def ensure_local_runtime_from_env() -> LocalRuntime: + """``ensure_local_runtime`` configured purely from ``HAI_AGENT_RUNTIME_*`` env (stdio servers: mcp/acp).""" + settings = load_holo_settings() + return ensure_local_runtime(spawn_config_from_env(settings=settings), settings=settings) diff --git a/src/holo_desktop/cli/acp.py b/src/holo_desktop/cli/acp.py index f0c316c..1d9dc4d 100644 --- a/src/holo_desktop/cli/acp.py +++ b/src/holo_desktop/cli/acp.py @@ -40,12 +40,13 @@ ) from agent_interface.agent_events import ErrorEvent, PolicyEvent, ToolResultEvent from agp_types import TrajectoryEvent, TrajectoryStatus +from hai_agents.local import LocalRuntime from pydantic import JsonValue from holo_desktop import __version__ from holo_desktop.agent_client.client import AgentApiClient from holo_desktop.agent_client.events import PolicyView, parse_agent_event -from holo_desktop.agent_client.launcher import AgentDaemon, ensure_running_from_env +from holo_desktop.agent_client.sdk_runtime import ensure_local_runtime_from_env from holo_desktop.agent_client.session_runner import ( DEFAULT_INTERACTIVE_IDLE_TIMEOUT_S, DEFAULT_MAX_STEPS, @@ -68,7 +69,7 @@ class HoloAcpAgent: def __init__(self) -> None: self._sessions: OrderedDict[str, Session] = OrderedDict() - self._daemon: AgentDaemon | None = None + self._runtime: LocalRuntime | None = None self._client: AgentApiClient | None = None self._api_lock = asyncio.Lock() self._cancelled: set[str] = set() @@ -80,8 +81,9 @@ async def _api(self) -> AgentApiClient: if self._client is None: async with self._api_lock: if self._client is None: - self._daemon = await ensure_running_from_env() - self._client = AgentApiClient(self._daemon.base_url, self._daemon.token) + # May download the runtime on first run; keep that off the event loop. + self._runtime = await asyncio.to_thread(ensure_local_runtime_from_env) + self._client = AgentApiClient(self._runtime) return self._client async def initialize( @@ -263,8 +265,9 @@ async def aclose(self) -> None: for session in list(self._sessions.values()): await self._cancel_session(session) await self._client.aclose() - if self._daemon is not None: - await self._daemon.aclose() + # Stop the runtime only if this server spawned it; attached runtimes belong to their spawner. + if self._runtime is not None and self._runtime.owned: + await asyncio.to_thread(self._runtime.shutdown) def _stringify(result: JsonValue) -> str: diff --git a/src/holo_desktop/cli/agent_api.py b/src/holo_desktop/cli/agent_api.py index ec5436b..3e94aa9 100644 --- a/src/holo_desktop/cli/agent_api.py +++ b/src/holo_desktop/cli/agent_api.py @@ -1,19 +1,17 @@ -"""`holo agent-api`: launch the hai-agent-runtime binary in the foreground.""" +"""`holo agent-api`: spawn the hai-agent-runtime through the SDK and tail its log.""" from __future__ import annotations -import subprocess -from typing import Annotated +import time +from pathlib import Path +from typing import TYPE_CHECKING, Annotated import tyro -from holo_desktop.agent_client.launcher import ( - AGENT_API_DEFAULT_PORT, - AUTH_TOKEN_ENV, - resolve_command, - runtime_child_env, -) -from holo_desktop.settings import load_holo_settings +from holo_desktop.settings import AGENT_API_DEFAULT_PORT, load_holo_settings + +if TYPE_CHECKING: + from rich.console import Console def agent_api( @@ -22,9 +20,10 @@ def agent_api( base_url: str | None = None, fake: Annotated[bool, tyro.conf.arg(help="Run the binary in fake-agent mode (no model/desktop).")] = False, ) -> None: - """Start the hai-agent-runtime agent API on 127.0.0.1 (foreground).""" + """Start the hai-agent-runtime agent API on 127.0.0.1 and tail its log (Ctrl+C stops it).""" from rich.console import Console + from holo_desktop.agent_client.sdk_runtime import SpawnConfig, ensure_local_runtime from holo_desktop.cli.bootstrap import load_holo_env, require_api_key load_holo_env() @@ -33,25 +32,45 @@ def agent_api( require_api_key(explicit_base_url=base_url, settings=settings) console = Console(stderr=True) - extra = {"HAI_AGENT_RUNTIME_PORT": str(port)} - if fake: - extra["HAI_AGENT_RUNTIME_FAKE"] = "1" - if model: - extra["HAI_AGENT_RUNTIME_MODEL"] = model - if base_url: - extra["HAI_AGENT_RUNTIME_BASE_URL"] = base_url - env = runtime_child_env(extra, settings=settings) - try: - cmd = resolve_command(settings=settings) + runtime = ensure_local_runtime( + SpawnConfig(port=port, model=model, base_url=base_url, fake=fake), settings=settings + ) except RuntimeError as exc: console.print(f"[bold red]✗[/bold red] {exc}") raise SystemExit(1) from exc - if AUTH_TOKEN_ENV not in env: - console.print(f"[dim]The binary will print an {AUTH_TOKEN_ENV} to export for clients.[/dim]") + console.print( + f"[dim]agent API at {runtime.base_url} (pid {runtime.pid}, " + f"v{runtime.version or 'unknown'}); log: {runtime.log_path}; Ctrl+C to stop.[/dim]" + ) try: - completed = subprocess.run(cmd, env=env, check=False) + if runtime.log_path is not None: + _tail(runtime.log_path, console) + else: + # A spawned runtime always has a log path; this only fires on an oddly-shaped attach. + console.print("[dim]no runtime log to tail; Ctrl+C to stop.[/dim]") + while True: + time.sleep(0.5) except KeyboardInterrupt: - raise SystemExit(0) from None - raise SystemExit(completed.returncode) + pass + finally: + if runtime.owned: + runtime.shutdown() + raise SystemExit(0) + + +def _tail(path: Path, console: Console) -> None: + """Follow `path` forever, printing appended chunks (the foreground-stderr replacement).""" + offset = 0 + while True: + try: + with path.open("r", encoding="utf-8", errors="replace") as fh: + fh.seek(offset) + chunk = fh.read() + offset = fh.tell() + except OSError: + chunk = "" + if chunk: + console.out(chunk, end="") + time.sleep(0.5) diff --git a/src/holo_desktop/cli/doctor.py b/src/holo_desktop/cli/doctor.py index c92506e..cddc5ca 100644 --- a/src/holo_desktop/cli/doctor.py +++ b/src/holo_desktop/cli/doctor.py @@ -2,26 +2,19 @@ from __future__ import annotations -import asyncio import platform import shutil +from hai_agents.local import LocalRuntime from pydantic import BaseModel from holo_desktop import customization -from holo_desktop.agent_client import launcher, runtime_install -from holo_desktop.agent_client.launcher import ( - AUTH_TOKEN_ENV, - LOOPBACK_HOST, - log_tail_suggests_permissions, - port_from_env, - probe_health, - token_file_path, -) +from holo_desktop.agent_client import permissions +from holo_desktop.agent_client.sdk_runtime import port_from_env from holo_desktop.cli import bootstrap from holo_desktop.cli.bootstrap import load_holo_env, read_user_env_key from holo_desktop.cli.profile import load_profile -from holo_desktop.settings import HoloSettings, load_holo_settings +from holo_desktop.settings import AUTH_TOKEN_ENV, HoloSettings, load_holo_settings ACCESSIBILITY_SETTINGS_URL = "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility" SCREEN_RECORDING_SETTINGS_URL = "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture" @@ -38,16 +31,11 @@ def check_binary() -> CheckResult: on_path = shutil.which("hai-agent-runtime") if on_path: return CheckResult(name="binary", ok=True, detail=f"on PATH: {on_path}") - managed = runtime_install.installed_binary(runtime_install.PINNED_RUNTIME_VERSION) - if managed is not None: - return CheckResult( - name="binary", ok=True, detail=f"managed install (v{runtime_install.PINNED_RUNTIME_VERSION}): {managed}" - ) + # Install/version pinning is SDK-owned; there is no local manifest to inspect anymore. return CheckResult( name="binary", - ok=False, - detail="hai-agent-runtime not found (not on PATH, no managed install)", - fix="any `holo run` downloads it automatically; or put hai-agent-runtime on PATH", + ok=True, + detail="managed by the hai-agents SDK (downloads on the first run)", ) @@ -71,27 +59,36 @@ def check_login(settings: HoloSettings) -> CheckResult: def check_agent_api(settings: HoloSettings) -> CheckResult: port = port_from_env(settings=settings) - probe = asyncio.run(probe_health(f"http://{LOOPBACK_HOST}:{port}")) - if probe is None: - return CheckResult(name="agent-api", ok=True, detail=f"no server on port {port} (spawns on demand)") - version = probe.version or "unknown version" - if version != runtime_install.PINNED_RUNTIME_VERSION and probe.version is not None: - version = f"{version} (client pins {runtime_install.PINNED_RUNTIME_VERSION})" - has_token = bool(settings.runtime.api_token) or token_file_path(port).is_file() - if not has_token: + runtime = LocalRuntime.attach(port=port) + if runtime is None: + return CheckResult(name="agent-api", ok=True, detail=f"no runtime on port {port} (spawns on demand)") + try: + runtime.health() + except Exception as exc: + return CheckResult( + name="agent-api", + ok=False, + detail=f"runtime on port {port} (pid {runtime.pid}) failed /health: {exc}", + fix="run `holo stop --force`, then re-run your command to spawn a fresh runtime", + ) + version = runtime.version or "unknown version" + if not runtime.api_key: return CheckResult( name="agent-api", ok=False, - detail=f"server running on port {port} ({version}) but no credentials to attach", + detail=f"runtime on port {port} ({version}) but no credentials to attach", fix=f"export {AUTH_TOKEN_ENV}, or stop that server so holo can spawn its own", ) - return CheckResult(name="agent-api", ok=True, detail=f"server running on port {port} ({version}), token available") + return CheckResult(name="agent-api", ok=True, detail=f"runtime on port {port} ({version}), credentials available") -def check_holo_dir() -> CheckResult: +def check_holo_dir(settings: HoloSettings) -> CheckResult: skills = sorted(customization.SKILLS_DIR.glob("*/SKILL.md")) - logs = sorted(launcher.LOG_DIR.glob("hai-agent-runtime-*.log")) if launcher.LOG_DIR.is_dir() else [] - log_note = f"; latest runtime log: {logs[-1]}" if logs else "" + # Only an attached runtime can point us at the SDK-owned log location; otherwise drop the note. + runtime = LocalRuntime.attach(port=port_from_env(settings=settings)) + log_note = "" + if runtime is not None and runtime.log_path is not None: + log_note = f"; runtime log: {runtime.log_path}" if not skills: return CheckResult( name="holo-dir", @@ -108,14 +105,14 @@ def permissions_guidance_needed(port: int) -> bool: if platform.system() != "Darwin": return False # A PATH binary (dev setup) never gets a first-run marker, so only the managed install counts as pending. - managed_first_run_pending = shutil.which("hai-agent-runtime") is None and runtime_install.first_run_pending( - runtime_install.PINNED_RUNTIME_VERSION - ) - return managed_first_run_pending or log_tail_suggests_permissions(port) + managed_first_run_pending = shutil.which("hai-agent-runtime") is None and permissions.first_run_pending() + runtime = LocalRuntime.attach(port=port) + log_path = runtime.log_path if runtime is not None else None + return managed_first_run_pending or permissions.log_tail_suggests_permissions(log_path) def run_checks(settings: HoloSettings) -> list[CheckResult]: - return [check_binary(), check_login(settings), check_agent_api(settings), check_holo_dir()] + return [check_binary(), check_login(settings), check_agent_api(settings), check_holo_dir(settings)] def doctor() -> None: diff --git a/src/holo_desktop/cli/mcp.py b/src/holo_desktop/cli/mcp.py index 1a3664b..9beb835 100644 --- a/src/holo_desktop/cli/mcp.py +++ b/src/holo_desktop/cli/mcp.py @@ -8,11 +8,12 @@ from dataclasses import dataclass from agp_types import TrajectoryEvent, TrajectoryStatus +from hai_agents.local import LocalRuntime from mcp.server.fastmcp import Context, FastMCP from holo_desktop.agent_client.client import AgentApiClient from holo_desktop.agent_client.events import format_event -from holo_desktop.agent_client.launcher import AgentDaemon, ensure_running_from_env +from holo_desktop.agent_client.sdk_runtime import ensure_local_runtime_from_env from holo_desktop.agent_client.session_runner import ( DEFAULT_MAX_STEPS, DEFAULT_MAX_TIME_S, @@ -38,21 +39,24 @@ @dataclass class Lifespan: client: AgentApiClient - daemon: AgentDaemon + runtime: LocalRuntime active_session: Session | None = None @asynccontextmanager async def lifespan(_: FastMCP) -> AsyncIterator[Lifespan]: - daemon = await ensure_running_from_env() - client = AgentApiClient(daemon.base_url, daemon.token) - state = Lifespan(client=client, daemon=daemon) + # ensure_local_runtime_from_env may download the runtime on first run; keep that off the event loop. + runtime = await asyncio.to_thread(ensure_local_runtime_from_env) + client = AgentApiClient(runtime) + state = Lifespan(client=client, runtime=runtime) try: yield state finally: await _cancel_active_sessions_best_effort(state) await client.aclose() - await daemon.aclose() + # Stop the runtime only if this server spawned it; attached runtimes belong to their spawner. + if runtime.owned: + await asyncio.to_thread(runtime.shutdown) mcp_app = FastMCP("holo-desktop", instructions=INSTRUCTIONS, lifespan=lifespan) diff --git a/src/holo_desktop/cli/run.py b/src/holo_desktop/cli/run.py index 8752979..d398907 100644 --- a/src/holo_desktop/cli/run.py +++ b/src/holo_desktop/cli/run.py @@ -11,21 +11,16 @@ import httpx import tyro +from hai_agents.local import LocalRuntimeError if TYPE_CHECKING: from rich.console import Console from holo_desktop.killswitch.listener import StopListener -from holo_desktop.agent_client import runtime_install -from holo_desktop.agent_client.launcher import ( - AGENT_API_DEFAULT_PORT, - PORT_ENV, - log_tail_suggests_permissions, - port_from_env, - text_suggests_permissions, -) -from holo_desktop.settings import HoloSettings +from holo_desktop.agent_client import permissions +from holo_desktop.agent_client.sdk_runtime import port_from_env +from holo_desktop.settings import AGENT_API_DEFAULT_PORT, PORT_ENV, HoloSettings logger = logging.getLogger(__name__) @@ -80,7 +75,7 @@ def run( # Per-request HTTP chatter is implementation detail, not user output. logging.getLogger("httpx").setLevel(logging.WARNING) if quiet: - logging.getLogger("holo_desktop.agent_client.launcher").setLevel(logging.WARNING) + logging.getLogger("hai_agents.local").setLevel(logging.WARNING) settings = bootstrap_interactive(base_url=base_url, fake=fake) @@ -108,7 +103,7 @@ def die(title: str, message: str) -> None: sys.platform == "darwin" and not fake and shutil.which("hai-agent-runtime") is None - and runtime_install.first_run_pending(runtime_install.PINNED_RUNTIME_VERSION) + and permissions.first_run_pending() ) if walkthrough_pending and not quiet: err.print( @@ -124,8 +119,8 @@ def die(title: str, message: str) -> None: ) ) - def attempt() -> tuple[str | None, str | None, str | None, bool]: - """One full session; the trailing bool reports whether this run spawned the runtime.""" + def attempt() -> tuple[str | None, str | None, str | None, bool, Path | None]: + """One full session; reports whether this run spawned the runtime, and its stderr log path.""" try: return asyncio.run( _drive( @@ -157,10 +152,10 @@ def attempt() -> tuple[str | None, str | None, str | None, bool]: enabled=not no_kill_switch and not fake and is_interactive_tty(), quiet=quiet, err=err ) try: - answer, status, error, spawned = attempt() + answer, status, error, spawned, log_path = attempt() # The runtime may surface a TCC failure only via the session error, not its stderr log: check both. - permission_shaped = log_tail_suggests_permissions(resolved_port) or ( - error is not None and text_suggests_permissions(error) + permission_shaped = permissions.log_tail_suggests_permissions(log_path) or ( + error is not None and permissions.text_suggests_permissions(error) ) if walkthrough_pending and status == "failed" and permission_shaped: if spawned: @@ -168,16 +163,16 @@ def attempt() -> tuple[str | None, str | None, str | None, bool]: "[yellow]→[/yellow] [dim]permission grants only apply after a runtime restart; " "restarting the runtime and retrying once[/dim]" ) - answer, status, error, spawned = attempt() + answer, status, error, spawned, log_path = attempt() else: - # Attached runtime: aclose() was a no-op, so a retry reuses the same process and grants stay unlatched. + # Attached runtime: shutdown was a no-op, so a retry reuses the same process and grants stay unlatched. err.print( f"[yellow]→[/yellow] [dim]permission grants only apply after a runtime restart, but the " f"runtime on port {resolved_port} was started by another Holo process (e.g. holo serve or " "holo mcp). Restart that process so the grants latch, or pass --port to spawn a fresh " "runtime here.[/dim]" ) - except (RuntimeError, httpx.HTTPError) as exc: + except (RuntimeError, LocalRuntimeError, httpx.HTTPError) as exc: die(type(exc).__name__, str(exc)) return finally: @@ -191,7 +186,7 @@ def attempt() -> tuple[str | None, str | None, str | None, bool]: if status in ("interrupted", "timed_out"): die(status, error or f"session {status}") if walkthrough_pending: - runtime_install.mark_first_run_complete(runtime_install.PINNED_RUNTIME_VERSION) + permissions.mark_first_run_complete() if answer: if quiet: out.print(Text(answer)) @@ -232,8 +227,8 @@ async def _drive( profile: bool, expand: bool, settings: HoloSettings, -) -> tuple[str | None, str | None, str | None, bool]: - """Spawn/attach the binary, run one turn, return (answer, status, error, spawned).""" +) -> tuple[str | None, str | None, str | None, bool, Path | None]: + """Spawn/attach the binary, run one turn, return (answer, status, error, spawned, log_path).""" from agp_types import TrajectoryEvent from rich.console import Console @@ -243,19 +238,21 @@ async def _drive( find_session_event_log, render_step_timings, ) - from holo_desktop.agent_client.launcher import SpawnConfig, ensure_running + from holo_desktop.agent_client.sdk_runtime import SpawnConfig, ensure_local_runtime from holo_desktop.agent_client.session_runner import Session, run_turn from holo_desktop.terminal.feed import LiveFeed console = Console(stderr=True) - daemon = await ensure_running( + # ensure_local_runtime may download the runtime on first run; keep that off the event loop. + runtime = await asyncio.to_thread( + ensure_local_runtime, SpawnConfig(port=port, model=model, base_url=base_url, fake=fake, fast=fast, runs_dir=runs_dir), settings=settings, ) - spawned = daemon.proc is not None + spawned = runtime.owned try: - async with AgentApiClient(daemon.base_url, daemon.token) as client: + async with AgentApiClient(runtime) as client: session = Session() feed = None if quiet else LiveFeed(console, expand=expand) @@ -279,9 +276,12 @@ async def render(event: TrajectoryEvent) -> None: # Render even on failure: partial timings are still useful. render_step_timings(timing, console) status = outcome.status.value if outcome.status is not None else None - return outcome.answer, status, outcome.error, spawned + return outcome.answer, status, outcome.error, spawned, runtime.log_path finally: - await daemon.aclose() + # Same semantics as the old daemon.aclose(): stop the runtime only if this run spawned it, + # which is also what makes the one-shot TCC retry respawn a fresh process. + if runtime.owned: + await asyncio.to_thread(runtime.shutdown) def _arm_kill_switch(*, enabled: bool, quiet: bool, err: Console) -> StopListener | None: diff --git a/src/holo_desktop/cli/serve.py b/src/holo_desktop/cli/serve.py index 2097ea8..63aadde 100644 --- a/src/holo_desktop/cli/serve.py +++ b/src/holo_desktop/cli/serve.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio import hashlib import hmac import logging @@ -38,6 +39,7 @@ TaskStatusUpdateEvent, ) from agp_types import TrajectoryEvent, TrajectoryStatus +from hai_agents.local import LocalRuntime from pydantic import BaseModel, ConfigDict from starlette.applications import Starlette from starlette.middleware import Middleware @@ -49,7 +51,12 @@ from holo_desktop import __version__ from holo_desktop.agent_client.client import AgentApiClient -from holo_desktop.agent_client.launcher import LOOPBACK_HOST, AgentDaemon, SpawnConfig, ensure_running, port_from_env +from holo_desktop.agent_client.sdk_runtime import ( + LOOPBACK_HOST, + SpawnConfig, + ensure_local_runtime, + port_from_env, +) from holo_desktop.agent_client.session_runner import ( DEFAULT_INTERACTIVE_IDLE_TIMEOUT_S, SUCCESSFUL_TURN_STATUSES, @@ -135,12 +142,14 @@ def __init__(self, *, model: str | None, base_url: str | None, fake: bool, setti self._fake = fake self._settings = settings self._sessions: OrderedDict[str, Session] = OrderedDict() - self._daemon: AgentDaemon | None = None + self._runtime: LocalRuntime | None = None self._client: AgentApiClient | None = None async def startup(self) -> None: # Resolved at startup (after load_holo_env) so HAI_AGENT_RUNTIME_PORT from env counts. - self._daemon = await ensure_running( + # ensure_local_runtime may download the runtime on first run; keep that off the event loop. + self._runtime = await asyncio.to_thread( + ensure_local_runtime, SpawnConfig( port=port_from_env(settings=self._settings), model=self._model, @@ -149,13 +158,14 @@ async def startup(self) -> None: ), settings=self._settings, ) - self._client = AgentApiClient(self._daemon.base_url, self._daemon.token) + self._client = AgentApiClient(self._runtime) async def shutdown(self) -> None: if self._client is not None: await self._client.aclose() - if self._daemon is not None: - await self._daemon.aclose() + # Stop the runtime only if this server spawned it; attached runtimes belong to their spawner. + if self._runtime is not None and self._runtime.owned: + await asyncio.to_thread(self._runtime.shutdown) @property def _api(self) -> AgentApiClient: diff --git a/src/holo_desktop/cli/stop.py b/src/holo_desktop/cli/stop.py index 99c4df0..5fc159e 100644 --- a/src/holo_desktop/cli/stop.py +++ b/src/holo_desktop/cli/stop.py @@ -6,25 +6,23 @@ from typing import Annotated import tyro +from hai_agents.local import LocalRuntime def stop( force: Annotated[ bool, - tyro.conf.arg( - help="Also SIGKILL the hai-agent-runtime process: instant, but ends the session outright. " - "Caveat: a runtime that died uncleanly can leave a stale pid file, so this may target a recycled pid." - ), + tyro.conf.arg(help="Also SIGKILL the hai-agent-runtime process: instant, but ends the session outright."), ] = False, port: Annotated[ int | None, - tyro.conf.arg(help="Runtime port to force-kill; defaults to every spawned runtime found."), + tyro.conf.arg(help="Runtime port to force-kill; defaults to the default port plus any legacy pid files."), ] = None, ) -> None: """Ask any in-flight Holo turn to pause then cancel (the same effect as the double-Esc kill switch).""" from rich.console import Console - from holo_desktop.agent_client.launcher import discover_runtime_pids, kill_runtime_by_pid + from holo_desktop.agent_client.legacy_state import legacy_force_kill from holo_desktop.killswitch.channel import request_stop out = Console(stderr=True) @@ -34,7 +32,16 @@ def stop( if not force: return - killed = [pid for pid in discover_runtime_pids(port) if kill_runtime_by_pid(pid)] + killed: list[int] = [] + # force_kill is a runtime-process kill, not a session kill — the same semantics + # as the old pid-file SIGKILL. attach() reads SDK discovery state, so it finds a + # hung-but-alive runtime even when /health would fail. + runtime = LocalRuntime.attach(port=port) + if runtime is not None and runtime.pid is not None: + runtime.force_kill() + killed.append(runtime.pid) + # One-release compat: runtimes spawned by a pre-SDK holo (see legacy_state docstring). + killed += legacy_force_kill(port) if killed: out.print(f"[red]✗ force-killed runtime[/red] [dim]pid(s) {', '.join(map(str, killed))}[/dim]") else: diff --git a/src/holo_desktop/installer_bootstrap.py b/src/holo_desktop/installer_bootstrap.py index 3b99548..a58379b 100644 --- a/src/holo_desktop/installer_bootstrap.py +++ b/src/holo_desktop/installer_bootstrap.py @@ -4,12 +4,27 @@ import argparse +from hai_agents.local import LocalRuntime, LocalRuntimeError from rich.console import Console +from rich.prompt import Confirm -from holo_desktop.agent_client.runtime_install import RuntimeArtifactUnavailable, ensure_managed_runtime +from holo_desktop.agent_client.sdk_runtime import SpawnConfig, ensure_local_runtime from holo_desktop.cli.bootstrap import load_holo_env from holo_desktop.customization import seed_bundled_skills -from holo_desktop.settings import load_holo_settings +from holo_desktop.settings import HoloSettings, load_holo_settings + + +def _runtime_for_installer(*, settings: HoloSettings, assume_yes: bool) -> LocalRuntime: + """Attach to or start the SDK-managed runtime used by the installed CLI.""" + existing = LocalRuntime.attach(port=settings.runtime.port) + if existing is not None: + return existing + if not assume_yes and not Confirm.ask("Download and start hai-agent-runtime?", default=True): + raise LocalRuntimeError("hai-agent-runtime download declined") + return ensure_local_runtime( + SpawnConfig(port=settings.runtime.port, require_fresh_for_config=False), + settings=settings, + ) def bootstrap_installer(*, yes: bool = False, login: bool = False, install_hosts: bool = False) -> None: @@ -19,11 +34,11 @@ def bootstrap_installer(*, yes: bool = False, login: bool = False, install_hosts settings = load_holo_settings() seed_bundled_skills() try: - runtime_path = ensure_managed_runtime(settings=settings.install, assume_yes=yes) - except RuntimeArtifactUnavailable as exc: + runtime = _runtime_for_installer(settings=settings, assume_yes=yes) + except LocalRuntimeError as exc: err.print(f"[bold red]x[/bold red] {exc}") raise SystemExit(1) from exc - err.print(f"[green]ok[/green] runtime ready: [cyan]{runtime_path}[/cyan]") + err.print(f"[green]ok[/green] runtime ready: [cyan]{runtime.base_url}[/cyan]") if login: from holo_desktop.cli.login import login as run_login diff --git a/tests/test_agent_api_client.py b/tests/test_agent_api_client.py deleted file mode 100644 index 1ad73c2..0000000 --- a/tests/test_agent_api_client.py +++ /dev/null @@ -1,371 +0,0 @@ -"""Behavioural contract tests for ``agent_client.client`` against a fake agent-API server. - -The fake serves the same wire shapes as the canonical ``hai-agent-api`` router -(scripted ``/changes`` + ``/status`` responses) and records every request, so -the tests can assert both client behaviour and that outgoing bodies validate -against the real ``agent_interface`` models. - -Covers the two contract pitfalls documented in the SDK's polling helpers: -- ``POST /messages`` takes a discriminated ``UserMessageEvent | UserMessageBatch`` - (a bare ``{"message": ...}`` is rejected by the server's tagged union); -- ``GET /changes`` 204s whenever no new events exist past ``from_index`` — - even after the session finished — so ``/status`` is authoritative for - terminal detection. -""" - -from __future__ import annotations - -import asyncio -import datetime -import json -import threading -from collections import deque -from collections.abc import AsyncIterator, Iterator -from contextlib import contextmanager -from dataclasses import dataclass, field -from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer -from typing import Annotated, Literal -from urllib.parse import parse_qs, urlparse - -from agent_interface.definition import UserMessageEvent -from agent_interface.specs.session import SessionRequest, SessionStatus -from agp_types import TrajectoryEvent, TrajectoryStatus -from pydantic import BaseModel, Field, TypeAdapter - -from holo_desktop.agent_client.client import AgentApiClient, SessionStream - -SESSION_ID = "11111111-2222-3333-4444-555555555555" -TOKEN = "test-token" -STREAM_TIMEOUT_S = 5.0 - - -class _UserMessageBatch(BaseModel): - """Test replica of ``agent_api.models.session.UserMessageBatch`` (not vendored in hai-agent-api).""" - - type: Literal["batch"] = "batch" - messages: list[UserMessageEvent] = Field(min_length=1) - - -# Mirror of the server-side ``SendMessage`` body: a *tagged* union, so payloads -# without an explicit ``type`` are rejected exactly like the real router does. -_SEND_MESSAGE_ADAPTER: TypeAdapter[UserMessageEvent | _UserMessageBatch] = TypeAdapter( - Annotated[UserMessageEvent | _UserMessageBatch, Field(discriminator="type")] -) - - -@dataclass -class RecordedRequest: - method: str - path: str - query: dict[str, list[str]] - body: dict | None - authorization: str | None - - -@dataclass -class FakeAgentApi: - """Scripted responses + a log of everything the client sent.""" - - # Each GET /changes pops the next entry; None -> 204. Exhausted -> 204. - changes: deque[dict | None] = field(default_factory=deque) - # Each GET /status pops the next entry; the last one sticks when exhausted. - statuses: deque[dict] = field(default_factory=deque) - requests: list[RecordedRequest] = field(default_factory=list) - _last_status: dict | None = None - - def next_changes(self) -> dict | None: - return self.changes.popleft() if self.changes else None - - def next_status(self) -> dict: - if self.statuses: - self._last_status = self.statuses.popleft() - assert self._last_status is not None, "fake server got /status request but no status was scripted" - return self._last_status - - -def _make_handler(api: FakeAgentApi) -> type[BaseHTTPRequestHandler]: - class Handler(BaseHTTPRequestHandler): - def log_message(self, format: str, *args: object) -> None: # stdlib signature; silences request logs - return - - def _record(self) -> None: - length = int(self.headers.get("Content-Length") or 0) - raw = self.rfile.read(length) if length else b"" - api.requests.append( - RecordedRequest( - method=self.command, - path=urlparse(self.path).path, - query=parse_qs(urlparse(self.path).query), - body=json.loads(raw) if raw else None, - authorization=self.headers.get("Authorization"), - ) - ) - - def _json(self, status: int, payload: dict | None) -> None: - self.send_response(status) - if payload is None: - self.end_headers() - return - data = json.dumps(payload).encode("utf-8") - self.send_header("Content-Type", "application/json") - self.send_header("Content-Length", str(len(data))) - self.end_headers() - self.wfile.write(data) - - def do_POST(self) -> None: - self._record() - path = urlparse(self.path).path - if path == "/api/v2/sessions": - self._json(200, {"id": SESSION_ID}) - elif path.endswith("/messages") or path.endswith("/pause"): - self._json(202, None) - else: - self._json(404, None) - - def do_GET(self) -> None: - self._record() - path = urlparse(self.path).path - if path.endswith("/changes"): - payload = api.next_changes() - self._json(204 if payload is None else 200, payload) - elif path.endswith("/status"): - self._json(200, api.next_status()) - else: - self._json(404, None) - - def do_DELETE(self) -> None: - self._record() - self._json(204, None) - - return Handler - - -@contextmanager -def _serve(api: FakeAgentApi) -> Iterator[str]: - server = ThreadingHTTPServer(("127.0.0.1", 0), _make_handler(api)) - thread = threading.Thread(target=server.serve_forever, daemon=True) - thread.start() - try: - yield f"http://127.0.0.1:{server.server_address[1]}" - finally: - server.shutdown() - thread.join(timeout=5.0) - - -def _event(kind: str, **data: object) -> dict: - return { - "type": "AgentEvent", - "data": {"kind": kind, **data}, - "timestamp": datetime.datetime.now(datetime.UTC).isoformat(), - } - - -def _changes(status: str, *, events: list[dict], answer: str | None, error: str | None) -> dict: - return {"status": status, "new_events": events, "answer": answer, "error": error} - - -def _status(status: str, *, error: str | None) -> dict: - return {"status": status, "error": error, "steps": 0} - - -async def _collect(events: AsyncIterator[TrajectoryEvent]) -> list[TrajectoryEvent]: - return [event async for event in events] - - -def _run_stream(api: FakeAgentApi, *, from_index: int) -> tuple[list[TrajectoryEvent], SessionStream]: - """Stream a session to end-of-turn against the fake; bounded so a 204 spin fails the test instead of hanging.""" - - async def go(url: str) -> tuple[list[TrajectoryEvent], SessionStream]: - async with AgentApiClient(url, TOKEN) as client: - stream = client.stream(SESSION_ID, from_index=from_index) - collected = await asyncio.wait_for(_collect(stream.events()), timeout=STREAM_TIMEOUT_S) - return collected, stream - - with _serve(api) as url: - return asyncio.run(go(url)) - - -# --- request bodies ------------------------------------------------------- - - -def test_create_session_sends_bearer_token_and_returns_id() -> None: - api = FakeAgentApi() - with _serve(api) as url: - - async def go() -> str: - async with AgentApiClient(url, TOKEN) as client: - return await client.create_session(SessionRequest(agent="holo", messages="say hi")) - - session_id = asyncio.run(go()) - - assert session_id == SESSION_ID - (req,) = api.requests - assert req.path == "/api/v2/sessions" - assert req.authorization == f"Bearer {TOKEN}" - - -def test_send_message_body_is_a_tagged_user_message_event() -> None: - api = FakeAgentApi() - with _serve(api) as url: - - async def go() -> None: - async with AgentApiClient(url, TOKEN) as client: - await client.send_message(SESSION_ID, "follow-up") - - asyncio.run(go()) - - (req,) = api.requests - assert req.path == f"/api/v2/sessions/{SESSION_ID}/messages" - assert req.body is not None - # Must survive the server's discriminated union: requires an explicit type tag. - parsed = _SEND_MESSAGE_ADAPTER.validate_python(req.body) - assert isinstance(parsed, UserMessageEvent) - assert parsed.message == "follow-up" - - -def test_pause_posts_to_the_pause_endpoint() -> None: - api = FakeAgentApi() - with _serve(api) as url: - - async def go() -> None: - async with AgentApiClient(url, TOKEN) as client: - await client.pause(SESSION_ID) - - asyncio.run(go()) - - (req,) = api.requests - assert req.method == "POST" - assert req.path == f"/api/v2/sessions/{SESSION_ID}/pause" - assert req.authorization == f"Bearer {TOKEN}" - - -# --- streaming ------------------------------------------------------------ - - -def test_stream_yields_events_until_terminal_changes() -> None: - api = FakeAgentApi( - changes=deque( - [ - _changes("running", events=[_event("policy_event"), _event("tool_result")], answer=None, error=None), - _changes("completed", events=[_event("answer_event", answer="done")], answer="done", error=None), - ] - ) - ) - events, stream = _run_stream(api, from_index=0) - assert len(events) == 3 - assert stream.status == TrajectoryStatus.COMPLETED - assert stream.answer == "done" - assert stream.next_index == 3 - - -def test_stream_ends_via_status_when_changes_204s_after_completion() -> None: - # The spin case: the session already finished, so /changes 204s forever - # past our cursor. /status must be consulted, and the final answer fetched. - api = FakeAgentApi( - changes=deque([None, _changes("completed", events=[], answer="late answer", error=None)]), - statuses=deque([_status("completed", error=None)]), - ) - events, stream = _run_stream(api, from_index=0) - assert events == [] - assert stream.status == TrajectoryStatus.COMPLETED - assert stream.answer == "late answer" - - -def test_stream_resume_with_cursor_at_tail_does_not_spin() -> None: - api = FakeAgentApi( - changes=deque([None, _changes("completed", events=[], answer="prior answer", error=None)]), - statuses=deque([_status("completed", error=None)]), - ) - events, stream = _run_stream(api, from_index=3) - assert events == [] - assert stream.next_index == 3 - assert stream.status == TrajectoryStatus.COMPLETED - - -def test_stream_204_while_running_keeps_polling() -> None: - api = FakeAgentApi( - changes=deque( - [ - None, - _changes("completed", events=[_event("answer_event", answer="ok")], answer="ok", error=None), - ] - ), - statuses=deque([_status("running", error=None)]), - ) - events, stream = _run_stream(api, from_index=0) - assert len(events) == 1 - assert stream.status == TrajectoryStatus.COMPLETED - - -def test_stream_idle_status_ends_the_turn() -> None: - # Interactive sessions (idle_timeout_s) go IDLE after answering; that is - # end-of-turn for a streaming consumer, not a reason to poll forever. - api = FakeAgentApi( - changes=deque([None, _changes("idle", events=[], answer="turn answer", error=None)]), - statuses=deque([_status("idle", error=None)]), - ) - events, stream = _run_stream(api, from_index=0) - assert events == [] - assert stream.status == TrajectoryStatus.IDLE - assert stream.answer == "turn answer" - - -def test_stream_idle_changes_fast_path_ends_the_turn() -> None: - api = FakeAgentApi( - changes=deque([_changes("idle", events=[_event("answer_event", answer="hi")], answer="hi", error=None)]), - ) - events, stream = _run_stream(api, from_index=0) - assert len(events) == 1 - assert stream.status == TrajectoryStatus.IDLE - assert stream.answer == "hi" - - -def test_stream_failed_session_reports_error() -> None: - api = FakeAgentApi( - changes=deque([None]), - statuses=deque([_status("failed", error="boom")]), - ) - events, stream = _run_stream(api, from_index=0) - assert events == [] - assert stream.status == TrajectoryStatus.FAILED - assert stream.error == "boom" - - -def test_stream_does_not_carry_stale_error_after_recovery() -> None: - # An error in an early batch must not survive a later successful batch that clears it. - api = FakeAgentApi( - changes=deque( - [ - _changes("running", events=[_event("policy_event")], answer=None, error="transient glitch"), - _changes("completed", events=[_event("answer_event", answer="ok")], answer="ok", error=None), - ] - ) - ) - _events, stream = _run_stream(api, from_index=0) - assert stream.status == TrajectoryStatus.COMPLETED - assert stream.error is None - - -def test_stream_unknown_status_ends_turn_as_failed() -> None: - # An unrecognized status must end the turn, not be treated as non-terminal "running": - # an unknown *terminal* status would otherwise poll until external limits hit. - api = FakeAgentApi(changes=deque([_changes("quantum_flux", events=[], answer=None, error=None)])) - events, stream = _run_stream(api, from_index=0) - assert events == [] - assert stream.status == TrajectoryStatus.FAILED - assert stream.error is not None and "quantum_flux" in stream.error - - -def test_get_status_hits_the_status_endpoint() -> None: - api = FakeAgentApi(statuses=deque([_status("running", error=None)])) - with _serve(api) as url: - - async def go() -> SessionStatus: - async with AgentApiClient(url, TOKEN) as client: - return await client.get_status(SESSION_ID) - - status = asyncio.run(go()) - - assert status.status == TrajectoryStatus.RUNNING - (req,) = api.requests - assert req.path == f"/api/v2/sessions/{SESSION_ID}/status" diff --git a/tests/test_agent_client_adapter.py b/tests/test_agent_client_adapter.py new file mode 100644 index 0000000..af9d3a1 --- /dev/null +++ b/tests/test_agent_client_adapter.py @@ -0,0 +1,195 @@ +"""SessionApi adapter mapping over fake hai-agents handles. + +The wire protocol moved into the SDK (plan 002); these tests only pin the +mapping Holo owns: SessionRequest passthrough, per-session handle routing for +send/pause/cancel, the EventStream projection session_runner reads, the +agp_types normalization of SDK events/statuses, and the ApiError -> httpx +translation the frozen session_runner's dead-session recovery depends on. +""" + +from __future__ import annotations + +import asyncio +import datetime +from types import SimpleNamespace + +import httpx +import pytest +from agp_types import TrajectoryEvent, TrajectoryStatus +from hai_agents.core.api_error import ApiError +from pydantic import BaseModel + +from holo_desktop.agent_client import client as client_module +from holo_desktop.agent_client import events as events_module +from holo_desktop.agent_client.client import AgentApiClient +from holo_desktop.agent_client.requests import build_session_request + + +class _TypedAnswerData(BaseModel): + """Stands in for a Fern typed event payload (a model, not a dict).""" + + kind: str = "answer_event" + answer: str = "done" + + +class _FernishEvent(BaseModel): + """Shape of an SDK SessionEvent: typed data that Holo must flatten back to dicts.""" + + type: str = "AgentEvent" + timestamp: datetime.datetime = datetime.datetime(2026, 6, 30, tzinfo=datetime.UTC) + data: _TypedAnswerData = _TypedAnswerData() + + +class _FakeHandle: + def __init__(self, events: list[object], *, status: str = "completed") -> None: + self.id = "s-1" + self.sent: list[object] = [] + self.paused = 0 + self.cancelled = 0 + self.send_error: Exception | None = None + self._events = events + self._status = status + + async def send_message(self, message: object) -> None: + if self.send_error is not None: + raise self.send_error + self.sent.append(message) + + async def pause(self) -> None: + self.paused += 1 + + async def cancel(self) -> None: + self.cancelled += 1 + + async def status(self): + # The SDK reports plain wire strings (Fern Literal), not agp enums. + return SimpleNamespace(status=self._status, error=None) + + async def changes(self, *, from_index: int = 0, **_kwargs: object): + return SimpleNamespace(answer="done", error=None) + + async def stream(self, *, from_index: int = 0, **_kwargs: object): + for event in self._events[from_index:]: + yield event + + +class _FakeSdkClient: + def __init__(self, handle: _FakeHandle) -> None: + self._handle = handle + self.requests: list[dict[str, object]] = [] + + async def start_session(self, **create_params: object) -> _FakeHandle: + self.requests.append(create_params) + return self._handle + + +@pytest.fixture() +def wired(monkeypatch: pytest.MonkeyPatch) -> tuple[AgentApiClient, _FakeHandle, _FakeSdkClient]: + handle = _FakeHandle(events=[_FernishEvent(), _FernishEvent()]) + sdk = _FakeSdkClient(handle) + monkeypatch.setattr(client_module, "AsyncClient", SimpleNamespace(local=lambda *, runtime=None, **_ignored: sdk)) + return AgentApiClient(SimpleNamespace(base_url="http://127.0.0.1:18795")), handle, sdk + + +def test_create_session_passes_the_holo_request_through(wired) -> None: + api, _handle, sdk = wired + request = build_session_request(task="do it", max_steps=7, max_time_s=60.0, idle_timeout_s=120) + + session_id = asyncio.run(api.create_session(request)) + + assert session_id == "s-1" + # The SDK's start_session takes create_session kwargs; the request content + # must reach it unmodified (same wire dump the old client POSTed directly). + assert sdk.requests == [request.model_dump(mode="json", exclude_none=True)] + + +def test_session_verbs_route_to_the_created_handle(wired) -> None: + api, handle, _sdk = wired + + async def flow() -> None: + session_id = await api.create_session(build_session_request(task="t", max_steps=None, max_time_s=None)) + await api.send_message(session_id, "more") + await api.pause(session_id) + await api.cancel(session_id) + + asyncio.run(flow()) + + assert handle.sent == ["more"] + assert handle.paused == 1 + assert handle.cancelled == 1 + + +def test_stream_projection_matches_the_runner_contract(wired) -> None: + api, _handle, _sdk = wired + + async def flow() -> list[TrajectoryEvent]: + session_id = await api.create_session(build_session_request(task="t", max_steps=None, max_time_s=None)) + stream = api.stream(session_id, from_index=0) + seen = [event async for event in stream.events()] + assert stream.next_index == 2, "resume cursor counts consumed events" + assert stream.status == TrajectoryStatus.COMPLETED + assert stream.answer == "done" + assert stream.error is None + return seen + + assert len(asyncio.run(flow())) == 2 + + +def test_events_are_normalized_to_agp_trajectory_events(wired) -> None: + # The runner-side helpers (events.is_answer, feed rendering, acp/mcp + # translation) require agp TrajectoryEvent with dict data; SDK events carry + # typed pydantic payloads and must be flattened back. + api, _handle, _sdk = wired + + async def flow() -> list[TrajectoryEvent]: + session_id = await api.create_session(build_session_request(task="t", max_steps=None, max_time_s=None)) + return [event async for event in api.stream(session_id, from_index=0).events()] + + seen = asyncio.run(flow()) + assert all(isinstance(event, TrajectoryEvent) for event in seen) + assert all(isinstance(event.data, dict) for event in seen) + assert events_module.is_answer(seen[0]), "the flattened payload must parse as the typed answer event" + assert events_module.answer_text(seen[0]) == "done" + + +def test_unknown_terminal_status_projects_as_failed(wired, monkeypatch: pytest.MonkeyPatch) -> None: + # Rehomed from the old client's _coerce_status: an unknown *terminal* status + # must end the turn as FAILED with an explanatory error, never crash the + # projection or report a phantom success. + api, handle, _sdk = wired + handle._status = "exploded" + + async def flow() -> None: + session_id = await api.create_session(build_session_request(task="t", max_steps=None, max_time_s=None)) + stream = api.stream(session_id, from_index=0) + async for _ in stream.events(): + pass + assert stream.status == TrajectoryStatus.FAILED + assert stream.error is not None and "exploded" in stream.error + + asyncio.run(flow()) + + +def test_dead_session_api_errors_translate_to_httpx(wired) -> None: + # session_runner._create_or_continue (frozen spec) recreates the session on + # httpx.HTTPStatusError with 404/409/410; SDK ApiErrors must surface as that. + api, handle, _sdk = wired + handle.send_error = ApiError(status_code=404, body="session gone") + + async def flow() -> None: + session_id = await api.create_session(build_session_request(task="t", max_steps=None, max_time_s=None)) + with pytest.raises(httpx.HTTPStatusError) as excinfo: + await api.send_message(session_id, "more") + assert excinfo.value.response.status_code == 404 + + asyncio.run(flow()) + + +def test_unknown_session_id_is_rejected(wired) -> None: + api, _handle, _sdk = wired + + async def flow() -> None: + with pytest.raises(RuntimeError, match="unknown agent-API session"): + await api.send_message("nope", "text") + + asyncio.run(flow()) diff --git a/tests/test_bump_runtime.py b/tests/test_bump_runtime.py deleted file mode 100644 index 9d1f138..0000000 --- a/tests/test_bump_runtime.py +++ /dev/null @@ -1,73 +0,0 @@ -"""Behavioural tests for the runtime pin-bump rewriter.""" - -from __future__ import annotations - -import subprocess -import sys -from pathlib import Path - -import bump_runtime -import pytest -from bump_runtime import RuntimeBump, apply_bump - -from holo_desktop.agent_client import runtime_install - -REAL_SOURCE = Path(runtime_install.__file__).read_text() -NEW_DARWIN = "a" * 64 -NEW_WINDOWS = "b" * 64 - - -def test_apply_bump_rewrites_version_and_digests() -> None: - bump = RuntimeBump(version="0.2.0", shas={"darwin-arm64": NEW_DARWIN, "windows-x86_64": NEW_WINDOWS}) - result = apply_bump(REAL_SOURCE, bump) - - assert 'PINNED_RUNTIME_VERSION = "0.2.0"' in result - assert NEW_DARWIN in result and NEW_WINDOWS in result - # The version literal must not appear duplicated in any artifact URL (URLs are derived). - assert result.count('PINNED_RUNTIME_VERSION = "') == 1 - # Untouched platform digests of the original must be gone (both were replaced). - assert "f4085285a9722730408fd5e5dfc36672809ba60250552cf701a5e1198bdc2427" not in result - - -def test_apply_bump_keeps_each_digest_with_its_platform() -> None: - bump = RuntimeBump(version="0.2.0", shas={"darwin-arm64": NEW_DARWIN, "windows-x86_64": NEW_WINDOWS}) - result = apply_bump(REAL_SOURCE, bump) - - darwin_idx = result.index("hai-agent-runtime-darwin-arm64.zip") - windows_idx = result.index("hai-agent-runtime-windows-x86_64.zip") - assert result.index(NEW_DARWIN, darwin_idx) < result.index(NEW_WINDOWS, windows_idx) - # The darwin digest must sit between the darwin filename and the windows entry. - assert darwin_idx < result.index(NEW_DARWIN) < windows_idx - - -def test_apply_bump_rejects_non_hex_sha() -> None: - with pytest.raises(ValueError, match="sha256"): - RuntimeBump(version="0.2.0", shas={"darwin-arm64": "not-a-sha"}) - - -def test_apply_bump_raises_when_platform_filename_absent() -> None: - bump = RuntimeBump(version="0.2.0", shas={"linux-x86_64": "c" * 64}) - with pytest.raises(ValueError, match="linux-x86_64"): - apply_bump(REAL_SOURCE, bump) - - -def test_apply_bump_requires_a_sha_for_every_published_platform() -> None: - # darwin alone bumps the shared version but leaves windows pinned to a stale - # digest at the new version's URL — a guaranteed download verification failure. - bump = RuntimeBump(version="0.2.0", shas={"darwin-arm64": NEW_DARWIN}) - with pytest.raises(ValueError, match="windows-x86_64"): - apply_bump(REAL_SOURCE, bump) - - -def test_script_imports_with_stdlib_only() -> None: - # `python scripts/bump_runtime.py` runs in a checkout with no deps installed. - # `-S` disables site-packages, so any third-party import would fail here - # exactly as it would in CI. - script_dir = Path(bump_runtime.__file__).parent - result = subprocess.run( - [sys.executable, "-S", "-c", "import bump_runtime"], - cwd=script_dir, - capture_output=True, - text=True, - ) - assert result.returncode == 0, result.stderr diff --git a/tests/test_doctor.py b/tests/test_doctor.py index 32e6424..9c79464 100644 --- a/tests/test_doctor.py +++ b/tests/test_doctor.py @@ -1,72 +1,59 @@ """Behavioural tests for `holo doctor` environment diagnostics. -Each check runs against tmp-path ``~/.holo`` fixtures and a real loopback -/health server; the command exits 0 only when every check passes and points -at a concrete fix otherwise. +The runtime lifecycle is SDK-owned now, so the agent-api check reads +``LocalRuntime.attach`` state through a fake seam instead of a real loopback +/health server; the command exits 0 only when every check passes and points at +a concrete fix otherwise. """ from __future__ import annotations import importlib -import json +import os import sys -from collections.abc import Iterator -from contextlib import contextmanager -from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path -from threading import Thread +from types import SimpleNamespace import pytest from holo_desktop import customization -from holo_desktop.agent_client import launcher, runtime_install +from holo_desktop.agent_client import permissions from holo_desktop.cli import bootstrap -from holo_desktop.settings import load_holo_settings +from holo_desktop.settings import AUTH_TOKEN_ENV, load_holo_settings # `holo_desktop.cli.__init__` re-exports the `doctor` command function under # the same name as the submodule; go through importlib to get the module. doctor = importlib.import_module("holo_desktop.cli.doctor") -@contextmanager -def _fake_agent_server(version: str) -> Iterator[int]: - body = json.dumps({"status": "ok", "version": version}).encode() +def _fake_runtime(*, version: str = "0.2.0", healthy: bool = True, api_key: str | None = "k"): + def health() -> dict: + if not healthy: + raise RuntimeError("connection refused") + return {"status": "ok", "version": version} - class Handler(BaseHTTPRequestHandler): - def do_GET(self) -> None: - if self.path != "/health": - self.send_response(404) - self.end_headers() - return - self.send_response(200) - self.send_header("Content-Length", str(len(body))) - self.end_headers() - self.wfile.write(body) - - def log_message(self, format: str, *args: object) -> None: # stdlib signature; silences request logs - return - - server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) - thread = Thread(target=server.serve_forever, daemon=True) - thread.start() - try: - yield server.server_address[1] - finally: - server.shutdown() - thread.join(timeout=5.0) + return SimpleNamespace( + version=version, + api_key=api_key, + pid=4242, + health=health, + log_path=None, + owned=False, + base_url="http://127.0.0.1:18795", + ) @pytest.fixture() def holo_home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: - monkeypatch.setattr(runtime_install, "RUNTIME_DIR", tmp_path / "runtime") - monkeypatch.setattr(launcher, "TOKEN_DIR", tmp_path / "tokens") - monkeypatch.setattr(launcher, "LOG_DIR", tmp_path / "logs") monkeypatch.setattr(customization, "SKILLS_DIR", tmp_path / "skills") monkeypatch.setattr(bootstrap, "USER_ENV_PATH", tmp_path / ".env") - monkeypatch.delenv(launcher.AUTH_TOKEN_ENV, raising=False) + monkeypatch.setattr(permissions, "FIRST_RUN_MARKER", tmp_path / "marker" / ".first-run-complete") + monkeypatch.delenv(AUTH_TOKEN_ENV, raising=False) monkeypatch.delenv("HAI_API_KEY", raising=False) monkeypatch.delenv("HAI_AGENT_RUNTIME_BASE_URL", raising=False) monkeypatch.setenv("PATH", str(tmp_path / "empty-path")) + # Default: no runtime attached; individual tests override the seam as needed. + monkeypatch.setattr(doctor.LocalRuntime, "attach", staticmethod(lambda *, port=None, cache_dir=None: None)) return tmp_path @@ -84,102 +71,110 @@ def _seed_skill(holo_home: Path) -> None: skill.write_text("---\ndescription: d\n---\nbody\n", encoding="utf-8") -def _seed_managed_install(holo_home: Path) -> Path: - version_dir = holo_home / "runtime" / runtime_install.PINNED_RUNTIME_VERSION - version_dir.mkdir(parents=True) - binary = version_dir / runtime_install.BINARY_NAME - binary.write_bytes(b"#!/bin/sh\n") - binary.chmod(0o755) - return binary - - def test_all_green_environment_passes(holo_home: Path, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("HAI_API_KEY", "key") - _seed_managed_install(holo_home) _seed_skill(holo_home) - - with _fake_agent_server(runtime_install.PINNED_RUNTIME_VERSION) as port: - monkeypatch.setenv(launcher.PORT_ENV, str(port)) - monkeypatch.setenv(launcher.AUTH_TOKEN_ENV, "token") - results = _run_checks() - + monkeypatch.setattr( + doctor.LocalRuntime, "attach", staticmethod(lambda *, port=None, cache_dir=None: _fake_runtime()) + ) + results = _run_checks() assert all(r.ok for r in results), [f"{r.name}: {r.detail}" for r in results if not r.ok] -def test_missing_binary_fails_with_pointer(holo_home: Path, monkeypatch: pytest.MonkeyPatch) -> None: +def test_binary_reports_sdk_managed_when_not_on_path(holo_home: Path, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("HAI_API_KEY", "key") - monkeypatch.setenv(launcher.PORT_ENV, "1") # nothing listens here - results = _by_name(_run_checks()) - - binary = results["binary"] - assert not binary.ok - assert binary.fix is not None - - -def test_binary_env_var_is_ignored(holo_home: Path, monkeypatch: pytest.MonkeyPatch) -> None: - # The removed HAI_AGENT_RUNTIME_BINARY escape hatch must not green-light the binary check. - monkeypatch.setenv("HAI_API_KEY", "key") - monkeypatch.setenv(launcher.PORT_ENV, "1") - monkeypatch.setenv("HAI_AGENT_RUNTIME_BINARY", "python -m hai_agent_runtime") - assert not _by_name(_run_checks())["binary"].ok + result = _by_name(_run_checks())["binary"] + # No local manifest to inspect anymore: install/version pinning is SDK-owned. + assert result.ok + assert "SDK" in result.detail -def test_managed_install_is_reported(holo_home: Path, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setenv("HAI_API_KEY", "key") - monkeypatch.setenv(launcher.PORT_ENV, "1") - binary = _seed_managed_install(holo_home) +def test_binary_reports_path_install_when_present(holo_home: Path, monkeypatch: pytest.MonkeyPatch) -> None: + from hai_agents.local.manifest import BINARY_NAME + bin_dir = holo_home / "bin" + bin_dir.mkdir() + binary = bin_dir / BINARY_NAME + binary.write_bytes(b"#!/bin/sh\n") + binary.chmod(0o755) + monkeypatch.setenv("PATH", str(bin_dir)) result = _by_name(_run_checks())["binary"] assert result.ok - assert str(binary) in result.detail + assert os.path.normcase(str(binary)) in os.path.normcase(result.detail) -def test_missing_credentials_fail_login_check(holo_home: Path, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setenv(launcher.PORT_ENV, "1") +def test_missing_credentials_fail_login_check(holo_home: Path) -> None: result = _by_name(_run_checks())["login"] assert not result.ok assert result.fix is not None and "holo login" in result.fix -def test_running_server_without_token_fails_agent_api_check(holo_home: Path, monkeypatch: pytest.MonkeyPatch) -> None: +def test_no_runtime_is_ok_and_says_spawn_on_demand(holo_home: Path, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("HAI_API_KEY", "key") - with _fake_agent_server("1.2.3") as port: - monkeypatch.setenv(launcher.PORT_ENV, str(port)) - result = _by_name(_run_checks())["agent-api"] - assert not result.ok - assert launcher.AUTH_TOKEN_ENV in (result.fix or "") + monkeypatch.setattr(doctor.LocalRuntime, "attach", staticmethod(lambda *, port=None, cache_dir=None: None)) + results = _by_name(_run_checks()) + assert results["agent-api"].ok + assert "spawns on demand" in results["agent-api"].detail -def test_idle_port_is_not_a_failure(holo_home: Path, monkeypatch: pytest.MonkeyPatch) -> None: - # No server running is the normal state: every surface spawns on demand. +def test_running_runtime_reports_version_and_credentials(holo_home: Path, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("HAI_API_KEY", "key") - monkeypatch.setenv(launcher.PORT_ENV, "1") - result = _by_name(_run_checks())["agent-api"] - assert result.ok + monkeypatch.setattr( + doctor.LocalRuntime, "attach", staticmethod(lambda *, port=None, cache_dir=None: _fake_runtime()) + ) + results = _by_name(_run_checks()) + assert results["agent-api"].ok + assert "0.2.0" in results["agent-api"].detail -PORT = 23498 # pinned: the log file `permissions_guidance_needed` inspects is keyed by port +def test_runtime_without_credentials_fails_with_fixit(holo_home: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("HAI_API_KEY", "key") + monkeypatch.setattr( + doctor.LocalRuntime, + "attach", + staticmethod(lambda *, port=None, cache_dir=None: _fake_runtime(api_key=None)), + ) + results = _by_name(_run_checks()) + assert not results["agent-api"].ok + assert results["agent-api"].fix is not None + + +def test_unhealthy_runtime_fails_and_points_at_force_stop(holo_home: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("HAI_API_KEY", "key") + monkeypatch.setattr( + doctor.LocalRuntime, + "attach", + staticmethod(lambda *, port=None, cache_dir=None: _fake_runtime(healthy=False)), + ) + results = _by_name(_run_checks()) + assert not results["agent-api"].ok + assert "holo stop --force" in (results["agent-api"].fix or "") @pytest.mark.skipif(sys.platform != "darwin", reason="TCC guidance is macOS-only") def test_permissions_guidance_shown_while_managed_first_run_pending(holo_home: Path) -> None: # No first-run marker and no `hai-agent-runtime` on PATH: grants were almost certainly never given. - assert doctor.permissions_guidance_needed(PORT) + assert doctor.permissions_guidance_needed(18795) @pytest.mark.skipif(sys.platform != "darwin", reason="TCC guidance is macOS-only") def test_permissions_guidance_hidden_after_first_run_completes(holo_home: Path) -> None: - runtime_install.mark_first_run_complete(runtime_install.PINNED_RUNTIME_VERSION) - assert not doctor.permissions_guidance_needed(PORT) + permissions.mark_first_run_complete() + assert not doctor.permissions_guidance_needed(18795) @pytest.mark.skipif(sys.platform != "darwin", reason="TCC guidance is macOS-only") -def test_permissions_guidance_reappears_on_permission_shaped_log(holo_home: Path) -> None: - runtime_install.mark_first_run_complete(runtime_install.PINNED_RUNTIME_VERSION) - log_dir = holo_home / "logs" - log_dir.mkdir() - (log_dir / f"hai-agent-runtime-{PORT}.log").write_text("screen recording denied by TCC\n", encoding="utf-8") - assert doctor.permissions_guidance_needed(PORT) +def test_permissions_guidance_reappears_on_permission_shaped_log( + holo_home: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + permissions.mark_first_run_complete() + log = holo_home / "logs" / "runtime.log" + log.parent.mkdir() + log.write_text("screen recording denied by TCC\n", encoding="utf-8") + runtime = _fake_runtime() + runtime.log_path = log + monkeypatch.setattr(doctor.LocalRuntime, "attach", staticmethod(lambda *, port=None, cache_dir=None: runtime)) + assert doctor.permissions_guidance_needed(18795) @pytest.mark.skipif(sys.platform != "darwin", reason="TCC guidance is macOS-only") @@ -191,12 +186,12 @@ def test_permissions_guidance_hidden_for_path_binary(holo_home: Path, monkeypatc binary.write_bytes(b"#!/bin/sh\n") binary.chmod(0o755) monkeypatch.setenv("PATH", str(bin_dir)) - assert not doctor.permissions_guidance_needed(PORT) + assert not doctor.permissions_guidance_needed(18795) @pytest.mark.skipif(sys.platform == "darwin", reason="covers the non-macOS branch") def test_permissions_guidance_never_shown_off_macos(holo_home: Path) -> None: - assert not doctor.permissions_guidance_needed(PORT) + assert not doctor.permissions_guidance_needed(18795) @pytest.mark.skipif(sys.platform != "darwin", reason="TCC guidance is macOS-only") @@ -204,22 +199,23 @@ def test_doctor_output_omits_permissions_panel_when_healthy( holo_home: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] ) -> None: monkeypatch.setenv("HAI_API_KEY", "key") - monkeypatch.setenv(launcher.PORT_ENV, str(PORT)) - _seed_managed_install(holo_home) _seed_skill(holo_home) - runtime_install.mark_first_run_complete(runtime_install.PINNED_RUNTIME_VERSION) - + permissions.mark_first_run_complete() + monkeypatch.setattr( + doctor.LocalRuntime, "attach", staticmethod(lambda *, port=None, cache_dir=None: _fake_runtime()) + ) doctor.doctor() assert "macOS permissions" not in capsys.readouterr().out def test_doctor_exit_codes(holo_home: Path, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setenv(launcher.PORT_ENV, "1") with pytest.raises(SystemExit) as excinfo: doctor.doctor() assert excinfo.value.code == 1 monkeypatch.setenv("HAI_API_KEY", "key") - _seed_managed_install(holo_home) _seed_skill(holo_home) + monkeypatch.setattr( + doctor.LocalRuntime, "attach", staticmethod(lambda *, port=None, cache_dir=None: _fake_runtime()) + ) doctor.doctor() # all green: returns normally (exit 0) diff --git a/tests/test_launcher.py b/tests/test_launcher.py deleted file mode 100644 index 035300b..0000000 --- a/tests/test_launcher.py +++ /dev/null @@ -1,235 +0,0 @@ -"""Behavioural tests for the attach path of ``agent_client.launcher.ensure_running``. - -A real loopback HTTP server impersonates an already-running hai-agent-runtime -binary (200 on ``/health``). The launcher must attach when no inference flags -are given, and fail fast — not silently drop the flags — when they are. -""" - -from __future__ import annotations - -import asyncio -import json -import logging -from collections.abc import Iterator -from contextlib import contextmanager -from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer -from pathlib import Path -from threading import Thread - -import pytest - -from holo_desktop.agent_client import launcher -from holo_desktop.agent_client.launcher import AUTH_TOKEN_ENV, SpawnConfig, ensure_running -from holo_desktop.agent_client.runtime_install import PINNED_RUNTIME_VERSION - - -class _HealthHandler(BaseHTTPRequestHandler): - health_body: bytes = b"" - - def do_GET(self) -> None: - if self.path != "/health": - self.send_response(404) - self.end_headers() - return - self.send_response(200) - self.send_header("Content-Length", str(len(self.health_body))) - self.end_headers() - self.wfile.write(self.health_body) - - def log_message(self, format: str, *args: object) -> None: # stdlib signature; silences request logs - return - - -@contextmanager -def _fake_agent_server(*, health_body: bytes = b"") -> Iterator[int]: - handler = type("Handler", (_HealthHandler,), {"health_body": health_body}) - server = ThreadingHTTPServer(("127.0.0.1", 0), handler) - thread = Thread(target=server.serve_forever, daemon=True) - thread.start() - try: - yield server.server_address[1] - finally: - server.shutdown() - thread.join(timeout=5.0) - - -async def _ensure_running(config: SpawnConfig) -> launcher.AgentDaemon: - return await ensure_running(config, settings=launcher.load_holo_settings()) - - -def test_attach_without_flags_succeeds(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setenv(AUTH_TOKEN_ENV, "test-token") - with _fake_agent_server() as port: - daemon = asyncio.run(_ensure_running(SpawnConfig(port=port))) - assert daemon.proc is None - assert daemon.token == "test-token" - assert daemon.base_url == f"http://127.0.0.1:{port}" - - -@pytest.mark.parametrize( - ("model", "base_url", "runs_dir"), - [ - (None, "http://localhost:8000/v1", None), - ("holo3-122b", None, None), - ("holo3-122b", "http://localhost:8000/v1", None), - (None, None, Path("/tmp/runs")), - ], -) -def test_attach_with_spawn_flags_fails_fast( - monkeypatch: pytest.MonkeyPatch, model: str | None, base_url: str | None, runs_dir: Path | None -) -> None: - # Even with a valid token, attaching must refuse: the running server keeps - # the configuration it was started with, so the flags cannot apply. - monkeypatch.setenv(AUTH_TOKEN_ENV, "test-token") - with _fake_agent_server() as port, pytest.raises(RuntimeError, match="already running"): - asyncio.run(_ensure_running(SpawnConfig(port=port, model=model, base_url=base_url, runs_dir=runs_dir))) - - -def test_attach_with_fake_fails_fast(monkeypatch: pytest.MonkeyPatch) -> None: - # --fake only reaches a freshly spawned binary; attaching would silently - # run the "fake" client against a real model and desktop. - monkeypatch.setenv(AUTH_TOKEN_ENV, "test-token") - with _fake_agent_server() as port, pytest.raises(RuntimeError, match="already running") as excinfo: - asyncio.run(_ensure_running(SpawnConfig(port=port, fake=True))) - assert "--fake" in str(excinfo.value) - - -def test_attach_with_env_model_config_succeeds(monkeypatch: pytest.MonkeyPatch) -> None: - # Model/base-url from ~/.holo/.env are spawn defaults for stdio servers, not - # explicit CLI flags. They should not block attaching to an already-running - # local runtime. - monkeypatch.setenv(AUTH_TOKEN_ENV, "test-token") - monkeypatch.setenv(launcher.PORT_ENV, "0") - monkeypatch.setenv("HAI_AGENT_RUNTIME_MODEL", "holo3-local") - monkeypatch.setenv("HAI_AGENT_RUNTIME_BASE_URL", "http://127.0.0.1:8000/v1") - with _fake_agent_server() as port: - monkeypatch.setenv(launcher.PORT_ENV, str(port)) - daemon = asyncio.run(launcher.ensure_running_from_env()) - assert daemon.proc is None - assert daemon.token == "test-token" - assert daemon.base_url == f"http://127.0.0.1:{port}" - - -def test_attach_with_env_fast_succeeds(monkeypatch: pytest.MonkeyPatch) -> None: - # `fast`/`fake` from the environment are ambient spawn defaults for stdio - # servers, not explicit CLI flags; a shared server may already be fast, so - # they must not block attaching to an already-running local runtime. - monkeypatch.setenv(AUTH_TOKEN_ENV, "test-token") - monkeypatch.setenv("HAI_AGENT_RUNTIME_FAST", "1") - with _fake_agent_server() as port: - monkeypatch.setenv(launcher.PORT_ENV, str(port)) - daemon = asyncio.run(launcher.ensure_running_from_env()) - assert daemon.proc is None - assert daemon.base_url == f"http://127.0.0.1:{port}" - - -def test_attach_with_cli_fast_fails_fast(monkeypatch: pytest.MonkeyPatch) -> None: - # An explicit `--fast` (CLI path, require_fresh_for_config=True) must still - # refuse to attach: the running server keeps its own quality settings. - monkeypatch.setenv(AUTH_TOKEN_ENV, "test-token") - with _fake_agent_server() as port, pytest.raises(RuntimeError, match="already running") as excinfo: - asyncio.run(_ensure_running(SpawnConfig(port=port, fast=True))) - assert "--fast" in str(excinfo.value) - - -def test_attach_error_names_the_rejected_flags(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setenv(AUTH_TOKEN_ENV, "test-token") - with _fake_agent_server() as port, pytest.raises(RuntimeError) as excinfo: - asyncio.run( - _ensure_running( - SpawnConfig( - port=port, - model="holo3-122b", - base_url="http://localhost:8000/v1", - runs_dir=Path("/tmp/runs"), - ) - ) - ) - message = str(excinfo.value) - assert "--model" in message - assert "--base-url" in message - assert "--runs-dir" in message - assert "--port" in message # the error must point at a way out - - -def test_attach_without_token_still_fails(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - # Pre-existing contract: attaching with no env token and no token file is an error. - monkeypatch.delenv(AUTH_TOKEN_ENV, raising=False) - monkeypatch.setattr(launcher, "TOKEN_DIR", tmp_path) - with _fake_agent_server() as port, pytest.raises(RuntimeError, match=AUTH_TOKEN_ENV): - asyncio.run(_ensure_running(SpawnConfig(port=port))) - - -def test_attach_captures_runtime_version_and_warns_on_mismatch( - monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture -) -> None: - monkeypatch.setenv(AUTH_TOKEN_ENV, "test-token") - body = json.dumps({"status": "ok", "version": "999.0.0"}).encode() - with _fake_agent_server(health_body=body) as port, caplog.at_level(logging.WARNING): - daemon = asyncio.run(_ensure_running(SpawnConfig(port=port))) - assert daemon.runtime_version == "999.0.0" - warning = next(r for r in caplog.records if r.levelno == logging.WARNING) - assert "999.0.0" in warning.getMessage() - assert PINNED_RUNTIME_VERSION in warning.getMessage() - - -def test_attach_with_matching_version_does_not_warn( - monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture -) -> None: - monkeypatch.setenv(AUTH_TOKEN_ENV, "test-token") - body = json.dumps({"status": "ok", "version": PINNED_RUNTIME_VERSION}).encode() - with _fake_agent_server(health_body=body) as port, caplog.at_level(logging.WARNING): - daemon = asyncio.run(_ensure_running(SpawnConfig(port=port))) - assert daemon.runtime_version == PINNED_RUNTIME_VERSION - assert not [r for r in caplog.records if r.levelno >= logging.WARNING] - - -def test_attach_tolerates_versionless_health_body(monkeypatch: pytest.MonkeyPatch) -> None: - # PATH/dev binaries and stubs may serve an empty /health; no version, no warning, no crash. - monkeypatch.setenv(AUTH_TOKEN_ENV, "test-token") - with _fake_agent_server() as port: - daemon = asyncio.run(_ensure_running(SpawnConfig(port=port))) - assert daemon.runtime_version is None - - -def test_runtime_child_env_keeps_portal_key_without_base_url(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setenv("HAI_API_KEY", "portal-key") - monkeypatch.delenv("HAI_AGENT_RUNTIME_BASE_URL", raising=False) - assert launcher.runtime_child_env({}, settings=launcher.load_holo_settings())["HAI_API_KEY"] == "portal-key" - - -def test_runtime_child_env_strips_portal_key_for_explicit_base_url(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setenv("HAI_API_KEY", "portal-key") - monkeypatch.delenv("HAI_AGENT_RUNTIME_BASE_URL", raising=False) - env = launcher.runtime_child_env( - {"HAI_AGENT_RUNTIME_BASE_URL": "http://localhost:8000/v1"}, settings=launcher.load_holo_settings() - ) - assert "HAI_API_KEY" not in env - - -def test_runtime_child_env_strips_portal_key_for_inherited_base_url(monkeypatch: pytest.MonkeyPatch) -> None: - # The base URL comes from ~/.holo/.env (inherited via os.environ), not a passed flag. - monkeypatch.setenv("HAI_API_KEY", "portal-key") - monkeypatch.setenv("HAI_AGENT_RUNTIME_BASE_URL", "http://localhost:8000/v1") - assert "HAI_API_KEY" not in launcher.runtime_child_env({}, settings=launcher.load_holo_settings()) - - -def test_runtime_child_env_treats_whitespace_base_url_as_hosted(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setenv("HAI_API_KEY", "portal-key") - monkeypatch.setenv("HAI_AGENT_RUNTIME_BASE_URL", " ") - monkeypatch.delenv(launcher.MODELS_API_BASE_URL_ENV, raising=False) - - env = launcher.runtime_child_env({}, settings=launcher.load_holo_settings()) - - assert env["HAI_API_KEY"] == "portal-key" - assert env[launcher.MODELS_API_BASE_URL_ENV] == launcher.PRODUCTION_GATEWAY_URL - assert "HAI_AGENT_RUNTIME_BASE_URL" not in env - - -def test_spawn_config_from_env_ignores_whitespace_base_url(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setenv("HAI_AGENT_RUNTIME_PORT", "12345") - monkeypatch.setenv("HAI_AGENT_RUNTIME_BASE_URL", " ") - - config = launcher.spawn_config_from_env(settings=launcher.load_holo_settings()) - - assert config.base_url is None diff --git a/tests/test_launcher_pid.py b/tests/test_launcher_pid.py deleted file mode 100644 index 3394ca8..0000000 --- a/tests/test_launcher_pid.py +++ /dev/null @@ -1,44 +0,0 @@ -"""Behavioural tests for the runtime pid file's on-disk hardening. - -The pid file's contents drive a privileged operation: ``holo stop --force`` reads it and -``os.killpg(..., SIGKILL)`` the pid it finds. It therefore earns the same protections as the bearer -token — owner-only permissions and a refusal to follow a symlink planted at its path — so it can never -be steered into killing an arbitrary process group. -""" - -from __future__ import annotations - -import os -import stat -import sys -from pathlib import Path - -import pytest - -from holo_desktop.agent_client import launcher - - -@pytest.mark.skipif(sys.platform == "win32", reason="POSIX file-mode semantics") -def test_pid_file_written_owner_only(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(launcher, "TOKEN_DIR", tmp_path) - path = launcher._write_pid_file(4242, 31337) - - assert path is not None - assert path.read_text(encoding="utf-8") == "31337" - assert stat.S_IMODE(path.stat().st_mode) == 0o600 - - -@pytest.mark.skipif(sys.platform == "win32", reason="O_NOFOLLOW is POSIX-only") -def test_pid_file_write_refuses_symlink_at_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - # An attacker pre-plants a symlink at the pid path pointing at a file they want clobbered (and, - # later, read back by `holo stop --force`). The hardened write must refuse to follow it. - monkeypatch.setattr(launcher, "TOKEN_DIR", tmp_path) - victim = tmp_path / "victim" - victim.write_text("untouched", encoding="utf-8") - pid_path = launcher.pid_file_path(4242) - pid_path.parent.mkdir(parents=True, exist_ok=True) - os.symlink(victim, pid_path) - - assert launcher._write_pid_file(4242, 31337) is None - assert victim.read_text(encoding="utf-8") == "untouched", "symlink target must not be clobbered" - assert pid_path.is_symlink(), "the planted symlink itself is left as-is, never written through" diff --git a/tests/test_launcher_runs_dir.py b/tests/test_launcher_runs_dir.py deleted file mode 100644 index 0f6b6fc..0000000 --- a/tests/test_launcher_runs_dir.py +++ /dev/null @@ -1,171 +0,0 @@ -"""Behavioural tests for the spawn-time env contract with the hai-agent-runtime binary. - -The binary owns JSONL persistence (``HAI_AGENT_RUNTIME_RUNS_DIR``, default -``~/.holo/runs``); the client's job is to forward an explicit ``--runs-dir`` -to a binary it spawns. The attach-path refusals live in ``test_launcher.py``. -""" - -from __future__ import annotations - -import asyncio -import json -import socket -import sys -import textwrap -from pathlib import Path - -import pytest - -from holo_desktop.agent_client import launcher - -# Dumps its HAI_AGENT_RUNTIME_* env to a file, then serves 200 on every GET so -# ensure_running's /health handshake succeeds. A stand-in for the real binary. -STUB_BINARY = textwrap.dedent( - """ - import json, os - from http.server import BaseHTTPRequestHandler, HTTPServer - - dump = {k: v for k, v in os.environ.items() if k.startswith("HAI_AGENT_RUNTIME_") or k == "HAI_BASE_URL"} - with open(os.environ["STUB_ENV_DUMP"], "w", encoding="utf-8") as fh: - json.dump(dump, fh) - - class Handler(BaseHTTPRequestHandler): - def do_GET(self): - self.send_response(200) - self.end_headers() - self.wfile.write(b"ok") - - def log_message(self, *args): - pass - - HTTPServer(("127.0.0.1", int(os.environ["HAI_AGENT_RUNTIME_PORT"])), Handler).serve_forever() - """ -) - - -def _free_port() -> int: - with socket.socket() as sock: - sock.bind(("127.0.0.1", 0)) - return int(sock.getsockname()[1]) - - -def _use_stub(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: - """Point the launcher at the env-dumping stub; returns the dump path.""" - script = tmp_path / "stub_runtime.py" - script.write_text(STUB_BINARY, encoding="utf-8") - dump_path = tmp_path / "env.json" - # The binary-resolution seam: resolution itself is covered in test_runtime_install.py. - monkeypatch.setattr(launcher, "resolve_command", lambda **_: [sys.executable, str(script)]) - monkeypatch.setenv("STUB_ENV_DUMP", str(dump_path)) - monkeypatch.delenv(launcher.AUTH_TOKEN_ENV, raising=False) - monkeypatch.setattr(launcher, "TOKEN_DIR", tmp_path / "tokens") - return dump_path - - -async def _ensure_running(config: launcher.SpawnConfig) -> launcher.AgentDaemon: - return await launcher.ensure_running(config, settings=launcher.load_holo_settings()) - - -@pytest.mark.timeout(60) -def test_spawn_forwards_runs_dir_to_binary(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - dump_path = _use_stub(tmp_path, monkeypatch) - runs_dir = tmp_path / "runs" - - async def spawn_and_stop() -> None: - daemon = await _ensure_running( - launcher.SpawnConfig( - port=_free_port(), - model="holo3-test", - base_url="http://127.0.0.1:8000/v1", - fake=True, - runs_dir=runs_dir, - ) - ) - await daemon.aclose() - - asyncio.run(spawn_and_stop()) - - env = json.loads(dump_path.read_text(encoding="utf-8")) - assert env["HAI_AGENT_RUNTIME_RUNS_DIR"] == str(runs_dir) - assert env["HAI_AGENT_RUNTIME_MODEL"] == "holo3-test" - assert env["HAI_AGENT_RUNTIME_BASE_URL"] == "http://127.0.0.1:8000/v1" - assert env["HAI_AGENT_RUNTIME_FAKE"] == "1" - assert "HAI_BASE_URL" not in env - - -@pytest.mark.timeout(60) -def test_spawn_defaults_hosted_models_api_to_production(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - dump_path = _use_stub(tmp_path, monkeypatch) - monkeypatch.delenv(launcher.MODELS_API_BASE_URL_ENV, raising=False) - - async def spawn_and_stop() -> None: - daemon = await _ensure_running(launcher.SpawnConfig(port=_free_port(), fake=True)) - await daemon.aclose() - - asyncio.run(spawn_and_stop()) - - env = json.loads(dump_path.read_text(encoding="utf-8")) - assert env["HAI_BASE_URL"] == launcher.PRODUCTION_GATEWAY_URL - - -@pytest.mark.timeout(60) -def test_spawn_defaults_blank_hosted_models_api_to_production(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - dump_path = _use_stub(tmp_path, monkeypatch) - monkeypatch.setenv(launcher.MODELS_API_BASE_URL_ENV, " ") - - async def spawn_and_stop() -> None: - daemon = await _ensure_running(launcher.SpawnConfig(port=_free_port(), fake=True)) - await daemon.aclose() - - asyncio.run(spawn_and_stop()) - - env = json.loads(dump_path.read_text(encoding="utf-8")) - assert env["HAI_BASE_URL"] == launcher.PRODUCTION_GATEWAY_URL - - -@pytest.mark.timeout(60) -def test_spawn_preserves_hosted_models_api_override(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - dump_path = _use_stub(tmp_path, monkeypatch) - override = "https://models.example.com/v1/models" - monkeypatch.setenv(launcher.MODELS_API_BASE_URL_ENV, override) - - async def spawn_and_stop() -> None: - daemon = await _ensure_running(launcher.SpawnConfig(port=_free_port(), fake=True)) - await daemon.aclose() - - asyncio.run(spawn_and_stop()) - - env = json.loads(dump_path.read_text(encoding="utf-8")) - assert env["HAI_BASE_URL"] == override - - -@pytest.mark.timeout(60) -def test_spawn_expands_tilde_in_runs_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - # A quoted "~/..." survives shell expansion; the binary must never see a literal tilde. - dump_path = _use_stub(tmp_path, monkeypatch) - - async def spawn_and_stop() -> None: - daemon = await _ensure_running( - launcher.SpawnConfig(port=_free_port(), fake=True, runs_dir=Path("~/custom/runs")) - ) - await daemon.aclose() - - asyncio.run(spawn_and_stop()) - - env = json.loads(dump_path.read_text(encoding="utf-8")) - assert env["HAI_AGENT_RUNTIME_RUNS_DIR"] == str(Path.home() / "custom" / "runs") - - -@pytest.mark.timeout(60) -def test_spawn_without_runs_dir_leaves_binary_default(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - dump_path = _use_stub(tmp_path, monkeypatch) - monkeypatch.delenv("HAI_AGENT_RUNTIME_RUNS_DIR", raising=False) - - async def spawn_and_stop() -> None: - daemon = await _ensure_running(launcher.SpawnConfig(port=_free_port(), fake=True)) - await daemon.aclose() - - asyncio.run(spawn_and_stop()) - - env = json.loads(dump_path.read_text(encoding="utf-8")) - assert "HAI_AGENT_RUNTIME_RUNS_DIR" not in env diff --git a/tests/test_launcher_spawn.py b/tests/test_launcher_spawn.py deleted file mode 100644 index a1b055f..0000000 --- a/tests/test_launcher_spawn.py +++ /dev/null @@ -1,176 +0,0 @@ -"""Behavioural tests for the spawn path's stderr handling. - -The binary's stderr must go to a log file, not a pipe: nobody drains a pipe -after a successful spawn, so a chatty binary would fill the ~64KB buffer and -block mid-run. The log file also has to feed the spawn-failure error message. -""" - -from __future__ import annotations - -import asyncio -import os -import socket -import subprocess -import sys -import textwrap -import time -from pathlib import Path - -import pytest - -from holo_desktop.agent_client import launcher - -# Exits 2 after complaining on stderr; never serves /health. -CRASHING_BINARY = textwrap.dedent( - """ - import sys - print("fatal: no desktop session available", file=sys.stderr) - sys.exit(2) - """ -) - -# Floods stderr past any platform's pipe capacity (~64KB) BEFORE serving /health: -# with a pipe nobody drains, the child blocks on write and never becomes healthy. -# 256KB is comfortably past the buffer while keeping the pre-health write cheap, so -# the healthy path stays fast even on a heavily contended CI runner. -CHATTY_BINARY = textwrap.dedent( - """ - import os, sys - from http.server import BaseHTTPRequestHandler, HTTPServer - - for _ in range(256): - sys.stderr.write("x" * 1024 + "\\n") - sys.stderr.flush() - - class Handler(BaseHTTPRequestHandler): - def do_GET(self): - self.send_response(200) - self.end_headers() - self.wfile.write(b"ok") - - def log_message(self, *args): - pass - - HTTPServer(("127.0.0.1", int(os.environ["HAI_AGENT_RUNTIME_PORT"])), Handler).serve_forever() - """ -) - - -# Writes its pid to a file, never serves /health: lets a test cancel -# ensure_running mid-spawn and then assert the child was terminated. -NEVER_HEALTHY_BINARY = textwrap.dedent( - """ - import os, time - with open(os.environ["STUB_PID_FILE"], "w", encoding="utf-8") as fh: - fh.write(str(os.getpid())) - time.sleep(120) - """ -) - - -def _free_port() -> int: - with socket.socket() as sock: - sock.bind(("127.0.0.1", 0)) - return int(sock.getsockname()[1]) - - -def _use_stub(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, source: str) -> None: - script = tmp_path / "stub_runtime.py" - script.write_text(source, encoding="utf-8") - # The binary-resolution seam: resolution itself is covered in test_runtime_install.py. - monkeypatch.setattr(launcher, "resolve_command", lambda **_: [sys.executable, str(script)]) - monkeypatch.delenv(launcher.AUTH_TOKEN_ENV, raising=False) - monkeypatch.setattr(launcher, "LOG_DIR", tmp_path / "logs") - monkeypatch.setattr(launcher, "TOKEN_DIR", tmp_path / "tokens") - - -async def _ensure_running(config: launcher.SpawnConfig) -> launcher.AgentDaemon: - return await launcher.ensure_running(config, settings=launcher.load_holo_settings()) - - -@pytest.mark.timeout(60) -def test_spawn_failure_reports_stderr_from_log(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - _use_stub(tmp_path, monkeypatch, CRASHING_BINARY) - - with pytest.raises(RuntimeError, match="no desktop session available"): - asyncio.run(_ensure_running(launcher.SpawnConfig(port=_free_port(), fake=True))) - - -@pytest.mark.timeout(60) -def test_cancellation_mid_spawn_terminates_the_child(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - # Ctrl+C while the health poll is still waiting cancels ensure_running; - # the freshly spawned binary must not be left running. - _use_stub(tmp_path, monkeypatch, NEVER_HEALTHY_BINARY) - pid_file = tmp_path / "stub.pid" - monkeypatch.setenv("STUB_PID_FILE", str(pid_file)) - - async def spawn_then_cancel() -> None: - task = asyncio.ensure_future(_ensure_running(launcher.SpawnConfig(port=_free_port(), fake=True))) - while not pid_file.exists(): # binary is up; health poll is now spinning - await asyncio.sleep(0.05) - task.cancel() - with pytest.raises(asyncio.CancelledError): - await task - - asyncio.run(spawn_then_cancel()) - - pid = int(pid_file.read_text(encoding="utf-8")) - deadline = time.monotonic() + 10.0 - while time.monotonic() < deadline: - if not _pid_alive(pid): - return # child is gone - time.sleep(0.1) - _force_kill(pid) # don't leave the leak behind even when failing - pytest.fail(f"spawned binary (pid {pid}) survived cancellation of ensure_running") - - -def _pid_alive(pid: int) -> bool: - # os.kill(pid, 0) is a liveness probe on POSIX only; on Windows signal 0 is - # TerminateProcess(pid, 0) — it would KILL the process we are checking. - if sys.platform == "win32": - import ctypes - - process_query_limited_information = 0x1000 - still_active = 259 - handle = ctypes.windll.kernel32.OpenProcess(process_query_limited_information, False, pid) - if not handle: - return False - try: - exit_code = ctypes.c_ulong() - ctypes.windll.kernel32.GetExitCodeProcess(handle, ctypes.byref(exit_code)) - return exit_code.value == still_active - finally: - ctypes.windll.kernel32.CloseHandle(handle) - try: - os.kill(pid, 0) - except ProcessLookupError: - return False - except PermissionError: - return True - return True - - -def _force_kill(pid: int) -> None: - if sys.platform == "win32": - subprocess.run(["taskkill", "/PID", str(pid), "/F"], capture_output=True, check=False) - return - os.kill(pid, 9) - - -@pytest.mark.timeout(60) -def test_spawn_survives_chatty_stderr(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - _use_stub(tmp_path, monkeypatch, CHATTY_BINARY) - # Use the production spawn timeout (the @pytest.mark.timeout below still bounds a true - # hang). An artificially tight override false-failed on contended macOS CI runners, - # where interpreter launch + health poll alone can exceed a low limit. - - async def spawn_and_stop() -> None: - daemon = await _ensure_running(launcher.SpawnConfig(port=_free_port(), fake=True)) - await daemon.aclose() - - asyncio.run(spawn_and_stop()) - - logs = list((tmp_path / "logs").glob("hai-agent-runtime-*.log")) - assert logs, "spawn must leave the binary's stderr in a log file" - # Past any platform's pipe buffer (~64KB), proving the flood wrote without blocking. - assert logs[0].stat().st_size > 200 * 1024 diff --git a/tests/test_launcher_token.py b/tests/test_launcher_token.py deleted file mode 100644 index 5d92cbb..0000000 --- a/tests/test_launcher_token.py +++ /dev/null @@ -1,211 +0,0 @@ -"""Behavioural tests for the spawn-token handoff between local clients. - -When ``ensure_running`` spawns the binary with a *generated* token, a second -client (e.g. ``holo run`` while ``holo serve`` holds the runtime) has no way -to learn it from the environment. The launcher must publish the generated -token to a per-port file so attaching clients can authenticate, and clean it -up when the daemon it spawned stops. Explicit ``HAI_AGENT_RUNTIME_API_TOKEN`` -values stay in the env only — the launcher never writes user secrets to disk. -""" - -from __future__ import annotations - -import asyncio -import logging -import socket -import stat -import sys -import textwrap -from collections.abc import Iterator -from contextlib import contextmanager -from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer -from pathlib import Path -from threading import Thread - -import pytest - -from holo_desktop.agent_client import launcher - -# Serves 200 on every GET so ensure_running's /health handshake succeeds. -HEALTHY_BINARY = textwrap.dedent( - """ - import os - from http.server import BaseHTTPRequestHandler, HTTPServer - - class Handler(BaseHTTPRequestHandler): - def do_GET(self): - self.send_response(200) - self.end_headers() - self.wfile.write(b"ok") - - def log_message(self, *args): - pass - - HTTPServer(("127.0.0.1", int(os.environ["HAI_AGENT_RUNTIME_PORT"])), Handler).serve_forever() - """ -) - - -def _free_port() -> int: - with socket.socket() as sock: - sock.bind(("127.0.0.1", 0)) - return int(sock.getsockname()[1]) - - -def _use_stub(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - script = tmp_path / "stub_runtime.py" - script.write_text(HEALTHY_BINARY, encoding="utf-8") - # The binary-resolution seam: resolution itself is covered in test_runtime_install.py. - monkeypatch.setattr(launcher, "resolve_command", lambda **_: [sys.executable, str(script)]) - monkeypatch.delenv(launcher.AUTH_TOKEN_ENV, raising=False) - monkeypatch.setattr(launcher, "LOG_DIR", tmp_path / "logs") - monkeypatch.setattr(launcher, "TOKEN_DIR", tmp_path / "tokens") - - -async def _ensure_running(config: launcher.SpawnConfig) -> launcher.AgentDaemon: - return await launcher.ensure_running(config, settings=launcher.load_holo_settings()) - - -class _HealthHandler(BaseHTTPRequestHandler): - def do_GET(self) -> None: - self.send_response(200 if self.path == "/health" else 404) - self.end_headers() - - def log_message(self, format: str, *args: object) -> None: # stdlib signature; silences request logs - return - - -@contextmanager -def _fake_agent_server() -> Iterator[int]: - server = ThreadingHTTPServer(("127.0.0.1", 0), _HealthHandler) - thread = Thread(target=server.serve_forever, daemon=True) - thread.start() - try: - yield server.server_address[1] - finally: - server.shutdown() - thread.join(timeout=5.0) - - -@pytest.mark.timeout(60) -def test_second_client_attaches_with_the_spawners_generated_token( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - # The reported bug: spawner generates a token, second CLI on the same port - # has no env token — it must still be able to authenticate. - _use_stub(tmp_path, monkeypatch) - port = _free_port() - - async def flow() -> None: - spawned = await _ensure_running(launcher.SpawnConfig(port=port, fake=True)) - try: - attached = await _ensure_running(launcher.SpawnConfig(port=port)) - assert attached.proc is None - assert attached.token == spawned.token - finally: - await spawned.aclose() - - asyncio.run(flow()) - - -@pytest.mark.timeout(60) -def test_spawn_persists_generated_token_with_owner_only_perms(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - _use_stub(tmp_path, monkeypatch) - port = _free_port() - - async def flow() -> None: - daemon = await _ensure_running(launcher.SpawnConfig(port=port, fake=True)) - try: - token_file = launcher.token_file_path(port) - assert token_file.read_text(encoding="utf-8").strip() == daemon.token - if sys.platform != "win32": - assert stat.S_IMODE(token_file.stat().st_mode) == 0o600 - finally: - await daemon.aclose() - - asyncio.run(flow()) - - -@pytest.mark.timeout(60) -def test_spawned_daemon_removes_its_token_file_on_close(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - _use_stub(tmp_path, monkeypatch) - port = _free_port() - - async def flow() -> None: - daemon = await _ensure_running(launcher.SpawnConfig(port=port, fake=True)) - await daemon.aclose() - - asyncio.run(flow()) - assert not launcher.token_file_path(port).exists() - - -@pytest.mark.timeout(60) -def test_spawn_with_explicit_env_token_writes_nothing_to_disk(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - # A user-supplied secret must never silently land on disk. - _use_stub(tmp_path, monkeypatch) - monkeypatch.setenv(launcher.AUTH_TOKEN_ENV, "user-secret") - port = _free_port() - - async def flow() -> None: - daemon = await _ensure_running(launcher.SpawnConfig(port=port, fake=True)) - try: - assert daemon.token == "user-secret" - finally: - await daemon.aclose() - - asyncio.run(flow()) - assert not launcher.token_file_path(port).exists() - - -def test_attach_env_token_wins_over_token_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(launcher, "TOKEN_DIR", tmp_path) - monkeypatch.setenv(launcher.AUTH_TOKEN_ENV, "env-token") - with _fake_agent_server() as port: - launcher.token_file_path(port).write_text("file-token", encoding="utf-8") - daemon = asyncio.run(_ensure_running(launcher.SpawnConfig(port=port))) - assert daemon.token == "env-token" - - -def test_attaching_client_never_deletes_the_token_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - # Only the spawner owns the file; an attach-then-close must leave it for other clients. - monkeypatch.setattr(launcher, "TOKEN_DIR", tmp_path) - monkeypatch.delenv(launcher.AUTH_TOKEN_ENV, raising=False) - with _fake_agent_server() as port: - launcher.token_file_path(port).write_text("file-token", encoding="utf-8") - - async def attach_and_close() -> None: - daemon = await _ensure_running(launcher.SpawnConfig(port=port)) - assert daemon.token == "file-token" - await daemon.aclose() - - asyncio.run(attach_and_close()) - assert launcher.token_file_path(port).exists() - - -def test_unreadable_token_file_logs_a_warning( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture -) -> None: - # A missing file is the normal case and stays quiet; any other OS error - # (here: the path is a directory) must be surfaced, not swallowed. - monkeypatch.setattr(launcher, "TOKEN_DIR", tmp_path) - monkeypatch.delenv(launcher.AUTH_TOKEN_ENV, raising=False) - port = _free_port() - - with caplog.at_level(logging.WARNING): - assert launcher._read_token_file(port) == "" - assert not caplog.records, "a missing token file is expected and must stay quiet" - - launcher.token_file_path(port).mkdir(parents=True) - with caplog.at_level(logging.WARNING): - assert launcher._read_token_file(port) == "" - assert any(str(launcher.token_file_path(port)) in r.getMessage() for r in caplog.records) - - -def test_attach_without_env_or_file_names_both_sources(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(launcher, "TOKEN_DIR", tmp_path) - monkeypatch.delenv(launcher.AUTH_TOKEN_ENV, raising=False) - with _fake_agent_server() as port, pytest.raises(RuntimeError) as excinfo: - asyncio.run(_ensure_running(launcher.SpawnConfig(port=port))) - message = str(excinfo.value) - assert launcher.AUTH_TOKEN_ENV in message - assert str(launcher.token_file_path(port)) in message diff --git a/tests/test_local_mode_integration.py b/tests/test_local_mode_integration.py new file mode 100644 index 0000000..1e44090 --- /dev/null +++ b/tests/test_local_mode_integration.py @@ -0,0 +1,50 @@ +"""End-to-end fake-mode gate for the SDK migration (the Task 4/5 before-and-after check). + +Runs `holo run --fake` as a subprocess against the real hai-agent-runtime +binary (no model, no desktop): spawn, session create, event streaming, answer, +teardown. Skipped when the binary is not on PATH; the runtime download is +deliberately not exercised here (Task 2 covers install behavior at the SDK +boundary). Follows the subprocess style of tests/test_integration_real.py. +""" + +from __future__ import annotations + +import shutil +import socket +import subprocess +import sys +from pathlib import Path + +import pytest + + +def _free_port() -> int: + with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + return int(sock.getsockname()[1]) + + +def _holo_executable() -> str: + found = shutil.which("holo") + if found: + return found + candidate = Path(sys.executable).with_name("holo") + if candidate.exists(): + return str(candidate) + raise AssertionError("could not locate the 'holo' console script") + + +@pytest.mark.timeout(120) +def test_fake_mode_run_completes_end_to_end() -> None: + if shutil.which("hai-agent-runtime") is None: + pytest.skip("put hai-agent-runtime (or a wrapper script) on PATH for the fake-mode gate") + + completed = subprocess.run( + [_holo_executable(), "run", "--fake", "--quiet", "--port", str(_free_port()), "--no-kill-switch", "say hi"], + capture_output=True, + text=True, + timeout=110, + check=False, + ) + + assert completed.returncode == 0, f"holo run --fake failed ({completed.returncode}):\n{completed.stderr}" diff --git a/tests/test_local_runtime_contract.py b/tests/test_local_runtime_contract.py new file mode 100644 index 0000000..408a638 --- /dev/null +++ b/tests/test_local_runtime_contract.py @@ -0,0 +1,302 @@ +"""Contract tests pinning the hai-agents local-runtime behaviors Holo relies on. + +Ported from tests/test_launcher_spawn.py, test_launcher_token.py, and +test_launcher_pid.py before those files (and the launcher/runtime_install +implementations they cover) are deleted in Task 9. Each test names the +launcher test it replaces; together they are the spec the SDK must keep +honoring for HoloDesktop. Failures here are upstream hai-agents bugs +(plan 002), not Holo bugs. +""" + +from __future__ import annotations + +import os +import socket +import stat +import subprocess +import sys +import textwrap +import time +from pathlib import Path + +import pytest +from hai_agents.local import LocalRuntime, RuntimeStartTimeoutError + +# Serves 200 on every GET so the SDK's /health handshake succeeds. +# (Verbatim from tests/test_launcher_token.py.) +HEALTHY_BINARY = textwrap.dedent( + """ + import os + from http.server import BaseHTTPRequestHandler, HTTPServer + + class Handler(BaseHTTPRequestHandler): + def do_GET(self): + self.send_response(200) + self.end_headers() + self.wfile.write(b"ok") + + def log_message(self, *args): + pass + + HTTPServer(("127.0.0.1", int(os.environ["HAI_AGENT_RUNTIME_PORT"])), Handler).serve_forever() + """ +) + +# Exits 2 after complaining on stderr; never serves /health. +# (Verbatim from tests/test_launcher_spawn.py.) +CRASHING_BINARY = textwrap.dedent( + """ + import sys + print("fatal: no desktop session available", file=sys.stderr) + sys.exit(2) + """ +) + +# Floods stderr past any platform's pipe capacity (~64KB) BEFORE serving /health. +# (Verbatim from tests/test_launcher_spawn.py — the chatty-binary deadlock spec.) +CHATTY_BINARY = textwrap.dedent( + """ + import os, sys + from http.server import BaseHTTPRequestHandler, HTTPServer + + for _ in range(256): + sys.stderr.write("x" * 1024 + "\\n") + sys.stderr.flush() + + class Handler(BaseHTTPRequestHandler): + def do_GET(self): + self.send_response(200) + self.end_headers() + self.wfile.write(b"ok") + + def log_message(self, *args): + pass + + HTTPServer(("127.0.0.1", int(os.environ["HAI_AGENT_RUNTIME_PORT"])), Handler).serve_forever() + """ +) + +# Writes its pid to a file, never serves /health: lets a test time out +# ensure_started and then assert the child was terminated, not leaked. +NEVER_HEALTHY_BINARY = textwrap.dedent( + """ + import os, time + with open(os.environ["STUB_PID_FILE"], "w", encoding="utf-8") as fh: + fh.write(str(os.getpid())) + time.sleep(120) + """ +) + + +@pytest.fixture(autouse=True) +def _sanitize_local_env(monkeypatch: pytest.MonkeyPatch) -> None: + """Ambient runtime config must not leak into the contract: each test picks its own port/cache.""" + for name in ( + "HAI_AGENT_RUNTIME_API_TOKEN", + "HAI_AGENT_RUNTIME_PORT", + "HAI_AGENT_LOCAL_BASE_URL", + "HAI_AGENT_LOCAL_BINARY_PATH", + "HAI_AGENT_LOCAL_CACHE_DIR", + ): + monkeypatch.delenv(name, raising=False) + + +def _free_port() -> int: + with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + return int(sock.getsockname()[1]) + + +def _stub_binary(tmp_path: Path, source: str) -> Path: + """An executable wrapper around a Python stub (binary_path takes one path).""" + script = tmp_path / "stub_runtime.py" + script.write_text(source, encoding="utf-8") + if os.name == "nt": + wrapper = tmp_path / "stub-runtime.cmd" + wrapper.write_text(f'@echo off\r\n"{sys.executable}" "{script}" %*\r\n', encoding="utf-8") + else: + wrapper = tmp_path / "stub-runtime" + wrapper.write_text(f'#!/bin/sh\nexec "{sys.executable}" "{script}" "$@"\n', encoding="utf-8") + wrapper.chmod(0o755) + return wrapper + + +def _pid_alive(pid: int) -> bool: + # os.kill(pid, 0) is a liveness probe on POSIX only; on Windows signal 0 is + # TerminateProcess(pid, 0) — it would KILL the process we are checking. + if sys.platform == "win32": + import ctypes + + process_query_limited_information = 0x1000 + still_active = 259 + handle = ctypes.windll.kernel32.OpenProcess(process_query_limited_information, False, pid) + if not handle: + return False + try: + exit_code = ctypes.c_ulong() + ctypes.windll.kernel32.GetExitCodeProcess(handle, ctypes.byref(exit_code)) + return exit_code.value == still_active + finally: + ctypes.windll.kernel32.CloseHandle(handle) + try: + os.kill(pid, 0) + except ProcessLookupError: + return False + except PermissionError: + return True + return True + + +def _force_kill(pid: int) -> None: + if sys.platform == "win32": + subprocess.run(["taskkill", "/F", "/T", "/PID", str(pid)], capture_output=True, check=False) + return + os.kill(pid, 9) + + +def test_attach_returns_none_when_no_runtime_listens(tmp_path: Path) -> None: + # Replaces the launcher's "spawn when /health is unreachable" branch: no + # discovery state + nothing listening must read as "no runtime", never a + # half-alive handle. + assert LocalRuntime.attach(port=_free_port(), cache_dir=tmp_path) is None + + +@pytest.mark.timeout(60) +def test_spawn_failure_reports_stderr_from_log(tmp_path: Path) -> None: + # Replaces test_launcher_spawn.test_spawn_failure_reports_stderr_from_log: + # a binary that dies before /health must surface its stderr in the error. + with pytest.raises(Exception, match="no desktop session available"): + LocalRuntime.ensure_started( + binary_path=_stub_binary(tmp_path, CRASHING_BINARY), + cache_dir=tmp_path / "cache", + port=_free_port(), + download=False, + ) + + +@pytest.mark.timeout(60) +def test_spawn_survives_chatty_stderr(tmp_path: Path) -> None: + # Replaces test_launcher_spawn.test_spawn_survives_chatty_stderr: stderr + # must land in a log file, not a pipe nobody drains, or a chatty binary + # blocks before ever becoming healthy. + runtime = LocalRuntime.ensure_started( + binary_path=_stub_binary(tmp_path, CHATTY_BINARY), + cache_dir=tmp_path / "cache", + port=_free_port(), + download=False, + ) + try: + assert runtime.owned is True + log_path = runtime.log_path + finally: + runtime.shutdown() + assert log_path is not None + # Past any platform's pipe buffer (~64KB), proving the flood wrote without blocking. + assert log_path.stat().st_size > 200 * 1024 + + +@pytest.mark.timeout(60) +def test_failed_spawn_never_leaks_the_child(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + # Replaces test_launcher_spawn.test_cancellation_mid_spawn_terminates_the_child, + # restated for the SDK's sync spawn: when ensure_started gives up (timeout), + # the child it started must be terminated, never left running. + pid_file = tmp_path / "stub.pid" + monkeypatch.setenv("STUB_PID_FILE", str(pid_file)) + + with pytest.raises(RuntimeStartTimeoutError): + LocalRuntime.ensure_started( + binary_path=_stub_binary(tmp_path, NEVER_HEALTHY_BINARY), + cache_dir=tmp_path / "cache", + port=_free_port(), + download=False, + timeout_s=3.0, + ) + + pid = int(pid_file.read_text(encoding="utf-8")) + deadline = time.monotonic() + 10.0 + while time.monotonic() < deadline: + if not _pid_alive(pid): + return + time.sleep(0.1) + _force_kill(pid) # don't leave the leak behind even when failing + pytest.fail(f"spawned binary (pid {pid}) survived a failed ensure_started") + + +@pytest.mark.timeout(60) +def test_second_client_attaches_with_the_spawners_credentials(tmp_path: Path) -> None: + # Replaces test_launcher_token.test_second_client_attaches_with_the_spawners_generated_token: + # holo run must be able to attach to a runtime holo serve spawned. + cache = tmp_path / "cache" + port = _free_port() + spawned = LocalRuntime.ensure_started( + binary_path=_stub_binary(tmp_path, HEALTHY_BINARY), cache_dir=cache, port=port, download=False + ) + try: + attached = LocalRuntime.attach(port=port, cache_dir=cache) + assert attached is not None + assert attached.owned is False, "an attacher must never think it owns the process" + assert attached.api_key == spawned.api_key + finally: + spawned.shutdown() + + +@pytest.mark.timeout(60) +def test_persisted_credentials_are_owner_only(tmp_path: Path) -> None: + # Replaces test_launcher_token.test_spawn_persists_generated_token_with_owner_only_perms + # and test_launcher_pid.test_pid_file_written_owner_only, without pinning the + # SDK's on-disk layout: any state file carrying the bearer credential is 0600. + if sys.platform == "win32": + pytest.skip("POSIX file-mode semantics") + cache = tmp_path / "cache" + runtime = LocalRuntime.ensure_started( + binary_path=_stub_binary(tmp_path, HEALTHY_BINARY), cache_dir=cache, port=_free_port(), download=False + ) + try: + carriers = [ + p + for p in cache.rglob("*") + if p.is_file() and runtime.api_key in p.read_text(encoding="utf-8", errors="replace") + ] + assert carriers, "the credential must be discoverable on disk for cross-process attach" + for path in carriers: + assert stat.S_IMODE(path.stat().st_mode) == 0o600, path + finally: + runtime.shutdown() + + +@pytest.mark.timeout(60) +def test_attach_ignores_stale_state_after_unclean_death(tmp_path: Path) -> None: + # Replaces the stale-pid gotcha documented on launcher.discover_runtime_pids: + # a runtime that died uncleanly leaves state behind; attach must probe and + # report None, never hand back a dead runtime as live. + cache = tmp_path / "cache" + port = _free_port() + runtime = LocalRuntime.ensure_started( + binary_path=_stub_binary(tmp_path, HEALTHY_BINARY), cache_dir=cache, port=port, download=False + ) + pid = runtime.pid + assert pid is not None + # Kill out-of-band (no SDK cleanup) so the token/pid state files stay behind, stale. + _force_kill(pid) + deadline = time.monotonic() + 10.0 + while time.monotonic() < deadline and _pid_alive(pid): + time.sleep(0.1) + assert LocalRuntime.attach(port=port, cache_dir=cache) is None + + +@pytest.mark.timeout(60) +def test_attach_env_token_wins_over_persisted_state(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + # Replaces test_launcher_token.test_attach_env_token_wins_over_token_file: + # an explicit HAI_AGENT_RUNTIME_API_TOKEN overrides discovered credentials. + cache = tmp_path / "cache" + port = _free_port() + runtime = LocalRuntime.ensure_started( + binary_path=_stub_binary(tmp_path, HEALTHY_BINARY), cache_dir=cache, port=port, download=False + ) + try: + monkeypatch.setenv("HAI_AGENT_RUNTIME_API_TOKEN", "env-token") + attached = LocalRuntime.attach(port=port, cache_dir=cache) + assert attached is not None + assert attached.api_key == "env-token" + finally: + runtime.shutdown() diff --git a/tests/test_mcp_loads_holo_env.py b/tests/test_mcp_loads_holo_env.py index ad6aaab..925edc9 100644 --- a/tests/test_mcp_loads_holo_env.py +++ b/tests/test_mcp_loads_holo_env.py @@ -13,8 +13,8 @@ import pytest from holo_desktop import customization -from holo_desktop.agent_client.launcher import PORT_ENV from holo_desktop.cli import bootstrap +from holo_desktop.settings import PORT_ENV # `holo_desktop.cli.__init__` re-exports the `mcp` command function under the # same name as the submodule; go through importlib to get the module itself. diff --git a/tests/test_mcp_terminal_mapping.py b/tests/test_mcp_terminal_mapping.py index 76a9493..9b48e0a 100644 --- a/tests/test_mcp_terminal_mapping.py +++ b/tests/test_mcp_terminal_mapping.py @@ -63,7 +63,7 @@ class FakeMcpContext: @property def request_context(self) -> object: - return SimpleNamespace(lifespan_context=Lifespan(client=FakeApiClient(self.outcome), daemon=None)) # type: ignore[arg-type] + return SimpleNamespace(lifespan_context=Lifespan(client=FakeApiClient(self.outcome), runtime=None)) # type: ignore[arg-type] async def info(self, line: str) -> None: self.infos.append(line) @@ -86,7 +86,7 @@ def test_host_request_cancellation_cancels_active_agent_api_session() -> None: timestamp=datetime.now(UTC), ) client = FakeApiClient(FakeStream(status=TrajectoryStatus.COMPLETED, to_yield=[event])) - state = Lifespan(client=client, daemon=None) # type: ignore[arg-type] + state = Lifespan(client=client, runtime=None) # type: ignore[arg-type] async def cancelled_info(line: str) -> None: raise asyncio.CancelledError @@ -107,7 +107,7 @@ async def cancelled_info(line: str) -> None: def test_lifespan_shutdown_cancels_active_agent_api_sessions() -> None: client = FakeApiClient(FakeStream(status=TrajectoryStatus.COMPLETED)) active = Session(session_id=SESSION_ID, next_index=4) - state = Lifespan(client=client, daemon=None) # type: ignore[arg-type] + state = Lifespan(client=client, runtime=None) # type: ignore[arg-type] state.active_session = active asyncio.run(mcp_mod._cancel_active_sessions_best_effort(state)) diff --git a/tests/test_permission_walkthrough.py b/tests/test_permission_walkthrough.py index d4b494f..1907425 100644 --- a/tests/test_permission_walkthrough.py +++ b/tests/test_permission_walkthrough.py @@ -2,7 +2,9 @@ TCC grants only latch after the runtime restarts, so the first task against a freshly installed managed runtime that fails with a permission-shaped error -must be retried exactly once with a fresh runtime process. +must be retried exactly once with a fresh runtime process. The heuristic and +first-run marker live in agent_client.permissions now; the runtime's stderr +arrives as the SDK's ``runtime.log_path``. """ from __future__ import annotations @@ -13,137 +15,137 @@ import pytest -from holo_desktop.agent_client import launcher, runtime_install +from holo_desktop.agent_client import permissions run_mod = importlib.import_module("holo_desktop.cli.run") -@pytest.fixture() -def runtime_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: - target = tmp_path / "runtime" - monkeypatch.setattr(runtime_install, "RUNTIME_DIR", target) - return target +@pytest.fixture(autouse=True) +def first_run_marker(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + marker = tmp_path / "marker" / ".first-run-complete" + monkeypatch.setattr(permissions, "FIRST_RUN_MARKER", marker) + return marker -def test_first_run_pending_until_marked_complete(runtime_dir: Path) -> None: - assert runtime_install.first_run_pending(runtime_install.PINNED_RUNTIME_VERSION) - runtime_install.mark_first_run_complete(runtime_install.PINNED_RUNTIME_VERSION) - assert not runtime_install.first_run_pending(runtime_install.PINNED_RUNTIME_VERSION) +def test_first_run_pending_until_marked_complete() -> None: + assert permissions.first_run_pending() + permissions.mark_first_run_complete() + assert not permissions.first_run_pending() -def test_log_tail_detects_permission_shaped_errors(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(launcher, "LOG_DIR", tmp_path) - port = 12345 - log = tmp_path / f"hai-agent-runtime-{port}.log" +def test_log_tail_heuristic_reads_the_sdk_log_path(tmp_path: Path) -> None: + log = tmp_path / "runtime.log" + log.write_bytes(b"boring startup\n" + b"could not create image from display\n") + assert permissions.log_tail_suggests_permissions(log) is True + assert permissions.log_tail_suggests_permissions(tmp_path / "absent.log") is False + assert permissions.log_tail_suggests_permissions(None) is False - assert not launcher.log_tail_suggests_permissions(port), "missing log must not match" + +def test_log_tail_detects_permission_shaped_errors(tmp_path: Path) -> None: + log = tmp_path / "hai-agent-runtime-12345.log" + + assert not permissions.log_tail_suggests_permissions(log), "missing log must not match" log.write_text("error: screen recording permission denied by TCC\n", encoding="utf-8") - assert launcher.log_tail_suggests_permissions(port) + assert permissions.log_tail_suggests_permissions(log) log.write_text("error: connection refused\n", encoding="utf-8") - assert not launcher.log_tail_suggests_permissions(port) + assert not permissions.log_tail_suggests_permissions(log) def test_text_detects_permission_shaped_errors() -> None: - assert launcher.text_suggests_permissions("screen recording not permitted") - assert launcher.text_suggests_permissions("Accessibility access denied by TCC") - assert launcher.text_suggests_permissions("could not create image from display") - assert not launcher.text_suggests_permissions("model endpoint 500") - assert not launcher.text_suggests_permissions("") - - -# Pinned so the log file the walkthrough inspects matches the resolved port -# regardless of HAI_AGENT_RUNTIME_PORT leakage from other tests' dotenv loads. -TEST_PORT = 23499 + assert permissions.text_suggests_permissions("screen recording not permitted") + assert permissions.text_suggests_permissions("Accessibility access denied by TCC") + assert permissions.text_suggests_permissions("could not create image from display") + assert not permissions.text_suggests_permissions("model endpoint 500") + assert not permissions.text_suggests_permissions("") def _run_with_scripted_drive( monkeypatch: pytest.MonkeyPatch, outcomes: list[tuple[str | None, str | None, str | None]], spawned: bool, + log_path: Path | None = None, ) -> int: """Drive `run()` with a scripted `_drive`; returns how many attempts were made. - `spawned` mirrors what `ensure_running` reports: True when this process owns - the runtime, False when it attached to one started by another Holo surface. + `spawned` mirrors ``runtime.owned``: True when this process owns the runtime, + False when it attached to one started by another Holo surface. ``log_path`` + is what the SDK reported as the runtime's stderr log. """ calls: list[object] = [] - async def fake_drive(**kwargs: object) -> tuple[str | None, str | None, str | None, bool]: + async def fake_drive(**kwargs: object) -> tuple[str | None, str | None, str | None, bool, Path | None]: calls.append(kwargs) answer, status, error = outcomes[len(calls) - 1] - return answer, status, error, spawned + return answer, status, error, spawned, log_path monkeypatch.setattr(run_mod, "_drive", fake_drive) monkeypatch.setenv("HAI_API_KEY", "key") - monkeypatch.setenv(launcher.PORT_ENV, str(TEST_PORT)) monkeypatch.setenv("PATH", "/nonexistent") run_mod.run("do the thing", quiet=True) return len(calls) @pytest.mark.skipif(sys.platform != "darwin", reason="walkthrough is macOS-only") -def test_permission_failure_on_first_managed_run_retries_once( - runtime_dir: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - log_dir = tmp_path / "logs" - log_dir.mkdir() - monkeypatch.setattr(launcher, "LOG_DIR", log_dir) - (log_dir / f"hai-agent-runtime-{TEST_PORT}.log").write_text("accessibility not granted", encoding="utf-8") +def test_permission_failure_on_first_managed_run_retries_once(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + log = tmp_path / "logs" / "hai-agent-runtime.log" + log.parent.mkdir() + log.write_text("accessibility not granted", encoding="utf-8") attempts = _run_with_scripted_drive( - monkeypatch, [(None, "failed", "permission boom"), ("done", "completed", None)], spawned=True + monkeypatch, + [(None, "failed", "permission boom"), ("done", "completed", None)], + spawned=True, + log_path=log, ) assert attempts == 2 - assert not runtime_install.first_run_pending(runtime_install.PINNED_RUNTIME_VERSION), ( - "a completed retry must mark the first run done" - ) + assert not permissions.first_run_pending(), "a completed retry must mark the first run done" @pytest.mark.skipif(sys.platform != "darwin", reason="walkthrough is macOS-only") def test_permission_shaped_session_error_retries_even_without_log_match( - runtime_dir: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: """The runtime may report TCC failures only via the agent API, never via stderr.""" - log_dir = tmp_path / "logs" - log_dir.mkdir() - monkeypatch.setattr(launcher, "LOG_DIR", log_dir) - (log_dir / f"hai-agent-runtime-{TEST_PORT}.log").write_text("model endpoint 500", encoding="utf-8") + log = tmp_path / "logs" / "hai-agent-runtime.log" + log.parent.mkdir() + log.write_text("model endpoint 500", encoding="utf-8") attempts = _run_with_scripted_drive( - monkeypatch, [(None, "failed", "screen recording not permitted"), ("done", "completed", None)], spawned=True + monkeypatch, + [(None, "failed", "screen recording not permitted"), ("done", "completed", None)], + spawned=True, + log_path=log, ) assert attempts == 2 @pytest.mark.skipif(sys.platform != "darwin", reason="walkthrough is macOS-only") -def test_permission_shaped_session_error_retries_with_missing_log( - runtime_dir: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - monkeypatch.setattr(launcher, "LOG_DIR", tmp_path / "no-such-dir") - +def test_permission_shaped_session_error_retries_with_missing_log(monkeypatch: pytest.MonkeyPatch) -> None: attempts = _run_with_scripted_drive( - monkeypatch, [(None, "failed", "accessibility access denied by TCC"), ("done", "completed", None)], spawned=True + monkeypatch, + [(None, "failed", "accessibility access denied by TCC"), ("done", "completed", None)], + spawned=True, + log_path=None, ) assert attempts == 2 @pytest.mark.skipif(sys.platform != "darwin", reason="walkthrough is macOS-only") -def test_non_permission_failure_does_not_retry( - runtime_dir: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - log_dir = tmp_path / "logs" - log_dir.mkdir() - monkeypatch.setattr(launcher, "LOG_DIR", log_dir) - (log_dir / f"hai-agent-runtime-{TEST_PORT}.log").write_text("model endpoint 500", encoding="utf-8") +def test_non_permission_failure_does_not_retry(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + log = tmp_path / "logs" / "hai-agent-runtime.log" + log.parent.mkdir() + log.write_text("model endpoint 500", encoding="utf-8") with pytest.raises(SystemExit): - _run_with_scripted_drive(monkeypatch, [(None, "failed", "boom"), ("never", "completed", None)], spawned=True) + _run_with_scripted_drive( + monkeypatch, [(None, "failed", "boom"), ("never", "completed", None)], spawned=True, log_path=log + ) def test_missing_terminal_status_exits_nonzero( - runtime_dir: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] ) -> None: with pytest.raises(SystemExit) as exc: _run_with_scripted_drive(monkeypatch, [(None, None, None)], spawned=True) @@ -153,33 +155,29 @@ def test_missing_terminal_status_exits_nonzero( @pytest.mark.skipif(sys.platform != "darwin", reason="walkthrough is macOS-only") -def test_completed_runs_after_first_do_not_recheck(runtime_dir: Path, monkeypatch: pytest.MonkeyPatch) -> None: - runtime_install.mark_first_run_complete(runtime_install.PINNED_RUNTIME_VERSION) +def test_completed_runs_after_first_do_not_recheck(monkeypatch: pytest.MonkeyPatch) -> None: + permissions.mark_first_run_complete() attempts = _run_with_scripted_drive(monkeypatch, [("done", "completed", None)], spawned=True) assert attempts == 1 @pytest.mark.skipif(sys.platform != "darwin", reason="walkthrough is macOS-only") def test_attach_mode_permission_failure_warns_instead_of_retrying( - runtime_dir: Path, - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], ) -> None: - """An attached runtime belongs to another Holo process: `aclose()` is a no-op, - so a retry would reuse the same process and TCC grants would still not latch. - Instead of a futile retry behind a false "restarting" message, point the user - at the owning process.""" - monkeypatch.setattr(launcher, "LOG_DIR", tmp_path / "no-such-dir") - + """An attached runtime belongs to another Holo process: shutting it down is not + ours to do, so a retry would reuse the same process and TCC grants would still + not latch. Instead of a futile retry behind a false "restarting" message, point + the user at the owning process.""" # A single scripted outcome: a second attempt would IndexError, failing the test. with pytest.raises(SystemExit): - _run_with_scripted_drive(monkeypatch, [(None, "failed", "screen recording not permitted")], spawned=False) + _run_with_scripted_drive( + monkeypatch, [(None, "failed", "screen recording not permitted")], spawned=False, log_path=None + ) err_text = capsys.readouterr().err.replace("\n", " ").lower() assert "another holo process" in err_text assert "restart" in err_text assert "restarting the runtime and retrying" not in err_text, "must not claim a restart it cannot perform" - assert runtime_install.first_run_pending(runtime_install.PINNED_RUNTIME_VERSION), ( - "a failed attach-mode first run must stay pending" - ) + assert permissions.first_run_pending(), "a failed attach-mode first run must stay pending" diff --git a/tests/test_run_port_env.py b/tests/test_run_port_env.py index d0843a4..2d5f69b 100644 --- a/tests/test_run_port_env.py +++ b/tests/test_run_port_env.py @@ -1,15 +1,14 @@ """Behavioural test: `holo run` must honour ``HAI_AGENT_RUNTIME_PORT`` like mcp/acp do. -A fake agent-API server (health + create-session + changes) listens on a free -port advertised only via ``HAI_AGENT_RUNTIME_PORT``. With no ``--port`` flag, -`run` must attach there — not probe the hardcoded default port and spawn a -fresh binary on the wrong loopback port. +A fake agent-API server (health + create-session + changes + status) listens on +a free port advertised only via ``HAI_AGENT_RUNTIME_PORT``. With no ``--port`` +flag, `run` must attach there — not probe the hardcoded default port and spawn +a fresh binary on the wrong loopback port. """ from __future__ import annotations import json -import sys from collections.abc import Iterator from contextlib import contextmanager from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer @@ -17,9 +16,8 @@ import pytest -from holo_desktop.agent_client import launcher -from holo_desktop.agent_client.launcher import AUTH_TOKEN_ENV, PORT_ENV from holo_desktop.cli.run import run +from holo_desktop.settings import AUTH_TOKEN_ENV, PORT_ENV FAKE_ANSWER = "42 unread emails" @@ -30,6 +28,14 @@ "answer": FAKE_ANSWER, } +# A full session envelope: the SDK client parses the create response typed. +_CREATED_SESSION = { + "id": "fake-session", + "request": {"agent": "holo"}, + "status": {"status": "running"}, + "created_at": "2026-06-30T00:00:00Z", +} + class _AgentApiHandler(BaseHTTPRequestHandler): """Minimal agent-API: healthy, one session, immediately-completed trajectory.""" @@ -48,13 +54,15 @@ def do_GET(self) -> None: self.end_headers() elif "/changes" in self.path: self._json(_COMPLETED_CHANGES) + elif self.path.endswith("/status"): + self._json({"status": "completed", "error": None}) else: self.send_response(404) self.end_headers() def do_POST(self) -> None: if self.path.endswith("/sessions"): - self._json({"id": "fake-session"}) + self._json(_CREATED_SESSION) else: self.send_response(404) self.end_headers() @@ -79,9 +87,10 @@ def _fake_agent_server() -> Iterator[int]: def test_run_attaches_to_port_from_env(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None: monkeypatch.setenv(AUTH_TOKEN_ENV, "test-token") monkeypatch.setenv("HAI_API_KEY", "test-key") # bypass the interactive login bootstrap - # Crash-only stub: if `run` ignores the env port it tries to spawn on the - # default port and must die loudly — never fall through to a real binary on PATH. - monkeypatch.setattr(launcher, "resolve_command", lambda **_: [sys.executable, "-c", "raise SystemExit(2)"]) + # Crash-only guard: if `run` ignores the env port it tries to spawn on the + # default port; pointing the SDK's binary override at a missing file makes + # that die loudly — never fall through to a real binary on PATH. + monkeypatch.setenv("HAI_AGENT_LOCAL_BINARY_PATH", "/nonexistent/spawn-attempted") with _fake_agent_server() as port: monkeypatch.setenv(PORT_ENV, str(port)) diff --git a/tests/test_runtime_install.py b/tests/test_runtime_install.py deleted file mode 100644 index af75dd4..0000000 --- a/tests/test_runtime_install.py +++ /dev/null @@ -1,328 +0,0 @@ -"""Behavioural tests for download-on-first-run of the hai-agent-runtime binary. - -A real loopback HTTP server plays the release host. The installer must -download, verify the sha256, and atomically install under the managed runtime -dir; `resolve_command` must prefer PATH > managed install, only then download, -and raise when nothing is found. There is no env override: pointing Holo at a -different runtime requires a deliberate `hai-agent-runtime` wrapper on PATH. -""" - -from __future__ import annotations - -import hashlib -import io -import os -import zipfile -from collections.abc import Iterator -from contextlib import contextmanager -from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer -from pathlib import Path -from threading import Thread - -import httpx -import pytest - -from holo_desktop.agent_client import launcher, runtime_install -from holo_desktop.agent_client.runtime_install import ( - DOWNLOAD_SHA256_ENV, - DOWNLOAD_URL_ENV, - PINNED_RUNTIME_VERSION, - RuntimeArtifact, - install_runtime, - installed_binary, - pinned_artifact, -) -from holo_desktop.settings import RuntimeInstallSettings, load_holo_settings - -FAKE_BINARY = b"#!/bin/sh\necho fake-runtime\n" - - -@contextmanager -def _release_server(payload: bytes, *, path: str = "/hai-agent-runtime") -> Iterator[str]: - """Serve `payload` at `path`; yields the full URL.""" - - class Handler(BaseHTTPRequestHandler): - def do_GET(self) -> None: - if self.path != path: - self.send_response(404) - self.end_headers() - return - self.send_response(200) - self.send_header("Content-Length", str(len(payload))) - self.end_headers() - self.wfile.write(payload) - - def log_message(self, format: str, *args: object) -> None: # stdlib signature; silences request logs - return - - server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) - thread = Thread(target=server.serve_forever, daemon=True) - thread.start() - try: - yield f"http://127.0.0.1:{server.server_address[1]}{path}" - finally: - server.shutdown() - thread.join(timeout=5.0) - - -def _point_at(monkeypatch: pytest.MonkeyPatch, url: str, payload: bytes) -> None: - monkeypatch.setenv(DOWNLOAD_URL_ENV, url) - monkeypatch.setenv(DOWNLOAD_SHA256_ENV, hashlib.sha256(payload).hexdigest()) - - -def _artifact() -> runtime_install.RuntimeArtifact: - return pinned_artifact(settings=load_holo_settings().install) - - -def _command() -> list[str]: - return launcher.resolve_command(settings=load_holo_settings()) - - -@pytest.fixture() -def runtime_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: - target = tmp_path / "runtime" - monkeypatch.setattr(runtime_install, "RUNTIME_DIR", target) - return target - - -def test_install_downloads_verifies_and_installs(runtime_dir: Path, monkeypatch: pytest.MonkeyPatch) -> None: - with _release_server(FAKE_BINARY) as url: - _point_at(monkeypatch, url, FAKE_BINARY) - binary = install_runtime(_artifact()) - - assert binary.read_bytes() == FAKE_BINARY - assert binary.is_relative_to(runtime_dir / PINNED_RUNTIME_VERSION) - if os.name != "nt": - assert os.access(binary, os.X_OK), "installed binary must be executable" - # A second resolve must find the managed install without re-downloading. - assert installed_binary(PINNED_RUNTIME_VERSION) == binary - - -def test_checksum_mismatch_raises_and_installs_nothing(runtime_dir: Path, monkeypatch: pytest.MonkeyPatch) -> None: - with _release_server(FAKE_BINARY) as url: - monkeypatch.setenv(DOWNLOAD_URL_ENV, url) - monkeypatch.setenv(DOWNLOAD_SHA256_ENV, hashlib.sha256(b"something else").hexdigest()) - with pytest.raises(RuntimeError, match="sha256"): - install_runtime(_artifact()) - - assert installed_binary(PINNED_RUNTIME_VERSION) is None, "a failed verify must not leave a binary behind" - - -def test_url_override_without_sha_is_refused(runtime_dir: Path, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setenv(DOWNLOAD_URL_ENV, "http://127.0.0.1:1/hai-agent-runtime") - monkeypatch.delenv(DOWNLOAD_SHA256_ENV, raising=False) - with pytest.raises(RuntimeError, match=DOWNLOAD_SHA256_ENV): - _artifact() - - -def test_http_error_raises(runtime_dir: Path, monkeypatch: pytest.MonkeyPatch) -> None: - with _release_server(FAKE_BINARY, path="/elsewhere") as url: - wrong_url = url.replace("/elsewhere", "/missing") - _point_at(monkeypatch, wrong_url, FAKE_BINARY) - with pytest.raises(RuntimeError, match="404"): - install_runtime(_artifact()) - assert installed_binary(PINNED_RUNTIME_VERSION) is None - - -def test_app_zip_artifact_is_extracted(runtime_dir: Path, monkeypatch: pytest.MonkeyPatch) -> None: - # macOS releases ship a zipped .app bundle; the executable lives inside it. - buffer = io.BytesIO() - with zipfile.ZipFile(buffer, "w") as zf: - info = zipfile.ZipInfo("hai_agent_runtime.app/Contents/MacOS/hai-agent-runtime") - info.external_attr = 0o755 << 16 - zf.writestr(info, FAKE_BINARY) - payload = buffer.getvalue() - - with _release_server(payload, path="/hai-agent-runtime.zip") as url: - _point_at(monkeypatch, url, payload) - binary = install_runtime(_artifact()) - - assert binary.name == "hai-agent-runtime" - assert "hai_agent_runtime.app" in binary.as_posix() - assert binary.read_bytes() == FAKE_BINARY - if os.name != "nt": - assert os.access(binary, os.X_OK) - assert installed_binary(PINNED_RUNTIME_VERSION) == binary - - -def _clear_resolution_env(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: - monkeypatch.delenv(DOWNLOAD_URL_ENV, raising=False) - monkeypatch.delenv(DOWNLOAD_SHA256_ENV, raising=False) - monkeypatch.setenv("PATH", str(tmp_path / "empty-path")) - - -def _managed_install(runtime_dir: Path) -> Path: - version_dir = runtime_dir / PINNED_RUNTIME_VERSION - version_dir.mkdir(parents=True) - binary = version_dir / ("hai-agent-runtime.exe" if os.name == "nt" else "hai-agent-runtime") - binary.write_bytes(FAKE_BINARY) - binary.chmod(0o755) - return binary - - -def test_resolve_ignores_binary_env_var(runtime_dir: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - # The old HAI_AGENT_RUNTIME_BINARY escape hatch silently swapped the runtime; it must be dead. - _clear_resolution_env(monkeypatch, tmp_path) - binary = _managed_install(runtime_dir) - monkeypatch.setenv("HAI_AGENT_RUNTIME_BINARY", "python -m hai_agent_runtime") - assert _command() == [str(binary)] - - -def test_resolve_raises_when_nothing_found_and_download_declined( - runtime_dir: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - _clear_resolution_env(monkeypatch, tmp_path) - monkeypatch.setattr(runtime_install, "confirm_download", lambda: False) - with pytest.raises(RuntimeError, match="PATH"): - _command() - - -def test_resolve_prefers_path_over_managed(runtime_dir: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - _clear_resolution_env(monkeypatch, tmp_path) - _managed_install(runtime_dir) - path_dir = tmp_path / "bin" - path_dir.mkdir() - # Windows shutil.which only matches PATHEXT names; an extensionless file is - # invisible there and resolution would fall through to the managed install. - exe_name = "hai-agent-runtime.exe" if os.name == "nt" else "hai-agent-runtime" - on_path = path_dir / exe_name - on_path.write_bytes(FAKE_BINARY) - on_path.chmod(0o755) - monkeypatch.setenv("PATH", str(path_dir)) - assert [os.path.normcase(p) for p in _command()] == [os.path.normcase(str(on_path))] - - -def test_resolve_uses_managed_install_without_downloading( - runtime_dir: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - _clear_resolution_env(monkeypatch, tmp_path) - binary = _managed_install(runtime_dir) - # Any download attempt would hit a dead URL and raise; reaching it means the managed install was ignored. - monkeypatch.setenv(DOWNLOAD_URL_ENV, "http://127.0.0.1:1/dead") - monkeypatch.setenv(DOWNLOAD_SHA256_ENV, "0" * 64) - assert _command() == [str(binary)] - - -def test_resolve_downloads_when_nothing_is_installed( - runtime_dir: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - _clear_resolution_env(monkeypatch, tmp_path) - with _release_server(FAKE_BINARY) as url: - _point_at(monkeypatch, url, FAKE_BINARY) - command = _command() - assert command == [str(installed_binary(PINNED_RUNTIME_VERSION))] - expected = runtime_dir / PINNED_RUNTIME_VERSION - assert Path(command[0]).is_relative_to(expected) - - -def test_ensure_managed_runtime_uses_existing_install(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: - existing = tmp_path / "hai-agent-runtime" - existing.write_text("") - monkeypatch.setattr(runtime_install, "installed_binary", lambda version: existing) - - called = False - - def fake_install(_artifact: RuntimeArtifact) -> Path: - nonlocal called - called = True - return tmp_path / "unexpected" - - monkeypatch.setattr(runtime_install, "install_runtime", fake_install) - - assert runtime_install.ensure_managed_runtime(settings=RuntimeInstallSettings(), assume_yes=True) == existing - assert called is False - - -def test_ensure_managed_runtime_downloads_when_assume_yes(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: - artifact = RuntimeArtifact(url="https://example.test/runtime.zip", sha256="1" * 64) - installed = tmp_path / "managed" / "hai-agent-runtime" - monkeypatch.setattr(runtime_install, "installed_binary", lambda version: None) - monkeypatch.setattr(runtime_install, "pinned_artifact", lambda *, settings: artifact) - - def fail_prompt() -> bool: - raise AssertionError("assume_yes must bypass confirm_download") - - monkeypatch.setattr(runtime_install, "confirm_download", fail_prompt) - monkeypatch.setattr(runtime_install, "install_runtime", lambda actual: installed if actual == artifact else None) - - assert runtime_install.ensure_managed_runtime(settings=RuntimeInstallSettings(), assume_yes=True) == installed - - -def test_ensure_managed_runtime_rejects_unimplemented_platform( - runtime_dir: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - _clear_resolution_env(monkeypatch, tmp_path) - monkeypatch.setattr(runtime_install, "platform_key", lambda: "linux-x86_64") - with pytest.raises(runtime_install.RuntimeArtifactUnavailable, match="not published"): - runtime_install.ensure_managed_runtime(settings=RuntimeInstallSettings(), assume_yes=True) - - -@pytest.mark.parametrize( - "platform_key, suffix", - [ - ("darwin-arm64", ".zip"), - ("windows-x86_64", ".zip"), - ], -) -def test_published_manifest_entry_is_real(platform_key: str, suffix: str) -> None: - # Published platforms must not ship placeholder values. - artifact = runtime_install.MANIFEST[platform_key] - assert len(artifact.sha256) == 64 and set(artifact.sha256) <= set("0123456789abcdef") - assert artifact.sha256 != runtime_install.PLACEHOLDER_SHA256, ( - "placeholder sha256 would make every download fail verification" - ) - assert artifact.url.startswith("https://") - assert artifact.url.endswith(suffix) - - -_NETWORK_TEST_ENV = "HOLO_RUNTIME_NETWORK_TEST" - - -@pytest.mark.skipif( - os.environ.get(_NETWORK_TEST_ENV, "").strip().lower() not in ("1", "true", "yes"), - reason=f"network test against the live CDN; set {_NETWORK_TEST_ENV}=1 to run", -) -@pytest.mark.parametrize("platform_key", sorted(runtime_install.MANIFEST)) -def test_pinned_artifact_is_published_and_matches_sha(platform_key: str) -> None: - # The unit suite only checks the pin's *shape*; this proves the pinned URL is - # actually live and its bytes hash to the pinned digest (the real end-to-end - # guarantee a download relies on). Opt-in: it hits the network and is large. - artifact = runtime_install.MANIFEST[platform_key] - digest = hashlib.sha256() - with ( - httpx.Client(follow_redirects=True, timeout=httpx.Timeout(30.0, read=600.0)) as client, - client.stream("GET", artifact.url) as response, - ): - assert response.status_code == 200, f"{artifact.url} -> HTTP {response.status_code}" - for chunk in response.iter_bytes(): - digest.update(chunk) - assert digest.hexdigest() == artifact.sha256.lower(), f"published bytes do not match pinned sha for {platform_key}" - - -@pytest.mark.parametrize("platform_key", ["darwin-x86_64", "linux-x86_64"]) -def test_unimplemented_platform_refuses_install( - platform_key: str, runtime_dir: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - # Unpublished platforms must be explicit product gaps, not placeholder - # release URLs that look real until download/verification time. - _clear_resolution_env(monkeypatch, tmp_path) - monkeypatch.setattr(runtime_install, "platform_key", lambda: platform_key) - with pytest.raises(runtime_install.RuntimeArtifactUnavailable, match="not published"): - _artifact() - assert installed_binary(PINNED_RUNTIME_VERSION) is None - - -@pytest.mark.parametrize("platform_key", ["darwin-x86_64", "linux-x86_64"]) -def test_resolve_fails_before_prompting_on_unimplemented_platform( - platform_key: str, runtime_dir: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - # The TTY confirm must never ask the user to approve a download that cannot happen. - _clear_resolution_env(monkeypatch, tmp_path) - monkeypatch.setattr(runtime_install, "platform_key", lambda: platform_key) - - def _no_prompt() -> bool: - raise AssertionError("confirm_download must not be reached for an unimplemented platform") - - monkeypatch.setattr(runtime_install, "confirm_download", _no_prompt) - with pytest.raises(runtime_install.RuntimeArtifactUnavailable, match="not published"): - _command() diff --git a/tests/test_sdk_runtime.py b/tests/test_sdk_runtime.py new file mode 100644 index 0000000..905df23 --- /dev/null +++ b/tests/test_sdk_runtime.py @@ -0,0 +1,106 @@ +"""Holo's spawn-env bridge into hai-agents local mode. + +Ports the runtime_child_env specs from test_launcher.py: DDTrace default-off, +the hosted-gateway default, and the self-hosted rule that a custom +HAI_AGENT_RUNTIME_BASE_URL must never see the portal HAI_API_KEY. Also keeps +the attach guard: explicit CLI config must not be silently ignored by an +already-running runtime. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from holo_desktop.agent_client import sdk_runtime +from holo_desktop.agent_client.sdk_runtime import SpawnConfig, ensure_local_runtime +from holo_desktop.settings import load_holo_settings + + +class _FakeRuntime: + base_url = "http://127.0.0.1:18795" + api_key = "k" + owned = True + + +def _capture_spawn(monkeypatch: pytest.MonkeyPatch, captured: dict) -> None: + def fake_ensure_started(*, port, spawn_env, inherit_env, timeout_s, **_ignored): + captured["port"] = port + captured["spawn_env"] = spawn_env + captured["inherit_env"] = inherit_env + return _FakeRuntime() + + monkeypatch.setattr(sdk_runtime.LocalRuntime, "attach", staticmethod(lambda *, port=None, cache_dir=None: None)) + monkeypatch.setattr(sdk_runtime.LocalRuntime, "ensure_started", staticmethod(fake_ensure_started)) + + +def test_custom_base_url_strips_portal_key_from_spawn_env(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("HAI_API_KEY", "portal-secret") + captured: dict = {} + _capture_spawn(monkeypatch, captured) + + ensure_local_runtime( + SpawnConfig(port=18795, base_url="https://self-hosted.example/v1"), + settings=load_holo_settings(), + ) + + env = captured["spawn_env"] + assert env["HAI_AGENT_RUNTIME_BASE_URL"] == "https://self-hosted.example/v1" + assert "HAI_API_KEY" not in env, "the portal key must never leak to a self-hosted endpoint" + # Without inherit_env=False the SDK would merge os.environ back in and undo the strip. + assert captured["inherit_env"] is False + + +def test_hosted_path_defaults_gateway_and_keeps_key(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("HAI_API_KEY", "portal-secret") + monkeypatch.delenv("HAI_BASE_URL", raising=False) + monkeypatch.delenv("HAI_AGENT_RUNTIME_BASE_URL", raising=False) + monkeypatch.delenv("DD_TRACE_ENABLED", raising=False) + captured: dict = {} + _capture_spawn(monkeypatch, captured) + + ensure_local_runtime(SpawnConfig(port=18795), settings=load_holo_settings()) + + env = captured["spawn_env"] + assert env["HAI_API_KEY"] == "portal-secret" + assert env["HAI_BASE_URL"] == sdk_runtime.PRODUCTION_GATEWAY_URL + assert env["DD_TRACE_ENABLED"] == "false" + + +def test_flags_and_runs_dir_reach_spawn_env(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + monkeypatch.delenv("HAI_AGENT_RUNTIME_BASE_URL", raising=False) + captured: dict = {} + _capture_spawn(monkeypatch, captured) + + ensure_local_runtime( + SpawnConfig(port=19001, model="holo-3", fake=True, fast=True, runs_dir=tmp_path / "runs"), + settings=load_holo_settings(), + ) + + env = captured["spawn_env"] + assert captured["port"] == 19001 + assert env["HAI_AGENT_RUNTIME_MODEL"] == "holo-3" + assert env["HAI_AGENT_RUNTIME_FAKE"] == "1" + assert env["HAI_AGENT_RUNTIME_FAST"] == "1" + assert env["HAI_AGENT_RUNTIME_RUNS_DIR"] == str(tmp_path / "runs") + + +def test_attach_with_explicit_flags_refuses_silent_ignore(monkeypatch: pytest.MonkeyPatch) -> None: + # Preserved ensure_running guard: a running runtime keeps its spawn-time + # config, so explicit CLI flags must error, never be silently dropped. + monkeypatch.setattr( + sdk_runtime.LocalRuntime, + "attach", + staticmethod(lambda *, port=None, cache_dir=None: _FakeRuntime()), + ) + + with pytest.raises(RuntimeError, match="silently ignored"): + ensure_local_runtime(SpawnConfig(port=18795, model="holo-3"), settings=load_holo_settings()) + + +def test_attach_without_flags_reuses_the_running_runtime(monkeypatch: pytest.MonkeyPatch) -> None: + runtime = _FakeRuntime() + monkeypatch.setattr(sdk_runtime.LocalRuntime, "attach", staticmethod(lambda *, port=None, cache_dir=None: runtime)) + + assert ensure_local_runtime(SpawnConfig(port=18795), settings=load_holo_settings()) is runtime diff --git a/tests/test_serve_port_env.py b/tests/test_serve_port_env.py index ddf7a29..3617643 100644 --- a/tests/test_serve_port_env.py +++ b/tests/test_serve_port_env.py @@ -9,7 +9,6 @@ import asyncio import importlib -import sys from collections.abc import Iterator from contextlib import contextmanager from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer @@ -17,10 +16,8 @@ import pytest -from holo_desktop.agent_client import launcher -from holo_desktop.agent_client.launcher import AUTH_TOKEN_ENV, PORT_ENV from holo_desktop.cli.serve import HoloExecutor -from holo_desktop.settings import load_holo_settings +from holo_desktop.settings import AUTH_TOKEN_ENV, PORT_ENV, load_holo_settings serve_mod = importlib.import_module("holo_desktop.cli.serve") @@ -49,13 +46,15 @@ def _fake_agent_server() -> Iterator[int]: @pytest.mark.timeout(60) def test_executor_attaches_to_port_from_env(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv(AUTH_TOKEN_ENV, "test-token") - # Crash-only stub: a wrong-port spawn attempt must die loudly, never reach a real binary. - monkeypatch.setattr(launcher, "resolve_command", lambda **_: [sys.executable, "-c", "raise SystemExit(2)"]) + # Crash-only guard: if the executor ignores the env port it tries to spawn on + # the default port; pointing the SDK's binary override at a missing file makes + # that die loudly — never fall through to a real binary on PATH. + monkeypatch.setenv("HAI_AGENT_LOCAL_BINARY_PATH", "/nonexistent/spawn-attempted") async def startup_and_shutdown(executor: HoloExecutor) -> str: await executor.startup() - assert executor._daemon is not None - base_url = executor._daemon.base_url + assert executor._runtime is not None + base_url = executor._runtime.base_url await executor.shutdown() return base_url diff --git a/tests/test_setup_cli.py b/tests/test_setup_cli.py index 89ca358..f930413 100644 --- a/tests/test_setup_cli.py +++ b/tests/test_setup_cli.py @@ -2,11 +2,10 @@ import importlib from pathlib import Path +from types import SimpleNamespace import pytest - -from holo_desktop.agent_client.runtime_install import RuntimeArtifactUnavailable -from holo_desktop.settings import RuntimeInstallSettings +from hai_agents.local import LocalRuntimeError def test_installer_bootstrap_prepares_runtime_and_prints_next_steps( @@ -15,19 +14,19 @@ def test_installer_bootstrap_prepares_runtime_and_prints_next_steps( setup_module = importlib.import_module("holo_desktop.installer_bootstrap") calls: list[tuple[str, object]] = [] - runtime_path = tmp_path / "runtime" / "hai-agent-runtime" + runtime = SimpleNamespace(base_url="http://127.0.0.1:18795") monkeypatch.setattr(setup_module, "load_holo_env", lambda: calls.append(("load_env", None))) monkeypatch.setattr( - setup_module, "load_holo_settings", lambda: type("Settings", (), {"install": RuntimeInstallSettings()})() + setup_module, "load_holo_settings", lambda: SimpleNamespace(runtime=SimpleNamespace(port=18795)) ) monkeypatch.setattr(setup_module, "seed_bundled_skills", lambda: calls.append(("seed_skills", None))) - def fake_ensure(*, settings: RuntimeInstallSettings, assume_yes: bool) -> Path: + def fake_ensure(*, settings: object, assume_yes: bool) -> object: calls.append(("ensure_runtime", assume_yes)) - return runtime_path + return runtime - monkeypatch.setattr(setup_module, "ensure_managed_runtime", fake_ensure) + monkeypatch.setattr(setup_module, "_runtime_for_installer", fake_ensure) setup_module.bootstrap_installer(yes=True, login=False, install_hosts=False) @@ -45,13 +44,13 @@ def test_installer_bootstrap_exits_cleanly_for_unsupported_runtime( monkeypatch.setattr(setup_module, "load_holo_env", lambda: None) monkeypatch.setattr( - setup_module, "load_holo_settings", lambda: type("Settings", (), {"install": RuntimeInstallSettings()})() + setup_module, "load_holo_settings", lambda: SimpleNamespace(runtime=SimpleNamespace(port=18795)) ) monkeypatch.setattr(setup_module, "seed_bundled_skills", lambda: None) monkeypatch.setattr( setup_module, - "ensure_managed_runtime", - lambda *, settings, assume_yes: (_ for _ in ()).throw(RuntimeArtifactUnavailable("not published")), + "_runtime_for_installer", + lambda *, settings, assume_yes: (_ for _ in ()).throw(LocalRuntimeError("not published")), ) with pytest.raises(SystemExit) as exc: @@ -67,13 +66,13 @@ def test_installer_bootstrap_can_run_login_and_host_install(monkeypatch: pytest. calls: list[str] = [] monkeypatch.setattr(setup_module, "load_holo_env", lambda: None) monkeypatch.setattr( - setup_module, "load_holo_settings", lambda: type("Settings", (), {"install": RuntimeInstallSettings()})() + setup_module, "load_holo_settings", lambda: SimpleNamespace(runtime=SimpleNamespace(port=18795)) ) monkeypatch.setattr(setup_module, "seed_bundled_skills", lambda: None) monkeypatch.setattr( setup_module, - "ensure_managed_runtime", - lambda *, settings, assume_yes: tmp_path / "hai-agent-runtime", + "_runtime_for_installer", + lambda *, settings, assume_yes: SimpleNamespace(base_url="http://127.0.0.1:18795"), ) install_module = importlib.import_module("holo_desktop.cli.install") diff --git a/tests/test_stop_cli.py b/tests/test_stop_cli.py index b70ff95..04e1af8 100644 --- a/tests/test_stop_cli.py +++ b/tests/test_stop_cli.py @@ -1,21 +1,28 @@ -"""`holo stop` files a stop request; `--force` kills the runtime pids it discovers.""" +"""`holo stop` files a stop request; `--force` kills via SDK discovery plus legacy pid files.""" from __future__ import annotations +import importlib from pathlib import Path +from types import SimpleNamespace import pytest -from holo_desktop.agent_client import launcher +from holo_desktop.agent_client import legacy_state from holo_desktop.cli.stop import stop from holo_desktop.killswitch import channel from holo_desktop.killswitch.channel import StopSentinel +# holo_desktop.cli.__init__ rebinds the `stop` attribute to the function, so reach +# the module object through importlib to patch its module-level LocalRuntime seam. +stop_module = importlib.import_module("holo_desktop.cli.stop") + @pytest.fixture(autouse=True) def _isolate(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(channel, "STOP_PATH", tmp_path / "stop") - monkeypatch.setattr(launcher, "TOKEN_DIR", tmp_path) + monkeypatch.setattr(legacy_state, "TOKEN_DIR", tmp_path) + monkeypatch.setattr(stop_module.LocalRuntime, "attach", staticmethod(lambda *, port=None, cache_dir=None: None)) def test_stop_files_a_fresh_request() -> None: @@ -25,22 +32,34 @@ def test_stop_files_a_fresh_request() -> None: assert before.stop_requested() is True -def test_force_kills_discovered_runtime_pids(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: +def test_force_kills_the_sdk_discovered_runtime(monkeypatch: pytest.MonkeyPatch) -> None: + kills: list[int] = [] + runtime = SimpleNamespace(pid=4242, force_kill=lambda: kills.append(4242)) + monkeypatch.setattr(stop_module.LocalRuntime, "attach", staticmethod(lambda *, port=None, cache_dir=None: runtime)) + + stop(force=True, port=9000) + + assert kills == [4242] + + +def test_force_still_kills_legacy_pre_sdk_pid_files(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + # One-release compat: a runtime spawned by a pre-SDK holo left ~/.holo/agent-pid-; + # an upgraded holo must still be able to stop it. (tmp_path / "agent-pid-18795").write_text("4242", encoding="utf-8") (tmp_path / "agent-pid-9000").write_text("4243", encoding="utf-8") killed: list[int] = [] - monkeypatch.setattr(launcher, "kill_runtime_by_pid", lambda pid: killed.append(pid) or True) + monkeypatch.setattr(legacy_state, "kill_runtime_by_pid", lambda pid: killed.append(pid) or True) stop(force=True) assert sorted(killed) == [4242, 4243] -def test_force_targets_only_the_requested_port(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: +def test_legacy_force_targets_only_the_requested_port(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: (tmp_path / "agent-pid-18795").write_text("4242", encoding="utf-8") (tmp_path / "agent-pid-9000").write_text("4243", encoding="utf-8") killed: list[int] = [] - monkeypatch.setattr(launcher, "kill_runtime_by_pid", lambda pid: killed.append(pid) or True) + monkeypatch.setattr(legacy_state, "kill_runtime_by_pid", lambda pid: killed.append(pid) or True) stop(force=True, port=9000) diff --git a/uv.lock b/uv.lock index 3481e31..7017eff 100644 --- a/uv.lock +++ b/uv.lock @@ -618,6 +618,16 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7b/da/9f1cf7c0881dd8ab24c9ce2ef5f60d1615977967b95114ce9534e4c697f3/hai_agent_api-0.1.15-py3-none-any.whl", hash = "sha256:e22ec2c61fc24dcc5a8847f44d9eeadeb2f5dad203dc96b3927881ecb3bd9a4e", size = 63401, upload-time = "2026-06-16T15:57:30.172Z" }, ] +[[package]] +name = "hai-agents" +version = "1.0.6" +source = { git = "https://github.com/hcompai/hai-agents-python.git?branch=claude%2Fsdk-local-mode#f65524a9962f0569232c40af5df660c17b74d6e4" } +dependencies = [ + { name = "httpx" }, + { name = "pydantic" }, + { name = "typing-extensions" }, +] + [[package]] name = "holo-desktop-cli" version = "0.0.2" @@ -626,6 +636,7 @@ dependencies = [ { name = "a2a-sdk", extra = ["http-server"] }, { name = "agent-client-protocol" }, { name = "hai-agent-api" }, + { name = "hai-agents" }, { name = "httpx" }, { name = "mcp" }, { name = "pydantic" }, @@ -659,6 +670,7 @@ requires-dist = [ { name = "a2a-sdk", extras = ["http-server"], specifier = ">=1.0.3" }, { name = "agent-client-protocol", specifier = ">=0.10" }, { name = "hai-agent-api", specifier = ">=0.1.15" }, + { name = "hai-agents", extras = ["local"], git = "https://github.com/hcompai/hai-agents-python.git?branch=claude%2Fsdk-local-mode" }, { name = "httpx", specifier = ">=0.28.1" }, { name = "mcp", specifier = ">=1.10" }, { name = "pydantic", specifier = ">=2.11.4" },