147 lines
5.0 KiB
TypeScript
147 lines
5.0 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 { createHash } from "node:crypto";
|
|
import { execFileSync } from "node:child_process";
|
|
import { realpathSync } from "node:fs";
|
|
import { dirname } from "node:path";
|
|
|
|
/** 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 | null {
|
|
if (process.platform === "win32") return null;
|
|
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 | null,
|
|
execFn: typeof execFileSync = execFileSync,
|
|
): string | null {
|
|
if (!tmuxPath) return 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;
|
|
}
|
|
|
|
/**
|
|
* Resolve a user-facing session ID to its Windows named pipe path.
|
|
*
|
|
* The runtime handle uses hash-prefixed IDs (e.g., "bac26c5725e3-tr-1")
|
|
* for named pipes, but the dashboard sends short IDs (e.g., "tr-1").
|
|
*
|
|
* Uses a cached hash prefix after the first successful resolution to avoid
|
|
* scanning the entire pipe directory (which can contain thousands of entries
|
|
* and block the event loop).
|
|
*
|
|
* @returns Full pipe path (e.g., "\\\\.\\pipe\\ao-pty-bac26c5725e3-tr-1"), or null
|
|
*/
|
|
/**
|
|
* Resolve a user-facing session ID to its Windows named pipe path.
|
|
*
|
|
* Computes the config hash directly from AO_CONFIG_PATH (set by ao start)
|
|
* using the same algorithm as generateConfigHash in core. No pipe directory
|
|
* scanning needed — instant resolution.
|
|
*
|
|
* @returns Full pipe path (e.g., "\\\\.\\pipe\\ao-pty-bac26c5725e3-tr-1"), or null
|
|
*/
|
|
export function resolvePipePath(sessionId: string): string | null {
|
|
if (process.platform !== "win32") return null;
|
|
|
|
const configPath = process.env["AO_CONFIG_PATH"];
|
|
if (!configPath) return null;
|
|
|
|
// Compute hash the same way as core/paths.ts generateConfigHash():
|
|
// SHA-256 of the resolved config directory, first 12 hex chars.
|
|
try {
|
|
const resolved = realpathSync(configPath);
|
|
const configDir = dirname(resolved);
|
|
const hash = createHash("sha256").update(configDir).digest("hex").slice(0, 12);
|
|
return `\\\\.\\pipe\\ao-pty-${hash}-${sessionId}`;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|