source dump of claude code
at main 427 lines 14 kB view raw
1/** 2 * TMUX SOCKET ISOLATION 3 * ===================== 4 * This module manages an isolated tmux socket for Claude's operations. 5 * 6 * WHY THIS EXISTS: 7 * Without isolation, Claude could accidentally affect the user's tmux sessions. 8 * For example, running `tmux kill-session` via the Bash tool would kill the 9 * user's current session if they started Claude from within tmux. 10 * 11 * HOW IT WORKS: 12 * 1. Claude creates its own tmux socket: `claude-<PID>` (e.g., `claude-12345`) 13 * 2. ALL Tmux tool commands use this socket via the `-L` flag 14 * 3. ALL Bash tool commands inherit TMUX env var pointing to this socket 15 * (set in Shell.ts via getClaudeTmuxEnv()) 16 * 17 * This means ANY tmux command run through Claude - whether via the Tmux tool 18 * directly or via Bash - will operate on Claude's isolated socket, NOT the 19 * user's tmux session. 20 * 21 * IMPORTANT: The user's original TMUX env var is NOT used. After socket 22 * initialization, getClaudeTmuxEnv() returns a value that overrides the 23 * user's TMUX in all child processes spawned by Shell.ts. 24 */ 25 26import { posix } from 'path' 27import { registerCleanup } from './cleanupRegistry.js' 28import { logForDebugging } from './debug.js' 29import { toError } from './errors.js' 30import { execFileNoThrow } from './execFileNoThrow.js' 31import { logError } from './log.js' 32import { getPlatform } from './platform.js' 33 34// Constants for tmux socket management 35const TMUX_COMMAND = 'tmux' 36const CLAUDE_SOCKET_PREFIX = 'claude' 37 38/** 39 * Executes a tmux command, routing through WSL on Windows. 40 * On Windows, tmux only exists inside WSL — WSL interop lets the tmux session 41 * launch .exe files as native Win32 processes while stdin/stdout flow through 42 * the WSL pty. 43 */ 44async function execTmux( 45 args: string[], 46 opts?: { useCwd?: boolean }, 47): Promise<{ stdout: string; stderr: string; code: number }> { 48 if (getPlatform() === 'windows') { 49 // -e execs tmux directly without the login shell. Without it, wsl hands the 50 // command line to bash which eats `#` as a comment: `display-message -p 51 // #{socket_path},#{pid}` below becomes `display-message -p ` → exit 1 → 52 // we silently fall back to the guessed path and never learn the real 53 // server PID. Same root cause as TungstenTool/utils.ts:execTmuxCommand. 54 const result = await execFileNoThrow('wsl', ['-e', TMUX_COMMAND, ...args], { 55 env: { ...process.env, WSL_UTF8: '1' }, 56 ...opts, 57 }) 58 return { 59 stdout: result.stdout || '', 60 stderr: result.stderr || '', 61 code: result.code || 0, 62 } 63 } 64 const result = await execFileNoThrow(TMUX_COMMAND, args, opts) 65 return { 66 stdout: result.stdout || '', 67 stderr: result.stderr || '', 68 code: result.code || 0, 69 } 70} 71 72// Socket state - initialized lazily when Tmux tool is first used or a tmux command is run 73let socketName: string | null = null 74let socketPath: string | null = null 75let serverPid: number | null = null 76let isInitializing = false 77let initPromise: Promise<void> | null = null 78 79// tmux availability - checked once upfront 80let tmuxAvailabilityChecked = false 81let tmuxAvailable = false 82 83// Track whether the Tmux tool has been used at least once 84// Used to defer socket initialization until actually needed 85let tmuxToolUsed = false 86 87/** 88 * Gets the socket name for Claude's isolated tmux session. 89 * Format: claude-<PID> 90 */ 91export function getClaudeSocketName(): string { 92 if (!socketName) { 93 socketName = `${CLAUDE_SOCKET_PREFIX}-${process.pid}` 94 } 95 return socketName 96} 97 98/** 99 * Gets the socket path if the socket has been initialized. 100 * Returns null if not yet initialized. 101 */ 102export function getClaudeSocketPath(): string | null { 103 return socketPath 104} 105 106/** 107 * Sets socket info after initialization. 108 * Called after the tmux session is created. 109 */ 110export function setClaudeSocketInfo(path: string, pid: number): void { 111 socketPath = path 112 serverPid = pid 113} 114 115/** 116 * Returns whether the socket has been initialized. 117 */ 118export function isSocketInitialized(): boolean { 119 return socketPath !== null && serverPid !== null 120} 121 122/** 123 * Gets the TMUX environment variable value for Claude's isolated socket. 124 * 125 * CRITICAL: This value is used by Shell.ts to override the TMUX env var 126 * in ALL child processes. This ensures that any `tmux` command run via 127 * the Bash tool will operate on Claude's socket, NOT the user's session. 128 * 129 * Format: "socket_path,server_pid,pane_index" (matches tmux's TMUX env var) 130 * Example: "/tmp/tmux-501/claude-12345,54321,0" 131 * 132 * Returns null if socket is not yet initialized. 133 * When null, Shell.ts does not override TMUX, preserving user's environment. 134 */ 135export function getClaudeTmuxEnv(): string | null { 136 if (!socketPath || serverPid === null) { 137 return null 138 } 139 return `${socketPath},${serverPid},0` 140} 141 142/** 143 * Checks if tmux is available on this system. 144 * This is checked once and cached for the lifetime of the process. 145 * 146 * When tmux is not available: 147 * - TungstenTool (Tmux) will not work 148 * - TeammateTool will not work (it uses tmux for pane management) 149 * - Bash commands will run without tmux isolation 150 */ 151export async function checkTmuxAvailable(): Promise<boolean> { 152 if (!tmuxAvailabilityChecked) { 153 const result = 154 getPlatform() === 'windows' 155 ? await execFileNoThrow('wsl', ['-e', TMUX_COMMAND, '-V'], { 156 env: { ...process.env, WSL_UTF8: '1' }, 157 useCwd: false, 158 }) 159 : await execFileNoThrow('which', [TMUX_COMMAND], { 160 useCwd: false, 161 }) 162 tmuxAvailable = result.code === 0 163 if (!tmuxAvailable) { 164 logForDebugging( 165 `[Socket] tmux is not installed. The Tmux tool and Teammate tool will not be available.`, 166 ) 167 } 168 tmuxAvailabilityChecked = true 169 } 170 return tmuxAvailable 171} 172 173/** 174 * Returns the cached tmux availability status. 175 * Returns false if availability hasn't been checked yet. 176 * Use checkTmuxAvailable() to perform the check. 177 */ 178export function isTmuxAvailable(): boolean { 179 return tmuxAvailabilityChecked && tmuxAvailable 180} 181 182/** 183 * Marks that the Tmux tool has been used at least once. 184 * Called by TungstenTool before initialization. 185 * After this is called, Shell.ts will initialize the socket for subsequent Bash commands. 186 */ 187export function markTmuxToolUsed(): void { 188 tmuxToolUsed = true 189} 190 191/** 192 * Returns whether the Tmux tool has been used at least once. 193 * Used by Shell.ts to decide whether to initialize the socket. 194 */ 195export function hasTmuxToolBeenUsed(): boolean { 196 return tmuxToolUsed 197} 198 199/** 200 * Ensures the socket is initialized with a tmux session. 201 * Called by Shell.ts when the Tmux tool has been used or the command includes "tmux". 202 * Safe to call multiple times; will only initialize once. 203 * 204 * If tmux is not installed, this function returns gracefully without 205 * initializing the socket. getClaudeTmuxEnv() will return null, and 206 * Bash commands will run without tmux isolation. 207 */ 208export async function ensureSocketInitialized(): Promise<void> { 209 // Already initialized 210 if (isSocketInitialized()) { 211 return 212 } 213 214 // Check if tmux is available before trying to use it 215 const available = await checkTmuxAvailable() 216 if (!available) { 217 return 218 } 219 220 // Another call is already initializing - wait for it but don't propagate errors 221 // The original caller handles the error and sets up graceful degradation 222 if (isInitializing && initPromise) { 223 try { 224 await initPromise 225 } catch { 226 // Ignore - the original caller logs the error 227 } 228 return 229 } 230 231 isInitializing = true 232 initPromise = doInitialize() 233 234 try { 235 await initPromise 236 } catch (error) { 237 // Log error but don't throw - graceful degradation 238 const err = toError(error) 239 logError(err) 240 logForDebugging( 241 `[Socket] Failed to initialize tmux socket: ${err.message}. Tmux isolation will be disabled.`, 242 ) 243 } finally { 244 isInitializing = false 245 } 246} 247 248/** 249 * Kills the tmux server for Claude's isolated socket. 250 * Called during graceful shutdown to clean up resources. 251 */ 252async function killTmuxServer(): Promise<void> { 253 const socket = getClaudeSocketName() 254 logForDebugging(`[Socket] Killing tmux server for socket: ${socket}`) 255 256 const result = await execTmux(['-L', socket, 'kill-server']) 257 258 if (result.code === 0) { 259 logForDebugging(`[Socket] Successfully killed tmux server`) 260 } else { 261 // Server may already be dead, which is fine 262 logForDebugging( 263 `[Socket] Failed to kill tmux server (exit ${result.code}): ${result.stderr}`, 264 ) 265 } 266} 267 268async function doInitialize(): Promise<void> { 269 const socket = getClaudeSocketName() 270 271 // Create a new session with our custom socket 272 // Pass CLAUDE_CODE_SKIP_PROMPT_HISTORY via -e so it's set in the initial shell environment 273 // 274 // On Windows, the tmux server inherits WSL_INTEROP from the short-lived 275 // wsl.exe that spawns it; once `new-session -d` detaches and wsl.exe exits, 276 // that socket stops servicing requests. Any cli.exe launched inside the pane 277 // then hits `UtilAcceptVsock: accept4 failed 110` (ETIMEDOUT). Observed on 278 // 2026-03-25: server PID 386 (started alongside /init at WSL boot) inherited 279 // /run/WSL/383_interop — init's own socket, which listens but doesn't handle 280 // interop. /run/WSL/1_interop is a stable symlink WSL maintains to the real 281 // handler; pin the server to it so interop survives the spawning wsl.exe. 282 const result = await execTmux([ 283 '-L', 284 socket, 285 'new-session', 286 '-d', 287 '-s', 288 'base', 289 '-e', 290 'CLAUDE_CODE_SKIP_PROMPT_HISTORY=true', 291 ...(getPlatform() === 'windows' 292 ? ['-e', 'WSL_INTEROP=/run/WSL/1_interop'] 293 : []), 294 ]) 295 296 if (result.code !== 0) { 297 // Session might already exist from a previous run with same PID (unlikely but possible) 298 // Check if the session exists 299 const checkResult = await execTmux([ 300 '-L', 301 socket, 302 'has-session', 303 '-t', 304 'base', 305 ]) 306 if (checkResult.code !== 0) { 307 throw new Error( 308 `Failed to create tmux session on socket ${socket}: ${result.stderr}`, 309 ) 310 } 311 } 312 313 // Register cleanup to kill the tmux server on exit 314 registerCleanup(killTmuxServer) 315 316 // Set CLAUDE_CODE_SKIP_PROMPT_HISTORY in the tmux GLOBAL environment (-g). 317 // Without -g this would only apply to the 'base' session, and new sessions 318 // created by TungstenTool (e.g. 'test', 'verify') would not inherit it. 319 // Any Claude Code instance spawned on this socket will inherit this env var, 320 // preventing test/verification sessions from polluting the user's real 321 // command history and --resume session list. 322 await execTmux([ 323 '-L', 324 socket, 325 'set-environment', 326 '-g', 327 'CLAUDE_CODE_SKIP_PROMPT_HISTORY', 328 'true', 329 ]) 330 331 // Same WSL_INTEROP pin as the new-session -e above, but in the GLOBAL env 332 // so sessions created by TungstenTool inherit it too. The -e on new-session 333 // only covers the base session's initial shell; a later `new-session -s cc` 334 // inherits the SERVER's env, which still holds the stale socket from the 335 // wsl.exe that spawned it. 336 if (getPlatform() === 'windows') { 337 await execTmux([ 338 '-L', 339 socket, 340 'set-environment', 341 '-g', 342 'WSL_INTEROP', 343 '/run/WSL/1_interop', 344 ]) 345 } 346 347 // Get the socket path and server PID 348 const infoResult = await execTmux([ 349 '-L', 350 socket, 351 'display-message', 352 '-p', 353 '#{socket_path},#{pid}', 354 ]) 355 356 if (infoResult.code === 0) { 357 const [path, pidStr] = infoResult.stdout.trim().split(',') 358 if (path && pidStr) { 359 const pid = parseInt(pidStr, 10) 360 if (!isNaN(pid)) { 361 setClaudeSocketInfo(path, pid) 362 return 363 } 364 } 365 // Parsing failed - log and fall through to fallback 366 logForDebugging( 367 `[Socket] Failed to parse socket info from tmux output: "${infoResult.stdout.trim()}". Using fallback path.`, 368 ) 369 } else { 370 // Command failed - log and fall through to fallback 371 logForDebugging( 372 `[Socket] Failed to get socket info via display-message (exit ${infoResult.code}): ${infoResult.stderr}. Using fallback path.`, 373 ) 374 } 375 376 // Fallback: construct the socket path from standard tmux location 377 // tmux sockets are typically at $TMPDIR/tmux-<UID>/<socket_name> (or /tmp/tmux-<UID>/ if TMPDIR is not set) 378 // On Windows this path is inside WSL, so always use POSIX separators. 379 // process.getuid() is undefined on Windows; WSL default user is root (uid 0) in CI. 380 const uid = process.getuid?.() ?? 0 381 const baseTmpDir = process.env.TMPDIR || '/tmp' 382 const fallbackPath = posix.join(baseTmpDir, `tmux-${uid}`, socket) 383 384 // Get server PID separately 385 const pidResult = await execTmux([ 386 '-L', 387 socket, 388 'display-message', 389 '-p', 390 '#{pid}', 391 ]) 392 393 if (pidResult.code === 0) { 394 const pid = parseInt(pidResult.stdout.trim(), 10) 395 if (!isNaN(pid)) { 396 logForDebugging( 397 `[Socket] Using fallback socket path: ${fallbackPath} (server PID: ${pid})`, 398 ) 399 setClaudeSocketInfo(fallbackPath, pid) 400 return 401 } 402 // PID parsing failed 403 logForDebugging( 404 `[Socket] Failed to parse server PID from tmux output: "${pidResult.stdout.trim()}"`, 405 ) 406 } else { 407 logForDebugging( 408 `[Socket] Failed to get server PID (exit ${pidResult.code}): ${pidResult.stderr}`, 409 ) 410 } 411 412 throw new Error( 413 `Failed to get socket info for ${socket}: primary="${infoResult.stderr}", fallback="${pidResult.stderr}"`, 414 ) 415} 416 417// For testing purposes 418export function resetSocketState(): void { 419 socketName = null 420 socketPath = null 421 serverPid = null 422 isInitializing = false 423 initPromise = null 424 tmuxAvailabilityChecked = false 425 tmuxAvailable = false 426 tmuxToolUsed = false 427}