diff --git a/docs/advanced/tools/tesseract_ocr_tool_use.md b/docs/advanced/tools/tesseract_ocr_tool_use.md index 892ccce3..c9aefbec 100644 --- a/docs/advanced/tools/tesseract_ocr_tool_use.md +++ b/docs/advanced/tools/tesseract_ocr_tool_use.md @@ -29,10 +29,10 @@ A tool YAML file supports the following fields: | `name` | Yes | Unique identifier for the tool | | `tool_type` | Yes | `internal` (runs in sandbox) or `external` (runs on host) | | `description` | Yes | Short description of the tool | -| `install_commands` | No | Shell commands to install the tool. Executed when the Environment is set up | -| `verify_commands` | No | Commands to confirm installation succeeded | -| `setup_commands` | No | Commands executed after code is mounted, before the LLM runs | -| `uninstall_commands` | No | Cleanup commands run during environment teardown. Can be omitted if the environment is not reused | +| `install_commands` | No | Shell commands to install the tool. Executed when the Environment is set up. A list, or a map of [distro-specific commands](#distro-specific-commands) | +| `verify_commands` | No | Commands to confirm installation succeeded. A list or a distro map | +| `setup_commands` | No | Commands executed after code is mounted, before the LLM runs. A list or a distro map | +| `uninstall_commands` | No | Cleanup commands run during environment teardown. Can be omitted if the environment is not reused. A list or a distro map | | `env_variables` | No | Environment variables to copy from the host into the sandbox | | `files_to_copy` | No | Files to copy into the environment with explicit permissions | | `parameters` | No | Parameter schema exposed to the LLM | @@ -50,6 +50,31 @@ Following the table of fields above, create a file called `tesseract_ocr.yaml`: Because `tool_type` is `internal`, every command in `install_commands` executes **inside the Docker sandbox**. The `apt-get install` never touches your host — even if it fails, only the disposable container is affected. +### Distro-specific commands + +`apt-get` only exists on Debian-based sandboxes, so the example keys its `install_commands` by distro. Each `*_commands` field accepts either form: + +- **A list**: generic commands that run on every distro. +- **A map**: keys are distro IDs, as in the sandbox's `/etc/os-release` (for example `debian` or `azurelinux`), and/or `generic`. + +For a map, Microbots reads the sandbox's `/etc/os-release` and picks the first entry that matches its `ID`, then its `ID_LIKE` entries (so `debian` also covers Ubuntu), and finally `generic`. + +- **Known distro, no match:** installation fails with `UnsupportedDistroError` before any command runs. +- **Distro can't be detected:** Microbots logs a warning and runs `generic`, or the first entry in the map. If those commands don't suit the image, the failing command reports the problem. + +```yaml +install_commands: + debian: + - apt-get update + - apt-get install -y tesseract-ocr + azurelinux: + - tdnf install -y tesseract +verify_commands: # a list applies to every distro + - tesseract --version +``` + +External tools resolve the map against the host's `/etc/os-release`. + The `usage_instructions_to_llm` block is injected verbatim into the LLM's system prompt. Write it as if you are explaining the tool to a colleague who has shell access but has never used it before. Since the Environment will be reaped once the job is over, it is overkill to uninstall the tool before cleaning up. So `uninstall_commands` is purposefully left out. diff --git a/docs/examples/tesseract_ocr_tool_use/tesseract_ocr.yaml b/docs/examples/tesseract_ocr_tool_use/tesseract_ocr.yaml index 224c609e..a6c497ef 100644 --- a/docs/examples/tesseract_ocr_tool_use/tesseract_ocr.yaml +++ b/docs/examples/tesseract_ocr_tool_use/tesseract_ocr.yaml @@ -3,9 +3,10 @@ tool_type: internal description: Tesseract OCR tool for text recognition install_commands: - - rm -f /etc/apt/sources.list.d/yarn.list - - apt-get update - - apt-get install -y tesseract-ocr + debian: + - rm -f /etc/apt/sources.list.d/yarn.list + - apt-get update + - apt-get install -y tesseract-ocr usage_instructions_to_llm: | This tool uses Tesseract OCR to recognize text from images. To use this tool, provide the path to the image file you want to process. The tool will return the recognized text from the image. diff --git a/src/microbots/environment/Environment.py b/src/microbots/environment/Environment.py index 68e34a34..86dfe660 100644 --- a/src/microbots/environment/Environment.py +++ b/src/microbots/environment/Environment.py @@ -2,6 +2,8 @@ from typing import Optional from dataclasses import dataclass +from microbots.utils.os_release import distro_ids, parse_os_release + @dataclass class CmdReturn: stdout: str @@ -10,6 +12,9 @@ class CmdReturn: class Environment(ABC): + # Cached result of get_distro_ids(); None until /etc/os-release has been read. + _distro_ids: Optional[list[str]] = None + @abstractmethod def start(self): pass @@ -28,6 +33,25 @@ def execute_privileged(self, command: str, timeout: Optional[int] = 300, sensiti Override this one while implementing custom Environment subclasses.""" return self.execute(command, timeout=timeout, sensitive=sensitive) + def get_distro_ids(self) -> list[str]: + """Identify the environment's Linux distribution from ``/etc/os-release``. + + Tools use these identifiers to pick distro-specific commands. The + result is cached after the first successful lookup. + + Returns + ------- + list[str] + ``ID`` followed by the ``ID_LIKE`` entries (for example + ``["ubuntu", "debian"]``), or an empty list if unknown. + """ + if self._distro_ids is None: + ret = self.execute_privileged("cat /etc/os-release") + if ret.return_code != 0: + return [] + self._distro_ids = distro_ids(parse_os_release(ret.stdout)) + return list(self._distro_ids) + def copy_to_container(self, src_path: str, dest_path: str) -> bool: raise NotImplementedError( f"{self.__class__.__name__} does not support copying files to container. " diff --git a/src/microbots/tools/external_tool.py b/src/microbots/tools/external_tool.py index 26bd0938..cd5496f7 100644 --- a/src/microbots/tools/external_tool.py +++ b/src/microbots/tools/external_tool.py @@ -7,6 +7,7 @@ from microbots.tools.tool import TOOLTYPE, ToolAbstract, EnvFileCopies from microbots.environment.Environment import Environment +from microbots.utils.os_release import host_distro_ids logger = logging.getLogger(" 🔧 ExternalTool") @@ -110,7 +111,8 @@ def install_tool(self, env: Environment): """Install the tool by running install commands on the **host**. Files listed in ``files_to_copy`` are also copied to the host.""" logger.debug("Installing external tool on host: %s", self.name) - for command in self.install_commands or []: + distro_ids = host_distro_ids() + for command in self.resolve_commands("install", distro_ids): result = self._run_host_command(command) if result.returncode != 0: logger.error( @@ -128,7 +130,8 @@ def install_tool(self, env: Environment): def verify_tool_installation(self, env: Environment): """Verify the tool by running verify commands on the **host**.""" logger.debug("Verifying installation of external tool: %s", self.name) - for command in self.verify_commands or []: + distro_ids = host_distro_ids() + for command in self.resolve_commands("verify", distro_ids): result = self._run_host_command(command) if result.returncode != 0: logger.error( @@ -150,7 +153,8 @@ def setup_tool(self, env: Environment): """ self._verify_env_variables() - for command in self.setup_commands or []: + distro_ids = host_distro_ids() + for command in self.resolve_commands("setup", distro_ids): result = self._run_host_command(command) if result.returncode != 0: logger.error( @@ -181,7 +185,8 @@ def uninstall_tool(self, env: Environment): dest, self.name, ) - for command in self.uninstall_commands or []: + distro_ids = host_distro_ids() + for command in self.resolve_commands("uninstall", distro_ids): result = self._run_host_command(command) if result.returncode != 0: logger.error( diff --git a/src/microbots/tools/internal_tool.py b/src/microbots/tools/internal_tool.py index 76a3a6f5..fa5e7309 100644 --- a/src/microbots/tools/internal_tool.py +++ b/src/microbots/tools/internal_tool.py @@ -109,7 +109,8 @@ def _copy_file_to_env(env: Environment, file_copy: EnvFileCopies): def install_tool(self, env: Environment): logger.debug("Installing Internal tool: %s", self.name) - for command in self.install_commands: + distro_ids = env.get_distro_ids() + for command in self.resolve_commands("install", distro_ids): output = env.execute_privileged(command) if output.return_code != 0: logger.error( @@ -126,7 +127,8 @@ def install_tool(self, env: Environment): def verify_tool_installation(self, env: Environment): logger.debug("Verifying installation of tool: %s", self.name) - for command in self.verify_commands: + distro_ids = env.get_distro_ids() + for command in self.resolve_commands("verify", distro_ids): output = env.execute(command) if output.return_code != 0: logger.error( @@ -146,7 +148,8 @@ def setup_tool(self, env: Environment): self._copy_env_variables(env) self._copy_files(env) - for command in self.setup_commands: + distro_ids = env.get_distro_ids() + for command in self.resolve_commands("setup", distro_ids): output = env.execute(command) if output.return_code != 0: logger.error( @@ -175,7 +178,8 @@ def uninstall_tool(self, env): f"Failed to remove copied file in container {file_copy.dest} during uninstallation of tool {self.name}. Output: {output}" ) - for command in self.uninstall_commands: + distro_ids = env.get_distro_ids() + for command in self.resolve_commands("uninstall", distro_ids): output = env.execute_privileged(command) if output.return_code != 0: logger.error( diff --git a/src/microbots/tools/tool.py b/src/microbots/tools/tool.py index 6b8351ff..7e16e540 100644 --- a/src/microbots/tools/tool.py +++ b/src/microbots/tools/tool.py @@ -1,5 +1,7 @@ from abc import ABC, abstractmethod -from typing import Optional, List +from collections.abc import Sequence +from typing import Dict, Optional, List, Union +from pydantic import field_validator from pydantic.dataclasses import dataclass, Field import logging from enum import Enum @@ -11,6 +13,19 @@ logger = logging.getLogger(" 🔧 Tool") +# Key of a command map whose commands apply to any distro without its own entry. +GENERIC_COMMANDS_KEY = "generic" + +# A command field is either a list (generic: runs on every distro) or a map +# from distro ID (as in /etc/os-release, e.g. "debian", "azurelinux") and/or +# "generic" to the commands for that distro. +ToolCommands = Union[List[str], Dict[str, List[str]]] + + +class UnsupportedDistroError(RuntimeError): + """Raised when a tool defines no commands for the target distro.""" + + class TOOLTYPE(str, Enum): INTERNAL = "internal" EXTERNAL = "external" @@ -63,7 +78,9 @@ class ToolAbstract(ABC): # while commands for external tools will be executed in the current environment (Host). # So, you should be careful about it as it is making changes to your system. # These commands will be executed in the order they are provided. - install_commands: Optional[List[str]] = Field(default_factory=list) + # Every *_commands field takes either a list (generic, any distro) or a map + # keyed by distro ID and/or "generic"; see resolve_commands(). + install_commands: Optional[ToolCommands] = Field(default_factory=list) # Optional parameters for the tool parameters: Optional[dict] = Field(default_factory=dict) @@ -75,15 +92,85 @@ class ToolAbstract(ABC): # This set of commands will be executed to verify if the tool is installed correctly. # If any of these commands fail, the tool installation is considered to have failed. - verify_commands: Optional[List[str]] = Field(default_factory=list) + verify_commands: Optional[ToolCommands] = Field(default_factory=list) # This set of commands will be executed after the code is copied to the environment # and before the llm is invoked. # For internal tools, these commands will be executed inside the mounted folder. - setup_commands: Optional[List[str]] = Field(default_factory=list) + setup_commands: Optional[ToolCommands] = Field(default_factory=list) # This set of commands will be executed when the environment is being torn down. - uninstall_commands: Optional[List[str]] = Field(default_factory=list) + uninstall_commands: Optional[ToolCommands] = Field(default_factory=list) + + @field_validator( + "install_commands", "verify_commands", "setup_commands", "uninstall_commands" + ) + @classmethod + def _normalize_command_map(cls, value): + """Lower-case distro keys of a command map and reject empty/duplicate keys.""" + if not isinstance(value, dict): + return value + normalized = {} + for key, commands in value.items(): + distro = key.strip().lower() + if not distro: + raise ValueError("command map keys must be non-empty distro IDs or 'generic'") + if distro in normalized: + raise ValueError(f"duplicate command map key {key!r}") + normalized[distro] = commands + return normalized + + def resolve_commands(self, kind: str, distro_ids: Sequence[str]) -> List[str]: + """Select the ``_commands`` to run on the target distro. + + A list applies to every distro. For a map, the first entry matching the + distro IDs (``ID`` then ``ID_LIKE``) wins, then ``generic``. When the + distro cannot be determined (no ``/etc/os-release``), ``generic`` is + used, else the first entry in the map; a failing command is then the + last line of defence. + + Parameters + ---------- + kind : str + ``"install"``, ``"verify"``, ``"setup"`` or ``"uninstall"``. + distro_ids : Sequence[str] + The target's distro IDs (``ID`` then ``ID_LIKE``); empty when the + distro is unknown. Only consulted for command maps. + + Returns + ------- + List[str] + The commands to run, in order (possibly empty). + + Raises + ------ + UnsupportedDistroError + If the distro is known but the command map has neither a matching + entry nor ``generic``. + """ + spec = getattr(self, f"{kind}_commands") or [] + if isinstance(spec, list): + return list(spec) + + if not spec: + return [] + + ids = [i.lower() for i in distro_ids] + for key in (*ids, GENERIC_COMMANDS_KEY): + if key in spec: + return list(spec[key]) + if not ids: + fallback = next(iter(spec)) + logger.warning( + "⚠️ Could not detect the distro for tool '%s'; trying its '%s' %s commands", + self.name, fallback, kind, + ) + return list(spec[fallback]) + raise UnsupportedDistroError( + f"Tool '{self.name}' has no {kind} commands for distro " + f"{'/'.join(ids)}; it defines {sorted(spec)}. " + f"Add a '{ids[0]}' or '{GENERIC_COMMANDS_KEY}' entry." + ) def is_invoked(self, command: str) -> bool: return False # Default implementation, override in subclasses if needed diff --git a/src/microbots/tools/tool_definitions/browser-use.yaml b/src/microbots/tools/tool_definitions/browser-use.yaml index 04130906..5cb226dd 100644 --- a/src/microbots/tools/tool_definitions/browser-use.yaml +++ b/src/microbots/tools/tool_definitions/browser-use.yaml @@ -11,11 +11,14 @@ files_to_copy: dest: /sbin/browser permissions: 7 # rwx +# `playwright install --with-deps` installs browser libraries with apt-get, so +# only Debian-based sandboxes are supported. install_commands: - # Install it system-wide using pip - - pip install --no-input browser-use==0.5.4 playwright - - playwright install --with-deps - - playwright install chromium --with-deps --no-shell + debian: + # Install it system-wide using pip + - pip install --no-input browser-use==0.5.4 playwright + - playwright install --with-deps + - playwright install chromium --with-deps --no-shell setup_commands: - cat /sbin/browser diff --git a/src/microbots/tools/tool_definitions/cscope.yaml b/src/microbots/tools/tool_definitions/cscope.yaml index 88485e00..f4fbeaee 100644 --- a/src/microbots/tools/tool_definitions/cscope.yaml +++ b/src/microbots/tools/tool_definitions/cscope.yaml @@ -54,9 +54,12 @@ usage_instructions_to_llm: | # End of non-interactive instructions. +# Commands are keyed by distro (/etc/os-release ID); installation fails on +# distros without an entry. install_commands: - - apt-get update - - apt-get install -y cscope + debian: + - apt-get update + - apt-get install -y cscope verify_commands: - cscope -V @@ -66,5 +69,6 @@ setup_commands: - cscope -bR uninstall_commands: - - apt-get remove -y cscope - - apt-get autoremove -y + debian: + - apt-get remove -y cscope + - apt-get autoremove -y diff --git a/src/microbots/utils/os_release.py b/src/microbots/utils/os_release.py new file mode 100644 index 00000000..f40b2de6 --- /dev/null +++ b/src/microbots/utils/os_release.py @@ -0,0 +1,67 @@ +"""Identify Linux distributions from ``/etc/os-release``.""" + +import platform +import shlex + + +def parse_os_release(text: str) -> dict[str, str]: + """Parse the ``KEY=value`` lines of an ``os-release`` file. + + Parameters + ---------- + text : str + Contents of an ``os-release`` file. + + Returns + ------- + dict[str, str] + The parsed fields; malformed lines are skipped. + """ + fields = {} + for line in text.splitlines(): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, _, value = line.partition("=") + try: + parts = shlex.split(value) + except ValueError: + continue + fields[key.strip()] = parts[0] if parts else "" + return fields + + +def distro_ids(fields: dict[str, str]) -> list[str]: + """Return the distro identifiers, most specific first. + + Parameters + ---------- + fields : dict[str, str] + Parsed ``os-release`` fields. + + Returns + ------- + list[str] + Lower-case ``ID`` followed by each ``ID_LIKE`` entry, without duplicates. + """ + ids = [] + for value in [fields.get("ID", ""), *fields.get("ID_LIKE", "").split()]: + value = value.strip().lower() + if value and value not in ids: + ids.append(value) + return ids + + +def host_distro_ids() -> list[str]: + """Return the distro identifiers of the host running Microbots. + + Returns + ------- + list[str] + ``ID`` then ``ID_LIKE`` of the host, or an empty list when the host + has no ``os-release`` (for example macOS or Windows). + """ + try: + return distro_ids(platform.freedesktop_os_release()) + except OSError: + return [] diff --git a/test/bot/test_microbot.py b/test/bot/test_microbot.py index e5649d14..088b4438 100644 --- a/test/bot/test_microbot.py +++ b/test/bot/test_microbot.py @@ -1002,6 +1002,8 @@ def test_tool_usage_instructions_in_system_prompt(self, cscope_tool): mock_env = Mock() mock_env.execute.return_value = Mock(return_code=0, stdout="cscope: version 15.9", stderr="") mock_env.execute_privileged = mock_env.execute + # cscope.yaml keys its commands by distro; report what a Debian sandbox would. + mock_env.get_distro_ids.return_value = ["debian"] base_prompt = "You are a code analysis assistant." diff --git a/test/tools/test_distro_commands.py b/test/tools/test_distro_commands.py new file mode 100644 index 00000000..c079473f --- /dev/null +++ b/test/tools/test_distro_commands.py @@ -0,0 +1,169 @@ +"""Unit tests for distro-aware tool commands.""" + +import os +import sys +from unittest.mock import Mock, patch + +import pytest +from pydantic import ValidationError + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../src"))) + +from microbots.environment.Environment import CmdReturn, Environment +from microbots.tools.external_tool import ExternalTool +from microbots.tools.internal_tool import Tool +from microbots.tools.tool import GENERIC_COMMANDS_KEY, UnsupportedDistroError +from microbots.tools.tool_yaml_parser import parse_tool_definition +from microbots.utils.os_release import distro_ids, host_distro_ids, parse_os_release + +pytestmark = pytest.mark.unit + + +def _tool(cls=Tool, **commands): + return cls(name="demo", description="d", usage_instructions_to_llm="u", **commands) + + +def _ids(*ids): + return list(ids) + + +class TestResolveCommands: + def test_list_is_generic_on_every_distro(self): + tool = _tool(install_commands=["a", "b"]) + assert tool.resolve_commands("install", _ids("azurelinux")) == ["a", "b"] + assert tool.resolve_commands("install", _ids()) == ["a", "b"] + + @pytest.mark.parametrize("value", [None, [], {}]) + def test_empty_or_missing_commands_resolve_to_nothing(self, value): + tool = _tool(verify_commands=value) if value is not None else _tool() + assert tool.resolve_commands("verify", _ids("debian")) == [] + + def test_exact_id_wins_over_like_and_generic(self): + tool = _tool(install_commands={"debian": ["apt"], "ubuntu": ["ubuntu-apt"], "generic": ["any"]}) + assert tool.resolve_commands("install", _ids("ubuntu", "debian")) == ["ubuntu-apt"] + + def test_id_like_used_before_generic(self): + tool = _tool(install_commands={"debian": ["apt"], "generic": ["any"]}) + assert tool.resolve_commands("install", _ids("ubuntu", "debian")) == ["apt"] + + def test_generic_fallback(self): + tool = _tool(setup_commands={"debian": ["apt"], GENERIC_COMMANDS_KEY: ["any"]}) + assert tool.resolve_commands("setup", _ids("azurelinux")) == ["any"] + + def test_known_distro_without_entry_fails_before_running(self): + tool = _tool(uninstall_commands={"debian": ["apt"]}) + with pytest.raises(UnsupportedDistroError) as err: + tool.resolve_commands("uninstall", _ids("azurelinux")) + message = str(err.value) + assert "Tool 'demo' has no uninstall commands for distro azurelinux" in message + assert "['debian']" in message and "Add a 'azurelinux'" in message + + def test_unknown_distro_prefers_generic(self, caplog): + tool = _tool(install_commands={"debian": ["apt"], "generic": ["any"]}) + with caplog.at_level("WARNING"): + assert tool.resolve_commands("install", _ids()) == ["any"] + assert "Could not detect" not in caplog.text + + def test_unknown_distro_tries_first_entry_with_warning(self, caplog): + tool = _tool(install_commands={"azurelinux": ["tdnf"], "debian": ["apt"]}) + with caplog.at_level("WARNING"): + assert tool.resolve_commands("install", _ids()) == ["tdnf"] + assert "Could not detect the distro for tool 'demo'; trying its 'azurelinux' install commands" in caplog.text + + def test_distro_lookup_is_case_insensitive(self): + tool = _tool(install_commands={" Debian ": ["apt"]}) + assert tool.install_commands == {"debian": ["apt"]} + assert tool.resolve_commands("install", _ids("DEBIAN")) == ["apt"] + + @pytest.mark.parametrize("bad", [{"": ["x"]}, {"Debian": ["a"], "debian": ["b"]}, {"debian": "apt-get"}, "apt-get"]) + def test_invalid_command_maps_rejected(self, bad): + with pytest.raises(ValidationError): + _tool(install_commands=bad) + + +class FakeEnv(Environment): + def __init__(self, os_release="ID=ubuntu\nID_LIKE=debian\n", code=0): + self.privileged = Mock(return_value=CmdReturn(os_release, "", code)) + self.execute = Mock(return_value=CmdReturn("", "", 0)) + + def start(self): + pass + + def stop(self): + pass + + def execute(self, command, timeout=300, sensitive=False): # replaced per instance + raise NotImplementedError + + def execute_privileged(self, command, timeout=300, sensitive=False): + return self.privileged(command) + + +class TestEnvironmentDistroIds: + def test_reads_and_caches_os_release(self): + env = FakeEnv() + assert env.get_distro_ids() == ["ubuntu", "debian"] + assert env.get_distro_ids() == ["ubuntu", "debian"] + env.privileged.assert_called_once_with("cat /etc/os-release") + + def test_unknown_when_os_release_missing_and_retries_later(self): + env = FakeEnv(os_release="", code=1) + assert env.get_distro_ids() == [] + assert env.get_distro_ids() == [] + assert env.privileged.call_count == 2 + + def test_internal_tool_installs_distro_commands_and_fails_without_them(self): + env = FakeEnv(os_release='ID="azurelinux"\n') + tool = _tool(install_commands={"azurelinux": ["tdnf install -y x"], "debian": ["apt-get install -y x"]}) + tool.install_tool(env) + assert env.privileged.call_args_list[-1].args == ("tdnf install -y x",) + + debian_only = _tool(install_commands={"debian": ["apt-get install -y x"]}) + calls = env.privileged.call_count + with pytest.raises(UnsupportedDistroError): + debian_only.install_tool(env) + assert env.privileged.call_count == calls # nothing was run + + @pytest.mark.parametrize("kind, method", [("verify", "verify_tool_installation"), ("setup", "setup_tool")]) + def test_internal_tool_bot_channel_commands_are_resolved(self, kind, method): + env = FakeEnv(os_release="ID=debian\n") + tool = _tool(**{f"{kind}_commands": {"debian": ["x --version"], "generic": ["y"]}}) + getattr(tool, method)(env) + env.execute.assert_called_once_with("x --version") + + +class TestExternalToolUsesHostDistro: + def test_resolves_against_host(self): + tool = _tool(ExternalTool, install_commands={"plan9": ["p9"], "generic": ["echo generic"]}) + with patch("microbots.tools.external_tool.host_distro_ids", return_value=["plan9"]) as host, \ + patch.object(ExternalTool, "_run_host_command", return_value=Mock(returncode=0)) as run: + tool.install_tool(Mock()) + host.assert_called_once_with() + run.assert_called_once_with("p9") + + +class TestOsRelease: + def test_parse_handles_quotes_comments_and_garbage(self): + text = '# comment\nNAME="Azure Linux"\nID=azurelinux\nID_LIKE="fedora rhel"\nBROKEN\nBAD="unterminated\n' + fields = parse_os_release(text) + assert fields["NAME"] == "Azure Linux" + assert distro_ids(fields) == ["azurelinux", "fedora", "rhel"] + assert "BAD" not in fields + + def test_distro_ids_dedupes_and_lowercases(self): + assert distro_ids({"ID": "Debian", "ID_LIKE": "debian DEBIAN"}) == ["debian"] + assert distro_ids({}) == [] + + def test_host_distro_ids(self): + with patch("platform.freedesktop_os_release", return_value={"ID": "ubuntu", "ID_LIKE": "debian"}): + assert host_distro_ids() == ["ubuntu", "debian"] + with patch("platform.freedesktop_os_release", side_effect=OSError): + assert host_distro_ids() == [] + + +def test_bundled_apt_tools_are_debian_only(): + for name in ("cscope.yaml", "browser-use.yaml"): + tool = parse_tool_definition(name) + assert set(tool.install_commands) == {"debian"} + with pytest.raises(UnsupportedDistroError): + tool.resolve_commands("install", ["azurelinux"]) diff --git a/test/tools/test_tool_yaml_parser.py b/test/tools/test_tool_yaml_parser.py index ebbe240e..34041831 100644 --- a/test/tools/test_tool_yaml_parser.py +++ b/test/tools/test_tool_yaml_parser.py @@ -76,8 +76,8 @@ def test_parsed_tool_has_usage_instructions(self): def test_parsed_tool_has_install_commands(self): """Test that parsed tool has install_commands field.""" tool = parse_tool_definition("cscope.yaml") - assert tool.install_commands is not None - assert isinstance(tool.install_commands, list) + # cscope installs with apt-get, so its commands are keyed by distro. + assert tool.install_commands == {"debian": ["apt-get update", "apt-get install -y cscope"]} def test_parsed_tool_has_verify_commands(self): """Test that parsed tool has verify_commands field."""