From bf7a1e8d25d2aa4c2a93099a478f4de15d5a7592 Mon Sep 17 00:00:00 2001 From: CoderLambert Date: Mon, 14 Sep 2026 18:22:56 +0800 Subject: [PATCH] fix: grock --- README.md | 9 +- apps/browser-extension/README.md | 11 +- .../browser-extension/content/frame-bridge.js | 117 ++++++++++-------- apps/browser-extension/manifest.json | 2 +- .../rules/bypass-headers.json | 9 -- apps/browser-extension/service-worker.js | 72 ++++++++++- apps/browser-extension/workspace/index.html | 6 + .../browser-extension/workspace/workspace.css | 7 ++ apps/browser-extension/workspace/workspace.js | 99 +++++++++++---- docs/architecture.md | 33 ++--- docs/v2.1-phase0-review.md | 20 +-- tests/service-worker-auth.test.js | 32 ++++- tests/top-level-bridge.test.js | 52 ++++++++ 13 files changed, 344 insertions(+), 125 deletions(-) create mode 100644 tests/top-level-bridge.test.js diff --git a/README.md b/README.md index 23a824e..d091a47 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,8 @@ AI Parallel is a monorepo for comparing and orchestrating multiple AI web experi The first app is a Chrome/Edge Manifest V3 extension. It reuses the user's existing authenticated web sessions and embeds the selected model sites as -live provider iframes in a single workspace. +live provider iframes in a single workspace. Providers that are not iframe-safe, +currently Grok, run in a controlled top-level tab through the same adapter contract. ## Current app @@ -35,11 +36,13 @@ AI Parallel Workspace ├─ ChatGPT iframe ─ Content Script ─┐ ├─ DeepSeek iframe ─ Content Script ├─ postMessage bridge ├─ Qwen iframe ───── Content Script ├─ native provider rendering - └─ Kimi iframe ───── Content Script ┘ + ├─ Kimi iframe ───── Content Script ┘ + └─ Grok tab ─ Service Worker ─ tabs.sendMessage bridge ``` Prompts are not transported in destination URLs. The workspace sends one-time -jobs to the selected iframe through an origin-checked `postMessage` bridge. +jobs to iframe providers through an origin-checked `postMessage` bridge and to +tab-mode providers through a provider-scoped service-worker bridge. ## Repository structure diff --git a/apps/browser-extension/README.md b/apps/browser-extension/README.md index 1c36f82..1a709f6 100644 --- a/apps/browser-extension/README.md +++ b/apps/browser-extension/README.md @@ -13,14 +13,14 @@ Workspace extension page ├─ Kimi iframe ├─ Claude iframe ├─ Gemini iframe - └─ Grok iframe + └─ Grok controlled top-level tab │ ├─ content/providers/core.js shared DOM operations ├─ content/providers/.js provider selectors └─ content/frame-bridge.js message boundary + adapter delegation ``` -`rules/bypass-headers.json` removes `X-Frame-Options` and framing CSP headers only for matching `sub_frame` responses so the original provider pages can render inside the extension workspace. +`rules/bypass-headers.json` removes `X-Frame-Options` and framing CSP headers only for matching `sub_frame` responses so compatible provider pages can render inside the extension workspace. Grok runs in a controlled top-level tab because its authenticated WebSocket channel does not work reliably inside an extension iframe. The original provider page remains visible directly. Compare can collect the latest visible response on demand for safe text rendering and export; it does @@ -30,10 +30,9 @@ not inject provider HTML or replace the native iframe view. Open `chrome://extensions`, enable Developer mode, choose **Load unpacked**, and select this directory. -Grok authentication is intentionally completed in a top-level browser tab because -`accounts.x.ai` does not permit login inside an iframe. Grok `/sign-in` links are -redirected to a top-level tab automatically; alternatively use **登录 ↗** in the -Grok panel. Finish signing in, return to the workspace, and reload the Grok panel. +Grok authentication and chat run in a top-level browser tab because `accounts.x.ai` +does not permit iframe login and Grok's real-time WebSocket is not iframe-safe. AI +Parallel reuses that tab for prompt dispatch, response collection, and handoff. ## Security boundary diff --git a/apps/browser-extension/content/frame-bridge.js b/apps/browser-extension/content/frame-bridge.js index 1beb7e9..46a9ba4 100644 --- a/apps/browser-extension/content/frame-bridge.js +++ b/apps/browser-extension/content/frame-bridge.js @@ -1,13 +1,13 @@ (() => { - if (window.top === window) return; - try { - if (window.parent !== window.top) return; - } catch { - return; + const isTopLevel = window.top === window; + if (!isTopLevel) { + try { + if (window.parent !== window.top) return; + } catch { + return; + } } - const MESSAGE_CONTEXT = "ai-parallel-workspace"; - const PARENT_ORIGIN = new URL(chrome.runtime.getURL("/")).origin; const adapters = globalThis.AIParallelProviderAdapters || {}; const providerId = Object.entries(adapters) .find(([, adapter]) => adapter.hosts.includes(location.hostname))?.[0]; @@ -15,6 +15,62 @@ const adapter = adapters[providerId]; + async function executeCommand(message) { + const messageType = message.type; + const requestId = String(message.requestId || ""); + if (!requestId) return { ok: false, error: "Missing request ID" }; + + if (messageType === "AI_PARALLEL_COLLECT_RESPONSE") { + const response = adapter.collectResponse(); + return { + type: "AI_PARALLEL_RESPONSE_RESULT", + requestId, + ok: Boolean(response), + response: response + ? { provider: providerId, ...response, timestamp: new Date().toISOString() } + : null, + error: response ? "" : "尚未找到可收集的模型回答" + }; + } + + if (messageType === "AI_PARALLEL_NEW_CHAT") { + const ok = adapter.newChat(); + return { + type: "AI_PARALLEL_NEW_CHAT_RESULT", + requestId, + ok, + error: ok ? "" : "未找到新建对话按钮" + }; + } + + if (messageType !== "AI_PARALLEL_SEND") return { ok: false, error: "Unknown provider command" }; + const prompt = String(message.prompt || "").trim(); + if (!prompt) return { type: "AI_PARALLEL_SEND_RESULT", requestId, ok: false, error: "Prompt 不能为空" }; + + try { + await adapter.sendPrompt(prompt); + return { type: "AI_PARALLEL_SEND_RESULT", requestId, ok: true }; + } catch (error) { + return { + type: "AI_PARALLEL_SEND_RESULT", + requestId, + ok: false, + error: error instanceof Error ? error.message : String(error) + }; + } + } + + if (isTopLevel) { + chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { + if (message?.type !== "AI_PARALLEL_TAB_COMMAND" || message.providerId !== providerId) return false; + executeCommand(message.command || {}).then(sendResponse); + return true; + }); + return; + } + + const PARENT_ORIGIN = new URL(chrome.runtime.getURL("/")).origin; + function postParent(payload) { window.parent.postMessage( { ...payload, context: MESSAGE_CONTEXT, providerId }, @@ -36,51 +92,8 @@ return; } - const messageType = event.data.type; - if (!["AI_PARALLEL_SEND", "AI_PARALLEL_COLLECT_RESPONSE", "AI_PARALLEL_NEW_CHAT"].includes(messageType)) return; - const requestId = String(event.data.requestId || ""); - if (!requestId) return; - - if (messageType === "AI_PARALLEL_COLLECT_RESPONSE") { - const response = adapter.collectResponse(); - postParent({ - type: "AI_PARALLEL_RESPONSE_RESULT", - requestId, - ok: Boolean(response), - response: response - ? { provider: providerId, ...response, timestamp: new Date().toISOString() } - : null, - error: response ? "" : "尚未找到可收集的模型回答" - }); - return; - } - - if (messageType === "AI_PARALLEL_NEW_CHAT") { - const ok = adapter.newChat(); - postParent({ - type: "AI_PARALLEL_NEW_CHAT_RESULT", - requestId, - ok, - error: ok ? "" : "未找到新建对话按钮" - }); - return; - } - - const prompt = String(event.data.prompt || "").trim(); - - if (!prompt) { - postParent({ type: "AI_PARALLEL_SEND_RESULT", requestId, ok: false, error: "Prompt 不能为空" }); - return; - } - - adapter.sendPrompt(prompt) - .then(() => postParent({ type: "AI_PARALLEL_SEND_RESULT", requestId, ok: true })) - .catch((error) => postParent({ - type: "AI_PARALLEL_SEND_RESULT", - requestId, - ok: false, - error: error instanceof Error ? error.message : String(error) - })); + if (!["AI_PARALLEL_SEND", "AI_PARALLEL_COLLECT_RESPONSE", "AI_PARALLEL_NEW_CHAT"].includes(event.data.type)) return; + executeCommand(event.data).then(postParent); }); announceReady(); diff --git a/apps/browser-extension/manifest.json b/apps/browser-extension/manifest.json index ef85b18..0cb8f17 100644 --- a/apps/browser-extension/manifest.json +++ b/apps/browser-extension/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "AI Parallel", - "version": "2.1.3", + "version": "2.1.4", "description": "Compare multiple AI web chats side by side in one live workspace.", "minimum_chrome_version": "120", "permissions": [ diff --git a/apps/browser-extension/rules/bypass-headers.json b/apps/browser-extension/rules/bypass-headers.json index c539730..b39f5bd 100644 --- a/apps/browser-extension/rules/bypass-headers.json +++ b/apps/browser-extension/rules/bypass-headers.json @@ -79,14 +79,5 @@ { "header": "Content-Security-Policy", "operation": "remove" } ]}, "condition": { "urlFilter": "https://chat.openai.com/*", "resourceTypes": ["sub_frame"] } - }, - { - "id": 10, - "priority": 1, - "action": { "type": "modifyHeaders", "responseHeaders": [ - { "header": "X-Frame-Options", "operation": "remove" }, - { "header": "Content-Security-Policy", "operation": "remove" } - ]}, - "condition": { "urlFilter": "https://grok.com/*", "resourceTypes": ["sub_frame"] } } ] diff --git a/apps/browser-extension/service-worker.js b/apps/browser-extension/service-worker.js index 61d93bb..9c75a51 100644 --- a/apps/browser-extension/service-worker.js +++ b/apps/browser-extension/service-worker.js @@ -6,7 +6,7 @@ const PROVIDERS = { kimi: { name: "Kimi", url: "https://www.kimi.com/" }, claude: { name: "Claude", url: "https://claude.ai/new" }, gemini: { name: "Gemini", url: "https://gemini.google.com/app" }, - grok: { name: "Grok", url: "https://grok.com/" } + grok: { name: "Grok", url: "https://grok.com/", tabMode: true, hosts: ["grok.com", "www.grok.com"] } }; const PROVIDER_AUTH_HOSTS = { @@ -14,6 +14,67 @@ const PROVIDER_AUTH_HOSTS = { }; const WORKSPACE_PATH = "workspace/index.html"; +const PROVIDER_COMMANDS = new Set([ + "AI_PARALLEL_SEND", + "AI_PARALLEL_COLLECT_RESPONSE", + "AI_PARALLEL_NEW_CHAT" +]); + +const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +function matchesProviderTab(tab, provider) { + if (!tab?.id || typeof tab.url !== "string") return false; + try { + return provider.hosts?.includes(new URL(tab.url).hostname) || false; + } catch { + return false; + } +} + +async function ensureProviderTab(providerId, { active = false } = {}) { + const provider = PROVIDERS[providerId]; + if (!provider) throw new Error("Unknown provider"); + const tabs = await chrome.tabs.query({}); + const existing = tabs.find((tab) => matchesProviderTab(tab, provider)); + if (existing) { + if (active) { + await chrome.tabs.update(existing.id, { active: true }); + if (existing.windowId) await chrome.windows.update(existing.windowId, { focused: true }); + } + return { tab: existing, created: false }; + } + const tab = await chrome.tabs.create({ url: provider.url, active: true }); + return { tab, created: true }; +} + +async function deliverProviderCommand(tabId, message, attempts) { + let lastError; + for (let attempt = 0; attempt < attempts; attempt += 1) { + try { + return await chrome.tabs.sendMessage(tabId, message); + } catch (error) { + lastError = error; + await delay(250); + } + } + throw lastError || new Error("Provider tab is not ready"); +} + +async function sendProviderTabCommand(providerId, command) { + const provider = PROVIDERS[providerId]; + if (!provider?.tabMode) throw new Error("Provider does not support tab mode"); + if (!PROVIDER_COMMANDS.has(command?.type)) throw new Error("Unknown provider command"); + + const { tab, created } = await ensureProviderTab(providerId); + const message = { type: "AI_PARALLEL_TAB_COMMAND", providerId, command }; + try { + return await deliverProviderCommand(tab.id, message, created ? 40 : 4); + } catch (error) { + if (created) throw error; + await chrome.tabs.reload(tab.id); + return deliverProviderCommand(tab.id, message, 40); + } +} async function ensureWorkspace() { const workspaceUrl = chrome.runtime.getURL(WORKSPACE_PATH); @@ -62,11 +123,18 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { sendResponse({ ok: false, error: "Unknown provider" }); return; } - const tab = await chrome.tabs.create({ url: provider.url, active: true }); + const { tab } = await ensureProviderTab(providerId, { active: true }); sendResponse({ ok: true, tabId: tab.id }); return; } + if (message.type === "PROVIDER_TAB_COMMAND") { + const providerId = String(message.providerId || ""); + const result = await sendProviderTabCommand(providerId, message.command); + sendResponse(result || { ok: false, error: "Provider did not return a result" }); + return; + } + if (message.type === "OPEN_PROVIDER_AUTH") { const providerId = String(message.providerId || ""); const allowedHosts = PROVIDER_AUTH_HOSTS[providerId]; diff --git a/apps/browser-extension/workspace/index.html b/apps/browser-extension/workspace/index.html index 34e0964..340efd4 100644 --- a/apps/browser-extension/workspace/index.html +++ b/apps/browser-extension/workspace/index.html @@ -105,6 +105,12 @@
正在加载原站…
+
diff --git a/apps/browser-extension/workspace/workspace.css b/apps/browser-extension/workspace/workspace.css index bab4157..77bf185 100644 --- a/apps/browser-extension/workspace/workspace.css +++ b/apps/browser-extension/workspace/workspace.css @@ -59,8 +59,15 @@ body { overflow: hidden; } .panel-btn:hover { background: var(--surface-2); color: var(--text); } .frame-wrap { position: relative; min-height: 0; background: white; } .frame-wrap iframe { width: 100%; height: 100%; min-height: 420px; display: block; border: 0; background: white; } +.frame-wrap iframe[hidden], .external-provider[hidden] { display: none; } .frame-loading { position: absolute; inset: 0; z-index: 2; display: grid; place-items: center; background: var(--surface); color: var(--muted); font-size: 11px; pointer-events: none; } .provider-panel[data-loaded="true"] .frame-loading { display: none; } +.external-provider { width: 100%; height: 100%; min-height: 420px; display: grid; place-content: center; justify-items: center; gap: 10px; padding: 28px; background: radial-gradient(circle at 50% 30%, #20202a 0, #111318 42%, #0d0f13 100%); color: var(--text); text-align: center; } +.external-provider-mark { width: 42px; height: 42px; display: grid; place-items: center; border: 1px solid var(--border-strong); border-radius: 13px; background: #f4f6f8; color: #090a0c; font-size: 18px; font-weight: 800; } +.external-provider strong { font-size: 13px; } +.external-provider p { max-width: 360px; margin: 0; color: var(--muted); font-size: 10px; line-height: 1.6; } +.external-open-btn { min-height: 32px; border: 1px solid var(--border-strong); border-radius: 8px; padding: 0 12px; background: #191c23; color: var(--text); cursor: pointer; font-size: 10px; font-weight: 700; } +.external-open-btn:hover { border-color: var(--accent); background: #211f32; } .composer-shell { padding: 9px 14px 10px; border-top: 1px solid var(--border); background: rgba(12,14,18,.98); } .dispatch-status { width: min(980px, 100%); margin: 0 auto 6px; color: var(--subtle); font-size: 9px; } .composer { width: min(980px, 100%); margin: 0 auto; display: grid; grid-template-columns: minmax(0,1fr) auto; gap: 8px; padding: 6px; border: 1px solid var(--border-strong); border-radius: 12px; background: #0a0c10; box-shadow: 0 10px 30px rgba(0,0,0,.2); } diff --git a/apps/browser-extension/workspace/workspace.js b/apps/browser-extension/workspace/workspace.js index b12d37a..bd9ae00 100644 --- a/apps/browser-extension/workspace/workspace.js +++ b/apps/browser-extension/workspace/workspace.js @@ -6,7 +6,7 @@ const PROVIDERS = [ { id: "kimi", name: "Kimi", url: "https://www.kimi.com/", origins: ["https://www.kimi.com", "https://kimi.com"], default: true }, { id: "claude", name: "Claude", url: "https://claude.ai/new", default: false }, { id: "gemini", name: "Gemini", url: "https://gemini.google.com/app", default: false }, - { id: "grok", name: "Grok", url: "https://grok.com/", loginUrl: "https://grok.com/", default: false } + { id: "grok", name: "Grok", url: "https://grok.com/", mode: "tab", default: false } ]; const MESSAGE_CONTEXT = "ai-parallel-workspace"; @@ -112,6 +112,15 @@ function pingFrame(providerId) { postToFrame(providerId, { type: "AI_PARALLEL_PING" }); } +async function openProviderTab(provider) { + const result = await chrome.runtime.sendMessage({ + type: "OPEN_PROVIDER_TAB", + providerId: provider.id + }); + if (!result?.ok) throw new Error(result?.error || "无法打开模型页面"); + return result; +} + function ensurePanel(provider) { if (panels.has(provider.id)) return panels.get(provider.id); const fragment = panelTemplate.content.cloneNode(true); @@ -121,9 +130,42 @@ function ensurePanel(provider) { panel.dataset.ready = "false"; panel.dataset.loaded = "false"; panel.querySelector(".provider-name").textContent = provider.name; - iframe.src = provider.url; iframe.title = provider.name; + if (provider.mode === "tab") { + const externalProvider = panel.querySelector(".external-provider"); + iframe.hidden = true; + externalProvider.hidden = false; + panel.dataset.loaded = "true"; + panel.dataset.ready = "true"; + panel.querySelector(".provider-state").textContent = "受控标签页"; + panel.querySelector(".reload-btn").title = "打开或聚焦独立标签页"; + panel.querySelector(".reload-btn").addEventListener("click", () => { + openProviderTab(provider).catch((error) => showError(error.message)); + }); + externalProvider.querySelector(".external-open-btn").addEventListener("click", () => { + openProviderTab(provider).catch((error) => showError(error.message)); + }); + } else { + iframe.src = provider.url; + + iframe.addEventListener("load", () => { + panel.dataset.loaded = "true"; + panel.dataset.ready = "false"; + panel.querySelector(".provider-state").textContent = "等待桥接"; + for (const delay of [80, 400, 1200, 2500]) { + setTimeout(() => pingFrame(provider.id), delay); + } + }); + + panel.querySelector(".reload-btn").addEventListener("click", () => { + panel.dataset.loaded = "false"; + panel.dataset.ready = "false"; + panel.querySelector(".provider-state").textContent = "重新加载"; + iframe.src = provider.url; + }); + } + const authButton = panel.querySelector(".auth-btn"); if (provider.loginUrl) { authButton.hidden = false; @@ -143,25 +185,9 @@ function ensurePanel(provider) { }); } - iframe.addEventListener("load", () => { - panel.dataset.loaded = "true"; - panel.dataset.ready = "false"; - panel.querySelector(".provider-state").textContent = "等待桥接"; - for (const delay of [80, 400, 1200, 2500]) { - setTimeout(() => pingFrame(provider.id), delay); - } - }); - - panel.querySelector(".reload-btn").addEventListener("click", () => { - panel.dataset.loaded = "false"; - panel.dataset.ready = "false"; - panel.querySelector(".provider-state").textContent = "重新加载"; - iframe.src = provider.url; - }); - panel.querySelector(".open-btn").addEventListener("click", async () => { try { - await chrome.tabs.create({ url: provider.url, active: true }); + await openProviderTab(provider); } catch (error) { showError(error instanceof Error ? error.message : "无法打开模型页面"); } @@ -312,12 +338,31 @@ async function requestFrameMessage(providerId, type, payload = {}, timeoutMs = 3 }); } -function sendPromptToFrame(providerId, prompt) { - return requestFrameMessage(providerId, "AI_PARALLEL_SEND", { prompt }); +async function requestProviderMessage(providerId, type, payload = {}, timeoutMs = 30000) { + const provider = providerById(providerId); + if (provider?.mode !== "tab") return requestFrameMessage(providerId, type, payload, timeoutMs); + + const requestId = crypto.randomUUID(); + try { + const result = await chrome.runtime.sendMessage({ + type: "PROVIDER_TAB_COMMAND", + providerId, + command: { type, requestId, ...payload } + }); + return result?.ok === true + ? result + : { ok: false, error: result?.error || "独立标签页命令执行失败" }; + } catch (error) { + return { ok: false, error: error instanceof Error ? error.message : String(error) }; + } } -function collectResponseFromFrame(providerId) { - return requestFrameMessage(providerId, "AI_PARALLEL_COLLECT_RESPONSE", {}, 12000); +function sendPromptToProvider(providerId, prompt) { + return requestProviderMessage(providerId, "AI_PARALLEL_SEND", { prompt }); +} + +function collectResponseFromProvider(providerId) { + return requestProviderMessage(providerId, "AI_PARALLEL_COLLECT_RESPONSE", {}, 12000); } async function dispatchPrompt() { @@ -336,7 +381,7 @@ async function dispatchPrompt() { await chrome.storage.local.set({ draftPrompt: promptInput.value }); const pairs = await Promise.all([...selected].map(async (providerId) => [ providerId, - await sendPromptToFrame(providerId, prompt) + await sendPromptToProvider(providerId, prompt) ])); const results = Object.fromEntries(pairs); @@ -429,7 +474,7 @@ async function collectResponses() { try { const pairs = await Promise.all([...selected].map(async (providerId) => [ providerId, - await collectResponseFromFrame(providerId) + await collectResponseFromProvider(providerId) ])); for (const [providerId, result] of pairs) responseBundles.set(providerId, result); const count = pairs.filter(([, result]) => result.ok).length; @@ -620,13 +665,13 @@ async function sendToAgent() { renderProviderBar(); renderPanels(); updateMeta(); - compareStatus.textContent = `正在打开 ${providerName(target)} iframe…`; + compareStatus.textContent = `正在打开 ${providerName(target)}…`; } sendAgentBtn.disabled = true; compareStatus.textContent = `正在发送上下文到 ${providerName(target)}…`; try { - const result = await sendPromptToFrame(target, buildHandoffPrompt()); + const result = await sendPromptToProvider(target, buildHandoffPrompt()); compareStatus.textContent = result.ok ? `上下文已发送到 ${providerName(target)}` : result.error || "Agent handoff 失败"; diff --git a/docs/architecture.md b/docs/architecture.md index 8e613e3..d6651ca 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -15,7 +15,7 @@ AI Parallel Workspace (extension page) ├─ Kimi iframe ├─ Claude iframe ├─ Gemini iframe - └─ Grok iframe + └─ Grok controlled top-level tab │ └─ content/frame-bridge.js ├─ providers/core.js @@ -27,10 +27,11 @@ AI Parallel Workspace (extension page) └─ host and selector knowledge per provider ``` -The original provider page remains the primary renderer inside the workspace -panel. When the user opens Compare, the selected iframe adapter can collect -the latest visible response on demand for the comparison drawer; the drawer -renders safe text and never injects provider HTML. +The original provider page remains the primary renderer. Iframe-compatible +providers render inside the workspace panel. Grok remains in a normal top-level +tab so its login state and WebSocket channel retain first-party browser behavior. +When the user opens Compare, the selected provider adapter can collect the latest +visible response on demand; the drawer renders safe text and never injects provider HTML. ## Framing model @@ -38,15 +39,19 @@ Most AI web applications send `X-Frame-Options` and/or CSP `frame-ancestors` hea Rules live in `apps/browser-extension/rules/bypass-headers.json`. -The extension does not rewrite provider HTML or JavaScript. It does not request cookie permission; the provider iframe uses the browser's normal authenticated session behavior. +The extension does not rewrite provider HTML or JavaScript. It does not request +cookie permission. Grok is excluded from framing-header bypass rules because its +authenticated real-time channel is not reliable in an extension iframe. ## Message flow ```text workspace/workspace.js - │ postMessage(AI_PARALLEL_SEND) - ▼ -provider iframe / content/frame-bridge.js + ├─ postMessage ─ provider iframe + └─ runtime message ─ service worker ─ tabs.sendMessage ─ Grok tab + │ + ▼ + content/frame-bridge.js │ adapter.sendPrompt(prompt) ├─ fill editor └─ submit @@ -55,10 +60,10 @@ provider iframe / content/frame-bridge.js workspace/workspace.js ``` -Each direct provider iframe announces `AI_PARALLEL_FRAME_READY`. The active -workspace path routes directly to the iframe and validates both the iframe -source and provider origin. The service worker owns workspace opening and -standalone provider-tab actions; it does not route iframe prompts. +Each direct provider iframe announces `AI_PARALLEL_FRAME_READY`. The workspace +validates both iframe source and provider origin. For Grok only, the service +worker finds or creates an allowlisted `grok.com` tab and routes provider-scoped +commands to the same adapter contract. It never receives provider credentials. ## Provider boundaries @@ -77,7 +82,7 @@ protocol. - Prompt contents are not put into destination URLs. - Response contents are not copied to extension storage. - No cookie/history/webRequest permission is requested. -- Opening a provider in a normal top-level tab does not activate the iframe bridge workflow. +- Tab-mode commands are restricted to providers explicitly marked for tab mode and to allowlisted command types. ## Inspiration diff --git a/docs/v2.1-phase0-review.md b/docs/v2.1-phase0-review.md index e7514fa..5c6f313 100644 --- a/docs/v2.1-phase0-review.md +++ b/docs/v2.1-phase0-review.md @@ -5,7 +5,8 @@ ```text Workspace extension page ├─ provider selection + panel layout - ├─ one iframe per selected provider + ├─ one iframe per selected iframe-compatible provider + ├─ controlled top-level tab for Grok ├─ postMessage(AI_PARALLEL_SEND) └─ receives FRAME_READY / SEND_RESULT │ @@ -24,16 +25,17 @@ Provider iframe content scripts (all_frames) Manifest V3 service worker ├─ opens/focuses the workspace ├─ opens standalone provider tabs + ├─ routes allowlisted commands to the Grok top-level tab └─ forwards queued launcher prompts to the workspace declarativeNetRequest └─ removes framing headers for configured provider sub_frames ``` -The active workspace path is direct `postMessage` between the extension page and -the provider iframe. Provider responses are still rendered natively by each -provider page; the workspace collects them only when Compare is explicitly -opened. +The active workspace path uses direct `postMessage` for iframe-compatible +providers and a service-worker `tabs.sendMessage` bridge for Grok. Provider +responses are still rendered natively by each provider page; the workspace +collects them only when Compare is explicitly opened. ## 2. Risk analysis @@ -49,7 +51,8 @@ opened. The origin checks in the active workspace/bridge path are a sound baseline: the workspace validates iframe source and provider origin, while the bridge -validates its parent source, extension origin, and provider id. +validates its parent source, extension origin, and provider id. Grok tab-mode +commands are separately restricted by provider and command allowlists. ## 3. v2.1 development plan @@ -84,5 +87,6 @@ validates its parent source, extension origin, and provider id. - Full response collector fixtures for each provider - Provider API integrations or credential handling -The adapter boundary intentionally keeps iframe ownership with the provider -page and keeps workspace orchestration outside provider-specific DOM code. +The adapter boundary keeps provider rendering ownership with the provider page, +whether iframe or controlled tab, and keeps workspace orchestration outside +provider-specific DOM code. diff --git a/tests/service-worker-auth.test.js b/tests/service-worker-auth.test.js index ff4b136..311b508 100644 --- a/tests/service-worker-auth.test.js +++ b/tests/service-worker-auth.test.js @@ -7,8 +7,10 @@ const vm = require("node:vm"); function loadServiceWorker() { let messageListener; const openedUrls = []; + const sentMessages = []; const context = { console, + setTimeout, URL, chrome: { action: { onClicked: { addListener() {} } }, @@ -22,8 +24,13 @@ function loadServiceWorker() { openedUrls.push(url); return { id: openedUrls.length, url }; }, - async query() { return []; }, - async sendMessage() {} + async query() { return [{ id: 42, url: "https://grok.com/", windowId: 7 }]; }, + async update() {}, + async reload() {}, + async sendMessage(tabId, message) { + sentMessages.push({ tabId, message }); + return { ok: true, response: { provider: "grok", content: "answer" } }; + } }, windows: { async update() {} } } @@ -34,7 +41,7 @@ function loadServiceWorker() { "utf8" ); vm.runInContext(source, context, { filename: "service-worker.js" }); - return { messageListener, openedUrls }; + return { messageListener, openedUrls, sentMessages }; } function sendMessage(listener, message) { @@ -69,3 +76,22 @@ test("Grok authentication only opens allowlisted HTTPS URLs", async () => { assert.equal(insecure.ok, false); assert.equal(openedUrls.length, 1); }); + +test("Grok tab mode reuses the official top-level tab", async () => { + const { messageListener, openedUrls, sentMessages } = loadServiceWorker(); + const result = await sendMessage(messageListener, { + type: "PROVIDER_TAB_COMMAND", + providerId: "grok", + command: { + type: "AI_PARALLEL_COLLECT_RESPONSE", + requestId: "request-1" + } + }); + + assert.equal(result.ok, true); + assert.equal(openedUrls.length, 0); + assert.equal(sentMessages.length, 1); + assert.equal(sentMessages[0].tabId, 42); + assert.equal(sentMessages[0].message.type, "AI_PARALLEL_TAB_COMMAND"); + assert.equal(sentMessages[0].message.providerId, "grok"); +}); diff --git a/tests/top-level-bridge.test.js b/tests/top-level-bridge.test.js new file mode 100644 index 0000000..37f6b7f --- /dev/null +++ b/tests/top-level-bridge.test.js @@ -0,0 +1,52 @@ +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const test = require("node:test"); +const vm = require("node:vm"); + +const extensionRoot = path.join(__dirname, "..", "apps", "browser-extension"); + +test("Grok top-level page exposes the provider command bridge", async () => { + let messageListener; + const context = { + console, + clearTimeout, + setTimeout, + URL, + location: { hostname: "grok.com" }, + document: { querySelectorAll() { return []; } }, + chrome: { + runtime: { + getURL: (value) => `chrome-extension://test/${value}`, + onMessage: { addListener(listener) { messageListener = listener; } } + } + } + }; + context.globalThis = context; + context.window = context; + context.top = context; + context.parent = context; + vm.createContext(context); + + for (const file of [ + "content/providers/core.js", + "content/providers/grok.js", + "content/frame-bridge.js" + ]) { + vm.runInContext(fs.readFileSync(path.join(extensionRoot, file), "utf8"), context, { filename: file }); + } + + assert.equal(typeof messageListener, "function"); + const result = await new Promise((resolve) => { + const asynchronous = messageListener({ + type: "AI_PARALLEL_TAB_COMMAND", + providerId: "grok", + command: { type: "AI_PARALLEL_COLLECT_RESPONSE", requestId: "request-1" } + }, {}, resolve); + assert.equal(asynchronous, true); + }); + + assert.equal(result.type, "AI_PARALLEL_RESPONSE_RESULT"); + assert.equal(result.requestId, "request-1"); + assert.equal(result.ok, false); +});