diff --git a/changelog.d/fixes/10732-copilot-m365-invocation-refresh.md b/changelog.d/fixes/10732-copilot-m365-invocation-refresh.md new file mode 100644 index 0000000000..acbfbbe693 --- /dev/null +++ b/changelog.d/fixes/10732-copilot-m365-invocation-refresh.md @@ -0,0 +1 @@ +- **fix(providers):** copilot-m365-web chat turns no longer surface as `(empty response)` — the type:4 invocation is aligned with the 2026-08 wire shape and now carries its type:1 Metrics follow-up in the same socket write, and the access token pre-flight-refreshes from a stored refresh_token instead of requiring a DevTools re-capture every ~75 minutes ([#10732](https://github.com/diegosouzapw/OmniRoute/pull/10732) — thanks @acc0mplish) diff --git a/docs/reference/PROVIDER_REFERENCE.md b/docs/reference/PROVIDER_REFERENCE.md index d6dcf8abb2..64dc00960d 100644 --- a/docs/reference/PROVIDER_REFERENCE.md +++ b/docs/reference/PROVIDER_REFERENCE.md @@ -91,7 +91,7 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each | `chatgpt-web-codex` | `cgpt-codex` | ChatGPT Web (Codex) | Web cookie | [link](https://chatgpt.com) | Paste the full ChatGPT Cookie header. OmniRoute verifies it in an isolated headless browser profile. | native | | `claude-web` | `cw` | Claude Web | Web cookie | [link](https://claude.ai) | Paste your session cookie from claude.ai | none | | `conol-web` | `cnl` | Conol (Unofficial/Experimental) | Web cookie | [link](https://conol.ai) | Use browser sign-in, or paste the full Cookie header from conol.ai. The __Secure-better-auth.session_token cookie is required. | — | -| `copilot-m365-web` | `m365copilot` | Microsoft 365 Copilot (BizChat) | Web cookie | [link](https://m365.cloud.microsoft/chat) | Sign in at m365.cloud.microsoft/chat, then open DevTools → Network → filter 'WS' → click the Chathub WebSocket connection. Copy both the access_token query parameter AND the account-specific Chathub path segment from its request URL (wss://…/Chathub/?…&access_token=…). It is NOT an Authorization: Bearer header on an XHR/Fetch request. The token is short-lived; this is an unofficial integration. | — | +| `copilot-m365-web` | `m365copilot` | Microsoft 365 Copilot (BizChat) | Web cookie | [link](https://m365.cloud.microsoft/chat) | Sign in at m365.cloud.microsoft/chat, then open DevTools → Network → filter 'WS' → click the Chathub WebSocket connection. Copy both the access_token query parameter AND the account-specific Chathub path segment from its request URL (wss://…/Chathub/?…&access_token=…). It is NOT an Authorization: Bearer header on an XHR/Fetch request. The token is short-lived; this is an unofficial integration. Optional: store a refresh_token in providerSpecificData.refreshToken (any Microsoft device-code/refresh flow for the substrate.office.com/sydney scopes) and OmniRoute pre-flight-refreshes the access token itself — otherwise re-capture after every ~75 min expiry. | — | | `copilot-web` | `copilot` | Microsoft Copilot Web | Web cookie | [link](https://copilot.microsoft.com) | Paste the access_token from an authenticated copilot.microsoft.com request (DevTools → Network → Authorization), or export a HAR while logged in | — | | `deepseek-web` | `ds-web` | DeepSeek Web | Web cookie | [link](https://chat.deepseek.com) | Paste your userToken from chat.deepseek.com — DevTools → Application → Local Storage → userToken | emulated | | `doubao-web` | `db` | Dola Web (ByteDance) | Web cookie | [link](https://www.dola.com) | Paste the full Cookie header from www.dola.com. It should include sessionid, ttwid, and s_v_web_id. If s_v_web_id is unavailable, fp=verify_... from a chat/completion request URL can be used as a fallback. | — | diff --git a/open-sse/executors/copilot-m365-connection.ts b/open-sse/executors/copilot-m365-connection.ts index 0c5303c250..d5f6d80c6c 100644 --- a/open-sse/executors/copilot-m365-connection.ts +++ b/open-sse/executors/copilot-m365-connection.ts @@ -160,7 +160,16 @@ export function resolveConnectionParams( const psd = (credentials?.providerSpecificData ?? {}) as JsonRecord; const parsedApiKey = typeof credentials?.apiKey === "string" ? parsePastedCredential(credentials.apiKey) : {}; + // A JWT in credentials.accessToken (3 dot-separated parts — the individual-tier + // token is an opaque JWE with 5) is the freshest copy: the executor refreshes it + // in place before resolving params, and the framework mutates it after a refresh. + const credentialsJwt = + typeof credentials?.accessToken === "string" && + credentials.accessToken.split(".").length === 3 + ? credentials.accessToken + : ""; const accessToken = + credentialsJwt || parsedApiKey.accessToken || (typeof credentials?.apiKey === "string" && credentials.apiKey && @@ -254,6 +263,135 @@ export function redactWsUrl(wsUrl: string): string { return wsUrl.replace(/access_token=[^&]*/i, "access_token=REDACTED"); } +// ── OAuth refresh support (#10718 — client ids observed in the browser token +// and M365-Copilot2API) ──────────────────────────────────────────────────── +// +// The browser-issued access_token lives ~75 minutes. These helpers redeem a +// stored refresh_token at the Microsoft identity platform (same public client +// the m365.cloud.microsoft web app uses) so the connection self-heals instead +// of requiring a fresh DevTools capture after every expiry. + +/** Public client id observed in both the browser token and M365-Copilot2API. */ +export const M365_OAUTH_CLIENT_ID = "c0ab8ce9-e9a0-42e7-b064-33d422df41f1"; + +export const M365_OAUTH_SCOPE = + "openid profile offline_access https://substrate.office.com/sydney/M365Chat.Read " + + "https://substrate.office.com/sydney/sydney.readwrite"; + +/** Refresh lead time — refresh when the current token has less than this left. */ +export const M365_REFRESH_LEAD_MS = 5 * 60 * 1000; + +type MinimalLog = { + info?: (tag: string, message: string) => void; + warn?: (tag: string, message: string) => void; +}; + +/** Decode a JWT payload WITHOUT verification — exp/tid are routing hints, never authz. */ +export function decodeJwtClaims( + token: string +): { exp?: number; tid?: string; oid?: string } | null { + try { + const parts = token.split("."); + if (parts.length !== 3) return null; + const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8")); + return payload && typeof payload === "object" ? payload : null; + } catch { + return null; + } +} + +/** True when the token is unreadable, already expired, or inside the refresh lead window. */ +export function tokenNeedsRefresh(token: string, leadMs = M365_REFRESH_LEAD_MS): boolean { + const claims = decodeJwtClaims(token); + if (!claims?.exp) return true; + return claims.exp * 1000 <= Date.now() + leadMs; +} + +/** The freshest readable access token for a connection (JWT column → apiKey → psd). */ +export function currentM365AccessToken( + credentials: ProviderCredentials | undefined +): string { + if ( + typeof credentials?.accessToken === "string" && + credentials.accessToken.split(".").length === 3 + ) { + return credentials.accessToken; + } + if (typeof credentials?.apiKey === "string") { + const parsed = parsePastedCredential(credentials.apiKey); + if (parsed.accessToken && parsed.accessToken.split(".").length === 3) return parsed.accessToken; + // Opaque (JWE) individual-tier token — still a usable credential, just not refreshable. + return parsed.accessToken || ""; + } + const psd = (credentials?.providerSpecificData ?? {}) as JsonRecord; + if (typeof psd.accessToken === "string") return psd.accessToken; + if (typeof psd.access_token === "string") return psd.access_token; + return ""; +} + +/** The chathub path (`@`) from wherever it is stored. */ +export function currentM365ChathubPath(credentials: ProviderCredentials | undefined): string { + const psd = (credentials?.providerSpecificData ?? {}) as JsonRecord; + return ( + (typeof credentials?.apiKey === "string" + ? parsePastedCredential(credentials.apiKey).chathubPath + : "") || + (typeof psd.chathubPath === "string" && psd.chathubPath) || + (typeof psd.userTenant === "string" && psd.userTenant) || + "" + ); +} + +export interface M365RefreshResult { + accessToken: string; + refreshToken?: string; + expiresIn?: number; +} + +/** + * Redeem the refresh_token (public client — no secret). MS may rotate the + * refresh_token; callers MUST persist the returned one when present or the + * token family dies after the first refresh. + */ +export async function refreshM365AccessToken( + refreshToken: string, + tid: string, + log?: MinimalLog +): Promise { + const endpoint = `https://login.microsoftonline.com/${tid || "common"}/oauth2/v2.0/token`; + try { + const res = await fetch(endpoint, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + body: new URLSearchParams({ + client_id: M365_OAUTH_CLIENT_ID, + grant_type: "refresh_token", + refresh_token: refreshToken, + scope: M365_OAUTH_SCOPE, + }), + }); + const data = (await res.json().catch(() => ({}))) as Record; + if (!res.ok || typeof data.access_token !== "string") { + const error = typeof data.error === "string" ? data.error : `HTTP ${res.status}`; + log?.warn?.("M365_TOKEN", `refresh_token grant failed: ${error}`); + return { error }; + } + log?.info?.("M365_TOKEN", "access token refreshed via refresh_token grant"); + return { + accessToken: data.access_token, + refreshToken: typeof data.refresh_token === "string" ? data.refresh_token : undefined, + expiresIn: typeof data.expires_in === "number" ? data.expires_in : undefined, + }; + } catch (e) { + const error = e instanceof Error ? e.message : String(e); + log?.warn?.("M365_TOKEN", `refresh request failed: ${error}`); + return { error }; + } +} + /** Flatten OpenAI messages into a single prompt (system instructions prepended). */ export function buildPrompt(body: JsonRecord | undefined): string { const messages = (body?.messages as Array) || []; diff --git a/open-sse/executors/copilot-m365-frames.ts b/open-sse/executors/copilot-m365-frames.ts index add2716ee0..c8c6dee7be 100644 --- a/open-sse/executors/copilot-m365-frames.ts +++ b/open-sse/executors/copilot-m365-frames.ts @@ -11,7 +11,9 @@ * Protocol (from @skyzea1's #4042 capture): * - JSON messages terminated with the SignalR record separator `\x1e`. * - Handshake: → {"protocol":"json","version":1} ← {} → {"type":6} - * - Send: type:4 invocation to target "chat" with arguments[0] = { message, ... } + * - Send: type:4 invocation to target "chat" with arguments[0] = { message, ... }, + * immediately followed by a type:1 target:"Metrics" frame in the SAME socket + * write (#10718 — an invocation without its Metrics pair is silently dropped). * - Stream: type:1 target:"update" deltas (bot text at arguments[0].messages[].text, * accumulated — NOT incremental) → isLastUpdate:true → type:2 final → type:3 completion. */ @@ -25,19 +27,18 @@ export const HANDSHAKE_REQUEST = { protocol: "json", version: 1 } as const; /** SignalR keepalive ping frame. */ export const KEEPALIVE_PING = { type: 6 } as const; -/** Allowed message types observed in the individual M365 send frame. */ +/** + * Allowed message types observed in the 2026-08 recapture of the working + * `m365.cloud.microsoft/chat` client (#10718). The old 11-entry list is no longer + * seen on the wire — the stale shape gets closed immediately after the type:4. + */ export const ALLOWED_MESSAGE_TYPES = [ "Chat", "Suggestion", - "InternalSearchQuery", "Disengaged", - "InternalLoaderMessage", "Progress", - "GeneratedCode", - "RenderCardRequest", - "AdsQuery", - "SemanticSerp", - "GenerateContentQuery", + "EndOfRequest", + "InternalLoaderMessage", ] as const; /** @@ -74,22 +75,20 @@ export const M365_ENTERPRISE_EXTRA_MESSAGE_TYPES = [ "SwitchRespondingEndpoint", ] as const; +/** + * Individual / EDU option sets from the 2026-08 recapture (#10718) — 14 entries. + * The previous 25-entry consumer/MSA set (enable_msa_user, pdnascan, cwc_code_*, + * …) is no longer observed on the wire and belongs to the shape the substrate + * now drops silently. + */ export const M365_DEFAULT_OPTION_SETS = [ "search_result_progress_messages_with_search_queries", "update_textdoc_response_after_streaming", "deepleo_networking_timeout_10minutes_canmore", "cwc_flux_image", - "cwc_code_interpreter", - "cwc_code_interpreter_amsfix", - "enable_msa_user", - "cwcgptv", + "cwcfluxgptv", "flux_v3_gptv_enable_upload_multi_image_in_turn_wo_ch", "gptvnorm2048", - "pdnascan", - "cwc_code_interpreter_citation_fix", - "code_interpreter_interactive_charts", - "cwc_code_interpreter_interactive_charts_inline_image", - "code_interpreter_matplotlib_patching", "cwc_fileupload_odb", "update_memory_plugin", "add_custom_instructions", @@ -97,9 +96,6 @@ export const M365_DEFAULT_OPTION_SETS = [ "flux_v3_progress_messages", "enable_batch_token_processing", "enable_gg_gpt", - "flux_v3_image_gen_enable_non_watermarked_storage", - "flux_v3_image_gen_enable_story", - "rich_responses", ] as const; /** Append the record separator to a JSON-serializable frame. */ @@ -117,6 +113,32 @@ export function keepaliveFrame(): string { return encodeFrame(KEEPALIVE_PING); } +/** + * #10718 — the browser follows the type:4 chat invocation with this type:1 + * target:"Metrics" frame in the SAME socket write. Sending the invocation alone + * gets it silently ignored (no update frames at all), so the executor must + * concatenate `metricsFrame()` onto the invocation payload. + */ +export const CHAT_METRICS_FRAME = { + arguments: [ + { + Timestamps: { + ConnectionEstablished: "", + ConnectionStart: "", + UserInputStart: "", + UserInputSubmit: "", + }, + }, + ], + target: "Metrics", + type: 1, +} as const; + +/** Serialized Metrics follow-up frame (see {@link CHAT_METRICS_FRAME}). */ +export function metricsFrame(): string { + return encodeFrame(CHAT_METRICS_FRAME); +} + /** * Split a raw socket buffer into complete `\x1e`-terminated frames, returning any * trailing partial frame as `rest` so it can be prepended to the next chunk. @@ -155,22 +177,37 @@ export function handshakeError(frame: Record | null): string | export interface ChatInvocationOptions { text: string; - /** Per-connection trace id (hex), reused as clientCorrelationId/traceId. */ + /** Per-invocation trace id (GUID). */ traceId: string; - /** Per-session id (GUID). */ + /** Client correlation id; defaults to {@link ChatInvocationOptions.traceId}. */ + clientCorrelationId?: string; + /** Per-session id (GUID, == the WS URL X-SessionId query). */ sessionId: string; + /** Per-request id (== the WS URL chatsessionid/clientrequestid query). */ + requestId: string; + /** + * Conversation id — MUST match the ConversationId query of the WS URL the + * invocation rides on (#10718: the server cross-checks the two). + */ + conversationId: string; + /** BCP-47 locale echoed in message.locale; defaults to "en-us". */ + locale?: string; + /** IANA time zone for message.locationInfo; defaults to "UTC". */ + timeZone?: string; + /** Hour offset for message.locationInfo; defaults to 0. */ + timeZoneOffset?: number; /** Whether this is the first turn of the conversation. */ isStartOfSession?: boolean; - /** Tier-specific option flags; left empty by default (tuned during live validation). */ + /** Tier-specific option flags; defaults to {@link M365_DEFAULT_OPTION_SETS}. */ optionsSets?: string[]; tone?: string; /** Tier-specific allowed message types; defaults to {@link ALLOWED_MESSAGE_TYPES}. */ allowedMessageTypes?: readonly string[]; /** - * Tier-specific disconnect behavior sent in every type:4 chat invocation. The work - * Surface rejects any value other than exactly "continue" (#8971). Defaults to "" - * for individual/consumer/EDU tiers; {@link resolveChatInvocationOverrides} returns - * "continue" for the enterprise tier. + * Tier-specific disconnect behavior sent in the type:4 chat invocation. The work + * surface rejects any value other than exactly "continue" (#8971), so the + * enterprise tier sends it; the 2026-08 recapture shows the individual/EDU + * surface omits the key entirely, so it is left out unless set (#10718). */ disconnectBehavior?: string; } @@ -185,7 +222,7 @@ export function resolveChatInvocationOverrides(tier: string | undefined): { optionsSets: string[]; tone: string; allowedMessageTypes: readonly string[]; - disconnectBehavior: string; + disconnectBehavior: string | undefined; } { if (tier === "enterprise") { return { @@ -197,9 +234,12 @@ export function resolveChatInvocationOverrides(tier: string | undefined): { } return { optionsSets: [...M365_DEFAULT_OPTION_SETS], - tone: "", + // #10718 — the 2026-08 recapture sends tone:"magic" (lowercase) on the + // individual/EDU surface; the old "" default is part of the dropped shape. + tone: "magic", allowedMessageTypes: ALLOWED_MESSAGE_TYPES, - disconnectBehavior: "", + // Omitted entirely on the individual/EDU wire (see ChatInvocationOptions). + disconnectBehavior: undefined, }; } @@ -207,7 +247,7 @@ export function resolveChatInvocationOverrides(tier: string | undefined): { * BizChat exposes several models selected by the `tone` field of the `type:4` chat * invocation (#7872, values confirmed against a real enterprise tenant in #7850). Each * tone-selected variant is registered as its own model id; the bare `copilot-m365` id is - * intentionally absent here so it keeps the tier default tone (`Magic` on enterprise, `""` + * intentionally absent here so it keeps the tier default tone (`Magic` on enterprise, `magic` * otherwise) resolved by {@link resolveChatInvocationOverrides}. */ export const M365_MODEL_TONE_MAP: Readonly> = { @@ -228,7 +268,14 @@ export function resolveToneForModel(model: string | undefined): string | undefin /** * Build the `type:4` chat invocation frame body (not yet `\x1e`-terminated). - * Mirrors the argument shape captured on the individual M365 path in #4042. + * Mirrors the argument shape recaptured from a working `m365.cloud.microsoft/chat` + * client in 2026-08 (#10718). Notable differences from the pre-#10718 shape: a + * populated `clientInfo` + `productThreadType:"Office"`, a `conversationId` + * matching the WS URL query, a rich `message` object, and no + * `spokenTextMode` / `extraExtensionParameters` / `isSbsSupported` / + * `renderReferencesBehindEOS` / `disconnectBehavior` — none of those are still + * observed on the wire, and the stale shape gets closed immediately after the + * invocation. */ export function buildChatInvocation(opts: ChatInvocationOptions): Record { return { @@ -237,33 +284,48 @@ export function buildChatInvocation(opts: ChatInvocationOptions): Record { - ws?.send(keepaliveFrame()); const overrides = resolveChatInvocationOverrides(input.tier); // Model-driven tone (#7872) wins over the tier default; a bare/unknown id // keeps the tier tone resolved above. const tone = resolveToneForModel(input.model) ?? overrides.tone; - ws?.send( - encodeFrame( - buildChatInvocation({ - text: input.prompt, - traceId, - sessionId, - isStartOfSession: true, - ...overrides, - tone, - }) - ) + const invocationFrame = encodeFrame( + buildChatInvocation({ + text: input.prompt, + traceId, + sessionId, + requestId, + conversationId, + isStartOfSession: true, + ...overrides, + tone, + }) ); + // #10718 — the invocation and its type:1 Metrics follow-up must land + // in ONE socket write, exactly as the browser sends them; a bare + // invocation (or one preceded by a type:6 ping) is silently dropped. + ws?.send(invocationFrame + metricsFrame()); }; ws.on("open", () => { @@ -273,6 +290,61 @@ export class CopilotM365WebExecutor extends BaseExecutor { ); } + /** + * #10718 — proactively refresh the M365 access token before opening the WS. + * A WS-handshake 401 surfaces as an error event INSIDE the SSE stream (the HTTP + * response is already 200 by then), so chatCore's generic 401→refresh→retry + * orchestration never triggers — the refresh has to happen here, pre-flight. + * No-ops for legacy connections without a stored refresh_token. + */ + private async ensureFreshCredentials( + credentials: ExecuteInput["credentials"], + onCredentialsRefreshed: ExecuteInput["onCredentialsRefreshed"], + log: ExecutorLog | null + ): Promise { + const psd = (credentials?.providerSpecificData ?? {}) as JsonRecord; + const refreshToken = + credentials.refreshToken || (typeof psd.refreshToken === "string" ? psd.refreshToken : ""); + if (!refreshToken) return; + + const current = currentM365AccessToken(credentials); + if (current && !tokenNeedsRefresh(current)) return; + + const tid = + decodeJwtClaims(current)?.tid || (typeof psd.tid === "string" ? psd.tid : "") || ""; + const result = await refreshM365AccessToken(refreshToken, tid, log ?? undefined); + if ("error" in result) { + // Fall through with the existing token — the WS layer will surface the failure. + return; + } + + const rotated = result.refreshToken || refreshToken; + const chathubPath = currentM365ChathubPath(credentials); + const next = { + ...credentials, + accessToken: result.accessToken, + refreshToken: rotated, + // Keep the pasted-format apiKey self-consistent so every resolution path + // (fresh column, stale column, dashboard re-read) sees the same token. + ...(chathubPath + ? { apiKey: `access_token=${result.accessToken}; chathubPath=${chathubPath}` } + : {}), + ...(result.expiresIn + ? { expiresAt: new Date(Date.now() + result.expiresIn * 1000).toISOString() } + : {}), + }; + Object.assign(credentials, next); + try { + await onCredentialsRefreshed?.(next); + } catch (err) { + // #7676 pattern: a persistence failure must never fail the user-facing response. + log?.warn?.( + "M365_TOKEN", + `persisting refreshed token failed (${err instanceof Error ? err.message : String(err)}) — will re-refresh next request` + ); + } + } + async execute(input: ExecuteInput): Promise<{ response: Response; url: string; @@ -293,6 +365,12 @@ export class CopilotM365WebExecutor extends BaseExecutor { }; } + await this.ensureFreshCredentials( + input.credentials, + input.onCredentialsRefreshed, + input.log ?? null + ); + const connectionParams = resolveConnectionParams(input.credentials); if ("error" in connectionParams) { return { diff --git a/src/shared/constants/providers/web-cookie.ts b/src/shared/constants/providers/web-cookie.ts index e01fa95190..6d1b08be7d 100644 --- a/src/shared/constants/providers/web-cookie.ts +++ b/src/shared/constants/providers/web-cookie.ts @@ -152,7 +152,7 @@ export const WEB_COOKIE_PROVIDERS = { textIcon: "M365", website: "https://m365.cloud.microsoft/chat", authHint: - "Sign in at m365.cloud.microsoft/chat, then open DevTools → Network → filter 'WS' → click the Chathub WebSocket connection. Copy both the access_token query parameter AND the account-specific Chathub path segment from its request URL (wss://…/Chathub/?…&access_token=…). It is NOT an Authorization: Bearer header on an XHR/Fetch request. The token is short-lived; this is an unofficial integration.", + "Sign in at m365.cloud.microsoft/chat, then open DevTools → Network → filter 'WS' → click the Chathub WebSocket connection. Copy both the access_token query parameter AND the account-specific Chathub path segment from its request URL (wss://…/Chathub/?…&access_token=…). It is NOT an Authorization: Bearer header on an XHR/Fetch request. The token is short-lived; this is an unofficial integration. Optional: store a refresh_token in providerSpecificData.refreshToken (any Microsoft device-code/refresh flow for the substrate.office.com/sydney scopes) and OmniRoute pre-flight-refreshes the access token itself — otherwise re-capture after every ~75 min expiry.", subscriptionRisk: true, riskNoticeVariant: "webCookie", }, diff --git a/tests/unit/copilot-m365-enterprise-invocation-7870.test.ts b/tests/unit/copilot-m365-enterprise-invocation-7870.test.ts index f95cd12df3..98a3548dfe 100644 --- a/tests/unit/copilot-m365-enterprise-invocation-7870.test.ts +++ b/tests/unit/copilot-m365-enterprise-invocation-7870.test.ts @@ -29,12 +29,24 @@ class MockM365WebSocket { send(data: string): void { this.sent.push(String(data)); - const parsed = JSON.parse(String(data).replace(/\x1e$/, "")); - if (parsed.protocol === "json") { + // #10718 — a single socket write may carry multiple \x1e-terminated frames + // (the chat invocation and its Metrics follow-up ride together). + const parsedFrames = String(data) + .split("\x1e") + .filter((f) => f.length > 0) + .map((f) => { + try { + return JSON.parse(f); + } catch { + return null; + } + }); + const parsed = parsedFrames.find((f) => f && f.protocol === "json") ?? parsedFrames[0]; + if (parsed?.protocol === "json") { queueMicrotask(() => this.emit("message", Buffer.from(encodeFrame({})))); return; } - if (parsed.type === 4 && parsed.target === "chat") { + if (parsedFrames.some((f) => f?.type === 4 && f?.target === "chat")) { queueMicrotask(() => { this.emit( "message", @@ -83,7 +95,7 @@ async function sendChatInvocation(tier: string | undefined) { assert.equal(MockM365WebSocket.instances.length, 1); const sentFrames = MockM365WebSocket.instances[0].sent; const chatFrameRaw = sentFrames - .map((f) => f.replace(/\x1e$/, "")) + .flatMap((f) => f.split("\x1e").filter((frame) => frame.length > 0)) .map((f) => { try { return JSON.parse(f); @@ -132,31 +144,30 @@ test("#7870: enterprise-tier chat invocation defaults tone to Magic", async () = assert.equal(invocationArgs.tone, "Magic"); }); -test("#7870: individual (no tier) chat invocation payload stays byte-identical to today", async () => { +test("#10718: individual (no tier) chat invocation carries the recaptured 2026-08 shape", async () => { const invocationArgs = await sendChatInvocation(undefined); const optionsSets = invocationArgs.optionsSets as string[]; - assert.ok(optionsSets.includes("enable_msa_user")); - assert.equal(invocationArgs.tone, ""); + // The 25-entry consumer/MSA set (enable_msa_user, pdnascan, …) is gone from + // the wire — the stale set was part of the silently-dropped shape. + assert.ok(!optionsSets.includes("enable_msa_user")); + assert.ok(optionsSets.includes("enable_gg_gpt")); + // The browser sends tone:"magic" (lowercase) on the individual/EDU surface. + assert.equal(invocationArgs.tone, "magic"); assert.deepEqual(invocationArgs.allowedMessageTypes, [ "Chat", "Suggestion", - "InternalSearchQuery", "Disengaged", - "InternalLoaderMessage", "Progress", - "GeneratedCode", - "RenderCardRequest", - "AdsQuery", - "SemanticSerp", - "GenerateContentQuery", + "EndOfRequest", + "InternalLoaderMessage", ]); }); -test("#7870: EDU-tier chat invocation payload stays byte-identical to today (unaffected by enterprise change)", async () => { +test("#10718: EDU-tier chat invocation carries the same recaptured shape", async () => { const invocationArgs = await sendChatInvocation("edu"); const optionsSets = invocationArgs.optionsSets as string[]; - assert.ok(optionsSets.includes("enable_msa_user")); - assert.equal(invocationArgs.tone, ""); + assert.ok(!optionsSets.includes("enable_msa_user")); + assert.equal(invocationArgs.tone, "magic"); }); test("#8971: enterprise-tier chat invocation must send disconnectBehavior=continue", async () => { @@ -168,11 +179,11 @@ test("#8971: enterprise-tier chat invocation must send disconnectBehavior=contin ); }); -test("#8971: individual (no tier) chat invocation disconnectBehavior remains empty (byte-identical to #4042)", async () => { +test("#8971/#10718: individual (no tier) chat invocation omits disconnectBehavior (not on the 2026-08 wire)", async () => { const invocationArgs = await sendChatInvocation(undefined); assert.equal( invocationArgs.disconnectBehavior, - "", - `individual-tier invocation must carry disconnectBehavior=""; got ${JSON.stringify(invocationArgs.disconnectBehavior)}` + undefined, + `individual-tier invocation must omit disconnectBehavior; got ${JSON.stringify(invocationArgs.disconnectBehavior)}` ); }); diff --git a/tests/unit/copilot-m365-invocation-refresh-10718.test.ts b/tests/unit/copilot-m365-invocation-refresh-10718.test.ts new file mode 100644 index 0000000000..f5e602ce3d --- /dev/null +++ b/tests/unit/copilot-m365-invocation-refresh-10718.test.ts @@ -0,0 +1,205 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +// #10718 — the substrate started dropping the old type:4 chat invocation shape +// (immediate bare type:3 close, "(empty response)" on every request). A fresh +// TLS-MITM capture of a working m365.cloud.microsoft/chat round-trip (2026-08) +// showed a materially different argument shape AND a type:1 target:"Metrics" +// frame written in the SAME socket write right after the invocation — an +// invocation without its Metrics pair is silently ignored. +// +// These tests pin the recaptured wire shape and the refresh_token pre-flight +// (the browser-issued access_token lives ~75 min with no refresh path before +// this). Live round-trip on a real EDU (A3/Starter) tenant is the separate +// Rule #18 validation gate. + +import { + RECORD_SEPARATOR, + metricsFrame, + buildChatInvocation, + resolveChatInvocationOverrides, + M365_DEFAULT_OPTION_SETS, + ALLOWED_MESSAGE_TYPES, +} from "../../open-sse/executors/copilot-m365-frames.ts"; +import { + decodeJwtClaims, + tokenNeedsRefresh, + refreshM365AccessToken, + M365_OAUTH_CLIENT_ID, + M365_REFRESH_LEAD_MS, +} from "../../open-sse/executors/copilot-m365-connection.ts"; + +// ── Metrics follow-up frame ──────────────────────────────────────────────── + +test("#10718: metricsFrame emits the exact bytes observed in the browser capture", () => { + assert.equal( + metricsFrame(), + '{"arguments":[{"Timestamps":{"ConnectionEstablished":"","ConnectionStart":"","UserInputStart":"","UserInputSubmit":""}}],"target":"Metrics","type":1}' + + RECORD_SEPARATOR + ); +}); + +// ── Recaptured invocation shape ──────────────────────────────────────────── + +test("#10718: buildChatInvocation matches the recaptured arguments[0] key set", () => { + const arg = buildChatInvocation({ + text: "Say OK in one word.", + traceId: "11111111-1111-1111-1111-111111111111", + sessionId: "22222222-2222-2222-2222-222222222222", + requestId: "33333333-3333-3333-3333-333333333333", + conversationId: "44444444-4444-4444-4444-444444444444", + }).arguments[0] as Record; + + // Exact key set from the capture — additions AND omissions are both pinned, + // because the stale keys are exactly what got the shape dropped. + assert.deepEqual(Object.keys(arg).sort(), [ + "allowedMessageTypes", + "clientCorrelationId", + "clientInfo", + "conversationId", + "isStartOfSession", + "message", + "options", + "optionsSets", + "plugins", + "productThreadType", + "sessionId", + "sliceIds", + "source", + "streamingMode", + "threadLevelGptId", + "tone", + "toolChoice", + "traceId", + ]); + assert.equal(arg.productThreadType, "Office"); + assert.deepEqual(arg.clientInfo, { clientAppName: "Office", clientPlatform: "mcmcopilot-web" }); + assert.equal(arg.conversationId, "44444444-4444-4444-4444-444444444444"); + assert.equal(arg.toolChoice, null); + assert.equal(arg.tone, "magic"); +}); + +test("#10718: the message object carries the recaptured rich shape", () => { + const arg = buildChatInvocation({ + text: "Say OK in one word.", + traceId: "t", + sessionId: "s", + requestId: "r", + conversationId: "c", + }).arguments[0] as Record; + const message = arg.message as Record; + + assert.deepEqual(Object.keys(message).sort(), [ + "adaptiveCards", + "attachments", + "author", + "clientPreferences", + "entityAnnotationTypes", + "experienceType", + "inputMethod", + "locale", + "locationInfo", + "messageType", + "requestId", + "text", + ]); + assert.equal(message.author, "user"); + assert.equal(message.messageType, "Chat"); + assert.equal(message.requestId, "r"); + assert.equal(message.experienceType, "Default"); + assert.deepEqual(message.entityAnnotationTypes, ["People", "File", "Event", "Email", "TeamsMessage"]); + assert.equal(message.attachments, null); + assert.deepEqual(message.locationInfo, { timeZone: "UTC", timeZoneOffset: 0 }); +}); + +test("#10718: default tier lists are the recaptured 14-entry optionsSets / 6-entry allowedMessageTypes", () => { + const overrides = resolveChatInvocationOverrides(undefined); + assert.equal(overrides.optionsSets.length, 14); + assert.equal(overrides.allowedMessageTypes.length, 6); + assert.equal(overrides.tone, "magic"); + // The pre-#10718 consumer/MSA flags are gone from the wire. + const optionSets = M365_DEFAULT_OPTION_SETS as readonly string[]; + const messageTypes = ALLOWED_MESSAGE_TYPES as readonly string[]; + for (const stale of ["enable_msa_user", "pdnascan", "cwc_code_interpreter", "rich_responses"]) { + assert.ok(!optionSets.includes(stale), `${stale} must not be in the default option sets`); + } + for (const stale of ["InternalSearchQuery", "GeneratedCode", "RenderCardRequest", "AdsQuery", "SemanticSerp", "GenerateContentQuery"]) { + assert.ok(!messageTypes.includes(stale), `${stale} must not be in allowedMessageTypes`); + } + // Entries the capture showed and the old lists lacked. + assert.ok(optionSets.includes("cwcfluxgptv")); + assert.ok(messageTypes.includes("EndOfRequest")); +}); + +// ── refresh_token helpers ────────────────────────────────────────────────── + +function fakeJwt(claims: Record): string { + const b64 = (value: unknown) => + Buffer.from(JSON.stringify(value)).toString("base64url"); + return `${b64({ alg: "none" })}.${b64(claims)}.sig`; +} + +test("#10718: decodeJwtClaims reads exp/tid without verification; non-JWT returns null", () => { + const claims = decodeJwtClaims(fakeJwt({ exp: 123, tid: "tenant-id", oid: "oid" })); + assert.equal(claims?.exp, 123); + assert.equal(claims?.tid, "tenant-id"); + assert.equal(decodeJwtClaims("not.a-jwt"), null); + assert.equal(decodeJwtClaims("opaque-jwe-token.with.five.parts.here.and-more"), null); +}); + +test("#10718: tokenNeedsRefresh — unreadable/expired/inside-lead needs refresh, fresh does not", () => { + const now = Math.floor(Date.now() / 1000); + assert.equal(tokenNeedsRefresh("opaque"), true); + assert.equal(tokenNeedsRefresh(fakeJwt({ exp: now - 60 })), true); + // Inside the 5-minute lead window. + assert.equal(tokenNeedsRefresh(fakeJwt({ exp: now + M365_REFRESH_LEAD_MS / 1000 - 30 })), true); + assert.equal(tokenNeedsRefresh(fakeJwt({ exp: now + 3600 })), false); +}); + +test("#10718: refreshM365AccessToken redeems the public client grant and returns rotated tokens", async () => { + const originalFetch = globalThis.fetch; + let capturedUrl = ""; + let capturedBody = ""; + globalThis.fetch = (async (url: unknown, init?: RequestInit) => { + capturedUrl = String(url); + capturedBody = String(init?.body); + return new Response( + JSON.stringify({ + access_token: "NEW-ACCESS", + refresh_token: "ROTATED-REFRESH", + expires_in: 4777, + }), + { status: 200 } + ); + }) as typeof fetch; + try { + const result = await refreshM365AccessToken("OLD-REFRESH", "tenant-id"); + assert.ok("accessToken" in result); + assert.equal(result.accessToken, "NEW-ACCESS"); + assert.equal(result.refreshToken, "ROTATED-REFRESH"); + assert.equal(result.expiresIn, 4777); + assert.match(capturedUrl, /login\.microsoftonline\.com\/tenant-id\/oauth2\/v2\.0\/token/); + assert.match(capturedBody, /grant_type=refresh_token/); + assert.match(capturedBody, new RegExp(`client_id=${M365_OAUTH_CLIENT_ID}`)); + assert.match(capturedBody, /refresh_token=OLD-REFRESH/); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("#10718: refreshM365AccessToken surfaces AAD errors and network failures as {error}", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => + new Response(JSON.stringify({ error: "invalid_grant", error_description: "AADSTS700082" }), { + status: 400, + })) as typeof fetch; + const aadError = await refreshM365AccessToken("STALE"); + assert.deepEqual(aadError, { error: "invalid_grant" }); + + globalThis.fetch = (async () => { + throw new Error("ENOTFOUND"); + }) as typeof fetch; + const netError = await refreshM365AccessToken("ANY"); + assert.deepEqual(netError, { error: "ENOTFOUND" }); + globalThis.fetch = originalFetch; +}); diff --git a/tests/unit/copilot-m365-web-executor.test.ts b/tests/unit/copilot-m365-web-executor.test.ts index 3150082cb1..f6e48e488e 100644 --- a/tests/unit/copilot-m365-web-executor.test.ts +++ b/tests/unit/copilot-m365-web-executor.test.ts @@ -40,12 +40,24 @@ class MockM365WebSocket { send(data: string): void { this.sent.push(String(data)); - const parsed = JSON.parse(String(data).replace(/\x1e$/, "")); - if (parsed.protocol === "json") { + // #10718 — a single socket write may carry multiple \x1e-terminated frames + // (the chat invocation and its Metrics follow-up ride together). + const parsedFrames = String(data) + .split("\x1e") + .filter((f) => f.length > 0) + .map((f) => { + try { + return JSON.parse(f); + } catch { + return null; + } + }); + const parsed = parsedFrames.find((f) => f && f.protocol === "json") ?? parsedFrames[0]; + if (parsed?.protocol === "json") { queueMicrotask(() => this.emit("message", Buffer.from(encodeFrame({})))); return; } - if (parsed.type === 4 && parsed.target === "chat") { + if (parsedFrames.some((f) => f?.type === 4 && f?.target === "chat")) { queueMicrotask(() => { this.emit( "message", @@ -116,10 +128,18 @@ test("CopilotM365WebExecutor streams OpenAI SSE chunks from accumulated M365 upd assert.doesNotMatch(result.url, /redacted-token/); assert.equal(MockM365WebSocket.instances.length, 1); - const sent = MockM365WebSocket.instances[0].sent.join("\n"); - assert.match(sent, /"protocol":"json"/); - assert.match(sent, /"type":6/); - assert.match(sent, /"target":"chat"/); + const sent = MockM365WebSocket.instances[0].sent; + const sentFrames = sent.flatMap((f) => f.split("\x1e").filter((frame) => frame.length > 0)); + assert.ok(sentFrames.some((f) => f.includes('"protocol":"json"'))); + // #10718 — the chat invocation and its type:1 Metrics follow-up ride in ONE + // socket write, and no type:6 keepalive is sent before them. + const invocationWrite = sent.find((f) => f.includes('"target":"chat"')); + assert.ok(invocationWrite, "expected a chat invocation write"); + assert.match(invocationWrite, /"target":"Metrics"/); + assert.ok( + !sentFrames.some((f) => f === '{"type":6}'), + "the leading keepalive ping was removed (#10718): it must not precede the invocation" + ); const dataLines = body .split("\n") diff --git a/tests/unit/m365-bizchat-frames-4042.test.ts b/tests/unit/m365-bizchat-frames-4042.test.ts index b1179d098a..cd67a0e42b 100644 --- a/tests/unit/m365-bizchat-frames-4042.test.ts +++ b/tests/unit/m365-bizchat-frames-4042.test.ts @@ -116,6 +116,8 @@ test("buildChatInvocation produces a type:4 chat invocation carrying the user te text: "protocol capture test. Reply with one word: pong.", traceId: "trace-id", sessionId: "session-id", + requestId: "request-id", + conversationId: "conversation-id", isStartOfSession: true, }); assert.equal(frame.type, 4); @@ -127,20 +129,30 @@ test("buildChatInvocation produces a type:4 chat invocation carrying the user te assert.equal(arg.traceId, "trace-id"); assert.equal(arg.clientCorrelationId, "trace-id"); assert.equal(arg.sessionId, "session-id"); + assert.equal(arg.conversationId, "conversation-id"); + assert.equal(arg.productThreadType, "Office"); + assert.deepEqual(arg.clientInfo, { clientAppName: "Office", clientPlatform: "mcmcopilot-web" }); assert.equal(arg.isStartOfSession, true); assert.ok(Array.isArray(arg.optionsSets)); - assert.ok((arg.optionsSets as string[]).includes("rich_responses")); + assert.ok((arg.optionsSets as string[]).includes("enable_gg_gpt")); assert.ok(Array.isArray(arg.allowedMessageTypes)); assert.ok((arg.allowedMessageTypes as string[]).includes("Chat")); const message = arg.message as Record; assert.equal(message.author, "user"); assert.equal(message.inputMethod, "Keyboard"); assert.equal(message.messageType, "Chat"); + assert.equal(message.requestId, "request-id"); assert.equal(message.text, "protocol capture test. Reply with one word: pong."); }); test("buildChatInvocation serializes/round-trips through the framing", () => { - const frame = buildChatInvocation({ text: "hi", traceId: "t", sessionId: "s" }); + const frame = buildChatInvocation({ + text: "hi", + traceId: "t", + sessionId: "s", + requestId: "r", + conversationId: "c", + }); const wire = encodeFrame(frame); assert.ok(wire.endsWith(RECORD_SEPARATOR)); const { frames } = splitFrames(wire); diff --git a/tests/unit/m365-tone-model-variants.test.ts b/tests/unit/m365-tone-model-variants.test.ts index 741ecd73a6..8860c17cdc 100644 --- a/tests/unit/m365-tone-model-variants.test.ts +++ b/tests/unit/m365-tone-model-variants.test.ts @@ -30,9 +30,10 @@ test("model-driven tone overrides the tier default; bare id keeps the tier tone" const enterprise = resolveChatInvocationOverrides("enterprise"); const individual = resolveChatInvocationOverrides(undefined); - // enterprise tier default tone is Magic + // enterprise tier default tone is Magic; individual/EDU sends "magic" (#10718 + // recapture — the old "" default was part of the silently-dropped shape) assert.equal(enterprise.tone, "Magic"); - assert.equal(individual.tone, ""); + assert.equal(individual.tone, "magic"); // precedence: resolveToneForModel(model) ?? overrides.tone (mirrors the executor wiring) const toneFor = (model: string | undefined, tierTone: string) => @@ -44,7 +45,7 @@ test("model-driven tone overrides the tier default; bare id keeps the tier tone" // the bare id keeps whatever the tier resolved assert.equal(toneFor("copilot-m365", enterprise.tone), "Magic"); - assert.equal(toneFor("copilot-m365", individual.tone), ""); + assert.equal(toneFor("copilot-m365", individual.tone), "magic"); }); test("registry exposes the bare id (first) plus every tone variant", () => {