342 lines
12 KiB
TypeScript
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;
|
|
}
|