Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: <all_urls>`。`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: <all_urls>`。`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 模型/调度/接线/卡片)。
Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"tabs",
"scripting",
"storage",
"declarativeNetRequestWithHostAccess",
"alarms",
"downloads",
"offscreen",
Expand Down
1 change: 1 addition & 0 deletions src/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
34 changes: 34 additions & 0 deletions src/agent/vnext/host/parallelSearchHeaders.js
Original file line number Diff line number Diff line change
@@ -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;
}
13 changes: 7 additions & 6 deletions src/agent/vnext/primitives/acquire.js
Original file line number Diff line number Diff line change
@@ -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).
*/
Expand All @@ -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) };
Expand All @@ -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) {
Expand Down
146 changes: 146 additions & 0 deletions src/agent/vnext/primitives/parallelSearch.js
Original file line number Diff line number Diff line change
@@ -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);
}
}
13 changes: 12 additions & 1 deletion src/agent/vnext/primitives/webSearch.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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({
Expand Down
12 changes: 12 additions & 0 deletions src/agent/vnext/sessionWorkspace/tools.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand All @@ -773,6 +784,7 @@ export function createSessionTools(env) {
fs: codeFs,
signal,
fetchImpl: fetchImpl || globalThis.fetch,
searchSessionId,
webAcquire: env.webAcquire
},
{
Expand Down
Loading