/ utils / tmuxSocket.ts
tmuxSocket.ts
  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  
 26  import { posix } from 'path'
 27  import { registerCleanup } from './cleanupRegistry.js'
 28  import { logForDebugging } from './debug.js'
 29  import { toError } from './errors.js'
 30  import { execFileNoThrow } from './execFileNoThrow.js'
 31  import { logError } from './log.js'
 32  import { getPlatform } from './platform.js'
 33  
 34  // Constants for tmux socket management
 35  const TMUX_COMMAND = 'tmux'
 36  const 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   */
 44  async 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
 73  let socketName: string | null = null
 74  let socketPath: string | null = null
 75  let serverPid: number | null = null
 76  let isInitializing = false
 77  let initPromise: Promise<void> | null = null
 78  
 79  // tmux availability - checked once upfront
 80  let tmuxAvailabilityChecked = false
 81  let tmuxAvailable = false
 82  
 83  // Track whether the Tmux tool has been used at least once
 84  // Used to defer socket initialization until actually needed
 85  let tmuxToolUsed = false
 86  
 87  /**
 88   * Gets the socket name for Claude's isolated tmux session.
 89   * Format: claude-<PID>
 90   */
 91  export 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   */
102  export 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   */
110  export 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   */
118  export 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   */
135  export 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   */
151  export 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   */
178  export 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   */
187  export 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   */
195  export 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   */
208  export 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   */
252  async 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  
268  async 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
418  export 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  }