From 0258fe9bc08d619ce489fe2e163f13e0bf092c0a Mon Sep 17 00:00:00 2001 From: "ASKA C." Date: Fri, 28 Aug 2026 07:17:51 +0800 Subject: [PATCH 1/2] Skip pip probe on Windows warm starts --- run.bat | 37 +++++++++++++++++++------------------ 1 file changed, 19 insertions(+), 18 deletions(-) diff --git a/run.bat b/run.bat index e9cb6ab..105d1d9 100644 --- a/run.bat +++ b/run.bat @@ -153,26 +153,11 @@ exit /b 0 :ensure_dependencies echo [*] Using Python runtime: %RUNTIME_PYTHON% -"%RUNTIME_PYTHON%" -m pip --version >nul 2>nul -if errorlevel 1 ( - echo [*] pip is missing; attempting ensurepip... - "%RUNTIME_PYTHON%" -m ensurepip --upgrade >nul 2>nul - if errorlevel 1 ( - echo [!] ERROR: pip is unavailable for this Python runtime. - if "%RUNTIME_KIND%"=="venv" ( - echo Delete "%VENV_DIR%" and rerun run.bat, or install/repair Python from https://www.python.org/. - ) else ( - echo Delete "%EMBED_PYTHON_DIR%" and rerun run.bat, or install Python 3.10+ manually. - ) - exit /b 1 - ) -) - -REM Consult the install stamp before running the dependency checker. The stamp -REM records the runtime kind, the Python version, and the requirements.txt hash, +REM Consult the install stamp before probing pip or running the dependency +REM checker. It records the runtime kind, Python version, and requirements hash, REM so a match means this runtime already satisfies requirements.txt. The checker REM imports paramiko and eventlet, which the server itself imports lazily or not -REM at all, so running it on every warm start costs seconds for no new information. +REM at all, while importing pip also adds avoidable work to every warm start. if "%FORCE_RECHECK%"=="true" goto :install_deps if not exist "%INSTALLED_FLAG%" goto :install_deps @@ -188,6 +173,22 @@ exit /b 0 :install_deps del "%INSTALLED_FLAG%" >nul 2>nul + +"%RUNTIME_PYTHON%" -m pip --version >nul 2>nul +if errorlevel 1 ( + echo [*] pip is missing; attempting ensurepip... + "%RUNTIME_PYTHON%" -m ensurepip --upgrade >nul 2>nul + if errorlevel 1 ( + echo [!] ERROR: pip is unavailable for this Python runtime. + if "%RUNTIME_KIND%"=="venv" ( + echo Delete "%VENV_DIR%" and rerun run.bat, or install/repair Python from https://www.python.org/. + ) else ( + echo Delete "%EMBED_PYTHON_DIR%" and rerun run.bat, or install Python 3.10+ manually. + ) + exit /b 1 + ) +) + if not exist "%REQ_FILE%" ( echo [!] ERROR: requirements.txt was not found: %REQ_FILE% echo Restore the repository files, then rerun run.bat. From 3bb22b51d3218008f8224d19c99b6998648129c2 Mon Sep 17 00:00:00 2001 From: "ASKA C." Date: Fri, 28 Aug 2026 14:51:06 +0800 Subject: [PATCH 2/2] Add lightweight SFTP file management ## Why SSH tabs expose interactive terminal access but do not provide a bounded way to move and manage reference files. Terminal Picture-in-Picture also hides the main-page context menu, making an SFTP-only entry point incomplete. ## What changed - Add direct-endpoint SFTP browsing, upload, download, rename, and two-stage permanent deletion without following nested SSH sessions. - Bind regular-file actions to short-lived opaque references and browser-, socket-, terminal-, and bridge-scoped transfer tickets. - Add SFTP actions to the main status bar, context menu, and Terminal PiP, with a disabled unavailable state and a clean PiP transition. - Defer session-recovery reconnect until the previous Socket.IO transport closes its current event-loop task. ## Testing - Covered SFTP bridge boundaries, atomic replacement, opaque references, ticket scope, single-use downloads, and exact byte streaming. - Covered main and PiP entry points, unavailable states, upload conflicts, downloads, rename/delete safeguards, and session recovery in Chromium. --- README.md | 23 + app.py | 591 +++++++++++++++- templates/index.html | 1209 ++++++++++++++++++++++++++++++++- terminal_backends/__init__.py | 3 +- terminal_backends/ssh.py | 449 ++++++++++++ tests/agent_backend_smoke.py | 386 +++++++++++ tests/agent_browser_smoke.py | 505 +++++++++++++- 7 files changed, 3150 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 4480cc3..61135c3 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,8 @@ pulling large changes. - Runs SSH, Local Shell, and UART sessions inside browser terminal tabs. - Supports multiple persistent terminal tabs while the server process is alive. +- Provides a lightweight SFTP File Manager for direct SSH sessions, including + upload, download, rename, and carefully confirmed permanent deletion. - Opens URLs and image links in an in-page overlay, and can pop a terminal into system Picture-in-Picture when the browser supports it. - Provides Windows Terminal-inspired themes, IBM 5153 colors, 256-color, and @@ -258,6 +260,27 @@ profile's public key before returning it to Paramiko. A browser disconnect, timeout, changed connection draft, or stale terminal start cancels the path without falling back to a password automatically. +### Lightweight SFTP File Manager + +For a connected SSH tab, use the folder button in the status bar, the terminal +context menu, or the folder button in Terminal Picture-in-Picture. StandTerm +opens a compact SFTP File Manager in Picture-in-Picture. When opened from a +terminal PiP, the terminal first returns to its tab so the single Document PiP +window can switch cleanly to the file manager. + +The file manager supports a flat directory listing with manual path navigation, +drag-and-drop upload, download, rename, and permanent deletion. Upload conflicts +offer **Keep Both** or atomic **Replace**. Delete uses two distinct confirmation +steps with deliberately separated actions. It operates only on the direct SSH +endpoint represented by the tab; it does not follow nested interactive SSH +sessions, recursively browse directory trees, or act as a full SFTP client. + +Remote file actions use short-lived opaque references and transfer tickets bound +to the browser session, socket, terminal, and live SSH bridge. Displayed paths +and file names remain data rather than control authority. Downloads and file +actions accept regular files only; symbolic links and other non-regular entries +are rejected. + **Settings > General > Import & Export** transfers browser preferences, SSH profiles and order, SSH history, and persistent UI layout in a versioned JSON envelope containing a Base64 ZIP archive. Import merges profiles by stable ID, diff --git a/app.py b/app.py index bafc2ec..6f0842c 100644 --- a/app.py +++ b/app.py @@ -20,7 +20,7 @@ import atexit from collections import deque from pathlib import Path -from flask import Flask, render_template, request, abort, make_response, redirect, send_file, jsonify +from flask import Flask, Response, render_template, request, abort, make_response, redirect, send_file, jsonify, stream_with_context from flask_socketio import SocketIO, ConnectionRefusedError from external_agent_dispatch import ExternalAgentCommandDispatcher from external_agent_handlers import ( @@ -62,6 +62,7 @@ BackendStartFieldSchema, LocalShellBackendPlugin, LocalShellBridge, + SFTPTransferError, SSHBackendPlugin, SSHBridge, TerminalBackendPlugin, @@ -146,6 +147,21 @@ def parse_optional_seconds_env(name, default=None): return default return seconds +def parse_positive_int_env(name, default): + raw_value = get_prefixed_env(name).strip() + env_name = get_prefixed_env_name(name) + if not raw_value: + return default + try: + value = int(raw_value) + except ValueError: + log_message(f"[!] Ignoring invalid {env_name}={raw_value!r}; expected a positive integer.", file=sys.stderr) + return default + if value <= 0: + log_message(f"[!] Ignoring invalid {env_name}={raw_value!r}; expected a positive integer.", file=sys.stderr) + return default + return value + SSH_TERM = 'xterm-256color' MAX_SSH_INPUT_BYTES = 65536 MAX_PASSWORD_BYTES = 4096 @@ -159,6 +175,17 @@ def parse_optional_seconds_env(name, default=None): SSH_BROWSER_SIGN_TIMEOUT_SECONDS = 15 SSH_BROWSER_SIGN_REQUEST_EVENT = 'ssh_browser_sign_request' SSH_BROWSER_SIGN_RESPONSE_EVENT = 'ssh_browser_sign_response' +SFTP_BROWSE_REQUEST_EVENT = 'sftp_browse_request' +SFTP_BROWSE_RESULT_EVENT = 'sftp_browse_result' +SFTP_UPLOAD_TICKET_REQUEST_EVENT = 'sftp_upload_ticket_request' +SFTP_UPLOAD_TICKET_RESULT_EVENT = 'sftp_upload_ticket_result' +SFTP_DOWNLOAD_TICKET_REQUEST_EVENT = 'sftp_download_ticket_request' +SFTP_DOWNLOAD_TICKET_RESULT_EVENT = 'sftp_download_ticket_result' +SFTP_FILE_ACTION_REQUEST_EVENT = 'sftp_file_action_request' +SFTP_FILE_ACTION_RESULT_EVENT = 'sftp_file_action_result' +SFTP_UPLOAD_TICKET_TTL_SECONDS = 60 +SFTP_DOWNLOAD_TICKET_TTL_SECONDS = 60 +SFTP_MAX_UPLOAD_BYTES = parse_positive_int_env('SFTP_MAX_UPLOAD_BYTES', 512 * 1024 * 1024) MIN_TERMINAL_COLS = 2 MAX_TERMINAL_COLS = 500 MIN_TERMINAL_ROWS = 2 @@ -2003,6 +2030,127 @@ def _trim(self, now): browser_ssh_sign_request_store = BrowserSSHSignRequestStore() +class SFTPUploadTicketStore: + def __init__(self, time_func=None): + self._records = {} + self._lock = threading.Lock() + self._time_func = time_func or time.time + + def clear(self): + with self._lock: + self._records.clear() + + def _discard_expired_locked(self): + now = self._time_func() + for token, record in list(self._records.items()): + if now > record['expires_at']: + self._records.pop(token, None) + + def create(self, session_token, terminal_id, sid, bridge, upload, expected_size): + token = secrets.token_urlsafe(32) + upload_id = 'sftpu_' + secrets.token_urlsafe(12) + record = { + 'session_token': session_token, + 'terminal_id': terminal_id, + 'sid': sid, + 'bridge': bridge, + 'upload': dict(upload), + 'expected_size': expected_size, + 'upload_id': upload_id, + 'expires_at': self._time_func() + SFTP_UPLOAD_TICKET_TTL_SECONDS, + } + with self._lock: + self._discard_expired_locked() + self._records[token] = record + return token, dict(record) + + def consume(self, token, session_token): + if not isinstance(token, str) or not isinstance(session_token, str): + return None, 'sftp_upload_ticket_invalid' + with self._lock: + record = self._records.get(token) + if not record or not secrets.compare_digest(record['session_token'], session_token): + return None, 'sftp_upload_ticket_invalid' + self._records.pop(token, None) + if self._time_func() > record['expires_at']: + return None, 'sftp_upload_ticket_expired' + return record, None + + def discard(self, session_token, terminal_id=None, sid=None): + with self._lock: + for token, record in list(self._records.items()): + if record['session_token'] != session_token: + continue + if terminal_id is not None and record['terminal_id'] != terminal_id: + continue + if sid is not None and record['sid'] != sid: + continue + self._records.pop(token, None) + + +sftp_upload_ticket_store = SFTPUploadTicketStore() + + +class SFTPDownloadTicketStore: + def __init__(self, time_func=None): + self._records = {} + self._lock = threading.Lock() + self._time_func = time_func or time.time + + def clear(self): + with self._lock: + self._records.clear() + + def _discard_expired_locked(self): + now = self._time_func() + for token, record in list(self._records.items()): + if now > record['expires_at']: + self._records.pop(token, None) + + def create(self, session_token, terminal_id, sid, bridge, file_snapshot): + token = secrets.token_urlsafe(32) + download_id = 'sftpd_' + secrets.token_urlsafe(6) + record = { + 'session_token': session_token, + 'terminal_id': terminal_id, + 'sid': sid, + 'bridge': bridge, + 'file': dict(file_snapshot), + 'download_id': download_id, + 'expires_at': self._time_func() + SFTP_DOWNLOAD_TICKET_TTL_SECONDS, + } + with self._lock: + self._discard_expired_locked() + self._records[token] = record + return token, dict(record) + + def consume(self, token, session_token): + if not isinstance(token, str) or not isinstance(session_token, str): + return None, 'sftp_download_ticket_invalid' + with self._lock: + record = self._records.get(token) + if not record or not secrets.compare_digest(record['session_token'], session_token): + return None, 'sftp_download_ticket_invalid' + self._records.pop(token, None) + if self._time_func() > record['expires_at']: + return None, 'sftp_download_ticket_expired' + return record, None + + def discard(self, session_token, terminal_id=None, sid=None): + with self._lock: + for token, record in list(self._records.items()): + if record['session_token'] != session_token: + continue + if terminal_id is not None and record['terminal_id'] != terminal_id: + continue + if sid is not None and record['sid'] != sid: + continue + self._records.pop(token, None) + + +sftp_download_ticket_store = SFTPDownloadTicketStore() + + def request_browser_ssh_signature(bridge, signer_sid, browser_key, challenge, algorithm): session_token = bridge.owner_session identity = socket_browser_identities.get(signer_sid) or {} @@ -4965,6 +5113,8 @@ def unregister_terminal_bridge(session_token, terminal_id, bridge): agent_viewport_snapshot_store.discard(session_token, terminal_id=terminal_id) agent_viewport_render_request_store.discard(session_token, terminal_id=terminal_id) browser_ssh_sign_request_store.discard(session_token, terminal_id=terminal_id) + sftp_upload_ticket_store.discard(session_token, terminal_id=terminal_id) + sftp_download_ticket_store.discard(session_token, terminal_id=terminal_id) close_bridge(bridge) def close_terminal_bridge(session_token, terminal_id): @@ -4978,6 +5128,8 @@ def close_terminal_bridge(session_token, terminal_id): agent_viewport_snapshot_store.discard(session_token, terminal_id=terminal_id) agent_viewport_render_request_store.discard(session_token, terminal_id=terminal_id) browser_ssh_sign_request_store.discard(session_token, terminal_id=terminal_id) + sftp_upload_ticket_store.discard(session_token, terminal_id=terminal_id) + sftp_download_ticket_store.discard(session_token, terminal_id=terminal_id) close_bridge(pop_bridge(session_token, terminal_id)) def close_all_terminal_bridges(session_token): @@ -4992,6 +5144,8 @@ def close_all_terminal_bridges(session_token): agent_viewport_snapshot_store.discard(session_token) agent_viewport_render_request_store.discard(session_token) browser_ssh_sign_request_store.discard(session_token) + sftp_upload_ticket_store.discard(session_token) + sftp_download_ticket_store.discard(session_token) terminals = bridges.pop(session_token, {}) for bridge in list(terminals.values()): close_bridge(bridge) @@ -6258,6 +6412,164 @@ def download_ca(): ) return add_common_headers(response) + +@app.route('/sftp/upload/', methods=['POST']) +def upload_sftp_file(ticket): + session_token = get_request_session_token() + if not session_token: + return add_common_headers(jsonify({ + 'status': 'failed', + 'error_code': 'session_required', + 'message': 'Session expired. Enter the access token again.', + })), 403 + record, error_code = sftp_upload_ticket_store.consume(ticket, session_token) + if not record: + status_code = 410 if error_code == 'sftp_upload_ticket_expired' else 404 + return add_common_headers(jsonify({ + 'status': 'failed', + 'error_code': error_code, + 'message': 'The SFTP upload request is invalid or expired.', + })), status_code + bridge = record['bridge'] + if ( + get_bridge(session_token, record['terminal_id']) is not bridge + or socket_session_tokens.get(record['sid']) != session_token + or not is_terminal_bridge_allowed_for_sid(bridge, record['sid']) + ): + return add_common_headers(jsonify({ + 'status': 'failed', + 'error_code': 'sftp_upload_not_authorized', + 'message': 'The SSH session is no longer available for this upload.', + })), 403 + content_length = request.content_length + if content_length is None: + return add_common_headers(jsonify({ + 'status': 'failed', + 'error_code': 'sftp_upload_length_required', + 'message': 'Upload size is required.', + })), 411 + if content_length != record['expected_size']: + return add_common_headers(jsonify({ + 'status': 'failed', + 'error_code': 'sftp_upload_size_changed', + 'message': 'The selected file size changed before upload started.', + })), 400 + if content_length > SFTP_MAX_UPLOAD_BYTES: + return add_common_headers(jsonify({ + 'status': 'failed', + 'error_code': 'sftp_upload_too_large', + 'message': 'The selected file exceeds the configured upload limit.', + })), 413 + try: + result = bridge.upload_sftp_stream( + request.stream, + record['upload'], + record['expected_size'], + ) + except SFTPTransferError as exc: + status_code = 409 if exc.error_code in { + 'sftp_atomic_replace_unavailable', + 'sftp_destination_changed', + 'sftp_destination_not_file', + 'sftp_destination_symlink', + } else 502 + return add_common_headers(jsonify({ + 'status': 'failed', + 'error_code': exc.error_code, + 'message': str(exc), + })), status_code + return add_common_headers(jsonify({ + 'status': 'completed', + 'upload_id': record['upload_id'], + **result, + })) + + +@app.route('/sftp/download/', methods=['GET']) +def download_sftp_file(ticket): + session_token = get_request_session_token() + if not session_token: + log_message('[sftp] Download rejected: session_required') + return add_common_headers(jsonify({ + 'status': 'failed', + 'error_code': 'session_required', + 'message': 'Session expired. Enter the access token again.', + })), 403 + record, error_code = sftp_download_ticket_store.consume(ticket, session_token) + if not record: + log_message(f'[sftp] Download rejected: {error_code}') + status_code = 410 if error_code == 'sftp_download_ticket_expired' else 404 + return add_common_headers(jsonify({ + 'status': 'failed', + 'error_code': error_code, + 'message': 'The SFTP download request is invalid or expired.', + })), status_code + bridge = record['bridge'] + if ( + get_bridge(session_token, record['terminal_id']) is not bridge + or socket_session_tokens.get(record['sid']) != session_token + or not is_terminal_bridge_allowed_for_sid(bridge, record['sid']) + ): + log_message('[sftp] Download rejected: sftp_download_not_authorized') + return add_common_headers(jsonify({ + 'status': 'failed', + 'error_code': 'sftp_download_not_authorized', + 'message': 'The SSH session is no longer available for this download.', + })), 403 + file_snapshot = record['file'] + filename = file_snapshot['filename'] + terminal_id = record['terminal_id'] + download_id = record['download_id'] + expected_size = file_snapshot['size'] + log_message( + f'[sftp] Download accepted: download_id={download_id} method={request.method} ' + f'terminal={terminal_id} expected_bytes={expected_size}' + ) + + def stream_download(): + bytes_sent = 0 + try: + for chunk in bridge.download_sftp_chunks(file_snapshot): + bytes_sent += len(chunk) + yield chunk + except GeneratorExit: + log_message( + f'[sftp] Download interrupted: download_id={download_id} terminal={terminal_id} ' + f'bytes_sent={bytes_sent} expected_bytes={expected_size}' + ) + raise + except SFTPTransferError as exc: + log_message( + f'[sftp] Download stream failed: download_id={download_id} terminal={terminal_id} ' + f'error_code={exc.error_code} bytes_sent={bytes_sent} ' + f'expected_bytes={expected_size}' + ) + raise + except Exception as exc: + log_message( + f'[sftp] Download stream failed: download_id={download_id} terminal={terminal_id} ' + f'error={type(exc).__name__} bytes_sent={bytes_sent} ' + f'expected_bytes={expected_size}' + ) + raise + else: + log_message( + f'[sftp] Download completed: download_id={download_id} terminal={terminal_id} ' + f'bytes_sent={bytes_sent}' + ) + + fallback_filename = re.sub(r'[^A-Za-z0-9._ -]', '_', filename).strip() or 'download' + encoded_filename = urllib.parse.quote(filename, safe='') + response = Response( + stream_with_context(stream_download()), + mimetype='application/octet-stream', + ) + response.headers['Content-Length'] = str(file_snapshot['size']) + response.headers['Content-Disposition'] = ( + f'attachment; filename="{fallback_filename}"; filename*=UTF-8\'\'{encoded_filename}' + ) + return add_common_headers(response) + @socketio.on('connect') def on_connect(): ensure_session_cleanup_task() @@ -7966,6 +8278,281 @@ def on_ssh_browser_sign_response(data): ) +def emit_sftp_result(event_name, sid, request_id, terminal_id, *, result=None, error=None): + payload = { + 'request_id': request_id, + 'terminal_id': terminal_id, + } + if error: + payload.update({ + 'status': 'failed', + 'error_code': error.error_code if isinstance(error, SFTPTransferError) else 'sftp_failed', + 'message': str(error), + }) + elif result: + payload.update(result) + socketio.emit(event_name, payload, room=sid) + + +@socketio.on(SFTP_BROWSE_REQUEST_EVENT) +def on_sftp_browse_request(data): + session_token = socket_session_tokens.get(request.sid) + terminal_id = validate_terminal_id_payload(data) + request_id = data.get('request_id') if isinstance(data, dict) else None + if not session_token or not terminal_id or not isinstance(request_id, str) or len(request_id) > 128: + return + bridge = get_allowed_bridge(session_token, terminal_id, request.sid, emit_error=True) + if not isinstance(bridge, SSHBridge): + emit_sftp_result( + SFTP_BROWSE_RESULT_EVENT, + request.sid, + request_id, + terminal_id, + error=SFTPTransferError('sftp_not_ssh', 'SFTP is only available for a connected SSH terminal.'), + ) + return + path = data.get('path') + child = data.get('child') + parent = data.get('parent') is True + if path is not None and not isinstance(path, str): + return + if child is not None and not isinstance(child, str): + return + try: + result = bridge.browse_sftp(path, child=child, parent=parent) + result.update({ + 'status': 'ready', + 'max_upload_bytes': SFTP_MAX_UPLOAD_BYTES, + }) + emit_sftp_result( + SFTP_BROWSE_RESULT_EVENT, + request.sid, + request_id, + terminal_id, + result=result, + ) + except SFTPTransferError as exc: + emit_sftp_result( + SFTP_BROWSE_RESULT_EVENT, + request.sid, + request_id, + terminal_id, + error=exc, + ) + + +@socketio.on(SFTP_UPLOAD_TICKET_REQUEST_EVENT) +def on_sftp_upload_ticket_request(data): + session_token = socket_session_tokens.get(request.sid) + terminal_id = validate_terminal_id_payload(data) + request_id = data.get('request_id') if isinstance(data, dict) else None + if not session_token or not terminal_id or not isinstance(request_id, str) or len(request_id) > 128: + return + bridge = get_allowed_bridge(session_token, terminal_id, request.sid, emit_error=True) + if not isinstance(bridge, SSHBridge): + emit_sftp_result( + SFTP_UPLOAD_TICKET_RESULT_EVENT, + request.sid, + request_id, + terminal_id, + error=SFTPTransferError('sftp_not_ssh', 'SFTP is only available for a connected SSH terminal.'), + ) + return + directory = data.get('directory') + filename = data.get('filename') + conflict_mode = data.get('conflict_mode', 'ask') + size = data.get('size') + if not isinstance(directory, str) or not isinstance(filename, str) or isinstance(size, bool): + return + try: + size = int(size) + except (TypeError, ValueError): + return + if size < 0 or size > SFTP_MAX_UPLOAD_BYTES: + emit_sftp_result( + SFTP_UPLOAD_TICKET_RESULT_EVENT, + request.sid, + request_id, + terminal_id, + error=SFTPTransferError('sftp_upload_too_large', 'The selected file exceeds the configured upload limit.'), + ) + return + try: + upload = bridge.prepare_sftp_upload(directory, filename, conflict_mode) + if upload['status'] == 'conflict': + emit_sftp_result( + SFTP_UPLOAD_TICKET_RESULT_EVENT, + request.sid, + request_id, + terminal_id, + result=upload, + ) + return + ticket, record = sftp_upload_ticket_store.create( + session_token, + terminal_id, + request.sid, + bridge, + upload, + size, + ) + emit_sftp_result( + SFTP_UPLOAD_TICKET_RESULT_EVENT, + request.sid, + request_id, + terminal_id, + result={ + 'status': 'ready', + 'upload_id': record['upload_id'], + 'upload_url': f'/sftp/upload/{ticket}', + 'destination_path': upload['destination_path'], + 'filename': upload['filename'], + 'endpoint': upload['endpoint'], + 'expires_in_seconds': SFTP_UPLOAD_TICKET_TTL_SECONDS, + }, + ) + except SFTPTransferError as exc: + emit_sftp_result( + SFTP_UPLOAD_TICKET_RESULT_EVENT, + request.sid, + request_id, + terminal_id, + error=exc, + ) + + +def parse_sftp_file_reference_id(data): + if not isinstance(data, dict) or not isinstance(data.get('file_id'), str) or len(data['file_id']) > 128: + raise SFTPTransferError('sftp_invalid_file_request', 'Remote file request is invalid.') + return data['file_id'] + + +@socketio.on(SFTP_DOWNLOAD_TICKET_REQUEST_EVENT) +def on_sftp_download_ticket_request(data): + session_token = socket_session_tokens.get(request.sid) + terminal_id = validate_terminal_id_payload(data) + request_id = data.get('request_id') if isinstance(data, dict) else None + if not session_token or not terminal_id or not isinstance(request_id, str) or len(request_id) > 128: + return + bridge = get_allowed_bridge(session_token, terminal_id, request.sid, emit_error=True) + if not isinstance(bridge, SSHBridge): + emit_sftp_result( + SFTP_DOWNLOAD_TICKET_RESULT_EVENT, + request.sid, + request_id, + terminal_id, + error=SFTPTransferError('sftp_not_ssh', 'SFTP is only available for a connected SSH terminal.'), + ) + return + try: + file_snapshot = bridge.resolve_sftp_file_reference(parse_sftp_file_reference_id(data)) + current_file = bridge.prepare_sftp_file(file_snapshot['directory'], file_snapshot['filename']) + if current_file['size'] != file_snapshot['size'] or current_file['mtime'] != file_snapshot['mtime']: + raise SFTPTransferError('sftp_file_changed', 'The remote file changed after the directory was listed.') + ticket, record = sftp_download_ticket_store.create( + session_token, + terminal_id, + request.sid, + bridge, + file_snapshot, + ) + log_message( + f'[sftp] Download ticket ready: download_id={record["download_id"]} terminal={terminal_id} ' + f'expected_bytes={file_snapshot["size"]}' + ) + emit_sftp_result( + SFTP_DOWNLOAD_TICKET_RESULT_EVENT, + request.sid, + request_id, + terminal_id, + result={ + 'status': 'ready', + 'download_url': f'/sftp/download/{ticket}', + 'download_id': record['download_id'], + 'path': file_snapshot['path'], + 'filename': file_snapshot['filename'], + 'size': file_snapshot['size'], + 'expires_in_seconds': SFTP_DOWNLOAD_TICKET_TTL_SECONDS, + 'endpoint': record['file']['endpoint'], + }, + ) + except SFTPTransferError as exc: + log_message( + f'[sftp] Download ticket failed: terminal={terminal_id} ' + f'error_code={exc.error_code}' + ) + emit_sftp_result( + SFTP_DOWNLOAD_TICKET_RESULT_EVENT, + request.sid, + request_id, + terminal_id, + error=exc, + ) + + +@socketio.on(SFTP_FILE_ACTION_REQUEST_EVENT) +def on_sftp_file_action_request(data): + session_token = socket_session_tokens.get(request.sid) + terminal_id = validate_terminal_id_payload(data) + request_id = data.get('request_id') if isinstance(data, dict) else None + if not session_token or not terminal_id or not isinstance(request_id, str) or len(request_id) > 128: + return + bridge = get_allowed_bridge(session_token, terminal_id, request.sid, emit_error=True) + if not isinstance(bridge, SSHBridge): + emit_sftp_result( + SFTP_FILE_ACTION_RESULT_EVENT, + request.sid, + request_id, + terminal_id, + error=SFTPTransferError('sftp_not_ssh', 'SFTP is only available for a connected SSH terminal.'), + ) + return + action = data.get('action') if isinstance(data, dict) else None + if action not in {'rename', 'delete'}: + emit_sftp_result( + SFTP_FILE_ACTION_RESULT_EVENT, + request.sid, + request_id, + terminal_id, + error=SFTPTransferError('sftp_invalid_file_action', 'Remote file action is invalid.'), + ) + return + try: + file_snapshot = bridge.resolve_sftp_file_reference(parse_sftp_file_reference_id(data)) + if action == 'rename': + result = bridge.rename_sftp_file( + file_snapshot['directory'], + file_snapshot['filename'], + data.get('new_filename'), + file_snapshot['size'], + file_snapshot['mtime'], + ) + else: + if data.get('delete_confirmation') != 'permanent_delete_confirmed': + raise SFTPTransferError('sftp_delete_confirmation_required', 'Permanent deletion was not confirmed.') + result = bridge.delete_sftp_file( + file_snapshot['directory'], + file_snapshot['filename'], + file_snapshot['size'], + file_snapshot['mtime'], + ) + emit_sftp_result( + SFTP_FILE_ACTION_RESULT_EVENT, + request.sid, + request_id, + terminal_id, + result=result, + ) + except SFTPTransferError as exc: + emit_sftp_result( + SFTP_FILE_ACTION_RESULT_EVENT, + request.sid, + request_id, + terminal_id, + error=exc, + ) + + @socketio.on('start_ssh') def on_start_ssh(data): cleanup_expired_sessions() @@ -8137,6 +8724,8 @@ def on_disconnect(reason=None): agent_viewer_ids.pop(request.sid, None) if session_token: browser_ssh_sign_request_store.discard(session_token, sid=request.sid) + sftp_upload_ticket_store.discard(session_token, sid=request.sid) + sftp_download_ticket_store.discard(session_token, sid=request.sid) with agent_lock: for state in [ state for state in agent_states.values() diff --git a/templates/index.html b/templates/index.html index 4e37740..bb4cabc 100644 --- a/templates/index.html +++ b/templates/index.html @@ -33,6 +33,11 @@ .status-action:hover { color: #fff; border-color: #0a84ff; background: #252b33; } .status-action:disabled { opacity: 0.28; cursor: default; border-color: #262626; background: transparent; color: #777; } .status-action:disabled:hover { border-color: #262626; background: transparent; color: #777; } + .status-sftp { min-width: 28px; padding: 0 4px; display: inline-flex; align-items: center; justify-content: center; } + .status-sftp[hidden] { display: none; } + .status-sftp.unavailable:disabled { opacity: 1; } + .sftp-icon-muted { filter: grayscale(1); opacity: 0.45; } + .sftp-unavailable-mark { color: #ff453a; font-weight: 800; margin-left: 1px; } #agent-pause-btn { display: none; margin-left: auto; border-color: #7a1f1f; background: #3a1212; color: #ff6b6b; font-weight: bold; } #agent-pause-btn:hover { border-color: #ff453a; background: #4a1616; color: #fff; } #agent-pause-btn.visible { display: inline-flex; align-items: center; justify-content: center; } @@ -105,8 +110,111 @@ #context-menu .menu-item { padding: 8px 16px; cursor: pointer; } #context-menu .menu-item:hover { background: #0a84ff; } #context-menu .menu-item.hidden { display: none; } + #context-menu .menu-item.disabled { color: #777; cursor: default; } + #context-menu .menu-item.disabled:hover { background: transparent; } #context-menu .menu-separator { height: 1px; background: #444; margin: 4px 0; } + .sftp-pip-shell { + flex: 1; min-height: 0; box-sizing: border-box; display: flex; flex-direction: column; + gap: 10px; padding: 12px; overflow: hidden; background: #171719; color: #ddd; + font: 12px/1.4 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + } + .sftp-pip-header { display: flex; align-items: center; gap: 8px; } + .sftp-pip-title { flex: 1; color: #fff; font-size: 14px; font-weight: 700; } + .sftp-pip-close { + width: 26px; height: 26px; padding: 0; border: 1px solid #444; border-radius: 5px; + background: #252527; color: #ddd; cursor: pointer; font-size: 17px; line-height: 1; + } + .sftp-pip-close:hover { border-color: #ff453a; color: #fff; } + .sftp-endpoint-card { + padding: 8px 10px; border: 1px solid #3a3a3c; border-radius: 6px; background: #202022; + } + .sftp-endpoint-value { color: #fff; font: 12px/1.35 ui-monospace, SFMono-Regular, Consolas, monospace; overflow-wrap: anywhere; } + .sftp-direct-hint { margin-top: 3px; color: #ffcc00; font-size: 11px; } + .sftp-path-controls { display: grid; grid-template-columns: auto auto minmax(0, 1fr) auto; gap: 6px; } + .sftp-path-controls button, .sftp-pip-actions button, .sftp-conflict-actions button, + .sftp-file-operation-actions button, .sftp-rename-controls button, .sftp-delete-actions button { + margin: 0; padding: 7px 10px; border: 1px solid #444; border-radius: 5px; + background: #2c2c2e; color: #ddd; cursor: pointer; font-size: 12px; + } + .sftp-path-controls button:hover, .sftp-pip-actions button:hover, .sftp-conflict-actions button:hover, + .sftp-file-operation-actions button:hover, .sftp-rename-controls button:hover, + .sftp-delete-actions button:hover { border-color: #0a84ff; color: #fff; } + .sftp-path-controls button:focus-visible, .sftp-pip-actions button:focus-visible, + .sftp-file-operation-actions button:focus-visible, .sftp-rename-controls button:focus-visible, + .sftp-delete-actions button:focus-visible, + .sftp-directory-entry:focus-visible { outline: 2px solid #72b5e5; outline-offset: -2px; } + .sftp-path-controls button:disabled, .sftp-pip-actions button:disabled, + .sftp-file-operation-actions button:disabled, .sftp-rename-controls button:disabled, + .sftp-delete-actions button:disabled { opacity: 0.45; cursor: default; } + .sftp-path-input { + min-width: 0; box-sizing: border-box; padding: 7px 8px; border: 1px solid #444; + border-radius: 5px; background: #101011; color: #fff; + font: 12px/1.35 ui-monospace, SFMono-Regular, Consolas, monospace; + } + .sftp-directory-list { + flex: 1; min-height: 90px; overflow: auto; border: 1px solid #343437; + border-radius: 6px; background: #101011; + } + .sftp-directory-entry { + display: block; width: 100%; box-sizing: border-box; margin: 0; padding: 7px 10px; + border: 0; border-bottom: 1px solid #242426; background: transparent; color: #ddd; + cursor: pointer; text-align: left; font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + } + .sftp-directory-entry:hover { background: #183452; color: #fff; } + .sftp-file-entry { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 10px; } + .sftp-file-entry-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .sftp-file-entry-size { color: #888; white-space: nowrap; } + .sftp-directory-list.busy .sftp-directory-entry { pointer-events: none; opacity: 0.45; } + .sftp-directory-empty { padding: 12px; color: #777; text-align: center; } + .sftp-file-operation-box { + display: none; padding: 9px; border: 1px solid #3a3a3c; border-radius: 6px; + background: #202022; color: #ddd; + } + .sftp-file-operation-box.visible { display: block; } + .sftp-file-operation-path { + margin: 4px 0 8px; color: #fff; overflow-wrap: anywhere; + font: 12px/1.35 ui-monospace, SFMono-Regular, Consolas, monospace; + } + .sftp-file-operation-actions { display: flex; justify-content: flex-end; gap: 7px; } + .sftp-file-operation-actions .danger, .sftp-delete-actions .danger { border-color: #8f2d2d; color: #ff8a83; } + .sftp-rename-controls { display: none; grid-template-columns: minmax(0, 1fr) auto auto; gap: 7px; } + .sftp-rename-controls.visible { display: grid; } + .sftp-rename-input { + min-width: 0; box-sizing: border-box; padding: 7px 8px; border: 1px solid #555; + border-radius: 5px; background: #101011; color: #fff; + font: 12px/1.35 ui-monospace, SFMono-Regular, Consolas, monospace; + } + .sftp-delete-confirmation { + display: none; padding: 10px; border: 1px solid #8f2d2d; border-radius: 6px; + background: #2c1717; color: #ffd5d2; + } + .sftp-delete-confirmation.visible { display: block; } + .sftp-delete-confirmation [hidden] { display: none; } + .sftp-delete-question { color: #fff; font-weight: 700; } + .sftp-delete-actions { display: flex; gap: 8px; margin-top: 10px; } + .sftp-delete-actions.phase-one { justify-content: flex-start; padding-right: 42%; } + .sftp-delete-actions.phase-two { justify-content: flex-end; padding-left: 34%; } + .sftp-drop-zone { + padding: 16px 12px; border: 1px dashed #4f83aa; border-radius: 7px; background: #173b5a; + color: #f4f7fa; cursor: pointer; text-align: center; + } + .sftp-drop-zone:hover, .sftp-drop-zone:focus-visible { border-color: #72b5e5; background: #1b496f; outline: none; } + .sftp-drop-zone.dragging { border-color: #8dceff; background: #205a86; color: #fff; } + .sftp-selected-file { min-height: 17px; color: #bbb; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .sftp-transfer-status { min-height: 18px; color: #aaa; overflow-wrap: anywhere; } + .sftp-transfer-status.error { color: #ff6b61; } + .sftp-transfer-status.success { color: #34c759; } + .sftp-progress { display: none; width: 100%; height: 6px; overflow: hidden; border-radius: 3px; background: #333; } + .sftp-progress.visible { display: block; } + .sftp-progress-bar { width: 0; height: 100%; background: #0a84ff; transition: width 0.1s linear; } + .sftp-pip-actions, .sftp-conflict-actions { display: flex; justify-content: flex-end; gap: 7px; } + .sftp-pip-actions .primary { border-color: #0a84ff; background: #0a84ff; color: #fff; font-weight: 700; } + .sftp-conflict-box { display: none; padding: 9px; border: 1px solid #7a5b12; border-radius: 6px; background: #2b2515; color: #ffdd83; } + .sftp-conflict-box.visible { display: block; } + .sftp-conflict-actions { margin-top: 8px; } + .sftp-conflict-actions .replace { border-color: #ff453a; color: #ff8a83; } + /* Settings Modal */ #settings-modal { display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; @@ -606,6 +714,7 @@ + @@ -935,6 +1044,7 @@

Manual browser authorization

+
⚙️
@@ -1811,6 +1921,7 @@

Access token required

const agentToggleBtn = document.getElementById('agent-toggle-btn'); const newTabBtn = document.getElementById('new-tab-btn'); const closeTabsBtn = document.getElementById('close-tabs-btn'); + const sftpStatusBtn = document.getElementById('sftp-status-btn'); const sessionRecoveryModal = document.getElementById('session-recovery-modal'); const sessionRecoveryForm = document.getElementById('session-recovery-form'); const sessionRecoveryToken = document.getElementById('session-recovery-token'); @@ -1879,6 +1990,8 @@

Access token required

let agentPanelDrag = null; let agentExternalTokenCountdownTimer = null; let pipTerminalState = null; + let sftpPipState = null; + let contextMenuTerminalId = null; let pendingPasteReview = null; let serverSettingsSnapshot = null; let currentSettingsAdminGrant = null; @@ -2785,6 +2898,7 @@

Access token required

agentProviderRunBtn.disabled = agentMockInput.disabled; updateAgentExternalUi(usable, mode, agent, state); updateAgentStatusMintButtons(); + updatePipStatus(pipTerminalState); renderAgentGateState(state); renderAgentStatusPanel(state); renderAgentActionPanel(state); @@ -4961,6 +5075,40 @@

Access token required

closeTabsBtn.disabled = !(socket && socket.connected && hasCloseableTerminal); } + function canUseSftpFileManager(state) { + return !!( + state + && state.connected + && state.connectionType === 'ssh' + && state.sftpAvailable !== false + ); + } + + function shouldShowSftpAction(state) { + return !!(state && state.connected && state.connectionType === 'ssh'); + } + + function updateSftpActionButton(button, state) { + if (!button) return; + const visible = shouldShowSftpAction(state); + const available = canUseSftpFileManager(state); + button.hidden = !visible; + button.disabled = visible && !available; + button.classList.toggle('unavailable', visible && !available); + const icon = button.ownerDocument.createElement('span'); + icon.innerText = '\u{1F4C1}'; + icon.className = available ? '' : 'sftp-icon-muted'; + button.replaceChildren(icon); + if (!available) { + const unavailableMark = button.ownerDocument.createElement('span'); + unavailableMark.className = 'sftp-unavailable-mark'; + unavailableMark.innerText = '\u00D7'; + button.appendChild(unavailableMark); + } + button.title = available ? 'Open SFTP File Manager' : 'SFTP not available'; + button.setAttribute('aria-label', button.title); + } + function updateActiveTerminalUi() { const state = getActiveTerminalState(); updateTabBarVisibility(); @@ -4969,6 +5117,7 @@

Access token required

updateAgentPauseButton(); updateAgentPanel(); updateTerminalApplicationTitleStatus(state); + updateSftpActionButton(sftpStatusBtn, state); if (!state) return; sshStatusEl.innerText = state.connected ? getTerminalDisplayLabel(state) : (state.connecting ? 'Connecting' : 'Disconnected'); sshStatusEl.style.color = state.connected ? '#34c759' : (state.connecting ? '#ffcc00' : '#ff9f0a'); @@ -5101,13 +5250,20 @@

Access token required

fitAddon, webglAddon: null, webLinksAddon: null, + sftpAvailable: null, + sftpEndpoint: null, inPip: false, pipWindow: null, pipBar: null, pipTitleEl: null, + pipApplicationTitleItem: null, + pipApplicationTitleEl: null, pipSizeEl: null, pipAgentBtn: null, pipAgentPauseBtn: null, + pipAgentMintBtn: null, + pipAgentMint3xBtn: null, + pipSftpBtn: null, pipTerminalHost: null, pipReturnNextSibling: null, pipPagehideHandler: null, @@ -5121,6 +5277,7 @@

Access token required

if (title === state.applicationTitle) return; state.applicationTitle = title; if (state.id === activeTerminalId) updateTerminalApplicationTitleStatus(state); + updatePipStatus(state); }); configureWebLinks(state); syncAgentTerminalMirrorSize(state); @@ -5343,6 +5500,9 @@

Access token required

getActiveAgentState() { return serializeAgentForTest(getActiveTerminalState()); }, + getAgentStateForTest(terminalId) { + return serializeAgentForTest(terminals.get(terminalId)); + }, activeTerminalHasFocus() { const state = getActiveTerminalState(); return !!(state && state.term && state.term.textarea === document.activeElement); @@ -5428,6 +5588,44 @@

Access token required

canMoveActiveTerminalToPip() { return canMoveActiveTerminalToPip(); }, + showContextMenuForTest(terminalId) { + const state = terminals.get(terminalId); + if (!state) return null; + showContextMenu(10, 10, state.tabEl); + return cloneForTest({ + terminalId: contextMenuTerminalId, + sftpVisible: !sftpSendOption.classList.contains('hidden'), + sftpDisabled: sftpSendOption.classList.contains('disabled'), + sftpText: sftpSendOption.textContent, + }); + }, + setSftpAvailabilityForTest(terminalId, available) { + const state = terminals.get(terminalId); + if (!state) return false; + state.sftpAvailable = available === null ? null : !!available; + updateActiveTerminalUi(); + return true; + }, + renderSftpEntriesForTest(payload) { + if (!sftpPipState || !sftpPipState.elements || !payload) return false; + sftpPipState.currentPath = typeof payload.path === 'string' ? payload.path : '/home/tester'; + sftpPipState.elements.path.value = sftpPipState.currentPath; + renderSftpEntries( + sftpPipState, + Array.isArray(payload.directories) ? payload.directories : [], + Array.isArray(payload.files) ? payload.files : [], + false, + ); + setSftpPipBusy(sftpPipState, false); + return true; + }, + startSftpBrowserDownloadForTest(payload) { + return startSftpBrowserDownload(cloneForTest(payload)); + }, + handleSftpDownloadTicketResultForTest(payload) { + handleSftpDownloadTicketResult(cloneForTest(payload)); + return true; + }, setTerminalPipModeForTest(terminalId, inPip) { const state = terminals.get(terminalId); if (!state) return false; @@ -5861,6 +6059,10 @@

Access token required

} } socket.on('ssh_browser_sign_request', handleBrowserSshSignRequest); + socket.on('sftp_browse_result', handleSftpBrowseResult); + socket.on('sftp_upload_ticket_result', handleSftpUploadTicketResult); + socket.on('sftp_download_ticket_result', handleSftpDownloadTicketResult); + socket.on('sftp_file_action_result', handleSftpFileActionResult); socket.on('browser_pairing_file', data => { clearBrowserPairingResponseTimer(); setBrowserAuthBusy(false); @@ -6075,6 +6277,10 @@

Access token required

hideSessionRecovery(); if (socket.connected) { socket.disconnect(); + setTimeout(() => { + if (!socket.connected) socket.connect(); + }, 0); + return; } socket.connect(); } @@ -6756,6 +6962,8 @@

Access token required

state.connecting = false; state.error = false; state.connectionType = normalizeConnectionType(data.connection_type); + state.sftpAvailable = null; + state.sftpEndpoint = null; state.label = successfulSshDraft && successfulSshDraft.profileName ? getSshProfileTabLabel(successfulSshDraft.profileName) : (data.terminal_label || CONNECTION_LABELS[state.connectionType] || 'Terminal'); @@ -6784,6 +6992,8 @@

Access token required

state.connecting = false; state.error = false; state.connectionType = null; + state.sftpAvailable = null; + state.sftpEndpoint = null; state.connectedAt = null; resetAgentClientState(state); if (state.id === activeTerminalId) { @@ -6810,11 +7020,887 @@

Access token required

} socket.on('ssh_output', handleSshOutputPayload); + function formatSftpEndpoint(endpoint) { + if (!endpoint || typeof endpoint !== 'object') return 'Direct SSH endpoint'; + const user = typeof endpoint.user === 'string' ? endpoint.user : ''; + const host = typeof endpoint.host === 'string' ? endpoint.host : ''; + const port = Number.isFinite(Number(endpoint.port)) ? Number(endpoint.port) : 22; + return `${user ? `${user}@` : ''}${host || 'unknown'}:${port}`; + } + + function formatSftpBytes(value) { + const bytes = Math.max(0, Number(value) || 0); + if (bytes < 1024) return `${bytes} B`; + const units = ['KiB', 'MiB', 'GiB', 'TiB']; + let size = bytes / 1024; + let unit = units[0]; + for (let index = 1; index < units.length && size >= 1024; index += 1) { + size /= 1024; + unit = units[index]; + } + return `${size.toFixed(size >= 10 ? 1 : 2)} ${unit}`; + } + + function nextSftpRequestId(prefix) { + if (window.crypto && crypto.randomUUID) return `${prefix}-${crypto.randomUUID()}`; + return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}`; + } + + function setSftpPipStatus(state, message, kind = '') { + if (!state || !state.elements || !state.elements.status) return; + state.elements.status.textContent = message || ''; + state.elements.status.className = `sftp-transfer-status${kind ? ` ${kind}` : ''}`; + } + + function logSftpBrowserDownload(message, details = {}, level = 'info') { + const logger = level === 'warn' ? console.warn : console.info; + logger(`[sftp] Download ${message}`, details); + } + + function updateSftpDownloadAvailability(state) { + if (!state || !state.elements || !state.elements.fileDownload) return; + const file = state.selectedRemoteFile; + const prepared = state.preparedDownload; + const ready = !!( + file + && prepared + && prepared.fileId === file.fileId + && prepared.expiresAt > Date.now() + ); + state.elements.fileDownload.disabled = !!( + state.busy + || state.uploading + || state.downloadPreparing + || !ready + ); + state.elements.fileDownload.textContent = state.downloadPreparing + ? 'Preparing…' + : (state.downloadConsumed ? 'Downloaded' : 'Download'); + } + + function setSftpPipBusy(state, busy) { + if (!state || !state.elements) return; + state.busy = !!busy; + const uploading = !!state.uploading; + state.elements.home.disabled = busy || uploading; + state.elements.up.disabled = busy || uploading || !state.currentPath; + state.elements.go.disabled = busy || uploading; + state.elements.path.disabled = busy || uploading; + state.elements.send.disabled = busy || uploading || !state.file || !state.currentPath; + state.elements.close.disabled = busy || uploading; + state.elements.conflictCancel.disabled = busy || uploading; + state.elements.conflictKeep.disabled = busy || uploading; + state.elements.conflictReplace.disabled = busy || uploading; + state.elements.drop.classList.toggle('disabled', uploading); + state.elements.directoryList.classList.toggle('busy', busy || uploading); + state.elements.directoryList.querySelectorAll('button').forEach(button => { + button.disabled = busy || uploading; + }); + [state.elements.fileRename, state.elements.fileDelete].forEach(button => { + button.disabled = busy || uploading; + }); + [state.elements.deleteYes, state.elements.deleteNo, state.elements.deleteSure, state.elements.deleteDont].forEach(button => { + button.disabled = busy || uploading; + }); + updateSftpDownloadAvailability(state); + updateSftpRenameAvailability(state); + } + + function updateSftpSelectedFile(state) { + if (!state || !state.elements) return; + state.elements.selectedFile.textContent = state.file + ? `${state.file.name} · ${formatSftpBytes(state.file.size)}` + : 'No file selected.'; + setSftpPipBusy(state, state.busy); + } + + function selectSftpFile(state, files) { + if (!state || state.uploading) return; + const selected = files && files.length ? files[0] : null; + if (!selected) return; + if (files.length > 1) { + setSftpPipStatus(state, 'Select one file at a time.', 'error'); + return; + } + if (state.maxUploadBytes && selected.size > state.maxUploadBytes) { + setSftpPipStatus( + state, + `The file exceeds the ${formatSftpBytes(state.maxUploadBytes)} upload limit.`, + 'error', + ); + return; + } + state.file = selected; + state.elements.conflict.classList.remove('visible'); + setSftpPipStatus(state, 'Ready to upload.'); + updateSftpSelectedFile(state); + } + + function getSftpRemoteFilePath(state, file) { + if (!state || !state.currentPath || !file) return ''; + return `${state.currentPath.endsWith('/') ? state.currentPath : `${state.currentPath}/`}${file.name}`; + } + + function closeSftpFileOperation(state, restoreFocus = true) { + if (!state || !state.elements) return; + state.elements.fileOperation.classList.remove('visible'); + state.elements.fileOperationActions.hidden = false; + state.elements.renameControls.classList.remove('visible'); + state.elements.deleteConfirmation.classList.remove('visible'); + state.elements.renameHint.textContent = ''; + state.deleteConfirmationPhase = 0; + state.pendingDownloadTicketRequestId = null; + state.pendingDownloadFileId = null; + state.downloadPreparing = false; + state.preparedDownload = null; + state.downloadConsumed = false; + updateSftpDownloadAvailability(state); + const focusTarget = state.selectedRemoteFileButton; + state.selectedRemoteFile = null; + state.selectedRemoteFileButton = null; + if (restoreFocus && focusTarget && focusTarget.isConnected) focusTarget.focus(); + } + + function showSftpFileOperation(state, file, sourceButton) { + if (!state || !state.elements || state.busy || state.uploading) return; + state.selectedRemoteFile = file; + state.selectedRemoteFileButton = sourceButton; + state.elements.fileOperationPath.textContent = getSftpRemoteFilePath(state, file); + state.elements.fileOperation.classList.add('visible'); + state.elements.fileOperationActions.hidden = false; + state.elements.renameControls.classList.remove('visible'); + state.elements.deleteConfirmation.classList.remove('visible'); + state.preparedDownload = null; + state.downloadConsumed = false; + prepareSftpDownloadTicket(state, file); + state.elements.fileRename.focus(); + } + + function getSftpRenameValidation(state) { + if (!state || !state.selectedRemoteFile || !state.elements) return 'Select a file.'; + const value = state.elements.renameInput.value; + if (!value) return 'Enter a file name.'; + if (value === '.' || value === '..' || value.includes('/') || value.includes('\\') || /[\u0000-\u001f\u007f]/.test(value)) { + return 'Enter a file name without a path.'; + } + if (new TextEncoder().encode(value).length > 255) return 'The file name is too long.'; + if (state.currentEntryNames.has(value)) return 'A file or directory with this name already exists.'; + return ''; + } + + function updateSftpRenameAvailability(state) { + if (!state || !state.elements || !state.elements.renameConfirm) return; + const message = getSftpRenameValidation(state); + state.elements.renameHint.textContent = message; + state.elements.renameConfirm.disabled = !!message || state.busy || state.uploading; + } + + function showSftpRename(state) { + if (!state || !state.selectedRemoteFile || state.busy || state.uploading) return; + state.elements.fileOperationActions.hidden = true; + state.elements.renameControls.classList.add('visible'); + state.elements.renameInput.value = state.selectedRemoteFile.name; + updateSftpRenameAvailability(state); + state.elements.renameInput.focus(); + state.elements.renameInput.select(); + } + + function cancelSftpRename(state) { + if (!state || !state.elements) return; + state.elements.renameControls.classList.remove('visible'); + state.elements.fileOperationActions.hidden = false; + state.elements.fileRename.focus(); + } + + function showSftpDeleteConfirmation(state, phase = 1) { + if (!state || !state.selectedRemoteFile || state.busy || state.uploading) return; + const path = getSftpRemoteFilePath(state, state.selectedRemoteFile); + state.elements.fileOperation.classList.remove('visible'); + state.elements.deleteConfirmation.classList.add('visible'); + state.elements.deletePath.textContent = path; + state.elements.deletePhaseOne.forEach(element => { element.hidden = phase !== 1; }); + state.elements.deletePhaseTwo.forEach(element => { element.hidden = phase !== 2; }); + state.deleteConfirmationPhase = phase; + const safeButton = phase === 1 ? state.elements.deleteNo : state.elements.deleteDont; + requestAnimationFrame(() => safeButton.focus()); + } + + function cancelSftpDelete(state) { + if (!state || !state.elements) return; + state.elements.deleteConfirmation.classList.remove('visible'); + state.elements.fileOperation.classList.add('visible'); + state.elements.fileOperationActions.hidden = false; + state.deleteConfirmationPhase = 0; + state.elements.fileDelete.focus(); + } + + function renderSftpEntries(state, directories, files, truncated) { + if (!state || !state.elements) return; + const list = state.elements.directoryList; + closeSftpFileOperation(state, false); + list.replaceChildren(); + const safeDirectories = Array.isArray(directories) ? directories : []; + state.currentFiles = (Array.isArray(files) ? files : []).filter(file => ( + file + && typeof file.file_id === 'string' + && typeof file.name === 'string' + && Number.isFinite(Number(file.size)) + && Number.isFinite(Number(file.mtime)) + )).map(file => ({ + fileId: file.file_id, + name: file.name, + size: Number(file.size), + mtime: Number(file.mtime), + })); + state.currentEntryNames = new Set([ + ...safeDirectories.filter(entry => entry && typeof entry.name === 'string').map(entry => entry.name), + ...state.currentFiles.map(file => file.name), + ]); + if (safeDirectories.length === 0 && state.currentFiles.length === 0) { + const empty = state.window.document.createElement('div'); + empty.className = 'sftp-directory-empty'; + empty.textContent = 'No subdirectories or files.'; + list.appendChild(empty); + } else { + safeDirectories.forEach(entry => { + if (!entry || typeof entry.name !== 'string') return; + const button = state.window.document.createElement('button'); + button.type = 'button'; + button.className = 'sftp-directory-entry'; + button.textContent = `📁 ${entry.name}`; + button.title = entry.name; + button.addEventListener('click', () => requestSftpBrowse(state, { + path: state.currentPath, + child: entry.name, + })); + list.appendChild(button); + }); + state.currentFiles.forEach(file => { + const button = state.window.document.createElement('button'); + button.type = 'button'; + button.className = 'sftp-directory-entry sftp-file-entry'; + button.title = getSftpRemoteFilePath(state, file); + button.setAttribute('aria-label', `File ${file.name}, ${formatSftpBytes(file.size)}. Open file actions.`); + const name = state.window.document.createElement('span'); + name.className = 'sftp-file-entry-name'; + name.textContent = `📄 ${file.name}`; + const size = state.window.document.createElement('span'); + size.className = 'sftp-file-entry-size'; + size.textContent = formatSftpBytes(file.size); + button.append(name, size); + button.addEventListener('click', () => showSftpFileOperation(state, file, button)); + list.appendChild(button); + }); + } + if (truncated) setSftpPipStatus(state, 'Directory list was truncated.', 'error'); + } + + function requestSftpBrowse(state, options = {}) { + if (!state || state.busy || state.uploading || !socket || !socket.connected) return; + const requestId = nextSftpRequestId('sftp-browse'); + state.pendingBrowseRequestId = requestId; + state.pendingBrowseStatus = options.successMessage || null; + setSftpPipBusy(state, true); + setSftpPipStatus(state, 'Opening remote directory…'); + socket.emit('sftp_browse_request', { + request_id: requestId, + terminal_id: state.terminalId, + path: options.path === undefined ? null : options.path, + child: options.child, + parent: options.parent === true, + }); + } + + function handleSftpBrowseResult(data) { + const state = sftpPipState; + if ( + !state + || !data + || data.terminal_id !== state.terminalId + || data.request_id !== state.pendingBrowseRequestId + ) return; + state.pendingBrowseRequestId = null; + const terminalState = terminals.get(state.terminalId); + if (data.status !== 'ready') { + state.pendingBrowseStatus = null; + if (terminalState && data.error_code === 'sftp_unavailable') terminalState.sftpAvailable = false; + updateActiveTerminalUi(); + setSftpPipBusy(state, false); + setSftpPipStatus(state, data.message || 'Remote directory could not be opened.', 'error'); + return; + } + if (terminalState) { + terminalState.sftpAvailable = true; + terminalState.sftpEndpoint = data.endpoint || null; + } + updateActiveTerminalUi(); + state.currentPath = data.path; + state.endpoint = data.endpoint || state.endpoint; + state.maxUploadBytes = Number(data.max_upload_bytes) || state.maxUploadBytes; + state.elements.path.value = state.currentPath; + state.elements.endpoint.textContent = formatSftpEndpoint(state.endpoint); + renderSftpEntries(state, data.directories, data.files, data.truncated === true); + setSftpPipBusy(state, false); + const completedMessage = state.pendingBrowseStatus; + state.pendingBrowseStatus = null; + if (completedMessage) setSftpPipStatus(state, completedMessage, 'success'); + else if (!data.truncated) setSftpPipStatus(state, 'Choose a file action or drop a file to upload.'); + } + + function requestSftpUploadTicket(state, conflictMode = 'ask') { + if (!state || !state.file || !state.currentPath || state.busy || state.uploading || !socket || !socket.connected) return; + const requestId = nextSftpRequestId('sftp-upload'); + state.pendingTicketRequestId = requestId; + setSftpPipBusy(state, true); + state.elements.conflict.classList.remove('visible'); + setSftpPipStatus(state, 'Checking upload destination…'); + socket.emit('sftp_upload_ticket_request', { + request_id: requestId, + terminal_id: state.terminalId, + directory: state.elements.path.value, + filename: state.file.name, + size: state.file.size, + conflict_mode: conflictMode, + }); + } + + function startSftpUpload(state, data) { + if (!state || !state.file || typeof data.upload_url !== 'string') return; + const file = state.file; + state.uploading = true; + state.busy = false; + state.destinationPath = data.destination_path; + state.elements.progress.classList.add('visible'); + state.elements.progressBar.style.width = file.size === 0 ? '100%' : '0%'; + setSftpPipBusy(state, false); + setSftpPipStatus(state, `Sending to ${data.destination_path}…`); + + const xhr = new XMLHttpRequest(); + state.xhr = xhr; + xhr.open('POST', data.upload_url, true); + xhr.setRequestHeader('Content-Type', 'application/octet-stream'); + xhr.upload.onprogress = event => { + if (!event.lengthComputable || !state.elements) return; + const percent = event.total ? Math.min(100, event.loaded * 100 / event.total) : 100; + state.elements.progressBar.style.width = `${percent}%`; + setSftpPipStatus( + state, + `Sending ${formatSftpBytes(event.loaded)} / ${formatSftpBytes(event.total)}…`, + ); + }; + xhr.onload = () => { + let result = null; + try { result = JSON.parse(xhr.responseText); } catch (error) {} + state.uploading = false; + state.xhr = null; + if (state.elements) { + state.elements.progress.classList.remove('visible'); + state.elements.progressBar.style.width = '0%'; + } + if (xhr.status >= 200 && xhr.status < 300 && result && result.status === 'completed') { + state.file = null; + updateSftpSelectedFile(state); + setSftpPipStatus(state, `Uploaded to ${result.destination_path}.`, 'success'); + } else { + setSftpPipStatus(state, (result && result.message) || 'File upload failed.', 'error'); + } + setSftpPipBusy(state, false); + if (!state.window || state.window.closed) { + if (xhr.status >= 200 && xhr.status < 300) alert(`SFTP upload completed: ${result.destination_path}`); + sftpPipState = null; + } + }; + xhr.onerror = () => { + state.uploading = false; + state.xhr = null; + setSftpPipStatus(state, 'File upload failed because the connection was interrupted.', 'error'); + setSftpPipBusy(state, false); + if (!state.window || state.window.closed) { + alert('SFTP upload failed because the connection was interrupted.'); + sftpPipState = null; + } + }; + xhr.send(file); + } + + function handleSftpUploadTicketResult(data) { + const state = sftpPipState; + if ( + !state + || !data + || data.terminal_id !== state.terminalId + || data.request_id !== state.pendingTicketRequestId + ) return; + state.pendingTicketRequestId = null; + setSftpPipBusy(state, false); + if (data.status === 'conflict') { + state.elements.conflictText.textContent = `${data.destination_path} already exists (${formatSftpBytes(data.existing_size)}).`; + state.elements.conflict.classList.add('visible'); + setSftpPipStatus(state, 'Choose how to handle the existing file.'); + return; + } + if (data.status !== 'ready') { + setSftpPipStatus(state, data.message || 'Upload could not be prepared.', 'error'); + return; + } + startSftpUpload(state, data); + } + + function buildSftpFileReference(state, file) { + return { + file_id: file.fileId, + }; + } + + function prepareSftpDownloadTicket(state, file = state && state.selectedRemoteFile) { + if (!file || state.busy || state.uploading || !socket || !socket.connected) return; + const requestId = nextSftpRequestId('sftp-download'); + state.pendingDownloadTicketRequestId = requestId; + state.pendingDownloadFileId = file.fileId; + state.downloadPreparing = true; + state.preparedDownload = null; + updateSftpDownloadAvailability(state); + setSftpPipStatus(state, `Preparing download for ${file.name}…`); + logSftpBrowserDownload('ticket requested', { + request_id: requestId, + terminal: state.terminalId, + expected_bytes: file.size, + }); + socket.emit('sftp_download_ticket_request', { + request_id: requestId, + terminal_id: state.terminalId, + ...buildSftpFileReference(state, file), + }); + } + + function startSftpBrowserDownload(data, targetDocument = document) { + if (!data || typeof data.download_url !== 'string') return false; + if (!targetDocument || !targetDocument.body) return false; + let downloadUrl; + try { + downloadUrl = new URL(data.download_url, window.location.href); + } catch (_error) { + logSftpBrowserDownload('link rejected', { + download_id: data.download_id || null, + reason: 'invalid_url', + }, 'warn'); + return false; + } + if ( + downloadUrl.origin !== window.location.origin + || !downloadUrl.pathname.startsWith('/sftp/download/') + ) { + logSftpBrowserDownload('link rejected', { + download_id: data.download_id || null, + reason: 'unexpected_origin_or_path', + }, 'warn'); + return false; + } + const link = targetDocument.createElement('a'); + link.href = downloadUrl.href; + link.target = '_blank'; + link.rel = 'noopener'; + link.style.display = 'none'; + targetDocument.body.appendChild(link); + link.click(); + link.remove(); + logSftpBrowserDownload('link dispatched', { + download_id: data.download_id || null, + terminal: data.terminal_id || null, + document_scope: targetDocument === document ? 'main' : 'pip', + target: link.target, + download_attribute: link.hasAttribute('download'), + origin: downloadUrl.origin, + path: '/sftp/download/', + }); + return true; + } + + function startPreparedSftpDownload(state) { + if (!state || !state.selectedRemoteFile || state.busy || state.uploading) return; + const prepared = state.preparedDownload; + if ( + !prepared + || prepared.fileId !== state.selectedRemoteFile.fileId + || prepared.expiresAt <= Date.now() + ) { + logSftpBrowserDownload('ticket unavailable at click', { + download_id: prepared && prepared.download_id ? prepared.download_id : null, + terminal: state.terminalId, + reason: prepared ? 'expired_or_wrong_file' : 'not_ready', + }, 'warn'); + state.preparedDownload = null; + state.downloadConsumed = false; + prepareSftpDownloadTicket(state); + return; + } + const activationNavigator = state.window && state.window.navigator + ? state.window.navigator + : navigator; + const userActivation = activationNavigator.userActivation; + logSftpBrowserDownload('button clicked', { + download_id: prepared.download_id || null, + terminal: state.terminalId, + user_activation_active: userActivation ? userActivation.isActive : null, + user_activation_seen: userActivation ? userActivation.hasBeenActive : null, + ticket_remaining_ms: Math.max(0, prepared.expiresAt - Date.now()), + }); + if (!startSftpBrowserDownload(prepared, state.window.document)) { + setSftpPipStatus(state, 'Download URL was invalid.', 'error'); + return; + } + state.preparedDownload = null; + state.downloadConsumed = true; + updateSftpDownloadAvailability(state); + setSftpPipStatus(state, `Download started: ${prepared.filename}.`, 'success'); + } + + function handleSftpDownloadTicketResult(data) { + const state = sftpPipState; + if ( + !state + || !data + || data.terminal_id !== state.terminalId + || data.request_id !== state.pendingDownloadTicketRequestId + ) return; + const pendingFileId = state.pendingDownloadFileId; + state.pendingDownloadTicketRequestId = null; + state.pendingDownloadFileId = null; + state.downloadPreparing = false; + if (!state.selectedRemoteFile || state.selectedRemoteFile.fileId !== pendingFileId) { + logSftpBrowserDownload('ticket result ignored', { + request_id: data.request_id, + terminal: state.terminalId, + reason: 'selection_changed', + }, 'warn'); + updateSftpDownloadAvailability(state); + return; + } + if (data.status !== 'ready' || typeof data.download_url !== 'string') { + logSftpBrowserDownload('ticket failed', { + request_id: data.request_id, + terminal: state.terminalId, + error_code: data.error_code || null, + status: data.status || null, + }, 'warn'); + updateSftpDownloadAvailability(state); + setSftpPipStatus(state, data.message || 'Download could not be prepared.', 'error'); + return; + } + const lifetimeSeconds = Number(data.expires_in_seconds); + state.preparedDownload = { + ...data, + fileId: pendingFileId, + expiresAt: Date.now() + Math.max(1, Number.isFinite(lifetimeSeconds) ? lifetimeSeconds : 60) * 1000, + }; + logSftpBrowserDownload('ticket ready', { + download_id: data.download_id || null, + request_id: data.request_id, + terminal: state.terminalId, + expected_bytes: Number(data.size), + expires_in_seconds: lifetimeSeconds, + }); + updateSftpDownloadAvailability(state); + setSftpPipStatus(state, `Download ready: ${data.filename}.`, 'success'); + } + + function requestSftpFileAction(state, action) { + const file = state && state.selectedRemoteFile; + if (!file || state.busy || state.uploading || !socket || !socket.connected) return; + const requestId = nextSftpRequestId(`sftp-${action}`); + state.pendingFileActionRequestId = requestId; + state.pendingFileAction = action; + const payload = { + request_id: requestId, + terminal_id: state.terminalId, + action, + ...buildSftpFileReference(state, file), + }; + if (action === 'rename') { + updateSftpRenameAvailability(state); + if (state.elements.renameConfirm.disabled) return; + payload.new_filename = state.elements.renameInput.value; + } else if (action === 'delete') { + if (state.deleteConfirmationPhase !== 2) return; + payload.delete_confirmation = 'permanent_delete_confirmed'; + } else { + return; + } + setSftpPipBusy(state, true); + setSftpPipStatus(state, action === 'rename' ? 'Renaming remote file…' : 'Permanently deleting remote file…'); + socket.emit('sftp_file_action_request', payload); + } + + function handleSftpFileActionResult(data) { + const state = sftpPipState; + if ( + !state + || !data + || data.terminal_id !== state.terminalId + || data.request_id !== state.pendingFileActionRequestId + ) return; + const action = state.pendingFileAction; + state.pendingFileActionRequestId = null; + state.pendingFileAction = null; + setSftpPipBusy(state, false); + if (data.status !== 'completed') { + if (state.elements.deleteConfirmation.classList.contains('visible')) cancelSftpDelete(state); + setSftpPipStatus(state, data.message || `Remote file ${action} failed.`, 'error'); + return; + } + const completedMessage = action === 'rename' + ? `Renamed to ${data.filename}.` + : `Permanently deleted ${data.filename}.`; + closeSftpFileOperation(state, false); + requestSftpBrowse(state, { path: state.currentPath, successMessage: completedMessage }); + } + + function closeSftpPip(state) { + if (!state) return; + if (state.busy || state.uploading) return; + const pipWindow = state.window; + if (pipWindow && !pipWindow.closed) pipWindow.close(); + if (sftpPipState === state) sftpPipState = null; + } + + function createSftpPipShell(state) { + const pipDocument = state.window.document; + const shell = pipDocument.createElement('div'); + shell.className = 'sftp-pip-shell'; + shell.innerHTML = ` +
+
SFTP File Manager
+ +
+
+
Destination endpoint
+
Direct SSH endpoint
+
Direct connection only. Nested SSH sessions inside the terminal are not used.
+
+
+ + + + +
+
+ + +
Drop one file here or click to choose
+ +
No file selected.
+
+
+
+ + + +
+
+
+
Opening SFTP…
+
+ `; + pipDocument.body.replaceChildren(shell); + state.elements = { + shell, + close: shell.querySelector('.sftp-pip-close'), + endpoint: shell.querySelector('.sftp-endpoint-value'), + home: shell.querySelector('.sftp-home'), + up: shell.querySelector('.sftp-up'), + path: shell.querySelector('.sftp-path-input'), + go: shell.querySelector('.sftp-go'), + directoryList: shell.querySelector('.sftp-directory-list'), + fileOperation: shell.querySelector('.sftp-file-operation-box'), + fileOperationPath: shell.querySelector('.sftp-file-operation-path'), + fileOperationActions: shell.querySelector('.sftp-file-operation-actions'), + fileDownload: shell.querySelector('.sftp-file-download'), + fileRename: shell.querySelector('.sftp-file-rename'), + fileDelete: shell.querySelector('.sftp-file-delete'), + fileOperationClose: shell.querySelector('.sftp-file-operation-close'), + renameControls: shell.querySelector('.sftp-rename-controls'), + renameInput: shell.querySelector('.sftp-rename-input'), + renameConfirm: shell.querySelector('.sftp-rename-confirm'), + renameCancel: shell.querySelector('.sftp-rename-cancel'), + renameHint: shell.querySelector('.sftp-rename-hint'), + deleteConfirmation: shell.querySelector('.sftp-delete-confirmation'), + deletePhaseOne: [...shell.querySelectorAll('.sftp-delete-phase-one')], + deletePhaseTwo: [...shell.querySelectorAll('.sftp-delete-phase-two')], + deletePath: shell.querySelector('.sftp-delete-path'), + deleteYes: shell.querySelector('.sftp-delete-yes'), + deleteNo: shell.querySelector('.sftp-delete-no'), + deleteSure: shell.querySelector('.sftp-delete-sure'), + deleteDont: shell.querySelector('.sftp-delete-dont'), + drop: shell.querySelector('.sftp-drop-zone'), + fileInput: shell.querySelector('.sftp-file-input'), + selectedFile: shell.querySelector('.sftp-selected-file'), + conflict: shell.querySelector('.sftp-conflict-box'), + conflictText: shell.querySelector('.sftp-conflict-text'), + conflictCancel: shell.querySelector('.sftp-conflict-cancel'), + conflictKeep: shell.querySelector('.sftp-conflict-keep'), + conflictReplace: shell.querySelector('.sftp-conflict-replace'), + progress: shell.querySelector('.sftp-progress'), + progressBar: shell.querySelector('.sftp-progress-bar'), + status: shell.querySelector('.sftp-transfer-status'), + send: shell.querySelector('.sftp-send'), + }; + state.elements.close.addEventListener('click', () => closeSftpPip(state)); + state.elements.home.addEventListener('click', () => requestSftpBrowse(state)); + state.elements.up.addEventListener('click', () => requestSftpBrowse(state, { path: state.currentPath, parent: true })); + state.elements.go.addEventListener('click', () => requestSftpBrowse(state, { path: state.elements.path.value })); + state.elements.path.addEventListener('keydown', event => { + if (event.key === 'Enter') requestSftpBrowse(state, { path: state.elements.path.value }); + }); + state.elements.fileOperationClose.addEventListener('click', () => closeSftpFileOperation(state)); + state.elements.fileDownload.addEventListener('click', () => startPreparedSftpDownload(state)); + state.elements.fileRename.addEventListener('click', () => showSftpRename(state)); + state.elements.fileDelete.addEventListener('click', () => showSftpDeleteConfirmation(state)); + state.elements.renameInput.addEventListener('input', () => updateSftpRenameAvailability(state)); + state.elements.renameInput.addEventListener('keydown', event => { + if (event.key === 'Enter' && !state.elements.renameConfirm.disabled) requestSftpFileAction(state, 'rename'); + if (event.key === 'Escape') { + event.preventDefault(); + event.stopPropagation(); + cancelSftpRename(state); + } + }); + state.elements.renameConfirm.addEventListener('click', () => requestSftpFileAction(state, 'rename')); + state.elements.renameCancel.addEventListener('click', () => cancelSftpRename(state)); + state.elements.deleteYes.addEventListener('click', () => showSftpDeleteConfirmation(state, 2)); + state.elements.deleteNo.addEventListener('click', () => cancelSftpDelete(state)); + state.elements.deleteSure.addEventListener('click', () => requestSftpFileAction(state, 'delete')); + state.elements.deleteDont.addEventListener('click', () => cancelSftpDelete(state)); + state.elements.drop.addEventListener('click', () => { + if (!state.uploading) state.elements.fileInput.click(); + }); + state.elements.drop.addEventListener('keydown', event => { + if ((event.key === 'Enter' || event.key === ' ') && !state.uploading) { + event.preventDefault(); + state.elements.fileInput.click(); + } + }); + state.elements.fileInput.addEventListener('change', () => selectSftpFile(state, state.elements.fileInput.files)); + ['dragenter', 'dragover'].forEach(eventName => state.elements.drop.addEventListener(eventName, event => { + event.preventDefault(); + if (!state.uploading) state.elements.drop.classList.add('dragging'); + })); + ['dragleave', 'drop'].forEach(eventName => state.elements.drop.addEventListener(eventName, event => { + event.preventDefault(); + state.elements.drop.classList.remove('dragging'); + })); + state.elements.drop.addEventListener('drop', event => selectSftpFile(state, event.dataTransfer.files)); + state.elements.send.addEventListener('click', () => requestSftpUploadTicket(state)); + state.elements.conflictCancel.addEventListener('click', () => { + state.elements.conflict.classList.remove('visible'); + setSftpPipStatus(state, 'Upload cancelled.'); + }); + state.elements.conflictKeep.addEventListener('click', () => requestSftpUploadTicket(state, 'keep_both')); + state.elements.conflictReplace.addEventListener('click', () => requestSftpUploadTicket(state, 'replace')); + shell.addEventListener('keydown', event => { + if (event.key !== 'Escape') return; + if (state.elements.deleteConfirmation.classList.contains('visible')) { + event.preventDefault(); + cancelSftpDelete(state); + } else if (state.elements.renameControls.classList.contains('visible')) { + event.preventDefault(); + cancelSftpRename(state); + } else if (state.elements.fileOperation.classList.contains('visible')) { + event.preventDefault(); + closeSftpFileOperation(state); + } + }); + } + + async function openSftpPip(state) { + if (!canUseSftpFileManager(state)) return; + if (!window.documentPictureInPicture) return alert('PiP not supported.'); + if (pipTerminalState) return alert('Restore the terminal from PiP before opening SFTP File Manager.'); + if (sftpPipState) { + if (sftpPipState.window && !sftpPipState.window.closed) sftpPipState.window.focus(); + return; + } + const pipWindow = await window.documentPictureInPicture.requestWindow({ width: 480, height: 620 }); + const transferState = { + window: pipWindow, + terminalId: state.id, + endpoint: state.sftpEndpoint, + currentPath: null, + maxUploadBytes: null, + currentFiles: [], + currentEntryNames: new Set(), + file: null, + selectedRemoteFile: null, + selectedRemoteFileButton: null, + deleteConfirmationPhase: 0, + busy: false, + uploading: false, + pendingBrowseRequestId: null, + pendingTicketRequestId: null, + pendingDownloadTicketRequestId: null, + pendingDownloadFileId: null, + downloadPreparing: false, + preparedDownload: null, + downloadConsumed: false, + pendingFileActionRequestId: null, + pendingFileAction: null, + pendingBrowseStatus: null, + elements: null, + xhr: null, + }; + sftpPipState = transferState; + createSftpPipShell(transferState); + copyStylesToPipWindow(pipWindow); + pipWindow.addEventListener('pagehide', () => { + transferState.window = null; + transferState.elements = null; + if (!transferState.uploading && sftpPipState === transferState) sftpPipState = null; + }, { once: true }); + requestSftpBrowse(transferState); + } + + function openSftpFromTerminalPip(state) { + if (!state || pipTerminalState !== state || !canUseSftpFileManager(state)) return; + restoreTerminalFromPip(state, { closeWindow: true }); + openSftpPip(state); + } + const contextMenu = document.getElementById('context-menu'); const urlOverlay = document.getElementById('url-overlay'); const openOption = document.getElementById('open-overlay-option'); const searchOption = document.getElementById('search-google-option'); const pipOption = document.getElementById('pip-option'); + const sftpSendOption = document.getElementById('sftp-send-option'); const menuSepSel = document.getElementById('menu-sep-sel'); const overlayIframe = document.getElementById('overlay-iframe'); const overlayImg = document.getElementById('overlay-img'); @@ -6825,12 +7911,33 @@

Access token required

const OVERLAY_MIN_WIDTH = 200; const OVERLAY_MIN_HEIGHT = 150; const IMAGE_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg', '.bmp']; - function showContextMenu(x, y) { + function showContextMenu(x, y, target = null) { const activeTerm = getActiveTerm(); + const targetTab = target && target.closest ? target.closest('.terminal-tab') : null; + contextMenuTerminalId = targetTab ? targetTab.dataset.terminalId : activeTerminalId; + const contextState = terminals.get(contextMenuTerminalId); const sel = activeTerm ? activeTerm.getSelection().trim() : ''; const hasSel = !!sel; openOption.classList.toggle('hidden', !hasSel); searchOption.classList.toggle('hidden', !hasSel); menuSepSel.style.display = hasSel ? 'block' : 'none'; pipOption.classList.toggle('hidden', !canMoveActiveTerminalToPip()); + const showSftp = shouldShowSftpAction(contextState); + const enableSftp = canUseSftpFileManager(contextState); + sftpSendOption.classList.toggle('hidden', !showSftp); + sftpSendOption.classList.toggle('disabled', showSftp && !enableSftp); + sftpSendOption.setAttribute('aria-disabled', String(showSftp && !enableSftp)); + const sftpMenuIcon = document.createElement('span'); + sftpMenuIcon.innerText = '\u{1F4C1}'; + sftpMenuIcon.className = enableSftp ? '' : 'sftp-icon-muted'; + sftpSendOption.replaceChildren(sftpMenuIcon); + if (!enableSftp) { + const unavailableMark = document.createElement('span'); + unavailableMark.className = 'sftp-unavailable-mark'; + unavailableMark.innerText = '\u00D7'; + sftpSendOption.appendChild(unavailableMark); + } + sftpSendOption.appendChild(document.createTextNode( + enableSftp ? ' SFTP File Manager\u2026' : ' SFTP not available' + )); if (hasSel) { openOption.dataset.url = sel; searchOption.dataset.query = sel; } contextMenu.style.left = `${x}px`; contextMenu.style.top = `${y}px`; contextMenu.style.display = 'block'; requestAnimationFrame(() => { const r = contextMenu.getBoundingClientRect(); if (r.right > window.innerWidth) contextMenu.style.left = `${x - r.width}px`; if (r.bottom > window.innerHeight) contextMenu.style.top = `${y - r.height}px`; }); @@ -6838,7 +7945,7 @@

Access token required

window.addEventListener('contextmenu', (e) => { if (e.shiftKey || !prefs.useCustomMenu) return; e.preventDefault(); - setTimeout(() => showContextMenu(e.clientX, e.clientY), 50); + setTimeout(() => showContextMenu(e.clientX, e.clientY, e.target), 50); }); window.addEventListener('mousedown', (e) => { if (!contextMenu.contains(e.target)) contextMenu.style.display = 'none'; }); @@ -6859,6 +7966,13 @@

Access token required

savePrefs(prefs); alert("Custom menu disabled. To restore, use the ⚙️ icon or Press Shift + Right-Click."); }; + sftpSendOption.onclick = (event) => { + event.stopPropagation(); + if (sftpSendOption.classList.contains('disabled')) return; + contextMenu.style.display = 'none'; + openSftpPip(terminals.get(contextMenuTerminalId)); + }; + sftpStatusBtn.onclick = () => openSftpPip(getActiveTerminalState()); function clearOverlayFallbackTimer() { if (overlayFallbackTimer) { @@ -6993,13 +8107,20 @@

Access token required

'body { display: flex; flex-direction: column; }', '.pip-title-bar { height: 26px; box-sizing: border-box; display: flex; align-items: center; gap: 10px; padding: 0 8px; background: #111; border-top: 1px solid #222; color: #ddd; font: 12px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }', '.pip-title-main { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #fff; font-weight: 700; }', + '.pip-application-title-item { flex: 0 1 240px; min-width: 0; display: flex; align-items: center; gap: 10px; color: #444; }', + '.pip-application-title-item[hidden] { display: none; }', + '.pip-application-title { min-width: 0; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #aaa; }', '.pip-title-meta { color: #aaa; white-space: nowrap; }', '.pip-title-spacer { flex: 1; }', '.pip-agent-button { flex: 0 0 auto; height: 22px; border: 1px solid #333; border-radius: 5px; background: #202020; color: #b6b6b6; cursor: pointer; font-size: 12px; padding: 0 8px; }', '.pip-agent-button:hover { color: #fff; border-color: #0a84ff; background: #252b33; }', '.pip-agent-button:disabled { opacity: 0.4; cursor: default; }', + '.pip-sftp-button { min-width: 28px; padding: 0 4px; display: inline-flex; align-items: center; justify-content: center; }', + '.pip-sftp-button[hidden] { display: none; }', + '.pip-sftp-button.unavailable:disabled { opacity: 1; }', '.pip-agent-pause { display: none; border-color: #7a1f1f; background: #3a1212; color: #ff6b6b; }', '.pip-agent-pause.visible { display: inline-block; }', + '.pip-agent-mint[hidden] { display: none; }', '.pip-terminal-host { flex: 1; min-height: 0; width: 100%; }', '.pip-terminal-host .terminal-pane { display: block !important; width: 100%; height: 100%; }', '#agent-panel { bottom: 10px; }' @@ -7013,19 +8134,56 @@

Access token required

state.pipTitleEl.innerText = label; state.pipTitleEl.title = label; state.pipSizeEl.innerText = state.term ? `${state.term.cols}x${state.term.rows}` : '--x--'; + if (state.pipApplicationTitleItem && state.pipApplicationTitleEl) { + const applicationTitle = state.applicationTitle || ''; + const titleVisible = prefs.showTerminalTitleInStatusBar && !!applicationTitle; + state.pipApplicationTitleItem.hidden = !titleVisible; + state.pipApplicationTitleEl.innerText = applicationTitle; + state.pipApplicationTitleItem.title = applicationTitle; + } + const usable = canUseAgentPanel(state); + const showing = !!( + agentPanelVisible + && agentPanelTerminalIdOverride === state.id + && state.pipWindow + && agentPanel.ownerDocument === state.pipWindow.document + ); if (state.pipAgentBtn) { - const usable = canUseAgentPanel(state); - const showing = agentPanelVisible && agentPanelTerminalIdOverride === state.id && agentPanel.ownerDocument === state.pipWindow.document; state.pipAgentBtn.disabled = !usable; state.pipAgentBtn.innerText = showing ? 'Hide Agent Panel' : 'Show Agent Panel'; state.pipAgentBtn.title = usable ? state.pipAgentBtn.innerText : 'Connect this terminal before using Agent panel'; } + const agent = state.agent; + const mode = agent ? agent.mode : AGENT_MODE_DISABLED; + const token = getCurrentAgentExternalToken(state); + const minting = !!(token && token.status === 'minting'); + const mintAvailable = !!( + usable + && !showing + && mode !== AGENT_MODE_DISABLED + && mode !== AGENT_MODE_PAUSED + && !(agent && agent.paused) + ); + [state.pipAgentMintBtn, state.pipAgentMint3xBtn].forEach(button => { + if (!button) return; + button.hidden = !mintAvailable; + button.disabled = !mintAvailable || minting; + }); + if (state.pipAgentMintBtn) { + state.pipAgentMintBtn.innerText = minting ? 'Minting…' : 'Mint'; + state.pipAgentMintBtn.title = `Mint a standard external-agent token for ${label}`; + } + if (state.pipAgentMint3xBtn) { + state.pipAgentMint3xBtn.innerText = minting ? 'Minting…' : 'Mint+'; + state.pipAgentMint3xBtn.title = `Mint a 3× idle-time external-agent token for ${label}`; + } if (state.pipAgentPauseBtn) { const pauseVisible = hasAgentPauseCapability(state); state.pipAgentPauseBtn.classList.toggle('visible', pauseVisible); state.pipAgentPauseBtn.disabled = !(pauseVisible && socket && socket.connected && state.connected); state.pipAgentPauseBtn.title = pauseVisible ? 'Pause Agent terminal input' : 'Agent pause is not available'; } + updateSftpActionButton(state.pipSftpBtn, state); } function restoreAgentPanelToMainDocument() { @@ -7060,6 +8218,16 @@

Access token required

bar.className = 'pip-title-bar'; const title = pipWindow.document.createElement('span'); title.className = 'pip-title-main'; + const applicationTitleItem = pipWindow.document.createElement('span'); + applicationTitleItem.className = 'pip-application-title-item'; + applicationTitleItem.hidden = true; + const applicationTitleStartSeparator = pipWindow.document.createElement('span'); + applicationTitleStartSeparator.innerText = '|'; + const applicationTitle = pipWindow.document.createElement('span'); + applicationTitle.className = 'pip-application-title'; + const applicationTitleEndSeparator = pipWindow.document.createElement('span'); + applicationTitleEndSeparator.innerText = '|'; + applicationTitleItem.append(applicationTitleStartSeparator, applicationTitle, applicationTitleEndSeparator); const meta = pipWindow.document.createElement('span'); meta.className = 'pip-title-meta'; const spacer = pipWindow.document.createElement('span'); @@ -7069,20 +8237,43 @@

Access token required

pauseButton.className = 'pip-agent-button pip-agent-pause'; pauseButton.innerText = 'Pause Agent'; pauseButton.addEventListener('click', () => pauseAgentForState(state)); + const mintButton = pipWindow.document.createElement('button'); + mintButton.type = 'button'; + mintButton.className = 'pip-agent-button pip-agent-mint'; + mintButton.hidden = true; + mintButton.addEventListener('click', () => mintAgentExternalToken(state, 1)); + const mint3xButton = pipWindow.document.createElement('button'); + mint3xButton.type = 'button'; + mint3xButton.className = 'pip-agent-button pip-agent-mint pip-agent-mint-3x'; + mint3xButton.hidden = true; + mint3xButton.addEventListener('click', () => mintAgentExternalToken(state, 3)); const agentButton = pipWindow.document.createElement('button'); agentButton.type = 'button'; - agentButton.className = 'pip-agent-button'; + agentButton.className = 'pip-agent-button pip-agent-panel'; agentButton.addEventListener('click', () => showAgentPanelForPipTerminal(state)); - bar.append(title, meta, spacer, pauseButton, agentButton); + const sftpButton = pipWindow.document.createElement('button'); + sftpButton.type = 'button'; + sftpButton.className = 'pip-agent-button pip-sftp-button'; + sftpButton.innerText = '\u{1F4C1}'; + sftpButton.title = 'Open SFTP File Manager'; + sftpButton.setAttribute('aria-label', 'Open SFTP File Manager'); + sftpButton.hidden = true; + sftpButton.addEventListener('click', () => openSftpFromTerminalPip(state)); + bar.append(title, applicationTitleItem, meta, spacer, pauseButton, mintButton, mint3xButton, agentButton, sftpButton); const host = pipWindow.document.createElement('div'); host.className = 'pip-terminal-host'; pipWindow.document.body.replaceChildren(host, bar); state.pipBar = bar; state.pipTitleEl = title; + state.pipApplicationTitleItem = applicationTitleItem; + state.pipApplicationTitleEl = applicationTitle; state.pipSizeEl = meta; state.pipAgentBtn = agentButton; state.pipAgentPauseBtn = pauseButton; + state.pipAgentMintBtn = mintButton; + state.pipAgentMint3xBtn = mint3xButton; + state.pipSftpBtn = sftpButton; state.pipTerminalHost = host; updatePipStatus(state); return host; @@ -7110,9 +8301,14 @@

Access token required

state.pipWindow = null; state.pipBar = null; state.pipTitleEl = null; + state.pipApplicationTitleItem = null; + state.pipApplicationTitleEl = null; state.pipSizeEl = null; state.pipAgentBtn = null; state.pipAgentPauseBtn = null; + state.pipAgentMintBtn = null; + state.pipAgentMint3xBtn = null; + state.pipSftpBtn = null; state.pipTerminalHost = null; state.pipReturnNextSibling = null; state.pipPagehideHandler = null; @@ -7130,6 +8326,7 @@

Access token required

contextMenu.style.display = 'none'; if (!canMoveActiveTerminalToPip()) return; if (!window.documentPictureInPicture) return alert("PiP not supported."); + if (sftpPipState) return alert('Close the SFTP transfer window before moving a terminal to PiP.'); const state = getActiveTerminalState(); if (!state) return; if (pipTerminalState && pipTerminalState !== state) { diff --git a/terminal_backends/__init__.py b/terminal_backends/__init__.py index ad7c5cb..18ec5aa 100644 --- a/terminal_backends/__init__.py +++ b/terminal_backends/__init__.py @@ -10,7 +10,7 @@ TerminalBridgeRuntime, ) from .local_shell import LocalShellBackendPlugin, LocalShellBridge -from .ssh import SSHBackendPlugin, SSHBridge +from .ssh import SFTPTransferError, SSHBackendPlugin, SSHBridge from .uart import UARTBackendPlugin, UARTBridge __all__ = [ @@ -18,6 +18,7 @@ 'LocalShellBridge', 'SSHBackendPlugin', 'SSHBridge', + 'SFTPTransferError', 'BackendAction', 'BackendActionStore', 'BackendPolicyContext', diff --git a/terminal_backends/ssh.py b/terminal_backends/ssh.py index 15035f6..461c307 100644 --- a/terminal_backends/ssh.py +++ b/terminal_backends/ssh.py @@ -4,6 +4,10 @@ import hashlib import os import re +import secrets +import stat +import threading +import time from pathlib import Path from .base import BackendAction, BackendSettingSchema, BackendStartFieldSchema, TerminalBackendPlugin, TerminalBridge @@ -13,12 +17,21 @@ SSH_PROFILE_NAME_MAX_LENGTH = 64 SSH_BROWSER_KEY_ID_MAX_LENGTH = 128 SSH_BROWSER_KEY_ID_PATTERN = re.compile(r'^[A-Za-z0-9_-]+$') +SFTP_FILE_REFERENCE_TTL_SECONDS = 5 * 60 +SFTP_FILE_REFERENCE_MAX_RECORDS = 4096 +SFTP_FILE_REFERENCE_TOKEN_BYTES = 12 class BrowserSSHKeyError(Exception): pass +class SFTPTransferError(Exception): + def __init__(self, error_code, message): + super().__init__(message) + self.error_code = error_code + + class BrowserEd25519Key: name = 'ssh-ed25519' public_blob = None @@ -90,6 +103,10 @@ def __init__( self._local_public_key_types = local_public_key_types self._request_browser_signature = request_browser_signature self._browser_signer_sid = None + self._sftp_lock = threading.Lock() + self._sftp_file_refs_lock = threading.Lock() + self._sftp_file_refs = {} + self._sftp_endpoint = None self.ssh = None self.auth_method = None self._reset_ssh_client() @@ -102,6 +119,429 @@ def metadata(self, cols=None, rows=None): metadata['auth_method'] = self.auth_method return metadata + def sftp_endpoint(self): + return dict(self._sftp_endpoint) if self._sftp_endpoint else None + + @staticmethod + def _validate_sftp_path(path): + if not isinstance(path, str): + raise SFTPTransferError('sftp_invalid_path', 'Remote path is invalid.') + if not path: + raise SFTPTransferError('sftp_invalid_path', 'Remote path is required.') + if len(path.encode('utf-8', errors='ignore')) > 4096 or any(ord(ch) < 32 or ord(ch) == 127 for ch in path): + raise SFTPTransferError('sftp_invalid_path', 'Remote path is invalid.') + return path + + @staticmethod + def _validate_sftp_name(name): + SSHBridge._validate_sftp_path(name) + if name in {'.', '..'} or '/' in name or '\\' in name: + raise SFTPTransferError('sftp_invalid_filename', 'File name is invalid.') + if len(name.encode('utf-8', errors='ignore')) > 255: + raise SFTPTransferError('sftp_invalid_filename', 'File name is too long.') + return name + + @staticmethod + def _join_sftp_path(directory, name): + if directory.endswith('/'): + return directory + name + return directory + '/' + name + + @staticmethod + def _is_sftp_not_found(exc): + return isinstance(exc, FileNotFoundError) or getattr(exc, 'errno', None) == 2 + + @staticmethod + def _keep_both_name(filename, sequence): + dot_index = filename.rfind('.') + if dot_index > 0: + stem = filename[:dot_index] + suffix = filename[dot_index:] + else: + stem = filename + suffix = '' + return f'{stem} ({sequence}){suffix}' + + def _open_sftp(self): + transport = self.ssh.get_transport() if self.ssh else None + if not transport or not transport.is_active(): + raise SFTPTransferError('sftp_connection_closed', 'The SSH connection is closed.') + try: + return self.ssh.open_sftp() + except Exception as exc: + raise SFTPTransferError('sftp_unavailable', 'SFTP is unavailable on this SSH server.') from exc + + def _canonical_sftp_directory(self, sftp, directory): + canonical_directory = self._validate_sftp_path(sftp.normalize(directory)) + directory_stat = sftp.stat(canonical_directory) + if directory_stat.st_mode is None or not stat.S_ISDIR(directory_stat.st_mode): + raise SFTPTransferError('sftp_not_directory', 'Remote path is not a directory.') + return canonical_directory + + def _get_sftp_regular_file(self, sftp, directory, filename): + path = self._join_sftp_path(directory, filename) + try: + attributes = sftp.lstat(path) + except Exception as exc: + if self._is_sftp_not_found(exc): + raise SFTPTransferError('sftp_file_not_found', 'The remote file no longer exists.') from exc + raise + if attributes.st_mode is not None and stat.S_ISLNK(attributes.st_mode): + raise SFTPTransferError('sftp_file_symlink', 'Symbolic links are not supported for this operation.') + if attributes.st_mode is None or not stat.S_ISREG(attributes.st_mode): + raise SFTPTransferError('sftp_file_not_regular', 'The remote path is not a regular file.') + return path, attributes + + @staticmethod + def _validate_sftp_file_snapshot(attributes, expected_size, expected_mtime): + if attributes.st_size != expected_size or attributes.st_mtime != expected_mtime: + raise SFTPTransferError('sftp_file_changed', 'The remote file changed after the directory was listed.') + + def _register_sftp_file_reference(self, file_snapshot): + now = time.monotonic() + with self._sftp_file_refs_lock: + for existing_id, record in list(self._sftp_file_refs.items()): + if record['expires_at'] <= now: + self._sftp_file_refs.pop(existing_id, None) + while len(self._sftp_file_refs) >= SFTP_FILE_REFERENCE_MAX_RECORDS: + self._sftp_file_refs.pop(next(iter(self._sftp_file_refs))) + file_id = 'sftpf_' + secrets.token_urlsafe(SFTP_FILE_REFERENCE_TOKEN_BYTES) + while file_id in self._sftp_file_refs: + file_id = 'sftpf_' + secrets.token_urlsafe(SFTP_FILE_REFERENCE_TOKEN_BYTES) + self._sftp_file_refs[file_id] = { + **file_snapshot, + 'expires_at': now + SFTP_FILE_REFERENCE_TTL_SECONDS, + } + return file_id + + def resolve_sftp_file_reference(self, file_id): + if not isinstance(file_id, str) or len(file_id) > 128: + raise SFTPTransferError('sftp_file_reference_invalid', 'The remote file selection is invalid.') + now = time.monotonic() + with self._sftp_file_refs_lock: + record = self._sftp_file_refs.get(file_id) + if not record or record['expires_at'] <= now: + self._sftp_file_refs.pop(file_id, None) + raise SFTPTransferError('sftp_file_reference_expired', 'The remote file selection expired. Refresh the directory and try again.') + return { + key: value + for key, value in record.items() + if key != 'expires_at' + } + + def browse_sftp(self, path=None, *, child=None, parent=False, max_entries=1000): + requested_path = '.' if path is None else self._validate_sftp_path(path) + if child is not None: + child = self._validate_sftp_name(child) + requested_path = self._join_sftp_path(requested_path, child) + elif parent: + requested_path = self._join_sftp_path(requested_path, '..') + + with self._sftp_lock: + sftp = self._open_sftp() + try: + canonical_path = self._canonical_sftp_directory(sftp, requested_path) + directories = [] + files = [] + truncated = False + for entry in sftp.listdir_iter(canonical_path, read_aheads=10): + try: + entry_name = self._validate_sftp_name(entry.filename) + except SFTPTransferError: + continue + is_directory = entry.st_mode is not None and stat.S_ISDIR(entry.st_mode) + is_regular_file = entry.st_mode is not None and stat.S_ISREG(entry.st_mode) + if not is_directory and not is_regular_file: + continue + if len(directories) + len(files) >= max_entries: + truncated = True + break + if is_directory: + directories.append({'name': entry_name}) + elif entry.st_size is not None and entry.st_mtime is not None: + file_snapshot = { + 'directory': canonical_path, + 'filename': entry_name, + 'path': self._join_sftp_path(canonical_path, entry_name), + 'size': entry.st_size, + 'mtime': entry.st_mtime, + 'endpoint': self.sftp_endpoint(), + } + files.append({ + 'file_id': self._register_sftp_file_reference(file_snapshot), + 'name': entry_name, + 'size': entry.st_size, + 'mtime': entry.st_mtime, + }) + directories.sort(key=lambda item: item['name'].casefold()) + files.sort(key=lambda item: item['name'].casefold()) + return { + 'path': canonical_path, + 'directories': directories, + 'files': files, + 'truncated': truncated, + 'endpoint': self.sftp_endpoint(), + } + except SFTPTransferError: + raise + except Exception as exc: + raise SFTPTransferError('sftp_browse_failed', f'Remote directory could not be opened: {exc}') from exc + finally: + sftp.close() + + def prepare_sftp_file(self, directory, filename): + directory = self._validate_sftp_path(directory) + filename = self._validate_sftp_name(filename) + with self._sftp_lock: + sftp = self._open_sftp() + try: + canonical_directory = self._canonical_sftp_directory(sftp, directory) + path, attributes = self._get_sftp_regular_file(sftp, canonical_directory, filename) + return { + 'directory': canonical_directory, + 'filename': filename, + 'path': path, + 'size': attributes.st_size, + 'mtime': attributes.st_mtime, + 'endpoint': self.sftp_endpoint(), + } + except SFTPTransferError: + raise + except Exception as exc: + raise SFTPTransferError('sftp_file_prepare_failed', f'Remote file could not be checked: {exc}') from exc + finally: + sftp.close() + + def download_sftp_chunks(self, file_snapshot, chunk_size=65536): + with self._sftp_lock: + sftp = self._open_sftp() + try: + path, attributes = self._get_sftp_regular_file( + sftp, + file_snapshot['directory'], + file_snapshot['filename'], + ) + self._validate_sftp_file_snapshot( + attributes, + file_snapshot['size'], + file_snapshot['mtime'], + ) + remaining = file_snapshot['size'] + with sftp.open(path, 'rb') as remote_file: + while remaining > 0: + chunk = remote_file.read(min(chunk_size, remaining)) + if not chunk: + raise SFTPTransferError('sftp_download_incomplete', 'The remote file ended before the download completed.') + remaining -= len(chunk) + yield chunk + finally: + sftp.close() + + def rename_sftp_file(self, directory, filename, new_filename, expected_size, expected_mtime): + directory = self._validate_sftp_path(directory) + filename = self._validate_sftp_name(filename) + new_filename = self._validate_sftp_name(new_filename) + if new_filename == filename: + raise SFTPTransferError('sftp_rename_unchanged', 'Enter a different file name.') + with self._sftp_lock: + sftp = self._open_sftp() + try: + canonical_directory = self._canonical_sftp_directory(sftp, directory) + source_path, attributes = self._get_sftp_regular_file(sftp, canonical_directory, filename) + self._validate_sftp_file_snapshot(attributes, expected_size, expected_mtime) + destination_path = self._join_sftp_path(canonical_directory, new_filename) + try: + sftp.lstat(destination_path) + except Exception as exc: + if not self._is_sftp_not_found(exc): + raise + else: + raise SFTPTransferError('sftp_rename_destination_exists', 'A file with the new name already exists.') + sftp.rename(source_path, destination_path) + return { + 'status': 'completed', + 'action': 'rename', + 'source_path': source_path, + 'destination_path': destination_path, + 'filename': new_filename, + } + except SFTPTransferError: + raise + except Exception as exc: + raise SFTPTransferError('sftp_rename_failed', f'Remote file could not be renamed: {exc}') from exc + finally: + sftp.close() + + def delete_sftp_file(self, directory, filename, expected_size, expected_mtime): + directory = self._validate_sftp_path(directory) + filename = self._validate_sftp_name(filename) + with self._sftp_lock: + sftp = self._open_sftp() + try: + canonical_directory = self._canonical_sftp_directory(sftp, directory) + path, attributes = self._get_sftp_regular_file(sftp, canonical_directory, filename) + self._validate_sftp_file_snapshot(attributes, expected_size, expected_mtime) + sftp.remove(path) + return { + 'status': 'completed', + 'action': 'delete', + 'deleted_path': path, + 'filename': filename, + } + except SFTPTransferError: + raise + except Exception as exc: + raise SFTPTransferError('sftp_delete_failed', f'Remote file could not be deleted: {exc}') from exc + finally: + sftp.close() + + def prepare_sftp_upload(self, directory, filename, conflict_mode='ask'): + directory = self._validate_sftp_path(directory) + filename = self._validate_sftp_name(filename) + if conflict_mode not in {'ask', 'keep_both', 'replace'}: + raise SFTPTransferError('sftp_invalid_conflict_mode', 'Upload conflict mode is invalid.') + + with self._sftp_lock: + sftp = self._open_sftp() + try: + canonical_directory = self._validate_sftp_path(sftp.normalize(directory)) + directory_stat = sftp.stat(canonical_directory) + if directory_stat.st_mode is None or not stat.S_ISDIR(directory_stat.st_mode): + raise SFTPTransferError('sftp_not_directory', 'Remote path is not a directory.') + selected_name = filename + destination_path = self._join_sftp_path(canonical_directory, selected_name) + existing = None + try: + existing = sftp.lstat(destination_path) + except Exception as exc: + if not self._is_sftp_not_found(exc): + raise + + if existing is not None: + if existing.st_mode is not None and stat.S_ISLNK(existing.st_mode): + raise SFTPTransferError('sftp_destination_symlink', 'The destination is a symbolic link and cannot be replaced.') + if existing.st_mode is None or not stat.S_ISREG(existing.st_mode): + raise SFTPTransferError('sftp_destination_not_file', 'The destination exists and is not a regular file.') + if conflict_mode == 'ask': + return { + 'status': 'conflict', + 'directory': canonical_directory, + 'filename': selected_name, + 'destination_path': destination_path, + 'existing_size': existing.st_size, + 'existing_mtime': existing.st_mtime, + 'endpoint': self.sftp_endpoint(), + } + if conflict_mode == 'keep_both': + for sequence in range(1, 10000): + candidate = self._keep_both_name(filename, sequence) + candidate_path = self._join_sftp_path(canonical_directory, candidate) + try: + sftp.lstat(candidate_path) + except Exception as exc: + if self._is_sftp_not_found(exc): + selected_name = candidate + destination_path = candidate_path + existing = None + break + raise + else: + raise SFTPTransferError('sftp_keep_both_exhausted', 'A unique destination name could not be created.') + + return { + 'status': 'ready', + 'directory': canonical_directory, + 'filename': selected_name, + 'destination_path': destination_path, + 'replace': existing is not None and conflict_mode == 'replace', + 'existing_size': existing.st_size if existing is not None else None, + 'existing_mtime': existing.st_mtime if existing is not None else None, + 'endpoint': self.sftp_endpoint(), + } + except SFTPTransferError: + raise + except Exception as exc: + raise SFTPTransferError('sftp_upload_prepare_failed', f'Upload destination could not be checked: {exc}') from exc + finally: + sftp.close() + + def upload_sftp_stream(self, stream, upload, expected_size, progress_callback=None): + destination_path = upload['destination_path'] + filename = upload['filename'] + replace = bool(upload.get('replace')) + expected_existing_size = upload.get('existing_size') + expected_existing_mtime = upload.get('existing_mtime') + temporary_path = self._join_sftp_path( + upload['directory'], + f'.standterm-upload-{os.urandom(16).hex()}', + ) + completed = False + + with self._sftp_lock: + sftp = self._open_sftp() + try: + try: + current = sftp.lstat(destination_path) + except Exception as exc: + if self._is_sftp_not_found(exc): + current = None + else: + raise + if replace: + if ( + current is None + or current.st_mode is None + or not stat.S_ISREG(current.st_mode) + or current.st_size != expected_existing_size + or current.st_mtime != expected_existing_mtime + ): + raise SFTPTransferError('sftp_destination_changed', 'The destination changed before upload started.') + elif current is not None: + raise SFTPTransferError('sftp_destination_changed', 'The destination was created before upload started.') + + transferred = 0 + with sftp.open(temporary_path, 'wx') as remote_file: + while transferred < expected_size: + chunk = stream.read(min(65536, expected_size - transferred)) + if not chunk: + raise SFTPTransferError('sftp_upload_incomplete', 'The upload ended before the complete file was received.') + remote_file.write(chunk) + transferred += len(chunk) + if progress_callback: + progress_callback(transferred, expected_size) + remote_file.flush() + + uploaded_stat = sftp.stat(temporary_path) + if uploaded_stat.st_size != expected_size: + raise SFTPTransferError('sftp_upload_size_mismatch', 'The uploaded file size did not match the source file.') + if replace: + try: + sftp.posix_rename(temporary_path, destination_path) + except Exception as exc: + raise SFTPTransferError( + 'sftp_atomic_replace_unavailable', + 'This SFTP server cannot replace the existing file atomically.', + ) from exc + else: + sftp.rename(temporary_path, destination_path) + completed = True + return { + 'destination_path': destination_path, + 'filename': filename, + 'bytes_written': expected_size, + } + except SFTPTransferError: + raise + except Exception as exc: + raise SFTPTransferError('sftp_upload_failed', f'File upload failed: {exc}') from exc + finally: + if not completed: + try: + sftp.remove(temporary_path) + except Exception: + pass + sftp.close() + def set_browser_signer_sid(self, sid): if self._browser_signer_sid is not None and self._browser_signer_sid != sid: raise BrowserSSHKeyError('Browser SSH signer is already assigned.') @@ -512,6 +952,12 @@ def connect(self, host, port, user, password=None, browser_key=None, cols=80, ro self.channel = self.ssh.invoke_shell(term=self._ssh_term, width=cols, height=rows) self.channel.setblocking(0) + self._sftp_endpoint = { + 'user': str(user), + 'host': str(host), + 'port': int(port), + 'route': 'direct', + } log_message(f"[+] SSH connection established for {self.sid}") return True, None except BrowserSSHKeyError as exc: @@ -577,6 +1023,9 @@ def resize(self, cols, rows): log_message(f"[!] Resize error: {e}") def close(self): + with self._sftp_file_refs_lock: + self._sftp_file_refs.clear() + self._sftp_endpoint = None if self.channel: try: self.channel.close() diff --git a/tests/agent_backend_smoke.py b/tests/agent_backend_smoke.py index 75966fd..7c8c91d 100644 --- a/tests/agent_backend_smoke.py +++ b/tests/agent_backend_smoke.py @@ -115,6 +115,8 @@ def reset_state(): standterm.agent_viewport_snapshot_store.clear() standterm.agent_viewport_render_request_store.clear() standterm.browser_ssh_sign_request_store.clear() + standterm.sftp_upload_ticket_store.clear() + standterm.sftp_download_ticket_store.clear() standterm.external_agent_attach_store.clear() standterm.operator_observations.clear() standterm.serial_port_cache['expires_at'] = 0 @@ -3976,6 +3978,388 @@ def test_browser_ssh_sign_request_store_is_sid_bound_and_fail_closed(): assert wait_error == 'ssh_browser_key_sign_stale' +def make_sftp_test_bridge(session_token, terminal_id=standterm.TERMINAL_ID_MAIN): + bridge = object.__new__(standterm.SSHBridge) + standterm.TerminalBridge.__init__(bridge, session_token, terminal_id) + bridge._sftp_endpoint = { + 'user': 'tester', + 'host': 'host.example', + 'port': 22, + 'route': 'direct', + } + bridge._sftp_lock = threading.Lock() + bridge._sftp_file_refs_lock = threading.Lock() + bridge._sftp_file_refs = {} + bridge.channel = None + bridge.ssh = None + bridge.close = lambda: None + return bridge + + +def test_sftp_bridge_browses_direct_endpoint_and_replaces_atomically(): + class Attr: + def __init__(self, mode, size=0, mtime=1, filename=None): + self.st_mode = mode + self.st_size = size + self.st_mtime = mtime + self.filename = filename + + class RemoteFile: + def __init__(self, filesystem, path): + self.filesystem = filesystem + self.path = path + self.buffer = bytearray() + + def __enter__(self): + return self + + def __exit__(self, exc_type, _exc, _traceback): + if exc_type is None: + self.filesystem[self.path] = {'data': bytes(self.buffer), 'mtime': 20} + + def write(self, data): + self.buffer.extend(data) + + def flush(self): + pass + + class RemoteReadFile: + def __init__(self, data): + self.data = data + self.offset = 0 + + def __enter__(self): + return self + + def __exit__(self, _exc_type, _exc, _traceback): + pass + + def read(self, size): + chunk = self.data[self.offset:self.offset + size] + self.offset += len(chunk) + return chunk + + class FakeSFTP: + def __init__(self, filesystem): + self.filesystem = filesystem + self.posix_renames = [] + + def normalize(self, path): + if path == '.': + return 'C:/Users/tester' + if path.endswith('/..'): + return path.rsplit('/', 2)[0] + return path + + def stat(self, path): + if path in {'C:/Users/tester', 'C:/Users/tester/docs'}: + return Attr(stat.S_IFDIR | 0o755) + item = self.filesystem[path] + return Attr(stat.S_IFREG | 0o644, len(item['data']), item['mtime']) + + def lstat(self, path): + if path not in self.filesystem: + raise FileNotFoundError(path) + item = self.filesystem[path] + return Attr(stat.S_IFREG | 0o644, len(item['data']), item['mtime']) + + def listdir_iter(self, path, read_aheads=10): + assert read_aheads == 10 + if path == 'C:/Users/tester': + yield Attr(stat.S_IFDIR | 0o755, filename='docs') + prefix = path + '/' + for file_path, item in self.filesystem.items(): + if file_path.startswith(prefix) and '/' not in file_path[len(prefix):]: + yield Attr( + stat.S_IFREG | 0o644, + size=len(item['data']), + mtime=item['mtime'], + filename=file_path[len(prefix):], + ) + + def open(self, path, mode): + if mode == 'rb': + return RemoteReadFile(self.filesystem[path]['data']) + if mode == 'wx': + if path in self.filesystem: + raise FileExistsError(path) + return RemoteFile(self.filesystem, path) + raise AssertionError(f'unexpected mode: {mode}') + + def rename(self, source, destination): + if destination in self.filesystem: + raise FileExistsError(destination) + self.filesystem[destination] = self.filesystem.pop(source) + + def posix_rename(self, source, destination): + self.posix_renames.append((source, destination)) + self.filesystem[destination] = self.filesystem.pop(source) + + def remove(self, path): + self.filesystem.pop(path, None) + + def close(self): + pass + + filesystem = { + 'C:/Users/tester/reference.txt': {'data': b'old', 'mtime': 10}, + } + opened_clients = [] + bridge = make_sftp_test_bridge('session-sftp') + + def open_sftp(): + client = FakeSFTP(filesystem) + opened_clients.append(client) + return client + + bridge._open_sftp = open_sftp + browse = bridge.browse_sftp() + assert browse['path'] == 'C:/Users/tester' + assert browse['directories'] == [{'name': 'docs'}] + assert len(browse['files']) == 1 + assert browse['files'][0]['name'] == 'reference.txt' + assert browse['files'][0]['file_id'].startswith('sftpf_') + assert 'path' not in browse['files'][0] + assert browse['endpoint']['route'] == 'direct' + + reference = bridge.resolve_sftp_file_reference(browse['files'][0]['file_id']) + assert b''.join(bridge.download_sftp_chunks(reference, chunk_size=2)) == b'old' + + conflict = bridge.prepare_sftp_upload('C:/Users/tester', 'reference.txt') + assert conflict['status'] == 'conflict' + assert conflict['existing_size'] == 3 + + keep_both = bridge.prepare_sftp_upload('C:/Users/tester', 'reference.txt', 'keep_both') + assert keep_both['destination_path'] == 'C:/Users/tester/reference (1).txt' + result = bridge.upload_sftp_stream(io.BytesIO(b'new copy'), keep_both, 8) + assert result['bytes_written'] == 8 + assert filesystem['C:/Users/tester/reference (1).txt']['data'] == b'new copy' + assert filesystem['C:/Users/tester/reference.txt']['data'] == b'old' + + replace = bridge.prepare_sftp_upload('C:/Users/tester', 'reference.txt', 'replace') + result = bridge.upload_sftp_stream(io.BytesIO(b'replaced'), replace, 8) + assert result['destination_path'] == 'C:/Users/tester/reference.txt' + assert filesystem['C:/Users/tester/reference.txt']['data'] == b'replaced' + assert opened_clients[-1].posix_renames + + browse = bridge.browse_sftp() + reference_entry = next(item for item in browse['files'] if item['name'] == 'reference.txt') + reference = bridge.resolve_sftp_file_reference(reference_entry['file_id']) + filesystem['C:/Users/tester/existing.txt'] = {'data': b'existing', 'mtime': 30} + try: + bridge.rename_sftp_file( + reference['directory'], + reference['filename'], + 'existing.txt', + reference['size'], + reference['mtime'], + ) + raise AssertionError('rename unexpectedly overwrote an existing file') + except standterm.SFTPTransferError as exc: + assert exc.error_code == 'sftp_rename_destination_exists' + renamed = bridge.rename_sftp_file( + reference['directory'], + reference['filename'], + 'renamed.txt', + reference['size'], + reference['mtime'], + ) + assert renamed['destination_path'] == 'C:/Users/tester/renamed.txt' + assert 'C:/Users/tester/reference.txt' not in filesystem + + browse = bridge.browse_sftp() + renamed_entry = next(item for item in browse['files'] if item['name'] == 'renamed.txt') + renamed_reference = bridge.resolve_sftp_file_reference(renamed_entry['file_id']) + assert b''.join(bridge.download_sftp_chunks(renamed_reference, chunk_size=2)) == b'replaced' + filesystem['C:/Users/tester/renamed.txt']['mtime'] = 99 + try: + bridge.delete_sftp_file( + renamed_reference['directory'], + renamed_reference['filename'], + renamed_reference['size'], + renamed_reference['mtime'], + ) + raise AssertionError('delete unexpectedly removed a changed file') + except standterm.SFTPTransferError as exc: + assert exc.error_code == 'sftp_file_changed' + assert 'C:/Users/tester/renamed.txt' in filesystem + filesystem['C:/Users/tester/renamed.txt']['mtime'] = renamed_reference['mtime'] + deleted = bridge.delete_sftp_file( + renamed_reference['directory'], + renamed_reference['filename'], + renamed_reference['size'], + renamed_reference['mtime'], + ) + assert deleted['deleted_path'] == 'C:/Users/tester/renamed.txt' + assert 'C:/Users/tester/renamed.txt' not in filesystem + + +def test_sftp_socket_ticket_streams_one_file_and_is_single_use(): + flask_client = make_flask_client() + socket_client = make_socket_client(flask_client) + session_token = current_session_token() + sid = current_sid_for_session(session_token) + bridge = make_sftp_test_bridge(session_token) + bridge.attach(sid) + uploads = [] + file_actions = [] + file_snapshot = { + 'directory': '/home/tester', + 'filename': 'reference.txt', + 'path': '/home/tester/reference.txt', + 'size': 9, + 'mtime': 25, + 'endpoint': bridge.sftp_endpoint(), + } + bridge.browse_sftp = lambda path=None, child=None, parent=False: { + 'path': '/home/tester', + 'directories': [{'name': 'docs'}], + 'files': [{'file_id': 'sftpf_test', 'name': 'reference.txt', 'size': 9, 'mtime': 25}], + 'truncated': False, + 'endpoint': bridge.sftp_endpoint(), + } + bridge.prepare_sftp_upload = lambda directory, filename, conflict_mode='ask': { + 'status': 'ready', + 'directory': directory, + 'filename': filename, + 'destination_path': f'{directory}/{filename}', + 'replace': False, + 'existing_size': None, + 'existing_mtime': None, + 'endpoint': bridge.sftp_endpoint(), + } + bridge.resolve_sftp_file_reference = lambda file_id: dict(file_snapshot) if file_id == 'sftpf_test' else None + bridge.prepare_sftp_file = lambda directory, filename: dict(file_snapshot) + bridge.download_sftp_chunks = lambda snapshot: iter([b'refer', b'ence']) + bridge.rename_sftp_file = lambda directory, filename, new_filename, size, mtime: ( + file_actions.append(('rename', directory, filename, new_filename, size, mtime)) + or { + 'status': 'completed', + 'action': 'rename', + 'source_path': f'{directory}/{filename}', + 'destination_path': f'{directory}/{new_filename}', + 'filename': new_filename, + } + ) + bridge.delete_sftp_file = lambda directory, filename, size, mtime: ( + file_actions.append(('delete', directory, filename, size, mtime)) + or { + 'status': 'completed', + 'action': 'delete', + 'deleted_path': f'{directory}/{filename}', + 'filename': filename, + } + ) + + def upload_stream(stream, upload, expected_size, progress_callback=None): + data = stream.read() + uploads.append((upload, expected_size, data)) + return { + 'destination_path': upload['destination_path'], + 'filename': upload['filename'], + 'bytes_written': len(data), + } + + bridge.upload_sftp_stream = upload_stream + standterm.set_bridge(session_token, standterm.TERMINAL_ID_MAIN, bridge) + + socket_client.emit(standterm.SFTP_BROWSE_REQUEST_EVENT, { + 'request_id': 'browse-1', + 'terminal_id': standterm.TERMINAL_ID_MAIN, + 'path': None, + }) + browse = last_payload(socket_client, standterm.SFTP_BROWSE_RESULT_EVENT) + assert browse['status'] == 'ready' + assert browse['endpoint']['host'] == 'host.example' + assert browse['files'][0]['file_id'] == 'sftpf_test' + + socket_client.emit(standterm.SFTP_UPLOAD_TICKET_REQUEST_EVENT, { + 'request_id': 'upload-1', + 'terminal_id': standterm.TERMINAL_ID_MAIN, + 'directory': '/home/tester', + 'filename': 'reference.txt', + 'size': 9, + 'conflict_mode': 'ask', + }) + ticket = last_payload(socket_client, standterm.SFTP_UPLOAD_TICKET_RESULT_EVENT) + assert ticket['status'] == 'ready' + assert ticket['destination_path'] == '/home/tester/reference.txt' + + response = flask_client.post( + ticket['upload_url'], + data=b'reference', + content_type='application/octet-stream', + ) + assert response.status_code == 200 + assert response.get_json()['status'] == 'completed' + assert uploads[0][1:] == (9, b'reference') + + response = flask_client.post( + ticket['upload_url'], + data=b'reference', + content_type='application/octet-stream', + ) + assert response.status_code == 404 + + socket_client.emit(standterm.SFTP_DOWNLOAD_TICKET_REQUEST_EVENT, { + 'request_id': 'download-1', + 'terminal_id': standterm.TERMINAL_ID_MAIN, + 'file_id': 'sftpf_test', + }) + download = last_payload(socket_client, standterm.SFTP_DOWNLOAD_TICKET_RESULT_EVENT) + assert download['status'] == 'ready' + assert download['download_id'].startswith('sftpd_') + assert 'reference.txt' not in download['download_url'] + response = flask_client.get(download['download_url']) + assert response.status_code == 200 + assert response.data == b'reference' + assert response.headers['Content-Disposition'].startswith('attachment;') + assert flask_client.get(download['download_url']).status_code == 404 + + socket_client.emit(standterm.SFTP_FILE_ACTION_REQUEST_EVENT, { + 'request_id': 'rename-1', + 'terminal_id': standterm.TERMINAL_ID_MAIN, + 'action': 'rename', + 'file_id': 'sftpf_test', + 'new_filename': 'renamed.txt', + }) + renamed = last_payload(socket_client, standterm.SFTP_FILE_ACTION_RESULT_EVENT) + assert renamed['status'] == 'completed' + assert file_actions[-1] == ('rename', '/home/tester', 'reference.txt', 'renamed.txt', 9, 25) + + socket_client.emit(standterm.SFTP_FILE_ACTION_REQUEST_EVENT, { + 'request_id': 'delete-unconfirmed', + 'terminal_id': standterm.TERMINAL_ID_MAIN, + 'action': 'delete', + 'file_id': 'sftpf_test', + }) + unconfirmed = last_payload(socket_client, standterm.SFTP_FILE_ACTION_RESULT_EVENT) + assert unconfirmed['error_code'] == 'sftp_delete_confirmation_required' + + socket_client.emit(standterm.SFTP_FILE_ACTION_REQUEST_EVENT, { + 'request_id': 'delete-confirmed', + 'terminal_id': standterm.TERMINAL_ID_MAIN, + 'action': 'delete', + 'file_id': 'sftpf_test', + 'delete_confirmation': 'permanent_delete_confirmed', + }) + deleted = last_payload(socket_client, standterm.SFTP_FILE_ACTION_RESULT_EVENT) + assert deleted['status'] == 'completed' + assert file_actions[-1] == ('delete', '/home/tester', 'reference.txt', 9, 25) + + socket_client.emit(standterm.SFTP_FILE_ACTION_REQUEST_EVENT, { + 'request_id': 'delete-direct-name', + 'terminal_id': standterm.TERMINAL_ID_MAIN, + 'action': 'delete', + 'directory': '/home/tester', + 'filename': 'reference.txt', + 'delete_confirmation': 'permanent_delete_confirmed', + }) + rejected = last_payload(socket_client, standterm.SFTP_FILE_ACTION_RESULT_EVENT) + assert rejected['error_code'] == 'sftp_invalid_file_request' + socket_client.disconnect() + + def test_terminal_start_tokens_reject_stale_background_connections(): first = standterm.begin_terminal_start('session-1', 'main') assert standterm.is_current_terminal_start('session-1', 'main', first) is True @@ -5981,6 +6365,8 @@ def main(): test_browser_ssh_key_payload_requires_local_or_authorized_https_transport, test_browser_ed25519_key_wraps_and_verifies_remote_signature, test_browser_ssh_sign_request_store_is_sid_bound_and_fail_closed, + test_sftp_bridge_browses_direct_endpoint_and_replaces_atomically, + test_sftp_socket_ticket_streams_one_file_and_is_single_use, test_terminal_start_tokens_reject_stale_background_connections, test_remote_unauthorized_socket_cannot_attach_existing_ssh_terminal, test_browser_authorization_success_refreshes_visible_terminal_list, diff --git a/tests/agent_browser_smoke.py b/tests/agent_browser_smoke.py index f165b55..beeff17 100644 --- a/tests/agent_browser_smoke.py +++ b/tests/agent_browser_smoke.py @@ -522,8 +522,50 @@ def test_terminal_pip_hides_selected_tab_and_keeps_background_tab(browser, acces active_id = before_pip['tabs']['activeTerminalId'] background_id = next(item['id'] for item in before_pip['tabs']['tabs'] if item['id'] != active_id) - moved = page.evaluate("terminalId => window.terminalTest.setTerminalPipModeForTest(terminalId, true)", active_id) - check(moved is True, 'test hook could not move terminal into PiP mode') + attach_agent(page) + page.evaluate( + "payload => window.terminalTest.writeTerminalOutput(payload)", + '\x1b]2;PiP workspace title\x07', + ) + page.wait_for_function( + "() => document.getElementById('terminal-title').innerText === 'PiP workspace title'", + timeout=5000, + ) + check(page.evaluate('() => !!window.documentPictureInPicture'), 'Document PiP is unavailable in the test browser') + page.evaluate("terminalId => window.terminalTest.showContextMenuForTest(terminalId)", active_id) + page.click('#pip-option') + page.wait_for_function('() => !!window.documentPictureInPicture.window', timeout=5000) + + pip_status = page.evaluate( + """() => { + const pipDocument = documentPictureInPicture.window.document; + return { + applicationTitle: pipDocument.querySelector('.pip-application-title')?.innerText, + applicationTitleHidden: pipDocument.querySelector('.pip-application-title-item')?.hidden, + mintText: pipDocument.querySelector('.pip-agent-mint:not(.pip-agent-mint-3x)')?.innerText, + mintHidden: pipDocument.querySelector('.pip-agent-mint:not(.pip-agent-mint-3x)')?.hidden, + mint3xText: pipDocument.querySelector('.pip-agent-mint-3x')?.innerText, + mint3xHidden: pipDocument.querySelector('.pip-agent-mint-3x')?.hidden, + agentPanelText: pipDocument.querySelector('.pip-agent-panel')?.innerText + }; + }""" + ) + check(pip_status['applicationTitle'] == 'PiP workspace title', 'Terminal PiP did not show the OSC title') + check(pip_status['applicationTitleHidden'] is False, 'Terminal PiP hid a non-empty OSC title') + check(pip_status['mintText'] == 'Mint' and pip_status['mintHidden'] is False, 'Terminal PiP did not show Mint') + check(pip_status['mint3xText'] == 'Mint+' and pip_status['mint3xHidden'] is False, 'Terminal PiP did not show Mint+') + check(pip_status['agentPanelText'] == 'Show Agent Panel', 'Terminal PiP Agent panel control was incorrect') + + page.click('#quick-settings') + page.wait_for_selector('#settings-modal.open', timeout=5000) + page.click('.settings-nav-item[data-tab="appearance"]') + page.uncheck('#pref-showTerminalTitleInStatusBar') + page.click('#settings-save') + page.wait_for_function( + "() => documentPictureInPicture.window.document.querySelector('.pip-application-title-item').hidden", + timeout=5000, + ) + in_pip = page.evaluate( """() => ({ canPip: window.terminalTest.canMoveActiveTerminalToPip(), @@ -537,12 +579,48 @@ def test_terminal_pip_hides_selected_tab_and_keeps_background_tab(browser, acces check(moved_tab['inPip'] is True and moved_tab['hidden'] is True, 'PiP tab did not disappear from tab list') check(background_tab['active'] is True and background_tab['hidden'] is False, 'remaining tab was not active and visible') - target_set = page.evaluate("terminalId => window.terminalTest.setAgentPanelTargetForTest(terminalId)", active_id) - check(target_set is True, 'test hook could not target Agent panel at PiP terminal') - agent_target = page.evaluate("() => window.terminalTest.getTerminalTabsState()") - check(agent_target['agentPanelTerminalId'] == active_id, 'Agent panel did not target PiP terminal') + page.evaluate("() => documentPictureInPicture.window.document.querySelector('.pip-agent-mint-3x').click()") + page.wait_for_function( + """terminalId => { + const token = window.terminalTest.getAgentStateForTest(terminalId)?.external_token; + return token && (token.status === 'active' || token.status === 'error'); + }""", + arg=active_id, + timeout=5000, + ) + token_state = page.evaluate( + """terminalId => ({ + token: window.terminalTest.getAgentStateForTest(terminalId)?.external_token, + activeTerminalId: window.terminalTest.getTerminalTabsState().activeTerminalId + })""", + active_id, + ) + check(token_state['token']['status'] == 'active', 'Terminal PiP Mint+ did not mint an active token') + check(token_state['token']['idleTimeoutMultiplier'] == 3, 'Terminal PiP Mint+ did not request the 3x lifetime') + check(token_state['activeTerminalId'] == background_id, 'Terminal PiP Mint+ changed the main active terminal') + + page.evaluate("() => documentPictureInPicture.window.document.querySelector('.pip-agent-panel').click()") + page.wait_for_function( + """() => { + const pipDocument = documentPictureInPicture.window.document; + return !!pipDocument.querySelector('#agent-panel.visible') + && pipDocument.querySelector('.pip-agent-mint').hidden + && pipDocument.querySelector('.pip-agent-mint-3x').hidden; + }""", + timeout=5000, + ) + page.evaluate("() => documentPictureInPicture.window.document.querySelector('#agent-panel-close-btn').click()") + page.wait_for_function( + """() => { + const pipDocument = documentPictureInPicture.window.document; + return !pipDocument.querySelector('.pip-agent-mint').hidden + && !pipDocument.querySelector('.pip-agent-mint-3x').hidden; + }""", + timeout=5000, + ) - page.evaluate("terminalId => window.terminalTest.setTerminalPipModeForTest(terminalId, false)", active_id) + page.evaluate('() => documentPictureInPicture.window.close()') + page.wait_for_function('() => !window.documentPictureInPicture.window', timeout=5000) restored = page.evaluate("() => window.terminalTest.getTerminalTabsState()") restored_tab = next(item for item in restored['tabs'] if item['id'] == active_id) check(restored_tab['inPip'] is False and restored_tab['hidden'] is False, 'restored PiP tab did not return to tab list') @@ -550,6 +628,410 @@ def test_terminal_pip_hides_selected_tab_and_keeps_background_tab(browser, acces close_context(context) +def test_sftp_status_actions_and_terminal_pip_transition(browser, access_url): + context, page = new_page(browser, access_url) + try: + page.evaluate( + """() => window.terminalTest.applyTerminalListForTest({ + terminals: [ + { + terminal_id: 'main', + connection_type: 'ssh', + terminal_label: 'SSH', + term: 'xterm-256color', + connected: true + }, + { + terminal_id: 'term-2', + connection_type: 'local_shell', + terminal_label: 'bash', + term: 'xterm-256color', + connected: true + } + ] + })""" + ) + page.evaluate("() => window.terminalTest.switchTerminalForTest('main')") + available_status = page.evaluate( + """() => { + const button = document.getElementById('sftp-status-btn'); + return { + hidden: button.hidden, + disabled: button.disabled, + title: button.title, + text: button.innerText + }; + }""" + ) + check( + available_status == { + 'hidden': False, + 'disabled': False, + 'title': 'Open SFTP File Manager', + 'text': '📁', + }, + 'connected SSH status bar did not expose the SFTP action', + ) + page.click('#sftp-status-btn') + page.wait_for_function( + "() => documentPictureInPicture.window?.document.querySelector('.sftp-pip-title')?.textContent === 'SFTP File Manager'", + timeout=5000, + ) + page.evaluate('() => documentPictureInPicture.window.close()') + page.wait_for_function('() => !window.documentPictureInPicture.window', timeout=5000) + + page.evaluate("() => window.terminalTest.setSftpAvailabilityForTest('main', false)") + unavailable_status = page.evaluate( + """() => { + const button = document.getElementById('sftp-status-btn'); + const mark = button.querySelector('.sftp-unavailable-mark'); + return { + hidden: button.hidden, + disabled: button.disabled, + title: button.title, + text: button.innerText, + markColor: getComputedStyle(mark).color + }; + }""" + ) + check(unavailable_status['hidden'] is False, 'unavailable SFTP status action disappeared') + check(unavailable_status['disabled'] is True, 'unavailable SFTP status action remained enabled') + check(unavailable_status['title'] == 'SFTP not available', 'unavailable SFTP status hint was unclear') + check('×' in unavailable_status['text'], 'unavailable SFTP status action omitted its cross mark') + check(unavailable_status['markColor'] == 'rgb(255, 69, 58)', 'unavailable SFTP cross was not red') + unavailable_menu = page.evaluate("() => window.terminalTest.showContextMenuForTest('main')") + check(unavailable_menu['sftpVisible'] is True, 'unavailable SSH context action disappeared') + check(unavailable_menu['sftpDisabled'] is True, 'unavailable SSH context action remained enabled') + check('SFTP not available' in unavailable_menu['sftpText'], 'unavailable SSH context action hint was unclear') + + page.evaluate("() => window.terminalTest.setSftpAvailabilityForTest('main', null)") + page.evaluate("() => window.terminalTest.showContextMenuForTest('main')") + page.click('#pip-option') + page.wait_for_function('() => !!window.documentPictureInPicture.window', timeout=5000) + pip_action = page.evaluate( + """() => { + const button = documentPictureInPicture.window.document.querySelector('.pip-sftp-button'); + return { + hidden: button.hidden, + disabled: button.disabled, + title: button.title, + text: button.innerText + }; + }""" + ) + check( + pip_action == { + 'hidden': False, + 'disabled': False, + 'title': 'Open SFTP File Manager', + 'text': '📁', + }, + 'Terminal PiP did not expose the SFTP action', + ) + + page.evaluate("() => documentPictureInPicture.window.document.querySelector('.pip-sftp-button').click()") + page.wait_for_function( + "() => documentPictureInPicture.window?.document.querySelector('.sftp-pip-title')?.textContent === 'SFTP File Manager'", + timeout=5000, + ) + restored = page.evaluate("() => window.terminalTest.getTerminalTabsState()") + restored_main = next(item for item in restored['tabs'] if item['id'] == 'main') + check(restored_main['inPip'] is False, 'opening SFTP from Terminal PiP did not restore the terminal') + page.evaluate('() => documentPictureInPicture.window.close()') + page.wait_for_function('() => !window.documentPictureInPicture.window', timeout=5000) + finally: + close_context(context) + + +def test_sftp_send_context_action_is_limited_to_connected_ssh_tabs(browser, access_url): + context, page = new_page(browser, access_url) + browser_console = [] + page.on('console', lambda message: browser_console.append(message.text)) + try: + page.evaluate( + """() => window.terminalTest.applyTerminalListForTest({ + terminals: [{ + terminal_id: 'main', + connection_type: 'ssh', + terminal_label: 'SSH', + term: 'xterm-256color', + connected: true + }] + })""" + ) + ssh_menu = page.evaluate("() => window.terminalTest.showContextMenuForTest('main')") + check(ssh_menu['terminalId'] == 'main', 'SFTP context action targeted the wrong terminal') + check(ssh_menu['sftpVisible'] is True, 'connected SSH tab did not show SFTP send action') + check('SFTP File Manager' in ssh_menu['sftpText'], 'SFTP context action label was unclear') + check(page.evaluate('() => !!window.documentPictureInPicture'), 'Document PiP is unavailable in the test browser') + page.click('#sftp-send-option') + page.wait_for_function('() => !!window.documentPictureInPicture.window', timeout=5000) + pip_state = page.evaluate( + """() => ({ + title: documentPictureInPicture.window.document.querySelector('.sftp-pip-title')?.textContent, + hint: documentPictureInPicture.window.document.querySelector('.sftp-direct-hint')?.textContent, + hasDropZone: !!documentPictureInPicture.window.document.querySelector('.sftp-drop-zone'), + hasPathInput: !!documentPictureInPicture.window.document.querySelector('.sftp-path-input') + })""" + ) + check(pip_state['title'] == 'SFTP File Manager', 'SFTP PiP title was missing') + check('Nested SSH sessions' in pip_state['hint'], 'SFTP PiP did not explain the direct endpoint boundary') + check(pip_state['hasDropZone'] is True, 'SFTP PiP did not expose a file drop zone') + check(pip_state['hasPathInput'] is True, 'SFTP PiP did not expose destination path navigation') + + page.wait_for_function( + "() => documentPictureInPicture.window.document.querySelector('.sftp-transfer-status')?.textContent !== 'Opening SFTP…'", + timeout=5000, + ) + rendered = page.evaluate( + """() => window.terminalTest.renderSftpEntriesForTest({ + path: '/home/tester', + directories: [{ name: 'docs' }], + files: [ + { file_id: 'sftpf_random_a', name: 'reference.txt', size: 9, mtime: 25 }, + { file_id: 'sftpf_random_b', name: 'existing.txt', size: 4, mtime: 26 } + ] + })""" + ) + check(rendered is True, 'SFTP PiP test fixture could not render remote files') + clear_emitted(page) + file_ui = page.evaluate( + """() => { + const pipDocument = documentPictureInPicture.window.document; + const files = [...pipDocument.querySelectorAll('.sftp-file-entry')]; + files[0].click(); + const preparing = { + disabled: pipDocument.querySelector('.sftp-file-download').disabled, + text: pipDocument.querySelector('.sftp-file-download').innerText + }; + const request = window.terminalTest.getEmitted() + .find(item => item.event === 'sftp_download_ticket_request'); + window.terminalTest.handleSftpDownloadTicketResultForTest({ + request_id: request.args[0].request_id, + terminal_id: 'main', + status: 'ready', + download_url: '/sftp/download/test-ticket', + download_id: 'sftpd_testlog', + filename: 'reference.txt', + size: 9, + expires_in_seconds: 60 + }); + return { + fileCount: files.length, + operationVisible: pipDocument.querySelector('.sftp-file-operation-box').classList.contains('visible'), + actions: [...pipDocument.querySelectorAll('.sftp-file-operation-actions button')].map(button => button.innerText), + preparing, + downloadReady: !pipDocument.querySelector('.sftp-file-download').disabled + }; + }""" + ) + check(file_ui['fileCount'] == 2, 'SFTP PiP did not list regular files') + check(file_ui['operationVisible'] is True, 'selecting an SFTP file did not open file actions') + check(file_ui['actions'] == ['Download', 'Rename…', 'Delete…', 'Close'], 'SFTP file actions were incomplete') + check(file_ui['preparing'] == {'disabled': True, 'text': 'Preparing…'}, 'SFTP Download was enabled before its ticket was ready') + check(file_ui['downloadReady'] is True, 'SFTP Download was not enabled after its ticket became ready') + + download_requests = get_emitted(page, 'sftp_download_ticket_request') + check(len(download_requests) == 1, 'selecting an SFTP file did not prepare one download ticket') + download_payload = download_requests[0]['args'][0] + check(download_payload['file_id'] == 'sftpf_random_a', 'SFTP Download did not use the backend file ID') + check('filename' not in download_payload and 'directory' not in download_payload, 'SFTP Download used display names as control data') + browser_download = page.evaluate( + """() => { + let clicked = null; + const pipWindow = documentPictureInPicture.window; + const originalClick = pipWindow.HTMLAnchorElement.prototype.click; + pipWindow.HTMLAnchorElement.prototype.click = function () { + clicked = { + href: this.href, + filename: this.download, + target: this.target, + rel: this.rel, + hiddenByStyle: this.style.display === 'none', + ownerIsPipDocument: this.ownerDocument === pipWindow.document + }; + }; + try { + documentPictureInPicture.window.document.querySelector('.sftp-file-download').click(); + return { + clicked, + remainingLinks: document.querySelectorAll('a[href*="/sftp/download/"]').length, + buttonDisabled: documentPictureInPicture.window.document.querySelector('.sftp-file-download').disabled, + buttonText: documentPictureInPicture.window.document.querySelector('.sftp-file-download').innerText + }; + } finally { + pipWindow.HTMLAnchorElement.prototype.click = originalClick; + } + }""" + ) + check(browser_download['clicked'] is not None, 'the ready SFTP Download button did not trigger a browser download') + access_parts = urllib.parse.urlsplit(access_url) + access_origin = f'{access_parts.scheme}://{access_parts.netloc}' + check(browser_download['clicked']['href'].startswith(access_origin + '/sftp/download/'), 'SFTP browser download lost the main page origin') + check(browser_download['clicked']['filename'] == '', 'SFTP browser download did not defer the filename to Content-Disposition') + check(browser_download['clicked']['target'] == '_blank', 'SFTP browser download tried to navigate the non-navigable PiP window') + check(browser_download['clicked']['rel'] == 'noopener', 'SFTP browser download did not isolate the top-level download context') + check(browser_download['clicked']['hiddenByStyle'] is True, 'SFTP browser download trigger could become visible') + check(browser_download['clicked']['ownerIsPipDocument'] is True, 'SFTP browser download did not preserve the PiP user-activation context') + check(browser_download['remainingLinks'] == 0, 'SFTP browser download trigger was not removed') + check(browser_download['buttonDisabled'] is True and browser_download['buttonText'] == 'Downloaded', 'used SFTP download ticket remained actionable') + check(any(message.startswith('[sftp] Download ticket requested') for message in browser_console), 'SFTP browser log omitted the ticket request') + check(any(message.startswith('[sftp] Download ticket ready') for message in browser_console), 'SFTP browser log omitted the ready ticket') + check(any(message.startswith('[sftp] Download button clicked') for message in browser_console), 'SFTP browser log omitted the explicit click') + check(any(message.startswith('[sftp] Download link dispatched') for message in browser_console), 'SFTP browser log omitted the link dispatch') + + page.evaluate("() => documentPictureInPicture.window.document.querySelector('.sftp-file-rename').click()") + rename_initial = page.evaluate( + """() => { + const pipDocument = documentPictureInPicture.window.document; + return { + confirmDisabled: pipDocument.querySelector('.sftp-rename-confirm').disabled, + hint: pipDocument.querySelector('.sftp-rename-hint').innerText, + inputFocused: pipDocument.activeElement === pipDocument.querySelector('.sftp-rename-input') + }; + }""" + ) + check(rename_initial['confirmDisabled'] is True, 'Rename allowed the unchanged file name') + check('already exists' in rename_initial['hint'], 'Rename did not explain the duplicate name') + check(rename_initial['inputFocused'] is True, 'Rename did not focus the file name input') + rename_duplicate = page.evaluate( + """() => { + const pipDocument = documentPictureInPicture.window.document; + const input = pipDocument.querySelector('.sftp-rename-input'); + input.value = 'existing.txt'; + input.dispatchEvent(new Event('input', { bubbles: true })); + return pipDocument.querySelector('.sftp-rename-confirm').disabled; + }""" + ) + check(rename_duplicate is True, 'Rename allowed another existing file name') + rename_ready = page.evaluate( + """() => { + const pipDocument = documentPictureInPicture.window.document; + const input = pipDocument.querySelector('.sftp-rename-input'); + input.value = 'renamed.txt'; + input.dispatchEvent(new Event('input', { bubbles: true })); + return pipDocument.querySelector('.sftp-rename-confirm').disabled; + }""" + ) + check(rename_ready is False, 'Rename kept a unique file name disabled') + page.evaluate( + """() => documentPictureInPicture.window.document.querySelector('.sftp-rename-input') + .dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))""" + ) + rename_cancelled = page.evaluate( + """() => { + const pipDocument = documentPictureInPicture.window.document; + return { + controlsVisible: pipDocument.querySelector('.sftp-rename-controls').classList.contains('visible'), + renameFocused: pipDocument.activeElement === pipDocument.querySelector('.sftp-file-rename') + }; + }""" + ) + check(rename_cancelled == {'controlsVisible': False, 'renameFocused': True}, 'Rename Escape did not return to file actions') + + clear_emitted(page) + page.evaluate( + """() => { + const pipDocument = documentPictureInPicture.window.document; + pipDocument.querySelector('.sftp-file-rename').click(); + const input = pipDocument.querySelector('.sftp-rename-input'); + input.value = 'renamed.txt'; + input.dispatchEvent(new Event('input', { bubbles: true })); + pipDocument.querySelector('.sftp-rename-confirm').click(); + }""" + ) + rename_requests = get_emitted(page, 'sftp_file_action_request') + check(len(rename_requests) == 1, 'SFTP Rename did not emit one action request') + rename_payload = rename_requests[0]['args'][0] + check(rename_payload['file_id'] == 'sftpf_random_a', 'SFTP Rename did not use the backend file ID') + check(rename_payload['new_filename'] == 'renamed.txt', 'SFTP Rename lost the new file name') + check('filename' not in rename_payload and 'directory' not in rename_payload, 'SFTP Rename used the old display name as control data') + page.wait_for_function( + "() => !documentPictureInPicture.window.document.querySelector('.sftp-rename-confirm').disabled", + timeout=5000, + ) + + page.evaluate( + """() => { + window.terminalTest.renderSftpEntriesForTest({ + path: '/home/tester', + files: [{ file_id: 'sftpf_random_a', name: 'reference.txt', size: 9, mtime: 25 }] + }); + const pipDocument = documentPictureInPicture.window.document; + pipDocument.querySelector('.sftp-file-entry').click(); + pipDocument.querySelector('.sftp-file-delete').click(); + }""" + ) + first_delete = page.evaluate( + """() => { + const pipDocument = documentPictureInPicture.window.document; + const yes = pipDocument.querySelector('.sftp-delete-yes').getBoundingClientRect(); + return { + question: pipDocument.querySelector('.sftp-delete-phase-one .sftp-delete-question').innerText, + path: pipDocument.querySelector('.sftp-delete-path').innerText, + noFocused: pipDocument.activeElement === pipDocument.querySelector('.sftp-delete-no'), + secondPhaseVisible: pipDocument.querySelector('.sftp-delete-actions.phase-two').getClientRects().length > 0, + yesCenterX: yes.left + yes.width / 2 + }; + }""" + ) + check(first_delete['question'] == 'Do you want to delete this file?', 'first delete warning was unclear') + check(first_delete['path'] == '/home/tester/reference.txt', 'delete warning did not show the full remote path') + check(first_delete['noFocused'] is True, 'first delete warning did not focus No') + check(first_delete['secondPhaseVisible'] is False, 'second delete actions were visible during the first phase') + page.evaluate("() => documentPictureInPicture.window.document.querySelector('.sftp-delete-yes').click()") + second_delete = page.evaluate( + """() => { + const pipDocument = documentPictureInPicture.window.document; + const sure = pipDocument.querySelector('.sftp-delete-sure').getBoundingClientRect(); + const dont = pipDocument.querySelector('.sftp-delete-dont').getBoundingClientRect(); + return { + question: pipDocument.querySelector('.sftp-delete-phase-two .sftp-delete-question').innerText, + dontFocused: pipDocument.activeElement === pipDocument.querySelector('.sftp-delete-dont'), + firstPhaseVisible: pipDocument.querySelector('.sftp-delete-actions.phase-one').getClientRects().length > 0, + sure: { left: sure.left, right: sure.right }, + dont: { left: dont.left, right: dont.right } + }; + }""" + ) + check('cannot be recovered' in second_delete['question'], 'second delete warning did not state permanent loss') + check(second_delete['dontFocused'] is True, 'second delete warning did not focus the safe action') + check(second_delete['firstPhaseVisible'] is False, 'first delete actions were visible during the second phase') + original_x = first_delete['yesCenterX'] + check( + not (second_delete['sure']['left'] <= original_x <= second_delete['sure']['right']) + and not (second_delete['dont']['left'] <= original_x <= second_delete['dont']['right']), + 'second delete actions overlapped the first Yes click position', + ) + + clear_emitted(page) + page.evaluate("() => documentPictureInPicture.window.document.querySelector('.sftp-delete-sure').click()") + delete_requests = get_emitted(page, 'sftp_file_action_request') + check(len(delete_requests) == 1, 'SFTP Delete did not emit one action request') + delete_payload = delete_requests[0]['args'][0] + check(delete_payload['file_id'] == 'sftpf_random_a', 'SFTP Delete did not use the backend file ID') + check(delete_payload['delete_confirmation'] == 'permanent_delete_confirmed', 'SFTP Delete omitted structured confirmation') + check('filename' not in delete_payload and 'directory' not in delete_payload, 'SFTP Delete used display names as control data') + page.evaluate('() => documentPictureInPicture.window.close()') + page.wait_for_function('() => !window.documentPictureInPicture.window', timeout=5000) + + page.evaluate( + """() => window.terminalTest.applyTerminalListForTest({ + terminals: [{ + terminal_id: 'main', + connection_type: 'local_shell', + terminal_label: 'bash', + term: 'xterm-256color', + connected: true + }] + })""" + ) + local_menu = page.evaluate("() => window.terminalTest.showContextMenuForTest('main')") + check(local_menu['sftpVisible'] is False, 'local shell tab exposed the SFTP send action') + finally: + close_context(context) + + def test_restored_terminal_list_allocates_next_new_tab_id(browser, access_url): context, page = new_page(browser, access_url) try: @@ -901,7 +1383,12 @@ def test_session_recovery_new_tab_can_renew_external_agent_token(browser, access page.evaluate("() => window.terminalTest.showSessionRecoveryForTest()") page.click('#session-recovery-remembered-token') page.wait_for_function( - "() => window.terminalTest.getSocketState().connected === true", + """() => { + const socket = window.terminalTest.getSocketState(); + return !document.getElementById('session-recovery-modal').classList.contains('open') + && socket.connected === true + && socket.serverConnectionState === 'available'; + }""", timeout=10000, ) page.wait_for_function( @@ -2849,6 +3336,8 @@ def main(): test_invalid_session_reconnect_prompts_for_current_token, test_agent_panel_can_be_dragged, test_terminal_pip_hides_selected_tab_and_keeps_background_tab, + test_sftp_status_actions_and_terminal_pip_transition, + test_sftp_send_context_action_is_limited_to_connected_ssh_tabs, test_restored_terminal_list_allocates_next_new_tab_id, test_operator_observation_warning_ui, test_hidden_mirror_ignores_visible_scroll,