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