mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 04:18:05 +00:00
feat(browser): 完成 browser MCP 第一阶段能力与配置打通
- 增加 browser_wait_for、browser_eval 及 click/query 扩展能力 - 打通 browser eval_mode 的配置、状态、启动链路与设置界面 - 补充浏览器相关测试隔离、文档和 CI parity 收尾调整
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -1164,6 +1164,7 @@ export async function execClaudeWithCLIProxy(
|
||||
devtoolsPort: browserAttachConfig.hasExplicitDevtoolsPort
|
||||
? String(browserAttachConfig.devtoolsPort)
|
||||
: undefined,
|
||||
evalMode: browserAttachConfig.evalMode,
|
||||
})),
|
||||
}
|
||||
: undefined;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 に適用されます。',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user