fix(providers): copilot-m365-web invocation dropped by substrate + token refresh (#10732)

Merged via merge-train (release/v3.8.50, batch1 2026-08-20) — static gates (typecheck/file-size/complexity/cognitive/changelog) green on the combined tree; test:unit reds observed in the boarded run were verified pre-existing on the pure release tip (unrelated flake), not caused by this PR. Thanks for the contribution!
This commit is contained in:
acc0mplish
2026-08-20 18:29:30 +09:00
committed by GitHub
parent 48f3428307
commit 2eb6d59ebc
11 changed files with 631 additions and 103 deletions

View File

@@ -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)

View File

@@ -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/<path>?…&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/<path>?…&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. | — |

View File

@@ -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 (`<user-oid>@<tenant-id>`) 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<M365RefreshResult | { error: string }> {
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<string, unknown>;
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<JsonRecord>) || [];

View File

@@ -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<string, unknown> | 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<Record<string, string>> = {
@@ -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<string, unknown> {
return {
@@ -237,33 +284,48 @@ export function buildChatInvocation(opts: ChatInvocationOptions): Record<string,
invocationId: "0",
arguments: [
{
source: "officeweb",
clientCorrelationId: opts.traceId,
sessionId: opts.sessionId,
optionsSets: opts.optionsSets ?? [...M365_DEFAULT_OPTION_SETS],
streamingMode: "ConciseWithPadding",
spokenTextMode: "None",
options: {},
extraExtensionParameters: {},
allowedMessageTypes: opts.allowedMessageTypes
? [...opts.allowedMessageTypes]
: [...ALLOWED_MESSAGE_TYPES],
sliceIds: [],
threadLevelGptId: {},
traceId: opts.traceId,
isStartOfSession: opts.isStartOfSession ?? true,
clientInfo: {},
message: {
author: "user",
inputMethod: "Keyboard",
text: opts.text,
messageType: "Chat",
clientCorrelationId: opts.clientCorrelationId ?? opts.traceId,
clientInfo: {
clientAppName: "Office",
clientPlatform: "mcmcopilot-web",
},
conversationId: opts.conversationId,
isStartOfSession: opts.isStartOfSession ?? true,
message: {
adaptiveCards: [],
attachments: null,
author: "user",
clientPreferences: {},
entityAnnotationTypes: ["People", "File", "Event", "Email", "TeamsMessage"],
experienceType: "Default",
inputMethod: "Keyboard",
locale: opts.locale ?? "en-us",
locationInfo: {
timeZone: opts.timeZone ?? "UTC",
timeZoneOffset: opts.timeZoneOffset ?? 0,
},
messageType: "Chat",
requestId: opts.requestId,
text: opts.text,
},
options: {},
optionsSets: opts.optionsSets ?? [...M365_DEFAULT_OPTION_SETS],
plugins: [],
isSbsSupported: false,
tone: opts.tone ?? "",
renderReferencesBehindEOS: true,
disconnectBehavior: opts.disconnectBehavior ?? "",
productThreadType: "Office",
sessionId: opts.sessionId,
sliceIds: [],
source: "officeweb",
streamingMode: "ConciseWithPadding",
threadLevelGptId: {},
tone: opts.tone ?? "magic",
toolChoice: null,
traceId: opts.traceId,
// #8971 keeps "continue" for the enterprise tier; the individual/EDU wire
// omits the key, so only include it when actually set (#10718).
...(opts.disconnectBehavior ? { disconnectBehavior: opts.disconnectBehavior } : {}),
},
],
};

View File

@@ -5,8 +5,13 @@ import { BaseExecutor, type ExecuteInput, type ExecutorLog } from "./base.ts";
import {
buildPrompt,
buildWsUrl,
currentM365AccessToken,
currentM365ChathubPath,
decodeJwtClaims,
redactWsUrl,
refreshM365AccessToken,
resolveConnectionParams,
tokenNeedsRefresh,
} from "./copilot-m365-connection.ts";
import {
accumulateBotContent,
@@ -17,7 +22,7 @@ import {
handshakeFrame,
isCompletionFrame,
isUpdateFrame,
keepaliveFrame,
metricsFrame,
parseFrame,
resolveChatInvocationOverrides,
resolveToneForModel,
@@ -152,8 +157,17 @@ export class CopilotM365WebExecutor extends BaseExecutor {
try {
const wsUrlParts = new URL(input.wsUrl);
const traceId = wsUrlParts.searchParams.get("clientrequestid") ?? crypto.randomUUID().replace(/-/g, "");
// #10718 — the invocation must echo the ids riding in the WS URL query
// (conversationId is cross-checked server-side). traceId is a fresh GUID
// per turn, as in the browser capture.
const requestId =
wsUrlParts.searchParams.get("chatsessionid") ??
wsUrlParts.searchParams.get("clientrequestid") ??
crypto.randomUUID();
const sessionId = wsUrlParts.searchParams.get("X-SessionId") ?? crypto.randomUUID();
const conversationId =
wsUrlParts.searchParams.get("ConversationId") ?? crypto.randomUUID();
const traceId = crypto.randomUUID();
log?.debug?.("M365_WS", `connecting → ${redactWsUrl(input.wsUrl)}`);
@@ -166,23 +180,26 @@ export class CopilotM365WebExecutor extends BaseExecutor {
});
const sendChat = () => {
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<void> {
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 {

View File

@@ -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/<path>?…&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/<path>?…&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",
},

View File

@@ -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)}`
);
});

View File

@@ -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<string, unknown>;
// 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<string, unknown>;
const message = arg.message as Record<string, unknown>;
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, unknown>): 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;
});

View File

@@ -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")

View File

@@ -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<string, unknown>;
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);

View File

@@ -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", () => {