diff --git a/app/paramiko_channels.py b/app/paramiko_channels.py index 2900ca8..aadf560 100644 --- a/app/paramiko_channels.py +++ b/app/paramiko_channels.py @@ -1,5 +1,7 @@ """Bound Paramiko channel handshakes and long-lived channel operations.""" +import logging +import re import socket import struct import time @@ -11,6 +13,79 @@ import config +SSH_OPEN_FAILED_RESOURCE_SHORTAGE = 4 +_PARAMIKO_TRANSPORT_LOGGER = 'paramiko.transport' +_CHANNEL_OPEN_FAILURE_LOG = re.compile( + r'\ASecsh channel (?P[0-9]+) open FAILED: .*: ' + r'(?PAdministratively prohibited|Connect failed|' + r'Unknown channel type|Resource shortage|\(unknown code\))\Z', + re.DOTALL, +) +_CHANNEL_OPEN_FAILURE_FILTER_MARKER = ( + '_webssh_paramiko_channel_open_failure_filter' +) + + +class _ChannelOpenFailureLogFilter(logging.Filter): + """Remove the SSH server's description from Paramiko failure records.""" + + _webssh_paramiko_channel_open_failure_filter = True + + def filter(self, record): + if ( + record.name != _PARAMIKO_TRANSPORT_LOGGER + or record.levelno != logging.ERROR + or record.args + or not isinstance(record.msg, str) + ): + return True + match = _CHANNEL_OPEN_FAILURE_LOG.fullmatch(record.msg) + if match: + record.msg = ( + f"Secsh channel {match.group('channel_id')} open FAILED: " + f"{match.group('reason')} (server description omitted)" + ) + record.args = () + return True + + +def _install_channel_open_failure_log_filter(): + logger = logging.getLogger(_PARAMIKO_TRANSPORT_LOGGER) + for existing_filter in logger.filters: + if getattr( + existing_filter, + _CHANNEL_OPEN_FAILURE_FILTER_MARKER, + False, + ): + return existing_filter + channel_filter = _ChannelOpenFailureLogFilter() + logger.addFilter(channel_filter) + return channel_filter + + +_install_channel_open_failure_log_filter() + + +def optional_channel_rejection_fields(error): + """Describe a remote capacity rejection for an optional SSH channel. + + RFC 4254 reason code 4 is supplied by the remote SSH server. Keep the + server-provided text out of application logs because it is untrusted, and + leave retry policy to the caller because this condition can be temporary. + Do not classify other channel failures here: callers may need to retry or + fail a primary SSH operation for those errors. + """ + if not isinstance(error, paramiko.ChannelException): + return None + code = getattr(error, 'code', None) + if type(code) is not int or code != SSH_OPEN_FAILED_RESOURCE_SHORTAGE: + return None + return { + 'ssh_channel_code': SSH_OPEN_FAILED_RESOURCE_SHORTAGE, + 'ssh_channel_reason': 'remote_resource_shortage', + } + + class BoundedSFTPClient(paramiko.SFTPClient): """Reject attacker-declared SFTP packets before allocating their body.""" diff --git a/app/session_insights.py b/app/session_insights.py index afe9fe3..93c220a 100644 --- a/app/session_insights.py +++ b/app/session_insights.py @@ -8,6 +8,8 @@ from paramiko import SSHException from . import ssh_manager +from .audit_logger import log_info +from .paramiko_channels import optional_channel_rejection_fields DEFAULT_MAX_BYTES = 16 * 1024 @@ -443,7 +445,17 @@ def parse_output(): return None, 'unsupported' return parse_output() - except SSHException: + except SSHException as error: + rejection = optional_channel_rejection_fields(error) + if rejection: + log_info( + 'Diagnostics temporarily unavailable because the remote SSH ' + 'server reported insufficient capacity for an additional ' + 'channel', + session_id=session_id, + **rejection, + ) + return None, 'resource_shortage' return None, 'transient' except (OSError, socket.timeout): return None, 'transient' diff --git a/app/sftp_handler.py b/app/sftp_handler.py index 85e2b9b..27cbd54 100644 --- a/app/sftp_handler.py +++ b/app/sftp_handler.py @@ -23,8 +23,11 @@ from paramiko.sftp_attr import SFTPAttributes import config from . import ssh_manager -from .paramiko_channels import open_sftp_client -from .audit_logger import log_warning, log_error +from .paramiko_channels import ( + open_sftp_client, + optional_channel_rejection_fields, +) +from .audit_logger import log_info, log_warning, log_error from .file_backend import FileReaderLease, FileWriteOutcome _sftp_cache = {} @@ -34,6 +37,7 @@ _sftp_session_locks_lock = Lock() CAPABILITY_RATE_LIMIT = '10 per minute' CAPABILITY_TIMEOUT = 3.0 +CAPABILITY_RESOURCE_SHORTAGE = 'resource_shortage' _capability_probe_locks = {} _capability_probe_locks_guard = Lock() @@ -237,6 +241,19 @@ def public_sftp_error(error, fallback=_PUBLIC_SFTP_ERROR): return fallback +def _log_sftp_channel_rejection(identifier, error): + rejection = optional_channel_rejection_fields(error) + if not rejection: + return False + log_info( + 'SFTP temporarily unavailable because the remote SSH server reported ' + 'insufficient capacity for an additional channel', + session_id=identifier, + **rejection, + ) + return True + + class UploadConflict(SFTPOperationError): """The upload destination exists and replacement was not approved.""" @@ -897,6 +914,7 @@ def get_sftp_client(session_id): return sftp, None except Exception as e: + _log_sftp_channel_rejection(session_id, e) return None, public_sftp_error(e, 'Failed to open SFTP channel') def get_sftp_client_fresh(session_id): @@ -914,6 +932,7 @@ def get_sftp_client_fresh(session_id): return sftp, None except Exception as e: + _log_sftp_channel_rejection(session_id, e) return None, public_sftp_error(e, 'Failed to open SFTP channel') @@ -1017,8 +1036,10 @@ def probe_sftp_capability(session_id): Opening the subsystem alone is insufficient for some appliances, so the probe performs one bounded directory read through a fresh short-lived channel. It never waits behind cached SFTP operations and concurrent probes - for the same session are deduplicated. ``None`` is retryable busy/timeout. - Remote exception details intentionally stay server-side. + for the same session are deduplicated. ``None`` is a generic retryable busy + or timeout result; ``CAPABILITY_RESOURCE_SHORTAGE`` preserves the distinct + retry signal for temporary remote channel exhaustion. Remote exception + details intentionally stay server-side. """ probe_lock = _acquire_capability_probe(session_id) if probe_lock is None: @@ -1071,11 +1092,13 @@ def expire_probe(): return True except (socket.timeout, TimeoutError): return None - except Exception: + except Exception as error: if deadline_expired.is_set() or ( deadline is not None and time.monotonic() >= deadline ): return None + if _log_sftp_channel_rejection(session_id, error): + return CAPABILITY_RESOURCE_SHORTAGE return False finally: if deadline_guard is not None: diff --git a/app/socket_events.py b/app/socket_events.py index e0c81e1..1bad97f 100644 --- a/app/socket_events.py +++ b/app/socket_events.py @@ -133,6 +133,8 @@ _ENGINEIO_REJECTION_POLL_SECONDS = 0.01 _smb_attempts_lock = threading.RLock() _smb_attempts = {} +_ssh_connect_attempts_lock = threading.RLock() +_ssh_connect_attempts = {} _ssh_banner_prompts_lock = threading.RLock() _ssh_banner_prompts = {} SSH_AUTH_BANNER_DECISION_TIMEOUT = 60 @@ -154,6 +156,74 @@ def _cancel_ssh_banner_prompts_for_socket(socket_sid): prompt['event'].set() +def _ssh_request_id(payload): + if not isinstance(payload, dict): + return '' + request_id = payload.get('client_request_id') + if not isinstance(request_id, str) or not _SMB_REQUEST_ID.fullmatch( + request_id + ): + return '' + return request_id + + +def _cancel_ssh_banner_prompt_for_request(user_id, socket_sid, request_id): + with _ssh_banner_prompts_lock: + prompts = [ + prompt + for prompt in _ssh_banner_prompts.values() + if prompt['socket_sid'] == socket_sid + and prompt['user_id'] == user_id + and prompt.get('client_request_id') == request_id + ] + for prompt in prompts: + prompt['accepted'] = False + prompt['event'].set() + + +def _try_cancel_ssh_attempt(attempt): + """Cancel an attempt unless an irreversible connection step won first.""" + commit_lock = attempt.get('commit_lock') + if commit_lock is None: + if attempt.get('state') == 'committed': + return False + attempt['state'] = 'cancelled' + attempt['cancel_event'].set() + return True + with commit_lock: + state = attempt.get('state', 'pending') + if state == 'committed' or state == 'finished': + return False + attempt['state'] = 'cancelled' + attempt['cancel_event'].set() + return True + + +def _force_cancel_ssh_attempt(attempt): + """Cancel runtime work even after the user-visible commit boundary.""" + commit_lock = attempt.get('commit_lock') + if commit_lock is None: + attempt['cancel_event'].set() + return + with commit_lock: + attempt['cancel_event'].set() + + +def _cancel_ssh_connect_attempts_for_socket(socket_sid): + handles = [] + with _ssh_connect_attempts_lock: + for (_owner_id, owner_sid, _request_id), attempt in tuple( + _ssh_connect_attempts.items() + ): + if owner_sid != socket_sid: + continue + _force_cancel_ssh_attempt(attempt) + if attempt.get('handle') is not None: + handles.append(attempt['handle']) + for handle in handles: + handle.cancel() + + def _smb_request_id(payload): if not isinstance(payload, dict): return '' @@ -690,9 +760,17 @@ def _audit_file_source_operation( class _CombinedCancellation: """Expose user and runtime cancellation through one Event-like interface.""" - def __init__(self, user_cancel_event, lifecycle_cancel_event): + def __init__( + self, + user_cancel_event, + lifecycle_cancel_event, + commit_lock=None, + attempt=None, + ): self._user_cancel_event = user_cancel_event self._lifecycle_cancel_event = lifecycle_cancel_event + self._commit_lock = commit_lock or threading.Lock() + self._attempt = attempt def is_set(self): return ( @@ -716,6 +794,18 @@ def wait(self, timeout=None): self._user_cancel_event.wait(min(remaining, 0.1)) return True + def commit_if_active(self): + """Linearize an irreversible setup step against user cancellation.""" + with self._commit_lock: + if self.is_set(): + return False + if self._attempt is not None: + state = self._attempt.get('state', 'pending') + if state == 'cancelled' or state == 'finished': + return False + self._attempt['state'] = 'committed' + return True + def _storage_error_payload(error, *, user_id, include_success=True, **extra): """Log storage metadata, never contents, and build one safe client error.""" @@ -1224,6 +1314,7 @@ def handle_disconnect(): socket_sid = request.sid ssh_output_flow.release_socket(socket_sid) _cancel_ssh_banner_prompts_for_socket(socket_sid) + _cancel_ssh_connect_attempts_for_socket(socket_sid) owner_id = socket_capacity.release(socket_sid) try: user = get_user_from_socket(socket_sid) @@ -1417,8 +1508,10 @@ def handle_ssh_connect(data, current_user=None): bastion_key_content = None client_request_id = None socket_sid = request.sid + client_cancel_event = threading.Event() try: - client_request_id = data.get('client_request_id') + data = data if isinstance(data, dict) else {} + client_request_id = _ssh_request_id(data) or None if not current_app.extensions[ 'runtime_lifecycle' ].accepting_work(): @@ -1439,6 +1532,8 @@ def emit_error(message): )) def request_auth_banner_decision(banner, context): + if client_cancel_event.is_set(): + return False prompt_id = secrets.token_urlsafe(24) decision_event = threading.Event() prompt = { @@ -1446,9 +1541,12 @@ def request_auth_banner_decision(banner, context): 'accepted': False, 'socket_sid': socket_sid, 'user_id': current_user.id, + 'client_request_id': client_request_id, } with _ssh_banner_prompts_lock: _ssh_banner_prompts[prompt_id] = prompt + if client_cancel_event.is_set(): + decision_event.set() emit('ssh_auth_banner', { 'prompt_id': prompt_id, 'banner': banner, @@ -1645,10 +1743,33 @@ def request_auth_banner_decision(banner, context): 'bastion_password': bastion_password, 'bastion_key_content': bastion_key_content, } + attempt_key = ( + (str(current_user.id), socket_sid, client_request_id) + if client_request_id else None + ) + attempt = { + 'cancel_event': client_cancel_event, + 'commit_lock': threading.Lock(), + 'handle': None, + 'state': 'pending', + } + if attempt_key is not None: + with _ssh_connect_attempts_lock: + if attempt_key in _ssh_connect_attempts: + credential_box.clear() + emit_error('Connection request already in progress') + return + _ssh_connect_attempts[attempt_key] = attempt @copy_current_request_context - def connect_ssh(cancel_event, credentials=credential_box): + def connect_ssh(lifecycle_cancel_event, credentials=credential_box): """Run blocking SSH setup outside the synchronous socket reader.""" + cancellation = _CombinedCancellation( + client_cancel_event, + lifecycle_cancel_event, + attempt['commit_lock'], + attempt, + ) local_password = credentials.pop('password', None) local_key_content = credentials.pop('key_content', None) local_bastion_password = credentials.pop( @@ -1658,7 +1779,7 @@ def connect_ssh(cancel_event, credentials=credential_box): 'bastion_key_content', None ) try: - if cancel_event.is_set(): + if cancellation.is_set(): return session_id, error = ssh_manager.create_ssh_connection( host=host, @@ -1682,8 +1803,19 @@ def connect_ssh(cancel_event, credentials=credential_box): ), auth_banner_decision=request_auth_banner_decision, tailscale_authorization=tailscale_authorization, + cancel_event=cancellation, + client_request_id=client_request_id, ) + if cancellation.is_set(): + if session_id: + ssh_manager.close_session( + session_id, + kill_tmux=bool( + use_tmux and not reconnect_tmux_name + ), + ) + return if error: emit_error(error) return @@ -1692,10 +1824,26 @@ def connect_ssh(cancel_event, credentials=credential_box): socket_sid=socket_sid, user_id=current_user.id, ).first() is not None - if cancel_event.is_set() or not socket_is_live: + if cancellation.is_set() or not socket_is_live: ssh_manager.close_session( session_id, - kill_tmux=use_tmux, + kill_tmux=bool( + use_tmux and not reconnect_tmux_name + ), + ) + return + + # Without startup commands this is the first irreversible + # user-visible step. Whichever side reaches this boundary + # first wins: an accepted cancel emits nothing, while a late + # cancel is rejected instead of pretending the connection was + # stopped. + if not cancellation.commit_if_active(): + ssh_manager.close_session( + session_id, + kill_tmux=bool( + use_tmux and not reconnect_tmux_name + ), ) return @@ -1707,18 +1855,22 @@ def connect_ssh(cancel_event, credentials=credential_box): ) emit_error("Connection failed") return + actual_use_tmux = bool(created_session.get('use_tmux')) + tmux_reconnect = bool(created_session.get('tmux_reconnect')) created_tmux_name = ( created_session.get('tmux_session_name') - if use_tmux else None + if actual_use_tmux else None ) - display_name = data.get('display_name') if use_tmux else None + display_name = ( + data.get('display_name') if actual_use_tmux else None + ) if display_name: display_name = display_name.strip()[:128] or None try: # Clean up the specific old disconnected persistent session # when reconnecting to avoid ghost tabs on refresh. - if use_tmux and reconnect_tmux_name: + if tmux_reconnect: old_session = SSHSession.query.filter_by( user_id=current_user.id, host=host, @@ -1742,11 +1894,11 @@ def connect_ssh(cancel_event, credentials=credential_box): host=host, port=port, username=username, - is_persistent=use_tmux, - key_id=key_id if use_tmux else None, + is_persistent=actual_use_tmux, + key_id=key_id if actual_use_tmux else None, auth_type=auth_type, tmux_session_name=created_tmux_name, - display_name=display_name if use_tmux else None, + display_name=display_name if actual_use_tmux else None, ) db.session.add(ssh_session) db.session.commit() @@ -1765,8 +1917,8 @@ def connect_ssh(cancel_event, credentials=credential_box): 'username': username, 'client_request_id': client_request_id, 'via_jump': bastion_host, - 'use_tmux': use_tmux, - 'key_id': key_id if use_tmux else None, + 'use_tmux': actual_use_tmux, + 'key_id': key_id if actual_use_tmux else None, 'auth_type': auth_type, 'tmux_session_name': created_tmux_name, 'display_name': display_name, @@ -1786,34 +1938,49 @@ def connect_ssh(cancel_event, credentials=credential_box): request.remote_addr, ) except StorageCorruptionError as error: - emit('ssh_error', _storage_error_payload( - error, - user_id=current_user.id, - include_success=False, - client_request_id=client_request_id, - )) + if not cancellation.is_set(): + emit('ssh_error', _storage_error_payload( + error, + user_id=current_user.id, + include_success=False, + client_request_id=client_request_id, + )) except Exception as error: log_error( "SSH connection failed", error=str(error), user=current_user.username, ) - emit('ssh_error', {'error': 'Connection failed'}) + if not cancellation.is_set(): + emit_error('Connection failed') finally: credentials.clear() local_password = None local_key_content = None local_bastion_password = None local_bastion_key_content = None + with attempt['commit_lock']: + attempt['state'] = 'finished' + if attempt_key is not None: + with _ssh_connect_attempts_lock: + if _ssh_connect_attempts.get(attempt_key) is attempt: + _ssh_connect_attempts.pop(attempt_key, None) try: - lifecycle.start_job( + handle = lifecycle.start_job( 'ssh_connect', connect_ssh, owner_id=current_user.id, ) + attempt['handle'] = handle + if client_cancel_event.is_set(): + handle.cancel() except Exception as error: credential_box.clear() + if attempt_key is not None: + with _ssh_connect_attempts_lock: + if _ssh_connect_attempts.get(attempt_key) is attempt: + _ssh_connect_attempts.pop(attempt_key, None) log_warning( 'SSH connection job rejected', user=current_user.username, @@ -1830,13 +1997,103 @@ def connect_ssh(cancel_event, credentials=credential_box): )) except Exception as e: log_error("SSH connection failed", error=str(e), user=current_user.username) - emit('ssh_error', {'error': 'Connection failed'}) + emit('ssh_error', connection_error_payload( + 'Connection failed', + client_request_id=client_request_id, + )) finally: password = None key_content = None bastion_password = None bastion_key_content = None + +@socketio.on('ssh_connect_cancel') +@socket_login_required +def handle_ssh_connect_cancel(data, current_user=None): + """Cancel one matching SSH connection attempt owned by this socket.""" + request_id = _ssh_request_id(data) + if not request_id: + return {'success': False} + attempt_key = (str(current_user.id), request.sid, request_id) + with _ssh_connect_attempts_lock: + attempt = _ssh_connect_attempts.get(attempt_key) + if attempt is None: + return { + 'success': False, + 'cancelled': False, + 'reason': 'not_found', + } + cancelled = _try_cancel_ssh_attempt(attempt) + if not cancelled: + return { + 'success': False, + 'cancelled': False, + 'reason': 'already_committed', + } + handle = attempt.get('handle') + _cancel_ssh_banner_prompt_for_request( + current_user.id, + request.sid, + request_id, + ) + if handle is not None: + handle.cancel() + return {'success': True, 'cancelled': True} + + +@socketio.on('ssh_discard_late_connection') +@socket_login_required +def handle_ssh_discard_late_connection(data, current_user=None): + """Discard a cancelled connection without trusting client cleanup policy.""" + request_id = _ssh_request_id(data) + session_id = data.get('session_id') if isinstance(data, dict) else None + if not request_id or not isinstance(session_id, str) or not session_id: + return {'success': False} + if not verify_session_ownership(session_id, current_user.id): + return {'success': False} + + runtime_session = ssh_manager.get_session(session_id) + if ( + not runtime_session + or runtime_session.get('client_request_id') != request_id + ): + return {'success': False} + + tmux_reconnect = bool(runtime_session.get('tmux_reconnect')) + ssh_session = SSHSession.query.filter_by( + session_id=session_id, + user_id=current_user.id, + ).first() + if ssh_session: + try: + if ssh_session.is_persistent and not tmux_reconnect: + db.session.delete(ssh_session) + else: + ssh_session.connected = False + db.session.commit() + except Exception as db_err: + db.session.rollback() + log_error( + "Failed to discard late SSH session", + error=str(db_err), + session_id=session_id, + ) + + success = ssh_manager.close_session( + session_id, + kill_tmux=bool( + runtime_session.get('use_tmux') and not tmux_reconnect + ), + ) + if success: + socketio.emit('ssh_disconnected', { + 'session_id': session_id, + 'reason': 'Cancelled connection discarded', + }, room=f'user_{current_user.id}') + return {'success': success} + + @socketio.on('ssh_input') @socket_login_required def handle_ssh_input(data, current_user=None): @@ -2503,13 +2760,16 @@ def handle_probe_session_sftp(data, current_user=None): safe_session_id = session_id if valid_identifiers else '' safe_request_id = request_id if valid_identifiers else '' - def emit_result(*, success, available=False): - emit('session_sftp_capability', { + def emit_result(*, success, available=False, reason=None): + payload = { 'success': success, 'session_id': safe_session_id, 'request_id': safe_request_id, 'available': available, - }) + } + if reason == sftp_handler.CAPABILITY_RESOURCE_SHORTAGE: + payload['reason'] = reason + emit('session_sftp_capability', payload) if not valid_identifiers: emit_result(success=False) @@ -2540,6 +2800,12 @@ def emit_result(*, success, available=False): emit_result(success=False) return + if available == sftp_handler.CAPABILITY_RESOURCE_SHORTAGE: + emit_result( + success=False, + reason=sftp_handler.CAPABILITY_RESOURCE_SHORTAGE, + ) + return if available is None: emit_result(success=False) return @@ -3237,7 +3503,12 @@ def emit_unavailable(reason=None): 'request_id': safe_request_id, 'error': 'Session insights unavailable', } - if reason in {'busy', 'transient', 'unsupported'}: + if reason in { + 'busy', + 'transient', + 'unsupported', + 'resource_shortage', + }: payload['reason'] = reason emit('session_insights', payload) diff --git a/app/socket_protocol.py b/app/socket_protocol.py index 628e8d5..0f173e7 100644 --- a/app/socket_protocol.py +++ b/app/socket_protocol.py @@ -3,5 +3,5 @@ # This value is intentionally independent of the application release version. # Increment it only when an incompatible Socket.IO payload contract is shipped. -SOCKET_WIRE_REVISION = 1 +SOCKET_WIRE_REVISION = 2 SOCKET_PROTOCOL_MISMATCH_EVENT = 'socket_protocol_mismatch' diff --git a/app/ssh_manager.py b/app/ssh_manager.py index e5a4514..11d13d9 100644 --- a/app/ssh_manager.py +++ b/app/ssh_manager.py @@ -147,7 +147,8 @@ def create_ssh_connection(host, port, username, password=None, key_path=None, ke use_tmux=False, reconnect_tmux_name=None, auth_type='password', startup_commands='', auth_banner_decision=None, - tailscale_authorization=None): + tailscale_authorization=None, + cancel_event=None, client_request_id=None): """ Create a new SSH connection and return session ID. @@ -165,7 +166,14 @@ def create_ssh_connection(host, port, username, password=None, key_path=None, ke auth_type: Target authentication method (password, key, or tailscale) auth_banner_decision: Callback that must accept a server banner before any forwarding channel, shell, tmux probe, or startup command opens + cancel_event: Event-like cancellation signal for an in-progress setup """ + def connection_cancelled(): + return cancel_event is not None and cancel_event.is_set() + + if connection_cancelled(): + return None, "Connection cancelled" + try: host_key_store = HostKeyStore( user_id, config.KNOWN_HOSTS_FILE, config.USERS_DIR @@ -206,6 +214,8 @@ def create_ssh_connection(host, port, username, password=None, key_path=None, ke validated_socket = None connection_stored = False try: + if connection_cancelled(): + return None, "Connection cancelled" # Optional ProxyJump: connect to the bastion first, then tunnel to the target. sock = None if proxy_jump_host: @@ -273,6 +283,8 @@ def create_ssh_connection(host, port, username, password=None, key_path=None, ke return None, "Jump host authentication method not provided" bastion_client.connect(**bastion_auth) + if connection_cancelled(): + return None, "Connection cancelled" bastion_transport = bastion_client.get_transport() if bastion_transport: bastion_transport.set_keepalive(30) @@ -285,6 +297,8 @@ def create_ssh_connection(host, port, username, password=None, key_path=None, ke code="auth_banner_declined", context="jump_host", ) + if connection_cancelled(): + return None, "Connection cancelled" sock = bastion_transport.open_channel( 'direct-tcpip', @@ -337,6 +351,9 @@ def create_ssh_connection(host, port, username, password=None, key_path=None, ke ) sock = validated_socket + if connection_cancelled(): + return None, "Connection cancelled" + client = paramiko.SSHClient() _configure_host_key_trust(client, host_key_store) @@ -363,6 +380,8 @@ def create_ssh_connection(host, port, username, password=None, key_path=None, ke return None, "No authentication method provided" client.connect(**auth_kwargs) + if connection_cancelled(): + return None, "Connection cancelled" transport = client.get_transport() if transport: @@ -376,6 +395,8 @@ def create_ssh_connection(host, port, username, password=None, key_path=None, ke code="auth_banner_declined", context="target", ) + if connection_cancelled(): + return None, "Connection cancelled" tmux_session_name = None if use_tmux: @@ -432,6 +453,9 @@ def create_ssh_connection(host, port, username, password=None, key_path=None, ke finally: probe_channel.close() + if connection_cancelled(): + return None, "Connection cancelled" + if not tmux_available: log_warning("tmux not found on target host, falling back to regular shell", host=f"{host}:{port}") @@ -456,6 +480,8 @@ def create_ssh_connection(host, port, username, password=None, key_path=None, ke ) channel.settimeout(0.1) else: + if connection_cancelled(): + return None, "Connection cancelled" channel = paramiko_channels.open_shell_channel( transport, timeout=config.SSH_CONNECT_TIMEOUT, @@ -484,6 +510,12 @@ def create_ssh_connection(host, port, username, password=None, key_path=None, ke 'auth_type': auth_type, 'use_tmux': use_tmux, 'tmux_session_name': tmux_session_name, + 'tmux_reconnect': bool( + use_tmux + and reconnect_tmux_name + and tmux_session_name == reconnect_tmux_name + ), + 'client_request_id': client_request_id, 'output_buffer': [], 'output_buffer_size': 0, 'output_buffer_max': 512000, # 512KB max buffer @@ -493,10 +525,20 @@ def create_ssh_connection(host, port, username, password=None, key_path=None, ke } connection_stored = True + if connection_cancelled(): + close_session( + session_id, + kill_tmux=bool(use_tmux and not reconnect_tmux_name), + ) + return None, "Connection cancelled" + if socketio_instance and app: lifecycle = getattr(app, 'extensions', {}).get('runtime_lifecycle') if lifecycle is None: - close_session(session_id, kill_tmux=use_tmux) + close_session( + session_id, + kill_tmux=bool(use_tmux and not reconnect_tmux_name), + ) return None, "Connection failed" try: reader_handle = lifecycle.start_job( @@ -512,7 +554,10 @@ def create_ssh_connection(host, port, username, password=None, key_path=None, ke session_id=session_id, error_type=type(exc).__name__, ) - close_session(session_id, kill_tmux=use_tmux) + close_session( + session_id, + kill_tmux=bool(use_tmux and not reconnect_tmux_name), + ) return None, "Connection failed" with sessions_lock: active_session = sessions.get(session_id) @@ -521,13 +566,44 @@ def create_ssh_connection(host, port, username, password=None, key_path=None, ke else: active_session['reader_handle'] = reader_handle + if connection_cancelled(): + close_session( + session_id, + kill_tmux=bool(use_tmux and not reconnect_tmux_name), + ) + return None, "Connection cancelled" + if startup_commands and not reconnect_tmux_name: terminal_input = to_terminal_input(startup_commands).rstrip('\r') + '\r' + commit_if_active = getattr(cancel_event, 'commit_if_active', None) + if ( + callable(commit_if_active) + and not commit_if_active() + ) or ( + not callable(commit_if_active) + and connection_cancelled() + ): + close_session( + session_id, + kill_tmux=bool(use_tmux and not reconnect_tmux_name), + ) + return None, "Connection cancelled" delivered, _delivery_error = send_ssh_input( - session_id, terminal_input, require_complete=True + session_id, + terminal_input, + require_complete=True, + # A successful commit_if_active() makes a user cancellation + # lose this race. Finish the bounded startup payload rather + # than acknowledge cancellation after a partial command may + # already have reached the remote shell. + cancel_event=( + None if callable(commit_if_active) else cancel_event + ), ) if not delivered: close_session(session_id, kill_tmux=use_tmux) + if connection_cancelled(): + return None, "Connection cancelled" return None, "Connection failed" return session_id, None @@ -731,7 +807,12 @@ def read_ssh_output(session_id, socketio_instance, app, cancel_event=None): close_session(session_id) -def send_ssh_input(session_id, data, require_complete=False): +def send_ssh_input( + session_id, + data, + require_complete=False, + cancel_event=None, +): """Send user input to SSH channel.""" try: import re as _re @@ -756,11 +837,15 @@ def send_ssh_input(session_id, data, require_complete=False): if require_complete: remaining = data.encode('utf-8') if isinstance(data, str) else data while remaining: + if cancel_event is not None and cancel_event.is_set(): + return False, "Connection cancelled" sent = channel.send(remaining) if not isinstance(sent, int) or sent <= 0: return False, "Failed to send SSH input" remaining = remaining[sent:] else: + if cancel_event is not None and cancel_event.is_set(): + return False, "Connection cancelled" channel.send(data) with sessions_lock: @@ -881,7 +966,9 @@ def get_session(session_id): 'connected': session['connected'], 'via_jump': session.get('proxy_jump_host'), 'use_tmux': session.get('use_tmux', False), - 'tmux_session_name': session.get('tmux_session_name') + 'tmux_session_name': session.get('tmux_session_name'), + 'tmux_reconnect': session.get('tmux_reconnect', False), + 'client_request_id': session.get('client_request_id'), } return None diff --git a/static/css/style.css b/static/css/style.css index 18d6faa..9cff4a6 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -865,6 +865,9 @@ body[data-theme="paper"] .terminal-pane { } .tab-close { + appearance: none; + border: 0; + background: transparent; font-size: 16px; line-height: 1; color: var(--text-secondary); @@ -1948,6 +1951,26 @@ body.broadcast-active .terminal-wrapper:not(.unassigned) { font-size: 13px; } +.profile-launcher-return { + flex: 0 0 auto; + display: inline-flex; + align-items: center; + gap: 7px; + max-width: 100%; +} + +.profile-launcher-return .material-icons { + flex: 0 0 auto; + font-size: 18px; +} + +.profile-launcher-return span:last-child { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + .profile-launcher-search, .profile-launcher-sections { width: min(1120px, 100%); diff --git a/static/js/account-workspace-pulse.js b/static/js/account-workspace-pulse.js index 3a18f6d..ab6c93b 100644 --- a/static/js/account-workspace-pulse.js +++ b/static/js/account-workspace-pulse.js @@ -12,7 +12,8 @@ function getWorkspacePulseState(sessionManager) { const sessions = sessionManager?.getAllSessions?.() || Object.values(sessionManager?.sessions || {}); - const activeSessionId = sessionManager?.getActiveSession?.() + const activeSessionId = sessionManager?.getWorkspaceSession?.() + || sessionManager?.getActiveSession?.() || sessionManager?.activeSessionId || null; const activeSession = activeSessionId diff --git a/static/js/app.js b/static/js/app.js index 0dafb4c..d07e78f 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -406,6 +406,14 @@ }, open() { + if (SessionManager.isConnectionLauncherOpen?.()) { + const launcherSearch = document.querySelector( + '.terminal-pane.active .profile-launcher-search' + ); + launcherSearch?.focus(); + launcherSearch?.select(); + return; + } if (!TerminalManager.hasSearchSupport()) { showNotification('Search not available', 'warning'); return; @@ -1152,7 +1160,14 @@ let pendingAuthBannerPrompt = null; - function closeAuthBannerPrompt() { + function closeAuthBannerPrompt(requestId = null) { + if ( + requestId + && pendingAuthBannerPrompt + && pendingAuthBannerPrompt.requestId !== requestId + ) { + return false; + } const hadPrompt = pendingAuthBannerPrompt !== null; pendingAuthBannerPrompt = null; window.ModalManager.close(document.getElementById('sshAuthBannerModal')); @@ -1160,6 +1175,7 @@ if (hadPrompt && connectionModal?.classList.contains('show')) { window.ModalManager.activeModal = connectionModal; } + return hadPrompt; } function answerAuthBannerPrompt(accepted) { @@ -1180,7 +1196,24 @@ ) { return; } - pendingAuthBannerPrompt = { promptId: data.prompt_id }; + const requestId = typeof data.client_request_id === 'string' + ? data.client_request_id + : null; + const isExpectedRequest = requestId && ( + requestId === currentConnectRequestId + || requestId.startsWith('reconnect_') + ); + if ( + !isExpectedRequest + || cancelledConnectRequestIds.has(requestId) + ) { + socket.emit('ssh_auth_banner_decision', { + prompt_id: data.prompt_id, + accepted: false, + }); + return; + } + pendingAuthBannerPrompt = { promptId: data.prompt_id, requestId }; const contextKey = data.context === 'jump_host' ? 'connection.authBannerJumpHost' : 'connection.authBannerTarget'; @@ -1241,33 +1274,85 @@ clearInterval(keepAliveInterval); keepAliveInterval = null; } + pendingRequestPaneMap.forEach((_pane, requestId) => { + SessionManager.clearPendingConnection(requestId); + }); + pendingRequestPaneMap.clear(); + cancelledConnectRequestIds.clear(); + cancellingConnectRequestIds.clear(); + completedWhileCancellingRequestIds.clear(); + cancelledSessionIds.clear(); + currentConnectRequestId = null; + connectionModalRequestId = null; + setConnectLoading(false); + stopConnectTimer(); outputFlowReconnect.handleDisconnect(reason); }); + function discardLateConnection(data, requestId) { + if (!data?.session_id) return; + rememberTransientId(cancelledSessionIds, data.session_id); + if (requestId) { + socket.emit('ssh_discard_late_connection', { + session_id: data.session_id, + client_request_id: requestId, + }); + } else { + socket.emit('ssh_disconnect', { session_id: data.session_id }); + } + } + socket.on('ssh_connected', (data) => { - closeAuthBannerPrompt(); - if (data.client_request_id) { - SessionManager.clearPendingConnection(data.client_request_id); + const requestId = typeof data?.client_request_id === 'string' + ? data.client_request_id + : null; + if (requestId && cancellingConnectRequestIds.has(requestId)) { + // A completed connection and its cancellation ACK share one + // ordered socket stream, but the success event can arrive first. + // Retain that correlation after the success handler clears the + // pending UI so the ACK can explain that cancellation was too late. + rememberTransientId(completedWhileCancellingRequestIds, requestId); + } + if (requestId && cancelledConnectRequestIds.delete(requestId)) { + closeAuthBannerPrompt(requestId); + pendingRequestPaneMap.delete(requestId); + SessionManager.clearPendingConnection(requestId); + discardLateConnection(data, requestId); + return; } - if (connectTimer) { - clearInterval(connectTimer); - connectTimer = null; - const connectBtn = document.getElementById('connectBtn'); - if (connectBtn) { - connectBtn.textContent = 'Connect'; - } + const isCurrentRequest = Boolean( + requestId && requestId === currentConnectRequestId + ); + const isReconnectRequest = Boolean( + requestId && requestId.startsWith('reconnect_') + ); + if (!isCurrentRequest && !isReconnectRequest) { + discardLateConnection(data, requestId); + return; } - setConnectLoading(false); - currentConnectRequestId = null; + closeAuthBannerPrompt(requestId); + if (requestId) { + SessionManager.clearPendingConnection(requestId); + } + + if (isCurrentRequest) { + stopConnectTimer(); + setConnectLoading(false); + currentConnectRequestId = null; + if (connectionModalRequestId === requestId) { + connectionModalRequestId = null; + } + clearPendingPane(); + } const sessionId = SessionManager.createSession(data); let targetPane = null; - if (data.client_request_id && pendingRequestPaneMap.has(data.client_request_id)) { - targetPane = pendingRequestPaneMap.get(data.client_request_id); - pendingRequestPaneMap.delete(data.client_request_id); + if (requestId && pendingRequestPaneMap.has(requestId)) { + targetPane = pendingRequestPaneMap.get(requestId); + pendingRequestPaneMap.delete(requestId); } if (targetPane === null || targetPane === undefined) { const emptyIndex = SessionManager.getFirstEmptyPaneIndex(); @@ -1275,7 +1360,9 @@ } SessionManager.assignSessionToPane(sessionId, targetPane); - window.ModalManager.close(document.getElementById('connectionModal')); + if (isCurrentRequest) { + window.ModalManager.close(document.getElementById('connectionModal')); + } const connMsg = data.via_jump ? `Connected to ${data.username}@${data.host} via ${data.via_jump}` : `Connected to ${data.username}@${data.host}`; @@ -1290,7 +1377,31 @@ }); socket.on('ssh_error', (data) => { - closeAuthBannerPrompt(); + const requestId = typeof data?.client_request_id === 'string' + ? data.client_request_id + : null; + if (requestId && cancelledConnectRequestIds.delete(requestId)) { + closeAuthBannerPrompt(requestId); + pendingRequestPaneMap.delete(requestId); + SessionManager.clearPendingConnection(requestId); + if (requestId === currentConnectRequestId) { + currentConnectRequestId = null; + setConnectLoading(false); + stopConnectTimer(); + } + return; + } + + const isCurrentRequest = Boolean( + requestId && requestId === currentConnectRequestId + ); + const isReconnectRequest = Boolean( + requestId && requestId.startsWith('reconnect_') + ); + if (requestId && !isCurrentRequest && !isReconnectRequest) { + return; + } + const presentation = window.SSHErrorUI?.describeSSHError?.( data, key => window.i18n?.t?.(key), @@ -1298,29 +1409,26 @@ ) || { message: `SSH Error: ${data.error}`, type: 'error' }; showNotification(presentation); - if (connectTimer) { - clearInterval(connectTimer); - connectTimer = null; - const connectBtn = document.getElementById('connectBtn'); - if (connectBtn) { - connectBtn.textContent = 'Connect'; - } + if (!isCurrentRequest) { + if (isReconnectRequest) closeAuthBannerPrompt(requestId); + return; } + closeAuthBannerPrompt(requestId); + stopConnectTimer(); setConnectLoading(false); - const requestId = data.client_request_id || currentConnectRequestId; - if (requestId) { - SessionManager.clearPendingConnection(requestId); - if (requestId === currentConnectRequestId) { - currentConnectRequestId = null; - } - if (pendingRequestPaneMap.has(requestId)) { - pendingRequestPaneMap.delete(requestId); - } + SessionManager.clearPendingConnection(requestId); + currentConnectRequestId = null; + if (connectionModalRequestId === requestId) { + connectionModalRequestId = null; } + pendingRequestPaneMap.delete(requestId); }); socket.on('ssh_disconnected', (data) => { + if (cancelledSessionIds.delete(data.session_id)) { + return; + } showNotification(`Session disconnected: ${data.reason}`, 'warning'); SessionManager.updateSessionStatus(data.session_id, 'disconnected'); @@ -1412,10 +1520,27 @@ }); let currentConnectRequestId = null; + let connectionModalRequestId = null; let pendingPaneIndex = null; const pendingRequestPaneMap = new Map(); + const cancelledConnectRequestIds = new Set(); + const cancellingConnectRequestIds = new Set(); + const completedWhileCancellingRequestIds = new Set(); + const cancelledSessionIds = new Set(); let connectTimer = null; let connectSeconds = 0; + const CONNECT_CANCEL_ACK_TIMEOUT_MS = 5000; + const TRANSIENT_ID_TTL_MS = 120000; + const MAX_TRANSIENT_IDS = 128; + + function rememberTransientId(collection, value) { + if (!value) return; + collection.add(value); + while (collection.size > MAX_TRANSIENT_IDS) { + collection.delete(collection.values().next().value); + } + window.setTimeout(() => collection.delete(value), TRANSIENT_ID_TTL_MS); + } function closeProfileManagementModal() { if ( @@ -1443,7 +1568,7 @@ client_request_id: requestId, }; currentConnectRequestId = requestId; - pendingPaneIndex = null; + pendingPaneIndex = paneIndex; SessionManager.createPendingConnection( requestId, payload.host, @@ -1452,6 +1577,12 @@ ); if (paneIndex !== null && paneIndex !== undefined) { pendingRequestPaneMap.set(requestId, paneIndex); + const modalOpen = document.getElementById('connectionModal') + ?.classList.contains('show'); + SessionManager.restoreConnectionLauncher?.( + paneIndex, + { activate: !modalOpen }, + ); } Object.keys(SessionManager.sessions).forEach(sessionId => { @@ -1469,11 +1600,18 @@ } function openConnectionModalForPane(paneIndex) { + if (currentConnectRequestId) { + showNotification( + window.i18n + ? i18n.t('connection.connectBusy') + : 'A connection attempt is already in progress.', + 'info', + ); + return false; + } window.clearConnectionProfileState(); + connectionModalRequestId = null; pendingPaneIndex = paneIndex; - if (paneIndex !== null && paneIndex !== undefined) { - SessionManager.setActivePane(paneIndex); - } // Reset the jump host selection so a previous jump never carries into a // new connection by accident. @@ -1492,6 +1630,7 @@ modal.classList.add('show'); } setConnectLoading(false); + return true; } function selectConnectionProfile(profileId) { @@ -1530,7 +1669,7 @@ function openProfileForReview(profileId, paneIndex, mode) { closeProfileManagementModal(); - openConnectionModalForPane(paneIndex); + if (!openConnectionModalForPane(paneIndex)) return; const selected = selectConnectionProfile(profileId); if (!selected) return; @@ -1547,6 +1686,163 @@ pendingPaneIndex = null; } + function stopConnectTimer() { + if (connectTimer) { + clearInterval(connectTimer); + connectTimer = null; + } + const connectLabel = document.querySelector('#connectBtn .btn-label'); + if (connectLabel) { + connectLabel.textContent = window.i18n + ? i18n.t('connection.connect') + : 'Connect'; + } + } + + function finishConnectionCancellation(requestId) { + rememberTransientId(cancelledConnectRequestIds, requestId); + closeAuthBannerPrompt(requestId); + pendingRequestPaneMap.delete(requestId); + SessionManager.clearPendingConnection(requestId); + if (requestId === currentConnectRequestId) { + currentConnectRequestId = null; + setConnectLoading(false); + stopConnectTimer(); + } + } + + function setPendingCancellationBusy(requestId, busy) { + const button = document.getElementById(`pending-${requestId}`) + ?.querySelector('.tab-close'); + if (!button) return; + button.setAttribute('aria-disabled', String(Boolean(busy))); + button.setAttribute('aria-busy', String(Boolean(busy))); + } + + function cancelConnectionAttempt( + requestId = currentConnectRequestId, + onSettled = null, + ) { + if ( + !requestId + || cancellingConnectRequestIds.has(requestId) + || ( + requestId !== currentConnectRequestId + && !pendingRequestPaneMap.has(requestId) + ) + ) { + return false; + } + cancellingConnectRequestIds.add(requestId); + setPendingCancellationBusy(requestId, true); + let settled = false; + const settleCancellation = acknowledgement => { + if (settled) return; + settled = true; + window.clearTimeout(acknowledgementTimer); + cancellingConnectRequestIds.delete(requestId); + setPendingCancellationBusy(requestId, false); + const requestStillPending = ( + requestId === currentConnectRequestId + || pendingRequestPaneMap.has(requestId) + ); + const completedWhileCancelling = ( + completedWhileCancellingRequestIds.delete(requestId) + ); + let cancelled = acknowledgement?.cancelled === true || ( + acknowledgement?.success === true + && acknowledgement?.cancelled !== false + ); + if (cancelled) { + finishConnectionCancellation(requestId); + } else if (completedWhileCancelling) { + // The commit won and the success event was already handled. + // Keep the usable connection and make the rejected + // cancellation explicit instead of silently ignoring it. + showNotification( + window.i18n + ? i18n.t('connection.cancelCompleted') + : 'Cancellation was too late. The connection had already opened and remains active.', + 'info', + ); + } else if ( + acknowledgement?.reason === 'not_found' + && requestStillPending + ) { + // The server no longer owns this request. Honour the user's + // cancellation locally and discard any success that was + // already in flight for the same correlated request. + cancelled = true; + finishConnectionCancellation(requestId); + showNotification( + window.i18n + ? i18n.t('connection.cancelAlreadyStopped') + : 'The connection attempt is no longer active.', + 'info', + ); + } else if ( + acknowledgement?.reason === 'already_committed' + && requestStillPending + ) { + showNotification( + window.i18n + ? i18n.t('connection.cancelTooLate') + : 'The connection is already finishing and can no longer be cancelled safely.', + 'info', + ); + } else if (requestStillPending) { + showNotification( + window.i18n + ? i18n.t('connection.cancelUnconfirmed') + : 'Cancellation was not confirmed. The attempt is still pending; try cancelling again.', + 'warning', + 7000, + ); + } + if (typeof onSettled === 'function') { + onSettled(cancelled, acknowledgement); + } + }; + const acknowledgementTimer = window.setTimeout(() => { + settleCancellation({ + success: false, + cancelled: false, + reason: 'ack_timeout', + }); + }, CONNECT_CANCEL_ACK_TIMEOUT_MS); + socket.emit( + 'ssh_connect_cancel', + { client_request_id: requestId }, + settleCancellation, + ); + return true; + } + + function dismissConnectionModal() { + const modalRequestId = connectionModalRequestId; + const targetPane = pendingPaneIndex; + const shouldReactivatePane = Number.isInteger(targetPane) + && Boolean(SessionManager.getWorkspaceSession?.()) + && !SessionManager.getActiveSession(); + const reactivatePreservedPane = () => { + if (!shouldReactivatePane) return; + SessionManager.setActivePane(targetPane); + window.requestAnimationFrame(() => SessionManager.focusActivePane()); + }; + cancelConnectionAttempt(modalRequestId); + connectionModalRequestId = null; + window.ModalManager.close(document.getElementById('connectionModal')); + clearPendingPane(); + if (!modalRequestId) { + setConnectLoading(false); + stopConnectTimer(); + } + // The replacement remains staged until ssh_connected, so returning to + // the preserved session is safe even while cancellation is pending or + // the server has already crossed its commit boundary. + reactivatePreservedPane(); + } + function getDefaultPaneIndex() { const activeIndex = SessionManager.getActivePaneIndex(); const activeSession = SessionManager.getActiveSession(); @@ -1560,6 +1856,11 @@ return activeIndex !== null && activeIndex !== undefined ? activeIndex : 0; } + function openDefaultConnectionModal() { + openConnectionModalForPane(getDefaultPaneIndex()); + } + window.openDefaultConnectionModal = openDefaultConnectionModal; + const savedConnectionLauncher = ( ConnectionLauncher.createConnectionLauncher({ getProfile: profileId => ProfileManager.getProfile(profileId), @@ -1590,11 +1891,9 @@ function setConnectLoading(isLoading) { const connectBtn = document.getElementById('connectBtn'); const spinner = document.getElementById('connectSpinner'); - if (!connectBtn || !spinner) { - return; - } + if (!connectBtn) return; connectBtn.disabled = isLoading; - spinner.classList.toggle('hidden', !isLoading); + spinner?.classList.toggle('hidden', !isLoading); } function setFieldState(input, hintEl, message, isValid) { @@ -1906,7 +2205,8 @@ e.preventDefault(); hideOverlay(); - const active = SessionManager.getActiveSession(); + const active = SessionManager.getWorkspaceSession?.() + || SessionManager.getActiveSession(); if (!active) { showNotification('No active session for upload', 'warning'); return; @@ -1929,7 +2229,8 @@ form.addEventListener('submit', (e) => { e.preventDefault(); - const active = SessionManager.getActiveSession(); + const active = SessionManager.getWorkspaceSession?.() + || SessionManager.getActiveSession(); if (!active || !pendingFile) { showNotification('No active session for upload', 'warning'); return; @@ -2025,7 +2326,7 @@ } const actions = [ - { id: 'quick-connect', labelKey: 'connection.newConnection', hint: 'Ctrl+Shift+N', action: () => openConnectionModalForPane(getDefaultPaneIndex()) }, + { id: 'quick-connect', labelKey: 'connection.newConnection', hint: 'Ctrl+Shift+N', action: openDefaultConnectionModal }, { id: 'command-library', labelKey: 'commands.library', hint: 'F1', action: () => CommandLibrary.openLibrary() }, { id: 'file-transfer', labelKey: 'files.fileTransfer', hint: '', action: () => document.getElementById('fileTransferBtn').click() }, { id: 'manage-keys', labelKey: 'keys.manageKeys', hint: '', action: () => openConnectionAssetManager('keys') }, @@ -2192,6 +2493,7 @@ e.preventDefault(); activateItem(filtered[activeIndex]); } else if (e.key === 'Escape') { + e.preventDefault(); closePalette(); } }); @@ -2257,24 +2559,22 @@ const newTabBtn = document.getElementById('newTabBtn'); if (newTabBtn) { newTabBtn.addEventListener('click', () => { - openConnectionModalForPane(getDefaultPaneIndex()); + SessionManager.showConnectionLauncher( + SessionManager.getActivePaneIndex() + ); }); } document.getElementById('closeConnectionModal').addEventListener('click', () => { - window.ModalManager.close(document.getElementById('connectionModal')); - setConnectLoading(false); - currentConnectRequestId = null; - clearPendingPane(); - if (connectTimer) { clearInterval(connectTimer); connectTimer = null; } + dismissConnectionModal(); }); document.getElementById('cancelConnectionBtn').addEventListener('click', () => { - window.ModalManager.close(document.getElementById('connectionModal')); - setConnectLoading(false); - currentConnectRequestId = null; - clearPendingPane(); - if (connectTimer) { clearInterval(connectTimer); connectTimer = null; } + dismissConnectionModal(); + }); + + window.addEventListener('ssh-connection-cancel-requested', event => { + cancelConnectionAttempt(event.detail?.requestId); }); document.getElementById('connectionForm').addEventListener('submit', (e) => { @@ -2378,15 +2678,24 @@ const started = startConnection(connectionData, targetPane); if (!started) return; + connectionModalRequestId = currentConnectRequestId; SessionManager.pendingReconnectTmux = null; SessionManager.pendingDisplayName = null; - const connectBtn = document.getElementById('connectBtn'); + const connectLabel = document.querySelector('#connectBtn .btn-label'); + const connectingText = seconds => ( + window.i18n + ? i18n.t('connection.connectingElapsed') + .replace('{seconds}', String(seconds)) + : `Connecting... ${seconds}s` + ); connectSeconds = 0; - connectBtn.textContent = 'Connecting... 0s'; + if (connectLabel) connectLabel.textContent = connectingText(connectSeconds); connectTimer = setInterval(() => { connectSeconds++; - connectBtn.textContent = `Connecting... ${connectSeconds}s`; + if (connectLabel) { + connectLabel.textContent = connectingText(connectSeconds); + } }, 1000); setConnectLoading(true); @@ -2588,11 +2897,11 @@ if (e.target.classList.contains('modal')) { if (e.target.classList.contains('primary-workspace-view')) return; if (e.target.id === 'sshAuthBannerModal') return; - window.ModalManager.close(e.target); if (e.target.id === 'connectionModal') { - clearPendingPane(); - if (connectTimer) { clearInterval(connectTimer); connectTimer = null; } + dismissConnectionModal(); + return; } + window.ModalManager.close(e.target); } }); @@ -2655,17 +2964,35 @@ } if (e.key === 'Escape') { + if (e.defaultPrevented) return; if (TerminalSearch.isOpen) { TerminalSearch.close(); } else { - document.querySelectorAll('.modal.show').forEach(modal => { + const openModals = Array.from(document.querySelectorAll('.modal.show')); + let handled = false; + openModals.forEach(modal => { if ( modal.id === 'sftpFileManager' || modal.id === 'sshAuthBannerModal' || modal.classList.contains('primary-workspace-view') ) return; + if (modal.id === 'connectionModal') { + dismissConnectionModal(); + handled = true; + return; + } window.ModalManager.close(modal); + handled = true; }); + if (!openModals.length) { + handled = Boolean(SessionManager.restoreConnectionLauncher?.( + SessionManager.getActivePaneIndex() + )); + } + if (handled) { + e.preventDefault(); + e.stopImmediatePropagation(); + } } } diff --git a/static/js/i18n.js b/static/js/i18n.js index c178a57..60acc23 100644 --- a/static/js/i18n.js +++ b/static/js/i18n.js @@ -199,7 +199,12 @@ const translations = { 'connection.profileName': 'Connection Name', 'connection.profileNamePlaceholder': 'My Server', 'connection.connect': 'Connect', + 'connection.connectingElapsed': 'Connecting... {seconds}s', 'connection.connectBusy': 'A connection attempt is already in progress.', + 'connection.cancelTooLate': 'The connection is already finishing and can no longer be cancelled safely.', + 'connection.cancelCompleted': 'Cancellation was too late. The connection had already opened and remains active.', + 'connection.cancelUnconfirmed': 'Cancellation was not confirmed. The attempt is still pending; try cancelling again.', + 'connection.cancelAlreadyStopped': 'The connection attempt is no longer active.', 'connection.reconnected': 'Reconnected!', 'connection.lostReconnecting': 'Connection lost. Reconnecting...', 'connection.lostReconnectingAttempt': 'Connection lost. Reconnecting... (attempt {attempt})', @@ -1181,6 +1186,8 @@ const translations = { 'panes.disconnected': 'Disconnected', 'panes.emptyPane': 'Empty pane', 'panes.selectSession': 'Select a session or use Quick Connect', + 'panes.sessionPreserved': '{label} remains open. This pane changes only after another connection succeeds.', + 'panes.returnToSession': 'Back to {label}', 'session.closeWarning': 'You have active SSH sessions. They will be closed.', 'session.rename': 'Rename session', @@ -1243,6 +1250,7 @@ const translations = { 'workspace.activeSessionFiles': 'Active session files', 'workspace.sftpChecking': 'Checking SFTP for {target}...', 'workspace.sftpUnavailable': 'SFTP is not available for {target}.', + 'workspace.sftpResourceShortage': 'SFTP is temporarily unavailable for {target} because the remote server cannot open another SSH channel. WebSSH will retry automatically.', 'sessionCommands.panelLabel': 'Session commands', 'admin.ldapDirectory': 'LDAP directory', 'admin.ldapCheckHint': 'Run a read-only TLS and service-bind check. Credentials and directory names are never returned to the browser.', @@ -1480,7 +1488,12 @@ const translations = { 'connection.profileName': 'Tên kết nối', 'connection.profileNamePlaceholder': 'Máy chủ của tôi', 'connection.connect': 'Kết nối', + 'connection.connectingElapsed': 'Đang kết nối... {seconds}s', 'connection.connectBusy': 'Một lần kết nối đang được thực hiện.', + 'connection.cancelTooLate': 'Kết nối đang hoàn tất và không thể hủy an toàn nữa.', + 'connection.cancelCompleted': 'Yêu cầu hủy đến quá muộn. Kết nối đã được mở và vẫn đang hoạt động.', + 'connection.cancelUnconfirmed': 'Không thể xác nhận việc hủy. Lần kết nối vẫn đang chờ; hãy thử hủy lại.', + 'connection.cancelAlreadyStopped': 'Lần kết nối không còn hoạt động.', 'connection.reconnected': 'Đã kết nối lại!', 'connection.lostReconnecting': 'Mất kết nối. Đang kết nối lại...', 'connection.lostReconnectingAttempt': 'Mất kết nối. Đang kết nối lại... (lần thử {attempt})', @@ -2462,6 +2475,8 @@ const translations = { 'panes.disconnected': 'Đã ngắt kết nối', 'panes.emptyPane': 'Khung trống', 'panes.selectSession': 'Chọn một phiên hoặc dùng Kết nối nhanh', + 'panes.sessionPreserved': '{label} vẫn mở. Khung này chỉ thay đổi sau khi một kết nối khác thành công.', + 'panes.returnToSession': 'Quay lại {label}', 'session.closeWarning': 'Bạn đang có phiên SSH hoạt động. Các phiên này sẽ bị đóng.', 'session.rename': 'Đổi tên phiên', @@ -2524,6 +2539,7 @@ const translations = { 'workspace.activeSessionFiles': 'Tệp của phiên đang hoạt động', 'workspace.sftpChecking': 'Đang kiểm tra SFTP cho {target}...', 'workspace.sftpUnavailable': 'SFTP không khả dụng cho {target}.', + 'workspace.sftpResourceShortage': 'SFTP tạm thời không khả dụng cho {target} vì máy chủ từ xa không thể mở thêm kênh SSH. WebSSH sẽ tự động thử lại.', 'sessionCommands.panelLabel': 'Lệnh của phiên', 'admin.ldapDirectory': 'Thư mục LDAP', 'admin.ldapCheckHint': 'Chạy kiểm tra TLS và tài khoản dịch vụ ở chế độ chỉ đọc. Thông tin đăng nhập và tên thư mục không bao giờ được trả về trình duyệt.', @@ -2760,7 +2776,12 @@ const translations = { 'connection.profileName': 'Verbindungsname', 'connection.profileNamePlaceholder': 'Mein Server', 'connection.connect': 'Verbinden', + 'connection.connectingElapsed': 'Verbindung wird hergestellt... {seconds}s', 'connection.connectBusy': 'Ein Verbindungsversuch läuft bereits.', + 'connection.cancelTooLate': 'Die Verbindung wird bereits abgeschlossen und kann nicht mehr sicher abgebrochen werden.', + 'connection.cancelCompleted': 'Der Abbruch kam zu spät. Die Verbindung war bereits hergestellt und bleibt aktiv.', + 'connection.cancelUnconfirmed': 'Der Abbruch wurde nicht bestätigt. Der Verbindungsversuch läuft noch; versuche erneut abzubrechen.', + 'connection.cancelAlreadyStopped': 'Der Verbindungsversuch ist nicht mehr aktiv.', 'connection.reconnected': 'Wieder verbunden!', 'connection.lostReconnecting': 'Verbindung unterbrochen. Wiederverbindung läuft...', 'connection.lostReconnectingAttempt': 'Verbindung unterbrochen. Wiederverbindung läuft... (Versuch {attempt})', @@ -3755,6 +3776,8 @@ const translations = { 'panes.disconnected': 'Getrennt', 'panes.emptyPane': 'Leeres Pane', 'panes.selectSession': 'Wählen Sie eine Sitzung oder nutzen Sie die Schnellverbindung', + 'panes.sessionPreserved': '{label} bleibt geöffnet. Dieser Bereich wechselt erst, wenn eine andere Verbindung erfolgreich hergestellt wurde.', + 'panes.returnToSession': 'Zurück zu {label}', 'commands.workspace': 'Befehle', 'commands.library': 'Befehlsbibliothek', @@ -3803,6 +3826,7 @@ const translations = { 'workspace.activeSessionFiles': 'Dateien der aktiven Sitzung', 'workspace.sftpChecking': 'SFTP für {target} wird geprüft...', 'workspace.sftpUnavailable': 'SFTP ist für {target} nicht verfügbar.', + 'workspace.sftpResourceShortage': 'SFTP ist für {target} vorübergehend nicht verfügbar, weil der entfernte Server keinen weiteren SSH-Kanal öffnen kann. WebSSH versucht es automatisch erneut.', 'sessionCommands.panelLabel': 'Sitzungsbefehle', 'admin.ldapDirectory': 'LDAP-Verzeichnis', 'admin.ldapCheckHint': 'Führt eine schreibgeschützte TLS- und Service-Bind-Prüfung aus. Zugangsdaten und Verzeichnisnamen werden niemals an den Browser zurückgegeben.', @@ -4039,7 +4063,12 @@ const translations = { 'connection.profileName': 'Nom de la connexion', 'connection.profileNamePlaceholder': 'Mon serveur', 'connection.connect': 'Connecter', + 'connection.connectingElapsed': 'Connexion en cours... {seconds}s', 'connection.connectBusy': 'Une tentative de connexion est déjà en cours.', + 'connection.cancelTooLate': 'La connexion est déjà en cours de finalisation et ne peut plus être annulée en toute sécurité.', + 'connection.cancelCompleted': 'L’annulation est arrivée trop tard. La connexion était déjà établie et reste active.', + 'connection.cancelUnconfirmed': 'L’annulation n’a pas été confirmée. La tentative est toujours en attente ; réessayez de l’annuler.', + 'connection.cancelAlreadyStopped': 'La tentative de connexion n’est plus active.', 'connection.reconnected': 'Reconnexion réussie !', 'connection.lostReconnecting': 'Connexion perdue. Reconnexion en cours...', 'connection.lostReconnectingAttempt': 'Connexion perdue. Reconnexion en cours... (tentative {attempt})', @@ -4634,6 +4663,8 @@ const translations = { 'panes.disconnected': 'Déconnecté', 'panes.emptyPane': 'Volet vide', 'panes.selectSession': 'Sélectionnez une session ou utilisez Connexion rapide', + 'panes.sessionPreserved': '{label} reste ouverte. Ce volet ne change qu’après la réussite d’une autre connexion.', + 'panes.returnToSession': 'Retour à {label}', 'session.closeWarning': 'Vous avez des sessions SSH actives. Elles seront fermées.', 'session.rename': 'Renommer la session', 'session.close': 'Fermer la session', @@ -5082,6 +5113,7 @@ const translations = { 'workspace.activeSessionFiles': 'Fichiers de la session active', 'workspace.sftpChecking': 'Vérification de SFTP pour {target}...', 'workspace.sftpUnavailable': 'SFTP n’est pas disponible pour {target}.', + 'workspace.sftpResourceShortage': 'SFTP est temporairement indisponible pour {target}, car le serveur distant ne peut pas ouvrir un autre canal SSH. WebSSH réessaiera automatiquement.', 'sessionCommands.panelLabel': 'Commandes de la session', 'admin.ldapDirectory': 'Annuaire LDAP', 'admin.ldapCheckHint': 'Exécute un contrôle TLS et de liaison de service en lecture seule. Les identifiants et noms d’annuaire ne sont jamais renvoyés au navigateur.', @@ -5318,7 +5350,12 @@ const translations = { 'connection.profileName': 'Nombre de la conexión', 'connection.profileNamePlaceholder': 'Mi servidor', 'connection.connect': 'Conectar', + 'connection.connectingElapsed': 'Conectando... {seconds}s', 'connection.connectBusy': 'Ya hay un intento de conexión en curso.', + 'connection.cancelTooLate': 'La conexión ya se está completando y ya no puede cancelarse de forma segura.', + 'connection.cancelCompleted': 'La cancelación llegó demasiado tarde. La conexión ya estaba abierta y sigue activa.', + 'connection.cancelUnconfirmed': 'No se confirmó la cancelación. El intento sigue pendiente; intenta cancelarlo de nuevo.', + 'connection.cancelAlreadyStopped': 'El intento de conexión ya no está activo.', 'connection.reconnected': '¡Conexión restablecida!', 'connection.lostReconnecting': 'Conexión perdida. Reconectando...', 'connection.lostReconnectingAttempt': 'Conexión perdida. Reconectando... (intento {attempt})', @@ -5913,6 +5950,8 @@ const translations = { 'panes.disconnected': 'Desconectado', 'panes.emptyPane': 'Panel vacío', 'panes.selectSession': 'Selecciona una sesión o usa Conexión rápida', + 'panes.sessionPreserved': '{label} permanece abierta. Este panel solo cambia cuando otra conexión se establece correctamente.', + 'panes.returnToSession': 'Volver a {label}', 'session.closeWarning': 'Tienes sesiones SSH activas. Se cerrarán.', 'session.rename': 'Cambiar nombre de la sesión', 'session.close': 'Cerrar sesión', @@ -6361,6 +6400,7 @@ const translations = { 'workspace.activeSessionFiles': 'Archivos de la sesión activa', 'workspace.sftpChecking': 'Comprobando SFTP para {target}...', 'workspace.sftpUnavailable': 'SFTP no está disponible para {target}.', + 'workspace.sftpResourceShortage': 'SFTP no está disponible temporalmente para {target} porque el servidor remoto no puede abrir otro canal SSH. WebSSH volverá a intentarlo automáticamente.', 'sessionCommands.panelLabel': 'Comandos de la sesión', 'admin.ldapDirectory': 'Directorio LDAP', 'admin.ldapCheckHint': 'Ejecuta una comprobación de TLS y enlace de servicio de solo lectura. Las credenciales y los nombres del directorio nunca se devuelven al navegador.', @@ -6597,7 +6637,12 @@ const translations = { 'connection.profileName': '连接名称', 'connection.profileNamePlaceholder': '我的服务器', 'connection.connect': '连接', + 'connection.connectingElapsed': '正在连接... {seconds}s', 'connection.connectBusy': '已有连接尝试正在进行。', + 'connection.cancelTooLate': '连接已进入完成阶段,无法再安全取消。', + 'connection.cancelCompleted': '取消操作已太迟。连接已经建立并保持活动状态。', + 'connection.cancelUnconfirmed': '未能确认取消操作。连接尝试仍在等待中;请再次尝试取消。', + 'connection.cancelAlreadyStopped': '该连接尝试已不再处于活动状态。', 'connection.reconnected': '已重新连接!', 'connection.lostReconnecting': '连接已中断。正在重新连接...', 'connection.lostReconnectingAttempt': '连接已中断。正在重新连接...(第 {attempt} 次尝试)', @@ -7592,6 +7637,8 @@ const translations = { 'panes.disconnected': '已断开', 'panes.emptyPane': '空分栏', 'panes.selectSession': '选择一个会话或使用快速连接', + 'panes.sessionPreserved': '{label} 会保持打开。仅当另一条连接成功建立后,此窗格才会切换。', + 'panes.returnToSession': '返回 {label}', 'commands.workspace': '命令', 'commands.library': '命令库', @@ -7640,6 +7687,7 @@ const translations = { 'workspace.activeSessionFiles': '活动会话文件', 'workspace.sftpChecking': '正在检查 {target} 的 SFTP...', 'workspace.sftpUnavailable': '{target} 不支持 SFTP。', + 'workspace.sftpResourceShortage': '{target} 的 SFTP 暂时不可用,因为远程服务器无法再打开一个 SSH 通道。WebSSH 将自动重试。', 'sessionCommands.panelLabel': '会话命令', 'admin.ldapDirectory': 'LDAP 目录', 'admin.ldapCheckHint': '运行只读 TLS 和服务绑定检查。凭据和目录名称绝不会返回给浏览器。', diff --git a/static/js/mobile-app-shell.js b/static/js/mobile-app-shell.js index d1374a1..25ca715 100644 --- a/static/js/mobile-app-shell.js +++ b/static/js/mobile-app-shell.js @@ -110,12 +110,17 @@ } function syncSessionToolAvailability() { - const hasSession = Boolean(sessionManager?.getActiveSession?.()); + const interactiveSessionId = sessionManager?.getActiveSession?.(); + const workspaceSessionId = sessionManager?.getWorkspaceSession?.() + || interactiveSessionId; Object.entries(SESSION_TOOL_TARGETS).forEach(([view, config]) => { const button = elements.dockItems.find( item => item.dataset.mobileView === view, ); const tab = byId(config.targetId); + const hasSession = view === 'session-commands' + ? Boolean(interactiveSessionId) + : Boolean(workspaceSessionId); const available = Boolean( hasSession && tab @@ -129,7 +134,9 @@ } function updateSessionSummary() { - const sessionId = sessionManager?.getActiveSession?.(); + const interactiveSessionId = sessionManager?.getActiveSession?.(); + const sessionId = sessionManager?.getWorkspaceSession?.() + || interactiveSessionId; const session = sessionId ? sessionManager?.getSession?.(sessionId) : null; const connected = Boolean(session?.connected); const label = session @@ -155,8 +162,10 @@ 'disconnected', Boolean(session && !connected), ); - if (elements.commandToggle) elements.commandToggle.disabled = !session; - if (!session) setCommandOpen(false); + if (elements.commandToggle) { + elements.commandToggle.disabled = !interactiveSessionId; + } + if (!interactiveSessionId) setCommandOpen(false); syncSessionToolAvailability(); renderDockSelection(); } @@ -312,7 +321,8 @@ } function handleSessionSummary() { - const sessionId = sessionManager?.getActiveSession?.(); + const sessionId = sessionManager?.getWorkspaceSession?.() + || sessionManager?.getActiveSession?.(); const tab = sessionId ? byId(`tab-${sessionId}`) : null; tab?.scrollIntoView?.({behavior: 'smooth', block: 'nearest', inline: 'center'}); tab?.focus?.(); diff --git a/static/js/profile-manager.js b/static/js/profile-manager.js index 4fc6a1d..ce5cc22 100644 --- a/static/js/profile-manager.js +++ b/static/js/profile-manager.js @@ -303,9 +303,16 @@ const ProfileManager = { }); }, - createEmptyPaneContent(paneIndex) { + createEmptyPaneContent(paneIndex, options = {}) { const empty = document.createElement('div'); empty.className = 'pane-empty profile-launcher'; + const returnLabel = typeof options.returnLabel === 'string' + ? options.returnLabel.trim() + : ''; + const canReturn = returnLabel && typeof options.onReturn === 'function'; + if (canReturn) { + empty.classList.add('profile-launcher-replacement'); + } const icon = document.createElement('div'); icon.className = 'pane-empty-icon material-icons'; @@ -316,18 +323,47 @@ const ProfileManager = { const profiles = this.profilesLoaded ? this.profiles : []; const title = document.createElement('div'); title.className = 'profile-launcher-title'; - title.textContent = profiles.length + title.textContent = canReturn + ? this.t('panes.selectSession', 'Select a session or use Quick Connect') + : profiles.length ? (window.i18n ? i18n.t('connection.savedProfiles') : 'Hosts') : (window.i18n ? i18n.t('panes.emptyPane') : 'Empty pane'); empty.appendChild(title); const hint = document.createElement('div'); hint.className = 'profile-launcher-hint'; - hint.textContent = profiles.length + hint.textContent = canReturn + ? this.t( + 'panes.sessionPreserved', + '{label} remains open. This pane changes only after another connection succeeds.', + ).replace('{label}', returnLabel) + : profiles.length ? (window.i18n ? i18n.t('connection.savedProfilesHint') : 'Choose a saved connection to connect') : (window.i18n ? i18n.t('panes.selectSession') : 'Select a session or open a connection'); empty.appendChild(hint); + if (canReturn) { + const returnButton = document.createElement('button'); + returnButton.type = 'button'; + returnButton.className = 'btn btn-secondary profile-launcher-return'; + const returnText = this.t( + 'panes.returnToSession', + 'Back to {label}', + ).replace('{label}', returnLabel); + const returnIcon = document.createElement('span'); + returnIcon.className = 'material-icons'; + returnIcon.setAttribute('aria-hidden', 'true'); + returnIcon.textContent = 'arrow_back'; + const returnCopy = document.createElement('span'); + returnCopy.textContent = returnText; + returnButton.append(returnIcon, returnCopy); + returnButton.addEventListener('click', event => { + event.stopPropagation(); + options.onReturn(); + }); + empty.appendChild(returnButton); + } + if (profiles.length) { const search = document.createElement('input'); search.type = 'search'; @@ -340,6 +376,7 @@ const ProfileManager = { 'aria-label', this.t('profiles.search', 'Search saved connections'), ); + search.addEventListener('click', event => event.stopPropagation()); const sectionContainer = document.createElement('div'); sectionContainer.className = 'profile-launcher-sections'; diff --git a/static/js/session-command-launcher.js b/static/js/session-command-launcher.js index ac79751..a17aee2 100644 --- a/static/js/session-command-launcher.js +++ b/static/js/session-command-launcher.js @@ -218,8 +218,7 @@ sync() { const sessionManager = getSessionManager(); - const paneIndex = sessionManager?.getActivePaneIndex?.(); - const sessionId = sessionManager?.paneAssignments?.[paneIndex]; + const sessionId = sessionManager?.getActiveSession?.(); const session = sessionId ? sessionManager.getSession(sessionId) : null; const connected = Boolean(sessionId && session?.connected); this.activeSessionId = connected ? sessionId : null; diff --git a/static/js/session-files-panel.js b/static/js/session-files-panel.js index 6b56a97..a9ed01a 100644 --- a/static/js/session-files-panel.js +++ b/static/js/session-files-panel.js @@ -41,13 +41,20 @@ status.hidden = false; const target = targetLabel(session); const checking = ['unknown', 'probing'].includes(nextStatus); - const key = checking - ? 'workspace.sftpChecking' - : 'workspace.sftpUnavailable'; - const fallback = checking - ? 'Checking SFTP for {target}...' - : 'SFTP is not available for {target}.'; - status.textContent = String(translate(key, fallback)) + const resourceShortage = nextStatus === 'resource_shortage'; + const key = resourceShortage + ? 'workspace.sftpResourceShortage' + : checking + ? 'workspace.sftpChecking' + : 'workspace.sftpUnavailable'; + const fallback = resourceShortage + ? 'The SSH server has no free channel capacity for SFTP on {target}. WebSSH will retry automatically in about one minute.' + : checking + ? 'Checking SFTP for {target}...' + : 'SFTP is not available for {target}.'; + const translated = translate(key, fallback); + const message = translated && translated !== key ? translated : fallback; + status.textContent = String(message) .replace('{target}', target); } diff --git a/static/js/session-insights.js b/static/js/session-insights.js index f97a911..d1e4033 100644 --- a/static/js/session-insights.js +++ b/static/js/session-insights.js @@ -214,6 +214,16 @@ } function renderFailure(reason = 'transient', responseRequest = null) { + if (reason === 'resource_shortage') { + failureCount = Math.max(failureCount, 3); + pauseRegularPolling(); + render(currentState( + lastGood ? 'stale' : 'unavailable', + lastGood ? { ...lastGood } : {}, + )); + scheduleRetry(); + return; + } if (reason === 'unsupported') { if (responseRequest?.includeDiagnostics) { unsupportedDiagnosticsSessions.add(sessionId); diff --git a/static/js/session-manager.js b/static/js/session-manager.js index 31d7ed7..ed3f012 100644 --- a/static/js/session-manager.js +++ b/static/js/session-manager.js @@ -8,6 +8,7 @@ const SessionManager = { pendingConnections: {}, layout: 1, paneAssignments: [], + connectionLauncherSessions: new Map(), activePaneIndex: 0, confirmSessionClose: document.body?.dataset.confirmSessionClose === 'true', disconnectSessionAction: ['retry', 'close'].includes( @@ -347,6 +348,10 @@ const SessionManager = { const assignedIndex = this.paneAssignments.findIndex(id => id === sessionId); if (assignedIndex !== -1) { + if (this.connectionLauncherSessions.has(assignedIndex)) { + this.restoreConnectionLauncher(assignedIndex); + return; + } this.setActivePane(assignedIndex); return; } @@ -395,6 +400,7 @@ const SessionManager = { const paneIndex = this.paneAssignments.findIndex(id => id === sessionId); if (paneIndex !== -1) { + this.connectionLauncherSessions.delete(paneIndex); this.paneAssignments[paneIndex] = null; this.renderPane(paneIndex); } @@ -600,12 +606,14 @@ const SessionManager = { tabLabel.className = 'tab-label'; tabLabel.textContent = `${username}@${host}`; - const tabClose = document.createElement('span'); + const tabClose = document.createElement('button'); + tabClose.type = 'button'; tabClose.className = 'tab-close'; tabClose.dataset.pendingId = requestId; tabClose.classList.add('material-icons'); tabClose.textContent = 'close'; tabClose.setAttribute('aria-label', 'Cancel connection'); + tabClose.setAttribute('title', 'Cancel connection'); tab.appendChild(statusDot); tab.appendChild(tabLabel); @@ -613,7 +621,10 @@ const SessionManager = { tabClose.addEventListener('click', (e) => { e.stopPropagation(); - this.clearPendingConnection(requestId); + window.dispatchEvent?.(new CustomEvent( + 'ssh-connection-cancel-requested', + { detail: { requestId } }, + )); }); document.getElementById('sessionTabs').appendChild(tab); @@ -622,10 +633,27 @@ const SessionManager = { clearPendingConnection(requestId) { const tab = document.getElementById(`pending-${requestId}`); + const shouldRestoreFocus = Boolean( + tab && tab.contains?.(document.activeElement) + ); if (tab) { tab.remove(); } delete this.pendingConnections[requestId]; + if (shouldRestoreFocus) { + const restoreFocus = () => { + if (this.getActiveSession()) { + this.focusActivePane(); + } else { + this.focusConnectionLauncher(this.activePaneIndex); + } + }; + if (typeof window.requestAnimationFrame === 'function') { + window.requestAnimationFrame(restoreFocus); + } else { + restoreFocus(); + } + } }, getDisplayLabel(sessionId, username, host) { @@ -688,7 +716,10 @@ const SessionManager = { input.focus(); input.select(); + let renameFinished = false; const finishRename = (save) => { + if (renameFinished) return; + renameFinished = true; const newName = input.value.trim(); input.remove(); @@ -801,7 +832,7 @@ const SessionManager = { window.dispatchEvent(new CustomEvent('session-workspace-change', { detail: { layout: this.layout, - sessionId: this.activeSessionId, + sessionId: this.getWorkspaceSession(), }, })); }, @@ -817,6 +848,7 @@ const SessionManager = { } const previousAssignments = this.paneAssignments.slice(); + this.connectionLauncherSessions.clear(); this.layout = layout; this.paneAssignments = new Array(layout).fill(null); for (let i = 0; i < layout; i++) { @@ -858,7 +890,8 @@ const SessionManager = { refreshEmptyPanes() { this.paneAssignments.forEach((sessionId, index) => { - if (!this.paneAssignments[index]) { + if (!this.paneAssignments[index] + || this.connectionLauncherSessions.has(index)) { this.renderPane(index); } }); @@ -876,7 +909,8 @@ const SessionManager = { pane.innerHTML = ''; const sessionId = this.paneAssignments[paneIndex]; - if (sessionId) { + const launcherSessionId = this.connectionLauncherSessions.get(paneIndex); + if (sessionId && !launcherSessionId) { const session = this.sessions[sessionId]; if (!session) { return; @@ -890,8 +924,27 @@ const SessionManager = { return; } + if (sessionId && launcherSessionId) { + const session = this.sessions[sessionId]; + const wrapper = session + ? document.getElementById(session.terminalId) + : null; + const container = document.getElementById('terminalsContainer'); + if (wrapper && container && wrapper.parentElement !== container) { + wrapper.classList.add('unassigned'); + container.appendChild(wrapper); + } + } + const empty = typeof ProfileManager !== 'undefined' - ? ProfileManager.createEmptyPaneContent(paneIndex) + ? ProfileManager.createEmptyPaneContent(paneIndex, launcherSessionId ? { + returnLabel: this.getDisplayLabel( + launcherSessionId, + this.sessions[launcherSessionId]?.username, + this.sessions[launcherSessionId]?.host, + ), + onReturn: () => this.restoreConnectionLauncher(paneIndex), + } : {}) : document.createElement('div'); if (!empty.className) { empty.className = 'pane-empty'; @@ -912,9 +965,11 @@ const SessionManager = { return; } + this.connectionLauncherSessions.delete(paneIndex); const clearedIndices = []; this.paneAssignments = this.paneAssignments.map((existing, index) => { if (existing === sessionId) { + this.connectionLauncherSessions.delete(index); clearedIndices.push(index); return null; } @@ -954,13 +1009,25 @@ const SessionManager = { if (paneIndex < 0 || paneIndex >= this.paneAssignments.length) { return; } + const previousPaneIndex = this.activePaneIndex; + if ( + previousPaneIndex !== paneIndex + && this.connectionLauncherSessions.has(previousPaneIndex) + ) { + this.restoreConnectionLauncher(previousPaneIndex, { activate: false }); + } + this.activePaneIndex = paneIndex; grid.querySelectorAll('.terminal-pane').forEach(pane => { pane.classList.toggle('active', pane.dataset.paneIndex === String(paneIndex)); }); - const sessionId = this.paneAssignments[paneIndex] || null; - this.activeSessionId = sessionId; + const assignedSessionId = this.paneAssignments[paneIndex] || null; + const launcherOpen = this.connectionLauncherSessions.has(paneIndex); + const sessionId = assignedSessionId; + // Preserve the pane context for Files and diagnostics, but never expose + // a hidden terminal as the target for paste, mobile input, or commands. + this.activeSessionId = launcherOpen ? null : sessionId; this.updateSessionMeta(sessionId); document.querySelectorAll('.session-tab').forEach(tab => { @@ -972,9 +1039,13 @@ const SessionManager = { tab.classList.add('active'); } } - this.focusActivePane(); + if (launcherOpen) { + this.focusConnectionLauncher(paneIndex); + } else { + this.focusActivePane(); + } - if (sessionId) { + if (sessionId && !launcherOpen) { setTimeout(() => { TerminalManager.fitAndSyncVisibleTerminals({ socket: window.socket, @@ -990,7 +1061,7 @@ const SessionManager = { }, focusActivePane() { - const sessionId = this.paneAssignments[this.activePaneIndex]; + const sessionId = this.getActiveSession(); if (!sessionId) { return; } @@ -1001,7 +1072,7 @@ const SessionManager = { }, getActiveTerminal() { - const sessionId = this.paneAssignments[this.activePaneIndex]; + const sessionId = this.getActiveSession(); if (!sessionId) { return null; } @@ -1012,6 +1083,14 @@ const SessionManager = { return this.activePaneIndex; }, + isConnectionLauncherOpen(paneIndex = this.activePaneIndex) { + return this.connectionLauncherSessions.has(paneIndex); + }, + + getWorkspaceSession() { + return this.paneAssignments[this.activePaneIndex] || null; + }, + getFirstEmptyPaneIndex() { return this.paneAssignments.findIndex(sessionId => !sessionId); }, @@ -1022,6 +1101,64 @@ const SessionManager = { .filter(index => index !== null); }, + showConnectionLauncher(paneIndex) { + if ( + !Number.isInteger(paneIndex) + || paneIndex < 0 + || paneIndex >= this.paneAssignments.length + ) { + return false; + } + + const sessionId = this.paneAssignments[paneIndex]; + const session = sessionId ? this.sessions[sessionId] : null; + const wrapper = session + ? document.getElementById(session.terminalId) + : null; + const container = document.getElementById('terminalsContainer'); + if (window.TerminalSearch?.isOpen) { + window.TerminalSearch.close(); + } + const workspaceState = window.workspaceLayoutController?.getState?.(); + if (workspaceState?.mode !== 'desktop' && workspaceState?.activeContext) { + window.workspaceLayoutController.closeContext?.('connection-launcher'); + } + if (wrapper && container) { + wrapper.classList.add('unassigned'); + container.appendChild(wrapper); + } + + if (sessionId) { + this.connectionLauncherSessions.set(paneIndex, sessionId); + } + this.renderPane(paneIndex); + this.setActivePane(paneIndex); + return true; + }, + + restoreConnectionLauncher(paneIndex, options = {}) { + if (!this.connectionLauncherSessions.has(paneIndex)) { + return false; + } + this.connectionLauncherSessions.delete(paneIndex); + this.renderPane(paneIndex); + if (options.activate !== false) { + this.setActivePane(paneIndex); + } + return true; + }, + + focusConnectionLauncher(paneIndex = this.activePaneIndex) { + const grid = this.ensureTerminalGrid(); + const pane = grid?.querySelector( + `.terminal-pane[data-pane-index="${paneIndex}"]` + ); + pane?.querySelector( + '.profile-launcher-return, .profile-launcher-search, ' + + '.profile-launcher-card, .profile-launcher-new' + )?.focus?.(); + }, + updateSplitControls() { document.querySelectorAll('.split-btn').forEach(btn => { const layout = parseInt(btn.dataset.layout, 10); @@ -1450,6 +1587,7 @@ const SessionManager = { return; } + this.connectionLauncherSessions.clear(); this.layout = layout; this.paneAssignments = new Array(layout).fill(null); diff --git a/static/js/session-workspace-ui.js b/static/js/session-workspace-ui.js index fc386c5..70210ab 100644 --- a/static/js/session-workspace-ui.js +++ b/static/js/session-workspace-ui.js @@ -141,7 +141,8 @@ }); function sync() { - const activeId = sessionManager.getActiveSession(); + const activeId = sessionManager.getWorkspaceSession?.() + || sessionManager.getActiveSession(); const activeSession = activeId ? sessionManager.getSession(activeId) : null; const connected = Boolean(activeId && activeSession?.connected); coordinator.update({ @@ -159,10 +160,12 @@ } } - documentRef.addEventListener('session-sftp-request-close', () => { + documentRef.addEventListener('session-sftp-request-close', event => { if (coordinator.getState().sftpOpen) coordinator.toggleSftp(); if (root.workspaceLayoutController?.getState?.().activeContext === 'files') { - root.workspaceLayoutController.closeContext('programmatic'); + root.workspaceLayoutController.closeContext( + event.detail?.reason === 'user' ? 'user' : 'programmatic' + ); } }); diff --git a/static/js/session-workspace.js b/static/js/session-workspace.js index af7dc8b..3ae2688 100644 --- a/static/js/session-workspace.js +++ b/static/js/session-workspace.js @@ -22,12 +22,14 @@ const pending = new Map(); const retryTimers = new Map(); const retryCounts = new Map(); + const retryStates = new Map(); let activeProbeSessionId = null; function clearRetry(sessionId) { const timerId = retryTimers.get(sessionId); if (timerId !== undefined) clearTimeoutFn(timerId); retryTimers.delete(sessionId); + retryStates.delete(sessionId); } function cancelProbe(sessionId) { @@ -55,9 +57,14 @@ return true; } - function scheduleRetry(sessionId) { + function scheduleRetry(sessionId, reason = null) { + const resourceShortage = reason === 'resource_shortage'; const retries = retryCounts.get(sessionId) || 0; - if (retries >= 2 && activeProbeSessionId === sessionId) { + if ( + !resourceShortage + && retries >= 2 + && activeProbeSessionId === sessionId + ) { clearRetry(sessionId); retryCounts.delete(sessionId); capabilities.set(sessionId, 'inconclusive'); @@ -68,12 +75,23 @@ retryTimers.has(sessionId) || activeProbeSessionId !== sessionId ) return; - retryCounts.set(sessionId, retries + 1); + if (resourceShortage) { + retryCounts.delete(sessionId); + retryStates.set(sessionId, 'resource_shortage'); + } else { + retryCounts.set(sessionId, retries + 1); + retryStates.delete(sessionId); + } const timerId = setTimeoutFn(() => { retryTimers.delete(sessionId); - if (activeProbeSessionId === sessionId) startProbe(sessionId); - }, 10000); + retryStates.delete(sessionId); + if (activeProbeSessionId === sessionId) { + startProbe(sessionId); + if (resourceShortage) onChange(sessionId); + } + }, resourceShortage ? 60000 : 10000); retryTimers.set(sessionId, timerId); + if (resourceShortage) onChange(sessionId); } socket.on('session_sftp_capability', data => { @@ -88,7 +106,12 @@ clearTimeoutFn(request.timeoutId); pending.delete(sessionId); if (data.success !== true) { - scheduleRetry(sessionId); + scheduleRetry( + sessionId, + data.reason === 'resource_shortage' + ? 'resource_shortage' + : null + ); return; } clearRetry(sessionId); @@ -103,7 +126,12 @@ return { get(sessionId) { if (capabilities.has(sessionId)) return capabilities.get(sessionId); - if (pending.has(sessionId) || retryTimers.has(sessionId)) return 'probing'; + if (pending.has(sessionId)) return 'probing'; + if ( + retryTimers.has(sessionId) + && retryStates.get(sessionId) === 'resource_shortage' + ) return 'resource_shortage'; + if (retryTimers.has(sessionId)) return 'probing'; return 'unknown'; }, @@ -173,7 +201,7 @@ const sftpProbeNeeded = Boolean( sessionId && session?.connected - && ['unknown', 'probing'].includes(sftpCapability) + && ['unknown', 'probing', 'resource_shortage'].includes(sftpCapability) ); return { layout, @@ -218,6 +246,7 @@ 'available', 'unavailable', 'probing', + 'resource_shortage', 'inconclusive', ].includes(next?.sftpCapability) ? next.sftpCapability diff --git a/static/js/sftp-file-manager.js b/static/js/sftp-file-manager.js index 4de4ffc..5535f20 100644 --- a/static/js/sftp-file-manager.js +++ b/static/js/sftp-file-manager.js @@ -1844,7 +1844,13 @@ class SFTPFileManager { } setupKeyboardShortcuts() { - document.addEventListener('keydown', e => this.handleKeyboardShortcut(e)); + // Capture Escape before the workspace and app-level handlers so the + // topmost Files layer always closes first. + document.addEventListener( + 'keydown', + e => this.handleKeyboardShortcut(e), + true, + ); } handlesSocketError(data) { @@ -1940,19 +1946,89 @@ class SFTPFileManager { if (!this.isOpen) return; if (e.key === 'Escape') { - this.closeContextMenu(); - if (this.sourceLauncherPane) { + if ( + window.TerminalSearch?.isOpen + && e.target?.closest?.('#terminalSearchBar') + ) return; + const connectionModalOpen = document.getElementById( + 'connectionModal' + )?.classList.contains('show'); + if (connectionModalOpen) return; + const mobileShellState = window.mobileAppShell?.getState?.(); + if (mobileShellState?.moreOpen || mobileShellState?.commandOpen) { + return; + } + + const consumeEscape = callback => { e.preventDefault(); - this.closeSourceLauncher(); + callback(); + e.stopImmediatePropagation?.(); + }; + + const contextMenuOpen = Boolean(this.contextMenu); + if (contextMenuOpen) { + consumeEscape(() => this.closeContextMenu()); + return; + } + if (this.actionSheet?.classList?.contains('visible')) { + consumeEscape(() => this.hideActionSheet()); + return; + } + const conflictDialog = document.querySelector('.fm-conflict-dialog'); + if (conflictDialog) { + consumeEscape(() => conflictDialog.querySelector( + '[data-conflict-action="cancel"]' + )?.click?.()); return; } + if (this.movePicker) { + consumeEscape(() => this.closeMovePicker()); + return; + } + if (this.qcModal?.classList.contains('show')) { + consumeEscape(() => this.closeQuickConnect()); + return; + } + if (this.sourceLauncherPane) { + consumeEscape(() => this.closeSourceLauncher()); + return; + } + const targetInsideFiles = Boolean( + this.modalBody?.contains?.(e.target) + || ( + this.displayMode === 'modal' + && this.modal?.contains?.(e.target) + ) + ); + if (!targetInsideFiles) return; + const foregroundModalOpen = Array.from( + document.querySelectorAll('.modal.show') + ).some(modal => ( + modal !== this.modal + && !modal.classList.contains('primary-workspace-view') + )); + if (foregroundModalOpen) return; + const launcherOpen = typeof SessionManager !== 'undefined' + && SessionManager.isConnectionLauncherOpen?.(); + const focusedLauncher = e.target?.closest?.('.profile-launcher') + || document.activeElement?.closest?.('.profile-launcher'); + if (launcherOpen && focusedLauncher) return; + if ( + this.displayMode === 'embedded' + && window.workspaceLayoutController?.getState?.().activeContext !== 'files' + ) return; if (!this.hasOpenDialogs()) { if (this.displayMode === 'embedded') { - window.dispatchEvent?.(new CustomEvent('session-sftp-request-close')); + document.dispatchEvent?.(new CustomEvent( + 'session-sftp-request-close', + { detail: { reason: 'user' } }, + )); if (this.displayMode === 'embedded') this.closeEmbedded(); } else if (!window.primaryWorkspaceController?.isElementActive(this.modal)) { this.close(); } + e.preventDefault(); + e.stopImmediatePropagation?.(); } } diff --git a/static/js/socket-protocol.js b/static/js/socket-protocol.js index 8e0f33c..064f937 100644 --- a/static/js/socket-protocol.js +++ b/static/js/socket-protocol.js @@ -9,7 +9,7 @@ })(typeof window !== 'undefined' ? window : globalThis, function() { 'use strict'; - const WIRE_REVISION = 1; + const WIRE_REVISION = 2; const MISMATCH_EVENT = 'socket_protocol_mismatch'; const RELOAD_GUARD_KEY = 'webssh:socket-protocol-reload'; diff --git a/static/js/webssh2-shell.js b/static/js/webssh2-shell.js index cbdf281..fc9dd9f 100644 --- a/static/js/webssh2-shell.js +++ b/static/js/webssh2-shell.js @@ -65,7 +65,8 @@ }; function render() { - const activeId = sessionManager.getActiveSession?.(); + const activeId = sessionManager.getWorkspaceSession?.() + || sessionManager.getActiveSession?.(); const session = activeId ? sessionManager.getSession?.(activeId) : null; diff --git a/static/js/workspace-layout-controller.js b/static/js/workspace-layout-controller.js index d6bf7eb..5bed9ea 100644 --- a/static/js/workspace-layout-controller.js +++ b/static/js/workspace-layout-controller.js @@ -478,8 +478,16 @@ listen(elements.backdrop, 'click', () => closeContext('user')); listen(documentRef, 'keydown', event => { if (event.key === 'Escape' && activeContext && mode !== 'desktop') { + if (event.defaultPrevented) return; + const foregroundModalOpen = Array.from( + documentRef.querySelectorAll?.('.modal.show') || [] + ).some(modal => !modal.classList.contains('primary-workspace-view')); + if (foregroundModalOpen || event.target?.closest?.('[role="dialog"]')) { + return; + } event.preventDefault?.(); closeContext('user'); + event.stopImmediatePropagation?.(); } }); listen(windowRef, 'resize', reconcile); diff --git a/templates/index.html b/templates/index.html index 8d7817a..c8b2ba4 100644 --- a/templates/index.html +++ b/templates/index.html @@ -48,7 +48,7 @@
- + @@ -115,7 +115,7 @@
- +
diff --git a/tests/e2e/primary-workspace-navigation.spec.js b/tests/e2e/primary-workspace-navigation.spec.js index c0832dd..b42fc6f 100644 --- a/tests/e2e/primary-workspace-navigation.spec.js +++ b/tests/e2e/primary-workspace-navigation.spec.js @@ -264,6 +264,7 @@ test('Settings presents GitHub as a security method and collapses its admin guid test('return-to-connection command-set editing still behaves as a nested modal', async ({ page }) => { await page.locator('#newTabBtn').click(); + await page.locator('.terminal-pane.active .profile-launcher-new').click(); await expect(page.locator('#connectionModal')).toHaveClass(/show/); await page.evaluate(() => window.CommandSetManager.openBuilder(null, true)); diff --git a/tests/e2e/product-captures.spec.js b/tests/e2e/product-captures.spec.js index 82516a2..c217088 100644 --- a/tests/e2e/product-captures.spec.js +++ b/tests/e2e/product-captures.spec.js @@ -589,6 +589,7 @@ test('captures the current Quick Connect surface at Full HD scale without outbou process.env.WEBSSH_CAPTURE_EXPECT_SAVED_CONNECTIONS || 'Hosts', ); await page.locator('#newTabBtn').click(); + await page.locator('.terminal-pane.active .profile-launcher-new').click(); await expect(page.locator('#connectionModal')).toHaveClass(/show/); await expect(page.locator('#connectionModalTitle')).toHaveText( process.env.WEBSSH_CAPTURE_EXPECT_QUICK_CONNECT || 'Quick Connect', @@ -623,6 +624,7 @@ test('captures seeded command and connection option surfaces at Full HD scale', await page.locator('#workspaceNavBtn').click(); await page.locator('#newTabBtn').click(); + await page.locator('.terminal-pane.active .profile-launcher-new').click(); await page.evaluate(() => window.selectConnectionProfile('post-command-set')); await sanitizeSeededCatalog(page); await expect(page.locator('#connectionModalTitle')).toHaveText('Quick Connect'); @@ -936,6 +938,7 @@ test('captures six current Command Sets animation frames without remote actions' await page.locator('#workspaceNavBtn').click(); await page.locator('#newTabBtn').click(); + await page.locator('.terminal-pane.active .profile-launcher-new').click(); await page.evaluate(() => window.selectConnectionProfile('post-command-set')); await sanitizeSeededCatalog(page); await expect(page.locator('#connectionModalTitle')).toHaveText('Quick Connect'); diff --git a/tests/e2e/quick-connect-redesign.spec.js b/tests/e2e/quick-connect-redesign.spec.js index b10b836..3d30ec4 100644 --- a/tests/e2e/quick-connect-redesign.spec.js +++ b/tests/e2e/quick-connect-redesign.spec.js @@ -15,6 +15,11 @@ test.afterEach(async ({ page }) => { await assertNoExternalRequests(page); }); +async function openQuickConnect(page) { + await page.locator('#newTabBtn').click(); + await page.locator('.terminal-pane.active .profile-launcher-new').click(); +} + test('keeps Quick Connect in the workspace without a duplicate header action', async ({ page }) => { await expect(page.locator('#newConnectionBtn')).toHaveCount(0); @@ -35,8 +40,12 @@ test('keeps Quick Connect in the workspace without a duplicate header action', a await page.evaluate(profiles => window.ProfileManager.setProfiles(profiles), savedProfiles); await newTab.click(); - await expect(page.locator('#connectionModal')).toHaveClass(/show/); - await page.locator('#cancelConnectionBtn').click(); + await expect(page.locator('#connectionModal')).not.toHaveClass(/show/); + await expect(page.locator('.terminal-pane.active .profile-launcher')).toBeVisible(); + await expect(newTab).toHaveAttribute( + 'aria-label', + 'Select a session or use Quick Connect', + ); await centralLauncher.click(); await expect(page.locator('#connectionModal')).toHaveClass(/show/); @@ -54,6 +63,23 @@ test('keeps Quick Connect in the workspace without a duplicate header action', a await expect(page.locator('#connectionModal')).toHaveClass(/show/); }); +test('action-bar plus targets the active pane when another pane is empty', async ({ page }) => { + await page.evaluate(() => { + window.__launcherTargetPane = null; + SessionManager.getActivePaneIndex = () => 1; + SessionManager.getActiveSession = () => ({id: 'active-session'}); + SessionManager.getFirstEmptyPaneIndex = () => 0; + SessionManager.showConnectionLauncher = paneIndex => { + window.__launcherTargetPane = paneIndex; + return true; + }; + }); + + await page.locator('#newTabBtn').click(); + + await expect.poll(() => page.evaluate(() => window.__launcherTargetPane)).toBe(1); +}); + test('presents a focused two-column quick connect without a saved-profile picker', async ({ page }) => { await page.evaluate(() => { for (let index = 0; index < 7; index += 1) { @@ -62,7 +88,7 @@ test('presents a focused two-column quick connect without a saved-profile picker ); } }); - await page.locator('#newTabBtn').click(); + await openQuickConnect(page); await expect(page.locator('#connectionModal')).toHaveClass(/show/); await expect(page.locator('#connectionDetailsCard')).toBeVisible(); @@ -126,7 +152,7 @@ test('presents a focused two-column quick connect without a saved-profile picker }); test('uses the requested connection details and advanced settings hierarchy', async ({ page }) => { - await page.locator('#newTabBtn').click(); + await openQuickConnect(page); const hierarchy = await page.locator('#connectionModal').evaluate(modal => { const detailsContent = modal.querySelector('.quick-connect-details-content'); @@ -175,7 +201,7 @@ test('keeps modal actions fixed while expanded content scrolls inside', async ({ ); } }); - await page.locator('#newTabBtn').click(); + await openQuickConnect(page); await page.locator('#connectionAdvancedSettings > summary').click(); await expect(page.locator('#connectionModal .modal-content')).toHaveCSS('transform', 'none'); @@ -242,7 +268,7 @@ test.describe('mobile quick connect', () => { 'mobile.internal', 22, 'mobile' ); }); - await page.locator('#newTabBtn').click(); + await openQuickConnect(page); const geometry = await page.locator('#connectionModal').evaluate(modal => { const details = modal.querySelector('#connectionDetailsCard') diff --git a/tests/e2e/session-workspace.spec.js b/tests/e2e/session-workspace.spec.js index 5bd1b92..19673bb 100644 --- a/tests/e2e/session-workspace.spec.js +++ b/tests/e2e/session-workspace.spec.js @@ -740,6 +740,41 @@ test('360px mobile workspace keeps tools available and preserves context across await embeddedFile.click(); await expect(embeddedFile).toHaveClass(/selected/); + await page.locator('#mobileMoreBtn').click(); + await expect(page.locator('#headerButtons')).toHaveClass(/is-open/); + await page.keyboard.press('Escape'); + await expect(page.locator('#headerButtons')).not.toHaveClass(/is-open/); + await expect(page.locator('#sessionFilesPanel')).toBeVisible(); + await expect(page.locator('#sessionFilesPanel #fmLeftPath')).toHaveValue( + '/srv/webssh/current', + ); + await expect(embeddedFile).toHaveClass(/selected/); + + await page.locator('#newTabBtn').click(); + const mobileLauncher = page.locator('.terminal-pane.active .profile-launcher'); + await expect(mobileLauncher).toBeVisible(); + await expect(page.locator('#contextWorkspace')).toBeHidden(); + await expect(page.locator('#mobileCommandToggle')).toBeDisabled(); + await expect(commandsDock).toBeDisabled(); + expect(await page.evaluate(() => ({ + interactive: SessionManager.getActiveSession(), + workspace: SessionManager.getWorkspaceSession(), + path: window.sftpFileManager.panes.left.path, + selected: Array.from(window.sftpFileManager.panes.left.selected), + }))).toEqual({ + interactive: null, + workspace: 'workspace-linux', + path: '/srv/webssh/current', + selected: [1], + }); + await mobileLauncher.locator('.profile-launcher-return').click(); + await expect(page.locator('.terminal-pane.active .xterm')).toBeVisible(); + await sftpDock.click(); + await expect(page.locator('#sessionFilesPanel #fmLeftPath')).toHaveValue( + '/srv/webssh/current', + ); + await expect(embeddedFile).toHaveClass(/selected/); + const embeddedScrollLayout = await page.evaluate(() => { const panel = document.getElementById('sessionFilesPanel'); const mount = document.getElementById('sessionFilesMount'); @@ -1174,6 +1209,952 @@ test('Files context follows session capability and stays mounted while tools swi await assertNoExternalRequests(page); }); +test('connection launcher preserves the live session and Files context until replacement', async ({ page }) => { + await login(page); + await seedLinuxSession(page); + + const filesPanel = page.locator('#sessionFilesPanel'); + const selectedFile = filesPanel.locator('#fmLeftList .fm-file-item[data-index="1"]'); + await expect(filesPanel).toBeVisible(); + await expect(filesPanel.locator('#fmLeftPath')).toHaveValue('/srv/webssh/current'); + await expect(filesPanel.locator('#fmLeftList .fm-file-item')).toHaveCount(5); + await selectedFile.click(); + await expect(selectedFile).toHaveClass(/selected/); + + const requestCountsBefore = await page.evaluate(() => ({ + home: window.__workspaceEvents.filter( + entry => entry.event === 'get_home_directory' + ).length, + list: window.__workspaceEvents.filter( + entry => entry.event === 'list_directory' + ).length, + })); + + await page.evaluate(() => { + ProfileManager.profilesLoaded = true; + ProfileManager.profiles = [{ + id: 'saved-replacement', + name: 'Staging gateway', + host: 'staging.example', + port: 22, + username: 'deploy', + auth_type: 'password', + }]; + }); + + await page.locator('#newTabBtn').click(); + + const launcher = page.locator('.terminal-pane.active .profile-launcher'); + const returnButton = launcher.locator('.profile-launcher-return'); + await expect(launcher).toBeVisible(); + await expect(returnButton).toContainText('Production Edge'); + await expect(returnButton).toBeFocused(); + const launcherSearch = launcher.locator('.profile-launcher-search'); + await launcherSearch.click(); + await expect(launcherSearch).toBeFocused(); + await returnButton.focus(); + await page.keyboard.press('Control+f'); + await expect(launcherSearch).toBeFocused(); + await expect(page.locator('#terminalSearchBar')).toHaveClass(/hidden/); + await expect(filesPanel).toBeVisible(); + await expect(filesPanel.locator('#fmLeftPath')).toHaveValue('/srv/webssh/current'); + await expect(selectedFile).toHaveClass(/selected/); + + await selectedFile.click({ button: 'right' }); + await expect(page.locator('.fm-context-menu')).toBeVisible(); + await page.keyboard.press('Escape'); + await expect(page.locator('.fm-context-menu')).toHaveCount(0); + await expect(launcher).toBeVisible(); + await expect(filesPanel).toBeVisible(); + + await page.keyboard.press('Control+k'); + await expect(page.locator('#commandPaletteModal')).toHaveClass(/show/); + await page.keyboard.press('Escape'); + await expect(page.locator('#commandPaletteModal')).not.toHaveClass(/show/); + await expect(launcher).toBeVisible(); + await expect(filesPanel).toBeVisible(); + + expect(await page.evaluate(() => ({ + assignment: SessionManager.paneAssignments[0], + activeSession: SessionManager.getActiveSession(), + workspaceSession: SessionManager.getWorkspaceSession(), + launcherOpen: SessionManager.isConnectionLauncherOpen(0), + connected: SessionManager.getSession('workspace-linux')?.connected, + filesMode: window.sftpFileManager?.displayMode, + disconnects: window.__workspaceEvents.filter( + entry => entry.event === 'ssh_disconnect' + ).length, + }))).toEqual({ + assignment: 'workspace-linux', + activeSession: null, + workspaceSession: 'workspace-linux', + launcherOpen: true, + connected: true, + filesMode: 'embedded', + disconnects: 0, + }); + + await launcher.locator('.profile-launcher-new').click(); + await expect(page.locator('#connectionModal')).toHaveClass(/show/); + await page.locator('#cancelConnectionBtn').click(); + await expect(page.locator('#connectionModal')).not.toHaveClass(/show/); + await expect(launcher).toBeVisible(); + await expect(filesPanel.locator('#fmLeftPath')).toHaveValue('/srv/webssh/current'); + await expect(selectedFile).toHaveClass(/selected/); + + await page.evaluate(() => { + TerminalManager.writeOutput( + 'workspace-linux', + '\r\noutput received while choosing a connection\r\n', + ); + }); + await page.keyboard.press('Escape'); + + await expect(launcher).toHaveCount(0); + await expect(page.locator('.terminal-pane.active .xterm')).toBeVisible(); + await expect.poll(() => page.evaluate(() => { + const terminalKey = TerminalManager.sessionTerminals['workspace-linux'][0]; + const buffer = TerminalManager.terminals[terminalKey].buffer.active; + return Array.from({ length: buffer.length }, (_entry, index) => ( + buffer.getLine(index)?.translateToString(true) || '' + )).join('\n'); + })).toContain('output received while choosing a connection'); + await expect(filesPanel.locator('#fmLeftPath')).toHaveValue('/srv/webssh/current'); + await expect(selectedFile).toHaveClass(/selected/); + + expect(await page.evaluate(() => ({ + home: window.__workspaceEvents.filter( + entry => entry.event === 'get_home_directory' + ).length, + list: window.__workspaceEvents.filter( + entry => entry.event === 'list_directory' + ).length, + disconnects: window.__workspaceEvents.filter( + entry => entry.event === 'ssh_disconnect' + ).length, + launcherOpen: SessionManager.isConnectionLauncherOpen(0), + }))).toEqual({ + ...requestCountsBefore, + disconnects: 0, + launcherOpen: false, + }); + await assertNoExternalRequests(page); +}); + +test('pending saved-profile connection can be cancelled from its tab by keyboard', async ({ page }) => { + await login(page); + await seedLinuxSession(page); + + await page.evaluate(() => { + ProfileManager.keys = [{ id: 'direct-key', usable: true }]; + ProfileManager.profilesLoaded = true; + ProfileManager.profiles = [{ + id: 'direct-cancel-profile', + name: 'Direct staging host', + host: 'direct.example', + port: 22, + username: 'deploy', + auth_type: 'key', + key_id: 'direct-key', + startup_mode: 'none', + }]; + const previousEmit = window.socket.emit.bind(window.socket); + window.__directConnectEvents = []; + window.socket.emit = function holdDirectConnection(event, payload, ...rest) { + if (event === 'ssh_connect' || event === 'ssh_connect_cancel') { + window.__directConnectEvents.push({ + event, + payload: structuredClone(payload), + }); + if (event === 'ssh_connect_cancel') { + queueMicrotask(() => rest[0]?.({ + success: true, + cancelled: true, + })); + } + return window.socket; + } + return previousEmit(event, payload, ...rest); + }; + }); + + await page.locator('#newTabBtn').click(); + await page.locator('[data-profile-id="direct-cancel-profile"]').click(); + await expect.poll(() => page.evaluate(() => ( + window.__directConnectEvents.filter(entry => entry.event === 'ssh_connect').length + ))).toBe(1); + + const requestId = await page.evaluate(() => ( + window.__directConnectEvents.find(entry => entry.event === 'ssh_connect') + .payload.client_request_id + )); + const pendingTab = page.locator(`[data-pending-id="${requestId}"]`).first(); + const cancelButton = pendingTab.getByRole('button', { name: 'Cancel connection' }); + await expect(cancelButton).toHaveAttribute('type', 'button'); + await cancelButton.focus(); + await cancelButton.press('Enter'); + + await expect(pendingTab).toHaveCount(0); + await expect( + page.locator('.terminal-pane.active .xterm-helper-textarea') + ).toBeFocused(); + await expect.poll(() => page.evaluate(() => ( + window.__directConnectEvents.filter(entry => entry.event === 'ssh_connect_cancel') + ))).toEqual([{ + event: 'ssh_connect_cancel', + payload: { client_request_id: requestId }, + }]); + expect(await page.evaluate(() => ({ + active: SessionManager.getActiveSession(), + workspace: SessionManager.getWorkspaceSession(), + connected: SessionManager.getSession('workspace-linux')?.connected, + disconnects: window.__workspaceEvents.filter( + entry => entry.event === 'ssh_disconnect' + ).length, + }))).toEqual({ + active: 'workspace-linux', + workspace: 'workspace-linux', + connected: true, + disconnects: 0, + }); + await assertNoExternalRequests(page); +}); + +test('pending connection cancellation returns focus to an empty pane launcher', async ({ page }) => { + await login(page); + + await page.evaluate(() => { + ProfileManager.keys = [{ id: 'empty-pane-key', usable: true }]; + ProfileManager.profilesLoaded = true; + ProfileManager.profiles = [{ + id: 'empty-pane-profile', + name: 'Empty pane host', + host: 'empty.example', + port: 22, + username: 'deploy', + auth_type: 'key', + key_id: 'empty-pane-key', + startup_mode: 'none', + }]; + const previousEmit = window.socket.emit.bind(window.socket); + window.__emptyPaneConnectEvents = []; + window.socket.emit = function holdEmptyPaneConnection(event, payload, ...rest) { + if (event === 'ssh_connect' || event === 'ssh_connect_cancel') { + window.__emptyPaneConnectEvents.push({ + event, + payload: structuredClone(payload), + }); + if (event === 'ssh_connect_cancel') { + queueMicrotask(() => rest[0]?.({ + success: true, + cancelled: true, + })); + } + return window.socket; + } + return previousEmit(event, payload, ...rest); + }; + SessionManager.renderPane(SessionManager.getActivePaneIndex()); + }); + + await page.locator('[data-profile-id="empty-pane-profile"]').click(); + await expect.poll(() => page.evaluate(() => ( + window.__emptyPaneConnectEvents.find(entry => entry.event === 'ssh_connect') + ?.payload.client_request_id + ))).toBeTruthy(); + const requestId = await page.evaluate(() => ( + window.__emptyPaneConnectEvents.find(entry => entry.event === 'ssh_connect') + .payload.client_request_id + )); + const cancelButton = page.locator( + `[data-pending-id="${requestId}"] [aria-label="Cancel connection"]` + ); + await cancelButton.focus(); + await cancelButton.press('Enter'); + + await expect(page.locator(`[data-pending-id="${requestId}"]`)).toHaveCount(0); + await expect( + page.locator('.terminal-pane.active .profile-launcher-search') + ).toBeFocused(); + await expect.poll(() => page.evaluate(expectedRequestId => ( + window.__emptyPaneConnectEvents.some( + entry => entry.event === 'ssh_connect_cancel' + && entry.payload.client_request_id === expectedRequestId + ) + ), requestId)).toBe(true); + await assertNoExternalRequests(page); +}); + +test('a late cancellation keeps and opens the already committed connection', async ({ page }) => { + await login(page); + + await page.evaluate(() => { + ProfileManager.keys = [{ id: 'committed-key', usable: true }]; + ProfileManager.profilesLoaded = true; + ProfileManager.profiles = [{ + id: 'committed-profile', + name: 'Committed host', + host: 'committed.example', + port: 22, + username: 'deploy', + auth_type: 'key', + key_id: 'committed-key', + startup_mode: 'none', + }]; + const previousEmit = window.socket.emit.bind(window.socket); + window.__committedConnectEvents = []; + window.socket.emit = function holdCommittedConnection(event, payload, ...rest) { + if (event === 'ssh_connect') { + window.__committedConnectEvents.push({ + event, + payload: structuredClone(payload), + }); + return window.socket; + } + if (event === 'ssh_connect_cancel') { + window.__committedConnectEvents.push({ + event, + payload: structuredClone(payload), + }); + queueMicrotask(() => rest[0]?.({ + success: false, + cancelled: false, + reason: 'already_committed', + })); + return window.socket; + } + if (event === 'ssh_discard_late_connection') { + window.__committedConnectEvents.push({ + event, + payload: structuredClone(payload), + }); + return window.socket; + } + return previousEmit(event, payload, ...rest); + }; + SessionManager.renderPane(SessionManager.getActivePaneIndex()); + }); + + await page.locator('[data-profile-id="committed-profile"]').click(); + await expect.poll(() => page.evaluate(() => ( + window.__committedConnectEvents.find(entry => entry.event === 'ssh_connect') + ?.payload.client_request_id + ))).toBeTruthy(); + const requestId = await page.evaluate(() => ( + window.__committedConnectEvents.find(entry => entry.event === 'ssh_connect') + .payload.client_request_id + )); + const pendingTab = page.locator(`[data-pending-id="${requestId}"]`).first(); + await pendingTab.getByRole('button', { name: 'Cancel connection' }).click(); + + await expect(pendingTab).toBeVisible(); + await expect(page.locator('.notification')).toContainText( + 'can no longer be cancelled safely', + ); + + await page.evaluate(activeRequestId => { + window.socket.listeners('ssh_connected').forEach(listener => listener({ + session_id: 'committed-session', + host: 'committed.example', + port: 22, + username: 'deploy', + client_request_id: activeRequestId, + })); + }, requestId); + + await expect(pendingTab).toHaveCount(0); + await expect.poll(() => page.evaluate(() => SessionManager.getActiveSession())) + .toBe('committed-session'); + expect(await page.evaluate(() => window.__committedConnectEvents.filter( + entry => entry.event === 'ssh_discard_late_connection' + ))).toEqual([]); + await assertNoExternalRequests(page); +}); + +test('a completed connection explains a cancellation acknowledgement that arrives later', async ({ page }) => { + await login(page); + + await page.evaluate(() => { + ProfileManager.keys = [{ id: 'completion-race-key', usable: true }]; + ProfileManager.profilesLoaded = true; + ProfileManager.profiles = [{ + id: 'completion-race-profile', + name: 'Completion race host', + host: 'completion-race.example', + port: 22, + username: 'deploy', + auth_type: 'key', + key_id: 'completion-race-key', + startup_mode: 'none', + }]; + const previousEmit = window.socket.emit.bind(window.socket); + window.__completionRaceEvents = []; + window.__completionRaceCancelAcknowledgement = null; + window.socket.emit = function holdCompletionRace(event, payload, ...rest) { + if ( + event === 'ssh_connect' + || event === 'ssh_connect_cancel' + || event === 'ssh_discard_late_connection' + ) { + window.__completionRaceEvents.push({ + event, + payload: structuredClone(payload), + }); + if (event === 'ssh_connect_cancel') { + window.__completionRaceCancelAcknowledgement = rest[0]; + } + return window.socket; + } + return previousEmit(event, payload, ...rest); + }; + SessionManager.renderPane(SessionManager.getActivePaneIndex()); + }); + + await page.locator('[data-profile-id="completion-race-profile"]').click(); + await expect.poll(() => page.evaluate(() => ( + window.__completionRaceEvents.find(entry => entry.event === 'ssh_connect') + ?.payload.client_request_id + ))).toBeTruthy(); + const requestId = await page.evaluate(() => ( + window.__completionRaceEvents.find(entry => entry.event === 'ssh_connect') + .payload.client_request_id + )); + const pendingTab = page.locator(`[data-pending-id="${requestId}"]`).first(); + await pendingTab.getByRole('button', { name: 'Cancel connection' }).click(); + await expect.poll(() => page.evaluate(activeRequestId => ({ + cancelRequests: window.__completionRaceEvents.filter(entry => ( + entry.event === 'ssh_connect_cancel' + && entry.payload.client_request_id === activeRequestId + )).length, + hasAcknowledgement: ( + typeof window.__completionRaceCancelAcknowledgement === 'function' + ), + }), requestId)).toEqual({ + cancelRequests: 1, + hasAcknowledgement: true, + }); + + await page.evaluate(activeRequestId => { + window.socket.listeners('ssh_connected').forEach(listener => listener({ + session_id: 'completion-race-session', + host: 'completion-race.example', + port: 22, + username: 'deploy', + client_request_id: activeRequestId, + })); + }, requestId); + + await expect(pendingTab).toHaveCount(0); + await expect.poll(() => page.evaluate(() => SessionManager.getActiveSession())) + .toBe('completion-race-session'); + + await page.evaluate(() => { + const acknowledge = window.__completionRaceCancelAcknowledgement; + window.__completionRaceCancelAcknowledgement = null; + acknowledge({ + success: false, + cancelled: false, + reason: 'not_found', + }); + }); + + await expect(page.getByText( + 'Cancellation was too late. The connection had already opened and remains active.', + { exact: true }, + )).toBeVisible(); + expect(await page.evaluate(() => ({ + active: SessionManager.getActiveSession(), + connected: SessionManager.getSession('completion-race-session')?.connected, + discards: window.__completionRaceEvents.filter( + entry => entry.event === 'ssh_discard_late_connection' + ), + }))).toEqual({ + active: 'completion-race-session', + connected: true, + discards: [], + }); + await assertNoExternalRequests(page); +}); + +test('a lost cancellation acknowledgement stays retryable and clears a finished request safely', async ({ page }) => { + await login(page); + await seedLinuxSession(page); + + await page.evaluate(() => { + const previousEmit = window.socket.emit.bind(window.socket); + window.__cancelRecoveryEvents = []; + window.__cancelRecoveryResponse = 'drop'; + window.socket.emit = function holdCancellation(event, payload, ...rest) { + if ( + event === 'ssh_connect' + || event === 'ssh_connect_cancel' + || event === 'ssh_discard_late_connection' + ) { + window.__cancelRecoveryEvents.push({ + event, + payload: structuredClone(payload), + }); + if ( + event === 'ssh_connect_cancel' + && window.__cancelRecoveryResponse === 'not_found' + ) { + queueMicrotask(() => rest[0]?.({ + success: false, + cancelled: false, + reason: 'not_found', + })); + } + return window.socket; + } + return previousEmit(event, payload, ...rest); + }; + }); + + await page.locator('#newTabBtn').click(); + await page.locator('.terminal-pane.active .profile-launcher-new').click(); + await page.locator('#hostInput').fill('cancel-recovery.example'); + await page.locator('#usernameInput').fill('deploy'); + await page.locator('#passwordInput').fill('replacement-password'); + await page.locator('#connectBtn').click(); + await expect.poll(() => page.evaluate(() => ( + window.__cancelRecoveryEvents.find(entry => entry.event === 'ssh_connect') + ?.payload.client_request_id + ))).toBeTruthy(); + const requestId = await page.evaluate(() => ( + window.__cancelRecoveryEvents.find(entry => entry.event === 'ssh_connect') + .payload.client_request_id + )); + const pendingTab = page.locator(`[data-pending-id="${requestId}"]`).first(); + const pendingCancel = pendingTab.getByRole('button', { name: 'Cancel connection' }); + + await page.locator('#cancelConnectionBtn').click(); + await expect(page.locator('#connectionModal')).not.toHaveClass(/show/); + await expect.poll(() => page.evaluate(() => SessionManager.getActiveSession())) + .toBe('workspace-linux'); + await expect( + page.locator('.terminal-pane.active .xterm-helper-textarea') + ).toBeFocused(); + await expect(pendingCancel).toHaveAttribute('aria-busy', 'true'); + + await expect(page.getByText( + 'Cancellation was not confirmed. The attempt is still pending; try cancelling again.', + { exact: true }, + )).toBeVisible({ timeout: 8000 }); + await expect(pendingCancel).toHaveAttribute('aria-busy', 'false'); + + await page.evaluate(() => { + window.__cancelRecoveryResponse = 'not_found'; + }); + await pendingCancel.click(); + await expect(pendingTab).toHaveCount(0); + await expect(page.getByText( + 'The connection attempt is no longer active.', + { exact: true }, + )).toBeVisible(); + + await page.evaluate(activeRequestId => { + window.socket.listeners('ssh_connected').forEach(listener => listener({ + session_id: 'cancel-recovery-late-session', + host: 'cancel-recovery.example', + port: 22, + username: 'deploy', + client_request_id: activeRequestId, + })); + }, requestId); + await expect.poll(() => page.evaluate(activeRequestId => ( + window.__cancelRecoveryEvents.some(entry => ( + entry.event === 'ssh_discard_late_connection' + && entry.payload.client_request_id === activeRequestId + && entry.payload.session_id === 'cancel-recovery-late-session' + )) + ), requestId)).toBe(true); + await expect.poll(() => page.evaluate(() => SessionManager.getActiveSession())) + .toBe('workspace-linux'); + + await page.locator('#newTabBtn').click(); + await expect(page.locator('.terminal-pane.active .profile-launcher-new')).toBeVisible(); + await assertNoExternalRequests(page); +}); + +test('a failed late replacement leaves the preserved terminal active', async ({ page }) => { + await login(page); + await seedLinuxSession(page); + + await page.evaluate(() => { + const previousEmit = window.socket.emit.bind(window.socket); + window.__lateReplacementEvents = []; + window.socket.emit = function holdLateReplacement(event, payload, ...rest) { + if (event === 'ssh_connect' || event === 'ssh_connect_cancel') { + window.__lateReplacementEvents.push({ + event, + payload: structuredClone(payload), + }); + if (event === 'ssh_connect_cancel') { + queueMicrotask(() => rest[0]?.({ + success: false, + cancelled: false, + reason: 'already_committed', + })); + } + return window.socket; + } + return previousEmit(event, payload, ...rest); + }; + }); + + await page.locator('#newTabBtn').click(); + await page.locator('.terminal-pane.active .profile-launcher-new').click(); + await page.locator('#hostInput').fill('late-replacement.example'); + await page.locator('#usernameInput').fill('deploy'); + await page.locator('#passwordInput').fill('replacement-password'); + await page.locator('#connectBtn').click(); + await expect.poll(() => page.evaluate(() => ( + window.__lateReplacementEvents.find(entry => entry.event === 'ssh_connect') + ?.payload.client_request_id + ))).toBeTruthy(); + const requestId = await page.evaluate(() => ( + window.__lateReplacementEvents.find(entry => entry.event === 'ssh_connect') + .payload.client_request_id + )); + + await page.locator('#cancelConnectionBtn').click(); + await expect(page.locator('#connectionModal')).not.toHaveClass(/show/); + await expect.poll(() => page.evaluate(() => SessionManager.getActiveSession())) + .toBe('workspace-linux'); + await expect( + page.locator('.terminal-pane.active .xterm-helper-textarea') + ).toBeFocused(); + await expect(page.locator('.notification')).toContainText( + 'can no longer be cancelled safely', + ); + + await page.evaluate(activeRequestId => { + window.socket.listeners('ssh_error').forEach(listener => listener({ + error: 'Late replacement failed', + client_request_id: activeRequestId, + })); + }, requestId); + await expect(page.locator(`[data-pending-id="${requestId}"]`)).toHaveCount(0); + await expect.poll(() => page.evaluate(() => SessionManager.getActiveSession())) + .toBe('workspace-linux'); + await expect( + page.locator('.terminal-pane.active .xterm-helper-textarea') + ).toBeFocused(); + expect(await page.evaluate(() => window.__workspaceEvents.filter( + entry => entry.event === 'ssh_disconnect' + && entry.payload?.session_id === 'workspace-linux' + ))).toEqual([]); + await assertNoExternalRequests(page); +}); + +test('failed and cancelled replacements preserve the current session until a successful replacement', async ({ page }) => { + await login(page); + await seedLinuxSession(page); + await expect(page.locator('#sessionFilesPanel #fmLeftPath')).toHaveValue( + '/srv/webssh/current', + ); + + await page.evaluate(() => { + const previousEmit = window.socket.emit.bind(window.socket); + window.__heldConnectAttempts = []; + window.socket.emit = function holdReplacementConnections(event, payload, ...rest) { + if (event === 'ssh_connect') { + window.__heldConnectAttempts.push(structuredClone(payload)); + return window.socket; + } + if (event === 'ssh_connect_cancel') { + window.__workspaceEvents.push({ + event, + payload: structuredClone(payload), + }); + queueMicrotask(() => rest[0]?.({ + success: true, + cancelled: true, + })); + return window.socket; + } + if (payload?.session_id === 'replacement-session' + || payload?.source_id === 'sftp-session:replacement-session') { + window.__workspaceEvents.push({ + event, + payload: structuredClone(payload), + }); + const deliver = (responseEvent, responsePayload) => queueMicrotask(() => { + window.socket.listeners(responseEvent).forEach( + listener => listener(responsePayload), + ); + }); + if (event === 'probe_session_sftp') { + deliver('session_sftp_capability', { + success: true, + available: true, + session_id: payload.session_id, + request_id: payload.request_id, + }); + } else if (event === 'get_home_directory') { + deliver('home_directory', { + source_id: payload.source_id, + path: '/home/replacement-user', + request_id: payload.request_id, + }); + } else if (event === 'list_directory') { + deliver('directory_listing', { + source_id: payload.source_id, + path: payload.remote_path, + files: [{ + name: 'replacement-ready.txt', + is_dir: false, + size: 24, + permissions: '-rw-r--r--', + }], + request_id: payload.request_id, + }); + } + return window.socket; + } + if (event === 'ssh_disconnect' && payload?.session_id === 'late-session') { + window.__workspaceEvents.push({ + event, + payload: structuredClone(payload), + }); + return window.socket; + } + return previousEmit(event, payload, ...rest); + }; + }); + + await page.locator('#newTabBtn').click(); + await page.locator('.terminal-pane.active .profile-launcher-new').click(); + await page.locator('#hostInput').fill('replacement.example'); + await page.locator('#usernameInput').fill('replacement-user'); + await page.locator('#passwordInput').fill('replacement-password'); + await page.locator('#connectBtn').click(); + + await expect.poll(() => page.evaluate(() => window.__heldConnectAttempts.length)) + .toBe(1); + const failedRequestId = await page.evaluate( + () => window.__heldConnectAttempts[0].client_request_id + ); + await page.evaluate(requestId => { + window.socket.listeners('ssh_error').forEach(listener => listener({ + error: 'Replacement connection failed', + client_request_id: requestId, + })); + }, failedRequestId); + + await expect(page.locator('.notification')).toContainText( + 'Replacement connection failed', + ); + await expect(page.locator('#connectionModal')).toHaveClass(/show/); + expect(await page.evaluate(() => ({ + active: SessionManager.getActiveSession(), + workspace: SessionManager.getWorkspaceSession(), + assignment: SessionManager.paneAssignments[0], + replacementExists: Boolean(SessionManager.getSession('late-session')), + }))).toEqual({ + active: null, + workspace: 'workspace-linux', + assignment: 'workspace-linux', + replacementExists: false, + }); + await expect(page.locator('#sessionFilesPanel #fmLeftPath')).toHaveValue( + '/srv/webssh/current', + ); + + await page.locator('#cancelConnectionBtn').click(); + await expect(page.locator('#connectionModal')).not.toHaveClass(/show/); + await expect.poll(() => page.evaluate(() => SessionManager.getActiveSession())) + .toBe('workspace-linux'); + + await page.locator('#newTabBtn').click(); + await page.locator('.terminal-pane.active .profile-launcher-new').click(); + await page.locator('#passwordInput').fill('replacement-password'); + await page.locator('#connectBtn').click(); + await expect.poll(() => page.evaluate(() => window.__heldConnectAttempts.length)) + .toBe(2); + const cancelledRequestId = await page.evaluate( + () => window.__heldConnectAttempts[1].client_request_id + ); + await page.locator('#cancelConnectionBtn').click(); + await expect(page.locator('#connectionModal')).not.toHaveClass(/show/); + await expect.poll(() => page.evaluate(() => SessionManager.getActiveSession())) + .toBe('workspace-linux'); + await expect.poll(() => page.evaluate(requestId => ( + window.__workspaceEvents + .filter(entry => entry.event === 'ssh_connect_cancel') + .map(entry => entry.payload?.client_request_id) + ), cancelledRequestId)).toEqual([cancelledRequestId]); + + await page.locator('#newTabBtn').click(); + await page.locator('.terminal-pane.active .profile-launcher-new').click(); + await page.locator('#hostInput').fill('replacement.example'); + await page.locator('#usernameInput').fill('replacement-user'); + await page.locator('#passwordInput').fill('replacement-password'); + await page.locator('#connectBtn').click(); + await expect.poll(() => page.evaluate(() => window.__heldConnectAttempts.length)) + .toBe(3); + const lateSuccessRequestId = await page.evaluate( + () => window.__heldConnectAttempts[2].client_request_id + ); + await page.evaluate(requestId => { + window.socket.listeners('ssh_auth_banner').forEach(listener => listener({ + prompt_id: 'cancelled-banner-prompt', + banner: 'This cancelled request must stay hidden', + context: 'target', + host: 'replacement.example', + port: 22, + client_request_id: requestId, + })); + }, cancelledRequestId); + await expect(page.locator('#sshAuthBannerModal')).not.toHaveClass(/show/); + await page.evaluate(requestId => { + window.socket.listeners('ssh_auth_banner').forEach(listener => listener({ + prompt_id: 'current-banner-prompt', + banner: 'Current connection banner', + context: 'target', + host: 'replacement.example', + port: 22, + client_request_id: requestId, + })); + }, lateSuccessRequestId); + await expect(page.locator('#sshAuthBannerModal')).toHaveClass(/show/); + await page.evaluate(requestId => { + window.socket.listeners('ssh_error').forEach(listener => listener({ + error: 'Cancelled request finished late', + client_request_id: requestId, + })); + }, cancelledRequestId); + await expect(page.locator('#sshAuthBannerModal')).toHaveClass(/show/); + await page.evaluate(() => { + window.socket.listeners('ssh_error').forEach(listener => listener({ + error: 'Unrelated session input error', + session_id: 'workspace-linux', + })); + }); + await expect(page.locator('#sshAuthBannerModal')).toHaveClass(/show/); + await expect(page.locator('#connectBtn')).toBeDisabled(); + await page.locator('#sshAuthBannerCancel').click(); + + await page.locator('#cancelConnectionBtn').click(); + await expect(page.locator('#connectBtn')).toHaveText('Connect'); + await expect(page.locator('#connectBtn')).toBeEnabled(); + await expect(page.locator('#connectSpinner')).toHaveClass(/hidden/); + await page.evaluate(requestId => { + window.socket.listeners('ssh_connected').forEach(listener => listener({ + session_id: 'late-session', + host: 'replacement.example', + port: 22, + username: 'replacement-user', + client_request_id: requestId, + })); + }, lateSuccessRequestId); + + await expect(page.locator('#connectionModal')).not.toHaveClass(/show/); + await expect.poll(() => page.evaluate(requestId => ( + window.__workspaceEvents.some( + entry => entry.event === 'ssh_discard_late_connection' + && entry.payload.session_id === 'late-session' + && entry.payload.client_request_id === requestId + ) + ), lateSuccessRequestId)).toBe(true); + expect(await page.evaluate(() => ({ + active: SessionManager.getActiveSession(), + assignment: SessionManager.paneAssignments[0], + replacementExists: Boolean(SessionManager.getSession('late-session')), + }))).toEqual({ + active: 'workspace-linux', + assignment: 'workspace-linux', + replacementExists: false, + }); + await expect(page.locator('#sessionFilesPanel #fmLeftPath')).toHaveValue( + '/srv/webssh/current', + ); + + await page.locator('#newTabBtn').click(); + await page.locator('.terminal-pane.active .profile-launcher-new').click(); + await page.locator('#hostInput').fill('replacement.example'); + await page.locator('#usernameInput').fill('replacement-user'); + await page.locator('#passwordInput').fill('replacement-password'); + await page.locator('#connectBtn').click(); + await expect.poll(() => page.evaluate(() => window.__heldConnectAttempts.length)) + .toBe(4); + const successfulRequestId = await page.evaluate( + () => window.__heldConnectAttempts[3].client_request_id + ); + + expect(await page.evaluate(() => ({ + active: SessionManager.getActiveSession(), + workspace: SessionManager.getWorkspaceSession(), + assignment: SessionManager.paneAssignments[0], + currentConnected: SessionManager.getSession('workspace-linux')?.connected, + replacementExists: Boolean(SessionManager.getSession('replacement-session')), + }))).toEqual({ + active: null, + workspace: 'workspace-linux', + assignment: 'workspace-linux', + currentConnected: true, + replacementExists: false, + }); + + await page.evaluate(requestId => { + window.socket.listeners('ssh_connected').forEach(listener => listener({ + session_id: 'replacement-session', + host: 'replacement.example', + port: 22, + username: 'replacement-user', + display_name: 'Replacement host', + client_request_id: requestId, + file_source: { + source_id: 'sftp-session:replacement-session', + kind: 'sftp', + label: 'Replacement host', + endpoint: 'replacement.example:22', + protocol: 'SFTP', + capabilities: [ + 'list', 'read', 'write', 'mkdir', 'rename', 'delete', + 'preview', 'edit', 'recursive', 'remote-transfer', + ], + ephemeral: false, + security: { host_key_verified: true }, + }, + })); + }, successfulRequestId); + + await expect(page.locator('#connectionModal')).not.toHaveClass(/show/); + await expect.poll(() => page.evaluate(() => ({ + active: SessionManager.getActiveSession(), + workspace: SessionManager.getWorkspaceSession(), + assignment: SessionManager.paneAssignments[0], + currentConnected: SessionManager.getSession('workspace-linux')?.connected, + replacementConnected: SessionManager.getSession('replacement-session')?.connected, + filesSession: window.sessionWorkspace?.getState?.().sessionId, + }))).toEqual({ + active: 'replacement-session', + workspace: 'replacement-session', + assignment: 'replacement-session', + currentConnected: true, + replacementConnected: true, + filesSession: 'replacement-session', + }); + await expect(page.locator('#tab-workspace-linux')).toBeVisible(); + await expect(page.locator('#tab-workspace-linux')).not.toHaveClass(/active/); + await expect(page.locator('#tab-replacement-session')).toHaveClass(/active/); + await expect(page.locator('#sessionFilesPanel #fmLeftBadge')).toContainText( + 'replacement-user@replacement.example', + ); + await expect(page.locator('#sessionFilesPanel #fmLeftPath')).toHaveValue( + '/home/replacement-user', + ); + await expect(page.locator('#sessionFilesPanel #fmLeftList .fm-file-item', { + hasText: 'replacement-ready.txt', + })).toHaveCount(1); + expect(await page.evaluate(() => window.__workspaceEvents.filter( + entry => entry.event === 'ssh_disconnect' + && entry.payload?.session_id === 'workspace-linux' + ))).toEqual([]); + await assertNoExternalRequests(page); +}); + test('closing the full File Manager restores the active embedded Files context', async ({ page }) => { await login(page); await seedLinuxSession(page); @@ -1227,6 +2208,74 @@ test('Escape closes the source launcher but top-level navigation restores embedd await assertNoExternalRequests(page); }); +test('compact Escape closes the Files menu before the Files context', async ({ page }) => { + await page.setViewportSize({ width: 800, height: 900 }); + await login(page); + await seedLinuxSession(page); + + await expect(page.locator('#contextWorkspaceLauncher')).toBeVisible(); + await page.locator('#contextWorkspaceLauncher').click(); + const filesPanel = page.locator('#sessionFilesPanel'); + const selectedFile = filesPanel.locator( + '#fmLeftList .fm-file-item[data-index="1"]' + ); + await expect(filesPanel).toBeVisible(); + await expect(filesPanel.locator('#fmLeftPath')).toHaveValue('/srv/webssh/current'); + await selectedFile.click(); + await expect(selectedFile).toHaveClass(/selected/); + + await selectedFile.click({ button: 'right' }); + await expect(page.locator('.fm-context-menu')).toBeVisible(); + await page.keyboard.press('Escape'); + + await expect(page.locator('.fm-context-menu')).toHaveCount(0); + await expect(filesPanel).toBeVisible(); + await expect(filesPanel.locator('#fmLeftPath')).toHaveValue('/srv/webssh/current'); + await expect(selectedFile).toHaveClass(/selected/); + + await page.keyboard.press('Escape'); + await expect(page.locator('#contextWorkspace')).toBeHidden(); + await expect(filesPanel).toBeHidden(); + await assertNoExternalRequests(page); +}); + +test('Escape respects focused controls outside embedded Files', async ({ page }) => { + await login(page); + await seedLinuxSession(page); + + const filesPanel = page.locator('#sessionFilesPanel'); + const selectedFile = filesPanel.locator( + '#fmLeftList .fm-file-item[data-index="1"]' + ); + await selectedFile.click(); + await expect(selectedFile).toHaveClass(/selected/); + + await page.locator('#broadcastToggleBtn').click(); + await expect(page.locator('#broadcastBar')).toBeVisible(); + await expect(page.locator('#broadcastInput')).toBeFocused(); + await page.keyboard.press('Escape'); + await expect(page.locator('#broadcastBar')).toBeHidden(); + await expect(filesPanel).toBeVisible(); + await expect(filesPanel.locator('#fmLeftPath')).toHaveValue( + '/srv/webssh/current', + ); + await expect(selectedFile).toHaveClass(/selected/); + + const sessionLabel = page.locator('#tab-workspace-linux .tab-label'); + await sessionLabel.dblclick(); + const renameInput = sessionLabel.locator('.tab-rename-input'); + await renameInput.fill('Discard this rename'); + await page.keyboard.press('Escape'); + await expect(renameInput).toHaveCount(0); + await expect(sessionLabel).toContainText('Production Edge'); + await expect(filesPanel).toBeVisible(); + await expect(filesPanel.locator('#fmLeftPath')).toHaveValue( + '/srv/webssh/current', + ); + await expect(selectedFile).toHaveClass(/selected/); + await assertNoExternalRequests(page); +}); + test('partial telemetry shows only metrics returned by the device', async ({ page }) => { await login(page); await seedLinuxSession(page, { partialMetrics: true, sftpAvailable: false }); diff --git a/tests/js/mobile-app-shell.test.js b/tests/js/mobile-app-shell.test.js index f502e81..c0978fb 100644 --- a/tests/js/mobile-app-shell.test.js +++ b/tests/js/mobile-app-shell.test.js @@ -128,6 +128,7 @@ function fixture() { }; const sessionManager = { getActiveSession() { return 'active'; }, + getWorkspaceSession() { return 'active'; }, getSession(id) { return id === 'active' ? session : null; }, getDisplayLabel() { return 'Production'; }, }; @@ -259,6 +260,29 @@ test('session tool dock availability follows the active session context', () => assert.equal(files.getAttribute('aria-disabled'), 'false'); }); +test('launcher keeps Files context but disables hidden-terminal command input', () => { + const {createController} = require('../../static/js/mobile-app-shell.js'); + const state = fixture(); + state.sessionManager.getActiveSession = () => null; + const controller = createController({ + window: state.windowRef, + document: state.documentRef, + sessionManager: state.sessionManager, + }); + controller.init(); + + const files = state.views.find( + button => button.dataset.mobileView === 'session-files', + ); + const commands = state.views.find( + button => button.dataset.mobileView === 'session-commands', + ); + assert.equal(state.elements.mobileSessionSummaryLabel.textContent, 'Production'); + assert.equal(state.elements.mobileCommandToggle.disabled, true); + assert.equal(files.disabled, false); + assert.equal(commands.disabled, true); +}); + test('short coarse-pointer landscape keeps the phone shell above 767px', () => { const {createController} = require('../../static/js/mobile-app-shell.js'); const state = fixture(); diff --git a/tests/js/session-command-launcher.test.js b/tests/js/session-command-launcher.test.js index 96d4857..554ee35 100644 --- a/tests/js/session-command-launcher.test.js +++ b/tests/js/session-command-launcher.test.js @@ -313,13 +313,16 @@ test('mounts with the real top-level const manager pattern', () => { }); context.window = context; vm.runInContext(` + let launcherOpen = false; const SessionManager = { paneAssignments: ['real-session'], getActivePaneIndex: () => 0, + getActiveSession: () => launcherOpen ? null : 'real-session', getSession: id => id === 'real-session' ? { connected: true, displayName: 'Production Edge' } : null, }; + window.setLauncherOpen = open => { launcherOpen = open; }; const CommandLibrary = { commands: [{ id: 'status', @@ -384,6 +387,15 @@ test('mounts with the real top-level const manager pattern', () => { context.socket.emissions[0][1].data, 'sudo systemctl status webssh' ); + + context.setLauncherOpen(true); + context.SessionCommandLauncher.sync(); + context.SessionCommandLauncher.render(); + const blockedInsert = context.SessionCommandLauncher.popup.findByText('Insert'); + assert.equal(blockedInsert.disabled, true); + blockedInsert.listeners.click(); + assert.equal(context.socket.emissions.length, 1); + assert.equal(panel.hidden, false); assert.ok(context.SessionCommandLauncher.popup); diff --git a/tests/js/session-insights.test.js b/tests/js/session-insights.test.js index bf055cd..280285d 100644 --- a/tests/js/session-insights.test.js +++ b/tests/js/session-insights.test.js @@ -411,6 +411,30 @@ test('backs off regular polling after three transient failures', () => { ); }); +test('backs off immediately but retries remote channel resource shortage', () => { + const runtime = fakeRuntime(); + runtime.controller.setSession('capacity-limited', true); + const request = runtime.emitted.at(-1).payload; + + runtime.handlers.get('session_insights')({ + success: false, + reason: 'resource_shortage', + session_id: 'capacity-limited', + request_id: request.request_id, + }); + + assert.equal(runtime.intervals.size, 0); + assert.equal(runtime.renders.at(-1).status, 'unavailable'); + const retry = [...runtime.timeouts.values()].find( + timer => timer.delay === insights.BACKOFF_RETRY_MS, + ); + assert.ok(retry); + + retry.callback(); + assert.equal(runtime.emitted.length, 2); + assert.equal(runtime.intervals.size, 1); +}); + test('keeps the newest 150 structured samples per session', () => { let now = 0; diff --git a/tests/js/session-manager-close.test.js b/tests/js/session-manager-close.test.js index f53006a..193e7bb 100644 --- a/tests/js/session-manager-close.test.js +++ b/tests/js/session-manager-close.test.js @@ -12,6 +12,7 @@ function createElement(tagName = 'div') { children: [], parentNode: null, dataset: {}, + attributes: {}, hidden: false, textContent: '', className: '', @@ -38,7 +39,20 @@ function createElement(tagName = 'div') { }, remove() { this.parentNode?.removeChild?.(this); }, addEventListener(type, handler) { listeners.set(type, handler); }, - click() { listeners.get('click')?.({ target: this }); }, + click() { + listeners.get('click')?.({ + target: this, + stopPropagation() {}, + }); + }, + pressKey(key) { + if (this.tagName === 'BUTTON' && ['Enter', ' '].includes(key)) { + this.click(); + } + }, + setAttribute(name, value) { + this.attributes[name] = String(value); + }, querySelector(selector) { return this.querySelectorAll(selector)[0] || null; }, @@ -89,7 +103,22 @@ function loadSessionManager( document: { body, createElement, - getElementById(id) { return elements.get(id) || null; }, + getElementById(id) { + if (elements.has(id)) return elements.get(id); + const findById = node => { + if (node.id === id) return node; + for (const child of node.children || []) { + const match = findById(child); + if (match) return match; + } + return null; + }; + for (const root of elements.values()) { + const match = findById(root); + if (match) return match; + } + return null; + }, }, TerminalManager: { destroyTerminal() {}, @@ -115,6 +144,7 @@ function loadSessionManager( }, }; context.window.window = context.window; + context.window.CustomEvent = context.CustomEvent; vm.createContext(context); vm.runInContext(`${source}\n;globalThis.__SessionManager = SessionManager;`, context); return { @@ -195,6 +225,198 @@ test('explicit logout retains namespaced convenience data', () => { ); }); +test('connection launcher stages replacement without unassigning the live session', () => { + const { + manager, context, createElement, registerElement, + } = loadSessionManager(false, 'retry'); + const terminalsContainer = registerElement('terminalsContainer', createElement()); + const pane = createElement(); + const terminal = registerElement('terminal-session-a', createElement()); + pane.appendChild(terminal); + manager.sessions = { + 'session-a': { + id: 'session-a', + terminalId: 'terminal-session-a', + }, + }; + manager.paneAssignments = ['session-a']; + const rendered = []; + const activated = []; + let searchClosed = 0; + context.window.TerminalSearch = { + isOpen: true, + close() { searchClosed += 1; }, + }; + manager.renderPane = paneIndex => rendered.push(paneIndex); + manager.setActivePane = paneIndex => activated.push(paneIndex); + + assert.equal(manager.showConnectionLauncher(0), true); + + assert.deepEqual(manager.paneAssignments, ['session-a']); + assert.equal(manager.connectionLauncherSessions.get(0), 'session-a'); + assert.equal(terminal.parentNode, terminalsContainer); + assert.equal(terminal.classList.contains('unassigned'), true); + assert.equal(manager.sessions['session-a'].id, 'session-a'); + assert.equal(searchClosed, 1); + assert.deepEqual(rendered, [0]); + assert.deepEqual(activated, [0]); + + assert.equal(manager.restoreConnectionLauncher(0), true); + assert.equal(manager.connectionLauncherSessions.has(0), false); + assert.deepEqual(manager.paneAssignments, ['session-a']); + assert.deepEqual(rendered, [0, 0]); + assert.deepEqual(activated, [0, 0]); +}); + +test('connection launcher rejects a pane outside the current layout', () => { + const {manager} = loadSessionManager(false, 'retry'); + manager.paneAssignments = [null]; + + assert.equal(manager.showConnectionLauncher(1), false); + assert.deepEqual(manager.paneAssignments, [null]); +}); + +test('pending close requests cancellation and waits for acknowledgement', () => { + const { + manager, context, registerElement, + } = loadSessionManager(false, 'retry'); + const sessionTabs = registerElement('sessionTabs', createElement()); + const cancellationRequests = []; + context.window.addEventListener('ssh-connection-cancel-requested', event => { + cancellationRequests.push(event.detail.requestId); + }); + + manager.createPendingConnection('request-a', 'a.example', 'alice', 22); + manager.createPendingConnection('request-b', 'b.example', 'bob', 2222); + + const requestATab = sessionTabs.children.find( + child => child.dataset.pendingId === 'request-a' + ); + const requestAClose = requestATab.querySelector('.tab-close'); + assert.equal(requestAClose.tagName, 'BUTTON'); + assert.equal(requestAClose.type, 'button'); + + requestAClose.click(); + + assert.deepEqual(cancellationRequests, ['request-a']); + assert.deepEqual( + JSON.parse(JSON.stringify(manager.pendingConnections['request-a'])), + { + host: 'a.example', + username: 'alice', + port: 22, + }, + ); + assert.deepEqual( + JSON.parse(JSON.stringify(manager.pendingConnections['request-b'])), + { + host: 'b.example', + username: 'bob', + port: 2222, + }, + ); + assert.equal( + sessionTabs.children.some(child => child.dataset.pendingId === 'request-a'), + true, + ); + assert.equal( + sessionTabs.children.some(child => child.dataset.pendingId === 'request-b'), + true, + ); + + manager.clearPendingConnection('request-a'); + assert.equal(manager.pendingConnections['request-a'], undefined); + assert.equal( + sessionTabs.children.some(child => child.dataset.pendingId === 'request-a'), + false, + ); +}); + +test('pending connection close supports native keyboard activation', () => { + const { + manager, context, registerElement, + } = loadSessionManager(false, 'retry'); + const sessionTabs = registerElement('sessionTabs', createElement()); + const cancellationRequests = []; + context.window.addEventListener('ssh-connection-cancel-requested', event => { + cancellationRequests.push(event.detail.requestId); + }); + + manager.createPendingConnection('request-enter', 'enter.example', 'alice', 22); + manager.createPendingConnection('request-space', 'space.example', 'bob', 22); + manager.createPendingConnection('request-other', 'other.example', 'carol', 22); + + const closeFor = requestId => sessionTabs.children.find( + child => child.dataset.pendingId === requestId + ).querySelector('.tab-close'); + closeFor('request-enter').pressKey('Enter'); + closeFor('request-space').pressKey(' '); + + assert.deepEqual(cancellationRequests, ['request-enter', 'request-space']); + assert.notEqual(manager.pendingConnections['request-enter'], undefined); + assert.notEqual(manager.pendingConnections['request-space'], undefined); + assert.deepEqual( + JSON.parse(JSON.stringify(manager.pendingConnections['request-other'])), + { + host: 'other.example', + username: 'carol', + port: 22, + }, + ); + assert.equal( + sessionTabs.children.some( + child => child.dataset.pendingId === 'request-other' + ), + true, + ); + + manager.clearPendingConnection('request-enter'); + manager.clearPendingConnection('request-space'); + assert.equal(manager.pendingConnections['request-enter'], undefined); + assert.equal(manager.pendingConnections['request-space'], undefined); +}); + +test('workspace notifications retain the staged session as the active context', () => { + const {manager, context} = loadSessionManager(false, 'retry'); + manager.sessions = {'session-a': {id: 'session-a'}}; + manager.paneAssignments = ['session-a']; + manager.activePaneIndex = 0; + manager.activeSessionId = 'session-a'; + manager.connectionLauncherSessions.set(0, 'session-a'); + let detail = null; + context.window.addEventListener('session-workspace-change', event => { + detail = event.detail; + }); + + manager.notifyWorkspaceChange(); + + assert.deepEqual(JSON.parse(JSON.stringify(detail)), { + layout: 1, + sessionId: 'session-a', + }); +}); + +test('successful assignment commits a staged pane replacement', () => { + const {manager} = loadSessionManager(false, 'retry'); + manager.sessions = { + 'session-a': {id: 'session-a', terminalId: 'terminal-session-a'}, + 'session-b': {id: 'session-b', terminalId: 'terminal-session-b'}, + }; + manager.paneAssignments = ['session-a']; + manager.connectionLauncherSessions.set(0, 'session-a'); + const rendered = []; + const activated = []; + manager.renderPane = paneIndex => rendered.push(paneIndex); + manager.setActivePane = paneIndex => activated.push(paneIndex); + + manager.assignSessionToPane('session-b', 0); + + assert.deepEqual(manager.paneAssignments, ['session-b']); + assert.equal(manager.connectionLauncherSessions.has(0), false); + assert.deepEqual(rendered, [0]); + assert.deepEqual(activated, [0]); +}); + function prepareSession(manager) { manager.sessions = { sessionA: { username: 'alice', host: 'example.test' }, diff --git a/tests/js/session-workspace.test.js b/tests/js/session-workspace.test.js index a8254d2..a77e55c 100644 --- a/tests/js/session-workspace.test.js +++ b/tests/js/session-workspace.test.js @@ -5,6 +5,9 @@ const { createCoordinator, createSftpCapabilityTracker, } = require('../../static/js/session-workspace.js'); +const { + createController: createFilesPanelController, +} = require('../../static/js/session-files-panel.js'); function createHarness(options = {}) { const calls = []; @@ -553,7 +556,120 @@ test('SFTP capability tracker retries a busy probe without opening the pane', () }]); }); -test('SFTP capability tracker cancels retries while the session is ineligible', () => { +test('SFTP capability tracker keeps remote resource shortage retryable', () => { + const handlers = {}; + const emitted = []; + const timers = new Map(); + let nextTimer = 1; + let nextRequest = 1; + const { coordinator, calls } = createHarness({ + isWideDesktop: () => true, + }); + const updateCoordinator = () => coordinator.update({ + layout: 1, + sessionId: 'capacity', + session: { host: 'capacity.example', connected: true }, + sessionCount: 1, + sftpCapability: tracker.get('capacity'), + }); + const tracker = createSftpCapabilityTracker({ + socket: { + on(event, handler) { handlers[event] = handler; }, + emit(event, payload) { emitted.push([event, payload]); }, + }, + onChange: updateCoordinator, + createRequestId: () => `probe-${nextRequest++}`, + setTimeoutFn(callback, delay) { + const id = nextTimer++; + timers.set(id, { callback, delay }); + return id; + }, + clearTimeoutFn(id) { timers.delete(id); }, + }); + + updateCoordinator(); + tracker.probeIfNeeded(coordinator.getState()); + for (let attempt = 1; attempt <= 3; attempt += 1) { + handlers.session_sftp_capability({ + success: false, + available: false, + reason: 'resource_shortage', + session_id: 'capacity', + request_id: `probe-${attempt}`, + }); + assert.equal(tracker.get('capacity'), 'resource_shortage'); + assert.equal(coordinator.getState().sftpCapability, 'resource_shortage'); + assert.equal(coordinator.getState().sftpProbeNeeded, true); + assert.equal(coordinator.getState().sftpOpen, false); + assert.deepEqual( + calls.filter(call => call[0] === 'files.status').at(-1), + ['files.status', 'resource_shortage', 'capacity.example'] + ); + const retry = [...timers.entries()] + .find(([_id, timer]) => timer.delay === 60000); + assert.notEqual(retry, undefined); + const emittedBeforeDuplicateProbe = emitted.length; + tracker.probeIfNeeded(coordinator.getState()); + assert.equal(emitted.length, emittedBeforeDuplicateProbe); + assert.equal( + [...timers.values()].filter(timer => timer.delay === 60000).length, + 1 + ); + timers.delete(retry[0]); + retry[1].callback(); + assert.equal(tracker.get('capacity'), 'probing'); + assert.equal(coordinator.getState().sftpCapability, 'probing'); + } + + assert.equal(emitted.at(-1)[1].request_id, 'probe-4'); + handlers.session_sftp_capability({ + success: true, + available: true, + session_id: 'capacity', + request_id: 'probe-4', + }); + assert.equal(tracker.get('capacity'), 'available'); + assert.equal(coordinator.getState().sftpEnabled, true); + assert.equal(coordinator.getState().sftpOpen, true); + assert.deepEqual( + calls.filter(call => call[0] === 'files.open'), + [['files.open', 'capacity', 'capacity.example']] + ); + assert.equal(timers.size, 0); +}); + +test('Files panel explains temporary SFTP channel shortage and automatic retry', () => { + const container = { hidden: false }; + const status = { hidden: true, textContent: '' }; + const translations = []; + const panel = createFilesPanelController({ + manager: { + openEmbedded() {}, + isEmbeddedOpen() { return false; }, + }, + container, + status, + translate(key, fallback) { + translations.push([key, fallback]); + return key; + }, + }); + + panel.setStatus('resource_shortage', { + username: 'ops', + host: 'capacity.example', + }); + + assert.equal(container.hidden, true); + assert.equal(status.hidden, false); + assert.equal(translations.at(-1)[0], 'workspace.sftpResourceShortage'); + assert.equal( + status.textContent, + 'The SSH server has no free channel capacity for SFTP on ops@capacity.example. WebSSH will retry automatically in about one minute.' + ); +}); + +test('SFTP capability tracker cancels resource-shortage retry while session is ineligible', () => { const handlers = {}; const emitted = []; const timers = new Map(); @@ -577,15 +693,17 @@ test('SFTP capability tracker cancels retries while the session is ineligible', handlers.session_sftp_capability({ success: false, available: false, + reason: 'resource_shortage', session_id: 's1', request_id: 'probe-1', }); - assert.equal([...timers.values()].some(timer => timer.delay === 10000), true); + assert.equal(tracker.get('s1'), 'resource_shortage'); + assert.equal([...timers.values()].some(timer => timer.delay === 60000), true); tracker.probeIfNeeded({ sessionId: 's1', sftpProbeNeeded: false, - sftpCapability: 'probing', + sftpCapability: 'resource_shortage', }); assert.equal(timers.size, 0); diff --git a/tests/test_command_set_socket_events.py b/tests/test_command_set_socket_events.py index 984fb27..9162e2c 100644 --- a/tests/test_command_set_socket_events.py +++ b/tests/test_command_set_socket_events.py @@ -704,6 +704,560 @@ def record_emit(event, payload=None, **_kwargs): )] +def test_ssh_connect_correlates_unexpected_worker_failure(app, monkeypatch): + import threading + from flask import request + from app import ssh_manager + import app.socket_events as socket_events + + _user_id, sid = create_socket_user(app, 'connect_worker_failure') + emitted = [] + completed = threading.Event() + + def record_emit(event, payload=None, **_kwargs): + emitted.append((event, payload)) + if event == 'ssh_error': + completed.set() + + monkeypatch.setattr( + socket_events, + 'emit', + record_emit, + ) + monkeypatch.setattr( + socket_events, + '_validate_ssh_params', + lambda host, port, username, **_kwargs: ( + host, int(port), username, None + ), + ) + monkeypatch.setattr( + ssh_manager, + 'create_ssh_connection', + lambda **_kwargs: (_ for _ in ()).throw(RuntimeError('local failure')), + ) + + with app.test_request_context('/socket.io'): + request.sid = sid + socket_events.handle_ssh_connect({ + 'host': 'example.com', + 'port': 22, + 'username': 'deploy', + 'password': 'secret', + 'client_request_id': 'replacement-request', + }) + + assert completed.wait(2) + assert emitted == [( + 'ssh_error', + { + 'error': 'Connection failed', + 'client_request_id': 'replacement-request', + }, + )] + + +def test_ssh_connect_cancel_signals_only_the_matching_background_job( + app, monkeypatch): + import threading + import time + from flask import request + from app import ssh_manager + import app.socket_events as socket_events + + _user_id, sid = create_socket_user(app, 'connect_request_cancel') + entered = threading.Event() + completed = threading.Event() + emitted = [] + + def wait_for_cancellation(**kwargs): + cancellation = kwargs['cancel_event'] + entered.set() + assert cancellation.wait(2) + completed.set() + return None, 'Connection cancelled' + + monkeypatch.setattr( + socket_events, + 'emit', + lambda event, payload=None, **_kwargs: emitted.append((event, payload)), + ) + monkeypatch.setattr( + socket_events, + '_validate_ssh_params', + lambda host, port, username, **_kwargs: ( + host, int(port), username, None + ), + ) + monkeypatch.setattr( + ssh_manager, + 'create_ssh_connection', + wait_for_cancellation, + ) + + with app.test_request_context('/socket.io'): + request.sid = sid + socket_events.handle_ssh_connect({ + 'host': 'example.com', + 'port': 22, + 'username': 'deploy', + 'password': 'secret', + 'client_request_id': 'cancel-this-request', + }) + + assert entered.wait(2) + with app.test_request_context('/socket.io'): + request.sid = sid + acknowledgement = socket_events.handle_ssh_connect_cancel({ + 'client_request_id': 'cancel-this-request', + }) + + assert acknowledgement == {'success': True, 'cancelled': True} + assert completed.wait(2) + assert not any(event in {'ssh_connected', 'ssh_error'} for event, _ in emitted) + attempt_key = (str(_user_id), sid, 'cancel-this-request') + deadline = time.monotonic() + 2 + while time.monotonic() < deadline: + with socket_events._ssh_connect_attempts_lock: + if attempt_key not in socket_events._ssh_connect_attempts: + break + time.sleep(0.01) + with socket_events._ssh_connect_attempts_lock: + assert attempt_key not in socket_events._ssh_connect_attempts + + +def test_ssh_connect_cancel_is_scoped_to_user_socket_request_and_banner(app): + import threading + from flask import request + import app.socket_events as socket_events + + user_id, sid = create_socket_user(app, 'connect_cancel_scope') + other_user_id, other_sid = create_socket_user( + app, 'connect_cancel_scope_other' + ) + + class RecordingHandle: + def __init__(self): + self.cancel_calls = 0 + + def cancel(self): + self.cancel_calls += 1 + + target_request = 'target-request' + other_request = 'other-request' + attempt_cases = { + 'matching': ( + (str(user_id), sid, target_request), + {'cancel_event': threading.Event(), 'handle': RecordingHandle()}, + ), + 'other_request': ( + (str(user_id), sid, other_request), + {'cancel_event': threading.Event(), 'handle': RecordingHandle()}, + ), + 'other_socket': ( + (str(user_id), other_sid, target_request), + {'cancel_event': threading.Event(), 'handle': RecordingHandle()}, + ), + 'other_user': ( + (str(other_user_id), sid, target_request), + {'cancel_event': threading.Event(), 'handle': RecordingHandle()}, + ), + } + banner_cases = { + 'matching-banner': { + 'event': threading.Event(), + 'accepted': True, + 'socket_sid': sid, + 'user_id': user_id, + 'client_request_id': target_request, + }, + 'other-request-banner': { + 'event': threading.Event(), + 'accepted': True, + 'socket_sid': sid, + 'user_id': user_id, + 'client_request_id': other_request, + }, + 'other-socket-banner': { + 'event': threading.Event(), + 'accepted': True, + 'socket_sid': other_sid, + 'user_id': user_id, + 'client_request_id': target_request, + }, + 'other-user-banner': { + 'event': threading.Event(), + 'accepted': True, + 'socket_sid': sid, + 'user_id': other_user_id, + 'client_request_id': target_request, + }, + } + attempt_keys = [case[0] for case in attempt_cases.values()] + banner_ids = list(banner_cases) + + with socket_events._ssh_connect_attempts_lock: + socket_events._ssh_connect_attempts.update( + dict(attempt_cases.values()) + ) + with socket_events._ssh_banner_prompts_lock: + socket_events._ssh_banner_prompts.update(banner_cases) + try: + with app.test_request_context('/socket.io'): + request.sid = sid + acknowledgement = socket_events.handle_ssh_connect_cancel({ + 'client_request_id': target_request, + }) + + assert acknowledgement == {'success': True, 'cancelled': True} + matching_attempt = attempt_cases['matching'][1] + assert matching_attempt['cancel_event'].is_set() + assert matching_attempt['handle'].cancel_calls == 1 + for name in ('other_request', 'other_socket', 'other_user'): + attempt = attempt_cases[name][1] + assert not attempt['cancel_event'].is_set() + assert attempt['handle'].cancel_calls == 0 + + matching_banner = banner_cases['matching-banner'] + assert matching_banner['event'].is_set() + assert matching_banner['accepted'] is False + for name in ( + 'other-request-banner', + 'other-socket-banner', + 'other-user-banner', + ): + banner = banner_cases[name] + assert not banner['event'].is_set() + assert banner['accepted'] is True + finally: + with socket_events._ssh_connect_attempts_lock: + for attempt_key in attempt_keys: + socket_events._ssh_connect_attempts.pop(attempt_key, None) + with socket_events._ssh_banner_prompts_lock: + for prompt_id in banner_ids: + socket_events._ssh_banner_prompts.pop(prompt_id, None) + + +def test_ssh_connect_cancel_rejects_an_already_committed_attempt(app): + import threading + from flask import request + import app.socket_events as socket_events + + user_id, sid = create_socket_user(app, 'connect_cancel_too_late') + + class RecordingHandle: + def __init__(self): + self.cancel_calls = 0 + + def cancel(self): + self.cancel_calls += 1 + + request_id = 'committed-request' + attempt_key = (str(user_id), sid, request_id) + attempt = { + 'cancel_event': threading.Event(), + 'commit_lock': threading.Lock(), + 'handle': RecordingHandle(), + 'state': 'committed', + } + with socket_events._ssh_connect_attempts_lock: + socket_events._ssh_connect_attempts[attempt_key] = attempt + try: + with app.test_request_context('/socket.io'): + request.sid = sid + acknowledgement = socket_events.handle_ssh_connect_cancel({ + 'client_request_id': request_id, + }) + + assert acknowledgement == { + 'success': False, + 'cancelled': False, + 'reason': 'already_committed', + } + assert not attempt['cancel_event'].is_set() + assert attempt['handle'].cancel_calls == 0 + finally: + with socket_events._ssh_connect_attempts_lock: + socket_events._ssh_connect_attempts.pop(attempt_key, None) + + +def test_cancelled_tmux_reconnect_preserves_existing_remote_session( + app, monkeypatch): + import threading + import config + from flask import request + from app import ssh_manager + from app.models import db, SSHSession + import app.socket_events as socket_events + + user_id, sid = create_socket_user(app, 'cancel_tmux_reconnect') + tmux_name = 'webssh_existing_remote_session' + with app.app_context(): + db.session.add(SSHSession( + session_id='disconnected-reconnect-candidate', + user_id=user_id, + host='example.com', + port=22, + username='deploy', + connected=False, + is_persistent=True, + tmux_session_name=tmux_name, + )) + db.session.commit() + + entered_connection = threading.Event() + closed_session = threading.Event() + close_calls = [] + emitted = [] + + def finish_after_cancellation(**kwargs): + assert kwargs['reconnect_tmux_name'] == tmux_name + entered_connection.set() + assert kwargs['cancel_event'].wait(2) + return 'cancelled-reconnect-session', None + + def record_close(session_id, kill_tmux=False): + close_calls.append((session_id, kill_tmux)) + closed_session.set() + return True + + monkeypatch.setattr(config, 'TMUX_ENABLED', True) + monkeypatch.setattr( + socket_events, + '_validate_ssh_params', + lambda host, port, username, **_kwargs: ( + host, int(port), username, None + ), + ) + monkeypatch.setattr( + socket_events, + 'emit', + lambda event, payload=None, **_kwargs: emitted.append((event, payload)), + ) + monkeypatch.setattr( + ssh_manager, + 'create_ssh_connection', + finish_after_cancellation, + ) + monkeypatch.setattr(ssh_manager, 'close_session', record_close) + monkeypatch.setattr( + ssh_manager, + 'get_session', + lambda _session_id: (_ for _ in ()).throw( + AssertionError('cancelled reconnect must stop before persistence') + ), + ) + + with app.test_request_context('/socket.io'): + request.sid = sid + socket_events.handle_ssh_connect({ + 'host': 'example.com', + 'port': 22, + 'username': 'deploy', + 'password': 'secret', + 'use_tmux': True, + 'reconnect_tmux_name': tmux_name, + 'client_request_id': 'cancel-reconnect-request', + }) + + assert entered_connection.wait(2) + with app.test_request_context('/socket.io'): + request.sid = sid + acknowledgement = socket_events.handle_ssh_connect_cancel({ + 'client_request_id': 'cancel-reconnect-request', + }) + + assert acknowledgement == {'success': True, 'cancelled': True} + assert closed_session.wait(2) + assert close_calls == [('cancelled-reconnect-session', False)] + assert not any(event in {'ssh_connected', 'ssh_error'} for event, _ in emitted) + + +def test_late_cancelled_tmux_reconnect_is_detached_and_remains_available( + app, monkeypatch): + from flask import request + from app import ssh_manager + from app.models import db, SSHSession + import app.socket_events as socket_events + + user_id, sid = create_socket_user(app, 'late_cancel_tmux_reconnect') + session_id = 'late-reconnect-session' + request_id = 'late-reconnect-request' + tmux_name = 'webssh_existing_remote_session' + with app.app_context(): + db.session.add(SSHSession( + session_id=session_id, + user_id=user_id, + host='example.com', + port=22, + username='deploy', + connected=True, + is_persistent=True, + tmux_session_name=tmux_name, + )) + db.session.commit() + + close_calls = [] + emitted = [] + monkeypatch.setattr( + ssh_manager, + 'get_session', + lambda candidate: { + 'id': candidate, + 'use_tmux': True, + 'tmux_session_name': tmux_name, + 'tmux_reconnect': True, + 'client_request_id': request_id, + } if candidate == session_id else None, + ) + monkeypatch.setattr( + ssh_manager, + 'close_session', + lambda candidate, kill_tmux=False: ( + close_calls.append((candidate, kill_tmux)) or True + ), + ) + monkeypatch.setattr( + socket_events.socketio, + 'emit', + lambda event, payload=None, **kwargs: emitted.append( + (event, payload, kwargs) + ), + ) + + with app.test_request_context('/socket.io'): + request.sid = sid + acknowledgement = socket_events.handle_ssh_discard_late_connection({ + 'session_id': session_id, + 'client_request_id': request_id, + }) + + assert acknowledgement == {'success': True} + assert close_calls == [(session_id, False)] + assert emitted == [( + 'ssh_disconnected', + { + 'session_id': session_id, + 'reason': 'Cancelled connection discarded', + }, + {'room': f'user_{user_id}'}, + )] + with app.app_context(): + candidates = SSHSession.query.filter_by( + user_id=user_id, + tmux_session_name=tmux_name, + ).all() + assert len(candidates) == 1 + assert candidates[0].session_id == session_id + assert candidates[0].connected is False + assert candidates[0].is_persistent is True + + +def test_late_cancelled_new_tmux_is_removed_and_killed(app, monkeypatch): + from flask import request + from app import ssh_manager + from app.models import db, SSHSession + import app.socket_events as socket_events + + user_id, sid = create_socket_user(app, 'late_cancel_new_tmux') + session_id = 'late-new-tmux-session' + request_id = 'late-new-tmux-request' + with app.app_context(): + db.session.add(SSHSession( + session_id=session_id, + user_id=user_id, + host='example.com', + port=22, + username='deploy', + connected=True, + is_persistent=True, + tmux_session_name='webssh_new_remote_session', + )) + db.session.commit() + + close_calls = [] + monkeypatch.setattr( + ssh_manager, + 'get_session', + lambda candidate: { + 'id': candidate, + 'use_tmux': True, + 'tmux_reconnect': False, + 'client_request_id': request_id, + } if candidate == session_id else None, + ) + monkeypatch.setattr( + ssh_manager, + 'close_session', + lambda candidate, kill_tmux=False: ( + close_calls.append((candidate, kill_tmux)) or True + ), + ) + monkeypatch.setattr(socket_events.socketio, 'emit', lambda *_args, **_kwargs: None) + + with app.test_request_context('/socket.io'): + request.sid = sid + acknowledgement = socket_events.handle_ssh_discard_late_connection({ + 'session_id': session_id, + 'client_request_id': request_id, + }) + + assert acknowledgement == {'success': True} + assert close_calls == [(session_id, True)] + with app.app_context(): + assert SSHSession.query.filter_by(session_id=session_id).first() is None + + +def test_late_connection_discard_rejects_a_different_request_id( + app, monkeypatch): + from flask import request + from app import ssh_manager + from app.models import db, SSHSession + import app.socket_events as socket_events + + user_id, sid = create_socket_user(app, 'late_cancel_request_scope') + session_id = 'late-request-scope-session' + with app.app_context(): + db.session.add(SSHSession( + session_id=session_id, + user_id=user_id, + host='example.com', + port=22, + username='deploy', + connected=True, + is_persistent=False, + )) + db.session.commit() + + monkeypatch.setattr( + ssh_manager, + 'get_session', + lambda _candidate: { + 'client_request_id': 'actual-request', + 'tmux_reconnect': False, + 'use_tmux': False, + }, + ) + monkeypatch.setattr( + ssh_manager, + 'close_session', + lambda *_args, **_kwargs: pytest.fail( + 'mismatched request must not close the session' + ), + ) + + with app.test_request_context('/socket.io'): + request.sid = sid + acknowledgement = socket_events.handle_ssh_discard_late_connection({ + 'session_id': session_id, + 'client_request_id': 'different-request', + }) + + assert acknowledgement == {'success': False} + with app.app_context(): + assert SSHSession.query.filter_by(session_id=session_id).one().connected + + def test_shutdown_rejects_new_ssh_and_quick_connections_before_network( app, monkeypatch): from flask import request diff --git a/tests/test_i18n_parity.py b/tests/test_i18n_parity.py index f3cbf2a..e2efc4b 100644 --- a/tests/test_i18n_parity.py +++ b/tests/test_i18n_parity.py @@ -150,15 +150,15 @@ def test_saved_connection_and_quick_connect_terms_are_consistent(): ), f'{match.group(1)}:{key} still uses legacy profile terminology' -def test_new_tab_accessible_name_uses_quick_connect_translation(): +def test_new_tab_accessible_name_describes_the_connection_launcher(): source = Path('templates/index.html').read_text(encoding='utf-8') new_tab = re.search(r']+id="newTabBtn"[^>]*>', source) assert new_tab is not None - assert 'title="Quick Connect"' in new_tab.group(0) - assert 'aria-label="Quick Connect"' in new_tab.group(0) - assert 'data-i18n-title="connection.newConnection"' in new_tab.group(0) - assert 'data-i18n-aria-label="connection.newConnection"' in new_tab.group(0) + assert 'title="Select a session or use Quick Connect"' in new_tab.group(0) + assert 'aria-label="Select a session or use Quick Connect"' in new_tab.group(0) + assert 'data-i18n-title="panes.selectSession"' in new_tab.group(0) + assert 'data-i18n-aria-label="panes.selectSession"' in new_tab.group(0) def test_english_visible_fallbacks_avoid_legacy_connection_terms(): diff --git a/tests/test_paramiko_channels.py b/tests/test_paramiko_channels.py index 69f5d6e..4bda014 100644 --- a/tests/test_paramiko_channels.py +++ b/tests/test_paramiko_channels.py @@ -1,3 +1,4 @@ +import logging import socket import threading import time @@ -6,6 +7,120 @@ import pytest +def _invoke_channel_open_failure(channel_id, reason_code, description): + transport = object.__new__(paramiko.Transport) + transport.logger = logging.getLogger('paramiko.transport') + transport.lock = threading.Lock() + transport.channel_events = {} + transport.saved_exception = None + + message = paramiko.Message() + message.add_int(channel_id) + message.add_int(reason_code) + message.add_string(description) + message.add_string('en') + message.rewind() + transport._parse_channel_open_failure(message) + return transport.saved_exception + + +@pytest.mark.parametrize( + ('reason_code', 'reason_text'), + [ + (1, 'Administratively prohibited'), + (2, 'Connect failed'), + (3, 'Unknown channel type'), + (4, 'Resource shortage'), + (99, '(unknown code)'), + ], +) +def test_paramiko_channel_open_failure_log_omits_remote_description( + caplog, reason_code, reason_text): + from app import paramiko_channels # noqa: F401 + + caplog.set_level(logging.ERROR, logger='paramiko.transport') + remote_description = 'attacker-line\nFORGED\r\x1b[31m\x00' + + error = _invoke_channel_open_failure(27, reason_code, remote_description) + + assert caplog.messages == [ + 'Secsh channel 27 open FAILED: ' + f'{reason_text} (server description omitted)' + ] + assert remote_description not in caplog.text + assert 'FORGED' not in caplog.text + assert '\x1b' not in caplog.text + assert '\x00' not in caplog.text + assert error.code == reason_code + assert error.text == reason_text + + +def test_paramiko_channel_log_filter_preserves_unrelated_records(caplog): + from app import paramiko_channels # noqa: F401 + + caplog.set_level(logging.ERROR, logger='paramiko.transport') + logger = logging.getLogger('paramiko.transport') + + logger.error('Unrelated Paramiko error: %s', 'connection reset') + + assert caplog.messages == [ + 'Unrelated Paramiko error: connection reset' + ] + + +def test_paramiko_channel_log_filter_installation_is_idempotent(): + from app import paramiko_channels + + logger = logging.getLogger('paramiko.transport') + paramiko_channels._install_channel_open_failure_log_filter() + paramiko_channels._install_channel_open_failure_log_filter() + + assert sum( + bool(getattr( + log_filter, + paramiko_channels._CHANNEL_OPEN_FAILURE_FILTER_MARKER, + False, + )) + for log_filter in logger.filters + ) == 1 + + +def test_optional_channel_rejection_fields_accepts_only_remote_resource_shortage(): + from app import paramiko_channels + + assert paramiko_channels.optional_channel_rejection_fields( + paramiko.ChannelException(4, 'server-controlled text') + ) == { + 'ssh_channel_code': 4, + 'ssh_channel_reason': 'remote_resource_shortage', + } + assert paramiko_channels.optional_channel_rejection_fields( + paramiko.ChannelException(1, 'Administratively prohibited') + ) is None + assert paramiko_channels.optional_channel_rejection_fields( + paramiko.SSHException('transport race') + ) is None + + +def test_primary_shell_keeps_remote_resource_shortage_fatal(): + from app import paramiko_channels + + class RejectingTransport: + def open_session(self, timeout=None): + raise paramiko.ChannelException(4, 'Resource shortage') + + with pytest.raises(paramiko.ChannelException) as error: + paramiko_channels.open_shell_channel( + RejectingTransport(), + timeout=1, + term='xterm-256color', + width=80, + height=24, + ) + + assert error.value.code == 4 + + class BlockingTransport: def __init__(self): self.open_timeout = None diff --git a/tests/test_profile_launcher_ui.py b/tests/test_profile_launcher_ui.py index c2b3b40..350e695 100644 --- a/tests/test_profile_launcher_ui.py +++ b/tests/test_profile_launcher_ui.py @@ -66,7 +66,7 @@ def test_index_exposes_only_bounded_numeric_transfer_limits(): def test_profile_manager_builds_safe_contextual_launcher_buttons(): source = read('static/js/profile-manager.js') - assert 'createEmptyPaneContent(paneIndex)' in source + assert 'createEmptyPaneContent(paneIndex, options = {})' in source assert "button.type = 'button'" in source assert 'button.dataset.profileId = profile.id' in source assert 'name.textContent = profile.name' in source @@ -76,6 +76,8 @@ def test_profile_manager_builds_safe_contextual_launcher_buttons(): assert 'profile-launcher-search' in source assert 'profile-launcher-section-title' in source assert 'ProfileLauncherUtils.buildProfileSections' in source + assert 'returnCopy.textContent = returnText' in source + assert 'options.onReturn()' in source assert 'innerHTML = profile' not in source @@ -103,7 +105,7 @@ def test_saved_connections_uses_the_shared_management_panel_hierarchy(): assert 'class="management-panel-actions"' in panel -def test_profile_dependencies_refresh_only_empty_panes(): +def test_profile_dependencies_refresh_empty_and_staged_launcher_panes(): profiles = read('static/js/profile-manager.js') jump_hosts = read('static/js/jump-host-manager.js') sessions = read('static/js/session-manager.js') @@ -111,7 +113,7 @@ def test_profile_dependencies_refresh_only_empty_panes(): assert profiles.count('this.refreshEmptyPanes()') >= 2 assert 'SessionManager.refreshEmptyPanes()' in jump_hosts assert 'refreshEmptyPanes()' in sessions - assert 'if (!this.paneAssignments[index])' in sessions + assert '|| this.connectionLauncherSessions.has(index)' in sessions def test_dynamic_empty_panes_refresh_after_language_changes(): diff --git a/tests/test_session_insights.py b/tests/test_session_insights.py index 3209c06..68a23ab 100644 --- a/tests/test_session_insights.py +++ b/tests/test_session_insights.py @@ -551,3 +551,60 @@ def test_collect_linux_stats_treats_generic_ssh_channel_race_as_transient(monkey assert stats is None assert error == 'transient' + + +def test_collect_linux_stats_backs_off_after_optional_channel_resource_shortage( + monkeypatch): + from paramiko import ChannelException + + install_session(monkeypatch) + monkeypatch.setattr( + session_insights.ssh_manager, + '_open_exec_channel', + lambda *_args, **_kwargs: (_ for _ in ()).throw( + ChannelException(4, 'server-controlled text') + ), + ) + logged = [] + monkeypatch.setattr( + session_insights, + 'log_info', + lambda message, **fields: logged.append((message, fields)), + ) + + stats, error = session_insights.collect_linux_stats('owned-session') + + assert stats is None + assert error == 'resource_shortage' + assert logged == [( + 'Diagnostics temporarily unavailable because the remote SSH server ' + 'reported insufficient capacity for an additional channel', + { + 'session_id': 'owned-session', + 'ssh_channel_code': 4, + 'ssh_channel_reason': 'remote_resource_shortage', + }, + )] + + +def test_collect_linux_stats_keeps_other_channel_rejections_retryable(monkeypatch): + from paramiko import ChannelException + + install_session(monkeypatch) + monkeypatch.setattr( + session_insights.ssh_manager, + '_open_exec_channel', + lambda *_args, **_kwargs: (_ for _ in ()).throw( + ChannelException(1, 'Administratively prohibited') + ), + ) + monkeypatch.setattr( + session_insights, + 'log_info', + lambda *_args, **_kwargs: pytest.fail('unexpected optional rejection log'), + ) + + stats, error = session_insights.collect_linux_stats('owned-session') + + assert stats is None + assert error == 'transient' diff --git a/tests/test_session_insights_socket.py b/tests/test_session_insights_socket.py index 127fb16..ad13ffd 100644 --- a/tests/test_session_insights_socket.py +++ b/tests/test_session_insights_socket.py @@ -150,6 +150,24 @@ def test_session_insights_socket_returns_generic_collector_failure(monkeypatch): })] +def test_session_insights_socket_preserves_resource_shortage_retry_signal( + monkeypatch): + emitted, calls = invoke( + monkeypatch, + {'session_id': 'owned-session', 'request_id': 'sample-capacity'}, + collector_result=(None, 'resource_shortage'), + ) + + assert calls == [('owned-session', False)] + assert emitted == [('session_insights', { + 'success': False, + 'session_id': 'owned-session', + 'request_id': 'sample-capacity', + 'error': 'Session insights unavailable', + 'reason': 'resource_shortage', + })] + + @pytest.mark.parametrize( ('requested_value', 'expected'), [ diff --git a/tests/test_sftp_handler.py b/tests/test_sftp_handler.py index 3da6413..f9354e3 100644 --- a/tests/test_sftp_handler.py +++ b/tests/test_sftp_handler.py @@ -540,6 +540,46 @@ def test_probe_sftp_capability_hides_remote_failure(monkeypatch): assert sftp_handler.probe_sftp_capability('session-a') is False +def test_probe_sftp_capability_explains_remote_channel_resource_shortage( + monkeypatch): + import paramiko + import app.sftp_handler as sftp_handler + + transport = type('Transport', (), {'is_active': lambda self: True})() + client = type('Client', (), {'get_transport': lambda self: transport})() + monkeypatch.setitem(sftp_handler.ssh_manager.sessions, 'session-a', { + 'connected': True, + 'client': client, + }) + monkeypatch.setattr( + sftp_handler, + 'open_sftp_client', + lambda *_args, **_kwargs: (_ for _ in ()).throw( + paramiko.ChannelException(4, 'server-controlled text') + ), + ) + logged = [] + monkeypatch.setattr( + sftp_handler, + 'log_info', + lambda message, **fields: logged.append((message, fields)), + ) + + assert ( + sftp_handler.probe_sftp_capability('session-a') + == sftp_handler.CAPABILITY_RESOURCE_SHORTAGE + ) + assert logged == [( + 'SFTP temporarily unavailable because the remote SSH server reported ' + 'insufficient capacity for an additional channel', + { + 'session_id': 'session-a', + 'ssh_channel_code': 4, + 'ssh_channel_reason': 'remote_resource_shortage', + }, + )] + + def test_probe_sftp_capability_uses_a_fresh_bounded_client(monkeypatch): import app.sftp_handler as sftp_handler diff --git a/tests/test_sftp_request_correlation.py b/tests/test_sftp_request_correlation.py index f7323c0..9434fc9 100644 --- a/tests/test_sftp_request_correlation.py +++ b/tests/test_sftp_request_correlation.py @@ -432,6 +432,31 @@ def test_sftp_capability_probe_reports_busy_as_retryable_generic_failure(monkeyp })] +def test_sftp_capability_probe_preserves_remote_resource_shortage(monkeypatch): + emitted, user = _capture(monkeypatch) + monkeypatch.setattr(socket_events, 'check_socket_rate_limit', lambda *_args: False) + monkeypatch.setattr( + socket_events.sftp_handler, + 'probe_sftp_capability', + lambda _session_id: ( + socket_events.sftp_handler.CAPABILITY_RESOURCE_SHORTAGE + ), + ) + + socket_events.handle_probe_session_sftp.__wrapped__({ + 'session_id': 'session-a', + 'request_id': 'sftp-probe:capacity', + }, current_user=user) + + assert emitted == [('session_sftp_capability', { + 'success': False, + 'session_id': 'session-a', + 'request_id': 'sftp-probe:capacity', + 'available': False, + 'reason': 'resource_shortage', + })] + + def test_sftp_capability_probe_rejects_unowned_session_generically(monkeypatch): emitted, user = _capture(monkeypatch) monkeypatch.setattr(socket_events, 'check_socket_rate_limit', lambda *_args: False) diff --git a/tests/test_ssh_manager.py b/tests/test_ssh_manager.py index c4d3043..eb63385 100644 --- a/tests/test_ssh_manager.py +++ b/tests/test_ssh_manager.py @@ -584,10 +584,23 @@ def test_password_tmux_preserves_remote_locale(monkeypatch): password='secret', use_tmux=True, reconnect_tmux_name='existing_session', + client_request_id='tmux-reconnect-request', ) assert error is None assert session_id in ssh_manager.sessions + assert ssh_manager.get_session(session_id) == { + 'id': session_id, + 'host': 'target.example', + 'port': 22, + 'username': 'alice', + 'connected': True, + 'via_jump': None, + 'use_tmux': True, + 'tmux_session_name': 'existing_session', + 'tmux_reconnect': True, + 'client_request_id': 'tmux-reconnect-request', + } _, tmux_channel = clients[0].transport.session_channels assert tmux_channel.command == 'tmux new-session -A -s existing_session' diff --git a/tests/test_startup_commands.py b/tests/test_startup_commands.py index 665ef7c..93bbd40 100644 --- a/tests/test_startup_commands.py +++ b/tests/test_startup_commands.py @@ -1,6 +1,7 @@ """Tests for post-connect command normalization and terminal input.""" from pathlib import Path +import threading import paramiko import pytest @@ -243,6 +244,214 @@ def test_create_ssh_connection_delivers_startup_commands_once( ssh_manager.close_session(session_id) +def test_cancelled_connection_never_delivers_startup_commands(monkeypatch): + from app import ssh_manager + + cancel_event = threading.Event() + + class CancelWhenShellIsReady(_StartupCommandChannel): + def settimeout(self, _timeout): + cancel_event.set() + + channel = CancelWhenShellIsReady() + client = _StartupCommandClient(channel) + monkeypatch.setattr(ssh_manager.paramiko, 'SSHClient', lambda: client) + monkeypatch.setattr(ssh_manager.time, 'sleep', lambda _seconds: None) + + session_id, error = ssh_manager.create_ssh_connection( + host='target.example', + port=22, + username='alice', + password='secret', + user_id=1, + startup_commands='touch should-not-run', + cancel_event=cancel_event, + ) + + assert session_id is None + assert error == 'Connection cancelled' + assert channel.sent == [] + assert channel.closed + assert client.closed + assert ssh_manager.sessions == {} + + +def test_cancellation_while_formatting_startup_commands_sends_nothing(monkeypatch): + from app import ssh_manager + + cancel_event = threading.Event() + channel = _StartupCommandChannel() + client = _StartupCommandClient(channel) + original_to_terminal_input = ssh_manager.to_terminal_input + + def cancel_during_formatting(commands): + cancel_event.set() + return original_to_terminal_input(commands) + + monkeypatch.setattr(ssh_manager.paramiko, 'SSHClient', lambda: client) + monkeypatch.setattr(ssh_manager.time, 'sleep', lambda _seconds: None) + monkeypatch.setattr( + ssh_manager, + 'to_terminal_input', + cancel_during_formatting, + ) + + session_id, error = ssh_manager.create_ssh_connection( + host='target.example', + port=22, + username='alice', + password='secret', + user_id=1, + startup_commands='touch should-not-run', + cancel_event=cancel_event, + ) + + assert session_id is None + assert error == 'Connection cancelled' + assert channel.sent == [] + assert channel.closed + assert client.closed + assert ssh_manager.sessions == {} + + +def test_cancellation_stops_partial_startup_command_delivery(monkeypatch): + from app import ssh_manager + + cancel_event = threading.Event() + + class CancelAfterFirstChunk(_StartupCommandChannel): + def send(self, data): + sent = super().send(data) + cancel_event.set() + return sent + + channel = CancelAfterFirstChunk(max_send_size=4) + client = _StartupCommandClient(channel) + monkeypatch.setattr(ssh_manager.paramiko, 'SSHClient', lambda: client) + monkeypatch.setattr(ssh_manager.time, 'sleep', lambda _seconds: None) + + session_id, error = ssh_manager.create_ssh_connection( + host='target.example', + port=22, + username='alice', + password='secret', + user_id=1, + startup_commands='touch should-not-run', + cancel_event=cancel_event, + ) + + assert session_id is None + assert error == 'Connection cancelled' + assert channel.sent == [b'touc'] + assert channel.closed + assert client.closed + assert ssh_manager.sessions == {} + + +def test_user_cancel_is_rejected_after_startup_delivery_commits(monkeypatch): + from app import ssh_manager + import app.socket_events as socket_events + + send_started = threading.Event() + release_send = threading.Event() + + class BlockingFullSend(_StartupCommandChannel): + def send(self, data): + send_started.set() + assert release_send.wait(2) + return super().send(data) + + channel = BlockingFullSend() + client = _StartupCommandClient(channel) + user_cancel = threading.Event() + lifecycle_cancel = threading.Event() + attempt = { + 'cancel_event': user_cancel, + 'commit_lock': threading.Lock(), + 'state': 'pending', + } + cancellation = socket_events._CombinedCancellation( + user_cancel, + lifecycle_cancel, + attempt['commit_lock'], + attempt, + ) + result = {} + + monkeypatch.setattr(ssh_manager.paramiko, 'SSHClient', lambda: client) + monkeypatch.setattr(ssh_manager.time, 'sleep', lambda _seconds: None) + + def connect(): + result['value'] = ssh_manager.create_ssh_connection( + host='target.example', + port=22, + username='alice', + password='secret', + user_id=1, + startup_commands='touch committed-command', + cancel_event=cancellation, + ) + + worker = threading.Thread(target=connect) + worker.start() + try: + assert send_started.wait(2) + assert attempt['state'] == 'committed' + assert socket_events._try_cancel_ssh_attempt(attempt) is False + assert not user_cancel.is_set() + finally: + release_send.set() + worker.join(2) + + assert not worker.is_alive() + session_id, error = result['value'] + assert error is None + assert b''.join(channel.sent) == b'touch committed-command\r' + assert session_id in ssh_manager.sessions + ssh_manager.close_session(session_id) + + +def test_user_cancel_before_startup_commit_sends_nothing(monkeypatch): + from app import ssh_manager + import app.socket_events as socket_events + + channel = _StartupCommandChannel() + client = _StartupCommandClient(channel) + user_cancel = threading.Event() + attempt = { + 'cancel_event': user_cancel, + 'commit_lock': threading.Lock(), + 'state': 'pending', + } + cancellation = socket_events._CombinedCancellation( + user_cancel, + threading.Event(), + attempt['commit_lock'], + attempt, + ) + assert socket_events._try_cancel_ssh_attempt(attempt) is True + + monkeypatch.setattr(ssh_manager.paramiko, 'SSHClient', lambda: client) + monkeypatch.setattr(ssh_manager.time, 'sleep', lambda _seconds: None) + + session_id, error = ssh_manager.create_ssh_connection( + host='target.example', + port=22, + username='alice', + password='secret', + user_id=1, + startup_commands='touch should-not-run', + cancel_event=cancellation, + ) + + assert session_id is None + assert error == 'Connection cancelled' + assert channel.sent == [] + assert channel.closed is False + assert client.closed is False + assert ssh_manager.sessions == {} + + def test_create_ssh_connection_delivers_all_startup_commands_after_partial_send(monkeypatch): from app import ssh_manager @@ -351,6 +560,43 @@ def test_create_ssh_connection_kills_new_tmux_when_startup_delivery_fails(monkey assert client.close_calls == 1 +def test_output_reader_start_failure_detaches_existing_tmux(monkeypatch): + from app import ssh_manager + + class RejectingLifecycle: + def start_job(self, *_args, **_kwargs): + raise RuntimeError('reader unavailable') + + class FakeApp: + extensions = {'runtime_lifecycle': RejectingLifecycle()} + + transport = _StartupCommandTransport() + client = _StartupCommandClient(_StartupCommandChannel(), transport=transport) + monkeypatch.setattr(ssh_manager.paramiko, 'SSHClient', lambda: client) + monkeypatch.setattr(ssh_manager.time, 'sleep', lambda _seconds: None) + + session_id, error = ssh_manager.create_ssh_connection( + host='target.example', + port=22, + username='alice', + password='secret', + user_id=1, + use_tmux=True, + reconnect_tmux_name='existing_session', + socketio_instance=object(), + app=FakeApp(), + ) + + assert session_id is None + assert error == 'Connection failed' + assert ssh_manager.sessions == {} + probe_channel, tmux_channel = transport.session_channels + assert probe_channel.command == 'command -v tmux' + assert tmux_channel.command == 'tmux new-session -A -s existing_session' + assert tmux_channel.closed + assert client.closed + + def test_connection_form_offers_free_text_command_and_named_set_modes(): template = Path('templates/index.html').read_text(encoding='utf-8') diff --git a/tests/test_tailscale_ssh.py b/tests/test_tailscale_ssh.py index 2edf905..698bd6f 100644 --- a/tests/test_tailscale_ssh.py +++ b/tests/test_tailscale_ssh.py @@ -515,7 +515,9 @@ def fake_get_session(session_id): return { 'connected': True, 'auth_type': 'tailscale', + 'use_tmux': True, 'tmux_session_name': 'webssh_tiny_root', + 'tmux_reconnect': True, } monkeypatch.setattr(ssh_manager, 'create_ssh_connection', fake_create_ssh_connection)