feat: move the terminal fully into the ssh-terminal plugin (B9)

This commit is contained in:
LukeGus committed 2026-09-23 21:41:22 -05:00
1 parent 535126fd1b
commit 79d77678c9
309 files changed
+41789 -14958

No files matched your search

+16
View File
@@ -108,6 +108,16 @@ function issuedCertificateProvider(
request.requestOrigin,
);
},
cancelInteraction: async (request) => {
if (!request.requestId) return;
if (type === "stepca") {
const { cancelStepCaAuth } = await import("../hosts/step-ca-auth.js");
cancelStepCaAuth(request.requestId);
return;
}
const { cancelAuthSession } = await import("../hosts/opkssh-auth.js");
cancelAuthSession(request.requestId);
},
};
}
@@ -202,6 +212,12 @@ const vaultProvider: SshAuthProvider = {
request.requestOrigin,
);
},
cancelInteraction: async (request) => {
if (request.hostId === undefined) return;
const { cancelVaultAuthByHost } =
await import("../hosts/vault-oidc-auth.js");
cancelVaultAuthByHost(request.userId, request.hostId);
},
};
const WARPGATE_PATTERN = /warpgate\s+authentication/i;
-2
View File
@@ -10,7 +10,6 @@ import hostRoutes from "./routes/host.js";
import alertRoutes from "./routes/alerts.js";
import credentialsRoutes from "./routes/credentials.js";
import sshAuthRoutes from "./routes/ssh-auth-routes.js";
import terminalRoutes from "./routes/terminal.js";
import sessionLogRoutes from "./routes/session-log-routes.js";
import sessionSharingRoutes from "../hosts/session-sharing/routes.js";
import collabRoutes from "../hosts/collab/routes.js";
@@ -1808,7 +1807,6 @@ app.use("/host", hostRoutes);
app.use("/alerts", alertRoutes);
app.use("/credentials", credentialsRoutes);
app.use("/ssh-auth", sshAuthRoutes);
app.use("/terminal", terminalRoutes);
app.use("/session_logs", sessionLogRoutes);
app.use("/session-sharing", sessionSharingRoutes);
app.use("/collab", collabRoutes);
+4 -38
View File
@@ -419,15 +419,8 @@ async function initializeCompleteDatabase(): Promise<void> {
FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS command_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
host_id INTEGER NOT NULL,
command TEXT NOT NULL,
executed_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
);
-- command_history is owned by the ssh-terminal plugin, which adopts it
-- as p_ssh_terminal_command_history in its first migration.
CREATE TABLE IF NOT EXISTS host_access (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -858,35 +851,8 @@ async function initializeCompleteDatabase(): Promise<void> {
`);
try {
const timeoutRow = sqlite
.prepare(
"SELECT value FROM settings WHERE key = 'terminal_session_timeout_minutes'",
)
.get() as { value: string } | undefined;
const timeoutMinutes = timeoutRow
? parseInt(timeoutRow.value, 10)
: 30;
const ttlMs =
!isNaN(timeoutMinutes) && timeoutMinutes > 0
? timeoutMinutes * 60_000
: 30 * 60_000;
const cutoff = new Date(Date.now() - ttlMs).toISOString();
const result = sqlite
.prepare("DELETE FROM user_open_tabs WHERE updated_at <= ?")
.run(cutoff);
if (result.changes > 0) {
databaseLogger.info("Expired open tabs cleared on startup", {
operation: "db_init_open_tabs_cleanup",
count: result.changes,
});
}
} catch (e) {
databaseLogger.warn("Could not clear expired open tabs on startup", {
operation: "db_init_open_tabs_cleanup_failed",
error: e,
});
}
// Expired open tabs are dropped when the tab list is read, against the
// terminal plugin's own session timeout.
try {
const result = sqlite
@@ -105,14 +105,9 @@ export const PERFORMANCE_INDEXES: PerformanceIndex[] = [
table: "recent_activity",
columns: "user_id, timestamp",
},
{
name: "idx_command_history_user_host",
table: "command_history",
columns: "user_id, host_id",
},
// file_manager_recent, file_manager_pinned, file_manager_shortcuts and
// transfer_recent are owned by the file-manager plugin now; its adopted
// table definitions carry these same indexes across the rename.
// file_manager_recent, file_manager_pinned, file_manager_shortcuts,
// transfer_recent and command_history are owned by plugins now; their
// adopted table definitions carry these same indexes across the rename.
{
name: "idx_user_open_tabs_user_id",
table: "user_open_tabs",
-20
View File
@@ -515,26 +515,6 @@ export const recentActivity = mysqlTable(
],
);
export const commandHistory = mysqlTable(
"command_history",
{
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" }),
command: text("command").notNull(),
executedAt: text("executed_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
},
(table) => [
index("idx_command_history_user_host").on(table.userId, table.hostId),
],
);
export const hostAccess = mysqlTable(
"host_access",
{
-20
View File
@@ -516,26 +516,6 @@ export const recentActivity = pgTable(
],
);
export const commandHistory = pgTable(
"command_history",
{
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" }),
command: text("command").notNull(),
executedAt: text("executed_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
(table) => [
index("idx_command_history_user_host").on(table.userId, table.hostId),
],
);
export const hostAccess = pgTable(
"host_access",
{
-20
View File
@@ -508,26 +508,6 @@ export const recentActivity = sqliteTable(
],
);
export const commandHistory = sqliteTable(
"command_history",
{
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" }),
command: text("command").notNull(),
executedAt: text("executed_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
(table) => [
index("idx_command_history_user_host").on(table.userId, table.hostId),
],
);
export const hostAccess = sqliteTable(
"host_access",
{
@@ -1,160 +0,0 @@
import { and, desc, eq, inArray, sql } from "drizzle-orm";
import { commandHistory } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { rowsAffected } from "./mutation-result.js";
import { insertReturning } from "./returning.js";
export type CommandHistoryRecord = typeof commandHistory.$inferSelect;
export class CommandHistoryRepository {
constructor(
private readonly context: DatabaseContext,
private readonly onWrite?: () => void | Promise<void>,
) {}
async create(
userId: string,
hostId: number,
command: string,
executedAt = new Date().toISOString(),
): Promise<CommandHistoryRecord> {
const [created] = await insertReturning(this.context, commandHistory, {
userId,
hostId,
command,
executedAt,
});
await this.afterWrite();
return created;
}
async listUniqueCommandsForHost(
userId: string,
hostId: number,
limit = 500,
): Promise<string[]> {
const rows = await this.context.drizzle
.select({
command: commandHistory.command,
maxExecutedAt: sql<number>`MAX(${commandHistory.executedAt})`,
})
.from(commandHistory)
.where(
and(
eq(commandHistory.userId, userId),
eq(commandHistory.hostId, hostId),
),
)
.groupBy(commandHistory.command)
.orderBy(desc(sql`MAX(${commandHistory.executedAt})`))
.limit(limit);
return rows.map((row) => row.command);
}
async listCommandsForHost(
userId: string,
hostId: number,
limit = 200,
): Promise<string[]> {
const rows = await this.context.drizzle
.select({
id: commandHistory.id,
command: commandHistory.command,
})
.from(commandHistory)
.where(
and(
eq(commandHistory.userId, userId),
eq(commandHistory.hostId, hostId),
),
)
.orderBy(desc(commandHistory.executedAt))
.limit(limit);
return rows.map((row) => row.command);
}
async deleteCommandForHost(
userId: string,
hostId: number,
command: string,
): Promise<number> {
const result = await this.context.drizzle
.delete(commandHistory)
.where(
and(
eq(commandHistory.userId, userId),
eq(commandHistory.hostId, hostId),
eq(commandHistory.command, command),
),
);
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rowsAffected(result);
}
async deleteByUserAndHost(userId: string, hostId: number): Promise<number> {
const result = await this.context.drizzle
.delete(commandHistory)
.where(
and(
eq(commandHistory.userId, userId),
eq(commandHistory.hostId, hostId),
),
);
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rowsAffected(result);
}
async deleteByHostId(hostId: number): Promise<number> {
const result = await this.context.drizzle
.delete(commandHistory)
.where(eq(commandHistory.hostId, hostId));
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rowsAffected(result);
}
async deleteByHostIds(hostIds: number[]): Promise<number> {
if (hostIds.length === 0) {
return 0;
}
const result = await this.context.drizzle
.delete(commandHistory)
.where(inArray(commandHistory.hostId, hostIds));
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rowsAffected(result);
}
async deleteByUserId(userId: string): Promise<number> {
const result = await this.context.drizzle
.delete(commandHistory)
.where(eq(commandHistory.userId, userId));
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rowsAffected(result);
}
private async afterWrite(): Promise<void> {
await this.onWrite?.();
}
}
@@ -9,7 +9,6 @@ import { NotificationChannelRepository } from "./notification-channel-repository
import { AutomationRepository } from "./automation-repository.js";
import { ApiKeyRepository } from "./api-key-repository.js";
import { AuditLogRepository } from "./audit-log-repository.js";
import { CommandHistoryRepository } from "./command-history-repository.js";
import { CredentialRepository } from "./credential-repository.js";
import { DashboardServiceLinkRepository } from "./dashboard-service-link-repository.js";
import { DismissedAlertRepository } from "./dismissed-alert-repository.js";
@@ -184,13 +183,6 @@ export function createCurrentAuditLogRepository(): AuditLogRepository {
);
}
export function createCurrentCommandHistoryRepository(): CommandHistoryRepository {
return new CommandHistoryRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook("command_history_repository_write"),
);
}
export function createCurrentCredentialRepository(): CredentialRepository {
return new CredentialRepository(
createCurrentRepositoryContext(),
@@ -4,7 +4,6 @@ import {
createCurrentNotificationChannelRepository,
createCurrentApiKeyRepository,
createCurrentAuditLogRepository,
createCurrentCommandHistoryRepository,
createCurrentCredentialRepository,
createCurrentDashboardServiceLinkRepository,
createCurrentDismissedAlertRepository,
@@ -94,8 +93,6 @@ export async function deleteUserAndRelatedData(
await createCurrentHostFolderRepository().deleteByUserId(userId);
await createCurrentCommandHistoryRepository().deleteByUserId(userId);
await createCurrentHostHealthRepository().deleteByUserId(userId);
await createCurrentHostMetricsPreferenceRepository().deleteByUserId(userId);
await createCurrentHostSidebarPreferenceRepository().deleteByUserId(userId);
@@ -1,139 +0,0 @@
import type { AuthenticatedRequest } from "../../../types/index.js";
import type { Request, RequestHandler, Response, Router } from "express";
import { sshLogger } from "../../utils/logger.js";
import { createCurrentCommandHistoryRepository } from "../repositories/factory.js";
import { isNonEmptyString } from "./host-normalizers.js";
export function registerHostCommandHistoryRoutes(
router: Router,
authenticateJWT: RequestHandler,
requireViewPermission: RequestHandler,
requireDataAccess: RequestHandler,
): void {
/**
* @openapi
* /host/command-history/{hostId}:
* get:
* summary: Get command history
* description: Retrieves the command history for a specific host.
* tags:
* - SSH
* parameters:
* - in: path
* name: hostId
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: A list of commands.
* 400:
* description: Invalid userId or hostId.
* 500:
* description: Failed to fetch command history.
*/
router.get(
"/command-history/:hostId",
authenticateJWT,
requireViewPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const hostIdParam = Array.isArray(req.params.hostId)
? req.params.hostId[0]
: req.params.hostId;
const hostId = parseInt(hostIdParam, 10);
if (!isNonEmptyString(userId) || !hostId) {
sshLogger.warn("Invalid userId or hostId for command history fetch", {
operation: "command_history_fetch",
hostId,
userId,
});
return res.status(400).json({ error: "Invalid userId or hostId" });
}
try {
const history =
await createCurrentCommandHistoryRepository().listCommandsForHost(
userId,
hostId,
);
res.json(history);
} catch (err) {
sshLogger.error("Failed to fetch command history from database", err, {
operation: "command_history_fetch",
hostId,
userId,
});
res.status(500).json({ error: "Failed to fetch command history" });
}
},
);
/**
* @openapi
* /host/command-history:
* delete:
* summary: Delete command from history
* description: Deletes a specific command from the history of a host.
* tags:
* - SSH
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* hostId:
* type: integer
* command:
* type: string
* responses:
* 200:
* description: Command deleted from history.
* 400:
* description: Invalid data.
* 500:
* description: Failed to delete command.
*/
router.delete(
"/command-history",
authenticateJWT,
requireViewPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { hostId, command } = req.body;
if (!isNonEmptyString(userId) || !hostId || !command) {
sshLogger.warn("Invalid data for command history deletion", {
operation: "command_history_delete",
hostId,
userId,
});
return res.status(400).json({ error: "Invalid data" });
}
try {
await createCurrentCommandHistoryRepository().deleteCommandForHost(
userId,
hostId,
command,
);
res.json({ message: "Command deleted from history" });
} catch (err) {
sshLogger.error("Failed to delete command from history", err, {
operation: "command_history_delete",
hostId,
userId,
command,
});
res.status(500).json({ error: "Failed to delete command" });
}
},
);
}
@@ -3,7 +3,6 @@ import type { AuthenticatedRequest } from "../../../types/index.js";
import { databaseLogger, sshLogger } from "../../utils/logger.js";
import { pluginEvents, TOPICS } from "../../plugins/events.js";
import {
createCurrentCommandHistoryRepository,
createCurrentCredentialRepository,
createCurrentHostFolderRepository,
createCurrentFolderAccessRepository,
@@ -398,13 +397,9 @@ export function registerHostFolderRoutes(
const hostIds = hostsToDelete.map((host) => host.id);
if (hostIds.length > 0) {
// file manager recent/pinned/shortcuts and transfer_recent cascade
// on the host's refHost() foreign key, as the file-manager
// plugin's adopted tables.
await createCurrentCommandHistoryRepository().deleteByHostIds(
hostIds,
);
// file manager recent/pinned/shortcuts, transfer_recent and command
// history cascade on the host's refHost() foreign key, as the
// file-manager and ssh-terminal plugins' adopted tables.
await createCurrentSshCredentialUsageRepository().deleteByHostIds(
hostIds,
+5 -16
View File
@@ -16,7 +16,6 @@ import {
} from "../../hosts/credential-username.js";
import { notifyAutomationInternalEvent } from "../../hosts/automation-events.js";
import {
createCurrentCommandHistoryRepository,
createCurrentCredentialRepository,
createCurrentOpksshTokenRepository,
createCurrentRecentActivityRepository,
@@ -53,7 +52,6 @@ import { validateParentHostId } from "./host-parent-validation.js";
import { registerHostOpksshRoutes } from "./host-opkssh-routes.js";
import { registerHostStepCaRoutes } from "./host-step-ca-routes.js";
import { registerHostFolderRoutes } from "./host-folder-routes.js";
import { registerHostCommandHistoryRoutes } from "./host-command-history-routes.js";
import { registerHostAutostartRoutes } from "./host-autostart-routes.js";
import { registerHostInternalRoutes } from "./host-internal-routes.js";
import { registerHostNetworkRoutes } from "./host-network-routes.js";
@@ -2530,13 +2528,9 @@ router.delete(
const numericHostId = Number(hostId);
// file manager recent/pinned/shortcuts and transfer_recent cascade on
// the host's refHost() foreign key, as the file-manager plugin's
// adopted tables.
await createCurrentCommandHistoryRepository().deleteByHostId(
numericHostId,
);
// file manager recent/pinned/shortcuts, transfer_recent and command
// history cascade on the host's refHost() foreign key, as the
// file-manager and ssh-terminal plugins' adopted tables.
await createCurrentSshCredentialUsageRepository().deleteByHostId(
numericHostId,
@@ -2612,13 +2606,8 @@ router.delete(
);
// File manager recent/pinned/shortcuts and transfer/recent routes moved to
// the file-manager plugin, under /plugin-api/file-manager/.
registerHostCommandHistoryRoutes(
router,
authenticateJWT,
permissionManager.requirePermission("hosts.view"),
requireDataAccess,
);
// the file-manager plugin, under /plugin-api/file-manager/, and command
// history to the ssh-terminal plugin, under /plugin-api/ssh-terminal/.
async function resolveHostCredentials(
host: Record<string, unknown>,
+35 -19
View File
@@ -2,9 +2,8 @@ import type { AuthenticatedRequest } from "../../../types/index.js";
import express, { type Request, type Response } from "express";
import { databaseLogger } from "../../utils/logger.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { sessionManager } from "../../hosts/terminal/session-manager.js";
import { liveTerminalSessions } from "../../hosts/live-terminal-sessions.js";
import {
getCurrentSettingValue,
createCurrentOpenTabRepository,
createCurrentSessionShareRepository,
} from "../repositories/factory.js";
@@ -26,17 +25,10 @@ const authenticateJWT = authManager.createAuthMiddleware();
*/
const DEFAULT_TAB_TTL_MINUTES = 30;
// Tabs live as long as the terminal keeps a detached session.
function getTabTtlMs(): number {
try {
const value = getCurrentSettingValue("terminal_session_timeout_minutes");
if (value) {
const minutes = parseInt(value, 10);
if (!isNaN(minutes) && minutes > 0) return minutes * 60_000;
}
} catch {
// DB not available, use default
}
return DEFAULT_TAB_TTL_MINUTES * 60_000;
const minutes = liveTerminalSessions.idleTimeoutMinutes();
return (minutes && minutes > 0 ? minutes : DEFAULT_TAB_TTL_MINUTES) * 60_000;
}
// Legacy tab types that were renamed. Normalize on read so previously saved
@@ -273,6 +265,33 @@ router.delete("/:id", authenticateJWT, async (req: Request, res: Response) => {
}
});
/**
* @openapi
* /open-tabs/session-timeout:
* get:
* summary: Get how long a detached session is kept
* description: The terminal's idle timeout for detached sessions, which is also how long a saved open tab lives. 30 while the terminal is off.
* tags:
* - Open Tabs
* responses:
* 200:
* description: The timeout in minutes.
* content:
* application/json:
* schema:
* type: object
* properties:
* minutes:
* type: integer
*/
router.get(
"/session-timeout",
authenticateJWT,
(_req: Request, res: Response) => {
res.json({ minutes: getTabTtlMs() / 60_000 });
},
);
/**
* @openapi
* /open-tabs/active-sessions:
@@ -324,12 +343,12 @@ router.get(
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const ownSessions = sessionManager.getUserSessions(userId);
const ownSessions = liveTerminalSessions.listForUser(userId);
const result = ownSessions.map((s) => ({
sessionId: s.id,
hostId: s.hostId,
hostName: s.hostName,
tabInstanceId: s.attachedTabInstanceId ?? s.tabInstanceId ?? null,
tabInstanceId: s.tabInstanceId,
isConnected: s.isConnected,
createdAt: s.createdAt,
isOwnSession: true,
@@ -344,16 +363,13 @@ router.get(
);
for (const share of sharedWithMe) {
if (share.protocol !== "ssh") continue;
const sharedSession = sessionManager.getSession(share.sessionId);
const sharedSession = liveTerminalSessions.getSession(share.sessionId);
if (!sharedSession || !sharedSession.isConnected) continue;
result.push({
sessionId: sharedSession.id,
hostId: sharedSession.hostId,
hostName: sharedSession.hostName,
tabInstanceId:
sharedSession.attachedTabInstanceId ??
sharedSession.tabInstanceId ??
null,
tabInstanceId: sharedSession.tabInstanceId,
isConnected: sharedSession.isConnected,
createdAt: sharedSession.createdAt,
isOwnSession: false,
@@ -1,264 +0,0 @@
import path from "path";
import { databaseLogger } from "../../utils/logger.js";
/**
* Terminal image storage modes.
*
* - `local`: always write to the backend's mapped local storage. Deterministic:
* never falls back to the remote SFTP path.
* - `remote-sftp`: always write to the connected terminal's SSH host over SFTP.
* Deterministic: never falls back to local storage.
* - `auto`: pick by capability only — remote SFTP when a connected terminal
* session exists, local storage otherwise. Configuration never influences
* this choice.
*/
export const TERMINAL_IMAGE_STORAGE_MODES = [
"auto",
"local",
"remote-sftp",
] as const;
export type TerminalImageStorageMode =
(typeof TERMINAL_IMAGE_STORAGE_MODES)[number];
export interface TerminalImageStorageSettings {
mode: TerminalImageStorageMode;
/** Absolute path on the Termix backend where local-mode files are written. */
localDir: string;
/**
* Absolute path handed to the terminal agent in local mode. This is the
* host-side view of `localDir` (e.g. /tmp mapped into the container); it is
* the only path ever exposed to callers.
*/
hostPath: string;
ttlMs: number;
maxCount: number;
maxBytes: number;
/** Both localDir and hostPath were explicitly configured and must be probed. */
localMappingConfigured: boolean;
}
/** Settings-table keys. Persisted values always win over environment. */
export const TERMINAL_IMAGE_STORAGE_KEYS = {
mode: "terminal_image_storage_mode",
localDir: "terminal_image_local_dir",
hostPath: "terminal_image_host_path",
ttlMs: "terminal_image_ttl_ms",
maxCount: "terminal_image_max_count",
maxBytes: "terminal_image_max_storage_bytes",
} as const;
/**
* Legacy environment variables from the original env-only configuration. They
* seed defaults only when no database value exists for the same field.
*/
export const TERMINAL_IMAGE_STORAGE_ENV = {
mode: "TERMIX_IMAGE_STORAGE_MODE",
localDir: "TERMIX_IMAGE_DIR",
hostPath: "TERMIX_IMAGE_HOST_PATH",
ttlMs: "TERMIX_IMAGE_TTL_MS",
maxCount: "TERMIX_MAX_IMAGE_COUNT",
maxBytes: "TERMIX_MAX_IMAGE_STORAGE_BYTES",
} as const;
export const DEFAULT_IMAGE_TTL_MS = 3_600_000;
export const DEFAULT_IMAGE_MAX_COUNT = 100;
export const DEFAULT_IMAGE_MAX_BYTES = 5_368_709_120;
export const DEFAULT_IMAGE_HOST_PATH = "/tmp/termix-image-v0";
export const MIN_IMAGE_MAX_BYTES = 1_048_576;
export function defaultImageLocalDir(env: NodeJS.ProcessEnv): string {
return path.resolve(
path.join(env.DATA_DIR || "./db/data", "termix-image-v0"),
);
}
export function parseTerminalImageStorageMode(
value: unknown,
): TerminalImageStorageMode | null {
if (typeof value !== "string") return null;
const normalized = value.trim().toLowerCase();
return (TERMINAL_IMAGE_STORAGE_MODES as readonly string[]).includes(
normalized,
)
? (normalized as TerminalImageStorageMode)
: null;
}
/**
* Local write directory must be an absolute path without NUL bytes. Relative
* values are rejected rather than resolved: a relative entry silently depends
* on the process cwd, which differs between Docker, systemd and dev runs.
*/
export function parseImageLocalDir(value: unknown): string | null {
if (typeof value !== "string") return null;
const trimmed = value.trim();
if (!trimmed || hasUnsafePathSyntax(trimmed)) return null;
if (!path.isAbsolute(trimmed)) return null;
return path.resolve(trimmed);
}
/** Agent-visible path. Always POSIX-style and absolute. */
export function parseImageHostPath(value: unknown): string | null {
if (typeof value !== "string") return null;
const trimmed = value.trim();
if (!trimmed || hasUnsafePathSyntax(trimmed)) return null;
if (!path.posix.isAbsolute(trimmed)) return null;
return path.posix.normalize(trimmed);
}
/**
* Numeric fields: unparseable values are rejected (caller falls through to the
* next source); parseable but out-of-range values are clamped, matching the
* original env-only behavior.
*/
function parseClampedInt(value: unknown, min: number): number | null {
if (typeof value !== "string" && typeof value !== "number") return null;
const parsed =
typeof value === "number" ? value : Number.parseInt(value.trim(), 10);
if (!Number.isFinite(parsed)) return null;
return Math.max(min, Math.trunc(parsed));
}
interface SettingSource {
get(key: string): Promise<string | null>;
}
function warnInvalid(key: string, source: string): void {
databaseLogger.warn("Ignoring invalid terminal image storage setting", {
operation: "terminal_image_storage_settings_invalid",
key,
source,
});
}
function hasUnsafePathSyntax(value: string): boolean {
return (
/[\u0000-\u001f\u007f]/.test(value) ||
value.split(/[\\/]+/).some((segment) => segment === "..") ||
value.includes("//") ||
value.includes("\\")
);
}
/**
* Resolves the effective image storage settings.
*
* Per-field precedence: a valid persisted database value wins; a legacy
* TERMIX_IMAGE_* variable seeds the default only when no database value
* exists; otherwise the built-in default applies. Invalid values are skipped
* with a warning and resolution falls through to the next source.
*
* Mode compatibility: with no mode in the database or environment, an explicit
* legacy local mapping (TERMIX_IMAGE_DIR) keeps old deployments on `local`;
* everything else defaults to `auto`.
*/
export async function resolveTerminalImageStorageSettings(
settings: SettingSource,
env: NodeJS.ProcessEnv = process.env,
): Promise<TerminalImageStorageSettings> {
async function pick<T>(
key: string,
envName: string,
parse: (value: unknown) => T | null,
fallback: T,
): Promise<T> {
const stored = await settings.get(key);
if (stored !== null) {
const parsed = parse(stored);
if (parsed !== null) return parsed;
warnInvalid(key, "database");
}
const fromEnv = env[envName];
if (fromEnv !== undefined) {
const parsed = parse(fromEnv);
if (parsed !== null) return parsed;
warnInvalid(key, "environment");
}
return fallback;
}
const dbModeRaw = await settings.get(TERMINAL_IMAGE_STORAGE_KEYS.mode);
let mode: TerminalImageStorageMode | null = null;
if (dbModeRaw !== null) {
mode = parseTerminalImageStorageMode(dbModeRaw);
if (mode === null)
warnInvalid(TERMINAL_IMAGE_STORAGE_KEYS.mode, "database");
}
if (mode === null) {
const envModeRaw = env[TERMINAL_IMAGE_STORAGE_ENV.mode];
if (envModeRaw !== undefined) {
mode = parseTerminalImageStorageMode(envModeRaw);
if (mode === null)
warnInvalid(TERMINAL_IMAGE_STORAGE_KEYS.mode, "environment");
}
}
if (mode === null) {
// Legacy deployments configured local storage purely through
// TERMIX_IMAGE_DIR; keep them on local mode unless a database value says
// otherwise.
mode =
env[TERMINAL_IMAGE_STORAGE_ENV.localDir] !== undefined ? "local" : "auto";
}
const legacyLocalDir = parseImageLocalDir(
env[TERMINAL_IMAGE_STORAGE_ENV.localDir],
);
const [localDir, hostPath, ttlMs, maxCount, maxBytes] = await Promise.all([
pick(
TERMINAL_IMAGE_STORAGE_KEYS.localDir,
TERMINAL_IMAGE_STORAGE_ENV.localDir,
parseImageLocalDir,
defaultImageLocalDir(env),
),
pick(
TERMINAL_IMAGE_STORAGE_KEYS.hostPath,
TERMINAL_IMAGE_STORAGE_ENV.hostPath,
parseImageHostPath,
legacyLocalDir ?? DEFAULT_IMAGE_HOST_PATH,
),
pick(
TERMINAL_IMAGE_STORAGE_KEYS.ttlMs,
TERMINAL_IMAGE_STORAGE_ENV.ttlMs,
(value) => parseClampedInt(value, 0),
DEFAULT_IMAGE_TTL_MS,
),
pick(
TERMINAL_IMAGE_STORAGE_KEYS.maxCount,
TERMINAL_IMAGE_STORAGE_ENV.maxCount,
(value) => parseClampedInt(value, 1),
DEFAULT_IMAGE_MAX_COUNT,
),
pick(
TERMINAL_IMAGE_STORAGE_KEYS.maxBytes,
TERMINAL_IMAGE_STORAGE_ENV.maxBytes,
(value) => parseClampedInt(value, MIN_IMAGE_MAX_BYTES),
DEFAULT_IMAGE_MAX_BYTES,
),
]);
const persistedLocalDir = await settings.get(
TERMINAL_IMAGE_STORAGE_KEYS.localDir,
);
const persistedHostPath = await settings.get(
TERMINAL_IMAGE_STORAGE_KEYS.hostPath,
);
const localMappingConfigured =
((persistedLocalDir !== null &&
parseImageLocalDir(persistedLocalDir) !== null) ||
parseImageLocalDir(env[TERMINAL_IMAGE_STORAGE_ENV.localDir]) !== null) &&
((persistedHostPath !== null &&
parseImageHostPath(persistedHostPath) !== null) ||
parseImageHostPath(env[TERMINAL_IMAGE_STORAGE_ENV.hostPath]) !== null ||
legacyLocalDir !== null);
return {
mode,
localDir,
hostPath,
ttlMs,
maxCount,
maxBytes,
localMappingConfigured,
};
}
@@ -1,675 +0,0 @@
import fs from "fs/promises";
import path from "path";
import { randomUUID } from "crypto";
import {
exceedsImageStorageLimit,
isExpiredImage,
isImageFilename,
} from "./terminal-image-utils.js";
import type { TerminalImageStorageSettings } from "./terminal-image-storage-settings.js";
/**
* Stable error codes for image storage failures. These are part of the upload
* route's contract; `IMAGE_REMOTE_WRITE_FAILED` predates this module and must
* not change.
*/
export type TerminalImageStorageErrorCode =
| "IMAGE_STORAGE_LIMIT_REACHED"
| "IMAGE_LOCAL_WRITE_FAILED"
| "IMAGE_LOCAL_INSPECTION_FAILED"
| "IMAGE_REMOTE_QUOTA_UNAVAILABLE"
| "IMAGE_REMOTE_WRITE_FAILED";
export class TerminalImageStorageError extends Error {
constructor(
readonly code: TerminalImageStorageErrorCode,
message: string,
readonly cause?: unknown,
) {
super(message);
this.name = "TerminalImageStorageError";
}
}
export interface StoredTerminalImage {
id: string;
filename: string;
/** Agent-visible path; never a backend-internal path. */
shellPath: string;
storage: "local" | "remote-sftp";
}
/**
* Mode selection for one upload. Explicit modes are deterministic — they are
* returned regardless of capability and the route reports the failure; only
* `auto` falls back, and only on capability (a connected terminal session
* with SFTP), never on configuration.
*/
export function selectImageStorageMode(
settings: Pick<
TerminalImageStorageSettings,
"mode" | "localMappingConfigured"
>,
capability: { remoteSftpAvailable: boolean; localHostVisible?: boolean },
): "local" | "remote-sftp" | "unavailable" {
if (settings.mode === "local") return "local";
if (settings.mode === "remote-sftp") return "remote-sftp";
if (settings.localMappingConfigured && capability.localHostVisible === true) {
return "local";
}
return capability.remoteSftpAvailable ? "remote-sftp" : "unavailable";
}
// Capacity checks and writes are serialized so concurrent uploads cannot
// bypass the count or byte limits.
let imageStorageQueue: Promise<unknown> = Promise.resolve();
function withImageStorageLock<T>(operation: () => Promise<T>): Promise<T> {
const result = imageStorageQueue.then(operation, operation);
imageStorageQueue = result.then(
() => undefined,
() => undefined,
);
return result;
}
async function cleanupExpiredImages(
localDir: string,
ttlMs: number,
): Promise<void> {
const entries = await fs.readdir(localDir, { withFileTypes: true });
const now = Date.now();
await Promise.all(
entries
.filter((entry) => entry.isFile() && isImageFilename(entry.name))
.map(async (entry) => {
const filePath = path.join(localDir, entry.name);
const stat = await fs.stat(filePath);
if (stat && isExpiredImage(stat.mtimeMs, now, ttlMs)) {
await fs.unlink(filePath).catch(() => undefined);
}
}),
);
}
async function getActiveImageStorageUsage(
localDir: string,
ttlMs: number,
): Promise<{ fileCount: number; totalBytes: number }> {
const entries = await fs.readdir(localDir, { withFileTypes: true });
const now = Date.now();
const stats = await Promise.all(
entries
.filter((entry) => entry.isFile() && isImageFilename(entry.name))
.map(async (entry) => {
const stat = await fs.stat(path.join(localDir, entry.name));
return !isExpiredImage(stat.mtimeMs, now, ttlMs) ? stat : null;
}),
);
return stats.reduce(
(usage, stat) => {
if (stat) {
usage.fileCount += 1;
usage.totalBytes += stat.size;
}
return usage;
},
{ fileCount: 0, totalBytes: 0 },
);
}
/**
* Local mapped-storage adapter. Enforces the TTL/count/byte policy and raises
* `IMAGE_STORAGE_LIMIT_REACHED` (HTTP 507 at the route) when the caps are hit.
* The returned shellPath is built from the agent-visible hostPath — the
* backend's own localDir is never exposed.
*/
export async function storeImageLocally(
image: Buffer,
settings: TerminalImageStorageSettings,
): Promise<StoredTerminalImage> {
return withImageStorageLock(async () => {
let usage: { fileCount: number; totalBytes: number };
try {
await fs.mkdir(settings.localDir, { recursive: true });
await cleanupExpiredImages(settings.localDir, settings.ttlMs);
usage = await getActiveImageStorageUsage(
settings.localDir,
settings.ttlMs,
);
} catch (error) {
throw new TerminalImageStorageError(
"IMAGE_LOCAL_INSPECTION_FAILED",
"Unable to inspect local image storage",
error,
);
}
if (
exceedsImageStorageLimit(
usage.fileCount,
usage.totalBytes,
image.length,
settings.maxCount,
settings.maxBytes,
)
) {
throw new TerminalImageStorageError(
"IMAGE_STORAGE_LIMIT_REACHED",
"Image storage limit reached",
);
}
const id = randomUUID();
const filename = `${id}.png`;
try {
await fs.writeFile(path.join(settings.localDir, filename), image);
} catch (error) {
await fs
.rm(path.join(settings.localDir, filename), { force: true })
.catch(() => undefined);
throw new TerminalImageStorageError(
"IMAGE_LOCAL_WRITE_FAILED",
"Failed to write image to local storage",
error,
);
}
return {
id,
filename,
shellPath: path.posix.join(settings.hostPath, filename),
storage: "local",
};
});
}
// Remote directory (on the SSH host the terminal is connected to) that
// uploaded/pasted images are written into. Always POSIX-style: this is a
// path on the remote shell, not on the Termix backend's own filesystem.
export const REMOTE_IMAGE_DIR = "/tmp/termix-images";
/** Minimal SFTP surface the remote adapter needs (satisfied by ssh2). */
export interface ImageSftpClient {
mkdir(
dir: string,
attrsOrCallback: { mode?: number } | ((err?: Error) => void),
callback?: (err?: Error) => void,
): void;
createWriteStream(
remotePath: string,
options?: { mode?: number },
): NodeJS.WritableStream;
stat?: (
dir: string,
callback: (
error: Error | undefined,
attrs?: { mode?: number; mtime?: number },
) => void,
) => void;
lstat?: (
dir: string,
callback: (
error: Error | undefined,
attrs?: { mode?: number; mtime?: number },
) => void,
) => void;
chmod?: (
dir: string,
mode: number,
callback: (error?: Error) => void,
) => void;
readdir?: (
dir: string,
callback: (error: Error | undefined, entries: ImageSftpEntry[]) => void,
) => void;
unlink?: (remotePath: string, callback: (error?: Error) => void) => void;
rmdir?: (dir: string, callback: (error?: Error) => void) => void;
end?: () => void;
}
interface ImageSftpEntry {
filename: string;
attrs?: { mtime?: number; size?: number };
}
export interface ImageSshExecClient {
exec(
command: string,
callback: (error: Error | undefined, stream?: ImageExecStream) => void,
): void;
}
interface ImageExecStream {
on(event: "close", listener: (code: number | null) => void): this;
on(event: "error", listener: (error: Error) => void): this;
resume(): void;
}
function quoteRemotePath(value: string): string {
return `'${value.replace(/'/g, `'"'"'`)}'`;
}
function execBounded(
sshConn: ImageSshExecClient,
command: string,
timeoutMs = 3_000,
): Promise<boolean> {
return new Promise((resolve) => {
let settled = false;
const finish = (result: boolean) => {
if (settled) return;
settled = true;
resolve(result);
};
const timer = setTimeout(() => finish(false), timeoutMs);
sshConn.exec(command, (error, stream) => {
if (error || !stream) {
clearTimeout(timer);
finish(false);
return;
}
stream.on("close", (code) => {
clearTimeout(timer);
finish(code === 0);
});
stream.on("error", () => {
clearTimeout(timer);
finish(false);
});
stream.resume();
});
});
}
function withTimeout<T>(
operation: Promise<T>,
timeoutMs: number,
message: string,
): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error(message)), timeoutMs);
operation.then(
(value) => {
clearTimeout(timer);
resolve(value);
},
(error) => {
clearTimeout(timer);
reject(error);
},
);
});
}
/** Verify a configured local mapping from the currently connected SSH session. */
export async function probeLocalImageVisibility(
sshConn: ImageSshExecClient,
settings: Pick<TerminalImageStorageSettings, "localDir" | "hostPath">,
): Promise<boolean> {
const filename = `.termix-image-probe-${randomUUID()}`;
const localProbe = path.join(settings.localDir, filename);
const remoteProbe = path.posix.join(settings.hostPath, filename);
await fs.mkdir(settings.localDir, { recursive: true });
await fs.writeFile(localProbe, "termix-image-probe", { flag: "wx" });
try {
return await execBounded(
sshConn,
`test -f -- ${quoteRemotePath(remoteProbe)}`,
);
} finally {
await fs.unlink(localProbe).catch(() => undefined);
await execBounded(sshConn, `rm -f -- ${quoteRemotePath(remoteProbe)}`);
}
}
function sftpMkdir(sftp: ImageSftpClient, dir: string): Promise<void> {
return new Promise((resolve, reject) => {
sftp.mkdir(dir, { mode: 0o700 }, (err) => {
if (!err) {
resolve();
return;
}
const inspect = (sftp.lstat ?? sftp.stat)?.bind(sftp);
if (!inspect) {
reject(err);
return;
}
inspect(dir, (inspectError, attrs) => {
if (inspectError || !attrs) {
reject(inspectError ?? err);
return;
}
if (attrs.mode !== undefined && (attrs.mode & 0o170000) !== 0o040000) {
reject(new Error("Remote image path is not a directory"));
return;
}
if (!sftp.chmod) {
reject(
new Error("Remote image directory permissions cannot be verified"),
);
return;
}
sftp.chmod(dir, 0o700, (chmodError) => {
if (chmodError) reject(chmodError);
else resolve();
});
});
});
});
}
const REMOTE_IMAGE_LOCK_DIR = `${REMOTE_IMAGE_DIR}/.termix-write-lock`;
const REMOTE_IMAGE_LOCK_LEASE_MS = 60_000;
function waitForRemoteImageLock(
sftp: ImageSftpClient,
attempts = 60,
): Promise<() => Promise<void>> {
if (!sftp.rmdir) {
return Promise.reject(
new TerminalImageStorageError(
"IMAGE_REMOTE_QUOTA_UNAVAILABLE",
"Remote image storage lock cannot be verified",
),
);
}
return new Promise((resolve, reject) => {
let remaining = attempts;
const tryAcquire = () => {
sftp.mkdir(REMOTE_IMAGE_LOCK_DIR, { mode: 0o700 }, (error) => {
if (!error) {
resolve(
() =>
new Promise<void>((releaseResolve, releaseReject) => {
sftp.rmdir!(REMOTE_IMAGE_LOCK_DIR, (releaseError) =>
releaseError ? releaseReject(releaseError) : releaseResolve(),
);
}),
);
return;
}
const inspect = (sftp.lstat ?? sftp.stat)?.bind(sftp);
if (!inspect) {
reject(
new TerminalImageStorageError(
"IMAGE_REMOTE_QUOTA_UNAVAILABLE",
"Remote image storage lock cannot be verified",
error,
),
);
return;
}
inspect(REMOTE_IMAGE_LOCK_DIR, (inspectError, attrs) => {
const stale =
!inspectError &&
typeof attrs?.mtime === "number" &&
Date.now() - attrs.mtime * 1000 > REMOTE_IMAGE_LOCK_LEASE_MS;
if (stale) {
sftp.rmdir!(REMOTE_IMAGE_LOCK_DIR, (removeError) => {
if (removeError) {
if (--remaining <= 0) {
reject(
new TerminalImageStorageError(
"IMAGE_REMOTE_QUOTA_UNAVAILABLE",
"Stale remote image storage lock cannot be removed",
removeError,
),
);
return;
}
setTimeout(tryAcquire, 50);
return;
}
tryAcquire();
});
return;
}
if (--remaining <= 0) {
reject(
new TerminalImageStorageError(
"IMAGE_REMOTE_QUOTA_UNAVAILABLE",
"Remote image storage lock is unavailable",
error,
),
);
return;
}
setTimeout(tryAcquire, 50);
});
});
};
tryAcquire();
});
}
function sftpWriteFile(
sftp: ImageSftpClient,
remotePath: string,
data: Buffer,
timeoutMs = 10_000,
): Promise<void> {
return new Promise((resolve, reject) => {
const stream = sftp.createWriteStream(remotePath, {
mode: 0o600,
}) as NodeJS.WritableStream & {
destroy?: () => void;
};
let settled = false;
const timer = setTimeout(() => {
if (settled) return;
settled = true;
stream.destroy?.();
reject(new Error("SFTP image write timed out"));
}, timeoutMs);
const finish = (error?: Error) => {
if (settled) return;
settled = true;
clearTimeout(timer);
if (error) reject(error);
else resolve();
};
stream.on("error", (error: Error) => finish(error));
stream.on("close", () => finish());
stream.end(data);
});
}
async function cleanupExpiredRemoteImages(
sftp: ImageSftpClient,
ttlMs: number | undefined,
nowMs = Date.now(),
): Promise<void> {
if (!ttlMs || ttlMs <= 0 || !sftp.readdir || !sftp.unlink) return;
const entries = await new Promise<ImageSftpEntry[]>((resolve) => {
sftp.readdir!(REMOTE_IMAGE_DIR, (error, result) => {
resolve(error ? [] : result);
});
});
const cutoffSeconds = (nowMs - ttlMs) / 1000;
const uuidPng =
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\.png$/i;
await Promise.all(
entries
.filter(
(entry) =>
uuidPng.test(entry.filename) &&
typeof entry.attrs?.mtime === "number" &&
entry.attrs.mtime < cutoffSeconds,
)
.map((entry) =>
withTimeout(
new Promise<void>((resolve) => {
sftp.unlink!(`${REMOTE_IMAGE_DIR}/${entry.filename}`, () =>
resolve(),
);
}),
3_000,
"SFTP cleanup operation timed out",
).catch(() => undefined),
),
);
}
const REMOTE_UUID_PNG_PATTERN =
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\.png$/i;
async function enforceRemoteImageLimits(
sftp: ImageSftpClient,
imageBytes: number,
maxCount: number,
maxBytes: number,
): Promise<void> {
if (!sftp.readdir) {
throw new TerminalImageStorageError(
"IMAGE_REMOTE_QUOTA_UNAVAILABLE",
"Remote image storage limits cannot be verified",
);
}
let entries: ImageSftpEntry[];
try {
entries = await new Promise<ImageSftpEntry[]>((resolve, reject) => {
sftp.readdir!(REMOTE_IMAGE_DIR, (error, result) => {
if (error) reject(error);
else resolve(result);
});
});
} catch (error) {
throw new TerminalImageStorageError(
"IMAGE_REMOTE_QUOTA_UNAVAILABLE",
"Remote image storage limits cannot be verified",
error,
);
}
const images = entries.filter((entry) =>
REMOTE_UUID_PNG_PATTERN.test(entry.filename),
);
const totalBytes = images.reduce((sum, entry) => {
if (typeof entry.attrs?.size !== "number") {
throw new TerminalImageStorageError(
"IMAGE_REMOTE_QUOTA_UNAVAILABLE",
"Remote image storage limits cannot be verified",
);
}
return sum + entry.attrs.size;
}, 0);
if (images.length >= maxCount || totalBytes + imageBytes > maxBytes) {
throw new TerminalImageStorageError(
"IMAGE_STORAGE_LIMIT_REACHED",
"Image storage limit reached",
);
}
}
async function storeImageViaSftpUnlocked(
sftp: ImageSftpClient,
image: Buffer,
options: {
writeTimeoutMs?: number;
ttlMs?: number;
maxCount?: number;
maxBytes?: number;
nowMs?: number;
} = {},
): Promise<StoredTerminalImage> {
const id = randomUUID();
const filename = `${id}.png`;
const remotePath = `${REMOTE_IMAGE_DIR}/${filename}`;
let releaseRemoteLock: (() => Promise<void>) | undefined;
let operationError: TerminalImageStorageError | undefined;
try {
await withTimeout(
sftpMkdir(sftp, REMOTE_IMAGE_DIR),
3_000,
"SFTP directory operation timed out",
);
releaseRemoteLock = await withTimeout(
waitForRemoteImageLock(sftp),
10_000,
"SFTP lock operation timed out",
);
await withTimeout(
cleanupExpiredRemoteImages(sftp, options.ttlMs, options.nowMs),
5_000,
"SFTP cleanup operation timed out",
);
if (options.maxCount !== undefined || options.maxBytes !== undefined) {
await withTimeout(
enforceRemoteImageLimits(
sftp,
image.length,
options.maxCount ?? 100,
options.maxBytes ?? 5_368_709_120,
),
5_000,
"SFTP quota operation timed out",
);
}
await sftpWriteFile(sftp, remotePath, image, options.writeTimeoutMs);
} catch (error) {
if (sftp.unlink) {
await withTimeout(
new Promise<void>((resolve) => {
sftp.unlink!(remotePath, () => resolve());
}),
3_000,
"SFTP cleanup operation timed out",
).catch(() => undefined);
}
operationError =
error instanceof TerminalImageStorageError
? error
: new TerminalImageStorageError(
"IMAGE_REMOTE_WRITE_FAILED",
"Failed to write image to the remote host",
error,
);
}
if (releaseRemoteLock) {
try {
await withTimeout(
releaseRemoteLock(),
3_000,
"SFTP lock release timed out",
);
} catch (releaseError) {
if (!operationError) {
if (sftp.unlink) {
await withTimeout(
new Promise<void>((resolve) => {
sftp.unlink!(remotePath, () => resolve());
}),
3_000,
"SFTP cleanup operation timed out",
).catch(() => undefined);
}
operationError = new TerminalImageStorageError(
"IMAGE_REMOTE_WRITE_FAILED",
"Failed to release remote image storage lock",
releaseError,
);
}
}
}
if (operationError) throw operationError;
return { id, filename, shellPath: remotePath, storage: "remote-sftp" };
}
export function storeImageViaSftp(
sftp: ImageSftpClient,
image: Buffer,
options: Parameters<typeof storeImageViaSftpUnlocked>[2] = {},
): Promise<StoredTerminalImage> {
return withImageStorageLock(() =>
withTimeout(
storeImageViaSftpUnlocked(sftp, image, options),
20_000,
"SFTP image operation timed out",
),
);
}
@@ -1,100 +0,0 @@
// Accepted decoded input formats; uploads are normalized to PNG by the route.
export const IMAGE_FORMAT_EXTENSIONS: Record<string, string> = {
avif: "avif",
gif: "gif",
heif: "heif",
jpeg: "jpg",
jp2: "jp2",
jxl: "jxl",
png: "png",
tiff: "tiff",
webp: "webp",
};
export function imageExtensionForFormat(
format: string | undefined,
): string | undefined {
return format ? IMAGE_FORMAT_EXTENSIONS[format] : undefined;
}
export const MAX_NORMALIZED_IMAGE_BYTES = 10 * 1024 * 1024;
export function exceedsNormalizedImageSize(
byteLength: number,
maxBytes = MAX_NORMALIZED_IMAGE_BYTES,
): boolean {
return byteLength > maxBytes;
}
export function createConcurrencyLimiter(
limit: number,
maxQueued = Number.POSITIVE_INFINITY,
): {
acquire: () => Promise<() => void>;
readonly active: number;
readonly queued: number;
} {
const max = Math.max(1, Math.floor(limit));
const queueLimit = Math.max(0, Math.floor(maxQueued));
let active = 0;
const waiters: Array<() => void> = [];
const startNext = () => {
if (active >= max || waiters.length === 0) return;
active += 1;
waiters.shift()!();
};
return {
acquire: () =>
new Promise<() => void>((resolve, reject) => {
if (active >= max && waiters.length >= queueLimit) {
reject(new Error("Concurrency admission queue is full"));
return;
}
waiters.push(() => {
let released = false;
resolve(() => {
if (released) return;
released = true;
active -= 1;
startNext();
});
});
startNext();
}),
get active() {
return active;
},
get queued() {
return waiters.length;
},
};
}
export const IMAGE_FILENAME_PATTERN = /^[0-9a-f-]{36}\.[a-z0-9]+$/i;
export function isImageFilename(filename: string): boolean {
return IMAGE_FILENAME_PATTERN.test(filename);
}
export function isExpiredImage(
modifiedAtMs: number,
nowMs: number,
ttlMs: number,
): boolean {
if (ttlMs <= 0) return false;
return nowMs - modifiedAtMs > ttlMs;
}
export function exceedsImageStorageLimit(
fileCount: number,
totalBytes: number,
incomingBytes: number,
maxFileCount: number,
maxStorageBytes: number,
): boolean {
return (
fileCount >= maxFileCount || totalBytes + incomingBytes > maxStorageBytes
);
}
-778
View File
@@ -1,778 +0,0 @@
import { getErrorMessage } from "../../utils/error-message.js";
import type { AuthenticatedRequest } from "../../../types/index.js";
import express, {
type NextFunction,
type Request,
type Response,
} from "express";
import { randomUUID } from "crypto";
import multer from "multer";
import {
createConcurrencyLimiter,
exceedsNormalizedImageSize,
imageExtensionForFormat,
} from "./terminal-image-utils.js";
import { resolveTerminalImageStorageSettings } from "./terminal-image-storage-settings.js";
import {
selectImageStorageMode,
probeLocalImageVisibility,
storeImageLocally,
storeImageViaSftp,
TerminalImageStorageError,
type ImageSftpClient,
} from "./terminal-image-storage.js";
import { authLogger, databaseLogger } from "../../utils/logger.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { sessionManager } from "../../hosts/terminal/session-manager.js";
import {
createCurrentCommandHistoryRepository,
createCurrentHostResolutionRepository,
createCurrentSettingsRepository,
} from "../repositories/factory.js";
const router = express.Router();
function isNonEmptyString(val: unknown): val is string {
return typeof val === "string" && val.trim().length > 0;
}
const authManager = AuthManager.getInstance();
const authenticateJWT = authManager.createAuthMiddleware();
const requireDataAccess = authManager.createDataAccessMiddleware();
// Browser image handoff for local terminal-agent workflows.
const imageUpload = multer({
storage: multer.memoryStorage(),
limits: {
fileSize: 50 * 1024 * 1024,
fields: 4,
fieldSize: 64 * 1024,
files: 1,
parts: 5,
headerPairs: 200,
},
});
const imageUploadMiddleware = imageUpload.single("image");
type SharpFactory = typeof import("sharp").default;
let sharpFactory: SharpFactory | null = null;
let sharpLoadFailure: string | null = null;
// A static import of sharp takes backend startup down with it when the native
// binary for the running architecture is missing. Resolve on first use so only
// image upload is lost.
async function loadSharp(): Promise<SharpFactory> {
if (sharpFactory) return sharpFactory;
if (sharpLoadFailure !== null) throw new Error(sharpLoadFailure);
try {
sharpFactory = (await import("sharp")).default;
return sharpFactory;
} catch (error) {
sharpLoadFailure = getErrorMessage(error, "unknown");
throw new Error(sharpLoadFailure);
}
}
const imageProcessingLimiter = createConcurrencyLimiter(4, 4);
const imageMultipartAdmissionLimiter = createConcurrencyLimiter(4, 4);
let imageUploadSequence = 0;
async function handleImageUploadMiddleware(
req: Request,
res: Response,
next: NextFunction,
): Promise<void> {
let releaseAdmission: (() => void) | undefined;
try {
releaseAdmission = await imageMultipartAdmissionLimiter.acquire();
} catch {
res.status(503).json({
error: "Image upload capacity is temporarily unavailable",
code: "IMAGE_UPLOAD_CAPACITY_EXCEEDED",
});
return;
}
imageUploadMiddleware(req, res, (error: unknown) => {
try {
if (!error) {
next();
return;
}
if (error instanceof multer.MulterError) {
databaseLogger.warn("Image upload multipart request rejected", {
operation: "terminal_image_upload_multipart_rejected",
code: error.code,
field: error.field,
contentType: req.headers["content-type"]?.split(";", 1)[0],
});
res.status(400).json({
error: "Image upload request rejected",
code: error.code,
field: error.field,
});
return;
}
databaseLogger.warn("Image upload multipart request malformed", {
operation: "terminal_image_upload_multipart_invalid",
contentType: req.headers["content-type"]?.split(";", 1)[0],
});
res.status(400).json({
error: "Malformed image upload request",
code: "IMAGE_MULTIPART_INVALID",
});
} finally {
releaseAdmission?.();
}
});
}
function findTerminalSession(userId: string, instanceId: string) {
return sessionManager
.getUserSessions(userId)
.find(
(session) =>
(session.attachedTabInstanceId ?? session.tabInstanceId) ===
instanceId && session.isConnected,
);
}
router.post(
"/image-upload",
authenticateJWT,
requireDataAccess,
handleImageUploadMiddleware,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const instanceId = req.body?.instanceId;
if (!req.file) {
return res.status(400).json({
error: "Image required",
code: "IMAGE_FILE_MISSING",
});
}
if (!isNonEmptyString(userId)) {
return res.status(400).json({
error: "Missing terminal session",
code: "IMAGE_SESSION_MISSING",
});
}
const requestId = randomUUID();
const sequence = ++imageUploadSequence;
const source =
req.body?.source === "file" || req.body?.source === "clipboard"
? req.body.source
: undefined;
const clientUploadTimestamp =
typeof req.body?.clientUploadTimestamp === "string" &&
!Number.isNaN(Date.parse(req.body.clientUploadTimestamp))
? req.body.clientUploadTimestamp
: undefined;
const serverReceivedAt = new Date().toISOString();
databaseLogger.info("Terminal image upload received", {
operation: "terminal_image_upload_received",
requestId,
sequence,
source,
clientUploadTimestamp,
serverReceivedAt,
bytes: req.file.size,
});
const storageSettings = await resolveTerminalImageStorageSettings(
createCurrentSettingsRepository(),
);
const session = isNonEmptyString(instanceId)
? findTerminalSession(userId, instanceId)
: undefined;
let localHostVisible = false;
if (storageSettings.localMappingConfigured && session?.sshConn) {
localHostVisible = await probeLocalImageVisibility(
session.sshConn,
storageSettings,
).catch(() => false);
}
const storageMode = selectImageStorageMode(storageSettings, {
remoteSftpAvailable: !!session?.sshConn,
localHostVisible,
});
if (storageMode === "unavailable") {
return res.status(503).json({
error: "Image storage is unavailable",
code: "IMAGE_STORAGE_UNAVAILABLE",
});
}
if (storageMode === "local" && !storageSettings.localMappingConfigured) {
return res.status(503).json({
error: "Local image storage is not configured",
code: "IMAGE_LOCAL_STORAGE_NOT_CONFIGURED",
});
}
if (storageMode === "remote-sftp") {
if (!isNonEmptyString(instanceId)) {
return res.status(400).json({
error: "Missing terminal session",
code: "IMAGE_SESSION_MISSING",
});
}
if (!session || !session.sshConn) {
return res.status(409).json({
error: "Terminal is not connected",
code: "IMAGE_TERMINAL_NOT_CONNECTED",
});
}
}
let sharp: SharpFactory;
try {
sharp = await loadSharp();
} catch (error) {
databaseLogger.error(
"Image processing unavailable: sharp failed to load",
error,
{
operation: "terminal_image_upload_sharp_unavailable",
reason: getErrorMessage(error, "unknown"),
},
);
return res.status(503).json({
error: "Image processing is unavailable on this installation",
code: "IMAGE_PROCESSING_UNAVAILABLE",
});
}
let normalizedImage: Buffer;
let releaseImageProcessingSlot: (() => void) | undefined;
try {
releaseImageProcessingSlot = await imageProcessingLimiter.acquire();
} catch {
return res.status(503).json({
error: "Image upload capacity is temporarily exhausted",
code: "IMAGE_UPLOAD_CAPACITY_EXCEEDED",
});
}
try {
const source = sharp(req.file.buffer, {
failOn: "error",
limitInputPixels: 40_000_000,
});
const { format } = await source.metadata();
if (!imageExtensionForFormat(format)) {
return res.status(400).json({
error: "Unsupported image format",
code: "IMAGE_FORMAT_UNSUPPORTED",
});
}
normalizedImage = await source.rotate().png().toBuffer();
if (exceedsNormalizedImageSize(normalizedImage.length)) {
return res.status(413).json({
error: "Normalized image is too large",
code: "IMAGE_NORMALIZED_SIZE_LIMIT",
});
}
} catch (error) {
databaseLogger.warn("Image upload failed image decoding", {
operation: "terminal_image_upload_decode",
mimeType: req.file.mimetype,
bytes: req.file.size,
reason: getErrorMessage(error, "unknown"),
});
return res.status(400).json({
error: "Invalid image data",
code: "IMAGE_DECODE_FAILED",
});
} finally {
releaseImageProcessingSlot?.();
}
let remoteSftp: ImageSftpClient | undefined;
try {
const stored =
storageMode === "remote-sftp"
? await (async () => {
remoteSftp = await new Promise<ImageSftpClient>(
(resolve, reject) => {
let settled = false;
const timer = setTimeout(() => {
settled = true;
reject(new Error("SFTP channel acquisition timed out"));
}, 3_000);
session!.sshConn!.sftp((err, sftp) => {
if (settled) {
sftp?.end?.();
return;
}
settled = true;
clearTimeout(timer);
if (err) return reject(err);
resolve(sftp);
});
},
);
return storeImageViaSftp(remoteSftp, normalizedImage, {
ttlMs: storageSettings.ttlMs,
maxCount: storageSettings.maxCount,
maxBytes: storageSettings.maxBytes,
});
})()
: await storeImageLocally(normalizedImage, storageSettings);
res.json(stored);
} catch (error) {
if (error instanceof TerminalImageStorageError) {
const status =
error.code === "IMAGE_STORAGE_LIMIT_REACHED"
? 507
: error.code === "IMAGE_REMOTE_WRITE_FAILED"
? 502
: error.code === "IMAGE_REMOTE_QUOTA_UNAVAILABLE"
? 503
: error.code === "IMAGE_LOCAL_INSPECTION_FAILED"
? 503
: 500;
databaseLogger.warn("Image upload storage write failed", {
operation:
error.code === "IMAGE_REMOTE_WRITE_FAILED"
? "terminal_image_upload_sftp_failed"
: "terminal_image_upload_local_failed",
code: error.code,
userId,
instanceId,
reason: getErrorMessage(error.cause ?? error, "unknown"),
});
return res.status(status).json({
error: error.message,
code: error.code,
});
}
databaseLogger.warn("Image upload failed to acquire remote channel", {
operation: "terminal_image_upload_sftp_failed",
code: "IMAGE_REMOTE_WRITE_FAILED",
userId,
instanceId,
reason: getErrorMessage(error, "unknown"),
});
return res.status(502).json({
error: "Failed to write image to the remote host",
code: "IMAGE_REMOTE_WRITE_FAILED",
});
} finally {
remoteSftp?.end?.();
}
},
);
/**
* @openapi
* /terminal/command_history:
* post:
* summary: Save command to history
* description: Saves a command to the command history for a specific host.
* tags:
* - Terminal
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* hostId:
* type: integer
* command:
* type: string
* responses:
* 201:
* description: Command saved successfully.
* 400:
* description: Missing required parameters.
* 500:
* description: Failed to save command.
*/
router.post(
"/command_history",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { hostId, command } = req.body;
if (!isNonEmptyString(userId) || !hostId || !isNonEmptyString(command)) {
authLogger.warn("Invalid command history save request", {
operation: "command_history_save",
userId,
hasHostId: !!hostId,
hasCommand: !!command,
});
return res.status(400).json({ error: "Missing required parameters" });
}
const sensitivePatterns = [
/passw(or)?d/i,
/\bsecret\b/i,
/\btoken\b/i,
/\bapi.?key\b/i,
/PASS(WORD)?=/i,
/AWS_SECRET/i,
/mysql\b.*-p/i,
/sudo\s+-S\b/,
/htpasswd/i,
/sshpass/i,
/curl\b.*-u\s/i,
/export\b.*(?:PASSWORD|SECRET|TOKEN|KEY)=/i,
];
const trimmedCommand = command.trim();
if (sensitivePatterns.some((p: RegExp) => p.test(trimmedCommand))) {
return res.status(201).json({
id: 0,
userId,
hostId: parseInt(hostId, 10),
command: trimmedCommand,
executedAt: new Date().toISOString(),
});
}
const globalEnabled = await createCurrentSettingsRepository().getBoolean(
"command_history_enabled",
true,
);
if (!globalEnabled) {
return res.status(201).json({
id: 0,
userId,
hostId: parseInt(hostId, 10),
command: trimmedCommand,
executedAt: new Date().toISOString(),
});
}
const hostRecord =
await createCurrentHostResolutionRepository().findHostById(
parseInt(hostId, 10),
userId,
);
if (hostRecord?.enableCommandHistory === false) {
return res.status(201).json({
id: 0,
userId,
hostId: parseInt(hostId, 10),
command: trimmedCommand,
executedAt: new Date().toISOString(),
});
}
try {
const result = await createCurrentCommandHistoryRepository().create(
userId,
parseInt(hostId, 10),
trimmedCommand,
);
res.status(201).json(result);
} catch (err) {
authLogger.error("Failed to save command to history", err);
res.status(500).json({
error: getErrorMessage(err, "Failed to save command"),
});
}
},
);
/**
* @openapi
* /terminal/command_history/{hostId}:
* get:
* summary: Get command history
* description: Retrieves the command history for a specific host.
* tags:
* - Terminal
* parameters:
* - in: path
* name: hostId
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: A list of commands.
* 400:
* description: Invalid request parameters.
* 500:
* description: Failed to fetch history.
*/
router.get(
"/command_history/:hostId",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const hostId = Array.isArray(req.params.hostId)
? req.params.hostId[0]
: req.params.hostId;
const hostIdNum = parseInt(hostId, 10);
if (!isNonEmptyString(userId) || isNaN(hostIdNum)) {
authLogger.warn("Invalid command history fetch request", {
userId,
hostId: hostIdNum,
});
return res.status(400).json({ error: "Invalid request parameters" });
}
try {
const uniqueCommands =
await createCurrentCommandHistoryRepository().listUniqueCommandsForHost(
userId,
hostIdNum,
);
res.json(uniqueCommands);
} catch (err) {
authLogger.error("Failed to fetch command history", err);
res.status(500).json({
error: getErrorMessage(err, "Failed to fetch history"),
});
}
},
);
/**
* @openapi
* /terminal/command_history/delete:
* post:
* summary: Delete a specific command from history
* description: Deletes a specific command from the history of a host.
* tags:
* - Terminal
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* hostId:
* type: integer
* command:
* type: string
* responses:
* 200:
* description: Command deleted successfully.
* 400:
* description: Missing required parameters.
* 500:
* description: Failed to delete command.
*/
router.post(
"/command_history/delete",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { hostId, command } = req.body;
if (!isNonEmptyString(userId) || !hostId || !isNonEmptyString(command)) {
authLogger.warn("Invalid command delete request", {
operation: "command_history_delete",
userId,
hasHostId: !!hostId,
hasCommand: !!command,
});
return res.status(400).json({ error: "Missing required parameters" });
}
try {
const hostIdNum = parseInt(hostId, 10);
await createCurrentCommandHistoryRepository().deleteCommandForHost(
userId,
hostIdNum,
command.trim(),
);
res.json({ success: true });
} catch (err) {
authLogger.error("Failed to delete command from history", err);
res.status(500).json({
error: getErrorMessage(err, "Failed to delete command"),
});
}
},
);
/**
* @openapi
* /terminal/command_history/{hostId}:
* delete:
* summary: Clear command history
* description: Clears the entire command history for a specific host.
* tags:
* - Terminal
* parameters:
* - in: path
* name: hostId
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Command history cleared successfully.
* 400:
* description: Invalid request.
* 500:
* description: Failed to clear history.
*/
router.delete(
"/command_history/:hostId",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const hostId = Array.isArray(req.params.hostId)
? req.params.hostId[0]
: req.params.hostId;
const hostIdNum = parseInt(hostId, 10);
if (!isNonEmptyString(userId) || isNaN(hostIdNum)) {
authLogger.warn("Invalid command history clear request");
return res.status(400).json({ error: "Invalid request" });
}
try {
await createCurrentCommandHistoryRepository().deleteByUserAndHost(
userId,
hostIdNum,
);
databaseLogger.info("Terminal history cleared", {
operation: "terminal_history_clear",
userId,
hostId: hostIdNum,
});
res.json({ success: true });
} catch (err) {
authLogger.error("Failed to clear command history", err);
res.status(500).json({
error: getErrorMessage(err, "Failed to clear history"),
});
}
},
);
/**
* @openapi
* /terminal/session_settings:
* get:
* summary: Get session persistence settings
* description: Returns the session timeout and persistence enabled flag.
* tags:
* - Terminal
* responses:
* 200:
* description: Session settings.
* 500:
* description: Failed to fetch settings.
*/
router.get(
"/session_settings",
authenticateJWT,
async (_req: Request, res: Response) => {
try {
const settings = createCurrentSettingsRepository();
const timeoutValue = await settings.get(
"terminal_session_timeout_minutes",
);
const enabled = await settings.getBoolean(
"terminal_session_persistence_enabled",
true,
);
res.json({
timeoutMinutes: timeoutValue ? parseInt(timeoutValue, 10) : 30,
enabled,
});
} catch (err) {
authLogger.error("Failed to fetch session settings", err);
res.status(500).json({
error: getErrorMessage(err, "Failed to fetch settings"),
});
}
},
);
/**
* @openapi
* /terminal/session_settings:
* post:
* summary: Update session persistence settings
* description: Saves session timeout and persistence enabled flag.
* tags:
* - Terminal
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* timeoutMinutes:
* type: integer
* enabled:
* type: boolean
* responses:
* 200:
* description: Settings saved successfully.
* 400:
* description: Invalid parameters.
* 500:
* description: Failed to save settings.
*/
router.post(
"/session_settings",
authenticateJWT,
async (req: Request, res: Response) => {
const { timeoutMinutes, enabled } = req.body;
if (
timeoutMinutes !== undefined &&
(typeof timeoutMinutes !== "number" ||
timeoutMinutes < 1 ||
timeoutMinutes > 1440)
) {
return res
.status(400)
.json({ error: "timeoutMinutes must be between 1 and 1440" });
}
try {
const settings = createCurrentSettingsRepository();
if (timeoutMinutes !== undefined) {
await settings.set(
"terminal_session_timeout_minutes",
String(timeoutMinutes),
);
}
if (enabled !== undefined) {
await settings.set(
"terminal_session_persistence_enabled",
String(enabled),
);
}
res.json({ success: true });
} catch (err) {
authLogger.error("Failed to save session settings", err);
res.status(500).json({
error: getErrorMessage(err, "Failed to save settings"),
});
}
},
);
export default router;
@@ -1,69 +0,0 @@
import type { RequestHandler, Router } from "express";
import type { AuthenticatedRequest } from "../../../types/index.js";
import {
normalizeTouchInputSettings,
TOUCH_INPUT_SETTING_KEY,
validateTouchInputSettingsUpdate,
} from "../../../types/touch-input-settings.js";
import { authLogger } from "../../utils/logger.js";
import {
createCurrentSettingsRepository,
createCurrentUserRepository,
} from "../repositories/factory.js";
async function readSettings() {
const raw = await createCurrentSettingsRepository().get(
TOUCH_INPUT_SETTING_KEY,
);
if (!raw) return normalizeTouchInputSettings(null);
try {
return normalizeTouchInputSettings(JSON.parse(raw));
} catch {
return normalizeTouchInputSettings(null);
}
}
export function registerTouchInputSettingsRoutes(
router: Router,
authenticateJWT: RequestHandler,
): void {
router.get("/touch-input-settings", authenticateJWT, async (_req, res) => {
try {
res.json(await readSettings());
} catch (error) {
authLogger.error("Failed to get touch input settings", error);
res.status(500).json({ error: "Failed to get touch input settings" });
}
});
router.patch("/touch-input-settings", authenticateJWT, async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const user = userId
? await createCurrentUserRepository().findById(userId)
: null;
if (!user?.isAdmin) {
return res.status(403).json({ error: "Not authorized" });
}
const validationError = validateTouchInputSettingsUpdate(req.body);
if (validationError) {
return res.status(400).json({ error: validationError });
}
const current = await readSettings();
const merged = { ...current, ...req.body };
const mergedValidationError = validateTouchInputSettingsUpdate(merged);
if (mergedValidationError) {
return res.status(400).json({ error: mergedValidationError });
}
const next = normalizeTouchInputSettings(merged);
await createCurrentSettingsRepository().set(
TOUCH_INPUT_SETTING_KEY,
JSON.stringify(next),
);
res.json(next);
} catch (error) {
authLogger.error("Failed to update touch input settings", error);
res.status(500).json({ error: "Failed to update touch input settings" });
}
});
}
@@ -1,334 +0,0 @@
import type { Request, RequestHandler, Response, Router } from "express";
import type { AuthenticatedRequest } from "../../../types/index.js";
import { databaseLogger } from "../../utils/logger.js";
import { sessionManager } from "../../hosts/terminal/session-manager.js";
import { createCurrentSettingsRepository } from "../repositories/factory.js";
import {
MIN_IMAGE_MAX_BYTES,
TERMINAL_IMAGE_STORAGE_KEYS,
parseImageHostPath,
parseImageLocalDir,
parseTerminalImageStorageMode,
resolveTerminalImageStorageSettings,
type TerminalImageStorageSettings,
} from "./terminal-image-storage-settings.js";
import {
probeLocalImageVisibility,
selectImageStorageMode,
} from "./terminal-image-storage.js";
/**
* Admin-only terminal image storage settings.
*
* The public shape deliberately omits `localDir`: it is a backend-internal
* path and only the agent-visible `hostPath` may leave the server. No
* credentials or connection details are ever returned here.
*/
interface PublicImageStorageSettings {
mode: TerminalImageStorageSettings["mode"];
hostPath: string;
ttlMs: number;
maxCount: number;
maxBytes: number;
localMappingConfigured: boolean;
}
function toPublicSettings(
settings: TerminalImageStorageSettings,
): PublicImageStorageSettings {
return {
mode: settings.mode,
hostPath: settings.hostPath,
ttlMs: settings.ttlMs,
maxCount: settings.maxCount,
maxBytes: settings.maxBytes,
localMappingConfigured: settings.localMappingConfigured,
};
}
const PATCHABLE_FIELDS = [
"mode",
"localDir",
"hostPath",
"ttlMs",
"maxCount",
"maxBytes",
] as const;
type PatchableField = (typeof PATCHABLE_FIELDS)[number];
function invalidField(res: Response, field: string): void {
// Safe by construction: the field name is fixed, the rejected value and
// any backend path are never echoed back.
res.status(400).json({
error: `Invalid value for ${field}`,
code: "IMAGE_STORAGE_SETTINGS_INVALID",
field,
});
}
function parseIntegerField(value: unknown, min: number): number | null {
if (typeof value !== "number" || !Number.isInteger(value)) return null;
if (value < min) return null;
return value;
}
/**
* Validates a PATCH body and returns the settings-table writes it implies, or
* null after the response has already been failed with a 400.
*/
function buildImageStorageWrites(
body: unknown,
res: Response,
): Array<{ key: string; value: string }> | null {
if (typeof body !== "object" || body === null || Array.isArray(body)) {
res.status(400).json({
error: "Invalid request body",
code: "IMAGE_STORAGE_SETTINGS_INVALID",
});
return null;
}
for (const field of Object.keys(body)) {
if (!(PATCHABLE_FIELDS as readonly string[]).includes(field)) {
res.status(400).json({
error: `Unknown setting field: ${field}`,
code: "IMAGE_STORAGE_SETTINGS_UNKNOWN_FIELD",
field,
});
return null;
}
}
const input = body as Partial<Record<PatchableField, unknown>>;
const writes: Array<{ key: string; value: string }> = [];
if (input.mode !== undefined) {
const mode = parseTerminalImageStorageMode(input.mode);
if (mode === null) {
invalidField(res, "mode");
return null;
}
writes.push({ key: TERMINAL_IMAGE_STORAGE_KEYS.mode, value: mode });
}
if (input.localDir !== undefined) {
const localDir = parseImageLocalDir(input.localDir);
if (localDir === null) {
invalidField(res, "localDir");
return null;
}
writes.push({ key: TERMINAL_IMAGE_STORAGE_KEYS.localDir, value: localDir });
}
if (input.hostPath !== undefined) {
const hostPath = parseImageHostPath(input.hostPath);
if (hostPath === null) {
invalidField(res, "hostPath");
return null;
}
writes.push({
key: TERMINAL_IMAGE_STORAGE_KEYS.hostPath,
value: hostPath,
});
}
if (input.ttlMs !== undefined) {
const ttlMs = parseIntegerField(input.ttlMs, 0);
if (ttlMs === null) {
invalidField(res, "ttlMs");
return null;
}
writes.push({
key: TERMINAL_IMAGE_STORAGE_KEYS.ttlMs,
value: String(ttlMs),
});
}
if (input.maxCount !== undefined) {
const maxCount = parseIntegerField(input.maxCount, 1);
if (maxCount === null) {
invalidField(res, "maxCount");
return null;
}
writes.push({
key: TERMINAL_IMAGE_STORAGE_KEYS.maxCount,
value: String(maxCount),
});
}
if (input.maxBytes !== undefined) {
const maxBytes = parseIntegerField(input.maxBytes, MIN_IMAGE_MAX_BYTES);
if (maxBytes === null) {
invalidField(res, "maxBytes");
return null;
}
writes.push({
key: TERMINAL_IMAGE_STORAGE_KEYS.maxBytes,
value: String(maxBytes),
});
}
return writes;
}
export function registerUserImageStorageRoutes(
router: Router,
requireAdmin: RequestHandler,
): void {
/**
* @openapi
* /users/terminal-image-storage-settings:
* get:
* summary: Get terminal image storage settings (admin only)
* description: Returns the effective terminal image storage settings. The backend-internal localDir is never exposed; only the agent-visible hostPath is returned.
* tags:
* - Users
* responses:
* 200:
* description: Effective image storage settings.
* 401:
* description: Not authenticated.
* 403:
* description: Admin access required.
* 500:
* description: Failed to load settings.
*/
router.get(
"/terminal-image-storage-settings",
requireAdmin,
async (_req: Request, res: Response) => {
try {
const settings = await resolveTerminalImageStorageSettings(
createCurrentSettingsRepository(),
);
res.json(toPublicSettings(settings));
} catch (err) {
databaseLogger.error("Failed to load image storage settings", err);
res
.status(500)
.json({ error: "Failed to load image storage settings" });
}
},
);
/**
* @openapi
* /users/terminal-image-storage-settings:
* patch:
* summary: Update terminal image storage settings (admin only)
* description: Persists a partial update. Accepts only mode (auto, local, remote-sftp), localDir, hostPath, ttlMs, maxCount and maxBytes; invalid values are rejected with a 400.
* tags:
* - Users
* responses:
* 200:
* description: Updated effective settings.
* 400:
* description: Invalid or unknown setting field.
* 401:
* description: Not authenticated.
* 403:
* description: Admin access required.
* 500:
* description: Failed to save settings.
*/
router.patch(
"/terminal-image-storage-settings",
requireAdmin,
async (req: Request, res: Response) => {
const writes = buildImageStorageWrites(req.body, res);
if (writes === null) return;
try {
const settings = createCurrentSettingsRepository();
if (typeof settings.setMany !== "function") {
throw new Error("Atomic settings persistence is unavailable");
}
await settings.setMany(writes);
const resolved = await resolveTerminalImageStorageSettings(settings);
res.json(toPublicSettings(resolved));
} catch (err) {
databaseLogger.error("Failed to save image storage settings", err);
res
.status(500)
.json({ error: "Failed to save image storage settings" });
}
},
);
/**
* @openapi
* /users/terminal-image-storage-settings/test:
* post:
* summary: Test image storage visibility (admin only)
* description: Reports which storage mode an upload would take for one of the caller's already-connected terminal sessions. Uses the bounded local-mapping probe only; it never opens new connections.
* tags:
* - Users
* responses:
* 200:
* description: Visibility test result.
* 400:
* description: Missing terminal session instanceId.
* 401:
* description: Not authenticated.
* 403:
* description: Admin access required.
* 500:
* description: Failed to run the test.
*/
router.post(
"/terminal-image-storage-settings/test",
requireAdmin,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const instanceId = req.body?.instanceId;
if (typeof instanceId !== "string" || instanceId.trim().length === 0) {
return res.status(400).json({
error: "Missing terminal session",
code: "IMAGE_SESSION_MISSING",
});
}
try {
const settings = await resolveTerminalImageStorageSettings(
createCurrentSettingsRepository(),
);
const session = sessionManager
.getUserSessions(userId)
.find(
(candidate) =>
(candidate.attachedTabInstanceId ?? candidate.tabInstanceId) ===
instanceId && candidate.isConnected,
);
const remoteSftpAvailable = !!session?.sshConn;
let localHostVisible: boolean | null = null;
if (settings.localMappingConfigured && session?.sshConn) {
localHostVisible = await probeLocalImageVisibility(
session.sshConn,
settings,
).catch(() => false);
}
const selectedMode = selectImageStorageMode(settings, {
remoteSftpAvailable,
...(localHostVisible !== null ? { localHostVisible } : {}),
});
res.json({
mode: settings.mode,
connected: !!session,
remoteSftpAvailable,
localHostVisible,
selectedMode,
localMappingConfigured: settings.localMappingConfigured,
});
} catch (err) {
databaseLogger.error("Failed to test image storage visibility", err);
res
.status(500)
.json({ error: "Failed to test image storage visibility" });
}
},
);
}
@@ -338,110 +338,6 @@ export function registerUserSettingsRoutes(
}
});
/**
* @openapi
* /users/command-history-enabled:
* get:
* summary: Get command history enabled setting
* description: Returns whether command history recording is globally enabled.
* tags:
* - Users
* responses:
* 200:
* description: Command history enabled status.
* content:
* application/json:
* schema:
* type: object
* properties:
* enabled:
* type: boolean
*/
router.get("/command-history-enabled", authenticateJWT, async (_req, res) => {
try {
res.json({
enabled: await createCurrentSettingsRepository().getBoolean(
"command_history_enabled",
true,
),
});
} catch (err) {
authLogger.error("Failed to get command history enabled setting", err);
res
.status(500)
.json({ error: "Failed to get command history enabled setting" });
}
});
/**
* @openapi
* /users/command-history-enabled:
* patch:
* summary: Update command history enabled setting (admin only)
* description: Globally enables or disables command history recording for all hosts.
* tags:
* - Users
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* enabled:
* type: boolean
* responses:
* 200:
* description: Setting updated.
* 403:
* description: Not authorized.
* 500:
* description: Failed to update setting.
*/
router.patch(
"/command-history-enabled",
authenticateJWT,
async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const actor = await getAdminActor(userId);
if (!actor) {
return res.status(403).json({ error: "Not authorized" });
}
const { enabled } = req.body;
if (typeof enabled !== "boolean") {
return res.status(400).json({ error: "enabled must be a boolean" });
}
await createCurrentSettingsRepository().set(
"command_history_enabled",
enabled ? "true" : "false",
);
const { ipAddress, userAgent } = getRequestMeta(req);
await logAudit({
userId,
username: actor.username ?? userId,
action: "update_command_history_enabled",
resourceType: "setting",
details: JSON.stringify({ enabled }),
ipAddress,
userAgent,
success: true,
});
res.json({ enabled });
} catch (err) {
authLogger.error(
"Failed to update command history enabled setting",
err,
);
res
.status(500)
.json({ error: "Failed to update command history enabled setting" });
}
},
);
/**
* @openapi
* /users/analytics-enabled:
-4
View File
@@ -24,10 +24,8 @@ import {
validateLogoutToken,
} from "./user-oidc-utils.js";
import { registerUserApiKeyRoutes } from "./user-api-key-routes.js";
import { registerUserImageStorageRoutes } from "./user-image-storage-routes.js";
import { registerBrandingRoutes } from "./branding-routes.js";
import { registerUserSettingsRoutes } from "./user-settings-routes.js";
import { registerTouchInputSettingsRoutes } from "./touch-input-settings-routes.js";
import { registerAcmeSSLRoutes } from "./acme-ssl-routes.js";
import { registerUserTotpRoutes } from "./user-totp-routes.js";
import { registerUserWebAuthnRoutes } from "./user-webauthn-routes.js";
@@ -2126,11 +2124,9 @@ registerUserOidcAccountRoutes(router, {
});
registerUserSettingsRoutes(router, authenticateJWT);
registerTouchInputSettingsRoutes(router, authenticateJWT);
registerAcmeSSLRoutes(router, authenticateJWT);
registerUserApiKeyRoutes(router, requireAdmin);
registerUserImageStorageRoutes(router, requireAdmin);
registerBrandingRoutes(router, requireAdmin);
registerSSOProviderRoutes(router);
+15 -9
View File
@@ -23,7 +23,7 @@ import {
collabRuntimeStore,
type CollabControlRequest,
} from "./runtime-store.js";
import { sessionManager } from "../terminal/session-manager.js";
import { liveTerminalSessions } from "../live-terminal-sessions.js";
import {
isLiveSession,
isLiveSessionOwnedBy,
@@ -79,9 +79,13 @@ async function revokeStageShare(room: CollabRoomRecord): Promise<void> {
throw new Error("Failed to revoke the active stage share");
}
if (share.protocol === "ssh") {
sessionManager.disconnectShareParticipants(share.sessionId, share.id, {
reason: "The collaboration stage ended",
});
liveTerminalSessions.disconnectShareParticipants(
share.sessionId,
share.id,
{
reason: "The collaboration stage ended",
},
);
void collabRuntimeStore.publish(room.id, {
type: "collab_internal_stage_revoked",
sessionId: share.sessionId,
@@ -241,6 +245,8 @@ router.get(
controlRequests: mayReviewControlRequests
? controlRequests
: controlRequests.filter((request) => request.userId === userId),
// Room events ride on the terminal socket.
eventsWsPath: "/plugin-ws/ssh-terminal/terminal",
});
} catch (error) {
sshLogger.error("Failed to get collab room", error, {
@@ -387,7 +393,7 @@ router.delete(
access.room.stageShareId,
);
if (share) {
sessionManager.disconnectShareParticipants(
liveTerminalSessions.disconnectShareParticipants(
share.sessionId,
share.id,
{
@@ -689,7 +695,7 @@ async function applyStageControl(
room.stageShareId,
);
if (share) {
sessionManager.setRoomShareControl(
liveTerminalSessions.setRoomShareControl(
share.sessionId,
share.id,
controllerUserId,
@@ -722,7 +728,7 @@ collabRuntimeStore.onEvent((roomId, message) => {
.findActiveById(room.stageShareId)
.then((share) => {
if (share) {
sessionManager.setRoomShareControl(
liveTerminalSessions.setRoomShareControl(
share.sessionId,
share.id,
controllerUserId,
@@ -739,7 +745,7 @@ collabRuntimeStore.onEvent((roomId, message) => {
"shareId" in message &&
typeof message.shareId === "string"
) {
sessionManager.disconnectShareParticipants(
liveTerminalSessions.disconnectShareParticipants(
message.sessionId,
message.shareId,
{
@@ -1016,7 +1022,7 @@ router.post(
access.room.stageShareId,
);
if (share) {
sessionManager.disconnectShareParticipants(
liveTerminalSessions.disconnectShareParticipants(
share.sessionId,
share.id,
{
@@ -1,277 +0,0 @@
/**
* Keyboard-interactive answering over the terminal WebSocket.
*
* The classifier decides what a round is; this keeps the terminal's message
* protocol (totp_required, totp_retry, password_required,
* warpgate_auth_required) and its timeouts exactly as the client expects.
*/
import type { WebSocket } from "ws";
import { sshLogger, authLogger } from "../../utils/logger.js";
import { ensureCoreSshAuthProviders } from "./core-providers.js";
import {
classifyKeyboardInteractive,
responsesWithAnswer,
} from "./keyboard-interactive.js";
import type { KeyboardInteractivePrompt, SshConnectHost } from "./types.js";
interface ResolvedCredentials {
username: string;
password?: string;
authType?: string;
}
interface HostFlags {
useWarpgate?: boolean;
[key: string]: unknown;
}
interface AuthContext {
userId: string;
ws: WebSocket;
hostId: number;
isKeyboardInteractive: boolean;
keyboardInteractiveResponded: boolean;
keyboardInteractiveFinish: ((responses: string[]) => void) | null;
totpPromptSent: boolean;
warpgateAuthPromptSent: boolean;
totpTimeout: NodeJS.Timeout | null;
warpgateAuthTimeout: NodeJS.Timeout | null;
totpAttempts: number;
}
const TOTP_TIMEOUT_MS = 180000;
const PUSH_TIMEOUT_MS = 300000;
const WARPGATE_TIMEOUT_MS = 300000;
export class SSHAuthManager {
public context: AuthContext;
constructor(context: AuthContext) {
this.context = context;
}
handleKeyboardInteractive(
name: string,
instructions: string,
_instructionsLang: string,
prompts: KeyboardInteractivePrompt[],
finish: (responses: string[]) => void,
resolvedCredentials: ResolvedCredentials,
hostConfig?: HostFlags,
): void {
ensureCoreSshAuthProviders();
this.context.isKeyboardInteractive = true;
const host = {
...(hostConfig ?? {}),
id: this.context.hostId,
ip: "",
port: 22,
username: resolvedCredentials.username,
password: resolvedCredentials.password,
authType: resolvedCredentials.authType,
} as SshConnectHost;
const decision = classifyKeyboardInteractive(
{ name, instructions, prompts },
host,
);
switch (decision.kind) {
case "auto":
finish(decision.responses);
return;
case "warpgate":
this.handleWarpgate(decision, finish);
return;
case "totp":
this.handleTotp(prompts, decision.promptIndex, finish, host.password);
return;
case "input":
this.handleInput(
prompts,
decision.promptIndex,
decision.isPush,
finish,
host.password,
);
return;
}
}
private handleWarpgate(
decision: { url: string | null; securityKey: string; instructions: string },
finish: (responses: string[]) => void,
): void {
this.context.keyboardInteractiveFinish = () => {
finish([""]);
};
this.context.warpgateAuthPromptSent = true;
this.sendLog("auth", "info", "Warpgate authentication required");
this.context.ws.send(
JSON.stringify({
type: "warpgate_auth_required",
url: decision.url,
securityKey: decision.securityKey,
instructions: decision.instructions,
}),
);
this.context.warpgateAuthTimeout = setTimeout(() => {
if (this.context.keyboardInteractiveFinish) {
this.context.keyboardInteractiveFinish = null;
this.context.warpgateAuthPromptSent = false;
sshLogger.warn("Warpgate authentication timeout", {
operation: "warpgate_timeout",
hostId: this.context.hostId,
});
this.context.ws.send(
JSON.stringify({
type: "error",
message: "Warpgate authentication timeout. Please reconnect.",
}),
);
}
}, WARPGATE_TIMEOUT_MS);
}
private handleTotp(
prompts: KeyboardInteractivePrompt[],
promptIndex: number,
finish: (responses: string[]) => void,
password: string | undefined,
): void {
if (this.context.totpPromptSent) {
sshLogger.warn("TOTP prompt asked again - invalid code", {
operation: "ssh_keyboard_interactive_totp_retry",
hostId: this.context.hostId,
});
authLogger.warn("TOTP verification failed for SSH session", {
operation: "terminal_totp_failed",
userId: this.context.userId,
hostId: this.context.hostId,
});
this.sendLog("auth", "warning", "Invalid TOTP code");
this.context.ws.send(JSON.stringify({ type: "totp_retry" }));
return;
}
this.context.totpPromptSent = true;
this.context.keyboardInteractiveResponded = true;
this.context.keyboardInteractiveFinish = (answers: string[]) => {
finish(
responsesWithAnswer(
prompts,
promptIndex,
(answers[0] || "").trim(),
password,
),
);
};
this.armTimeout(TOTP_TIMEOUT_MS, "totp");
this.sendLog("auth", "info", "TOTP verification required");
authLogger.info("TOTP verification prompt sent to client", {
operation: "terminal_totp_prompt",
userId: this.context.userId,
hostId: this.context.hostId,
});
this.context.ws.send(
JSON.stringify({
type: "totp_required",
prompt: prompts[promptIndex].prompt,
}),
);
}
private handleInput(
prompts: KeyboardInteractivePrompt[],
promptIndex: number,
isPush: boolean,
finish: (responses: string[]) => void,
password: string | undefined,
): void {
if (this.context.keyboardInteractiveResponded) return;
this.context.keyboardInteractiveResponded = true;
this.context.keyboardInteractiveFinish = (answers: string[]) => {
finish(
responsesWithAnswer(
prompts,
promptIndex,
(answers[0] || "").trim(),
password,
),
);
};
this.armTimeout(isPush ? PUSH_TIMEOUT_MS : TOTP_TIMEOUT_MS, "password");
this.sendLog("auth", "info", "Password authentication required");
this.context.ws.send(
JSON.stringify({
type: "password_required",
prompt: prompts[promptIndex].prompt,
echo: prompts[promptIndex].echo,
}),
);
}
private armTimeout(ms: number, kind: "totp" | "password"): void {
if (this.context.totpTimeout) clearTimeout(this.context.totpTimeout);
this.context.totpTimeout = setTimeout(() => {
if (!this.context.keyboardInteractiveFinish) return;
this.context.keyboardInteractiveFinish = null;
if (kind === "totp") {
this.context.totpPromptSent = false;
} else {
this.context.keyboardInteractiveResponded = false;
}
sshLogger.warn(
`${kind === "totp" ? "TOTP" : "Password"} prompt timeout`,
{
operation: `${kind}_timeout`,
hostId: this.context.hostId,
},
);
this.context.ws.send(
JSON.stringify({
type: "error",
message:
kind === "totp"
? "TOTP verification timeout. Please reconnect."
: "Password verification timeout. Please reconnect.",
}),
);
}, ms);
}
sendLog(
stage: string,
level: string,
message: string,
details?: Record<string, unknown>,
): void {
this.context.ws.send(
JSON.stringify({
type: "connection_log",
data: { stage, level, message, details },
}),
);
}
cleanup(): void {
if (this.context.totpTimeout) {
clearTimeout(this.context.totpTimeout);
this.context.totpTimeout = null;
}
if (this.context.warpgateAuthTimeout) {
clearTimeout(this.context.warpgateAuthTimeout);
this.context.warpgateAuthTimeout = null;
}
this.context.keyboardInteractiveFinish = null;
this.context.totpPromptSent = false;
this.context.warpgateAuthPromptSent = false;
this.context.keyboardInteractiveResponded = false;
}
}
+6
View File
@@ -178,6 +178,12 @@ export interface SshAuthProvider {
* transports that can show one (the terminal today).
*/
startInteraction?: (request: SshInteractionRequest) => Promise<void>;
/** Cancels a pending browser step, by request id or by host. */
cancelInteraction?: (request: {
userId: string;
hostId?: number;
requestId?: string;
}) => void | Promise<void>;
prepare: (
config: MutableConnectConfig,
host: SshConnectHost,
@@ -0,0 +1,82 @@
/**
* What core needs from live SSH terminal sessions, through the plugin
* service "sessions.live" rather than an import.
*
* Session sharing, collab and the open-tabs route still live in core and
* reach the sessions the ssh-terminal plugin holds. With no provider (the
* plugin is off) a lookup finds nothing and a control call does nothing,
* because "no such live session" and "the terminal is off" mean the same
* thing to every caller here.
*/
import { getServiceImplementation } from "../plugins/service-registry.js";
export const LIVE_SESSIONS_SERVICE = "sessions.live";
export interface LiveSessionInfo {
id: string;
userId: string;
hostId: number;
hostName: string;
isConnected: boolean;
createdAt: number;
lastDetachedAt: number | null;
tabInstanceId: string | null;
tmuxSessionName: string | null;
cols: number;
rows: number;
}
interface LiveSessionsV1 {
getSession: (sessionId: string) => LiveSessionInfo | null;
listForUser: (userId: string) => LiveSessionInfo[];
ownerEndSession: (sessionId: string, reason: string) => void;
disconnectParticipants: (
sessionId: string,
shareId: string,
options: { reason: string; userId?: string | null },
) => number;
setRoomShareControl: (
sessionId: string,
shareId: string,
controllerUserId: string | null,
) => void;
idleTimeoutMinutes: () => number;
}
function provider(): LiveSessionsV1 | undefined {
return getServiceImplementation<LiveSessionsV1>(
LIVE_SESSIONS_SERVICE,
"^1.0.0",
);
}
export const liveTerminalSessions = {
getSession(sessionId: string): LiveSessionInfo | null {
return provider()?.getSession(sessionId) ?? null;
},
listForUser(userId: string): LiveSessionInfo[] {
return provider()?.listForUser(userId) ?? [];
},
ownerEndSession(sessionId: string, reason: string): void {
provider()?.ownerEndSession(sessionId, reason);
},
disconnectShareParticipants(
sessionId: string,
shareId: string,
options: { reason: string; userId?: string | null },
): number {
return provider()?.disconnectParticipants(sessionId, shareId, options) ?? 0;
},
setRoomShareControl(
sessionId: string,
shareId: string,
controllerUserId: string | null,
): void {
provider()?.setRoomShareControl(sessionId, shareId, controllerUserId);
},
/** How long the terminal keeps a detached session, or null while it is off. */
idleTimeoutMinutes(): number | null {
return provider()?.idleTimeoutMinutes() ?? null;
},
};
@@ -1,4 +1,4 @@
import { sessionManager } from "../terminal/session-manager.js";
import { liveTerminalSessions } from "../live-terminal-sessions.js";
// Remote Desktop is a first-party plugin (plugins/remote-desktop); this goes
// through the same in-core bridge its router is mounted behind, since
// plugins/ is compiled separately from src/backend/ and core cannot
@@ -43,7 +43,7 @@ export function isLiveSessionOwnedBy(
userId: string,
): boolean {
if (protocol === "ssh") {
const session = sessionManager.getSession(sessionId);
const session = liveTerminalSessions.getSession(sessionId);
return !!session && session.isConnected && session.userId === userId;
}
const info = getGuacSessionInfo(sessionId);
@@ -55,7 +55,7 @@ export function isLiveSession(
sessionId: string,
): boolean {
if (protocol === "ssh") {
const session = sessionManager.getSession(sessionId);
const session = liveTerminalSessions.getSession(sessionId);
return !!session && session.isConnected;
}
return !!getGuacSessionInfo(sessionId);
+3 -3
View File
@@ -4,7 +4,7 @@ import type { AuthenticatedRequest } from "../../../types/index.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { PermissionManager } from "../../utils/permission-manager.js";
import { sshLogger } from "../../utils/logger.js";
import { sessionManager } from "../terminal/session-manager.js";
import { liveTerminalSessions } from "../live-terminal-sessions.js";
import {
isLiveSession,
isLiveSessionOwnedBy,
@@ -326,7 +326,7 @@ router.delete(
// guac link only blocks *future* resolves until the guest's own socket ends.
if (share.protocol === "ssh") {
try {
sessionManager.ownerEndSession(
liveTerminalSessions.ownerEndSession(
share.sessionId,
"Session share revoked by owner",
);
@@ -482,7 +482,7 @@ router.post(
}
if (share.protocol === "ssh") {
sessionManager.ownerEndSession(
liveTerminalSessions.ownerEndSession(
share.sessionId,
"Session ended by owner",
);
+1 -53
View File
@@ -12,7 +12,7 @@ import ssh2Pkg, {
type KnownPublicKey = KnownPublicKeys[number];
const { AgentProtocol, BaseAgent } = ssh2Pkg;
const { BaseAgent } = ssh2Pkg;
const DEFAULT_PORT_KNOCK_TIMEOUT_MS = 1000;
type Sleep = (ms: number) => Promise<void>;
@@ -27,58 +27,6 @@ type PortKnockingOptions = {
const sleep: Sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
export class MemoryAgent extends BaseAgent {
private key: ParsedKey;
constructor(key: ParsedKey) {
super();
this.key = key;
}
getIdentities(cb: IdentityCallback<ParsedKey>): void {
cb(null, [this.key]);
}
getStream(cb: GetStreamCallback): void {
const protocol = new AgentProtocol(false);
protocol.on("identities", (request) => {
protocol.getIdentitiesReply(request, [this.key]);
});
protocol.on("sign", (request, publicKey, data, options) => {
this.sign(publicKey, data, options, (error, signature) => {
if (error || !signature) return protocol.failureReply(request);
protocol.signReply(request, signature);
});
});
cb(null, protocol);
}
sign(
_pubKey: ParsedKey | Buffer | string,
data: Buffer,
optionsOrCb: SigningRequestOptions | SignCallback,
cb?: SignCallback,
): void {
const callback = typeof optionsOrCb === "function" ? optionsOrCb : cb!;
const options = typeof optionsOrCb === "function" ? {} : optionsOrCb;
try {
const algo =
options.hash === "sha256"
? "rsa-sha2-256"
: options.hash === "sha512"
? "rsa-sha2-512"
: undefined;
const signature = this.key.sign(data, algo);
callback(null, signature);
} catch (err) {
callback(err instanceof Error ? err : new Error(String(err)));
}
}
}
export async function resolveAgentSocket(
terminalConfig: Record<string, unknown> | undefined,
): Promise<{ socketPath: string } | { error: string }> {
File diff suppressed because it is too large. Load diff
@@ -1,934 +0,0 @@
import { type Client, type ClientChannel } from "ssh2";
import { WebSocket } from "ws";
import fs from "fs";
import path from "path";
import { sshLogger } from "../../utils/logger.js";
import {
getCurrentSettingValue,
createCurrentSessionRecordingRepository,
} from "../../database/repositories/factory.js";
const MAX_BUFFER_BYTES = 512 * 1024;
const DATA_DIR = process.env.DATA_DIR ?? "./db/data";
const SESSION_LOGS_DIR = path.join(DATA_DIR, "session_logs");
const DEFAULT_TIMEOUT_MINUTES = 30;
const HEALTH_CHECK_INTERVAL_MS = 60_000;
const MAX_SESSIONS_PER_USER = 10;
// Coalesces recording writes: a chatty SSH stream can emit dozens of "data"
// events per second, and appending to disk on every single one saturates the
// libuv threadpool (default size 4), starving unrelated fs/DNS/crypto work
// and stalling the WS ping/pong health check enough to look like connection
// drops. Batch pending lines and flush on a short trailing edge instead.
const RECORDING_FLUSH_INTERVAL_MS = 300;
export interface SessionParticipant {
ws: WebSocket;
userId: string | null; // null for anonymous link guests
permissionLevel: "read-write" | "read-only";
isOwner: boolean;
displayName?: string;
guestLabel?: string;
tabInstanceId?: string;
joinedViaShareId?: string;
}
export interface TerminalSession {
id: string;
userId: string;
hostId: number;
hostName: string;
tabInstanceId?: string;
attachedTabInstanceId?: string;
sshConn: Client | null;
sshStream: ClientChannel | null;
jumpClient: Client | null;
cols: number;
rows: number;
isConnected: boolean;
createdAt: number;
participants: Map<string, SessionParticipant>;
lastDetachedAt: number | null;
detachTimeout: NodeJS.Timeout | null;
outputBuffer: string[];
outputBufferBytes: number;
recordingPath: string | null;
recordingHeader: string | null;
recordingBytes: number;
recordingId: number | null;
recordingWriteChain: Promise<void>;
recordingPersistChain: Promise<void>;
pendingRecordingData: string;
recordingFlushTimer: NodeJS.Timeout | null;
tmuxSessionName: string | null;
sessionLoggingEnabled: boolean;
sessionStartedAt: number;
lastPersistedBytes: number;
terminatedByOwner: boolean;
terminationReason: string | null;
}
/** Message types a non-owner participant may legally send. */
const NON_OWNER_ALLOWED_MESSAGE_TYPES = new Set([
"input",
"ping",
"disconnect",
]);
/**
* Server-side gate for whether a participant may send a given WS message
* type. The owner may send anything; non-owners are limited to input (if
* read-write), ping, and disconnect. Pure function so read-only enforcement
* is unit-testable without a real WebSocketServer.
*/
export function isMessageAllowedForParticipant(
participant: Pick<SessionParticipant, "isOwner" | "permissionLevel"> | null,
messageType: string,
): boolean {
if (!participant || participant.isOwner) return true;
if (!NON_OWNER_ALLOWED_MESSAGE_TYPES.has(messageType)) return false;
if (messageType === "input" && participant.permissionLevel === "read-only") {
return false;
}
return true;
}
class TerminalSessionManager {
private static instance: TerminalSessionManager;
private sessions = new Map<string, TerminalSession>();
private healthCheckTimer: NodeJS.Timeout | null = null;
private constructor() {
this.healthCheckTimer = setInterval(
() => this.healthCheck(),
HEALTH_CHECK_INTERVAL_MS,
);
}
static getInstance(): TerminalSessionManager {
if (!TerminalSessionManager.instance) {
TerminalSessionManager.instance = new TerminalSessionManager();
}
return TerminalSessionManager.instance;
}
createSession(
userId: string,
hostId: number,
hostName: string,
cols: number,
rows: number,
tabInstanceId?: string,
sessionLoggingEnabled = true,
): string {
const userSessions = this.getUserSessions(userId);
if (userSessions.length >= MAX_SESSIONS_PER_USER) {
const detached = userSessions
.filter((s) => this.getOwnerParticipant(s) === null)
.sort(
(a, b) =>
(a.lastDetachedAt ?? a.createdAt) -
(b.lastDetachedAt ?? b.createdAt),
);
if (detached.length > 0) {
this.destroySession(detached[0].id);
}
}
if (tabInstanceId) {
const tabSessions = userSessions.filter(
(s) => s.tabInstanceId === tabInstanceId,
);
for (const existing of tabSessions) {
const isLiveSession =
existing.isConnected &&
existing.sshStream != null &&
!existing.sshStream.destroyed;
if (isLiveSession) {
// Don't destroy a live session (even if detached) — the caller should attach instead
sshLogger.warn(
"Tab instance has live session, skipping duplicate create",
{
operation: "session_tab_duplicate_skip",
existingSessionId: existing.id,
tabInstanceId,
hasAttachedWs: this.getOwnerParticipant(existing) !== null,
},
);
return existing.id;
}
sshLogger.warn("Tab instance already has session, destroying old", {
operation: "session_tab_duplicate_cleanup",
existingSessionId: existing.id,
tabInstanceId,
});
this.destroySession(existing.id);
}
}
const id = crypto.randomUUID();
const now = Date.now();
let recordingPath: string | null = null;
let recordingHeader: string | null = null;
if (sessionLoggingEnabled) {
const userLogDir = path.join(SESSION_LOGS_DIR, userId);
recordingPath = path.join(userLogDir, `${id}.cast`);
recordingHeader = `${JSON.stringify({
version: 2,
width: cols,
height: rows,
timestamp: Math.floor(now / 1000),
env: { TERM: "xterm-256color", SHELL: "/bin/sh" },
})}\n`;
}
const session: TerminalSession = {
id,
userId,
hostId,
hostName,
tabInstanceId,
sshConn: null,
sshStream: null,
jumpClient: null,
cols,
rows,
isConnected: false,
createdAt: now,
participants: new Map(),
lastDetachedAt: null,
detachTimeout: null,
outputBuffer: [],
outputBufferBytes: 0,
recordingPath,
recordingHeader,
recordingBytes: 0,
recordingId: null,
recordingWriteChain: Promise.resolve(),
recordingPersistChain: Promise.resolve(),
pendingRecordingData: "",
recordingFlushTimer: null,
tmuxSessionName: null,
sessionLoggingEnabled,
sessionStartedAt: now,
lastPersistedBytes: 0,
terminatedByOwner: false,
terminationReason: null,
};
this.sessions.set(id, session);
sshLogger.info("Terminal session created", {
operation: "session_created",
sessionId: id,
userId,
hostId,
});
return id;
}
getSession(sessionId: string | null): TerminalSession | null {
if (!sessionId) return null;
return this.sessions.get(sessionId) ?? null;
}
setSSHState(
sessionId: string,
conn: Client,
stream: ClientChannel,
jumpClient?: Client | null,
): void {
const session = this.sessions.get(sessionId);
if (!session) return;
session.sshConn = conn;
session.sshStream = stream;
session.jumpClient = jumpClient ?? null;
session.isConnected = true;
}
/** Finds the owner's participant entry, if currently attached. */
private getOwnerParticipant(
session: TerminalSession,
): SessionParticipant | null {
for (const participant of session.participants.values()) {
if (participant.isOwner) return participant;
}
return null;
}
private getOwnerEntry(
session: TerminalSession,
): [string, SessionParticipant] | null {
for (const entry of session.participants.entries()) {
if (entry[1].isOwner) return entry;
}
return null;
}
attachWs(
sessionId: string,
userId: string,
ws: WebSocket,
tabInstanceId?: string,
): TerminalSession | null {
const session = this.sessions.get(sessionId);
if (!session) {
sshLogger.warn("Session not found for attachment", {
operation: "session_attach_not_found",
sessionId,
userId,
});
return null;
}
if (session.userId !== userId) {
sshLogger.warn("Session userId mismatch", {
operation: "session_attach_user_mismatch",
sessionId,
expectedUserId: session.userId,
providedUserId: userId,
});
return null;
}
if (!session.isConnected) {
sshLogger.warn("Session not connected", {
operation: "session_attach_not_connected",
sessionId,
userId,
createdAt: session.createdAt,
elapsed: Date.now() - session.createdAt,
});
return null;
}
const ownerParticipant = this.getOwnerParticipant(session);
const isDetached =
!ownerParticipant || ownerParticipant.ws.readyState !== WebSocket.OPEN;
const isOriginalTab =
(session.attachedTabInstanceId ?? session.tabInstanceId) ===
tabInstanceId;
if (
!isDetached &&
!isOriginalTab &&
session.tabInstanceId &&
tabInstanceId
) {
sshLogger.warn("Session actively attached to different tab instance", {
operation: "session_attach_instance_conflict",
sessionId,
sessionInstanceId: session.tabInstanceId,
providedInstanceId: tabInstanceId,
});
try {
ws.send(
JSON.stringify({
type: "sessionExpired",
sessionId,
message: "Session belongs to a different tab instance",
}),
);
} catch {
/* ignore */
}
return null;
}
if (
session.tabInstanceId &&
tabInstanceId &&
session.tabInstanceId !== tabInstanceId
) {
sshLogger.info(
"Session attached to different tab instance (split-screen)",
{
operation: "session_attach_split_screen",
originalInstanceId: session.tabInstanceId,
newInstanceId: tabInstanceId,
sessionId,
},
);
}
const ownerEntry = this.getOwnerEntry(session);
if (ownerEntry && ownerEntry[1].ws !== ws) {
try {
ownerEntry[1].ws.send(
JSON.stringify({
type: "sessionTakenOver",
sessionId,
message: "Session was attached from another tab",
}),
);
} catch {
/* ignore */
}
session.participants.delete(ownerEntry[0]);
}
if (session.detachTimeout) {
clearTimeout(session.detachTimeout);
session.detachTimeout = null;
}
const participantId = crypto.randomUUID();
session.participants.set(participantId, {
ws,
userId,
permissionLevel: "read-write",
isOwner: true,
tabInstanceId,
});
session.attachedTabInstanceId = tabInstanceId;
session.lastDetachedAt = null;
this.broadcastParticipants(sessionId);
sshLogger.info("WebSocket attached to session", {
operation: "session_attach",
sessionId,
userId,
tabInstanceId,
});
return session;
}
/**
* Adds a non-owner participant (in-app share join or anonymous link guest).
* Purely additive - never evicts the owner or any other participant.
*/
joinAsParticipant(
sessionId: string,
ws: WebSocket,
opts: {
userId: string | null;
permissionLevel: "read-write" | "read-only";
displayName?: string;
guestLabel?: string;
tabInstanceId?: string;
shareId?: string;
},
): TerminalSession | null {
const session = this.sessions.get(sessionId);
if (!session || !session.isConnected) return null;
const participantId = crypto.randomUUID();
session.participants.set(participantId, {
ws,
userId: opts.userId,
permissionLevel: opts.permissionLevel,
isOwner: false,
displayName: opts.displayName,
guestLabel: opts.guestLabel,
tabInstanceId: opts.tabInstanceId,
joinedViaShareId: opts.shareId,
});
this.broadcastParticipants(sessionId);
sshLogger.info("Participant joined shared session", {
operation: "session_join_participant",
sessionId,
userId: opts.userId,
permissionLevel: opts.permissionLevel,
shareId: opts.shareId,
});
return session;
}
/**
* Tells everyone in a shared session who is present. Sent on join and
* leave, and only while someone besides the owner is (or just was) in the
* room - a solo owner never receives presence traffic.
*/
private broadcastParticipants(sessionId: string): void {
const session = this.sessions.get(sessionId);
if (!session) return;
const participants = Array.from(session.participants.values()).map(
(participant) => ({
isOwner: participant.isOwner,
permissionLevel: participant.permissionLevel,
label: participant.displayName ?? participant.guestLabel ?? null,
}),
);
if (participants.every((participant) => participant.isOwner)) return;
this.broadcast(sessionId, { type: "participants", participants });
}
/**
* Grants stage control: participants joined via this share become
* read-write only while they are the controller. The owner is untouched.
*/
setRoomShareControl(
sessionId: string,
shareId: string,
controllerUserId: string | null,
): void {
const session = this.sessions.get(sessionId);
if (!session) return;
for (const participant of session.participants.values()) {
if (participant.isOwner || participant.joinedViaShareId !== shareId)
continue;
participant.permissionLevel =
controllerUserId && participant.userId === controllerUserId
? "read-write"
: "read-only";
}
}
/**
* Disconnects non-owner participants that joined through one share.
* Supplying userId narrows the kick to that authenticated user; null targets
* anonymous guests. Omitting it revokes the share for every participant.
*/
disconnectShareParticipants(
sessionId: string,
shareId: string,
options: { userId?: string | null; reason: string },
): number {
const session = this.sessions.get(sessionId);
if (!session) return 0;
const filterByUser = Object.hasOwn(options, "userId");
let disconnected = 0;
for (const [id, participant] of session.participants.entries()) {
if (
participant.isOwner ||
participant.joinedViaShareId !== shareId ||
(filterByUser && participant.userId !== options.userId)
) {
continue;
}
session.participants.delete(id);
disconnected += 1;
if (participant.ws.readyState === WebSocket.OPEN) {
try {
participant.ws.send(
JSON.stringify({
type: "sessionExpired",
sessionId,
message: options.reason,
}),
);
participant.ws.close(1008, options.reason);
} catch {
participant.ws.terminate();
}
}
}
if (disconnected > 0) this.broadcastParticipants(sessionId);
return disconnected;
}
/** Fans out a message to every OPEN participant socket; skips closed ones and send failures. */
broadcast(sessionId: string, message: object): void {
const session = this.sessions.get(sessionId);
if (!session) return;
const payload = JSON.stringify(message);
for (const participant of session.participants.values()) {
if (participant.ws.readyState !== WebSocket.OPEN) continue;
try {
participant.ws.send(payload);
} catch {
/* ignore individual send failures, keep broadcasting to the rest */
}
}
}
/** Finds the participant entry (owner or not) for a given socket. */
getParticipantForWs(
session: TerminalSession,
ws: WebSocket,
): SessionParticipant | null {
for (const participant of session.participants.values()) {
if (participant.ws === ws) return participant;
}
return null;
}
/**
* Removes a non-owner participant's socket. No detach timeout or session
* destruction side effects - a guest leaving must never end the session.
*/
removeParticipant(sessionId: string, ws: WebSocket): void {
const session = this.sessions.get(sessionId);
if (!session) return;
for (const [id, participant] of session.participants.entries()) {
if (participant.ws === ws && !participant.isOwner) {
session.participants.delete(id);
this.broadcastParticipants(sessionId);
sshLogger.info("Participant left shared session", {
operation: "session_leave_participant",
sessionId,
userId: participant.userId,
});
return;
}
}
}
/** Broadcasts termination to all guests, then destroys the session. */
ownerEndSession(sessionId: string, reason: string): void {
const session = this.sessions.get(sessionId);
if (!session) return;
this.broadcast(sessionId, { type: "sessionTerminatedByOwner", reason });
session.terminatedByOwner = true;
session.terminationReason = reason;
sshLogger.info("Owner ended shared session", {
operation: "session_owner_end",
sessionId,
reason,
});
this.destroySession(sessionId);
}
detachWs(sessionId: string): void {
const session = this.sessions.get(sessionId);
if (!session) return;
if (session.detachTimeout) {
clearTimeout(session.detachTimeout);
session.detachTimeout = null;
}
const ownerEntry = this.getOwnerEntry(session);
if (ownerEntry) {
session.participants.delete(ownerEntry[0]);
}
session.lastDetachedAt = Date.now();
// Persist log immediately when the user detaches so it appears right away,
// regardless of whether the session is later reattached or times out.
this.maybePersistLog(session);
const timeoutMs = this.getTimeoutMs();
session.detachTimeout = setTimeout(() => {
sshLogger.info("Session idle timeout expired", {
operation: "session_idle_timeout",
sessionId,
userId: session.userId,
});
this.destroySession(sessionId);
}, timeoutMs);
sshLogger.info("WebSocket detached from session", {
operation: "session_detach",
sessionId,
userId: session.userId,
timeoutMinutes: timeoutMs / 60_000,
});
}
destroySession(sessionId: string): void {
const session = this.sessions.get(sessionId);
if (!session) return;
if (session.detachTimeout) {
clearTimeout(session.detachTimeout);
session.detachTimeout = null;
}
this.maybePersistLog(session, true);
if (session.recordingPath && session.recordingBytes === 0) {
fs.promises.unlink(session.recordingPath).catch(() => {});
}
for (const participant of session.participants.values()) {
if (participant.isOwner) continue;
if (participant.ws.readyState !== WebSocket.OPEN) continue;
try {
participant.ws.send(
JSON.stringify({
type: "sessionExpired",
sessionId,
message: "Session has ended",
}),
);
} catch {
/* ignore */
}
}
session.participants.clear();
if (session.sshStream) {
try {
session.sshStream.end();
} catch {
/* ignore */
}
session.sshStream = null;
}
if (session.sshConn) {
try {
session.sshConn.end();
} catch {
/* ignore */
}
session.sshConn = null;
}
if (session.jumpClient) {
try {
session.jumpClient.end();
} catch {
/* ignore */
}
session.jumpClient = null;
}
session.isConnected = false;
session.outputBuffer = [];
session.outputBufferBytes = 0;
this.sessions.delete(sessionId);
sshLogger.info("Terminal session destroyed", {
operation: "session_destroyed",
sessionId,
userId: session.userId,
hostId: session.hostId,
});
}
private maybePersistLog(session: TerminalSession, force = false): void {
if (!session.sessionLoggingEnabled) return;
if (session.recordingFlushTimer) {
clearTimeout(session.recordingFlushTimer);
session.recordingFlushTimer = null;
this.flushRecording(session);
}
if (session.recordingBytes === 0) return;
if (!force && session.recordingBytes === session.lastPersistedBytes) return;
session.lastPersistedBytes = session.recordingBytes;
session.recordingPersistChain = session.recordingPersistChain
.then(() => this.persistSessionLog(session))
.catch((err) => {
sshLogger.warn("Failed to persist session log", {
operation: "session_log_persist_error",
sessionId: session.id,
error: err instanceof Error ? err.message : String(err),
});
});
}
private async persistSessionLog(session: TerminalSession): Promise<void> {
if (!session.recordingPath) return;
await session.recordingWriteChain;
const endedAt = Date.now();
const duration = Math.floor((endedAt - session.sessionStartedAt) / 1000);
try {
const repo = createCurrentSessionRecordingRepository();
if (session.recordingId == null) {
const created = await repo.create({
hostId: session.hostId,
userId: session.userId,
startedAt: new Date(session.sessionStartedAt).toISOString(),
endedAt: new Date(endedAt).toISOString(),
duration,
recordingPath: session.recordingPath,
protocol: "ssh",
format: "asciicast",
terminatedByOwner: session.terminatedByOwner || undefined,
terminationReason: session.terminationReason ?? undefined,
});
session.recordingId = created.id;
} else {
await repo.updateEnded(session.recordingId, {
endedAt: new Date(endedAt).toISOString(),
duration,
terminatedByOwner: session.terminatedByOwner || undefined,
terminationReason: session.terminationReason ?? undefined,
});
}
} catch (err) {
sshLogger.warn("Failed to insert session recording row", {
operation: "session_recording_insert_error",
sessionId: session.id,
error: err instanceof Error ? err.message : String(err),
});
}
sshLogger.info("Session log persisted", {
operation: "session_log_persisted",
sessionId: session.id,
userId: session.userId,
hostId: session.hostId,
duration,
bytes: session.recordingBytes,
});
}
getUserSessions(userId: string): TerminalSession[] {
const result: TerminalSession[] = [];
for (const session of this.sessions.values()) {
if (session.userId === userId) {
result.push(session);
}
}
return result;
}
bufferOutput(sessionId: string, data: string): void {
const session = this.sessions.get(sessionId);
if (!session) return;
session.outputBuffer.push(data);
session.outputBufferBytes += data.length;
while (
session.outputBufferBytes > MAX_BUFFER_BYTES &&
session.outputBuffer.length > 0
) {
const removed = session.outputBuffer.shift();
if (removed) session.outputBufferBytes -= removed.length;
}
this.recordSessionEvent(session, "o", data);
}
bufferInput(sessionId: string, data: string): void {
const session = this.sessions.get(sessionId);
if (!session) return;
this.recordSessionEvent(session, "i", data);
}
bufferResize(sessionId: string, cols: number, rows: number): void {
const session = this.sessions.get(sessionId);
if (!session) return;
this.recordSessionEvent(session, "r", `${cols}x${rows}`);
}
private recordSessionEvent(
session: TerminalSession,
type: "i" | "o" | "r",
data: string,
): void {
if (!session.sessionLoggingEnabled || !session.recordingPath || !data)
return;
const elapsed = (Date.now() - session.sessionStartedAt) / 1000;
const line = `${JSON.stringify([elapsed, type, data])}\n`;
session.recordingBytes += Buffer.byteLength(line);
session.pendingRecordingData += line;
if (!session.recordingFlushTimer) {
session.recordingFlushTimer = setTimeout(() => {
session.recordingFlushTimer = null;
this.flushRecording(session);
}, RECORDING_FLUSH_INTERVAL_MS);
}
}
/** Coalesces buffered recording lines into a single disk write. */
private flushRecording(session: TerminalSession): void {
if (!session.recordingPath || !session.pendingRecordingData) return;
const chunk = session.pendingRecordingData;
session.pendingRecordingData = "";
const firstWrite = session.recordingBytes === Buffer.byteLength(chunk);
session.recordingWriteChain = session.recordingWriteChain.then(async () => {
if (firstWrite) {
await fs.promises.mkdir(path.dirname(session.recordingPath!), {
recursive: true,
});
await fs.promises.writeFile(
session.recordingPath!,
`${session.recordingHeader}${chunk}`,
"utf8",
);
return;
}
await fs.promises.appendFile(session.recordingPath!, chunk, "utf8");
});
}
flushBuffer(session: TerminalSession): string | null {
if (session.outputBuffer.length === 0) return null;
const data = session.outputBuffer.join("");
session.outputBuffer = [];
session.outputBufferBytes = 0;
return data;
}
getBuffer(session: TerminalSession): string | null {
if (session.outputBuffer.length === 0) return null;
return session.outputBuffer.join("");
}
private getTimeoutMs(): number {
try {
const value = getCurrentSettingValue("terminal_session_timeout_minutes");
if (value) {
const minutes = parseInt(value, 10);
if (!isNaN(minutes) && minutes > 0) {
return minutes * 60_000;
}
}
} catch {
// DB not available, use default
}
return DEFAULT_TIMEOUT_MINUTES * 60_000;
}
private healthCheck(): void {
const toDestroy: string[] = [];
const now = Date.now();
const GRACE_PERIOD_MS = 10_000;
for (const [id, session] of this.sessions) {
if (!session.isConnected) continue;
const hasOpenParticipant = Array.from(session.participants.values()).some(
(p) => p.ws.readyState === WebSocket.OPEN,
);
if (hasOpenParticipant) {
continue;
}
if (session.sshStream?.destroyed) {
const detachedDuration = session.lastDetachedAt
? now - session.lastDetachedAt
: 0;
if (detachedDuration > GRACE_PERIOD_MS) {
sshLogger.info(
"SSH stream destroyed during detach window, cleaning up",
{
operation: "session_health_check_stream_destroyed",
sessionId: id,
userId: session.userId,
detachedFor: detachedDuration,
},
);
toDestroy.push(id);
}
}
if (!session.sshConn) {
toDestroy.push(id);
}
}
for (const id of toDestroy) {
this.destroySession(id);
}
}
destroyAll(): void {
for (const id of [...this.sessions.keys()]) {
this.destroySession(id);
}
if (this.healthCheckTimer) {
clearInterval(this.healthCheckTimer);
this.healthCheckTimer = null;
}
}
}
export const sessionManager = TerminalSessionManager.getInstance();
+19
View File
@@ -21,7 +21,9 @@ import type {
PluginShareableRole,
} from "@termix/plugin-sdk/backend";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import { PluginCapabilityError } from "@termix/plugin-sdk/backend";
import { assertCapability } from "./permissions.js";
import { hostSessionStatus } from "../hosts/host-session-status.js";
import { getActor } from "./actor.js";
type AuditFn = (
@@ -392,5 +394,22 @@ export function createPluginHosts({ manifest, audit }: Deps): PluginHosts {
.filter((r) => !r.isSystem)
.map((r) => ({ id: r.id, name: r.name, displayName: r.displayName }));
},
// Synchronous so a transport can call it from an ssh2 "ready" handler, so
// only the declaration is checked here, like ctx.http.router.
trackSession: (hostId: number) => {
if (!declared.includes("hosts:read")) {
throw new PluginCapabilityError(pluginId, "hosts:read");
}
return hostSessionStatus.register(hostId);
},
recordActivity: async (hostId, type, hostName) => {
await requireRead();
const userId = actingUser();
const { recordRecentActivity } =
await import("../services/recent-activity.js");
await recordRecentActivity(userId, { type, hostId, hostName });
},
};
}
+93 -3
View File
@@ -13,12 +13,16 @@ import type {
PluginSshHost,
} from "@termix/plugin-sdk/backend";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import { PluginCapabilityError } from "@termix/plugin-sdk/backend";
import {
PluginCapabilityError,
PluginSshInteractionError,
} from "@termix/plugin-sdk/backend";
import { assertCapability } from "./permissions.js";
import { getActor } from "./actor.js";
import type { DisposableBag } from "./disposables.js";
import {
getSshAuthProvider,
listSshAuthProviders,
registerKeyboardInteractiveInterceptor,
registerSshAuthProvider,
} from "../hosts/connect/auth-provider-registry.js";
@@ -55,6 +59,7 @@ const PLUGIN_PURPOSES = new Set<SshConnectPurpose>([
"tunnel",
"file-manager",
"file-transfer",
"terminal",
]);
function purposeOf(options?: PluginSshConnectOptions): SshConnectPurpose {
@@ -222,6 +227,21 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh {
poolKey,
resolveHost: async (hostId, options) => {
await checkSsh(true);
const userId = actingUser(hostId);
const { resolveHostById, resolveHostBySyncId } =
await import("../hosts/host-resolver.js");
const resolved = options?.syncId
? await resolveHostBySyncId(options.syncId, userId)
: await resolveHostById(hostId, userId);
await audit("ssh_resolve_host", describeHost(hostId), {
success: !!resolved,
errorMessage: resolved ? undefined : "Host not found",
});
return (resolved as unknown as PluginSshHost | null) ?? null;
},
prepare: async (host, options) => {
await checkSsh(true);
const { buildConnectConfig } =
@@ -232,22 +252,38 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh {
client: options.client as never,
serverHostId: options.serverHostId,
log: options.log,
interactive: options.interactive,
hostKeySocket: (options.hostKeySocket ?? null) as never,
});
await audit("ssh_prepare", describeHost(host), {
success: built.outcome.status === "ready",
errorMessage:
built.outcome.status === "ready" ? undefined : built.outcome.message,
});
return { config: built.config, outcome: built.outcome };
const provider = built.provider;
const target = host as SshConnectHost;
return {
config: built.config,
outcome: built.outcome,
authType: provider?.type ?? null,
onBanner: provider?.onBanner
? (banner: string) =>
provider.onBanner!(banner, target, built.env) as never
: undefined,
onAuthFailed: provider?.onAuthFailed
? (context) => provider.onAuthFailed!(target, built.env, context)
: undefined,
};
},
openTransport: async (host, config) => {
openTransport: async (host, config, transportOptions) => {
await checkSsh(false);
const { openSshTransport } =
await import("../hosts/connect/transport.js");
const opened = await openSshTransport(
host as SshConnectHost,
config as MutableConnectConfig,
transportOptions,
);
if (opened.jumpClient) {
const jumpClient = opened.jumpClient;
@@ -261,6 +297,60 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh {
return opened;
},
startInteraction: async (interaction, request) => {
await checkSsh(true);
const userId = actingUser(request.hostId);
ensureCoreSshAuthProviders();
const { createCurrentHostResolutionRepository } =
await import("../database/repositories/factory.js");
const host = await createCurrentHostResolutionRepository().findHostById(
request.hostId,
userId,
);
if (!host) throw new PluginSshInteractionError("Host not found");
const own = getSshAuthProvider(host.authType as string);
const provider =
own?.interaction === interaction && own.startInteraction
? own
: listSshAuthProviders().find(
(candidate) =>
candidate.interaction === interaction &&
candidate.startInteraction,
);
if (!provider?.startInteraction) {
throw new PluginSshInteractionError(
`No enabled provider handles ${interaction} sign-in`,
);
}
await audit("ssh_start_interaction", describeHost(request.hostId), {
success: true,
});
await provider.startInteraction({
userId,
hostId: request.hostId,
host: {
name: host.name as string | null,
ip: host.ip as string,
username: host.username as string,
},
socket: request.socket as never,
requestOrigin: request.requestOrigin,
payload: request.payload ?? {},
});
},
cancelInteraction: async (interaction, request) => {
await checkSsh(false);
const userId = actingUser(request.hostId ?? 0);
ensureCoreSshAuthProviders();
for (const provider of listSshAuthProviders()) {
if (provider.interaction !== interaction) continue;
if (!provider.cancelInteraction) continue;
await provider.cancelInteraction({ userId, ...request });
}
},
classifyKeyboardInteractive: (round, host) => {
ensureCoreSshAuthProviders();
return classifyKeyboardInteractive(round, host as SshConnectHost);
+23
View File
@@ -640,6 +640,29 @@ export function createPluginContext(
openIsolatedWindow: (request) => desktopOpenIsolatedWindow(request),
},
audit: {
// Attribution comes from the runtime: the actor, never a plugin value.
record: async (entry) => {
try {
const { logAudit } = await import("../utils/audit-logger.js");
const actor = getActor() ?? "system";
await logAudit({
userId: actor,
username: actor,
action: entry.action,
resourceType: entry.resourceType ?? "plugin",
resourceId: entry.resourceId ?? pluginId,
resourceName: entry.resourceName ?? manifest.name,
details: entry.details ?? `via plugin ${pluginId}`,
success: entry.success,
errorMessage: entry.errorMessage,
});
} catch {
// Auditing must never break the caller.
}
},
},
/**
* Background work acts as a named user. Always audited, because "this ran
* as someone" is exactly the thing an operator needs to be able to see.
+19
View File
@@ -166,6 +166,25 @@ export function getRegistration(
return services.get(service);
}
/**
* A plugin service as core itself calls it, or undefined while no plugin
* provides a compatible version.
*
* Core is not a plugin and already authorized the request it is serving, so
* this skips the per-call permission check a plugin's handle runs. It is the
* same object plugins get. Session sharing reaching the terminal's live
* sessions is the first caller.
*/
export function getServiceImplementation<T extends object>(
service: string,
versionRange: string,
): T | undefined {
const registration = services.get(service);
if (!registration) return undefined;
if (!semver.satisfies(registration.version, versionRange)) return undefined;
return registration.implementation as T;
}
export function listServices(): ServiceRegistration[] {
return [...services.values()];
}
+14 -1
View File
@@ -159,6 +159,8 @@ export async function handlePluginUpgrade(
return true;
}
userId = resolved;
} else if (route.options.optionalAuth) {
userId = (await verifyToken(request)) ?? "";
}
if (route.rawHandler) {
@@ -175,6 +177,17 @@ export async function handlePluginUpgrade(
return true;
}
const { getClientIp, getRequestOrigin } =
await import("../utils/request-origin.js");
const { DataCrypto } = await import("../utils/data-crypto.js");
const connection = {
userId,
request,
clientIp: getClientIp(request as never),
requestOrigin: getRequestOrigin(request as never),
isDataUnlocked: () => !!userId && !!DataCrypto.getUserDataKey(userId),
};
server.handleUpgrade(request, socket, head, (ws: WebSocket) => {
const handler = route.handler;
if (!handler) {
@@ -182,7 +195,7 @@ export async function handlePluginUpgrade(
return;
}
runAsActor(userId || "anonymous", "request", () => {
void Promise.resolve(handler({ userId, request, socket: ws })).catch(
void Promise.resolve(handler({ ...connection, socket: ws })).catch(
(error) => {
pluginLogger.error(
`Plugin ${route.pluginId} socket ${route.path} failed`,
+11 -85
View File
@@ -6,13 +6,9 @@ import { dashboardLogger } from "../utils/logger.js";
import { AuthManager } from "../utils/auth-manager.js";
import type { AuthenticatedRequest } from "../../types/index.js";
import { dashboardServiceLinksRouter } from "../database/routes/dashboard-service-links-routes.js";
import {
createCurrentHostResolutionRepository,
createCurrentRbacAccessRepository,
createCurrentRecentActivityRepository,
createCurrentRoleRepository,
} from "../database/repositories/factory.js";
import { createCurrentRecentActivityRepository } from "../database/repositories/factory.js";
import { DataCrypto } from "../utils/data-crypto.js";
import { recordRecentActivity } from "./recent-activity.js";
import { listenOnServicePort } from "../utils/service-listen.js";
const app = express();
@@ -21,9 +17,6 @@ const authManager = AuthManager.getInstance();
const serverStartTime = Date.now();
const activityRateLimiter = new Map<string, number>();
const RATE_LIMIT_MS = 1000;
function isUserDataUnlocked(userId: string): boolean {
return DataCrypto.getUserDataKey(userId) !== null;
}
@@ -181,91 +174,24 @@ app.post("/activity/log", async (req, res) => {
});
}
if (
![
"terminal",
"file_manager",
"server_stats",
"tunnel",
"docker",
"telnet",
"vnc",
"rdp",
].includes(type)
) {
const result = await recordRecentActivity(userId, {
type,
hostId,
hostName,
});
if (result.status === "invalid_type") {
return res.status(400).json({
error:
"Invalid activity type. Must be 'terminal', 'file_manager', 'server_stats', 'tunnel', 'docker', 'telnet', 'vnc', or 'rdp'",
});
}
const rateLimitKey = `${userId}:${hostId}:${type}`;
const now = Date.now();
const lastLogged = activityRateLimiter.get(rateLimitKey);
if (lastLogged && now - lastLogged < RATE_LIMIT_MS) {
if (result.status === "rate_limited") {
return res.json({
message: "Activity already logged recently (rate limited)",
});
}
activityRateLimiter.set(rateLimitKey, now);
if (activityRateLimiter.size > 10000) {
const entriesToDelete: string[] = [];
for (const [key, timestamp] of activityRateLimiter.entries()) {
if (now - timestamp > RATE_LIMIT_MS * 2) {
entriesToDelete.push(key);
}
}
entriesToDelete.forEach((key) => activityRateLimiter.delete(key));
}
const isOwnedHost =
await createCurrentHostResolutionRepository().isHostOwnedByUser(
hostId,
userId,
);
if (!isOwnedHost) {
const roleIds =
await createCurrentRoleRepository().listUserRoleIds(userId);
const sharedHosts =
await createCurrentRbacAccessRepository().listVisibleHostAccessEntries(
userId,
roleIds,
);
const hasSharedAccess = sharedHosts.some(
(access) => access.hostId === hostId,
);
if (!hasSharedAccess) {
return res
.status(404)
.json({ error: "Host not found or access denied" });
}
}
const result = await createCurrentRecentActivityRepository().create({
userId,
type,
hostId,
hostName,
});
// Best-effort trim of old activity entries; failures here should not
// cause the primary /activity/log request to 500.
try {
await createCurrentRecentActivityRepository().trimUserActivity(
userId,
100,
);
} catch (trimErr) {
dashboardLogger.warn("Failed to trim recent_activity (non-fatal)", {
operation: "trim_recent_activity",
userId,
error: trimErr instanceof Error ? trimErr.message : String(trimErr),
});
if (result.status === "denied") {
return res.status(404).json({ error: "Host not found or access denied" });
}
res.json({ message: "Activity logged", id: result.id });
+93
View File
@@ -0,0 +1,93 @@
import {
createCurrentHostResolutionRepository,
createCurrentRbacAccessRepository,
createCurrentRecentActivityRepository,
createCurrentRoleRepository,
} from "../database/repositories/factory.js";
import { dashboardLogger } from "../utils/logger.js";
const RATE_LIMIT_MS = 1000;
const activityRateLimiter = new Map<string, number>();
export const RECENT_ACTIVITY_TYPES = [
"terminal",
"file_manager",
"server_stats",
"tunnel",
"docker",
"telnet",
"vnc",
"rdp",
] as const;
export type RecordActivityResult =
| { status: "logged"; id: number }
| { status: "rate_limited" }
| { status: "invalid_type" }
| { status: "denied" };
/**
* Adds one recent activity entry: the dashboard's /activity/log route and
* ctx.hosts.recordActivity both come through here, so the rate limit and the
* access rule are the same for both.
*/
export async function recordRecentActivity(
userId: string,
entry: { type: string; hostId: number; hostName: string },
): Promise<RecordActivityResult> {
const { type, hostId, hostName } = entry;
if (!(RECENT_ACTIVITY_TYPES as readonly string[]).includes(type)) {
return { status: "invalid_type" };
}
const rateLimitKey = `${userId}:${hostId}:${type}`;
const now = Date.now();
const lastLogged = activityRateLimiter.get(rateLimitKey);
if (lastLogged && now - lastLogged < RATE_LIMIT_MS) {
return { status: "rate_limited" };
}
activityRateLimiter.set(rateLimitKey, now);
if (activityRateLimiter.size > 10000) {
for (const [key, timestamp] of activityRateLimiter.entries()) {
if (now - timestamp > RATE_LIMIT_MS * 2) activityRateLimiter.delete(key);
}
}
const isOwnedHost =
await createCurrentHostResolutionRepository().isHostOwnedByUser(
hostId,
userId,
);
if (!isOwnedHost) {
const roleIds = await createCurrentRoleRepository().listUserRoleIds(userId);
const sharedHosts =
await createCurrentRbacAccessRepository().listVisibleHostAccessEntries(
userId,
roleIds,
);
if (!sharedHosts.some((access) => access.hostId === hostId)) {
return { status: "denied" };
}
}
const result = await createCurrentRecentActivityRepository().create({
userId,
type,
hostId,
hostName,
});
// Best-effort trim; a failure here must not fail the log itself.
try {
await createCurrentRecentActivityRepository().trimUserActivity(userId, 100);
} catch (trimErr) {
dashboardLogger.warn("Failed to trim recent_activity (non-fatal)", {
operation: "trim_recent_activity",
userId,
error: trimErr instanceof Error ? trimErr.message : String(trimErr),
});
}
return { status: "logged", id: result.id };
}
+4
View File
@@ -361,6 +361,10 @@ async function provisionLocalDesktopUserIfNeeded(): Promise<void> {
const { runWebEndpointSettingsMigration } =
await import("./utils/crypto-migration/web-endpoint-settings-migration.js");
await runWebEndpointSettingsMigration();
const { runSshTerminalSettingsMigration } =
await import("./utils/crypto-migration/ssh-terminal-settings-migration.js");
await runSshTerminalSettingsMigration();
} catch (error) {
systemLogger.warn("Plugin runtime failed to initialize", {
operation: "plugin_init",
@@ -1,76 +0,0 @@
import { afterEach, describe, expect, it } from "vitest";
import { TestSqliteDatabase } from "./test-support.js";
import { CommandHistoryRepository } from "../../../database/repositories/command-history-repository.js";
describe("CommandHistoryRepository", () => {
let adapter: TestSqliteDatabase | null = null;
afterEach(async () => {
if (adapter) {
await adapter.close();
adapter = null;
}
});
async function createRepository(
onWrite?: () => void | Promise<void>,
): Promise<CommandHistoryRepository> {
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-1', 'two', '10.0.0.1', 22, 'root', 'password'), (3, 'user-2', 'other', '10.0.0.1', 22, 'root', 'password');
`);
return new CommandHistoryRepository(context, onWrite);
}
it("creates and lists unique commands by latest execution", async () => {
const repo = await createRepository();
await repo.create("user-1", 1, "ls", "2026-06-27T00:00:00.000Z");
await repo.create("user-1", 1, "pwd", "2026-06-27T01:00:00.000Z");
await repo.create("user-1", 1, "ls", "2026-06-27T02:00:00.000Z");
await repo.create("user-2", 3, "whoami", "2026-06-27T03:00:00.000Z");
expect(await repo.listUniqueCommandsForHost("user-1", 1)).toEqual([
"ls",
"pwd",
]);
expect(await repo.listCommandsForHost("user-1", 1)).toEqual([
"ls",
"pwd",
"ls",
]);
});
it("deletes commands by command, host, host list, and user", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
await repo.create("user-1", 1, "ls");
await repo.create("user-1", 1, "ls");
await repo.create("user-1", 2, "pwd");
await repo.create("user-2", 3, "whoami");
expect(writeCount).toBe(4);
expect(await repo.deleteCommandForHost("user-1", 1, "missing")).toBe(0);
expect(writeCount).toBe(4);
expect(await repo.deleteCommandForHost("user-1", 1, "ls")).toBe(2);
expect(writeCount).toBe(5);
expect(await repo.deleteByUserAndHost("user-1", 2)).toBe(1);
expect(await repo.deleteByHostIds([])).toBe(0);
expect(await repo.deleteByHostIds([3])).toBe(1);
expect(writeCount).toBe(7);
await repo.create("user-1", 1, "date");
expect(await repo.deleteByUserId("user-1")).toBe(1);
expect(writeCount).toBe(9);
});
});
@@ -1,304 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { EventEmitter } from "events";
import fs from "fs/promises";
import os from "os";
import path from "path";
import express, {
type Request,
type RequestHandler,
type Response,
} from "express";
const state = vi.hoisted(() => ({
userId: "admin-1",
settings: {} as Record<string, string>,
sessions: [] as unknown[],
}));
vi.mock("../../../utils/logger.js", () => ({
authLogger: { warn: vi.fn(), error: vi.fn(), info: vi.fn() },
databaseLogger: { warn: vi.fn(), error: vi.fn(), info: vi.fn() },
}));
vi.mock("../../../hosts/terminal/session-manager.js", () => ({
sessionManager: {
getUserSessions: () => state.sessions,
},
}));
vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentSettingsRepository: () => ({
get: async (key: string) => state.settings[key] ?? null,
set: async (key: string, value: string) => {
state.settings[key] = value;
},
setMany: async (writes: Array<{ key: string; value: string }>) => {
for (const write of writes) state.settings[write.key] = write.value;
},
}),
}));
const { registerUserImageStorageRoutes } =
await import("../../../database/routes/user-image-storage-routes.js");
const requireAdmin: RequestHandler = (_req, _res, next) => next();
const router = express.Router();
registerUserImageStorageRoutes(router, requireAdmin);
interface RouteLayer {
route?: {
path: string;
methods: Record<string, boolean>;
stack: Array<{ handle: RequestHandler }>;
};
}
function handlerFor(pathName: string, method: string): RequestHandler {
const layer = (router as unknown as { stack: RouteLayer[] }).stack.find(
(candidate) =>
candidate.route?.path === pathName && candidate.route.methods[method],
);
if (!layer) throw new Error(`Route not registered: ${method} ${pathName}`);
return layer.route!.stack[layer.route!.stack.length - 1]!.handle;
}
const getHandler = handlerFor("/terminal-image-storage-settings", "get");
const patchHandler = handlerFor("/terminal-image-storage-settings", "patch");
const testHandler = handlerFor("/terminal-image-storage-settings/test", "post");
async function invoke(handler: RequestHandler, body?: unknown) {
const req = {
userId: state.userId,
body: body ?? {},
headers: {},
} as unknown as Request;
const result = { statusCode: 200, body: null as unknown };
const res = {
status(code: number) {
result.statusCode = code;
return this;
},
json(payload: unknown) {
result.body = payload;
return this;
},
} as unknown as Response;
await handler(req, res, () => {});
return result;
}
/** Fake ssh2 exec channel: every command exits 0. */
function fakeSshConn() {
return {
exec(
_command: string,
callback: (error: Error | undefined, stream?: unknown) => void,
) {
const stream = new EventEmitter() as EventEmitter & {
resume: () => void;
};
stream.resume = () => queueMicrotask(() => stream.emit("close", 0));
callback(undefined, stream);
},
};
}
const LEGACY_ENV_NAMES = [
"TERMIX_IMAGE_STORAGE_MODE",
"TERMIX_IMAGE_DIR",
"TERMIX_IMAGE_HOST_PATH",
"TERMIX_IMAGE_TTL_MS",
"TERMIX_MAX_IMAGE_COUNT",
"TERMIX_MAX_IMAGE_STORAGE_BYTES",
"DATA_DIR",
];
let savedEnv: Record<string, string | undefined>;
let localDir: string;
beforeEach(async () => {
state.settings = {};
state.sessions = [];
savedEnv = Object.fromEntries(
LEGACY_ENV_NAMES.map((name) => [name, process.env[name]]),
);
for (const name of LEGACY_ENV_NAMES) delete process.env[name];
// The settings validator rejects backslashes, so use a POSIX-style form of
// the real temp dir. Windows still resolves it, so file checks keep working.
localDir = (
await fs.mkdtemp(path.join(os.tmpdir(), "termix-image-test-"))
).replace(/\\/g, "/");
});
afterEach(async () => {
for (const name of LEGACY_ENV_NAMES) {
if (savedEnv[name] === undefined) delete process.env[name];
else process.env[name] = savedEnv[name];
}
await fs.rm(localDir, { recursive: true, force: true });
});
describe("GET /users/terminal-image-storage-settings", () => {
it("returns the public settings shape without the backend localDir", async () => {
state.settings["terminal_image_storage_mode"] = "local";
state.settings["terminal_image_local_dir"] = localDir;
state.settings["terminal_image_host_path"] = "/mnt/images";
const response = await invoke(getHandler);
expect(response.statusCode).toBe(200);
expect(response.body).toEqual({
mode: "local",
hostPath: "/mnt/images",
ttlMs: 3_600_000,
maxCount: 100,
maxBytes: 5_368_709_120,
localMappingConfigured: true,
});
expect(JSON.stringify(response.body)).not.toContain(localDir);
});
});
describe("PATCH /users/terminal-image-storage-settings", () => {
it("rejects an invalid mode with a safe 400", async () => {
const response = await invoke(patchHandler, { mode: "nfs" });
expect(response.statusCode).toBe(400);
expect(response.body).toEqual({
error: "Invalid value for mode",
code: "IMAGE_STORAGE_SETTINGS_INVALID",
field: "mode",
});
});
it("rejects a relative localDir", async () => {
const response = await invoke(patchHandler, { localDir: "images/tmp" });
expect(response.statusCode).toBe(400);
expect(response.body).toMatchObject({
code: "IMAGE_STORAGE_SETTINGS_INVALID",
field: "localDir",
});
expect(JSON.stringify(response.body)).not.toContain("images/tmp");
});
it("rejects out-of-range numeric limits", async () => {
const response = await invoke(patchHandler, { maxBytes: 1024 });
expect(response.statusCode).toBe(400);
expect(response.body).toMatchObject({
code: "IMAGE_STORAGE_SETTINGS_INVALID",
field: "maxBytes",
});
});
it("rejects unknown fields without writing anything", async () => {
const response = await invoke(patchHandler, { shellPath: "/tmp/x" });
expect(response.statusCode).toBe(400);
expect(response.body).toMatchObject({
code: "IMAGE_STORAGE_SETTINGS_UNKNOWN_FIELD",
});
expect(state.settings).toEqual({});
});
// On Windows path.resolve turns the stored dir back into a backslash path,
// which the validator rejects on read, so the round trip only holds on POSIX.
it.skipIf(process.platform === "win32")(
"persists a valid partial update and returns the public shape",
async () => {
const response = await invoke(patchHandler, {
mode: "local",
localDir,
hostPath: "/host/images",
ttlMs: 60_000,
maxCount: 5,
maxBytes: 10_485_760,
});
expect(response.statusCode).toBe(200);
expect(state.settings).toEqual({
terminal_image_storage_mode: "local",
terminal_image_local_dir: path.resolve(localDir),
terminal_image_host_path: "/host/images",
terminal_image_ttl_ms: "60000",
terminal_image_max_count: "5",
terminal_image_max_storage_bytes: "10485760",
});
expect(response.body).toMatchObject({
mode: "local",
hostPath: "/host/images",
ttlMs: 60_000,
maxCount: 5,
maxBytes: 10_485_760,
localMappingConfigured: true,
});
expect(JSON.stringify(response.body)).not.toContain(localDir);
},
);
});
describe("POST /users/terminal-image-storage-settings/test", () => {
it("requires an instanceId", async () => {
const response = await invoke(testHandler, {});
expect(response.statusCode).toBe(400);
expect(response.body).toMatchObject({ code: "IMAGE_SESSION_MISSING" });
});
it("reports unavailable storage when no session is connected", async () => {
const response = await invoke(testHandler, { instanceId: "tab-1" });
expect(response.statusCode).toBe(200);
expect(response.body).toEqual({
mode: "auto",
connected: false,
remoteSftpAvailable: false,
localHostVisible: null,
selectedMode: "unavailable",
localMappingConfigured: false,
});
});
it("probes local visibility through the connected session only", async () => {
state.settings["terminal_image_local_dir"] = localDir;
state.settings["terminal_image_host_path"] = "/host/images";
state.sessions = [
{
tabInstanceId: "tab-1",
isConnected: true,
sshConn: fakeSshConn(),
},
];
const response = await invoke(testHandler, { instanceId: "tab-1" });
expect(response.statusCode).toBe(200);
expect(response.body).toEqual({
mode: "auto",
connected: true,
remoteSftpAvailable: true,
localHostVisible: true,
selectedMode: "local",
localMappingConfigured: true,
});
// The bounded probe cleans up after itself.
expect(
(await fs.readdir(localDir)).filter((f) => f.includes("probe")),
).toEqual([]);
});
it("does not probe sessions owned by other instance IDs", async () => {
state.sessions = [
{
tabInstanceId: "tab-2",
isConnected: true,
sshConn: fakeSshConn(),
},
];
const response = await invoke(testHandler, { instanceId: "tab-1" });
expect(response.statusCode).toBe(200);
expect(response.body).toMatchObject({
connected: false,
remoteSftpAvailable: false,
localHostVisible: null,
selectedMode: "unavailable",
});
});
});
@@ -1,192 +0,0 @@
import path from "node:path";
import { describe, expect, it } from "vitest";
import {
DEFAULT_IMAGE_HOST_PATH,
DEFAULT_IMAGE_MAX_BYTES,
DEFAULT_IMAGE_MAX_COUNT,
DEFAULT_IMAGE_TTL_MS,
defaultImageLocalDir,
parseImageHostPath,
parseImageLocalDir,
parseTerminalImageStorageMode,
resolveTerminalImageStorageSettings,
TERMINAL_IMAGE_STORAGE_KEYS,
} from "../../../database/routes/terminal-image-storage-settings.js";
import { SettingsRepository } from "../../../database/repositories/settings-repository.js";
import { TestSqliteDatabase } from "../repositories/test-support.js";
// parseImageLocalDir resolves against the host platform, so a POSIX literal
// becomes a drive-rooted path on Windows. Compare against the same resolution.
function localDir(posixPath: string): string {
return path.resolve(posixPath);
}
function stubSettings(values: Record<string, string> = {}) {
return {
get: async (key: string) => values[key] ?? null,
};
}
const EMPTY_ENV: NodeJS.ProcessEnv = {};
describe("terminal image storage settings", () => {
describe("mode parsing", () => {
it("accepts the three documented modes case-insensitively", () => {
expect(parseTerminalImageStorageMode("auto")).toBe("auto");
expect(parseTerminalImageStorageMode("LOCAL")).toBe("local");
expect(parseTerminalImageStorageMode(" remote-sftp ")).toBe(
"remote-sftp",
);
});
it("rejects unknown modes and non-strings", () => {
expect(parseTerminalImageStorageMode("s3")).toBeNull();
expect(parseTerminalImageStorageMode("")).toBeNull();
expect(parseTerminalImageStorageMode(undefined)).toBeNull();
});
});
describe("path validation", () => {
it("accepts absolute local directories and normalizes them", () => {
expect(parseImageLocalDir("/var/lib/termix/images")).toBe(
localDir("/var/lib/termix/images"),
);
expect(parseImageLocalDir("/var/lib/termix/../termix/images")).toBeNull();
});
it("rejects relative, empty and NUL-containing local directories", () => {
expect(parseImageLocalDir("images")).toBeNull();
expect(parseImageLocalDir("./db/data/images")).toBeNull();
expect(parseImageLocalDir("")).toBeNull();
expect(parseImageLocalDir("/tmp/a\0b")).toBeNull();
});
it("requires the agent-visible host path to be POSIX-absolute", () => {
expect(parseImageHostPath("/tmp/termix-image-v0")).toBe(
"/tmp/termix-image-v0",
);
expect(parseImageHostPath("tmp/termix-image-v0")).toBeNull();
expect(parseImageHostPath("C:\\images")).toBeNull();
expect(parseImageHostPath("/tmp/a\0b")).toBeNull();
});
});
describe("resolution precedence", () => {
it("uses built-in defaults when neither the database nor env has values", async () => {
const resolved = await resolveTerminalImageStorageSettings(
stubSettings(),
EMPTY_ENV,
);
expect(resolved.mode).toBe("auto");
expect(resolved.localDir).toBe(defaultImageLocalDir(EMPTY_ENV));
expect(resolved.hostPath).toBe(DEFAULT_IMAGE_HOST_PATH);
expect(resolved.ttlMs).toBe(DEFAULT_IMAGE_TTL_MS);
expect(resolved.maxCount).toBe(DEFAULT_IMAGE_MAX_COUNT);
expect(resolved.maxBytes).toBe(DEFAULT_IMAGE_MAX_BYTES);
});
it("seeds defaults from legacy TERMIX_IMAGE_* env when no DB value exists", async () => {
const resolved = await resolveTerminalImageStorageSettings(
stubSettings(),
{
TERMIX_IMAGE_DIR: "/host-tmp/images",
TERMIX_IMAGE_HOST_PATH: "/tmp/images",
TERMIX_IMAGE_TTL_MS: "60000",
TERMIX_MAX_IMAGE_COUNT: "5",
TERMIX_MAX_IMAGE_STORAGE_BYTES: "10485760",
},
);
expect(resolved.localDir).toBe(localDir("/host-tmp/images"));
expect(resolved.hostPath).toBe("/tmp/images");
expect(resolved.ttlMs).toBe(60_000);
expect(resolved.maxCount).toBe(5);
expect(resolved.maxBytes).toBe(10_485_760);
});
it("lets persisted DB values win over legacy env values", async () => {
const resolved = await resolveTerminalImageStorageSettings(
stubSettings({
[TERMINAL_IMAGE_STORAGE_KEYS.localDir]: "/db/images",
[TERMINAL_IMAGE_STORAGE_KEYS.ttlMs]: "1000",
[TERMINAL_IMAGE_STORAGE_KEYS.maxCount]: "7",
}),
{
TERMIX_IMAGE_DIR: "/host-tmp/images",
TERMIX_IMAGE_TTL_MS: "60000",
TERMIX_MAX_IMAGE_COUNT: "5",
},
);
expect(resolved.localDir).toBe(localDir("/db/images"));
expect(resolved.ttlMs).toBe(1_000);
expect(resolved.maxCount).toBe(7);
});
it("falls through to env and defaults when the DB value is invalid", async () => {
const resolved = await resolveTerminalImageStorageSettings(
stubSettings({
[TERMINAL_IMAGE_STORAGE_KEYS.localDir]: "relative/path",
[TERMINAL_IMAGE_STORAGE_KEYS.ttlMs]: "not-a-number",
}),
{ TERMIX_IMAGE_DIR: "/host-tmp/images" },
);
expect(resolved.localDir).toBe(localDir("/host-tmp/images"));
expect(resolved.ttlMs).toBe(DEFAULT_IMAGE_TTL_MS);
});
it("keeps legacy explicit local mappings on local mode", async () => {
const resolved = await resolveTerminalImageStorageSettings(
stubSettings(),
{ TERMIX_IMAGE_DIR: "/host-tmp/images" },
);
expect(resolved.mode).toBe("local");
// hostPath falls back to the legacy local dir, which resolves natively.
expect(resolved.hostPath).toBe(localDir("/host-tmp/images"));
expect(resolved.localMappingConfigured).toBe(true);
});
it("lets a persisted DB mode override the legacy local mapping", async () => {
const resolved = await resolveTerminalImageStorageSettings(
stubSettings({
[TERMINAL_IMAGE_STORAGE_KEYS.mode]: "remote-sftp",
}),
{ TERMIX_IMAGE_DIR: "/host-tmp/images" },
);
expect(resolved.mode).toBe("remote-sftp");
});
it("clamps out-of-range numeric values like the legacy env parsing did", async () => {
const resolved = await resolveTerminalImageStorageSettings(
stubSettings(),
{
TERMIX_IMAGE_TTL_MS: "-5",
TERMIX_MAX_IMAGE_COUNT: "0",
TERMIX_MAX_IMAGE_STORAGE_BYTES: "10",
},
);
expect(resolved.ttlMs).toBe(0);
expect(resolved.maxCount).toBe(1);
expect(resolved.maxBytes).toBe(1_048_576);
});
it("reads persisted values through the real settings repository", async () => {
const adapter = new TestSqliteDatabase();
try {
const context = await adapter.connect();
const repository = new SettingsRepository(context);
await repository.set(TERMINAL_IMAGE_STORAGE_KEYS.mode, "local");
await repository.set(
TERMINAL_IMAGE_STORAGE_KEYS.localDir,
"/persisted/images",
);
const resolved = await resolveTerminalImageStorageSettings(repository, {
TERMIX_IMAGE_DIR: "/host-tmp/images",
});
expect(resolved.mode).toBe("local");
expect(resolved.localDir).toBe(localDir("/persisted/images"));
} finally {
await adapter.close();
}
});
});
});
@@ -1,571 +0,0 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { EventEmitter } from "events";
import fs from "fs/promises";
import os from "os";
import path from "path";
import { randomUUID } from "crypto";
import {
REMOTE_IMAGE_DIR,
selectImageStorageMode,
storeImageLocally,
storeImageViaSftp,
TerminalImageStorageError,
type ImageSftpClient,
} from "../../../database/routes/terminal-image-storage.js";
import type { TerminalImageStorageSettings } from "../../../database/routes/terminal-image-storage-settings.js";
const PNG_BYTES = Buffer.from([0x89, 0x50, 0x4e, 0x47]);
function settings(
overrides: Partial<TerminalImageStorageSettings>,
): TerminalImageStorageSettings {
return {
mode: "local",
localDir: "/nonexistent",
hostPath: "/tmp/termix-image-v0",
ttlMs: 3_600_000,
maxCount: 100,
maxBytes: 5_368_709_120,
localMappingConfigured: false,
...overrides,
};
}
describe("selectImageStorageMode", () => {
it("keeps explicit modes deterministic regardless of capability", () => {
expect(
selectImageStorageMode(settings({ mode: "local" }), {
remoteSftpAvailable: true,
}),
).toBe("local");
expect(
selectImageStorageMode(settings({ mode: "remote-sftp" }), {
remoteSftpAvailable: false,
}),
).toBe("remote-sftp");
});
it("falls back on capability only in auto mode", () => {
expect(
selectImageStorageMode(settings({ mode: "auto" }), {
remoteSftpAvailable: true,
}),
).toBe("remote-sftp");
expect(
selectImageStorageMode(settings({ mode: "auto" }), {
remoteSftpAvailable: false,
}),
).toBe("unavailable");
expect(
selectImageStorageMode(
settings({ mode: "auto", localMappingConfigured: true }),
{ remoteSftpAvailable: false, localHostVisible: true },
),
).toBe("local");
});
});
describe("storeImageLocally", () => {
let dir: string;
beforeEach(async () => {
dir = await fs.mkdtemp(path.join(os.tmpdir(), "termix-images-test-"));
});
afterEach(async () => {
await fs.rm(dir, { recursive: true, force: true });
});
async function seedFile(bytes: number, mtimeMs?: number): Promise<string> {
const name = `${randomUUID()}.png`;
const filePath = path.join(dir, name);
await fs.writeFile(filePath, Buffer.alloc(bytes));
if (mtimeMs !== undefined) {
const date = new Date(mtimeMs);
await fs.utimes(filePath, date, date);
}
return name;
}
it("writes a UUID-named PNG and returns the agent-visible host path", async () => {
const stored = await storeImageLocally(
PNG_BYTES,
settings({ localDir: dir, hostPath: "/host-view/images" }),
);
expect(stored.storage).toBe("local");
expect(stored.filename).toBe(`${stored.id}.png`);
expect(stored.shellPath).toBe(
path.posix.join("/host-view/images", stored.filename),
);
expect(stored.shellPath).not.toContain(dir);
await expect(fs.readFile(path.join(dir, stored.filename))).resolves.toEqual(
PNG_BYTES,
);
});
it("rejects with IMAGE_STORAGE_LIMIT_REACHED when the count cap is full", async () => {
await seedFile(10);
const error = await storeImageLocally(
PNG_BYTES,
settings({ localDir: dir, maxCount: 1 }),
).catch((caught: unknown) => caught);
expect(error).toBeInstanceOf(TerminalImageStorageError);
expect((error as TerminalImageStorageError).code).toBe(
"IMAGE_STORAGE_LIMIT_REACHED",
);
});
it("rejects with IMAGE_STORAGE_LIMIT_REACHED when the byte cap is full", async () => {
await seedFile(900);
const error = await storeImageLocally(
Buffer.alloc(200),
settings({ localDir: dir, maxBytes: 1_000 }),
).catch((caught: unknown) => caught);
expect((error as TerminalImageStorageError).code).toBe(
"IMAGE_STORAGE_LIMIT_REACHED",
);
});
it("cleans expired files during upload so they no longer count", async () => {
await seedFile(10, Date.now() - 2 * 3_600_000);
const stored = await storeImageLocally(
PNG_BYTES,
settings({ localDir: dir, maxCount: 1, ttlMs: 3_600_000 }),
);
expect(stored.storage).toBe("local");
const remaining = await fs.readdir(dir);
expect(remaining).toEqual([stored.filename]);
});
it("fails closed when local storage inspection is unavailable", async () => {
const blocked = path.join(dir, "blocked-file");
await fs.writeFile(blocked, "not a directory");
const error = await storeImageLocally(
PNG_BYTES,
settings({ localDir: blocked }),
).catch((caught: unknown) => caught);
expect(error).toBeInstanceOf(TerminalImageStorageError);
expect((error as TerminalImageStorageError).code).toBe(
"IMAGE_LOCAL_INSPECTION_FAILED",
);
});
it("reports inspection failures before attempting a write", async () => {
const blocked = path.join(dir, "blocked");
await fs.writeFile(blocked, "not a directory");
const error = await storeImageLocally(
PNG_BYTES,
settings({ localDir: path.join(blocked, "images") }),
).catch((caught: unknown) => caught);
expect(error).toBeInstanceOf(TerminalImageStorageError);
expect((error as TerminalImageStorageError).code).toBe(
"IMAGE_LOCAL_INSPECTION_FAILED",
);
});
});
describe("storeImageViaSftp", () => {
function fakeSftp(behavior: {
mkdirError?: Error;
writeError?: Error;
stallWrite?: boolean;
stallLock?: boolean;
stallReaddir?: boolean;
stallRmdir?: boolean;
stallUnlink?: boolean;
readdirEntries?: Array<{ filename: string; mtime?: number; size?: number }>;
readdirError?: Error;
statMode?: number;
}): {
sftp: ImageSftpClient;
written: Map<string, Buffer>;
calls: {
mkdir: Array<{ dir: string; mode?: number }>;
createWriteStream: Array<{ path: string; mode?: number }>;
};
streams: Array<
NodeJS.WritableStream & { destroy: () => void; destroyed: boolean }
>;
} {
const written = new Map<string, Buffer>();
const streams: Array<
NodeJS.WritableStream & { destroy: () => void; destroyed: boolean }
> = [];
const calls = { mkdir: [], createWriteStream: [] } as {
mkdir: Array<{ dir: string; mode?: number }>;
createWriteStream: Array<{ path: string; mode?: number }>;
};
const sftp = {
mkdir: (
dir: string,
attrsOrCallback: { mode?: number } | ((err?: Error) => void),
maybeCallback?: (err?: Error) => void,
) => {
const callback =
typeof attrsOrCallback === "function"
? attrsOrCallback
: maybeCallback!;
calls.mkdir.push({
dir,
mode:
typeof attrsOrCallback === "function"
? undefined
: attrsOrCallback.mode,
});
if (
behavior.stallLock &&
dir === `${REMOTE_IMAGE_DIR}/.termix-write-lock`
) {
return;
}
callback(dir === REMOTE_IMAGE_DIR ? behavior.mkdirError : undefined);
},
stat: (
_dir: string,
callback: (error: Error | undefined, attrs?: { mode?: number }) => void,
) => callback(undefined, { mode: behavior.statMode ?? 0o40700 }),
chmod: (_dir: string, _mode: number, callback: (error?: Error) => void) =>
callback(),
readdir: (
_dir: string,
callback: (
error: Error | undefined,
entries: Array<{
filename: string;
attrs?: { mtime?: number; size?: number };
}>,
) => void,
) => {
if (behavior.stallReaddir) return;
callback(
behavior.readdirError,
behavior.readdirEntries?.map((entry) => ({
filename: entry.filename,
attrs: entry,
})) ?? [],
);
},
createWriteStream: (remotePath: string, options?: { mode?: number }) => {
calls.createWriteStream.push({ path: remotePath, mode: options?.mode });
const stream = new EventEmitter() as NodeJS.WritableStream & {
end: (data: Buffer) => void;
destroy: () => void;
destroyed: boolean;
};
stream.destroyed = false;
stream.destroy = () => {
stream.destroyed = true;
};
streams.push(stream);
stream.end = (data: Buffer) => {
if (behavior.stallWrite) return;
queueMicrotask(() => {
if (behavior.writeError) {
stream.emit("error", behavior.writeError);
return;
}
written.set(remotePath, data);
stream.emit("close");
});
};
return stream;
},
unlink: (_remotePath: string, callback: (error?: Error) => void) => {
if (behavior.stallUnlink) return;
callback();
},
rmdir: (_dir: string, callback: (error?: Error) => void) => {
if (behavior.stallRmdir) return;
callback();
},
} as unknown as ImageSftpClient;
return { sftp, written, calls, streams };
}
it("writes into the remote image directory and returns its POSIX path", async () => {
const { sftp, written } = fakeSftp({});
const stored = await storeImageViaSftp(sftp, PNG_BYTES);
expect(stored.storage).toBe("remote-sftp");
expect(stored.shellPath).toBe(`${REMOTE_IMAGE_DIR}/${stored.id}.png`);
expect(written.get(stored.shellPath)).toEqual(PNG_BYTES);
});
it("requests restrictive modes for the remote directory and file", async () => {
const { sftp, calls } = fakeSftp({});
const stored = await storeImageViaSftp(sftp, PNG_BYTES);
expect(stored.storage).toBe("remote-sftp");
expect(calls.mkdir).toEqual([
{ dir: REMOTE_IMAGE_DIR, mode: 0o700 },
{ dir: `${REMOTE_IMAGE_DIR}/.termix-write-lock`, mode: 0o700 },
]);
expect(calls.createWriteStream).toEqual([
{ path: stored.shellPath, mode: 0o600 },
]);
});
it("removes expired UUID PNGs with best-effort remote retention", async () => {
const nowMs = 10_000_000;
const expiredName = `${randomUUID()}.png`;
const freshName = `${randomUUID()}.png`;
const unlinked: string[] = [];
const base = fakeSftp({});
const sftp = base.sftp as ImageSftpClient & {
readdir: (
dir: string,
callback: (
err: Error | undefined,
entries: Array<{ filename: string; attrs?: { mtime?: number } }>,
) => void,
) => void;
unlink: (remotePath: string, callback: (err?: Error) => void) => void;
};
sftp.readdir = (_dir, callback) =>
callback(undefined, [
{ filename: expiredName, attrs: { mtime: (nowMs - 7_200_000) / 1000 } },
{ filename: freshName, attrs: { mtime: nowMs / 1000 } },
{ filename: "other.txt", attrs: { mtime: 0 } },
]);
sftp.unlink = (remotePath, callback) => {
unlinked.push(remotePath);
callback();
};
await storeImageViaSftp(sftp, PNG_BYTES, {
ttlMs: 3_600_000,
nowMs,
});
expect(unlinked).toEqual([`${REMOTE_IMAGE_DIR}/${expiredName}`]);
});
it("fails closed when remote quota inspection fails", async () => {
const { sftp } = fakeSftp({
readdirError: new Error("remote listing failed"),
});
const error = await storeImageViaSftp(sftp, PNG_BYTES, {
maxCount: 10,
maxBytes: 1024,
}).catch((caught: unknown) => caught);
expect(error).toBeInstanceOf(TerminalImageStorageError);
expect((error as TerminalImageStorageError).code).toBe(
"IMAGE_REMOTE_QUOTA_UNAVAILABLE",
);
});
it("rejects remote writes at the configured count limit", async () => {
const { sftp } = fakeSftp({
readdirEntries: [{ filename: `${randomUUID()}.png`, size: 12 }],
});
const error = await storeImageViaSftp(sftp, PNG_BYTES, {
maxCount: 1,
maxBytes: 1024,
}).catch((caught: unknown) => caught);
expect(error).toBeInstanceOf(TerminalImageStorageError);
expect((error as TerminalImageStorageError).code).toBe(
"IMAGE_STORAGE_LIMIT_REACHED",
);
});
it("destroys a stalled SFTP write after its timeout", async () => {
const { sftp, streams } = fakeSftp({ stallWrite: true });
const error = await storeImageViaSftp(sftp, PNG_BYTES, {
writeTimeoutMs: 25,
}).catch((caught: unknown) => caught);
expect(error).toBeInstanceOf(TerminalImageStorageError);
expect((error as TerminalImageStorageError).code).toBe(
"IMAGE_REMOTE_WRITE_FAILED",
);
expect(streams[0]!.destroyed).toBe(true);
});
it("rejects an existing remote path that is not a directory", async () => {
const { sftp } = fakeSftp({
mkdirError: new Error("Failure: file already exists"),
statMode: 0o100644,
});
const error = await storeImageViaSftp(sftp, PNG_BYTES).catch(
(caught: unknown) => caught,
);
expect(error).toBeInstanceOf(TerminalImageStorageError);
expect((error as TerminalImageStorageError).code).toBe(
"IMAGE_REMOTE_WRITE_FAILED",
);
});
it("preserves SFTP client context for directory inspection", async () => {
const base = fakeSftp({ mkdirError: new Error("already exists") });
const sftp = base.sftp;
sftp.lstat = function (
this: ImageSftpClient,
_dir: string,
callback: (error: Error | undefined, attrs?: { mode?: number }) => void,
) {
if (this !== sftp) {
callback(new Error("SFTP context lost"));
return;
}
callback(undefined, { mode: 0o40700 });
};
const result = await storeImageViaSftp(sftp, PNG_BYTES);
expect(result.storage).toBe("remote-sftp");
});
it("tolerates mkdir failures for an already-existing directory", async () => {
const { sftp } = fakeSftp({
mkdirError: new Error("Failure: file already exists"),
});
await expect(storeImageViaSftp(sftp, PNG_BYTES)).resolves.toMatchObject({
storage: "remote-sftp",
});
});
it("cleans up a partially created remote file after a write failure", async () => {
const { sftp } = fakeSftp({ writeError: new Error("write failed") });
const unlinked: string[] = [];
(sftp as ImageSftpClient).unlink = (remotePath, callback) => {
unlinked.push(remotePath);
callback();
};
const error = await storeImageViaSftp(sftp, PNG_BYTES).catch(
(caught: unknown) => caught,
);
expect(error).toBeInstanceOf(TerminalImageStorageError);
expect(unlinked).toHaveLength(1);
expect(unlinked[0]).toMatch(
new RegExp(`${REMOTE_IMAGE_DIR}/[0-9a-f-]+\\.png`),
);
});
it("preserves the write error when partial-file cleanup stalls", async () => {
const { sftp } = fakeSftp({
writeError: new Error("write failed"),
stallUnlink: true,
});
const error = await storeImageViaSftp(sftp, PNG_BYTES).catch(
(caught: unknown) => caught,
);
expect(error).toBeInstanceOf(TerminalImageStorageError);
expect((error as TerminalImageStorageError).code).toBe(
"IMAGE_REMOTE_WRITE_FAILED",
);
}, 5_000);
it("recovers a stale remote lock before writing", async () => {
const base = fakeSftp({});
const sftp = base.sftp as ImageSftpClient;
const lockPath = `${REMOTE_IMAGE_DIR}/.termix-write-lock`;
let lockAttempt = 0;
const removed: string[] = [];
const originalMkdir = sftp.mkdir.bind(sftp);
sftp.mkdir = (dir, attrs, callback) => {
if (dir === lockPath && lockAttempt++ === 0) {
(typeof attrs === "function" ? attrs : callback!)(new Error("exists"));
return;
}
originalMkdir(dir, attrs, callback);
};
sftp.lstat = (_dir, callback) =>
callback(undefined, {
mode: 0o40700,
mtime: (Date.now() - 60_000) / 1000,
});
sftp.rmdir = (dir, callback) => {
removed.push(dir);
callback();
};
await expect(storeImageViaSftp(sftp, PNG_BYTES)).resolves.toMatchObject({
storage: "remote-sftp",
});
expect(removed).toContain(lockPath);
});
it("does not replace a successful write with an unlock failure", async () => {
const base = fakeSftp({});
const sftp = base.sftp as ImageSftpClient;
const lockPath = `${REMOTE_IMAGE_DIR}/.termix-write-lock`;
const originalMkdir = sftp.mkdir.bind(sftp);
sftp.mkdir = (dir, attrs, callback) => originalMkdir(dir, attrs, callback);
sftp.rmdir = (dir, callback) => {
if (dir === lockPath) callback(new Error("unlock failed"));
else callback();
};
const error = await storeImageViaSftp(sftp, PNG_BYTES).catch(
(caught: unknown) => caught,
);
expect(error).toBeInstanceOf(TerminalImageStorageError);
expect((error as TerminalImageStorageError).code).toBe(
"IMAGE_REMOTE_WRITE_FAILED",
);
});
it("fails closed when remote lock acquisition stalls", async () => {
const { sftp } = fakeSftp({ stallLock: true });
const error = await storeImageViaSftp(sftp, PNG_BYTES).catch(
(caught: unknown) => caught,
);
expect(error).toBeInstanceOf(TerminalImageStorageError);
expect((error as TerminalImageStorageError).code).toBe(
"IMAGE_REMOTE_WRITE_FAILED",
);
}, 12_000);
it("fails closed when remote quota inspection stalls", async () => {
const { sftp } = fakeSftp({ stallReaddir: true });
const error = await storeImageViaSftp(sftp, PNG_BYTES, {
maxCount: 10,
maxBytes: 1024,
}).catch((caught: unknown) => caught);
expect(error).toBeInstanceOf(TerminalImageStorageError);
expect((error as TerminalImageStorageError).code).toBe(
"IMAGE_REMOTE_WRITE_FAILED",
);
}, 7_000);
it("fails closed when remote expiry cleanup stalls", async () => {
const { sftp } = fakeSftp({ stallReaddir: true });
const error = await storeImageViaSftp(sftp, PNG_BYTES, {
ttlMs: 1_000,
}).catch((caught: unknown) => caught);
expect(error).toBeInstanceOf(TerminalImageStorageError);
expect((error as TerminalImageStorageError).code).toBe(
"IMAGE_REMOTE_WRITE_FAILED",
);
}, 7_000);
it("fails closed when remote lock release stalls", async () => {
const { sftp } = fakeSftp({ stallRmdir: true });
const error = await storeImageViaSftp(sftp, PNG_BYTES).catch(
(caught: unknown) => caught,
);
expect(error).toBeInstanceOf(TerminalImageStorageError);
expect((error as TerminalImageStorageError).code).toBe(
"IMAGE_REMOTE_WRITE_FAILED",
);
}, 5_000);
it("maps SFTP failures to IMAGE_REMOTE_WRITE_FAILED", async () => {
const { sftp } = fakeSftp({ writeError: new Error("Permission denied") });
const error = await storeImageViaSftp(sftp, PNG_BYTES).catch(
(caught: unknown) => caught,
);
expect(error).toBeInstanceOf(TerminalImageStorageError);
expect((error as TerminalImageStorageError).code).toBe(
"IMAGE_REMOTE_WRITE_FAILED",
);
expect((error as TerminalImageStorageError).message).toBe(
"Failed to write image to the remote host",
);
});
});
@@ -1,541 +0,0 @@
import {
afterEach,
beforeAll,
beforeEach,
describe,
expect,
it,
vi,
} from "vitest";
import { EventEmitter } from "events";
import fs from "fs/promises";
import os from "os";
import path from "path";
import { randomUUID } from "crypto";
import sharp from "sharp";
import type { Request, RequestHandler, Response } from "express";
import type { ImageSftpClient } from "../../../database/routes/terminal-image-storage.js";
import { databaseLogger } from "../../../utils/logger.js";
const state = vi.hoisted(() => ({
userId: "user-1",
settings: {} as Record<string, string>,
sessions: [] as unknown[],
}));
vi.mock("../../../utils/logger.js", () => ({
authLogger: { warn: vi.fn(), error: vi.fn(), info: vi.fn() },
databaseLogger: { warn: vi.fn(), error: vi.fn(), info: vi.fn() },
}));
vi.mock("../../../utils/auth-manager.js", () => ({
AuthManager: {
getInstance: () => ({
createAuthMiddleware:
() => (_req: unknown, _res: unknown, next: () => void) =>
next(),
createDataAccessMiddleware:
() => (_req: unknown, _res: unknown, next: () => void) =>
next(),
}),
},
}));
vi.mock("../../../hosts/terminal/session-manager.js", () => ({
sessionManager: {
getUserSessions: () => state.sessions,
},
}));
vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentSettingsRepository: () => ({
get: async (key: string) => state.settings[key] ?? null,
}),
createCurrentHostResolutionRepository: () => ({}),
createCurrentCommandHistoryRepository: () => ({}),
}));
const { default: router } =
await import("../../../database/routes/terminal.js");
interface RouteLayer {
route?: {
path: string;
methods: Record<string, boolean>;
stack: Array<{ handle: RequestHandler }>;
};
}
const imageUploadLayer = (
router as unknown as { stack: RouteLayer[] }
).stack.find(
(layer) => layer.route?.path === "/image-upload" && layer.route.methods.post,
);
const imageUploadHandler =
imageUploadLayer!.route!.stack[imageUploadLayer!.route!.stack.length - 1]!
.handle;
function fakeSftp(behavior: { writeError?: Error } = {}): {
sftp: ImageSftpClient;
written: Map<string, Buffer>;
end: ReturnType<typeof vi.fn>;
} {
const written = new Map<string, Buffer>();
const end = vi.fn();
const sftp = {
mkdir: (
_dir: string,
attrsOrCallback: { mode?: number } | ((err?: Error) => void),
maybeCallback?: (err?: Error) => void,
) => {
const callback =
typeof attrsOrCallback === "function"
? attrsOrCallback
: maybeCallback!;
callback();
},
createWriteStream: (remotePath: string, _options?: { mode?: number }) => {
const stream = new EventEmitter() as NodeJS.WritableStream & {
end: (data: Buffer) => void;
};
stream.end = (data: Buffer) => {
queueMicrotask(() => {
if (behavior.writeError) {
stream.emit("error", behavior.writeError);
return;
}
written.set(remotePath, data);
stream.emit("close");
});
};
return stream;
},
readdir: (
_dir: string,
callback: (
error: Error | undefined,
entries: Array<{
filename: string;
attrs?: { size?: number; mtime?: number };
}>,
) => void,
) => callback(undefined, []),
unlink: (_path: string, callback: (error?: Error) => void) => callback(),
rmdir: (_dir: string, callback: (error?: Error) => void) => callback(),
end,
} as unknown as ImageSftpClient & { end: ReturnType<typeof vi.fn> };
return { sftp, written, end };
}
function connectedSession(instanceId: string, sftp: ImageSftpClient) {
return {
tabInstanceId: instanceId,
isConnected: true,
sshConn: {
sftp: (
callback: (err: Error | undefined, sftp: ImageSftpClient) => void,
) => callback(undefined, sftp),
},
};
}
async function invoke(options: {
file?: { buffer: Buffer; mimetype: string; size: number };
instanceId?: string;
metadata?: { source?: string; clientUploadTimestamp?: string };
}) {
const body: Record<string, string> = {};
if (options.instanceId) body.instanceId = options.instanceId;
if (options.metadata?.source !== undefined)
body.source = options.metadata.source;
if (options.metadata?.clientUploadTimestamp !== undefined)
body.clientUploadTimestamp = options.metadata.clientUploadTimestamp;
const req = {
userId: state.userId,
body,
file: options.file,
headers: {},
} as unknown as Request;
const result = { statusCode: 200, body: null as unknown };
const res = {
status(code: number) {
result.statusCode = code;
return this;
},
json(body: unknown) {
result.body = body;
return this;
},
} as unknown as Response;
await imageUploadHandler(req, res, () => {});
return result;
}
const LEGACY_ENV_NAMES = [
"TERMIX_IMAGE_STORAGE_MODE",
"TERMIX_IMAGE_DIR",
"TERMIX_IMAGE_HOST_PATH",
"TERMIX_IMAGE_TTL_MS",
"TERMIX_MAX_IMAGE_COUNT",
"TERMIX_MAX_IMAGE_STORAGE_BYTES",
"DATA_DIR",
];
let pngBuffer: Buffer;
let savedEnv: Record<string, string | undefined>;
beforeAll(async () => {
pngBuffer = await sharp({
create: { width: 2, height: 2, channels: 3, background: "#ffffff" },
})
.png()
.toBuffer();
});
beforeEach(() => {
state.settings = {};
state.sessions = [];
savedEnv = Object.fromEntries(
LEGACY_ENV_NAMES.map((name) => [name, process.env[name]]),
);
for (const name of LEGACY_ENV_NAMES) delete process.env[name];
});
afterEach(() => {
for (const name of LEGACY_ENV_NAMES) {
if (savedEnv[name] === undefined) delete process.env[name];
else process.env[name] = savedEnv[name];
}
});
describe("terminal image upload route", () => {
it("rejects requests without an image file", async () => {
const response = await invoke({ instanceId: "tab-1" });
expect(response.statusCode).toBe(400);
expect(response.body).toMatchObject({ code: "IMAGE_FILE_MISSING" });
});
it("requires a connected terminal in explicit remote-sftp mode", async () => {
state.settings["terminal_image_storage_mode"] = "remote-sftp";
const response = await invoke({
file: {
buffer: pngBuffer,
mimetype: "image/png",
size: pngBuffer.length,
},
instanceId: "tab-1",
});
expect(response.statusCode).toBe(409);
expect(response.body).toMatchObject({
code: "IMAGE_TERMINAL_NOT_CONNECTED",
});
});
it("writes over SFTP in auto mode when a session is connected", async () => {
const { sftp, written, end } = fakeSftp();
state.sessions = [connectedSession("tab-1", sftp)];
const response = await invoke({
file: {
buffer: pngBuffer,
mimetype: "image/png",
size: pngBuffer.length,
},
instanceId: "tab-1",
});
expect(response.statusCode).toBe(200);
const body = response.body as {
id: string;
filename: string;
shellPath: string;
storage: string;
};
expect(body.storage).toBe("remote-sftp");
expect(body.filename).toBe(`${body.id}.png`);
expect(body.shellPath).toBe(`/tmp/termix-images/${body.filename}`);
// Sharp normalized the upload to PNG before the write.
const writtenBytes = written.get(body.shellPath)!;
expect(writtenBytes.subarray(1, 4).toString()).toBe("PNG");
expect(end).toHaveBeenCalledTimes(1);
});
it("maps SFTP failures to 502 without leaking the raw remote error", async () => {
const { sftp, end } = fakeSftp({
writeError: new Error("Permission denied"),
});
state.sessions = [connectedSession("tab-1", sftp)];
const response = await invoke({
file: {
buffer: pngBuffer,
mimetype: "image/png",
size: pngBuffer.length,
},
instanceId: "tab-1",
});
expect(response.statusCode).toBe(502);
expect(response.body).toEqual({
error: "Failed to write image to the remote host",
code: "IMAGE_REMOTE_WRITE_FAILED",
});
expect(JSON.stringify(response.body)).not.toContain("Permission denied");
expect(end).toHaveBeenCalledTimes(1);
});
it("rejects undecodable image data", async () => {
const { sftp } = fakeSftp();
state.sessions = [connectedSession("tab-1", sftp)];
const response = await invoke({
file: {
buffer: Buffer.from("definitely not an image"),
mimetype: "image/png",
size: 23,
},
instanceId: "tab-1",
});
expect(response.statusCode).toBe(400);
expect(response.body).toMatchObject({ code: "IMAGE_DECODE_FAILED" });
});
describe("local mapped storage", () => {
let dir: string;
beforeEach(async () => {
// The settings validator rejects backslashes, so store the temp dir in
// POSIX form. Windows still resolves it for the real file checks.
dir = (
await fs.mkdtemp(path.join(os.tmpdir(), "termix-route-test-"))
).replace(/\\/g, "/");
});
afterEach(async () => {
await fs.rm(dir, { recursive: true, force: true });
});
it("stores locally without a terminal session and hides backend paths", async () => {
state.settings["terminal_image_storage_mode"] = "local";
state.settings["terminal_image_local_dir"] = dir;
state.settings["terminal_image_host_path"] = "/host-view/images";
const response = await invoke({
file: {
buffer: pngBuffer,
mimetype: "image/png",
size: pngBuffer.length,
},
});
expect(response.statusCode).toBe(200);
const body = response.body as {
id: string;
filename: string;
shellPath: string;
storage: string;
};
expect(body.storage).toBe("local");
expect(body.shellPath).toBe(`/host-view/images/${body.filename}`);
expect(JSON.stringify(body)).not.toContain(dir);
const stored = await fs.readFile(path.join(dir, body.filename));
expect(stored.subarray(1, 4).toString()).toBe("PNG");
});
it("answers 507 when the configured image count cap is reached", async () => {
state.settings["terminal_image_storage_mode"] = "local";
state.settings["terminal_image_local_dir"] = dir;
state.settings["terminal_image_host_path"] = "/host-view/images";
state.settings["terminal_image_max_count"] = "1";
await fs.writeFile(
path.join(dir, `${randomUUID()}.png`),
Buffer.alloc(16),
);
const response = await invoke({
file: {
buffer: pngBuffer,
mimetype: "image/png",
size: pngBuffer.length,
},
});
expect(response.statusCode).toBe(507);
expect(response.body).toMatchObject({
error: "Image storage limit reached",
code: "IMAGE_STORAGE_LIMIT_REACHED",
});
});
it("keeps legacy explicit local mappings on local mode", async () => {
process.env.TERMIX_IMAGE_DIR = dir;
process.env.TERMIX_IMAGE_HOST_PATH = "/host-view/images";
const response = await invoke({
file: {
buffer: pngBuffer,
mimetype: "image/png",
size: pngBuffer.length,
},
});
expect(response.statusCode).toBe(200);
expect(response.body).toMatchObject({ storage: "local" });
});
it("returns 503 when local storage inspection fails", async () => {
const blocked = `${dir}/blocked-file`;
await fs.writeFile(blocked, "not a directory");
state.settings["terminal_image_storage_mode"] = "local";
state.settings["terminal_image_local_dir"] = blocked;
state.settings["terminal_image_host_path"] = "/host-view/images";
const response = await invoke({
file: {
buffer: pngBuffer,
mimetype: "image/png",
size: pngBuffer.length,
},
});
expect(response.statusCode).toBe(503);
expect(response.body).toEqual({
error: "Unable to inspect local image storage",
code: "IMAGE_LOCAL_INSPECTION_FAILED",
});
expect(JSON.stringify(response.body)).not.toContain(blocked);
});
it("rejects auto mode when no verified storage capability is available", async () => {
process.env.DATA_DIR = dir;
const response = await invoke({
file: {
buffer: pngBuffer,
mimetype: "image/png",
size: pngBuffer.length,
},
instanceId: "tab-missing",
});
expect(response.statusCode).toBe(503);
expect(response.body).toMatchObject({
code: "IMAGE_STORAGE_UNAVAILABLE",
});
});
});
describe("diagnostic metadata", () => {
interface UploadLogMeta {
operation?: string;
requestId?: string;
sequence?: number;
source?: string;
clientUploadTimestamp?: string;
serverReceivedAt?: string;
bytes?: number;
}
function uploadLogEntries(): UploadLogMeta[] {
return vi
.mocked(databaseLogger.info)
.mock.calls.filter(
(call) =>
(call[1] as UploadLogMeta | undefined)?.operation ===
"terminal_image_upload_received",
)
.map((call) => call[1] as UploadLogMeta);
}
beforeEach(() => {
vi.mocked(databaseLogger.info).mockClear();
});
it("logs correlation id, receipt time, and propagated source metadata", async () => {
const { sftp } = fakeSftp();
state.sessions = [connectedSession("tab-1", sftp)];
const response = await invoke({
file: {
buffer: pngBuffer,
mimetype: "image/png",
size: pngBuffer.length,
},
instanceId: "tab-1",
metadata: {
source: "clipboard",
clientUploadTimestamp: "2026-08-15T12:00:00.000Z",
},
});
expect(response.statusCode).toBe(200);
const entries = uploadLogEntries();
expect(entries).toHaveLength(1);
const meta = entries[0]!;
expect(meta.requestId).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,
);
expect(typeof meta.sequence).toBe("number");
expect(meta.source).toBe("clipboard");
expect(meta.clientUploadTimestamp).toBe("2026-08-15T12:00:00.000Z");
expect(Number.isNaN(Date.parse(meta.serverReceivedAt ?? ""))).toBe(false);
expect(meta.bytes).toBe(pngBuffer.length);
});
it("logs a monotonically increasing upload sequence", async () => {
const { sftp } = fakeSftp();
state.sessions = [connectedSession("tab-1", sftp)];
await invoke({
file: {
buffer: pngBuffer,
mimetype: "image/png",
size: pngBuffer.length,
},
instanceId: "tab-1",
metadata: { source: "file" },
});
await invoke({
file: {
buffer: pngBuffer,
mimetype: "image/png",
size: pngBuffer.length,
},
instanceId: "tab-1",
metadata: { source: "clipboard" },
});
const sequences = uploadLogEntries().map((entry) => entry.sequence!);
expect(sequences).toHaveLength(2);
expect(sequences[1]).toBe(sequences[0]! + 1);
});
it("accepts missing metadata and keeps raw paths out of the log", async () => {
const dir = (
await fs.mkdtemp(path.join(os.tmpdir(), "termix-meta-test-"))
).replace(/\\/g, "/");
try {
state.settings["terminal_image_storage_mode"] = "local";
state.settings["terminal_image_local_dir"] = dir;
state.settings["terminal_image_host_path"] = "/host-view/images";
const response = await invoke({
file: {
buffer: pngBuffer,
mimetype: "image/png",
size: pngBuffer.length,
},
});
expect(response.statusCode).toBe(200);
const entries = uploadLogEntries();
expect(entries).toHaveLength(1);
expect(entries[0]!.source).toBeUndefined();
expect(entries[0]!.clientUploadTimestamp).toBeUndefined();
expect(JSON.stringify(entries[0])).not.toContain(dir);
} finally {
await fs.rm(dir, { recursive: true, force: true });
}
});
});
});
@@ -1,129 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { Request, RequestHandler, Response } from "express";
import { databaseLogger } from "../../../utils/logger.js";
const state = vi.hoisted(() => ({
userId: "user-1",
settings: {} as Record<string, string>,
sessions: [] as unknown[],
}));
// A package shipped without the native binary for this architecture.
vi.mock("sharp", () => {
throw new Error(
'Could not load the "sharp" module using the darwin-x64 runtime',
);
});
vi.mock("../../../utils/logger.js", () => ({
authLogger: { warn: vi.fn(), error: vi.fn(), info: vi.fn() },
databaseLogger: { warn: vi.fn(), error: vi.fn(), info: vi.fn() },
}));
vi.mock("../../../utils/auth-manager.js", () => ({
AuthManager: {
getInstance: () => ({
createAuthMiddleware:
() => (_req: unknown, _res: unknown, next: () => void) =>
next(),
createDataAccessMiddleware:
() => (_req: unknown, _res: unknown, next: () => void) =>
next(),
}),
},
}));
vi.mock("../../../hosts/terminal/session-manager.js", () => ({
sessionManager: {
getUserSessions: () => state.sessions,
},
}));
vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentSettingsRepository: () => ({
get: async (key: string) => state.settings[key] ?? null,
}),
createCurrentHostResolutionRepository: () => ({}),
createCurrentCommandHistoryRepository: () => ({}),
}));
const { default: router } =
await import("../../../database/routes/terminal.js");
interface RouteLayer {
route?: {
path: string;
methods: Record<string, boolean>;
stack: Array<{ handle: RequestHandler }>;
};
}
const imageUploadLayer = (
router as unknown as { stack: RouteLayer[] }
).stack.find(
(layer) => layer.route?.path === "/image-upload" && layer.route.methods.post,
);
const imageUploadHandler =
imageUploadLayer!.route!.stack[imageUploadLayer!.route!.stack.length - 1]!
.handle;
async function invoke() {
const buffer = Buffer.from([0x89, 0x50, 0x4e, 0x47]);
const req = {
userId: state.userId,
body: { instanceId: "tab-1" },
file: { buffer, mimetype: "image/png", size: buffer.length },
headers: {},
} as unknown as Request;
const result = { statusCode: 200, body: null as unknown };
const res = {
status(code: number) {
result.statusCode = code;
return this;
},
json(body: unknown) {
result.body = body;
return this;
},
} as unknown as Response;
await imageUploadHandler(req, res, () => {});
return result;
}
beforeEach(() => {
vi.mocked(databaseLogger.error).mockClear();
state.settings = { terminal_image_storage_mode: "remote-sftp" };
state.sessions = [{ tabInstanceId: "tab-1", isConnected: true, sshConn: {} }];
});
describe("terminal image upload route when sharp cannot be loaded", () => {
it("imports the router instead of failing at module scope", () => {
expect(imageUploadHandler).toBeTypeOf("function");
});
it("answers 503 and logs the reason rather than throwing", async () => {
const response = await invoke();
expect(response.statusCode).toBe(503);
expect(response.body).toMatchObject({
code: "IMAGE_PROCESSING_UNAVAILABLE",
});
expect(databaseLogger.error).toHaveBeenCalledWith(
"Image processing unavailable: sharp failed to load",
expect.anything(),
expect.objectContaining({
operation: "terminal_image_upload_sharp_unavailable",
}),
);
});
it("keeps answering 503 on later uploads without retrying the import", async () => {
await invoke();
const response = await invoke();
expect(response.statusCode).toBe(503);
expect(response.body).toMatchObject({
code: "IMAGE_PROCESSING_UNAVAILABLE",
});
});
});
@@ -1,76 +0,0 @@
import { describe, expect, it } from "vitest";
import {
exceedsImageStorageLimit,
exceedsNormalizedImageSize,
imageExtensionForFormat,
createConcurrencyLimiter,
isExpiredImage,
isImageFilename,
} from "../../../database/routes/terminal-image-utils.js";
describe("terminal image utilities", () => {
it("accepts UUID-based image filenames", () => {
expect(isImageFilename("b797234d-eb5d-4b1b-b7f3-17c26f257506.jpeg")).toBe(
true,
);
});
it("rejects unsafe or unrelated filenames", () => {
expect(isImageFilename("../secrets.txt")).toBe(false);
expect(isImageFilename("not-an-image.jpeg")).toBe(false);
expect(isImageFilename("b797234d-eb5d-4b1b-b7f3-17c26f257506")).toBe(false);
});
it("maps decoded raster formats while excluding SVG", () => {
expect(imageExtensionForFormat("jpeg")).toBe("jpg");
expect(imageExtensionForFormat("heif")).toBe("heif");
expect(imageExtensionForFormat("tiff")).toBe("tiff");
expect(imageExtensionForFormat("svg")).toBeUndefined();
expect(imageExtensionForFormat(undefined)).toBeUndefined();
});
it("rejects normalized output beyond the byte ceiling", () => {
expect(exceedsNormalizedImageSize(10_000_001, 10_000_000)).toBe(true);
expect(exceedsNormalizedImageSize(10_000_000, 10_000_000)).toBe(false);
});
it("expires files older than the configured TTL", () => {
expect(isExpiredImage(1_000, 3_000, 1_000)).toBe(true);
expect(isExpiredImage(2_500, 3_000, 1_000)).toBe(false);
});
it("treats a zero TTL as retention disabled", () => {
expect(isExpiredImage(1_000, 999_999, 0)).toBe(false);
});
it("bounds the admission queue", async () => {
const limiter = createConcurrencyLimiter(1, 0);
const release = await limiter.acquire();
await expect(limiter.acquire()).rejects.toThrow("queue is full");
release();
});
it("rejects uploads that exceed the count or byte limit", () => {
expect(exceedsImageStorageLimit(100, 10, 1, 100, 1000)).toBe(true);
expect(exceedsImageStorageLimit(1, 900, 101, 100, 1000)).toBe(true);
expect(exceedsImageStorageLimit(1, 900, 100, 100, 1000)).toBe(false);
});
it("bounds concurrent work", async () => {
const limiter = createConcurrencyLimiter(1);
let active = 0;
let peak = 0;
const task = async () => {
const release = await limiter.acquire();
active += 1;
peak = Math.max(peak, active);
await new Promise((resolve) => setTimeout(resolve, 5));
active -= 1;
release();
};
await Promise.all([task(), task(), task()]);
expect(peak).toBe(1);
expect(limiter.active).toBe(0);
});
});
@@ -1,92 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { Request, RequestHandler, Response } from "express";
import { TOUCH_INPUT_DEFAULTS } from "../../../../types/touch-input-settings.js";
const state = vi.hoisted(() => ({
userId: "admin",
admins: new Set(["admin"]),
stored: null as string | null,
}));
vi.mock("../../../utils/logger.js", () => ({
authLogger: { error: vi.fn() },
}));
vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentUserRepository: () => ({
findById: async (id: string) => ({ id, isAdmin: state.admins.has(id) }),
}),
createCurrentSettingsRepository: () => ({
get: async () => state.stored,
set: async (_key: string, value: string) => {
state.stored = value;
},
}),
}));
const { registerTouchInputSettingsRoutes } =
await import("../../../database/routes/touch-input-settings-routes.js");
type Registered = { method: string; path: string; handler: RequestHandler };
const registered: Registered[] = [];
const router = {
get: (path: string, ...handlers: RequestHandler[]) =>
registered.push({ method: "get", path, handler: handlers.at(-1)! }),
patch: (path: string, ...handlers: RequestHandler[]) =>
registered.push({ method: "patch", path, handler: handlers.at(-1)! }),
} as unknown as import("express").Router;
registerTouchInputSettingsRoutes(router, (_req, _res, next) => next());
async function invoke(method: string, body: unknown = {}) {
const handler = registered.find((entry) => entry.method === method)!.handler;
const req = { userId: state.userId, body } as unknown as Request;
const result = { statusCode: 200, body: null as unknown };
const res = {
status(code: number) {
result.statusCode = code;
return this;
},
json(bodyValue: unknown) {
result.body = bodyValue;
return this;
},
} as Response;
await handler(req, res, () => {});
return result;
}
beforeEach(() => {
state.userId = "admin";
state.stored = null;
});
describe("touch input settings routes", () => {
it("allows authenticated non-admin users to read normalized defaults", async () => {
state.userId = "user";
const response = await invoke("get");
expect(response.statusCode).toBe(200);
expect(response.body).toEqual(TOUCH_INPUT_DEFAULTS);
});
it("only allows admins to write", async () => {
state.userId = "user";
const response = await invoke("patch", { enabled: false });
expect(response.statusCode).toBe(403);
expect(state.stored).toBeNull();
});
it("rejects out-of-range values and persists normalized updates", async () => {
const invalid = await invoke("patch", { maximumTicksPerFrame: 101 });
expect(invalid.statusCode).toBe(400);
const valid = await invoke("patch", {
dragThresholdPx: 9,
momentumEnabled: false,
});
expect(valid.statusCode).toBe(200);
expect(JSON.parse(state.stored!)).toEqual({
...TOUCH_INPUT_DEFAULTS,
dragThresholdPx: 9,
momentumEnabled: false,
});
});
});
@@ -101,8 +101,8 @@ vi.mock("../../../hosts/collab/runtime-store.js", () => ({
},
},
}));
vi.mock("../../../hosts/terminal/session-manager.js", () => ({
sessionManager: {
vi.mock("../../../hosts/live-terminal-sessions.js", () => ({
liveTerminalSessions: {
setRoomShareControl: (...args: unknown[]) => {
state.control.push(args);
},
@@ -1,244 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentHostResolutionRepository: () => ({
findCredentialByIdForUser: async () => null,
}),
}));
vi.mock("../../../utils/logger.js", () => ({
sshLogger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
authLogger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
}));
import { SSHAuthManager } from "../../../hosts/connect/terminal-prompt.js";
function createManager() {
const sent: Record<string, unknown>[] = [];
const ws = { send: (data: string) => sent.push(JSON.parse(data)) } as any;
const manager = new SSHAuthManager({
userId: "user-1",
ws,
hostId: 1,
isKeyboardInteractive: false,
keyboardInteractiveResponded: false,
keyboardInteractiveFinish: null,
totpPromptSent: false,
warpgateAuthPromptSent: false,
totpTimeout: null,
warpgateAuthTimeout: null,
totpAttempts: 0,
});
return { manager, sent };
}
describe("SSHAuthManager.handleKeyboardInteractive", () => {
beforeEach(() => {
vi.useRealTimers();
});
it("routes a TOTP verification prompt to the totp flow", () => {
const { manager, sent } = createManager();
const finish = vi.fn();
manager.handleKeyboardInteractive(
"",
"",
"",
[{ prompt: "Verification code: ", echo: true }],
finish,
{ username: "root", authType: "none" },
);
expect(sent).toEqual([
{
type: "connection_log",
data: {
stage: "auth",
level: "info",
message: "TOTP verification required",
},
},
{ type: "totp_required", prompt: "Verification code: " },
]);
expect(finish).not.toHaveBeenCalled();
});
it("forwards echo:true for a JumpCloud-style push/TOTP menu prompt", () => {
const { manager, sent } = createManager();
const finish = vi.fn();
manager.handleKeyboardInteractive(
"",
"",
"",
[{ prompt: "Choose [1] Push, or [2] TOTP: ", echo: true }],
finish,
{ username: "root", authType: "none" },
);
expect(sent).toEqual([
{
type: "connection_log",
data: {
stage: "auth",
level: "info",
message: "Password authentication required",
},
},
{
type: "password_required",
prompt: "Choose [1] Push, or [2] TOTP: ",
echo: true,
},
]);
});
it("silently auto-answers a plain password prompt when a stored password exists", () => {
const { manager, sent } = createManager();
const finish = vi.fn();
manager.handleKeyboardInteractive(
"",
"",
"",
[{ prompt: "Password: ", echo: false }],
finish,
{ username: "root", password: "hunter2", authType: "password" },
);
expect(finish).toHaveBeenCalledWith(["hunter2"]);
expect(sent).toEqual([]);
});
it("prompts the user for a push-confirm prompt and accepts an empty response", () => {
const { manager, sent } = createManager();
const finish = vi.fn();
manager.handleKeyboardInteractive(
"",
"",
"",
[{ prompt: "Press enter to send Push request: ", echo: true }],
finish,
{ username: "root", authType: "none" },
);
expect(sent).toEqual([
{
type: "connection_log",
data: {
stage: "auth",
level: "info",
message: "Password authentication required",
},
},
{
type: "password_required",
prompt: "Press enter to send Push request: ",
echo: true,
},
]);
manager.context.keyboardInteractiveFinish?.([""]);
expect(finish).toHaveBeenCalledWith([""]);
});
it("uses a longer timeout for push-style prompts than generic prompts", () => {
vi.useFakeTimers();
try {
const { manager, sent } = createManager();
const finish = vi.fn();
manager.handleKeyboardInteractive(
"",
"",
"",
[{ prompt: "Press enter to send Push request: ", echo: true }],
finish,
{ username: "root", authType: "none" },
);
vi.advanceTimersByTime(180001);
expect(sent.some((m) => m.type === "error")).toBe(false);
vi.advanceTimersByTime(120000);
expect(sent.some((m) => m.type === "error")).toBe(true);
} finally {
vi.useRealTimers();
}
});
it("routes a FortiToken prompt to the totp flow instead of the push-confirm flow", () => {
const { manager, sent } = createManager();
const finish = vi.fn();
manager.handleKeyboardInteractive(
"",
"",
"",
[
{
prompt:
"Enter your Token or type 'push' to receive a push notification: ",
echo: true,
},
],
finish,
{ username: "root", authType: "none" },
);
expect(sent).toEqual([
{
type: "connection_log",
data: {
stage: "auth",
level: "info",
message: "TOTP verification required",
},
},
{
type: "totp_required",
prompt:
"Enter your Token or type 'push' to receive a push notification: ",
},
]);
manager.context.keyboardInteractiveFinish?.(["push"]);
expect(finish).toHaveBeenCalledWith(["push"]);
});
it("routes Warpgate prompts to the warpgate flow, not the generic path", () => {
const { manager, sent } = createManager();
const finish = vi.fn();
manager.handleKeyboardInteractive(
"Warpgate Authentication",
"Visit https://warpgate.example.com/auth to continue. Security key: AB12",
"",
[{ prompt: "Press enter once done: ", echo: true }],
finish,
{ username: "root", authType: "none" },
);
expect(sent).toEqual([
{
type: "connection_log",
data: {
stage: "auth",
level: "info",
message: "Warpgate authentication required",
},
},
{
type: "warpgate_auth_required",
url: "https://warpgate.example.com/auth",
securityKey: "AB12",
instructions:
"Visit https://warpgate.example.com/auth to continue. Security key: AB12",
},
]);
});
});
@@ -52,8 +52,8 @@ vi.mock("../../../utils/permission-manager.js", () => ({
},
}));
vi.mock("../../../hosts/terminal/session-manager.js", () => ({
sessionManager: {
vi.mock("../../../hosts/live-terminal-sessions.js", () => ({
liveTerminalSessions: {
getSession: (sessionId: string) => {
const session = state.sshSessions.get(sessionId);
if (!session) return null;
@@ -9,57 +9,10 @@ vi.mock("fs/promises", () => ({
}));
import {
MemoryAgent,
FilteredAgent,
resolveAgentSocket,
} from "../../hosts/terminal-auth-helpers.js";
describe("MemoryAgent", () => {
it("serves identities and signatures over the agent protocol", async () => {
const { privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 });
const parsed = ssh2Pkg.utils.parseKey(
privateKey.export({ type: "pkcs1", format: "pem" }),
);
expect(parsed).not.toBeInstanceOf(Error);
const agent = new MemoryAgent(parsed as ParsedKey);
const stream = await new Promise<NodeJS.ReadWriteStream>(
(resolve, reject) => {
agent.getStream((error, result) => {
if (error || !result)
reject(error ?? new Error("Missing agent stream"));
else resolve(result);
});
},
);
const client = new ssh2Pkg.AgentProtocol(true);
client.pipe(stream).pipe(client);
const identities = await new Promise<ParsedKey[]>((resolve, reject) => {
client.getIdentities((error, keys) => {
if (error || !keys) reject(error ?? new Error("Missing identities"));
else resolve(keys);
});
});
expect(identities).toHaveLength(1);
expect(identities[0].getPublicSSH()).toEqual(
(parsed as ParsedKey).getPublicSSH(),
);
const data = Buffer.from("forwarded-agent-test");
const signature = await new Promise<Buffer>((resolve, reject) => {
client.sign(identities[0], data, (error, result) => {
if (error || !result) reject(error ?? new Error("Missing signature"));
else resolve(result);
});
});
expect((parsed as ParsedKey).verify(data, signature)).toBe(true);
client.destroy();
stream.destroy();
});
});
describe("resolveAgentSocket", () => {
const originalEnv = process.env.SSH_AUTH_SOCK;
const originalPlatform = process.platform;
@@ -228,58 +181,3 @@ describe("FilteredAgent", () => {
expect(identities).toHaveLength(0);
});
});
describe("MemoryAgent unsupported extension framing", () => {
it.each([false, true])(
"can enumerate keys after rejecting session-bind (fragmented=%s)",
async (fragmented) => {
const { privateKey } = generateKeyPairSync("rsa", {
modulusLength: 2048,
});
const key = ssh2Pkg.utils.parseKey(
privateKey.export({ type: "pkcs1", format: "pem" }),
) as ParsedKey;
const agent = new MemoryAgent(key);
const stream = await new Promise<NodeJS.ReadWriteStream>(
(resolve, reject) => {
agent.getStream((error, result) =>
error || !result ? reject(error) : resolve(result),
);
},
);
const replies: Buffer[] = [];
let buffered = Buffer.alloc(0);
stream.on("data", (data: Buffer) => {
buffered = Buffer.concat([buffered, data]);
while (
buffered.length >= 4 &&
buffered.length >= buffered.readUInt32BE(0) + 4
) {
const end = buffered.readUInt32BE(0) + 4;
replies.push(buffered.subarray(4, end));
buffered = buffered.subarray(end);
}
});
const extension = Buffer.from("session-bind@openssh.com");
const request = Buffer.alloc(4 + 1 + 4 + extension.length);
request.writeUInt32BE(request.length - 4, 0);
request[4] = 27;
request.writeUInt32BE(extension.length, 5);
extension.copy(request, 9);
const identities = Buffer.from([0, 0, 0, 1, 11]);
try {
const requests = Buffer.concat([request, identities]);
if (fragmented) {
stream.write(requests.subarray(0, 7));
stream.write(requests.subarray(7));
} else stream.write(requests);
await vi.waitFor(() => expect(replies).toHaveLength(2));
expect(replies[0][0]).toBe(5); // Unsupported extension remains refused.
expect(replies[1][0]).toBe(12); // The following identities request succeeds.
expect(replies[1].readUInt32BE(1)).toBe(1);
} finally {
stream.destroy();
}
},
);
});
@@ -1,50 +0,0 @@
/**
* The terminal WS server's lifecycle, which the ssh-terminal plugin drives.
*
* There is no terminal port any more: core serves the socket at
* /plugin-ws/ssh-terminal/terminal and the plugin hands each upgrade to the
* WebSocketServer this module owns. So the thing worth asserting changed with
* it. It used to be "the port stops listening"; it is now "the module binds
* nothing on import, and disabling the plugin closes the live sessions", which
* is what disabling a transport owner has to mean when core owns the socket.
*
* The terminal module is heavy (it pulls in the whole SSH stack), so this
* exercises only the lifecycle surface.
*/
import { describe, expect, it, vi } from "vitest";
vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentHostResolutionRepository: () => ({}),
createCurrentCollabRoomRepository: () => ({}),
createCurrentSessionShareRepository: () => ({}),
createCurrentSettingsRepository: () => ({}),
createCurrentSessionRecordingRepository: () => ({}),
}));
describe("terminal server lifecycle", () => {
it("binds no port of its own", async () => {
const terminal = await import("../../../hosts/terminal/index.js");
// The old module exported a port and a listen() call. Both are gone, and
// their absence is the contract: a plugin that binds its own port is
// exactly what A4 removed.
expect("TERMINAL_WS_PORT" in terminal).toBe(false);
expect("startTerminalServer" in terminal).toBe(false);
});
it("exposes an upgrade handler for core to route to", async () => {
const { handleTerminalUpgrade } =
await import("../../../hosts/terminal/index.js");
expect(typeof handleTerminalUpgrade).toBe("function");
});
it("is idempotent on stop, so a double deactivate is safe", async () => {
const { stopTerminalServer } =
await import("../../../hosts/terminal/index.js");
await expect(stopTerminalServer()).resolves.toBeUndefined();
await expect(stopTerminalServer()).resolves.toBeUndefined();
});
});
@@ -1,510 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
// Stub all external imports before loading the module under test
const mockCreate = vi.fn().mockResolvedValue({ id: 1 });
const mockUpdateEnded = vi.fn().mockResolvedValue(undefined);
vi.mock("../../../database/db/index.js", () => ({
getDb: () => ({}),
}));
vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentSessionRecordingRepository: () => ({
create: mockCreate,
updateEnded: mockUpdateEnded,
}),
}));
vi.mock("../../../utils/logger.js", () => ({
sshLogger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
}));
// Mock individual fs.promises methods via a stub object
const mockMkdir = vi.fn().mockResolvedValue(undefined);
const mockWriteFile = vi.fn().mockResolvedValue(undefined);
const mockAppendFile = vi.fn().mockResolvedValue(undefined);
const mockUnlink = vi.fn().mockResolvedValue(undefined);
vi.mock("fs", () => ({
default: {
promises: {
mkdir: mockMkdir,
writeFile: mockWriteFile,
appendFile: mockAppendFile,
readFile: vi.fn(),
unlink: mockUnlink,
},
},
promises: {
mkdir: mockMkdir,
writeFile: mockWriteFile,
appendFile: mockAppendFile,
readFile: vi.fn(),
unlink: mockUnlink,
},
}));
const { sessionManager, isMessageAllowedForParticipant } =
await import("../../../hosts/terminal/session-manager.js");
// Minimal fake WebSocket - only the surface session-manager touches.
function makeFakeWs(readyState = 1 /* OPEN */) {
return {
readyState,
send: vi.fn(),
close: vi.fn(),
terminate: vi.fn(),
} as unknown as import("ws").WebSocket;
}
const WS_OPEN = 1;
const WS_CLOSED = 3;
describe("TerminalSessionManager - session logging", () => {
beforeEach(() => {
vi.clearAllMocks();
// Re-apply resolved values after clearAllMocks
mockMkdir.mockResolvedValue(undefined);
mockWriteFile.mockResolvedValue(undefined);
mockCreate.mockResolvedValue({ id: 1 });
mockUpdateEnded.mockResolvedValue(undefined);
});
it("createSession stores sessionLoggingEnabled=true by default", () => {
const id = sessionManager.createSession("u1", 1, "host", 80, 24);
const session = sessionManager.getSession(id);
expect(session?.sessionLoggingEnabled).toBe(true);
sessionManager.destroySession(id);
});
it("createSession stores sessionLoggingEnabled=false when passed", () => {
const id = sessionManager.createSession(
"u1",
1,
"host",
80,
24,
undefined,
false,
);
const session = sessionManager.getSession(id);
expect(session?.sessionLoggingEnabled).toBe(false);
sessionManager.destroySession(id);
});
it("does not write log file when sessionLoggingEnabled=false", async () => {
const id = sessionManager.createSession(
"u1",
1,
"host",
80,
24,
undefined,
false,
);
sessionManager.bufferOutput(id, "some output");
sessionManager.destroySession(id);
await new Promise((r) => setTimeout(r, 20));
expect(mockWriteFile).not.toHaveBeenCalled();
});
it("writes log file and inserts DB row when sessionLoggingEnabled=true", async () => {
const id = sessionManager.createSession(
"u1",
1,
"host",
80,
24,
undefined,
true,
);
sessionManager.bufferOutput(id, "terminal output data");
sessionManager.destroySession(id);
await new Promise((r) => setTimeout(r, 20));
expect(mockWriteFile).toHaveBeenCalledOnce();
expect(mockCreate).toHaveBeenCalledOnce();
});
it("does not write log file when buffer is empty", async () => {
const id = sessionManager.createSession(
"u1",
1,
"host",
80,
24,
undefined,
true,
);
sessionManager.destroySession(id);
await new Promise((r) => setTimeout(r, 20));
expect(mockWriteFile).not.toHaveBeenCalled();
});
it("bufferOutput trims old data when exceeding 512KB", () => {
const id = sessionManager.createSession(
"u1",
1,
"host",
80,
24,
undefined,
false,
);
const chunk = "x".repeat(300 * 1024);
sessionManager.bufferOutput(id, chunk);
sessionManager.bufferOutput(id, chunk);
const session = sessionManager.getSession(id);
expect(session!.outputBufferBytes).toBeLessThanOrEqual(512 * 1024);
sessionManager.destroySession(id);
});
});
describe("TerminalSessionManager - multiplayer participants", () => {
beforeEach(() => {
vi.clearAllMocks();
mockMkdir.mockResolvedValue(undefined);
mockWriteFile.mockResolvedValue(undefined);
mockCreate.mockResolvedValue({ id: 1 });
mockUpdateEnded.mockResolvedValue(undefined);
});
function createConnectedSession(): string {
const id = sessionManager.createSession(
"owner-1",
1,
"host",
80,
24,
undefined,
false,
);
// Mark connected without a real ssh2 stream - only isConnected is read
// by attachWs/joinAsParticipant.
const session = sessionManager.getSession(id)!;
session.isConnected = true;
return id;
}
it("joinAsParticipant adds a participant without evicting the owner", () => {
const id = createConnectedSession();
const ownerWs = makeFakeWs();
sessionManager.attachWs(id, "owner-1", ownerWs);
const guestWs = makeFakeWs();
const session = sessionManager.joinAsParticipant(id, guestWs, {
userId: null,
permissionLevel: "read-only",
guestLabel: "Guest",
});
expect(session).not.toBeNull();
expect(session!.participants.size).toBe(2);
const ownerParticipant = sessionManager.getParticipantForWs(
session!,
ownerWs,
);
expect(ownerParticipant?.isOwner).toBe(true);
// The join is announced to everyone already in the session - and that is
// the only unsolicited message the owner receives.
expect(ownerWs.send).toHaveBeenCalledTimes(1);
const announced = JSON.parse(
(ownerWs.send as ReturnType<typeof vi.fn>).mock.calls[0][0] as string,
);
expect(announced.type).toBe("participants");
expect(announced.participants).toHaveLength(2);
sessionManager.destroySession(id);
});
it("setRoomShareControl makes only the controller read-write and never touches the owner", () => {
const id = createConnectedSession();
const ownerWs = makeFakeWs();
sessionManager.attachWs(id, "owner-1", ownerWs);
const aliceWs = makeFakeWs();
const bobWs = makeFakeWs();
const session = sessionManager.joinAsParticipant(id, aliceWs, {
userId: "alice",
permissionLevel: "read-only",
shareId: "stage-share",
})!;
sessionManager.joinAsParticipant(id, bobWs, {
userId: "bob",
permissionLevel: "read-only",
shareId: "stage-share",
});
sessionManager.setRoomShareControl(id, "stage-share", "alice");
expect(
sessionManager.getParticipantForWs(session, aliceWs)?.permissionLevel,
).toBe("read-write");
expect(
sessionManager.getParticipantForWs(session, bobWs)?.permissionLevel,
).toBe("read-only");
expect(
sessionManager.getParticipantForWs(session, ownerWs)?.permissionLevel,
).toBe("read-write");
sessionManager.setRoomShareControl(id, "stage-share", null);
expect(
sessionManager.getParticipantForWs(session, aliceWs)?.permissionLevel,
).toBe("read-only");
sessionManager.destroySession(id);
});
it("disconnectShareParticipants revokes only the selected share participants", () => {
const id = createConnectedSession();
const ownerWs = makeFakeWs();
const aliceWs = makeFakeWs();
const guestWs = makeFakeWs();
sessionManager.attachWs(id, "owner-1", ownerWs);
const session = sessionManager.joinAsParticipant(id, aliceWs, {
userId: "alice",
permissionLevel: "read-only",
shareId: "stage-share",
})!;
sessionManager.joinAsParticipant(id, guestWs, {
userId: null,
permissionLevel: "read-only",
shareId: "stage-share",
});
expect(
sessionManager.disconnectShareParticipants(id, "stage-share", {
userId: "alice",
reason: "Removed",
}),
).toBe(1);
expect(sessionManager.getParticipantForWs(session, aliceWs)).toBeNull();
expect(sessionManager.getParticipantForWs(session, guestWs)).not.toBeNull();
expect(sessionManager.getParticipantForWs(session, ownerWs)?.isOwner).toBe(
true,
);
expect(aliceWs.close).toHaveBeenCalledWith(1008, "Removed");
sessionManager.destroySession(id);
});
it("joinAsParticipant returns null for a nonexistent or unconnected session", () => {
expect(
sessionManager.joinAsParticipant("does-not-exist", makeFakeWs(), {
userId: null,
permissionLevel: "read-only",
}),
).toBeNull();
});
it("broadcast sends to all OPEN participant sockets and skips CLOSED ones", () => {
const id = createConnectedSession();
const ownerWs = makeFakeWs(WS_OPEN);
sessionManager.attachWs(id, "owner-1", ownerWs);
const openGuestWs = makeFakeWs(WS_OPEN);
const closedGuestWs = makeFakeWs(WS_CLOSED);
sessionManager.joinAsParticipant(id, openGuestWs, {
userId: null,
permissionLevel: "read-only",
});
sessionManager.joinAsParticipant(id, closedGuestWs, {
userId: null,
permissionLevel: "read-only",
});
sessionManager.broadcast(id, { type: "data", data: "hello" });
expect(ownerWs.send).toHaveBeenCalledWith(
JSON.stringify({ type: "data", data: "hello" }),
);
expect(openGuestWs.send).toHaveBeenCalledWith(
JSON.stringify({ type: "data", data: "hello" }),
);
expect(closedGuestWs.send).not.toHaveBeenCalled();
sessionManager.destroySession(id);
});
it("broadcast does not throw if a socket's send throws", () => {
const id = createConnectedSession();
const throwingWs = makeFakeWs(WS_OPEN);
(throwingWs.send as ReturnType<typeof vi.fn>).mockImplementation(() => {
throw new Error("send failed");
});
sessionManager.attachWs(id, "owner-1", throwingWs);
expect(() =>
sessionManager.broadcast(id, { type: "data", data: "x" }),
).not.toThrow();
sessionManager.destroySession(id);
});
it("broadcast is a no-op for a nonexistent session", () => {
expect(() =>
sessionManager.broadcast("does-not-exist", { type: "data" }),
).not.toThrow();
});
it("owner detach arms the idle timeout (existing behavior)", () => {
vi.useFakeTimers();
try {
const id = createConnectedSession();
const ownerWs = makeFakeWs();
sessionManager.attachWs(id, "owner-1", ownerWs);
sessionManager.detachWs(id);
const session = sessionManager.getSession(id);
expect(session?.detachTimeout).not.toBeNull();
expect(session?.lastDetachedAt).not.toBeNull();
sessionManager.destroySession(id);
} finally {
vi.useRealTimers();
}
});
it("removeParticipant on a non-owner does not arm a timeout or destroy the session", () => {
const id = createConnectedSession();
const ownerWs = makeFakeWs();
sessionManager.attachWs(id, "owner-1", ownerWs);
const guestWs = makeFakeWs();
sessionManager.joinAsParticipant(id, guestWs, {
userId: null,
permissionLevel: "read-write",
});
sessionManager.removeParticipant(id, guestWs);
const session = sessionManager.getSession(id);
expect(session).not.toBeNull();
expect(session?.detachTimeout).toBeNull();
expect(session?.participants.size).toBe(1);
expect(sessionManager.getParticipantForWs(session!, guestWs)).toBeNull();
sessionManager.destroySession(id);
});
it("removeParticipant is a no-op when the ws belongs to the owner", () => {
const id = createConnectedSession();
const ownerWs = makeFakeWs();
sessionManager.attachWs(id, "owner-1", ownerWs);
sessionManager.removeParticipant(id, ownerWs);
const session = sessionManager.getSession(id);
expect(session?.participants.size).toBe(1);
expect(sessionManager.getParticipantForWs(session!, ownerWs)?.isOwner).toBe(
true,
);
sessionManager.destroySession(id);
});
it("destroySession cleans up all participants, not just the owner", () => {
const id = createConnectedSession();
const ownerWs = makeFakeWs();
sessionManager.attachWs(id, "owner-1", ownerWs);
const guestWs = makeFakeWs();
sessionManager.joinAsParticipant(id, guestWs, {
userId: null,
permissionLevel: "read-only",
});
sessionManager.destroySession(id);
expect(guestWs.send).toHaveBeenCalled();
expect(sessionManager.getSession(id)).toBeNull();
});
it("ownerEndSession notifies non-owner participants and destroys the session", () => {
const id = createConnectedSession();
const ownerWs = makeFakeWs();
sessionManager.attachWs(id, "owner-1", ownerWs);
const guestWs = makeFakeWs();
sessionManager.joinAsParticipant(id, guestWs, {
userId: null,
permissionLevel: "read-write",
});
sessionManager.ownerEndSession(id, "owner ended the session");
expect(guestWs.send).toHaveBeenCalledWith(
JSON.stringify({
type: "sessionTerminatedByOwner",
reason: "owner ended the session",
}),
);
expect(sessionManager.getSession(id)).toBeNull();
});
});
describe("isMessageAllowedForParticipant", () => {
it("allows any message type for the owner or when there is no participant", () => {
expect(isMessageAllowedForParticipant(null, "connectToHost")).toBe(true);
expect(
isMessageAllowedForParticipant(
{ isOwner: true, permissionLevel: "read-write" },
"resize",
),
).toBe(true);
});
it("drops input from a read-only participant", () => {
expect(
isMessageAllowedForParticipant(
{ isOwner: false, permissionLevel: "read-only" },
"input",
),
).toBe(false);
});
it("allows input from a read-write non-owner participant", () => {
expect(
isMessageAllowedForParticipant(
{ isOwner: false, permissionLevel: "read-write" },
"input",
),
).toBe(true);
});
it("allows ping and disconnect for any non-owner participant", () => {
expect(
isMessageAllowedForParticipant(
{ isOwner: false, permissionLevel: "read-only" },
"ping",
),
).toBe(true);
expect(
isMessageAllowedForParticipant(
{ isOwner: false, permissionLevel: "read-only" },
"disconnect",
),
).toBe(true);
});
it("blocks resize and auth/tmux message types for non-owner participants regardless of permission level", () => {
for (const type of [
"resize",
"totp_response",
"password_response",
"tmux_attach",
"tmux_detach",
"get_cwd",
"vault_start_auth",
"opkssh_start_auth",
]) {
expect(
isMessageAllowedForParticipant(
{ isOwner: false, permissionLevel: "read-write" },
type,
),
).toBe(false);
}
});
});
@@ -0,0 +1,83 @@
/**
* ctx.audit.record: a plugin writes an audit line under its own action name,
* and the user it names is always the runtime's actor, never one the plugin
* picked.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
const auditEntries: Array<Record<string, unknown>> = [];
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginPermissionGrantRepository: () => ({
listByPlugin: async () => [],
}),
}));
vi.mock("../../utils/audit-logger.js", () => ({
logAudit: async (entry: Record<string, unknown>) => {
auditEntries.push(entry);
},
}));
import { createPluginContext, createPluginHandle } from "../../plugins/ctx.js";
import { runAsActor } from "../../plugins/actor.js";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
function contextFor(pluginId: string) {
const manifest = {
id: pluginId,
name: "Audit Fixture",
version: "1.0.0",
description: "",
author: { name: "test" },
license: "MIT",
category: "Productivity",
engine: { termix: ">=2.9.0", api: "1" },
capabilities: [],
} as PluginManifest;
const handle = createPluginHandle(pluginId, { activate: () => {} });
return createPluginContext(manifest, handle);
}
beforeEach(() => {
auditEntries.length = 0;
});
describe("ctx.audit.record", () => {
it("writes the plugin's action for the acting user", async () => {
const ctx = contextFor("audit-fixture");
await runAsActor("user-3", "request", () =>
ctx.audit.record({
action: "ssh_connect",
resourceType: "host",
resourceId: "12",
resourceName: "root@10.0.0.1:22",
success: true,
}),
);
expect(auditEntries).toEqual([
expect.objectContaining({
userId: "user-3",
action: "ssh_connect",
resourceType: "host",
resourceId: "12",
resourceName: "root@10.0.0.1:22",
details: "via plugin audit-fixture",
success: true,
}),
]);
});
it("falls back to the plugin as the resource and system as the user", async () => {
const ctx = contextFor("audit-fixture");
await ctx.audit.record({ action: "cleanup", success: false });
expect(auditEntries[0]).toMatchObject({
userId: "system",
resourceType: "plugin",
resourceId: "audit-fixture",
resourceName: "Audit Fixture",
success: false,
});
});
});
@@ -18,6 +18,9 @@ const h = vi.hoisted(() => ({
updates: [] as Array<Record<string, unknown>>,
updateResult: null as Record<string, unknown> | null,
users: [] as Array<{ id: string; username: string }>,
activities: [] as Array<Record<string, unknown>>,
sessions: [] as number[],
released: [] as number[],
roles: [] as Array<{
id: number;
name: string;
@@ -90,6 +93,23 @@ vi.mock("../../database/repositories/factory.js", () => ({
listDecryptedByUserId: async () => h.ownedHosts,
}),
}));
vi.mock("../../services/recent-activity.js", () => ({
recordRecentActivity: async (
userId: string,
entry: Record<string, unknown>,
) => {
h.activities.push({ userId, ...entry });
return { status: "logged", id: 1 };
},
}));
vi.mock("../../hosts/host-session-status.js", () => ({
hostSessionStatus: {
register: (hostId: number) => {
h.sessions.push(hostId);
return () => h.released.push(hostId);
},
},
}));
vi.mock("../../utils/shared-host-secrets-manager.js", () => ({
SharedHostSecretsManager: {
getInstance: () => ({
@@ -347,3 +367,40 @@ describe("ctx.hosts", () => {
expect(await hosts.listOwned()).toMatchObject([{ id: 1, name: "own" }]);
});
});
describe("ctx.hosts session tracking and activity", () => {
it("counts a live session through core and releases it", () => {
h.sessions = [];
h.released = [];
const hosts = createPluginHosts({
manifest: manifest(["hosts:read"]),
audit: vi.fn(async () => {}),
});
const release = hosts.trackSession(4);
expect(h.sessions).toEqual([4]);
release();
expect(h.released).toEqual([4]);
});
it("refuses to track a session without hosts:read declared", () => {
const hosts = createPluginHosts({
manifest: manifest([]),
audit: vi.fn(async () => {}),
});
expect(() => hosts.trackSession(4)).toThrow(PluginCapabilityError);
});
it("records activity for the acting user only", async () => {
h.activities = [];
h.granted = new Set(["hosts:read"]);
h.actor = "user-7";
const hosts = createPluginHosts({
manifest: manifest(["hosts:read"]),
audit: vi.fn(async () => {}),
});
await hosts.recordActivity(4, "terminal", "web-01");
expect(h.activities).toEqual([
{ userId: "user-7", type: "terminal", hostId: 4, hostName: "web-01" },
]);
});
});
@@ -0,0 +1,263 @@
/**
* The ctx.ssh members an interactive transport (the terminal) needs: host
* resolution with secrets, prepare with its provider hooks bound, and the
* browser sign-in start and cancel that used to be reached by import.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
const h = vi.hoisted(() => ({
granted: new Set<string>(),
actor: "user-1" as string | undefined,
resolved: [] as Array<{ by: string; ref: unknown; userId: string }>,
built: [] as Array<Record<string, unknown>>,
}));
vi.mock("../../utils/logger.js", () => {
const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() };
return { pluginLogger: log, sshLogger: log, logger: log, authLogger: log };
});
vi.mock("../../plugins/permissions.js", async () => {
const { PluginCapabilityError } = await import("@termix/plugin-sdk/backend");
return {
assertCapability: async (
pluginId: string,
capability: string,
declared: readonly string[],
) => {
if (!declared.includes(capability) || !h.granted.has(capability)) {
throw new PluginCapabilityError(pluginId, capability);
}
},
};
});
vi.mock("../../plugins/actor.js", () => ({ getActor: () => h.actor }));
vi.mock("../../hosts/connect/core-providers.js", () => ({
ensureCoreSshAuthProviders: () => {},
}));
vi.mock("../../hosts/host-resolver.js", () => ({
resolveHostById: async (id: number, userId: string) => {
h.resolved.push({ by: "id", ref: id, userId });
return id === 404 ? null : { id, ip: "10.0.0.1", password: "pw" };
},
resolveHostBySyncId: async (syncId: string, userId: string) => {
h.resolved.push({ by: "syncId", ref: syncId, userId });
return { id: 9, ip: "10.0.0.9" };
},
}));
vi.mock("../../hosts/connect/build-connect-config.js", () => ({
buildConnectConfig: async (
_host: unknown,
options: Record<string, unknown>,
) => {
h.built.push(options);
return {
config: { host: "10.0.0.1" },
outcome: { status: "ready" },
env: { interactive: options.interactive },
provider: {
type: "fixture-auth",
onBanner: (banner: string, _host: unknown, env: unknown) => ({
action: "hold",
timeoutMs: 1,
message: banner,
details: { env },
}),
onAuthFailed: () => ({ status: "retry", patch: {}, message: "again" }),
},
};
},
}));
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentHostResolutionRepository: () => ({
findHostById: async (hostId: number) =>
hostId === 1
? {
id: 1,
name: "box",
ip: "10.0.0.1",
username: "root",
authType: "fixture-auth",
}
: null,
}),
}));
const { createPluginSsh } = await import("../../plugins/ctx-ssh-auth.js");
const { DisposableBag } = await import("../../plugins/disposables.js");
const { registerSshAuthProvider, resetSshAuthRegistryForTests } =
await import("../../hosts/connect/auth-provider-registry.js");
const { PluginCapabilityError, PluginSshInteractionError } =
await import("@termix/plugin-sdk/backend");
const ALL = ["ssh:connect", "credentials:use"];
function sshWith(capabilities = ALL) {
const audit = vi.fn(async () => {});
const ssh = createPluginSsh({
manifest: {
id: "fixture",
name: "Fixture",
version: "1.0.0",
capabilities,
} as never,
bag: new DisposableBag("fixture"),
audit,
});
return { ssh, audit };
}
beforeEach(() => {
h.granted = new Set(ALL);
h.actor = "user-1";
h.resolved = [];
h.built = [];
resetSshAuthRegistryForTests();
});
describe("ctx.ssh.resolveHost", () => {
it("resolves by sync id first, as the acting user, and audits", async () => {
const { ssh, audit } = sshWith();
expect(await ssh.resolveHost(3, { syncId: "abc" })).toMatchObject({
id: 9,
});
expect(await ssh.resolveHost(3)).toMatchObject({ id: 3, password: "pw" });
expect(h.resolved).toEqual([
{ by: "syncId", ref: "abc", userId: "user-1" },
{ by: "id", ref: 3, userId: "user-1" },
]);
expect(audit).toHaveBeenCalledWith("ssh_resolve_host", "host 3", {
success: true,
});
});
it("answers null for a host this user cannot reach", async () => {
const { ssh } = sshWith();
expect(await ssh.resolveHost(404)).toBeNull();
});
it("refuses without credentials:use", async () => {
h.granted = new Set(["ssh:connect"]);
const { ssh } = sshWith(["ssh:connect"]);
await expect(ssh.resolveHost(3)).rejects.toBeInstanceOf(
PluginCapabilityError,
);
expect(h.resolved).toEqual([]);
});
});
describe("ctx.ssh.prepare", () => {
it("passes the terminal purpose, interactivity and host key socket through", async () => {
const { ssh } = sshWith();
const socket = { send: vi.fn() };
await ssh.prepare(
{ id: 1, ip: "10.0.0.1", port: 22, username: "root" },
{
purpose: "terminal",
client: {},
interactive: true,
hostKeySocket: socket,
},
);
expect(h.built[0]).toMatchObject({
purpose: "terminal",
interactive: true,
hostKeySocket: socket,
userId: "user-1",
});
});
it("hands back the provider's hooks bound to this host", async () => {
const { ssh } = sshWith();
const prepared = await ssh.prepare(
{ id: 1, ip: "10.0.0.1", port: 22, username: "root" },
{ client: {}, interactive: true },
);
expect(prepared.authType).toBe("fixture-auth");
expect(prepared.onBanner?.("waiting")).toMatchObject({
action: "hold",
message: "waiting",
details: { env: { interactive: true } },
});
expect(
prepared.onAuthFailed?.({
error: new Error("x"),
retries: 0,
canRetry: true,
methodNotAvailable: false,
}),
).toMatchObject({ status: "retry" });
});
});
describe("ctx.ssh browser sign-in", () => {
it("starts the interaction on the host's own provider", async () => {
const started = vi.fn(async () => {});
registerSshAuthProvider({
type: "fixture-auth",
pluginId: "core",
labelKey: "k",
interaction: "fixture",
startInteraction: started,
prepare: async () => ({ status: "ready" }),
});
const { ssh } = sshWith();
await ssh.startInteraction("fixture", {
hostId: 1,
socket: "ws",
requestOrigin: "https://termix.example",
});
expect(started).toHaveBeenCalledWith(
expect.objectContaining({
userId: "user-1",
hostId: 1,
socket: "ws",
requestOrigin: "https://termix.example",
host: { name: "box", ip: "10.0.0.1", username: "root" },
}),
);
});
it("says why when nothing can start it", async () => {
const { ssh } = sshWith();
await expect(
ssh.startInteraction("fixture", {
hostId: 1,
socket: null,
requestOrigin: "",
}),
).rejects.toBeInstanceOf(PluginSshInteractionError);
await expect(
ssh.startInteraction("fixture", {
hostId: 2,
socket: null,
requestOrigin: "",
}),
).rejects.toThrow("Host not found");
});
it("cancels on every provider that owns the interaction", async () => {
const first = vi.fn();
const second = vi.fn();
const other = vi.fn();
for (const [type, interaction, cancel] of [
["a", "fixture", first],
["b", "fixture", second],
["c", "elsewhere", other],
] as const) {
registerSshAuthProvider({
type,
pluginId: "core",
labelKey: "k",
interaction,
cancelInteraction: cancel,
prepare: async () => ({ status: "ready" }),
});
}
const { ssh } = sshWith();
await ssh.cancelInteraction("fixture", { requestId: "r1" });
expect(first).toHaveBeenCalledWith({ userId: "user-1", requestId: "r1" });
expect(second).toHaveBeenCalledOnce();
expect(other).not.toHaveBeenCalled();
});
});
@@ -21,6 +21,7 @@ const {
clearServiceRegistry,
createServiceHandle,
getRegistration,
getServiceImplementation,
listServices,
provideService,
resolveRequirements,
@@ -85,6 +86,19 @@ describe("service registry", () => {
expect(revokeService("nothing.here")).toBe(false);
});
it("hands core the implementation of a compatible version, or nothing", () => {
expect(
getServiceImplementation("testplugin.greet", "^1.0.0"),
).toBeUndefined();
const registration = register({ version: "1.4.0" });
expect(getServiceImplementation("testplugin.greet", "^1.0.0")).toBe(
registration.implementation,
);
expect(
getServiceImplementation("testplugin.greet", "^2.0.0"),
).toBeUndefined();
});
describe("resolution", () => {
it("is satisfied when the version is in range", () => {
register({ version: "1.2.0" });
+55
View File
@@ -37,6 +37,13 @@ vi.mock("../../utils/auth-manager.js", () => ({
},
}));
vi.mock("../../utils/data-crypto.js", () => ({
DataCrypto: {
getUserDataKey: (userId: string) =>
userId === state.userId ? Buffer.from("key") : null,
},
}));
vi.mock("../../plugins/permissions.js", () => ({
hasCapability: async (
_pluginId: string,
@@ -190,6 +197,54 @@ describe("ctx.ws authentication", () => {
});
});
describe("ctx.ws optional auth", () => {
function register(seen: Array<Record<string, unknown>>) {
ws.registerPluginWsRoute(
"sample-plugin",
"/mixed",
(connection) => {
seen.push({
userId: connection.userId,
unlocked: connection.isDataUnlocked(),
clientIp: connection.clientIp,
requestOrigin: connection.requestOrigin,
});
(connection.socket as WebSocket).send("hi");
},
DECLARED,
{ public: true, optionalAuth: true },
);
}
it("names the user when a valid token came with the upgrade", async () => {
const seen: Array<Record<string, unknown>> = [];
register(seen);
const base = await startServer();
const result = await connect(
`${base}/plugin-ws/sample-plugin/mixed`,
authProtocols(state.validToken),
);
expect(result.outcome).toBe("open");
expect(seen[0]).toMatchObject({ userId: state.userId, unlocked: true });
expect(typeof seen[0].clientIp).toBe("string");
expect(String(seen[0].requestOrigin)).toMatch(/^http/);
if (result.outcome === "open") result.socket.close();
});
it("still serves a guest, with no user and nothing unlocked", async () => {
const seen: Array<Record<string, unknown>> = [];
register(seen);
const base = await startServer();
const result = await connect(
`${base}/plugin-ws/sample-plugin/mixed`,
authProtocols("nope"),
);
expect(result.outcome).toBe("open");
expect(seen[0]).toMatchObject({ userId: "", unlocked: false });
if (result.outcome === "open") result.socket.close();
});
});
describe("ctx.ws capability", () => {
it("refuses the upgrade when network:serve is not granted", async () => {
ws.registerPluginWsRoute("sample-plugin", "/socket", () => {}, DECLARED);
@@ -1,68 +0,0 @@
/**
* dispatchLoginEvent used to POST to the metrics service on localhost:30005
* with an internal auth token. Host metrics is a plugin now and has no port,
* so it publishes on the plugin event bus and the plugin subscribes.
*
* What is asserted changed with it: the payload a subscriber receives, and
* that a throwing subscriber cannot fail the login that caused the event.
*/
import { afterEach, describe, expect, it, vi } from "vitest";
import { dispatchLoginEvent } from "../../utils/login-event-dispatch.js";
import { pluginEvents, TOPICS } from "../../plugins/events.js";
import { sshLogger } from "../../utils/logger.js";
describe("dispatchLoginEvent", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("publishes the login details a subscriber needs", async () => {
const received: unknown[] = [];
const unsubscribe = pluginEvents.on(TOPICS.hostLogin, (payload) => {
received.push(payload);
});
try {
await dispatchLoginEvent(42, "user-1", "root", "10.0.0.5");
} finally {
unsubscribe();
}
expect(received).toEqual([
{ hostId: 42, userId: "user-1", sshUser: "root", fromIp: "10.0.0.5" },
]);
});
it("does not log a warning on the happy path", async () => {
const warn = vi.spyOn(sshLogger, "warn").mockImplementation(() => {});
await dispatchLoginEvent(7, "user-1", "root", "192.0.2.1");
expect(warn).not.toHaveBeenCalled();
});
it("never sends the event over the network", async () => {
const fetchSpy = vi.spyOn(globalThis, "fetch");
await dispatchLoginEvent(7, "user-1", "root", "192.0.2.1");
// The whole point of the move: no service-to-service HTTP call, and no
// internal shared secret to carry on it.
expect(fetchSpy).not.toHaveBeenCalled();
});
it("does not propagate a subscriber's throw to the caller", async () => {
const unsubscribe = pluginEvents.on(TOPICS.hostLogin, () => {
throw new Error("subscriber exploded");
});
try {
await expect(
dispatchLoginEvent(1, "user-1", "root", "127.0.0.1"),
).resolves.toBeUndefined();
} finally {
unsubscribe();
}
});
});
@@ -0,0 +1,217 @@
/**
* The SSH terminal settings migration.
*
* An upgrade must be lossless: an install's session timeout, command history
* switch, touch tuning and image storage settings, and every host that turned
* a terminal switch off, must come through into the plugin's own settings.
* Running it twice must not duplicate or clobber what it moved.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
interface Row {
pluginId: string;
scope: string;
scopeId: string | null;
key: string;
value: string | null;
encrypted: boolean;
}
interface HostRow {
id: number;
enable_terminal: number | null;
enable_terminal_toolbar: number | null;
enable_command_history: number | null;
}
const coreSettings = new Map<string, string>();
const pluginRows: Row[] = [];
const hostRows: HostRow[] = [];
let pluginInstalled = true;
const find = (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
) =>
pluginRows.find(
(row) =>
row.pluginId === pluginId &&
row.scope === scope &&
row.scopeId === scopeId &&
row.key === key,
);
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginRepository: () => ({
findById: async (id: string) =>
pluginInstalled && id === "ssh-terminal" ? { id } : null,
}),
createCurrentPluginSettingsRepository: () => ({
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,
encrypted = false,
) => {
const existing = find(pluginId, scope, scopeId, key);
if (existing) {
existing.value = value;
existing.encrypted = encrypted;
return;
}
pluginRows.push({ pluginId, scope, scopeId, key, value, encrypted });
},
}),
createCurrentSettingsRepository: () => ({
get: async (key: string) => coreSettings.get(key) ?? null,
}),
}));
vi.mock("../../database/db/index.js", () => ({
getDb: () => ({
all: async () =>
hostRows.filter(
(row) =>
row.enable_terminal === 0 ||
row.enable_terminal_toolbar === 0 ||
row.enable_command_history === 0,
),
}),
}));
vi.mock("../../utils/system-secret-crypto.js", () => ({
encryptSystemSecret: async (plaintext: string) =>
plaintext.startsWith("sysenc:") ? plaintext : `sysenc:${plaintext}`,
}));
const { runSshTerminalSettingsMigration } =
await import("../../utils/crypto-migration/ssh-terminal-settings-migration.js");
function admin(key: string): unknown {
const row = find("ssh-terminal", "admin", null, key);
return row?.value == null ? undefined : JSON.parse(row.value);
}
function host(id: number, key: string): unknown {
const row = find("ssh-terminal", "host", String(id), key);
return row?.value == null ? undefined : JSON.parse(row.value);
}
beforeEach(() => {
coreSettings.clear();
pluginRows.length = 0;
hostRows.length = 0;
pluginInstalled = true;
});
describe("runSshTerminalSettingsMigration", () => {
it("moves the admin settings with their types, the local dir as a secret", async () => {
coreSettings.set("terminal_session_timeout_minutes", "120");
coreSettings.set("terminal_session_persistence_enabled", "false");
coreSettings.set("command_history_enabled", "false");
coreSettings.set(
"touch_input_settings",
JSON.stringify({ enabled: false, dragThresholdPx: 12 }),
);
coreSettings.set("terminal_image_storage_mode", "local");
coreSettings.set("terminal_image_local_dir", "/data/images");
coreSettings.set("terminal_image_max_count", "50");
const result = await runSshTerminalSettingsMigration();
expect(result.moved).toHaveLength(7);
expect(admin("sessionTimeoutMinutes")).toBe(120);
expect(admin("sessionPersistence")).toBe(false);
expect(admin("commandHistoryEnabled")).toBe(false);
expect(admin("touchInput")).toEqual({
enabled: false,
dragThresholdPx: 12,
});
expect(admin("imageStorageMode")).toBe("local");
expect(admin("imageMaxCount")).toBe(50);
const localDir = find("ssh-terminal", "admin", null, "imageLocalDir");
expect(localDir?.encrypted).toBe(true);
expect(JSON.parse(localDir!.value!)).toBe("sysenc:/data/images");
});
it("copies each host that switched something off, keeping the rest on", async () => {
hostRows.push(
{
id: 1,
enable_terminal: 1,
enable_terminal_toolbar: 0,
enable_command_history: 1,
},
{
id: 2,
enable_terminal: 0,
enable_terminal_toolbar: 1,
enable_command_history: 0,
},
{
id: 3,
enable_terminal: 1,
enable_terminal_toolbar: 1,
enable_command_history: 1,
},
);
const result = await runSshTerminalSettingsMigration();
expect(result.hostsMoved).toBe(2);
expect(host(1, "enableTerminal")).toBe(true);
expect(host(1, "enableTerminalToolbar")).toBe(false);
expect(host(1, "enableCommandHistory")).toBe(true);
expect(host(2, "enableTerminal")).toBe(false);
expect(host(2, "enableCommandHistory")).toBe(false);
// All on is the plugin's default, so it needs no row.
expect(host(3, "enableTerminal")).toBeUndefined();
});
it("changes nothing on a second run, even after the admin changed a value", async () => {
coreSettings.set("terminal_session_timeout_minutes", "120");
hostRows.push({
id: 1,
enable_terminal: 0,
enable_terminal_toolbar: 1,
enable_command_history: 1,
});
await runSshTerminalSettingsMigration();
const changed = find(
"ssh-terminal",
"admin",
null,
"sessionTimeoutMinutes",
);
changed!.value = JSON.stringify(15);
const rowsBefore = pluginRows.length;
const second = await runSshTerminalSettingsMigration();
expect(second.moved).toEqual([]);
expect(second.hostsMoved).toBe(0);
expect(second.hostsSkipped).toBe(1);
expect(pluginRows).toHaveLength(rowsBefore);
expect(admin("sessionTimeoutMinutes")).toBe(15);
});
it("does nothing until the plugin row exists", async () => {
pluginInstalled = false;
coreSettings.set("terminal_session_timeout_minutes", "120");
const result = await runSshTerminalSettingsMigration();
expect(result.moved).toEqual([]);
expect(pluginRows).toEqual([]);
});
});
@@ -0,0 +1,235 @@
/**
* Moves the terminal's settings out of core and into the ssh-terminal
* plugin's own settings.
*
* Admin keys from the core `settings` table: session persistence, command
* history, touch input tuning and terminal image storage. The image storage
* local directory was never sent to a browser, so it lands as a secret field,
* which keeps it that way. The legacy rows are left in place for a release;
* nothing reads them any more.
*
* Host columns from ssh_data: enable_terminal, enable_terminal_toolbar and
* enable_command_history. All three default to on in the plugin, so only
* hosts that turned one off need a row.
*
* Idempotent: an admin key already set in the plugin, or a host that already
* has an enableTerminal row, is skipped, so this is safe on every boot.
*/
import { sql } from "drizzle-orm";
import { databaseLogger } from "../logger.js";
import { getDb } from "../../database/db/index.js";
import {
createCurrentPluginRepository,
createCurrentPluginSettingsRepository,
createCurrentSettingsRepository,
} from "../../database/repositories/factory.js";
import { encryptSystemSecret } from "../system-secret-crypto.js";
const PLUGIN_ID = "ssh-terminal";
type Parse = (raw: string) => unknown;
const asNumber: Parse = (raw) => {
const value = Number(raw);
return Number.isFinite(value) ? value : undefined;
};
const asBoolean: Parse = (raw) =>
raw === "true" || raw === "1"
? true
: raw === "false" || raw === "0"
? false
: undefined;
const asString: Parse = (raw) => raw;
const asJson: Parse = (raw) => {
try {
return JSON.parse(raw);
} catch {
return undefined;
}
};
/** Legacy core key -> the admin field the plugin declares. */
const ADMIN_MOVES: {
legacyKey: string;
field: string;
parse: Parse;
secret?: boolean;
}[] = [
{
legacyKey: "terminal_session_timeout_minutes",
field: "sessionTimeoutMinutes",
parse: asNumber,
},
{
legacyKey: "terminal_session_persistence_enabled",
field: "sessionPersistence",
parse: asBoolean,
},
{
legacyKey: "command_history_enabled",
field: "commandHistoryEnabled",
parse: asBoolean,
},
{ legacyKey: "touch_input_settings", field: "touchInput", parse: asJson },
{
legacyKey: "terminal_image_storage_mode",
field: "imageStorageMode",
parse: asString,
},
{
legacyKey: "terminal_image_local_dir",
field: "imageLocalDir",
parse: asString,
secret: true,
},
{
legacyKey: "terminal_image_host_path",
field: "imageHostPath",
parse: asString,
},
{ legacyKey: "terminal_image_ttl_ms", field: "imageTtlMs", parse: asNumber },
{
legacyKey: "terminal_image_max_count",
field: "imageMaxCount",
parse: asNumber,
},
{
legacyKey: "terminal_image_max_storage_bytes",
field: "imageMaxBytes",
parse: asNumber,
},
];
interface LegacyTerminalHostRow {
id: number;
enable_terminal: number | boolean | null;
enable_terminal_toolbar: number | boolean | null;
enable_command_history: number | boolean | null;
}
/** A column that was never set keeps the old default, on. */
function isOn(value: number | boolean | null): boolean {
return value === null || value === undefined ? true : !!value;
}
export interface SshTerminalSettingsMigrationResult {
moved: string[];
skipped: string[];
hostsMoved: number;
hostsSkipped: number;
}
export async function runSshTerminalSettingsMigration(): Promise<SshTerminalSettingsMigrationResult> {
const result: SshTerminalSettingsMigrationResult = {
moved: [],
skipped: [],
hostsMoved: 0,
hostsSkipped: 0,
};
try {
// plugin_settings has a foreign key to plugins: nothing to migrate into
// until the plugin row exists.
const plugin = await createCurrentPluginRepository().findById(PLUGIN_ID);
if (!plugin) return result;
const settingsRepository = createCurrentSettingsRepository();
const pluginSettingsRepository = createCurrentPluginSettingsRepository();
for (const move of ADMIN_MOVES) {
const legacyValue = await settingsRepository.get(move.legacyKey);
if (legacyValue === null || legacyValue === "") continue;
const existing = await pluginSettingsRepository.get(
PLUGIN_ID,
"admin",
null,
move.field,
);
if (existing && existing.value !== null) {
result.skipped.push(move.legacyKey);
continue;
}
const parsed = move.parse(legacyValue);
if (parsed === undefined) {
result.skipped.push(move.legacyKey);
continue;
}
const stored = move.secret
? await encryptSystemSecret(String(parsed))
: parsed;
await pluginSettingsRepository.set(
PLUGIN_ID,
"admin",
null,
move.field,
JSON.stringify(stored),
!!move.secret,
);
result.moved.push(move.legacyKey);
}
// Raw SQL, not the typed schema, so this keeps working once schema.ts
// stops declaring these columns.
const rows = await getDb().all<LegacyTerminalHostRow>(sql`
SELECT id, enable_terminal, enable_terminal_toolbar, enable_command_history
FROM ssh_data
WHERE enable_terminal = false
OR enable_terminal_toolbar = false
OR enable_command_history = false
`);
for (const row of rows) {
const scopeId = String(row.id);
const existing = await pluginSettingsRepository.get(
PLUGIN_ID,
"host",
scopeId,
"enableTerminal",
);
if (existing && existing.value !== null) {
result.hostsSkipped++;
continue;
}
const values: Record<string, boolean> = {
enableTerminal: isOn(row.enable_terminal),
enableTerminalToolbar: isOn(row.enable_terminal_toolbar),
enableCommandHistory: isOn(row.enable_command_history),
};
for (const [key, value] of Object.entries(values)) {
await pluginSettingsRepository.set(
PLUGIN_ID,
"host",
scopeId,
key,
JSON.stringify(value),
);
}
result.hostsMoved++;
}
if (result.moved.length > 0 || result.hostsMoved > 0) {
databaseLogger.info(
`Moved ${result.moved.length} terminal setting(s) and ${result.hostsMoved} host(s) into plugin settings`,
{
operation: "ssh_terminal_settings_migration",
moved: result.moved.join(", "),
hostsMoved: result.hostsMoved,
},
);
}
} catch (error) {
// A failed migration must not stop the backend. The terminal falls back
// to its defaults, which is a visible, recoverable state.
databaseLogger.warn("SSH terminal settings migration failed", {
operation: "ssh_terminal_settings_migration",
error: error instanceof Error ? error.message : String(error),
});
}
return result;
}
-27
View File
@@ -1,27 +0,0 @@
import { pluginEvents, TOPICS } from "../plugins/events.js";
import { sshLogger } from "./logger.js";
/**
* Announces an SSH login so anything watching can react to it.
*
* Was an HTTP POST to the metrics service on localhost:30005. Host metrics is
* a plugin now and has no port, so this publishes on the plugin event bus and
* the plugin subscribes. Fire and forget: a subscriber that throws must never
* fail the login that caused it.
*/
export async function dispatchLoginEvent(
hostId: number,
userId: string,
sshUser: string,
fromIp: string,
): Promise<void> {
try {
pluginEvents.emit(TOPICS.hostLogin, { hostId, userId, sshUser, fromIp });
} catch (err) {
sshLogger.warn("Failed to dispatch login event", {
operation: "login_event_dispatch_error",
hostId,
error: err instanceof Error ? err.message : String(err),
});
}
}
-127
View File
@@ -1,127 +0,0 @@
export const TOUCH_INPUT_SETTING_KEY = "touch_input_settings";
export interface TouchInputSettings {
enabled: boolean;
momentumEnabled: boolean;
dragThresholdPx: number;
maxWheelDeltaPx: number;
momentumSampleWindowMs: number;
releaseGracePeriodMs: number;
minimumVelocityPxPerMs: number;
maximumVelocityPxPerMs: number;
maximumDurationMs: number;
maximumTravelPx: number;
decayTimeMs: number;
pixelsPerTick: number;
maximumFrameIntervalMs: number;
maximumTicksPerFrame: number;
}
export const TOUCH_INPUT_DEFAULTS: TouchInputSettings = {
enabled: true,
momentumEnabled: true,
dragThresholdPx: 6,
maxWheelDeltaPx: 120,
momentumSampleWindowMs: 100,
releaseGracePeriodMs: 120,
minimumVelocityPxPerMs: 0.15,
maximumVelocityPxPerMs: 2.5,
maximumDurationMs: 1_000,
maximumTravelPx: 720,
decayTimeMs: 300,
pixelsPerTick: 12,
maximumFrameIntervalMs: 32,
maximumTicksPerFrame: 4,
};
export type TouchInputNumericKey = Exclude<
keyof TouchInputSettings,
"enabled" | "momentumEnabled"
>;
export const TOUCH_INPUT_NUMERIC_BOUNDS: Record<
TouchInputNumericKey,
{ min: number; max: number; step: number }
> = {
dragThresholdPx: { min: 0, max: 100, step: 1 },
maxWheelDeltaPx: { min: 1, max: 1_000, step: 1 },
momentumSampleWindowMs: { min: 10, max: 1_000, step: 10 },
releaseGracePeriodMs: { min: 0, max: 2_000, step: 10 },
minimumVelocityPxPerMs: { min: 0, max: 10, step: 0.01 },
maximumVelocityPxPerMs: { min: 0.01, max: 20, step: 0.01 },
maximumDurationMs: { min: 0, max: 10_000, step: 100 },
maximumTravelPx: { min: 0, max: 10_000, step: 10 },
decayTimeMs: { min: 1, max: 5_000, step: 10 },
pixelsPerTick: { min: 1, max: 500, step: 1 },
maximumFrameIntervalMs: { min: 1, max: 1_000, step: 1 },
maximumTicksPerFrame: { min: 1, max: 100, step: 1 },
};
export function normalizeTouchInputSettings(
value: unknown,
): TouchInputSettings {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return { ...TOUCH_INPUT_DEFAULTS };
}
const input = value as Record<string, unknown>;
const normalized: TouchInputSettings = { ...TOUCH_INPUT_DEFAULTS };
for (const key of ["enabled", "momentumEnabled"] as const) {
if (typeof input[key] === "boolean") normalized[key] = input[key];
}
for (const [key, bounds] of Object.entries(TOUCH_INPUT_NUMERIC_BOUNDS) as [
TouchInputNumericKey,
{ min: number; max: number },
][]) {
const candidate = input[key];
if (
typeof candidate === "number" &&
Number.isFinite(candidate) &&
candidate >= bounds.min &&
candidate <= bounds.max
) {
normalized[key] = candidate;
}
}
return normalized;
}
export function validateTouchInputSettingsUpdate(
value: unknown,
): string | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return "settings must be an object";
}
const input = value as Record<string, unknown>;
const validKeys = new Set(Object.keys(TOUCH_INPUT_DEFAULTS));
const unknownKey = Object.keys(input).find((key) => !validKeys.has(key));
if (unknownKey) return `Unknown touch input setting: ${unknownKey}`;
for (const key of ["enabled", "momentumEnabled"] as const) {
if (key in input && typeof input[key] !== "boolean") {
return `${key} must be a boolean`;
}
}
for (const [key, bounds] of Object.entries(TOUCH_INPUT_NUMERIC_BOUNDS) as [
TouchInputNumericKey,
{ min: number; max: number },
][]) {
if (!(key in input)) continue;
const candidate = input[key];
if (
typeof candidate !== "number" ||
!Number.isFinite(candidate) ||
candidate < bounds.min ||
candidate > bounds.max
) {
return `${key} must be a number between ${bounds.min} and ${bounds.max}`;
}
}
if (
typeof input.minimumVelocityPxPerMs === "number" &&
typeof input.maximumVelocityPxPerMs === "number" &&
input.minimumVelocityPxPerMs > input.maximumVelocityPxPerMs
) {
return "minimumVelocityPxPerMs must not exceed maximumVelocityPxPerMs";
}
return null;
}
+8
View File
@@ -337,6 +337,14 @@ export type Tab = {
getApplicationCursorKeysMode?: () => boolean;
openShareModal?: () => void;
canShare?: () => boolean;
openFileManager?: () => void;
getShareTarget?: () => {
hostId: number;
sessionId: string;
protocol: "ssh";
tabInstanceId?: string;
} | null;
focus?: () => void;
} | null>;
};
+66 -59
View File
@@ -1,5 +1,6 @@
/* eslint-disable react-refresh/only-export-components */
/* eslint-disable react-hooks/exhaustive-deps */
import type { TabHandle } from "@termix/plugin-sdk/frontend";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import { Separator } from "@/components/separator";
@@ -89,9 +90,21 @@ const CollabPanel = lazy(() =>
const MacrosPanel = lazy(() =>
import("@/sidebar/MacrosPanel").then((m) => ({ default: m.MacrosPanel })),
);
const HistoryPanel = lazy(() =>
import("@/sidebar/HistoryPanel").then((m) => ({ default: m.HistoryPanel })),
const ShareSessionModal = lazy(() =>
import("@/features/session-sharing/ShareSessionModal").then((m) => ({
default: m.ShareSessionModal,
})),
);
/** What a session tab's handle gives the shell's share dialog. */
interface ShareTarget {
hostId: number;
sessionId: string;
protocol: "ssh";
tabInstanceId?: string;
}
const SessionLogsPanel = lazy(() =>
import("@/sidebar/SessionLogsPanel").then((m) => ({
default: m.SessionLogsPanel,
@@ -514,10 +527,7 @@ export function AppShell({
const tabId = activeTabIdRef.current;
if (!tabId) return;
const termRef = terminalRefs.current.get(tabId);
(
termRef?.current as
import("@/features/terminal/Terminal").TerminalHandle | null
)?.reconnect();
(termRef?.current as TabHandle | null)?.reconnect?.();
return;
}
const currentTabs = tabsRef.current;
@@ -608,9 +618,12 @@ export function AppShell({
// Once those panels can themselves be the active tab, activeTabId points at
// the panel and the lookup misses, so remember the last terminal instead.
const [lastTerminalTabId, setLastTerminalTabId] = useState(activeTabId);
const [shareTarget, setShareTarget] = useState<ShareTarget | null>(null);
useEffect(() => {
const active = tabs.find((t) => t.id === activeTabId);
if (active?.type === "terminal") setLastTerminalTabId(active.id);
if (active && getTabType(active.type)?.commandTarget) {
setLastTerminalTabId(active.id);
}
}, [activeTabId, tabs]);
useEffect(() => {
splitModeRef.current = splitMode;
@@ -856,10 +869,7 @@ export function AppShell({
const tabId = paneTabIdsRef.current[next];
if (tabId) {
const termRef = terminalRefs.current.get(tabId);
(
termRef?.current as
import("@/features/terminal/Terminal").TerminalHandle | null
)?.focus();
(termRef?.current as TabHandle | null)?.focus?.();
}
return;
}
@@ -1688,6 +1698,7 @@ export function AppShell({
},
) {
const type = resolveHostTabType(host, preferredType);
if (!type) return;
// --- tmux-monitor --- singleton tab, not a per-host tab
if (type === "tmux_monitor") {
openSingletonTab(type, undefined, host);
@@ -1758,26 +1769,24 @@ export function AppShell({
});
}
function openLocalTerminalTab(): string {
/** A tab type that opens a fresh tab every time (a local shell). */
function openMultiInstanceTab(type: TabType): string {
const instanceId =
typeof crypto.randomUUID === "function"
? crypto.randomUUID()
: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
const id = `local-terminal-${instanceId}`;
const id = `${type}-${instanceId}`;
const titleKey = getTabType(type)?.titleKey;
const title = titleKey ? t(titleKey) : type;
setTabs((current) => {
const count = current.filter(
(tab) => tab.type === "local-terminal",
).length;
const count = current.filter((tab) => tab.type === type).length;
return [
...current,
{
id,
instanceId,
type: "local-terminal",
label:
count === 0
? t("nav.localTerminal")
: `${t("nav.localTerminal")} (${count + 1})`,
type,
label: count === 0 ? title : `${title} (${count + 1})`,
openedAt: Date.now(),
},
];
@@ -1841,9 +1850,9 @@ export function AppShell({
host?: Host,
data?: Record<string, unknown>,
) {
// Local terminals are never singletons, each one is its own shell.
if (type === "local-terminal") {
return openLocalTerminalTab();
// A local shell is never a singleton: each open is its own session.
if (getTabType(type)?.multiInstance) {
return openMultiInstanceTab(type);
}
if (type === "host-manager") {
if (pendingEvent === "host-manager:add-credential") {
@@ -2024,8 +2033,15 @@ export function AppShell({
function openShareForTab(id: string) {
const tab = tabs.find((t) => t.id === id);
if (!tab) return;
const ref = tab.terminalRef?.current;
if (ref?.canShare?.()) {
const ref = tab.terminalRef?.current as TabHandle | null | undefined;
// A session tab either opens its own share dialog or hands over what
// the shell's dialog needs.
const target = (
ref?.getShareTarget as (() => ShareTarget | null) | undefined
)?.();
if (target) {
setShareTarget(target);
} else if (ref?.canShare?.()) {
ref.openShareModal?.();
} else {
toast.error(t("sessionSharing.notReadyToShare"));
@@ -2311,8 +2327,7 @@ export function AppShell({
}
for (const tab of tabs) {
const isTerminal =
tab.type === "terminal" || tab.type === "local-terminal";
const isTerminal = !!getTabType(tab.type)?.ownBackground;
const node = getTabNode(tab.id, isTerminal);
const paneIdx = isSplit ? paneTabIds.indexOf(tab.id) : -1;
const inPane = paneIdx !== -1;
@@ -2373,7 +2388,7 @@ export function AppShell({
}
});
const terminalTabs = tabs.filter((t) => t.type === "terminal");
const terminalTabs = tabs.filter((t) => getTabType(t.type)?.commandTarget);
const topLevelTabs = tabs.filter((tab) => !tab.parentSplitTabId);
function reorderTopLevelTabs(reordered: Tab[]) {
@@ -2402,10 +2417,6 @@ export function AppShell({
*/
// The param deliberately shadows the outer railView so the body reads the
// same whichever dock is rendering.
const newInstanceId = () =>
typeof crypto.randomUUID === "function"
? crypto.randomUUID()
: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
/**
* What plugins may ask of the shell. Rebuilt every render so it always
@@ -2423,12 +2434,7 @@ export function AppShell({
openSingletonTab(type, undefined, undefined, options?.data),
closeTab: (tabId) => closeTab(tabId),
renameTab: (tabId, label) => renameTab(tabId, label),
openTerminalTab: (host, path) =>
openTab(host, "terminal", {
instanceId: newInstanceId(),
restoredSessionId: null,
initialFilePath: path,
}),
openRailView: (id) => {
setRailView(id as RailView);
setSidebarOpen(true);
@@ -2540,15 +2546,6 @@ export function AppShell({
</div>
)}
{railView === "history" && (
<div className="flex flex-col flex-1 min-h-0 overflow-y-auto">
<HistoryPanel
terminalTabs={terminalTabs}
activeTabId={targetTerminalTabId}
/>
</div>
)}
{railView === "split-screen" && (
<div className="flex-1 min-h-0 overflow-y-auto">
<SplitScreenPanel
@@ -2579,6 +2576,9 @@ export function AppShell({
className={`flex flex-col flex-1 min-h-0 overflow-y-auto ${shown ? "" : "hidden"}`}
>
<Panel
targetTab={terminalTabs.find(
(tab) => tab.id === targetTerminalTabId,
)}
active={shown}
shell={panelShell}
setEditing={setSidebarEditing}
@@ -2673,16 +2673,10 @@ export function AppShell({
serverTunnels: [],
quickActions: [],
};
const instanceId =
typeof crypto.randomUUID === "function"
? crypto.randomUUID()
: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
openTab(host, "terminal", {
instanceId,
restoredSessionId: null,
void invokeAction("terminal.open", host, {
joinSharedSessionId: session.sessionId,
joinShareId: session.shareId,
savedLabel: t("connections.sharedSessionLabel", {
label: t("connections.sharedSessionLabel", {
hostName: session.hostName,
}),
});
@@ -3053,7 +3047,7 @@ export function AppShell({
{tabsByPortalOrder.map((tab) => {
const tabNode = getTabNode(
tab.id,
tab.type === "terminal" || tab.type === "local-terminal",
!!getTabType(tab.type)?.ownBackground,
);
const paneIdx = isSplit ? paneTabIds.indexOf(tab.id) : -1;
const inPane = paneIdx !== -1;
@@ -3129,6 +3123,19 @@ export function AppShell({
</div>
</div>
{shareTarget && (
<Suspense fallback={null}>
<ShareSessionModal
open
onClose={() => setShareTarget(null)}
hostId={shareTarget.hostId}
sessionId={shareTarget.sessionId}
protocol={shareTarget.protocol}
tabInstanceId={shareTarget.tabInstanceId}
/>
</Suspense>
)}
{commandPaletteOpen && (
<Suspense fallback={null}>
<CommandPalette
@@ -3148,8 +3155,8 @@ export function AppShell({
].includes(type)
) {
openSingletonTab(type, pendingEvent);
} else if (type === "local-terminal") {
openLocalTerminalTab();
} else if (getTabType(type)?.multiInstance) {
openMultiInstanceTab(type);
} else if (type === "tmux_monitor") {
// --- tmux-monitor --- singleton tab, optionally preselecting a host
openSingletonTab(
+2
View File
@@ -47,6 +47,8 @@ export interface CollabRoomDetail {
stage: CollabStage;
controllerUserId: string | null;
controlRequests: CollabControlRequest[];
/** Where live room events are served, as /plugin-ws/<id>/<path>. */
eventsWsPath: string;
}
export interface CollabControlRequest {
-63
View File
@@ -1,63 +0,0 @@
import { authApi, handleApiError } from "@/main-axios";
// COMMAND HISTORY API
// ============================================================================
export async function saveCommandToHistory(
hostId: number,
command: string,
): Promise<{ id: number; command: string; executedAt: string }> {
try {
const response = await authApi.post("/terminal/command_history", {
hostId,
command,
});
return response.data;
} catch (error) {
throw handleApiError(error, "save command to history");
}
}
export async function getCommandHistory(
hostId: number,
limit: number = 100,
): Promise<string[]> {
try {
const response = await authApi.get(`/terminal/command_history/${hostId}`, {
params: { limit },
});
return response.data;
} catch (error) {
throw handleApiError(error, "fetch command history");
}
}
export async function deleteCommandFromHistory(
hostId: number,
command: string,
): Promise<{ success: boolean }> {
try {
const response = await authApi.post("/terminal/command_history/delete", {
hostId,
command,
});
return response.data;
} catch (error) {
throw handleApiError(error, "delete command from history");
}
}
export async function clearCommandHistory(
hostId: number,
): Promise<{ success: boolean }> {
try {
const response = await authApi.delete(
`/terminal/command_history/${hostId}`,
);
return response.data;
} catch (error) {
throw handleApiError(error, "clear command history");
}
}
// ============================================================================
+6
View File
@@ -91,6 +91,12 @@ export async function getActiveSessions(): Promise<ActiveSessionInfo[]> {
});
}
/** How long a detached terminal session is kept, in minutes. */
export async function getSessionTimeoutMinutes(): Promise<number> {
const response = await authApi.get("/open-tabs/session-timeout");
return Number(response.data?.minutes) || 30;
}
// ============================================================================
// USER PREFERENCES API
// ============================================================================
-97
View File
@@ -60,30 +60,6 @@ export async function getSessionTimeout(): Promise<{ timeoutHours: number }> {
}
}
// How long a detached terminal session is kept alive server-side.
export async function getTerminalSessionSettings(): Promise<{
timeoutMinutes: number;
enabled: boolean;
}> {
try {
const response = await authApi.get("/terminal/session_settings");
return response.data;
} catch (error) {
handleApiError(error, "fetch terminal session settings");
}
}
export async function updateTerminalSessionSettings(input: {
timeoutMinutes?: number;
enabled?: boolean;
}): Promise<void> {
try {
await authApi.patch("/terminal/session_settings", input);
} catch (error) {
handleApiError(error, "update terminal session settings");
}
}
export interface StepCaSettings {
configured: boolean;
caUrl: string;
@@ -190,79 +166,6 @@ export async function updateAnalyticsEnabled(
// TERMINAL IMAGE STORAGE SETTINGS
// ============================================================================
export type TerminalImageStorageMode = "auto" | "local" | "remote-sftp";
/** Public settings shape: the backend-internal localDir is never returned. */
export interface TerminalImageStorageSettings {
mode: TerminalImageStorageMode;
hostPath: string;
ttlMs: number;
maxCount: number;
maxBytes: number;
localMappingConfigured: boolean;
}
export interface TerminalImageStorageSettingsUpdate {
mode?: TerminalImageStorageMode;
localDir?: string;
hostPath?: string;
ttlMs?: number;
maxCount?: number;
maxBytes?: number;
}
export interface TerminalImageStorageTestResult {
mode: TerminalImageStorageMode;
connected: boolean;
remoteSftpAvailable: boolean;
localHostVisible: boolean | null;
selectedMode: "local" | "remote-sftp" | "unavailable";
localMappingConfigured: boolean;
}
export async function getTerminalImageStorageSettings(): Promise<TerminalImageStorageSettings> {
try {
const response = await authApi.get(
"/users/terminal-image-storage-settings",
);
return response.data;
} catch (error) {
handleApiError(error, "fetch terminal image storage settings");
}
}
export async function updateTerminalImageStorageSettings(
settings: TerminalImageStorageSettingsUpdate,
): Promise<TerminalImageStorageSettings> {
try {
const response = await authApi.patch(
"/users/terminal-image-storage-settings",
settings,
);
return response.data;
} catch (error) {
handleApiError(error, "update terminal image storage settings");
}
}
export async function testTerminalImageStorage(
instanceId: string,
): Promise<TerminalImageStorageTestResult> {
try {
const response = await authApi.post(
"/users/terminal-image-storage-settings/test",
{ instanceId },
);
return response.data;
} catch (error) {
handleApiError(error, "test terminal image storage");
}
}
// ============================================================================
// WHITE LABEL BRANDING
// ============================================================================
export interface BrandingSettings {
appName: string;
tagline: string;
-28
View File
@@ -1,28 +0,0 @@
import { authApi, handleApiError } from "@/main-axios";
import {
normalizeTouchInputSettings,
type TouchInputSettings,
} from "@/types/touch-input-settings";
export async function getTouchInputSettings(): Promise<TouchInputSettings> {
try {
const response = await authApi.get("/users/touch-input-settings");
return normalizeTouchInputSettings(response.data);
} catch (error) {
handleApiError(error, "fetch touch input settings");
}
}
export async function updateTouchInputSettings(
settings: TouchInputSettings,
): Promise<TouchInputSettings> {
try {
const response = await authApi.patch(
"/users/touch-input-settings",
settings,
);
return normalizeTouchInputSettings(response.data);
} catch (error) {
handleApiError(error, "update touch input settings");
}
}
-24
View File
@@ -343,30 +343,6 @@ export async function updatePasswordResetAllowed(
}
}
export async function getCommandHistoryEnabled(): Promise<{
enabled: boolean;
}> {
try {
const response = await authApi.get("/users/command-history-enabled");
return response.data;
} catch (error) {
handleApiError(error, "get command history enabled");
}
}
export async function updateCommandHistoryEnabled(
enabled: boolean,
): Promise<{ enabled: boolean }> {
try {
const response = await authApi.patch("/users/command-history-enabled", {
enabled,
});
return response.data;
} catch (error) {
handleApiError(error, "update command history enabled");
}
}
export async function updateOIDCConfig(
config: Record<string, unknown>,
): Promise<Record<string, unknown>> {
+14 -21
View File
@@ -16,16 +16,12 @@ import {
type TerminalDefaults,
} from "@/lib/connection-defaults";
type DefaultsKind = "terminal" | "rdp";
interface ConnectionDefaultsContextValue {
ready: boolean;
terminal: TerminalDefaults;
rdp: RemoteDesktopDefaults;
saveDefaults: (
kind: DefaultsKind,
value: TerminalDefaults | RemoteDesktopDefaults,
) => Promise<void>;
saveTerminalDefaults: (value: TerminalDefaults) => Promise<void>;
saveRdpDefaults: (value: RemoteDesktopDefaults) => Promise<void>;
}
const ConnectionDefaultsContext = createContext<ConnectionDefaultsContextValue>(
@@ -33,7 +29,8 @@ const ConnectionDefaultsContext = createContext<ConnectionDefaultsContextValue>(
ready: true,
terminal: {},
rdp: {},
saveDefaults: async () => {},
saveTerminalDefaults: async () => {},
saveRdpDefaults: async () => {},
},
);
@@ -63,22 +60,18 @@ export function ConnectionDefaultsProvider({
};
}, []);
const saveDefaults = useCallback(
async (
kind: DefaultsKind,
value: TerminalDefaults | RemoteDesktopDefaults,
) => {
const serialized = JSON.stringify(value);
await saveUserPreferences({ [`${kind}Defaults`]: serialized });
if (kind === "terminal") setTerminal(value as TerminalDefaults);
if (kind === "rdp") setRdp(value as RemoteDesktopDefaults);
},
[],
);
const saveTerminalDefaults = useCallback(async (value: TerminalDefaults) => {
await saveUserPreferences({ terminalDefaults: JSON.stringify(value) });
setTerminal(value);
}, []);
const saveRdpDefaults = useCallback(async (value: RemoteDesktopDefaults) => {
await saveUserPreferences({ rdpDefaults: JSON.stringify(value) });
setRdp(value);
}, []);
const value = useMemo(
() => ({ ready, terminal, rdp, saveDefaults }),
[ready, terminal, rdp, saveDefaults],
() => ({ ready, terminal, rdp, saveTerminalDefaults, saveRdpDefaults }),
[ready, terminal, rdp, saveTerminalDefaults, saveRdpDefaults],
);
return (
<ConnectionDefaultsContext.Provider value={value}>
+4 -1
View File
@@ -384,7 +384,10 @@ function QuickActionsCard({
{pinnedHosts.slice(0, 4).map((host) => (
<button
key={host.id}
onClick={() => onOpenTab(host, getDefaultConnectionTab(host))}
onClick={() => {
const type = getDefaultConnectionTab(host);
if (type) onOpenTab(host, type);
}}
className="group/btn flex items-center gap-2.5 px-4 py-2 hover:bg-muted transition-colors cursor-pointer border-b border-border last:border-b-0"
>
<div className="size-7 border border-border bg-muted flex items-center justify-center shrink-0 group-hover/btn:bg-accent-brand/20 group-hover/btn:border-accent-brand/40 transition-colors">
+4 -5
View File
@@ -1,6 +1,5 @@
import React, { useEffect, useState } from "react";
import { TabProvider } from "@/shell/TabContext.tsx";
import { CommandHistoryProvider } from "@/features/terminal/command-history/CommandHistoryContext.tsx";
import { SidebarProvider } from "@/components/sidebar.tsx";
import {
getSSHHosts,
@@ -173,7 +172,7 @@ export const FullScreenAppWrapper: React.FC<FullScreenAppWrapperProps> = ({
return (
<SidebarProvider>
<TabProvider>
<CommandHistoryProvider>
<>
<div
className="w-full h-screen overflow-hidden flex items-center justify-center"
style={{ backgroundColor: "var(--bg-base)" }}
@@ -187,7 +186,7 @@ export const FullScreenAppWrapper: React.FC<FullScreenAppWrapperProps> = ({
offset={20}
/>
</div>
</CommandHistoryProvider>
</>
</TabProvider>
</SidebarProvider>
);
@@ -202,14 +201,14 @@ export const FullScreenAppWrapper: React.FC<FullScreenAppWrapperProps> = ({
return (
<SidebarProvider>
<TabProvider>
<CommandHistoryProvider>
<>
<div
className="w-full h-screen overflow-hidden"
style={{ backgroundColor: "var(--bg-base)" }}
>
{children(hostConfig, phase)}
</div>
</CommandHistoryProvider>
</>
</TabProvider>
</SidebarProvider>
);
+1 -1
View File
@@ -93,7 +93,7 @@ export default function CollabGuestView() {
<GuestTerminalView
key={stage.shareId}
share={{ permissionLevel: "read-only" }}
wsQuery={`roomGuestToken=${encodeURIComponent(token ?? "")}`}
wsPath={stage.wsPath ?? ""}
hideBadges
onParticipantsChange={(participants: SessionParticipantInfo[]) =>
setParticipantCount(participants.length)
+39 -41
View File
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { PluginComponent } from "@/plugin-host/component-registry";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import {
@@ -31,8 +32,6 @@ import {
} from "@/components/alert-dialog";
import { Input } from "@/components/input";
import { CollabMembersSidebar } from "./CollabMembersSidebar";
import { Terminal } from "@/features/terminal/Terminal";
import { CommandHistoryProvider } from "@/features/terminal/command-history/CommandHistoryContext";
import {
RemoteDisplay,
createRemoteSessionToken,
@@ -40,7 +39,7 @@ import {
import { getSSHHosts, getUserList, type SSHHostWithStatus } from "@/main-axios";
import { getRoles } from "@/api/rbac-api";
import type { Role } from "@/main-axios";
import { pluginWsUrl } from "@/lib/plugin-transport";
import { pluginWsUrlForPath } from "@/lib/plugin-transport";
import { getErrorMessage } from "@/lib/error-message";
import {
deleteCollabRoom,
@@ -65,8 +64,9 @@ const POLL_FALLBACK_MS = 15000;
// The terminal socket the ssh-terminal plugin serves. Authentication rides on
// the jwt cookie the way every terminal WS connection does.
async function roomEventsWsUrl(): Promise<string | null> {
const target = await pluginWsUrl("ssh-terminal", "/terminal");
async function roomEventsWsUrl(roomId: string): Promise<string | null> {
const { eventsWsPath } = await getCollabRoom(roomId);
const target = await pluginWsUrlForPath(eventsWsPath);
return target?.url ?? null;
}
@@ -186,7 +186,7 @@ export function CollabRoomTab({
const connect = async () => {
if (cancelled) return;
try {
const url = await roomEventsWsUrl();
const url = await roomEventsWsUrl(roomId);
if (cancelled) return;
if (!url) throw new Error("No terminal endpoint is available");
ws = new WebSocket(url);
@@ -673,23 +673,22 @@ export function CollabRoomTab({
<div className="relative flex-1 min-w-0 min-h-0">
{draft ? (
draft.protocol === "ssh" ? (
<CommandHistoryProvider>
<Terminal
hostConfig={{
...draft.host,
id: Number(draft.host.id),
ip: draft.host.ip,
port: draft.host.port,
username: draft.host.username,
instanceId: `collab-present-${roomId}`,
}}
isVisible={isVisible}
disableAutoFocus={false}
onSessionReady={(sessionId) =>
void registerStage("ssh", sessionId, Number(draft.host.id))
}
/>
</CommandHistoryProvider>
<PluginComponent
id="terminal.view"
hostConfig={{
...draft.host,
id: Number(draft.host.id),
ip: draft.host.ip,
port: draft.host.port,
username: draft.host.username,
instanceId: `collab-present-${roomId}`,
}}
isVisible={isVisible}
disableAutoFocus={false}
onSessionReady={(sessionId) =>
void registerStage("ssh", sessionId, Number(draft.host.id))
}
/>
) : (
<RemoteDisplay
token={draft.token}
@@ -716,23 +715,22 @@ export function CollabRoomTab({
</div>
)}
{stage.protocol === "ssh" ? (
<CommandHistoryProvider>
<Terminal
hostConfig={{
id: stage.hostId ?? undefined,
name: detail?.room.name ?? "stage",
ip: "",
port: 0,
username: "",
authType: "none",
instanceId: `collab-view-${roomId}-${stage.shareId}`,
joinShareId: stage.shareId,
joinSharedSessionId: stage.sessionId ?? null,
}}
isVisible={isVisible}
disableAutoFocus
/>
</CommandHistoryProvider>
<PluginComponent
id="terminal.view"
hostConfig={{
id: stage.hostId ?? undefined,
name: detail?.room.name ?? "stage",
ip: "",
port: 0,
username: "",
authType: "none",
instanceId: `collab-view-${roomId}-${stage.shareId}`,
joinShareId: stage.shareId,
joinSharedSessionId: stage.sessionId ?? null,
}}
isVisible={isVisible}
disableAutoFocus
/>
) : stage.connectParams?.token ? (
<RemoteDisplay
key={stage.connectParams.token}
@@ -785,7 +783,7 @@ export function CollabRoomTab({
)}
{filteredHosts.map((host) => {
const protocols: Array<"ssh" | "rdp" | "vnc" | "telnet"> = [];
if (host.enableTerminal || host.enableSsh) protocols.push("ssh");
if (host.enableSsh) protocols.push("ssh");
if (host.enableRdp) protocols.push("rdp");
if (host.enableVnc) protocols.push("vnc");
if (host.enableTelnet) protocols.push("telnet");
@@ -38,10 +38,13 @@ function QuickConnectWidget({
const [loading, setLoading] = useState(true);
const targets = quickConnectTargets(useHostActions());
// An empty list means the first tools plugins offer, in their order.
const types =
config.connectionTypes.length > 0
? config.connectionTypes
: (["terminal"] as QuickConnectType[]);
: (targets
.slice(0, 2)
.map((target) => target.type) as QuickConnectType[]);
useEffect(() => {
getSSHHosts()
@@ -131,7 +134,7 @@ registerWidget<QuickConnectConfig>({
icon: <Zap size={14} />,
defaultConfig: {
hostIds: [],
connectionTypes: ["terminal", "files"],
connectionTypes: [],
showStatus: true,
layout: "list",
},
@@ -1,4 +1,5 @@
import { useState, useRef, useEffect } from "react";
import { PluginComponent } from "@/plugin-host/component-registry";
import { Terminal as TerminalIcon, Play } from "lucide-react";
import { useTranslation } from "react-i18next";
import { registerWidget } from "./WidgetRegistry";
@@ -7,8 +8,7 @@ import type {
WidgetComponentProps,
} from "@/types/homepage-types";
import { GRID_SIZE } from "@/types/homepage-types";
import { CommandHistoryProvider } from "@/features/terminal/command-history/CommandHistoryContext";
import { Terminal, type TerminalHandle } from "@/features/terminal/Terminal";
import type { TabHandle as TerminalHandle } from "@termix/plugin-sdk/frontend";
import { getSSHHosts } from "@/api/ssh-host-management-api";
import type { SSHHostWithStatus } from "@/main-axios";
import { WidgetTitle } from "./WidgetTitle";
@@ -136,17 +136,16 @@ function SshTerminalWidget({
>
<WidgetTitle title={widget.title} icon={<TerminalIcon size={11} />} />
<div className="flex-1 overflow-hidden">
<CommandHistoryProvider>
<Terminal
ref={terminalRef}
hostConfig={hostConfig}
isVisible={true}
showTitle={false}
splitScreen={false}
disableAutoFocus={true}
onClose={() => setStarted(false)}
/>
</CommandHistoryProvider>
<PluginComponent
id="terminal.view"
handleRef={terminalRef}
hostConfig={hostConfig}
isVisible={true}
showTitle={false}
splitScreen={false}
disableAutoFocus={true}
onClose={() => setStarted(false)}
/>
</div>
</div>
);
@@ -1,197 +0,0 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { FitAddon } from "@xterm/addon-fit";
import { ClipboardAddon } from "@xterm/addon-clipboard";
import { useXTerm } from "react-xtermjs";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import { useTheme } from "@/components/theme-provider";
import { resolveTermixThemeColors } from "@/features/terminal/terminal-theme";
import { DEFAULT_TERMINAL_CONFIG, TERMINAL_FONTS } from "@/lib/terminal-themes";
import { getMacLineNavigationSequence } from "@/lib/mac-line-navigation";
import { ensureTerminalFontsLoaded } from "@/features/terminal/terminal-global-styles";
import {
handleTerminalClipboardKeyEvent,
createTerminalContextMenuHandler,
} from "@/features/terminal/terminal-clipboard";
import { RobustClipboardProvider } from "@/lib/clipboard-provider";
import { copyToClipboard, readFromClipboard } from "@/lib/clipboard";
export function LocalTerminal({
instanceId,
isVisible,
}: {
instanceId: string;
isVisible: boolean;
}) {
const { t } = useTranslation();
// Read via a ref inside the session-lifecycle effect below so a language
// change (which gives react-i18next a new `t` identity) doesn't tear down
// the running local shell and spawn a new session.
const tRef = useRef(t);
tRef.current = t;
const { theme: appTheme } = useTheme();
const { instance: terminal, ref: xtermRef } = useXTerm();
const fitAddonRef = useRef<FitAddon | null>(null);
const sessionIdRef = useRef<string | null>(null);
const [isWindows, setIsWindows] = useState(false);
const [shell, setShell] = useState<"default" | "wsl">("default");
useEffect(() => {
window.electronAPI?.getPlatform().then((platform) => {
setIsWindows(platform === "win32");
});
}, []);
const fit = useCallback(() => {
const fitAddon = fitAddonRef.current;
const sessionId = sessionIdRef.current;
if (!terminal || !fitAddon) return;
fitAddon.fit();
if (sessionId) {
window.electronAPI.resizeLocalTerminal(
sessionId,
terminal.cols,
terminal.rows,
);
}
}, [terminal]);
useEffect(() => {
if (!terminal) return;
const colors = resolveTermixThemeColors("termix", appTheme);
const font = TERMINAL_FONTS.find(
(item) => item.value === DEFAULT_TERMINAL_CONFIG.fontFamily,
);
ensureTerminalFontsLoaded(font?.value ?? TERMINAL_FONTS[0].value);
terminal.options.theme = colors;
terminal.options.fontFamily = font?.fallback ?? TERMINAL_FONTS[0].fallback;
terminal.options.fontSize = DEFAULT_TERMINAL_CONFIG.fontSize;
}, [appTheme, terminal]);
useEffect(() => {
if (!terminal || !window.electronAPI?.isElectron) return;
const fitAddon = new FitAddon();
const clipboardProvider = new RobustClipboardProvider();
const clipboardAddon = new ClipboardAddon(undefined, clipboardProvider);
fitAddonRef.current = fitAddon;
terminal.loadAddon(fitAddon);
terminal.loadAddon(clipboardAddon);
fitAddon.fit();
async function writeTextToClipboard(text: string): Promise<boolean> {
const ok = await copyToClipboard(text);
if (!ok) toast.error(tRef.current("terminal.clipboardWriteFailed"));
return ok;
}
async function readTextFromClipboard(): Promise<string> {
const text = await readFromClipboard();
if (!text) toast.error(tRef.current("terminal.clipboardReadFailed"));
return text;
}
const clipboardActions = { writeTextToClipboard, readTextFromClipboard };
terminal.attachCustomKeyEventHandler((e: KeyboardEvent): boolean => {
if (e.type !== "keydown") return true;
const sequence = getMacLineNavigationSequence(e);
if (sequence) {
e.preventDefault();
e.stopPropagation();
terminal.input(sequence, true);
return false;
}
// No native "paste" event listener here (unlike the SSH terminal), so
// plain Ctrl/Cmd+V reads the clipboard explicitly rather than relying
// on the browser's own paste event.
return handleTerminalClipboardKeyEvent(e, terminal, clipboardActions, {
plainPasteMode: "explicit",
});
});
const handleContextMenu = createTerminalContextMenuHandler(
terminal,
clipboardActions,
);
const element = xtermRef.current;
element?.addEventListener("contextmenu", handleContextMenu);
let disposed = false;
let removeData = () => {};
let removeExit = () => {};
const input = terminal.onData((data) => {
const sessionId = sessionIdRef.current;
if (sessionId) window.electronAPI.writeLocalTerminal(sessionId, data);
});
window.electronAPI
.startLocalTerminal({ cols: terminal.cols, rows: terminal.rows, shell })
.then(({ sessionId }) => {
if (disposed) {
window.electronAPI.closeLocalTerminal(sessionId);
return;
}
sessionIdRef.current = sessionId;
removeData = window.electronAPI.onLocalTerminalData(sessionId, (data) =>
terminal.write(data),
);
removeExit = window.electronAPI.onLocalTerminalExit(
sessionId,
(exitCode) => {
sessionIdRef.current = null;
terminal.write(
`\r\n\x1b[33mProcess exited (${exitCode})\x1b[0m\r\n`,
);
},
);
return window.electronAPI.readyLocalTerminal(sessionId);
})
.catch((error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
terminal.write(`\r\n\x1b[31m${message}\x1b[0m\r\n`);
});
const observer = new ResizeObserver(() => fit());
if (xtermRef.current) observer.observe(xtermRef.current);
return () => {
disposed = true;
terminal.attachCustomKeyEventHandler(() => true);
observer.disconnect();
input.dispose();
removeData();
removeExit();
element?.removeEventListener("contextmenu", handleContextMenu);
clipboardProvider.dispose();
const sessionId = sessionIdRef.current;
sessionIdRef.current = null;
if (sessionId) window.electronAPI.closeLocalTerminal(sessionId);
fitAddonRef.current = null;
fitAddon.dispose();
};
}, [fit, instanceId, shell, terminal, xtermRef]);
useEffect(() => {
if (isVisible) requestAnimationFrame(fit);
}, [fit, isVisible]);
return (
<div className="flex h-full w-full flex-col bg-background">
{isWindows && (
<div className="flex justify-end border-b border-border px-2 py-1">
<select
aria-label="Local terminal shell"
className="rounded border border-border bg-background px-2 py-1 text-xs text-foreground"
value={shell}
onChange={(event) =>
setShell(event.target.value === "wsl" ? "wsl" : "default")
}
>
<option value="default">PowerShell</option>
<option value="wsl">WSL</option>
</select>
</div>
)}
<div ref={xtermRef} className="min-h-0 flex-1 p-2" />
</div>
);
}
+2 -2
View File
@@ -12,9 +12,9 @@ import { TriangleAlert } from "lucide-react";
import { isElectron } from "@/lib/electron";
import { websocketAuthProtocols } from "@/lib/ws-auth";
import { useTheme } from "@/components/theme-provider";
import { resolveTermixThemeColors } from "@/features/terminal/terminal-theme";
import { resolveTermixThemeColors } from "@/lib/terminal-look/terminal-theme";
import { DEFAULT_TERMINAL_CONFIG, TERMINAL_FONTS } from "@/lib/terminal-themes";
import { ensureTerminalFontsLoaded } from "@/features/terminal/terminal-global-styles";
import { ensureTerminalFontsLoaded } from "@/lib/terminal-look/terminal-global-styles";
import type { SerialConfig } from "@/types/ui-types";
import type { SerialHandle } from "./serial-types";
@@ -9,7 +9,7 @@ import {
type ShareLinkErrorKind,
} from "@/api/session-sharing-api";
import { SimpleLoader } from "@/lib/SimpleLoader.tsx";
import { pluginWsUrl } from "@/lib/plugin-transport";
import { pluginWsUrlForPath } from "@/lib/plugin-transport";
import { RemoteDisplay } from "@/features/remote-display/RemoteDisplay";
const PING_INTERVAL_MS = 30000;
@@ -25,8 +25,8 @@ interface TerminalWsMessage {
// A shared session link is always resolved against the desktop app's
// embedded local backend -- joining a session hosted on someone else's
// remote server isn't supported from the desktop app today.
async function resolveTerminalWsBaseUrl(): Promise<string> {
const target = await pluginWsUrl("ssh-terminal", "/terminal");
async function resolveTerminalWsUrl(wsPath: string): Promise<string> {
const target = await pluginWsUrlForPath(wsPath);
if (!target) throw new Error("No terminal endpoint is available");
return target.url;
}
@@ -107,12 +107,13 @@ function CenteredMessage({
export function GuestTerminalView({
share,
wsQuery,
wsPath,
hideBadges = false,
onParticipantsChange,
}: {
share: Pick<ResolvedShareLink, "permissionLevel">;
wsQuery: string;
/** The socket path the backend handed out, query string included. */
wsPath: string;
/** Suppress the built-in overlay badges when the host page renders its own. */
hideBadges?: boolean;
onParticipantsChange?: (participants: SessionParticipantInfo[]) => void;
@@ -144,10 +145,9 @@ export function GuestTerminalView({
let cancelled = false;
let ws: WebSocket | null = null;
resolveTerminalWsBaseUrl().then((baseWsUrl) => {
resolveTerminalWsUrl(wsPath).then((url) => {
if (cancelled) return;
const separator = baseWsUrl.includes("?") ? "&" : "?";
ws = new WebSocket(`${baseWsUrl}${separator}${wsQuery}`);
ws = new WebSocket(url);
wsRef.current = ws;
ws.onopen = () => {
@@ -209,7 +209,7 @@ export function GuestTerminalView({
};
// Deliberately runs once terminal mounts - share/token/permission are stable for the view's lifetime.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [terminal, wsQuery]);
}, [terminal, wsPath]);
return (
<div className="relative w-full h-full">
@@ -354,10 +354,7 @@ export default function SharedSessionView() {
share &&
linkToken &&
(share.protocol === "ssh" ? (
<GuestTerminalView
share={share}
wsQuery={`shareToken=${encodeURIComponent(linkToken)}`}
/>
<GuestTerminalView share={share} wsPath={share.wsPath} />
) : (
<GuestGuacamoleView share={share} />
))}
@@ -1,525 +0,0 @@
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
ChevronUp,
ChevronDown,
ChevronLeft,
ChevronRight,
Clipboard,
Pencil,
X,
Plus,
RotateCcw,
} from "lucide-react";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from "@/components/sheet";
import type { TerminalHandle } from "./terminal-types";
interface MobileTerminalKeyboardProps {
terminalRef: React.RefObject<TerminalHandle | null>;
}
const CTRL_MAP: Record<string, string> = {
a: "\x01",
c: "\x03",
d: "\x04",
k: "\x0B",
l: "\x0C",
r: "\x12",
u: "\x15",
w: "\x17",
z: "\x1A",
};
const DEFAULT_QUICK_KEYS = [
"/",
"|",
"~",
"-",
"_",
"#",
"\\",
'"',
"'",
";",
":",
"!",
"&",
];
const LS_KEY = "termix:mobileQuickKeys";
function loadQuickKeys(): string[] {
try {
const raw = localStorage.getItem(LS_KEY);
if (raw) {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed) && parsed.every((v) => typeof v === "string"))
return parsed;
}
} catch {
/* ignore */
}
return DEFAULT_QUICK_KEYS;
}
function saveQuickKeys(keys: string[]) {
try {
localStorage.setItem(LS_KEY, JSON.stringify(keys));
} catch {
/* ignore */
}
}
// Shared button styles
const KEY_BASE =
"flex items-center justify-center rounded border transition-colors select-none touch-none active:scale-95 shrink-0";
const KEY_NORMAL = "border-border bg-muted/50 text-foreground hover:bg-muted";
const KEY_ACTIVE =
"border-accent-brand bg-accent-brand/20 text-accent-brand shadow-[0_0_0_1px_color-mix(in_oklab,var(--accent-brand)_30%,transparent)]";
const KEY_MD = "h-9 px-3 min-w-[2.75rem] text-xs font-medium";
const KEY_SM = "h-9 w-9";
const SEP = "w-px h-5 bg-border mx-0.5 shrink-0";
// --- QuickKeysSheet ---
interface QuickKeysSheetProps {
open: boolean;
onOpenChange: (open: boolean) => void;
quickKeys: string[];
onUpdateKeys: (keys: string[]) => void;
}
function QuickKeysSheet({
open,
onOpenChange,
quickKeys,
onUpdateKeys,
}: QuickKeysSheetProps) {
const { t } = useTranslation();
const [newSymbol, setNewSymbol] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (!open) setNewSymbol("");
}, [open]);
function addKey() {
const sym = newSymbol.trim();
if (!sym || quickKeys.includes(sym) || sym.length > 8) {
setNewSymbol("");
return;
}
onUpdateKeys([...quickKeys, sym]);
setNewSymbol("");
inputRef.current?.focus();
}
function removeKey(index: number) {
onUpdateKeys(quickKeys.filter((_, i) => i !== index));
}
function resetDefaults() {
onUpdateKeys(DEFAULT_QUICK_KEYS);
}
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
side="bottom"
className="max-h-[70vh] flex flex-col border-border"
>
<SheetHeader className="flex-row items-start justify-between pr-8">
<div>
<SheetTitle>{t("mobileKeyboard.quickKeysTitle")}</SheetTitle>
<SheetDescription>
{t("mobileKeyboard.quickKeysDesc")}
</SheetDescription>
</div>
<Button
variant="ghost"
size="sm"
className="text-xs"
onClick={resetDefaults}
>
<RotateCcw className="size-3 mr-1" />
{t("mobileKeyboard.resetDefaults")}
</Button>
</SheetHeader>
<div className="flex-1 overflow-y-auto px-4 py-2">
<div className="flex flex-wrap gap-2">
{quickKeys.map((sym, i) => (
<div
key={i}
className="flex items-center gap-1 h-8 pl-3 pr-1 rounded border border-border bg-muted/50 text-xs font-medium text-foreground"
>
<span className="font-mono">{sym}</span>
<button
className="size-5 flex items-center justify-center rounded hover:bg-destructive hover:text-destructive-foreground transition-colors"
onClick={() => removeKey(i)}
title={t("mobileKeyboard.removeQuickKey")}
>
<X className="size-3" />
</button>
</div>
))}
</div>
</div>
<div className="flex gap-2 px-4 py-2 border-t border-border">
<Input
ref={inputRef}
value={newSymbol}
onChange={(e) => setNewSymbol(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") addKey();
}}
maxLength={8}
placeholder={t("mobileKeyboard.quickKeyPlaceholder")}
className="h-9 text-xs font-mono"
/>
<Button
variant="outline"
size="sm"
className="h-9 shrink-0"
onClick={addKey}
disabled={!newSymbol.trim() || quickKeys.includes(newSymbol.trim())}
>
<Plus className="size-3.5 mr-1" />
{t("mobileKeyboard.addQuickKey")}
</Button>
</div>
<SheetFooter className="pt-0">
<Button className="w-full" onClick={() => onOpenChange(false)}>
{t("mobileKeyboard.done")}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}
// --- CtrlPanel ---
interface CtrlPanelProps {
onSend: (letter: string) => void;
}
function CtrlPanel({ onSend }: CtrlPanelProps) {
const CTRL_KEYS = ["c", "d", "l", "u", "z", "a", "r", "w", "k"];
return (
<div className="flex items-center gap-1 px-2 py-1 bg-muted/30 border-t border-border overflow-x-auto">
{CTRL_KEYS.map((k) => (
<button
key={k}
className={cn(KEY_BASE, KEY_NORMAL, KEY_MD, "font-mono")}
onPointerDown={(e) => {
e.preventDefault();
onSend(k);
}}
title={`Ctrl+${k.toUpperCase()}`}
>
^{k.toUpperCase()}
</button>
))}
</div>
);
}
// --- MobileTerminalKeyboard ---
export function MobileTerminalKeyboard({
terminalRef,
}: MobileTerminalKeyboardProps) {
const { t } = useTranslation();
const [ctrlActive, setCtrlActive] = useState(false);
const [shiftActive, setShiftActive] = useState(false);
const [quickKeys, setQuickKeys] = useState<string[]>(loadQuickKeys);
const [sheetOpen, setSheetOpen] = useState(false);
function send(seq: string) {
terminalRef.current?.sendInput?.(seq);
}
async function handlePaste() {
try {
const text = window.electronClipboard
? await window.electronClipboard.readText()
: ((await navigator.clipboard?.readText?.()) ?? "");
if (text) terminalRef.current?.paste?.(text);
else toast.error(t("terminal.clipboardReadFailed"));
} catch {
toast.error(t("terminal.clipboardReadFailed"));
}
}
function toggleCtrl() {
setCtrlActive((v) => !v);
setShiftActive(false);
}
function toggleShift() {
setShiftActive((v) => !v);
setCtrlActive(false);
}
function sendArrow(normalSeq: string, appSeq: string, shiftSeq: string) {
if (shiftActive) {
send(shiftSeq);
setShiftActive(false);
return;
}
const appMode =
terminalRef.current?.getApplicationCursorKeysMode?.() ?? false;
send(appMode ? appSeq : normalSeq);
}
function handleTab() {
send(shiftActive ? "\x1b[Z" : "\t");
setShiftActive(false);
}
function handleCtrlKey(letter: string) {
const seq = CTRL_MAP[letter];
if (seq) send(seq);
setCtrlActive(false);
}
function updateQuickKeys(next: string[]) {
setQuickKeys(next);
saveQuickKeys(next);
}
return (
<div className="md:hidden flex flex-col bg-sidebar border-t border-border shrink-0">
{/* Row 1 — special keys */}
<div className="flex items-center gap-1 px-2 py-1.5 overflow-x-auto">
{/* ESC */}
<button
className={cn(KEY_BASE, KEY_NORMAL, KEY_MD)}
onPointerDown={(e) => {
e.preventDefault();
send("\x1b");
}}
title={t("mobileKeyboard.esc")}
>
{t("mobileKeyboard.esc")}
</button>
{/* Tab / back-tab */}
<button
className={cn(
KEY_BASE,
KEY_NORMAL,
KEY_MD,
shiftActive && "ring-1 ring-accent-brand/50",
)}
onPointerDown={(e) => {
e.preventDefault();
handleTab();
}}
title={shiftActive ? "Shift+Tab" : t("mobileKeyboard.tab")}
>
{shiftActive ? t("mobileKeyboard.backTab") : t("mobileKeyboard.tab")}
</button>
{/* Paste */}
<button
className={cn(KEY_BASE, KEY_NORMAL, KEY_SM)}
onPointerDown={(e) => {
e.preventDefault();
handlePaste();
}}
title={t("mobileKeyboard.paste")}
>
<Clipboard className="size-4" />
</button>
<div className={SEP} />
{/* Ctrl */}
<button
className={cn(KEY_BASE, KEY_MD, ctrlActive ? KEY_ACTIVE : KEY_NORMAL)}
onPointerDown={(e) => {
e.preventDefault();
toggleCtrl();
}}
title={t("mobileKeyboard.ctrl")}
>
{t("mobileKeyboard.ctrl")}
</button>
{/* Shift */}
<button
className={cn(
KEY_BASE,
KEY_MD,
shiftActive ? KEY_ACTIVE : KEY_NORMAL,
)}
onPointerDown={(e) => {
e.preventDefault();
toggleShift();
}}
title={t("mobileKeyboard.shift")}
>
{t("mobileKeyboard.shift")}
</button>
<div className={SEP} />
{/* Arrow keys */}
<button
className={cn(KEY_BASE, KEY_NORMAL, KEY_SM)}
onPointerDown={(e) => {
e.preventDefault();
sendArrow("\x1b[A", "\x1bOA", "\x1b[1;2A");
}}
title={t("mobileKeyboard.arrowUp")}
>
<ChevronUp className="size-4" />
</button>
<button
className={cn(KEY_BASE, KEY_NORMAL, KEY_SM)}
onPointerDown={(e) => {
e.preventDefault();
sendArrow("\x1b[B", "\x1bOB", "\x1b[1;2B");
}}
title={t("mobileKeyboard.arrowDown")}
>
<ChevronDown className="size-4" />
</button>
<button
className={cn(KEY_BASE, KEY_NORMAL, KEY_SM)}
onPointerDown={(e) => {
e.preventDefault();
sendArrow("\x1b[D", "\x1bOD", "\x1b[1;2D");
}}
title={t("mobileKeyboard.arrowLeft")}
>
<ChevronLeft className="size-4" />
</button>
<button
className={cn(KEY_BASE, KEY_NORMAL, KEY_SM)}
onPointerDown={(e) => {
e.preventDefault();
sendArrow("\x1b[C", "\x1bOC", "\x1b[1;2C");
}}
title={t("mobileKeyboard.arrowRight")}
>
<ChevronRight className="size-4" />
</button>
<div className={SEP} />
{/* Home / End */}
<button
className={cn(KEY_BASE, KEY_NORMAL, KEY_MD)}
onPointerDown={(e) => {
e.preventDefault();
send("\x1b[H");
}}
title={t("mobileKeyboard.home")}
>
{t("mobileKeyboard.home")}
</button>
<button
className={cn(KEY_BASE, KEY_NORMAL, KEY_MD)}
onPointerDown={(e) => {
e.preventDefault();
send("\x1b[F");
}}
title={t("mobileKeyboard.end")}
>
{t("mobileKeyboard.end")}
</button>
<div className={SEP} />
{/* PgUp / PgDn / Del */}
<button
className={cn(KEY_BASE, KEY_NORMAL, KEY_MD)}
onPointerDown={(e) => {
e.preventDefault();
send("\x1b[5~");
}}
title={t("mobileKeyboard.pageUp")}
>
{t("mobileKeyboard.pageUp")}
</button>
<button
className={cn(KEY_BASE, KEY_NORMAL, KEY_MD)}
onPointerDown={(e) => {
e.preventDefault();
send("\x1b[6~");
}}
title={t("mobileKeyboard.pageDown")}
>
{t("mobileKeyboard.pageDown")}
</button>
<button
className={cn(KEY_BASE, KEY_NORMAL, KEY_MD)}
onPointerDown={(e) => {
e.preventDefault();
send("\x1b[3~");
}}
title={t("mobileKeyboard.delete")}
>
{t("mobileKeyboard.delete")}
</button>
</div>
{/* Ctrl combos panel */}
{ctrlActive && <CtrlPanel onSend={handleCtrlKey} />}
{/* Row 2 — quick keys */}
<div className="flex items-center gap-1 px-2 pb-1.5 overflow-x-auto">
{quickKeys.map((sym, i) => (
<button
key={i}
className={cn(KEY_BASE, KEY_NORMAL, KEY_MD, "font-mono")}
onPointerDown={(e) => {
e.preventDefault();
send(sym);
}}
title={sym}
>
{sym}
</button>
))}
<div className="ml-auto shrink-0">
<button
className={cn(KEY_BASE, KEY_NORMAL, KEY_SM)}
onPointerDown={(e) => {
e.preventDefault();
setSheetOpen(true);
}}
title={t("mobileKeyboard.editQuickKeys")}
>
<Pencil className="size-3.5" />
</button>
</div>
</div>
<QuickKeysSheet
open={sheetOpen}
onOpenChange={setSheetOpen}
quickKeys={quickKeys}
onUpdateKeys={updateQuickKeys}
/>
</div>
);
}
File diff suppressed because it is too large. Load diff
-64
View File
@@ -1,64 +0,0 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { Terminal } from "@/features/terminal/Terminal.tsx";
import { FullScreenAppWrapper } from "@/features/FullScreenAppWrapper.tsx";
import { ConnectionScreen } from "@/components/connection/ConnectionScreen.tsx";
interface TerminalAppProps {
hostId?: string;
/** tmux session to attach to once the shell is ready (tmux monitor "Attach"). */
tmuxSession?: string;
}
// Only the session name travels in the URL (never a raw command), so a crafted
// link cannot execute arbitrary input. `=` forces exact-name matching in tmux.
function tmuxAttachCommand(session: string): string {
return `tmux attach-session -t '=${session.replace(/'/g, "'\\''")}'`;
}
const TerminalApp: React.FC<TerminalAppProps> = ({ hostId, tmuxSession }) => {
const { t } = useTranslation();
return (
<FullScreenAppWrapper hostId={hostId}>
{(hostConfig, phase) => {
if (phase === "loading") {
return (
<div className="relative h-full w-full">
<ConnectionScreen
status="connecting"
message={t("hosts.loadingHost")}
/>
</div>
);
}
if (!hostConfig) {
return (
<div className="relative h-full w-full">
<ConnectionScreen
status="disconnected"
message={t("hosts.hostNotFound")}
/>
</div>
);
}
return (
<Terminal
hostConfig={hostConfig}
isVisible={true}
title={hostConfig.name || `${hostConfig.username}@${hostConfig.ip}`}
showTitle={false}
splitScreen={false}
onClose={() => {}}
executeCommand={
tmuxSession ? tmuxAttachCommand(tmuxSession) : undefined
}
/>
);
}}
</FullScreenAppWrapper>
);
};
export default TerminalApp;
@@ -1,119 +0,0 @@
import { lazy, Suspense } from "react";
import { TerminalSquare } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { TerminalHandle, TerminalHostConfig } from "./Terminal";
import { useIsMobile } from "@/hooks/use-mobile";
import { useTabsSafe } from "@/shell/TabContext";
import { hostToSSHHost } from "@/lib/host-to-ssh-host";
import { invokeAction } from "@/shell/action-registry";
import type { TabRenderProps } from "@/shell/tab-registry";
const CommandHistoryProvider = lazy(() =>
import("./command-history/CommandHistoryContext").then((m) => ({
default: m.CommandHistoryProvider,
})),
);
export const loadTerminal = () =>
import("./Terminal").then((m) => ({ default: m.Terminal }));
const TerminalFeature = lazy(loadTerminal);
const MobileTerminalKeyboard = lazy(() =>
import("./MobileTerminalKeyboard").then((m) => ({
default: m.MobileTerminalKeyboard,
})),
);
function TabChunkFallback() {
return (
<div className="flex h-full w-full items-center justify-center bg-background">
<div className="size-5 rounded-full border-2 border-muted-foreground/30 border-t-muted-foreground/70 animate-spin" />
</div>
);
}
/**
* The SSH terminal as a tab. Core code, because the terminal's session
* manager, split view and file-manager hooks are core until the terminal's
* own Phase B step; the ssh-terminal plugin registers it as the "terminal"
* tab type, so it comes and goes with that plugin.
*/
export function TerminalTabContent({
tab,
host,
label,
isVisible,
isFocusedPane,
shell,
}: TabRenderProps) {
const { t } = useTranslation();
const { previewTerminalTheme } = useTabsSafe();
const isMobile = useIsMobile();
if (!host) {
return (
<div className="flex flex-col items-center justify-center flex-1 gap-3 p-6 text-center">
<TerminalSquare className="size-5 text-muted-foreground/30" />
<span className="text-sm font-semibold text-muted-foreground/60">
{t("terminal.noHostSelected")}
</span>
</div>
);
}
return (
<Suspense fallback={<TabChunkFallback />}>
<CommandHistoryProvider>
<div className="flex flex-col h-full w-full">
<div className="flex-1 min-h-0">
<TerminalFeature
ref={tab.terminalRef as React.Ref<TerminalHandle>}
hostConfig={
{
...hostToSSHHost(host),
sshPort: host.sshPort ?? host.port,
instanceId: tab.instanceId ?? tab.id,
restoredSessionId: tab.restoredSessionId ?? null,
joinSharedSessionId: tab.joinSharedSessionId ?? null,
joinShareId: tab.joinShareId ?? null,
} as TerminalHostConfig
}
isVisible={isVisible}
initialPath={tab.initialFilePath}
title={label}
showTitle={false}
splitScreen={false}
onClose={() => shell.closeTab(tab.id)}
onTitleChange={
host.terminalConfig?.useSSHTitle
? (title) => shell.renameTab(tab.id, title)
: undefined
}
previewTheme={previewTerminalTheme}
onOpenFileInEditor={(filePath) =>
void invokeAction("files.openEditor", host, filePath)
}
onOpenFileManager={(path) =>
void invokeAction("files.openHost", host, path)
}
isQuickConnect={host.id.startsWith("quick-connect-")}
onSaveQuickConnect={
shell.saveQuickConnect
? () => shell.saveQuickConnect!(tab, host)
: undefined
}
host={host}
onOpenTab={(type) => shell.openTab(host, type)}
isFocusedPane={isFocusedPane}
/>
</div>
{isMobile && (
<MobileTerminalKeyboard
terminalRef={
tab.terminalRef as React.RefObject<TerminalHandle | null>
}
/>
)}
</div>
</CommandHistoryProvider>
</Suspense>
);
}
@@ -1,896 +0,0 @@
import React, {
useEffect,
useId,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { useTranslation } from "react-i18next";
import {
ChevronDown,
ClipboardPaste,
GripVertical,
ImagePlus,
LayoutGrid,
LogOut,
Maximize2,
Minimize2,
X,
} from "lucide-react";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
} from "@/components/select";
import { cn } from "@/lib/utils";
import { TERMINAL_TOOLBAR_SLOT, type TerminalSlotApi } from "./terminal-slots";
import { ActionSlot } from "@/shell/ActionSlot";
import { useActionSlot } from "@/hooks/use-action-slot";
import { useIsMobile } from "@/hooks/use-mobile";
import { getSshActions } from "@/sidebar/tree/HostItem/HostItem";
import {
getServerMetricsById,
startMetricsPolling,
stopMetricsPolling,
} from "@/api/host-metrics-status-api";
import type { Host, TabType } from "@/types/ui-types";
import {
getPollingEnvironmentMultiplier,
runAdaptivePolling,
} from "@/lib/adaptive-polling";
import {
clampToolbarPosition,
getResponsiveToolbarDensity,
persistToolbarPosition,
readStoredToolbarPosition,
type ToolbarPosition,
type ToolbarDensity,
} from "./toolbar-geometry";
type SelectedToolbarDensity = ToolbarDensity;
const DENSITY_STORAGE_KEY = "termix-terminal-toolbar-density";
/** Plugins contribute toolbar buttons here. Declared by the ssh-terminal plugin. */
const TOOLBAR_SLOT_ID = TERMINAL_TOOLBAR_SLOT;
const DENSITY_OPTIONS: { value: ToolbarDensity }[] = [
{ value: "icon" },
{ value: "labeled" },
{ value: "expanded" },
];
const DENSITIES = DENSITY_OPTIONS.map((option) => option.value);
function readStoredDensity(): ToolbarDensity {
if (typeof window === "undefined") return "labeled";
try {
const stored = window.localStorage.getItem(DENSITY_STORAGE_KEY);
return DENSITIES.includes(stored as ToolbarDensity)
? (stored as ToolbarDensity)
: "labeled";
} catch {
return "labeled";
}
}
const CONTROL =
"inline-flex min-h-8 min-w-8 items-center justify-center gap-1.5 rounded-sm px-2 text-xs font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground active:translate-y-px active:bg-muted/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-40 data-[state=open]:bg-muted data-[state=open]:text-foreground";
const SEPARATOR = "mx-0.5 h-5 w-px shrink-0 bg-border";
function StatBar({
label,
percent,
}: {
label: string;
percent: number | null;
}) {
const value = percent == null ? null : Math.max(0, Math.min(100, percent));
return (
<div className="flex items-center gap-1.5 px-1.5">
<span className="w-12 shrink-0 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
{label}
</span>
<div className="h-1.5 w-12 shrink-0 overflow-hidden rounded-full bg-muted">
{value != null && (
<div
className={cn(
"h-full rounded-full transition-all",
value >= 90
? "bg-red-500"
: value >= 70
? "bg-yellow-500"
: "bg-accent-brand",
)}
style={{ width: `${value}%` }}
/>
)}
</div>
<span className="w-7 shrink-0 text-right text-[10px] tabular-nums text-muted-foreground">
{value == null ? "--" : `${Math.round(value)}%`}
</span>
</div>
);
}
interface TerminalToolbarProps {
host: Host;
terminalIdentity?: string;
isConnected: boolean;
isTmuxAttached: boolean;
onTmuxDetach: () => void;
isImageUploading: boolean;
onUploadImage: (file: File) => void | Promise<void>;
onPasteImage: () => void | Promise<void>;
onOpenTab?: (type: TabType) => void;
onOpenFiles?: () => void;
isFocused: boolean;
/** Hides every contributed action, e.g. while the toolbar is measuring. */
actionsEnabled?: boolean;
/** Handed to contributed actions when they are invoked. */
slotApi?: TerminalSlotApi;
}
export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
host,
terminalIdentity,
isConnected,
isTmuxAttached,
onTmuxDetach,
isImageUploading,
onUploadImage,
onPasteImage,
onOpenTab,
onOpenFiles,
isFocused,
actionsEnabled = true,
slotApi,
}) => {
const { t } = useTranslation();
// Drives both the measurement copy and the separator, so the toolbar sizes
// itself correctly whether or not anything contributed.
const allSlotContributions = useActionSlot(TOOLBAR_SLOT_ID, { host });
const slotContributions = useMemo(
() =>
allSlotContributions.filter(
(contribution) => contribution.kind !== "component",
),
[allSlotContributions],
);
const [density, setDensity] =
useState<SelectedToolbarDensity>(readStoredDensity);
const [responsiveDensity, setResponsiveDensity] =
useState<SelectedToolbarDensity>(readStoredDensity);
const [position, setPosition] = useState<ToolbarPosition>(
readStoredToolbarPosition,
);
const positionRef = useRef(position);
const [collapsed, setCollapsed] = useState(false);
const [densityOpen, setDensityOpen] = useState(false);
const [metrics, setMetrics] = useState<{
cpu: number | null;
memory: number | null;
disk: number | null;
} | null>(null);
const [statsAvailable, setStatsAvailable] = useState(true);
const [imageError, setImageError] = useState<string | null>(null);
const [retryImageAction, setRetryImageAction] = useState<(() => void) | null>(
null,
);
const fileInputRef = useRef<HTMLInputElement>(null);
const hideToolbarRef = useRef<HTMLButtonElement>(null);
const pendingExpansionFocusRef = useRef(false);
const pendingRightEdgeRef = useRef<number | null>(null);
const mountedRef = useRef(false);
const imageGenerationRef = useRef(0);
const hostRef = useRef<HTMLDivElement>(null);
const toolbarRef = useRef<HTMLDivElement>(null);
const measurementRef = useRef<HTMLDivElement>(null);
const dragRef = useRef<{
pointerId: number;
startX: number;
startY: number;
basePosition: ToolbarPosition;
} | null>(null);
const capturedPointerIdRef = useRef<number | null>(null);
const dragMovedRef = useRef(false);
const densityId = useId();
const [desktopViewportReady, setDesktopViewportReady] = useState<boolean>();
const hostIdentity = terminalIdentity ?? `${host.id ?? "unknown"}`;
positionRef.current = position;
const hostActions = getSshActions(host).filter(
(action) => action.type !== "terminal",
);
const effectiveDensity: ToolbarDensity = isFocused
? responsiveDensity
: "icon";
const showStats = effectiveDensity === "expanded";
const hostId = host.id ? Number(host.id) : null;
const isMobile = useIsMobile();
useLayoutEffect(() => {
setDesktopViewportReady(
typeof window !== "undefined" && window.innerWidth >= 768,
);
}, []);
useEffect(() => {
if (isMobile === false) setDesktopViewportReady(true);
}, [isMobile]);
useEffect(() => {
if (desktopViewportReady !== true || isMobile !== false) return;
try {
window.localStorage.setItem(DENSITY_STORAGE_KEY, density);
} catch {
// Storage can be unavailable in hardened browser contexts.
}
}, [density, desktopViewportReady, isMobile]);
// Picking an interface preset seeds this key, so pick the new value up
// without needing the tab to remount.
useEffect(() => {
if (desktopViewportReady !== true || isMobile !== false) return;
const handler = () => setDensity(readStoredDensity());
window.addEventListener("terminalToolbarDensityChanged", handler);
return () =>
window.removeEventListener("terminalToolbarDensityChanged", handler);
}, [desktopViewportReady, isMobile]);
useEffect(() => {
if (desktopViewportReady !== true || isMobile !== false) return;
const measurement = measurementRef.current;
const host = hostRef.current;
if (!measurement || !host) return;
let frameId: number | null = null;
let remeasurements = 0;
const measure = () => {
frameId = null;
const available = host.getBoundingClientRect().width;
const required = measurement.getBoundingClientRect().width;
if (available > 0 && required > 0) {
setResponsiveDensity((current) =>
getResponsiveToolbarDensity(density, current, available, required),
);
remeasurements = 0;
} else if (remeasurements < 2) {
remeasurements += 1;
frameId = window.requestAnimationFrame(measure);
}
};
const schedule = () => {
if (frameId == null) frameId = window.requestAnimationFrame(measure);
};
schedule();
const observer =
typeof ResizeObserver === "undefined"
? null
: new ResizeObserver(schedule);
observer?.observe(host);
observer?.observe(measurement);
return () => {
if (frameId != null) window.cancelAnimationFrame(frameId);
observer?.disconnect();
};
}, [
density,
desktopViewportReady,
hostActions.length,
isConnected,
isMobile,
isTmuxAttached,
actionsEnabled,
slotContributions,
t,
]);
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
imageGenerationRef.current += 1;
dragRef.current = null;
capturedPointerIdRef.current = null;
};
}, []);
useLayoutEffect(() => {
if (desktopViewportReady !== true || isMobile !== false) return;
const toolbar = toolbarRef.current;
const host = hostRef.current;
if (!toolbar || !host || dragRef.current) return;
const hostRect = host.getBoundingClientRect();
const toolbarRect = toolbar.getBoundingClientRect();
const extremeOffset =
hostRect.width > 0 &&
hostRect.height > 0 &&
Math.max(
Math.abs(positionRef.current.x),
Math.abs(positionRef.current.y),
) >
Math.max(hostRect.width, hostRect.height) * 4;
if (extremeOffset) {
positionRef.current = { x: 0, y: 0 };
setPosition({ x: 0, y: 0 });
persistToolbarPosition({ x: 0, y: 0 });
return;
}
if (toolbarRect.width > 0 && toolbarRect.height > 0) {
const pendingRightEdge = pendingRightEdgeRef.current;
pendingRightEdgeRef.current = null;
const baseRight = toolbarRect.right - positionRef.current.x;
const next = clampToolbarPosition(
{
x:
pendingRightEdge == null
? positionRef.current.x
: pendingRightEdge - baseRight,
y: positionRef.current.y,
},
toolbarRect,
hostRect,
positionRef.current,
);
if (
next.x !== positionRef.current.x ||
next.y !== positionRef.current.y
) {
positionRef.current = next;
setPosition(next);
persistToolbarPosition(next);
}
}
if (!collapsed && pendingExpansionFocusRef.current) {
pendingExpansionFocusRef.current = false;
hideToolbarRef.current?.focus();
}
}, [
collapsed,
density,
desktopViewportReady,
effectiveDensity,
hostActions.length,
isConnected,
isMobile,
isTmuxAttached,
responsiveDensity,
]);
useEffect(() => {
imageGenerationRef.current += 1;
setImageError(null);
setRetryImageAction(null);
}, [isConnected, hostIdentity]);
useEffect(() => {
if (collapsed || !pendingExpansionFocusRef.current) return;
pendingExpansionFocusRef.current = false;
hideToolbarRef.current?.focus();
}, [collapsed]);
useEffect(() => {
if (
!showStats ||
desktopViewportReady !== true ||
isMobile !== false ||
!hostId ||
collapsed ||
!isConnected
) {
setMetrics(null);
return;
}
let cancelled = false;
let stopPolling: (() => void) | undefined;
let viewerSessionId: string | undefined;
let previousMetrics: typeof metrics = null;
setStatsAvailable(true);
const poll = async () => {
try {
const data = await getServerMetricsById(hostId);
if (cancelled || !data) return;
const next = {
cpu: data.cpu?.percent ?? null,
memory: data.memory?.percent ?? null,
disk: data.disk?.percent ?? null,
};
const changed =
!previousMetrics ||
(["cpu", "memory", "disk"] as const).some((key) => {
const previous = previousMetrics?.[key];
const current = next[key];
return (
previous == null ||
current == null ||
Math.abs(current - previous) >= 2
);
});
previousMetrics = next;
setMetrics(next);
return changed;
} catch {
// Retain the previous sample after a transient polling error.
return false;
}
};
const start = async () => {
try {
const result = await startMetricsPolling(hostId);
if (cancelled) {
if (result.viewerSessionId) {
void stopMetricsPolling(hostId, result.viewerSessionId).catch(
() => {},
);
}
return;
}
if (result.requires_totp) {
setStatsAvailable(false);
return;
}
viewerSessionId = result.viewerSessionId;
stopPolling = runAdaptivePolling(
poll,
{
minIntervalMs: 5_000,
maxIntervalMs: 20_000,
stablePollsPerStep: 2,
maxRequestDutyCycle: 0.2,
},
{ intervalMultiplier: getPollingEnvironmentMultiplier },
);
} catch {
if (!cancelled) setStatsAvailable(false);
}
};
void start();
return () => {
cancelled = true;
stopPolling?.();
void stopMetricsPolling(hostId, viewerSessionId).catch(() => {});
};
}, [
desktopViewportReady,
showStats,
isMobile,
hostId,
collapsed,
isConnected,
hostIdentity,
]);
if (!isConnected || desktopViewportReady !== true || isMobile !== false)
return null;
const setAndStoreDensity = (next: ToolbarDensity) => {
setDensity(next);
};
const reportImageResult = (
action: () => void | Promise<void>,
retry: () => void,
) => {
const generation = ++imageGenerationRef.current;
let result: void | Promise<void>;
try {
result = action();
} catch (error) {
result = Promise.reject(error);
}
void Promise.resolve(result).then(
() => {
if (!mountedRef.current || generation !== imageGenerationRef.current)
return;
setImageError(null);
setRetryImageAction(null);
},
(error: unknown) => {
if (!mountedRef.current || generation !== imageGenerationRef.current)
return;
setImageError(
error instanceof Error
? error.message
: t("terminalToolbar.imageActionFailed"),
);
setRetryImageAction(() => retry);
},
);
};
const uploadFile = (file: File) => {
setImageError(null);
reportImageResult(
() => onUploadImage(file),
() => uploadFile(file),
);
};
const pasteImage = () => {
setImageError(null);
// Invoke clipboard access in this click's user-activation call chain.
reportImageResult(onPasteImage, pasteImage);
};
const chooseFile = () => fileInputRef.current?.click();
const handleGrabPointerDown = (
event: React.PointerEvent<HTMLButtonElement>,
) => {
if (event.button !== 0 || event.pointerType === "touch") return;
event.currentTarget.setPointerCapture?.(event.pointerId);
capturedPointerIdRef.current = event.pointerId;
dragMovedRef.current = false;
dragRef.current = {
pointerId: event.pointerId,
startX: event.clientX,
startY: event.clientY,
basePosition: positionRef.current,
};
};
const handleGrabPointerMove = (
event: React.PointerEvent<HTMLButtonElement>,
) => {
const drag = dragRef.current;
if (!drag || drag.pointerId !== event.pointerId) return;
const deltaX = event.clientX - drag.startX;
const deltaY = event.clientY - drag.startY;
const toolbar = toolbarRef.current;
const host = hostRef.current;
if (!toolbar || !host) return;
const next = clampToolbarPosition(
{
x: drag.basePosition.x + deltaX,
y: drag.basePosition.y + deltaY,
},
toolbar.getBoundingClientRect(),
host.getBoundingClientRect(),
positionRef.current,
);
if (event.clientX !== drag.startX || event.clientY !== drag.startY) {
dragMovedRef.current = true;
}
positionRef.current = next;
setPosition(next);
};
const handleGrabPointerEnd = (
event: React.PointerEvent<HTMLButtonElement>,
) => {
const drag = dragRef.current;
if (!drag || drag.pointerId !== event.pointerId) return;
persistToolbarPosition(positionRef.current);
dragRef.current = null;
if (capturedPointerIdRef.current === event.pointerId) {
capturedPointerIdRef.current = null;
if (event.type !== "lostpointercapture") {
try {
event.currentTarget.releasePointerCapture?.(event.pointerId);
} catch {
// The browser may have already released capture for this pointer.
}
}
}
};
const densityLabels: Record<ToolbarDensity, string> = {
icon: t("terminalToolbar.layoutIcon"),
labeled: t("terminalToolbar.layoutLabeled"),
expanded: t("terminalToolbar.layoutExpanded"),
};
const densitySelect = (
<Select
value={density}
open={densityOpen}
onOpenChange={setDensityOpen}
onValueChange={(value) => setAndStoreDensity(value as ToolbarDensity)}
>
<SelectTrigger
id={densityId}
size="sm"
aria-label={t("terminalToolbar.layout")}
title={t("terminalToolbar.layout")}
className={cn(
CONTROL,
"h-8 border-0 bg-transparent px-2 shadow-none dark:bg-transparent dark:hover:bg-muted",
)}
>
<LayoutGrid className="size-4 shrink-0" />
</SelectTrigger>
<SelectContent>
{DENSITY_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{densityLabels[option.value]}
</SelectItem>
))}
</SelectContent>
</Select>
);
const renderHostAction = (action: (typeof hostActions)[number]) => {
const Icon = action.icon;
const invoke = () =>
action.type === "files" && onOpenFiles
? onOpenFiles()
: onOpenTab?.(action.type);
return (
<button
key={action.type}
type="button"
className={CONTROL}
aria-label={action.label}
title={action.label}
onClick={invoke}
>
<Icon className="size-4 shrink-0" />
{effectiveDensity !== "icon" && action.label}
</button>
);
};
const imageButtons = (
<>
<button
type="button"
className={CONTROL}
aria-label={t("terminalToolbar.uploadImage")}
title={t("terminalToolbar.uploadImage")}
disabled={isImageUploading}
onClick={chooseFile}
>
<ImagePlus className="size-4 shrink-0" />
{effectiveDensity !== "icon" && t("terminalToolbar.upload")}
</button>
<button
type="button"
className={CONTROL}
aria-label={t("terminalToolbar.pasteImage")}
title={t("terminalToolbar.pasteImage")}
disabled={isImageUploading}
onClick={pasteImage}
>
<ClipboardPaste className="size-4 shrink-0" />
{effectiveDensity !== "icon" && t("terminalToolbar.paste")}
</button>
</>
);
const expandButton = (
<button
type="button"
className={CONTROL}
aria-label={t("terminalToolbar.showToolbar")}
title={t("terminalToolbar.showToolbar")}
onClick={() => {
pendingRightEdgeRef.current =
toolbarRef.current?.getBoundingClientRect().right ?? null;
pendingExpansionFocusRef.current = true;
setCollapsed(false);
}}
>
<Maximize2 className="size-4" />
<span className="sr-only">{t("terminalToolbar.showToolbar")}</span>
</button>
);
const grabButton = () => (
<button
type="button"
className={cn(CONTROL, "cursor-grab active:cursor-grabbing")}
aria-label={t("terminalToolbar.moveToolbar")}
title={t("terminalToolbar.moveToolbarHint")}
onPointerDown={handleGrabPointerDown}
onPointerMove={handleGrabPointerMove}
onPointerUp={handleGrabPointerEnd}
onPointerCancel={handleGrabPointerEnd}
onLostPointerCapture={handleGrabPointerEnd}
>
<GripVertical className="size-4" />
</button>
);
return (
<>
<div
ref={hostRef}
data-terminal-toolbar-host
className={cn(
"pointer-events-none @container absolute inset-0 z-[110] flex items-end justify-center pb-2",
)}
>
<input
ref={fileInputRef}
type="file"
accept="image/*"
className="sr-only"
tabIndex={-1}
disabled={isImageUploading}
aria-hidden="true"
onChange={(event) => {
const file = event.target.files?.[0];
event.target.value = "";
if (file) uploadFile(file);
}}
/>
<span role="status" aria-live="polite" className="sr-only">
{isImageUploading ? t("terminalToolbar.uploadingImage") : ""}
</span>
{imageError && (
<div
role="alert"
className="pointer-events-auto absolute bottom-full mb-2 flex min-h-11 max-w-sm items-center gap-2 rounded-sm border border-destructive bg-background p-2 text-xs text-destructive shadow-lg"
>
<span>{imageError}</span>
{retryImageAction && (
<button
type="button"
className={cn(CONTROL, "text-foreground")}
title={t("terminalToolbar.retry")}
disabled={isImageUploading}
onClick={retryImageAction}
>
{t("terminalToolbar.retry")}
</button>
)}
<button
type="button"
className={cn(CONTROL, "px-2")}
aria-label={t("terminalToolbar.dismissError")}
title={t("terminalToolbar.dismissError")}
onClick={() => {
setImageError(null);
setRetryImageAction(null);
}}
>
<X className="size-4" />
</button>
</div>
)}
<div
ref={measurementRef}
aria-hidden="true"
className="pointer-events-none invisible absolute left-0 top-0 flex whitespace-nowrap"
>
{hostActions.map((action) => (
<span key={action.type} className={CONTROL}>
<action.icon className="size-4" />
{density !== "icon" && action.label}
</span>
))}
{isTmuxAttached && (
<span className={CONTROL}>
<LogOut className="size-4" />
{density !== "icon" && t("terminalToolbar.detachTmux")}
</span>
)}
<span className={CONTROL}>
<ImagePlus className="size-4" />
{density !== "icon" && t("terminalToolbar.upload")}
</span>
<span className={CONTROL}>
<ClipboardPaste className="size-4" />
{density !== "icon" && t("terminalToolbar.paste")}
</span>
{actionsEnabled &&
slotContributions.map((contribution) => (
<span key={contribution.actionId} className={CONTROL}>
{contribution.icon && <contribution.icon className="size-4" />}
{density !== "icon" && t(contribution.titleKey)}
</span>
))}
<span className={cn(CONTROL, "h-8 px-2")}>
<LayoutGrid className="size-4" />
<ChevronDown className="size-4" />
</span>
<span className={CONTROL}>
<Minimize2 className="size-4" />
</span>
<span className={CONTROL}>
<GripVertical className="size-4" />
</span>
</div>
<div
ref={toolbarRef}
data-terminal-toolbar-wide
className={cn(
"pointer-events-auto transition-opacity duration-300 hover:duration-0 focus-within:duration-0 hover:opacity-100 focus-within:opacity-100",
collapsed || densityOpen ? "opacity-100" : "opacity-30",
)}
style={{
transform: `translate(${position.x}px, ${position.y}px)`,
}}
>
{collapsed ? (
<div className="terminal-toolbar-collapsed-shell flex rounded-sm border border-border bg-background/90 p-0.5 shadow-lg backdrop-blur-sm">
{expandButton}
{grabButton()}
</div>
) : (
<div className="flex flex-col overflow-hidden rounded-sm border border-border bg-background/90 shadow-lg backdrop-blur-sm">
{showStats && (
<div className="flex flex-wrap items-center justify-center gap-x-1 border-b border-border px-1.5 py-0.5">
{statsAvailable ? (
<>
<StatBar
label={t("terminalToolbar.cpu")}
percent={metrics?.cpu ?? null}
/>
<StatBar
label={t("terminalToolbar.memory")}
percent={metrics?.memory ?? null}
/>
<StatBar
label={t("terminalToolbar.disk")}
percent={metrics?.disk ?? null}
/>
</>
) : (
<span
role="status"
className="px-2 py-1 text-xs text-muted-foreground"
>
{t("terminalToolbar.statsUnavailable")}
</span>
)}
</div>
)}
<div className={cn("flex gap-0.5 p-0.5", "items-center")}>
{hostActions.map(renderHostAction)}
{isTmuxAttached && (
<button
type="button"
className={CONTROL}
aria-label={t("terminalToolbar.detachTmuxDescription")}
title={t("terminalToolbar.detachTmuxDescription")}
onClick={onTmuxDetach}
>
<LogOut className="size-4" />
{effectiveDensity !== "icon" &&
t("terminalToolbar.detachTmux")}
</button>
)}
{(hostActions.length > 0 || isTmuxAttached) && (
<div className={SEPARATOR} />
)}
<div
role="group"
aria-label={t("terminalToolbar.image")}
className="flex items-center gap-0.5"
>
{imageButtons}
</div>
{actionsEnabled && slotContributions.length > 0 && (
<>
<div className={SEPARATOR} />
<ActionSlot
slotId={TOOLBAR_SLOT_ID}
className={CONTROL}
enabled={actionsEnabled}
hideLabels={effectiveDensity === "icon"}
when={{ host }}
context={() => [slotApi]}
/>
</>
)}
<div className={SEPARATOR} />
{densitySelect}
<button
ref={hideToolbarRef}
type="button"
className={CONTROL}
aria-label={t("terminalToolbar.hideToolbar")}
title={t("terminalToolbar.hideToolbar")}
onClick={() => {
pendingRightEdgeRef.current =
toolbarRef.current?.getBoundingClientRect().right ?? null;
setCollapsed(true);
}}
>
<Minimize2 className="size-4" />
<span className="sr-only">
{t("terminalToolbar.hideToolbar")}
</span>
</button>
<div className={SEPARATOR} />
{grabButton()}
</div>
</div>
)}
</div>
</div>
</>
);
};
@@ -1,30 +0,0 @@
import type { HostBackspaceMode } from "@/sidebar/HostEditorData";
const CURSOR_SEQUENCES: Record<string, [normal: string, application: string]> =
{
ArrowUp: ["\x1b[A", "\x1bOA"],
ArrowDown: ["\x1b[B", "\x1bOB"],
ArrowRight: ["\x1b[C", "\x1bOC"],
ArrowLeft: ["\x1b[D", "\x1bOD"],
};
export function getAndroidHardwareKeySequence(
event: Pick<
KeyboardEvent,
"key" | "ctrlKey" | "altKey" | "metaKey" | "shiftKey"
>,
applicationCursorKeys: boolean,
backspaceMode: HostBackspaceMode | undefined,
): string | null {
if (event.ctrlKey || event.altKey || event.metaKey || event.shiftKey) {
return null;
}
const cursor = CURSOR_SEQUENCES[event.key];
if (cursor) return cursor[applicationCursorKeys ? 1 : 0];
if (event.key === "Delete") return "\x1b[3~";
if (event.key === "Backspace" && backspaceMode !== "control-h") {
return "\x7f";
}
return null;
}
@@ -1,110 +0,0 @@
import React, { useEffect, useRef } from "react";
import { cn } from "@/lib/utils.ts";
interface CommandAutocompleteProps {
suggestions: string[];
selectedIndex: number;
onSelect: (command: string) => void;
position: { top: number; left: number };
visible: boolean;
}
interface CommandAutosuggestionProps {
suggestion: string;
position: { top: number; left: number };
style?: React.CSSProperties;
visible: boolean;
}
export function CommandAutosuggestion({
suggestion,
position,
style,
visible,
}: CommandAutosuggestionProps) {
if (!visible || !suggestion) {
return null;
}
return (
<div
className="pointer-events-none fixed z-[9998] whitespace-pre text-muted-foreground/45"
style={{
top: `${position.top}px`,
left: `${position.left}px`,
fontVariantLigatures: "none",
fontFeatureSettings: '"liga" 0, "calt" 0',
textRendering: "optimizeLegibility",
WebkitFontSmoothing: "antialiased",
transition: "opacity 80ms ease-out",
willChange: "contents",
...style,
}}
>
{suggestion}
</div>
);
}
export function CommandAutocomplete({
suggestions,
selectedIndex,
onSelect,
position,
visible,
}: CommandAutocompleteProps) {
const containerRef = useRef<HTMLDivElement>(null);
const selectedRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (selectedRef.current && containerRef.current) {
selectedRef.current.scrollIntoView({
block: "nearest",
behavior: "smooth",
});
}
}, [selectedIndex]);
if (!visible || suggestions.length === 0) {
return null;
}
const footerHeight = 32;
const maxSuggestionsHeight = 240 - footerHeight;
return (
<div
ref={containerRef}
className="fixed z-[9999] bg-popover border border-border shadow-lg min-w-[200px] max-w-[600px] flex flex-col"
style={{
top: `${position.top}px`,
left: `${position.left}px`,
maxHeight: "240px",
}}
>
<div
className="overflow-y-auto thin-scrollbar"
style={{ maxHeight: `${maxSuggestionsHeight}px` }}
>
{suggestions.map((suggestion, index) => (
<div
key={index}
ref={index === selectedIndex ? selectedRef : null}
className={cn(
"px-3 py-1.5 text-sm font-mono cursor-pointer transition-colors text-foreground",
"hover:bg-muted",
index === selectedIndex && "bg-accent text-accent-foreground",
)}
onClick={() => onSelect(suggestion)}
onMouseEnter={() => {}}
>
{suggestion}
</div>
))}
</div>
<div className="px-3 py-1 text-xs text-muted-foreground border-t border-border bg-muted/50 shrink-0">
Tab/Enter to complete • ↑↓ to navigate • Esc to close
</div>
</div>
);
}
@@ -1,86 +0,0 @@
/* eslint-disable react-refresh/only-export-components */
import React, {
createContext,
useContext,
useState,
useCallback,
type ReactNode,
} from "react";
interface CommandHistoryContextType {
commandHistory: string[];
isLoading: boolean;
setCommandHistory: (history: string[]) => void;
setIsLoading: (loading: boolean) => void;
onSelectCommand?: (command: string) => void;
setOnSelectCommand: (callback: (command: string) => void) => void;
onDeleteCommand?: (command: string) => void;
setOnDeleteCommand: (callback: (command: string) => void) => void;
openCommandHistory: () => void;
setOpenCommandHistory: (callback: () => void) => void;
}
const CommandHistoryContext = createContext<
CommandHistoryContextType | undefined
>(undefined);
export function CommandHistoryProvider({ children }: { children: ReactNode }) {
const [commandHistory, setCommandHistory] = useState<string[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [onSelectCommand, setOnSelectCommand] = useState<
((command: string) => void) | undefined
>(undefined);
const [onDeleteCommand, setOnDeleteCommand] = useState<
((command: string) => void) | undefined
>(undefined);
const [openCommandHistory, setOpenCommandHistory] = useState<
(() => void) | undefined
>(() => () => {});
const handleSetOnSelectCommand = useCallback(
(callback: (command: string) => void) => {
setOnSelectCommand(() => callback);
},
[],
);
const handleSetOnDeleteCommand = useCallback(
(callback: (command: string) => void) => {
setOnDeleteCommand(() => callback);
},
[],
);
const handleSetOpenCommandHistory = useCallback((callback: () => void) => {
setOpenCommandHistory(() => callback);
}, []);
return (
<CommandHistoryContext.Provider
value={{
commandHistory,
isLoading,
setCommandHistory,
setIsLoading,
onSelectCommand,
setOnSelectCommand: handleSetOnSelectCommand,
onDeleteCommand,
setOnDeleteCommand: handleSetOnDeleteCommand,
openCommandHistory: openCommandHistory || (() => {}),
setOpenCommandHistory: handleSetOpenCommandHistory,
}}
>
{children}
</CommandHistoryContext.Provider>
);
}
export function useCommandHistory() {
const context = useContext(CommandHistoryContext);
if (context === undefined) {
throw new Error(
"useCommandHistory must be used within a CommandHistoryProvider",
);
}
return context;
}
@@ -1,130 +0,0 @@
import { useRef, useCallback } from "react";
import { saveCommandToHistory } from "@/main-axios.ts";
const SENSITIVE_PATTERNS = [
/\bpassw(or)?d\b/i,
/\bsecret\b/i,
/\btoken\b/i,
/\bapi.?key\b/i,
/\bPASS(WORD)?=/i,
/\bAWS_SECRET/i,
/\bmysql\b.*-p/i,
/\bsudo\s+-S\b/,
/\bhtpasswd\b/i,
/\bsshpass\b/i,
/\bcurl\b.*-u\s/i,
/\bexport\b.*(?:PASSWORD|SECRET|TOKEN|KEY)=/i,
];
interface UseCommandTrackerOptions {
hostId?: number;
enabled?: boolean;
onCommandExecuted?: (command: string) => void;
}
interface CommandTrackerResult {
trackInput: (data: string) => void;
getCurrentCommand: () => string;
clearCurrentCommand: () => void;
updateCurrentCommand: (command: string) => void;
}
export function useCommandTracker({
hostId,
enabled = true,
onCommandExecuted,
}: UseCommandTrackerOptions): CommandTrackerResult {
const currentCommandRef = useRef<string>("");
const isInEscapeSequenceRef = useRef<boolean>(false);
const trackInput = useCallback(
(data: string) => {
if (!enabled || !hostId) {
return;
}
for (let i = 0; i < data.length; i++) {
const char = data[i];
const charCode = char.charCodeAt(0);
if (charCode === 27) {
isInEscapeSequenceRef.current = true;
continue;
}
if (isInEscapeSequenceRef.current) {
if (
(charCode >= 65 && charCode <= 90) ||
(charCode >= 97 && charCode <= 122) ||
charCode === 126
) {
isInEscapeSequenceRef.current = false;
}
continue;
}
if (charCode === 13 || charCode === 10) {
const command = currentCommandRef.current.trim();
if (command.length > 0) {
const isSensitive = SENSITIVE_PATTERNS.some((p) => p.test(command));
if (!isSensitive) {
saveCommandToHistory(hostId, command).catch((error) => {
console.error("Failed to save command to history:", error);
});
}
if (onCommandExecuted) {
onCommandExecuted(command);
}
}
currentCommandRef.current = "";
continue;
}
if (charCode === 8 || charCode === 127) {
if (currentCommandRef.current.length > 0) {
currentCommandRef.current = currentCommandRef.current.slice(0, -1);
}
continue;
}
if (charCode === 3 || charCode === 4) {
currentCommandRef.current = "";
continue;
}
if (charCode === 21) {
currentCommandRef.current = "";
continue;
}
if (charCode >= 32 && charCode <= 126) {
currentCommandRef.current += char;
}
}
},
[enabled, hostId, onCommandExecuted],
);
const getCurrentCommand = useCallback(() => {
return currentCommandRef.current;
}, []);
const clearCurrentCommand = useCallback(() => {
currentCommandRef.current = "";
}, []);
const updateCurrentCommand = useCallback((command: string) => {
currentCommandRef.current = command;
}, []);
return {
trackInput,
getCurrentCommand,
clearCurrentCommand,
updateCurrentCommand,
};
}
@@ -1,40 +0,0 @@
export function fontSizeStorageKey(identity: unknown): string | null {
return typeof identity === "string" || typeof identity === "number"
? `terminal_fontsize_host_${identity}`
: null;
}
export function readFontSize(
storage: Storage,
key: string | null,
configured: number,
): number | null {
if (!key) return null;
try {
const value = JSON.parse(storage.getItem(key) || "null");
if (
value?.configured === configured &&
Number.isFinite(value.size) &&
value.size > 0
)
return value.size;
storage.removeItem(key);
} catch {
/* Storage may be unavailable. */
}
return null;
}
export function saveFontSize(
storage: Storage,
key: string | null,
configured: number,
size: number,
): void {
if (!key) return;
try {
storage.setItem(key, JSON.stringify({ configured, size }));
} catch {
/* Storage may be unavailable. */
}
}
@@ -1,249 +0,0 @@
import React from "react";
import { ChevronDown, ChevronUp, X } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Input } from "@/components/input";
import { Button } from "@/components/button";
import { Separator } from "@/components/separator";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/tooltip";
import { cn } from "@/lib/utils.ts";
interface TerminalSearchBarProps {
visible: boolean;
query: string;
onQueryChange: (query: string) => void;
onFindNext: () => void;
onFindPrevious: () => void;
onClose: () => void;
caseSensitive: boolean;
onToggleCaseSensitive: () => void;
wholeWord: boolean;
onToggleWholeWord: () => void;
regex: boolean;
onToggleRegex: () => void;
resultIndex: number;
resultCount: number;
inputRef: React.RefObject<HTMLInputElement | null>;
}
interface SearchToggleProps {
label: string;
active: boolean;
onClick: () => void;
className?: string;
children: React.ReactNode;
}
function SearchToggle({
label,
active,
onClick,
className,
children,
}: SearchToggleProps) {
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label={label}
aria-pressed={active}
onClick={onClick}
className={cn(
"font-mono text-[11px] leading-none text-muted-foreground",
active &&
"border-border bg-muted text-foreground hover:bg-muted dark:bg-muted/60",
className,
)}
>
{children}
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{label}
</TooltipContent>
</Tooltip>
);
}
export function TerminalSearchBar({
visible,
query,
onQueryChange,
onFindNext,
onFindPrevious,
onClose,
caseSensitive,
onToggleCaseSensitive,
wholeWord,
onToggleWholeWord,
regex,
onToggleRegex,
resultIndex,
resultCount,
inputRef,
}: TerminalSearchBarProps) {
const { t } = useTranslation();
if (!visible) {
return null;
}
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
e.preventDefault();
if (e.shiftKey) {
onFindPrevious();
} else {
onFindNext();
}
return;
}
if (e.key === "Escape") {
e.preventDefault();
onClose();
return;
}
// Stop keys like Ctrl+C/Ctrl+V from bubbling up to xterm's document-level
// paste/clipboard handling while the search input is focused.
e.stopPropagation();
};
const hasQuery = query.length > 0;
const noResults = hasQuery && resultCount === 0;
const resultLabel = !hasQuery
? ""
: resultCount === 0
? t("terminal.searchNoResults")
: t("terminal.searchResultCount", {
index: resultIndex + 1,
count: resultCount,
});
return (
<TooltipProvider delayDuration={500}>
<div
className="absolute top-2 right-2 z-[130] flex items-center gap-1 border border-border bg-background/95 p-1 shadow-lg backdrop-blur-sm"
onMouseDown={(e) => e.stopPropagation()}
>
<div className="relative flex items-center">
<Input
ref={inputRef}
value={query}
onChange={(e) => onQueryChange(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={t("terminal.searchPlaceholder")}
aria-label={t("terminal.searchPlaceholder")}
className={cn(
"h-7 w-48 pr-16 font-mono",
noResults &&
"border-destructive focus-visible:border-destructive focus-visible:ring-destructive/20",
)}
autoComplete="off"
autoCorrect="off"
autoCapitalize="none"
spellCheck={false}
/>
<span
className={cn(
"pointer-events-none absolute right-2 font-mono text-[11px] tabular-nums select-none",
noResults ? "text-destructive" : "text-muted-foreground",
)}
>
{resultLabel}
</span>
</div>
<Separator orientation="vertical" className="mx-0.5 !h-5" />
<div className="flex items-center gap-0.5">
<SearchToggle
label={t("terminal.searchCaseSensitive")}
active={caseSensitive}
onClick={onToggleCaseSensitive}
>
Aa
</SearchToggle>
<SearchToggle
label={t("terminal.searchWholeWord")}
active={wholeWord}
onClick={onToggleWholeWord}
className="underline underline-offset-2"
>
ab
</SearchToggle>
<SearchToggle
label={t("terminal.searchRegex")}
active={regex}
onClick={onToggleRegex}
>
.*
</SearchToggle>
</div>
<Separator orientation="vertical" className="mx-0.5 !h-5" />
<div className="flex items-center gap-0.5">
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label={t("terminal.searchPrevious")}
disabled={!hasQuery || resultCount === 0}
onClick={onFindPrevious}
>
<ChevronUp />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{t("terminal.searchPrevious")}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label={t("terminal.searchNext")}
disabled={!hasQuery || resultCount === 0}
onClick={onFindNext}
>
<ChevronDown />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{t("terminal.searchNext")}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label={t("terminal.searchClose")}
onClick={onClose}
>
<X />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{t("terminal.searchClose")}
</TooltipContent>
</Tooltip>
</div>
</div>
</TooltipProvider>
);
}
@@ -1,47 +0,0 @@
import type { Terminal } from "@xterm/xterm";
/**
* How much scrollback to hand out by default. Bounded on purpose: a
* long-running session holds megabytes, and nothing consuming this wants all
* of it.
*/
export const DEFAULT_BUFFER_CONTEXT_LINES = 200;
/**
* Serializes the tail of a terminal's buffer to plain text.
*
* Wrapped rows are joined back onto the line they continue. xterm splits one
* logical line across several buffer rows when it wraps, so emitting a newline
* per row would chop long commands in half.
*/
export function getTerminalBufferText(
terminal: Terminal | null | undefined,
maxLines: number = DEFAULT_BUFFER_CONTEXT_LINES,
): string {
if (!terminal || maxLines <= 0) return "";
try {
const buffer = terminal.buffer.active;
const end = buffer.baseY + terminal.rows;
const start = Math.max(0, end - maxLines);
const lines: string[] = [];
for (let i = start; i < end; i++) {
const line = buffer.getLine(i);
if (!line) continue;
const text = line.translateToString(true);
if (line.isWrapped && lines.length > 0) {
lines[lines.length - 1] += text;
} else {
lines.push(text);
}
}
while (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
return lines.join("\n");
} catch {
// Called from a click handler. A disposed terminal returns nothing
// rather than breaking the click.
return "";
}
}
@@ -1,26 +0,0 @@
export type TerminalContextMenuAction = "native" | "copy" | "paste";
export function resolveTerminalContextMenuAction({
rightClickCopyPaste,
copyOnSelect,
hasSelection,
}: {
rightClickCopyPaste: boolean;
copyOnSelect: boolean;
hasSelection: boolean;
}): TerminalContextMenuAction {
if (!rightClickCopyPaste) return "native";
return hasSelection && !copyOnSelect ? "copy" : "paste";
}
export function selectedTextToCopy({
copyOnSelect,
button,
selection,
}: {
copyOnSelect: boolean;
button: number;
selection: string;
}): string | null {
return copyOnSelect && button === 0 && selection ? selection : null;
}
@@ -1,152 +0,0 @@
import type { Terminal } from "@xterm/xterm";
import { getCookie } from "@/main-axios.ts";
import { isPhysicalShortcutKey } from "./terminal-key-event";
export function getUseRightClickCopyPaste(): boolean {
return getCookie("rightClickCopyPaste") !== "false";
}
export interface TerminalClipboardActions {
writeTextToClipboard: (text: string) => Promise<unknown>;
readTextFromClipboard: () => Promise<string>;
}
export interface TerminalClipboardKeyOptions {
/**
* How a plain Ctrl/Cmd+V with no other modifiers is handled.
* "native" (default, matches the SSH terminal) leaves the key alone so the
* browser's own paste event fires - this avoids re-triggering the OS
* clipboard permission prompt on every keystroke, but requires the caller
* to also listen for the DOM "paste" event.
* "explicit" reads the clipboard directly instead, for terminals with no
* such paste event listener of their own.
*/
plainPasteMode?: "native" | "explicit";
}
/**
* Shared copy/paste keyboard shortcuts for xterm-based terminals:
* Ctrl/Cmd+C copies the current selection (Ctrl+C also clears it, matching
* its dual use as SIGINT when nothing is selected), Ctrl+Insert copies
* without clearing, and Ctrl+Shift+C / Ctrl+Shift+V always copy/paste
* regardless of selection state. Returns false (having called
* preventDefault/stopPropagation) once it has handled the event, true
* otherwise so callers can keep processing the keydown.
*/
export function handleTerminalClipboardKeyEvent(
e: KeyboardEvent,
terminal: Terminal,
{ writeTextToClipboard, readTextFromClipboard }: TerminalClipboardActions,
{ plainPasteMode = "native" }: TerminalClipboardKeyOptions = {},
): boolean {
const isCopyKey = isPhysicalShortcutKey(e, "KeyC", "c");
const isPasteKey = isPhysicalShortcutKey(e, "KeyV", "v");
if (
e.ctrlKey &&
!e.shiftKey &&
!e.altKey &&
!e.metaKey &&
isCopyKey &&
terminal.hasSelection()
) {
const selection = terminal.getSelection();
if (selection) {
e.preventDefault();
e.stopPropagation();
writeTextToClipboard(selection);
terminal.clearSelection();
return false;
}
}
if (
(e.metaKey && !e.shiftKey && !e.ctrlKey && !e.altKey && isCopyKey) ||
(e.ctrlKey && !e.shiftKey && !e.altKey && !e.metaKey && e.key === "Insert")
) {
const selection = terminal.getSelection();
if (selection) {
e.preventDefault();
e.stopPropagation();
writeTextToClipboard(selection);
return false;
}
}
if (e.ctrlKey && e.shiftKey && !e.altKey && !e.metaKey && isCopyKey) {
const selection = terminal.getSelection();
if (selection) {
e.preventDefault();
e.stopPropagation();
writeTextToClipboard(selection);
terminal.clearSelection();
return false;
}
}
if (e.ctrlKey && e.shiftKey && !e.altKey && !e.metaKey && isPasteKey) {
e.preventDefault();
e.stopPropagation();
readTextFromClipboard().then((text) => {
if (text) terminal.paste(text);
});
return false;
}
const plainModPaste =
((e.ctrlKey && !e.metaKey) || (e.metaKey && !e.ctrlKey)) &&
!e.shiftKey &&
!e.altKey &&
isPasteKey;
if (plainModPaste) {
if (plainPasteMode === "explicit") {
e.preventDefault();
e.stopPropagation();
readTextFromClipboard().then((text) => {
if (text) terminal.paste(text);
});
}
// "native": leave the event alone so the browser's own paste event fires.
return false;
}
return true;
}
export interface TerminalContextMenuOptions {
/** Optional Ctrl+right-click passthrough, e.g. to open a file manager. */
onCtrlClick?: () => void;
}
/**
* Builds a "contextmenu" listener implementing right-click copy/paste:
* copies the selection if one exists, otherwise pastes. Gated behind the
* user's "rightClickCopyPaste" preference cookie.
*/
export function createTerminalContextMenuHandler(
terminal: Terminal,
{ writeTextToClipboard, readTextFromClipboard }: TerminalClipboardActions,
{ onCtrlClick }: TerminalContextMenuOptions = {},
): (e: MouseEvent) => void {
return (e: MouseEvent) => {
if (e.ctrlKey && onCtrlClick) {
e.preventDefault();
e.stopPropagation();
onCtrlClick();
return;
}
if (!getUseRightClickCopyPaste()) return;
e.preventDefault();
e.stopPropagation();
if (terminal.hasSelection()) {
const text = terminal.getSelection();
writeTextToClipboard(text).then(() => terminal.clearSelection());
} else {
readTextFromClipboard().then((text) => {
if (text) terminal.paste(text);
});
}
};
}
@@ -1,10 +0,0 @@
/**
* Return an absolute POSIX path as one safe shell argument for terminal input.
*
* The backend already constrains this to an absolute agent-visible path. This
* helper handles spaces and shell metacharacters at the final terminal-input
* boundary so configured paths cannot become shell syntax.
*/
export function quoteTerminalImagePath(value: string): string {
return `'${value.replace(/'/g, `'\\''`)}'`;
}
@@ -1,15 +0,0 @@
export type TerminalImageUploadSource = "file" | "clipboard";
export function buildImageUploadFormData(
file: File,
instanceId: string,
source: TerminalImageUploadSource,
clientUploadTimestamp = new Date().toISOString(),
): FormData {
const form = new FormData();
form.append("image", file);
form.append("instanceId", instanceId);
form.append("source", source);
form.append("clientUploadTimestamp", clientUploadTimestamp);
return form;
}
@@ -1,13 +0,0 @@
export function isTabKeyEvent(event: KeyboardEvent): boolean {
return event.key === "Tab" || event.code === "Tab" || event.keyCode === 9;
}
export function isPhysicalShortcutKey(
event: Pick<KeyboardEvent, "code" | "key">,
code: string,
fallbackKey: string,
): boolean {
return event.code
? event.code === code
: event.key.toLowerCase() === fallbackKey;
}
@@ -1,60 +0,0 @@
import type { Host } from "@/types/ui-types";
/**
* The slots the terminal offers plugins, and what it hands them.
*
* - "terminal.toolbar" (buttons): actions are invoked with a TerminalSlotApi.
* - "terminal.dock" (components): a side panel an action opens with
* `openDock(id)`, rendered with TerminalDockProps.
* - "terminal.overlay" (components): always mounted over the terminal and
* given the session's server messages, for connection flows a plugin owns
* (a Tailscale check, say).
*
* Every contribution's `when` sees `{ host }`.
*/
export const TERMINAL_TOOLBAR_SLOT = "terminal.toolbar";
export const TERMINAL_DOCK_SLOT = "terminal.dock";
export const TERMINAL_OVERLAY_SLOT = "terminal.overlay";
export interface TerminalSlotApi {
host: Host | undefined;
/** The visible scrollback, read at call time. */
getBufferText: () => string;
/** Opens a "terminal.dock" contribution by its actionId. */
openDock: (dockId: string, props?: Record<string, unknown>) => void;
/** Types a command into the session and runs it. */
runCommand: (command: string) => void;
}
export interface TerminalDockProps {
host: Host | undefined;
hostId: number | undefined;
hostLabel: string;
/** Whatever the opening action passed to openDock. */
dockProps: Record<string, unknown>;
onClose: () => void;
onRunInTerminal: (command: string) => void;
}
export interface TerminalSessionMessage {
type: string;
[key: string]: unknown;
}
export interface TerminalOverlayProps {
host: Host | undefined;
backgroundColor?: string;
/** Server messages for this session, plus a local "session_closed". */
subscribe: (
listener: (message: TerminalSessionMessage) => void,
) => () => void;
/**
* Holds the connect timeout while the overlay waits on the user, e.g. an
* approval in another window. Release it when done.
*/
holdConnectTimeout: (held: boolean) => void;
/** Ends the attempt with an error shown in the terminal. */
fail: (message: string) => void;
/** Closes the session's connection. */
disconnect: () => void;
}
Loaded 100 of 309 files, more files were not shown because too many files have changed in this diff. Show more