mirror of
https://github.com/sums001/Windows-Copilot-API
synced 2026-08-09 13:11:24 +00:00
Patch + diagnostic
This commit is contained in:
+2
-1
@@ -14,4 +14,5 @@ config.json
|
||||
|
||||
# Dev files
|
||||
test.py
|
||||
main.py
|
||||
main.py
|
||||
CLAUDE.md
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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": []}
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user