Skip to content

Regression (1.0.88 → 1.0.89): MCP OAuth authorization page opens on every ACP session instead of the challenge being resolved silently #4994

Open

Description

@avinashbhat09

Describe the bug

An OAuth-protected HTTP MCP server that has a valid, unexpired access token in
~/.copilot/mcp-oauth-config is answered with HTTP 401 on every new --acp
session, and the CLI responds by opening the OAuth authorization page again.
Because the browser SSO session is already established, the flow self-approves
and the tab closes — so the user sees an unrequested sign-in page flash open on
every single session start, with no way to suppress it.

This is a regression: 1.0.88 connects the same server, with the same token,
and resolves the identical OAuth challenge silently — no page, no interaction.

Critically, the stored token has no refresh token (the tokens file contains
only accessToken, expiresAt, scope), so there is no silent renewal path —
the CLI appears to fall straight through to the interactive flow rather than
using the access token it already holds.

Affected version

  • Last known good: 1.0.88
  • Broken: 1.0.89

A single version bump. Both were tested on the same machine, minutes apart, against
the same MCP server and the same on-disk token.

Installed Size SHA-256 (first 16) --no-auto-update --version Behaviour
Aug 14 152.1 MB 4F590F1F60F3F2BD 1.0.80 works — challenge resolved silently
Sep 23 144.2 MB E2160809894BA950 1.0.88 works — challenge resolved silently
Sep 29 144.8 MB 155778DD92A2171A 1.0.89 broken — page opens every session

Secondary reporting bug worth fixing on its own: plain copilot --version
reported 1.0.89 for all three binaries, including the 1.0.80 one. Only
copilot --no-auto-update --version reveals the real built-in versions above.
That made this regression very hard to characterise — it looked like one version
behaving two different ways. If --version is reporting a cached/candidate
package rather than the running build, it should say so.

  • OS: Windows 11 (10.0.26200)
  • Mode: copilot --acp (ACP server), 4 × --plugin-dir
  • MCP protocol negotiated: 2025-11-25

Steps to reproduce the behavior

  1. Configure an OAuth-protected HTTP MCP server in ~/.copilot/mcp-config.json:
    https://mcp.internal.example.com/servers/REDACTED-MCP/mcp
  2. Complete its OAuth flow once. Confirm ~/.copilot/mcp-oauth-config now holds a
    registration with "isStatic": false and a paired *.tokens.json whose keys are
    exactly accessToken, expiresAt, scope — i.e. no refreshToken, and
    expiresAt comfortably in the future (mine was ~3 days out).
  3. Start copilot --acp and drive initialize → authenticate → session/new,
    passing that server in mcpServers.
  4. Observe: the authorization page opens even though the token is still valid.
  5. Repeat step 3. It opens again, every time.

Expected behavior

With a valid unexpired access token on disk, session/new should connect the MCP
server using that token and open nothing. If the server genuinely rejects the
token with 401, the CLI should surface an actionable error rather than silently
launching a browser authorization flow on every session — that behaviour is
unusable for headless or unattended ACP clients.

Actual behavior — CLI log

From ~/.copilot/logs/process-*.log on the Sep 29 build (hostnames redacted):

[ERROR] [rust:rmcp::transport::worker] worker quit with fatal: Transport channel closed,
        when Client(OAuthChallenge {
          www_authenticate_header: "******\"https://mcp.internal.example.com/.well-known/oauth-protected-resource/servers/REDACTED-MCP/mcp\"",
          response: McpOAuthHttpResponse { status_code: 401, header_count: 6, has_body: true } })

[INFO]  [rust:acp::mcp_oauth] opened the MCP OAuth authorization page {"server_name":"REDACTED-MCP"}

[WARNING] [rust:copilot_runtime::session::mcp::agent_host] HTTP 401 challenge
        (WWW-Authenticate: ******"https://mcp.internal.example.com/.well-known/oauth-protected-resource/servers/REDACTED-MCP/mcp")
        {"server":"REDACTED-MCP"}

Possibly the same root cause — the new discovery call is rejected for lack of a
bearer token, then falls back:

[WARNING] [rust:mcp::client] server/discover failed; retrying with legacy initialize
        {"error":"unexpected server response: HTTP 400 Bad Request: {\"error\":\"Invalid or missing bearer token\"}"}

A/B isolating the build

Same machine, same 3-minute window, same on-disk token (issued earlier that day,
expiresAt 3 days out), identical client invocation and identical MCP server list.
Only the copilot.exe binary was swapped between runs:

Run Version opened the MCP OAuth authorization page 401 challenge
15:29 1.0.80 (4F590F1F…) 0 0
15:32 1.0.89 (155778DD…) 1 2
20:33 1.0.88 (E2160809…) 0 0

Because the token was unchanged and still valid across all three runs, token expiry
is ruled out as the cause; the binary is the only variable. 1.0.88 passing puts the
regression squarely in the 1.0.88 → 1.0.89 change.

On 1.0.88 and 1.0.80 the server issues the same OAuth challenge — the log still shows
worker quit with fatal: Transport channel closed, when Client(OAuthChallenge {…})
— but it is then resolved without any user interaction:

[ERROR] Successfully authenticated with co-design
[INFO]  [rust:rmcp::service] Service initialized as client { … }
[ERROR] Handling tools refresh for co-design

So the regression is not "the server started returning 401". It is that 1.0.89 no
longer completes that challenge silently and instead falls back to opening the
interactive authorization page. (Those two lines are also logged at ERROR level
on success in 1.0.80, which looks like a separate log-level mistake.)

Impact

Every new ACP session pops a browser window the operator did not ask for. For any
non-interactive ACP client (our case: a local web UI and scheduled agent runs)
this is disruptive, and on a headless host the flow has nobody to approve it.

Activity

  1. added
    area:mcpMCP server configuration, discovery, connectivity, OAuth, policy, and registry
    area:non-interactiveNon-interactive mode (-p), CI/CD, ACP protocol, and headless automation
    and removed on Sep 29, 2026
  2. changed the title [-]Regression: MCP OAuth authorization page reopens on every ACP session for a server with a valid (non-refreshable) access token[/-] [+]Regression (1.0.88 → 1.0.89): MCP OAuth authorization page opens on every ACP session instead of the challenge being resolved silently[/+] on Sep 30, 2026
  3. grahamannett commented on Oct 16, 2026

    @grahamannett

    Running into this as well!

  4. pWoLiAn commented on Oct 19, 2026

    @pWoLiAn

    Still reproduces on 1.0.98 (macOS arm64, copilot --acp launched by an ACP client), and from what I can see the problem is worse than the original report. The token is never persisted at all.

    Setup: project .mcp.json → {"type":"http","url":"https://mcp.atlassian.com/v2/mcp"}

    Observed

    • Each new ACP session is a separate copilot --acp process. On startup it gets HTTP 401 challenge → [acp::mcp_oauth] opened the MCP OAuth authorization page.
    • When the user completes the flow, it works for that session (Service initialized as client about 12s after the page opened).
    • Nothing is written to disk: ~/.copilot/mcp-oauth-config/ does not exist (HOME is the normal user home, no COPILOT_HOME). No MCP-related macOS keychain item exists, and no "token saved/persisted" log line appears. The token seems to live only in that process's memory.
    • The next ACP session → 401 → the browser opens again. If it isn't completed: MCP OAuth login did not complete … timed out after 60000ms.
    • Older logs from the same machine (around 1.0.8x) show the earlier, quieter behaviour: MCP OAuth authorization failed; reporting the server as needing auth, with no browser opened.

    Expected / requests

    1. Persist the OAuth tokens obtained through the ACP flow (same store as interactive mode) and reuse them in later sessions.
    2. Lazy auth: don't open a browser during session startup just to list tools. Mark the server as "needs auth" (the old behaviour) and run the interactive flow only when the user or model actually invokes one of that server's tools, or on an explicit sign-in action. Opening a browser tab on every new chat, before any tool is used, is very disruptive.
  5. andriy-ah commented on Oct 27, 2026

    @andriy-ah

    Super annoying, tried to use Copilot in T3 Code and it constantly opens new browser tabs to authenticate. Also reproducible with OpenCode. The issue is 1 month old and seems quite critical, how is it not being worked on? Really disappointed by Copilot team, sent a number of issues already in discussions and don't see any replies from the team. Makes me question whether we need to switch to something else from Copilot.

  6. lukecharman commented on Oct 27, 2026

    @lukecharman

    Still reproduces on 1.0.104 (macOS, Homebrew). Repro without a host app: a bare stdio ACP client running copilot --acp --yolo gets "MCP server 'Atlassian' requires authentication" on an Atlassian tool call. The same binary with copilot -p uses the stored keychain token (service copilot-mcp-oauth) and works. Authorising during an ACP session works only for that session. The keychain entry's modified time doesn't change, and the prompt returns on restart. +1 to lazy auth (don't open a browser at session start).

  7. summer4096 commented on Oct 29, 2026

    @summer4096

    Here is a self-contained repro that needs no real MCP server, no GitHub sign-in and no browser interaction. It exits 0 when the bug is fixed and 1 when it is present, so it can double as a regression check.

    python3 copilot-cli-4994-repro.py [path/to/copilot]   # defaults to `copilot` on PATH
    

    It starts an OAuth-protected mock MCP server on 127.0.0.1 (protected-resource metadata, authorization-server metadata, dynamic client registration, PKCE code flow) and replaces open / xdg-open with shims that record each authorization page and approve it, like a browser with an existing SSO session. Each session is a separate copilot --acp process (initialize, then session/new with the server in mcpServers), using a temporary COPILOT_HOME, auto-update off and a placeholder BYOK provider instead of GitHub sign-in. Session 1 may sign in; later sessions must reuse the stored token.

    Results on macOS arm64, 3 sessions each:

    Version Authorization pages per session Client registrations per session Result
    1.0.88 1, 0, 0 1, 0, 0 PASS
    1.0.89 2, 2, 2 1, 1, 1 FAIL
    1.0.103 2, 2, 2 1, 1, 1 FAIL
    1.0.105 2, 2, 2 1, 1, 1 FAIL

    The same split holds with --refresh-token, where the mock also issues a refresh token.

    What the mock and the CLI show on 1.0.89 and later:

    • Every session starts from scratch: a new dynamic client registration, two authorization pages and a new token, although the previous session completed the flow against the same server.
    • The CLI logs MCP OAuth token exchange completed; saving tokens and MCP OAuth tokens saved, but nothing is written to the macOS keychain (service copilot-mcp-oauth) or to $COPILOT_HOME/mcp-oauth-config. On 1.0.88 the same run writes a registration and a token to the keychain, and later sessions connect with that token without opening any page.
    • The two pages per session come from two HTTP 401 challenge / opened the MCP OAuth authorization page pairs within about 15 ms, which suggests two connection attempts each starting their own flow.

    On macOS the script deletes the keychain entries created during the run. Options: --sessions N, --refresh-token, --keep (keeps the temporary COPILOT_HOME with the CLI logs) and -v.

    copilot-cli-4994-repro.py
    #!/usr/bin/env python3
    """Clean reproduction for github/copilot-cli#4994.
    
    Copilot CLI 1.0.89 and later open the MCP OAuth authorization page on every new
    `copilot --acp` session for an OAuth-protected HTTP MCP server, instead of
    reusing the token obtained in an earlier session. 1.0.88 signs in once and
    reuses the stored token silently.
    
    The script needs only Python 3 and a Copilot CLI binary. It needs no GitHub
    sign-in, real MCP server or browser:
    
    * A local mock MCP server requires OAuth (protected-resource metadata,
      authorization-server metadata, dynamic client registration, PKCE code flow).
    * `open` / `xdg-open` (and friends) are shimmed so "opening the authorization
      page" is recorded and auto-approved, like a browser with an existing SSO
      session. Every /authorize request the mock receives is one page the user
      would have seen.
    * A BYOK provider URL pointing at the mock replaces GitHub sign-in; no model
      request is made.
    * COPILOT_HOME is a temporary directory, auto-update is disabled.
    
    Each ACP session runs in its own `copilot --acp` process, like an ACP client
    that starts a process per session: initialize -> session/new with the server in
    `mcpServers`, then the script waits for the CLI to connect to the server.
    
    Session 1 is allowed to sign in (one authorization page). Every later session
    must connect with the stored token and open no authorization page.
    
    Exit codes: 0 fixed (PASS), 1 bug reproduced (FAIL), 2 inconclusive or setup
    error.
    
    Usage:
        python3 copilot-cli-4994-repro.py [path/to/copilot] [--sessions N] [-v] [--keep]
    
    Expected: 1.0.88 PASS; 1.0.89 through at least 1.0.105 FAIL.
    
    On macOS the CLI stores MCP OAuth secrets in the login keychain (service
    "copilot-mcp-oauth"). The script removes the entries created during the run
    (snapshot before, delete new entries after); pass --keep-keychain to skip that.
    Tested on macOS arm64. On Linux the browser shims cover xdg-open and the usual
    alternatives. On Windows no shim is installed, so the real browser opens the
    mock's page, which approves and closes the flow on its own.
    """
    
    import argparse
    import http.server
    import json
    import os
    import platform
    import re
    import secrets
    import shutil
    import subprocess
    import sys
    import tempfile
    import threading
    import time
    import urllib.parse
    
    KEYCHAIN_SERVICE = "copilot-mcp-oauth"
    
    
    class Mock:
        """OAuth-protected MCP server plus the OAuth endpoints it advertises."""
    
        def __init__(self, refresh_token):
            self.lock = threading.Lock()
            self.events = []
            self.valid_tokens = set()
            self.refresh_tokens = set()
            self.codes = {}
            self.issue_refresh_token = refresh_token
            self.server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), self._handler())
            self.base = f"http://127.0.0.1:{self.server.server_address[1]}"
            threading.Thread(target=self.server.serve_forever, daemon=True).start()
    
        def record(self, kind, **data):
            with self.lock:
                self.events.append((time.time(), kind, data))
            if VERBOSE:
                print(f"    [mock] {kind} {data}", flush=True)
    
        def since(self, mark):
            with self.lock:
                return list(self.events[mark:])
    
        def mark(self):
            with self.lock:
                return len(self.events)
    
        def _handler(self):
            mock = self
    
            class Handler(http.server.BaseHTTPRequestHandler):
                protocol_version = "HTTP/1.1"
    
                def log_message(self, *args):
                    pass
    
                def reply(self, code, body=None, headers=None):
                    data = json.dumps(body).encode() if body is not None else b""
                    self.send_response(code)
                    if body is not None:
                        self.send_header("Content-Type", "application/json")
                    self.send_header("Content-Length", str(len(data)))
                    for key, value in (headers or {}).items():
                        self.send_header(key, value)
                    self.end_headers()
                    self.wfile.write(data)
    
                def body(self):
                    length = int(self.headers.get("Content-Length") or 0)
                    return self.rfile.read(length) if length else b""
    
                def do_GET(self):
                    url = urllib.parse.urlparse(self.path)
                    query = dict(urllib.parse.parse_qsl(url.query))
                    if url.path.startswith("/.well-known/oauth-protected-resource"):
                        return self.reply(200, {
                            "resource": mock.base + "/mcp",
                            "authorization_servers": [mock.base],
                            "bearer_methods_supported": ["header"],
                        })
                    if url.path.startswith(("/.well-known/oauth-authorization-server", "/.well-known/openid-configuration")):
                        return self.reply(200, {
                            "issuer": mock.base,
                            "authorization_endpoint": mock.base + "/authorize",
                            "token_endpoint": mock.base + "/token",
                            "registration_endpoint": mock.base + "/register",
                            "response_types_supported": ["code"],
                            "grant_types_supported": ["authorization_code", "refresh_token"],
                            "code_challenge_methods_supported": ["S256"],
                            "token_endpoint_auth_methods_supported": ["none", "client_secret_post", "client_secret_basic"],
                        })
                    if url.path == "/authorize":
                        mock.record("authorize")
                        code = secrets.token_hex(8)
                        mock.codes[code] = query
                        redirect = query.get("redirect_uri", "")
                        separator = "&" if "?" in redirect else "?"
                        location = redirect + separator + urllib.parse.urlencode({"code": code, "state": query.get("state", "")})
                        return self.reply(302, headers={"Location": location})
                    return self.reply(405 if url.path == "/mcp" else 404)
    
                def do_DELETE(self):
                    self.reply(200)
    
                def do_POST(self):
                    url = urllib.parse.urlparse(self.path)
                    raw = self.body()
                    if url.path == "/register":
                        request = json.loads(raw or b"{}")
                        request.update({"client_id": "client-" + secrets.token_hex(4), "client_id_issued_at": int(time.time())})
                        mock.record("register")
                        return self.reply(201, request)
                    if url.path == "/token":
                        form = dict(urllib.parse.parse_qsl(raw.decode()))
                        grant = form.get("grant_type")
                        if grant == "authorization_code" and form.get("code") in mock.codes:
                            mock.codes.pop(form["code"])
                        elif not (grant == "refresh_token" and form.get("refresh_token") in mock.refresh_tokens):
                            mock.record("token_rejected", grant=grant)
                            return self.reply(400, {"error": "invalid_grant"})
                        token = "tok-" + secrets.token_hex(8)
                        mock.valid_tokens.add(token)
                        response = {"access_token": token, "token_type": "Bearer", "expires_in": 3 * 24 * 3600, "scope": "mcp"}
                        if mock.issue_refresh_token:
                            refresh = "rt-" + secrets.token_hex(8)
                            mock.refresh_tokens.add(refresh)
                            response["refresh_token"] = refresh
                        mock.record("token", grant=grant)
                        return self.reply(200, response)
                    if url.path == "/mcp":
                        auth = self.headers.get("Authorization", "")
                        token = auth[7:] if auth.lower().startswith("bearer ") else None
                        try:
                            message = json.loads(raw or b"{}")
                        except ValueError:
                            message = {}
                        method = message.get("method") if isinstance(message, dict) else "batch"
                        if token not in mock.valid_tokens:
                            mock.record("mcp_unauthorized", method=method, sent_token=bool(token))
                            return self.reply(401, {"error": "invalid_token"}, {
                                "WWW-Authenticate": f'Bearer resource_metadata="{mock.base}/.well-known/oauth-protected-resource/mcp"',
                            })
                        mock.record("mcp_authorized", method=method)
                        request_id = message.get("id")
                        if request_id is None:
                            return self.reply(202)
                        if method == "initialize":
                            version = (message.get("params") or {}).get("protocolVersion", "2025-06-18")
                            result = {"protocolVersion": version, "capabilities": {"tools": {}}, "serverInfo": {"name": "mock-oauth-mcp", "version": "1.0.0"}}
                            return self.reply(200, {"jsonrpc": "2.0", "id": request_id, "result": result}, {"Mcp-Session-Id": "s-" + secrets.token_hex(4)})
                        if method == "tools/list":
                            tools = [{"name": "whoami", "description": "Returns ok", "inputSchema": {"type": "object", "properties": {}}}]
                            return self.reply(200, {"jsonrpc": "2.0", "id": request_id, "result": {"tools": tools}})
                        if method == "tools/call":
                            return self.reply(200, {"jsonrpc": "2.0", "id": request_id, "result": {"content": [{"type": "text", "text": "ok"}]}})
                        if method == "ping":
                            return self.reply(200, {"jsonrpc": "2.0", "id": request_id, "result": {}})
                        return self.reply(200, {"jsonrpc": "2.0", "id": request_id, "error": {"code": -32601, "message": "Method not found"}})
                    return self.reply(404)
    
            return Handler
    
    
    def install_browser_shims(directory, record_file, mock_base):
        """Shim the commands CLIs use to open URLs: record, then auto-approve mock URLs only."""
        if os.name == "nt":
            return None
        script = f"""#!/bin/sh
    for arg; do url="$arg"; done
    echo "$url" >> "{record_file}"
    case "$url" in
      {mock_base}/*) (curl -s -o /dev/null -L --max-time 15 "$url" >/dev/null 2>&1 &) ;;
    esac
    exit 0
    """
        for name in ("open", "xdg-open", "gio", "gnome-open", "kde-open", "wslview", "x-www-browser", "sensible-browser", "www-browser"):
            path = os.path.join(directory, name)
            with open(path, "w") as handle:
                handle.write(script)
            os.chmod(path, 0o755)
        return os.path.join(directory, "open")
    
    
    def keychain_accounts():
        if platform.system() != "Darwin" or not shutil.which("security"):
            return None
        dump = subprocess.run(["security", "dump-keychain"], capture_output=True, text=True).stdout
        accounts = set()
        for item in dump.split("keychain: "):
            if f'"svce"<blob>="{KEYCHAIN_SERVICE}"' in item:
                match = re.search(r'"acct"<blob>="([^"]*)"', item)
                if match:
                    accounts.add(match.group(1))
        return accounts
    
    
    class AcpSession:
        def __init__(self, binary, cwd, env):
            self.messages = []
            self.process = subprocess.Popen(
                [binary, "--acp", "--no-auto-update", "--disable-builtin-mcps", "--log-level", "all"],
                cwd=cwd, env=env, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
                text=True, bufsize=1,
            )
            threading.Thread(target=self._read, daemon=True).start()
    
        def _read(self):
            for line in self.process.stdout:
                try:
                    message = json.loads(line)
                except ValueError:
                    continue
                self.messages.append(message)
                if VERBOSE:
                    print("    [acp] <", line.strip()[:240], flush=True)
    
        def request(self, request_id, method, params, timeout):
            self.process.stdin.write(json.dumps({"jsonrpc": "2.0", "id": request_id, "method": method, "params": params}) + "\n")
            self.process.stdin.flush()
            deadline = time.time() + timeout
            while time.time() < deadline:
                for message in list(self.messages):
                    if message.get("id") == request_id and ("result" in message or "error" in message):
                        return message
                if self.process.poll() is not None:
                    return None
                time.sleep(0.1)
            return None
    
        def close(self):
            try:
                self.process.stdin.close()
            except OSError:
                pass
            self.process.terminate()
            try:
                self.process.wait(5)
            except subprocess.TimeoutExpired:
                self.process.kill()
    
    
    def run_session(number, binary, cwd, env, mock, wait_seconds):
        mark = mock.mark()
        session = AcpSession(binary, cwd, env)
        outcome = {"session": number, "error": None}
        try:
            if not session.request(1, "initialize", {"protocolVersion": 1, "clientCapabilities": {}}, 60):
                outcome["error"] = "initialize failed"
                return outcome
            server = {"type": "http", "name": "mock-oauth", "url": mock.base + "/mcp", "headers": []}
            reply = session.request(2, "session/new", {"cwd": cwd, "mcpServers": [server]}, 90)
            if not reply or "result" not in reply:
                outcome["error"] = f"session/new failed: {reply}"
                return outcome
            # Wait for an authorized MCP connection, then a grace period for any late
            # authorization attempt and for the CLI to persist a new token.
            deadline = time.time() + wait_seconds
            connected_at = None
            while time.time() < deadline:
                events = mock.since(mark)
                if connected_at is None and any(k == "mcp_authorized" and d.get("method") == "initialize" for _, k, d in events):
                    connected_at = time.time()
                    deadline = min(deadline, connected_at + 5)
                time.sleep(0.2)
        finally:
            session.close()
        events = mock.since(mark)
        outcome["authorization_pages"] = sum(1 for _, k, _ in events if k == "authorize")
        outcome["tokens_issued"] = sum(1 for _, k, _ in events if k == "token")
        outcome["client_registrations"] = sum(1 for _, k, _ in events if k == "register")
        outcome["connected"] = any(k == "mcp_authorized" and d.get("method") == "initialize" for _, k, d in events)
        return outcome
    
    
    def oauth_log_excerpt(home, limit=12):
        lines = []
        logs = os.path.join(home, "logs")
        if not os.path.isdir(logs):
            return lines
        for name in sorted(os.listdir(logs)):
            try:
                with open(os.path.join(logs, name), errors="replace") as handle:
                    for line in handle:
                        if "[rust:" in line and "telemetry" not in line and re.search(r"mcp_oauth|OAuth|authorization page|401 challenge", line):
                            lines.append(f"{name}: {line.rstrip()[:260]}")
            except OSError:
                continue
        return lines[-limit:]
    
    
    def main():
        global VERBOSE
        parser = argparse.ArgumentParser(description="Reproduce github/copilot-cli#4994 (MCP OAuth page on every ACP session).")
        parser.add_argument("binary", nargs="?", default=shutil.which("copilot"), help="Copilot CLI binary (default: copilot on PATH)")
        parser.add_argument("--sessions", type=int, default=3, help="ACP sessions to run; the first may sign in (default 3)")
        parser.add_argument("--wait", type=float, default=30, help="seconds to wait per session for the MCP connection (default 30)")
        parser.add_argument("--refresh-token", action="store_true", help="mock issues a refresh token too (default: access token only, as in the report)")
        parser.add_argument("--keep", action="store_true", help="keep the temporary COPILOT_HOME (with CLI logs) for inspection")
        parser.add_argument("--keep-keychain", action="store_true", help="macOS: do not delete keychain entries created during the run")
        parser.add_argument("-v", "--verbose", action="store_true")
        args = parser.parse_args()
        VERBOSE = args.verbose
    
        if not args.binary or not os.path.exists(args.binary):
            print("Copilot CLI binary not found; pass its path as the first argument.", file=sys.stderr)
            return 2
        binary = os.path.abspath(args.binary)
        version = subprocess.run([binary, "--no-auto-update", "--version"], capture_output=True, text=True,
                                 env=dict(os.environ, COPILOT_AUTO_UPDATE="false")).stdout.strip().splitlines()
        print(f"Copilot CLI: {binary}\nVersion:     {version[0] if version else 'unknown'}")
        if args.sessions < 2:
            print("--sessions must be at least 2", file=sys.stderr)
            return 2
    
        work = tempfile.mkdtemp(prefix="copilot-cli-4994-")
        home = os.path.join(work, "copilot-home")
        cwd = os.path.join(work, "workspace")
        shims = os.path.join(work, "bin")
        for directory in (home, cwd, shims):
            os.makedirs(directory)
        keychain_before = None if args.keep_keychain else keychain_accounts()
        mock = Mock(args.refresh_token)
        browser = install_browser_shims(shims, os.path.join(work, "opened-urls.txt"), mock.base)
    
        env = dict(os.environ)
        for key in ("COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"):
            env.pop(key, None)
        env.update({
            "COPILOT_HOME": home,
            "COPILOT_AUTO_UPDATE": "false",
            "COPILOT_PROVIDER_BASE_URL": mock.base + "/v1",
            "COPILOT_PROVIDER_API_KEY": "unused",
            "COPILOT_MODEL": "mock-model",
        })
        if browser:
            env["PATH"] = shims + os.pathsep + env.get("PATH", "")
            env["BROWSER"] = browser
        print(f"Mock MCP:    {mock.base}/mcp (OAuth required{', refresh tokens' if args.refresh_token else ', no refresh token'})\n")
    
        results = []
        try:
            for number in range(1, args.sessions + 1):
                print(f"Session {number}: starting copilot --acp ...", flush=True)
                result = run_session(number, binary, cwd, env, mock, args.wait)
                results.append(result)
                if result["error"]:
                    print(f"Session {number}: {result['error']}")
                    break
                print(f"Session {number}: authorization pages opened={result['authorization_pages']}, "
                      f"tokens issued={result['tokens_issued']}, client registrations={result['client_registrations']}, "
                      f"connected={'yes' if result['connected'] else 'no'}")
        finally:
            excerpt = oauth_log_excerpt(home)
            mock.server.shutdown()
            if keychain_before is not None:
                created = (keychain_accounts() or set()) - keychain_before
                for account in created:
                    subprocess.run(["security", "delete-generic-password", "-s", KEYCHAIN_SERVICE, "-a", account], capture_output=True)
                if created:
                    print(f"\nRemoved {len(created)} keychain entr{'y' if len(created) == 1 else 'ies'} created by this run (service {KEYCHAIN_SERVICE}).")
            if args.keep:
                print(f"Kept {work} (CLI logs in copilot-home/logs).")
            else:
                shutil.rmtree(work, ignore_errors=True)
    
        print()
        if any(r["error"] for r in results):
            print("INCONCLUSIVE: an ACP session failed to start; rerun with -v.")
            return 2
        first, later = results[0], results[1:]
        if not first["connected"]:
            print("INCONCLUSIVE: session 1 never connected to the mock server, so there is no stored sign-in to reuse.")
            return 2
        reprompted = [r["session"] for r in later if r["authorization_pages"] > 0]
        if reprompted:
            print(f"FAIL (bug present): session(s) {', '.join(map(str, reprompted))} opened the authorization page again "
                  f"although session 1 already signed in. Each later session should reuse the stored token.")
            if excerpt:
                print("\nCLI log lines about MCP OAuth:")
                print("\n".join("  " + line for line in excerpt))
            return 1
        if not all(r["connected"] for r in later):
            print("INCONCLUSIVE: a later session opened no authorization page but also never connected to the server.")
            return 2
        later_range = "session 2" if args.sessions == 2 else f"sessions 2-{args.sessions}"
        print(f"PASS (fixed): session 1 signed in once; {later_range} reused the stored token and opened no authorization page.")
        return 0
    
    
    VERBOSE = False
    
    if __name__ == "__main__":
        sys.exit(main())
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:mcpMCP server configuration, discovery, connectivity, OAuth, policy, and registryarea:non-interactiveNon-interactive mode (-p), CI/CD, ACP protocol, and headless automation

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions