|
| 1 | +# ******************************************************************************* |
| 2 | +# Copyright (c) 2026 Contributors to the Eclipse Foundation |
| 3 | +# |
| 4 | +# See the NOTICE file(s) distributed with this work for additional |
| 5 | +# information regarding copyright ownership. |
| 6 | +# |
| 7 | +# This program and the accompanying materials are made available under the |
| 8 | +# terms of the Apache License Version 2.0 which is available at |
| 9 | +# https://www.apache.org/licenses/LICENSE-2.0 |
| 10 | +# |
| 11 | +# SPDX-License-Identifier: Apache-2.0 |
| 12 | +# ******************************************************************************* |
| 13 | +"""Lifecycle management for the Copilot CLI subprocess and SDK client.""" |
| 14 | + |
| 15 | +from __future__ import annotations |
| 16 | + |
| 17 | +import logging |
| 18 | +from typing import Any, Optional |
| 19 | + |
| 20 | +from copilot import CopilotClient, SubprocessConfig |
| 21 | + |
| 22 | +from ._errors import CopilotSetupError |
| 23 | +from ._preflight import ( |
| 24 | + check_auth_sources, |
| 25 | + check_cli_binary, |
| 26 | + check_environment, |
| 27 | + describe_auth_sources, |
| 28 | + resolve_copilot_cli_path, |
| 29 | +) |
| 30 | + |
| 31 | +logger = logging.getLogger(__name__) |
| 32 | + |
| 33 | + |
| 34 | +class CopilotClientManager: |
| 35 | + """Owns the lifecycle of a single CopilotClient / CLI subprocess. |
| 36 | +
|
| 37 | + Responsibilities: |
| 38 | + - Resolve the CLI binary path (rules_python copy_executables workaround) |
| 39 | + - Run pre-flight checks before spawning the process |
| 40 | + - Start the subprocess and verify authentication |
| 41 | + - Expose the live client for callers |
| 42 | + - Shut the process down cleanly on close |
| 43 | +
|
| 44 | + This class is intentionally not a Pydantic model — it holds mutable |
| 45 | + runtime state that must not be serialised. |
| 46 | + """ |
| 47 | + |
| 48 | + def __init__(self, copilot_client_options: dict[str, Any] | None = None) -> None: |
| 49 | + self._options: dict[str, Any] = dict(copilot_client_options or {}) |
| 50 | + self._client: Optional[CopilotClient] = None |
| 51 | + self._started: bool = False |
| 52 | + |
| 53 | + # ------------------------------------------------------------------ |
| 54 | + # Public interface |
| 55 | + # ------------------------------------------------------------------ |
| 56 | + |
| 57 | + async def ensure_client(self) -> CopilotClient: |
| 58 | + """Return a started, authenticated CopilotClient. |
| 59 | +
|
| 60 | + Creates and starts the client on the first call; subsequent calls |
| 61 | + return the cached instance immediately. |
| 62 | +
|
| 63 | + Pre-flight sequence (runs once, before the CLI is spawned): |
| 64 | + 1. Resolve the CLI binary path |
| 65 | + 2. Validate the binary exists and is executable |
| 66 | + 3. Hard-fail if no auth source is available at all |
| 67 | + 4. Warn about missing $HOME / HTTPS_PROXY (non-fatal) |
| 68 | + 5. Start the CLI subprocess |
| 69 | + 6. Verify authentication via get_auth_status() |
| 70 | +
|
| 71 | + Raises: |
| 72 | + CopilotSetupError: With a detailed, actionable message for any |
| 73 | + failure that prevents the CLI from being used. |
| 74 | + """ |
| 75 | + if self._client is None: |
| 76 | + self._client = self._create_client() |
| 77 | + |
| 78 | + if not self._started: |
| 79 | + await self._start_and_verify() |
| 80 | + |
| 81 | + return self._client |
| 82 | + |
| 83 | + async def close(self) -> None: |
| 84 | + """Stop the CLI subprocess if it is running.""" |
| 85 | + if self._client and self._started: |
| 86 | + await self._client.stop() |
| 87 | + self._started = False |
| 88 | + |
| 89 | + # ------------------------------------------------------------------ |
| 90 | + # Internal helpers |
| 91 | + # ------------------------------------------------------------------ |
| 92 | + |
| 93 | + def _create_client(self) -> CopilotClient: |
| 94 | + """Run pre-flight checks and construct (but do not start) the client.""" |
| 95 | + opts = dict(self._options) |
| 96 | + |
| 97 | + # --- Resolve CLI binary path ---------------------------------- |
| 98 | + if "cli_path" not in opts and "cli_url" not in opts: |
| 99 | + resolved = resolve_copilot_cli_path() |
| 100 | + if resolved: |
| 101 | + opts["cli_path"] = resolved |
| 102 | + logger.info("Resolved Copilot CLI path: %s", resolved) |
| 103 | + else: |
| 104 | + logger.warning( |
| 105 | + "Could not find copilot_cli (copy_executables target). " |
| 106 | + "Falling back to bundled binary — this may fail with " |
| 107 | + "PermissionError if the executable bit was stripped." |
| 108 | + ) |
| 109 | + |
| 110 | + # --- Check binary -------------------------------------------- |
| 111 | + cli_path = opts.get("cli_path") |
| 112 | + if cli_path: |
| 113 | + problems = check_cli_binary(cli_path) |
| 114 | + if problems: |
| 115 | + raise CopilotSetupError( |
| 116 | + "Copilot CLI binary check failed:\n" |
| 117 | + + "\n".join(f" - {p}" for p in problems) |
| 118 | + ) |
| 119 | + |
| 120 | + # --- Hard-fail if no auth source available ------------------- |
| 121 | + auth_problems = check_auth_sources() |
| 122 | + if auth_problems: |
| 123 | + raise CopilotSetupError( |
| 124 | + "Copilot authentication pre-flight check failed — " |
| 125 | + "the CLI process will not be started:\n" |
| 126 | + + "\n".join(f" - {p}" for p in auth_problems) |
| 127 | + + "\n\n" |
| 128 | + + describe_auth_sources() |
| 129 | + ) |
| 130 | + |
| 131 | + # --- Warn about non-fatal env issues ------------------------- |
| 132 | + env_problems = check_environment() |
| 133 | + if env_problems: |
| 134 | + logger.warning( |
| 135 | + "Environment issues detected:\n%s\n%s", |
| 136 | + "\n".join(f" - {p}" for p in env_problems), |
| 137 | + describe_auth_sources(), |
| 138 | + ) |
| 139 | + |
| 140 | + logger.info("Starting CopilotClient...\n%s", describe_auth_sources()) |
| 141 | + _subprocess_fields = frozenset( |
| 142 | + { |
| 143 | + "cli_path", |
| 144 | + "cli_args", |
| 145 | + "cwd", |
| 146 | + "use_stdio", |
| 147 | + "port", |
| 148 | + "log_level", |
| 149 | + "env", |
| 150 | + "github_token", |
| 151 | + "use_logged_in_user", |
| 152 | + "telemetry", |
| 153 | + "session_fs", |
| 154 | + "session_idle_timeout_seconds", |
| 155 | + } |
| 156 | + ) |
| 157 | + subprocess_kwargs = {k: v for k, v in opts.items() if k in _subprocess_fields} |
| 158 | + return CopilotClient(SubprocessConfig(**subprocess_kwargs)) |
| 159 | + |
| 160 | + async def _start_and_verify(self) -> None: |
| 161 | + """Start the CLI subprocess and verify authentication.""" |
| 162 | + assert self._client is not None |
| 163 | + |
| 164 | + try: |
| 165 | + await self._client.start() |
| 166 | + except PermissionError as exc: |
| 167 | + raise CopilotSetupError( |
| 168 | + f"PermissionError starting Copilot CLI: {exc}\n" |
| 169 | + " The CLI binary is not executable. Make sure\n" |
| 170 | + " pip.whl_mods / copy_executables is configured in MODULE.bazel\n" |
| 171 | + " to create an executable copy of copilot/bin/copilot." |
| 172 | + ) from exc |
| 173 | + except RuntimeError as exc: |
| 174 | + if "timeout" in str(exc).lower() or "Timeout" in str(exc): |
| 175 | + raise CopilotSetupError( |
| 176 | + f"Timeout starting Copilot CLI server: {exc}\n" |
| 177 | + " The CLI started but did not become ready in time.\n" |
| 178 | + " This usually means the CLI cannot authenticate.\n\n" |
| 179 | + + describe_auth_sources() |
| 180 | + + "\n\n" |
| 181 | + " Possible fixes:\n" |
| 182 | + " 1. Run 'copilot' in a terminal and sign in interactively.\n" |
| 183 | + " 2. Set COPILOT_GITHUB_TOKEN (or GH_TOKEN / GITHUB_TOKEN)\n" |
| 184 | + " and pass it via --action_env=COPILOT_GITHUB_TOKEN.\n" |
| 185 | + " 3. Ensure HOME is available in the action environment\n" |
| 186 | + " (use_default_shell_env = True in the Bazel rule).\n" |
| 187 | + " See: https://github.com/github/copilot-sdk/blob/main/docs/auth/index.md" |
| 188 | + ) from exc |
| 189 | + raise |
| 190 | + except Exception as exc: |
| 191 | + raise CopilotSetupError( |
| 192 | + f"Failed to start CopilotClient: {type(exc).__name__}: {exc}\n\n" |
| 193 | + + describe_auth_sources() |
| 194 | + ) from exc |
| 195 | + |
| 196 | + self._started = True |
| 197 | + await self._verify_auth() |
| 198 | + |
| 199 | + async def _verify_auth(self) -> None: |
| 200 | + """Log the result of get_auth_status() as a diagnostic; never hard-fail. |
| 201 | +
|
| 202 | + Rationale: get_auth_status() can return isAuthenticated=False even when |
| 203 | + the CLI is fully functional — for example: |
| 204 | + - The auth state is resolved lazily on the first real request. |
| 205 | + - GitHub Enterprise hosts (*.ghe.com) may not be reflected immediately. |
| 206 | + - There is a brief window after start() where the status is not yet set. |
| 207 | +
|
| 208 | + A false-positive hard-fail here would block valid requests. The actual |
| 209 | + LLM call (send_and_wait) will fail with a clear error if auth is truly |
| 210 | + broken, so we demote this check to a warning-only diagnostic. |
| 211 | + """ |
| 212 | + assert self._client is not None |
| 213 | + try: |
| 214 | + auth_status = await self._client.get_auth_status() |
| 215 | + # The SDK uses camelCase on some versions, snake_case on others. |
| 216 | + is_auth = getattr(auth_status, "isAuthenticated", None) or getattr( |
| 217 | + auth_status, "is_authenticated", None |
| 218 | + ) |
| 219 | + if is_auth: |
| 220 | + user = getattr(auth_status, "login", "unknown") |
| 221 | + logger.info("Copilot authenticated as: %s", user) |
| 222 | + else: |
| 223 | + # Log as a warning only — do not raise. The CLI may still work. |
| 224 | + logger.warning( |
| 225 | + "get_auth_status() reports isAuthenticated=False — " |
| 226 | + "continuing anyway; auth may be resolved on first request.\n" |
| 227 | + " Auth status: %s\n%s", |
| 228 | + auth_status, |
| 229 | + describe_auth_sources(), |
| 230 | + ) |
| 231 | + except Exception as exc: |
| 232 | + # get_auth_status itself failed — log but do not block. |
| 233 | + logger.warning( |
| 234 | + "Could not verify auth status (non-fatal): %s: %s", |
| 235 | + type(exc).__name__, |
| 236 | + exc, |
| 237 | + ) |
0 commit comments