import { promises as fsPromises } from "fs"; import { createConnection } from "net"; import type { Socket } from "net"; import { platform } from "os"; import { dirname } from "path"; import type { ClaudeForChromeContext, PermissionMode, PermissionOverrides, } from "./types.js"; export class SocketConnectionError extends Error { constructor(message: string) { super(message); this.name = "SocketConnectionError"; } } interface ToolRequest { method: string; // "execute_tool" params?: { client_id?: string; // "desktop" | "claude-code" tool?: string; args?: Record; }; } interface ToolResponse { result?: unknown; error?: string; } interface Notification { method: string; params?: Record; } type SocketMessage = ToolResponse | Notification; function isToolResponse(message: SocketMessage): message is ToolResponse { return "result" in message || "error" in message; } function isNotification(message: SocketMessage): message is Notification { return "method" in message && typeof message.method === "string"; } class McpSocketClient { private socket: Socket | null = null; private connected = false; private connecting = false; private responseCallback: ((response: ToolResponse) => void) | null = null; private notificationHandler: ((notification: Notification) => void) | null = null; private responseBuffer = Buffer.alloc(0); private reconnectAttempts = 0; private maxReconnectAttempts = 10; private reconnectDelay = 1000; private reconnectTimer: NodeJS.Timeout | null = null; private context: ClaudeForChromeContext; // When true, disables automatic reconnection. Used by McpSocketPool which // manages reconnection externally by rescanning available sockets. public disableAutoReconnect = false; constructor(context: ClaudeForChromeContext) { this.context = context; } private async connect(): Promise { const { serverName, logger } = this.context; if (this.connecting) { logger.info( `[${serverName}] Already connecting, skipping duplicate attempt`, ); return; } this.closeSocket(); this.connecting = true; const socketPath = this.context.getSocketPath?.() ?? this.context.socketPath; logger.info(`[${serverName}] Attempting to connect to: ${socketPath}`); try { await this.validateSocketSecurity(socketPath); } catch (error) { this.connecting = false; logger.info(`[${serverName}] Security validation failed:`, error); // Don't retry on security failures (wrong perms/owner) - those won't // self-resolve. Only the error handler retries on transient errors. return; } this.socket = createConnection(socketPath); // Timeout the initial connection attempt - if socket file exists but native // host is dead, the connect can hang indefinitely const connectTimeout = setTimeout(() => { if (!this.connected) { logger.info( `[${serverName}] Connection attempt timed out after 5000ms`, ); this.closeSocket(); this.scheduleReconnect(); } }, 5000); this.socket.on("connect", () => { clearTimeout(connectTimeout); this.connected = true; this.connecting = false; this.reconnectAttempts = 0; logger.info(`[${serverName}] Successfully connected to bridge server`); }); this.socket.on("data", (data: Buffer) => { this.responseBuffer = Buffer.concat([this.responseBuffer, data]); while (this.responseBuffer.length >= 4) { const length = this.responseBuffer.readUInt32LE(0); if (this.responseBuffer.length < 4 + length) { break; } const messageBytes = this.responseBuffer.slice(4, 4 + length); this.responseBuffer = this.responseBuffer.slice(4 + length); try { const message = JSON.parse( messageBytes.toString("utf-8"), ) as SocketMessage; if (isNotification(message)) { logger.info( `[${serverName}] Received notification: ${message.method}`, ); if (this.notificationHandler) { this.notificationHandler(message); } } else if (isToolResponse(message)) { logger.info(`[${serverName}] Received tool response: ${message}`); this.handleResponse(message); } else { logger.info(`[${serverName}] Received unknown message: ${message}`); } } catch (error) { logger.info(`[${serverName}] Failed to parse message:`, error); } } }); this.socket.on("error", (error: Error & { code?: string }) => { clearTimeout(connectTimeout); logger.info(`[${serverName}] Socket error (code: ${error.code}):`, error); this.connected = false; this.connecting = false; if ( error.code && [ "ECONNREFUSED", // Native host not listening (stale socket) "ECONNRESET", // Connection reset by peer "EPIPE", // Broken pipe (native host died mid-write) "ENOENT", // Socket file was deleted "EOPNOTSUPP", // Socket file exists but is not a valid socket "ECONNABORTED", // Connection aborted ].includes(error.code) ) { this.scheduleReconnect(); } }); this.socket.on("close", () => { clearTimeout(connectTimeout); this.connected = false; this.connecting = false; this.scheduleReconnect(); }); } private scheduleReconnect(): void { const { serverName, logger } = this.context; if (this.disableAutoReconnect) { return; } if (this.reconnectTimer) { logger.info(`[${serverName}] Reconnect already scheduled, skipping`); return; } this.reconnectAttempts++; // Give up after extended polling (~50 min). A new ensureConnected() call // from a tool request will restart the cycle if needed. const maxTotalAttempts = 100; if (this.reconnectAttempts > maxTotalAttempts) { logger.info( `[${serverName}] Giving up after ${maxTotalAttempts} attempts. Will retry on next tool call.`, ); this.reconnectAttempts = 0; return; } // Use aggressive backoff for first 10 attempts, then slow poll every 30s. const delay = Math.min( this.reconnectDelay * Math.pow(1.5, this.reconnectAttempts - 1), 30000, ); if (this.reconnectAttempts <= this.maxReconnectAttempts) { logger.info( `[${serverName}] Reconnecting in ${Math.round(delay)}ms (attempt ${ this.reconnectAttempts })`, ); } else if (this.reconnectAttempts % 10 === 0) { // Log every 10th slow-poll attempt to avoid log spam logger.info( `[${serverName}] Still polling for native host (attempt ${this.reconnectAttempts})`, ); } this.reconnectTimer = setTimeout(() => { this.reconnectTimer = null; void this.connect(); }, delay); } private handleResponse(response: ToolResponse): void { if (this.responseCallback) { const callback = this.responseCallback; this.responseCallback = null; callback(response); } } public setNotificationHandler( handler: (notification: Notification) => void, ): void { this.notificationHandler = handler; } public async ensureConnected(): Promise { const { serverName } = this.context; if (this.connected && this.socket) { return true; } if (!this.socket && !this.connecting) { await this.connect(); } // Wait for connection with timeout return new Promise((resolve, reject) => { let checkTimeoutId: NodeJS.Timeout | null = null; const timeout = setTimeout(() => { if (checkTimeoutId) { clearTimeout(checkTimeoutId); } reject( new SocketConnectionError( `[${serverName}] Connection attempt timed out after 5000ms`, ), ); }, 5000); const checkConnection = () => { if (this.connected) { clearTimeout(timeout); resolve(true); } else { checkTimeoutId = setTimeout(checkConnection, 500); } }; checkConnection(); }); } private async sendRequest( request: ToolRequest, timeoutMs = 30000, ): Promise { const { serverName } = this.context; if (!this.socket) { throw new SocketConnectionError( `[${serverName}] Cannot send request: not connected`, ); } const socket = this.socket; return new Promise((resolve, reject) => { const timeout = setTimeout(() => { this.responseCallback = null; reject( new SocketConnectionError( `[${serverName}] Tool request timed out after ${timeoutMs}ms`, ), ); }, timeoutMs); this.responseCallback = (response) => { clearTimeout(timeout); resolve(response); }; const requestJson = JSON.stringify(request); const requestBytes = Buffer.from(requestJson, "utf-8"); const lengthPrefix = Buffer.allocUnsafe(4); lengthPrefix.writeUInt32LE(requestBytes.length, 0); const message = Buffer.concat([lengthPrefix, requestBytes]); socket.write(message); }); } public async callTool( name: string, args: Record, _permissionOverrides?: PermissionOverrides, ): Promise { const request: ToolRequest = { method: "execute_tool", params: { client_id: this.context.clientTypeId, tool: name, args, }, }; return this.sendRequestWithRetry(request); } /** * Send a request with automatic retry on connection errors. * * On connection error or timeout, the native host may be a zombie (connected * to dead Chrome). Force reconnect to pick up a fresh native host process * and retry once. */ private async sendRequestWithRetry(request: ToolRequest): Promise { const { serverName, logger } = this.context; try { return await this.sendRequest(request); } catch (error) { if (!(error instanceof SocketConnectionError)) { throw error; } logger.info( `[${serverName}] Connection error, forcing reconnect and retrying: ${error.message}`, ); this.closeSocket(); await this.ensureConnected(); return await this.sendRequest(request); } } public async setPermissionMode( _mode: PermissionMode, _allowedDomains?: string[], ): Promise { // No-op: permission mode is only supported over the bridge (WebSocket) transport } public isConnected(): boolean { return this.connected; } private closeSocket(): void { if (this.socket) { this.socket.removeAllListeners(); this.socket.end(); this.socket.destroy(); this.socket = null; } this.connected = false; this.connecting = false; } private cleanup(): void { if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; } this.closeSocket(); this.reconnectAttempts = 0; this.responseBuffer = Buffer.alloc(0); this.responseCallback = null; } public disconnect(): void { this.cleanup(); } private async validateSocketSecurity(socketPath: string): Promise { const { serverName, logger } = this.context; if (platform() === "win32") { return; } try { // Validate the parent directory permissions if it's the socket directory // (not /tmp itself, which has mode 1777 for legacy single-socket paths) const dirPath = dirname(socketPath); const dirBasename = dirPath.split("/").pop() || ""; const isSocketDir = dirBasename.startsWith("claude-mcp-browser-bridge-"); if (isSocketDir) { try { const dirStats = await fsPromises.stat(dirPath); if (dirStats.isDirectory()) { const dirMode = dirStats.mode & 0o777; if (dirMode !== 0o700) { throw new Error( `[${serverName}] Insecure socket directory permissions: ${dirMode.toString( 8, )} (expected 0700). Directory may have been tampered with.`, ); } const currentUid = process.getuid?.(); if (currentUid !== undefined && dirStats.uid !== currentUid) { throw new Error( `Socket directory not owned by current user (uid: ${currentUid}, dir uid: ${dirStats.uid}). ` + `Potential security risk.`, ); } } } catch (dirError) { if ((dirError as NodeJS.ErrnoException).code !== "ENOENT") { throw dirError; } // Directory doesn't exist yet - native host will create it } } const stats = await fsPromises.stat(socketPath); if (!stats.isSocket()) { throw new Error( `[${serverName}] Path exists but it's not a socket: ${socketPath}`, ); } const mode = stats.mode & 0o777; if (mode !== 0o600) { throw new Error( `[${serverName}] Insecure socket permissions: ${mode.toString( 8, )} (expected 0600). Socket may have been tampered with.`, ); } const currentUid = process.getuid?.(); if (currentUid !== undefined && stats.uid !== currentUid) { throw new Error( `Socket not owned by current user (uid: ${currentUid}, socket uid: ${stats.uid}). ` + `Potential security risk.`, ); } logger.info(`[${serverName}] Socket security validation passed`); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") { logger.info( `[${serverName}] Socket not found, will be created by server`, ); return; } throw error; } } } export function createMcpSocketClient( context: ClaudeForChromeContext, ): McpSocketClient { return new McpSocketClient(context); } export type { McpSocketClient };