From 2c1c80303de1d6f22d8f7262d3331d9bd8d80131 Mon Sep 17 00:00:00 2001 From: Rithmatist Date: Tue, 7 Jul 2026 21:52:02 +0200 Subject: [PATCH 1/2] feat: enterprise M365 Copilot (Cowork) driver + OpenAI bridge routing Work/school (Entra) accounts are redirected off consumer Copilot to the enterprise M365 Copilot "Cowork" agent, which the existing driver can't speak. Add copilot/m365: a headless driver that mints resource tokens via a public-client refresh-token grant (bootstrapped once interactively), sends prompts to the Copilot Studio "Aether" runtime, and reads the reply off a Trouter (Teams Socket.IO) push channel after registering the endpoint. Reverse-engineered from live captures (tests/capture_*.py). Server: route model `copilot-work` -> M365 driver, `copilot` -> consumer; short-circuit the client's title-gen meta-call; bind instantly (drop the startup browser pre-warm); text tool-call bridging (server/tool_calls.py) so OpenAI-compatible agent clients get executable tool_calls. work-chat.ps1 launches openclaude against the bridge. 44 unit tests. Co-Authored-By: Claude Opus 4.8 --- copilot/m365/__init__.py | 32 +++ copilot/m365/__main__.py | 12 + copilot/m365/auth.py | 192 ++++++++++++++++ copilot/m365/bootstrap.py | 136 ++++++++++++ copilot/m365/driver.py | 137 ++++++++++++ copilot/m365/probe.py | 70 ++++++ copilot/m365/protocol.py | 120 ++++++++++ copilot/m365/trouter.py | 145 ++++++++++++ copilot/m365/trouter_client.py | 204 +++++++++++++++++ server/__init__.py | 15 +- server/api.py | 254 ++++++++++++++++++++- server/config.py | 12 +- server/openai_format.py | 75 ++++++- server/prompt.py | 127 ++++++++++- server/schemas.py | 9 + server/tool_calls.py | 394 +++++++++++++++++++++++++++++++++ tests/capture_m365.py | 274 +++++++++++++++++++++++ tests/capture_registrar.py | 106 +++++++++ tests/test_m365_auth.py | 59 +++++ tests/test_m365_driver.py | 92 ++++++++ tests/test_m365_protocol.py | 127 +++++++++++ tests/test_tool_calls.py | 322 +++++++++++++++++++++++++++ work-chat.ps1 | 66 ++++++ 23 files changed, 2945 insertions(+), 35 deletions(-) create mode 100644 copilot/m365/__init__.py create mode 100644 copilot/m365/__main__.py create mode 100644 copilot/m365/auth.py create mode 100644 copilot/m365/bootstrap.py create mode 100644 copilot/m365/driver.py create mode 100644 copilot/m365/probe.py create mode 100644 copilot/m365/protocol.py create mode 100644 copilot/m365/trouter.py create mode 100644 copilot/m365/trouter_client.py create mode 100644 server/tool_calls.py create mode 100644 tests/capture_m365.py create mode 100644 tests/capture_registrar.py create mode 100644 tests/test_m365_auth.py create mode 100644 tests/test_m365_driver.py create mode 100644 tests/test_m365_protocol.py create mode 100644 tests/test_tool_calls.py create mode 100644 work-chat.ps1 diff --git a/copilot/m365/__init__.py b/copilot/m365/__init__.py new file mode 100644 index 0000000..176a91f --- /dev/null +++ b/copilot/m365/__init__.py @@ -0,0 +1,32 @@ +"""Enterprise Microsoft 365 Copilot ("Copilot Cowork") support. + +A work/school (Entra ID) account is not served by consumer copilot.microsoft.com +— Microsoft redirects it to the enterprise experience at m365.cloud.microsoft, +which runs on **Teams messaging infrastructure**, not the consumer chat socket: + + * the prompt is sent into a Teams *thread* (``19:…@thread``, a cowork chat); + * the streamed reply is pushed back over **Trouter** — Teams' Socket.IO push + channel on ``*.trouter.teams.microsoft.com`` — as a tunnelled message the + client must ACK, carrying ``copilotTextSegments`` and a terminal + ``copilotTaskState:"completed"``; + * auth is Bearer tokens for ``substrate.office.com`` and ``ic3.teams.office.com`` + (the Trouter/Skype token), minted server-side (never in localStorage). + +So this is a small headless Teams-messaging client, captured with +``tests/capture_m365.py``. Layers: + + * :mod:`copilot.m365.trouter` — Socket.IO 0.9 frame codec (pure). + * :mod:`copilot.m365.protocol` — parse a Trouter delivery into a reply event, + build the delivery ACK (pure). + * auth / send / driver land here as the capture pins down their endpoints. + +The consumer path (:mod:`copilot.driver`) is unaffected; account-type routing +picks between them. + + from copilot.m365 import M365Copilot + print(M365Copilot().ask("hello")) # after `python -m copilot.m365.bootstrap` +""" + +from .driver import M365Copilot, new_conversation_id + +__all__ = ["M365Copilot", "new_conversation_id"] diff --git a/copilot/m365/__main__.py b/copilot/m365/__main__.py new file mode 100644 index 0000000..e6d93c8 --- /dev/null +++ b/copilot/m365/__main__.py @@ -0,0 +1,12 @@ +"""CLI for enterprise Copilot Cowork. + + python -m copilot.m365.bootstrap # one-time interactive sign-in (work account) + python -m copilot.m365 "hello" # ask, headless +""" + +import sys + +from .driver import main + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/copilot/m365/auth.py b/copilot/m365/auth.py new file mode 100644 index 0000000..3c2cea3 --- /dev/null +++ b/copilot/m365/auth.py @@ -0,0 +1,192 @@ +"""Headless token minting for enterprise M365 Copilot (Copilot Cowork). + +Captured from the real m365.cloud.microsoft web client (``tests/capture_m365.py``): +every resource token it uses — the Cowork agent runtime, substrate, sydney, +ic3.teams (Trouter), Graph … — is minted by a plain OAuth2 **public-client +refresh-token grant** against the tenant token endpoint. No browser, no client +secret, no proof-of-possession (the tokens are ordinary Bearer). So headless auth +reduces to: + + 1. **Bootstrap once** from an interactive sign-in: capture the MSAL refresh + token for the M365 Copilot SPA client (public client :data:`CLIENT_ID`) and + the account's tenant + object id. Persisted to ``session/m365_token.json``. + 2. **Mint per resource** on demand: POST ``grant_type=refresh_token`` with + ``scope=/.default``. Each response returns a short-lived access + token *and a rotated refresh token*, which we persist for next time. + +The request mirrors ``@azure/msal-browser`` exactly — ``redirect_uri= +brk-multihub://outlook.office.com`` plus the ``brk_*`` broker params — because +ESTS rejects the grant when they are missing. Scopes seen in the capture are in +:data:`SCOPES`; :data:`SCOPE_COWORK` is the one the chat *send* needs. + +Conditional-access note: this works only if the tenant issues the account a +*non-bound* refresh token (as this one does). A tenant that requires a compliant/ +managed device or app-bound tokens will reject the non-interactive grant — that +is a policy wall no client can code around. +""" + +from __future__ import annotations + +import json +import threading +import time +from pathlib import Path +from typing import Dict, Optional + +from curl_cffi.requests import Session + +from ..useragent import CHROME_UA + +# Public SPA client id the M365 Copilot web app authenticates as (no secret). +CLIENT_ID = "c0ab8ce9-e9a0-42e7-b064-33d422df41f1" +# Broker (Office) client the SPA rides; ESTS wants these echoed on every grant. +BROKER_CLIENT_ID = "4765445b-32c6-49b0-83e6-1d93765276ca" +REDIRECT_URI = "brk-multihub://outlook.office.com" +BROKER_REDIRECT_URI = "https://m365.cloud.microsoft/spalanding" +# ESTS registers c0ab8ce9 as a Single-Page-Application client, so it only redeems +# tokens on a *cross-origin* request — the grant must carry the SPA's Origin +# header (the browser adds it automatically; curl_cffi must send it explicitly or +# ESTS returns AADSTS9002327). This is the m365.cloud.microsoft page origin. +SPA_ORIGIN = "https://m365.cloud.microsoft" + +# The Cowork agent's resource — the token the chat send (``/v1/messages``) carries. +COWORK_RESOURCE = "6ab48b67-cd74-4ad4-81af-5932984589be" + +# Every scope observed being minted via refresh-token grant, keyed by short name. +# All are ``/.default`` public-client grants for the same refresh token. +SCOPES: Dict[str, str] = { + "cowork": f"{COWORK_RESOURCE}/.default", # chat send (aether runtime) + "substrate": "https://substrate.office.com/.default", + "substrate_search": "https://substrate.office.com/search/.default", + "sydney": "https://substrate.office.com/sydney/.default", + "ic3": "https://ic3.teams.office.com/.default", # Trouter / Skype token + "m365": "https://m365.cloud.microsoft/v2/.default", + "graph": "https://graph.microsoft.com/.default", +} +SCOPE_COWORK = "cowork" + +AUTH_FILE = "session/m365_token.json" +# Refresh a cached access token this many seconds before its stated expiry. +_EXPIRY_SKEW = 120 + + +def token_endpoint(tenant: str) -> str: + return f"https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token" + + +def build_refresh_body(refresh_token: str, scope: str, oid: str, tenant: str) -> Dict[str, str]: + """Return the form fields for a refresh-token grant, matching the capture. + + Pure — no I/O — so the exact wire shape (down to the broker params ESTS + requires) is unit-tested without a network call. ``scope`` is a full + ``/.default`` string; ``openid profile offline_access`` are appended + as the web client does, so the response carries a rotated refresh token. + """ + return { + "client_id": CLIENT_ID, + "redirect_uri": REDIRECT_URI, + "scope": f"{scope} openid profile offline_access", + "grant_type": "refresh_token", + "refresh_token": refresh_token, + "X-AnchorMailbox": f"Oid:{oid}@{tenant}", + "brk_client_id": BROKER_CLIENT_ID, + "brk_redirect_uri": BROKER_REDIRECT_URI, + "x-client-SKU": "msal.js.browser", + "x-client-VER": "5.9.0", + } + + +class M365Auth: + """Mints + caches per-resource access tokens from one bootstrapped refresh token. + + Persists ``{refresh_token, tenant, oid, tokens:{scope:{access_token,exp}}}`` to + :data:`AUTH_FILE`. The refresh token rotates on every grant; the newest is + saved so the bridge keeps working across restarts until the RT expires or the + tenant revokes it. Construct from a saved file with :meth:`load`. + """ + + def __init__(self, refresh_token: str, tenant: str, oid: str, + path: str = AUTH_FILE, proxy: Optional[str] = None): + self.refresh_token = refresh_token + self.tenant = tenant + self.oid = oid + self.path = path + self.proxy = proxy + self._tokens: Dict[str, dict] = {} # scope-name -> {access_token, exp} + # Serialize minting: concurrent chats (e.g. a coding-agent firing several + # calls at once) must not race the refresh-token rotation and lose the + # newest RT. Cached tokens are read without the lock; only a mint locks. + self._mint_lock = threading.Lock() + + @classmethod + def load(cls, path: str = AUTH_FILE, proxy: Optional[str] = None) -> "M365Auth": + """Rehydrate from :data:`AUTH_FILE`; raises if not bootstrapped yet.""" + data = json.loads(Path(path).read_text(encoding="utf-8")) + if not data.get("refresh_token"): + raise RuntimeError( + "No M365 refresh token saved. Bootstrap an enterprise sign-in first " + "(interactive login that captures the refresh token)." + ) + auth = cls(data["refresh_token"], data["tenant"], data["oid"], path=path, proxy=proxy) + auth._tokens = data.get("tokens", {}) + return auth + + def token(self, scope_name: str = SCOPE_COWORK) -> str: + """Return a valid access token for ``scope_name``, minting if needed. + + Reuses a cached token until it is within :data:`_EXPIRY_SKEW` of expiry, + then does one refresh-token grant, caches the result, and persists the + rotated refresh token. + """ + if scope_name not in SCOPES: + raise KeyError(f"unknown scope {scope_name!r}; known: {sorted(SCOPES)}") + cached = self._tokens.get(scope_name) + if cached and cached.get("exp", 0) - _EXPIRY_SKEW > time.time(): + return cached["access_token"] + with self._mint_lock: + # Re-check: another thread may have minted while we waited. + cached = self._tokens.get(scope_name) + if cached and cached.get("exp", 0) - _EXPIRY_SKEW > time.time(): + return cached["access_token"] + return self._mint(scope_name) + + def _mint(self, scope_name: str) -> str: + body = build_refresh_body(self.refresh_token, SCOPES[scope_name], self.oid, self.tenant) + # Origin is mandatory for the SPA client (AADSTS9002327); Referer mirrors + # what the browser sends so the request is a well-formed cross-origin call. + headers = { + "User-Agent": CHROME_UA, + "Origin": SPA_ORIGIN, + "Referer": f"{SPA_ORIGIN}/", + "Content-Type": "application/x-www-form-urlencoded", + } + with Session(proxy=self.proxy, headers=headers) as s: + resp = s.post(token_endpoint(self.tenant), data=body) + if resp.status_code != 200: + raise RuntimeError( + f"M365 token grant failed for {scope_name!r} ({resp.status_code}): " + f"{resp.text[:300]}" + ) + data = resp.json() + access = data.get("access_token") + if not access: + raise RuntimeError(f"M365 token grant returned no access_token: {data}") + # RTs rotate: keep the newest so the next mint (and next run) still works. + if data.get("refresh_token"): + self.refresh_token = data["refresh_token"] + self._tokens[scope_name] = { + "access_token": access, + "exp": time.time() + int(data.get("expires_in", 3600)), + } + self._save() + return access + + def _save(self) -> None: + dest = Path(self.path) + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_text(json.dumps({ + "refresh_token": self.refresh_token, + "tenant": self.tenant, + "oid": self.oid, + "tokens": self._tokens, + }, indent=2), encoding="utf-8") diff --git a/copilot/m365/bootstrap.py b/copilot/m365/bootstrap.py new file mode 100644 index 0000000..1f79e55 --- /dev/null +++ b/copilot/m365/bootstrap.py @@ -0,0 +1,136 @@ +"""One-time interactive bootstrap of enterprise (Cowork) auth. + +Everything else in :mod:`copilot.m365` runs headless off a refresh token (see +:mod:`copilot.m365.auth`) — but that first refresh token can only come from an +interactive sign-in. This opens a visible browser on the persistent profile, +lets you sign in with the work account, and **intercepts the MSAL ``/token`` +response** to capture the refresh token (plus tenant + object id decoded from the +access token). It writes ``session/m365_token.json`` so +:meth:`copilot.m365.auth.M365Auth.load` works from then on. + +We read the refresh token off the token *response* rather than out of MSAL's +localStorage because the M365 SPA does not leave a readable token cache on the +``m365.cloud.microsoft`` origin (confirmed by capture) — the response body is the +reliable source. + + python -m copilot.m365.bootstrap # opens a browser; sign in with work account +""" + +from __future__ import annotations + +import base64 +import json +import sys +import time +from pathlib import Path +from typing import Optional, Tuple + +from .auth import AUTH_FILE + +M365_CHAT_URL = "https://m365.cloud.microsoft/chat/" +DEFAULT_PROFILE_DIR = "session/profile" +_TOKEN_HINT = "/oauth2/v2.0/token" + + +def _decode_claims(token: str) -> dict: + """Return a JWT's payload claims (unsigned decode), or ``{}`` on failure.""" + try: + payload = token.split(".")[1] + payload += "=" * (-len(payload) % 4) + return json.loads(base64.urlsafe_b64decode(payload)) + except Exception: + return {} + + +def bootstrap(path: str = AUTH_FILE, profile_dir: str = DEFAULT_PROFILE_DIR, + timeout: int = 300, proxy: Optional[str] = None) -> dict: + """Run the interactive capture and persist ``m365_token.json``; return it. + + Blocks until a ``/token`` response carrying a ``refresh_token`` is seen (the + account is signed in and the SPA has fetched its first resource token) or + ``timeout`` elapses. Raises ``RuntimeError`` if nothing was captured. + """ + from playwright.sync_api import sync_playwright + + captured: dict = {} + + print("\n" + "=" * 70) + print("Opening a browser. Sign in with your WORK account and wait for the") + print("Microsoft 365 Copilot chat to load. This window captures the refresh") + print("token and closes itself; nothing is sent anywhere.") + print("=" * 70 + "\n") + + with sync_playwright() as pw: + launch = dict(headless=False, args=["--disable-blink-features=AutomationControlled"], + user_agent=None) + if proxy: + from urllib.parse import urlparse + u = urlparse(proxy) + launch["proxy"] = {"server": f"{u.scheme}://{u.hostname}:{u.port}"} + ctx = pw.chromium.launch_persistent_context(str(Path(profile_dir).resolve()), + **{k: v for k, v in launch.items() if v is not None}) + page = ctx.pages[0] if ctx.pages else ctx.new_page() + + def on_response(resp) -> None: + if captured or _TOKEN_HINT not in resp.url: + return + try: + data = resp.json() + except Exception: + return + rt = data.get("refresh_token") + at = data.get("access_token") + if not rt: + return + claims = _decode_claims(at) if at else {} + tenant = claims.get("tid") or _tenant_from_url(resp.url) + oid = claims.get("oid") + if tenant and oid: + captured.update(refresh_token=rt, tenant=tenant, oid=oid) + + page.on("response", on_response) + try: + page.goto(M365_CHAT_URL, wait_until="domcontentloaded") + except Exception: + pass + + deadline = time.time() + timeout + while not captured and time.time() < deadline: + if page.is_closed(): + break + page.wait_for_timeout(500) + try: + ctx.close() + except Exception: + pass + + if not captured: + raise RuntimeError( + "No refresh token captured. Make sure you signed in with the work " + "account and the M365 Copilot chat finished loading before the timeout." + ) + + out = {"refresh_token": captured["refresh_token"], "tenant": captured["tenant"], + "oid": captured["oid"], "tokens": {}} + dest = Path(path) + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_text(json.dumps(out, indent=2), encoding="utf-8") + print(f"Saved enterprise auth to {path} (tenant={captured['tenant']}).") + print("You can now mint tokens headless: copilot.m365.auth.M365Auth.load().token()") + return out + + +def _tenant_from_url(url: str) -> Optional[str]: + """Pull the tenant guid out of a ``…//oauth2/v2.0/token`` URL.""" + try: + return url.split("login.microsoftonline.com/", 1)[1].split("/", 1)[0] + except Exception: + return None + + +if __name__ == "__main__": + try: + bootstrap() + except Exception as exc: # noqa: BLE001 + print(f"bootstrap failed: {exc}", file=sys.stderr) + raise SystemExit(1) diff --git a/copilot/m365/driver.py b/copilot/m365/driver.py new file mode 100644 index 0000000..51a6033 --- /dev/null +++ b/copilot/m365/driver.py @@ -0,0 +1,137 @@ +"""Pure-HTTP driver for enterprise M365 Copilot (Copilot Cowork). + +Validated end-to-end against a real work tenant. Cowork is a Copilot Studio +("Aether") agent whose transport is split: + + * **send** — ``POST https:///v1/messages`` (Cowork-resource Bearer); + returns ``{"status":"accepted"}`` — fire-and-forget. + * **receive** — the reply is pushed over **Trouter** (Teams' Socket.IO channel); + the Aether runtime exposes no REST read. So we open the Trouter socket, mint + the ``ic3.teams.office.com`` token, register the endpoint, and read the reply + off the socket. See :mod:`copilot.m365.trouter_client`. + +All tokens are minted headless from one bootstrapped refresh token +(:mod:`copilot.m365.auth`) — no browser, no Cloudflare. The whole flow: + + tokens (refresh-grant) -> Trouter connect+auth -> registrar -> send -> reply + + from copilot.m365 import M365Copilot + print(M365Copilot().ask("hello")) +""" + +from __future__ import annotations + +import json +import os +import sys +import uuid +from typing import Optional + +from curl_cffi.requests import Session + +from ..useragent import CHROME_UA +from .auth import M365Auth, SCOPE_COWORK +from .trouter_client import TrouterReceiver + +# Discovered per-geo/tenant via a routing call and cached by the web client +# ("RoutingCacheHit"); stable per tenant. Override for other tenants/geos. +DEFAULT_RUNTIME_HOST = "mcsaetherruntime-neu.eu-ia102.gateway.prod.island.powerapps.com" +IC3_SCOPE = "ic3" + +# Feature/model flags the web client sends; ``model`` selects the backend. +DEFAULT_CONTAINER_CONFIG = ( + "renderUi=true;searchBackend=bing;citationsEnabled=true;acceptLanguage=en-US;model=fable-5:claude" +) + + +def new_conversation_id(tenant: str, oid: str) -> str: + """A fresh Cowork conversation id: ``::``.""" + return f"{tenant}:{oid}:{uuid.uuid4()}" + + +class M365Copilot: + """Chat with enterprise Copilot Cowork, headless (send + Trouter receive).""" + + def __init__(self, auth: Optional[M365Auth] = None, runtime_host: str = DEFAULT_RUNTIME_HOST, + container_config: str = DEFAULT_CONTAINER_CONFIG, + proxy: Optional[str] = None, timeout: Optional[int] = None): + self.auth = auth or M365Auth.load(proxy=proxy) + self.runtime_host = runtime_host + self.container_config = container_config + self.proxy = proxy + # Real Cowork turns have been observed at up to ~110s; 120s left almost + # no headroom, so slow-but-successful turns died as timeouts. Tunable + # because acceptable wait is a deployment choice (M365_TIMEOUT, seconds). + self.timeout = timeout if timeout is not None else int(os.environ.get("M365_TIMEOUT", "180")) + self.last_conversation_id: Optional[str] = None + + def ask(self, prompt: str, conversation_id: Optional[str] = None) -> str: + """Send ``prompt`` and return the full reply text. + + Starts a new conversation when ``conversation_id`` is None; pass a prior + :attr:`last_conversation_id` back to continue the same thread. Blocks up + to ``timeout`` per attempt for the reply (model think-time is ~10-30s, + long agent turns up to ~2min). + + If the socket saw *zero* deliveries by the deadline, the Trouter + registration or route likely went stale (observed as every subsequent + turn hanging forever). One retry runs on a brand-new socket, endpoint id + and registration — re-sending the prompt on the same conversation. A + duplicate send is harmless here: we take the first completed reply. + """ + conv = conversation_id or new_conversation_id(self.auth.tenant, self.auth.oid) + self.last_conversation_id = conv + cowork = self.auth.token(SCOPE_COWORK) + ic3 = self.auth.token(IC3_SCOPE) + + def send(_connected_info) -> None: + self._send(conv, prompt, cowork) + + deliveries = 0 + for attempt in (1, 2): + receiver = TrouterReceiver(ic3, proxy=self.proxy) + reply = receiver.receive(send, timeout=self.timeout) + deliveries = receiver._deliveries + if reply: + return reply + if deliveries: + # The route works (frames arrived) but the turn never completed; + # a fresh socket + duplicate send won't finish it any faster. + break + raise RuntimeError( + "No reply received from Copilot Cowork within " + f"{self.timeout}s (deliveries={deliveries}, attempts={attempt}). " + "The turn may still be running, or the Trouter registration/route changed." + ) + + def _send(self, conversation_id: str, prompt: str, cowork_token: str) -> None: + url = f"https://{self.runtime_host}/v1/messages" + headers = { + "User-Agent": CHROME_UA, + "Authorization": f"Bearer {cowork_token}", + "Content-Type": "application/json", + "Origin": "https://m365.cloud.microsoft", + "X-User-ID": self.auth.oid, + "X-Container-Config": self.container_config, + } + body = { + "connectorsConfig": {"connectors": [], "include_defaults": True, "packages": []}, + "content": [{"text": prompt, "type": "text"}], + "conversationId": conversation_id, + "messageId": str(uuid.uuid4()), + "role": "user", + } + with Session(proxy=self.proxy, timeout=60) as s: + r = s.post(url, headers=headers, data=json.dumps(body)) + if r.status_code >= 400: + raise RuntimeError(f"Cowork send failed ({r.status_code}): {r.text[:300]}") + + +def main(argv) -> int: + prompt = " ".join(argv) or "Hello" + print(M365Copilot().ask(prompt)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/copilot/m365/probe.py b/copilot/m365/probe.py new file mode 100644 index 0000000..a9c36e8 --- /dev/null +++ b/copilot/m365/probe.py @@ -0,0 +1,70 @@ +"""End-to-end enterprise Cowork test over the live Trouter channel (dev probe). + +Mints the ic3 (Trouter) + Cowork tokens headless, opens the Trouter socket, and +once it's up fires the ``/v1/messages`` send — then waits for the reply to be +pushed back down the socket. This validates the full round trip and whether the +registrar step is needed (it runs WITHOUT registrar). + + python -m copilot.m365.probe "hello" +""" + +from __future__ import annotations + +import json +import sys +import uuid + +from curl_cffi.requests import Session + +from ..useragent import CHROME_UA +from .auth import M365Auth, SCOPE_COWORK +from .driver import DEFAULT_CONTAINER_CONFIG, DEFAULT_RUNTIME_HOST, new_conversation_id +from .trouter_client import TrouterReceiver + +IC3_SCOPE = "ic3" + + +def main(argv) -> int: + prompt = " ".join(argv) or "hello" + auth = M365Auth.load() + ic3 = auth.token(IC3_SCOPE) + cowork = auth.token(SCOPE_COWORK) + conv = new_conversation_id(auth.tenant, auth.oid) + print(f"tokens minted (ic3 len={len(ic3)}, cowork len={len(cowork)})") + print(f"conversation: {conv}\n") + + def send(_info) -> None: + url = f"https://{DEFAULT_RUNTIME_HOST}/v1/messages" + headers = { + "User-Agent": CHROME_UA, + "Authorization": f"Bearer {cowork}", + "Content-Type": "application/json", + "Origin": "https://m365.cloud.microsoft", + "X-User-ID": auth.oid, + "X-Container-Config": DEFAULT_CONTAINER_CONFIG, + } + body = { + "connectorsConfig": {"connectors": [], "include_defaults": True, "packages": []}, + "content": [{"text": prompt, "type": "text"}], + "conversationId": conv, "messageId": str(uuid.uuid4()), "role": "user", + } + with Session(timeout=60) as s: + r = s.post(url, headers=headers, data=json.dumps(body)) + print(f"[send] {r.status_code} {r.text[:160]}") + + rx = TrouterReceiver(ic3, verbose=True) + reply = rx.receive(send, timeout=120) + + print("\n" + "=" * 60) + if reply: + print("REPLY:\n" + reply) + else: + print("No reply received over Trouter within the timeout.") + print("If deliveries=0, the backend needs the registrar step; if the socket " + "never connected, check the ic3 token / WS URL.") + print("=" * 60) + return 0 if reply else 1 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/copilot/m365/protocol.py b/copilot/m365/protocol.py new file mode 100644 index 0000000..746b703 --- /dev/null +++ b/copilot/m365/protocol.py @@ -0,0 +1,120 @@ +"""Parse Copilot Cowork replies out of Trouter deliveries; build the ACK. + +Every inbound push arrives as a Socket.IO type-3 message whose JSON is an +HTTP-request shape (:func:`copilot.m365.trouter.is_delivery`):: + + {"id": 498…, "method": "POST", "url": ".../messaging", + "headers": {…}, "body": ""} + +The ``body`` string decodes to a Teams messaging event:: + + {"type":"EventMessage","resourceType":"NewMessage", + "resource":{"content":"…","messagetype":"RichText/Copilot_AgentResponse", + "properties":{"copilotTextSegments":[{"id":"t0","text":"Hi"},…], + "copilotTaskState":"completed", + "copilotConversationId":"…"}}} + +We care about three ``resource`` shapes seen in the capture: + + * ``messagetype == "RichText/Copilot_AgentResponse"`` — the assistant's reply. + Text is in ``content`` (full) and streamed in ``copilotTextSegments``; + ``copilotTaskState == "completed"`` marks the turn done. + * ``messagetype == "Text"`` from an ``8:orgid:`` sender — the echo of the + user's own message; ignored. + * ``resourceType == "ThreadUpdate"`` — thread state (``copilotMetadata.state`` + e.g. ``response_sent``); carries no reply text. + +Each delivery must be answered with a type-3 ACK echoing the ``id`` and +``status:200`` (see :func:`build_ack`), or Trouter re-delivers. + +Pure parsing only — unit-tested against captured frames. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional + +from .trouter import Packet, TYPE_MESSAGE, decode, encode + +AGENT_RESPONSE_TYPE = "RichText/Copilot_AgentResponse" + + +@dataclass +class ReplyEvent: + """A parsed assistant reply from one Trouter delivery. + + ``text`` is the best full-text for this frame (``content`` when present, else + the concatenated segments). ``completed`` is True once the backend marks + ``copilotTaskState == "completed"`` — the caller stops reading the turn then. + """ + + text: str + completed: bool + conversation_id: Optional[str] = None + segments: List[str] = field(default_factory=list) + reasoning: List[str] = field(default_factory=list) + message_id: Optional[str] = None + + +def _delivery_body(delivery: Dict[str, Any]) -> Optional[Dict[str, Any]]: + """Decode a delivery's ``body`` (a JSON *string*) into the messaging event.""" + body = delivery.get("body") + if not isinstance(body, str) or not body: + return None + try: + return json.loads(body) + except ValueError: + return None + + +def parse_delivery(delivery: Dict[str, Any]) -> Optional[ReplyEvent]: + """Return a :class:`ReplyEvent` if ``delivery`` carries an assistant reply. + + ``delivery`` is the decoded type-3 JSON (``{id, method, url, headers, body}``). + Returns ``None`` for user echoes, thread updates, notifications, and anything + that isn't a ``Copilot_AgentResponse`` — the caller ACKs those but yields no + text. + """ + event = _delivery_body(delivery) + if not event: + return None + resource = event.get("resource") + if not isinstance(resource, dict): + return None + if resource.get("messagetype") != AGENT_RESPONSE_TYPE: + return None + + props = resource.get("properties") or {} + segments = [s.get("text", "") for s in props.get("copilotTextSegments", []) if isinstance(s, dict)] + reasoning = [r.get("content", "") for r in props.get("copilotReasoning", []) if isinstance(r, dict)] + content = resource.get("content") + text = content if isinstance(content, str) and content else "".join(segments) + + return ReplyEvent( + text=text, + completed=props.get("copilotTaskState") == "completed", + conversation_id=props.get("copilotConversationId"), + segments=segments, + reasoning=reasoning, + message_id=resource.get("id"), + ) + + +def build_ack(delivery: Dict[str, Any], status: int = 200) -> str: + """Build the type-3 ACK frame for an inbound delivery. + + Echoes the delivery ``id`` and the ``trouter-request``/``MS-CV`` headers back + with a status, matching the captured client ACK + ``3:::{"id":…,"status":200,"headers":{…},"body":""}``. Without this ACK the + Trouter backend redelivers the message. + """ + in_headers = delivery.get("headers") or {} + ack_headers: Dict[str, Any] = {} + for h in ("MS-CV", "trouter-request"): + if h in in_headers: + ack_headers[h] = in_headers[h] + ack_headers["trouter-client"] = {"cd": 1} + ack = {"id": delivery.get("id"), "status": status, "headers": ack_headers, "body": ""} + return encode(Packet(TYPE_MESSAGE, "", "", json.dumps(ack, separators=(",", ":")))) diff --git a/copilot/m365/trouter.py b/copilot/m365/trouter.py new file mode 100644 index 0000000..eeb0035 --- /dev/null +++ b/copilot/m365/trouter.py @@ -0,0 +1,145 @@ +"""Socket.IO 0.9 frame codec for the Trouter push channel. + +Trouter (``wss://.trouter.teams.microsoft.com/v4/c``) speaks the classic +**Socket.IO 0.9** wire protocol — the framing captured off the real BizChat/Cowork +client (see ``tests/capture_m365.py``). A frame is:: + + ::: + +where ``id`` and ``endpoint`` are usually empty and ``data`` (when present) is a +JSON string. Observed frames, all reproduced by :func:`decode`/:func:`encode`: + + 1:: connect ack (server) + 5:::{"name":"user.authenticate",...} event (client -> server: send token) + 5:1+::{"name":"ping"} event (client keepalive; id "1+") + 6:::1+["pong"] ack (server -> client) + 3:::{"id":498…,"method":"POST",...} message (server delivers a push) + 3:::{"id":498…,"status":200,...} message (client ACKs that delivery) + +The ``+`` suffix on an id marks that an ack is expected. Trouter tunnels each +inbound push as a **type-3 message** whose JSON body is itself an HTTP-request +shape (``{id, method, url, headers, body}``); the client must answer with a +type-3 message echoing the ``id`` and a ``status`` (see +:func:`copilot.m365.protocol.build_ack`). This module is pure framing only — no +I/O, no network — so it is unit-tested directly against captured frames. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from typing import Any, Optional + +# Socket.IO 0.9 packet types. +TYPE_DISCONNECT = 0 +TYPE_CONNECT = 1 +TYPE_HEARTBEAT = 2 +TYPE_MESSAGE = 3 +TYPE_JSON = 4 +TYPE_EVENT = 5 +TYPE_ACK = 6 +TYPE_ERROR = 7 + + +@dataclass +class Packet: + """A decoded Socket.IO 0.9 frame. + + ``id`` keeps its raw form including any trailing ``+`` (ack-requested), e.g. + ``"1+"``; ``data`` is the raw trailing string (often JSON — use :meth:`json`). + """ + + type: int + id: str + endpoint: str + data: str + + def json(self) -> Any: + """Parse ``data`` as JSON, or return ``None`` if it isn't valid JSON.""" + if not self.data: + return None + try: + return json.loads(self.data) + except ValueError: + return None + + @property + def ack_requested(self) -> bool: + """True when the peer wants an ack for this frame (id ends in ``+``).""" + return self.id.endswith("+") + + +def decode(frame: str) -> Packet: + """Decode one Socket.IO 0.9 frame string into a :class:`Packet`. + + Splits on ``:`` at most three times so a ``data`` segment containing colons + (URLs, JSON) stays intact, and pads missing trailing segments so bare frames + like ``"1::"`` decode without error. + """ + parts = frame.split(":", 3) + while len(parts) < 4: + parts.append("") + try: + ptype = int(parts[0]) + except ValueError as exc: + raise ValueError(f"not a Socket.IO frame: {frame!r}") from exc + return Packet(ptype, parts[1], parts[2], parts[3]) + + +def encode(packet: Packet) -> str: + """Encode a :class:`Packet` back to the wire string. + + A frame with empty ``data`` is emitted as three segments (``"1::"``), matching + the connect/heartbeat shape; otherwise all four segments are present. + """ + head = f"{packet.type}:{packet.id}:{packet.endpoint}" + return f"{head}:{packet.data}" if packet.data != "" else head + + +def event(name: str, args: Any, msg_id: str = "") -> str: + """Build a type-5 event frame, e.g. ``user.authenticate`` or ``ping``. + + ``args`` is placed as the event's argument list (wrapped in a list if it + isn't one already), matching ``{"name":..,"args":[..]}`` from the capture. + """ + payload = {"name": name, "args": args if isinstance(args, list) else [args]} + return encode(Packet(TYPE_EVENT, msg_id, "", json.dumps(payload, separators=(",", ":")))) + + +def message(obj: Any, msg_id: str = "") -> str: + """Build a type-3 message frame carrying ``obj`` as JSON (used for ACKs).""" + return encode(Packet(TYPE_MESSAGE, msg_id, "", json.dumps(obj, separators=(",", ":")))) + + +def authenticate_frame(access_token: str) -> str: + """The first client frame: authenticate the socket with a Bearer token. + + Mirrors the captured ``5:::{"name":"user.authenticate","args":[{"headers": + {"Authorization":"Bearer …","X-MS-Migration":"True"}}]}``. The token is the + ``ic3.teams.office.com`` (Trouter/Skype) token, not the substrate one. + """ + return event( + "user.authenticate", + [{"headers": {"Authorization": f"Bearer {access_token}", "X-MS-Migration": "True"}}], + ) + + +def ping_frame(seq: int) -> str: + """A keepalive ``ping`` event; ``seq`` becomes the ack-requested id ``"+"``.""" + return event("ping", [], msg_id=f"{seq}+") + + +def is_pong(packet: Packet) -> bool: + """True if ``packet`` is a server pong ack (``6:::N+["pong"]``).""" + return packet.type == TYPE_ACK and "pong" in (packet.data or "") + + +def is_delivery(packet: Packet) -> bool: + """True if ``packet`` is an inbound tunnelled push to ACK (a type-3 request). + + Distinguishes the server->client delivery (has ``method``) from our own + client->server ACK (has ``status``).""" + if packet.type != TYPE_MESSAGE: + return False + obj = packet.json() + return isinstance(obj, dict) and "method" in obj and "status" not in obj diff --git a/copilot/m365/trouter_client.py b/copilot/m365/trouter_client.py new file mode 100644 index 0000000..a5a4797 --- /dev/null +++ b/copilot/m365/trouter_client.py @@ -0,0 +1,204 @@ +"""Live Trouter receiver — the Cowork reply channel. + +The enterprise reply is delivered only over Trouter (Teams' Socket.IO push +channel); the Aether runtime exposes no REST read (confirmed: ``/v1/messages`` is +POST-only, ``/v1/conversations/`` is DELETE-only). Captured handshake +(``tests/capture_m365.py``): + + GET go-eu.trouter.teams.microsoft.com/?check=…&cor_id=…&epid=…&tc=… + WSS go-eu.trouter.teams.microsoft.com/v4/c?tc=…&timeout=40&epid=…&cor_id=…&con_num=… + <- 1:: (connect) + -> 5:::{"name":"user.authenticate", …Bearer …} + <- 5:1::{"name":"trouter.connected", {id, surl, registrarUrl, …}} + <- 3:::{… "body":"{…\"messagetype\":\"RichText/Copilot_AgentResponse\" …}"} (reply) + -> 3:::{"id":…,"status":200, …} (ACK) + +The reply arrives as a single delivery carrying the full ``content`` + +``copilotTaskState:"completed"`` (see :mod:`copilot.m365.protocol`). This client +connects, authenticates with the ``ic3.teams.office.com`` token, fires the send +via a callback once the channel is up, and returns the first completed reply. + +Registrar note: whether the backend needs an explicit registrar POST to route the +reply here is untested — :class:`TrouterReceiver` runs without it so we can learn +empirically; if no delivery arrives, the registrar step is added. +""" + +from __future__ import annotations + +import json +import time +import uuid +from typing import Callable, List, Optional +from urllib.parse import quote + +from curl_cffi.const import CurlECode, CurlInfo +from curl_cffi.curl import CurlError +from curl_cffi.requests import Session, CurlWsFlag +from select import select + +from ..useragent import CHROME_UA +from . import protocol, trouter + +TROUTER_HOST = "go-eu.trouter.teams.microsoft.com" +# The client-descriptor query the web app sends on every Trouter call. +_TC = {"cv": "2025.30.01.1", "ua": "BizChat", "hr": "", "v": "3639/1.0.0"} +_CURL_SOCKET_BAD = -1 + +# Where the endpoint is registered so the messaging backend routes the Cowork +# reply to our Trouter path. Body shape captured from the real client +# (tests/capture_registrar.py): registrationId == our WS epid, the TROUTER path +# is the surl from the trouter.connected frame. +REGISTRAR_URL = "https://edge.skype.com/registrar/prod/V3/registrations" +_CLIENT_DESCRIPTION = { + "appId": "bizchat", + "aesKey": "", + "languageId": "en-US", + "platform": "3639/1.0.0", + "templateKey": "bizchat_5.0", + "platformUIVersion": "3639/1.0.0", + "productContext": "COPILOT", +} + + +def _tc_param() -> str: + return quote(json.dumps(_TC, separators=(",", ":"))) + + +class TrouterReceiver: + """Connects the Trouter socket and returns one completed Cowork reply.""" + + def __init__(self, ic3_token: str, proxy: Optional[str] = None, verbose: bool = False): + self.ic3_token = ic3_token + self.proxy = proxy + self.verbose = verbose + self.connected_info: Optional[dict] = None + self._deliveries = 0 + + def _log(self, msg: str) -> None: + if self.verbose: + print(f"[trouter] {msg}", flush=True) + + def receive(self, on_connected: Callable[[dict], None], timeout: int = 120) -> str: + """Open the socket, authenticate, call ``on_connected`` once the channel + is up (fire the chat send there), and return the first completed reply's + text. Returns ``""`` if none arrives before ``timeout``. + """ + epid = str(uuid.uuid4()) + cor = str(uuid.uuid4()) + con_num = f"{int(time.time() * 1000)}_0" + ws_url = (f"wss://{TROUTER_HOST}/v4/c?tc={_tc_param()}&timeout=40&epid={epid}" + f"&ccid=&dom=m365.cloud.microsoft&cor_id={cor}&con_num={con_num}") + + segments: List[str] = [] + full_reply = "" + with Session(proxy=self.proxy, timeout=timeout + 30) as s: + # Pre-GET the mapping endpoint the web client hits first (best-effort). + try: + s.get(f"https://{TROUTER_HOST}/?check={int(time.time() * 1000)}" + f"&cor_id={cor}&epid={epid}&tc={_tc_param()}", + headers={"User-Agent": CHROME_UA}) + except Exception: + pass + + ws = s.ws_connect(ws_url, headers={"User-Agent": CHROME_UA}) + self._log(f"socket open (epid={epid})") + deadline = time.time() + timeout + fired = False + ping_seq = 0 + last_ping = time.time() + + while time.time() < deadline: + frame = self._recv_text(ws, min(deadline, time.time() + 20)) + if frame is None: + # Idle: keepalive ping so Trouter doesn't drop us. + if time.time() - last_ping > 15: + ping_seq += 1 + ws.send(trouter.ping_frame(ping_seq).encode(), CurlWsFlag.TEXT) + last_ping = time.time() + continue + + pkt = trouter.decode(frame) + if pkt.type == trouter.TYPE_CONNECT: # '1::' + ws.send(trouter.authenticate_frame(self.ic3_token).encode(), CurlWsFlag.TEXT) + self._log("authenticated") + continue + + obj = pkt.json() + if pkt.type == trouter.TYPE_EVENT and isinstance(obj, dict): + if obj.get("name") == "trouter.connected": + self.connected_info = (obj.get("args") or [{}])[0] + self._log("trouter.connected") + if not fired: + fired = True + # Register the endpoint FIRST so the messaging backend + # routes the reply to us, THEN fire the send. + self._register(epid, self.connected_info) + on_connected(self.connected_info) + continue + # ignore trouter.message_loss and other control events + + if trouter.is_delivery(pkt): + delivery = pkt.json() + self._deliveries += 1 + # ACK immediately or Trouter redelivers. + ws.send(protocol.build_ack(delivery).encode(), CurlWsFlag.TEXT) + ev = protocol.parse_delivery(delivery) + if ev and ev.text: + self._log(f"reply frame (completed={ev.completed})") + # The agent response carries the full text in one frame; + # keep the longest/last as the answer. + full_reply = ev.text if len(ev.text) >= len(full_reply) else full_reply + if ev.completed: + return full_reply + self._log(f"timeout after {self._deliveries} deliveries") + return full_reply + + def _register(self, epid: str, info: dict) -> None: + """Register our Trouter endpoint so the reply is routed to this socket. + + ``registrationId`` is our WS ``epid``; the TROUTER ``path`` is the ``surl`` + the server handed back in ``trouter.connected``. Bearer is the + ``ic3.teams.office.com`` token. Body shape captured from the real client. + """ + surl = info.get("surl") + if not surl: + self._log("no surl in trouter.connected; cannot register") + return + body = { + "clientDescription": _CLIENT_DESCRIPTION, + "registrationId": epid, + "nodeId": "", + "transports": {"TROUTER": [{"context": "", "path": surl, "ttl": 3600}]}, + } + try: + with Session(proxy=self.proxy, timeout=30) as s: + r = s.post(REGISTRAR_URL, headers={ + "User-Agent": CHROME_UA, + "Authorization": f"Bearer {self.ic3_token}", + "Content-Type": "application/json", + }, data=json.dumps(body)) + self._log(f"registrar -> {r.status_code}") + except Exception as e: # noqa: BLE001 + self._log(f"registrar error: {e}") + + @staticmethod + def _recv_text(ws, deadline: float) -> Optional[str]: + """Read one full WS text frame, or None past ``deadline`` (idle).""" + sock_fd = ws.curl.getinfo(CurlInfo.ACTIVESOCKET) + if sock_fd == _CURL_SOCKET_BAD: + raise ConnectionError("Trouter socket has no active socket") + chunks = [] + while True: + try: + chunk, frame = ws.recv_fragment() + chunks.append(chunk) + if frame.bytesleft == 0 and frame.flags & CurlWsFlag.CONT == 0: + data = b"".join(chunks) + return data.decode("utf-8", "replace") + except CurlError as e: + if e.code != CurlECode.AGAIN: + raise + remaining = deadline - time.time() + if remaining <= 0: + return None + select([sock_fd], [], [], min(0.5, remaining)) diff --git a/server/__init__.py b/server/__init__.py index c2c9ce6..570b868 100644 --- a/server/__init__.py +++ b/server/__init__.py @@ -32,20 +32,17 @@ def app(host=None, port=None) -> None: """ import uvicorn - from copilot.auth import load_auth - if host is None: host = os.environ.get("HOST", "127.0.0.1") if port is None: port = int(os.environ.get("PORT", "8000")) - # Ensure a signed-in Copilot session exists before we start serving. On the - # very first run this triggers the interactive browser sign-in (instead of - # letting the first HTTP request fail), then caches it for reuse. - try: - load_auth() - except Exception as exc: - print(f"Warning: could not establish a Copilot session: {exc}") + # No auth pre-warm at startup: it used to spin a headless browser (10-60s) when + # no consumer session existed, delaying the port bind so clients hit + # ECONNREFUSED while the server "started". Both drivers load their session + # lazily on the first matching request instead, so the server binds instantly. + # `copilot` -> sign in once: `python -m copilot login` + # `copilot-work` -> bootstrap once: `python -m copilot.m365.bootstrap` print(f"Copilot OpenAI-compatible API on http://{host}:{port} (POST /v1/chat/completions)") uvicorn.run(_api, host=host, port=port) diff --git a/server/api.py b/server/api.py index 82748a2..0b3199a 100644 --- a/server/api.py +++ b/server/api.py @@ -1,7 +1,9 @@ """FastAPI app wiring Copilot onto the OpenAI Chat Completions API.""" +import os import threading import time +from pathlib import Path from fastapi import FastAPI from fastapi.responses import JSONResponse, StreamingResponse @@ -9,16 +11,19 @@ from copilot import CopilotClient from copilot.driver import ClearanceRequired -from .config import MODEL_NAME, RATE_LIMIT_BURST, RATE_LIMIT_RPM +from .config import MAX_PROMPT_CHARS, MODEL_NAME, RATE_LIMIT_BURST, RATE_LIMIT_RPM, WORK_MODEL_NAME from .openai_format import ( completion_response, new_id, sse_event, stream_chunk, + usage_block, + usage_chunk, ) from .prompt import messages_to_prompt from .ratelimit import TokenBucket from .schemas import ChatCompletionRequest +from .tool_calls import fallback_local_tool_calls, parse_tool_calls, tool_choice_allows_tools app = FastAPI(title="Copilot OpenAI-compatible API", version="1.0.0") # Server runs headless and must never pop a visible browser mid-request. With @@ -34,6 +39,126 @@ "and pass the 'verify you're human' check, then retry." ) +# Enterprise (work-account) driver, lazily loaded on first `copilot-work` request +# so the server still starts consumer-only when M365 isn't bootstrapped. Cached +# after the first successful load (it holds the rotating refresh token). +_work_client = None +_work_lock = threading.Lock() +_WORK_HELP = ( + "The work-account (Microsoft 365 Copilot) driver is not set up. Run the " + "one-time bootstrap once: `python -m copilot.m365.bootstrap`, sign in with the " + "work account, then retry." +) + + +def _get_work_client(): + """Return the cached :class:`M365Copilot`, loading it on first use.""" + global _work_client + if _work_client is None: + with _work_lock: + if _work_client is None: + from copilot.m365 import M365Copilot + _work_client = M365Copilot() + return _work_client + + +def _include_usage(req) -> bool: + """True when the client asked for the standalone usage chunk (OpenAI + ``stream_options: {"include_usage": true}``).""" + return bool(req.stream_options and req.stream_options.get("include_usage")) + + +def _stream_work(prompt: str, model: str, conversation_id=None, tools=None, messages=None, include_usage=False): + """Yield OpenAI SSE chunks for the enterprise driver. + + Cowork delivers its reply as one completed message (no token stream), so we + emit the whole reply as a single content chunk between the role and stop + chunks — valid OpenAI streaming that clients render as one arrival. + + The upstream turn (lock wait + Cowork think-time) runs 30-120s with no bytes + of its own, which trips client inactivity timeouts and triggers retry storms. + So the blocking call runs in a worker thread while this generator emits SSE + comment keepalives — ignored by clients, but they reset the idle timer. The + worker also owns the upstream lock, so a client disconnect can no longer + leave the lock held past the turn: the thread always finishes and releases. + """ + cid = new_id() + created = int(time.time()) + yield sse_event(stream_chunk(cid, created, model, {"role": "assistant"})) + + result = {} + done = threading.Event() + + def worker(): + try: + # Serialize upstream turns: each Cowork call blocks a worker on a + # Trouter socket for ~20-30s, and a coding agent fires several at + # once; letting them all run concurrently starved the accept loop + # (clients saw ECONNREFUSED). One at a time is slower but stable. + with _upstream_lock: + bot = _get_work_client() + result["text"] = bot.ask(prompt, conversation_id=conversation_id) + result["conversation_id"] = bot.last_conversation_id + except Exception as exc: # includes missing-bootstrap RuntimeError + result["error"] = exc + finally: + done.set() + + threading.Thread(target=worker, daemon=True).start() + while not done.wait(15): + yield ": keepalive\n\n" + + if "error" in result: + _debug(f"ERROR(stream) work: {result['error']}") + yield sse_event(stream_chunk(cid, created, model, {"content": f"\n[error: {result['error']}]"}, finish="error")) + else: + text = result["text"] + _debug(f"REPLY(stream) work len={len(text)} :: {text[:120]!r}") + yield from _stream_text_or_tool_calls( + cid, created, model, text, result["conversation_id"], tools, messages, + prompt=prompt, include_usage=include_usage, + ) + yield "data: [DONE]\n\n" + + +def _stream_text_or_tool_calls(cid: str, created: int, model: str, text: str, conversation_id=None, tools=None, messages=None, prompt="", include_usage=False): + # Usage is counted locally (Copilot reports none). It rides on the finish + # chunk by default; when the client asked via stream_options.include_usage + # it instead arrives as the spec's standalone empty-choices chunk — either + # way it appears exactly once, so clients that sum chunk.usage stay correct. + usage = usage_block(prompt, text) + tool_calls = parse_tool_calls(text, tools) if tools else [] + if not tool_calls and tools and messages: + tool_calls = fallback_local_tool_calls(messages, tools, text) + if tool_calls: + _debug(f"TOOL_CALLS(stream) n={len(tool_calls)} names={[c['function']['name'] for c in tool_calls]}") + for index, call in enumerate(tool_calls): + delta_call = { + "index": index, + "id": call["id"], + "type": "function", + "function": call["function"], + } + yield sse_event(stream_chunk(cid, created, model, {"tool_calls": [delta_call]})) + yield sse_event(stream_chunk( + cid, created, model, {}, finish="tool_calls", + conversation_id=conversation_id, + usage=None if include_usage else usage, + )) + if include_usage: + yield sse_event(usage_chunk(cid, created, model, usage)) + return + + if text: + yield sse_event(stream_chunk(cid, created, model, {"content": text})) + yield sse_event(stream_chunk( + cid, created, model, {}, finish="stop", + conversation_id=conversation_id, + usage=None if include_usage else usage, + )) + if include_usage: + yield sse_event(usage_chunk(cid, created, model, usage)) + # Self-imposed rate limit on top of the concurrency lock below: this caps # requests-per-minute, the lock caps requests-in-flight. See server/ratelimit.py. _rate_limiter = TokenBucket(RATE_LIMIT_RPM, RATE_LIMIT_BURST) @@ -65,8 +190,56 @@ def _rate_limited_response(): # parallelism — fine for a personal bridge. _upstream_lock = threading.Lock() +# Lightweight request/reply tracing to session/bridge_debug.log (on by default; +# set BRIDGE_DEBUG=0 to silence). Used to see exactly what an agent client sends +# and gets back when a turn appears to hang. +_DEBUG = os.environ.get("BRIDGE_DEBUG", "1") == "1" + + +def _debug(msg: str) -> None: + if not _DEBUG: + return + try: + Path("session").mkdir(exist_ok=True) + with open("session/bridge_debug.log", "a", encoding="utf-8") as f: + f.write(f"{time.time():.1f} {msg}\n") + except Exception: + pass + -def _stream(prompt: str, model: str, conversation_id=None): +# Coding-agent clients (openclaude) fire a *title-generation* meta-call alongside +# every real turn: a prompt like "Generate a concise, sentence-case title …". On +# a slow backend (Cowork ~20s/call) that meta-call doubles every turn's latency +# for zero user value, and — serialized — makes the real answer wait behind it. +# So we answer title/summary meta-calls locally and instantly, never upstream. +def _is_title_request(prompt: str) -> bool: + p = prompt.lower() + return "title" in p and ( + "sentence-case" in p or ("concise" in p and "generate" in p) or "summarize" in p + ) + + +def _canned_title(prompt: str) -> str: + """A cheap title so the client has something to show, no upstream call.""" + # openclaude expects a JSON object with a "title" field. + return '{"title": "New conversation"}' + + +def _stream_text(text: str, model: str, prompt="", include_usage=False): + """Emit a fixed string as a one-shot OpenAI SSE stream (role/content/stop).""" + cid = new_id() + created = int(time.time()) + usage = usage_block(prompt, text) + yield sse_event(stream_chunk(cid, created, model, {"role": "assistant"})) + yield sse_event(stream_chunk(cid, created, model, {"content": text})) + yield sse_event(stream_chunk(cid, created, model, {}, finish="stop", + usage=None if include_usage else usage)) + if include_usage: + yield sse_event(usage_chunk(cid, created, model, usage)) + yield "data: [DONE]\n\n" + + +def _stream(prompt: str, model: str, conversation_id=None, tools=None, messages=None, include_usage=False): """Yield OpenAI ``chat.completion.chunk`` SSE events for ``prompt``. ``conversation_id`` continues an existing Copilot thread; ``None`` starts a @@ -78,17 +251,34 @@ def _stream(prompt: str, model: str, conversation_id=None): with _upstream_lock: # one upstream chat at a time (released on disconnect) yield sse_event(stream_chunk(cid, created, model, {"role": "assistant"})) stream = client.stream(prompt, conversation_id=conversation_id) + # Pieces are buffered even while streamed live, so the full reply is + # available for token counting (and tool-call parsing) at the end. + buffered = [] for piece in stream: if isinstance(piece, str) and piece: - yield sse_event(stream_chunk(cid, created, model, {"content": piece})) + buffered.append(piece) + if not tools: + yield sse_event(stream_chunk(cid, created, model, {"content": piece})) # Copilot's conversation id is known once the stream has run; emit it # on the final chunk so callers can track the upstream thread. - yield sse_event( - stream_chunk( - cid, created, model, {}, finish="stop", - conversation_id=stream.conversation_id, + text = "".join(buffered) + if tools: + _debug(f"REPLY(stream) consumer len={len(text)} :: {text[:120]!r}") + yield from _stream_text_or_tool_calls( + cid, created, model, text, stream.conversation_id, tools, messages, + prompt=prompt, include_usage=include_usage, ) - ) + else: + usage = usage_block(prompt, text) + yield sse_event( + stream_chunk( + cid, created, model, {}, finish="stop", + conversation_id=stream.conversation_id, + usage=None if include_usage else usage, + ) + ) + if include_usage: + yield sse_event(usage_chunk(cid, created, model, usage)) except ClearanceRequired: yield sse_event( stream_chunk(cid, created, model, {"content": f"\n[error: {_CLEARANCE_HELP}]"}, finish="error") @@ -105,20 +295,38 @@ def list_models(): return { "object": "list", "data": [ - {"id": MODEL_NAME, "object": "model", "created": 0, "owned_by": "microsoft"} + {"id": MODEL_NAME, "object": "model", "created": 0, "owned_by": "microsoft"}, + {"id": WORK_MODEL_NAME, "object": "model", "created": 0, "owned_by": "microsoft"}, ], } @app.post("/v1/chat/completions") def chat_completions(req: ChatCompletionRequest): - prompt = messages_to_prompt(req.messages) + active_tools = req.tools if req.tools and tool_choice_allows_tools(req.tool_choice) else None + prompt = messages_to_prompt(req.messages, active_tools, max_chars=MAX_PROMPT_CHARS) if not prompt.strip(): return JSONResponse( status_code=400, content={"error": {"message": "no text content in messages", "type": "invalid_request_error"}}, ) model = req.model or MODEL_NAME + is_work = model == WORK_MODEL_NAME + _debug(f"REQ model={model} stream={req.stream} nmsg={len(req.messages)} " + f"roles={[m.role for m in req.messages]} tools={len(active_tools or [])} " + f"prompt[:100]={prompt[:100]!r}") + + # Short-circuit the client's title/summary meta-call: answer locally so it + # never spends an upstream turn (and never queues ahead of the real answer). + if _is_title_request(prompt): + title = _canned_title(prompt) + _debug(f"SHORTCIRCUIT title-gen -> {title}") + if req.stream: + return StreamingResponse( + _stream_text(title, model, prompt, _include_usage(req)), + media_type="text/event-stream", + ) + return completion_response(title, model, None, prompt_text=prompt) # Enforce the per-minute ceiling before touching the upstream lock, so excess # callers get a fast 429 instead of piling up behind the serialized queue. @@ -127,10 +335,29 @@ def chat_completions(req: ChatCompletionRequest): return limited if req.stream: + streamer = _stream_work if is_work else _stream return StreamingResponse( - _stream(prompt, model, req.conversation_id), media_type="text/event-stream" + streamer(prompt, model, req.conversation_id, active_tools, req.messages, _include_usage(req)), + media_type="text/event-stream", ) + if is_work: + try: + with _upstream_lock: # serialize (see _stream_work): stable under an agent's bursts + bot = _get_work_client() + text = bot.ask(prompt, conversation_id=req.conversation_id) + _debug(f"REPLY(non-stream) work len={len(text)} :: {text[:120]!r}") + except Exception as exc: + _debug(f"ERROR(non-stream) work: {exc}") + return JSONResponse( + status_code=502, + content={"error": {"message": str(exc), "type": "upstream_error"}}, + ) + tool_calls = parse_tool_calls(text, active_tools) if active_tools else [] + if not tool_calls and active_tools: + tool_calls = fallback_local_tool_calls(req.messages, active_tools, text) + return completion_response(text, model, bot.last_conversation_id, tool_calls=tool_calls, prompt_text=prompt) + try: with _upstream_lock: # serialize: one upstream chat at a time reply = client.chat(prompt, conversation_id=req.conversation_id) @@ -144,7 +371,10 @@ def chat_completions(req: ChatCompletionRequest): status_code=502, content={"error": {"message": str(exc), "type": "upstream_error"}}, ) - return completion_response(reply.text, model, reply.conversation_id) + tool_calls = parse_tool_calls(reply.text, active_tools) if active_tools else [] + if not tool_calls and active_tools: + tool_calls = fallback_local_tool_calls(req.messages, active_tools, reply.text) + return completion_response(reply.text, model, reply.conversation_id, tool_calls=tool_calls, prompt_text=prompt) @app.get("/") diff --git a/server/config.py b/server/config.py index ecdc5d4..cb66e0a 100644 --- a/server/config.py +++ b/server/config.py @@ -2,8 +2,12 @@ import os -# The single model id this bridge advertises (Copilot has no model selector). +# The consumer (personal-account) model id this bridge advertises. MODEL_NAME = "copilot" +# The enterprise (work/school account) model id — routed to the Microsoft 365 +# Copilot "Cowork" driver (copilot.m365). Requires a one-time bootstrap: +# `python -m copilot.m365.bootstrap`. Select it from clients via this model name. +WORK_MODEL_NAME = os.environ.get("WORK_MODEL_NAME", "copilot-work") # Self-imposed rate limit (Copilot publishes none). Tune to whatever ceiling the # probe in tests/ratelimit.py shows your account tolerates. @@ -13,3 +17,9 @@ # upstream 502s, so the limiter only bites when callers try to exceed that. RATE_LIMIT_RPM = float(os.environ.get("RATE_LIMIT_RPM", "12")) # 12 rpm ≈ 5s per call RATE_LIMIT_BURST = int(os.environ.get("RATE_LIMIT_BURST", "4")) + +# Ceiling on the flattened prompt sent upstream, in characters. The bridge +# resends the whole conversation each turn, so long agent sessions eventually +# exceed what Copilot accepts; past this cap the oldest turns are dropped +# (system + tool instructions + newest turns always survive). 0 disables. +MAX_PROMPT_CHARS = int(os.environ.get("MAX_PROMPT_CHARS", "120000")) diff --git a/server/openai_format.py b/server/openai_format.py index 70275b7..be83585 100644 --- a/server/openai_format.py +++ b/server/openai_format.py @@ -4,19 +4,64 @@ import time import uuid +# Token counting for the `usage` block. Copilot's protocols report no usage, so +# the bridge counts locally: tiktoken when installed (accurate), else a ~4 +# chars/token estimate — close enough for client-side cost displays. +_encoder = None +_encoder_tried = False + + +def _get_encoder(): + global _encoder, _encoder_tried + if not _encoder_tried: + _encoder_tried = True + try: + import tiktoken + _encoder = tiktoken.get_encoding("o200k_base") + except Exception: + _encoder = None + return _encoder + + +def count_tokens(text: str) -> int: + """Token count of ``text`` — tiktoken if available, else len/4 estimate.""" + if not text: + return 0 + enc = _get_encoder() + if enc is not None: + return len(enc.encode(text)) + return max(1, (len(text) + 3) // 4) + + +def usage_block(prompt_text: str, completion_text: str) -> dict: + """An OpenAI ``usage`` object counted from the raw prompt/reply text.""" + prompt_tokens = count_tokens(prompt_text) + completion_tokens = count_tokens(completion_text) + return { + "prompt_tokens": prompt_tokens, + "completion_tokens": completion_tokens, + "total_tokens": prompt_tokens + completion_tokens, + } + def new_id() -> str: """A fresh ``chatcmpl-...`` id, as the OpenAI API returns.""" return f"chatcmpl-{uuid.uuid4().hex}" -def completion_response(text: str, model: str, conversation_id=None) -> dict: +def completion_response(text: str, model: str, conversation_id=None, tool_calls=None, prompt_text="") -> dict: """A non-streaming ``chat.completion`` object. ``conversation_id`` is Copilot's own conversation id, surfaced as an extra top-level field (not part of OpenAI's schema, so standard clients ignore it) for callers that want to track the upstream thread. """ + message = {"role": "assistant", "content": None if tool_calls else text} + finish_reason = "stop" + if tool_calls: + message["tool_calls"] = tool_calls + finish_reason = "tool_calls" + return { "id": new_id(), "object": "chat.completion", @@ -26,11 +71,11 @@ def completion_response(text: str, model: str, conversation_id=None) -> dict: "choices": [ { "index": 0, - "message": {"role": "assistant", "content": text}, - "finish_reason": "stop", + "message": message, + "finish_reason": finish_reason, } ], - "usage": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}, + "usage": usage_block(prompt_text, text), } @@ -40,13 +85,15 @@ def sse_event(payload: dict) -> str: def stream_chunk( - cid: str, created: int, model: str, delta: dict, finish=None, conversation_id=None + cid: str, created: int, model: str, delta: dict, finish=None, conversation_id=None, usage=None ) -> dict: """A single ``chat.completion.chunk`` object for streaming responses. ``conversation_id`` (Copilot's upstream id) is added as an extra top-level field when known — typically only on the final chunk, since a new conversation's id isn't available until the stream has started. + ``usage`` (a :func:`usage_block`) is attached when given — on the final + chunk, so clients that read ``chunk.usage`` see it exactly once. """ chunk = { "id": cid, @@ -57,4 +104,22 @@ def stream_chunk( } if conversation_id is not None: chunk["conversation_id"] = conversation_id + if usage is not None: + chunk["usage"] = usage return chunk + + +def usage_chunk(cid: str, created: int, model: str, usage: dict) -> dict: + """The standalone usage chunk OpenAI sends when ``include_usage`` is set. + + Per spec it has an empty ``choices`` list and arrives after the finish + chunk, just before ``[DONE]``. + """ + return { + "id": cid, + "object": "chat.completion.chunk", + "created": created, + "model": model, + "choices": [], + "usage": usage, + } diff --git a/server/prompt.py b/server/prompt.py index 8ed8b8c..2fffdd1 100644 --- a/server/prompt.py +++ b/server/prompt.py @@ -4,9 +4,11 @@ turn — so we collapse the whole conversation into one piece of text. """ -from typing import Any, List, Optional, Union +import json +from typing import Any, List, Optional, Sequence, Union from .schemas import ChatMessage +from .tool_calls import build_tool_instructions def content_text(content: Optional[Union[str, List[Any]]]) -> str: @@ -20,28 +22,137 @@ def content_text(content: Optional[Union[str, List[Any]]]) -> str: if isinstance(part, dict): if part.get("type") == "text": parts.append(part.get("text", "")) + elif part.get("type") == "tool_result": + parts.append(content_text(part.get("content"))) + elif isinstance(part.get("text"), str): + parts.append(part["text"]) else: parts.append(str(part)) return "\n".join(p for p in parts if p) -def messages_to_prompt(messages: List[ChatMessage]) -> str: - """Flatten an OpenAI ``messages`` array into a single Copilot prompt.""" +TRUNCATION_NOTICE = ( + "[Note: earlier turns of this conversation were dropped to fit the upstream " + "prompt limit. Ask for a recap or re-read files if older context is needed.]" +) + + +def messages_to_prompt( + messages: List[ChatMessage], + tools: Optional[Sequence[Any]] = None, + max_chars: Optional[int] = None, +) -> str: + """Flatten an OpenAI ``messages`` array into a single Copilot prompt. + + When ``max_chars`` is set and the flattened prompt would exceed it, the + oldest conversation lines are dropped (and oversized lines middle-cut) so + the system prompt, tool instructions, and newest turns always survive. + """ system = "\n\n".join( content_text(m.content) for m in messages if m.role == "system" and m.content ) + tool_instructions = build_tool_instructions(tools) + if system and tool_instructions: + system = f"{system}\n\n{tool_instructions}" + elif tool_instructions: + system = tool_instructions + convo = [m for m in messages if m.role != "system"] if len(convo) == 1 and convo[0].role == "user": - body = content_text(convo[0].content) # simple single-turn request + lines = [content_text(convo[0].content)] # simple single-turn request + cue = None else: lines = [] for m in convo: - label = "User" if m.role == "user" else "Assistant" - lines.append(f"{label}: {content_text(m.content)}") - lines.append("Assistant:") # cue Copilot to continue - body = "\n".join(lines) + text = content_text(m.content) + if m.role == "user": + lines.append(f"User: {text}") + elif m.role == "assistant": + if text: + lines.append(f"Assistant: {text}") + if m.tool_calls: + lines.append(f"Assistant tool calls: {_tool_calls_text(m.tool_calls)}") + elif m.role == "tool": + name = m.name or m.tool_call_id or "tool" + lines.append(f"Tool result ({name}): {text}") + else: + lines.append(f"{m.role.title()}: {text}") + cue = "Assistant:" # cue Copilot to continue + + if max_chars and max_chars > 0: + overhead = (len(system) + 2 if system else 0) + (len(cue) + 1 if cue else 0) + budget = max_chars - overhead - (len(TRUNCATION_NOTICE) + 1) + lines, truncated = _fit_lines(lines, budget) + if truncated: + lines.insert(0, TRUNCATION_NOTICE) + + if cue: + lines.append(cue) + body = "\n".join(lines) if system and body: return f"{system}\n\n{body}" return system or body + + +def _fit_lines(lines: List[str], budget: int) -> tuple: + """Keep the newest lines that fit ``budget`` joined chars; report truncation. + + Oversized individual lines (huge tool results) are middle-cut rather than + allowed to crowd out whole turns. + """ + total = sum(len(line) for line in lines) + max(0, len(lines) - 1) + if total <= budget: + return lines, False + if budget <= 0: + return [], True + + # A single line may claim at most a third of the budget once we are over. + per_line_cap = max(200, budget // 3) + clamped = [_middle_cut(line, per_line_cap) for line in lines] + + kept: List[str] = [] + used = 0 + for line in reversed(clamped): # newest first + cost = len(line) + (1 if kept else 0) + if used + cost > budget: + if not kept: # even the newest line alone is too big + kept.append(_middle_cut(line, budget)) + break + kept.append(line) + used += cost + kept.reverse() + return kept, True + + +def _middle_cut(line: str, limit: int) -> str: + if len(line) <= limit: + return line + marker = " ...[truncated]... " + half = max(1, (limit - len(marker)) // 2) + return f"{line[:half]}{marker}{line[-half:]}" + + +def _tool_calls_text(tool_calls: List[Any]) -> str: + rendered = [] + for call in tool_calls: + if not isinstance(call, dict): + continue + fn = call.get("function") if isinstance(call.get("function"), dict) else {} + name = fn.get("name") or call.get("name") or "tool" + arguments = fn.get("arguments", call.get("arguments", {})) + if isinstance(arguments, str): + try: + arguments = json.loads(arguments) + except Exception: + pass + rendered.append(f"{name}({_safe_json(arguments)}) [id: {call.get('id', 'unknown')}]") + return "; ".join(rendered) + + +def _safe_json(value: Any) -> str: + try: + return json.dumps(value, ensure_ascii=False, separators=(",", ":")) + except Exception: + return str(value) diff --git a/server/schemas.py b/server/schemas.py index 1e78e5a..9ff7a25 100644 --- a/server/schemas.py +++ b/server/schemas.py @@ -12,12 +12,21 @@ class ChatMessage(BaseModel): # content is a plain string, or OpenAI "content parts" (list of dicts), or # null for some tool/assistant messages. content: Optional[Union[str, List[Any]]] = None + # Tool-call fields used by coding-agent clients on follow-up turns. + tool_calls: Optional[List[Any]] = None + tool_call_id: Optional[str] = None + name: Optional[str] = None class ChatCompletionRequest(BaseModel): messages: List[ChatMessage] model: Optional[str] = MODEL_NAME stream: bool = False + # OpenAI stream options; {"include_usage": true} asks for the standalone + # usage chunk (empty choices) after the finish chunk. + stream_options: Optional[dict] = None + tools: Optional[List[Any]] = None + tool_choice: Optional[Any] = None # Copilot's own conversation id (returned in earlier responses). Pass it back # to continue that thread; omit it to start a fresh conversation. Outside # OpenAI's schema, but standard clients can set it via extra_body. diff --git a/server/tool_calls.py b/server/tool_calls.py new file mode 100644 index 0000000..c1e91f4 --- /dev/null +++ b/server/tool_calls.py @@ -0,0 +1,394 @@ +"""Compatibility helpers for OpenAI-style local tool calls. + +Copilot only returns text, while coding-agent clients such as OpenClaude expect +OpenAI ``tool_calls`` objects. These helpers add a small text protocol around +Copilot and translate matching replies back into structured tool calls. +""" + +import json +import re +import uuid +from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple + + +RAW_TOOL_PREFIX = "Tool calls requested:" +_TOOL_LINE_RE = re.compile( + r"^-\s*([A-Za-z_][A-Za-z0-9_.-]*)\(([\s\S]*)\)\s*\[id:\s*([^\]\s]+)\]\s*$" +) +_FENCED_JSON_RE = re.compile(r"```(?:json)?\s*\n?([\s\S]*?)\n?```", re.I) +_XML_TOOL_RE = re.compile(r"([\s\S]*?)(?:|$)", re.I) +_XML_FUNCTION_RE = re.compile(r"\s]+)\s*>", re.I) +_XML_PARAM_RE = re.compile(r"\s]+)\s*>([\s\S]*?)", re.I) + + +def tool_choice_allows_tools(tool_choice: Any) -> bool: + """Return whether an OpenAI ``tool_choice`` value permits tool use.""" + if tool_choice is None: + return True + if isinstance(tool_choice, str): + return tool_choice.lower() != "none" + if isinstance(tool_choice, dict): + return str(tool_choice.get("type", "")).lower() != "none" + return True + + +def build_tool_instructions(tools: Optional[Sequence[Any]]) -> str: + """Build concise prompt instructions that teach text-only Copilot tool use.""" + specs = list(_iter_tool_specs(tools or [])) + if not specs: + return "" + + lines = [ + "Local tool use is available through the client.", + "You are connected to the user's real local machine and workspace through these tools.", + "Ignore any built-in environment you believe you have, such as an upload sandbox with input/, output/, or working/ directories - it does not exist here and the user cannot upload files.", + "When you need to inspect or modify local files, search the workspace, or run commands, request a tool instead of guessing.", + "Never answer questions about local files, directories, or repository state from memory; request a tool first.", + "If you need a tool, reply with only this exact format:", + RAW_TOOL_PREFIX, + '- ToolName({"arg":"value"}) [id: call_short_unique_id]', + "Use JSON object arguments that match the selected tool. Do not claim you checked files until a Tool result appears in the conversation.", + "", + "Available tools:", + ] + + for name, description, parameters in specs[:40]: + lines.append(f"- {name}: {_truncate(description, 500)}") + if parameters: + lines.append(f" parameters: {_truncate(_json(parameters), 1500)}") + return "\n".join(lines) + + +def parse_tool_calls(text: str, tools: Optional[Sequence[Any]] = None) -> List[Dict[str, Any]]: + """Parse Copilot text into OpenAI ``tool_calls`` objects.""" + allowed = {name for name, _, _ in _iter_tool_specs(tools or [])} + calls: List[Tuple[Optional[str], str, Any]] = [] + calls.extend(_parse_raw_tool_block(text)) + calls.extend(_parse_json_tool_calls(text)) + calls.extend(_parse_xml_tool_calls(text)) + + result: List[Dict[str, Any]] = [] + seen = set() + for call_id, name, arguments in calls: + if allowed and name not in allowed: + continue + args = _coerce_arguments(arguments) + key = (name, _json(args)) + if key in seen: + continue + seen.add(key) + result.append({ + "id": call_id or _new_tool_call_id(), + "type": "function", + "function": { + "name": name, + "arguments": _json(args), + }, + }) + return result + + +def fallback_local_tool_calls(messages: Sequence[Any], tools: Sequence[Any], assistant_text: str = "") -> List[Dict[str, Any]]: + """Force a read-only local tool call when the upstream answers without one. + + Some text-only backends ignore tool-use instructions and answer from stale + environment context. For obvious local-inspection requests, returning a + tool call is safer than passing through a guessed workspace answer. + """ + if not tools or _has_tool_result_after_last_user(messages): + return [] + + user_text = _last_user_text(messages).lower() + if not user_text: + return [] + + if _asks_to_list_files(user_text) or _looks_like_stale_workspace_answer(assistant_text): + if _has_tool(tools, "Glob"): + return [_tool_call("Glob", {"pattern": "**/*"})] + if _has_tool(tools, "Bash"): + return [_tool_call("Bash", { + "command": "pwd; find . -maxdepth 2 -print | sort", + "description": "List files in current directory", + })] + + if _asks_for_git_status(user_text) and _has_tool(tools, "Bash"): + return [_tool_call("Bash", { + "command": "git status --short", + "description": "Show working tree status", + })] + + if _asks_for_current_directory(user_text) and _has_tool(tools, "Bash"): + return [_tool_call("Bash", { + "command": "pwd", + "description": "Show current directory", + })] + + return [] + + +def _iter_tool_specs(tools: Iterable[Any]) -> Iterable[Tuple[str, str, Any]]: + for tool in tools: + if not isinstance(tool, dict): + continue + fn = tool.get("function") if tool.get("type") == "function" else tool + if not isinstance(fn, dict): + continue + name = fn.get("name") + if not isinstance(name, str) or not name: + continue + description = fn.get("description") if isinstance(fn.get("description"), str) else "" + parameters = fn.get("parameters") or fn.get("input_schema") or {} + yield name, description, parameters + + +def _tool_call(name: str, arguments: Dict[str, Any]) -> Dict[str, Any]: + return { + "id": _new_tool_call_id(), + "type": "function", + "function": { + "name": name, + "arguments": _json(arguments), + }, + } + + +def _has_tool(tools: Sequence[Any], name: str) -> bool: + return any(tool_name == name for tool_name, _, _ in _iter_tool_specs(tools)) + + +def _last_user_text(messages: Sequence[Any]) -> str: + for message in reversed(messages): + role = getattr(message, "role", None) + if role != "user": + continue + return _content_text(getattr(message, "content", None)) + return "" + + +def _has_tool_result_after_last_user(messages: Sequence[Any]) -> bool: + for message in reversed(messages): + role = getattr(message, "role", None) + if role == "tool": + return True + if role == "user": + content = getattr(message, "content", None) + if _content_contains_tool_result(content): + return True + return False + return False + + +def _content_text(content: Any) -> str: + if content is None: + return "" + if isinstance(content, str): + return content + if isinstance(content, list): + parts = [] + for part in content: + if isinstance(part, dict): + if isinstance(part.get("text"), str): + parts.append(part["text"]) + elif part.get("type") == "tool_result": + parts.append(_content_text(part.get("content"))) + else: + parts.append(str(part)) + return "\n".join(parts) + return str(content) + + +def _content_contains_tool_result(content: Any) -> bool: + return isinstance(content, list) and any( + isinstance(part, dict) and part.get("type") == "tool_result" + for part in content + ) + + +_LIST_FILES_RE = re.compile( + r"\b(?:list|show|see|what)\b[\w\s'\",:-]{0,40}\bfiles\b" +) + + +def _asks_to_list_files(text: str) -> bool: + compact = " ".join(text.split()) + return ( + bool(_LIST_FILES_RE.search(compact)) + or compact in {"ls", "dir", "tree"} + or compact.startswith("ls ") + or compact.startswith("dir ") + or compact.startswith("tree ") + ) + + +def _asks_for_git_status(text: str) -> bool: + compact = " ".join(text.split()) + return "git status" in compact or compact in {"status", "repo status"} + + +def _asks_for_current_directory(text: str) -> bool: + compact = " ".join(text.split()) + return compact in {"pwd", "where am i", "current directory", "working directory"} + + +def _looks_like_stale_workspace_answer(text: str) -> bool: + lowered = text.lower() + if "/mnt/workspace" in lowered: + return True + if any( + marker in lowered + for marker in ("no user files uploaded", "nothing uploaded", "no files uploaded") + ): + return True + # Copilot's sandbox persona describes input/, output/, working/ directories. + sandbox_dirs = sum(d in lowered for d in ("input/", "output/", "working/")) + return sandbox_dirs >= 2 and ("empty" in lowered or "upload" in lowered) + + +def _parse_raw_tool_block(text: str) -> List[Tuple[Optional[str], str, Any]]: + idx = text.find(RAW_TOOL_PREFIX) + if idx == -1: + return [] + calls = [] + body = text[idx + len(RAW_TOOL_PREFIX):] + for raw_line in body.splitlines(): + line = raw_line.strip() + if not line or line.startswith("```"): + continue + match = _TOOL_LINE_RE.match(line) + if not match: + if calls: + break + continue + name, raw_args, call_id = match.groups() + calls.append((call_id, name, _coerce_arguments(raw_args))) + return calls + + +def _parse_json_tool_calls(text: str) -> List[Tuple[Optional[str], str, Any]]: + calls: List[Tuple[Optional[str], str, Any]] = [] + for match in _FENCED_JSON_RE.finditer(text): + calls.extend(_calls_from_json_text(match.group(1).strip())) + + pos = 0 + while True: + start = text.find("{", pos) + if start == -1: + break + raw = _extract_balanced_json(text, start) + if raw: + calls.extend(_calls_from_json_text(raw)) + pos = start + len(raw) + else: + pos = start + 1 + return calls + + +def _parse_xml_tool_calls(text: str) -> List[Tuple[Optional[str], str, Any]]: + calls = [] + for block in _XML_TOOL_RE.finditer(text): + inner = block.group(1) or "" + fn_match = _XML_FUNCTION_RE.search(inner) + if not fn_match: + continue + name = fn_match.group(1) + args = { + key: _coerce_xml_value(value) + for key, value in _XML_PARAM_RE.findall(inner) + } + calls.append((None, name, args)) + return calls + + +def _calls_from_json_text(raw: str) -> List[Tuple[Optional[str], str, Any]]: + try: + parsed = json.loads(raw) + except Exception: + return [] + return _calls_from_json_value(parsed) + + +def _calls_from_json_value(value: Any) -> List[Tuple[Optional[str], str, Any]]: + if isinstance(value, list): + calls: List[Tuple[Optional[str], str, Any]] = [] + for item in value: + calls.extend(_calls_from_json_value(item)) + return calls + if not isinstance(value, dict): + return [] + + if isinstance(value.get("tool_calls"), list): + return _calls_from_json_value(value["tool_calls"]) + + call_id = value.get("id") if isinstance(value.get("id"), str) else None + if isinstance(value.get("name"), str): + return [(call_id, value["name"], value.get("arguments", value.get("input", {})))] + + fn = value.get("function") + if isinstance(fn, dict) and isinstance(fn.get("name"), str): + return [(call_id, fn["name"], fn.get("arguments", {}))] + return [] + + +def _coerce_arguments(raw: Any) -> Dict[str, Any]: + if raw is None: + return {} + if isinstance(raw, dict): + return raw + if isinstance(raw, str): + stripped = raw.strip() + if not stripped: + return {} + try: + parsed = json.loads(stripped) + return parsed if isinstance(parsed, dict) else {} + except Exception: + return {} + return {} + + +def _coerce_xml_value(raw: str) -> Any: + stripped = raw.strip() + if not stripped: + return "" + try: + return json.loads(stripped) + except Exception: + return stripped + + +def _extract_balanced_json(text: str, start: int) -> Optional[str]: + depth = 0 + in_string = False + escape = False + for idx in range(start, len(text)): + char = text[idx] + if escape: + escape = False + continue + if char == "\\" and in_string: + escape = True + continue + if char == '"': + in_string = not in_string + continue + if in_string: + continue + if char == "{": + depth += 1 + elif char == "}": + depth -= 1 + if depth == 0: + return text[start:idx + 1] + return None + + +def _new_tool_call_id() -> str: + return f"call_{uuid.uuid4().hex[:16]}" + + +def _json(value: Any) -> str: + return json.dumps(value, ensure_ascii=False, separators=(",", ":")) + + +def _truncate(value: str, limit: int) -> str: + return value if len(value) <= limit else value[: limit - 3] + "..." diff --git a/tests/capture_m365.py b/tests/capture_m365.py new file mode 100644 index 0000000..39d88f1 --- /dev/null +++ b/tests/capture_m365.py @@ -0,0 +1,274 @@ +"""Capture the Microsoft 365 (enterprise) Copilot chat protocol — for building an +m365 driver. + +Personal Copilot lives at copilot.microsoft.com; a work/school (Entra ID) account +is redirected to the *enterprise* Microsoft 365 Copilot at m365.cloud.microsoft, +whose chat backend the consumer driver (:mod:`copilot.driver`) does NOT speak. +This harness records that enterprise protocol from real traffic so the driver can +be written from evidence instead of guesses. + +Run it signed in with your WORK account (the persistent profile under +``session/profile`` must already hold that sign-in — do ``python -m copilot +login`` with the work account first if not): + + python tests/capture_m365.py + +It opens the signed-in profile, lands on the M365 Copilot chat (letting the +work-account redirect happen), and asks you to send ONE short message. Everything +below is appended, redacted, to ``session/m365_capture.log``: + + * WEBSOCKETS: every socket opened, its URL (query redacted), and every + frame sent/received — this is the reply protocol if the transport is WS. + * NETWORK: every request/response to office/substrate/m365 hosts — method, + URL, status, content-type, whether an ``Authorization: Bearer`` header rode + along, and (decoded from the JWT's *unsigned* payload) that token's ``aud`` + (resource) and ``scp`` (scopes). The request POST bodies are logged too: + they are the chat-send payload shape the driver must reproduce. + * TOKENS: the MSAL access-token entries in localStorage — their ``target`` + (scope) and ``clientId`` only, never the secret — so we learn which scope + the enterprise chat call actually uses (vs consumer ``ChatAI.ReadWrite``). + +SAFE TO SHARE: access tokens, JWTs, auth codes, and emails are redacted; only +non-secret token *claims* (aud/scp) are decoded and logged. Skim before posting +and attach ``session/m365_capture.log`` to the design discussion. +""" + +import base64 +import json +import re +import sys +import time +from pathlib import Path +from urllib.parse import urlparse + +# Run as a plain script: put the project root on sys.path so `copilot` imports. +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +SESSION_DIR = Path("session") +PROFILE_DIR = SESSION_DIR / "profile" +CAPTURE_LOG = SESSION_DIR / "m365_capture.log" + +# Where the work-account chat actually lives. We navigate to consumer Copilot and +# let it redirect (matches what a normal sign-in does), then also hard-nav to the +# M365 chat in case the profile lands somewhere neutral. +START_URL = "https://copilot.microsoft.com/" +M365_CHAT_URL = "https://m365.cloud.microsoft/chat/" + +# Hosts whose traffic is the enterprise chat protocol (everything else is noise). +# Enterprise BizChat sends the query to Substrate/Sydney (substrate.office.com) +# and receives the streamed reply over a Trouter push channel on +# *.trouter.teams.microsoft.com — so those hosts must be in scope or the actual +# chat protocol is invisible (the first capture missed it). +_INTERESTING_HOST_HINTS = ( + "cloud.microsoft", "office.com", "substrate", "m365", "copilot", + "trouter", "teams.microsoft", "sydney", "bing.com", "skype", +) + +_CAPTURE_TICKS = 600 # 600 * 500ms = 5 min ceiling + + +# --- redaction (standalone copy of diagnostic.py's, so this script is portable) -- +_REDACTORS = [ + (re.compile(r"(accessToken|access_token)=[^&\s\"]+", re.I), r"\1="), + (re.compile(r"(code|client_info|state|nonce|epct|epctrc|reconnectionToken)=[^&\s\"]+", re.I), r"\1="), + (re.compile(r"eyJ[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+(?:\.[A-Za-z0-9_\-]+)?"), ""), + (re.compile(r"\bM\.[A-Za-z0-9_\-.!*$%]{20,}"), ""), + (re.compile(r"[\w.+-]+@[\w-]+\.[\w.-]+"), ""), + (re.compile(r"[A-Za-z0-9_\-]{40,}"), ""), +] + + +def redact(text: str) -> str: + 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) + + +def _interesting(url: str) -> bool: + host = (urlparse(url).hostname or "").lower() + return any(hint in host for hint in _INTERESTING_HOST_HINTS) + + +def _jwt_claims(bearer: str) -> str: + """Return non-secret ``aud``/``scp``/``appid`` from a Bearer JWT's payload. + + Decodes only the middle (payload) segment — never verifies or logs the + signature — to reveal which resource+scope the enterprise chat call uses. + Returns "" if the header isn't a decodable JWT.""" + try: + tok = bearer.split(None, 1)[1] if bearer.lower().startswith("bearer ") else bearer + parts = tok.split(".") + if len(parts) < 2: + return "" + pad = parts[1] + "=" * (-len(parts[1]) % 4) + claims = json.loads(base64.urlsafe_b64decode(pad)) + keep = {k: claims.get(k) for k in ("aud", "scp", "appid", "roles", "iss") if k in claims} + return json.dumps(keep, ensure_ascii=False) + except Exception: + return "" + + +# List MSAL AccessToken entries' scope/client only (never the secret). +_LIST_TOKENS_JS = """ +() => { + const out = []; + try { + 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); + out.push({target: o.target || null, clientId: o.clientId || null, + realm: o.realm || null, secretLen: (o.secret||'').length}); + } catch (e) {} + } + } + } catch (e) {} + return out; +} +""" + + +def main() -> None: + SESSION_DIR.mkdir(parents=True, exist_ok=True) + sink = CAPTURE_LOG.open("w", encoding="utf-8") + + def log(tag: str, payload="") -> None: + if not sink.closed: + sink.write(redact(f"{tag} {_to_text(payload)}").rstrip() + "\n") + sink.flush() + + print("\n" + "=" * 70) + print("Opening your signed-in (work-account) profile.") + print("IMPORTANT: wait for the M365 Copilot chat to fully load, then TYPE A") + print("MESSAGE (e.g. 'hello') AND SEND IT. Wait for the full reply to finish") + print("streaming — the reply arrives over a Trouter channel and is the main") + print("thing we need to capture. Only THEN close the window.") + print(f"Frames + requests stream to {CAPTURE_LOG}") + print("Auto-stops after 5 min.") + print("=" * 70 + "\n") + + try: + from playwright.sync_api import sync_playwright + except Exception as e: # noqa: BLE001 + print(f"Playwright unavailable: {e}") + return + + with sync_playwright() as pw: + ctx = pw.chromium.launch_persistent_context( + str(PROFILE_DIR.resolve()), + headless=False, + args=["--disable-blink-features=AutomationControlled"], + ) + page = ctx.pages[0] if ctx.pages else ctx.new_page() + + # --- WebSocket transport ------------------------------------------ + # Log EVERY socket, not just "interesting" hosts: Trouter/Sydney reply + # channels live on hosts (trouter.teams.microsoft.com) the request filter + # would otherwise drop, and the reply frames are the whole point. + def on_ws(ws) -> None: + log("[WS-OPEN]", ws.url) + ws.on("framesent", lambda *a: log("[WS-SENT]", a[0] if a else b"")) + ws.on("framereceived", lambda *a: log("[WS-RECV]", a[0] if a else b"")) + ws.on("close", lambda *a: log("[WS-CLOSE]", ws.url)) + + # --- HTTP transport (SSE / fetch streaming / plain POST) ---------- + def on_request(req) -> None: + # Log every non-GET (POST/PUT/PATCH) regardless of host — the outbound + # message send goes to a Teams messaging host that the interesting-host + # filter misses, and it's the one request we still need. GETs stay + # filtered to avoid drowning in static-asset noise. + if req.method == "GET" and not _interesting(req.url): + return + hdrs = {} + try: + hdrs = req.headers + except Exception: + pass + auth = hdrs.get("authorization", "") + claims = _jwt_claims(auth) if auth else "" + log("[REQ]", f"{req.method} {req.url}") + if auth: + log(" auth", f"present; claims={claims}") + for h in ("content-type", "x-anchormailbox", "x-routingparameter-sessionkey", + "x-scenario", "x-clientcorrelationid", "correlationvector"): + if h in hdrs: + log(" hdr", f"{h}: {hdrs[h]}") + body = None + try: + body = req.post_data + except Exception: + pass + if body: + log(" body", body[:4000]) + + def on_response(resp) -> None: + if not _interesting(resp.url): + return + ct = "" + try: + ct = resp.headers.get("content-type", "") + except Exception: + pass + log("[RESP]", f"{resp.status} {resp.url} ct={ct}") + + page.on("websocket", on_ws) + page.on("request", on_request) + page.on("response", on_response) + + try: + page.goto(START_URL, wait_until="domcontentloaded") + page.wait_for_timeout(4000) + # If the redirect didn't land us on the chat, nudge it there. + if "m365.cloud.microsoft" not in (page.url or ""): + try: + page.goto(M365_CHAT_URL, wait_until="domcontentloaded") + except Exception: + pass + except Exception: + pass + + # Snapshot the token scopes present (scope-only, no secrets). + try: + toks = page.evaluate(_LIST_TOKENS_JS) + log("[TOKENS]", json.dumps(toks, ensure_ascii=False)) + except Exception: + pass + + ticks = _CAPTURE_TICKS + try: + while not page.is_closed() and ticks > 0: + page.wait_for_timeout(500) + ticks -= 1 + except Exception: + pass + + # Re-snapshot tokens at the end: the chat token may only mint on the turn. + try: + if not page.is_closed(): + toks = page.evaluate(_LIST_TOKENS_JS) + log("[TOKENS-END]", json.dumps(toks, ensure_ascii=False)) + except Exception: + pass + + sink.close() + try: + ctx.close() + except Exception: + pass + + print("\n" + "=" * 62) + print(f"Capture written to {CAPTURE_LOG}") + print("Secrets are redacted. Skim it, then share it so the m365 driver can be") + print("designed from the real protocol (transport, endpoints, token scope).") + + +if __name__ == "__main__": + main() diff --git a/tests/capture_registrar.py b/tests/capture_registrar.py new file mode 100644 index 0000000..2dcebca --- /dev/null +++ b/tests/capture_registrar.py @@ -0,0 +1,106 @@ +"""Capture the Trouter registrar POST body (Copilot Cowork). + +The enterprise reply is pushed over Trouter only after the client registers its +Trouter endpoint with the messaging backend (``…/registrar/…/registrations``). +That request is issued from a Web Worker, so the page-level request listener in +``capture_m365.py`` never saw its body. This uses a **context-level route**, which +does intercept worker requests, to dump the registrar request (method, url, +headers, and full JSON body — the piece needed to build the registration) to +``session/registrar_capture.log``. + +Run signed in with the WORK account; send one message so the app registers: + + python tests/capture_registrar.py + +SAFE TO SHARE: bearer tokens / JWTs / emails are redacted; the registration body +shape (transports, ttl, clientDescription) is what we keep. Skim before posting. +""" + +import re +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +SESSION_DIR = Path("session") +PROFILE_DIR = SESSION_DIR / "profile" +OUT = SESSION_DIR / "registrar_capture.log" +M365_CHAT_URL = "https://m365.cloud.microsoft/chat/" + +_REDACTORS = [ + (re.compile(r"(Bearer)\s+[A-Za-z0-9._\-]+", re.I), r"\1 "), + (re.compile(r"eyJ[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+(?:\.[A-Za-z0-9_\-]+)?"), ""), + (re.compile(r"[\w.+-]+@[\w-]+\.[\w.-]+"), ""), +] + + +def redact(text: str) -> str: + for pat, repl in _REDACTORS: + text = pat.sub(repl, text) + return text + + +def main() -> None: + SESSION_DIR.mkdir(parents=True, exist_ok=True) + fh = OUT.open("w", encoding="utf-8") + + print("\n" + "=" * 70) + print("Sign in (work account), wait for the chat, then TYPE A MESSAGE and send") + print("it. The app registers its Trouter endpoint on load/first turn; that") + print("registration body is captured. Close the window when the reply arrives.") + print("=" * 70 + "\n") + + from playwright.sync_api import sync_playwright + + with sync_playwright() as pw: + ctx = pw.chromium.launch_persistent_context( + str(PROFILE_DIR.resolve()), headless=False, + args=["--disable-blink-features=AutomationControlled"], + ) + + def on_route(route): + req = route.request + try: + if "registrar" in req.url or req.url.rstrip("/").endswith("/registrations"): + body = req.post_data or "(no body exposed)" + hdrs = {k: v for k, v in (req.headers or {}).items() + if k.lower() in ("content-type", "authorization", "x-skypetoken", "mstrouterservicedata")} + entry = (f"### {req.method} {req.url}\n" + f"# headers: {redact(str(hdrs))}\n" + f"# body:\n{redact(body)}\n\n") + fh.write(entry); fh.flush() + print(f"captured registrar {req.method} ({len(body)} body bytes)") + except Exception as e: # noqa: BLE001 + fh.write(f"# route error: {e}\n") + try: + route.continue_() + except Exception: + pass + + # Context-level route sees Web Worker requests (where the registrar call lives). + ctx.route("**/*", on_route) + page = ctx.pages[0] if ctx.pages else ctx.new_page() + try: + page.goto(M365_CHAT_URL, wait_until="domcontentloaded") + except Exception: + pass + + ticks = 600 # 5 min ceiling + try: + while not page.is_closed() and ticks > 0: + page.wait_for_timeout(500) + ticks -= 1 + except Exception: + pass + try: + ctx.close() + except Exception: + pass + + fh.close() + print(f"\nRegistrar capture -> {OUT}") + + +if __name__ == "__main__": + main() diff --git a/tests/test_m365_auth.py b/tests/test_m365_auth.py new file mode 100644 index 0000000..30464dc --- /dev/null +++ b/tests/test_m365_auth.py @@ -0,0 +1,59 @@ +"""Unit tests for the enterprise refresh-token grant construction. + +The request body is asserted against the exact shape captured from the real +m365.cloud.microsoft client (``tests/capture_m365.py``), so a future refactor +can't silently drop a field ESTS requires (the ``brk_*`` broker params, the +``.default`` scope, the anchor mailbox). +""" + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from copilot.m365 import auth + + +def test_refresh_body_matches_captured_shape(): + body = auth.build_refresh_body( + refresh_token="1.RT.VALUE", + scope=auth.SCOPES["cowork"], + oid="00000000-0000-0000-0000-0000000000oid", + tenant="00000000-0000-0000-0000-00000000tenant", + ) + assert body["client_id"] == auth.CLIENT_ID + assert body["grant_type"] == "refresh_token" + assert body["refresh_token"] == "1.RT.VALUE" + assert body["redirect_uri"] == "brk-multihub://outlook.office.com" + assert body["brk_client_id"] == auth.BROKER_CLIENT_ID + assert body["brk_redirect_uri"] == "https://m365.cloud.microsoft/spalanding" + # scope keeps the captured resource + the offline_access that rotates the RT. + assert body["scope"].startswith("6ab48b67-cd74-4ad4-81af-5932984589be/.default") + assert "offline_access" in body["scope"] + assert body["X-AnchorMailbox"] == "Oid:00000000-0000-0000-0000-0000000000oid@00000000-0000-0000-0000-00000000tenant" + + +def test_cowork_scope_is_the_send_resource(): + # The chat send (/v1/messages) rides the Cowork agent's own resource token. + assert auth.SCOPES[auth.SCOPE_COWORK] == f"{auth.COWORK_RESOURCE}/.default" + + +def test_token_endpoint_is_tenant_scoped(): + ep = auth.token_endpoint("TENANT") + assert ep == "https://login.microsoftonline.com/TENANT/oauth2/v2.0/token" + + +def test_load_without_refresh_token_raises(tmp_path): + p = tmp_path / "m365_token.json" + p.write_text('{"tenant":"t","oid":"o"}', encoding="utf-8") + try: + auth.M365Auth.load(str(p)) + assert False, "expected RuntimeError" + except RuntimeError as e: + assert "refresh token" in str(e).lower() + + +def test_all_scopes_are_default_grants(): + # Every known scope is a public-client /.default string. + for name, scope in auth.SCOPES.items(): + assert scope.endswith("/.default"), f"{name} -> {scope}" diff --git a/tests/test_m365_driver.py b/tests/test_m365_driver.py new file mode 100644 index 0000000..6449f95 --- /dev/null +++ b/tests/test_m365_driver.py @@ -0,0 +1,92 @@ +"""Unit tests for M365Copilot.ask() retry/timeout behavior (no network). + +The wedge this pins down: a Trouter endpoint registration going stale makes +every turn time out with *zero* deliveries. ask() must retry exactly once on a +fresh socket in that case, must NOT retry when frames did arrive (the route +works; the turn just never completed), and must surface a clear error either +way. +""" + +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from copilot.m365 import driver +from copilot.m365.driver import M365Copilot + + +class FakeAuth: + tenant = "tenant-guid" + oid = "oid-guid" + + def token(self, scope): + return f"token-{scope}" + + +class FakeReceiver: + """Scripted TrouterReceiver: each instance pops the next (reply, deliveries).""" + + script = [] # class-level: [(reply, deliveries), ...] + instances = [] + + def __init__(self, ic3_token, proxy=None, verbose=False): + FakeReceiver.instances.append(self) + self.reply, self._deliveries = FakeReceiver.script.pop(0) + self.sends = 0 + + def receive(self, on_connected, timeout): + on_connected({}) + self.sends += 1 + return self.reply + + +@pytest.fixture +def bot(monkeypatch): + monkeypatch.setattr(driver, "TrouterReceiver", FakeReceiver) + FakeReceiver.instances = [] + b = M365Copilot(auth=FakeAuth(), timeout=1) + # ask() fires the real HTTP send in on_connected; stub it out. + monkeypatch.setattr(b, "_send", lambda *a, **k: None) + return b + + +def test_ask_returns_reply_first_attempt(bot): + FakeReceiver.script = [("hello back", 1)] + assert bot.ask("hi") == "hello back" + assert len(FakeReceiver.instances) == 1 + + +def test_ask_retries_once_on_zero_deliveries(bot): + # First socket sees nothing (stale registration); second works. + FakeReceiver.script = [("", 0), ("recovered", 1)] + assert bot.ask("hi") == "recovered" + assert len(FakeReceiver.instances) == 2 + + +def test_ask_gives_up_after_two_silent_attempts(bot): + FakeReceiver.script = [("", 0), ("", 0)] + with pytest.raises(RuntimeError, match="deliveries=0, attempts=2"): + bot.ask("hi") + assert len(FakeReceiver.instances) == 2 + + +def test_ask_does_not_retry_when_frames_arrived(bot): + # Deliveries came in but the turn never completed: a duplicate send won't + # finish it faster, so exactly one attempt. + FakeReceiver.script = [("", 3)] + with pytest.raises(RuntimeError, match="deliveries=3, attempts=1"): + bot.ask("hi") + assert len(FakeReceiver.instances) == 1 + + +def test_timeout_env_override(monkeypatch): + monkeypatch.setenv("M365_TIMEOUT", "42") + assert M365Copilot(auth=FakeAuth()).timeout == 42 + + +def test_timeout_default(monkeypatch): + monkeypatch.delenv("M365_TIMEOUT", raising=False) + assert M365Copilot(auth=FakeAuth()).timeout == 180 diff --git a/tests/test_m365_protocol.py b/tests/test_m365_protocol.py new file mode 100644 index 0000000..7af401a --- /dev/null +++ b/tests/test_m365_protocol.py @@ -0,0 +1,127 @@ +"""Unit tests for the enterprise (Copilot Cowork) Trouter codec + reply parser. + +Frames here are the *real* ones captured off m365.cloud.microsoft with +``tests/capture_m365.py`` (thread ids reduced to placeholders). They pin the +Socket.IO 0.9 framing and the ``Copilot_AgentResponse`` shape so the driver can +be built and refactored against a fixed contract without a live account. +""" + +import json +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from copilot.m365 import protocol, trouter + + +# --- Socket.IO framing (captured client/server frames) ---------------------- + +def test_decode_connect_ack(): + p = trouter.decode("1::") + assert (p.type, p.id, p.endpoint, p.data) == (trouter.TYPE_CONNECT, "", "", "") + + +def test_decode_event_with_json_data_keeps_colons(): + # URL/JSON in data contains colons; only the first three ':' are separators. + frame = '3:::{"id":42,"url":"https://x/y","status":200}' + p = trouter.decode(frame) + assert p.type == trouter.TYPE_MESSAGE + assert p.json()["url"] == "https://x/y" + + +def test_encode_roundtrip_event(): + frame = trouter.event("ping", [], msg_id="1+") + p = trouter.decode(frame) + assert p.type == trouter.TYPE_EVENT and p.id == "1+" and p.ack_requested + assert p.json() == {"name": "ping", "args": []} + + +def test_authenticate_frame_shape(): + p = trouter.decode(trouter.authenticate_frame("TOK")) + args = p.json()["args"][0] + assert args["headers"]["Authorization"] == "Bearer TOK" + assert args["headers"]["X-MS-Migration"] == "True" + + +def test_is_pong_and_is_delivery(): + assert trouter.is_pong(trouter.decode('6:::1+["pong"]')) + assert not trouter.is_pong(trouter.decode("1::")) + # A server delivery has method (no status); our own ACK has status (no method). + delivery = '3:::{"id":1,"method":"POST","url":"/m","headers":{},"body":"{}"}' + ack = '3:::{"id":1,"status":200,"headers":{},"body":""}' + assert trouter.is_delivery(trouter.decode(delivery)) + assert not trouter.is_delivery(trouter.decode(ack)) + + +# --- Cowork reply parsing (the captured agent response) --------------------- + +def _agent_delivery(content, task_state="completed", segments=None): + """Wrap a resource in the delivery shape Trouter tunnels (body is a string).""" + props = { + "copilotTaskState": task_state, + "copilotConversationId": "00000000-0000-0000-0000-000000000conv", + } + if segments is not None: + props["copilotTextSegments"] = [{"id": f"t{i}", "text": t} for i, t in enumerate(segments)] + body = { + "type": "EventMessage", + "resourceType": "NewMessage", + "resource": { + "id": "1783418253839", + "content": content, + "messagetype": protocol.AGENT_RESPONSE_TYPE, + "properties": props, + }, + } + return {"id": 1183867798, "method": "POST", "url": "/v4/f/x/messaging", + "headers": {"MS-CV": "abc", "trouter-request": "{}"}, "body": json.dumps(body)} + + +def test_parse_real_agent_response(): + content = ("Hi there! I'm here to help you with your email, calendar, Teams, " + "files, and documents — or anything else you're working on in " + "Microsoft 365.\n\nWhat can I do for you today?") + ev = protocol.parse_delivery(_agent_delivery( + content, segments=["Hi", " there! …", " What can I do for you today?"])) + assert ev is not None + assert ev.text == content # full content preferred over segments + assert ev.completed is True + assert ev.conversation_id == "00000000-0000-0000-0000-000000000conv" + assert len(ev.segments) == 3 + + +def test_text_falls_back_to_segments_when_no_content(): + ev = protocol.parse_delivery(_agent_delivery("", segments=["Hel", "lo"])) + assert ev.text == "Hello" + + +def test_incomplete_turn_not_completed(): + ev = protocol.parse_delivery(_agent_delivery("thinking…", task_state="running")) + assert ev is not None and ev.completed is False + + +def test_user_echo_is_ignored(): + body = {"type": "EventMessage", "resourceType": "NewMessage", + "resource": {"content": "Hello", "messagetype": "Text", + "from": "…/8:orgid:00000000", "properties": {}}} + delivery = {"id": 451502264, "method": "POST", "url": "/m", "headers": {}, "body": json.dumps(body)} + assert protocol.parse_delivery(delivery) is None + + +def test_thread_update_is_ignored(): + body = {"type": "EventMessage", "resourceType": "ThreadUpdate", + "resource": {"id": "19:x@thread", "type": "Thread", "properties": {"productThreadType": "cowork"}}} + delivery = {"id": 498345833, "method": "POST", "url": "/m", "headers": {}, "body": json.dumps(body)} + assert protocol.parse_delivery(delivery) is None + + +def test_build_ack_echoes_id_and_status(): + delivery = {"id": 995027863, "method": "POST", "url": "/m", + "headers": {"MS-CV": "cv123", "trouter-request": "{\"id\":\"r\"}"}, "body": "{}"} + ack = protocol.build_ack(delivery) + p = trouter.decode(ack) + obj = p.json() + assert obj["id"] == 995027863 and obj["status"] == 200 + assert obj["headers"]["MS-CV"] == "cv123" + assert obj["body"] == "" diff --git a/tests/test_tool_calls.py b/tests/test_tool_calls.py new file mode 100644 index 0000000..3d29f3d --- /dev/null +++ b/tests/test_tool_calls.py @@ -0,0 +1,322 @@ +import unittest +from types import SimpleNamespace +from unittest.mock import patch + +from fastapi.testclient import TestClient + +from server.api import app +from server.openai_format import completion_response +from server.prompt import TRUNCATION_NOTICE, messages_to_prompt +from server.schemas import ChatMessage +from server.tool_calls import ( + build_tool_instructions, + fallback_local_tool_calls, + parse_tool_calls, + tool_choice_allows_tools, +) + + +TOOLS = [ + { + "type": "function", + "function": { + "name": "Read", + "description": "Read a local file.", + "parameters": { + "type": "object", + "properties": {"file_path": {"type": "string"}}, + "required": ["file_path"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "Bash", + "description": "Run a shell command.", + "parameters": { + "type": "object", + "properties": {"command": {"type": "string"}}, + "required": ["command"], + }, + }, + }, +] + + +class ToolCallTests(unittest.TestCase): + def test_parse_raw_tool_call_block(self): + calls = parse_tool_calls( + 'Tool calls requested:\n- Read({"file_path":"server/api.py"}) [id: call_read_api]', + TOOLS, + ) + + self.assertEqual(len(calls), 1) + self.assertEqual(calls[0]["id"], "call_read_api") + self.assertEqual(calls[0]["function"]["name"], "Read") + self.assertEqual(calls[0]["function"]["arguments"], '{"file_path":"server/api.py"}') + + def test_parse_json_tool_call(self): + calls = parse_tool_calls( + '{"name":"Bash","arguments":{"command":"rg --files"}}', + TOOLS, + ) + + self.assertEqual(len(calls), 1) + self.assertEqual(calls[0]["function"]["name"], "Bash") + self.assertEqual(calls[0]["function"]["arguments"], '{"command":"rg --files"}') + + def test_parse_fenced_json_tool_call_with_nested_arguments(self): + calls = parse_tool_calls( + '```json\n{"name":"Read","arguments":{"file_path":"server/prompt.py"}}\n```', + TOOLS, + ) + + self.assertEqual(len(calls), 1) + self.assertEqual(calls[0]["function"]["name"], "Read") + self.assertEqual(calls[0]["function"]["arguments"], '{"file_path":"server/prompt.py"}') + + def test_ignores_unknown_tools(self): + calls = parse_tool_calls( + 'Tool calls requested:\n- Unknown({"x":1}) [id: call_unknown]', + TOOLS, + ) + + self.assertEqual(calls, []) + + def test_completion_response_can_return_tool_calls(self): + tool_calls = parse_tool_calls( + 'Tool calls requested:\n- Read({"file_path":"README.md"}) [id: call_readme]', + TOOLS, + ) + response = completion_response("ignored", "copilot", "conv", tool_calls=tool_calls) + choice = response["choices"][0] + + self.assertIsNone(choice["message"]["content"]) + self.assertEqual(choice["finish_reason"], "tool_calls") + self.assertEqual(choice["message"]["tool_calls"][0]["function"]["name"], "Read") + + def test_prompt_includes_tool_instructions_and_results(self): + prompt = messages_to_prompt( + [ + ChatMessage(role="system", content="You are a coding agent."), + ChatMessage(role="user", content="Inspect the API."), + ChatMessage( + role="assistant", + content=None, + tool_calls=[ + { + "id": "call_read_api", + "type": "function", + "function": { + "name": "Read", + "arguments": '{"file_path":"server/api.py"}', + }, + } + ], + ), + ChatMessage(role="tool", tool_call_id="call_read_api", content="def chat_completions(...):"), + ], + TOOLS, + ) + + self.assertIn("Local tool use is available", prompt) + self.assertIn("Assistant tool calls: Read", prompt) + self.assertIn("Tool result (call_read_api): def chat_completions", prompt) + + def test_prompt_untouched_when_under_max_chars(self): + messages = [ + ChatMessage(role="system", content="You are a coding agent."), + ChatMessage(role="user", content="hello"), + ] + unlimited = messages_to_prompt(messages) + capped = messages_to_prompt(messages, max_chars=10_000) + + self.assertEqual(unlimited, capped) + self.assertNotIn(TRUNCATION_NOTICE, capped) + + def test_prompt_truncation_keeps_system_and_newest_turns(self): + messages = [ChatMessage(role="system", content="SYSTEM RULES")] + for i in range(40): + messages.append(ChatMessage(role="user", content=f"question {i}: " + "x" * 500)) + messages.append(ChatMessage(role="assistant", content=f"answer {i}: " + "y" * 500)) + messages.append(ChatMessage(role="user", content="newest question")) + + prompt = messages_to_prompt(messages, max_chars=6_000) + + self.assertLessEqual(len(prompt), 6_000) + self.assertIn("SYSTEM RULES", prompt) + self.assertIn("newest question", prompt) + self.assertIn(TRUNCATION_NOTICE, prompt) + self.assertNotIn("question 0:", prompt) + self.assertTrue(prompt.endswith("Assistant:")) + + def test_prompt_truncation_middle_cuts_huge_tool_result(self): + messages = [ + ChatMessage(role="user", content="read the big file"), + ChatMessage(role="tool", tool_call_id="call_big", content="A" * 50_000), + ChatMessage(role="user", content="now summarize it"), + ] + + prompt = messages_to_prompt(messages, max_chars=8_000) + + self.assertLessEqual(len(prompt), 8_000) + self.assertIn("...[truncated]...", prompt) + self.assertIn("now summarize it", prompt) + + def test_tool_choice_none_disables_tools(self): + self.assertFalse(tool_choice_allows_tools("none")) + self.assertFalse(tool_choice_allows_tools({"type": "none"})) + self.assertTrue(tool_choice_allows_tools({"type": "auto"})) + + def test_tool_instructions_are_empty_without_tools(self): + self.assertEqual(build_tool_instructions([]), "") + + def test_fallback_for_list_files_uses_glob(self): + calls = fallback_local_tool_calls( + [ChatMessage(role="user", content="list the files")], + TOOLS + [{ + "type": "function", + "function": { + "name": "Glob", + "description": "Find files by glob pattern.", + "parameters": {"type": "object", "properties": {"pattern": {"type": "string"}}}, + }, + }], + "Workspace files:\nRoot (`/mnt/workspace`)\n- input/ empty", + ) + + self.assertEqual(len(calls), 1) + self.assertEqual(calls[0]["function"]["name"], "Glob") + self.assertEqual(calls[0]["function"]["arguments"], '{"pattern":"**/*"}') + + def test_fallback_for_list_my_files_phrasing(self): + glob_tool = [{ + "type": "function", + "function": { + "name": "Glob", + "description": "Find files by glob pattern.", + "parameters": {"type": "object", "properties": {"pattern": {"type": "string"}}}, + }, + }] + calls = fallback_local_tool_calls( + [ChatMessage(role="user", content="List my files.")], + TOOLS + glob_tool, + "", + ) + + self.assertEqual(len(calls), 1) + self.assertEqual(calls[0]["function"]["name"], "Glob") + + def test_fallback_detects_sandbox_persona_answer(self): + glob_tool = [{ + "type": "function", + "function": { + "name": "Glob", + "description": "Find files by glob pattern.", + "parameters": {"type": "object", "properties": {"pattern": {"type": "string"}}}, + }, + }] + calls = fallback_local_tool_calls( + [ChatMessage(role="user", content="Anything in the workspace?")], + TOOLS + glob_tool, + "No files. input/, output/, working/ all empty. Nothing uploaded yet.", + ) + + self.assertEqual(len(calls), 1) + self.assertEqual(calls[0]["function"]["name"], "Glob") + + def test_tool_instructions_override_sandbox_persona(self): + instructions = build_tool_instructions(TOOLS) + + self.assertIn("real local machine", instructions) + self.assertIn("upload sandbox", instructions) + + def test_fallback_does_not_repeat_after_tool_result(self): + calls = fallback_local_tool_calls( + [ + ChatMessage(role="user", content="list the files"), + ChatMessage(role="tool", tool_call_id="call_glob", content="README.md"), + ], + TOOLS, + "", + ) + + self.assertEqual(calls, []) + + def test_chat_completion_route_returns_structured_tool_call(self): + client = TestClient(app) + upstream = SimpleNamespace( + text='Tool calls requested:\n- Read({"file_path":"server/api.py"}) [id: call_route_read]', + conversation_id="conv_route", + ) + + with patch("server.api.client.chat", return_value=upstream): + response = client.post("/v1/chat/completions", json={ + "model": "copilot", + "messages": [{"role": "user", "content": "Read server/api.py"}], + "tools": TOOLS, + }) + + self.assertEqual(response.status_code, 200) + choice = response.json()["choices"][0] + self.assertEqual(choice["finish_reason"], "tool_calls") + self.assertEqual(choice["message"]["tool_calls"][0]["function"]["name"], "Read") + + def test_chat_completion_route_falls_back_to_local_tool_call(self): + client = TestClient(app) + upstream = SimpleNamespace( + text="Workspace files:\nRoot (`/mnt/workspace`)\n- input/ empty", + conversation_id="conv_route", + ) + + with patch("server.api.client.chat", return_value=upstream): + response = client.post("/v1/chat/completions", json={ + "model": "copilot", + "messages": [{"role": "user", "content": "list the files"}], + "tools": TOOLS + [{ + "type": "function", + "function": { + "name": "Glob", + "description": "Find files by glob pattern.", + "parameters": {"type": "object", "properties": {"pattern": {"type": "string"}}}, + }, + }], + }) + + self.assertEqual(response.status_code, 200) + choice = response.json()["choices"][0] + self.assertEqual(choice["finish_reason"], "tool_calls") + self.assertEqual(choice["message"]["tool_calls"][0]["function"]["name"], "Glob") + + def test_streaming_route_falls_back_to_local_tool_call(self): + class FakeStream: + conversation_id = "conv_stream" + + def __iter__(self): + yield "Workspace files:\nRoot (`/mnt/workspace`)\n- input/ empty" + + client = TestClient(app) + with patch("server.api.client.stream", return_value=FakeStream()): + with client.stream("POST", "/v1/chat/completions", json={ + "model": "copilot", + "stream": True, + "messages": [{"role": "user", "content": "list the files"}], + "tools": TOOLS + [{ + "type": "function", + "function": { + "name": "Glob", + "description": "Find files by glob pattern.", + "parameters": {"type": "object", "properties": {"pattern": {"type": "string"}}}, + }, + }], + }) as response: + body = response.read().decode("utf-8") + + self.assertEqual(response.status_code, 200) + self.assertIn('"finish_reason": "tool_calls"', body) + self.assertIn('"name": "Glob"', body) + + +if __name__ == "__main__": + unittest.main() diff --git a/work-chat.ps1 b/work-chat.ps1 new file mode 100644 index 0000000..28e8e0a --- /dev/null +++ b/work-chat.ps1 @@ -0,0 +1,66 @@ +# work-chat.ps1 - launch OpenClaude as a local coding agent through this bridge. +# +# .\work-chat.ps1 # uses copilot-work on http://127.0.0.1:8000 +# .\work-chat.ps1 -Model copilot # personal-account model instead +# .\work-chat.ps1 -Workspace D:\SpiralPulse +# +# Start the bridge first in another terminal: python app.py +# +# This intentionally leaves OpenClaude's local tools enabled. The bridge turns +# Copilot's text tool requests into OpenAI tool_calls, and OpenClaude executes +# Read/Glob/Grep/Bash/Edit/Write on this machine. + +param( + [string]$Model = "copilot-work", + [string]$BaseUrl = "http://127.0.0.1:8000/v1", + [string]$Port = "8000", + [string]$Workspace = $PSScriptRoot +) + +try { + $WorkspacePath = (Resolve-Path -LiteralPath $Workspace -ErrorAction Stop).Path +} catch { + Write-Host "Workspace not found: $Workspace" -ForegroundColor Red + exit 1 +} + +# Point OpenClaude at the local OpenAI-compatible bridge. +$env:CLAUDE_CODE_USE_OPENAI = "1" +$env:OPENAI_BASE_URL = $BaseUrl +$env:OPENAI_API_KEY = "local" +$env:OPENAI_MODEL = $Model + +# Cowork turns take 30-120s and requests queue behind the bridge's serialized +# upstream lock; OpenClaude's default request timeout (~90s) fires mid-turn and +# triggers retry storms. Give requests 10 minutes. +$env:API_TIMEOUT_MS = "600000" + +# Reduce non-essential background model calls; the Copilot backend is serialized. +$env:CLAUDE_CODE_DISABLE_AUTO_MEMORY = "1" +$env:DISABLE_AUTO_COMPACT = "1" +$env:CLAUDE_CODE_DISABLE_BACKGROUND_TASKS = "1" + +# Quick reachability check so failures are clear instead of ECONNREFUSED loops. +try { + Invoke-WebRequest -UseBasicParsing -TimeoutSec 3 "http://127.0.0.1:$Port/v1/models" | Out-Null +} catch { + Write-Host "Bridge not reachable at $BaseUrl - start it first: python app.py" -ForegroundColor Yellow +} + +$agentPrompt = @' +You are running in OpenClaude with local coding tools available. When workspace +facts, source code, reports, or filesystem state matter, use the available local +tools instead of guessing. Do not claim you inspected files, directories, or +command output unless a tool result in this conversation shows it. +'@ + +# Permission bypass keeps coding-agent runs non-interactive. Remove this flag if +# you want OpenClaude to prompt before sensitive local actions. +Write-Host "Launching OpenClaude in workspace: $WorkspacePath" -ForegroundColor Cyan +Push-Location -LiteralPath $WorkspacePath +try { + $env:PWD = $WorkspacePath + openclaude --add-dir $WorkspacePath --append-system-prompt $agentPrompt --dangerously-skip-permissions +} finally { + Pop-Location +} From 53d19acbf8e2d172f56d78c9b3bcb4da4f15dcd6 Mon Sep 17 00:00:00 2001 From: Rithmatist Date: Tue, 7 Jul 2026 21:54:40 +0200 Subject: [PATCH 2/2] =?UTF-8?q?docs:=20README=20=E2=80=94=20work-account?= =?UTF-8?q?=20(copilot-work)=20+=20openclaude=20setup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- README.md | 46 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 9d3fcfb..a2eb360 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ You sign in once in a browser with your Microsoft **or Google** account; your se - [Run with Docker (optional)](#run-with-docker-optional) - [Usage 1: In Python (no server)](#usage-1-in-python-no-server) - [Usage 2: As an OpenAI-compatible server](#usage-2-as-an-openai-compatible-server) +- [Usage 3: Work / school account + coding agents](#usage-3-work--school-account-microsoft-365-copilot--coding-agents) - [Command line](#command-line) - [Concurrency & stress test](#concurrency--stress-test) - [Rate limiting](#rate-limiting) @@ -183,7 +184,7 @@ curl http://localhost:8000/v1/chat/completions \ | Method | Path | Description | | --- | --- | --- | | `POST` | `/v1/chat/completions` | Chat (supports `"stream": true` and an optional `"conversation_id"`) | -| `GET` | `/v1/models` | Lists the single `copilot` model | +| `GET` | `/v1/models` | Lists the `copilot` (personal) and `copilot-work` (Microsoft 365) models | > Change the address with env vars: `HOST=0.0.0.0 PORT=8080 python app.py`, or run `uvicorn server.api:app --host 0.0.0.0 --port 8080`. @@ -191,6 +192,49 @@ curl http://localhost:8000/v1/chat/completions \ --- +## Usage 3: Work / school account (Microsoft 365 Copilot) + coding agents + +Personal Microsoft accounts use consumer Copilot (the `copilot` model above). A **work or school (Entra ID) account** is served by the enterprise **Microsoft 365 Copilot** — a different backend ("Cowork", running on Claude Fable 5) that this project speaks through the **`copilot-work`** model. Everything is headless after a one-time sign-in. + +**1. Bootstrap once** — interactive sign-in that captures a refresh token; every call after this is headless: + +```powershell +python -m copilot.m365.bootstrap +# a browser opens — sign in with your WORK account, wait for the chat to load, it closes itself +``` + +**2. Start the server:** + +```powershell +python app.py +# -> Copilot OpenAI-compatible API on http://127.0.0.1:8000 +``` + +**3a. Run it as a coding agent** with [OpenClaude](https://github.com/Gitlawb/openclaude) — the bundled launcher wires everything up and opens OpenClaude in your workspace with local tools enabled: + +```powershell +.\work-chat.ps1 -Workspace C:\your\work\directory +``` + +**3b. Or point any OpenAI-compatible agent at it manually:** + +```powershell +$env:CLAUDE_CODE_USE_OPENAI=1 +$env:OPENAI_BASE_URL="http://127.0.0.1:8000/v1" +$env:OPENAI_API_KEY="local" +$env:OPENAI_MODEL="copilot-work" +$env:CLAUDE_CODE_DISABLE_AUTO_MEMORY=1 +$env:CLAUDE_CODE_DISABLE_BACKGROUND_TASKS=1 +$env:API_TIMEOUT_MS="600000" +openclaude --dangerously-skip-permissions +``` + +Tokens refresh automatically; re-run the bootstrap only if the refresh token dies (long inactivity, password change, or an admin revocation). + +> **Notes.** Work turns are slow — ~20–60s each (the Cowork backend, not the bridge), and the server serializes upstream calls, so give agent clients a long request timeout (`API_TIMEOUT_MS`). The title-generation meta-call is answered locally to avoid wasting a turn. Requires an M365 Copilot–licensed tenant that permits token-based access; some tenants block non-interactive tokens via conditional access. + +--- + ## Command line ```bash