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

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

No files matched your search

+15 -9
View File
@@ -23,7 +23,7 @@ import {
collabRuntimeStore,
type CollabControlRequest,
} from "./runtime-store.js";
import { sessionManager } from "../terminal/session-manager.js";
import { liveTerminalSessions } from "../live-terminal-sessions.js";
import {
isLiveSession,
isLiveSessionOwnedBy,
@@ -79,9 +79,13 @@ async function revokeStageShare(room: CollabRoomRecord): Promise<void> {
throw new Error("Failed to revoke the active stage share");
}
if (share.protocol === "ssh") {
sessionManager.disconnectShareParticipants(share.sessionId, share.id, {
reason: "The collaboration stage ended",
});
liveTerminalSessions.disconnectShareParticipants(
share.sessionId,
share.id,
{
reason: "The collaboration stage ended",
},
);
void collabRuntimeStore.publish(room.id, {
type: "collab_internal_stage_revoked",
sessionId: share.sessionId,
@@ -241,6 +245,8 @@ router.get(
controlRequests: mayReviewControlRequests
? controlRequests
: controlRequests.filter((request) => request.userId === userId),
// Room events ride on the terminal socket.
eventsWsPath: "/plugin-ws/ssh-terminal/terminal",
});
} catch (error) {
sshLogger.error("Failed to get collab room", error, {
@@ -387,7 +393,7 @@ router.delete(
access.room.stageShareId,
);
if (share) {
sessionManager.disconnectShareParticipants(
liveTerminalSessions.disconnectShareParticipants(
share.sessionId,
share.id,
{
@@ -689,7 +695,7 @@ async function applyStageControl(
room.stageShareId,
);
if (share) {
sessionManager.setRoomShareControl(
liveTerminalSessions.setRoomShareControl(
share.sessionId,
share.id,
controllerUserId,
@@ -722,7 +728,7 @@ collabRuntimeStore.onEvent((roomId, message) => {
.findActiveById(room.stageShareId)
.then((share) => {
if (share) {
sessionManager.setRoomShareControl(
liveTerminalSessions.setRoomShareControl(
share.sessionId,
share.id,
controllerUserId,
@@ -739,7 +745,7 @@ collabRuntimeStore.onEvent((roomId, message) => {
"shareId" in message &&
typeof message.shareId === "string"
) {
sessionManager.disconnectShareParticipants(
liveTerminalSessions.disconnectShareParticipants(
message.sessionId,
message.shareId,
{
@@ -1016,7 +1022,7 @@ router.post(
access.room.stageShareId,
);
if (share) {
sessionManager.disconnectShareParticipants(
liveTerminalSessions.disconnectShareParticipants(
share.sessionId,
share.id,
{
@@ -1,277 +0,0 @@
/**
* Keyboard-interactive answering over the terminal WebSocket.
*
* The classifier decides what a round is; this keeps the terminal's message
* protocol (totp_required, totp_retry, password_required,
* warpgate_auth_required) and its timeouts exactly as the client expects.
*/
import type { WebSocket } from "ws";
import { sshLogger, authLogger } from "../../utils/logger.js";
import { ensureCoreSshAuthProviders } from "./core-providers.js";
import {
classifyKeyboardInteractive,
responsesWithAnswer,
} from "./keyboard-interactive.js";
import type { KeyboardInteractivePrompt, SshConnectHost } from "./types.js";
interface ResolvedCredentials {
username: string;
password?: string;
authType?: string;
}
interface HostFlags {
useWarpgate?: boolean;
[key: string]: unknown;
}
interface AuthContext {
userId: string;
ws: WebSocket;
hostId: number;
isKeyboardInteractive: boolean;
keyboardInteractiveResponded: boolean;
keyboardInteractiveFinish: ((responses: string[]) => void) | null;
totpPromptSent: boolean;
warpgateAuthPromptSent: boolean;
totpTimeout: NodeJS.Timeout | null;
warpgateAuthTimeout: NodeJS.Timeout | null;
totpAttempts: number;
}
const TOTP_TIMEOUT_MS = 180000;
const PUSH_TIMEOUT_MS = 300000;
const WARPGATE_TIMEOUT_MS = 300000;
export class SSHAuthManager {
public context: AuthContext;
constructor(context: AuthContext) {
this.context = context;
}
handleKeyboardInteractive(
name: string,
instructions: string,
_instructionsLang: string,
prompts: KeyboardInteractivePrompt[],
finish: (responses: string[]) => void,
resolvedCredentials: ResolvedCredentials,
hostConfig?: HostFlags,
): void {
ensureCoreSshAuthProviders();
this.context.isKeyboardInteractive = true;
const host = {
...(hostConfig ?? {}),
id: this.context.hostId,
ip: "",
port: 22,
username: resolvedCredentials.username,
password: resolvedCredentials.password,
authType: resolvedCredentials.authType,
} as SshConnectHost;
const decision = classifyKeyboardInteractive(
{ name, instructions, prompts },
host,
);
switch (decision.kind) {
case "auto":
finish(decision.responses);
return;
case "warpgate":
this.handleWarpgate(decision, finish);
return;
case "totp":
this.handleTotp(prompts, decision.promptIndex, finish, host.password);
return;
case "input":
this.handleInput(
prompts,
decision.promptIndex,
decision.isPush,
finish,
host.password,
);
return;
}
}
private handleWarpgate(
decision: { url: string | null; securityKey: string; instructions: string },
finish: (responses: string[]) => void,
): void {
this.context.keyboardInteractiveFinish = () => {
finish([""]);
};
this.context.warpgateAuthPromptSent = true;
this.sendLog("auth", "info", "Warpgate authentication required");
this.context.ws.send(
JSON.stringify({
type: "warpgate_auth_required",
url: decision.url,
securityKey: decision.securityKey,
instructions: decision.instructions,
}),
);
this.context.warpgateAuthTimeout = setTimeout(() => {
if (this.context.keyboardInteractiveFinish) {
this.context.keyboardInteractiveFinish = null;
this.context.warpgateAuthPromptSent = false;
sshLogger.warn("Warpgate authentication timeout", {
operation: "warpgate_timeout",
hostId: this.context.hostId,
});
this.context.ws.send(
JSON.stringify({
type: "error",
message: "Warpgate authentication timeout. Please reconnect.",
}),
);
}
}, WARPGATE_TIMEOUT_MS);
}
private handleTotp(
prompts: KeyboardInteractivePrompt[],
promptIndex: number,
finish: (responses: string[]) => void,
password: string | undefined,
): void {
if (this.context.totpPromptSent) {
sshLogger.warn("TOTP prompt asked again - invalid code", {
operation: "ssh_keyboard_interactive_totp_retry",
hostId: this.context.hostId,
});
authLogger.warn("TOTP verification failed for SSH session", {
operation: "terminal_totp_failed",
userId: this.context.userId,
hostId: this.context.hostId,
});
this.sendLog("auth", "warning", "Invalid TOTP code");
this.context.ws.send(JSON.stringify({ type: "totp_retry" }));
return;
}
this.context.totpPromptSent = true;
this.context.keyboardInteractiveResponded = true;
this.context.keyboardInteractiveFinish = (answers: string[]) => {
finish(
responsesWithAnswer(
prompts,
promptIndex,
(answers[0] || "").trim(),
password,
),
);
};
this.armTimeout(TOTP_TIMEOUT_MS, "totp");
this.sendLog("auth", "info", "TOTP verification required");
authLogger.info("TOTP verification prompt sent to client", {
operation: "terminal_totp_prompt",
userId: this.context.userId,
hostId: this.context.hostId,
});
this.context.ws.send(
JSON.stringify({
type: "totp_required",
prompt: prompts[promptIndex].prompt,
}),
);
}
private handleInput(
prompts: KeyboardInteractivePrompt[],
promptIndex: number,
isPush: boolean,
finish: (responses: string[]) => void,
password: string | undefined,
): void {
if (this.context.keyboardInteractiveResponded) return;
this.context.keyboardInteractiveResponded = true;
this.context.keyboardInteractiveFinish = (answers: string[]) => {
finish(
responsesWithAnswer(
prompts,
promptIndex,
(answers[0] || "").trim(),
password,
),
);
};
this.armTimeout(isPush ? PUSH_TIMEOUT_MS : TOTP_TIMEOUT_MS, "password");
this.sendLog("auth", "info", "Password authentication required");
this.context.ws.send(
JSON.stringify({
type: "password_required",
prompt: prompts[promptIndex].prompt,
echo: prompts[promptIndex].echo,
}),
);
}
private armTimeout(ms: number, kind: "totp" | "password"): void {
if (this.context.totpTimeout) clearTimeout(this.context.totpTimeout);
this.context.totpTimeout = setTimeout(() => {
if (!this.context.keyboardInteractiveFinish) return;
this.context.keyboardInteractiveFinish = null;
if (kind === "totp") {
this.context.totpPromptSent = false;
} else {
this.context.keyboardInteractiveResponded = false;
}
sshLogger.warn(
`${kind === "totp" ? "TOTP" : "Password"} prompt timeout`,
{
operation: `${kind}_timeout`,
hostId: this.context.hostId,
},
);
this.context.ws.send(
JSON.stringify({
type: "error",
message:
kind === "totp"
? "TOTP verification timeout. Please reconnect."
: "Password verification timeout. Please reconnect.",
}),
);
}, ms);
}
sendLog(
stage: string,
level: string,
message: string,
details?: Record<string, unknown>,
): void {
this.context.ws.send(
JSON.stringify({
type: "connection_log",
data: { stage, level, message, details },
}),
);
}
cleanup(): void {
if (this.context.totpTimeout) {
clearTimeout(this.context.totpTimeout);
this.context.totpTimeout = null;
}
if (this.context.warpgateAuthTimeout) {
clearTimeout(this.context.warpgateAuthTimeout);
this.context.warpgateAuthTimeout = null;
}
this.context.keyboardInteractiveFinish = null;
this.context.totpPromptSent = false;
this.context.warpgateAuthPromptSent = false;
this.context.keyboardInteractiveResponded = false;
}
}
+6
View File
@@ -178,6 +178,12 @@ export interface SshAuthProvider {
* transports that can show one (the terminal today).
*/
startInteraction?: (request: SshInteractionRequest) => Promise<void>;
/** Cancels a pending browser step, by request id or by host. */
cancelInteraction?: (request: {
userId: string;
hostId?: number;
requestId?: string;
}) => void | Promise<void>;
prepare: (
config: MutableConnectConfig,
host: SshConnectHost,
@@ -0,0 +1,82 @@
/**
* What core needs from live SSH terminal sessions, through the plugin
* service "sessions.live" rather than an import.
*
* Session sharing, collab and the open-tabs route still live in core and
* reach the sessions the ssh-terminal plugin holds. With no provider (the
* plugin is off) a lookup finds nothing and a control call does nothing,
* because "no such live session" and "the terminal is off" mean the same
* thing to every caller here.
*/
import { getServiceImplementation } from "../plugins/service-registry.js";
export const LIVE_SESSIONS_SERVICE = "sessions.live";
export interface LiveSessionInfo {
id: string;
userId: string;
hostId: number;
hostName: string;
isConnected: boolean;
createdAt: number;
lastDetachedAt: number | null;
tabInstanceId: string | null;
tmuxSessionName: string | null;
cols: number;
rows: number;
}
interface LiveSessionsV1 {
getSession: (sessionId: string) => LiveSessionInfo | null;
listForUser: (userId: string) => LiveSessionInfo[];
ownerEndSession: (sessionId: string, reason: string) => void;
disconnectParticipants: (
sessionId: string,
shareId: string,
options: { reason: string; userId?: string | null },
) => number;
setRoomShareControl: (
sessionId: string,
shareId: string,
controllerUserId: string | null,
) => void;
idleTimeoutMinutes: () => number;
}
function provider(): LiveSessionsV1 | undefined {
return getServiceImplementation<LiveSessionsV1>(
LIVE_SESSIONS_SERVICE,
"^1.0.0",
);
}
export const liveTerminalSessions = {
getSession(sessionId: string): LiveSessionInfo | null {
return provider()?.getSession(sessionId) ?? null;
},
listForUser(userId: string): LiveSessionInfo[] {
return provider()?.listForUser(userId) ?? [];
},
ownerEndSession(sessionId: string, reason: string): void {
provider()?.ownerEndSession(sessionId, reason);
},
disconnectShareParticipants(
sessionId: string,
shareId: string,
options: { reason: string; userId?: string | null },
): number {
return provider()?.disconnectParticipants(sessionId, shareId, options) ?? 0;
},
setRoomShareControl(
sessionId: string,
shareId: string,
controllerUserId: string | null,
): void {
provider()?.setRoomShareControl(sessionId, shareId, controllerUserId);
},
/** How long the terminal keeps a detached session, or null while it is off. */
idleTimeoutMinutes(): number | null {
return provider()?.idleTimeoutMinutes() ?? null;
},
};
@@ -1,4 +1,4 @@
import { sessionManager } from "../terminal/session-manager.js";
import { liveTerminalSessions } from "../live-terminal-sessions.js";
// Remote Desktop is a first-party plugin (plugins/remote-desktop); this goes
// through the same in-core bridge its router is mounted behind, since
// plugins/ is compiled separately from src/backend/ and core cannot
@@ -43,7 +43,7 @@ export function isLiveSessionOwnedBy(
userId: string,
): boolean {
if (protocol === "ssh") {
const session = sessionManager.getSession(sessionId);
const session = liveTerminalSessions.getSession(sessionId);
return !!session && session.isConnected && session.userId === userId;
}
const info = getGuacSessionInfo(sessionId);
@@ -55,7 +55,7 @@ export function isLiveSession(
sessionId: string,
): boolean {
if (protocol === "ssh") {
const session = sessionManager.getSession(sessionId);
const session = liveTerminalSessions.getSession(sessionId);
return !!session && session.isConnected;
}
return !!getGuacSessionInfo(sessionId);
+3 -3
View File
@@ -4,7 +4,7 @@ import type { AuthenticatedRequest } from "../../../types/index.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { PermissionManager } from "../../utils/permission-manager.js";
import { sshLogger } from "../../utils/logger.js";
import { sessionManager } from "../terminal/session-manager.js";
import { liveTerminalSessions } from "../live-terminal-sessions.js";
import {
isLiveSession,
isLiveSessionOwnedBy,
@@ -326,7 +326,7 @@ router.delete(
// guac link only blocks *future* resolves until the guest's own socket ends.
if (share.protocol === "ssh") {
try {
sessionManager.ownerEndSession(
liveTerminalSessions.ownerEndSession(
share.sessionId,
"Session share revoked by owner",
);
@@ -482,7 +482,7 @@ router.post(
}
if (share.protocol === "ssh") {
sessionManager.ownerEndSession(
liveTerminalSessions.ownerEndSession(
share.sessionId,
"Session ended by owner",
);
+1 -53
View File
@@ -12,7 +12,7 @@ import ssh2Pkg, {
type KnownPublicKey = KnownPublicKeys[number];
const { AgentProtocol, BaseAgent } = ssh2Pkg;
const { BaseAgent } = ssh2Pkg;
const DEFAULT_PORT_KNOCK_TIMEOUT_MS = 1000;
type Sleep = (ms: number) => Promise<void>;
@@ -27,58 +27,6 @@ type PortKnockingOptions = {
const sleep: Sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
export class MemoryAgent extends BaseAgent {
private key: ParsedKey;
constructor(key: ParsedKey) {
super();
this.key = key;
}
getIdentities(cb: IdentityCallback<ParsedKey>): void {
cb(null, [this.key]);
}
getStream(cb: GetStreamCallback): void {
const protocol = new AgentProtocol(false);
protocol.on("identities", (request) => {
protocol.getIdentitiesReply(request, [this.key]);
});
protocol.on("sign", (request, publicKey, data, options) => {
this.sign(publicKey, data, options, (error, signature) => {
if (error || !signature) return protocol.failureReply(request);
protocol.signReply(request, signature);
});
});
cb(null, protocol);
}
sign(
_pubKey: ParsedKey | Buffer | string,
data: Buffer,
optionsOrCb: SigningRequestOptions | SignCallback,
cb?: SignCallback,
): void {
const callback = typeof optionsOrCb === "function" ? optionsOrCb : cb!;
const options = typeof optionsOrCb === "function" ? {} : optionsOrCb;
try {
const algo =
options.hash === "sha256"
? "rsa-sha2-256"
: options.hash === "sha512"
? "rsa-sha2-512"
: undefined;
const signature = this.key.sign(data, algo);
callback(null, signature);
} catch (err) {
callback(err instanceof Error ? err : new Error(String(err)));
}
}
}
export async function resolveAgentSocket(
terminalConfig: Record<string, unknown> | undefined,
): Promise<{ socketPath: string } | { error: string }> {
File diff suppressed because it is too large. Load diff
@@ -1,934 +0,0 @@
import { type Client, type ClientChannel } from "ssh2";
import { WebSocket } from "ws";
import fs from "fs";
import path from "path";
import { sshLogger } from "../../utils/logger.js";
import {
getCurrentSettingValue,
createCurrentSessionRecordingRepository,
} from "../../database/repositories/factory.js";
const MAX_BUFFER_BYTES = 512 * 1024;
const DATA_DIR = process.env.DATA_DIR ?? "./db/data";
const SESSION_LOGS_DIR = path.join(DATA_DIR, "session_logs");
const DEFAULT_TIMEOUT_MINUTES = 30;
const HEALTH_CHECK_INTERVAL_MS = 60_000;
const MAX_SESSIONS_PER_USER = 10;
// Coalesces recording writes: a chatty SSH stream can emit dozens of "data"
// events per second, and appending to disk on every single one saturates the
// libuv threadpool (default size 4), starving unrelated fs/DNS/crypto work
// and stalling the WS ping/pong health check enough to look like connection
// drops. Batch pending lines and flush on a short trailing edge instead.
const RECORDING_FLUSH_INTERVAL_MS = 300;
export interface SessionParticipant {
ws: WebSocket;
userId: string | null; // null for anonymous link guests
permissionLevel: "read-write" | "read-only";
isOwner: boolean;
displayName?: string;
guestLabel?: string;
tabInstanceId?: string;
joinedViaShareId?: string;
}
export interface TerminalSession {
id: string;
userId: string;
hostId: number;
hostName: string;
tabInstanceId?: string;
attachedTabInstanceId?: string;
sshConn: Client | null;
sshStream: ClientChannel | null;
jumpClient: Client | null;
cols: number;
rows: number;
isConnected: boolean;
createdAt: number;
participants: Map<string, SessionParticipant>;
lastDetachedAt: number | null;
detachTimeout: NodeJS.Timeout | null;
outputBuffer: string[];
outputBufferBytes: number;
recordingPath: string | null;
recordingHeader: string | null;
recordingBytes: number;
recordingId: number | null;
recordingWriteChain: Promise<void>;
recordingPersistChain: Promise<void>;
pendingRecordingData: string;
recordingFlushTimer: NodeJS.Timeout | null;
tmuxSessionName: string | null;
sessionLoggingEnabled: boolean;
sessionStartedAt: number;
lastPersistedBytes: number;
terminatedByOwner: boolean;
terminationReason: string | null;
}
/** Message types a non-owner participant may legally send. */
const NON_OWNER_ALLOWED_MESSAGE_TYPES = new Set([
"input",
"ping",
"disconnect",
]);
/**
* Server-side gate for whether a participant may send a given WS message
* type. The owner may send anything; non-owners are limited to input (if
* read-write), ping, and disconnect. Pure function so read-only enforcement
* is unit-testable without a real WebSocketServer.
*/
export function isMessageAllowedForParticipant(
participant: Pick<SessionParticipant, "isOwner" | "permissionLevel"> | null,
messageType: string,
): boolean {
if (!participant || participant.isOwner) return true;
if (!NON_OWNER_ALLOWED_MESSAGE_TYPES.has(messageType)) return false;
if (messageType === "input" && participant.permissionLevel === "read-only") {
return false;
}
return true;
}
class TerminalSessionManager {
private static instance: TerminalSessionManager;
private sessions = new Map<string, TerminalSession>();
private healthCheckTimer: NodeJS.Timeout | null = null;
private constructor() {
this.healthCheckTimer = setInterval(
() => this.healthCheck(),
HEALTH_CHECK_INTERVAL_MS,
);
}
static getInstance(): TerminalSessionManager {
if (!TerminalSessionManager.instance) {
TerminalSessionManager.instance = new TerminalSessionManager();
}
return TerminalSessionManager.instance;
}
createSession(
userId: string,
hostId: number,
hostName: string,
cols: number,
rows: number,
tabInstanceId?: string,
sessionLoggingEnabled = true,
): string {
const userSessions = this.getUserSessions(userId);
if (userSessions.length >= MAX_SESSIONS_PER_USER) {
const detached = userSessions
.filter((s) => this.getOwnerParticipant(s) === null)
.sort(
(a, b) =>
(a.lastDetachedAt ?? a.createdAt) -
(b.lastDetachedAt ?? b.createdAt),
);
if (detached.length > 0) {
this.destroySession(detached[0].id);
}
}
if (tabInstanceId) {
const tabSessions = userSessions.filter(
(s) => s.tabInstanceId === tabInstanceId,
);
for (const existing of tabSessions) {
const isLiveSession =
existing.isConnected &&
existing.sshStream != null &&
!existing.sshStream.destroyed;
if (isLiveSession) {
// Don't destroy a live session (even if detached) — the caller should attach instead
sshLogger.warn(
"Tab instance has live session, skipping duplicate create",
{
operation: "session_tab_duplicate_skip",
existingSessionId: existing.id,
tabInstanceId,
hasAttachedWs: this.getOwnerParticipant(existing) !== null,
},
);
return existing.id;
}
sshLogger.warn("Tab instance already has session, destroying old", {
operation: "session_tab_duplicate_cleanup",
existingSessionId: existing.id,
tabInstanceId,
});
this.destroySession(existing.id);
}
}
const id = crypto.randomUUID();
const now = Date.now();
let recordingPath: string | null = null;
let recordingHeader: string | null = null;
if (sessionLoggingEnabled) {
const userLogDir = path.join(SESSION_LOGS_DIR, userId);
recordingPath = path.join(userLogDir, `${id}.cast`);
recordingHeader = `${JSON.stringify({
version: 2,
width: cols,
height: rows,
timestamp: Math.floor(now / 1000),
env: { TERM: "xterm-256color", SHELL: "/bin/sh" },
})}\n`;
}
const session: TerminalSession = {
id,
userId,
hostId,
hostName,
tabInstanceId,
sshConn: null,
sshStream: null,
jumpClient: null,
cols,
rows,
isConnected: false,
createdAt: now,
participants: new Map(),
lastDetachedAt: null,
detachTimeout: null,
outputBuffer: [],
outputBufferBytes: 0,
recordingPath,
recordingHeader,
recordingBytes: 0,
recordingId: null,
recordingWriteChain: Promise.resolve(),
recordingPersistChain: Promise.resolve(),
pendingRecordingData: "",
recordingFlushTimer: null,
tmuxSessionName: null,
sessionLoggingEnabled,
sessionStartedAt: now,
lastPersistedBytes: 0,
terminatedByOwner: false,
terminationReason: null,
};
this.sessions.set(id, session);
sshLogger.info("Terminal session created", {
operation: "session_created",
sessionId: id,
userId,
hostId,
});
return id;
}
getSession(sessionId: string | null): TerminalSession | null {
if (!sessionId) return null;
return this.sessions.get(sessionId) ?? null;
}
setSSHState(
sessionId: string,
conn: Client,
stream: ClientChannel,
jumpClient?: Client | null,
): void {
const session = this.sessions.get(sessionId);
if (!session) return;
session.sshConn = conn;
session.sshStream = stream;
session.jumpClient = jumpClient ?? null;
session.isConnected = true;
}
/** Finds the owner's participant entry, if currently attached. */
private getOwnerParticipant(
session: TerminalSession,
): SessionParticipant | null {
for (const participant of session.participants.values()) {
if (participant.isOwner) return participant;
}
return null;
}
private getOwnerEntry(
session: TerminalSession,
): [string, SessionParticipant] | null {
for (const entry of session.participants.entries()) {
if (entry[1].isOwner) return entry;
}
return null;
}
attachWs(
sessionId: string,
userId: string,
ws: WebSocket,
tabInstanceId?: string,
): TerminalSession | null {
const session = this.sessions.get(sessionId);
if (!session) {
sshLogger.warn("Session not found for attachment", {
operation: "session_attach_not_found",
sessionId,
userId,
});
return null;
}
if (session.userId !== userId) {
sshLogger.warn("Session userId mismatch", {
operation: "session_attach_user_mismatch",
sessionId,
expectedUserId: session.userId,
providedUserId: userId,
});
return null;
}
if (!session.isConnected) {
sshLogger.warn("Session not connected", {
operation: "session_attach_not_connected",
sessionId,
userId,
createdAt: session.createdAt,
elapsed: Date.now() - session.createdAt,
});
return null;
}
const ownerParticipant = this.getOwnerParticipant(session);
const isDetached =
!ownerParticipant || ownerParticipant.ws.readyState !== WebSocket.OPEN;
const isOriginalTab =
(session.attachedTabInstanceId ?? session.tabInstanceId) ===
tabInstanceId;
if (
!isDetached &&
!isOriginalTab &&
session.tabInstanceId &&
tabInstanceId
) {
sshLogger.warn("Session actively attached to different tab instance", {
operation: "session_attach_instance_conflict",
sessionId,
sessionInstanceId: session.tabInstanceId,
providedInstanceId: tabInstanceId,
});
try {
ws.send(
JSON.stringify({
type: "sessionExpired",
sessionId,
message: "Session belongs to a different tab instance",
}),
);
} catch {
/* ignore */
}
return null;
}
if (
session.tabInstanceId &&
tabInstanceId &&
session.tabInstanceId !== tabInstanceId
) {
sshLogger.info(
"Session attached to different tab instance (split-screen)",
{
operation: "session_attach_split_screen",
originalInstanceId: session.tabInstanceId,
newInstanceId: tabInstanceId,
sessionId,
},
);
}
const ownerEntry = this.getOwnerEntry(session);
if (ownerEntry && ownerEntry[1].ws !== ws) {
try {
ownerEntry[1].ws.send(
JSON.stringify({
type: "sessionTakenOver",
sessionId,
message: "Session was attached from another tab",
}),
);
} catch {
/* ignore */
}
session.participants.delete(ownerEntry[0]);
}
if (session.detachTimeout) {
clearTimeout(session.detachTimeout);
session.detachTimeout = null;
}
const participantId = crypto.randomUUID();
session.participants.set(participantId, {
ws,
userId,
permissionLevel: "read-write",
isOwner: true,
tabInstanceId,
});
session.attachedTabInstanceId = tabInstanceId;
session.lastDetachedAt = null;
this.broadcastParticipants(sessionId);
sshLogger.info("WebSocket attached to session", {
operation: "session_attach",
sessionId,
userId,
tabInstanceId,
});
return session;
}
/**
* Adds a non-owner participant (in-app share join or anonymous link guest).
* Purely additive - never evicts the owner or any other participant.
*/
joinAsParticipant(
sessionId: string,
ws: WebSocket,
opts: {
userId: string | null;
permissionLevel: "read-write" | "read-only";
displayName?: string;
guestLabel?: string;
tabInstanceId?: string;
shareId?: string;
},
): TerminalSession | null {
const session = this.sessions.get(sessionId);
if (!session || !session.isConnected) return null;
const participantId = crypto.randomUUID();
session.participants.set(participantId, {
ws,
userId: opts.userId,
permissionLevel: opts.permissionLevel,
isOwner: false,
displayName: opts.displayName,
guestLabel: opts.guestLabel,
tabInstanceId: opts.tabInstanceId,
joinedViaShareId: opts.shareId,
});
this.broadcastParticipants(sessionId);
sshLogger.info("Participant joined shared session", {
operation: "session_join_participant",
sessionId,
userId: opts.userId,
permissionLevel: opts.permissionLevel,
shareId: opts.shareId,
});
return session;
}
/**
* Tells everyone in a shared session who is present. Sent on join and
* leave, and only while someone besides the owner is (or just was) in the
* room - a solo owner never receives presence traffic.
*/
private broadcastParticipants(sessionId: string): void {
const session = this.sessions.get(sessionId);
if (!session) return;
const participants = Array.from(session.participants.values()).map(
(participant) => ({
isOwner: participant.isOwner,
permissionLevel: participant.permissionLevel,
label: participant.displayName ?? participant.guestLabel ?? null,
}),
);
if (participants.every((participant) => participant.isOwner)) return;
this.broadcast(sessionId, { type: "participants", participants });
}
/**
* Grants stage control: participants joined via this share become
* read-write only while they are the controller. The owner is untouched.
*/
setRoomShareControl(
sessionId: string,
shareId: string,
controllerUserId: string | null,
): void {
const session = this.sessions.get(sessionId);
if (!session) return;
for (const participant of session.participants.values()) {
if (participant.isOwner || participant.joinedViaShareId !== shareId)
continue;
participant.permissionLevel =
controllerUserId && participant.userId === controllerUserId
? "read-write"
: "read-only";
}
}
/**
* Disconnects non-owner participants that joined through one share.
* Supplying userId narrows the kick to that authenticated user; null targets
* anonymous guests. Omitting it revokes the share for every participant.
*/
disconnectShareParticipants(
sessionId: string,
shareId: string,
options: { userId?: string | null; reason: string },
): number {
const session = this.sessions.get(sessionId);
if (!session) return 0;
const filterByUser = Object.hasOwn(options, "userId");
let disconnected = 0;
for (const [id, participant] of session.participants.entries()) {
if (
participant.isOwner ||
participant.joinedViaShareId !== shareId ||
(filterByUser && participant.userId !== options.userId)
) {
continue;
}
session.participants.delete(id);
disconnected += 1;
if (participant.ws.readyState === WebSocket.OPEN) {
try {
participant.ws.send(
JSON.stringify({
type: "sessionExpired",
sessionId,
message: options.reason,
}),
);
participant.ws.close(1008, options.reason);
} catch {
participant.ws.terminate();
}
}
}
if (disconnected > 0) this.broadcastParticipants(sessionId);
return disconnected;
}
/** Fans out a message to every OPEN participant socket; skips closed ones and send failures. */
broadcast(sessionId: string, message: object): void {
const session = this.sessions.get(sessionId);
if (!session) return;
const payload = JSON.stringify(message);
for (const participant of session.participants.values()) {
if (participant.ws.readyState !== WebSocket.OPEN) continue;
try {
participant.ws.send(payload);
} catch {
/* ignore individual send failures, keep broadcasting to the rest */
}
}
}
/** Finds the participant entry (owner or not) for a given socket. */
getParticipantForWs(
session: TerminalSession,
ws: WebSocket,
): SessionParticipant | null {
for (const participant of session.participants.values()) {
if (participant.ws === ws) return participant;
}
return null;
}
/**
* Removes a non-owner participant's socket. No detach timeout or session
* destruction side effects - a guest leaving must never end the session.
*/
removeParticipant(sessionId: string, ws: WebSocket): void {
const session = this.sessions.get(sessionId);
if (!session) return;
for (const [id, participant] of session.participants.entries()) {
if (participant.ws === ws && !participant.isOwner) {
session.participants.delete(id);
this.broadcastParticipants(sessionId);
sshLogger.info("Participant left shared session", {
operation: "session_leave_participant",
sessionId,
userId: participant.userId,
});
return;
}
}
}
/** Broadcasts termination to all guests, then destroys the session. */
ownerEndSession(sessionId: string, reason: string): void {
const session = this.sessions.get(sessionId);
if (!session) return;
this.broadcast(sessionId, { type: "sessionTerminatedByOwner", reason });
session.terminatedByOwner = true;
session.terminationReason = reason;
sshLogger.info("Owner ended shared session", {
operation: "session_owner_end",
sessionId,
reason,
});
this.destroySession(sessionId);
}
detachWs(sessionId: string): void {
const session = this.sessions.get(sessionId);
if (!session) return;
if (session.detachTimeout) {
clearTimeout(session.detachTimeout);
session.detachTimeout = null;
}
const ownerEntry = this.getOwnerEntry(session);
if (ownerEntry) {
session.participants.delete(ownerEntry[0]);
}
session.lastDetachedAt = Date.now();
// Persist log immediately when the user detaches so it appears right away,
// regardless of whether the session is later reattached or times out.
this.maybePersistLog(session);
const timeoutMs = this.getTimeoutMs();
session.detachTimeout = setTimeout(() => {
sshLogger.info("Session idle timeout expired", {
operation: "session_idle_timeout",
sessionId,
userId: session.userId,
});
this.destroySession(sessionId);
}, timeoutMs);
sshLogger.info("WebSocket detached from session", {
operation: "session_detach",
sessionId,
userId: session.userId,
timeoutMinutes: timeoutMs / 60_000,
});
}
destroySession(sessionId: string): void {
const session = this.sessions.get(sessionId);
if (!session) return;
if (session.detachTimeout) {
clearTimeout(session.detachTimeout);
session.detachTimeout = null;
}
this.maybePersistLog(session, true);
if (session.recordingPath && session.recordingBytes === 0) {
fs.promises.unlink(session.recordingPath).catch(() => {});
}
for (const participant of session.participants.values()) {
if (participant.isOwner) continue;
if (participant.ws.readyState !== WebSocket.OPEN) continue;
try {
participant.ws.send(
JSON.stringify({
type: "sessionExpired",
sessionId,
message: "Session has ended",
}),
);
} catch {
/* ignore */
}
}
session.participants.clear();
if (session.sshStream) {
try {
session.sshStream.end();
} catch {
/* ignore */
}
session.sshStream = null;
}
if (session.sshConn) {
try {
session.sshConn.end();
} catch {
/* ignore */
}
session.sshConn = null;
}
if (session.jumpClient) {
try {
session.jumpClient.end();
} catch {
/* ignore */
}
session.jumpClient = null;
}
session.isConnected = false;
session.outputBuffer = [];
session.outputBufferBytes = 0;
this.sessions.delete(sessionId);
sshLogger.info("Terminal session destroyed", {
operation: "session_destroyed",
sessionId,
userId: session.userId,
hostId: session.hostId,
});
}
private maybePersistLog(session: TerminalSession, force = false): void {
if (!session.sessionLoggingEnabled) return;
if (session.recordingFlushTimer) {
clearTimeout(session.recordingFlushTimer);
session.recordingFlushTimer = null;
this.flushRecording(session);
}
if (session.recordingBytes === 0) return;
if (!force && session.recordingBytes === session.lastPersistedBytes) return;
session.lastPersistedBytes = session.recordingBytes;
session.recordingPersistChain = session.recordingPersistChain
.then(() => this.persistSessionLog(session))
.catch((err) => {
sshLogger.warn("Failed to persist session log", {
operation: "session_log_persist_error",
sessionId: session.id,
error: err instanceof Error ? err.message : String(err),
});
});
}
private async persistSessionLog(session: TerminalSession): Promise<void> {
if (!session.recordingPath) return;
await session.recordingWriteChain;
const endedAt = Date.now();
const duration = Math.floor((endedAt - session.sessionStartedAt) / 1000);
try {
const repo = createCurrentSessionRecordingRepository();
if (session.recordingId == null) {
const created = await repo.create({
hostId: session.hostId,
userId: session.userId,
startedAt: new Date(session.sessionStartedAt).toISOString(),
endedAt: new Date(endedAt).toISOString(),
duration,
recordingPath: session.recordingPath,
protocol: "ssh",
format: "asciicast",
terminatedByOwner: session.terminatedByOwner || undefined,
terminationReason: session.terminationReason ?? undefined,
});
session.recordingId = created.id;
} else {
await repo.updateEnded(session.recordingId, {
endedAt: new Date(endedAt).toISOString(),
duration,
terminatedByOwner: session.terminatedByOwner || undefined,
terminationReason: session.terminationReason ?? undefined,
});
}
} catch (err) {
sshLogger.warn("Failed to insert session recording row", {
operation: "session_recording_insert_error",
sessionId: session.id,
error: err instanceof Error ? err.message : String(err),
});
}
sshLogger.info("Session log persisted", {
operation: "session_log_persisted",
sessionId: session.id,
userId: session.userId,
hostId: session.hostId,
duration,
bytes: session.recordingBytes,
});
}
getUserSessions(userId: string): TerminalSession[] {
const result: TerminalSession[] = [];
for (const session of this.sessions.values()) {
if (session.userId === userId) {
result.push(session);
}
}
return result;
}
bufferOutput(sessionId: string, data: string): void {
const session = this.sessions.get(sessionId);
if (!session) return;
session.outputBuffer.push(data);
session.outputBufferBytes += data.length;
while (
session.outputBufferBytes > MAX_BUFFER_BYTES &&
session.outputBuffer.length > 0
) {
const removed = session.outputBuffer.shift();
if (removed) session.outputBufferBytes -= removed.length;
}
this.recordSessionEvent(session, "o", data);
}
bufferInput(sessionId: string, data: string): void {
const session = this.sessions.get(sessionId);
if (!session) return;
this.recordSessionEvent(session, "i", data);
}
bufferResize(sessionId: string, cols: number, rows: number): void {
const session = this.sessions.get(sessionId);
if (!session) return;
this.recordSessionEvent(session, "r", `${cols}x${rows}`);
}
private recordSessionEvent(
session: TerminalSession,
type: "i" | "o" | "r",
data: string,
): void {
if (!session.sessionLoggingEnabled || !session.recordingPath || !data)
return;
const elapsed = (Date.now() - session.sessionStartedAt) / 1000;
const line = `${JSON.stringify([elapsed, type, data])}\n`;
session.recordingBytes += Buffer.byteLength(line);
session.pendingRecordingData += line;
if (!session.recordingFlushTimer) {
session.recordingFlushTimer = setTimeout(() => {
session.recordingFlushTimer = null;
this.flushRecording(session);
}, RECORDING_FLUSH_INTERVAL_MS);
}
}
/** Coalesces buffered recording lines into a single disk write. */
private flushRecording(session: TerminalSession): void {
if (!session.recordingPath || !session.pendingRecordingData) return;
const chunk = session.pendingRecordingData;
session.pendingRecordingData = "";
const firstWrite = session.recordingBytes === Buffer.byteLength(chunk);
session.recordingWriteChain = session.recordingWriteChain.then(async () => {
if (firstWrite) {
await fs.promises.mkdir(path.dirname(session.recordingPath!), {
recursive: true,
});
await fs.promises.writeFile(
session.recordingPath!,
`${session.recordingHeader}${chunk}`,
"utf8",
);
return;
}
await fs.promises.appendFile(session.recordingPath!, chunk, "utf8");
});
}
flushBuffer(session: TerminalSession): string | null {
if (session.outputBuffer.length === 0) return null;
const data = session.outputBuffer.join("");
session.outputBuffer = [];
session.outputBufferBytes = 0;
return data;
}
getBuffer(session: TerminalSession): string | null {
if (session.outputBuffer.length === 0) return null;
return session.outputBuffer.join("");
}
private getTimeoutMs(): number {
try {
const value = getCurrentSettingValue("terminal_session_timeout_minutes");
if (value) {
const minutes = parseInt(value, 10);
if (!isNaN(minutes) && minutes > 0) {
return minutes * 60_000;
}
}
} catch {
// DB not available, use default
}
return DEFAULT_TIMEOUT_MINUTES * 60_000;
}
private healthCheck(): void {
const toDestroy: string[] = [];
const now = Date.now();
const GRACE_PERIOD_MS = 10_000;
for (const [id, session] of this.sessions) {
if (!session.isConnected) continue;
const hasOpenParticipant = Array.from(session.participants.values()).some(
(p) => p.ws.readyState === WebSocket.OPEN,
);
if (hasOpenParticipant) {
continue;
}
if (session.sshStream?.destroyed) {
const detachedDuration = session.lastDetachedAt
? now - session.lastDetachedAt
: 0;
if (detachedDuration > GRACE_PERIOD_MS) {
sshLogger.info(
"SSH stream destroyed during detach window, cleaning up",
{
operation: "session_health_check_stream_destroyed",
sessionId: id,
userId: session.userId,
detachedFor: detachedDuration,
},
);
toDestroy.push(id);
}
}
if (!session.sshConn) {
toDestroy.push(id);
}
}
for (const id of toDestroy) {
this.destroySession(id);
}
}
destroyAll(): void {
for (const id of [...this.sessions.keys()]) {
this.destroySession(id);
}
if (this.healthCheckTimer) {
clearInterval(this.healthCheckTimer);
this.healthCheckTimer = null;
}
}
}
export const sessionManager = TerminalSessionManager.getInstance();