Files
2026-07-13 13:39:12 +08:00

723 lines
26 KiB
TypeScript

/**
* CopilotWebExecutor — Microsoft Copilot Web Session Provider
*
* Routes requests through copilot.microsoft.com's WebSocket API using
* session credentials, translating between OpenAI chat completions format
* and Copilot's proprietary WebSocket event protocol.
*
* Auth: access_token from copilot.microsoft.com (extracted from browser
* DevTools or HAR file). Anonymous access supported with limited models.
*
* Protocol:
* 1. POST /c/api/start → conversationId
* 2. WS connect wss://copilot.microsoft.com/c/api/chat?api-version=2
* 3. Send: { event: "send", conversationId, content, mode }
* 4. Receive: stream of JSON events (appendText, done, error, etc.)
*/
import { BaseExecutor, type ExecuteInput } from "./base.ts";
import { FETCH_TIMEOUT_MS } from "../config/constants.ts";
import { createHash, randomBytes } from "node:crypto";
import { sanitizeErrorMessage } from "../utils/error.ts";
// ─── Constants ──────────────────────────────────────────────────────────────
const COPILOT_BASE = "https://copilot.microsoft.com";
const COPILOT_START_URL = `${COPILOT_BASE}/c/api/start`;
const COPILOT_WS_URL = "wss://copilot.microsoft.com/c/api/chat?api-version=2";
const COPILOT_USER_AGENT =
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36";
// Model mapping: OmniRoute model ID → Copilot mode
const MODEL_MODE_MAP: Record<string, string> = {
copilot: "chat",
"copilot-chat": "chat",
"gpt-4o": "chat",
"gpt-4": "chat",
"copilot-think": "reasoning",
"copilot-think-deeper": "reasoning",
o1: "reasoning",
o3: "reasoning",
"copilot-smart": "smart",
"copilot-gpt5": "smart",
"gpt-5": "smart",
"copilot-study": "chat",
};
const DEFAULT_MODE = "chat";
// ─── Types ──────────────────────────────────────────────────────────────────
interface CopilotStartResponse {
currentConversationId?: string;
conversationId?: string;
remainingTurns?: number;
isBlocked?: boolean;
banExpiresAt?: string;
}
interface CopilotWsEvent {
event: string;
text?: string;
conversationId?: string;
url?: string;
thumbnailUrl?: string;
suggestions?: string[];
error?: string;
[key: string]: unknown;
}
// ─── Helpers ────────────────────────────────────────────────────────────────
export function getCopilotMode(model?: string): string {
if (!model) return DEFAULT_MODE;
const lower = model.toLowerCase();
return MODEL_MODE_MAP[lower] || DEFAULT_MODE;
}
// Hashcash difficulty cap. Upstream supplies `difficulty`, so we clamp it to
// prevent a malicious/buggy server from forcing huge prefix allocations or
// effectively infinite work. 8 hex zeros = 2^32 expected iterations, already
// far beyond the ~10M iteration budget below.
const MAX_HASHCASH_DIFFICULTY = 8;
export function solveHashcash(parameter: string, difficulty: number): number | null {
if (!Number.isInteger(difficulty) || difficulty < 1 || difficulty > MAX_HASHCASH_DIFFICULTY) {
return null;
}
const prefix = "0".repeat(difficulty);
for (let i = 0; i < 10_000_000; i++) {
const hash = createHash("sha256").update(`${parameter}:${i}`).digest("hex");
if (hash.startsWith(prefix)) return i;
}
return null;
}
export function extractAccessToken(credential: string): string | null {
if (!credential) return null;
// Direct token
if (credential.startsWith("ey") || credential.length > 100) return credential;
// Try parsing as cookie string — look for _EDGE_S or similar
const match = credential.match(/access_token=([^;]+)/);
if (match) return match[1];
// Try HAR-extracted bearer
const bearerMatch = credential.match(/[Bb]earer\s+(.+)/);
if (bearerMatch) return bearerMatch[1];
return credential;
}
/**
* Map a token (or absence of one) to an in-memory session-pool key.
*
* Earlier iterations hashed the token with SHA-256, then with HMAC-SHA-256.
* Both forms left CodeQL's data-flow analysis tracing an OAuth bearer into
* a "fast" hash and re-raising `js/insufficient-password-hash`, even though
* the value is high-entropy and the output never leaves the process.
* bcrypt/scrypt/argon2 are the wrong tool here (they slow down brute-force
* of low-entropy human passwords we do not have).
*
* We instead key the in-memory `sessionPool` by the token itself. The token
* already lives in this process — embedded in `CopilotSession.cookies` for
* every entry — so this exposes nothing the runtime did not already hold.
* The map is capped at MAX_POOL_SIZE with LRU eviction, so memory remains
* bounded regardless of how many distinct tokens appear.
*
* See docs/security/PUBLIC_CREDS.md for the broader credential-handling
* pattern.
*/
export function sessionPoolKey(token?: string): string {
return token && token.length > 0 ? token : "anonymous";
}
// ─── Session Management ─────────────────────────────────────────────────────
interface CopilotSession {
conversationId: string;
cookies: string;
remainingTurns: number;
isBlocked: boolean;
createdAt: number;
}
// Shared session pool across all executor instances (singleton)
const sessionPool = new Map<string, CopilotSession>();
let sessionRotationCount = 0;
const MIN_REMAINING_TURNS = 5;
const MAX_ROTATIONS = 1000;
const MAX_POOL_SIZE = 100;
// ─── Executor ───────────────────────────────────────────────────────────────
export class CopilotWebExecutor extends BaseExecutor {
constructor() {
super("copilot-web", { id: "copilot-web", baseUrl: COPILOT_START_URL });
}
/**
* Get or create a session. Rotates when remainingTurns is low or blocked.
*/
private async getSession(accessToken?: string, signal?: AbortSignal): Promise<CopilotSession> {
const poolKey = sessionPoolKey(accessToken);
const existing = sessionPool.get(poolKey);
if (
existing &&
!existing.isBlocked &&
existing.remainingTurns > MIN_REMAINING_TURNS &&
Date.now() - existing.createdAt < 3_600_000 // 1 hour max session age
) {
return existing;
}
// Create new session (rotate)
if (sessionRotationCount >= MAX_ROTATIONS) {
// Reset counter after max rotations (prevent memory leak)
sessionRotationCount = 0;
}
const session = await this.createSession(accessToken, signal);
// Evict oldest entry if pool is at capacity (Map preserves insertion order)
if (sessionPool.size >= MAX_POOL_SIZE) {
sessionPool.delete(sessionPool.keys().next().value!);
}
sessionPool.set(poolKey, session);
sessionRotationCount++;
return session;
}
/**
* Create a fresh session with new cookies and conversationId.
*/
private async createSession(accessToken?: string, signal?: AbortSignal): Promise<CopilotSession> {
const headers: Record<string, string> = {
"Content-Type": "application/json",
"User-Agent": COPILOT_USER_AGENT,
Origin: COPILOT_BASE,
Referer: `${COPILOT_BASE}/`,
};
if (accessToken) {
headers["Authorization"] = `Bearer ${accessToken}`;
}
const res = await fetch(COPILOT_START_URL, {
method: "POST",
headers,
body: JSON.stringify({
timeZone: "America/New_York",
startNewConversation: true,
teenSupportEnabled: false,
}),
signal,
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`Copilot /c/api/start failed (${res.status}): ${text.slice(0, 200)}`);
}
const data = (await res.json()) as CopilotStartResponse;
const convId = data.currentConversationId || data.conversationId;
if (!convId) {
throw new Error("Copilot /c/api/start returned no conversationId");
}
// Extract cookies from response
const setCookies = res.headers.getSetCookie();
const cookies = setCookies.map((c) => c.split(";")[0]).join("; ");
return {
conversationId: convId,
cookies,
remainingTurns: data.remainingTurns ?? 1000,
isBlocked: data.isBlocked ?? false,
createdAt: Date.now(),
};
}
/**
* Send a message via WebSocket and collect the streamed response.
*/
private async wsChat(
conversationId: string,
prompt: string,
mode: string,
accessToken?: string,
signal?: AbortSignal
): Promise<ReadableStream<Uint8Array>> {
// Build WebSocket URL without credentials in query string
const wsUrl = `${COPILOT_WS_URL}&clientSessionId=${crypto.randomUUID()}`;
return new ReadableStream(
{
start: async (controller) => {
const encoder = new TextEncoder();
let ws: WebSocket | null = null;
let settled = false;
const cleanup = () => {
if (ws) {
try {
ws.close();
} catch {
/* ignore */
}
ws = null;
}
};
const finish = () => {
if (settled) return;
settled = true;
cleanup();
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
controller.close();
};
const abort = (reason?: string) => {
if (settled) return;
settled = true;
cleanup();
if (reason) {
controller.enqueue(
encoder.encode(`data: ${JSON.stringify({ error: { message: reason } })}\n\n`)
);
}
controller.close();
};
// Handle upstream abort signal
signal?.addEventListener("abort", () => abort("Request aborted"), { once: true });
try {
// Use Node.js built-in WebSocket if available, else dynamic import.
// Pass the access token via Authorization header (not URL) to avoid
// credential exposure in server logs.
let WS = globalThis.WebSocket;
if (!WS) {
// @ts-ignore — ws module has no type declarations in this project
WS = (await import("ws")).default as unknown as typeof WebSocket;
if (accessToken) {
// @ts-ignore — ws module supports headers option in second arg
ws = new WS(wsUrl, {
headers: { Authorization: `Bearer ${accessToken}` },
}) as WebSocket;
}
}
if (!ws) {
ws = new WS(wsUrl) as WebSocket;
}
const timeout = setTimeout(() => abort("Copilot WebSocket timeout"), FETCH_TIMEOUT_MS);
let chatSent = false;
const sendChat = () => {
if (chatSent) return;
chatSent = true;
ws!.send(
JSON.stringify({
event: "send",
conversationId,
content: [{ type: "text", text: prompt }],
mode,
})
);
};
ws.onopen = () => {
sendChat();
};
ws.onmessage = (ev: MessageEvent) => {
try {
const event: CopilotWsEvent =
typeof ev.data === "string" ? JSON.parse(ev.data) : JSON.parse(String(ev.data));
switch (event.event) {
case "challenge": {
if (event.method === "hashcash" && event.parameter) {
const parts = String(event.parameter).split(":");
const param = parts[0];
const difficulty = parseInt(parts[1] || "1", 10);
const solution = solveHashcash(param, difficulty);
ws!.send(
JSON.stringify({
event: "challengeResponse",
token: solution !== null ? String(solution) : "",
method: "hashcash",
})
);
// Re-send chat after solving challenge
chatSent = false;
sendChat();
} else if (event.method === "cloudflare") {
abort(
"Copilot requires Cloudflare Turnstile verification. Use an authenticated session (access_token) instead."
);
} else {
abort(
`Copilot challenge "${event.method}" not supported. Use an authenticated session.`
);
}
break;
}
case "appendText": {
if (event.text) {
const chunk = {
id: `chatcmpl-copilot-${Date.now()}`,
object: "chat.completion.chunk",
created: Math.floor(Date.now() / 1000),
model: "copilot",
choices: [
{
index: 0,
delta: { content: event.text },
finish_reason: null,
},
],
};
controller.enqueue(encoder.encode(`data: ${JSON.stringify(chunk)}\n\n`));
}
break;
}
case "chainOfThought": {
if (event.text) {
const chunk = {
id: `chatcmpl-copilot-${Date.now()}`,
object: "chat.completion.chunk",
created: Math.floor(Date.now() / 1000),
model: "copilot",
choices: [
{
index: 0,
delta: { reasoning_content: event.text },
finish_reason: null,
},
],
};
controller.enqueue(encoder.encode(`data: ${JSON.stringify(chunk)}\n\n`));
}
break;
}
case "replaceText": {
if (event.text) {
const chunk = {
id: `chatcmpl-copilot-${Date.now()}`,
object: "chat.completion.chunk",
created: Math.floor(Date.now() / 1000),
model: "copilot",
choices: [
{
index: 0,
delta: { content: event.text },
finish_reason: null,
},
],
};
controller.enqueue(encoder.encode(`data: ${JSON.stringify(chunk)}\n\n`));
}
break;
}
case "imageGenerated": {
if (event.url) {
const chunk = {
id: `chatcmpl-copilot-${Date.now()}`,
object: "chat.completion.chunk",
created: Math.floor(Date.now() / 1000),
model: "copilot",
choices: [
{
index: 0,
delta: {
content: [
{
type: "image_url",
image_url: { url: event.url, detail: "auto" },
},
],
},
finish_reason: null,
},
],
};
controller.enqueue(encoder.encode(`data: ${JSON.stringify(chunk)}\n\n`));
}
break;
}
case "citation": {
if (event.url) {
const annotation = {
type: "url_citation",
url_citation: {
url: event.url,
title: event.title || event.url,
},
};
const chunk = {
id: `chatcmpl-copilot-${Date.now()}`,
object: "chat.completion.chunk",
created: Math.floor(Date.now() / 1000),
model: "copilot",
choices: [
{
index: 0,
delta: { annotations: [annotation] },
finish_reason: null,
},
],
};
controller.enqueue(encoder.encode(`data: ${JSON.stringify(chunk)}\n\n`));
}
break;
}
case "suggestedFollowups": {
if (event.suggestions && Array.isArray(event.suggestions)) {
const chunk = {
id: `chatcmpl-copilot-${Date.now()}`,
object: "chat.completion.chunk",
created: Math.floor(Date.now() / 1000),
model: "copilot",
choices: [
{
index: 0,
delta: {
content: `\n\n**Suggested follow-ups:**\n${event.suggestions.map((s: string) => `- ${s}`).join("\n")}`,
},
finish_reason: null,
},
],
};
controller.enqueue(encoder.encode(`data: ${JSON.stringify(chunk)}\n\n`));
}
break;
}
case "done": {
clearTimeout(timeout);
const finalChunk = {
id: `chatcmpl-copilot-${Date.now()}`,
object: "chat.completion.chunk",
created: Math.floor(Date.now() / 1000),
model: "copilot",
choices: [
{
index: 0,
delta: {},
finish_reason: "stop",
},
],
};
controller.enqueue(encoder.encode(`data: ${JSON.stringify(finalChunk)}\n\n`));
finish();
break;
}
case "error": {
clearTimeout(timeout);
abort(event.error || "Copilot stream error");
break;
}
// Ignore other events: connected, received, citation, etc.
default:
break;
}
} catch {
// Skip unparseable messages
}
};
ws.onerror = (err: Event) => {
clearTimeout(timeout);
const msg = (err as ErrorEvent).message || "Copilot WebSocket error";
abort(msg);
};
ws.onclose = () => {
clearTimeout(timeout);
finish();
};
} catch (err) {
abort(err instanceof Error ? err.message : "Failed to connect to Copilot");
}
},
},
{ highWaterMark: 16384 }
);
}
/**
* Main execute method — translates OpenAI format to Copilot WebSocket protocol.
*/
async execute(input: ExecuteInput): Promise<{
response: Response;
url: string;
headers: Record<string, string>;
transformedBody: unknown;
}> {
const { credentials, signal, model: inputModel, stream: inputStream } = input;
const body = input.body as Record<string, unknown> | undefined;
const model = inputModel || (body?.model as string) || "copilot";
const mode = getCopilotMode(model);
const stream = inputStream !== false; // Default to streaming
// Extract access token from credentials
const rawCred =
credentials?.apiKey || (credentials?.providerSpecificData?.cookie as string) || "";
const accessToken = extractAccessToken(rawCred);
// Extract prompt from messages
const messages = (body?.messages as Array<Record<string, unknown>>) || [];
const userMsg = messages.filter((m) => m.role === "user").pop();
const systemMsgs = messages.filter((m) => m.role === "system");
const prompt = (userMsg?.content as string) || "";
if (!prompt || (typeof prompt === "string" && !prompt.trim())) {
return {
response: new Response(JSON.stringify({ error: { message: "No user message provided" } }), {
status: 400,
headers: { "Content-Type": "application/json" },
}),
url: COPILOT_START_URL,
headers: {},
transformedBody: null,
};
}
// Build full prompt with system instructions
let fullPrompt = "";
if (systemMsgs.length > 0) {
const sysText = systemMsgs
.map((m) => (typeof m.content === "string" ? m.content : ""))
.filter(Boolean)
.join("\n");
if (sysText) fullPrompt += `[System Instructions]\n${sysText}\n\n`;
}
fullPrompt += typeof prompt === "string" ? prompt : JSON.stringify(prompt);
// Get or create session (auto-rotates when turns exhausted)
let conversationId: string;
let sessionCookies = "";
try {
const session = await this.getSession(accessToken || undefined, signal);
conversationId = session.conversationId;
sessionCookies = session.cookies;
} catch (err) {
const msg = sanitizeErrorMessage(
err instanceof Error ? err.message : "Failed to start Copilot conversation"
);
return {
response: new Response(JSON.stringify({ error: { message: msg } }), {
status: 502,
headers: { "Content-Type": "application/json" },
}),
url: COPILOT_START_URL,
headers: accessToken ? { Authorization: `Bearer ${accessToken.slice(0, 20)}...` } : {},
transformedBody: { conversationId: null, mode, prompt: fullPrompt.slice(0, 100) },
};
}
// Non-streaming: collect all chunks and return as single response
if (!stream) {
try {
const wsStream = await this.wsChat(
conversationId,
fullPrompt,
mode,
accessToken || undefined,
signal
);
const reader = wsStream.getReader();
const decoder = new TextDecoder();
let fullText = "";
let reasoningText = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
const lines = decoder.decode(value, { stream: true }).split("\n");
for (const line of lines) {
if (!line.startsWith("data: ")) continue;
const data = line.slice(6).trim();
if (data === "[DONE]") continue;
try {
const parsed = JSON.parse(data);
const delta = parsed.choices?.[0]?.delta;
if (delta?.content) fullText += delta.content;
if (delta?.reasoning_content) reasoningText += delta.reasoning_content;
} catch {
/* skip */
}
}
}
const result = {
id: `chatcmpl-copilot-${Date.now()}`,
object: "chat.completion",
created: Math.floor(Date.now() / 1000),
model,
choices: [
{
index: 0,
message: { role: "assistant", content: fullText || "(empty response)" },
finish_reason: "stop",
},
],
usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
};
return {
response: new Response(JSON.stringify(result), {
headers: { "Content-Type": "application/json" },
}),
url: COPILOT_WS_URL,
headers: {},
transformedBody: { conversationId, mode, prompt: fullPrompt.slice(0, 100) },
};
} catch (err) {
const msg = err instanceof Error ? err.message : "Copilot non-streaming error";
return {
response: new Response(JSON.stringify({ error: { message: msg } }), {
status: 502,
headers: { "Content-Type": "application/json" },
}),
url: COPILOT_WS_URL,
headers: {},
transformedBody: { conversationId, mode },
};
}
}
// Streaming: pipe WebSocket events as SSE
try {
const wsStream = await this.wsChat(
conversationId,
fullPrompt,
mode,
accessToken || undefined,
signal
);
return {
response: new Response(wsStream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
}),
url: COPILOT_WS_URL,
headers: {},
transformedBody: { conversationId, mode, prompt: fullPrompt.slice(0, 100) },
};
} catch (err) {
const msg = err instanceof Error ? err.message : "Copilot streaming error";
return {
response: new Response(JSON.stringify({ error: { message: msg } }), {
status: 502,
headers: { "Content-Type": "application/json" },
}),
url: COPILOT_WS_URL,
headers: {},
transformedBody: { conversationId, mode },
};
}
}
}