mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-17 13:55:50 +00:00
1120 lines
36 KiB
TypeScript
1120 lines
36 KiB
TypeScript
/**
|
|
* WebSocket bridge client for the Chrome extension MCP server.
|
|
* Communicates with the Chrome extension via the office bridge server's /chrome path.
|
|
*/
|
|
|
|
import WebSocket from 'ws'
|
|
|
|
import { SocketConnectionError } from './mcpSocketClient.js'
|
|
import {
|
|
localPlatformLabel,
|
|
type BridgePermissionRequest,
|
|
type ChromeExtensionInfo,
|
|
type ClaudeForChromeContext,
|
|
type PermissionMode,
|
|
type PermissionOverrides,
|
|
type SocketClient,
|
|
} from './types.js'
|
|
|
|
/** Timeout for list_extensions response from the bridge. */
|
|
const DISCOVERY_TIMEOUT_MS = 5000
|
|
|
|
/** How long to wait for a peer_connected event when 0 extensions are found. */
|
|
const PEER_WAIT_TIMEOUT_MS = 10_000
|
|
|
|
interface PendingToolCall {
|
|
resolve: (value: unknown) => void
|
|
reject: (reason: Error) => void
|
|
timer: NodeJS.Timeout
|
|
results: unknown[]
|
|
isTabsContext: boolean
|
|
onPermissionRequest?: (request: BridgePermissionRequest) => Promise<boolean>
|
|
startTime: number
|
|
toolName: string
|
|
}
|
|
|
|
export class BridgeClient implements SocketClient {
|
|
private ws: WebSocket | null = null
|
|
private connected = false
|
|
private authenticated = false
|
|
private connecting = false
|
|
private reconnectTimer: NodeJS.Timeout | null = null
|
|
private reconnectAttempts = 0
|
|
private pendingCalls = new Map<string, PendingToolCall>()
|
|
private notificationHandler:
|
|
| ((notification: {
|
|
method: string
|
|
params?: Record<string, unknown>
|
|
}) => void)
|
|
| null = null
|
|
private context: ClaudeForChromeContext
|
|
private permissionMode: PermissionMode = 'ask'
|
|
private allowedDomains: string[] | undefined
|
|
private tabsContextCollectionTimeoutMs = 2000
|
|
private toolCallTimeoutMs = 120_000
|
|
private connectionStartTime: number | null = null
|
|
private connectionEstablishedTime: number | null = null
|
|
/** The device_id of the selected Chrome extension for targeted routing. */
|
|
private selectedDeviceId: string | undefined
|
|
/** True after first discovery attempt completes (success or timeout). */
|
|
private discoveryComplete = false
|
|
/** Shared promise so concurrent callTool invocations join the same discovery. */
|
|
private discoveryPromise: Promise<void> | null = null
|
|
/** Pending discovery response from bridge. */
|
|
private pendingDiscovery: {
|
|
resolve: (extensions: ChromeExtensionInfo[]) => void
|
|
timeout: NodeJS.Timeout
|
|
} | null = null
|
|
/** The device_id we had selected before a peer_disconnected — for auto-reselect. */
|
|
private previousSelectedDeviceId: string | undefined
|
|
/** Callbacks waiting for the next peer_connected event. Receives `true` on peer arrival, `false` on abort. */
|
|
private peerConnectedWaiters: Array<(arrived: boolean) => void> = []
|
|
/** The request_id of the current pending pairing broadcast. */
|
|
private pendingPairingRequestId: string | undefined
|
|
/** Whether a pairing broadcast is in progress (multiple extensions, no persisted selection). */
|
|
// biome-ignore lint/correctness/noUnusedPrivateClassMembers: state flag — written in multiple places, read planned for future routing logic
|
|
private pairingInProgress = false
|
|
/** The deviceId from a previous persisted pairing. */
|
|
private persistedDeviceId: string | undefined
|
|
/** Resolve callback for a blocking switchBrowser() call. */
|
|
private pendingSwitchResolve:
|
|
| ((result: { deviceId: string; name: string } | null) => void)
|
|
| null = null
|
|
|
|
constructor(context: ClaudeForChromeContext) {
|
|
this.context = context
|
|
if (context.initialPermissionMode) {
|
|
this.permissionMode = context.initialPermissionMode
|
|
}
|
|
}
|
|
|
|
public async ensureConnected(): Promise<boolean> {
|
|
const { logger, serverName } = this.context
|
|
logger.info(
|
|
`[${serverName}] ensureConnected called, connected=${this.connected}, authenticated=${this.authenticated}, wsState=${this.ws?.readyState}`,
|
|
)
|
|
|
|
if (
|
|
this.connected &&
|
|
this.authenticated &&
|
|
this.ws?.readyState === WebSocket.OPEN
|
|
) {
|
|
logger.info(`[${serverName}] Already connected and authenticated`)
|
|
return true
|
|
}
|
|
|
|
if (!this.connecting) {
|
|
logger.info(`[${serverName}] Not connecting, starting connection...`)
|
|
await this.connect()
|
|
} else {
|
|
logger.info(`[${serverName}] Already connecting, waiting...`)
|
|
}
|
|
|
|
// Wait for authentication with timeout
|
|
return new Promise(resolve => {
|
|
const timeout = setTimeout(() => {
|
|
logger.info(
|
|
`[${serverName}] Connection timeout, connected=${this.connected}, authenticated=${this.authenticated}`,
|
|
)
|
|
resolve(false)
|
|
}, 10_000)
|
|
const check = () => {
|
|
if (this.connected && this.authenticated) {
|
|
logger.info(`[${serverName}] Connection successful`)
|
|
clearTimeout(timeout)
|
|
resolve(true)
|
|
} else if (!this.connecting) {
|
|
logger.info(`[${serverName}] No longer connecting, giving up`)
|
|
clearTimeout(timeout)
|
|
resolve(false)
|
|
} else {
|
|
setTimeout(check, 200)
|
|
}
|
|
}
|
|
check()
|
|
})
|
|
}
|
|
|
|
public async callTool(
|
|
name: string,
|
|
args: Record<string, unknown>,
|
|
permissionOverrides?: PermissionOverrides,
|
|
): Promise<unknown> {
|
|
const { logger, serverName, trackEvent } = this.context
|
|
|
|
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
throw new SocketConnectionError(`[${serverName}] Bridge not connected`)
|
|
}
|
|
|
|
// Lazy discovery: run on first tool call if no extension selected yet.
|
|
// Use a shared promise so concurrent callers join the same discovery.
|
|
if (!this.selectedDeviceId && !this.discoveryComplete) {
|
|
this.discoveryPromise ??= this.discoverAndSelectExtension().finally(
|
|
() => {
|
|
this.discoveryPromise = null
|
|
},
|
|
)
|
|
await this.discoveryPromise
|
|
}
|
|
|
|
// TODO: Once all extensions support pairing, throw here for multi-extension
|
|
// cases where pairingInProgress is true. For now, let the bridge handle
|
|
// routing — it auto-routes to a single extension or returns an error for
|
|
// multiple extensions without a target_device_id.
|
|
|
|
const toolUseId = crypto.randomUUID()
|
|
const isTabsContext = name === 'tabs_context_mcp'
|
|
const startTime = Date.now()
|
|
const timeoutMs = isTabsContext
|
|
? this.tabsContextCollectionTimeoutMs
|
|
: this.toolCallTimeoutMs
|
|
|
|
// Track tool call start
|
|
trackEvent?.('chrome_bridge_tool_call_started', {
|
|
tool_name: name,
|
|
tool_use_id: toolUseId,
|
|
})
|
|
|
|
// Per-call overrides (from session context) take priority over
|
|
// instance values (from set_permission_mode on the singleton).
|
|
const effectivePermissionMode =
|
|
permissionOverrides?.permissionMode ?? this.permissionMode
|
|
const effectiveAllowedDomains =
|
|
permissionOverrides?.allowedDomains ?? this.allowedDomains
|
|
|
|
return new Promise((resolve, reject) => {
|
|
const timer = setTimeout(() => {
|
|
const pending = this.pendingCalls.get(toolUseId)
|
|
if (pending) {
|
|
this.pendingCalls.delete(toolUseId)
|
|
const durationMs = Date.now() - pending.startTime
|
|
|
|
if (isTabsContext && pending.results.length > 0) {
|
|
// For tabs_context, resolve with collected results even on timeout
|
|
trackEvent?.('chrome_bridge_tool_call_completed', {
|
|
tool_name: name,
|
|
tool_use_id: toolUseId,
|
|
duration_ms: durationMs,
|
|
})
|
|
resolve(this.mergeTabsResults(pending.results))
|
|
} else {
|
|
logger.warn(
|
|
`[${serverName}] Tool call timeout: ${name} (${toolUseId.slice(0, 8)}) after ${durationMs}ms, pending calls: ${this.pendingCalls.size}`,
|
|
)
|
|
trackEvent?.('chrome_bridge_tool_call_timeout', {
|
|
tool_name: name,
|
|
tool_use_id: toolUseId,
|
|
duration_ms: durationMs,
|
|
timeout_ms: timeoutMs,
|
|
})
|
|
reject(
|
|
new SocketConnectionError(
|
|
`[${serverName}] Tool call timed out: ${name}`,
|
|
),
|
|
)
|
|
}
|
|
}
|
|
}, timeoutMs)
|
|
|
|
this.pendingCalls.set(toolUseId, {
|
|
resolve,
|
|
reject,
|
|
timer,
|
|
results: [],
|
|
isTabsContext,
|
|
onPermissionRequest: permissionOverrides?.onPermissionRequest,
|
|
startTime,
|
|
toolName: name,
|
|
})
|
|
|
|
const message: Record<string, unknown> = {
|
|
type: 'tool_call',
|
|
tool_use_id: toolUseId,
|
|
client_type: this.context.clientTypeId,
|
|
tool: name,
|
|
args,
|
|
}
|
|
|
|
// Target the selected extension for routing
|
|
if (this.selectedDeviceId) {
|
|
message.target_device_id = this.selectedDeviceId
|
|
}
|
|
|
|
// Only include permission fields when a value exists.
|
|
// Priority: per-call override (from session context) > instance value (from set_permission_mode).
|
|
if (effectivePermissionMode) {
|
|
message.permission_mode = effectivePermissionMode
|
|
}
|
|
if (effectiveAllowedDomains?.length) {
|
|
message.allowed_domains = effectiveAllowedDomains
|
|
}
|
|
if (permissionOverrides?.onPermissionRequest) {
|
|
message.handle_permission_prompts = true
|
|
}
|
|
|
|
logger.debug(
|
|
`[${serverName}] Sending tool_call: ${name} (${toolUseId.slice(0, 8)})`,
|
|
)
|
|
this.ws!.send(JSON.stringify(message))
|
|
})
|
|
}
|
|
|
|
public isConnected(): boolean {
|
|
return (
|
|
this.connected &&
|
|
this.authenticated &&
|
|
this.ws?.readyState === WebSocket.OPEN
|
|
)
|
|
}
|
|
|
|
public disconnect(): void {
|
|
this.cleanup()
|
|
}
|
|
|
|
public setNotificationHandler(
|
|
handler: (notification: {
|
|
method: string
|
|
params?: Record<string, unknown>
|
|
}) => void,
|
|
): void {
|
|
this.notificationHandler = handler
|
|
}
|
|
|
|
public async setPermissionMode(
|
|
mode: PermissionMode,
|
|
allowedDomains?: string[],
|
|
): Promise<void> {
|
|
this.permissionMode = mode
|
|
this.allowedDomains = allowedDomains
|
|
}
|
|
|
|
// ===========================================================================
|
|
// Extension discovery and selection
|
|
// ===========================================================================
|
|
|
|
/**
|
|
* Discover connected extensions and auto-select one, or broadcast a pairing request.
|
|
* Called lazily on the first tool call.
|
|
*/
|
|
private async discoverAndSelectExtension(): Promise<void> {
|
|
const { logger, serverName } = this.context
|
|
|
|
this.persistedDeviceId ??= this.context.getPersistedDeviceId?.()
|
|
|
|
let extensions = await this.queryBridgeExtensions()
|
|
|
|
if (extensions.length === 0) {
|
|
logger.info(
|
|
`[${serverName}] No extensions connected, waiting up to ${PEER_WAIT_TIMEOUT_MS}ms for peer_connected`,
|
|
)
|
|
const peerArrived = await this.waitForPeerConnected(PEER_WAIT_TIMEOUT_MS)
|
|
if (peerArrived) {
|
|
extensions = await this.queryBridgeExtensions()
|
|
}
|
|
}
|
|
|
|
this.discoveryComplete = true
|
|
|
|
if (extensions.length === 0) {
|
|
// Still nothing — callTool will throw a clear error
|
|
logger.info(`[${serverName}] No extensions found after waiting`)
|
|
return
|
|
}
|
|
|
|
// Single extension: auto-select silently
|
|
if (extensions.length === 1) {
|
|
const ext = extensions[0]!
|
|
if (!this.isLocalExtension(ext)) {
|
|
this.context.onRemoteExtensionWarning?.(ext)
|
|
}
|
|
this.selectExtension(ext.deviceId)
|
|
return
|
|
}
|
|
|
|
// Multiple extensions: check for persisted selection
|
|
if (this.persistedDeviceId) {
|
|
const persisted = extensions.find(
|
|
e => e.deviceId === this.persistedDeviceId,
|
|
)
|
|
if (persisted) {
|
|
logger.info(
|
|
`[${serverName}] Auto-connecting to persisted extension: ${persisted.name || persisted.deviceId.slice(0, 8)}`,
|
|
)
|
|
this.selectExtension(persisted.deviceId)
|
|
return
|
|
}
|
|
}
|
|
|
|
// Multiple extensions, no valid persisted selection: broadcast and fail fast
|
|
this.broadcastPairingRequest()
|
|
this.pairingInProgress = true
|
|
}
|
|
|
|
/**
|
|
* Query the bridge for connected extensions. Returns empty array on timeout.
|
|
* Deduplicates by deviceId, keeping the most recent connection — the bridge
|
|
* may report stale duplicates (e.g. after a service worker restart).
|
|
*/
|
|
private async queryBridgeExtensions(): Promise<ChromeExtensionInfo[]> {
|
|
const raw: ChromeExtensionInfo[] = await new Promise(resolve => {
|
|
const timeout = setTimeout(() => {
|
|
this.pendingDiscovery = null
|
|
resolve([])
|
|
}, DISCOVERY_TIMEOUT_MS)
|
|
|
|
this.pendingDiscovery = { resolve, timeout }
|
|
this.ws?.send(JSON.stringify({ type: 'list_extensions' }))
|
|
})
|
|
|
|
const byDeviceId = new Map<string, ChromeExtensionInfo>()
|
|
for (const ext of raw) {
|
|
const existing = byDeviceId.get(ext.deviceId)
|
|
if (!existing || ext.connectedAt > existing.connectedAt) {
|
|
byDeviceId.set(ext.deviceId, ext)
|
|
}
|
|
}
|
|
return [...byDeviceId.values()]
|
|
}
|
|
|
|
/**
|
|
* Select an extension by device ID for per-message targeted routing.
|
|
*/
|
|
private selectExtension(deviceId: string): void {
|
|
const { logger, serverName } = this.context
|
|
this.selectedDeviceId = deviceId
|
|
this.previousSelectedDeviceId = undefined
|
|
logger.info(
|
|
`[${serverName}] Selected Chrome extension: ${deviceId.slice(0, 8)}...`,
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Check if an extension might be on the same machine as this MCP client
|
|
* by comparing OS platform. Extensions can't provide a real hostname from
|
|
* the service worker sandbox, so platform is a weak heuristic. The profile
|
|
* email is the primary differentiator shown in the selection dialog.
|
|
*/
|
|
private isLocalExtension(ext: ChromeExtensionInfo): boolean {
|
|
if (!ext.osPlatform) return false
|
|
return ext.osPlatform === localPlatformLabel()
|
|
}
|
|
|
|
/**
|
|
* Returns a promise that resolves to `true` when a peer_connected event
|
|
* fires, or `false` if the timeout elapses first.
|
|
*/
|
|
private waitForPeerConnected(timeoutMs: number): Promise<boolean> {
|
|
return new Promise(resolve => {
|
|
const timer = setTimeout(() => {
|
|
this.peerConnectedWaiters = this.peerConnectedWaiters.filter(
|
|
w => w !== onPeer,
|
|
)
|
|
resolve(false)
|
|
}, timeoutMs)
|
|
|
|
const onPeer = (arrived: boolean) => {
|
|
clearTimeout(timer)
|
|
resolve(arrived)
|
|
}
|
|
|
|
this.peerConnectedWaiters.push(onPeer)
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Broadcast a pairing request to all connected extensions.
|
|
* Non-blocking — the pairing_response handler will select the extension.
|
|
*/
|
|
private broadcastPairingRequest(): void {
|
|
const requestId = crypto.randomUUID()
|
|
this.pendingPairingRequestId = requestId
|
|
this.ws?.send(
|
|
JSON.stringify({
|
|
type: 'pairing_request',
|
|
request_id: requestId,
|
|
client_type: this.context.clientTypeId,
|
|
}),
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Switch to a different browser. Broadcasts a pairing request and blocks
|
|
* until a response arrives or timeout (120s). Returns the paired extension
|
|
* info, or null on timeout.
|
|
*/
|
|
public async switchBrowser(): Promise<
|
|
| {
|
|
deviceId: string
|
|
name: string
|
|
}
|
|
| 'no_other_browsers'
|
|
| null
|
|
> {
|
|
const extensions = await this.queryBridgeExtensions()
|
|
const currentDeviceId =
|
|
this.selectedDeviceId ?? this.previousSelectedDeviceId
|
|
if (
|
|
extensions.length === 0 ||
|
|
(extensions.length === 1 &&
|
|
(!currentDeviceId || extensions[0]!.deviceId === currentDeviceId))
|
|
) {
|
|
return 'no_other_browsers'
|
|
}
|
|
|
|
this.previousSelectedDeviceId = this.selectedDeviceId
|
|
this.selectedDeviceId = undefined
|
|
this.discoveryComplete = false
|
|
this.pairingInProgress = false
|
|
|
|
const requestId = crypto.randomUUID()
|
|
this.pendingPairingRequestId = requestId
|
|
if (this.ws?.readyState !== WebSocket.OPEN) {
|
|
return null
|
|
}
|
|
this.ws.send(
|
|
JSON.stringify({
|
|
type: 'pairing_request',
|
|
request_id: requestId,
|
|
client_type: this.context.clientTypeId,
|
|
}),
|
|
)
|
|
|
|
// Resolve any previous pending switch so the caller doesn't hang forever
|
|
if (this.pendingSwitchResolve) {
|
|
this.pendingSwitchResolve(null)
|
|
}
|
|
|
|
// Block for switch_browser since user is actively engaged
|
|
return new Promise(resolve => {
|
|
const timer = setTimeout(() => {
|
|
if (this.pendingPairingRequestId === requestId) {
|
|
this.pendingPairingRequestId = undefined
|
|
}
|
|
this.pendingSwitchResolve = null
|
|
resolve(null)
|
|
}, 120_000)
|
|
|
|
this.pendingSwitchResolve = result => {
|
|
clearTimeout(timer)
|
|
this.pendingSwitchResolve = null
|
|
resolve(result)
|
|
}
|
|
})
|
|
}
|
|
|
|
private async connect(): Promise<void> {
|
|
const { logger, serverName, bridgeConfig, trackEvent } = this.context
|
|
|
|
if (!bridgeConfig) {
|
|
logger.error(`[${serverName}] No bridge config provided`)
|
|
return
|
|
}
|
|
|
|
if (this.connecting) {
|
|
return
|
|
}
|
|
|
|
this.connecting = true
|
|
this.authenticated = false
|
|
this.connectionStartTime = Date.now()
|
|
this.closeSocket()
|
|
|
|
// Get user ID for the connection path
|
|
let userId: string
|
|
let token: string | undefined
|
|
|
|
if (bridgeConfig.devUserId) {
|
|
userId = bridgeConfig.devUserId
|
|
logger.debug(`[${serverName}] Using dev user ID for bridge connection`)
|
|
} else {
|
|
logger.debug(`[${serverName}] Fetching user ID for bridge connection`)
|
|
const fetchedUserId = await bridgeConfig.getUserId()
|
|
if (!fetchedUserId) {
|
|
const durationMs = Date.now() - this.connectionStartTime
|
|
logger.error(
|
|
`[${serverName}] No user ID available after ${durationMs}ms`,
|
|
)
|
|
trackEvent?.('chrome_bridge_connection_failed', {
|
|
duration_ms: durationMs,
|
|
error_type: 'no_user_id',
|
|
reconnect_attempt: this.reconnectAttempts,
|
|
})
|
|
this.connecting = false
|
|
this.context.onAuthenticationError?.()
|
|
return
|
|
}
|
|
userId = fetchedUserId
|
|
|
|
logger.debug(`[${serverName}] Fetching OAuth token for bridge connection`)
|
|
token = await bridgeConfig.getOAuthToken()
|
|
if (!token) {
|
|
const durationMs = Date.now() - this.connectionStartTime
|
|
logger.error(
|
|
`[${serverName}] No OAuth token available after ${durationMs}ms`,
|
|
)
|
|
trackEvent?.('chrome_bridge_connection_failed', {
|
|
duration_ms: durationMs,
|
|
error_type: 'no_oauth_token',
|
|
reconnect_attempt: this.reconnectAttempts,
|
|
})
|
|
this.connecting = false
|
|
this.context.onAuthenticationError?.()
|
|
return
|
|
}
|
|
}
|
|
|
|
// Connect to user-specific endpoint: /chrome/<user_id>
|
|
const wsUrl = `${bridgeConfig.url}/chrome/${userId}`
|
|
logger.info(`[${serverName}] Connecting to bridge: ${wsUrl}`)
|
|
|
|
// Track connection started
|
|
trackEvent?.('chrome_bridge_connection_started', {
|
|
bridge_url: wsUrl,
|
|
})
|
|
|
|
try {
|
|
this.ws = new WebSocket(wsUrl)
|
|
} catch (error) {
|
|
const durationMs = Date.now() - this.connectionStartTime
|
|
logger.error(
|
|
`[${serverName}] Failed to create WebSocket after ${durationMs}ms:`,
|
|
error,
|
|
)
|
|
trackEvent?.('chrome_bridge_connection_failed', {
|
|
duration_ms: durationMs,
|
|
error_type: 'websocket_error',
|
|
reconnect_attempt: this.reconnectAttempts,
|
|
})
|
|
this.connecting = false
|
|
this.scheduleReconnect()
|
|
return
|
|
}
|
|
|
|
this.ws.on('open', () => {
|
|
logger.info(
|
|
`[${serverName}] WebSocket connected, sending connect message`,
|
|
)
|
|
|
|
// First message must be connect (same format as office path)
|
|
const connectMessage: Record<string, unknown> = {
|
|
type: 'connect',
|
|
client_type: this.context.clientTypeId,
|
|
}
|
|
|
|
if (bridgeConfig.devUserId) {
|
|
connectMessage.dev_user_id = bridgeConfig.devUserId
|
|
} else {
|
|
connectMessage.oauth_token = token
|
|
}
|
|
|
|
this.ws?.send(JSON.stringify(connectMessage))
|
|
})
|
|
|
|
this.ws.on('message', (data: WebSocket.Data) => {
|
|
try {
|
|
const message = JSON.parse(data.toString()) as Record<string, unknown>
|
|
logger.debug(
|
|
`[${serverName}] Bridge received: ${JSON.stringify(message)}`,
|
|
)
|
|
this.handleMessage(message)
|
|
} catch (error) {
|
|
logger.error(`[${serverName}] Failed to parse bridge message:`, error)
|
|
}
|
|
})
|
|
|
|
this.ws.on('close', (code: number) => {
|
|
const durationSinceConnect = this.connectionEstablishedTime
|
|
? Date.now() - this.connectionEstablishedTime
|
|
: 0
|
|
logger.info(
|
|
`[${serverName}] Bridge connection closed (code: ${code}, duration: ${durationSinceConnect}ms)`,
|
|
)
|
|
trackEvent?.('chrome_bridge_disconnected', {
|
|
close_code: code,
|
|
duration_since_connect_ms: durationSinceConnect,
|
|
reconnect_attempt: this.reconnectAttempts + 1,
|
|
})
|
|
this.connected = false
|
|
this.authenticated = false
|
|
this.connecting = false
|
|
this.connectionEstablishedTime = null
|
|
this.scheduleReconnect()
|
|
})
|
|
|
|
this.ws.on('error', (error: Error) => {
|
|
const durationMs = this.connectionStartTime
|
|
? Date.now() - this.connectionStartTime
|
|
: 0
|
|
logger.error(
|
|
`[${serverName}] Bridge WebSocket error after ${durationMs}ms: ${error.message}`,
|
|
)
|
|
trackEvent?.('chrome_bridge_connection_failed', {
|
|
duration_ms: durationMs,
|
|
error_type: 'websocket_error',
|
|
reconnect_attempt: this.reconnectAttempts,
|
|
})
|
|
this.connected = false
|
|
this.authenticated = false
|
|
this.connecting = false
|
|
})
|
|
}
|
|
|
|
private handleMessage(message: Record<string, unknown>): void {
|
|
const { logger, serverName, trackEvent } = this.context
|
|
|
|
switch (message.type) {
|
|
case 'paired': {
|
|
const durationMs = this.connectionStartTime
|
|
? Date.now() - this.connectionStartTime
|
|
: 0
|
|
logger.info(
|
|
`[${serverName}] Paired with Chrome extension (duration: ${durationMs}ms)`,
|
|
)
|
|
this.connected = true
|
|
this.authenticated = true
|
|
this.connecting = false
|
|
this.reconnectAttempts = 0
|
|
this.connectionEstablishedTime = Date.now()
|
|
trackEvent?.('chrome_bridge_connection_succeeded', {
|
|
duration_ms: durationMs,
|
|
status: 'paired',
|
|
})
|
|
break
|
|
}
|
|
|
|
case 'waiting': {
|
|
const durationMs = this.connectionStartTime
|
|
? Date.now() - this.connectionStartTime
|
|
: 0
|
|
logger.info(
|
|
`[${serverName}] Waiting for Chrome extension to connect (duration: ${durationMs}ms)`,
|
|
)
|
|
this.connected = true
|
|
this.authenticated = true
|
|
this.connecting = false
|
|
this.reconnectAttempts = 0
|
|
this.connectionEstablishedTime = Date.now()
|
|
trackEvent?.('chrome_bridge_connection_succeeded', {
|
|
duration_ms: durationMs,
|
|
status: 'waiting',
|
|
})
|
|
break
|
|
}
|
|
|
|
case 'peer_connected':
|
|
logger.info(`[${serverName}] Chrome extension connected to bridge`)
|
|
trackEvent?.('chrome_bridge_peer_connected', null)
|
|
// If no extension selected, mark discovery as needed (next tool call will discover)
|
|
if (!this.selectedDeviceId) {
|
|
this.discoveryComplete = false
|
|
}
|
|
// Auto-reselect if the previously selected extension reconnected (e.g., service worker restart)
|
|
if (
|
|
this.previousSelectedDeviceId &&
|
|
message.deviceId === this.previousSelectedDeviceId &&
|
|
!this.pendingSwitchResolve
|
|
) {
|
|
logger.info(
|
|
`[${serverName}] Previously selected extension reconnected, auto-reselecting`,
|
|
)
|
|
this.selectExtension(this.previousSelectedDeviceId)
|
|
this.previousSelectedDeviceId = undefined
|
|
}
|
|
if (this.peerConnectedWaiters.length > 0) {
|
|
const waiters = this.peerConnectedWaiters
|
|
this.peerConnectedWaiters = []
|
|
for (const waiter of waiters) {
|
|
waiter(true)
|
|
}
|
|
}
|
|
break
|
|
|
|
case 'peer_disconnected':
|
|
logger.info(`[${serverName}] Chrome extension disconnected from bridge`)
|
|
trackEvent?.('chrome_bridge_peer_disconnected', null)
|
|
// If the selected extension disconnected, clear selection for re-discovery
|
|
if (message.deviceId && message.deviceId === this.selectedDeviceId) {
|
|
logger.info(
|
|
`[${serverName}] Selected extension disconnected, clearing selection`,
|
|
)
|
|
this.previousSelectedDeviceId = this.selectedDeviceId
|
|
this.selectedDeviceId = undefined
|
|
this.discoveryComplete = false
|
|
}
|
|
break
|
|
|
|
case 'extensions_list':
|
|
// Response to list_extensions — resolve pending discovery
|
|
if (this.pendingDiscovery) {
|
|
clearTimeout(this.pendingDiscovery.timeout)
|
|
this.pendingDiscovery.resolve(
|
|
(message.extensions as ChromeExtensionInfo[]) ?? [],
|
|
)
|
|
this.pendingDiscovery = null
|
|
}
|
|
break
|
|
|
|
case 'pairing_response': {
|
|
const requestId = message.request_id as string
|
|
const responseDeviceId = message.device_id as string
|
|
const responseName = message.name as string
|
|
if (
|
|
this.pendingPairingRequestId === requestId &&
|
|
responseDeviceId &&
|
|
responseName
|
|
) {
|
|
this.pendingPairingRequestId = undefined
|
|
this.pairingInProgress = false
|
|
this.selectExtension(responseDeviceId)
|
|
this.context.onExtensionPaired?.(responseDeviceId, responseName)
|
|
logger.info(
|
|
`[${serverName}] Paired with "${responseName}" (${responseDeviceId.slice(0, 8)})`,
|
|
)
|
|
if (this.pendingSwitchResolve) {
|
|
this.pendingSwitchResolve({
|
|
deviceId: responseDeviceId,
|
|
name: responseName,
|
|
})
|
|
this.pendingSwitchResolve = null
|
|
}
|
|
}
|
|
break
|
|
}
|
|
|
|
case 'ping':
|
|
this.ws?.send(JSON.stringify({ type: 'pong' }))
|
|
break
|
|
|
|
case 'pong':
|
|
// Response to our keepalive, nothing to do
|
|
break
|
|
|
|
case 'tool_result':
|
|
this.handleToolResult(message)
|
|
break
|
|
|
|
case 'permission_request':
|
|
void this.handlePermissionRequest(message)
|
|
break
|
|
|
|
case 'notification':
|
|
if (this.notificationHandler) {
|
|
this.notificationHandler({
|
|
method: message.method as string,
|
|
params: message.params as Record<string, unknown> | undefined,
|
|
})
|
|
}
|
|
break
|
|
|
|
case 'error':
|
|
logger.warn(`[${serverName}] Bridge error: ${message.error}`)
|
|
// If we had a selected extension, the error may indicate it's gone
|
|
// (e.g., extension disconnected between list and select). Clear state
|
|
// so the next tool call re-discovers.
|
|
if (this.selectedDeviceId) {
|
|
this.selectedDeviceId = undefined
|
|
this.discoveryComplete = false
|
|
}
|
|
break
|
|
|
|
default:
|
|
logger.warn(
|
|
`[${serverName}] Unrecognized bridge message type: ${message.type}`,
|
|
)
|
|
}
|
|
}
|
|
|
|
private async handlePermissionRequest(
|
|
message: Record<string, unknown>,
|
|
): Promise<void> {
|
|
const { logger, serverName } = this.context
|
|
const toolUseId = message.tool_use_id as string
|
|
const requestId = message.request_id as string
|
|
|
|
if (!toolUseId || !requestId) {
|
|
logger.warn(
|
|
`[${serverName}] permission_request missing tool_use_id or request_id`,
|
|
)
|
|
return
|
|
}
|
|
|
|
const pending = this.pendingCalls.get(toolUseId)
|
|
if (!pending?.onPermissionRequest) {
|
|
// Don't auto-deny — the bridge broadcasts permission_request to all
|
|
// connected MCP clients, and only the client that made the tool call
|
|
// has the pending entry. Auto-denying here would race with the correct
|
|
// client's handler when multiple Desktop instances are connected.
|
|
logger.debug(
|
|
`[${serverName}] Ignoring permission_request for unknown tool_use_id ${toolUseId.slice(0, 8)} (not our call)`,
|
|
)
|
|
return
|
|
}
|
|
|
|
const request: BridgePermissionRequest = {
|
|
toolUseId,
|
|
requestId,
|
|
toolType: (message.tool_type as string) ?? 'unknown',
|
|
url: (message.url as string) ?? '',
|
|
actionData: message.action_data as Record<string, unknown> | undefined,
|
|
}
|
|
|
|
try {
|
|
const allowed = await pending.onPermissionRequest(request)
|
|
this.sendPermissionResponse(requestId, allowed)
|
|
} catch (error) {
|
|
logger.error(`[${serverName}] Error handling permission request:`, error)
|
|
this.sendPermissionResponse(requestId, false)
|
|
}
|
|
}
|
|
|
|
private sendPermissionResponse(requestId: string, allowed: boolean): void {
|
|
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
const message: Record<string, unknown> = {
|
|
type: 'permission_response',
|
|
request_id: requestId,
|
|
allowed,
|
|
}
|
|
if (this.selectedDeviceId) {
|
|
message.target_device_id = this.selectedDeviceId
|
|
}
|
|
this.ws.send(JSON.stringify(message))
|
|
}
|
|
}
|
|
|
|
private handleToolResult(message: Record<string, unknown>): void {
|
|
const { logger, serverName, trackEvent } = this.context
|
|
const toolUseId = message.tool_use_id as string
|
|
if (!toolUseId) {
|
|
logger.warn(`[${serverName}] Received tool_result without tool_use_id`)
|
|
return
|
|
}
|
|
|
|
const pending = this.pendingCalls.get(toolUseId)
|
|
if (!pending) {
|
|
logger.debug(
|
|
`[${serverName}] Received tool_result for unknown call: ${toolUseId.slice(0, 8)}`,
|
|
)
|
|
return
|
|
}
|
|
|
|
const durationMs = Date.now() - pending.startTime
|
|
|
|
// Normalize bridge response format to match socket client format.
|
|
// Bridge sends: { type, tool_use_id, content: [...], is_error?: boolean }
|
|
// Socket sends: { result: { content: [...] } } or { error: { content: [...] } }
|
|
const normalized = this.normalizeBridgeResponse(message)
|
|
const isError = Boolean(message.is_error) || 'error' in normalized
|
|
|
|
if (pending.isTabsContext && !this.selectedDeviceId) {
|
|
// No extension selected: collect results from all extensions (pre-selection / backward compat)
|
|
pending.results.push(normalized)
|
|
// Don't resolve yet — let the timer handle collection
|
|
} else {
|
|
// For other tools, resolve on first result
|
|
clearTimeout(pending.timer)
|
|
this.pendingCalls.delete(toolUseId)
|
|
|
|
if (isError) {
|
|
// Extract error message for telemetry
|
|
const errorContent = (normalized as { error?: { content?: unknown[] } })
|
|
.error?.content
|
|
let errorMessage = 'Unknown error'
|
|
if (Array.isArray(errorContent)) {
|
|
const textItem = errorContent.find(
|
|
item => typeof item === 'object' && item !== null && 'text' in item,
|
|
) as { text?: string } | undefined
|
|
if (textItem?.text) {
|
|
errorMessage = textItem.text.slice(0, 200)
|
|
}
|
|
}
|
|
|
|
logger.warn(
|
|
`[${serverName}] Tool call error: ${pending.toolName} (${toolUseId.slice(0, 8)}) after ${durationMs}ms`,
|
|
)
|
|
trackEvent?.('chrome_bridge_tool_call_error', {
|
|
tool_name: pending.toolName,
|
|
tool_use_id: toolUseId,
|
|
duration_ms: durationMs,
|
|
error_message: errorMessage,
|
|
})
|
|
} else {
|
|
logger.debug(
|
|
`[${serverName}] Tool call completed: ${pending.toolName} (${toolUseId.slice(0, 8)}) in ${durationMs}ms`,
|
|
)
|
|
trackEvent?.('chrome_bridge_tool_call_completed', {
|
|
tool_name: pending.toolName,
|
|
tool_use_id: toolUseId,
|
|
duration_ms: durationMs,
|
|
})
|
|
}
|
|
|
|
pending.resolve(normalized)
|
|
}
|
|
}
|
|
|
|
private normalizeBridgeResponse(
|
|
message: Record<string, unknown>,
|
|
): Record<string, unknown> {
|
|
// Already has result/error wrapper (socket format) — pass through
|
|
if (message.result || message.error) {
|
|
return message
|
|
}
|
|
|
|
// Bridge format has content at top level — wrap it
|
|
if (message.content) {
|
|
if (message.is_error) {
|
|
return { error: { content: message.content } }
|
|
}
|
|
return { result: { content: message.content } }
|
|
}
|
|
|
|
return message
|
|
}
|
|
|
|
private mergeTabsResults(results: unknown[]): unknown {
|
|
const mergedTabs: unknown[] = []
|
|
|
|
for (const result of results) {
|
|
const msg = result as Record<string, unknown>
|
|
const resultData = msg.result as
|
|
| { content?: Array<{ type: string; text?: string }> }
|
|
| undefined
|
|
const content = resultData?.content
|
|
|
|
if (!content || !Array.isArray(content)) continue
|
|
|
|
for (const item of content) {
|
|
if (item.type === 'text' && item.text) {
|
|
try {
|
|
const parsed = JSON.parse(item.text)
|
|
if (Array.isArray(parsed)) {
|
|
mergedTabs.push(...parsed)
|
|
} else if (
|
|
parsed?.availableTabs &&
|
|
Array.isArray(parsed.availableTabs)
|
|
) {
|
|
mergedTabs.push(...parsed.availableTabs)
|
|
}
|
|
} catch {
|
|
// Not JSON, skip
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (mergedTabs.length > 0) {
|
|
const tabListText = mergedTabs
|
|
.map(t => {
|
|
const tab = t as { tabId: number; title: string; url: string }
|
|
return ` \u2022 tabId ${tab.tabId}: "${tab.title}" (${tab.url})`
|
|
})
|
|
.join('\n')
|
|
|
|
return {
|
|
result: {
|
|
content: [
|
|
{
|
|
type: 'text',
|
|
text: JSON.stringify({ availableTabs: mergedTabs }),
|
|
},
|
|
{
|
|
type: 'text',
|
|
text: `\n\nTab Context:\n- Available tabs:\n${tabListText}`,
|
|
},
|
|
],
|
|
},
|
|
}
|
|
}
|
|
|
|
// Return first result as fallback
|
|
return results[0]
|
|
}
|
|
|
|
private scheduleReconnect(): void {
|
|
const { logger, serverName, trackEvent } = this.context
|
|
|
|
if (this.reconnectTimer) return
|
|
|
|
this.reconnectAttempts++
|
|
|
|
if (this.reconnectAttempts > 100) {
|
|
logger.warn(
|
|
`[${serverName}] Giving up bridge reconnection after 100 attempts`,
|
|
)
|
|
trackEvent?.('chrome_bridge_reconnect_exhausted', {
|
|
total_attempts: 100,
|
|
})
|
|
this.reconnectAttempts = 0
|
|
return
|
|
}
|
|
|
|
const delay = Math.min(2000 * 1.5 ** (this.reconnectAttempts - 1), 30_000)
|
|
|
|
if (this.reconnectAttempts <= 10 || this.reconnectAttempts % 10 === 0) {
|
|
logger.info(
|
|
`[${serverName}] Bridge reconnecting in ${Math.round(delay)}ms (attempt ${this.reconnectAttempts})`,
|
|
)
|
|
}
|
|
|
|
this.reconnectTimer = setTimeout(() => {
|
|
this.reconnectTimer = null
|
|
void this.connect()
|
|
}, delay)
|
|
}
|
|
|
|
private closeSocket(): void {
|
|
if (this.ws) {
|
|
this.ws.removeAllListeners()
|
|
this.ws.close()
|
|
this.ws = null
|
|
}
|
|
this.connected = false
|
|
this.authenticated = false
|
|
// Clear extension selection state so reconnections start fresh
|
|
this.selectedDeviceId = undefined
|
|
this.discoveryComplete = false
|
|
this.pendingPairingRequestId = undefined
|
|
this.pairingInProgress = false
|
|
if (this.pendingSwitchResolve) {
|
|
this.pendingSwitchResolve(null)
|
|
this.pendingSwitchResolve = null
|
|
}
|
|
if (this.pendingDiscovery) {
|
|
clearTimeout(this.pendingDiscovery.timeout)
|
|
this.pendingDiscovery.resolve([])
|
|
this.pendingDiscovery = null
|
|
}
|
|
// Unblock any in-progress waitForPeerConnected so it doesn't hang until its timeout
|
|
if (this.peerConnectedWaiters.length > 0) {
|
|
const waiters = this.peerConnectedWaiters
|
|
this.peerConnectedWaiters = []
|
|
for (const waiter of waiters) {
|
|
waiter(false)
|
|
}
|
|
}
|
|
}
|
|
|
|
private cleanup(): void {
|
|
if (this.reconnectTimer) {
|
|
clearTimeout(this.reconnectTimer)
|
|
this.reconnectTimer = null
|
|
}
|
|
|
|
// Reject all pending calls
|
|
for (const [id, pending] of this.pendingCalls) {
|
|
clearTimeout(pending.timer)
|
|
pending.reject(new SocketConnectionError('Bridge client disconnected'))
|
|
this.pendingCalls.delete(id)
|
|
}
|
|
|
|
this.closeSocket()
|
|
this.reconnectAttempts = 0
|
|
}
|
|
}
|
|
|
|
export function createBridgeClient(
|
|
context: ClaudeForChromeContext,
|
|
): BridgeClient {
|
|
return new BridgeClient(context)
|
|
}
|