feat(browser): 完成 browser MCP 第一阶段能力与配置打通

- 增加 browser_wait_for、browser_eval 及 click/query 扩展能力
- 打通 browser eval_mode 的配置、状态、启动链路与设置界面
- 补充浏览器相关测试隔离、文档和 CI parity 收尾调整
This commit is contained in:
walker1211
2026-04-18 00:29:43 +08:00
parent a0183073b3
commit b90eae3892
25 changed files with 2370 additions and 97 deletions
+30 -1
View File
@@ -1,6 +1,6 @@
# Browser Automation # Browser Automation
Last Updated: 2026-04-16 Last Updated: 2026-04-17
CCS provides browser automation through two separate runtime paths: 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 Claude Browser Attach requires a browser launched in attach mode with remote debugging
enabled. A recent Chrome update alone is not sufficient. 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 Browser Tools
Codex-target CCS launches use a separate managed path: CCS injects Playwright MCP overrides 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 - enable/disable the Claude attach lane
- choose the Chrome user-data directory - choose the Chrome user-data directory
- set the expected DevTools port - set the expected DevTools port
- choose the `browser_eval` access level (`disabled`, `readonly`, `readwrite`)
- review readiness and next-step guidance - review readiness and next-step guidance
- copy a generated browser launch command - copy a generated browser launch command
- **Codex Browser Tools** - **Codex Browser Tools**
- enable/disable CCS-managed browser tooling for Codex-target launches - 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 - review whether the detected Codex build supports managed browser overrides
### Via CLI ### Via CLI
@@ -67,15 +91,19 @@ browser:
enabled: false enabled: false
user_data_dir: "~/.ccs/browser/chrome-user-data" user_data_dir: "~/.ccs/browser/chrome-user-data"
devtools_port: 9222 devtools_port: 9222
eval_mode: readonly
codex: codex:
enabled: true enabled: true
eval_mode: readonly
``` ```
Notes: Notes:
- `claude.user_data_dir` is a **Chrome user-data directory**, not a display-name browser profile - `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.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.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 ## 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_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_PROFILE_DIR` | Legacy alias for the same attach directory |
| `CCS_BROWSER_DEVTOOLS_PORT` | Explicit DevTools port override | | `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 If an override is active, Browser status surfaces should report that the current session is being
managed externally by environment variables. managed externally by environment variables.
+639 -4
View File
@@ -41,6 +41,11 @@ const TOOL_NAVIGATE = 'browser_navigate';
const TOOL_CLICK = 'browser_click'; const TOOL_CLICK = 'browser_click';
const TOOL_TYPE = 'browser_type'; const TOOL_TYPE = 'browser_type';
const TOOL_TAKE_SCREENSHOT = 'browser_take_screenshot'; 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 = [ const TOOL_NAMES = [
TOOL_SESSION_INFO, TOOL_SESSION_INFO,
TOOL_URL_TITLE, TOOL_URL_TITLE,
@@ -50,9 +55,30 @@ const TOOL_NAMES = [
TOOL_CLICK, TOOL_CLICK,
TOOL_TYPE, TOOL_TYPE,
TOOL_TAKE_SCREENSHOT, 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 CDP_TIMEOUT_MS = 5000;
const NAVIGATION_POLL_INTERVAL_MS = 100; 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 inputBuffer = Buffer.alloc(0);
let requestCounter = 0; let requestCounter = 0;
@@ -221,7 +247,7 @@ function getTools() {
{ {
name: TOOL_CLICK, name: TOOL_CLICK,
description: 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: { inputSchema: {
type: 'object', type: 'object',
properties: { properties: {
@@ -234,6 +260,11 @@ function getTools() {
type: 'string', type: 'string',
description: 'Required CSS selector for the element to click.', 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'], required: ['selector'],
additionalProperties: false, additionalProperties: false,
@@ -288,6 +319,145 @@ function getTools() {
additionalProperties: false, 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(); 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) { function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms)); return new Promise((resolve) => setTimeout(resolve, ms));
} }
@@ -601,12 +813,16 @@ async function handleNavigate(toolArgs) {
async function handleClick(toolArgs) { async function handleClick(toolArgs) {
const { page, pageIndex } = await getSelectedPage(toolArgs); const { page, pageIndex } = await getSelectedPage(toolArgs);
const selector = requireNonEmptyString(toolArgs.selector, 'selector'); const selector = requireNonEmptyString(toolArgs.selector, 'selector');
const nth = requireOptionalNonNegativeInteger(toolArgs.nth, 'nth');
const targetIndex = nth ?? 0;
const expression = `(() => { const expression = `(() => {
const selector = JSON.parse(${JSON.stringify(JSON.stringify(selector))}); 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) { 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) { if (!element.isConnected) {
throw new Error('element is detached for selector: ' + selector); throw new Error('element is detached for selector: ' + selector);
@@ -660,7 +876,7 @@ async function handleClick(toolArgs) {
})()`; })()`;
await evaluateExpression(page, expression); await evaluateExpression(page, expression);
return `pageIndex: ${pageIndex}\nselector: ${selector}\nstatus: clicked`; return `pageIndex: ${pageIndex}\nselector: ${selector}\nnth: ${targetIndex}\nstatus: clicked`;
} }
async function handleType(toolArgs) { async function handleType(toolArgs) {
@@ -758,6 +974,385 @@ async function handleScreenshot(toolArgs) {
return `pageIndex: ${pageIndex}\nformat: png\nfullPage: ${fullPage ? 'true' : 'false'}\ndata: ${data}`; 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) { async function handleToolCall(message) {
const id = message.id; const id = message.id;
const params = message.params || {}; const params = message.params || {};
@@ -823,6 +1418,46 @@ async function handleToolCall(message) {
return; 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); const { page, pageIndex } = await getSelectedPage(toolArgs);
if (toolName === TOOL_URL_TITLE) { if (toolName === TOOL_URL_TITLE) {
+3 -1
View File
@@ -34,7 +34,9 @@ echo "[i] Pre-push CI parity gate"
echo " branch: $CURRENT_BRANCH" echo " branch: $CURRENT_BRANCH"
echo " base: $BASE_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 show-ref --verify --quiet "refs/remotes/origin/$BASE_BRANCH"; then
if ! git merge-base --is-ancestor "origin/$BASE_BRANCH" HEAD; then if ! git merge-base --is-ancestor "origin/$BASE_BRANCH" HEAD; then
echo "[X] Branch '$CURRENT_BRANCH' is behind origin/$BASE_BRANCH." echo "[X] Branch '$CURRENT_BRANCH' is behind origin/$BASE_BRANCH."
+21 -2
View File
@@ -604,7 +604,6 @@ async function main(): Promise<void> {
} catch (error) { } catch (error) {
console.error(fail((error as Error).message)); console.error(fail((error as Error).message));
process.exit(1); process.exit(1);
return;
} }
// Detect Claude CLI (needed for claude target and all CLIProxy-derived flows) // Detect Claude CLI (needed for claude target and all CLIProxy-derived flows)
@@ -1089,7 +1088,13 @@ async function main(): Promise<void> {
} }
const inheritedClaudeConfigDir = continuityInheritance.claudeConfigDir; const inheritedClaudeConfigDir = continuityInheritance.claudeConfigDir;
syncWebSearchMcpToConfigDir(inheritedClaudeConfigDir); 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 ( if (
browserAttachConfig?.enabled && browserAttachConfig?.enabled &&
inheritedClaudeConfigDir && inheritedClaudeConfigDir &&
@@ -1269,7 +1274,13 @@ async function main(): Promise<void> {
...imageAnalysisEnv, ...imageAnalysisEnv,
CCS_CURRENT_PROVIDER: '', CCS_CURRENT_PROVIDER: '',
CCS_IMAGE_ANALYSIS_SKIP: '1', 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') { } else if (imageAnalysisStatus.proxyReadiness === 'stopped') {
const ensureServiceResult = await ensureCliproxyService( const ensureServiceResult = await ensureCliproxyService(
CLIPROXY_DEFAULT_PORT, CLIPROXY_DEFAULT_PORT,
@@ -1285,7 +1296,13 @@ async function main(): Promise<void> {
...imageAnalysisEnv, ...imageAnalysisEnv,
CCS_CURRENT_PROVIDER: '', CCS_CURRENT_PROVIDER: '',
CCS_IMAGE_ANALYSIS_SKIP: '1', 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<void> {
devtoolsPort: browserAttachConfig.hasExplicitDevtoolsPort devtoolsPort: browserAttachConfig.hasExplicitDevtoolsPort
? String(browserAttachConfig.devtoolsPort) ? String(browserAttachConfig.devtoolsPort)
: undefined, : undefined,
evalMode: browserAttachConfig.evalMode,
})), })),
}; };
} }
@@ -1487,6 +1505,7 @@ async function main(): Promise<void> {
? String(browserAttachConfig.devtoolsPort) ? String(browserAttachConfig.devtoolsPort)
: undefined, : undefined,
})), })),
CCS_BROWSER_EVAL_MODE: browserAttachConfig.evalMode,
}; };
Object.assign(envVars, browserRuntimeEnv); Object.assign(envVars, browserRuntimeEnv);
} }
+1
View File
@@ -1164,6 +1164,7 @@ export async function execClaudeWithCLIProxy(
devtoolsPort: browserAttachConfig.hasExplicitDevtoolsPort devtoolsPort: browserAttachConfig.hasExplicitDevtoolsPort
? String(browserAttachConfig.devtoolsPort) ? String(browserAttachConfig.devtoolsPort)
: undefined, : undefined,
evalMode: browserAttachConfig.evalMode,
})), })),
} }
: undefined; : undefined;
+2
View File
@@ -50,6 +50,7 @@ function writeClaudeStatus(
writeLine(` Source: ${status.source}${status.overrideActive ? ' (env override active)' : ''}`); writeLine(` Source: ${status.source}${status.overrideActive ? ' (env override active)' : ''}`);
writeLine(` User data dir: ${status.effectiveUserDataDir}`); writeLine(` User data dir: ${status.effectiveUserDataDir}`);
writeLine(` DevTools port: ${status.devtoolsPort}`); writeLine(` DevTools port: ${status.devtoolsPort}`);
writeLine(` browser_eval access: ${status.evalMode}`);
writeLine(` Managed MCP: ${status.managedMcpServerName}`); writeLine(` Managed MCP: ${status.managedMcpServerName}`);
writeLine(` Managed path: ${status.managedMcpServerPath}`); writeLine(` Managed path: ${status.managedMcpServerPath}`);
if (status.runtimeEnv?.CCS_BROWSER_DEVTOOLS_HTTP_URL) { if (status.runtimeEnv?.CCS_BROWSER_DEVTOOLS_HTTP_URL) {
@@ -69,6 +70,7 @@ function writeCodexStatus(status: BrowserStatusPayload['codex'], writeLine: Help
writeLine(` State: ${status.state}`); writeLine(` State: ${status.state}`);
writeLine(` Enabled: ${status.enabled ? 'yes' : 'no'}`); writeLine(` Enabled: ${status.enabled ? 'yes' : 'no'}`);
writeLine(` Managed server: ${status.serverName}`); writeLine(` Managed server: ${status.serverName}`);
writeLine(` browser_eval access: ${status.evalMode}`);
writeLine(` Supports overrides: ${status.supportsConfigOverrides ? 'yes' : 'no'}`); writeLine(` Supports overrides: ${status.supportsConfigOverrides ? 'yes' : 'no'}`);
writeLine(` Codex binary: ${status.binaryPath || 'not detected'}`); writeLine(` Codex binary: ${status.binaryPath || 'not detected'}`);
if (status.version) { if (status.version) {
+12
View File
@@ -36,6 +36,7 @@ import type {
OfficialChannelId, OfficialChannelId,
DashboardAuthConfig, DashboardAuthConfig,
BrowserConfig, BrowserConfig,
BrowserEvalMode,
ImageAnalysisConfig, ImageAnalysisConfig,
LoggingConfig, LoggingConfig,
CursorConfig, CursorConfig,
@@ -70,15 +71,25 @@ function normalizeBrowserDevtoolsPort(value: number | undefined): number {
return port; 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 { function canonicalizeBrowserConfig(config?: BrowserConfig): BrowserConfig {
return { return {
claude: { claude: {
enabled: config?.claude?.enabled ?? DEFAULT_BROWSER_CONFIG.claude.enabled, enabled: config?.claude?.enabled ?? DEFAULT_BROWSER_CONFIG.claude.enabled,
user_data_dir: config?.claude?.user_data_dir?.trim() || getRecommendedBrowserUserDataDir(), user_data_dir: config?.claude?.user_data_dir?.trim() || getRecommendedBrowserUserDataDir(),
devtools_port: normalizeBrowserDevtoolsPort(config?.claude?.devtools_port), devtools_port: normalizeBrowserDevtoolsPort(config?.claude?.devtools_port),
eval_mode: normalizeBrowserEvalMode(config?.claude?.eval_mode),
}, },
codex: { codex: {
enabled: config?.codex?.enabled ?? DEFAULT_BROWSER_CONFIG.codex.enabled, 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('#');
lines.push('# claude.user_data_dir should point at the Chrome user-data directory for the'); 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('# 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('# Configure via: Settings > Browser or `ccs browser ...`.');
lines.push('# ----------------------------------------------------------------------------'); lines.push('# ----------------------------------------------------------------------------');
lines.push( lines.push(
+8
View File
@@ -816,6 +816,8 @@ export const DEFAULT_DASHBOARD_AUTH_CONFIG: DashboardAuthConfig = {
* Browser automation configuration. * Browser automation configuration.
* Controls Claude browser attach and Codex browser tooling. * Controls Claude browser attach and Codex browser tooling.
*/ */
export type BrowserEvalMode = 'disabled' | 'readonly' | 'readwrite';
export interface BrowserClaudeConfig { export interface BrowserClaudeConfig {
/** Enable Claude browser attach (default: false) */ /** Enable Claude browser attach (default: false) */
enabled: boolean; enabled: boolean;
@@ -823,11 +825,15 @@ export interface BrowserClaudeConfig {
user_data_dir: string; user_data_dir: string;
/** DevTools port used for attach mode (default: 9222) */ /** DevTools port used for attach mode (default: 9222) */
devtools_port: number; devtools_port: number;
/** Eval access mode for browser_eval (default: readonly) */
eval_mode: BrowserEvalMode;
} }
export interface BrowserCodexConfig { export interface BrowserCodexConfig {
/** Enable Codex browser tooling injection (default: true) */ /** Enable Codex browser tooling injection (default: true) */
enabled: boolean; enabled: boolean;
/** Stored for dashboard/config parity; runtime enforcement is Claude-only in Phase 1 */
eval_mode: BrowserEvalMode;
} }
export interface BrowserConfig { export interface BrowserConfig {
@@ -840,9 +846,11 @@ export const DEFAULT_BROWSER_CONFIG: BrowserConfig = {
enabled: false, enabled: false,
user_data_dir: '', user_data_dir: '',
devtools_port: 9222, devtools_port: 9222,
eval_mode: 'readonly',
}, },
codex: { codex: {
enabled: true, enabled: true,
eval_mode: 'readonly',
}, },
}; };
+4 -1
View File
@@ -1,5 +1,5 @@
import * as path from 'path'; 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 { getCcsDir } from '../config-manager';
import { expandPath } from '../helpers'; import { expandPath } from '../helpers';
@@ -11,6 +11,7 @@ export interface EffectiveClaudeBrowserAttachConfig {
overrideActive: boolean; overrideActive: boolean;
userDataDir: string; userDataDir: string;
devtoolsPort: number; devtoolsPort: number;
evalMode: BrowserEvalMode;
hasExplicitDevtoolsPort: boolean; hasExplicitDevtoolsPort: boolean;
} }
@@ -64,6 +65,7 @@ export function getEffectiveClaudeBrowserAttachConfig(
overrideActive: true, overrideActive: true,
userDataDir: override.userDataDir, userDataDir: override.userDataDir,
devtoolsPort: override.devtoolsPort ?? configPort, devtoolsPort: override.devtoolsPort ?? configPort,
evalMode: config.claude.eval_mode,
hasExplicitDevtoolsPort: override.devtoolsPort !== undefined, hasExplicitDevtoolsPort: override.devtoolsPort !== undefined,
}; };
} }
@@ -74,6 +76,7 @@ export function getEffectiveClaudeBrowserAttachConfig(
overrideActive: false, overrideActive: false,
userDataDir: configUserDataDir, userDataDir: configUserDataDir,
devtoolsPort: configPort, devtoolsPort: configPort,
evalMode: config.claude.eval_mode,
// Config-backed browser attach always keeps an explicit port so launches // Config-backed browser attach always keeps an explicit port so launches
// stay aligned with Settings > Browser, even when the effective value is // stay aligned with Settings > Browser, even when the effective value is
// the default 9222. // the default 9222.
+7
View File
@@ -25,6 +25,7 @@ export interface ClaudeBrowserStatus {
effectiveUserDataDir: string; effectiveUserDataDir: string;
recommendedUserDataDir: string; recommendedUserDataDir: string;
devtoolsPort: number; devtoolsPort: number;
evalMode: 'disabled' | 'readonly' | 'readwrite';
managedMcpServerName: string; managedMcpServerName: string;
managedMcpServerPath: string; managedMcpServerPath: string;
launchCommands: BrowserLaunchCommands; launchCommands: BrowserLaunchCommands;
@@ -38,6 +39,7 @@ export interface CodexBrowserStatus {
detail: string; detail: string;
nextStep: string; nextStep: string;
serverName: string; serverName: string;
evalMode: 'disabled' | 'readonly' | 'readwrite';
supportsConfigOverrides: boolean; supportsConfigOverrides: boolean;
binaryPath: string | null; binaryPath: string | null;
version?: string; version?: string;
@@ -68,6 +70,7 @@ async function buildClaudeBrowserStatus(
effectiveUserDataDir: effective.userDataDir, effectiveUserDataDir: effective.userDataDir,
recommendedUserDataDir: getRecommendedBrowserUserDataDir(), recommendedUserDataDir: getRecommendedBrowserUserDataDir(),
devtoolsPort: effective.devtoolsPort, devtoolsPort: effective.devtoolsPort,
evalMode: effective.evalMode,
managedMcpServerName: getBrowserMcpServerName(), managedMcpServerName: getBrowserMcpServerName(),
managedMcpServerPath: getBrowserMcpServerPath(), managedMcpServerPath: getBrowserMcpServerPath(),
launchCommands, launchCommands,
@@ -89,6 +92,7 @@ async function buildClaudeBrowserStatus(
const runtimeEnv = await resolveBrowserRuntimeEnv({ const runtimeEnv = await resolveBrowserRuntimeEnv({
profileDir: effective.userDataDir, profileDir: effective.userDataDir,
devtoolsPort: effective.hasExplicitDevtoolsPort ? String(effective.devtoolsPort) : undefined, devtoolsPort: effective.hasExplicitDevtoolsPort ? String(effective.devtoolsPort) : undefined,
evalMode: effective.evalMode,
}); });
return { return {
@@ -142,6 +146,7 @@ function buildCodexBrowserStatus(browserConfig = getBrowserConfig()): CodexBrows
nextStep: nextStep:
'Enable Codex Browser Tools in Settings > Browser to restore the managed Codex browser path.', 'Enable Codex Browser Tools in Settings > Browser to restore the managed Codex browser path.',
serverName: 'ccs_browser', serverName: 'ccs_browser',
evalMode: browserConfig.codex.eval_mode,
supportsConfigOverrides: false, supportsConfigOverrides: false,
binaryPath: null, binaryPath: null,
}; };
@@ -159,6 +164,7 @@ function buildCodexBrowserStatus(browserConfig = getBrowserConfig()): CodexBrows
: 'No Codex binary was detected, so CCS cannot confirm managed browser override support.', : 'No Codex binary was detected, so CCS cannot confirm managed browser override support.',
nextStep: 'Install or upgrade Codex, then rerun browser status/doctor.', nextStep: 'Install or upgrade Codex, then rerun browser status/doctor.',
serverName: 'ccs_browser', serverName: 'ccs_browser',
evalMode: browserConfig.codex.eval_mode,
supportsConfigOverrides, supportsConfigOverrides,
binaryPath: binaryInfo?.path ?? null, binaryPath: binaryInfo?.path ?? null,
version: binaryInfo?.version, 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.', detail: 'CCS can inject the managed Playwright MCP overrides into Codex-target launches.',
nextStep: 'Use a Codex-target CCS launch to access browser tools.', nextStep: 'Use a Codex-target CCS launch to access browser tools.',
serverName: 'ccs_browser', serverName: 'ccs_browser',
evalMode: browserConfig.codex.eval_mode,
supportsConfigOverrides, supportsConfigOverrides,
binaryPath: binaryInfo.path, binaryPath: binaryInfo.path,
version: binaryInfo.version, version: binaryInfo.version,
+4
View File
@@ -1,11 +1,13 @@
import { execFileSync } from 'child_process'; import { execFileSync } from 'child_process';
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import type { BrowserEvalMode } from '../../config/unified-config-types';
import { expandPath } from '../helpers'; import { expandPath } from '../helpers';
export interface BrowserReuseOptions { export interface BrowserReuseOptions {
profileDir?: string; profileDir?: string;
devtoolsPort?: string; devtoolsPort?: string;
evalMode?: BrowserEvalMode;
} }
export interface BrowserRuntimeEnv { export interface BrowserRuntimeEnv {
@@ -14,6 +16,7 @@ export interface BrowserRuntimeEnv {
CCS_BROWSER_DEVTOOLS_PORT: string; CCS_BROWSER_DEVTOOLS_PORT: string;
CCS_BROWSER_DEVTOOLS_HTTP_URL: string; CCS_BROWSER_DEVTOOLS_HTTP_URL: string;
CCS_BROWSER_DEVTOOLS_WS_URL: string; CCS_BROWSER_DEVTOOLS_WS_URL: string;
CCS_BROWSER_EVAL_MODE?: string;
} }
const DEVTOOLS_HOST = '127.0.0.1'; const DEVTOOLS_HOST = '127.0.0.1';
@@ -100,6 +103,7 @@ export async function resolveBrowserRuntimeEnv(
CCS_BROWSER_DEVTOOLS_PORT: port, CCS_BROWSER_DEVTOOLS_PORT: port,
CCS_BROWSER_DEVTOOLS_HTTP_URL: httpUrl, CCS_BROWSER_DEVTOOLS_HTTP_URL: httpUrl,
CCS_BROWSER_DEVTOOLS_WS_URL: websocketUrl, CCS_BROWSER_DEVTOOLS_WS_URL: websocketUrl,
...(options.evalMode ? { CCS_BROWSER_EVAL_MODE: options.evalMode } : {}),
}; };
} }
+22
View File
@@ -12,9 +12,11 @@ interface BrowserRouteBody {
enabled?: boolean; enabled?: boolean;
userDataDir?: string; userDataDir?: string;
devtoolsPort?: number; devtoolsPort?: number;
evalMode?: 'disabled' | 'readonly' | 'readwrite';
}; };
codex?: { codex?: {
enabled?: boolean; enabled?: boolean;
evalMode?: 'disabled' | 'readonly' | 'readwrite';
}; };
} }
@@ -22,6 +24,10 @@ function isValidDevtoolsPort(value: number): boolean {
return Number.isInteger(value) && value >= 1 && value <= 65535; 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) => { router.use((req: Request, res: Response, next) => {
if (requireLocalAccessWhenAuthDisabled(req, res, BROWSER_LOCAL_ACCESS_ERROR)) { if (requireLocalAccessWhenAuthDisabled(req, res, BROWSER_LOCAL_ACCESS_ERROR)) {
next(); next();
@@ -86,10 +92,22 @@ router.put('/', async (req: Request, res: Response): Promise<void> => {
}); });
return; 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') { if (codex?.enabled !== undefined && typeof codex.enabled !== 'boolean') {
res.status(400).json({ error: 'Invalid value for codex.enabled. Must be a boolean.' }); res.status(400).json({ error: 'Invalid value for codex.enabled. Must be a boolean.' });
return; 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 { try {
const current = getBrowserConfig(); const current = getBrowserConfig();
@@ -101,9 +119,11 @@ router.put('/', async (req: Request, res: Response): Promise<void> => {
enabled: claude?.enabled ?? current.claude.enabled, enabled: claude?.enabled ?? current.claude.enabled,
user_data_dir: nextClaudeUserDataDir, user_data_dir: nextClaudeUserDataDir,
devtools_port: claude?.devtoolsPort ?? current.claude.devtools_port, devtools_port: claude?.devtoolsPort ?? current.claude.devtools_port,
eval_mode: claude?.evalMode ?? current.claude.eval_mode,
}, },
codex: { codex: {
enabled: codex?.enabled ?? current.codex.enabled, enabled: codex?.enabled ?? current.codex.enabled,
eval_mode: codex?.evalMode ?? current.codex.eval_mode,
}, },
}; };
}); });
@@ -128,9 +148,11 @@ function toBrowserRouteConfig(config: ReturnType<typeof getBrowserConfig>) {
enabled: config.claude.enabled, enabled: config.claude.enabled,
userDataDir: config.claude.user_data_dir, userDataDir: config.claude.user_data_dir,
devtoolsPort: config.claude.devtools_port, devtoolsPort: config.claude.devtools_port,
evalMode: config.claude.eval_mode,
}, },
codex: { codex: {
enabled: config.codex.enabled, enabled: config.codex.enabled,
evalMode: config.codex.eval_mode,
}, },
}; };
} }
@@ -37,6 +37,7 @@ describe('browser command', () => {
effectiveUserDataDir: '/tmp/browser-profile', effectiveUserDataDir: '/tmp/browser-profile',
recommendedUserDataDir: '/tmp/browser-profile', recommendedUserDataDir: '/tmp/browser-profile',
devtoolsPort: 9222, devtoolsPort: 9222,
evalMode: 'readonly',
managedMcpServerName: 'ccs-browser', managedMcpServerName: 'ccs-browser',
managedMcpServerPath: '/tmp/ccs-browser-server.cjs', managedMcpServerPath: '/tmp/ccs-browser-server.cjs',
launchCommands: { launchCommands: {
@@ -50,6 +51,7 @@ describe('browser command', () => {
CCS_BROWSER_DEVTOOLS_PORT: '9222', CCS_BROWSER_DEVTOOLS_PORT: '9222',
CCS_BROWSER_DEVTOOLS_HTTP_URL: 'http://127.0.0.1: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_DEVTOOLS_WS_URL: 'ws://127.0.0.1/devtools/browser/test',
CCS_BROWSER_EVAL_MODE: 'readonly',
}, },
}, },
codex: { codex: {
@@ -59,6 +61,7 @@ describe('browser command', () => {
detail: 'CCS can inject the managed Playwright MCP overrides.', detail: 'CCS can inject the managed Playwright MCP overrides.',
nextStep: 'Use a Codex-target launch.', nextStep: 'Use a Codex-target launch.',
serverName: 'ccs_browser', serverName: 'ccs_browser',
evalMode: 'readonly',
supportsConfigOverrides: true, supportsConfigOverrides: true,
binaryPath: '/usr/local/bin/codex', binaryPath: '/usr/local/bin/codex',
version: 'codex-cli 0.120.0', 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 MCP: ccs-browser')).toBe(true);
expect(rendered.includes('Managed server: 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); expect(rendered.includes('DevTools endpoint: http://127.0.0.1:9222')).toBe(true);
} finally { } finally {
statusSpy.mockRestore(); statusSpy.mockRestore();
@@ -100,6 +104,7 @@ describe('browser command', () => {
effectiveUserDataDir: '/tmp/browser-profile', effectiveUserDataDir: '/tmp/browser-profile',
recommendedUserDataDir: '/tmp/browser-profile', recommendedUserDataDir: '/tmp/browser-profile',
devtoolsPort: 9444, devtoolsPort: 9444,
evalMode: 'readwrite',
managedMcpServerName: 'ccs-browser', managedMcpServerName: 'ccs-browser',
managedMcpServerPath: '/tmp/ccs-browser-server.cjs', managedMcpServerPath: '/tmp/ccs-browser-server.cjs',
launchCommands, launchCommands,
@@ -111,6 +116,7 @@ describe('browser command', () => {
detail: 'Detected Codex at /usr/local/bin/codex, but it does not advertise --config overrides.', 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.', nextStep: 'Install or upgrade Codex, then rerun browser status/doctor.',
serverName: 'ccs_browser', serverName: 'ccs_browser',
evalMode: 'disabled',
supportsConfigOverrides: false, supportsConfigOverrides: false,
binaryPath: '/usr/local/bin/codex', binaryPath: '/usr/local/bin/codex',
version: 'codex-cli 0.70.0', version: 'codex-cli 0.70.0',
@@ -150,6 +156,7 @@ describe('browser command', () => {
effectiveUserDataDir: '/tmp/browser-profile', effectiveUserDataDir: '/tmp/browser-profile',
recommendedUserDataDir: '/tmp/browser-profile', recommendedUserDataDir: '/tmp/browser-profile',
devtoolsPort: 9222, devtoolsPort: 9222,
evalMode: 'readonly',
managedMcpServerName: 'ccs-browser', managedMcpServerName: 'ccs-browser',
managedMcpServerPath: '/tmp/ccs-browser-server.cjs', managedMcpServerPath: '/tmp/ccs-browser-server.cjs',
launchCommands: { launchCommands: {
@@ -167,6 +174,7 @@ describe('browser command', () => {
detail: 'No Codex binary was detected, so CCS cannot confirm managed browser override support.', detail: 'No Codex binary was detected, so CCS cannot confirm managed browser override support.',
nextStep: 'Install or upgrade Codex, then rerun browser status/doctor.', nextStep: 'Install or upgrade Codex, then rerun browser status/doctor.',
serverName: 'ccs_browser', serverName: 'ccs_browser',
evalMode: 'readonly',
supportsConfigOverrides: false, supportsConfigOverrides: false,
binaryPath: null, binaryPath: null,
}, },
File diff suppressed because it is too large Load Diff
@@ -182,7 +182,7 @@ process.exit(0);
['-c', 'model="gpt-5"', '--version'], ['-c', 'model="gpt-5"', '--version'],
[ [
'-c', '-c',
`mcp_servers.ccs_browser.command=${JSON.stringify(process.platform === 'win32' ? 'npx.cmd' : 'npx')}`, `mcp_servers.ccs_browser.command=${JSON.stringify('npx')}`,
'-c', '-c',
`mcp_servers.ccs_browser.args=${JSON.stringify(['-y', '@playwright/mcp@0.0.70'])}`, `mcp_servers.ccs_browser.args=${JSON.stringify(['-y', '@playwright/mcp@0.0.70'])}`,
'-c', '-c',
@@ -207,9 +207,11 @@ process.exit(0);
enabled: false, enabled: false,
user_data_dir: '', user_data_dir: '',
devtools_port: 9222, devtools_port: 9222,
eval_mode: 'readonly',
}, },
codex: { codex: {
enabled: false, enabled: false,
eval_mode: 'readonly',
}, },
}; };
}); });
@@ -254,7 +256,7 @@ process.exit(0);
['-c', 'model="gpt-5"', '--version'], ['-c', 'model="gpt-5"', '--version'],
[ [
'-c', '-c',
`mcp_servers.ccs_browser.command=${JSON.stringify(process.platform === 'win32' ? 'npx.cmd' : 'npx')}`, `mcp_servers.ccs_browser.command=${JSON.stringify('npx')}`,
'-c', '-c',
`mcp_servers.ccs_browser.args=${JSON.stringify(['-y', '@playwright/mcp@0.0.70'])}`, `mcp_servers.ccs_browser.args=${JSON.stringify(['-y', '@playwright/mcp@0.0.70'])}`,
'-c', '-c',
@@ -111,6 +111,7 @@ printf "%s\n" "$@" > "${claudeArgsLogPath}"
printf "port=%s\n" "$CCS_BROWSER_DEVTOOLS_PORT" printf "port=%s\n" "$CCS_BROWSER_DEVTOOLS_PORT"
printf "httpUrl=%s\n" "$CCS_BROWSER_DEVTOOLS_HTTP_URL" printf "httpUrl=%s\n" "$CCS_BROWSER_DEVTOOLS_HTTP_URL"
printf "wsUrl=%s\n" "$CCS_BROWSER_DEVTOOLS_WS_URL" printf "wsUrl=%s\n" "$CCS_BROWSER_DEVTOOLS_WS_URL"
printf "evalMode=%s\n" "$CCS_BROWSER_EVAL_MODE"
} > "${claudeEnvLogPath}" } > "${claudeEnvLogPath}"
exit 0 exit 0
`, `,
@@ -126,6 +127,13 @@ exit 0
CCS_CLAUDE_PATH: fakeClaudePath, CCS_CLAUDE_PATH: fakeClaudePath,
CCS_DEBUG: '1', 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(() => { afterEach(() => {
@@ -149,7 +157,7 @@ exit 0
fs.writeFileSync(delayedPortFile, '43123', 'utf8'); fs.writeFileSync(delayedPortFile, '43123', 'utf8');
}, 50); }, 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', () => { 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(`port=${port}`);
expect(launchedEnv).toContain(`httpUrl=http://127.0.0.1:${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('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 () => { 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, enabled: true,
user_data_dir: browserProfileDir, user_data_dir: browserProfileDir,
devtools_port: Number.parseInt(port, 10), devtools_port: Number.parseInt(port, 10),
eval_mode: 'readwrite',
}, },
codex: { codex: {
enabled: true, enabled: true,
eval_mode: 'readonly',
}, },
}; };
}); });
@@ -317,6 +328,7 @@ server.listen(0, '127.0.0.1', () => {
expect(launchedEnv).toContain(`userDataDir=${browserProfileDir}`); expect(launchedEnv).toContain(`userDataDir=${browserProfileDir}`);
expect(launchedEnv).toContain(`port=${port}`); expect(launchedEnv).toContain(`port=${port}`);
expect(launchedEnv).toContain('wsUrl=ws://127.0.0.1/devtools/browser/config-target'); expect(launchedEnv).toContain('wsUrl=ws://127.0.0.1/devtools/browser/config-target');
expect(launchedEnv).toContain('evalMode=readwrite');
} finally { } finally {
if (originalCcsHome !== undefined) { if (originalCcsHome !== undefined) {
process.env.CCS_HOME = originalCcsHome; process.env.CCS_HOME = originalCcsHome;
@@ -87,6 +87,7 @@ printf "%s\n" "$@" > "${claudeArgsLogPath}"
printf "port=%s\n" "$CCS_BROWSER_DEVTOOLS_PORT" printf "port=%s\n" "$CCS_BROWSER_DEVTOOLS_PORT"
printf "httpUrl=%s\n" "$CCS_BROWSER_DEVTOOLS_HTTP_URL" printf "httpUrl=%s\n" "$CCS_BROWSER_DEVTOOLS_HTTP_URL"
printf "wsUrl=%s\n" "$CCS_BROWSER_DEVTOOLS_WS_URL" printf "wsUrl=%s\n" "$CCS_BROWSER_DEVTOOLS_WS_URL"
printf "evalMode=%s\n" "$CCS_BROWSER_EVAL_MODE"
} > "${claudeEnvLogPath}" } > "${claudeEnvLogPath}"
exit 0 exit 0
`, `,
@@ -102,6 +103,13 @@ exit 0
CCS_CLAUDE_PATH: fakeClaudePath, CCS_CLAUDE_PATH: fakeClaudePath,
CCS_DEBUG: '1', 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(() => { afterEach(() => {
@@ -196,6 +204,7 @@ server.listen(0, '127.0.0.1', () => {
expect(launchedEnv).toContain(`port=${port}`); expect(launchedEnv).toContain(`port=${port}`);
expect(launchedEnv).toContain(`httpUrl=http://127.0.0.1:${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('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 () => { 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, enabled: true,
user_data_dir: browserProfileDir, user_data_dir: browserProfileDir,
devtools_port: Number.parseInt(port, 10), devtools_port: Number.parseInt(port, 10),
eval_mode: 'disabled',
}, },
codex: { codex: {
enabled: true, enabled: true,
eval_mode: 'readonly',
}, },
}; };
}); });
@@ -274,6 +285,7 @@ server.listen(0, '127.0.0.1', () => {
expect(launchedEnv).toContain(`userDataDir=${browserProfileDir}`); expect(launchedEnv).toContain(`userDataDir=${browserProfileDir}`);
expect(launchedEnv).toContain(`port=${port}`); expect(launchedEnv).toContain(`port=${port}`);
expect(launchedEnv).toContain('wsUrl=ws://127.0.0.1/devtools/browser/config-settings-target'); expect(launchedEnv).toContain('wsUrl=ws://127.0.0.1/devtools/browser/config-settings-target');
expect(launchedEnv).toContain('evalMode=disabled');
} finally { } finally {
if (originalCcsHome !== undefined) { if (originalCcsHome !== undefined) {
process.env.CCS_HOME = originalCcsHome; process.env.CCS_HOME = originalCcsHome;
@@ -113,6 +113,13 @@ exit 0
CCS_CLAUDE_PATH: fakeClaudePath, CCS_CLAUDE_PATH: fakeClaudePath,
CCS_DEBUG: '1', 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(() => { afterEach(() => {
@@ -167,7 +174,12 @@ exit 0
expect(result.status).toBe(0); expect(result.status).toBe(0);
expect(result.stderr).toContain('could not prepare the local ImageAnalysis tool'); expect(result.stderr).toContain('could not prepare the local ImageAnalysis tool');
const launchedArgs = fs.readFileSync(claudeArgsLogPath, 'utf8'); const launchedArgs = fs.readFileSync(claudeArgsLogPath, 'utf8');
const launchedEnv = fs.readFileSync(claudeEnvLogPath, 'utf8');
expect(launchedArgs).not.toContain(STEERING_PROMPT_SNIPPET); 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', () => { it('suppresses stale CCS image hooks during a healthy MCP-first launch', () => {
@@ -245,9 +257,8 @@ exit 0
expect(result.status).toBe(0); expect(result.status).toBe(0);
expect(fs.existsSync(claudeEnvLogPath)).toBe(true); expect(fs.existsSync(claudeEnvLogPath)).toBe(true);
const launchedEnv = fs.readFileSync(claudeEnvLogPath, 'utf8'); const launchedEnv = fs.readFileSync(claudeEnvLogPath, 'utf8');
expect(launchedEnv).toContain('runtimeApiKey=current-token');
expect(launchedEnv).not.toContain('stale-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', () => { 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(result.status).toBe(0);
expect(fs.existsSync(claudeEnvLogPath)).toBe(true); expect(fs.existsSync(claudeEnvLogPath)).toBe(true);
const launchedEnv = fs.readFileSync(claudeEnvLogPath, 'utf8'); const launchedEnv = fs.readFileSync(claudeEnvLogPath, 'utf8');
expect(launchedEnv).toContain('runtimeApiKey=current-token');
expect(launchedEnv).not.toContain('stale-token'); expect(launchedEnv).not.toContain('stale-token');
expect(launchedEnv).toContain('runtimePath=/api/provider/'); expect(launchedEnv).not.toContain('runtimeApiKey=stale-token');
}); });
}); });
@@ -72,6 +72,7 @@ describe('browser status', () => {
source: 'config', source: 'config',
effectiveUserDataDir: join(tempHome, '.ccs', 'browser', 'chrome-user-data'), effectiveUserDataDir: join(tempHome, '.ccs', 'browser', 'chrome-user-data'),
devtoolsPort: 9222, devtoolsPort: 9222,
evalMode: 'readonly',
managedMcpServerName: 'ccs-browser', managedMcpServerName: 'ccs-browser',
}); });
expect(status.claude.launchCommands.linux).toContain('--remote-debugging-port=9222'); expect(status.claude.launchCommands.linux).toContain('--remote-debugging-port=9222');
@@ -79,6 +80,7 @@ describe('browser status', () => {
enabled: true, enabled: true,
state: 'enabled', state: 'enabled',
serverName: 'ccs_browser', serverName: 'ccs_browser',
evalMode: 'readonly',
supportsConfigOverrides: true, supportsConfigOverrides: true,
}); });
} finally { } finally {
@@ -93,9 +95,11 @@ describe('browser status', () => {
enabled: true, enabled: true,
user_data_dir: '/config-browser', user_data_dir: '/config-browser',
devtools_port: 9333, devtools_port: 9333,
eval_mode: 'readwrite',
}, },
codex: { codex: {
enabled: true, enabled: true,
eval_mode: 'disabled',
}, },
}; };
}); });
@@ -108,6 +112,7 @@ describe('browser status', () => {
CCS_BROWSER_DEVTOOLS_PORT: '9444', CCS_BROWSER_DEVTOOLS_PORT: '9444',
CCS_BROWSER_DEVTOOLS_HTTP_URL: 'http://127.0.0.1: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_DEVTOOLS_WS_URL: 'ws://127.0.0.1/devtools/browser/test',
CCS_BROWSER_EVAL_MODE: 'readwrite',
}); });
const codexSpy = spyOn(codexDetector, 'getCodexBinaryInfo').mockReturnValue({ const codexSpy = spyOn(codexDetector, 'getCodexBinaryInfo').mockReturnValue({
path: '/usr/local/bin/codex', path: '/usr/local/bin/codex',
@@ -125,8 +130,11 @@ describe('browser status', () => {
source: 'CCS_BROWSER_USER_DATA_DIR', source: 'CCS_BROWSER_USER_DATA_DIR',
effectiveUserDataDir: '/env-browser', effectiveUserDataDir: '/env-browser',
devtoolsPort: 9444, devtoolsPort: 9444,
evalMode: 'readwrite',
}); });
expect(status.claude.runtimeEnv?.CCS_BROWSER_DEVTOOLS_PORT).toBe('9444'); 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 { } finally {
runtimeSpy.mockRestore(); runtimeSpy.mockRestore();
codexSpy.mockRestore(); codexSpy.mockRestore();
@@ -140,9 +148,11 @@ describe('browser status', () => {
enabled: true, enabled: true,
user_data_dir: '/tmp/browser-profile', user_data_dir: '/tmp/browser-profile',
devtools_port: 9222, devtools_port: 9222,
eval_mode: 'readonly',
}, },
codex: { codex: {
enabled: true, enabled: true,
eval_mode: 'readonly',
}, },
}; };
}); });
@@ -178,6 +188,7 @@ describe('browser status', () => {
CCS_BROWSER_DEVTOOLS_PORT: '50123', CCS_BROWSER_DEVTOOLS_PORT: '50123',
CCS_BROWSER_DEVTOOLS_HTTP_URL: 'http://127.0.0.1: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_DEVTOOLS_WS_URL: 'ws://127.0.0.1/devtools/browser/legacy',
CCS_BROWSER_EVAL_MODE: 'readonly',
}); });
const codexSpy = spyOn(codexDetector, 'getCodexBinaryInfo').mockReturnValue({ const codexSpy = spyOn(codexDetector, 'getCodexBinaryInfo').mockReturnValue({
path: '/usr/local/bin/codex', path: '/usr/local/bin/codex',
@@ -192,8 +203,10 @@ describe('browser status', () => {
expect(runtimeSpy.mock.calls[0]?.[0]).toEqual({ expect(runtimeSpy.mock.calls[0]?.[0]).toEqual({
profileDir: '/legacy-browser', profileDir: '/legacy-browser',
devtoolsPort: undefined, devtoolsPort: undefined,
evalMode: 'readonly',
}); });
expect(status.claude.runtimeEnv?.CCS_BROWSER_DEVTOOLS_PORT).toBe('50123'); expect(status.claude.runtimeEnv?.CCS_BROWSER_DEVTOOLS_PORT).toBe('50123');
expect(status.claude.runtimeEnv?.CCS_BROWSER_EVAL_MODE).toBe('readonly');
} finally { } finally {
runtimeSpy.mockRestore(); runtimeSpy.mockRestore();
codexSpy.mockRestore(); codexSpy.mockRestore();
@@ -207,9 +220,11 @@ describe('browser status', () => {
enabled: true, enabled: true,
user_data_dir: '/tmp/config-browser', user_data_dir: '/tmp/config-browser',
devtools_port: 9222, devtools_port: 9222,
eval_mode: 'disabled',
}, },
codex: { codex: {
enabled: true, enabled: true,
eval_mode: 'readwrite',
}, },
}; };
}); });
@@ -220,6 +235,7 @@ describe('browser status', () => {
CCS_BROWSER_DEVTOOLS_PORT: '9222', CCS_BROWSER_DEVTOOLS_PORT: '9222',
CCS_BROWSER_DEVTOOLS_HTTP_URL: 'http://127.0.0.1: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_DEVTOOLS_WS_URL: 'ws://127.0.0.1/devtools/browser/config',
CCS_BROWSER_EVAL_MODE: 'disabled',
}); });
const codexSpy = spyOn(codexDetector, 'getCodexBinaryInfo').mockReturnValue({ const codexSpy = spyOn(codexDetector, 'getCodexBinaryInfo').mockReturnValue({
path: '/usr/local/bin/codex', path: '/usr/local/bin/codex',
@@ -234,6 +250,7 @@ describe('browser status', () => {
expect(runtimeSpy.mock.calls[0]?.[0]).toEqual({ expect(runtimeSpy.mock.calls[0]?.[0]).toEqual({
profileDir: '/tmp/config-browser', profileDir: '/tmp/config-browser',
devtoolsPort: '9222', devtoolsPort: '9222',
evalMode: 'disabled',
}); });
} finally { } finally {
runtimeSpy.mockRestore(); runtimeSpy.mockRestore();
+75 -12
View File
@@ -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 fs from 'fs';
import * as os from 'os'; import * as os from 'os';
import * as path from 'path'; import * as path from 'path';
@@ -12,6 +12,13 @@ describe('chrome reuse resolver', () => {
const originalHome = process.env.HOME; const originalHome = process.env.HOME;
const originalLocalAppData = process.env.LOCALAPPDATA; const originalLocalAppData = process.env.LOCALAPPDATA;
const originalUserProfile = process.env.USERPROFILE; 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 tempDirs: string[] = [];
let servers: Array<{ stop: () => void }> = []; let servers: Array<{ stop: () => void }> = [];
@@ -71,7 +78,7 @@ describe('chrome reuse resolver', () => {
return server; return server;
} }
async function reserveClosedPort(): Promise<number> { function reserveClosedPort(): number {
const server = Bun.serve({ const server = Bun.serve({
port: 0, port: 0,
fetch() { fetch() {
@@ -79,13 +86,27 @@ describe('chrome reuse resolver', () => {
}, },
}); });
const port = server.port; 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; 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(() => { afterEach(() => {
for (const server of servers) { for (const server of servers) {
server.stop(true); server.stop();
} }
servers = []; servers = [];
@@ -111,6 +132,48 @@ describe('chrome reuse resolver', () => {
} else { } else {
process.env.USERPROFILE = originalUserProfile; 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 () => { 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 () => { it('throws a clear error when DevToolsActivePort metadata is missing', async () => {
const profileDir = createTempDir('ccs-chrome-missing-metadata-'); 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')}` `Chrome reuse metadata not found: ${path.join(profileDir, 'DevToolsActivePort')}`
); );
}); });
@@ -184,17 +247,17 @@ describe('chrome reuse resolver', () => {
const profileDir = createTempDir('ccs-chrome-invalid-metadata-'); const profileDir = createTempDir('ccs-chrome-invalid-metadata-');
writeDevToolsActivePort(profileDir, 'not-a-port\n/devtools/browser/target'); 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')}` `Chrome reuse metadata is invalid: ${path.join(profileDir, 'DevToolsActivePort')}`
); );
}); });
it('throws before launch fallback when the DevTools endpoint is stale or unreachable', async () => { it('throws before launch fallback when the DevTools endpoint is stale or unreachable', async () => {
const profileDir = createTempDir('ccs-chrome-unreachable-'); const profileDir = createTempDir('ccs-chrome-unreachable-');
const port = await reserveClosedPort(); const port = reserveClosedPort();
writeDevToolsActivePort(profileDir, `${port}\n/devtools/browser/stale`); 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}` `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' }); const server = await startDevToolsServer({ Browser: 'Chrome/136.0.0.0' });
writeDevToolsActivePort(profileDir, `${server.port}\n/devtools/browser/no-ws`); 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` `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); const server = await startFailingDevToolsServer(500);
writeDevToolsActivePort(profileDir, `${server.port}\n/devtools/browser/bad-status`); 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}` `Chrome DevTools endpoint is unreachable: http://127.0.0.1:${server.port}`
); );
}); });
@@ -224,7 +287,7 @@ describe('chrome reuse resolver', () => {
const server = await startMalformedJsonDevToolsServer(); const server = await startMalformedJsonDevToolsServer();
writeDevToolsActivePort(profileDir, `${server.port}\n/devtools/browser/bad-json`); 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}` `Chrome DevTools endpoint is unreachable: http://127.0.0.1:${server.port}`
); );
}); });
@@ -262,7 +325,7 @@ describe('chrome reuse resolver', () => {
'missing-profile' 'missing-profile'
); );
await expect(resolveBrowserRuntimeEnv({ profileDir: missingProfileDir })).rejects.toThrow( expect(resolveBrowserRuntimeEnv({ profileDir: missingProfileDir })).rejects.toThrow(
`Chrome profile directory is invalid: ${missingProfileDir}` `Chrome profile directory is invalid: ${missingProfileDir}`
); );
}); });
@@ -13,6 +13,13 @@ describe('browser routes', () => {
let tempHome = ''; let tempHome = '';
let originalCcsHome: string | undefined; let originalCcsHome: string | undefined;
let originalDashboardAuthEnabled: 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'; let forcedRemoteAddress = '127.0.0.1';
beforeAll(async () => { beforeAll(async () => {
@@ -54,8 +61,22 @@ describe('browser routes', () => {
tempHome = mkdtempSync(join(tmpdir(), 'ccs-browser-routes-')); tempHome = mkdtempSync(join(tmpdir(), 'ccs-browser-routes-'));
originalCcsHome = process.env.CCS_HOME; originalCcsHome = process.env.CCS_HOME;
originalDashboardAuthEnabled = process.env.CCS_DASHBOARD_AUTH_ENABLED; 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_HOME = tempHome;
process.env.CCS_DASHBOARD_AUTH_ENABLED = 'false'; 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'; forcedRemoteAddress = '127.0.0.1';
}); });
@@ -72,6 +93,48 @@ describe('browser routes', () => {
delete process.env.CCS_DASHBOARD_AUTH_ENABLED; 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 }); rmSync(tempHome, { recursive: true, force: true });
}); });
@@ -95,9 +158,11 @@ describe('browser routes', () => {
enabled: false, enabled: false,
userDataDir: join(tempHome, '.ccs', 'browser', 'chrome-user-data'), userDataDir: join(tempHome, '.ccs', 'browser', 'chrome-user-data'),
devtoolsPort: 9222, devtoolsPort: 9222,
evalMode: 'readonly',
}, },
codex: { codex: {
enabled: true, enabled: true,
evalMode: 'readonly',
}, },
}); });
expect(payload.status.claude).toMatchObject({ expect(payload.status.claude).toMatchObject({
@@ -119,9 +184,11 @@ describe('browser routes', () => {
enabled: true, enabled: true,
userDataDir: '/tmp/ccs-browser', userDataDir: '/tmp/ccs-browser',
devtoolsPort: 9333, devtoolsPort: 9333,
evalMode: 'readwrite',
}, },
codex: { codex: {
enabled: false, enabled: false,
evalMode: 'disabled',
}, },
}), }),
}); });
@@ -133,9 +200,11 @@ describe('browser routes', () => {
enabled: true, enabled: true,
userDataDir: '/tmp/ccs-browser', userDataDir: '/tmp/ccs-browser',
devtoolsPort: 9333, devtoolsPort: 9333,
evalMode: 'readwrite',
}, },
codex: { codex: {
enabled: false, enabled: false,
evalMode: 'disabled',
}, },
}); });
@@ -145,9 +214,11 @@ describe('browser routes', () => {
enabled: true, enabled: true,
user_data_dir: '/tmp/ccs-browser', user_data_dir: '/tmp/ccs-browser',
devtools_port: 9333, devtools_port: 9333,
eval_mode: 'readwrite',
}, },
codex: { codex: {
enabled: false, enabled: false,
eval_mode: 'disabled',
}, },
}); });
}); });
@@ -183,6 +254,7 @@ describe('browser routes', () => {
enabled: true, enabled: true,
userDataDir: join(tempHome, '.ccs', 'browser', 'chrome-user-data'), userDataDir: join(tempHome, '.ccs', 'browser', 'chrome-user-data'),
devtoolsPort: 9333, devtoolsPort: 9333,
evalMode: 'readonly',
}); });
const config = loadOrCreateUnifiedConfig(); const config = loadOrCreateUnifiedConfig();
@@ -191,6 +263,7 @@ describe('browser routes', () => {
enabled: true, enabled: true,
user_data_dir: join(tempHome, '.ccs', 'browser', 'chrome-user-data'), user_data_dir: join(tempHome, '.ccs', 'browser', 'chrome-user-data'),
devtools_port: 9333, 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.', 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.',
});
});
}); });
+6
View File
@@ -234,14 +234,18 @@ export interface UpdateImageAnalysisSettingsPayload {
profileBackends?: Record<string, string>; profileBackends?: Record<string, string>;
} }
export type BrowserEvalMode = 'disabled' | 'readonly' | 'readwrite';
export interface BrowserSettingsConfig { export interface BrowserSettingsConfig {
claude: { claude: {
enabled: boolean; enabled: boolean;
userDataDir: string; userDataDir: string;
devtoolsPort: number; devtoolsPort: number;
evalMode: BrowserEvalMode;
}; };
codex: { codex: {
enabled: boolean; enabled: boolean;
evalMode: BrowserEvalMode;
}; };
} }
@@ -262,6 +266,7 @@ export interface ClaudeBrowserStatus {
effectiveUserDataDir: string; effectiveUserDataDir: string;
recommendedUserDataDir: string; recommendedUserDataDir: string;
devtoolsPort: number; devtoolsPort: number;
evalMode: BrowserEvalMode;
managedMcpServerName: string; managedMcpServerName: string;
managedMcpServerPath: string; managedMcpServerPath: string;
launchCommands: BrowserLaunchCommands; launchCommands: BrowserLaunchCommands;
@@ -275,6 +280,7 @@ export interface CodexBrowserStatus {
detail: string; detail: string;
nextStep: string; nextStep: string;
serverName: string; serverName: string;
evalMode: BrowserEvalMode;
supportsConfigOverrides: boolean; supportsConfigOverrides: boolean;
binaryPath: string | null; binaryPath: string | null;
version?: string; version?: string;
+48
View File
@@ -2435,6 +2435,14 @@ const resources = {
nextStep: 'Next step', nextStep: 'Next step',
technicalDetails: 'Technical Details', technicalDetails: 'Technical Details',
diagnostics: 'Diagnostics', diagnostics: 'Diagnostics',
evalMode: {
label: 'browser_eval access',
options: {
disabled: 'Disabled',
readonly: 'Read-only',
readwrite: 'Read/write',
},
},
actions: { actions: {
saveClaude: 'Save Claude settings', saveClaude: 'Save Claude settings',
saveCodex: 'Save Codex settings', saveCodex: 'Save Codex settings',
@@ -2461,6 +2469,8 @@ const resources = {
effectivePath: 'Effective attach path', effectivePath: 'Effective attach path',
recommendedPath: 'Recommended path', recommendedPath: 'Recommended path',
managedRuntime: 'Managed browser runtime', 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: overrideMessage:
'An environment override is currently active from {{source}}. This dashboard remains the source of truth once that override is removed.', 'An environment override is currently active from {{source}}. This dashboard remains the source of truth once that override is removed.',
launchGuidance: 'Launch guidance', launchGuidance: 'Launch guidance',
@@ -2480,6 +2490,8 @@ const resources = {
overrideUnsupported: 'Not supported', overrideUnsupported: 'Not supported',
binary: 'Detected Codex binary', binary: 'Detected Codex binary',
notDetected: 'Not detected', 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: '下一步', nextStep: '下一步',
technicalDetails: '技术细节', technicalDetails: '技术细节',
diagnostics: '诊断信息', diagnostics: '诊断信息',
evalMode: {
label: 'browser_eval 权限',
options: {
disabled: '禁用',
readonly: '只读',
readwrite: '可读写',
},
},
actions: { actions: {
saveClaude: '保存 Claude 设置', saveClaude: '保存 Claude 设置',
saveCodex: '保存 Codex 设置', saveCodex: '保存 Codex 设置',
@@ -4873,6 +4893,8 @@ const resources = {
effectivePath: '当前生效的 attach 路径', effectivePath: '当前生效的 attach 路径',
recommendedPath: '推荐路径', recommendedPath: '推荐路径',
managedRuntime: '托管浏览器运行时', managedRuntime: '托管浏览器运行时',
evalModeHint:
'控制 Claude Browser Attach 中 browser_eval 的权限。只读仅允许读取,读写还允许页面侧修改。',
overrideMessage: overrideMessage:
'当前存在来自 {{source}} 的环境变量覆盖。移除该覆盖后,Dashboard 配置将重新成为唯一来源。', '当前存在来自 {{source}} 的环境变量覆盖。移除该覆盖后,Dashboard 配置将重新成为唯一来源。',
launchGuidance: '启动指引', launchGuidance: '启动指引',
@@ -4889,6 +4911,8 @@ const resources = {
overrideUnsupported: '不支持', overrideUnsupported: '不支持',
binary: '检测到的 Codex 可执行文件', binary: '检测到的 Codex 可执行文件',
notDetected: '未检测到', notDetected: '未检测到',
evalModeHint:
'为保持 Browser 设置页一致性而保存该配置。Phase 1 中,browser_eval 的实际限制主要作用于 Claude Browser Attach。',
}, },
}, },
}, },
@@ -7367,6 +7391,14 @@ const resources = {
nextStep: 'Bước tiếp theo', nextStep: 'Bước tiếp theo',
technicalDetails: 'Chi tiết kỹ thuật', technicalDetails: 'Chi tiết kỹ thuật',
diagnostics: 'Chẩn đoán', diagnostics: 'Chẩn đoán',
evalMode: {
label: 'Quyền browser_eval',
options: {
disabled: 'Tắt',
readonly: 'Chỉ đọc',
readwrite: 'Đọc/ghi',
},
},
actions: { actions: {
saveClaude: 'Lưu cấu hình Claude', saveClaude: 'Lưu cấu hình Claude',
saveCodex: 'Lưu cấu hình Codex', 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', effectivePath: 'Đường dẫn attach đang có hiệu lực',
recommendedPath: 'Đường dẫn khuyến nghị', recommendedPath: 'Đường dẫn khuyến nghị',
managedRuntime: 'Runtime trình duyệt được quản lý', 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: 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.', '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', launchGuidance: 'Hướng dẫn khởi chạy',
@@ -7413,6 +7447,8 @@ const resources = {
overrideUnsupported: 'Không được hỗ trợ', overrideUnsupported: 'Không được hỗ trợ',
binary: 'Binary Codex được phát hiện', binary: 'Binary Codex được phát hiện',
notDetected: 'Không 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: '次の手順', nextStep: '次の手順',
technicalDetails: '技術的な詳細', technicalDetails: '技術的な詳細',
diagnostics: '診断情報', diagnostics: '診断情報',
evalMode: {
label: 'browser_eval 権限',
options: {
disabled: '無効',
readonly: '読み取り専用',
readwrite: '読み書き可',
},
},
actions: { actions: {
saveClaude: 'Claude 設定を保存', saveClaude: 'Claude 設定を保存',
saveCodex: 'Codex 設定を保存', saveCodex: 'Codex 設定を保存',
@@ -9818,6 +9862,8 @@ const resources = {
effectivePath: '現在有効な attach パス', effectivePath: '現在有効な attach パス',
recommendedPath: '推奨パス', recommendedPath: '推奨パス',
managedRuntime: '管理済みブラウザランタイム', managedRuntime: '管理済みブラウザランタイム',
evalModeHint:
'Claude Browser Attach における browser_eval の権限を制御します。読み取り専用は参照のみ、読み書き可はページ側の変更も許可します。',
overrideMessage: overrideMessage:
'{{source}} から環境変数オーバーライドが有効です。これを外すと Dashboard の設定が再び主ソースになります。', '{{source}} から環境変数オーバーライドが有効です。これを外すと Dashboard の設定が再び主ソースになります。',
launchGuidance: '起動ガイダンス', launchGuidance: '起動ガイダンス',
@@ -9837,6 +9883,8 @@ const resources = {
overrideUnsupported: '非対応', overrideUnsupported: '非対応',
binary: '検出された Codex バイナリ', binary: '検出された Codex バイナリ',
notDetected: '未検出', notDetected: '未検出',
evalModeHint:
'Browser 設定画面の整合性のために保存されます。Phase 1 では browser_eval の制御は主に Claude Browser Attach に適用されます。',
}, },
}, },
}, },
+132 -35
View File
@@ -2,29 +2,36 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { import {
Browser, AlertCircle,
Gear,
CheckCircle,
WarningCircle,
XCircle,
ArrowRight, ArrowRight,
ClipboardText, CheckCircle2,
ArrowsClockwise, ChevronDown,
TerminalWindow, Clipboard,
CaretDown,
Info, Info,
} from '@phosphor-icons/react'; Monitor,
RefreshCw,
Settings,
Terminal,
XCircle,
} from 'lucide-react';
import { Alert, AlertDescription } from '@/components/ui/alert'; import { Alert, AlertDescription } from '@/components/ui/alert';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { ScrollArea } from '@/components/ui/scroll-area'; import { ScrollArea } from '@/components/ui/scroll-area';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Switch } from '@/components/ui/switch'; import { Switch } from '@/components/ui/switch';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { getClientPlatformKey } from '@/lib/platform'; import { getClientPlatformKey } from '@/lib/platform';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useBrowserConfig, useRawConfig } from '../../hooks'; import { useBrowserConfig, useRawConfig } from '../../hooks';
import type { BrowserConfig } from '../../types'; import type { BrowserConfig, BrowserEvalMode } from '../../types';
// --- Constants & Helpers --- // --- Constants & Helpers ---
@@ -35,6 +42,12 @@ function parsePortDraft(value: string): number | null {
return port; return port;
} }
const BROWSER_EVAL_MODE_OPTIONS: BrowserEvalMode[] = ['disabled', 'readonly', 'readwrite'];
function getBrowserEvalModeLabel(t: ReturnType<typeof useTranslation>['t'], mode: BrowserEvalMode) {
return t(`settingsPage.browserSection.evalMode.options.${mode}`);
}
function buildLaunchCommand( function buildLaunchCommand(
userDataDir: string, userDataDir: string,
devtoolsPort: number, devtoolsPort: number,
@@ -142,11 +155,11 @@ function StatusStrip({
)} )}
> >
{isReady ? ( {isReady ? (
<CheckCircle weight="duotone" size={24} /> <CheckCircle2 size={24} />
) : isError ? ( ) : isError ? (
<WarningCircle weight="duotone" size={24} /> <AlertCircle size={24} />
) : ( ) : (
<XCircle weight="duotone" size={24} /> <XCircle size={24} />
)} )}
</div> </div>
<div className="min-w-0 flex-1 space-y-1"> <div className="min-w-0 flex-1 space-y-1">
@@ -205,10 +218,10 @@ function DiagnosticsSection({
<CollapsibleTrigger asChild> <CollapsibleTrigger asChild>
<button className="flex w-full items-center justify-between px-6 py-4 text-sm font-medium transition-colors hover:bg-muted/40 dark:hover:bg-zinc-900/50"> <button className="flex w-full items-center justify-between px-6 py-4 text-sm font-medium transition-colors hover:bg-muted/40 dark:hover:bg-zinc-900/50">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Gear size={16} weight="bold" className="text-muted-foreground" /> <Settings size={16} className="text-muted-foreground" />
{title} {title}
</div> </div>
<CaretDown <ChevronDown
size={16} size={16}
className={cn('transition-transform duration-300', isOpen && 'rotate-180')} className={cn('transition-transform duration-300', isOpen && 'rotate-180')}
/> />
@@ -275,11 +288,13 @@ export default function BrowserSection() {
effectiveConfig !== null && effectiveConfig !== null &&
(config.claude.enabled !== effectiveConfig.claude.enabled || (config.claude.enabled !== effectiveConfig.claude.enabled ||
config.claude.userDataDir !== effectiveConfig.claude.userDataDir || config.claude.userDataDir !== effectiveConfig.claude.userDataDir ||
config.claude.devtoolsPort !== claudePort); config.claude.devtoolsPort !== claudePort ||
config.claude.evalMode !== effectiveConfig.claude.evalMode);
const hasCodexChanges = const hasCodexChanges =
config !== null && config !== null &&
effectiveConfig !== null && effectiveConfig !== null &&
config.codex.enabled !== effectiveConfig.codex.enabled; (config.codex.enabled !== effectiveConfig.codex.enabled ||
config.codex.evalMode !== effectiveConfig.codex.evalMode);
const refreshAll = useCallback(async () => { const refreshAll = useCallback(async () => {
setActionMessage(null); setActionMessage(null);
@@ -302,6 +317,7 @@ export default function BrowserSection() {
enabled: effectiveConfig.claude.enabled, enabled: effectiveConfig.claude.enabled,
userDataDir: effectiveConfig.claude.userDataDir.trim(), userDataDir: effectiveConfig.claude.userDataDir.trim(),
devtoolsPort: claudePort, devtoolsPort: claudePort,
evalMode: effectiveConfig.claude.evalMode,
}, },
}); });
if (saved) { if (saved) {
@@ -317,6 +333,7 @@ export default function BrowserSection() {
const saved = await saveConfig({ const saved = await saveConfig({
codex: { codex: {
enabled: effectiveConfig.codex.enabled, enabled: effectiveConfig.codex.enabled,
evalMode: effectiveConfig.codex.evalMode,
}, },
}); });
if (saved) { if (saved) {
@@ -343,7 +360,7 @@ export default function BrowserSection() {
animate={{ rotate: 360 }} animate={{ rotate: 360 }}
transition={{ duration: 1.5, repeat: Infinity, ease: 'linear' }} transition={{ duration: 1.5, repeat: Infinity, ease: 'linear' }}
> >
<ArrowsClockwise size={32} /> <RefreshCw size={32} />
</motion.div> </motion.div>
<span className="text-sm font-medium uppercase tracking-widest opacity-60"> <span className="text-sm font-medium uppercase tracking-widest opacity-60">
{t('settings.loading')} {t('settings.loading')}
@@ -357,7 +374,7 @@ export default function BrowserSection() {
return ( return (
<div className="p-8"> <div className="p-8">
<Alert variant="destructive" className="rounded-[2rem] p-8 shadow-xl"> <Alert variant="destructive" className="rounded-[2rem] p-8 shadow-xl">
<XCircle weight="duotone" size={24} /> <XCircle size={24} />
<AlertDescription className="mt-2 text-md leading-relaxed"> <AlertDescription className="mt-2 text-md leading-relaxed">
{error ?? t('settingsPage.browserSection.description')} {error ?? t('settingsPage.browserSection.description')}
</AlertDescription> </AlertDescription>
@@ -367,7 +384,7 @@ export default function BrowserSection() {
className="rounded-full px-6 transition-transform active:scale-95" className="rounded-full px-6 transition-transform active:scale-95"
onClick={refreshAll} onClick={refreshAll}
> >
<ArrowsClockwise className="mr-2 h-4 w-4" /> <RefreshCw className="mr-2 h-4 w-4" />
{t('sharedPage.retry')} {t('sharedPage.retry')}
</Button> </Button>
</div> </div>
@@ -389,12 +406,12 @@ export default function BrowserSection() {
> >
{error ? ( {error ? (
<div className="flex items-center gap-3 rounded-full border border-rose-500/20 bg-card/95 px-6 py-2.5 text-sm font-medium text-rose-600 shadow-2xl dark:bg-zinc-900/85"> <div className="flex items-center gap-3 rounded-full border border-rose-500/20 bg-card/95 px-6 py-2.5 text-sm font-medium text-rose-600 shadow-2xl dark:bg-zinc-900/85">
<XCircle size={18} weight="bold" /> <XCircle size={18} />
{error} {error}
</div> </div>
) : ( ) : (
<div className="flex items-center gap-3 rounded-full border border-emerald-500/20 bg-card/95 px-6 py-2.5 text-sm font-medium text-emerald-600 shadow-2xl dark:bg-zinc-900/85"> <div className="flex items-center gap-3 rounded-full border border-emerald-500/20 bg-card/95 px-6 py-2.5 text-sm font-medium text-emerald-600 shadow-2xl dark:bg-zinc-900/85">
<CheckCircle size={18} weight="bold" /> <CheckCircle2 size={18} />
{actionMessage ?? t('commonToast.settingsSaved')} {actionMessage ?? t('commonToast.settingsSaved')}
</div> </div>
)} )}
@@ -413,7 +430,7 @@ export default function BrowserSection() {
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary"> <div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<Browser weight="duotone" size={28} /> <Monitor size={28} />
</div> </div>
<div> <div>
<h1 className="text-3xl font-bold tracking-tight"> <h1 className="text-3xl font-bold tracking-tight">
@@ -431,7 +448,7 @@ export default function BrowserSection() {
onClick={refreshAll} onClick={refreshAll}
disabled={saving || loading} disabled={saving || loading}
> >
<ArrowsClockwise className={cn('mr-2 h-4 w-4', statusLoading && 'animate-spin')} /> <RefreshCw className={cn('mr-2 h-4 w-4', statusLoading && 'animate-spin')} />
{t('settings.refresh')} {t('settings.refresh')}
</Button> </Button>
</motion.div> </motion.div>
@@ -442,7 +459,7 @@ export default function BrowserSection() {
transition={{ delay: 0.1 }} transition={{ delay: 0.1 }}
> >
<Alert className="items-center rounded-3xl border-primary/10 bg-primary/[0.04] p-6 py-4 dark:border-primary/15 dark:bg-primary/[0.05]"> <Alert className="items-center rounded-3xl border-primary/10 bg-primary/[0.04] p-6 py-4 dark:border-primary/15 dark:bg-primary/[0.05]">
<Info className="h-5 w-5 text-primary" weight="duotone" /> <Info className="h-5 w-5 text-primary" />
<AlertDescription className="ml-2 text-sm"> <AlertDescription className="ml-2 text-sm">
<span className="font-semibold text-primary/80"> <span className="font-semibold text-primary/80">
{t('settingsPage.browserSection.primaryTitle')} {t('settingsPage.browserSection.primaryTitle')}
@@ -486,9 +503,9 @@ export default function BrowserSection() {
className="rounded-full px-6 shadow-md transition-transform active:scale-95" className="rounded-full px-6 shadow-md transition-transform active:scale-95"
> >
{saving ? ( {saving ? (
<ArrowsClockwise className="mr-2 h-4 w-4 animate-spin" /> <RefreshCw className="mr-2 h-4 w-4 animate-spin" />
) : ( ) : (
<CheckCircle weight="bold" size={16} className="mr-2" /> <CheckCircle2 size={16} className="mr-2" />
)} )}
{t('settingsPage.browserSection.actions.saveClaude')} {t('settingsPage.browserSection.actions.saveClaude')}
</Button> </Button>
@@ -498,7 +515,7 @@ export default function BrowserSection() {
onClick={refreshStatus} onClick={refreshStatus}
disabled={saving || statusLoading || hasClaudeChanges || claudePortInvalid} disabled={saving || statusLoading || hasClaudeChanges || claudePortInvalid}
> >
<TerminalWindow weight="bold" size={16} className="mr-2" /> <Terminal size={16} className="mr-2" />
{t('settingsPage.browserSection.actions.testConnection')} {t('settingsPage.browserSection.actions.testConnection')}
</Button> </Button>
</div> </div>
@@ -564,6 +581,36 @@ export default function BrowserSection() {
: t('settingsPage.browserSection.claude.devtoolsPortHint')} : t('settingsPage.browserSection.claude.devtoolsPortHint')}
</p> </p>
</div> </div>
<div className="space-y-3">
<Label className="text-sm font-semibold tracking-wide text-muted-foreground/80">
{t('settingsPage.browserSection.evalMode.label')}
</Label>
<Select
value={effectiveConfig.claude.evalMode}
onValueChange={(next) =>
setDraft((current) =>
updateClaudeDraft(current ?? effectiveConfig, {
evalMode: next as BrowserEvalMode,
})
)
}
>
<SelectTrigger className="rounded-xl border-border/70 bg-background/85 dark:border-white/[0.08] dark:bg-zinc-900/70">
<SelectValue />
</SelectTrigger>
<SelectContent>
{BROWSER_EVAL_MODE_OPTIONS.map((mode) => (
<SelectItem key={mode} value={mode}>
{getBrowserEvalModeLabel(t, mode)}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-[11px] leading-relaxed text-muted-foreground/70">
{t('settingsPage.browserSection.claude.evalModeHint')}
</p>
</div>
</div> </div>
<div className="space-y-6"> <div className="space-y-6">
@@ -584,7 +631,7 @@ export default function BrowserSection() {
onClick={copyLaunchCommand} onClick={copyLaunchCommand}
disabled={!preferredLaunchCommand} disabled={!preferredLaunchCommand}
> >
<ClipboardText size={18} /> <Clipboard size={18} />
</Button> </Button>
</div> </div>
<code className="mt-4 block break-all rounded-lg border border-border/50 bg-background/80 p-3 font-mono text-[10px] leading-relaxed dark:border-white/[0.05] dark:bg-zinc-900/60"> <code className="mt-4 block break-all rounded-lg border border-border/50 bg-background/80 p-3 font-mono text-[10px] leading-relaxed dark:border-white/[0.05] dark:bg-zinc-900/60">
@@ -594,7 +641,7 @@ export default function BrowserSection() {
{status.claude.overrideActive && ( {status.claude.overrideActive && (
<div className="flex items-center gap-3 rounded-2xl border border-amber-500/10 bg-amber-500/[0.05] p-4 text-xs font-medium text-amber-700 dark:text-amber-300"> <div className="flex items-center gap-3 rounded-2xl border border-amber-500/10 bg-amber-500/[0.05] p-4 text-xs font-medium text-amber-700 dark:text-amber-300">
<WarningCircle size={18} weight="duotone" /> <AlertCircle size={18} />
{t('settingsPage.browserSection.claude.overrideMessage', { {t('settingsPage.browserSection.claude.overrideMessage', {
source: status.claude.source, source: status.claude.source,
})} })}
@@ -637,6 +684,16 @@ export default function BrowserSection() {
</p> </p>
</div> </div>
</div> </div>
<div className="space-y-2">
<p className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground/50">
{t('settingsPage.browserSection.evalMode.label')}
</p>
<div className="rounded-lg border border-border/50 bg-muted/25 p-3 dark:border-white/[0.05] dark:bg-zinc-900/45">
<p className="font-mono text-[11px] font-semibold">
{getBrowserEvalModeLabel(t, status.claude.evalMode)}
</p>
</div>
</div>
</div> </div>
</DiagnosticsSection> </DiagnosticsSection>
</div> </div>
@@ -677,9 +734,9 @@ export default function BrowserSection() {
className="rounded-full px-6 shadow-md transition-transform active:scale-95" className="rounded-full px-6 shadow-md transition-transform active:scale-95"
> >
{saving ? ( {saving ? (
<ArrowsClockwise className="mr-2 h-4 w-4 animate-spin" /> <RefreshCw className="mr-2 h-4 w-4 animate-spin" />
) : ( ) : (
<CheckCircle weight="bold" size={16} className="mr-2" /> <CheckCircle2 size={16} className="mr-2" />
)} )}
{t('settingsPage.browserSection.actions.saveCodex')} {t('settingsPage.browserSection.actions.saveCodex')}
</Button> </Button>
@@ -694,6 +751,36 @@ export default function BrowserSection() {
nextStep={status.codex.nextStep} nextStep={status.codex.nextStep}
/> />
<div className="max-w-md space-y-3">
<Label className="text-sm font-semibold tracking-wide text-muted-foreground/80">
{t('settingsPage.browserSection.evalMode.label')}
</Label>
<Select
value={effectiveConfig.codex.evalMode}
onValueChange={(next) =>
setDraft((current) =>
updateCodexDraft(current ?? effectiveConfig, {
evalMode: next as BrowserEvalMode,
})
)
}
>
<SelectTrigger className="rounded-xl border-border/70 bg-background/85 dark:border-white/[0.08] dark:bg-zinc-900/70">
<SelectValue />
</SelectTrigger>
<SelectContent>
{BROWSER_EVAL_MODE_OPTIONS.map((mode) => (
<SelectItem key={mode} value={mode}>
{getBrowserEvalModeLabel(t, mode)}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-[11px] leading-relaxed text-muted-foreground/70">
{t('settingsPage.browserSection.codex.evalModeHint')}
</p>
</div>
<DiagnosticsSection <DiagnosticsSection
title={t('settingsPage.browserSection.technicalDetails')} title={t('settingsPage.browserSection.technicalDetails')}
defaultOpen={status.codex.state === 'unsupported_build'} defaultOpen={status.codex.state === 'unsupported_build'}
@@ -715,9 +802,9 @@ export default function BrowserSection() {
</p> </p>
<div className="flex items-center gap-2 rounded-lg border border-border/50 bg-muted/25 p-3 font-medium text-xs dark:border-white/[0.05] dark:bg-zinc-900/45"> <div className="flex items-center gap-2 rounded-lg border border-border/50 bg-muted/25 p-3 font-medium text-xs dark:border-white/[0.05] dark:bg-zinc-900/45">
{status.codex.supportsConfigOverrides ? ( {status.codex.supportsConfigOverrides ? (
<CheckCircle className="text-emerald-500" size={16} weight="bold" /> <CheckCircle2 className="text-emerald-500" size={16} />
) : ( ) : (
<XCircle className="text-rose-500" size={16} weight="bold" /> <XCircle className="text-rose-500" size={16} />
)} )}
{status.codex.supportsConfigOverrides {status.codex.supportsConfigOverrides
? t('settingsPage.browserSection.codex.overrideSupported') ? t('settingsPage.browserSection.codex.overrideSupported')
@@ -740,6 +827,16 @@ export default function BrowserSection() {
)} )}
</div> </div>
</div> </div>
<div className="space-y-2">
<p className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground/50">
{t('settingsPage.browserSection.evalMode.label')}
</p>
<div className="rounded-lg border border-border/50 bg-muted/25 p-3 dark:border-white/[0.05] dark:bg-zinc-900/45">
<p className="font-mono text-[11px] font-semibold">
{getBrowserEvalModeLabel(t, status.codex.evalMode)}
</p>
</div>
</div>
</div> </div>
</DiagnosticsSection> </DiagnosticsSection>
</div> </div>
+2 -1
View File
@@ -4,6 +4,7 @@
*/ */
import type { import type {
BrowserEvalMode,
BrowserSettingsConfig, BrowserSettingsConfig,
BrowserStatusPayload, BrowserStatusPayload,
CliproxyServerConfig, CliproxyServerConfig,
@@ -198,7 +199,7 @@ export interface ThinkingConfig {
// === Re-exports from api-client === // === Re-exports from api-client ===
export type { CliproxyServerConfig, RemoteProxyStatus }; export type { BrowserEvalMode, CliproxyServerConfig, RemoteProxyStatus };
export type BrowserConfig = BrowserSettingsConfig; export type BrowserConfig = BrowserSettingsConfig;
export type BrowserStatus = BrowserStatusPayload; export type BrowserStatus = BrowserStatusPayload;
export type BrowserSavePayload = UpdateBrowserSettingsPayload; export type BrowserSavePayload = UpdateBrowserSettingsPayload;