diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2ebf76a..f6bbc2f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -167,7 +167,7 @@ jobs: - name: Install GitHub Copilot SDK dependencies for GHCP tests if: matrix.test-type == 'ghcp' run: | - pip install "github-copilot-sdk==0.3.0" + pip install -e ".[ghcp]" - name: Install Azure AD / keyless auth dependencies run: | @@ -238,7 +238,10 @@ jobs: COPILOT_BYOK_PROVIDER_TYPE: ${{ matrix.test-type == 'ghcp' && 'azure' || '' }} COPILOT_BYOK_MODEL: ${{ matrix.test-type == 'ghcp' && vars.AZURE_OPENAI_DEPLOYMENT_NAME || '' }} COPILOT_BYOK_AZURE_API_VERSION: ${{ matrix.test-type == 'ghcp' && vars.AZURE_OPENAI_API_VERSION || '' }} - COPILOT_BYOK_WIRE_API: ${{ matrix.test-type == 'ghcp' && 'completions' || '' }} + COPILOT_BYOK_WIRE_API: ${{ matrix.test-type == 'ghcp' && 'responses' || '' }} + # The deployment name (AZURE_OPENAI_DEPLOYMENT_NAME) is not a model copilot-cli + # knows: tell it the model behind it so it sends gpt-5-compatible requests. + COPILOT_BYOK_MODEL_ID: ${{ matrix.test-type == 'ghcp' && 'gpt-5' || '' }} run: | python -m pytest ${{ matrix.pytest-args }} \ -n auto \ diff --git a/docs/advanced/bots/copilot-bot.md b/docs/advanced/bots/copilot-bot.md index b834862..3c5ffa5 100644 --- a/docs/advanced/bots/copilot-bot.md +++ b/docs/advanced/bots/copilot-bot.md @@ -16,10 +16,10 @@ CopilotBot is a Microbots agent that delegates the entire agent loop to the **Gi pip install microbots[ghcp] ``` -This installs the `github-copilot-sdk` package alongside Microbots. +This installs the `github-copilot-sdk` package (1.x) alongside Microbots. !!! note - You do **not** need to install `copilot-cli` on your host machine. Microbots automatically installs and runs it inside the Docker container during initialization. + You do **not** need to install `copilot-cli` on your host machine. Microbots automatically installs and runs it inside the Docker container during initialization, using the `copilot-cli` version that the pinned `github-copilot-sdk` release targets. ## Quick Start @@ -132,6 +132,22 @@ bot = CopilotBot( ) ``` +#### Using `model_id` for custom deployment names + +copilot-cli adjusts its requests to the model, for example whether it sends `temperature` or `reasoning_effort`. It recognises the model by name. When a BYOK deployment has a custom name (common on Azure), set `model_id` to the model behind it. Otherwise the CLI falls back to generic defaults that reasoning models such as GPT-5 reject (`'temperature' does not support 0 with this model`): + +```python +bot = CopilotBot( + model="my-gpt5-deployment", # name sent to the provider + model_id="gpt-5", # model behind the deployment + folder_to_mount="/path/to/project", + bearer_token=token, + base_url="https://my-resource.openai.azure.com", + provider_type="azure", + wire_api="responses", +) +``` + ### 3. BYOK — Bearer Token If your provider uses bearer token authentication instead of an API key: @@ -181,6 +197,7 @@ export COPILOT_BYOK_BASE_URL="https://api.openai.com/v1" export COPILOT_BYOK_API_KEY="sk-your-key" export COPILOT_BYOK_PROVIDER_TYPE="openai" # optional, defaults to "openai" export COPILOT_BYOK_MODEL="gpt-4.1" # optional, overrides the model param +export COPILOT_BYOK_MODEL_ID="gpt-4.1" # optional, model behind a custom deployment name export COPILOT_BYOK_WIRE_API="completions" # optional export COPILOT_BYOK_AZURE_API_VERSION="2024-10-21" # optional, for Azure only ``` @@ -225,7 +242,10 @@ When multiple auth methods are configured simultaneously, CopilotBot resolves th | `wire_api` | `str` | `None` | API format: `"completions"` or `"responses"`. | | `azure_api_version` | `str` | `None` | Azure API version (for `provider_type="azure"` only). | | `token_provider` | `Callable[[], str]` | `None` | Callable returning a bearer token string. | +| `model_id` | `str` | `None` | BYOK only: the model behind a custom deployment name, e.g. `"gpt-5"`. Falls back to `COPILOT_BYOK_MODEL_ID`. See [Using `model_id`](#using-model_id-for-custom-deployment-names). | | `sudo_access` | `bool` | `False` | Give the agent passwordless `sudo` inside the sandbox. See [Sudo access](../sandbox-images.md#sudo-access). | +| `image` | `str` | `None` | Shell-server image of the auto-created sandbox, e.g. `AZURELINUX_IMAGE`. See [Sandbox Images](../sandbox-images.md). | +| `session_options` | `Mapping[str, Any]` | `None` | Extra keyword arguments forwarded to the SDK's `create_session` on every run. See [Copilot SDK session options](#copilot-sdk-session-options). | ### `run()` method @@ -235,6 +255,9 @@ When multiple auth methods are configured simultaneously, CopilotBot resolves th | `additional_mounts` | `list[Mount]` | `None` | Extra folders to copy into the container. | | `timeout_in_seconds` | `int` | `600` | Maximum wall-clock time for the agent run. | | `streaming` | `bool` | `False` | Enable streaming delta events (logged at DEBUG level). | +| `session_options` | `Mapping[str, Any]` | `None` | Per-run `create_session` arguments. They override the constructor's `session_options` for this run only. | + +After a run, `bot.invoked_skills` lists the names of the agent skills used during that run. ### Return value — `BotRunResult` @@ -326,6 +349,55 @@ result = bot.run( bot.stop() ``` +## Copilot SDK session options + +`session_options` passes extra keyword arguments to the Copilot SDK's `CopilotClient.create_session`. Use it to configure agent skills, custom agents, MCP servers, or the working directory: + +```python +bot = CopilotBot( + folder_to_mount="/path/to/project", + session_options={ + "working_directory": "/workdir/project", + "skill_directories": ["/workdir/project/.agents/skills"], + "disabled_skills": ["experimental-skill"], + }, +) +``` + +- Paths are resolved inside the sandbox. The mounted folder is at `/workdir/`. +- CopilotBot checks each key against the installed SDK's `create_session` signature and raises `ValueError` for unknown keys. +- CopilotBot manages `model`, `provider`, `streaming`, `hooks`, and `system_message` itself, so `session_options` rejects them. Use the matching CopilotBot parameters instead. +- Only option names are logged, never values, because values such as MCP server environment variables can contain secrets. + +The Copilot CLI always loads `AGENTS.md` and `.github/copilot-instructions.md` from the working directory. + +## Using a repository's agent skills (Azure Linux example) + +The Azure Linux repository ships `AGENTS.md` and `azldev-*` skills under `.agents/skills`. These skills drive `azldev`, `mock`, and `rpm`, which Microbots provides as the `azldev.yaml`, `mock.yaml`, and `rpm.yaml` [tool definitions](../sandbox-images.md#azure-linux-build-tools). They run in the Azure Linux sandbox image, and `mock` requires `sudo_access=True`. + +```python +from microbots.bot.CopilotBot import CopilotBot +from microbots.environment.local_docker.images import AZURELINUX_IMAGE +from microbots.tools.tool_yaml_parser import parse_tool_definition + +bot = CopilotBot( + folder_to_mount="/mnt/azurelinux", + permission="READ_WRITE", # use READ_ONLY to try changes without keeping them + image=AZURELINUX_IMAGE, + sudo_access=True, + additional_tools=[parse_tool_definition(n) for n in ("rpm.yaml", "mock.yaml", "azldev.yaml")], + session_options={ + "working_directory": "/workdir/azurelinux", + "skill_directories": ["/workdir/azurelinux/.agents/skills"], + }, +) +result = bot.run("Upgrade the util-linux component to the newest Fedora rawhide version.") +print(bot.invoked_skills) +bot.stop() +``` + +See `test/bot/test_copilot_bot_azurelinux_skills.py` for an end-to-end example. + ## Cleanup Always call `bot.stop()` when you are done. This tears down the SDK client, the CLI server, and the Docker container: diff --git a/pyproject.toml b/pyproject.toml index 77f746e..6766ff7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,7 +24,10 @@ classifiers = [ requires-python = ">=3.11" [project.optional-dependencies] -ghcp = ["github-copilot-sdk==0.3.0"] +# Each github-copilot-sdk release targets one copilot-cli version (CLI_VERSION in the +# SDK's copilot/_cli_version.py). When upgrading, also update the @github/copilot pin +# in src/microbots/tools/tool_definitions/copilot-cli.yaml. +ghcp = ["github-copilot-sdk==1.0.28"] azure_ad = ["azure-identity>=1.15.0"] dev = ["pre-commit>=3.7", "numpydoc>=1.8"] training = ["datasets==5.0.1", "swebench==4.1.0"] diff --git a/src/microbots/bot/CopilotBot.py b/src/microbots/bot/CopilotBot.py index 446db8c..cd46871 100644 --- a/src/microbots/bot/CopilotBot.py +++ b/src/microbots/bot/CopilotBot.py @@ -10,8 +10,9 @@ - Docker sandbox (LocalDockerEnvironment) for isolated execution - Mount system for folder access control - ToolAbstract lifecycle (install → verify → setup) for additional tools - - copilot-cli is installed **inside** the container and run in headless - server mode; the SDK on the host connects to it via TCP. + - copilot-cli is installed **inside** the container (from the + ``copilot-cli.yaml`` tool definition) and run in headless server mode; + the SDK on the host connects to it via TCP. Architecture: @@ -20,7 +21,7 @@ CopilotBot copilot-cli --headless --port

│ │ ├── Copilot SDK ──TCP──────────┘ - │ (ExternalServerConfig) + │ (UriRuntimeConnection) │ ├── additional tools │ (define_tool → SDK session) @@ -34,17 +35,19 @@ """ import asyncio +import inspect import os import time import threading -from collections.abc import Callable +from collections.abc import Callable, Mapping from logging import getLogger -from typing import Optional +from typing import Any, Optional from microbots.constants import ( DOCKER_WORKING_DIR, PermissionLabels, ) +from microbots.environment.local_docker.images import DEFAULT_IMAGE from microbots.environment.local_docker.LocalDockerEnvironment import ( LocalDockerEnvironment, ) @@ -52,6 +55,7 @@ from microbots.MicroBot import BotRunResult from microbots.tools.external_tool import ExternalTool from microbots.tools.tool import ToolAbstract +from microbots.tools.tool_yaml_parser import parse_tool_definition from microbots.utils.copilot_auth import get_copilot_token from microbots.utils.network import get_free_port # still used for _create_environment @@ -66,6 +70,74 @@ # copilot-cli port inside the container _CONTAINER_CLI_PORT = 4321 +# ``create_session`` arguments CopilotBot sets itself, mapped to where callers +# configure them instead of ``session_options``. +_RESERVED_SESSION_OPTIONS = { + "model": "the 'model' argument (or COPILOT_BYOK_MODEL)", + "provider": "the BYOK arguments (api_key, bearer_token, base_url, provider_type, ...)", + "streaming": "run(streaming=...)", + "hooks": "nothing — CopilotBot uses them for tool-call logging", + "system_message": "folder_to_mount / additional_tools usage instructions", +} + + +def _validate_session_options( + session_options: Optional[Mapping[str, Any]], + create_session: Callable, +) -> dict[str, Any]: + """Return a copy of *session_options* after checking each key is an + argument of the SDK's ``create_session`` that CopilotBot does not own. + + Parameters + ---------- + session_options : Optional[Mapping[str, Any]] + Keyword arguments destined for ``create_session``. + create_session : Callable + The SDK's ``create_session`` whose signature defines accepted keys. + + Returns + ------- + dict[str, Any] + A shallow copy of the options, or an empty dict when *None*. + + Raises + ------ + TypeError + If *session_options* is not a mapping. + ValueError + If a key is managed by CopilotBot or unknown to the installed SDK. + """ + if session_options is None: + return {} + if not isinstance(session_options, Mapping): + raise TypeError("session_options must be a mapping of create_session keyword arguments.") + + for key in session_options: + if key in _RESERVED_SESSION_OPTIONS: + raise ValueError( + f"session_options cannot set '{key}'; it is managed by CopilotBot " + f"via {_RESERVED_SESSION_OPTIONS[key]}." + ) + + accepted = { + name + for name, param in inspect.signature(create_session).parameters.items() + if param.kind in (param.KEYWORD_ONLY, param.POSITIONAL_OR_KEYWORD) and name != "self" + } + unknown = sorted(set(session_options) - accepted) + if unknown: + raise ValueError( + f"Unsupported session_options {unknown} for the installed github-copilot-sdk. " + f"Supported options: {sorted(accepted - set(_RESERVED_SESSION_OPTIONS))}" + ) + return dict(session_options) + + +# copilot-cli (with its Node.js runtime) is installed from this tool definition, +# which holds the per-distro install commands and the pinned CLI version. +_COPILOT_CLI_TOOL = "copilot-cli.yaml" + + # Environment variable names for BYOK configuration _BYOK_ENV_PROVIDER_TYPE = "COPILOT_BYOK_PROVIDER_TYPE" _BYOK_ENV_BASE_URL = "COPILOT_BYOK_BASE_URL" @@ -74,6 +146,7 @@ _BYOK_ENV_WIRE_API = "COPILOT_BYOK_WIRE_API" # completions / responses _BYOK_ENV_AZURE_API_VERSION = "COPILOT_BYOK_AZURE_API_VERSION" _BYOK_ENV_MODEL = "COPILOT_BYOK_MODEL" +_BYOK_ENV_MODEL_ID = "COPILOT_BYOK_MODEL_ID" def resolve_auth_config( @@ -86,6 +159,7 @@ def resolve_auth_config( wire_api: Optional[str] = None, azure_api_version: Optional[str] = None, token_provider: Optional[Callable[[], str]] = None, + model_id: Optional[str] = None, ) -> tuple[str, Optional[str], Optional[dict]]: """Resolve authentication and provider configuration for CopilotBot. @@ -121,6 +195,12 @@ def resolve_auth_config( token provider). The token is fetched once at config resolution time. For long-running sessions, create a new session with a refreshed token. + model_id : Optional[str] + Well-known model behind a BYOK deployment (e.g. ``"gpt-5"`` for an + Azure deployment named ``"my-gpt5"``). copilot-cli uses it to pick + the model's prompts, tools, reasoning behaviour and token limits, + while ``model`` is still sent to the provider as the wire model. + Falls back to ``COPILOT_BYOK_MODEL_ID``. Returns ------- @@ -149,6 +229,8 @@ def resolve_auth_config( bearer_token=bearer_token, wire_api=wire_api, azure_api_version=azure_api_version, + model=model, + model_id=model_id or os.environ.get(_BYOK_ENV_MODEL_ID), ) logger.info("🔑 BYOK auth resolved via explicit credentials (type=%s)", provider["type"]) return model, None, provider @@ -167,6 +249,8 @@ def resolve_auth_config( bearer_token=env_bearer_token, wire_api=os.environ.get(_BYOK_ENV_WIRE_API), azure_api_version=os.environ.get(_BYOK_ENV_AZURE_API_VERSION), + model=env_model, + model_id=model_id or os.environ.get(_BYOK_ENV_MODEL_ID), ) logger.info("🔑 BYOK auth resolved via environment variables (type=%s)", provider["type"]) return env_model, None, provider @@ -203,15 +287,18 @@ def resolve_auth_config( if not isinstance(token, str) or not token: raise ValueError("token_provider must return a non-empty string token.") + resolved_model = os.environ.get(_BYOK_ENV_MODEL, model) provider = _build_provider_config( provider_type=provider_type or os.environ.get(_BYOK_ENV_PROVIDER_TYPE, "openai"), base_url=resolved_url, bearer_token=token, wire_api=wire_api or os.environ.get(_BYOK_ENV_WIRE_API), azure_api_version=azure_api_version or os.environ.get(_BYOK_ENV_AZURE_API_VERSION), + model=resolved_model, + model_id=model_id or os.environ.get(_BYOK_ENV_MODEL_ID), ) logger.info("🔑 BYOK auth resolved via token_provider (type=%s)", provider["type"]) - return os.environ.get(_BYOK_ENV_MODEL, model), None, provider + return resolved_model, None, provider # ── 4. Native GitHub Copilot auth ──────────────────────────────── resolved_github_token = ( @@ -232,6 +319,8 @@ def _build_provider_config( bearer_token: Optional[str] = None, wire_api: Optional[str] = None, azure_api_version: Optional[str] = None, + model: Optional[str] = None, + model_id: Optional[str] = None, ) -> dict: """Build the ``provider`` dict accepted by ``create_session``.""" config: dict = { @@ -250,6 +339,12 @@ def _build_provider_config( if provider_type == "azure" and azure_api_version: config["azure"] = {"api_version": azure_api_version} + if model_id: + config["model_id"] = model_id + # The CLI sends model_id on the wire unless wire_model is set. + if model: + config["wire_model"] = model + return config @@ -299,6 +394,12 @@ class CopilotBot: token_provider : Optional[Callable[[], str]] A callable returning a bearer token (e.g. Azure AD token provider). Requires ``base_url``. + model_id : Optional[str] + For BYOK, the well-known model behind a custom deployment name + (e.g. ``"gpt-5"`` for a deployment named ``"my-gpt5"``), so + copilot-cli applies that model's request settings such as reasoning + and temperature. ``model`` is still the name sent to the provider. + Falls back to ``COPILOT_BYOK_MODEL_ID``. sudo_access : bool Give the agent passwordless ``sudo`` inside the sandbox. Needed by tools such as ``mock``. Commands still run as the host user unless @@ -308,6 +409,21 @@ class CopilotBot: tasks and repositories. Applies to the auto-created environment; a passed-in ``environment`` must already have been created with ``sudo_access=True``. + image : Optional[str] + Shell-server image of the auto-created environment, for example + ``images.AZURELINUX_IMAGE``. Defaults to the Debian image. Cannot + be combined with ``environment``. copilot-cli is installed to suit + the image's distro (from its ``/etc/os-release``). + session_options : Optional[Mapping[str, Any]] + Extra keyword arguments forwarded to the SDK's + ``CopilotClient.create_session`` for every run — for example + ``skill_directories``, ``disabled_skills``, ``custom_agents``, + ``mcp_servers``, ``working_directory``, ``enable_config_discovery``, + ``reasoning_effort`` or ``on_permission_request``. Paths are + resolved inside the sandbox (the mounted folder lives at + ``/workdir/``). ``model``, ``provider``, + ``streaming``, ``hooks`` and ``system_message`` are managed by + CopilotBot and rejected here. ``run()`` accepts per-run overrides. """ def __init__( @@ -325,32 +441,40 @@ def __init__( wire_api: Optional[str] = None, azure_api_version: Optional[str] = None, token_provider: Optional[Callable[[], str]] = None, + model_id: Optional[str] = None, sudo_access: bool = False, + image: Optional[str] = None, + session_options: Optional[Mapping[str, Any]] = None, ): try: - from copilot import CopilotClient, ExternalServerConfig + import copilot # noqa: F401 except ImportError: raise ImportError( "CopilotBot requires the github-copilot-sdk package. " "Install with: pip install microbots[ghcp]" ) - - # ``PermissionHandler`` lives in ``copilot.session`` in - # github-copilot-sdk >= 0.3.0 and in ``copilot.types`` in older - # releases. Try the new location first and fall back to the old - # one so we work with both. try: + from copilot import CopilotClient, UriRuntimeConnection from copilot.session import PermissionHandler - except ImportError: - try: - from copilot.types import PermissionHandler - except ImportError as exc: - raise ImportError( - "CopilotBot could not locate 'PermissionHandler' in the " - "installed github-copilot-sdk (looked in copilot.session " - "and copilot.types). Please install a compatible version, " - "e.g. pip install -U microbots[ghcp]." - ) from exc + except ImportError as exc: + raise ImportError( + "CopilotBot requires github-copilot-sdk 1.x (UriRuntimeConnection, " + "copilot.session.PermissionHandler). " + "Upgrade with: pip install -U microbots[ghcp]" + ) from exc + + self._sdk_create_session = CopilotClient.create_session + self.session_options = _validate_session_options( + session_options, self._sdk_create_session + ) + self.invoked_skills: list[str] = [] + + if image is not None and environment is not None: + raise ValueError( + "Pass either `image` or `environment`, not both: the image of a " + "provided environment is chosen when that environment is created." + ) + self.image = image or DEFAULT_IMAGE if ( environment is not None @@ -376,6 +500,7 @@ def __init__( wire_api=wire_api, azure_api_version=azure_api_version, token_provider=token_provider, + model_id=model_id, ) # ── Mount setup ───────────────────────────────────────────── @@ -418,7 +543,7 @@ def __init__( container_ip = self.environment.get_ipv4_address() logger.info("🔌 Connecting Copilot SDK to CLI server at %s:%d", container_ip, _CONTAINER_CLI_PORT) self._client = CopilotClient( - ExternalServerConfig(url=f"{container_ip}:{_CONTAINER_CLI_PORT}") + connection=UriRuntimeConnection(url=f"{container_ip}:{_CONTAINER_CLI_PORT}") ) self._run_async(self._client.start()) self._PermissionHandler = PermissionHandler @@ -440,6 +565,7 @@ def run( additional_mounts: Optional[list[Mount]] = None, timeout_in_seconds: int = 600, streaming: bool = False, + session_options: Optional[Mapping[str, Any]] = None, ) -> BotRunResult: """Send *task* to the Copilot agent and wait for completion. @@ -457,6 +583,9 @@ def run( Maximum wall-clock time for the agent run. streaming : bool Whether to enable streaming delta events (logged at DEBUG level). + session_options : Optional[Mapping[str, Any]] + Per-run ``create_session`` arguments, merged over (and taking + precedence on) the constructor's ``session_options``. Returns ------- @@ -466,6 +595,12 @@ def run( """ logger.info("🚀 Starting CopilotBot run — task: %.120s...", task) + run_session_options = { + **self.session_options, + **_validate_session_options(session_options, self._sdk_create_session), + } + self.invoked_skills = [] + # Setup additional tools (env vars, files, setup_commands) for tool in self.additional_tools: logger.info("⚙️ Setting up tool '%s'", tool.name) @@ -485,6 +620,7 @@ def run( system_content=system_content, timeout=timeout_in_seconds, streaming=streaming, + session_options=run_session_options, ) ) logger.info("✅ CopilotBot run completed successfully") @@ -536,41 +672,28 @@ def _create_environment(self): self.environment = LocalDockerEnvironment( port=free_port, folder_to_mount=self.folder_to_mount, + image=self.image, sudo_access=self.sudo_access, ) def _install_copilot_cli(self): - """Install copilot-cli inside the Docker container.""" - logger.info("📦 Installing copilot-cli inside container...") + """Install copilot-cli inside the container from ``copilot-cli.yaml``. - # Install Node.js (required for copilot-cli via npm) - install_commands = [ - # Remove stale third-party repos that may have expired GPG keys - "rm -f /etc/apt/sources.list.d/yarn.list", - # Install Node.js 22.x (copilot-cli requires Node 22+) - "apt-get update -qq && apt-get install -y -qq curl ca-certificates > /dev/null 2>&1", - "curl -fsSL https://deb.nodesource.com/setup_22.x | bash - > /dev/null 2>&1", - "apt-get install -y -qq nodejs > /dev/null 2>&1", - # Install copilot-cli globally - # NOTE: Pinning to 1.0.39 which is the latest @github/copilot release compatible with github-copilot-sdk==0.3.0 as of May 2026. - "npm install -g @github/copilot@1.0.39 > /dev/null 2>&1", - ] - - for cmd in install_commands: - result = self.environment.execute_privileged(cmd, timeout=300) - if result.return_code != 0: - raise RuntimeError( - f"Failed to install copilot-cli: {cmd}\n" - f"stdout: {result.stdout}\nstderr: {result.stderr}" - ) + The tool definition picks the Node.js install for the sandbox's distro + (from its ``/etc/os-release``) and pins the CLI version. - # Verify installation - verify = self.environment.execute("copilot --version") - if verify.return_code != 0: - raise RuntimeError( - f"copilot-cli installation verification failed: {verify.stderr}" - ) - logger.info("✅ copilot-cli installed: %s", verify.stdout.strip()) + Raises + ------ + RuntimeError + If installation or ``copilot --version`` fails, or (as + ``UnsupportedDistroError``) if the sandbox runs a detected but + unsupported distro. + """ + logger.info("📦 Installing copilot-cli inside container...") + tool = parse_tool_definition(_COPILOT_CLI_TOOL) + tool.install_tool(self.environment) + tool.verify_tool_installation(self.environment) + logger.info("✅ copilot-cli installed") def _start_copilot_cli_server(self): """Start copilot-cli in headless server mode inside the container. @@ -647,9 +770,10 @@ async def _execute_session( system_content: str, timeout: int, streaming: bool, + session_options: Optional[Mapping[str, Any]] = None, ) -> str: """Create a Copilot session, send the task, and collect the result.""" - from copilot.generated.session_events import SessionEventType + from copilot.session_events import SessionEventType session_kwargs = { "model": self.model, @@ -669,10 +793,16 @@ async def _execute_session( logger.info("📡 Creating Copilot session (model=%s, streaming=%s, byok=%s)", self.model, streaming, self._provider_config is not None) logger.debug("Session kwargs: %s", session_kwargs) + if session_options: + # Only keys are logged: values (e.g. MCP server env/headers) may hold secrets. + logger.info("🧩 Extra session options: %s", sorted(session_options)) + session_kwargs.update(session_options) session = await self._client.create_session(**session_kwargs) collected_text = [] done_event = asyncio.Event() + skill_invoked = getattr(SessionEventType, "SKILL_INVOKED", None) + skills_loaded = getattr(SessionEventType, "SESSION_SKILLS_LOADED", None) def _on_event(event): if event.type == SessionEventType.ASSISTANT_MESSAGE: @@ -685,6 +815,13 @@ def _on_event(event): elif event.type == SessionEventType.SESSION_IDLE: logger.info("⏹️ Session idle — agent finished processing") done_event.set() + elif skill_invoked is not None and event.type == skill_invoked: + name = getattr(event.data, "name", None) or "unknown" + self.invoked_skills.append(name) + logger.info("🧠 Skill invoked: %s (%s)", name, getattr(event.data, "path", "")) + elif skills_loaded is not None and event.type == skills_loaded: + names = [s.name for s in (getattr(event.data, "skills", None) or [])] + logger.info("📚 Skills loaded (%d): %s", len(names), ", ".join(names)) else: logger.debug("📨 Session event: %s", event.type) diff --git a/src/microbots/tools/tool_definitions/copilot-cli.yaml b/src/microbots/tools/tool_definitions/copilot-cli.yaml new file mode 100644 index 0000000..16d9e72 --- /dev/null +++ b/src/microbots/tools/tool_definitions/copilot-cli.yaml @@ -0,0 +1,40 @@ +name: copilot-cli +tool_type: internal +description: GitHub Copilot CLI, which CopilotBot runs as a headless server inside the sandbox. + +# Infrastructure for CopilotBot rather than a tool for the model: CopilotBot installs +# it itself before starting the server, so it is never passed as an additional tool +# and has no usage instructions. +usage_instructions_to_llm: "" + +# copilot-cli needs Node.js 22+. The CLI version must match the github-copilot-sdk +# pinned by the `ghcp` extra in pyproject.toml: github-copilot-sdk==1.0.28 targets +# @github/copilot 1.0.105 (CLI_VERSION in the SDK's copilot/_cli_version.py). +# When upgrading, also update the `ghcp` pin in pyproject.toml. +install_commands: + debian: + # Remove stale third-party repos that may have expired GPG keys. + - rm -f /etc/apt/sources.list.d/yarn.list + - apt-get update -qq && apt-get install -y -qq curl ca-certificates > /dev/null 2>&1 + - curl -fsSL https://deb.nodesource.com/setup_22.x | bash - > /dev/null 2>&1 + - apt-get install -y -qq nodejs > /dev/null 2>&1 + - &install_cli "npm install -g @github/copilot@1.0.105 > /dev/null 2>&1" + azurelinux: + - tdnf install -y curl ca-certificates tar xz > /dev/null + # Azure Linux 3 only packages Node.js 20: install the official Node.js 22 + # build, verified against the release's SHASUMS256.txt. + - | + set -e + base=https://nodejs.org/dist/latest-v22.x + case "$(uname -m)" in x86_64) arch=x64;; aarch64) arch=arm64;; *) arch=$(uname -m);; esac + cd /tmp + curl -fsSL "$base/SHASUMS256.txt" -o SHASUMS256.txt + tarball=$(grep -o "node-v22[^ ]*-linux-$arch.tar.xz" SHASUMS256.txt | head -1) + curl -fsSL "$base/$tarball" -o "$tarball" + grep " $tarball\$" SHASUMS256.txt | sha256sum -c - > /dev/null + tar -xJf "$tarball" -C /usr/local --strip-components=1 --no-same-owner + rm -f "$tarball" SHASUMS256.txt + - *install_cli + +verify_commands: + - copilot --version diff --git a/test/bot/test_copilot_bot.py b/test/bot/test_copilot_bot.py index d5a9f3c..11ec13b 100644 --- a/test/bot/test_copilot_bot.py +++ b/test/bot/test_copilot_bot.py @@ -26,7 +26,7 @@ # --------------------------------------------------------------------------- _mock_copilot = MagicMock() _mock_copilot.CopilotClient = MagicMock -_mock_copilot.ExternalServerConfig = MagicMock +_mock_copilot.UriRuntimeConnection = MagicMock _mock_session = MagicMock() _mock_session.PermissionRequestResult = MagicMock @@ -45,15 +45,10 @@ _mock_tools.ToolResult = MagicMock _mock_tools.define_tool = MagicMock -_mock_types = MagicMock() -_mock_types.PermissionHandler = MagicMock() -_mock_types.PermissionHandler.approve_all = MagicMock() - sys.modules.setdefault("copilot", _mock_copilot) sys.modules.setdefault("copilot.session", _mock_session) -sys.modules.setdefault("copilot.generated.session_events", _mock_events) +sys.modules.setdefault("copilot.session_events", _mock_events) sys.modules.setdefault("copilot.tools", _mock_tools) -sys.modules.setdefault("copilot.types", _mock_types) # Reload to pick up mock if "microbots.bot.CopilotBot" in sys.modules: @@ -76,77 +71,6 @@ def _restore_real_copilot_modules(): del sys.modules["microbots.bot.CopilotBot"] -def _reinstall_default_copilot_mocks(): - """Reinstall the module-level copilot mocks and reload CopilotBot. - - Used to undo a temporary swap performed by ``_swap_copilot_sdk_layout`` - so subsequent tests in this file see the original mock fixtures. - """ - sys.modules["copilot"] = _mock_copilot - sys.modules["copilot.session"] = _mock_session - sys.modules["copilot.generated.session_events"] = _mock_events - sys.modules["copilot.tools"] = _mock_tools - sys.modules["copilot.types"] = _mock_types - if "microbots.bot.CopilotBot" in sys.modules: - importlib.reload(sys.modules["microbots.bot.CopilotBot"]) - - -@contextlib.contextmanager -def _swap_copilot_sdk_layout(*, session_has_handler: bool, types_has_handler: bool): - """Temporarily install fresh stub modules for ``copilot.session`` and - ``copilot.types`` to simulate a specific SDK layout, then reload - ``microbots.bot.CopilotBot`` so the new import logic re-runs. - - ``session_has_handler``/``types_has_handler`` control whether - ``PermissionHandler`` is exposed on the corresponding module. The - returned tuple yields the two ``PermissionHandler`` sentinels (or - ``None`` when not present) so tests can assert which one was picked. - """ - # Build a stub ``copilot`` package that still exposes CopilotClient and - # ExternalServerConfig so the first try/except in __init__ succeeds. - stub_copilot = types.ModuleType("copilot") - stub_copilot.CopilotClient = _mock_copilot.CopilotClient - stub_copilot.ExternalServerConfig = _mock_copilot.ExternalServerConfig - - stub_session = types.ModuleType("copilot.session") - stub_session.PermissionRequestResult = MagicMock - session_handler = MagicMock(name="session.PermissionHandler") if session_has_handler else None - if session_handler is not None: - session_handler.approve_all = MagicMock(name="session.approve_all") - stub_session.PermissionHandler = session_handler - - stub_types = types.ModuleType("copilot.types") - types_handler = MagicMock(name="types.PermissionHandler") if types_has_handler else None - if types_handler is not None: - types_handler.approve_all = MagicMock(name="types.approve_all") - stub_types.PermissionHandler = types_handler - - saved = { - name: sys.modules.get(name) - for name in ("copilot", "copilot.session", "copilot.types") - } - sys.modules["copilot"] = stub_copilot - sys.modules["copilot.session"] = stub_session - sys.modules["copilot.types"] = stub_types - # Other submodules (generated.session_events, tools) keep the existing - # module-level mocks so CopilotBot's other imports still work. - try: - if "microbots.bot.CopilotBot" in sys.modules: - importlib.reload(sys.modules["microbots.bot.CopilotBot"]) - yield session_handler, types_handler - finally: - for name, mod in saved.items(): - if mod is None: - sys.modules.pop(name, None) - else: - sys.modules[name] = mod - _reinstall_default_copilot_mocks() - - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - def _copilot_sdk_installed(): try: from importlib.metadata import version @@ -234,7 +158,7 @@ def copilot_bot(mock_environment, mock_copilot_client): patch("microbots.bot.CopilotBot.CopilotBot._start_copilot_cli_server"), patch("microbots.bot.CopilotBot.CopilotBot._wait_for_cli_ready"), patch("copilot.CopilotClient", return_value=mock_copilot_client), - patch("copilot.ExternalServerConfig", return_value=MagicMock()), + patch("copilot.UriRuntimeConnection", return_value=MagicMock()), ): from microbots.bot.CopilotBot import CopilotBot bot = CopilotBot( @@ -286,7 +210,7 @@ def test_rejects_external_tool(self, mock_environment, mock_copilot_client): patch("microbots.bot.CopilotBot.CopilotBot._start_copilot_cli_server"), patch("microbots.bot.CopilotBot.CopilotBot._wait_for_cli_ready"), patch("copilot.CopilotClient", return_value=mock_copilot_client), - patch("copilot.ExternalServerConfig", return_value=MagicMock()), + patch("copilot.UriRuntimeConnection", return_value=MagicMock()), ): from microbots.bot.CopilotBot import CopilotBot with pytest.raises(ValueError, match="does not support ExternalTool"): @@ -314,90 +238,44 @@ def test_import_error_without_sdk(self): if "microbots.bot.CopilotBot" in sys.modules: importlib.reload(sys.modules["microbots.bot.CopilotBot"]) - # ────────────────────────────────────────────────────────────────── - # PermissionHandler import path — covers the new logic in __init__ - # that supports both pre-0.3.0 and >=0.3.0 github-copilot-sdk layouts. - # ────────────────────────────────────────────────────────────────── + def test_import_error_with_pre_1x_sdk(self, mock_environment, mock_copilot_client): + """An installed SDK without ``UriRuntimeConnection`` (0.x) raises an + ImportError asking for an upgrade instead of failing later.""" + old_sdk = types.ModuleType("copilot") + old_sdk.CopilotClient = MagicMock + old_sdk.ExternalServerConfig = MagicMock + saved = sys.modules.get("copilot") + try: + sys.modules["copilot"] = old_sdk + from microbots.bot.CopilotBot import CopilotBot as CB + with patch("microbots.bot.CopilotBot.LocalDockerEnvironment", return_value=mock_environment): + with pytest.raises(ImportError, match="github-copilot-sdk 1.x"): + CB(model="gpt-4.1", environment=mock_environment, github_token="ghp_test") + finally: + sys.modules["copilot"] = saved - def _build_bot_under_swap(self, mock_environment, mock_copilot_client): - """Construct a CopilotBot using the reloaded module under the - currently-active ``_swap_copilot_sdk_layout`` context.""" - from microbots.bot.CopilotBot import CopilotBot + def test_client_connects_to_container_cli_via_uri(self, mock_environment, mock_copilot_client): + """The SDK client attaches to the in-container CLI server through a + ``UriRuntimeConnection`` pointing at the container IP and CLI port.""" + from microbots.bot.CopilotBot import _CONTAINER_CLI_PORT, CopilotBot with ( patch("microbots.bot.CopilotBot.LocalDockerEnvironment", return_value=mock_environment), patch("microbots.bot.CopilotBot.get_free_port", side_effect=[9000]), patch("microbots.bot.CopilotBot.CopilotBot._install_copilot_cli"), patch("microbots.bot.CopilotBot.CopilotBot._start_copilot_cli_server"), patch("microbots.bot.CopilotBot.CopilotBot._wait_for_cli_ready"), - patch("copilot.CopilotClient", return_value=mock_copilot_client), - patch("copilot.ExternalServerConfig", return_value=MagicMock()), + patch("copilot.CopilotClient", return_value=mock_copilot_client) as client_cls, + patch("copilot.UriRuntimeConnection") as connection_cls, ): - bot = CopilotBot( - model="gpt-4.1", - environment=mock_environment, - github_token="ghp_test", - ) - return bot - - def test_permission_handler_loaded_from_session( - self, mock_environment, mock_copilot_client - ): - """SDK >= 0.3.0: ``PermissionHandler`` is loaded from copilot.session - and is preferred over copilot.types when both expose it.""" - with _swap_copilot_sdk_layout( - session_has_handler=True, types_has_handler=True - ) as (session_handler, types_handler): - bot = self._build_bot_under_swap(mock_environment, mock_copilot_client) - try: - assert bot._PermissionHandler is session_handler - assert bot._PermissionHandler is not types_handler - finally: - bot._loop.call_soon_threadsafe(bot._loop.stop) - bot._thread.join(timeout=2) - bot.environment = None - - def test_permission_handler_falls_back_to_types( - self, mock_environment, mock_copilot_client - ): - """Pre-0.3.0 SDK: ``PermissionHandler`` is absent from copilot.session - and the import falls back to copilot.types.""" - with _swap_copilot_sdk_layout( - session_has_handler=False, types_has_handler=True - ) as (session_handler, types_handler): - assert session_handler is None - bot = self._build_bot_under_swap(mock_environment, mock_copilot_client) - try: - assert bot._PermissionHandler is types_handler - finally: - bot._loop.call_soon_threadsafe(bot._loop.stop) - bot._thread.join(timeout=2) - bot.environment = None - - def test_import_error_when_permission_handler_missing_everywhere( - self, mock_environment, mock_copilot_client - ): - """If neither copilot.session nor copilot.types exposes - ``PermissionHandler`` we raise a distinct, accurate ImportError - (and not the misleading "github-copilot-sdk is not installed" one).""" - with _swap_copilot_sdk_layout( - session_has_handler=False, types_has_handler=False - ): - from microbots.bot.CopilotBot import CopilotBot - with pytest.raises(ImportError, match="PermissionHandler"): - with ( - patch("microbots.bot.CopilotBot.LocalDockerEnvironment", return_value=mock_environment), - patch("microbots.bot.CopilotBot.get_free_port", side_effect=[9000]), - patch("microbots.bot.CopilotBot.CopilotBot._install_copilot_cli"), - patch("microbots.bot.CopilotBot.CopilotBot._start_copilot_cli_server"), - patch("microbots.bot.CopilotBot.CopilotBot._wait_for_cli_ready"), - patch("copilot.CopilotClient", return_value=mock_copilot_client), - patch("copilot.ExternalServerConfig", return_value=MagicMock()), - ): - CopilotBot( - model="gpt-4.1", - environment=mock_environment, - github_token="ghp_test", - ) + bot = CopilotBot(model="gpt-4.1", environment=mock_environment, github_token="ghp_test") + try: + connection_cls.assert_called_once_with(url=f"172.17.0.2:{_CONTAINER_CLI_PORT}") + client_cls.assert_called_once_with(connection=connection_cls.return_value) + mock_copilot_client.start.assert_awaited_once() + finally: + bot._loop.call_soon_threadsafe(bot._loop.stop) + bot._thread.join(timeout=2) + bot.environment = None @pytest.mark.unit @@ -448,7 +326,7 @@ def test_system_message_includes_mount_path(self, mock_environment, mock_copilot patch("microbots.bot.CopilotBot.CopilotBot._start_copilot_cli_server"), patch("microbots.bot.CopilotBot.CopilotBot._wait_for_cli_ready"), patch("copilot.CopilotClient", return_value=mock_copilot_client), - patch("copilot.ExternalServerConfig", return_value=MagicMock()), + patch("copilot.UriRuntimeConnection", return_value=MagicMock()), ): from microbots.bot.CopilotBot import CopilotBot from microbots.extras.mount import Mount @@ -497,7 +375,7 @@ def test_install_cli_calls_execute(self, mock_environment): patch("microbots.bot.CopilotBot.CopilotBot._start_copilot_cli_server"), patch("microbots.bot.CopilotBot.CopilotBot._wait_for_cli_ready"), patch("copilot.CopilotClient", return_value=AsyncMock()), - patch("copilot.ExternalServerConfig", return_value=MagicMock()), + patch("copilot.UriRuntimeConnection", return_value=MagicMock()), ): bot = CopilotBot( model="gpt-4.1", @@ -530,9 +408,9 @@ def test_install_cli_raises_on_failure(self, mock_environment): patch("microbots.bot.CopilotBot.CopilotBot._start_copilot_cli_server"), patch("microbots.bot.CopilotBot.CopilotBot._wait_for_cli_ready"), patch("copilot.CopilotClient", return_value=AsyncMock()), - patch("copilot.ExternalServerConfig", return_value=MagicMock()), + patch("copilot.UriRuntimeConnection", return_value=MagicMock()), ): - with pytest.raises(RuntimeError, match="Failed to install copilot-cli"): + with pytest.raises(RuntimeError, match="Failed to install tool copilot-cli"): CopilotBot( model="gpt-4.1", environment=mock_environment, @@ -606,6 +484,60 @@ def test_wire_api_included_when_set(self): ) assert provider["wire_api"] == "responses" + def test_model_id_absent_by_default(self, monkeypatch): + from microbots.bot.CopilotBot import resolve_auth_config + + monkeypatch.delenv("COPILOT_BYOK_MODEL_ID", raising=False) + _, _, provider = resolve_auth_config( + model="my-deployment", api_key="key", base_url="https://endpoint.com/v1" + ) + assert "model_id" not in provider + assert "wire_model" not in provider + + def test_model_id_sets_wire_model_to_deployment(self): + """``model_id`` names the model for the CLI; the deployment stays the wire model.""" + from microbots.bot.CopilotBot import resolve_auth_config + + model, _, provider = resolve_auth_config( + model="my-gpt5-deployment", + api_key="key", + base_url="https://endpoint.com/v1", + model_id="gpt-5", + ) + assert model == "my-gpt5-deployment" + assert provider["model_id"] == "gpt-5" + assert provider["wire_model"] == "my-gpt5-deployment" + + def test_model_id_from_env_with_env_credentials(self, monkeypatch): + from microbots.bot.CopilotBot import resolve_auth_config + + monkeypatch.setenv("COPILOT_BYOK_BASE_URL", "https://env-endpoint.com/v1") + monkeypatch.setenv("COPILOT_BYOK_API_KEY", "env-key") + monkeypatch.setenv("COPILOT_BYOK_MODEL", "env-deployment") + monkeypatch.setenv("COPILOT_BYOK_MODEL_ID", "gpt-5") + + model, _, provider = resolve_auth_config(model="gpt-4.1") + assert model == "env-deployment" + assert provider["model_id"] == "gpt-5" + assert provider["wire_model"] == "env-deployment" + + def test_model_id_with_token_provider(self, monkeypatch): + from microbots.bot.CopilotBot import resolve_auth_config + + for var in ("COPILOT_BYOK_BASE_URL", "COPILOT_BYOK_API_KEY", + "COPILOT_BYOK_BEARER_TOKEN", "COPILOT_BYOK_MODEL", "COPILOT_BYOK_MODEL_ID"): + monkeypatch.delenv(var, raising=False) + model, _, provider = resolve_auth_config( + model="my-deployment", + base_url="https://endpoint.com/v1", + token_provider=lambda: "token", + model_id="gpt-5", + ) + assert model == "my-deployment" + assert provider["bearer_token"] == "token" + assert provider["model_id"] == "gpt-5" + assert provider["wire_model"] == "my-deployment" + def test_env_vars_resolve_byok(self, monkeypatch): from microbots.bot.CopilotBot import resolve_auth_config @@ -779,7 +711,7 @@ def test_byok_api_key_sets_provider_config(self, mock_environment, mock_copilot_ patch("microbots.bot.CopilotBot.CopilotBot._start_copilot_cli_server"), patch("microbots.bot.CopilotBot.CopilotBot._wait_for_cli_ready"), patch("copilot.CopilotClient", return_value=mock_copilot_client), - patch("copilot.ExternalServerConfig", return_value=MagicMock()), + patch("copilot.UriRuntimeConnection", return_value=MagicMock()), ): from microbots.bot.CopilotBot import CopilotBot bot = CopilotBot( @@ -801,7 +733,7 @@ def test_byok_token_provider_sets_provider_config(self, mock_environment, mock_c patch("microbots.bot.CopilotBot.CopilotBot._start_copilot_cli_server"), patch("microbots.bot.CopilotBot.CopilotBot._wait_for_cli_ready"), patch("copilot.CopilotClient", return_value=mock_copilot_client), - patch("copilot.ExternalServerConfig", return_value=MagicMock()), + patch("copilot.UriRuntimeConnection", return_value=MagicMock()), ): from microbots.bot.CopilotBot import CopilotBot bot = CopilotBot( @@ -815,6 +747,28 @@ def test_byok_token_provider_sets_provider_config(self, mock_environment, mock_c assert bot.github_token is None bot.stop() + def test_byok_model_id_forwarded_to_provider_config(self, mock_environment, mock_copilot_client): + with ( + patch("microbots.bot.CopilotBot.LocalDockerEnvironment", return_value=mock_environment), + patch("microbots.bot.CopilotBot.get_free_port", side_effect=[9000]), + patch("microbots.bot.CopilotBot.CopilotBot._install_copilot_cli"), + patch("microbots.bot.CopilotBot.CopilotBot._start_copilot_cli_server"), + patch("microbots.bot.CopilotBot.CopilotBot._wait_for_cli_ready"), + patch("copilot.CopilotClient", return_value=mock_copilot_client), + patch("copilot.UriRuntimeConnection", return_value=MagicMock()), + ): + from microbots.bot.CopilotBot import CopilotBot + bot = CopilotBot( + model="my-gpt5-deployment", + environment=mock_environment, + api_key="sk-byok-key", + base_url="https://api.openai.com/v1", + model_id="gpt-5", + ) + assert bot._provider_config["model_id"] == "gpt-5" + assert bot._provider_config["wire_model"] == "my-gpt5-deployment" + bot.stop() + def test_native_auth_has_no_provider_config(self, copilot_bot): assert copilot_bot._provider_config is None assert copilot_bot.github_token == "ghp_test_token_123" @@ -833,7 +787,7 @@ def _standard_init_patches(mock_environment, mock_copilot_client): patch("microbots.bot.CopilotBot.CopilotBot._start_copilot_cli_server"), patch("microbots.bot.CopilotBot.CopilotBot._wait_for_cli_ready"), patch("copilot.CopilotClient", return_value=mock_copilot_client), - patch("copilot.ExternalServerConfig", return_value=MagicMock()), + patch("copilot.UriRuntimeConnection", return_value=MagicMock()), ) @@ -854,7 +808,7 @@ def test_folder_to_mount_creates_mount_object(self, mock_environment, mock_copil patch("microbots.bot.CopilotBot.CopilotBot._start_copilot_cli_server"), patch("microbots.bot.CopilotBot.CopilotBot._wait_for_cli_ready"), patch("copilot.CopilotClient", return_value=mock_copilot_client), - patch("copilot.ExternalServerConfig", return_value=MagicMock()), + patch("copilot.UriRuntimeConnection", return_value=MagicMock()), ): from microbots.bot.CopilotBot import CopilotBot bot = CopilotBot( @@ -880,7 +834,7 @@ def test_auto_creates_environment_when_none(self, mock_environment, mock_copilot patch("microbots.bot.CopilotBot.CopilotBot._start_copilot_cli_server"), patch("microbots.bot.CopilotBot.CopilotBot._wait_for_cli_ready"), patch("copilot.CopilotClient", return_value=mock_copilot_client), - patch("copilot.ExternalServerConfig", return_value=MagicMock()), + patch("copilot.UriRuntimeConnection", return_value=MagicMock()), ): from microbots.bot.CopilotBot import CopilotBot bot = CopilotBot( @@ -917,7 +871,7 @@ def test_non_external_tool_install_and_verify_called( patch("microbots.bot.CopilotBot.CopilotBot._start_copilot_cli_server"), patch("microbots.bot.CopilotBot.CopilotBot._wait_for_cli_ready"), patch("copilot.CopilotClient", return_value=mock_copilot_client), - patch("copilot.ExternalServerConfig", return_value=MagicMock()), + patch("copilot.UriRuntimeConnection", return_value=MagicMock()), ): from microbots.bot.CopilotBot import CopilotBot bot = CopilotBot( @@ -966,9 +920,9 @@ def side_effect(cmd, **kwargs): patch("microbots.bot.CopilotBot.CopilotBot._start_copilot_cli_server"), patch("microbots.bot.CopilotBot.CopilotBot._wait_for_cli_ready"), patch("copilot.CopilotClient", return_value=AsyncMock()), - patch("copilot.ExternalServerConfig", return_value=MagicMock()), + patch("copilot.UriRuntimeConnection", return_value=MagicMock()), ): - with pytest.raises(RuntimeError, match="verification failed"): + with pytest.raises(RuntimeError, match="Failed to verify installation of tool copilot-cli"): CopilotBot( model="gpt-4.1", environment=mock_environment, @@ -992,7 +946,7 @@ def _make_bot_for_server_test(self, mock_environment, mock_copilot_client, githu patch("microbots.bot.CopilotBot.CopilotBot._install_copilot_cli"), patch("microbots.bot.CopilotBot.CopilotBot._wait_for_cli_ready"), patch("copilot.CopilotClient", return_value=mock_copilot_client), - patch("copilot.ExternalServerConfig", return_value=MagicMock()), + patch("copilot.UriRuntimeConnection", return_value=MagicMock()), ): from microbots.bot.CopilotBot import CopilotBot if github_token: @@ -1060,7 +1014,7 @@ def side_effect(cmd, **kwargs): patch("microbots.bot.CopilotBot.CopilotBot._install_copilot_cli"), patch("microbots.bot.CopilotBot.CopilotBot._wait_for_cli_ready"), patch("copilot.CopilotClient", return_value=mock_copilot_client), - patch("copilot.ExternalServerConfig", return_value=MagicMock()), + patch("copilot.UriRuntimeConnection", return_value=MagicMock()), ): from microbots.bot.CopilotBot import CopilotBot with pytest.raises(RuntimeError, match="Failed to start copilot-cli server"): @@ -1177,7 +1131,7 @@ def test_execute_session_includes_provider_config(self, mock_environment, mock_c patch("microbots.bot.CopilotBot.CopilotBot._start_copilot_cli_server"), patch("microbots.bot.CopilotBot.CopilotBot._wait_for_cli_ready"), patch("copilot.CopilotClient", return_value=mock_copilot_client), - patch("copilot.ExternalServerConfig", return_value=MagicMock()), + patch("copilot.UriRuntimeConnection", return_value=MagicMock()), ): from microbots.bot.CopilotBot import CopilotBot bot = CopilotBot( @@ -1219,7 +1173,7 @@ def test_execute_session_includes_system_message(self, mock_environment, mock_co patch("microbots.bot.CopilotBot.CopilotBot._start_copilot_cli_server"), patch("microbots.bot.CopilotBot.CopilotBot._wait_for_cli_ready"), patch("copilot.CopilotClient", return_value=mock_copilot_client), - patch("copilot.ExternalServerConfig", return_value=MagicMock()), + patch("copilot.UriRuntimeConnection", return_value=MagicMock()), ): from microbots.bot.CopilotBot import CopilotBot bot = CopilotBot( @@ -1269,10 +1223,10 @@ def capture_on(callback): patch("microbots.bot.CopilotBot.CopilotBot._start_copilot_cli_server"), patch("microbots.bot.CopilotBot.CopilotBot._wait_for_cli_ready"), patch("copilot.CopilotClient", return_value=mock_copilot_client), - patch("copilot.ExternalServerConfig", return_value=MagicMock()), + patch("copilot.UriRuntimeConnection", return_value=MagicMock()), ): from microbots.bot.CopilotBot import CopilotBot - from copilot.generated.session_events import SessionEventType + from copilot.session_events import SessionEventType bot = CopilotBot( model="gpt-4.1", @@ -1325,7 +1279,7 @@ def test_execute_session_returns_fallback_when_no_content( patch("microbots.bot.CopilotBot.CopilotBot._start_copilot_cli_server"), patch("microbots.bot.CopilotBot.CopilotBot._wait_for_cli_ready"), patch("copilot.CopilotClient", return_value=mock_copilot_client), - patch("copilot.ExternalServerConfig", return_value=MagicMock()), + patch("copilot.UriRuntimeConnection", return_value=MagicMock()), ): from microbots.bot.CopilotBot import CopilotBot bot = CopilotBot( @@ -1400,7 +1354,7 @@ async def _send_and_wait_with_events(task, timeout): patch("microbots.bot.CopilotBot.CopilotBot._start_copilot_cli_server"), patch("microbots.bot.CopilotBot.CopilotBot._wait_for_cli_ready"), patch("copilot.CopilotClient", return_value=mock_copilot_client), - patch("copilot.ExternalServerConfig", return_value=MagicMock()), + patch("copilot.UriRuntimeConnection", return_value=MagicMock()), ): from microbots.bot.CopilotBot import CopilotBot bot = CopilotBot( @@ -1522,29 +1476,20 @@ def test_mount_additional_copy_succeeds(self, copilot_bot): # --------------------------------------------------------------------------- -# Integration tests — SDK compatibility (both pre-0.3.0 and >=0.3.0 layouts) +# Integration-level test — full ``CopilotBot.__init__`` → ``run()`` path # -# These exercise the full ``CopilotBot.__init__`` → ``run()`` → assistant- -# response path against simulated SDK module layouts so we can verify *both* -# supported ``github-copilot-sdk`` shapes in a single test run (only one -# version of the real SDK can be installed at a time). External I/O (Docker, -# CLI startup, network) is stubbed out; everything else — the new -# PermissionHandler import branch, the asyncio loop, session creation, -# event handling, result extraction — runs for real. +# External I/O (Docker, CLI startup, network) is stubbed out; everything +# else — the asyncio loop, session creation, event handling, result +# extraction — runs for real against the github-copilot-sdk 1.x API. # --------------------------------------------------------------------------- @pytest.mark.unit -class TestCopilotBotSDKLayoutIntegration: - """Integration-level tests that drive a full ``run()`` against each - supported SDK module layout.""" - - def _run_bot_with_layout(self, *, session_has_handler, types_has_handler): - """Build and run a CopilotBot under a simulated SDK layout. +class TestCopilotBotSDKIntegration: + """Drives a full ``run()`` through the SDK 1.x client/session API.""" - Returns ``(result, bot, session_handler, types_handler)`` where - ``result`` is the ``BotRunResult`` from ``bot.run()``. - """ + def _run_bot(self): + """Build and run a CopilotBot; return ``(result, bot, client)``.""" # Mock Docker environment env = MagicMock() env.port = 9000 @@ -1577,66 +1522,279 @@ def _run_bot_with_layout(self, *, session_has_handler, types_has_handler): client.stop = AsyncMock() client.create_session = AsyncMock(return_value=session) - with _swap_copilot_sdk_layout( - session_has_handler=session_has_handler, - types_has_handler=types_has_handler, - ) as (session_handler, types_handler): - from microbots.bot.CopilotBot import CopilotBot - with ( - patch("microbots.bot.CopilotBot.LocalDockerEnvironment", return_value=env), - patch("microbots.bot.CopilotBot.get_free_port", side_effect=[9000]), - patch("microbots.bot.CopilotBot.CopilotBot._install_copilot_cli"), - patch("microbots.bot.CopilotBot.CopilotBot._start_copilot_cli_server"), - patch("microbots.bot.CopilotBot.CopilotBot._wait_for_cli_ready"), - patch("copilot.CopilotClient", return_value=client), - patch("copilot.ExternalServerConfig", return_value=MagicMock()), - ): - bot = CopilotBot( - model="gpt-4.1", - environment=env, - github_token="ghp_test", - ) - try: - result = bot.run("Do the thing") - finally: - # Tear down the asyncio loop thread cleanly - bot._loop.call_soon_threadsafe(bot._loop.stop) - bot._thread.join(timeout=2) - bot.environment = None - return result, bot, session_handler, types_handler, client - - def test_run_with_new_sdk_layout_session(self): - """SDK >= 0.3.0: PermissionHandler lives in ``copilot.session``. - - Verifies CopilotBot initialises, picks up the handler from - ``copilot.session``, and ``run()`` completes successfully passing - that handler as ``on_permission_request``.""" - result, bot, session_handler, types_handler, client = self._run_bot_with_layout( - session_has_handler=True, types_has_handler=False - ) - assert types_handler is None - assert bot._PermissionHandler is session_handler + from microbots.bot.CopilotBot import CopilotBot + with ( + patch("microbots.bot.CopilotBot.LocalDockerEnvironment", return_value=env), + patch("microbots.bot.CopilotBot.get_free_port", side_effect=[9000]), + patch("microbots.bot.CopilotBot.CopilotBot._install_copilot_cli"), + patch("microbots.bot.CopilotBot.CopilotBot._start_copilot_cli_server"), + patch("microbots.bot.CopilotBot.CopilotBot._wait_for_cli_ready"), + patch("copilot.CopilotClient", return_value=client), + patch("copilot.UriRuntimeConnection", return_value=MagicMock()), + ): + bot = CopilotBot( + model="gpt-4.1", + environment=env, + github_token="ghp_test", + ) + try: + result = bot.run("Do the thing") + finally: + # Tear down the asyncio loop thread cleanly + bot._loop.call_soon_threadsafe(bot._loop.stop) + bot._thread.join(timeout=2) + bot.environment = None + return result, bot, client + + def test_run_completes_and_wires_permission_handler(self): + result, bot, client = self._run_bot() assert result.status is True, f"run() failed: {result.error}" assert result.error is None - # The new-SDK handler must be the one wired into the session. + assert result.result == "Task completed successfully." kwargs = client.create_session.await_args.kwargs - assert kwargs["on_permission_request"] is session_handler.approve_all + assert kwargs["on_permission_request"] is bot._PermissionHandler.approve_all - def test_run_with_old_sdk_layout_types(self): - """Pre-0.3.0 SDK: PermissionHandler lives in ``copilot.types``. - Verifies CopilotBot falls back to ``copilot.types`` and ``run()`` - still completes successfully wiring that handler through to the - session.""" - result, bot, session_handler, types_handler, client = self._run_bot_with_layout( - session_has_handler=False, types_has_handler=True - ) - assert session_handler is None - assert bot._PermissionHandler is types_handler - assert result.status is True, f"run() failed: {result.error}" - assert result.error is None - kwargs = client.create_session.await_args.kwargs - assert kwargs["on_permission_request"] is types_handler.approve_all +# --------------------------------------------------------------------------- +# Unit tests — session_options, distro and skill tracking +# --------------------------------------------------------------------------- + +async def _fake_create_session( + self, *, model=None, streaming=None, hooks=None, provider=None, + system_message=None, on_permission_request=None, skill_directories=None, + disabled_skills=None, working_directory=None, mcp_servers=None, +): + """Stand-in with the keyword surface of the SDK's ``create_session``.""" + + +@contextlib.contextmanager +def _bot_dependencies(mock_environment, client): + client_cls = MagicMock(return_value=client) + client_cls.create_session = _fake_create_session + with ( + patch("microbots.bot.CopilotBot.LocalDockerEnvironment", return_value=mock_environment) as lde, + patch("microbots.bot.CopilotBot.get_free_port", return_value=9000), + patch("microbots.bot.CopilotBot.CopilotBot._install_copilot_cli"), + patch("microbots.bot.CopilotBot.CopilotBot._start_copilot_cli_server"), + patch("microbots.bot.CopilotBot.CopilotBot._wait_for_cli_ready"), + patch("copilot.CopilotClient", new=client_cls), + patch("copilot.UriRuntimeConnection", return_value=MagicMock()), + ): + from microbots.bot.CopilotBot import CopilotBot + yield CopilotBot, lde + + +@pytest.mark.unit +class TestValidateSessionOptions: + """Tests for the module-level _validate_session_options helper.""" + + def _validate(self, options): + from microbots.bot.CopilotBot import _validate_session_options + return _validate_session_options(options, _fake_create_session) + + def test_none_returns_empty_dict(self): + assert self._validate(None) == {} + + def test_returns_copy_of_valid_options(self): + options = {"skill_directories": ["/workdir/repo/.agents/skills"], "disabled_skills": ["x"]} + validated = self._validate(options) + assert validated == options + assert validated is not options + + def test_rejects_non_mapping(self): + with pytest.raises(TypeError, match="must be a mapping"): + self._validate([("skill_directories", [])]) + + @pytest.mark.parametrize("key", ["model", "provider", "streaming", "hooks", "system_message"]) + def test_rejects_reserved_keys(self, key): + with pytest.raises(ValueError, match=f"cannot set '{key}'"): + self._validate({key: "value"}) + + def test_rejects_unknown_keys_and_lists_supported(self): + with pytest.raises(ValueError, match=r"Unsupported session_options \['skill_dirs'\]") as err: + self._validate({"skill_dirs": []}) + assert "skill_directories" in str(err.value) + assert "'self'" not in str(err.value) + assert "'model'" not in str(err.value) + + +@pytest.mark.unit +class TestCopilotBotSessionOptions: + """session_options are validated up front and forwarded to create_session.""" + + def test_invalid_option_fails_before_environment_creation(self, mock_environment, mock_copilot_client): + with _bot_dependencies(mock_environment, mock_copilot_client) as (CopilotBot, lde): + with pytest.raises(ValueError, match="Unsupported session_options"): + CopilotBot(model="gpt-4.1", github_token="ghp_test", session_options={"nope": 1}) + lde.assert_not_called() + + def test_options_forwarded_and_run_overrides_take_precedence( + self, mock_environment, mock_copilot_client + ): + with _bot_dependencies(mock_environment, mock_copilot_client) as (CopilotBot, _): + bot = CopilotBot( + model="gpt-4.1", + environment=mock_environment, + github_token="ghp_test", + session_options={ + "skill_directories": ["/workdir/repo/.agents/skills"], + "working_directory": "/workdir/repo", + }, + ) + result = bot.run("task", session_options={"working_directory": "/workdir/other"}) + bot.stop() + + assert result.status is True, result.error + kwargs = mock_copilot_client.create_session.call_args.kwargs + assert kwargs["skill_directories"] == ["/workdir/repo/.agents/skills"] + assert kwargs["working_directory"] == "/workdir/other" + assert kwargs["model"] == bot.model + # Per-run overrides do not leak into the bot's defaults. + assert bot.session_options["working_directory"] == "/workdir/repo" + + def test_invalid_run_option_raises(self, mock_environment, mock_copilot_client): + with _bot_dependencies(mock_environment, mock_copilot_client) as (CopilotBot, _): + bot = CopilotBot(model="gpt-4.1", environment=mock_environment, github_token="ghp_test") + with pytest.raises(ValueError, match="cannot set 'model'"): + bot.run("task", session_options={"model": "other"}) + bot.stop() + mock_copilot_client.create_session.assert_not_called() + + def test_option_values_are_not_logged(self, mock_environment, mock_copilot_client, caplog): + import logging + + secret_servers = {"srv": {"env": {"TOKEN": "super-secret"}}} + with _bot_dependencies(mock_environment, mock_copilot_client) as (CopilotBot, _): + bot = CopilotBot( + model="gpt-4.1", + environment=mock_environment, + github_token="ghp_test", + session_options={"mcp_servers": secret_servers}, + ) + with caplog.at_level(logging.DEBUG): + bot.run("task") + bot.stop() + + assert "mcp_servers" in caplog.text + assert "super-secret" not in caplog.text + assert mock_copilot_client.create_session.call_args.kwargs["mcp_servers"] == secret_servers + + +@pytest.mark.unit +class TestCopilotBotSkillEvents: + """Skill events from the session are logged and recorded per run.""" + + def test_records_invoked_skills_and_resets_each_run( + self, mock_environment, mock_copilot_client, mock_copilot_session, caplog + ): + import logging + + captured = [] + mock_copilot_session.on = MagicMock(side_effect=captured.append) + + def _event(event_type, **data): + return Mock(type=event_type, data=Mock(**data)) + + loaded = _event("session.skills_loaded", skills=[Mock(), Mock()]) + loaded.data.skills[0].name = "azldev-comp-toml" + loaded.data.skills[1].name = "azldev-update-component" + invoked = _event("skill.invoked", path="/workdir/repo/.agents/skills/x/SKILL.md") + invoked.data.name = "azldev-update-component" + events = [loaded, invoked] + + response = Mock() + response.data = Mock(content="done") + + async def _send_and_wait(task, timeout): + for event in events: + captured[-1](event) + return response + + mock_copilot_session.send_and_wait = _send_and_wait + + with ( + _bot_dependencies(mock_environment, mock_copilot_client) as (CopilotBot, _), + patch.object(_mock_events.SessionEventType, "SKILL_INVOKED", "skill.invoked", create=True), + patch.object( + _mock_events.SessionEventType, "SESSION_SKILLS_LOADED", "session.skills_loaded", create=True + ), + ): + bot = CopilotBot(model="gpt-4.1", environment=mock_environment, github_token="ghp_test") + with caplog.at_level(logging.INFO): + assert bot.run("first").status is True + assert bot.invoked_skills == ["azldev-update-component"] + + events = [] + assert bot.run("second").status is True + assert bot.invoked_skills == [] + bot.stop() + + assert "Skills loaded (2): azldev-comp-toml, azldev-update-component" in caplog.text + assert "Skill invoked: azldev-update-component" in caplog.text + + +@pytest.mark.unit +class TestCopilotBotImage: + """image selects the sandbox image; copilot-cli is installed per detected distro.""" + + @pytest.mark.parametrize("image, expected", [ + (None, "mcr.microsoft.com/devcontainers/python:3.11"), + ("mcr.microsoft.com/azurelinux/base/core:3.0", "mcr.microsoft.com/azurelinux/base/core:3.0"), + ]) + def test_forwarded_to_auto_created_environment( + self, mock_environment, mock_copilot_client, image, expected + ): + with _bot_dependencies(mock_environment, mock_copilot_client) as (CopilotBot, lde): + bot = CopilotBot(model="gpt-4.1", github_token="ghp_test", image=image) + bot.stop() + assert bot.image == expected + assert lde.call_args.kwargs["image"] == expected + + def test_rejects_image_with_provided_environment(self, mock_environment, mock_copilot_client): + with _bot_dependencies(mock_environment, mock_copilot_client) as (CopilotBot, lde): + with pytest.raises(ValueError, match="Pass either `image` or `environment`"): + CopilotBot(model="gpt-4.1", environment=mock_environment, github_token="ghp_test", image="x/y:z") + lde.assert_not_called() + + @pytest.mark.parametrize( + "ids, first_command", + [ + (["debian"], "rm -f /etc/apt/sources.list.d/yarn.list"), + (["ubuntu", "debian"], "rm -f /etc/apt/sources.list.d/yarn.list"), + # Unknown distro: the tool falls back to its first entry (Debian). + ([], "rm -f /etc/apt/sources.list.d/yarn.list"), + (["azurelinux"], "tdnf install -y curl ca-certificates tar xz > /dev/null"), + ], + ) + def test_copilot_cli_install_matches_sandbox_distro(self, mock_environment, ids, first_command): + import importlib + + bot_module = importlib.import_module("microbots.bot.CopilotBot") + bot = bot_module.CopilotBot.__new__(bot_module.CopilotBot) + bot.environment = mock_environment + mock_environment.get_distro_ids = MagicMock(return_value=ids) + + bot._install_copilot_cli() + + commands = [c.args[0] for c in mock_environment.execute_privileged.call_args_list] + assert commands[0] == first_command + assert commands[-1] == "npm install -g @github/copilot@1.0.105 > /dev/null 2>&1" + if ids == ["azurelinux"]: + assert "sha256sum -c" in commands[1] and "latest-v22.x" in commands[1] + mock_environment.execute.assert_called_once_with("copilot --version") + + def test_copilot_cli_install_rejects_unsupported_distro(self, mock_environment): + import importlib + from microbots.tools.tool import UnsupportedDistroError + + bot_module = importlib.import_module("microbots.bot.CopilotBot") + bot = bot_module.CopilotBot.__new__(bot_module.CopilotBot) + bot.environment = mock_environment + mock_environment.get_distro_ids = MagicMock(return_value=["alpine"]) + + with pytest.raises(UnsupportedDistroError, match="no install commands for distro alpine"): + bot._install_copilot_cli() + mock_environment.execute_privileged.assert_not_called() # --------------------------------------------------------------------------- @@ -1652,7 +1810,7 @@ def _root_bot_dependencies(mock_environment, client): patch("microbots.bot.CopilotBot.CopilotBot._start_copilot_cli_server"), patch("microbots.bot.CopilotBot.CopilotBot._wait_for_cli_ready"), patch("copilot.CopilotClient", return_value=client), - patch("copilot.ExternalServerConfig", return_value=MagicMock()), + patch("copilot.UriRuntimeConnection", return_value=MagicMock()), ): from microbots.bot.CopilotBot import CopilotBot yield CopilotBot, lde diff --git a/test/bot/test_copilot_bot_azurelinux_skills.py b/test/bot/test_copilot_bot_azurelinux_skills.py new file mode 100644 index 0000000..6240a3e --- /dev/null +++ b/test/bot/test_copilot_bot_azurelinux_skills.py @@ -0,0 +1,204 @@ +""" +Integration test: CopilotBot uses a third-party repository's agent skills. + +The Azure Linux repository ships ``AGENTS.md`` plus ``azldev-*`` skills under +``.agents/skills``. This test mounts the checkout into an Azure Linux +sandbox with sudo access (``mock`` needs it), installs the ``rpm``, ``mock`` and ``azldev`` +tool definitions, points the Copilot session at the repository's skills via +``session_options`` and asks the agent to upgrade the ``util-linux`` component. + +By default the checkout is mounted READ_ONLY: the agent works on an overlay and +its changes are discarded with the sandbox, so the host checkout is unchanged. +Set ``AZURELINUX_KEEP_CHANGES=1`` to mount it READ_WRITE and keep the agent's +changes in the checkout for review. + +Prerequisites: + - Docker daemon, ``pip install microbots[ghcp]`` + - Copilot auth: COPILOT_BYOK_* (see test_copilot_bot.py) or GitHub auth + - An Azure Linux checkout at ``AZURELINUX_REPO_PATH`` (default /mnt/azurelinux) + - Network access: the stock Azure Linux image (AZURELINUX_IMAGE) is prepared on + the fly. Set MICROBOTS_SHELL_IMAGE to try a different image. + +Usage: + pytest test/bot/test_copilot_bot_azurelinux_skills.py -m "integration and ghcp" -v +""" + +import logging +import os +import re +import shutil +import subprocess +import hashlib +import sys +from pathlib import Path + +import pytest + +sys.path.insert( + 0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../src")) +) + +from microbots.constants import DOCKER_WORKING_DIR, PermissionLabels +from microbots.environment.local_docker.images import AZURELINUX_IMAGE +from microbots.tools.tool_yaml_parser import parse_tool_definition + +logger = logging.getLogger(__name__) + +AZURELINUX_REPO = Path(os.environ.get("AZURELINUX_REPO_PATH", "/mnt/azurelinux")) +COMPONENT = "util-linux" +RENDERED_SPEC = f"specs/u/{COMPONENT}/{COMPONENT}.spec" +LOCK_FILE = f"locks/{COMPONENT}.lock" +COMP_DIR = f"base/comps/{COMPONENT}" +KEEP_CHANGES = os.environ.get("AZURELINUX_KEEP_CHANGES", "").strip().lower() in ("1", "true", "yes") + +UPGRADE_TASK = f""" +You are working in the Azure Linux repository (your current working directory). +Upgrade the `{COMPONENT}` component to the newest {COMPONENT} version that Fedora +provides upstream (Fedora rawhide). Follow AGENTS.md and use the azldev-* skills +available to you (in particular azldev-comp-toml and azldev-update-component). + +Requirements: +- Change only what is needed under {COMP_DIR}/ to pick up the newer upstream + version, with a TOML comment explaining why. +- Refresh the lock with `azldev comp update -p {COMPONENT}` and re-render with + `azldev comp render -p {COMPONENT}`. +- Confirm {RENDERED_SPEC} now reports the new Version. If it still shows the old + Version, fix the component's source resolution (for example the upstream + snapshot it inherits) and re-run update and render until it does. +- This is an automated validation run on a machine with limited disk space: + do NOT run `azldev comp build`, mock smoke tests, `git commit` or `git push`. +- Finish with a short summary of the old and new versions and the files you changed. +""" + + +def _copilot_sdk_installed(): + try: + from importlib.metadata import version + version("github-copilot-sdk") + return True + except Exception: + return False + + +def _copilot_auth_configured(): + if os.environ.get("COPILOT_BYOK_BASE_URL") and os.environ.get("COPILOT_BYOK_PROVIDER_TYPE"): + return True + if any(os.environ.get(v) for v in ("COPILOT_GITHUB_TOKEN", "GITHUB_TOKEN", "GH_TOKEN")): + return True + if shutil.which("gh"): + try: + return subprocess.run(["gh", "auth", "status"], capture_output=True, timeout=10).returncode == 0 + except Exception: + pass + return False + + +def _docker_available(): + try: + import docker + docker.from_env().ping() + return True + except Exception: + return False + + +def _use_real_copilot_sdk(): + """Drop the SDK mocks other test modules install in sys.modules.""" + for key in [k for k in sys.modules if k == "copilot" or k.startswith("copilot.")]: + del sys.modules[key] + sys.modules.pop("microbots.bot.CopilotBot", None) + + +def _spec_version(spec_text: str) -> tuple[int, ...]: + match = re.search(r"^Version:\s*(\S+)", spec_text, re.MULTILINE) + assert match, "rendered spec has no Version tag" + return tuple(int(part) for part in re.findall(r"\d+", match.group(1))) + + +def _tree_digest(root: Path, paths: list[str]) -> str: + digest = hashlib.sha256() + for rel in paths: + target = root / rel + files = sorted(target.rglob("*")) if target.is_dir() else [target] + for f in files: + if f.is_file(): + digest.update(str(f.relative_to(root)).encode()) + digest.update(f.read_bytes()) + return digest.hexdigest() + + +@pytest.mark.skipif(not _copilot_sdk_installed(), reason="github-copilot-sdk not installed (pip install microbots[ghcp])") +@pytest.mark.skipif(not _copilot_auth_configured(), reason="No Copilot auth (COPILOT_BYOK_* or GitHub auth)") +@pytest.mark.skipif( + not (AZURELINUX_REPO / "AGENTS.md").is_file() or not (AZURELINUX_REPO / ".agents" / "skills").is_dir(), + reason=f"Azure Linux checkout with AGENTS.md and .agents/skills not found at {AZURELINUX_REPO}", +) +@pytest.mark.skipif(not _docker_available(), reason="Docker daemon not available") +@pytest.mark.integration +@pytest.mark.slow +@pytest.mark.docker +@pytest.mark.ghcp +class TestCopilotBotAzureLinuxSkills: + """CopilotBot follows Azure Linux's AGENTS.md and azldev-* skills end to end.""" + + def test_upgrade_util_linux_with_azldev_skills(self): + _use_real_copilot_sdk() + from microbots.bot.CopilotBot import CopilotBot + + tracked = [COMP_DIR, LOCK_FILE, f"specs/u/{COMPONENT}"] + host_digest = _tree_digest(AZURELINUX_REPO, tracked) + original_version = _spec_version((AZURELINUX_REPO / RENDERED_SPEC).read_text()) + permission = PermissionLabels.READ_WRITE if KEEP_CHANGES else PermissionLabels.READ_ONLY + + sandbox_repo = f"{DOCKER_WORKING_DIR}/{AZURELINUX_REPO.name}" + + bot = CopilotBot( + model=os.environ.get("COPILOT_BYOK_MODEL", "gpt-4.1"), + folder_to_mount=str(AZURELINUX_REPO), + permission=permission, + image=os.environ.get("MICROBOTS_SHELL_IMAGE", AZURELINUX_IMAGE), + sudo_access=True, + additional_tools=[parse_tool_definition(name) for name in ("rpm.yaml", "mock.yaml", "azldev.yaml")], + session_options={ + "working_directory": sandbox_repo, + "skill_directories": [f"{sandbox_repo}/.agents/skills"], + }, + ) + + try: + result = bot.run(UPGRADE_TASK, timeout_in_seconds=2400) + logger.info("Agent summary:\n%s", result.result) + assert result.status is True, f"CopilotBot run failed: {result.error}" + + azldev_skills = [s for s in bot.invoked_skills if s.startswith("azldev-")] + assert azldev_skills, f"No azldev-* skill was invoked (invoked: {bot.invoked_skills})" + + changed = bot.environment.execute( + f"git -C {sandbox_repo} status --porcelain -- {COMP_DIR} {LOCK_FILE} {RENDERED_SPEC}" + ) + assert changed.return_code == 0, changed.stderr + assert LOCK_FILE in changed.stdout, f"Lock was not refreshed:\n{changed.stdout}" + assert RENDERED_SPEC in changed.stdout, f"Spec was not re-rendered:\n{changed.stdout}" + + spec = bot.environment.execute(f"cat {sandbox_repo}/{RENDERED_SPEC}") + assert spec.return_code == 0, spec.stderr + new_version = _spec_version(spec.stdout) + assert new_version > original_version, ( + f"{COMPONENT} was not upgraded: {original_version} -> {new_version}" + ) + finally: + bot.stop() + + if KEEP_CHANGES: + assert _tree_digest(AZURELINUX_REPO, tracked) != host_digest + # The agent runs azldev/mock as the host user, so it leaves no root-owned files. + if os.getuid() != 0: + root_owned = [ + p for rel in tracked + for p in [AZURELINUX_REPO / rel, *(AZURELINUX_REPO / rel).rglob("*")] + if p.exists() and p.lstat().st_uid == 0 + ] + assert not root_owned, f"Root-owned files left in the checkout: {root_owned[:5]}" + else: + # READ_ONLY: every change stayed in the sandbox overlay. + assert _tree_digest(AZURELINUX_REPO, tracked) == host_digest diff --git a/test/bot/test_copilot_version_pins.py b/test/bot/test_copilot_version_pins.py new file mode 100644 index 0000000..01205a5 --- /dev/null +++ b/test/bot/test_copilot_version_pins.py @@ -0,0 +1,59 @@ +""" +Keep the github-copilot-sdk pin (``ghcp`` extra in pyproject.toml) and the +copilot-cli pin (copilot-cli.yaml) in step: each SDK release targets one CLI +version, recorded as ``CLI_VERSION`` in the SDK's ``copilot/_cli_version.py``. +""" + +import re +import tomllib +from importlib.metadata import PackageNotFoundError, distribution +from pathlib import Path + +import pytest +import yaml + +_REPO_ROOT = Path(__file__).resolve().parents[2] +_PYPROJECT = _REPO_ROOT / "pyproject.toml" +_CLI_YAML = _REPO_ROOT / "src/microbots/tools/tool_definitions/copilot-cli.yaml" + + +def _sdk_pin() -> str: + extras = tomllib.loads(_PYPROJECT.read_text())["project"]["optional-dependencies"] + (requirement,) = extras["ghcp"] + match = re.fullmatch(r"github-copilot-sdk==(\d+\.\d+\.\d+)", requirement) + assert match, f"ghcp extra must pin an exact github-copilot-sdk version, got {requirement!r}" + return match.group(1) + + +def _cli_pins() -> set[str]: + install_commands = yaml.safe_load(_CLI_YAML.read_text())["install_commands"] + pins = set() + for commands in install_commands.values(): + for command in commands: + pins.update(re.findall(r"@github/copilot@(\S+)", command)) + return pins + + +@pytest.mark.unit +def test_sdk_and_cli_are_pinned_to_exact_versions(): + _sdk_pin() + pins = _cli_pins() + assert len(pins) == 1, f"every distro must install the same copilot-cli version, got {pins}" + assert re.fullmatch(r"\d+\.\d+\.\d+", pins.pop()) + + +@pytest.mark.ghcp +def test_installed_sdk_matches_pins(): + try: + sdk = distribution("github-copilot-sdk") + except PackageNotFoundError: + pytest.skip("github-copilot-sdk not installed (pip install microbots[ghcp])") + + assert sdk.version == _sdk_pin() + cli_version_file = Path(sdk.locate_file("copilot/_cli_version.py")) + match = re.search(r'^CLI_VERSION\b[^=\n]*=\s*"([^"]+)"', cli_version_file.read_text(), re.M) + assert match, f"CLI_VERSION not found in {cli_version_file}" + assert _cli_pins() == {match.group(1)}, ( + f"github-copilot-sdk {sdk.version} targets copilot-cli {match.group(1)}; " + "update the @github/copilot pin in copilot-cli.yaml" + ) diff --git a/test/tools/test_tool_yaml_parser.py b/test/tools/test_tool_yaml_parser.py index c758165..59fdb5a 100644 --- a/test/tools/test_tool_yaml_parser.py +++ b/test/tools/test_tool_yaml_parser.py @@ -82,6 +82,23 @@ def test_azldev_yaml_shares_install_steps_across_distros(self): # azldev runs as the agent, so it needs no root override. assert not any("AZLDEV_ALLOW_ROOT" in c for c in azl + deb) + def test_copilot_cli_yaml_installs_node_and_pinned_cli_per_distro(self): + from microbots.tools.tool import UnsupportedDistroError + + tool = parse_tool_definition("copilot-cli.yaml") + assert isinstance(tool, Tool) + # Infrastructure for CopilotBot: nothing is added to the model's prompt. + assert tool.usage_instructions_to_llm == "" + assert list(tool.install_commands) == ["debian", "azurelinux"] + cli = "npm install -g @github/copilot@1.0.105 > /dev/null 2>&1" + for distro in ("debian", "azurelinux"): + assert tool.resolve_commands("install", [distro])[-1] == cli + assert "nodesource.com/setup_22.x" in " ".join(tool.resolve_commands("install", ["debian"])) + assert "sha256sum -c" in " ".join(tool.resolve_commands("install", ["azurelinux"])) + assert tool.resolve_commands("verify", ["azurelinux"]) == ["copilot --version"] + with pytest.raises(UnsupportedDistroError): + tool.resolve_commands("install", ["alpine"]) + def test_parse_absolute_path(self, tmp_path): """Test parsing with absolute path works correctly.""" yaml_content = """