diff --git a/.gitignore b/.gitignore index a485ef2..943e95b 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,6 @@ node_modules/ .npm pnpm-debug.log* .worktrees/ -docs/plans/* \ No newline at end of file +docs/plans/* +.superset +AGENTS.local.md diff --git a/AGENTS.md b/AGENTS.md index 2a6a8c3..540bf0c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,13 +1,8 @@ # BrowserForce — Agent Guidelines -## Playwriter Reference +## Local Private Overrides -**Before writing any new code, always check how [playwriter](../playwriter) solves the same problem.** Playwriter is the reference implementation for a browser extension + CDP relay + MCP server stack. It lives at `~/Documents/projects/playwriter`. - -Rules: -- **Don't reinvent what playwriter already solved.** Read the relevant playwriter source file first. -- **Only add code for new requirements or problems playwriter hasn't already solved.** -- Reference files: `playwriter/src/cdp-relay.ts`, `playwriter/src/executor.ts`, `playwriter/src/mcp.ts`, `playwriter/src/relay-client.ts` +@AGENTS.local.md ## Project Overview @@ -157,6 +152,38 @@ When a user clicks "Cancel" on Chrome's automation infobar, Chrome detaches the `RelayServer.start()` accepts `{ writeCdpUrl: false }` to prevent test instances from clobbering `~/.browserforce/cdp-url`. **All test `relay.start()` calls must pass `{ writeCdpUrl: false }`** or the production cdp-url file gets overwritten with random test ports. +### Client Arbitration: BF_CLIENT_MODE + +`BF_CLIENT_MODE` controls agent-side CDP arbitration: +- `multi-client` (default): allows concurrent `/cdp` clients. +- `single-active`: opt-in mode that allows only one active `/cdp` client connection at a time. + +In `single-active`, contention returns HTTP `409 Conflict` for additional `/cdp` connects while the slot is busy. Slot state is exposed at `GET /client-slot` (`mode`, `busy`, `activeClientId`, `connectedAt`). + +### MCP Standby Polling + +MCP handles `409`/busy connect errors by entering standby and polling `GET /client-slot` with short jittered intervals (~200-400ms), then reconnecting when `busy: false` (up to a 30s connect timeout). + +### BrowserForce Agent Session Identity (No Fixed ID) + +For side-panel chat UX, **never hardcode or assume a fixed `sessionId`**. + +- Sessions are user-selectable conversation threads (ChatGPT/Atlas style). +- The UI must list prior sessions and let the user resume any session. +- New chats must create a new generated session ID (UUID/ULID), then persist metadata + transcript. +- Streaming channels (`/events`) must be scoped by explicit selected `sessionId`. +- Do not infer continuity from "current Codex turn/session" alone; BrowserForce Agent keeps its own session store. + +### Codex Provider Session Continuity + Usage Telemetry + +For side-panel chat continuity, BrowserForce session metadata stores Codex provider state: + +- Persist Codex thread identity at `providerState.codex.sessionId`. +- On each new run, pass that mapping as `resumeSessionId` so runner can invoke `codex exec resume --json`. +- Persist latest context/token telemetry at `providerState.codex.latestUsage`. +- Emit and consume `run.usage` and `run.provider_session` events. +- Side-panel hydrates usage from `GET /v1/sessions/:sessionId` and shows `Context: unavailable` when telemetry is missing. + ## Security Rules - Relay binds to `127.0.0.1` ONLY. Never `0.0.0.0`. @@ -165,6 +192,12 @@ When a user clicks "Cancel" on Chrome's automation infobar, Chrome detaches the - Token file permissions: `0o600` (owner read/write only). - Single extension slot. Second extension connection gets HTTP 409. +## Operational Non-Goals + +- No new dependencies for client arbitration or standby behavior. +- No per-tab ownership model; arbitration is one relay-level client slot. +- No extension protocol changes for this feature area. + ## Development Workflow ### Commands @@ -229,3 +262,5 @@ Run with: `node --test relay/test/relay-server.test.js` and `node --test mcp/tes 5. **Relay port collision**: Default port 19222. If tests fail with EADDRINUSE, kill stale processes: `lsof -ti:19222 | xargs kill -9`. 6. **Test writeCdpUrl**: Never call `relay.start()` in tests without `{ writeCdpUrl: false }` — it overwrites the production cdp-url file. + +7. **No fixed chat session IDs**: BrowserForce Agent chat must always use explicit user-selected/generated session IDs and persisted session history. Never bind side-panel chat to a single hardcoded ID. diff --git a/GUIDE.md b/GUIDE.md index 4b5b2f9..1e3c59b 100644 --- a/GUIDE.md +++ b/GUIDE.md @@ -1,383 +1,234 @@ -# BrowserForce — User Guide +# BrowserForce - Advanced Guide -## What is this? +This guide is an extension of README, not a duplicate. -BrowserForce gives AI agents — like [OpenClaw](https://github.com/openclaw/openclaw), Claude, or any MCP-compatible tool — access to **your real Chrome browser**. The one you're already logged into. No headless browser, no fake profiles. The AI sees your actual tabs and can interact with any website using your existing sessions. +Use README for onboarding and baseline usage: +- Install and extension setup: [README Setup](README.md#setup) +- Agent connection and MCP snippets: [README Connect Your Agent](README.md#connect-your-agent) +- CLI commands: [README CLI](README.md#cli) +- Core examples: [README Examples](README.md#examples) +- Security model: [README Security](README.md#security) -**Example:** You tell your agent "go to my Gmail and summarize my latest emails" — and it actually opens your Gmail (already logged in), reads the page, and gives you a summary. No passwords, no login flows. +Use this guide for operator workflows, strict tab control, parallel extraction strategy, and production diagnostics. -## What can it do? +## Controlled Tabs Playbook -### Browse the web as you +Use this when you need hard boundaries on what an agent can touch. -| Capability | What it means | -|------------|---------------| -| **See your tabs** | AI sees all your open Chrome tabs instantly | -| **Navigate** | Open any URL in your real browser (with your cookies) | -| **Open new tabs** | Create tabs that inherit all your sessions | -| **Close tabs** | Clean up when done | +### 1) Manual attach (single trusted page) -### Interact with pages +1. Open the exact target tab. +2. Click the BrowserForce extension icon. +3. In the popup, click **+ Attach Current Tab**. +4. Confirm it appears under **Controlled Tabs**. +5. For full CDP traffic, click **View Full Logs** to open the dedicated logs page. -| Capability | What it means | -|------------|---------------| -| **Click** | Click buttons, links, menus — anything | -| **Type** | Type text into any input, search box, or contenteditable field | -| **Fill forms** | Fill input fields (clears existing value first) | -| **Press keys** | Enter, Tab, Escape, Ctrl+C, any key combo | -| **Scroll** | Scroll pages or specific elements | -| **Hover** | Trigger hover menus and tooltips | -| **Select dropdowns** | Pick options from ` +
+ + +
+ + + + + + + + + + + + + + + diff --git a/extension/agent-panel.js b/extension/agent-panel.js new file mode 100644 index 0000000..1bbf4af --- /dev/null +++ b/extension/agent-panel.js @@ -0,0 +1,2024 @@ +import { applyEvent, initialState, reduceState } from './agent-panel-state.js'; +import { + assignSessionRunId, + classifyRunStepIcon, + clearSessionRunId, + formatContextUsage, + getSessionRunId, + renderMarkdownContent, + renderInlineContent, + shouldApplySessionSelection, +} from './agent-panel-runtime.js'; + +const REASONING_PRESETS = [ + { value: null, label: 'Default (Config)' }, + { value: 'low', label: 'Low' }, + { value: 'medium', label: 'Medium' }, + { value: 'high', label: 'High' }, + { value: 'xhigh', label: 'Extra High' }, +]; +const BROWSERFORCE_AGENT_OPEN_REQUEST_KEY = 'browserforceAgentOpenRequest'; +const BROWSERFORCE_AGENT_OPEN_REQUEST_MAX_AGE_MS = 60_000; +const STREAM_CHUNK_TARGET_CHARS = 24; +const STREAM_CHUNK_LOOKAHEAD_CHARS = 14; +const STREAM_CHUNK_INTERVAL_MS = 26; + +const state = { + value: initialState, + auth: null, + providerPresets: [{ value: 'codex', label: 'Codex' }], + defaultProvider: 'codex', + modelPresets: [{ value: null, label: 'Default' }], + defaultReasoningEffort: 'medium', + currentRunBySession: {}, + expandedTimelineEntries: {}, + latestReasoningTitleByRun: {}, + transcriptHandlersBound: false, + tabAttachWatchersBound: false, + agentOpenRequestWatcherBound: false, + lastHandledAgentOpenRequestId: null, + pendingAgentOpenRequest: null, + localImageBlobUrlByPath: {}, + localImageLoadsByPath: {}, + initialTabAttachInFlight: false, + initialTabAttachStarted: false, + editingSessionId: null, + sessionTitleDrafts: {}, + eventController: null, + eventLoopToken: 0, + streamEventQueue: [], + streamEventTimer: null, + sessionSelectionToken: 0, + popover: 'none', + startupIssue: null, + status: { + kind: 'info', + text: 'Starting...', + }, +}; + +const statusEl = document.getElementById('bf-agent-status'); +const statusIconEl = document.getElementById('bf-agent-status-icon'); +const statusTextEl = document.getElementById('bf-agent-status-text'); +const contextUsageEl = document.getElementById('bf-context-usage'); +const modelTriggerBtn = document.getElementById('bf-model-trigger'); +const modelLabelEl = document.getElementById('bf-model-label'); +const sessionTriggerBtn = document.getElementById('bf-session-trigger'); +const sessionLabelEl = document.getElementById('bf-session-label'); +const newSessionBtn = document.getElementById('bf-new-session'); +const popoverBackdropEl = document.getElementById('bf-popover-backdrop'); +const modelPanelEl = document.getElementById('bf-model-panel'); +const sessionPanelEl = document.getElementById('bf-session-panel'); +const providerListEl = document.getElementById('bf-provider-list'); +const modelListEl = document.getElementById('bf-model-list'); +const thinkingListEl = document.getElementById('bf-thinking-list'); +const switchSessionListEl = document.getElementById('bf-switch-session-list'); +const transcriptEl = document.getElementById('bf-transcript'); +const chatFormEl = document.getElementById('bf-chat-form'); +const composerBoxEl = chatFormEl.querySelector('.composer-box'); +const chatInputEl = document.getElementById('bf-chat-input'); +const stopRunBtn = document.getElementById('bf-stop-run'); +const sendBtn = chatFormEl.querySelector('button[type="submit"]'); +const tabAttachBannerEl = document.getElementById('bf-tab-attach-banner'); +const tabAttachTextEl = document.getElementById('bf-tab-attach-text'); +const attachCurrentTabBtn = document.getElementById('bf-attach-current-tab'); +let tabAttachRefreshTimer = null; +let tabAttachRefreshToken = 0; + +function getActiveSession() { + return state.value.sessions.find((item) => item.sessionId === state.value.activeSessionId) || null; +} + +function getActiveMessages() { + return state.value.messagesBySession[state.value.activeSessionId] || []; +} + +function getActiveRun() { + const sessionId = state.value.activeSessionId; + if (!sessionId) return null; + const runId = getSessionRunId(state.currentRunBySession, sessionId); + if (!runId) return null; + return state.value.runs[runId] || null; +} + +function isActiveRunInProgress() { + const run = getActiveRun(); + return !!(run && !run.done); +} + +function reconcileSessionRunState(sessionId) { + if (!sessionId) return false; + const runId = getSessionRunId(state.currentRunBySession, sessionId); + if (!runId) return false; + const run = state.value.runs[runId] || null; + if (!run || run.done) { + state.currentRunBySession = clearSessionRunId(state.currentRunBySession, sessionId, runId); + return true; + } + return false; +} + +function autoResizeInput() { + const styles = window.getComputedStyle(chatInputEl); + const maxHeight = Number.parseFloat(styles.maxHeight) || 160; + chatInputEl.style.height = 'auto'; + chatInputEl.style.height = `${Math.min(chatInputEl.scrollHeight + 1, maxHeight)}px`; +} + +function syncComposerLayoutState() { + const styles = window.getComputedStyle(chatInputEl); + const lineHeight = Number.parseFloat(styles.lineHeight) || 21; + const paddingTop = Number.parseFloat(styles.paddingTop) || 0; + const paddingBottom = Number.parseFloat(styles.paddingBottom) || 0; + const singleLineHeight = lineHeight + paddingTop + paddingBottom; + const hasContent = chatInputEl.value.trim().length > 0; + const isMultiline = hasContent && chatInputEl.scrollHeight > (singleLineHeight + 6); + composerBoxEl.classList.toggle('is-multiline', isMultiline); +} + +function syncComposerState() { + const enabled = !chatInputEl.disabled; + const hasText = chatInputEl.value.trim().length > 0; + const runInProgress = isActiveRunInProgress(); + + composerBoxEl.classList.toggle('is-thinking', enabled && runInProgress); + stopRunBtn.disabled = !enabled || !runInProgress; + stopRunBtn.classList.toggle('active', enabled && runInProgress); + stopRunBtn.hidden = !runInProgress; + sendBtn.disabled = !enabled || runInProgress || !hasText; + sendBtn.hidden = runInProgress; +} + +function syncStatusIndicator() { + const runInProgress = isActiveRunInProgress(); + const hasError = state.status.kind === 'error'; + const text = hasError + ? state.status.text + : runInProgress + ? 'Thinking...' + : state.status.text; + + statusEl.classList.toggle('error', hasError); + statusEl.classList.toggle('thinking', runInProgress && !hasError); + statusEl.title = text || 'Ready'; + statusTextEl.textContent = text || ''; + statusIconEl.textContent = ''; +} + +function renderContextUsageChip() { + if (!contextUsageEl) return; + const sessionId = state.value.activeSessionId; + const usage = sessionId ? state.value.latestUsageBySession?.[sessionId] : null; + const formatted = formatContextUsage(usage || {}); + const note = formatted ? `Context: ${formatted}` : ''; + contextUsageEl.classList.toggle('hidden', !note); + if (!note) { + contextUsageEl.textContent = ''; + contextUsageEl.removeAttribute('title'); + return; + } + contextUsageEl.textContent = note; + contextUsageEl.title = note; +} + +function setStatus(kind, text) { + state.status = { kind, text }; + syncStatusIndicator(); +} + +function normalizeStartupError(code = '', fallbackMessage = 'Unable to connect to BrowserForce Agent') { + const normalized = String(code || '').trim().toLowerCase(); + if (normalized === 'agent_not_running') { + return { + code: 'agent_not_running', + statusText: 'Agent not running', + title: 'BrowserForce Agent is not running', + detail: 'Relay is reachable, but the local agent daemon (chatd) is offline.', + command: 'browserforce agent start', + }; + } + if (normalized === 'extension_not_connected') { + return { + code: 'extension_not_connected', + statusText: 'Extension not connected', + title: 'Extension is not connected to relay', + detail: 'Open the BrowserForce extension popup and reconnect it to the relay.', + command: null, + }; + } + if (normalized === 'relay_unreachable') { + return { + code: 'relay_unreachable', + statusText: 'Relay unreachable', + title: 'Relay is not reachable', + detail: 'Start relay first, then retry opening this side panel.', + command: 'browserforce serve', + }; + } + return { + code: 'unknown', + statusText: 'Connection failed', + title: 'Unable to connect to BrowserForce Agent', + detail: fallbackMessage || 'Check relay and agent daemon status, then try again.', + command: null, + }; +} + +function startupActionsForIssue(startupIssue) { + const code = String(startupIssue?.code || '').trim().toLowerCase(); + const actions = [{ key: 'retry', label: 'Retry' }]; + if (code === 'extension_not_connected' || code === 'relay_unreachable') { + actions.push({ key: 'refresh-connection', label: 'Refresh connection' }); + } + return actions; +} + +function setComposerEnabled(enabled) { + chatInputEl.disabled = !enabled; + autoResizeInput(); + syncComposerLayoutState(); + syncComposerState(); +} + +function setTabAttachBannerState({ + hidden = true, + text = 'Current tab is not connected', + canAttach = false, + busy = false, +} = {}) { + if (!tabAttachBannerEl || !tabAttachTextEl || !attachCurrentTabBtn) return; + tabAttachBannerEl.classList.toggle('hidden', !!hidden); + if (hidden) return; + tabAttachTextEl.textContent = text; + attachCurrentTabBtn.disabled = busy || !canAttach; + attachCurrentTabBtn.textContent = busy ? 'Attaching...' : 'Attach current tab'; +} + +function getTabAttachInProgressState() { + if (!state.initialTabAttachInFlight) return null; + return { + hidden: false, + text: 'Currently attaching active tab...', + canAttach: false, + busy: true, + }; +} + +function dispatch(action) { + state.value = reduceState(state.value, action); + render(); +} + +function applyIncomingEvent(evt) { + state.value = applyEvent(state.value, evt); + const isActiveSessionEvent = evt?.sessionId && evt.sessionId === state.value.activeSessionId; + if (isActiveSessionEvent && evt?.event === 'run.started') { + setStatus('ready', 'Ready'); + } + if (isActiveSessionEvent && evt?.event === 'run.error') { + const errorText = evt?.payload?.error || 'Run failed'; + setStatus('error', `Run failed: ${errorText}`); + } + if (isActiveSessionEvent && (evt?.event === 'chat.final' || evt?.event === 'run.aborted')) { + setStatus('ready', 'Ready'); + } + if (evt?.event === 'run.started' && evt.sessionId && evt.runId) { + state.currentRunBySession = assignSessionRunId(state.currentRunBySession, evt.sessionId, evt.runId); + } + if (evt?.sessionId && evt?.runId && (evt.event === 'chat.final' || evt.event === 'run.error' || evt.event === 'run.aborted')) { + state.currentRunBySession = clearSessionRunId(state.currentRunBySession, evt.sessionId, evt.runId); + } + render(); +} + +function splitDeltaForDisplayStreaming(delta) { + const text = String(delta || ''); + if (!text) return []; + if (text.length <= STREAM_CHUNK_TARGET_CHARS) return [text]; + const chunks = []; + let cursor = 0; + while (cursor < text.length) { + let end = Math.min(cursor + STREAM_CHUNK_TARGET_CHARS, text.length); + if (end < text.length) { + const lookahead = text.slice(end, Math.min(end + STREAM_CHUNK_LOOKAHEAD_CHARS, text.length)); + const wsIndex = lookahead.search(/\s/); + if (wsIndex >= 0) { + end += wsIndex + 1; + } + } + if (end <= cursor) end = Math.min(cursor + STREAM_CHUNK_TARGET_CHARS, text.length); + chunks.push(text.slice(cursor, end)); + cursor = end; + } + return chunks; +} + +function resetStreamEventQueue() { + if (state.streamEventTimer) { + window.clearTimeout(state.streamEventTimer); + state.streamEventTimer = null; + } + state.streamEventQueue = []; +} + +function scheduleStreamEventPump() { + if (state.streamEventTimer || state.streamEventQueue.length === 0) return; + state.streamEventTimer = window.setTimeout(() => { + state.streamEventTimer = null; + const next = state.streamEventQueue.shift(); + if (next) { + applyIncomingEvent(next); + } + if (state.streamEventQueue.length > 0) { + scheduleStreamEventPump(); + } + }, STREAM_CHUNK_INTERVAL_MS); +} + +function flushStreamEventsForRun(sessionId, runId) { + if (!sessionId || !runId || state.streamEventQueue.length === 0) return; + const keep = []; + const flush = []; + for (const queued of state.streamEventQueue) { + if (queued?.sessionId === sessionId && queued?.runId === runId) { + flush.push(queued); + } else { + keep.push(queued); + } + } + state.streamEventQueue = keep; + if (flush.length > 0) { + for (const queued of flush) { + applyIncomingEvent(queued); + } + } + if (state.streamEventTimer) { + window.clearTimeout(state.streamEventTimer); + state.streamEventTimer = null; + } + if (state.streamEventQueue.length > 0) { + scheduleStreamEventPump(); + } +} + +function dispatchEvent(evt) { + if (!evt || typeof evt !== 'object') return; + const eventType = String(evt.event || ''); + const isTextDeltaEvent = ( + (eventType === 'chat.delta' || eventType === 'chat.commentary') + && typeof evt.payload?.delta === 'string' + ); + + if (!isTextDeltaEvent) { + flushStreamEventsForRun(evt.sessionId, evt.runId); + applyIncomingEvent(evt); + return; + } + + const chunks = splitDeltaForDisplayStreaming(evt.payload.delta); + if (chunks.length <= 1) { + applyIncomingEvent(evt); + return; + } + + const firstPayload = { ...(evt.payload || {}), delta: chunks[0] }; + applyIncomingEvent({ ...evt, payload: firstPayload }); + + const bufferedPayload = { ...(evt.payload || {}) }; + for (let index = 1; index < chunks.length; index += 1) { + state.streamEventQueue.push({ + ...evt, + payload: { + ...bufferedPayload, + delta: chunks[index], + }, + }); + } + scheduleStreamEventPump(); +} + +function formatModelLabel(model) { + return model && String(model).trim() ? model : 'Default'; +} + +function normalizeProvider(value) { + const normalized = String(value || '').trim().toLowerCase(); + return normalized || null; +} + +function formatProviderLabel(provider) { + const normalized = normalizeProvider(provider); + if (!normalized) return 'Provider'; + return normalized.charAt(0).toUpperCase() + normalized.slice(1); +} + +function getSessionProvider(session) { + const explicit = normalizeProvider(session?.provider); + if (explicit) return explicit; + const configured = normalizeProvider(state.defaultProvider); + if (configured) return configured; + const firstPreset = normalizeProvider(state.providerPresets?.[0]?.value); + if (firstPreset) return firstPreset; + return 'codex'; +} + +function normalizeReasoningEffort(value) { + const normalized = String(value || '').trim().toLowerCase(); + if (normalized === 'low' || normalized === 'medium' || normalized === 'high' || normalized === 'xhigh') { + return normalized; + } + return null; +} + +function formatReasoningEffortLabel(value) { + const normalized = normalizeReasoningEffort(value); + if (normalized === 'low') return 'Low'; + if (normalized === 'medium') return 'Medium'; + if (normalized === 'high') return 'High'; + if (normalized === 'xhigh') return 'Extra High'; + return 'Medium'; +} + +function isDefaultSessionTitle(title) { + const lowered = String(title || '').trim().toLowerCase(); + return !lowered || lowered === 'new session' || lowered === 'new chat'; +} + +function formatShortSessionId(sessionId) { + const raw = String(sessionId || '').trim(); + if (!raw) return 'unknown'; + return raw.slice(0, 8); +} + +function formatSessionDisplayName(session) { + if (!session) return 'Session'; + const title = String(session.title || '').trim(); + if (!isDefaultSessionTitle(title)) return title; + return session.sessionId || 'Session'; +} + +function formatSessionLabel(session) { + if (!session) return 'Session'; + const title = String(session.title || '').trim(); + if (!isDefaultSessionTitle(title)) return title; + return formatShortSessionId(session.sessionId); +} + +function formatSessionTimestamp(session) { + const raw = session?.updatedAt || session?.createdAt; + if (!raw) return 'Unknown time'; + const date = new Date(raw); + if (Number.isNaN(date.getTime())) return 'Unknown time'; + return date.toLocaleString(undefined, { + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + }); +} + +function renderSelectors() { + const activeSession = getActiveSession(); + const modelLabel = `Model: ${formatModelLabel(activeSession?.model)}`; + const sessionLabel = formatSessionLabel(activeSession); + + if (modelLabelEl) { + modelLabelEl.textContent = modelLabel; + } else { + modelTriggerBtn.textContent = modelLabel; + } + + if (sessionLabelEl) { + sessionLabelEl.textContent = sessionLabel; + } else { + sessionTriggerBtn.textContent = sessionLabel; + } +} + +function renderModelList() { + if (!modelListEl || !thinkingListEl) return; + const activeSession = getActiveSession(); + const activeProvider = getSessionProvider(activeSession); + const activeModel = activeSession?.model || null; + const activeReasoningEffort = normalizeReasoningEffort(activeSession?.reasoningEffort); + const providerRows = state.providerPresets.length > 0 + ? state.providerPresets + : [{ value: activeProvider, label: formatProviderLabel(activeProvider) }]; + + if (providerListEl) { + providerListEl.innerHTML = providerRows.map((preset) => { + const providerValue = normalizeProvider(preset.value); + const active = providerValue === activeProvider ? 'active' : ''; + return ` +
  • + +
  • + `; + }).join(''); + + providerListEl.querySelectorAll('button[data-provider]').forEach((button) => { + button.addEventListener('click', () => { + const provider = button.dataset.provider || null; + updateActiveSessionProvider(provider).catch((error) => { + setStatus('error', error.message || 'Unable to update provider'); + }); + }); + }); + } + + const rows = state.modelPresets.map((preset) => { + const active = (preset.value || null) === activeModel ? 'active' : ''; + return `
  • `; + }); + rows.push('
  • '); + + modelListEl.innerHTML = rows.join(''); + thinkingListEl.innerHTML = REASONING_PRESETS.map((preset) => { + const active = (preset.value || null) === (activeReasoningEffort || null) ? 'active' : ''; + let label = preset.label; + if (preset.value == null) { + label = `Default (Config: ${formatReasoningEffortLabel(state.defaultReasoningEffort)})`; + } + return `
  • `; + }).join(''); + + modelListEl.querySelectorAll('button[data-model]').forEach((button) => { + button.addEventListener('click', () => { + const model = button.dataset.model || null; + updateActiveSessionModel(model).catch((error) => { + setStatus('error', error.message || 'Unable to update model'); + }); + }); + }); + + const customBtn = modelListEl.querySelector('button[data-model-custom]'); + if (customBtn) { + customBtn.addEventListener('click', async () => { + const current = activeModel || ''; + const value = window.prompt('Enter model id', current); + if (value === null) return; + const model = value.trim() || null; + try { + await updateActiveSessionModel(model); + } catch (error) { + setStatus('error', error.message || 'Unable to update model'); + } + }); + } + + thinkingListEl.querySelectorAll('button[data-reasoning-effort]').forEach((button) => { + button.addEventListener('click', () => { + const value = button.dataset.reasoningEffort || null; + const reasoningEffort = normalizeReasoningEffort(value); + updateActiveSessionReasoningEffort(reasoningEffort).catch((error) => { + setStatus('error', error.message || 'Unable to update thinking level'); + }); + }); + }); +} + +function renderSessions() { + const sessions = state.value.sessions; + if (!sessions.length) { + switchSessionListEl.innerHTML = '
  • No sessions
  • '; + return; + } + + switchSessionListEl.innerHTML = sessions + .map((session) => { + const active = session.sessionId === state.value.activeSessionId ? 'active' : ''; + const displayName = formatSessionDisplayName(session); + const timestamp = formatSessionTimestamp(session); + const shortId = formatShortSessionId(session.sessionId); + const editing = session.sessionId === state.editingSessionId; + const draftTitle = Object.prototype.hasOwnProperty.call(state.sessionTitleDrafts, session.sessionId) + ? state.sessionTitleDrafts[session.sessionId] + : (isDefaultSessionTitle(session.title) ? '' : String(session.title || '').trim()); + + if (editing) { + return ` +
  • +
    + + + +
    +
  • + `; + } + + return ` +
  • + + +
  • + `; + }) + .join(''); + + switchSessionListEl.querySelectorAll('button[data-session-id]').forEach((button) => { + button.addEventListener('click', async () => { + await selectSession(button.dataset.sessionId); + setPopover('none'); + }); + }); + + switchSessionListEl.querySelectorAll('button[data-session-edit-btn]').forEach((button) => { + button.addEventListener('click', () => { + beginSessionEdit(button.getAttribute('data-session-edit-btn') || ''); + }); + }); + + switchSessionListEl.querySelectorAll('form[data-session-edit-form]').forEach((form) => { + form.addEventListener('submit', async (event) => { + event.preventDefault(); + const sessionId = form.getAttribute('data-session-edit-form') || ''; + const input = form.querySelector('input[data-session-edit-input]'); + const title = input?.value || ''; + try { + await updateSessionTitle(sessionId, title); + } catch (error) { + setStatus('error', error?.message || 'Unable to rename session'); + } + }); + }); + + switchSessionListEl.querySelectorAll('button[data-session-edit-cancel]').forEach((button) => { + button.addEventListener('click', () => { + cancelSessionEdit(button.getAttribute('data-session-edit-cancel') || ''); + }); + }); + + switchSessionListEl.querySelectorAll('input[data-session-edit-input]').forEach((input) => { + input.addEventListener('input', () => { + const sessionId = input.getAttribute('data-session-edit-input') || ''; + state.sessionTitleDrafts = { + ...(state.sessionTitleDrafts || {}), + [sessionId]: input.value, + }; + }); + input.addEventListener('keydown', (event) => { + if (event.key !== 'Escape') return; + event.preventDefault(); + const sessionId = input.getAttribute('data-session-edit-input') || ''; + cancelSessionEdit(sessionId); + }); + }); +} + +function normalizeRunTimeline(run, fallbackText = '') { + if (!run) return []; + if (Array.isArray(run.timeline) && run.timeline.length > 0) { + const isRenderableStep = (entry) => ( + !!entry + && typeof entry === 'object' + && entry.type === 'step' + && typeof entry.label === 'string' + && entry.label.trim().length > 0 + ); + const isRenderableText = (entry) => ( + !!entry + && typeof entry === 'object' + && entry.type === 'text' + && typeof entry.text === 'string' + && entry.text.trim().length > 0 + ); + const stripInlineMarkdown = (text) => String(text || '') + .replace(/`([^`]+)`/g, '$1') + .replace(/\*\*([^*]+)\*\*/g, '$1') + .replace(/\*([^*\n]+)\*/g, '$1') + .replace(/~~([^~]+)~~/g, '$1') + .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1') + .replace(/^>\s*/gm, '') + .trim(); + const headingFromText = (text) => { + const firstLine = String(text || '') + .split('\n') + .map((line) => line.trim()) + .find(Boolean) || ''; + if (!firstLine) return ''; + let heading = stripInlineMarkdown(firstLine) + .replace(/^[\-*\d.)\s]+/, '') + .replace(/^\s*(?:i'?m|i am|i'?ll|i will)\s+/i, '') + .replace(/^(?:next|now)\s*,?\s+/i, '') + .replace(/[.?!:;,\s]+$/, '') + .replace(/\s+/g, ' ') + .trim(); + if (!heading) return ''; + if (heading.length > 96) heading = `${heading.slice(0, 93).trimEnd()}...`; + return heading.charAt(0).toUpperCase() + heading.slice(1); + }; + + const timeline = []; + const source = run.timeline.filter((entry) => isRenderableStep(entry) || isRenderableText(entry)); + for (let index = 0; index < source.length; index += 1) { + const entry = source[index]; + if (entry.type === 'step') { + timeline.push(entry); + continue; + } + const hasStepAfter = source.slice(index + 1).some((item) => item.type === 'step'); + if (!hasStepAfter) { + timeline.push(entry); + continue; + } + const heading = headingFromText(entry.text || ''); + if (!heading) continue; + timeline.push({ + type: 'step', + kind: 'reasoning', + status: run.done ? 'done' : 'running', + key: `derived:commentary:${index}`, + label: heading, + }); + } + return timeline; + } + + const steps = Array.isArray(run.steps) ? run.steps : []; + const timeline = steps.map((step) => ({ + type: 'step', + kind: step?.kind || 'reasoning', + status: step?.status || 'running', + label: step?.label || '', + })); + + const text = typeof fallbackText === 'string' && fallbackText + ? fallbackText + : (typeof run.text === 'string' ? run.text : ''); + if (text) timeline.push({ type: 'text', text }); + return timeline; +} + +const EXECUTE_HELPER_EXCLUDE_CALLS = new Set([ + 'if', + 'for', + 'while', + 'switch', + 'catch', + 'snapshot', + 'reftolocator', + 'waitforpageload', + 'getlogs', + 'clearlogs', + 'screenshotwithaccessibilitylabels', + 'cleanhtml', + 'pagemarkdown', + 'getcdpsession', + 'plugincatalog', + 'pluginhelp', + 'fetch', + 'settimeout', + 'cleartimeout', + 'promise', + 'array', + 'object', + 'number', + 'string', + 'boolean', + 'date', + 'math', + 'json', + 'parseint', + 'parsefloat', + 'isnan', + 'isfinite', + 'encodeuri', + 'decodeuri', +]); + +function isBrowserForceExecuteStep(entry) { + const label = String(entry?.label || '').trim().toLowerCase(); + return ( + label === 'browserforce:execute' + || label === 'browserforce execute' + || label === 'mcp__browserforce__execute' + || label === 'execute' + ); +} + +function extractExecuteHelperCalls(details) { + if (!Array.isArray(details) || details.length === 0) return []; + const helperCalls = []; + const seen = new Set(); + const callPattern = /(^|[^.\w$])([A-Za-z_$][\w$]{2,})\s*\(/g; + + for (const line of details) { + const text = String(line || ''); + if (!text) continue; + callPattern.lastIndex = 0; + for (const match of text.matchAll(callPattern)) { + const callName = String(match[2] || '').trim(); + if (!callName) continue; + const normalized = callName.toLowerCase(); + if (EXECUTE_HELPER_EXCLUDE_CALLS.has(normalized)) continue; + if (seen.has(normalized)) continue; + seen.add(normalized); + helperCalls.push(callName); + if (helperCalls.length >= 3) return helperCalls; + } + } + + return helperCalls; +} + +function renderExecuteHelperTreePreview(entry, expanded) { + if (expanded) return ''; + if (!isBrowserForceExecuteStep(entry)) return ''; + const details = Array.isArray(entry?.details) ? entry.details : []; + const helperCalls = extractExecuteHelperCalls(details); + if (!helperCalls.length) return ''; + const status = String(entry?.status || '').toLowerCase() === 'done' ? 'done' : 'running'; + return ` + + `; +} + +function getLatestInFlightTimelineStepIndex(run, timeline) { + if (!run || run.done) return -1; + for (let index = timeline.length - 1; index >= 0; index -= 1) { + const entry = timeline[index]; + if (entry?.type !== 'step') continue; + const status = String(entry.status || 'running').toLowerCase(); + if (status === 'running') return index; + } + return -1; +} + +function shouldAnimateLatestReasoningTitle({ run, entry, isLatest, isRunningReasoning }) { + if (!isLatest || !isRunningReasoning) return false; + const runId = String(run?.runId || '').trim(); + if (!runId) return false; + const signature = `${String(entry?.key || '').trim()}::${String(entry?.label || '').trim()}`; + if (!signature || signature === '::') return false; + const previous = state.latestReasoningTitleByRun[runId]; + if (previous === signature) return false; + state.latestReasoningTitleByRun = { + ...state.latestReasoningTitleByRun, + [runId]: signature, + }; + return true; +} + +function renderRunTimeline(run, fallbackText = '') { + const timeline = normalizeRunTimeline(run, fallbackText); + if (!timeline.length) return ''; + const latestStepIndex = getLatestInFlightTimelineStepIndex(run, timeline); + const getTimelineEntryKey = (entry, index) => { + const runId = String(run?.runId || 'run'); + const stableKey = String(entry?.key || '').trim(); + if (stableKey) return `${runId}:${stableKey}`; + const kind = String(entry?.kind || ''); + const status = String(entry?.status || ''); + const label = String(entry?.label || ''); + return `${runId}:${index}:${kind}:${status}:${label}`; + }; + return ` +
    + ${timeline.map((entry, index) => { + if (entry.type === 'text') { + return `
    ${renderContent(entry.text || '')}
    `; + } + const status = entry?.status || 'running'; + const normalizedStatus = String(status || '').toLowerCase(); + const icon = classifyRunStepIcon(entry); + const isLatest = index === latestStepIndex; + const shouldPulse = isLatest && status === 'running'; + const isReasoningTitle = String(entry?.kind || '').toLowerCase() === 'reasoning'; + const isExecuteTitle = isBrowserForceExecuteStep(entry); + const isTitleRow = isReasoningTitle || isExecuteTitle; + const isRunningTitle = isTitleRow && normalizedStatus === 'running'; + const labelClasses = ['step-label']; + if (isTitleRow) labelClasses.push('title-label'); + if (isRunningTitle && isLatest) { + labelClasses.push('shimmer-text'); + if (shouldAnimateLatestReasoningTitle({ run, entry, isLatest, isRunningReasoning: isRunningTitle })) { + labelClasses.push('title-transition-in'); + } + } + const details = Array.isArray(entry?.details) ? entry.details.filter(Boolean) : []; + const isCollapsible = details.length > 0; + const classes = ['step-item', 'timeline-step', escapeHtml(status)]; + if (isLatest) classes.push('latest'); + if (shouldPulse) classes.push('pulse'); + if (!isCollapsible) { + return `
    ${renderRunStepIcon(icon)}${renderInlineContent(entry.label || 'Step')}
    `; + } + classes.push('collapsible'); + const key = getTimelineEntryKey(entry, index); + const expanded = !!state.expandedTimelineEntries[key]; + if (expanded) classes.push('expanded'); + const helperTreePreviewHtml = renderExecuteHelperTreePreview(entry, expanded); + const detailsHtml = details + .map((line) => `
  • ${renderInlineContent(line)}
  • `) + .join(''); + return ` +
    + ${renderRunStepIcon(icon)} +
    + + ${expanded ? `
      ${detailsHtml}
    ` : ''} +
    +
    + `; + }).join('')} +
    + `; +} + +function renderRunStepIcon(icon) { + const iconName = String(icon || '').trim().toLowerCase(); + if (iconName === 'done') { + return ` + + `; + } + return ``; +} + +function renderContent(value) { + return renderMarkdownContent(value); +} + +async function loadLocalImageBlobUrl(localPath) { + const path = String(localPath || '').trim(); + if (!path || !state.auth?.baseUrl || !state.auth?.token) return null; + if (state.localImageBlobUrlByPath[path]) return state.localImageBlobUrlByPath[path]; + if (state.localImageLoadsByPath[path]) return state.localImageLoadsByPath[path]; + + const loadPromise = (async () => { + const url = `${state.auth.baseUrl}/v1/local-file?path=${encodeURIComponent(path)}`; + const response = await fetch(url, { + method: 'GET', + headers: { + authorization: `Bearer ${state.auth.token}`, + }, + }); + if (!response.ok) return null; + const blob = await response.blob(); + const blobUrl = URL.createObjectURL(blob); + state.localImageBlobUrlByPath = { + ...(state.localImageBlobUrlByPath || {}), + [path]: blobUrl, + }; + return blobUrl; + })().finally(() => { + const nextLoads = { ...(state.localImageLoadsByPath || {}) }; + delete nextLoads[path]; + state.localImageLoadsByPath = nextLoads; + }); + + state.localImageLoadsByPath = { + ...(state.localImageLoadsByPath || {}), + [path]: loadPromise, + }; + return loadPromise; +} + +function hydrateLocalImagePreviews() { + if (!transcriptEl) return; + const imageNodes = transcriptEl.querySelectorAll('img.inline-local-image[data-local-path]'); + for (const node of imageNodes) { + const localPath = String(node.getAttribute('data-local-path') || '').trim(); + if (!localPath) continue; + const cached = state.localImageBlobUrlByPath?.[localPath]; + if (cached) { + if (node.getAttribute('src') !== cached) node.setAttribute('src', cached); + continue; + } + loadLocalImageBlobUrl(localPath) + .then((blobUrl) => { + if (!blobUrl) return; + transcriptEl.querySelectorAll('img.inline-local-image[data-local-path]').forEach((img) => { + if (String(img.getAttribute('data-local-path') || '').trim() !== localPath) return; + img.setAttribute('src', blobUrl); + }); + }) + .catch(() => { + // best-effort preview only + }); + } +} + +function bindTranscriptHandlers() { + if (state.transcriptHandlersBound) return; + transcriptEl.addEventListener('click', async (event) => { + const startupActionBtn = event.target.closest('button[data-startup-action]'); + if (startupActionBtn && transcriptEl.contains(startupActionBtn)) { + event.preventDefault(); + const msgAction = startupActionBtn.getAttribute('data-startup-action'); + if (msgAction === 'retry') { + await retryStartup(); + return; + } + if (msgAction === 'refresh-connection') { + await retryStartup({ refreshConnection: true }); + return; + } + } + const toggleBtn = event.target.closest('button[data-step-key]'); + if (!toggleBtn || !transcriptEl.contains(toggleBtn)) return; + const stepKey = toggleBtn.getAttribute('data-step-key'); + if (!stepKey) return; + const nextExpanded = !state.expandedTimelineEntries[stepKey]; + state.expandedTimelineEntries = { + ...state.expandedTimelineEntries, + [stepKey]: nextExpanded, + }; + const scrollTop = transcriptEl.scrollTop; + renderTranscript({ preserveScrollTop: scrollTop }); + }); + state.transcriptHandlersBound = true; +} + +function renderTranscript({ preserveScrollTop = null } = {}) { + const messages = getActiveMessages(); + const sessionId = state.value.activeSessionId; + const sessionRunId = getSessionRunId(state.currentRunBySession, sessionId); + const run = sessionRunId ? state.value.runs[sessionRunId] : null; + + const chunks = messages.map((msg) => { + const role = msg.role || 'assistant'; + if (role === 'user') { + return ` +
    +
    You
    +
    ${escapeHtml(msg.text || '')}
    +
    + `; + } + + const messageRun = msg.runId ? state.value.runs[msg.runId] : null; + const timelineHtml = renderRunTimeline(messageRun, msg.text || ''); + const fallbackHtml = `
    ${renderContent(msg.text || '')}
    `; + return ` +
    +
    BrowserForce
    +
    + ${timelineHtml || fallbackHtml} +
    +
    + `; + }); + + if (run && !run.done) { + const timelineHtml = renderRunTimeline(run, run.text || ''); + const shouldShowThinking = !(run.text && run.text.trim()); + chunks.push(` +
    +
    BrowserForce
    +
    + ${timelineHtml} + ${shouldShowThinking ? '
    Thinking...
    ' : ''} +
    +
    + `); + } + + if (!chunks.length) { + const startupIssue = state.startupIssue; + if (startupIssue) { + const commandHtml = startupIssue.command + ? `

    ${escapeHtml(startupIssue.command)}

    ` + : ''; + const actions = startupActionsForIssue(startupIssue); + const actionsHtml = actions.length > 0 + ? ` +
    + ${actions.map((action) => ` + + `).join('')} +
    + ` + : ''; + transcriptEl.innerHTML = ` +
    +
    !
    +
    +

    ${escapeHtml(startupIssue.title || 'Unable to connect')}

    +

    ${escapeHtml(startupIssue.detail || '')}

    + ${commandHtml} + ${actionsHtml} +
    +
    + `; + bindTranscriptHandlers(); + if (Number.isFinite(preserveScrollTop)) { + transcriptEl.scrollTop = preserveScrollTop; + } else { + transcriptEl.scrollTop = transcriptEl.scrollHeight; + } + syncStatusIndicator(); + syncComposerState(); + return; + } + transcriptEl.innerHTML = ` +
    +
    B
    +
    +

    Start a conversation

    +

    Ask BrowserForce to inspect your active tab or run a browser task.

    +
    +
    + `; + } else { + transcriptEl.innerHTML = chunks.join(''); + } + + bindTranscriptHandlers(); + hydrateLocalImagePreviews(); + if (Number.isFinite(preserveScrollTop)) { + transcriptEl.scrollTop = preserveScrollTop; + } else { + transcriptEl.scrollTop = transcriptEl.scrollHeight; + } + syncStatusIndicator(); + syncComposerState(); +} + +function setPopover(popover) { + state.popover = popover; + renderPopovers(); +} + +function renderPopovers() { + const modelOpen = state.popover === 'model'; + const sessionOpen = state.popover === 'session'; + const anyOpen = modelOpen || sessionOpen; + + modelTriggerBtn.setAttribute('aria-expanded', modelOpen ? 'true' : 'false'); + sessionTriggerBtn.setAttribute('aria-expanded', sessionOpen ? 'true' : 'false'); + popoverBackdropEl.classList.toggle('hidden', !anyOpen); + modelPanelEl.classList.toggle('hidden', !modelOpen); + sessionPanelEl.classList.toggle('hidden', !sessionOpen); +} + +function render() { + renderSelectors(); + renderContextUsageChip(); + renderModelList(); + renderSessions(); + renderTranscript(); + renderPopovers(); +} + +function escapeHtml(value) { + const div = document.createElement('div'); + div.textContent = value; + return div.innerHTML; +} + +function normalizeAgentOpenRequest(raw) { + if (!raw || typeof raw !== 'object') return null; + const requestId = String(raw.requestId || '').trim(); + const requestedAt = Number(raw.requestedAt); + if (!requestId || !Number.isFinite(requestedAt)) return null; + if ((Date.now() - requestedAt) > BROWSERFORCE_AGENT_OPEN_REQUEST_MAX_AGE_MS) return null; + return { + requestId, + requestedAt, + source: String(raw.source || '').trim() || null, + }; +} + +async function consumePendingAgentOpenRequest() { + if (!chrome?.storage?.local?.get || !chrome?.storage?.local?.remove) return null; + try { + const stored = await chrome.storage.local.get([BROWSERFORCE_AGENT_OPEN_REQUEST_KEY]); + const request = normalizeAgentOpenRequest(stored?.[BROWSERFORCE_AGENT_OPEN_REQUEST_KEY]); + if (!request) return null; + await chrome.storage.local.remove(BROWSERFORCE_AGENT_OPEN_REQUEST_KEY); + state.lastHandledAgentOpenRequestId = request.requestId; + return request; + } catch { + return null; + } +} + +async function startFreshSessionFromOpenRequest(rawRequest) { + const request = normalizeAgentOpenRequest(rawRequest); + if (!request) return; + if (state.lastHandledAgentOpenRequestId === request.requestId) return; + state.lastHandledAgentOpenRequestId = request.requestId; + if (!state.auth) { + state.pendingAgentOpenRequest = request; + return; + } + try { + await chrome.storage.local.remove(BROWSERFORCE_AGENT_OPEN_REQUEST_KEY); + } catch { + // best-effort cleanup + } + state.pendingAgentOpenRequest = null; + await createSession(); +} + +function bindAgentOpenRequestWatcher() { + if (state.agentOpenRequestWatcherBound) return; + if (!chrome?.storage?.onChanged?.addListener) return; + state.agentOpenRequestWatcherBound = true; + chrome.storage.onChanged.addListener((changes, areaName) => { + if (areaName !== 'local') return; + const change = changes?.[BROWSERFORCE_AGENT_OPEN_REQUEST_KEY]; + if (!change?.newValue) return; + startFreshSessionFromOpenRequest(change.newValue).catch((error) => { + setStatus('error', error?.message || 'Unable to start a new conversation'); + }); + }); +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function runtimeMessage(message) { + return new Promise((resolve, reject) => { + if (!chrome?.runtime?.sendMessage) { + resolve(null); + return; + } + try { + chrome.runtime.sendMessage(message, (response) => { + if (chrome.runtime.lastError) { + reject(new Error(chrome.runtime.lastError.message || 'runtime message failed')); + return; + } + resolve(response || null); + }); + } catch (error) { + reject(error); + } + }); +} + +function isIgnoredAttachError(errorMessage) { + const text = String(errorMessage || '').toLowerCase(); + return ( + text.includes('already attached') + || text.includes('cannot attach internal') + || text.includes('no active tab') + ); +} + +async function ensureCurrentTabAttached() { + try { + const response = await runtimeMessage({ type: 'attachCurrentTab' }); + if (response?.error && !isIgnoredAttachError(response.error)) { + console.warn('[bf-agent] attachCurrentTab failed:', response.error); + } + return response || null; + } catch { + // best-effort only + return null; + } +} + +function isTabAttachableUrl(url) { + const value = String(url || '').trim(); + if (!value) return false; + return !( + value.startsWith('chrome://') + || value.startsWith('chrome-extension://') + || value.startsWith('edge://') + || value.startsWith('devtools://') + ); +} + +async function getCurrentTabAttachmentState() { + if (!chrome?.tabs?.query) return { hidden: true }; + let tab = null; + try { + [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); + } catch { + return { hidden: true }; + } + if (!tab || typeof tab.id !== 'number') return { hidden: true }; + if (!isTabAttachableUrl(tab.url)) { + return { + hidden: false, + text: 'This tab cannot be attached', + canAttach: false, + }; + } + + try { + const status = await runtimeMessage({ type: 'getStatus' }); + if (status?.connectionState && status.connectionState !== 'connected') { + return { + hidden: false, + text: 'Relay disconnected', + canAttach: false, + }; + } + + const attachedTabs = Array.isArray(status?.tabs) ? status.tabs : []; + const attached = attachedTabs.some((item) => Number(item?.tabId) === tab.id); + if (attached) return { hidden: true }; + return { + hidden: false, + text: 'Current tab is not connected', + canAttach: true, + }; + } catch { + return { + hidden: false, + text: 'Unable to check tab connection', + canAttach: false, + }; + } +} + +async function refreshTabAttachBanner() { + const token = ++tabAttachRefreshToken; + const inProgressState = getTabAttachInProgressState(); + if (inProgressState) { + setTabAttachBannerState(inProgressState); + return; + } + const next = await getCurrentTabAttachmentState(); + if (token !== tabAttachRefreshToken) return; + setTabAttachBannerState(next); +} + +function scheduleTabAttachRefresh(delayMs = 0) { + if (tabAttachRefreshTimer) clearTimeout(tabAttachRefreshTimer); + tabAttachRefreshTimer = setTimeout(() => { + refreshTabAttachBanner().catch(() => {}); + }, delayMs); +} + +function bindTabAttachWatchers() { + if (state.tabAttachWatchersBound) return; + state.tabAttachWatchersBound = true; + if (chrome?.tabs?.onActivated?.addListener) { + chrome.tabs.onActivated.addListener(() => { + scheduleTabAttachRefresh(40); + }); + } + if (chrome?.tabs?.onUpdated?.addListener) { + chrome.tabs.onUpdated.addListener((_tabId, changeInfo, tab) => { + if (!tab?.active) return; + if (!('status' in changeInfo) && !('url' in changeInfo) && !('title' in changeInfo)) return; + scheduleTabAttachRefresh(80); + }); + } + if (chrome?.windows?.onFocusChanged?.addListener) { + chrome.windows.onFocusChanged.addListener(() => { + scheduleTabAttachRefresh(80); + }); + } +} + +function startInitialTabAttach() { + if (state.initialTabAttachStarted) return; + state.initialTabAttachStarted = true; + state.initialTabAttachInFlight = true; + setTabAttachBannerState(getTabAttachInProgressState() || undefined); + renderContextUsageChip(); + window.setTimeout(() => { + ensureCurrentTabAttached() + .catch(() => { + // best-effort only + }) + .finally(() => { + state.initialTabAttachInFlight = false; + renderContextUsageChip(); + scheduleTabAttachRefresh(0); + }); + }, 2000); +} + +async function getActiveTabContext() { + if (!chrome?.tabs?.query) return null; + try { + const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); + if (!tab || typeof tab.id !== 'number') return null; + const title = String(tab.title || '').trim().slice(0, 180); + const url = String(tab.url || '').trim(); + if (!isTabAttachableUrl(url)) { + return { tabId: tab.id, title, url: null }; + } + return { tabId: tab.id, title, url: url.slice(0, 500) }; + } catch { + return null; + } +} + +async function getRelayHttpUrl() { + const stored = await chrome.storage.local.get(['relayUrl']); + const relayUrl = stored.relayUrl || 'ws://127.0.0.1:19222/extension'; + if (relayUrl.startsWith('ws://')) return relayUrl.replace('ws://', 'http://').replace('/extension', ''); + if (relayUrl.startsWith('wss://')) return relayUrl.replace('wss://', 'https://').replace('/extension', ''); + return 'http://127.0.0.1:19222'; +} + +async function refreshExtensionConnection() { + const stored = await chrome.storage.local.get(['relayUrl']); + const relayUrl = stored.relayUrl || 'ws://127.0.0.1:19222/extension'; + const response = await runtimeMessage({ type: 'updateRelayUrl', relayUrl }); + if (response?.error) throw new Error(response.error); +} + +async function loadAuth() { + const relayHttpUrl = await getRelayHttpUrl(); + const extensionId = chrome?.runtime?.id; + let res; + try { + res = await fetch(`${relayHttpUrl}/chatd-url`, { + headers: extensionId ? { 'x-browserforce-extension-id': extensionId } : {}, + }); + } catch { + const error = new Error('relay_unreachable'); + error.code = 'relay_unreachable'; + throw error; + } + if (!res.ok) { + const body = await readJsonOrEmpty(res); + const relayError = String(body?.error || '').toLowerCase(); + if (res.status === 404 && relayError.includes('chatd not running')) { + const error = new Error('agent_not_running'); + error.code = 'agent_not_running'; + throw error; + } + if (res.status === 503 && relayError.includes('extension not connected')) { + const error = new Error('extension_not_connected'); + error.code = 'extension_not_connected'; + throw error; + } + const error = new Error(body?.error || `chatd-url failed (${res.status})`); + error.code = 'daemon_unavailable'; + throw error; + } + const body = await res.json(); + state.auth = { + baseUrl: `http://127.0.0.1:${body.port}`, + token: body.token, + }; +} + +async function api(path, init = {}) { + const headers = { + 'content-type': 'application/json', + authorization: `Bearer ${state.auth.token}`, + ...(init.headers || {}), + }; + return fetch(`${state.auth.baseUrl}${path}`, { ...init, headers }); +} + +async function readJsonOrEmpty(response) { + try { + return await response.json(); + } catch { + return {}; + } +} + +async function ensureOk(response, fallbackMessage) { + if (response.ok) return response; + const body = await readJsonOrEmpty(response); + throw new Error(body.error || `${fallbackMessage} (${response.status})`); +} + +async function loadSessions(preferredSessionId = null) { + const res = await api('/v1/sessions'); + await ensureOk(res, 'Failed to load sessions'); + const body = await readJsonOrEmpty(res); + const sessions = body.sessions || []; + const activeFromPreference = preferredSessionId && sessions.some((s) => s.sessionId === preferredSessionId) + ? preferredSessionId + : null; + dispatch({ + type: 'session.list.loaded', + sessions, + activeSessionId: activeFromPreference || sessions[0]?.sessionId || null, + }); +} + +function normalizeModelRows(input) { + const source = Array.isArray(input) ? input : []; + const seen = new Set(['__default__']); + const rows = [{ value: null, label: 'Default' }]; + for (const row of source) { + if (!row || typeof row !== 'object') continue; + const value = row.value == null ? null : String(row.value).trim(); + const key = value || '__default__'; + if (seen.has(key)) continue; + seen.add(key); + rows.push({ + value, + label: row.label && String(row.label).trim() ? String(row.label).trim() : (value || 'Default'), + }); + } + return rows; +} + +function normalizeProviderRows(input) { + const source = Array.isArray(input) ? input : []; + const seen = new Set(); + const rows = []; + for (const row of source) { + if (!row) continue; + const rawValue = typeof row === 'string' + ? row + : (row.value ?? row.id); + const value = normalizeProvider(rawValue); + if (!value || seen.has(value)) continue; + seen.add(value); + const label = (typeof row === 'object' && row.label && String(row.label).trim()) + ? String(row.label).trim() + : formatProviderLabel(value); + rows.push({ value, label }); + } + return rows; +} + +function resolveModelCatalog(body, preferredProvider = null) { + const payload = (body && typeof body === 'object') ? body : {}; + const modelsByProvider = payload.modelsByProvider && typeof payload.modelsByProvider === 'object' + ? payload.modelsByProvider + : null; + const providerRows = normalizeProviderRows( + payload.providers || payload.providerPresets || payload.availableProviders + || (modelsByProvider ? Object.keys(modelsByProvider) : []), + ); + const defaultProvider = normalizeProvider(payload.defaultProvider || payload.provider) + || normalizeProvider(providerRows[0]?.value) + || 'codex'; + const selectedProvider = normalizeProvider(preferredProvider) + || defaultProvider; + const scopedModels = (modelsByProvider && selectedProvider && Array.isArray(modelsByProvider[selectedProvider])) + ? modelsByProvider[selectedProvider] + : payload.models; + return { + providerRows: providerRows.length > 0 + ? providerRows + : [{ value: selectedProvider, label: formatProviderLabel(selectedProvider) }], + defaultProvider, + models: normalizeModelRows(scopedModels), + }; +} + +async function loadModelPresets(provider = null) { + const scopedProvider = normalizeProvider(provider); + const path = scopedProvider + ? `/v1/models?provider=${encodeURIComponent(scopedProvider)}` + : '/v1/models'; + const res = await api(path, { method: 'GET', headers: {} }); + await ensureOk(res, 'Failed to load models'); + const body = await readJsonOrEmpty(res); + const catalog = resolveModelCatalog(body, scopedProvider); + state.providerPresets = catalog.providerRows; + state.defaultProvider = catalog.defaultProvider; + state.modelPresets = catalog.models; + state.defaultReasoningEffort = normalizeReasoningEffort(body.defaultReasoningEffort) || 'medium'; +} + +async function loadMessages(sessionId) { + const res = await api(`/v1/sessions/${encodeURIComponent(sessionId)}/messages?limit=200`, { + method: 'GET', + headers: {}, + }); + await ensureOk(res, 'Failed to load messages'); + const body = await readJsonOrEmpty(res); + dispatch({ type: 'messages.loaded', sessionId, messages: body.messages || [] }); + if (reconcileSessionRunState(sessionId)) { + render(); + } +} + +async function loadSessionMetadata(sessionId) { + const res = await api(`/v1/sessions/${encodeURIComponent(sessionId)}`, { + method: 'GET', + headers: {}, + }); + await ensureOk(res, 'Failed to load session metadata'); + const session = await readJsonOrEmpty(res); + dispatch({ type: 'session.metadata.loaded', sessionId, session }); +} + +async function selectSession(sessionId) { + resetStreamEventQueue(); + state.sessionSelectionToken += 1; + const selectionToken = state.sessionSelectionToken; + dispatch({ type: 'session.selected', sessionId }); + await loadMessages(sessionId); + await loadSessionMetadata(sessionId); + if (!shouldApplySessionSelection({ + requestToken: selectionToken, + latestRequestToken: state.sessionSelectionToken, + requestedSessionId: sessionId, + activeSessionId: state.value.activeSessionId, + })) { + return; + } + await loadModelPresets(getSessionProvider(getActiveSession())).catch(() => {}); + if (!shouldApplySessionSelection({ + requestToken: selectionToken, + latestRequestToken: state.sessionSelectionToken, + requestedSessionId: sessionId, + activeSessionId: state.value.activeSessionId, + })) { + return; + } + connectEvents(sessionId); +} + +async function createSession() { + const res = await api('/v1/sessions', { + method: 'POST', + body: JSON.stringify({ title: 'New Session' }), + }); + await ensureOk(res, 'Failed to create session'); + const created = await readJsonOrEmpty(res); + await loadSessions(created.sessionId); + await selectSession(created.sessionId); +} + +function beginSessionEdit(sessionId) { + if (!sessionId) return; + const session = state.value.sessions.find((item) => item.sessionId === sessionId); + if (!session) return; + + const current = isDefaultSessionTitle(session.title) ? '' : String(session.title || '').trim(); + state.editingSessionId = sessionId; + state.sessionTitleDrafts = { + ...(state.sessionTitleDrafts || {}), + [sessionId]: current, + }; + renderSessions(); + + window.requestAnimationFrame(() => { + const input = switchSessionListEl.querySelector(`input[data-session-edit-input="${sessionId}"]`); + if (!input) return; + input.focus(); + input.select(); + }); +} + +function cancelSessionEdit(sessionId) { + if (!sessionId) return; + state.editingSessionId = null; + const nextDrafts = { ...(state.sessionTitleDrafts || {}) }; + delete nextDrafts[sessionId]; + state.sessionTitleDrafts = nextDrafts; + renderSessions(); +} + +async function updateSessionTitle(sessionId, rawTitle) { + const title = String(rawTitle || '').trim(); + if (!sessionId) return; + if (!title) { + throw new Error('Session name cannot be empty'); + } + + const res = await api(`/v1/sessions/${encodeURIComponent(sessionId)}`, { + method: 'PATCH', + body: JSON.stringify({ title: title }), + }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error || 'Unable to rename session'); + } + + state.editingSessionId = null; + const nextDrafts = { ...(state.sessionTitleDrafts || {}) }; + delete nextDrafts[sessionId]; + state.sessionTitleDrafts = nextDrafts; + + const activeSessionId = state.value.activeSessionId || sessionId; + await loadSessions(activeSessionId); + setStatus('ready', 'Ready'); +} + +async function updateActiveSessionModel(model) { + const sessionId = state.value.activeSessionId; + if (!sessionId) return; + + const res = await api(`/v1/sessions/${encodeURIComponent(sessionId)}`, { + method: 'PATCH', + body: JSON.stringify({ model }), + }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error || 'Unable to update model'); + } + + await loadSessions(sessionId); + await loadModelPresets(getSessionProvider(getActiveSession())).catch(() => {}); + setPopover('none'); + setStatus('ready', 'Ready'); +} + +async function updateActiveSessionProvider(provider) { + const sessionId = state.value.activeSessionId; + const nextProvider = normalizeProvider(provider); + if (!sessionId || !nextProvider) return; + + const res = await api(`/v1/sessions/${encodeURIComponent(sessionId)}`, { + method: 'PATCH', + body: JSON.stringify({ provider: nextProvider }), + }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error || 'Unable to update provider'); + } + + await loadSessions(sessionId); + await loadModelPresets(getSessionProvider(getActiveSession())).catch(() => {}); + setPopover('none'); + setStatus('ready', 'Ready'); +} + +async function updateActiveSessionReasoningEffort(reasoningEffort) { + const sessionId = state.value.activeSessionId; + if (!sessionId) return; + + const res = await api(`/v1/sessions/${encodeURIComponent(sessionId)}`, { + method: 'PATCH', + body: JSON.stringify({ reasoningEffort }), + }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error || 'Unable to update thinking level'); + } + + await loadSessions(sessionId); + setPopover('none'); + setStatus('ready', 'Ready'); +} + +async function consumeEventStream(body, loopToken) { + if (!body) return; + const reader = body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + while (state.eventLoopToken === loopToken) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const frames = buffer.split('\n\n'); + buffer = frames.pop() || ''; + for (const frame of frames) { + for (const line of frame.split('\n')) { + if (!line.startsWith('data: ')) continue; + try { + const evt = JSON.parse(line.slice(6)); + dispatchEvent(evt); + } catch { + // ignore malformed event + } + } + } + } +} + +function connectEvents(sessionId) { + resetStreamEventQueue(); + state.eventLoopToken += 1; + const loopToken = state.eventLoopToken; + if (state.eventController) state.eventController.abort(); + + (async () => { + let backoffMs = 250; + while (state.eventLoopToken === loopToken && state.value.activeSessionId === sessionId) { + const controller = new AbortController(); + state.eventController = controller; + + try { + const response = await fetch( + `${state.auth.baseUrl}/v1/events?sessionId=${encodeURIComponent(sessionId)}`, + { + headers: { authorization: `Bearer ${state.auth.token}` }, + signal: controller.signal, + }, + ); + + if (!response.ok) { + throw new Error(`Event stream failed (${response.status})`); + } + + backoffMs = 250; + await loadMessages(sessionId).catch(() => {}); + await consumeEventStream(response.body, loopToken); + } catch { + if (controller.signal.aborted || state.eventLoopToken !== loopToken) break; + const jitter = Math.floor(Math.random() * 150); + await sleep(backoffMs + jitter); + backoffMs = Math.min(backoffMs * 2, 4000); + } + } + })().catch(() => { + // no-op + }); +} + +async function sendMessage(text) { + const sessionId = state.value.activeSessionId; + if (!sessionId || !text.trim()) return; + + const existing = getActiveMessages(); + dispatch({ type: 'messages.loaded', sessionId, messages: [...existing, { role: 'user', text }] }); + + await ensureCurrentTabAttached(); + scheduleTabAttachRefresh(0); + const browserContext = await getActiveTabContext(); + + const res = await api('/v1/runs', { + method: 'POST', + body: JSON.stringify({ sessionId, message: text, browserContext }), + }); + if (!res.ok) { + dispatch({ type: 'messages.loaded', sessionId, messages: existing }); + const body = await readJsonOrEmpty(res); + throw new Error(body.error || `Failed to send message (${res.status})`); + } + const body = await readJsonOrEmpty(res); + if (body.runId) { + state.currentRunBySession = assignSessionRunId(state.currentRunBySession, sessionId, body.runId); + } + render(); +} + +async function stopRun() { + const sessionId = state.value.activeSessionId; + const runId = getSessionRunId(state.currentRunBySession, sessionId); + if (!runId) return; + await api(`/v1/runs/${encodeURIComponent(runId)}/abort`, { + method: 'DELETE', + headers: {}, + }); +} + +async function initializePanel() { + state.startupIssue = null; + setComposerEnabled(false); + setStatus('info', 'Connecting...'); + render(); + bindAgentOpenRequestWatcher(); + const openRequest = await consumePendingAgentOpenRequest(); + let shouldStartFreshSession = !!openRequest; + if (shouldStartFreshSession) { + state.pendingAgentOpenRequest = null; + } else if (state.pendingAgentOpenRequest) { + shouldStartFreshSession = true; + state.pendingAgentOpenRequest = null; + } + startInitialTabAttach(); + await loadAuth(); + bindTabAttachWatchers(); + await loadSessions(); + try { + await loadModelPresets(getSessionProvider(getActiveSession())); + } catch { + state.providerPresets = [{ value: 'codex', label: 'Codex' }]; + state.defaultProvider = 'codex'; + state.modelPresets = [{ value: null, label: 'Default' }]; + state.defaultReasoningEffort = 'medium'; + } + if (shouldStartFreshSession || !state.value.activeSessionId) { + await createSession(); + } else { + await selectSession(state.value.activeSessionId); + } + setComposerEnabled(true); + scheduleTabAttachRefresh(0); + setStatus('ready', 'Ready'); + render(); +} + +async function retryStartup({ refreshConnection = false } = {}) { + try { + setStatus('info', refreshConnection ? 'Refreshing connection...' : 'Retrying...'); + render(); + if (refreshConnection) { + await refreshExtensionConnection(); + } + await initializePanel(); + } catch (error) { + state.startupIssue = normalizeStartupError(error?.code, error?.message); + setComposerEnabled(false); + setTabAttachBannerState({ hidden: true }); + setStatus('error', state.startupIssue.statusText || 'Daemon unavailable'); + render(); + } +} + +chatFormEl.addEventListener('submit', async (event) => { + event.preventDefault(); + const text = chatInputEl.value; + try { + await sendMessage(text); + chatInputEl.value = ''; + autoResizeInput(); + syncComposerLayoutState(); + syncComposerState(); + } catch (error) { + chatInputEl.value = text; + autoResizeInput(); + syncComposerLayoutState(); + syncComposerState(); + setStatus('error', error?.message || 'Failed to send message'); + } +}); + +chatInputEl.addEventListener('keydown', (event) => { + if (event.key !== 'Enter' || event.shiftKey) return; + if (event.isComposing) return; + event.preventDefault(); + if (sendBtn.disabled) return; + chatFormEl.requestSubmit(); +}); + +if (attachCurrentTabBtn) { + attachCurrentTabBtn.addEventListener('click', async () => { + setTabAttachBannerState({ + hidden: false, + text: tabAttachTextEl?.textContent || 'Current tab is not connected', + canAttach: false, + busy: true, + }); + const response = await ensureCurrentTabAttached(); + if (response?.error && !isIgnoredAttachError(response.error)) { + setStatus('error', response.error || 'Unable to attach current tab'); + } + scheduleTabAttachRefresh(0); + }); +} + +chatInputEl.addEventListener('input', () => { + autoResizeInput(); + syncComposerLayoutState(); + syncComposerState(); +}); + +newSessionBtn.addEventListener('click', () => { + createSession() + .then(() => setPopover('none')) + .catch((err) => setStatus('error', err.message || 'Unable to create session')); +}); + +stopRunBtn.addEventListener('click', () => { + stopRun().catch((err) => setStatus('error', err.message || 'Unable to stop run')); +}); + +modelTriggerBtn.addEventListener('click', () => { + setPopover(state.popover === 'model' ? 'none' : 'model'); +}); + +sessionTriggerBtn.addEventListener('click', () => { + setPopover(state.popover === 'session' ? 'none' : 'session'); +}); + +popoverBackdropEl.addEventListener('click', () => { + setPopover('none'); +}); + +(async function init() { + try { + await initializePanel(); + } catch (error) { + state.startupIssue = normalizeStartupError(error?.code, error?.message); + setComposerEnabled(false); + setTabAttachBannerState({ hidden: true }); + setStatus('error', state.startupIssue.statusText || 'Daemon unavailable'); + render(); + } +})(); diff --git a/extension/background.js b/extension/background.js index e32bf9c..162364c 100644 --- a/extension/background.js +++ b/extension/background.js @@ -4,12 +4,20 @@ const RELAY_URL_DEFAULT = 'ws://127.0.0.1:19222/extension'; const RECONNECT_DELAY_MS = 3000; const CDP_VERSION = '1.3'; +const RELAY_HTTP_DEFAULT = 'http://127.0.0.1:19222'; +const TAB_GROUP_COLOR = 'orange'; +const BADGE_COLORS = { + connected: '#C15F3C', + connecting: '#B1ADA1', + disconnected: '#B1ADA1', +}; // ─── State ─────────────────────────────────────────────────────────────────── let ws = null; let connectionState = 'disconnected'; // disconnected | connecting | connected let maintainLoopActive = false; +let currentRelayUrl = RELAY_URL_DEFAULT; /** @type {Map} */ const attachedTabs = new Map(); @@ -34,7 +42,7 @@ let restrictionExplained = false; (async function init() { const stored = await chrome.storage.local.get(['relayUrl']); - const relayUrl = stored.relayUrl || RELAY_URL_DEFAULT; + currentRelayUrl = stored.relayUrl || RELAY_URL_DEFAULT; // Register debugger listeners once (persists across reconnections) chrome.debugger.onEvent.addListener(onDebuggerEvent); @@ -48,22 +56,22 @@ let restrictionExplained = false; chrome.alarms.create('bf-reconnect', { periodInMinutes: 0.5 }); chrome.alarms.onAlarm.addListener((alarm) => { if (alarm.name === 'bf-reconnect' && !ws) { - startMaintainLoop(relayUrl); + startMaintainLoop(); } }); - startMaintainLoop(relayUrl); + startMaintainLoop(); })(); // ─── Connection Management ─────────────────────────────────────────────────── -function startMaintainLoop(relayUrl) { +function startMaintainLoop() { if (maintainLoopActive) return; maintainLoopActive = true; - maintainConnection(relayUrl); + maintainConnection(); } -async function maintainConnection(relayUrl) { +async function maintainConnection() { while (true) { if (!ws || ws.readyState !== WebSocket.OPEN) { if (connectionState !== 'connecting') { @@ -72,7 +80,7 @@ async function maintainConnection(relayUrl) { } try { - await connect(relayUrl); + await connect(currentRelayUrl); } catch { connectionState = 'disconnected'; updateBadge(); @@ -138,6 +146,41 @@ function connect(relayUrl) { }); } +function requestRelayReconnect() { + if (connectionState !== 'connecting') { + connectionState = 'connecting'; + updateBadge(); + } + + if (ws) { + try { + ws.close(); + } catch { + // ignore close races + } + } else if (!maintainLoopActive) { + startMaintainLoop(); + } +} + +async function waitForConnectionState(timeoutMs = 5000) { + const started = Date.now(); + while (Date.now() - started < timeoutMs) { + if (connectionState === 'connected') return connectionState; + await sleep(200); + } + return connectionState; +} + +function isValidRelayUrl(relayUrl) { + try { + const parsed = new URL(relayUrl); + return parsed.protocol === 'ws:' || parsed.protocol === 'wss:'; + } catch { + return false; + } +} + // ─── Relay Message Handling ────────────────────────────────────────────────── function handleRelayMessage(msg) { @@ -146,6 +189,14 @@ function handleRelayMessage(msg) { return; } + if (msg.method === 'reload') { + // Ack before restarting so relay knows the message was received + send({ method: 'reload-ack' }); + // Yield so the send flushes before the service worker restarts + setTimeout(() => chrome.runtime.reload(), 0); + return; + } + // Command from relay (has id) if (msg.id !== undefined) { executeCommand(msg) @@ -181,6 +232,8 @@ async function executeCommand(msg) { }); }); }); + case 'getAgentPreferences': + return getAgentExecutionSettings(); default: throw new Error(`Unknown command: ${msg.method}`); } @@ -188,6 +241,31 @@ async function executeCommand(msg) { // ─── Tab Operations ────────────────────────────────────────────────────────── +async function getAgentExecutionSettings() { + const s = await chrome.storage.local.get(['executionMode', 'parallelVisibilityMode']); + const executionMode = s.executionMode === 'sequential' ? 'sequential' : 'parallel'; + const parallelVisibilityMode = + s.parallelVisibilityMode === 'rotate-visible' + ? 'rotate-visible' + : 'foreground-tab'; + + return { executionMode, parallelVisibilityMode }; +} + +async function getCurrentWindowId() { + const tabs = await chrome.tabs.query({ active: true, currentWindow: true }); + if (tabs[0] && typeof tabs[0].windowId === 'number') { + return tabs[0].windowId; + } + + const win = await chrome.windows.getLastFocused(); + if (win && typeof win.id === 'number') { + return win.id; + } + + return undefined; +} + async function listTabs() { const tabs = await chrome.tabs.query({}); return { @@ -216,6 +294,8 @@ async function attachTab(tabId, sessionId) { if (attachedTabs.has(tabId)) { const existing = attachedTabs.get(tabId); existing.sessionId = sessionId; + // Ensure attached tabs are always reconciled into the browserforce group. + queueSyncTabGroup(); return existing; } @@ -284,10 +364,23 @@ async function createTab(params) { throw new Error(`BLOCKED: ${msg}`); } - const tab = await chrome.tabs.create({ + const agentSettings = await getAgentExecutionSettings(); + const windowId = await getCurrentWindowId(); + const createOptions = { url: params.url || 'about:blank', - active: false, - }); + // Keep agent-created tabs visible; do not spawn separate windows. + active: true, + }; + if (typeof windowId === 'number') { + createOptions.windowId = windowId; + } + + // rotate-visible remains normalized to visible tab creation in current window. + if (agentSettings.parallelVisibilityMode === 'rotate-visible') { + createOptions.active = true; + } + + const tab = await chrome.tabs.create(createOptions); // Brief delay for Chrome to finalize tab creation await sleep(200); @@ -483,20 +576,27 @@ function onTabRemoved(tabId) { function onTabUpdated(tabId, changeInfo) { if (!attachedTabs.has(tabId)) return; - if (!changeInfo.url && !changeInfo.title) return; + if (!changeInfo.url && !changeInfo.title && changeInfo.groupId === undefined) return; + + // Reconcile group membership/title if user or Chrome moved this attached tab. + if (changeInfo.groupId !== undefined) { + queueSyncTabGroup(); + } const entry = attachedTabs.get(tabId); if (changeInfo.url) entry.targetInfo.url = changeInfo.url; if (changeInfo.title) entry.targetInfo.title = changeInfo.title; - send({ - method: 'tabUpdated', - params: { - tabId, - url: changeInfo.url, - title: changeInfo.title, - }, - }); + if (changeInfo.url || changeInfo.title) { + send({ + method: 'tabUpdated', + params: { + tabId, + url: changeInfo.url, + title: changeInfo.title, + }, + }); + } } // ─── Helpers ───────────────────────────────────────────────────────────────── @@ -553,6 +653,14 @@ async function checkInactiveTabs() { } chrome.storage.onChanged.addListener(async (changes) => { + if (changes.relayUrl) { + const nextRelayUrl = changes.relayUrl.newValue || RELAY_URL_DEFAULT; + if (nextRelayUrl !== currentRelayUrl) { + currentRelayUrl = nextRelayUrl; + requestRelayReconnect(); + } + } + if (changes.autoDetachMinutes || changes.autoCloseMinutes) { const settings = await chrome.storage.local.get(['autoDetachMinutes', 'autoCloseMinutes']); const anyEnabled = (settings.autoDetachMinutes || 0) > 0 || (settings.autoCloseMinutes || 0) > 0; @@ -630,7 +738,7 @@ async function syncTabGroup() { // Always ensure group title/color are correct if (groupId !== undefined) { - await chrome.tabGroups.update(groupId, { title: 'browserforce', color: 'cyan' }); + await chrome.tabGroups.update(groupId, { title: 'browserforce', color: TAB_GROUP_COLOR }); } } catch (e) { console.warn('[bf] syncTabGroup error:', e.message); @@ -654,13 +762,13 @@ function updateBadge() { if (connectionState === 'connected') { chrome.action.setBadgeText({ text: count > 0 ? String(count) : 'ON' }); - chrome.action.setBadgeBackgroundColor({ color: '#4CAF50' }); + chrome.action.setBadgeBackgroundColor({ color: BADGE_COLORS.connected }); } else if (connectionState === 'connecting') { chrome.action.setBadgeText({ text: '...' }); - chrome.action.setBadgeBackgroundColor({ color: '#FF9800' }); + chrome.action.setBadgeBackgroundColor({ color: BADGE_COLORS.connecting }); } else { chrome.action.setBadgeText({ text: '' }); - chrome.action.setBadgeBackgroundColor({ color: '#9E9E9E' }); + chrome.action.setBadgeBackgroundColor({ color: BADGE_COLORS.disconnected }); } } @@ -668,6 +776,29 @@ function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } +function relayWsToHttpBase(wsUrl) { + try { + const parsed = new URL(wsUrl || RELAY_URL_DEFAULT); + const protocol = parsed.protocol === 'wss:' ? 'https:' : 'http:'; + return `${protocol}//${parsed.host}`; + } catch { + return RELAY_HTTP_DEFAULT; + } +} + +async function getMcpClientCount() { + if (connectionState !== 'connected') return 0; + const base = relayWsToHttpBase(currentRelayUrl); + try { + const response = await fetch(`${base}/client-slot`, { method: 'GET', cache: 'no-store' }); + if (!response.ok) return 0; + const data = await response.json(); + return Number.isFinite(data?.clients) ? data.clients : 0; + } catch { + return 0; + } +} + // ─── Popup Message Handler ─────────────────────────────────────────────────── chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { @@ -684,7 +815,7 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { // Compute seconds until next auto-action (detach or close) let nextAutoActionSecs = null; - chrome.storage.local.get(['autoDetachMinutes', 'autoCloseMinutes'], (settings) => { + chrome.storage.local.get(['autoDetachMinutes', 'autoCloseMinutes', 'mode'], async (settings) => { const detachMs = (settings.autoDetachMinutes || 0) * 60_000; const closeMs = (settings.autoCloseMinutes || 0) * 60_000; if ((detachMs || closeMs) && tabLastActivity.size > 0) { @@ -700,7 +831,53 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { nextAutoActionSecs = Math.max(0, Math.ceil(earliest / 1000)); } } - sendResponse({ connectionState, tabs, nextAutoActionSecs }); + const mcpClientCount = await getMcpClientCount(); + sendResponse({ + connectionState, + tabs, + nextAutoActionSecs, + mode: settings.mode || 'auto', + mcpClientCount, + }); + }); + return true; // async sendResponse + } + + if (msg.type === 'updateRelayUrl') { + const relayUrl = typeof msg.relayUrl === 'string' ? msg.relayUrl.trim() : ''; + if (!relayUrl) { + sendResponse({ error: 'Relay URL is required' }); + return false; + } + if (!isValidRelayUrl(relayUrl)) { + sendResponse({ error: 'Relay URL must start with ws:// or wss://' }); + return false; + } + + const previousRelayUrl = currentRelayUrl; + currentRelayUrl = relayUrl; + requestRelayReconnect(); + waitForConnectionState(5000).then((settledState) => { + if (settledState !== 'connected') { + currentRelayUrl = previousRelayUrl; + requestRelayReconnect(); + waitForConnectionState(5000).then((fallbackState) => { + sendResponse({ error: 'Connection failed', connectionState: fallbackState }); + }); + return; + } + + chrome.storage.local.set({ relayUrl }, () => { + if (chrome.runtime.lastError) { + sendResponse({ error: chrome.runtime.lastError.message || 'Failed to save relay URL' }); + return; + } + sendResponse({ ok: true, connectionState: settledState }); + }); + }).catch(() => { + currentRelayUrl = previousRelayUrl; + requestRelayReconnect(); + sendResponse({ error: 'Connection failed', connectionState: connectionState }); }); return true; // async sendResponse } diff --git a/extension/manifest.json b/extension/manifest.json index 4234c97..766c2c3 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -1,18 +1,24 @@ { "manifest_version": 3, "name": "BrowserForce", - "version": "1.0.0", + "version": "1.0.18", "description": "Give AI agents your real Chrome browser — your logins, cookies, and tabs. Works with OpenClaw, Claude, and any MCP agent.", "permissions": [ "debugger", "tabs", "tabGroups", "storage", - "alarms" + "alarms", + "sidePanel" + ], + "host_permissions": [ + "http://127.0.0.1/*", + "http://localhost/*" ], "background": { "service_worker": "background.js" }, + "options_page": "options.html", "icons": { "16": "icons/icon16.png", "48": "icons/icon48.png", @@ -25,5 +31,8 @@ "16": "icons/icon16.png", "48": "icons/icon48.png" } + }, + "side_panel": { + "default_path": "agent-panel.html" } } diff --git a/extension/options.css b/extension/options.css new file mode 100644 index 0000000..1adfc46 --- /dev/null +++ b/extension/options.css @@ -0,0 +1,254 @@ +:root { + --bf-crail: #C15F3C; + --bf-cloudy: #B1ADA1; + --bf-pampas: #F4F3EE; + --bf-white: #FFFFFF; + + --bf-page-bg: var(--bf-pampas); + --bf-surface: var(--bf-white); + --bf-text: #3D3028; + --bf-text-muted: #756F63; + --bf-text-subtle: #8C857A; + --bf-border: #D8D3C9; + --bf-border-soft: #E9E4DA; + --bf-accent: var(--bf-crail); + --bf-accent-hover: #B05535; + --bf-ghost-bg-hover: #ECE6DB; + --bf-row-hover: #EEE8DD; + --bf-row-active: #E6DFD3; + --bf-table-head-bg: #F0EBE1; + --bf-error-bg: #F4E5E0; + --bf-error-border: #E3B9AA; + --bf-error-text: #8A3D24; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + background: var(--bf-page-bg); + color: var(--bf-text); +} + +.layout { + max-width: 1200px; + margin: 0 auto; + padding: 20px; +} + +.topbar { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + margin-bottom: 16px; +} + +h1 { + margin: 0; + font-size: 24px; +} + +h2 { + margin: 0 0 10px; + font-size: 14px; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--bf-text-muted); +} + +.subtitle { + margin: 6px 0 0; + font-size: 13px; + color: var(--bf-text-subtle); +} + +.controls { + display: flex; + gap: 8px; +} + +button { + border: 1px solid var(--bf-accent); + border-radius: 8px; + background: var(--bf-accent); + color: var(--bf-white); + height: 36px; + padding: 0 14px; + font-size: 13px; + cursor: pointer; +} + +button:hover { + background: var(--bf-accent-hover); +} + +button.ghost { + border-color: var(--bf-border); + background: var(--bf-surface); + color: var(--bf-text); +} + +button.ghost:hover { + background: var(--bf-ghost-bg-hover); +} + +.cards { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; + margin-bottom: 12px; +} + +.card { + border: 1px solid var(--bf-border); + border-radius: 10px; + padding: 12px; + background: var(--bf-surface); + min-height: 90px; +} + +.card p { + margin: 0 0 6px; + font-size: 13px; +} + +.client-list { + margin: 0; + padding-left: 16px; + max-height: 96px; + overflow: auto; +} + +.client-list li { + font-size: 12px; + margin-bottom: 4px; +} + +.notes { + display: flex; + gap: 6px; + padding: 10px 12px; + border: 1px solid var(--bf-border); + border-radius: 10px; + background: var(--bf-surface); + margin-bottom: 12px; + font-size: 13px; +} + +.error { + margin: 0 0 12px; + border: 1px solid var(--bf-error-border); + background: var(--bf-error-bg); + color: var(--bf-error-text); + padding: 10px 12px; + border-radius: 8px; + font-size: 13px; +} + +.logs-panel, +.details-panel { + border: 1px solid var(--bf-border); + border-radius: 10px; + background: var(--bf-surface); + margin-bottom: 12px; +} + +.logs-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px; + border-bottom: 1px solid var(--bf-border-soft); +} + +.logs-header h2 { + margin: 0; +} + +.table-wrap { + max-height: 480px; + overflow: auto; +} + +table { + width: 100%; + border-collapse: collapse; +} + +th, +td { + font-size: 12px; + text-align: left; + padding: 8px 10px; + border-bottom: 1px solid var(--bf-border-soft); + vertical-align: top; +} + +th { + position: sticky; + top: 0; + background: var(--bf-table-head-bg); + z-index: 1; + color: var(--bf-text-muted); + font-weight: 600; +} + +tr.clickable { + cursor: pointer; +} + +tr.clickable:hover { + background: var(--bf-row-hover); +} + +tr.active { + background: var(--bf-row-active); +} + +.empty { + color: var(--bf-text-subtle); + text-align: center; +} + +.details-panel { + padding: 12px; +} + +pre { + margin: 0; + max-height: 280px; + overflow: auto; + font-size: 12px; + line-height: 1.45; + white-space: pre-wrap; + word-break: break-word; +} + +.mono { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', monospace; +} + +@media (max-width: 960px) { + .cards { + grid-template-columns: 1fr; + } + + .topbar { + flex-direction: column; + align-items: stretch; + } + + .controls { + width: 100%; + flex-wrap: wrap; + } + + button { + flex: 1; + min-width: 110px; + } +} diff --git a/extension/options.html b/extension/options.html new file mode 100644 index 0000000..9232520 --- /dev/null +++ b/extension/options.html @@ -0,0 +1,80 @@ + + + + + + BrowserForce Logs + + + +
    +
    +
    +

    BrowserForce Logs

    +

    Live CDP traffic from relay, polled every second while this page is visible.

    +
    +
    + + + +
    +
    + +
    +
    +

    Relay

    +

    -

    +

    -

    +
    +
    +

    Connections

    +

    -

    +
      +
      +
      +

      Log Stats

      +

      -

      +

      -

      +
      +
      + +
      + Entry fields: + seq, timestamp, direction, optional clientId/clientLabel, and nested message payload. +
      + + + +
      +
      +

      CDP Entries

      + 0 entries +
      +
      + + + + + + + + + + + + + + +
      SeqTimeDirectionClientMethodSession
      No logs yet.
      +
      +
      + +
      +

      Selected Entry

      +
      Select a row to inspect full JSON payload.
      +
      +
      + + + + diff --git a/extension/options.js b/extension/options.js new file mode 100644 index 0000000..f8288e7 --- /dev/null +++ b/extension/options.js @@ -0,0 +1,253 @@ +const RELAY_URL_DEFAULT = 'ws://127.0.0.1:19222/extension'; +const POLL_INTERVAL_MS = 1000; +const MAX_RENDERED_ENTRIES = 10000; + +const relayUrlEl = document.getElementById('bf-relay-url'); +const relayHealthEl = document.getElementById('bf-relay-health'); +const connSummaryEl = document.getElementById('bf-conn-summary'); +const clientsEl = document.getElementById('bf-clients'); +const logSummaryEl = document.getElementById('bf-log-summary'); +const lastUpdatedEl = document.getElementById('bf-last-updated'); +const errorEl = document.getElementById('bf-error'); +const entryCountEl = document.getElementById('bf-entry-count'); +const rowsEl = document.getElementById('bf-log-rows'); +const detailsEl = document.getElementById('bf-entry-details'); +const refreshBtn = document.getElementById('bf-refresh'); +const pauseBtn = document.getElementById('bf-pause'); +const clearBtn = document.getElementById('bf-clear'); + +const state = { + relayWsUrl: RELAY_URL_DEFAULT, + relayHttpBase: wsToHttpBase(RELAY_URL_DEFAULT), + timer: null, + inFlight: false, + paused: false, + lastSeq: 0, + entries: [], + selectedSeq: null, +}; + +chrome.storage.local.get(['relayUrl'], (stored) => { + const relayUrl = stored.relayUrl || RELAY_URL_DEFAULT; + state.relayWsUrl = relayUrl; + state.relayHttpBase = wsToHttpBase(relayUrl); + relayUrlEl.textContent = state.relayHttpBase; + pollOnce(); +}); + +chrome.storage.onChanged.addListener((changes) => { + if (!changes.relayUrl) return; + const nextRelay = changes.relayUrl.newValue || RELAY_URL_DEFAULT; + state.relayWsUrl = nextRelay; + state.relayHttpBase = wsToHttpBase(nextRelay); + relayUrlEl.textContent = state.relayHttpBase; + state.lastSeq = 0; + state.entries = []; + state.selectedSeq = null; + renderEntries(); + pollOnce(); +}); + +refreshBtn.addEventListener('click', () => { + pollOnce(); +}); + +pauseBtn.addEventListener('click', () => { + state.paused = !state.paused; + pauseBtn.textContent = state.paused ? 'Resume' : 'Pause'; + if (state.paused) { + stopPolling(); + } else if (!document.hidden) { + startPolling(); + pollOnce(); + } +}); + +clearBtn.addEventListener('click', () => { + state.entries = []; + state.selectedSeq = null; + renderEntries(); + detailsEl.textContent = 'Select a row to inspect full JSON payload.'; +}); + +document.addEventListener('visibilitychange', () => { + if (document.hidden) { + stopPolling(); + return; + } + if (!state.paused) { + startPolling(); + pollOnce(); + } +}); + +window.addEventListener('beforeunload', () => { + stopPolling(); +}); + +relayUrlEl.textContent = state.relayHttpBase; +startPolling(); +pollOnce(); + +function wsToHttpBase(wsUrl) { + try { + const parsed = new URL(wsUrl); + const protocol = parsed.protocol === 'wss:' ? 'https:' : 'http:'; + return `${protocol}//${parsed.host}`; + } catch { + return 'http://127.0.0.1:19222'; + } +} + +function startPolling() { + if (state.timer || state.paused) return; + state.timer = setInterval(() => { + if (state.inFlight) return; + pollOnce(); + }, POLL_INTERVAL_MS); +} + +function stopPolling() { + if (!state.timer) return; + clearInterval(state.timer); + state.timer = null; +} + +async function pollOnce() { + if (state.inFlight) return; + state.inFlight = true; + + try { + const [status, logs] = await Promise.all([ + fetchJson('/logs/status'), + fetchJson(`/logs/cdp?after=${state.lastSeq}&limit=500`), + ]); + + if (logs.resetRequired) { + state.entries = []; + state.selectedSeq = null; + detailsEl.textContent = 'Log buffer rotated. Showing current buffered entries.'; + } + + if (Array.isArray(logs.entries) && logs.entries.length > 0) { + state.entries.push(...logs.entries); + if (state.entries.length > MAX_RENDERED_ENTRIES) { + state.entries.splice(0, state.entries.length - MAX_RENDERED_ENTRIES); + } + } + + state.lastSeq = logs.latestSeq || state.lastSeq; + renderStatus(status); + renderEntries(); + setError(''); + } catch (err) { + setError(err.message || String(err)); + } finally { + state.inFlight = false; + } +} + +async function fetchJson(pathname) { + const extensionId = chrome?.runtime?.id; + const headers = extensionId ? { 'x-browserforce-extension-id': extensionId } : {}; + const response = await fetch(`${state.relayHttpBase}${pathname}`, { + method: 'GET', + cache: 'no-store', + headers, + }); + + if (!response.ok) { + const body = await response.text(); + throw new Error(`Request failed (${response.status}): ${body || response.statusText}`); + } + + return response.json(); +} + +function renderStatus(status) { + const ext = status.extension?.connected + ? `Extension connected (${status.extension.origin || 'unknown origin'})` + : 'Extension disconnected'; + relayHealthEl.textContent = `${ext} • targets: ${status.targets}`; + + connSummaryEl.textContent = `${status.clients.count} active CDP client(s)`; + clientsEl.innerHTML = ''; + const clients = status.clients.items || []; + if (clients.length === 0) { + const li = document.createElement('li'); + li.textContent = 'No active clients.'; + clientsEl.appendChild(li); + } else { + for (const client of clients) { + const li = document.createElement('li'); + const origin = client.origin || 'no origin'; + const label = client.label || 'unlabeled'; + li.textContent = `${label} (${client.id}) • ${origin}`; + clientsEl.appendChild(li); + } + } + + const counts = status.logs.directionCounts; + logSummaryEl.textContent = `from-playwright ${counts.fromPlaywright} • to-playwright ${counts.toPlaywright} • from-extension ${counts.fromExtension} • to-extension ${counts.toExtension}`; + lastUpdatedEl.textContent = `Updated: ${new Date().toLocaleTimeString()}`; +} + +function renderEntries() { + entryCountEl.textContent = `${state.entries.length} entries`; + + if (state.entries.length === 0) { + rowsEl.innerHTML = 'No logs yet.'; + return; + } + + rowsEl.innerHTML = ''; + for (const entry of state.entries) { + const row = document.createElement('tr'); + row.className = 'clickable'; + if (state.selectedSeq === entry.seq) row.classList.add('active'); + + const method = entry.message?.method || 'response'; + const sessionId = entry.message?.sessionId || ''; + const time = formatTime(entry.timestamp); + + row.innerHTML = [ + `${entry.seq}`, + `${time}`, + `${entry.direction}`, + `${escapeHtml(entry.clientLabel || entry.clientId || '-')}`, + `${escapeHtml(method)}`, + `${escapeHtml(sessionId)}`, + ].join(''); + + row.addEventListener('click', () => { + state.selectedSeq = entry.seq; + detailsEl.textContent = JSON.stringify(entry, null, 2); + renderEntries(); + }); + + rowsEl.appendChild(row); + } +} + +function formatTime(iso) { + if (!iso) return '-'; + const date = new Date(iso); + if (Number.isNaN(date.getTime())) return iso; + return date.toLocaleTimeString(); +} + +function setError(message) { + if (!message) { + errorEl.hidden = true; + errorEl.textContent = ''; + return; + } + errorEl.hidden = false; + errorEl.textContent = message; +} + +function escapeHtml(value) { + const div = document.createElement('div'); + div.textContent = String(value || ''); + return div.innerHTML; +} diff --git a/extension/popup.css b/extension/popup.css index d31b4e8..ab57010 100644 --- a/extension/popup.css +++ b/extension/popup.css @@ -1,3 +1,32 @@ +:root { + --bf-crail: #C15F3C; + --bf-cloudy: #B1ADA1; + --bf-pampas: #F4F3EE; + --bf-white: #FFFFFF; + + --bf-bg: var(--bf-white); + --bf-surface: var(--bf-white); + --bf-surface-soft: var(--bf-pampas); + --bf-text: #3D3028; + --bf-text-muted: #756F63; + --bf-text-subtle: #8C857A; + --bf-border: #D8D3C9; + --bf-border-soft: #E9E4DA; + --bf-border-strong: #C7C0B3; + --bf-accent: var(--bf-crail); + --bf-accent-hover: #B05535; + --bf-accent-press: #9F4D30; + --bf-status-connected: var(--bf-crail); + --bf-status-connecting: var(--bf-cloudy); + --bf-status-disconnected: #CFCBBF; + --bf-danger-bg: #F4E5E0; + --bf-danger-fg: #8A3D24; + --bf-danger-bg-press: #EED7D0; + --bf-surface-soft-hover: #EDE7DC; + --bf-surface-soft-press: #E4DED3; + --bf-surface-press: #ECE6DB; +} + * { margin: 0; padding: 0; @@ -7,13 +36,14 @@ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 13px; - color: #1a1a1a; - background: #fff; + color: var(--bf-text); + background: var(--bf-bg); } .bf-popup { width: 320px; padding: 16px; + overflow-x: hidden; } header { @@ -23,6 +53,12 @@ header { margin-bottom: 12px; } +.header-right { + display: flex; + align-items: center; + gap: 8px; +} + h1 { font-size: 14px; font-weight: 600; @@ -40,12 +76,22 @@ h1 { width: 8px; height: 8px; border-radius: 50%; - background: #9e9e9e; + background: var(--bf-status-disconnected); } -.status.connected .dot { background: #4caf50; } -.status.connecting .dot { background: #ff9800; animation: pulse 1s infinite; } -.status.disconnected .dot { background: #9e9e9e; } +.status.connected .dot { background: var(--bf-status-connected); } +.status.connecting .dot { background: var(--bf-status-connecting); animation: pulse 1s infinite; } +.status.disconnected .dot { background: var(--bf-status-disconnected); } + +.mcp-count { + font-size: 11px; + font-weight: 600; + color: var(--bf-text); + background: var(--bf-surface-soft); + border-radius: 10px; + padding: 2px 8px; + border: 1px solid var(--bf-border-soft); +} @keyframes pulse { 0%, 100% { opacity: 1; } @@ -55,7 +101,7 @@ h1 { /* Tab Navigation */ .tab-nav { display: flex; - border-bottom: 2px solid #eee; + border-bottom: 2px solid var(--bf-border-soft); margin-bottom: 14px; gap: 0; } @@ -67,16 +113,16 @@ h1 { background: none; font-size: 12px; font-weight: 600; - color: #999; + color: var(--bf-text-subtle); cursor: pointer; border-bottom: 2px solid transparent; margin-bottom: -2px; border-radius: 0; } -.tab-btn:hover { color: #666; background: none; } +.tab-btn:hover { color: var(--bf-text-muted); background: none; } .tab-btn:active { background: none; } -.tab-btn.active { color: #4caf50; border-bottom-color: #4caf50; } +.tab-btn.active { color: var(--bf-accent); border-bottom-color: var(--bf-accent); } .tab-panel { display: none; } .tab-panel.active { display: block; } @@ -94,16 +140,17 @@ h1 { font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; - color: #666; + color: var(--bf-text-muted); margin-bottom: 6px; } .badge { - background: #e0e0e0; - color: #555; + background: var(--bf-surface-soft); + color: var(--bf-text-muted); font-size: 10px; padding: 1px 6px; border-radius: 10px; + border: 1px solid var(--bf-border-soft); } .input-row { @@ -114,48 +161,50 @@ h1 { input[type="text"] { flex: 1; padding: 6px 10px; - border: 1px solid #ddd; + border: 1px solid var(--bf-border); border-radius: 6px; font-size: 12px; outline: none; + color: var(--bf-text); + background: var(--bf-surface); } input[type="text"]:focus { - border-color: #4caf50; + border-color: var(--bf-accent); } button { padding: 6px 14px; border: none; border-radius: 6px; - background: #4caf50; - color: #fff; + background: var(--bf-accent); + color: var(--bf-white); font-size: 12px; font-weight: 500; cursor: pointer; } -button:hover { background: #43a047; } -button:active { background: #388e3c; } +button:hover { background: var(--bf-accent-hover); } +button:active { background: var(--bf-accent-press); } /* Tabs list */ .tabs-list { max-height: 200px; overflow-y: auto; - border: 1px solid #eee; + border: 1px solid var(--bf-border-soft); border-radius: 6px; } .tabs-list .empty { padding: 12px; text-align: center; - color: #999; + color: var(--bf-text-subtle); font-size: 12px; } .tab-item { padding: 8px 10px; - border-bottom: 1px solid #f5f5f5; + border-bottom: 1px solid var(--bf-border-soft); overflow: hidden; } @@ -184,7 +233,7 @@ button:active { background: #388e3c; } border: none; border-radius: 4px; background: transparent; - color: #999; + color: var(--bf-text-subtle); font-size: 14px; line-height: 20px; text-align: center; @@ -192,17 +241,17 @@ button:active { background: #388e3c; } } .detach-btn:hover { - background: #fee; - color: #d32f2f; + background: var(--bf-danger-bg); + color: var(--bf-danger-fg); } .detach-btn:active { - background: #fdd; + background: var(--bf-danger-bg-press); } .tab-item .tab-url { font-size: 11px; - color: #888; + color: var(--bf-text-subtle); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; @@ -214,7 +263,7 @@ button:active { background: #388e3c; } font-size: 11px; font-weight: 500; font-variant-numeric: tabular-nums; - color: #999; + color: var(--bf-text-subtle); text-transform: none; letter-spacing: normal; } @@ -225,21 +274,54 @@ button:active { background: #388e3c; } .attach-btn { width: 100%; padding: 10px; - background: #f5f5f5; - color: #333; + background: var(--bf-surface-soft); + color: var(--bf-text); font-size: 12px; font-weight: 500; - border: 1px dashed #ccc; + border: 1px dashed var(--bf-border-strong); border-radius: 6px; cursor: pointer; } -.attach-btn:hover { background: #eee; border-color: #aaa; } -.attach-btn:active { background: #e0e0e0; } +.attach-btn:hover { background: var(--bf-surface-soft-hover); border-color: var(--bf-cloudy); } +.attach-btn:active { background: var(--bf-surface-soft-press); } + +.agent-btn { + width: 100%; + margin-top: 8px; + margin-bottom: 8px; + padding: 10px; + background: var(--bf-accent); + color: var(--bf-white); + border-radius: 6px; +} + +.agent-btn:hover { background: var(--bf-accent-hover); } +.agent-btn:active { background: var(--bf-accent-press); } + +.logs-btn { + width: 100%; + margin-top: 8px; + padding: 9px; + background: var(--bf-surface); + color: var(--bf-text); + font-size: 12px; + font-weight: 500; + border: 1px solid var(--bf-border); + border-radius: 6px; +} + +.logs-btn:hover { + background: var(--bf-surface-soft); +} + +.logs-btn:active { + background: var(--bf-surface-press); +} /* Settings groups */ .settings-group { - border: 1px solid #eee; + border: 1px solid var(--bf-border-soft); border-radius: 6px; padding: 8px 10px; } @@ -252,14 +334,14 @@ button:active { background: #388e3c; } } .setting-row + .setting-row { - border-top: 1px solid #f5f5f5; + border-top: 1px solid var(--bf-border-soft); padding-top: 8px; margin-top: 4px; } .setting-label { font-size: 12px; - color: #333; + color: var(--bf-text); } /* Checkbox rows */ @@ -273,28 +355,28 @@ button:active { background: #388e3c; } font-weight: normal; text-transform: none; letter-spacing: normal; - color: #333; + color: var(--bf-text); } .checkbox-row + .checkbox-row { - border-top: 1px solid #f5f5f5; + border-top: 1px solid var(--bf-border-soft); } .checkbox-row input[type="checkbox"] { width: 14px; height: 14px; - accent-color: #4caf50; + accent-color: var(--bf-accent); cursor: pointer; } /* Select + textarea */ select { padding: 4px 8px; - border: 1px solid #ddd; + border: 1px solid var(--bf-border); border-radius: 4px; font-size: 12px; - background: #fff; - color: #333; + background: var(--bf-surface); + color: var(--bf-text); cursor: pointer; outline: none; } @@ -306,21 +388,61 @@ select.full-width { } select:focus { - border-color: #4caf50; + border-color: var(--bf-accent); } textarea { width: 100%; padding: 8px 10px; - border: 1px solid #ddd; + border: 1px solid var(--bf-border); border-radius: 6px; font-size: 12px; font-family: inherit; resize: vertical; outline: none; min-height: 60px; + color: var(--bf-text); + background: var(--bf-surface); } textarea:focus { - border-color: #4caf50; + border-color: var(--bf-accent); +} + +.auto-mode-note { + margin: 10px -16px -16px; + padding: 8px 16px 12px; + font-size: 11px; + color: var(--bf-text-subtle); + line-height: 1.2; + position: relative; + background: var(--bf-surface-soft); +} + +.auto-mode-note-text { + display: block; + width: 100%; + font-size: 11px; + line-height: 1.2; + white-space: nowrap; +} + +.auto-mode-note::before, +.auto-mode-note::after { + content: ''; + position: absolute; + left: 0; + right: 0; +} + +.auto-mode-note::before { + bottom: 2px; + height: 1px; + background: var(--bf-danger-fg); +} + +.auto-mode-note::after { + bottom: 0; + height: 2px; + background: var(--bf-accent); } diff --git a/extension/popup.html b/extension/popup.html index ae19f9b..93dddad 100644 --- a/extension/popup.html +++ b/extension/popup.html @@ -9,9 +9,12 @@

      BrowserForce

      -
      - - Disconnected +
      +
      + + Disconnected +
      + MCP 0
      @@ -38,6 +41,9 @@

      BrowserForce

      + + +
      @@ -50,6 +56,22 @@

      BrowserForce

      +
      + + +
      + +
      + + +
      +
      @@ -101,6 +123,10 @@

      BrowserForce

      + + diff --git a/extension/popup.js b/extension/popup.js index bf06c78..70ba4ed 100644 --- a/extension/popup.js +++ b/extension/popup.js @@ -1,6 +1,7 @@ // BrowserForce — Popup UI const RELAY_URL_DEFAULT = 'ws://127.0.0.1:19222/extension'; +const BROWSERFORCE_AGENT_OPEN_REQUEST_KEY = 'browserforceAgentOpenRequest'; // Auto-generated instruction lines per restriction const RESTRICTION_LINES = { @@ -13,13 +14,20 @@ const RESTRICTION_LINES = { const statusEl = document.getElementById('bf-status'); const statusTextEl = document.getElementById('bf-status-text'); +const mcpClientsEl = document.getElementById('bf-mcp-clients'); +const autoModeNoteEl = document.getElementById('bf-auto-mode-note'); +const autoModeNoteTextEl = autoModeNoteEl?.querySelector('.auto-mode-note-text') || null; const relayUrlInput = document.getElementById('bf-relay-url'); const saveUrlBtn = document.getElementById('bf-save-url'); const tabCountEl = document.getElementById('bf-tab-count'); const tabsListEl = document.getElementById('bf-tabs-list'); const autoTimerEl = document.getElementById('bf-auto-timer'); const attachBtn = document.getElementById('bf-attach-tab'); +const openAgentBtn = document.getElementById('bf-open-agent'); +const openLogsBtn = document.getElementById('bf-open-logs'); const modeSelect = document.getElementById('bf-mode'); +const executionModeSelect = document.getElementById('bf-execution-mode'); +const parallelVisibilitySelect = document.getElementById('bf-parallel-visibility'); const lockUrlCb = document.getElementById('bf-lock-url'); const noNewTabsCb = document.getElementById('bf-no-new-tabs'); const readOnlyCb = document.getElementById('bf-read-only'); @@ -43,6 +51,7 @@ document.querySelectorAll('.tab-btn').forEach((btn) => { const SETTINGS_KEYS = [ 'relayUrl', 'autoDetachMinutes', 'autoCloseMinutes', 'mode', 'lockUrl', 'noNewTabs', 'readOnly', 'userInstructions', + 'executionMode', 'parallelVisibilityMode', ]; chrome.storage.local.get(SETTINGS_KEYS, (s) => { @@ -50,25 +59,66 @@ chrome.storage.local.get(SETTINGS_KEYS, (s) => { autoDetachSelect.value = String(s.autoDetachMinutes || 0); autoCloseSelect.value = String(s.autoCloseMinutes || 0); modeSelect.value = s.mode || 'auto'; + executionModeSelect.value = s.executionMode || 'parallel'; + parallelVisibilitySelect.value = s.parallelVisibilityMode || 'foreground-tab'; lockUrlCb.checked = !!s.lockUrl; noNewTabsCb.checked = !!s.noNewTabs; readOnlyCb.checked = !!s.readOnly; instructionsEl.value = s.userInstructions || ''; + setAutoModeState(s.mode || 'auto'); }); // --- Save Handlers --- +function setSaveUrlFeedback(label, disabled) { + saveUrlBtn.textContent = label; + saveUrlBtn.disabled = !!disabled; +} + saveUrlBtn.addEventListener('click', () => { const url = relayUrlInput.value.trim(); if (!url) return; - chrome.storage.local.set({ relayUrl: url }, () => { - saveUrlBtn.textContent = 'Saved'; - setTimeout(() => { saveUrlBtn.textContent = 'Save'; }, 1200); + setSaveUrlFeedback('Connecting...', true); + setStatus('connecting', 'connecting'); + + chrome.runtime.sendMessage({ type: 'updateRelayUrl', relayUrl: url }, (response) => { + if (chrome.runtime.lastError || !response) { + setSaveUrlFeedback('Connection failed', false); + setStatus('disconnected', 'connection failed'); + setTimeout(() => setSaveUrlFeedback('Save', false), 1800); + return; + } + + if (response.error) { + setSaveUrlFeedback('Connection failed', false); + setStatus('disconnected', response.error); + setTimeout(() => { + setSaveUrlFeedback('Save', false); + refreshStatus(); + }, 1800); + return; + } + + setSaveUrlFeedback('Connected', false); + setStatus(response.connectionState || 'connected', response.connectionState || 'connected'); + setTimeout(() => { + setSaveUrlFeedback('Save', false); + refreshStatus(); + }, 1200); }); }); modeSelect.addEventListener('change', () => { chrome.storage.local.set({ mode: modeSelect.value }); + setAutoModeState(modeSelect.value); +}); + +executionModeSelect.addEventListener('change', () => { + chrome.storage.local.set({ executionMode: executionModeSelect.value }); +}); + +parallelVisibilitySelect.addEventListener('change', () => { + chrome.storage.local.set({ parallelVisibilityMode: parallelVisibilitySelect.value }); }); autoDetachSelect.addEventListener('change', () => { @@ -147,6 +197,29 @@ attachBtn.addEventListener('click', () => { }); }); +openAgentBtn.addEventListener('click', async () => { + try { + const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); + await chrome.storage.local.set({ + [BROWSERFORCE_AGENT_OPEN_REQUEST_KEY]: { + requestId: (globalThis.crypto?.randomUUID?.() || `bf-open-${Date.now()}`), + requestedAt: Date.now(), + source: 'popup-open-agent', + tabId: Number.isFinite(tab?.id) ? Number(tab.id) : null, + }, + }); + await chrome.sidePanel.open({ windowId: tab?.windowId }); + window.close(); + } catch { + openAgentBtn.textContent = 'Failed to open'; + setTimeout(() => { openAgentBtn.textContent = 'Open BrowserForce Agent'; }, 1500); + } +}); + +openLogsBtn.addEventListener('click', () => { + chrome.runtime.openOptionsPage(); +}); + // --- Status Polling --- function refreshStatus() { @@ -160,6 +233,8 @@ function refreshStatus() { setStatus(response.connectionState, response.connectionState); setTabs(response.tabs || []); setAutoTimer(response.nextAutoActionSecs); + setMcpClientCount(response.mcpClientCount); + setAutoModeState(response.mode || modeSelect.value || 'auto'); }); } @@ -210,6 +285,44 @@ function setAutoTimer(secs) { autoTimerEl.textContent = `${m}:${String(s).padStart(2, '0')}`; } +function setMcpClientCount(count) { + const safeCount = Number.isFinite(count) ? count : 0; + mcpClientsEl.textContent = `MCP ${safeCount}`; +} + +function fitAutoModeNoteText() { + if (!autoModeNoteEl || !autoModeNoteTextEl || autoModeNoteEl.hidden) return; + const maxSizePx = 11; + const minSizePx = 8; + let size = maxSizePx; + autoModeNoteTextEl.style.fontSize = `${size}px`; + autoModeNoteTextEl.style.letterSpacing = ''; + + let safety = 0; + while ( + size > minSizePx + && autoModeNoteTextEl.scrollWidth > autoModeNoteTextEl.clientWidth + && safety < 24 + ) { + size -= 0.25; + autoModeNoteTextEl.style.fontSize = `${size}px`; + safety += 1; + } + + if (autoModeNoteTextEl.scrollWidth > autoModeNoteTextEl.clientWidth) { + autoModeNoteTextEl.style.letterSpacing = '-0.02em'; + } +} + +function setAutoModeState(mode) { + if (!autoModeNoteEl) return; + const showAutoModeNote = mode === 'auto'; + autoModeNoteEl.hidden = !showAutoModeNote; + if (showAutoModeNote) { + window.requestAnimationFrame(fitAutoModeNoteText); + } +} + function escapeHtml(str) { const div = document.createElement('div'); div.textContent = str; @@ -218,3 +331,6 @@ function escapeHtml(str) { refreshStatus(); setInterval(refreshStatus, 1000); +window.addEventListener('resize', () => { + window.requestAnimationFrame(fitAutoModeNoteText); +}); diff --git a/mcp/package.json b/mcp/package.json index 63b727e..c6f4d6e 100644 --- a/mcp/package.json +++ b/mcp/package.json @@ -1,6 +1,6 @@ { "name": "browserforce-mcp", - "version": "1.0.0", + "version": "1.0.18", "private": true, "type": "module", "description": "MCP server exposing Chrome browser control via BrowserForce", diff --git a/mcp/src/a11y-labels.js b/mcp/src/a11y-labels.js index 6ec29aa..942f076 100644 --- a/mcp/src/a11y-labels.js +++ b/mcp/src/a11y-labels.js @@ -356,6 +356,7 @@ export const A11Y_CLIENT_CODE = ` const MAX_CONCURRENCY = 24; const BOX_MODEL_TIMEOUT_MS = 5000; const MAX_SCREENSHOT_DIMENSION = 1568; +const MAX_LABEL_BOXES = 400; export async function resolveScopeBackendNodeId(cdp, selector) { if (!selector) return null; @@ -371,10 +372,12 @@ export async function resolveScopeBackendNodeId(cdp, selector) { } export async function getLabelBoxes(cdp, refs) { + const labelRefs = refs + .filter(ref => ref.backendNodeId && INTERACTIVE_ROLES.has(ref.role)) + .slice(0, MAX_LABEL_BOXES); const sema = new Semaphore(MAX_CONCURRENCY); const results = await Promise.all( - refs.map(async (ref) => { - if (!ref.backendNodeId) return null; + labelRefs.map(async (ref) => { await sema.acquire(); try { const response = await Promise.race([ diff --git a/mcp/src/clean-html.js b/mcp/src/clean-html.js new file mode 100644 index 0000000..e34480d --- /dev/null +++ b/mcp/src/clean-html.js @@ -0,0 +1,189 @@ +// Clean HTML extraction — runs entirely in the browser via page.evaluate(). +// Strips scripts, styles, decorative elements; keeps semantic attributes. + +import { createSmartDiff } from './snapshot.js'; + +const lastHtmlSnapshots = new WeakMap(); + +/** + * Extracts cleaned HTML from a Playwright page or locator. + * All processing happens in-page via DOM manipulation — no server-side parsing deps. + * + * @param {import('playwright-core').Page} page + * @param {string} [selector] - CSS selector to scope extraction (default: document) + * @param {{ maxAttrLen?: number, maxContentLen?: number, showDiffSinceLastCall?: boolean }} [opts] + * @returns {Promise} + */ +export async function getCleanHTML(page, selector, opts = {}) { + const maxAttrLen = opts.maxAttrLen ?? 200; + const maxContentLen = opts.maxContentLen ?? 500; + const showDiffSinceLastCall = opts.showDiffSinceLastCall ?? true; + + const html = await page.evaluate(({ selector, maxAttrLen, maxContentLen }) => { + const TAGS_TO_REMOVE = new Set([ + 'script', 'style', 'link', 'meta', 'noscript', + 'svg', 'head', 'iframe', 'object', 'embed', + ]); + + const ATTRS_TO_KEEP = new Set([ + 'href', 'src', 'alt', 'title', 'name', 'value', 'checked', + 'placeholder', 'type', 'role', 'target', 'label', 'for', + 'aria-label', 'aria-placeholder', 'aria-valuetext', + 'aria-roledescription', 'aria-hidden', 'aria-expanded', + 'aria-checked', 'aria-selected', 'aria-disabled', + 'aria-pressed', 'aria-required', 'aria-current', + 'data-testid', 'data-test', 'data-cy', 'data-qa', + ]); + + const SEMANTIC_TAGS = new Set([ + 'html', 'body', 'main', 'header', 'footer', + 'nav', 'section', 'article', 'aside', + ]); + + const FORM_TAGS = new Set(['input', 'select', 'textarea', 'button']); + + function truncate(str, max) { + if (str.length <= max) return str; + return str.slice(0, max) + '...[' + (str.length - max) + ' more]'; + } + + function shouldKeepAttr(name) { + if (ATTRS_TO_KEEP.has(name)) return true; + if (name.startsWith('aria-')) return true; + if (name.startsWith('data-test') || name.startsWith('data-cy') || name.startsWith('data-qa')) return true; + return false; + } + + function hasUsefulContent(el) { + if (el.nodeType === Node.TEXT_NODE) { + return el.textContent.trim().length > 0; + } + if (el.nodeType !== Node.ELEMENT_NODE) return false; + + const tag = el.tagName.toLowerCase(); + if (FORM_TAGS.has(tag)) return true; + if (tag === 'img' && el.getAttribute('alt')?.trim()) return true; + if (tag === 'a' && el.getAttribute('href')) return true; + + for (const child of el.childNodes) { + if (hasUsefulContent(child)) return true; + } + return false; + } + + function cleanNode(el) { + if (el.nodeType === Node.COMMENT_NODE) { + el.remove(); + return; + } + + if (el.nodeType === Node.TEXT_NODE) { + if (el.textContent.trim().length === 0) return; + el.textContent = truncate(el.textContent, maxContentLen); + return; + } + + if (el.nodeType !== Node.ELEMENT_NODE) return; + + const tag = el.tagName.toLowerCase(); + + if (TAGS_TO_REMOVE.has(tag)) { + el.remove(); + return; + } + + if (el.getAttribute('aria-hidden') === 'true') { + el.remove(); + return; + } + + // Strip non-semantic attributes + const attrsToRemove = []; + for (const attr of el.attributes) { + if (!shouldKeepAttr(attr.name)) { + attrsToRemove.push(attr.name); + } + } + for (const name of attrsToRemove) { + el.removeAttribute(name); + } + + // Truncate long attribute values + for (const attr of el.attributes) { + if (attr.value.length > maxAttrLen) { + el.setAttribute(attr.name, truncate(attr.value, maxAttrLen)); + } + } + + // Recurse children (iterate in reverse since we may remove) + const children = Array.from(el.childNodes); + for (const child of children) { + cleanNode(child); + } + + // After cleaning children: remove decorative elements (no text, no form elements) + if (!SEMANTIC_TAGS.has(tag) && !FORM_TAGS.has(tag) && !hasUsefulContent(el)) { + el.remove(); + return; + } + + // Unwrap unnecessary wrappers: single-child divs/spans with no attributes + if (el.attributes.length === 0 && el.children.length === 1 && el.childNodes.length === 1) { + const onlyChild = el.children[0]; + if (onlyChild && onlyChild.nodeType === Node.ELEMENT_NODE) { + el.replaceWith(onlyChild); + } + } + } + + // Determine root to clean + let root; + if (selector) { + const target = document.querySelector(selector); + if (!target) return ''; + root = target.cloneNode(true); + } else { + root = document.documentElement.cloneNode(true); + } + + cleanNode(root); + + // Remove empty elements in multiple passes + let changed = true; + while (changed) { + changed = false; + for (const el of root.querySelectorAll('*')) { + if ( + el.attributes.length === 0 && + el.childNodes.length === 0 && + !FORM_TAGS.has(el.tagName.toLowerCase()) + ) { + el.remove(); + changed = true; + } + } + } + + return root.outerHTML || root.innerHTML || ''; + }, { selector: selector || null, maxAttrLen, maxContentLen }); + + let pageSnapshots = lastHtmlSnapshots.get(page); + if (!pageSnapshots) { + pageSnapshots = new Map(); + lastHtmlSnapshots.set(page, pageSnapshots); + } + + const snapshotKey = selector || '__full_page__'; + const previousSnapshot = pageSnapshots.get(snapshotKey); + pageSnapshots.set(snapshotKey, html); + + if (showDiffSinceLastCall && previousSnapshot) { + const diffResult = createSmartDiff(previousSnapshot, html); + if (diffResult.type === 'no-change') { + return 'No changes since last call. Use showDiffSinceLastCall: false to see full content.'; + } + return diffResult.content; + } + + return html; +} diff --git a/mcp/src/exec-engine.js b/mcp/src/exec-engine.js index e59f0b7..60a5331 100644 --- a/mcp/src/exec-engine.js +++ b/mcp/src/exec-engine.js @@ -7,52 +7,195 @@ import { homedir } from 'node:os'; import { fileURLToPath } from 'node:url'; import { spawn } from 'node:child_process'; import { - TEST_ID_ATTRS, + TEST_ID_ATTRS, createSmartDiff, buildSnapshotText, parseSearchPattern, annotateStableAttrs, } from './snapshot.js'; +import { Semaphore, injectA11yClient, showLabels, hideLabels } from './a11y-labels.js'; +import { getCleanHTML } from './clean-html.js'; +import { getPageMarkdown } from './page-markdown.js'; // ─── Configuration ─────────────────────────────────────────────────────────── const DEFAULT_PORT = 19222; +const LABEL_SCREENSHOT_MAX_DIMENSION = 1568; +const LABEL_BOX_CONCURRENCY = 16; +const MAX_LABEL_OVERLAY_REFS = 300; export const BF_DIR = join(homedir(), '.browserforce'); export const CDP_URL_FILE = join(BF_DIR, 'cdp-url'); const RELAY_SCRIPT = fileURLToPath(new URL('../../relay/src/index.js', import.meta.url)); -export function getCdpUrl() { - if (process.env.BF_CDP_URL) return process.env.BF_CDP_URL; +function getExplicitCdpUrlOverride() { + const value = process.env.BF_CDP_URL; + if (!value) return null; + const trimmed = value.trim(); + return trimmed || null; +} + +function parseRelayHttpUrlFromCdpUrl(cdpUrl) { + try { + const parsed = new URL(cdpUrl); + if (!parsed.hostname || !parsed.port) return null; + return `http://${parsed.hostname}:${parsed.port}`; + } catch { + return null; + } +} + +function readCdpUrlFromFile() { try { const url = readFileSync(CDP_URL_FILE, 'utf8').trim(); - if (url) return url; + return url || null; + } catch { /* fall through */ } + return null; +} + +export async function getCdpUrl({ baseUrl = getRelayHttpUrl(), timeoutMs = 2000 } = {}) { + const explicit = getExplicitCdpUrlOverride(); + if (explicit) return explicit; + + const resolvedBaseUrl = String(baseUrl).replace(/\/+$/, ''); + try { + const response = await fetch(`${resolvedBaseUrl}/json/version`, { + signal: AbortSignal.timeout(timeoutMs), + }); + if (response.ok) { + const data = await response.json(); + if (typeof data?.webSocketDebuggerUrl === 'string' && data.webSocketDebuggerUrl.trim()) { + return data.webSocketDebuggerUrl.trim(); + } + } } catch { /* fall through */ } + + const legacyFileUrl = readCdpUrlFromFile(); + if (legacyFileUrl) return legacyFileUrl; + throw new Error( 'Cannot find CDP URL. Either:\n' + ' 1. Start the relay first: browserforce serve\n' + - ' 2. Set BF_CDP_URL environment variable' + ` 2. Ensure relay is reachable at ${resolvedBaseUrl}\n` + + ' 3. Set BF_CDP_URL environment variable' ); } /** Derive the relay HTTP base URL from the CDP WebSocket URL. */ export function getRelayHttpUrl() { - const cdpUrl = getCdpUrl(); + const explicit = getExplicitCdpUrlOverride(); + if (explicit) { + return parseRelayHttpUrlFromCdpUrl(explicit) || `http://127.0.0.1:${getRelayPort()}`; + } + return `http://127.0.0.1:${getRelayPort()}`; +} + +export function getRelayHttpUrlFromCdpUrl(cdpUrl) { + return parseRelayHttpUrlFromCdpUrl(cdpUrl) || getRelayHttpUrl(); +} + +export async function assertExtensionConnected({ baseUrl = getRelayHttpUrl(), timeoutMs = 2000 } = {}) { + const resolvedBaseUrl = String(baseUrl).replace(/\/+$/, ''); + let response; try { - const parsed = new URL(cdpUrl); - return `http://${parsed.hostname}:${parsed.port}`; + response = await fetch(`${resolvedBaseUrl}/`, { + signal: AbortSignal.timeout(timeoutMs), + }); + } catch { + throw new Error( + `Cannot reach BrowserForce relay at ${resolvedBaseUrl}. ` + + 'Start it with `browserforce serve`.' + ); + } + + if (!response.ok) { + throw new Error( + `Cannot reach BrowserForce relay at ${resolvedBaseUrl} (HTTP ${response.status}).` + ); + } + + let status; + try { + status = await response.json(); } catch { - return `http://127.0.0.1:${DEFAULT_PORT}`; + throw new Error(`Relay at ${resolvedBaseUrl} returned invalid status JSON.`); + } + + if (status?.extension !== true) { + throw new Error( + `BrowserForce extension is not connected to relay at ${resolvedBaseUrl}.` + ); + } + + return status; +} + +export function isCdpBusyError(err) { + const message = String(err?.message || '').toLowerCase(); + return ( + message.includes('409') || + message.includes('slot busy') || + message.includes('slot is busy') || + message.includes('busy') || + message.includes('already connected') || + message.includes('already in use') || + message.includes('another cdp client') + ); +} + +export async function waitForFreeClientSlot({ timeoutMs = 30000, baseUrl } = {}) { + const start = Date.now(); + const resolvedBaseUrl = String(baseUrl || getRelayHttpUrl()).replace(/\/+$/, ''); + const slotUrl = `${resolvedBaseUrl}/client-slot`; + + while (Date.now() - start < timeoutMs) { + try { + const res = await fetch(slotUrl, { signal: AbortSignal.timeout(2000) }); + if (res.ok) { + const data = await res.json(); + if (data && data.busy === false) return true; + } + } catch { /* keep polling until timeout */ } + + const elapsed = Date.now() - start; + const remaining = timeoutMs - elapsed; + if (remaining <= 0) break; + const jitteredDelayMs = 200 + Math.floor(Math.random() * 200); + await new Promise((r) => globalThis.setTimeout(r, Math.min(jitteredDelayMs, remaining))); + } + + return false; +} + +export async function connectOverCdpWithBusyRetry({ + connect, + cdpUrl, + baseUrl = getRelayHttpUrl(), + timeoutMs = 30000, + waitForFreeSlot = waitForFreeClientSlot, +} = {}) { + const deadline = Date.now() + timeoutMs; + let lastBusyError = null; + + while (Date.now() < deadline) { + try { + return await connect(cdpUrl); + } catch (err) { + if (!isCdpBusyError(err)) throw err; + lastBusyError = err; + const remainingMs = deadline - Date.now(); + if (remainingMs <= 0) break; + const slotFreed = await waitForFreeSlot({ timeoutMs: remainingMs, baseUrl }); + if (!slotFreed) break; + } } + + throw lastBusyError || new Error('Failed to connect to CDP relay'); } // ─── Auto-start relay ─────────────────────────────────────────────────────── function getRelayPort() { - if (process.env.RELAY_PORT) return parseInt(process.env.RELAY_PORT, 10); - try { - const url = readFileSync(CDP_URL_FILE, 'utf8').trim(); - if (url) { - const port = new URL(url).port; - if (port) return parseInt(port, 10); - } - } catch { /* fall through */ } + if (process.env.RELAY_PORT) { + const parsed = parseInt(process.env.RELAY_PORT, 10); + if (Number.isFinite(parsed) && parsed > 0) return parsed; + } return DEFAULT_PORT; } @@ -70,6 +213,8 @@ async function isRelayRunning(port) { * background process and wait for it to become reachable. */ export async function ensureRelay() { + if (getExplicitCdpUrlOverride()) return; + const port = getRelayPort(); if (await isRelayRunning(port)) return; @@ -404,8 +549,19 @@ export class CodeExecutionTimeoutError extends Error { // buildExecContext takes userState and optional console helpers as params // instead of referencing module-level singletons. -export function buildExecContext(defaultPage, ctx, userState, consoleHelpers = {}, pluginHelpers = {}) { +export function buildExecContext( + defaultPage, + ctx, + userState, + consoleHelpers = {}, + pluginHelpers = {}, + agentPreferences = {}, + runtimeRestrictions = {}, + pluginSkillRuntime = {}, +) { const { consoleLogs, setupConsoleCapture } = consoleHelpers; + const lastSnapshots = userState.__lastSnapshots || (userState.__lastSnapshots = new WeakMap()); + const lastRefToLocator = userState.__lastRefToLocator || (userState.__lastRefToLocator = new WeakMap()); const activePage = () => { if (userState.page && !userState.page.isClosed()) return userState.page; @@ -413,20 +569,93 @@ export function buildExecContext(defaultPage, ctx, userState, consoleHelpers = { throw new Error('No active page. Create one first: state.page = await context.newPage()'); }; - const snapshot = async ({ selector, search } = {}) => { + const snapshot = async ({ selector, search, showDiffSinceLastCall = true } = {}) => { const page = activePage(); const axRoot = await getAccessibilityTree(page, selector); - if (!axRoot) return 'No accessibility tree available for this page.'; + if (!axRoot) { + lastRefToLocator.set(page, new Map()); + return 'No accessibility tree available for this page.'; + } const stableIds = await getStableIds(page, selector); annotateStableAttrs(axRoot, stableIds); const searchPattern = parseSearchPattern(search); - const { text: snapshotText, refs } = buildSnapshotText(axRoot, null, searchPattern); + const { text: fullSnapshotText, refs: fullRefs } = buildSnapshotText(axRoot, null, null); + const refMap = new Map(fullRefs.map(({ ref, locator }) => [ref, locator])); + lastRefToLocator.set(page, refMap); + const title = await page.title().catch(() => ''); + const pageUrl = page.url(); + const formatSnapshot = (snapshotText, refs) => { + const refTable = refs.length > 0 + ? '\n\n--- Ref → Locator ---\n' + refs.map(r => `${r.ref}: ${r.locator}`).join('\n') + : ''; + return `Page: ${title} (${pageUrl})\nRefs: ${refs.length} interactive elements\n\n${snapshotText}${refTable}`; + }; + const fullSnapshot = formatSnapshot(fullSnapshotText, fullRefs); + + let pageSnapshots = lastSnapshots.get(page); + if (!(pageSnapshots instanceof Map)) { + const migratedSnapshots = new Map(); + if (typeof pageSnapshots === 'string') { + migratedSnapshots.set('__full_page__', pageSnapshots); + } + pageSnapshots = migratedSnapshots; + lastSnapshots.set(page, pageSnapshots); + } + const snapshotKey = selector || '__full_page__'; + const previousSnapshot = pageSnapshots.get(snapshotKey); + pageSnapshots.set(snapshotKey, fullSnapshot); + + if (!selector && !search && showDiffSinceLastCall && previousSnapshot) { + const diffResult = createSmartDiff(previousSnapshot, fullSnapshot); + if (diffResult.type === 'no-change') { + return 'No changes since last snapshot. Use showDiffSinceLastCall: false to see full content.'; + } + return diffResult.content; + } + + if (searchPattern) { + const { text: filteredSnapshotText, refs: filteredRefs } = buildSnapshotText(axRoot, null, searchPattern); + return formatSnapshot(filteredSnapshotText, filteredRefs); + } + + return fullSnapshot; + }; + + const buildSnapshotData = async ({ selector, search, refAll = false } = {}) => { + const page = activePage(); + const axRoot = await getAccessibilityTree(page, selector); + if (!axRoot) { + lastRefToLocator.set(page, new Map()); + return { + text: 'No accessibility tree available for this page.', + refs: [], + page, + }; + } + + const stableIds = await getStableIds(page, selector); + annotateStableAttrs(axRoot, stableIds); + const searchPattern = parseSearchPattern(search); + const { text: snapshotText, refs } = buildSnapshotText(axRoot, null, searchPattern, { refAll }); + const refMap = new Map(refs.map(({ ref, locator }) => [ref, locator])); + lastRefToLocator.set(page, refMap); + const title = await page.title().catch(() => ''); + const pageUrl = page.url(); const refTable = refs.length > 0 ? '\n\n--- Ref → Locator ---\n' + refs.map(r => `${r.ref}: ${r.locator}`).join('\n') : ''; - const title = await page.title().catch(() => ''); - const pageUrl = page.url(); - return `Page: ${title} (${pageUrl})\nRefs: ${refs.length} interactive elements\n\n${snapshotText}${refTable}`; + return { + text: `Page: ${title} (${pageUrl})\nRefs: ${refs.length} labeled elements\n\n${snapshotText}${refTable}`, + refs, + page, + }; + }; + + const refToLocator = ({ ref, page: targetPage } = {}) => { + const p = targetPage || activePage(); + const map = lastRefToLocator.get(p); + if (!map) return null; + return map.get(ref) ?? null; }; const waitForPageLoad = (opts = {}) => @@ -444,9 +673,166 @@ export function buildExecContext(defaultPage, ctx, userState, consoleHelpers = { if (consoleLogs) consoleLogs.set(activePage(), []); }; + const getCDPSession = async ({ page: targetPage } = {}) => { + const p = targetPage || activePage(); + if (!p || p.isClosed()) { + throw new Error('Cannot create CDP session for closed page'); + } + return p.context().newCDPSession(p); + }; + + const screenshotWithAccessibilityLabels = async ({ selector, interactiveOnly = true } = {}) => { + const { text: snapText, refs, page } = await buildSnapshotData({ + selector, + search: null, + refAll: !interactiveOnly, + }); + + const sema = new Semaphore(LABEL_BOX_CONCURRENCY); + const labelCandidates = refs + .map(ref => ({ ref: ref.ref, role: ref.role, locator: ref.locator })) + .slice(0, MAX_LABEL_OVERLAY_REFS); + const labels = (await Promise.all(labelCandidates.map(async (candidate) => { + await sema.acquire(); + try { + const box = await page.locator(candidate.locator).first().boundingBox(); + if (!box || box.width <= 0 || box.height <= 0) return null; + return { + ref: candidate.ref, + role: candidate.role, + box: { x: box.x, y: box.y, width: box.width, height: box.height }, + }; + } catch { + return null; + } finally { + sema.release(); + } + }))).filter(Boolean); + + let labelsInjected = false; + let labelCount = 0; + if (labels.length > 0) { + await injectA11yClient(page); + labelCount = await showLabels(page, labels); + labelsInjected = true; + } + + const viewport = await page.evaluate((maxDim) => ({ + width: Math.min(window.innerWidth, maxDim), + height: Math.min(window.innerHeight, maxDim), + }), LABEL_SCREENSHOT_MAX_DIMENSION); + try { + const screenshot = await page.screenshot({ + type: 'jpeg', + quality: 80, + scale: 'css', + clip: { x: 0, y: 0, ...viewport }, + }); + return { _bf_type: 'labeled_screenshot', screenshot, snapshot: snapText, labelCount }; + } finally { + if (labelsInjected) { + try { await hideLabels(page); } catch { /* page may have navigated */ } + } + } + }; + + const cleanHTML = (selector, opts) => getCleanHTML(activePage(), selector, opts); + + const pageMarkdown = (opts) => getPageMarkdown(activePage(), opts); + + const browserforceSettings = { + executionMode: agentPreferences?.executionMode === 'sequential' ? 'sequential' : 'parallel', + parallelVisibilityMode: 'foreground-tab', + }; + const browserforceRestrictions = { + mode: runtimeRestrictions?.mode === 'manual' ? 'manual' : 'auto', + lockUrl: !!runtimeRestrictions?.lockUrl, + noNewTabs: !!runtimeRestrictions?.noNewTabs, + readOnly: !!runtimeRestrictions?.readOnly, + instructions: typeof runtimeRestrictions?.instructions === 'string' ? runtimeRestrictions.instructions : '', + }; + + const pluginCatalog = () => { + const catalog = Array.isArray(pluginSkillRuntime?.catalog) ? pluginSkillRuntime.catalog : []; + return catalog.map((entry) => ({ + ...entry, + helpers: Array.isArray(entry?.helpers) ? [...entry.helpers] : [], + sections: Array.isArray(entry?.sections) ? [...entry.sections] : [], + })); + }; + + const pluginHelp = (name, section) => { + const requestedName = String(name || '').trim().toLowerCase(); + if (!requestedName) { + throw new Error('pluginHelp(name, section?) requires a plugin name'); + } + + const lookup = pluginSkillRuntime?.byName && typeof pluginSkillRuntime.byName === 'object' + ? pluginSkillRuntime.byName + : {}; + const plugin = lookup[requestedName]; + if (!plugin) { + const available = pluginCatalog().map((entry) => entry.name).join(', ') || '(none)'; + throw new Error(`Unknown plugin "${name}". Available plugins: ${available}`); + } + + if (section === undefined || section === null || String(section).trim() === '') { + if (plugin.text && plugin.text.trim()) return plugin.text; + if (plugin.description && plugin.description.trim()) { + return `${plugin.name}: ${plugin.description.trim()}`; + } + return `${plugin.name} has no SKILL.md help text.`; + } + + const normalizedSection = String(section) + .toLowerCase() + .trim() + .replace(/^[\d.)\s-]+/, '') + .replace(/[^\p{L}\p{N}\s-]/gu, '') + .replace(/\s+/g, ' ') + .trim(); + const sections = plugin.sections && typeof plugin.sections === 'object' ? plugin.sections : {}; + if (sections[normalizedSection]) return sections[normalizedSection]; + const availableSections = Object.keys(sections).join(', ') || '(none)'; + throw new Error( + `Unknown section "${section}" for plugin "${plugin.name}". Available sections: ${availableSections}` + ); + }; + + const reservedContextNames = new Set([ + 'browserforceSettings', + 'browserforceRestrictions', + 'page', + 'context', + 'state', + 'snapshot', + 'refToLocator', + 'waitForPageLoad', + 'getLogs', + 'clearLogs', + 'getCDPSession', + 'screenshotWithAccessibilityLabels', + 'cleanHTML', + 'pageMarkdown', + 'pluginCatalog', + 'pluginHelp', + 'fetch', + 'URL', + 'URLSearchParams', + 'Buffer', + 'setTimeout', + 'clearTimeout', + 'TextEncoder', + 'TextDecoder', + ]); + // Wrap plugin helpers to auto-inject (page, ctx, state) as first three args const wrappedPluginHelpers = {}; for (const [name, fn] of Object.entries(pluginHelpers)) { + if (reservedContextNames.has(name)) { + process.stderr.write(`[bf-plugins] Ignoring helper "${name}" because it conflicts with a built-in\n`); + continue; + } wrappedPluginHelpers[name] = (...args) => { let pg = null; try { pg = activePage(); } catch { /* no active page */ } @@ -456,8 +842,12 @@ export function buildExecContext(defaultPage, ctx, userState, consoleHelpers = { return { ...wrappedPluginHelpers, // plugin helpers spread first — built-ins always win + browserforceSettings, + browserforceRestrictions, page: defaultPage, context: ctx, state: userState, - snapshot, waitForPageLoad, getLogs, clearLogs, + snapshot, refToLocator, waitForPageLoad, getLogs, clearLogs, getCDPSession, + screenshotWithAccessibilityLabels, cleanHTML, pageMarkdown, + pluginCatalog, pluginHelp, fetch, URL, URLSearchParams, Buffer, setTimeout, clearTimeout, TextEncoder, TextDecoder, }; @@ -481,6 +871,12 @@ export function formatResult(result) { if (result === undefined || result === null) { return { type: 'text', text: String(result) }; } + if (result && typeof result === 'object' && result._bf_type === 'labeled_screenshot') { + return [ + { type: 'image', data: result.screenshot.toString('base64'), mimeType: 'image/jpeg' }, + { type: 'text', text: `Labels: ${result.labelCount} interactive elements\n\n${result.snapshot}` }, + ]; + } if (Buffer.isBuffer(result)) { return { type: 'image', data: result.toString('base64'), mimeType: 'image/png' }; } diff --git a/mcp/src/index.js b/mcp/src/index.js index 5bb01e0..fdcb312 100644 --- a/mcp/src/index.js +++ b/mcp/src/index.js @@ -1,5 +1,5 @@ // BrowserForce — MCP Server -// 3-tool architecture: execute (run Playwright code) + reset (reconnect) + screenshot_with_labels (visual a11y labels) +// 2-tool architecture: execute (run Playwright code) + reset (reconnect) // Connects to the relay via Playwright's CDP client. import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; @@ -7,10 +7,17 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' import { z } from 'zod'; import { chromium } from 'playwright-core'; import { - getCdpUrl, ensureRelay, CodeExecutionTimeoutError, buildExecContext, runCode, formatResult, + getCdpUrl, getRelayHttpUrl, getRelayHttpUrlFromCdpUrl, assertExtensionConnected, + ensureRelay, connectOverCdpWithBusyRetry, + CodeExecutionTimeoutError, buildExecContext, runCode, formatResult, } from './exec-engine.js'; -import { screenshotWithLabels } from './a11y-labels.js'; -import { loadPlugins, buildPluginHelpers, buildPluginSkillAppendix } from './plugin-loader.js'; +import { + loadPlugins, + buildPluginHelpers, + buildPluginSkillAppendix, + buildPluginSkillRuntime, +} from './plugin-loader.js'; +import { checkForUpdate } from './update-check.js'; // ─── Console Log Capture ───────────────────────────────────────────────────── @@ -63,28 +70,109 @@ function ensureAllPagesCapture() { // ─── Browser Connection ────────────────────────────────────────────────────── let browser = null; +const CONNECT_RETRY_TIMEOUT_MS = 30000; +const BACKGROUND_CONNECT_RETRY_INTERVAL_MS = 1500; +let browserConnectPromise = null; +let backgroundConnectLoopStarted = false; +let lastBackgroundConnectError = null; + +function sleep(ms) { + return new Promise((resolve) => globalThis.setTimeout(resolve, ms)); +} + +function withClientLabel(cdpUrl) { + try { + const url = new URL(cdpUrl); + if (!url.searchParams.get('label')) { + url.searchParams.set( + 'label', + process.env.BROWSERFORCE_CDP_CLIENT_LABEL || 'browserforce-mcp', + ); + } + return url.toString(); + } catch { + return cdpUrl; + } +} async function ensureBrowser() { if (browser?.isConnected()) return; - await ensureRelay(); - const cdpUrl = getCdpUrl(); - browser = await chromium.connectOverCDP(cdpUrl); - browser.on('disconnected', () => { - browser = null; - contextListenerAttached = false; - consoleLogs.clear(); - }); + if (browserConnectPromise) { + await browserConnectPromise; + return; + } + + browserConnectPromise = (async () => { + await ensureRelay(); + const cdpUrl = withClientLabel(await getCdpUrl()); + const baseUrl = getRelayHttpUrlFromCdpUrl(cdpUrl); + await assertExtensionConnected({ baseUrl }); + const nextBrowser = await connectOverCdpWithBusyRetry({ + connect: (url) => chromium.connectOverCDP(url), + cdpUrl, + baseUrl, + timeoutMs: CONNECT_RETRY_TIMEOUT_MS, + }); + browser = nextBrowser; + browser.on('disconnected', () => { + browser = null; + contextListenerAttached = false; + consoleLogs.clear(); + }); + + try { + const ctx = browser.contexts()[0]; + if (ctx && !contextListenerAttached) { + ctx.on('page', (page) => setupConsoleCapture(page)); + contextListenerAttached = true; + for (const page of ctx.pages()) { + setupConsoleCapture(page); + } + } + } catch { /* context not ready yet — capture will attach lazily */ } + })(); try { - const ctx = browser.contexts()[0]; - if (ctx && !contextListenerAttached) { - ctx.on('page', (page) => setupConsoleCapture(page)); - contextListenerAttached = true; - for (const page of ctx.pages()) { - setupConsoleCapture(page); + await browserConnectPromise; + } finally { + browserConnectPromise = null; + } +} + +function startBackgroundConnectionLoop() { + if (backgroundConnectLoopStarted) return; + backgroundConnectLoopStarted = true; + + (async () => { + while (true) { + if (browser?.isConnected()) { + lastBackgroundConnectError = null; + await sleep(BACKGROUND_CONNECT_RETRY_INTERVAL_MS); + continue; + } + + try { + await ensureBrowser(); + if (lastBackgroundConnectError !== null) { + process.stderr.write('[bf-mcp] Relay slot available; connected\n'); + lastBackgroundConnectError = null; + } else { + process.stderr.write('[bf-mcp] Connected to relay\n'); + } + } catch (err) { + const message = err?.message || String(err); + if (message !== lastBackgroundConnectError) { + process.stderr.write(`[bf-mcp] Waiting for relay/browser: ${message}\n`); + process.stderr.write('[bf-mcp] MCP is running; tools will connect when slot is available\n'); + lastBackgroundConnectError = message; + } } + + await sleep(BACKGROUND_CONNECT_RETRY_INTERVAL_MS); } - } catch { /* context not ready yet — capture will attach lazily */ } + })().catch((err) => { + process.stderr.write(`[bf-mcp] Background connect loop error: ${err?.message || String(err)}\n`); + }); } function getContext() { @@ -101,11 +189,90 @@ function getPages() { // ─── Persistent State ──────────────────────────────────────────────────────── let userState = {}; +const DEFAULT_AGENT_PREFERENCES = Object.freeze({ + executionMode: 'parallel', + parallelVisibilityMode: 'foreground-tab', +}); +const DEFAULT_BROWSERFORCE_RESTRICTIONS = Object.freeze({ + mode: 'auto', + lockUrl: false, + noNewTabs: false, + readOnly: false, + instructions: '', +}); +let cachedAgentPreferences = null; +let cachedBrowserforceRestrictions = null; + +function normalizeAgentPreferences(raw) { + const executionMode = raw?.executionMode === 'sequential' ? 'sequential' : 'parallel'; + // Keep behavior locked to visible tabs in the current window. + const parallelVisibilityMode = 'foreground-tab'; + return { executionMode, parallelVisibilityMode }; +} + +async function getAgentPreferencesForSession() { + if (cachedAgentPreferences) { + return cachedAgentPreferences; + } + + try { + const response = await fetch(`${getRelayHttpUrl()}/agent-preferences`, { + signal: AbortSignal.timeout(2000), + }); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + const raw = await response.json(); + cachedAgentPreferences = normalizeAgentPreferences(raw); + return cachedAgentPreferences; + } catch { + cachedAgentPreferences = { ...DEFAULT_AGENT_PREFERENCES }; + return cachedAgentPreferences; + } +} + +function normalizeRestrictions(raw) { + return { + mode: raw?.mode === 'manual' ? 'manual' : 'auto', + lockUrl: !!raw?.lockUrl, + noNewTabs: !!raw?.noNewTabs, + readOnly: !!raw?.readOnly, + instructions: typeof raw?.instructions === 'string' ? raw.instructions : '', + }; +} + +async function getBrowserforceRestrictionsForSession() { + if (cachedBrowserforceRestrictions) { + return cachedBrowserforceRestrictions; + } + + try { + const response = await fetch(`${getRelayHttpUrl()}/restrictions`, { + signal: AbortSignal.timeout(2000), + }); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + const raw = await response.json(); + cachedBrowserforceRestrictions = normalizeRestrictions(raw); + return cachedBrowserforceRestrictions; + } catch { + cachedBrowserforceRestrictions = { ...DEFAULT_BROWSERFORCE_RESTRICTIONS }; + return cachedBrowserforceRestrictions; + } +} // ─── Plugin State ──────────────────────────────────────────────────────────── let plugins = []; let pluginHelpers = {}; +let pluginSkillRuntime = { catalog: [], byName: {} }; + +// ─── Update State ──────────────────────────────────────────────────────────── +// Checked once at startup; notice injected into first execute response only. + +let pendingUpdate = null; // { current, latest } or null +let updateNoticeSent = false; // ─── MCP Server ────────────────────────────────────────────────────────────── @@ -125,189 +292,213 @@ Variables: page Default page (first tab in context — shared, avoid navigating it) context Browser context — access all pages via context.pages() state Persistent object across calls (cleared on reset). Store your working page here. + browserforceSettings Session defaults loaded once per MCP session (refresh on reset). + Keys: executionMode, parallelVisibilityMode. + browserforceRestrictions Session restrictions from extension/relay. + Keys: mode, lockUrl, noNewTabs, readOnly, instructions. Helpers: - snapshot({ selector?, search? }) Accessibility tree as text. 10-100x cheaper than screenshots. + snapshot({ selector?, search?, showDiffSinceLastCall? }) Accessibility tree as text. 10-100x cheaper than screenshots. + refToLocator({ ref }) Resolve a snapshot ref (e.g., e3) to a Playwright locator string. waitForPageLoad({ timeout? }) Smart load detection (filters analytics/ads, polls readyState). getLogs({ count? }) Browser console logs captured for current page. clearLogs() Clear captured console logs. + screenshotWithAccessibilityLabels({ selector?, interactiveOnly? }) + Vimium-style labeled screenshot + accessibility snapshot. + Returns image with color-coded element labels (e1, e2...) and + matching text snapshot. Use for explicitly annotated/labeled captures. + For plain screenshots, prefer state.page.screenshot() and snapshot() as separate calls. + cleanHTML(selector?, opts?) Cleaned HTML — strips scripts, styles, decorative elements. + Keeps semantic attrs: href, src, role, aria-*, data-testid. + opts: { maxAttrLen?, maxContentLen? } + pageMarkdown() Article content via Mozilla Readability (Firefox Reader View). + Strips nav/ads/sidebars. Returns title + metadata + body text. + Falls back to raw body text for non-article pages. + getCDPSession({ page }) Create a relay-safe raw CDP session for a page. + Use this instead of page.context().newCDPSession(page). + pluginCatalog() Returns installed plugin metadata (metadata-first discovery). + pluginHelp(name, section?) Returns on-demand SKILL help for one plugin from in-memory cache. Globals: fetch, URL, URLSearchParams, Buffer, setTimeout, clearTimeout, TextEncoder, TextDecoder +Plugin workflow (metadata-first): + 1) Call pluginCatalog() to discover plugin names, helper names, and available sections. + 2) Call pluginHelp(name, section?) only when you need plugin-specific instructions. + 3) Avoid calling pluginHelp blindly for every plugin. + ═══ FIRST CALL — PAGE SETUP ═══ IMPORTANT: Do NOT navigate the user's existing tabs. Always create or reuse a dedicated tab. -On your first call, initialize state.page: - // Reuse an about:blank tab if one exists, otherwise create a new one +On your first call: state.page = context.pages().find(p => p.url() === 'about:blank') || await context.newPage(); await state.page.goto('https://example.com'); await waitForPageLoad(); return await snapshot(); -After setup, use state.page for ALL subsequent operations — not the default page variable. -If state.page was closed or navigated away, recreate it: +After setup, use state.page for all subsequent operations. +If state.page was closed: if (!state.page || state.page.isClosed()) { - state.page = await context.newPage(); + state.page = context.pages().find(p => p.url() === 'about:blank') || await context.newPage(); } -═══ WORKFLOW — OBSERVE → ACT → OBSERVE ═══ - -After every action, verify its result before proceeding: +═══ URL DISCOVERY (NO GUESSING) ═══ -1. OBSERVE: snapshot() to understand current page state -2. ACT: Perform ONE action (click, type, navigate, etc.) -3. OBSERVE: snapshot() again to verify the action worked +Do NOT guess deep links when the site already exposes navigation links. +When discovering a section/page: + 1) Snapshot first and inspect visible refs. + 2) Prefer clicking discovered links/buttons or reading hrefs from those elements. + 3) Only construct a URL manually if there is no discoverable navigation path. + 4) If a guessed URL fails (404/wrong content), back up and derive it from on-page links. -Never chain multiple actions blindly. If you click a button, verify it worked before clicking the next. -Each execute call should do ONE meaningful action and return verification. +Example href discovery: + const hrefs = await state.page.evaluate(() => + Array.from(document.querySelectorAll('a')).map(a => ({ text: a.textContent?.trim(), href: a.getAttribute('href') })) + ); -When navigating: - await state.page.goto(url); - await waitForPageLoad(); - return await snapshot(); +═══ SETTINGS & STRATEGY PRECHECK ═══ -When clicking: - await state.page.locator('role=button[name="Submit"]').click(); - await waitForPageLoad(); - return await snapshot(); +Read browserforceSettings + browserforceRestrictions before planning execution. +- executionMode=sequential: do one task at a time; do not run tab swarms. +- executionMode=parallel: parallelize only independent read-only tasks. +- parallelVisibilityMode=foreground-tab: new tabs are visible in the current window; avoid disruptive tab choreography. +- mode=manual or noNewTabs=true: do not create tabs, only operate on user-attached tabs. +- lockUrl=true: do not navigate away from current URL (reload is allowed). +- readOnly=true: no click/type/submit actions; observe with snapshot/screenshot/evaluate only. +- instructions: treat as mandatory policy text for this session. -When filling forms: - await state.page.locator('role=textbox[name="Email"]').fill('user@example.com'); - return await snapshot(); +Empty tabs/targets handling: +- If tabs/targets are empty, treat it as normal startup state and create/reuse a dedicated tab with context.newPage(). +- Do not ask the user to click Attach/Share by default. +- Ask for manual Attach/Share only when mode=manual or noNewTabs=true, or when the user explicitly asks to use their current tab. -═══ SNAPSHOT FIRST ═══ +═══ CORE LOOP — OBSERVE → ACT → OBSERVE ═══ -ALWAYS prefer snapshot() over screenshot(): -- snapshot() returns a text accessibility tree — fast, cheap, searchable -- screenshot() returns a PNG image — expensive, requires vision processing +After every action, verify the result before proceeding. +Each execute call should usually do one meaningful action and return verification. +Multi-step is allowed for read-only bulk extraction when actions are independent. -Use snapshot() for: - ✓ Reading page content and text - ✓ Finding interactive elements (buttons, links, inputs) - ✓ Verifying actions succeeded - ✓ Checking if a page loaded correctly +Recommended cycle: + 1) OBSERVE: console.log('URL:', state.page.url()); return await snapshot(); + 2) ACT: one action (click, type, navigate, submit) + 3) OBSERVE: snapshot() again; verify the expected change happened -Use screenshot() ONLY for: - ✓ Visual layout verification (grids, alignment, spacing) - ✓ Seeing images, charts, or visual content - ✓ Debugging when snapshot doesn't show the issue +If nothing changed, wait for load and observe again before retrying. -Targeted snapshots: snapshot({ search: /pattern/i }) filters the tree. -Scoped snapshots: snapshot({ selector: '#main' }) limits to a subtree. +═══ INTERACTION RULES ═══ -═══ PAGE MANAGEMENT ═══ +Selector priority: + 1) Use fresh [ref=...] locators from snapshot output + 2) Use role/name locators from snapshot + 3) Use stable test IDs (data-testid) + 4) Avoid brittle nth()/deep CSS selectors unless no stable option exists -Listing tabs: const pages = context.pages(); -Creating a tab: const p = await context.newPage(); -Navigating: await state.page.goto(url); -Current URL: state.page.url() -Page title: await state.page.title() +If snapshot shows [ref=e3]: + const locator = refToLocator({ ref: 'e3' }); + if (locator) await state.page.locator(locator).click(); -context.pages() returns ALL open tabs. Index 0 is usually the user's original tab. -Store your working page in state.page to avoid losing track of it. +Before interacting, dismiss blockers: + await snapshot({ search: /cookie|consent|accept|reject|allow|age|verify|login|sign.in/i }); -For multi-tab workflows: - const pages = context.pages(); - // Find a specific tab by URL - const gmail = pages.find(p => p.url().includes('mail.google')); +Handle login popups by preferring controllable tabs over blocked popup windows. -═══ INTERACTING WITH ELEMENTS ═══ +For multiline text, prefer fill() with \\n: + await state.page.locator('role=textbox[name="Message"]').fill('Line 1\\nLine 2'); -Use Playwright locators with accessibility roles (from snapshot output): - await state.page.locator('role=button[name="Sign in"]').click(); - await state.page.locator('role=textbox[name="Search"]').fill('query'); - await state.page.locator('role=link[name="Settings"]').click(); +═══ SNAPSHOT DIFF CONTROL ═══ -If snapshot shows [ref=some-id] for an element with a data-testid or id: - await state.page.locator('[data-testid="some-id"]').click(); +Use snapshot({ showDiffSinceLastCall: true }) to get concise diffs when repeatedly observing the same page. +Use snapshot({ showDiffSinceLastCall: false }) when you need full output. -For text content: - const text = await state.page.locator('role=heading').textContent(); +═══ SNAPSHOT VS SCREENSHOT ═══ -═══ COMMON PATTERNS ═══ +Prefer snapshot() for text/content/verification. +For plain screenshot requests, use state.page.screenshot() first, then snapshot() if textual verification is needed. +Use screenshotWithAccessibilityLabels() only when labels/refs on the image are explicitly needed. -Navigate and read: - await state.page.goto('https://example.com'); - await waitForPageLoad(); - return await snapshot(); +snapshot vs cleanHTML vs pageMarkdown: + - snapshot(): interactive structure, refs, quick verification + - cleanHTML(): structured DOM extraction/parsing + - pageMarkdown(): article-like content extraction -Click and verify: - await state.page.locator('role=button[name="Next"]').click(); - await waitForPageLoad(); - return await snapshot(); +Authenticated fetch: + Use state.page.evaluate(() => fetch(...)) when authenticated browser session context matters. -Fill form and submit: - await state.page.locator('role=textbox[name="Username"]').fill('user'); - await state.page.locator('role=textbox[name="Password"]').fill('pass'); - await state.page.locator('role=button[name="Login"]').click(); - await waitForPageLoad(); - return await snapshot(); +Downloads: + Prefer browser-driven download flows for large outputs instead of printing huge payloads. -Extract data: - return await state.page.evaluate(() => { - return document.querySelector('.price').textContent; - }); - -Wait for specific element: - await state.page.locator('role=heading[name="Dashboard"]').waitFor(); - return await snapshot(); +═══ BROWSERFORCE TAB SWARMS // PARALLEL TABS PROCESSING ═══ -Debug with console logs: - return getLogs({ count: 20 }); +Read browserforceSettings.executionMode before choosing strategy. +For independent read-only extraction tasks, use Promise.all with a concurrency cap (usually 3-8, start at 5). +Never run Promise.all actions against the same Page object. +Parallel task rule: one tab/page per task, then aggregate results. +On 429/challenges/timeouts: retry with lower concurrency, then sequential if needed. +If visibility mode requires showing work (for example, rotating/foreground demos), bringing your own working tab to front is allowed. -═══ ANTI-PATTERNS ═══ +Return telemetry for swarm runs: + { + peakConcurrentTasks, + wallClockMs, + sumTaskDurationsMs, + failures, + retries + } -✗ Don't navigate the user's existing tabs — create your own via context.newPage() -✗ Don't screenshot() to read text — use snapshot() -✗ Don't chain actions without verifying — observe after each action -✗ Don't use page.waitForTimeout() — use waitForPageLoad() or waitFor() -✗ Don't forget to return a value — every call should return verification -✗ Don't write complex multi-step scripts — split into separate execute calls -✗ Don't use page variable directly — use state.page after first call setup +═══ DEBUGGING QUICK LOOP ═══ -═══ ERROR RECOVERY ═══ +1) snapshot({ search: /button|dialog|error|target/i }) +2) getLogs({ count: 30 }) +3) state.page.evaluate(...) for visibility/disabled/overlay checks -If page closed: state.page = await context.newPage(); -If navigation fails: Check state.page.url() to see where you actually are -If element missing: Use snapshot({ search: /element/ }) to find it -If connection lost: Call the reset tool, then re-initialize state.page -If timeout: Increase timeout param, or break into smaller steps +Combine snapshot + logs to debug JS-heavy failures. +For JS-heavy or authenticated sites, stay in browser automation. +Do not switch to raw HTTP/curl expecting fully rendered DOM state. -═══ API REFERENCE ═══ +═══ HARD RULES ═══ -snapshot(options?) - options.selector CSS selector to scope the snapshot (e.g., '#main', '.sidebar') - options.search Regex string to filter tree nodes (e.g., 'button|link') - Returns: Text accessibility tree with interactive element refs +✗ Don't navigate the user's existing tabs +✗ Don't screenshot to read text; use snapshot +✗ Don't chain actions blindly without verification +✗ Don't use page.waitForTimeout() when a deterministic wait is available +✗ Don't use stale refs after DOM/navigation updates (stale locator refs cause false actions) +✗ Don't call page.context().newCDPSession(page); use getCDPSession({ page }) +✗ Don't call browser.close() or context.close() +✗ Don't call page.bringToFront() by default; only use it when user asks or when visibility mode needs visible tab progression +✗ Don't use the default page variable for ongoing work after setup; use state.page -waitForPageLoad(options?) - options.timeout Max wait in ms (default: 30000) - Returns: { success, readyState, pendingRequests, waitTimeMs, timedOut } - Filters analytics/ad requests that never finish. Polls document.readyState. +═══ ERROR RECOVERY ═══ -getLogs(options?) - options.count Number of recent entries (default: all) - Returns: Array of "[type] message" strings from browser console +If page closed: recreate state.page with context.newPage() (or reuse about:blank) +If navigation fails: check current URL, then snapshot() to re-ground state +If element missing: use snapshot({ search: /.../ }) with tighter patterns +If connection lost: call reset, then reinitialize state.page +If timeout: increase timeout or break work into smaller execute calls +If Chrome/extension unavailable: ask user to open Chrome, keep at least one normal web tab open, and ensure BrowserForce extension is connected -clearLogs() - Clears captured console logs for current page. +═══ API QUICK REFERENCE ═══ -state - Persistent object — survives across execute calls. Cleared on reset. - Use state.page, state.data, state.anything to preserve working state.`; +snapshot(options?) -> text accessibility tree with interactive refs; options.showDiffSinceLastCall toggles diff/full output +waitForPageLoad(options?) -> { success, readyState, pendingRequests, waitTimeMs, timedOut } +getLogs(options?) -> browser console log entries +clearLogs() -> clears captured logs for current page +state -> persistent across execute calls; cleared on reset`; function registerExecuteTool(skillAppendix = '') { server.tool( 'execute', EXECUTE_PROMPT + skillAppendix, { - code: z.string().describe('JavaScript to run — page/context/state/snapshot/waitForPageLoad/getLogs in scope'), + code: z.string().describe('JavaScript to run — page/context/state/snapshot/refToLocator/getCDPSession/waitForPageLoad/getLogs/cleanHTML/pageMarkdown in scope'), timeout: z.number().optional().describe('Max execution time in ms (default: 30000)'), }, async ({ code, timeout = 30000 }) => { await ensureBrowser(); ensureAllPagesCapture(); + const [agentPreferences, browserforceRestrictions] = await Promise.all([ + getAgentPreferencesForSession(), + getBrowserforceRestrictionsForSession(), + ]); const ctx = getContext(); const pages = ctx.pages(); const page = pages[0] || null; @@ -315,14 +506,20 @@ function registerExecuteTool(skillAppendix = '') { if (page) setupConsoleCapture(page); const execCtx = buildExecContext(page, ctx, userState, { consoleLogs, setupConsoleCapture, - }, pluginHelpers); + }, pluginHelpers, agentPreferences, browserforceRestrictions, pluginSkillRuntime); try { const result = await runCode(code, execCtx, timeout); const formatted = formatResult(result); - return { content: [formatted] }; + const content = Array.isArray(formatted) ? [...formatted] : [formatted]; + // Append update notice as a separate content item (once only per session) + if (pendingUpdate && !updateNoticeSent && content[0]?.type === 'text') { + updateNoticeSent = true; + content.push({ type: 'text', text: `[BrowserForce update available: ${pendingUpdate.current} → ${pendingUpdate.latest}]\n[Run: browserforce update or: npm install -g browserforce]` }); + } + return { content }; } catch (err) { const isTimeout = err instanceof CodeExecutionTimeoutError; - const hint = isTimeout ? '' : '\n\n[If connection lost, call reset tool to reconnect]'; + const hint = isTimeout ? '' : '\n\n[HINT: Call reset only for connection/internal failures (relay disconnect, page/context closed, Playwright internal/assertion issues). For normal selector/logic errors, fix and retry without reset.]'; return { content: [{ type: 'text', text: `Error: ${err.message}${hint}` }], isError: true, @@ -334,7 +531,7 @@ function registerExecuteTool(skillAppendix = '') { server.tool( 'reset', - 'Reconnects to the relay, reinitializes the browser context, and clears persistent state. Use when: connection lost, pages closed unexpectedly, or state is corrupt.', + 'Reconnects CDP and reinitializes browser/page bindings. Use when MCP stops responding, connection errors occur, pages/context were closed, or state is inconsistent. Reset clears persistent state; reinitialize state.page after calling it.', {}, async () => { if (browser) { @@ -342,6 +539,8 @@ server.tool( } browser = null; userState = {}; + cachedAgentPreferences = null; + cachedBrowserforceRestrictions = null; contextListenerAttached = false; consoleLogs.clear(); try { @@ -360,85 +559,13 @@ server.tool( } ); -// ─── Screenshot with Labels Tool ────────────────────────────────────────────── - -const SCREENSHOT_LABELS_PROMPT = `Take a screenshot with Vimium-style accessibility labels on interactive elements. - -Returns TWO content items: -1. JPEG screenshot with color-coded labels (e1, e2, e3...) on buttons, links, inputs, etc. -2. Text accessibility snapshot with matching refs and role/name locators - -Labels are color-coded by role: -- Yellow: links -- Orange: buttons, menu items, tabs -- Red/pink: text inputs, search boxes -- Green: checkboxes, radio buttons -- Blue: sliders, spinbuttons, media -- Purple: switches - -Use this tool when: -- You need to understand the visual layout of a page -- Text snapshot alone can't convey spatial relationships -- You need to verify element positions (dashboards, grids, maps) -- You need both visual context AND element refs for interaction - -After getting the screenshot, use the refs to interact via the execute tool: - await state.page.locator('role=button[name="Submit"]').click(); - -Parameters: -- selector: CSS selector to scope labels to part of the page (e.g., '#main', '.sidebar'). Main frame only. -- interactiveOnly: Only label interactive elements like buttons/links/inputs (default: true) - -Limitations: -- Main frame only — does not label elements inside cross-origin iframes -- Locators are role/name based — no data-testid matching`; - -server.tool( - 'screenshot_with_labels', - SCREENSHOT_LABELS_PROMPT, - { - selector: z.string().optional().describe('CSS selector to scope labels to a subtree of the main frame'), - interactiveOnly: z.boolean().optional().describe('Only label interactive elements (default: true)'), - }, - async ({ selector, interactiveOnly = true }) => { - await ensureBrowser(); - const ctx = getContext(); - const page = (userState.page && !userState.page.isClosed()) - ? userState.page - : ctx.pages()[0] || null; - if (!page) { - return { - content: [{ type: 'text', text: 'Error: No pages available. Open a tab first.' }], - isError: true, - }; - } - - try { - const { screenshot, snapshot, labelCount } = await screenshotWithLabels(page, { - selector, - interactiveOnly, - }); - return { - content: [ - { type: 'image', data: screenshot.toString('base64'), mimeType: 'image/jpeg' }, - { type: 'text', text: `Labels: ${labelCount} interactive elements\n\n${snapshot}` }, - ], - }; - } catch (err) { - return { - content: [{ type: 'text', text: `Error: ${err.message}` }], - isError: true, - }; - } - } -); - // ─── Plugin Init ───────────────────────────────────────────────────────────── async function initPlugins() { try { plugins = await loadPlugins(); pluginHelpers = buildPluginHelpers(plugins); + pluginSkillRuntime = buildPluginSkillRuntime(plugins); if (plugins.length > 0) { process.stderr.write(`[bf-mcp] Loaded ${plugins.length} plugin(s): ${plugins.map(p => p.name).join(', ')}\n`); } @@ -453,17 +580,13 @@ async function main() { await initPlugins(); registerExecuteTool(buildPluginSkillAppendix(plugins)); - try { - await ensureBrowser(); - process.stderr.write('[bf-mcp] Connected to relay\n'); - } catch (err) { - process.stderr.write(`[bf-mcp] Warning: ${err.message}\n`); - process.stderr.write('[bf-mcp] Tools will attempt to connect on first use\n'); - } + // Fire update check in background — result stored in pendingUpdate for execute handler + checkForUpdate().then(info => { pendingUpdate = info; }).catch(() => {}); const transport = new StdioServerTransport(); await server.connect(transport); process.stderr.write('[bf-mcp] MCP server running\n'); + startBackgroundConnectionLoop(); } main().catch((err) => { diff --git a/mcp/src/openclaw-setup.js b/mcp/src/openclaw-setup.js new file mode 100644 index 0000000..156e921 --- /dev/null +++ b/mcp/src/openclaw-setup.js @@ -0,0 +1,334 @@ +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +const RELAY_PORT = 19222; +const DARWIN_LAUNCH_AGENT_LABEL = 'ai.browserforce.relay'; +const LINUX_SYSTEMD_USER_SERVICE = 'browserforce-relay.service'; +const WIN32_TASK_NAME = 'BrowserForceRelay'; + +function shellQuote(value) { + return `'${String(value).replace(/'/g, `'\\''`)}'`; +} + +function posixPathJoin(left, right) { + return `${String(left).replace(/\/+$/, '')}/${String(right).replace(/^\/+/, '')}`; +} + +function windowsCommandArg(value) { + return String(value) + .replace(/"/g, '""') + .replace(/%/g, '%%') + .replace(/[&<>|^]/g, '^$&'); +} + +function windowsTaskQuotedArg(value) { + return `"${windowsCommandArg(value)}"`; +} + +function windowsTaskEscapeForTr(value) { + return String(value).replace(/"/g, '""'); +} + +function xmlEscape(value) { + return String(value).replace(/[&<>"']/g, (ch) => { + if (ch === '&') return '&'; + if (ch === '<') return '<'; + if (ch === '>') return '>'; + if (ch === '"') return '"'; + return '''; + }); +} + +export function renderLaunchAgentPlist({ label, nodePath, binScriptPath }) { + const escapedLabel = xmlEscape(label); + const escapedNodePath = xmlEscape(nodePath); + const escapedBinScriptPath = xmlEscape(binScriptPath); + + return [ + '', + '', + '', + '', + ' Label', + ` ${escapedLabel}`, + ' ProgramArguments', + ' ', + ` ${escapedNodePath}`, + ` ${escapedBinScriptPath}`, + ' serve', + ' ', + ' RunAtLoad', + ' ', + ' KeepAlive', + ' ', + '', + '', + '', + ].join('\n'); +} + +export function renderSystemdUserService({ nodePath, binScriptPath }) { + return [ + '[Unit]', + 'Description=BrowserForce Relay', + 'After=network.target', + '', + '[Service]', + 'Type=simple', + `ExecStart="${nodePath}" "${binScriptPath}" serve`, + 'Restart=always', + 'RestartSec=2', + '', + '[Install]', + 'WantedBy=default.target', + '', + ].join('\n'); +} + +export function buildAutostartSpec({ platform, homeDir, nodePath, binScriptPath }) { + const activePlatform = platform || process.platform; + + if (activePlatform === 'darwin') { + const plistPath = posixPathJoin( + posixPathJoin(homeDir, 'Library/LaunchAgents'), + `${DARWIN_LAUNCH_AGENT_LABEL}.plist`, + ); + const programArguments = [nodePath, binScriptPath, 'serve']; + const plist = renderLaunchAgentPlist({ + label: DARWIN_LAUNCH_AGENT_LABEL, + nodePath, + binScriptPath, + }); + + return { + platform: activePlatform, + filesToWrite: [ + { + path: plistPath, + content: plist, + }, + ], + commands: [ + `launchctl unload ${shellQuote(plistPath)} >/dev/null 2>&1 || true`, + `launchctl load -w ${shellQuote(plistPath)}`, + ], + summary: `Install launchd agent ${DARWIN_LAUNCH_AGENT_LABEL}`, + launchAgent: { + label: DARWIN_LAUNCH_AGENT_LABEL, + plistPath, + programArguments, + }, + }; + } + + if (activePlatform === 'linux') { + const servicePath = posixPathJoin( + posixPathJoin(homeDir, '.config/systemd/user'), + LINUX_SYSTEMD_USER_SERVICE, + ); + const serviceContents = renderSystemdUserService({ nodePath, binScriptPath }); + + return { + platform: activePlatform, + filesToWrite: [ + { + path: servicePath, + content: serviceContents, + }, + ], + commands: [ + 'systemctl --user daemon-reload', + `systemctl --user enable --now ${LINUX_SYSTEMD_USER_SERVICE}`, + ], + summary: `Install systemd user service ${LINUX_SYSTEMD_USER_SERVICE}`, + systemd: { + serviceName: LINUX_SYSTEMD_USER_SERVICE, + servicePath, + }, + }; + } + + if (activePlatform === 'win32') { + const commandToRun = `${windowsTaskQuotedArg(nodePath)} ${windowsTaskQuotedArg(binScriptPath)} serve`; + const createCommand = `schtasks /Create /F /TN "${WIN32_TASK_NAME}" /SC ONLOGON /TR "${windowsTaskEscapeForTr(commandToRun)}"`; + + return { + platform: activePlatform, + filesToWrite: [], + commands: [createCommand], + summary: `Install scheduled task ${WIN32_TASK_NAME}`, + scheduledTask: { + taskName: WIN32_TASK_NAME, + createCommand, + commandToRun, + }, + }; + } + + throw new Error(`Unsupported platform: ${activePlatform}`); +} + +export function buildBrowserforceMcpServerEntry({ platform = process.platform } = {}) { + if (platform === 'win32') { + const command = [ + `if (-not (netstat -ano | Select-String ':${RELAY_PORT}\\s+.*LISTENING')) {`, + "Start-Process -WindowStyle Hidden -FilePath 'npx' -ArgumentList '-y','browserforce@latest','serve'", + '}', + '& npx -y browserforce@latest mcp', + ].join(' '); + + return { + name: 'browserforce', + transport: 'stdio', + command: 'powershell', + args: ['-NoProfile', '-NonInteractive', '-Command', command], + }; + } + + const command = [ + `if ! lsof -tiTCP:${RELAY_PORT} -sTCP:LISTEN >/dev/null 2>&1; then`, + 'npx -y browserforce@latest serve >/dev/null 2>&1 &', + 'fi;', + 'exec npx -y browserforce@latest mcp', + ].join(' '); + + return { + name: 'browserforce', + transport: 'stdio', + command: 'sh', + args: ['-lc', command], + }; +} + +function asObject(value) { + return value && typeof value === 'object' && !Array.isArray(value) ? value : {}; +} + +function ensureMcpAdapterOnce(allowList) { + const values = Array.isArray(allowList) ? allowList : []; + const filtered = values.filter((value) => value !== 'mcp-adapter'); + return [...filtered, 'mcp-adapter']; +} + +function mergeServers(existingServers, { platform = process.platform } = {}) { + const values = Array.isArray(existingServers) ? existingServers : []; + const browserforceEntry = buildBrowserforceMcpServerEntry({ platform }); + const merged = []; + let inserted = false; + + for (const value of values) { + const isBrowserforce = + value && + typeof value === 'object' && + !Array.isArray(value) && + value.name === 'browserforce'; + + if (!isBrowserforce) { + merged.push(value); + continue; + } + + if (!inserted) { + merged.push(browserforceEntry); + inserted = true; + } + } + + if (!inserted) { + merged.push(browserforceEntry); + } + + return merged; +} + +export function mergeOpenClawConfig(existingConfig, { platform = process.platform } = {}) { + const root = asObject(existingConfig); + + const plugins = asObject(root.plugins); + const entries = asObject(plugins.entries); + const mcpAdapter = asObject(entries['mcp-adapter']); + const mcpAdapterConfig = asObject(mcpAdapter.config); + + const tools = asObject(root.tools); + const sandbox = asObject(tools.sandbox); + const sandboxTools = asObject(sandbox.tools); + + return { + ...root, + plugins: { + ...plugins, + entries: { + ...entries, + 'mcp-adapter': { + ...mcpAdapter, + enabled: true, + config: { + ...mcpAdapterConfig, + servers: mergeServers(mcpAdapterConfig.servers, { platform }), + }, + }, + }, + }, + tools: { + ...tools, + sandbox: { + ...sandbox, + tools: { + ...sandboxTools, + allow: ensureMcpAdapterOnce(sandboxTools.allow), + }, + }, + }, + }; +} + +export function formatJsonStable(obj) { + return `${JSON.stringify(obj, null, 2)}\n`; +} + +function defaultExecFn(command) { + const result = spawnSync(command, { + shell: true, + stdio: 'inherit', + }); + + if (result.error) { + throw result.error; + } + + if (typeof result.status === 'number' && result.status !== 0) { + throw new Error(`Command failed with exit code ${result.status}: ${command}`); + } + + if (result.status === null) { + throw new Error(`Command terminated unexpectedly: ${command}`); + } +} + +export async function applyAutostart(spec, { dryRun = false, execFn = defaultExecFn, fsApi = fs } = {}) { + const filesToWrite = Array.isArray(spec?.filesToWrite) ? spec.filesToWrite : []; + const commands = Array.isArray(spec?.commands) ? spec.commands : []; + const report = { + wroteFiles: filesToWrite.map((file) => file.path), + ranCommands: [], + skippedCommands: dryRun ? [...commands] : [], + }; + + if (dryRun) { + return report; + } + + for (const file of filesToWrite) { + const parentDir = path.dirname(file.path); + await fsApi.mkdir(parentDir, { recursive: true }); + await fsApi.writeFile(file.path, file.content, 'utf8'); + } + + for (const command of commands) { + await execFn(command); + report.ranCommands.push(command); + } + + return report; +} diff --git a/mcp/src/page-markdown.js b/mcp/src/page-markdown.js new file mode 100644 index 0000000..f17be92 --- /dev/null +++ b/mcp/src/page-markdown.js @@ -0,0 +1,189 @@ +// Page markdown extraction — uses Mozilla Readability (Firefox Reader View algorithm). +// Injects a pre-bundled Readability IIFE into the page, then extracts article content. + +import { readFileSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createSmartDiff } from './snapshot.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +let readabilityCode = null; +const lastMarkdownSnapshots = new WeakMap(); + +function isRegExp(value) { + return ( + typeof value === 'object' && + value !== null && + typeof value.test === 'function' && + typeof value.exec === 'function' + ); +} + +function lineMatchesSearch(search, line) { + if (!isRegExp(search)) { + return line.toLowerCase().includes(String(search).toLowerCase()); + } + if (search.global || search.sticky) { + search.lastIndex = 0; + } + return search.test(line); +} + +function getReadabilityCode() { + if (readabilityCode) return readabilityCode; + const bundlePath = join(__dirname, 'vendor', 'readability.bundle.js'); + readabilityCode = readFileSync(bundlePath, 'utf-8'); + return readabilityCode; +} + +/** + * Extracts page content as structured markdown using Mozilla Readability. + * Strips nav, ads, sidebars — returns article body with metadata. + * + * @param {import('playwright-core').Page} page + * @param {{ search?: string | RegExp, showDiffSinceLastCall?: boolean }} [opts] + * @returns {Promise} + */ +export async function getPageMarkdown(page, opts = {}) { + const search = opts.search; + const showDiffSinceLastCall = opts.showDiffSinceLastCall ?? true; + + // Inject Readability if not already present + const hasReadability = await page.evaluate(() => !!globalThis.__readability); + if (!hasReadability) { + await page.evaluate(getReadabilityCode()); + } + + const result = await page.evaluate(() => { + const { Readability, isProbablyReaderable } = globalThis.__readability; + + const documentClone = document.cloneNode(true); + + if (!isProbablyReaderable(documentClone)) { + return { + content: document.body?.innerText || '', + title: document.title || null, + author: null, + excerpt: null, + siteName: null, + lang: document.documentElement?.lang || null, + publishedTime: null, + wordCount: (document.body?.innerText || '').split(/\s+/).filter(Boolean).length, + readable: false, + }; + } + + const article = new Readability(documentClone).parse(); + + if (!article) { + return { + content: document.body?.innerText || '', + title: document.title || null, + author: null, + excerpt: null, + siteName: null, + lang: document.documentElement?.lang || null, + publishedTime: null, + wordCount: (document.body?.innerText || '').split(/\s+/).filter(Boolean).length, + readable: false, + }; + } + + return { + content: article.textContent || '', + title: article.title || null, + author: article.byline || null, + excerpt: article.excerpt || null, + siteName: article.siteName || null, + lang: article.lang || null, + publishedTime: article.publishedTime || null, + wordCount: (article.textContent || '').split(/\s+/).filter(Boolean).length, + readable: true, + }; + }); + + // Format output as structured markdown + const lines = []; + + if (result.title) { + lines.push(`# ${result.title}`, ''); + } + + const metadata = []; + if (result.author) metadata.push(`Author: ${result.author}`); + if (result.siteName) metadata.push(`Site: ${result.siteName}`); + if (result.publishedTime) metadata.push(`Published: ${result.publishedTime}`); + if (metadata.length > 0) { + lines.push(metadata.join(' | '), ''); + } + + if (result.excerpt && result.content && result.excerpt !== result.content.slice(0, result.excerpt.length)) { + lines.push(`> ${result.excerpt}`, ''); + } + + lines.push(result.content); + + if (!result.readable) { + lines.push('', '---', '_Note: Page was not recognized as an article. Returned raw body text._'); + } + + let markdown = lines.join('\n').trim(); + + // Sanitize unpaired surrogates that break JSON encoding + if (typeof markdown.toWellFormed === 'function') { + markdown = markdown.toWellFormed(); + } + + const previousSnapshot = lastMarkdownSnapshots.get(page); + lastMarkdownSnapshots.set(page, markdown); + + if (search) { + const lines = markdown.split('\n'); + const matchIndices = []; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (lineMatchesSearch(search, line)) { + matchIndices.push(i); + if (matchIndices.length >= 10) break; + } + } + + if (matchIndices.length === 0) { + return 'No matches found'; + } + + const CONTEXT_LINES = 5; + const includedLines = new Set(); + for (const idx of matchIndices) { + const start = Math.max(0, idx - CONTEXT_LINES); + const end = Math.min(lines.length - 1, idx + CONTEXT_LINES); + for (let i = start; i <= end; i++) { + includedLines.add(i); + } + } + + const sortedIndices = [...includedLines].sort((a, b) => a - b); + const resultLines = []; + for (let i = 0; i < sortedIndices.length; i++) { + const lineIdx = sortedIndices[i]; + if (i > 0 && sortedIndices[i - 1] !== lineIdx - 1) { + resultLines.push('---'); + } + resultLines.push(lines[lineIdx]); + } + + return resultLines.join('\n'); + } + + if (showDiffSinceLastCall && previousSnapshot) { + const diffResult = createSmartDiff(previousSnapshot, markdown); + if (diffResult.type === 'no-change') { + return 'No changes since last call. Use showDiffSinceLastCall: false to see full content.'; + } + return diffResult.content; + } + + return markdown; +} diff --git a/mcp/src/plugin-installer.js b/mcp/src/plugin-installer.js index 517abc7..647f998 100644 --- a/mcp/src/plugin-installer.js +++ b/mcp/src/plugin-installer.js @@ -4,7 +4,6 @@ import { createHash } from 'node:crypto'; import https from 'node:https'; const REGISTRY_URL = 'https://raw.githubusercontent.com/ivalsaraj/browserforce/main/plugins/registry.json'; -const BASE_RAW = 'https://raw.githubusercontent.com/ivalsaraj/browserforce/main/'; function httpsGetRaw(url) { return new Promise((resolve, reject) => { diff --git a/mcp/src/plugin-loader.js b/mcp/src/plugin-loader.js index 69e417d..130029d 100644 --- a/mcp/src/plugin-loader.js +++ b/mcp/src/plugin-loader.js @@ -5,6 +5,208 @@ import { homedir } from 'node:os'; export const PLUGINS_DIR = join(homedir(), '.browserforce', 'plugins'); +function stripWrappingQuotes(value) { + if (value.length >= 2) { + const first = value[0]; + const last = value[value.length - 1]; + if ((first === '"' && last === '"') || (first === '\'' && last === '\'')) { + return value.slice(1, -1); + } + } + return value; +} + +const CANONICAL_SKILL_META_KEYS = new Set([ + 'name', + 'description', + 'helpers', + 'tools', + 'when_to_use', +]); +const CANONICAL_SKILL_LIST_KEYS = new Set([ + 'helpers', + 'tools', + 'when_to_use', +]); + +function parseBlockScalarValue(lines, style) { + if (style === '|') { + return lines.join('\n').trimEnd(); + } + + // Minimal folded-scalar support for `>`: fold newlines into spaces. + return lines + .map((line) => line.trim()) + .join(' ') + .replace(/\s+/g, ' ') + .trim(); +} + +function normalizeListItem(value) { + return stripWrappingQuotes(String(value || '').trim()); +} + +function parseInlineList(rawValue) { + const trimmed = String(rawValue || '').trim(); + if (!trimmed.startsWith('[') || !trimmed.endsWith(']')) { + return null; + } + try { + const parsed = JSON.parse(trimmed); + if (Array.isArray(parsed)) { + return parsed.map(normalizeListItem).filter(Boolean); + } + } catch { /* fall back to scalar parsing */ } + return null; +} + +function normalizeMetaValue(key, value) { + const normalizedValue = typeof value === 'string' ? value.trim() : value; + if (!CANONICAL_SKILL_LIST_KEYS.has(key)) { + return normalizedValue; + } + + const inline = parseInlineList(normalizedValue); + if (inline) return inline; + if (typeof normalizedValue !== 'string') return []; + if (!normalizedValue) return []; + if (normalizedValue.includes(',')) { + return normalizedValue.split(',').map(normalizeListItem).filter(Boolean); + } + return [normalizeListItem(normalizedValue)].filter(Boolean); +} + +function parseSkillFrontmatter(rawSkill = '') { + const skillText = typeof rawSkill === 'string' ? rawSkill : ''; + + try { + if (!skillText.startsWith('---')) { + return { meta: {}, body: skillText }; + } + + const match = skillText.match(/^---\s*\r?\n([\s\S]*?)\r?\n---\s*(?:\r?\n)?([\s\S]*)$/); + if (!match) { + return { meta: {}, body: skillText }; + } + + const [, rawMeta, rawBody] = match; + const meta = {}; + const lines = rawMeta.split(/\r?\n/); + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + + const keyMatch = line.match(/^\s*([^:]+?)\s*:\s*(.*)$/); + if (!keyMatch) continue; + + const key = keyMatch[1].trim().toLowerCase(); + const rawValue = keyMatch[2].trim(); + + if (rawValue === '|' || rawValue === '>') { + const blockLines = []; + let j = i + 1; + for (; j < lines.length; j++) { + const blockLine = lines[j]; + if (blockLine.trim() === '') { + blockLines.push(''); + continue; + } + if (!/^\s+/.test(blockLine)) { + break; + } + blockLines.push(blockLine.replace(/^\s+/, '')); + } + + i = j - 1; + if (!CANONICAL_SKILL_META_KEYS.has(key)) continue; + meta[key] = normalizeMetaValue(key, parseBlockScalarValue(blockLines, rawValue)); + continue; + } + + if (rawValue === '' && CANONICAL_SKILL_LIST_KEYS.has(key)) { + const listItems = []; + let j = i + 1; + for (; j < lines.length; j++) { + const listLine = lines[j]; + if (!listLine.trim()) continue; + if (!/^\s+/.test(listLine)) break; + const listMatch = listLine.match(/^\s*-\s+(.+)$/); + if (!listMatch) break; + listItems.push(normalizeListItem(listMatch[1])); + } + i = j - 1; + meta[key] = listItems.filter(Boolean); + continue; + } + + if (!CANONICAL_SKILL_META_KEYS.has(key)) continue; + meta[key] = normalizeMetaValue(key, stripWrappingQuotes(rawValue)); + } + + return { meta, body: rawBody }; + } catch { + return { meta: {}, body: skillText }; + } +} + +function normalizeMarkdownHeading(heading) { + return heading + .toLowerCase() + .trim() + .replace(/^[\d.)\s-]+/, '') + .replace(/[^\p{L}\p{N}\s-]/gu, '') + .replace(/\s+/g, ' ') + .trim(); +} + +function extractSkillSections(skillBody = '') { + const normalizedBody = typeof skillBody === 'string' ? skillBody.replace(/\r\n/g, '\n') : ''; + const sections = {}; + const headingEntries = []; + const lines = normalizedBody.split('\n'); + let offset = 0; + let activeFence = null; + + for (const line of lines) { + const fenceMatch = line.match(/^\s*(`{3,}|~{3,})/); + if (fenceMatch) { + const fence = fenceMatch[1]; + if (!activeFence) { + activeFence = { char: fence[0], len: fence.length }; + } else if (fence[0] === activeFence.char && fence.length >= activeFence.len) { + activeFence = null; + } + } else if (!activeFence) { + const headingMatch = line.match(/^\s*##\s+(.+?)\s*$/); + if (headingMatch) { + headingEntries.push({ + headingText: String(headingMatch[1] || '').trim(), + lineStart: offset, + contentStart: offset + line.length + 1, + }); + } + } + offset += line.length + 1; + } + + if (headingEntries.length === 0) return sections; + + for (let i = 0; i < headingEntries.length; i++) { + const { headingText, contentStart } = headingEntries[i]; + const key = normalizeMarkdownHeading(headingText); + if (!key) continue; + const contentEnd = i + 1 < headingEntries.length ? headingEntries[i + 1].lineStart : normalizedBody.length; + const safeContentStart = Math.min(contentStart, normalizedBody.length); + const sectionBody = normalizedBody.slice(safeContentStart, contentEnd).trim(); + if (sectionBody) { + sections[key] = sectionBody; + } + } + + return sections; +} + /** * Scan pluginsDir for subfolders with index.js. Loads each as an ESM module. * @param {string} [pluginsDir] @@ -47,8 +249,15 @@ export async function loadPlugins(pluginsDir = PLUGINS_DIR) { try { skill = await readFile(join(pluginDir, 'SKILL.md'), 'utf8'); } catch { /* SKILL.md is optional */ } + const { meta: skillMeta, body: skillBody } = parseSkillFrontmatter(skill); - plugins.push({ ...plugin, _skill: skill, _dir: pluginDir }); + plugins.push({ + ...plugin, + _skill: skill, + _skillMeta: skillMeta, + _skillBody: skillBody, + _dir: pluginDir, + }); process.stderr.write(`[bf-plugins] Loaded plugin: ${plugin.name}\n`); } @@ -75,11 +284,73 @@ export function buildPluginHelpers(plugins) { /** * Build the SKILL.md appendix to append to the execute tool prompt. - * Only includes plugins that have non-empty SKILL.md content. + * Includes plugins that provide either non-empty SKILL.md content or parsed + * frontmatter metadata. */ export function buildPluginSkillAppendix(plugins) { - const sections = plugins - .filter(p => p._skill && p._skill.trim()) - .map(p => `\n\n═══ PLUGIN: ${p.name} ═══\n\n${p._skill.trim()}`); - return sections.join(''); + const lines = []; + lines.push('\n\n═══ PLUGINS (METADATA-ONLY) ═══'); + lines.push('Use pluginCatalog() for plugin metadata, then pluginHelp(name, section?) for details on demand.'); + + let included = 0; + for (const plugin of plugins) { + const skillBody = typeof plugin._skillBody === 'string' ? plugin._skillBody : plugin._skill; + const hasSkill = typeof skillBody === 'string' && skillBody.trim().length > 0; + const meta = plugin._skillMeta && typeof plugin._skillMeta === 'object' ? plugin._skillMeta : {}; + const hasMeta = Object.keys(meta).length > 0; + if (!hasSkill && !hasMeta) continue; + included += 1; + + const helperNames = Object.keys(plugin.helpers || {}); + const description = String(meta.description || '').trim() || 'No description provided'; + lines.push(''); + lines.push(`PLUGIN: ${plugin.name}`); + lines.push(`description: ${description}`); + lines.push(`helpers: ${helperNames.length ? helperNames.join(', ') : '(none)'}`); + } + + if (included === 0) { + lines.push('No plugin skills currently advertise metadata.'); + } + + return lines.join('\n'); +} + +export function buildPluginSkillRuntime(plugins) { + const catalog = []; + const byName = {}; + + for (const plugin of plugins) { + const normalizedName = String(plugin.name).toLowerCase(); + if (Object.prototype.hasOwnProperty.call(byName, normalizedName)) { + process.stderr.write( + `[bf-plugins] Duplicate plugin skill name after normalization: "${plugin.name}" conflicts with "${byName[normalizedName].name}" (key "${normalizedName}"). Keeping first.\n` + ); + continue; + } + + const helperNames = Object.keys(plugin.helpers || {}); + const meta = plugin._skillMeta && typeof plugin._skillMeta === 'object' ? plugin._skillMeta : {}; + const skillBody = (typeof plugin._skillBody === 'string' ? plugin._skillBody : plugin._skill || '').trim(); + const description = String(meta.description || '').trim() || ''; + const sections = extractSkillSections(skillBody); + const sectionNames = Object.keys(sections); + + catalog.push({ + name: plugin.name, + description: description || 'No description provided', + helpers: helperNames, + sections: sectionNames, + }); + + byName[normalizedName] = { + name: plugin.name, + description, + text: skillBody, + sections, + helpers: helperNames, + }; + } + + return { catalog, byName }; } diff --git a/mcp/src/snapshot.js b/mcp/src/snapshot.js index 6913f5a..8db53b1 100644 --- a/mcp/src/snapshot.js +++ b/mcp/src/snapshot.js @@ -86,7 +86,7 @@ export function hasMatchingDescendant(node, pattern) { * * When multiple nodes resolve to the same ref, a -2, -3 suffix is appended. */ -export function buildSnapshotText(axTree, stableIdMap, searchPattern) { +export function buildSnapshotText(axTree, stableIdMap, searchPattern, { refAll = false } = {}) { const lines = []; const refs = []; let refCounter = 0; @@ -119,7 +119,7 @@ export function buildSnapshotText(axTree, stableIdMap, searchPattern) { lineText += ` "${escapeLocatorName(name)}"`; } - if (isInteractive) { + if (isInteractive || (refAll && isContext)) { refCounter++; const stableEntry = node._stableAttr || null; let baseRef = stableEntry ? stableEntry.value : `e${refCounter}`; diff --git a/mcp/src/update-check.js b/mcp/src/update-check.js new file mode 100644 index 0000000..8e6aa60 --- /dev/null +++ b/mcp/src/update-check.js @@ -0,0 +1,62 @@ +import https from 'node:https'; +import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { homedir } from 'node:os'; +import { fileURLToPath } from 'node:url'; + +export function semverGt(a, b) { + const pa = a.split('.').map(Number); + const pb = b.split('.').map(Number); + for (let i = 0; i < 3; i++) { + if ((pa[i] || 0) > (pb[i] || 0)) return true; + if ((pa[i] || 0) < (pb[i] || 0)) return false; + } + return false; +} + +/** + * Check npm registry for a newer version of browserforce. + * Result is cached for 24 h in ~/.browserforce/update-check.json. + * Returns { current, latest } if an update is available, otherwise null. + * Never throws — all errors resolve to null. + */ +export async function checkForUpdate() { + // package.json is two levels up from mcp/src/ + const pkgPath = fileURLToPath(new URL('../../package.json', import.meta.url)); + const current = JSON.parse(readFileSync(pkgPath, 'utf8')).version; + + const cacheDir = join(homedir(), '.browserforce'); + const cacheFile = join(cacheDir, 'update-check.json'); + + // Return cached result if still fresh (< 24 h) + try { + const cached = JSON.parse(readFileSync(cacheFile, 'utf8')); + if (Date.now() - cached.checkedAt < 86_400_000) { + return semverGt(cached.latest, current) ? { current, latest: cached.latest } : null; + } + } catch { /* no cache yet, or invalid */ } + + // Fetch latest from npm registry — let errors propagate to caller + const latest = await new Promise((resolve, reject) => { + const req = https.get( + 'https://registry.npmjs.org/browserforce/latest', + { headers: { 'User-Agent': 'browserforce-cli' } }, + (res) => { + if (res.statusCode !== 200) { res.resume(); return reject(new Error(`HTTP ${res.statusCode}`)); } + let data = ''; + res.on('data', d => (data += d)); + res.on('end', () => { try { resolve(JSON.parse(data).version); } catch { reject(new Error('parse error')); } }); + }, + ); + req.on('error', reject); + req.setTimeout(5000, () => { req.destroy(); reject(new Error('timeout')); }); + }); + + // Persist to cache + try { + mkdirSync(cacheDir, { recursive: true }); + writeFileSync(cacheFile, JSON.stringify({ checkedAt: Date.now(), latest })); + } catch { /* ignore cache write errors */ } + + return semverGt(latest, current) ? { current, latest } : null; +} diff --git a/mcp/src/vendor/readability.bundle.js b/mcp/src/vendor/readability.bundle.js new file mode 100644 index 0000000..0288cf9 --- /dev/null +++ b/mcp/src/vendor/readability.bundle.js @@ -0,0 +1,2064 @@ +// Auto-generated by scripts/bundle-readability.js — do not edit +(() => { + var __getOwnPropNames = Object.getOwnPropertyNames; + var __commonJS = (cb, mod) => function __require() { + return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports; + }; + + // ../node_modules/.pnpm/@mozilla+readability@0.6.0/node_modules/@mozilla/readability/Readability.js + var require_Readability = __commonJS({ + "../node_modules/.pnpm/@mozilla+readability@0.6.0/node_modules/@mozilla/readability/Readability.js"(exports, module) { + function Readability(doc, options) { + if (options && options.documentElement) { + doc = options; + options = arguments[2]; + } else if (!doc || !doc.documentElement) { + throw new Error( + "First argument to Readability constructor should be a document object." + ); + } + options = options || {}; + this._doc = doc; + this._docJSDOMParser = this._doc.firstChild.__JSDOMParser__; + this._articleTitle = null; + this._articleByline = null; + this._articleDir = null; + this._articleSiteName = null; + this._attempts = []; + this._metadata = {}; + this._debug = !!options.debug; + this._maxElemsToParse = options.maxElemsToParse || this.DEFAULT_MAX_ELEMS_TO_PARSE; + this._nbTopCandidates = options.nbTopCandidates || this.DEFAULT_N_TOP_CANDIDATES; + this._charThreshold = options.charThreshold || this.DEFAULT_CHAR_THRESHOLD; + this._classesToPreserve = this.CLASSES_TO_PRESERVE.concat( + options.classesToPreserve || [] + ); + this._keepClasses = !!options.keepClasses; + this._serializer = options.serializer || function(el) { + return el.innerHTML; + }; + this._disableJSONLD = !!options.disableJSONLD; + this._allowedVideoRegex = options.allowedVideoRegex || this.REGEXPS.videos; + this._linkDensityModifier = options.linkDensityModifier || 0; + this._flags = this.FLAG_STRIP_UNLIKELYS | this.FLAG_WEIGHT_CLASSES | this.FLAG_CLEAN_CONDITIONALLY; + if (this._debug) { + let logNode = function(node) { + if (node.nodeType == node.TEXT_NODE) { + return `${node.nodeName} ("${node.textContent}")`; + } + let attrPairs = Array.from(node.attributes || [], function(attr) { + return `${attr.name}="${attr.value}"`; + }).join(" "); + return `<${node.localName} ${attrPairs}>`; + }; + this.log = function() { + if (typeof console !== "undefined") { + let args = Array.from(arguments, (arg) => { + if (arg && arg.nodeType == this.ELEMENT_NODE) { + return logNode(arg); + } + return arg; + }); + args.unshift("Reader: (Readability)"); + console.log(...args); + } else if (typeof dump !== "undefined") { + var msg = Array.prototype.map.call(arguments, function(x) { + return x && x.nodeName ? logNode(x) : x; + }).join(" "); + dump("Reader: (Readability) " + msg + "\n"); + } + }; + } else { + this.log = function() { + }; + } + } + Readability.prototype = { + FLAG_STRIP_UNLIKELYS: 1, + FLAG_WEIGHT_CLASSES: 2, + FLAG_CLEAN_CONDITIONALLY: 4, + // https://developer.mozilla.org/en-US/docs/Web/API/Node/nodeType + ELEMENT_NODE: 1, + TEXT_NODE: 3, + // Max number of nodes supported by this parser. Default: 0 (no limit) + DEFAULT_MAX_ELEMS_TO_PARSE: 0, + // The number of top candidates to consider when analysing how + // tight the competition is among candidates. + DEFAULT_N_TOP_CANDIDATES: 5, + // Element tags to score by default. + DEFAULT_TAGS_TO_SCORE: "section,h2,h3,h4,h5,h6,p,td,pre".toUpperCase().split(","), + // The default number of chars an article must have in order to return a result + DEFAULT_CHAR_THRESHOLD: 500, + // All of the regular expressions in use within readability. + // Defined up here so we don't instantiate them repeatedly in loops. + REGEXPS: { + // NOTE: These two regular expressions are duplicated in + // Readability-readerable.js. Please keep both copies in sync. + unlikelyCandidates: /-ad-|ai2html|banner|breadcrumbs|combx|comment|community|cover-wrap|disqus|extra|footer|gdpr|header|legends|menu|related|remark|replies|rss|shoutbox|sidebar|skyscraper|social|sponsor|supplemental|ad-break|agegate|pagination|pager|popup|yom-remote/i, + okMaybeItsACandidate: /and|article|body|column|content|main|shadow/i, + positive: /article|body|content|entry|hentry|h-entry|main|page|pagination|post|text|blog|story/i, + negative: /-ad-|hidden|^hid$| hid$| hid |^hid |banner|combx|comment|com-|contact|footer|gdpr|masthead|media|meta|outbrain|promo|related|scroll|share|shoutbox|sidebar|skyscraper|sponsor|shopping|tags|widget/i, + extraneous: /print|archive|comment|discuss|e[\-]?mail|share|reply|all|login|sign|single|utility/i, + byline: /byline|author|dateline|writtenby|p-author/i, + replaceFonts: /<(\/?)font[^>]*>/gi, + normalize: /\s{2,}/g, + videos: /\/\/(www\.)?((dailymotion|youtube|youtube-nocookie|player\.vimeo|v\.qq)\.com|(archive|upload\.wikimedia)\.org|player\.twitch\.tv)/i, + shareElements: /(\b|_)(share|sharedaddy)(\b|_)/i, + nextLink: /(next|weiter|continue|>([^\|]|$)|»([^\|]|$))/i, + prevLink: /(prev|earl|old|new|<|«)/i, + tokenize: /\W+/g, + whitespace: /^\s*$/, + hasContent: /\S$/, + hashUrl: /^#.+/, + srcsetUrl: /(\S+)(\s+[\d.]+[xw])?(\s*(?:,|$))/g, + b64DataUrl: /^data:\s*([^\s;,]+)\s*;\s*base64\s*,/i, + // Commas as used in Latin, Sindhi, Chinese and various other scripts. + // see: https://en.wikipedia.org/wiki/Comma#Comma_variants + commas: /\u002C|\u060C|\uFE50|\uFE10|\uFE11|\u2E41|\u2E34|\u2E32|\uFF0C/g, + // See: https://schema.org/Article + jsonLdArticleTypes: /^Article|AdvertiserContentArticle|NewsArticle|AnalysisNewsArticle|AskPublicNewsArticle|BackgroundNewsArticle|OpinionNewsArticle|ReportageNewsArticle|ReviewNewsArticle|Report|SatiricalArticle|ScholarlyArticle|MedicalScholarlyArticle|SocialMediaPosting|BlogPosting|LiveBlogPosting|DiscussionForumPosting|TechArticle|APIReference$/, + // used to see if a node's content matches words commonly used for ad blocks or loading indicators + adWords: /^(ad(vertising|vertisement)?|pub(licité)?|werb(ung)?|广告|Реклама|Anuncio)$/iu, + loadingWords: /^((loading|正在加载|Загрузка|chargement|cargando)(…|\.\.\.)?)$/iu + }, + UNLIKELY_ROLES: [ + "menu", + "menubar", + "complementary", + "navigation", + "alert", + "alertdialog", + "dialog" + ], + DIV_TO_P_ELEMS: /* @__PURE__ */ new Set([ + "BLOCKQUOTE", + "DL", + "DIV", + "IMG", + "OL", + "P", + "PRE", + "TABLE", + "UL" + ]), + ALTER_TO_DIV_EXCEPTIONS: ["DIV", "ARTICLE", "SECTION", "P", "OL", "UL"], + PRESENTATIONAL_ATTRIBUTES: [ + "align", + "background", + "bgcolor", + "border", + "cellpadding", + "cellspacing", + "frame", + "hspace", + "rules", + "style", + "valign", + "vspace" + ], + DEPRECATED_SIZE_ATTRIBUTE_ELEMS: ["TABLE", "TH", "TD", "HR", "PRE"], + // The commented out elements qualify as phrasing content but tend to be + // removed by readability when put into paragraphs, so we ignore them here. + PHRASING_ELEMS: [ + // "CANVAS", "IFRAME", "SVG", "VIDEO", + "ABBR", + "AUDIO", + "B", + "BDO", + "BR", + "BUTTON", + "CITE", + "CODE", + "DATA", + "DATALIST", + "DFN", + "EM", + "EMBED", + "I", + "IMG", + "INPUT", + "KBD", + "LABEL", + "MARK", + "MATH", + "METER", + "NOSCRIPT", + "OBJECT", + "OUTPUT", + "PROGRESS", + "Q", + "RUBY", + "SAMP", + "SCRIPT", + "SELECT", + "SMALL", + "SPAN", + "STRONG", + "SUB", + "SUP", + "TEXTAREA", + "TIME", + "VAR", + "WBR" + ], + // These are the classes that readability sets itself. + CLASSES_TO_PRESERVE: ["page"], + // These are the list of HTML entities that need to be escaped. + HTML_ESCAPE_MAP: { + lt: "<", + gt: ">", + amp: "&", + quot: '"', + apos: "'" + }, + /** + * Run any post-process modifications to article content as necessary. + * + * @param Element + * @return void + **/ + _postProcessContent(articleContent) { + this._fixRelativeUris(articleContent); + this._simplifyNestedElements(articleContent); + if (!this._keepClasses) { + this._cleanClasses(articleContent); + } + }, + /** + * Iterates over a NodeList, calls `filterFn` for each node and removes node + * if function returned `true`. + * + * If function is not passed, removes all the nodes in node list. + * + * @param NodeList nodeList The nodes to operate on + * @param Function filterFn the function to use as a filter + * @return void + */ + _removeNodes(nodeList, filterFn) { + if (this._docJSDOMParser && nodeList._isLiveNodeList) { + throw new Error("Do not pass live node lists to _removeNodes"); + } + for (var i = nodeList.length - 1; i >= 0; i--) { + var node = nodeList[i]; + var parentNode = node.parentNode; + if (parentNode) { + if (!filterFn || filterFn.call(this, node, i, nodeList)) { + parentNode.removeChild(node); + } + } + } + }, + /** + * Iterates over a NodeList, and calls _setNodeTag for each node. + * + * @param NodeList nodeList The nodes to operate on + * @param String newTagName the new tag name to use + * @return void + */ + _replaceNodeTags(nodeList, newTagName) { + if (this._docJSDOMParser && nodeList._isLiveNodeList) { + throw new Error("Do not pass live node lists to _replaceNodeTags"); + } + for (const node of nodeList) { + this._setNodeTag(node, newTagName); + } + }, + /** + * Iterate over a NodeList, which doesn't natively fully implement the Array + * interface. + * + * For convenience, the current object context is applied to the provided + * iterate function. + * + * @param NodeList nodeList The NodeList. + * @param Function fn The iterate function. + * @return void + */ + _forEachNode(nodeList, fn) { + Array.prototype.forEach.call(nodeList, fn, this); + }, + /** + * Iterate over a NodeList, and return the first node that passes + * the supplied test function + * + * For convenience, the current object context is applied to the provided + * test function. + * + * @param NodeList nodeList The NodeList. + * @param Function fn The test function. + * @return void + */ + _findNode(nodeList, fn) { + return Array.prototype.find.call(nodeList, fn, this); + }, + /** + * Iterate over a NodeList, return true if any of the provided iterate + * function calls returns true, false otherwise. + * + * For convenience, the current object context is applied to the + * provided iterate function. + * + * @param NodeList nodeList The NodeList. + * @param Function fn The iterate function. + * @return Boolean + */ + _someNode(nodeList, fn) { + return Array.prototype.some.call(nodeList, fn, this); + }, + /** + * Iterate over a NodeList, return true if all of the provided iterate + * function calls return true, false otherwise. + * + * For convenience, the current object context is applied to the + * provided iterate function. + * + * @param NodeList nodeList The NodeList. + * @param Function fn The iterate function. + * @return Boolean + */ + _everyNode(nodeList, fn) { + return Array.prototype.every.call(nodeList, fn, this); + }, + _getAllNodesWithTag(node, tagNames) { + if (node.querySelectorAll) { + return node.querySelectorAll(tagNames.join(",")); + } + return [].concat.apply( + [], + tagNames.map(function(tag) { + var collection = node.getElementsByTagName(tag); + return Array.isArray(collection) ? collection : Array.from(collection); + }) + ); + }, + /** + * Removes the class="" attribute from every element in the given + * subtree, except those that match CLASSES_TO_PRESERVE and + * the classesToPreserve array from the options object. + * + * @param Element + * @return void + */ + _cleanClasses(node) { + var classesToPreserve = this._classesToPreserve; + var className = (node.getAttribute("class") || "").split(/\s+/).filter((cls) => classesToPreserve.includes(cls)).join(" "); + if (className) { + node.setAttribute("class", className); + } else { + node.removeAttribute("class"); + } + for (node = node.firstElementChild; node; node = node.nextElementSibling) { + this._cleanClasses(node); + } + }, + /** + * Tests whether a string is a URL or not. + * + * @param {string} str The string to test + * @return {boolean} true if str is a URL, false if not + */ + _isUrl(str) { + try { + new URL(str); + return true; + } catch { + return false; + } + }, + /** + * Converts each and uri in the given element to an absolute URI, + * ignoring #ref URIs. + * + * @param Element + * @return void + */ + _fixRelativeUris(articleContent) { + var baseURI = this._doc.baseURI; + var documentURI = this._doc.documentURI; + function toAbsoluteURI(uri) { + if (baseURI == documentURI && uri.charAt(0) == "#") { + return uri; + } + try { + return new URL(uri, baseURI).href; + } catch (ex) { + } + return uri; + } + var links = this._getAllNodesWithTag(articleContent, ["a"]); + this._forEachNode(links, function(link) { + var href = link.getAttribute("href"); + if (href) { + if (href.indexOf("javascript:") === 0) { + if (link.childNodes.length === 1 && link.childNodes[0].nodeType === this.TEXT_NODE) { + var text = this._doc.createTextNode(link.textContent); + link.parentNode.replaceChild(text, link); + } else { + var container = this._doc.createElement("span"); + while (link.firstChild) { + container.appendChild(link.firstChild); + } + link.parentNode.replaceChild(container, link); + } + } else { + link.setAttribute("href", toAbsoluteURI(href)); + } + } + }); + var medias = this._getAllNodesWithTag(articleContent, [ + "img", + "picture", + "figure", + "video", + "audio", + "source" + ]); + this._forEachNode(medias, function(media) { + var src = media.getAttribute("src"); + var poster = media.getAttribute("poster"); + var srcset = media.getAttribute("srcset"); + if (src) { + media.setAttribute("src", toAbsoluteURI(src)); + } + if (poster) { + media.setAttribute("poster", toAbsoluteURI(poster)); + } + if (srcset) { + var newSrcset = srcset.replace( + this.REGEXPS.srcsetUrl, + function(_, p1, p2, p3) { + return toAbsoluteURI(p1) + (p2 || "") + p3; + } + ); + media.setAttribute("srcset", newSrcset); + } + }); + }, + _simplifyNestedElements(articleContent) { + var node = articleContent; + while (node) { + if (node.parentNode && ["DIV", "SECTION"].includes(node.tagName) && !(node.id && node.id.startsWith("readability"))) { + if (this._isElementWithoutContent(node)) { + node = this._removeAndGetNext(node); + continue; + } else if (this._hasSingleTagInsideElement(node, "DIV") || this._hasSingleTagInsideElement(node, "SECTION")) { + var child = node.children[0]; + for (var i = 0; i < node.attributes.length; i++) { + child.setAttributeNode(node.attributes[i].cloneNode()); + } + node.parentNode.replaceChild(child, node); + node = child; + continue; + } + } + node = this._getNextNode(node); + } + }, + /** + * Get the article title as an H1. + * + * @return string + **/ + _getArticleTitle() { + var doc = this._doc; + var curTitle = ""; + var origTitle = ""; + try { + curTitle = origTitle = doc.title.trim(); + if (typeof curTitle !== "string") { + curTitle = origTitle = this._getInnerText( + doc.getElementsByTagName("title")[0] + ); + } + } catch (e) { + } + var titleHadHierarchicalSeparators = false; + function wordCount(str) { + return str.split(/\s+/).length; + } + if (/ [\|\-\\\/>»] /.test(curTitle)) { + titleHadHierarchicalSeparators = / [\\\/>»] /.test(curTitle); + let allSeparators = Array.from(origTitle.matchAll(/ [\|\-\\\/>»] /gi)); + curTitle = origTitle.substring(0, allSeparators.pop().index); + if (wordCount(curTitle) < 3) { + curTitle = origTitle.replace(/^[^\|\-\\\/>»]*[\|\-\\\/>»]/gi, ""); + } + } else if (curTitle.includes(": ")) { + var headings = this._getAllNodesWithTag(doc, ["h1", "h2"]); + var trimmedTitle = curTitle.trim(); + var match = this._someNode(headings, function(heading) { + return heading.textContent.trim() === trimmedTitle; + }); + if (!match) { + curTitle = origTitle.substring(origTitle.lastIndexOf(":") + 1); + if (wordCount(curTitle) < 3) { + curTitle = origTitle.substring(origTitle.indexOf(":") + 1); + } else if (wordCount(origTitle.substr(0, origTitle.indexOf(":"))) > 5) { + curTitle = origTitle; + } + } + } else if (curTitle.length > 150 || curTitle.length < 15) { + var hOnes = doc.getElementsByTagName("h1"); + if (hOnes.length === 1) { + curTitle = this._getInnerText(hOnes[0]); + } + } + curTitle = curTitle.trim().replace(this.REGEXPS.normalize, " "); + var curTitleWordCount = wordCount(curTitle); + if (curTitleWordCount <= 4 && (!titleHadHierarchicalSeparators || curTitleWordCount != wordCount(origTitle.replace(/[\|\-\\\/>»]+/g, "")) - 1)) { + curTitle = origTitle; + } + return curTitle; + }, + /** + * Prepare the HTML document for readability to scrape it. + * This includes things like stripping javascript, CSS, and handling terrible markup. + * + * @return void + **/ + _prepDocument() { + var doc = this._doc; + this._removeNodes(this._getAllNodesWithTag(doc, ["style"])); + if (doc.body) { + this._replaceBrs(doc.body); + } + this._replaceNodeTags(this._getAllNodesWithTag(doc, ["font"]), "SPAN"); + }, + /** + * Finds the next node, starting from the given node, and ignoring + * whitespace in between. If the given node is an element, the same node is + * returned. + */ + _nextNode(node) { + var next = node; + while (next && next.nodeType != this.ELEMENT_NODE && this.REGEXPS.whitespace.test(next.textContent)) { + next = next.nextSibling; + } + return next; + }, + /** + * Replaces 2 or more successive
      elements with a single

      . + * Whitespace between
      elements are ignored. For example: + *

      foo
      bar


      abc
      + * will become: + *
      foo
      bar

      abc

      + */ + _replaceBrs(elem) { + this._forEachNode(this._getAllNodesWithTag(elem, ["br"]), function(br) { + var next = br.nextSibling; + var replaced = false; + while ((next = this._nextNode(next)) && next.tagName == "BR") { + replaced = true; + var brSibling = next.nextSibling; + next.remove(); + next = brSibling; + } + if (replaced) { + var p = this._doc.createElement("p"); + br.parentNode.replaceChild(p, br); + next = p.nextSibling; + while (next) { + if (next.tagName == "BR") { + var nextElem = this._nextNode(next.nextSibling); + if (nextElem && nextElem.tagName == "BR") { + break; + } + } + if (!this._isPhrasingContent(next)) { + break; + } + var sibling = next.nextSibling; + p.appendChild(next); + next = sibling; + } + while (p.lastChild && this._isWhitespace(p.lastChild)) { + p.lastChild.remove(); + } + if (p.parentNode.tagName === "P") { + this._setNodeTag(p.parentNode, "DIV"); + } + } + }); + }, + _setNodeTag(node, tag) { + this.log("_setNodeTag", node, tag); + if (this._docJSDOMParser) { + node.localName = tag.toLowerCase(); + node.tagName = tag.toUpperCase(); + return node; + } + var replacement = node.ownerDocument.createElement(tag); + while (node.firstChild) { + replacement.appendChild(node.firstChild); + } + node.parentNode.replaceChild(replacement, node); + if (node.readability) { + replacement.readability = node.readability; + } + for (var i = 0; i < node.attributes.length; i++) { + replacement.setAttributeNode(node.attributes[i].cloneNode()); + } + return replacement; + }, + /** + * Prepare the article node for display. Clean out any inline styles, + * iframes, forms, strip extraneous

      tags, etc. + * + * @param Element + * @return void + **/ + _prepArticle(articleContent) { + this._cleanStyles(articleContent); + this._markDataTables(articleContent); + this._fixLazyImages(articleContent); + this._cleanConditionally(articleContent, "form"); + this._cleanConditionally(articleContent, "fieldset"); + this._clean(articleContent, "object"); + this._clean(articleContent, "embed"); + this._clean(articleContent, "footer"); + this._clean(articleContent, "link"); + this._clean(articleContent, "aside"); + var shareElementThreshold = this.DEFAULT_CHAR_THRESHOLD; + this._forEachNode(articleContent.children, function(topCandidate) { + this._cleanMatchedNodes(topCandidate, function(node, matchString) { + return this.REGEXPS.shareElements.test(matchString) && node.textContent.length < shareElementThreshold; + }); + }); + this._clean(articleContent, "iframe"); + this._clean(articleContent, "input"); + this._clean(articleContent, "textarea"); + this._clean(articleContent, "select"); + this._clean(articleContent, "button"); + this._cleanHeaders(articleContent); + this._cleanConditionally(articleContent, "table"); + this._cleanConditionally(articleContent, "ul"); + this._cleanConditionally(articleContent, "div"); + this._replaceNodeTags( + this._getAllNodesWithTag(articleContent, ["h1"]), + "h2" + ); + this._removeNodes( + this._getAllNodesWithTag(articleContent, ["p"]), + function(paragraph) { + var contentElementCount = this._getAllNodesWithTag(paragraph, [ + "img", + "embed", + "object", + "iframe" + ]).length; + return contentElementCount === 0 && !this._getInnerText(paragraph, false); + } + ); + this._forEachNode( + this._getAllNodesWithTag(articleContent, ["br"]), + function(br) { + var next = this._nextNode(br.nextSibling); + if (next && next.tagName == "P") { + br.remove(); + } + } + ); + this._forEachNode( + this._getAllNodesWithTag(articleContent, ["table"]), + function(table) { + var tbody = this._hasSingleTagInsideElement(table, "TBODY") ? table.firstElementChild : table; + if (this._hasSingleTagInsideElement(tbody, "TR")) { + var row = tbody.firstElementChild; + if (this._hasSingleTagInsideElement(row, "TD")) { + var cell = row.firstElementChild; + cell = this._setNodeTag( + cell, + this._everyNode(cell.childNodes, this._isPhrasingContent) ? "P" : "DIV" + ); + table.parentNode.replaceChild(cell, table); + } + } + } + ); + }, + /** + * Initialize a node with the readability object. Also checks the + * className/id for special names to add to its score. + * + * @param Element + * @return void + **/ + _initializeNode(node) { + node.readability = { contentScore: 0 }; + switch (node.tagName) { + case "DIV": + node.readability.contentScore += 5; + break; + case "PRE": + case "TD": + case "BLOCKQUOTE": + node.readability.contentScore += 3; + break; + case "ADDRESS": + case "OL": + case "UL": + case "DL": + case "DD": + case "DT": + case "LI": + case "FORM": + node.readability.contentScore -= 3; + break; + case "H1": + case "H2": + case "H3": + case "H4": + case "H5": + case "H6": + case "TH": + node.readability.contentScore -= 5; + break; + } + node.readability.contentScore += this._getClassWeight(node); + }, + _removeAndGetNext(node) { + var nextNode = this._getNextNode(node, true); + node.remove(); + return nextNode; + }, + /** + * Traverse the DOM from node to node, starting at the node passed in. + * Pass true for the second parameter to indicate this node itself + * (and its kids) are going away, and we want the next node over. + * + * Calling this in a loop will traverse the DOM depth-first. + * + * @param {Element} node + * @param {boolean} ignoreSelfAndKids + * @return {Element} + */ + _getNextNode(node, ignoreSelfAndKids) { + if (!ignoreSelfAndKids && node.firstElementChild) { + return node.firstElementChild; + } + if (node.nextElementSibling) { + return node.nextElementSibling; + } + do { + node = node.parentNode; + } while (node && !node.nextElementSibling); + return node && node.nextElementSibling; + }, + // compares second text to first one + // 1 = same text, 0 = completely different text + // works the way that it splits both texts into words and then finds words that are unique in second text + // the result is given by the lower length of unique parts + _textSimilarity(textA, textB) { + var tokensA = textA.toLowerCase().split(this.REGEXPS.tokenize).filter(Boolean); + var tokensB = textB.toLowerCase().split(this.REGEXPS.tokenize).filter(Boolean); + if (!tokensA.length || !tokensB.length) { + return 0; + } + var uniqTokensB = tokensB.filter((token) => !tokensA.includes(token)); + var distanceB = uniqTokensB.join(" ").length / tokensB.join(" ").length; + return 1 - distanceB; + }, + /** + * Checks whether an element node contains a valid byline + * + * @param node {Element} + * @param matchString {string} + * @return boolean + */ + _isValidByline(node, matchString) { + var rel = node.getAttribute("rel"); + var itemprop = node.getAttribute("itemprop"); + var bylineLength = node.textContent.trim().length; + return (rel === "author" || itemprop && itemprop.includes("author") || this.REGEXPS.byline.test(matchString)) && !!bylineLength && bylineLength < 100; + }, + _getNodeAncestors(node, maxDepth) { + maxDepth = maxDepth || 0; + var i = 0, ancestors = []; + while (node.parentNode) { + ancestors.push(node.parentNode); + if (maxDepth && ++i === maxDepth) { + break; + } + node = node.parentNode; + } + return ancestors; + }, + /*** + * grabArticle - Using a variety of metrics (content score, classname, element types), find the content that is + * most likely to be the stuff a user wants to read. Then return it wrapped up in a div. + * + * @param page a document to run upon. Needs to be a full document, complete with body. + * @return Element + **/ + /* eslint-disable-next-line complexity */ + _grabArticle(page) { + this.log("**** grabArticle ****"); + var doc = this._doc; + var isPaging = page !== null; + page = page ? page : this._doc.body; + if (!page) { + this.log("No body found in document. Abort."); + return null; + } + var pageCacheHtml = page.innerHTML; + while (true) { + this.log("Starting grabArticle loop"); + var stripUnlikelyCandidates = this._flagIsActive( + this.FLAG_STRIP_UNLIKELYS + ); + var elementsToScore = []; + var node = this._doc.documentElement; + let shouldRemoveTitleHeader = true; + while (node) { + if (node.tagName === "HTML") { + this._articleLang = node.getAttribute("lang"); + } + var matchString = node.className + " " + node.id; + if (!this._isProbablyVisible(node)) { + this.log("Removing hidden node - " + matchString); + node = this._removeAndGetNext(node); + continue; + } + if (node.getAttribute("aria-modal") == "true" && node.getAttribute("role") == "dialog") { + node = this._removeAndGetNext(node); + continue; + } + if (!this._articleByline && !this._metadata.byline && this._isValidByline(node, matchString)) { + var endOfSearchMarkerNode = this._getNextNode(node, true); + var next = this._getNextNode(node); + var itemPropNameNode = null; + while (next && next != endOfSearchMarkerNode) { + var itemprop = next.getAttribute("itemprop"); + if (itemprop && itemprop.includes("name")) { + itemPropNameNode = next; + break; + } else { + next = this._getNextNode(next); + } + } + this._articleByline = (itemPropNameNode ?? node).textContent.trim(); + node = this._removeAndGetNext(node); + continue; + } + if (shouldRemoveTitleHeader && this._headerDuplicatesTitle(node)) { + this.log( + "Removing header: ", + node.textContent.trim(), + this._articleTitle.trim() + ); + shouldRemoveTitleHeader = false; + node = this._removeAndGetNext(node); + continue; + } + if (stripUnlikelyCandidates) { + if (this.REGEXPS.unlikelyCandidates.test(matchString) && !this.REGEXPS.okMaybeItsACandidate.test(matchString) && !this._hasAncestorTag(node, "table") && !this._hasAncestorTag(node, "code") && node.tagName !== "BODY" && node.tagName !== "A") { + this.log("Removing unlikely candidate - " + matchString); + node = this._removeAndGetNext(node); + continue; + } + if (this.UNLIKELY_ROLES.includes(node.getAttribute("role"))) { + this.log( + "Removing content with role " + node.getAttribute("role") + " - " + matchString + ); + node = this._removeAndGetNext(node); + continue; + } + } + if ((node.tagName === "DIV" || node.tagName === "SECTION" || node.tagName === "HEADER" || node.tagName === "H1" || node.tagName === "H2" || node.tagName === "H3" || node.tagName === "H4" || node.tagName === "H5" || node.tagName === "H6") && this._isElementWithoutContent(node)) { + node = this._removeAndGetNext(node); + continue; + } + if (this.DEFAULT_TAGS_TO_SCORE.includes(node.tagName)) { + elementsToScore.push(node); + } + if (node.tagName === "DIV") { + var p = null; + var childNode = node.firstChild; + while (childNode) { + var nextSibling = childNode.nextSibling; + if (this._isPhrasingContent(childNode)) { + if (p !== null) { + p.appendChild(childNode); + } else if (!this._isWhitespace(childNode)) { + p = doc.createElement("p"); + node.replaceChild(p, childNode); + p.appendChild(childNode); + } + } else if (p !== null) { + while (p.lastChild && this._isWhitespace(p.lastChild)) { + p.lastChild.remove(); + } + p = null; + } + childNode = nextSibling; + } + if (this._hasSingleTagInsideElement(node, "P") && this._getLinkDensity(node) < 0.25) { + var newNode = node.children[0]; + node.parentNode.replaceChild(newNode, node); + node = newNode; + elementsToScore.push(node); + } else if (!this._hasChildBlockElement(node)) { + node = this._setNodeTag(node, "P"); + elementsToScore.push(node); + } + } + node = this._getNextNode(node); + } + var candidates = []; + this._forEachNode(elementsToScore, function(elementToScore) { + if (!elementToScore.parentNode || typeof elementToScore.parentNode.tagName === "undefined") { + return; + } + var innerText = this._getInnerText(elementToScore); + if (innerText.length < 25) { + return; + } + var ancestors2 = this._getNodeAncestors(elementToScore, 5); + if (ancestors2.length === 0) { + return; + } + var contentScore = 0; + contentScore += 1; + contentScore += innerText.split(this.REGEXPS.commas).length; + contentScore += Math.min(Math.floor(innerText.length / 100), 3); + this._forEachNode(ancestors2, function(ancestor, level) { + if (!ancestor.tagName || !ancestor.parentNode || typeof ancestor.parentNode.tagName === "undefined") { + return; + } + if (typeof ancestor.readability === "undefined") { + this._initializeNode(ancestor); + candidates.push(ancestor); + } + if (level === 0) { + var scoreDivider = 1; + } else if (level === 1) { + scoreDivider = 2; + } else { + scoreDivider = level * 3; + } + ancestor.readability.contentScore += contentScore / scoreDivider; + }); + }); + var topCandidates = []; + for (var c = 0, cl = candidates.length; c < cl; c += 1) { + var candidate = candidates[c]; + var candidateScore = candidate.readability.contentScore * (1 - this._getLinkDensity(candidate)); + candidate.readability.contentScore = candidateScore; + this.log("Candidate:", candidate, "with score " + candidateScore); + for (var t = 0; t < this._nbTopCandidates; t++) { + var aTopCandidate = topCandidates[t]; + if (!aTopCandidate || candidateScore > aTopCandidate.readability.contentScore) { + topCandidates.splice(t, 0, candidate); + if (topCandidates.length > this._nbTopCandidates) { + topCandidates.pop(); + } + break; + } + } + } + var topCandidate = topCandidates[0] || null; + var neededToCreateTopCandidate = false; + var parentOfTopCandidate; + if (topCandidate === null || topCandidate.tagName === "BODY") { + topCandidate = doc.createElement("DIV"); + neededToCreateTopCandidate = true; + while (page.firstChild) { + this.log("Moving child out:", page.firstChild); + topCandidate.appendChild(page.firstChild); + } + page.appendChild(topCandidate); + this._initializeNode(topCandidate); + } else if (topCandidate) { + var alternativeCandidateAncestors = []; + for (var i = 1; i < topCandidates.length; i++) { + if (topCandidates[i].readability.contentScore / topCandidate.readability.contentScore >= 0.75) { + alternativeCandidateAncestors.push( + this._getNodeAncestors(topCandidates[i]) + ); + } + } + var MINIMUM_TOPCANDIDATES = 3; + if (alternativeCandidateAncestors.length >= MINIMUM_TOPCANDIDATES) { + parentOfTopCandidate = topCandidate.parentNode; + while (parentOfTopCandidate.tagName !== "BODY") { + var listsContainingThisAncestor = 0; + for (var ancestorIndex = 0; ancestorIndex < alternativeCandidateAncestors.length && listsContainingThisAncestor < MINIMUM_TOPCANDIDATES; ancestorIndex++) { + listsContainingThisAncestor += Number( + alternativeCandidateAncestors[ancestorIndex].includes( + parentOfTopCandidate + ) + ); + } + if (listsContainingThisAncestor >= MINIMUM_TOPCANDIDATES) { + topCandidate = parentOfTopCandidate; + break; + } + parentOfTopCandidate = parentOfTopCandidate.parentNode; + } + } + if (!topCandidate.readability) { + this._initializeNode(topCandidate); + } + parentOfTopCandidate = topCandidate.parentNode; + var lastScore = topCandidate.readability.contentScore; + var scoreThreshold = lastScore / 3; + while (parentOfTopCandidate.tagName !== "BODY") { + if (!parentOfTopCandidate.readability) { + parentOfTopCandidate = parentOfTopCandidate.parentNode; + continue; + } + var parentScore = parentOfTopCandidate.readability.contentScore; + if (parentScore < scoreThreshold) { + break; + } + if (parentScore > lastScore) { + topCandidate = parentOfTopCandidate; + break; + } + lastScore = parentOfTopCandidate.readability.contentScore; + parentOfTopCandidate = parentOfTopCandidate.parentNode; + } + parentOfTopCandidate = topCandidate.parentNode; + while (parentOfTopCandidate.tagName != "BODY" && parentOfTopCandidate.children.length == 1) { + topCandidate = parentOfTopCandidate; + parentOfTopCandidate = topCandidate.parentNode; + } + if (!topCandidate.readability) { + this._initializeNode(topCandidate); + } + } + var articleContent = doc.createElement("DIV"); + if (isPaging) { + articleContent.id = "readability-content"; + } + var siblingScoreThreshold = Math.max( + 10, + topCandidate.readability.contentScore * 0.2 + ); + parentOfTopCandidate = topCandidate.parentNode; + var siblings = parentOfTopCandidate.children; + for (var s = 0, sl = siblings.length; s < sl; s++) { + var sibling = siblings[s]; + var append = false; + this.log( + "Looking at sibling node:", + sibling, + sibling.readability ? "with score " + sibling.readability.contentScore : "" + ); + this.log( + "Sibling has score", + sibling.readability ? sibling.readability.contentScore : "Unknown" + ); + if (sibling === topCandidate) { + append = true; + } else { + var contentBonus = 0; + if (sibling.className === topCandidate.className && topCandidate.className !== "") { + contentBonus += topCandidate.readability.contentScore * 0.2; + } + if (sibling.readability && sibling.readability.contentScore + contentBonus >= siblingScoreThreshold) { + append = true; + } else if (sibling.nodeName === "P") { + var linkDensity = this._getLinkDensity(sibling); + var nodeContent = this._getInnerText(sibling); + var nodeLength = nodeContent.length; + if (nodeLength > 80 && linkDensity < 0.25) { + append = true; + } else if (nodeLength < 80 && nodeLength > 0 && linkDensity === 0 && nodeContent.search(/\.( |$)/) !== -1) { + append = true; + } + } + } + if (append) { + this.log("Appending node:", sibling); + if (!this.ALTER_TO_DIV_EXCEPTIONS.includes(sibling.nodeName)) { + this.log("Altering sibling:", sibling, "to div."); + sibling = this._setNodeTag(sibling, "DIV"); + } + articleContent.appendChild(sibling); + siblings = parentOfTopCandidate.children; + s -= 1; + sl -= 1; + } + } + if (this._debug) { + this.log("Article content pre-prep: " + articleContent.innerHTML); + } + this._prepArticle(articleContent); + if (this._debug) { + this.log("Article content post-prep: " + articleContent.innerHTML); + } + if (neededToCreateTopCandidate) { + topCandidate.id = "readability-page-1"; + topCandidate.className = "page"; + } else { + var div = doc.createElement("DIV"); + div.id = "readability-page-1"; + div.className = "page"; + while (articleContent.firstChild) { + div.appendChild(articleContent.firstChild); + } + articleContent.appendChild(div); + } + if (this._debug) { + this.log("Article content after paging: " + articleContent.innerHTML); + } + var parseSuccessful = true; + var textLength = this._getInnerText(articleContent, true).length; + if (textLength < this._charThreshold) { + parseSuccessful = false; + page.innerHTML = pageCacheHtml; + this._attempts.push({ + articleContent, + textLength + }); + if (this._flagIsActive(this.FLAG_STRIP_UNLIKELYS)) { + this._removeFlag(this.FLAG_STRIP_UNLIKELYS); + } else if (this._flagIsActive(this.FLAG_WEIGHT_CLASSES)) { + this._removeFlag(this.FLAG_WEIGHT_CLASSES); + } else if (this._flagIsActive(this.FLAG_CLEAN_CONDITIONALLY)) { + this._removeFlag(this.FLAG_CLEAN_CONDITIONALLY); + } else { + this._attempts.sort(function(a, b) { + return b.textLength - a.textLength; + }); + if (!this._attempts[0].textLength) { + return null; + } + articleContent = this._attempts[0].articleContent; + parseSuccessful = true; + } + } + if (parseSuccessful) { + var ancestors = [parentOfTopCandidate, topCandidate].concat( + this._getNodeAncestors(parentOfTopCandidate) + ); + this._someNode(ancestors, function(ancestor) { + if (!ancestor.tagName) { + return false; + } + var articleDir = ancestor.getAttribute("dir"); + if (articleDir) { + this._articleDir = articleDir; + return true; + } + return false; + }); + return articleContent; + } + } + }, + /** + * Converts some of the common HTML entities in string to their corresponding characters. + * + * @param str {string} - a string to unescape. + * @return string without HTML entity. + */ + _unescapeHtmlEntities(str) { + if (!str) { + return str; + } + var htmlEscapeMap = this.HTML_ESCAPE_MAP; + return str.replace(/&(quot|amp|apos|lt|gt);/g, function(_, tag) { + return htmlEscapeMap[tag]; + }).replace(/&#(?:x([0-9a-f]+)|([0-9]+));/gi, function(_, hex, numStr) { + var num = parseInt(hex || numStr, hex ? 16 : 10); + if (num == 0 || num > 1114111 || num >= 55296 && num <= 57343) { + num = 65533; + } + return String.fromCodePoint(num); + }); + }, + /** + * Try to extract metadata from JSON-LD object. + * For now, only Schema.org objects of type Article or its subtypes are supported. + * @return Object with any metadata that could be extracted (possibly none) + */ + _getJSONLD(doc) { + var scripts = this._getAllNodesWithTag(doc, ["script"]); + var metadata; + this._forEachNode(scripts, function(jsonLdElement) { + if (!metadata && jsonLdElement.getAttribute("type") === "application/ld+json") { + try { + var content = jsonLdElement.textContent.replace( + /^\s*\s*$/g, + "" + ); + var parsed = JSON.parse(content); + if (Array.isArray(parsed)) { + parsed = parsed.find((it) => { + return it["@type"] && it["@type"].match(this.REGEXPS.jsonLdArticleTypes); + }); + if (!parsed) { + return; + } + } + var schemaDotOrgRegex = /^https?\:\/\/schema\.org\/?$/; + var matches = typeof parsed["@context"] === "string" && parsed["@context"].match(schemaDotOrgRegex) || typeof parsed["@context"] === "object" && typeof parsed["@context"]["@vocab"] == "string" && parsed["@context"]["@vocab"].match(schemaDotOrgRegex); + if (!matches) { + return; + } + if (!parsed["@type"] && Array.isArray(parsed["@graph"])) { + parsed = parsed["@graph"].find((it) => { + return (it["@type"] || "").match(this.REGEXPS.jsonLdArticleTypes); + }); + } + if (!parsed || !parsed["@type"] || !parsed["@type"].match(this.REGEXPS.jsonLdArticleTypes)) { + return; + } + metadata = {}; + if (typeof parsed.name === "string" && typeof parsed.headline === "string" && parsed.name !== parsed.headline) { + var title = this._getArticleTitle(); + var nameMatches = this._textSimilarity(parsed.name, title) > 0.75; + var headlineMatches = this._textSimilarity(parsed.headline, title) > 0.75; + if (headlineMatches && !nameMatches) { + metadata.title = parsed.headline; + } else { + metadata.title = parsed.name; + } + } else if (typeof parsed.name === "string") { + metadata.title = parsed.name.trim(); + } else if (typeof parsed.headline === "string") { + metadata.title = parsed.headline.trim(); + } + if (parsed.author) { + if (typeof parsed.author.name === "string") { + metadata.byline = parsed.author.name.trim(); + } else if (Array.isArray(parsed.author) && parsed.author[0] && typeof parsed.author[0].name === "string") { + metadata.byline = parsed.author.filter(function(author) { + return author && typeof author.name === "string"; + }).map(function(author) { + return author.name.trim(); + }).join(", "); + } + } + if (typeof parsed.description === "string") { + metadata.excerpt = parsed.description.trim(); + } + if (parsed.publisher && typeof parsed.publisher.name === "string") { + metadata.siteName = parsed.publisher.name.trim(); + } + if (typeof parsed.datePublished === "string") { + metadata.datePublished = parsed.datePublished.trim(); + } + } catch (err) { + this.log(err.message); + } + } + }); + return metadata ? metadata : {}; + }, + /** + * Attempts to get excerpt and byline metadata for the article. + * + * @param {Object} jsonld — object containing any metadata that + * could be extracted from JSON-LD object. + * + * @return Object with optional "excerpt" and "byline" properties + */ + _getArticleMetadata(jsonld) { + var metadata = {}; + var values = {}; + var metaElements = this._doc.getElementsByTagName("meta"); + var propertyPattern = /\s*(article|dc|dcterm|og|twitter)\s*:\s*(author|creator|description|published_time|title|site_name)\s*/gi; + var namePattern = /^\s*(?:(dc|dcterm|og|twitter|parsely|weibo:(article|webpage))\s*[-\.:]\s*)?(author|creator|pub-date|description|title|site_name)\s*$/i; + this._forEachNode(metaElements, function(element) { + var elementName = element.getAttribute("name"); + var elementProperty = element.getAttribute("property"); + var content = element.getAttribute("content"); + if (!content) { + return; + } + var matches = null; + var name = null; + if (elementProperty) { + matches = elementProperty.match(propertyPattern); + if (matches) { + name = matches[0].toLowerCase().replace(/\s/g, ""); + values[name] = content.trim(); + } + } + if (!matches && elementName && namePattern.test(elementName)) { + name = elementName; + if (content) { + name = name.toLowerCase().replace(/\s/g, "").replace(/\./g, ":"); + values[name] = content.trim(); + } + } + }); + metadata.title = jsonld.title || values["dc:title"] || values["dcterm:title"] || values["og:title"] || values["weibo:article:title"] || values["weibo:webpage:title"] || values.title || values["twitter:title"] || values["parsely-title"]; + if (!metadata.title) { + metadata.title = this._getArticleTitle(); + } + const articleAuthor = typeof values["article:author"] === "string" && !this._isUrl(values["article:author"]) ? values["article:author"] : void 0; + metadata.byline = jsonld.byline || values["dc:creator"] || values["dcterm:creator"] || values.author || values["parsely-author"] || articleAuthor; + metadata.excerpt = jsonld.excerpt || values["dc:description"] || values["dcterm:description"] || values["og:description"] || values["weibo:article:description"] || values["weibo:webpage:description"] || values.description || values["twitter:description"]; + metadata.siteName = jsonld.siteName || values["og:site_name"]; + metadata.publishedTime = jsonld.datePublished || values["article:published_time"] || values["parsely-pub-date"] || null; + metadata.title = this._unescapeHtmlEntities(metadata.title); + metadata.byline = this._unescapeHtmlEntities(metadata.byline); + metadata.excerpt = this._unescapeHtmlEntities(metadata.excerpt); + metadata.siteName = this._unescapeHtmlEntities(metadata.siteName); + metadata.publishedTime = this._unescapeHtmlEntities(metadata.publishedTime); + return metadata; + }, + /** + * Check if node is image, or if node contains exactly only one image + * whether as a direct child or as its descendants. + * + * @param Element + **/ + _isSingleImage(node) { + while (node) { + if (node.tagName === "IMG") { + return true; + } + if (node.children.length !== 1 || node.textContent.trim() !== "") { + return false; + } + node = node.children[0]; + } + return false; + }, + /** + * Find all

      clean body
      '; + } + throw new Error('Unexpected evaluate call in cleanHTML test'); + }, + }; +} + +function createPageMarkdownPage(content = 'Markdown content line', options = {}) { + const title = options.title === undefined ? 'Markdown Title' : options.title; + return { + isClosed: () => false, + evaluate: async (arg) => { + if (typeof arg === 'function') { + const fnSource = arg.toString(); + if (fnSource.includes('!!globalThis.__readability')) { + return true; + } + if (fnSource.includes('isProbablyReaderable')) { + return { + content, + title, + author: null, + excerpt: null, + siteName: null, + lang: 'en', + publishedTime: null, + wordCount: 3, + readable: true, + }; + } + } + if (typeof arg === 'string') { + return undefined; + } + throw new Error('Unexpected evaluate call in pageMarkdown test'); + }, + }; +} + +function createGoogleSheetsMockPage(cellValues = {}) { + let activeRef = 'A1'; + let editorReadCount = 0; + + const page = { + isClosed: () => false, + url: () => 'https://docs.google.com/spreadsheets/d/test-sheet-id/edit#gid=1', + title: async () => 'Mock Sheet', + locator: (selector) => { + assert.equal(selector, '#t-name-box'); + return { + click: async () => {}, + fill: async (value) => { + activeRef = String(value || '').toUpperCase(); + }, + }; + }, + keyboard: { + press: async () => {}, + }, + waitForTimeout: async () => {}, + evaluate: async (fn, arg) => { + const source = String(fn); + if (arg && typeof arg === 'object' && typeof arg.textValue === 'string') { + cellValues[activeRef] = arg.textValue; + return { after: arg.textValue, lineCount: arg.textValue.split('\n').length }; + } + if (source.includes('createTreeWalker(editor, NodeFilter.SHOW_TEXT)')) { + const text = Object.prototype.hasOwnProperty.call(cellValues, activeRef) + ? String(cellValues[activeRef]) + : ''; + return { + text, + baseStyle: '', + boldRanges: [], + lineCount: text.split('\n').length, + }; + } + if (source.includes('#waffle-rich-text-editor')) { + editorReadCount += 1; + return Object.prototype.hasOwnProperty.call(cellValues, activeRef) + ? String(cellValues[activeRef]) + : ''; + } + throw new Error('Unexpected evaluate call in google-sheets mock'); + }, + }; + + return { + page, + getEditorReadCount: () => editorReadCount, + }; +} + test('plugin helpers are available in execute scope', async () => { const pluginHelpers = { myHelper: async (page, ctx, state, arg) => `result:${arg}`, @@ -15,6 +204,33 @@ test('plugin helpers are available in execute scope', async () => { assert.equal(result, 'result:hello'); }); +test('pluginCatalog and pluginHelp built-ins are available in execute scope', async () => { + const pluginSkillRuntime = { + catalog: [{ + name: 'tagger', + description: 'Tags elements quickly', + helpers: ['tagger'], + sections: ['examples'], + }], + byName: { + tagger: { + text: 'Use tagger() to tag.', + sections: { examples: '- tagger("hero")' }, + }, + }, + }; + + const ctx = buildExecContext(mockPage, mockCtx, {}, {}, {}, {}, {}, pluginSkillRuntime); + const catalog = await runCode('return pluginCatalog()', ctx, 5000); + assert.deepEqual(catalog, pluginSkillRuntime.catalog); + + const defaultHelp = await runCode('return pluginHelp("tagger")', ctx, 5000); + assert.equal(defaultHelp, 'Use tagger() to tag.'); + + const sectionHelp = await runCode('return pluginHelp("tagger", "examples")', ctx, 5000); + assert.equal(sectionHelp, '- tagger("hero")'); +}); + test('built-in helpers always win over plugin helpers with same name', async () => { const pluginHelpers = { snapshot: async () => 'fake-snapshot-string', // attempt to override @@ -27,6 +243,34 @@ test('built-in helpers always win over plugin helpers with same name', async () assert.notEqual(result, 'fake-snapshot-string'); }); +test('plugin helpers cannot override pluginCatalog/pluginHelp built-ins', async () => { + const pluginHelpers = { + pluginCatalog: async () => ['evil'], + pluginHelp: async () => 'evil-help', + }; + const pluginSkillRuntime = { + catalog: [{ name: 'safe', helpers: [], sections: [] }], + byName: { safe: { text: 'safe-help', sections: {} } }, + }; + + const ctx = buildExecContext( + mockPage, + mockCtx, + {}, + {}, + pluginHelpers, + {}, + {}, + pluginSkillRuntime, + ); + + const catalog = await runCode('return pluginCatalog()', ctx, 5000); + assert.deepEqual(catalog, pluginSkillRuntime.catalog); + + const help = await runCode('return pluginHelp("safe")', ctx, 5000); + assert.equal(help, 'safe-help'); +}); + test('plugin helper receives null page gracefully when no page open', async () => { const pluginHelpers = { safeHelper: async (page, ctx, state) => page === null ? 'no-page' : 'has-page', @@ -37,3 +281,441 @@ test('plugin helper receives null page gracefully when no page open', async () = const result = await runCode('return await safeHelper()', ctx, 5000); assert.equal(result, 'no-page'); }); + +test('gsSummarizeSheet reuses cached rows on repeated calls with same options', async () => { + const { default: googleSheetsPlugin } = await import('../../plugins/official/google-sheets/index.js'); + const summarize = googleSheetsPlugin.helpers.gsSummarizeSheet; + const { page, getEditorReadCount } = createGoogleSheetsMockPage({ + A1: 'Level', + B1: 'Expectation', + A2: 'Junior', + B2: 'Owns scoped tasks', + A3: '', + B3: '', + }); + const state = {}; + const options = { + columns: ['A', 'B'], + startRow: 1, + maxRows: 6, + emptyStreakStop: 1, + previewRows: 2, + }; + + const first = await summarize(page, null, state, options); + const readsAfterFirst = getEditorReadCount(); + assert.equal(first.scan.usedRowCount, 2); + assert.ok(readsAfterFirst > 0); + + const second = await summarize(page, null, state, options); + const readsAfterSecond = getEditorReadCount(); + assert.equal(second.scan.usedRowCount, 2); + assert.equal(readsAfterSecond, readsAfterFirst); +}); + +test('gsSummarizeSheet forceRefresh bypasses cache', async () => { + const { default: googleSheetsPlugin } = await import('../../plugins/official/google-sheets/index.js'); + const summarize = googleSheetsPlugin.helpers.gsSummarizeSheet; + const { page, getEditorReadCount } = createGoogleSheetsMockPage({ + A1: 'Level', + B1: 'Expectation', + A2: 'Junior', + B2: 'Owns scoped tasks', + A3: '', + B3: '', + }); + const state = {}; + const options = { + columns: ['A', 'B'], + startRow: 1, + maxRows: 6, + emptyStreakStop: 1, + previewRows: 2, + }; + + await summarize(page, null, state, options); + const readsAfterFirst = getEditorReadCount(); + await summarize(page, null, state, { ...options, forceRefresh: true }); + const readsAfterForceRefresh = getEditorReadCount(); + assert.ok(readsAfterForceRefresh > readsAfterFirst); +}); + +test('gsSummarizeSheet useCache false bypasses cache reads and writes', async () => { + const { default: googleSheetsPlugin } = await import('../../plugins/official/google-sheets/index.js'); + const summarize = googleSheetsPlugin.helpers.gsSummarizeSheet; + const { page, getEditorReadCount } = createGoogleSheetsMockPage({ + A1: 'Level', + B1: 'Expectation', + A2: 'Junior', + B2: 'Owns scoped tasks', + A3: '', + B3: '', + }); + const state = {}; + const options = { + columns: ['A', 'B'], + startRow: 1, + maxRows: 6, + emptyStreakStop: 1, + previewRows: 2, + useCache: false, + }; + + await summarize(page, null, state, options); + const readsAfterFirst = getEditorReadCount(); + await summarize(page, null, state, options); + const readsAfterSecond = getEditorReadCount(); + assert.ok(readsAfterSecond > readsAfterFirst); +}); + +test('gsSplitBulletsInRange invalidates gsSummarizeSheet cache after real write', async () => { + const { default: googleSheetsPlugin } = await import('../../plugins/official/google-sheets/index.js'); + const summarize = googleSheetsPlugin.helpers.gsSummarizeSheet; + const splitBullets = googleSheetsPlugin.helpers.gsSplitBulletsInRange; + const { page, getEditorReadCount } = createGoogleSheetsMockPage({ + A1: 'Level', + B1: 'Expectation', + A2: 'Junior', + B2: 'Owns scoped tasks', + A3: '', + B3: '', + D2: 'Alpha - Beta', + }); + const state = {}; + const summarizeOptions = { + columns: ['A', 'B'], + startRow: 1, + maxRows: 6, + emptyStreakStop: 1, + previewRows: 2, + }; + + await summarize(page, null, state, summarizeOptions); + const readsAfterFirst = getEditorReadCount(); + await summarize(page, null, state, summarizeOptions); + const readsAfterSecond = getEditorReadCount(); + assert.equal(readsAfterSecond, readsAfterFirst); + + const splitResult = await splitBullets(page, null, state, 'D2:D2', { + verify: false, + dryRun: false, + }); + assert.equal(splitResult.changed, 1); + + const readsAfterWrite = getEditorReadCount(); + await summarize(page, null, state, summarizeOptions); + const readsAfterThird = getEditorReadCount(); + assert.ok(readsAfterThird > readsAfterWrite); +}); + +test('gsRebalanceBoldInRange invalidates gsSummarizeSheet cache after real write', async () => { + const { default: googleSheetsPlugin } = await import('../../plugins/official/google-sheets/index.js'); + const summarize = googleSheetsPlugin.helpers.gsSummarizeSheet; + const rebalanceBold = googleSheetsPlugin.helpers.gsRebalanceBoldInRange; + const { page, getEditorReadCount } = createGoogleSheetsMockPage({ + A1: 'Level', + B1: 'Expectation', + A2: 'Junior', + B2: 'Owns scoped tasks', + A3: '', + B3: '', + D2: 'Alpha Beta', + }); + const state = {}; + const summarizeOptions = { + columns: ['A', 'B'], + startRow: 1, + maxRows: 6, + emptyStreakStop: 1, + previewRows: 2, + }; + + await summarize(page, null, state, summarizeOptions); + const readsAfterFirst = getEditorReadCount(); + await summarize(page, null, state, summarizeOptions); + const readsAfterSecond = getEditorReadCount(); + assert.equal(readsAfterSecond, readsAfterFirst); + + const rebalanceResult = await rebalanceBold(page, null, state, 'D2:D2', { + verify: false, + dryRun: false, + preferredPhrases: ['Alpha'], + }); + assert.equal(rebalanceResult.changed, 1); + + const readsAfterWrite = getEditorReadCount(); + await summarize(page, null, state, summarizeOptions); + const readsAfterThird = getEditorReadCount(); + assert.ok(readsAfterThird > readsAfterWrite); +}); + +test('gsFormatBulletsInRange invalidates gsSummarizeSheet cache after real write', async () => { + const { default: googleSheetsPlugin } = await import('../../plugins/official/google-sheets/index.js'); + const summarize = googleSheetsPlugin.helpers.gsSummarizeSheet; + const formatBullets = googleSheetsPlugin.helpers.gsFormatBulletsInRange; + const { page, getEditorReadCount } = createGoogleSheetsMockPage({ + A1: 'Level', + B1: 'Expectation', + A2: 'Junior', + B2: 'Owns scoped tasks', + A3: '', + B3: '', + D2: 'Alpha - Beta', + }); + const state = {}; + const summarizeOptions = { + columns: ['A', 'B'], + startRow: 1, + maxRows: 6, + emptyStreakStop: 1, + previewRows: 2, + }; + + await summarize(page, null, state, summarizeOptions); + const readsAfterFirst = getEditorReadCount(); + await summarize(page, null, state, summarizeOptions); + const readsAfterSecond = getEditorReadCount(); + assert.equal(readsAfterSecond, readsAfterFirst); + + const formatResult = await formatBullets(page, null, state, 'D2:D2', { + verify: false, + dryRun: false, + }); + assert.equal(formatResult.changed, 1); + + const readsAfterWrite = getEditorReadCount(); + await summarize(page, null, state, summarizeOptions); + const readsAfterThird = getEditorReadCount(); + assert.ok(readsAfterThird > readsAfterWrite); +}); + +test('buildExecContext exposes screenshot and content helpers in execute scope', () => { + const ctx = buildExecContext(mockPage, mockCtx, {}, {}, {}); + assert.equal(typeof ctx.screenshotWithAccessibilityLabels, 'function'); + assert.equal(typeof ctx.cleanHTML, 'function'); + assert.equal(typeof ctx.pageMarkdown, 'function'); +}); + +test('screenshotWithAccessibilityLabels runs snapshot and direct screenshot sequentially', async () => { + const page = createLabeledScreenshotPage(); + const ctx = buildExecContext(page, { pages: () => [page] }, {}, {}, {}); + + const result = await ctx.screenshotWithAccessibilityLabels({ interactiveOnly: false }); + const calls = page.getScreenshotCalls(); + const locatorCalls = page.getLocatorCalls(); + + assert.equal(calls.length, 1); + assert.equal(locatorCalls.length, 2); + assert.deepEqual(calls[0], { + type: 'jpeg', + quality: 80, + scale: 'css', + clip: { x: 0, y: 0, width: 1200, height: 700 }, + }); + assert.equal(result._bf_type, 'labeled_screenshot'); + assert.equal(result.screenshot.toString('base64'), page.getScreenshotBuffer().toString('base64')); + assert.ok(result.snapshot.includes('Page: Snapshot Test (https://example.test)')); + assert.ok(result.snapshot.includes('- button "Submit" [ref=e2]')); + assert.equal(result.labelCount, 2); + assert.ok(result.snapshot.includes('- main [ref=e1]')); +}); + +test('buildExecContext exposes callable ref and CDP helpers', async () => { + const fakeSession = { send: async () => ({}) }; + const page = { + isClosed: () => false, + context: () => ({ + newCDPSession: async (targetPage) => { + assert.equal(targetPage, page); + return fakeSession; + }, + }), + }; + + const ctx = buildExecContext(page, { pages: () => [page] }, {}, {}, {}); + assert.equal(typeof ctx.refToLocator, 'function'); + assert.equal(typeof ctx.getCDPSession, 'function'); + + const session = await ctx.getCDPSession({ page }); + assert.equal(session, fakeSession); + + await assert.rejects( + () => ctx.getCDPSession({ page: { isClosed: () => true } }), + /Cannot create CDP session for closed page/ + ); +}); + +test('formatResult returns multi-content for labeled screenshot sentinel', () => { + const fakeBuffer = Buffer.from('fake-jpeg-data'); + const formatted = formatResult({ + _bf_type: 'labeled_screenshot', + screenshot: fakeBuffer, + snapshot: '- button "Submit" [ref=e1]', + labelCount: 1, + }); + + assert.ok(Array.isArray(formatted)); + assert.equal(formatted.length, 2); + assert.deepEqual(formatted[0], { + type: 'image', + data: fakeBuffer.toString('base64'), + mimeType: 'image/jpeg', + }); + assert.equal(formatted[1].type, 'text'); + assert.ok(formatted[1].text.includes('Labels: 1 interactive elements')); +}); + +test('snapshot diff wiring returns full, then no-change guidance, then full when disabled', async () => { + const page = createSnapshotPage(); + const ctx = buildExecContext(page, { pages: () => [page] }, {}, {}, {}); + + const first = await ctx.snapshot({ showDiffSinceLastCall: true }); + assert.ok(first.includes('Page: Snapshot Test (https://example.test)')); + assert.ok(first.includes('- button "Submit" [ref=e1]')); + + const second = await ctx.snapshot({ showDiffSinceLastCall: true }); + assert.ok(second.includes('No changes since last snapshot')); + assert.ok(second.includes('showDiffSinceLastCall: false')); + + const full = await ctx.snapshot({ showDiffSinceLastCall: false }); + assert.ok(full.includes('Page: Snapshot Test (https://example.test)')); +}); + +test('cleanHTML diff wiring returns no-change guidance on identical repeated calls', async () => { + const page = createCleanHtmlPage(); + const ctx = buildExecContext(page, { pages: () => [page] }, {}, {}, {}); + + const first = await ctx.cleanHTML('body', { showDiffSinceLastCall: true }); + assert.ok(first.includes('
      clean body
      ')); + + const second = await ctx.cleanHTML('body', { showDiffSinceLastCall: true }); + assert.ok(second.includes('No changes since last call')); + assert.ok(second.includes('showDiffSinceLastCall: false')); + + const full = await ctx.cleanHTML('body', { showDiffSinceLastCall: false }); + assert.ok(full.includes('
      clean body
      ')); +}); + +test('pageMarkdown option forwarding and diff wiring returns no-change guidance on repeated calls', async () => { + const page = createPageMarkdownPage(); + const ctx = buildExecContext(page, { pages: () => [page] }, {}, {}, {}); + + const first = await ctx.pageMarkdown({ showDiffSinceLastCall: true }); + assert.ok(first.includes('# Markdown Title')); + assert.ok(first.includes('Markdown content line')); + + const second = await ctx.pageMarkdown({ showDiffSinceLastCall: true }); + assert.ok(second.includes('No changes since last call')); + assert.ok(second.includes('showDiffSinceLastCall: false')); + + const full = await ctx.pageMarkdown({ showDiffSinceLastCall: false }); + assert.ok(full.includes('# Markdown Title')); +}); + +test('pageMarkdown search takes precedence over diff mode on repeated calls', async () => { + const page = createPageMarkdownPage('alpha line\nfind me here\nomega line'); + const ctx = buildExecContext(page, { pages: () => [page] }, {}, {}, {}); + + await ctx.pageMarkdown({ showDiffSinceLastCall: true }); + const searched = await ctx.pageMarkdown({ search: 'find me' }); + + assert.ok(searched.includes('find me here')); + assert.ok(!searched.includes('No changes since last call')); +}); + +test('pageMarkdown search resets regex state for g/y regex flags', async () => { + const page = createPageMarkdownPage('target on only line', { title: null }); + const ctx = buildExecContext(page, { pages: () => [page] }, {}, {}, {}); + const search = /target/g; + search.lastIndex = 1; + + const result = await ctx.pageMarkdown({ search, showDiffSinceLastCall: false }); + assert.ok(result.includes('target on only line')); + assert.ok(!result.includes('No matches found')); +}); + +test('getRelayHttpUrl defaults to localhost:19222', () => { + const originalPort = process.env.RELAY_PORT; + const originalCdp = process.env.BF_CDP_URL; + try { + delete process.env.RELAY_PORT; + delete process.env.BF_CDP_URL; + assert.equal(getRelayHttpUrl(), 'http://127.0.0.1:19222'); + } finally { + if (originalPort === undefined) delete process.env.RELAY_PORT; + else process.env.RELAY_PORT = originalPort; + if (originalCdp === undefined) delete process.env.BF_CDP_URL; + else process.env.BF_CDP_URL = originalCdp; + } +}); + +test('getRelayHttpUrl respects BF_CDP_URL override host/port', () => { + const originalCdp = process.env.BF_CDP_URL; + try { + process.env.BF_CDP_URL = 'ws://127.0.0.1:19457/cdp?token=test-token'; + assert.equal(getRelayHttpUrl(), 'http://127.0.0.1:19457'); + } finally { + if (originalCdp === undefined) delete process.env.BF_CDP_URL; + else process.env.BF_CDP_URL = originalCdp; + } +}); + +test('getCdpUrl resolves from /json/version when BF_CDP_URL is not set', async () => { + const originalCdp = process.env.BF_CDP_URL; + const originalPort = process.env.RELAY_PORT; + const originalFetch = globalThis.fetch; + + try { + delete process.env.BF_CDP_URL; + process.env.RELAY_PORT = '19222'; + globalThis.fetch = async (url) => { + assert.equal(url, 'http://127.0.0.1:19222/json/version'); + return { + ok: true, + json: async () => ({ + webSocketDebuggerUrl: 'ws://127.0.0.1:19222/cdp?token=from-json-version', + }), + }; + }; + + const cdpUrl = await getCdpUrl(); + assert.equal(cdpUrl, 'ws://127.0.0.1:19222/cdp?token=from-json-version'); + } finally { + if (originalCdp === undefined) delete process.env.BF_CDP_URL; + else process.env.BF_CDP_URL = originalCdp; + if (originalPort === undefined) delete process.env.RELAY_PORT; + else process.env.RELAY_PORT = originalPort; + globalThis.fetch = originalFetch; + } +}); + +test('assertExtensionConnected throws a clear error when extension is disconnected', async () => { + const originalFetch = globalThis.fetch; + try { + globalThis.fetch = async () => ({ + ok: true, + json: async () => ({ status: 'ok', extension: false }), + }); + await assert.rejects( + () => assertExtensionConnected({ baseUrl: 'http://127.0.0.1:19222' }), + /extension is not connected/i + ); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test('assertExtensionConnected succeeds when extension is connected', async () => { + const originalFetch = globalThis.fetch; + try { + globalThis.fetch = async () => ({ + ok: true, + json: async () => ({ status: 'ok', extension: true }), + }); + await assert.doesNotReject( + () => assertExtensionConnected({ baseUrl: 'http://127.0.0.1:19222' }) + ); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/mcp/test/mcp-plugin-integration.test.js b/mcp/test/mcp-plugin-integration.test.js index ec128c6..1567fd3 100644 --- a/mcp/test/mcp-plugin-integration.test.js +++ b/mcp/test/mcp-plugin-integration.test.js @@ -6,7 +6,12 @@ import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { buildExecContext, runCode } from '../src/exec-engine.js'; -import { loadPlugins, buildPluginHelpers, buildPluginSkillAppendix } from '../src/plugin-loader.js'; +import { + loadPlugins, + buildPluginHelpers, + buildPluginSkillAppendix, + buildPluginSkillRuntime, +} from '../src/plugin-loader.js'; test('plugin helper is callable in execute scope after loadPlugins', async () => { const dir = await mkdtemp(join(tmpdir(), 'bf-mcp-test-')); @@ -33,18 +38,52 @@ test('plugin helper is callable in execute scope after loadPlugins', async () => await rm(dir, { recursive: true }); }); -test('plugin SKILL.md content is included in plugin appendix', async () => { +test('plugin appendix is metadata-only and runtime help remains available', async () => { const dir = await mkdtemp(join(tmpdir(), 'bf-mcp-test-')); const pluginDir = join(dir, 'tagger'); await mkdir(pluginDir); - await writeFile(join(pluginDir, 'index.js'), `export default { name: 'tagger', helpers: {} };`); - await writeFile(join(pluginDir, 'SKILL.md'), 'Use tagger() to tag elements.'); + await writeFile(join(pluginDir, 'index.js'), ` + export default { + name: 'tagger', + helpers: { tagger: () => 'ok' }, + }; + `); + await writeFile(join(pluginDir, 'SKILL.md'), `--- +name: tagger +description: Tags elements with labels. +--- +Use tagger() to tag elements. + +## examples +- tagger('hero')`); const plugins = await loadPlugins(dir); const appendix = buildPluginSkillAppendix(plugins); + const pluginSkillRuntime = buildPluginSkillRuntime(plugins); + const mockPage = { isClosed: () => false, url: () => 'about:blank', title: async () => '' }; + + const ctx = buildExecContext( + mockPage, + { pages: () => [mockPage] }, + {}, + {}, + buildPluginHelpers(plugins), + {}, + {}, + pluginSkillRuntime, + ); assert.ok(appendix.includes('PLUGIN: tagger')); - assert.ok(appendix.includes('Use tagger() to tag elements.')); + assert.ok(appendix.includes('Tags elements with labels.')); + assert.ok(!appendix.includes('Use tagger() to tag elements.')); + + const catalog = await runCode('return pluginCatalog()', ctx, 5000); + assert.equal(Array.isArray(catalog), true); + assert.equal(catalog[0].name, 'tagger'); + assert.equal(catalog[0].description, 'Tags elements with labels.'); + + const help = await runCode('return pluginHelp("tagger", "examples")', ctx, 5000); + assert.ok(help.includes("tagger('hero')")); await rm(dir, { recursive: true }); }); diff --git a/mcp/test/mcp-tools.test.js b/mcp/test/mcp-tools.test.js index c25037c..c3cb82e 100644 --- a/mcp/test/mcp-tools.test.js +++ b/mcp/test/mcp-tools.test.js @@ -56,7 +56,19 @@ describe('Tool Definitions', () => { assert.equal(result, ''); }); - it('registers exactly 3 tools: execute, reset, screenshot_with_labels', () => { + it('index imports only exports available from exec-engine', async () => { + const { execSync } = await import('node:child_process'); + const result = execSync( + 'node --input-type=module -e "import { getCdpUrl, getRelayHttpUrl, getRelayHttpUrlFromCdpUrl, assertExtensionConnected, ensureRelay, connectOverCdpWithBusyRetry, CodeExecutionTimeoutError, buildExecContext, runCode, formatResult } from \'./src/exec-engine.js\'; console.log(typeof getRelayHttpUrlFromCdpUrl, typeof assertExtensionConnected);"', + { + cwd: join(import.meta.url.replace('file://', ''), '../../'), + encoding: 'utf8', + } + ).trim(); + assert.equal(result, 'function function'); + }); + + it('registers exactly 2 tools: execute, reset', () => { const source = readFileSync( join(import.meta.url.replace('file://', ''), '../../src/index.js'), 'utf8' @@ -69,8 +81,8 @@ describe('Tool Definitions', () => { toolNames.push(match[1]); } - assert.equal(toolNames.length, 3, `Should have exactly 3 tools, found ${toolNames.length}: ${toolNames.join(', ')}`); - assert.deepEqual(toolNames.sort(), ['execute', 'reset', 'screenshot_with_labels']); + assert.equal(toolNames.length, 2, `Should have exactly 2 tools, found ${toolNames.length}: ${toolNames.join(', ')}`); + assert.deepEqual(toolNames.sort(), ['execute', 'reset']); }); it('tools have non-empty descriptions', () => { @@ -101,15 +113,108 @@ describe('Tool Definitions', () => { assert.ok(promptBlock.includes('page'), 'should mention page'); assert.ok(promptBlock.includes('context'), 'should mention context'); assert.ok(promptBlock.includes('state'), 'should mention state'); + assert.ok(promptBlock.includes('browserforceRestrictions'), 'should mention browserforceRestrictions for extension policy'); // Key behavioral guidance assert.ok(promptBlock.includes('state.page'), 'should mention state.page for page management'); assert.ok(promptBlock.includes('snapshot'), 'should mention snapshot-first approach'); assert.ok(promptBlock.includes('waitForPageLoad'), 'should mention waitForPageLoad'); + assert.ok(promptBlock.includes('screenshotWithAccessibilityLabels'), 'should mention screenshotWithAccessibilityLabels helper'); + assert.ok(promptBlock.includes('refToLocator({ ref })'), 'should mention refToLocator helper usage'); + assert.ok(promptBlock.includes('getCDPSession({ page })'), 'should mention relay-safe getCDPSession helper usage'); + assert.ok(promptBlock.includes('cleanHTML'), 'should mention cleanHTML helper'); + assert.ok(promptBlock.includes('pageMarkdown'), 'should mention pageMarkdown helper'); + assert.ok(promptBlock.includes('pluginCatalog()'), 'should mention pluginCatalog built-in helper'); + assert.ok(promptBlock.includes('pluginHelp(name, section?)'), 'should mention pluginHelp built-in helper'); + assert.ok(promptBlock.includes('metadata-first'), 'should guide plugin usage as metadata-first'); assert.ok(promptBlock.includes('newPage'), 'should mention creating new tabs'); // Anti-patterns section assert.ok(promptBlock.includes('ANTI-PATTERN') || promptBlock.includes('Don\'t') || promptBlock.includes('✗'), 'should include anti-patterns'); }); + it('execute prompt includes tactical anti-pattern and decision guidance', () => { + const source = readFileSync( + join(import.meta.url.replace('file://', ''), '../../src/index.js'), + 'utf8' + ); + + const promptStart = source.indexOf('const EXECUTE_PROMPT'); + const promptEnd = source.indexOf("server.tool(\n 'execute'"); + const promptBlock = source.slice(promptStart, promptEnd); + + assert.ok(promptBlock.includes('Selector priority'), 'should include selector ranking guidance'); + assert.ok(promptBlock.includes('login popups'), 'should include login popup handling'); + assert.ok(promptBlock.includes('cookie') || promptBlock.includes('consent'), 'should include consent modal handling'); + assert.ok(promptBlock.includes('stale locator'), 'should include stale locator warning'); + assert.ok(promptBlock.includes('snapshot({ showDiffSinceLastCall'), 'should include diff usage guidance'); + assert.ok(promptBlock.includes('options.showDiffSinceLastCall'), 'should document snapshot diff toggle in API reference'); + }); + + it('execute prompt includes tool-selection and debugging decision trees', () => { + const source = readFileSync( + join(import.meta.url.replace('file://', ''), '../../src/index.js'), + 'utf8' + ); + const promptStart = source.indexOf('const EXECUTE_PROMPT'); + const promptEnd = source.indexOf("server.tool(\n 'execute'"); + const promptBlock = source.slice(promptStart, promptEnd); + + assert.ok(promptBlock.includes('snapshot vs cleanHTML vs pageMarkdown'), 'should include extraction decision tree'); + assert.ok(promptBlock.includes('Combine snapshot + logs'), 'should include debugging workflow'); + assert.ok(promptBlock.includes('Authenticated fetch'), 'should include authenticated fetch pattern'); + assert.ok(promptBlock.includes('Downloads'), 'should include download pattern'); + }); + + it('execute prompt includes parallel-first swarm policy and telemetry contract', () => { + const source = readFileSync( + join(import.meta.url.replace('file://', ''), '../../src/index.js'), + 'utf8' + ); + const promptStart = source.indexOf('const EXECUTE_PROMPT'); + const promptEnd = source.indexOf("server.tool(\n 'execute'"); + const promptBlock = source.slice(promptStart, promptEnd); + + assert.ok( + promptBlock.includes('BROWSERFORCE TAB SWARMS // PARALLEL TABS PROCESSING'), + 'should include tab swarm policy section' + ); + assert.ok( + promptBlock.includes('Promise.all with a concurrency cap'), + 'should include parallel-first concurrency guidance' + ); + assert.ok( + promptBlock.includes('Multi-step is allowed for read-only bulk extraction'), + 'should include explicit anti-pattern exception for read-only bulk extraction' + ); + assert.ok(promptBlock.includes('peakConcurrentTasks'), 'should require peakConcurrentTasks telemetry'); + assert.ok(promptBlock.includes('wallClockMs'), 'should require wallClockMs telemetry'); + assert.ok(promptBlock.includes('sumTaskDurationsMs'), 'should require sumTaskDurationsMs telemetry'); + assert.ok(promptBlock.includes('failures'), 'should require failures telemetry'); + assert.ok(promptBlock.includes('retries'), 'should require retries telemetry'); + }); + + it('execute prompt guards against guessed URLs and unsafe single-page parallelism', () => { + const source = readFileSync( + join(import.meta.url.replace('file://', ''), '../../src/index.js'), + 'utf8' + ); + const promptStart = source.indexOf('const EXECUTE_PROMPT'); + const promptEnd = source.indexOf("server.tool(\n 'execute'"); + const promptBlock = source.slice(promptStart, promptEnd); + + assert.ok( + promptBlock.includes('URL DISCOVERY (NO GUESSING)'), + 'should include explicit no-guessing URL discovery guidance' + ); + assert.ok( + promptBlock.includes('Do NOT guess deep links when the site already exposes navigation links'), + 'should require deriving URLs from visible on-page links first' + ); + assert.ok( + promptBlock.includes('Never run Promise.all actions against the same Page object'), + 'should forbid parallel interactions against one page object' + ); + }); + it('execute tool has code and optional timeout params', () => { const source = readFileSync( join(import.meta.url.replace('file://', ''), '../../src/index.js'), @@ -138,31 +243,118 @@ describe('Tool Definitions', () => { assert.ok(paramsMatch, 'reset should have empty params {}'); }); - it('screenshot_with_labels tool has optional selector and interactiveOnly params', () => { + it('does not register screenshot_with_labels tool', () => { + const source = readFileSync( + join(import.meta.url.replace('file://', ''), '../../src/index.js'), + 'utf8' + ); + + assert.ok(!source.includes("'screenshot_with_labels'"), 'screenshot_with_labels tool should be removed'); + assert.ok(!source.includes('SCREENSHOT_LABELS_PROMPT'), 'dedicated screenshot prompt should be removed'); + }); + + it('exec context source exposes refToLocator helper', () => { + const source = readFileSync( + join(import.meta.url.replace('file://', ''), '../../src/exec-engine.js'), + 'utf8' + ); + + assert.ok(source.includes('refToLocator'), 'exec engine should expose refToLocator helper'); + assert.ok(source.includes('const getCDPSession = async'), 'exec engine should define getCDPSession helper'); + assert.ok( + source.includes('No changes since last snapshot. Use showDiffSinceLastCall: false to see full content.'), + 'exec engine should return snapshot no-change guidance' + ); + assert.ok( + source.includes('!selector && !search && showDiffSinceLastCall') || + source.includes('showDiffSinceLastCall && !selector && !search'), + 'snapshot diff mode should only run for full-page snapshots with no search' + ); + }); + + it('execute context includes browserforceSettings', () => { + const source = readFileSync( + join(import.meta.url.replace('file://', ''), '../../src/exec-engine.js'), + 'utf8' + ); + + assert.ok( + source.includes('browserforceSettings'), + 'exec context should expose browserforceSettings in the sandbox scope' + ); + assert.ok( + source.includes('executionMode') && source.includes('parallelVisibilityMode'), + 'browserforceSettings should include executionMode and parallelVisibilityMode' + ); + }); + + it('execute context includes browserforceRestrictions', () => { + const source = readFileSync( + join(import.meta.url.replace('file://', ''), '../../src/exec-engine.js'), + 'utf8' + ); + + assert.ok( + source.includes('browserforceRestrictions'), + 'exec context should expose browserforceRestrictions in the sandbox scope' + ); + assert.ok( + source.includes('lockUrl') && source.includes('noNewTabs') && source.includes('readOnly'), + 'browserforceRestrictions should include lockUrl, noNewTabs, and readOnly flags' + ); + }); + + it('MCP preferences fetch is cached once per session', () => { + const source = readFileSync( + join(import.meta.url.replace('file://', ''), '../../src/index.js'), + 'utf8' + ); + + assert.ok(source.includes('cachedAgentPreferences'), 'should track cached agent preferences'); + assert.ok( + source.includes('if (cachedAgentPreferences)'), + 'should return cached preferences without refetching' + ); + assert.ok( + source.includes('/agent-preferences'), + 'should fetch preferences from relay /agent-preferences endpoint' + ); + }); + + it('MCP restrictions fetch is cached once per session', () => { const source = readFileSync( join(import.meta.url.replace('file://', ''), '../../src/index.js'), 'utf8' ); - const toolBlock = source.split("'screenshot_with_labels'")[1]?.split('server.tool(')[0] || ''; - assert.ok(toolBlock.includes('z.string().optional()'), 'should have optional string param (selector)'); - assert.ok(toolBlock.includes('z.boolean().optional()'), 'should have optional boolean param (interactiveOnly)'); - assert.ok(toolBlock.includes('selector:'), 'should have selector param'); - assert.ok(toolBlock.includes('interactiveOnly:'), 'should have interactiveOnly param'); + assert.ok(source.includes('cachedBrowserforceRestrictions'), 'should track cached browserforce restrictions'); + assert.ok( + source.includes('if (cachedBrowserforceRestrictions)'), + 'should return cached restrictions without refetching' + ); + assert.ok( + source.includes('/restrictions'), + 'should fetch restrictions from relay /restrictions endpoint' + ); }); - it('screenshot_with_labels tool has descriptive prompt', () => { + it('reset clears cached preferences', () => { const source = readFileSync( join(import.meta.url.replace('file://', ''), '../../src/index.js'), 'utf8' ); - assert.ok(source.includes('SCREENSHOT_LABELS_PROMPT'), 'should reference SCREENSHOT_LABELS_PROMPT'); - assert.ok(source.includes('const SCREENSHOT_LABELS_PROMPT'), 'SCREENSHOT_LABELS_PROMPT should be defined'); - const promptIdx = source.indexOf('const SCREENSHOT_LABELS_PROMPT'); - const promptBlock = source.slice(promptIdx, source.indexOf("server.tool(\n 'screenshot_with_labels'")); - assert.ok(promptBlock.includes('color-coded'), 'prompt should mention color coding'); - assert.ok(promptBlock.includes('snapshot'), 'prompt should mention snapshot'); + const resetIdx = source.indexOf("'reset'"); + assert.ok(resetIdx !== -1, 'reset tool should exist'); + const resetBlock = source.slice(resetIdx, resetIdx + 2500); + assert.ok( + resetBlock.includes('cachedAgentPreferences = null'), + 'reset should clear cached agent preferences' + ); + assert.ok( + resetBlock.includes('cachedBrowserforceRestrictions = null'), + 'reset should clear cached browserforce restrictions' + ); }); }); @@ -201,7 +393,7 @@ describe('MCP Response Format', () => { assert.equal(parsed[1].url, 'https://github.com'); }); - it('screenshot_with_labels multi-content format is valid', () => { + it('labeled screenshot multi-content format is valid', () => { const fakeBase64 = Buffer.from('fake-jpeg-data').toString('base64'); const response = { content: [ @@ -504,3 +696,67 @@ describe('smartWaitForPageLoad', () => { assert.equal(expectedShape.timedOut, false); }); }); + +// ─── CDP Busy Helpers ─────────────────────────────────────────────────────── + +describe('CDP Busy Helpers', () => { + it('detects relay slot contention errors', async () => { + const { isCdpBusyError } = await import('../src/exec-engine.js'); + assert.equal(isCdpBusyError(new Error('Unexpected server response: 409')), true); + assert.equal(isCdpBusyError(new Error('ECONNREFUSED')), false); + }); + + it('retries busy connect and succeeds after slot is free', async () => { + const { connectOverCdpWithBusyRetry } = await import('../src/exec-engine.js'); + + let connectCalls = 0; + const expectedBrowser = { connected: true }; + const connect = async () => { + connectCalls += 1; + if (connectCalls === 1) { + throw new Error('Unexpected server response: 409'); + } + return expectedBrowser; + }; + + let waitCalls = 0; + const waitForFreeSlot = async () => { + waitCalls += 1; + return true; + }; + + const browser = await connectOverCdpWithBusyRetry({ + connect, + cdpUrl: 'ws://127.0.0.1:19222/cdp?token=test', + baseUrl: 'http://127.0.0.1:19222', + timeoutMs: 5000, + waitForFreeSlot, + }); + + assert.equal(browser, expectedBrowser); + assert.equal(connectCalls, 2); + assert.equal(waitCalls, 1); + }); + + it('does not retry non-busy connect errors', async () => { + const { connectOverCdpWithBusyRetry } = await import('../src/exec-engine.js'); + + let waitCalls = 0; + const error = new Error('ECONNREFUSED'); + + await assert.rejects( + () => connectOverCdpWithBusyRetry({ + connect: async () => { throw error; }, + cdpUrl: 'ws://127.0.0.1:19222/cdp?token=test', + timeoutMs: 5000, + waitForFreeSlot: async () => { + waitCalls += 1; + return true; + }, + }), + /ECONNREFUSED/ + ); + + assert.equal(waitCalls, 0); + }); +}); diff --git a/mcp/test/openclaw-setup.test.js b/mcp/test/openclaw-setup.test.js new file mode 100644 index 0000000..a7801af --- /dev/null +++ b/mcp/test/openclaw-setup.test.js @@ -0,0 +1,530 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { + applyAutostart, + buildBrowserforceMcpServerEntry, + buildAutostartSpec, + formatJsonStable, + mergeOpenClawConfig, + renderLaunchAgentPlist, + renderSystemdUserService, +} from '../src/openclaw-setup.js'; + +function quoteShellArg(value) { + const stringValue = String(value); + if (process.platform === 'win32') { + return `"${stringValue.replace(/"/g, '""')}"`; + } + return `'${stringValue.replace(/'/g, `'\\''`)}'`; +} + +function buildNodeEvalCommand(source) { + return `${quoteShellArg(process.execPath)} -e ${quoteShellArg(source)}`; +} + +test('buildBrowserforceMcpServerEntry returns stdio sh wrapper with relay autostart on POSIX', () => { + const entry = buildBrowserforceMcpServerEntry({ platform: 'linux' }); + + assert.equal(entry.transport, 'stdio'); + assert.equal(entry.command, 'sh'); + assert.equal(entry.args[0], '-lc'); + assert.match(entry.args[1], /if ! lsof -tiTCP:19222 -sTCP:LISTEN/); + assert.match(entry.args[1], /npx -y browserforce@latest serve/); + assert.match(entry.args[1], /exec npx -y browserforce@latest mcp/); +}); + +test('buildBrowserforceMcpServerEntry returns win32-safe powershell wrapper', () => { + const entry = buildBrowserforceMcpServerEntry({ platform: 'win32' }); + + assert.equal(entry.transport, 'stdio'); + assert.equal(entry.command, 'powershell'); + assert.deepEqual(entry.args.slice(0, 3), ['-NoProfile', '-NonInteractive', '-Command']); + assert.match(entry.args[3], /netstat -ano/); + assert.match(entry.args[3], /browserforce@latest','serve/); + assert.match(entry.args[3], /& npx -y browserforce@latest mcp/); +}); + +test('mergeOpenClawConfig adds and enables plugins.entries["mcp-adapter"]', () => { + const merged = mergeOpenClawConfig({ + plugins: { + entries: { + 'mcp-adapter': { + enabled: false, + }, + }, + }, + }); + + assert.equal(merged.plugins.entries['mcp-adapter'].enabled, true); +}); + +test('mergeOpenClawConfig preserves unrelated keys', () => { + const existing = { + ui: { + theme: 'light', + }, + plugins: { + entries: { + other: { + enabled: false, + config: { foo: 1 }, + }, + }, + }, + tools: { + sandbox: { + tools: { + allow: ['shell'], + deny: ['network'], + }, + }, + }, + }; + + const merged = mergeOpenClawConfig(existing); + + assert.equal(merged.ui.theme, 'light'); + assert.deepEqual(merged.plugins.entries.other, existing.plugins.entries.other); + assert.deepEqual(merged.tools.sandbox.tools.deny, ['network']); +}); + +test('mergeOpenClawConfig preserves existing non-browserforce servers', () => { + const existing = { + plugins: { + entries: { + 'mcp-adapter': { + enabled: false, + config: { + timeoutMs: 1000, + servers: [ + { + name: 'custom', + transport: 'stdio', + command: 'node', + args: ['custom-mcp.js'], + }, + ], + }, + }, + }, + }, + }; + + const merged = mergeOpenClawConfig(existing); + const servers = merged.plugins.entries['mcp-adapter'].config.servers; + + assert.equal(merged.plugins.entries['mcp-adapter'].config.timeoutMs, 1000); + assert.deepEqual(servers.find((server) => server.name === 'custom'), existing.plugins.entries['mcp-adapter'].config.servers[0]); + assert.equal(servers.filter((server) => server.name === 'browserforce').length, 1); +}); + +test('mergeOpenClawConfig updates browserforce server entry once without duplicates', () => { + const existing = { + plugins: { + entries: { + 'mcp-adapter': { + config: { + servers: [ + { name: 'custom', transport: 'stdio', command: 'node', args: ['custom.js'] }, + { name: 'browserforce', transport: 'stdio', command: 'node', args: ['old-browserforce.js'] }, + { name: 'browserforce', transport: 'stdio', command: 'node', args: ['stale-browserforce.js'] }, + ], + }, + }, + }, + }, + }; + + const merged = mergeOpenClawConfig(existing); + const servers = merged.plugins.entries['mcp-adapter'].config.servers; + const browserforceServers = servers.filter((server) => server.name === 'browserforce'); + + assert.equal(browserforceServers.length, 1); + assert.deepEqual(browserforceServers[0], buildBrowserforceMcpServerEntry()); + assert.deepEqual(servers.find((server) => server.name === 'custom'), existing.plugins.entries['mcp-adapter'].config.servers[0]); +}); + +test('mergeOpenClawConfig is idempotent', () => { + const first = mergeOpenClawConfig({ + plugins: { entries: {} }, + tools: { sandbox: { tools: { allow: ['shell'] } } }, + }); + const second = mergeOpenClawConfig(first); + + assert.deepEqual(second, first); +}); + +test('mergeOpenClawConfig writes win32 browserforce server entry without sh', () => { + const merged = mergeOpenClawConfig( + { + plugins: { + entries: { + 'mcp-adapter': { + config: { + servers: [], + }, + }, + }, + }, + }, + { platform: 'win32' }, + ); + const server = merged.plugins.entries['mcp-adapter'].config.servers.find((value) => value.name === 'browserforce'); + + assert.equal(server.command, 'powershell'); + assert.deepEqual(server.args.slice(0, 3), ['-NoProfile', '-NonInteractive', '-Command']); +}); + +test('formatJsonStable uses 2-space indentation and trailing newline', () => { + const out = formatJsonStable({ a: 1, nested: { b: true } }); + assert.equal(out, '{\n "a": 1,\n "nested": {\n "b": true\n }\n}\n'); +}); + +test('mergeOpenClawConfig ensures tools.sandbox.tools.allow includes mcp-adapter once', () => { + const merged = mergeOpenClawConfig({ + tools: { + sandbox: { + tools: { + allow: ['shell', 'mcp-adapter', 'mcp-adapter'], + }, + }, + }, + }); + + const allow = merged.tools.sandbox.tools.allow; + assert.equal(allow.includes('mcp-adapter'), true); + assert.equal(allow.filter((item) => item === 'mcp-adapter').length, 1); + assert.equal(allow.includes('shell'), true); +}); + +test('buildAutostartSpec returns darwin launch agent spec', () => { + const spec = buildAutostartSpec({ + platform: 'darwin', + homeDir: '/Users/alex', + nodePath: '/usr/local/bin/node', + binScriptPath: '/Users/alex/.npm/_npx/browserforce/bin.js', + }); + + assert.equal(spec.platform, 'darwin'); + assert.equal(Array.isArray(spec.filesToWrite), true); + assert.equal(spec.filesToWrite.length, 1); + assert.equal(spec.filesToWrite[0].path, '/Users/alex/Library/LaunchAgents/ai.browserforce.relay.plist'); + assert.match(spec.filesToWrite[0].content, /Label<\/key>\n\s*ai\.browserforce\.relay<\/string>/); + assert.equal(Array.isArray(spec.commands), true); + assert.deepEqual(spec.commands, [ + "launchctl unload '/Users/alex/Library/LaunchAgents/ai.browserforce.relay.plist' >/dev/null 2>&1 || true", + "launchctl load -w '/Users/alex/Library/LaunchAgents/ai.browserforce.relay.plist'", + ]); + assert.equal(typeof spec.summary, 'string'); + assert.notEqual(spec.summary.trim(), ''); + assert.equal(spec.launchAgent.label, 'ai.browserforce.relay'); + assert.equal(spec.launchAgent.plistPath, '/Users/alex/Library/LaunchAgents/ai.browserforce.relay.plist'); + assert.match(spec.launchAgent.programArguments.join(' '), /\/usr\/local\/bin\/node .*\/bin\.js serve/); +}); + +test('buildAutostartSpec returns linux systemd user service spec', () => { + const spec = buildAutostartSpec({ + platform: 'linux', + homeDir: '/home/alex', + nodePath: '/usr/bin/node', + binScriptPath: '/home/alex/.npm/_npx/browserforce/bin.js', + }); + + assert.equal(spec.platform, 'linux'); + assert.equal(Array.isArray(spec.filesToWrite), true); + assert.equal(spec.filesToWrite.length, 1); + assert.equal(spec.filesToWrite[0].path, '/home/alex/.config/systemd/user/browserforce-relay.service'); + assert.match(spec.filesToWrite[0].content, /ExecStart="\/usr\/bin\/node" "\/home\/alex\/\.npm\/_npx\/browserforce\/bin\.js" serve/); + assert.equal(Array.isArray(spec.commands), true); + assert.deepEqual(spec.commands, [ + 'systemctl --user daemon-reload', + 'systemctl --user enable --now browserforce-relay.service', + ]); + assert.equal(typeof spec.summary, 'string'); + assert.notEqual(spec.summary.trim(), ''); + assert.equal(spec.systemd.servicePath, '/home/alex/.config/systemd/user/browserforce-relay.service'); + assert.equal(spec.commands.some((command) => command === 'systemctl --user enable --now browserforce-relay.service'), true); +}); + +test('buildAutostartSpec returns win32 scheduled task spec', () => { + const spec = buildAutostartSpec({ + platform: 'win32', + homeDir: 'C:\\Users\\alex', + nodePath: 'C:\\Program Files\\nodejs\\node.exe', + binScriptPath: 'C:\\Users\\alex\\AppData\\Roaming\\npm\\node_modules\\browserforce\\bin.js', + }); + + assert.equal(spec.platform, 'win32'); + assert.equal(Array.isArray(spec.filesToWrite), true); + assert.deepEqual(spec.filesToWrite, []); + assert.equal(Array.isArray(spec.commands), true); + assert.equal(spec.commands.length, 1); + assert.match(spec.commands[0], /schtasks\s+\/Create/); + assert.equal(typeof spec.summary, 'string'); + assert.notEqual(spec.summary.trim(), ''); + assert.equal(spec.scheduledTask.taskName, 'BrowserForceRelay'); + assert.match(spec.scheduledTask.createCommand, /schtasks\s+\/Create/); + assert.match(spec.scheduledTask.createCommand, /\/SC\s+ONLOGON/); +}); + +test('buildAutostartSpec win32 escapes cmd metacharacters and quotes in /TR payload', () => { + const spec = buildAutostartSpec({ + platform: 'win32', + homeDir: 'C:\\Users\\alex', + nodePath: 'C:\\Program Files\\Tools & Stuff\\100%\\node.exe', + binScriptPath: 'C:\\Users\\alex\\AppData\\Roaming\\npm\\b&f%\\bin"odd".js', + }); + + assert.equal(spec.platform, 'win32'); + assert.match(spec.scheduledTask.commandToRun, /"\S[\s\S]*"\s+"\S[\s\S]*"\s+serve$/); + assert.match(spec.scheduledTask.commandToRun, /\^&/); + assert.match(spec.scheduledTask.commandToRun, /%%/); + assert.match(spec.scheduledTask.commandToRun, /""odd""/); + assert.match(spec.scheduledTask.createCommand, /\/TR\s+"/); + assert.match(spec.scheduledTask.createCommand, /\^&/); + assert.match(spec.scheduledTask.createCommand, /%%/); +}); + +test('buildAutostartSpec throws for unsupported platform', () => { + assert.throws( + () => + buildAutostartSpec({ + platform: 'freebsd', + homeDir: '/home/alex', + nodePath: '/usr/bin/node', + binScriptPath: '/home/alex/bin/browserforce', + }), + /Unsupported platform: freebsd/, + ); +}); + +test('renderLaunchAgentPlist includes expected label, args, and run-at-load keys', () => { + const output = renderLaunchAgentPlist({ + label: 'ai.browserforce.relay', + nodePath: '/usr/local/bin/node', + binScriptPath: '/Users/alex/.npm/_npx/browserforce/bin.js', + }); + + assert.match(output, /Label<\/key>\n\s*ai\.browserforce\.relay<\/string>/); + assert.match(output, /ProgramArguments<\/key>\n\s*\n\s*\/usr\/local\/bin\/node<\/string>\n\s*\/Users\/alex\/\.npm\/_npx\/browserforce\/bin\.js<\/string>\n\s*serve<\/string>\n\s*<\/array>/); + assert.match(output, /RunAtLoad<\/key>\n\s*/); +}); + +test('renderLaunchAgentPlist escapes XML entities in interpolated values', () => { + const output = renderLaunchAgentPlist({ + label: 'relay &