From 55eefc0db7fe9bb346ddebdd9fbed4c54b7f272a Mon Sep 17 00:00:00 2001 From: George Pickett <297992784+georgeatparallel@users.noreply.github.com> Date: Tue, 20 Oct 2026 12:25:27 +0000 Subject: [PATCH 1/2] Add keyless Parallel MCP search to session acquire tools --- AGENTS.md | 2 +- README.md | 8 + manifest.json | 1 + src/AGENTS.md | 1 + src/agent/vnext/host/parallelSearchHeaders.js | 34 +++ src/agent/vnext/primitives/acquire.js | 13 +- src/agent/vnext/primitives/parallelSearch.js | 146 ++++++++++ src/agent/vnext/primitives/webSearch.js | 13 +- src/agent/vnext/sessionWorkspace/tools.js | 12 + src/agent/webAcquireSettings.js | 18 +- src/background.js | 6 + src/sidepanel.html | 6 +- src/sidepanel.js | 37 ++- tests/parallel-search.test.mjs | 274 ++++++++++++++++++ 14 files changed, 545 insertions(+), 26 deletions(-) create mode 100644 src/agent/vnext/host/parallelSearchHeaders.js create mode 100644 src/agent/vnext/primitives/parallelSearch.js create mode 100644 tests/parallel-search.test.mjs diff --git a/AGENTS.md b/AGENTS.md index 4a2a7a6..75aea9f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -139,7 +139,7 @@ src/ ## 日常约定 - 改代码 → `chrome://extensions` 点本扩展 **重新加载**。offscreen / SW 会重建;`action` 的 `rev` 在 SW 内存,重载后需重新 `snapshot`。 -- 权限见 `manifest.json`:`sidePanel` `activeTab` `tabs` `scripting` `storage` `alarms` `downloads` `offscreen` `tabGroups` `webNavigation` `userScripts` `debugger`;`host_permissions: `。`userScripts` / `debugger` 只服务 guest `sys`([src/AGENTS.md](src/AGENTS.md))。`alarms` 只唤醒 due task,不是 journal。 +- 权限见 `manifest.json`:`sidePanel` `activeTab` `tabs` `scripting` `storage` `declarativeNetRequestWithHostAccess` `alarms` `downloads` `offscreen` `tabGroups` `webNavigation` `userScripts` `debugger`;`host_permissions: `。`declarativeNetRequestWithHostAccess` 仅为本扩展发往 Parallel MCP 的请求设置 User-Agent。`userScripts` / `debugger` 只服务 guest `sys`([src/AGENTS.md](src/AGENTS.md))。`alarms` 只唤醒 due task,不是 journal。 - 命令:`toggle-picker` = Alt+Shift+S;`capture-screenshot` = Alt+Shift+C。 - Git:本地 `main`;`origin` = `https://github.com/Player-YN/PawWork_ZhuaZhua.git`。不要改 `git config`。不要 force-push `main`。 - 逻辑回归:`node --test tests/*.test.mjs`(与 CI 同一 glob:`runtime-regression` + `tab-lease` + `task-*`。覆盖磁盘失败恢复与原子性、OPFS 替换失败、慢文档/表格保存、画布重试与冲突、截图目标、流读取限额、fetch 取消、`saveTo`、CDP 并发与权限探测、guest `sleep`、`sys.waitFor`、tab 租约、task 模型/调度/接线/卡片)。 diff --git a/README.md b/README.md index 12d34ff..93d2349 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,14 @@ Not on the Chrome Web Store. No hosted model — bring your own key. After editing files, click **重新加载** on the extension card. There is no `package.json` and no build step. +Public-web search works without a search API key on a fresh installation through +[Parallel Search MCP](https://docs.parallel.ai/integrations/mcp/search-mcp), using Fast mode. +Search queries are sent to `https://search.parallel.ai/mcp` without browser cookies. +Saved Tavily, Brave and Firecrawl settings keep their existing routing. In settings, +select **Parallel** to use keyless search or **Search off / 关闭公开网搜索** to disable +public-web search. URL fetching and bound-page access keep their existing behavior. +Anonymous search has service limits; errors are returned without switching providers. + Needs Chrome 135+. On Chrome 138+, enable **Allow User Scripts** on the extension details page if you want `sys.eval` or page-identity fetch. Close DevTools on the target tab before using `sys.cdp` (otherwise `CDP_BUSY`). Restricted pages such as `chrome://` and the Web Store return `NEED_PAGE`. ## Tests diff --git a/manifest.json b/manifest.json index 5f65eb0..51aebe1 100644 --- a/manifest.json +++ b/manifest.json @@ -15,6 +15,7 @@ "tabs", "scripting", "storage", + "declarativeNetRequestWithHostAccess", "alarms", "downloads", "offscreen", diff --git a/src/AGENTS.md b/src/AGENTS.md index ae6d35b..c775804 100644 --- a/src/AGENTS.md +++ b/src/AGENTS.md @@ -31,6 +31,7 @@ CSP:`extension_pages` 允许 `'wasm-unsafe-eval'`(QuickJS / Univer);sand sheet_host → 打开/复用 sheet.html,再 pawwork_sheet_rpc canvas_host → docs.html 同类 RPC(无 Design/Slides) workspace_fetch / workspace_sys / workspace_capture_* / workspace_find_tab / … + workspace_parallel_search_headers → fixed session header rule for this extension's Parallel MCP requests workspace_tab_lease_peek|release / workspace_task_resolve_page storage_local_get|set → chrome.storage.local 代理 workspace_get_llm_settings / workspace_get_active_page diff --git a/src/agent/vnext/host/parallelSearchHeaders.js b/src/agent/vnext/host/parallelSearchHeaders.js new file mode 100644 index 0000000..e9f5219 --- /dev/null +++ b/src/agent/vnext/host/parallelSearchHeaders.js @@ -0,0 +1,34 @@ +import { PARALLEL_USER_AGENT } from '../primitives/parallelSearch.js'; + +// Chrome strips User-Agent from fetch(). A session rule tags only our MCP calls. +const RULE_ID = 920001; +let ready; + +export function ensureParallelSearchHeaders(sender) { + const runtime = chrome.runtime; + const allowed = ['src/offscreen/runtime.html', 'src/sidepanel.html'].map(path => runtime.getURL(path)); + let senderUrl; + try { senderUrl = new URL(sender?.url); senderUrl.search = ''; senderUrl.hash = ''; } catch {} + if (sender?.id !== runtime.id || !allowed.includes(senderUrl?.href)) { + return Promise.resolve({ ok: false, code: 'AUTH_DENIED', error: 'Parallel header setup requires an extension runtime page' }); + } + if (!ready) { + ready = chrome.declarativeNetRequest.updateSessionRules({ + removeRuleIds: [RULE_ID], + addRules: [{ + id: RULE_ID, + priority: 1, + action: { type: 'modifyHeaders', requestHeaders: [{ header: 'User-Agent', operation: 'set', value: PARALLEL_USER_AGENT }] }, + condition: { + regexFilter: '^https://search\\.parallel\\.ai/mcp$', + initiatorDomains: [runtime.id], + resourceTypes: ['xmlhttprequest'] + } + }] + }).then(() => ({ ok: true })).catch(error => { + ready = null; + return { ok: false, error: `Parallel request header setup failed: ${error.message}` }; + }); + } + return ready; +} diff --git a/src/agent/vnext/primitives/acquire.js b/src/agent/vnext/primitives/acquire.js index 10eaf52..44c5ebf 100644 --- a/src/agent/vnext/primitives/acquire.js +++ b/src/agent/vnext/primitives/acquire.js @@ -1,7 +1,7 @@ /** * acquire primitive — import external read-only information. * Writes `/work/sources/...` (legacy path). Session guest remaps `/work` → `/scratch`. - * search: Tavily (default), Brave, or site-read API search when that is the only key. + * search: Parallel for fresh settings, or saved BYOK providers. * fetch: site-read scrape when configured, else anonymous public GET. * map / crawl: site-read API only (host-capped). */ @@ -24,10 +24,10 @@ export async function acquire(ctx, input = {}) { if (ctx.signal?.aborted) return { ok: false, error: 'aborted' }; try { - if (action === 'search') return searchWeb(ctx, input, fetchImpl); - if (action === 'fetch') return fetchSource(ctx, input, fetchImpl); - if (action === 'map') return mapSite(ctx, input, fetchImpl); - if (action === 'crawl') return crawlSite(ctx, input, fetchImpl); + if (action === 'search') return await searchWeb(ctx, input, fetchImpl); + if (action === 'fetch') return await fetchSource(ctx, input, fetchImpl); + if (action === 'map') return await mapSite(ctx, input, fetchImpl); + if (action === 'crawl') return await crawlSite(ctx, input, fetchImpl); return { ok: false, error: 'action must be search, fetch, map, or crawl' }; } catch (error) { return { ok: false, action, error: error instanceof Error ? error.message : String(error) }; @@ -44,7 +44,8 @@ async function searchWeb(ctx, input, fetchImpl) { query, limit, fetchImpl, - signal: ctx.signal + signal: ctx.signal, + searchSessionId: ctx.searchSessionId }); if (!found.ok) return found; if (!found.results?.length) { diff --git a/src/agent/vnext/primitives/parallelSearch.js b/src/agent/vnext/primitives/parallelSearch.js new file mode 100644 index 0000000..1ecfaa3 --- /dev/null +++ b/src/agent/vnext/primitives/parallelSearch.js @@ -0,0 +1,146 @@ +/** Small HTTP MCP client for anonymous Parallel Search. Each call owns its connection. */ +import { assertPublicHttpUrl } from './netGuard.js'; + +export const PARALLEL_SEARCH_URL = 'https://search.parallel.ai/mcp'; +export const PARALLEL_USER_AGENT = 'BrowserKitten/1.0 (+https://github.com/Player-YN/BrowserKitten)'; +const PROTOCOL_VERSION = '2025-03-26'; +const MAX_RESPONSE_BYTES = 2 * 1024 * 1024; +const SEARCH_TIMEOUT_MS = 30000; + +function waitWithSignal(promise, signal) { + signal.throwIfAborted(); + return new Promise((resolve, reject) => { + const abort = () => { signal.removeEventListener('abort', abort); reject(signal.reason); }; + signal.addEventListener('abort', abort, { once: true }); + promise.then(value => { + signal.removeEventListener('abort', abort); resolve(value); + }, error => { + signal.removeEventListener('abort', abort); reject(error); + }); + }); +} + +async function readReply(response, id) { + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + const sse = (response.headers.get('content-type') || '').includes('text/event-stream'); + let size = 0; + let text = ''; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + size += value.byteLength; + if (size > MAX_RESPONSE_BYTES) throw new Error('Parallel MCP response too large'); + text += decoder.decode(value, { stream: true }); + if (sse) { + text = text.replace(/\r\n/g, '\n'); + let end; + while ((end = text.indexOf('\n\n')) >= 0) { + const event = text.slice(0, end); + text = text.slice(end + 2); + const data = event.split('\n').filter(line => line.startsWith('data:')) + .map(line => line.slice(5).trimStart()).join('\n'); + if (!data) continue; + const reply = JSON.parse(data); + if (reply.id === id) return reply; + } + } + } + if (sse) throw new Error('Parallel MCP stream ended without a reply'); + return JSON.parse(text + decoder.decode()); + } finally { + await reader.cancel().catch(() => {}); + reader.releaseLock(); + } +} + +export async function searchParallel({ query, fetchImpl, signal, sessionId }) { + if (!sessionId) throw new Error('Parallel search needs a conversation identifier'); + const controller = new AbortController(); + const abort = () => controller.abort(signal.reason); + if (signal?.aborted) abort(); + else signal?.addEventListener('abort', abort, { once: true }); + const timer = setTimeout(() => controller.abort(new Error('Parallel search timed out')), SEARCH_TIMEOUT_MS); + let connectionId; + + async function request(method, params, id) { + controller.signal.throwIfAborted(); + const response = await fetchImpl(PARALLEL_SEARCH_URL, { + method: 'POST', + credentials: 'omit', + redirect: 'error', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + 'User-Agent': PARALLEL_USER_AGENT, + 'MCP-Protocol-Version': PROTOCOL_VERSION, + ...(connectionId ? { 'Mcp-Session-Id': connectionId } : {}) + }, + body: JSON.stringify({ jsonrpc: '2.0', ...(id === undefined ? {} : { id }), method, ...(params ? { params } : {}) }), + signal: controller.signal + }); + if (!response.ok) { + await response.body?.cancel().catch(() => {}); + throw new Error(`Parallel MCP HTTP ${response.status}${response.status === 429 ? ': search quota reached; try again later' : ''}`); + } + if (id === undefined) { + await response.body?.cancel().catch(() => {}); + return; + } + if (method === 'initialize') connectionId = response.headers.get('mcp-session-id') || undefined; + const reply = await readReply(response, id); + if (reply.jsonrpc !== '2.0' || reply.id !== id) throw new Error('Invalid Parallel MCP reply'); + if (reply.error) throw new Error(`Parallel MCP: ${String(reply.error.message || 'request failed').slice(0, 200)}`); + if (!reply.result) throw new Error('Missing Parallel MCP result'); + return reply.result; + } + + try { + if (globalThis.chrome?.runtime?.id) { + const setup = await waitWithSignal(chrome.runtime.sendMessage({ + target: 'pawwork-background', action: 'workspace_parallel_search_headers' + }), controller.signal); + if (!setup?.ok) throw new Error(setup?.error || 'Parallel request header setup unavailable'); + } + const initialized = await request('initialize', { + protocolVersion: PROTOCOL_VERSION, + capabilities: {}, + clientInfo: { name: 'BrowserKitten', version: '1.0' } + }, 1); + if (initialized.protocolVersion !== PROTOCOL_VERSION) throw new Error('Unsupported Parallel MCP protocol version'); + await request('notifications/initialized'); + const discovery = await request('tools/list', {}, 2); + if (!discovery.tools?.some(tool => tool.name === 'web_search')) throw new Error('Parallel MCP search tool unavailable'); + const output = await request('tools/call', { + name: 'web_search', + arguments: { objective: query, search_queries: [query], session_id: sessionId } + }, 3); + if (output.isError) { + const message = output.content?.find(part => part.type === 'text')?.text || 'search failed'; + throw new Error(`Parallel search: ${message.slice(0, 200)}`); + } + const data = output.structuredContent || JSON.parse(output.content?.find(part => part.type === 'text')?.text || '{}'); + if (!Array.isArray(data.results)) throw new Error('Invalid Parallel search results'); + return data.results.filter(hit => assertPublicHttpUrl(hit.url).ok).map(hit => ({ + url: hit.url, + title: hit.title || hit.url, + content: Array.isArray(hit.excerpts) ? hit.excerpts.join('\n\n') : '' + })); + } finally { + // The MCP connection is separate from the durable research conversation. + if (connectionId && !controller.signal.aborted) { + await fetchImpl(PARALLEL_SEARCH_URL, { + method: 'DELETE', credentials: 'omit', redirect: 'error', + headers: { + 'Mcp-Session-Id': connectionId, + 'MCP-Protocol-Version': PROTOCOL_VERSION, + 'User-Agent': PARALLEL_USER_AGENT + }, + signal: controller.signal + }).then(response => response.body?.cancel()).catch(() => {}); + } + clearTimeout(timer); + signal?.removeEventListener('abort', abort); + } +} diff --git a/src/agent/vnext/primitives/webSearch.js b/src/agent/vnext/primitives/webSearch.js index 474fe02..983065b 100644 --- a/src/agent/vnext/primitives/webSearch.js +++ b/src/agent/vnext/primitives/webSearch.js @@ -3,6 +3,9 @@ * Output is always { results: [{ title, url, snippet, content }] }. */ +import { normalizeWebAcquireSettings } from '../../webAcquireSettings.js'; +import { searchParallel } from './parallelSearch.js'; + function clip(s, n) { const t = String(s || '').replace(/\s+/g, ' ').trim(); return t.length > n ? `${t.slice(0, n)}…` : t; @@ -104,7 +107,15 @@ export async function searchFirecrawl({ query, limit, apiKey, baseURL, fetchImpl throw new Error(lastErr); } -export async function runConfiguredSearch(settings, { query, limit, fetchImpl, signal }) { +export async function runConfiguredSearch(settings, { query, limit, fetchImpl, signal, searchSessionId }) { + settings = normalizeWebAcquireSettings(settings); + if (settings.searchProvider === 'disabled') { + return { ok: false, action: 'search', code: 'SEARCH_DISABLED', error: 'Public-web search is disabled' }; + } + if (settings.searchProvider === 'parallel') { + const hits = await searchParallel({ query, fetchImpl, signal, sessionId: searchSessionId }); + return { ok: true, provider: 'parallel', results: normalizeHits(hits, limit) }; + } const wantBrave = settings?.searchProvider === 'brave'; if (wantBrave && settings?.braveKey) { const results = await searchBrave({ diff --git a/src/agent/vnext/sessionWorkspace/tools.js b/src/agent/vnext/sessionWorkspace/tools.js index 768d02d..32fe789 100644 --- a/src/agent/vnext/sessionWorkspace/tools.js +++ b/src/agent/vnext/sessionWorkspace/tools.js @@ -23,6 +23,7 @@ import { } from './auth.js'; import { run as runCodePrimitive } from '../primitives/run.js'; import { acquire as acquirePrimitive } from '../primitives/acquire.js'; +import { normalizeWebAcquireSettings } from '../../webAcquireSettings.js'; import { generateSessionImage } from './imageGen.js'; import { guessMimeFromName } from './artifactValidate.js'; import { coerceToUint8Array } from './fs.js'; @@ -747,6 +748,16 @@ export function createSessionTools(env) { } // Real search/fetch via shared primitive; map /work/sources → /scratch/sources + let searchSessionId; + if (action === 'search' && normalizeWebAcquireSettings(env.webAcquire).searchProvider === 'parallel') { + const session = store.get('sessions', sessionId); + if (!session) return { ok: false, code: 'AUTH_DENIED', error: 'session not found' }; + // Conversation-owned, durable across turns/restarts; never a shared client ID. + searchSessionId = session.parallelSearchSessionId || crypto.randomUUID(); + if (!session.parallelSearchSessionId) { + store.put('sessions', sessionId, { ...session, parallelSearchSessionId: searchSessionId }); + } + } const codeFs = createCodeFsBridge(fs); let fetchUrl = input.url; let fetchMeta = null; @@ -773,6 +784,7 @@ export function createSessionTools(env) { fs: codeFs, signal, fetchImpl: fetchImpl || globalThis.fetch, + searchSessionId, webAcquire: env.webAcquire }, { diff --git a/src/agent/webAcquireSettings.js b/src/agent/webAcquireSettings.js index 4e0c8c1..956d5f9 100644 --- a/src/agent/webAcquireSettings.js +++ b/src/agent/webAcquireSettings.js @@ -1,11 +1,17 @@ /** - * BYOK web acquire: search (Tavily default, Brave optional) + Firecrawl fetch. + * Public web acquire: keyless Parallel search + saved BYOK providers / Firecrawl fetch. * Storage key pagewand_web_acquire. Model still only sees acquire search|fetch. */ export const WEB_ACQUIRE_STORAGE_KEY = 'pagewand_web_acquire'; export const SEARCH_PROVIDERS = [ + { + id: 'parallel', + name: 'Parallel (no key)', + baseURL: 'https://search.parallel.ai/mcp', + keyPlaceholder: '' + }, { id: 'tavily', name: 'Tavily', @@ -22,7 +28,7 @@ export const SEARCH_PROVIDERS = [ export function defaultWebAcquireSettings() { return { - searchProvider: 'tavily', + searchProvider: 'parallel', tavilyKey: '', tavilyBaseURL: 'https://api.tavily.com', braveKey: '', @@ -38,9 +44,11 @@ function trimStr(v, fallback = '') { export function normalizeWebAcquireSettings(raw = {}) { const d = defaultWebAcquireSettings(); const o = raw && typeof raw === 'object' ? raw : {}; - const provider = String(o.searchProvider || d.searchProvider).toLowerCase(); + // Old stored settings without a provider can still contain intentional keys. + const legacy = o.tavilyKey || o.firecrawlKey ? 'tavily' : o.braveKey ? 'brave' : d.searchProvider; + const provider = String(o.searchProvider || legacy).toLowerCase(); return { - searchProvider: provider === 'brave' ? 'brave' : 'tavily', + searchProvider: ['parallel', 'tavily', 'brave', 'disabled'].includes(provider) ? provider : 'disabled', tavilyKey: trimStr(o.tavilyKey), tavilyBaseURL: trimStr(o.tavilyBaseURL, d.tavilyBaseURL).replace(/\/$/, ''), braveKey: trimStr(o.braveKey), @@ -51,6 +59,8 @@ export function normalizeWebAcquireSettings(raw = {}) { export function searchApiConfigured(settings) { const s = normalizeWebAcquireSettings(settings); + if (s.searchProvider === 'disabled') return false; + if (s.searchProvider === 'parallel') return true; if (s.searchProvider === 'brave' && s.braveKey) return true; if (s.tavilyKey) return true; return Boolean(s.firecrawlKey); diff --git a/src/background.js b/src/background.js index d0efadf..b2f46be 100644 --- a/src/background.js +++ b/src/background.js @@ -4,6 +4,7 @@ import { loadLlmSettings } from './agent/llm.js'; import { sheetTabMatches, htmlTabMatches } from './sidepanel/sessionIsolation.js'; import { previewEntryForItem } from './agent/vnext/sessionWorkspace/openClassify.js'; import { handleWorkspaceSys } from './agent/vnext/host/browserSysHost.js'; +import { ensureParallelSearchHeaders } from './agent/vnext/host/parallelSearchHeaders.js'; import { peekTabLease, preparePageActionTarget, @@ -1298,6 +1299,11 @@ chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { return true; } + if (request?.target === 'pawwork-background' && request?.action === 'workspace_parallel_search_headers') { + ensureParallelSearchHeaders(sender).then(sendResponse).catch(error => sendResponse({ ok: false, error: error.message })); + return true; + } + if (request?.target === 'pawwork-background' && request?.action === 'workspace_fetch') { handleWorkspaceFetch(request) .then((result) => sendResponse(result)) diff --git a/src/sidepanel.html b/src/sidepanel.html index 7ecf3f7..6927675 100644 --- a/src/sidepanel.html +++ b/src/sidepanel.html @@ -676,7 +676,7 @@

2. 图像生成 API<

3. 网页搜索 / 抽取 API

-

未添加搜索 Key 时模型不能公开网搜索。绑定页优先于公开网。

+

新安装默认使用 Parallel 免费搜索,无需搜索 Key。绑定页优先于公开网。

diff --git a/src/sidepanel.js b/src/sidepanel.js index b562477..11017d1 100644 --- a/src/sidepanel.js +++ b/src/sidepanel.js @@ -12621,7 +12621,8 @@ function renderVendorCard({ name, meta, active, onSelect, onDelete }) { e.stopPropagation(); onDelete(); }); - card.append(main, del); + card.append(main); + if (onDelete) card.append(del); return card; } @@ -12731,7 +12732,18 @@ async function renderWebVendorList() { const cfg = await loadWebAcquireSettings(); const en = settingsLangEn(); const line = document.getElementById('webAcquireStatusLine'); - const vendors = []; + const vendors = [ + { + kind: 'parallel', name: 'Parallel', + meta: en ? 'Free search · no account or key' : '免费搜索 · 无需账号或 Key', + active: cfg.searchProvider === 'parallel' + }, + { + kind: 'disabled', name: en ? 'Search off' : '关闭公开网搜索', + meta: en ? 'Keep page access and fetch available' : '仍可访问绑定页与抓取 URL', + active: cfg.searchProvider === 'disabled' + } + ]; if (cfg.tavilyKey) { vendors.push({ kind: 'tavily', @@ -12757,15 +12769,9 @@ async function renderWebVendorList() { }); } if (line) { - if (!vendors.length) { - line.textContent = en - ? 'No search key · public-web search is off. Bound page still works.' - : '未添加搜索 Key · 模型不能公开网搜索。绑定页仍然可用。'; - } else { - line.textContent = en - ? 'Tap a search card to use it. Firecrawl only upgrades fetch scrape.' - : '点搜索卡片设为当前使用。Firecrawl 只增强 fetch 抽取。'; - } + line.textContent = en + ? 'Tap a search card to use it. Parallel sends search queries to search.parallel.ai. Firecrawl upgrades fetch scrape.' + : '点搜索卡片设为当前使用。Parallel 会将搜索词发送至 search.parallel.ai。Firecrawl 增强 fetch 抽取。'; } for (const v of vendors) { list.appendChild( @@ -12775,6 +12781,12 @@ async function renderWebVendorList() { active: v.active, onSelect: () => { void (async () => { + if (v.kind === 'parallel' || v.kind === 'disabled') { + await saveWebAcquireSettings({ searchProvider: v.kind }); + setWebEditorOpen(false); + await renderWebVendorList(); + return; + } if (v.kind === 'tavily' || v.kind === 'brave') { await saveWebAcquireSettings({ searchProvider: v.kind }); await renderWebVendorList(); @@ -12782,7 +12794,8 @@ async function renderWebVendorList() { openWebEditor(v.kind); })(); }, - onDelete: () => confirmDeleteVendor(v.name, () => deleteWebVendor(v.kind)) + onDelete: v.kind === 'parallel' || v.kind === 'disabled' + ? null : () => confirmDeleteVendor(v.name, () => deleteWebVendor(v.kind)) }) ); } diff --git a/tests/parallel-search.test.mjs b/tests/parallel-search.test.mjs new file mode 100644 index 0000000..e963a77 --- /dev/null +++ b/tests/parallel-search.test.mjs @@ -0,0 +1,274 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { SessionWorkspaceStore } from '../src/agent/vnext/sessionWorkspace/store.js'; +import { SessionWorkspaceService } from '../src/agent/vnext/service/sessionWorkspaceService.js'; +import { createSessionGuestFs } from '../src/agent/vnext/sessionWorkspace/fs.js'; +import { createSessionTools } from '../src/agent/vnext/sessionWorkspace/tools.js'; +import { defaultWebAcquireSettings, normalizeWebAcquireSettings, loadWebAcquireSettings, saveWebAcquireSettings, searchApiConfigured } from '../src/agent/webAcquireSettings.js'; +import { PARALLEL_SEARCH_URL, PARALLEL_USER_AGENT } from '../src/agent/vnext/primitives/parallelSearch.js'; + +function caller({ store = new SessionWorkspaceStore(), sessionId = 'conversation-a', settings, fetchImpl, signal } = {}) { + if (!store.has('sessions', sessionId)) store.put('sessions', sessionId, { sessionId, messages: [] }); + const execution = { executionId: `execution-${sessionId}` }; + const fs = createSessionGuestFs(store, { sessionId, executionId: execution.executionId }); + const tools = createSessionTools({ store, fs, sessionId, execution, webAcquire: settings, fetchImpl, signal }); + return { store, fs, tools, search: query => tools.acquire.execute({ action: 'search', query, limit: 2 }) }; +} +function reply(id, result) { return Response.json({ jsonrpc: '2.0', id, result }); } +function transport({ calls = [], toolResult, mode = 'json', toolHandler } = {}) { + return async (url, init) => { + const request = JSON.parse(init.body); + calls.push({ url, init, request }); + assert.equal(url, PARALLEL_SEARCH_URL); + assert.equal(init.credentials, 'omit'); + assert.equal(init.redirect, 'error'); + assert.equal(init.headers['User-Agent'], PARALLEL_USER_AGENT); + assert.equal(init.headers.Authorization, undefined); + assert.equal(init.headers.Cookie, undefined); + assert.equal(init.headers['MCP-Protocol-Version'], '2025-03-26'); + if (request.method === 'initialize') return reply(request.id, { protocolVersion: '2025-03-26', capabilities: { tools: {} } }); + if (request.method === 'notifications/initialized') return new Response(null, { status: 202 }); + if (request.method === 'tools/list') return reply(request.id, { tools: [{ name: 'web_search' }] }); + assert.equal(request.method, 'tools/call'); + assert.equal(request.params.name, 'web_search'); + if (toolHandler) return toolHandler(request, init); + const result = toolResult || { structuredContent: { results: [ + { url: 'https://example.com/a', title: 'Research result', excerpts: ['Useful evidence', 'More detail'] }, + { url: 'https://example.com/a', title: 'Duplicate', excerpts: [] }, + { url: 'javascript:alert(1)', excerpts: ['unsafe'] }, + { url: 'http://127.0.0.1/private', excerpts: ['private'] }, + { url: 'https://example.org/b', excerpts: ['Second source'] }, + { url: 'https://example.net/c', excerpts: ['Beyond limit'] } + ] } }; + if (mode === 'sse') { + const text = ': heartbeat\r\n\r\ndata: '+JSON.stringify({ jsonrpc: '2.0', method: 'notifications/progress' })+'\r\n\r\ndata: '+JSON.stringify({ jsonrpc: '2.0', id: request.id, result })+'\r\n\r\n'; + return new Response(new ReadableStream({ start(c) { for (const ch of text) c.enqueue(new TextEncoder().encode(ch)); } }), { headers: { 'content-type': 'text/event-stream' } }); + } + return reply(request.id, result); + }; +} + +test('fresh no-key settings dispatch MCP search and persist model-readable results', async () => { + const calls = []; + const { search, fs } = caller({ fetchImpl: transport({ calls }) }); + const result = await search('browser extension search'); + assert.equal(result.ok, true); + assert.equal(result.results.length, 2); + assert.equal(result.results[0].snippet, 'Useful evidence More detail'); + assert.equal(result.results[0].url, 'https://example.com/a'); + assert.match(result.path, /^\/scratch\/sources\/search_.*\.json$/); + const saved = JSON.parse(fs.readFile(result.path)); + assert.equal(saved.provider, 'parallel'); + assert.deepEqual(saved.results, result.results); + assert.deepEqual(calls.map(c => c.request.method), ['initialize', 'notifications/initialized', 'tools/list', 'tools/call']); + assert.equal(searchApiConfigured({}), true); +}); + +test('conversation identifiers survive follow-up, interleaving, quota, retry and restart; new chat differs', async () => { + const calls = []; + let quota = true; + const fetchImpl = transport({ calls, toolHandler(request) { + if (request.params.arguments.objective === 'quota' && quota) { quota = false; return new Response('', { status: 429 }); } + return reply(request.id, { structuredContent: { results: [{ url: 'https://example.com', excerpts: ['ok'] }] } }); + } }); + const a = caller({ fetchImpl }); + const b = caller({ store: a.store, sessionId: 'conversation-b', fetchImpl }); + await Promise.all([a.search('first a'), b.search('first b')]); + // Existing HTTP fetch stays direct and does not create a new MCP conversation. + const af = caller({ store: a.store, fetchImpl: async (url, init) => { + assert.equal(url, 'https://example.com/source'); assert.equal(init.credentials, 'omit'); + return new Response('source contents', { headers: { 'content-type': 'text/plain' } }); + } }); + assert.equal((await af.tools.acquire.execute({ action: 'fetch', url: 'https://example.com/source' })).ok, true); + assert.equal((await a.search('quota')).ok, false); + assert.equal((await a.search('retry a')).ok, true); + await b.search('follow-up b'); + const restored = new SessionWorkspaceStore(); restored.importSnapshot(a.store.exportSnapshot()); + await caller({ store: restored, fetchImpl }).search('restored a'); + const fresh = caller({ store: restored, sessionId: 'new-chat', fetchImpl }); await fresh.search('new chat'); + const args = calls.filter(c => c.request.method === 'tools/call').map(c => c.request.params.arguments); + const idA = args.find(a => a.objective === 'first a').session_id; + const idB = args.find(a => a.objective === 'first b').session_id; + assert.match(idA, /^[a-f\d-]{36}$/); assert.notEqual(idA, idB); + for (const objective of ['quota', 'retry a', 'restored a']) assert.equal(args.find(a => a.objective === objective).session_id, idA); + assert.equal(args.find(a => a.objective === 'follow-up b').session_id, idB); + assert.notEqual(args.find(a => a.objective === 'new chat').session_id, idA); + assert.notEqual(args.find(a => a.objective === 'new chat').session_id, idB); +}); + +test('saved providers, credential-only legacy settings and explicit overrides never become Parallel', async () => { + const cases = [ + [{ searchProvider: 'tavily', tavilyKey: 't', tavilyBaseURL: 'https://tavily.example' }, 'https://tavily.example/search'], + [{ searchProvider: 'brave', braveKey: 'b' }, 'https://api.search.brave.com/res/v1/web/search'], + [{ tavilyKey: 't' }, 'https://api.tavily.com/search'], + [{ braveKey: 'b' }, 'https://api.search.brave.com/res/v1/web/search'], + [{ firecrawlKey: 'f' }, 'https://api.firecrawl.dev/v2/search'], + [{ searchProvider: 'tavily', firecrawlKey: 'f' }, 'https://api.firecrawl.dev/v2/search'] + ]; + for (const [settings, endpoint] of cases) { + let count = 0; + const { search } = caller({ settings, fetchImpl: async (url) => { + count++; assert.ok(url.startsWith(endpoint)); + return Response.json({ results: [{ url: 'https://example.com', content: 'saved provider' }], web: { results: [{ url: 'https://example.com', description: 'brave' }] }, data: [{ url: 'https://example.com', content: 'firecrawl' }] }); + } }); + assert.equal((await search('saved')).ok, true); assert.equal(count, 1); + } + const explicit = caller({ settings: { searchProvider: 'parallel', tavilyKey: 'saved-key' }, fetchImpl: transport() }); + assert.equal((await explicit.search('explicit override')).ok, true); + for (const settings of [{ searchProvider: 'disabled', tavilyKey: 't' }, { searchProvider: 'unknown' }, { searchProvider: 'tavily' }]) { + const c = caller({ settings, fetchImpl: () => { throw new Error('network should not run'); } }); + const result = await c.search('off'); assert.equal(result.ok, false); + assert.ok(['SEARCH_DISABLED', 'SEARCH_NOT_CONFIGURED'].includes(result.code)); + assert.equal(c.store.get('sessions', 'conversation-a').parallelSearchSessionId, undefined); + } + for (const settings of [{ searchProvider: 'tavily', tavilyKey: 't' }, { searchProvider: 'brave', braveKey: 'b' }]) { + let calls = 0; + const c = caller({ settings, fetchImpl: async () => { calls++; return new Response('', { status: 401 }); } }); + assert.equal((await c.search('failure')).ok, false); assert.equal(calls, 1); + } + assert.equal(searchApiConfigured({ searchProvider: 'disabled', tavilyKey: 't' }), false); + assert.equal(normalizeWebAcquireSettings(defaultWebAcquireSettings()).searchProvider, 'parallel'); +}); + +test('settings load/save preserves keys and a disabled choice; fresh storage is keyless', async () => { + const previous = globalThis.chrome; + let data = {}; + globalThis.chrome = { storage: { local: { get: async () => data, set: async value => { data = value; } } } }; + try { + assert.equal((await loadWebAcquireSettings()).searchProvider, 'parallel'); + await saveWebAcquireSettings({ searchProvider: 'tavily', tavilyKey: 'existing', tavilyBaseURL: 'https://custom.example' }); + await saveWebAcquireSettings({ searchProvider: 'disabled' }); + assert.equal((await loadWebAcquireSettings()).tavilyKey, 'existing'); + assert.equal((await loadWebAcquireSettings()).searchProvider, 'disabled'); + await saveWebAcquireSettings({ firecrawlKey: 'fetch' }); + assert.equal((await loadWebAcquireSettings()).searchProvider, 'disabled'); + } finally { globalThis.chrome = previous; } +}); + +test('JSON text and chunked SSE MCP tool replies reach result consumption', async () => { + for (const options of [{ mode: 'sse' }, { toolResult: { content: [{ type: 'text', text: JSON.stringify({ results: [{ url: 'https://example.com', excerpts: ['JSON evidence'] }] }) }] } }]) { + const c = caller({ fetchImpl: transport(options) }); + assert.equal((await c.search('transport')).ok, true); + } +}); + +test('MCP errors, invalid replies and oversized results are returned without provider fallback or files', async () => { + const modes = ['http', 'tool', 'rpc', 'parse', 'shape', 'oversize']; + for (const mode of modes) { + let count = 0; + const fetchImpl = transport({ toolHandler(request) { + count++; + if (mode === 'http') return new Response('', { status: 503 }); + if (mode === 'tool') return reply(request.id, { isError: true, content: [{ type: 'text', text: 'backend error' }] }); + if (mode === 'rpc') return Response.json({ jsonrpc: '2.0', id: request.id, error: { code: -32603, message: 'internal error' } }); + if (mode === 'parse') return new Response('not JSON'); + if (mode === 'shape') return reply(request.id, { structuredContent: { other: [] } }); + return new Response('x'.repeat(2 * 1024 * 1024 + 1)); + } }); + const c = caller({ settings: { searchProvider: 'parallel', tavilyKey: 'do-not-fallback' }, fetchImpl }); + const result = await c.search(mode); + assert.equal(result.ok, false); assert.equal(typeof result.error, 'string'); assert.equal(count, 1); + assert.equal(result.path, undefined); + } +}); + +test('abort before dispatch, cancellation while reading, deadline, missing session and private URLs respect boundaries', async () => { + const controller = new AbortController(); controller.abort(); + const c = caller({ signal: controller.signal, fetchImpl: () => { throw new Error('should not run'); } }); + assert.equal((await c.search('aborted')).ok, false); + for (const timeout of [false, true]) { + const controller = new AbortController(); let started; + const ready = new Promise(r => { started = r; }); + const originalSetTimeout = globalThis.setTimeout; + if (timeout) globalThis.setTimeout = (callback, ms, ...args) => originalSetTimeout(callback, ms === 30000 ? 10 : ms, ...args); + try { + const fetchImpl = transport({ toolHandler(_request, init) { + return new Response(new ReadableStream({ start(stream) { + init.signal.addEventListener('abort', () => stream.error(init.signal.reason)); started(); + } })); + } }); + const c = caller({ signal: controller.signal, fetchImpl }); + const pending = c.search('slow'); await ready; + if (!timeout) controller.abort(new Error('user cancelled')); + const result = await pending; + assert.equal(result.ok, false); assert.match(result.error, timeout ? /timed out/ : /user cancelled/); + } finally { globalThis.setTimeout = originalSetTimeout; } + } + let network = 0; + const privateCaller = caller({ fetchImpl: () => { network++; } }); + assert.equal((await privateCaller.tools.acquire.execute({ action: 'fetch', url: 'http://127.0.0.1/secret' })).code, 'NET_DENIED'); + privateCaller.store.delete('sessions', 'conversation-a'); + assert.equal((await privateCaller.search('no session')).code, 'AUTH_DENIED'); + assert.equal(network, 0); +}); + +test('scripted model uses the production ToolLoopAgent dispatcher and consumes search evidence', async () => { + const store = new SessionWorkspaceStore(); + const service = new SessionWorkspaceService({ store }); service.ensureSession('model-conversation'); + let step = 0; let consumed = false; + const result = await service.sendMessage({ sessionId: 'model-conversation', content: 'Research browser extensions', webAcquire: {}, fetchImpl: transport(), callModel: async ({ messages }) => { + if (step++ === 0) return { toolCalls: [{ id: 'search-1', name: 'acquire', args: { action: 'search', query: 'browser extension research' } }] }; + consumed = JSON.stringify(messages).includes('Useful evidence'); + return { text: 'Research result: https://example.com/a' }; + } }); + assert.equal(consumed, true); assert.equal(step, 2); + assert.ok(JSON.stringify(result).includes('https://example.com/a')); +}); + +test('Chrome header setup is scoped to extension runtime pages and reports permission failure', async () => { + const previous = globalThis.chrome; + const { ensureParallelSearchHeaders } = await import('../src/agent/vnext/host/parallelSearchHeaders.js'); + let rules; let calls = 0; + globalThis.chrome = { + runtime: { id: 'extension-id', getURL: path => `chrome-extension://extension-id/${path}` }, + declarativeNetRequest: { updateSessionRules: async value => { calls++; rules = value; } } + }; + try { + for (const sender of [{ id: 'other', url: 'chrome-extension://extension-id/src/offscreen/runtime.html' }, { id: 'extension-id', url: 'https://example.com' }]) { + assert.equal((await ensureParallelSearchHeaders(sender)).code, 'AUTH_DENIED'); + } + assert.equal(calls, 0); + assert.equal((await ensureParallelSearchHeaders({ id: 'extension-id', url: 'chrome-extension://extension-id/src/offscreen/runtime.html' })).ok, true); + assert.equal(calls, 1); + assert.deepEqual(rules.addRules[0].condition, { regexFilter: '^https://search\\.parallel\\.ai/mcp$', initiatorDomains: ['extension-id'], resourceTypes: ['xmlhttprequest'] }); + assert.equal(rules.addRules[0].action.requestHeaders[0].value, PARALLEL_USER_AGENT); + chrome.runtime.sendMessage = async () => ({ ok: false, error: 'permission denied' }); + const c = caller({ fetchImpl: () => { throw new Error('network should not run'); } }); + assert.match((await c.search('no permission')).error, /permission denied/); + } finally { globalThis.chrome = previous; } +}); + +test('MCP connection headers and close are separate from conversation identity', async () => { + let connection; let closed = false; + const base = transport(); + const c = caller({ fetchImpl: async (url, init) => { + if (init.method === 'DELETE') { + assert.equal(url, PARALLEL_SEARCH_URL); + assert.equal(init.headers['Mcp-Session-Id'], 'connection-only'); + assert.equal(init.headers['User-Agent'], PARALLEL_USER_AGENT); + closed = true; return new Response(null, { status: 204 }); + } + const body = JSON.parse(init.body); + if (body.method === 'initialize') { + const response = await base(url, init); + response.headers.set('mcp-session-id', 'connection-only'); return response; + } + assert.equal(init.headers['Mcp-Session-Id'], 'connection-only'); + if (body.method === 'tools/call') connection = body.params.arguments.session_id; + return base(url, init); + } }); + assert.equal((await c.search('connection lifecycle')).ok, true); + assert.equal(closed, true); assert.notEqual(connection, 'connection-only'); +}); + +test('cancellation also stops waiting for Chrome header setup', async () => { + const previous = globalThis.chrome; + const controller = new AbortController(); + globalThis.chrome = { runtime: { id: 'extension-id', sendMessage: () => new Promise(() => {}) } }; + try { + const c = caller({ signal: controller.signal, fetchImpl: () => { throw new Error('should not run'); } }); + const pending = c.search('waiting for host'); + controller.abort(new Error('cancel host setup')); + assert.match((await pending).error, /cancel host setup/); + } finally { globalThis.chrome = previous; } +}); From 0c465373fa4606ae74987bd8701785c181cec62a Mon Sep 17 00:00:00 2001 From: George Pickett <297992784+georgeatparallel@users.noreply.github.com> Date: Tue, 20 Oct 2026 12:28:08 +0000 Subject: [PATCH 2/2] Respect offscreen search settings through the storage bridge --- src/agent/webAcquireSettings.js | 32 +++++++++--- tests/browser-smoke.cjs | 89 +++++++++++++++++++++++++++++++++ tests/parallel-search.test.mjs | 31 ++++++++++++ 3 files changed, 146 insertions(+), 6 deletions(-) diff --git a/src/agent/webAcquireSettings.js b/src/agent/webAcquireSettings.js index 956d5f9..4634eed 100644 --- a/src/agent/webAcquireSettings.js +++ b/src/agent/webAcquireSettings.js @@ -70,19 +70,34 @@ export function firecrawlConfigured(settings) { return Boolean(normalizeWebAcquireSettings(settings).firecrawlKey); } +async function readWebAcquireSettings() { + if (typeof chrome === 'undefined') return defaultWebAcquireSettings(); + let bag; + if (chrome.storage?.local?.get) { + bag = await chrome.storage.local.get(WEB_ACQUIRE_STORAGE_KEY); + } else if (chrome.runtime?.sendMessage) { + // Offscreen has chrome.runtime, but no chrome.storage. Use the existing SW bridge. + const response = await chrome.runtime.sendMessage({ + target: 'pawwork-background', action: 'storage_local_get', keys: [WEB_ACQUIRE_STORAGE_KEY] + }); + if (!response?.ok) throw new Error(response?.error || 'Web search settings unavailable'); + bag = response.result; + } + if (!bag || typeof bag !== 'object' || Array.isArray(bag)) throw new Error('Web search settings unavailable'); + return normalizeWebAcquireSettings(bag[WEB_ACQUIRE_STORAGE_KEY]); +} + export async function loadWebAcquireSettings() { - const empty = defaultWebAcquireSettings(); try { - if (typeof chrome === 'undefined' || !chrome?.storage?.local?.get) return empty; - const bag = await chrome.storage.local.get(WEB_ACQUIRE_STORAGE_KEY); - return normalizeWebAcquireSettings(bag?.[WEB_ACQUIRE_STORAGE_KEY]); + return await readWebAcquireSettings(); } catch { - return empty; + // Unreadable settings might contain Search off or an intentional provider. + return normalizeWebAcquireSettings({ searchProvider: 'disabled' }); } } export async function saveWebAcquireSettings(patch = {}) { - const prev = await loadWebAcquireSettings(); + const prev = await readWebAcquireSettings(); const take = (key) => Object.prototype.hasOwnProperty.call(patch, key) ? patch[key] : prev[key]; const next = normalizeWebAcquireSettings({ @@ -94,6 +109,11 @@ export async function saveWebAcquireSettings(patch = {}) { }); if (typeof chrome !== 'undefined' && chrome?.storage?.local?.set) { await chrome.storage.local.set({ [WEB_ACQUIRE_STORAGE_KEY]: next }); + } else if (typeof chrome !== 'undefined' && chrome.runtime?.sendMessage) { + const response = await chrome.runtime.sendMessage({ + target: 'pawwork-background', action: 'storage_local_set', values: { [WEB_ACQUIRE_STORAGE_KEY]: next } + }); + if (!response?.ok) throw new Error(response?.error || 'Web search settings could not be saved'); } return next; } diff --git a/tests/browser-smoke.cjs b/tests/browser-smoke.cjs index 1f741e1..e346b40 100644 --- a/tests/browser-smoke.cjs +++ b/tests/browser-smoke.cjs @@ -29,6 +29,86 @@ const path = require('node:path'); page.on('pageerror', error => evidence.errors.push(error.message)); page.on('console', msg => { if (['error', 'warning'].includes(msg.type())) evidence.console.push(msg.text()); }); await page.goto(`chrome-extension://${id}/src/sidepanel.html`); + // Offscreen exposes chrome.runtime, not chrome.storage. Verify the real + // settings bridge and tool loop there, rather than only in the side panel. + await page.evaluate(async () => { + const { workspaceRpc } = await import('./agent/vnext/host/workspaceClient.js'); + await workspaceRpc('getWorkspaceState', { sessionId: 'search-smoke' }); + }); + const browserCdp = await context.browser().newBrowserCDPSession(); + const targets = (await browserCdp.send('Target.getTargets')).targetInfos; + const offscreen = targets.find(target => target.url === `chrome-extension://${id}/src/offscreen/runtime.html`); + if (!offscreen) throw new Error('offscreen runtime did not start'); + const attached = await browserCdp.send('Target.attachToTarget', { targetId: offscreen.targetId, flatten: false }); + let evaluateId = 0; + async function inOffscreen(expression) { + const requestId = ++evaluateId; + const response = new Promise(resolve => { + const listener = message => { + if (message.sessionId !== attached.sessionId) return; + const data = JSON.parse(message.message); + if (data.id !== requestId) return; + browserCdp.off('Target.receivedMessageFromTarget', listener); + resolve(data); + }; + browserCdp.on('Target.receivedMessageFromTarget', listener); + }); + await browserCdp.send('Target.sendMessageToTarget', { sessionId: attached.sessionId, + message: JSON.stringify({ id: requestId, method: 'Runtime.evaluate', params: { expression, awaitPromise: true, returnByValue: true } }) }); + const result = (await response).result; + if (result.exceptionDetails) throw new Error(JSON.stringify(result.exceptionDetails)); + return result.result.value; + } + evidence.searchSettings = []; + for (const settings of [null, { searchProvider: 'disabled' }, { searchProvider: 'brave', braveKey: 'fixture-brave' }, + { searchProvider: 'tavily', tavilyKey: 'fixture-tavily' }, { firecrawlKey: 'fixture-firecrawl' }]) { + await page.evaluate(async settings => { + if (settings) await chrome.storage.local.set({ pagewand_web_acquire: settings }); + else await chrome.storage.local.remove('pagewand_web_acquire'); + }, settings); + evidence.searchSettings.push(await inOffscreen('(async()=>({storage:typeof chrome.storage,settings:await (await import("../agent/webAcquireSettings.js")).loadWebAcquireSettings()}))()')); + } + await page.evaluate(() => chrome.storage.local.remove('pagewand_web_acquire')); + evidence.searchLoop = await inOffscreen(`(${async function () { + const { SessionWorkspaceService } = await import('../agent/vnext/service/sessionWorkspaceService.js'); + const service = new SessionWorkspaceService(); + service.ensureSession('search-smoke-loop'); + const requests = []; + let step = 0; let consumed = false; + const result = await service.sendMessage({ sessionId: 'search-smoke-loop', content: 'Find browser documentation', + fetchImpl: async (url, init) => { + const request = JSON.parse(init.body); requests.push({ url, headers: init.headers, request }); + if (request.method === 'notifications/initialized') return new Response(null, { status: 202 }); + const data = request.method === 'initialize' ? { protocolVersion: '2025-03-26', capabilities: { tools: {} } } + : request.method === 'tools/list' ? { tools: [{ name: 'web_search' }] } + : { structuredContent: { results: [{ url: 'https://example.com/research', excerpts: ['offscreen-search-evidence'] }] } }; + return Response.json({ jsonrpc: '2.0', id: request.id, result: data }); + }, + callModel: async ({ messages }) => { + if (step++ === 0) return { toolCalls: [{ id: 'search-fixture', name: 'acquire', args: { action: 'search', query: 'browser documentation' } }] }; + consumed = JSON.stringify(messages).includes('offscreen-search-evidence'); + return { text: 'Search evidence consumed.' }; + } + }); + return { consumed, requests, result, searchId: service.runtime.store.get('sessions', 'search-smoke-loop').parallelSearchSessionId }; + }})()`); + await page.evaluate(() => chrome.storage.local.set({ pagewand_web_acquire: { searchProvider: 'disabled' } })); + evidence.searchDisabled = await inOffscreen(`(${async function () { + const { SessionWorkspaceService } = await import('../agent/vnext/service/sessionWorkspaceService.js'); + const service = new SessionWorkspaceService(); service.ensureSession('disabled-search-smoke'); + let step = 0; let network = 0; let disabled = false; + await service.sendMessage({ sessionId: 'disabled-search-smoke', content: 'Try searching', + fetchImpl: async () => { network++; throw new Error('Search off must not use the network'); }, + callModel: async ({ messages }) => { + if (step++ === 0) return { toolCalls: [{ id: 'disabled-fixture', name: 'acquire', args: { action: 'search', query: 'must remain private' } }] }; + disabled = JSON.stringify(messages).includes('SEARCH_DISABLED'); return { text: 'Search is off.' }; + } + }); + return { network, disabled }; + }})()`); + await page.evaluate(() => chrome.storage.local.remove('pagewand_web_acquire')); + await browserCdp.send('Target.detachFromTarget', { sessionId: attached.sessionId }); + await browserCdp.detach(); evidence.storage = await page.evaluate(async () => { const { DurableSessionWorkspaceStore } = await import('./agent/vnext/sessionWorkspace/durableStore.js'); const results = []; @@ -121,6 +201,15 @@ const path = require('node:path'); return handleWorkspaceSys({ op: 'waitFor', params: { text: 'x' } }); }); const assert = require('node:assert/strict'); + assert.ok(evidence.searchSettings.every(entry => entry.storage === 'undefined')); + assert.deepEqual(evidence.searchSettings.map(entry => entry.settings.searchProvider), ['parallel', 'disabled', 'brave', 'tavily', 'tavily']); + assert.equal(evidence.searchSettings[2].settings.braveKey, 'fixture-brave'); + assert.equal(evidence.searchSettings[3].settings.tavilyKey, 'fixture-tavily'); + assert.equal(evidence.searchSettings[4].settings.firecrawlKey, 'fixture-firecrawl'); + assert.equal(evidence.searchLoop.consumed, true); + assert.equal(evidence.searchLoop.result.finalText, 'Search evidence consumed.'); + assert.equal(evidence.searchLoop.requests.at(-1).request.params.arguments.session_id, evidence.searchLoop.searchId); + assert.deepEqual(evidence.searchDisabled, { network: 0, disabled: true }); for (const entry of evidence.storage) assert.deepEqual(entry.bytes, [4,5,6]); assert.match(evidence.sandbox[0].stdout || '', /guest-ok/); assert.match(evidence.sandbox[1].stdout || '', /SYS_DENIED/); diff --git a/tests/parallel-search.test.mjs b/tests/parallel-search.test.mjs index e963a77..6d4ce88 100644 --- a/tests/parallel-search.test.mjs +++ b/tests/parallel-search.test.mjs @@ -272,3 +272,34 @@ test('cancellation also stops waiting for Chrome header setup', async () => { assert.match((await pending).error, /cancel host setup/); } finally { globalThis.chrome = previous; } }); + +test('offscreen settings use the existing SW storage bridge; read failure disables search and refuses destructive saves', async () => { + const previous = globalThis.chrome; + let bag = {}; + const requests = []; + globalThis.chrome = { runtime: { sendMessage: async request => { + requests.push(request); + if (request.action === 'storage_local_get') return { ok: true, result: bag }; + assert.equal(request.action, 'storage_local_set'); bag = request.values; return { ok: true }; + } } }; + try { + assert.equal((await loadWebAcquireSettings()).searchProvider, 'parallel'); + await saveWebAcquireSettings({ searchProvider: 'brave', braveKey: 'saved-brave' }); + assert.equal((await loadWebAcquireSettings()).searchProvider, 'brave'); + assert.equal((await loadWebAcquireSettings()).braveKey, 'saved-brave'); + await saveWebAcquireSettings({ searchProvider: 'disabled' }); + assert.equal((await loadWebAcquireSettings()).searchProvider, 'disabled'); + assert.equal((await loadWebAcquireSettings()).braveKey, 'saved-brave'); + assert.ok(requests.every(r => r.target === 'pawwork-background')); + assert.deepEqual(requests[0].keys, ['pagewand_web_acquire']); + for (const fail of [async () => { throw new Error('bridge unavailable'); }, async () => ({ ok: false }), async () => ({ ok: true })]) { + chrome.runtime.sendMessage = fail; + const settings = await loadWebAcquireSettings(); assert.equal(settings.searchProvider, 'disabled'); + const c = caller({ settings, fetchImpl: () => { throw new Error('must stay offline'); } }); + assert.equal((await c.search('offscreen failure')).code, 'SEARCH_DISABLED'); + await assert.rejects(saveWebAcquireSettings({ firecrawlKey: 'new' })); + } + globalThis.chrome = { storage: { local: { get: async () => { throw new Error('disk failure'); } } } }; + assert.equal((await loadWebAcquireSettings()).searchProvider, 'disabled'); + } finally { globalThis.chrome = previous; } +});