103 lines
3.3 KiB
TypeScript
103 lines
3.3 KiB
TypeScript
/**
|
|
* 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;
|
|
}
|