feat: convert tmux monitoring into a plugin (B10)

This commit is contained in:
LukeGus committed 2026-09-23 22:42:56 -05:00
1 parent 79d77678c9
commit 708eedb861
145 files changed
+30308 -4458

No files matched your search

-73
View File
@@ -1950,79 +1950,6 @@ const migrateSchema = () => {
}
}
// --- tmux-monitor begin ---
try {
sqlite.prepare("SELECT id FROM tmux_session_tags LIMIT 1").get();
} catch {
try {
sqlite.exec(`
CREATE TABLE IF NOT EXISTS tmux_session_tags (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
host_id INTEGER NOT NULL,
session_name TEXT NOT NULL,
tag TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE
);
`);
} catch (createError) {
databaseLogger.warn("Failed to create tmux_session_tags table", {
operation: "schema_migration",
error: createError,
});
}
}
// Rebuild pre-release tables created without the host FK so deleting a
// host cascades to its tags (SQLite cannot add a FK via ALTER TABLE).
try {
const tagFks = sqlite
.prepare("PRAGMA foreign_key_list(tmux_session_tags)")
.all() as Array<{ table: string; from: string }>;
const hasHostFk = tagFks.some(
(fk) => fk.from === "host_id" && fk.table === "ssh_data",
);
if (!hasHostFk) {
sqlite.exec(`
BEGIN;
CREATE TABLE tmux_session_tags_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
host_id INTEGER NOT NULL,
session_name TEXT NOT NULL,
tag TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE
);
INSERT INTO tmux_session_tags_new (id, user_id, host_id, session_name, tag, created_at)
SELECT id, user_id, host_id, session_name, tag, created_at
FROM tmux_session_tags
WHERE user_id IN (SELECT id FROM users)
AND host_id IN (SELECT id FROM ssh_data);
DROP TABLE tmux_session_tags;
ALTER TABLE tmux_session_tags_new RENAME TO tmux_session_tags;
COMMIT;
`);
databaseLogger.info("Rebuilt tmux_session_tags with host FK", {
operation: "schema_migration",
});
}
} catch (rebuildError) {
try {
sqlite.exec("ROLLBACK;");
} catch {
// no transaction open
}
databaseLogger.warn("Failed to add host FK to tmux_session_tags", {
operation: "schema_migration",
error: rebuildError,
});
}
// --- tmux-monitor end ---
try {
const existingRoles = sqlite.prepare("SELECT name, is_system FROM roles").all() as Array<{ name: string; is_system: number }>;
-17
View File
@@ -1204,23 +1204,6 @@ export const termixIdentityCa = mysqlTable("termix_identity_ca", {
});
// --- termix-id end ---
// --- tmux-monitor begin ---
export const tmuxSessionTags = mysqlTable("tmux_session_tags", {
id: int("id").autoincrement().primaryKey(),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
hostId: int("host_id")
.notNull()
.references(() => hosts.id, { onDelete: "cascade" }),
sessionName: text("session_name").notNull(),
tag: text("tag").notNull(),
createdAt: varchar("created_at", { length: 255 })
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
});
// --- tmux-monitor end ---
// --- metrics-history begin ---
export const hostMetricsHistory = mysqlTable("host_metrics_history", {
id: int("id").autoincrement().primaryKey(),
-17
View File
@@ -1205,23 +1205,6 @@ export const termixIdentityCa = pgTable("termix_identity_ca", {
});
// --- termix-id end ---
// --- tmux-monitor begin ---
export const tmuxSessionTags = pgTable("tmux_session_tags", {
id: serial("id").primaryKey(),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
hostId: integer("host_id")
.notNull()
.references(() => hosts.id, { onDelete: "cascade" }),
sessionName: text("session_name").notNull(),
tag: text("tag").notNull(),
createdAt: varchar("created_at", { length: 255 })
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
});
// --- tmux-monitor end ---
// --- metrics-history begin ---
export const hostMetricsHistory = pgTable("host_metrics_history", {
id: serial("id").primaryKey(),
-17
View File
@@ -1203,23 +1203,6 @@ export const termixIdentityCa = sqliteTable("termix_identity_ca", {
});
// --- termix-id end ---
// --- tmux-monitor begin ---
export const tmuxSessionTags = sqliteTable("tmux_session_tags", {
id: integer("id").primaryKey({ autoIncrement: true }),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
hostId: integer("host_id")
.notNull()
.references(() => hosts.id, { onDelete: "cascade" }),
sessionName: text("session_name").notNull(),
tag: text("tag").notNull(),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
});
// --- tmux-monitor end ---
// --- metrics-history begin ---
export const hostMetricsHistory = sqliteTable("host_metrics_history", {
id: integer("id").primaryKey({ autoIncrement: true }),
+1 -8
View File
@@ -53,7 +53,6 @@ import { SyncTombstoneRepository } from "./sync-tombstone-repository.js";
import { SsoProviderRepository } from "./sso-provider-repository.js";
import { TermixIdentityCaRepository } from "./termix-identity-ca-repository.js";
import { TermixIdentityRepository } from "./termix-identity-repository.js";
import { TmuxSessionTagRepository } from "./tmux-session-tag-repository.js";
import { TrustedDeviceRepository } from "./trusted-device-repository.js";
import { UserDataExportRepository } from "./user-data-export-repository.js";
import { UserPreferenceRepository } from "./user-preference-repository.js";
@@ -482,14 +481,8 @@ export function createCurrentTermixIdentityRepository(): TermixIdentityRepositor
);
}
export function createCurrentTmuxSessionTagRepository(): TmuxSessionTagRepository {
return new TmuxSessionTagRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook("tmux_session_tag_repository_write"),
);
}
// transfer_recent moved to the file-manager plugin.
// tmux_session_tags moved to the tmux-monitor plugin.
export function createCurrentTrustedDeviceRepository(): TrustedDeviceRepository {
return new TrustedDeviceRepository(
@@ -1,136 +0,0 @@
import { and, eq } from "drizzle-orm";
import { tmuxSessionTags } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { rowsAffected } from "./mutation-result.js";
export type TmuxSessionTagRecord = typeof tmuxSessionTags.$inferSelect;
export interface TmuxSessionTagInput {
userId: string;
hostId: number;
sessionName: string;
tag: string;
}
export class TmuxSessionTagRepository {
constructor(
private readonly context: DatabaseContext,
private readonly onWrite?: () => void | Promise<void>,
) {}
async listByUserAndHost(
userId: string,
hostId: number,
): Promise<Map<string, string[]>> {
const rows = await this.context.drizzle
.select()
.from(tmuxSessionTags)
.where(
and(
eq(tmuxSessionTags.userId, userId),
eq(tmuxSessionTags.hostId, hostId),
),
);
const bySession = new Map<string, string[]>();
for (const row of rows) {
const tags = bySession.get(row.sessionName) ?? [];
tags.push(row.tag);
bySession.set(row.sessionName, tags);
}
return bySession;
}
async renameSessionForHost(
hostId: number,
sessionName: string,
newSessionName: string,
): Promise<number> {
const result = await this.context.drizzle
.update(tmuxSessionTags)
.set({ sessionName: newSessionName })
.where(
and(
eq(tmuxSessionTags.hostId, hostId),
eq(tmuxSessionTags.sessionName, sessionName),
),
);
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rowsAffected(result);
}
async deleteSessionForHost(
hostId: number,
sessionName: string,
): Promise<number> {
const result = await this.context.drizzle
.delete(tmuxSessionTags)
.where(
and(
eq(tmuxSessionTags.hostId, hostId),
eq(tmuxSessionTags.sessionName, sessionName),
),
);
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rowsAffected(result);
}
async replaceForUserHostSession(
userId: string,
hostId: number,
sessionName: string,
tags: string[],
): Promise<number> {
const result = await this.context.drizzle
.delete(tmuxSessionTags)
.where(
and(
eq(tmuxSessionTags.userId, userId),
eq(tmuxSessionTags.hostId, hostId),
eq(tmuxSessionTags.sessionName, sessionName),
),
);
if (tags.length > 0) {
await this.context.drizzle.insert(tmuxSessionTags).values(
tags.map((tag) => ({
userId,
hostId,
sessionName,
tag,
})),
);
}
const changedRows = rowsAffected(result) + tags.length;
if (changedRows > 0) {
await this.afterWrite();
}
return changedRows;
}
async deleteByUserId(userId: string): Promise<number> {
const result = await this.context.drizzle
.delete(tmuxSessionTags)
.where(eq(tmuxSessionTags.userId, userId));
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rowsAffected(result);
}
private async afterWrite(): Promise<void> {
await this.onWrite?.();
}
}
@@ -28,7 +28,6 @@ import {
createCurrentSshCredentialUsageRepository,
createCurrentTermixIdentityCaRepository,
createCurrentTermixIdentityRepository,
createCurrentTmuxSessionTagRepository,
createCurrentTrustedDeviceRepository,
createCurrentUserPreferenceRepository,
createCurrentUserRepository,
@@ -112,7 +111,6 @@ export async function deleteUserAndRelatedData(
await createCurrentSecretSourceRepository().deleteByUserId(userId);
await createCurrentTermixIdentityCaRepository().deleteByUserId(userId);
await createCurrentTermixIdentityRepository().deleteByUserId(userId);
await createCurrentTmuxSessionTagRepository().deleteByUserId(userId);
await createCurrentOpenTabRepository().deleteByUserId(userId);
await createCurrentUserPreferenceRepository().deleteByUserId(userId);
-190
View File
@@ -1,190 +0,0 @@
import type { Client, ClientChannel } from "ssh2";
import { sshLogger } from "../../utils/logger.js";
export interface TmuxSessionInfo {
name: string;
created: number;
lastActivity: number;
windows: number;
attachedClients: number;
}
export interface TmuxDetectionResult {
available: boolean;
sessions: TmuxSessionInfo[];
}
const TMUX_PATH_DIRS = [
"/opt/homebrew/bin",
"/usr/local/bin",
"/opt/bin",
"/usr/pkg/bin",
];
export function withTmuxPath(command: string): string {
const script = `PATH=${TMUX_PATH_DIRS.join(":")}:"$PATH"; export PATH; ${command}`;
return `/bin/sh -c ${shellEscape(script)}`;
}
export function tmuxCommand(args: string): string {
return withTmuxPath(`tmux -u ${args}`);
}
/**
* Run a command on the remote host via a separate exec channel.
* Returns stdout as a string. Does not pollute the interactive shell.
*/
export function execCommand(conn: Client, command: string): Promise<string> {
return new Promise((resolve, reject) => {
conn.exec(command, (err, stream) => {
if (err) {
reject(err);
return;
}
let stdout = "";
let stderr = "";
stream.on("data", (data: Buffer) => {
stdout += data.toString("utf-8");
});
stream.stderr.on("data", (data: Buffer) => {
stderr += data.toString("utf-8");
});
stream.on("error", (err: Error) => {
reject(err);
});
stream.on("close", (code: number) => {
if (code !== 0 && stdout === "") {
reject(
new Error(stderr.trim() || `Command exited with code ${code}`),
);
} else {
resolve(stdout.trim());
}
});
});
});
}
/**
* Detect whether tmux is installed and list all existing sessions with details.
*/
export async function detectTmux(conn: Client): Promise<TmuxDetectionResult> {
try {
await execCommand(conn, tmuxCommand("-V"));
} catch {
return { available: false, sessions: [] };
}
let sessions: TmuxSessionInfo[] = [];
try {
const output = await execCommand(
conn,
tmuxCommand(
`list-sessions -F "#{session_name}|#{session_created}|#{session_activity}|#{session_windows}|#{session_attached}" 2>/dev/null`,
),
);
if (output) {
sessions = output
.split("\n")
.filter((line) => line.length > 0)
.map((line) => {
const [name, created, activity, windows, attached] = line.split("|");
return {
name,
created: parseInt(created, 10) || 0,
lastActivity: parseInt(activity, 10) || 0,
windows: parseInt(windows, 10) || 0,
attachedClients: parseInt(attached, 10) || 0,
};
});
}
} catch {
// tmux server not running yet -- no sessions exist
}
return { available: true, sessions };
}
// tmux options applied on every attach/create:
// - mouse on: enables mouse wheel / touch scrollback through tmux history
// - history-limit: deep scrollback buffer on the remote host
// - set-clipboard on: use OSC 52 to sync tmux selections to the client clipboard
// - mode-keys vi: use vi-style keys in copy mode
// - MouseDragEnd: stop the selection but keep it highlighted so the user can
// adjust and press Enter to copy (or drag again)
// - Enter: copy the (possibly adjusted) selection and exit copy mode
// - pane-mode-changed hook: on copy-mode entry, show a brief hint so users
// know to press Enter to copy the selection
// Using -q on set/set-hook to suppress errors on older tmux versions that don't support
// a particular option (e.g. set-clipboard on tmux < 2.5). Note: set-hook doesn't support -q.
const TMUX_OPTS =
`set -gq mouse on` +
` \\; set -gq history-limit 50000` +
` \\; set -gq set-clipboard on` +
` \\; set -gq aggressive-resize on` +
` \\; set -gq mode-keys vi` +
` \\; bind-key -T copy-mode-vi MouseDragEnd1Pane send-keys -X stop-selection` +
` \\; bind-key -T copy-mode-vi Enter send-keys -X copy-selection-and-cancel` +
` \\; set-hook -g pane-mode-changed` +
` 'if -F "#{pane_in_mode}"` +
` "display-message -d 2500 \\"Adjust selection and press Enter to copy\\""'`;
/**
* Wait for a tmux session to appear by polling via exec channel.
* Returns the session name once found, or null on timeout.
*/
export async function waitForTmuxSession(
conn: Client,
sessionName: string,
timeoutMs = 5000,
intervalMs = 100,
): Promise<string | null> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try {
await execCommand(
conn,
tmuxCommand(`has-session -t ${shellEscape(sessionName)} 2>/dev/null`),
);
return sessionName;
} catch {
// session not ready yet
}
await new Promise((resolve) => setTimeout(resolve, intervalMs));
}
return null;
}
/**
* Write tmux attach or new-session command to the interactive shell stream.
* Uses && exit so the shell only closes if tmux started successfully.
*/
export function attachOrCreateTmuxSession(
stream: ClientChannel,
existingSessionName?: string,
newSessionName?: string,
): void {
let command: string;
if (existingSessionName) {
command = `${tmuxCommand(`${TMUX_OPTS} \\; attach-session -t ${shellEscape(existingSessionName)}`)} && exit\r`;
} else {
const nameFlag = newSessionName ? ` -s ${shellEscape(newSessionName)}` : "";
command = `${tmuxCommand(`${TMUX_OPTS} \\; new-session${nameFlag}`)} && exit\r`;
}
sshLogger.info("Writing tmux command to shell", {
operation: "tmux_attach_or_create",
sessionName: existingSessionName || "(auto)",
isReattach: !!existingSessionName,
});
stream.write(command);
}
/**
* Query the name of the most recently created tmux session via exec channel.
*/
function shellEscape(s: string): string {
return "'" + s.replace(/'/g, "'\\''") + "'";
}
-767
View File
@@ -1,767 +0,0 @@
import { getErrorMessage } from "../../utils/error-message.js";
import express from "express";
import cookieParser from "cookie-parser";
import type { Client } from "ssh2";
import { connectHost, getConnectionPoolKey } from "../connect/connect-host.js";
import type { SshConnectHost } from "../connect/types.js";
import { createCorsMiddleware } from "../../utils/cors-config.js";
import { createCompressionMiddleware } from "../../utils/compression-config.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { DataCrypto } from "../../utils/data-crypto.js";
import {
createCurrentTmuxSessionTagRepository,
createCurrentUserRepository,
} from "../../database/repositories/factory.js";
import { logAudit, getRequestMeta } from "../../utils/audit-logger.js";
import { sshLogger } from "../../utils/logger.js";
import { resolveHostById, checkHostAccess } from "../host-resolver.js";
import type { HostAction } from "../../utils/permission-manager.js";
import { withConnection } from "../ssh-connection-pool.js";
import { execCommand, tmuxCommand } from "./helper.js";
import {
SEP,
parseSessions,
parseWindows,
parsePanes,
parsePsOutput,
parseGpuOutput,
buildPaneMetrics,
attachPanesToWindows,
shellEscape,
type RawPane,
type TmuxSessionSummary,
type TmuxWindow,
type PaneMetrics,
} from "./monitor-helpers.js";
import type { SSHHost, AuthenticatedRequest } from "../../../types/index.js";
import { listenOnServicePort } from "../../utils/service-listen.js";
const PANE_ID_RE = /^%\d+$/;
// tmux session names cannot contain ":" or "."; keep to a conservative
// printable subset so the name is safe as a tmux target everywhere.
const SESSION_NAME_RE = /^[A-Za-z0-9_@%+=-]{1,64}$/;
const MAX_SEARCH_PANES = 100;
const MAX_MATCHES_PER_PANE = 50;
const SEARCH_HISTORY_LINES = 2000;
const SEARCH_CONCURRENCY = 4;
interface TmuxSessionOverview extends TmuxSessionSummary {
windows: TmuxWindow[];
tags: string[];
}
// SSH connection
export function connectToHost(host: SSHHost): () => Promise<Client> {
return async () => {
const connection = await connectHost(host as unknown as SshConnectHost, {
userId: host.userId || "",
purpose: "tmux",
});
return connection.client;
};
}
function getPoolKey(host: SSHHost): string {
return getConnectionPoolKey(
"tmux-monitor",
host as unknown as SshConnectHost,
);
}
async function withHostConnection<T>(
host: SSHHost,
fn: (client: Client) => Promise<T>,
): Promise<T> {
return withConnection(getPoolKey(host), connectToHost(host), fn);
}
// tmux queries
async function tmuxAvailable(conn: Client): Promise<boolean> {
try {
await execCommand(conn, tmuxCommand("-V"));
return true;
} catch {
return false;
}
}
async function runTmuxList(conn: Client, command: string): Promise<string> {
try {
return await execCommand(conn, command);
} catch {
return ""; // tmux server not running -- no sessions
}
}
function listSessionsCmd(): string {
return tmuxCommand(
`list-sessions -F "#{session_name}${SEP}#{session_created}${SEP}#{session_activity}${SEP}#{session_attached}" 2>/dev/null`,
);
}
function listWindowsCmd(): string {
return tmuxCommand(
`list-windows -a -F "#{session_name}${SEP}#{window_index}${SEP}#{window_active}${SEP}#{window_name}" 2>/dev/null`,
);
}
function listPanesCmd(): string {
return tmuxCommand(
`list-panes -a -F "#{session_name}${SEP}#{window_index}${SEP}#{pane_id}${SEP}#{pane_index}${SEP}#{pane_pid}${SEP}#{pane_active}${SEP}#{pane_width}${SEP}#{pane_height}${SEP}#{pane_current_command}${SEP}#{pane_current_path}${SEP}#{pane_title}" 2>/dev/null`,
);
}
async function listPanesRaw(conn: Client): Promise<RawPane[]> {
return parsePanes(await runTmuxList(conn, listPanesCmd()));
}
async function fetchSessionTags(
userId: string,
hostId: number,
): Promise<Map<string, string[]>> {
return createCurrentTmuxSessionTagRepository().listByUserAndHost(
userId,
hostId,
);
}
async function collectPaneMetrics(
conn: Client,
panes: RawPane[],
): Promise<PaneMetrics[]> {
let psOutput = "";
try {
psOutput = await execCommand(
conn,
"ps -eo pid=,ppid=,pcpu=,pmem=,rss=,comm= 2>/dev/null",
);
} catch {
return [];
}
// GPU memory per pid (best effort; nvidia-smi may not exist)
let gpuOutput = "";
try {
gpuOutput = await execCommand(
conn,
"command -v nvidia-smi >/dev/null 2>&1 && nvidia-smi --query-compute-apps=pid,used_gpu_memory --format=csv,noheader,nounits 2>/dev/null || true",
);
} catch {
// no GPU on host
}
return buildPaneMetrics(
panes,
parsePsOutput(psOutput),
parseGpuOutput(gpuOutput),
);
}
// Express app
const app = express();
app.set("trust proxy", "loopback");
const authManager = AuthManager.getInstance();
app.use(createCompressionMiddleware());
app.use(createCorsMiddleware(["GET", "POST", "PUT", "DELETE", "OPTIONS"]));
app.use(cookieParser());
app.use(express.json({ limit: "1mb" }));
app.use((_req, res, next) => {
res.setHeader("Cache-Control", "no-store");
next();
});
app.use(authManager.createAuthMiddleware());
/**
* Resolve the host for a request and verify the user can access it.
* Sends the error response and returns null when access is denied.
*/
async function requireHost(
req: express.Request,
res: express.Response,
permission: HostAction = "connect",
): Promise<SSHHost | null> {
const userId = (req as unknown as AuthenticatedRequest).userId;
const hostId = parseInt(String(req.params.hostId), 10);
if (isNaN(hostId)) {
res.status(400).json({ error: "Invalid host ID" });
return null;
}
if (DataCrypto.getUserDataKey(userId) === null) {
res.status(401).json({ error: "User data is locked" });
return null;
}
let host: SSHHost | null = null;
try {
host = await resolveHostById(hostId, userId);
} catch (err) {
sshLogger.error(`Failed to resolve host ${hostId} for tmux monitor`, err);
}
if (!host) {
res.status(404).json({ error: "Host not found" });
return null;
}
const hasAccess = await checkHostAccess(
hostId,
userId,
host.userId || userId,
permission,
);
if (!hasAccess) {
res.status(403).json({ error: "Access denied" });
return null;
}
// The monitor is opt-in per host (same pattern as enableDocker in
// docker.ts): hiding the UI is not enough, the API must refuse too.
if (!host.enableTmuxMonitor) {
res
.status(403)
.json({ error: "Tmux Monitor is not enabled for this host" });
return null;
}
return host;
}
function toErrorMessage(err: unknown): string {
return getErrorMessage(err);
}
// Destructive tmux actions terminate processes on the remote host, so they
// land in the audit log like other host-level mutations (see host.ts).
async function auditTmuxAction(
req: express.Request,
host: SSHHost,
action: string,
resourceName: string,
details?: Record<string, unknown>,
): Promise<void> {
const userId = (req as unknown as AuthenticatedRequest).userId;
const { ipAddress, userAgent } = getRequestMeta(req);
let username = userId;
try {
const actor = await createCurrentUserRepository().findById(userId);
username = actor?.username ?? userId;
} catch {
// fall back to the raw user id
}
await logAudit({
userId,
username,
action,
resourceType: "host",
resourceId: String(host.id),
resourceName,
details: details ? JSON.stringify(details) : undefined,
ipAddress,
userAgent,
success: true,
});
}
// Typed error codes so the frontend can render a helpful state instead of a
// raw 500 (same pattern as SESSION_EXPIRED handling in main-axios).
type TmuxErrorCode =
"TMUX_NOT_INSTALLED" | "TMUX_NO_SERVER" | "HOST_UNREACHABLE" | "TMUX_ERROR";
function classifyTmuxError(err: unknown): TmuxErrorCode {
const msg = getErrorMessage(err, "");
if (/command not found|exited with code 127/i.test(msg))
return "TMUX_NOT_INSTALLED";
if (/no server running|lost server/i.test(msg)) return "TMUX_NO_SERVER";
if (
/timeout|timed out|econnrefused|ehostunreach|enotfound|enetunreach|econnreset|authentication|handshake|keepalive/i.test(
msg,
)
)
return "HOST_UNREACHABLE";
return "TMUX_ERROR";
}
function sendTmuxError(
res: express.Response,
err: unknown,
context: string,
hostId: number,
): void {
const code = classifyTmuxError(err);
const status = code === "TMUX_ERROR" ? 500 : 503;
const error =
code === "TMUX_NOT_INSTALLED"
? "tmux is not installed on this host"
: code === "TMUX_NO_SERVER"
? "No tmux server is running on this host"
: code === "HOST_UNREACHABLE"
? "Could not connect to the host"
: toErrorMessage(err);
sshLogger.error(`tmux ${context} failed for host ${hostId}`, err);
res.status(status).json({ error, code });
}
app.get("/tmux_monitor/:hostId/overview", async (req, res) => {
const userId = (req as unknown as AuthenticatedRequest).userId;
const host = await requireHost(req, res);
if (!host) return;
try {
const result = await withHostConnection(host, async (conn) => {
if (!(await tmuxAvailable(conn))) {
return { available: false, sessions: [] as TmuxSessionOverview[] };
}
const [sessionsOut, windowsOut, panesOut] = await Promise.all([
runTmuxList(conn, listSessionsCmd()),
runTmuxList(conn, listWindowsCmd()),
runTmuxList(conn, listPanesCmd()),
]);
const sessions = parseSessions(sessionsOut);
const windows = parseWindows(windowsOut);
attachPanesToWindows(windows, parsePanes(panesOut));
const tags = await fetchSessionTags(userId, host.id);
const full: TmuxSessionOverview[] = sessions.map((s) => ({
...s,
windows: windows.get(s.name) || [],
tags: tags.get(s.name) || [],
}));
return { available: true, sessions: full };
});
res.json(result);
} catch (err) {
sendTmuxError(res, err, "overview", host.id);
}
});
// Focus a pane: select its window and pane on the server so every attached
// client (including the monitor's embedded terminal) switches to it.
app.post("/tmux_monitor/:hostId/focus", async (req, res) => {
const host = await requireHost(req, res, "connect");
if (!host) return;
const paneId = String((req.body as { paneId?: string })?.paneId || "");
if (!PANE_ID_RE.test(paneId)) {
return res.status(400).json({ error: "Invalid pane ID" });
}
try {
await withHostConnection(host, (conn) =>
// A pane id is a valid window target: tmux resolves it to the window
// containing the pane.
execCommand(
conn,
tmuxCommand(
`select-window -t ${shellEscape(paneId)} \\; select-pane -t ${shellEscape(paneId)}`,
),
),
);
res.json({ ok: true });
} catch (err) {
sendTmuxError(res, err, "focus", host.id);
}
});
// Create a detached session. Starts the tmux server if none is running.
app.post("/tmux_monitor/:hostId/sessions", async (req, res) => {
const host = await requireHost(req, res, "connect");
if (!host) return;
const name = String((req.body as { name?: string })?.name || "").trim();
if (!SESSION_NAME_RE.test(name)) {
return res.status(400).json({ error: "Invalid session name" });
}
try {
await withHostConnection(host, (conn) =>
execCommand(conn, tmuxCommand(`new-session -d -s ${shellEscape(name)}`)),
);
sshLogger.info("tmux session created", {
operation: "tmux_session_create",
hostId: host.id,
sessionName: name,
});
res.json({ ok: true, name });
} catch (err) {
if (/duplicate session/i.test(toErrorMessage(err))) {
return res
.status(409)
.json({ error: "A session with this name already exists" });
}
sendTmuxError(res, err, "create session", host.id);
}
});
// Create a window in an existing session. The session name comes from tmux's
// own listing, so it is only checked for characters that would change the
// target's meaning (":" and "." are window/pane separators in tmux targets);
// "=" prefixes the target for an exact-name match.
app.post("/tmux_monitor/:hostId/windows", async (req, res) => {
const host = await requireHost(req, res, "connect");
if (!host) return;
const sessionName = String(
(req.body as { sessionName?: string })?.sessionName || "",
).trim();
if (!sessionName || /[:.\n]/.test(sessionName)) {
return res.status(400).json({ error: "Invalid session name" });
}
try {
await withHostConnection(host, (conn) =>
execCommand(
conn,
tmuxCommand(`new-window -t ${shellEscape(`=${sessionName}`)}`),
),
);
sshLogger.info("tmux window created", {
operation: "tmux_window_create",
hostId: host.id,
sessionName,
});
res.json({ ok: true });
} catch (err) {
if (/can't find session|no such session/i.test(toErrorMessage(err))) {
return res.status(404).json({ error: "Session not found" });
}
sendTmuxError(res, err, "create window", host.id);
}
});
// Rename a session. Saved tags follow the session to its new name (for every
// user — the session itself is shared on the host).
app.post("/tmux_monitor/:hostId/rename", async (req, res) => {
const host = await requireHost(req, res, "connect");
if (!host) return;
const body = req.body as { sessionName?: string; newName?: string };
const sessionName = String(body?.sessionName || "").trim();
const newName = String(body?.newName || "").trim();
if (!sessionName || /[:.\n]/.test(sessionName)) {
return res.status(400).json({ error: "Invalid session name" });
}
if (!SESSION_NAME_RE.test(newName)) {
return res.status(400).json({ error: "Invalid new session name" });
}
try {
await withHostConnection(host, (conn) =>
execCommand(
conn,
tmuxCommand(
`rename-session -t ${shellEscape(`=${sessionName}`)} ${shellEscape(newName)}`,
),
),
);
await createCurrentTmuxSessionTagRepository().renameSessionForHost(
host.id,
sessionName,
newName,
);
sshLogger.info("tmux session renamed", {
operation: "tmux_session_rename",
hostId: host.id,
sessionName,
newName,
});
await auditTmuxAction(req, host, "tmux_session_rename", sessionName, {
newName,
});
res.json({ ok: true, name: newName });
} catch (err) {
if (/can't find session|no such session/i.test(toErrorMessage(err))) {
return res.status(404).json({ error: "Session not found" });
}
if (/duplicate session/i.test(toErrorMessage(err))) {
return res
.status(409)
.json({ error: "A session with this name already exists" });
}
sendTmuxError(res, err, "rename session", host.id);
}
});
// Kill a session and drop its saved tags.
app.post("/tmux_monitor/:hostId/kill", async (req, res) => {
const host = await requireHost(req, res, "connect");
if (!host) return;
const sessionName = String(
(req.body as { sessionName?: string })?.sessionName || "",
).trim();
if (!sessionName || /[:.\n]/.test(sessionName)) {
return res.status(400).json({ error: "Invalid session name" });
}
try {
await withHostConnection(host, (conn) =>
execCommand(
conn,
tmuxCommand(`kill-session -t ${shellEscape(`=${sessionName}`)}`),
),
);
await createCurrentTmuxSessionTagRepository().deleteSessionForHost(
host.id,
sessionName,
);
sshLogger.info("tmux session killed", {
operation: "tmux_session_kill",
hostId: host.id,
sessionName,
});
await auditTmuxAction(req, host, "tmux_session_kill", sessionName);
res.json({ ok: true });
} catch (err) {
if (/can't find session|no such session/i.test(toErrorMessage(err))) {
return res.status(404).json({ error: "Session not found" });
}
sendTmuxError(res, err, "kill session", host.id);
}
});
// Kill a window (and every pane in it). Killing the last window of a session
// ends the session — tmux semantics.
app.post("/tmux_monitor/:hostId/kill-window", async (req, res) => {
const host = await requireHost(req, res, "connect");
if (!host) return;
const body = req.body as { sessionName?: string; windowIndex?: number };
const sessionName = String(body?.sessionName || "").trim();
const windowIndex = Number(body?.windowIndex);
if (!sessionName || /[:.\n]/.test(sessionName)) {
return res.status(400).json({ error: "Invalid session name" });
}
if (!Number.isInteger(windowIndex) || windowIndex < 0) {
return res.status(400).json({ error: "Invalid window index" });
}
try {
await withHostConnection(host, (conn) =>
execCommand(
conn,
tmuxCommand(
`kill-window -t ${shellEscape(`=${sessionName}:${windowIndex}`)}`,
),
),
);
sshLogger.info("tmux window killed", {
operation: "tmux_window_kill",
hostId: host.id,
sessionName,
windowIndex,
});
await auditTmuxAction(req, host, "tmux_window_kill", sessionName, {
windowIndex,
});
res.json({ ok: true });
} catch (err) {
if (
/can't find window|no such window|can't find session/i.test(
toErrorMessage(err),
)
) {
return res.status(404).json({ error: "Window not found" });
}
sendTmuxError(res, err, "kill window", host.id);
}
});
// Kill a single pane. Killing the last pane of a window closes the window,
// and the last window of a session ends the session — tmux semantics.
app.post("/tmux_monitor/:hostId/kill-pane", async (req, res) => {
const host = await requireHost(req, res, "connect");
if (!host) return;
const paneId = String((req.body as { paneId?: string })?.paneId || "");
if (!PANE_ID_RE.test(paneId)) {
return res.status(400).json({ error: "Invalid pane ID" });
}
try {
await withHostConnection(host, (conn) =>
execCommand(conn, tmuxCommand(`kill-pane -t ${shellEscape(paneId)}`)),
);
sshLogger.info("tmux pane killed", {
operation: "tmux_pane_kill",
hostId: host.id,
paneId,
});
await auditTmuxAction(req, host, "tmux_pane_kill", paneId);
res.json({ ok: true });
} catch (err) {
if (/can't find pane|no such pane/i.test(toErrorMessage(err))) {
return res.status(404).json({ error: "Pane not found" });
}
sendTmuxError(res, err, "kill pane", host.id);
}
});
// Split the window containing a pane. "h" places the new pane to the right,
// "v" below — matching tmux's own -h/-v semantics. The new pane starts in the
// source pane's working directory.
app.post("/tmux_monitor/:hostId/split", async (req, res) => {
const host = await requireHost(req, res, "connect");
if (!host) return;
const body = req.body as { paneId?: string; direction?: string };
const paneId = String(body?.paneId || "");
const direction = body?.direction === "v" ? "-v" : "-h";
if (!PANE_ID_RE.test(paneId)) {
return res.status(400).json({ error: "Invalid pane ID" });
}
if (body?.direction !== "h" && body?.direction !== "v") {
return res.status(400).json({ error: "Invalid split direction" });
}
try {
await withHostConnection(host, (conn) =>
execCommand(
conn,
tmuxCommand(
`split-window ${direction} -t ${shellEscape(paneId)} -c ${shellEscape("#{pane_current_path}")}`,
),
),
);
sshLogger.info("tmux pane split", {
operation: "tmux_pane_split",
hostId: host.id,
paneId,
direction: body.direction,
});
res.json({ ok: true });
} catch (err) {
sendTmuxError(res, err, "split", host.id);
}
});
app.get("/tmux_monitor/:hostId/search", async (req, res) => {
const host = await requireHost(req, res);
if (!host) return;
const query = String(req.query.q || "").trim();
if (!query) {
return res.status(400).json({ error: "Missing search query" });
}
try {
const results = await withHostConnection(host, async (conn) => {
const allPanes = await listPanesRaw(conn);
const panes = allPanes.slice(0, MAX_SEARCH_PANES);
// Flips to true whenever a limit was hit, so the UI can tell the user
// the results are partial instead of silently truncating.
let truncated = allPanes.length > MAX_SEARCH_PANES;
const matches: Array<{
paneId: string;
sessionName: string;
windowIndex: number;
line: number;
text: string;
}> = [];
// Bounded concurrency; each search runs capture+grep remotely so only
// matching lines travel back over the wire.
for (let i = 0; i < panes.length; i += SEARCH_CONCURRENCY) {
const batch = panes.slice(i, i + SEARCH_CONCURRENCY);
await Promise.all(
batch.map(async (pane) => {
try {
const output = await execCommand(
conn,
tmuxCommand(
`capture-pane -p -J -t ${shellEscape(pane.id)} -S -${SEARCH_HISTORY_LINES} 2>/dev/null | grep -n -i -F -- ${shellEscape(query)} | head -${MAX_MATCHES_PER_PANE}`,
),
);
const lines = output.split("\n").filter(Boolean);
if (lines.length >= MAX_MATCHES_PER_PANE) truncated = true;
for (const line of lines) {
const sep = line.indexOf(":");
if (sep === -1) continue;
matches.push({
paneId: pane.id,
sessionName: pane.sessionName,
windowIndex: pane.windowIndex,
line: parseInt(line.slice(0, sep), 10) || 0,
text: line.slice(sep + 1).slice(0, 500),
});
}
} catch {
// grep exits non-zero when there are no matches -- not an error
}
}),
);
}
return { matches, truncated };
});
res.json({
query,
matches: results.matches,
truncated: results.truncated,
searchedLines: SEARCH_HISTORY_LINES,
maxPanes: MAX_SEARCH_PANES,
});
} catch (err) {
sendTmuxError(res, err, "search", host.id);
}
});
app.get("/tmux_monitor/:hostId/metrics", async (req, res) => {
const host = await requireHost(req, res);
if (!host) return;
try {
const metrics = await withHostConnection(host, async (conn) => {
const panes = await listPanesRaw(conn);
if (panes.length === 0) return [];
return collectPaneMetrics(conn, panes);
});
res.json({ panes: metrics });
} catch (err) {
sendTmuxError(res, err, "metrics", host.id);
}
});
app.put("/tmux_monitor/:hostId/tags", async (req, res) => {
const userId = (req as unknown as AuthenticatedRequest).userId;
const host = await requireHost(req, res);
if (!host) return;
const { sessionName, tags } = req.body as {
sessionName?: string;
tags?: string[];
};
if (!sessionName || typeof sessionName !== "string") {
return res.status(400).json({ error: "Missing session name" });
}
if (!Array.isArray(tags) || tags.some((t) => typeof t !== "string")) {
return res.status(400).json({ error: "Tags must be an array of strings" });
}
const cleanTags = [
...new Set(tags.map((t) => t.trim().slice(0, 64)).filter(Boolean)),
].slice(0, 20);
try {
await createCurrentTmuxSessionTagRepository().replaceForUserHostSession(
userId,
host.id,
sessionName,
cleanTags,
);
res.json({ sessionName, tags: cleanTags });
} catch (err) {
sshLogger.error(
`Failed to save tmux session tags for host ${host.id}`,
err,
);
res.status(500).json({ error: toErrorMessage(err) });
}
});
const PORT = 30010;
listenOnServicePort({
app,
port: PORT,
logger: sshLogger,
serviceName: "tmux",
});
-250
View File
@@ -1,250 +0,0 @@
// Pure parsing/aggregation helpers for the tmux monitor module.
// Kept free of SSH/Express dependencies so they can be unit-tested.
// Field separator used in tmux -F format strings. Session names, pane titles
// and paths may contain "|", and tmux sanitizes control characters (and, under
// non-UTF-8 locales, multibyte characters) in format output to "_", so a
// printable ASCII token is the only separator that survives everywhere.
export const SEP = "<<TMX>>";
export interface TmuxPane {
id: string;
index: number;
pid: number;
active: boolean;
width: number;
height: number;
command: string;
path: string;
title: string;
}
export interface TmuxWindow {
index: number;
name: string;
active: boolean;
panes: TmuxPane[];
}
export interface TmuxSessionSummary {
name: string;
created: number;
lastActivity: number;
attachedClients: number;
}
export interface RawPane extends TmuxPane {
sessionName: string;
windowIndex: number;
}
export interface ProcessInfo {
pid: number;
ppid: number;
cpu: number;
mem: number;
rss: number;
comm: string;
}
export interface PaneMetrics {
paneId: string;
sessionName: string;
pid: number;
processCount: number;
cpuPercent: number;
memRssKb: number;
gpuMemMb: number;
topCommand: string | null;
}
export function parseSessions(output: string): TmuxSessionSummary[] {
return output
.split("\n")
.filter(Boolean)
.map((line) => {
const [name, created, activity, attached] = line.split(SEP);
return {
name,
created: parseInt(created, 10) || 0,
lastActivity: parseInt(activity, 10) || 0,
attachedClients: parseInt(attached, 10) || 0,
};
});
}
export function parseWindows(output: string): Map<string, TmuxWindow[]> {
const bySession = new Map<string, TmuxWindow[]>();
for (const line of output.split("\n").filter(Boolean)) {
const [session, index, active, name] = line.split(SEP);
if (!bySession.has(session)) bySession.set(session, []);
bySession.get(session)!.push({
index: parseInt(index, 10) || 0,
name: name || "",
active: active === "1",
panes: [],
});
}
return bySession;
}
export function parsePanes(output: string): RawPane[] {
return output
.split("\n")
.filter(Boolean)
.map((line) => {
const [
sessionName,
windowIndex,
id,
index,
pid,
active,
width,
height,
command,
path,
title,
] = line.split(SEP);
return {
sessionName,
windowIndex: parseInt(windowIndex, 10) || 0,
id,
index: parseInt(index, 10) || 0,
pid: parseInt(pid, 10) || 0,
active: active === "1",
width: parseInt(width, 10) || 0,
height: parseInt(height, 10) || 0,
command: command || "",
path: path || "",
title: title || "",
};
});
}
export function parsePsOutput(output: string): ProcessInfo[] {
const processes: ProcessInfo[] = [];
for (const line of output.split("\n")) {
const parts = line.trim().split(/\s+/);
if (parts.length < 6) continue;
const pid = parseInt(parts[0], 10);
const ppid = parseInt(parts[1], 10);
if (isNaN(pid) || isNaN(ppid)) continue;
processes.push({
pid,
ppid,
cpu: parseFloat(parts[2]) || 0,
mem: parseFloat(parts[3]) || 0,
rss: parseInt(parts[4], 10) || 0,
comm: parts.slice(5).join(" "),
});
}
return processes;
}
export function parseGpuOutput(output: string): Map<number, number> {
const gpuByPid = new Map<number, number>();
for (const line of output.split("\n").filter(Boolean)) {
const [pid, mem] = line.split(",").map((s) => s.trim());
const pidNum = parseInt(pid, 10);
const memNum = parseInt(mem, 10);
if (!isNaN(pidNum) && !isNaN(memNum)) {
gpuByPid.set(pidNum, (gpuByPid.get(pidNum) || 0) + memNum);
}
}
return gpuByPid;
}
/**
* Map each pane's shell pid to its descendant process tree and aggregate
* CPU/RAM/GPU usage per pane.
*/
export function buildPaneMetrics(
panes: RawPane[],
processes: ProcessInfo[],
gpuByPid: Map<number, number>,
): PaneMetrics[] {
const byPid = new Map<number, ProcessInfo>();
const childrenOf = new Map<number, number[]>();
for (const p of processes) {
byPid.set(p.pid, p);
if (!childrenOf.has(p.ppid)) childrenOf.set(p.ppid, []);
childrenOf.get(p.ppid)!.push(p.pid);
}
return panes.map((pane) => {
// Walk the descendant tree starting at (and including) the pane's shell
const treePids: number[] = [];
const queue = [pane.pid];
const seen = new Set<number>();
for (let cursor = 0; cursor < queue.length; cursor++) {
const pid = queue[cursor];
if (seen.has(pid)) continue;
seen.add(pid);
if (byPid.has(pid)) treePids.push(pid);
for (const child of childrenOf.get(pid) || []) queue.push(child);
}
let cpuPercent = 0;
let memRssKb = 0;
let gpuMemMb = 0;
let topCommand: string | null = null;
let topCpu = -1;
for (const pid of treePids) {
const p = byPid.get(pid)!;
cpuPercent += p.cpu;
memRssKb += p.rss;
gpuMemMb += gpuByPid.get(pid) || 0;
// The pane shell itself is rarely the interesting process
if (p.cpu > topCpu && pid !== pane.pid) {
topCpu = p.cpu;
topCommand = p.comm;
}
}
if (topCommand === null && treePids.length > 0) {
topCommand = byPid.get(treePids[0])!.comm;
}
return {
paneId: pane.id,
sessionName: pane.sessionName,
pid: pane.pid,
processCount: treePids.length,
cpuPercent: Math.round(cpuPercent * 10) / 10,
memRssKb,
gpuMemMb,
topCommand,
};
});
}
/**
* Group panes into their windows (mutates the window objects' pane arrays).
*/
export function attachPanesToWindows(
windows: Map<string, TmuxWindow[]>,
panes: RawPane[],
): void {
const windowsBySessionAndIndex = new Map<string, Map<number, TmuxWindow>>();
for (const [sessionName, sessionWindows] of windows) {
const byIndex = new Map<number, TmuxWindow>();
for (const window of sessionWindows) {
if (!byIndex.has(window.index)) byIndex.set(window.index, window);
}
windowsBySessionAndIndex.set(sessionName, byIndex);
}
for (const pane of panes) {
const window = windowsBySessionAndIndex
.get(pane.sessionName)
?.get(pane.windowIndex);
if (window) {
const { sessionName: _s, windowIndex: _w, ...paneFields } = pane;
window.panes.push(paneFields);
}
}
}
export function shellEscape(s: string): string {
return "'" + s.replace(/'/g, "'\\''") + "'";
}
+1
View File
@@ -60,6 +60,7 @@ const PLUGIN_PURPOSES = new Set<SshConnectPurpose>([
"file-manager",
"file-transfer",
"terminal",
"tmux",
]);
function purposeOf(options?: PluginSshConnectOptions): SshConnectPurpose {
+10 -6
View File
@@ -302,18 +302,18 @@ async function provisionLocalDesktopUserIfNeeded(): Promise<void> {
const { primeKnownPermissions } =
await import("./utils/known-permissions.js");
await primeKnownPermissions();
// Terminal, docker, host-metrics and file-manager are deliberately
// absent: the ssh-terminal, docker, host-metrics and file-manager plugins
// start their own servers, so disabling any of them stops its WS/HTTP
// server. See plugins/ssh-terminal, plugins/docker, plugins/host-metrics
// and plugins/file-manager.
// Terminal, docker, host-metrics, file-manager and tmux monitoring are
// deliberately absent: the ssh-terminal, docker, host-metrics,
// file-manager and tmux-monitor plugins start their own servers, so
// disabling any of them stops its WS/HTTP server. See
// plugins/ssh-terminal, plugins/docker, plugins/host-metrics,
// plugins/file-manager and plugins/tmux-monitor.
// Every other plugin (AI, Proxmox, Remote Desktop, Fleets, Automations,
// Network Topology, Workspaces, Web Endpoint, Tunnels) is absent for the
// same reason: each one serves its routes under /plugin-api/<id>/ through
// ctx.http on activate, so disabling it answers 503 instead of leaving a
// dead import here. Automations' scheduler and tunnel autostart also
// start from their own activate() rather than here.
await import("./hosts/tmux/index.js");
await import("./hosts/serial.js");
await import("./services/dashboard.js");
await import("./services/homepage.js");
@@ -365,6 +365,10 @@ async function provisionLocalDesktopUserIfNeeded(): Promise<void> {
const { runSshTerminalSettingsMigration } =
await import("./utils/crypto-migration/ssh-terminal-settings-migration.js");
await runSshTerminalSettingsMigration();
const { runTmuxMonitorSettingsMigration } =
await import("./utils/crypto-migration/tmux-monitor-settings-migration.js");
await runTmuxMonitorSettingsMigration();
} catch (error) {
systemLogger.warn("Plugin runtime failed to initialize", {
operation: "plugin_init",
@@ -1,105 +0,0 @@
import { afterEach, describe, expect, it } from "vitest";
import { TestSqliteDatabase } from "./test-support.js";
import { TmuxSessionTagRepository } from "../../../database/repositories/tmux-session-tag-repository.js";
describe("TmuxSessionTagRepository", () => {
let adapter: TestSqliteDatabase | null = null;
afterEach(async () => {
if (adapter) {
await adapter.close();
adapter = null;
}
});
async function createRepository(
onWrite?: () => void | Promise<void>,
): Promise<TmuxSessionTagRepository> {
adapter = new TestSqliteDatabase();
const context = await adapter.connect();
await adapter.exec(`
INSERT INTO users (id, username, password_hash)
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
INSERT INTO ssh_data (id, user_id, name, ip, port, username, auth_type)
VALUES (1, 'user-1', 'one', '10.0.0.1', 22, 'root', 'password'), (2, 'user-2', 'two', '10.0.0.1', 22, 'root', 'password');
INSERT INTO tmux_session_tags (user_id, host_id, session_name, tag)
VALUES
('user-1', 1, 'api', 'prod'),
('user-1', 1, 'api', 'critical'),
('user-1', 1, 'worker', 'batch'),
('user-2', 2, 'api', 'other');
`);
return new TmuxSessionTagRepository(context, onWrite);
}
it("groups tags by session for a user and host", async () => {
const repo = await createRepository();
const tags = await repo.listByUserAndHost("user-1", 1);
expect(tags.get("api")).toEqual(["prod", "critical"]);
expect(tags.get("worker")).toEqual(["batch"]);
expect(tags.has("missing")).toBe(false);
});
it("renames and deletes session tags by host/session", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
expect(await repo.renameSessionForHost(1, "api", "api-renamed")).toBe(2);
expect(await repo.renameSessionForHost(1, "missing", "noop")).toBe(0);
const renamed = await repo.listByUserAndHost("user-1", 1);
expect(renamed.get("api-renamed")).toEqual(["prod", "critical"]);
expect(renamed.has("api")).toBe(false);
expect(await repo.deleteSessionForHost(1, "api-renamed")).toBe(2);
expect(await repo.deleteSessionForHost(1, "api-renamed")).toBe(0);
expect(writeCount).toBe(2);
});
it("replaces tags for one user/host/session", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
expect(
await repo.replaceForUserHostSession("user-1", 1, "api", [
"blue",
"green",
]),
).toBe(4);
const tags = await repo.listByUserAndHost("user-1", 1);
expect(tags.get("api")).toEqual(["blue", "green"]);
expect(tags.get("worker")).toEqual(["batch"]);
expect(
await repo.replaceForUserHostSession("user-1", 1, "worker", []),
).toBe(1);
expect(
await repo.replaceForUserHostSession("user-1", 1, "missing", []),
).toBe(0);
expect(writeCount).toBe(2);
});
it("deletes all tags for a user", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
await expect(repo.deleteByUserId("user-1")).resolves.toBe(3);
await expect(repo.deleteByUserId("missing")).resolves.toBe(0);
expect(await repo.listByUserAndHost("user-1", 1)).toEqual(new Map());
expect(await repo.listByUserAndHost("user-2", 2)).toEqual(
new Map([["api", ["other"]]]),
);
expect(writeCount).toBe(1);
});
});
@@ -1,78 +0,0 @@
import { EventEmitter } from "node:events";
import { execFileSync } from "node:child_process";
import type { Client } from "ssh2";
import { describe, expect, it } from "vitest";
import {
detectTmux,
tmuxCommand,
withTmuxPath,
} from "../../../hosts/tmux/helper.js";
describe("tmux command path handling", () => {
it("prepends all non-login tmux paths while preserving inherited PATH", () => {
expect(withTmuxPath("command -v tmux")).toBe(
`/bin/sh -c 'PATH=/opt/homebrew/bin:/usr/local/bin:/opt/bin:/usr/pkg/bin:"$PATH"; export PATH; command -v tmux'`,
);
});
it("shell-escapes embedded single quotes in wrapped commands", () => {
// Asserted as a string so the escaping rule is covered everywhere. The
// round-trip below proves it against a real parser, but only where one
// exists -- see the note there.
expect(withTmuxPath(`printf '%s' "can't"`)).toBe(
"/bin/sh -c 'PATH=/opt/homebrew/bin:/usr/local/bin:/opt/bin:/usr/pkg/bin:\"$PATH\"; export PATH; printf '\\''%s'\\'' \"can'\\''t\"'",
);
});
// /bin/sh is not on Windows, and Windows is a supported platform for the
// desktop app -- contributors run `npm test` there. CI is ubuntu-only, so it
// would never notice this failing.
it.skipIf(process.platform === "win32")(
"produces a command a real shell parses back to the original",
() => {
const command = withTmuxPath(`printf '%s' "can't"`);
expect(
execFileSync("/bin/sh", ["-c", command], { encoding: "utf8" }),
).toBe("can't");
},
);
it("runs every tmux invocation in UTF-8 mode through the path wrapper", () => {
expect(tmuxCommand("list-sessions")).toBe(
`/bin/sh -c 'PATH=/opt/homebrew/bin:/usr/local/bin:/opt/bin:/usr/pkg/bin:"$PATH"; export PATH; tmux -u list-sessions'`,
);
});
it("detects tmux with the UTF-8 wrapper", async () => {
const commands: string[] = [];
const conn = {
exec(command: string, callback: (error: null, stream: never) => void) {
commands.push(command);
const stream = new EventEmitter() as EventEmitter & {
stderr: EventEmitter;
};
stream.stderr = new EventEmitter();
callback(null, stream as never);
queueMicrotask(() => {
if (commands.length === 1) {
stream.emit("data", Buffer.from("tmux 3.7b\n"));
stream.emit("close", 0);
return;
}
stream.emit("close", 1);
});
},
} as unknown as Client;
await expect(detectTmux(conn)).resolves.toEqual({
available: true,
sessions: [],
});
expect(commands).toEqual([
`/bin/sh -c 'PATH=/opt/homebrew/bin:/usr/local/bin:/opt/bin:/usr/pkg/bin:"$PATH"; export PATH; tmux -u -V'`,
`/bin/sh -c 'PATH=/opt/homebrew/bin:/usr/local/bin:/opt/bin:/usr/pkg/bin:"$PATH"; export PATH; tmux -u list-sessions -F "#{session_name}|#{session_created}|#{session_activity}|#{session_windows}|#{session_attached}" 2>/dev/null'`,
]);
});
});
@@ -1,272 +0,0 @@
import { describe, it, expect } from "vitest";
import {
SEP,
parseSessions,
parseWindows,
parsePanes,
parsePsOutput,
parseGpuOutput,
buildPaneMetrics,
attachPanesToWindows,
shellEscape,
type ProcessInfo,
type TmuxWindow,
} from "../../../hosts/tmux/monitor-helpers.js";
function join(...fields: (string | number)[]): string {
return fields.join(SEP);
}
describe("parseSessions", () => {
it("parses tmux list-sessions output", () => {
const output = [
join("training", 1760000000, 1760001000, 1),
join("lab|with|pipes", 1760000500, 1760002000, 0),
].join("\n");
const sessions = parseSessions(output);
expect(sessions).toHaveLength(2);
expect(sessions[0]).toEqual({
name: "training",
created: 1760000000,
lastActivity: 1760001000,
attachedClients: 1,
});
// Session names containing "|" survive because SEP is a multi-char token
expect(sessions[1].name).toBe("lab|with|pipes");
expect(sessions[1].attachedClients).toBe(0);
});
it("returns empty array for empty output", () => {
expect(parseSessions("")).toEqual([]);
});
});
describe("parseWindows", () => {
it("groups windows by session", () => {
const output = [
join("training", 0, 1, "vim"),
join("training", 1, 0, "logs"),
join("api", 0, 1, "server"),
].join("\n");
const windows = parseWindows(output);
expect(windows.get("training")).toHaveLength(2);
expect(windows.get("training")![0]).toMatchObject({
index: 0,
name: "vim",
active: true,
});
expect(windows.get("api")![0].name).toBe("server");
});
});
describe("parsePanes", () => {
it("parses full pane lines including free-text fields", () => {
const output = join(
"training",
0,
"%3",
1,
12345,
1,
120,
40,
"python",
"/home/user/my|dir",
"gpu01: train.py",
);
const panes = parsePanes(output);
expect(panes).toHaveLength(1);
expect(panes[0]).toEqual({
sessionName: "training",
windowIndex: 0,
id: "%3",
index: 1,
pid: 12345,
active: true,
width: 120,
height: 40,
command: "python",
path: "/home/user/my|dir",
title: "gpu01: train.py",
});
});
});
describe("parsePsOutput", () => {
it("parses ps -eo pid,ppid,pcpu,pmem,rss,comm output", () => {
const output = [
" 1 0 0.0 0.1 1234 systemd",
"12345 1 2.5 1.0 50000 bash",
"12400 12345 95.3 12.5 800000 python3",
"garbage line",
].join("\n");
const procs = parsePsOutput(output);
expect(procs).toHaveLength(3);
expect(procs[2]).toEqual({
pid: 12400,
ppid: 12345,
cpu: 95.3,
mem: 12.5,
rss: 800000,
comm: "python3",
});
});
});
describe("parseGpuOutput", () => {
it("parses nvidia-smi csv output and sums per pid", () => {
const output = ["12400, 8000", "12400, 2000", "99999, 512"].join("\n");
const gpu = parseGpuOutput(output);
expect(gpu.get(12400)).toBe(10000);
expect(gpu.get(99999)).toBe(512);
});
it("handles empty output (no GPU)", () => {
expect(parseGpuOutput("").size).toBe(0);
});
});
describe("buildPaneMetrics", () => {
const panes = parsePanes(
[
join("training", 0, "%1", 0, 100, 1, 80, 24, "bash", "/", "t"),
join("idle", 0, "%2", 0, 200, 1, 80, 24, "bash", "/", "t"),
].join("\n"),
);
const processes = parsePsOutput(
[
// pane %1: bash(100) -> python3(110) -> worker(111)
" 100 1 0.1 0.1 4000 bash",
" 110 100 90.0 10.0 700000 python3",
" 111 110 9.5 2.0 100000 dataloader",
// pane %2: bash(200) only
" 200 1 0.0 0.1 4000 bash",
// unrelated process
" 300 1 50.0 5.0 200000 chrome",
].join("\n"),
);
it("aggregates descendant trees per pane", () => {
const metrics = buildPaneMetrics(panes, processes, new Map());
const m1 = metrics.find((m) => m.paneId === "%1")!;
expect(m1.processCount).toBe(3);
expect(m1.cpuPercent).toBeCloseTo(99.6, 1);
expect(m1.memRssKb).toBe(804000);
expect(m1.topCommand).toBe("python3");
const m2 = metrics.find((m) => m.paneId === "%2")!;
expect(m2.processCount).toBe(1);
expect(m2.cpuPercent).toBe(0);
// Unrelated process is never attributed
expect(m2.memRssKb).toBe(4000);
});
it("attributes GPU memory through the process tree", () => {
const gpu = new Map([
[110, 8000],
[300, 4000],
]);
const metrics = buildPaneMetrics(panes, processes, gpu);
expect(metrics.find((m) => m.paneId === "%1")!.gpuMemMb).toBe(8000);
expect(metrics.find((m) => m.paneId === "%2")!.gpuMemMb).toBe(0);
});
it("handles a pane whose pid is missing from ps output", () => {
const orphan = parsePanes(
join("gone", 0, "%9", 0, 99999, 0, 80, 24, "bash", "/", "t"),
);
const metrics = buildPaneMetrics(orphan, processes, new Map());
expect(metrics[0].processCount).toBe(0);
expect(metrics[0].cpuPercent).toBe(0);
expect(metrics[0].topCommand).toBeNull();
});
it("does not loop on cyclic ppid data", () => {
const cyclic = parsePsOutput(
[" 100 101 1.0 0.1 1000 a", " 101 100 1.0 0.1 1000 b"].join("\n"),
);
const pane = parsePanes(
join("s", 0, "%1", 0, 100, 1, 80, 24, "a", "/", "t"),
);
const metrics = buildPaneMetrics(pane, cyclic, new Map());
expect(metrics[0].processCount).toBe(2);
});
it("aggregates a wide process tree without dropping children", () => {
const childCount = 2_000;
const wideTree: ProcessInfo[] = [
{ pid: 1, ppid: 0, cpu: 0, mem: 0, rss: 1, comm: "bash" },
...Array.from({ length: childCount }, (_, index) => ({
pid: index + 2,
ppid: 1,
cpu: 0.1,
mem: 0,
rss: 1,
comm: `worker-${index}`,
})),
];
const pane = parsePanes(
join("wide", 0, "%1", 0, 1, 1, 80, 24, "bash", "/", "t"),
);
const [metrics] = buildPaneMetrics(pane, wideTree, new Map());
expect(metrics.processCount).toBe(childCount + 1);
expect(metrics.memRssKb).toBe(childCount + 1);
});
});
describe("attachPanesToWindows", () => {
it("places panes into their windows", () => {
const windows = parseWindows(
[join("s1", 0, 1, "main"), join("s1", 1, 0, "logs")].join("\n"),
);
const panes = parsePanes(
[
join("s1", 0, "%1", 0, 100, 1, 80, 24, "bash", "/", "t"),
join("s1", 1, "%2", 0, 200, 0, 80, 24, "tail", "/", "t"),
join("unknown", 5, "%3", 0, 300, 0, 80, 24, "bash", "/", "t"),
].join("\n"),
);
attachPanesToWindows(windows, panes);
expect(windows.get("s1")![0].panes).toHaveLength(1);
expect(windows.get("s1")![0].panes[0].id).toBe("%1");
expect(windows.get("s1")![1].panes[0].id).toBe("%2");
});
it("preserves first-match behavior for duplicate window indexes", () => {
const first: TmuxWindow = {
index: 0,
name: "first",
active: true,
panes: [],
};
const duplicate: TmuxWindow = {
index: 0,
name: "duplicate",
active: false,
panes: [],
};
const windows = new Map([["s1", [first, duplicate]]]);
const panes = parsePanes(
join("s1", 0, "%1", 0, 100, 1, 80, 24, "bash", "/", "t"),
);
attachPanesToWindows(windows, panes);
expect(first.panes).toHaveLength(1);
expect(duplicate.panes).toHaveLength(0);
});
});
describe("shellEscape", () => {
it("wraps in single quotes and escapes embedded quotes", () => {
expect(shellEscape("simple")).toBe("'simple'");
expect(shellEscape("it's")).toBe("'it'\\''s'");
expect(shellEscape("$(rm -rf /)")).toBe("'$(rm -rf /)'");
});
});
@@ -0,0 +1,149 @@
/**
* The tmux monitor host-column migration.
*
* An upgrade must be lossless: a host with the monitor on must keep it on
* once the ssh_data column is dropped. Running it twice must not duplicate
* or clobber what it moved.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
interface HostRow {
id: number;
enable_tmux_monitor: boolean;
}
interface SettingsRow {
pluginId: string;
scope: string;
scopeId: string | null;
key: string;
value: string | null;
}
const hostRows: HostRow[] = [];
const settingsRows: SettingsRow[] = [];
const find = (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
) =>
settingsRows.find(
(row) =>
row.pluginId === pluginId &&
row.scope === scope &&
row.scopeId === scopeId &&
row.key === key,
);
const pluginSettingsRepository = {
get: async (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
) => find(pluginId, scope, scopeId, key) ?? null,
set: async (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
value: string | null,
) => {
const existing = find(pluginId, scope, scopeId, key);
if (existing) {
existing.value = value;
return;
}
settingsRows.push({ pluginId, scope, scopeId, key, value });
},
};
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginSettingsRepository: () => pluginSettingsRepository,
}));
vi.mock("../../database/db/index.js", () => ({
getDb: () => ({
all: async () => hostRows.filter((row) => row.enable_tmux_monitor),
}),
}));
const { runTmuxMonitorSettingsMigration } =
await import("../../utils/crypto-migration/tmux-monitor-settings-migration.js");
function storedValue(hostId: number, key: string): unknown {
const row = settingsRows.find(
(entry) => entry.scopeId === String(hostId) && entry.key === key,
);
return row?.value === null || row?.value === undefined
? undefined
: JSON.parse(row.value);
}
beforeEach(() => {
hostRows.length = 0;
settingsRows.length = 0;
});
describe("runTmuxMonitorSettingsMigration", () => {
it("moves a host's enabled switch into plugin settings", async () => {
hostRows.push({ id: 1, enable_tmux_monitor: true });
const result = await runTmuxMonitorSettingsMigration();
expect(result.moved).toBe(1);
expect(storedValue(1, "enableTmuxMonitor")).toBe(true);
expect(settingsRows.every((row) => row.pluginId === "tmux-monitor")).toBe(
true,
);
expect(settingsRows.every((row) => row.scope === "host")).toBe(true);
});
it("leaves a disabled host untouched", async () => {
hostRows.push({ id: 3, enable_tmux_monitor: false });
const result = await runTmuxMonitorSettingsMigration();
expect(result.moved).toBe(0);
expect(storedValue(3, "enableTmuxMonitor")).toBeUndefined();
});
it("does nothing on a fresh install", async () => {
const result = await runTmuxMonitorSettingsMigration();
expect(result.moved).toBe(0);
expect(settingsRows).toEqual([]);
});
it("is idempotent: a second run changes nothing", async () => {
hostRows.push({ id: 2, enable_tmux_monitor: true });
await runTmuxMonitorSettingsMigration();
const afterFirst = JSON.parse(JSON.stringify(settingsRows));
const second = await runTmuxMonitorSettingsMigration();
expect(second.moved).toBe(0);
expect(second.skipped).toBe(1);
expect(settingsRows).toEqual(afterFirst);
});
it("does not overwrite a value the plugin already saved", async () => {
hostRows.push({ id: 5, enable_tmux_monitor: true });
await pluginSettingsRepository.set(
"tmux-monitor",
"host",
"5",
"enableTmuxMonitor",
"false",
);
const result = await runTmuxMonitorSettingsMigration();
expect(result.skipped).toBe(1);
expect(storedValue(5, "enableTmuxMonitor")).toBe(false);
});
});
@@ -0,0 +1,84 @@
/**
* Moves the tmux monitor host column into the tmux-monitor plugin's host-scope
* settings, before the column is dropped from ssh_data.
*
* Idempotent: skips a host that already has a tmux-monitor settings row, so it
* is safe to run on every boot. Lossless: reads straight off the still-present
* ssh_data column, so it must run and finish before that column is ever
* dropped from schema.ts.
*/
import { sql } from "drizzle-orm";
import { databaseLogger } from "../logger.js";
import { getDb } from "../../database/db/index.js";
import { createCurrentPluginSettingsRepository } from "../../database/repositories/factory.js";
export interface TmuxMonitorSettingsMigrationResult {
moved: number;
skipped: number;
}
interface LegacyTmuxMonitorRow {
id: number;
enable_tmux_monitor: number | boolean | null;
}
export async function runTmuxMonitorSettingsMigration(): Promise<TmuxMonitorSettingsMigrationResult> {
const result: TmuxMonitorSettingsMigrationResult = { moved: 0, skipped: 0 };
try {
const drizzleDb = getDb();
// Raw SQL, not the typed schema, so this keeps working once schema.ts
// stops declaring this column. The plugin's enable switch defaults to
// off, matching the old column's default, so only hosts that turned it
// on need a row.
const rows = await drizzleDb.all<LegacyTmuxMonitorRow>(sql`
SELECT id, enable_tmux_monitor
FROM ssh_data
WHERE enable_tmux_monitor = true
`);
if (rows.length === 0) return result;
const pluginSettingsRepository = createCurrentPluginSettingsRepository();
for (const row of rows) {
const scopeId = String(row.id);
const existing = await pluginSettingsRepository.get(
"tmux-monitor",
"host",
scopeId,
"enableTmuxMonitor",
);
if (existing && existing.value !== null) {
result.skipped++;
continue;
}
await pluginSettingsRepository.set(
"tmux-monitor",
"host",
scopeId,
"enableTmuxMonitor",
JSON.stringify(!!row.enable_tmux_monitor),
);
result.moved++;
}
if (result.moved > 0) {
databaseLogger.info(
`Moved tmux monitor settings for ${result.moved} host(s) into plugin settings`,
{ operation: "tmux_monitor_settings_migration", moved: result.moved },
);
}
} catch (error) {
// A failed migration must not stop the backend. Hosts simply keep the
// monitor off, the plugin's default.
databaseLogger.warn("Tmux monitor settings migration failed", {
operation: "tmux_monitor_settings_migration",
error: error instanceof Error ? error.message : String(error),
});
}
return result;
}