mirror of
https://github.com/tiennm99/ccs.git
synced 2026-08-07 02:24:17 +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
|
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:
|
third-party launches instead of leaving you to wire them by hand. Browser
|
||||||
[WebSearch](https://docs.ccs.kaitran.ca/features/ai/websearch).
|
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
|
## 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) |
|
| 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) |
|
| 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) |
|
| 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) |
|
| 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) |
|
| 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) |
|
| 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
|
### 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**: **#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-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.
|
- **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 {
|
import {
|
||||||
appendBrowserToolArgs,
|
appendBrowserToolArgs,
|
||||||
ensureBrowserMcpOrThrow,
|
ensureBrowserMcpOrThrow,
|
||||||
|
getEffectiveClaudeBrowserAttachConfig,
|
||||||
resolveBrowserRuntimeEnv,
|
resolveBrowserRuntimeEnv,
|
||||||
resolveConfiguredBrowserProfileDir,
|
|
||||||
syncBrowserMcpToConfigDir,
|
syncBrowserMcpToConfigDir,
|
||||||
} from './utils/browser';
|
} from './utils/browser';
|
||||||
import { getGlobalEnvConfig, getOfficialChannelsConfig } from './config/unified-config-loader';
|
import {
|
||||||
|
getBrowserConfig,
|
||||||
|
getGlobalEnvConfig,
|
||||||
|
getOfficialChannelsConfig,
|
||||||
|
} from './config/unified-config-loader';
|
||||||
import {
|
import {
|
||||||
ensureProfileHooks as ensureImageAnalyzerHooks,
|
ensureProfileHooks as ensureImageAnalyzerHooks,
|
||||||
removeImageAnalysisProfileHook,
|
removeImageAnalysisProfileHook,
|
||||||
@@ -135,7 +139,7 @@ const CODEX_NATIVE_PASSTHROUGH_FLAGS = new Set(['--help', '-h', '--version', '-v
|
|||||||
function resolveCodexRuntimeConfigOverrides(
|
function resolveCodexRuntimeConfigOverrides(
|
||||||
target: ReturnType<typeof resolveTargetType>
|
target: ReturnType<typeof resolveTargetType>
|
||||||
): string[] {
|
): string[] {
|
||||||
if (target !== 'codex') {
|
if (target !== 'codex' || !getBrowserConfig().codex.enabled) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
return buildCodexBrowserMcpOverrides();
|
return buildCodexBrowserMcpOverrides();
|
||||||
@@ -1054,13 +1058,13 @@ async function main(): Promise<void> {
|
|||||||
const imageAnalysisMcpReady =
|
const imageAnalysisMcpReady =
|
||||||
resolvedTarget === 'claude' ? ensureImageAnalysisMcpOrThrow() : true;
|
resolvedTarget === 'claude' ? ensureImageAnalysisMcpOrThrow() : true;
|
||||||
let browserRuntimeEnv: TargetCredentials['browserRuntimeEnv'];
|
let browserRuntimeEnv: TargetCredentials['browserRuntimeEnv'];
|
||||||
const browserProfileDir =
|
const browserAttachConfig =
|
||||||
resolvedTarget === 'claude'
|
resolvedTarget === 'claude'
|
||||||
? resolveConfiguredBrowserProfileDir(process.env.CCS_BROWSER_PROFILE_DIR)
|
? getEffectiveClaudeBrowserAttachConfig(getBrowserConfig())
|
||||||
: undefined;
|
: undefined;
|
||||||
if (resolvedTarget === 'claude') {
|
if (resolvedTarget === 'claude') {
|
||||||
ensureWebSearchMcpOrThrow();
|
ensureWebSearchMcpOrThrow();
|
||||||
if (browserProfileDir) {
|
if (browserAttachConfig?.enabled) {
|
||||||
ensureBrowserMcpOrThrow();
|
ensureBrowserMcpOrThrow();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1087,7 +1091,7 @@ async function main(): Promise<void> {
|
|||||||
syncWebSearchMcpToConfigDir(inheritedClaudeConfigDir);
|
syncWebSearchMcpToConfigDir(inheritedClaudeConfigDir);
|
||||||
syncImageAnalysisMcpToConfigDir(inheritedClaudeConfigDir);
|
syncImageAnalysisMcpToConfigDir(inheritedClaudeConfigDir);
|
||||||
if (
|
if (
|
||||||
browserProfileDir &&
|
browserAttachConfig?.enabled &&
|
||||||
inheritedClaudeConfigDir &&
|
inheritedClaudeConfigDir &&
|
||||||
!syncBrowserMcpToConfigDir(inheritedClaudeConfigDir)
|
!syncBrowserMcpToConfigDir(inheritedClaudeConfigDir)
|
||||||
) {
|
) {
|
||||||
@@ -1297,10 +1301,13 @@ async function main(): Promise<void> {
|
|||||||
|
|
||||||
// Explicitly inject effective settings env vars so stale ANTHROPIC_*
|
// Explicitly inject effective settings env vars so stale ANTHROPIC_*
|
||||||
// values from prior sessions cannot leak into the active profile.
|
// values from prior sessions cannot leak into the active profile.
|
||||||
if (browserProfileDir) {
|
if (browserAttachConfig?.enabled) {
|
||||||
browserRuntimeEnv = {
|
browserRuntimeEnv = {
|
||||||
...(await resolveBrowserRuntimeEnv({
|
...(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',
|
CCS_IMAGE_ANALYSIS_SKIP: '1',
|
||||||
};
|
};
|
||||||
let browserRuntimeEnv: TargetCredentials['browserRuntimeEnv'];
|
let browserRuntimeEnv: TargetCredentials['browserRuntimeEnv'];
|
||||||
const browserProfileDir =
|
const browserAttachConfig =
|
||||||
resolvedTarget === 'claude'
|
resolvedTarget === 'claude'
|
||||||
? resolveConfiguredBrowserProfileDir(process.env.CCS_BROWSER_PROFILE_DIR)
|
? getEffectiveClaudeBrowserAttachConfig(getBrowserConfig())
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
if (resolvedTarget === 'claude') {
|
if (resolvedTarget === 'claude') {
|
||||||
if (browserProfileDir) {
|
if (browserAttachConfig?.enabled) {
|
||||||
ensureBrowserMcpOrThrow();
|
ensureBrowserMcpOrThrow();
|
||||||
browserRuntimeEnv = {
|
browserRuntimeEnv = {
|
||||||
...(await resolveBrowserRuntimeEnv({
|
...(await resolveBrowserRuntimeEnv({
|
||||||
profileDir: browserProfileDir,
|
profileDir: browserAttachConfig.userDataDir,
|
||||||
|
devtoolsPort: browserAttachConfig.hasExplicitDevtoolsPort
|
||||||
|
? String(browserAttachConfig.devtoolsPort)
|
||||||
|
: undefined,
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
Object.assign(envVars, browserRuntimeEnv);
|
Object.assign(envVars, browserRuntimeEnv);
|
||||||
@@ -1495,7 +1505,7 @@ async function main(): Promise<void> {
|
|||||||
if (defaultContinuityInheritance.claudeConfigDir) {
|
if (defaultContinuityInheritance.claudeConfigDir) {
|
||||||
envVars.CLAUDE_CONFIG_DIR = defaultContinuityInheritance.claudeConfigDir;
|
envVars.CLAUDE_CONFIG_DIR = defaultContinuityInheritance.claudeConfigDir;
|
||||||
if (
|
if (
|
||||||
browserProfileDir &&
|
browserAttachConfig?.enabled &&
|
||||||
!syncBrowserMcpToConfigDir(defaultContinuityInheritance.claudeConfigDir)
|
!syncBrowserMcpToConfigDir(defaultContinuityInheritance.claudeConfigDir)
|
||||||
) {
|
) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
|
|||||||
@@ -66,11 +66,15 @@ import {
|
|||||||
import {
|
import {
|
||||||
appendBrowserToolArgs,
|
appendBrowserToolArgs,
|
||||||
ensureBrowserMcpOrThrow,
|
ensureBrowserMcpOrThrow,
|
||||||
|
getEffectiveClaudeBrowserAttachConfig,
|
||||||
resolveBrowserRuntimeEnv,
|
resolveBrowserRuntimeEnv,
|
||||||
resolveConfiguredBrowserProfileDir,
|
|
||||||
syncBrowserMcpToConfigDir,
|
syncBrowserMcpToConfigDir,
|
||||||
} from '../../utils/browser';
|
} 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 { HttpsTunnelProxy } from '../https-tunnel-proxy';
|
||||||
import {
|
import {
|
||||||
isKiroAuthMethod,
|
isKiroAuthMethod,
|
||||||
@@ -260,8 +264,8 @@ export async function execClaudeWithCLIProxy(
|
|||||||
// Setup first-class CCS WebSearch runtime
|
// Setup first-class CCS WebSearch runtime
|
||||||
ensureWebSearchMcpOrThrow();
|
ensureWebSearchMcpOrThrow();
|
||||||
const imageAnalysisMcpReady = ensureImageAnalysisMcpOrThrow();
|
const imageAnalysisMcpReady = ensureImageAnalysisMcpOrThrow();
|
||||||
const browserProfileDir = resolveConfiguredBrowserProfileDir(process.env.CCS_BROWSER_PROFILE_DIR);
|
const browserAttachConfig = getEffectiveClaudeBrowserAttachConfig(getBrowserConfig());
|
||||||
if (browserProfileDir) {
|
if (browserAttachConfig.enabled) {
|
||||||
ensureBrowserMcpOrThrow();
|
ensureBrowserMcpOrThrow();
|
||||||
}
|
}
|
||||||
displayWebSearchStatus();
|
displayWebSearchStatus();
|
||||||
@@ -1050,7 +1054,7 @@ export async function execClaudeWithCLIProxy(
|
|||||||
|
|
||||||
syncImageAnalysisMcpToConfigDir(inheritedClaudeConfigDir);
|
syncImageAnalysisMcpToConfigDir(inheritedClaudeConfigDir);
|
||||||
if (
|
if (
|
||||||
browserProfileDir &&
|
browserAttachConfig.enabled &&
|
||||||
inheritedClaudeConfigDir &&
|
inheritedClaudeConfigDir &&
|
||||||
!syncBrowserMcpToConfigDir(inheritedClaudeConfigDir)
|
!syncBrowserMcpToConfigDir(inheritedClaudeConfigDir)
|
||||||
) {
|
) {
|
||||||
@@ -1153,10 +1157,13 @@ export async function execClaudeWithCLIProxy(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 11. Build final environment with all proxy chains
|
// 11. Build final environment with all proxy chains
|
||||||
const browserRuntimeEnv = browserProfileDir
|
const browserRuntimeEnv = browserAttachConfig.enabled
|
||||||
? {
|
? {
|
||||||
...(await resolveBrowserRuntimeEnv({
|
...(await resolveBrowserRuntimeEnv({
|
||||||
profileDir: browserProfileDir,
|
profileDir: browserAttachConfig.userDataDir,
|
||||||
|
devtoolsPort: browserAttachConfig.hasExplicitDevtoolsPort
|
||||||
|
? String(browserAttachConfig.devtoolsPort)
|
||||||
|
: undefined,
|
||||||
})),
|
})),
|
||||||
}
|
}
|
||||||
: 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 { COPILOT_SUBCOMMANDS } from '../copilot/constants';
|
||||||
import { CLIPROXY_PROVIDER_IDS } from '../cliproxy/provider-capabilities';
|
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 {
|
export interface HelpTopicEntry {
|
||||||
name: HelpTopicName;
|
name: HelpTopicName;
|
||||||
@@ -25,6 +31,7 @@ export const ROOT_HELP_TOPICS: readonly HelpTopicEntry[] = [
|
|||||||
{ name: 'profiles', summary: 'Account profiles, API profiles, and CLIProxy variants' },
|
{ name: 'profiles', summary: 'Account profiles, API profiles, and CLIProxy variants' },
|
||||||
{ name: 'providers', summary: 'Built-in OAuth providers and runtime shortcuts' },
|
{ name: 'providers', summary: 'Built-in OAuth providers and runtime shortcuts' },
|
||||||
{ name: 'kiro', summary: 'Kiro auth methods, IDC flags, and callback guidance' },
|
{ 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: 'completion', summary: 'Shell completion install, refresh, and testing' },
|
||||||
{ name: 'targets', summary: 'Claude, Droid, and Codex target routing' },
|
{ name: 'targets', summary: 'Claude, Droid, and Codex target routing' },
|
||||||
] as const;
|
] as const;
|
||||||
@@ -114,6 +121,12 @@ export const ROOT_COMMAND_CATALOG: readonly RootCommandEntry[] = [
|
|||||||
group: 'runtime',
|
group: 'runtime',
|
||||||
visibility: 'public',
|
visibility: 'public',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'browser',
|
||||||
|
summary: 'Inspect Claude Browser Attach and Codex Browser Tools readiness',
|
||||||
|
group: 'runtime',
|
||||||
|
visibility: 'public',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'copilot',
|
name: 'copilot',
|
||||||
summary: 'Run or manage the GitHub Copilot bridge',
|
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'],
|
config: ['--help', '-h', '--port', '-p', '--host', '-H', '--dev'],
|
||||||
cursor: ['--help', '-h'],
|
cursor: ['--help', '-h'],
|
||||||
doctor: ['--fix', '-f', '--help', '-h'],
|
doctor: ['--fix', '-f', '--help', '-h'],
|
||||||
|
browser: ['status', 'doctor', '--help', '-h'],
|
||||||
docker: ['--help', '-h', '--host'],
|
docker: ['--help', '-h', '--host'],
|
||||||
env: ['--format', '--shell', '--ide', '--help', '-h'],
|
env: ['--format', '--shell', '--ide', '--help', '-h'],
|
||||||
migrate: MIGRATE_FLAGS,
|
migrate: MIGRATE_FLAGS,
|
||||||
|
|||||||
@@ -249,7 +249,7 @@ export async function handleHelpCommand(writeLine: HelpWriter = console.log): Pr
|
|||||||
|
|
||||||
writeLine(header(`CCS CLI v${packageJson.version}`));
|
writeLine(header(`CCS CLI v${packageJson.version}`));
|
||||||
writeLine('');
|
writeLine('');
|
||||||
writeLine(' Claude profile switching, provider routing, and compatible runtime bridges.');
|
writeLine(' Claude profile switching, provider routing, runtime bridges, and browser tooling.');
|
||||||
writeLine('');
|
writeLine('');
|
||||||
|
|
||||||
writeLine(subheader('Usage'));
|
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 profiles', summary: getTopicSummary('profiles') },
|
||||||
{ name: 'ccs help providers', summary: getTopicSummary('providers') },
|
{ name: 'ccs help providers', summary: getTopicSummary('providers') },
|
||||||
|
{ name: 'ccs help browser', summary: getTopicSummary('browser') },
|
||||||
{ name: 'ccs help completion', summary: getTopicSummary('completion') },
|
{ name: 'ccs help completion', summary: getTopicSummary('completion') },
|
||||||
{ name: 'ccs help targets', summary: getTopicSummary('targets') },
|
{ name: 'ccs help targets', summary: getTopicSummary('targets') },
|
||||||
{ name: 'ccs api --help', summary: 'Deep help for API profile lifecycle commands' },
|
{ name: 'ccs api --help', summary: 'Deep help for API profile lifecycle commands' },
|
||||||
@@ -328,6 +329,10 @@ export async function handleHelpRoute(
|
|||||||
await showTargetsHelp(writeLine);
|
await showTargetsHelp(writeLine);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (topic === 'browser') {
|
||||||
|
await (await import('./browser-command')).showBrowserHelp(writeLine);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (topic === 'completion') {
|
if (topic === 'completion') {
|
||||||
const { showShellCompletionHelp } = await import('./shell-completion-command');
|
const { showShellCompletionHelp } = await import('./shell-completion-command');
|
||||||
showShellCompletionHelp(writeLine);
|
showShellCompletionHelp(writeLine);
|
||||||
@@ -342,6 +347,7 @@ export async function handleHelpRoute(
|
|||||||
await new AuthCommands().showHelp();
|
await new AuthCommands().showHelp();
|
||||||
},
|
},
|
||||||
cleanup: async () => (await import('./cleanup-command')).handleCleanupCommand(['--help']),
|
cleanup: async () => (await import('./cleanup-command')).handleCleanupCommand(['--help']),
|
||||||
|
browser: async () => (await import('./browser-command')).showBrowserHelp(writeLine),
|
||||||
cliproxy: async () => (await import('./cliproxy/help-subcommand')).showHelp(),
|
cliproxy: async () => (await import('./cliproxy/help-subcommand')).showHelp(),
|
||||||
copilot: async () =>
|
copilot: async () =>
|
||||||
process.exit(await (await import('./copilot-command')).handleCopilotCommand(['--help'])),
|
process.exit(await (await import('./copilot-command')).handleCopilotCommand(['--help'])),
|
||||||
|
|||||||
@@ -105,6 +105,13 @@ export const ROOT_COMMAND_ROUTES: readonly NamedCommandRoute[] = [
|
|||||||
await handleSyncCommand();
|
await handleSyncCommand();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'browser',
|
||||||
|
handle: async (args) => {
|
||||||
|
const { handleBrowserCommand } = await import('./browser-command');
|
||||||
|
await handleBrowserCommand(args);
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'cleanup',
|
name: 'cleanup',
|
||||||
aliases: ['--cleanup'],
|
aliases: ['--cleanup'],
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
DEFAULT_THINKING_CONFIG,
|
DEFAULT_THINKING_CONFIG,
|
||||||
DEFAULT_OFFICIAL_CHANNELS_CONFIG,
|
DEFAULT_OFFICIAL_CHANNELS_CONFIG,
|
||||||
DEFAULT_DASHBOARD_AUTH_CONFIG,
|
DEFAULT_DASHBOARD_AUTH_CONFIG,
|
||||||
|
DEFAULT_BROWSER_CONFIG,
|
||||||
DEFAULT_IMAGE_ANALYSIS_CONFIG,
|
DEFAULT_IMAGE_ANALYSIS_CONFIG,
|
||||||
DEFAULT_LOGGING_CONFIG,
|
DEFAULT_LOGGING_CONFIG,
|
||||||
} from './unified-config-types';
|
} from './unified-config-types';
|
||||||
@@ -34,6 +35,7 @@ import type {
|
|||||||
OfficialChannelsConfig,
|
OfficialChannelsConfig,
|
||||||
OfficialChannelId,
|
OfficialChannelId,
|
||||||
DashboardAuthConfig,
|
DashboardAuthConfig,
|
||||||
|
BrowserConfig,
|
||||||
ImageAnalysisConfig,
|
ImageAnalysisConfig,
|
||||||
LoggingConfig,
|
LoggingConfig,
|
||||||
CursorConfig,
|
CursorConfig,
|
||||||
@@ -46,6 +48,7 @@ import {
|
|||||||
normalizeOfficialChannelIds,
|
normalizeOfficialChannelIds,
|
||||||
resolveLegacyDiscordSelection,
|
resolveLegacyDiscordSelection,
|
||||||
} from '../channels/official-channels-runtime';
|
} from '../channels/official-channels-runtime';
|
||||||
|
import { getRecommendedBrowserUserDataDir } from '../utils/browser/browser-settings';
|
||||||
import { canonicalizeImageAnalysisConfig } from '../utils/hooks/image-analysis-backend-resolver';
|
import { canonicalizeImageAnalysisConfig } from '../utils/hooks/image-analysis-backend-resolver';
|
||||||
import { normalizeSearxngBaseUrl } from '../utils/websearch/types';
|
import { normalizeSearxngBaseUrl } from '../utils/websearch/types';
|
||||||
|
|
||||||
@@ -54,6 +57,32 @@ const CONFIG_JSON = 'config.json';
|
|||||||
const CONFIG_LOCK = 'config.yaml.lock';
|
const CONFIG_LOCK = 'config.yaml.lock';
|
||||||
const LOCK_STALE_MS = 5000; // Lock is stale after 5 seconds
|
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
|
* Get path to unified config.yaml
|
||||||
*/
|
*/
|
||||||
@@ -592,6 +621,7 @@ function mergeWithDefaults(partial: Partial<UnifiedConfig>): UnifiedConfig {
|
|||||||
partial.dashboard_auth?.session_timeout_hours ??
|
partial.dashboard_auth?.session_timeout_hours ??
|
||||||
DEFAULT_DASHBOARD_AUTH_CONFIG.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 config - enabled by default for CLIProxy providers
|
||||||
image_analysis: canonicalizeImageAnalysisConfig({
|
image_analysis: canonicalizeImageAnalysisConfig({
|
||||||
enabled: partial.image_analysis?.enabled ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.enabled,
|
enabled: partial.image_analysis?.enabled ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.enabled,
|
||||||
@@ -916,6 +946,23 @@ function generateYamlWithComments(config: UnifiedConfig): string {
|
|||||||
lines.push('');
|
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
|
// Image analysis section
|
||||||
if (config.image_analysis) {
|
if (config.image_analysis) {
|
||||||
lines.push('# ----------------------------------------------------------------------------');
|
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.
|
* Get image_analysis configuration.
|
||||||
* Returns defaults if not configured.
|
* Returns defaults if not configured.
|
||||||
|
|||||||
@@ -812,6 +812,40 @@ export const DEFAULT_DASHBOARD_AUTH_CONFIG: DashboardAuthConfig = {
|
|||||||
session_timeout_hours: 24,
|
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.
|
* Image analysis configuration.
|
||||||
* Routes image/PDF files through CLIProxy for vision analysis.
|
* Routes image/PDF files through CLIProxy for vision analysis.
|
||||||
@@ -895,6 +929,8 @@ export interface UnifiedConfig {
|
|||||||
channels?: OfficialChannelsConfig;
|
channels?: OfficialChannelsConfig;
|
||||||
/** Dashboard authentication configuration (optional) */
|
/** Dashboard authentication configuration (optional) */
|
||||||
dashboard_auth?: DashboardAuthConfig;
|
dashboard_auth?: DashboardAuthConfig;
|
||||||
|
/** Browser automation configuration */
|
||||||
|
browser?: BrowserConfig;
|
||||||
/** Image analysis configuration (vision via CLIProxy) */
|
/** Image analysis configuration (vision via CLIProxy) */
|
||||||
image_analysis?: ImageAnalysisConfig;
|
image_analysis?: ImageAnalysisConfig;
|
||||||
}
|
}
|
||||||
@@ -1041,6 +1077,10 @@ export function createEmptyUnifiedConfig(): UnifiedConfig {
|
|||||||
thinking: { ...DEFAULT_THINKING_CONFIG },
|
thinking: { ...DEFAULT_THINKING_CONFIG },
|
||||||
channels: { ...DEFAULT_OFFICIAL_CHANNELS_CONFIG },
|
channels: { ...DEFAULT_OFFICIAL_CHANNELS_CONFIG },
|
||||||
dashboard_auth: { ...DEFAULT_DASHBOARD_AUTH_CONFIG },
|
dashboard_auth: { ...DEFAULT_DASHBOARD_AUTH_CONFIG },
|
||||||
|
browser: {
|
||||||
|
claude: { ...DEFAULT_BROWSER_CONFIG.claude },
|
||||||
|
codex: { ...DEFAULT_BROWSER_CONFIG.codex },
|
||||||
|
},
|
||||||
image_analysis: { ...DEFAULT_IMAGE_ANALYSIS_CONFIG },
|
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 { appendBrowserToolArgs } from './claude-tool-args';
|
||||||
|
|
||||||
|
export {
|
||||||
|
getRecommendedBrowserUserDataDir,
|
||||||
|
getBrowserAttachOverride,
|
||||||
|
getEffectiveClaudeBrowserAttachConfig,
|
||||||
|
} from './browser-settings';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
resolveBrowserRuntimeEnv,
|
resolveBrowserRuntimeEnv,
|
||||||
resolveDefaultChromeUserDataDir,
|
resolveDefaultChromeUserDataDir,
|
||||||
resolveConfiguredBrowserProfileDir,
|
resolveConfiguredBrowserProfileDir,
|
||||||
} from './chrome-reuse';
|
} from './chrome-reuse';
|
||||||
export type { BrowserReuseOptions, BrowserRuntimeEnv } 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 channelsRoutes from './channels-routes';
|
||||||
import websearchRoutes from './websearch-routes';
|
import websearchRoutes from './websearch-routes';
|
||||||
import imageAnalysisRoutes from './image-analysis-routes';
|
import imageAnalysisRoutes from './image-analysis-routes';
|
||||||
|
import browserRoutes from './browser-routes';
|
||||||
import cliproxyAuthRoutes from './cliproxy-auth-routes';
|
import cliproxyAuthRoutes from './cliproxy-auth-routes';
|
||||||
import cliproxyStatsRoutes from './cliproxy-stats-routes';
|
import cliproxyStatsRoutes from './cliproxy-stats-routes';
|
||||||
import cliproxyRoutingRoutes from './cliproxy-routing-routes';
|
import cliproxyRoutingRoutes from './cliproxy-routing-routes';
|
||||||
@@ -97,6 +98,7 @@ apiRoutes.use('/cliproxy/openai-compat', providerRoutes);
|
|||||||
|
|
||||||
// ==================== WebSearch ====================
|
// ==================== WebSearch ====================
|
||||||
apiRoutes.use('/websearch', websearchRoutes);
|
apiRoutes.use('/websearch', websearchRoutes);
|
||||||
|
apiRoutes.use('/browser', browserRoutes);
|
||||||
apiRoutes.use('/image-analysis', imageAnalysisRoutes);
|
apiRoutes.use('/image-analysis', imageAnalysisRoutes);
|
||||||
|
|
||||||
// ==================== Copilot ====================
|
// ==================== 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',
|
'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',
|
'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',
|
'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: [
|
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(visibleLines.length).toBeLessThanOrEqual(90);
|
||||||
expect(rendered.includes('ccs help <topic>')).toBe(true);
|
expect(rendered.includes('ccs help <topic>')).toBe(true);
|
||||||
|
expect(rendered.includes('ccs help browser')).toBe(true);
|
||||||
expect(rendered.includes('ccs help completion')).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);
|
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 () => {
|
test('completion topic documents install and verification paths', async () => {
|
||||||
const rendered = await renderLines((writeLine) => handleHelpRoute(['completion'], writeLine));
|
const rendered = await renderLines((writeLine) => handleHelpRoute(['completion'], writeLine));
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { spawnSync } from 'child_process';
|
|||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
import { mutateUnifiedConfig } from '../../../src/config/unified-config-loader';
|
||||||
|
|
||||||
interface RunResult {
|
interface RunResult {
|
||||||
status: number | null;
|
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', () => {
|
it('keeps browser MCP runtime overrides when CCS_THINKING is ignored for native Codex default mode', () => {
|
||||||
if (process.platform === 'win32') return;
|
if (process.platform === 'win32') return;
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { spawn, spawnSync, type ChildProcess } from 'child_process';
|
|||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
import { mutateUnifiedConfig } from '../../../src/config/unified-config-loader';
|
||||||
|
|
||||||
const BROWSER_PROMPT_SNIPPET = 'prefer the CCS MCP Browser tool';
|
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(`httpUrl=http://127.0.0.1:${port}`);
|
||||||
expect(launchedEnv).toContain('wsUrl=ws://127.0.0.1/devtools/browser/default-target');
|
expect(launchedEnv).toContain('wsUrl=ws://127.0.0.1/devtools/browser/default-target');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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 fs from 'fs';
|
||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
import { mutateUnifiedConfig } from '../../../src/config/unified-config-loader';
|
||||||
|
|
||||||
const BROWSER_PROMPT_SNIPPET = 'prefer the CCS MCP Browser tool';
|
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(`httpUrl=http://127.0.0.1:${port}`);
|
||||||
expect(launchedEnv).toContain('wsUrl=ws://127.0.0.1/devtools/browser/browser-target');
|
expect(launchedEnv).toContain('wsUrl=ws://127.0.0.1/devtools/browser/browser-target');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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.
|
Export <code>CLIPROXY_API_KEY</code> before launching native Codex.
|
||||||
</li>
|
</li>
|
||||||
</ol>
|
</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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ const EMPTY_MCP_SERVER_DRAFT: CodexMcpServerEntry = {
|
|||||||
toolTimeoutSec: null,
|
toolTimeoutSec: null,
|
||||||
enabledTools: [],
|
enabledTools: [],
|
||||||
disabledTools: [],
|
disabledTools: [],
|
||||||
|
isCcsManaged: false,
|
||||||
|
managementSurface: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
function toCsv(value: string[]) {
|
function toCsv(value: string[]) {
|
||||||
@@ -70,9 +72,16 @@ function McpServerEditor({
|
|||||||
}: McpServerEditorProps) {
|
}: McpServerEditorProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [draft, setDraft] = useState<CodexMcpServerEntry>(initialDraft);
|
const [draft, setDraft] = useState<CodexMcpServerEntry>(initialDraft);
|
||||||
|
const reservedManagedBrowserDraft = isNew && draft.name.trim() === 'ccs_browser';
|
||||||
|
|
||||||
return (
|
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">
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
<Input
|
<Input
|
||||||
value={draft.name}
|
value={draft.name}
|
||||||
@@ -216,7 +225,9 @@ function McpServerEditor({
|
|||||||
disabledTools: draft.disabledTools,
|
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}
|
{saving ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
|
||||||
{/* TODO i18n: missing key codex.saveMcpServer */}
|
{/* TODO i18n: missing key codex.saveMcpServer */}
|
||||||
@@ -244,6 +255,8 @@ export function CodexMcpServersCard({
|
|||||||
);
|
);
|
||||||
const draftSeed = selectedEntry ?? EMPTY_MCP_SERVER_DRAFT;
|
const draftSeed = selectedEntry ?? EMPTY_MCP_SERVER_DRAFT;
|
||||||
const draftKey = JSON.stringify(draftSeed);
|
const draftKey = JSON.stringify(draftSeed);
|
||||||
|
const selectedEntryIsManagedBrowser =
|
||||||
|
selectedEntry?.isCcsManaged && selectedEntry.managementSurface === 'browser-settings';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CodexConfigCardShell
|
<CodexConfigCardShell
|
||||||
@@ -255,6 +268,12 @@ export function CodexMcpServersCard({
|
|||||||
description="Manage the safe MCP transport fields. Keep auth headers and bearer tokens in raw TOML."
|
description="Manage the safe MCP transport fields. Keep auth headers and bearer tokens in raw TOML."
|
||||||
disabledReason={disabledReason}
|
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}>
|
<Select value={selectedName} onValueChange={setSelectedName} disabled={disabled}>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
{/* TODO i18n: missing key codex.selectMcpServer */}
|
{/* TODO i18n: missing key codex.selectMcpServer */}
|
||||||
@@ -264,7 +283,7 @@ export function CodexMcpServersCard({
|
|||||||
<SelectItem value="new">{t('codex.createNewMcpServer')}</SelectItem>
|
<SelectItem value="new">{t('codex.createNewMcpServer')}</SelectItem>
|
||||||
{entries.map((entry) => (
|
{entries.map((entry) => (
|
||||||
<SelectItem key={entry.name} value={entry.name}>
|
<SelectItem key={entry.name} value={entry.name}>
|
||||||
{entry.name}
|
{entry.isCcsManaged ? `${entry.name} (CCS managed)` : entry.name}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
@@ -273,9 +292,9 @@ export function CodexMcpServersCard({
|
|||||||
key={draftKey}
|
key={draftKey}
|
||||||
initialDraft={draftSeed}
|
initialDraft={draftSeed}
|
||||||
isNew={selectedName === 'new'}
|
isNew={selectedName === 'new'}
|
||||||
disabled={disabled}
|
disabled={disabled || Boolean(selectedEntryIsManagedBrowser)}
|
||||||
saving={saving}
|
saving={saving}
|
||||||
canDelete={selectedEntry !== null}
|
canDelete={selectedEntry !== null && !selectedEntryIsManagedBrowser}
|
||||||
onDelete={async () => {
|
onDelete={async () => {
|
||||||
if (!selectedEntry) return;
|
if (!selectedEntry) return;
|
||||||
await onDelete(selectedEntry.name);
|
await onDelete(selectedEntry.name);
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import type {
|
|||||||
UpsertAiProviderEntryInput,
|
UpsertAiProviderEntryInput,
|
||||||
} from '../../../src/cliproxy/ai-providers';
|
} from '../../../src/cliproxy/ai-providers';
|
||||||
import type { ProviderEntitlementEvidence } from '../../../src/cliproxy/provider-entitlement-types';
|
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_BASE_URL = '/api';
|
||||||
export const API_CONFLICT_ERROR_CODE = 'CONFLICT';
|
export const API_CONFLICT_ERROR_CODE = 'CONFLICT';
|
||||||
@@ -233,6 +234,67 @@ export interface UpdateImageAnalysisSettingsPayload {
|
|||||||
profileBackends?: Record<string, string>;
|
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 {
|
export interface Profile {
|
||||||
name: string;
|
name: string;
|
||||||
settingsPath: string;
|
settingsPath: string;
|
||||||
@@ -1090,6 +1152,15 @@ export const api = {
|
|||||||
body: JSON.stringify(data),
|
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: {
|
logs: {
|
||||||
getConfig: () => request<{ logging: LogsConfig }>('/logs/config'),
|
getConfig: () => request<{ logging: LogsConfig }>('/logs/config'),
|
||||||
updateConfig: (data: UpdateLogsConfigPayload) =>
|
updateConfig: (data: UpdateLogsConfigPayload) =>
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ export interface CodexMcpServerEntry {
|
|||||||
toolTimeoutSec: number | null;
|
toolTimeoutSec: number | null;
|
||||||
enabledTools: string[];
|
enabledTools: string[];
|
||||||
disabledTools: string[];
|
disabledTools: string[];
|
||||||
|
isCcsManaged: boolean;
|
||||||
|
managementSurface: 'browser-settings' | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CodexFeatureCatalogEntry {
|
export interface CodexFeatureCatalogEntry {
|
||||||
@@ -239,6 +241,8 @@ export function readCodexMcpServers(config: Record<string, unknown> | null): Cod
|
|||||||
toolTimeoutSec: asNumber(server.tool_timeout_sec),
|
toolTimeoutSec: asNumber(server.tool_timeout_sec),
|
||||||
enabledTools: asStringArray(server.enabled_tools),
|
enabledTools: asStringArray(server.enabled_tools),
|
||||||
disabledTools: asStringArray(server.disabled_tools),
|
disabledTools: asStringArray(server.disabled_tools),
|
||||||
|
isCcsManaged: name === 'ccs_browser',
|
||||||
|
managementSurface: name === 'ccs_browser' ? 'browser-settings' : null,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.filter((entry): entry is CodexMcpServerEntry => entry !== null)
|
.filter((entry): entry is CodexMcpServerEntry => entry !== null)
|
||||||
|
|||||||
@@ -873,6 +873,7 @@ const resources = {
|
|||||||
configFileNotFound: 'Config file not found',
|
configFileNotFound: 'Config file not found',
|
||||||
},
|
},
|
||||||
settingsTabs: {
|
settingsTabs: {
|
||||||
|
browser: 'Browser',
|
||||||
web: 'Web',
|
web: 'Web',
|
||||||
image: 'Image',
|
image: 'Image',
|
||||||
channels: 'Channels',
|
channels: 'Channels',
|
||||||
@@ -2424,6 +2425,61 @@ const resources = {
|
|||||||
description: 'Configure image analysis settings.',
|
description: 'Configure image analysis settings.',
|
||||||
loading: 'Loading image 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: {
|
codexPage: {
|
||||||
title: 'Codex',
|
title: 'Codex',
|
||||||
@@ -3284,6 +3340,7 @@ const resources = {
|
|||||||
configFileNotFound: '未找到配置文件',
|
configFileNotFound: '未找到配置文件',
|
||||||
},
|
},
|
||||||
settingsTabs: {
|
settingsTabs: {
|
||||||
|
browser: '浏览器',
|
||||||
web: '网页',
|
web: '网页',
|
||||||
image: '图片',
|
image: '图片',
|
||||||
channels: '频道',
|
channels: '频道',
|
||||||
@@ -4781,6 +4838,55 @@ const resources = {
|
|||||||
description: '配置图片分析设置。',
|
description: '配置图片分析设置。',
|
||||||
loading: '加载图片设置中...',
|
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: {
|
codexPage: {
|
||||||
title: 'Codex',
|
title: 'Codex',
|
||||||
@@ -5716,6 +5822,7 @@ const resources = {
|
|||||||
configFileNotFound: 'Không tìm thấy tập tin cấu hình',
|
configFileNotFound: 'Không tìm thấy tập tin cấu hình',
|
||||||
},
|
},
|
||||||
settingsTabs: {
|
settingsTabs: {
|
||||||
|
browser: 'Trình duyệt',
|
||||||
web: 'Web',
|
web: 'Web',
|
||||||
image: 'Hình ảnh',
|
image: 'Hình ảnh',
|
||||||
channels: 'Kê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.',
|
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...',
|
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: {
|
codexPage: {
|
||||||
title: 'Codex',
|
title: 'Codex',
|
||||||
@@ -8180,6 +8343,7 @@ const resources = {
|
|||||||
configFileNotFound: '設定ファイルが見つかりません',
|
configFileNotFound: '設定ファイルが見つかりません',
|
||||||
},
|
},
|
||||||
settingsTabs: {
|
settingsTabs: {
|
||||||
|
browser: 'ブラウザ',
|
||||||
web: 'Web検索',
|
web: 'Web検索',
|
||||||
image: '画像',
|
image: '画像',
|
||||||
channels: 'チャンネル',
|
channels: 'チャンネル',
|
||||||
@@ -9611,6 +9775,62 @@ const resources = {
|
|||||||
description: '画像分析の設定。',
|
description: '画像分析の設定。',
|
||||||
loading: '画像設定を読み込み中...',
|
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: {
|
setupWizard: {
|
||||||
title: 'クイックセットアップウィザード',
|
title: 'クイックセットアップウィザード',
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
Brain,
|
Brain,
|
||||||
Archive,
|
Archive,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
|
Monitor,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import type { SettingsTab } from '../types';
|
import type { SettingsTab } from '../types';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
@@ -25,6 +26,7 @@ interface TabNavigationProps {
|
|||||||
export function TabNavigation({ activeTab, onTabChange }: TabNavigationProps) {
|
export function TabNavigation({ activeTab, onTabChange }: TabNavigationProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const tabs = [
|
const tabs = [
|
||||||
|
{ value: 'browser' as const, label: t('settingsTabs.browser'), icon: Monitor },
|
||||||
{ value: 'websearch' as const, label: t('settingsTabs.web'), icon: Globe },
|
{ value: 'websearch' as const, label: t('settingsTabs.web'), icon: Globe },
|
||||||
{ value: 'image' as const, label: t('settingsTabs.image'), icon: ImageIcon },
|
{ value: 'image' as const, label: t('settingsTabs.image'), icon: ImageIcon },
|
||||||
{ value: 'channels' as const, label: t('settingsTabs.channels'), icon: MessageSquare },
|
{ value: 'channels' as const, label: t('settingsTabs.channels'), icon: MessageSquare },
|
||||||
@@ -37,7 +39,7 @@ export function TabNavigation({ activeTab, onTabChange }: TabNavigationProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Tabs value={activeTab} onValueChange={(v) => onTabChange(v as SettingsTab)}>
|
<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 }) => (
|
{tabs.map(({ value, label, icon: Icon }) => (
|
||||||
<TabsTrigger key={value} value={value} className="gap-1.5 px-2 text-xs">
|
<TabsTrigger key={value} value={value} className="gap-1.5 px-2 text-xs">
|
||||||
<Icon className="h-3.5 w-3.5 shrink-0" />
|
<Icon className="h-3.5 w-3.5 shrink-0" />
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
useBrowserConfig,
|
||||||
useSettingsContext,
|
useSettingsContext,
|
||||||
useSettingsActions,
|
useSettingsActions,
|
||||||
useSettingsTab,
|
useSettingsTab,
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
import { useCallback, useContext, useMemo } from 'react';
|
import { useCallback, useContext, useMemo } from 'react';
|
||||||
import { SettingsContext } from '../settings-context';
|
import { SettingsContext } from '../settings-context';
|
||||||
import type {
|
import type {
|
||||||
|
BrowserConfig,
|
||||||
|
BrowserStatus,
|
||||||
WebSearchConfig,
|
WebSearchConfig,
|
||||||
GlobalEnvConfig,
|
GlobalEnvConfig,
|
||||||
CliproxyServerConfig,
|
CliproxyServerConfig,
|
||||||
@@ -24,6 +26,41 @@ export function useSettingsContext() {
|
|||||||
export function useSettingsActions() {
|
export function useSettingsActions() {
|
||||||
const { dispatch } = useSettingsContext();
|
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(
|
const setWebSearchConfig = useCallback(
|
||||||
(config: WebSearchConfig | null) => dispatch({ type: 'SET_WEBSEARCH_CONFIG', payload: config }),
|
(config: WebSearchConfig | null) => dispatch({ type: 'SET_WEBSEARCH_CONFIG', payload: config }),
|
||||||
[dispatch]
|
[dispatch]
|
||||||
@@ -133,6 +170,13 @@ export function useSettingsActions() {
|
|||||||
|
|
||||||
return useMemo(
|
return useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
|
setBrowserConfig,
|
||||||
|
setBrowserStatus,
|
||||||
|
setBrowserLoading,
|
||||||
|
setBrowserStatusLoading,
|
||||||
|
setBrowserSaving,
|
||||||
|
setBrowserError,
|
||||||
|
setBrowserSuccess,
|
||||||
setWebSearchConfig,
|
setWebSearchConfig,
|
||||||
setWebSearchStatus,
|
setWebSearchStatus,
|
||||||
setWebSearchLoading,
|
setWebSearchLoading,
|
||||||
@@ -156,6 +200,13 @@ export function useSettingsActions() {
|
|||||||
setRawConfigLoading,
|
setRawConfigLoading,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
|
setBrowserConfig,
|
||||||
|
setBrowserStatus,
|
||||||
|
setBrowserLoading,
|
||||||
|
setBrowserStatusLoading,
|
||||||
|
setBrowserSaving,
|
||||||
|
setBrowserError,
|
||||||
|
setBrowserSuccess,
|
||||||
setWebSearchConfig,
|
setWebSearchConfig,
|
||||||
setWebSearchStatus,
|
setWebSearchStatus,
|
||||||
setWebSearchLoading,
|
setWebSearchLoading,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
export { useSettingsContext, useSettingsActions } from './context-hooks';
|
export { useSettingsContext, useSettingsActions } from './context-hooks';
|
||||||
export { useSettingsTab } from './use-settings-tab';
|
export { useSettingsTab } from './use-settings-tab';
|
||||||
|
export { useBrowserConfig } from './use-browser-config';
|
||||||
export { useWebSearchConfig } from './use-websearch-config';
|
export { useWebSearchConfig } from './use-websearch-config';
|
||||||
export { useGlobalEnvConfig } from './use-globalenv-config';
|
export { useGlobalEnvConfig } from './use-globalenv-config';
|
||||||
export { useProxyConfig } from './use-proxy-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,
|
type ComponentType,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels';
|
import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels';
|
||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { RefreshCw, FileCode, Copy, Check, GripVertical, AlertCircle } from 'lucide-react';
|
import { RefreshCw, FileCode, Copy, Check, GripVertical, AlertCircle } from 'lucide-react';
|
||||||
import { CodeEditor } from '@/components/shared/code-editor';
|
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
|
// Lazy-loaded sections with retry capability
|
||||||
const WebSearchSection = lazyWithRetry(() => import('./sections/websearch'));
|
const WebSearchSection = lazyWithRetry(() => import('./sections/websearch'));
|
||||||
|
const BrowserSection = lazyWithRetry(() => import('./sections/browser'));
|
||||||
const ImageAnalysisSection = lazyWithRetry(() => import('./sections/image-analysis'));
|
const ImageAnalysisSection = lazyWithRetry(() => import('./sections/image-analysis'));
|
||||||
const ChannelsSection = lazyWithRetry(() => import('./sections/channels'));
|
const ChannelsSection = lazyWithRetry(() => import('./sections/channels'));
|
||||||
const GlobalEnvSection = lazyWithRetry(() => import('./sections/globalenv-section'));
|
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
|
// Inner component that uses context
|
||||||
function SettingsPageInner() {
|
function SettingsPageInner() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { activeTab, setActiveTab } = useSettingsTab();
|
const { setActiveTab } = useSettingsTab();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const activeTab = resolveSettingsTab(searchParams.get('tab'));
|
||||||
const {
|
const {
|
||||||
rawConfig,
|
rawConfig,
|
||||||
loading: rawConfigLoading,
|
loading: rawConfigLoading,
|
||||||
@@ -131,6 +159,7 @@ function SettingsPageInner() {
|
|||||||
</div>
|
</div>
|
||||||
<SectionErrorBoundary>
|
<SectionErrorBoundary>
|
||||||
<Suspense fallback={<SectionSkeleton />}>
|
<Suspense fallback={<SectionSkeleton />}>
|
||||||
|
{activeTab === 'browser' && <BrowserSection />}
|
||||||
{activeTab === 'websearch' && <WebSearchSection />}
|
{activeTab === 'websearch' && <WebSearchSection />}
|
||||||
{activeTab === 'image' && <ImageAnalysisSection />}
|
{activeTab === 'image' && <ImageAnalysisSection />}
|
||||||
{activeTab === 'channels' && <ChannelsSection />}
|
{activeTab === 'channels' && <ChannelsSection />}
|
||||||
@@ -156,6 +185,7 @@ function SettingsPageInner() {
|
|||||||
{/* Tab Content */}
|
{/* Tab Content */}
|
||||||
<SectionErrorBoundary>
|
<SectionErrorBoundary>
|
||||||
<Suspense fallback={<SectionSkeleton />}>
|
<Suspense fallback={<SectionSkeleton />}>
|
||||||
|
{activeTab === 'browser' && <BrowserSection />}
|
||||||
{activeTab === 'websearch' && <WebSearchSection />}
|
{activeTab === 'websearch' && <WebSearchSection />}
|
||||||
{activeTab === 'image' && <ImageAnalysisSection />}
|
{activeTab === 'image' && <ImageAnalysisSection />}
|
||||||
{activeTab === 'channels' && <ChannelsSection />}
|
{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 { createContext, type Dispatch } from 'react';
|
||||||
import type {
|
import type {
|
||||||
|
BrowserConfig,
|
||||||
|
BrowserStatus,
|
||||||
WebSearchConfig,
|
WebSearchConfig,
|
||||||
GlobalEnvConfig,
|
GlobalEnvConfig,
|
||||||
CliproxyServerConfig,
|
CliproxyServerConfig,
|
||||||
@@ -15,6 +17,14 @@ import type {
|
|||||||
// === State ===
|
// === State ===
|
||||||
|
|
||||||
export interface SettingsState {
|
export interface SettingsState {
|
||||||
|
// Browser state
|
||||||
|
browserConfig: BrowserConfig | null;
|
||||||
|
browserStatus: BrowserStatus | null;
|
||||||
|
browserLoading: boolean;
|
||||||
|
browserStatusLoading: boolean;
|
||||||
|
browserSaving: boolean;
|
||||||
|
browserError: string | null;
|
||||||
|
browserSuccess: boolean;
|
||||||
// WebSearch state
|
// WebSearch state
|
||||||
webSearchConfig: WebSearchConfig | null;
|
webSearchConfig: WebSearchConfig | null;
|
||||||
webSearchStatus: WebSearchStatus | null;
|
webSearchStatus: WebSearchStatus | null;
|
||||||
@@ -43,6 +53,13 @@ export interface SettingsState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const initialSettingsState: SettingsState = {
|
export const initialSettingsState: SettingsState = {
|
||||||
|
browserConfig: null,
|
||||||
|
browserStatus: null,
|
||||||
|
browserLoading: true,
|
||||||
|
browserStatusLoading: true,
|
||||||
|
browserSaving: false,
|
||||||
|
browserError: null,
|
||||||
|
browserSuccess: false,
|
||||||
webSearchConfig: null,
|
webSearchConfig: null,
|
||||||
webSearchStatus: null,
|
webSearchStatus: null,
|
||||||
webSearchLoading: true,
|
webSearchLoading: true,
|
||||||
@@ -69,6 +86,13 @@ export const initialSettingsState: SettingsState = {
|
|||||||
// === Actions ===
|
// === Actions ===
|
||||||
|
|
||||||
export type SettingsAction =
|
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_CONFIG'; payload: WebSearchConfig | null }
|
||||||
| { type: 'SET_WEBSEARCH_STATUS'; payload: WebSearchStatus | null }
|
| { type: 'SET_WEBSEARCH_STATUS'; payload: WebSearchStatus | null }
|
||||||
| { type: 'SET_WEBSEARCH_LOADING'; payload: boolean }
|
| { type: 'SET_WEBSEARCH_LOADING'; payload: boolean }
|
||||||
@@ -93,6 +117,20 @@ export type SettingsAction =
|
|||||||
|
|
||||||
export function settingsReducer(state: SettingsState, action: SettingsAction): SettingsState {
|
export function settingsReducer(state: SettingsState, action: SettingsAction): SettingsState {
|
||||||
switch (action.type) {
|
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':
|
case 'SET_WEBSEARCH_CONFIG':
|
||||||
return { ...state, webSearchConfig: action.payload };
|
return { ...state, webSearchConfig: action.payload };
|
||||||
case 'SET_WEBSEARCH_STATUS':
|
case 'SET_WEBSEARCH_STATUS':
|
||||||
|
|||||||
@@ -3,7 +3,13 @@
|
|||||||
* Type definitions for WebSearch, GlobalEnv, and Proxy configurations
|
* 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 ===
|
// === WebSearch Types ===
|
||||||
|
|
||||||
@@ -162,6 +168,7 @@ export interface OfficialChannelsStatus {
|
|||||||
// === Tab Types ===
|
// === Tab Types ===
|
||||||
|
|
||||||
export type SettingsTab =
|
export type SettingsTab =
|
||||||
|
| 'browser'
|
||||||
| 'websearch'
|
| 'websearch'
|
||||||
| 'image'
|
| 'image'
|
||||||
| 'channels'
|
| 'channels'
|
||||||
@@ -192,3 +199,6 @@ export interface ThinkingConfig {
|
|||||||
// === Re-exports from api-client ===
|
// === Re-exports from api-client ===
|
||||||
|
|
||||||
export type { CliproxyServerConfig, RemoteProxyStatus };
|
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>,
|
default: () => <div>WebSearch Section</div>,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/pages/settings/sections/browser', () => ({
|
||||||
|
default: () => <div>Browser Section</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock('@/pages/settings/sections/channels', () => ({
|
vi.mock('@/pages/settings/sections/channels', () => ({
|
||||||
default: () => <div>Channels Section</div>,
|
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 () => {
|
it('keeps the current config editor visible while raw config refreshes', async () => {
|
||||||
|
window.history.pushState({}, '', '/settings');
|
||||||
mocks.useRawConfig.mockReturnValue({
|
mocks.useRawConfig.mockReturnValue({
|
||||||
rawConfig: 'websearch:\n enabled: true\n',
|
rawConfig: 'websearch:\n enabled: true\n',
|
||||||
loading: true,
|
loading: true,
|
||||||
@@ -84,4 +89,19 @@ describe('SettingsPage raw config panel', () => {
|
|||||||
expect(screen.getByLabelText('config editor')).toHaveValue('websearch:\n enabled: true\n');
|
expect(screen.getByLabelText('config editor')).toHaveValue('websearch:\n enabled: true\n');
|
||||||
expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
|
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