diff --git a/pyproject.toml b/pyproject.toml index f7d7067..e925c90 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,6 +37,10 @@ cli = [ "rich>=13,<16", "typer>=0.12,<1", ] +# Local mode is stdlib + the core httpx dep today; the empty extra keeps +# `pip install "hai-agents[local]"` valid documentation and lets a real +# dependency land later without a breaking install-instruction change. +local = [] desktop = [ "hai-drivers[desktop]>=0.1.0", ] diff --git a/src/hai_agents/client.py b/src/hai_agents/client.py index 82e5354..d23524a 100644 --- a/src/hai_agents/client.py +++ b/src/hai_agents/client.py @@ -27,6 +27,28 @@ from .sessions.client import AsyncSessionsClient, SessionsClient from .tools import ToolInput, as_tools +if typing.TYPE_CHECKING: + from .local.runtime import LocalRuntime + + +def _ensure_local_runtime( + runtime: typing.Optional["LocalRuntime"], + *, + spawn_env: typing.Optional[typing.Dict[str, str]] = None, + **local_kwargs: typing.Any, +) -> "LocalRuntime": + """Import hai_agents.local lazily so remote-only users never pay for it.""" + if runtime is not None: + return runtime + try: + from .local.runtime import LocalRuntime as _LocalRuntime + except ImportError as exc: + raise ImportError( + 'Local mode is unavailable: install the local extra with pip install "hai-agents[local]" ' + "and ensure your hai-agents build ships hai_agents.local." + ) from exc + return _LocalRuntime.ensure_started(spawn_env=spawn_env, **local_kwargs) + class Client(BaseClient): def run_session( @@ -76,6 +98,26 @@ def session(self, id: str) -> SessionHandle: """Wrap an existing session id in a handle.""" return SessionHandle(self, id) + @classmethod + def local( + cls, + *, + runtime: typing.Optional["LocalRuntime"] = None, + spawn_env: typing.Optional[typing.Dict[str, str]] = None, + **local_kwargs: typing.Any, + ) -> "Client": + """A Client targeting a local hai-agent-runtime, starting one if needed. + + ``local_kwargs`` are forwarded to ``LocalRuntime.ensure_started`` (``binary_path``, + ``version``, ``cache_dir``, ``port``, ``download``, ``timeout_s``). The client + authenticates with the runtime's generated local bearer — never the cloud + ``HAI_API_KEY``, which only passes through to the runtime for model-gateway calls. + """ + runtime = _ensure_local_runtime(runtime, spawn_env=spawn_env, **local_kwargs) + client = cls(base_url=runtime.base_url, api_key=runtime.api_key) + client._local_runtime = runtime # keep the manager reachable for lifecycle calls + return client + @property def sessions(self) -> SessionsClient: if self._sessions is None: @@ -133,6 +175,28 @@ def session(self, id: str) -> AsyncSessionHandle: """Wrap an existing session id in a handle.""" return AsyncSessionHandle(self, id) + @classmethod + def local( + cls, + *, + runtime: typing.Optional["LocalRuntime"] = None, + spawn_env: typing.Optional[typing.Dict[str, str]] = None, + **local_kwargs: typing.Any, + ) -> "AsyncClient": + """An AsyncClient targeting a local hai-agent-runtime, starting one if needed. + + ``local_kwargs`` are forwarded to ``LocalRuntime.ensure_started`` (``binary_path``, + ``version``, ``cache_dir``, ``port``, ``download``, ``timeout_s``). The client + authenticates with the runtime's generated local bearer — never the cloud + ``HAI_API_KEY``, which only passes through to the runtime for model-gateway calls. + ``LocalRuntime.ensure_started`` is intentionally synchronous — it runs once at + construction. + """ + runtime = _ensure_local_runtime(runtime, spawn_env=spawn_env, **local_kwargs) + client = cls(base_url=runtime.base_url, api_key=runtime.api_key) + client._local_runtime = runtime # keep the manager reachable for lifecycle calls + return client + @property def sessions(self) -> AsyncSessionsClient: if self._sessions is None: diff --git a/src/hai_agents/local/__init__.py b/src/hai_agents/local/__init__.py new file mode 100644 index 0000000..215e46d --- /dev/null +++ b/src/hai_agents/local/__init__.py @@ -0,0 +1,25 @@ +"""Local-mode runtime management: install/find/start a hai-agent-runtime binary. + +Never imported by the base ``hai_agents`` package; ``Client.local`` pulls it in +lazily so remote-only users pay nothing for it. +""" + +from .errors import ( + BinaryIncompatibleError, + BinaryNotFoundError, + DownloadVerificationError, + LocalRuntimeError, + RuntimeStartTimeoutError, + RuntimeUnhealthyError, +) +from .runtime import LocalRuntime + +__all__ = [ + "BinaryIncompatibleError", + "BinaryNotFoundError", + "DownloadVerificationError", + "LocalRuntime", + "LocalRuntimeError", + "RuntimeStartTimeoutError", + "RuntimeUnhealthyError", +] diff --git a/src/hai_agents/local/errors.py b/src/hai_agents/local/errors.py new file mode 100644 index 0000000..3878565 --- /dev/null +++ b/src/hai_agents/local/errors.py @@ -0,0 +1,27 @@ +"""Error types for hai_agents.local.""" + +from __future__ import annotations + + +class LocalRuntimeError(Exception): + """Base error for local hai-agent-runtime management.""" + + +class BinaryNotFoundError(LocalRuntimeError): + """No runtime binary: no override, nothing on PATH, no managed install, and download disabled.""" + + +class BinaryIncompatibleError(LocalRuntimeError): + """The pinned manifest has no verifiable artifact for this platform or requested version.""" + + +class RuntimeUnhealthyError(LocalRuntimeError): + """The runtime process exited, or /health is not answering with a 200.""" + + +class RuntimeStartTimeoutError(LocalRuntimeError): + """The spawned runtime did not become healthy before the timeout.""" + + +class DownloadVerificationError(LocalRuntimeError): + """A runtime download could not be sha256-verified (mismatch, or no digest to verify against).""" diff --git a/src/hai_agents/local/install.py b/src/hai_agents/local/install.py new file mode 100644 index 0000000..a662c8d --- /dev/null +++ b/src/hai_agents/local/install.py @@ -0,0 +1,184 @@ +"""Verified download and atomic install of the hai-agent-runtime binary. + +Port of holo_desktop.agent_client.runtime_install with the TTY prompt and rich +progress removed: the SDK is a library, so consent is the caller's +``download=True`` and progress is plain logging. +""" + +from __future__ import annotations + +import hashlib +import logging +import os +import pathlib +import shutil +import tempfile +import typing +import zipfile +from urllib.parse import urlsplit + +import httpx + +from .errors import BinaryIncompatibleError, DownloadVerificationError, LocalRuntimeError +from .manifest import ( + BINARY_NAME, + MANIFEST, + PINNED_RUNTIME_VERSION, + PLACEHOLDER_SHA256, + UNIMPLEMENTED_PLATFORMS, + RuntimeArtifact, + platform_key, +) +from .state import resolve_cache_dir + +logger = logging.getLogger(__name__) + +DOWNLOAD_URL_ENV = "HAI_AGENT_RUNTIME_DOWNLOAD_URL" +DOWNLOAD_SHA256_ENV = "HAI_AGENT_RUNTIME_DOWNLOAD_SHA256" +_DOWNLOAD_TIMEOUT = httpx.Timeout(30.0, read=600.0) +# Generous ceiling (the runtime is hundreds of MB); guards against a lying/absent Content-Length. +MAX_DOWNLOAD_BYTES = 1024 * 1024 * 1024 +_LOOPBACK_HOSTS = frozenset({"127.0.0.1", "localhost", "::1"}) + +_PathInput = typing.Union[str, "os.PathLike[str]"] + + +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 LocalRuntimeError(f"refusing insecure hai-agent-runtime download URL (need https): {url}") + + +def bin_dir(version: str, *, cache_dir: typing.Optional[_PathInput] = None) -> pathlib.Path: + return resolve_cache_dir(cache_dir) / "bin" / version + + +def _find_binary(root: pathlib.Path) -> typing.Optional[pathlib.Path]: + 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, *, cache_dir: typing.Optional[_PathInput] = None) -> typing.Optional[pathlib.Path]: + """The managed install's executable for `version`, or None if absent/incomplete.""" + return _find_binary(bin_dir(version, cache_dir=cache_dir)) + + +def pinned_artifact() -> RuntimeArtifact: + """Artifact to install: env override (tests/ops) or the pinned per-platform manifest entry.""" + override_url = os.environ.get(DOWNLOAD_URL_ENV, "").strip() + if override_url: + override_sha = os.environ.get(DOWNLOAD_SHA256_ENV, "").strip() + if not override_sha: + raise DownloadVerificationError( + f"{DOWNLOAD_URL_ENV} is set but {DOWNLOAD_SHA256_ENV} is not; refusing an unverified download" + ) + return RuntimeArtifact(url=override_url, sha256=override_sha) + key = platform_key() + if key in UNIMPLEMENTED_PLATFORMS: + raise BinaryIncompatibleError( + 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 BinaryIncompatibleError(f"no hai-agent-runtime release artifact for platform {key}") + if artifact.sha256 == PLACEHOLDER_SHA256: + raise BinaryIncompatibleError( + 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 install_runtime( + artifact: RuntimeArtifact, *, version: str, cache_dir: typing.Optional[_PathInput] = None +) -> pathlib.Path: + """Download, sha256-verify, and atomically install `artifact` as `version`; returns the executable path.""" + root = resolve_cache_dir(cache_dir) / "bin" + root.mkdir(parents=True, exist_ok=True) + version_dir = root / version + + # Stage on the same filesystem as the final location so os.replace stays atomic. + with tempfile.TemporaryDirectory(dir=root, prefix=".staging-") as staging_str: + staging = pathlib.Path(staging_str) + download_path = staging / "artifact" + actual_sha256 = _download_to(artifact.url, download_path) + if actual_sha256 != artifact.sha256.lower(): + raise DownloadVerificationError( + 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(str(download_path), str(staged_version / BINARY_NAME)) + + binary = _find_binary(staged_version) + if binary is None: + raise LocalRuntimeError( + 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(version, cache_dir=cache_dir) + if existing is not None: + logger.info("hai-agent-runtime %s already installed by a concurrent run", version) + return existing + shutil.rmtree(version_dir, ignore_errors=True) + os.replace(staged_version, version_dir) + + installed = installed_binary(version, cache_dir=cache_dir) + assert installed is not None, "atomic rename just published the staged install" + logger.info("installed hai-agent-runtime %s at %s", version, installed) + return installed + + +def _download_to(url: str, dest: pathlib.Path) -> str: + """Stream `url` into `dest`; returns the sha256 hex digest of the bytes written.""" + _require_secure_url(url) + digest = hashlib.sha256() + written = 0 + logger.info("downloading hai-agent-runtime from %s", url) + 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 LocalRuntimeError(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 LocalRuntimeError( + f"hai-agent-runtime download too large: {total} bytes exceeds {MAX_DOWNLOAD_BYTES}" + ) + with dest.open("wb") as fh: + for chunk in response.iter_bytes(): + written += len(chunk) + if written > MAX_DOWNLOAD_BYTES: + raise LocalRuntimeError( + f"hai-agent-runtime download exceeded {MAX_DOWNLOAD_BYTES} bytes; aborting" + ) + digest.update(chunk) + fh.write(chunk) + except httpx.HTTPError as exc: + raise LocalRuntimeError(f"hai-agent-runtime download failed: {exc} (url: {url})") from exc + logger.info("download complete (%d bytes)", written) + return digest.hexdigest() diff --git a/src/hai_agents/local/manifest.py b/src/hai_agents/local/manifest.py new file mode 100644 index 0000000..e9588f6 --- /dev/null +++ b/src/hai_agents/local/manifest.py @@ -0,0 +1,69 @@ +"""Pinned hai-agent-runtime version and per-platform artifact digests. + +This module is the SDK's single runtime pin: a runtime release bumps +PINNED_RUNTIME_VERSION and MANIFEST here (via the retargeted +release-hai-agent-runtime.yaml pin PR) and nothing else. Artifacts live under an +immutable version-scoped CDN prefix, so an edge can never serve stale bytes. +Cross-ref: eng_plans/14-06-2026-holodesktop-binary-versioning-autoupdate. +""" + +from __future__ import annotations + +import dataclasses +import platform +import sys +import typing + +# SHIP-GATE: repoint to the plan-005 release before merge +PINNED_RUNTIME_VERSION = "0.1.8" +RUNTIME_CDN_BASE = "https://assets.hcompanyprod.fr/hai-agent-runtime" +# Guard value: published manifest entries must never use it (every download would fail verification). +PLACEHOLDER_SHA256 = "0" * 64 +BINARY_NAME = "hai-agent-runtime.exe" if sys.platform == "win32" else "hai-agent-runtime" + + +@dataclasses.dataclass(frozen=True) +class RuntimeArtifact: + 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: typing.Dict[str, RuntimeArtifact] = { + "darwin-arm64": _artifact( + "hai-agent-runtime-darwin-arm64.zip", + # SHIP-GATE: repoint to the plan-005 release before merge + "1aed0055898116732aee031dc4a1235782b2909ee51e0367e2d50bb3be6671c9", + ), + "windows-x86_64": _artifact( + "hai-agent-runtime-windows-x86_64.zip", + # SHIP-GATE: repoint to the plan-005 release before merge + "4e6b2bcd42af2bb6b22197fcde947327497f5c62fd60d48bc9037730d80dc691", + ), +} + +UNIMPLEMENTED_PLATFORMS: typing.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: + """`-` manifest key for the current host, e.g. darwin-arm64.""" + 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}" diff --git a/src/hai_agents/local/process.py b/src/hai_agents/local/process.py new file mode 100644 index 0000000..2cbf88e --- /dev/null +++ b/src/hai_agents/local/process.py @@ -0,0 +1,140 @@ +"""Spawn, health-check, and stop hai-agent-runtime processes (sync, loopback-only).""" + +from __future__ import annotations + +import logging +import os +import pathlib +import signal +import subprocess +import time +import typing + +import httpx + +from .errors import RuntimeStartTimeoutError, RuntimeUnhealthyError + +logger = logging.getLogger(__name__) + +LOOPBACK_HOST = "127.0.0.1" +SPAWN_TIMEOUT_S = 45.0 +HEALTH_POLL_INTERVAL_S = 0.25 +TERM_GRACE_S = 2.0 +LOG_TAIL_CHARS = 4000 + + +def probe_health(base_url: str) -> typing.Optional[typing.Dict[str, typing.Any]]: + """The /health JSON body on a 200 ({} for non-JSON bodies); None when unreachable/unhealthy.""" + try: + response = httpx.get(f"{base_url}/health", timeout=2.0) + except httpx.HTTPError: + return None + if response.status_code != 200: + return None + try: + payload = response.json() + except ValueError: + return {} + return payload if isinstance(payload, dict) else {} + + +def spawn(cmd: typing.List[str], *, env: typing.Dict[str, str], log_path: pathlib.Path) -> subprocess.Popen: + """Start the runtime in its own process group with stderr to `log_path`. + + stderr goes to a file, not a pipe: nobody drains a pipe after spawn, so the + buffer would fill and block. Own process group so we can reap grandchildren + (e.g. desktop helpers) the binary may spawn. + """ + log_path.parent.mkdir(parents=True, exist_ok=True) + logger.info("spawning hai-agent-runtime: %s (stderr -> %s)", " ".join(cmd), log_path) + with log_path.open("wb") as log_file: # child inherits the fd; the parent handle can close right away + return 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), + ) + + +def wait_healthy( + base_url: str, proc: subprocess.Popen, *, timeout_s: float, log_path: pathlib.Path +) -> typing.Dict[str, typing.Any]: + """Poll /health until 200; raises RuntimeUnhealthyError (child exited) or RuntimeStartTimeoutError.""" + deadline = time.monotonic() + timeout_s + while True: + payload = probe_health(base_url) + if payload is not None: + logger.info("hai-agent-runtime ready (pid %d)", proc.pid) + return payload + if proc.poll() is not None: + raise RuntimeUnhealthyError( + f"hai-agent-runtime exited with code {proc.returncode}: {log_tail(log_path)} (full log: {log_path})" + ) + if time.monotonic() >= deadline: + raise RuntimeStartTimeoutError( + f"hai-agent-runtime did not become healthy within {timeout_s:.0f}s (see {log_path})" + ) + time.sleep(HEALTH_POLL_INTERVAL_S) + + +def log_tail(path: pathlib.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 _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_process_group(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 _signal(proc: subprocess.Popen, *, force: bool) -> bool: + """Signal the runtime's whole process tree; False if it was already gone.""" + if os.name == "posix": + return _killpg_posix(proc.pid, signal.SIGKILL if force else signal.SIGTERM) + try: + # Windows has no portable graceful process-group signal. /F is required + # to ensure an executable launched through a .cmd shim cannot outlive it. + subprocess.run(["taskkill", "/F", "/T", "/PID", str(proc.pid)], check=True, capture_output=True) + except (OSError, subprocess.CalledProcessError): + return False + return True + + +def terminate(proc: subprocess.Popen) -> None: + """Graceful stop: SIGTERM the group, wait the grace period, then SIGKILL the group.""" + if proc.poll() is not None: + return + if not _signal(proc, force=False): + return + try: + proc.wait(timeout=TERM_GRACE_S) + return + except subprocess.TimeoutExpired: + pass + if _signal(proc, force=True): + try: + proc.wait(timeout=TERM_GRACE_S) + except subprocess.TimeoutExpired: + logger.warning("hai-agent-runtime (pid %d) did not exit after forced kill", proc.pid) diff --git a/src/hai_agents/local/runtime.py b/src/hai_agents/local/runtime.py new file mode 100644 index 0000000..bacdd79 --- /dev/null +++ b/src/hai_agents/local/runtime.py @@ -0,0 +1,331 @@ +"""SDK-managed local hai-agent-runtime: install/find/start/attach/stop.""" + +from __future__ import annotations + +import logging +import os +import pathlib +import secrets +import shutil +import subprocess +import typing +from urllib.parse import urlsplit + +from .errors import ( + BinaryIncompatibleError, + BinaryNotFoundError, + LocalRuntimeError, + RuntimeUnhealthyError, +) +from .install import DOWNLOAD_SHA256_ENV, DOWNLOAD_URL_ENV, install_runtime, installed_binary, pinned_artifact +from .manifest import PINNED_RUNTIME_VERSION +from .process import ( + LOOPBACK_HOST, + SPAWN_TIMEOUT_S, + kill_process_group, + probe_health, + spawn, + terminate, + wait_healthy, +) +from .state import ( + DEFAULT_PORT, + pid_file_path, + read_pid, + read_state_file, + resolve_cache_dir, + runtime_log_path, + token_file_path, + unlink_if_content, + write_owner_only, +) + +logger = logging.getLogger(__name__) + +BINARY_PATH_ENV = "HAI_AGENT_LOCAL_BINARY_PATH" +BINARY_VERSION_ENV = "HAI_AGENT_LOCAL_BINARY_VERSION" +BASE_URL_ENV = "HAI_AGENT_LOCAL_BASE_URL" +PORT_ENV = "HAI_AGENT_RUNTIME_PORT" +AUTH_TOKEN_ENV = "HAI_AGENT_RUNTIME_API_TOKEN" + +_PathInput = typing.Union[str, "os.PathLike[str]"] + + +def _warn_on_version_skew(version: typing.Optional[str]) -> None: + """Warn (not fail) on a client/runtime version skew; PATH/override dev binaries stay usable.""" + if version is not None and version != PINNED_RUNTIME_VERSION: + logger.warning( + "hai-agent-runtime version skew: server reports %s, this SDK pins %s; " + "wire-contract drift may cause subtle failures", + version, + PINNED_RUNTIME_VERSION, + ) + + +def _port_of(base_url: str) -> int: + return urlsplit(base_url).port or DEFAULT_PORT + + +class LocalRuntime: + """A reachable local agent runtime: where it is, how to authenticate, and (if ours) the process.""" + + def __init__( + self, + *, + base_url: str, + api_key: str, + pid: typing.Optional[int], + version: typing.Optional[str], + log_path: typing.Optional[pathlib.Path], + owned: bool, + cache_dir: pathlib.Path, + port: int, + proc: typing.Optional[subprocess.Popen] = None, + token_file: typing.Optional[pathlib.Path] = None, + pid_file: typing.Optional[pathlib.Path] = None, + ) -> None: + self.base_url = base_url + self.api_key = api_key + self.pid = pid + self.version = version + self.log_path = log_path + self.owned = owned + self._cache_dir = cache_dir + self._port = port + self._proc = proc + # Set only on the spawner that published generated state; attachers never own the files. + self._token_file = token_file + self._pid_file = pid_file + + @classmethod + def ensure_started( + cls, + *, + binary_path: typing.Optional[_PathInput] = None, + version: typing.Optional[str] = None, + cache_dir: typing.Optional[_PathInput] = None, + port: typing.Optional[int] = None, + spawn_env: typing.Optional[typing.Dict[str, str]] = None, + inherit_env: bool = True, + download: bool = True, + timeout_s: float = SPAWN_TIMEOUT_S, + ) -> "LocalRuntime": + """Return a reachable LocalRuntime, attaching to an existing one or spawning the binary.""" + resolved_cache = resolve_cache_dir(cache_dir) + base_override = os.environ.get(BASE_URL_ENV, "").strip() + if base_override: + attached = cls._attach(base_url=base_override.rstrip("/"), cache_dir=resolved_cache) + if attached is None: + raise RuntimeUnhealthyError( + f"{BASE_URL_ENV} is set to {base_override} but /health is not answering there" + ) + return attached + + resolved_port = port if port is not None else int(os.environ.get(PORT_ENV, "").strip() or DEFAULT_PORT) + base_url = f"http://{LOOPBACK_HOST}:{resolved_port}" + attached = cls._attach(base_url=base_url, cache_dir=resolved_cache) + if attached is not None: + return attached + + cmd = cls._resolve_command( + binary_path=binary_path, version=version, cache_dir=resolved_cache, download=download + ) + token = secrets.token_urlsafe(32) + # Publish the token before the health wait so a client racing our probe can authenticate. + token_file = write_owner_only(token_file_path(resolved_port, cache_dir=resolved_cache), token) + log_path = runtime_log_path(resolved_port, cache_dir=resolved_cache) + proc = spawn( + cmd, + env=cls._child_env(port=resolved_port, token=token, spawn_env=spawn_env, inherit_env=inherit_env), + log_path=log_path, + ) + try: + payload = wait_healthy(base_url, proc, timeout_s=timeout_s, log_path=log_path) + except BaseException: + # Covers KeyboardInterrupt mid-spawn: never leak the child or its token file. + terminate(proc) + unlink_if_content(token_file, token) + raise + pid_file = write_owner_only(pid_file_path(resolved_port, cache_dir=resolved_cache), str(proc.pid)) + reported = payload.get("version") + reported_version = reported if isinstance(reported, str) else None + _warn_on_version_skew(reported_version) + return cls( + base_url=base_url, + api_key=token, + pid=proc.pid, + version=reported_version, + log_path=log_path, + owned=True, + cache_dir=resolved_cache, + port=resolved_port, + proc=proc, + token_file=token_file, + pid_file=pid_file, + ) + + @classmethod + def attach( + cls, *, port: typing.Optional[int] = None, cache_dir: typing.Optional[_PathInput] = None + ) -> typing.Optional["LocalRuntime"]: + """A LocalRuntime for an already-running local runtime, or None when nothing answers /health.""" + resolved_cache = resolve_cache_dir(cache_dir) + base_override = os.environ.get(BASE_URL_ENV, "").strip() + if base_override: + return cls._attach(base_url=base_override.rstrip("/"), cache_dir=resolved_cache) + resolved_port = port if port is not None else int(os.environ.get(PORT_ENV, "").strip() or DEFAULT_PORT) + return cls._attach(base_url=f"http://{LOOPBACK_HOST}:{resolved_port}", cache_dir=resolved_cache) + + @classmethod + def _attach(cls, *, base_url: str, cache_dir: pathlib.Path) -> typing.Optional["LocalRuntime"]: + port = _port_of(base_url) + payload = probe_health(base_url) + if payload is None: + return None + token = os.environ.get(AUTH_TOKEN_ENV, "").strip() or read_state_file( + token_file_path(port, cache_dir=cache_dir) + ) + if not token: + raise LocalRuntimeError( + f"an agent runtime is answering at {base_url} but no credentials were found: " + f"{AUTH_TOKEN_ENV} is not set and {token_file_path(port, cache_dir=cache_dir)} does not exist, " + "so this client cannot authenticate. Export the token or stop that runtime." + ) + reported = payload.get("version") + reported_version = reported if isinstance(reported, str) else None + _warn_on_version_skew(reported_version) + log_path = runtime_log_path(port, cache_dir=cache_dir) + return cls( + base_url=base_url, + api_key=token, + pid=read_pid(port, cache_dir=cache_dir), + version=reported_version, + log_path=log_path if log_path.exists() else None, + owned=False, + cache_dir=cache_dir, + port=port, + ) + + @staticmethod + def _child_env( + *, + port: int, + token: str, + spawn_env: typing.Optional[typing.Dict[str, str]], + inherit_env: bool, + ) -> typing.Dict[str, str]: + """Child env: inherited-plus-overlay by default, caller-verbatim with inherit_env=False. + + Inheriting os.environ passes the model-gateway HAI_API_KEY / HAI_BASE_URL through to the + binary (without them local sessions cannot run inference) and forwards caller flags such as + HAI_AGENT_RUNTIME_MODEL/FAKE/FAST/RUNS_DIR. inherit_env=False takes spawn_env as the + complete base environment instead — for callers that must *remove* inherited keys, which an + overlay cannot express (HoloDesktop strips HAI_API_KEY for self-hosted base URLs). The + generated local bearer and the cloud HAI_API_KEY are different credentials: the token below + is the only local bearer, and the cloud key is never used to authenticate against the local + runtime. Port and token are set last in both modes so caller input never clobbers them. + """ + env = {**os.environ, **(spawn_env or {})} if inherit_env else dict(spawn_env or {}) + env[PORT_ENV] = str(port) + env[AUTH_TOKEN_ENV] = token + return env + + @staticmethod + def _resolve_command( + *, + binary_path: typing.Optional[_PathInput], + version: typing.Optional[str], + cache_dir: pathlib.Path, + download: bool, + ) -> typing.List[str]: + """Explicit path > HAI_AGENT_LOCAL_BINARY_PATH > PATH > managed install > verified download.""" + explicit = str(binary_path) if binary_path is not None else os.environ.get(BINARY_PATH_ENV, "").strip() + if explicit: + candidate = pathlib.Path(explicit).expanduser() + if not candidate.is_file(): + raise BinaryNotFoundError(f"binary_path / {BINARY_PATH_ENV} points at a missing file: {candidate}") + return [str(candidate)] + found = shutil.which("hai-agent-runtime") + if found: + logger.info("resolved hai-agent-runtime from PATH: %s", found) + return [found] + pinned = version or os.environ.get(BINARY_VERSION_ENV, "").strip() or PINNED_RUNTIME_VERSION + managed = installed_binary(pinned, cache_dir=cache_dir) + if managed is not None: + logger.info("resolved hai-agent-runtime from managed install v%s: %s", pinned, managed) + return [str(managed)] + if not download: + raise BinaryNotFoundError( + "hai-agent-runtime not found: not on PATH, no managed install under " + f"{cache_dir / 'bin'}, and download=False. Pass binary_path=, set {BINARY_PATH_ENV}, " + "or allow download=True." + ) + if pinned != PINNED_RUNTIME_VERSION and not os.environ.get(DOWNLOAD_URL_ENV, "").strip(): + raise BinaryIncompatibleError( + f"cannot download hai-agent-runtime {pinned}: this SDK pins sha256 digests for " + f"{PINNED_RUNTIME_VERSION} only. Install {pinned} yourself, or set " + f"{DOWNLOAD_URL_ENV} + {DOWNLOAD_SHA256_ENV} to a trusted build." + ) + installed = install_runtime(pinned_artifact(), version=pinned, cache_dir=cache_dir) + logger.info("resolved hai-agent-runtime from fresh download v%s: %s", pinned, installed) + return [str(installed)] + + def health(self) -> typing.Dict[str, typing.Any]: + """The /health JSON body; raises RuntimeUnhealthyError when the runtime is not answering.""" + payload = probe_health(self.base_url) + if payload is None: + raise RuntimeUnhealthyError(f"hai-agent-runtime at {self.base_url} is not answering /health") + return payload + + def shutdown(self) -> None: + """Gracefully stop the runtime this LocalRuntime spawned (SIGTERM group, grace, SIGKILL group).""" + if not self.owned or self._proc is None: + raise LocalRuntimeError( + "shutdown() only stops runtimes this LocalRuntime spawned; " + "use force_kill() to stop a runtime you attached to" + ) + terminate(self._proc) + self._cleanup_state_files() + + # Statuses that mean the runtime still holds live session state a shutdown would destroy. + # ("idle" sessions await user input but keep runtime state.) + ACTIVE_SESSION_STATUSES: typing.ClassVar[typing.Tuple[str, ...]] = ( + "pending", + "running", + "paused", + "idle", + "awaiting_tool_results", + ) + + def shutdown_if_idle(self) -> bool: + """Stop the owned runtime only when it hosts no active sessions; True when it was stopped.""" + from ..client import Client # runtime-time import: client.py imports this module lazily too + + client = Client(base_url=self.base_url, api_key=self.api_key) + page = client.sessions.list_sessions(status=list(self.ACTIVE_SESSION_STATUSES), size=1) + if page.items: + return False + self.shutdown() + return True + + def force_kill(self) -> None: + """SIGKILL the runtime's process group via the persisted pid; works from any process. + + This is what backs ``holo stop --force``: it needs only the pid file, not the + Popen handle, so an attached manager (or a brand-new process) can use it. + """ + pid = self.pid if self.pid is not None else read_pid(self._port, cache_dir=self._cache_dir) + if pid is None: + raise LocalRuntimeError(f"no pid recorded for the runtime on port {self._port}; nothing to kill") + if not kill_process_group(pid): + logger.info("hai-agent-runtime pid %d was already gone", pid) + pid_file_path(self._port, cache_dir=self._cache_dir).unlink(missing_ok=True) + unlink_if_content(token_file_path(self._port, cache_dir=self._cache_dir), self.api_key) + + def _cleanup_state_files(self) -> None: + if self._token_file is not None: + unlink_if_content(self._token_file, self.api_key) + self._token_file = None + if self._pid_file is not None: + self._pid_file.unlink(missing_ok=True) + self._pid_file = None diff --git a/src/hai_agents/local/state.py b/src/hai_agents/local/state.py new file mode 100644 index 0000000..1af0150 --- /dev/null +++ b/src/hai_agents/local/state.py @@ -0,0 +1,90 @@ +"""Owner-only on-disk discovery state for locally spawned hai-agent-runtime processes. + +A spawner persists the generated bearer token and the runtime pid under the SDK +cache dir so a second process can attach (token) or force-kill (pid) without any +IPC. Both files drive privileged actions, so they are 0600 from the first byte +and refuse pre-planted symlinks (port of holo_desktop launcher._write_owner_only). +""" + +from __future__ import annotations + +import contextlib +import os +import pathlib +import typing + +CACHE_DIR_ENV = "HAI_AGENT_LOCAL_CACHE_DIR" +DEFAULT_CACHE_DIR = pathlib.Path.home() / ".hai" / "agent-runtime" +# HoloDesktop's AGENT_API_DEFAULT_PORT: the shared well-known local runtime port. +DEFAULT_PORT = 18795 + +_PathInput = typing.Union[str, "os.PathLike[str]"] + + +def resolve_cache_dir(cache_dir: typing.Optional[_PathInput] = None) -> pathlib.Path: + """Explicit argument > HAI_AGENT_LOCAL_CACHE_DIR > ~/.hai/agent-runtime.""" + if cache_dir is not None: + return pathlib.Path(cache_dir).expanduser() + override = os.environ.get(CACHE_DIR_ENV, "").strip() + if override: + return pathlib.Path(override).expanduser() + return DEFAULT_CACHE_DIR + + +def state_dir(cache_dir: typing.Optional[_PathInput] = None) -> pathlib.Path: + return resolve_cache_dir(cache_dir) / "state" + + +def token_file_path(port: int, *, cache_dir: typing.Optional[_PathInput] = None) -> pathlib.Path: + """Where a spawner publishes its generated bearer token for other local clients.""" + return state_dir(cache_dir) / f"agent-token-{port}" + + +def pid_file_path(port: int, *, cache_dir: typing.Optional[_PathInput] = None) -> pathlib.Path: + """Where a spawner publishes the runtime pid so force_kill() works from another process.""" + return state_dir(cache_dir) / f"agent-pid-{port}" + + +def runtime_log_path(port: int, *, cache_dir: typing.Optional[_PathInput] = None) -> pathlib.Path: + """Where the runtime spawned on `port` writes its stderr.""" + return resolve_cache_dir(cache_dir) / "logs" / f"hai-agent-runtime-{port}.log" + + +def write_owner_only(path: pathlib.Path, content: str) -> pathlib.Path: + """Write `content` to `path` owner-only (0600), refusing a pre-existing symlink at the path.""" + path.parent.mkdir(parents=True, exist_ok=True) + with contextlib.suppress(OSError): + path.parent.chmod(0o700) # owner-only state dir; 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 read_state_file(path: pathlib.Path) -> typing.Optional[str]: + """The stripped file contents, or None when missing/unreadable/empty.""" + try: + return path.read_text(encoding="utf-8").strip() or None + except OSError: + return None + + +def read_pid(port: int, *, cache_dir: typing.Optional[_PathInput] = None) -> typing.Optional[int]: + """The persisted runtime pid for `port`, or None when absent or malformed.""" + raw = read_state_file(pid_file_path(port, cache_dir=cache_dir)) + if raw is None: + return None + try: + return int(raw) + except ValueError: + return None + + +def unlink_if_content(path: pathlib.Path, content: str) -> None: + """Remove our state file, but never one a concurrent spawner already replaced.""" + with contextlib.suppress(OSError): + if path.read_text(encoding="utf-8").strip() == content: + path.unlink(missing_ok=True) diff --git a/tests/conftest.py b/tests/conftest.py index 2cbc2eb..927511e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,7 +1,73 @@ +"""Shared offline fixtures: an executable fake hai-agent-runtime.""" + +from __future__ import annotations + +import pathlib +import socket + import pytest from hai_agents_local.config import AUTO_BRIDGE_ENV_VAR +FAKE_RUNTIME_SOURCE = '''#!/usr/bin/env python3 +"""Stand-in hai-agent-runtime: /health plus a bearer-checked empty sessions list. + +Reads the same spawn env contract as the real binary: HAI_AGENT_RUNTIME_PORT and +HAI_AGENT_RUNTIME_API_TOKEN (crashes loudly if the SDK failed to pass them). +""" +import json +import os +import sys +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +PORT = int(os.environ["HAI_AGENT_RUNTIME_PORT"]) +TOKEN = os.environ["HAI_AGENT_RUNTIME_API_TOKEN"] + + +class Handler(BaseHTTPRequestHandler): + def do_GET(self): + if self.path == "/health": + # env echo (HAI_-prefixed only) lets tests assert what the SDK spawn passed us. + hai_env = {k: v for k, v in os.environ.items() if k.startswith("HAI_")} + self._reply(200, {"status": "ok", "version": "0.0.0-fake", "env": hai_env}) + elif self.path.startswith("/api/v2/sessions"): + if self.headers.get("Authorization") != "Bearer " + TOKEN: + self._reply(401, {"detail": "invalid token"}) + else: + self._reply(200, {"items": [], "total": 0, "page": 1}) + else: + self._reply(404, {"detail": "not found"}) + + def _reply(self, status, payload): + body = json.dumps(payload).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, fmt, *args): + print(fmt % args, file=sys.stderr) + + +print("fake runtime listening on", PORT, file=sys.stderr) +ThreadingHTTPServer(("127.0.0.1", PORT), Handler).serve_forever() +''' + + +@pytest.fixture +def fake_binary(tmp_path: pathlib.Path) -> pathlib.Path: + path = tmp_path / "hai-agent-runtime" + path.write_text(FAKE_RUNTIME_SOURCE) + path.chmod(0o755) + return path + + +def free_port() -> int: + with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + return sock.getsockname()[1] + @pytest.fixture(autouse=True) def _no_auto_bridges(monkeypatch): diff --git a/tests/test_client_local.py b/tests/test_client_local.py new file mode 100644 index 0000000..e9eeacd --- /dev/null +++ b/tests/test_client_local.py @@ -0,0 +1,66 @@ +"""Client.local wiring: base_url/api_key come from the LocalRuntime, imported lazily.""" + +from __future__ import annotations + +import os +import sys + +import pytest + +from hai_agents import AsyncClient, Client +from tests.conftest import free_port + +pytestmark = pytest.mark.skipif( + os.name != "posix", reason="local-mode tests exercise POSIX process groups and file modes" +) + + +class _StubRuntime: + base_url = "http://127.0.0.1:18795" + api_key = "local-token-abc" + + +def test_client_local_wires_runtime_url_and_bearer() -> None: + client = Client.local(runtime=_StubRuntime()) + + assert client._client_wrapper.get_base_url() == "http://127.0.0.1:18795" + assert client._client_wrapper.get_headers()["Authorization"] == "Bearer local-token-abc" + + +def test_client_local_never_uses_the_cloud_env_key_as_bearer(monkeypatch) -> None: + monkeypatch.setenv("HAI_API_KEY", "hk-cloud") + + client = Client.local(runtime=_StubRuntime()) + + assert client._client_wrapper.get_headers()["Authorization"] == "Bearer local-token-abc" + + +def test_async_client_local_wires_the_same_runtime() -> None: + client = AsyncClient.local(runtime=_StubRuntime()) + + assert client._client_wrapper.get_base_url() == "http://127.0.0.1:18795" + assert client._client_wrapper.get_headers()["Authorization"] == "Bearer local-token-abc" + + +def test_client_local_raises_a_helpful_import_error(monkeypatch) -> None: + # Simulate a broken/missing hai_agents.local: a None entry makes `import` raise ImportError. + for name in [m for m in sys.modules if m == "hai_agents.local" or m.startswith("hai_agents.local.")]: + monkeypatch.delitem(sys.modules, name) + monkeypatch.setitem(sys.modules, "hai_agents.local", None) + + with pytest.raises(ImportError, match=r'pip install "hai-agents\[local\]"'): + Client.local() + + +def test_client_local_end_to_end_against_fake_runtime(fake_binary, tmp_path) -> None: + port = free_port() + client = Client.local(binary_path=fake_binary, cache_dir=tmp_path / "cache", port=port, timeout_s=20.0) + runtime = client._local_runtime + try: + # The generated sessions client works unchanged against the local endpoint, + # authenticated with the generated bearer (the fake 401s any other token). + page = client.sessions.list_sessions(size=1) + assert page.items == [] + assert runtime.owned is True + finally: + runtime.shutdown() diff --git a/tests/test_local_runtime.py b/tests/test_local_runtime.py new file mode 100644 index 0000000..8f34de5 --- /dev/null +++ b/tests/test_local_runtime.py @@ -0,0 +1,386 @@ +"""hai_agents.local unit tests: state files, install, spawn lifecycle. + +Offline by construction: a fake zip over a loopback http.server stands in for +the CDN, and a tiny Python HTTP script stands in for the runtime binary. +""" + +from __future__ import annotations + +import os +import stat + +import pytest + +pytestmark = pytest.mark.skipif( + os.name != "posix", reason="local-mode tests exercise POSIX process groups and file modes" +) + + +def test_all_local_errors_share_one_base() -> None: + from hai_agents.local import errors + + for name in ( + "BinaryNotFoundError", + "BinaryIncompatibleError", + "RuntimeUnhealthyError", + "RuntimeStartTimeoutError", + "DownloadVerificationError", + ): + assert issubclass(getattr(errors, name), errors.LocalRuntimeError), name + + +def test_state_files_are_owner_only_and_round_trip(tmp_path) -> None: + from hai_agents.local import state + + token_path = state.token_file_path(18795, cache_dir=tmp_path) + state.write_owner_only(token_path, "tok-1") + + assert token_path == tmp_path / "state" / "agent-token-18795" + assert stat.S_IMODE(token_path.stat().st_mode) == 0o600 + assert state.read_state_file(token_path) == "tok-1" + + pid_path = state.pid_file_path(18795, cache_dir=tmp_path) + state.write_owner_only(pid_path, "4242") + assert state.read_pid(18795, cache_dir=tmp_path) == 4242 + + +def test_write_owner_only_refuses_a_planted_symlink(tmp_path) -> None: + from hai_agents.local import state + + victim = tmp_path / "victim" + victim.write_text("keep") + link = tmp_path / "state" / "agent-token-1" + link.parent.mkdir(parents=True) + link.symlink_to(victim) + + with pytest.raises(OSError): + state.write_owner_only(link, "tok") + assert victim.read_text() == "keep" + + +def test_cache_dir_resolution_order(tmp_path, monkeypatch) -> None: + from hai_agents.local import state + + monkeypatch.setenv("HAI_AGENT_LOCAL_CACHE_DIR", str(tmp_path / "from-env")) + assert state.resolve_cache_dir() == tmp_path / "from-env" + # An explicit argument beats the env override. + assert state.resolve_cache_dir(tmp_path / "explicit") == tmp_path / "explicit" + monkeypatch.delenv("HAI_AGENT_LOCAL_CACHE_DIR") + assert state.resolve_cache_dir() == state.DEFAULT_CACHE_DIR + + +def test_unlink_if_content_never_removes_a_replaced_token(tmp_path) -> None: + from hai_agents.local import state + + path = state.token_file_path(1, cache_dir=tmp_path) + state.write_owner_only(path, "mine") + + state.unlink_if_content(path, "not-mine") + assert path.exists() + state.unlink_if_content(path, "mine") + assert not path.exists() + + +import contextlib +import functools +import hashlib +import http.server +import pathlib +import threading +import zipfile + + +@contextlib.contextmanager +def _serving(directory: pathlib.Path): + """Serve `directory` on an ephemeral loopback port (http-on-loopback is allowed by the URL policy).""" + handler = functools.partial(http.server.SimpleHTTPRequestHandler, directory=str(directory)) + server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), handler) + 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) + + +def _fake_zip(tmp_path: pathlib.Path) -> tuple[pathlib.Path, str]: + payload = tmp_path / "hai-agent-runtime" + payload.write_text("#!/bin/sh\nexit 0\n") + archive = tmp_path / "artifact.zip" + with zipfile.ZipFile(archive, "w") as zf: + zf.write(payload, arcname="hai-agent-runtime") + return archive, hashlib.sha256(archive.read_bytes()).hexdigest() + + +def test_env_override_download_verifies_and_installs_atomically(tmp_path, monkeypatch) -> None: + from hai_agents.local import install + + _, digest = _fake_zip(tmp_path) + cache = tmp_path / "cache" + with _serving(tmp_path) as base: + monkeypatch.setenv(install.DOWNLOAD_URL_ENV, f"{base}/artifact.zip") + monkeypatch.setenv(install.DOWNLOAD_SHA256_ENV, digest) + artifact = install.pinned_artifact() + binary = install.install_runtime(artifact, version="0.0.0-test", cache_dir=cache) + + assert binary == cache / "bin" / "0.0.0-test" / "hai-agent-runtime" + assert os.access(binary, os.X_OK) # zipfile drops the exec bit; install must restore it + assert install.installed_binary("0.0.0-test", cache_dir=cache) == binary + + +def test_sha256_mismatch_raises_and_installs_nothing(tmp_path) -> None: + from hai_agents.local import install + from hai_agents.local.errors import DownloadVerificationError + + _fake_zip(tmp_path) + cache = tmp_path / "cache" + with _serving(tmp_path) as base: + artifact = install.RuntimeArtifact(url=f"{base}/artifact.zip", sha256="ab" * 32) + with pytest.raises(DownloadVerificationError): + install.install_runtime(artifact, version="0.0.0-test", cache_dir=cache) + + assert install.installed_binary("0.0.0-test", cache_dir=cache) is None + + +def test_download_url_env_without_sha_is_refused(monkeypatch) -> None: + from hai_agents.local import install + from hai_agents.local.errors import DownloadVerificationError + + monkeypatch.setenv(install.DOWNLOAD_URL_ENV, "https://example.test/runtime.zip") + monkeypatch.delenv(install.DOWNLOAD_SHA256_ENV, raising=False) + with pytest.raises(DownloadVerificationError, match="unverified"): + install.pinned_artifact() + + +def test_non_loopback_plain_http_is_refused(tmp_path) -> None: + from hai_agents.local import install + from hai_agents.local.errors import LocalRuntimeError + + artifact = install.RuntimeArtifact(url="http://evil.example/runtime.zip", sha256="00" * 32) + with pytest.raises(LocalRuntimeError, match="https"): + install.install_runtime(artifact, version="0.0.0-test", cache_dir=tmp_path / "cache") + + +def test_manifest_pins_the_cdn_and_never_a_placeholder_digest() -> None: + from hai_agents.local import manifest + + assert manifest.RUNTIME_CDN_BASE == "https://assets.hcompanyprod.fr/hai-agent-runtime" + for key, artifact in manifest.MANIFEST.items(): + assert artifact.url.startswith(f"{manifest.RUNTIME_CDN_BASE}/{manifest.PINNED_RUNTIME_VERSION}/"), key + assert artifact.sha256 != manifest.PLACEHOLDER_SHA256, key + assert len(artifact.sha256) == 64, key + + +from tests.conftest import free_port + + +def test_ensure_started_spawns_health_checks_and_shuts_down(fake_binary, tmp_path) -> None: + from hai_agents.local import state + from hai_agents.local.process import probe_health + from hai_agents.local.runtime import LocalRuntime + + cache = tmp_path / "cache" + port = free_port() + runtime = LocalRuntime.ensure_started(binary_path=fake_binary, cache_dir=cache, port=port, timeout_s=20.0) + token_path = state.token_file_path(port, cache_dir=cache) + pid_path = state.pid_file_path(port, cache_dir=cache) + try: + assert runtime.owned is True + assert runtime.base_url == f"http://127.0.0.1:{port}" + assert runtime.pid is not None + assert runtime.version == "0.0.0-fake" + assert runtime.health()["status"] == "ok" + # Discovery state is published, owner-only. + assert state.read_state_file(token_path) == runtime.api_key + assert state.read_pid(port, cache_dir=cache) == runtime.pid + for path in (token_path, pid_path): + assert stat.S_IMODE(path.stat().st_mode) == 0o600 + # stderr goes to a log file the caller can read (TCC triage needs this). + assert runtime.log_path is not None + assert runtime.log_path.exists() + finally: + runtime.shutdown() + + assert not token_path.exists() + assert not pid_path.exists() + assert probe_health(runtime.base_url) is None + + +def test_local_bearer_is_generated_never_the_cloud_key(fake_binary, tmp_path, monkeypatch) -> None: + from hai_agents.local.runtime import LocalRuntime + + monkeypatch.setenv("HAI_API_KEY", "hk-cloud-key") + runtime = LocalRuntime.ensure_started( + binary_path=fake_binary, cache_dir=tmp_path / "cache", port=free_port(), timeout_s=20.0 + ) + try: + assert runtime.api_key != "hk-cloud-key" + assert len(runtime.api_key) >= 32 # secrets.token_urlsafe(32) output + finally: + runtime.shutdown() + + +def test_spawn_env_forwards_caller_flags(fake_binary, tmp_path) -> None: + from hai_agents.local.runtime import LocalRuntime + + port = free_port() + runtime = LocalRuntime.ensure_started( + binary_path=fake_binary, + cache_dir=tmp_path / "cache", + port=port, + spawn_env={"HAI_AGENT_RUNTIME_FAKE": "1", "HAI_AGENT_RUNTIME_FAST": "1"}, + timeout_s=20.0, + ) + # The fake starts iff PORT and TOKEN arrived; spawn_env must not clobber them. + try: + assert runtime.health()["status"] == "ok" + finally: + runtime.shutdown() + + +def test_inherit_env_false_uses_spawn_env_verbatim(fake_binary, tmp_path, monkeypatch) -> None: + """inherit_env=False makes spawn_env the complete child environment. + + This is the plan-003 contract: HoloDesktop builds the full child env itself so it can + *remove* HAI_API_KEY for self-hosted base URLs — a removal an overlay cannot express. + The fake runtime echoes its environment in /health, so we can assert the inherited + marker never reached the child while PORT/TOKEN still did. + """ + from hai_agents.local.runtime import LocalRuntime + + monkeypatch.setenv("HAI_LOCAL_TEST_MARKER", "must-not-inherit") + verbatim_env = {"PATH": os.environ["PATH"], "HAI_AGENT_RUNTIME_FAKE": "1"} + runtime = LocalRuntime.ensure_started( + binary_path=fake_binary, + cache_dir=tmp_path / "cache", + port=free_port(), + spawn_env=verbatim_env, + inherit_env=False, + timeout_s=20.0, + ) + try: + child_env = runtime.health()["env"] + assert "HAI_LOCAL_TEST_MARKER" not in child_env + assert child_env["HAI_AGENT_RUNTIME_FAKE"] == "1" + finally: + runtime.shutdown() + + +def test_ensure_started_attaches_to_an_already_running_runtime(fake_binary, tmp_path) -> None: + from hai_agents.local.runtime import LocalRuntime + + cache = tmp_path / "cache" + port = free_port() + first = LocalRuntime.ensure_started(binary_path=fake_binary, cache_dir=cache, port=port, timeout_s=20.0) + try: + second = LocalRuntime.ensure_started(binary_path=fake_binary, cache_dir=cache, port=port, timeout_s=20.0) + assert second.owned is False + assert second.api_key == first.api_key # read from the published token file + assert second.pid == first.pid + finally: + first.shutdown() + + +def test_never_healthy_binary_times_out_and_cleans_up(tmp_path) -> None: + from hai_agents.local import state + from hai_agents.local.errors import RuntimeStartTimeoutError + from hai_agents.local.runtime import LocalRuntime + + stuck = tmp_path / "hai-agent-runtime" + stuck.write_text("#!/usr/bin/env python3\nimport time\ntime.sleep(600)\n") + stuck.chmod(0o755) + cache = tmp_path / "cache" + port = free_port() + + with pytest.raises(RuntimeStartTimeoutError): + LocalRuntime.ensure_started(binary_path=stuck, cache_dir=cache, port=port, timeout_s=1.5) + + # The failed spawn must not leak its token file (and the child was reaped). + assert state.read_state_file(state.token_file_path(port, cache_dir=cache)) is None + + +def test_crashing_binary_surfaces_its_stderr_tail(tmp_path) -> None: + from hai_agents.local.errors import RuntimeUnhealthyError + from hai_agents.local.runtime import LocalRuntime + + crash = tmp_path / "hai-agent-runtime" + crash.write_text('#!/usr/bin/env python3\nimport sys\nprint("boom: cannot start", file=sys.stderr)\nsys.exit(3)\n') + crash.chmod(0o755) + + with pytest.raises(RuntimeUnhealthyError, match="boom: cannot start"): + LocalRuntime.ensure_started( + binary_path=crash, cache_dir=tmp_path / "cache", port=free_port(), timeout_s=20.0 + ) + + +def test_missing_binary_path_raises_binary_not_found(tmp_path) -> None: + from hai_agents.local.errors import BinaryNotFoundError + from hai_agents.local.runtime import LocalRuntime + + with pytest.raises(BinaryNotFoundError): + LocalRuntime.ensure_started( + binary_path=tmp_path / "does-not-exist", cache_dir=tmp_path / "cache", port=free_port() + ) + + +import time + + +def test_attach_returns_none_when_nothing_runs(tmp_path) -> None: + from hai_agents.local.runtime import LocalRuntime + + assert LocalRuntime.attach(port=free_port(), cache_dir=tmp_path) is None + + +def test_attach_finds_a_runtime_started_by_another_manager(fake_binary, tmp_path) -> None: + from hai_agents.local.runtime import LocalRuntime + + cache = tmp_path / "cache" + port = free_port() + owner = LocalRuntime.ensure_started(binary_path=fake_binary, cache_dir=cache, port=port, timeout_s=20.0) + try: + attached = LocalRuntime.attach(port=port, cache_dir=cache) + assert attached is not None + assert attached.owned is False + assert attached.api_key == owner.api_key + assert attached.pid == owner.pid + assert attached.health()["status"] == "ok" + with pytest.raises(Exception, match="force_kill"): + attached.shutdown() # attachers must not gracefully stop what they do not own + finally: + owner.shutdown() + + +def test_shutdown_if_idle_stops_a_runtime_with_no_active_sessions(fake_binary, tmp_path) -> None: + from hai_agents.local.process import probe_health + from hai_agents.local.runtime import LocalRuntime + + runtime = LocalRuntime.ensure_started( + binary_path=fake_binary, cache_dir=tmp_path / "cache", port=free_port(), timeout_s=20.0 + ) + + assert runtime.shutdown_if_idle() is True # fake reports zero sessions + assert probe_health(runtime.base_url) is None + + +def test_force_kill_via_persisted_pid_from_an_attached_manager(fake_binary, tmp_path) -> None: + from hai_agents.local import state + from hai_agents.local.process import probe_health + from hai_agents.local.runtime import LocalRuntime + + cache = tmp_path / "cache" + port = free_port() + owner = LocalRuntime.ensure_started(binary_path=fake_binary, cache_dir=cache, port=port, timeout_s=20.0) + attached = LocalRuntime.attach(port=port, cache_dir=cache) + assert attached is not None + + attached.force_kill() + + owner._proc.wait(timeout=10) # reap; SIGKILL hit the whole process group + deadline = time.monotonic() + 10 + while probe_health(owner.base_url) is not None and time.monotonic() < deadline: + time.sleep(0.1) + assert probe_health(owner.base_url) is None + assert not state.pid_file_path(port, cache_dir=cache).exists() + assert state.read_state_file(state.token_file_path(port, cache_dir=cache)) is None diff --git a/tests/test_smoke.py b/tests/test_smoke.py index 1ae56e5..b100621 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -17,3 +17,16 @@ def test_core_model_round_trips() -> None: assert status.status == "completed" assert status.steps == 3 assert status.model_dump(exclude_none=True)["status"] == "completed" + + +def test_base_import_never_loads_local_mode() -> None: + """Remote-only users must not pay for process/download machinery at import.""" + import subprocess + import sys + + code = ( + "import sys, hai_agents; " + "loaded = sorted(m for m in sys.modules if m.startswith('hai_agents.local')); " + "assert not loaded, loaded" + ) + subprocess.run([sys.executable, "-c", code], check=True) diff --git a/uv.lock b/uv.lock index 88c9b7d..4ea23d2 100644 --- a/uv.lock +++ b/uv.lock @@ -193,7 +193,7 @@ requires-dist = [ { name = "typer", marker = "extra == 'cli'", specifier = ">=0.12,<1" }, { name = "typing-extensions", specifier = ">=4.6" }, ] -provides-extras = ["cli", "desktop", "browser", "all"] +provides-extras = ["cli", "local", "desktop", "browser", "all"] [package.metadata.requires-dev] dev = [ @@ -201,7 +201,7 @@ dev = [ { name = "pytest-asyncio", specifier = ">=1.2,<2" }, { name = "python-dotenv", specifier = ">=1.2.2,<2" }, { name = "rich", specifier = ">=13,<16" }, - { name = "ruff", specifier = "==0.15.20" }, + { name = "ruff", specifier = "==0.15.21" }, { name = "typer", specifier = ">=0.12,<1" }, ] @@ -866,27 +866,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.20" +version = "0.15.21" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/43/dc/35b341fc554ba02f217fc10da57d1a75168cfbcf75b0ef2202176d4c4f2d/ruff-0.15.20.tar.gz", hash = "sha256:1416eb04349192646b54de98f146c4f59afe37d0decfc02c3cbbf396f3a28566", size = 4755489, upload-time = "2026-06-25T17:20:37.578Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/36/6f65aa9989acdec45d417192d8f4e7921931d8a6cf87ac74bce3eed98a8e/ruff-0.15.21.tar.gz", hash = "sha256:d0cfc841c572283c36548f82664a54ce6565567f1b0d5b4cf2caac693d8b7500", size = 4769401, upload-time = "2026-07-09T20:01:34.005Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/94/d9/2d5014f0253ba541d2061d9fa7193f48e941c8b21bb88a7ff9bbe0bd0596/ruff-0.15.20-py3-none-linux_armv6l.whl", hash = "sha256:00e188c53e499c3c1637f73c91dcf2fb56d576cab76ce1be50a27c4e80e37078", size = 10839665, upload-time = "2026-06-25T17:19:44.702Z" }, - { url = "https://files.pythonhosted.org/packages/c6/d3/ac1798ba64f670698867fcfc591d50e7e421bef137db564858f619a30fcf/ruff-0.15.20-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9ebd1fd9b9c95fc0bd7b2761aebec1f030013d2e193a2901b224af68fe47251b", size = 11208649, upload-time = "2026-06-25T17:19:48.787Z" }, - { url = "https://files.pythonhosted.org/packages/47/47/d3ac899991202095dfcf3d5176be4272642be3cf981a2f1a30f72a2afb95/ruff-0.15.20-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5b16cdd67ca108185cd36dce98c576350c03b1660a751de725fb049193a0632", size = 10622638, upload-time = "2026-06-25T17:19:51.354Z" }, - { url = "https://files.pythonhosted.org/packages/33/13/4e043fe30aa94d4ff5213a9881fc296d12960f5971b234a5263fdc225312/ruff-0.15.20-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3413bb3c3d2ca6a8208f1f4809cd2dca3c6de6d0b491c0e70847672bde6e6efd", size = 10984227, upload-time = "2026-06-25T17:19:54.044Z" }, - { url = "https://files.pythonhosted.org/packages/76/e6/92e7bf40388bc5800073b96564f56264f7e48bfd1a498f5ced6ae6d5a769/ruff-0.15.20-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd7ec42b3bb3da066488db093308a69c4ac5ee6d2af333a86ba6e2eb2e7dd44b", size = 10622882, upload-time = "2026-06-25T17:19:57.037Z" }, - { url = "https://files.pythonhosted.org/packages/13/7a/43460be3f24495a3aa46d4b16873e2c4941b3b5f0b00cf88c03b7b94b339/ruff-0.15.20-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e1a36ad0eb77fba9aabfb69ede54de6f376d04ac18ebea022847046d340a8267", size = 11474808, upload-time = "2026-06-25T17:20:00.357Z" }, - { url = "https://files.pythonhosted.org/packages/27/a0/f37077884873221c6b33b4ab49eb18f9f88e54a16a25a5bca59bef46dd66/ruff-0.15.20-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b6df3b1e4610432f0386dba04d853b5f08cbbc903410c6fcc02f620f05aff53c", size = 12293094, upload-time = "2026-06-25T17:20:03.446Z" }, - { url = "https://files.pythonhosted.org/packages/a6/74/165545b60256a9704c21ac0ec4a0d07933b320812f9584836c9f4aca4292/ruff-0.15.20-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e89f198a1ea6ef0d727c1cf16088bc91a6cb0ab947dedc966715691647186eae", size = 11526176, upload-time = "2026-06-25T17:20:06.301Z" }, - { url = "https://files.pythonhosted.org/packages/86/b1/a976a136d40ade83ce743578399865f57001003a409acadc0ecbb3051082/ruff-0.15.20-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:309809086c2acb67624950a3c8133e80f32d0d3e27106c0cd60ff26657c9f24b", size = 11520767, upload-time = "2026-06-25T17:20:09.191Z" }, - { url = "https://files.pythonhosted.org/packages/19/0f/f032696cb01c9b54c0263fa393474d7758f1cdc021a01b04e3cbc2500999/ruff-0.15.20-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2d2374caa2f2c2f9e2b7da0a50802cfb8b79f55a9b5e49379f564544fbf56487", size = 11500132, upload-time = "2026-06-25T17:20:13.602Z" }, - { url = "https://files.pythonhosted.org/packages/4b/f4/51b1a14bc69e8c224b15dab9cce8e99b425e0455d462caa2b3c9be2b6a8e/ruff-0.15.20-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a1ed17b65293e0c2f22fc387bc13198a5de94bf4429589b0ff6946b0feaf21a3", size = 10943828, upload-time = "2026-06-25T17:20:16.635Z" }, - { url = "https://files.pythonhosted.org/packages/71/4b/fe267640783cd02bf6c5cc290b1df1051be2ec294c678b5c15fe19e52343/ruff-0.15.20-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f701305e66b38ea6c91882490eb73459796808e4c6362a1b765255e0cdcd4053", size = 10645418, upload-time = "2026-06-25T17:20:19.4Z" }, - { url = "https://files.pythonhosted.org/packages/b0/c0/a65aa4ec2f5e87a1df32dc3ec1fede434fe3dfd5cbcf3b503cafc676ab54/ruff-0.15.20-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5b9c0c367ad8e5d0d5b5b8537864c469a0a0e55417aadfbeca41fa61333be9f4", size = 11211770, upload-time = "2026-06-25T17:20:22.033Z" }, - { url = "https://files.pythonhosted.org/packages/5a/a4/0caa331d954ae2723d729d351c989cb4ca8b6077d5c6c2cb6de75e98c041/ruff-0.15.20-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:01cc00dd58f0df339d0e902219dd53990ea99996a0344e5d9cc8d45d5307e460", size = 11618698, upload-time = "2026-06-25T17:20:25.259Z" }, - { url = "https://files.pythonhosted.org/packages/10/9b/5f14927848d2fd4aa891fd88d883788c5a7baba561c7874732364045708c/ruff-0.15.20-py3-none-win32.whl", hash = "sha256:ed65ef510e43a137207e0f01cfcf998aeddb1aeeda5c9d35023e910284d7cf21", size = 10857322, upload-time = "2026-06-25T17:20:28.612Z" }, - { url = "https://files.pythonhosted.org/packages/fa/f0/fe47c501f9dea92a26d788ff98bb5d92ed4cb4c88792c5c88af6b697dc8e/ruff-0.15.20-py3-none-win_amd64.whl", hash = "sha256:a525c81c70fb0380344dd1d8745d8cc1c890b7fc94a58d5a07bd8eb9557b8415", size = 11993274, upload-time = "2026-06-25T17:20:31.871Z" }, - { url = "https://files.pythonhosted.org/packages/d7/2b/9555445e1201d92b3195f45cdb153a0b68f24e0a4273f6e3d5ab46e212bb/ruff-0.15.20-py3-none-win_arm64.whl", hash = "sha256:2f5b2a6d614e8700388806a14996c40fab2c47b819ef57d790a34878858ed9ca", size = 11343498, upload-time = "2026-06-25T17:20:35.03Z" }, + { url = "https://files.pythonhosted.org/packages/d0/c6/ede15cac6839f3dbce52565c8f5164a8210e669c7bc4decb03e5bdf47d0d/ruff-0.15.21-py3-none-linux_armv6l.whl", hash = "sha256:63ea0e965e5d73c90e95b2434beeafc70820536717f561b32ab6e777cb9bdf5d", size = 10854342, upload-time = "2026-07-09T20:00:53.998Z" }, + { url = "https://files.pythonhosted.org/packages/28/9d/d825b07ee7ea9e2d61df92a860033c94e06e7300d50a1c2653aac27d24fe/ruff-0.15.21-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0f212c5d7d54c01bbfe6dcab02b724a39300f3e34ed7acbe995ccb320a2c58bd", size = 11139539, upload-time = "2026-07-09T20:00:57.809Z" }, + { url = "https://files.pythonhosted.org/packages/f5/de/3b107712e642f063c7a9e0887c427b22cb44097de5aab36c05f2e280670c/ruff-0.15.21-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e6312e41bc96791299614995ea3a977c5857c3b5662b1ecef6755b02b87cb646", size = 10595437, upload-time = "2026-07-09T20:01:00.006Z" }, + { url = "https://files.pythonhosted.org/packages/9a/6f/b4523cc90ba239ede441447a19d0c968846a3012e5a0b0c5b62831a3d5e3/ruff-0.15.21-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:01d65b4831c6b2a4ba8ee6faa84049d44d982b7a706e622c4094c509e51673be", size = 10990053, upload-time = "2026-07-09T20:01:02.187Z" }, + { url = "https://files.pythonhosted.org/packages/92/cc/c6a9872a5375f0628875481cf2f66b13d7d865bf3ca2e57f91c7e762d976/ruff-0.15.21-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2c5a913a589120ce67933d5d05fd6ddbcc2481c6a054980ee767f7414c72b4fd", size = 10666096, upload-time = "2026-07-09T20:01:04.299Z" }, + { url = "https://files.pythonhosted.org/packages/ab/97/c621f7a17e097f1790fa3af6374138823b330b2d03fc38337945daca212c/ruff-0.15.21-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5ef04b681d02ad4dc9620f00f83ac5c22f652d0e9a9cfe431d219b16ad5ccc41", size = 11537011, upload-time = "2026-07-09T20:01:06.771Z" }, + { url = "https://files.pythonhosted.org/packages/ea/51/d928727e476e25ccc57c6f449ffd80241a651a973ad949d39cfb2a771d28/ruff-0.15.21-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:16d090c0740916594157e75b80d666eab8e78083b39b3b0e1d698f4670a17b86", size = 12347101, upload-time = "2026-07-09T20:01:08.859Z" }, + { url = "https://files.pythonhosted.org/packages/1e/88/8cd62026802b16018ad06931d87997cf795ba2a6239ab659606c87d96bf0/ruff-0.15.21-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3a10e74757dd65004d779b73e2f3c5210156d9980b41224d50d2ebcf1db51e67", size = 11572001, upload-time = "2026-07-09T20:01:11.092Z" }, + { url = "https://files.pythonhosted.org/packages/b2/97/f63084cf55444fc110e8cb985ebfcc592af47f597d44453d778cb81bc156/ruff-0.15.21-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bab0905d2f29e0d9fbc3c373ed23db0095edaa3f71f1f4f519ec15134d9e85c8", size = 11549239, upload-time = "2026-07-09T20:01:13.27Z" }, + { url = "https://files.pythonhosted.org/packages/9d/77/f107da4a2874b7715914b03f09ba9c54424de3ff8a1cc5d015d3ee2ce0ac/ruff-0.15.21-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:00eca240af5789fec6fe7df74c088cc1f9644ed83027113468efba7c92b94075", size = 11535340, upload-time = "2026-07-09T20:01:15.206Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e9/601deb322d3303a7bf212b0100ead6f2ee3f6a044d89c30f2f92bf83c731/ruff-0.15.21-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:262ab31557a75141325e32d3357f3597645a7f084e732b6b054dde428ecd9341", size = 10964048, upload-time = "2026-07-09T20:01:17.723Z" }, + { url = "https://files.pythonhosted.org/packages/ea/2e/0f2176d1e99c15192caea19c8c3a0a955246b4cb4de795042eeb616345cd/ruff-0.15.21-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:659c4e7a4212f83306045ec7c5e5a356d16d9a6ef4ae0c7a4d872914fc655d9d", size = 10667055, upload-time = "2026-07-09T20:01:19.73Z" }, + { url = "https://files.pythonhosted.org/packages/48/60/abd74a02e0c4214f12a68becfd30af7165cfdcb0e661ecdc60bbb949c09a/ruff-0.15.21-py3-none-musllinux_1_2_i686.whl", hash = "sha256:9e866eab611a5f959d36df2d10e446973a3610bc42b0c15b31dc27977d59c233", size = 11242043, upload-time = "2026-07-09T20:01:21.947Z" }, + { url = "https://files.pythonhosted.org/packages/b2/c6/583075d8ccabb4b229345edcaf1545eb3d8d6be90f686a479d7e94088bbf/ruff-0.15.21-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e89bc93c0d3803ba870b55c29671bad9dc6d94bb1eb181b056b52eb05b52854f", size = 11648064, upload-time = "2026-07-09T20:01:24.023Z" }, + { url = "https://files.pythonhosted.org/packages/3a/3c/37d0ecb729a7cc2d393ea7dce316fc585680f35d93b8d62139d7d0a3700c/ruff-0.15.21-py3-none-win32.whl", hash = "sha256:01f8d5be84823c172b389e123174f781f9daf86d6c58719d603f941932195cdd", size = 10896555, upload-time = "2026-07-09T20:01:26.941Z" }, + { url = "https://files.pythonhosted.org/packages/c0/b8/e43466b2a6067ce91e669068f6e28d6c719a920f014b070d5c8731725de3/ruff-0.15.21-py3-none-win_amd64.whl", hash = "sha256:d4b8d9a2f0f12b816b50447f6eccb9f4bb01a6b82c86b50fb3b5354b458dc6d3", size = 12038772, upload-time = "2026-07-09T20:01:29.497Z" }, + { url = "https://files.pythonhosted.org/packages/dd/75/e90ab9aeece218a9fc5a5bc3ec97d0ee6bb3c4ff95869463c1de58e29a1c/ruff-0.15.21-py3-none-win_arm64.whl", hash = "sha256:6e83115d4b9377c1cbc13abf0e051f069fab0ef815ea0504a8a008cee24dd0a8", size = 11375265, upload-time = "2026-07-09T20:01:31.772Z" }, ] [[package]]