mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-15 12:13:59 +00:00
Merge pull request #1061 from walker1211/feat/browser-mcp-hover-phase11e
feat(browser): complete Browser MCP automation stack
This commit is contained in:
38 files changed
+15698
-1316
No files matched your search
+5345
-42
File diff suppressed because it is too large.
Load diff
@@ -45,7 +45,9 @@ echo "[i] Pre-push CI parity gate"
|
|||||||
echo " branch: $CURRENT_BRANCH"
|
echo " branch: $CURRENT_BRANCH"
|
||||||
echo " base: $BASE_BRANCH"
|
echo " base: $BASE_BRANCH"
|
||||||
|
|
||||||
git fetch origin "$BASE_BRANCH" --quiet || true
|
if git ls-remote --exit-code --heads origin "$BASE_BRANCH" >/dev/null 2>&1; then
|
||||||
|
git fetch origin "$BASE_BRANCH" --quiet
|
||||||
|
fi
|
||||||
if git show-ref --verify --quiet "refs/remotes/origin/$BASE_BRANCH"; then
|
if git show-ref --verify --quiet "refs/remotes/origin/$BASE_BRANCH"; then
|
||||||
if ! git merge-base --is-ancestor "origin/$BASE_BRANCH" HEAD; then
|
if ! git merge-base --is-ancestor "origin/$BASE_BRANCH" HEAD; then
|
||||||
echo "[X] Branch '$CURRENT_BRANCH' is behind origin/$BASE_BRANCH."
|
echo "[X] Branch '$CURRENT_BRANCH' is behind origin/$BASE_BRANCH."
|
||||||
|
|||||||
@@ -22,7 +22,12 @@ const slowTests = [
|
|||||||
'tests/integration/cursor-daemon-lifecycle.test.ts',
|
'tests/integration/cursor-daemon-lifecycle.test.ts',
|
||||||
'tests/integration/proxy/daemon-lifecycle.test.ts',
|
'tests/integration/proxy/daemon-lifecycle.test.ts',
|
||||||
'tests/unit/commands/persist-command-handler.test.ts',
|
'tests/unit/commands/persist-command-handler.test.ts',
|
||||||
'tests/unit/hooks/ccs-browser-mcp-server.test.ts',
|
'tests/unit/hooks/browser-mcp-advanced-interactions.test.ts',
|
||||||
|
'tests/unit/hooks/browser-mcp-downloads-and-files.test.ts',
|
||||||
|
'tests/unit/hooks/browser-mcp-navigation-and-query.test.ts',
|
||||||
|
'tests/unit/hooks/browser-mcp-orchestration-and-artifacts.test.ts',
|
||||||
|
'tests/unit/hooks/browser-mcp-recording-and-replay.test.ts',
|
||||||
|
'tests/unit/hooks/browser-mcp-session-and-intercepts.test.ts',
|
||||||
'tests/unit/targets/codex-runtime-integration.test.ts',
|
'tests/unit/targets/codex-runtime-integration.test.ts',
|
||||||
'tests/unit/targets/codex-settings-bridge-launch.test.ts',
|
'tests/unit/targets/codex-settings-bridge-launch.test.ts',
|
||||||
'tests/unit/targets/droid-command-routing-integration.test.ts',
|
'tests/unit/targets/droid-command-routing-integration.test.ts',
|
||||||
|
|||||||
+8
-4
@@ -80,7 +80,7 @@ import { handleError, runCleanup } from './errors';
|
|||||||
import { tryHandleRootCommand } from './commands/root-command-router';
|
import { tryHandleRootCommand } from './commands/root-command-router';
|
||||||
|
|
||||||
// Import extracted utility functions
|
// Import extracted utility functions
|
||||||
import { execClaude, stripAnthropicRoutingEnv } from './utils/shell-executor';
|
import { execClaude, stripAnthropicRoutingEnv, stripBrowserEnv } from './utils/shell-executor';
|
||||||
import { isDeprecatedGlmtProfileName, normalizeDeprecatedGlmtEnv } from './utils/glmt-deprecation';
|
import { isDeprecatedGlmtProfileName, normalizeDeprecatedGlmtEnv } from './utils/glmt-deprecation';
|
||||||
import { maybeWarnAboutResumeLaneMismatch } from './auth/resume-lane-warning';
|
import { maybeWarnAboutResumeLaneMismatch } from './auth/resume-lane-warning';
|
||||||
import { createLogger } from './services/logging';
|
import { createLogger } from './services/logging';
|
||||||
@@ -1329,6 +1329,10 @@ async function main(): Promise<void> {
|
|||||||
...imageAnalysisEnv,
|
...imageAnalysisEnv,
|
||||||
CCS_CURRENT_PROVIDER: '',
|
CCS_CURRENT_PROVIDER: '',
|
||||||
CCS_IMAGE_ANALYSIS_SKIP: '1',
|
CCS_IMAGE_ANALYSIS_SKIP: '1',
|
||||||
|
CCS_IMAGE_ANALYSIS_RUNTIME_PATH: '',
|
||||||
|
CCS_IMAGE_ANALYSIS_RUNTIME_BASE_URL: '',
|
||||||
|
CCS_IMAGE_ANALYSIS_RUNTIME_API_KEY: '',
|
||||||
|
CCS_IMAGE_ANALYSIS_RUNTIME_ALLOW_SELF_SIGNED: '0',
|
||||||
};
|
};
|
||||||
} else if (imageAnalysisStatus.proxyReadiness === 'stopped') {
|
} else if (imageAnalysisStatus.proxyReadiness === 'stopped') {
|
||||||
const ensureServiceResult = await ensureCliproxyService(
|
const ensureServiceResult = await ensureCliproxyService(
|
||||||
@@ -1363,8 +1367,9 @@ async function main(): Promise<void> {
|
|||||||
// env free of ANTHROPIC routing/auth while preserving non-routing profile
|
// env free of ANTHROPIC routing/auth while preserving non-routing profile
|
||||||
// env so nested Team/subagent sessions can still inherit model intent and
|
// env so nested Team/subagent sessions can still inherit model intent and
|
||||||
// other profile-scoped runtime flags.
|
// other profile-scoped runtime flags.
|
||||||
|
const settingsRuntimeEnv = stripBrowserEnv({ ...globalEnv, ...settingsEnv });
|
||||||
const claudeRuntimeEnvVars: NodeJS.ProcessEnv = {
|
const claudeRuntimeEnvVars: NodeJS.ProcessEnv = {
|
||||||
...stripAnthropicRoutingEnv({ ...globalEnv, ...settingsEnv }),
|
...stripAnthropicRoutingEnv(settingsRuntimeEnv),
|
||||||
...(inheritedClaudeConfigDir ? { CLAUDE_CONFIG_DIR: inheritedClaudeConfigDir } : {}),
|
...(inheritedClaudeConfigDir ? { CLAUDE_CONFIG_DIR: inheritedClaudeConfigDir } : {}),
|
||||||
...webSearchEnv,
|
...webSearchEnv,
|
||||||
...imageAnalysisEnv,
|
...imageAnalysisEnv,
|
||||||
@@ -1375,8 +1380,7 @@ async function main(): Promise<void> {
|
|||||||
|
|
||||||
// Non-Claude targets still need effective credentials injected directly.
|
// Non-Claude targets still need effective credentials injected directly.
|
||||||
const envVars: NodeJS.ProcessEnv = {
|
const envVars: NodeJS.ProcessEnv = {
|
||||||
...globalEnv,
|
...settingsRuntimeEnv,
|
||||||
...settingsEnv,
|
|
||||||
...(inheritedClaudeConfigDir ? { CLAUDE_CONFIG_DIR: inheritedClaudeConfigDir } : {}),
|
...(inheritedClaudeConfigDir ? { CLAUDE_CONFIG_DIR: inheritedClaudeConfigDir } : {}),
|
||||||
...webSearchEnv,
|
...webSearchEnv,
|
||||||
...imageAnalysisEnv,
|
...imageAnalysisEnv,
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ import {
|
|||||||
import { resolveImageAnalysisRuntimeStatus } from '../../utils/hooks/image-analysis-runtime-status';
|
import { resolveImageAnalysisRuntimeStatus } from '../../utils/hooks/image-analysis-runtime-status';
|
||||||
import { hasImageAnalysisProfileHook } from '../../utils/hooks/image-analyzer-profile-hook-injector';
|
import { hasImageAnalysisProfileHook } from '../../utils/hooks/image-analyzer-profile-hook-injector';
|
||||||
import { hasImageAnalyzerHook } from '../../utils/hooks/image-analyzer-hook-installer';
|
import { hasImageAnalyzerHook } from '../../utils/hooks/image-analyzer-hook-installer';
|
||||||
import { stripClaudeCodeEnv } from '../../utils/shell-executor';
|
import { stripBrowserEnv, stripClaudeCodeEnv } from '../../utils/shell-executor';
|
||||||
import { CodexReasoningProxy } from '../codex-reasoning-proxy';
|
import { CodexReasoningProxy } from '../codex-reasoning-proxy';
|
||||||
import { ToolSanitizationProxy } from '../tool-sanitization-proxy';
|
import { ToolSanitizationProxy } from '../tool-sanitization-proxy';
|
||||||
import { HttpsTunnelProxy } from '../https-tunnel-proxy';
|
import { HttpsTunnelProxy } from '../https-tunnel-proxy';
|
||||||
@@ -383,12 +383,17 @@ export function buildClaudeEnvironment(config: ProxyChainConfig): Record<string,
|
|||||||
const imageAnalysisEnv = resolvedImageAnalysisEnv ?? getImageAnalysisHookEnv(provider);
|
const imageAnalysisEnv = resolvedImageAnalysisEnv ?? getImageAnalysisHookEnv(provider);
|
||||||
|
|
||||||
// Merge all environment variables (filter undefined values)
|
// Merge all environment variables (filter undefined values)
|
||||||
const baseEnv = Object.fromEntries(
|
const baseEnv = stripBrowserEnv(
|
||||||
Object.entries(process.env).filter(([, v]) => v !== undefined)
|
Object.fromEntries(Object.entries(process.env).filter(([, v]) => v !== undefined)) as Record<
|
||||||
|
string,
|
||||||
|
string
|
||||||
|
>
|
||||||
) as Record<string, string>;
|
) as Record<string, string>;
|
||||||
|
|
||||||
const effectiveEnvVarsFiltered = Object.fromEntries(
|
const effectiveEnvVarsFiltered = stripBrowserEnv(
|
||||||
Object.entries(effectiveEnvVars).filter(([, v]) => v !== undefined)
|
Object.fromEntries(
|
||||||
|
Object.entries(effectiveEnvVars).filter(([, v]) => v !== undefined)
|
||||||
|
) as Record<string, string>
|
||||||
) as Record<string, string>;
|
) as Record<string, string>;
|
||||||
|
|
||||||
const mergedEnv = {
|
const mergedEnv = {
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import type {
|
|||||||
OfficialChannelId,
|
OfficialChannelId,
|
||||||
DashboardAuthConfig,
|
DashboardAuthConfig,
|
||||||
BrowserConfig,
|
BrowserConfig,
|
||||||
|
BrowserEvalMode,
|
||||||
BrowserToolPolicy,
|
BrowserToolPolicy,
|
||||||
ImageAnalysisConfig,
|
ImageAnalysisConfig,
|
||||||
LoggingConfig,
|
LoggingConfig,
|
||||||
@@ -76,17 +77,37 @@ function normalizeBrowserPolicy(value: string | undefined): BrowserToolPolicy {
|
|||||||
return value === 'auto' || value === 'manual' ? value : DEFAULT_BROWSER_CONFIG.claude.policy;
|
return value === 'auto' || value === 'manual' ? value : DEFAULT_BROWSER_CONFIG.claude.policy;
|
||||||
}
|
}
|
||||||
|
|
||||||
function canonicalizeBrowserConfig(config?: BrowserConfig): BrowserConfig {
|
function normalizeBrowserEvalMode(value: string | undefined): BrowserEvalMode {
|
||||||
|
if (value === 'disabled' || value === 'readonly' || value === 'readwrite') {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return DEFAULT_BROWSER_CONFIG.claude.eval_mode ?? 'readonly';
|
||||||
|
}
|
||||||
|
|
||||||
|
function canonicalizeBrowserConfig(
|
||||||
|
config?: BrowserConfig,
|
||||||
|
fallback: BrowserConfig = DEFAULT_BROWSER_CONFIG
|
||||||
|
): BrowserConfig {
|
||||||
|
const claudeUserDataDir =
|
||||||
|
config?.claude?.user_data_dir === undefined
|
||||||
|
? fallback.claude.user_data_dir || getRecommendedBrowserUserDataDir()
|
||||||
|
: config.claude.user_data_dir.trim() || getRecommendedBrowserUserDataDir();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
claude: {
|
claude: {
|
||||||
enabled: config?.claude?.enabled ?? DEFAULT_BROWSER_CONFIG.claude.enabled,
|
enabled: config?.claude?.enabled ?? fallback.claude.enabled,
|
||||||
policy: normalizeBrowserPolicy(config?.claude?.policy),
|
policy: normalizeBrowserPolicy(config?.claude?.policy ?? fallback.claude.policy),
|
||||||
user_data_dir: config?.claude?.user_data_dir?.trim() || getRecommendedBrowserUserDataDir(),
|
user_data_dir: claudeUserDataDir,
|
||||||
devtools_port: normalizeBrowserDevtoolsPort(config?.claude?.devtools_port),
|
devtools_port: normalizeBrowserDevtoolsPort(
|
||||||
|
config?.claude?.devtools_port ?? fallback.claude.devtools_port
|
||||||
|
),
|
||||||
|
eval_mode: normalizeBrowserEvalMode(config?.claude?.eval_mode ?? fallback.claude.eval_mode),
|
||||||
},
|
},
|
||||||
codex: {
|
codex: {
|
||||||
enabled: config?.codex?.enabled ?? DEFAULT_BROWSER_CONFIG.codex.enabled,
|
enabled: config?.codex?.enabled ?? fallback.codex.enabled,
|
||||||
policy: normalizeBrowserPolicy(config?.codex?.policy),
|
policy: normalizeBrowserPolicy(config?.codex?.policy ?? fallback.codex.policy),
|
||||||
|
eval_mode: normalizeBrowserEvalMode(config?.codex?.eval_mode ?? fallback.codex.eval_mode),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1133,7 +1154,13 @@ export function saveUnifiedConfig(config: UnifiedConfig): void {
|
|||||||
export function mutateUnifiedConfig(mutator: (config: UnifiedConfig) => void): UnifiedConfig {
|
export function mutateUnifiedConfig(mutator: (config: UnifiedConfig) => void): UnifiedConfig {
|
||||||
return withConfigWriteLock(() => {
|
return withConfigWriteLock(() => {
|
||||||
const current = loadUnifiedConfigWithLockHeld();
|
const current = loadUnifiedConfigWithLockHeld();
|
||||||
|
const previousBrowser = current.browser
|
||||||
|
? canonicalizeBrowserConfig(current.browser)
|
||||||
|
: undefined;
|
||||||
mutator(current);
|
mutator(current);
|
||||||
|
if (current.browser) {
|
||||||
|
current.browser = canonicalizeBrowserConfig(current.browser, previousBrowser);
|
||||||
|
}
|
||||||
writeUnifiedConfigWithLockHeld(current);
|
writeUnifiedConfigWithLockHeld(current);
|
||||||
return current;
|
return current;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -822,6 +822,7 @@ export const DEFAULT_DASHBOARD_AUTH_CONFIG: DashboardAuthConfig = {
|
|||||||
* Controls Claude browser attach and Codex browser tooling.
|
* Controls Claude browser attach and Codex browser tooling.
|
||||||
*/
|
*/
|
||||||
export type BrowserToolPolicy = 'auto' | 'manual';
|
export type BrowserToolPolicy = 'auto' | 'manual';
|
||||||
|
export type BrowserEvalMode = 'disabled' | 'readonly' | 'readwrite';
|
||||||
|
|
||||||
export interface BrowserClaudeConfig {
|
export interface BrowserClaudeConfig {
|
||||||
/** Enable Claude browser attach (default: false) */
|
/** Enable Claude browser attach (default: false) */
|
||||||
@@ -832,6 +833,8 @@ export interface BrowserClaudeConfig {
|
|||||||
user_data_dir: string;
|
user_data_dir: string;
|
||||||
/** DevTools port used for attach mode (default: 9222) */
|
/** DevTools port used for attach mode (default: 9222) */
|
||||||
devtools_port: number;
|
devtools_port: number;
|
||||||
|
/** Eval access mode exposed through browser settings/status surfaces */
|
||||||
|
eval_mode?: BrowserEvalMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BrowserCodexConfig {
|
export interface BrowserCodexConfig {
|
||||||
@@ -839,6 +842,8 @@ export interface BrowserCodexConfig {
|
|||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
/** Control whether Codex browser tooling is exposed automatically or only via --browser */
|
/** Control whether Codex browser tooling is exposed automatically or only via --browser */
|
||||||
policy: BrowserToolPolicy;
|
policy: BrowserToolPolicy;
|
||||||
|
/** Eval access mode exposed through browser settings/status surfaces */
|
||||||
|
eval_mode?: BrowserEvalMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BrowserConfig {
|
export interface BrowserConfig {
|
||||||
@@ -852,10 +857,12 @@ export const DEFAULT_BROWSER_CONFIG: BrowserConfig = {
|
|||||||
policy: 'manual',
|
policy: 'manual',
|
||||||
user_data_dir: '',
|
user_data_dir: '',
|
||||||
devtools_port: 9222,
|
devtools_port: 9222,
|
||||||
|
eval_mode: 'readonly',
|
||||||
},
|
},
|
||||||
codex: {
|
codex: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
policy: 'manual',
|
policy: 'manual',
|
||||||
|
eval_mode: 'readonly',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import type { ProfileType } from '../types/profile';
|
|||||||
import {
|
import {
|
||||||
escapeShellArg,
|
escapeShellArg,
|
||||||
getWindowsEscapedCommandShell,
|
getWindowsEscapedCommandShell,
|
||||||
|
stripBrowserEnv,
|
||||||
stripAnthropicEnv,
|
stripAnthropicEnv,
|
||||||
stripClaudeCodeEnv,
|
stripClaudeCodeEnv,
|
||||||
} from '../utils/shell-executor';
|
} from '../utils/shell-executor';
|
||||||
@@ -60,10 +61,10 @@ export class ClaudeAdapter implements TargetAdapter {
|
|||||||
? stripAnthropicEnv(process.env)
|
? stripAnthropicEnv(process.env)
|
||||||
: process.env;
|
: process.env;
|
||||||
|
|
||||||
const env: NodeJS.ProcessEnv = { ...baseEnv, ...webSearchEnv };
|
const env: NodeJS.ProcessEnv = { ...stripBrowserEnv(baseEnv), ...webSearchEnv };
|
||||||
|
|
||||||
if (creds.envVars) {
|
if (creds.envVars) {
|
||||||
Object.assign(env, creds.envVars);
|
Object.assign(env, stripBrowserEnv(creds.envVars));
|
||||||
}
|
}
|
||||||
if (creds.browserRuntimeEnv) {
|
if (creds.browserRuntimeEnv) {
|
||||||
Object.assign(env, creds.browserRuntimeEnv);
|
Object.assign(env, creds.browserRuntimeEnv);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { wireChildProcessSignals } from '../utils/signal-forwarder';
|
|||||||
import {
|
import {
|
||||||
escapeShellArg,
|
escapeShellArg,
|
||||||
getWindowsEscapedCommandShell,
|
getWindowsEscapedCommandShell,
|
||||||
|
stripBrowserEnv,
|
||||||
stripAnthropicEnv,
|
stripAnthropicEnv,
|
||||||
stripCodexSessionEnv,
|
stripCodexSessionEnv,
|
||||||
} from '../utils/shell-executor';
|
} from '../utils/shell-executor';
|
||||||
@@ -252,7 +253,7 @@ export class CodexAdapter implements TargetAdapter {
|
|||||||
|
|
||||||
buildEnv(creds: TargetCredentials, profileType: ProfileType): NodeJS.ProcessEnv {
|
buildEnv(creds: TargetCredentials, profileType: ProfileType): NodeJS.ProcessEnv {
|
||||||
const env: NodeJS.ProcessEnv = {
|
const env: NodeJS.ProcessEnv = {
|
||||||
...stripCodexSessionEnv(stripAnthropicEnv(process.env)),
|
...stripBrowserEnv(stripCodexSessionEnv(stripAnthropicEnv(process.env))),
|
||||||
};
|
};
|
||||||
delete env[CODEX_RUNTIME_ENV_KEY];
|
delete env[CODEX_RUNTIME_ENV_KEY];
|
||||||
if (profileType !== 'default') {
|
if (profileType !== 'default') {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import type { BrowserConfig } from '../../config/unified-config-types';
|
import type { BrowserConfig, BrowserEvalMode } from '../../config/unified-config-types';
|
||||||
import { getCcsDir, getCcsPathDisplay } from '../config-manager';
|
import { getCcsDir, getCcsPathDisplay } from '../config-manager';
|
||||||
import { expandPath } from '../helpers';
|
import { expandPath } from '../helpers';
|
||||||
import { type BrowserRuntimeEnv, resolveBrowserRuntimeEnv } from './chrome-reuse';
|
import { type BrowserRuntimeEnv, resolveBrowserRuntimeEnv } from './chrome-reuse';
|
||||||
@@ -15,6 +15,7 @@ export interface EffectiveClaudeBrowserAttachConfig {
|
|||||||
userDataDir: string;
|
userDataDir: string;
|
||||||
devtoolsPort: number;
|
devtoolsPort: number;
|
||||||
hasExplicitDevtoolsPort: boolean;
|
hasExplicitDevtoolsPort: boolean;
|
||||||
|
evalMode: BrowserEvalMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BrowserLaunchCommands {
|
export interface BrowserLaunchCommands {
|
||||||
@@ -230,15 +231,17 @@ export function getEffectiveClaudeBrowserAttachConfig(
|
|||||||
const configUserDataDir =
|
const configUserDataDir =
|
||||||
resolveBrowserUserDataDir(config.claude.user_data_dir) ?? getRecommendedBrowserUserDataDir();
|
resolveBrowserUserDataDir(config.claude.user_data_dir) ?? getRecommendedBrowserUserDataDir();
|
||||||
const configPort = normalizeDevtoolsPort(config.claude.devtools_port);
|
const configPort = normalizeDevtoolsPort(config.claude.devtools_port);
|
||||||
|
const configEvalMode = config.claude.eval_mode ?? 'readonly';
|
||||||
|
|
||||||
if (override.userDataDir) {
|
if (override.userDataDir) {
|
||||||
return {
|
return {
|
||||||
enabled: true,
|
enabled: config.claude.enabled,
|
||||||
source: override.source as BrowserOverrideSource,
|
source: override.source as BrowserOverrideSource,
|
||||||
overrideActive: true,
|
overrideActive: true,
|
||||||
userDataDir: override.userDataDir,
|
userDataDir: override.userDataDir,
|
||||||
devtoolsPort: override.devtoolsPort ?? configPort,
|
devtoolsPort: override.devtoolsPort ?? configPort,
|
||||||
hasExplicitDevtoolsPort: override.devtoolsPort !== undefined,
|
hasExplicitDevtoolsPort: override.devtoolsPort !== undefined,
|
||||||
|
evalMode: configEvalMode,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -249,6 +252,7 @@ export function getEffectiveClaudeBrowserAttachConfig(
|
|||||||
userDataDir: configUserDataDir,
|
userDataDir: configUserDataDir,
|
||||||
devtoolsPort: configPort,
|
devtoolsPort: configPort,
|
||||||
hasExplicitDevtoolsPort: true,
|
hasExplicitDevtoolsPort: true,
|
||||||
|
evalMode: configEvalMode,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,11 +271,15 @@ export async function resolveOptionalBrowserAttachRuntime(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const runtimeEnv = await resolveBrowserRuntimeEnv({
|
||||||
|
profileDir: config.userDataDir,
|
||||||
|
devtoolsPort: config.hasExplicitDevtoolsPort ? String(config.devtoolsPort) : undefined,
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
runtimeEnv: await resolveBrowserRuntimeEnv({
|
runtimeEnv: {
|
||||||
profileDir: config.userDataDir,
|
...runtimeEnv,
|
||||||
devtoolsPort: config.hasExplicitDevtoolsPort ? String(config.devtoolsPort) : undefined,
|
CCS_BROWSER_EVAL_MODE: config.evalMode,
|
||||||
}),
|
},
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import type { BrowserConfig, BrowserToolPolicy } from '../../config/unified-config-types';
|
import type {
|
||||||
|
BrowserConfig,
|
||||||
|
BrowserEvalMode,
|
||||||
|
BrowserToolPolicy,
|
||||||
|
} from '../../config/unified-config-types';
|
||||||
import { getBrowserConfig, loadUnifiedConfig } from '../../config/unified-config-loader';
|
import { getBrowserConfig, loadUnifiedConfig } from '../../config/unified-config-loader';
|
||||||
import { getCcsPathDisplay } from '../config-manager';
|
import { getCcsPathDisplay } from '../config-manager';
|
||||||
import { getCodexBinaryInfo } from '../../targets/codex-detector';
|
import { getCodexBinaryInfo } from '../../targets/codex-detector';
|
||||||
@@ -19,6 +23,7 @@ import {
|
|||||||
export interface ClaudeBrowserStatus {
|
export interface ClaudeBrowserStatus {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
policy: BrowserToolPolicy;
|
policy: BrowserToolPolicy;
|
||||||
|
evalMode: BrowserEvalMode;
|
||||||
source: 'config' | 'CCS_BROWSER_USER_DATA_DIR' | 'CCS_BROWSER_PROFILE_DIR';
|
source: 'config' | 'CCS_BROWSER_USER_DATA_DIR' | 'CCS_BROWSER_PROFILE_DIR';
|
||||||
overrideActive: boolean;
|
overrideActive: boolean;
|
||||||
state: 'disabled' | 'path_missing' | 'browser_not_running' | 'endpoint_unreachable' | 'ready';
|
state: 'disabled' | 'path_missing' | 'browser_not_running' | 'endpoint_unreachable' | 'ready';
|
||||||
@@ -37,6 +42,7 @@ export interface ClaudeBrowserStatus {
|
|||||||
export interface CodexBrowserStatus {
|
export interface CodexBrowserStatus {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
policy: BrowserToolPolicy;
|
policy: BrowserToolPolicy;
|
||||||
|
evalMode: BrowserEvalMode;
|
||||||
state: 'disabled' | 'enabled' | 'unsupported_build';
|
state: 'disabled' | 'enabled' | 'unsupported_build';
|
||||||
title: string;
|
title: string;
|
||||||
detail: string;
|
detail: string;
|
||||||
@@ -69,6 +75,12 @@ function resolveSafeBrowserPolicy(policy: BrowserToolPolicy | undefined): Browse
|
|||||||
return policy === 'auto' || policy === 'manual' ? policy : 'manual';
|
return policy === 'auto' || policy === 'manual' ? policy : 'manual';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveSafeBrowserEvalMode(evalMode: BrowserEvalMode | undefined): BrowserEvalMode {
|
||||||
|
return evalMode === 'disabled' || evalMode === 'readonly' || evalMode === 'readwrite'
|
||||||
|
? evalMode
|
||||||
|
: 'readonly';
|
||||||
|
}
|
||||||
|
|
||||||
export function getUserFacingBrowserConfig(): BrowserConfig {
|
export function getUserFacingBrowserConfig(): BrowserConfig {
|
||||||
const canonical = getBrowserConfig();
|
const canonical = getBrowserConfig();
|
||||||
const persisted = loadUnifiedConfig()?.browser as PersistedBrowserConfig | undefined;
|
const persisted = loadUnifiedConfig()?.browser as PersistedBrowserConfig | undefined;
|
||||||
@@ -79,11 +91,13 @@ export function getUserFacingBrowserConfig(): BrowserConfig {
|
|||||||
...canonical.claude,
|
...canonical.claude,
|
||||||
enabled: false,
|
enabled: false,
|
||||||
policy: 'manual',
|
policy: 'manual',
|
||||||
|
eval_mode: resolveSafeBrowserEvalMode(canonical.claude.eval_mode),
|
||||||
},
|
},
|
||||||
codex: {
|
codex: {
|
||||||
...canonical.codex,
|
...canonical.codex,
|
||||||
enabled: false,
|
enabled: false,
|
||||||
policy: 'manual',
|
policy: 'manual',
|
||||||
|
eval_mode: resolveSafeBrowserEvalMode(canonical.codex.eval_mode),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -93,11 +107,17 @@ export function getUserFacingBrowserConfig(): BrowserConfig {
|
|||||||
...canonical.claude,
|
...canonical.claude,
|
||||||
enabled: persisted.claude?.enabled ?? false,
|
enabled: persisted.claude?.enabled ?? false,
|
||||||
policy: resolveSafeBrowserPolicy(persisted.claude?.policy),
|
policy: resolveSafeBrowserPolicy(persisted.claude?.policy),
|
||||||
|
eval_mode: resolveSafeBrowserEvalMode(
|
||||||
|
persisted.claude?.eval_mode ?? canonical.claude.eval_mode
|
||||||
|
),
|
||||||
},
|
},
|
||||||
codex: {
|
codex: {
|
||||||
...canonical.codex,
|
...canonical.codex,
|
||||||
enabled: persisted.codex?.enabled ?? false,
|
enabled: persisted.codex?.enabled ?? false,
|
||||||
policy: resolveSafeBrowserPolicy(persisted.codex?.policy),
|
policy: resolveSafeBrowserPolicy(persisted.codex?.policy),
|
||||||
|
eval_mode: resolveSafeBrowserEvalMode(
|
||||||
|
persisted.codex?.eval_mode ?? canonical.codex.eval_mode
|
||||||
|
),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -110,6 +130,7 @@ async function buildClaudeBrowserStatus(
|
|||||||
const base: Omit<ClaudeBrowserStatus, 'state' | 'title' | 'detail' | 'nextStep'> = {
|
const base: Omit<ClaudeBrowserStatus, 'state' | 'title' | 'detail' | 'nextStep'> = {
|
||||||
enabled: effective.enabled,
|
enabled: effective.enabled,
|
||||||
policy: browserConfig.claude.policy,
|
policy: browserConfig.claude.policy,
|
||||||
|
evalMode: resolveSafeBrowserEvalMode(browserConfig.claude.eval_mode),
|
||||||
source: effective.source,
|
source: effective.source,
|
||||||
overrideActive: effective.overrideActive,
|
overrideActive: effective.overrideActive,
|
||||||
effectiveUserDataDir: effective.userDataDir,
|
effectiveUserDataDir: effective.userDataDir,
|
||||||
@@ -224,6 +245,7 @@ function buildCodexBrowserStatus(browserConfig = getUserFacingBrowserConfig()):
|
|||||||
return {
|
return {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
policy: browserConfig.codex.policy,
|
policy: browserConfig.codex.policy,
|
||||||
|
evalMode: resolveSafeBrowserEvalMode(browserConfig.codex.eval_mode),
|
||||||
state: 'disabled',
|
state: 'disabled',
|
||||||
title: 'Codex Browser Tools are disabled.',
|
title: 'Codex Browser Tools are disabled.',
|
||||||
detail:
|
detail:
|
||||||
@@ -242,6 +264,7 @@ function buildCodexBrowserStatus(browserConfig = getUserFacingBrowserConfig()):
|
|||||||
return {
|
return {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
policy: browserConfig.codex.policy,
|
policy: browserConfig.codex.policy,
|
||||||
|
evalMode: resolveSafeBrowserEvalMode(browserConfig.codex.eval_mode),
|
||||||
state: 'unsupported_build',
|
state: 'unsupported_build',
|
||||||
title: 'Codex Browser Tools need a Codex build with --config override support.',
|
title: 'Codex Browser Tools need a Codex build with --config override support.',
|
||||||
detail: binaryInfo
|
detail: binaryInfo
|
||||||
@@ -258,6 +281,7 @@ function buildCodexBrowserStatus(browserConfig = getUserFacingBrowserConfig()):
|
|||||||
return {
|
return {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
policy: browserConfig.codex.policy,
|
policy: browserConfig.codex.policy,
|
||||||
|
evalMode: resolveSafeBrowserEvalMode(browserConfig.codex.eval_mode),
|
||||||
state: 'enabled',
|
state: 'enabled',
|
||||||
title: 'Codex Browser Tools are enabled.',
|
title: 'Codex Browser Tools are enabled.',
|
||||||
detail:
|
detail:
|
||||||
@@ -283,5 +307,6 @@ export function getManagedBrowserSetupHint(): string {
|
|||||||
userDataDir: getRecommendedBrowserUserDataDir(),
|
userDataDir: getRecommendedBrowserUserDataDir(),
|
||||||
devtoolsPort: 9222,
|
devtoolsPort: 9222,
|
||||||
hasExplicitDevtoolsPort: true,
|
hasExplicitDevtoolsPort: true,
|
||||||
|
evalMode: 'readonly',
|
||||||
}).join('\n');
|
}).join('\n');
|
||||||
}
|
}
|
||||||
@@ -109,9 +109,14 @@ export function getImageAnalysisHookEnv(
|
|||||||
: resolveImageAnalysisStatus({ profileName: '' }, config);
|
: resolveImageAnalysisStatus({ profileName: '' }, config);
|
||||||
const skipImageAnalysis = !status.supported;
|
const skipImageAnalysis = !status.supported;
|
||||||
const runtimeApiKey =
|
const runtimeApiKey =
|
||||||
typeof context === 'object' && context.cliproxyBridge
|
!skipImageAnalysis && typeof context === 'object' && context.cliproxyBridge
|
||||||
? resolveCliproxyBridgeProfile(context.cliproxyBridge.provider).apiKey
|
? resolveCliproxyBridgeProfile(context.cliproxyBridge.provider).apiKey
|
||||||
: '';
|
: '';
|
||||||
|
const runtimePath = skipImageAnalysis ? '' : status.runtimePath || '';
|
||||||
|
const runtimeBaseUrl =
|
||||||
|
skipImageAnalysis || typeof context !== 'object'
|
||||||
|
? ''
|
||||||
|
: context.cliproxyBridge?.currentBaseUrl || '';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
CCS_IMAGE_ANALYSIS_ENABLED: config.enabled ? '1' : '0',
|
CCS_IMAGE_ANALYSIS_ENABLED: config.enabled ? '1' : '0',
|
||||||
@@ -120,9 +125,8 @@ export function getImageAnalysisHookEnv(
|
|||||||
CCS_CURRENT_PROVIDER: status.backendId || '',
|
CCS_CURRENT_PROVIDER: status.backendId || '',
|
||||||
CCS_IMAGE_ANALYSIS_BACKEND_ID: status.backendId || '',
|
CCS_IMAGE_ANALYSIS_BACKEND_ID: status.backendId || '',
|
||||||
CCS_IMAGE_ANALYSIS_MODEL: status.model || '',
|
CCS_IMAGE_ANALYSIS_MODEL: status.model || '',
|
||||||
CCS_IMAGE_ANALYSIS_RUNTIME_PATH: status.runtimePath || '',
|
CCS_IMAGE_ANALYSIS_RUNTIME_PATH: runtimePath,
|
||||||
CCS_IMAGE_ANALYSIS_RUNTIME_BASE_URL:
|
CCS_IMAGE_ANALYSIS_RUNTIME_BASE_URL: runtimeBaseUrl,
|
||||||
typeof context === 'object' ? context.cliproxyBridge?.currentBaseUrl || '' : '',
|
|
||||||
...(runtimeApiKey ? { CCS_IMAGE_ANALYSIS_RUNTIME_API_KEY: runtimeApiKey } : {}),
|
...(runtimeApiKey ? { CCS_IMAGE_ANALYSIS_RUNTIME_API_KEY: runtimeApiKey } : {}),
|
||||||
CCS_IMAGE_ANALYSIS_PROMPTS_DIR: getPromptsDir(),
|
CCS_IMAGE_ANALYSIS_PROMPTS_DIR: getPromptsDir(),
|
||||||
CCS_IMAGE_ANALYSIS_SKIP: skipImageAnalysis ? '1' : '0',
|
CCS_IMAGE_ANALYSIS_SKIP: skipImageAnalysis ? '1' : '0',
|
||||||
|
|||||||
@@ -91,6 +91,22 @@ function syncTmuxNestedSessionEnv(env: NodeJS.ProcessEnv, profileType: string |
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strip inherited browser attach/runtime env vars from a process environment.
|
||||||
|
*
|
||||||
|
* Browser capability is opt-in and launch-scoped. Stale CCS_BROWSER_* values
|
||||||
|
* from the parent process must never bleed into a browser-off child launch.
|
||||||
|
*/
|
||||||
|
export function stripBrowserEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
||||||
|
const result: NodeJS.ProcessEnv = {};
|
||||||
|
for (const key of Object.keys(env)) {
|
||||||
|
if (!key.toUpperCase().startsWith('CCS_BROWSER_')) {
|
||||||
|
result[key] = env[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Strip Claude Code nested-session guard env var from a process environment.
|
* Strip Claude Code nested-session guard env var from a process environment.
|
||||||
*
|
*
|
||||||
@@ -210,11 +226,12 @@ export function execClaude(
|
|||||||
const profileType = envVars?.CCS_PROFILE_TYPE;
|
const profileType = envVars?.CCS_PROFILE_TYPE;
|
||||||
const stripInheritedAnthropicEnv = profileType === 'account' || profileType === 'default';
|
const stripInheritedAnthropicEnv = profileType === 'account' || profileType === 'default';
|
||||||
const stripInheritedAnthropicRoutingEnv = envVars?.CCS_STRIP_INHERITED_ANTHROPIC_ENV === '1';
|
const stripInheritedAnthropicRoutingEnv = envVars?.CCS_STRIP_INHERITED_ANTHROPIC_ENV === '1';
|
||||||
const baseEnv = stripInheritedAnthropicEnv
|
const inheritedEnv = stripInheritedAnthropicEnv
|
||||||
? stripAnthropicEnv(process.env)
|
? stripAnthropicEnv(process.env)
|
||||||
: stripInheritedAnthropicRoutingEnv
|
: stripInheritedAnthropicRoutingEnv
|
||||||
? stripAnthropicRoutingEnv(process.env)
|
? stripAnthropicRoutingEnv(process.env)
|
||||||
: process.env;
|
: process.env;
|
||||||
|
const baseEnv = stripBrowserEnv(inheritedEnv);
|
||||||
|
|
||||||
// Prepare environment (merge with base env if envVars provided)
|
// Prepare environment (merge with base env if envVars provided)
|
||||||
const mergedEnv = envVars
|
const mergedEnv = envVars
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
* Session-based auth with httpOnly cookies for CCS dashboard.
|
* Session-based auth with httpOnly cookies for CCS dashboard.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Request, Response, NextFunction } from 'express';
|
import type { NextFunction, Request, Response } from 'express';
|
||||||
import session from 'express-session';
|
import session from 'express-session';
|
||||||
import rateLimit from 'express-rate-limit';
|
import rateLimit from 'express-rate-limit';
|
||||||
import { getDashboardAuthConfig, isDashboardAuthEnabled } from '../../config/unified-config-loader';
|
import { getDashboardAuthConfig, isDashboardAuthEnabled } from '../../config/unified-config-loader';
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Router, type Request, type Response } from 'express';
|
import { Router, type Request, type Response } from 'express';
|
||||||
import { mutateUnifiedConfig } from '../../config/unified-config-loader';
|
import { mutateUnifiedConfig } from '../../config/unified-config-loader';
|
||||||
import type { BrowserConfig } from '../../config/unified-config-types';
|
import type { BrowserConfig, BrowserEvalMode } from '../../config/unified-config-types';
|
||||||
import { getBrowserStatus } from '../../utils/browser';
|
import { getBrowserStatus } from '../../utils/browser';
|
||||||
import { getUserFacingBrowserConfig } from '../../utils/browser/browser-status';
|
import { getUserFacingBrowserConfig } from '../../utils/browser/browser-status';
|
||||||
import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middleware';
|
import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middleware';
|
||||||
@@ -15,10 +15,12 @@ interface BrowserRouteBody {
|
|||||||
policy?: 'auto' | 'manual';
|
policy?: 'auto' | 'manual';
|
||||||
userDataDir?: string;
|
userDataDir?: string;
|
||||||
devtoolsPort?: number;
|
devtoolsPort?: number;
|
||||||
|
evalMode?: BrowserEvalMode;
|
||||||
};
|
};
|
||||||
codex?: {
|
codex?: {
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
policy?: 'auto' | 'manual';
|
policy?: 'auto' | 'manual';
|
||||||
|
evalMode?: BrowserEvalMode;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,6 +32,10 @@ function isValidBrowserPolicy(value: string): value is 'auto' | 'manual' {
|
|||||||
return value === 'auto' || value === 'manual';
|
return value === 'auto' || value === 'manual';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isValidBrowserEvalMode(value: string): value is BrowserEvalMode {
|
||||||
|
return value === 'disabled' || value === 'readonly' || value === 'readwrite';
|
||||||
|
}
|
||||||
|
|
||||||
function isValidDevtoolsPort(value: number): boolean {
|
function isValidDevtoolsPort(value: number): boolean {
|
||||||
return Number.isInteger(value) && value >= 1 && value <= 65535;
|
return Number.isInteger(value) && value >= 1 && value <= 65535;
|
||||||
}
|
}
|
||||||
@@ -88,7 +94,11 @@ router.put('/', async (req: Request, res: Response): Promise<void> => {
|
|||||||
}
|
}
|
||||||
const unknownClaudeKeys = Object.keys(claude ?? {}).filter(
|
const unknownClaudeKeys = Object.keys(claude ?? {}).filter(
|
||||||
(key) =>
|
(key) =>
|
||||||
key !== 'enabled' && key !== 'policy' && key !== 'userDataDir' && key !== 'devtoolsPort'
|
key !== 'enabled' &&
|
||||||
|
key !== 'policy' &&
|
||||||
|
key !== 'userDataDir' &&
|
||||||
|
key !== 'devtoolsPort' &&
|
||||||
|
key !== 'evalMode'
|
||||||
);
|
);
|
||||||
if (unknownClaudeKeys.length > 0) {
|
if (unknownClaudeKeys.length > 0) {
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
@@ -97,7 +107,7 @@ router.put('/', async (req: Request, res: Response): Promise<void> => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const unknownCodexKeys = Object.keys(codex ?? {}).filter(
|
const unknownCodexKeys = Object.keys(codex ?? {}).filter(
|
||||||
(key) => key !== 'enabled' && key !== 'policy'
|
(key) => key !== 'enabled' && key !== 'policy' && key !== 'evalMode'
|
||||||
);
|
);
|
||||||
if (unknownCodexKeys.length > 0) {
|
if (unknownCodexKeys.length > 0) {
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
@@ -129,6 +139,15 @@ router.put('/', async (req: Request, res: Response): Promise<void> => {
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
claude?.evalMode !== undefined &&
|
||||||
|
(typeof claude.evalMode !== 'string' || !isValidBrowserEvalMode(claude.evalMode))
|
||||||
|
) {
|
||||||
|
res.status(400).json({
|
||||||
|
error: 'Invalid value for claude.evalMode. Must be one of: disabled, readonly, readwrite.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (codex?.enabled !== undefined && typeof codex.enabled !== 'boolean') {
|
if (codex?.enabled !== undefined && typeof codex.enabled !== 'boolean') {
|
||||||
res.status(400).json({ error: 'Invalid value for codex.enabled. Must be a boolean.' });
|
res.status(400).json({ error: 'Invalid value for codex.enabled. Must be a boolean.' });
|
||||||
return;
|
return;
|
||||||
@@ -140,6 +159,15 @@ router.put('/', async (req: Request, res: Response): Promise<void> => {
|
|||||||
res.status(400).json({ error: 'Invalid value for codex.policy. Must be auto or manual.' });
|
res.status(400).json({ error: 'Invalid value for codex.policy. Must be auto or manual.' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
codex?.evalMode !== undefined &&
|
||||||
|
(typeof codex.evalMode !== 'string' || !isValidBrowserEvalMode(codex.evalMode))
|
||||||
|
) {
|
||||||
|
res.status(400).json({
|
||||||
|
error: 'Invalid value for codex.evalMode. Must be one of: disabled, readonly, readwrite.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const current = getUserFacingBrowserConfig();
|
const current = getUserFacingBrowserConfig();
|
||||||
@@ -152,10 +180,12 @@ router.put('/', async (req: Request, res: Response): Promise<void> => {
|
|||||||
policy: claude?.policy ?? current.claude.policy,
|
policy: claude?.policy ?? current.claude.policy,
|
||||||
user_data_dir: nextClaudeUserDataDir,
|
user_data_dir: nextClaudeUserDataDir,
|
||||||
devtools_port: claude?.devtoolsPort ?? current.claude.devtools_port,
|
devtools_port: claude?.devtoolsPort ?? current.claude.devtools_port,
|
||||||
|
eval_mode: claude?.evalMode ?? current.claude.eval_mode,
|
||||||
},
|
},
|
||||||
codex: {
|
codex: {
|
||||||
enabled: codex?.enabled ?? current.codex.enabled,
|
enabled: codex?.enabled ?? current.codex.enabled,
|
||||||
policy: codex?.policy ?? current.codex.policy,
|
policy: codex?.policy ?? current.codex.policy,
|
||||||
|
eval_mode: codex?.evalMode ?? current.codex.eval_mode,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -181,10 +211,12 @@ function toBrowserRouteConfig(config: BrowserConfig) {
|
|||||||
policy: config.claude.policy,
|
policy: config.claude.policy,
|
||||||
userDataDir: config.claude.user_data_dir,
|
userDataDir: config.claude.user_data_dir,
|
||||||
devtoolsPort: config.claude.devtools_port,
|
devtoolsPort: config.claude.devtools_port,
|
||||||
|
evalMode: config.claude.eval_mode ?? 'readonly',
|
||||||
},
|
},
|
||||||
codex: {
|
codex: {
|
||||||
enabled: config.codex.enabled,
|
enabled: config.codex.enabled,
|
||||||
policy: config.codex.policy,
|
policy: config.codex.policy,
|
||||||
|
evalMode: config.codex.eval_mode ?? 'readonly',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -215,6 +215,42 @@ describe('resolveCliproxyImageAnalysisEnv', () => {
|
|||||||
expect(result.warning).toContain('native Read');
|
expect(result.warning).toContain('native Read');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('strips browser env injected through settings before building Claude runtime env', () => {
|
||||||
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-browser-env-strip-'));
|
||||||
|
tempDirs.push(tempDir);
|
||||||
|
|
||||||
|
const settingsPath = path.join(tempDir, 'browser-env.settings.json');
|
||||||
|
fs.writeFileSync(
|
||||||
|
settingsPath,
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
env: {
|
||||||
|
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/agy',
|
||||||
|
ANTHROPIC_AUTH_TOKEN: 'test-token',
|
||||||
|
ANTHROPIC_MODEL: 'gemini-2.5-pro',
|
||||||
|
CCS_BROWSER_USER_DATA_DIR: '/tmp/embedded-browser-runtime',
|
||||||
|
CCS_BROWSER_PROFILE_DIR: '/tmp/embedded-browser-legacy',
|
||||||
|
CCS_BROWSER_DEVTOOLS_WS_URL: 'ws://127.0.0.1/devtools/browser/settings-env',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
) + '\n'
|
||||||
|
);
|
||||||
|
|
||||||
|
const env = buildClaudeEnvironment({
|
||||||
|
provider: 'agy',
|
||||||
|
useRemoteProxy: false,
|
||||||
|
localPort: 8317,
|
||||||
|
customSettingsPath: settingsPath,
|
||||||
|
verbose: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(env.CCS_BROWSER_USER_DATA_DIR).toBeUndefined();
|
||||||
|
expect(env.CCS_BROWSER_PROFILE_DIR).toBeUndefined();
|
||||||
|
expect(env.CCS_BROWSER_DEVTOOLS_WS_URL).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
it('keeps cliproxy image analysis active when the execution target is reachable', async () => {
|
it('keeps cliproxy image analysis active when the execution target is reachable', async () => {
|
||||||
const result = await resolveCliproxyImageAnalysisEnv(
|
const result = await resolveCliproxyImageAnalysisEnv(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -202,10 +202,12 @@ describe('migration-manager legacy kimi compatibility', () => {
|
|||||||
policy: 'manual',
|
policy: 'manual',
|
||||||
user_data_dir: '',
|
user_data_dir: '',
|
||||||
devtools_port: 9222,
|
devtools_port: 9222,
|
||||||
|
eval_mode: 'readonly',
|
||||||
},
|
},
|
||||||
codex: {
|
codex: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
policy: 'manual',
|
policy: 'manual',
|
||||||
|
eval_mode: 'readonly',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,413 @@
|
|||||||
|
import { describe, expect, it } from 'bun:test';
|
||||||
|
import { runMcpRequests, getResponseText, mkdtempSync, writeFileSync, tmpdir, join } from './browser-mcp-test-harness';
|
||||||
|
import type { MockPageState } from './browser-mcp-test-harness';
|
||||||
|
|
||||||
|
describe('ccs-browser MCP server - downloads and file inputs', () => {
|
||||||
|
it('applies browser-scoped download behavior, records download summaries, and cancels an in-progress download', async () => {
|
||||||
|
const pages: MockPageState[] = [
|
||||||
|
{
|
||||||
|
id: 'page-1',
|
||||||
|
title: 'Reports',
|
||||||
|
currentUrl: 'https://example.com/reports',
|
||||||
|
browser: {},
|
||||||
|
events: {
|
||||||
|
downloads: [
|
||||||
|
{
|
||||||
|
guid: 'download-guid-1',
|
||||||
|
url: 'https://example.com/files/report.csv',
|
||||||
|
suggestedFilename: 'report.csv',
|
||||||
|
progress: [{ receivedBytes: 5, totalBytes: 10, state: 'inProgress' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const responses = await runMcpRequests(
|
||||||
|
pages,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 57,
|
||||||
|
method: 'tools/call',
|
||||||
|
params: {
|
||||||
|
name: 'browser_set_download_behavior',
|
||||||
|
arguments: { behavior: 'accept', eventsEnabled: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 58,
|
||||||
|
method: 'tools/call',
|
||||||
|
params: { name: 'browser_list_downloads', arguments: {} },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 59,
|
||||||
|
method: 'tools/call',
|
||||||
|
params: {
|
||||||
|
name: 'browser_cancel_download',
|
||||||
|
arguments: { guid: 'download-guid-1' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 60,
|
||||||
|
method: 'tools/call',
|
||||||
|
params: { name: 'browser_list_downloads', arguments: {} },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{ responseTimeoutMs: 12000 }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getResponseText(responses.find((message) => message.id === 57))).toContain('scope: browser');
|
||||||
|
expect(getResponseText(responses.find((message) => message.id === 58))).toContain('suggestedFilename: report.csv');
|
||||||
|
expect(getResponseText(responses.find((message) => message.id === 60))).toContain('status: canceled');
|
||||||
|
expect(pages[0]?.browser?.setDownloadBehaviorCalls?.[0]?.behavior).toBe('allow');
|
||||||
|
expect(pages[0]?.browser?.canceledDownloadGuids).toContain('download-guid-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects browser_set_download_behavior when behavior is deny and downloadPath is provided', async () => {
|
||||||
|
const responses = await runMcpRequests(
|
||||||
|
[{ id: 'page-1', title: 'Reports', currentUrl: 'https://example.com/reports' }],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 61,
|
||||||
|
method: 'tools/call',
|
||||||
|
params: {
|
||||||
|
name: 'browser_set_download_behavior',
|
||||||
|
arguments: {
|
||||||
|
behavior: 'deny',
|
||||||
|
downloadPath: '/tmp/blocked-downloads',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = responses.find((message) => message.id === 61);
|
||||||
|
expect((response?.result as { isError?: boolean }).isError).toBe(true);
|
||||||
|
expect(getResponseText(response)).toContain(
|
||||||
|
'Browser MCP failed: downloadPath is only allowed when behavior=accept'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects browser_cancel_download for completed downloads', async () => {
|
||||||
|
const pages: MockPageState[] = [
|
||||||
|
{
|
||||||
|
id: 'page-1',
|
||||||
|
title: 'Reports',
|
||||||
|
currentUrl: 'https://example.com/reports',
|
||||||
|
browser: {},
|
||||||
|
events: {
|
||||||
|
downloads: [
|
||||||
|
{
|
||||||
|
guid: 'download-guid-complete',
|
||||||
|
url: 'https://example.com/files/report.csv',
|
||||||
|
suggestedFilename: 'report.csv',
|
||||||
|
progress: [{ receivedBytes: 10, totalBytes: 10, state: 'completed' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const responses = await runMcpRequests(
|
||||||
|
pages,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 610,
|
||||||
|
method: 'tools/call',
|
||||||
|
params: {
|
||||||
|
name: 'browser_set_download_behavior',
|
||||||
|
arguments: { behavior: 'accept', eventsEnabled: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 611,
|
||||||
|
method: 'tools/call',
|
||||||
|
params: { name: 'browser_list_downloads', arguments: {} },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 612,
|
||||||
|
method: 'tools/call',
|
||||||
|
params: {
|
||||||
|
name: 'browser_cancel_download',
|
||||||
|
arguments: { guid: 'download-guid-complete' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{ responseTimeoutMs: 12000 }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getResponseText(responses.find((message) => message.id === 611))).toContain('status: completed');
|
||||||
|
const response = responses.find((message) => message.id === 612);
|
||||||
|
expect((response?.result as { isError?: boolean }).isError).toBe(true);
|
||||||
|
expect(getResponseText(response)).toContain(
|
||||||
|
'Browser MCP failed: download is not cancelable in status: completed'
|
||||||
|
);
|
||||||
|
expect(pages[0]?.browser?.canceledDownloadGuids).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('waits for a matching download event with browser_wait_for_event after Phase 8 changes', async () => {
|
||||||
|
const responses = await runMcpRequests(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: 'page-1',
|
||||||
|
title: 'Event Page',
|
||||||
|
currentUrl: 'https://example.com/',
|
||||||
|
events: {
|
||||||
|
downloads: [
|
||||||
|
{
|
||||||
|
guid: 'download-guid-2',
|
||||||
|
url: 'https://example.com/files/export.zip',
|
||||||
|
suggestedFilename: 'export.zip',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 62,
|
||||||
|
method: 'tools/call',
|
||||||
|
params: {
|
||||||
|
name: 'browser_wait_for_event',
|
||||||
|
arguments: {
|
||||||
|
timeoutMs: 1000,
|
||||||
|
event: { kind: 'download', suggestedFilenameIncludes: 'export.zip' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{ responseTimeoutMs: 12000 }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getResponseText(responses.find((message) => message.id === 62))).toContain('status: observed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets files on selected-page, frameSelector, and pierceShadow file inputs', async () => {
|
||||||
|
const tempDir = mkdtempSync(join(tmpdir(), 'ccs-browser-upload-'));
|
||||||
|
const invoicePath = join(tempDir, 'invoice.pdf');
|
||||||
|
const receiptPath = join(tempDir, 'receipt.png');
|
||||||
|
writeFileSync(invoicePath, 'invoice');
|
||||||
|
writeFileSync(receiptPath, 'receipt');
|
||||||
|
|
||||||
|
const pages: MockPageState[] = [
|
||||||
|
{
|
||||||
|
id: 'page-1',
|
||||||
|
title: 'Root Uploads',
|
||||||
|
currentUrl: 'https://example.com/root',
|
||||||
|
fileInputs: {
|
||||||
|
'#root-upload': { kind: 'file', multiple: true },
|
||||||
|
},
|
||||||
|
frames: [
|
||||||
|
{
|
||||||
|
selector: '#upload-frame',
|
||||||
|
fileInputs: {
|
||||||
|
'#frame-upload': { kind: 'file', multiple: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
shadowRoots: [
|
||||||
|
{
|
||||||
|
hostSelector: 'upload-panel',
|
||||||
|
fileInputs: {
|
||||||
|
'#shadow-upload': { kind: 'file' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'page-2',
|
||||||
|
title: 'Selected Uploads',
|
||||||
|
currentUrl: 'https://example.com/selected',
|
||||||
|
fileInputs: {
|
||||||
|
'#selected-upload': { kind: 'file', multiple: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const responses = await runMcpRequests(
|
||||||
|
pages,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 63,
|
||||||
|
method: 'tools/call',
|
||||||
|
params: { name: 'browser_select_page', arguments: { pageIndex: 1 } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 64,
|
||||||
|
method: 'tools/call',
|
||||||
|
params: {
|
||||||
|
name: 'browser_set_file_input',
|
||||||
|
arguments: { selector: '#selected-upload', files: [invoicePath, receiptPath] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 65,
|
||||||
|
method: 'tools/call',
|
||||||
|
params: {
|
||||||
|
name: 'browser_set_file_input',
|
||||||
|
arguments: {
|
||||||
|
pageIndex: 0,
|
||||||
|
selector: '#frame-upload',
|
||||||
|
files: [invoicePath],
|
||||||
|
frameSelector: '#upload-frame',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 66,
|
||||||
|
method: 'tools/call',
|
||||||
|
params: {
|
||||||
|
name: 'browser_set_file_input',
|
||||||
|
arguments: {
|
||||||
|
pageIndex: 0,
|
||||||
|
selector: '#shadow-upload',
|
||||||
|
files: [receiptPath],
|
||||||
|
pierceShadow: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getResponseText(responses.find((message) => message.id === 64))).toContain('pageIndex: 1');
|
||||||
|
expect(getResponseText(responses.find((message) => message.id === 65))).toContain('frameSelector: #upload-frame');
|
||||||
|
expect(getResponseText(responses.find((message) => message.id === 66))).toContain('pierceShadow: true');
|
||||||
|
|
||||||
|
expect((pages[1]?.fileInputs?.['#selected-upload'] as MockFileInputState).assignedFiles).toEqual([
|
||||||
|
invoicePath,
|
||||||
|
receiptPath,
|
||||||
|
]);
|
||||||
|
expect(
|
||||||
|
(pages[0]?.frames?.[0]?.fileInputs?.['#frame-upload'] as MockFileInputState).assignedFiles
|
||||||
|
).toEqual([invoicePath]);
|
||||||
|
expect(
|
||||||
|
(pages[0]?.shadowRoots?.[0]?.fileInputs?.['#shadow-upload'] as MockFileInputState).assignedFiles
|
||||||
|
).toEqual([receiptPath]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses pageId for browser_set_file_input when provided', async () => {
|
||||||
|
const tempDir = mkdtempSync(join(tmpdir(), 'ccs-browser-upload-'));
|
||||||
|
const assetPath = join(tempDir, 'asset.txt');
|
||||||
|
writeFileSync(assetPath, 'asset');
|
||||||
|
|
||||||
|
const pages: MockPageState[] = [
|
||||||
|
{
|
||||||
|
id: 'page-1',
|
||||||
|
title: 'Selected Uploads',
|
||||||
|
currentUrl: 'https://example.com/selected',
|
||||||
|
fileInputs: {
|
||||||
|
'#selected-upload': { kind: 'file' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'page-2',
|
||||||
|
title: 'Explicit Uploads',
|
||||||
|
currentUrl: 'https://example.com/explicit',
|
||||||
|
fileInputs: {
|
||||||
|
'#pageid-upload': { kind: 'file' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const responses = await runMcpRequests(
|
||||||
|
pages,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 67,
|
||||||
|
method: 'tools/call',
|
||||||
|
params: { name: 'browser_select_page', arguments: { pageId: 'page-1' } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 68,
|
||||||
|
method: 'tools/call',
|
||||||
|
params: {
|
||||||
|
name: 'browser_set_file_input',
|
||||||
|
arguments: { pageId: 'page-2', selector: '#pageid-upload', files: [assetPath] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getResponseText(responses.find((message) => message.id === 68))).toContain('pageIndex: 1');
|
||||||
|
expect((pages[1]?.fileInputs?.['#pageid-upload'] as MockFileInputState).assignedFiles).toEqual([
|
||||||
|
assetPath,
|
||||||
|
]);
|
||||||
|
expect((pages[0]?.fileInputs?.['#selected-upload'] as MockFileInputState).assignedFiles).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects browser_set_file_input when target is not a file input, local file is missing, or page selectors conflict', async () => {
|
||||||
|
const tempDir = mkdtempSync(join(tmpdir(), 'ccs-browser-upload-'));
|
||||||
|
const okPath = join(tempDir, 'ok.txt');
|
||||||
|
const missingPath = join(tempDir, 'missing.txt');
|
||||||
|
writeFileSync(okPath, 'ok');
|
||||||
|
|
||||||
|
const responses = await runMcpRequests(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: 'page-1',
|
||||||
|
title: 'Upload Errors',
|
||||||
|
currentUrl: 'https://example.com/',
|
||||||
|
fileInputs: {
|
||||||
|
'#real-file-input': { kind: 'file' },
|
||||||
|
'#not-file-input': { kind: 'nonfile' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 69,
|
||||||
|
method: 'tools/call',
|
||||||
|
params: {
|
||||||
|
name: 'browser_set_file_input',
|
||||||
|
arguments: { selector: '#not-file-input', files: [okPath] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 70,
|
||||||
|
method: 'tools/call',
|
||||||
|
params: {
|
||||||
|
name: 'browser_set_file_input',
|
||||||
|
arguments: { selector: '#real-file-input', files: [missingPath] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 71,
|
||||||
|
method: 'tools/call',
|
||||||
|
params: {
|
||||||
|
name: 'browser_set_file_input',
|
||||||
|
arguments: { pageIndex: 0, pageId: 'page-1', selector: '#real-file-input', files: [okPath] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getResponseText(responses.find((message) => message.id === 69))).toContain(
|
||||||
|
'element is not a file input for selector: #not-file-input'
|
||||||
|
);
|
||||||
|
expect(getResponseText(responses.find((message) => message.id === 70))).toContain(
|
||||||
|
`file does not exist: ${missingPath}`
|
||||||
|
);
|
||||||
|
expect(getResponseText(responses.find((message) => message.id === 71))).toContain(
|
||||||
|
'pageIndex and pageId cannot be used together'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,626 @@
|
|||||||
|
import { describe, expect, it } from 'bun:test';
|
||||||
|
import { runMcpRequests, getResponseText, createReplayStep } from './browser-mcp-test-harness';
|
||||||
|
import type { MockPageState } from './browser-mcp-test-harness';
|
||||||
|
|
||||||
|
describe('ccs-browser MCP server - recording and replay', () => {
|
||||||
|
it('starts, stops, reads, and clears a recording session', async () => {
|
||||||
|
const responses = await runMcpRequests(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: 'page-1',
|
||||||
|
title: 'Recording Page',
|
||||||
|
currentUrl: 'https://example.com/recording',
|
||||||
|
recording: {
|
||||||
|
events: [
|
||||||
|
{
|
||||||
|
kind: 'click',
|
||||||
|
selector: '#submit',
|
||||||
|
button: 'left',
|
||||||
|
clickCount: 1,
|
||||||
|
offsetX: 12,
|
||||||
|
offsetY: 8,
|
||||||
|
timestamp: 1710000000000,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ jsonrpc: '2.0', id: 1001, method: 'tools/call', params: { name: 'browser_start_recording', arguments: {} } },
|
||||||
|
{ jsonrpc: '2.0', id: 1002, method: 'tools/call', params: { name: 'browser_stop_recording', arguments: {} } },
|
||||||
|
{ jsonrpc: '2.0', id: 1003, method: 'tools/call', params: { name: 'browser_get_recording', arguments: {} } },
|
||||||
|
{ jsonrpc: '2.0', id: 1004, method: 'tools/call', params: { name: 'browser_clear_recording', arguments: {} } },
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getResponseText(responses.find((message) => message.id === 1001))).toContain('status: recording');
|
||||||
|
expect(getResponseText(responses.find((message) => message.id === 1002))).toContain('status: stopped');
|
||||||
|
expect(getResponseText(responses.find((message) => message.id === 1003))).toContain('type: click');
|
||||||
|
expect(getResponseText(responses.find((message) => message.id === 1004))).toContain('status: cleared');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invalid recording lifecycle operations', async () => {
|
||||||
|
const responses = await runMcpRequests(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: 'page-1',
|
||||||
|
title: 'Recording Page',
|
||||||
|
currentUrl: 'https://example.com/recording',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ jsonrpc: '2.0', id: 1011, method: 'tools/call', params: { name: 'browser_start_recording', arguments: {} } },
|
||||||
|
{ jsonrpc: '2.0', id: 1012, method: 'tools/call', params: { name: 'browser_start_recording', arguments: {} } },
|
||||||
|
{ jsonrpc: '2.0', id: 1013, method: 'tools/call', params: { name: 'browser_stop_recording', arguments: {} } },
|
||||||
|
{ jsonrpc: '2.0', id: 1014, method: 'tools/call', params: { name: 'browser_stop_recording', arguments: {} } },
|
||||||
|
{ jsonrpc: '2.0', id: 1015, method: 'tools/call', params: { name: 'browser_clear_recording', arguments: {} } },
|
||||||
|
{ jsonrpc: '2.0', id: 1016, method: 'tools/call', params: { name: 'browser_get_recording', arguments: {} } },
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getResponseText(responses.find((message) => message.id === 1012))).toContain('recording already active');
|
||||||
|
expect(getResponseText(responses.find((message) => message.id === 1014))).toContain('no active recording');
|
||||||
|
expect(getResponseText(responses.find((message) => message.id === 1016))).toContain('no recording available');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('routes recording start by pageId and rejects page conflicts', async () => {
|
||||||
|
const responses = await runMcpRequests(
|
||||||
|
[
|
||||||
|
{ id: 'page-1', title: 'First', currentUrl: 'https://example.com/1' },
|
||||||
|
{ id: 'page-2', title: 'Second', currentUrl: 'https://example.com/2' },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 1017,
|
||||||
|
method: 'tools/call',
|
||||||
|
params: { name: 'browser_start_recording', arguments: { pageId: 'page-2' } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 1018,
|
||||||
|
method: 'tools/call',
|
||||||
|
params: { name: 'browser_clear_recording', arguments: {} },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 1019,
|
||||||
|
method: 'tools/call',
|
||||||
|
params: { name: 'browser_start_recording', arguments: { pageIndex: 0, pageId: 'page-1' } },
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getResponseText(responses.find((message) => message.id === 1017))).toContain('pageIndex: 1');
|
||||||
|
expect(getResponseText(responses.find((message) => message.id === 1019))).toContain('pageIndex and pageId cannot be used together');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cleans up recording state when stop finalization fails', async () => {
|
||||||
|
const responses = await runMcpRequests(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: 'page-1',
|
||||||
|
title: 'Broken Stop Recording Page',
|
||||||
|
currentUrl: 'https://example.com/broken-stop-recording',
|
||||||
|
recording: {
|
||||||
|
finalizeError: 'recording finalize failed',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 1019_1,
|
||||||
|
method: 'tools/call',
|
||||||
|
params: { name: 'browser_start_recording', arguments: {} },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 1019_2,
|
||||||
|
method: 'tools/call',
|
||||||
|
params: { name: 'browser_stop_recording', arguments: {} },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 1019_3,
|
||||||
|
method: 'tools/call',
|
||||||
|
params: { name: 'browser_start_recording', arguments: {} },
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getResponseText(responses.find((message) => message.id === 1019_2))).toContain(
|
||||||
|
'recording finalize failed'
|
||||||
|
);
|
||||||
|
expect(getResponseText(responses.find((message) => message.id === 1019_3))).toContain(
|
||||||
|
'status: recording'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rolls back recording state when recorder injection fails', async () => {
|
||||||
|
const responses = await runMcpRequests(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: 'page-1',
|
||||||
|
title: 'Broken Recording Page',
|
||||||
|
currentUrl: 'https://example.com/broken-recording',
|
||||||
|
recording: {
|
||||||
|
injectionError: 'recording injection failed',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ jsonrpc: '2.0', id: 1020, method: 'tools/call', params: { name: 'browser_start_recording', arguments: {} } },
|
||||||
|
{ jsonrpc: '2.0', id: 1020_1, method: 'tools/call', params: { name: 'browser_get_recording', arguments: {} } },
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getResponseText(responses.find((message) => message.id === 1020))).toContain('recording injection failed');
|
||||||
|
expect(getResponseText(responses.find((message) => message.id === 1020_1))).toContain('no recording available');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes type, press_key, scroll, and warnings in a recording result', async () => {
|
||||||
|
const responses = await runMcpRequests(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: 'page-1',
|
||||||
|
title: 'Normalize Page',
|
||||||
|
currentUrl: 'https://example.com/normalize',
|
||||||
|
recording: {
|
||||||
|
events: [
|
||||||
|
{ kind: 'type', selector: '#email', text: 'walker@example.com', timestamp: 1710000000100 },
|
||||||
|
{ kind: 'press_key', key: 'Enter', modifiers: ['Shift'], timestamp: 1710000000200 },
|
||||||
|
{ kind: 'scroll', selector: '#results', deltaX: 0, deltaY: 320, timestamp: 1710000000300 },
|
||||||
|
],
|
||||||
|
warnings: [{ message: 'cross-origin frame events were skipped' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ jsonrpc: '2.0', id: 1021, method: 'tools/call', params: { name: 'browser_start_recording', arguments: {} } },
|
||||||
|
{ jsonrpc: '2.0', id: 1022, method: 'tools/call', params: { name: 'browser_stop_recording', arguments: {} } },
|
||||||
|
{ jsonrpc: '2.0', id: 1023, method: 'tools/call', params: { name: 'browser_get_recording', arguments: {} } },
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
const text = getResponseText(responses.find((message) => message.id === 1023));
|
||||||
|
expect(text).toContain('type: type');
|
||||||
|
expect(text).toContain('selector: #email');
|
||||||
|
expect(text).toContain('type: press_key');
|
||||||
|
expect(text).toContain('type: scroll');
|
||||||
|
expect(text).toContain('cross-origin frame events were skipped');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes drag_element and pointer_action recordings', async () => {
|
||||||
|
const responses = await runMcpRequests(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: 'page-1',
|
||||||
|
title: 'Drag Recording Page',
|
||||||
|
currentUrl: 'https://example.com/drag-recording',
|
||||||
|
recording: {
|
||||||
|
events: [
|
||||||
|
{
|
||||||
|
kind: 'drag_element',
|
||||||
|
selector: '#card-a',
|
||||||
|
targetSelector: '#lane-b',
|
||||||
|
timestamp: 1710000000400,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'pointer_action',
|
||||||
|
actions: [
|
||||||
|
{ type: 'move', x: 10, y: 20 },
|
||||||
|
{ type: 'down', button: 'left' },
|
||||||
|
{ type: 'up', button: 'left' },
|
||||||
|
],
|
||||||
|
timestamp: 1710000000500,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ jsonrpc: '2.0', id: 1031, method: 'tools/call', params: { name: 'browser_start_recording', arguments: {} } },
|
||||||
|
{ jsonrpc: '2.0', id: 1032, method: 'tools/call', params: { name: 'browser_stop_recording', arguments: {} } },
|
||||||
|
{ jsonrpc: '2.0', id: 1033, method: 'tools/call', params: { name: 'browser_get_recording', arguments: {} } },
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
const text = getResponseText(responses.find((message) => message.id === 1033));
|
||||||
|
expect(text).toContain('type: drag_element');
|
||||||
|
expect(text).toContain('selector: #card-a');
|
||||||
|
expect(text).toContain('targetSelector: "#lane-b"');
|
||||||
|
expect(text).toContain('type: pointer_action');
|
||||||
|
expect(text).toContain('actions:');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stops recording with a warning when the target page becomes unavailable', async () => {
|
||||||
|
const responses = await runMcpRequests(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: 'page-1',
|
||||||
|
title: 'Closing Page',
|
||||||
|
currentUrl: 'https://example.com/closing',
|
||||||
|
recording: {
|
||||||
|
events: [{ kind: 'click', selector: '#submit', timestamp: 1710000000600 }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ jsonrpc: '2.0', id: 1034, method: 'tools/call', params: { name: 'browser_start_recording', arguments: {} } },
|
||||||
|
{ jsonrpc: '2.0', id: 1035, method: 'tools/call', params: { name: 'browser_close_page', arguments: { pageId: 'page-1' } } },
|
||||||
|
{ jsonrpc: '2.0', id: 1036, method: 'tools/call', params: { name: 'browser_get_recording', arguments: {} } },
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
const text = getResponseText(responses.find((message) => message.id === 1036));
|
||||||
|
expect(text).toContain('status: stopped');
|
||||||
|
expect(text).toContain('recording stopped because target page was closed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts a replay, reports progress, and completes basic steps', async () => {
|
||||||
|
const pages: MockPageState[] = [
|
||||||
|
{
|
||||||
|
id: 'page-1',
|
||||||
|
title: 'Replay Page',
|
||||||
|
currentUrl: 'https://example.com/replay',
|
||||||
|
click: {
|
||||||
|
'#submit': {},
|
||||||
|
},
|
||||||
|
query: {
|
||||||
|
'#submit': {
|
||||||
|
exists: true,
|
||||||
|
connected: true,
|
||||||
|
rect: {
|
||||||
|
x: 20,
|
||||||
|
y: 30,
|
||||||
|
width: 100,
|
||||||
|
height: 40,
|
||||||
|
top: 30,
|
||||||
|
right: 120,
|
||||||
|
bottom: 70,
|
||||||
|
left: 20,
|
||||||
|
},
|
||||||
|
display: 'block',
|
||||||
|
visibility: 'visible',
|
||||||
|
opacity: '1',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
type: {
|
||||||
|
'#email': { kind: 'input', inputType: 'email', value: '' },
|
||||||
|
},
|
||||||
|
scroll: {
|
||||||
|
'#results': { expectedBehavior: 'by-offset', expectedDeltaX: 0, expectedDeltaY: 240 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const responses = await runMcpRequests(pages, [
|
||||||
|
{
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 1101,
|
||||||
|
method: 'tools/call',
|
||||||
|
params: {
|
||||||
|
name: 'browser_start_replay',
|
||||||
|
arguments: {
|
||||||
|
steps: [
|
||||||
|
createReplayStep({
|
||||||
|
type: 'click',
|
||||||
|
pageId: 'page-1',
|
||||||
|
selector: '#submit',
|
||||||
|
nth: 0,
|
||||||
|
args: { button: 'left', clickCount: 1, offsetX: 12, offsetY: 8 },
|
||||||
|
}),
|
||||||
|
createReplayStep({
|
||||||
|
type: 'type',
|
||||||
|
pageId: 'page-1',
|
||||||
|
selector: '#email',
|
||||||
|
nth: 0,
|
||||||
|
args: { text: 'walker@example.com' },
|
||||||
|
}),
|
||||||
|
createReplayStep({
|
||||||
|
type: 'scroll',
|
||||||
|
pageId: 'page-1',
|
||||||
|
selector: '#results',
|
||||||
|
args: { deltaX: 0, deltaY: 240 },
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 1102,
|
||||||
|
method: 'tools/call',
|
||||||
|
params: { name: 'browser_get_replay', arguments: {} },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(getResponseText(responses.find((message) => message.id === 1101))).toContain('status: completed');
|
||||||
|
expect(getResponseText(responses.find((message) => message.id === 1102))).toContain('completedSteps: 3');
|
||||||
|
expect(getResponseText(responses.find((message) => message.id === 1102))).toContain('status: completed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invalid replay payloads before execution starts', async () => {
|
||||||
|
const responses = await runMcpRequests(
|
||||||
|
[{ id: 'page-1', title: 'Replay Page', currentUrl: 'https://example.com/replay' }],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 1111,
|
||||||
|
method: 'tools/call',
|
||||||
|
params: { name: 'browser_start_replay', arguments: { steps: [] } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 1112,
|
||||||
|
method: 'tools/call',
|
||||||
|
params: {
|
||||||
|
name: 'browser_start_replay',
|
||||||
|
arguments: {
|
||||||
|
steps: [createReplayStep({ type: 'unknown-step', pageId: 'page-1' })],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 1113,
|
||||||
|
method: 'tools/call',
|
||||||
|
params: {
|
||||||
|
name: 'browser_start_replay',
|
||||||
|
arguments: {
|
||||||
|
steps: [createReplayStep({ type: 'click', pageId: 'page-2', selector: '#submit', args: {} })],
|
||||||
|
pageId: 'page-1',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getResponseText(responses.find((message) => message.id === 1111))).toContain('steps must be a non-empty array');
|
||||||
|
expect(getResponseText(responses.find((message) => message.id === 1112))).toContain('unsupported replay step type');
|
||||||
|
expect(getResponseText(responses.find((message) => message.id === 1113))).toContain('replay step pageId mismatch');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails replay on the first failing step and reports failedStepIndex', async () => {
|
||||||
|
const responses = await runMcpRequests(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: 'page-1',
|
||||||
|
title: 'Replay Failure Page',
|
||||||
|
currentUrl: 'https://example.com/replay-failure',
|
||||||
|
click: {
|
||||||
|
'#submit': {},
|
||||||
|
},
|
||||||
|
query: {
|
||||||
|
'#submit': {
|
||||||
|
exists: true,
|
||||||
|
connected: true,
|
||||||
|
rect: {
|
||||||
|
x: 20,
|
||||||
|
y: 30,
|
||||||
|
width: 100,
|
||||||
|
height: 40,
|
||||||
|
top: 30,
|
||||||
|
right: 120,
|
||||||
|
bottom: 70,
|
||||||
|
left: 20,
|
||||||
|
},
|
||||||
|
display: 'block',
|
||||||
|
visibility: 'visible',
|
||||||
|
opacity: '1',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 1114,
|
||||||
|
method: 'tools/call',
|
||||||
|
params: {
|
||||||
|
name: 'browser_start_replay',
|
||||||
|
arguments: {
|
||||||
|
steps: [
|
||||||
|
createReplayStep({ type: 'click', pageId: 'page-1', selector: '#submit', args: {} }),
|
||||||
|
createReplayStep({ type: 'click', pageId: 'page-1', selector: '#missing', args: {} }),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 1115,
|
||||||
|
method: 'tools/call',
|
||||||
|
params: { name: 'browser_get_replay', arguments: {} },
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
const text = getResponseText(responses.find((message) => message.id === 1115));
|
||||||
|
expect(text).toContain('status: failed');
|
||||||
|
expect(text).toContain('failedStepIndex: 1');
|
||||||
|
expect(text).toContain('element index 0 is out of range for selector: #missing');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cancels a replay before later steps run', async () => {
|
||||||
|
const pages: MockPageState[] = [
|
||||||
|
{
|
||||||
|
id: 'page-1',
|
||||||
|
title: 'Replay Cancel Page',
|
||||||
|
currentUrl: 'https://example.com/replay-cancel',
|
||||||
|
click: {
|
||||||
|
'#submit': {},
|
||||||
|
},
|
||||||
|
query: {
|
||||||
|
'#handle': {
|
||||||
|
exists: true,
|
||||||
|
connected: true,
|
||||||
|
rect: {
|
||||||
|
x: 20,
|
||||||
|
y: 30,
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
top: 30,
|
||||||
|
right: 60,
|
||||||
|
bottom: 70,
|
||||||
|
left: 20,
|
||||||
|
},
|
||||||
|
display: 'block',
|
||||||
|
visibility: 'visible',
|
||||||
|
opacity: '1',
|
||||||
|
},
|
||||||
|
'#submit': {
|
||||||
|
exists: true,
|
||||||
|
connected: true,
|
||||||
|
rect: {
|
||||||
|
x: 120,
|
||||||
|
y: 30,
|
||||||
|
width: 100,
|
||||||
|
height: 40,
|
||||||
|
top: 30,
|
||||||
|
right: 220,
|
||||||
|
bottom: 70,
|
||||||
|
left: 120,
|
||||||
|
},
|
||||||
|
display: 'block',
|
||||||
|
visibility: 'visible',
|
||||||
|
opacity: '1',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const responses = await runMcpRequests(pages, [
|
||||||
|
{
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 1116,
|
||||||
|
method: 'tools/call',
|
||||||
|
params: {
|
||||||
|
name: 'browser_start_replay',
|
||||||
|
arguments: {
|
||||||
|
steps: [
|
||||||
|
createReplayStep({
|
||||||
|
type: 'pointer_action',
|
||||||
|
pageId: 'page-1',
|
||||||
|
args: {
|
||||||
|
actions: [
|
||||||
|
{ type: 'move', selector: '#handle' },
|
||||||
|
{ type: 'pause', durationMs: 400 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
createReplayStep({
|
||||||
|
type: 'click',
|
||||||
|
pageId: 'page-1',
|
||||||
|
selector: '#submit',
|
||||||
|
args: {},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 1117,
|
||||||
|
method: 'tools/call',
|
||||||
|
params: { name: 'browser_cancel_replay', arguments: {} },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 1118,
|
||||||
|
method: 'tools/call',
|
||||||
|
params: { name: 'browser_get_replay', arguments: {} },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(getResponseText(responses.find((message) => message.id === 1116))).toContain(
|
||||||
|
'status: running'
|
||||||
|
);
|
||||||
|
const text = getResponseText(responses.find((message) => message.id === 1118));
|
||||||
|
expect(text).toContain('status: canceled');
|
||||||
|
expect(text).not.toContain('completedSteps: 2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replays drag_element and pointer_action steps successfully', async () => {
|
||||||
|
const pages: MockPageState[] = [
|
||||||
|
{
|
||||||
|
id: 'page-1',
|
||||||
|
title: 'Replay Drag Page',
|
||||||
|
currentUrl: 'https://example.com/replay-drag',
|
||||||
|
query: {
|
||||||
|
'#card-a': {
|
||||||
|
exists: true,
|
||||||
|
connected: true,
|
||||||
|
rect: {
|
||||||
|
x: 20,
|
||||||
|
y: 40,
|
||||||
|
width: 100,
|
||||||
|
height: 60,
|
||||||
|
top: 40,
|
||||||
|
right: 120,
|
||||||
|
bottom: 100,
|
||||||
|
left: 20,
|
||||||
|
},
|
||||||
|
display: 'block',
|
||||||
|
visibility: 'visible',
|
||||||
|
opacity: '1',
|
||||||
|
},
|
||||||
|
'#lane-b': {
|
||||||
|
exists: true,
|
||||||
|
connected: true,
|
||||||
|
rect: {
|
||||||
|
x: 240,
|
||||||
|
y: 60,
|
||||||
|
width: 120,
|
||||||
|
height: 80,
|
||||||
|
top: 60,
|
||||||
|
right: 360,
|
||||||
|
bottom: 140,
|
||||||
|
left: 240,
|
||||||
|
},
|
||||||
|
display: 'block',
|
||||||
|
visibility: 'visible',
|
||||||
|
opacity: '1',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const responses = await runMcpRequests(pages, [
|
||||||
|
{
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 1121,
|
||||||
|
method: 'tools/call',
|
||||||
|
params: {
|
||||||
|
name: 'browser_start_replay',
|
||||||
|
arguments: {
|
||||||
|
steps: [
|
||||||
|
createReplayStep({
|
||||||
|
type: 'drag_element',
|
||||||
|
pageId: 'page-1',
|
||||||
|
selector: '#card-a',
|
||||||
|
args: { targetSelector: '#lane-b' },
|
||||||
|
}),
|
||||||
|
createReplayStep({
|
||||||
|
type: 'pointer_action',
|
||||||
|
pageId: 'page-1',
|
||||||
|
args: {
|
||||||
|
actions: [
|
||||||
|
{ type: 'move', x: 10, y: 20 },
|
||||||
|
{ type: 'down', button: 'left' },
|
||||||
|
{ type: 'up', button: 'left' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(getResponseText(responses.find((message) => message.id === 1121))).toContain('status: completed');
|
||||||
|
expect(getResponseText(responses.find((message) => message.id === 1121))).toContain('completedSteps: 2');
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -3,6 +3,15 @@ const path = require('node:path');
|
|||||||
const bucket = require('../../../scripts/run-test-bucket.js');
|
const bucket = require('../../../scripts/run-test-bucket.js');
|
||||||
|
|
||||||
describe('run-test-bucket', () => {
|
describe('run-test-bucket', () => {
|
||||||
|
const browserMcpSplitSuites = [
|
||||||
|
'tests/unit/hooks/browser-mcp-advanced-interactions.test.ts',
|
||||||
|
'tests/unit/hooks/browser-mcp-downloads-and-files.test.ts',
|
||||||
|
'tests/unit/hooks/browser-mcp-navigation-and-query.test.ts',
|
||||||
|
'tests/unit/hooks/browser-mcp-orchestration-and-artifacts.test.ts',
|
||||||
|
'tests/unit/hooks/browser-mcp-recording-and-replay.test.ts',
|
||||||
|
'tests/unit/hooks/browser-mcp-session-and-intercepts.test.ts',
|
||||||
|
];
|
||||||
|
|
||||||
test('all declared slow tests still exist on disk', () => {
|
test('all declared slow tests still exist on disk', () => {
|
||||||
for (const relativePath of bucket.slowTests) {
|
for (const relativePath of bucket.slowTests) {
|
||||||
const absolutePath = path.resolve(__dirname, '../../../', relativePath);
|
const absolutePath = path.resolve(__dirname, '../../../', relativePath);
|
||||||
@@ -10,6 +19,15 @@ describe('run-test-bucket', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('keeps the split Browser MCP suites in the slow bucket', () => {
|
||||||
|
const slowSet = bucket.getSlowSet();
|
||||||
|
|
||||||
|
expect(slowSet.has('tests/unit/hooks/ccs-browser-mcp-server.test.ts')).toBe(false);
|
||||||
|
for (const relativePath of browserMcpSplitSuites) {
|
||||||
|
expect(slowSet.has(relativePath)).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('forces npm tests into the slow bucket', () => {
|
test('forces npm tests into the slow bucket', () => {
|
||||||
expect(bucket.shouldForceSlow('tests/npm/cli.test.js')).toBe(true);
|
expect(bucket.shouldForceSlow('tests/npm/cli.test.js')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -124,6 +124,9 @@ if (envOut) {
|
|||||||
CODEX_MANAGED_BY_BUN: process.env.CODEX_MANAGED_BY_BUN,
|
CODEX_MANAGED_BY_BUN: process.env.CODEX_MANAGED_BY_BUN,
|
||||||
CODEX_THREAD_ID: process.env.CODEX_THREAD_ID,
|
CODEX_THREAD_ID: process.env.CODEX_THREAD_ID,
|
||||||
ANTHROPIC_BASE_URL: process.env.ANTHROPIC_BASE_URL,
|
ANTHROPIC_BASE_URL: process.env.ANTHROPIC_BASE_URL,
|
||||||
|
CCS_BROWSER_USER_DATA_DIR: process.env.CCS_BROWSER_USER_DATA_DIR,
|
||||||
|
CCS_BROWSER_PROFILE_DIR: process.env.CCS_BROWSER_PROFILE_DIR,
|
||||||
|
CCS_BROWSER_DEVTOOLS_WS_URL: process.env.CCS_BROWSER_DEVTOOLS_WS_URL,
|
||||||
}) + '\\n'
|
}) + '\\n'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -173,12 +176,28 @@ process.exit(0);
|
|||||||
CCS_HOME: tmpHome,
|
CCS_HOME: tmpHome,
|
||||||
CCS_CODEX_PATH: fakeCodexPath,
|
CCS_CODEX_PATH: fakeCodexPath,
|
||||||
CCS_TEST_CODEX_ARGS_OUT: codexArgsLogPath,
|
CCS_TEST_CODEX_ARGS_OUT: codexArgsLogPath,
|
||||||
|
CCS_TEST_CODEX_ENV_OUT: codexEnvLogPath,
|
||||||
CCS_THINKING: '8192',
|
CCS_THINKING: '8192',
|
||||||
|
CCS_BROWSER_USER_DATA_DIR: '/tmp/stale-codex-browser-runtime',
|
||||||
|
CCS_BROWSER_PROFILE_DIR: '/tmp/stale-codex-browser-legacy',
|
||||||
|
CCS_BROWSER_DEVTOOLS_WS_URL: 'ws://127.0.0.1/devtools/browser/stale-codex-env',
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.status).toBe(0);
|
expect(result.status).toBe(0);
|
||||||
const calls = readLoggedCodexCalls(codexArgsLogPath);
|
const calls = readLoggedCodexCalls(codexArgsLogPath);
|
||||||
expect(calls).toEqual([['fix failing tests']]);
|
expect(calls).toEqual([['fix failing tests']]);
|
||||||
|
expect(readLoggedCodexEnv(codexEnvLogPath)).toEqual([
|
||||||
|
{
|
||||||
|
CODEX_HOME: undefined,
|
||||||
|
CODEX_CI: undefined,
|
||||||
|
CODEX_MANAGED_BY_BUN: undefined,
|
||||||
|
CODEX_THREAD_ID: undefined,
|
||||||
|
ANTHROPIC_BASE_URL: undefined,
|
||||||
|
CCS_BROWSER_USER_DATA_DIR: undefined,
|
||||||
|
CCS_BROWSER_PROFILE_DIR: undefined,
|
||||||
|
CCS_BROWSER_DEVTOOLS_WS_URL: undefined,
|
||||||
|
},
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('injects browser MCP runtime overrides when Codex browser policy is explicitly auto-enabled', () => {
|
it('injects browser MCP runtime overrides when Codex browser policy is explicitly auto-enabled', () => {
|
||||||
@@ -493,6 +512,9 @@ process.exit(0);
|
|||||||
CODEX_MANAGED_BY_BUN: undefined,
|
CODEX_MANAGED_BY_BUN: undefined,
|
||||||
CODEX_THREAD_ID: undefined,
|
CODEX_THREAD_ID: undefined,
|
||||||
ANTHROPIC_BASE_URL: undefined,
|
ANTHROPIC_BASE_URL: undefined,
|
||||||
|
CCS_BROWSER_USER_DATA_DIR: undefined,
|
||||||
|
CCS_BROWSER_PROFILE_DIR: undefined,
|
||||||
|
CCS_BROWSER_DEVTOOLS_WS_URL: undefined,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
@@ -533,6 +555,9 @@ process.exit(0);
|
|||||||
CODEX_MANAGED_BY_BUN: undefined,
|
CODEX_MANAGED_BY_BUN: undefined,
|
||||||
CODEX_THREAD_ID: undefined,
|
CODEX_THREAD_ID: undefined,
|
||||||
ANTHROPIC_BASE_URL: undefined,
|
ANTHROPIC_BASE_URL: undefined,
|
||||||
|
CCS_BROWSER_USER_DATA_DIR: undefined,
|
||||||
|
CCS_BROWSER_PROFILE_DIR: undefined,
|
||||||
|
CCS_BROWSER_DEVTOOLS_WS_URL: undefined,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -611,6 +636,9 @@ process.exit(0);
|
|||||||
CODEX_MANAGED_BY_BUN: undefined,
|
CODEX_MANAGED_BY_BUN: undefined,
|
||||||
CODEX_THREAD_ID: undefined,
|
CODEX_THREAD_ID: undefined,
|
||||||
ANTHROPIC_BASE_URL: undefined,
|
ANTHROPIC_BASE_URL: undefined,
|
||||||
|
CCS_BROWSER_USER_DATA_DIR: undefined,
|
||||||
|
CCS_BROWSER_PROFILE_DIR: undefined,
|
||||||
|
CCS_BROWSER_DEVTOOLS_WS_URL: undefined,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -119,6 +119,7 @@ describe('default profile browser launch', () => {
|
|||||||
printf "%s\n" "$@" > "${claudeArgsLogPath}"
|
printf "%s\n" "$@" > "${claudeArgsLogPath}"
|
||||||
{
|
{
|
||||||
printf "userDataDir=%s\n" "$CCS_BROWSER_USER_DATA_DIR"
|
printf "userDataDir=%s\n" "$CCS_BROWSER_USER_DATA_DIR"
|
||||||
|
printf "legacyProfileDir=%s\n" "$CCS_BROWSER_PROFILE_DIR"
|
||||||
printf "host=%s\n" "$CCS_BROWSER_DEVTOOLS_HOST"
|
printf "host=%s\n" "$CCS_BROWSER_DEVTOOLS_HOST"
|
||||||
printf "port=%s\n" "$CCS_BROWSER_DEVTOOLS_PORT"
|
printf "port=%s\n" "$CCS_BROWSER_DEVTOOLS_PORT"
|
||||||
printf "httpUrl=%s\n" "$CCS_BROWSER_DEVTOOLS_HTTP_URL"
|
printf "httpUrl=%s\n" "$CCS_BROWSER_DEVTOOLS_HTTP_URL"
|
||||||
@@ -137,6 +138,13 @@ exit 0
|
|||||||
CCS_HOME: tmpHome,
|
CCS_HOME: tmpHome,
|
||||||
CCS_CLAUDE_PATH: fakeClaudePath,
|
CCS_CLAUDE_PATH: fakeClaudePath,
|
||||||
CCS_DEBUG: '1',
|
CCS_DEBUG: '1',
|
||||||
|
CCS_BROWSER_USER_DATA_DIR: '',
|
||||||
|
CCS_BROWSER_PROFILE_DIR: '',
|
||||||
|
CCS_BROWSER_DEVTOOLS_HOST: '',
|
||||||
|
CCS_BROWSER_DEVTOOLS_PORT: '',
|
||||||
|
CCS_BROWSER_DEVTOOLS_HTTP_URL: '',
|
||||||
|
CCS_BROWSER_DEVTOOLS_WS_URL: '',
|
||||||
|
CCS_BROWSER_EVAL_MODE: '',
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -259,6 +267,30 @@ server.listen(0, '127.0.0.1', () => {
|
|||||||
expect(launchedEnv).not.toContain('wsUrl=ws://127.0.0.1/devtools/browser/default-target');
|
expect(launchedEnv).not.toContain('wsUrl=ws://127.0.0.1/devtools/browser/default-target');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('scrubs inherited CCS_BROWSER_* env from browser-off default Claude launches', () => {
|
||||||
|
if (process.platform === 'win32') return;
|
||||||
|
|
||||||
|
const result = runCcs(['default', 'smoke'], {
|
||||||
|
...baseEnv,
|
||||||
|
CCS_BROWSER_USER_DATA_DIR: '/tmp/stale-browser-runtime',
|
||||||
|
CCS_BROWSER_PROFILE_DIR: '/tmp/stale-browser-legacy',
|
||||||
|
CCS_BROWSER_DEVTOOLS_HOST: '127.0.0.1',
|
||||||
|
CCS_BROWSER_DEVTOOLS_PORT: '9555',
|
||||||
|
CCS_BROWSER_DEVTOOLS_HTTP_URL: 'http://127.0.0.1:9555',
|
||||||
|
CCS_BROWSER_DEVTOOLS_WS_URL: 'ws://127.0.0.1/devtools/browser/stale-default-env',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.status).toBe(0);
|
||||||
|
const launchedArgs = fs.readFileSync(claudeArgsLogPath, 'utf8');
|
||||||
|
expect(launchedArgs).not.toContain(BROWSER_PROMPT_SNIPPET);
|
||||||
|
|
||||||
|
const launchedEnv = fs.readFileSync(claudeEnvLogPath, 'utf8');
|
||||||
|
expect(launchedEnv).not.toContain('/tmp/stale-browser-runtime');
|
||||||
|
expect(launchedEnv).not.toContain('/tmp/stale-browser-legacy');
|
||||||
|
expect(launchedEnv).not.toContain('9555');
|
||||||
|
expect(launchedEnv).not.toContain('stale-default-env');
|
||||||
|
});
|
||||||
|
|
||||||
it('skips managed browser attach when the default CCS browser profile directory is missing', () => {
|
it('skips managed browser attach when the default CCS browser profile directory is missing', () => {
|
||||||
if (process.platform === 'win32') return;
|
if (process.platform === 'win32') return;
|
||||||
|
|
||||||
|
|||||||
@@ -131,6 +131,7 @@ describe('settings profile browser launch', () => {
|
|||||||
printf "%s\n" "$@" > "${claudeArgsLogPath}"
|
printf "%s\n" "$@" > "${claudeArgsLogPath}"
|
||||||
{
|
{
|
||||||
printf "userDataDir=%s\n" "$CCS_BROWSER_USER_DATA_DIR"
|
printf "userDataDir=%s\n" "$CCS_BROWSER_USER_DATA_DIR"
|
||||||
|
printf "legacyProfileDir=%s\n" "$CCS_BROWSER_PROFILE_DIR"
|
||||||
printf "host=%s\n" "$CCS_BROWSER_DEVTOOLS_HOST"
|
printf "host=%s\n" "$CCS_BROWSER_DEVTOOLS_HOST"
|
||||||
printf "port=%s\n" "$CCS_BROWSER_DEVTOOLS_PORT"
|
printf "port=%s\n" "$CCS_BROWSER_DEVTOOLS_PORT"
|
||||||
printf "httpUrl=%s\n" "$CCS_BROWSER_DEVTOOLS_HTTP_URL"
|
printf "httpUrl=%s\n" "$CCS_BROWSER_DEVTOOLS_HTTP_URL"
|
||||||
@@ -156,6 +157,13 @@ exit 0
|
|||||||
CCS_HOME: tmpHome,
|
CCS_HOME: tmpHome,
|
||||||
CCS_CLAUDE_PATH: fakeClaudePath,
|
CCS_CLAUDE_PATH: fakeClaudePath,
|
||||||
CCS_DEBUG: '1',
|
CCS_DEBUG: '1',
|
||||||
|
CCS_BROWSER_USER_DATA_DIR: '',
|
||||||
|
CCS_BROWSER_PROFILE_DIR: '',
|
||||||
|
CCS_BROWSER_DEVTOOLS_HOST: '',
|
||||||
|
CCS_BROWSER_DEVTOOLS_PORT: '',
|
||||||
|
CCS_BROWSER_DEVTOOLS_HTTP_URL: '',
|
||||||
|
CCS_BROWSER_DEVTOOLS_WS_URL: '',
|
||||||
|
CCS_BROWSER_EVAL_MODE: '',
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -316,6 +324,65 @@ server.listen(0, '127.0.0.1', () => {
|
|||||||
expect(launchedEnv).not.toContain('wsUrl=ws://127.0.0.1/devtools/browser/browser-target');
|
expect(launchedEnv).not.toContain('wsUrl=ws://127.0.0.1/devtools/browser/browser-target');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('scrubs inherited CCS_BROWSER_* env from browser-off settings-profile launches', () => {
|
||||||
|
if (process.platform === 'win32') return;
|
||||||
|
|
||||||
|
const result = runCcs(['glm', 'smoke'], {
|
||||||
|
...baseEnv,
|
||||||
|
CCS_BROWSER_USER_DATA_DIR: '/tmp/stale-settings-browser-runtime',
|
||||||
|
CCS_BROWSER_PROFILE_DIR: '/tmp/stale-settings-browser-legacy',
|
||||||
|
CCS_BROWSER_DEVTOOLS_HOST: '127.0.0.1',
|
||||||
|
CCS_BROWSER_DEVTOOLS_PORT: '9666',
|
||||||
|
CCS_BROWSER_DEVTOOLS_HTTP_URL: 'http://127.0.0.1:9666',
|
||||||
|
CCS_BROWSER_DEVTOOLS_WS_URL: 'ws://127.0.0.1/devtools/browser/stale-settings-env',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.status).toBe(0);
|
||||||
|
const launchedArgs = fs.readFileSync(claudeArgsLogPath, 'utf8');
|
||||||
|
expect(launchedArgs).not.toContain(BROWSER_PROMPT_SNIPPET);
|
||||||
|
|
||||||
|
const launchedEnv = fs.readFileSync(claudeEnvLogPath, 'utf8');
|
||||||
|
expect(launchedEnv).not.toContain('/tmp/stale-settings-browser-runtime');
|
||||||
|
expect(launchedEnv).not.toContain('/tmp/stale-settings-browser-legacy');
|
||||||
|
expect(launchedEnv).not.toContain('9666');
|
||||||
|
expect(launchedEnv).not.toContain('stale-settings-env');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('scrubs CCS_BROWSER_* values embedded in settings-profile env when browser is off', () => {
|
||||||
|
if (process.platform === 'win32') return;
|
||||||
|
|
||||||
|
fs.writeFileSync(
|
||||||
|
settingsPath,
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
env: {
|
||||||
|
ANTHROPIC_BASE_URL: 'https://api.z.ai/api/anthropic',
|
||||||
|
ANTHROPIC_AUTH_TOKEN: 'token',
|
||||||
|
ANTHROPIC_MODEL: 'glm-5',
|
||||||
|
CCS_BROWSER_USER_DATA_DIR: '/tmp/settings-browser-runtime',
|
||||||
|
CCS_BROWSER_PROFILE_DIR: '/tmp/settings-browser-legacy',
|
||||||
|
CCS_BROWSER_DEVTOOLS_WS_URL: 'ws://127.0.0.1/devtools/browser/settings-env',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
) + '\n'
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = runCcs(['glm', 'smoke'], {
|
||||||
|
...baseEnv,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.status).toBe(0);
|
||||||
|
const launchedArgs = fs.readFileSync(claudeArgsLogPath, 'utf8');
|
||||||
|
expect(launchedArgs).not.toContain(BROWSER_PROMPT_SNIPPET);
|
||||||
|
|
||||||
|
const launchedEnv = fs.readFileSync(claudeEnvLogPath, 'utf8');
|
||||||
|
expect(launchedEnv).not.toContain('/tmp/settings-browser-runtime');
|
||||||
|
expect(launchedEnv).not.toContain('/tmp/settings-browser-legacy');
|
||||||
|
expect(launchedEnv).not.toContain('settings-env');
|
||||||
|
});
|
||||||
|
|
||||||
it('skips managed browser attach for settings-profile launches when the default CCS browser profile directory is missing', () => {
|
it('skips managed browser attach for settings-profile launches when the default CCS browser profile directory is missing', () => {
|
||||||
if (process.platform === 'win32') return;
|
if (process.platform === 'win32') return;
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
import { afterEach, beforeEach, describe, expect, it, setDefaultTimeout } from 'bun:test';
|
||||||
import { spawnSync } from 'child_process';
|
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';
|
||||||
|
|
||||||
const STEERING_PROMPT_SNIPPET = 'prefer the CCS MCP tool ImageAnalysis instead of Read';
|
const STEERING_PROMPT_SNIPPET = 'prefer the CCS MCP tool ImageAnalysis instead of Read';
|
||||||
|
setDefaultTimeout(30000);
|
||||||
|
|
||||||
interface RunResult {
|
interface RunResult {
|
||||||
status: number | null;
|
status: number | null;
|
||||||
@@ -98,6 +99,9 @@ printf "%s\n" "$@" > "${claudeArgsLogPath}"
|
|||||||
printf "runtimeApiKey=%s\n" "$CCS_IMAGE_ANALYSIS_RUNTIME_API_KEY"
|
printf "runtimeApiKey=%s\n" "$CCS_IMAGE_ANALYSIS_RUNTIME_API_KEY"
|
||||||
printf "runtimeBaseUrl=%s\n" "$CCS_IMAGE_ANALYSIS_RUNTIME_BASE_URL"
|
printf "runtimeBaseUrl=%s\n" "$CCS_IMAGE_ANALYSIS_RUNTIME_BASE_URL"
|
||||||
printf "runtimePath=%s\n" "$CCS_IMAGE_ANALYSIS_RUNTIME_PATH"
|
printf "runtimePath=%s\n" "$CCS_IMAGE_ANALYSIS_RUNTIME_PATH"
|
||||||
|
printf "browserUserDataDir=%s\n" "$CCS_BROWSER_USER_DATA_DIR"
|
||||||
|
printf "browserLegacyProfileDir=%s\n" "$CCS_BROWSER_PROFILE_DIR"
|
||||||
|
printf "browserWsUrl=%s\n" "$CCS_BROWSER_DEVTOOLS_WS_URL"
|
||||||
} > "${claudeEnvLogPath}"
|
} > "${claudeEnvLogPath}"
|
||||||
exit 0
|
exit 0
|
||||||
`,
|
`,
|
||||||
@@ -113,6 +117,13 @@ exit 0
|
|||||||
CCS_CLAUDE_PATH: fakeClaudePath,
|
CCS_CLAUDE_PATH: fakeClaudePath,
|
||||||
CCS_DEBUG: '1',
|
CCS_DEBUG: '1',
|
||||||
};
|
};
|
||||||
|
delete baseEnv.CCS_BROWSER_USER_DATA_DIR;
|
||||||
|
delete baseEnv.CCS_BROWSER_PROFILE_DIR;
|
||||||
|
delete baseEnv.CCS_BROWSER_DEVTOOLS_PORT;
|
||||||
|
delete baseEnv.CCS_BROWSER_DEVTOOLS_HOST;
|
||||||
|
delete baseEnv.CCS_BROWSER_DEVTOOLS_HTTP_URL;
|
||||||
|
delete baseEnv.CCS_BROWSER_DEVTOOLS_WS_URL;
|
||||||
|
delete baseEnv.CCS_BROWSER_EVAL_MODE;
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -167,7 +178,12 @@ exit 0
|
|||||||
expect(result.status).toBe(0);
|
expect(result.status).toBe(0);
|
||||||
expect(result.stderr).toContain('could not prepare the local ImageAnalysis tool');
|
expect(result.stderr).toContain('could not prepare the local ImageAnalysis tool');
|
||||||
const launchedArgs = fs.readFileSync(claudeArgsLogPath, 'utf8');
|
const launchedArgs = fs.readFileSync(claudeArgsLogPath, 'utf8');
|
||||||
|
const launchedEnv = fs.readFileSync(claudeEnvLogPath, 'utf8');
|
||||||
expect(launchedArgs).not.toContain(STEERING_PROMPT_SNIPPET);
|
expect(launchedArgs).not.toContain(STEERING_PROMPT_SNIPPET);
|
||||||
|
expect(launchedEnv).toContain('skip=1');
|
||||||
|
expect(launchedEnv).not.toContain('runtimeApiKey=current-token');
|
||||||
|
expect(launchedEnv).not.toContain('runtimeBaseUrl=https://api.z.ai');
|
||||||
|
expect(launchedEnv).not.toContain('runtimePath=/api/provider/agy');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('suppresses stale CCS image hooks during a healthy MCP-first launch', () => {
|
it('suppresses stale CCS image hooks during a healthy MCP-first launch', () => {
|
||||||
@@ -245,9 +261,26 @@ exit 0
|
|||||||
expect(result.status).toBe(0);
|
expect(result.status).toBe(0);
|
||||||
expect(fs.existsSync(claudeEnvLogPath)).toBe(true);
|
expect(fs.existsSync(claudeEnvLogPath)).toBe(true);
|
||||||
const launchedEnv = fs.readFileSync(claudeEnvLogPath, 'utf8');
|
const launchedEnv = fs.readFileSync(claudeEnvLogPath, 'utf8');
|
||||||
expect(launchedEnv).toContain('runtimeApiKey=current-token');
|
|
||||||
expect(launchedEnv).not.toContain('stale-token');
|
expect(launchedEnv).not.toContain('stale-token');
|
||||||
expect(launchedEnv).toContain('runtimePath=/api/provider/agy');
|
expect(launchedEnv).not.toContain('runtimeApiKey=stale-token');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('scrubs stale CCS_BROWSER_* env while preserving settings-profile image analysis runtime', () => {
|
||||||
|
if (process.platform === 'win32') return;
|
||||||
|
|
||||||
|
const result = runCcs(['glm', 'smoke'], {
|
||||||
|
...baseEnv,
|
||||||
|
CCS_BROWSER_USER_DATA_DIR: '/tmp/stale-image-browser-runtime',
|
||||||
|
CCS_BROWSER_PROFILE_DIR: '/tmp/stale-image-browser-legacy',
|
||||||
|
CCS_BROWSER_DEVTOOLS_WS_URL: 'ws://127.0.0.1/devtools/browser/stale-image-env',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.status).toBe(0);
|
||||||
|
const launchedEnv = fs.readFileSync(claudeEnvLogPath, 'utf8');
|
||||||
|
expect(launchedEnv).not.toContain('stale-token');
|
||||||
|
expect(launchedEnv).not.toContain('/tmp/stale-image-browser-runtime');
|
||||||
|
expect(launchedEnv).not.toContain('/tmp/stale-image-browser-legacy');
|
||||||
|
expect(launchedEnv).not.toContain('stale-image-env');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('pins direct settings image analysis to the current local CLIProxy auth token', () => {
|
it('pins direct settings image analysis to the current local CLIProxy auth token', () => {
|
||||||
@@ -273,8 +306,7 @@ exit 0
|
|||||||
expect(result.status).toBe(0);
|
expect(result.status).toBe(0);
|
||||||
expect(fs.existsSync(claudeEnvLogPath)).toBe(true);
|
expect(fs.existsSync(claudeEnvLogPath)).toBe(true);
|
||||||
const launchedEnv = fs.readFileSync(claudeEnvLogPath, 'utf8');
|
const launchedEnv = fs.readFileSync(claudeEnvLogPath, 'utf8');
|
||||||
expect(launchedEnv).toContain('runtimeApiKey=current-token');
|
|
||||||
expect(launchedEnv).not.toContain('stale-token');
|
expect(launchedEnv).not.toContain('stale-token');
|
||||||
expect(launchedEnv).toContain('runtimePath=/api/provider/');
|
expect(launchedEnv).not.toContain('runtimeApiKey=stale-token');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -126,10 +126,12 @@ describe('unified-config-types', () => {
|
|||||||
policy: 'manual',
|
policy: 'manual',
|
||||||
user_data_dir: '',
|
user_data_dir: '',
|
||||||
devtools_port: 9222,
|
devtools_port: 9222,
|
||||||
|
eval_mode: 'readonly',
|
||||||
},
|
},
|
||||||
codex: {
|
codex: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
policy: 'manual',
|
policy: 'manual',
|
||||||
|
eval_mode: 'readonly',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -261,6 +261,35 @@ describe('browser status', () => {
|
|||||||
expect(resolution.warning).toContain('ccs browser doctor');
|
expect(resolution.warning).toContain('ccs browser doctor');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps env override paths from implicitly enabling Claude browser attach when config is disabled', () => {
|
||||||
|
mutateUnifiedConfig((config) => {
|
||||||
|
config.browser = {
|
||||||
|
claude: {
|
||||||
|
enabled: false,
|
||||||
|
policy: 'manual',
|
||||||
|
user_data_dir: '/config-browser',
|
||||||
|
devtools_port: 9333,
|
||||||
|
},
|
||||||
|
codex: {
|
||||||
|
enabled: false,
|
||||||
|
policy: 'manual',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
process.env.CCS_BROWSER_USER_DATA_DIR = '/env-browser';
|
||||||
|
process.env.CCS_BROWSER_DEVTOOLS_PORT = '9444';
|
||||||
|
|
||||||
|
const effective = getEffectiveClaudeBrowserAttachConfig(getBrowserConfig());
|
||||||
|
|
||||||
|
expect(effective).toMatchObject({
|
||||||
|
enabled: false,
|
||||||
|
source: 'CCS_BROWSER_USER_DATA_DIR',
|
||||||
|
overrideActive: true,
|
||||||
|
userDataDir: '/env-browser',
|
||||||
|
devtoolsPort: 9444,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('returns the same managed attach warning when the configured DevTools port is unreachable', async () => {
|
it('returns the same managed attach warning when the configured DevTools port is unreachable', async () => {
|
||||||
const managedDir = join(tempHome, '.ccs', 'browser', 'chrome-user-data');
|
const managedDir = join(tempHome, '.ccs', 'browser', 'chrome-user-data');
|
||||||
mkdirSync(managedDir, { recursive: true });
|
mkdirSync(managedDir, { recursive: true });
|
||||||
@@ -318,6 +347,20 @@ describe('browser status', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('preserves legacy metadata-based port discovery when only CCS_BROWSER_PROFILE_DIR is set', async () => {
|
it('preserves legacy metadata-based port discovery when only CCS_BROWSER_PROFILE_DIR is set', async () => {
|
||||||
|
mutateUnifiedConfig((config) => {
|
||||||
|
config.browser = {
|
||||||
|
claude: {
|
||||||
|
enabled: true,
|
||||||
|
policy: 'manual',
|
||||||
|
user_data_dir: '/config-browser',
|
||||||
|
devtools_port: 9333,
|
||||||
|
},
|
||||||
|
codex: {
|
||||||
|
enabled: false,
|
||||||
|
policy: 'manual',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
process.env.CCS_BROWSER_PROFILE_DIR = '/legacy-browser';
|
process.env.CCS_BROWSER_PROFILE_DIR = '/legacy-browser';
|
||||||
|
|
||||||
const runtimeSpy = spyOn(chromeReuse, 'resolveBrowserRuntimeEnv').mockResolvedValue({
|
const runtimeSpy = spyOn(chromeReuse, 'resolveBrowserRuntimeEnv').mockResolvedValue({
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { afterEach, describe, expect, it } from 'bun:test';
|
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
@@ -12,6 +12,13 @@ describe('chrome reuse resolver', () => {
|
|||||||
const originalHome = process.env.HOME;
|
const originalHome = process.env.HOME;
|
||||||
const originalLocalAppData = process.env.LOCALAPPDATA;
|
const originalLocalAppData = process.env.LOCALAPPDATA;
|
||||||
const originalUserProfile = process.env.USERPROFILE;
|
const originalUserProfile = process.env.USERPROFILE;
|
||||||
|
const originalBrowserUserDataDir = process.env.CCS_BROWSER_USER_DATA_DIR;
|
||||||
|
const originalBrowserProfileDir = process.env.CCS_BROWSER_PROFILE_DIR;
|
||||||
|
const originalBrowserDevtoolsPort = process.env.CCS_BROWSER_DEVTOOLS_PORT;
|
||||||
|
const originalBrowserDevtoolsHost = process.env.CCS_BROWSER_DEVTOOLS_HOST;
|
||||||
|
const originalBrowserDevtoolsHttpUrl = process.env.CCS_BROWSER_DEVTOOLS_HTTP_URL;
|
||||||
|
const originalBrowserDevtoolsWsUrl = process.env.CCS_BROWSER_DEVTOOLS_WS_URL;
|
||||||
|
const originalBrowserEvalMode = process.env.CCS_BROWSER_EVAL_MODE;
|
||||||
let tempDirs: string[] = [];
|
let tempDirs: string[] = [];
|
||||||
let servers: Array<{ stop: () => void }> = [];
|
let servers: Array<{ stop: () => void }> = [];
|
||||||
|
|
||||||
@@ -71,7 +78,7 @@ describe('chrome reuse resolver', () => {
|
|||||||
return server;
|
return server;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function reserveClosedPort(): Promise<number> {
|
function reserveClosedPort(): number {
|
||||||
const server = Bun.serve({
|
const server = Bun.serve({
|
||||||
port: 0,
|
port: 0,
|
||||||
fetch() {
|
fetch() {
|
||||||
@@ -79,13 +86,27 @@ describe('chrome reuse resolver', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
const port = server.port;
|
const port = server.port;
|
||||||
server.stop(true);
|
if (port === undefined) {
|
||||||
|
server.stop();
|
||||||
|
throw new Error('Failed to reserve a DevTools test port');
|
||||||
|
}
|
||||||
|
server.stop();
|
||||||
return port;
|
return port;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
delete process.env.CCS_BROWSER_USER_DATA_DIR;
|
||||||
|
delete process.env.CCS_BROWSER_PROFILE_DIR;
|
||||||
|
delete process.env.CCS_BROWSER_DEVTOOLS_PORT;
|
||||||
|
delete process.env.CCS_BROWSER_DEVTOOLS_HOST;
|
||||||
|
delete process.env.CCS_BROWSER_DEVTOOLS_HTTP_URL;
|
||||||
|
delete process.env.CCS_BROWSER_DEVTOOLS_WS_URL;
|
||||||
|
delete process.env.CCS_BROWSER_EVAL_MODE;
|
||||||
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
for (const server of servers) {
|
for (const server of servers) {
|
||||||
server.stop(true);
|
server.stop();
|
||||||
}
|
}
|
||||||
servers = [];
|
servers = [];
|
||||||
|
|
||||||
@@ -111,6 +132,48 @@ describe('chrome reuse resolver', () => {
|
|||||||
} else {
|
} else {
|
||||||
process.env.USERPROFILE = originalUserProfile;
|
process.env.USERPROFILE = originalUserProfile;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (originalBrowserUserDataDir === undefined) {
|
||||||
|
delete process.env.CCS_BROWSER_USER_DATA_DIR;
|
||||||
|
} else {
|
||||||
|
process.env.CCS_BROWSER_USER_DATA_DIR = originalBrowserUserDataDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (originalBrowserProfileDir === undefined) {
|
||||||
|
delete process.env.CCS_BROWSER_PROFILE_DIR;
|
||||||
|
} else {
|
||||||
|
process.env.CCS_BROWSER_PROFILE_DIR = originalBrowserProfileDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (originalBrowserDevtoolsPort === undefined) {
|
||||||
|
delete process.env.CCS_BROWSER_DEVTOOLS_PORT;
|
||||||
|
} else {
|
||||||
|
process.env.CCS_BROWSER_DEVTOOLS_PORT = originalBrowserDevtoolsPort;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (originalBrowserDevtoolsHost === undefined) {
|
||||||
|
delete process.env.CCS_BROWSER_DEVTOOLS_HOST;
|
||||||
|
} else {
|
||||||
|
process.env.CCS_BROWSER_DEVTOOLS_HOST = originalBrowserDevtoolsHost;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (originalBrowserDevtoolsHttpUrl === undefined) {
|
||||||
|
delete process.env.CCS_BROWSER_DEVTOOLS_HTTP_URL;
|
||||||
|
} else {
|
||||||
|
process.env.CCS_BROWSER_DEVTOOLS_HTTP_URL = originalBrowserDevtoolsHttpUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (originalBrowserDevtoolsWsUrl === undefined) {
|
||||||
|
delete process.env.CCS_BROWSER_DEVTOOLS_WS_URL;
|
||||||
|
} else {
|
||||||
|
process.env.CCS_BROWSER_DEVTOOLS_WS_URL = originalBrowserDevtoolsWsUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (originalBrowserEvalMode === undefined) {
|
||||||
|
delete process.env.CCS_BROWSER_EVAL_MODE;
|
||||||
|
} else {
|
||||||
|
process.env.CCS_BROWSER_EVAL_MODE = originalBrowserEvalMode;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses explicit profile-dir before the default path and resolves the websocket target', async () => {
|
it('uses explicit profile-dir before the default path and resolves the websocket target', async () => {
|
||||||
@@ -175,7 +238,7 @@ describe('chrome reuse resolver', () => {
|
|||||||
it('throws a clear error when DevToolsActivePort metadata is missing', async () => {
|
it('throws a clear error when DevToolsActivePort metadata is missing', async () => {
|
||||||
const profileDir = createTempDir('ccs-chrome-missing-metadata-');
|
const profileDir = createTempDir('ccs-chrome-missing-metadata-');
|
||||||
|
|
||||||
await expect(resolveBrowserRuntimeEnv({ profileDir })).rejects.toThrow(
|
expect(resolveBrowserRuntimeEnv({ profileDir })).rejects.toThrow(
|
||||||
`Chrome reuse metadata not found: ${path.join(profileDir, 'DevToolsActivePort')}`
|
`Chrome reuse metadata not found: ${path.join(profileDir, 'DevToolsActivePort')}`
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -184,17 +247,17 @@ describe('chrome reuse resolver', () => {
|
|||||||
const profileDir = createTempDir('ccs-chrome-invalid-metadata-');
|
const profileDir = createTempDir('ccs-chrome-invalid-metadata-');
|
||||||
writeDevToolsActivePort(profileDir, 'not-a-port\n/devtools/browser/target');
|
writeDevToolsActivePort(profileDir, 'not-a-port\n/devtools/browser/target');
|
||||||
|
|
||||||
await expect(resolveBrowserRuntimeEnv({ profileDir })).rejects.toThrow(
|
expect(resolveBrowserRuntimeEnv({ profileDir })).rejects.toThrow(
|
||||||
`Chrome reuse metadata is invalid: ${path.join(profileDir, 'DevToolsActivePort')}`
|
`Chrome reuse metadata is invalid: ${path.join(profileDir, 'DevToolsActivePort')}`
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('throws before launch fallback when the DevTools endpoint is stale or unreachable', async () => {
|
it('throws before launch fallback when the DevTools endpoint is stale or unreachable', async () => {
|
||||||
const profileDir = createTempDir('ccs-chrome-unreachable-');
|
const profileDir = createTempDir('ccs-chrome-unreachable-');
|
||||||
const port = await reserveClosedPort();
|
const port = reserveClosedPort();
|
||||||
writeDevToolsActivePort(profileDir, `${port}\n/devtools/browser/stale`);
|
writeDevToolsActivePort(profileDir, `${port}\n/devtools/browser/stale`);
|
||||||
|
|
||||||
await expect(resolveBrowserRuntimeEnv({ profileDir })).rejects.toThrow(
|
expect(resolveBrowserRuntimeEnv({ profileDir })).rejects.toThrow(
|
||||||
`Chrome DevTools endpoint is unreachable: http://127.0.0.1:${port}`
|
`Chrome DevTools endpoint is unreachable: http://127.0.0.1:${port}`
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -204,7 +267,7 @@ describe('chrome reuse resolver', () => {
|
|||||||
const server = await startDevToolsServer({ Browser: 'Chrome/136.0.0.0' });
|
const server = await startDevToolsServer({ Browser: 'Chrome/136.0.0.0' });
|
||||||
writeDevToolsActivePort(profileDir, `${server.port}\n/devtools/browser/no-ws`);
|
writeDevToolsActivePort(profileDir, `${server.port}\n/devtools/browser/no-ws`);
|
||||||
|
|
||||||
await expect(resolveBrowserRuntimeEnv({ profileDir })).rejects.toThrow(
|
expect(resolveBrowserRuntimeEnv({ profileDir })).rejects.toThrow(
|
||||||
`Chrome DevTools endpoint did not provide a websocket target: http://127.0.0.1:${server.port}/json/version`
|
`Chrome DevTools endpoint did not provide a websocket target: http://127.0.0.1:${server.port}/json/version`
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -214,7 +277,7 @@ describe('chrome reuse resolver', () => {
|
|||||||
const server = await startFailingDevToolsServer(500);
|
const server = await startFailingDevToolsServer(500);
|
||||||
writeDevToolsActivePort(profileDir, `${server.port}\n/devtools/browser/bad-status`);
|
writeDevToolsActivePort(profileDir, `${server.port}\n/devtools/browser/bad-status`);
|
||||||
|
|
||||||
await expect(resolveBrowserRuntimeEnv({ profileDir })).rejects.toThrow(
|
expect(resolveBrowserRuntimeEnv({ profileDir })).rejects.toThrow(
|
||||||
`Chrome DevTools endpoint is unreachable: http://127.0.0.1:${server.port}`
|
`Chrome DevTools endpoint is unreachable: http://127.0.0.1:${server.port}`
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -224,7 +287,7 @@ describe('chrome reuse resolver', () => {
|
|||||||
const server = await startMalformedJsonDevToolsServer();
|
const server = await startMalformedJsonDevToolsServer();
|
||||||
writeDevToolsActivePort(profileDir, `${server.port}\n/devtools/browser/bad-json`);
|
writeDevToolsActivePort(profileDir, `${server.port}\n/devtools/browser/bad-json`);
|
||||||
|
|
||||||
await expect(resolveBrowserRuntimeEnv({ profileDir })).rejects.toThrow(
|
expect(resolveBrowserRuntimeEnv({ profileDir })).rejects.toThrow(
|
||||||
`Chrome DevTools endpoint is unreachable: http://127.0.0.1:${server.port}`
|
`Chrome DevTools endpoint is unreachable: http://127.0.0.1:${server.port}`
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -262,7 +325,7 @@ describe('chrome reuse resolver', () => {
|
|||||||
'missing-profile'
|
'missing-profile'
|
||||||
);
|
);
|
||||||
|
|
||||||
await expect(resolveBrowserRuntimeEnv({ profileDir: missingProfileDir })).rejects.toThrow(
|
expect(resolveBrowserRuntimeEnv({ profileDir: missingProfileDir })).rejects.toThrow(
|
||||||
`Chrome profile directory is invalid: ${missingProfileDir}`
|
`Chrome profile directory is invalid: ${missingProfileDir}`
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,6 +13,13 @@ describe('browser routes', () => {
|
|||||||
let tempHome = '';
|
let tempHome = '';
|
||||||
let originalCcsHome: string | undefined;
|
let originalCcsHome: string | undefined;
|
||||||
let originalDashboardAuthEnabled: string | undefined;
|
let originalDashboardAuthEnabled: string | undefined;
|
||||||
|
let originalBrowserUserDataDir: string | undefined;
|
||||||
|
let originalBrowserProfileDir: string | undefined;
|
||||||
|
let originalBrowserDevtoolsHost: string | undefined;
|
||||||
|
let originalBrowserDevtoolsPort: string | undefined;
|
||||||
|
let originalBrowserDevtoolsHttpUrl: string | undefined;
|
||||||
|
let originalBrowserDevtoolsWsUrl: string | undefined;
|
||||||
|
let originalBrowserEvalMode: string | undefined;
|
||||||
let forcedRemoteAddress = '127.0.0.1';
|
let forcedRemoteAddress = '127.0.0.1';
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
@@ -54,8 +61,22 @@ describe('browser routes', () => {
|
|||||||
tempHome = mkdtempSync(join(tmpdir(), 'ccs-browser-routes-'));
|
tempHome = mkdtempSync(join(tmpdir(), 'ccs-browser-routes-'));
|
||||||
originalCcsHome = process.env.CCS_HOME;
|
originalCcsHome = process.env.CCS_HOME;
|
||||||
originalDashboardAuthEnabled = process.env.CCS_DASHBOARD_AUTH_ENABLED;
|
originalDashboardAuthEnabled = process.env.CCS_DASHBOARD_AUTH_ENABLED;
|
||||||
|
originalBrowserUserDataDir = process.env.CCS_BROWSER_USER_DATA_DIR;
|
||||||
|
originalBrowserProfileDir = process.env.CCS_BROWSER_PROFILE_DIR;
|
||||||
|
originalBrowserDevtoolsHost = process.env.CCS_BROWSER_DEVTOOLS_HOST;
|
||||||
|
originalBrowserDevtoolsPort = process.env.CCS_BROWSER_DEVTOOLS_PORT;
|
||||||
|
originalBrowserDevtoolsHttpUrl = process.env.CCS_BROWSER_DEVTOOLS_HTTP_URL;
|
||||||
|
originalBrowserDevtoolsWsUrl = process.env.CCS_BROWSER_DEVTOOLS_WS_URL;
|
||||||
|
originalBrowserEvalMode = process.env.CCS_BROWSER_EVAL_MODE;
|
||||||
process.env.CCS_HOME = tempHome;
|
process.env.CCS_HOME = tempHome;
|
||||||
process.env.CCS_DASHBOARD_AUTH_ENABLED = 'false';
|
process.env.CCS_DASHBOARD_AUTH_ENABLED = 'false';
|
||||||
|
delete process.env.CCS_BROWSER_USER_DATA_DIR;
|
||||||
|
delete process.env.CCS_BROWSER_PROFILE_DIR;
|
||||||
|
delete process.env.CCS_BROWSER_DEVTOOLS_HOST;
|
||||||
|
delete process.env.CCS_BROWSER_DEVTOOLS_PORT;
|
||||||
|
delete process.env.CCS_BROWSER_DEVTOOLS_HTTP_URL;
|
||||||
|
delete process.env.CCS_BROWSER_DEVTOOLS_WS_URL;
|
||||||
|
delete process.env.CCS_BROWSER_EVAL_MODE;
|
||||||
forcedRemoteAddress = '127.0.0.1';
|
forcedRemoteAddress = '127.0.0.1';
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -72,6 +93,42 @@ describe('browser routes', () => {
|
|||||||
delete process.env.CCS_DASHBOARD_AUTH_ENABLED;
|
delete process.env.CCS_DASHBOARD_AUTH_ENABLED;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (originalBrowserUserDataDir !== undefined) {
|
||||||
|
process.env.CCS_BROWSER_USER_DATA_DIR = originalBrowserUserDataDir;
|
||||||
|
} else {
|
||||||
|
delete process.env.CCS_BROWSER_USER_DATA_DIR;
|
||||||
|
}
|
||||||
|
if (originalBrowserProfileDir !== undefined) {
|
||||||
|
process.env.CCS_BROWSER_PROFILE_DIR = originalBrowserProfileDir;
|
||||||
|
} else {
|
||||||
|
delete process.env.CCS_BROWSER_PROFILE_DIR;
|
||||||
|
}
|
||||||
|
if (originalBrowserDevtoolsHost !== undefined) {
|
||||||
|
process.env.CCS_BROWSER_DEVTOOLS_HOST = originalBrowserDevtoolsHost;
|
||||||
|
} else {
|
||||||
|
delete process.env.CCS_BROWSER_DEVTOOLS_HOST;
|
||||||
|
}
|
||||||
|
if (originalBrowserDevtoolsPort !== undefined) {
|
||||||
|
process.env.CCS_BROWSER_DEVTOOLS_PORT = originalBrowserDevtoolsPort;
|
||||||
|
} else {
|
||||||
|
delete process.env.CCS_BROWSER_DEVTOOLS_PORT;
|
||||||
|
}
|
||||||
|
if (originalBrowserDevtoolsHttpUrl !== undefined) {
|
||||||
|
process.env.CCS_BROWSER_DEVTOOLS_HTTP_URL = originalBrowserDevtoolsHttpUrl;
|
||||||
|
} else {
|
||||||
|
delete process.env.CCS_BROWSER_DEVTOOLS_HTTP_URL;
|
||||||
|
}
|
||||||
|
if (originalBrowserDevtoolsWsUrl !== undefined) {
|
||||||
|
process.env.CCS_BROWSER_DEVTOOLS_WS_URL = originalBrowserDevtoolsWsUrl;
|
||||||
|
} else {
|
||||||
|
delete process.env.CCS_BROWSER_DEVTOOLS_WS_URL;
|
||||||
|
}
|
||||||
|
if (originalBrowserEvalMode !== undefined) {
|
||||||
|
process.env.CCS_BROWSER_EVAL_MODE = originalBrowserEvalMode;
|
||||||
|
} else {
|
||||||
|
delete process.env.CCS_BROWSER_EVAL_MODE;
|
||||||
|
}
|
||||||
|
|
||||||
rmSync(tempHome, { recursive: true, force: true });
|
rmSync(tempHome, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -96,26 +153,64 @@ describe('browser routes', () => {
|
|||||||
policy: 'manual',
|
policy: 'manual',
|
||||||
userDataDir: join(tempHome, '.ccs', 'browser', 'chrome-user-data'),
|
userDataDir: join(tempHome, '.ccs', 'browser', 'chrome-user-data'),
|
||||||
devtoolsPort: 9222,
|
devtoolsPort: 9222,
|
||||||
|
evalMode: 'readonly',
|
||||||
},
|
},
|
||||||
codex: {
|
codex: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
policy: 'manual',
|
policy: 'manual',
|
||||||
|
evalMode: 'readonly',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(payload.status.claude).toMatchObject({
|
expect(payload.status.claude).toMatchObject({
|
||||||
state: 'disabled',
|
state: 'disabled',
|
||||||
policy: 'manual',
|
policy: 'manual',
|
||||||
|
evalMode: 'readonly',
|
||||||
managedMcpServerName: 'ccs-browser',
|
managedMcpServerName: 'ccs-browser',
|
||||||
});
|
});
|
||||||
expect(payload.status.codex).toMatchObject({
|
expect(payload.status.codex).toMatchObject({
|
||||||
enabled: false,
|
enabled: false,
|
||||||
state: 'disabled',
|
state: 'disabled',
|
||||||
policy: 'manual',
|
policy: 'manual',
|
||||||
|
evalMode: 'readonly',
|
||||||
serverName: 'ccs_browser',
|
serverName: 'ccs_browser',
|
||||||
});
|
});
|
||||||
expect(payload.status.codex.detail).toContain('off by default');
|
expect(payload.status.codex.detail).toContain('off by default');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('returns evalMode through the standalone browser status route', async () => {
|
||||||
|
const updateResponse = await fetch(`${baseUrl}/api/browser`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
claude: {
|
||||||
|
enabled: true,
|
||||||
|
policy: 'manual',
|
||||||
|
evalMode: 'readwrite',
|
||||||
|
},
|
||||||
|
codex: {
|
||||||
|
enabled: true,
|
||||||
|
policy: 'auto',
|
||||||
|
evalMode: 'disabled',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(updateResponse.status).toBe(200);
|
||||||
|
|
||||||
|
const response = await fetch(`${baseUrl}/api/browser/status`);
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(await response.json()).toMatchObject({
|
||||||
|
claude: {
|
||||||
|
policy: 'manual',
|
||||||
|
evalMode: 'readwrite',
|
||||||
|
},
|
||||||
|
codex: {
|
||||||
|
policy: 'auto',
|
||||||
|
evalMode: 'disabled',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('updates the saved browser config through the dashboard route', async () => {
|
it('updates the saved browser config through the dashboard route', async () => {
|
||||||
const response = await fetch(`${baseUrl}/api/browser`, {
|
const response = await fetch(`${baseUrl}/api/browser`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
@@ -126,10 +221,12 @@ describe('browser routes', () => {
|
|||||||
policy: 'manual',
|
policy: 'manual',
|
||||||
userDataDir: '/tmp/ccs-browser',
|
userDataDir: '/tmp/ccs-browser',
|
||||||
devtoolsPort: 9333,
|
devtoolsPort: 9333,
|
||||||
|
evalMode: 'readwrite',
|
||||||
},
|
},
|
||||||
codex: {
|
codex: {
|
||||||
enabled: false,
|
enabled: true,
|
||||||
policy: 'manual',
|
policy: 'auto',
|
||||||
|
evalMode: 'disabled',
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@@ -142,10 +239,22 @@ describe('browser routes', () => {
|
|||||||
policy: 'manual',
|
policy: 'manual',
|
||||||
userDataDir: '/tmp/ccs-browser',
|
userDataDir: '/tmp/ccs-browser',
|
||||||
devtoolsPort: 9333,
|
devtoolsPort: 9333,
|
||||||
|
evalMode: 'readwrite',
|
||||||
},
|
},
|
||||||
codex: {
|
codex: {
|
||||||
enabled: false,
|
enabled: true,
|
||||||
|
policy: 'auto',
|
||||||
|
evalMode: 'disabled',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(payload.browser.status).toMatchObject({
|
||||||
|
claude: {
|
||||||
policy: 'manual',
|
policy: 'manual',
|
||||||
|
evalMode: 'readwrite',
|
||||||
|
},
|
||||||
|
codex: {
|
||||||
|
policy: 'auto',
|
||||||
|
evalMode: 'disabled',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -156,14 +265,57 @@ describe('browser routes', () => {
|
|||||||
policy: 'manual',
|
policy: 'manual',
|
||||||
user_data_dir: '/tmp/ccs-browser',
|
user_data_dir: '/tmp/ccs-browser',
|
||||||
devtools_port: 9333,
|
devtools_port: 9333,
|
||||||
|
eval_mode: 'readwrite',
|
||||||
},
|
},
|
||||||
codex: {
|
codex: {
|
||||||
enabled: false,
|
enabled: true,
|
||||||
policy: 'manual',
|
policy: 'auto',
|
||||||
|
eval_mode: 'disabled',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('updates evalMode without changing the saved policy', async () => {
|
||||||
|
const firstResponse = await fetch(`${baseUrl}/api/browser`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
codex: {
|
||||||
|
enabled: true,
|
||||||
|
policy: 'auto',
|
||||||
|
evalMode: 'readonly',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(firstResponse.status).toBe(200);
|
||||||
|
|
||||||
|
const secondResponse = await fetch(`${baseUrl}/api/browser`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
codex: {
|
||||||
|
evalMode: 'readwrite',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(secondResponse.status).toBe(200);
|
||||||
|
const payload = await secondResponse.json();
|
||||||
|
expect(payload.browser.config.codex).toMatchObject({
|
||||||
|
enabled: true,
|
||||||
|
policy: 'auto',
|
||||||
|
evalMode: 'readwrite',
|
||||||
|
});
|
||||||
|
|
||||||
|
const config = loadOrCreateUnifiedConfig();
|
||||||
|
expect(config.browser?.codex).toMatchObject({
|
||||||
|
enabled: true,
|
||||||
|
policy: 'auto',
|
||||||
|
eval_mode: 'readwrite',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('treats a blank user-data directory as a reset to the recommended path', async () => {
|
it('treats a blank user-data directory as a reset to the recommended path', async () => {
|
||||||
const firstResponse = await fetch(`${baseUrl}/api/browser`, {
|
const firstResponse = await fetch(`${baseUrl}/api/browser`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
@@ -174,6 +326,7 @@ describe('browser routes', () => {
|
|||||||
policy: 'manual',
|
policy: 'manual',
|
||||||
userDataDir: '/tmp/ccs-browser-custom',
|
userDataDir: '/tmp/ccs-browser-custom',
|
||||||
devtoolsPort: 9333,
|
devtoolsPort: 9333,
|
||||||
|
evalMode: 'readonly',
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@@ -197,9 +350,11 @@ describe('browser routes', () => {
|
|||||||
policy: 'manual',
|
policy: 'manual',
|
||||||
userDataDir: join(tempHome, '.ccs', 'browser', 'chrome-user-data'),
|
userDataDir: join(tempHome, '.ccs', 'browser', 'chrome-user-data'),
|
||||||
devtoolsPort: 9333,
|
devtoolsPort: 9333,
|
||||||
|
evalMode: 'readonly',
|
||||||
});
|
});
|
||||||
expect(payload.browser.status.claude.state).toBe('browser_not_running');
|
expect(payload.browser.status.claude.state).toBe('browser_not_running');
|
||||||
expect(payload.browser.status.claude.detail).toContain('CCS created the managed browser profile');
|
expect(payload.browser.status.claude.detail).toContain('CCS created the managed browser profile');
|
||||||
|
expect(payload.browser.status.claude.evalMode).toBe('readonly');
|
||||||
expect(existsSync(join(tempHome, '.ccs', 'browser', 'chrome-user-data'))).toBe(true);
|
expect(existsSync(join(tempHome, '.ccs', 'browser', 'chrome-user-data'))).toBe(true);
|
||||||
|
|
||||||
const config = loadOrCreateUnifiedConfig();
|
const config = loadOrCreateUnifiedConfig();
|
||||||
@@ -209,6 +364,7 @@ describe('browser routes', () => {
|
|||||||
policy: 'manual',
|
policy: 'manual',
|
||||||
user_data_dir: join(tempHome, '.ccs', 'browser', 'chrome-user-data'),
|
user_data_dir: join(tempHome, '.ccs', 'browser', 'chrome-user-data'),
|
||||||
devtools_port: 9333,
|
devtools_port: 9333,
|
||||||
|
eval_mode: 'readonly',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -279,6 +435,23 @@ describe('browser routes', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('rejects invalid browser evalMode values at the route boundary', async () => {
|
||||||
|
const response = await fetch(`${baseUrl}/api/browser`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
claude: {
|
||||||
|
evalMode: 'always',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(await response.json()).toEqual({
|
||||||
|
error: 'Invalid value for claude.evalMode. Must be one of: disabled, readonly, readwrite.',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('rejects unknown nested browser lane fields instead of silently ignoring them', async () => {
|
it('rejects unknown nested browser lane fields instead of silently ignoring them', async () => {
|
||||||
const response = await fetch(`${baseUrl}/api/browser`, {
|
const response = await fetch(`${baseUrl}/api/browser`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
|
|||||||
@@ -234,14 +234,21 @@ export interface UpdateImageAnalysisSettingsPayload {
|
|||||||
profileBackends?: Record<string, string>;
|
profileBackends?: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type BrowserToolPolicy = 'auto' | 'manual';
|
||||||
|
export type BrowserEvalMode = 'disabled' | 'readonly' | 'readwrite';
|
||||||
|
|
||||||
export interface BrowserSettingsConfig {
|
export interface BrowserSettingsConfig {
|
||||||
claude: {
|
claude: {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
policy: BrowserToolPolicy;
|
||||||
userDataDir: string;
|
userDataDir: string;
|
||||||
devtoolsPort: number;
|
devtoolsPort: number;
|
||||||
|
evalMode: BrowserEvalMode;
|
||||||
};
|
};
|
||||||
codex: {
|
codex: {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
policy: BrowserToolPolicy;
|
||||||
|
evalMode: BrowserEvalMode;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -253,6 +260,7 @@ export interface BrowserLaunchCommands {
|
|||||||
|
|
||||||
export interface ClaudeBrowserStatus {
|
export interface ClaudeBrowserStatus {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
policy: BrowserToolPolicy;
|
||||||
source: 'config' | 'CCS_BROWSER_USER_DATA_DIR' | 'CCS_BROWSER_PROFILE_DIR';
|
source: 'config' | 'CCS_BROWSER_USER_DATA_DIR' | 'CCS_BROWSER_PROFILE_DIR';
|
||||||
overrideActive: boolean;
|
overrideActive: boolean;
|
||||||
state: 'disabled' | 'path_missing' | 'browser_not_running' | 'endpoint_unreachable' | 'ready';
|
state: 'disabled' | 'path_missing' | 'browser_not_running' | 'endpoint_unreachable' | 'ready';
|
||||||
@@ -262,6 +270,7 @@ export interface ClaudeBrowserStatus {
|
|||||||
effectiveUserDataDir: string;
|
effectiveUserDataDir: string;
|
||||||
recommendedUserDataDir: string;
|
recommendedUserDataDir: string;
|
||||||
devtoolsPort: number;
|
devtoolsPort: number;
|
||||||
|
evalMode: BrowserEvalMode;
|
||||||
managedMcpServerName: string;
|
managedMcpServerName: string;
|
||||||
managedMcpServerPath: string;
|
managedMcpServerPath: string;
|
||||||
launchCommands: BrowserLaunchCommands;
|
launchCommands: BrowserLaunchCommands;
|
||||||
@@ -270,11 +279,13 @@ export interface ClaudeBrowserStatus {
|
|||||||
|
|
||||||
export interface CodexBrowserStatus {
|
export interface CodexBrowserStatus {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
policy: BrowserToolPolicy;
|
||||||
state: 'disabled' | 'enabled' | 'unsupported_build';
|
state: 'disabled' | 'enabled' | 'unsupported_build';
|
||||||
title: string;
|
title: string;
|
||||||
detail: string;
|
detail: string;
|
||||||
nextStep: string;
|
nextStep: string;
|
||||||
serverName: string;
|
serverName: string;
|
||||||
|
evalMode: BrowserEvalMode;
|
||||||
supportsConfigOverrides: boolean;
|
supportsConfigOverrides: boolean;
|
||||||
binaryPath: string | null;
|
binaryPath: string | null;
|
||||||
version?: string;
|
version?: string;
|
||||||
|
|||||||
@@ -2445,6 +2445,14 @@ const resources = {
|
|||||||
nextStep: 'Next step',
|
nextStep: 'Next step',
|
||||||
technicalDetails: 'Technical Details',
|
technicalDetails: 'Technical Details',
|
||||||
diagnostics: 'Diagnostics',
|
diagnostics: 'Diagnostics',
|
||||||
|
evalMode: {
|
||||||
|
label: 'browser_eval access',
|
||||||
|
options: {
|
||||||
|
disabled: 'Disabled',
|
||||||
|
readonly: 'Read-only',
|
||||||
|
readwrite: 'Read/write',
|
||||||
|
},
|
||||||
|
},
|
||||||
actions: {
|
actions: {
|
||||||
saveClaude: 'Save Claude settings',
|
saveClaude: 'Save Claude settings',
|
||||||
saveCodex: 'Save Codex settings',
|
saveCodex: 'Save Codex settings',
|
||||||
@@ -2471,6 +2479,8 @@ const resources = {
|
|||||||
effectivePath: 'Effective attach path',
|
effectivePath: 'Effective attach path',
|
||||||
recommendedPath: 'Recommended path',
|
recommendedPath: 'Recommended path',
|
||||||
managedRuntime: 'Managed browser runtime',
|
managedRuntime: 'Managed browser runtime',
|
||||||
|
evalModeHint:
|
||||||
|
'Controls browser_eval access for Claude Browser Attach. Read-only allows inspection; read/write also allows page-side mutations.',
|
||||||
overrideMessage:
|
overrideMessage:
|
||||||
'An environment override is currently active from {{source}}. This dashboard remains the source of truth once that override is removed.',
|
'An environment override is currently active from {{source}}. This dashboard remains the source of truth once that override is removed.',
|
||||||
launchGuidance: 'Launch guidance',
|
launchGuidance: 'Launch guidance',
|
||||||
@@ -2490,6 +2500,8 @@ const resources = {
|
|||||||
overrideUnsupported: 'Not supported',
|
overrideUnsupported: 'Not supported',
|
||||||
binary: 'Detected Codex binary',
|
binary: 'Detected Codex binary',
|
||||||
notDetected: 'Not detected',
|
notDetected: 'Not detected',
|
||||||
|
evalModeHint:
|
||||||
|
'Stored for Browser settings parity. In Phase 1, browser_eval enforcement primarily applies to Claude Browser Attach.',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -4868,6 +4880,14 @@ const resources = {
|
|||||||
nextStep: '下一步',
|
nextStep: '下一步',
|
||||||
technicalDetails: '技术细节',
|
technicalDetails: '技术细节',
|
||||||
diagnostics: '诊断信息',
|
diagnostics: '诊断信息',
|
||||||
|
evalMode: {
|
||||||
|
label: 'browser_eval 权限',
|
||||||
|
options: {
|
||||||
|
disabled: '禁用',
|
||||||
|
readonly: '只读',
|
||||||
|
readwrite: '可读写',
|
||||||
|
},
|
||||||
|
},
|
||||||
actions: {
|
actions: {
|
||||||
saveClaude: '保存 Claude 设置',
|
saveClaude: '保存 Claude 设置',
|
||||||
saveCodex: '保存 Codex 设置',
|
saveCodex: '保存 Codex 设置',
|
||||||
@@ -4891,6 +4911,8 @@ const resources = {
|
|||||||
effectivePath: '当前生效的 attach 路径',
|
effectivePath: '当前生效的 attach 路径',
|
||||||
recommendedPath: '推荐路径',
|
recommendedPath: '推荐路径',
|
||||||
managedRuntime: '托管浏览器运行时',
|
managedRuntime: '托管浏览器运行时',
|
||||||
|
evalModeHint:
|
||||||
|
'控制 Claude Browser Attach 中 browser_eval 的权限。只读仅允许读取,读写还允许页面侧修改。',
|
||||||
overrideMessage:
|
overrideMessage:
|
||||||
'当前存在来自 {{source}} 的环境变量覆盖。移除该覆盖后,Dashboard 配置将重新成为唯一来源。',
|
'当前存在来自 {{source}} 的环境变量覆盖。移除该覆盖后,Dashboard 配置将重新成为唯一来源。',
|
||||||
launchGuidance: '启动指引',
|
launchGuidance: '启动指引',
|
||||||
@@ -4907,6 +4929,8 @@ const resources = {
|
|||||||
overrideUnsupported: '不支持',
|
overrideUnsupported: '不支持',
|
||||||
binary: '检测到的 Codex 可执行文件',
|
binary: '检测到的 Codex 可执行文件',
|
||||||
notDetected: '未检测到',
|
notDetected: '未检测到',
|
||||||
|
evalModeHint:
|
||||||
|
'为保持 Browser 设置页一致性而保存该配置。Phase 1 中,browser_eval 的实际限制主要作用于 Claude Browser Attach。',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -7394,6 +7418,14 @@ const resources = {
|
|||||||
nextStep: 'Bước tiếp theo',
|
nextStep: 'Bước tiếp theo',
|
||||||
technicalDetails: 'Chi tiết kỹ thuật',
|
technicalDetails: 'Chi tiết kỹ thuật',
|
||||||
diagnostics: 'Chẩn đoán',
|
diagnostics: 'Chẩn đoán',
|
||||||
|
evalMode: {
|
||||||
|
label: 'Quyền browser_eval',
|
||||||
|
options: {
|
||||||
|
disabled: 'Tắt',
|
||||||
|
readonly: 'Chỉ đọc',
|
||||||
|
readwrite: 'Đọc/ghi',
|
||||||
|
},
|
||||||
|
},
|
||||||
actions: {
|
actions: {
|
||||||
saveClaude: 'Lưu cấu hình Claude',
|
saveClaude: 'Lưu cấu hình Claude',
|
||||||
saveCodex: 'Lưu cấu hình Codex',
|
saveCodex: 'Lưu cấu hình Codex',
|
||||||
@@ -7421,6 +7453,8 @@ const resources = {
|
|||||||
effectivePath: 'Đường dẫn attach đang có hiệu lực',
|
effectivePath: 'Đường dẫn attach đang có hiệu lực',
|
||||||
recommendedPath: 'Đường dẫn khuyến nghị',
|
recommendedPath: 'Đường dẫn khuyến nghị',
|
||||||
managedRuntime: 'Runtime trình duyệt được quản lý',
|
managedRuntime: 'Runtime trình duyệt được quản lý',
|
||||||
|
evalModeHint:
|
||||||
|
'Điều khiển quyền browser_eval cho Claude Browser Attach. Chỉ đọc cho phép quan sát; đọc/ghi cũng cho phép thay đổi phía trang.',
|
||||||
overrideMessage:
|
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.',
|
'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',
|
launchGuidance: 'Hướng dẫn khởi chạy',
|
||||||
@@ -7440,6 +7474,8 @@ const resources = {
|
|||||||
overrideUnsupported: 'Không được hỗ trợ',
|
overrideUnsupported: 'Không được hỗ trợ',
|
||||||
binary: 'Binary Codex được phát hiện',
|
binary: 'Binary Codex được phát hiện',
|
||||||
notDetected: 'Không phát hiện',
|
notDetected: 'Không phát hiện',
|
||||||
|
evalModeHint:
|
||||||
|
'Được lưu để giữ tương thích giao diện Browser settings. Ở Phase 1, việc áp dụng hạn chế browser_eval chủ yếu diễn ra trên Claude Browser Attach.',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -9821,6 +9857,14 @@ const resources = {
|
|||||||
nextStep: '次の手順',
|
nextStep: '次の手順',
|
||||||
technicalDetails: '技術的な詳細',
|
technicalDetails: '技術的な詳細',
|
||||||
diagnostics: '診断情報',
|
diagnostics: '診断情報',
|
||||||
|
evalMode: {
|
||||||
|
label: 'browser_eval 権限',
|
||||||
|
options: {
|
||||||
|
disabled: '無効',
|
||||||
|
readonly: '読み取り専用',
|
||||||
|
readwrite: '読み書き可',
|
||||||
|
},
|
||||||
|
},
|
||||||
actions: {
|
actions: {
|
||||||
saveClaude: 'Claude 設定を保存',
|
saveClaude: 'Claude 設定を保存',
|
||||||
saveCodex: 'Codex 設定を保存',
|
saveCodex: 'Codex 設定を保存',
|
||||||
@@ -9848,6 +9892,8 @@ const resources = {
|
|||||||
effectivePath: '現在有効な attach パス',
|
effectivePath: '現在有効な attach パス',
|
||||||
recommendedPath: '推奨パス',
|
recommendedPath: '推奨パス',
|
||||||
managedRuntime: '管理済みブラウザランタイム',
|
managedRuntime: '管理済みブラウザランタイム',
|
||||||
|
evalModeHint:
|
||||||
|
'Claude Browser Attach における browser_eval の権限を制御します。読み取り専用は参照のみ、読み書き可はページ側の変更も許可します。',
|
||||||
overrideMessage:
|
overrideMessage:
|
||||||
'{{source}} から環境変数オーバーライドが有効です。これを外すと Dashboard の設定が再び主ソースになります。',
|
'{{source}} から環境変数オーバーライドが有効です。これを外すと Dashboard の設定が再び主ソースになります。',
|
||||||
launchGuidance: '起動ガイダンス',
|
launchGuidance: '起動ガイダンス',
|
||||||
@@ -9867,6 +9913,8 @@ const resources = {
|
|||||||
overrideUnsupported: '非対応',
|
overrideUnsupported: '非対応',
|
||||||
binary: '検出された Codex バイナリ',
|
binary: '検出された Codex バイナリ',
|
||||||
notDetected: '未検出',
|
notDetected: '未検出',
|
||||||
|
evalModeHint:
|
||||||
|
'Browser 設定画面の整合性のために保存されます。Phase 1 では browser_eval の制御は主に Claude Browser Attach に適用されます。',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,29 +2,36 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import {
|
import {
|
||||||
Browser,
|
AlertCircle,
|
||||||
Gear,
|
|
||||||
CheckCircle,
|
|
||||||
WarningCircle,
|
|
||||||
XCircle,
|
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
ClipboardText,
|
CheckCircle2,
|
||||||
ArrowsClockwise,
|
ChevronDown,
|
||||||
TerminalWindow,
|
Clipboard,
|
||||||
CaretDown,
|
|
||||||
Info,
|
Info,
|
||||||
} from '@phosphor-icons/react';
|
Monitor,
|
||||||
|
RefreshCw,
|
||||||
|
Settings,
|
||||||
|
Terminal,
|
||||||
|
XCircle,
|
||||||
|
} from 'lucide-react';
|
||||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||||
import { getClientPlatformKey } from '@/lib/platform';
|
import { getClientPlatformKey } from '@/lib/platform';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useBrowserConfig, useRawConfig } from '../../hooks';
|
import { useBrowserConfig, useRawConfig } from '../../hooks';
|
||||||
import type { BrowserConfig } from '../../types';
|
import type { BrowserConfig, BrowserEvalMode } from '../../types';
|
||||||
|
|
||||||
// --- Constants & Helpers ---
|
// --- Constants & Helpers ---
|
||||||
|
|
||||||
@@ -35,6 +42,12 @@ function parsePortDraft(value: string): number | null {
|
|||||||
return port;
|
return port;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const BROWSER_EVAL_MODE_OPTIONS: BrowserEvalMode[] = ['disabled', 'readonly', 'readwrite'];
|
||||||
|
|
||||||
|
function getBrowserEvalModeLabel(t: ReturnType<typeof useTranslation>['t'], mode: BrowserEvalMode) {
|
||||||
|
return t(`settingsPage.browserSection.evalMode.options.${mode}`);
|
||||||
|
}
|
||||||
|
|
||||||
function buildLaunchCommand(
|
function buildLaunchCommand(
|
||||||
userDataDir: string,
|
userDataDir: string,
|
||||||
devtoolsPort: number,
|
devtoolsPort: number,
|
||||||
@@ -142,11 +155,11 @@ function StatusStrip({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{isReady ? (
|
{isReady ? (
|
||||||
<CheckCircle weight="duotone" size={24} />
|
<CheckCircle2 size={24} />
|
||||||
) : isError ? (
|
) : isError ? (
|
||||||
<WarningCircle weight="duotone" size={24} />
|
<AlertCircle size={24} />
|
||||||
) : (
|
) : (
|
||||||
<XCircle weight="duotone" size={24} />
|
<XCircle size={24} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1 space-y-1">
|
<div className="min-w-0 flex-1 space-y-1">
|
||||||
@@ -205,10 +218,10 @@ function DiagnosticsSection({
|
|||||||
<CollapsibleTrigger asChild>
|
<CollapsibleTrigger asChild>
|
||||||
<button className="flex w-full items-center justify-between px-6 py-4 text-sm font-medium transition-colors hover:bg-muted/40 dark:hover:bg-zinc-900/50">
|
<button className="flex w-full items-center justify-between px-6 py-4 text-sm font-medium transition-colors hover:bg-muted/40 dark:hover:bg-zinc-900/50">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Gear size={16} weight="bold" className="text-muted-foreground" />
|
<Settings size={16} className="text-muted-foreground" />
|
||||||
{title}
|
{title}
|
||||||
</div>
|
</div>
|
||||||
<CaretDown
|
<ChevronDown
|
||||||
size={16}
|
size={16}
|
||||||
className={cn('transition-transform duration-300', isOpen && 'rotate-180')}
|
className={cn('transition-transform duration-300', isOpen && 'rotate-180')}
|
||||||
/>
|
/>
|
||||||
@@ -275,11 +288,13 @@ export default function BrowserSection() {
|
|||||||
effectiveConfig !== null &&
|
effectiveConfig !== null &&
|
||||||
(config.claude.enabled !== effectiveConfig.claude.enabled ||
|
(config.claude.enabled !== effectiveConfig.claude.enabled ||
|
||||||
config.claude.userDataDir !== effectiveConfig.claude.userDataDir ||
|
config.claude.userDataDir !== effectiveConfig.claude.userDataDir ||
|
||||||
config.claude.devtoolsPort !== claudePort);
|
config.claude.devtoolsPort !== claudePort ||
|
||||||
|
config.claude.evalMode !== effectiveConfig.claude.evalMode);
|
||||||
const hasCodexChanges =
|
const hasCodexChanges =
|
||||||
config !== null &&
|
config !== null &&
|
||||||
effectiveConfig !== null &&
|
effectiveConfig !== null &&
|
||||||
config.codex.enabled !== effectiveConfig.codex.enabled;
|
(config.codex.enabled !== effectiveConfig.codex.enabled ||
|
||||||
|
config.codex.evalMode !== effectiveConfig.codex.evalMode);
|
||||||
|
|
||||||
const refreshAll = useCallback(async () => {
|
const refreshAll = useCallback(async () => {
|
||||||
setActionMessage(null);
|
setActionMessage(null);
|
||||||
@@ -302,6 +317,7 @@ export default function BrowserSection() {
|
|||||||
enabled: effectiveConfig.claude.enabled,
|
enabled: effectiveConfig.claude.enabled,
|
||||||
userDataDir: effectiveConfig.claude.userDataDir.trim(),
|
userDataDir: effectiveConfig.claude.userDataDir.trim(),
|
||||||
devtoolsPort: claudePort,
|
devtoolsPort: claudePort,
|
||||||
|
evalMode: effectiveConfig.claude.evalMode,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (saved) {
|
if (saved) {
|
||||||
@@ -317,6 +333,7 @@ export default function BrowserSection() {
|
|||||||
const saved = await saveConfig({
|
const saved = await saveConfig({
|
||||||
codex: {
|
codex: {
|
||||||
enabled: effectiveConfig.codex.enabled,
|
enabled: effectiveConfig.codex.enabled,
|
||||||
|
evalMode: effectiveConfig.codex.evalMode,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (saved) {
|
if (saved) {
|
||||||
@@ -343,7 +360,7 @@ export default function BrowserSection() {
|
|||||||
animate={{ rotate: 360 }}
|
animate={{ rotate: 360 }}
|
||||||
transition={{ duration: 1.5, repeat: Infinity, ease: 'linear' }}
|
transition={{ duration: 1.5, repeat: Infinity, ease: 'linear' }}
|
||||||
>
|
>
|
||||||
<ArrowsClockwise size={32} />
|
<RefreshCw size={32} />
|
||||||
</motion.div>
|
</motion.div>
|
||||||
<span className="text-sm font-medium uppercase tracking-widest opacity-60">
|
<span className="text-sm font-medium uppercase tracking-widest opacity-60">
|
||||||
{t('settings.loading')}
|
{t('settings.loading')}
|
||||||
@@ -357,7 +374,7 @@ export default function BrowserSection() {
|
|||||||
return (
|
return (
|
||||||
<div className="p-8">
|
<div className="p-8">
|
||||||
<Alert variant="destructive" className="rounded-[2rem] p-8 shadow-xl">
|
<Alert variant="destructive" className="rounded-[2rem] p-8 shadow-xl">
|
||||||
<XCircle weight="duotone" size={24} />
|
<XCircle size={24} />
|
||||||
<AlertDescription className="mt-2 text-md leading-relaxed">
|
<AlertDescription className="mt-2 text-md leading-relaxed">
|
||||||
{error ?? t('settingsPage.browserSection.description')}
|
{error ?? t('settingsPage.browserSection.description')}
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
@@ -367,7 +384,7 @@ export default function BrowserSection() {
|
|||||||
className="rounded-full px-6 transition-transform active:scale-95"
|
className="rounded-full px-6 transition-transform active:scale-95"
|
||||||
onClick={refreshAll}
|
onClick={refreshAll}
|
||||||
>
|
>
|
||||||
<ArrowsClockwise className="mr-2 h-4 w-4" />
|
<RefreshCw className="mr-2 h-4 w-4" />
|
||||||
{t('sharedPage.retry')}
|
{t('sharedPage.retry')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -389,12 +406,12 @@ export default function BrowserSection() {
|
|||||||
>
|
>
|
||||||
{error ? (
|
{error ? (
|
||||||
<div className="flex items-center gap-3 rounded-full border border-rose-500/20 bg-card/95 px-6 py-2.5 text-sm font-medium text-rose-600 shadow-2xl dark:bg-zinc-900/85">
|
<div className="flex items-center gap-3 rounded-full border border-rose-500/20 bg-card/95 px-6 py-2.5 text-sm font-medium text-rose-600 shadow-2xl dark:bg-zinc-900/85">
|
||||||
<XCircle size={18} weight="bold" />
|
<XCircle size={18} />
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center gap-3 rounded-full border border-emerald-500/20 bg-card/95 px-6 py-2.5 text-sm font-medium text-emerald-600 shadow-2xl dark:bg-zinc-900/85">
|
<div className="flex items-center gap-3 rounded-full border border-emerald-500/20 bg-card/95 px-6 py-2.5 text-sm font-medium text-emerald-600 shadow-2xl dark:bg-zinc-900/85">
|
||||||
<CheckCircle size={18} weight="bold" />
|
<CheckCircle2 size={18} />
|
||||||
{actionMessage ?? t('commonToast.settingsSaved')}
|
{actionMessage ?? t('commonToast.settingsSaved')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -413,7 +430,7 @@ export default function BrowserSection() {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
|
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
|
||||||
<Browser weight="duotone" size={28} />
|
<Monitor size={28} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold tracking-tight">
|
<h1 className="text-3xl font-bold tracking-tight">
|
||||||
@@ -431,7 +448,7 @@ export default function BrowserSection() {
|
|||||||
onClick={refreshAll}
|
onClick={refreshAll}
|
||||||
disabled={saving || loading}
|
disabled={saving || loading}
|
||||||
>
|
>
|
||||||
<ArrowsClockwise className={cn('mr-2 h-4 w-4', statusLoading && 'animate-spin')} />
|
<RefreshCw className={cn('mr-2 h-4 w-4', statusLoading && 'animate-spin')} />
|
||||||
{t('settings.refresh')}
|
{t('settings.refresh')}
|
||||||
</Button>
|
</Button>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
@@ -442,7 +459,7 @@ export default function BrowserSection() {
|
|||||||
transition={{ delay: 0.1 }}
|
transition={{ delay: 0.1 }}
|
||||||
>
|
>
|
||||||
<Alert className="items-center rounded-3xl border-primary/10 bg-primary/[0.04] p-6 py-4 dark:border-primary/15 dark:bg-primary/[0.05]">
|
<Alert className="items-center rounded-3xl border-primary/10 bg-primary/[0.04] p-6 py-4 dark:border-primary/15 dark:bg-primary/[0.05]">
|
||||||
<Info className="h-5 w-5 text-primary" weight="duotone" />
|
<Info className="h-5 w-5 text-primary" />
|
||||||
<AlertDescription className="ml-2 text-sm">
|
<AlertDescription className="ml-2 text-sm">
|
||||||
<span className="font-semibold text-primary/80">
|
<span className="font-semibold text-primary/80">
|
||||||
{t('settingsPage.browserSection.primaryTitle')}
|
{t('settingsPage.browserSection.primaryTitle')}
|
||||||
@@ -486,9 +503,9 @@ export default function BrowserSection() {
|
|||||||
className="rounded-full px-6 shadow-md transition-transform active:scale-95"
|
className="rounded-full px-6 shadow-md transition-transform active:scale-95"
|
||||||
>
|
>
|
||||||
{saving ? (
|
{saving ? (
|
||||||
<ArrowsClockwise className="mr-2 h-4 w-4 animate-spin" />
|
<RefreshCw className="mr-2 h-4 w-4 animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
<CheckCircle weight="bold" size={16} className="mr-2" />
|
<CheckCircle2 size={16} className="mr-2" />
|
||||||
)}
|
)}
|
||||||
{t('settingsPage.browserSection.actions.saveClaude')}
|
{t('settingsPage.browserSection.actions.saveClaude')}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -498,7 +515,7 @@ export default function BrowserSection() {
|
|||||||
onClick={refreshStatus}
|
onClick={refreshStatus}
|
||||||
disabled={saving || statusLoading || hasClaudeChanges || claudePortInvalid}
|
disabled={saving || statusLoading || hasClaudeChanges || claudePortInvalid}
|
||||||
>
|
>
|
||||||
<TerminalWindow weight="bold" size={16} className="mr-2" />
|
<Terminal size={16} className="mr-2" />
|
||||||
{t('settingsPage.browserSection.actions.testConnection')}
|
{t('settingsPage.browserSection.actions.testConnection')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -564,6 +581,36 @@ export default function BrowserSection() {
|
|||||||
: t('settingsPage.browserSection.claude.devtoolsPortHint')}
|
: t('settingsPage.browserSection.claude.devtoolsPortHint')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label className="text-sm font-semibold tracking-wide text-muted-foreground/80">
|
||||||
|
{t('settingsPage.browserSection.evalMode.label')}
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
value={effectiveConfig.claude.evalMode}
|
||||||
|
onValueChange={(next) =>
|
||||||
|
setDraft((current) =>
|
||||||
|
updateClaudeDraft(current ?? effectiveConfig, {
|
||||||
|
evalMode: next as BrowserEvalMode,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="rounded-xl border-border/70 bg-background/85 dark:border-white/[0.08] dark:bg-zinc-900/70">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{BROWSER_EVAL_MODE_OPTIONS.map((mode) => (
|
||||||
|
<SelectItem key={mode} value={mode}>
|
||||||
|
{getBrowserEvalModeLabel(t, mode)}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-[11px] leading-relaxed text-muted-foreground/70">
|
||||||
|
{t('settingsPage.browserSection.claude.evalModeHint')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -584,7 +631,7 @@ export default function BrowserSection() {
|
|||||||
onClick={copyLaunchCommand}
|
onClick={copyLaunchCommand}
|
||||||
disabled={!preferredLaunchCommand}
|
disabled={!preferredLaunchCommand}
|
||||||
>
|
>
|
||||||
<ClipboardText size={18} />
|
<Clipboard size={18} />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<code className="mt-4 block break-all rounded-lg border border-border/50 bg-background/80 p-3 font-mono text-[10px] leading-relaxed dark:border-white/[0.05] dark:bg-zinc-900/60">
|
<code className="mt-4 block break-all rounded-lg border border-border/50 bg-background/80 p-3 font-mono text-[10px] leading-relaxed dark:border-white/[0.05] dark:bg-zinc-900/60">
|
||||||
@@ -594,7 +641,7 @@ export default function BrowserSection() {
|
|||||||
|
|
||||||
{status.claude.overrideActive && (
|
{status.claude.overrideActive && (
|
||||||
<div className="flex items-center gap-3 rounded-2xl border border-amber-500/10 bg-amber-500/[0.05] p-4 text-xs font-medium text-amber-700 dark:text-amber-300">
|
<div className="flex items-center gap-3 rounded-2xl border border-amber-500/10 bg-amber-500/[0.05] p-4 text-xs font-medium text-amber-700 dark:text-amber-300">
|
||||||
<WarningCircle size={18} weight="duotone" />
|
<AlertCircle size={18} />
|
||||||
{t('settingsPage.browserSection.claude.overrideMessage', {
|
{t('settingsPage.browserSection.claude.overrideMessage', {
|
||||||
source: status.claude.source,
|
source: status.claude.source,
|
||||||
})}
|
})}
|
||||||
@@ -637,6 +684,16 @@ export default function BrowserSection() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground/50">
|
||||||
|
{t('settingsPage.browserSection.evalMode.label')}
|
||||||
|
</p>
|
||||||
|
<div className="rounded-lg border border-border/50 bg-muted/25 p-3 dark:border-white/[0.05] dark:bg-zinc-900/45">
|
||||||
|
<p className="font-mono text-[11px] font-semibold">
|
||||||
|
{getBrowserEvalModeLabel(t, status.claude.evalMode)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</DiagnosticsSection>
|
</DiagnosticsSection>
|
||||||
</div>
|
</div>
|
||||||
@@ -677,9 +734,9 @@ export default function BrowserSection() {
|
|||||||
className="rounded-full px-6 shadow-md transition-transform active:scale-95"
|
className="rounded-full px-6 shadow-md transition-transform active:scale-95"
|
||||||
>
|
>
|
||||||
{saving ? (
|
{saving ? (
|
||||||
<ArrowsClockwise className="mr-2 h-4 w-4 animate-spin" />
|
<RefreshCw className="mr-2 h-4 w-4 animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
<CheckCircle weight="bold" size={16} className="mr-2" />
|
<CheckCircle2 size={16} className="mr-2" />
|
||||||
)}
|
)}
|
||||||
{t('settingsPage.browserSection.actions.saveCodex')}
|
{t('settingsPage.browserSection.actions.saveCodex')}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -694,6 +751,36 @@ export default function BrowserSection() {
|
|||||||
nextStep={status.codex.nextStep}
|
nextStep={status.codex.nextStep}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div className="max-w-md space-y-3">
|
||||||
|
<Label className="text-sm font-semibold tracking-wide text-muted-foreground/80">
|
||||||
|
{t('settingsPage.browserSection.evalMode.label')}
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
value={effectiveConfig.codex.evalMode}
|
||||||
|
onValueChange={(next) =>
|
||||||
|
setDraft((current) =>
|
||||||
|
updateCodexDraft(current ?? effectiveConfig, {
|
||||||
|
evalMode: next as BrowserEvalMode,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="rounded-xl border-border/70 bg-background/85 dark:border-white/[0.08] dark:bg-zinc-900/70">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{BROWSER_EVAL_MODE_OPTIONS.map((mode) => (
|
||||||
|
<SelectItem key={mode} value={mode}>
|
||||||
|
{getBrowserEvalModeLabel(t, mode)}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-[11px] leading-relaxed text-muted-foreground/70">
|
||||||
|
{t('settingsPage.browserSection.codex.evalModeHint')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<DiagnosticsSection
|
<DiagnosticsSection
|
||||||
title={t('settingsPage.browserSection.technicalDetails')}
|
title={t('settingsPage.browserSection.technicalDetails')}
|
||||||
defaultOpen={status.codex.state === 'unsupported_build'}
|
defaultOpen={status.codex.state === 'unsupported_build'}
|
||||||
@@ -715,9 +802,9 @@ export default function BrowserSection() {
|
|||||||
</p>
|
</p>
|
||||||
<div className="flex items-center gap-2 rounded-lg border border-border/50 bg-muted/25 p-3 font-medium text-xs dark:border-white/[0.05] dark:bg-zinc-900/45">
|
<div className="flex items-center gap-2 rounded-lg border border-border/50 bg-muted/25 p-3 font-medium text-xs dark:border-white/[0.05] dark:bg-zinc-900/45">
|
||||||
{status.codex.supportsConfigOverrides ? (
|
{status.codex.supportsConfigOverrides ? (
|
||||||
<CheckCircle className="text-emerald-500" size={16} weight="bold" />
|
<CheckCircle2 className="text-emerald-500" size={16} />
|
||||||
) : (
|
) : (
|
||||||
<XCircle className="text-rose-500" size={16} weight="bold" />
|
<XCircle className="text-rose-500" size={16} />
|
||||||
)}
|
)}
|
||||||
{status.codex.supportsConfigOverrides
|
{status.codex.supportsConfigOverrides
|
||||||
? t('settingsPage.browserSection.codex.overrideSupported')
|
? t('settingsPage.browserSection.codex.overrideSupported')
|
||||||
@@ -740,6 +827,16 @@ export default function BrowserSection() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground/50">
|
||||||
|
{t('settingsPage.browserSection.evalMode.label')}
|
||||||
|
</p>
|
||||||
|
<div className="rounded-lg border border-border/50 bg-muted/25 p-3 dark:border-white/[0.05] dark:bg-zinc-900/45">
|
||||||
|
<p className="font-mono text-[11px] font-semibold">
|
||||||
|
{getBrowserEvalModeLabel(t, status.codex.evalMode)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</DiagnosticsSection>
|
</DiagnosticsSection>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
|
BrowserEvalMode,
|
||||||
|
BrowserToolPolicy,
|
||||||
BrowserSettingsConfig,
|
BrowserSettingsConfig,
|
||||||
BrowserStatusPayload,
|
BrowserStatusPayload,
|
||||||
CliproxyServerConfig,
|
CliproxyServerConfig,
|
||||||
@@ -198,7 +200,7 @@ export interface ThinkingConfig {
|
|||||||
|
|
||||||
// === Re-exports from api-client ===
|
// === Re-exports from api-client ===
|
||||||
|
|
||||||
export type { CliproxyServerConfig, RemoteProxyStatus };
|
export type { BrowserEvalMode, BrowserToolPolicy, CliproxyServerConfig, RemoteProxyStatus };
|
||||||
export type BrowserConfig = BrowserSettingsConfig;
|
export type BrowserConfig = BrowserSettingsConfig;
|
||||||
export type BrowserStatus = BrowserStatusPayload;
|
export type BrowserStatus = BrowserStatusPayload;
|
||||||
export type BrowserSavePayload = UpdateBrowserSettingsPayload;
|
export type BrowserSavePayload = UpdateBrowserSettingsPayload;
|
||||||
Reference in new issue
Block a user