mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
feat: move the terminal fully into the ssh-terminal plugin (B9)
This commit is contained in:
1 parent
535126fd1b
commit
79d77678c9
309 files changed
+41789
-14958
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
{
|
||||
|
||||
@@ -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",
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
@@ -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 });
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()];
|
||||
}
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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" });
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -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
|
||||
// ============================================================================
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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>> {
|
||||
|
||||
File renamed without changes.
@@ -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}>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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
Reference in new issue
Block a user