Patch + diagnostic

This commit is contained in:
Sumit Gautam
2026-06-23 22:12:56 +05:30
parent 2fd9fa47bc
commit 4094d98110
6 changed files with 624 additions and 30 deletions
+2 -1
View File
@@ -14,4 +14,5 @@ config.json
# Dev files
test.py
main.py
main.py
CLAUDE.md
+34 -5
View File
@@ -73,9 +73,9 @@ playwright install chromium
python -m copilot login
```
That's it. Your session is saved under `session/` (git-ignored, never shared) and reused on every run.
The browser **closes by itself** once sign-in is detected — you don't need to press Enter or close it manually. The steps are logged to `session/login.log` if anything goes wrong. That's it: your session is saved under `session/` (git-ignored, never shared) and reused on every run.
> 💡 You can even skip step 4: the **first** time you call `chat()` or start the server, it opens the sign-in browser for you automatically.
> 💡 You can even skip step 3: the **first** time you call `chat()` or start the server, it opens the sign-in browser for you automatically.
---
@@ -261,7 +261,7 @@ add a few retries yourself.
| [copilot/](copilot/) | The core library: `CopilotClient`, auth, browser sign-in, HTTP driver |
| [server/](server/) | The FastAPI OpenAI-compatible server |
| [examples/](examples/) | Runnable examples for every feature ([examples/README.md](examples/README.md)) |
| [tests/](tests/) | Test scripts, including the concurrency stress test ([tests/stress.py](tests/stress.py)) |
| [tests/](tests/) | Test scripts: the concurrency stress test ([tests/stress.py](tests/stress.py)) and the diagnostic/captcha-fix tool ([tests/diagnostic.py](tests/diagnostic.py)) |
| [app.py](app.py) | Starts the server |
---
@@ -278,8 +278,37 @@ add a few retries yourself.
## Troubleshooting
**`RuntimeError: Copilot error: invalid-event` (or the chat hangs) on a server/VPS.**
On datacenter IPs Cloudflare withholds bot-clearance, so the chat socket stalls on an empty challenge sometimes. **Fix it manually:** on that machine, open [copilot.microsoft.com](https://copilot.microsoft.com) in a browser and pass the "verify you're human" check once; that sets a `cf_clearance` cookie which the saved session reuses. Re-do it if it expires, or route the server's traffic through a residential connection (e.g. a home-PC exit node).
**Hit an error? Run the diagnostic first — it both *fixes* and *logs*.**
```bash
python tests/diagnostic.py # browser capture + captcha fix + report
python tests/diagnostic.py --report-only # headless/VPS: report only, no browser
```
The default run opens your signed-in browser and asks you to send one short
message. That single action does two things:
- **Fixes captcha:** it drives a *real* browser on the same `session/profile/`
the bridge uses, so passing any "verify you're human" check earns a fresh
`cf_clearance` cookie. When the turn completes the tool snapshots that session
(cookies + token) into `session/token.json`, so the pure-HTTP driver adopts
the clearance immediately.
- **Captures the protocol** to `session/ws_capture.log`. A clean turn goes
`setOptions` → `send` → `appendText…` → `done`; a `{"event":"challenge",
"method":"cloudflare",…}` frame means Cloudflare gated you (now cleared).
It also writes `session/diagnostic_report.txt` — environment, the *shape* of your
session (cookie names + token length, never the values), a live chat probe, and
redacted log tails. **Both files are safe to share:** access tokens, cookies,
OAuth codes, and emails are redacted before anything is written. Attach
`diagnostic_report.txt` to a GitHub issue (skim it first) and the cause is
usually obvious.
> On a headless **server/VPS** you can't open the browser, so the captcha fix
> isn't available there — pass `--report-only`, then do the clearance step on a
> machine with a display (or route traffic through a residential connection,
> e.g. a home-PC exit node) since datacenter IPs are where Cloudflare withholds
> clearance and you see `RuntimeError: Copilot error: invalid-event`.
---
+122 -21
View File
@@ -19,24 +19,33 @@ It exposes the same ``create_completion(prompt, stream=...)`` generator API as
PROTOCOL ASSUMPTIONS (verify at runtime against a live session):
* Conversation create: POST /c/api/conversations -> {"id": "..."}
* Chat socket: wss://copilot.microsoft.com/c/api/chat?api-version=2
(with &accessToken=<token> when signed in)
&clientSessionId=<uuid> (with &accessToken=<token> when
signed in)
* Handshake: send SET_OPTIONS_FRAME then CONSENTS_FRAME before the
first send, or the backend returns invalid-event
* Send frame: {"event":"send","conversationId":...,
"content":[{"type":"text","text":...}],"mode":"chat"}
"content":[{"type":"text","text":...}],
"mode":"smart","context":{}}
* Stream frames: {"event":"appendText","text":...}, then {"event":"done"}
These mirror the captured protocol in ``client.py``. If Microsoft changes them,
adjust the JS templates below.
The wire shapes are the single source of truth in :mod:`copilot.protocol`; these
JS templates just replay them. Recapture with ``tests/diagnostic.py`` if Microsoft
changes the protocol.
"""
from __future__ import annotations
import json
import time
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Dict, Generator, Optional
from urllib.parse import quote
from playwright.sync_api import sync_playwright, Error as PlaywrightError
from .auth import DEFAULT_AUTH_FILE, DEFAULT_PROFILE_DIR
from .protocol import CHAT_WEBSOCKET_URL, CONSENTS_FRAME, SET_OPTIONS_FRAME
COPILOT_URL = "https://copilot.microsoft.com/"
@@ -92,20 +101,22 @@ _FIND_TOKEN_JS = """
# Open the chat WebSocket and wire handlers that push into a window-scoped
# buffer. Returns immediately; messages accumulate while Python polls.
_START_STREAM_JS = """
([conversationId, accessToken, prompt]) => {
([url, conversationId, prompt, prelude]) => {
const state = {queue: [], done: false, error: null, started: false};
window.__copilot = state;
let url = 'wss://copilot.microsoft.com/c/api/chat?api-version=2';
if (accessToken) url += '&accessToken=' + encodeURIComponent(accessToken);
let ws;
try { ws = new WebSocket(url); } catch (e) { state.error = 'ws-init: ' + e; state.done = true; return false; }
window.__copilotWs = ws;
ws.onopen = () => {
// Initialise the session (setOptions, reportLocalConsents) before sending,
// or the backend rejects `send` with invalid-event.
for (const frame of prelude) ws.send(JSON.stringify(frame));
ws.send(JSON.stringify({
event: 'send',
conversationId: conversationId,
content: [{type: 'text', text: prompt}],
mode: 'chat'
mode: 'smart',
context: {}
}));
};
ws.onmessage = (ev) => {
@@ -167,6 +178,7 @@ class BrowserCopilot:
self._pw = None
self._context = None
self._page = None
self._login_log_fh = None
# -- lifecycle ----------------------------------------------------------
@@ -226,7 +238,11 @@ class BrowserCopilot:
return "available in your region" in (text or "").lower()
def close(self) -> None:
for attr, closer in (("_context", lambda c: c.close()), ("_pw", lambda p: p.stop())):
for attr, closer in (
("_context", lambda c: c.close()),
("_pw", lambda p: p.stop()),
("_login_log_fh", lambda f: f.close()),
):
obj = getattr(self, attr, None)
if obj is not None:
try:
@@ -244,35 +260,116 @@ class BrowserCopilot:
# -- auth ---------------------------------------------------------------
def login(self, path: str = DEFAULT_AUTH_FILE) -> dict:
def login(self, path: str = DEFAULT_AUTH_FILE, timeout: int = 300) -> dict:
"""Open a visible window for interactive Microsoft sign-in.
Blocks until you press Enter in the console. The session is persisted in
``profile_dir`` (and snapshotted to ``path``), so subsequent headless
runs reuse it. Returns the captured auth dict.
Auto-detects success — the Copilot chat access token appearing in the
page, the same signal :mod:`copilot.auth` uses — then snapshots the
session and closes the browser by itself. No key-press needed. Every step
is appended to ``<session>/login.log`` so a failed sign-in is diagnosable.
``timeout`` bounds the wait before giving up and snapshotting whatever
state exists. The session persists in ``profile_dir`` for headless reuse.
"""
self.close()
self.start(headless=False)
log = self._open_login_log(Path(path).resolve().parent / "login.log")
log(f"login started; browser open at {COPILOT_URL}")
self._mirror_page_events(log)
print(
"\nA browser window is open at copilot.microsoft.com.\n"
"Sign in (or just solve any Cloudflare check for anonymous use),\n"
"then return here and press Enter to save the session..."
"Sign in (and pass any 'verify you're human' check).\n"
"It closes by itself once sign-in is detected — no need to press Enter.\n"
)
try:
input()
except EOFError:
pass
# Snapshot fresh auth so the headless curl_cffi path works immediately.
# Poll for the signed-in chat token; bail early if the user closes the
# window or the timeout elapses.
detected = False
deadline = time.time() + timeout
while time.time() < deadline:
if self._window_closed():
log("browser window closed before sign-in was detected")
break
try:
token = self.access_token()
except PlaywrightError:
token = None
if token:
log("chat access token detected — sign-in successful")
detected = True
break
try:
self._page.wait_for_timeout(1500)
except PlaywrightError:
break
if not detected:
log(f"no chat access token within {timeout}s; snapshotting current state")
print("Sign-in not auto-detected; saving whatever session state exists.")
# Let cookies/token settle, then snapshot for the headless curl_cffi path.
auth: dict = {}
try:
if detected and not self._window_closed():
self._page.wait_for_timeout(800)
auth = self.export_auth(path=path, stamp=time.time())
log(f"auth snapshot saved to {path} (access_token={'yes' if auth.get('access_token') else 'no'})")
print(f"Auth snapshot saved to {path}")
except Exception as exc:
log(f"could not snapshot auth: {exc}")
print(f"(could not snapshot auth: {exc})")
log("closing browser")
self.close()
print(f"Session saved to {self.profile_dir}")
return auth
def _open_login_log(self, log_path: Path):
"""Return a best-effort timestamped append-logger to ``log_path``.
The handle is parked on the context so :meth:`close` can release it; if the
file can't be opened, the returned logger is a silent no-op.
"""
try:
log_path.parent.mkdir(parents=True, exist_ok=True)
self._login_log_fh = log_path.open("a", encoding="utf-8")
except OSError:
self._login_log_fh = None
def log(message: str) -> None:
fh = self._login_log_fh
if fh is None:
return
try:
fh.write(f"{datetime.now(timezone.utc).isoformat()}\t{message}\n")
fh.flush()
except Exception:
pass
return log
def _mirror_page_events(self, log) -> None:
"""Stream main-frame navigations and console errors into the login log."""
try:
self._page.on(
"framenavigated",
lambda fr: fr == self._page.main_frame and log(f"navigated: {fr.url}"),
)
self._page.on(
"console",
lambda m: m.type == "error" and log(f"console.error: {m.text}"),
)
except PlaywrightError:
pass
def _window_closed(self) -> bool:
"""True if the page/context is gone (e.g. the user closed the window)."""
try:
return self._page is None or self._page.is_closed()
except Exception:
return True
def access_token(self) -> Optional[str]:
"""Return the page's MSAL access token, or ``None`` if anonymous."""
self._ensure_started()
@@ -347,7 +444,11 @@ class BrowserCopilot:
token = self._page.evaluate(_FIND_TOKEN_JS)
started_ok = self._page.evaluate(_START_STREAM_JS, [conversation_id, token, prompt])
ws_url = f"{CHAT_WEBSOCKET_URL}&clientSessionId={uuid.uuid4()}"
if token:
ws_url += f"&accessToken={quote(token)}"
prelude = [SET_OPTIONS_FRAME, CONSENTS_FRAME]
started_ok = self._page.evaluate(_START_STREAM_JS, [ws_url, conversation_id, prompt, prelude])
if started_ok is False:
state = self._page.evaluate(_POLL_JS)
raise ConnectionError(f"WebSocket failed to start: {state.get('error')}")
+15 -3
View File
@@ -8,6 +8,7 @@ See :mod:`copilot.browser` for the Playwright-backed fallback.
import json
import time
import uuid
from select import select
from typing import Dict, Optional
from urllib.parse import quote
@@ -23,6 +24,7 @@ _CURL_SOCKET_BAD = -1
from .challenges import solve_copilot_challenge, solve_hashcash
from .models import AbstractProvider, Conversation, ImageResponse, ImageType
from .protocol import CHAT_WEBSOCKET_URL, CONSENTS_FRAME, SET_OPTIONS_FRAME
from .utils import drain_json, is_accepted_format, raise_for_status, to_bytes
@@ -33,7 +35,7 @@ class Copilot(AbstractProvider):
supports_stream = True
default_model = "Copilot"
needs_auth = False # consumer chat works anonymously (cookies only)
websocket_url = "wss://copilot.microsoft.com/c/api/chat?api-version=2"
websocket_url = CHAT_WEBSOCKET_URL
conversation_url = f"{url}/c/api/conversations"
def create_completion(
@@ -87,7 +89,11 @@ class Copilot(AbstractProvider):
# wrong-audience token 401s the WS upgrade, while *no* token makes the
# chat backend treat the session as anonymous -> chat-service-
# unavailable in geo-restricted regions (e.g. India).
websocket_url = self.websocket_url
# Mirror the real client's URL shape: api-version, then a fresh
# per-connection clientSessionId, then the access token. The current chat
# backend expects clientSessionId; omitting it is one trigger for an
# `invalid-event` rejection.
websocket_url = f"{self.websocket_url}&clientSessionId={uuid.uuid4()}"
if access_token:
websocket_url = f"{websocket_url}&accessToken={quote(access_token)}"
@@ -126,10 +132,16 @@ class Copilot(AbstractProvider):
"event": "send",
"conversationId": conversation_id,
"content": [*images, {"type": "text", "text": prompt}],
"mode": "chat",
"mode": "smart",
"context": {},
}).encode()
wss = session.ws_connect(websocket_url)
# Initialise the session before sending: setOptions then
# reportLocalConsents. A `send` issued first is rejected with
# `invalid-event` (see the handshake constants above).
wss.send(json.dumps(SET_OPTIONS_FRAME).encode(), CurlWsFlag.TEXT)
wss.send(json.dumps(CONSENTS_FRAME).encode(), CurlWsFlag.TEXT)
wss.send(send_frame, CurlWsFlag.TEXT)
yield from self._read_stream(wss, send_frame, timeout)
+61
View File
@@ -0,0 +1,61 @@
"""Shared Microsoft Copilot chat-protocol constants — the single source of truth.
Both the pure-HTTP driver (:mod:`copilot.driver`) and the browser driver
(:mod:`copilot.browser`) speak the *same* chat-socket protocol, so the wire
shapes live here once. When Microsoft changes the protocol, recapture it with
``tests/diagnostic.py`` (its browser capture writes ``session/ws_capture.log``)
and update this file — both drivers follow.
Captured from a live copilot.microsoft.com session. The connect sequence is:
ws_connect(CHAT_WEBSOCKET_URL + &clientSessionId=<uuid> [+ &accessToken=<tok>])
-> send SET_OPTIONS_FRAME
-> send CONSENTS_FRAME
-> send {"event":"send", ..., "mode":"smart", "context":{}}
-> receive appendText* then done
A `send` issued *before* the setOptions/consents handshake is rejected by the
backend with ``error: invalid-event``.
"""
# Base chat socket; callers append &clientSessionId=<uuid> and, when signed in,
# &accessToken=<token>.
CHAT_WEBSOCKET_URL = "wss://copilot.microsoft.com/c/api/chat?api-version=2"
# First handshake frame: advertise the features/cards/UI components the client
# supports. The lists only describe what a UI *could* render; a text prompt still
# streams back as plain `appendText`, so they're harmless for this bridge.
SET_OPTIONS_FRAME = {
"event": "setOptions",
"supportedFeatures": [
"partial-generated-images",
"composer-prefill-conversation-action",
"composer-send-conversation-action-v2",
"side-by-side-comparison",
"session-duration-nudge",
"compose-email-html",
],
"supportedCards": [
"weather", "local", "image", "sports", "video", "healthcareEntity",
"healthcareInfo", "healthRecordsConnectNewProvider", "healthRecordsUpdate",
"suggestHealth", "chart", "ads", "safetyHelpline", "quiz", "finance",
"recipe", "personalArtifacts", "flashcard", "navigation", "person",
"powerPointCreator", "consentV2", "composeEmail", "createCalendarEvent",
"modifyCalendarEvent", "deleteCalendarEvent", "practiceTest", "tapToReveal",
],
"supportedUIComponents": {
"Badge": "1.2", "Basic": "1.2", "Box": "1.2", "Button": "1.2",
"Card": "1.2", "Caption": "1.2", "Chart": "1.2", "Checkbox": "1.2",
"Col": "1.2", "DatePicker": "1.3", "Divider": "1.2", "Form": "1.2",
"Icon": "1.2", "Image": "1.2", "Label": "1.2", "ListView": "1.2",
"ListViewItem": "1.2", "Map": "1.3", "Markdown": "1.2", "Pressable": "1.3",
"RadioGroup": "1.3", "Row": "1.2", "Select": "1.3", "Spacer": "1.2",
"Table": "1.3", "Table.Cell": "1.3", "Table.Row": "1.3", "Text": "1.2",
"Textarea": "1.3", "Title": "1.2", "Transition": "1.2",
},
"ads": {"supportedTypes": ["text", "product", "multimedia", "tourActivity", "propertyPromotion"]},
"supportedActions": [],
}
# Second handshake frame: declare no locally-granted consents.
CONSENTS_FRAME = {"event": "reportLocalConsents", "grantedConsents": []}
+390
View File
@@ -0,0 +1,390 @@
"""Diagnose (and often fix) a broken Copilot session — for bug reports.
One run does two jobs and writes two files under ``session/``:
* **Fix + capture** (interactive): opens your signed-in browser; you send ONE
message in the Copilot UI. That captures the live chat protocol to
``session/ws_capture.log`` AND, because it drives a real browser on the
shared profile, passes any Cloudflare/captcha check — refreshing the
``cf_clearance`` cookie the pure-HTTP driver reuses. So watching the protocol
and clearing the captcha are the same action.
* **Log** (always): writes a redacted, shareable report to
``session/diagnostic_report.txt`` — environment, the *shape* of your saved
session (never the secrets), a live chat probe, and redacted log tails.
Run::
python tests/diagnostic.py # browser capture + fix + report
python tests/diagnostic.py --report-only # headless/VPS: report only, no browser
SAFE TO SHARE. Secrets never reach either file as plaintext: the report reports
cookie *names* only and the token's *length*, and every captured/log line is run
through a redactor that strips access tokens, JWTs, OAuth codes, and emails —
including inside ``ws_capture.log`` itself. Skim before posting anyway, and
attach ``diagnostic_report.txt`` (not the raw capture).
"""
import argparse
import io
import json
import platform
import re
import sys
import time
import traceback
from pathlib import Path
# Run as a plain script (`python tests/diagnostic.py`): put the project root on
# sys.path so the `copilot` package imports without installing it.
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
SESSION_DIR = Path("session")
TOKEN_FILE = SESSION_DIR / "token.json"
PROFILE_DIR = SESSION_DIR / "profile"
LOGIN_LOG = SESSION_DIR / "login.log"
WS_LOG = SESSION_DIR / "ws_capture.log"
REPORT = SESSION_DIR / "diagnostic_report.txt"
COPILOT_URL = "https://copilot.microsoft.com/"
CHAT_HINT = "/c/api/chat" # the chat socket; other sockets are telemetry noise
# Packages whose versions matter for reproducing chat/auth issues.
_PACKAGES = ["curl_cffi", "playwright", "fastapi", "uvicorn", "pydantic", "websockets"]
# Cookies that matter for the signed-in + Cloudflare paths; we report whether
# each is present, never its value.
_KEY_COOKIES = ["cf_clearance", "__Secure-1PSID", "MUID", "_U", "ANON"]
_LOG_TAIL_LINES = 40 # trailing log lines to include (redacted)
_CAPTURE_TICKS = 600 # 600 * 500ms = 5 min ceiling on the browser capture
# --- redaction ------------------------------------------------------------
# Order matters: specific named params first, then a generic long-token catch-all.
_REDACTORS = [
(re.compile(r"(accessToken|access_token)=[^&\s\"]+", re.I), r"\1=<REDACTED>"),
(re.compile(r"(code|client_info|state|nonce|epct|epctrc|reconnectionToken)=[^&\s\"]+", re.I), r"\1=<REDACTED>"),
# JWTs / MSAL artifacts: eyJ... .... (.optional third segment)
(re.compile(r"eyJ[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+(?:\.[A-Za-z0-9_\-]+)?"), "<JWT>"),
(re.compile(r"\bM\.[A-Za-z0-9_\-.!*$%]{20,}"), "<AUTHCODE>"),
(re.compile(r"[\w.+-]+@[\w-]+\.[\w.-]+"), "<EMAIL>"),
# Catch-all: any remaining opaque run that looks like a secret.
(re.compile(r"[A-Za-z0-9_\-]{40,}"), "<REDACTED>"),
]
def redact(text: str) -> str:
"""Strip secrets (tokens, JWTs, auth codes, emails) from a line of text."""
for pattern, repl in _REDACTORS:
text = pattern.sub(repl, text)
return text
def _to_text(payload) -> str:
if isinstance(payload, (bytes, bytearray)):
return payload.decode("utf-8", "replace")
return str(payload)
# --- report assembly ------------------------------------------------------
class Report:
"""Accumulates the report and echoes each line to the console as it's built."""
def __init__(self) -> None:
self._buf = io.StringIO()
def line(self, text: str = "") -> None:
print(text)
self._buf.write(text + "\n")
def section(self, title: str) -> None:
self.line()
self.line(f"== {title} " + "=" * max(0, 60 - len(title)))
def text(self) -> str:
return self._buf.getvalue()
def env_section(r: Report) -> None:
r.section("Environment")
r.line(f"python : {platform.python_version()} ({sys.executable})")
r.line(f"platform : {platform.platform()}")
r.line(f"machine : {platform.machine()}")
def packages_section(r: Report) -> None:
r.section("Package versions")
try:
from importlib.metadata import PackageNotFoundError, version
except ImportError: # pragma: no cover - py<3.8
r.line("importlib.metadata unavailable")
return
for name in _PACKAGES:
try:
r.line(f"{name:<12}: {version(name)}")
except PackageNotFoundError:
r.line(f"{name:<12}: NOT INSTALLED")
def playwright_section(r: Report) -> None:
r.section("Playwright browser")
try:
from playwright.sync_api import sync_playwright
with sync_playwright() as pw:
exe = Path(pw.chromium.executable_path)
r.line(f"chromium : {'present' if exe.exists() else 'MISSING'} ({exe})")
except Exception as e: # noqa: BLE001 - report whatever went wrong
r.line(f"could not query Playwright: {type(e).__name__}: {e}")
r.line("hint: run `playwright install chromium`")
def session_section(r: Report) -> None:
r.section("Session state")
r.line(f"profile dir : {'present' if PROFILE_DIR.is_dir() else 'MISSING'} ({PROFILE_DIR})")
if not TOKEN_FILE.exists():
r.line("token.json : MISSING (not signed in yet — run `python -m copilot login`)")
return
import json
try:
data = json.loads(TOKEN_FILE.read_text(encoding="utf-8"))
except (ValueError, OSError) as e:
r.line(f"token.json : UNREADABLE ({type(e).__name__}: {e})")
return
saved_at = data.get("saved_at", 0) or 0
age = time.time() - saved_at if saved_at else None
token = data.get("access_token")
cookies = data.get("cookies") or {}
r.line("token.json : present")
r.line(f" saved_at : {age:.0f}s ago" if age is not None else " saved_at : unknown")
# Report presence + length only — never the token itself.
r.line(f" access_token : {'yes (len ' + str(len(token)) + ')' if token else 'NO — anonymous/expired'}")
r.line(f" cookies : {len(cookies)} total")
for name in _KEY_COOKIES:
r.line(f" {name:<16}: {'present' if name in cookies else 'absent'}")
if "cf_clearance" not in cookies:
r.line(" ^ cf_clearance absent: likely Cloudflare/captcha gating on this network.")
def _snapshot_auth(ctx, page) -> bool:
"""Write a fresh token.json straight from the live capture browser.
Reading the cookies (incl. the just-earned ``cf_clearance``) + MSAL token
from the *open* capture context avoids spawning a second browser — which
would race the capture browser for the profile lock and fall through to an
interactive sign-in. Returns True if a token was captured and saved.
"""
from copilot.browser import _FIND_TOKEN_JS
try:
token = page.evaluate(_FIND_TOKEN_JS)
except Exception: # noqa: BLE001
token = None
if not token:
return False
try:
raw = ctx.cookies()
except Exception: # noqa: BLE001
return False
cookies = {c["name"]: c["value"] for c in raw if "microsoft.com" in c.get("domain", "")}
TOKEN_FILE.write_text(
json.dumps({"cookies": cookies, "access_token": token, "saved_at": time.time()}, indent=2),
encoding="utf-8",
)
return True
def browser_capture(r: Report) -> bool:
"""Open the real browser, sniff the chat socket (redacted) to ws_capture.log.
Returns True if a completed turn let us snapshot a fresh signed-in session
(cookies incl. cf_clearance + token) into token.json — i.e. the captcha fix
was adopted for the pure-HTTP path.
"""
r.section("Live protocol capture (browser)")
try:
from playwright.sync_api import sync_playwright
from copilot.auth import DEFAULT_PROFILE_DIR
except Exception as e: # noqa: BLE001
r.line(f"skipped — Playwright unavailable: {type(e).__name__}: {e}")
return False
summary = {"chat_open": False, "challenge": None, "append": False,
"done": False, "frames": 0, "refreshed": False}
SESSION_DIR.mkdir(parents=True, exist_ok=True)
sink = WS_LOG.open("w", encoding="utf-8")
def write(tag: str, payload) -> str:
raw = _to_text(payload)
if not sink.closed: # frames can arrive after we stop; don't crash
sink.write(redact(f"{tag} {raw}") + "\n")
sink.flush()
return raw
print("\n" + "=" * 70)
print("A browser is opening with your signed-in profile.")
print("Type ONE short message into the Copilot UI and send it.")
print("(If a 'verify you're human' check appears, pass it — that's the fix.)")
print("Frames stream to session/ws_capture.log. CLOSE THE WINDOW when the")
print("reply finishes (or after ~15s if it hangs). Auto-stops after 5 min.")
print("=" * 70 + "\n")
try:
with sync_playwright() as pw:
ctx = pw.chromium.launch_persistent_context(
str(Path(DEFAULT_PROFILE_DIR).resolve()),
headless=False,
args=["--disable-blink-features=AutomationControlled"],
)
page = ctx.pages[0] if ctx.pages else ctx.new_page()
def on_ws(ws) -> None:
is_chat = CHAT_HINT in ws.url
if is_chat:
summary["chat_open"] = True
write("[OPEN]", f"{'CHAT' if is_chat else 'other'} {ws.url}")
def on_recv(*a) -> None:
text = write("[RECV]", a[0] if a else b"")
summary["frames"] += 1
if '"event":"challenge"' in text:
m = re.search(r'"method"\s*:\s*"([^"]+)"', text)
summary["challenge"] = m.group(1) if m else "unknown"
if '"event":"appendText"' in text:
summary["append"] = True
if '"event":"done"' in text:
summary["done"] = True
ws.on("framesent", lambda *a: write("[SENT]", a[0] if a else b""))
ws.on("framereceived", on_recv)
ws.on("close", lambda *a: write("[CLOSE]", ws.url))
page.on("websocket", on_ws)
page.goto(COPILOT_URL, wait_until="domcontentloaded")
ticks = _CAPTURE_TICKS
try:
while not page.is_closed() and ticks > 0:
page.wait_for_timeout(500)
ticks -= 1
# Once a turn completes, snapshot auth from THIS browser while
# it's still open (captures the refreshed cf_clearance without
# racing a second browser for the profile lock).
if summary["done"] and not summary["refreshed"]:
summary["refreshed"] = _snapshot_auth(ctx, page)
except Exception:
pass # window/context torn down by the close
sink.close()
try:
ctx.close()
except Exception:
pass
except Exception: # noqa: BLE001 - capture failures shouldn't kill the report
if not sink.closed:
sink.close()
r.line("capture failed (traceback redacted):")
for tb in redact(traceback.format_exc()).splitlines():
r.line(f" {tb}")
return False
# Summarise what we saw — this is the gold for captcha/protocol diagnosis.
r.line(f"ws_capture.log written: {summary['frames']} frames (tokens redacted)")
if summary["challenge"]:
r.line(f"CHALLENGE frame seen: method={summary['challenge']!r}")
if summary["challenge"] == "cloudflare":
r.line(" -> Cloudflare/Turnstile gated this turn. Passing the human check in")
r.line(" the browser just refreshed cf_clearance on your profile (the fix).")
elif summary["chat_open"]:
states = [k for k in ("append", "done") if summary[k]]
r.line(f"clean turn, no challenge (saw: {', '.join(states) or 'connect only'})")
else:
r.line("no chat socket observed — was a message sent in the Copilot window?")
if summary["refreshed"]:
r.line("snapshotted a fresh session from this turn (token.json updated)")
elif summary["chat_open"]:
r.line("could not snapshot a token from the turn; token.json left unchanged")
return summary["refreshed"]
def live_probe_section(r: Report, refreshed: bool) -> None:
r.section("Live chat probe (HTTP driver)")
if refreshed:
r.line("using the session just snapshotted from the browser turn")
r.line("sending one short message (60s budget)...")
started = time.time()
try:
from copilot import CopilotClient
reply = CopilotClient().chat("Reply with exactly one word: pong", timeout=60)
elapsed = time.time() - started
snippet = (reply.text or "").strip().replace("\n", " ")[:80]
r.line(f"RESULT : OK in {elapsed:.1f}s")
r.line(f"reply : {snippet!r}")
r.line(f"conv_id : {'set' if reply.conversation_id else 'none'}")
except Exception: # noqa: BLE001 - the whole point is to capture the failure
elapsed = time.time() - started
r.line(f"RESULT : FAILED after {elapsed:.1f}s")
r.line("traceback (redacted):")
for tb in redact(traceback.format_exc()).splitlines():
r.line(f" {tb}")
def log_tail_section(r: Report, title: str, path: Path) -> None:
r.section(f"{title} (last {_LOG_TAIL_LINES} lines, redacted)")
if not path.exists():
r.line("(not present)")
return
try:
lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
except OSError as e:
r.line(f"(unreadable: {e})")
return
for raw in lines[-_LOG_TAIL_LINES:]:
r.line(redact(raw))
def main() -> None:
parser = argparse.ArgumentParser(description="Diagnose/fix a Copilot session for bug reports.")
parser.add_argument(
"--report-only",
action="store_true",
help="Skip the interactive browser capture (for headless/VPS); just write the report.",
)
args = parser.parse_args()
SESSION_DIR.mkdir(parents=True, exist_ok=True)
r = Report()
r.line("Windows Copilot API — diagnostic report")
r.line("(safe to share: secrets are redacted; skim before posting)")
env_section(r)
packages_section(r)
playwright_section(r)
session_section(r)
refreshed = False
if args.report_only:
r.section("Live protocol capture (browser)")
r.line("skipped (--report-only)")
else:
refreshed = browser_capture(r)
live_probe_section(r, refreshed)
log_tail_section(r, "login.log", LOGIN_LOG)
log_tail_section(r, "ws_capture.log", WS_LOG)
REPORT.write_text(r.text(), encoding="utf-8")
print("\n" + "=" * 62)
print(f"Report written to {REPORT}")
print("Attach that file to your GitHub issue. Secrets are already redacted,")
print("but give it a skim before posting.")
if __name__ == "__main__":
main()