feat(cli): add browser automation commands

This commit is contained in:
Tam Nhu Tran
2026-04-16 18:49:24 -04:00
parent b6aef885ad
commit 4e30c9b080
42 changed files with 3028 additions and 32 deletions
+5 -2
View File
@@ -127,8 +127,10 @@ Deep dive:
![WebSearch Fallback](assets/screenshots/websearch.webp)
CCS can provision first-class local tools like WebSearch and image analysis for
third-party launches instead of leaving you to wire them by hand. Deep dive:
[WebSearch](https://docs.ccs.kaitran.ca/features/ai/websearch).
third-party launches instead of leaving you to wire them by hand. Browser
automation now has a first-class setup path as well. Deep dive:
[WebSearch](https://docs.ccs.kaitran.ca/features/ai/websearch) |
[Browser Automation](./docs/browser-automation.md).
## Docs Matrix
@@ -144,6 +146,7 @@ reference material.
| Compare OAuth providers, Claude accounts, and API profiles | [Provider Overview](https://docs.ccs.kaitran.ca/providers/concepts/overview) |
| Learn the dashboard structure and feature pages | [Dashboard Overview](https://docs.ccs.kaitran.ca/features/dashboard/overview) |
| Configure profiles, paths, and environment variables | [Configuration](https://docs.ccs.kaitran.ca/getting-started/configuration) |
| Understand browser attach vs Codex browser tooling | [Browser Automation](./docs/browser-automation.md) |
| Keep OpenCode aligned with your live CCS setup | [OpenCode Sync Plugin](https://docs.ccs.kaitran.ca/features/workflow/opencode-sync) |
| Browse every command and flag | [CLI Commands](https://docs.ccs.kaitran.ca/reference/cli-commands) |
| Recover from install, auth, or provider failures | [Troubleshooting](https://docs.ccs.kaitran.ca/reference/troubleshooting) |
+164
View File
@@ -0,0 +1,164 @@
# Browser Automation
Last Updated: 2026-04-16
CCS provides browser automation through two separate runtime paths:
- **Claude Browser Attach**: reuses a running Chrome/Chromium session through the CCS-managed local `ccs-browser` MCP runtime
- **Codex Browser Tools**: injects Playwright MCP tooling into Codex-target launches
These are related, but they are not the same implementation and they do not promise a shared browser session.
## How Browser Automation Works
### Claude Browser Attach
Claude-target CCS launches can provision a managed local MCP server named `ccs-browser`.
That path is designed for workflows where you want Claude to interact with a browser session
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.
### Codex Browser Tools
Codex-target CCS launches use a separate managed path: CCS injects Playwright MCP overrides
for the `ccs_browser` runtime config entry.
This is configured from the same Browser settings surface, but it is distinct from Claude
Browser Attach.
## Configuration
### Via Dashboard
Open `ccs config` -> `Settings` -> `Browser`.
The Browser screen exposes two sections:
- **Claude Browser Attach**
- enable/disable the Claude attach lane
- choose the Chrome user-data directory
- set the expected DevTools port
- 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
- review whether the detected Codex build supports managed browser overrides
### Via CLI
```bash
ccs help browser
ccs browser status
ccs browser doctor
```
Use `ccs browser status` for the current state and `ccs browser doctor` for actionable
troubleshooting guidance.
### Via Config File
Edit `~/.ccs/config.yaml`:
```yaml
browser:
claude:
enabled: false
user_data_dir: "~/.ccs/browser/chrome-user-data"
devtools_port: 9222
codex:
enabled: true
```
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
- `codex.enabled` controls whether CCS injects browser tooling into Codex-target launches
## Environment Variable Overrides
CCS still supports environment-variable overrides for backward compatibility.
| Variable | Description |
|----------|-------------|
| `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 |
If an override is active, Browser status surfaces should report that the current session is being
managed externally by environment variables.
## Managed Runtime Files
- `~/.claude.json` -> CCS manages `mcpServers.ccs-browser` for Claude Browser Attach
- `~/.ccs/mcp/ccs-browser-server.cjs` -> local Claude Browser Attach MCP runtime
- `Codex runtime config overrides` -> CCS manages the `ccs_browser` MCP entry for Codex-target launches
Do not treat the generic Codex MCP editor as the primary browser setup path. CCS-managed browser
entries should be configured from `Settings -> Browser`.
## Launching Chrome For Claude Attach
Claude Browser Attach needs a browser launched with remote debugging.
Typical examples:
```bash
# macOS
open -na "Google Chrome" --args --remote-debugging-port=9222 --user-data-dir="$HOME/.ccs/browser/chrome-user-data"
# Linux
google-chrome --remote-debugging-port=9222 --user-data-dir="$HOME/.ccs/browser/chrome-user-data"
# Windows
chrome.exe --remote-debugging-port=9222 --user-data-dir="%USERPROFILE%\\.ccs\\browser\\chrome-user-data"
```
Using a dedicated CCS browser data dir is recommended. It avoids profile-locking issues and keeps
automation state separate from your daily browser profile.
## Troubleshooting
### Browser status says Claude Browser Attach is disabled
Enable Claude Browser Attach in `Settings -> Browser` or via the browser config block in
`~/.ccs/config.yaml`.
### Browser status says the path is missing
The configured Chrome user-data directory does not exist yet.
1. Create the directory or use the generated launch command
2. Start Chrome in attach mode with `--remote-debugging-port`
3. Rerun `ccs browser doctor`
### Browser status says no running browser session was found
CCS could not find usable DevTools attach metadata for the configured user-data directory.
1. Make sure Chrome was started with `--remote-debugging-port=<port>`
2. Make sure it is using the same `user_data_dir` configured in CCS
3. Rerun `ccs browser doctor`
### Browser status says the DevTools endpoint is unreachable
CCS found attach metadata, but the endpoint did not answer successfully.
1. Restart the attach browser session
2. Confirm the expected port matches the real remote debugging port
3. Rerun `ccs browser status`
### Codex Browser Tools are unavailable
Codex browser tooling depends on a Codex build that supports `--config` overrides.
If CCS reports `unsupported_build`, upgrade Codex and rerun `ccs browser status`.
## Security Notes
- Browser automation may operate inside authenticated browser sessions
- Prefer a dedicated automation user-data dir instead of your everyday browser profile
- Do not commit browser paths, secrets, or generated session state to version control
- Treat `~/.ccs/config.yaml`, `~/.claude.json`, and the browser user-data directory as local machine state
+1
View File
@@ -41,6 +41,7 @@ All major modularization work is complete. The codebase evolved from monolithic
### Recent Fixes
- **2026-04-16**: **#1030** Browser automation is now a first-class CCS surface instead of an env-only/runtime-only feature. CCS adds `ccs help browser`, `ccs browser status`, and `ccs browser doctor`; a dedicated `Settings -> Browser` dashboard tab for Claude Browser Attach and Codex Browser Tools; a new `browser` section in `~/.ccs/config.yaml`; explicit readiness/next-step messaging for attach-mode Chrome sessions; and Codex UI guidance that marks the managed `ccs_browser` entry as CCS-owned and redirects browser setup away from the generic MCP editor.
- **2026-04-15**: **#969** Local CLIProxy bootstrap no longer depends on live GitHub reachability during normal dashboard and runtime startup. CCS now skips hidden auto-update lookups on standard CLIProxy bootstrap paths, fails fast with explicit `ccs cliproxy install` guidance when a service start needs a binary that is not installed locally, and keeps `ccs config` able to open the dashboard in limited mode instead of stalling behind blocked release downloads.
- **2026-04-15**: **#1010** Remote dashboard auth guidance now explains the Docker boundary explicitly. The readonly banner, remote login/setup card, and dashboard-auth docs now tell users that integrated Docker deployments keep config inside the running `ccs-cliproxy` container volume, so `ccs config auth setup` must run there rather than in the outer host shell.
- **2026-04-14**: **#991** CCS now auto-routes Claude-target settings profiles that use OpenAI-compatible endpoints through a local Anthropic-compatible proxy instead of sending raw Anthropic `/v1/messages` traffic directly to chat-completions backends. The `ccs proxy` command now supports `start`, `status`, `activate`, and `stop` with explicit host binding, shell-aware activation helpers, and a fuller local runtime env contract. The proxy surface now exposes `GET /`, `/health`, `/v1/models`, and `/v1/messages`, logs routing decisions into CCS structured logs, supports Anthropic image blocks plus request-time `profile:model` overrides, and adds config-driven scenario routing (`background`, `think`, `longContext`, `webSearch`) on top of the compatible-profile path. Coverage now includes request routing, rate-limit/timeout/empty-upstream failures, chunked tool-call streaming, and disconnect cleanup alongside the existing unit, integration, and e2e suites.
+24 -14
View File
@@ -39,11 +39,15 @@ import {
import {
appendBrowserToolArgs,
ensureBrowserMcpOrThrow,
getEffectiveClaudeBrowserAttachConfig,
resolveBrowserRuntimeEnv,
resolveConfiguredBrowserProfileDir,
syncBrowserMcpToConfigDir,
} from './utils/browser';
import { getGlobalEnvConfig, getOfficialChannelsConfig } from './config/unified-config-loader';
import {
getBrowserConfig,
getGlobalEnvConfig,
getOfficialChannelsConfig,
} from './config/unified-config-loader';
import {
ensureProfileHooks as ensureImageAnalyzerHooks,
removeImageAnalysisProfileHook,
@@ -135,7 +139,7 @@ const CODEX_NATIVE_PASSTHROUGH_FLAGS = new Set(['--help', '-h', '--version', '-v
function resolveCodexRuntimeConfigOverrides(
target: ReturnType<typeof resolveTargetType>
): string[] {
if (target !== 'codex') {
if (target !== 'codex' || !getBrowserConfig().codex.enabled) {
return [];
}
return buildCodexBrowserMcpOverrides();
@@ -1054,13 +1058,13 @@ async function main(): Promise<void> {
const imageAnalysisMcpReady =
resolvedTarget === 'claude' ? ensureImageAnalysisMcpOrThrow() : true;
let browserRuntimeEnv: TargetCredentials['browserRuntimeEnv'];
const browserProfileDir =
const browserAttachConfig =
resolvedTarget === 'claude'
? resolveConfiguredBrowserProfileDir(process.env.CCS_BROWSER_PROFILE_DIR)
? getEffectiveClaudeBrowserAttachConfig(getBrowserConfig())
: undefined;
if (resolvedTarget === 'claude') {
ensureWebSearchMcpOrThrow();
if (browserProfileDir) {
if (browserAttachConfig?.enabled) {
ensureBrowserMcpOrThrow();
}
}
@@ -1087,7 +1091,7 @@ async function main(): Promise<void> {
syncWebSearchMcpToConfigDir(inheritedClaudeConfigDir);
syncImageAnalysisMcpToConfigDir(inheritedClaudeConfigDir);
if (
browserProfileDir &&
browserAttachConfig?.enabled &&
inheritedClaudeConfigDir &&
!syncBrowserMcpToConfigDir(inheritedClaudeConfigDir)
) {
@@ -1297,10 +1301,13 @@ async function main(): Promise<void> {
// Explicitly inject effective settings env vars so stale ANTHROPIC_*
// values from prior sessions cannot leak into the active profile.
if (browserProfileDir) {
if (browserAttachConfig?.enabled) {
browserRuntimeEnv = {
...(await resolveBrowserRuntimeEnv({
profileDir: browserProfileDir,
profileDir: browserAttachConfig.userDataDir,
devtoolsPort: browserAttachConfig.hasExplicitDevtoolsPort
? String(browserAttachConfig.devtoolsPort)
: undefined,
})),
};
}
@@ -1465,17 +1472,20 @@ async function main(): Promise<void> {
CCS_IMAGE_ANALYSIS_SKIP: '1',
};
let browserRuntimeEnv: TargetCredentials['browserRuntimeEnv'];
const browserProfileDir =
const browserAttachConfig =
resolvedTarget === 'claude'
? resolveConfiguredBrowserProfileDir(process.env.CCS_BROWSER_PROFILE_DIR)
? getEffectiveClaudeBrowserAttachConfig(getBrowserConfig())
: undefined;
if (resolvedTarget === 'claude') {
if (browserProfileDir) {
if (browserAttachConfig?.enabled) {
ensureBrowserMcpOrThrow();
browserRuntimeEnv = {
...(await resolveBrowserRuntimeEnv({
profileDir: browserProfileDir,
profileDir: browserAttachConfig.userDataDir,
devtoolsPort: browserAttachConfig.hasExplicitDevtoolsPort
? String(browserAttachConfig.devtoolsPort)
: undefined,
})),
};
Object.assign(envVars, browserRuntimeEnv);
@@ -1495,7 +1505,7 @@ async function main(): Promise<void> {
if (defaultContinuityInheritance.claudeConfigDir) {
envVars.CLAUDE_CONFIG_DIR = defaultContinuityInheritance.claudeConfigDir;
if (
browserProfileDir &&
browserAttachConfig?.enabled &&
!syncBrowserMcpToConfigDir(defaultContinuityInheritance.claudeConfigDir)
) {
throw new Error(
+14 -7
View File
@@ -66,11 +66,15 @@ import {
import {
appendBrowserToolArgs,
ensureBrowserMcpOrThrow,
getEffectiveClaudeBrowserAttachConfig,
resolveBrowserRuntimeEnv,
resolveConfiguredBrowserProfileDir,
syncBrowserMcpToConfigDir,
} from '../../utils/browser';
import { loadOrCreateUnifiedConfig, getThinkingConfig } from '../../config/unified-config-loader';
import {
getBrowserConfig,
loadOrCreateUnifiedConfig,
getThinkingConfig,
} from '../../config/unified-config-loader';
import { HttpsTunnelProxy } from '../https-tunnel-proxy';
import {
isKiroAuthMethod,
@@ -260,8 +264,8 @@ export async function execClaudeWithCLIProxy(
// Setup first-class CCS WebSearch runtime
ensureWebSearchMcpOrThrow();
const imageAnalysisMcpReady = ensureImageAnalysisMcpOrThrow();
const browserProfileDir = resolveConfiguredBrowserProfileDir(process.env.CCS_BROWSER_PROFILE_DIR);
if (browserProfileDir) {
const browserAttachConfig = getEffectiveClaudeBrowserAttachConfig(getBrowserConfig());
if (browserAttachConfig.enabled) {
ensureBrowserMcpOrThrow();
}
displayWebSearchStatus();
@@ -1050,7 +1054,7 @@ export async function execClaudeWithCLIProxy(
syncImageAnalysisMcpToConfigDir(inheritedClaudeConfigDir);
if (
browserProfileDir &&
browserAttachConfig.enabled &&
inheritedClaudeConfigDir &&
!syncBrowserMcpToConfigDir(inheritedClaudeConfigDir)
) {
@@ -1153,10 +1157,13 @@ export async function execClaudeWithCLIProxy(
}
// 11. Build final environment with all proxy chains
const browserRuntimeEnv = browserProfileDir
const browserRuntimeEnv = browserAttachConfig.enabled
? {
...(await resolveBrowserRuntimeEnv({
profileDir: browserProfileDir,
profileDir: browserAttachConfig.userDataDir,
devtoolsPort: browserAttachConfig.hasExplicitDevtoolsPort
? String(browserAttachConfig.devtoolsPort)
: undefined,
})),
}
: undefined;
+155
View File
@@ -0,0 +1,155 @@
import { getBrowserStatus, type BrowserStatusPayload } from '../utils/browser';
import { color, dim, header, initUI, subheader } from '../utils/ui';
type HelpWriter = (line: string) => void;
function currentPlatform(): 'darwin' | 'linux' | 'win32' {
if (process.platform === 'darwin') return 'darwin';
if (process.platform === 'win32') return 'win32';
return 'linux';
}
function summarizeBrowserHealth(status: BrowserStatusPayload): {
label: 'ready' | 'partial' | 'action required';
exitCode: 0 | 1;
} {
const claudeNeedsAttention = status.claude.enabled && status.claude.state !== 'ready';
if (claudeNeedsAttention) {
return { label: 'action required', exitCode: 1 };
}
if (status.codex.enabled && status.codex.state !== 'enabled') {
return { label: 'partial', exitCode: 0 };
}
return { label: 'ready', exitCode: 0 };
}
function writeCommandTable(writeLine: HelpWriter): void {
writeLine(subheader('Commands'));
writeLine(
` ${color('ccs browser status', 'command')} Show Claude attach and Codex browser readiness`
);
writeLine(
` ${color('ccs browser doctor', 'command')} Explain what is missing and how to fix it`
);
writeLine('');
}
function writeIntro(writeLine: HelpWriter): void {
writeLine(' Claude Browser Attach reuses a local Chrome session for Claude-target launches.');
writeLine(
' Codex Browser Tools inject managed Playwright MCP overrides into Codex-target launches.'
);
writeLine('');
}
function writeClaudeStatus(
status: BrowserStatusPayload['claude'],
writeLine: HelpWriter,
includeLaunchGuidance: boolean
): void {
writeLine(subheader('Claude Browser Attach'));
writeLine(` State: ${status.state}`);
writeLine(` Enabled: ${status.enabled ? 'yes' : 'no'}`);
writeLine(` Source: ${status.source}${status.overrideActive ? ' (env override active)' : ''}`);
writeLine(` User data dir: ${status.effectiveUserDataDir}`);
writeLine(` DevTools port: ${status.devtoolsPort}`);
writeLine(` Managed MCP: ${status.managedMcpServerName}`);
writeLine(` Managed path: ${status.managedMcpServerPath}`);
if (status.runtimeEnv?.CCS_BROWSER_DEVTOOLS_HTTP_URL) {
writeLine(` DevTools endpoint: ${status.runtimeEnv.CCS_BROWSER_DEVTOOLS_HTTP_URL}`);
}
writeLine(` Detail: ${status.detail}`);
writeLine(` Next step: ${status.nextStep}`);
if (includeLaunchGuidance && status.enabled && status.state !== 'ready') {
writeLine(
` Launch command (${currentPlatform()}): ${status.launchCommands[currentPlatform()]}`
);
}
writeLine('');
}
function writeCodexStatus(status: BrowserStatusPayload['codex'], writeLine: HelpWriter): void {
writeLine(subheader('Codex Browser Tools'));
writeLine(` State: ${status.state}`);
writeLine(` Enabled: ${status.enabled ? 'yes' : 'no'}`);
writeLine(` Managed server: ${status.serverName}`);
writeLine(` Supports overrides: ${status.supportsConfigOverrides ? 'yes' : 'no'}`);
writeLine(` Codex binary: ${status.binaryPath || 'not detected'}`);
if (status.version) {
writeLine(` Codex version: ${status.version}`);
}
writeLine(` Detail: ${status.detail}`);
writeLine(` Next step: ${status.nextStep}`);
writeLine('');
}
export async function showBrowserHelp(writeLine: HelpWriter = console.log): Promise<void> {
await initUI();
writeLine(header('CCS Browser Help'));
writeLine('');
writeIntro(writeLine);
writeLine(subheader('Usage'));
writeLine(` ${color('ccs browser <status|doctor>', 'command')}`);
writeLine(` ${color('ccs help browser', 'command')}`);
writeLine('');
writeCommandTable(writeLine);
writeLine(subheader('What Each Lane Does'));
writeLine(' Claude Browser Attach expects a Chrome user-data dir and remote debugging port.');
writeLine(' Codex Browser Tools depend on a Codex build that supports --config overrides.');
writeLine('');
writeLine(subheader('Examples'));
writeLine(` ${color('ccs browser status', 'command')} ${dim('# Quick readiness snapshot')}`);
writeLine(
` ${color('ccs browser doctor', 'command')} ${dim('# Detailed troubleshooting output')}`
);
writeLine(
` ${color('ccs config', 'command')} ${dim('# Open Settings > Browser in the dashboard')}`
);
writeLine('');
}
export async function handleBrowserCommand(
args: string[],
writeLine: HelpWriter = console.log
): Promise<void> {
const subcommand = args[0];
if (!subcommand || subcommand === '--help' || subcommand === '-h' || subcommand === 'help') {
await showBrowserHelp(writeLine);
return;
}
if (subcommand !== 'status' && subcommand !== 'doctor') {
await initUI();
writeLine(color(`Unknown browser subcommand: ${subcommand}`, 'error'));
writeLine('');
writeLine(` ${dim('Supported subcommands: status, doctor')}`);
writeLine('');
process.exitCode = 1;
return;
}
await initUI();
const status = await getBrowserStatus();
writeLine(header(`ccs browser ${subcommand}`));
writeLine('');
writeIntro(writeLine);
if (subcommand === 'doctor') {
const summary = summarizeBrowserHealth(status);
writeLine(subheader('Overall'));
writeLine(` Claude Browser Attach: ${status.claude.title}`);
writeLine(` Codex Browser Tools: ${status.codex.title}`);
writeLine(` Result: ${summary.label}`);
writeLine('');
}
writeClaudeStatus(status.claude, writeLine, subcommand === 'doctor');
writeCodexStatus(status.codex, writeLine);
if (subcommand === 'doctor') {
process.exitCode = summarizeBrowserHealth(status).exitCode;
}
}
+15 -1
View File
@@ -1,7 +1,13 @@
import { COPILOT_SUBCOMMANDS } from '../copilot/constants';
import { CLIPROXY_PROVIDER_IDS } from '../cliproxy/provider-capabilities';
export type HelpTopicName = 'profiles' | 'providers' | 'kiro' | 'completion' | 'targets';
export type HelpTopicName =
| 'profiles'
| 'providers'
| 'kiro'
| 'browser'
| 'completion'
| 'targets';
export interface HelpTopicEntry {
name: HelpTopicName;
@@ -25,6 +31,7 @@ export const ROOT_HELP_TOPICS: readonly HelpTopicEntry[] = [
{ name: 'profiles', summary: 'Account profiles, API profiles, and CLIProxy variants' },
{ name: 'providers', summary: 'Built-in OAuth providers and runtime shortcuts' },
{ name: 'kiro', summary: 'Kiro auth methods, IDC flags, and callback guidance' },
{ name: 'browser', summary: 'Claude Browser Attach and Codex Browser Tools guidance' },
{ name: 'completion', summary: 'Shell completion install, refresh, and testing' },
{ name: 'targets', summary: 'Claude, Droid, and Codex target routing' },
] as const;
@@ -114,6 +121,12 @@ export const ROOT_COMMAND_CATALOG: readonly RootCommandEntry[] = [
group: 'runtime',
visibility: 'public',
},
{
name: 'browser',
summary: 'Inspect Claude Browser Attach and Codex Browser Tools readiness',
group: 'runtime',
visibility: 'public',
},
{
name: 'copilot',
summary: 'Run or manage the GitHub Copilot bridge',
@@ -307,6 +320,7 @@ export const COMMAND_FLAG_SUGGESTIONS: Readonly<Record<string, readonly string[]
config: ['--help', '-h', '--port', '-p', '--host', '-H', '--dev'],
cursor: ['--help', '-h'],
doctor: ['--fix', '-f', '--help', '-h'],
browser: ['status', 'doctor', '--help', '-h'],
docker: ['--help', '-h', '--host'],
env: ['--format', '--shell', '--ide', '--help', '-h'],
migrate: MIGRATE_FLAGS,
+7 -1
View File
@@ -249,7 +249,7 @@ export async function handleHelpCommand(writeLine: HelpWriter = console.log): Pr
writeLine(header(`CCS CLI v${packageJson.version}`));
writeLine('');
writeLine(' Claude profile switching, provider routing, and compatible runtime bridges.');
writeLine(' Claude profile switching, provider routing, runtime bridges, and browser tooling.');
writeLine('');
writeLine(subheader('Usage'));
@@ -279,6 +279,7 @@ export async function handleHelpCommand(writeLine: HelpWriter = console.log): Pr
[
{ name: 'ccs help profiles', summary: getTopicSummary('profiles') },
{ name: 'ccs help providers', summary: getTopicSummary('providers') },
{ name: 'ccs help browser', summary: getTopicSummary('browser') },
{ name: 'ccs help completion', summary: getTopicSummary('completion') },
{ name: 'ccs help targets', summary: getTopicSummary('targets') },
{ name: 'ccs api --help', summary: 'Deep help for API profile lifecycle commands' },
@@ -328,6 +329,10 @@ export async function handleHelpRoute(
await showTargetsHelp(writeLine);
return;
}
if (topic === 'browser') {
await (await import('./browser-command')).showBrowserHelp(writeLine);
return;
}
if (topic === 'completion') {
const { showShellCompletionHelp } = await import('./shell-completion-command');
showShellCompletionHelp(writeLine);
@@ -342,6 +347,7 @@ export async function handleHelpRoute(
await new AuthCommands().showHelp();
},
cleanup: async () => (await import('./cleanup-command')).handleCleanupCommand(['--help']),
browser: async () => (await import('./browser-command')).showBrowserHelp(writeLine),
cliproxy: async () => (await import('./cliproxy/help-subcommand')).showHelp(),
copilot: async () =>
process.exit(await (await import('./copilot-command')).handleCopilotCommand(['--help'])),
+7
View File
@@ -105,6 +105,13 @@ export const ROOT_COMMAND_ROUTES: readonly NamedCommandRoute[] = [
await handleSyncCommand();
},
},
{
name: 'browser',
handle: async (args) => {
const { handleBrowserCommand } = await import('./browser-command');
await handleBrowserCommand(args);
},
},
{
name: 'cleanup',
aliases: ['--cleanup'],
+56
View File
@@ -23,6 +23,7 @@ import {
DEFAULT_THINKING_CONFIG,
DEFAULT_OFFICIAL_CHANNELS_CONFIG,
DEFAULT_DASHBOARD_AUTH_CONFIG,
DEFAULT_BROWSER_CONFIG,
DEFAULT_IMAGE_ANALYSIS_CONFIG,
DEFAULT_LOGGING_CONFIG,
} from './unified-config-types';
@@ -34,6 +35,7 @@ import type {
OfficialChannelsConfig,
OfficialChannelId,
DashboardAuthConfig,
BrowserConfig,
ImageAnalysisConfig,
LoggingConfig,
CursorConfig,
@@ -46,6 +48,7 @@ import {
normalizeOfficialChannelIds,
resolveLegacyDiscordSelection,
} from '../channels/official-channels-runtime';
import { getRecommendedBrowserUserDataDir } from '../utils/browser/browser-settings';
import { canonicalizeImageAnalysisConfig } from '../utils/hooks/image-analysis-backend-resolver';
import { normalizeSearxngBaseUrl } from '../utils/websearch/types';
@@ -54,6 +57,32 @@ const CONFIG_JSON = 'config.json';
const CONFIG_LOCK = 'config.yaml.lock';
const LOCK_STALE_MS = 5000; // Lock is stale after 5 seconds
function normalizeBrowserDevtoolsPort(value: number | undefined): number {
if (!Number.isFinite(value)) {
return DEFAULT_BROWSER_CONFIG.claude.devtools_port;
}
const port = Math.floor(value as number);
if (port < 1 || port > 65535) {
return DEFAULT_BROWSER_CONFIG.claude.devtools_port;
}
return port;
}
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),
},
codex: {
enabled: config?.codex?.enabled ?? DEFAULT_BROWSER_CONFIG.codex.enabled,
},
};
}
/**
* Get path to unified config.yaml
*/
@@ -592,6 +621,7 @@ function mergeWithDefaults(partial: Partial<UnifiedConfig>): UnifiedConfig {
partial.dashboard_auth?.session_timeout_hours ??
DEFAULT_DASHBOARD_AUTH_CONFIG.session_timeout_hours,
},
browser: canonicalizeBrowserConfig(partial.browser),
// Image analysis config - enabled by default for CLIProxy providers
image_analysis: canonicalizeImageAnalysisConfig({
enabled: partial.image_analysis?.enabled ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.enabled,
@@ -916,6 +946,23 @@ function generateYamlWithComments(config: UnifiedConfig): string {
lines.push('');
}
// Browser automation section
if (config.browser) {
lines.push('# ----------------------------------------------------------------------------');
lines.push('# Browser Automation: Claude browser attach and Codex browser tooling');
lines.push('# Claude attach reuses a running Chrome/Chromium session with remote debugging.');
lines.push('# Codex tooling controls whether CCS injects Playwright MCP overrides.');
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('# Configure via: Settings > Browser or `ccs browser ...`.');
lines.push('# ----------------------------------------------------------------------------');
lines.push(
yaml.dump({ browser: config.browser }, { indent: 2, lineWidth: -1, quotingType: '"' }).trim()
);
lines.push('');
}
// Image analysis section
if (config.image_analysis) {
lines.push('# ----------------------------------------------------------------------------');
@@ -1334,6 +1381,15 @@ export function getDashboardAuthConfig(): DashboardAuthConfig {
};
}
/**
* Get browser automation configuration.
* Returns canonicalized defaults if not configured.
*/
export function getBrowserConfig(): BrowserConfig {
const config = loadOrCreateUnifiedConfig();
return canonicalizeBrowserConfig(config.browser);
}
/**
* Get image_analysis configuration.
* Returns defaults if not configured.
+40
View File
@@ -812,6 +812,40 @@ export const DEFAULT_DASHBOARD_AUTH_CONFIG: DashboardAuthConfig = {
session_timeout_hours: 24,
};
/**
* Browser automation configuration.
* Controls Claude browser attach and Codex browser tooling.
*/
export interface BrowserClaudeConfig {
/** Enable Claude browser attach (default: false) */
enabled: boolean;
/** Chrome user-data directory used for attach mode */
user_data_dir: string;
/** DevTools port used for attach mode (default: 9222) */
devtools_port: number;
}
export interface BrowserCodexConfig {
/** Enable Codex browser tooling injection (default: true) */
enabled: boolean;
}
export interface BrowserConfig {
claude: BrowserClaudeConfig;
codex: BrowserCodexConfig;
}
export const DEFAULT_BROWSER_CONFIG: BrowserConfig = {
claude: {
enabled: false,
user_data_dir: '',
devtools_port: 9222,
},
codex: {
enabled: true,
},
};
/**
* Image analysis configuration.
* Routes image/PDF files through CLIProxy for vision analysis.
@@ -895,6 +929,8 @@ export interface UnifiedConfig {
channels?: OfficialChannelsConfig;
/** Dashboard authentication configuration (optional) */
dashboard_auth?: DashboardAuthConfig;
/** Browser automation configuration */
browser?: BrowserConfig;
/** Image analysis configuration (vision via CLIProxy) */
image_analysis?: ImageAnalysisConfig;
}
@@ -1041,6 +1077,10 @@ export function createEmptyUnifiedConfig(): UnifiedConfig {
thinking: { ...DEFAULT_THINKING_CONFIG },
channels: { ...DEFAULT_OFFICIAL_CHANNELS_CONFIG },
dashboard_auth: { ...DEFAULT_DASHBOARD_AUTH_CONFIG },
browser: {
claude: { ...DEFAULT_BROWSER_CONFIG.claude },
codex: { ...DEFAULT_BROWSER_CONFIG.codex },
},
image_analysis: { ...DEFAULT_IMAGE_ANALYSIS_CONFIG },
};
}
+100
View File
@@ -0,0 +1,100 @@
import * as path from 'path';
import type { BrowserConfig } from '../../config/unified-config-types';
import { getCcsDir } from '../config-manager';
import { expandPath } from '../helpers';
export type BrowserOverrideSource = 'CCS_BROWSER_USER_DATA_DIR' | 'CCS_BROWSER_PROFILE_DIR';
export interface EffectiveClaudeBrowserAttachConfig {
enabled: boolean;
source: 'config' | BrowserOverrideSource;
overrideActive: boolean;
userDataDir: string;
devtoolsPort: number;
hasExplicitDevtoolsPort: boolean;
}
export function getRecommendedBrowserUserDataDir(): string {
return path.join(getCcsDir(), 'browser', 'chrome-user-data');
}
export function resolveBrowserUserDataDir(value?: string): string | undefined {
return value?.trim() ? expandPath(value) : undefined;
}
export function getBrowserAttachOverride(env: NodeJS.ProcessEnv = process.env): {
userDataDir?: string;
devtoolsPort?: number;
source?: BrowserOverrideSource;
} {
const explicitUserDataDir = resolveBrowserUserDataDir(env.CCS_BROWSER_USER_DATA_DIR);
if (explicitUserDataDir) {
return {
userDataDir: explicitUserDataDir,
devtoolsPort: parseDevtoolsPort(env.CCS_BROWSER_DEVTOOLS_PORT),
source: 'CCS_BROWSER_USER_DATA_DIR',
};
}
const legacyProfileDir = resolveBrowserUserDataDir(env.CCS_BROWSER_PROFILE_DIR);
if (legacyProfileDir) {
return {
userDataDir: legacyProfileDir,
devtoolsPort: parseDevtoolsPort(env.CCS_BROWSER_DEVTOOLS_PORT),
source: 'CCS_BROWSER_PROFILE_DIR',
};
}
return {};
}
export function getEffectiveClaudeBrowserAttachConfig(
config: BrowserConfig,
env: NodeJS.ProcessEnv = process.env
): EffectiveClaudeBrowserAttachConfig {
const override = getBrowserAttachOverride(env);
const configUserDataDir =
resolveBrowserUserDataDir(config.claude.user_data_dir) ?? getRecommendedBrowserUserDataDir();
const configPort = normalizeDevtoolsPort(config.claude.devtools_port);
if (override.userDataDir) {
return {
enabled: true,
source: override.source as BrowserOverrideSource,
overrideActive: true,
userDataDir: override.userDataDir,
devtoolsPort: override.devtoolsPort ?? configPort,
hasExplicitDevtoolsPort: override.devtoolsPort !== undefined,
};
}
return {
enabled: config.claude.enabled,
source: 'config',
overrideActive: false,
userDataDir: configUserDataDir,
devtoolsPort: configPort,
hasExplicitDevtoolsPort: true,
};
}
function parseDevtoolsPort(value?: string): number | undefined {
if (!value?.trim() || !/^\d+$/.test(value.trim())) {
return undefined;
}
return normalizeDevtoolsPort(Number.parseInt(value.trim(), 10));
}
function normalizeDevtoolsPort(value: number | undefined): number {
if (!Number.isFinite(value)) {
return 9222;
}
const port = Math.floor(value as number);
if (port < 1 || port > 65535) {
return 9222;
}
return port;
}
+193
View File
@@ -0,0 +1,193 @@
import { getBrowserConfig } from '../../config/unified-config-loader';
import { getCodexBinaryInfo } from '../../targets/codex-detector';
import { type BrowserRuntimeEnv, resolveBrowserRuntimeEnv } from './chrome-reuse';
import { getBrowserMcpServerName, getBrowserMcpServerPath } from './mcp-installer';
import {
getEffectiveClaudeBrowserAttachConfig,
getRecommendedBrowserUserDataDir,
} from './browser-settings';
export interface BrowserLaunchCommands {
darwin: string;
linux: string;
win32: string;
}
export interface ClaudeBrowserStatus {
enabled: boolean;
source: 'config' | 'CCS_BROWSER_USER_DATA_DIR' | 'CCS_BROWSER_PROFILE_DIR';
overrideActive: boolean;
state: 'disabled' | 'path_missing' | 'browser_not_running' | 'endpoint_unreachable' | 'ready';
title: string;
detail: string;
nextStep: string;
effectiveUserDataDir: string;
recommendedUserDataDir: string;
devtoolsPort: number;
managedMcpServerName: string;
managedMcpServerPath: string;
launchCommands: BrowserLaunchCommands;
runtimeEnv?: BrowserRuntimeEnv;
}
export interface CodexBrowserStatus {
enabled: boolean;
state: 'disabled' | 'enabled' | 'unsupported_build';
title: string;
detail: string;
nextStep: string;
serverName: string;
supportsConfigOverrides: boolean;
binaryPath: string | null;
version?: string;
}
export interface BrowserStatusPayload {
claude: ClaudeBrowserStatus;
codex: CodexBrowserStatus;
}
export async function getBrowserStatus(): Promise<BrowserStatusPayload> {
const browserConfig = getBrowserConfig();
return {
claude: await buildClaudeBrowserStatus(browserConfig),
codex: buildCodexBrowserStatus(browserConfig),
};
}
async function buildClaudeBrowserStatus(
browserConfig = getBrowserConfig()
): Promise<ClaudeBrowserStatus> {
const effective = getEffectiveClaudeBrowserAttachConfig(browserConfig);
const launchCommands = buildLaunchCommands(effective.userDataDir, effective.devtoolsPort);
const base: Omit<ClaudeBrowserStatus, 'state' | 'title' | 'detail' | 'nextStep'> = {
enabled: effective.enabled,
source: effective.source,
overrideActive: effective.overrideActive,
effectiveUserDataDir: effective.userDataDir,
recommendedUserDataDir: getRecommendedBrowserUserDataDir(),
devtoolsPort: effective.devtoolsPort,
managedMcpServerName: getBrowserMcpServerName(),
managedMcpServerPath: getBrowserMcpServerPath(),
launchCommands,
};
if (!effective.enabled) {
return {
...base,
state: 'disabled',
title: 'Claude Browser Attach is disabled.',
detail:
'CCS will not provision the managed browser MCP runtime for Claude launches until this lane is enabled.',
nextStep:
'Enable Claude Browser Attach in Settings > Browser or in ~/.ccs/config.yaml, then rerun `ccs browser doctor`.',
};
}
try {
const runtimeEnv = await resolveBrowserRuntimeEnv({
profileDir: effective.userDataDir,
devtoolsPort: effective.hasExplicitDevtoolsPort ? String(effective.devtoolsPort) : undefined,
});
return {
...base,
state: 'ready',
title: 'Claude Browser Attach is ready.',
detail:
'CCS can reach the configured Chrome DevTools endpoint for the current attach session.',
nextStep: 'Launch a Claude-target CCS session to use the managed browser MCP runtime.',
runtimeEnv,
};
} catch (error) {
const message = (error as Error).message;
if (message.includes('Chrome profile directory is invalid')) {
return {
...base,
state: 'path_missing',
title: 'Claude Browser Attach path is missing.',
detail: message,
nextStep: `Create or choose a Chrome user-data directory, then launch Chrome with attach mode enabled. Example: ${launchCommands[platformKey()]}`,
};
}
if (message.includes('Chrome reuse metadata')) {
return {
...base,
state: 'browser_not_running',
title: 'Claude Browser Attach could not find a running browser session.',
detail: message,
nextStep: `Start Chrome with remote debugging and the configured user-data dir. Example: ${launchCommands[platformKey()]}`,
};
}
return {
...base,
state: 'endpoint_unreachable',
title: 'Claude Browser Attach could not reach the DevTools endpoint.',
detail: message,
nextStep: `Restart the attach browser session or confirm the configured port. Example: ${launchCommands[platformKey()]}`,
};
}
}
function buildCodexBrowserStatus(browserConfig = getBrowserConfig()): CodexBrowserStatus {
if (!browserConfig.codex.enabled) {
return {
enabled: false,
state: 'disabled',
title: 'Codex Browser Tools are disabled.',
detail: 'CCS will not inject Playwright MCP browser tooling into Codex-target launches.',
nextStep:
'Enable Codex Browser Tools in Settings > Browser to restore the managed Codex browser path.',
serverName: 'ccs_browser',
supportsConfigOverrides: false,
binaryPath: null,
};
}
const binaryInfo = getCodexBinaryInfo({ includeVersion: true, includeFeatures: true });
const supportsConfigOverrides = Boolean(binaryInfo?.features?.includes('config-overrides'));
if (!binaryInfo || !supportsConfigOverrides) {
return {
enabled: true,
state: 'unsupported_build',
title: 'Codex Browser Tools need a Codex build with --config override support.',
detail: binaryInfo
? `Detected Codex at ${binaryInfo.path}, but it does not advertise --config overrides.`
: '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',
supportsConfigOverrides,
binaryPath: binaryInfo?.path ?? null,
version: binaryInfo?.version,
};
}
return {
enabled: true,
state: 'enabled',
title: 'Codex Browser Tools are enabled.',
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',
supportsConfigOverrides,
binaryPath: binaryInfo.path,
version: binaryInfo.version,
};
}
function buildLaunchCommands(userDataDir: string, devtoolsPort: number): BrowserLaunchCommands {
const quotedPath = JSON.stringify(userDataDir);
return {
darwin: `open -na "Google Chrome" --args --remote-debugging-port=${devtoolsPort} --user-data-dir=${quotedPath}`,
linux: `google-chrome --remote-debugging-port=${devtoolsPort} --user-data-dir=${quotedPath}`,
win32: `chrome.exe --remote-debugging-port=${devtoolsPort} --user-data-dir=${quotedPath}`,
};
}
function platformKey(): keyof BrowserLaunchCommands {
if (process.platform === 'darwin') return 'darwin';
if (process.platform === 'win32') return 'win32';
return 'linux';
}
+13
View File
@@ -17,9 +17,22 @@ export {
export { appendBrowserToolArgs } from './claude-tool-args';
export {
getRecommendedBrowserUserDataDir,
getBrowserAttachOverride,
getEffectiveClaudeBrowserAttachConfig,
} from './browser-settings';
export {
resolveBrowserRuntimeEnv,
resolveDefaultChromeUserDataDir,
resolveConfiguredBrowserProfileDir,
} from './chrome-reuse';
export type { BrowserReuseOptions, BrowserRuntimeEnv } from './chrome-reuse';
export { getBrowserStatus } from './browser-status';
export type {
BrowserStatusPayload,
ClaudeBrowserStatus,
CodexBrowserStatus,
} from './browser-status';
+136
View File
@@ -0,0 +1,136 @@
import { Router, type Request, type Response } from 'express';
import { getBrowserConfig, mutateUnifiedConfig } from '../../config/unified-config-loader';
import { getBrowserStatus } from '../../utils/browser';
import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middleware';
const router = Router();
const BROWSER_LOCAL_ACCESS_ERROR =
'Browser endpoints require localhost access when dashboard auth is disabled.';
interface BrowserRouteBody {
claude?: {
enabled?: boolean;
userDataDir?: string;
devtoolsPort?: number;
};
codex?: {
enabled?: boolean;
};
}
function isValidDevtoolsPort(value: number): boolean {
return Number.isInteger(value) && value >= 1 && value <= 65535;
}
router.use((req: Request, res: Response, next) => {
if (requireLocalAccessWhenAuthDisabled(req, res, BROWSER_LOCAL_ACCESS_ERROR)) {
next();
}
});
router.get('/', async (_req: Request, res: Response): Promise<void> => {
try {
const config = getBrowserConfig();
const status = await getBrowserStatus();
res.json({
config: toBrowserRouteConfig(config),
status,
});
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
router.get('/status', async (_req: Request, res: Response): Promise<void> => {
try {
res.json(await getBrowserStatus());
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
router.put('/', async (req: Request, res: Response): Promise<void> => {
if (
req.body === null ||
req.body === undefined ||
typeof req.body !== 'object' ||
Array.isArray(req.body)
) {
res.status(400).json({ error: 'Invalid request body. Must be an object.' });
return;
}
const { claude, codex } = req.body as BrowserRouteBody;
if (claude && (typeof claude !== 'object' || Array.isArray(claude))) {
res.status(400).json({ error: 'Invalid value for claude. Must be an object.' });
return;
}
if (codex && (typeof codex !== 'object' || Array.isArray(codex))) {
res.status(400).json({ error: 'Invalid value for codex. Must be an object.' });
return;
}
if (claude?.enabled !== undefined && typeof claude.enabled !== 'boolean') {
res.status(400).json({ error: 'Invalid value for claude.enabled. Must be a boolean.' });
return;
}
if (claude?.userDataDir !== undefined && typeof claude.userDataDir !== 'string') {
res.status(400).json({ error: 'Invalid value for claude.userDataDir. Must be a string.' });
return;
}
if (
claude?.devtoolsPort !== undefined &&
(typeof claude.devtoolsPort !== 'number' || !isValidDevtoolsPort(claude.devtoolsPort))
) {
res.status(400).json({
error: 'Invalid value for claude.devtoolsPort. Must be an integer between 1 and 65535.',
});
return;
}
if (codex?.enabled !== undefined && typeof codex.enabled !== 'boolean') {
res.status(400).json({ error: 'Invalid value for codex.enabled. Must be a boolean.' });
return;
}
try {
const current = getBrowserConfig();
mutateUnifiedConfig((config) => {
config.browser = {
claude: {
enabled: claude?.enabled ?? current.claude.enabled,
user_data_dir: claude?.userDataDir?.trim() || current.claude.user_data_dir,
devtools_port: claude?.devtoolsPort ?? current.claude.devtools_port,
},
codex: {
enabled: codex?.enabled ?? current.codex.enabled,
},
};
});
const config = getBrowserConfig();
const status = await getBrowserStatus();
res.json({
success: true,
browser: {
config: toBrowserRouteConfig(config),
status,
},
});
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
function toBrowserRouteConfig(config: ReturnType<typeof getBrowserConfig>) {
return {
claude: {
enabled: config.claude.enabled,
userDataDir: config.claude.user_data_dir,
devtoolsPort: config.claude.devtools_port,
},
codex: {
enabled: config.codex.enabled,
},
};
}
export default router;
+2
View File
@@ -19,6 +19,7 @@ import settingsRoutes from './settings-routes';
import channelsRoutes from './channels-routes';
import websearchRoutes from './websearch-routes';
import imageAnalysisRoutes from './image-analysis-routes';
import browserRoutes from './browser-routes';
import cliproxyAuthRoutes from './cliproxy-auth-routes';
import cliproxyStatsRoutes from './cliproxy-stats-routes';
import cliproxyRoutingRoutes from './cliproxy-routing-routes';
@@ -97,6 +98,7 @@ apiRoutes.use('/cliproxy/openai-compat', providerRoutes);
// ==================== WebSearch ====================
apiRoutes.use('/websearch', websearchRoutes);
apiRoutes.use('/browser', browserRoutes);
apiRoutes.use('/image-analysis', imageAnalysisRoutes);
// ==================== Copilot ====================
@@ -114,6 +114,7 @@ const COMPATIBLE_CLI_DOCS_REGISTRY: Record<string, CompatibleCliDocsRegistryEntr
'CLI --profile selects a named [profiles.<name>] overlay on top of base config',
'CCS-backed Codex launches may apply transient -c overrides and CCS_CODEX_API_KEY',
'Official docs treat model_providers, mcp_servers, features, and project trust as schema-backed config surfaces',
'CCS-managed browser tooling for Codex should be configured from Settings > Browser, not by editing the ccs_browser MCP entry directly',
],
links: [
{
+185
View File
@@ -0,0 +1,185 @@
import { afterEach, describe, expect, test, spyOn } from 'bun:test';
import * as browserUtils from '../../../src/utils/browser';
import { handleBrowserCommand } from '../../../src/commands/browser-command';
function stripAnsi(input: string): string {
return input.replace(/\u001b\[[0-9;]*m/g, '');
}
async function renderLines(args: string[]): Promise<string> {
const lines: string[] = [];
await handleBrowserCommand(args, (line) => lines.push(line));
return stripAnsi(lines.join('\n'));
}
function currentPlatform(): 'darwin' | 'linux' | 'win32' {
if (process.platform === 'darwin') return 'darwin';
if (process.platform === 'win32') return 'win32';
return 'linux';
}
describe('browser command', () => {
afterEach(() => {
process.exitCode = 0;
});
test('status renders both browser lanes from the shared status payload', async () => {
const statusSpy = spyOn(browserUtils, 'getBrowserStatus').mockResolvedValue({
claude: {
enabled: true,
source: 'config',
overrideActive: false,
state: 'ready',
title: 'Claude Browser Attach is ready.',
detail: 'CCS can reach the configured Chrome DevTools endpoint.',
nextStep: 'Launch Claude.',
effectiveUserDataDir: '/tmp/browser-profile',
recommendedUserDataDir: '/tmp/browser-profile',
devtoolsPort: 9222,
managedMcpServerName: 'ccs-browser',
managedMcpServerPath: '/tmp/ccs-browser-server.cjs',
launchCommands: {
darwin: 'open -na "Google Chrome" --args',
linux: 'google-chrome --remote-debugging-port=9222',
win32: 'chrome.exe --remote-debugging-port=9222',
},
runtimeEnv: {
CCS_BROWSER_USER_DATA_DIR: '/tmp/browser-profile',
CCS_BROWSER_DEVTOOLS_HOST: '127.0.0.1',
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',
},
},
codex: {
enabled: true,
state: 'enabled',
title: 'Codex Browser Tools are enabled.',
detail: 'CCS can inject the managed Playwright MCP overrides.',
nextStep: 'Use a Codex-target launch.',
serverName: 'ccs_browser',
supportsConfigOverrides: true,
binaryPath: '/usr/local/bin/codex',
version: 'codex-cli 0.120.0',
},
});
try {
const rendered = await renderLines(['status']);
expect(rendered.includes('ccs browser status')).toBe(true);
expect(rendered.includes('Claude Browser Attach reuses a local Chrome session')).toBe(true);
expect(rendered.includes('Codex Browser Tools inject managed Playwright MCP overrides')).toBe(
true
);
expect(rendered.includes('Managed MCP: ccs-browser')).toBe(true);
expect(rendered.includes('Managed server: ccs_browser')).toBe(true);
expect(rendered.includes('DevTools endpoint: http://127.0.0.1:9222')).toBe(true);
} finally {
statusSpy.mockRestore();
}
});
test('doctor prints env override context and launch guidance when Claude attach is not ready', async () => {
const launchCommands = {
darwin: 'open -na "Google Chrome" --args --remote-debugging-port=9444',
linux:
'google-chrome --remote-debugging-port=9444 --user-data-dir="/tmp/browser-profile"',
win32: 'chrome.exe --remote-debugging-port=9444 --user-data-dir="/tmp/browser-profile"',
};
const statusSpy = spyOn(browserUtils, 'getBrowserStatus').mockResolvedValue({
claude: {
enabled: true,
source: 'CCS_BROWSER_PROFILE_DIR',
overrideActive: true,
state: 'browser_not_running',
title: 'Claude Browser Attach could not find a running browser session.',
detail: 'Chrome reuse metadata not found: /tmp/browser-profile/DevToolsActivePort',
nextStep: 'Start Chrome with remote debugging.',
effectiveUserDataDir: '/tmp/browser-profile',
recommendedUserDataDir: '/tmp/browser-profile',
devtoolsPort: 9444,
managedMcpServerName: 'ccs-browser',
managedMcpServerPath: '/tmp/ccs-browser-server.cjs',
launchCommands,
},
codex: {
enabled: true,
state: 'unsupported_build',
title: 'Codex Browser Tools need a Codex build with --config override support.',
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',
supportsConfigOverrides: false,
binaryPath: '/usr/local/bin/codex',
version: 'codex-cli 0.70.0',
},
});
try {
const rendered = await renderLines(['doctor']);
expect(rendered.includes('Result: action required')).toBe(true);
expect(rendered.includes('Source: CCS_BROWSER_PROFILE_DIR (env override active)')).toBe(
true
);
expect(
rendered.includes(
`Launch command (${currentPlatform()}): ${launchCommands[currentPlatform()]}`
)
).toBe(true);
expect(rendered.includes('Detected Codex at /usr/local/bin/codex')).toBe(true);
expect(process.exitCode).toBe(1);
} finally {
statusSpy.mockRestore();
}
});
test('doctor stays ready on Claude-only machines when Codex is not installed', async () => {
const statusSpy = spyOn(browserUtils, 'getBrowserStatus').mockResolvedValue({
claude: {
enabled: false,
source: 'config',
overrideActive: false,
state: 'disabled',
title: 'Claude Browser Attach is disabled.',
detail: 'CCS will not provision the managed browser MCP runtime for Claude launches until this lane is enabled.',
nextStep:
'Enable Claude Browser Attach in Settings > Browser or in ~/.ccs/config.yaml, then rerun `ccs browser doctor`.',
effectiveUserDataDir: '/tmp/browser-profile',
recommendedUserDataDir: '/tmp/browser-profile',
devtoolsPort: 9222,
managedMcpServerName: 'ccs-browser',
managedMcpServerPath: '/tmp/ccs-browser-server.cjs',
launchCommands: {
darwin: 'open -na "Google Chrome" --args --remote-debugging-port=9222',
linux:
'google-chrome --remote-debugging-port=9222 --user-data-dir="/tmp/browser-profile"',
win32:
'chrome.exe --remote-debugging-port=9222 --user-data-dir="/tmp/browser-profile"',
},
},
codex: {
enabled: true,
state: 'unsupported_build',
title: 'Codex Browser Tools need a Codex build with --config override support.',
detail: 'No Codex binary was detected, so CCS cannot confirm managed browser override support.',
nextStep: 'Install or upgrade Codex, then rerun browser status/doctor.',
serverName: 'ccs_browser',
supportsConfigOverrides: false,
binaryPath: null,
},
});
try {
const rendered = await renderLines(['doctor']);
expect(rendered.includes('Result: partial')).toBe(true);
expect(rendered.includes('run `ccs browser enable`')).toBe(false);
expect(process.exitCode).toBe(0);
} finally {
statusSpy.mockRestore();
}
});
});
@@ -25,6 +25,7 @@ describe('help command parity', () => {
expect(visibleLines.length).toBeLessThanOrEqual(90);
expect(rendered.includes('ccs help <topic>')).toBe(true);
expect(rendered.includes('ccs help browser')).toBe(true);
expect(rendered.includes('ccs help completion')).toBe(true);
});
@@ -69,6 +70,18 @@ describe('help command parity', () => {
expect(rendered.includes('GitHub OAuth is dashboard-only')).toBe(true);
});
test('browser topic explains Claude attach versus Codex browser tools', async () => {
const rendered = await renderLines((writeLine) => handleHelpRoute(['browser'], writeLine));
expect(rendered.includes('CCS Browser Help')).toBe(true);
expect(rendered.includes('Claude Browser Attach reuses a local Chrome session')).toBe(true);
expect(rendered.includes('Codex Browser Tools inject managed Playwright MCP overrides')).toBe(
true
);
expect(rendered.includes('ccs browser status')).toBe(true);
expect(rendered.includes('ccs browser doctor')).toBe(true);
});
test('completion topic documents install and verification paths', async () => {
const rendered = await renderLines((writeLine) => handleHelpRoute(['completion'], writeLine));
@@ -3,6 +3,7 @@ import { spawnSync } from 'child_process';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { mutateUnifiedConfig } from '../../../src/config/unified-config-loader';
interface RunResult {
status: number | null;
@@ -193,6 +194,47 @@ process.exit(0);
]);
});
it('skips Codex browser MCP overrides when browser tooling is disabled in config', () => {
if (process.platform === 'win32') return;
const originalCcsHome = process.env.CCS_HOME;
process.env.CCS_HOME = tmpHome;
try {
mutateUnifiedConfig((config) => {
config.browser = {
claude: {
enabled: false,
user_data_dir: '',
devtools_port: 9222,
},
codex: {
enabled: false,
},
};
});
const result = runCcs(['default', '--target', 'codex', 'fix failing tests'], {
...process.env,
CI: '1',
NO_COLOR: '1',
CCS_HOME: tmpHome,
CCS_CODEX_PATH: fakeCodexPath,
CCS_TEST_CODEX_ARGS_OUT: codexArgsLogPath,
});
expect(result.status).toBe(0);
const calls = readLoggedCodexCalls(codexArgsLogPath);
expect(calls).toEqual([['fix failing tests']]);
} finally {
if (originalCcsHome !== undefined) {
process.env.CCS_HOME = originalCcsHome;
} else {
delete process.env.CCS_HOME;
}
}
});
it('keeps browser MCP runtime overrides when CCS_THINKING is ignored for native Codex default mode', () => {
if (process.platform === 'win32') return;
@@ -4,6 +4,7 @@ import { spawn, spawnSync, type ChildProcess } from 'child_process';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { mutateUnifiedConfig } from '../../../src/config/unified-config-loader';
const BROWSER_PROMPT_SNIPPET = 'prefer the CCS MCP Browser tool';
@@ -245,4 +246,83 @@ server.listen(0, '127.0.0.1', () => {
expect(launchedEnv).toContain(`httpUrl=http://127.0.0.1:${port}`);
expect(launchedEnv).toContain('wsUrl=ws://127.0.0.1/devtools/browser/default-target');
});
it('uses config-backed browser attach settings when env overrides are absent', async () => {
if (process.platform === 'win32') return;
const originalCcsHome = process.env.CCS_HOME;
process.env.CCS_HOME = tmpHome;
try {
const mockServerScriptPath = path.join(tmpHome, 'mock-devtools-server.js');
const mockServerPortPath = path.join(tmpHome, 'mock-devtools-port.txt');
fs.writeFileSync(
mockServerScriptPath,
`const { createServer } = require('http');
const fs = require('fs');
const server = createServer((req, res) => {
if (req.url === '/json/version') {
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ Browser: 'Chrome/136.0.0.0', webSocketDebuggerUrl: 'ws://127.0.0.1/devtools/browser/config-target' }));
return;
}
res.writeHead(404);
res.end('not found');
});
server.listen(0, '127.0.0.1', () => {
const address = server.address();
fs.writeFileSync(${JSON.stringify(mockServerPortPath)}, String(address.port), 'utf8');
});
`,
'utf8'
);
devtoolsServer = spawn(process.execPath, [mockServerScriptPath], {
stdio: 'ignore',
env: baseEnv,
});
const port = await waitForMockDevtoolsPort(mockServerPortPath);
await waitForDevtoolsVersionEndpoint(port);
fs.mkdirSync(browserProfileDir, { recursive: true });
fs.writeFileSync(
path.join(browserProfileDir, 'DevToolsActivePort'),
`${port}\n/devtools/browser/config-target`,
'utf8'
);
mutateUnifiedConfig((config) => {
config.browser = {
claude: {
enabled: true,
user_data_dir: browserProfileDir,
devtools_port: Number.parseInt(port, 10),
},
codex: {
enabled: true,
},
};
});
const result = runCcs(['default', 'smoke'], {
...baseEnv,
});
expect(result.status).toBe(0);
const launchedArgs = fs.readFileSync(claudeArgsLogPath, 'utf8');
expect(launchedArgs).toContain(BROWSER_PROMPT_SNIPPET);
const launchedEnv = fs.readFileSync(claudeEnvLogPath, 'utf8');
expect(launchedEnv).toContain(`userDataDir=${browserProfileDir}`);
expect(launchedEnv).toContain(`port=${port}`);
expect(launchedEnv).toContain('wsUrl=ws://127.0.0.1/devtools/browser/config-target');
} finally {
if (originalCcsHome !== undefined) {
process.env.CCS_HOME = originalCcsHome;
} else {
delete process.env.CCS_HOME;
}
}
});
});
@@ -3,6 +3,7 @@ import { spawn, spawnSync, type ChildProcess } from 'child_process';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { mutateUnifiedConfig } from '../../../src/config/unified-config-loader';
const BROWSER_PROMPT_SNIPPET = 'prefer the CCS MCP Browser tool';
@@ -196,4 +197,89 @@ server.listen(0, '127.0.0.1', () => {
expect(launchedEnv).toContain(`httpUrl=http://127.0.0.1:${port}`);
expect(launchedEnv).toContain('wsUrl=ws://127.0.0.1/devtools/browser/browser-target');
});
it('uses config-backed browser attach settings for settings-profile launches', async () => {
if (process.platform === 'win32') return;
const originalCcsHome = process.env.CCS_HOME;
process.env.CCS_HOME = tmpHome;
try {
const mockServerScriptPath = path.join(tmpHome, 'mock-devtools-server.js');
const mockServerPortPath = path.join(tmpHome, 'mock-devtools-port.txt');
fs.writeFileSync(
mockServerScriptPath,
`const { createServer } = require('http');
const fs = require('fs');
const server = createServer((req, res) => {
if (req.url === '/json/version') {
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ Browser: 'Chrome/136.0.0.0', webSocketDebuggerUrl: 'ws://127.0.0.1/devtools/browser/config-settings-target' }));
return;
}
res.writeHead(404);
res.end('not found');
});
server.listen(0, '127.0.0.1', () => {
const address = server.address();
fs.writeFileSync(${JSON.stringify(mockServerPortPath)}, String(address.port), 'utf8');
});
`,
'utf8'
);
devtoolsServer = spawn(process.execPath, [mockServerScriptPath], {
stdio: 'ignore',
env: baseEnv,
});
const startDeadline = Date.now() + 5000;
while (!fs.existsSync(mockServerPortPath)) {
if (Date.now() > startDeadline) {
throw new Error('Timed out waiting for mock DevTools server to start');
}
await new Promise((resolve) => setTimeout(resolve, 25));
}
const port = fs.readFileSync(mockServerPortPath, 'utf8').trim();
fs.mkdirSync(browserProfileDir, { recursive: true });
fs.writeFileSync(
path.join(browserProfileDir, 'DevToolsActivePort'),
`${port}\n/devtools/browser/config-settings-target`,
'utf8'
);
mutateUnifiedConfig((config) => {
config.browser = {
claude: {
enabled: true,
user_data_dir: browserProfileDir,
devtools_port: Number.parseInt(port, 10),
},
codex: {
enabled: true,
},
};
});
const result = runCcs(['glm', 'smoke'], {
...baseEnv,
});
expect(result.status).toBe(0);
const launchedArgs = fs.readFileSync(claudeArgsLogPath, 'utf8');
expect(launchedArgs).toContain(BROWSER_PROMPT_SNIPPET);
const launchedEnv = fs.readFileSync(claudeEnvLogPath, 'utf8');
expect(launchedEnv).toContain(`userDataDir=${browserProfileDir}`);
expect(launchedEnv).toContain(`port=${port}`);
expect(launchedEnv).toContain('wsUrl=ws://127.0.0.1/devtools/browser/config-settings-target');
} finally {
if (originalCcsHome !== undefined) {
process.env.CCS_HOME = originalCcsHome;
} else {
delete process.env.CCS_HOME;
}
}
});
});
@@ -0,0 +1,202 @@
import { afterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { mutateUnifiedConfig } from '../../../../src/config/unified-config-loader';
import * as chromeReuse from '../../../../src/utils/browser/chrome-reuse';
import { getBrowserStatus } from '../../../../src/utils/browser/browser-status';
import * as codexDetector from '../../../../src/targets/codex-detector';
describe('browser status', () => {
let tempHome = '';
let originalCcsHome: string | undefined;
let originalBrowserUserDataDir: string | undefined;
let originalBrowserProfileDir: string | undefined;
let originalBrowserDevtoolsPort: string | undefined;
beforeEach(() => {
tempHome = mkdtempSync(join(tmpdir(), 'ccs-browser-status-'));
originalCcsHome = process.env.CCS_HOME;
originalBrowserUserDataDir = process.env.CCS_BROWSER_USER_DATA_DIR;
originalBrowserProfileDir = process.env.CCS_BROWSER_PROFILE_DIR;
originalBrowserDevtoolsPort = process.env.CCS_BROWSER_DEVTOOLS_PORT;
process.env.CCS_HOME = tempHome;
delete process.env.CCS_BROWSER_USER_DATA_DIR;
delete process.env.CCS_BROWSER_PROFILE_DIR;
delete process.env.CCS_BROWSER_DEVTOOLS_PORT;
});
afterEach(() => {
if (originalCcsHome !== undefined) {
process.env.CCS_HOME = originalCcsHome;
} else {
delete process.env.CCS_HOME;
}
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;
}
rmSync(tempHome, { recursive: true, force: true });
});
it('returns a disabled Claude lane with the recommended managed user-data dir by default', async () => {
const codexSpy = spyOn(codexDetector, 'getCodexBinaryInfo').mockReturnValue({
path: '/usr/local/bin/codex',
needsShell: false,
version: 'codex-cli 0.120.0',
features: ['config-overrides'],
});
try {
const status = await getBrowserStatus();
expect(status.claude).toMatchObject({
enabled: false,
state: 'disabled',
source: 'config',
effectiveUserDataDir: join(tempHome, '.ccs', 'browser', 'chrome-user-data'),
devtoolsPort: 9222,
managedMcpServerName: 'ccs-browser',
});
expect(status.claude.launchCommands.linux).toContain('--remote-debugging-port=9222');
expect(status.codex).toMatchObject({
enabled: true,
state: 'enabled',
serverName: 'ccs_browser',
supportsConfigOverrides: true,
});
} finally {
codexSpy.mockRestore();
}
});
it('prefers CCS_BROWSER_USER_DATA_DIR over config when an env override is present', async () => {
mutateUnifiedConfig((config) => {
config.browser = {
claude: {
enabled: true,
user_data_dir: '/config-browser',
devtools_port: 9333,
},
codex: {
enabled: true,
},
};
});
process.env.CCS_BROWSER_USER_DATA_DIR = '/env-browser';
process.env.CCS_BROWSER_DEVTOOLS_PORT = '9444';
const runtimeSpy = spyOn(chromeReuse, 'resolveBrowserRuntimeEnv').mockResolvedValue({
CCS_BROWSER_USER_DATA_DIR: '/env-browser',
CCS_BROWSER_DEVTOOLS_HOST: '127.0.0.1',
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',
});
const codexSpy = spyOn(codexDetector, 'getCodexBinaryInfo').mockReturnValue({
path: '/usr/local/bin/codex',
needsShell: false,
version: 'codex-cli 0.120.0',
features: ['config-overrides'],
});
try {
const status = await getBrowserStatus();
expect(status.claude).toMatchObject({
enabled: true,
state: 'ready',
source: 'CCS_BROWSER_USER_DATA_DIR',
effectiveUserDataDir: '/env-browser',
devtoolsPort: 9444,
});
expect(status.claude.runtimeEnv?.CCS_BROWSER_DEVTOOLS_PORT).toBe('9444');
} finally {
runtimeSpy.mockRestore();
codexSpy.mockRestore();
}
});
it('reports browser_not_running when attach metadata is missing', async () => {
mutateUnifiedConfig((config) => {
config.browser = {
claude: {
enabled: true,
user_data_dir: '/tmp/browser-profile',
devtools_port: 9222,
},
codex: {
enabled: true,
},
};
});
const runtimeSpy = spyOn(chromeReuse, 'resolveBrowserRuntimeEnv').mockRejectedValue(
new Error('Chrome reuse metadata not found: /tmp/browser-profile/DevToolsActivePort')
);
const codexSpy = spyOn(codexDetector, 'getCodexBinaryInfo').mockReturnValue({
path: '/usr/local/bin/codex',
needsShell: false,
version: 'codex-cli 0.120.0',
features: ['config-overrides'],
});
try {
const status = await getBrowserStatus();
expect(status.claude.state).toBe('browser_not_running');
expect(status.claude.detail).toContain('DevToolsActivePort');
expect(status.claude.nextStep).toContain('--remote-debugging-port=9222');
} finally {
runtimeSpy.mockRestore();
codexSpy.mockRestore();
}
});
it('preserves legacy metadata-based port discovery when only CCS_BROWSER_PROFILE_DIR is set', async () => {
process.env.CCS_BROWSER_PROFILE_DIR = '/legacy-browser';
const runtimeSpy = spyOn(chromeReuse, 'resolveBrowserRuntimeEnv').mockResolvedValue({
CCS_BROWSER_USER_DATA_DIR: '/legacy-browser',
CCS_BROWSER_DEVTOOLS_HOST: '127.0.0.1',
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',
});
const codexSpy = spyOn(codexDetector, 'getCodexBinaryInfo').mockReturnValue({
path: '/usr/local/bin/codex',
needsShell: false,
version: 'codex-cli 0.120.0',
features: ['config-overrides'],
});
try {
const status = await getBrowserStatus();
expect(runtimeSpy.mock.calls[0]?.[0]).toEqual({
profileDir: '/legacy-browser',
devtoolsPort: undefined,
});
expect(status.claude.runtimeEnv?.CCS_BROWSER_DEVTOOLS_PORT).toBe('50123');
} finally {
runtimeSpy.mockRestore();
codexSpy.mockRestore();
}
});
});
@@ -0,0 +1,171 @@
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'bun:test';
import express from 'express';
import { mkdtempSync, rmSync } from 'node:fs';
import type { Server } from 'node:http';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import browserRoutes from '../../../src/web-server/routes/browser-routes';
import { loadOrCreateUnifiedConfig } from '../../../src/config/unified-config-loader';
describe('browser routes', () => {
let server: Server;
let baseUrl = '';
let tempHome = '';
let originalCcsHome: string | undefined;
let originalDashboardAuthEnabled: string | undefined;
let forcedRemoteAddress = '127.0.0.1';
beforeAll(async () => {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
Object.defineProperty(req.socket, 'remoteAddress', {
value: forcedRemoteAddress,
configurable: true,
});
next();
});
app.use('/api/browser', browserRoutes);
await new Promise<void>((resolve, reject) => {
server = app.listen(0, '127.0.0.1');
const onError = (error: Error) => reject(error);
server.once('error', onError);
server.once('listening', () => {
server.off('error', onError);
resolve();
});
});
const address = server.address();
if (!address || typeof address === 'string') {
throw new Error('Unable to resolve test server port');
}
baseUrl = `http://127.0.0.1:${address.port}`;
});
afterAll(async () => {
await new Promise<void>((resolve) => server.close(() => resolve()));
});
beforeEach(() => {
tempHome = mkdtempSync(join(tmpdir(), 'ccs-browser-routes-'));
originalCcsHome = process.env.CCS_HOME;
originalDashboardAuthEnabled = process.env.CCS_DASHBOARD_AUTH_ENABLED;
process.env.CCS_HOME = tempHome;
process.env.CCS_DASHBOARD_AUTH_ENABLED = 'false';
forcedRemoteAddress = '127.0.0.1';
});
afterEach(() => {
if (originalCcsHome !== undefined) {
process.env.CCS_HOME = originalCcsHome;
} else {
delete process.env.CCS_HOME;
}
if (originalDashboardAuthEnabled !== undefined) {
process.env.CCS_DASHBOARD_AUTH_ENABLED = originalDashboardAuthEnabled;
} else {
delete process.env.CCS_DASHBOARD_AUTH_ENABLED;
}
rmSync(tempHome, { recursive: true, force: true });
});
it('blocks remote access when dashboard auth is disabled', async () => {
forcedRemoteAddress = '10.10.0.24';
const response = await fetch(`${baseUrl}/api/browser`);
expect(response.status).toBe(403);
expect(await response.json()).toEqual({
error: 'Browser endpoints require localhost access when dashboard auth is disabled.',
});
});
it('returns the default browser config and status payload', async () => {
const response = await fetch(`${baseUrl}/api/browser`);
expect(response.status).toBe(200);
const payload = await response.json();
expect(payload.config).toMatchObject({
claude: {
enabled: false,
userDataDir: join(tempHome, '.ccs', 'browser', 'chrome-user-data'),
devtoolsPort: 9222,
},
codex: {
enabled: true,
},
});
expect(payload.status.claude).toMatchObject({
state: 'disabled',
managedMcpServerName: 'ccs-browser',
});
expect(payload.status.codex).toMatchObject({
enabled: true,
serverName: 'ccs_browser',
});
});
it('updates the saved browser config through the dashboard route', async () => {
const response = await fetch(`${baseUrl}/api/browser`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
claude: {
enabled: true,
userDataDir: '/tmp/ccs-browser',
devtoolsPort: 9333,
},
codex: {
enabled: false,
},
}),
});
expect(response.status).toBe(200);
const payload = await response.json();
expect(payload.browser.config).toMatchObject({
claude: {
enabled: true,
userDataDir: '/tmp/ccs-browser',
devtoolsPort: 9333,
},
codex: {
enabled: false,
},
});
const config = loadOrCreateUnifiedConfig();
expect(config.browser).toMatchObject({
claude: {
enabled: true,
user_data_dir: '/tmp/ccs-browser',
devtools_port: 9333,
},
codex: {
enabled: false,
},
});
});
it('rejects invalid DevTools ports at the route boundary', async () => {
const response = await fetch(`${baseUrl}/api/browser`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
claude: {
devtoolsPort: 0,
},
}),
});
expect(response.status).toBe(400);
expect(await response.json()).toEqual({
error: 'Invalid value for claude.devtoolsPort. Must be an integer between 1 and 65535.',
});
});
});
@@ -139,6 +139,11 @@ export function CodexDocsTab({ diagnostics }: CodexDocsTabProps) {
Export <code>CLIPROXY_API_KEY</code> before launching native Codex.
</li>
</ol>
<p>
CCS-managed browser tooling belongs to <code>Settings &gt; Browser</code>. Do not edit
the <code>ccs_browser</code> entry from the generic MCP card unless you are
intentionally overriding the managed path in raw TOML.
</p>
</CardContent>
</Card>
@@ -36,6 +36,8 @@ const EMPTY_MCP_SERVER_DRAFT: CodexMcpServerEntry = {
toolTimeoutSec: null,
enabledTools: [],
disabledTools: [],
isCcsManaged: false,
managementSurface: null,
};
function toCsv(value: string[]) {
@@ -70,9 +72,16 @@ function McpServerEditor({
}: McpServerEditorProps) {
const { t } = useTranslation();
const [draft, setDraft] = useState<CodexMcpServerEntry>(initialDraft);
const reservedManagedBrowserDraft = isNew && draft.name.trim() === 'ccs_browser';
return (
<>
{reservedManagedBrowserDraft ? (
<div className="rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-sm text-amber-900 dark:text-amber-200">
<strong>ccs_browser</strong> is reserved for the CCS-managed browser tooling path.
Configure it from <code>Settings &gt; Browser</code> instead of creating it here.
</div>
) : null}
<div className="grid gap-3 sm:grid-cols-2">
<Input
value={draft.name}
@@ -216,7 +225,9 @@ function McpServerEditor({
disabledTools: draft.disabledTools,
})
}
disabled={disabled || saving || draft.name.trim().length === 0}
disabled={
disabled || saving || draft.name.trim().length === 0 || reservedManagedBrowserDraft
}
>
{saving ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
{/* TODO i18n: missing key codex.saveMcpServer */}
@@ -244,6 +255,8 @@ export function CodexMcpServersCard({
);
const draftSeed = selectedEntry ?? EMPTY_MCP_SERVER_DRAFT;
const draftKey = JSON.stringify(draftSeed);
const selectedEntryIsManagedBrowser =
selectedEntry?.isCcsManaged && selectedEntry.managementSurface === 'browser-settings';
return (
<CodexConfigCardShell
@@ -255,6 +268,12 @@ export function CodexMcpServersCard({
description="Manage the safe MCP transport fields. Keep auth headers and bearer tokens in raw TOML."
disabledReason={disabledReason}
>
{selectedEntryIsManagedBrowser ? (
<div className="rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-sm text-amber-900 dark:text-amber-200">
<strong>{selectedEntry?.name}</strong> is CCS-managed. Configure browser tooling from{' '}
<code>Settings &gt; Browser</code>; the generic MCP editor is read-only for this entry.
</div>
) : null}
<Select value={selectedName} onValueChange={setSelectedName} disabled={disabled}>
<SelectTrigger>
{/* TODO i18n: missing key codex.selectMcpServer */}
@@ -264,7 +283,7 @@ export function CodexMcpServersCard({
<SelectItem value="new">{t('codex.createNewMcpServer')}</SelectItem>
{entries.map((entry) => (
<SelectItem key={entry.name} value={entry.name}>
{entry.name}
{entry.isCcsManaged ? `${entry.name} (CCS managed)` : entry.name}
</SelectItem>
))}
</SelectContent>
@@ -273,9 +292,9 @@ export function CodexMcpServersCard({
key={draftKey}
initialDraft={draftSeed}
isNew={selectedName === 'new'}
disabled={disabled}
disabled={disabled || Boolean(selectedEntryIsManagedBrowser)}
saving={saving}
canDelete={selectedEntry !== null}
canDelete={selectedEntry !== null && !selectedEntryIsManagedBrowser}
onDelete={async () => {
if (!selectedEntry) return;
await onDelete(selectedEntry.name);
+71
View File
@@ -10,6 +10,7 @@ import type {
UpsertAiProviderEntryInput,
} from '../../../src/cliproxy/ai-providers';
import type { ProviderEntitlementEvidence } from '../../../src/cliproxy/provider-entitlement-types';
import type { BrowserRuntimeEnv } from '../../../src/utils/browser/chrome-reuse';
export const API_BASE_URL = '/api';
export const API_CONFLICT_ERROR_CODE = 'CONFLICT';
@@ -233,6 +234,67 @@ export interface UpdateImageAnalysisSettingsPayload {
profileBackends?: Record<string, string>;
}
export interface BrowserSettingsConfig {
claude: {
enabled: boolean;
userDataDir: string;
devtoolsPort: number;
};
codex: {
enabled: boolean;
};
}
export interface BrowserLaunchCommands {
darwin: string;
linux: string;
win32: string;
}
export interface ClaudeBrowserStatus {
enabled: boolean;
source: 'config' | 'CCS_BROWSER_USER_DATA_DIR' | 'CCS_BROWSER_PROFILE_DIR';
overrideActive: boolean;
state: 'disabled' | 'path_missing' | 'browser_not_running' | 'endpoint_unreachable' | 'ready';
title: string;
detail: string;
nextStep: string;
effectiveUserDataDir: string;
recommendedUserDataDir: string;
devtoolsPort: number;
managedMcpServerName: string;
managedMcpServerPath: string;
launchCommands: BrowserLaunchCommands;
runtimeEnv?: BrowserRuntimeEnv;
}
export interface CodexBrowserStatus {
enabled: boolean;
state: 'disabled' | 'enabled' | 'unsupported_build';
title: string;
detail: string;
nextStep: string;
serverName: string;
supportsConfigOverrides: boolean;
binaryPath: string | null;
version?: string;
}
export interface BrowserStatusPayload {
claude: ClaudeBrowserStatus;
codex: CodexBrowserStatus;
}
export interface BrowserDashboardPayload {
config: BrowserSettingsConfig;
status: BrowserStatusPayload;
}
export interface UpdateBrowserSettingsPayload {
claude?: Partial<BrowserSettingsConfig['claude']>;
codex?: Partial<BrowserSettingsConfig['codex']>;
}
export interface Profile {
name: string;
settingsPath: string;
@@ -1090,6 +1152,15 @@ export const api = {
body: JSON.stringify(data),
}),
},
browser: {
get: () => request<BrowserDashboardPayload>('/browser'),
getStatus: () => request<BrowserStatusPayload>('/browser/status'),
update: (data: UpdateBrowserSettingsPayload) =>
request<{ success: boolean; browser: BrowserDashboardPayload }>('/browser', {
method: 'PUT',
body: JSON.stringify(data),
}),
},
logs: {
getConfig: () => request<{ logging: LogsConfig }>('/logs/config'),
updateConfig: (data: UpdateLogsConfigPayload) =>
+4
View File
@@ -45,6 +45,8 @@ export interface CodexMcpServerEntry {
toolTimeoutSec: number | null;
enabledTools: string[];
disabledTools: string[];
isCcsManaged: boolean;
managementSurface: 'browser-settings' | null;
}
export interface CodexFeatureCatalogEntry {
@@ -239,6 +241,8 @@ export function readCodexMcpServers(config: Record<string, unknown> | null): Cod
toolTimeoutSec: asNumber(server.tool_timeout_sec),
enabledTools: asStringArray(server.enabled_tools),
disabledTools: asStringArray(server.disabled_tools),
isCcsManaged: name === 'ccs_browser',
managementSurface: name === 'ccs_browser' ? 'browser-settings' : null,
};
})
.filter((entry): entry is CodexMcpServerEntry => entry !== null)
+220
View File
@@ -873,6 +873,7 @@ const resources = {
configFileNotFound: 'Config file not found',
},
settingsTabs: {
browser: 'Browser',
web: 'Web',
image: 'Image',
channels: 'Channels',
@@ -2424,6 +2425,61 @@ const resources = {
description: 'Configure image analysis settings.',
loading: 'Loading image settings...',
},
browserSection: {
title: 'Browser',
description: 'Primary setup surface for managed browser automation in CCS.',
primaryTitle: 'Settings > Browser is the primary browser setup surface.',
primaryDescription:
'Configure Claude Browser Attach and Codex Browser Tools here, then use the guidance below to launch or verify each lane.',
readiness: 'Readiness',
nextStep: 'Next step',
actions: {
saveClaude: 'Save Claude settings',
saveCodex: 'Save Codex settings',
testConnection: 'Test connection',
copyLaunchCommand: 'Copy launch command',
},
messages: {
statusRefreshed: 'Browser status refreshed',
launchCommandCopied: 'Launch command copied',
},
claude: {
title: 'Claude Browser Attach',
description:
'Attach Claude-target launches to a managed Chrome session with remote debugging enabled.',
enabledLabel: 'Enable Claude Browser Attach',
enabledDescription:
'When enabled, CCS prepares the managed browser MCP runtime for Claude-target launches.',
userDataDir: 'Chrome user-data directory',
userDataDirHint:
'Use a dedicated Chrome profile directory so attach-mode state stays isolated from your daily browser.',
devtoolsPort: 'DevTools port',
devtoolsPortHint: 'Use the same port when you launch Chrome in attach mode.',
devtoolsPortInvalid: 'Enter a valid port between 1 and 65535.',
effectivePath: 'Effective attach path',
recommendedPath: 'Recommended path',
managedRuntime: 'Managed browser runtime',
overrideMessage:
'An environment override is currently active from {{source}}. This dashboard remains the source of truth once that override is removed.',
launchGuidance: 'Launch guidance',
launchGuidanceHint:
'Start Chrome with remote debugging enabled, then rerun Test connection if needed.',
},
codex: {
title: 'Codex Browser Tools',
description:
'Controls the managed Playwright MCP override path for Codex-target launches.',
enabledLabel: 'Enable Codex Browser Tools',
enabledDescription:
'Keep this on to make the Browser page the first-class setup surface for Codex browser tooling.',
serverName: 'Managed server name',
overrideSupport: 'Config override support',
overrideSupported: 'Supported',
overrideUnsupported: 'Not supported',
binary: 'Detected Codex binary',
notDetected: 'Not detected',
},
},
},
codexPage: {
title: 'Codex',
@@ -3284,6 +3340,7 @@ const resources = {
configFileNotFound: '未找到配置文件',
},
settingsTabs: {
browser: '浏览器',
web: '网页',
image: '图片',
channels: '频道',
@@ -4781,6 +4838,55 @@ const resources = {
description: '配置图片分析设置。',
loading: '加载图片设置中...',
},
browserSection: {
title: '浏览器',
description: 'CCS 中托管浏览器自动化的主设置入口。',
primaryTitle: 'Settings > Browser 是主要的浏览器设置界面。',
primaryDescription:
'在这里配置 Claude Browser Attach 和 Codex Browser Tools,然后按下方指引启动或验证每条路径。',
readiness: '就绪状态',
nextStep: '下一步',
actions: {
saveClaude: '保存 Claude 设置',
saveCodex: '保存 Codex 设置',
testConnection: '测试连接',
copyLaunchCommand: '复制启动命令',
},
messages: {
statusRefreshed: '浏览器状态已刷新',
launchCommandCopied: '启动命令已复制',
},
claude: {
title: 'Claude Browser Attach',
description: '将 Claude 目标会话附加到启用了远程调试的 Chrome 会话。',
enabledLabel: '启用 Claude Browser Attach',
enabledDescription: '启用后,CCS 会为 Claude 目标会话准备托管浏览器 MCP 运行时。',
userDataDir: 'Chrome 用户数据目录',
userDataDirHint: '建议使用单独的 Chrome 配置目录,避免自动化状态污染日常浏览器。',
devtoolsPort: 'DevTools 端口',
devtoolsPortHint: '必须与 Chrome attach 模式实际启动时使用的端口一致。',
devtoolsPortInvalid: '请输入 1 到 65535 之间的有效端口。',
effectivePath: '当前生效的 attach 路径',
recommendedPath: '推荐路径',
managedRuntime: '托管浏览器运行时',
overrideMessage:
'当前存在来自 {{source}} 的环境变量覆盖。移除该覆盖后,Dashboard 配置将重新成为唯一来源。',
launchGuidance: '启动指引',
launchGuidanceHint: '使用远程调试启动 Chrome,如有需要再重新执行 Test connection。',
},
codex: {
title: 'Codex Browser Tools',
description: '控制 Codex 目标会话中的托管 Playwright MCP 浏览器路径。',
enabledLabel: '启用 Codex Browser Tools',
enabledDescription: '保持开启可让 Browser 页面成为 Codex 浏览器工具的主设置入口。',
serverName: '托管服务名称',
overrideSupport: '配置覆盖支持',
overrideSupported: '支持',
overrideUnsupported: '不支持',
binary: '检测到的 Codex 可执行文件',
notDetected: '未检测到',
},
},
},
codexPage: {
title: 'Codex',
@@ -5716,6 +5822,7 @@ const resources = {
configFileNotFound: 'Không tìm thấy tập tin cấu hình',
},
settingsTabs: {
browser: 'Trình duyệt',
web: 'Web',
image: 'Hình ảnh',
channels: 'Kênh',
@@ -7246,6 +7353,62 @@ const resources = {
description: 'Cấu hình cài đặt phân tích hình ảnh.',
loading: 'Đang tải cài đặt hình ảnh...',
},
browserSection: {
title: 'Trình duyệt',
description: 'Bề mặt thiết lập chính cho tự động hóa trình duyệt được CCS quản lý.',
primaryTitle: 'Settings > Browser là nơi thiết lập trình duyệt chính.',
primaryDescription:
'Cấu hình Claude Browser Attach và Codex Browser Tools tại đây, rồi dùng hướng dẫn bên dưới để khởi chạy hoặc kiểm tra từng luồng.',
readiness: 'Trạng thái sẵn sàng',
nextStep: 'Bước tiếp theo',
actions: {
saveClaude: 'Lưu cấu hình Claude',
saveCodex: 'Lưu cấu hình Codex',
testConnection: 'Kiểm tra kết nối',
copyLaunchCommand: 'Sao chép lệnh khởi chạy',
},
messages: {
statusRefreshed: 'Đã làm mới trạng thái trình duyệt',
launchCommandCopied: 'Đã sao chép lệnh khởi chạy',
},
claude: {
title: 'Claude Browser Attach',
description:
'Gắn các phiên chạy Claude-target vào một phiên Chrome có bật remote debugging.',
enabledLabel: 'Bật Claude Browser Attach',
enabledDescription:
'Khi bật, CCS sẽ chuẩn bị runtime MCP trình duyệt được quản lý cho các phiên chạy Claude-target.',
userDataDir: 'Thư mục dữ liệu người dùng Chrome',
userDataDirHint:
'Nên dùng thư mục profile Chrome riêng để trạng thái attach được tách khỏi trình duyệt hằng ngày.',
devtoolsPort: 'Cổng DevTools',
devtoolsPortHint:
'Phải khớp với cổng thực tế dùng khi khởi chạy Chrome trong attach mode.',
devtoolsPortInvalid: 'Nhập cổng hợp lệ trong khoảng 1 đến 65535.',
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ý',
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',
launchGuidanceHint:
'Khởi chạy Chrome với remote debugging, rồi chạy lại Test connection nếu cần.',
},
codex: {
title: 'Codex Browser Tools',
description:
'Điều khiển đường dẫn trình duyệt Playwright MCP được quản lý cho các phiên chạy Codex-target.',
enabledLabel: 'Bật Codex Browser Tools',
enabledDescription:
'Giữ bật để trang Browser là nơi cấu hình chính cho công cụ trình duyệt của Codex.',
serverName: 'Tên dịch vụ được quản lý',
overrideSupport: 'Hỗ trợ config override',
overrideSupported: 'Được hỗ trợ',
overrideUnsupported: 'Không được hỗ trợ',
binary: 'Binary Codex được phát hiện',
notDetected: 'Không phát hiện',
},
},
},
codexPage: {
title: 'Codex',
@@ -8180,6 +8343,7 @@ const resources = {
configFileNotFound: '設定ファイルが見つかりません',
},
settingsTabs: {
browser: 'ブラウザ',
web: 'Web検索',
image: '画像',
channels: 'チャンネル',
@@ -9611,6 +9775,62 @@ const resources = {
description: '画像分析の設定。',
loading: '画像設定を読み込み中...',
},
browserSection: {
title: 'ブラウザ',
description: 'CCS が管理するブラウザ自動化の主要な設定画面です。',
primaryTitle: 'Settings > Browser がブラウザ設定の主入口です。',
primaryDescription:
'ここで Claude Browser Attach と Codex Browser Tools を設定し、各レーンの起動や確認は下の案内を使ってください。',
readiness: '準備状況',
nextStep: '次の手順',
actions: {
saveClaude: 'Claude 設定を保存',
saveCodex: 'Codex 設定を保存',
testConnection: '接続を確認',
copyLaunchCommand: '起動コマンドをコピー',
},
messages: {
statusRefreshed: 'ブラウザ状態を更新しました',
launchCommandCopied: '起動コマンドをコピーしました',
},
claude: {
title: 'Claude Browser Attach',
description:
'リモートデバッグを有効にした Chrome セッションへ Claude-target の起動を接続します。',
enabledLabel: 'Claude Browser Attach を有効にする',
enabledDescription:
'有効にすると、CCS は Claude-target 起動向けに管理済みブラウザ MCP ランタイムを準備します。',
userDataDir: 'Chrome ユーザーデータディレクトリ',
userDataDirHint:
'日常利用のブラウザと状態を分離するため、専用の Chrome プロファイルディレクトリを使うことを推奨します。',
devtoolsPort: 'DevTools ポート',
devtoolsPortHint:
'Chrome を attach モードで起動したときに使う実際のポートと一致させてください。',
devtoolsPortInvalid: '1 から 65535 の有効なポート番号を入力してください。',
effectivePath: '現在有効な attach パス',
recommendedPath: '推奨パス',
managedRuntime: '管理済みブラウザランタイム',
overrideMessage:
'{{source}} から環境変数オーバーライドが有効です。これを外すと Dashboard の設定が再び主ソースになります。',
launchGuidance: '起動ガイダンス',
launchGuidanceHint:
'リモートデバッグ付きで Chrome を起動し、必要なら Test connection を再実行してください。',
},
codex: {
title: 'Codex Browser Tools',
description:
'Codex-target 起動向けの管理済み Playwright MCP ブラウザパスを制御します。',
enabledLabel: 'Codex Browser Tools を有効にする',
enabledDescription:
'有効のままにすると、Browser ページが Codex ブラウザツールの主設定画面になります。',
serverName: '管理対象サーバー名',
overrideSupport: 'config override 対応',
overrideSupported: '対応',
overrideUnsupported: '非対応',
binary: '検出された Codex バイナリ',
notDetected: '未検出',
},
},
},
setupWizard: {
title: 'クイックセットアップウィザード',
@@ -13,6 +13,7 @@ import {
Brain,
Archive,
MessageSquare,
Monitor,
} from 'lucide-react';
import type { SettingsTab } from '../types';
import { useTranslation } from 'react-i18next';
@@ -25,6 +26,7 @@ interface TabNavigationProps {
export function TabNavigation({ activeTab, onTabChange }: TabNavigationProps) {
const { t } = useTranslation();
const tabs = [
{ value: 'browser' as const, label: t('settingsTabs.browser'), icon: Monitor },
{ value: 'websearch' as const, label: t('settingsTabs.web'), icon: Globe },
{ value: 'image' as const, label: t('settingsTabs.image'), icon: ImageIcon },
{ value: 'channels' as const, label: t('settingsTabs.channels'), icon: MessageSquare },
@@ -37,7 +39,7 @@ export function TabNavigation({ activeTab, onTabChange }: TabNavigationProps) {
return (
<Tabs value={activeTab} onValueChange={(v) => onTabChange(v as SettingsTab)}>
<TabsList className="grid w-full grid-cols-8">
<TabsList className="grid w-full grid-cols-9">
{tabs.map(({ value, label, icon: Icon }) => (
<TabsTrigger key={value} value={value} className="gap-1.5 px-2 text-xs">
<Icon className="h-3.5 w-3.5 shrink-0" />
+1
View File
@@ -3,6 +3,7 @@
*/
export {
useBrowserConfig,
useSettingsContext,
useSettingsActions,
useSettingsTab,
@@ -6,6 +6,8 @@
import { useCallback, useContext, useMemo } from 'react';
import { SettingsContext } from '../settings-context';
import type {
BrowserConfig,
BrowserStatus,
WebSearchConfig,
GlobalEnvConfig,
CliproxyServerConfig,
@@ -24,6 +26,41 @@ export function useSettingsContext() {
export function useSettingsActions() {
const { dispatch } = useSettingsContext();
const setBrowserConfig = useCallback(
(config: BrowserConfig | null) => dispatch({ type: 'SET_BROWSER_CONFIG', payload: config }),
[dispatch]
);
const setBrowserStatus = useCallback(
(status: BrowserStatus | null) => dispatch({ type: 'SET_BROWSER_STATUS', payload: status }),
[dispatch]
);
const setBrowserLoading = useCallback(
(loading: boolean) => dispatch({ type: 'SET_BROWSER_LOADING', payload: loading }),
[dispatch]
);
const setBrowserStatusLoading = useCallback(
(loading: boolean) => dispatch({ type: 'SET_BROWSER_STATUS_LOADING', payload: loading }),
[dispatch]
);
const setBrowserSaving = useCallback(
(saving: boolean) => dispatch({ type: 'SET_BROWSER_SAVING', payload: saving }),
[dispatch]
);
const setBrowserError = useCallback(
(error: string | null) => dispatch({ type: 'SET_BROWSER_ERROR', payload: error }),
[dispatch]
);
const setBrowserSuccess = useCallback(
(success: boolean) => dispatch({ type: 'SET_BROWSER_SUCCESS', payload: success }),
[dispatch]
);
const setWebSearchConfig = useCallback(
(config: WebSearchConfig | null) => dispatch({ type: 'SET_WEBSEARCH_CONFIG', payload: config }),
[dispatch]
@@ -133,6 +170,13 @@ export function useSettingsActions() {
return useMemo(
() => ({
setBrowserConfig,
setBrowserStatus,
setBrowserLoading,
setBrowserStatusLoading,
setBrowserSaving,
setBrowserError,
setBrowserSuccess,
setWebSearchConfig,
setWebSearchStatus,
setWebSearchLoading,
@@ -156,6 +200,13 @@ export function useSettingsActions() {
setRawConfigLoading,
}),
[
setBrowserConfig,
setBrowserStatus,
setBrowserLoading,
setBrowserStatusLoading,
setBrowserSaving,
setBrowserError,
setBrowserSuccess,
setWebSearchConfig,
setWebSearchStatus,
setWebSearchLoading,
+1
View File
@@ -4,6 +4,7 @@
export { useSettingsContext, useSettingsActions } from './context-hooks';
export { useSettingsTab } from './use-settings-tab';
export { useBrowserConfig } from './use-browser-config';
export { useWebSearchConfig } from './use-websearch-config';
export { useGlobalEnvConfig } from './use-globalenv-config';
export { useProxyConfig } from './use-proxy-config';
@@ -0,0 +1,93 @@
import { useCallback } from 'react';
import { api } from '@/lib/api-client';
import { useSettingsActions, useSettingsContext } from './context-hooks';
import type { BrowserConfig, BrowserSavePayload } from '../types';
function mergeConfig(current: BrowserConfig, updates: BrowserSavePayload): BrowserConfig {
return {
claude: {
...current.claude,
...updates.claude,
},
codex: {
...current.codex,
...updates.codex,
},
};
}
export function useBrowserConfig() {
const { state } = useSettingsContext();
const actions = useSettingsActions();
const fetchConfig = useCallback(async () => {
try {
actions.setBrowserLoading(true);
actions.setBrowserError(null);
const payload = await api.browser.get();
actions.setBrowserConfig(payload.config);
actions.setBrowserStatus(payload.status);
} catch (err) {
actions.setBrowserError((err as Error).message);
} finally {
actions.setBrowserLoading(false);
actions.setBrowserStatusLoading(false);
}
}, [actions]);
const fetchStatus = useCallback(async () => {
try {
actions.setBrowserStatusLoading(true);
actions.setBrowserError(null);
const status = await api.browser.getStatus();
actions.setBrowserStatus(status);
return status;
} catch (err) {
actions.setBrowserError((err as Error).message);
return null;
} finally {
actions.setBrowserStatusLoading(false);
}
}, [actions]);
const saveConfig = useCallback(
async (updates: BrowserSavePayload) => {
const currentConfig = state.browserConfig;
if (!currentConfig) return false;
const optimisticConfig = mergeConfig(currentConfig, updates);
actions.setBrowserConfig(optimisticConfig);
try {
actions.setBrowserSaving(true);
actions.setBrowserError(null);
const response = await api.browser.update(updates);
actions.setBrowserConfig(response.browser.config);
actions.setBrowserStatus(response.browser.status);
actions.setBrowserSuccess(true);
window.setTimeout(() => actions.setBrowserSuccess(false), 1500);
return true;
} catch (err) {
actions.setBrowserConfig(currentConfig);
actions.setBrowserError((err as Error).message);
return false;
} finally {
actions.setBrowserSaving(false);
}
},
[actions, state.browserConfig]
);
return {
config: state.browserConfig,
status: state.browserStatus,
loading: state.browserLoading,
statusLoading: state.browserStatusLoading,
saving: state.browserSaving,
error: state.browserError,
success: state.browserSuccess,
fetchConfig,
fetchStatus,
saveConfig,
};
}
+31 -1
View File
@@ -13,6 +13,7 @@ import {
type ComponentType,
} from 'react';
import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels';
import { useSearchParams } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import { RefreshCw, FileCode, Copy, Check, GripVertical, AlertCircle } from 'lucide-react';
import { CodeEditor } from '@/components/shared/code-editor';
@@ -48,6 +49,7 @@ function lazyWithRetry<T extends ComponentType<unknown>>(importFn: () => Promise
// Lazy-loaded sections with retry capability
const WebSearchSection = lazyWithRetry(() => import('./sections/websearch'));
const BrowserSection = lazyWithRetry(() => import('./sections/browser'));
const ImageAnalysisSection = lazyWithRetry(() => import('./sections/image-analysis'));
const ChannelsSection = lazyWithRetry(() => import('./sections/channels'));
const GlobalEnvSection = lazyWithRetry(() => import('./sections/globalenv-section'));
@@ -99,10 +101,36 @@ class SectionErrorBoundary extends Component<
}
}
function resolveSettingsTab(tabParam: string | null | undefined): SettingsTab {
switch (tabParam?.toLowerCase()) {
case 'browser':
return 'browser';
case 'imageanalysis':
case 'image':
return 'image';
case 'channels':
return 'channels';
case 'globalenv':
return 'globalenv';
case 'proxy':
return 'proxy';
case 'auth':
return 'auth';
case 'thinking':
return 'thinking';
case 'backups':
return 'backups';
default:
return 'websearch';
}
}
// Inner component that uses context
function SettingsPageInner() {
const { t } = useTranslation();
const { activeTab, setActiveTab } = useSettingsTab();
const { setActiveTab } = useSettingsTab();
const [searchParams] = useSearchParams();
const activeTab = resolveSettingsTab(searchParams.get('tab'));
const {
rawConfig,
loading: rawConfigLoading,
@@ -131,6 +159,7 @@ function SettingsPageInner() {
</div>
<SectionErrorBoundary>
<Suspense fallback={<SectionSkeleton />}>
{activeTab === 'browser' && <BrowserSection />}
{activeTab === 'websearch' && <WebSearchSection />}
{activeTab === 'image' && <ImageAnalysisSection />}
{activeTab === 'channels' && <ChannelsSection />}
@@ -156,6 +185,7 @@ function SettingsPageInner() {
{/* Tab Content */}
<SectionErrorBoundary>
<Suspense fallback={<SectionSkeleton />}>
{activeTab === 'browser' && <BrowserSection />}
{activeTab === 'websearch' && <WebSearchSection />}
{activeTab === 'image' && <ImageAnalysisSection />}
{activeTab === 'channels' && <ChannelsSection />}
@@ -0,0 +1,577 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Switch } from '@/components/ui/switch';
import {
AlertCircle,
CheckCircle2,
Copy,
Monitor,
RefreshCw,
SearchCheck,
Wrench,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { useBrowserConfig, useRawConfig } from '../../hooks';
import type { BrowserConfig } from '../../types';
function getPlatformKey(): 'darwin' | 'linux' | 'win32' {
const platform = navigator.platform.toLowerCase();
if (platform.includes('mac')) return 'darwin';
if (platform.includes('win')) return 'win32';
return 'linux';
}
function parsePortDraft(value: string): number | null {
if (!/^\d+$/.test(value.trim())) {
return null;
}
const port = Number.parseInt(value.trim(), 10);
if (port < 1 || port > 65535) {
return null;
}
return port;
}
function statusTone(state: string) {
if (state === 'ready' || state === 'enabled') {
return 'border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300';
}
if (state === 'disabled') {
return 'border-slate-500/20 bg-slate-500/10 text-slate-700 dark:text-slate-300';
}
return 'border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300';
}
function stateLabel(state: string) {
return state.replaceAll('_', ' ');
}
function buildLaunchCommand(
userDataDir: string,
devtoolsPort: number,
platform: 'darwin' | 'linux' | 'win32'
): string {
const quotedPath = JSON.stringify(userDataDir);
if (platform === 'darwin') {
return `open -na "Google Chrome" --args --remote-debugging-port=${devtoolsPort} --user-data-dir=${quotedPath}`;
}
if (platform === 'win32') {
return `chrome.exe --remote-debugging-port=${devtoolsPort} --user-data-dir=${quotedPath}`;
}
return `google-chrome --remote-debugging-port=${devtoolsPort} --user-data-dir=${quotedPath}`;
}
function updateClaudeDraft(
source: BrowserConfig,
updates: Partial<BrowserConfig['claude']>
): BrowserConfig {
return {
...source,
claude: {
...source.claude,
...updates,
},
};
}
function updateCodexDraft(
source: BrowserConfig,
updates: Partial<BrowserConfig['codex']>
): BrowserConfig {
return {
...source,
codex: {
...source.codex,
...updates,
},
};
}
export default function BrowserSection() {
const { t } = useTranslation();
const { fetchRawConfig } = useRawConfig();
const {
config,
status,
loading,
statusLoading,
saving,
error,
success,
fetchConfig,
fetchStatus,
saveConfig,
} = useBrowserConfig();
const [draft, setDraft] = useState<BrowserConfig | null>(null);
const [claudePortDraft, setClaudePortDraft] = useState<string | null>(null);
const [actionMessage, setActionMessage] = useState<string | null>(null);
useEffect(() => {
void fetchConfig();
}, [fetchConfig]);
useEffect(() => {
if (!actionMessage && !success) return;
const timer = window.setTimeout(() => setActionMessage(null), 2500);
return () => window.clearTimeout(timer);
}, [actionMessage, success]);
const effectiveConfig = draft ?? config;
const preferredLaunchCommand = useMemo(() => {
if (!effectiveConfig) return '';
return buildLaunchCommand(
effectiveConfig.claude.userDataDir,
effectiveConfig.claude.devtoolsPort,
getPlatformKey()
);
}, [effectiveConfig]);
const displayedClaudePort =
claudePortDraft ?? String(effectiveConfig?.claude.devtoolsPort ?? 9222);
const claudePort = parsePortDraft(displayedClaudePort);
const claudePortInvalid = displayedClaudePort.trim().length > 0 && claudePort === null;
const hasClaudeChanges =
config !== null &&
effectiveConfig !== null &&
(config.claude.enabled !== effectiveConfig.claude.enabled ||
config.claude.userDataDir !== effectiveConfig.claude.userDataDir ||
config.claude.devtoolsPort !== claudePort);
const hasCodexChanges =
config !== null &&
effectiveConfig !== null &&
config.codex.enabled !== effectiveConfig.codex.enabled;
const refreshAll = useCallback(async () => {
setActionMessage(null);
setDraft(null);
setClaudePortDraft(null);
await Promise.all([fetchConfig(), fetchRawConfig()]);
}, [fetchConfig, fetchRawConfig]);
const refreshStatus = useCallback(async () => {
const nextStatus = await fetchStatus();
if (nextStatus) {
setActionMessage(t('settingsPage.browserSection.messages.statusRefreshed'));
}
}, [fetchStatus, t]);
const saveClaudeSettings = useCallback(async () => {
if (!effectiveConfig || claudePort === null) return;
const saved = await saveConfig({
claude: {
enabled: effectiveConfig.claude.enabled,
userDataDir: effectiveConfig.claude.userDataDir.trim(),
devtoolsPort: claudePort,
},
});
if (saved) {
await fetchRawConfig();
setActionMessage(null);
setDraft(null);
setClaudePortDraft(null);
}
}, [claudePort, effectiveConfig, fetchRawConfig, saveConfig]);
const saveCodexSettings = useCallback(async () => {
if (!effectiveConfig) return;
const saved = await saveConfig({
codex: {
enabled: effectiveConfig.codex.enabled,
},
});
if (saved) {
await fetchRawConfig();
setActionMessage(null);
setDraft(null);
setClaudePortDraft(null);
}
}, [effectiveConfig, fetchRawConfig, saveConfig]);
const copyLaunchCommand = useCallback(async () => {
if (!preferredLaunchCommand) return;
await navigator.clipboard.writeText(preferredLaunchCommand);
setActionMessage(t('settingsPage.browserSection.messages.launchCommandCopied'));
}, [preferredLaunchCommand, t]);
if (loading) {
return (
<div className="flex flex-1 items-center justify-center">
<div className="flex items-center gap-3 text-muted-foreground">
<RefreshCw className="h-5 w-5 animate-spin" />
<span>{t('settings.loading')}</span>
</div>
</div>
);
}
if (!config || !status || !effectiveConfig) {
return (
<div className="p-5">
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>
{error ?? t('settingsPage.browserSection.description')}
</AlertDescription>
</Alert>
<div className="mt-4">
<Button variant="outline" size="sm" onClick={refreshAll}>
<RefreshCw className="mr-2 h-4 w-4" />
{t('sharedPage.retry')}
</Button>
</div>
</div>
);
}
return (
<div className="relative flex min-h-0 flex-1 flex-col">
<div
className={cn(
'absolute left-5 right-5 top-20 z-10 transition-all duration-200 ease-out',
error || success || actionMessage
? 'translate-y-0 opacity-100'
: 'pointer-events-none -translate-y-2 opacity-0'
)}
>
{error && (
<Alert variant="destructive" className="py-2 shadow-lg">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{!error && (success || actionMessage) && (
<div className="flex items-center gap-2 rounded-md border border-emerald-200 bg-emerald-50 px-3 py-2 text-emerald-700 shadow-lg dark:border-emerald-900/50 dark:bg-emerald-950/80 dark:text-emerald-300">
<CheckCircle2 className="h-4 w-4 shrink-0" />
<span className="text-sm font-medium">
{actionMessage ?? t('commonToast.settingsSaved')}
</span>
</div>
)}
</div>
<ScrollArea className="flex-1">
<div className="space-y-5 p-5">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="space-y-1">
<div className="flex items-center gap-2">
<Monitor className="h-5 w-5 text-primary" />
<h2 className="text-lg font-semibold">{t('settingsPage.browserSection.title')}</h2>
</div>
<p className="text-sm text-muted-foreground">
{t('settingsPage.browserSection.description')}
</p>
</div>
<Button variant="outline" size="sm" onClick={refreshAll} disabled={saving || loading}>
<RefreshCw className={cn('mr-2 h-4 w-4', statusLoading && 'animate-spin')} />
{t('settings.refresh')}
</Button>
</div>
<Alert>
<Wrench className="h-4 w-4" />
<AlertDescription>
<span className="font-medium">{t('settingsPage.browserSection.primaryTitle')}</span>{' '}
{t('settingsPage.browserSection.primaryDescription')}
</AlertDescription>
</Alert>
<Card>
<CardHeader className="gap-3">
<div className="flex items-start justify-between gap-3">
<div>
<CardTitle>{t('settingsPage.browserSection.claude.title')}</CardTitle>
<CardDescription>
{t('settingsPage.browserSection.claude.description')}
</CardDescription>
</div>
<Badge variant="outline" className={statusTone(status.claude.state)}>
{stateLabel(status.claude.state)}
</Badge>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 md:grid-cols-[1fr_auto] md:items-center">
<div>
<Label htmlFor="browser-claude-enabled">
{t('settingsPage.browserSection.claude.enabledLabel')}
</Label>
<p className="mt-1 text-sm text-muted-foreground">
{t('settingsPage.browserSection.claude.enabledDescription')}
</p>
</div>
<Switch
id="browser-claude-enabled"
checked={effectiveConfig.claude.enabled}
onCheckedChange={(next) =>
setDraft((current) =>
updateClaudeDraft(current ?? effectiveConfig, { enabled: next })
)
}
aria-label={t('settingsPage.browserSection.claude.enabledLabel')}
/>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="browser-claude-user-data-dir">
{t('settingsPage.browserSection.claude.userDataDir')}
</Label>
<Input
id="browser-claude-user-data-dir"
value={effectiveConfig.claude.userDataDir}
onChange={(event) =>
setDraft((current) =>
updateClaudeDraft(current ?? effectiveConfig, {
userDataDir: event.target.value,
})
)
}
placeholder={status.claude.recommendedUserDataDir}
/>
<p className="text-xs text-muted-foreground">
{t('settingsPage.browserSection.claude.userDataDirHint')}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="browser-claude-devtools-port">
{t('settingsPage.browserSection.claude.devtoolsPort')}
</Label>
<Input
id="browser-claude-devtools-port"
value={displayedClaudePort}
onChange={(event) => setClaudePortDraft(event.target.value)}
inputMode="numeric"
/>
<p
className={cn(
'text-xs text-muted-foreground',
claudePortInvalid && 'text-destructive'
)}
>
{claudePortInvalid
? t('settingsPage.browserSection.claude.devtoolsPortInvalid')
: t('settingsPage.browserSection.claude.devtoolsPortHint')}
</p>
</div>
</div>
<div className="grid gap-3 rounded-lg border bg-muted/30 p-4 md:grid-cols-2">
<div>
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{t('settingsPage.browserSection.readiness')}
</p>
<p className="mt-1 font-medium">{status.claude.title}</p>
<p className="mt-1 text-sm text-muted-foreground">{status.claude.detail}</p>
</div>
<div>
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{t('settingsPage.browserSection.nextStep')}
</p>
<p className="mt-1 text-sm">{status.claude.nextStep}</p>
</div>
</div>
<div className="grid gap-3 text-sm md:grid-cols-2">
<div className="rounded-lg border p-3">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{t('settingsPage.browserSection.claude.effectivePath')}
</p>
<p className="mt-1 break-all font-mono text-xs">
{status.claude.effectiveUserDataDir}
</p>
<p className="mt-2 text-xs text-muted-foreground">
{t('settingsPage.browserSection.claude.recommendedPath')}:{' '}
{status.claude.recommendedUserDataDir}
</p>
</div>
<div className="rounded-lg border p-3">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{t('settingsPage.browserSection.claude.managedRuntime')}
</p>
<p className="mt-1">{status.claude.managedMcpServerName}</p>
<p className="mt-2 break-all font-mono text-xs text-muted-foreground">
{status.claude.managedMcpServerPath}
</p>
</div>
</div>
{status.claude.overrideActive ? (
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription>
{t('settingsPage.browserSection.claude.overrideMessage', {
source: status.claude.source,
})}
</AlertDescription>
</Alert>
) : null}
<div className="space-y-2 rounded-lg border p-3">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm font-medium">
{t('settingsPage.browserSection.claude.launchGuidance')}
</p>
<p className="text-xs text-muted-foreground">
{t('settingsPage.browserSection.claude.launchGuidanceHint')}
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={copyLaunchCommand}
disabled={!preferredLaunchCommand}
>
<Copy className="mr-2 h-4 w-4" />
{t('settingsPage.browserSection.actions.copyLaunchCommand')}
</Button>
</div>
<pre className="overflow-x-auto rounded-md bg-muted p-3 text-xs">
<code>{preferredLaunchCommand}</code>
</pre>
{status.claude.runtimeEnv?.CCS_BROWSER_DEVTOOLS_HTTP_URL ? (
<p className="text-xs text-muted-foreground">
DevTools: {status.claude.runtimeEnv.CCS_BROWSER_DEVTOOLS_HTTP_URL}
</p>
) : null}
</div>
<div className="flex flex-wrap gap-2">
<Button
onClick={saveClaudeSettings}
disabled={saving || claudePortInvalid || !hasClaudeChanges}
>
{saving ? (
<RefreshCw className="mr-2 h-4 w-4 animate-spin" />
) : (
<CheckCircle2 className="mr-2 h-4 w-4" />
)}
{t('settingsPage.browserSection.actions.saveClaude')}
</Button>
<Button
variant="outline"
onClick={refreshStatus}
disabled={saving || statusLoading || hasClaudeChanges || claudePortInvalid}
>
<SearchCheck className="mr-2 h-4 w-4" />
{t('settingsPage.browserSection.actions.testConnection')}
</Button>
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="gap-3">
<div className="flex items-start justify-between gap-3">
<div>
<CardTitle>{t('settingsPage.browserSection.codex.title')}</CardTitle>
<CardDescription>
{t('settingsPage.browserSection.codex.description')}
</CardDescription>
</div>
<Badge variant="outline" className={statusTone(status.codex.state)}>
{stateLabel(status.codex.state)}
</Badge>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 md:grid-cols-[1fr_auto] md:items-center">
<div>
<Label htmlFor="browser-codex-enabled">
{t('settingsPage.browserSection.codex.enabledLabel')}
</Label>
<p className="mt-1 text-sm text-muted-foreground">
{t('settingsPage.browserSection.codex.enabledDescription')}
</p>
</div>
<Switch
id="browser-codex-enabled"
checked={effectiveConfig.codex.enabled}
onCheckedChange={(next) =>
setDraft((current) =>
updateCodexDraft(current ?? effectiveConfig, { enabled: next })
)
}
aria-label={t('settingsPage.browserSection.codex.enabledLabel')}
/>
</div>
<div className="grid gap-3 rounded-lg border bg-muted/30 p-4 md:grid-cols-2">
<div>
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{t('settingsPage.browserSection.readiness')}
</p>
<p className="mt-1 font-medium">{status.codex.title}</p>
<p className="mt-1 text-sm text-muted-foreground">{status.codex.detail}</p>
</div>
<div>
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{t('settingsPage.browserSection.nextStep')}
</p>
<p className="mt-1 text-sm">{status.codex.nextStep}</p>
</div>
</div>
<div className="grid gap-3 text-sm md:grid-cols-3">
<div className="rounded-lg border p-3">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{t('settingsPage.browserSection.codex.serverName')}
</p>
<p className="mt-1 font-mono text-xs">{status.codex.serverName}</p>
</div>
<div className="rounded-lg border p-3">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{t('settingsPage.browserSection.codex.overrideSupport')}
</p>
<p className="mt-1">
{status.codex.supportsConfigOverrides
? t('settingsPage.browserSection.codex.overrideSupported')
: t('settingsPage.browserSection.codex.overrideUnsupported')}
</p>
</div>
<div className="rounded-lg border p-3">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{t('settingsPage.browserSection.codex.binary')}
</p>
<p className="mt-1 break-all font-mono text-xs">
{status.codex.binaryPath ?? t('settingsPage.browserSection.codex.notDetected')}
</p>
{status.codex.version ? (
<p className="mt-2 text-xs text-muted-foreground">{status.codex.version}</p>
) : null}
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button onClick={saveCodexSettings} disabled={saving || !hasCodexChanges}>
{saving ? (
<RefreshCw className="mr-2 h-4 w-4 animate-spin" />
) : (
<CheckCircle2 className="mr-2 h-4 w-4" />
)}
{t('settingsPage.browserSection.actions.saveCodex')}
</Button>
</div>
</CardContent>
</Card>
</div>
</ScrollArea>
</div>
);
}
+38
View File
@@ -5,6 +5,8 @@
import { createContext, type Dispatch } from 'react';
import type {
BrowserConfig,
BrowserStatus,
WebSearchConfig,
GlobalEnvConfig,
CliproxyServerConfig,
@@ -15,6 +17,14 @@ import type {
// === State ===
export interface SettingsState {
// Browser state
browserConfig: BrowserConfig | null;
browserStatus: BrowserStatus | null;
browserLoading: boolean;
browserStatusLoading: boolean;
browserSaving: boolean;
browserError: string | null;
browserSuccess: boolean;
// WebSearch state
webSearchConfig: WebSearchConfig | null;
webSearchStatus: WebSearchStatus | null;
@@ -43,6 +53,13 @@ export interface SettingsState {
}
export const initialSettingsState: SettingsState = {
browserConfig: null,
browserStatus: null,
browserLoading: true,
browserStatusLoading: true,
browserSaving: false,
browserError: null,
browserSuccess: false,
webSearchConfig: null,
webSearchStatus: null,
webSearchLoading: true,
@@ -69,6 +86,13 @@ export const initialSettingsState: SettingsState = {
// === Actions ===
export type SettingsAction =
| { type: 'SET_BROWSER_CONFIG'; payload: BrowserConfig | null }
| { type: 'SET_BROWSER_STATUS'; payload: BrowserStatus | null }
| { type: 'SET_BROWSER_LOADING'; payload: boolean }
| { type: 'SET_BROWSER_STATUS_LOADING'; payload: boolean }
| { type: 'SET_BROWSER_SAVING'; payload: boolean }
| { type: 'SET_BROWSER_ERROR'; payload: string | null }
| { type: 'SET_BROWSER_SUCCESS'; payload: boolean }
| { type: 'SET_WEBSEARCH_CONFIG'; payload: WebSearchConfig | null }
| { type: 'SET_WEBSEARCH_STATUS'; payload: WebSearchStatus | null }
| { type: 'SET_WEBSEARCH_LOADING'; payload: boolean }
@@ -93,6 +117,20 @@ export type SettingsAction =
export function settingsReducer(state: SettingsState, action: SettingsAction): SettingsState {
switch (action.type) {
case 'SET_BROWSER_CONFIG':
return { ...state, browserConfig: action.payload };
case 'SET_BROWSER_STATUS':
return { ...state, browserStatus: action.payload };
case 'SET_BROWSER_LOADING':
return { ...state, browserLoading: action.payload };
case 'SET_BROWSER_STATUS_LOADING':
return { ...state, browserStatusLoading: action.payload };
case 'SET_BROWSER_SAVING':
return { ...state, browserSaving: action.payload };
case 'SET_BROWSER_ERROR':
return { ...state, browserError: action.payload };
case 'SET_BROWSER_SUCCESS':
return { ...state, browserSuccess: action.payload };
case 'SET_WEBSEARCH_CONFIG':
return { ...state, webSearchConfig: action.payload };
case 'SET_WEBSEARCH_STATUS':
+11 -1
View File
@@ -3,7 +3,13 @@
* Type definitions for WebSearch, GlobalEnv, and Proxy configurations
*/
import type { CliproxyServerConfig, RemoteProxyStatus } from '@/lib/api-client';
import type {
BrowserSettingsConfig,
BrowserStatusPayload,
CliproxyServerConfig,
RemoteProxyStatus,
UpdateBrowserSettingsPayload,
} from '@/lib/api-client';
// === WebSearch Types ===
@@ -162,6 +168,7 @@ export interface OfficialChannelsStatus {
// === Tab Types ===
export type SettingsTab =
| 'browser'
| 'websearch'
| 'image'
| 'channels'
@@ -192,3 +199,6 @@ export interface ThinkingConfig {
// === Re-exports from api-client ===
export type { CliproxyServerConfig, RemoteProxyStatus };
export type BrowserConfig = BrowserSettingsConfig;
export type BrowserStatus = BrowserStatusPayload;
export type BrowserSavePayload = UpdateBrowserSettingsPayload;
@@ -0,0 +1,22 @@
import { describe, expect, it, vi } from 'vitest';
import { render, screen, userEvent } from '@tests/setup/test-utils';
import { CodexMcpServersCard } from '@/components/compatible-cli/codex-mcp-servers-card';
describe('CodexMcpServersCard', () => {
it('blocks creating the reserved ccs_browser entry from the generic MCP editor', async () => {
render(<CodexMcpServersCard entries={[]} onSave={vi.fn()} onDelete={vi.fn()} />);
const nameInput = screen.getByPlaceholderText('playwright');
await userEvent.type(nameInput, 'ccs_browser');
expect(
screen.getAllByText(
(_, element) =>
element?.textContent?.includes(
'ccs_browser is reserved for the CCS-managed browser tooling path.'
) ?? false
)[0]
).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Save MCP server' })).toBeDisabled();
});
});
@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest';
import { readCodexMcpServers } from '@/lib/codex-config';
describe('readCodexMcpServers', () => {
it('marks the CCS browser MCP entry as managed by Browser settings', () => {
const entries = readCodexMcpServers({
mcp_servers: {
ccs_browser: {
command: 'npx',
args: ['-y', '@playwright/mcp@0.0.70'],
enabled: true,
},
playwright: {
command: 'npx',
args: ['-y', '@playwright/mcp@latest'],
enabled: true,
},
},
});
expect(entries).toEqual(
expect.arrayContaining([
expect.objectContaining({
name: 'ccs_browser',
isCcsManaged: true,
managementSurface: 'browser-settings',
}),
expect.objectContaining({
name: 'playwright',
isCcsManaged: false,
managementSurface: null,
}),
])
);
});
});
@@ -0,0 +1,109 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { render, screen, userEvent } from '@tests/setup/test-utils';
const mocks = vi.hoisted(() => ({
useBrowserConfig: vi.fn(),
useRawConfig: vi.fn(),
fetchConfig: vi.fn(),
fetchStatus: vi.fn(),
saveConfig: vi.fn(),
fetchRawConfig: vi.fn(),
}));
vi.mock('@/pages/settings/hooks', async () => {
const actual =
await vi.importActual<typeof import('@/pages/settings/hooks')>('@/pages/settings/hooks');
return {
...actual,
useBrowserConfig: mocks.useBrowserConfig,
useRawConfig: mocks.useRawConfig,
};
});
import BrowserSection from '@/pages/settings/sections/browser';
describe('BrowserSection', () => {
beforeEach(() => {
mocks.fetchConfig.mockReset();
mocks.fetchStatus.mockReset();
mocks.saveConfig.mockReset();
mocks.fetchRawConfig.mockReset();
mocks.useRawConfig.mockReturnValue({
rawConfig: 'browser:\n claude:\n enabled: true\n',
loading: false,
copied: false,
fetchRawConfig: mocks.fetchRawConfig,
copyToClipboard: vi.fn(),
});
mocks.useBrowserConfig.mockReturnValue({
config: {
claude: {
enabled: true,
userDataDir: '/tmp/browser-profile',
devtoolsPort: 9222,
},
codex: {
enabled: true,
},
},
status: {
claude: {
enabled: true,
source: 'config',
overrideActive: false,
state: 'ready',
title: 'Claude Browser Attach is ready.',
detail: 'CCS can reach the configured Chrome DevTools endpoint.',
nextStep: 'Launch Claude.',
effectiveUserDataDir: '/tmp/browser-profile',
recommendedUserDataDir: '/tmp/browser-profile',
devtoolsPort: 9222,
managedMcpServerName: 'ccs-browser',
managedMcpServerPath: '/tmp/ccs-browser-server.cjs',
launchCommands: {
darwin:
'open -na "Google Chrome" --args --remote-debugging-port=9222 --user-data-dir="/tmp/browser-profile"',
linux:
'google-chrome --remote-debugging-port=9222 --user-data-dir="/tmp/browser-profile"',
win32: 'chrome.exe --remote-debugging-port=9222 --user-data-dir="/tmp/browser-profile"',
},
},
codex: {
enabled: true,
state: 'enabled',
title: 'Codex Browser Tools are enabled.',
detail: 'CCS can inject managed Playwright MCP overrides.',
nextStep: 'Use a Codex-target launch.',
serverName: 'ccs_browser',
supportsConfigOverrides: true,
binaryPath: '/usr/local/bin/codex',
version: 'codex-cli 0.120.0',
},
},
loading: false,
statusLoading: false,
saving: false,
error: null,
success: false,
fetchConfig: mocks.fetchConfig,
fetchStatus: mocks.fetchStatus,
saveConfig: mocks.saveConfig,
});
});
it('uses the current draft for launch guidance and disables testing until the draft is saved', async () => {
render(<BrowserSection />, { withSettingsProvider: true });
const pathInput = screen.getByLabelText('Chrome user-data directory');
await userEvent.clear(pathInput);
await userEvent.type(pathInput, '/tmp/new-browser-profile');
const launchCommand = screen.getByText(/new-browser-profile/);
expect(launchCommand).toBeInTheDocument();
const testConnectionButton = screen.getByRole('button', { name: 'Test connection' });
expect(testConnectionButton).toBeDisabled();
});
});
@@ -31,6 +31,10 @@ vi.mock('@/pages/settings/sections/websearch', () => ({
default: () => <div>WebSearch Section</div>,
}));
vi.mock('@/pages/settings/sections/browser', () => ({
default: () => <div>Browser Section</div>,
}));
vi.mock('@/pages/settings/sections/channels', () => ({
default: () => <div>Channels Section</div>,
}));
@@ -70,6 +74,7 @@ describe('SettingsPage raw config panel', () => {
});
it('keeps the current config editor visible while raw config refreshes', async () => {
window.history.pushState({}, '', '/settings');
mocks.useRawConfig.mockReturnValue({
rawConfig: 'websearch:\n enabled: true\n',
loading: true,
@@ -84,4 +89,19 @@ describe('SettingsPage raw config panel', () => {
expect(screen.getByLabelText('config editor')).toHaveValue('websearch:\n enabled: true\n');
expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
});
it('renders the Browser section when the settings tab query is browser', async () => {
window.history.pushState({}, '', '/settings?tab=browser');
mocks.useRawConfig.mockReturnValue({
rawConfig: 'browser:\n claude:\n enabled: false\n',
loading: false,
copied: false,
fetchRawConfig: mocks.fetchRawConfig,
copyToClipboard: mocks.copyToClipboard,
});
render(<SettingsPage />);
expect(await screen.findAllByText('Browser Section')).toHaveLength(2);
});
});