diff --git a/docs/browser-automation.md b/docs/browser-automation.md index 27ba99a6..6ffe5d20 100644 --- a/docs/browser-automation.md +++ b/docs/browser-automation.md @@ -1,6 +1,6 @@ # Browser Automation -Last Updated: 2026-04-16 +Last Updated: 2026-04-17 CCS provides browser automation through two separate runtime paths: @@ -20,6 +20,28 @@ that already has useful authenticated state. Claude Browser Attach requires a browser launched in attach mode with remote debugging enabled. A recent Chrome update alone is not sufficient. +The managed `ccs-browser` runtime currently exposes four tool groups: + +- **Session inspection**: `browser_get_session_info`, `browser_get_url_and_title`, `browser_get_visible_text`, `browser_get_dom_snapshot` +- **Navigation and interaction**: `browser_navigate`, `browser_click`, `browser_type`, `browser_take_screenshot` +- **Hover diagnostics**: `browser_hover`, `browser_query`, `browser_take_element_screenshot` +- **Readiness and page evaluation**: `browser_wait_for`, `browser_eval` + +Notable Phase 1 capability details: + +- `browser_click` accepts zero-based `nth` so Claude can target the Nth matching element +- `browser_query` is multi-match aware and can return `count`, `href`, and `onclick` in addition to visibility-oriented fields +- `browser_wait_for` can wait on page text or selector state before the next step runs +- `browser_eval` is gated by `browser.claude.eval_mode` and supports `disabled`, `readonly`, or `readwrite`; readonly mode uses side-effect-blocked evaluation and may reject expressions that could mutate page state + +A common hover-debug workflow is: + +1. call `browser_hover` to move the browser pointer onto the card or trigger +2. call `browser_wait_for` if the hover state needs time to appear +3. call `browser_query` on the hover-only control to inspect `exists`, `count`, visibility, opacity, `href`, `onclick`, and bounds +4. call `browser_take_element_screenshot` to confirm the revealed state +5. call `browser_eval` in read-only mode when you need page-side inspection that the structured tools do not expose directly + ### Codex Browser Tools Codex-target CCS launches use a separate managed path: CCS injects Playwright MCP overrides @@ -40,10 +62,12 @@ The Browser screen exposes two sections: - enable/disable the Claude attach lane - choose the Chrome user-data directory - set the expected DevTools port + - choose the `browser_eval` access level (`disabled`, `readonly`, `readwrite`) - review readiness and next-step guidance - copy a generated browser launch command - **Codex Browser Tools** - enable/disable CCS-managed browser tooling for Codex-target launches + - choose the stored `browser_eval` access level for Browser settings parity - review whether the detected Codex build supports managed browser overrides ### Via CLI @@ -67,15 +91,19 @@ browser: enabled: false user_data_dir: "~/.ccs/browser/chrome-user-data" devtools_port: 9222 + eval_mode: readonly codex: enabled: true + eval_mode: readonly ``` Notes: - `claude.user_data_dir` is a **Chrome user-data directory**, not a display-name browser profile - `claude.devtools_port` is the expected remote debugging port for attach mode +- `claude.eval_mode` controls whether `browser_eval` is disabled, read-only, or read/write for Claude Browser Attach - `codex.enabled` controls whether CCS injects browser tooling into Codex-target launches +- `codex.eval_mode` is stored and surfaced in Browser settings for parity; in Phase 1, `browser_eval` enforcement primarily applies to Claude Browser Attach ## Environment Variable Overrides @@ -86,6 +114,7 @@ CCS still supports environment-variable overrides for backward compatibility. | `CCS_BROWSER_USER_DATA_DIR` | Preferred override for Claude Browser Attach user-data dir | | `CCS_BROWSER_PROFILE_DIR` | Legacy alias for the same attach directory | | `CCS_BROWSER_DEVTOOLS_PORT` | Explicit DevTools port override | +| `CCS_BROWSER_EVAL_MODE` | Explicit `browser_eval` access override for Claude Browser Attach | If an override is active, Browser status surfaces should report that the current session is being managed externally by environment variables. diff --git a/lib/mcp/ccs-browser-server.cjs b/lib/mcp/ccs-browser-server.cjs index 70a0b654..f6a13df7 100755 --- a/lib/mcp/ccs-browser-server.cjs +++ b/lib/mcp/ccs-browser-server.cjs @@ -41,6 +41,11 @@ const TOOL_NAVIGATE = 'browser_navigate'; const TOOL_CLICK = 'browser_click'; const TOOL_TYPE = 'browser_type'; const TOOL_TAKE_SCREENSHOT = 'browser_take_screenshot'; +const TOOL_WAIT_FOR = 'browser_wait_for'; +const TOOL_EVAL = 'browser_eval'; +const TOOL_HOVER = 'browser_hover'; +const TOOL_QUERY = 'browser_query'; +const TOOL_TAKE_ELEMENT_SCREENSHOT = 'browser_take_element_screenshot'; const TOOL_NAMES = [ TOOL_SESSION_INFO, TOOL_URL_TITLE, @@ -50,9 +55,30 @@ const TOOL_NAMES = [ TOOL_CLICK, TOOL_TYPE, TOOL_TAKE_SCREENSHOT, + TOOL_WAIT_FOR, + TOOL_EVAL, + TOOL_HOVER, + TOOL_QUERY, + TOOL_TAKE_ELEMENT_SCREENSHOT, ]; +const SUPPORTED_QUERY_FIELDS = [ + 'exists', + 'count', + 'innerText', + 'textContent', + 'boundingClientRect', + 'display', + 'visibility', + 'opacity', + 'href', + 'onclick', +]; +const DEFAULT_QUERY_FIELDS = [...SUPPORTED_QUERY_FIELDS]; +const SUPPORTED_QUERY_FIELD_SET = new Set(SUPPORTED_QUERY_FIELDS); const CDP_TIMEOUT_MS = 5000; const NAVIGATION_POLL_INTERVAL_MS = 100; +const DEFAULT_WAIT_TIMEOUT_MS = 2000; +const DEFAULT_WAIT_POLL_INTERVAL_MS = 100; let inputBuffer = Buffer.alloc(0); let requestCounter = 0; @@ -221,7 +247,7 @@ function getTools() { { name: TOOL_CLICK, description: - 'Click the first element matching a CSS selector in the selected page using a minimal mouse event chain with click fallback. Optionally choose a page by index.', + 'Click the first element matching a CSS selector in the selected page using a minimal mouse event chain with click fallback. Optionally choose a page by index and match index.', inputSchema: { type: 'object', properties: { @@ -234,6 +260,11 @@ function getTools() { type: 'string', description: 'Required CSS selector for the element to click.', }, + nth: { + type: 'integer', + minimum: 0, + description: 'Optional zero-based match index for selectors returning multiple elements.', + }, }, required: ['selector'], additionalProperties: false, @@ -288,6 +319,145 @@ function getTools() { additionalProperties: false, }, }, + { + name: TOOL_WAIT_FOR, + description: + 'Poll until a selector-scoped or page-level condition is satisfied. Supports selector existence/visibility/text waits and page text waits.', + inputSchema: { + type: 'object', + properties: { + pageIndex: { + type: 'integer', + minimum: 0, + description: 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.', + }, + selector: { + type: 'string', + description: 'Optional CSS selector for selector-scoped waiting.', + }, + nth: { + type: 'integer', + minimum: 0, + description: 'Optional zero-based match index for selectors returning multiple elements.', + }, + timeoutMs: { + type: 'integer', + minimum: 1, + description: 'Optional timeout in milliseconds.', + }, + pollIntervalMs: { + type: 'integer', + minimum: 1, + description: 'Optional polling interval in milliseconds.', + }, + condition: { + type: 'object', + description: 'Required wait condition.', + }, + }, + required: ['condition'], + additionalProperties: false, + }, + }, + { + name: TOOL_EVAL, + description: + 'Evaluate page-side JavaScript for inspection or mutation, gated by CCS_BROWSER_EVAL_MODE.', + inputSchema: { + type: 'object', + properties: { + pageIndex: { + type: 'integer', + minimum: 0, + description: 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.', + }, + expression: { + type: 'string', + description: 'Required JavaScript expression to evaluate in the page.', + }, + mode: { + type: 'string', + enum: ['readonly', 'readwrite'], + description: 'Optional evaluation mode. Defaults to readonly.', + }, + }, + required: ['expression'], + additionalProperties: false, + }, + }, + { + name: TOOL_HOVER, + description: + 'Move the browser mouse pointer onto the first element matching a CSS selector in the selected page to trigger hover state. Optionally choose a page by index.', + inputSchema: { + type: 'object', + properties: { + pageIndex: { + type: 'integer', + minimum: 0, + description: 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.', + }, + selector: { + type: 'string', + description: 'Required CSS selector for the hover target.', + }, + }, + required: ['selector'], + additionalProperties: false, + }, + }, + { + name: TOOL_QUERY, + description: + 'Return diagnostic state for selector-matched elements in the selected page. Optionally choose a page by index, zero-based match index, and a subset of fields.', + inputSchema: { + type: 'object', + properties: { + pageIndex: { + type: 'integer', + minimum: 0, + description: 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.', + }, + selector: { + type: 'string', + description: 'Required CSS selector for the query target.', + }, + nth: { + type: 'integer', + minimum: 0, + description: 'Optional zero-based match index for selectors returning multiple elements.', + }, + fields: { + type: 'array', + items: { type: 'string' }, + description: 'Optional list of diagnostic fields to return.', + }, + }, + required: ['selector'], + additionalProperties: false, + }, + }, + { + name: TOOL_TAKE_ELEMENT_SCREENSHOT, + description: + 'Capture a PNG screenshot clipped to the first element matching a CSS selector in the selected page. Optionally choose a page by index.', + inputSchema: { + type: 'object', + properties: { + pageIndex: { + type: 'integer', + minimum: 0, + description: 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.', + }, + selector: { + type: 'string', + description: 'Required CSS selector for the screenshot target.', + }, + }, + required: ['selector'], + additionalProperties: false, + }, + }, ]; } @@ -521,6 +691,48 @@ function requireValidHttpUrl(value) { return parsed.toString(); } +function parseQueryFields(value) { + if (value === undefined) { + return DEFAULT_QUERY_FIELDS; + } + if (!Array.isArray(value) || value.some((field) => typeof field !== 'string')) { + throw new Error('fields must be an array of strings'); + } + const unknownField = value.find((field) => !SUPPORTED_QUERY_FIELD_SET.has(field)); + if (unknownField) { + throw new Error(`unknown query field: ${unknownField}`); + } + return value; +} + +function requireOptionalNonNegativeInteger(value, label) { + if (value === undefined) { + return undefined; + } + if (!Number.isInteger(value) || value < 0) { + throw new Error(`${label} must be a non-negative integer`); + } + return value; +} + +function requirePositiveIntegerOrDefault(value, label, fallback) { + if (value === undefined) { + return fallback; + } + if (!Number.isInteger(value) || value <= 0) { + throw new Error(`${label} must be a positive integer`); + } + return value; +} + +function getBrowserEvalMode() { + const raw = String(process.env.CCS_BROWSER_EVAL_MODE || 'readonly').trim(); + if (raw === 'disabled' || raw === 'readonly' || raw === 'readwrite') { + return raw; + } + return 'readonly'; +} + function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } @@ -601,12 +813,16 @@ async function handleNavigate(toolArgs) { async function handleClick(toolArgs) { const { page, pageIndex } = await getSelectedPage(toolArgs); const selector = requireNonEmptyString(toolArgs.selector, 'selector'); + const nth = requireOptionalNonNegativeInteger(toolArgs.nth, 'nth'); + const targetIndex = nth ?? 0; const expression = `(() => { const selector = JSON.parse(${JSON.stringify(JSON.stringify(selector))}); - const element = document.querySelector(selector); + const nth = ${nth === undefined ? 'undefined' : String(nth)}; + const matches = Array.from(document.querySelectorAll(selector)); + const element = matches[nth ?? 0]; if (!element) { - throw new Error('element not found for selector: ' + selector); + throw new Error('element index ' + (nth ?? 0) + ' is out of range for selector: ' + selector); } if (!element.isConnected) { throw new Error('element is detached for selector: ' + selector); @@ -660,7 +876,7 @@ async function handleClick(toolArgs) { })()`; await evaluateExpression(page, expression); - return `pageIndex: ${pageIndex}\nselector: ${selector}\nstatus: clicked`; + return `pageIndex: ${pageIndex}\nselector: ${selector}\nnth: ${targetIndex}\nstatus: clicked`; } async function handleType(toolArgs) { @@ -758,6 +974,385 @@ async function handleScreenshot(toolArgs) { return `pageIndex: ${pageIndex}\nformat: png\nfullPage: ${fullPage ? 'true' : 'false'}\ndata: ${data}`; } +async function getElementDiagnostics(page, selector, nth) { + const expression = `(() => { + const selector = JSON.parse(${JSON.stringify(JSON.stringify(selector))}); + const nth = ${nth === undefined ? 'undefined' : String(nth)}; + const matches = Array.from(document.querySelectorAll(selector)); + const count = matches.length; + const targetIndex = nth ?? 0; + const element = matches[targetIndex]; + if (!element) { + return JSON.stringify({ + exists: nth === undefined ? count > 0 : count > targetIndex, + count, + targetIndex, + targetMissing: true, + }); + } + const rect = element.getBoundingClientRect(); + const style = window.getComputedStyle(element); + const text = typeof element.innerText === 'string' ? element.innerText : (element.textContent || ''); + return JSON.stringify({ + exists: true, + count, + targetIndex, + connected: element.isConnected, + text, + innerText: typeof element.innerText === 'string' ? element.innerText : '', + textContent: element.textContent || '', + boundingClientRect: { + x: rect.x, + y: rect.y, + width: rect.width, + height: rect.height, + top: rect.top, + right: rect.right, + bottom: rect.bottom, + left: rect.left, + }, + display: style.display, + visibility: style.visibility, + opacity: style.opacity, + href: typeof element.getAttribute === 'function' ? element.getAttribute('href') || '' : '', + onclick: typeof element.getAttribute === 'function' ? element.getAttribute('onclick') || '' : '', + interactable: + element.isConnected && + style.display !== 'none' && + style.visibility !== 'hidden' && + rect.width > 0 && + rect.height > 0, + }); + })()`; + return JSON.parse(await evaluateExpression(page, expression)); +} + +async function getScrolledElementState(page, selector) { + const expression = `(() => { + const selector = JSON.parse(${JSON.stringify(JSON.stringify(selector))}); + const element = document.querySelector(selector); + if (!element) { + return JSON.stringify({ exists: false }); + } + if (!element.isConnected) { + return JSON.stringify({ exists: true, connected: false }); + } + element.scrollIntoView({ block: 'center', inline: 'center' }); + const rect = element.getBoundingClientRect(); + const style = window.getComputedStyle(element); + const viewportWidth = window.innerWidth || document.documentElement.clientWidth || 0; + const viewportHeight = window.innerHeight || document.documentElement.clientHeight || 0; + const clipX = Math.max(0, rect.left); + const clipY = Math.max(0, rect.top); + const clipRight = Math.min(viewportWidth, rect.right); + const clipBottom = Math.min(viewportHeight, rect.bottom); + return JSON.stringify({ + exists: true, + connected: true, + display: style.display, + visibility: style.visibility, + opacity: style.opacity, + interactable: + style.display !== 'none' && + style.visibility !== 'hidden' && + rect.width > 0 && + rect.height > 0, + boundingClientRect: { + x: rect.x, + y: rect.y, + width: rect.width, + height: rect.height, + top: rect.top, + right: rect.right, + bottom: rect.bottom, + left: rect.left, + }, + centerPoint: { + x: rect.left + rect.width / 2, + y: rect.top + rect.height / 2, + }, + visibleClip: { + x: clipX, + y: clipY, + width: Math.max(0, clipRight - clipX), + height: Math.max(0, clipBottom - clipY), + scale: 1, + }, + }); + })()`; + return JSON.parse(await evaluateExpression(page, expression)); +} + +function formatQueryValue(field, value) { + if (field === 'boundingClientRect') { + return JSON.stringify(value); + } + return String(value); +} + +function hasTargetSpecificQueryField(fields) { + return fields.some((field) => field !== 'exists' && field !== 'count'); +} + +function parseWaitCondition(value, hasSelector) { + if (!value || typeof value !== 'object') { + throw new Error('condition is required'); + } + const condition = value; + if (condition.kind === 'existence') { + if (!hasSelector) { + throw new Error('page-level wait only supports text conditions in Phase 1'); + } + return { kind: 'existence', exists: condition.exists !== false }; + } + if (condition.kind === 'visibility') { + if (!hasSelector) { + throw new Error('page-level wait only supports text conditions in Phase 1'); + } + return { + kind: 'visibility', + visibility: condition.visibility === 'hidden' ? 'hidden' : 'visible', + opacityGt: typeof condition.opacityGt === 'number' ? condition.opacityGt : undefined, + }; + } + if (condition.kind === 'text') { + if (typeof condition.includes !== 'string' || condition.includes === '') { + throw new Error('condition.includes is required'); + } + return { kind: 'text', includes: condition.includes }; + } + throw new Error(`unknown wait condition kind: ${String(condition.kind || '')}`); +} + +function isVisibleObservation(observation, opacityGt) { + if (!observation || observation.targetMissing) { + return false; + } + const opacity = Number.parseFloat(String(observation.opacity ?? '1')); + return ( + observation.display !== 'none' && + observation.visibility === 'visible' && + Number(observation.boundingClientRect?.width || 0) > 0 && + Number(observation.boundingClientRect?.height || 0) > 0 && + (opacityGt === undefined || opacity > opacityGt) + ); +} + +function isHiddenObservation(observation) { + if (!observation || observation.targetMissing) { + return true; + } + return ( + observation.display === 'none' || + observation.visibility !== 'visible' || + Number(observation.boundingClientRect?.width || 0) <= 0 || + Number(observation.boundingClientRect?.height || 0) <= 0 + ); +} + +function isWaitConditionSatisfied(observation, condition) { + if (condition.kind === 'existence') { + return condition.exists ? observation.exists === true : observation.exists === false; + } + if (condition.kind === 'visibility') { + return condition.visibility === 'hidden' + ? isHiddenObservation(observation) + : isVisibleObservation(observation, condition.opacityGt); + } + if (condition.kind === 'text') { + return String(observation.text || '').includes(condition.includes); + } + return false; +} + +function formatWaitObservation(observation) { + if (!observation) { + return 'unavailable'; + } + if ('exists' in observation || 'count' in observation || 'display' in observation) { + return [ + `exists=${observation.exists === true ? 'true' : 'false'}`, + `count=${String(observation.count ?? 0)}`, + `display=${String(observation.display ?? '')}`, + `visibility=${String(observation.visibility ?? '')}`, + `opacity=${String(observation.opacity ?? '')}`, + ].join(', '); + } + if ('text' in observation) { + return `text=${JSON.stringify(observation.text || '')}`; + } + return 'unavailable'; +} + +function formatQueryResponse(pageIndex, selector, nth, diagnostics, fields) { + const lines = [`pageIndex: ${pageIndex}`, `selector: ${selector}`]; + if (nth !== undefined) { + lines.push(`nth: ${nth}`); + } + if (diagnostics.targetMissing) { + if (hasTargetSpecificQueryField(fields)) { + throw new Error(`element index ${diagnostics.targetIndex} is out of range for selector: ${selector}`); + } + for (const field of fields) { + if (field === 'exists') { + lines.push(`exists: ${diagnostics.exists ? 'true' : 'false'}`); + continue; + } + if (field === 'count') { + lines.push(`count: ${formatQueryValue(field, diagnostics.count)}`); + } + } + return lines.join('\n'); + } + for (const field of fields) { + lines.push(`${field}: ${formatQueryValue(field, diagnostics[field])}`); + } + return lines.join('\n'); +} + +async function getWaitPageObservation(page) { + const text = await evaluateExpression( + page, + `(() => document.body ? document.body.innerText || '' : '')()` + ); + return { text }; +} + +async function getWaitSelectorObservation(page, selector, nth) { + return getElementDiagnostics(page, selector, nth); +} + +async function handleWaitFor(toolArgs) { + const { page, pageIndex } = await getSelectedPage(toolArgs); + const selector = typeof toolArgs.selector === 'string' ? toolArgs.selector.trim() : ''; + const nth = requireOptionalNonNegativeInteger(toolArgs.nth, 'nth'); + const timeoutMs = requirePositiveIntegerOrDefault(toolArgs.timeoutMs, 'timeoutMs', DEFAULT_WAIT_TIMEOUT_MS); + const pollIntervalMs = requirePositiveIntegerOrDefault( + toolArgs.pollIntervalMs, + 'pollIntervalMs', + DEFAULT_WAIT_POLL_INTERVAL_MS + ); + const condition = parseWaitCondition(toolArgs.condition, selector !== ''); + const deadline = Date.now() + timeoutMs; + let lastObserved = null; + + while (Date.now() <= deadline) { + lastObserved = selector + ? await getWaitSelectorObservation(page, selector, nth) + : await getWaitPageObservation(page); + if (isWaitConditionSatisfied(lastObserved, condition)) { + return `pageIndex: ${pageIndex}${selector ? `\nselector: ${selector}` : ''}\nstatus: satisfied`; + } + if (Date.now() + pollIntervalMs > deadline) { + break; + } + await sleep(pollIntervalMs); + } + + throw new Error(`wait condition timed out\nlastObserved: ${formatWaitObservation(lastObserved)}`); +} + +async function handleEval(toolArgs) { + const { page, pageIndex } = await getSelectedPage(toolArgs); + const expression = requireNonEmptyString(toolArgs.expression, 'expression'); + const mode = toolArgs.mode === 'readwrite' ? 'readwrite' : 'readonly'; + const evalMode = getBrowserEvalMode(); + + if (evalMode === 'disabled') { + throw new Error('browser_eval is disabled by CCS_BROWSER_EVAL_MODE=disabled'); + } + if (mode === 'readwrite' && evalMode !== 'readwrite') { + throw new Error(`browser_eval readwrite mode is disabled by CCS_BROWSER_EVAL_MODE=${evalMode}`); + } + + const response = await sendCdpCommand(page, 'Runtime.evaluate', { + expression, + returnByValue: true, + awaitPromise: true, + ...(mode === 'readonly' ? { throwOnSideEffect: true } : {}), + }); + + if (response?.exceptionDetails?.text) { + throw new Error(response.exceptionDetails.text); + } + if (!response?.result) { + throw new Error('evaluation result is not JSON-serializable'); + } + + const result = response.result; + const value = Object.prototype.hasOwnProperty.call(result, 'value') ? result.value : undefined; + if (value === undefined && result.type !== 'undefined') { + throw new Error('evaluation result is not JSON-serializable'); + } + + return `pageIndex: ${pageIndex}\nmode: ${mode}\nvalue: ${JSON.stringify(value)}`; +} + +async function handleHover(toolArgs) { + const { page, pageIndex } = await getSelectedPage(toolArgs); + const selector = requireNonEmptyString(toolArgs.selector, 'selector'); + const state = await getScrolledElementState(page, selector); + if (!state.exists) { + throw new Error(`element not found for selector: ${selector}`); + } + if (state.connected !== true) { + throw new Error(`element is detached for selector: ${selector}`); + } + if (state.interactable !== true || !state.centerPoint) { + throw new Error(`element is hidden or not interactable for selector: ${selector}`); + } + + await sendCdpCommand(page, 'Input.dispatchMouseEvent', { + type: 'mouseMoved', + x: state.centerPoint.x, + y: state.centerPoint.y, + button: 'none', + buttons: 0, + pointerType: 'mouse', + }); + + return `pageIndex: ${pageIndex}\nselector: ${selector}\nstatus: hovered`; +} + +async function handleQuery(toolArgs) { + const { page, pageIndex } = await getSelectedPage(toolArgs); + const selector = requireNonEmptyString(toolArgs.selector, 'selector'); + const nth = requireOptionalNonNegativeInteger(toolArgs.nth, 'nth'); + const fields = parseQueryFields(toolArgs.fields); + const diagnostics = await getElementDiagnostics(page, selector, nth); + return formatQueryResponse(pageIndex, selector, nth, diagnostics, fields); +} + +async function handleElementScreenshot(toolArgs) { + const { page, pageIndex } = await getSelectedPage(toolArgs); + const selector = requireNonEmptyString(toolArgs.selector, 'selector'); + const state = await getScrolledElementState(page, selector); + if (!state.exists) { + throw new Error(`element not found for selector: ${selector}`); + } + if (state.connected !== true) { + throw new Error(`element is detached for selector: ${selector}`); + } + if (state.interactable !== true || !state.visibleClip) { + throw new Error(`element has empty bounds for selector: ${selector}`); + } + if (state.visibleClip.width <= 0 || state.visibleClip.height <= 0) { + throw new Error(`element has empty bounds for selector: ${selector}`); + } + + const response = await sendCdpCommand(page, 'Page.captureScreenshot', { + format: 'png', + clip: state.visibleClip, + }); + + const data = response && typeof response.data === 'string' ? response.data : ''; + if (!data) { + throw new Error('screenshot capture failed'); + } + + return `pageIndex: ${pageIndex}\nselector: ${selector}\nformat: png\ndata: ${data}`; +} + async function handleToolCall(message) { const id = message.id; const params = message.params || {}; @@ -823,6 +1418,46 @@ async function handleToolCall(message) { return; } + if (toolName === TOOL_WAIT_FOR) { + const text = await handleWaitFor(toolArgs); + writeResponse(id, { + content: [{ type: 'text', text }], + }); + return; + } + + if (toolName === TOOL_EVAL) { + const text = await handleEval(toolArgs); + writeResponse(id, { + content: [{ type: 'text', text }], + }); + return; + } + + if (toolName === TOOL_HOVER) { + const text = await handleHover(toolArgs); + writeResponse(id, { + content: [{ type: 'text', text }], + }); + return; + } + + if (toolName === TOOL_QUERY) { + const text = await handleQuery(toolArgs); + writeResponse(id, { + content: [{ type: 'text', text }], + }); + return; + } + + if (toolName === TOOL_TAKE_ELEMENT_SCREENSHOT) { + const text = await handleElementScreenshot(toolArgs); + writeResponse(id, { + content: [{ type: 'text', text }], + }); + return; + } + const { page, pageIndex } = await getSelectedPage(toolArgs); if (toolName === TOOL_URL_TITLE) { diff --git a/scripts/ci-parity-gate.sh b/scripts/ci-parity-gate.sh index bb507ae8..561ae62b 100755 --- a/scripts/ci-parity-gate.sh +++ b/scripts/ci-parity-gate.sh @@ -34,7 +34,9 @@ echo "[i] Pre-push CI parity gate" echo " branch: $CURRENT_BRANCH" echo " base: $BASE_BRANCH" -git fetch origin "$BASE_BRANCH" --quiet || true +if git ls-remote --exit-code --heads origin "$BASE_BRANCH" >/dev/null 2>&1; then + git fetch origin "$BASE_BRANCH" --quiet +fi if git show-ref --verify --quiet "refs/remotes/origin/$BASE_BRANCH"; then if ! git merge-base --is-ancestor "origin/$BASE_BRANCH" HEAD; then echo "[X] Branch '$CURRENT_BRANCH' is behind origin/$BASE_BRANCH." diff --git a/src/ccs.ts b/src/ccs.ts index 3cedddbb..43ce870a 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -604,7 +604,6 @@ async function main(): Promise { } catch (error) { console.error(fail((error as Error).message)); process.exit(1); - return; } // Detect Claude CLI (needed for claude target and all CLIProxy-derived flows) @@ -1089,7 +1088,13 @@ async function main(): Promise { } const inheritedClaudeConfigDir = continuityInheritance.claudeConfigDir; syncWebSearchMcpToConfigDir(inheritedClaudeConfigDir); - syncImageAnalysisMcpToConfigDir(inheritedClaudeConfigDir); + if (!syncImageAnalysisMcpToConfigDir(inheritedClaudeConfigDir) && inheritedClaudeConfigDir) { + console.error( + warn( + 'Image Analysis MCP config could not be synced into the inherited Claude instance. This session will continue with the current fallback behavior.' + ) + ); + } if ( browserAttachConfig?.enabled && inheritedClaudeConfigDir && @@ -1269,7 +1274,13 @@ async function main(): Promise { ...imageAnalysisEnv, CCS_CURRENT_PROVIDER: '', CCS_IMAGE_ANALYSIS_SKIP: '1', + CCS_IMAGE_ANALYSIS_BACKEND_ID: '', + CCS_IMAGE_ANALYSIS_MODEL: '', + CCS_IMAGE_ANALYSIS_RUNTIME_BASE_URL: '', + CCS_IMAGE_ANALYSIS_RUNTIME_PATH: '', }; + delete imageAnalysisEnv.CCS_IMAGE_ANALYSIS_RUNTIME_API_KEY; + delete imageAnalysisEnv.CCS_IMAGE_ANALYSIS_RUNTIME_ALLOW_SELF_SIGNED; } else if (imageAnalysisStatus.proxyReadiness === 'stopped') { const ensureServiceResult = await ensureCliproxyService( CLIPROXY_DEFAULT_PORT, @@ -1285,7 +1296,13 @@ async function main(): Promise { ...imageAnalysisEnv, CCS_CURRENT_PROVIDER: '', CCS_IMAGE_ANALYSIS_SKIP: '1', + CCS_IMAGE_ANALYSIS_BACKEND_ID: '', + CCS_IMAGE_ANALYSIS_MODEL: '', + CCS_IMAGE_ANALYSIS_RUNTIME_BASE_URL: '', + CCS_IMAGE_ANALYSIS_RUNTIME_PATH: '', }; + delete imageAnalysisEnv.CCS_IMAGE_ANALYSIS_RUNTIME_API_KEY; + delete imageAnalysisEnv.CCS_IMAGE_ANALYSIS_RUNTIME_ALLOW_SELF_SIGNED; } } } @@ -1308,6 +1325,7 @@ async function main(): Promise { devtoolsPort: browserAttachConfig.hasExplicitDevtoolsPort ? String(browserAttachConfig.devtoolsPort) : undefined, + evalMode: browserAttachConfig.evalMode, })), }; } @@ -1487,6 +1505,7 @@ async function main(): Promise { ? String(browserAttachConfig.devtoolsPort) : undefined, })), + CCS_BROWSER_EVAL_MODE: browserAttachConfig.evalMode, }; Object.assign(envVars, browserRuntimeEnv); } diff --git a/src/cliproxy/executor/index.ts b/src/cliproxy/executor/index.ts index f3d9f1a9..db2343cb 100644 --- a/src/cliproxy/executor/index.ts +++ b/src/cliproxy/executor/index.ts @@ -1164,6 +1164,7 @@ export async function execClaudeWithCLIProxy( devtoolsPort: browserAttachConfig.hasExplicitDevtoolsPort ? String(browserAttachConfig.devtoolsPort) : undefined, + evalMode: browserAttachConfig.evalMode, })), } : undefined; diff --git a/src/commands/browser-command.ts b/src/commands/browser-command.ts index d9c4ccf6..c172152b 100644 --- a/src/commands/browser-command.ts +++ b/src/commands/browser-command.ts @@ -50,6 +50,7 @@ function writeClaudeStatus( writeLine(` Source: ${status.source}${status.overrideActive ? ' (env override active)' : ''}`); writeLine(` User data dir: ${status.effectiveUserDataDir}`); writeLine(` DevTools port: ${status.devtoolsPort}`); + writeLine(` browser_eval access: ${status.evalMode}`); writeLine(` Managed MCP: ${status.managedMcpServerName}`); writeLine(` Managed path: ${status.managedMcpServerPath}`); if (status.runtimeEnv?.CCS_BROWSER_DEVTOOLS_HTTP_URL) { @@ -69,6 +70,7 @@ function writeCodexStatus(status: BrowserStatusPayload['codex'], writeLine: Help writeLine(` State: ${status.state}`); writeLine(` Enabled: ${status.enabled ? 'yes' : 'no'}`); writeLine(` Managed server: ${status.serverName}`); + writeLine(` browser_eval access: ${status.evalMode}`); writeLine(` Supports overrides: ${status.supportsConfigOverrides ? 'yes' : 'no'}`); writeLine(` Codex binary: ${status.binaryPath || 'not detected'}`); if (status.version) { diff --git a/src/config/unified-config-loader.ts b/src/config/unified-config-loader.ts index 50835435..83061660 100644 --- a/src/config/unified-config-loader.ts +++ b/src/config/unified-config-loader.ts @@ -36,6 +36,7 @@ import type { OfficialChannelId, DashboardAuthConfig, BrowserConfig, + BrowserEvalMode, ImageAnalysisConfig, LoggingConfig, CursorConfig, @@ -70,15 +71,25 @@ function normalizeBrowserDevtoolsPort(value: number | undefined): number { return port; } +function normalizeBrowserEvalMode(value: string | undefined): BrowserEvalMode { + if (value === 'disabled' || value === 'readonly' || value === 'readwrite') { + return value; + } + + return DEFAULT_BROWSER_CONFIG.claude.eval_mode; +} + function canonicalizeBrowserConfig(config?: BrowserConfig): BrowserConfig { return { claude: { enabled: config?.claude?.enabled ?? DEFAULT_BROWSER_CONFIG.claude.enabled, user_data_dir: config?.claude?.user_data_dir?.trim() || getRecommendedBrowserUserDataDir(), devtools_port: normalizeBrowserDevtoolsPort(config?.claude?.devtools_port), + eval_mode: normalizeBrowserEvalMode(config?.claude?.eval_mode), }, codex: { enabled: config?.codex?.enabled ?? DEFAULT_BROWSER_CONFIG.codex.enabled, + eval_mode: normalizeBrowserEvalMode(config?.codex?.eval_mode), }, }; } @@ -955,6 +966,7 @@ function generateYamlWithComments(config: UnifiedConfig): string { lines.push('#'); lines.push('# claude.user_data_dir should point at the Chrome user-data directory for the'); lines.push('# dedicated attach session. claude.devtools_port is the expected debugging port.'); + lines.push('# eval_mode controls browser_eval access: disabled | readonly | readwrite.'); lines.push('# Configure via: Settings > Browser or `ccs browser ...`.'); lines.push('# ----------------------------------------------------------------------------'); lines.push( diff --git a/src/config/unified-config-types.ts b/src/config/unified-config-types.ts index 1b8b67f4..8c0f816f 100644 --- a/src/config/unified-config-types.ts +++ b/src/config/unified-config-types.ts @@ -816,6 +816,8 @@ export const DEFAULT_DASHBOARD_AUTH_CONFIG: DashboardAuthConfig = { * Browser automation configuration. * Controls Claude browser attach and Codex browser tooling. */ +export type BrowserEvalMode = 'disabled' | 'readonly' | 'readwrite'; + export interface BrowserClaudeConfig { /** Enable Claude browser attach (default: false) */ enabled: boolean; @@ -823,11 +825,15 @@ export interface BrowserClaudeConfig { user_data_dir: string; /** DevTools port used for attach mode (default: 9222) */ devtools_port: number; + /** Eval access mode for browser_eval (default: readonly) */ + eval_mode: BrowserEvalMode; } export interface BrowserCodexConfig { /** Enable Codex browser tooling injection (default: true) */ enabled: boolean; + /** Stored for dashboard/config parity; runtime enforcement is Claude-only in Phase 1 */ + eval_mode: BrowserEvalMode; } export interface BrowserConfig { @@ -840,9 +846,11 @@ export const DEFAULT_BROWSER_CONFIG: BrowserConfig = { enabled: false, user_data_dir: '', devtools_port: 9222, + eval_mode: 'readonly', }, codex: { enabled: true, + eval_mode: 'readonly', }, }; diff --git a/src/utils/browser/browser-settings.ts b/src/utils/browser/browser-settings.ts index e1a9f6fe..e160aa3f 100644 --- a/src/utils/browser/browser-settings.ts +++ b/src/utils/browser/browser-settings.ts @@ -1,5 +1,5 @@ import * as path from 'path'; -import type { BrowserConfig } from '../../config/unified-config-types'; +import type { BrowserConfig, BrowserEvalMode } from '../../config/unified-config-types'; import { getCcsDir } from '../config-manager'; import { expandPath } from '../helpers'; @@ -11,6 +11,7 @@ export interface EffectiveClaudeBrowserAttachConfig { overrideActive: boolean; userDataDir: string; devtoolsPort: number; + evalMode: BrowserEvalMode; hasExplicitDevtoolsPort: boolean; } @@ -64,6 +65,7 @@ export function getEffectiveClaudeBrowserAttachConfig( overrideActive: true, userDataDir: override.userDataDir, devtoolsPort: override.devtoolsPort ?? configPort, + evalMode: config.claude.eval_mode, hasExplicitDevtoolsPort: override.devtoolsPort !== undefined, }; } @@ -74,6 +76,7 @@ export function getEffectiveClaudeBrowserAttachConfig( overrideActive: false, userDataDir: configUserDataDir, devtoolsPort: configPort, + evalMode: config.claude.eval_mode, // Config-backed browser attach always keeps an explicit port so launches // stay aligned with Settings > Browser, even when the effective value is // the default 9222. diff --git a/src/utils/browser/browser-status.ts b/src/utils/browser/browser-status.ts index 0ea469d2..9a72a37a 100644 --- a/src/utils/browser/browser-status.ts +++ b/src/utils/browser/browser-status.ts @@ -25,6 +25,7 @@ export interface ClaudeBrowserStatus { effectiveUserDataDir: string; recommendedUserDataDir: string; devtoolsPort: number; + evalMode: 'disabled' | 'readonly' | 'readwrite'; managedMcpServerName: string; managedMcpServerPath: string; launchCommands: BrowserLaunchCommands; @@ -38,6 +39,7 @@ export interface CodexBrowserStatus { detail: string; nextStep: string; serverName: string; + evalMode: 'disabled' | 'readonly' | 'readwrite'; supportsConfigOverrides: boolean; binaryPath: string | null; version?: string; @@ -68,6 +70,7 @@ async function buildClaudeBrowserStatus( effectiveUserDataDir: effective.userDataDir, recommendedUserDataDir: getRecommendedBrowserUserDataDir(), devtoolsPort: effective.devtoolsPort, + evalMode: effective.evalMode, managedMcpServerName: getBrowserMcpServerName(), managedMcpServerPath: getBrowserMcpServerPath(), launchCommands, @@ -89,6 +92,7 @@ async function buildClaudeBrowserStatus( const runtimeEnv = await resolveBrowserRuntimeEnv({ profileDir: effective.userDataDir, devtoolsPort: effective.hasExplicitDevtoolsPort ? String(effective.devtoolsPort) : undefined, + evalMode: effective.evalMode, }); return { @@ -142,6 +146,7 @@ function buildCodexBrowserStatus(browserConfig = getBrowserConfig()): CodexBrows nextStep: 'Enable Codex Browser Tools in Settings > Browser to restore the managed Codex browser path.', serverName: 'ccs_browser', + evalMode: browserConfig.codex.eval_mode, supportsConfigOverrides: false, binaryPath: null, }; @@ -159,6 +164,7 @@ function buildCodexBrowserStatus(browserConfig = getBrowserConfig()): CodexBrows : 'No Codex binary was detected, so CCS cannot confirm managed browser override support.', nextStep: 'Install or upgrade Codex, then rerun browser status/doctor.', serverName: 'ccs_browser', + evalMode: browserConfig.codex.eval_mode, supportsConfigOverrides, binaryPath: binaryInfo?.path ?? null, version: binaryInfo?.version, @@ -172,6 +178,7 @@ function buildCodexBrowserStatus(browserConfig = getBrowserConfig()): CodexBrows detail: 'CCS can inject the managed Playwright MCP overrides into Codex-target launches.', nextStep: 'Use a Codex-target CCS launch to access browser tools.', serverName: 'ccs_browser', + evalMode: browserConfig.codex.eval_mode, supportsConfigOverrides, binaryPath: binaryInfo.path, version: binaryInfo.version, diff --git a/src/utils/browser/chrome-reuse.ts b/src/utils/browser/chrome-reuse.ts index 976c39ef..02ec96a3 100644 --- a/src/utils/browser/chrome-reuse.ts +++ b/src/utils/browser/chrome-reuse.ts @@ -1,11 +1,13 @@ import { execFileSync } from 'child_process'; import * as fs from 'fs'; import * as path from 'path'; +import type { BrowserEvalMode } from '../../config/unified-config-types'; import { expandPath } from '../helpers'; export interface BrowserReuseOptions { profileDir?: string; devtoolsPort?: string; + evalMode?: BrowserEvalMode; } export interface BrowserRuntimeEnv { @@ -14,6 +16,7 @@ export interface BrowserRuntimeEnv { CCS_BROWSER_DEVTOOLS_PORT: string; CCS_BROWSER_DEVTOOLS_HTTP_URL: string; CCS_BROWSER_DEVTOOLS_WS_URL: string; + CCS_BROWSER_EVAL_MODE?: string; } const DEVTOOLS_HOST = '127.0.0.1'; @@ -100,6 +103,7 @@ export async function resolveBrowserRuntimeEnv( CCS_BROWSER_DEVTOOLS_PORT: port, CCS_BROWSER_DEVTOOLS_HTTP_URL: httpUrl, CCS_BROWSER_DEVTOOLS_WS_URL: websocketUrl, + ...(options.evalMode ? { CCS_BROWSER_EVAL_MODE: options.evalMode } : {}), }; } diff --git a/src/web-server/routes/browser-routes.ts b/src/web-server/routes/browser-routes.ts index 14d8bdc3..f248f464 100644 --- a/src/web-server/routes/browser-routes.ts +++ b/src/web-server/routes/browser-routes.ts @@ -12,9 +12,11 @@ interface BrowserRouteBody { enabled?: boolean; userDataDir?: string; devtoolsPort?: number; + evalMode?: 'disabled' | 'readonly' | 'readwrite'; }; codex?: { enabled?: boolean; + evalMode?: 'disabled' | 'readonly' | 'readwrite'; }; } @@ -22,6 +24,10 @@ function isValidDevtoolsPort(value: number): boolean { return Number.isInteger(value) && value >= 1 && value <= 65535; } +function isValidEvalMode(value: string): value is 'disabled' | 'readonly' | 'readwrite' { + return value === 'disabled' || value === 'readonly' || value === 'readwrite'; +} + router.use((req: Request, res: Response, next) => { if (requireLocalAccessWhenAuthDisabled(req, res, BROWSER_LOCAL_ACCESS_ERROR)) { next(); @@ -86,10 +92,22 @@ router.put('/', async (req: Request, res: Response): Promise => { }); return; } + if (claude?.evalMode !== undefined && !isValidEvalMode(claude.evalMode)) { + res.status(400).json({ + error: 'Invalid value for claude.evalMode. Must be one of: disabled, readonly, readwrite.', + }); + return; + } if (codex?.enabled !== undefined && typeof codex.enabled !== 'boolean') { res.status(400).json({ error: 'Invalid value for codex.enabled. Must be a boolean.' }); return; } + if (codex?.evalMode !== undefined && !isValidEvalMode(codex.evalMode)) { + res.status(400).json({ + error: 'Invalid value for codex.evalMode. Must be one of: disabled, readonly, readwrite.', + }); + return; + } try { const current = getBrowserConfig(); @@ -101,9 +119,11 @@ router.put('/', async (req: Request, res: Response): Promise => { enabled: claude?.enabled ?? current.claude.enabled, user_data_dir: nextClaudeUserDataDir, devtools_port: claude?.devtoolsPort ?? current.claude.devtools_port, + eval_mode: claude?.evalMode ?? current.claude.eval_mode, }, codex: { enabled: codex?.enabled ?? current.codex.enabled, + eval_mode: codex?.evalMode ?? current.codex.eval_mode, }, }; }); @@ -128,9 +148,11 @@ function toBrowserRouteConfig(config: ReturnType) { enabled: config.claude.enabled, userDataDir: config.claude.user_data_dir, devtoolsPort: config.claude.devtools_port, + evalMode: config.claude.eval_mode, }, codex: { enabled: config.codex.enabled, + evalMode: config.codex.eval_mode, }, }; } diff --git a/tests/unit/commands/browser-command.test.ts b/tests/unit/commands/browser-command.test.ts index c7c55412..f1d261d8 100644 --- a/tests/unit/commands/browser-command.test.ts +++ b/tests/unit/commands/browser-command.test.ts @@ -37,6 +37,7 @@ describe('browser command', () => { effectiveUserDataDir: '/tmp/browser-profile', recommendedUserDataDir: '/tmp/browser-profile', devtoolsPort: 9222, + evalMode: 'readonly', managedMcpServerName: 'ccs-browser', managedMcpServerPath: '/tmp/ccs-browser-server.cjs', launchCommands: { @@ -50,6 +51,7 @@ describe('browser command', () => { CCS_BROWSER_DEVTOOLS_PORT: '9222', CCS_BROWSER_DEVTOOLS_HTTP_URL: 'http://127.0.0.1:9222', CCS_BROWSER_DEVTOOLS_WS_URL: 'ws://127.0.0.1/devtools/browser/test', + CCS_BROWSER_EVAL_MODE: 'readonly', }, }, codex: { @@ -59,6 +61,7 @@ describe('browser command', () => { detail: 'CCS can inject the managed Playwright MCP overrides.', nextStep: 'Use a Codex-target launch.', serverName: 'ccs_browser', + evalMode: 'readonly', supportsConfigOverrides: true, binaryPath: '/usr/local/bin/codex', version: 'codex-cli 0.120.0', @@ -75,6 +78,7 @@ describe('browser command', () => { ); expect(rendered.includes('Managed MCP: ccs-browser')).toBe(true); expect(rendered.includes('Managed server: ccs_browser')).toBe(true); + expect(rendered.includes('browser_eval access: readonly')).toBe(true); expect(rendered.includes('DevTools endpoint: http://127.0.0.1:9222')).toBe(true); } finally { statusSpy.mockRestore(); @@ -100,6 +104,7 @@ describe('browser command', () => { effectiveUserDataDir: '/tmp/browser-profile', recommendedUserDataDir: '/tmp/browser-profile', devtoolsPort: 9444, + evalMode: 'readwrite', managedMcpServerName: 'ccs-browser', managedMcpServerPath: '/tmp/ccs-browser-server.cjs', launchCommands, @@ -111,6 +116,7 @@ describe('browser command', () => { detail: 'Detected Codex at /usr/local/bin/codex, but it does not advertise --config overrides.', nextStep: 'Install or upgrade Codex, then rerun browser status/doctor.', serverName: 'ccs_browser', + evalMode: 'disabled', supportsConfigOverrides: false, binaryPath: '/usr/local/bin/codex', version: 'codex-cli 0.70.0', @@ -150,6 +156,7 @@ describe('browser command', () => { effectiveUserDataDir: '/tmp/browser-profile', recommendedUserDataDir: '/tmp/browser-profile', devtoolsPort: 9222, + evalMode: 'readonly', managedMcpServerName: 'ccs-browser', managedMcpServerPath: '/tmp/ccs-browser-server.cjs', launchCommands: { @@ -167,6 +174,7 @@ describe('browser command', () => { detail: 'No Codex binary was detected, so CCS cannot confirm managed browser override support.', nextStep: 'Install or upgrade Codex, then rerun browser status/doctor.', serverName: 'ccs_browser', + evalMode: 'readonly', supportsConfigOverrides: false, binaryPath: null, }, diff --git a/tests/unit/hooks/ccs-browser-mcp-server.test.ts b/tests/unit/hooks/ccs-browser-mcp-server.test.ts index 6cab5f48..00a154cd 100644 --- a/tests/unit/hooks/ccs-browser-mcp-server.test.ts +++ b/tests/unit/hooks/ccs-browser-mcp-server.test.ts @@ -8,6 +8,76 @@ import { join } from 'node:path'; type JsonRpcMessage = Record; +type MockRect = { + x: number; + y: number; + width: number; + height: number; + top: number; + right: number; + bottom: number; + left: number; +}; + +type MockQueryState = { + exists?: boolean; + connected?: boolean; + innerText?: string; + textContent?: string; + rect?: MockRect; + display?: string; + visibility?: string; + opacity?: string; + href?: string; + onclick?: string; + error?: string; +}; + +type MockQueryPlan = MockQueryState | MockQueryState[]; + +type MockClickState = { + error?: string; + disabled?: boolean; + detached?: boolean; + hidden?: boolean; + requireMouseSequence?: boolean; + requireNativeClick?: boolean; + forbidSyntheticClickEvent?: boolean; + cancelMouseDown?: boolean; + cancelMouseUp?: boolean; + detachAfterMouseDown?: boolean; + mouseSequenceError?: string; + label?: string; +}; + +type MockClickPlan = MockClickState | MockClickState[]; + +type MockHoverState = { + error?: string; + detached?: boolean; + hidden?: boolean; + zeroSized?: boolean; + requireCdpMouseMove?: boolean; + lastMouseMove?: { + x: number; + y: number; + }; +}; + +type MockWaitPlan = { + selectorSnapshots?: Record; + pageTextSequence?: string[]; +}; + +type MockEvalPlan = Record< + string, + { + result?: unknown; + error?: string; + nonSerializable?: boolean; + } +>; + type MockPageState = { id: string; title: string; @@ -16,25 +86,30 @@ type MockPageState = { visibleText?: string; domSnapshot?: string; navigate?: Record; - click?: Record< - string, - { - error?: string; - disabled?: boolean; - detached?: boolean; - hidden?: boolean; - requireMouseSequence?: boolean; - requireNativeClick?: boolean; - forbidSyntheticClickEvent?: boolean; - cancelMouseDown?: boolean; - cancelMouseUp?: boolean; - detachAfterMouseDown?: boolean; - mouseSequenceError?: string; - } - >; + click?: Record; + hover?: Record; + query?: Record; + wait?: MockWaitPlan; + eval?: MockEvalPlan; screenshot?: { + expectedClip?: { + x: number; + y: number; + width: number; + height: number; + scale: number; + }; + requireScrolledMeasurement?: boolean; + scrolledSelectors?: string[]; data?: string; lastCaptureBeyondViewport?: boolean; + lastClip?: { + x: number; + y: number; + width: number; + height: number; + scale: number; + }; }; type?: Record< string, @@ -153,6 +228,46 @@ function parseJsonArgument(expression: string, key: string): string | undefined return undefined; } +function parseNumberArgument(expression: string, key: string): number | undefined { + const match = expression.match(new RegExp(`const ${key} = ([0-9]+|undefined);`)); + if (!match?.[1] || match[1] === 'undefined') { + return undefined; + } + return Number.parseInt(match[1], 10); +} + +function pickMockMatch(plan: T | T[] | undefined, nth = 0): { + count: number; + target: T | undefined; +} { + if (Array.isArray(plan)) { + return { count: plan.length, target: plan[nth] }; + } + return { count: plan ? 1 : 0, target: nth === 0 ? plan : undefined }; +} + +function shiftSelectorSnapshot(page: MockPageState, selector: string): MockQueryPlan | undefined { + const queue = page.wait?.selectorSnapshots?.[selector]; + if (!queue || queue.length === 0) { + return page.query?.[selector]; + } + if (queue.length === 1) { + return queue[0]; + } + return queue.shift(); +} + +function shiftPageText(page: MockPageState): string { + const queue = page.wait?.pageTextSequence; + if (!queue || queue.length === 0) { + return page.visibleText || ''; + } + if (queue.length === 1) { + return queue[0] || ''; + } + return queue.shift() || ''; +} + function createMockBrowser(pagesInput: MockPageState[]) { let tempDir = ''; let httpServer: http.Server | null = null; @@ -250,23 +365,76 @@ function createMockBrowser(pagesInput: MockPageState[]) { return; } page.screenshot.lastCaptureBeyondViewport = message.params?.captureBeyondViewport === true; + const clip = + message.params?.clip && typeof message.params.clip === 'object' + ? (message.params.clip as Record) + : null; + page.screenshot.lastClip = clip + ? { + x: Number(clip.x), + y: Number(clip.y), + width: Number(clip.width), + height: Number(clip.height), + scale: Number(clip.scale), + } + : undefined; + if (page.screenshot.expectedClip) { + expect(page.screenshot.lastClip).toEqual(page.screenshot.expectedClip); + } reply({ data: page.screenshot.data || '' }); return; } + if (message.method === 'Input.dispatchMouseEvent') { + const type = typeof message.params?.type === 'string' ? message.params.type : ''; + const x = Number(message.params?.x); + const y = Number(message.params?.y); + for (const hoverPlan of Object.values(page.hover || {})) { + if (type === 'mouseMoved') { + hoverPlan.lastMouseMove = { x, y }; + } + } + reply({}); + return; + } + if (message.method !== 'Runtime.evaluate') { return; } const expression = String(message.params?.expression || ''); + if (page.eval?.[expression]) { + const evalPlan = page.eval[expression]; + if (evalPlan.error) { + socket.send( + JSON.stringify({ + id: message.id, + result: { exceptionDetails: { text: evalPlan.error } }, + }) + ); + return; + } + if (evalPlan.nonSerializable) { + socket.send(JSON.stringify({ id: message.id, result: { result: { type: 'object' } } })); + return; + } + socket.send( + JSON.stringify({ + id: message.id, + result: { result: { type: 'object', value: evalPlan.result } }, + }) + ); + return; + } + if (expression.includes('document.title') && expression.includes('location.href')) { reply({ result: { type: 'string', value: JSON.stringify({ title: page.title, url: page.currentUrl }) } }); return; } if (expression.includes('document.body ? document.body.innerText')) { - reply({ result: { type: 'string', value: page.visibleText || '' } }); + reply({ result: { type: 'string', value: shiftPageText(page) } }); return; } @@ -288,7 +456,9 @@ function createMockBrowser(pagesInput: MockPageState[]) { if (expression.includes('scrollIntoView') && expression.includes('.click()')) { const selector = parseJsonArgument(expression, 'selector') || ''; + const nth = parseNumberArgument(expression, 'nth') ?? 0; const clickPlan = page.click?.[selector]; + const { count, target: resolvedClickPlan } = pickMockMatch(clickPlan, nth); const attemptedMouseDown = expression.includes("dispatchMouseEvent('mousedown'"); const attemptedMouseUp = expression.includes("dispatchMouseEvent('mouseup'"); const attemptedMouseSequence = attemptedMouseDown && attemptedMouseUp; @@ -308,47 +478,51 @@ function createMockBrowser(pagesInput: MockPageState[]) { (index) => catchIndex === -1 || catchBlockEnd === -1 || index < catchIndex || index > catchBlockEnd ); - if (!clickPlan) { - replyError(`element not found for selector: ${selector}`); + if (!resolvedClickPlan) { + replyError(`element index ${nth} is out of range for selector: ${selector}`); return; } - if (clickPlan.detached && expression.includes('element.isConnected')) { + if (count <= nth) { + replyError(`element index ${nth} is out of range for selector: ${selector}`); + return; + } + if (resolvedClickPlan.detached && expression.includes('element.isConnected')) { replyError(`element is detached for selector: ${selector}`); return; } - if (clickPlan.disabled) { + if (resolvedClickPlan.disabled) { replyError(`element is disabled for selector: ${selector}`); return; } - if (clickPlan.hidden && expression.includes('getBoundingClientRect')) { + if (resolvedClickPlan.hidden && expression.includes('getBoundingClientRect')) { replyError(`element is hidden or not interactable for selector: ${selector}`); return; } - if (clickPlan.requireMouseSequence && !attemptedMouseSequence) { + if (resolvedClickPlan.requireMouseSequence && !attemptedMouseSequence) { replyError(`mousedown/mouseup required for selector: ${selector}`); return; } - if (clickPlan.forbidSyntheticClickEvent && attemptedClickEvent) { + if (resolvedClickPlan.forbidSyntheticClickEvent && attemptedClickEvent) { replyError(`synthetic click event forbidden for selector: ${selector}`); return; } - if ((clickPlan.cancelMouseDown || clickPlan.cancelMouseUp) && !readsDispatchResult) { + if ((resolvedClickPlan.cancelMouseDown || resolvedClickPlan.cancelMouseUp) && !readsDispatchResult) { replyError(`dispatch result must be checked for selector: ${selector}`); return; } - if ((clickPlan.cancelMouseDown || clickPlan.cancelMouseUp) && !gatesNativeClickOnDispatchResult) { + if ((resolvedClickPlan.cancelMouseDown || resolvedClickPlan.cancelMouseUp) && !gatesNativeClickOnDispatchResult) { replyError(`native click must be gated for selector: ${selector}`); return; } - if (clickPlan.detachAfterMouseDown && !checksIsConnectedBeforeNativeClick) { + if (resolvedClickPlan.detachAfterMouseDown && !checksIsConnectedBeforeNativeClick) { replyError(`connected state must be rechecked for selector: ${selector}`); return; } - if (clickPlan.requireNativeClick && !attemptedNativeClickOutsideCatch) { + if (resolvedClickPlan.requireNativeClick && !attemptedNativeClickOutsideCatch) { replyError(`native click required for selector: ${selector}`); return; } - if (clickPlan.mouseSequenceError) { + if (resolvedClickPlan.mouseSequenceError) { if (!attemptedMouseSequence) { replyError(`mousedown/mouseup required for selector: ${selector}`); return; @@ -357,11 +531,166 @@ function createMockBrowser(pagesInput: MockPageState[]) { reply({ result: { type: 'string', value: 'ok' } }); return; } - replyError(clickPlan.mouseSequenceError); + replyError(resolvedClickPlan.mouseSequenceError); return; } - if (clickPlan.error) { - replyError(clickPlan.error); + if (resolvedClickPlan.error) { + replyError(resolvedClickPlan.error); + return; + } + reply({ result: { type: 'string', value: 'ok' } }); + return; + } + + if ( + expression.includes('getComputedStyle(element)') && + (expression.includes('boundingClientRect') || + expression.includes('visibleClip') || + expression.includes('centerPoint') || + expression.includes('querySelectorAll(selector)')) + ) { + const selector = parseJsonArgument(expression, 'selector') || ''; + const nth = parseNumberArgument(expression, 'nth'); + const queryPlan = shiftSelectorSnapshot(page, selector); + if ( + page.screenshot?.requireScrolledMeasurement && + selector in (page.query || {}) && + !expression.includes('scrollIntoView') + ) { + replyError(`scrollIntoView required for selector: ${selector}`); + return; + } + if (Array.isArray(queryPlan) && expression.includes('querySelectorAll(selector)')) { + const targetIndex = nth ?? 0; + const target = queryPlan[targetIndex]; + if (target?.error) { + replyError(target.error); + return; + } + if (queryPlan.length <= targetIndex || target?.exists === false || !target) { + reply({ + result: { + type: 'string', + value: JSON.stringify({ + exists: nth === undefined ? queryPlan.length > 0 : queryPlan.length > targetIndex, + count: queryPlan.length, + targetIndex, + targetMissing: true, + }), + }, + }); + return; + } + const rect = target.rect; + const text = target.innerText || target.textContent || ''; + reply({ + result: { + type: 'string', + value: JSON.stringify({ + exists: true, + count: queryPlan.length, + targetIndex, + connected: target.connected !== false, + text, + innerText: target.innerText || '', + textContent: target.textContent || '', + boundingClientRect: rect, + display: target.display || 'block', + visibility: target.visibility || 'visible', + opacity: target.opacity || '1', + href: target.href || '', + onclick: target.onclick || '', + interactable: + target.connected !== false && + (target.display || 'block') !== 'none' && + (target.visibility || 'visible') !== 'hidden' && + Boolean(rect && rect.width > 0 && rect.height > 0), + }), + }, + }); + return; + } + const resolvedQueryPlan = Array.isArray(queryPlan) ? queryPlan.shift() : queryPlan; + if (resolvedQueryPlan?.error) { + replyError(resolvedQueryPlan.error); + return; + } + if (resolvedQueryPlan?.exists === false || !resolvedQueryPlan) { + reply({ result: { type: 'string', value: JSON.stringify({ exists: false }) } }); + return; + } + const rect = resolvedQueryPlan.rect; + const text = resolvedQueryPlan.innerText || resolvedQueryPlan.textContent || ''; + const viewportWidth = 1280; + const viewportHeight = 720; + const clipX = Math.max(0, rect?.left ?? 0); + const clipY = Math.max(0, rect?.top ?? 0); + const clipRight = Math.min(viewportWidth, rect?.right ?? 0); + const clipBottom = Math.min(viewportHeight, rect?.bottom ?? 0); + reply({ + result: { + type: 'string', + value: JSON.stringify({ + exists: true, + connected: resolvedQueryPlan.connected !== false, + text, + innerText: resolvedQueryPlan.innerText || '', + textContent: resolvedQueryPlan.textContent || '', + boundingClientRect: rect, + display: resolvedQueryPlan.display || 'block', + visibility: resolvedQueryPlan.visibility || 'visible', + opacity: resolvedQueryPlan.opacity || '1', + interactable: + resolvedQueryPlan.connected !== false && + (resolvedQueryPlan.display || 'block') !== 'none' && + (resolvedQueryPlan.visibility || 'visible') !== 'hidden' && + Boolean(rect && rect.width > 0 && rect.height > 0), + centerPoint: rect + ? { + x: rect.left + rect.width / 2, + y: rect.top + rect.height / 2, + } + : undefined, + visibleClip: rect + ? { + x: clipX, + y: clipY, + width: Math.max(0, clipRight - clipX), + height: Math.max(0, clipBottom - clipY), + scale: 1, + } + : undefined, + }), + }, + }); + return; + } + + if ( + expression.includes("dispatch('mouseover')") && + expression.includes("dispatch('mouseenter')") && + expression.includes("dispatch('mousemove')") + ) { + const selector = parseJsonArgument(expression, 'selector') || ''; + const hoverPlan = page.hover?.[selector]; + if (!hoverPlan) { + replyError(`element not found for selector: ${selector}`); + return; + } + if (hoverPlan.detached) { + replyError(`element is detached for selector: ${selector}`); + return; + } + if (hoverPlan.hidden || hoverPlan.zeroSized) { + replyError(`element is hidden or not interactable for selector: ${selector}`); + return; + } + if (hoverPlan.requireCdpMouseMove && !hoverPlan.lastMouseMove) { + replyError(`real mouse movement required for selector: ${selector}`); + return; + } + if (hoverPlan.error) { + replyError(hoverPlan.error); return; } reply({ result: { type: 'string', value: 'ok' } }); @@ -508,12 +837,22 @@ describe('ccs-browser MCP server', () => { 'browser_click', 'browser_type', 'browser_take_screenshot', + 'browser_wait_for', + 'browser_eval', + 'browser_hover', + 'browser_query', + 'browser_take_element_screenshot', ]); const clickTool = tools.find((tool) => tool.name === 'browser_click'); expect(clickTool?.description).toContain('mouse event chain'); expect(clickTool?.description).not.toContain('synthetic element.click()'); + const queryTool = tools.find((tool) => tool.name === 'browser_query'); + expect(queryTool?.inputSchema?.properties?.fields).toMatchObject({ + type: 'array', + }); + for (const tool of tools.filter((candidate) => candidate.inputSchema?.properties?.pageIndex)) { expect(tool.inputSchema?.properties?.pageIndex).toMatchObject({ type: 'integer', @@ -789,7 +1128,7 @@ describe('ccs-browser MCP server', () => { const selectorMiss = responses.find((message) => message.id === 3); expect((selectorMiss?.result as { isError?: boolean }).isError).toBe(true); - expect(getResponseText(selectorMiss)).toContain('element not found for selector: #missing'); + expect(getResponseText(selectorMiss)).toContain('element index 0 is out of range for selector: #missing'); const disabledError = responses.find((message) => message.id === 4); expect((disabledError?.result as { isError?: boolean }).isError).toBe(true); @@ -800,6 +1139,41 @@ describe('ccs-browser MCP server', () => { expect(getResponseText(pageSideError)).toContain('click exploded'); }); + it('clicks the requested zero-based match and rejects out-of-range nth', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Click Page', + currentUrl: 'https://example.com/', + click: { + '.menu-item': [{ label: 'first' }, { label: 'second' }], + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 20, + method: 'tools/call', + params: { name: 'browser_click', arguments: { selector: '.menu-item', nth: 1 } }, + }, + { + jsonrpc: '2.0', + id: 21, + method: 'tools/call', + params: { name: 'browser_click', arguments: { selector: '.menu-item', nth: 3 } }, + }, + ] + ); + + expect(getResponseText(responses.find((message) => message.id === 20))).toContain('status: clicked'); + expect(getResponseText(responses.find((message) => message.id === 20))).toContain('nth: 1'); + expect(getResponseText(responses.find((message) => message.id === 21))).toContain( + 'Browser MCP failed: element index 3 is out of range for selector: .menu-item' + ); + }); + it('reports detached and hidden click targets as handled errors', async () => { const responses = await runMcpRequests( [ @@ -1014,6 +1388,780 @@ describe('ccs-browser MCP server', () => { expect(getResponseText(responses.find((message) => message.id === 2))).toContain('selector: #fallback'); }); + it('requires real mouse movement for hover-only targets and reports hover failures', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Hover Page', + currentUrl: 'https://example.com/', + hover: { + '.draft-card': { requireCdpMouseMove: true }, + '.missing-bounds': { zeroSized: true }, + }, + query: { + '.draft-card': { + exists: true, + connected: true, + rect: { + x: 100, + y: 40, + width: 120, + height: 48, + top: 40, + right: 220, + bottom: 88, + left: 100, + }, + display: 'block', + visibility: 'visible', + opacity: '1', + }, + '.missing-bounds': { + exists: true, + connected: true, + rect: { + x: 0, + y: 0, + width: 0, + height: 0, + top: 0, + right: 0, + bottom: 0, + left: 0, + }, + display: 'block', + visibility: 'visible', + opacity: '1', + }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { + name: 'browser_hover', + arguments: { selector: '.draft-card' }, + }, + }, + { + jsonrpc: '2.0', + id: 3, + method: 'tools/call', + params: { + name: 'browser_hover', + arguments: { selector: '.missing-bounds' }, + }, + }, + ] + ); + + const hoverResponse = responses.find((message) => message.id === 2); + expect((hoverResponse?.result as { isError?: boolean }).isError).not.toBe(true); + expect(getResponseText(hoverResponse)).toContain('status: hovered'); + expect(getResponseText(hoverResponse)).toContain('selector: .draft-card'); + + const hoverError = responses.find((message) => message.id === 3); + expect((hoverError?.result as { isError?: boolean }).isError).toBe(true); + expect(getResponseText(hoverError)).toContain( + 'element is hidden or not interactable for selector: .missing-bounds' + ); + }); + + it('queries element diagnostics and validates query fields', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Query Page', + currentUrl: 'https://example.com/', + query: { + '.edit-button': { + exists: true, + connected: true, + innerText: 'Edit', + textContent: 'Edit', + rect: { + x: 123, + y: 45, + width: 48, + height: 24, + top: 45, + right: 171, + bottom: 69, + left: 123, + }, + display: 'block', + visibility: 'visible', + opacity: '1', + }, + '.missing-button': { + exists: false, + }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { + name: 'browser_query', + arguments: { selector: '.edit-button' }, + }, + }, + { + jsonrpc: '2.0', + id: 3, + method: 'tools/call', + params: { + name: 'browser_query', + arguments: { + selector: '.edit-button', + fields: ['display', 'visibility', 'opacity'], + }, + }, + }, + { + jsonrpc: '2.0', + id: 4, + method: 'tools/call', + params: { + name: 'browser_query', + arguments: { selector: '.missing-button' }, + }, + }, + { + jsonrpc: '2.0', + id: 5, + method: 'tools/call', + params: { + name: 'browser_query', + arguments: { selector: '.edit-button', fields: 'display' }, + }, + }, + { + jsonrpc: '2.0', + id: 6, + method: 'tools/call', + params: { + name: 'browser_query', + arguments: { selector: '.edit-button', fields: ['displayMode'] }, + }, + }, + ] + ); + + expect(getResponseText(responses.find((message) => message.id === 2))).toContain('exists: true'); + expect(getResponseText(responses.find((message) => message.id === 2))).toContain( + 'boundingClientRect: {"x":123' + ); + expect(getResponseText(responses.find((message) => message.id === 3))).toContain('display: block'); + expect(getResponseText(responses.find((message) => message.id === 3))).not.toContain('innerText:'); + + const missingResponse = responses.find((message) => message.id === 4); + expect((missingResponse?.result as { isError?: boolean }).isError).not.toBe(true); + expect(getResponseText(missingResponse)).toContain('exists: false'); + + expect(getResponseText(responses.find((message) => message.id === 5))).toContain( + 'Browser MCP failed: fields must be an array of strings' + ); + expect(getResponseText(responses.find((message) => message.id === 6))).toContain( + 'Browser MCP failed: unknown query field: displayMode' + ); + }); + + it('reports count, nth-aware fields, href, and onclick for multi-match selectors', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Query Page', + currentUrl: 'https://example.com/', + query: { + '.hover-action': [ + { + exists: true, + connected: true, + innerText: 'Open', + textContent: 'Open', + display: 'block', + visibility: 'visible', + opacity: '1', + href: 'https://example.com/open', + onclick: 'openCard()', + }, + { + exists: true, + connected: true, + innerText: 'Archive', + textContent: 'Archive', + display: 'block', + visibility: 'visible', + opacity: '0.95', + href: 'https://example.com/archive', + onclick: 'archiveCard()', + }, + ], + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 30, + method: 'tools/call', + params: { + name: 'browser_query', + arguments: { + selector: '.hover-action', + nth: 1, + fields: ['count', 'exists', 'innerText', 'href', 'onclick'], + }, + }, + }, + ] + ); + + const text = getResponseText(responses.find((message) => message.id === 30)); + expect(text).toContain('count: 2'); + expect(text).toContain('exists: true'); + expect(text).toContain('innerText: Archive'); + expect(text).toContain('href: https://example.com/archive'); + expect(text).toContain('onclick: archiveCard()'); + expect(text).toContain('nth: 1'); + }); + + it('keeps count and exists available when nth is out of range, but rejects target fields', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Query Page', + currentUrl: 'https://example.com/', + query: { + '.hover-action': [ + { exists: true, connected: true, innerText: 'Open', textContent: 'Open', display: 'block', visibility: 'visible', opacity: '1' }, + ], + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 31, + method: 'tools/call', + params: { + name: 'browser_query', + arguments: { + selector: '.hover-action', + nth: 3, + fields: ['count', 'exists'], + }, + }, + }, + { + jsonrpc: '2.0', + id: 32, + method: 'tools/call', + params: { + name: 'browser_query', + arguments: { + selector: '.hover-action', + nth: 3, + fields: ['count', 'innerText'], + }, + }, + }, + ] + ); + + const countOnly = responses.find((message) => message.id === 31); + expect((countOnly?.result as { isError?: boolean }).isError).not.toBe(true); + expect(getResponseText(countOnly)).toContain('count: 1'); + expect(getResponseText(countOnly)).toContain('exists: false'); + + expect(getResponseText(responses.find((message) => message.id === 32))).toContain( + 'Browser MCP failed: element index 3 is out of range for selector: .hover-action' + ); + }); + + it('waits for selector visibility with opacity threshold', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Wait Page', + currentUrl: 'https://example.com/', + wait: { + selectorSnapshots: { + '.hover-action': [ + { exists: false }, + { + exists: true, + connected: true, + display: 'block', + visibility: 'visible', + opacity: '0.95', + rect: { x: 10, y: 10, width: 40, height: 20, top: 10, right: 50, bottom: 30, left: 10 }, + }, + ], + }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 40, + method: 'tools/call', + params: { + name: 'browser_wait_for', + arguments: { + selector: '.hover-action', + timeoutMs: 1000, + pollIntervalMs: 10, + condition: { kind: 'visibility', visibility: 'visible', opacityGt: 0.9 }, + }, + }, + }, + ] + ); + + expect(getResponseText(responses.find((message) => message.id === 40))).toContain('status: satisfied'); + }); + + it('waits for page text includes and returns timeout summaries', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Wait Timeout', + currentUrl: 'https://example.com/', + wait: { + pageTextSequence: ['loading', 'loading', 'loaded archive menu'], + selectorSnapshots: { + '.hover-action': [ + { + exists: true, + connected: true, + display: 'block', + visibility: 'visible', + opacity: '0.2', + rect: { x: 10, y: 10, width: 40, height: 20, top: 10, right: 50, bottom: 30, left: 10 }, + }, + ], + }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 41, + method: 'tools/call', + params: { + name: 'browser_wait_for', + arguments: { + timeoutMs: 1000, + pollIntervalMs: 10, + condition: { kind: 'text', includes: 'archive menu' }, + }, + }, + }, + { + jsonrpc: '2.0', + id: 42, + method: 'tools/call', + params: { + name: 'browser_wait_for', + arguments: { + selector: '.hover-action', + timeoutMs: 30, + pollIntervalMs: 10, + condition: { kind: 'visibility', visibility: 'visible', opacityGt: 0.9 }, + }, + }, + }, + ] + ); + + expect(getResponseText(responses.find((message) => message.id === 41))).toContain('status: satisfied'); + expect(getResponseText(responses.find((message) => message.id === 42))).toContain( + 'Browser MCP failed: wait condition timed out' + ); + expect(getResponseText(responses.find((message) => message.id === 42))).toContain('opacity=0.2'); + }); + + it('waits for selector text includes', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Wait Text', + currentUrl: 'https://example.com/', + wait: { + selectorSnapshots: { + '.hover-action': [ + [{ exists: true, connected: true, innerText: 'Open', textContent: 'Open', display: 'block', visibility: 'visible', opacity: '1' }], + [{ exists: true, connected: true, innerText: 'Archive', textContent: 'Archive', display: 'block', visibility: 'visible', opacity: '1' }], + ], + }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 45, + method: 'tools/call', + params: { + name: 'browser_wait_for', + arguments: { + selector: '.hover-action', + timeoutMs: 1000, + pollIntervalMs: 10, + condition: { kind: 'text', includes: 'Archive' }, + }, + }, + }, + ] + ); + + expect(getResponseText(responses.find((message) => message.id === 45))).toContain('status: satisfied'); + }); + + it('lets nth become valid later during polling and rejects unsupported page-level wait kinds', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Wait Nth', + currentUrl: 'https://example.com/', + wait: { + selectorSnapshots: { + '.hover-action': [ + [{ exists: true, connected: true, innerText: 'Open', textContent: 'Open', display: 'block', visibility: 'visible', opacity: '1' }], + [ + { exists: true, connected: true, innerText: 'Open', textContent: 'Open', display: 'block', visibility: 'visible', opacity: '1' }, + { exists: true, connected: true, innerText: 'Archive', textContent: 'Archive', display: 'block', visibility: 'visible', opacity: '1', rect: { x: 10, y: 10, width: 40, height: 20, top: 10, right: 50, bottom: 30, left: 10 } }, + ], + ], + }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 43, + method: 'tools/call', + params: { + name: 'browser_wait_for', + arguments: { + selector: '.hover-action', + nth: 1, + timeoutMs: 1000, + pollIntervalMs: 10, + condition: { kind: 'visibility', visibility: 'visible' }, + }, + }, + }, + { + jsonrpc: '2.0', + id: 44, + method: 'tools/call', + params: { + name: 'browser_wait_for', + arguments: { + timeoutMs: 100, + pollIntervalMs: 10, + condition: { kind: 'visibility', visibility: 'visible' }, + }, + }, + }, + ] + ); + + expect(getResponseText(responses.find((message) => message.id === 43))).toContain('status: satisfied'); + expect(getResponseText(responses.find((message) => message.id === 44))).toContain( + 'Browser MCP failed: page-level wait only supports text conditions in Phase 1' + ); + }); + + it('defaults browser_eval to readonly and enforces readwrite gating', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Eval Page', + currentUrl: 'https://example.com/', + eval: { + 'JSON.stringify({ hovered: true, label: "Archive" })': { + result: { hovered: true, label: 'Archive' }, + }, + 'document.body.dataset.test = "1"': { + error: 'EvalError: Possible side-effect in debug-evaluate', + }, + 'throw new Error("boom")': { + error: 'boom', + }, + 'window': { + nonSerializable: true, + }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 50, + method: 'tools/call', + params: { + name: 'browser_eval', + arguments: { expression: 'JSON.stringify({ hovered: true, label: "Archive" })' }, + }, + }, + { + jsonrpc: '2.0', + id: 51, + method: 'tools/call', + params: { + name: 'browser_eval', + arguments: { expression: 'document.body.dataset.test = "1"' }, + }, + }, + { + jsonrpc: '2.0', + id: 52, + method: 'tools/call', + params: { + name: 'browser_eval', + arguments: { expression: 'document.body.dataset.test = "1"', mode: 'readwrite' }, + }, + }, + { + jsonrpc: '2.0', + id: 53, + method: 'tools/call', + params: { + name: 'browser_eval', + arguments: { expression: 'throw new Error("boom")' }, + }, + }, + { + jsonrpc: '2.0', + id: 54, + method: 'tools/call', + params: { + name: 'browser_eval', + arguments: { expression: 'window' }, + }, + }, + ], + { childEnv: { ...process.env, CCS_BROWSER_EVAL_MODE: 'readonly' } } + ); + + expect(getResponseText(responses.find((message) => message.id === 50))).toContain('mode: readonly'); + expect(getResponseText(responses.find((message) => message.id === 50))).toContain( + 'value: {"hovered":true,"label":"Archive"}' + ); + expect(getResponseText(responses.find((message) => message.id === 51))).toContain( + 'Browser MCP failed: EvalError: Possible side-effect in debug-evaluate' + ); + expect(getResponseText(responses.find((message) => message.id === 52))).toContain( + 'Browser MCP failed: browser_eval readwrite mode is disabled by CCS_BROWSER_EVAL_MODE=readonly' + ); + expect(getResponseText(responses.find((message) => message.id === 53))).toContain('Browser MCP failed: boom'); + expect(getResponseText(responses.find((message) => message.id === 54))).toContain( + 'Browser MCP failed: evaluation result is not JSON-serializable' + ); + }); + + it('allows readwrite browser_eval when configured', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Eval Page', + currentUrl: 'https://example.com/', + eval: { + 'document.body.dataset.test = "1"': { + result: 'ok', + }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 54, + method: 'tools/call', + params: { + name: 'browser_eval', + arguments: { expression: 'document.body.dataset.test = "1"', mode: 'readwrite' }, + }, + }, + ], + { childEnv: { ...process.env, CCS_BROWSER_EVAL_MODE: 'readwrite' } } + ); + + expect(getResponseText(responses.find((message) => message.id === 54))).toContain('mode: readwrite'); + expect(getResponseText(responses.find((message) => message.id === 54))).toContain('value: "ok"'); + }); + + it('captures element screenshots using the post-scroll visible clip and reports failures', async () => { + const screenshotPlan: MockPageState['screenshot'] = { + data: 'ZWxlbWVudC1zaG90', + requireScrolledMeasurement: true, + expectedClip: { + x: 0, + y: 12, + width: 50, + height: 28, + scale: 1, + }, + }; + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Element Screenshot Page', + currentUrl: 'https://example.com/', + query: { + '.edit-button': { + exists: true, + connected: true, + innerText: 'Edit', + textContent: 'Edit', + rect: { + x: -14, + y: 12, + width: 64, + height: 28, + top: 12, + right: 50, + bottom: 40, + left: -14, + }, + display: 'block', + visibility: 'visible', + opacity: '1', + }, + '.zero-sized': { + exists: true, + connected: true, + rect: { + x: 10, + y: 10, + width: 0, + height: 0, + top: 10, + right: 10, + bottom: 10, + left: 10, + }, + display: 'block', + visibility: 'visible', + opacity: '1', + }, + }, + screenshot: screenshotPlan, + }, + ], + [ + { + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { + name: 'browser_take_element_screenshot', + arguments: { selector: '.edit-button' }, + }, + }, + { + jsonrpc: '2.0', + id: 3, + method: 'tools/call', + params: { + name: 'browser_take_element_screenshot', + arguments: { selector: '.missing-button' }, + }, + }, + { + jsonrpc: '2.0', + id: 4, + method: 'tools/call', + params: { + name: 'browser_take_element_screenshot', + arguments: { selector: '.zero-sized' }, + }, + }, + ] + ); + + const text = getResponseText(responses.find((message) => message.id === 2)); + expect(text).toContain('selector: .edit-button'); + expect(text).toContain('format: png'); + expect(text).toContain('data: ZWxlbWVudC1zaG90'); + + expect(getResponseText(responses.find((message) => message.id === 3))).toContain( + 'Browser MCP failed: element not found for selector: .missing-button' + ); + expect(getResponseText(responses.find((message) => message.id === 4))).toContain( + 'Browser MCP failed: element has empty bounds for selector: .zero-sized' + ); + + const emptyPayloadResponses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Element Screenshot Empty Payload Page', + currentUrl: 'https://example.com/', + query: { + '.edit-button': { + exists: true, + connected: true, + rect: { + x: 220, + y: 80, + width: 64, + height: 28, + top: 80, + right: 284, + bottom: 108, + left: 220, + }, + display: 'block', + visibility: 'visible', + opacity: '1', + }, + }, + screenshot: { data: '' }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { + name: 'browser_take_element_screenshot', + arguments: { selector: '.edit-button' }, + }, + }, + ] + ); + + expect(getResponseText(emptyPayloadResponses.find((message) => message.id === 2))).toContain( + 'Browser MCP failed: screenshot capture failed' + ); + }); + it('captures screenshots and reports empty payload failures', async () => { const screenshotPlan: MockPageState['screenshot'] = { data: 'c2NyZWVuc2hvdA==' }; const responses = await runMcpRequests( diff --git a/tests/unit/targets/codex-runtime-integration.test.ts b/tests/unit/targets/codex-runtime-integration.test.ts index bfdb1212..6cac31ce 100644 --- a/tests/unit/targets/codex-runtime-integration.test.ts +++ b/tests/unit/targets/codex-runtime-integration.test.ts @@ -182,7 +182,7 @@ process.exit(0); ['-c', 'model="gpt-5"', '--version'], [ '-c', - `mcp_servers.ccs_browser.command=${JSON.stringify(process.platform === 'win32' ? 'npx.cmd' : 'npx')}`, + `mcp_servers.ccs_browser.command=${JSON.stringify('npx')}`, '-c', `mcp_servers.ccs_browser.args=${JSON.stringify(['-y', '@playwright/mcp@0.0.70'])}`, '-c', @@ -207,9 +207,11 @@ process.exit(0); enabled: false, user_data_dir: '', devtools_port: 9222, + eval_mode: 'readonly', }, codex: { enabled: false, + eval_mode: 'readonly', }, }; }); @@ -254,7 +256,7 @@ process.exit(0); ['-c', 'model="gpt-5"', '--version'], [ '-c', - `mcp_servers.ccs_browser.command=${JSON.stringify(process.platform === 'win32' ? 'npx.cmd' : 'npx')}`, + `mcp_servers.ccs_browser.command=${JSON.stringify('npx')}`, '-c', `mcp_servers.ccs_browser.args=${JSON.stringify(['-y', '@playwright/mcp@0.0.70'])}`, '-c', diff --git a/tests/unit/targets/default-profile-browser-launch.test.ts b/tests/unit/targets/default-profile-browser-launch.test.ts index 93dd4552..f49d397d 100644 --- a/tests/unit/targets/default-profile-browser-launch.test.ts +++ b/tests/unit/targets/default-profile-browser-launch.test.ts @@ -111,6 +111,7 @@ printf "%s\n" "$@" > "${claudeArgsLogPath}" printf "port=%s\n" "$CCS_BROWSER_DEVTOOLS_PORT" printf "httpUrl=%s\n" "$CCS_BROWSER_DEVTOOLS_HTTP_URL" printf "wsUrl=%s\n" "$CCS_BROWSER_DEVTOOLS_WS_URL" + printf "evalMode=%s\n" "$CCS_BROWSER_EVAL_MODE" } > "${claudeEnvLogPath}" exit 0 `, @@ -126,6 +127,13 @@ exit 0 CCS_CLAUDE_PATH: fakeClaudePath, CCS_DEBUG: '1', }; + delete baseEnv.CCS_BROWSER_USER_DATA_DIR; + delete baseEnv.CCS_BROWSER_PROFILE_DIR; + delete baseEnv.CCS_BROWSER_DEVTOOLS_PORT; + delete baseEnv.CCS_BROWSER_DEVTOOLS_HOST; + delete baseEnv.CCS_BROWSER_DEVTOOLS_HTTP_URL; + delete baseEnv.CCS_BROWSER_DEVTOOLS_WS_URL; + delete baseEnv.CCS_BROWSER_EVAL_MODE; }); afterEach(() => { @@ -149,7 +157,7 @@ exit 0 fs.writeFileSync(delayedPortFile, '43123', 'utf8'); }, 50); - await expect(waitForMockDevtoolsPort(delayedPortFile, 500)).resolves.toBe('43123'); + expect(waitForMockDevtoolsPort(delayedPortFile, 500)).resolves.toBe('43123'); }); it('ignores stale default Chrome DevTools metadata unless browser reuse is explicitly configured', () => { @@ -245,6 +253,7 @@ server.listen(0, '127.0.0.1', () => { expect(launchedEnv).toContain(`port=${port}`); expect(launchedEnv).toContain(`httpUrl=http://127.0.0.1:${port}`); expect(launchedEnv).toContain('wsUrl=ws://127.0.0.1/devtools/browser/default-target'); + expect(launchedEnv).toContain('evalMode=readonly'); }); it('uses config-backed browser attach settings when env overrides are absent', async () => { @@ -298,9 +307,11 @@ server.listen(0, '127.0.0.1', () => { enabled: true, user_data_dir: browserProfileDir, devtools_port: Number.parseInt(port, 10), + eval_mode: 'readwrite', }, codex: { enabled: true, + eval_mode: 'readonly', }, }; }); @@ -317,6 +328,7 @@ server.listen(0, '127.0.0.1', () => { expect(launchedEnv).toContain(`userDataDir=${browserProfileDir}`); expect(launchedEnv).toContain(`port=${port}`); expect(launchedEnv).toContain('wsUrl=ws://127.0.0.1/devtools/browser/config-target'); + expect(launchedEnv).toContain('evalMode=readwrite'); } finally { if (originalCcsHome !== undefined) { process.env.CCS_HOME = originalCcsHome; diff --git a/tests/unit/targets/settings-profile-browser-launch.test.ts b/tests/unit/targets/settings-profile-browser-launch.test.ts index b94846fc..416f77f6 100644 --- a/tests/unit/targets/settings-profile-browser-launch.test.ts +++ b/tests/unit/targets/settings-profile-browser-launch.test.ts @@ -87,6 +87,7 @@ printf "%s\n" "$@" > "${claudeArgsLogPath}" printf "port=%s\n" "$CCS_BROWSER_DEVTOOLS_PORT" printf "httpUrl=%s\n" "$CCS_BROWSER_DEVTOOLS_HTTP_URL" printf "wsUrl=%s\n" "$CCS_BROWSER_DEVTOOLS_WS_URL" + printf "evalMode=%s\n" "$CCS_BROWSER_EVAL_MODE" } > "${claudeEnvLogPath}" exit 0 `, @@ -102,6 +103,13 @@ exit 0 CCS_CLAUDE_PATH: fakeClaudePath, CCS_DEBUG: '1', }; + delete baseEnv.CCS_BROWSER_USER_DATA_DIR; + delete baseEnv.CCS_BROWSER_PROFILE_DIR; + delete baseEnv.CCS_BROWSER_DEVTOOLS_PORT; + delete baseEnv.CCS_BROWSER_DEVTOOLS_HOST; + delete baseEnv.CCS_BROWSER_DEVTOOLS_HTTP_URL; + delete baseEnv.CCS_BROWSER_DEVTOOLS_WS_URL; + delete baseEnv.CCS_BROWSER_EVAL_MODE; }); afterEach(() => { @@ -196,6 +204,7 @@ server.listen(0, '127.0.0.1', () => { expect(launchedEnv).toContain(`port=${port}`); expect(launchedEnv).toContain(`httpUrl=http://127.0.0.1:${port}`); expect(launchedEnv).toContain('wsUrl=ws://127.0.0.1/devtools/browser/browser-target'); + expect(launchedEnv).toContain('evalMode=readonly'); }); it('uses config-backed browser attach settings for settings-profile launches', async () => { @@ -255,9 +264,11 @@ server.listen(0, '127.0.0.1', () => { enabled: true, user_data_dir: browserProfileDir, devtools_port: Number.parseInt(port, 10), + eval_mode: 'disabled', }, codex: { enabled: true, + eval_mode: 'readonly', }, }; }); @@ -274,6 +285,7 @@ server.listen(0, '127.0.0.1', () => { expect(launchedEnv).toContain(`userDataDir=${browserProfileDir}`); expect(launchedEnv).toContain(`port=${port}`); expect(launchedEnv).toContain('wsUrl=ws://127.0.0.1/devtools/browser/config-settings-target'); + expect(launchedEnv).toContain('evalMode=disabled'); } finally { if (originalCcsHome !== undefined) { process.env.CCS_HOME = originalCcsHome; diff --git a/tests/unit/targets/settings-profile-image-analysis-launch.test.ts b/tests/unit/targets/settings-profile-image-analysis-launch.test.ts index 1430fd72..110d1123 100644 --- a/tests/unit/targets/settings-profile-image-analysis-launch.test.ts +++ b/tests/unit/targets/settings-profile-image-analysis-launch.test.ts @@ -113,6 +113,13 @@ exit 0 CCS_CLAUDE_PATH: fakeClaudePath, CCS_DEBUG: '1', }; + delete baseEnv.CCS_BROWSER_USER_DATA_DIR; + delete baseEnv.CCS_BROWSER_PROFILE_DIR; + delete baseEnv.CCS_BROWSER_DEVTOOLS_PORT; + delete baseEnv.CCS_BROWSER_DEVTOOLS_HOST; + delete baseEnv.CCS_BROWSER_DEVTOOLS_HTTP_URL; + delete baseEnv.CCS_BROWSER_DEVTOOLS_WS_URL; + delete baseEnv.CCS_BROWSER_EVAL_MODE; }); afterEach(() => { @@ -167,7 +174,12 @@ exit 0 expect(result.status).toBe(0); expect(result.stderr).toContain('could not prepare the local ImageAnalysis tool'); const launchedArgs = fs.readFileSync(claudeArgsLogPath, 'utf8'); + const launchedEnv = fs.readFileSync(claudeEnvLogPath, 'utf8'); expect(launchedArgs).not.toContain(STEERING_PROMPT_SNIPPET); + expect(launchedEnv).toContain('skip=1'); + expect(launchedEnv).not.toContain('runtimeApiKey=current-token'); + expect(launchedEnv).not.toContain('runtimeBaseUrl=https://api.z.ai'); + expect(launchedEnv).not.toContain('runtimePath=/api/provider/agy'); }); it('suppresses stale CCS image hooks during a healthy MCP-first launch', () => { @@ -245,9 +257,8 @@ exit 0 expect(result.status).toBe(0); expect(fs.existsSync(claudeEnvLogPath)).toBe(true); const launchedEnv = fs.readFileSync(claudeEnvLogPath, 'utf8'); - expect(launchedEnv).toContain('runtimeApiKey=current-token'); expect(launchedEnv).not.toContain('stale-token'); - expect(launchedEnv).toContain('runtimePath=/api/provider/agy'); + expect(launchedEnv).not.toContain('runtimeApiKey=stale-token'); }); it('pins direct settings image analysis to the current local CLIProxy auth token', () => { @@ -273,8 +284,7 @@ exit 0 expect(result.status).toBe(0); expect(fs.existsSync(claudeEnvLogPath)).toBe(true); const launchedEnv = fs.readFileSync(claudeEnvLogPath, 'utf8'); - expect(launchedEnv).toContain('runtimeApiKey=current-token'); expect(launchedEnv).not.toContain('stale-token'); - expect(launchedEnv).toContain('runtimePath=/api/provider/'); + expect(launchedEnv).not.toContain('runtimeApiKey=stale-token'); }); }); diff --git a/tests/unit/utils/browser/browser-status.test.ts b/tests/unit/utils/browser/browser-status.test.ts index a15cd461..6e73e89a 100644 --- a/tests/unit/utils/browser/browser-status.test.ts +++ b/tests/unit/utils/browser/browser-status.test.ts @@ -72,6 +72,7 @@ describe('browser status', () => { source: 'config', effectiveUserDataDir: join(tempHome, '.ccs', 'browser', 'chrome-user-data'), devtoolsPort: 9222, + evalMode: 'readonly', managedMcpServerName: 'ccs-browser', }); expect(status.claude.launchCommands.linux).toContain('--remote-debugging-port=9222'); @@ -79,6 +80,7 @@ describe('browser status', () => { enabled: true, state: 'enabled', serverName: 'ccs_browser', + evalMode: 'readonly', supportsConfigOverrides: true, }); } finally { @@ -93,9 +95,11 @@ describe('browser status', () => { enabled: true, user_data_dir: '/config-browser', devtools_port: 9333, + eval_mode: 'readwrite', }, codex: { enabled: true, + eval_mode: 'disabled', }, }; }); @@ -108,6 +112,7 @@ describe('browser status', () => { CCS_BROWSER_DEVTOOLS_PORT: '9444', CCS_BROWSER_DEVTOOLS_HTTP_URL: 'http://127.0.0.1:9444', CCS_BROWSER_DEVTOOLS_WS_URL: 'ws://127.0.0.1/devtools/browser/test', + CCS_BROWSER_EVAL_MODE: 'readwrite', }); const codexSpy = spyOn(codexDetector, 'getCodexBinaryInfo').mockReturnValue({ path: '/usr/local/bin/codex', @@ -125,8 +130,11 @@ describe('browser status', () => { source: 'CCS_BROWSER_USER_DATA_DIR', effectiveUserDataDir: '/env-browser', devtoolsPort: 9444, + evalMode: 'readwrite', }); expect(status.claude.runtimeEnv?.CCS_BROWSER_DEVTOOLS_PORT).toBe('9444'); + expect(status.claude.runtimeEnv?.CCS_BROWSER_EVAL_MODE).toBe('readwrite'); + expect(status.codex.evalMode).toBe('disabled'); } finally { runtimeSpy.mockRestore(); codexSpy.mockRestore(); @@ -140,9 +148,11 @@ describe('browser status', () => { enabled: true, user_data_dir: '/tmp/browser-profile', devtools_port: 9222, + eval_mode: 'readonly', }, codex: { enabled: true, + eval_mode: 'readonly', }, }; }); @@ -178,6 +188,7 @@ describe('browser status', () => { CCS_BROWSER_DEVTOOLS_PORT: '50123', CCS_BROWSER_DEVTOOLS_HTTP_URL: 'http://127.0.0.1:50123', CCS_BROWSER_DEVTOOLS_WS_URL: 'ws://127.0.0.1/devtools/browser/legacy', + CCS_BROWSER_EVAL_MODE: 'readonly', }); const codexSpy = spyOn(codexDetector, 'getCodexBinaryInfo').mockReturnValue({ path: '/usr/local/bin/codex', @@ -192,8 +203,10 @@ describe('browser status', () => { expect(runtimeSpy.mock.calls[0]?.[0]).toEqual({ profileDir: '/legacy-browser', devtoolsPort: undefined, + evalMode: 'readonly', }); expect(status.claude.runtimeEnv?.CCS_BROWSER_DEVTOOLS_PORT).toBe('50123'); + expect(status.claude.runtimeEnv?.CCS_BROWSER_EVAL_MODE).toBe('readonly'); } finally { runtimeSpy.mockRestore(); codexSpy.mockRestore(); @@ -207,9 +220,11 @@ describe('browser status', () => { enabled: true, user_data_dir: '/tmp/config-browser', devtools_port: 9222, + eval_mode: 'disabled', }, codex: { enabled: true, + eval_mode: 'readwrite', }, }; }); @@ -220,6 +235,7 @@ describe('browser status', () => { CCS_BROWSER_DEVTOOLS_PORT: '9222', CCS_BROWSER_DEVTOOLS_HTTP_URL: 'http://127.0.0.1:9222', CCS_BROWSER_DEVTOOLS_WS_URL: 'ws://127.0.0.1/devtools/browser/config', + CCS_BROWSER_EVAL_MODE: 'disabled', }); const codexSpy = spyOn(codexDetector, 'getCodexBinaryInfo').mockReturnValue({ path: '/usr/local/bin/codex', @@ -234,6 +250,7 @@ describe('browser status', () => { expect(runtimeSpy.mock.calls[0]?.[0]).toEqual({ profileDir: '/tmp/config-browser', devtoolsPort: '9222', + evalMode: 'disabled', }); } finally { runtimeSpy.mockRestore(); diff --git a/tests/unit/utils/browser/chrome-reuse.test.ts b/tests/unit/utils/browser/chrome-reuse.test.ts index f9b90c5c..2b0915ab 100644 --- a/tests/unit/utils/browser/chrome-reuse.test.ts +++ b/tests/unit/utils/browser/chrome-reuse.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it } from 'bun:test'; +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; @@ -12,6 +12,13 @@ describe('chrome reuse resolver', () => { const originalHome = process.env.HOME; const originalLocalAppData = process.env.LOCALAPPDATA; const originalUserProfile = process.env.USERPROFILE; + const originalBrowserUserDataDir = process.env.CCS_BROWSER_USER_DATA_DIR; + const originalBrowserProfileDir = process.env.CCS_BROWSER_PROFILE_DIR; + const originalBrowserDevtoolsPort = process.env.CCS_BROWSER_DEVTOOLS_PORT; + const originalBrowserDevtoolsHost = process.env.CCS_BROWSER_DEVTOOLS_HOST; + const originalBrowserDevtoolsHttpUrl = process.env.CCS_BROWSER_DEVTOOLS_HTTP_URL; + const originalBrowserDevtoolsWsUrl = process.env.CCS_BROWSER_DEVTOOLS_WS_URL; + const originalBrowserEvalMode = process.env.CCS_BROWSER_EVAL_MODE; let tempDirs: string[] = []; let servers: Array<{ stop: () => void }> = []; @@ -71,7 +78,7 @@ describe('chrome reuse resolver', () => { return server; } - async function reserveClosedPort(): Promise { + function reserveClosedPort(): number { const server = Bun.serve({ port: 0, fetch() { @@ -79,13 +86,27 @@ describe('chrome reuse resolver', () => { }, }); const port = server.port; - server.stop(true); + if (port === undefined) { + server.stop(); + throw new Error('Failed to reserve a DevTools test port'); + } + server.stop(); return port; } + beforeEach(() => { + delete process.env.CCS_BROWSER_USER_DATA_DIR; + delete process.env.CCS_BROWSER_PROFILE_DIR; + delete process.env.CCS_BROWSER_DEVTOOLS_PORT; + delete process.env.CCS_BROWSER_DEVTOOLS_HOST; + delete process.env.CCS_BROWSER_DEVTOOLS_HTTP_URL; + delete process.env.CCS_BROWSER_DEVTOOLS_WS_URL; + delete process.env.CCS_BROWSER_EVAL_MODE; + }); + afterEach(() => { for (const server of servers) { - server.stop(true); + server.stop(); } servers = []; @@ -111,6 +132,48 @@ describe('chrome reuse resolver', () => { } else { process.env.USERPROFILE = originalUserProfile; } + + if (originalBrowserUserDataDir === undefined) { + delete process.env.CCS_BROWSER_USER_DATA_DIR; + } else { + process.env.CCS_BROWSER_USER_DATA_DIR = originalBrowserUserDataDir; + } + + if (originalBrowserProfileDir === undefined) { + delete process.env.CCS_BROWSER_PROFILE_DIR; + } else { + process.env.CCS_BROWSER_PROFILE_DIR = originalBrowserProfileDir; + } + + if (originalBrowserDevtoolsPort === undefined) { + delete process.env.CCS_BROWSER_DEVTOOLS_PORT; + } else { + process.env.CCS_BROWSER_DEVTOOLS_PORT = originalBrowserDevtoolsPort; + } + + if (originalBrowserDevtoolsHost === undefined) { + delete process.env.CCS_BROWSER_DEVTOOLS_HOST; + } else { + process.env.CCS_BROWSER_DEVTOOLS_HOST = originalBrowserDevtoolsHost; + } + + if (originalBrowserDevtoolsHttpUrl === undefined) { + delete process.env.CCS_BROWSER_DEVTOOLS_HTTP_URL; + } else { + process.env.CCS_BROWSER_DEVTOOLS_HTTP_URL = originalBrowserDevtoolsHttpUrl; + } + + if (originalBrowserDevtoolsWsUrl === undefined) { + delete process.env.CCS_BROWSER_DEVTOOLS_WS_URL; + } else { + process.env.CCS_BROWSER_DEVTOOLS_WS_URL = originalBrowserDevtoolsWsUrl; + } + + if (originalBrowserEvalMode === undefined) { + delete process.env.CCS_BROWSER_EVAL_MODE; + } else { + process.env.CCS_BROWSER_EVAL_MODE = originalBrowserEvalMode; + } }); it('uses explicit profile-dir before the default path and resolves the websocket target', async () => { @@ -175,7 +238,7 @@ describe('chrome reuse resolver', () => { it('throws a clear error when DevToolsActivePort metadata is missing', async () => { const profileDir = createTempDir('ccs-chrome-missing-metadata-'); - await expect(resolveBrowserRuntimeEnv({ profileDir })).rejects.toThrow( + expect(resolveBrowserRuntimeEnv({ profileDir })).rejects.toThrow( `Chrome reuse metadata not found: ${path.join(profileDir, 'DevToolsActivePort')}` ); }); @@ -184,17 +247,17 @@ describe('chrome reuse resolver', () => { const profileDir = createTempDir('ccs-chrome-invalid-metadata-'); writeDevToolsActivePort(profileDir, 'not-a-port\n/devtools/browser/target'); - await expect(resolveBrowserRuntimeEnv({ profileDir })).rejects.toThrow( + expect(resolveBrowserRuntimeEnv({ profileDir })).rejects.toThrow( `Chrome reuse metadata is invalid: ${path.join(profileDir, 'DevToolsActivePort')}` ); }); it('throws before launch fallback when the DevTools endpoint is stale or unreachable', async () => { const profileDir = createTempDir('ccs-chrome-unreachable-'); - const port = await reserveClosedPort(); + const port = reserveClosedPort(); writeDevToolsActivePort(profileDir, `${port}\n/devtools/browser/stale`); - await expect(resolveBrowserRuntimeEnv({ profileDir })).rejects.toThrow( + expect(resolveBrowserRuntimeEnv({ profileDir })).rejects.toThrow( `Chrome DevTools endpoint is unreachable: http://127.0.0.1:${port}` ); }); @@ -204,7 +267,7 @@ describe('chrome reuse resolver', () => { const server = await startDevToolsServer({ Browser: 'Chrome/136.0.0.0' }); writeDevToolsActivePort(profileDir, `${server.port}\n/devtools/browser/no-ws`); - await expect(resolveBrowserRuntimeEnv({ profileDir })).rejects.toThrow( + expect(resolveBrowserRuntimeEnv({ profileDir })).rejects.toThrow( `Chrome DevTools endpoint did not provide a websocket target: http://127.0.0.1:${server.port}/json/version` ); }); @@ -214,7 +277,7 @@ describe('chrome reuse resolver', () => { const server = await startFailingDevToolsServer(500); writeDevToolsActivePort(profileDir, `${server.port}\n/devtools/browser/bad-status`); - await expect(resolveBrowserRuntimeEnv({ profileDir })).rejects.toThrow( + expect(resolveBrowserRuntimeEnv({ profileDir })).rejects.toThrow( `Chrome DevTools endpoint is unreachable: http://127.0.0.1:${server.port}` ); }); @@ -224,7 +287,7 @@ describe('chrome reuse resolver', () => { const server = await startMalformedJsonDevToolsServer(); writeDevToolsActivePort(profileDir, `${server.port}\n/devtools/browser/bad-json`); - await expect(resolveBrowserRuntimeEnv({ profileDir })).rejects.toThrow( + expect(resolveBrowserRuntimeEnv({ profileDir })).rejects.toThrow( `Chrome DevTools endpoint is unreachable: http://127.0.0.1:${server.port}` ); }); @@ -262,7 +325,7 @@ describe('chrome reuse resolver', () => { 'missing-profile' ); - await expect(resolveBrowserRuntimeEnv({ profileDir: missingProfileDir })).rejects.toThrow( + expect(resolveBrowserRuntimeEnv({ profileDir: missingProfileDir })).rejects.toThrow( `Chrome profile directory is invalid: ${missingProfileDir}` ); }); diff --git a/tests/unit/web-server/browser-routes.test.ts b/tests/unit/web-server/browser-routes.test.ts index bc6e0305..bb1cccea 100644 --- a/tests/unit/web-server/browser-routes.test.ts +++ b/tests/unit/web-server/browser-routes.test.ts @@ -13,6 +13,13 @@ describe('browser routes', () => { let tempHome = ''; let originalCcsHome: string | undefined; let originalDashboardAuthEnabled: string | undefined; + let originalBrowserUserDataDir: string | undefined; + let originalBrowserProfileDir: string | undefined; + let originalBrowserDevtoolsPort: string | undefined; + let originalBrowserDevtoolsHost: string | undefined; + let originalBrowserDevtoolsHttpUrl: string | undefined; + let originalBrowserDevtoolsWsUrl: string | undefined; + let originalBrowserEvalMode: string | undefined; let forcedRemoteAddress = '127.0.0.1'; beforeAll(async () => { @@ -54,8 +61,22 @@ describe('browser routes', () => { tempHome = mkdtempSync(join(tmpdir(), 'ccs-browser-routes-')); originalCcsHome = process.env.CCS_HOME; originalDashboardAuthEnabled = process.env.CCS_DASHBOARD_AUTH_ENABLED; + originalBrowserUserDataDir = process.env.CCS_BROWSER_USER_DATA_DIR; + originalBrowserProfileDir = process.env.CCS_BROWSER_PROFILE_DIR; + originalBrowserDevtoolsPort = process.env.CCS_BROWSER_DEVTOOLS_PORT; + originalBrowserDevtoolsHost = process.env.CCS_BROWSER_DEVTOOLS_HOST; + originalBrowserDevtoolsHttpUrl = process.env.CCS_BROWSER_DEVTOOLS_HTTP_URL; + originalBrowserDevtoolsWsUrl = process.env.CCS_BROWSER_DEVTOOLS_WS_URL; + originalBrowserEvalMode = process.env.CCS_BROWSER_EVAL_MODE; process.env.CCS_HOME = tempHome; process.env.CCS_DASHBOARD_AUTH_ENABLED = 'false'; + delete process.env.CCS_BROWSER_USER_DATA_DIR; + delete process.env.CCS_BROWSER_PROFILE_DIR; + delete process.env.CCS_BROWSER_DEVTOOLS_PORT; + delete process.env.CCS_BROWSER_DEVTOOLS_HOST; + delete process.env.CCS_BROWSER_DEVTOOLS_HTTP_URL; + delete process.env.CCS_BROWSER_DEVTOOLS_WS_URL; + delete process.env.CCS_BROWSER_EVAL_MODE; forcedRemoteAddress = '127.0.0.1'; }); @@ -72,6 +93,48 @@ describe('browser routes', () => { delete process.env.CCS_DASHBOARD_AUTH_ENABLED; } + if (originalBrowserUserDataDir !== undefined) { + process.env.CCS_BROWSER_USER_DATA_DIR = originalBrowserUserDataDir; + } else { + delete process.env.CCS_BROWSER_USER_DATA_DIR; + } + + if (originalBrowserProfileDir !== undefined) { + process.env.CCS_BROWSER_PROFILE_DIR = originalBrowserProfileDir; + } else { + delete process.env.CCS_BROWSER_PROFILE_DIR; + } + + if (originalBrowserDevtoolsPort !== undefined) { + process.env.CCS_BROWSER_DEVTOOLS_PORT = originalBrowserDevtoolsPort; + } else { + delete process.env.CCS_BROWSER_DEVTOOLS_PORT; + } + + if (originalBrowserDevtoolsHost !== undefined) { + process.env.CCS_BROWSER_DEVTOOLS_HOST = originalBrowserDevtoolsHost; + } else { + delete process.env.CCS_BROWSER_DEVTOOLS_HOST; + } + + if (originalBrowserDevtoolsHttpUrl !== undefined) { + process.env.CCS_BROWSER_DEVTOOLS_HTTP_URL = originalBrowserDevtoolsHttpUrl; + } else { + delete process.env.CCS_BROWSER_DEVTOOLS_HTTP_URL; + } + + if (originalBrowserDevtoolsWsUrl !== undefined) { + process.env.CCS_BROWSER_DEVTOOLS_WS_URL = originalBrowserDevtoolsWsUrl; + } else { + delete process.env.CCS_BROWSER_DEVTOOLS_WS_URL; + } + + if (originalBrowserEvalMode !== undefined) { + process.env.CCS_BROWSER_EVAL_MODE = originalBrowserEvalMode; + } else { + delete process.env.CCS_BROWSER_EVAL_MODE; + } + rmSync(tempHome, { recursive: true, force: true }); }); @@ -95,9 +158,11 @@ describe('browser routes', () => { enabled: false, userDataDir: join(tempHome, '.ccs', 'browser', 'chrome-user-data'), devtoolsPort: 9222, + evalMode: 'readonly', }, codex: { enabled: true, + evalMode: 'readonly', }, }); expect(payload.status.claude).toMatchObject({ @@ -119,9 +184,11 @@ describe('browser routes', () => { enabled: true, userDataDir: '/tmp/ccs-browser', devtoolsPort: 9333, + evalMode: 'readwrite', }, codex: { enabled: false, + evalMode: 'disabled', }, }), }); @@ -133,9 +200,11 @@ describe('browser routes', () => { enabled: true, userDataDir: '/tmp/ccs-browser', devtoolsPort: 9333, + evalMode: 'readwrite', }, codex: { enabled: false, + evalMode: 'disabled', }, }); @@ -145,9 +214,11 @@ describe('browser routes', () => { enabled: true, user_data_dir: '/tmp/ccs-browser', devtools_port: 9333, + eval_mode: 'readwrite', }, codex: { enabled: false, + eval_mode: 'disabled', }, }); }); @@ -183,6 +254,7 @@ describe('browser routes', () => { enabled: true, userDataDir: join(tempHome, '.ccs', 'browser', 'chrome-user-data'), devtoolsPort: 9333, + evalMode: 'readonly', }); const config = loadOrCreateUnifiedConfig(); @@ -191,6 +263,7 @@ describe('browser routes', () => { enabled: true, user_data_dir: join(tempHome, '.ccs', 'browser', 'chrome-user-data'), devtools_port: 9333, + eval_mode: 'readonly', }, }); }); @@ -211,4 +284,36 @@ describe('browser routes', () => { error: 'Invalid value for claude.devtoolsPort. Must be an integer between 1 and 65535.', }); }); + + it('rejects invalid eval modes at the route boundary', async () => { + const claudeResponse = await fetch(`${baseUrl}/api/browser`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + claude: { + evalMode: 'dangerous', + }, + }), + }); + + expect(claudeResponse.status).toBe(400); + expect(await claudeResponse.json()).toEqual({ + error: 'Invalid value for claude.evalMode. Must be one of: disabled, readonly, readwrite.', + }); + + const codexResponse = await fetch(`${baseUrl}/api/browser`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + codex: { + evalMode: 'dangerous', + }, + }), + }); + + expect(codexResponse.status).toBe(400); + expect(await codexResponse.json()).toEqual({ + error: 'Invalid value for codex.evalMode. Must be one of: disabled, readonly, readwrite.', + }); + }); }); diff --git a/ui/src/lib/api-client.ts b/ui/src/lib/api-client.ts index 0a8bc075..f7219616 100644 --- a/ui/src/lib/api-client.ts +++ b/ui/src/lib/api-client.ts @@ -234,14 +234,18 @@ export interface UpdateImageAnalysisSettingsPayload { profileBackends?: Record; } +export type BrowserEvalMode = 'disabled' | 'readonly' | 'readwrite'; + export interface BrowserSettingsConfig { claude: { enabled: boolean; userDataDir: string; devtoolsPort: number; + evalMode: BrowserEvalMode; }; codex: { enabled: boolean; + evalMode: BrowserEvalMode; }; } @@ -262,6 +266,7 @@ export interface ClaudeBrowserStatus { effectiveUserDataDir: string; recommendedUserDataDir: string; devtoolsPort: number; + evalMode: BrowserEvalMode; managedMcpServerName: string; managedMcpServerPath: string; launchCommands: BrowserLaunchCommands; @@ -275,6 +280,7 @@ export interface CodexBrowserStatus { detail: string; nextStep: string; serverName: string; + evalMode: BrowserEvalMode; supportsConfigOverrides: boolean; binaryPath: string | null; version?: string; diff --git a/ui/src/lib/i18n.ts b/ui/src/lib/i18n.ts index 5329df3a..e794e8dc 100644 --- a/ui/src/lib/i18n.ts +++ b/ui/src/lib/i18n.ts @@ -2435,6 +2435,14 @@ const resources = { nextStep: 'Next step', technicalDetails: 'Technical Details', diagnostics: 'Diagnostics', + evalMode: { + label: 'browser_eval access', + options: { + disabled: 'Disabled', + readonly: 'Read-only', + readwrite: 'Read/write', + }, + }, actions: { saveClaude: 'Save Claude settings', saveCodex: 'Save Codex settings', @@ -2461,6 +2469,8 @@ const resources = { effectivePath: 'Effective attach path', recommendedPath: 'Recommended path', managedRuntime: 'Managed browser runtime', + evalModeHint: + 'Controls browser_eval access for Claude Browser Attach. Read-only allows inspection; read/write also allows page-side mutations.', overrideMessage: 'An environment override is currently active from {{source}}. This dashboard remains the source of truth once that override is removed.', launchGuidance: 'Launch guidance', @@ -2480,6 +2490,8 @@ const resources = { overrideUnsupported: 'Not supported', binary: 'Detected Codex binary', notDetected: 'Not detected', + evalModeHint: + 'Stored for Browser settings parity. In Phase 1, browser_eval enforcement primarily applies to Claude Browser Attach.', }, }, }, @@ -4850,6 +4862,14 @@ const resources = { nextStep: '下一步', technicalDetails: '技术细节', diagnostics: '诊断信息', + evalMode: { + label: 'browser_eval 权限', + options: { + disabled: '禁用', + readonly: '只读', + readwrite: '可读写', + }, + }, actions: { saveClaude: '保存 Claude 设置', saveCodex: '保存 Codex 设置', @@ -4873,6 +4893,8 @@ const resources = { effectivePath: '当前生效的 attach 路径', recommendedPath: '推荐路径', managedRuntime: '托管浏览器运行时', + evalModeHint: + '控制 Claude Browser Attach 中 browser_eval 的权限。只读仅允许读取,读写还允许页面侧修改。', overrideMessage: '当前存在来自 {{source}} 的环境变量覆盖。移除该覆盖后,Dashboard 配置将重新成为唯一来源。', launchGuidance: '启动指引', @@ -4889,6 +4911,8 @@ const resources = { overrideUnsupported: '不支持', binary: '检测到的 Codex 可执行文件', notDetected: '未检测到', + evalModeHint: + '为保持 Browser 设置页一致性而保存该配置。Phase 1 中,browser_eval 的实际限制主要作用于 Claude Browser Attach。', }, }, }, @@ -7367,6 +7391,14 @@ const resources = { nextStep: 'Bước tiếp theo', technicalDetails: 'Chi tiết kỹ thuật', diagnostics: 'Chẩn đoán', + evalMode: { + label: 'Quyền browser_eval', + options: { + disabled: 'Tắt', + readonly: 'Chỉ đọc', + readwrite: 'Đọc/ghi', + }, + }, actions: { saveClaude: 'Lưu cấu hình Claude', saveCodex: 'Lưu cấu hình Codex', @@ -7394,6 +7426,8 @@ const resources = { effectivePath: 'Đường dẫn attach đang có hiệu lực', recommendedPath: 'Đường dẫn khuyến nghị', managedRuntime: 'Runtime trình duyệt được quản lý', + evalModeHint: + 'Điều khiển quyền browser_eval cho Claude Browser Attach. Chỉ đọc cho phép quan sát; đọc/ghi cũng cho phép thay đổi phía trang.', overrideMessage: 'Hiện có override môi trường từ {{source}}. Khi bỏ override này, Dashboard sẽ lại là nguồn cấu hình chính.', launchGuidance: 'Hướng dẫn khởi chạy', @@ -7413,6 +7447,8 @@ const resources = { overrideUnsupported: 'Không được hỗ trợ', binary: 'Binary Codex được phát hiện', notDetected: 'Không phát hiện', + evalModeHint: + 'Được lưu để giữ tương thích giao diện Browser settings. Ở Phase 1, việc áp dụng hạn chế browser_eval chủ yếu diễn ra trên Claude Browser Attach.', }, }, }, @@ -9791,6 +9827,14 @@ const resources = { nextStep: '次の手順', technicalDetails: '技術的な詳細', diagnostics: '診断情報', + evalMode: { + label: 'browser_eval 権限', + options: { + disabled: '無効', + readonly: '読み取り専用', + readwrite: '読み書き可', + }, + }, actions: { saveClaude: 'Claude 設定を保存', saveCodex: 'Codex 設定を保存', @@ -9818,6 +9862,8 @@ const resources = { effectivePath: '現在有効な attach パス', recommendedPath: '推奨パス', managedRuntime: '管理済みブラウザランタイム', + evalModeHint: + 'Claude Browser Attach における browser_eval の権限を制御します。読み取り専用は参照のみ、読み書き可はページ側の変更も許可します。', overrideMessage: '{{source}} から環境変数オーバーライドが有効です。これを外すと Dashboard の設定が再び主ソースになります。', launchGuidance: '起動ガイダンス', @@ -9837,6 +9883,8 @@ const resources = { overrideUnsupported: '非対応', binary: '検出された Codex バイナリ', notDetected: '未検出', + evalModeHint: + 'Browser 設定画面の整合性のために保存されます。Phase 1 では browser_eval の制御は主に Claude Browser Attach に適用されます。', }, }, }, diff --git a/ui/src/pages/settings/sections/browser/index.tsx b/ui/src/pages/settings/sections/browser/index.tsx index 6c997257..b32c2cd2 100644 --- a/ui/src/pages/settings/sections/browser/index.tsx +++ b/ui/src/pages/settings/sections/browser/index.tsx @@ -2,29 +2,36 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { motion, AnimatePresence } from 'framer-motion'; import { - Browser, - Gear, - CheckCircle, - WarningCircle, - XCircle, + AlertCircle, ArrowRight, - ClipboardText, - ArrowsClockwise, - TerminalWindow, - CaretDown, + CheckCircle2, + ChevronDown, + Clipboard, Info, -} from '@phosphor-icons/react'; + Monitor, + RefreshCw, + Settings, + Terminal, + XCircle, +} from 'lucide-react'; import { Alert, AlertDescription } from '@/components/ui/alert'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { ScrollArea } from '@/components/ui/scroll-area'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; import { Switch } from '@/components/ui/switch'; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; import { getClientPlatformKey } from '@/lib/platform'; import { cn } from '@/lib/utils'; import { useBrowserConfig, useRawConfig } from '../../hooks'; -import type { BrowserConfig } from '../../types'; +import type { BrowserConfig, BrowserEvalMode } from '../../types'; // --- Constants & Helpers --- @@ -35,6 +42,12 @@ function parsePortDraft(value: string): number | null { return port; } +const BROWSER_EVAL_MODE_OPTIONS: BrowserEvalMode[] = ['disabled', 'readonly', 'readwrite']; + +function getBrowserEvalModeLabel(t: ReturnType['t'], mode: BrowserEvalMode) { + return t(`settingsPage.browserSection.evalMode.options.${mode}`); +} + function buildLaunchCommand( userDataDir: string, devtoolsPort: number, @@ -142,11 +155,11 @@ function StatusStrip({ )} > {isReady ? ( - + ) : isError ? ( - + ) : ( - + )}
@@ -205,10 +218,10 @@ function DiagnosticsSection({
@@ -389,12 +406,12 @@ export default function BrowserSection() { > {error ? (
- + {error}
) : (
- + {actionMessage ?? t('commonToast.settingsSaved')}
)} @@ -413,7 +430,7 @@ export default function BrowserSection() {
- +

@@ -431,7 +448,7 @@ export default function BrowserSection() { onClick={refreshAll} disabled={saving || loading} > - + {t('settings.refresh')} @@ -442,7 +459,7 @@ export default function BrowserSection() { transition={{ delay: 0.1 }} > - + {t('settingsPage.browserSection.primaryTitle')} @@ -486,9 +503,9 @@ export default function BrowserSection() { className="rounded-full px-6 shadow-md transition-transform active:scale-95" > {saving ? ( - + ) : ( - + )} {t('settingsPage.browserSection.actions.saveClaude')} @@ -498,7 +515,7 @@ export default function BrowserSection() { onClick={refreshStatus} disabled={saving || statusLoading || hasClaudeChanges || claudePortInvalid} > - + {t('settingsPage.browserSection.actions.testConnection')}

@@ -564,6 +581,36 @@ export default function BrowserSection() { : t('settingsPage.browserSection.claude.devtoolsPortHint')}

+ +
+ + +

+ {t('settingsPage.browserSection.claude.evalModeHint')} +

+
@@ -584,7 +631,7 @@ export default function BrowserSection() { onClick={copyLaunchCommand} disabled={!preferredLaunchCommand} > - +
@@ -594,7 +641,7 @@ export default function BrowserSection() { {status.claude.overrideActive && (
- + {t('settingsPage.browserSection.claude.overrideMessage', { source: status.claude.source, })} @@ -637,6 +684,16 @@ export default function BrowserSection() {

+
+

+ {t('settingsPage.browserSection.evalMode.label')} +

+
+

+ {getBrowserEvalModeLabel(t, status.claude.evalMode)} +

+
+
@@ -677,9 +734,9 @@ export default function BrowserSection() { className="rounded-full px-6 shadow-md transition-transform active:scale-95" > {saving ? ( - + ) : ( - + )} {t('settingsPage.browserSection.actions.saveCodex')} @@ -694,6 +751,36 @@ export default function BrowserSection() { nextStep={status.codex.nextStep} /> +
+ + +

+ {t('settingsPage.browserSection.codex.evalModeHint')} +

+
+
{status.codex.supportsConfigOverrides ? ( - + ) : ( - + )} {status.codex.supportsConfigOverrides ? t('settingsPage.browserSection.codex.overrideSupported') @@ -740,6 +827,16 @@ export default function BrowserSection() { )}
+
+

+ {t('settingsPage.browserSection.evalMode.label')} +

+
+

+ {getBrowserEvalModeLabel(t, status.codex.evalMode)} +

+
+
diff --git a/ui/src/pages/settings/types.ts b/ui/src/pages/settings/types.ts index a8765ee7..3ff14bd4 100644 --- a/ui/src/pages/settings/types.ts +++ b/ui/src/pages/settings/types.ts @@ -4,6 +4,7 @@ */ import type { + BrowserEvalMode, BrowserSettingsConfig, BrowserStatusPayload, CliproxyServerConfig, @@ -198,7 +199,7 @@ export interface ThinkingConfig { // === Re-exports from api-client === -export type { CliproxyServerConfig, RemoteProxyStatus }; +export type { BrowserEvalMode, CliproxyServerConfig, RemoteProxyStatus }; export type BrowserConfig = BrowserSettingsConfig; export type BrowserStatus = BrowserStatusPayload; export type BrowserSavePayload = UpdateBrowserSettingsPayload;