init claude-code
This commit is contained in:
@@ -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()
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
@@ -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' })
|
||||
}
|
||||
}
|
||||
@@ -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.`
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user