401 lines
13 KiB
TypeScript
401 lines
13 KiB
TypeScript
import { BROWSER_TOOLS } from '@ant/claude-for-chrome-mcp'
|
|
import { chmod, mkdir, readFile, writeFile } from 'fs/promises'
|
|
import { homedir } from 'os'
|
|
import { join } from 'path'
|
|
import { fileURLToPath } from 'url'
|
|
import {
|
|
getIsInteractive,
|
|
getIsNonInteractiveSession,
|
|
getSessionBypassPermissionsMode,
|
|
} from '../../bootstrap/state.js'
|
|
import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js'
|
|
import type { ScopedMcpServerConfig } from '../../services/mcp/types.js'
|
|
import { isInBundledMode } from '../bundledMode.js'
|
|
import { getGlobalConfig, saveGlobalConfig } from '../config.js'
|
|
import { logForDebugging } from '../debug.js'
|
|
import {
|
|
getClaudeConfigHomeDir,
|
|
isEnvDefinedFalsy,
|
|
isEnvTruthy,
|
|
} from '../envUtils.js'
|
|
import { execFileNoThrowWithCwd } from '../execFileNoThrow.js'
|
|
import { getPlatform } from '../platform.js'
|
|
import { jsonStringify } from '../slowOperations.js'
|
|
import {
|
|
CLAUDE_IN_CHROME_MCP_SERVER_NAME,
|
|
getAllBrowserDataPaths,
|
|
getAllNativeMessagingHostsDirs,
|
|
getAllWindowsRegistryKeys,
|
|
openInChrome,
|
|
} from './common.js'
|
|
import { getChromeSystemPrompt } from './prompt.js'
|
|
import { isChromeExtensionInstalledPortable } from './setupPortable.js'
|
|
|
|
const CHROME_EXTENSION_RECONNECT_URL = 'https://clau.de/chrome/reconnect'
|
|
|
|
const NATIVE_HOST_IDENTIFIER = 'com.anthropic.claude_code_browser_extension'
|
|
const NATIVE_HOST_MANIFEST_NAME = `${NATIVE_HOST_IDENTIFIER}.json`
|
|
|
|
export function shouldEnableClaudeInChrome(chromeFlag?: boolean): boolean {
|
|
// Disable by default in non-interactive sessions (e.g., SDK, CI)
|
|
if (getIsNonInteractiveSession() && chromeFlag !== true) {
|
|
return false
|
|
}
|
|
|
|
// Check CLI flags
|
|
if (chromeFlag === true) {
|
|
return true
|
|
}
|
|
if (chromeFlag === false) {
|
|
return false
|
|
}
|
|
|
|
// Check environment variables
|
|
if (isEnvTruthy(process.env.CLAUDE_CODE_ENABLE_CFC)) {
|
|
return true
|
|
}
|
|
if (isEnvDefinedFalsy(process.env.CLAUDE_CODE_ENABLE_CFC)) {
|
|
return false
|
|
}
|
|
|
|
// Check default config settings
|
|
const config = getGlobalConfig()
|
|
if (config.claudeInChromeDefaultEnabled !== undefined) {
|
|
return config.claudeInChromeDefaultEnabled
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
let shouldAutoEnable: boolean | undefined = undefined
|
|
|
|
export function shouldAutoEnableClaudeInChrome(): boolean {
|
|
if (shouldAutoEnable !== undefined) {
|
|
return shouldAutoEnable
|
|
}
|
|
|
|
shouldAutoEnable =
|
|
getIsInteractive() &&
|
|
isChromeExtensionInstalled_CACHED_MAY_BE_STALE() &&
|
|
(process.env.USER_TYPE === 'ant' ||
|
|
getFeatureValue_CACHED_MAY_BE_STALE('tengu_chrome_auto_enable', false))
|
|
|
|
return shouldAutoEnable
|
|
}
|
|
|
|
/**
|
|
* Setup Claude in Chrome MCP server and tools
|
|
*
|
|
* @returns MCP config and allowed tools, or throws an error if platform is unsupported
|
|
*/
|
|
export function setupClaudeInChrome(): {
|
|
mcpConfig: Record<string, ScopedMcpServerConfig>
|
|
allowedTools: string[]
|
|
systemPrompt: string
|
|
} {
|
|
const isNativeBuild = isInBundledMode()
|
|
const allowedTools = BROWSER_TOOLS.map(
|
|
tool => `mcp__claude-in-chrome__${tool.name}`,
|
|
)
|
|
|
|
const env: Record<string, string> = {}
|
|
if (getSessionBypassPermissionsMode()) {
|
|
env.CLAUDE_CHROME_PERMISSION_MODE = 'skip_all_permission_checks'
|
|
}
|
|
const hasEnv = Object.keys(env).length > 0
|
|
|
|
if (isNativeBuild) {
|
|
// Create a wrapper script that calls the same binary with --chrome-native-host. This
|
|
// is needed because the native host manifest "path" field cannot contain arguments.
|
|
const execCommand = `"${process.execPath}" --chrome-native-host`
|
|
|
|
// Run asynchronously without blocking; best-effort so swallow errors
|
|
void createWrapperScript(execCommand)
|
|
.then(manifestBinaryPath =>
|
|
installChromeNativeHostManifest(manifestBinaryPath),
|
|
)
|
|
.catch(e =>
|
|
logForDebugging(
|
|
`[Claude in Chrome] Failed to install native host: ${e}`,
|
|
{ level: 'error' },
|
|
),
|
|
)
|
|
|
|
return {
|
|
mcpConfig: {
|
|
[CLAUDE_IN_CHROME_MCP_SERVER_NAME]: {
|
|
type: 'stdio' as const,
|
|
command: process.execPath,
|
|
args: ['--claude-in-chrome-mcp'],
|
|
scope: 'dynamic' as const,
|
|
...(hasEnv && { env }),
|
|
},
|
|
},
|
|
allowedTools,
|
|
systemPrompt: getChromeSystemPrompt(),
|
|
}
|
|
} else {
|
|
const __filename = fileURLToPath(import.meta.url)
|
|
const __dirname = join(__filename, '..')
|
|
const cliPath = join(__dirname, 'cli.js')
|
|
|
|
void createWrapperScript(
|
|
`"${process.execPath}" "${cliPath}" --chrome-native-host`,
|
|
)
|
|
.then(manifestBinaryPath =>
|
|
installChromeNativeHostManifest(manifestBinaryPath),
|
|
)
|
|
.catch(e =>
|
|
logForDebugging(
|
|
`[Claude in Chrome] Failed to install native host: ${e}`,
|
|
{ level: 'error' },
|
|
),
|
|
)
|
|
|
|
const mcpConfig = {
|
|
[CLAUDE_IN_CHROME_MCP_SERVER_NAME]: {
|
|
type: 'stdio' as const,
|
|
command: process.execPath,
|
|
args: [`${cliPath}`, '--claude-in-chrome-mcp'],
|
|
scope: 'dynamic' as const,
|
|
...(hasEnv && { env }),
|
|
},
|
|
}
|
|
|
|
return {
|
|
mcpConfig,
|
|
allowedTools,
|
|
systemPrompt: getChromeSystemPrompt(),
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get native messaging hosts directories for all supported browsers
|
|
* Returns an array of directories where the native host manifest should be installed
|
|
*/
|
|
function getNativeMessagingHostsDirs(): string[] {
|
|
const platform = getPlatform()
|
|
|
|
if (platform === 'windows') {
|
|
// Windows uses a single location with registry entries pointing to it
|
|
const home = homedir()
|
|
const appData = process.env.APPDATA || join(home, 'AppData', 'Local')
|
|
return [join(appData, 'Claude Code', 'ChromeNativeHost')]
|
|
}
|
|
|
|
// macOS and Linux: return all browser native messaging directories
|
|
return getAllNativeMessagingHostsDirs().map(({ path }) => path)
|
|
}
|
|
|
|
export async function installChromeNativeHostManifest(
|
|
manifestBinaryPath: string,
|
|
): Promise<void> {
|
|
const manifestDirs = getNativeMessagingHostsDirs()
|
|
if (manifestDirs.length === 0) {
|
|
throw Error('Claude in Chrome Native Host not supported on this platform')
|
|
}
|
|
|
|
const manifest = {
|
|
name: NATIVE_HOST_IDENTIFIER,
|
|
description: 'Claude Code Browser Extension Native Host',
|
|
path: manifestBinaryPath,
|
|
type: 'stdio',
|
|
allowed_origins: [
|
|
`chrome-extension://fcoeoabgfenejglbffodgkkbkcdhcgfn/`, // PROD_EXTENSION_ID
|
|
...(process.env.USER_TYPE === 'ant'
|
|
? [
|
|
'chrome-extension://dihbgbndebgnbjfmelmegjepbnkhlgni/', // DEV_EXTENSION_ID
|
|
'chrome-extension://dngcpimnedloihjnnfngkgjoidhnaolf/', // ANT_EXTENSION_ID
|
|
]
|
|
: []),
|
|
],
|
|
}
|
|
|
|
const manifestContent = jsonStringify(manifest, null, 2)
|
|
let anyManifestUpdated = false
|
|
|
|
// Install manifest to all browser directories
|
|
for (const manifestDir of manifestDirs) {
|
|
const manifestPath = join(manifestDir, NATIVE_HOST_MANIFEST_NAME)
|
|
|
|
// Check if content matches to avoid unnecessary writes
|
|
const existingContent = await readFile(manifestPath, 'utf-8').catch(
|
|
() => null,
|
|
)
|
|
if (existingContent === manifestContent) {
|
|
continue
|
|
}
|
|
|
|
try {
|
|
await mkdir(manifestDir, { recursive: true })
|
|
await writeFile(manifestPath, manifestContent)
|
|
logForDebugging(
|
|
`[Claude in Chrome] Installed native host manifest at: ${manifestPath}`,
|
|
)
|
|
anyManifestUpdated = true
|
|
} catch (error) {
|
|
// Log but don't fail - the browser might not be installed
|
|
logForDebugging(
|
|
`[Claude in Chrome] Failed to install manifest at ${manifestPath}: ${error}`,
|
|
)
|
|
}
|
|
}
|
|
|
|
// Windows requires registry entries pointing to the manifest for each browser
|
|
if (getPlatform() === 'windows') {
|
|
const manifestPath = join(manifestDirs[0]!, NATIVE_HOST_MANIFEST_NAME)
|
|
registerWindowsNativeHosts(manifestPath)
|
|
}
|
|
|
|
// Restart the native host if we have rewritten any manifest
|
|
if (anyManifestUpdated) {
|
|
void isChromeExtensionInstalled().then(isInstalled => {
|
|
if (isInstalled) {
|
|
logForDebugging(
|
|
`[Claude in Chrome] First-time install detected, opening reconnect page in browser`,
|
|
)
|
|
void openInChrome(CHROME_EXTENSION_RECONNECT_URL)
|
|
} else {
|
|
logForDebugging(
|
|
`[Claude in Chrome] First-time install detected, but extension not installed, skipping reconnect`,
|
|
)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Register the native host in Windows registry for all supported browsers
|
|
*/
|
|
function registerWindowsNativeHosts(manifestPath: string): void {
|
|
const registryKeys = getAllWindowsRegistryKeys()
|
|
|
|
for (const { browser, key } of registryKeys) {
|
|
const fullKey = `${key}\\${NATIVE_HOST_IDENTIFIER}`
|
|
// Use reg.exe to add the registry entry
|
|
// https://developer.chrome.com/docs/extensions/develop/concepts/native-messaging
|
|
void execFileNoThrowWithCwd('reg', [
|
|
'add',
|
|
fullKey,
|
|
'/ve', // Set the default (unnamed) value
|
|
'/t',
|
|
'REG_SZ',
|
|
'/d',
|
|
manifestPath,
|
|
'/f', // Force overwrite without prompt
|
|
]).then(result => {
|
|
if (result.code === 0) {
|
|
logForDebugging(
|
|
`[Claude in Chrome] Registered native host for ${browser} in Windows registry: ${fullKey}`,
|
|
)
|
|
} else {
|
|
logForDebugging(
|
|
`[Claude in Chrome] Failed to register native host for ${browser} in Windows registry: ${result.stderr}`,
|
|
)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create a wrapper script in ~/.claude/chrome/ that invokes the given command. This is
|
|
* necessary because Chrome's native host manifest "path" field cannot contain arguments.
|
|
*
|
|
* @param command - The full command to execute (e.g., "/path/to/claude --chrome-native-host")
|
|
* @returns The path to the wrapper script
|
|
*/
|
|
async function createWrapperScript(command: string): Promise<string> {
|
|
const platform = getPlatform()
|
|
const chromeDir = join(getClaudeConfigHomeDir(), 'chrome')
|
|
const wrapperPath =
|
|
platform === 'windows'
|
|
? join(chromeDir, 'chrome-native-host.bat')
|
|
: join(chromeDir, 'chrome-native-host')
|
|
|
|
const scriptContent =
|
|
platform === 'windows'
|
|
? `@echo off
|
|
REM Chrome native host wrapper script
|
|
REM Generated by Claude Code - do not edit manually
|
|
${command}
|
|
`
|
|
: `#!/bin/sh
|
|
# Chrome native host wrapper script
|
|
# Generated by Claude Code - do not edit manually
|
|
exec ${command}
|
|
`
|
|
|
|
// Check if content matches to avoid unnecessary writes
|
|
const existingContent = await readFile(wrapperPath, 'utf-8').catch(() => null)
|
|
if (existingContent === scriptContent) {
|
|
return wrapperPath
|
|
}
|
|
|
|
await mkdir(chromeDir, { recursive: true })
|
|
await writeFile(wrapperPath, scriptContent)
|
|
|
|
if (platform !== 'windows') {
|
|
await chmod(wrapperPath, 0o755)
|
|
}
|
|
|
|
logForDebugging(
|
|
`[Claude in Chrome] Created Chrome native host wrapper script: ${wrapperPath}`,
|
|
)
|
|
return wrapperPath
|
|
}
|
|
|
|
/**
|
|
* Get cached value of whether Chrome extension is installed. Returns
|
|
* from disk cache immediately, updates cache in background.
|
|
*
|
|
* Use this for sync/startup-critical paths where blocking on filesystem
|
|
* access is not acceptable. The value may be stale if the cache hasn't
|
|
* been updated recently.
|
|
*
|
|
* Only positive detections are persisted. A negative result from the
|
|
* filesystem scan is not cached, because it may come from a machine that
|
|
* shares ~/.claude.json but has no local Chrome (e.g. a remote dev
|
|
* environment using the bridge), and caching it would permanently poison
|
|
* auto-enable for every session on every machine that reads that config.
|
|
*/
|
|
function isChromeExtensionInstalled_CACHED_MAY_BE_STALE(): boolean {
|
|
// Update cache in background without blocking
|
|
void isChromeExtensionInstalled().then(isInstalled => {
|
|
// Only persist positive detections — see docstring. The cost of a stale
|
|
// `true` is one silent MCP connection attempt per session; the cost of a
|
|
// stale `false` is auto-enable never working again without manual repair.
|
|
if (!isInstalled) {
|
|
return
|
|
}
|
|
const config = getGlobalConfig()
|
|
if (config.cachedChromeExtensionInstalled !== isInstalled) {
|
|
saveGlobalConfig(prev => ({
|
|
...prev,
|
|
cachedChromeExtensionInstalled: isInstalled,
|
|
}))
|
|
}
|
|
})
|
|
|
|
// Return cached value immediately from disk
|
|
const cached = getGlobalConfig().cachedChromeExtensionInstalled
|
|
return cached ?? false
|
|
}
|
|
|
|
/**
|
|
* Detects if the Claude in Chrome extension is installed by checking the Extensions
|
|
* directory across all supported Chromium-based browsers and their profiles.
|
|
*
|
|
* @returns Object with isInstalled boolean and the browser where the extension was found
|
|
*/
|
|
export async function isChromeExtensionInstalled(): Promise<boolean> {
|
|
const browserPaths = getAllBrowserDataPaths()
|
|
if (browserPaths.length === 0) {
|
|
logForDebugging(
|
|
`[Claude in Chrome] Unsupported platform for extension detection: ${getPlatform()}`,
|
|
)
|
|
return false
|
|
}
|
|
return isChromeExtensionInstalledPortable(browserPaths, logForDebugging)
|
|
}
|