init claude-code

This commit is contained in:
2026-04-01 17:32:37 +02:00
commit 73b208c009
1902 changed files with 513237 additions and 0 deletions
+527
View File
@@ -0,0 +1,527 @@
// biome-ignore-all lint/suspicious/noConsole: file uses console intentionally
/**
* Chrome Native Host - Pure TypeScript Implementation
*
* This module provides the Chrome native messaging host functionality,
* previously implemented as a Rust NAPI binding but now in pure TypeScript.
*/
import {
appendFile,
chmod,
mkdir,
readdir,
rmdir,
stat,
unlink,
} from 'fs/promises'
import { createServer, type Server, type Socket } from 'net'
import { homedir, platform } from 'os'
import { join } from 'path'
import { z } from 'zod'
import { lazySchema } from '../lazySchema.js'
import { jsonParse, jsonStringify } from '../slowOperations.js'
import { getSecureSocketPath, getSocketDir } from './common.js'
const VERSION = '1.0.0'
const MAX_MESSAGE_SIZE = 1024 * 1024 // 1MB - Max message size that can be sent to Chrome
const LOG_FILE =
process.env.USER_TYPE === 'ant'
? join(homedir(), '.claude', 'debug', 'chrome-native-host.txt')
: undefined
function log(message: string, ...args: unknown[]): void {
if (LOG_FILE) {
const timestamp = new Date().toISOString()
const formattedArgs = args.length > 0 ? ' ' + jsonStringify(args) : ''
const logLine = `[${timestamp}] [Claude Chrome Native Host] ${message}${formattedArgs}\n`
// Fire-and-forget: logging is best-effort and callers (including event
// handlers) don't await
void appendFile(LOG_FILE, logLine).catch(() => {
// Ignore file write errors
})
}
console.error(`[Claude Chrome Native Host] ${message}`, ...args)
}
/**
* Send a message to stdout (Chrome native messaging protocol)
*/
export function sendChromeMessage(message: string): void {
const jsonBytes = Buffer.from(message, 'utf-8')
const lengthBuffer = Buffer.alloc(4)
lengthBuffer.writeUInt32LE(jsonBytes.length, 0)
process.stdout.write(lengthBuffer)
process.stdout.write(jsonBytes)
}
export async function runChromeNativeHost(): Promise<void> {
log('Initializing...')
const host = new ChromeNativeHost()
const messageReader = new ChromeMessageReader()
// Start the native host server
await host.start()
// Process messages from Chrome until stdin closes
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
while (true) {
const message = await messageReader.read()
if (message === null) {
// stdin closed, Chrome disconnected
break
}
await host.handleMessage(message)
}
// Stop the server
await host.stop()
}
const messageSchema = lazySchema(() =>
z
.object({
type: z.string(),
})
.passthrough(),
)
type ToolRequest = {
method: string
params?: unknown
}
type McpClient = {
id: number
socket: Socket
buffer: Buffer
}
class ChromeNativeHost {
private mcpClients = new Map<number, McpClient>()
private nextClientId = 1
private server: Server | null = null
private running = false
private socketPath: string | null = null
async start(): Promise<void> {
if (this.running) {
return
}
this.socketPath = getSecureSocketPath()
if (platform() !== 'win32') {
const socketDir = getSocketDir()
// Migrate legacy socket: if socket dir path exists as a file/socket, remove it
try {
const dirStats = await stat(socketDir)
if (!dirStats.isDirectory()) {
await unlink(socketDir)
}
} catch {
// Doesn't exist, that's fine
}
// Create socket directory with secure permissions
await mkdir(socketDir, { recursive: true, mode: 0o700 })
// Fix perms if directory already existed
await chmod(socketDir, 0o700).catch(() => {
// Ignore
})
// Clean up stale sockets
try {
const files = await readdir(socketDir)
for (const file of files) {
if (!file.endsWith('.sock')) {
continue
}
const pid = parseInt(file.replace('.sock', ''), 10)
if (isNaN(pid)) {
continue
}
try {
process.kill(pid, 0)
// Process is alive, leave it
} catch {
// Process is dead, remove stale socket
await unlink(join(socketDir, file)).catch(() => {
// Ignore
})
log(`Removed stale socket for PID ${pid}`)
}
}
} catch {
// Ignore errors scanning directory
}
}
log(`Creating socket listener: ${this.socketPath}`)
this.server = createServer(socket => this.handleMcpClient(socket))
await new Promise<void>((resolve, reject) => {
this.server!.listen(this.socketPath!, () => {
log('Socket server listening for connections')
this.running = true
resolve()
})
this.server!.on('error', err => {
log('Socket server error:', err)
reject(err)
})
})
// Set permissions on Unix (after listen resolves so socket file exists)
if (platform() !== 'win32') {
try {
await chmod(this.socketPath!, 0o600)
log('Socket permissions set to 0600')
} catch (e) {
log('Failed to set socket permissions:', e)
}
}
}
async stop(): Promise<void> {
if (!this.running) {
return
}
// Close all MCP clients
for (const [, client] of this.mcpClients) {
client.socket.destroy()
}
this.mcpClients.clear()
// Close server
if (this.server) {
await new Promise<void>(resolve => {
this.server!.close(() => resolve())
})
this.server = null
}
// Cleanup socket file
if (platform() !== 'win32' && this.socketPath) {
try {
await unlink(this.socketPath)
log('Cleaned up socket file')
} catch {
// ENOENT is fine, ignore
}
// Remove directory if empty
try {
const socketDir = getSocketDir()
const remaining = await readdir(socketDir)
if (remaining.length === 0) {
await rmdir(socketDir)
log('Removed empty socket directory')
}
} catch {
// Ignore
}
}
this.running = false
}
async isRunning(): Promise<boolean> {
return this.running
}
async getClientCount(): Promise<number> {
return this.mcpClients.size
}
async handleMessage(messageJson: string): Promise<void> {
let rawMessage: unknown
try {
rawMessage = jsonParse(messageJson)
} catch (e) {
log('Invalid JSON from Chrome:', (e as Error).message)
sendChromeMessage(
jsonStringify({
type: 'error',
error: 'Invalid message format',
}),
)
return
}
const parsed = messageSchema().safeParse(rawMessage)
if (!parsed.success) {
log('Invalid message from Chrome:', parsed.error.message)
sendChromeMessage(
jsonStringify({
type: 'error',
error: 'Invalid message format',
}),
)
return
}
const message = parsed.data
log(`Handling Chrome message type: ${message.type}`)
switch (message.type) {
case 'ping':
log('Responding to ping')
sendChromeMessage(
jsonStringify({
type: 'pong',
timestamp: Date.now(),
}),
)
break
case 'get_status':
sendChromeMessage(
jsonStringify({
type: 'status_response',
native_host_version: VERSION,
}),
)
break
case 'tool_response': {
if (this.mcpClients.size > 0) {
log(`Forwarding tool response to ${this.mcpClients.size} MCP clients`)
// Extract the data portion (everything except 'type')
const { type: _, ...data } = message
const responseData = Buffer.from(jsonStringify(data), 'utf-8')
const lengthBuffer = Buffer.alloc(4)
lengthBuffer.writeUInt32LE(responseData.length, 0)
const responseMsg = Buffer.concat([lengthBuffer, responseData])
for (const [id, client] of this.mcpClients) {
try {
client.socket.write(responseMsg)
} catch (e) {
log(`Failed to send to MCP client ${id}:`, e)
}
}
}
break
}
case 'notification': {
if (this.mcpClients.size > 0) {
log(`Forwarding notification to ${this.mcpClients.size} MCP clients`)
// Extract the data portion (everything except 'type')
const { type: _, ...data } = message
const notificationData = Buffer.from(jsonStringify(data), 'utf-8')
const lengthBuffer = Buffer.alloc(4)
lengthBuffer.writeUInt32LE(notificationData.length, 0)
const notificationMsg = Buffer.concat([
lengthBuffer,
notificationData,
])
for (const [id, client] of this.mcpClients) {
try {
client.socket.write(notificationMsg)
} catch (e) {
log(`Failed to send notification to MCP client ${id}:`, e)
}
}
}
break
}
default:
log(`Unknown message type: ${message.type}`)
sendChromeMessage(
jsonStringify({
type: 'error',
error: `Unknown message type: ${message.type}`,
}),
)
}
}
private handleMcpClient(socket: Socket): void {
const clientId = this.nextClientId++
const client: McpClient = {
id: clientId,
socket,
buffer: Buffer.alloc(0),
}
this.mcpClients.set(clientId, client)
log(
`MCP client ${clientId} connected. Total clients: ${this.mcpClients.size}`,
)
// Notify Chrome of connection
sendChromeMessage(
jsonStringify({
type: 'mcp_connected',
}),
)
socket.on('data', (data: Buffer) => {
client.buffer = Buffer.concat([client.buffer, data])
// Process complete messages
while (client.buffer.length >= 4) {
const length = client.buffer.readUInt32LE(0)
if (length === 0 || length > MAX_MESSAGE_SIZE) {
log(`Invalid message length from MCP client ${clientId}: ${length}`)
socket.destroy()
return
}
if (client.buffer.length < 4 + length) {
break // Wait for more data
}
const messageBytes = client.buffer.slice(4, 4 + length)
client.buffer = client.buffer.slice(4 + length)
try {
const request = jsonParse(
messageBytes.toString('utf-8'),
) as ToolRequest
log(
`Forwarding tool request from MCP client ${clientId}: ${request.method}`,
)
// Forward to Chrome
sendChromeMessage(
jsonStringify({
type: 'tool_request',
method: request.method,
params: request.params,
}),
)
} catch (e) {
log(`Failed to parse tool request from MCP client ${clientId}:`, e)
}
}
})
socket.on('error', err => {
log(`MCP client ${clientId} error: ${err}`)
})
socket.on('close', () => {
log(
`MCP client ${clientId} disconnected. Remaining clients: ${this.mcpClients.size - 1}`,
)
this.mcpClients.delete(clientId)
// Notify Chrome of disconnection
sendChromeMessage(
jsonStringify({
type: 'mcp_disconnected',
}),
)
})
}
}
/**
* Chrome message reader using async stdin. Synchronous reads can crash Bun, so we use
* async reads with a buffer.
*/
class ChromeMessageReader {
private buffer = Buffer.alloc(0)
private pendingResolve: ((value: string | null) => void) | null = null
private closed = false
constructor() {
process.stdin.on('data', (chunk: Buffer) => {
this.buffer = Buffer.concat([this.buffer, chunk])
this.tryProcessMessage()
})
process.stdin.on('end', () => {
this.closed = true
if (this.pendingResolve) {
this.pendingResolve(null)
this.pendingResolve = null
}
})
process.stdin.on('error', () => {
this.closed = true
if (this.pendingResolve) {
this.pendingResolve(null)
this.pendingResolve = null
}
})
}
private tryProcessMessage(): void {
if (!this.pendingResolve) {
return
}
// Need at least 4 bytes for length prefix
if (this.buffer.length < 4) {
return
}
const length = this.buffer.readUInt32LE(0)
if (length === 0 || length > MAX_MESSAGE_SIZE) {
log(`Invalid message length: ${length}`)
this.pendingResolve(null)
this.pendingResolve = null
return
}
// Check if we have the full message
if (this.buffer.length < 4 + length) {
return // Wait for more data
}
// Extract the message
const messageBytes = this.buffer.subarray(4, 4 + length)
this.buffer = this.buffer.subarray(4 + length)
const message = messageBytes.toString('utf-8')
this.pendingResolve(message)
this.pendingResolve = null
}
async read(): Promise<string | null> {
if (this.closed) {
return null
}
// Check if we already have a complete message buffered
if (this.buffer.length >= 4) {
const length = this.buffer.readUInt32LE(0)
if (
length > 0 &&
length <= MAX_MESSAGE_SIZE &&
this.buffer.length >= 4 + length
) {
const messageBytes = this.buffer.subarray(4, 4 + length)
this.buffer = this.buffer.subarray(4 + length)
return messageBytes.toString('utf-8')
}
}
// Wait for more data
return new Promise(resolve => {
this.pendingResolve = resolve
// In case data arrived between check and setting pendingResolve
this.tryProcessMessage()
})
}
}
+540
View File
@@ -0,0 +1,540 @@
import { readdirSync } from 'fs'
import { stat } from 'fs/promises'
import { homedir, platform, tmpdir, userInfo } from 'os'
import { join } from 'path'
import { normalizeNameForMCP } from '../../services/mcp/normalization.js'
import { logForDebugging } from '../debug.js'
import { isFsInaccessible } from '../errors.js'
import { execFileNoThrow } from '../execFileNoThrow.js'
import { getPlatform } from '../platform.js'
import { which } from '../which.js'
export const CLAUDE_IN_CHROME_MCP_SERVER_NAME = 'claude-in-chrome'
// Re-export ChromiumBrowser type for setup.ts
export type { ChromiumBrowser } from './setupPortable.js'
// Import for local use
import type { ChromiumBrowser } from './setupPortable.js'
type BrowserConfig = {
name: string
macos: {
appName: string
dataPath: string[]
nativeMessagingPath: string[]
}
linux: {
binaries: string[]
dataPath: string[]
nativeMessagingPath: string[]
}
windows: {
dataPath: string[]
registryKey: string
useRoaming?: boolean // Opera uses Roaming instead of Local
}
}
export const CHROMIUM_BROWSERS: Record<ChromiumBrowser, BrowserConfig> = {
chrome: {
name: 'Google Chrome',
macos: {
appName: 'Google Chrome',
dataPath: ['Library', 'Application Support', 'Google', 'Chrome'],
nativeMessagingPath: [
'Library',
'Application Support',
'Google',
'Chrome',
'NativeMessagingHosts',
],
},
linux: {
binaries: ['google-chrome', 'google-chrome-stable'],
dataPath: ['.config', 'google-chrome'],
nativeMessagingPath: ['.config', 'google-chrome', 'NativeMessagingHosts'],
},
windows: {
dataPath: ['Google', 'Chrome', 'User Data'],
registryKey: 'HKCU\\Software\\Google\\Chrome\\NativeMessagingHosts',
},
},
brave: {
name: 'Brave',
macos: {
appName: 'Brave Browser',
dataPath: [
'Library',
'Application Support',
'BraveSoftware',
'Brave-Browser',
],
nativeMessagingPath: [
'Library',
'Application Support',
'BraveSoftware',
'Brave-Browser',
'NativeMessagingHosts',
],
},
linux: {
binaries: ['brave-browser', 'brave'],
dataPath: ['.config', 'BraveSoftware', 'Brave-Browser'],
nativeMessagingPath: [
'.config',
'BraveSoftware',
'Brave-Browser',
'NativeMessagingHosts',
],
},
windows: {
dataPath: ['BraveSoftware', 'Brave-Browser', 'User Data'],
registryKey:
'HKCU\\Software\\BraveSoftware\\Brave-Browser\\NativeMessagingHosts',
},
},
arc: {
name: 'Arc',
macos: {
appName: 'Arc',
dataPath: ['Library', 'Application Support', 'Arc', 'User Data'],
nativeMessagingPath: [
'Library',
'Application Support',
'Arc',
'User Data',
'NativeMessagingHosts',
],
},
linux: {
// Arc is not available on Linux
binaries: [],
dataPath: [],
nativeMessagingPath: [],
},
windows: {
// Arc Windows is Chromium-based
dataPath: ['Arc', 'User Data'],
registryKey: 'HKCU\\Software\\ArcBrowser\\Arc\\NativeMessagingHosts',
},
},
chromium: {
name: 'Chromium',
macos: {
appName: 'Chromium',
dataPath: ['Library', 'Application Support', 'Chromium'],
nativeMessagingPath: [
'Library',
'Application Support',
'Chromium',
'NativeMessagingHosts',
],
},
linux: {
binaries: ['chromium', 'chromium-browser'],
dataPath: ['.config', 'chromium'],
nativeMessagingPath: ['.config', 'chromium', 'NativeMessagingHosts'],
},
windows: {
dataPath: ['Chromium', 'User Data'],
registryKey: 'HKCU\\Software\\Chromium\\NativeMessagingHosts',
},
},
edge: {
name: 'Microsoft Edge',
macos: {
appName: 'Microsoft Edge',
dataPath: ['Library', 'Application Support', 'Microsoft Edge'],
nativeMessagingPath: [
'Library',
'Application Support',
'Microsoft Edge',
'NativeMessagingHosts',
],
},
linux: {
binaries: ['microsoft-edge', 'microsoft-edge-stable'],
dataPath: ['.config', 'microsoft-edge'],
nativeMessagingPath: [
'.config',
'microsoft-edge',
'NativeMessagingHosts',
],
},
windows: {
dataPath: ['Microsoft', 'Edge', 'User Data'],
registryKey: 'HKCU\\Software\\Microsoft\\Edge\\NativeMessagingHosts',
},
},
vivaldi: {
name: 'Vivaldi',
macos: {
appName: 'Vivaldi',
dataPath: ['Library', 'Application Support', 'Vivaldi'],
nativeMessagingPath: [
'Library',
'Application Support',
'Vivaldi',
'NativeMessagingHosts',
],
},
linux: {
binaries: ['vivaldi', 'vivaldi-stable'],
dataPath: ['.config', 'vivaldi'],
nativeMessagingPath: ['.config', 'vivaldi', 'NativeMessagingHosts'],
},
windows: {
dataPath: ['Vivaldi', 'User Data'],
registryKey: 'HKCU\\Software\\Vivaldi\\NativeMessagingHosts',
},
},
opera: {
name: 'Opera',
macos: {
appName: 'Opera',
dataPath: ['Library', 'Application Support', 'com.operasoftware.Opera'],
nativeMessagingPath: [
'Library',
'Application Support',
'com.operasoftware.Opera',
'NativeMessagingHosts',
],
},
linux: {
binaries: ['opera'],
dataPath: ['.config', 'opera'],
nativeMessagingPath: ['.config', 'opera', 'NativeMessagingHosts'],
},
windows: {
dataPath: ['Opera Software', 'Opera Stable'],
registryKey:
'HKCU\\Software\\Opera Software\\Opera Stable\\NativeMessagingHosts',
useRoaming: true, // Opera uses Roaming AppData, not Local
},
},
}
// Priority order for browser detection (most common first)
export const BROWSER_DETECTION_ORDER: ChromiumBrowser[] = [
'chrome',
'brave',
'arc',
'edge',
'chromium',
'vivaldi',
'opera',
]
/**
* Get all browser data paths to check for extension installation
*/
export function getAllBrowserDataPaths(): {
browser: ChromiumBrowser
path: string
}[] {
const platform = getPlatform()
const home = homedir()
const paths: { browser: ChromiumBrowser; path: string }[] = []
for (const browserId of BROWSER_DETECTION_ORDER) {
const config = CHROMIUM_BROWSERS[browserId]
let dataPath: string[] | undefined
switch (platform) {
case 'macos':
dataPath = config.macos.dataPath
break
case 'linux':
case 'wsl':
dataPath = config.linux.dataPath
break
case 'windows': {
if (config.windows.dataPath.length > 0) {
const appDataBase = config.windows.useRoaming
? join(home, 'AppData', 'Roaming')
: join(home, 'AppData', 'Local')
paths.push({
browser: browserId,
path: join(appDataBase, ...config.windows.dataPath),
})
}
continue
}
}
if (dataPath && dataPath.length > 0) {
paths.push({
browser: browserId,
path: join(home, ...dataPath),
})
}
}
return paths
}
/**
* Get native messaging host directories for all supported browsers
*/
export function getAllNativeMessagingHostsDirs(): {
browser: ChromiumBrowser
path: string
}[] {
const platform = getPlatform()
const home = homedir()
const paths: { browser: ChromiumBrowser; path: string }[] = []
for (const browserId of BROWSER_DETECTION_ORDER) {
const config = CHROMIUM_BROWSERS[browserId]
switch (platform) {
case 'macos':
if (config.macos.nativeMessagingPath.length > 0) {
paths.push({
browser: browserId,
path: join(home, ...config.macos.nativeMessagingPath),
})
}
break
case 'linux':
case 'wsl':
if (config.linux.nativeMessagingPath.length > 0) {
paths.push({
browser: browserId,
path: join(home, ...config.linux.nativeMessagingPath),
})
}
break
case 'windows':
// Windows uses registry, not file paths for native messaging
// We'll use a common location for the manifest file
break
}
}
return paths
}
/**
* Get Windows registry keys for all supported browsers
*/
export function getAllWindowsRegistryKeys(): {
browser: ChromiumBrowser
key: string
}[] {
const keys: { browser: ChromiumBrowser; key: string }[] = []
for (const browserId of BROWSER_DETECTION_ORDER) {
const config = CHROMIUM_BROWSERS[browserId]
if (config.windows.registryKey) {
keys.push({
browser: browserId,
key: config.windows.registryKey,
})
}
}
return keys
}
/**
* Detect which browser to use for opening URLs
* Returns the first available browser, or null if none found
*/
export async function detectAvailableBrowser(): Promise<ChromiumBrowser | null> {
const platform = getPlatform()
for (const browserId of BROWSER_DETECTION_ORDER) {
const config = CHROMIUM_BROWSERS[browserId]
switch (platform) {
case 'macos': {
// Check if the .app bundle (a directory) exists
const appPath = `/Applications/${config.macos.appName}.app`
try {
const stats = await stat(appPath)
if (stats.isDirectory()) {
logForDebugging(
`[Claude in Chrome] Detected browser: ${config.name}`,
)
return browserId
}
} catch (e) {
if (!isFsInaccessible(e)) throw e
// App not found, continue checking
}
break
}
case 'wsl':
case 'linux': {
// Check if any binary exists
for (const binary of config.linux.binaries) {
if (await which(binary).catch(() => null)) {
logForDebugging(
`[Claude in Chrome] Detected browser: ${config.name}`,
)
return browserId
}
}
break
}
case 'windows': {
// Check if data path exists (indicates browser is installed)
const home = homedir()
if (config.windows.dataPath.length > 0) {
const appDataBase = config.windows.useRoaming
? join(home, 'AppData', 'Roaming')
: join(home, 'AppData', 'Local')
const dataPath = join(appDataBase, ...config.windows.dataPath)
try {
const stats = await stat(dataPath)
if (stats.isDirectory()) {
logForDebugging(
`[Claude in Chrome] Detected browser: ${config.name}`,
)
return browserId
}
} catch (e) {
if (!isFsInaccessible(e)) throw e
// Browser not found, continue checking
}
}
break
}
}
}
return null
}
export function isClaudeInChromeMCPServer(name: string): boolean {
return normalizeNameForMCP(name) === CLAUDE_IN_CHROME_MCP_SERVER_NAME
}
const MAX_TRACKED_TABS = 200
const trackedTabIds = new Set<number>()
export function trackClaudeInChromeTabId(tabId: number): void {
if (trackedTabIds.size >= MAX_TRACKED_TABS && !trackedTabIds.has(tabId)) {
trackedTabIds.clear()
}
trackedTabIds.add(tabId)
}
export function isTrackedClaudeInChromeTabId(tabId: number): boolean {
return trackedTabIds.has(tabId)
}
export async function openInChrome(url: string): Promise<boolean> {
const currentPlatform = getPlatform()
// Detect the best available browser
const browser = await detectAvailableBrowser()
if (!browser) {
logForDebugging('[Claude in Chrome] No compatible browser found')
return false
}
const config = CHROMIUM_BROWSERS[browser]
switch (currentPlatform) {
case 'macos': {
const { code } = await execFileNoThrow('open', [
'-a',
config.macos.appName,
url,
])
return code === 0
}
case 'windows': {
// Use rundll32 to avoid cmd.exe metacharacter issues with URLs containing & | > <
const { code } = await execFileNoThrow('rundll32', ['url,OpenURL', url])
return code === 0
}
case 'wsl':
case 'linux': {
for (const binary of config.linux.binaries) {
const { code } = await execFileNoThrow(binary, [url])
if (code === 0) {
return true
}
}
return false
}
default:
return false
}
}
/**
* Get the socket directory path (Unix only)
*/
export function getSocketDir(): string {
return `/tmp/claude-mcp-browser-bridge-${getUsername()}`
}
/**
* Get the socket path (Unix) or pipe name (Windows)
*/
export function getSecureSocketPath(): string {
if (platform() === 'win32') {
return `\\\\.\\pipe\\${getSocketName()}`
}
return join(getSocketDir(), `${process.pid}.sock`)
}
/**
* Get all socket paths including PID-based sockets in the directory
* and legacy fallback paths
*/
export function getAllSocketPaths(): string[] {
// Windows uses named pipes, not Unix sockets
if (platform() === 'win32') {
return [`\\\\.\\pipe\\${getSocketName()}`]
}
const paths: string[] = []
const socketDir = getSocketDir()
// Scan for *.sock files in the socket directory
try {
// eslint-disable-next-line custom-rules/no-sync-fs -- ClaudeForChromeContext.getSocketPaths (external @ant/claude-for-chrome-mcp) requires a sync () => string[] callback
const files = readdirSync(socketDir)
for (const file of files) {
if (file.endsWith('.sock')) {
paths.push(join(socketDir, file))
}
}
} catch {
// Directory may not exist yet
}
// Legacy fallback paths
const legacyName = `claude-mcp-browser-bridge-${getUsername()}`
const legacyTmpdir = join(tmpdir(), legacyName)
const legacyTmp = `/tmp/${legacyName}`
if (!paths.includes(legacyTmpdir)) {
paths.push(legacyTmpdir)
}
if (legacyTmpdir !== legacyTmp && !paths.includes(legacyTmp)) {
paths.push(legacyTmp)
}
return paths
}
function getSocketName(): string {
// NOTE: This must match the one used in the Claude in Chrome MCP
return `claude-mcp-browser-bridge-${getUsername()}`
}
function getUsername(): string {
try {
return userInfo().username || 'default'
} catch {
return process.env.USER || process.env.USERNAME || 'default'
}
}
+293
View File
@@ -0,0 +1,293 @@
import {
type ClaudeForChromeContext,
createClaudeForChromeMcpServer,
type Logger,
type PermissionMode,
} from '@ant/claude-for-chrome-mcp'
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
import { format } from 'util'
import { shutdownDatadog } from '../../services/analytics/datadog.js'
import { shutdown1PEventLogging } from '../../services/analytics/firstPartyEventLogger.js'
import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js'
import {
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
logEvent,
} from '../../services/analytics/index.js'
import { initializeAnalyticsSink } from '../../services/analytics/sink.js'
import { getClaudeAIOAuthTokens } from '../auth.js'
import { enableConfigs, getGlobalConfig, saveGlobalConfig } from '../config.js'
import { logForDebugging } from '../debug.js'
import { isEnvTruthy } from '../envUtils.js'
import { sideQuery } from '../sideQuery.js'
import { getAllSocketPaths, getSecureSocketPath } from './common.js'
const EXTENSION_DOWNLOAD_URL = 'https://claude.ai/chrome'
const BUG_REPORT_URL =
'https://github.com/anthropics/claude-code/issues/new?labels=bug,claude-in-chrome'
// String metadata keys safe to forward to analytics. Keys like error_message
// are excluded because they could contain page content or user data.
const SAFE_BRIDGE_STRING_KEYS = new Set([
'bridge_status',
'error_type',
'tool_name',
])
const PERMISSION_MODES: readonly PermissionMode[] = [
'ask',
'skip_all_permission_checks',
'follow_a_plan',
]
function isPermissionMode(raw: string): raw is PermissionMode {
return PERMISSION_MODES.some(m => m === raw)
}
/**
* Resolves the Chrome bridge URL based on environment and feature flag.
* Bridge is used when the feature flag is enabled; ant users always get
* bridge. API key / 3P users fall back to native messaging.
*/
function getChromeBridgeUrl(): string | undefined {
const bridgeEnabled =
process.env.USER_TYPE === 'ant' ||
getFeatureValue_CACHED_MAY_BE_STALE('tengu_copper_bridge', false)
if (!bridgeEnabled) {
return undefined
}
if (
isEnvTruthy(process.env.USE_LOCAL_OAUTH) ||
isEnvTruthy(process.env.LOCAL_BRIDGE)
) {
return 'ws://localhost:8765'
}
if (isEnvTruthy(process.env.USE_STAGING_OAUTH)) {
return 'wss://bridge-staging.claudeusercontent.com'
}
return 'wss://bridge.claudeusercontent.com'
}
function isLocalBridge(): boolean {
return (
isEnvTruthy(process.env.USE_LOCAL_OAUTH) ||
isEnvTruthy(process.env.LOCAL_BRIDGE)
)
}
/**
* Build the ClaudeForChromeContext used by both the subprocess MCP server
* and the in-process path in the MCP client.
*/
export function createChromeContext(
env?: Record<string, string>,
): ClaudeForChromeContext {
const logger = new DebugLogger()
const chromeBridgeUrl = getChromeBridgeUrl()
logger.info(`Bridge URL: ${chromeBridgeUrl ?? 'none (using native socket)'}`)
const rawPermissionMode =
env?.CLAUDE_CHROME_PERMISSION_MODE ??
process.env.CLAUDE_CHROME_PERMISSION_MODE
let initialPermissionMode: PermissionMode | undefined
if (rawPermissionMode) {
if (isPermissionMode(rawPermissionMode)) {
initialPermissionMode = rawPermissionMode
} else {
logger.warn(
`Invalid CLAUDE_CHROME_PERMISSION_MODE "${rawPermissionMode}". Valid values: ${PERMISSION_MODES.join(', ')}`,
)
}
}
return {
serverName: 'Claude in Chrome',
logger,
socketPath: getSecureSocketPath(),
getSocketPaths: getAllSocketPaths,
clientTypeId: 'claude-code',
onAuthenticationError: () => {
logger.warn(
'Authentication error occurred. Please ensure you are logged into the Claude browser extension with the same claude.ai account as Claude Code.',
)
},
onToolCallDisconnected: () => {
return `Browser extension is not connected. Please ensure the Claude browser extension is installed and running (${EXTENSION_DOWNLOAD_URL}), and that you are logged into claude.ai with the same account as Claude Code. If this is your first time connecting to Chrome, you may need to restart Chrome for the installation to take effect. If you continue to experience issues, please report a bug: ${BUG_REPORT_URL}`
},
onExtensionPaired: (deviceId: string, name: string) => {
saveGlobalConfig(config => {
if (
config.chromeExtension?.pairedDeviceId === deviceId &&
config.chromeExtension?.pairedDeviceName === name
) {
return config
}
return {
...config,
chromeExtension: {
pairedDeviceId: deviceId,
pairedDeviceName: name,
},
}
})
logger.info(`Paired with "${name}" (${deviceId.slice(0, 8)})`)
},
getPersistedDeviceId: () => {
return getGlobalConfig().chromeExtension?.pairedDeviceId
},
...(chromeBridgeUrl && {
bridgeConfig: {
url: chromeBridgeUrl,
getUserId: async () => {
return getGlobalConfig().oauthAccount?.accountUuid
},
getOAuthToken: async () => {
return getClaudeAIOAuthTokens()?.accessToken ?? ''
},
...(isLocalBridge() && { devUserId: 'dev_user_local' }),
},
}),
...(initialPermissionMode && { initialPermissionMode }),
// Wire inference for the browser_task tool — the chrome-mcp server runs
// a lightning-mode agent loop in Node and calls the extension's
// lightning_turn tool once per iteration for execution.
//
// Ant-only: the extension's lightning_turn is build-time-gated via
// import.meta.env.ANT_ONLY_BUILD — the whole lightning/ module graph is
// tree-shaken from the public extension build (build:prod greps for a
// marker to verify). Without this injection, the Node MCP server's
// ListTools also filters browser_task + lightning_turn out, so external
// users never see the tools advertised. Three independent gates.
//
// Types inlined: AnthropicMessagesRequest/Response live in
// @ant/claude-for-chrome-mcp@0.4.0 which isn't published yet. CI installs
// 0.3.0. The callAnthropicMessages field is also 0.4.0-only, but spreading
// an extra property into ClaudeForChromeContext is fine against either
// version — 0.3.0 sees an unknown field (allowed in spread), 0.4.0 sees a
// structurally-matching one. Once 0.4.0 is published, this can switch to
// the package's exported types and the dep can be bumped.
...(process.env.USER_TYPE === 'ant' && {
callAnthropicMessages: async (req: {
model: string
max_tokens: number
system: string
messages: Parameters<typeof sideQuery>[0]['messages']
stop_sequences?: string[]
signal?: AbortSignal
}): Promise<{
content: Array<{ type: 'text'; text: string }>
stop_reason: string | null
usage?: { input_tokens: number; output_tokens: number }
}> => {
// sideQuery handles OAuth attribution fingerprint, proxy, model betas.
// skipSystemPromptPrefix: the lightning prompt is complete on its own;
// the CLI prefix would dilute the batching instructions.
// tools: [] is load-bearing — without it Sonnet emits
// <function_calls> XML before the text commands. Original
// lightning-harness.js (apps repo) does the same.
const response = await sideQuery({
model: req.model,
system: req.system,
messages: req.messages,
max_tokens: req.max_tokens,
stop_sequences: req.stop_sequences,
signal: req.signal,
skipSystemPromptPrefix: true,
tools: [],
querySource: 'chrome_mcp',
})
// BetaContentBlock is TextBlock | ThinkingBlock | ToolUseBlock | ...
// Only text blocks carry the model's command output.
const textBlocks: Array<{ type: 'text'; text: string }> = []
for (const b of response.content) {
if (b.type === 'text') {
textBlocks.push({ type: 'text', text: b.text })
}
}
return {
content: textBlocks,
stop_reason: response.stop_reason,
usage: {
input_tokens: response.usage.input_tokens,
output_tokens: response.usage.output_tokens,
},
}
},
}),
trackEvent: (eventName, metadata) => {
const safeMetadata: {
[key: string]:
| boolean
| number
| AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
| undefined
} = {}
if (metadata) {
for (const [key, value] of Object.entries(metadata)) {
// Rename 'status' to 'bridge_status' to avoid Datadog's reserved field
const safeKey = key === 'status' ? 'bridge_status' : key
if (typeof value === 'boolean' || typeof value === 'number') {
safeMetadata[safeKey] = value
} else if (
typeof value === 'string' &&
SAFE_BRIDGE_STRING_KEYS.has(safeKey)
) {
// Only forward allowlisted string keys — fields like error_message
// could contain page content or user data
safeMetadata[safeKey] =
value as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
}
}
}
logEvent(eventName, safeMetadata)
},
}
}
export async function runClaudeInChromeMcpServer(): Promise<void> {
enableConfigs()
initializeAnalyticsSink()
const context = createChromeContext()
const server = createClaudeForChromeMcpServer(context)
const transport = new StdioServerTransport()
// Exit when parent process dies (stdin pipe closes).
// Flush analytics before exiting so final-batch events (e.g. disconnect) aren't lost.
let exiting = false
const shutdownAndExit = async (): Promise<void> => {
if (exiting) {
return
}
exiting = true
await shutdown1PEventLogging()
await shutdownDatadog()
// eslint-disable-next-line custom-rules/no-process-exit
process.exit(0)
}
process.stdin.on('end', () => void shutdownAndExit())
process.stdin.on('error', () => void shutdownAndExit())
logForDebugging('[Claude in Chrome] Starting MCP server')
await server.connect(transport)
logForDebugging('[Claude in Chrome] MCP server started')
}
class DebugLogger implements Logger {
silly(message: string, ...args: unknown[]): void {
logForDebugging(format(message, ...args), { level: 'debug' })
}
debug(message: string, ...args: unknown[]): void {
logForDebugging(format(message, ...args), { level: 'debug' })
}
info(message: string, ...args: unknown[]): void {
logForDebugging(format(message, ...args), { level: 'info' })
}
warn(message: string, ...args: unknown[]): void {
logForDebugging(format(message, ...args), { level: 'warn' })
}
error(message: string, ...args: unknown[]): void {
logForDebugging(format(message, ...args), { level: 'error' })
}
}
+83
View File
@@ -0,0 +1,83 @@
export const BASE_CHROME_PROMPT = `# Claude in Chrome browser automation
You have access to browser automation tools (mcp__claude-in-chrome__*) for interacting with web pages in Chrome. Follow these guidelines for effective browser automation.
## GIF recording
When performing multi-step browser interactions that the user may want to review or share, use mcp__claude-in-chrome__gif_creator to record them.
You must ALWAYS:
* Capture extra frames before and after taking actions to ensure smooth playback
* Name the file meaningfully to help the user identify it later (e.g., "login_process.gif")
## Console log debugging
You can use mcp__claude-in-chrome__read_console_messages to read console output. Console output may be verbose. If you are looking for specific log entries, use the 'pattern' parameter with a regex-compatible pattern. This filters results efficiently and avoids overwhelming output. For example, use pattern: "[MyApp]" to filter for application-specific logs rather than reading all console output.
## Alerts and dialogs
IMPORTANT: Do not trigger JavaScript alerts, confirms, prompts, or browser modal dialogs through your actions. These browser dialogs block all further browser events and will prevent the extension from receiving any subsequent commands. Instead, when possible, use console.log for debugging and then use the mcp__claude-in-chrome__read_console_messages tool to read those log messages. If a page has dialog-triggering elements:
1. Avoid clicking buttons or links that may trigger alerts (e.g., "Delete" buttons with confirmation dialogs)
2. If you must interact with such elements, warn the user first that this may interrupt the session
3. Use mcp__claude-in-chrome__javascript_tool to check for and dismiss any existing dialogs before proceeding
If you accidentally trigger a dialog and lose responsiveness, inform the user they need to manually dismiss it in the browser.
## Avoid rabbit holes and loops
When using browser automation tools, stay focused on the specific task. If you encounter any of the following, stop and ask the user for guidance:
- Unexpected complexity or tangential browser exploration
- Browser tool calls failing or returning errors after 2-3 attempts
- No response from the browser extension
- Page elements not responding to clicks or input
- Pages not loading or timing out
- Unable to complete the browser task despite multiple approaches
Explain what you attempted, what went wrong, and ask how the user would like to proceed. Do not keep retrying the same failing browser action or explore unrelated pages without checking in first.
## Tab context and session startup
IMPORTANT: At the start of each browser automation session, call mcp__claude-in-chrome__tabs_context_mcp first to get information about the user's current browser tabs. Use this context to understand what the user might want to work with before creating new tabs.
Never reuse tab IDs from a previous/other session. Follow these guidelines:
1. Only reuse an existing tab if the user explicitly asks to work with it
2. Otherwise, create a new tab with mcp__claude-in-chrome__tabs_create_mcp
3. If a tool returns an error indicating the tab doesn't exist or is invalid, call tabs_context_mcp to get fresh tab IDs
4. When a tab is closed by the user or a navigation error occurs, call tabs_context_mcp to see what tabs are available`
/**
* Additional instructions for chrome tools when tool search is enabled.
* These instruct the model to load chrome tools via ToolSearch before using them.
* Only injected when tool search is actually enabled (not just optimistically possible).
*/
export const CHROME_TOOL_SEARCH_INSTRUCTIONS = `**IMPORTANT: Before using any chrome browser tools, you MUST first load them using ToolSearch.**
Chrome browser tools are MCP tools that require loading before use. Before calling any mcp__claude-in-chrome__* tool:
1. Use ToolSearch with \`select:mcp__claude-in-chrome__<tool_name>\` to load the specific tool
2. Then call the tool
For example, to get tab context:
1. First: ToolSearch with query "select:mcp__claude-in-chrome__tabs_context_mcp"
2. Then: Call mcp__claude-in-chrome__tabs_context_mcp`
/**
* Get the base chrome system prompt (without tool search instructions).
* Tool search instructions are injected separately at request time in claude.ts
* based on the actual tool search enabled state.
*/
export function getChromeSystemPrompt(): string {
return BASE_CHROME_PROMPT
}
/**
* Minimal hint about Claude in Chrome skill availability. This is injected at startup when the extension is installed
* to guide the model to invoke the skill before using the MCP tools.
*/
export const CLAUDE_IN_CHROME_SKILL_HINT = `**Browser Automation**: Chrome browser tools are available via the "claude-in-chrome" skill. CRITICAL: Before using any mcp__claude-in-chrome__* tools, invoke the skill by calling the Skill tool with skill: "claude-in-chrome". The skill provides browser automation instructions and enables the tools.`
/**
* Variant when the built-in WebBrowser tool is also available — steer
* dev-loop tasks to WebBrowser and reserve the extension for the user's
* authenticated Chrome (logged-in sites, OAuth, computer-use).
*/
export const CLAUDE_IN_CHROME_SKILL_HINT_WITH_WEBBROWSER = `**Browser Automation**: Use WebBrowser for development (dev servers, JS eval, console, screenshots). Use claude-in-chrome for the user's real Chrome when you need logged-in sessions, OAuth, or computer-use — invoke Skill(skill: "claude-in-chrome") before any mcp__claude-in-chrome__* tool.`
+400
View File
@@ -0,0 +1,400 @@
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)
}
+233
View File
@@ -0,0 +1,233 @@
import { readdir } from 'fs/promises'
import { homedir } from 'os'
import { join } from 'path'
import { isFsInaccessible } from '../errors.js'
export const CHROME_EXTENSION_URL = 'https://claude.ai/chrome'
// Production extension ID
const PROD_EXTENSION_ID = 'fcoeoabgfenejglbffodgkkbkcdhcgfn'
// Dev extension IDs (for internal use)
const DEV_EXTENSION_ID = 'dihbgbndebgnbjfmelmegjepbnkhlgni'
const ANT_EXTENSION_ID = 'dngcpimnedloihjnnfngkgjoidhnaolf'
function getExtensionIds(): string[] {
return process.env.USER_TYPE === 'ant'
? [PROD_EXTENSION_ID, DEV_EXTENSION_ID, ANT_EXTENSION_ID]
: [PROD_EXTENSION_ID]
}
// Must match ChromiumBrowser from common.ts
export type ChromiumBrowser =
| 'chrome'
| 'brave'
| 'arc'
| 'chromium'
| 'edge'
| 'vivaldi'
| 'opera'
export type BrowserPath = {
browser: ChromiumBrowser
path: string
}
type Logger = (message: string) => void
// Browser detection order - must match BROWSER_DETECTION_ORDER from common.ts
const BROWSER_DETECTION_ORDER: ChromiumBrowser[] = [
'chrome',
'brave',
'arc',
'edge',
'chromium',
'vivaldi',
'opera',
]
type BrowserDataConfig = {
macos: string[]
linux: string[]
windows: { path: string[]; useRoaming?: boolean }
}
// Must match CHROMIUM_BROWSERS dataPath from common.ts
const CHROMIUM_BROWSERS: Record<ChromiumBrowser, BrowserDataConfig> = {
chrome: {
macos: ['Library', 'Application Support', 'Google', 'Chrome'],
linux: ['.config', 'google-chrome'],
windows: { path: ['Google', 'Chrome', 'User Data'] },
},
brave: {
macos: ['Library', 'Application Support', 'BraveSoftware', 'Brave-Browser'],
linux: ['.config', 'BraveSoftware', 'Brave-Browser'],
windows: { path: ['BraveSoftware', 'Brave-Browser', 'User Data'] },
},
arc: {
macos: ['Library', 'Application Support', 'Arc', 'User Data'],
linux: [],
windows: { path: ['Arc', 'User Data'] },
},
chromium: {
macos: ['Library', 'Application Support', 'Chromium'],
linux: ['.config', 'chromium'],
windows: { path: ['Chromium', 'User Data'] },
},
edge: {
macos: ['Library', 'Application Support', 'Microsoft Edge'],
linux: ['.config', 'microsoft-edge'],
windows: { path: ['Microsoft', 'Edge', 'User Data'] },
},
vivaldi: {
macos: ['Library', 'Application Support', 'Vivaldi'],
linux: ['.config', 'vivaldi'],
windows: { path: ['Vivaldi', 'User Data'] },
},
opera: {
macos: ['Library', 'Application Support', 'com.operasoftware.Opera'],
linux: ['.config', 'opera'],
windows: { path: ['Opera Software', 'Opera Stable'], useRoaming: true },
},
}
/**
* Get all browser data paths to check for extension installation.
* Portable version that uses process.platform directly.
*/
export function getAllBrowserDataPathsPortable(): BrowserPath[] {
const home = homedir()
const paths: BrowserPath[] = []
for (const browserId of BROWSER_DETECTION_ORDER) {
const config = CHROMIUM_BROWSERS[browserId]
let dataPath: string[] | undefined
switch (process.platform) {
case 'darwin':
dataPath = config.macos
break
case 'linux':
dataPath = config.linux
break
case 'win32': {
if (config.windows.path.length > 0) {
const appDataBase = config.windows.useRoaming
? join(home, 'AppData', 'Roaming')
: join(home, 'AppData', 'Local')
paths.push({
browser: browserId,
path: join(appDataBase, ...config.windows.path),
})
}
continue
}
}
if (dataPath && dataPath.length > 0) {
paths.push({
browser: browserId,
path: join(home, ...dataPath),
})
}
}
return paths
}
/**
* Detects if the Claude in Chrome extension is installed by checking the Extensions
* directory across all supported Chromium-based browsers and their profiles.
*
* This is a portable version that can be used by both TUI and VS Code extension.
*
* @param browserPaths - Array of browser data paths to check (from getAllBrowserDataPaths)
* @param log - Optional logging callback for debug messages
* @returns Object with isInstalled boolean and the browser where the extension was found
*/
export async function detectExtensionInstallationPortable(
browserPaths: BrowserPath[],
log?: Logger,
): Promise<{
isInstalled: boolean
browser: ChromiumBrowser | null
}> {
if (browserPaths.length === 0) {
log?.(`[Claude in Chrome] No browser paths to check`)
return { isInstalled: false, browser: null }
}
const extensionIds = getExtensionIds()
// Check each browser for the extension
for (const { browser, path: browserBasePath } of browserPaths) {
let browserProfileEntries = []
try {
browserProfileEntries = await readdir(browserBasePath, {
withFileTypes: true,
})
} catch (e) {
// Browser not installed or path doesn't exist, continue to next browser
if (isFsInaccessible(e)) continue
throw e
}
const profileDirs = browserProfileEntries
.filter(entry => entry.isDirectory())
.filter(
entry => entry.name === 'Default' || entry.name.startsWith('Profile '),
)
.map(entry => entry.name)
if (profileDirs.length > 0) {
log?.(
`[Claude in Chrome] Found ${browser} profiles: ${profileDirs.join(', ')}`,
)
}
// Check each profile for any of the extension IDs
for (const profile of profileDirs) {
for (const extensionId of extensionIds) {
const extensionPath = join(
browserBasePath,
profile,
'Extensions',
extensionId,
)
try {
await readdir(extensionPath)
log?.(
`[Claude in Chrome] Extension ${extensionId} found in ${browser} ${profile}`,
)
return { isInstalled: true, browser }
} catch {
// Extension not found in this profile, continue checking
}
}
}
}
log?.(`[Claude in Chrome] Extension not found in any browser`)
return { isInstalled: false, browser: null }
}
/**
* Simple wrapper that returns just the boolean result
*/
export async function isChromeExtensionInstalledPortable(
browserPaths: BrowserPath[],
log?: Logger,
): Promise<boolean> {
const result = await detectExtensionInstallationPortable(browserPaths, log)
return result.isInstalled
}
/**
* Convenience function that gets browser paths automatically.
* Use this when you don't need to provide custom browser paths.
*/
export function isChromeExtensionInstalled(log?: Logger): Promise<boolean> {
const browserPaths = getAllBrowserDataPathsPortable()
return isChromeExtensionInstalledPortable(browserPaths, log)
}
File diff suppressed because one or more lines are too long