init
This commit is contained in:
@@ -0,0 +1,427 @@
|
||||
/**
|
||||
* TMUX SOCKET ISOLATION
|
||||
* =====================
|
||||
* This module manages an isolated tmux socket for Claude's operations.
|
||||
*
|
||||
* WHY THIS EXISTS:
|
||||
* Without isolation, Claude could accidentally affect the user's tmux sessions.
|
||||
* For example, running `tmux kill-session` via the Bash tool would kill the
|
||||
* user's current session if they started Claude from within tmux.
|
||||
*
|
||||
* HOW IT WORKS:
|
||||
* 1. Claude creates its own tmux socket: `claude-<PID>` (e.g., `claude-12345`)
|
||||
* 2. ALL Tmux tool commands use this socket via the `-L` flag
|
||||
* 3. ALL Bash tool commands inherit TMUX env var pointing to this socket
|
||||
* (set in Shell.ts via getClaudeTmuxEnv())
|
||||
*
|
||||
* This means ANY tmux command run through Claude - whether via the Tmux tool
|
||||
* directly or via Bash - will operate on Claude's isolated socket, NOT the
|
||||
* user's tmux session.
|
||||
*
|
||||
* IMPORTANT: The user's original TMUX env var is NOT used. After socket
|
||||
* initialization, getClaudeTmuxEnv() returns a value that overrides the
|
||||
* user's TMUX in all child processes spawned by Shell.ts.
|
||||
*/
|
||||
|
||||
import { posix } from 'path'
|
||||
import { registerCleanup } from './cleanupRegistry.js'
|
||||
import { logForDebugging } from './debug.js'
|
||||
import { toError } from './errors.js'
|
||||
import { execFileNoThrow } from './execFileNoThrow.js'
|
||||
import { logError } from './log.js'
|
||||
import { getPlatform } from './platform.js'
|
||||
|
||||
// Constants for tmux socket management
|
||||
const TMUX_COMMAND = 'tmux'
|
||||
const CLAUDE_SOCKET_PREFIX = 'claude'
|
||||
|
||||
/**
|
||||
* Executes a tmux command, routing through WSL on Windows.
|
||||
* On Windows, tmux only exists inside WSL — WSL interop lets the tmux session
|
||||
* launch .exe files as native Win32 processes while stdin/stdout flow through
|
||||
* the WSL pty.
|
||||
*/
|
||||
async function execTmux(
|
||||
args: string[],
|
||||
opts?: { useCwd?: boolean },
|
||||
): Promise<{ stdout: string; stderr: string; code: number }> {
|
||||
if (getPlatform() === 'windows') {
|
||||
// -e execs tmux directly without the login shell. Without it, wsl hands the
|
||||
// command line to bash which eats `#` as a comment: `display-message -p
|
||||
// #{socket_path},#{pid}` below becomes `display-message -p ` → exit 1 →
|
||||
// we silently fall back to the guessed path and never learn the real
|
||||
// server PID. Same root cause as TungstenTool/utils.ts:execTmuxCommand.
|
||||
const result = await execFileNoThrow('wsl', ['-e', TMUX_COMMAND, ...args], {
|
||||
env: { ...process.env, WSL_UTF8: '1' },
|
||||
...opts,
|
||||
})
|
||||
return {
|
||||
stdout: result.stdout || '',
|
||||
stderr: result.stderr || '',
|
||||
code: result.code || 0,
|
||||
}
|
||||
}
|
||||
const result = await execFileNoThrow(TMUX_COMMAND, args, opts)
|
||||
return {
|
||||
stdout: result.stdout || '',
|
||||
stderr: result.stderr || '',
|
||||
code: result.code || 0,
|
||||
}
|
||||
}
|
||||
|
||||
// Socket state - initialized lazily when Tmux tool is first used or a tmux command is run
|
||||
let socketName: string | null = null
|
||||
let socketPath: string | null = null
|
||||
let serverPid: number | null = null
|
||||
let isInitializing = false
|
||||
let initPromise: Promise<void> | null = null
|
||||
|
||||
// tmux availability - checked once upfront
|
||||
let tmuxAvailabilityChecked = false
|
||||
let tmuxAvailable = false
|
||||
|
||||
// Track whether the Tmux tool has been used at least once
|
||||
// Used to defer socket initialization until actually needed
|
||||
let tmuxToolUsed = false
|
||||
|
||||
/**
|
||||
* Gets the socket name for Claude's isolated tmux session.
|
||||
* Format: claude-<PID>
|
||||
*/
|
||||
export function getClaudeSocketName(): string {
|
||||
if (!socketName) {
|
||||
socketName = `${CLAUDE_SOCKET_PREFIX}-${process.pid}`
|
||||
}
|
||||
return socketName
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the socket path if the socket has been initialized.
|
||||
* Returns null if not yet initialized.
|
||||
*/
|
||||
export function getClaudeSocketPath(): string | null {
|
||||
return socketPath
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets socket info after initialization.
|
||||
* Called after the tmux session is created.
|
||||
*/
|
||||
export function setClaudeSocketInfo(path: string, pid: number): void {
|
||||
socketPath = path
|
||||
serverPid = pid
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the socket has been initialized.
|
||||
*/
|
||||
export function isSocketInitialized(): boolean {
|
||||
return socketPath !== null && serverPid !== null
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the TMUX environment variable value for Claude's isolated socket.
|
||||
*
|
||||
* CRITICAL: This value is used by Shell.ts to override the TMUX env var
|
||||
* in ALL child processes. This ensures that any `tmux` command run via
|
||||
* the Bash tool will operate on Claude's socket, NOT the user's session.
|
||||
*
|
||||
* Format: "socket_path,server_pid,pane_index" (matches tmux's TMUX env var)
|
||||
* Example: "/tmp/tmux-501/claude-12345,54321,0"
|
||||
*
|
||||
* Returns null if socket is not yet initialized.
|
||||
* When null, Shell.ts does not override TMUX, preserving user's environment.
|
||||
*/
|
||||
export function getClaudeTmuxEnv(): string | null {
|
||||
if (!socketPath || serverPid === null) {
|
||||
return null
|
||||
}
|
||||
return `${socketPath},${serverPid},0`
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if tmux is available on this system.
|
||||
* This is checked once and cached for the lifetime of the process.
|
||||
*
|
||||
* When tmux is not available:
|
||||
* - TungstenTool (Tmux) will not work
|
||||
* - TeammateTool will not work (it uses tmux for pane management)
|
||||
* - Bash commands will run without tmux isolation
|
||||
*/
|
||||
export async function checkTmuxAvailable(): Promise<boolean> {
|
||||
if (!tmuxAvailabilityChecked) {
|
||||
const result =
|
||||
getPlatform() === 'windows'
|
||||
? await execFileNoThrow('wsl', ['-e', TMUX_COMMAND, '-V'], {
|
||||
env: { ...process.env, WSL_UTF8: '1' },
|
||||
useCwd: false,
|
||||
})
|
||||
: await execFileNoThrow('which', [TMUX_COMMAND], {
|
||||
useCwd: false,
|
||||
})
|
||||
tmuxAvailable = result.code === 0
|
||||
if (!tmuxAvailable) {
|
||||
logForDebugging(
|
||||
`[Socket] tmux is not installed. The Tmux tool and Teammate tool will not be available.`,
|
||||
)
|
||||
}
|
||||
tmuxAvailabilityChecked = true
|
||||
}
|
||||
return tmuxAvailable
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the cached tmux availability status.
|
||||
* Returns false if availability hasn't been checked yet.
|
||||
* Use checkTmuxAvailable() to perform the check.
|
||||
*/
|
||||
export function isTmuxAvailable(): boolean {
|
||||
return tmuxAvailabilityChecked && tmuxAvailable
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks that the Tmux tool has been used at least once.
|
||||
* Called by TungstenTool before initialization.
|
||||
* After this is called, Shell.ts will initialize the socket for subsequent Bash commands.
|
||||
*/
|
||||
export function markTmuxToolUsed(): void {
|
||||
tmuxToolUsed = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the Tmux tool has been used at least once.
|
||||
* Used by Shell.ts to decide whether to initialize the socket.
|
||||
*/
|
||||
export function hasTmuxToolBeenUsed(): boolean {
|
||||
return tmuxToolUsed
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures the socket is initialized with a tmux session.
|
||||
* Called by Shell.ts when the Tmux tool has been used or the command includes "tmux".
|
||||
* Safe to call multiple times; will only initialize once.
|
||||
*
|
||||
* If tmux is not installed, this function returns gracefully without
|
||||
* initializing the socket. getClaudeTmuxEnv() will return null, and
|
||||
* Bash commands will run without tmux isolation.
|
||||
*/
|
||||
export async function ensureSocketInitialized(): Promise<void> {
|
||||
// Already initialized
|
||||
if (isSocketInitialized()) {
|
||||
return
|
||||
}
|
||||
|
||||
// Check if tmux is available before trying to use it
|
||||
const available = await checkTmuxAvailable()
|
||||
if (!available) {
|
||||
return
|
||||
}
|
||||
|
||||
// Another call is already initializing - wait for it but don't propagate errors
|
||||
// The original caller handles the error and sets up graceful degradation
|
||||
if (isInitializing && initPromise) {
|
||||
try {
|
||||
await initPromise
|
||||
} catch {
|
||||
// Ignore - the original caller logs the error
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
isInitializing = true
|
||||
initPromise = doInitialize()
|
||||
|
||||
try {
|
||||
await initPromise
|
||||
} catch (error) {
|
||||
// Log error but don't throw - graceful degradation
|
||||
const err = toError(error)
|
||||
logError(err)
|
||||
logForDebugging(
|
||||
`[Socket] Failed to initialize tmux socket: ${err.message}. Tmux isolation will be disabled.`,
|
||||
)
|
||||
} finally {
|
||||
isInitializing = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Kills the tmux server for Claude's isolated socket.
|
||||
* Called during graceful shutdown to clean up resources.
|
||||
*/
|
||||
async function killTmuxServer(): Promise<void> {
|
||||
const socket = getClaudeSocketName()
|
||||
logForDebugging(`[Socket] Killing tmux server for socket: ${socket}`)
|
||||
|
||||
const result = await execTmux(['-L', socket, 'kill-server'])
|
||||
|
||||
if (result.code === 0) {
|
||||
logForDebugging(`[Socket] Successfully killed tmux server`)
|
||||
} else {
|
||||
// Server may already be dead, which is fine
|
||||
logForDebugging(
|
||||
`[Socket] Failed to kill tmux server (exit ${result.code}): ${result.stderr}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async function doInitialize(): Promise<void> {
|
||||
const socket = getClaudeSocketName()
|
||||
|
||||
// Create a new session with our custom socket
|
||||
// Pass CLAUDE_CODE_SKIP_PROMPT_HISTORY via -e so it's set in the initial shell environment
|
||||
//
|
||||
// On Windows, the tmux server inherits WSL_INTEROP from the short-lived
|
||||
// wsl.exe that spawns it; once `new-session -d` detaches and wsl.exe exits,
|
||||
// that socket stops servicing requests. Any cli.exe launched inside the pane
|
||||
// then hits `UtilAcceptVsock: accept4 failed 110` (ETIMEDOUT). Observed on
|
||||
// 2026-03-25: server PID 386 (started alongside /init at WSL boot) inherited
|
||||
// /run/WSL/383_interop — init's own socket, which listens but doesn't handle
|
||||
// interop. /run/WSL/1_interop is a stable symlink WSL maintains to the real
|
||||
// handler; pin the server to it so interop survives the spawning wsl.exe.
|
||||
const result = await execTmux([
|
||||
'-L',
|
||||
socket,
|
||||
'new-session',
|
||||
'-d',
|
||||
'-s',
|
||||
'base',
|
||||
'-e',
|
||||
'CLAUDE_CODE_SKIP_PROMPT_HISTORY=true',
|
||||
...(getPlatform() === 'windows'
|
||||
? ['-e', 'WSL_INTEROP=/run/WSL/1_interop']
|
||||
: []),
|
||||
])
|
||||
|
||||
if (result.code !== 0) {
|
||||
// Session might already exist from a previous run with same PID (unlikely but possible)
|
||||
// Check if the session exists
|
||||
const checkResult = await execTmux([
|
||||
'-L',
|
||||
socket,
|
||||
'has-session',
|
||||
'-t',
|
||||
'base',
|
||||
])
|
||||
if (checkResult.code !== 0) {
|
||||
throw new Error(
|
||||
`Failed to create tmux session on socket ${socket}: ${result.stderr}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Register cleanup to kill the tmux server on exit
|
||||
registerCleanup(killTmuxServer)
|
||||
|
||||
// Set CLAUDE_CODE_SKIP_PROMPT_HISTORY in the tmux GLOBAL environment (-g).
|
||||
// Without -g this would only apply to the 'base' session, and new sessions
|
||||
// created by TungstenTool (e.g. 'test', 'verify') would not inherit it.
|
||||
// Any Claude Code instance spawned on this socket will inherit this env var,
|
||||
// preventing test/verification sessions from polluting the user's real
|
||||
// command history and --resume session list.
|
||||
await execTmux([
|
||||
'-L',
|
||||
socket,
|
||||
'set-environment',
|
||||
'-g',
|
||||
'CLAUDE_CODE_SKIP_PROMPT_HISTORY',
|
||||
'true',
|
||||
])
|
||||
|
||||
// Same WSL_INTEROP pin as the new-session -e above, but in the GLOBAL env
|
||||
// so sessions created by TungstenTool inherit it too. The -e on new-session
|
||||
// only covers the base session's initial shell; a later `new-session -s cc`
|
||||
// inherits the SERVER's env, which still holds the stale socket from the
|
||||
// wsl.exe that spawned it.
|
||||
if (getPlatform() === 'windows') {
|
||||
await execTmux([
|
||||
'-L',
|
||||
socket,
|
||||
'set-environment',
|
||||
'-g',
|
||||
'WSL_INTEROP',
|
||||
'/run/WSL/1_interop',
|
||||
])
|
||||
}
|
||||
|
||||
// Get the socket path and server PID
|
||||
const infoResult = await execTmux([
|
||||
'-L',
|
||||
socket,
|
||||
'display-message',
|
||||
'-p',
|
||||
'#{socket_path},#{pid}',
|
||||
])
|
||||
|
||||
if (infoResult.code === 0) {
|
||||
const [path, pidStr] = infoResult.stdout.trim().split(',')
|
||||
if (path && pidStr) {
|
||||
const pid = parseInt(pidStr, 10)
|
||||
if (!isNaN(pid)) {
|
||||
setClaudeSocketInfo(path, pid)
|
||||
return
|
||||
}
|
||||
}
|
||||
// Parsing failed - log and fall through to fallback
|
||||
logForDebugging(
|
||||
`[Socket] Failed to parse socket info from tmux output: "${infoResult.stdout.trim()}". Using fallback path.`,
|
||||
)
|
||||
} else {
|
||||
// Command failed - log and fall through to fallback
|
||||
logForDebugging(
|
||||
`[Socket] Failed to get socket info via display-message (exit ${infoResult.code}): ${infoResult.stderr}. Using fallback path.`,
|
||||
)
|
||||
}
|
||||
|
||||
// Fallback: construct the socket path from standard tmux location
|
||||
// tmux sockets are typically at $TMPDIR/tmux-<UID>/<socket_name> (or /tmp/tmux-<UID>/ if TMPDIR is not set)
|
||||
// On Windows this path is inside WSL, so always use POSIX separators.
|
||||
// process.getuid() is undefined on Windows; WSL default user is root (uid 0) in CI.
|
||||
const uid = process.getuid?.() ?? 0
|
||||
const baseTmpDir = process.env.TMPDIR || '/tmp'
|
||||
const fallbackPath = posix.join(baseTmpDir, `tmux-${uid}`, socket)
|
||||
|
||||
// Get server PID separately
|
||||
const pidResult = await execTmux([
|
||||
'-L',
|
||||
socket,
|
||||
'display-message',
|
||||
'-p',
|
||||
'#{pid}',
|
||||
])
|
||||
|
||||
if (pidResult.code === 0) {
|
||||
const pid = parseInt(pidResult.stdout.trim(), 10)
|
||||
if (!isNaN(pid)) {
|
||||
logForDebugging(
|
||||
`[Socket] Using fallback socket path: ${fallbackPath} (server PID: ${pid})`,
|
||||
)
|
||||
setClaudeSocketInfo(fallbackPath, pid)
|
||||
return
|
||||
}
|
||||
// PID parsing failed
|
||||
logForDebugging(
|
||||
`[Socket] Failed to parse server PID from tmux output: "${pidResult.stdout.trim()}"`,
|
||||
)
|
||||
} else {
|
||||
logForDebugging(
|
||||
`[Socket] Failed to get server PID (exit ${pidResult.code}): ${pidResult.stderr}`,
|
||||
)
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Failed to get socket info for ${socket}: primary="${infoResult.stderr}", fallback="${pidResult.stderr}"`,
|
||||
)
|
||||
}
|
||||
|
||||
// For testing purposes
|
||||
export function resetSocketState(): void {
|
||||
socketName = null
|
||||
socketPath = null
|
||||
serverPid = null
|
||||
isInitializing = false
|
||||
initPromise = null
|
||||
tmuxAvailabilityChecked = false
|
||||
tmuxAvailable = false
|
||||
tmuxToolUsed = false
|
||||
}
|
||||
Reference in New Issue
Block a user