Compare commits

...

1 Commits

Author SHA1 Message Date
Serena Ruan d5cceea0ac fix(copilot): honour gh-CLI login and support GitHub Enterprise host (#1936)
The copilot SDK harness returned "401 Bad credentials" whenever no GitHub
token was explicitly set, and offered no way to target a GitHub Enterprise
Server (GHE) Copilot instance.

Root cause & fix:
- CopilotExecutor built CopilotClient without `use_logged_in_user`, so a user
  logged in via `gh auth login` with no ambient token env var sent an empty
  credential and got a 401. Now, when no explicit/ambient token resolves, the
  executor opts into `use_logged_in_user=True` so the SDK reuses the gh CLI's
  logged-in user.
- No GHE host could be configured. Added a `copilot.github_host` config field
  (with an ambient `GH_HOST` fallback), a `resolve_copilot_github_host` /
  `copilot_github_host_settings` surface, an `omni setup` -> Copilot prompt to
  set/clear it, spawn-env forwarding (`HARNESS_COPILOT_GITHUB_HOST`), and the
  executor forwards it to the SDK subprocess as `env={"GH_HOST": ...}`.

The setup token/host writes now deep-merge the `copilot:` block so a token and
a host coexist without clobbering each other.

Test Plan:
- tests/e2e/test_copilot_auth_gaps_1936.py: e2e regression (both facets) —
  fails on the pre-fix build (client built with github_token=None and no
  use_logged_in_user -> 401 config; no resolve_copilot_github_host), passes
  once the harness honours the gh-CLI login and forwards the GHE host.
- Added targeted tests: executor bring-up kwargs (use_logged_in_user / GH_HOST
  env), copilot_auth host resolution + settings, and spawn-env host forwarding.
- All copilot test modules green; no new type errors.
2026-08-05 16:36:32 +08:00
9 changed files with 459 additions and 11 deletions
+81 -8
View File
@@ -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.
+40 -2
View File
@@ -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.
+7 -1
View File
@@ -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(),
+45
View File
@@ -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}}
+8
View File
@@ -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)
+152
View File
@@ -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"
+59
View File
@@ -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)
# ---------------------------------------------------------------------------
+42
View File
@@ -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"}
}
+25
View File
@@ -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