diff --git a/copilot/browser.py b/copilot/browser.py index 5fb6019..b22f8bb 100644 --- a/copilot/browser.py +++ b/copilot/browser.py @@ -58,18 +58,32 @@ async () => { } """ -# Best-effort discovery of an MSAL access token from localStorage. Returns null -# for anonymous sessions (anonymous chat may still work via cookies alone). +# Discover the Copilot chat MSAL access token from localStorage. The cache holds +# several tokens for different scopes; the chat WebSocket only accepts the one +# scoped 'ChatAI.ReadWrite' — a wrong-audience token (e.g. the Graph +# User.Read/Files.Read token) makes the WS upgrade 401. We therefore PREFER the +# ChatAI token and only fall back to the first token found if none matches. +# Returns null for anonymous sessions (anonymous chat may still work via cookies). _FIND_TOKEN_JS = """ () => { try { + let fallback = null; for (let i = 0; i < localStorage.length; i++) { const k = localStorage.key(i); const v = localStorage.getItem(k); if (v && v.indexOf('"credentialType":"AccessToken"') !== -1) { - try { const o = JSON.parse(v); if (o && o.secret) return o.secret; } catch (e) {} + try { + const o = JSON.parse(v); + if (o && o.secret) { + // Match the chat scope (e.g. '/ChatAI.ReadWrite'); take the + // first non-matching token only as a last-resort fallback. + if (o.target && o.target.indexOf('ChatAI') !== -1) return o.secret; + if (!fallback) fallback = o.secret; + } + } catch (e) {} } } + return fallback; } catch (e) {} return null; } diff --git a/copilot/driver.py b/copilot/driver.py index 05c7cd1..e1fb9e8 100644 --- a/copilot/driver.py +++ b/copilot/driver.py @@ -69,18 +69,25 @@ class Copilot(AbstractProvider): if access_token is None and conversation is not None: access_token = conversation.access_token + # Auth model mirrors the browser: + # * REST calls (conversation create, attachment upload) authenticate by + # COOKIE only. Sending the token as an Authorization: Bearer header + # there gets a 401 (browsers never do it), so we don't. + # * the chat WebSocket carries the signed-in identity via its + # ?accessToken= param. This must be the Copilot chat token (MSAL scope + # ChatAI.ReadWrite, selected in browser._FIND_TOKEN_JS): a + # 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 - headers = None if access_token: websocket_url = f"{websocket_url}&accessToken={quote(access_token)}" - headers = {"authorization": f"Bearer {access_token}"} with Session( timeout=timeout, proxy=proxy, impersonate="chrome", cookies=cookies, - headers=headers, ) as session: # Establish cookies + Cloudflare clearance (anonymous is fine). session.get(f"{self.url}/")