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

342 lines
12 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 { 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<unknown>;
/**
* 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<boolean> {
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<FsAdapter, "readdir" | "exists" | "homedir"> & {
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;
}