Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d5cceea0ac |
+81
-8
@@ -2699,11 +2699,11 @@ def _manage_copilot_harness() -> None:
|
||||
"""
|
||||
from omnigent.onboarding import secrets as secret_store
|
||||
from omnigent.onboarding.copilot_auth import (
|
||||
COPILOT_CONFIG_KEY,
|
||||
COPILOT_SECRET_NAME,
|
||||
copilot_github_token_configured,
|
||||
copilot_github_token_ref,
|
||||
copilot_sdk_installed,
|
||||
resolve_copilot_github_host,
|
||||
)
|
||||
from omnigent.onboarding.interactive import select
|
||||
|
||||
@@ -2718,6 +2718,7 @@ def _manage_copilot_harness() -> None:
|
||||
while True:
|
||||
config = _load_global_config()
|
||||
token_set = copilot_github_token_configured(config)
|
||||
host = resolve_copilot_github_host(config)
|
||||
|
||||
rows: list[_HarnessMenuRow] = [
|
||||
_HarnessMenuRow(
|
||||
@@ -2727,11 +2728,22 @@ def _manage_copilot_harness() -> None:
|
||||
]
|
||||
if token_set:
|
||||
rows.append(_HarnessMenuRow("Remove GitHub token", action="remove_key"))
|
||||
rows.append(
|
||||
_HarnessMenuRow(
|
||||
"Change GitHub Enterprise host" if host else "Set GitHub Enterprise host",
|
||||
action="set_host",
|
||||
)
|
||||
)
|
||||
if host:
|
||||
rows.append(_HarnessMenuRow("Clear GitHub Enterprise host", action="remove_host"))
|
||||
rows.append(_HarnessMenuRow("← Back", action="back"))
|
||||
|
||||
header = (
|
||||
"Copilot — GitHub token configured" if token_set else "Copilot — no GitHub token yet"
|
||||
)
|
||||
if token_set:
|
||||
header = "Copilot — GitHub token configured"
|
||||
else:
|
||||
header = "Copilot — no GitHub token yet"
|
||||
if host:
|
||||
header += f" (host: {host})"
|
||||
idx = select(header, [r.label for r in rows], clear_on_exit=True, status=status)
|
||||
if idx < 0: # Esc / q
|
||||
return
|
||||
@@ -2745,11 +2757,17 @@ def _manage_copilot_harness() -> None:
|
||||
# Only the secret we own (``keychain:copilot``) is ours to delete: a
|
||||
# hand-edited block may point at a shared ``keychain:<other>`` secret,
|
||||
# and an ``env:`` ref names the user's own environment. In both of
|
||||
# those cases just drop the config block and leave the secret.
|
||||
# those cases just drop the reference and leave the secret.
|
||||
if ref == f"keychain:{COPILOT_SECRET_NAME}":
|
||||
secret_store.delete_secret(COPILOT_SECRET_NAME)
|
||||
_save_global_config({}, unset_keys=(COPILOT_CONFIG_KEY,))
|
||||
# Preserve any configured host — remove only the token fields.
|
||||
_remove_copilot_block_fields(config, ("github_token_ref", "github_token"))
|
||||
status = "✓ Removed Copilot GitHub token"
|
||||
elif action == "set_host":
|
||||
status = _set_copilot_github_host()
|
||||
elif action == "remove_host":
|
||||
_remove_copilot_block_fields(config, ("github_host",))
|
||||
status = "✓ Cleared Copilot GitHub Enterprise host"
|
||||
|
||||
|
||||
def _set_copilot_github_token() -> str | None:
|
||||
@@ -2784,7 +2802,10 @@ def _set_copilot_github_token() -> str | None:
|
||||
default=False,
|
||||
):
|
||||
return None
|
||||
_save_global_config(copilot_github_token_settings(f"env:{detected_var}"))
|
||||
_save_global_config(
|
||||
copilot_github_token_settings(f"env:{detected_var}"),
|
||||
deep_merge_keys=("copilot",),
|
||||
)
|
||||
return f"✓ Copilot GitHub token set (from ${detected_var})"
|
||||
|
||||
pasted = prompt_text("GitHub token with Copilot access", hide_input=True).strip()
|
||||
@@ -2797,10 +2818,62 @@ def _set_copilot_github_token() -> str | None:
|
||||
):
|
||||
return None
|
||||
secret_store.store_secret(COPILOT_SECRET_NAME, pasted)
|
||||
_save_global_config(copilot_github_token_settings(f"keychain:{COPILOT_SECRET_NAME}"))
|
||||
_save_global_config(
|
||||
copilot_github_token_settings(f"keychain:{COPILOT_SECRET_NAME}"),
|
||||
deep_merge_keys=("copilot",),
|
||||
)
|
||||
return "✓ Copilot GitHub token stored"
|
||||
|
||||
|
||||
def _remove_copilot_block_fields(
|
||||
config: dict[str, Any], # type: ignore[explicit-any]
|
||||
fields: tuple[str, ...],
|
||||
) -> None:
|
||||
"""Drop *fields* from the ``copilot:`` config block, preserving siblings.
|
||||
|
||||
``_save_global_config`` can only unset whole top-level keys, so removing one
|
||||
field of a block is a read-modify-write: rebuild the block without *fields*
|
||||
and either replace it (still non-empty) or unset the whole ``copilot:`` key
|
||||
(now empty).
|
||||
|
||||
:param config: The already-loaded global config mapping.
|
||||
:param fields: Field names to remove from the ``copilot:`` block.
|
||||
"""
|
||||
from omnigent.onboarding.copilot_auth import COPILOT_CONFIG_KEY
|
||||
|
||||
block = config.get(COPILOT_CONFIG_KEY)
|
||||
remaining = (
|
||||
{k: v for k, v in block.items() if k not in fields} if isinstance(block, dict) else {}
|
||||
)
|
||||
if remaining:
|
||||
_save_global_config({COPILOT_CONFIG_KEY: remaining})
|
||||
else:
|
||||
_save_global_config({}, unset_keys=(COPILOT_CONFIG_KEY,))
|
||||
|
||||
|
||||
def _set_copilot_github_host() -> str | None:
|
||||
"""Prompt for and store a Copilot GitHub Enterprise hostname.
|
||||
|
||||
Deep-merges a ``github_host`` into the ``copilot:`` block so it coexists with
|
||||
any configured token reference. An entered value is normalized to a bare
|
||||
hostname (scheme/path stripped). An empty entry aborts.
|
||||
|
||||
:returns: A status string for the menu, or ``None`` if the user aborted.
|
||||
"""
|
||||
from omnigent.onboarding.copilot_auth import copilot_github_host_settings
|
||||
from omnigent.onboarding.interactive import prompt_text
|
||||
|
||||
entered = prompt_text("GitHub Enterprise hostname (e.g. shs.ghe.com; blank to cancel)").strip()
|
||||
if not entered:
|
||||
return None
|
||||
# Accept a pasted URL by keeping only the host component.
|
||||
host = entered.removeprefix("https://").removeprefix("http://").split("/", 1)[0].strip()
|
||||
if not host:
|
||||
return None
|
||||
_save_global_config(copilot_github_host_settings(host), deep_merge_keys=("copilot",))
|
||||
return f"✓ Copilot GitHub Enterprise host set ({host})"
|
||||
|
||||
|
||||
def _manage_credential(provider: str, family: str) -> str | None:
|
||||
"""Run the level-3 loop for one credential: make default / remove.
|
||||
|
||||
|
||||
@@ -45,7 +45,12 @@ Auth: a **GitHub token** that carries Copilot access — a fine-grained PAT with
|
||||
the "Copilot Requests" permission, or an OAuth token from the GitHub CLI (``gh``)
|
||||
/ Copilot CLI app. Resolved from a spec ``api_key`` or the ambient
|
||||
``COPILOT_GITHUB_TOKEN`` / ``GH_TOKEN`` / ``GITHUB_TOKEN`` (the same precedence
|
||||
the bundled CLI uses). Classic ``ghp_`` tokens are not accepted by Copilot.
|
||||
the bundled CLI uses). Classic ``ghp_`` tokens are not accepted by Copilot. When
|
||||
*no* token resolves, the SDK is asked to reuse the ``gh`` CLI's logged-in user
|
||||
(``use_logged_in_user``) so a plain ``gh auth login`` authenticates the harness
|
||||
without an exported token. A GitHub Enterprise Server instance is targeted by a
|
||||
configured ``copilot.github_host`` (or an ambient ``GH_HOST``), forwarded to the
|
||||
SDK subprocess as ``GH_HOST``.
|
||||
|
||||
Requirements:
|
||||
The ``github-copilot-sdk`` package must be installed (it bundles the
|
||||
@@ -300,6 +305,7 @@ class CopilotExecutor(Executor):
|
||||
os_env: OSEnvSpec | None = None,
|
||||
model: str | None = None,
|
||||
github_token: str | None = None,
|
||||
github_host: str | None = None,
|
||||
bundle_dir: Path | None = None,
|
||||
agent_name: str | None = None,
|
||||
skills_filter: str | list[str] = "all",
|
||||
@@ -314,7 +320,11 @@ class CopilotExecutor(Executor):
|
||||
gateway-routed id or ``None`` falls back to Copilot's auto-select.
|
||||
:param github_token: GitHub token carrying Copilot access. ``None``
|
||||
falls back to ``COPILOT_GITHUB_TOKEN`` / ``GH_TOKEN`` /
|
||||
``GITHUB_TOKEN`` in the environment.
|
||||
``GITHUB_TOKEN`` in the environment; when no token resolves the SDK
|
||||
is asked to reuse the ``gh`` CLI's logged-in user instead.
|
||||
:param github_host: GitHub Enterprise hostname (e.g. ``"shs.ghe.com"``)
|
||||
to route Copilot auth to. ``None`` uses the stock ``github.com``
|
||||
backend (the SDK default).
|
||||
:param bundle_dir: Reserved for future skill wiring; unused in v1.
|
||||
:param agent_name: Optional agent name (reserved for parity).
|
||||
:param skills_filter: Accepted for parity; copilot has no skill
|
||||
@@ -324,6 +334,7 @@ class CopilotExecutor(Executor):
|
||||
self._os_env_spec = os_env
|
||||
self._model_override = model
|
||||
self._github_token = github_token or _ambient_github_token()
|
||||
self._github_host = github_host or _resolve_github_host()
|
||||
self._bundle_dir = bundle_dir
|
||||
self._agent_name = agent_name
|
||||
self._skills_filter = skills_filter
|
||||
@@ -519,10 +530,20 @@ class CopilotExecutor(Executor):
|
||||
# must be absolute"), and a spec / os_env can hand us a relative cwd
|
||||
# (e.g. ``.``), so always resolve to an absolute path.
|
||||
cwd = os.path.abspath(self._cwd or os.getcwd())
|
||||
# With no explicit/ambient token, opt into the SDK reusing the ``gh``
|
||||
# CLI's logged-in user — otherwise ``github_token=None`` is sent as an
|
||||
# empty credential and the backend answers 401 (Bad credentials).
|
||||
use_logged_in_user = True if not self._github_token else None
|
||||
# A GHE host is forwarded via the SDK subprocess env (``GH_HOST``), the
|
||||
# same var the bundled Copilot CLI honors; the SDK has no dedicated
|
||||
# host kwarg. ``None`` leaves the SDK on its default github.com backend.
|
||||
client_env = {"GH_HOST": self._github_host} if self._github_host else None
|
||||
client = CopilotClient(
|
||||
github_token=self._github_token,
|
||||
working_directory=cwd,
|
||||
log_level="error",
|
||||
use_logged_in_user=use_logged_in_user,
|
||||
env=client_env,
|
||||
)
|
||||
try:
|
||||
# ``start()`` is inside the try: it spawns the bundled Copilot CLI
|
||||
@@ -855,6 +876,23 @@ def _ambient_github_token() -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_github_host() -> str | None:
|
||||
"""Return the configured GitHub Enterprise host for Copilot, softly.
|
||||
|
||||
Delegates to :func:`omnigent.onboarding.copilot_auth.resolve_copilot_github_host`
|
||||
(the ``copilot.github_host`` config field, else an ambient ``GH_HOST``),
|
||||
imported lazily so the hot executor path doesn't pull the onboarding layer
|
||||
at module load. Any resolution failure yields ``None`` — a bad host must
|
||||
never sink session bring-up; the SDK then uses its default github.com host.
|
||||
"""
|
||||
try:
|
||||
from omnigent.onboarding import copilot_auth
|
||||
|
||||
return copilot_auth.resolve_copilot_github_host()
|
||||
except Exception: # noqa: BLE001 — host resolution is best-effort
|
||||
return None
|
||||
|
||||
|
||||
def _coerce_args(raw: Any) -> dict[str, Any]: # type: ignore[explicit-any]
|
||||
"""Coerce a tool-call ``arguments`` payload to a dict.
|
||||
|
||||
|
||||
@@ -24,7 +24,11 @@ Env vars read at startup:
|
||||
``None`` falls back to ``os_env.cwd`` then the process cwd.
|
||||
- ``HARNESS_COPILOT_GITHUB_TOKEN``: GitHub token carrying Copilot access, used
|
||||
as the SDK ``github_token``. ``None`` falls back to an inherited
|
||||
``COPILOT_GITHUB_TOKEN`` / ``GH_TOKEN`` / ``GITHUB_TOKEN``.
|
||||
``COPILOT_GITHUB_TOKEN`` / ``GH_TOKEN`` / ``GITHUB_TOKEN``; with no token the
|
||||
executor reuses the ``gh`` CLI's logged-in user.
|
||||
- ``HARNESS_COPILOT_GITHUB_HOST``: GitHub Enterprise hostname to route Copilot
|
||||
auth to. ``None`` falls back to a configured ``copilot.github_host`` / ambient
|
||||
``GH_HOST``, else the stock github.com backend.
|
||||
- ``HARNESS_COPILOT_OS_ENV``: JSON-encoded :class:`OSEnvSpec` (its ``cwd`` is
|
||||
used when ``HARNESS_COPILOT_CWD`` is unset). Defaults to
|
||||
``caller_process + sandbox=none``.
|
||||
@@ -53,6 +57,7 @@ _logger = logging.getLogger(__name__)
|
||||
_ENV_MODEL = "HARNESS_COPILOT_MODEL"
|
||||
_ENV_CWD = "HARNESS_COPILOT_CWD"
|
||||
_ENV_GITHUB_TOKEN = "HARNESS_COPILOT_GITHUB_TOKEN"
|
||||
_ENV_GITHUB_HOST = "HARNESS_COPILOT_GITHUB_HOST"
|
||||
_ENV_OS_ENV = "HARNESS_COPILOT_OS_ENV"
|
||||
_ENV_SKILLS_FILTER = "HARNESS_COPILOT_SKILLS_FILTER"
|
||||
_ENV_BUNDLE_DIR = "HARNESS_COPILOT_BUNDLE_DIR"
|
||||
@@ -133,6 +138,7 @@ def _build_copilot_executor() -> Executor:
|
||||
os_env=_resolve_os_env(),
|
||||
model=os.environ.get(_ENV_MODEL) or None,
|
||||
github_token=os.environ.get(_ENV_GITHUB_TOKEN) or None,
|
||||
github_host=os.environ.get(_ENV_GITHUB_HOST) or None,
|
||||
bundle_dir=bundle_dir,
|
||||
agent_name=os.environ.get(_ENV_AGENT_NAME, "").strip() or None,
|
||||
skills_filter=_resolve_skills_filter(),
|
||||
|
||||
@@ -31,6 +31,7 @@ accepted by Copilot.
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
from omnigent.errors import OmnigentError
|
||||
@@ -45,10 +46,17 @@ COPILOT_SECRET_NAME = "copilot"
|
||||
COPILOT_CONFIG_KEY = "copilot"
|
||||
_TOKEN_REF_FIELD = "github_token_ref"
|
||||
_TOKEN_FIELD = "github_token"
|
||||
# Field naming the GitHub Enterprise Server hostname Copilot auth routes to.
|
||||
_HOST_FIELD = "github_host"
|
||||
|
||||
# Ambient GitHub-token env vars, in the precedence the Copilot CLI/SDK honors.
|
||||
COPILOT_TOKEN_ENV_VARS = ("COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN")
|
||||
|
||||
# Ambient env var the GitHub CLI/Copilot honor to target a GHE instance; used as
|
||||
# a fallback when no ``copilot.github_host`` is configured, mirroring the token
|
||||
# env-var fallback.
|
||||
COPILOT_HOST_ENV_VAR = "GH_HOST"
|
||||
|
||||
# Token-shape prefixes Copilot accepts. The check is deliberately *soft* — a
|
||||
# user may force a non-matching value through — so a future prefix change can
|
||||
# never lock anyone out of their own token. Classic ``ghp_`` PATs are excluded
|
||||
@@ -200,3 +208,40 @@ def copilot_github_token_settings(ref: str) -> dict[str, object]:
|
||||
:returns: ``{"copilot": {"github_token_ref": ref}}``.
|
||||
"""
|
||||
return {COPILOT_CONFIG_KEY: {_TOKEN_REF_FIELD: ref}}
|
||||
|
||||
|
||||
def resolve_copilot_github_host(config: dict[str, object] | None = None) -> str | None:
|
||||
"""Resolve the configured GitHub Enterprise host for Copilot auth, softly.
|
||||
|
||||
Reads the ``github_host`` field of the dedicated ``copilot:`` block; when
|
||||
unset it falls back to an ambient ``GH_HOST`` (the same var the GitHub CLI
|
||||
honors) so an org that already exports it need not re-configure. Returns
|
||||
``None`` for a stock ``github.com`` install, in which case the harness lets
|
||||
the SDK use its default host.
|
||||
|
||||
:param config: A pre-loaded config mapping; ``None`` loads
|
||||
``~/.omnigent/config.yaml`` via :func:`load_config`.
|
||||
:returns: The GHE hostname, e.g. ``"shs.ghe.com"``, or ``None`` when none is
|
||||
configured.
|
||||
"""
|
||||
cfg = load_config() if config is None else config
|
||||
block = cfg.get(COPILOT_CONFIG_KEY)
|
||||
if isinstance(block, dict):
|
||||
host = block.get(_HOST_FIELD)
|
||||
if isinstance(host, str) and host.strip():
|
||||
return host.strip()
|
||||
env_host = os.environ.get(COPILOT_HOST_ENV_VAR)
|
||||
return env_host.strip() if env_host and env_host.strip() else None
|
||||
|
||||
|
||||
def copilot_github_host_settings(host: str) -> dict[str, object]:
|
||||
"""Build the ``{"copilot": {"github_host": host}}`` settings dict.
|
||||
|
||||
Handed to :func:`omnigent.cli._save_global_config` with the ``copilot`` key
|
||||
in ``deep_merge_keys`` so it layers onto (rather than replaces) an existing
|
||||
``github_token_ref`` in the same block.
|
||||
|
||||
:param host: The GHE hostname to record, e.g. ``"shs.ghe.com"``.
|
||||
:returns: ``{"copilot": {"github_host": host}}``.
|
||||
"""
|
||||
return {COPILOT_CONFIG_KEY: {_HOST_FIELD: host}}
|
||||
|
||||
@@ -2125,6 +2125,14 @@ def _build_copilot_spawn_env(
|
||||
if os.environ.get(_env_var):
|
||||
env["HARNESS_COPILOT_GITHUB_TOKEN"] = os.environ[_env_var]
|
||||
break
|
||||
# A configured GitHub Enterprise host applies regardless of the token
|
||||
# source (spec api-key or stored/ambient token), so resolve it outside the
|
||||
# no-spec-auth branch. Unset for a stock github.com install.
|
||||
from omnigent.onboarding.copilot_auth import resolve_copilot_github_host
|
||||
|
||||
github_host = resolve_copilot_github_host()
|
||||
if github_host is not None:
|
||||
env["HARNESS_COPILOT_GITHUB_HOST"] = github_host
|
||||
# Always set so the wrap doesn't fall back to ``"all"`` and override an
|
||||
# explicit ``skills: none`` from the spec (parity with the peer builders).
|
||||
env["HARNESS_COPILOT_SKILLS_FILTER"] = json.dumps(spec.skills_filter)
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
"""Regression e2e for issue #1936 — copilot harness auth gaps.
|
||||
|
||||
The bug report is a compound bug with two independent sub-symptoms; each gets
|
||||
its own test so a partially-landed fix can't hide the still-broken half.
|
||||
|
||||
Facet 1 — CLI login not honoured
|
||||
A user logged in via ``gh auth login`` with NO ``COPILOT_GITHUB_TOKEN`` /
|
||||
``GH_TOKEN`` / ``GITHUB_TOKEN`` in the environment gets a 401. Root cause:
|
||||
:class:`~omnigent.inner.copilot_executor.CopilotExecutor` constructs the SDK
|
||||
``CopilotClient`` without ``use_logged_in_user=True`` and has no ``gh`` CLI
|
||||
fallback, so ``github_token`` resolves to ``None`` and the SDK rejects the
|
||||
empty credential. ``omnigent setup`` -> Copilot also offers no
|
||||
"Login via GitHub CLI" path.
|
||||
|
||||
Facet 2 — no GitHub Enterprise host configuration
|
||||
There is no ``copilot.github_host`` config field, no ``omnigent setup``
|
||||
prompt for a GHE hostname, and the host is never forwarded to the SDK, so an
|
||||
org whose Copilot lives on a GHE instance (e.g. ``shs.ghe.com``) cannot be
|
||||
targeted.
|
||||
|
||||
These drive the REAL executor client construction and the REAL ``copilot_auth``
|
||||
config surface against a spy ``copilot`` SDK module (no network, no bundled
|
||||
CLI), so they are deterministic and unskipped. They FAIL on the pre-fix build
|
||||
(no ``use_logged_in_user``, no ``resolve_copilot_github_host``) and PASS once the
|
||||
harness honours the gh-CLI login and forwards a configured GHE host.
|
||||
|
||||
Run::
|
||||
|
||||
pytest tests/e2e/test_copilot_auth_gaps_1936.py -v
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
import types
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
# Ambient GitHub-token env vars the executor consults; cleared per-test so we
|
||||
# faithfully simulate "logged in via gh CLI, but no token exported".
|
||||
_TOKEN_ENV_VARS = ("COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN")
|
||||
|
||||
|
||||
def _install_spy_copilot(monkeypatch: pytest.MonkeyPatch, capture: dict[str, Any]) -> None:
|
||||
"""Install a spy ``copilot`` SDK module that records ``CopilotClient`` kwargs.
|
||||
|
||||
``start()`` mimics the real SDK: with no ``github_token`` AND no
|
||||
``use_logged_in_user`` opt-in, credential validation fails with the exact
|
||||
401 the report shows. Once either is supplied, ``start()`` succeeds.
|
||||
"""
|
||||
module = types.ModuleType("copilot")
|
||||
|
||||
class _SpySession: ...
|
||||
|
||||
class _SpyClient:
|
||||
def __init__(self, **kwargs: Any) -> None:
|
||||
capture["client_kwargs"] = kwargs
|
||||
|
||||
async def start(self) -> None:
|
||||
kw = capture["client_kwargs"]
|
||||
if not kw.get("github_token") and not kw.get("use_logged_in_user"):
|
||||
raise RuntimeError(
|
||||
"JSON-RPC Error -32603: Request session.create failed with "
|
||||
"message: Authentication failed: Failed to validate SDK "
|
||||
"token (401): GitHub returned: Bad credentials"
|
||||
)
|
||||
|
||||
async def stop(self) -> None: ...
|
||||
|
||||
async def create_session(self, **kwargs: Any) -> _SpySession:
|
||||
capture["create_kwargs"] = kwargs
|
||||
return _SpySession()
|
||||
|
||||
class _Tool:
|
||||
def __init__(self, **kwargs: Any) -> None:
|
||||
self.__dict__.update(kwargs)
|
||||
|
||||
class _ToolResult:
|
||||
def __init__(self, **kwargs: Any) -> None:
|
||||
self.__dict__.update(kwargs)
|
||||
|
||||
module.CopilotClient = _SpyClient # type: ignore[attr-defined]
|
||||
module.Tool = _Tool # type: ignore[attr-defined]
|
||||
module.ToolResult = _ToolResult # type: ignore[attr-defined]
|
||||
module.PermissionHandler = types.SimpleNamespace(approve_all="approve_all") # type: ignore[attr-defined]
|
||||
monkeypatch.setitem(sys.modules, "copilot", module)
|
||||
|
||||
rpc = types.ModuleType("copilot.rpc")
|
||||
rpc.PermissionDecisionApproveOnce = type("A", (), {"kind": "approve-once"}) # type: ignore[attr-defined]
|
||||
rpc.PermissionDecisionReject = type( # type: ignore[attr-defined]
|
||||
"R", (), {"__init__": lambda self, feedback=None: None}
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "copilot.rpc", rpc)
|
||||
|
||||
|
||||
def _build_client_kwargs(monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]:
|
||||
"""Drive the real executor bring-up; return (captured, start_error)."""
|
||||
for var in _TOKEN_ENV_VARS:
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
|
||||
capture: dict[str, Any] = {}
|
||||
_install_spy_copilot(monkeypatch, capture)
|
||||
|
||||
import omnigent.inner.copilot_executor as ce
|
||||
|
||||
executor = ce.CopilotExecutor() # no token: relies on the gh CLI login
|
||||
state = ce._CopilotSessionState()
|
||||
|
||||
async def _drive() -> Exception | None:
|
||||
try:
|
||||
await executor._ensure_session(state, model=None, tools=[], system_prompt="")
|
||||
except Exception as exc:
|
||||
return exc
|
||||
return None
|
||||
|
||||
error = asyncio.run(_drive())
|
||||
capture["start_error"] = error
|
||||
capture["resolved_token"] = executor._github_token
|
||||
return capture
|
||||
|
||||
|
||||
def test_copilot_honours_gh_cli_login_without_token_env(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Facet 1: gh-CLI login must authenticate the harness without a token env var."""
|
||||
capture = _build_client_kwargs(monkeypatch)
|
||||
|
||||
# Precondition sanity: with no env token the executor resolves None.
|
||||
assert capture["resolved_token"] is None
|
||||
|
||||
# The harness must opt into the SDK's logged-in-user auth so a gh-CLI login
|
||||
# with no token env var authenticates instead of 401-ing (Bad credentials).
|
||||
assert capture["client_kwargs"].get("use_logged_in_user") is True
|
||||
assert capture["start_error"] is None
|
||||
|
||||
|
||||
def test_copilot_supports_github_enterprise_host(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Facet 2: a GHE hostname must be configurable and forwarded to the SDK."""
|
||||
import omnigent.onboarding.copilot_auth as copilot_auth
|
||||
|
||||
# A GHE hostname must be configurable (a resolver exists) and forwarded to
|
||||
# the SDK so an org whose Copilot lives on a GHE instance can be targeted.
|
||||
assert hasattr(copilot_auth, "resolve_copilot_github_host")
|
||||
monkeypatch.setattr(copilot_auth, "resolve_copilot_github_host", lambda *a, **k: "shs.ghe.com")
|
||||
capture = _build_client_kwargs(monkeypatch)
|
||||
forwarded = capture["client_kwargs"]
|
||||
host_seen = forwarded.get("github_host") or ((forwarded.get("env") or {}).get("GH_HOST"))
|
||||
assert host_seen == "shs.ghe.com"
|
||||
@@ -350,6 +350,65 @@ def test_capabilities() -> None:
|
||||
assert ex.supports_live_message_queue() is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Session bring-up auth wiring (issue #1936)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _ensure_session_kwargs(
|
||||
monkeypatch: pytest.MonkeyPatch, executor: CopilotExecutor
|
||||
) -> dict[str, Any]:
|
||||
"""Drive ``_ensure_session`` against the fake SDK; return CopilotClient kwargs."""
|
||||
from omnigent.inner.copilot_executor import _CopilotSessionState
|
||||
|
||||
state_capture = _install_fake_copilot(monkeypatch)
|
||||
session_state = _CopilotSessionState()
|
||||
asyncio.run(executor._ensure_session(session_state, model=None, tools=[], system_prompt=""))
|
||||
return state_capture["client_kwargs"][-1]
|
||||
|
||||
|
||||
def test_ensure_session_opts_into_logged_in_user_without_token(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Facet 1: no token -> use_logged_in_user=True so the gh-CLI login is honored."""
|
||||
for var in ("COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"):
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
monkeypatch.setattr("omnigent.inner.copilot_executor._resolve_github_host", lambda: None)
|
||||
kwargs = _ensure_session_kwargs(monkeypatch, CopilotExecutor())
|
||||
assert kwargs["github_token"] is None
|
||||
assert kwargs["use_logged_in_user"] is True
|
||||
|
||||
|
||||
def test_ensure_session_prefers_explicit_token_over_logged_in_user(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""An explicit token is used directly; the logged-in-user opt-in stays off."""
|
||||
monkeypatch.setattr("omnigent.inner.copilot_executor._resolve_github_host", lambda: None)
|
||||
kwargs = _ensure_session_kwargs(monkeypatch, CopilotExecutor(github_token="gho_explicit"))
|
||||
assert kwargs["github_token"] == "gho_explicit"
|
||||
assert kwargs["use_logged_in_user"] is None
|
||||
|
||||
|
||||
def test_ensure_session_forwards_github_enterprise_host(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Facet 2: a configured GHE host reaches the SDK as env['GH_HOST']."""
|
||||
monkeypatch.setattr(
|
||||
"omnigent.inner.copilot_executor._resolve_github_host", lambda: "shs.ghe.com"
|
||||
)
|
||||
kwargs = _ensure_session_kwargs(monkeypatch, CopilotExecutor(github_token="gho_x"))
|
||||
assert kwargs["env"] == {"GH_HOST": "shs.ghe.com"}
|
||||
|
||||
|
||||
def test_ensure_session_no_env_for_stock_github(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""A stock github.com install forwards no env override (env=None)."""
|
||||
monkeypatch.setattr("omnigent.inner.copilot_executor._resolve_github_host", lambda: None)
|
||||
kwargs = _ensure_session_kwargs(monkeypatch, CopilotExecutor(github_token="gho_x"))
|
||||
assert kwargs["env"] is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tool-result encoding + bridge (needs the fake ToolResult)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -21,6 +21,7 @@ from omnigent.onboarding import copilot_auth, extra_install
|
||||
from omnigent.onboarding import secrets as secret_store
|
||||
from omnigent.onboarding.copilot_auth import (
|
||||
COPILOT_SECRET_NAME,
|
||||
copilot_github_host_settings,
|
||||
copilot_github_token_configured,
|
||||
copilot_github_token_ref,
|
||||
copilot_github_token_settings,
|
||||
@@ -28,6 +29,7 @@ from omnigent.onboarding.copilot_auth import (
|
||||
copilot_sdk_installed,
|
||||
install_copilot_sdk,
|
||||
looks_like_github_copilot_token,
|
||||
resolve_copilot_github_host,
|
||||
resolve_copilot_github_token,
|
||||
)
|
||||
|
||||
@@ -254,3 +256,43 @@ def test_install_copilot_sdk_false_on_spawn_failure(monkeypatch: pytest.MonkeyPa
|
||||
monkeypatch.setattr(extra_install.shutil, "which", lambda name: None)
|
||||
monkeypatch.setattr(copilot_auth.subprocess, "run", _boom)
|
||||
assert install_copilot_sdk() is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GitHub Enterprise host (issue #1936, Facet 2)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_resolve_github_host_none_when_unconfigured(tmp_path: Path) -> None:
|
||||
"""A stock install with no host config and no GH_HOST resolves to None."""
|
||||
_write_config(tmp_path, {"copilot": {"github_token_ref": "keychain:copilot"}})
|
||||
assert resolve_copilot_github_host() is None
|
||||
|
||||
|
||||
def test_resolve_github_host_from_config(tmp_path: Path) -> None:
|
||||
"""A configured ``copilot.github_host`` resolves (trimmed)."""
|
||||
_write_config(tmp_path, {"copilot": {"github_host": " shs.ghe.com "}})
|
||||
assert resolve_copilot_github_host() == "shs.ghe.com"
|
||||
|
||||
|
||||
def test_resolve_github_host_falls_back_to_env(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""With no config field, an ambient ``GH_HOST`` is honored."""
|
||||
_write_config(tmp_path, {})
|
||||
monkeypatch.setenv("GH_HOST", "ghe.example.com")
|
||||
assert resolve_copilot_github_host() == "ghe.example.com"
|
||||
|
||||
|
||||
def test_config_field_wins_over_env(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""A configured host takes precedence over an ambient ``GH_HOST``."""
|
||||
_write_config(tmp_path, {"copilot": {"github_host": "configured.ghe.com"}})
|
||||
monkeypatch.setenv("GH_HOST", "ambient.ghe.com")
|
||||
assert resolve_copilot_github_host() == "configured.ghe.com"
|
||||
|
||||
|
||||
def test_host_settings_shape() -> None:
|
||||
"""The settings dict targets the ``copilot.github_host`` field."""
|
||||
assert copilot_github_host_settings("shs.ghe.com") == {
|
||||
"copilot": {"github_host": "shs.ghe.com"}
|
||||
}
|
||||
|
||||
@@ -107,3 +107,28 @@ def test_no_auth_prefers_stored_block_over_ambient(
|
||||
def test_bundle_dir_threaded(tmp_path: Path) -> None:
|
||||
env = _build_copilot_spawn_env(_make_spec(), workdir=tmp_path)
|
||||
assert env["HARNESS_COPILOT_BUNDLE_DIR"] == str(tmp_path)
|
||||
|
||||
|
||||
def test_github_host_forwarded_from_config(tmp_path: Path) -> None:
|
||||
"""A configured ``copilot.github_host`` reaches HARNESS_COPILOT_GITHUB_HOST."""
|
||||
(tmp_path / "config.yaml").write_text(
|
||||
yaml.safe_dump({"copilot": {"github_host": "shs.ghe.com"}})
|
||||
)
|
||||
env = _build_copilot_spawn_env(_make_spec())
|
||||
assert env["HARNESS_COPILOT_GITHUB_HOST"] == "shs.ghe.com"
|
||||
|
||||
|
||||
def test_github_host_forwarded_with_api_key_auth(tmp_path: Path) -> None:
|
||||
"""The GHE host applies even when the token comes from an api-key auth."""
|
||||
(tmp_path / "config.yaml").write_text(
|
||||
yaml.safe_dump({"copilot": {"github_host": "shs.ghe.com"}})
|
||||
)
|
||||
env = _build_copilot_spawn_env(_make_spec(auth=ApiKeyAuth(api_key="gho_abc")))
|
||||
assert env["HARNESS_COPILOT_GITHUB_TOKEN"] == "gho_abc"
|
||||
assert env["HARNESS_COPILOT_GITHUB_HOST"] == "shs.ghe.com"
|
||||
|
||||
|
||||
def test_github_host_absent_when_unconfigured() -> None:
|
||||
"""A stock github.com install sets no host env var."""
|
||||
env = _build_copilot_spawn_env(_make_spec())
|
||||
assert "HARNESS_COPILOT_GITHUB_HOST" not in env
|
||||
|
||||
Reference in New Issue
Block a user