From 602022ce790613a591aeec01330a2510a3aa4ec3 Mon Sep 17 00:00:00 2001 From: Wenhao Date: Fri, 16 Oct 2026 22:06:42 +0800 Subject: [PATCH 1/2] fix: restore embedded Grok conversations and cookie consent --- PRIVACY.md | 5 +- background/service-worker.js | 52 ++++- content-scripts/enter-behavior-grok.js | 8 + content-scripts/grok-cookie-consent.js | 46 ++++ manifest.json | 13 ++ modules/grok-websocket-cookies.js | 289 +++++++++++++++++++++++++ multi-panel/multi-panel.js | 13 ++ tests/e2e/grok-websocket.e2e.test.js | 197 +++++++++++++++++ tests/grok-websocket-cookies.test.js | 282 ++++++++++++++++++++++++ 9 files changed, 901 insertions(+), 4 deletions(-) create mode 100644 content-scripts/grok-cookie-consent.js create mode 100644 modules/grok-websocket-cookies.js create mode 100644 tests/e2e/grok-websocket.e2e.test.js create mode 100644 tests/grok-websocket-cookies.test.js diff --git a/PRIVACY.md b/PRIVACY.md index 3df511a..62c6508 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -79,7 +79,9 @@ When you interact with these AI providers through Panelize, you are subject to t ### Cookie Access -Panelize does not access cookie data directly and does not use the cookies API. Provider websites may use your existing browser session cookies when they load inside panels, just like in a normal tab. +Provider websites use your existing browser session cookies when they load inside panels. Because Chromium omits `SameSite=Lax` and `SameSite=Strict` cookies on WebSocket handshakes initiated from embedded extension panels, Panelize uses the `cookies` API locally in your browser to forward existing `grok.com` session cookies to Grok's own `wss://grok.com/*` WebSocket requests inside Panelize tabs. The forwarding rule lasts only for the browser session; cookie values are not saved in Panelize's settings or sent to Panelize-operated servers or unrelated third parties. + +Panelize also preserves your existing Grok cookie consent choices by setting the `OptanonAlertBoxClosed` and `OptanonConsent` cookies to `SameSite=None; Secure`, allowing Grok to read and save those choices inside its embedded panel. This preserves the choice you make in Grok's cookie controls and does not automatically accept cookies on your behalf. ### Update Checks @@ -155,6 +157,7 @@ Panelize requests the following Chrome extension permissions: | Permission | Purpose | |------------|---------| | `storage` | Store your prompts and settings locally | +| `cookies` | Attach existing `grok.com` session cookies to Grok's embedded WebSocket connections and preserve your Grok cookie consent choices inside Panelize | | `contextMenus` | Add "Send to Panelize" option when right-clicking | | `declarativeNetRequest` | Allow AI provider websites to load in embedded panels (bypass X-Frame-Options) | | `declarativeNetRequestWithHostAccess` | Apply header modifications for specific AI provider domains | diff --git a/background/service-worker.js b/background/service-worker.js index d3dd37d..89c53d8 100644 --- a/background/service-worker.js +++ b/background/service-worker.js @@ -5,6 +5,11 @@ import { removeDeprecatedClaudeModelMode } from '../modules/settings.js'; import { reconcileOptionalProviderAccess } from '../modules/optional-provider-access.js'; +import { + isGrokCookieDomain, + reconcileGrokWebSocketCookies, + unregisterMultiPanelTabId +} from '../modules/grok-websocket-cookies.js'; // Install event - setup context menus const DEFAULT_SHORTCUT_SETTING = { keyboardShortcutEnabled: true }; @@ -70,24 +75,28 @@ async function openMultiPanel() { if (tab.url === multiPanelUrl) { // 已有窗口,聚焦它 await chrome.windows.update(win.id, { focused: true }); + await scheduleGrokWebSocketCookieReconcile({ tabId: tab.id }); return; } } } // 创建新弹出窗口 - await chrome.windows.create({ + const createdWindow = await chrome.windows.create({ url: multiPanelUrl, type: 'popup', width: 1400, height: 900 }); + const popupTabId = createdWindow?.tabs?.[0]?.id; + await scheduleGrokWebSocketCookieReconcile({ tabId: popupTabId }); } else { // 标签页模式:始终创建新标签页 - await chrome.tabs.create({ + const createdTab = await chrome.tabs.create({ url: multiPanelUrl, active: true }); + await scheduleGrokWebSocketCookieReconcile({ tabId: createdTab?.id }); } } @@ -115,6 +124,7 @@ async function migrateProviderSettingsForUpdate(details) { } let optionalProviderReconcileQueue = Promise.resolve(); +let grokWebSocketCookieReconcileQueue = Promise.resolve(); function scheduleOptionalProviderReconcile() { optionalProviderReconcileQueue = optionalProviderReconcileQueue @@ -126,12 +136,23 @@ function scheduleOptionalProviderReconcile() { return optionalProviderReconcileQueue; } +function scheduleGrokWebSocketCookieReconcile(options = {}) { + grokWebSocketCookieReconcileQueue = grokWebSocketCookieReconcileQueue + .then(() => reconcileGrokWebSocketCookies(options)) + .catch((error) => { + console.error('[Background] Grok WebSocket cookie reconciliation queue failed:', error); + }); + + return grokWebSocketCookieReconcileQueue; +} + chrome.runtime.onInstalled.addListener(async (details) => { await migrateProviderSettingsForUpdate(details); if (details?.reason === 'update') { await removeDeprecatedClaudeModelMode(); } await scheduleOptionalProviderReconcile(); + await scheduleGrokWebSocketCookieReconcile(); await createContextMenus(); await loadShortcutSetting(); await loadOpenModeSetting(); @@ -139,6 +160,7 @@ chrome.runtime.onInstalled.addListener(async (details) => { chrome.runtime.onStartup.addListener(async () => { await scheduleOptionalProviderReconcile(); + await scheduleGrokWebSocketCookieReconcile(); await loadShortcutSetting(); await loadOpenModeSetting(); }); @@ -172,9 +194,25 @@ chrome.storage.onChanged.addListener((changes, namespace) => { if (namespace === 'sync' && changes.enabledProviders) { scheduleOptionalProviderReconcile(); + scheduleGrokWebSocketCookieReconcile(); } }); +if (chrome.cookies?.onChanged) { + chrome.cookies.onChanged.addListener((changeInfo) => { + if (isGrokCookieDomain(changeInfo?.cookie?.domain)) { + scheduleGrokWebSocketCookieReconcile(); + } + }); +} + +if (chrome.tabs?.onRemoved) { + chrome.tabs.onRemoved.addListener((tabId) => { + unregisterMultiPanelTabId(tabId); + scheduleGrokWebSocketCookieReconcile(); + }); +} + if (chrome.permissions?.onAdded) { chrome.permissions.onAdded.addListener(() => { scheduleOptionalProviderReconcile(); @@ -295,7 +333,15 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { handleFetchLatestCommit().then(sendResponse); return true; // Keep channel open for async response } - return true; + + if (message.action === 'syncGrokWebSocketCookies') { + scheduleGrokWebSocketCookieReconcile({ tabId: sender?.tab?.id }) + .then(() => sendResponse({ success: true })) + .catch((error) => sendResponse({ success: false, error: error.message })); + return true; + } + + return false; }); // Handle version check by fetching latest commit from GitHub API diff --git a/content-scripts/enter-behavior-grok.js b/content-scripts/enter-behavior-grok.js index 6149043..1e02e0f 100644 --- a/content-scripts/enter-behavior-grok.js +++ b/content-scripts/enter-behavior-grok.js @@ -169,3 +169,11 @@ function handleEnterSwap(event) { // Apply the setting on initial load applyEnterSwapSetting(); + +if (window.top !== window.self && typeof chrome !== 'undefined' && chrome.runtime?.sendMessage) { + try { + Promise.resolve(chrome.runtime.sendMessage({ action: 'syncGrokWebSocketCookies' })).catch(() => {}); + } catch { + // Ignore extension context errors + } +} diff --git a/content-scripts/grok-cookie-consent.js b/content-scripts/grok-cookie-consent.js new file mode 100644 index 0000000..23ce5de --- /dev/null +++ b/content-scripts/grok-cookie-consent.js @@ -0,0 +1,46 @@ +// Ensure OneTrust cookie consent choices (OptanonAlertBoxClosed, OptanonConsent) +// written via document.cookie inside an embedded Grok iframe use SameSite=None; Secure +// so Chromium's cross-site script cookie check accepts and persists them. +(() => { + if (window.top === window.self) { + return; + } + + const OPTANON_COOKIE_PATTERN = /^\s*Optanon(?:AlertBoxClosed|Consent)=/; + + function rewriteGrokConsentCookieString(cookieString) { + if (typeof cookieString !== 'string' || !OPTANON_COOKIE_PATTERN.test(cookieString)) { + return cookieString; + } + + if (typeof location !== 'undefined' && location.protocol !== 'https:') { + return cookieString; + } + + let rewritten = cookieString.replace(/;\s*SameSite=(?:Lax|Strict)/gi, '; SameSite=None'); + if (!/;\s*SameSite=None\b/i.test(rewritten)) { + rewritten += '; SameSite=None'; + } + if (!/;\s*Secure\b/i.test(rewritten)) { + rewritten += '; Secure'; + } + return rewritten; + } + + const descriptor = Object.getOwnPropertyDescriptor(Document.prototype, 'cookie') || + Object.getOwnPropertyDescriptor(HTMLDocument.prototype, 'cookie'); + if (!descriptor || typeof descriptor.get !== 'function' || typeof descriptor.set !== 'function') { + return; + } + + Object.defineProperty(Document.prototype, 'cookie', { + configurable: true, + enumerable: descriptor.enumerable ?? true, + get() { + return descriptor.get.call(this); + }, + set(value) { + return descriptor.set.call(this, rewriteGrokConsentCookieString(value)); + } + }); +})(); diff --git a/manifest.json b/manifest.json index 92cfcc8..5f166f2 100644 --- a/manifest.json +++ b/manifest.json @@ -6,6 +6,7 @@ "default_locale": "en", "permissions": [ "storage", + "cookies", "contextMenus", "activeTab", "scripting", @@ -18,6 +19,7 @@ "*://claude.ai/*", "*://gemini.google.com/*", "*://grok.com/*", + "wss://grok.com/*", "*://chat.deepseek.com/*", "*://www.kimi.com/*", "*://kimi.com/*", @@ -114,6 +116,17 @@ "run_at": "document_start", "all_frames": true }, + { + "matches": [ + "https://grok.com/*" + ], + "js": [ + "content-scripts/grok-cookie-consent.js" + ], + "run_at": "document_start", + "all_frames": true, + "world": "MAIN" + }, { "matches": [ "https://grok.com/*" diff --git a/modules/grok-websocket-cookies.js b/modules/grok-websocket-cookies.js new file mode 100644 index 0000000..d257cc9 --- /dev/null +++ b/modules/grok-websocket-cookies.js @@ -0,0 +1,289 @@ +import { DEFAULT_PROVIDER_IDS } from './provider-defaults.js'; + +export const GROK_PROVIDER_ID = 'grok'; +export const GROK_COOKIE_URL = 'https://grok.com/'; +export const GROK_DOMAIN = 'grok.com'; +export const GROK_WEBSOCKET_COOKIE_RULE_ID = 2001; +export const MULTI_PANEL_PAGE_PATH = 'multi-panel/multi-panel.html'; +export const GROK_CONSENT_COOKIE_NAMES = Object.freeze([ + 'OptanonAlertBoxClosed', + 'OptanonConsent' +]); + +const registeredMultiPanelTabIds = new Set(); + +function isValidTabId(tabId) { + return Number.isInteger(tabId) && tabId >= 0; +} + +export function registerMultiPanelTabId(tabId) { + if (isValidTabId(tabId)) { + registeredMultiPanelTabIds.add(tabId); + } +} + +export function unregisterMultiPanelTabId(tabId) { + if (isValidTabId(tabId)) { + registeredMultiPanelTabIds.delete(tabId); + } +} + +export function clearRegisteredMultiPanelTabIds() { + registeredMultiPanelTabIds.clear(); +} + +export function isGrokCookieDomain(domain) { + if (typeof domain !== 'string' || domain.length === 0) { + return false; + } + + const normalized = domain.startsWith('.') + ? domain.slice(1).toLowerCase() + : domain.toLowerCase(); + return normalized === GROK_DOMAIN || normalized.endsWith(`.${GROK_DOMAIN}`); +} + +/** + * Formats Grok cookies that Chromium omits from cross-site WebSocket handshakes + * inside extension iframes. SameSite=None cookies (including partitioned CHIPS + * cookies such as cf_clearance) are already attached by Chromium, so only + * SameSite=Lax, SameSite=Strict, and unspecified cookies are appended. + */ +export function formatGrokWebSocketCookieHeader(cookies) { + if (!Array.isArray(cookies)) { + return ''; + } + + return cookies + .filter((cookie) => ( + cookie && + typeof cookie.name === 'string' && + cookie.name.length > 0 && + typeof cookie.value === 'string' && + cookie.sameSite !== 'no_restriction' && + !cookie.partitionKey + )) + .map((cookie) => `${cookie.name}=${cookie.value}`) + .join('; '); +} + +export function createGrokWebSocketCookieRule(cookieHeaderValue, tabIds) { + const normalizedTabIds = [...new Set( + (Array.isArray(tabIds) ? tabIds : []).filter(isValidTabId) + )].sort((a, b) => a - b); + + return { + id: GROK_WEBSOCKET_COOKIE_RULE_ID, + priority: 1, + action: { + type: 'modifyHeaders', + requestHeaders: [ + { + header: 'Cookie', + operation: 'append', + value: cookieHeaderValue + } + ] + }, + condition: { + requestDomains: [GROK_DOMAIN], + initiatorDomains: [GROK_DOMAIN], + resourceTypes: ['websocket'], + tabIds: normalizedTabIds + } + }; +} + +export async function getMultiPanelTabIds(extraTabId = null) { + if (isValidTabId(extraTabId)) { + registeredMultiPanelTabIds.add(extraTabId); + } + + if (!isValidTabId(extraTabId) && typeof chrome !== 'undefined' && chrome.tabs?.getCurrent) { + try { + const currentTab = await chrome.tabs.getCurrent(); + if (isValidTabId(currentTab?.id)) { + registeredMultiPanelTabIds.add(currentTab.id); + } + } catch (error) { + // Ignore when called outside a tab context such as the background service worker + } + } + + if (typeof chrome !== 'undefined' && chrome.runtime?.getContexts && chrome.runtime?.getURL) { + try { + const multiPanelUrl = chrome.runtime.getURL(MULTI_PANEL_PAGE_PATH); + const contexts = await chrome.runtime.getContexts({ contextTypes: ['TAB'] }); + if (Array.isArray(contexts)) { + for (const context of contexts) { + if ( + typeof context?.documentUrl === 'string' && + context.documentUrl.startsWith(multiPanelUrl) && + isValidTabId(context.tabId) + ) { + registeredMultiPanelTabIds.add(context.tabId); + } + } + } + } catch (error) { + // Fallback to in-memory registered tab IDs if getContexts fails + } + } + + return [...registeredMultiPanelTabIds].sort((a, b) => a - b); +} + +function isSameRule(leftRule, rightRule) { + if (!leftRule || !rightRule) { + return false; + } + + const leftHeader = leftRule.action?.requestHeaders?.[0]; + const rightHeader = rightRule.action?.requestHeaders?.[0]; + const leftTabIds = leftRule.condition?.tabIds || []; + const rightTabIds = rightRule.condition?.tabIds || []; + + return ( + leftRule.id === rightRule.id && + leftRule.priority === rightRule.priority && + leftRule.action?.type === rightRule.action?.type && + leftHeader?.header === rightHeader?.header && + leftHeader?.operation === rightHeader?.operation && + leftHeader?.value === rightHeader?.value && + leftTabIds.length === rightTabIds.length && + leftTabIds.every((tabId, index) => tabId === rightTabIds[index]) + ); +} + +export async function syncGrokWebSocketCookies({ + enabledProviderIds = DEFAULT_PROVIDER_IDS, + tabIds = [], + cookies = [] +} = {}) { + if ( + typeof chrome === 'undefined' || + !chrome.declarativeNetRequest?.getSessionRules || + !chrome.declarativeNetRequest?.updateSessionRules + ) { + return; + } + + const grokEnabled = Array.isArray(enabledProviderIds) && enabledProviderIds.includes(GROK_PROVIDER_ID); + const normalizedTabIds = [...new Set( + (Array.isArray(tabIds) ? tabIds : []).filter(isValidTabId) + )].sort((a, b) => a - b); + const cookieHeaderValue = grokEnabled && normalizedTabIds.length > 0 + ? formatGrokWebSocketCookieHeader(cookies) + : ''; + const desiredRule = cookieHeaderValue + ? createGrokWebSocketCookieRule(cookieHeaderValue, normalizedTabIds) + : null; + + const sessionRules = await chrome.declarativeNetRequest.getSessionRules(); + const existingRule = sessionRules.find(({ id }) => id === GROK_WEBSOCKET_COOKIE_RULE_ID) || null; + + if (!desiredRule) { + if (existingRule) { + await chrome.declarativeNetRequest.updateSessionRules({ + removeRuleIds: [GROK_WEBSOCKET_COOKIE_RULE_ID], + addRules: [] + }); + } + return; + } + + if (isSameRule(existingRule, desiredRule)) { + return; + } + + await chrome.declarativeNetRequest.updateSessionRules({ + removeRuleIds: existingRule ? [GROK_WEBSOCKET_COOKIE_RULE_ID] : [], + addRules: [desiredRule] + }); +} + +export async function upgradeGrokConsentCookies(cookies) { + if (!Array.isArray(cookies) || typeof chrome === 'undefined' || !chrome.cookies?.set) { + return cookies; + } + + const updatedCookies = []; + for (const cookie of cookies) { + if ( + cookie && + GROK_CONSENT_COOKIE_NAMES.includes(cookie.name) && + !cookie.partitionKey && + (cookie.sameSite !== 'no_restriction' || !cookie.secure) + ) { + const details = { + url: GROK_COOKIE_URL, + name: cookie.name, + value: cookie.value, + path: cookie.path || '/', + secure: true, + httpOnly: Boolean(cookie.httpOnly), + sameSite: 'no_restriction' + }; + if (!cookie.hostOnly && cookie.domain) { + details.domain = cookie.domain; + } + if (typeof cookie.expirationDate === 'number') { + details.expirationDate = cookie.expirationDate; + } + if (typeof cookie.storeId === 'string') { + details.storeId = cookie.storeId; + } + + try { + await chrome.cookies.set(details); + updatedCookies.push({ + ...cookie, + secure: true, + sameSite: 'no_restriction' + }); + continue; + } catch (error) { + // Keep original cookie entry if chrome.cookies.set fails + } + } + + updatedCookies.push(cookie); + } + + return updatedCookies; +} + +export async function reconcileGrokWebSocketCookies({ tabId = null } = {}) { + try { + if ( + typeof chrome === 'undefined' || + !chrome.declarativeNetRequest?.getSessionRules || + !chrome.declarativeNetRequest?.updateSessionRules + ) { + return; + } + + const settings = await chrome.storage.sync.get({ + enabledProviders: DEFAULT_PROVIDER_IDS + }); + const enabledProviderIds = Array.isArray(settings.enabledProviders) + ? settings.enabledProviders + : DEFAULT_PROVIDER_IDS; + const tabIds = await getMultiPanelTabIds(tabId); + const grokEnabled = enabledProviderIds.includes(GROK_PROVIDER_ID); + const rawCookies = grokEnabled && tabIds.length > 0 && chrome.cookies?.getAll + ? await chrome.cookies.getAll({ url: GROK_COOKIE_URL }) + : []; + const cookies = grokEnabled && tabIds.length > 0 + ? await upgradeGrokConsentCookies(rawCookies) + : rawCookies; + + await syncGrokWebSocketCookies({ + enabledProviderIds, + tabIds, + cookies + }); + } catch (error) { + console.error('[Background] Failed to synchronize Grok WebSocket cookies:', error); + } +} diff --git a/multi-panel/multi-panel.js b/multi-panel/multi-panel.js index 6ec59fe..cf32c42 100644 --- a/multi-panel/multi-panel.js +++ b/multi-panel/multi-panel.js @@ -7,6 +7,7 @@ import { PROVIDERS, getProviderById, getEnabledProviders, getProviderIcon } from '../modules/providers.js'; import { DEFAULT_PROVIDER_IDS } from '../modules/provider-defaults.js'; +import { reconcileGrokWebSocketCookies } from '../modules/grok-websocket-cookies.js'; import { DEFAULT_GOOGLE_PROVIDER_MODE, GOOGLE_PROVIDER_MODE_AI, @@ -244,6 +245,10 @@ function isChatgptProvider(providerId) { return providerId === 'chatgpt'; } +async function ensureGrokWebSocketCookiesSynced() { + await reconcileGrokWebSocketCookies(); +} + function isTemporaryChatSupportedProvider(providerId) { return TEMP_CHAT_SUPPORTED_PROVIDERS.has(providerId); } @@ -1194,6 +1199,10 @@ async function addPanel(providerId) { return; } + if (providerId === 'grok') { + await ensureGrokWebSocketCookiesSynced(); + } + const panelId = `panel-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; const panelGrid = document.getElementById('panel-grid'); @@ -1322,6 +1331,10 @@ async function switchPanelProvider(panelId, newProviderId) { syncGoogleModeControls(); } + if (newProviderId === 'grok') { + await ensureGrokWebSocketCookiesSynced(); + } + // Update panel header const headerIcon = panelEl.querySelector('.panel-header-left img'); const headerName = panelEl.querySelector('.panel-header-left span'); diff --git a/tests/e2e/grok-websocket.e2e.test.js b/tests/e2e/grok-websocket.e2e.test.js new file mode 100644 index 0000000..6a5b96a --- /dev/null +++ b/tests/e2e/grok-websocket.e2e.test.js @@ -0,0 +1,197 @@ +import { test, expect } from '@playwright/test'; +import { createHash } from 'node:crypto'; +import http from 'node:http'; +import { + createPatchedExtensionCopy, + launchExtension, + openMultiPanel, +} from './extension-harness.js'; + +const WEBSOCKET_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'; + +function buildWebSocketTextFrame(text) { + const payload = Buffer.from(text, 'utf8'); + return Buffer.concat([Buffer.from([0x81, payload.length]), payload]); +} + +function startGrokWebSocketFixtureServer() { + const wsHandshakes = []; + const sockets = new Set(); + const server = http.createServer((request, response) => { + response.writeHead(200, { 'Content-Type': 'text/html' }); + response.end(` +

Connecting

+ + `); + }); + + server.on('connection', (socket) => { + sockets.add(socket); + socket.on('close', () => sockets.delete(socket)); + }); + + server.on('upgrade', (request, socket) => { + sockets.add(socket); + socket.on('close', () => sockets.delete(socket)); + const cookieHeader = request.headers.cookie || ''; + const ssoMatches = cookieHeader.match(/(?:^|;\s*)sso=([^;]+)/g) || []; + const ssoRwMatches = cookieHeader.match(/(?:^|;\s*)sso-rw=([^;]+)/g) || []; + const deviceMatches = cookieHeader.match(/(?:^|;\s*)grok_device_id=([^;]+)/g) || []; + const ssoValue = cookieHeader.match(/(?:^|;\s*)sso=([^;]+)/)?.[1] || null; + const authenticated = Boolean( + ssoValue && + ssoRwMatches.length === 1 && + deviceMatches.length === 1 + ); + + wsHandshakes.push({ + authenticated, + ssoValue, + ssoCount: ssoMatches.length, + ssoRwCount: ssoRwMatches.length, + }); + + if (!authenticated) { + socket.write('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n'); + socket.destroy(); + return; + } + + const acceptKey = createHash('sha1') + .update(`${request.headers['sec-websocket-key']}${WEBSOCKET_GUID}`) + .digest('base64'); + + socket.write([ + 'HTTP/1.1 101 Switching Protocols', + 'Upgrade: websocket', + 'Connection: Upgrade', + `Sec-WebSocket-Accept: ${acceptKey}`, + '\r\n', + ].join('\r\n')); + socket.write(buildWebSocketTextFrame(`OK:${ssoValue}`)); + }); + + return new Promise((resolve) => { + server.listen(0, '127.0.0.1', () => { + resolve({ + port: server.address().port, + wsHandshakes, + close: () => new Promise((closeResolve, closeReject) => { + for (const socket of sockets) { + socket.destroy(); + } + server.close((error) => (error ? closeReject(error) : closeResolve())); + }), + }); + }); + }); +} + +test('connects Grok WebSockets in an embedded panel with SameSite session cookies', async () => { + test.setTimeout(60000); + const server = await startGrokWebSocketFixtureServer(); + const extensionCopy = await createPatchedExtensionCopy((manifest) => { + // Map the wss:// permission to ws:// for the loopback fixture server. + manifest.host_permissions.push('ws://grok.com/*'); + }); + const extension = await launchExtension({ + extensionPath: extensionCopy.extensionPath, + args: [ + '--disable-features=HttpsUpgrades', + '--proxy-bypass-list=*', + '--host-resolver-rules=MAP grok.com 127.0.0.1', + ], + }); + + try { + const pageErrors = []; + extension.context.on('page', (page) => { + page.on('pageerror', (error) => pageErrors.push(error.message)); + }); + + // Grok's real session and device cookies use SameSite=Lax and SameSite=Strict + // with HttpOnly, which Chromium strips on cross-site WebSocket handshakes. + await extension.context.addCookies([ + { + name: 'grok_device_id', + value: 'fixture-device', + domain: '.grok.com', + path: '/', + httpOnly: true, + sameSite: 'Lax', + }, + { + name: 'sso', + value: 'fixture-session-1', + domain: '.grok.com', + path: '/', + httpOnly: true, + sameSite: 'Lax', + }, + { + name: 'sso-rw', + value: 'fixture-session-rw', + domain: '.grok.com', + path: '/', + httpOnly: true, + sameSite: 'Strict', + }, + ]); + + await extension.context.route('https://grok.com/**', (route) => route.fulfill({ + status: 302, + headers: { Location: `http://grok.com:${server.port}/` }, + })); + + // Top-level Grok works natively and must not receive duplicate cookies. + const topLevel = await extension.context.newPage(); + await topLevel.goto(`http://grok.com:${server.port}/`); + await expect(topLevel.locator('#ws-state')).toHaveText('OK:fixture-session-1'); + + // Embedded Grok panel inside Panelize must also establish its WebSocket. + const page = await openMultiPanel(extension, { providers: ['grok'] }); + const panelState = page.frameLocator('iframe').locator('#ws-state'); + await expect(panelState).toHaveText('OK:fixture-session-1'); + + // Rotate the session cookie and verify the updated cookie is used on reload. + await extension.context.addCookies([ + { + name: 'sso', + value: 'fixture-session-2', + domain: '.grok.com', + path: '/', + httpOnly: true, + sameSite: 'Lax', + }, + ]); + + await page.reload(); + await expect(page.frameLocator('iframe').locator('#ws-state')) + .toHaveText('OK:fixture-session-2'); + + // Re-check top-level tab while the Multi-Panel session rule is active. + await topLevel.reload(); + await expect(topLevel.locator('#ws-state')).toHaveText('OK:fixture-session-2'); + + expect(server.wsHandshakes).toEqual([ + { authenticated: true, ssoValue: 'fixture-session-1', ssoCount: 1, ssoRwCount: 1 }, + { authenticated: true, ssoValue: 'fixture-session-1', ssoCount: 1, ssoRwCount: 1 }, + { authenticated: true, ssoValue: 'fixture-session-2', ssoCount: 1, ssoRwCount: 1 }, + { authenticated: true, ssoValue: 'fixture-session-2', ssoCount: 1, ssoRwCount: 1 }, + ]); + expect(pageErrors).toEqual([]); + } finally { + await extension.close(); + await server.close(); + await extensionCopy.cleanup(); + } +}); diff --git a/tests/grok-websocket-cookies.test.js b/tests/grok-websocket-cookies.test.js new file mode 100644 index 0000000..585d82c --- /dev/null +++ b/tests/grok-websocket-cookies.test.js @@ -0,0 +1,282 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + GROK_COOKIE_URL, + GROK_WEBSOCKET_COOKIE_RULE_ID, + clearRegisteredMultiPanelTabIds, + createGrokWebSocketCookieRule, + formatGrokWebSocketCookieHeader, + getMultiPanelTabIds, + isGrokCookieDomain, + reconcileGrokWebSocketCookies, + registerMultiPanelTabId, + syncGrokWebSocketCookies, + unregisterMultiPanelTabId, + upgradeGrokConsentCookies, +} from '../modules/grok-websocket-cookies.js'; + +const manifest = JSON.parse( + readFileSync(resolve(process.cwd(), 'manifest.json'), 'utf8') +); +const grokCookieConsentScript = readFileSync( + resolve(process.cwd(), 'content-scripts/grok-cookie-consent.js'), + 'utf8' +); + +describe('Grok WebSocket cookie synchronization', () => { + beforeEach(() => { + clearRegisteredMultiPanelTabIds(); + chrome.runtime.getURL = vi.fn((path) => `chrome-extension://test-ext-id/${path}`); + chrome.runtime.getContexts = vi.fn(() => Promise.resolve([])); + chrome.tabs.getCurrent = vi.fn(() => Promise.resolve(null)); + chrome.cookies = { + getAll: vi.fn(() => Promise.resolve([])), + set: vi.fn(() => Promise.resolve({})), + }; + chrome.declarativeNetRequest = { + getSessionRules: vi.fn(() => Promise.resolve([])), + updateSessionRules: vi.fn(() => Promise.resolve()), + }; + chrome.storage.sync.get.mockImplementation((defaults) => Promise.resolve(defaults)); + }); + + it('declares cookies and wss://grok.com/* permissions in manifest.json', () => { + expect(manifest.permissions).toContain('cookies'); + expect(manifest.host_permissions).toEqual(expect.arrayContaining([ + '*://grok.com/*', + 'wss://grok.com/*', + ])); + expect(manifest.content_scripts).toContainEqual({ + matches: ['https://grok.com/*'], + js: ['content-scripts/grok-cookie-consent.js'], + run_at: 'document_start', + all_frames: true, + world: 'MAIN', + }); + }); + + it('matches Grok cookie domains and rejects unrelated domains', () => { + expect(isGrokCookieDomain('grok.com')).toBe(true); + expect(isGrokCookieDomain('.grok.com')).toBe(true); + expect(isGrokCookieDomain('assets.grok.com')).toBe(true); + expect(isGrokCookieDomain('chatgpt.com')).toBe(false); + expect(isGrokCookieDomain('notgrok.com')).toBe(false); + expect(isGrokCookieDomain('')).toBe(false); + expect(isGrokCookieDomain(null)).toBe(false); + }); + + it('formats SameSite=Lax, Strict, and unspecified cookies while omitting SameSite=None and partitioned cookies', () => { + const header = formatGrokWebSocketCookieHeader([ + { name: 'grok_device_id', value: 'device-1', sameSite: 'lax' }, + { name: 'sso', value: 'sso-token', sameSite: 'lax', httpOnly: true }, + { name: 'sso-rw', value: 'sso-rw-token', sameSite: 'strict', httpOnly: true }, + { name: '__cuid', value: 'cuid-1', sameSite: 'unspecified' }, + { name: '__cf_bm', value: 'cf-bm', sameSite: 'no_restriction' }, + { + name: 'cf_clearance', + value: 'cf-clearance', + sameSite: 'no_restriction', + partitionKey: { topLevelSite: 'chrome-extension://test-ext-id' }, + }, + ]); + + expect(header).toBe( + 'grok_device_id=device-1; sso=sso-token; sso-rw=sso-rw-token; __cuid=cuid-1' + ); + }); + + it('creates a session rule scoped to Grok WebSockets in Multi-Panel tabs', () => { + expect(createGrokWebSocketCookieRule('sso=token-1; sso-rw=token-2', [42, 7, 42])).toEqual({ + id: GROK_WEBSOCKET_COOKIE_RULE_ID, + priority: 1, + action: { + type: 'modifyHeaders', + requestHeaders: [ + { + header: 'Cookie', + operation: 'append', + value: 'sso=token-1; sso-rw=token-2', + }, + ], + }, + condition: { + requestDomains: ['grok.com'], + initiatorDomains: ['grok.com'], + resourceTypes: ['websocket'], + tabIds: [7, 42], + }, + }); + }); + + it('discovers Multi-Panel tab IDs from extension contexts and current tab', async () => { + chrome.tabs.getCurrent.mockResolvedValue({ id: 15 }); + chrome.runtime.getContexts.mockResolvedValue([ + { + contextType: 'TAB', + documentUrl: 'chrome-extension://test-ext-id/multi-panel/multi-panel.html', + tabId: 8, + }, + { + contextType: 'TAB', + documentUrl: 'chrome-extension://test-ext-id/options/options.html', + tabId: 99, + }, + ]); + + await expect(getMultiPanelTabIds()).resolves.toEqual([8, 15]); + }); + + it('installs and updates the Grok WebSocket session rule when Grok is enabled', async () => { + registerMultiPanelTabId(12); + chrome.cookies.getAll.mockResolvedValue([ + { name: 'sso', value: 'session-a', sameSite: 'lax', httpOnly: true }, + { name: 'sso-rw', value: 'session-rw-a', sameSite: 'strict', httpOnly: true }, + ]); + + await reconcileGrokWebSocketCookies(); + + expect(chrome.cookies.getAll).toHaveBeenCalledWith({ url: GROK_COOKIE_URL }); + expect(chrome.declarativeNetRequest.updateSessionRules).toHaveBeenCalledWith({ + removeRuleIds: [], + addRules: [ + createGrokWebSocketCookieRule('sso=session-a; sso-rw=session-rw-a', [12]), + ], + }); + }); + + it('skips redundant session rule updates when the installed rule already matches', async () => { + const rule = createGrokWebSocketCookieRule('sso=session-a', [12]); + chrome.declarativeNetRequest.getSessionRules.mockResolvedValue([rule]); + + await syncGrokWebSocketCookies({ + enabledProviderIds: ['grok'], + tabIds: [12], + cookies: [{ name: 'sso', value: 'session-a', sameSite: 'lax' }], + }); + + expect(chrome.declarativeNetRequest.updateSessionRules).not.toHaveBeenCalled(); + }); + + it('removes the session rule when Grok is disabled or Multi-Panel tabs close', async () => { + const existingRule = createGrokWebSocketCookieRule('sso=session-a', [12]); + registerMultiPanelTabId(12); + chrome.declarativeNetRequest.getSessionRules.mockResolvedValue([existingRule]); + chrome.storage.sync.get.mockResolvedValue({ enabledProviders: ['chatgpt'] }); + + await reconcileGrokWebSocketCookies(); + + expect(chrome.declarativeNetRequest.updateSessionRules).toHaveBeenCalledWith({ + removeRuleIds: [GROK_WEBSOCKET_COOKIE_RULE_ID], + addRules: [], + }); + + chrome.declarativeNetRequest.updateSessionRules.mockClear(); + chrome.storage.sync.get.mockResolvedValue({ enabledProviders: ['grok'] }); + unregisterMultiPanelTabId(12); + + await reconcileGrokWebSocketCookies(); + + expect(chrome.declarativeNetRequest.updateSessionRules).toHaveBeenCalledWith({ + removeRuleIds: [GROK_WEBSOCKET_COOKIE_RULE_ID], + addRules: [], + }); + }); + + it('upgrades existing OneTrust consent cookies to SameSite=None; Secure for embedded frames', async () => { + const cookies = [ + { + name: 'OptanonAlertBoxClosed', + value: '2026-10-16T12:00:00.000Z', + domain: '.grok.com', + path: '/', + secure: false, + httpOnly: false, + sameSite: 'lax', + expirationDate: 1821756479, + storeId: '0', + }, + { + name: 'sso', + value: 'session-token', + domain: '.grok.com', + path: '/', + secure: true, + httpOnly: true, + sameSite: 'lax', + }, + ]; + + const upgraded = await upgradeGrokConsentCookies(cookies); + + expect(chrome.cookies.set).toHaveBeenCalledTimes(1); + expect(chrome.cookies.set).toHaveBeenCalledWith({ + url: GROK_COOKIE_URL, + name: 'OptanonAlertBoxClosed', + value: '2026-10-16T12:00:00.000Z', + domain: '.grok.com', + path: '/', + secure: true, + httpOnly: false, + sameSite: 'no_restriction', + expirationDate: 1821756479, + storeId: '0', + }); + expect(upgraded[0]).toMatchObject({ + name: 'OptanonAlertBoxClosed', + secure: true, + sameSite: 'no_restriction', + }); + expect(upgraded[1]).toMatchObject({ + name: 'sso', + sameSite: 'lax', + }); + }); + + it('rewrites Optanon consent cookies written via document.cookie in embedded Grok frames', () => { + const writtenValues = []; + const fakeDocumentProto = {}; + Object.defineProperty(fakeDocumentProto, 'cookie', { + configurable: true, + enumerable: true, + get() { + return writtenValues.join('; '); + }, + set(value) { + writtenValues.push(value); + }, + }); + const fakeDoc = Object.create(fakeDocumentProto); + const fakeWindow = { + self: {}, + top: {}, + location: { protocol: 'https:' }, + Document: { prototype: fakeDocumentProto }, + HTMLDocument: { prototype: fakeDocumentProto }, + }; + + new Function( + 'window', + 'document', + 'location', + 'Document', + 'HTMLDocument', + grokCookieConsentScript + )( + fakeWindow, + fakeDoc, + fakeWindow.location, + fakeWindow.Document, + fakeWindow.HTMLDocument + ); + + fakeDoc.cookie = 'OptanonAlertBoxClosed=2026-10-16T13:00:00.000Z; path=/; domain=.grok.com; Samesite=Lax'; + fakeDoc.cookie = 'other_cookie=1; path=/; SameSite=Lax'; + + expect(writtenValues).toEqual([ + 'OptanonAlertBoxClosed=2026-10-16T13:00:00.000Z; path=/; domain=.grok.com; SameSite=None; Secure', + 'other_cookie=1; path=/; SameSite=Lax', + ]); + }); +}); From 07310d52d29c7508fd38feaee1608ad28bb1b2d6 Mon Sep 17 00:00:00 2001 From: Wenhao Date: Fri, 16 Oct 2026 23:18:31 +0800 Subject: [PATCH 2/2] fix(grok): limit cookie forwarding to top-level Multi-Panel tabs Address review findings on the embedded Grok cookie forwarding: - Only accept sync requests from the top frame of the extension's own Multi-Panel page, derive tabs from runtime.getContexts (frameId 0) and add a topDomains condition when supported, so a website framing the web-accessible Multi-Panel page no longer receives Grok session cookies. - Drop a tab from the rule as soon as it starts navigating and only re-admit it after a new sync request, avoiding duplicate cookies (401) after the tab leaves the Multi-Panel page. - Route every reconcile through one coalescing service worker scheduler instead of reconciling from the page, and propagate failures. - Upgrade consent cookies only on an explicit Multi-Panel sync request. - Reconcile on tab removal and cookie changes only when relevant. - Sync before the panel limit check in addPanel so concurrent adds cannot exceed MAX_PANELS. - Patch the prototype that owns document.cookie and run the consent script only inside extension pages. Claude-Session: https://claude.ai/code/session_01W5d9xkwrKRHCwLGjgnvYQn --- PRIVACY.md | 4 +- background/service-worker.js | 74 +++-- content-scripts/enter-behavior-grok.js | 8 - content-scripts/grok-cookie-consent.js | 20 +- modules/grok-websocket-cookies.js | 393 ++++++++++++++++++------- multi-panel/multi-panel.js | 45 +-- tests/e2e/grok-websocket.e2e.test.js | 341 ++++++++++++++++----- tests/grok-websocket-cookies.test.js | 306 ++++++++++++++++--- 8 files changed, 918 insertions(+), 273 deletions(-) diff --git a/PRIVACY.md b/PRIVACY.md index 62c6508..48f7fe0 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -79,9 +79,9 @@ When you interact with these AI providers through Panelize, you are subject to t ### Cookie Access -Provider websites use your existing browser session cookies when they load inside panels. Because Chromium omits `SameSite=Lax` and `SameSite=Strict` cookies on WebSocket handshakes initiated from embedded extension panels, Panelize uses the `cookies` API locally in your browser to forward existing `grok.com` session cookies to Grok's own `wss://grok.com/*` WebSocket requests inside Panelize tabs. The forwarding rule lasts only for the browser session; cookie values are not saved in Panelize's settings or sent to Panelize-operated servers or unrelated third parties. +Provider websites use your existing browser session cookies when they load inside panels. Because Chromium omits `SameSite=Lax` and `SameSite=Strict` cookies on WebSocket handshakes initiated from embedded extension panels, Panelize uses the `cookies` API locally in your browser to forward existing `grok.com` session cookies to Grok's own `wss://grok.com/*` WebSocket requests made from Panelize's Multi-Panel tabs. The forwarding applies only while the Multi-Panel page is the top-level page of a tab; it does not apply when another website embeds a Panelize page. The forwarding rule lasts only for the browser session; cookie values are not saved in Panelize's settings or sent to Panelize-operated servers or unrelated third parties. -Panelize also preserves your existing Grok cookie consent choices by setting the `OptanonAlertBoxClosed` and `OptanonConsent` cookies to `SameSite=None; Secure`, allowing Grok to read and save those choices inside its embedded panel. This preserves the choice you make in Grok's cookie controls and does not automatically accept cookies on your behalf. +Panelize also preserves your existing Grok cookie consent choices by setting the `OptanonAlertBoxClosed` and `OptanonConsent` cookies to `SameSite=None; Secure`, allowing Grok to read and save those choices inside its embedded panel. This happens only when you open Grok in the Multi-Panel page. This preserves the choice you make in Grok's cookie controls and does not automatically accept cookies on your behalf. ### Update Checks diff --git a/background/service-worker.js b/background/service-worker.js index 89c53d8..821b599 100644 --- a/background/service-worker.js +++ b/background/service-worker.js @@ -6,9 +6,15 @@ import { reconcileOptionalProviderAccess } from '../modules/optional-provider-access.js'; import { - isGrokCookieDomain, - reconcileGrokWebSocketCookies, - unregisterMultiPanelTabId + GROK_WEBSOCKET_COOKIE_SYNC_ACTION, + createGrokWebSocketCookieReconcileScheduler, + handleGrokTabNavigationComplete, + handleGrokTabNavigationStart, + handleGrokTabRemoved, + handleMultiPanelSyncRequest, + isGrokWebSocketCookieChange, + isMultiPanelTopFrameSender, + reconcileGrokWebSocketCookies } from '../modules/grok-websocket-cookies.js'; // Install event - setup context menus @@ -75,28 +81,24 @@ async function openMultiPanel() { if (tab.url === multiPanelUrl) { // 已有窗口,聚焦它 await chrome.windows.update(win.id, { focused: true }); - await scheduleGrokWebSocketCookieReconcile({ tabId: tab.id }); return; } } } // 创建新弹出窗口 - const createdWindow = await chrome.windows.create({ + await chrome.windows.create({ url: multiPanelUrl, type: 'popup', width: 1400, height: 900 }); - const popupTabId = createdWindow?.tabs?.[0]?.id; - await scheduleGrokWebSocketCookieReconcile({ tabId: popupTabId }); } else { // 标签页模式:始终创建新标签页 - const createdTab = await chrome.tabs.create({ + await chrome.tabs.create({ url: multiPanelUrl, active: true }); - await scheduleGrokWebSocketCookieReconcile({ tabId: createdTab?.id }); } } @@ -124,7 +126,6 @@ async function migrateProviderSettingsForUpdate(details) { } let optionalProviderReconcileQueue = Promise.resolve(); -let grokWebSocketCookieReconcileQueue = Promise.resolve(); function scheduleOptionalProviderReconcile() { optionalProviderReconcileQueue = optionalProviderReconcileQueue @@ -136,15 +137,12 @@ function scheduleOptionalProviderReconcile() { return optionalProviderReconcileQueue; } -function scheduleGrokWebSocketCookieReconcile(options = {}) { - grokWebSocketCookieReconcileQueue = grokWebSocketCookieReconcileQueue - .then(() => reconcileGrokWebSocketCookies(options)) - .catch((error) => { - console.error('[Background] Grok WebSocket cookie reconciliation queue failed:', error); - }); - - return grokWebSocketCookieReconcileQueue; -} +const scheduleGrokWebSocketCookieReconcile = createGrokWebSocketCookieReconcileScheduler( + reconcileGrokWebSocketCookies, + (error) => { + console.error('[Background] Failed to synchronize Grok WebSocket cookies:', error); + } +); chrome.runtime.onInstalled.addListener(async (details) => { await migrateProviderSettingsForUpdate(details); @@ -200,7 +198,20 @@ chrome.storage.onChanged.addListener((changes, namespace) => { if (chrome.cookies?.onChanged) { chrome.cookies.onChanged.addListener((changeInfo) => { - if (isGrokCookieDomain(changeInfo?.cookie?.domain)) { + if (isGrokWebSocketCookieChange(changeInfo)) { + scheduleGrokWebSocketCookieReconcile(); + } + }); +} + +if (chrome.tabs?.onUpdated) { + chrome.tabs.onUpdated.addListener((tabId, changeInfo) => { + if (changeInfo?.status === 'loading') { + handleGrokTabNavigationStart(tabId).catch((error) => { + console.error('[Background] Failed to drop navigating tab from Grok cookie rule:', error); + scheduleGrokWebSocketCookieReconcile(); + }); + } else if (changeInfo?.status === 'complete' && handleGrokTabNavigationComplete(tabId)) { scheduleGrokWebSocketCookieReconcile(); } }); @@ -208,8 +219,15 @@ if (chrome.cookies?.onChanged) { if (chrome.tabs?.onRemoved) { chrome.tabs.onRemoved.addListener((tabId) => { - unregisterMultiPanelTabId(tabId); - scheduleGrokWebSocketCookieReconcile(); + handleGrokTabRemoved(tabId) + .then((inRule) => { + if (inRule) { + scheduleGrokWebSocketCookieReconcile(); + } + }) + .catch((error) => { + console.error('[Background] Failed to handle closed Grok tab:', error); + }); }); } @@ -334,10 +352,14 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { return true; // Keep channel open for async response } - if (message.action === 'syncGrokWebSocketCookies') { - scheduleGrokWebSocketCookieReconcile({ tabId: sender?.tab?.id }) - .then(() => sendResponse({ success: true })) - .catch((error) => sendResponse({ success: false, error: error.message })); + if (message.action === GROK_WEBSOCKET_COOKIE_SYNC_ACTION) { + if (!isMultiPanelTopFrameSender(sender)) { + sendResponse({ success: false, error: 'Grok cookie sync is only available to the Multi-Panel page' }); + return false; + } + + handleMultiPanelSyncRequest(sender.tab.id); + scheduleGrokWebSocketCookieReconcile({ upgradeConsentCookies: true }).then(sendResponse); return true; } diff --git a/content-scripts/enter-behavior-grok.js b/content-scripts/enter-behavior-grok.js index 1e02e0f..6149043 100644 --- a/content-scripts/enter-behavior-grok.js +++ b/content-scripts/enter-behavior-grok.js @@ -169,11 +169,3 @@ function handleEnterSwap(event) { // Apply the setting on initial load applyEnterSwapSetting(); - -if (window.top !== window.self && typeof chrome !== 'undefined' && chrome.runtime?.sendMessage) { - try { - Promise.resolve(chrome.runtime.sendMessage({ action: 'syncGrokWebSocketCookies' })).catch(() => {}); - } catch { - // Ignore extension context errors - } -} diff --git a/content-scripts/grok-cookie-consent.js b/content-scripts/grok-cookie-consent.js index 23ce5de..51ac0dc 100644 --- a/content-scripts/grok-cookie-consent.js +++ b/content-scripts/grok-cookie-consent.js @@ -6,6 +6,15 @@ return; } + // Only rewrite inside extension pages such as the Multi-Panel page. Grok + // frames embedded by ordinary websites (or by a website framing the + // web-accessible Multi-Panel page) keep the browser's default behavior. + const ancestorOrigins = location.ancestorOrigins; + const topOrigin = ancestorOrigins?.length ? ancestorOrigins[ancestorOrigins.length - 1] : ''; + if (!topOrigin.startsWith('chrome-extension://')) { + return; + } + const OPTANON_COOKIE_PATTERN = /^\s*Optanon(?:AlertBoxClosed|Consent)=/; function rewriteGrokConsentCookieString(cookieString) { @@ -27,13 +36,18 @@ return rewritten; } - const descriptor = Object.getOwnPropertyDescriptor(Document.prototype, 'cookie') || - Object.getOwnPropertyDescriptor(HTMLDocument.prototype, 'cookie'); + // Patch the prototype that actually owns the accessor (Document.prototype in + // Chromium) so no own property further down the chain shadows the override. + let owner = Object.getPrototypeOf(document); + while (owner && !Object.prototype.hasOwnProperty.call(owner, 'cookie')) { + owner = Object.getPrototypeOf(owner); + } + const descriptor = owner && Object.getOwnPropertyDescriptor(owner, 'cookie'); if (!descriptor || typeof descriptor.get !== 'function' || typeof descriptor.set !== 'function') { return; } - Object.defineProperty(Document.prototype, 'cookie', { + Object.defineProperty(owner, 'cookie', { configurable: true, enumerable: descriptor.enumerable ?? true, get() { diff --git a/modules/grok-websocket-cookies.js b/modules/grok-websocket-cookies.js index d257cc9..8b29817 100644 --- a/modules/grok-websocket-cookies.js +++ b/modules/grok-websocket-cookies.js @@ -4,32 +4,39 @@ export const GROK_PROVIDER_ID = 'grok'; export const GROK_COOKIE_URL = 'https://grok.com/'; export const GROK_DOMAIN = 'grok.com'; export const GROK_WEBSOCKET_COOKIE_RULE_ID = 2001; +export const GROK_WEBSOCKET_COOKIE_SYNC_ACTION = 'syncGrokWebSocketCookies'; export const MULTI_PANEL_PAGE_PATH = 'multi-panel/multi-panel.html'; export const GROK_CONSENT_COOKIE_NAMES = Object.freeze([ 'OptanonAlertBoxClosed', 'OptanonConsent' ]); -const registeredMultiPanelTabIds = new Set(); +// Tabs whose top-level document started navigating. They stay out of the +// forwarding rule until the navigation completes or a Multi-Panel page in that +// tab asks for a sync, so a Multi-Panel tab that navigates elsewhere never +// keeps receiving forwarded cookies while its old document is still listed. +const navigatingTabIds = new Set(); +// Navigating tabs that were left out of the rule only because of the mark +// above; they need a reconcile once their navigation settles. +const suppressedTabIds = new Set(); +// Tab IDs of the installed rule, or null when unknown (e.g. after a service +// worker restart) and the session rules must be read again. +let appliedRuleTabIds = null; function isValidTabId(tabId) { return Number.isInteger(tabId) && tabId >= 0; } -export function registerMultiPanelTabId(tabId) { - if (isValidTabId(tabId)) { - registeredMultiPanelTabIds.add(tabId); - } -} - -export function unregisterMultiPanelTabId(tabId) { - if (isValidTabId(tabId)) { - registeredMultiPanelTabIds.delete(tabId); - } +function normalizeTabIds(tabIds) { + return [...new Set( + (Array.isArray(tabIds) ? tabIds : []).filter(isValidTabId) + )].sort((a, b) => a - b); } -export function clearRegisteredMultiPanelTabIds() { - registeredMultiPanelTabIds.clear(); +export function resetGrokWebSocketCookieState() { + navigatingTabIds.clear(); + suppressedTabIds.clear(); + appliedRuleTabIds = null; } export function isGrokCookieDomain(domain) { @@ -43,6 +50,17 @@ export function isGrokCookieDomain(domain) { return normalized === GROK_DOMAIN || normalized.endsWith(`.${GROK_DOMAIN}`); } +function isForwardedCookie(cookie) { + return Boolean( + cookie && + typeof cookie.name === 'string' && + cookie.name.length > 0 && + typeof cookie.value === 'string' && + cookie.sameSite !== 'no_restriction' && + !cookie.partitionKey + ); +} + /** * Formats Grok cookies that Chromium omits from cross-site WebSocket handshakes * inside extension iframes. SameSite=None cookies (including partitioned CHIPS @@ -55,23 +73,43 @@ export function formatGrokWebSocketCookieHeader(cookies) { } return cookies - .filter((cookie) => ( - cookie && - typeof cookie.name === 'string' && - cookie.name.length > 0 && - typeof cookie.value === 'string' && - cookie.sameSite !== 'no_restriction' && - !cookie.partitionKey - )) + .filter(isForwardedCookie) .map((cookie) => `${cookie.name}=${cookie.value}`) .join('; '); } -export function createGrokWebSocketCookieRule(cookieHeaderValue, tabIds) { - const normalizedTabIds = [...new Set( - (Array.isArray(tabIds) ? tabIds : []).filter(isValidTabId) - )].sort((a, b) => a - b); +/** + * Whether a chrome.cookies.onChanged event can change the forwarded header. + * Overwrites report the old cookie as removed and the new one as added, so + * a cookie moving out of the header (e.g. to SameSite=None) is still caught + * by its removal event. + */ +export function isGrokWebSocketCookieChange(changeInfo) { + const cookie = changeInfo?.cookie; + return isGrokCookieDomain(cookie?.domain) && isForwardedCookie(cookie); +} + +/** + * Top-level domains the rule is restricted to: this extension's own pages. + * RuleCondition.topDomains exists since Chrome 145, and older versions reject + * unknown condition keys, so it is only used when the browser advertises it. + * There the rule cannot apply to a Multi-Panel tab that already navigated to + * another site, or to a third-party page framing multi-panel.html, even + * before the tab list is updated. + */ +export function getExtensionTopDomains() { + if ( + typeof chrome === 'undefined' || + chrome.declarativeNetRequest?.RuleConditionKeys?.TOP_DOMAINS !== 'topDomains' || + typeof chrome.runtime?.id !== 'string' || + chrome.runtime.id.length === 0 + ) { + return null; + } + return [chrome.runtime.id]; +} +export function createGrokWebSocketCookieRule(cookieHeaderValue, tabIds, topDomains = getExtensionTopDomains()) { return { id: GROK_WEBSOCKET_COOKIE_RULE_ID, priority: 1, @@ -88,49 +126,151 @@ export function createGrokWebSocketCookieRule(cookieHeaderValue, tabIds) { condition: { requestDomains: [GROK_DOMAIN], initiatorDomains: [GROK_DOMAIN], + ...(Array.isArray(topDomains) && topDomains.length > 0 ? { topDomains: [...topDomains] } : {}), resourceTypes: ['websocket'], - tabIds: normalizedTabIds + tabIds: normalizeTabIds(tabIds) } }; } -export async function getMultiPanelTabIds(extraTabId = null) { - if (isValidTabId(extraTabId)) { - registeredMultiPanelTabIds.add(extraTabId); +export function isMultiPanelPageUrl(url) { + if (typeof url !== 'string' || typeof chrome === 'undefined' || !chrome.runtime?.getURL) { + return false; } - if (!isValidTabId(extraTabId) && typeof chrome !== 'undefined' && chrome.tabs?.getCurrent) { - try { - const currentTab = await chrome.tabs.getCurrent(); - if (isValidTabId(currentTab?.id)) { - registeredMultiPanelTabIds.add(currentTab.id); - } - } catch (error) { - // Ignore when called outside a tab context such as the background service worker - } + try { + const actual = new URL(url); + const expected = new URL(chrome.runtime.getURL(MULTI_PANEL_PAGE_PATH)); + // Compare scheme and host explicitly: URL.origin is "null" for + // chrome-extension: URLs outside the browser. + return ( + actual.protocol === expected.protocol && + actual.host === expected.host && + actual.pathname === expected.pathname + ); + } catch { + return false; } +} - if (typeof chrome !== 'undefined' && chrome.runtime?.getContexts && chrome.runtime?.getURL) { - try { - const multiPanelUrl = chrome.runtime.getURL(MULTI_PANEL_PAGE_PATH); - const contexts = await chrome.runtime.getContexts({ contextTypes: ['TAB'] }); - if (Array.isArray(contexts)) { - for (const context of contexts) { - if ( - typeof context?.documentUrl === 'string' && - context.documentUrl.startsWith(multiPanelUrl) && - isValidTabId(context.tabId) - ) { - registeredMultiPanelTabIds.add(context.tabId); - } - } - } - } catch (error) { - // Fallback to in-memory registered tab IDs if getContexts fails +/** + * Only a Multi-Panel page that is the top-level document of its tab may ask + * for cookie forwarding. multi-panel.html is web-accessible, so third-party + * pages can frame it; those frames have a non-zero frameId. + */ +export function isMultiPanelTopFrameSender(sender) { + return Boolean( + sender && + typeof chrome !== 'undefined' && + sender.id === chrome.runtime?.id && + sender.frameId === 0 && + isValidTabId(sender.tab?.id) && + isMultiPanelPageUrl(sender.url) + ); +} + +async function getMultiPanelTopFrameTabIds() { + if (typeof chrome === 'undefined' || !chrome.runtime?.getContexts) { + // Without getContexts there is no trustworthy way to tell which tabs show + // the Multi-Panel page, so forward nothing. + return []; + } + + const contexts = await chrome.runtime.getContexts({ contextTypes: ['TAB'] }); + return normalizeTabIds((Array.isArray(contexts) ? contexts : []) + .filter((context) => context?.frameId === 0 && isMultiPanelPageUrl(context.documentUrl)) + .map((context) => context.tabId)); +} + +export async function getMultiPanelTabIds() { + const tabIds = await getMultiPanelTopFrameTabIds(); + return tabIds.filter((tabId) => { + if (navigatingTabIds.has(tabId)) { + suppressedTabIds.add(tabId); + return false; } + return true; + }); +} + +async function getAppliedRuleTabIds() { + if (appliedRuleTabIds) { + return appliedRuleTabIds; + } + + if (typeof chrome === 'undefined' || !chrome.declarativeNetRequest?.getSessionRules) { + return []; } - return [...registeredMultiPanelTabIds].sort((a, b) => a - b); + const sessionRules = await chrome.declarativeNetRequest.getSessionRules(); + const rule = sessionRules.find(({ id }) => id === GROK_WEBSOCKET_COOKIE_RULE_ID); + appliedRuleTabIds = normalizeTabIds(rule?.condition?.tabIds); + return appliedRuleTabIds; +} + +/** + * Handles a top-level navigation start in any tab. A tab covered by the + * forwarding rule is removed from it right away, without a full reconcile, so + * the next document in that tab (e.g. grok.com top-level) does not receive + * duplicate cookies. Returns true when the rule changed. + */ +export async function handleGrokTabNavigationStart(tabId) { + if (!isValidTabId(tabId)) { + return false; + } + + // Mark synchronously so a reconcile racing with this handler already skips + // the tab. + navigatingTabIds.add(tabId); + const ruleTabIds = await getAppliedRuleTabIds(); + if (!ruleTabIds.includes(tabId)) { + return false; + } + + const sessionRules = await chrome.declarativeNetRequest.getSessionRules(); + const rule = sessionRules.find(({ id }) => id === GROK_WEBSOCKET_COOKIE_RULE_ID); + const remainingTabIds = normalizeTabIds(rule?.condition?.tabIds).filter((id) => id !== tabId); + await chrome.declarativeNetRequest.updateSessionRules({ + removeRuleIds: [GROK_WEBSOCKET_COOKIE_RULE_ID], + addRules: rule && remainingTabIds.length > 0 + ? [{ ...rule, condition: { ...rule.condition, tabIds: remainingTabIds } }] + : [] + }); + appliedRuleTabIds = remainingTabIds; + suppressedTabIds.add(tabId); + return true; +} + +/** + * Handles a finished navigation. Returns true when the tab was left out of the + * rule while navigating and must be re-evaluated against its new document. + */ +export function handleGrokTabNavigationComplete(tabId) { + navigatingTabIds.delete(tabId); + return suppressedTabIds.delete(tabId); +} + +/** + * Handles a sync request from a trusted Multi-Panel top frame: its document is + * live, so the tab no longer counts as navigating. + */ +export function handleMultiPanelSyncRequest(tabId) { + navigatingTabIds.delete(tabId); + suppressedTabIds.delete(tabId); +} + +/** + * Handles a closed tab. Returns true when the forwarding rule still lists it. + */ +export async function handleGrokTabRemoved(tabId) { + navigatingTabIds.delete(tabId); + suppressedTabIds.delete(tabId); + const ruleTabIds = await getAppliedRuleTabIds(); + return ruleTabIds.includes(tabId); +} + +function isSameList(left = [], right = []) { + return left.length === right.length && left.every((value, index) => value === right[index]); } function isSameRule(leftRule, rightRule) { @@ -140,8 +280,6 @@ function isSameRule(leftRule, rightRule) { const leftHeader = leftRule.action?.requestHeaders?.[0]; const rightHeader = rightRule.action?.requestHeaders?.[0]; - const leftTabIds = leftRule.condition?.tabIds || []; - const rightTabIds = rightRule.condition?.tabIds || []; return ( leftRule.id === rightRule.id && @@ -150,8 +288,8 @@ function isSameRule(leftRule, rightRule) { leftHeader?.header === rightHeader?.header && leftHeader?.operation === rightHeader?.operation && leftHeader?.value === rightHeader?.value && - leftTabIds.length === rightTabIds.length && - leftTabIds.every((tabId, index) => tabId === rightTabIds[index]) + isSameList(leftRule.condition?.tabIds, rightRule.condition?.tabIds) && + isSameList(leftRule.condition?.topDomains, rightRule.condition?.topDomains) ); } @@ -169,9 +307,7 @@ export async function syncGrokWebSocketCookies({ } const grokEnabled = Array.isArray(enabledProviderIds) && enabledProviderIds.includes(GROK_PROVIDER_ID); - const normalizedTabIds = [...new Set( - (Array.isArray(tabIds) ? tabIds : []).filter(isValidTabId) - )].sort((a, b) => a - b); + const normalizedTabIds = normalizeTabIds(tabIds); const cookieHeaderValue = grokEnabled && normalizedTabIds.length > 0 ? formatGrokWebSocketCookieHeader(cookies) : ''; @@ -189,19 +325,28 @@ export async function syncGrokWebSocketCookies({ addRules: [] }); } + appliedRuleTabIds = []; return; } - if (isSameRule(existingRule, desiredRule)) { - return; + if (!isSameRule(existingRule, desiredRule)) { + // Always remove the ID first so the update stays valid even if another + // context installed the rule after getSessionRules() returned. + await chrome.declarativeNetRequest.updateSessionRules({ + removeRuleIds: [GROK_WEBSOCKET_COOKIE_RULE_ID], + addRules: [desiredRule] + }); } - - await chrome.declarativeNetRequest.updateSessionRules({ - removeRuleIds: existingRule ? [GROK_WEBSOCKET_COOKIE_RULE_ID] : [], - addRules: [desiredRule] - }); + appliedRuleTabIds = desiredRule.condition.tabIds; } +/** + * Rewrites existing OneTrust consent cookies to SameSite=None; Secure so Grok + * can read a choice the user already made top-level from inside its embedded + * panel; Chromium hides SameSite=Lax cookies from document.cookie there. + * Value, path, domain and expiry are kept. New choices made inside the panel + * are handled by content-scripts/grok-cookie-consent.js instead. + */ export async function upgradeGrokConsentCookies(cookies) { if (!Array.isArray(cookies) || typeof chrome === 'undefined' || !chrome.cookies?.set) { return cookies; @@ -243,7 +388,7 @@ export async function upgradeGrokConsentCookies(cookies) { }); continue; } catch (error) { - // Keep original cookie entry if chrome.cookies.set fails + console.warn(`[Grok] Failed to update consent cookie ${cookie.name}:`, error); } } @@ -253,37 +398,81 @@ export async function upgradeGrokConsentCookies(cookies) { return updatedCookies; } -export async function reconcileGrokWebSocketCookies({ tabId = null } = {}) { - try { - if ( - typeof chrome === 'undefined' || - !chrome.declarativeNetRequest?.getSessionRules || - !chrome.declarativeNetRequest?.updateSessionRules - ) { - return; - } +/** + * Recomputes the forwarding rule from the live Multi-Panel tabs and cookies. + * @param {object} [options] + * @param {boolean} [options.upgradeConsentCookies] - Also rewrite existing + * consent cookies; only set when a Multi-Panel page is about to load Grok. + */ +export async function reconcileGrokWebSocketCookies({ upgradeConsentCookies = false } = {}) { + if ( + typeof chrome === 'undefined' || + !chrome.declarativeNetRequest?.getSessionRules || + !chrome.declarativeNetRequest?.updateSessionRules + ) { + return; + } - const settings = await chrome.storage.sync.get({ - enabledProviders: DEFAULT_PROVIDER_IDS - }); - const enabledProviderIds = Array.isArray(settings.enabledProviders) - ? settings.enabledProviders - : DEFAULT_PROVIDER_IDS; - const tabIds = await getMultiPanelTabIds(tabId); - const grokEnabled = enabledProviderIds.includes(GROK_PROVIDER_ID); - const rawCookies = grokEnabled && tabIds.length > 0 && chrome.cookies?.getAll - ? await chrome.cookies.getAll({ url: GROK_COOKIE_URL }) - : []; - const cookies = grokEnabled && tabIds.length > 0 - ? await upgradeGrokConsentCookies(rawCookies) - : rawCookies; - - await syncGrokWebSocketCookies({ - enabledProviderIds, - tabIds, - cookies - }); - } catch (error) { - console.error('[Background] Failed to synchronize Grok WebSocket cookies:', error); + const settings = await chrome.storage.sync.get({ + enabledProviders: DEFAULT_PROVIDER_IDS + }); + const enabledProviderIds = Array.isArray(settings.enabledProviders) + ? settings.enabledProviders + : DEFAULT_PROVIDER_IDS; + const grokEnabled = enabledProviderIds.includes(GROK_PROVIDER_ID); + const tabIds = grokEnabled ? await getMultiPanelTabIds() : []; + const active = grokEnabled && tabIds.length > 0; + let cookies = active && chrome.cookies?.getAll + ? await chrome.cookies.getAll({ url: GROK_COOKIE_URL }) + : []; + if (active && upgradeConsentCookies) { + cookies = await upgradeGrokConsentCookies(cookies); } + + await syncGrokWebSocketCookies({ + enabledProviderIds, + tabIds, + cookies + }); +} + +/** + * Serializes reconciles and coalesces bursts: requests that arrive while a run + * is queued share it, so a flurry of cookie or tab events costs at most one + * run in progress plus one queued run. The returned promise never rejects. + * @param {(options: {upgradeConsentCookies?: boolean}) => Promise} reconcile + * @param {(error: unknown) => void} [onError] + * @returns {(options?: {upgradeConsentCookies?: boolean}) => + * Promise<{success: true} | {success: false, error: string}>} + */ +export function createGrokWebSocketCookieReconcileScheduler(reconcile, onError = () => {}) { + let tail = Promise.resolve(); + let pendingRun = null; + let pendingOptions = {}; + + return function scheduleReconcile(options = {}) { + if (options.upgradeConsentCookies) { + pendingOptions = { ...pendingOptions, upgradeConsentCookies: true }; + } + if (pendingRun) { + return pendingRun; + } + + pendingRun = tail + .then(() => { + const runOptions = pendingOptions; + pendingRun = null; + pendingOptions = {}; + return reconcile(runOptions); + }) + .then( + () => ({ success: true }), + (error) => { + onError(error); + return { success: false, error: error?.message || String(error) }; + } + ); + tail = pendingRun; + return pendingRun; + }; } diff --git a/multi-panel/multi-panel.js b/multi-panel/multi-panel.js index cf32c42..64c0267 100644 --- a/multi-panel/multi-panel.js +++ b/multi-panel/multi-panel.js @@ -7,7 +7,7 @@ import { PROVIDERS, getProviderById, getEnabledProviders, getProviderIcon } from '../modules/providers.js'; import { DEFAULT_PROVIDER_IDS } from '../modules/provider-defaults.js'; -import { reconcileGrokWebSocketCookies } from '../modules/grok-websocket-cookies.js'; +import { GROK_WEBSOCKET_COOKIE_SYNC_ACTION } from '../modules/grok-websocket-cookies.js'; import { DEFAULT_GOOGLE_PROVIDER_MODE, GOOGLE_PROVIDER_MODE_AI, @@ -245,8 +245,17 @@ function isChatgptProvider(providerId) { return providerId === 'chatgpt'; } -async function ensureGrokWebSocketCookiesSynced() { - await reconcileGrokWebSocketCookies(); +// The service worker owns the forwarding rule and serializes updates to it; +// a failed sync must not block the panel from loading. +async function requestGrokWebSocketCookieSync() { + try { + const response = await chrome.runtime.sendMessage({ action: GROK_WEBSOCKET_COOKIE_SYNC_ACTION }); + if (!response?.success) { + console.warn('[Multi-Panel] Grok WebSocket cookie sync failed:', response?.error); + } + } catch (error) { + console.warn('[Multi-Panel] Grok WebSocket cookie sync failed:', error); + } } function isTemporaryChatSupportedProvider(providerId) { @@ -1169,11 +1178,25 @@ async function initializePanels() { // ===== Panel Management ===== async function addPanel(providerId) { + // Sync before the capacity check so no await separates the check from the + // panel being added. + if (providerId === 'grok') { + await requestGrokWebSocketCookieSync(); + } + if (panels.length >= MAX_PANELS) { showToast(`Maximum number of panels reached (${MAX_PANELS})`); return; } + const provider = getProviderById(providerId); + if (!provider) { + console.error('Provider not found:', providerId); + return; + } + + // Everything from the capacity check to panels.push() below runs without + // awaiting, so concurrent calls cannot both pass the check. // Auto layout adjustment: upgrade from 1xN to 1x(N+1) when adding panel exceeds capacity const newPanelCount = panels.length + 1; const adjustedLayout = getAutoAdjustedLayout(currentLayout, newPanelCount); @@ -1188,19 +1211,7 @@ async function addPanel(providerId) { document.querySelectorAll('.layout-option').forEach(btn => { btn.classList.toggle('active', btn.dataset.layout === adjustedLayout); }); - - // Save configuration - await saveProviderConfiguration(); - } - - const provider = getProviderById(providerId); - if (!provider) { - console.error('Provider not found:', providerId); - return; - } - - if (providerId === 'grok') { - await ensureGrokWebSocketCookiesSynced(); + // The adjusted layout is saved together with the new panel below. } const panelId = `panel-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; @@ -1332,7 +1343,7 @@ async function switchPanelProvider(panelId, newProviderId) { } if (newProviderId === 'grok') { - await ensureGrokWebSocketCookiesSynced(); + await requestGrokWebSocketCookieSync(); } // Update panel header diff --git a/tests/e2e/grok-websocket.e2e.test.js b/tests/e2e/grok-websocket.e2e.test.js index 6a5b96a..d9eab8b 100644 --- a/tests/e2e/grok-websocket.e2e.test.js +++ b/tests/e2e/grok-websocket.e2e.test.js @@ -6,32 +6,53 @@ import { launchExtension, openMultiPanel, } from './extension-harness.js'; +import { PROVIDERS } from '../../modules/providers.js'; const WEBSOCKET_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'; +const GROK_WEBSOCKET_COOKIE_RULE_ID = 2001; function buildWebSocketTextFrame(text) { const payload = Buffer.from(text, 'utf8'); return Buffer.concat([Buffer.from([0x81, payload.length]), payload]); } +function renderGrokPage() { + return ` +

Connecting

+ + `; +} + +function renderThirdPartyPage(frameUrl) { + return ` + + `; +} + function startGrokWebSocketFixtureServer() { const wsHandshakes = []; const sockets = new Set(); const server = http.createServer((request, response) => { + const url = new URL(request.url, `http://${request.headers.host}`); response.writeHead(200, { 'Content-Type': 'text/html' }); - response.end(` -

Connecting

- - `); + if (url.hostname === 'attacker.test') { + response.end(renderThirdPartyPage(url.searchParams.get('frame'))); + return; + } + response.end(renderGrokPage()); }); server.on('connection', (socket) => { @@ -42,6 +63,7 @@ function startGrokWebSocketFixtureServer() { server.on('upgrade', (request, socket) => { sockets.add(socket); socket.on('close', () => sockets.delete(socket)); + const tag = new URL(request.url, 'http://grok.com').searchParams.get('tag'); const cookieHeader = request.headers.cookie || ''; const ssoMatches = cookieHeader.match(/(?:^|;\s*)sso=([^;]+)/g) || []; const ssoRwMatches = cookieHeader.match(/(?:^|;\s*)sso-rw=([^;]+)/g) || []; @@ -49,11 +71,13 @@ function startGrokWebSocketFixtureServer() { const ssoValue = cookieHeader.match(/(?:^|;\s*)sso=([^;]+)/)?.[1] || null; const authenticated = Boolean( ssoValue && + ssoMatches.length === 1 && ssoRwMatches.length === 1 && deviceMatches.length === 1 ); wsHandshakes.push({ + tag, authenticated, ssoValue, ssoCount: ssoMatches.length, @@ -96,65 +120,134 @@ function startGrokWebSocketFixtureServer() { }); } -test('connects Grok WebSockets in an embedded panel with SameSite session cookies', async () => { - test.setTimeout(60000); +// Grok's real session and device cookies use SameSite=Lax and SameSite=Strict +// with HttpOnly, which Chromium strips on cross-site WebSocket handshakes. +function grokSessionCookies(ssoValue) { + return [ + { + name: 'grok_device_id', + value: 'fixture-device', + domain: '.grok.com', + path: '/', + httpOnly: true, + sameSite: 'Lax', + }, + { + name: 'sso', + value: ssoValue, + domain: '.grok.com', + path: '/', + httpOnly: true, + sameSite: 'Lax', + }, + { + name: 'sso-rw', + value: 'fixture-session-rw', + domain: '.grok.com', + path: '/', + httpOnly: true, + sameSite: 'Strict', + }, + ]; +} + +async function launchGrokFixture({ grantOptionalProviders = false } = {}) { const server = await startGrokWebSocketFixtureServer(); const extensionCopy = await createPatchedExtensionCopy((manifest) => { + if (grantOptionalProviders) { + // Optional providers need a user gesture to grant; grant them up front. + manifest.host_permissions.push(...manifest.optional_host_permissions); + } // Map the wss:// permission to ws:// for the loopback fixture server. manifest.host_permissions.push('ws://grok.com/*'); + // Run the Grok content scripts on the plain-http fixture as well. + for (const contentScript of manifest.content_scripts) { + if (contentScript.matches.includes('https://grok.com/*')) { + contentScript.matches.push('http://grok.com/*'); + } + } }); const extension = await launchExtension({ extensionPath: extensionCopy.extensionPath, args: [ '--disable-features=HttpsUpgrades', '--proxy-bypass-list=*', - '--host-resolver-rules=MAP grok.com 127.0.0.1', + '--host-resolver-rules=MAP grok.com 127.0.0.1, MAP attacker.test 127.0.0.1', ], }); - try { - const pageErrors = []; - extension.context.on('page', (page) => { - page.on('pageerror', (error) => pageErrors.push(error.message)); + const pageErrors = []; + extension.context.on('page', (page) => { + page.on('pageerror', (error) => pageErrors.push(error.message)); + }); + + await extension.context.addCookies(grokSessionCookies('fixture-session-1')); + // Other provider sites are irrelevant here; serve blank pages instead of the network. + await extension.context.route((url) => url.protocol === 'https:' && url.hostname !== 'grok.com', (route) => ( + route.fulfill({ contentType: 'text/html', body: 'provider' }) + )); + await extension.context.route('https://grok.com/**', (route) => { + const target = new URL(route.request().url()); + return route.fulfill({ + status: 302, + headers: { Location: `http://grok.com:${server.port}${target.pathname}${target.search}` }, }); + }); - // Grok's real session and device cookies use SameSite=Lax and SameSite=Strict - // with HttpOnly, which Chromium strips on cross-site WebSocket handshakes. - await extension.context.addCookies([ - { - name: 'grok_device_id', - value: 'fixture-device', - domain: '.grok.com', - path: '/', - httpOnly: true, - sameSite: 'Lax', - }, - { - name: 'sso', - value: 'fixture-session-1', - domain: '.grok.com', - path: '/', - httpOnly: true, - sameSite: 'Lax', - }, - { - name: 'sso-rw', - value: 'fixture-session-rw', - domain: '.grok.com', - path: '/', - httpOnly: true, - sameSite: 'Strict', - }, - ]); + return { + server, + extension, + pageErrors, + grokUrl: (tag, delay = 0) => `http://grok.com:${server.port}/?tag=${tag}&delay=${delay}`, + // Simulates Chrome < 145, which has no RuleCondition.topDomains. + async disableTopDomains() { + await extension.serviceWorker.evaluate(() => { + Object.defineProperty(chrome.declarativeNetRequest, 'RuleConditionKeys', { + configurable: true, + value: undefined, + }); + }); + }, + async getRule() { + return extension.serviceWorker.evaluate(async (ruleId) => { + const rules = await chrome.declarativeNetRequest.getSessionRules(); + return rules.find(({ id }) => id === ruleId) ?? null; + }, GROK_WEBSOCKET_COOKIE_RULE_ID); + }, + thirdPartyUrl: (frameUrl) => ( + `http://attacker.test:${server.port}/?frame=${encodeURIComponent(frameUrl)}` + ), + async getRuleTabIds() { + return extension.serviceWorker.evaluate(async (ruleId) => { + const rules = await chrome.declarativeNetRequest.getSessionRules(); + return rules.find(({ id }) => id === ruleId)?.condition?.tabIds ?? null; + }, GROK_WEBSOCKET_COOKIE_RULE_ID); + }, + async getTabId(page) { + const url = page.url(); + return extension.serviceWorker.evaluate(async (pageUrl) => { + const contexts = await chrome.runtime.getContexts({ contextTypes: ['TAB'] }); + const match = contexts.find((context) => context.frameId === 0 && context.documentUrl === pageUrl); + return match?.tabId ?? null; + }, url); + }, + async close() { + await extension.close(); + await server.close(); + await extensionCopy.cleanup(); + }, + }; +} - await extension.context.route('https://grok.com/**', (route) => route.fulfill({ - status: 302, - headers: { Location: `http://grok.com:${server.port}/` }, - })); +test('connects Grok WebSockets in an embedded panel with SameSite session cookies', async () => { + test.setTimeout(60000); + const fixture = await launchGrokFixture(); + const { extension, server } = fixture; + try { // Top-level Grok works natively and must not receive duplicate cookies. const topLevel = await extension.context.newPage(); - await topLevel.goto(`http://grok.com:${server.port}/`); + await topLevel.goto(fixture.grokUrl('top')); await expect(topLevel.locator('#ws-state')).toHaveText('OK:fixture-session-1'); // Embedded Grok panel inside Panelize must also establish its WebSocket. @@ -163,16 +256,7 @@ test('connects Grok WebSockets in an embedded panel with SameSite session cookie await expect(panelState).toHaveText('OK:fixture-session-1'); // Rotate the session cookie and verify the updated cookie is used on reload. - await extension.context.addCookies([ - { - name: 'sso', - value: 'fixture-session-2', - domain: '.grok.com', - path: '/', - httpOnly: true, - sameSite: 'Lax', - }, - ]); + await extension.context.addCookies(grokSessionCookies('fixture-session-2')); await page.reload(); await expect(page.frameLocator('iframe').locator('#ws-state')) @@ -182,16 +266,137 @@ test('connects Grok WebSockets in an embedded panel with SameSite session cookie await topLevel.reload(); await expect(topLevel.locator('#ws-state')).toHaveText('OK:fixture-session-2'); - expect(server.wsHandshakes).toEqual([ + expect(server.wsHandshakes.map(({ authenticated, ssoValue, ssoCount, ssoRwCount }) => ({ + authenticated, ssoValue, ssoCount, ssoRwCount, + }))).toEqual([ { authenticated: true, ssoValue: 'fixture-session-1', ssoCount: 1, ssoRwCount: 1 }, { authenticated: true, ssoValue: 'fixture-session-1', ssoCount: 1, ssoRwCount: 1 }, { authenticated: true, ssoValue: 'fixture-session-2', ssoCount: 1, ssoRwCount: 1 }, { authenticated: true, ssoValue: 'fixture-session-2', ssoCount: 1, ssoRwCount: 1 }, ]); - expect(pageErrors).toEqual([]); + expect(await fixture.getRuleTabIds()).toEqual([await fixture.getTabId(page)]); + expect(fixture.pageErrors).toEqual([]); + } finally { + await fixture.close(); + } +}); + +for (const legacy of [false, true]) { + const variant = legacy ? ' (without topDomains)' : ''; + + test(`never forwards Grok session cookies to Grok frames embedded by third-party pages${variant}`, async () => { + test.setTimeout(60000); + const fixture = await launchGrokFixture(); + const { extension, server } = fixture; + + try { + if (legacy) { + await fixture.disableTopDomains(); + } + const multiPanel = await openMultiPanel(extension, { providers: ['grok'] }); + await expect(multiPanel.frameLocator('iframe').locator('#ws-state')) + .toHaveText('OK:fixture-session-1'); + const multiPanelTabId = await fixture.getTabId(multiPanel); + expect((await fixture.getRule()).condition.topDomains) + .toEqual(legacy ? undefined : [extension.extensionId]); + + // A third-party page frames grok.com directly. + const thirdParty = await extension.context.newPage(); + await thirdParty.goto(fixture.thirdPartyUrl(fixture.grokUrl('third-party'))); + await expect(thirdParty.frameLocator('#embedded').locator('#ws-state')) + .toHaveText('Unable to start conversation'); + + // A third-party page frames the web-accessible Multi-Panel page, which in + // turn frames Grok. + const framedMultiPanel = await extension.context.newPage(); + await framedMultiPanel.goto(fixture.thirdPartyUrl( + extension.extensionUrl('multi-panel/multi-panel.html') + )); + await expect(framedMultiPanel.frameLocator('#embedded').locator('#panel-grid iframe')) + .toHaveCount(1); + await expect(framedMultiPanel.frameLocator('#embedded').frameLocator('#panel-grid iframe') + .locator('#ws-state')).toHaveText('Unable to start conversation'); + + expect(await fixture.getRuleTabIds()).toEqual([multiPanelTabId]); + expect(server.wsHandshakes.filter(({ tag }) => tag === 'third-party')) + .toEqual([{ tag: 'third-party', authenticated: false, ssoValue: null, ssoCount: 0, ssoRwCount: 0 }]); + // Panel frames load https://grok.com/ without a tag: first the real + // Multi-Panel tab, then the one framed by the third-party page. + expect(server.wsHandshakes.filter(({ tag }) => tag === 'default')).toEqual([ + { tag: 'default', authenticated: true, ssoValue: 'fixture-session-1', ssoCount: 1, ssoRwCount: 1 }, + { tag: 'default', authenticated: false, ssoValue: null, ssoCount: 0, ssoRwCount: 0 }, + ]); + } finally { + await fixture.close(); + } + }); + + test(`stops forwarding Grok cookies once a Multi-Panel tab navigates to grok.com${variant}`, async () => { + test.setTimeout(60000); + const fixture = await launchGrokFixture(); + const { extension, server } = fixture; + + try { + if (legacy) { + await fixture.disableTopDomains(); + } + const page = await openMultiPanel(extension, { providers: ['grok'] }); + await expect(page.frameLocator('iframe').locator('#ws-state')) + .toHaveText('OK:fixture-session-1'); + expect(await fixture.getRuleTabIds()).toHaveLength(1); + + // The same tab now shows Grok top-level, where Chromium already sends + // SameSite cookies natively; appending them again would duplicate them. + // With topDomains the rule never matches there, even if the WebSocket + // opens immediately. Without it the tab is dropped from the rule when + // the navigation starts, ahead of Grok loading its app bundle. + await page.goto(fixture.grokUrl('navigated', legacy ? 300 : 0)); + await expect(page.locator('#ws-state')).toHaveText('OK:fixture-session-1'); + + expect(server.wsHandshakes.filter(({ tag }) => tag === 'navigated')) + .toEqual([{ tag: 'navigated', authenticated: true, ssoValue: 'fixture-session-1', ssoCount: 1, ssoRwCount: 1 }]); + await expect.poll(() => fixture.getRuleTabIds()).toBeNull(); + } finally { + await fixture.close(); + } + }); +} + +test('adding Grok twice at once never exceeds the panel limit', async () => { + test.setTimeout(90000); + const fixture = await launchGrokFixture({ grantOptionalProviders: true }); + const { extension } = fixture; + const MAX_PANELS = 12; + const otherProviderIds = PROVIDERS.map(({ id }) => id).filter((id) => id !== 'grok').slice(0, MAX_PANELS - 1); + expect(otherProviderIds).toHaveLength(MAX_PANELS - 1); + + try { + // Multi-Panel opens min(enabled providers, layout capacity) panels, so + // start with exactly 11 enabled and enable Grok only for the add menu. + const page = await openMultiPanel(extension, { + providers: otherProviderIds, + layout: '1x12', + }); + await extension.serviceWorker.evaluate(async (enabledProviders) => { + await chrome.storage.sync.set({ enabledProviders, providerOrder: enabledProviders }); + }, [...otherProviderIds, 'grok']); + + await page.click('#add-panel-btn'); + const grokItem = page.locator('.add-panel-item[data-provider-id="grok"]'); + await expect(grokItem).toHaveCount(1); + // Both clicks start addPanel() before either Grok cookie sync resolves. + await grokItem.evaluate((item) => { + item.click(); + item.click(); + }); + + await expect(page.locator('#panel-grid iframe')).toHaveCount(MAX_PANELS); + await expect.poll(() => extension.serviceWorker.evaluate(async () => ( + (await chrome.storage.sync.get('multiPanelProviders')).multiPanelProviders.length + ))).toBe(MAX_PANELS); + await page.waitForTimeout(500); + await expect(page.locator('#panel-grid iframe')).toHaveCount(MAX_PANELS); } finally { - await extension.close(); - await server.close(); - await extensionCopy.cleanup(); + await fixture.close(); } }); diff --git a/tests/grok-websocket-cookies.test.js b/tests/grok-websocket-cookies.test.js index 585d82c..527c201 100644 --- a/tests/grok-websocket-cookies.test.js +++ b/tests/grok-websocket-cookies.test.js @@ -5,15 +5,20 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { GROK_COOKIE_URL, GROK_WEBSOCKET_COOKIE_RULE_ID, - clearRegisteredMultiPanelTabIds, + createGrokWebSocketCookieReconcileScheduler, createGrokWebSocketCookieRule, formatGrokWebSocketCookieHeader, getMultiPanelTabIds, + handleGrokTabNavigationComplete, + handleGrokTabNavigationStart, + handleGrokTabRemoved, + handleMultiPanelSyncRequest, isGrokCookieDomain, + isGrokWebSocketCookieChange, + isMultiPanelTopFrameSender, reconcileGrokWebSocketCookies, - registerMultiPanelTabId, + resetGrokWebSocketCookieState, syncGrokWebSocketCookies, - unregisterMultiPanelTabId, upgradeGrokConsentCookies, } from '../modules/grok-websocket-cookies.js'; @@ -25,19 +30,37 @@ const grokCookieConsentScript = readFileSync( 'utf8' ); +const MULTI_PANEL_URL = 'chrome-extension://test-ext-id/multi-panel/multi-panel.html'; + +function mockMultiPanelTabs(...tabIds) { + chrome.runtime.getContexts.mockResolvedValue(tabIds.map((tabId) => ({ + contextType: 'TAB', + documentUrl: MULTI_PANEL_URL, + frameId: 0, + tabId, + }))); +} + describe('Grok WebSocket cookie synchronization', () => { beforeEach(() => { - clearRegisteredMultiPanelTabIds(); + resetGrokWebSocketCookieState(); + chrome.runtime.id = 'test-ext-id'; chrome.runtime.getURL = vi.fn((path) => `chrome-extension://test-ext-id/${path}`); chrome.runtime.getContexts = vi.fn(() => Promise.resolve([])); - chrome.tabs.getCurrent = vi.fn(() => Promise.resolve(null)); chrome.cookies = { getAll: vi.fn(() => Promise.resolve([])), set: vi.fn(() => Promise.resolve({})), }; + let sessionRules = []; chrome.declarativeNetRequest = { - getSessionRules: vi.fn(() => Promise.resolve([])), - updateSessionRules: vi.fn(() => Promise.resolve()), + getSessionRules: vi.fn(() => Promise.resolve(sessionRules)), + updateSessionRules: vi.fn(({ removeRuleIds = [], addRules = [] }) => { + sessionRules = [ + ...sessionRules.filter(({ id }) => !removeRuleIds.includes(id)), + ...addRules, + ]; + return Promise.resolve(); + }), }; chrome.storage.sync.get.mockImplementation((defaults) => Promise.resolve(defaults)); }); @@ -110,26 +133,64 @@ describe('Grok WebSocket cookie synchronization', () => { }); }); - it('discovers Multi-Panel tab IDs from extension contexts and current tab', async () => { - chrome.tabs.getCurrent.mockResolvedValue({ id: 15 }); + it('restricts the rule to this extension as top-level site when topDomains is supported', async () => { + chrome.declarativeNetRequest.RuleConditionKeys = { TOP_DOMAINS: 'topDomains' }; + + expect(createGrokWebSocketCookieRule('sso=a', [3]).condition.topDomains).toEqual(['test-ext-id']); + + // A rule installed before topDomains was available gets replaced. + chrome.declarativeNetRequest.getSessionRules.mockResolvedValue([ + createGrokWebSocketCookieRule('sso=a', [3], null), + ]); + await syncGrokWebSocketCookies({ + enabledProviderIds: ['grok'], + tabIds: [3], + cookies: [{ name: 'sso', value: 'a', sameSite: 'lax' }], + }); + expect(chrome.declarativeNetRequest.updateSessionRules).toHaveBeenCalledWith({ + removeRuleIds: [GROK_WEBSOCKET_COOKIE_RULE_ID], + addRules: [expect.objectContaining({ + condition: expect.objectContaining({ topDomains: ['test-ext-id'], tabIds: [3] }), + })], + }); + }); + + it('omits topDomains on browsers that reject unknown rule condition keys', () => { + expect(createGrokWebSocketCookieRule('sso=a', [3]).condition).not.toHaveProperty('topDomains'); + }); + + it('discovers only Multi-Panel pages that are the top-level document of their tab', async () => { chrome.runtime.getContexts.mockResolvedValue([ - { - contextType: 'TAB', - documentUrl: 'chrome-extension://test-ext-id/multi-panel/multi-panel.html', - tabId: 8, - }, - { - contextType: 'TAB', - documentUrl: 'chrome-extension://test-ext-id/options/options.html', - tabId: 99, - }, + { contextType: 'TAB', documentUrl: `${MULTI_PANEL_URL}?mode=popup`, frameId: 0, tabId: 8 }, + { contextType: 'TAB', documentUrl: 'chrome-extension://test-ext-id/options/options.html', frameId: 0, tabId: 99 }, + // multi-panel.html is web-accessible, so third-party pages can frame it. + { contextType: 'TAB', documentUrl: MULTI_PANEL_URL, frameId: 5, tabId: 77 }, + { contextType: 'TAB', documentUrl: 'chrome-extension://other-ext/multi-panel/multi-panel.html', frameId: 0, tabId: 66 }, ]); - await expect(getMultiPanelTabIds()).resolves.toEqual([8, 15]); + await expect(getMultiPanelTabIds()).resolves.toEqual([8]); + }); + + it('forwards nothing when runtime.getContexts is unavailable', async () => { + delete chrome.runtime.getContexts; + + await expect(getMultiPanelTabIds()).resolves.toEqual([]); + }); + + it('accepts sync requests only from the top-level Multi-Panel page of this extension', () => { + const trusted = { id: 'test-ext-id', frameId: 0, tab: { id: 3 }, url: MULTI_PANEL_URL }; + + expect(isMultiPanelTopFrameSender(trusted)).toBe(true); + expect(isMultiPanelTopFrameSender({ ...trusted, frameId: 4 })).toBe(false); + expect(isMultiPanelTopFrameSender({ ...trusted, url: 'https://grok.com/' })).toBe(false); + expect(isMultiPanelTopFrameSender({ ...trusted, url: 'chrome-extension://test-ext-id/options/options.html' })).toBe(false); + expect(isMultiPanelTopFrameSender({ ...trusted, id: 'other-ext' })).toBe(false); + expect(isMultiPanelTopFrameSender({ ...trusted, tab: undefined })).toBe(false); + expect(isMultiPanelTopFrameSender(undefined)).toBe(false); }); it('installs and updates the Grok WebSocket session rule when Grok is enabled', async () => { - registerMultiPanelTabId(12); + mockMultiPanelTabs(12); chrome.cookies.getAll.mockResolvedValue([ { name: 'sso', value: 'session-a', sameSite: 'lax', httpOnly: true }, { name: 'sso-rw', value: 'session-rw-a', sameSite: 'strict', httpOnly: true }, @@ -139,13 +200,21 @@ describe('Grok WebSocket cookie synchronization', () => { expect(chrome.cookies.getAll).toHaveBeenCalledWith({ url: GROK_COOKIE_URL }); expect(chrome.declarativeNetRequest.updateSessionRules).toHaveBeenCalledWith({ - removeRuleIds: [], + removeRuleIds: [GROK_WEBSOCKET_COOKIE_RULE_ID], addRules: [ createGrokWebSocketCookieRule('sso=session-a; sso-rw=session-rw-a', [12]), ], }); }); + it('propagates failures instead of swallowing them', async () => { + mockMultiPanelTabs(12); + chrome.cookies.getAll.mockResolvedValue([{ name: 'sso', value: 'session-a', sameSite: 'lax' }]); + chrome.declarativeNetRequest.updateSessionRules.mockRejectedValue(new Error('quota')); + + await expect(reconcileGrokWebSocketCookies()).rejects.toThrow('quota'); + }); + it('skips redundant session rule updates when the installed rule already matches', async () => { const rule = createGrokWebSocketCookieRule('sso=session-a', [12]); chrome.declarativeNetRequest.getSessionRules.mockResolvedValue([rule]); @@ -161,12 +230,14 @@ describe('Grok WebSocket cookie synchronization', () => { it('removes the session rule when Grok is disabled or Multi-Panel tabs close', async () => { const existingRule = createGrokWebSocketCookieRule('sso=session-a', [12]); - registerMultiPanelTabId(12); + mockMultiPanelTabs(12); + chrome.cookies.getAll.mockResolvedValue([{ name: 'sso', value: 'session-a', sameSite: 'lax' }]); chrome.declarativeNetRequest.getSessionRules.mockResolvedValue([existingRule]); chrome.storage.sync.get.mockResolvedValue({ enabledProviders: ['chatgpt'] }); await reconcileGrokWebSocketCookies(); + expect(chrome.runtime.getContexts).not.toHaveBeenCalled(); expect(chrome.declarativeNetRequest.updateSessionRules).toHaveBeenCalledWith({ removeRuleIds: [GROK_WEBSOCKET_COOKIE_RULE_ID], addRules: [], @@ -174,7 +245,7 @@ describe('Grok WebSocket cookie synchronization', () => { chrome.declarativeNetRequest.updateSessionRules.mockClear(); chrome.storage.sync.get.mockResolvedValue({ enabledProviders: ['grok'] }); - unregisterMultiPanelTabId(12); + mockMultiPanelTabs(); await reconcileGrokWebSocketCookies(); @@ -184,6 +255,140 @@ describe('Grok WebSocket cookie synchronization', () => { }); }); + it('drops a Multi-Panel tab from the rule while its top-level document navigates', async () => { + mockMultiPanelTabs(12, 20); + chrome.cookies.getAll.mockResolvedValue([{ name: 'sso', value: 'session-a', sameSite: 'lax' }]); + await reconcileGrokWebSocketCookies(); + chrome.declarativeNetRequest.updateSessionRules.mockClear(); + + // Unrelated tab navigations do not require a reconcile. + await expect(handleGrokTabNavigationStart(30)).resolves.toBe(false); + expect(handleGrokTabNavigationComplete(30)).toBe(false); + + // Tab 12 starts navigating: it leaves the rule immediately. + await expect(handleGrokTabNavigationStart(12)).resolves.toBe(true); + expect(chrome.declarativeNetRequest.updateSessionRules).toHaveBeenLastCalledWith({ + removeRuleIds: [GROK_WEBSOCKET_COOKIE_RULE_ID], + addRules: [createGrokWebSocketCookieRule('sso=session-a', [20])], + }); + + // A reconcile while getContexts still lists the old document keeps it out. + chrome.declarativeNetRequest.updateSessionRules.mockClear(); + await reconcileGrokWebSocketCookies(); + expect(chrome.declarativeNetRequest.updateSessionRules).not.toHaveBeenCalled(); + + // Navigation finished on a page that is no longer the Multi-Panel page. + mockMultiPanelTabs(20); + expect(handleGrokTabNavigationComplete(12)).toBe(true); + chrome.declarativeNetRequest.updateSessionRules.mockClear(); + await reconcileGrokWebSocketCookies(); + expect(chrome.declarativeNetRequest.updateSessionRules).not.toHaveBeenCalled(); + }); + + it('re-admits a reloading Multi-Panel tab once its new page requests a sync', async () => { + mockMultiPanelTabs(12); + chrome.cookies.getAll.mockResolvedValue([{ name: 'sso', value: 'session-a', sameSite: 'lax' }]); + await reconcileGrokWebSocketCookies(); + + await expect(handleGrokTabNavigationStart(12)).resolves.toBe(true); + expect(chrome.declarativeNetRequest.updateSessionRules).toHaveBeenLastCalledWith({ + removeRuleIds: [GROK_WEBSOCKET_COOKIE_RULE_ID], + addRules: [], + }); + + handleMultiPanelSyncRequest(12); + await reconcileGrokWebSocketCookies(); + expect(chrome.declarativeNetRequest.updateSessionRules).toHaveBeenLastCalledWith({ + removeRuleIds: [GROK_WEBSOCKET_COOKIE_RULE_ID], + addRules: [createGrokWebSocketCookieRule('sso=session-a', [12])], + }); + expect(handleGrokTabNavigationComplete(12)).toBe(false); + }); + + it('reconciles on tab close only when the rule covers the closed tab', async () => { + chrome.declarativeNetRequest.getSessionRules.mockResolvedValue([ + createGrokWebSocketCookieRule('sso=session-a', [12]), + ]); + + await expect(handleGrokTabRemoved(99)).resolves.toBe(false); + await expect(handleGrokTabRemoved(12)).resolves.toBe(true); + // The installed tab set is cached after the first read. + expect(chrome.declarativeNetRequest.getSessionRules).toHaveBeenCalledTimes(1); + }); + + it('only treats cookie changes that alter the forwarded header as relevant', () => { + expect(isGrokWebSocketCookieChange({ cookie: { domain: '.grok.com', name: 'sso', value: 'a', sameSite: 'lax' } })).toBe(true); + expect(isGrokWebSocketCookieChange({ removed: true, cause: 'overwrite', cookie: { domain: '.grok.com', name: 'OptanonConsent', value: 'a', sameSite: 'lax' } })).toBe(true); + expect(isGrokWebSocketCookieChange({ cookie: { domain: '.grok.com', name: '__cf_bm', value: 'a', sameSite: 'no_restriction' } })).toBe(false); + expect(isGrokWebSocketCookieChange({ cookie: { domain: 'grok.com', name: 'cf', value: 'a', sameSite: 'lax', partitionKey: { topLevelSite: 'chrome-extension://x' } } })).toBe(false); + expect(isGrokWebSocketCookieChange({ cookie: { domain: '.x.com', name: 'sso', value: 'a', sameSite: 'lax' } })).toBe(false); + }); + + it('coalesces queued reconcile requests and reports failures without rejecting', async () => { + const calls = []; + let releaseFirst; + const reconcile = vi.fn((options) => { + calls.push(options); + if (calls.length === 1) { + return new Promise((resolve) => { releaseFirst = resolve; }); + } + if (calls.length === 3) { + return Promise.reject(new Error('boom')); + } + return Promise.resolve(); + }); + const onError = vi.fn(); + const schedule = createGrokWebSocketCookieReconcileScheduler(reconcile, onError); + + const first = schedule(); + await Promise.resolve(); + await Promise.resolve(); + // Three requests while the first run is in progress share one queued run. + const second = schedule(); + const third = schedule({ upgradeConsentCookies: true }); + const fourth = schedule(); + expect(third).toBe(second); + expect(fourth).toBe(second); + + releaseFirst(); + await expect(first).resolves.toEqual({ success: true }); + await expect(second).resolves.toEqual({ success: true }); + expect(reconcile).toHaveBeenCalledTimes(2); + expect(calls[1]).toEqual({ upgradeConsentCookies: true }); + + await expect(schedule()).resolves.toEqual({ success: false, error: 'boom' }); + expect(onError).toHaveBeenCalledWith(expect.objectContaining({ message: 'boom' })); + await expect(schedule()).resolves.toEqual({ success: true }); + expect(calls[2]).toEqual({}); + }); + + it('upgrades consent cookies only for explicit Multi-Panel sync requests', async () => { + mockMultiPanelTabs(12); + const consentCookie = { + name: 'OptanonConsent', + value: 'groups=C0001:1', + domain: '.grok.com', + path: '/', + secure: false, + sameSite: 'lax', + }; + chrome.cookies.getAll.mockResolvedValue([ + consentCookie, + { name: 'sso', value: 'session-a', sameSite: 'lax' }, + ]); + + await reconcileGrokWebSocketCookies(); + expect(chrome.cookies.set).not.toHaveBeenCalled(); + + await reconcileGrokWebSocketCookies({ upgradeConsentCookies: true }); + expect(chrome.cookies.set).toHaveBeenCalledTimes(1); + // The upgraded cookie is now attached natively, so it leaves the header. + expect(chrome.declarativeNetRequest.updateSessionRules).toHaveBeenLastCalledWith({ + removeRuleIds: [GROK_WEBSOCKET_COOKIE_RULE_ID], + addRules: [createGrokWebSocketCookieRule('sso=session-a', [12])], + }); + }); + it('upgrades existing OneTrust consent cookies to SameSite=None; Secure for embedded frames', async () => { const cookies = [ { @@ -234,10 +439,12 @@ describe('Grok WebSocket cookie synchronization', () => { }); }); - it('rewrites Optanon consent cookies written via document.cookie in embedded Grok frames', () => { + function runConsentScript({ cookieOwner = 'document', ancestorOrigins = ['chrome-extension://test-ext-id'] } = {}) { const writtenValues = []; - const fakeDocumentProto = {}; - Object.defineProperty(fakeDocumentProto, 'cookie', { + const documentProto = {}; + const htmlDocumentProto = Object.create(documentProto); + const owner = cookieOwner === 'htmlDocument' ? htmlDocumentProto : documentProto; + Object.defineProperty(owner, 'cookie', { configurable: true, enumerable: true, get() { @@ -247,36 +454,41 @@ describe('Grok WebSocket cookie synchronization', () => { writtenValues.push(value); }, }); - const fakeDoc = Object.create(fakeDocumentProto); - const fakeWindow = { - self: {}, - top: {}, - location: { protocol: 'https:' }, - Document: { prototype: fakeDocumentProto }, - HTMLDocument: { prototype: fakeDocumentProto }, - }; + const fakeDoc = Object.create(htmlDocumentProto); + const fakeLocation = { protocol: 'https:', ancestorOrigins }; + const fakeWindow = { self: {}, top: {}, location: fakeLocation }; - new Function( - 'window', - 'document', - 'location', - 'Document', - 'HTMLDocument', - grokCookieConsentScript - )( + new Function('window', 'document', 'location', grokCookieConsentScript)( fakeWindow, fakeDoc, - fakeWindow.location, - fakeWindow.Document, - fakeWindow.HTMLDocument + fakeLocation ); fakeDoc.cookie = 'OptanonAlertBoxClosed=2026-10-16T13:00:00.000Z; path=/; domain=.grok.com; Samesite=Lax'; fakeDoc.cookie = 'other_cookie=1; path=/; SameSite=Lax'; + return writtenValues; + } - expect(writtenValues).toEqual([ + it('rewrites Optanon consent cookies written via document.cookie in Grok frames embedded by an extension page', () => { + expect(runConsentScript()).toEqual([ 'OptanonAlertBoxClosed=2026-10-16T13:00:00.000Z; path=/; domain=.grok.com; SameSite=None; Secure', 'other_cookie=1; path=/; SameSite=Lax', ]); }); + + it('patches the cookie accessor on whichever prototype owns it', () => { + expect(runConsentScript({ cookieOwner: 'htmlDocument' })[0]).toBe( + 'OptanonAlertBoxClosed=2026-10-16T13:00:00.000Z; path=/; domain=.grok.com; SameSite=None; Secure' + ); + }); + + it('leaves consent cookies untouched in Grok frames embedded by websites', () => { + const untouched = 'OptanonAlertBoxClosed=2026-10-16T13:00:00.000Z; path=/; domain=.grok.com; Samesite=Lax'; + + expect(runConsentScript({ ancestorOrigins: ['https://attacker.test'] })[0]).toBe(untouched); + // A website framing the web-accessible Multi-Panel page. + expect(runConsentScript({ + ancestorOrigins: ['chrome-extension://test-ext-id', 'https://attacker.test'], + })[0]).toBe(untouched); + }); });