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

253 lines
9.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 { execFileSync } from "node:child_process";
import { readdirSync, existsSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } 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}-/;
/**
* 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): string[] {
const aoBase = join(fs.homedir(), ".agent-orchestrator");
let entries: string[];
try {
entries = fs.readdir(aoBase);
} catch {
return [];
}
const matches: string[] = [];
for (const entry of entries) {
if (!STORAGE_KEY_PATTERN.test(entry)) continue;
const sessionFile = join(aoBase, entry, "sessions", sessionId);
if (fs.exists(sessionFile)) {
matches.push(entry);
}
}
return 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 (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.
*
* 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,
): 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)) {
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.
*
* Reads `runtimeHandle.data.pipePath` from the on-disk session metadata at
* `~/.agent-orchestrator/{storageKey}/sessions/{sessionId}`. The metadata is
* the single source of truth — recomputing the hash here is fragile because
* the storageKey can be either a bare 12-hex hash or the wrapped
* `{hash}-{projectName}` form, and on Windows the path-based hash function
* produces a different value than `deriveStorageKey` once paths contain
* drive letters and backslashes.
*
* If multiple storageKeys own the sessionId (rare — same id across
* projects), we walk candidates and return the first one whose metadata
* carries a parseable pipePath.
*
* @returns Full pipe path (e.g., "\\\\.\\pipe\\ao-pty-bac26c5725e3-tr-1"), or null
*/
export function resolvePipePath(
sessionId: string,
fs: Pick<FsAdapter, "readdir" | "exists" | "homedir"> & {
readFile?: (path: string) => string;
} = defaultFs,
): string | null {
if (process.platform !== "win32") return null;
const readFile = fs.readFile ?? ((p: string) => readFileSync(p, "utf8"));
for (const storageKey of findStorageKeysForSession(sessionId, {
readdir: fs.readdir,
exists: fs.exists,
homedir: fs.homedir,
})) {
const sessionFile = join(fs.homedir(), ".agent-orchestrator", storageKey, "sessions", sessionId);
let content: string;
try {
content = readFile(sessionFile);
} catch {
continue;
}
// Metadata is line-delimited key=value. The runtimeHandle value is a JSON blob.
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;
}