source dump of claude code
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 527 lines 14 kB view raw
1// biome-ignore-all lint/suspicious/noConsole: file uses console intentionally 2/** 3 * Chrome Native Host - Pure TypeScript Implementation 4 * 5 * This module provides the Chrome native messaging host functionality, 6 * previously implemented as a Rust NAPI binding but now in pure TypeScript. 7 */ 8 9import { 10 appendFile, 11 chmod, 12 mkdir, 13 readdir, 14 rmdir, 15 stat, 16 unlink, 17} from 'fs/promises' 18import { createServer, type Server, type Socket } from 'net' 19import { homedir, platform } from 'os' 20import { join } from 'path' 21import { z } from 'zod' 22import { lazySchema } from '../lazySchema.js' 23import { jsonParse, jsonStringify } from '../slowOperations.js' 24import { getSecureSocketPath, getSocketDir } from './common.js' 25 26const VERSION = '1.0.0' 27const MAX_MESSAGE_SIZE = 1024 * 1024 // 1MB - Max message size that can be sent to Chrome 28 29const LOG_FILE = 30 process.env.USER_TYPE === 'ant' 31 ? join(homedir(), '.claude', 'debug', 'chrome-native-host.txt') 32 : undefined 33 34function log(message: string, ...args: unknown[]): void { 35 if (LOG_FILE) { 36 const timestamp = new Date().toISOString() 37 const formattedArgs = args.length > 0 ? ' ' + jsonStringify(args) : '' 38 const logLine = `[${timestamp}] [Claude Chrome Native Host] ${message}${formattedArgs}\n` 39 // Fire-and-forget: logging is best-effort and callers (including event 40 // handlers) don't await 41 void appendFile(LOG_FILE, logLine).catch(() => { 42 // Ignore file write errors 43 }) 44 } 45 console.error(`[Claude Chrome Native Host] ${message}`, ...args) 46} 47/** 48 * Send a message to stdout (Chrome native messaging protocol) 49 */ 50export function sendChromeMessage(message: string): void { 51 const jsonBytes = Buffer.from(message, 'utf-8') 52 const lengthBuffer = Buffer.alloc(4) 53 lengthBuffer.writeUInt32LE(jsonBytes.length, 0) 54 55 process.stdout.write(lengthBuffer) 56 process.stdout.write(jsonBytes) 57} 58 59export async function runChromeNativeHost(): Promise<void> { 60 log('Initializing...') 61 62 const host = new ChromeNativeHost() 63 const messageReader = new ChromeMessageReader() 64 65 // Start the native host server 66 await host.start() 67 68 // Process messages from Chrome until stdin closes 69 // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition 70 while (true) { 71 const message = await messageReader.read() 72 if (message === null) { 73 // stdin closed, Chrome disconnected 74 break 75 } 76 77 await host.handleMessage(message) 78 } 79 80 // Stop the server 81 await host.stop() 82} 83 84const messageSchema = lazySchema(() => 85 z 86 .object({ 87 type: z.string(), 88 }) 89 .passthrough(), 90) 91 92type ToolRequest = { 93 method: string 94 params?: unknown 95} 96 97type McpClient = { 98 id: number 99 socket: Socket 100 buffer: Buffer 101} 102 103class ChromeNativeHost { 104 private mcpClients = new Map<number, McpClient>() 105 private nextClientId = 1 106 private server: Server | null = null 107 private running = false 108 private socketPath: string | null = null 109 110 async start(): Promise<void> { 111 if (this.running) { 112 return 113 } 114 115 this.socketPath = getSecureSocketPath() 116 117 if (platform() !== 'win32') { 118 const socketDir = getSocketDir() 119 120 // Migrate legacy socket: if socket dir path exists as a file/socket, remove it 121 try { 122 const dirStats = await stat(socketDir) 123 if (!dirStats.isDirectory()) { 124 await unlink(socketDir) 125 } 126 } catch { 127 // Doesn't exist, that's fine 128 } 129 130 // Create socket directory with secure permissions 131 await mkdir(socketDir, { recursive: true, mode: 0o700 }) 132 133 // Fix perms if directory already existed 134 await chmod(socketDir, 0o700).catch(() => { 135 // Ignore 136 }) 137 138 // Clean up stale sockets 139 try { 140 const files = await readdir(socketDir) 141 for (const file of files) { 142 if (!file.endsWith('.sock')) { 143 continue 144 } 145 const pid = parseInt(file.replace('.sock', ''), 10) 146 if (isNaN(pid)) { 147 continue 148 } 149 try { 150 process.kill(pid, 0) 151 // Process is alive, leave it 152 } catch { 153 // Process is dead, remove stale socket 154 await unlink(join(socketDir, file)).catch(() => { 155 // Ignore 156 }) 157 log(`Removed stale socket for PID ${pid}`) 158 } 159 } 160 } catch { 161 // Ignore errors scanning directory 162 } 163 } 164 165 log(`Creating socket listener: ${this.socketPath}`) 166 167 this.server = createServer(socket => this.handleMcpClient(socket)) 168 169 await new Promise<void>((resolve, reject) => { 170 this.server!.listen(this.socketPath!, () => { 171 log('Socket server listening for connections') 172 this.running = true 173 resolve() 174 }) 175 176 this.server!.on('error', err => { 177 log('Socket server error:', err) 178 reject(err) 179 }) 180 }) 181 182 // Set permissions on Unix (after listen resolves so socket file exists) 183 if (platform() !== 'win32') { 184 try { 185 await chmod(this.socketPath!, 0o600) 186 log('Socket permissions set to 0600') 187 } catch (e) { 188 log('Failed to set socket permissions:', e) 189 } 190 } 191 } 192 193 async stop(): Promise<void> { 194 if (!this.running) { 195 return 196 } 197 198 // Close all MCP clients 199 for (const [, client] of this.mcpClients) { 200 client.socket.destroy() 201 } 202 this.mcpClients.clear() 203 204 // Close server 205 if (this.server) { 206 await new Promise<void>(resolve => { 207 this.server!.close(() => resolve()) 208 }) 209 this.server = null 210 } 211 212 // Cleanup socket file 213 if (platform() !== 'win32' && this.socketPath) { 214 try { 215 await unlink(this.socketPath) 216 log('Cleaned up socket file') 217 } catch { 218 // ENOENT is fine, ignore 219 } 220 221 // Remove directory if empty 222 try { 223 const socketDir = getSocketDir() 224 const remaining = await readdir(socketDir) 225 if (remaining.length === 0) { 226 await rmdir(socketDir) 227 log('Removed empty socket directory') 228 } 229 } catch { 230 // Ignore 231 } 232 } 233 234 this.running = false 235 } 236 237 async isRunning(): Promise<boolean> { 238 return this.running 239 } 240 241 async getClientCount(): Promise<number> { 242 return this.mcpClients.size 243 } 244 245 async handleMessage(messageJson: string): Promise<void> { 246 let rawMessage: unknown 247 try { 248 rawMessage = jsonParse(messageJson) 249 } catch (e) { 250 log('Invalid JSON from Chrome:', (e as Error).message) 251 sendChromeMessage( 252 jsonStringify({ 253 type: 'error', 254 error: 'Invalid message format', 255 }), 256 ) 257 return 258 } 259 const parsed = messageSchema().safeParse(rawMessage) 260 if (!parsed.success) { 261 log('Invalid message from Chrome:', parsed.error.message) 262 sendChromeMessage( 263 jsonStringify({ 264 type: 'error', 265 error: 'Invalid message format', 266 }), 267 ) 268 return 269 } 270 const message = parsed.data 271 272 log(`Handling Chrome message type: ${message.type}`) 273 274 switch (message.type) { 275 case 'ping': 276 log('Responding to ping') 277 278 sendChromeMessage( 279 jsonStringify({ 280 type: 'pong', 281 timestamp: Date.now(), 282 }), 283 ) 284 break 285 286 case 'get_status': 287 sendChromeMessage( 288 jsonStringify({ 289 type: 'status_response', 290 native_host_version: VERSION, 291 }), 292 ) 293 break 294 295 case 'tool_response': { 296 if (this.mcpClients.size > 0) { 297 log(`Forwarding tool response to ${this.mcpClients.size} MCP clients`) 298 299 // Extract the data portion (everything except 'type') 300 const { type: _, ...data } = message 301 const responseData = Buffer.from(jsonStringify(data), 'utf-8') 302 const lengthBuffer = Buffer.alloc(4) 303 lengthBuffer.writeUInt32LE(responseData.length, 0) 304 const responseMsg = Buffer.concat([lengthBuffer, responseData]) 305 306 for (const [id, client] of this.mcpClients) { 307 try { 308 client.socket.write(responseMsg) 309 } catch (e) { 310 log(`Failed to send to MCP client ${id}:`, e) 311 } 312 } 313 } 314 break 315 } 316 317 case 'notification': { 318 if (this.mcpClients.size > 0) { 319 log(`Forwarding notification to ${this.mcpClients.size} MCP clients`) 320 321 // Extract the data portion (everything except 'type') 322 const { type: _, ...data } = message 323 const notificationData = Buffer.from(jsonStringify(data), 'utf-8') 324 const lengthBuffer = Buffer.alloc(4) 325 lengthBuffer.writeUInt32LE(notificationData.length, 0) 326 const notificationMsg = Buffer.concat([ 327 lengthBuffer, 328 notificationData, 329 ]) 330 331 for (const [id, client] of this.mcpClients) { 332 try { 333 client.socket.write(notificationMsg) 334 } catch (e) { 335 log(`Failed to send notification to MCP client ${id}:`, e) 336 } 337 } 338 } 339 break 340 } 341 342 default: 343 log(`Unknown message type: ${message.type}`) 344 345 sendChromeMessage( 346 jsonStringify({ 347 type: 'error', 348 error: `Unknown message type: ${message.type}`, 349 }), 350 ) 351 } 352 } 353 354 private handleMcpClient(socket: Socket): void { 355 const clientId = this.nextClientId++ 356 const client: McpClient = { 357 id: clientId, 358 socket, 359 buffer: Buffer.alloc(0), 360 } 361 362 this.mcpClients.set(clientId, client) 363 log( 364 `MCP client ${clientId} connected. Total clients: ${this.mcpClients.size}`, 365 ) 366 367 // Notify Chrome of connection 368 sendChromeMessage( 369 jsonStringify({ 370 type: 'mcp_connected', 371 }), 372 ) 373 374 socket.on('data', (data: Buffer) => { 375 client.buffer = Buffer.concat([client.buffer, data]) 376 377 // Process complete messages 378 while (client.buffer.length >= 4) { 379 const length = client.buffer.readUInt32LE(0) 380 381 if (length === 0 || length > MAX_MESSAGE_SIZE) { 382 log(`Invalid message length from MCP client ${clientId}: ${length}`) 383 socket.destroy() 384 return 385 } 386 387 if (client.buffer.length < 4 + length) { 388 break // Wait for more data 389 } 390 391 const messageBytes = client.buffer.slice(4, 4 + length) 392 client.buffer = client.buffer.slice(4 + length) 393 394 try { 395 const request = jsonParse( 396 messageBytes.toString('utf-8'), 397 ) as ToolRequest 398 log( 399 `Forwarding tool request from MCP client ${clientId}: ${request.method}`, 400 ) 401 402 // Forward to Chrome 403 sendChromeMessage( 404 jsonStringify({ 405 type: 'tool_request', 406 method: request.method, 407 params: request.params, 408 }), 409 ) 410 } catch (e) { 411 log(`Failed to parse tool request from MCP client ${clientId}:`, e) 412 } 413 } 414 }) 415 416 socket.on('error', err => { 417 log(`MCP client ${clientId} error: ${err}`) 418 }) 419 420 socket.on('close', () => { 421 log( 422 `MCP client ${clientId} disconnected. Remaining clients: ${this.mcpClients.size - 1}`, 423 ) 424 this.mcpClients.delete(clientId) 425 426 // Notify Chrome of disconnection 427 sendChromeMessage( 428 jsonStringify({ 429 type: 'mcp_disconnected', 430 }), 431 ) 432 }) 433 } 434} 435 436/** 437 * Chrome message reader using async stdin. Synchronous reads can crash Bun, so we use 438 * async reads with a buffer. 439 */ 440class ChromeMessageReader { 441 private buffer = Buffer.alloc(0) 442 private pendingResolve: ((value: string | null) => void) | null = null 443 private closed = false 444 445 constructor() { 446 process.stdin.on('data', (chunk: Buffer) => { 447 this.buffer = Buffer.concat([this.buffer, chunk]) 448 this.tryProcessMessage() 449 }) 450 451 process.stdin.on('end', () => { 452 this.closed = true 453 if (this.pendingResolve) { 454 this.pendingResolve(null) 455 this.pendingResolve = null 456 } 457 }) 458 459 process.stdin.on('error', () => { 460 this.closed = true 461 if (this.pendingResolve) { 462 this.pendingResolve(null) 463 this.pendingResolve = null 464 } 465 }) 466 } 467 468 private tryProcessMessage(): void { 469 if (!this.pendingResolve) { 470 return 471 } 472 473 // Need at least 4 bytes for length prefix 474 if (this.buffer.length < 4) { 475 return 476 } 477 478 const length = this.buffer.readUInt32LE(0) 479 480 if (length === 0 || length > MAX_MESSAGE_SIZE) { 481 log(`Invalid message length: ${length}`) 482 this.pendingResolve(null) 483 this.pendingResolve = null 484 return 485 } 486 487 // Check if we have the full message 488 if (this.buffer.length < 4 + length) { 489 return // Wait for more data 490 } 491 492 // Extract the message 493 const messageBytes = this.buffer.subarray(4, 4 + length) 494 this.buffer = this.buffer.subarray(4 + length) 495 496 const message = messageBytes.toString('utf-8') 497 this.pendingResolve(message) 498 this.pendingResolve = null 499 } 500 501 async read(): Promise<string | null> { 502 if (this.closed) { 503 return null 504 } 505 506 // Check if we already have a complete message buffered 507 if (this.buffer.length >= 4) { 508 const length = this.buffer.readUInt32LE(0) 509 if ( 510 length > 0 && 511 length <= MAX_MESSAGE_SIZE && 512 this.buffer.length >= 4 + length 513 ) { 514 const messageBytes = this.buffer.subarray(4, 4 + length) 515 this.buffer = this.buffer.subarray(4 + length) 516 return messageBytes.toString('utf-8') 517 } 518 } 519 520 // Wait for more data 521 return new Promise(resolve => { 522 this.pendingResolve = resolve 523 // In case data arrived between check and setting pendingResolve 524 this.tryProcessMessage() 525 }) 526 } 527}