/** * 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 { execFile, execFileSync } from "node:child_process"; import { readdirSync, existsSync, readFileSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; import { promisify } from "node:util"; import { isWindows } from "@aoagents/ao-core"; const execFileAsync = promisify(execFile); /** 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}-/; /** * StorageKey pattern — either bare 12-hex hash or `{hash}-{projectName}` * wrapped form. The wrapped suffix comes from `basename(projectPath)` on * disk so it can contain any character a filesystem path component allows * (spaces, unicode, etc.); we only require that something non-empty * follows the `-` separator. Security: storageKey is passed to * `execFileSync` which bypasses the shell, so arbitrary characters are * safe in the argv. */ const STORAGE_KEY_PATTERN = /^[a-f0-9]{12}(-.+)?$/; /** Filesystem accessors injected for testability. */ interface FsAdapter { readdir: (path: string) => string[]; exists: (path: string) => boolean; homedir: () => string; } const defaultFs: FsAdapter = { // Only return subdirectory names. `readdirSync` without withFileTypes // includes plain files, so a stray file like `aabbccddeef0` would pass // STORAGE_KEY_PATTERN and trigger an unnecessary existsSync probe. readdir: (p) => readdirSync(p, { withFileTypes: true }) .filter((e) => e.isDirectory()) .map((e) => e.name), exists: (p) => existsSync(p), homedir, }; /** * Find every storageKey that owns a given sessionId by scanning the AO * base directory for projects whose `sessions/{sessionId}` file exists. * * This is the authoritative disambiguation step: tmux names alone are * ambiguous when storageKey can take either the bare-hash or wrapped * `{hash}-{projectName}` form. The on-disk session record is unique per * storageKey so it tells us exactly which tmux name to expect. * * Returns all candidates (not just the first). Multiple projects can * share a sessionId like `app-1`, and the caller must probe each until * it finds a live tmux session — otherwise a stale metadata dir from * one project could shadow the live session of another. */ function findStorageKeysForSession( sessionId: string, fs: FsAdapter, projectId?: string, ): string[] { const aoBase = join(fs.homedir(), ".agent-orchestrator"); let entries: string[]; try { entries = fs.readdir(aoBase); } catch { return []; } const matches: string[] = []; const projectMatches: string[] = []; for (const entry of entries) { if (!STORAGE_KEY_PATTERN.test(entry)) continue; const sessionFile = join(aoBase, entry, "sessions", sessionId); if (fs.exists(sessionFile)) { const unwrappedProjectId = entry.slice(13); // Strip "{hash}-" prefix when present. if (projectId && (entry === projectId || unwrappedProjectId === projectId)) { projectMatches.push(entry); } else { matches.push(entry); } } } return [...projectMatches, ...matches]; } /** * 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 (isWindows()) 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 } /** Async exec signature used by `tmuxHasSession` (and injectable for tests). */ export type ExecFileAsync = ( file: string, args: readonly string[], options: { timeout: number }, ) => Promise; /** * Check whether a tmux session with the given name exists. * * Uses `=` exact-match prefix so the lookup never falls back to tmux's * default prefix matching (where "ao-1" would match "ao-15"). The caller * must already have the canonical tmux session name (typically the value * returned by `resolveTmuxSession`). * * Async: this runs from inside node-pty's `onExit` callback on every agent * exit, and the WebSocket server is single-threaded. A synchronous * `execFileSync` here would block the event loop — and every WebSocket * connection, HTTP request, and in-flight terminal — for up to the 5 s * subprocess timeout when tmux is slow to respond. Use the promisified * `execFile` form instead. * * @returns true if the session exists, false otherwise (including tmux * not running, no sessions, or any unexpected error) */ export async function tmuxHasSession( tmuxPath: string | null, tmuxSessionName: string, execFn: ExecFileAsync = execFileAsync as unknown as ExecFileAsync, ): Promise { if (!tmuxPath) return false; try { await execFn(tmuxPath, ["has-session", "-t", `=${tmuxSessionName}`], { timeout: 5000 }); return true; } catch { return false; } } /** * Resolve a user-facing session ID to its actual tmux session name. * * ao-core names tmux sessions as `{storageKey}-{sessionId}`, where * storageKey is either `{12-hex}` (bare hash) or `{12-hex}-{projectName}` * (legacy wrapped format). This function: * * 1. Tries exact match using tmux's `=` prefix syntax to prevent * prefix matching (where "ao-1" would incorrectly match "ao-15"). * 2. Looks up the storageKey owning this sessionId on disk (under * `~/.agent-orchestrator/{storageKey}/sessions/{sessionId}`) and asks * tmux whether the exact `{storageKey}-{sessionId}` session exists. * The on-disk check is authoritative — it avoids ambiguous suffix * matches where a bare session like `{hash}-my-app-1` could be * mistaken for a lookup of `app-1`. * 3. Falls back to listing sessions and matching a hash-prefixed name * whose remainder equals the sessionId (bare-hash only), so behavior * stays correct even if the on-disk session record is absent. * * @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. * @param fs - Injectable filesystem adapter for testing. * @returns The actual tmux session name, or null if not found */ export function resolveTmuxSession( sessionId: string, tmuxPath: string | null, execFn: typeof execFileSync = execFileSync, fs: FsAdapter = defaultFs, projectId?: string, ): 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 } // Authoritative path: find candidate storageKeys on disk, then verify // each exact tmux session name with has-session. This is unambiguous // even when the storageKey is wrapped (`{hash}-{projectName}`). Walk // every candidate so a stale metadata dir in one project can't shadow // the live session of another project with the same sessionId. for (const storageKey of findStorageKeysForSession(sessionId, fs, projectId)) { const tmuxName = `${storageKey}-${sessionId}`; try { execFn(tmuxPath, ["has-session", "-t", `=${tmuxName}`], { timeout: 5000 }); return tmuxName; } catch { // Session dir exists but tmux session doesn't — try next candidate } } // Fallback: list sessions and match the bare-hash form only. We // intentionally do NOT match by trailing suffix here — that would cause // `app-1` to falsely resolve a distinct session `{hash}-my-app-1`. If a // wrapped-storageKey session isn't findable on disk above, it's safer // to return null than to guess. 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. * * V2 layout (current): JSON metadata at * `~/.agent-orchestrator/projects/{projectId}/sessions/{sessionId}.json` * with `runtimeHandle.data.pipePath` as a top-level field. * * V1 layout (legacy fallback): line-delimited key=value at * `~/.agent-orchestrator/{storageKey}/sessions/{sessionId}` where * storageKey is bare 12-hex or `{hash}-{projectName}`. Kept so users * who haven't run `ao migrate-storage` still see live sessions. * * When `projectId` is provided, only that project's metadata file is read. * Without it (legacy callers), walks all projects and returns the first * matching pipePath — which can collide when two projects share a sessionId. * * @returns Full pipe path (e.g., "\\\\.\\pipe\\ao-pty-win1-orchestrator"), or null */ export function resolvePipePath( sessionId: string, projectId?: string, fs: Pick & { readFile?: (path: string) => string; } = defaultFs, ): string | null { if (!isWindows()) return null; const readFile = fs.readFile ?? ((p: string) => readFileSync(p, "utf8")); const aoBase = join(fs.homedir(), ".agent-orchestrator"); const readPipeFromV2 = (project: string): string | null => { const sessionFile = join(aoBase, "projects", project, "sessions", `${sessionId}.json`); if (!fs.exists(sessionFile)) return null; try { const meta = JSON.parse(readFile(sessionFile)) as { runtimeHandle?: { data?: { pipePath?: string } }; }; const pipePath = meta.runtimeHandle?.data?.pipePath; return typeof pipePath === "string" && pipePath.length > 0 ? pipePath : null; } catch { return null; } }; // V2: prefer the caller's projectId when provided; otherwise walk all projects const projectsDir = join(aoBase, "projects"); if (projectId) { const pipe = readPipeFromV2(projectId); if (pipe) return pipe; } else if (fs.exists(projectsDir)) { let projects: string[]; try { projects = fs.readdir(projectsDir); } catch { projects = []; } for (const project of projects) { const pipe = readPipeFromV2(project); if (pipe) return pipe; } } // V1 fallback: line-delimited key=value under {storageKey}/sessions/{sessionId} for (const storageKey of findStorageKeysForSession(sessionId, { readdir: fs.readdir, exists: fs.exists, homedir: fs.homedir, })) { const sessionFile = join(aoBase, storageKey, "sessions", sessionId); let content: string; try { content = readFile(sessionFile); } catch { continue; } const match = content.match(/^runtimeHandle=(.+)$/m); if (!match) continue; try { const handle = JSON.parse(match[1]) as { data?: { pipePath?: string } }; const pipePath = handle.data?.pipePath; if (pipePath && pipePath.length > 0) return pipePath; } catch { continue; } } return null; }