From 5e76c9c2136a678be61be51bbe105f402730a00b Mon Sep 17 00:00:00 2001 From: namabeeru Date: Fri, 26 Jun 2026 23:35:01 +0900 Subject: [PATCH 1/8] Handle missing OpenAI SDK for compatible providers --- agent_core/core/models/factory.py | 144 ++++++++++++++++++++++++++++-- tests/test_model_factory.py | 106 ++++++++++++++++++++++ 2 files changed, 241 insertions(+), 9 deletions(-) create mode 100644 tests/test_model_factory.py diff --git a/agent_core/core/models/factory.py b/agent_core/core/models/factory.py index a2476e18..f1b598c8 100644 --- a/agent_core/core/models/factory.py +++ b/agent_core/core/models/factory.py @@ -5,13 +5,12 @@ """ import logging +from types import SimpleNamespace +from typing import Any, Optional import urllib.request +import urllib.error import json as _json -from openai import OpenAI -from anthropic import Anthropic -from typing import Optional - try: import boto3 # type: ignore[import] except ImportError: # pragma: no cover — boto3 is an optional extra @@ -53,6 +52,119 @@ _OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1" +def _to_namespace(value: Any) -> Any: + """Convert provider JSON into SDK-like objects with attribute access.""" + if isinstance(value, dict): + return SimpleNamespace(**{k: _to_namespace(v) for k, v in value.items()}) + if isinstance(value, list): + return [_to_namespace(item) for item in value] + return value + + +class _OpenAICompatibleChatCompletions: + def __init__(self, parent: "_OpenAICompatibleClient") -> None: + self._parent = parent + + def create(self, **kwargs: Any) -> Any: + payload = dict(kwargs) + extra_body = payload.pop("extra_body", None) + if isinstance(extra_body, dict): + payload.update(extra_body) + + req = urllib.request.Request( + self._parent.chat_completions_url, + data=_json.dumps(payload).encode("utf-8"), + headers={ + "Authorization": f"Bearer {self._parent.api_key}", + "Content-Type": "application/json", + }, + method="POST", + ) + + try: + with urllib.request.urlopen(req, timeout=self._parent.timeout) as resp: + raw = _json.loads(resp.read().decode("utf-8")) + except urllib.error.HTTPError as exc: + body = exc.read().decode("utf-8", errors="replace") + raise RuntimeError( + f"OpenAI-compatible request failed with HTTP {exc.code}: " + f"{body[:500]}" + ) from exc + + usage = raw.setdefault("usage", {}) + if isinstance(usage, dict): + usage.setdefault("prompt_tokens", 0) + usage.setdefault("completion_tokens", 0) + + return _to_namespace(raw) + + +class _OpenAICompatibleClient: + """Small HTTP fallback for chat-completions compatible providers. + + CraftBot prefers the official OpenAI SDK when it is installed. This fallback + keeps OpenAI-compatible LLM providers such as DeepSeek usable in lightweight + installs that do not have the SDK, while preserving the same response shape + the rest of the code reads (`choices[0].message.content`, `usage.*`). + """ + + def __init__( + self, + *, + api_key: str, + base_url: Optional[str] = None, + timeout: float = 120.0, + ) -> None: + self.api_key = api_key + self.base_url = (base_url or "https://api.openai.com/v1").rstrip("/") + self.timeout = timeout + self.chat_completions_url = f"{self.base_url}/chat/completions" + self.chat = SimpleNamespace( + completions=_OpenAICompatibleChatCompletions(self) + ) + + +def _create_openai_client( + *, + provider: str, + interface: InterfaceType, + api_key: str, + base_url: Optional[str] = None, +) -> Any: + """Create an OpenAI SDK client, or a chat-compatible HTTP fallback.""" + try: + from openai import OpenAI + + if base_url: + return OpenAI(api_key=api_key, base_url=base_url) + return OpenAI(api_key=api_key) + except ImportError as exc: + if interface in (InterfaceType.LLM, InterfaceType.VLM): + logger.warning( + "[FACTORY] openai package is not installed; using HTTP fallback " + "for OpenAI-compatible provider '%s'.", + provider, + ) + return _OpenAICompatibleClient(api_key=api_key, base_url=base_url) + raise ImportError( + f"The openai package is required for {provider} {interface.value}. " + "Install it with the Python that launches CraftBot: " + "`python -m pip install 'openai>=2.0.0'`." + ) from exc + + +def _create_anthropic_client(*, api_key: str) -> Any: + try: + from anthropic import Anthropic + except ImportError as exc: + raise ImportError( + "The anthropic package is required for the Anthropic provider. " + "Install it with the Python that launches CraftBot: " + "`python -m pip install 'anthropic>=0.97.0'`." + ) from exc + return Anthropic(api_key=api_key) + + def _to_openrouter_slug(provider: str, model: str) -> str: """Convert a provider-native model ID to its OpenRouter slug.""" if "/" in model: @@ -120,7 +232,7 @@ def create( Returns: Dictionary with provider context including client instances """ - # OpenAI-compatible providers that use OpenAI client with a custom base_url + # OpenAI-compatible providers that use chat-completions with a custom base_url. _OPENAI_COMPAT = {"minimax", "deepseek", "moonshot", "grok", "openrouter"} if provider not in PROVIDER_CONFIG: @@ -175,7 +287,11 @@ def create( return { "provider": provider, "model": model, - "client": OpenAI(api_key=api_key), + "client": _create_openai_client( + provider=provider, + interface=interface, + api_key=api_key, + ), "gemini_client": None, "remote_url": None, "byteplus": None, @@ -215,7 +331,7 @@ def create( "gemini_client": None, "remote_url": None, "byteplus": None, - "anthropic_client": Anthropic(api_key=api_key), + "anthropic_client": _create_anthropic_client(api_key=api_key), "bedrock_client": None, "initialized": True, } @@ -273,7 +389,12 @@ def create( return { "provider": "openrouter", "model": or_model, - "client": OpenAI(api_key=or_key, base_url=_OPENROUTER_BASE_URL), + "client": _create_openai_client( + provider="openrouter", + interface=interface, + api_key=or_key, + base_url=_OPENROUTER_BASE_URL, + ), "gemini_client": None, "remote_url": None, "byteplus": None, @@ -290,7 +411,12 @@ def create( return { "provider": provider, "model": model, - "client": OpenAI(api_key=api_key, base_url=resolved_base_url), + "client": _create_openai_client( + provider=provider, + interface=interface, + api_key=api_key, + base_url=resolved_base_url, + ), "gemini_client": None, "remote_url": None, "byteplus": None, diff --git a/tests/test_model_factory.py b/tests/test_model_factory.py new file mode 100644 index 00000000..2be53436 --- /dev/null +++ b/tests/test_model_factory.py @@ -0,0 +1,106 @@ +# -*- coding: utf-8 -*- +import json +from pathlib import Path +import subprocess +import sys +import textwrap + +from agent_core.core.models.factory import _OpenAICompatibleClient + +PROJECT_ROOT = Path(__file__).resolve().parents[1] + + +def test_openai_compatible_fallback_posts_chat_completion(monkeypatch): + seen = {} + + class FakeResponse: + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def read(self): + return json.dumps( + { + "choices": [{"message": {"content": "{\"ok\":true}"}}], + "usage": { + "prompt_tokens": 3, + "completion_tokens": 2, + "prompt_tokens_details": {"cached_tokens": 1}, + }, + } + ).encode("utf-8") + + def fake_urlopen(req, timeout): + seen["url"] = req.full_url + seen["timeout"] = timeout + seen["headers"] = dict(req.header_items()) + seen["body"] = json.loads(req.data.decode("utf-8")) + return FakeResponse() + + monkeypatch.setattr( + "agent_core.core.models.factory.urllib.request.urlopen", + fake_urlopen, + ) + + client = _OpenAICompatibleClient( + api_key="deepseek-key", + base_url="https://api.deepseek.com", + timeout=7, + ) + response = client.chat.completions.create( + model="deepseek-chat", + messages=[{"role": "user", "content": "hi"}], + max_tokens=1, + extra_body={"prompt_cache_key": "route-1"}, + ) + + assert seen["url"] == "https://api.deepseek.com/chat/completions" + assert seen["timeout"] == 7 + assert seen["headers"]["Authorization"] == "Bearer deepseek-key" + assert seen["body"]["prompt_cache_key"] == "route-1" + assert response.choices[0].message.content == "{\"ok\":true}" + assert response.usage.prompt_tokens == 3 + assert response.usage.prompt_tokens_details.cached_tokens == 1 + + +def test_deepseek_context_uses_fallback_when_openai_sdk_missing(): + code = textwrap.dedent( + """ + import importlib.abc + import sys + + class BlockOpenAI(importlib.abc.MetaPathFinder): + def find_spec(self, fullname, path=None, target=None): + if fullname == "openai" or fullname.startswith("openai."): + raise ImportError("openai intentionally blocked") + return None + + for name in list(sys.modules): + if name == "openai" or name.startswith("openai."): + del sys.modules[name] + sys.meta_path.insert(0, BlockOpenAI()) + + from agent_core.core.models.factory import ModelFactory + from agent_core.core.models.types import InterfaceType + + ctx = ModelFactory.create( + provider="deepseek", + interface=InterfaceType.LLM, + api_key="deepseek-key", + ) + assert ctx["initialized"] is True + assert ctx["provider"] == "deepseek" + assert ctx["client"].__class__.__name__ == "_OpenAICompatibleClient" + """ + ) + + result = subprocess.run( + [sys.executable, "-c", code], + cwd=PROJECT_ROOT, + text=True, + capture_output=True, + ) + + assert result.returncode == 0, result.stderr From 850c509cda02b382483c79a79bf8bb23ab3ac27e Mon Sep 17 00:00:00 2001 From: namabeeru Date: Fri, 26 Jun 2026 23:56:56 +0900 Subject: [PATCH 2/8] Detect missing runtime dependencies --- agent_core/core/models/factory.py | 111 ++++------------------ run.py | 103 +++++++++++++++++++++ tests/test_model_factory.py | 143 ++++++++++++++--------------- tests/test_run_dependency_check.py | 69 ++++++++++++++ 4 files changed, 259 insertions(+), 167 deletions(-) create mode 100644 tests/test_run_dependency_check.py diff --git a/agent_core/core/models/factory.py b/agent_core/core/models/factory.py index f1b598c8..3e0b91ed 100644 --- a/agent_core/core/models/factory.py +++ b/agent_core/core/models/factory.py @@ -5,10 +5,8 @@ """ import logging -from types import SimpleNamespace -from typing import Any, Optional +from typing import Optional import urllib.request -import urllib.error import json as _json try: @@ -52,108 +50,40 @@ _OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1" -def _to_namespace(value: Any) -> Any: - """Convert provider JSON into SDK-like objects with attribute access.""" - if isinstance(value, dict): - return SimpleNamespace(**{k: _to_namespace(v) for k, v in value.items()}) - if isinstance(value, list): - return [_to_namespace(item) for item in value] - return value - - -class _OpenAICompatibleChatCompletions: - def __init__(self, parent: "_OpenAICompatibleClient") -> None: - self._parent = parent - - def create(self, **kwargs: Any) -> Any: - payload = dict(kwargs) - extra_body = payload.pop("extra_body", None) - if isinstance(extra_body, dict): - payload.update(extra_body) - - req = urllib.request.Request( - self._parent.chat_completions_url, - data=_json.dumps(payload).encode("utf-8"), - headers={ - "Authorization": f"Bearer {self._parent.api_key}", - "Content-Type": "application/json", - }, - method="POST", - ) - - try: - with urllib.request.urlopen(req, timeout=self._parent.timeout) as resp: - raw = _json.loads(resp.read().decode("utf-8")) - except urllib.error.HTTPError as exc: - body = exc.read().decode("utf-8", errors="replace") - raise RuntimeError( - f"OpenAI-compatible request failed with HTTP {exc.code}: " - f"{body[:500]}" - ) from exc - - usage = raw.setdefault("usage", {}) - if isinstance(usage, dict): - usage.setdefault("prompt_tokens", 0) - usage.setdefault("completion_tokens", 0) - - return _to_namespace(raw) - - -class _OpenAICompatibleClient: - """Small HTTP fallback for chat-completions compatible providers. - - CraftBot prefers the official OpenAI SDK when it is installed. This fallback - keeps OpenAI-compatible LLM providers such as DeepSeek usable in lightweight - installs that do not have the SDK, while preserving the same response shape - the rest of the code reads (`choices[0].message.content`, `usage.*`). - """ - - def __init__( - self, - *, - api_key: str, - base_url: Optional[str] = None, - timeout: float = 120.0, - ) -> None: - self.api_key = api_key - self.base_url = (base_url or "https://api.openai.com/v1").rstrip("/") - self.timeout = timeout - self.chat_completions_url = f"{self.base_url}/chat/completions" - self.chat = SimpleNamespace( - completions=_OpenAICompatibleChatCompletions(self) - ) +_PROVIDER_DISPLAY = { + "openai": "OpenAI", + "deepseek": "DeepSeek", + "grok": "Grok", + "moonshot": "Moonshot", + "minimax": "MiniMax", + "openrouter": "OpenRouter", +} def _create_openai_client( *, provider: str, - interface: InterfaceType, api_key: str, base_url: Optional[str] = None, -) -> Any: - """Create an OpenAI SDK client, or a chat-compatible HTTP fallback.""" +): + """Create an OpenAI SDK client for OpenAI-compatible providers.""" try: from openai import OpenAI - - if base_url: - return OpenAI(api_key=api_key, base_url=base_url) - return OpenAI(api_key=api_key) except ImportError as exc: - if interface in (InterfaceType.LLM, InterfaceType.VLM): - logger.warning( - "[FACTORY] openai package is not installed; using HTTP fallback " - "for OpenAI-compatible provider '%s'.", - provider, - ) - return _OpenAICompatibleClient(api_key=api_key, base_url=base_url) + display = _PROVIDER_DISPLAY.get(provider, provider) raise ImportError( - f"The openai package is required for {provider} {interface.value}. " + f"The openai package is required for {display} because CraftBot " + "uses the OpenAI-compatible SDK client for this provider. " "Install it with the Python that launches CraftBot: " "`python -m pip install 'openai>=2.0.0'`." ) from exc + if base_url: + return OpenAI(api_key=api_key, base_url=base_url) + return OpenAI(api_key=api_key) + -def _create_anthropic_client(*, api_key: str) -> Any: +def _create_anthropic_client(*, api_key: str): try: from anthropic import Anthropic except ImportError as exc: @@ -289,7 +219,6 @@ def create( "model": model, "client": _create_openai_client( provider=provider, - interface=interface, api_key=api_key, ), "gemini_client": None, @@ -391,7 +320,6 @@ def create( "model": or_model, "client": _create_openai_client( provider="openrouter", - interface=interface, api_key=or_key, base_url=_OPENROUTER_BASE_URL, ), @@ -413,7 +341,6 @@ def create( "model": model, "client": _create_openai_client( provider=provider, - interface=interface, api_key=api_key, base_url=resolved_base_url, ), diff --git a/run.py b/run.py index 7db8a51b..2fec0220 100644 --- a/run.py +++ b/run.py @@ -119,6 +119,14 @@ def _bootstrap_frozen(): OMNIPARSER_ENV_NAME = "omni" OMNIPARSER_SERVER_URL = os.getenv("OMNIPARSER_BASE_URL", "http://localhost:7861") +RUNTIME_IMPORT_CHECKS = { + "requests": "requests", + "aiohttp": "aiohttp", + "websockets": "websockets", + "pymongo": "pymongo", + "pyyaml": "yaml", +} + # ========================================== # TERMINAL COLORS (orange/white brand palette) @@ -1062,6 +1070,99 @@ def verify_env(env_name: str) -> bool: return False +def _runtime_import_command( + use_conda: bool, env_name: Optional[str], import_name: str +) -> Tuple[List[str], str]: + if use_conda and env_name: + return ( + [ + get_conda_command(), + "run", + "-n", + env_name, + "python", + "-c", + f"import {import_name}", + ], + f"conda environment '{env_name}'", + ) + return ([sys.executable, "-c", f"import {import_name}"], sys.executable) + + +def find_missing_runtime_dependencies( + *, + use_conda: bool, + env_name: Optional[str], + checks: Optional[Dict[str, str]] = None, +) -> Tuple[List[str], str]: + """Return missing core imports for the Python runtime that will run the agent.""" + if checks is None: + checks = RUNTIME_IMPORT_CHECKS + missing: List[str] = [] + runtime_label = ( + f"conda environment '{env_name}'" if use_conda and env_name else sys.executable + ) + + for package_name, import_name in checks.items(): + cmd, runtime_label = _runtime_import_command(use_conda, env_name, import_name) + try: + result = subprocess.run(cmd, capture_output=True, timeout=20) + except Exception: + missing.append(package_name) + continue + if result.returncode != 0: + missing.append(package_name) + + return missing, runtime_label or sys.executable + + +def print_missing_runtime_dependencies( + *, + missing: List[str], + runtime_label: str, + use_conda: bool, + env_name: Optional[str], +) -> None: + print("\nError: CraftBot Python dependencies are missing.") + print(f"Runtime checked: {runtime_label}") + print("\nMissing imports:") + for package_name in missing: + print(f" - {package_name}") + + print( + "\nThis usually means CraftBot is running with a different Python " + "than the one used during install." + ) + print("\nFix:") + if use_conda and env_name: + print(f" python install.py --conda") + print(f" conda run -n {env_name} python run.py") + else: + print(f" {sys.executable} install.py") + print(f" {sys.executable} run.py") + print("\nIf you installed CraftBot with another Python, start it with that Python.") + + +def ensure_runtime_dependencies( + *, use_conda: bool, env_name: Optional[str] +) -> None: + if getattr(sys, "frozen", False): + return + + missing, runtime_label = find_missing_runtime_dependencies( + use_conda=use_conda, + env_name=env_name, + ) + if missing: + print_missing_runtime_dependencies( + missing=missing, + runtime_label=runtime_label, + use_conda=use_conda, + env_name=env_name, + ) + sys.exit(1) + + # ========================================== # OMNIPARSER SERVER # ========================================== @@ -1254,6 +1355,8 @@ def launch_agent(env_name: Optional[str], conda_base: Optional[str], use_conda: print("Run 'python install.py' or 'python install.py --conda' first.\n") sys.exit(1) + ensure_runtime_dependencies(use_conda=use_conda, env_name=env_name) + # Start OmniParser only if GUI mode and it was installed if gui_mode and gui_installed: if not launch_omniparser(use_conda): diff --git a/tests/test_model_factory.py b/tests/test_model_factory.py index 2be53436..2cfb06a0 100644 --- a/tests/test_model_factory.py +++ b/tests/test_model_factory.py @@ -1,106 +1,99 @@ # -*- coding: utf-8 -*- -import json from pathlib import Path import subprocess import sys import textwrap -from agent_core.core.models.factory import _OpenAICompatibleClient - PROJECT_ROOT = Path(__file__).resolve().parents[1] -def test_openai_compatible_fallback_posts_chat_completion(monkeypatch): - seen = {} - - class FakeResponse: - def __enter__(self): - return self - - def __exit__(self, exc_type, exc, tb): - return False - - def read(self): - return json.dumps( - { - "choices": [{"message": {"content": "{\"ok\":true}"}}], - "usage": { - "prompt_tokens": 3, - "completion_tokens": 2, - "prompt_tokens_details": {"cached_tokens": 1}, - }, - } - ).encode("utf-8") - - def fake_urlopen(req, timeout): - seen["url"] = req.full_url - seen["timeout"] = timeout - seen["headers"] = dict(req.header_items()) - seen["body"] = json.loads(req.data.decode("utf-8")) - return FakeResponse() - - monkeypatch.setattr( - "agent_core.core.models.factory.urllib.request.urlopen", - fake_urlopen, - ) - - client = _OpenAICompatibleClient( - api_key="deepseek-key", - base_url="https://api.deepseek.com", - timeout=7, - ) - response = client.chat.completions.create( - model="deepseek-chat", - messages=[{"role": "user", "content": "hi"}], - max_tokens=1, - extra_body={"prompt_cache_key": "route-1"}, - ) - - assert seen["url"] == "https://api.deepseek.com/chat/completions" - assert seen["timeout"] == 7 - assert seen["headers"]["Authorization"] == "Bearer deepseek-key" - assert seen["body"]["prompt_cache_key"] == "route-1" - assert response.choices[0].message.content == "{\"ok\":true}" - assert response.usage.prompt_tokens == 3 - assert response.usage.prompt_tokens_details.cached_tokens == 1 - - -def test_deepseek_context_uses_fallback_when_openai_sdk_missing(): - code = textwrap.dedent( - """ +def _run_with_blocked_sdks(code: str) -> subprocess.CompletedProcess: + script = textwrap.dedent( + f""" import importlib.abc import sys - class BlockOpenAI(importlib.abc.MetaPathFinder): + class BlockProviderSdks(importlib.abc.MetaPathFinder): def find_spec(self, fullname, path=None, target=None): - if fullname == "openai" or fullname.startswith("openai."): - raise ImportError("openai intentionally blocked") + if ( + fullname == "openai" + or fullname.startswith("openai.") + or fullname == "anthropic" + or fullname.startswith("anthropic.") + ): + raise ImportError(f"{{fullname}} intentionally blocked") return None for name in list(sys.modules): - if name == "openai" or name.startswith("openai."): + if ( + name == "openai" + or name.startswith("openai.") + or name == "anthropic" + or name.startswith("anthropic.") + ): del sys.modules[name] - sys.meta_path.insert(0, BlockOpenAI()) + sys.meta_path.insert(0, BlockProviderSdks()) + {textwrap.indent(textwrap.dedent(code), " ")} + """ + ) + return subprocess.run( + [sys.executable, "-c", script], + cwd=PROJECT_ROOT, + text=True, + capture_output=True, + ) + + +def test_importing_model_factory_does_not_require_provider_sdks(): + result = _run_with_blocked_sdks( + """ + from agent_core.core.models.factory import ModelFactory + assert ModelFactory is not None + """ + ) + + assert result.returncode == 0, result.stderr + + +def test_deferred_openai_compatible_provider_does_not_require_openai_sdk(): + result = _run_with_blocked_sdks( + """ from agent_core.core.models.factory import ModelFactory from agent_core.core.models.types import InterfaceType ctx = ModelFactory.create( provider="deepseek", interface=InterfaceType.LLM, - api_key="deepseek-key", + deferred=True, ) - assert ctx["initialized"] is True - assert ctx["provider"] == "deepseek" - assert ctx["client"].__class__.__name__ == "_OpenAICompatibleClient" + assert ctx["initialized"] is False + assert ctx["client"] is None """ ) - result = subprocess.run( - [sys.executable, "-c", code], - cwd=PROJECT_ROOT, - text=True, - capture_output=True, + assert result.returncode == 0, result.stderr + + +def test_openai_compatible_provider_reports_missing_openai_sdk(): + result = _run_with_blocked_sdks( + """ + from agent_core.core.models.factory import ModelFactory + from agent_core.core.models.types import InterfaceType + + try: + ModelFactory.create( + provider="deepseek", + interface=InterfaceType.LLM, + api_key="deepseek-key", + ) + except ImportError as exc: + message = str(exc) + assert "openai package is required" in message + assert "DeepSeek" in message + else: + raise AssertionError("expected missing OpenAI SDK to raise ImportError") + """ ) assert result.returncode == 0, result.stderr diff --git a/tests/test_run_dependency_check.py b/tests/test_run_dependency_check.py new file mode 100644 index 00000000..ee839181 --- /dev/null +++ b/tests/test_run_dependency_check.py @@ -0,0 +1,69 @@ +# -*- coding: utf-8 -*- +import subprocess +import sys + +import run + + +def test_find_missing_runtime_dependencies_reports_current_interpreter(monkeypatch): + seen_commands = [] + + def fake_run(cmd, capture_output, timeout): + seen_commands.append(cmd) + return subprocess.CompletedProcess(cmd, 1 if cmd[-1] == "import requests" else 0) + + monkeypatch.setattr(run.subprocess, "run", fake_run) + + missing, runtime_label = run.find_missing_runtime_dependencies( + use_conda=False, + env_name=None, + checks={"requests": "requests", "aiohttp": "aiohttp"}, + ) + + assert missing == ["requests"] + assert runtime_label == sys.executable + assert seen_commands == [ + [sys.executable, "-c", "import requests"], + [sys.executable, "-c", "import aiohttp"], + ] + + +def test_find_missing_runtime_dependencies_checks_conda_env(monkeypatch): + seen_commands = [] + + monkeypatch.setattr(run, "get_conda_command", lambda: "conda") + + def fake_run(cmd, capture_output, timeout): + seen_commands.append(cmd) + return subprocess.CompletedProcess(cmd, 0) + + monkeypatch.setattr(run.subprocess, "run", fake_run) + + missing, runtime_label = run.find_missing_runtime_dependencies( + use_conda=True, + env_name="craftbot", + checks={"requests": "requests"}, + ) + + assert missing == [] + assert runtime_label == "conda environment 'craftbot'" + assert seen_commands == [ + ["conda", "run", "-n", "craftbot", "python", "-c", "import requests"] + ] + + +def test_print_missing_runtime_dependencies_includes_fix(monkeypatch, capsys): + monkeypatch.setattr(sys, "executable", "/opt/homebrew/bin/python3") + + run.print_missing_runtime_dependencies( + missing=["requests", "aiohttp"], + runtime_label="/opt/homebrew/bin/python3", + use_conda=False, + env_name=None, + ) + + output = capsys.readouterr().out + assert "requests" in output + assert "aiohttp" in output + assert "/opt/homebrew/bin/python3" in output + assert "/opt/homebrew/bin/python3 install.py" in output From 71bf7045848d1b95a0d35f73ab851c2ef3db3d38 Mon Sep 17 00:00:00 2001 From: namabeeru Date: Sat, 27 Jun 2026 00:09:29 +0900 Subject: [PATCH 3/8] Share runtime dependency preflight --- app/main.py | 4 + app/runtime_preflight.py | 154 +++++++++++++++++++++++++++++ run.py | 110 ++------------------- tests/test_run_dependency_check.py | 91 +++++++++++++---- 4 files changed, 237 insertions(+), 122 deletions(-) create mode 100644 app/runtime_preflight.py diff --git a/app/main.py b/app/main.py index 02455d5b..ca05dfd1 100644 --- a/app/main.py +++ b/app/main.py @@ -51,6 +51,10 @@ def _suppress_console_logging_early() -> None: import argparse import asyncio +from app.runtime_preflight import ensure_current_runtime_dependencies + +ensure_current_runtime_dependencies() + # Register agent_core state provider and config before importing AgentBase # This ensures shared code can access state via get_state() from agent_core import StateRegistry, ConfigRegistry diff --git a/app/runtime_preflight.py b/app/runtime_preflight.py new file mode 100644 index 00000000..aadba714 --- /dev/null +++ b/app/runtime_preflight.py @@ -0,0 +1,154 @@ +# -*- coding: utf-8 -*- +"""Runtime dependency checks that can run before dependency-heavy imports.""" + +import json +import subprocess +import sys +from typing import Dict, List, Optional, Tuple + + +_MISSING_SENTINEL = "__CRAFTBOT_MISSING_RUNTIME_IMPORTS__" + +RUNTIME_IMPORT_CHECKS = { + # Packages imported during backend startup. Provider SDKs stay deferred so + # DeepSeek/OpenRouter users are not forced to install unrelated SDKs. + "requests": "requests", + "pyyaml": "yaml", + "loguru": "loguru", + "nest-asyncio": "nest_asyncio", + "pymongo": "pymongo", + "tzlocal": "tzlocal", + "aiohttp": "aiohttp", + "chromadb": "chromadb", + "tiktoken": "tiktoken", + "mss": "mss", + "httpx": "httpx", + "websockets": "websockets", + "tenacity": "tenacity", + "gradio_client": "gradio_client", + "python-dotenv": "dotenv", + "scikit-learn": "sklearn", + "watchdog": "watchdog", + "croniter": "croniter", +} + + +def _runtime_import_script(checks: Dict[str, str]) -> str: + return ( + "import importlib\n" + "import json\n" + f"checks = {list(checks.items())!r}\n" + "missing = []\n" + "for package_name, import_name in checks:\n" + " try:\n" + " importlib.import_module(import_name)\n" + " except Exception:\n" + " missing.append(package_name)\n" + f"print({_MISSING_SENTINEL!r} + json.dumps(missing))\n" + ) + + +def _runtime_import_command( + use_conda: bool, + env_name: Optional[str], + checks: Dict[str, str], + conda_command: str, +) -> Tuple[List[str], str]: + script = _runtime_import_script(checks) + if use_conda and env_name: + return ( + [ + conda_command, + "run", + "-n", + env_name, + "python", + "-c", + script, + ], + f"conda environment '{env_name}'", + ) + return ([sys.executable, "-c", script], sys.executable) + + +def find_missing_runtime_dependencies( + *, + use_conda: bool, + env_name: Optional[str], + checks: Optional[Dict[str, str]] = None, + conda_command: str = "conda", +) -> Tuple[List[str], str]: + """Return missing core imports for the Python runtime that will run the agent.""" + if checks is None: + checks = RUNTIME_IMPORT_CHECKS + cmd, runtime_label = _runtime_import_command( + use_conda, env_name, checks, conda_command + ) + + try: + result = subprocess.run(cmd, capture_output=True, text=True, timeout=60) + except Exception: + return list(checks), runtime_label + if result.returncode != 0: + return list(checks), runtime_label + + for line in reversed(result.stdout.splitlines()): + if line.startswith(_MISSING_SENTINEL): + return json.loads(line[len(_MISSING_SENTINEL) :]), runtime_label + return list(checks), runtime_label + + +def print_missing_runtime_dependencies( + *, + missing: List[str], + runtime_label: str, + use_conda: bool, + env_name: Optional[str], +) -> None: + print("\nError: CraftBot Python dependencies are missing.") + print(f"Runtime checked: {runtime_label}") + print("\nMissing imports:") + for package_name in missing: + print(f" - {package_name}") + + print( + "\nThis usually means CraftBot is running with a different Python " + "than the one used during install." + ) + print("\nFix:") + if use_conda and env_name: + print(" python install.py --conda") + print(f" conda run -n {env_name} python run.py") + else: + print(f" {sys.executable} install.py") + print(f" {sys.executable} run.py") + print("\nIf you installed CraftBot with another Python, start it with that Python.") + + +def ensure_runtime_dependencies( + *, + use_conda: bool, + env_name: Optional[str], + conda_command: str = "conda", +) -> None: + if getattr(sys, "frozen", False): + return + + missing, runtime_label = find_missing_runtime_dependencies( + use_conda=use_conda, + env_name=env_name, + conda_command=conda_command, + ) + if missing: + print_missing_runtime_dependencies( + missing=missing, + runtime_label=runtime_label, + use_conda=use_conda, + env_name=env_name, + ) + sys.exit(1) + + +def ensure_current_runtime_dependencies() -> None: + """Check imports for direct app.main usage with the current interpreter.""" + ensure_runtime_dependencies(use_conda=False, env_name=None) diff --git a/run.py b/run.py index 2fec0220..e7aebcf7 100644 --- a/run.py +++ b/run.py @@ -30,6 +30,8 @@ import atexit from typing import Tuple, Optional, Dict, Any, List +from app.runtime_preflight import ensure_runtime_dependencies + multiprocessing.freeze_support() # Configuration is loaded from settings.json via the agent startup @@ -119,15 +121,6 @@ def _bootstrap_frozen(): OMNIPARSER_ENV_NAME = "omni" OMNIPARSER_SERVER_URL = os.getenv("OMNIPARSER_BASE_URL", "http://localhost:7861") -RUNTIME_IMPORT_CHECKS = { - "requests": "requests", - "aiohttp": "aiohttp", - "websockets": "websockets", - "pymongo": "pymongo", - "pyyaml": "yaml", -} - - # ========================================== # TERMINAL COLORS (orange/white brand palette) # ========================================== @@ -1070,99 +1063,6 @@ def verify_env(env_name: str) -> bool: return False -def _runtime_import_command( - use_conda: bool, env_name: Optional[str], import_name: str -) -> Tuple[List[str], str]: - if use_conda and env_name: - return ( - [ - get_conda_command(), - "run", - "-n", - env_name, - "python", - "-c", - f"import {import_name}", - ], - f"conda environment '{env_name}'", - ) - return ([sys.executable, "-c", f"import {import_name}"], sys.executable) - - -def find_missing_runtime_dependencies( - *, - use_conda: bool, - env_name: Optional[str], - checks: Optional[Dict[str, str]] = None, -) -> Tuple[List[str], str]: - """Return missing core imports for the Python runtime that will run the agent.""" - if checks is None: - checks = RUNTIME_IMPORT_CHECKS - missing: List[str] = [] - runtime_label = ( - f"conda environment '{env_name}'" if use_conda and env_name else sys.executable - ) - - for package_name, import_name in checks.items(): - cmd, runtime_label = _runtime_import_command(use_conda, env_name, import_name) - try: - result = subprocess.run(cmd, capture_output=True, timeout=20) - except Exception: - missing.append(package_name) - continue - if result.returncode != 0: - missing.append(package_name) - - return missing, runtime_label or sys.executable - - -def print_missing_runtime_dependencies( - *, - missing: List[str], - runtime_label: str, - use_conda: bool, - env_name: Optional[str], -) -> None: - print("\nError: CraftBot Python dependencies are missing.") - print(f"Runtime checked: {runtime_label}") - print("\nMissing imports:") - for package_name in missing: - print(f" - {package_name}") - - print( - "\nThis usually means CraftBot is running with a different Python " - "than the one used during install." - ) - print("\nFix:") - if use_conda and env_name: - print(f" python install.py --conda") - print(f" conda run -n {env_name} python run.py") - else: - print(f" {sys.executable} install.py") - print(f" {sys.executable} run.py") - print("\nIf you installed CraftBot with another Python, start it with that Python.") - - -def ensure_runtime_dependencies( - *, use_conda: bool, env_name: Optional[str] -) -> None: - if getattr(sys, "frozen", False): - return - - missing, runtime_label = find_missing_runtime_dependencies( - use_conda=use_conda, - env_name=env_name, - ) - if missing: - print_missing_runtime_dependencies( - missing=missing, - runtime_label=runtime_label, - use_conda=use_conda, - env_name=env_name, - ) - sys.exit(1) - - # ========================================== # OMNIPARSER SERVER # ========================================== @@ -1355,7 +1255,11 @@ def launch_agent(env_name: Optional[str], conda_base: Optional[str], use_conda: print("Run 'python install.py' or 'python install.py --conda' first.\n") sys.exit(1) - ensure_runtime_dependencies(use_conda=use_conda, env_name=env_name) + ensure_runtime_dependencies( + use_conda=use_conda, + env_name=env_name, + conda_command=get_conda_command() if use_conda else "conda", + ) # Start OmniParser only if GUI mode and it was installed if gui_mode and gui_installed: diff --git a/tests/test_run_dependency_check.py b/tests/test_run_dependency_check.py index ee839181..018debb7 100644 --- a/tests/test_run_dependency_check.py +++ b/tests/test_run_dependency_check.py @@ -1,20 +1,25 @@ # -*- coding: utf-8 -*- import subprocess import sys +import textwrap -import run +from app import runtime_preflight def test_find_missing_runtime_dependencies_reports_current_interpreter(monkeypatch): seen_commands = [] - def fake_run(cmd, capture_output, timeout): + def fake_run(cmd, capture_output, text, timeout): seen_commands.append(cmd) - return subprocess.CompletedProcess(cmd, 1 if cmd[-1] == "import requests" else 0) + return subprocess.CompletedProcess( + cmd, + 0, + stdout='ignored import noise\n__CRAFTBOT_MISSING_RUNTIME_IMPORTS__["requests"]\n', + ) - monkeypatch.setattr(run.subprocess, "run", fake_run) + monkeypatch.setattr(runtime_preflight.subprocess, "run", fake_run) - missing, runtime_label = run.find_missing_runtime_dependencies( + missing, runtime_label = runtime_preflight.find_missing_runtime_dependencies( use_conda=False, env_name=None, checks={"requests": "requests", "aiohttp": "aiohttp"}, @@ -22,40 +27,42 @@ def fake_run(cmd, capture_output, timeout): assert missing == ["requests"] assert runtime_label == sys.executable - assert seen_commands == [ - [sys.executable, "-c", "import requests"], - [sys.executable, "-c", "import aiohttp"], - ] + assert len(seen_commands) == 1 + assert seen_commands[0][:2] == [sys.executable, "-c"] + assert "importlib.import_module(import_name)" in seen_commands[0][2] def test_find_missing_runtime_dependencies_checks_conda_env(monkeypatch): seen_commands = [] - monkeypatch.setattr(run, "get_conda_command", lambda: "conda") - - def fake_run(cmd, capture_output, timeout): + def fake_run(cmd, capture_output, text, timeout): seen_commands.append(cmd) - return subprocess.CompletedProcess(cmd, 0) + return subprocess.CompletedProcess( + cmd, + 0, + stdout="__CRAFTBOT_MISSING_RUNTIME_IMPORTS__[]\n", + ) - monkeypatch.setattr(run.subprocess, "run", fake_run) + monkeypatch.setattr(runtime_preflight.subprocess, "run", fake_run) - missing, runtime_label = run.find_missing_runtime_dependencies( + missing, runtime_label = runtime_preflight.find_missing_runtime_dependencies( use_conda=True, env_name="craftbot", checks={"requests": "requests"}, + conda_command="conda", ) assert missing == [] assert runtime_label == "conda environment 'craftbot'" - assert seen_commands == [ - ["conda", "run", "-n", "craftbot", "python", "-c", "import requests"] - ] + assert len(seen_commands) == 1 + assert seen_commands[0][:6] == ["conda", "run", "-n", "craftbot", "python", "-c"] + assert "importlib.import_module(import_name)" in seen_commands[0][6] def test_print_missing_runtime_dependencies_includes_fix(monkeypatch, capsys): monkeypatch.setattr(sys, "executable", "/opt/homebrew/bin/python3") - run.print_missing_runtime_dependencies( + runtime_preflight.print_missing_runtime_dependencies( missing=["requests", "aiohttp"], runtime_label="/opt/homebrew/bin/python3", use_conda=False, @@ -67,3 +74,49 @@ def test_print_missing_runtime_dependencies_includes_fix(monkeypatch, capsys): assert "aiohttp" in output assert "/opt/homebrew/bin/python3" in output assert "/opt/homebrew/bin/python3 install.py" in output + + +def test_runtime_preflight_does_not_require_provider_sdks(): + assert "openai" not in runtime_preflight.RUNTIME_IMPORT_CHECKS + assert "anthropic" not in runtime_preflight.RUNTIME_IMPORT_CHECKS + + +def test_app_main_runs_preflight_before_agent_core_import(): + code = textwrap.dedent( + """ + import importlib.abc + import sys + import types + + fake_preflight = types.ModuleType("app.runtime_preflight") + + def ensure_current_runtime_dependencies(): + raise SystemExit(77) + + fake_preflight.ensure_current_runtime_dependencies = ensure_current_runtime_dependencies + sys.modules["app.runtime_preflight"] = fake_preflight + + class BlockAgentCore(importlib.abc.MetaPathFinder): + def find_spec(self, fullname, path=None, target=None): + if fullname == "agent_core" or fullname.startswith("agent_core."): + raise AssertionError("agent_core imported before runtime preflight") + return None + + sys.meta_path.insert(0, BlockAgentCore()) + + try: + import app.main # noqa: F401 + except SystemExit as exc: + assert exc.code == 77 + else: + raise AssertionError("app.main did not run runtime preflight") + """ + ) + + result = subprocess.run( + [sys.executable, "-c", code], + text=True, + capture_output=True, + ) + + assert result.returncode == 0, result.stderr From 663db582e2f50307e09dcea27afa1bf9c6a80f27 Mon Sep 17 00:00:00 2001 From: namabeeru Date: Sat, 27 Jun 2026 09:11:42 +0900 Subject: [PATCH 4/8] Report service startup failures --- craftbot.py | 30 +++++++++++++++++++++++++++++ tests/test_craftbot_service.py | 35 ++++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+) create mode 100644 tests/test_craftbot_service.py diff --git a/craftbot.py b/craftbot.py index 8a3eeb7e..c4444fe4 100644 --- a/craftbot.py +++ b/craftbot.py @@ -322,6 +322,17 @@ def _remove_pid() -> None: pass +def _tail_log_lines(n: int = 30) -> str: + if not os.path.isfile(LOG_FILE): + return "" + try: + with open(LOG_FILE, "r", errors="replace") as f: + lines = f.readlines() + except Exception: + return "" + return "".join(lines[-n:]) + + def _is_running(pid: int) -> bool: """Return True if a process with the given PID is currently alive.""" if _PLATFORM == "win32": @@ -503,6 +514,25 @@ def cmd_start(extra_args: List[str]) -> None: # Parent closes its copy — the child process (run.py) keeps the fd open log_fh.close() _write_pid(proc.pid) + + # Catch immediate startup failures before reporting success. This surfaces + # wrong-Python dependency errors from run.py instead of leaving a stale PID. + try: + exit_code = proc.wait(timeout=8.0) + except subprocess.TimeoutExpired: + exit_code = None + + if exit_code is not None: + _remove_pid() + print( + f" {RED}✗{RESET} {WHITE}CraftBot failed to start{RESET} {DIM}exit {exit_code}{RESET}" + ) + log_tail = _tail_log_lines() + if log_tail: + print(f"\n{DIM}Last log lines:{RESET}\n{log_tail}", end="") + print(f"\nCheck logs: {sys.executable} craftbot.py logs") + return + print( f" {GREEN}▸{RESET} {WHITE}CRAFTBOT STARTED{RESET} {DIM}PID {proc.pid}{RESET}" ) diff --git a/tests/test_craftbot_service.py b/tests/test_craftbot_service.py new file mode 100644 index 00000000..b030a377 --- /dev/null +++ b/tests/test_craftbot_service.py @@ -0,0 +1,35 @@ +# -*- coding: utf-8 -*- +import subprocess + +import craftbot + + +class _ExitedProcess: + pid = 12345 + + def wait(self, timeout=None): + return 1 + + +def test_start_reports_immediate_child_failure(tmp_path, monkeypatch, capsys): + pid_file = tmp_path / "craftbot.pid" + log_file = tmp_path / "craftbot.log" + log_file.write_text("dependency failure\n", encoding="utf-8") + + monkeypatch.setattr(craftbot, "PID_FILE", str(pid_file)) + monkeypatch.setattr(craftbot, "LOG_FILE", str(log_file)) + monkeypatch.setattr(craftbot, "RUN_SCRIPT", str(tmp_path / "run.py")) + monkeypatch.setattr(craftbot, "_create_desktop_shortcut_unix", lambda: None) + monkeypatch.setattr(craftbot, "_open_browser_detached", lambda url: None) + + def fake_popen(*args, **kwargs): + return _ExitedProcess() + + monkeypatch.setattr(craftbot.subprocess, "Popen", fake_popen) + + craftbot.cmd_start([]) + + output = capsys.readouterr().out + assert "CraftBot failed to start" in output + assert "dependency failure" in output + assert not pid_file.exists() From 335d50f286b99a27bd0283cd176a53b5582f1d20 Mon Sep 17 00:00:00 2001 From: namabeeru Date: Sat, 27 Jun 2026 09:15:42 +0900 Subject: [PATCH 5/8] Make desktop shortcut start source service --- craftbot.py | 14 ++++++++++++-- tests/test_craftbot_service.py | 24 ++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/craftbot.py b/craftbot.py index c4444fe4..ec0f2728 100644 --- a/craftbot.py +++ b/craftbot.py @@ -63,6 +63,7 @@ def flush(self) -> None: sys.stderr = _NullIO() import os +import shlex import shutil import signal import subprocess @@ -796,9 +797,18 @@ def _create_desktop_shortcut_unix() -> None: return try: if _PLATFORM == "darwin": - # macOS does not support XDG .desktop files — create a double-clickable .command script + # macOS does not support XDG .desktop files — create a double-clickable + # .command script. In source mode, start the service instead of only + # opening the URL so the shortcut works after CraftBot is stopped. shortcut_path = os.path.join(desktop, "CraftBot.command") - content = f"#!/bin/sh\nopen '{BROWSER_URL}'\n" + if IS_FROZEN: + content = f"#!/bin/sh\nopen {shlex.quote(BROWSER_URL)}\n" + else: + content = ( + "#!/bin/sh\n" + f"cd {shlex.quote(BASE_DIR)} || exit 1\n" + f"exec {shlex.quote(_python_exe())} craftbot.py start\n" + ) with open(shortcut_path, "w") as f: f.write(content) os.chmod(shortcut_path, 0o755) diff --git a/tests/test_craftbot_service.py b/tests/test_craftbot_service.py index b030a377..5929e0c2 100644 --- a/tests/test_craftbot_service.py +++ b/tests/test_craftbot_service.py @@ -33,3 +33,27 @@ def fake_popen(*args, **kwargs): assert "CraftBot failed to start" in output assert "dependency failure" in output assert not pid_file.exists() + + +def test_macos_source_shortcut_starts_service(tmp_path, monkeypatch, capsys): + desktop = tmp_path / "Desktop" + desktop.mkdir() + base_dir = tmp_path / "CraftBot" + base_dir.mkdir() + + monkeypatch.setattr(craftbot, "_PLATFORM", "darwin") + monkeypatch.setattr(craftbot, "IS_FROZEN", False) + monkeypatch.setattr(craftbot, "BASE_DIR", str(base_dir)) + monkeypatch.setattr(craftbot, "_find_desktop", lambda: str(desktop)) + monkeypatch.setattr(craftbot, "_python_exe", lambda: "/usr/local/bin/python3.10") + + craftbot._create_desktop_shortcut_unix() + + shortcut = desktop / "CraftBot.command" + content = shortcut.read_text() + assert f"cd {base_dir}" in content + assert "exec /usr/local/bin/python3.10 craftbot.py start" in content + assert "open 'http://localhost:7925'" not in content + + output = capsys.readouterr().out + assert "Desktop shortcut created" in output From 130817ea0d9851a689cc5eb2f5d16deba9e5a044 Mon Sep 17 00:00:00 2001 From: namabeeru Date: Sat, 27 Jun 2026 09:18:39 +0900 Subject: [PATCH 6/8] Open running source service from shortcut --- craftbot.py | 6 +++++- tests/test_craftbot_service.py | 3 ++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/craftbot.py b/craftbot.py index ec0f2728..c19c705d 100644 --- a/craftbot.py +++ b/craftbot.py @@ -807,7 +807,11 @@ def _create_desktop_shortcut_unix() -> None: content = ( "#!/bin/sh\n" f"cd {shlex.quote(BASE_DIR)} || exit 1\n" - f"exec {shlex.quote(_python_exe())} craftbot.py start\n" + f"if curl -fsS {shlex.quote(BROWSER_URL)} >/dev/null 2>&1; then\n" + f" open {shlex.quote(BROWSER_URL)}\n" + "else\n" + f" exec {shlex.quote(_python_exe())} craftbot.py start\n" + "fi\n" ) with open(shortcut_path, "w") as f: f.write(content) diff --git a/tests/test_craftbot_service.py b/tests/test_craftbot_service.py index 5929e0c2..64c97d7b 100644 --- a/tests/test_craftbot_service.py +++ b/tests/test_craftbot_service.py @@ -52,8 +52,9 @@ def test_macos_source_shortcut_starts_service(tmp_path, monkeypatch, capsys): shortcut = desktop / "CraftBot.command" content = shortcut.read_text() assert f"cd {base_dir}" in content + assert "curl -fsS http://localhost:7925" in content + assert "open http://localhost:7925" in content assert "exec /usr/local/bin/python3.10 craftbot.py start" in content - assert "open 'http://localhost:7925'" not in content output = capsys.readouterr().out assert "Desktop shortcut created" in output From 4c9ffdf9c80a17242691b7fe959389d193baff22 Mon Sep 17 00:00:00 2001 From: namabeeru Date: Sat, 27 Jun 2026 09:40:10 +0900 Subject: [PATCH 7/8] Add launcher edge-case coverage --- craftbot.py | 26 ++++--- tests/test_craftbot_service.py | 111 ++++++++++++++++++++++++++--- tests/test_model_factory.py | 54 ++++++++------ tests/test_run_dependency_check.py | 38 ++++++++++ 4 files changed, 191 insertions(+), 38 deletions(-) diff --git a/craftbot.py b/craftbot.py index c19c705d..e00d66fa 100644 --- a/craftbot.py +++ b/craftbot.py @@ -454,8 +454,12 @@ def _poll_and_open() -> None: subprocess.Popen([python, "-c", poll_script], **kwargs) -def cmd_start(extra_args: List[str]) -> None: - """Start CraftBot as a detached background process.""" +def cmd_start(extra_args: List[str]) -> bool: + """Start CraftBot as a detached background process. + + Returns True once the service survives the early startup check; False when + launch fails before CraftBot can be used. + """ pid = _read_pid() if pid and _is_running(pid): cmd_stop() @@ -474,7 +478,7 @@ def cmd_start(extra_args: List[str]) -> None: installed = installed_exe_path() if not installed: print("Error: no installed agent found — run install first.") - return + return False cmd = [installed] + run_args else: python = _python_exe() @@ -510,7 +514,7 @@ def cmd_start(extra_args: List[str]) -> None: except FileNotFoundError as e: log_fh.close() print(f" {RED}✗{RESET} {WHITE}Could not launch CraftBot — {e}{RESET}") - return + return False # Parent closes its copy — the child process (run.py) keeps the fd open log_fh.close() @@ -532,7 +536,7 @@ def cmd_start(extra_args: List[str]) -> None: if log_tail: print(f"\n{DIM}Last log lines:{RESET}\n{log_tail}", end="") print(f"\nCheck logs: {sys.executable} craftbot.py logs") - return + return False print( f" {GREEN}▸{RESET} {WHITE}CRAFTBOT STARTED{RESET} {DIM}PID {proc.pid}{RESET}" @@ -549,6 +553,7 @@ def cmd_start(extra_args: List[str]) -> None: if open_browser: print(f" {DIM}░░{RESET} {ORANGE}{BROWSER_URL}{RESET}") _open_browser_detached(BROWSER_URL) + return True def cmd_stop() -> None: @@ -644,10 +649,11 @@ def cmd_logs(n: int = 50) -> None: print(f" {RED}✗{RESET} {WHITE}Error reading log: {e}{RESET}") -def cmd_restart(extra_args: List[str]) -> None: +def cmd_restart(extra_args: List[str]) -> bool: + """Restart CraftBot and return whether the new service started.""" cmd_stop() time.sleep(1) - cmd_start(extra_args) + return cmd_start(extra_args) # ─── Desktop shortcut ───────────────────────────────────────────────────────── @@ -1585,13 +1591,15 @@ def main() -> None: rest = args[1:] if command == "start": - cmd_start(rest) + if not cmd_start(rest): + sys.exit(1) elif command == "stop": cmd_stop() elif command == "restart": - cmd_restart(rest) + if not cmd_restart(rest): + sys.exit(1) elif command == "status": cmd_status() diff --git a/tests/test_craftbot_service.py b/tests/test_craftbot_service.py index 64c97d7b..efc8ca64 100644 --- a/tests/test_craftbot_service.py +++ b/tests/test_craftbot_service.py @@ -1,6 +1,9 @@ # -*- coding: utf-8 -*- +import shlex import subprocess +import sys +import pytest import craftbot @@ -11,50 +14,142 @@ def wait(self, timeout=None): return 1 +class _RunningProcess: + pid = 23456 + + def wait(self, timeout=None): + raise subprocess.TimeoutExpired("craftbot", timeout) + + def test_start_reports_immediate_child_failure(tmp_path, monkeypatch, capsys): pid_file = tmp_path / "craftbot.pid" log_file = tmp_path / "craftbot.log" log_file.write_text("dependency failure\n", encoding="utf-8") + events = [] monkeypatch.setattr(craftbot, "PID_FILE", str(pid_file)) monkeypatch.setattr(craftbot, "LOG_FILE", str(log_file)) monkeypatch.setattr(craftbot, "RUN_SCRIPT", str(tmp_path / "run.py")) - monkeypatch.setattr(craftbot, "_create_desktop_shortcut_unix", lambda: None) - monkeypatch.setattr(craftbot, "_open_browser_detached", lambda url: None) + monkeypatch.setattr( + craftbot, "_create_desktop_shortcut_unix", lambda: events.append("shortcut") + ) + monkeypatch.setattr( + craftbot, "_open_browser_detached", lambda url: events.append("browser") + ) def fake_popen(*args, **kwargs): return _ExitedProcess() monkeypatch.setattr(craftbot.subprocess, "Popen", fake_popen) - craftbot.cmd_start([]) + assert craftbot.cmd_start([]) is False output = capsys.readouterr().out assert "CraftBot failed to start" in output assert "dependency failure" in output assert not pid_file.exists() + assert events == [] + + +def test_start_reports_success_for_long_running_child(tmp_path, monkeypatch, capsys): + pid_file = tmp_path / "craftbot.pid" + log_file = tmp_path / "craftbot.log" + events = [] + monkeypatch.setattr(craftbot, "PID_FILE", str(pid_file)) + monkeypatch.setattr(craftbot, "LOG_FILE", str(log_file)) + monkeypatch.setattr(craftbot, "RUN_SCRIPT", str(tmp_path / "run.py")) + monkeypatch.setattr( + craftbot, "_create_desktop_shortcut_unix", lambda: events.append("shortcut") + ) + monkeypatch.setattr( + craftbot, "_open_browser_detached", lambda url: events.append(("browser", url)) + ) -def test_macos_source_shortcut_starts_service(tmp_path, monkeypatch, capsys): + def fake_popen(*args, **kwargs): + return _RunningProcess() + + monkeypatch.setattr(craftbot.subprocess, "Popen", fake_popen) + + assert craftbot.cmd_start([]) is True + + output = capsys.readouterr().out + assert "CRAFTBOT STARTED" in output + assert pid_file.read_text() == "23456" + assert events == ["shortcut", ("browser", craftbot.BROWSER_URL)] + + +def test_macos_source_shortcut_opens_or_starts_service(tmp_path, monkeypatch, capsys): desktop = tmp_path / "Desktop" desktop.mkdir() - base_dir = tmp_path / "CraftBot" + base_dir = tmp_path / "Craft Bot" base_dir.mkdir() + python_exe = "/Applications/Python 3.10/bin/python3.10" monkeypatch.setattr(craftbot, "_PLATFORM", "darwin") monkeypatch.setattr(craftbot, "IS_FROZEN", False) monkeypatch.setattr(craftbot, "BASE_DIR", str(base_dir)) monkeypatch.setattr(craftbot, "_find_desktop", lambda: str(desktop)) - monkeypatch.setattr(craftbot, "_python_exe", lambda: "/usr/local/bin/python3.10") + monkeypatch.setattr(craftbot, "_python_exe", lambda: python_exe) craftbot._create_desktop_shortcut_unix() shortcut = desktop / "CraftBot.command" content = shortcut.read_text() - assert f"cd {base_dir}" in content + assert f"cd {shlex.quote(str(base_dir))}" in content assert "curl -fsS http://localhost:7925" in content assert "open http://localhost:7925" in content - assert "exec /usr/local/bin/python3.10 craftbot.py start" in content + assert f"exec {shlex.quote(python_exe)} craftbot.py start" in content output = capsys.readouterr().out assert "Desktop shortcut created" in output + + +def test_macos_frozen_shortcut_only_opens_url(tmp_path, monkeypatch): + desktop = tmp_path / "Desktop" + desktop.mkdir() + + monkeypatch.setattr(craftbot, "_PLATFORM", "darwin") + monkeypatch.setattr(craftbot, "IS_FROZEN", True) + monkeypatch.setattr(craftbot, "_find_desktop", lambda: str(desktop)) + + craftbot._create_desktop_shortcut_unix() + + content = (desktop / "CraftBot.command").read_text() + assert content == "#!/bin/sh\nopen http://localhost:7925\n" + assert "craftbot.py start" not in content + + +def test_linux_shortcut_remains_url_opener(tmp_path, monkeypatch): + desktop = tmp_path / "Desktop" + desktop.mkdir() + + monkeypatch.setattr(craftbot, "_PLATFORM", "linux") + monkeypatch.setattr(craftbot, "_find_desktop", lambda: str(desktop)) + + craftbot._create_desktop_shortcut_unix() + + content = (desktop / "CraftBot.desktop").read_text() + assert "Exec=" in content + assert "http://localhost:7925" in content + assert "craftbot.py start" not in content + + +def test_cli_start_exits_nonzero_when_start_fails(monkeypatch): + monkeypatch.setattr(sys, "argv", ["craftbot.py", "start"]) + monkeypatch.setattr(craftbot, "cmd_start", lambda args: False) + + with pytest.raises(SystemExit) as exc: + craftbot.main() + + assert exc.value.code == 1 + + +def test_cli_restart_exits_nonzero_when_restart_fails(monkeypatch): + monkeypatch.setattr(sys, "argv", ["craftbot.py", "restart"]) + monkeypatch.setattr(craftbot, "cmd_restart", lambda args: False) + + with pytest.raises(SystemExit) as exc: + craftbot.main() + + assert exc.value.code == 1 diff --git a/tests/test_model_factory.py b/tests/test_model_factory.py index 2cfb06a0..298cd8dd 100644 --- a/tests/test_model_factory.py +++ b/tests/test_model_factory.py @@ -56,43 +56,55 @@ def test_importing_model_factory_does_not_require_provider_sdks(): assert result.returncode == 0, result.stderr -def test_deferred_openai_compatible_provider_does_not_require_openai_sdk(): +def test_deferred_openai_compatible_providers_do_not_require_openai_sdk(): result = _run_with_blocked_sdks( """ from agent_core.core.models.factory import ModelFactory from agent_core.core.models.types import InterfaceType - ctx = ModelFactory.create( - provider="deepseek", - interface=InterfaceType.LLM, - deferred=True, - ) - assert ctx["initialized"] is False - assert ctx["client"] is None + for provider in ("deepseek", "grok", "moonshot", "minimax", "openrouter"): + ctx = ModelFactory.create( + provider=provider, + interface=InterfaceType.LLM, + deferred=True, + ) + assert ctx["initialized"] is False + assert ctx["client"] is None """ ) assert result.returncode == 0, result.stderr -def test_openai_compatible_provider_reports_missing_openai_sdk(): +def test_openai_compatible_providers_report_missing_openai_sdk(): result = _run_with_blocked_sdks( """ from agent_core.core.models.factory import ModelFactory from agent_core.core.models.types import InterfaceType - try: - ModelFactory.create( - provider="deepseek", - interface=InterfaceType.LLM, - api_key="deepseek-key", - ) - except ImportError as exc: - message = str(exc) - assert "openai package is required" in message - assert "DeepSeek" in message - else: - raise AssertionError("expected missing OpenAI SDK to raise ImportError") + providers = { + "deepseek": "DeepSeek", + "grok": "Grok", + "moonshot": "Moonshot", + "minimax": "MiniMax", + "openrouter": "OpenRouter", + } + + for provider, display in providers.items(): + try: + ModelFactory.create( + provider=provider, + interface=InterfaceType.LLM, + api_key=f"{provider}-key", + ) + except ImportError as exc: + message = str(exc) + assert "openai package is required" in message + assert display in message + else: + raise AssertionError( + f"expected missing OpenAI SDK to raise ImportError for {provider}" + ) """ ) diff --git a/tests/test_run_dependency_check.py b/tests/test_run_dependency_check.py index 018debb7..4fdc0ddc 100644 --- a/tests/test_run_dependency_check.py +++ b/tests/test_run_dependency_check.py @@ -59,6 +59,44 @@ def fake_run(cmd, capture_output, text, timeout): assert "importlib.import_module(import_name)" in seen_commands[0][6] +def test_find_missing_runtime_dependencies_marks_all_missing_on_probe_failure( + monkeypatch, +): + def fake_run(cmd, capture_output, text, timeout): + return subprocess.CompletedProcess(cmd, 1, stdout="import failed\n") + + monkeypatch.setattr(runtime_preflight.subprocess, "run", fake_run) + + checks = {"requests": "requests", "aiohttp": "aiohttp"} + missing, runtime_label = runtime_preflight.find_missing_runtime_dependencies( + use_conda=False, + env_name=None, + checks=checks, + ) + + assert missing == ["requests", "aiohttp"] + assert runtime_label == sys.executable + + +def test_find_missing_runtime_dependencies_marks_all_missing_without_sentinel( + monkeypatch, +): + def fake_run(cmd, capture_output, text, timeout): + return subprocess.CompletedProcess(cmd, 0, stdout="third-party import noise\n") + + monkeypatch.setattr(runtime_preflight.subprocess, "run", fake_run) + + checks = {"requests": "requests", "aiohttp": "aiohttp"} + missing, runtime_label = runtime_preflight.find_missing_runtime_dependencies( + use_conda=False, + env_name=None, + checks=checks, + ) + + assert missing == ["requests", "aiohttp"] + assert runtime_label == sys.executable + + def test_print_missing_runtime_dependencies_includes_fix(monkeypatch, capsys): monkeypatch.setattr(sys, "executable", "/opt/homebrew/bin/python3") From b8d046fda465a3631ffc927063021843e03075f0 Mon Sep 17 00:00:00 2001 From: namabeeru Date: Sat, 27 Jun 2026 10:19:17 +0900 Subject: [PATCH 8/8] Harden launcher startup checks --- app/runtime_preflight.py | 13 +++++- craftbot.py | 46 ++++++++++++++------ run.py | 6 ++- tests/test_craftbot_service.py | 67 ++++++++++++++++++++++++++++++ tests/test_run_dependency_check.py | 36 ++++++++++++++++ 5 files changed, 153 insertions(+), 15 deletions(-) diff --git a/app/runtime_preflight.py b/app/runtime_preflight.py index aadba714..6925b971 100644 --- a/app/runtime_preflight.py +++ b/app/runtime_preflight.py @@ -2,11 +2,13 @@ """Runtime dependency checks that can run before dependency-heavy imports.""" import json +import os import subprocess import sys from typing import Dict, List, Optional, Tuple +_PREFLIGHT_OK_ENV = "CRAFTBOT_RUNTIME_PREFLIGHT_OK" _MISSING_SENTINEL = "__CRAFTBOT_MISSING_RUNTIME_IMPORTS__" RUNTIME_IMPORT_CHECKS = { @@ -94,7 +96,10 @@ def find_missing_runtime_dependencies( for line in reversed(result.stdout.splitlines()): if line.startswith(_MISSING_SENTINEL): - return json.loads(line[len(_MISSING_SENTINEL) :]), runtime_label + try: + return json.loads(line[len(_MISSING_SENTINEL) :]), runtime_label + except json.JSONDecodeError: + return list(checks), runtime_label return list(checks), runtime_label @@ -149,6 +154,12 @@ def ensure_runtime_dependencies( sys.exit(1) +def mark_runtime_dependencies_checked() -> None: + os.environ[_PREFLIGHT_OK_ENV] = "1" + + def ensure_current_runtime_dependencies() -> None: """Check imports for direct app.main usage with the current interpreter.""" + if os.environ.get(_PREFLIGHT_OK_ENV) == "1": + return ensure_runtime_dependencies(use_conda=False, env_name=None) diff --git a/craftbot.py b/craftbot.py index e00d66fa..aa7f77aa 100644 --- a/craftbot.py +++ b/craftbot.py @@ -184,6 +184,7 @@ def installed_exe_path() -> Optional[str]: SYSTEMD_SERVICE = "craftbot" # Linux systemd service name LAUNCHD_LABEL = "com.craftbot.agent" # macOS launchd label BROWSER_URL = "http://localhost:7925" +BACKEND_URL = "http://localhost:7926" SHORTCUT_NAME = "CraftBot.lnk" # Bundled icons live in sys._MEIPASS in frozen mode (PyInstaller's runtime # extract dir) and alongside craftbot.py in source mode. _ensure_ico() copies @@ -323,17 +324,32 @@ def _remove_pid() -> None: pass -def _tail_log_lines(n: int = 30) -> str: +def _tail_log_lines(n: int = 30, start_offset: int = 0) -> str: if not os.path.isfile(LOG_FILE): return "" try: with open(LOG_FILE, "r", errors="replace") as f: + if start_offset: + f.seek(start_offset) lines = f.readlines() except Exception: return "" return "".join(lines[-n:]) +def _wait_for_startup_exit( + proc, timeout: float = 8.0, ready_log_offset: int = 0 +) -> Optional[int]: + deadline = time.time() + timeout + while time.time() < deadline: + try: + return proc.wait(timeout=0.1) + except subprocess.TimeoutExpired: + if "CRAFTBOT IS READY" in _tail_log_lines(80, ready_log_offset): + return None + return None + + def _is_running(pid: int) -> bool: """Return True if a process with the given PID is currently alive.""" if _PLATFORM == "win32": @@ -495,6 +511,7 @@ def cmd_start(extra_args: List[str]) -> bool: log_fh.write(f"Command: {' '.join(cmd)}\n") log_fh.write(f"{'=' * 60}\n") log_fh.flush() + ready_log_offset = log_fh.tell() env = os.environ.copy() env["PYTHONIOENCODING"] = "utf-8" @@ -522,10 +539,7 @@ def cmd_start(extra_args: List[str]) -> bool: # Catch immediate startup failures before reporting success. This surfaces # wrong-Python dependency errors from run.py instead of leaving a stale PID. - try: - exit_code = proc.wait(timeout=8.0) - except subprocess.TimeoutExpired: - exit_code = None + exit_code = _wait_for_startup_exit(proc, ready_log_offset=ready_log_offset) if exit_code is not None: _remove_pid() @@ -813,7 +827,8 @@ def _create_desktop_shortcut_unix() -> None: content = ( "#!/bin/sh\n" f"cd {shlex.quote(BASE_DIR)} || exit 1\n" - f"if curl -fsS {shlex.quote(BROWSER_URL)} >/dev/null 2>&1; then\n" + f"if curl -fsS {shlex.quote(BROWSER_URL)} >/dev/null 2>&1 " + f"&& curl -fsS {shlex.quote(BACKEND_URL)} >/dev/null 2>&1; then\n" f" open {shlex.quote(BROWSER_URL)}\n" "else\n" f" exec {shlex.quote(_python_exe())} craftbot.py start\n" @@ -1255,10 +1270,11 @@ def _full_install_frozen( )(run_args) # 6. Start the service via the extracted agent EXE - cmd_start(extra_args) + if not cmd_start(extra_args): + raise RuntimeError("CraftBot installed but failed to start.") -def cmd_install(extra_args: List[str]) -> None: +def cmd_install(extra_args: List[str]) -> bool: """Install dependencies (source mode) or copy-and-register (frozen mode), then start the service.""" if IS_FROZEN: @@ -1269,7 +1285,7 @@ def cmd_install(extra_args: List[str]) -> None: target_dir = default_install_location() print(f" {ORANGE}▸{RESET} {WHITE}Installing CraftBot to {target_dir}{RESET}") _full_install_frozen(target_dir, extra_args) - return + return True _warn_path_issues() # ── Step 1: Install dependencies via install.py ──────────────────────── @@ -1291,7 +1307,7 @@ def cmd_install(extra_args: List[str]) -> None: print( f" {DIM}Run 'python install.py' directly to see the full error.{RESET}" ) - return + return False # Verify critical packages are actually importable with this interpreter. # install.py may exit 0 while packages ended up in a different site-packages. @@ -1307,7 +1323,7 @@ def cmd_install(extra_args: List[str]) -> None: print( f" {DIM}Run 'python install.py' to reinstall with this Python.{RESET}" ) - return + return False print() else: print(f" {DIM}(install.py not found — skipping dependency install){RESET}\n") @@ -1331,13 +1347,16 @@ def cmd_install(extra_args: List[str]) -> None: # ── Step 3: Start the service now ────────────────────────────────────── _retro_step(3, 3, "Starting CraftBot") - cmd_start(extra_args) + if not cmd_start(extra_args): + print(f"\n {RED}✗{RESET} {WHITE}CraftBot failed to start.{RESET}") + return False print(f"\n {GREEN}▸{RESET} {WHITE}CRAFTBOT IS RUNNING IN THE BACKGROUND{RESET}") print(f" {DIM}░░{RESET} {ORANGE}{BROWSER_URL}{RESET}") print("You can close this window now.") time.sleep(2) _close_console_window() + return True def _remove_desktop_shortcut() -> None: @@ -1615,7 +1634,8 @@ def main() -> None: cmd_logs(n) elif command == "install": - cmd_install(rest) + if not cmd_install(rest): + sys.exit(1) elif command == "uninstall": cmd_uninstall() diff --git a/run.py b/run.py index e7aebcf7..ca37ed3b 100644 --- a/run.py +++ b/run.py @@ -30,7 +30,10 @@ import atexit from typing import Tuple, Optional, Dict, Any, List -from app.runtime_preflight import ensure_runtime_dependencies +from app.runtime_preflight import ( + ensure_runtime_dependencies, + mark_runtime_dependencies_checked, +) multiprocessing.freeze_support() @@ -1260,6 +1263,7 @@ def launch_agent(env_name: Optional[str], conda_base: Optional[str], use_conda: env_name=env_name, conda_command=get_conda_command() if use_conda else "conda", ) + mark_runtime_dependencies_checked() # Start OmniParser only if GUI mode and it was installed if gui_mode and gui_installed: diff --git a/tests/test_craftbot_service.py b/tests/test_craftbot_service.py index efc8ca64..c71c96b4 100644 --- a/tests/test_craftbot_service.py +++ b/tests/test_craftbot_service.py @@ -21,6 +21,19 @@ def wait(self, timeout=None): raise subprocess.TimeoutExpired("craftbot", timeout) +class _DelayedFailureProcess: + pid = 34567 + + def __init__(self): + self.calls = 0 + + def wait(self, timeout=None): + self.calls += 1 + if self.calls == 1: + raise subprocess.TimeoutExpired("craftbot", timeout) + return 1 + + def test_start_reports_immediate_child_failure(tmp_path, monkeypatch, capsys): pid_file = tmp_path / "craftbot.pid" log_file = tmp_path / "craftbot.log" @@ -67,6 +80,8 @@ def test_start_reports_success_for_long_running_child(tmp_path, monkeypatch, cap ) def fake_popen(*args, **kwargs): + kwargs["stdout"].write(" ▸ CRAFTBOT IS READY\n") + kwargs["stdout"].flush() return _RunningProcess() monkeypatch.setattr(craftbot.subprocess, "Popen", fake_popen) @@ -79,6 +94,37 @@ def fake_popen(*args, **kwargs): assert events == ["shortcut", ("browser", craftbot.BROWSER_URL)] +def test_start_ignores_stale_ready_marker_when_child_exits( + tmp_path, monkeypatch, capsys +): + pid_file = tmp_path / "craftbot.pid" + log_file = tmp_path / "craftbot.log" + log_file.write_text("old run\nCRAFTBOT IS READY\n", encoding="utf-8") + events = [] + + monkeypatch.setattr(craftbot, "PID_FILE", str(pid_file)) + monkeypatch.setattr(craftbot, "LOG_FILE", str(log_file)) + monkeypatch.setattr(craftbot, "RUN_SCRIPT", str(tmp_path / "run.py")) + monkeypatch.setattr( + craftbot, "_create_desktop_shortcut_unix", lambda: events.append("shortcut") + ) + monkeypatch.setattr( + craftbot, "_open_browser_detached", lambda url: events.append(("browser", url)) + ) + + def fake_popen(*args, **kwargs): + return _DelayedFailureProcess() + + monkeypatch.setattr(craftbot.subprocess, "Popen", fake_popen) + + assert craftbot.cmd_start([]) is False + + output = capsys.readouterr().out + assert "CraftBot failed to start" in output + assert not pid_file.exists() + assert events == [] + + def test_macos_source_shortcut_opens_or_starts_service(tmp_path, monkeypatch, capsys): desktop = tmp_path / "Desktop" desktop.mkdir() @@ -98,6 +144,7 @@ def test_macos_source_shortcut_opens_or_starts_service(tmp_path, monkeypatch, ca content = shortcut.read_text() assert f"cd {shlex.quote(str(base_dir))}" in content assert "curl -fsS http://localhost:7925" in content + assert "curl -fsS http://localhost:7926" in content assert "open http://localhost:7925" in content assert f"exec {shlex.quote(python_exe)} craftbot.py start" in content @@ -153,3 +200,23 @@ def test_cli_restart_exits_nonzero_when_restart_fails(monkeypatch): craftbot.main() assert exc.value.code == 1 + + +def test_source_install_returns_false_when_service_start_fails( + tmp_path, monkeypatch, capsys +): + monkeypatch.setattr(craftbot, "IS_FROZEN", False) + monkeypatch.setattr(craftbot, "BASE_DIR", str(tmp_path)) + monkeypatch.setattr(craftbot, "_PLATFORM", "darwin") + monkeypatch.setattr(craftbot, "_is_installed", lambda: True) + monkeypatch.setattr(craftbot, "cmd_start", lambda args: False) + monkeypatch.setattr( + craftbot, + "_close_console_window", + lambda: (_ for _ in ()).throw(AssertionError("should not close")), + ) + + assert craftbot.cmd_install([]) is False + + output = capsys.readouterr().out + assert "CraftBot failed to start" in output diff --git a/tests/test_run_dependency_check.py b/tests/test_run_dependency_check.py index 4fdc0ddc..7275d382 100644 --- a/tests/test_run_dependency_check.py +++ b/tests/test_run_dependency_check.py @@ -97,6 +97,42 @@ def fake_run(cmd, capture_output, text, timeout): assert runtime_label == sys.executable +def test_find_missing_runtime_dependencies_marks_all_missing_on_malformed_sentinel( + monkeypatch, +): + def fake_run(cmd, capture_output, text, timeout): + return subprocess.CompletedProcess( + cmd, + 0, + stdout="__CRAFTBOT_MISSING_RUNTIME_IMPORTS__not-json\n", + ) + + monkeypatch.setattr(runtime_preflight.subprocess, "run", fake_run) + + checks = {"requests": "requests", "aiohttp": "aiohttp"} + missing, runtime_label = runtime_preflight.find_missing_runtime_dependencies( + use_conda=False, + env_name=None, + checks=checks, + ) + + assert missing == ["requests", "aiohttp"] + assert runtime_label == sys.executable + + +def test_current_runtime_preflight_skips_when_launcher_already_checked( + monkeypatch, +): + monkeypatch.setenv("CRAFTBOT_RUNTIME_PREFLIGHT_OK", "1") + + def fail_run(*args, **kwargs): + raise AssertionError("preflight should not run twice") + + monkeypatch.setattr(runtime_preflight.subprocess, "run", fail_run) + + runtime_preflight.ensure_current_runtime_dependencies() + + def test_print_missing_runtime_dependencies_includes_fix(monkeypatch, capsys): monkeypatch.setattr(sys, "executable", "/opt/homebrew/bin/python3")