agent-orchestrator/packages/web/server/tmux-utils.ts

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;
}