mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-15 16:19:12 +00:00
feat(cli): add browser automation commands
This commit is contained in:
@@ -127,8 +127,10 @@ Deep dive:
|
||||

|
||||
|
||||
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) |
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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'])),
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
@@ -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 > 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 > 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 > 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);
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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':
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user