/** * Shared tmux utilities for terminal servers. * * Extracted from direct-terminal-ws.ts and terminal-websocket.ts * so the logic can be properly unit tested. */ import { execFileSync } from "node:child_process"; /** Session ID validation regex — alphanumeric, hyphens, underscores only */ export const SESSION_ID_PATTERN = /^[a-zA-Z0-9_-]+$/; /** Hash prefix pattern — 12-char lowercase hex, as generated by generateConfigHash */ const HASH_PREFIX_PATTERN = /^[a-f0-9]{12}-/; /** * Validate a session ID format. * Prevents path traversal, shell injection, and other attacks. */ export function validateSessionId(sessionId: string): boolean { return SESSION_ID_PATTERN.test(sessionId); } /** * Find full path to tmux binary. * * Checks common installation locations because node-pty's posix_spawnp * doesn't reliably inherit PATH, and some Node.js environments (e.g., * launched from GUI apps) have minimal PATH. * * @param execFn - Injectable execFileSync for testing. Defaults to child_process.execFileSync. */ export function findTmux( execFn: typeof execFileSync = execFileSync, ): string { const candidates = [ "/opt/homebrew/bin/tmux", // macOS ARM (Homebrew) "/usr/local/bin/tmux", // macOS Intel (Homebrew) "/usr/bin/tmux", // Linux ]; for (const p of candidates) { try { execFn(p, ["-V"], { timeout: 5000 }); return p; } catch { continue; } } return "tmux"; // Fall back to bare name } /** * Resolve a user-facing session ID to its actual tmux session name. * * The hash-based architecture prefixes tmux session names with a config hash * (e.g., "8474d6f29887-ao-15" for user-facing "ao-15"). This function: * * 1. Tries exact match first using tmux's `=` prefix syntax to prevent * prefix matching (where "ao-1" would incorrectly match "ao-15"). * 2. Falls back to listing all sessions and finding one with a 12-char hex * prefix followed by the exact session ID (e.g., `{12-hex}-{sessionId}`). * * @param sessionId - User-facing session ID (e.g., "ao-15") * @param tmuxPath - Full path to tmux binary * @param execFn - Injectable execFileSync for testing. Defaults to child_process.execFileSync. * @returns The actual tmux session name, or null if not found */ export function resolveTmuxSession( sessionId: string, tmuxPath: string, execFn: typeof execFileSync = execFileSync, ): string | null { // Try exact match first using = prefix for exact matching (e.g., "ao-orchestrator") // Without =, tmux uses prefix matching: "ao-1" would match "ao-15" try { execFn(tmuxPath, ["has-session", "-t", `=${sessionId}`], { timeout: 5000 }); return sessionId; } catch { // Not an exact match } // Search for hash-prefixed tmux session (e.g., "8474d6f29887-ao-15" for "ao-15") // Validate the 12-char hex prefix to avoid ambiguous suffix matches where // "hash-my-app-1" could falsely match a lookup for "app-1". try { const output = execFn(tmuxPath, ["list-sessions", "-F", "#{session_name}"], { timeout: 5000, encoding: "utf8", }) as string; const sessions = output.split("\n").filter(Boolean); const match = sessions.find((s) => HASH_PREFIX_PATTERN.test(s) && s.substring(13) === sessionId, ); if (match) { return match; } } catch { // tmux not running or no sessions } return null; }