mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-31 19:02:31 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b92177ec42 | ||
|
|
54481b6306 | ||
|
|
7e81bece9e | ||
|
|
04ee9afa7f | ||
|
|
efe5d4530d | ||
|
|
32e75b6ace | ||
|
|
4a831f41c2 | ||
|
|
141148ae0d | ||
|
|
8d15a29bc6 | ||
|
|
fcb6723c3d | ||
|
|
519c24cc4c | ||
|
|
0256e7471d | ||
|
|
a4b1009c2a | ||
|
|
54b6f85b09 | ||
|
|
62798393c3 | ||
|
|
d382f7bd4f | ||
|
|
ff36dc7bce | ||
|
|
d45fb75370 | ||
|
|
5a1b50a407 | ||
|
|
0e7ba69edb | ||
|
|
babf97e2c1 | ||
|
|
622ffec2cd | ||
|
|
598de9cda0 | ||
|
|
3c1afa9b7d | ||
|
|
36d9a7bd7f | ||
|
|
c44633231a | ||
|
|
b1a13011a4 | ||
|
|
631ce6c1d5 | ||
|
|
3e9ac6a36b | ||
|
|
cbb1262955 | ||
|
|
7dcb52062f | ||
|
|
2f1cc57c54 | ||
|
|
c54df5e7d5 | ||
|
|
bb1d1ce8f7 |
No files matched your search
@@ -964,9 +964,53 @@ Highlight the focused split pane:
|
||||
'settings.shortcuts.category.terminal': 'Terminal',
|
||||
'settings.shortcuts.category.navigation': 'Navigation',
|
||||
'settings.shortcuts.category.app': 'App',
|
||||
'settings.shortcuts.binding.toggle-host-tree': 'Toggle Host List',
|
||||
'settings.shortcuts.category.sftp': 'SFTP',
|
||||
|
||||
// Settings > Shortcuts -> key bindings
|
||||
'settings.shortcuts.binding.switch-tab-1-9': 'Switch to Tab [1...9]',
|
||||
'settings.shortcuts.binding.next-tab': 'Next Tab',
|
||||
'settings.shortcuts.binding.prev-tab': 'Previous Tab',
|
||||
'settings.shortcuts.binding.close-tab': 'Close Tab',
|
||||
'settings.shortcuts.binding.close-session': 'Close Session Pane',
|
||||
'settings.shortcuts.binding.new-tab': 'New Local Tab',
|
||||
'settings.shortcuts.binding.copy': 'Copy from Terminal',
|
||||
'settings.shortcuts.binding.paste': 'Paste to Terminal',
|
||||
'settings.shortcuts.binding.paste-selection': 'Paste Selection to Terminal',
|
||||
'settings.shortcuts.binding.select-all': 'Select All in Terminal',
|
||||
'settings.shortcuts.binding.clear-buffer': 'Clear Terminal Buffer',
|
||||
'settings.shortcuts.binding.search-terminal': 'Open Terminal Search',
|
||||
'settings.shortcuts.binding.increase-terminal-font-size': 'Increase Terminal Font Size',
|
||||
'settings.shortcuts.binding.decrease-terminal-font-size': 'Decrease Terminal Font Size',
|
||||
'settings.shortcuts.binding.reset-terminal-font-size': 'Reset Terminal Font Size',
|
||||
'settings.shortcuts.binding.move-focus': 'Move focus between Split View panes',
|
||||
'settings.shortcuts.binding.split-horizontal': 'Split Horizontal',
|
||||
'settings.shortcuts.binding.split-vertical': 'Split Vertical',
|
||||
'settings.shortcuts.binding.toggle-pane-zoom': 'Toggle Pane Zoom',
|
||||
'settings.shortcuts.binding.open-hosts': 'Open Hosts Page',
|
||||
'settings.shortcuts.binding.open-local': 'Open Local Terminal',
|
||||
'settings.shortcuts.binding.open-sftp': 'Open SFTP',
|
||||
'settings.shortcuts.binding.port-forwarding': 'Open Port Forwarding',
|
||||
'settings.shortcuts.binding.command-palette': 'Open Command Palette',
|
||||
'settings.shortcuts.binding.quick-switch': 'Quick Switch',
|
||||
'settings.shortcuts.binding.new-workspace': 'New Workspace',
|
||||
'settings.shortcuts.binding.snippets': 'Open Snippets',
|
||||
'settings.shortcuts.binding.broadcast': 'Switch the Broadcast Mode',
|
||||
'settings.shortcuts.binding.toggle-side-panel': 'Toggle Side Panel',
|
||||
'settings.shortcuts.binding.toggle-host-tree': 'Toggle Host List',
|
||||
'settings.shortcuts.binding.open-settings': 'Open Settings',
|
||||
'settings.shortcuts.binding.toggle-opacity': 'Toggle Window Transparency',
|
||||
'settings.shortcuts.binding.sftp-copy': 'Copy Files',
|
||||
'settings.shortcuts.binding.sftp-cut': 'Cut Files',
|
||||
'settings.shortcuts.binding.sftp-paste': 'Paste Files',
|
||||
'settings.shortcuts.binding.sftp-select-all': 'Select All Files',
|
||||
'settings.shortcuts.binding.sftp-rename': 'Rename File',
|
||||
'settings.shortcuts.binding.sftp-delete': 'Delete Files',
|
||||
'settings.shortcuts.binding.sftp-refresh': 'Refresh',
|
||||
'settings.shortcuts.binding.sftp-new-folder': 'New Folder',
|
||||
'settings.shortcuts.binding.sftp-open': 'Open File / Enter Directory',
|
||||
'settings.shortcuts.binding.sftp-go-parent': 'Go to Parent Directory',
|
||||
'settings.shortcuts.binding.sftp-navigate-to': 'Navigate to Selected Directory',
|
||||
|
||||
// Context menus / common actions
|
||||
'action.newHost': 'New Host',
|
||||
'action.newSubfolder': 'New Subfolder',
|
||||
|
||||
@@ -90,6 +90,15 @@ export const esAiMessages: Messages = {
|
||||
'ai.providers.advancedParams.hint': 'Déjalo en blanco para usar los valores predeterminados del proveedor.',
|
||||
'ai.providers.advancedParams.maxTokens.placeholder': 'p. ej. 4096',
|
||||
'ai.providers.advancedParams.default': 'Predeterminado del proveedor',
|
||||
'ai.providers.headers': 'Cabeceras HTTP personalizadas',
|
||||
'ai.providers.headers.help': 'Cabeceras opcionales para este proveedor. Los valores se ocultan y se cifran al guardarlos.',
|
||||
'ai.providers.headers.name': 'Nombre de la cabecera',
|
||||
'ai.providers.headers.value': 'Valor de la cabecera',
|
||||
'ai.providers.headers.add': 'Agregar cabecera',
|
||||
'ai.providers.headers.remove': 'Quitar cabecera',
|
||||
'ai.providers.headers.invalid': 'Usa cabeceras HTTP válidas y únicas. Content-Length y Transfer-Encoding se gestionan automáticamente.',
|
||||
'ai.providers.headers.loadError': 'No se pudieron desbloquear las cabeceras guardadas. Cancela y vuelve a intentarlo después de desbloquear el llavero del sistema.',
|
||||
'ai.providers.headers.saveError': 'No se pudo guardar de forma segura. Comprueba que el llavero del sistema esté disponible y vuelve a intentarlo.',
|
||||
|
||||
// AI Codex
|
||||
'ai.codex': 'Codex',
|
||||
@@ -280,6 +289,14 @@ export const esAiMessages: Messages = {
|
||||
'ai.opencode.customPathPlaceholder': 'p. ej. /usr/local/bin/opencode',
|
||||
'ai.opencode.check': 'Verificar',
|
||||
'ai.opencode.resetPath': 'Restablecer',
|
||||
'ai.opencode.v2.startupTimeout': 'OpenCode 2 no estuvo listo a tiempo. Confirma que `opencode serve` funciona en una terminal y vuelve a intentarlo.',
|
||||
'ai.opencode.v2.exited': 'OpenCode 2 se detuvo antes de que el chat pudiera iniciarse.',
|
||||
'ai.opencode.v2.unauthorized': 'OpenCode 2 rechazó la conexión. Vuelve a intentar el chat.',
|
||||
'ai.opencode.v2.unavailable': 'No se pudo contactar con OpenCode 2. Comprueba que esté instalado y vuelve a intentarlo.',
|
||||
'ai.opencode.v2.sessionFailed': 'OpenCode 2 no pudo completar este turno.',
|
||||
'ai.opencode.v2.empty': 'OpenCode 2 devolvió una respuesta vacía. Ejecuta `opencode` en una terminal para configurar la autenticación y los modelos.',
|
||||
'ai.opencode.v2.contextSkipped': 'OpenCode 2 no pudo aplicar el contexto de la conversación de Netcatty.',
|
||||
'ai.opencode.v2.mcpSkipped': 'OpenCode 2 no pudo adjuntar una herramienta MCP. El chat continuará sin ella.',
|
||||
|
||||
// AI MiMo Code (CLI de Xiaomi MiMo Code — fork de OpenCode)
|
||||
'ai.mimo.title': 'MiMo Code',
|
||||
@@ -694,6 +711,9 @@ export const esAiMessages: Messages = {
|
||||
'terminal.layer.splitHorizontal': 'Dividir arriba y abajo',
|
||||
'terminal.layer.splitVertical': 'Dividir izquierda y derecha',
|
||||
'terminal.layer.openInNewSplit': 'Abrir en una división nueva',
|
||||
'terminal.layer.saveLayoutAsDefault': 'Guardar diseño como predeterminado',
|
||||
'terminal.layer.layoutSavedAsDefault': 'Diseño guardado como predeterminado',
|
||||
'terminal.layer.layoutSaveFailed': 'No se pudo guardar el diseño',
|
||||
'terminal.layer.hostTree.search': 'Buscar hosts...',
|
||||
'terminal.layer.hostTree.searchButton': 'Buscar',
|
||||
'terminal.layer.hostTree.tagsButton': 'Filtrar por etiquetas',
|
||||
|
||||
@@ -84,6 +84,32 @@ export const esCoreMessages: Messages = {
|
||||
'credentials.protectionUnavailable.message': 'Las contraseñas y claves guardadas no se pueden descifrar automáticamente en este dispositivo. Vuelve a ingresar las credenciales antes de conectarte.',
|
||||
'credentials.protectionUnavailable.action': 'Abrir configuración',
|
||||
|
||||
// App Lock
|
||||
'appLock.title': 'Netcatty está bloqueado',
|
||||
'appLock.reason.default': 'Ingresa tu contraseña de bloqueo para continuar.',
|
||||
'appLock.reason.startup': 'Netcatty se bloquea cada vez que se abre esta ventana.',
|
||||
'appLock.reason.idle': 'Netcatty se bloqueó tras el tiempo de inactividad.',
|
||||
'appLock.reason.manual': 'Netcatty se bloqueó manualmente.',
|
||||
'appLock.passwordLabel': 'Contraseña de bloqueo',
|
||||
'appLock.passwordPlaceholder': 'Ingresa la contraseña para desbloquear',
|
||||
'appLock.unlock': 'Desbloquear',
|
||||
'appLock.unlocking': 'Desbloqueando...',
|
||||
'appLock.usePassword': 'Usar contraseña',
|
||||
'appLock.error.emptyPassword': 'Ingresa la contraseña de bloqueo.',
|
||||
'appLock.error.incorrectPassword': 'La contraseña de bloqueo es incorrecta.',
|
||||
'appLock.systemUnlock.unlockWith': 'Desbloquear con {label}',
|
||||
'appLock.systemUnlock.preparing': 'Preparando {label}...',
|
||||
'appLock.systemUnlock.verifying': 'Esperando a {label}...',
|
||||
'appLock.systemUnlock.error': 'No se completó el desbloqueo del sistema. Usa la contraseña de bloqueo para continuar.',
|
||||
'appLock.logoLabel': 'Netcatty',
|
||||
'appLock.reset.title': 'Restablecer el bloqueo de la aplicación',
|
||||
'appLock.reset.description': 'Esto elimina la contraseña de bloqueo actual y desactiva el bloqueo de la aplicación. Crea una nueva contraseña desde Configuración cuando la necesites.',
|
||||
'appLock.reset.cancel': 'Cancelar',
|
||||
'appLock.reset.confirm': 'Restablecer el bloqueo de la aplicación',
|
||||
'appLock.reset.resetting': 'Restableciendo...',
|
||||
'appLock.reset.error': 'No se pudo restablecer el bloqueo de la aplicación. Inténtalo de nuevo.',
|
||||
'topTabs.lockApp': 'Bloquear Netcatty',
|
||||
|
||||
// Settings shell
|
||||
'settings.title': 'Configuración',
|
||||
'settings.tab.application': 'Aplicación',
|
||||
@@ -119,6 +145,65 @@ export const esCoreMessages: Messages = {
|
||||
'settings.system.credentials.unavailableHint': 'Las credenciales cifradas en otro perfil de usuario o máquina no se pueden descifrar aquí. Vuelve a ingresar y guarda las credenciales en este dispositivo.',
|
||||
'settings.system.credentials.portabilityHint': 'La sincronización en la nube es portátil porque usa tu cifrado de clave maestra. El cifrado local de safeStorage está limitado al dispositivo y al usuario.',
|
||||
|
||||
// Settings > System > App Lock
|
||||
'settings.appLock.title': 'Bloqueo de la aplicación',
|
||||
'settings.appLock.description': 'Solicita una contraseña de bloqueo local cuando Netcatty se abre, tras un periodo de inactividad o cuando lo bloqueas manualmente.',
|
||||
'settings.appLock.setupTitle': 'Define una contraseña de bloqueo',
|
||||
'settings.appLock.setupDescription': 'Crea la contraseña a continuación. El bloqueo de la aplicación se activa en cuanto se guarda.',
|
||||
'settings.appLock.manageTitle': 'El bloqueo de la aplicación está listo',
|
||||
'settings.appLock.enabledStatus': 'Activado. Netcatty se bloquea al iniciar, tras un periodo de inactividad y cuando lo bloqueas manualmente.',
|
||||
'settings.appLock.disabledStatus': 'Contraseña guardada, pero el bloqueo de la aplicación está desactivado.',
|
||||
'settings.appLock.disableTitle': 'Desactivar el bloqueo de la aplicación',
|
||||
'settings.appLock.disableDescription': 'Ingresa la contraseña de bloqueo actual para dejar de bloquear Netcatty. La contraseña guardada se eliminará; para volver a activar el bloqueo tendrás que crear una nueva.',
|
||||
'settings.appLock.disable': 'Desactivar el bloqueo de la aplicación',
|
||||
'settings.appLock.disabling': 'Desactivando...',
|
||||
'settings.appLock.setupPasswordTitle': 'Crear contraseña de bloqueo',
|
||||
'settings.appLock.setupPasswordDescription': 'Esta contraseña es necesaria para desbloquear Netcatty y para cambiar o desactivar el bloqueo de la aplicación más adelante.',
|
||||
'settings.appLock.changePasswordTitle': 'Cambiar la contraseña de bloqueo',
|
||||
'settings.appLock.changePasswordDescription': 'Ingresa la contraseña de bloqueo actual y luego elige una nueva.',
|
||||
'settings.appLock.enable': 'Activar el bloqueo de la aplicación',
|
||||
'settings.appLock.enableDesc': 'Bloquea al iniciar y tras el tiempo de inactividad seleccionado.',
|
||||
'settings.appLock.timeout': 'Tiempo de inactividad',
|
||||
'settings.appLock.timeoutDesc': 'Bloquea tras un periodo de inactividad o al cerrar la ventana en la bandeja. Nunca omite ambos casos.',
|
||||
'settings.appLock.timeout.0': 'Nunca bloquear automáticamente',
|
||||
'settings.appLock.timeout.1': '1 minuto',
|
||||
'settings.appLock.timeout.5': '5 minutos',
|
||||
'settings.appLock.timeout.15': '15 minutos',
|
||||
'settings.appLock.timeout.30': '30 minutos',
|
||||
'settings.appLock.timeout.60': '60 minutos',
|
||||
'settings.appLock.systemUnlock.label': 'Desbloqueo con {label}',
|
||||
'settings.appLock.systemUnlock.desc': 'Usa {label} como forma alternativa de desbloquear Netcatty en este dispositivo.',
|
||||
'settings.appLock.systemUnlock.unavailableDesc': '{label} no está disponible en este dispositivo ahora mismo.',
|
||||
'settings.appLock.systemUnlock.unavailable': 'El desbloqueo del sistema no está disponible en este dispositivo.',
|
||||
'settings.appLock.systemUnlock.locked': 'Desbloquea Netcatty antes de cambiar el desbloqueo del sistema.',
|
||||
'settings.appLock.systemUnlock.autoPrompt.label': 'Abrir {label} automáticamente',
|
||||
'settings.appLock.systemUnlock.autoPrompt.desc': 'Muestra el aviso de {label} automáticamente cuando Netcatty está esperando para desbloquearse.',
|
||||
'settings.appLock.currentPassword': 'Contraseña actual',
|
||||
'settings.appLock.currentPasswordPlaceholder': 'Necesaria para desactivar o reemplazar',
|
||||
'settings.appLock.currentPasswordForDisablePlaceholder': 'Contraseña actual para desactivar el bloqueo de la aplicación',
|
||||
'settings.appLock.currentPasswordForChangePlaceholder': 'Contraseña actual para cambiarla',
|
||||
'settings.appLock.newPassword': 'Nueva contraseña',
|
||||
'settings.appLock.newPasswordPlaceholder': 'Define la contraseña de bloqueo',
|
||||
'settings.appLock.confirmPassword': 'Confirmar contraseña',
|
||||
'settings.appLock.confirmPasswordPlaceholder': 'Repite la contraseña',
|
||||
'settings.appLock.savePassword': 'Definir y activar',
|
||||
'settings.appLock.savingPassword': 'Guardando...',
|
||||
'settings.appLock.passwordSet': 'Contraseña configurada',
|
||||
'settings.appLock.replacePassword': 'Reemplazar contraseña',
|
||||
'settings.appLock.enableAfterPassword': 'Define una contraseña de bloqueo antes de activar el bloqueo de la aplicación.',
|
||||
'settings.appLock.localOnlyHint': 'Bloqueo de privacidad local del dispositivo. No se sincroniza y no es una barrera de seguridad del sistema operativo.',
|
||||
'settings.appLock.validation.currentRequired': 'Ingresa la contraseña de bloqueo actual.',
|
||||
'settings.appLock.validation.newRequired': 'Ingresa una nueva contraseña de bloqueo.',
|
||||
'settings.appLock.validation.confirmRequired': 'Confirma la nueva contraseña de bloqueo.',
|
||||
'settings.appLock.validation.mismatch': 'Las contraseñas nuevas no coinciden.',
|
||||
'settings.appLock.validation.incorrect': 'La contraseña de bloqueo actual es incorrecta.',
|
||||
|
||||
// Settings > System > Auto Launch
|
||||
'settings.autoLaunch.title': 'Abrir al iniciar sesión',
|
||||
'settings.autoLaunch.enabled': 'Iniciar al arrancar el sistema',
|
||||
'settings.autoLaunch.enabledDesc': 'Inicia Netcatty automáticamente al iniciar sesión, oculto en la bandeja del sistema.',
|
||||
'settings.autoLaunch.unsupportedDesc': 'No disponible en esta plataforma ni en una compilación de desarrollo.',
|
||||
|
||||
// Settings > System > Network Proxy
|
||||
'settings.system.networkProxy.title': 'Proxy de red',
|
||||
'settings.system.networkProxy.description': 'Proxy HTTP(S) para la sincronización en la nube y los proveedores de IA. Esto es independiente de los perfiles de ProxyJump / ProxyCommand de SSH.',
|
||||
|
||||
@@ -33,6 +33,9 @@ export const esSystemManagerMessages: Messages = {
|
||||
'systemManager.errors.loadPorts': 'No se pudieron cargar los puertos en escucha',
|
||||
'systemManager.errors.loadServices': 'No se pudieron cargar los servicios de systemd',
|
||||
'systemManager.errors.sshChannelUnavailable': 'El servidor se negó a abrir un nuevo canal de ejecución. Inténtalo de nuevo más tarde o vuelve a conectar este host.',
|
||||
'systemManager.errors.nerdctlUnreachable': 'nerdctl no puede conectarse a containerd',
|
||||
'systemManager.errors.clusterUnreachable': 'No se puede conectar al clúster para el contexto {{context}}',
|
||||
'systemManager.errors.clusterUnreachableGeneric': 'No se puede conectar al clúster',
|
||||
'systemManager.unsupportedRemote': 'Este host no permite canales SSH de ejecución adicionales, por lo que el administrador del sistema no está disponible. Abrir este panel ya no desconectará la terminal.',
|
||||
|
||||
'systemManager.overview.empty': 'Aún no hay datos de resumen del sistema.',
|
||||
@@ -212,6 +215,20 @@ export const esSystemManagerMessages: Messages = {
|
||||
'systemManager.k8s.restarts': '{{count}} reinicios',
|
||||
'systemManager.k8s.chooseContainer': 'Elegir un contenedor',
|
||||
'systemManager.k8s.noContext': 'No hay un contexto de Kubernetes en este host',
|
||||
'systemManager.k8s.status.Running': 'En ejecución',
|
||||
'systemManager.k8s.status.Pending': 'Pendiente',
|
||||
'systemManager.k8s.status.Completed': 'Completado',
|
||||
'systemManager.k8s.status.Succeeded': 'Con éxito',
|
||||
'systemManager.k8s.status.Failed': 'Fallido',
|
||||
'systemManager.k8s.status.Unknown': 'Desconocido',
|
||||
'systemManager.k8s.status.CrashLoopBackOff': 'Bucle de fallos',
|
||||
'systemManager.k8s.status.ContainerCreating': 'Creando contenedor',
|
||||
'systemManager.k8s.status.Terminating': 'Terminando',
|
||||
'systemManager.k8s.status.ImagePullBackOff': 'Reintentando descarga de imagen',
|
||||
'systemManager.k8s.status.ErrImagePull': 'Error al descargar la imagen',
|
||||
'systemManager.k8s.status.Error': 'Error',
|
||||
'systemManager.k8s.status.Evicted': 'Desalojado',
|
||||
'systemManager.k8s.status.OOMKilled': 'Sin memoria',
|
||||
|
||||
'systemManager.ports.unavailable': 'No se detectaron herramientas de puertos en escucha (ss / netstat) en este host.',
|
||||
'systemManager.ports.loading': 'Cargando puertos en escucha…',
|
||||
|
||||
@@ -219,6 +219,7 @@ export const esTerminalMessages: Messages = {
|
||||
'terminal.progress.timeoutIn': 'Tiempo de espera en {seconds}s',
|
||||
'terminal.progress.waitingForUserInput': 'Esperando entrada del usuario',
|
||||
'terminal.progress.disconnected': 'Desconectado',
|
||||
'terminal.progress.connected': 'Conectado',
|
||||
'terminal.progress.cancelling': 'Cancelando...',
|
||||
'terminal.progress.startOver': 'Empezar de nuevo',
|
||||
'terminal.progress.enterReconnectHint': 'Presiona Enter para reconectar',
|
||||
@@ -675,6 +676,45 @@ export const esTerminalMessages: Messages = {
|
||||
'keychain.keyDialog.editTitle': 'Editar clave',
|
||||
'keychain.keyDialog.editDesc': 'Actualizar esta clave SSH',
|
||||
'keychain.keyDialog.updateKey': 'Actualizar clave',
|
||||
'keychain.generate.standardKeys': 'Claves estándar',
|
||||
'keychain.generate.securityKeys': 'Llaves de seguridad · FIDO2',
|
||||
'keychain.generate.residentDesc': 'Permite recuperar las credenciales desde esta llave de seguridad.',
|
||||
'keychain.generate.verifyRequiredDesc': 'Solicita el PIN del dispositivo antes de firmar.',
|
||||
'keychain.generate.generating': 'Generando…',
|
||||
'keychain.generate.fidoHint': 'Conecta una llave de seguridad FIDO2. Es posible que se te solicite su PIN o un toque.',
|
||||
'keychain.generate.resident': 'Guardar las credenciales en la llave',
|
||||
'keychain.generate.verifyRequired': 'Verificar el PIN cada vez',
|
||||
'keychain.action.loadResident': 'Cargar claves residentes desde la llave de seguridad',
|
||||
|
||||
// FIDO2 security key prompts and recovery states
|
||||
'fido.prompt.pinTouchDesc': 'Ingresa tu PIN y luego toca la llave para confirmar.',
|
||||
'fido.prompt.details': 'Detalles de la solicitud',
|
||||
'fido.prompt.showPin': 'Mostrar PIN',
|
||||
'fido.prompt.hidePin': 'Ocultar PIN',
|
||||
'fido.prompt.verifying': 'Verificando…',
|
||||
'fido.prompt.pinTitle': 'PIN de la llave de seguridad',
|
||||
'fido.prompt.touchTitle': 'Toca tu llave de seguridad',
|
||||
'fido.prompt.pinDesc': 'Ingresa el PIN de tu llave de seguridad para continuar.',
|
||||
'fido.prompt.touchDesc': 'Toca tu llave de seguridad para confirmar tu identidad.',
|
||||
'fido.prompt.pinLabel': 'PIN',
|
||||
'fido.prompt.touchWaiting': 'Esperando tu llave…',
|
||||
'fido.prompt.touchAutomatic': 'Continúa automáticamente después de que la toques',
|
||||
'fido.prompt.cancelled': 'Se canceló la autenticación con llave de seguridad.',
|
||||
'fido.prompt.touchDone': 'Ya la toqué',
|
||||
'fido.prompt.timeout': 'El aviso de FIDO agotó el tiempo de espera. Intenta conectarte de nuevo.',
|
||||
'fido.recovery.device-unavailable.title': 'Conecta la llave de seguridad correspondiente',
|
||||
'fido.recovery.device-unavailable.desc': 'Conecta el dispositivo que contiene la credencial de esta llave y vuelve a intentarlo.',
|
||||
'fido.recovery.timeout.title': 'Se agotó el tiempo de confirmación de la llave de seguridad',
|
||||
'fido.recovery.timeout.desc': 'La operación del dispositivo se detuvo. Comprueba que tu llave esté conectada, vuelve a intentarlo y sigue el aviso de toque o verificación.',
|
||||
'fido.recovery.pin-failed.title': 'No se aceptó el PIN de la llave de seguridad',
|
||||
'fido.recovery.pin-failed.desc': 'Verifica el PIN FIDO2 de este dispositivo antes de reintentar. Varios intentos incorrectos pueden bloquear el dispositivo.',
|
||||
'fido.recovery.failed.title': 'No se completó la verificación de la llave de seguridad',
|
||||
'fido.recovery.failed.desc': 'Comprueba que el dispositivo correspondiente esté conectado y admita esta llave. Reintenta o cancela para revisar el dispositivo y la configuración de OpenSSH.',
|
||||
'fido.recovery.retry': 'Reintentar verificación',
|
||||
'fido.recovery.usePassword': 'Usar la contraseña configurada',
|
||||
'fido.recovery.passwordFallback': 'Probando la contraseña configurada para este host',
|
||||
'fido.error.opensshMissing': 'Se requiere OpenSSH con FIDO/libfido2. En macOS: brew install openssh libfido2.',
|
||||
'fido.error.agentUnavailable': 'No se pudo iniciar un agente SSH compatible con FIDO.',
|
||||
|
||||
// Tabs
|
||||
'tabs.closeSessionAria': 'Cerrar sesión',
|
||||
|
||||
@@ -1044,6 +1044,18 @@ export const esVaultMessages: Messages = {
|
||||
'hostDetails.addTag': 'Agregar una etiqueta...',
|
||||
'hostDetails.createTag': 'Crear etiqueta',
|
||||
'hostDetails.createGroup': 'Crear grupo',
|
||||
|
||||
// Host connection test
|
||||
'hostConnectionTest.title': 'Probar conexión',
|
||||
'hostConnectionTest.test': 'Probar',
|
||||
'hostConnectionTest.chainOf': 'Cadena {current} de {total}',
|
||||
'hostConnectionTest.success': 'Conexión correcta',
|
||||
'hostConnectionTest.successHint': 'El host aceptó tus credenciales. Puedes cerrar esta ventana.',
|
||||
'hostConnectionTest.connecting': 'Conectando… tiempo de espera en {seconds}s',
|
||||
'hostConnectionTest.failed': 'No se pudo conectar.',
|
||||
'hostConnectionTest.retry': 'Reintentar',
|
||||
'hostConnectionTest.credentialsUnavailable': 'Las credenciales guardadas no se pueden descifrar en este dispositivo. Vuelve a ingresarlas abajo.',
|
||||
|
||||
// Entradas de shell local (CMD) en la bóveda
|
||||
'localShell.subtitle': 'Shell local',
|
||||
'localShell.panel.title': 'Nueva shell local',
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { DEFAULT_KEY_BINDINGS } from "../../../domain/models/keyBindings.ts";
|
||||
import en from "./en.ts";
|
||||
import es from "./es.ts";
|
||||
import ru from "./ru.ts";
|
||||
import zhCN from "./zh-CN.ts";
|
||||
import zhTW from "./zh-TW.ts";
|
||||
|
||||
// Locales that are not at full key parity with `en` yet. They are tracked here
|
||||
// so the remaining debt stays visible instead of silently rendering in English
|
||||
// through the `DEFAULT_UI_LOCALE` fallback in `I18nProvider.resolveMessage`.
|
||||
// Move a locale out of this list once it covers every `en` key.
|
||||
const LOCALES_PENDING_PARITY = ["ru", "zh-CN", "zh-TW"] as const;
|
||||
|
||||
const hasKey = (messages: Record<string, string>, key: string) =>
|
||||
Object.hasOwn(messages, key);
|
||||
|
||||
const interpolationTokens = (value: string) =>
|
||||
[...value.matchAll(/\{\{\s*\w+\s*\}\}|\{\s*\w+\s*\}/g)]
|
||||
.map((match) => match[0].replace(/[{}]/g, "").trim())
|
||||
.sort();
|
||||
|
||||
test("es defines every message key that en defines", () => {
|
||||
const missing = Object.keys(en).filter((key) => !hasKey(es, key));
|
||||
|
||||
assert.deepEqual(
|
||||
missing,
|
||||
[],
|
||||
`es is missing ${missing.length} message keys`,
|
||||
);
|
||||
});
|
||||
|
||||
test("es defines no message keys that en does not define", () => {
|
||||
const extra = Object.keys(es).filter((key) => !hasKey(en, key));
|
||||
|
||||
assert.deepEqual(
|
||||
extra,
|
||||
[],
|
||||
`es defines ${extra.length} keys that no longer exist in en`,
|
||||
);
|
||||
});
|
||||
|
||||
test("es keeps the same interpolation placeholders as en", () => {
|
||||
const mismatched: string[] = [];
|
||||
|
||||
for (const key of Object.keys(es)) {
|
||||
if (!hasKey(en, key)) continue;
|
||||
|
||||
const expected = interpolationTokens(en[key] ?? "").join(",");
|
||||
const actual = interpolationTokens(es[key] ?? "").join(",");
|
||||
if (expected !== actual) mismatched.push(key);
|
||||
}
|
||||
|
||||
assert.deepEqual(
|
||||
mismatched,
|
||||
[],
|
||||
`es changes interpolation placeholders for ${mismatched.length} keys`,
|
||||
);
|
||||
});
|
||||
|
||||
test("every default shortcut has a label in en and es", () => {
|
||||
const locales = [
|
||||
{ name: "en", messages: en },
|
||||
{ name: "es", messages: es },
|
||||
];
|
||||
|
||||
for (const locale of locales) {
|
||||
const missing = DEFAULT_KEY_BINDINGS.map(
|
||||
(binding) => `settings.shortcuts.binding.${binding.id}`,
|
||||
).filter((key) => !hasKey(locale.messages, key));
|
||||
|
||||
assert.deepEqual(
|
||||
missing,
|
||||
[],
|
||||
`${locale.name} is missing ${missing.length} shortcut labels`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("en shortcut labels stay identical to DEFAULT_KEY_BINDINGS", () => {
|
||||
// SettingsShortcutsTab falls back to `binding.label` only while the locale
|
||||
// resolves the key to itself. Once `en` defines the key, the locale becomes
|
||||
// the rendered source, so the two must not drift apart.
|
||||
for (const binding of DEFAULT_KEY_BINDINGS) {
|
||||
const key = `settings.shortcuts.binding.${binding.id}`;
|
||||
|
||||
assert.equal(
|
||||
en[key],
|
||||
binding.label,
|
||||
`en label drifted from DEFAULT_KEY_BINDINGS for ${binding.id}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("every locale outside the pending list is at full parity with en", () => {
|
||||
const locales = [
|
||||
{ name: "es", messages: es },
|
||||
{ name: "ru", messages: ru },
|
||||
{ name: "zh-CN", messages: zhCN },
|
||||
{ name: "zh-TW", messages: zhTW },
|
||||
];
|
||||
|
||||
for (const locale of locales) {
|
||||
if ((LOCALES_PENDING_PARITY as readonly string[]).includes(locale.name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const missing = Object.keys(en).filter(
|
||||
(key) => !hasKey(locale.messages, key),
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
missing,
|
||||
[],
|
||||
`${locale.name} is missing ${missing.length} message keys`,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -18,12 +18,19 @@ const LOCALIZED_SETTINGS_LOCALES = [
|
||||
|
||||
const APP_LOCK_LOCALES = [
|
||||
{ name: "en", messages: en },
|
||||
{ name: "zh-CN", messages: zhCN },
|
||||
{ name: "es", messages: es },
|
||||
{ name: "ru", messages: ru },
|
||||
{ name: "zh-CN", messages: zhCN },
|
||||
{ name: "zh-TW", messages: zhTW },
|
||||
];
|
||||
|
||||
const ALL_SETTINGS_LOCALES = [
|
||||
{ name: "en", messages: en },
|
||||
...LOCALIZED_SETTINGS_LOCALES,
|
||||
];
|
||||
|
||||
test("localized settings include names for every default shortcut", () => {
|
||||
for (const locale of LOCALIZED_SETTINGS_LOCALES) {
|
||||
for (const locale of ALL_SETTINGS_LOCALES) {
|
||||
const missing = DEFAULT_KEY_BINDINGS
|
||||
.map((binding) => `settings.shortcuts.binding.${binding.id}`)
|
||||
.filter((key) => !locale.messages[key]);
|
||||
|
||||
@@ -80,6 +80,76 @@ test("hibernate failure after the soft-hidden marker drops does not retain the l
|
||||
assert.deepEqual(recoveredOutcomes, ["soft-hide"]);
|
||||
});
|
||||
|
||||
test("a live keep count that still fits after eviction soft-hides the leaving tab", async () => {
|
||||
let keepCount = 0;
|
||||
const outcomes: string[] = [];
|
||||
await retainLeavingTabAfterEviction({
|
||||
sessionId: "leaving",
|
||||
candidates: ["victim"],
|
||||
keepCount,
|
||||
resolveKeepCount: () => keepCount,
|
||||
stillWantsRetention: () => true,
|
||||
softHide: () => outcomes.push("soft-hide"),
|
||||
fullHibernate: () => outcomes.push("full-hibernate"),
|
||||
evict: async () => {
|
||||
keepCount = 2;
|
||||
await Promise.resolve();
|
||||
return true;
|
||||
},
|
||||
});
|
||||
assert.deepEqual(outcomes, ["soft-hide"]);
|
||||
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 0);
|
||||
});
|
||||
|
||||
test("a keep count dropped to zero during eviction full-hibernates the leaving tab", async () => {
|
||||
let keepCount = 2;
|
||||
const outcomes: string[] = [];
|
||||
await retainLeavingTabAfterEviction({
|
||||
sessionId: "leaving",
|
||||
candidates: ["victim"],
|
||||
keepCount,
|
||||
resolveKeepCount: () => keepCount,
|
||||
stillWantsRetention: () => true,
|
||||
softHide: () => outcomes.push("soft-hide"),
|
||||
fullHibernate: () => outcomes.push("full-hibernate"),
|
||||
evict: async () => {
|
||||
keepCount = 0;
|
||||
await Promise.resolve();
|
||||
return true;
|
||||
},
|
||||
});
|
||||
assert.deepEqual(outcomes, ["full-hibernate"]);
|
||||
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 0);
|
||||
});
|
||||
|
||||
test("a keep count that no longer fits after eviction full-hibernates the leaving tab", async () => {
|
||||
const sessions = ["victim", "other", "leaving"];
|
||||
for (const sessionId of sessions) terminalHiddenRendererStore.clearSoftHidden(sessionId);
|
||||
terminalHiddenRendererStore.markSoftHidden("other");
|
||||
let keepCount = 2;
|
||||
const outcomes: string[] = [];
|
||||
try {
|
||||
await retainLeavingTabAfterEviction({
|
||||
sessionId: "leaving",
|
||||
candidates: ["victim"],
|
||||
keepCount,
|
||||
resolveKeepCount: () => keepCount,
|
||||
stillWantsRetention: () => true,
|
||||
softHide: () => outcomes.push("soft-hide"),
|
||||
fullHibernate: () => outcomes.push("full-hibernate"),
|
||||
evict: async () => {
|
||||
keepCount = 1;
|
||||
await Promise.resolve();
|
||||
return true;
|
||||
},
|
||||
});
|
||||
assert.deepEqual(outcomes, ["full-hibernate"]);
|
||||
assert.equal(terminalHiddenRendererStore.isSoftHidden("leaving"), false);
|
||||
} finally {
|
||||
for (const sessionId of sessions) terminalHiddenRendererStore.clearSoftHidden(sessionId);
|
||||
}
|
||||
});
|
||||
|
||||
test("a leaving tab that no longer wants retention is not soft-hidden or hibernated", async () => {
|
||||
const outcomes: string[] = [];
|
||||
await retainLeavingTabAfterEviction({
|
||||
|
||||
@@ -11,7 +11,14 @@ import { terminalHiddenRendererStore } from "./terminalHiddenRendererStore.ts";
|
||||
export async function retainLeavingTabAfterEviction(input: {
|
||||
sessionId: string;
|
||||
candidates: readonly string[];
|
||||
/** Count captured when this leave started. Used only if resolveKeepCount is absent. */
|
||||
keepCount: number;
|
||||
/**
|
||||
* Live keep-count read after eviction settles. Settings can change while the
|
||||
* victim drains or serializes; retaining against the captured count would
|
||||
* soft-hide a tab the user no longer wants kept.
|
||||
*/
|
||||
resolveKeepCount?: () => number;
|
||||
stillWantsRetention: () => boolean;
|
||||
softHide: () => void;
|
||||
fullHibernate: () => void;
|
||||
@@ -31,9 +38,10 @@ export async function retainLeavingTabAfterEviction(input: {
|
||||
}
|
||||
}
|
||||
if (!input.stillWantsRetention()) return;
|
||||
const keepCount = input.resolveKeepCount?.() ?? input.keepCount;
|
||||
if (
|
||||
completed
|
||||
&& terminalHiddenRendererStore.leavingSlotStillFits(input.sessionId, input.keepCount)
|
||||
&& terminalHiddenRendererStore.leavingSlotStillFits(input.sessionId, keepCount)
|
||||
) {
|
||||
input.softHide();
|
||||
return;
|
||||
|
||||
@@ -1,3 +1,14 @@
|
||||
!macro customInit
|
||||
; #3946: external MCP hosts launch Netcatty.exe with ELECTRON_RUN_AS_NODE=1
|
||||
; via netcatty-external-mcp.cmd. Those processes can outlive the host UI as
|
||||
; orphans and keep $INSTDIR locked, so the in-app updater hangs (AppHangB1).
|
||||
; Force-kill leftover Netcatty.exe before files are replaced. Do not pass /T:
|
||||
; electron-updater spawns this installer as a child of the UI Netcatty.exe,
|
||||
; and /T would terminate the installer itself. Ignore a missing process.
|
||||
nsExec::ExecToLog "taskkill /F /IM Netcatty.exe"
|
||||
Pop $0
|
||||
!macroend
|
||||
|
||||
!macro customInstall
|
||||
; Open a selected folder in a new Netcatty local terminal.
|
||||
; Args go after `--` so Electron/Chromium does not swallow them. Use
|
||||
|
||||
@@ -523,7 +523,8 @@ const SettingsPageContent: React.FC<{ settings: SettingsState; appLock?: AppLock
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 h-full flex flex-col min-h-0 bg-muted/10">
|
||||
{/* min-w-0: row flex item must shrink so long Agent paths truncate inside the pane. */}
|
||||
<div className="flex-1 h-full flex flex-col min-h-0 min-w-0 bg-muted/10">
|
||||
{mountedTabs.has("application") && (
|
||||
<SettingsLazyTab value="application">
|
||||
<LazySettingsApplicationTab
|
||||
|
||||
+15
-1
@@ -2355,8 +2355,14 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
// Disconnected sessions never stay soft-hidden. A connected session whose
|
||||
// keep count dropped to zero takes the same upgrade. A positive count stays
|
||||
// retained until a later leave evicts overflow.
|
||||
if (softHiddenRef.current) {
|
||||
if (statusRef.current === "disconnected") {
|
||||
const retainedKeepCount = statusRef.current === "connected"
|
||||
? resolveHibernateKeepRendererCount(terminalSettingsRef.current)
|
||||
: 0;
|
||||
if (statusRef.current === "disconnected" || !(retainedKeepCount > 0)) {
|
||||
upgradeSoftHiddenRuntimeToHibernate();
|
||||
}
|
||||
return;
|
||||
@@ -2387,6 +2393,13 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
sessionId,
|
||||
candidates: keepDecision.candidates,
|
||||
keepCount,
|
||||
// Re-read after the victim drain. The count above was captured when
|
||||
// this timer fired; a zero or tighter setting must full-hibernate.
|
||||
resolveKeepCount: () => (
|
||||
statusRef.current === "connected"
|
||||
? resolveHibernateKeepRendererCount(terminalSettingsRef.current)
|
||||
: 0
|
||||
),
|
||||
stillWantsRetention: () => (
|
||||
hibernateEvictionAttemptRef.current === attempt
|
||||
&& !isVisibleRef.current
|
||||
@@ -4756,6 +4769,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
isSearchOpen,
|
||||
hibernateEnabled: hibernateEnabled,
|
||||
hibernateDelayMs: resolveTerminalHibernateDelayMs(terminalSettings),
|
||||
hibernateKeepRendererCount: resolveHibernateKeepRendererCount(terminalSettings),
|
||||
fileTransferActive: hibernateFileTransferActive,
|
||||
hibernatedRef,
|
||||
softHiddenRef,
|
||||
|
||||
@@ -8,3 +8,40 @@ test("SFTP settings imports the shared Toggle used by its option rows", () => {
|
||||
assert.match(source, /<Toggle\b/);
|
||||
assert.match(source, /Toggle,\s*\n\s*Select,/);
|
||||
});
|
||||
|
||||
function functionBody(functionName: string): string {
|
||||
const start = source.indexOf(`const ${functionName} = useCallback`);
|
||||
assert.notEqual(start, -1, `${functionName} should exist`);
|
||||
const next = source.indexOf("\n const ", start + 1);
|
||||
return source.slice(start, next === -1 ? source.length : next);
|
||||
}
|
||||
|
||||
test("removing an SFTP file association confirms with the in-app dialog", () => {
|
||||
assert.match(source, /import \{ ConfirmDialog \} from "\.\.\/\.\.\/ui\/confirm-dialog";/);
|
||||
assert.doesNotMatch(source, /window\.confirm|globalThis\.confirm|\bconfirm\(/);
|
||||
assert.match(
|
||||
source,
|
||||
/const \[removeConfirmExt, setRemoveConfirmExt\] = useState<string \| null>\(null\);/,
|
||||
);
|
||||
|
||||
const handleRemove = functionBody("handleRemove");
|
||||
assert.match(handleRemove, /setRemoveConfirmExt\(extension\)/);
|
||||
assert.doesNotMatch(handleRemove, /removeAssociation\(/);
|
||||
|
||||
const handleConfirm = functionBody("handleConfirmRemove");
|
||||
assert.match(handleConfirm, /if \(!removeConfirmExt\) return;/);
|
||||
assert.match(handleConfirm, /removeAssociation\(removeConfirmExt\);\s*setRemoveConfirmExt\(null\);/);
|
||||
|
||||
const dialogStart = source.indexOf("<ConfirmDialog");
|
||||
assert.notEqual(dialogStart, -1, "ConfirmDialog should be rendered");
|
||||
const dialog = source.slice(dialogStart);
|
||||
assert.match(dialog, /open=\{removeConfirmExt !== null\}/);
|
||||
assert.match(dialog, /settings\.sftpFileAssociations\.removeConfirm/);
|
||||
assert.match(dialog, /confirmLabel=\{t\('action\.remove'\)\}/);
|
||||
assert.match(dialog, /\bdestructive\b/);
|
||||
assert.match(dialog, /onConfirm=\{handleConfirmRemove\}/);
|
||||
|
||||
const onOpenChange = dialog.slice(dialog.indexOf("onOpenChange"), dialog.indexOf("onConfirm"));
|
||||
assert.match(onOpenChange, /if \(!open\) setRemoveConfirmExt\(null\);/);
|
||||
assert.doesNotMatch(onOpenChange, /removeAssociation\(/);
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import { useSettingsState } from "../../../application/state/useSettingsState";
|
||||
import type { FileOpenerType, SystemAppInfo } from "../../../lib/sftpFileUtils";
|
||||
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||||
import { Button } from "../../ui/button";
|
||||
import { ConfirmDialog } from "../../ui/confirm-dialog";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../../ui/tooltip";
|
||||
import {
|
||||
SectionHeader,
|
||||
@@ -52,6 +53,7 @@ export default function SettingsFileAssociationsTab() {
|
||||
const defaultOpener = getDefaultOpener();
|
||||
const [editingExtension, setEditingExtension] = useState<string | null>(null);
|
||||
const [isSelectingDefaultApp, setIsSelectingDefaultApp] = useState(false);
|
||||
const [removeConfirmExt, setRemoveConfirmExt] = useState<string | null>(null);
|
||||
|
||||
const defaultOpenerValue = useMemo(() => {
|
||||
if (!defaultOpener) return 'ask';
|
||||
@@ -60,10 +62,14 @@ export default function SettingsFileAssociationsTab() {
|
||||
}, [defaultOpener]);
|
||||
|
||||
const handleRemove = useCallback((extension: string) => {
|
||||
if (confirm(t('settings.sftpFileAssociations.removeConfirm', { ext: extension === 'file' ? t('sftp.opener.noExtension') : extension }))) {
|
||||
removeAssociation(extension);
|
||||
}
|
||||
}, [removeAssociation, t]);
|
||||
setRemoveConfirmExt(extension);
|
||||
}, []);
|
||||
|
||||
const handleConfirmRemove = useCallback(() => {
|
||||
if (!removeConfirmExt) return;
|
||||
removeAssociation(removeConfirmExt);
|
||||
setRemoveConfirmExt(null);
|
||||
}, [removeAssociation, removeConfirmExt]);
|
||||
|
||||
const handleSelectDefaultSystemApp = useCallback(async () => {
|
||||
setIsSelectingDefaultApp(true);
|
||||
@@ -372,6 +378,16 @@ export default function SettingsFileAssociationsTab() {
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
<ConfirmDialog
|
||||
open={removeConfirmExt !== null}
|
||||
title={removeConfirmExt ? t('settings.sftpFileAssociations.removeConfirm', { ext: removeConfirmExt === 'file' ? t('sftp.opener.noExtension') : removeConfirmExt }) : ''}
|
||||
confirmLabel={t('action.remove')}
|
||||
destructive
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setRemoveConfirmExt(null);
|
||||
}}
|
||||
onConfirm={handleConfirmRemove}
|
||||
/>
|
||||
</SettingsTabContent>
|
||||
);
|
||||
}
|
||||
@@ -77,13 +77,13 @@ export const ClaudeCodeCard: React.FC<{
|
||||
</div>
|
||||
|
||||
{found && (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="text-muted-foreground">{t('ai.claude.path')}</span>
|
||||
<span className="font-mono text-foreground truncate">{pathInfo.path}</span>
|
||||
<div className="flex items-center gap-2 text-xs min-w-0">
|
||||
<span className="text-muted-foreground shrink-0">{t('ai.claude.path')}</span>
|
||||
<span className="font-mono text-foreground min-w-0 flex-1 truncate" title={pathInfo.path ?? undefined}>{pathInfo.path}</span>
|
||||
{pathInfo.version && (
|
||||
<>
|
||||
<span className="text-muted-foreground">|</span>
|
||||
<span className="text-muted-foreground">{pathInfo.version}</span>
|
||||
<span className="text-muted-foreground shrink-0">|</span>
|
||||
<span className="text-muted-foreground shrink-0">{pathInfo.version}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -102,7 +102,7 @@ export const ClaudeCodeCard: React.FC<{
|
||||
value={customPath}
|
||||
onChange={(e) => onCustomPathChange(e.target.value)}
|
||||
placeholder={t('ai.claude.customPathPlaceholder')}
|
||||
className="flex-1 h-8 rounded-md border border-input bg-background px-3 text-sm font-mono placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
className="flex-1 min-w-0 h-8 rounded-md border border-input bg-background px-3 text-sm font-mono placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
<Button variant="outline" size="sm" onClick={onRecheckPath} disabled={!customPath.trim()}>
|
||||
<RefreshCw size={14} className="mr-1.5" />
|
||||
|
||||
@@ -117,13 +117,13 @@ export const CodebuddyCard: React.FC<{
|
||||
</div>
|
||||
|
||||
{found && (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="text-muted-foreground">{t('ai.codebuddy.path')}</span>
|
||||
<span className="font-mono text-foreground truncate">{pathInfo.path}</span>
|
||||
<div className="flex items-center gap-2 text-xs min-w-0">
|
||||
<span className="text-muted-foreground shrink-0">{t('ai.codebuddy.path')}</span>
|
||||
<span className="font-mono text-foreground min-w-0 flex-1 truncate" title={pathInfo.path ?? undefined}>{pathInfo.path}</span>
|
||||
{pathInfo.version && (
|
||||
<>
|
||||
<span className="text-muted-foreground">|</span>
|
||||
<span className="text-muted-foreground">{pathInfo.version}</span>
|
||||
<span className="text-muted-foreground shrink-0">|</span>
|
||||
<span className="text-muted-foreground shrink-0">{pathInfo.version}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -142,7 +142,7 @@ export const CodebuddyCard: React.FC<{
|
||||
value={customPath}
|
||||
onChange={(e) => onCustomPathChange(e.target.value)}
|
||||
placeholder={t('ai.codebuddy.customPathPlaceholder')}
|
||||
className="flex-1 h-8 rounded-md border border-input bg-background px-3 text-sm font-mono placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
className="flex-1 min-w-0 h-8 rounded-md border border-input bg-background px-3 text-sm font-mono placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
<Button variant="outline" size="sm" onClick={onRecheckPath} disabled={!customPath.trim()}>
|
||||
<RefreshCw size={14} className="mr-1.5" />
|
||||
|
||||
@@ -108,13 +108,13 @@ export const CodexConnectionCard: React.FC<{
|
||||
</div>
|
||||
|
||||
{found && (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="text-muted-foreground">{t('ai.codex.path')}</span>
|
||||
<span className="font-mono text-foreground truncate">{pathInfo.path}</span>
|
||||
<div className="flex items-center gap-2 text-xs min-w-0">
|
||||
<span className="text-muted-foreground shrink-0">{t('ai.codex.path')}</span>
|
||||
<span className="font-mono text-foreground min-w-0 flex-1 truncate" title={pathInfo.path ?? undefined}>{pathInfo.path}</span>
|
||||
{pathInfo.version && (
|
||||
<>
|
||||
<span className="text-muted-foreground">|</span>
|
||||
<span className="text-muted-foreground">{pathInfo.version}</span>
|
||||
<span className="text-muted-foreground shrink-0">|</span>
|
||||
<span className="text-muted-foreground shrink-0">{pathInfo.version}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -133,7 +133,7 @@ export const CodexConnectionCard: React.FC<{
|
||||
value={customPath}
|
||||
onChange={(e) => onCustomPathChange(e.target.value)}
|
||||
placeholder={t('ai.codex.customPathPlaceholder')}
|
||||
className="flex-1 h-8 rounded-md border border-input bg-background px-3 text-sm font-mono placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
className="flex-1 min-w-0 h-8 rounded-md border border-input bg-background px-3 text-sm font-mono placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
<Button variant="outline" size="sm" onClick={onRecheckPath} disabled={!customPath.trim()}>
|
||||
<RefreshCw size={14} className="mr-1.5" />
|
||||
|
||||
@@ -61,13 +61,13 @@ export const CopilotCliCard: React.FC<{
|
||||
</div>
|
||||
|
||||
{found && (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="text-muted-foreground">{t(`${i18nPrefix}.path`)}</span>
|
||||
<span className="font-mono text-foreground truncate">{pathInfo.path}</span>
|
||||
<div className="flex items-center gap-2 text-xs min-w-0">
|
||||
<span className="text-muted-foreground shrink-0">{t(`${i18nPrefix}.path`)}</span>
|
||||
<span className="font-mono text-foreground min-w-0 flex-1 truncate" title={pathInfo.path ?? undefined}>{pathInfo.path}</span>
|
||||
{pathInfo.version && (
|
||||
<>
|
||||
<span className="text-muted-foreground">|</span>
|
||||
<span className="text-muted-foreground">{pathInfo.version}</span>
|
||||
<span className="text-muted-foreground shrink-0">|</span>
|
||||
<span className="text-muted-foreground shrink-0">{pathInfo.version}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -87,7 +87,7 @@ export const CopilotCliCard: React.FC<{
|
||||
value={customPath}
|
||||
onChange={(e) => onCustomPathChange(e.target.value)}
|
||||
placeholder={t(`${i18nPrefix}.customPathPlaceholder`)}
|
||||
className="flex-1 h-8 rounded-md border border-input bg-background px-3 text-sm font-mono placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
className="flex-1 min-w-0 h-8 rounded-md border border-input bg-background px-3 text-sm font-mono placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
)}
|
||||
<Button variant="outline" size="sm" onClick={onRecheckPath} disabled={!allowEmptyCheck && !customPath.trim()}>
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { ClaudeCodeCard } from "./ClaudeCodeCard";
|
||||
import { CodebuddyCard } from "./CodebuddyCard";
|
||||
import { CodexConnectionCard } from "./CodexConnectionCard";
|
||||
import { CopilotCliCard } from "./CopilotCliCard";
|
||||
|
||||
const LONG_PATH = "C:\\Users\\70541\\AppData\\Local\\Microsoft\\WinGet\\Packages\\Anthropic.ClaudeCode_Microsoft.Winget.Source_8wekyb3d8bbwe\\claude.exe";
|
||||
const VERSION = "9.9.9-test";
|
||||
|
||||
function classList(tag: string): Set<string> {
|
||||
const match = tag.match(/class="([^"]*)"/);
|
||||
assert.ok(match, `missing class on ${tag}`);
|
||||
return new Set(match[1].split(/\s+/));
|
||||
}
|
||||
|
||||
function assertClasses(tag: string, expected: string[]) {
|
||||
const have = classList(tag);
|
||||
for (const name of expected) {
|
||||
assert.ok(have.has(name), `expected ${name} on ${tag}`);
|
||||
}
|
||||
}
|
||||
|
||||
function openingTagContaining(markup: string, text: string): string {
|
||||
const idx = markup.indexOf(`>${text}<`);
|
||||
assert.ok(idx >= 0, `missing text node ${text}`);
|
||||
const start = markup.lastIndexOf("<", idx);
|
||||
return markup.slice(start, idx + 1);
|
||||
}
|
||||
|
||||
function pathRowClass(markup: string): string {
|
||||
const idx = markup.indexOf(LONG_PATH);
|
||||
assert.ok(idx >= 0, "missing path text");
|
||||
const matches = [...markup.slice(0, idx).matchAll(/<div\b[^>]*class="([^"]*)"/g)];
|
||||
assert.ok(matches.length > 0, "missing path row");
|
||||
return matches[matches.length - 1][1];
|
||||
}
|
||||
|
||||
function assertLongPathTruncates(markup: string) {
|
||||
const pathTag = openingTagContaining(markup, LONG_PATH);
|
||||
assertClasses(pathTag, ["truncate", "min-w-0", "flex-1"]);
|
||||
assert.match(pathTag, new RegExp(`title="${LONG_PATH.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}"`));
|
||||
assert.ok(pathRowClass(markup).split(/\s+/).includes("min-w-0"), "path row missing min-w-0");
|
||||
|
||||
const versionTag = openingTagContaining(markup, VERSION);
|
||||
assertClasses(versionTag, ["shrink-0"]);
|
||||
|
||||
const input = markup.match(/<input\b[^>]*>/);
|
||||
assert.ok(input, "missing custom path input");
|
||||
assertClasses(input[0], ["flex-1", "min-w-0"]);
|
||||
assert.match(input[0], new RegExp(`value="${LONG_PATH.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}"`));
|
||||
}
|
||||
|
||||
test("Claude Code card truncates a long CLI path inside the card", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<ClaudeCodeCard
|
||||
pathInfo={{ path: LONG_PATH, version: VERSION, available: true }}
|
||||
isResolvingPath={false}
|
||||
customPath={LONG_PATH}
|
||||
onCustomPathChange={() => {}}
|
||||
onRecheckPath={() => {}}
|
||||
onResetPath={() => {}}
|
||||
configDir=""
|
||||
onConfigDirChange={() => {}}
|
||||
settingsPath=""
|
||||
onSettingsPathChange={() => {}}
|
||||
envText=""
|
||||
onEnvTextChange={() => {}}
|
||||
/>,
|
||||
);
|
||||
assertLongPathTruncates(markup);
|
||||
});
|
||||
|
||||
test("Codex card truncates a long CLI path inside the card", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<CodexConnectionCard
|
||||
pathInfo={{ path: LONG_PATH, version: VERSION, available: true }}
|
||||
isResolvingPath={false}
|
||||
customPath={LONG_PATH}
|
||||
onCustomPathChange={() => {}}
|
||||
onRecheckPath={() => {}}
|
||||
onResetPath={() => {}}
|
||||
integration={null}
|
||||
loginSession={null}
|
||||
isLoading={false}
|
||||
error={null}
|
||||
onRefresh={() => {}}
|
||||
onConnect={() => {}}
|
||||
onCancel={() => {}}
|
||||
onOpenUrl={() => {}}
|
||||
onLogout={() => {}}
|
||||
appServerRuntime="sdk"
|
||||
appServerStatus={null}
|
||||
onAppServerRuntimeChange={() => {}}
|
||||
/>,
|
||||
);
|
||||
assertLongPathTruncates(markup);
|
||||
});
|
||||
|
||||
test("Codebuddy card truncates a long CLI path inside the card", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<CodebuddyCard
|
||||
pathInfo={{ path: LONG_PATH, version: VERSION, available: true }}
|
||||
isResolvingPath={false}
|
||||
customPath={LONG_PATH}
|
||||
onCustomPathChange={() => {}}
|
||||
onRecheckPath={() => {}}
|
||||
onResetPath={() => {}}
|
||||
internetEnv=""
|
||||
onInternetEnvChange={() => {}}
|
||||
envText=""
|
||||
onEnvTextChange={() => {}}
|
||||
/>,
|
||||
);
|
||||
assertLongPathTruncates(markup);
|
||||
});
|
||||
|
||||
test("Copilot CLI card truncates a long CLI path inside the card", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<CopilotCliCard
|
||||
pathInfo={{ path: LONG_PATH, version: VERSION, available: true }}
|
||||
isResolvingPath={false}
|
||||
customPath={LONG_PATH}
|
||||
onCustomPathChange={() => {}}
|
||||
onRecheckPath={() => {}}
|
||||
onResetPath={() => {}}
|
||||
/>,
|
||||
);
|
||||
assertLongPathTruncates(markup);
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import { createRequire } from "node:module";
|
||||
import type { SerializeAddon as SerializeAddonType } from "@xterm/addon-serialize";
|
||||
import type { Terminal as XTermType } from "@xterm/xterm";
|
||||
|
||||
import { DEFAULT_KEYWORD_HIGHLIGHT_RULES } from "../../domain/models/terminal.ts";
|
||||
import { writeTerminalDataWithLineTimestamps } from "./runtime/terminalLineTimestamps.ts";
|
||||
import {
|
||||
noteTerminalOutputPressureData,
|
||||
@@ -81,6 +82,76 @@ test("new output is colored on the written cells without decorations", async ()
|
||||
term.dispose();
|
||||
});
|
||||
|
||||
test("keyword highlight skips cells whose PTY foreground is already colored", async () => {
|
||||
// Starship directory segments set a truecolor foreground (#3940). Those
|
||||
// cells must keep the PTY color; only default-foreground matches are recolored.
|
||||
const unixPath = DEFAULT_KEYWORD_HIGHLIGHT_RULES.find((entry) => entry.id === "unix-path");
|
||||
assert.ok(unixPath);
|
||||
const keywordRgb = Number.parseInt(unixPath.color.slice(1), 16);
|
||||
const cream = 0xfbf1c7;
|
||||
const yellow = 0xfabd2f;
|
||||
const term = new XTerm({ allowProposedApi: true, cols: 80, rows: 5, scrollback: 20 });
|
||||
const highlighter = new KeywordHighlighter(term);
|
||||
highlighter.setRules([unixPath], true);
|
||||
await write(
|
||||
term,
|
||||
"\x1b[38;2;251;241;199m\x1b[48;2;250;189;47m/opt\x1b[0m"
|
||||
+ " \x1b[38;5;220m/mnt\x1b[0m"
|
||||
+ " \x1b[31m/bin\x1b[0m"
|
||||
+ " /var",
|
||||
);
|
||||
|
||||
const tokenCells = (text: string) => {
|
||||
const line = term.buffer.active.getLine(0);
|
||||
const index = (line?.translateToString(true) ?? "").indexOf(text);
|
||||
assert.ok(index >= 0, text);
|
||||
return Array.from({ length: text.length }, (_, offset) => {
|
||||
const cell = line?.getCell(index + offset);
|
||||
assert.ok(cell, `${text}[${offset}]`);
|
||||
return cell;
|
||||
});
|
||||
};
|
||||
|
||||
for (const cell of tokenCells("/opt")) {
|
||||
assert.equal(cell.isFgRGB(), true);
|
||||
assert.equal(cell.getFgColorMode(), 0x3000000);
|
||||
assert.equal(cell.getFgColor(), cream);
|
||||
assert.equal(cell.isBgRGB(), true);
|
||||
assert.equal(cell.getBgColor(), yellow);
|
||||
}
|
||||
for (const cell of tokenCells("/mnt")) {
|
||||
assert.equal(cell.isFgPalette(), true);
|
||||
assert.equal(cell.getFgColorMode(), 0x2000000);
|
||||
assert.equal(cell.getFgColor(), 220);
|
||||
}
|
||||
for (const cell of tokenCells("/bin")) {
|
||||
assert.equal(cell.isFgPalette(), true);
|
||||
assert.equal(cell.getFgColorMode(), 0x1000000);
|
||||
assert.equal(cell.getFgColor(), 1);
|
||||
}
|
||||
for (const cell of tokenCells("/var")) {
|
||||
assert.equal(cell.isFgRGB(), true);
|
||||
assert.equal(cell.getFgColor(), keywordRgb);
|
||||
}
|
||||
|
||||
highlighter.setRules([{ ...unixPath, color: "#60A5FA" }], true);
|
||||
await highlighter.whenSettled();
|
||||
assert.equal(tokenCells("/opt")[0]?.getFgColor(), cream);
|
||||
assert.equal(tokenCells("/mnt")[0]?.getFgColor(), 220);
|
||||
assert.equal(tokenCells("/bin")[0]?.getFgColor(), 1);
|
||||
assert.equal(tokenCells("/var")[0]?.getFgColor(), BLUE);
|
||||
|
||||
highlighter.setRules([{ ...unixPath, color: "#60A5FA" }], false);
|
||||
await highlighter.whenSettled();
|
||||
assert.equal(tokenCells("/opt")[0]?.getFgColor(), cream);
|
||||
assert.equal(tokenCells("/mnt")[0]?.getFgColor(), 220);
|
||||
assert.equal(tokenCells("/bin")[0]?.getFgColor(), 1);
|
||||
assert.equal(tokenCells("/var")[0]?.getFgColor(), -1);
|
||||
|
||||
highlighter.dispose();
|
||||
term.dispose();
|
||||
});
|
||||
|
||||
test("ordinary Enter output does not revisit existing history", async () => {
|
||||
const term = new XTerm({ allowProposedApi: true, cols: 80, rows: 5, scrollback: 20 });
|
||||
const highlighter = new KeywordHighlighter(term);
|
||||
|
||||
@@ -67,6 +67,7 @@ const CELL_INDICES = 3;
|
||||
const CELL_CONTENT = 0;
|
||||
const CELL_FG = 1;
|
||||
const STYLE_MASK = 0xfc000000;
|
||||
const CM_MASK = 0x3000000;
|
||||
const CM_RGB = 0x3000000;
|
||||
const MAX_PLUGIN_HIGHLIGHT_SCAN_CHARS = 4_096;
|
||||
const MAX_PLUGIN_HIGHLIGHT_MATCHES_PER_WRITE = 256;
|
||||
@@ -1061,7 +1062,13 @@ export class KeywordHighlighter implements IDisposable {
|
||||
const dataIndex = x * CELL_INDICES;
|
||||
const content = internal._data[dataIndex + CELL_CONTENT];
|
||||
const currentFg = internal._data[dataIndex + CELL_FG];
|
||||
if (!originals.mask[x] || originals.content[x] !== content) {
|
||||
// A cell already carrying a PTY palette/truecolor foreground (starship
|
||||
// directory segments, ANSI logs) must keep that color. Keyword RGB is
|
||||
// only for the default foreground.
|
||||
const maskedForContent = originals.mask[x] === 1 && originals.content[x] === content;
|
||||
const ptyFg = maskedForContent ? originals.fg[x] : currentFg;
|
||||
if ((ptyFg & CM_MASK) !== 0) continue;
|
||||
if (!maskedForContent) {
|
||||
originals.fg[x] = currentFg;
|
||||
originals.content[x] = content;
|
||||
originals.mask[x] = 1;
|
||||
|
||||
@@ -348,6 +348,62 @@ test("startSerial does not arm the fallback when auto-login is cancelled before
|
||||
assert.deepEqual(writtenCommands, []);
|
||||
});
|
||||
|
||||
test("restored serial attach ignores a startup command hydrated while the port open is pending", async () => {
|
||||
const writtenCommands: string[] = [];
|
||||
let autoLoginComplete: ((evt: { sessionId: string }) => void) | null = null;
|
||||
let releaseOpen: (() => void) | undefined;
|
||||
const openPending = new Promise<void>((resolve) => {
|
||||
releaseOpen = resolve;
|
||||
});
|
||||
const bootHost = {
|
||||
id: "serial-1",
|
||||
hostname: "/dev/ttyUSB0",
|
||||
protocol: "serial" as const,
|
||||
username: "admin",
|
||||
password: "secret",
|
||||
};
|
||||
const hostRef = { current: bootHost };
|
||||
|
||||
const backend = buildBackend({
|
||||
startSerialSession: async () => {
|
||||
await openPending;
|
||||
return "serial-session";
|
||||
},
|
||||
onTelnetAutoLoginComplete: (
|
||||
_sessionId: string,
|
||||
cb: (evt: { sessionId: string }) => void,
|
||||
) => {
|
||||
autoLoginComplete = cb;
|
||||
return noop;
|
||||
},
|
||||
onTelnetAutoLoginCancelled: () => noop,
|
||||
writeToSession: (_sessionId: string, data: string) => {
|
||||
writtenCommands.push(data);
|
||||
},
|
||||
});
|
||||
|
||||
const ctx = buildCtx(backend, {
|
||||
host: bootHost,
|
||||
hostRef,
|
||||
terminalSettings: { startupCommandDelayMs: 0 },
|
||||
});
|
||||
|
||||
const started = createTerminalSessionStarters(ctx as never).startSerial(term as never);
|
||||
// Vault hydration can replace the fallback host after preflight has already
|
||||
// decided there is no command to hold for auto-login.
|
||||
hostRef.current = { ...bootHost, startupCommand: "show version" };
|
||||
releaseOpen?.();
|
||||
await started;
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
autoLoginComplete?.({ sessionId: "session-1" });
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
assert.equal(autoLoginComplete, null);
|
||||
assert.equal(ctx.hasRunStartupCommandRef.current, false);
|
||||
assert.deepEqual(writtenCommands, []);
|
||||
});
|
||||
|
||||
for (const runMode of ['paste', 'lineDelay', 'noAutoRun']) {
|
||||
test(`serial reconnect discards the previous delayed startup command (${runMode})`, async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
|
||||
@@ -3653,6 +3653,207 @@ test("restored local reconnect runs the host startup command while automatic ret
|
||||
assert.deepEqual(sessionWrites, []);
|
||||
});
|
||||
|
||||
test("restored SSH attach runs the live host startup command when the boot snapshot predates vault hydration", async () => {
|
||||
const sessionWrites: Array<{ id: string; data: string; automated?: boolean }> = [];
|
||||
// Session restore mounts the pane before the vault host (and group defaults)
|
||||
// replace the fallback snapshot. The dial can still succeed from that
|
||||
// snapshot; the startup command is only known on the effective host by the
|
||||
// time the shell is attached.
|
||||
const bootHost = {
|
||||
id: "host-1",
|
||||
label: "Target",
|
||||
hostname: "target.example.test",
|
||||
username: "alice",
|
||||
port: 22,
|
||||
protocol: "ssh" as const,
|
||||
password: "secret",
|
||||
};
|
||||
const effectiveHost = {
|
||||
...bootHost,
|
||||
startupCommand: "echo restored-startup",
|
||||
startupCommandRunMode: "paste" as const,
|
||||
};
|
||||
const hostRef = { current: bootHost };
|
||||
let releaseDial: (() => void) | undefined;
|
||||
const dialStarted = new Promise<void>((resolve) => {
|
||||
releaseDial = resolve;
|
||||
});
|
||||
const terminalBackend = {
|
||||
backendAvailable: () => true,
|
||||
startSSHSession: async () => {
|
||||
await dialStarted;
|
||||
return "ssh-session";
|
||||
},
|
||||
onSessionData: () => noop,
|
||||
onSessionExit: () => noop,
|
||||
onChainProgress: () => noop,
|
||||
writeToSession: (id: string, data: string, options?: { automated?: boolean }) => {
|
||||
sessionWrites.push({ id, data, automated: options?.automated });
|
||||
},
|
||||
resizeSession: noop,
|
||||
closeSession: noop,
|
||||
};
|
||||
const ctx = createStarterContext({
|
||||
host: bootHost,
|
||||
hostRef,
|
||||
terminalSettings: { startupCommandDelayMs: 0 },
|
||||
terminalBackend,
|
||||
startupCommand: undefined,
|
||||
suppressHostStartupCommandRef: {
|
||||
current: shouldSuppressHostStartupCommandOnReconnect("restored"),
|
||||
},
|
||||
promptLineBreakStateRef: undefined,
|
||||
});
|
||||
|
||||
const started = createTerminalSessionStarters(ctx as never).startSSH(createTermStub() as never);
|
||||
hostRef.current = effectiveHost;
|
||||
releaseDial?.();
|
||||
await started;
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
assert.deepEqual(sessionWrites, [
|
||||
{ id: "ssh-session", data: "echo restored-startup\r", automated: true },
|
||||
]);
|
||||
|
||||
sessionWrites.length = 0;
|
||||
hostRef.current = effectiveHost;
|
||||
const automaticCtx = createStarterContext({
|
||||
...ctx,
|
||||
sessionRef: { current: null },
|
||||
hasRunStartupCommandRef: { current: false },
|
||||
suppressHostStartupCommandRef: {
|
||||
current: shouldSuppressHostStartupCommandOnReconnect("automatic"),
|
||||
},
|
||||
});
|
||||
await createTerminalSessionStarters(automaticCtx as never).startSSH(createTermStub() as never);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.deepEqual(sessionWrites, []);
|
||||
});
|
||||
|
||||
test("restored SSH attach ignores a live startup command for a different dial target", async () => {
|
||||
const bootHost = {
|
||||
id: "host-1",
|
||||
label: "Target",
|
||||
hostname: "target.example.test",
|
||||
username: "alice",
|
||||
port: 22,
|
||||
protocol: "ssh" as const,
|
||||
password: "secret",
|
||||
};
|
||||
const mismatches = [
|
||||
{ label: "hostname", patch: { hostname: "moved.example.test" } },
|
||||
{ label: "port", patch: { port: 2222 } },
|
||||
{ label: "username", patch: { username: "bob" } },
|
||||
{ label: "protocol", patch: { protocol: "telnet" as const } },
|
||||
];
|
||||
|
||||
for (const { label, patch } of mismatches) {
|
||||
const sessionWrites: Array<{ id: string; data: string; automated?: boolean }> = [];
|
||||
const hostRef = { current: bootHost };
|
||||
let releaseDial: (() => void) | undefined;
|
||||
const dialStarted = new Promise<void>((resolve) => {
|
||||
releaseDial = resolve;
|
||||
});
|
||||
const terminalBackend = {
|
||||
backendAvailable: () => true,
|
||||
startSSHSession: async () => {
|
||||
await dialStarted;
|
||||
return "ssh-session";
|
||||
},
|
||||
onSessionData: () => noop,
|
||||
onSessionExit: () => noop,
|
||||
onChainProgress: () => noop,
|
||||
writeToSession: (id: string, data: string, options?: { automated?: boolean }) => {
|
||||
sessionWrites.push({ id, data, automated: options?.automated });
|
||||
},
|
||||
resizeSession: noop,
|
||||
closeSession: noop,
|
||||
};
|
||||
const ctx = createStarterContext({
|
||||
host: bootHost,
|
||||
hostRef,
|
||||
terminalSettings: { startupCommandDelayMs: 0 },
|
||||
terminalBackend,
|
||||
startupCommand: undefined,
|
||||
suppressHostStartupCommandRef: {
|
||||
current: shouldSuppressHostStartupCommandOnReconnect("restored"),
|
||||
},
|
||||
promptLineBreakStateRef: undefined,
|
||||
});
|
||||
|
||||
const started = createTerminalSessionStarters(ctx as never).startSSH(createTermStub() as never);
|
||||
hostRef.current = {
|
||||
...bootHost,
|
||||
...patch,
|
||||
startupCommand: "echo moved-startup",
|
||||
};
|
||||
releaseDial?.();
|
||||
await started;
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
assert.equal(ctx.hasRunStartupCommandRef.current, false, label);
|
||||
assert.deepEqual(sessionWrites, [], label);
|
||||
}
|
||||
});
|
||||
|
||||
test("restored SSH attach still runs a hydrated startup command when only the default port is materialized", async () => {
|
||||
const sessionWrites: Array<{ id: string; data: string; automated?: boolean }> = [];
|
||||
const bootHost = {
|
||||
id: "host-1",
|
||||
label: "Target",
|
||||
hostname: "target.example.test",
|
||||
username: "alice",
|
||||
port: 22,
|
||||
protocol: "ssh" as const,
|
||||
password: "secret",
|
||||
};
|
||||
const hostRef = { current: bootHost };
|
||||
let releaseDial: (() => void) | undefined;
|
||||
const dialStarted = new Promise<void>((resolve) => {
|
||||
releaseDial = resolve;
|
||||
});
|
||||
const terminalBackend = {
|
||||
backendAvailable: () => true,
|
||||
startSSHSession: async () => {
|
||||
await dialStarted;
|
||||
return "ssh-session";
|
||||
},
|
||||
onSessionData: () => noop,
|
||||
onSessionExit: () => noop,
|
||||
onChainProgress: () => noop,
|
||||
writeToSession: (id: string, data: string, options?: { automated?: boolean }) => {
|
||||
sessionWrites.push({ id, data, automated: options?.automated });
|
||||
},
|
||||
resizeSession: noop,
|
||||
closeSession: noop,
|
||||
};
|
||||
const ctx = createStarterContext({
|
||||
host: bootHost,
|
||||
hostRef,
|
||||
terminalSettings: { startupCommandDelayMs: 0 },
|
||||
terminalBackend,
|
||||
startupCommand: undefined,
|
||||
suppressHostStartupCommandRef: {
|
||||
current: shouldSuppressHostStartupCommandOnReconnect("restored"),
|
||||
},
|
||||
promptLineBreakStateRef: undefined,
|
||||
});
|
||||
|
||||
const started = createTerminalSessionStarters(ctx as never).startSSH(createTermStub() as never);
|
||||
hostRef.current = {
|
||||
...bootHost,
|
||||
port: undefined,
|
||||
startupCommand: "echo restored-startup",
|
||||
};
|
||||
releaseDial?.();
|
||||
await started;
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
assert.deepEqual(sessionWrites, [
|
||||
{ id: "ssh-session", data: "echo restored-startup\r", automated: true },
|
||||
]);
|
||||
});
|
||||
|
||||
for (const { name, hostDirectory, globalDirectory, expectedDirectory } of [
|
||||
{ name: "uses saved directory before global default", hostDirectory: "/Users/alice/project", globalDirectory: "/Users/alice/default", expectedDirectory: "/Users/alice/project" },
|
||||
{ name: "inherits global directory when unset", hostDirectory: undefined, globalDirectory: "/Users/alice/default", expectedDirectory: "/Users/alice/default" },
|
||||
|
||||
@@ -3,6 +3,56 @@ import { normalizeLineEndings, wrapBracketedPaste } from "../../../lib/utils";
|
||||
import { markPromptLineBreakCommandPending } from "./promptLineBreak";
|
||||
import type { TerminalSessionStartersContext } from "./createTerminalSessionStarters.types";
|
||||
|
||||
/**
|
||||
* Host startup is scheduled only after the shell attaches. Restored SSH panes
|
||||
* boot from the session fallback first; vault hydration (including group
|
||||
* defaults) replaces `hostRef` while that dial is still in flight. `startSSH`
|
||||
* keeps dialing the boot snapshot (`ctx.host`). The startup command may come
|
||||
* from the live host only when that object is still this dial: same id,
|
||||
* hostname, username, and dial port (`port || 22`), and the same protocol
|
||||
* when the live host sets one. A missing protocol is a legacy SSH host.
|
||||
* A saved host edited onto another endpoint since the workspace was saved
|
||||
* keeps the snapshot, so its command is not typed into the old session.
|
||||
*
|
||||
* Serial preflight uses this same resolver to decide whether to wait for
|
||||
* auto-login, then schedules again after the port opens. Non-SSH transports
|
||||
* stay on the snapshot, so a command that appears on `hostRef` during the
|
||||
* open is not typed into a saved username/password exchange.
|
||||
* A ref that only carries a write-path toggle (no id) is not a host snapshot.
|
||||
* A different host id is a later pane, not this dial, so keep the snapshot.
|
||||
*/
|
||||
const prefersLiveStartupHost = (protocol: string | undefined): boolean =>
|
||||
protocol === "ssh" || protocol === undefined;
|
||||
|
||||
const sshDialPort = (port: number | undefined): number => port || 22;
|
||||
|
||||
const sameSshDialTarget = (
|
||||
dial: TerminalSessionStartersContext["host"],
|
||||
live: TerminalSessionStartersContext["host"],
|
||||
): boolean => {
|
||||
if (live.hostname !== dial.hostname) return false;
|
||||
if ((live.username ?? "") !== (dial.username ?? "")) return false;
|
||||
if (sshDialPort(live.port) !== sshDialPort(dial.port)) return false;
|
||||
// A live protocol, when set, must still be the protocol this dial used.
|
||||
if (live.protocol !== undefined && live.protocol !== (dial.protocol || "ssh")) return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
const resolveStartupHost = (
|
||||
ctx: TerminalSessionStartersContext,
|
||||
): TerminalSessionStartersContext["host"] => {
|
||||
const live = ctx.hostRef?.current;
|
||||
if (
|
||||
prefersLiveStartupHost(ctx.host.protocol)
|
||||
&& live
|
||||
&& live.id === ctx.host.id
|
||||
&& sameSshDialTarget(ctx.host, live)
|
||||
) {
|
||||
return live;
|
||||
}
|
||||
return ctx.host;
|
||||
};
|
||||
|
||||
const STARTUP_COMMAND_DEFAULT_DELAY_MS = 600;
|
||||
const STARTUP_COMMAND_MAX_DELAY_MS = 10000;
|
||||
|
||||
@@ -38,7 +88,7 @@ export const resolveStartupCommand = (
|
||||
ctx: TerminalSessionStartersContext,
|
||||
options?: { consumeSuppressHostStartupCommand?: boolean },
|
||||
): string | undefined => {
|
||||
const command = ctx.startupCommand || (ctx.suppressHostStartupCommandRef?.current ? undefined : ctx.host.startupCommand);
|
||||
const command = ctx.startupCommand || (ctx.suppressHostStartupCommandRef?.current ? undefined : resolveStartupHost(ctx).startupCommand);
|
||||
if (options?.consumeSuppressHostStartupCommand && ctx.suppressHostStartupCommandRef) {
|
||||
ctx.suppressHostStartupCommandRef.current = false;
|
||||
}
|
||||
@@ -96,7 +146,7 @@ export const scheduleStartupCommand = (
|
||||
|
||||
const runMode = ctx.startupCommand
|
||||
? (ctx.multiLineRunMode ?? "paste")
|
||||
: (ctx.host.startupCommandRunMode ?? "paste");
|
||||
: (resolveStartupHost(ctx).startupCommandRunMode ?? "paste");
|
||||
if (runMode === "paste") {
|
||||
timeoutId = setTimeout(() => {
|
||||
if (cancelled) return;
|
||||
|
||||
@@ -211,7 +211,7 @@ test("ended soft-hidden and alternate-screen sessions can fully hibernate", () =
|
||||
);
|
||||
assert.match(
|
||||
hibernateBody,
|
||||
/if \(softHiddenRef\.current\) \{\s*if \(statusRef\.current === "disconnected"\) \{\s*upgradeSoftHiddenRuntimeToHibernate\(\);\s*\}\s*return;/,
|
||||
/if \(softHiddenRef\.current\) \{\s*const retainedKeepCount = statusRef\.current === "connected"\s*\? resolveHibernateKeepRendererCount\(terminalSettingsRef\.current\)\s*: 0;\s*if \(statusRef\.current === "disconnected" \|\| !\(retainedKeepCount > 0\)\) \{\s*upgradeSoftHiddenRuntimeToHibernate\(\);\s*\}\s*return;/,
|
||||
);
|
||||
assert.match(
|
||||
alternateSkipBody,
|
||||
@@ -227,7 +227,7 @@ test("a cancelled soft-hidden upgrade resumes its renderer", () => {
|
||||
|
||||
assert.match(
|
||||
hibernateBody,
|
||||
/if \(softHiddenRef\.current\) \{\s*if \(statusRef\.current === "disconnected"\) \{\s*upgradeSoftHiddenRuntimeToHibernate\(\);/,
|
||||
/if \(softHiddenRef\.current\) \{\s*const retainedKeepCount = statusRef\.current === "connected"\s*\? resolveHibernateKeepRendererCount\(terminalSettingsRef\.current\)\s*: 0;\s*if \(statusRef\.current === "disconnected" \|\| !\(retainedKeepCount > 0\)\) \{\s*upgradeSoftHiddenRuntimeToHibernate\(\);/,
|
||||
);
|
||||
const wakeIndex = upgradeBody.indexOf("wakeSoftHiddenRuntimeRef.current?.()");
|
||||
const upgradeIndex = upgradeBody.indexOf("fullHibernateRuntime().then(");
|
||||
@@ -311,10 +311,15 @@ test("a full keep count soft-hides the leaving session instead of snapshotting i
|
||||
assert.match(body, /getSoftHiddenOccupancy\(\)/);
|
||||
assert.match(body, /stillWantsRetention:/);
|
||||
assert.match(body, /softHide: \(\) => hideRuntimeOnly\(\)/);
|
||||
assert.match(
|
||||
body,
|
||||
/resolveKeepCount: \(\) => \(\s*statusRef\.current === "connected"\s*\? resolveHibernateKeepRendererCount\(terminalSettingsRef\.current\)\s*: 0\s*\)/,
|
||||
"retention after eviction must read the live keep count, not the captured one",
|
||||
);
|
||||
assert.doesNotMatch(body, /resolveHiddenTabEviction/);
|
||||
assert.doesNotMatch(body, /requestEviction\(keepDecision/);
|
||||
assert.match(
|
||||
body,
|
||||
/if \(softHiddenRef\.current\) \{\s*if \(statusRef\.current === "disconnected"\) \{\s*upgradeSoftHiddenRuntimeToHibernate\(\);\s*\}\s*return;\s*\}/,
|
||||
/if \(softHiddenRef\.current\) \{\s*const retainedKeepCount = statusRef\.current === "connected"\s*\? resolveHibernateKeepRendererCount\(terminalSettingsRef\.current\)\s*: 0;\s*if \(statusRef\.current === "disconnected" \|\| !\(retainedKeepCount > 0\)\) \{\s*upgradeSoftHiddenRuntimeToHibernate\(\);\s*\}\s*return;\s*\}/,
|
||||
);
|
||||
});
|
||||
@@ -20,6 +20,8 @@ type UseTerminalHibernateEffectOptions = {
|
||||
isSearchOpen: boolean;
|
||||
hibernateEnabled: boolean;
|
||||
hibernateDelayMs: number;
|
||||
/** Current retained-renderer limit. Zero rechecks soft-hidden tabs immediately. */
|
||||
hibernateKeepRendererCount?: number;
|
||||
fileTransferActive: boolean;
|
||||
hibernatedRef: React.MutableRefObject<boolean>;
|
||||
softHiddenRef: React.MutableRefObject<boolean>;
|
||||
@@ -49,6 +51,7 @@ export function useTerminalHibernateEffect({
|
||||
isSearchOpen,
|
||||
hibernateEnabled,
|
||||
hibernateDelayMs,
|
||||
hibernateKeepRendererCount,
|
||||
fileTransferActive,
|
||||
hibernatedRef,
|
||||
softHiddenRef,
|
||||
@@ -210,4 +213,27 @@ export function useTerminalHibernateEffect({
|
||||
softHiddenRef,
|
||||
status,
|
||||
]);
|
||||
|
||||
// Separate from the visibility timer so a keep-count edit does not clear an
|
||||
// in-flight leave. Already soft-hidden tabs have no timer left to notice a
|
||||
// later zero; this one re-enters hibernate without stacking a second snapshot
|
||||
// when StrictMode runs the effect twice.
|
||||
useEffect(() => {
|
||||
if (!hibernateEnabled || hibernateKeepRendererCount !== 0) return;
|
||||
if (!softHiddenRef.current) return;
|
||||
if (resolvePaneVisible(sessionId, isVisibleRef.current)) return;
|
||||
const timer = window.setTimeout(() => {
|
||||
if (resolvePaneVisible(sessionId, isVisibleRef.current)) return;
|
||||
if (!softHiddenRef.current) return;
|
||||
onHibernateRef.current();
|
||||
}, 0);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [
|
||||
hibernateEnabled,
|
||||
hibernateKeepRendererCount,
|
||||
isVisible,
|
||||
isVisibleRef,
|
||||
sessionId,
|
||||
softHiddenRef,
|
||||
]);
|
||||
}
|
||||
@@ -58,6 +58,90 @@ test("soft-hidden wake keeps its marker until the runtime has resumed", () => {
|
||||
assert.doesNotMatch(softWakeBranch, /softHiddenRef\.current\s*=\s*false/);
|
||||
});
|
||||
|
||||
test("a keep-count of zero rechecks a soft-hidden tab while hibernate stays enabled", async (t) => {
|
||||
const sessionId = "keep-count-zero-recheck";
|
||||
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
|
||||
const previousActEnvironment = Object.getOwnPropertyDescriptor(
|
||||
globalThis,
|
||||
"IS_REACT_ACT_ENVIRONMENT",
|
||||
);
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
configurable: true,
|
||||
value: { setTimeout, clearTimeout },
|
||||
});
|
||||
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", {
|
||||
configurable: true,
|
||||
value: true,
|
||||
});
|
||||
t.after(() => {
|
||||
removePaneVisible(sessionId);
|
||||
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow);
|
||||
else Reflect.deleteProperty(globalThis, "window");
|
||||
if (previousActEnvironment) {
|
||||
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", previousActEnvironment);
|
||||
} else {
|
||||
Reflect.deleteProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT");
|
||||
}
|
||||
});
|
||||
|
||||
let hibernateCalls = 0;
|
||||
let renderer: ReactTestRenderer | null = null;
|
||||
const softHiddenRef = { current: true };
|
||||
const ref = <T>(current: T) => ({ current });
|
||||
|
||||
function Probe({ keepCount }: { keepCount: number }) {
|
||||
useTerminalHibernateEffect({
|
||||
sessionId,
|
||||
isVisible: false,
|
||||
isVisibleRef: ref(false),
|
||||
getSessionConnectedRef: ref(() => true),
|
||||
status: "connected",
|
||||
isSearchOpen: false,
|
||||
hibernateEnabled: true,
|
||||
hibernateDelayMs: 60_000,
|
||||
hibernateKeepRendererCount: keepCount,
|
||||
fileTransferActive: false,
|
||||
hibernatedRef: ref(false),
|
||||
softHiddenRef,
|
||||
hibernatePendingBufferRef: ref(""),
|
||||
hibernateSnapshotRef: ref(""),
|
||||
hibernateViewportSnapshotRef: ref(""),
|
||||
hibernateScrollbackSnapshotRef: ref(""),
|
||||
hibernateContextSnapshotRef: ref(""),
|
||||
hibernateContextViewportSnapshotRef: ref(""),
|
||||
hibernateContextScrollbackSnapshotRef: ref(""),
|
||||
hibernateAlternateScreenRef: ref(false),
|
||||
hasRuntimeRef: ref(true),
|
||||
onHibernate: () => { hibernateCalls += 1; },
|
||||
onSoftHideWake: () => {},
|
||||
onWake: () => true,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const element = (keepCount: number) => React.createElement(
|
||||
React.StrictMode,
|
||||
null,
|
||||
React.createElement(Probe, { keepCount }),
|
||||
);
|
||||
|
||||
await act(async () => { renderer = create(element(2)); });
|
||||
assert.equal(hibernateCalls, 0, "a positive keep count must leave the retained runtime alone");
|
||||
|
||||
await act(async () => { renderer!.update(element(1)); });
|
||||
assert.equal(hibernateCalls, 0, "a still-positive keep count must not drop every retained runtime");
|
||||
|
||||
await act(async () => {
|
||||
renderer!.update(element(0));
|
||||
});
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
assert.equal(hibernateCalls, 1, "zero retention must re-enter hibernate for a soft-hidden tab");
|
||||
|
||||
await act(async () => renderer!.unmount());
|
||||
});
|
||||
|
||||
test("hidden disconnected remote terminals release their retained runtime", async (t) => {
|
||||
const sessionId = "disconnected-hidden-hibernate-test";
|
||||
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
|
||||
|
||||
@@ -31,20 +31,17 @@
|
||||
<text x="85" y="85.0" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">7K</text>
|
||||
<text x="43" y="271.5" fill="#e6edf3" font-size="17" font-family="Handlee, cursive" font-weight="bold" transform="rotate(-90, 43, 271.5)">GitHub Stars</text>
|
||||
<text x="90.0" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jan 2026</text>
|
||||
<text x="223.6" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Mar 2026</text>
|
||||
<text x="296.2" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Apr 2026</text>
|
||||
<text x="366.6" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">May 2026</text>
|
||||
<text x="439.2" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jun 2026</text>
|
||||
<text x="509.5" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jul 2026</text>
|
||||
<text x="582.2" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Aug 2026</text>
|
||||
<text x="654.8" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Sep 2026</text>
|
||||
<text x="725.2" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Oct 2026</text>
|
||||
<text x="220.4" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Mar 2026</text>
|
||||
<text x="291.4" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Apr 2026</text>
|
||||
<text x="430.9" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jun 2026</text>
|
||||
<text x="570.5" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Aug 2026</text>
|
||||
<text x="710.1" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Oct 2026</text>
|
||||
<text x="50%" y="525" fill="#e6edf3" font-size="17" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Date</text>
|
||||
<line x1="90" y1="80" x2="90" y2="463" stroke="#30363d" stroke-width="2.5" filter="url(#xkcdify)"/>
|
||||
<line x1="90" y1="463" x2="765" y2="463" stroke="#30363d" stroke-width="2.5" filter="url(#xkcdify)"/>
|
||||
<path d="M 90.0,462.2 C 90.4,462.1 92.0,461.8 92.3,461.7 C 93.1,461.6 93.9,461.4 94.7,461.2 C 95.5,461.1 96.2,460.8 97.0,460.8 C 97.8,460.7 98.6,460.7 99.4,460.7 C 100.2,460.7 100.9,460.6 101.7,460.6 C 102.5,460.5 103.3,460.5 104.1,460.4 C 104.8,460.4 105.2,460.3 106.4,460.3 C 107.6,460.2 109.9,460.1 111.1,460.1 C 112.3,460.1 112.7,460.0 113.4,460.0 C 114.2,460.0 115.0,460.0 115.8,459.9 C 116.6,459.9 117.3,459.9 118.1,459.9 C 118.9,459.9 119.7,459.9 120.5,459.8 C 121.2,459.8 122.0,459.7 122.8,459.7 C 123.6,459.6 124.4,459.6 125.2,459.4 C 125.9,459.3 126.7,459.0 127.5,458.7 C 128.3,458.3 129.1,457.6 129.8,457.3 C 130.6,457.1 131.4,457.2 132.2,457.1 C 133.0,457.1 133.8,457.1 134.5,457.0 C 135.3,457.0 135.7,457.0 136.9,456.9 C 138.0,456.9 140.0,456.9 141.6,456.9 C 143.1,456.8 145.1,456.8 146.2,456.8 C 147.4,456.7 147.8,456.7 148.6,456.7 C 149.4,456.6 150.2,456.6 150.9,456.5 C 151.7,456.5 152.5,456.5 153.3,456.4 C 154.1,456.4 154.8,456.4 155.6,456.4 C 156.4,456.4 156.8,456.4 158.0,456.3 C 159.1,456.3 160.7,456.2 162.7,456.2 C 164.6,456.2 167.7,456.2 169.7,456.2 C 171.6,456.1 172.0,456.2 174.4,456.1 C 176.7,456.1 181.8,455.9 183.8,455.9 C 185.7,455.8 184.9,455.9 186.1,455.8 C 187.3,455.8 188.8,455.8 190.8,455.8 C 192.7,455.8 193.9,455.7 197.8,455.7 C 201.7,455.6 211.1,455.5 214.2,455.5 C 217.3,455.5 215.8,455.5 216.6,455.4 C 217.3,455.4 217.7,456.1 218.9,455.4 C 220.1,454.7 222.4,454.1 223.6,451.3 C 224.8,448.4 225.2,440.7 225.9,438.3 C 226.7,435.9 227.5,437.3 228.3,436.9 C 229.1,436.5 229.8,436.3 230.6,436.0 C 231.4,435.8 232.2,435.6 233.0,435.4 C 233.8,435.3 234.5,435.3 235.3,435.2 C 236.1,435.1 236.9,434.9 237.7,434.8 C 238.4,434.8 239.2,434.8 240.0,434.8 C 240.8,434.7 241.6,434.5 242.3,434.4 C 243.1,434.3 243.9,434.3 244.7,434.2 C 245.5,434.1 246.2,433.7 247.0,433.6 C 247.8,433.5 248.6,433.5 249.4,433.5 C 250.2,433.4 250.5,433.2 251.7,433.1 C 252.9,433.0 255.2,433.0 256.4,432.9 C 257.6,432.9 258.0,432.8 258.8,432.7 C 259.5,432.7 260.3,432.7 261.1,432.6 C 261.9,432.5 262.7,432.2 263.4,432.1 C 264.2,431.9 265.0,432.6 265.8,431.7 C 266.6,430.8 267.3,427.6 268.1,426.6 C 268.9,425.5 269.7,425.7 270.5,425.5 C 271.2,425.3 272.0,425.5 272.8,425.4 C 273.6,425.3 274.4,424.9 275.2,424.6 C 275.9,424.4 276.7,424.2 277.5,423.7 C 278.3,423.2 279.1,422.2 279.8,421.6 C 280.6,421.0 281.4,420.4 282.2,420.0 C 283.0,419.5 283.8,419.2 284.5,419.0 C 285.3,418.7 286.1,418.6 286.9,418.5 C 287.7,418.3 288.4,418.4 289.2,418.2 C 290.0,418.1 290.8,417.9 291.6,417.8 C 292.3,417.7 293.1,417.8 293.9,417.7 C 294.7,417.6 295.5,417.6 296.2,417.5 C 297.0,417.4 297.8,417.4 298.6,417.0 C 299.4,416.6 300.2,415.5 300.9,415.0 C 301.7,414.5 302.5,414.3 303.3,414.0 C 304.1,413.6 304.8,414.3 305.6,412.8 C 306.4,411.2 307.2,406.4 308.0,404.6 C 308.8,402.7 309.5,402.4 310.3,401.7 C 311.1,400.9 311.9,400.4 312.7,399.9 C 313.4,399.3 314.2,398.8 315.0,398.4 C 315.8,398.0 316.6,397.6 317.3,397.3 C 318.1,397.1 318.9,397.0 319.7,396.8 C 320.5,396.6 321.2,396.3 322.0,396.0 C 322.8,395.7 323.6,395.2 324.4,395.0 C 325.2,394.7 325.9,394.8 326.7,394.5 C 327.5,394.2 328.3,393.8 329.1,393.5 C 329.8,393.1 330.6,392.9 331.4,392.6 C 332.2,392.4 333.0,392.2 333.8,392.0 C 334.5,391.9 335.3,391.8 336.1,391.7 C 336.9,391.6 337.7,391.4 338.4,391.2 C 339.2,391.0 340.0,390.9 340.8,390.7 C 341.6,390.5 342.3,390.3 343.1,390.0 C 343.9,389.6 344.7,389.0 345.5,388.8 C 346.2,388.5 347.0,388.4 347.8,388.2 C 348.6,388.1 349.4,387.9 350.2,387.8 C 350.9,387.7 351.7,387.6 352.5,387.5 C 353.3,387.4 354.1,387.2 354.8,387.2 C 355.6,387.1 356.4,387.1 357.2,387.0 C 358.0,386.9 358.8,386.7 359.5,386.4 C 360.3,386.1 361.1,385.6 361.9,385.3 C 362.7,385.0 363.4,384.6 364.2,384.4 C 365.0,384.2 365.8,384.1 366.6,384.0 C 367.3,383.9 368.1,383.8 368.9,383.6 C 369.7,383.3 370.5,382.8 371.2,382.6 C 372.0,382.4 372.8,382.4 373.6,382.3 C 374.4,382.2 375.2,382.0 375.9,381.8 C 376.7,381.6 377.5,381.4 378.3,381.3 C 379.1,381.1 379.8,380.8 380.6,380.7 C 381.4,380.5 382.2,380.4 383.0,380.2 C 383.8,379.9 384.5,379.6 385.3,379.3 C 386.1,379.1 386.9,379.0 387.7,378.7 C 388.4,378.5 389.2,378.2 390.0,377.9 C 390.8,377.6 391.6,377.4 392.3,377.1 C 393.1,376.8 393.9,376.8 394.7,376.4 C 395.5,376.0 396.2,375.1 397.0,374.7 C 397.8,374.3 398.6,374.3 399.4,374.1 C 400.2,374.0 400.9,374.0 401.7,373.9 C 402.5,373.8 403.3,373.7 404.1,373.5 C 404.8,373.4 405.6,373.6 406.4,373.3 C 407.2,372.9 408.0,371.8 408.8,371.4 C 409.5,371.0 410.3,371.1 411.1,370.8 C 411.9,370.6 412.7,370.1 413.4,369.9 C 414.2,369.7 415.0,369.8 415.8,369.6 C 416.6,369.4 417.3,368.9 418.1,368.7 C 418.9,368.5 419.7,368.5 420.5,368.4 C 421.2,368.3 422.0,368.3 422.8,368.1 C 423.6,367.9 424.4,367.6 425.2,367.1 C 425.9,366.6 426.7,365.5 427.5,365.0 C 428.3,364.5 429.1,364.2 429.8,363.9 C 430.6,363.5 431.4,363.4 432.2,362.7 C 433.0,361.9 433.8,360.2 434.5,359.4 C 435.3,358.6 436.1,358.3 436.9,357.8 C 437.7,357.3 438.4,357.2 439.2,356.5 C 440.0,355.8 440.8,354.2 441.6,353.5 C 442.3,352.8 443.1,352.7 443.9,352.3 C 444.7,351.9 445.5,351.5 446.2,351.1 C 447.0,3Line truncated
|
||||
<path d="M 90.0,462.2 C 90.4,462.1 92.0,461.8 92.3,461.7 C 93.1,461.6 93.9,461.4 94.7,461.2 C 95.5,461.1 96.2,460.8 97.0,460.8 C 97.8,460.7 98.6,460.7 99.4,460.7 C 100.2,460.7 100.9,460.6 101.7,460.6 C 102.5,460.5 103.3,460.5 104.1,460.4 C 104.8,460.4 105.2,460.3 106.4,460.3 C 107.6,460.2 109.9,460.1 111.1,460.1 C 112.3,460.1 112.7,460.0 113.4,460.0 C 114.2,460.0 115.0,460.0 115.8,459.9 C 116.6,459.9 117.3,459.9 118.1,459.9 C 118.9,459.9 119.7,459.9 120.5,459.8 C 121.2,459.8 122.0,459.7 122.8,459.7 C 123.6,459.6 124.4,459.6 125.2,459.4 C 125.9,459.3 126.7,459.0 127.5,458.7 C 128.3,458.3 129.1,457.6 129.8,457.3 C 130.6,457.1 131.4,457.2 132.2,457.1 C 133.0,457.1 133.8,457.1 134.5,457.0 C 135.3,457.0 135.7,457.0 136.9,456.9 C 138.0,456.9 140.0,456.9 141.6,456.9 C 143.1,456.8 145.1,456.8 146.2,456.8 C 147.4,456.7 147.8,456.7 148.6,456.7 C 149.4,456.6 150.2,456.6 150.9,456.5 C 151.7,456.5 152.5,456.5 153.3,456.4 C 154.1,456.4 154.8,456.4 155.6,456.4 C 156.4,456.4 156.8,456.4 158.0,456.3 C 159.1,456.3 160.7,456.2 162.7,456.2 C 164.6,456.2 167.7,456.2 169.7,456.2 C 171.6,456.1 172.0,456.2 174.4,456.1 C 176.7,456.1 181.8,455.9 183.8,455.9 C 185.7,455.8 184.9,455.9 186.1,455.8 C 187.3,455.8 188.8,455.8 190.8,455.8 C 192.7,455.8 193.9,455.7 197.8,455.7 C 201.7,455.6 211.1,455.5 214.2,455.5 C 217.3,455.5 215.8,455.5 216.6,455.4 C 217.3,455.4 217.7,456.1 218.9,455.4 C 220.1,454.7 222.4,454.1 223.6,451.3 C 224.8,448.4 225.2,440.7 225.9,438.3 C 226.7,435.9 227.5,437.3 228.3,436.9 C 229.1,436.5 229.8,436.3 230.6,436.0 C 231.4,435.8 232.2,435.6 233.0,435.4 C 233.8,435.3 234.5,435.3 235.3,435.2 C 236.1,435.1 236.9,434.9 237.7,434.8 C 238.4,434.8 239.2,434.8 240.0,434.8 C 240.8,434.7 241.6,434.5 242.3,434.4 C 243.1,434.3 243.9,434.3 244.7,434.2 C 245.5,434.1 246.2,433.7 247.0,433.6 C 247.8,433.5 248.6,433.5 249.4,433.5 C 250.2,433.4 250.5,433.2 251.7,433.1 C 252.9,433.0 255.2,433.0 256.4,432.9 C 257.6,432.9 258.0,432.8 258.8,432.7 C 259.5,432.7 260.3,432.7 261.1,432.6 C 261.9,432.5 262.7,432.2 263.4,432.1 C 264.2,431.9 265.0,432.6 265.8,431.7 C 266.6,430.8 267.3,427.6 268.1,426.6 C 268.9,425.5 269.7,425.7 270.5,425.5 C 271.2,425.3 272.0,425.5 272.8,425.4 C 273.6,425.3 274.4,424.9 275.2,424.6 C 275.9,424.4 276.7,424.2 277.5,423.7 C 278.3,423.2 279.1,422.2 279.8,421.6 C 280.6,421.0 281.4,420.4 282.2,420.0 C 283.0,419.5 283.8,419.2 284.5,419.0 C 285.3,418.7 286.1,418.6 286.9,418.5 C 287.7,418.3 288.4,418.4 289.2,418.2 C 290.0,418.1 290.8,417.9 291.6,417.8 C 292.3,417.7 293.1,417.8 293.9,417.7 C 294.7,417.6 295.5,417.6 296.2,417.5 C 297.0,417.4 297.8,417.4 298.6,417.0 C 299.4,416.6 300.2,415.5 300.9,415.0 C 301.7,414.5 302.5,414.3 303.3,414.0 C 304.1,413.6 304.8,414.3 305.6,412.8 C 306.4,411.2 307.2,406.4 308.0,404.6 C 308.8,402.7 309.5,402.4 310.3,401.7 C 311.1,400.9 311.9,400.4 312.7,399.9 C 313.4,399.3 314.2,398.8 315.0,398.4 C 315.8,398.0 316.6,397.6 317.3,397.3 C 318.1,397.1 318.9,397.0 319.7,396.8 C 320.5,396.6 321.2,396.3 322.0,396.0 C 322.8,395.7 323.6,395.2 324.4,395.0 C 325.2,394.7 325.9,394.8 326.7,394.5 C 327.5,394.2 328.3,393.8 329.1,393.5 C 329.8,393.1 330.6,392.9 331.4,392.6 C 332.2,392.4 333.0,392.2 333.8,392.0 C 334.5,391.9 335.3,391.8 336.1,391.7 C 336.9,391.6 337.7,391.4 338.4,391.2 C 339.2,391.0 340.0,390.9 340.8,390.7 C 341.6,390.5 342.3,390.3 343.1,390.0 C 343.9,389.6 344.7,389.0 345.5,388.8 C 346.2,388.5 347.0,388.4 347.8,388.2 C 348.6,388.1 349.4,387.9 350.2,387.8 C 350.9,387.7 351.7,387.6 352.5,387.5 C 353.3,387.4 354.1,387.2 354.8,387.2 C 355.6,387.1 356.4,387.1 357.2,387.0 C 358.0,386.9 358.8,386.7 359.5,386.4 C 360.3,386.1 361.1,385.6 361.9,385.3 C 362.7,385.0 363.4,384.6 364.2,384.4 C 365.0,384.2 365.8,384.1 366.6,384.0 C 367.3,383.9 368.1,383.8 368.9,383.6 C 369.7,383.3 370.5,382.8 371.2,382.6 C 372.0,382.4 372.8,382.4 373.6,382.3 C 374.4,382.2 375.2,382.0 375.9,381.8 C 376.7,381.6 377.5,381.4 378.3,381.3 C 379.1,381.1 379.8,380.8 380.6,380.7 C 381.4,380.5 382.2,380.4 383.0,380.2 C 383.8,379.9 384.5,379.6 385.3,379.3 C 386.1,379.1 386.9,379.0 387.7,378.7 C 388.4,378.5 389.2,378.2 390.0,377.9 C 390.8,377.6 391.6,377.4 392.3,377.1 C 393.1,376.8 393.9,376.8 394.7,376.4 C 395.5,376.0 396.2,375.1 397.0,374.7 C 397.8,374.3 398.6,374.3 399.4,374.1 C 400.2,374.0 400.9,374.0 401.7,373.9 C 402.5,373.8 403.3,373.7 404.1,373.5 C 404.8,373.4 405.6,373.6 406.4,373.3 C 407.2,372.9 408.0,371.8 408.8,371.4 C 409.5,371.0 410.3,371.1 411.1,370.8 C 411.9,370.6 412.7,370.1 413.4,369.9 C 414.2,369.7 415.0,369.8 415.8,369.6 C 416.6,369.4 417.3,368.9 418.1,368.7 C 418.9,368.5 419.7,368.5 420.5,368.4 C 421.2,368.3 422.0,368.3 422.8,368.1 C 423.6,367.9 424.4,367.6 425.2,367.1 C 425.9,366.6 426.7,365.5 427.5,365.0 C 428.3,364.5 429.1,364.2 429.8,363.9 C 430.6,363.5 431.4,363.4 432.2,362.7 C 433.0,361.9 433.8,360.2 434.5,359.4 C 435.3,358.6 436.1,358.3 436.9,357.8 C 437.7,357.3 438.4,357.2 439.2,356.5 C 440.0,355.8 440.8,354.2 441.6,353.5 C 442.3,352.8 443.1,352.7 443.9,352.3 C 444.7,351.9 445.5,351.5 446.2,351.1 C 447.0,3Line truncated
|
||||
<circle cx="765.0" cy="99.0" r="5" fill="#dd4528" stroke="#0d1117" stroke-width="2" filter="url(#xkcdify)"/>
|
||||
<text x="753.0" y="77.0" fill="#dd4528" font-size="18" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">6652</text>
|
||||
<path d="M 90.0,462.2 C 90.4,462.1 91.9,461.8 92.3,461.7 C 93.1,461.6 93.8,461.4 94.6,461.2 C 95.3,461.1 96.1,460.8 96.9,460.8 C 97.6,460.7 98.4,460.7 99.2,460.7 C 99.9,460.7 100.7,460.6 101.4,460.6 C 102.2,460.5 103.0,460.5 103.7,460.4 C 104.5,460.4 104.9,460.3 106.0,460.3 C 107.2,460.2 109.4,460.1 110.6,460.1 C 111.7,460.1 112.1,460.0 112.9,460.0 C 113.6,460.0 114.4,460.0 115.2,459.9 C 115.9,459.9 116.7,459.9 117.5,459.9 C 118.2,459.9 119.0,459.9 119.7,459.8 C 120.5,459.8 121.3,459.7 122.0,459.7 C 122.8,459.6 123.6,459.6 124.3,459.4 C 125.1,459.3 125.8,459.0 126.6,458.7 C 127.4,458.3 128.1,457.6 128.9,457.3 C 129.7,457.1 130.4,457.2 131.2,457.1 C 131.9,457.1 132.7,457.1 133.5,457.0 C 134.2,457.0 134.6,457.0 135.8,456.9 C 136.9,456.9 138.8,456.9 140.3,456.9 C 141.9,456.8 143.8,456.8 144.9,456.8 C 146.1,456.7 146.4,456.7 147.2,456.7 C 148.0,456.6 148.7,456.6 149.5,456.5 C 150.3,456.5 151.0,456.5 151.8,456.4 C 152.5,456.4 153.3,456.4 154.1,456.4 C 154.8,456.4 155.2,456.4 156.4,456.3 C 157.5,456.3 159.0,456.2 160.9,456.2 C 162.8,456.2 165.9,456.2 167.8,456.2 C 169.7,456.1 170.1,456.2 172.4,456.1 C 174.7,456.1 179.6,455.9 181.5,455.9 C 183.4,455.8 182.7,455.9 183.8,455.8 C 185.0,455.8 186.5,455.8 188.4,455.8 C 190.3,455.8 191.4,455.7 195.3,455.7 C 199.1,455.6 208.2,455.5 211.3,455.5 C 214.3,455.5 212.8,455.5 213.6,455.4 C 214.3,455.4 214.7,456.1 215.8,455.4 C 217.0,454.7 219.3,454.1 220.4,451.3 C 221.6,448.4 221.9,440.7 222.7,438.3 C 223.5,435.9 224.2,437.3 225.0,436.9 C 225.8,436.5 226.5,436.3 227.3,436.0 C 228.1,435.8 228.8,435.6 229.6,435.4 C 230.3,435.3 231.1,435.3 231.9,435.2 C 232.6,435.1 233.4,434.9 234.2,434.8 C 234.9,434.8 235.7,434.8 236.4,434.8 C 237.2,434.7 238.0,434.5 238.7,434.4 C 239.5,434.3 240.3,434.3 241.0,434.2 C 241.8,434.1 242.5,433.7 243.3,433.6 C 244.1,433.5 244.8,433.5 245.6,433.5 C 246.4,433.4 246.7,433.2 247.9,433.1 C 249.0,433.0 251.3,433.0 252.5,432.9 C 253.6,432.9 254.0,432.8 254.7,432.7 C 255.5,432.7 256.3,432.7 257.0,432.6 C 257.8,432.5 258.6,432.2 259.3,432.1 C 260.1,431.9 260.8,432.6 261.6,431.7 C 262.4,430.8 263.1,427.6 263.9,426.6 C 264.7,425.5 265.4,425.7 266.2,425.5 C 266.9,425.3 267.7,425.5 268.5,425.4 C 269.2,425.3 270.0,424.9 270.8,424.6 C 271.5,424.4 272.3,424.2 273.1,423.7 C 273.8,423.2 274.6,422.2 275.3,421.6 C 276.1,421.0 276.9,420.5 277.6,420.0 C 278.4,419.6 279.2,419.3 279.9,419.0 C 280.7,418.8 281.4,418.6 282.2,418.5 C 283.0,418.4 283.7,418.4 284.5,418.3 C 285.3,418.2 286.0,418.0 286.8,417.9 C 287.5,417.8 288.3,417.8 289.1,417.8 C 289.8,417.7 290.6,417.7 291.4,417.5 C 292.1,417.4 292.9,417.5 293.6,417.0 C 294.4,416.6 295.2,415.6 295.9,415.1 C 296.7,414.6 297.5,414.4 298.2,414.0 C 299.0,413.7 299.7,414.4 300.5,412.8 C 301.3,411.3 302.0,406.5 302.8,404.6 C 303.6,402.8 304.3,402.5 305.1,401.7 C 305.8,400.9 306.6,400.4 307.4,399.9 C 308.1,399.3 308.9,398.8 309.7,398.4 C 310.4,398.0 311.2,397.6 311.9,397.3 C 312.7,397.1 313.5,397.0 314.2,396.8 C 315.0,396.6 315.8,396.3 316.5,396.0 C 317.3,395.7 318.1,395.2 318.8,395.0 C 319.6,394.7 320.3,394.8 321.1,394.5 C 321.9,394.2 322.6,393.8 323.4,393.5 C 324.2,393.1 324.9,392.9 325.7,392.6 C 326.4,392.4 327.2,392.2 328.0,392.0 C 328.7,391.9 329.5,391.8 330.3,391.7 C 331.0,391.6 331.8,391.4 332.5,391.2 C 333.3,391.0 334.1,390.9 334.8,390.7 C 335.6,390.5 336.4,390.3 337.1,390.0 C 337.9,389.6 338.6,389.0 339.4,388.8 C 340.2,388.5 340.9,388.4 341.7,388.2 C 342.5,388.1 343.2,387.9 344.0,387.8 C 344.7,387.7 345.5,387.6 346.3,387.5 C 347.0,387.4 347.8,387.2 348.6,387.2 C 349.3,387.1 350.1,387.1 350.8,387.0 C 351.6,386.9 352.4,386.7 353.1,386.4 C 353.9,386.1 354.7,385.6 355.4,385.3 C 356.2,385.0 356.9,384.6 357.7,384.4 C 358.5,384.2 359.2,384.1 360.0,384.0 C 360.8,383.9 361.5,383.8 362.3,383.6 C 363.1,383.3 363.8,382.8 364.6,382.6 C 365.3,382.4 366.1,382.4 366.9,382.3 C 367.6,382.2 368.4,382.0 369.2,381.8 C 369.9,381.6 370.7,381.4 371.4,381.3 C 372.2,381.1 373.0,380.8 373.7,380.7 C 374.5,380.5 375.3,380.4 376.0,380.2 C 376.8,379.9 377.5,379.6 378.3,379.3 C 379.1,379.1 379.8,379.0 380.6,378.7 C 381.4,378.5 382.1,378.2 382.9,377.9 C 383.6,377.6 384.4,377.4 385.2,377.1 C 385.9,376.8 386.7,376.8 387.5,376.4 C 388.2,376.0 389.0,375.1 389.7,374.7 C 390.5,374.3 391.3,374.3 392.0,374.1 C 392.8,374.0 393.6,374.0 394.3,373.9 C 395.1,373.8 395.8,373.7 396.6,373.5 C 397.4,373.4 398.1,373.6 398.9,373.3 C 399.7,372.9 400.4,371.8 401.2,371.4 C 401.9,371.0 402.7,371.1 403.5,370.8 C 404.2,370.6 405.0,370.1 405.8,369.9 C 406.5,369.7 407.3,369.8 408.1,369.6 C 408.8,369.4 409.6,368.9 410.3,368.7 C 411.1,368.5 411.9,368.5 412.6,368.4 C 413.4,368.3 414.2,368.3 414.9,368.1 C 415.7,367.9 416.4,367.6 417.2,367.1 C 418.0,366.6 418.7,365.5 419.5,365.0 C 420.3,364.5 421.0,364.2 421.8,363.9 C 422.5,363.5 423.3,363.4 424.1,362.7 C 424.8,361.9 425.6,360.2 426.4,359.4 C 427.1,358.6 427.9,358.3 428.6,357.8 C 429.4,357.4 430.2,357.3 430.9,356.6 C 431.7,355.9 432.5,354.2 433.2,353.5 C 434.0,352.8 434.7,352.7 435.5,352.3 C 436.3,351.9 437.0,351.6 437.8,351.1 C 438.6,35Line truncated
|
||||
<path d="M 90.0,462.2 C 90.4,462.1 91.9,461.8 92.3,461.7 C 93.1,461.6 93.8,461.4 94.6,461.2 C 95.3,461.1 96.1,460.8 96.9,460.8 C 97.6,460.7 98.4,460.7 99.2,460.7 C 99.9,460.7 100.7,460.6 101.4,460.6 C 102.2,460.5 103.0,460.5 103.7,460.4 C 104.5,460.4 104.9,460.3 106.0,460.3 C 107.2,460.2 109.4,460.1 110.6,460.1 C 111.7,460.1 112.1,460.0 112.9,460.0 C 113.6,460.0 114.4,460.0 115.2,459.9 C 115.9,459.9 116.7,459.9 117.5,459.9 C 118.2,459.9 119.0,459.9 119.7,459.8 C 120.5,459.8 121.3,459.7 122.0,459.7 C 122.8,459.6 123.6,459.6 124.3,459.4 C 125.1,459.3 125.8,459.0 126.6,458.7 C 127.4,458.3 128.1,457.6 128.9,457.3 C 129.7,457.1 130.4,457.2 131.2,457.1 C 131.9,457.1 132.7,457.1 133.5,457.0 C 134.2,457.0 134.6,457.0 135.8,456.9 C 136.9,456.9 138.8,456.9 140.3,456.9 C 141.9,456.8 143.8,456.8 144.9,456.8 C 146.1,456.7 146.4,456.7 147.2,456.7 C 148.0,456.6 148.7,456.6 149.5,456.5 C 150.3,456.5 151.0,456.5 151.8,456.4 C 152.5,456.4 153.3,456.4 154.1,456.4 C 154.8,456.4 155.2,456.4 156.4,456.3 C 157.5,456.3 159.0,456.2 160.9,456.2 C 162.8,456.2 165.9,456.2 167.8,456.2 C 169.7,456.1 170.1,456.2 172.4,456.1 C 174.7,456.1 179.6,455.9 181.5,455.9 C 183.4,455.8 182.7,455.9 183.8,455.8 C 185.0,455.8 186.5,455.8 188.4,455.8 C 190.3,455.8 191.4,455.7 195.3,455.7 C 199.1,455.6 208.2,455.5 211.3,455.5 C 214.3,455.5 212.8,455.5 213.6,455.4 C 214.3,455.4 214.7,456.1 215.8,455.4 C 217.0,454.7 219.3,454.1 220.4,451.3 C 221.6,448.4 221.9,440.7 222.7,438.3 C 223.5,435.9 224.2,437.3 225.0,436.9 C 225.8,436.5 226.5,436.3 227.3,436.0 C 228.1,435.8 228.8,435.6 229.6,435.4 C 230.3,435.3 231.1,435.3 231.9,435.2 C 232.6,435.1 233.4,434.9 234.2,434.8 C 234.9,434.8 235.7,434.8 236.4,434.8 C 237.2,434.7 238.0,434.5 238.7,434.4 C 239.5,434.3 240.3,434.3 241.0,434.2 C 241.8,434.1 242.5,433.7 243.3,433.6 C 244.1,433.5 244.8,433.5 245.6,433.5 C 246.4,433.4 246.7,433.2 247.9,433.1 C 249.0,433.0 251.3,433.0 252.5,432.9 C 253.6,432.9 254.0,432.8 254.7,432.7 C 255.5,432.7 256.3,432.7 257.0,432.6 C 257.8,432.5 258.6,432.2 259.3,432.1 C 260.1,431.9 260.8,432.6 261.6,431.7 C 262.4,430.8 263.1,427.6 263.9,426.6 C 264.7,425.5 265.4,425.7 266.2,425.5 C 266.9,425.3 267.7,425.5 268.5,425.4 C 269.2,425.3 270.0,424.9 270.8,424.6 C 271.5,424.4 272.3,424.2 273.1,423.7 C 273.8,423.2 274.6,422.2 275.3,421.6 C 276.1,421.0 276.9,420.5 277.6,420.0 C 278.4,419.6 279.2,419.3 279.9,419.0 C 280.7,418.8 281.4,418.6 282.2,418.5 C 283.0,418.4 283.7,418.4 284.5,418.3 C 285.3,418.2 286.0,418.0 286.8,417.9 C 287.5,417.8 288.3,417.8 289.1,417.8 C 289.8,417.7 290.6,417.7 291.4,417.5 C 292.1,417.4 292.9,417.5 293.6,417.0 C 294.4,416.6 295.2,415.6 295.9,415.1 C 296.7,414.6 297.5,414.4 298.2,414.0 C 299.0,413.7 299.7,414.4 300.5,412.8 C 301.3,411.3 302.0,406.5 302.8,404.6 C 303.6,402.8 304.3,402.5 305.1,401.7 C 305.8,400.9 306.6,400.4 307.4,399.9 C 308.1,399.3 308.9,398.8 309.7,398.4 C 310.4,398.0 311.2,397.6 311.9,397.3 C 312.7,397.1 313.5,397.0 314.2,396.8 C 315.0,396.6 315.8,396.3 316.5,396.0 C 317.3,395.7 318.1,395.2 318.8,395.0 C 319.6,394.7 320.3,394.8 321.1,394.5 C 321.9,394.2 322.6,393.8 323.4,393.5 C 324.2,393.1 324.9,392.9 325.7,392.6 C 326.4,392.4 327.2,392.2 328.0,392.0 C 328.7,391.9 329.5,391.8 330.3,391.7 C 331.0,391.6 331.8,391.4 332.5,391.2 C 333.3,391.0 334.1,390.9 334.8,390.7 C 335.6,390.5 336.4,390.3 337.1,390.0 C 337.9,389.6 338.6,389.0 339.4,388.8 C 340.2,388.5 340.9,388.4 341.7,388.2 C 342.5,388.1 343.2,387.9 344.0,387.8 C 344.7,387.7 345.5,387.6 346.3,387.5 C 347.0,387.4 347.8,387.2 348.6,387.2 C 349.3,387.1 350.1,387.1 350.8,387.0 C 351.6,386.9 352.4,386.7 353.1,386.4 C 353.9,386.1 354.7,385.6 355.4,385.3 C 356.2,385.0 356.9,384.6 357.7,384.4 C 358.5,384.2 359.2,384.1 360.0,384.0 C 360.8,383.9 361.5,383.8 362.3,383.6 C 363.1,383.3 363.8,382.8 364.6,382.6 C 365.3,382.4 366.1,382.4 366.9,382.3 C 367.6,382.2 368.4,382.0 369.2,381.8 C 369.9,381.6 370.7,381.4 371.4,381.3 C 372.2,381.1 373.0,380.8 373.7,380.7 C 374.5,380.5 375.3,380.4 376.0,380.2 C 376.8,379.9 377.5,379.6 378.3,379.3 C 379.1,379.1 379.8,379.0 380.6,378.7 C 381.4,378.5 382.1,378.2 382.9,377.9 C 383.6,377.6 384.4,377.4 385.2,377.1 C 385.9,376.8 386.7,376.8 387.5,376.4 C 388.2,376.0 389.0,375.1 389.7,374.7 C 390.5,374.3 391.3,374.3 392.0,374.1 C 392.8,374.0 393.6,374.0 394.3,373.9 C 395.1,373.8 395.8,373.7 396.6,373.5 C 397.4,373.4 398.1,373.6 398.9,373.3 C 399.7,372.9 400.4,371.8 401.2,371.4 C 401.9,371.0 402.7,371.1 403.5,370.8 C 404.2,370.6 405.0,370.1 405.8,369.9 C 406.5,369.7 407.3,369.8 408.1,369.6 C 408.8,369.4 409.6,368.9 410.3,368.7 C 411.1,368.5 411.9,368.5 412.6,368.4 C 413.4,368.3 414.2,368.3 414.9,368.1 C 415.7,367.9 416.4,367.6 417.2,367.1 C 418.0,366.6 418.7,365.5 419.5,365.0 C 420.3,364.5 421.0,364.2 421.8,363.9 C 422.5,363.5 423.3,363.4 424.1,362.7 C 424.8,361.9 425.6,360.2 426.4,359.4 C 427.1,358.6 427.9,358.3 428.6,357.8 C 429.4,357.4 430.2,357.3 430.9,356.6 C 431.7,355.9 432.5,354.2 433.2,353.5 C 434.0,352.8 434.7,352.7 435.5,352.3 C 436.3,351.9 437.0,351.6 437.8,351.1 C 438.6,35Line truncated
|
||||
<circle cx="765.0" cy="90.8" r="5" fill="#dd4528" stroke="#0d1117" stroke-width="2" filter="url(#xkcdify)"/>
|
||||
<text x="753.0" y="77.0" fill="#dd4528" font-size="18" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">6802</text>
|
||||
<a href="https://github.com/carsteneu/mystarhistory" target="_blank" rel="noopener"><text x="763" y="527" font-size="13" font-family="Handlee, cursive" fill="#fff" text-anchor="end">Made with carsteneu/mystarhistory</text></a>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 36 KiB |
@@ -31,20 +31,17 @@
|
||||
<text x="85" y="85.0" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">7K</text>
|
||||
<text x="43" y="271.5" fill="#000" font-size="17" font-family="Handlee, cursive" font-weight="bold" transform="rotate(-90, 43, 271.5)">GitHub Stars</text>
|
||||
<text x="90.0" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jan 2026</text>
|
||||
<text x="223.6" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Mar 2026</text>
|
||||
<text x="296.2" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Apr 2026</text>
|
||||
<text x="366.6" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">May 2026</text>
|
||||
<text x="439.2" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jun 2026</text>
|
||||
<text x="509.5" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jul 2026</text>
|
||||
<text x="582.2" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Aug 2026</text>
|
||||
<text x="654.8" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Sep 2026</text>
|
||||
<text x="725.2" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Oct 2026</text>
|
||||
<text x="220.4" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Mar 2026</text>
|
||||
<text x="291.4" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Apr 2026</text>
|
||||
<text x="430.9" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jun 2026</text>
|
||||
<text x="570.5" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Aug 2026</text>
|
||||
<text x="710.1" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Oct 2026</text>
|
||||
<text x="50%" y="525" fill="#000" font-size="17" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Date</text>
|
||||
<line x1="90" y1="80" x2="90" y2="463" stroke="#222" stroke-width="2.5" filter="url(#xkcdify)"/>
|
||||
<line x1="90" y1="463" x2="765" y2="463" stroke="#222" stroke-width="2.5" filter="url(#xkcdify)"/>
|
||||
<path d="M 90.0,462.2 C 90.4,462.1 92.0,461.8 92.3,461.7 C 93.1,461.6 93.9,461.4 94.7,461.2 C 95.5,461.1 96.2,460.8 97.0,460.8 C 97.8,460.7 98.6,460.7 99.4,460.7 C 100.2,460.7 100.9,460.6 101.7,460.6 C 102.5,460.5 103.3,460.5 104.1,460.4 C 104.8,460.4 105.2,460.3 106.4,460.3 C 107.6,460.2 109.9,460.1 111.1,460.1 C 112.3,460.1 112.7,460.0 113.4,460.0 C 114.2,460.0 115.0,460.0 115.8,459.9 C 116.6,459.9 117.3,459.9 118.1,459.9 C 118.9,459.9 119.7,459.9 120.5,459.8 C 121.2,459.8 122.0,459.7 122.8,459.7 C 123.6,459.6 124.4,459.6 125.2,459.4 C 125.9,459.3 126.7,459.0 127.5,458.7 C 128.3,458.3 129.1,457.6 129.8,457.3 C 130.6,457.1 131.4,457.2 132.2,457.1 C 133.0,457.1 133.8,457.1 134.5,457.0 C 135.3,457.0 135.7,457.0 136.9,456.9 C 138.0,456.9 140.0,456.9 141.6,456.9 C 143.1,456.8 145.1,456.8 146.2,456.8 C 147.4,456.7 147.8,456.7 148.6,456.7 C 149.4,456.6 150.2,456.6 150.9,456.5 C 151.7,456.5 152.5,456.5 153.3,456.4 C 154.1,456.4 154.8,456.4 155.6,456.4 C 156.4,456.4 156.8,456.4 158.0,456.3 C 159.1,456.3 160.7,456.2 162.7,456.2 C 164.6,456.2 167.7,456.2 169.7,456.2 C 171.6,456.1 172.0,456.2 174.4,456.1 C 176.7,456.1 181.8,455.9 183.8,455.9 C 185.7,455.8 184.9,455.9 186.1,455.8 C 187.3,455.8 188.8,455.8 190.8,455.8 C 192.7,455.8 193.9,455.7 197.8,455.7 C 201.7,455.6 211.1,455.5 214.2,455.5 C 217.3,455.5 215.8,455.5 216.6,455.4 C 217.3,455.4 217.7,456.1 218.9,455.4 C 220.1,454.7 222.4,454.1 223.6,451.3 C 224.8,448.4 225.2,440.7 225.9,438.3 C 226.7,435.9 227.5,437.3 228.3,436.9 C 229.1,436.5 229.8,436.3 230.6,436.0 C 231.4,435.8 232.2,435.6 233.0,435.4 C 233.8,435.3 234.5,435.3 235.3,435.2 C 236.1,435.1 236.9,434.9 237.7,434.8 C 238.4,434.8 239.2,434.8 240.0,434.8 C 240.8,434.7 241.6,434.5 242.3,434.4 C 243.1,434.3 243.9,434.3 244.7,434.2 C 245.5,434.1 246.2,433.7 247.0,433.6 C 247.8,433.5 248.6,433.5 249.4,433.5 C 250.2,433.4 250.5,433.2 251.7,433.1 C 252.9,433.0 255.2,433.0 256.4,432.9 C 257.6,432.9 258.0,432.8 258.8,432.7 C 259.5,432.7 260.3,432.7 261.1,432.6 C 261.9,432.5 262.7,432.2 263.4,432.1 C 264.2,431.9 265.0,432.6 265.8,431.7 C 266.6,430.8 267.3,427.6 268.1,426.6 C 268.9,425.5 269.7,425.7 270.5,425.5 C 271.2,425.3 272.0,425.5 272.8,425.4 C 273.6,425.3 274.4,424.9 275.2,424.6 C 275.9,424.4 276.7,424.2 277.5,423.7 C 278.3,423.2 279.1,422.2 279.8,421.6 C 280.6,421.0 281.4,420.4 282.2,420.0 C 283.0,419.5 283.8,419.2 284.5,419.0 C 285.3,418.7 286.1,418.6 286.9,418.5 C 287.7,418.3 288.4,418.4 289.2,418.2 C 290.0,418.1 290.8,417.9 291.6,417.8 C 292.3,417.7 293.1,417.8 293.9,417.7 C 294.7,417.6 295.5,417.6 296.2,417.5 C 297.0,417.4 297.8,417.4 298.6,417.0 C 299.4,416.6 300.2,415.5 300.9,415.0 C 301.7,414.5 302.5,414.3 303.3,414.0 C 304.1,413.6 304.8,414.3 305.6,412.8 C 306.4,411.2 307.2,406.4 308.0,404.6 C 308.8,402.7 309.5,402.4 310.3,401.7 C 311.1,400.9 311.9,400.4 312.7,399.9 C 313.4,399.3 314.2,398.8 315.0,398.4 C 315.8,398.0 316.6,397.6 317.3,397.3 C 318.1,397.1 318.9,397.0 319.7,396.8 C 320.5,396.6 321.2,396.3 322.0,396.0 C 322.8,395.7 323.6,395.2 324.4,395.0 C 325.2,394.7 325.9,394.8 326.7,394.5 C 327.5,394.2 328.3,393.8 329.1,393.5 C 329.8,393.1 330.6,392.9 331.4,392.6 C 332.2,392.4 333.0,392.2 333.8,392.0 C 334.5,391.9 335.3,391.8 336.1,391.7 C 336.9,391.6 337.7,391.4 338.4,391.2 C 339.2,391.0 340.0,390.9 340.8,390.7 C 341.6,390.5 342.3,390.3 343.1,390.0 C 343.9,389.6 344.7,389.0 345.5,388.8 C 346.2,388.5 347.0,388.4 347.8,388.2 C 348.6,388.1 349.4,387.9 350.2,387.8 C 350.9,387.7 351.7,387.6 352.5,387.5 C 353.3,387.4 354.1,387.2 354.8,387.2 C 355.6,387.1 356.4,387.1 357.2,387.0 C 358.0,386.9 358.8,386.7 359.5,386.4 C 360.3,386.1 361.1,385.6 361.9,385.3 C 362.7,385.0 363.4,384.6 364.2,384.4 C 365.0,384.2 365.8,384.1 366.6,384.0 C 367.3,383.9 368.1,383.8 368.9,383.6 C 369.7,383.3 370.5,382.8 371.2,382.6 C 372.0,382.4 372.8,382.4 373.6,382.3 C 374.4,382.2 375.2,382.0 375.9,381.8 C 376.7,381.6 377.5,381.4 378.3,381.3 C 379.1,381.1 379.8,380.8 380.6,380.7 C 381.4,380.5 382.2,380.4 383.0,380.2 C 383.8,379.9 384.5,379.6 385.3,379.3 C 386.1,379.1 386.9,379.0 387.7,378.7 C 388.4,378.5 389.2,378.2 390.0,377.9 C 390.8,377.6 391.6,377.4 392.3,377.1 C 393.1,376.8 393.9,376.8 394.7,376.4 C 395.5,376.0 396.2,375.1 397.0,374.7 C 397.8,374.3 398.6,374.3 399.4,374.1 C 400.2,374.0 400.9,374.0 401.7,373.9 C 402.5,373.8 403.3,373.7 404.1,373.5 C 404.8,373.4 405.6,373.6 406.4,373.3 C 407.2,372.9 408.0,371.8 408.8,371.4 C 409.5,371.0 410.3,371.1 411.1,370.8 C 411.9,370.6 412.7,370.1 413.4,369.9 C 414.2,369.7 415.0,369.8 415.8,369.6 C 416.6,369.4 417.3,368.9 418.1,368.7 C 418.9,368.5 419.7,368.5 420.5,368.4 C 421.2,368.3 422.0,368.3 422.8,368.1 C 423.6,367.9 424.4,367.6 425.2,367.1 C 425.9,366.6 426.7,365.5 427.5,365.0 C 428.3,364.5 429.1,364.2 429.8,363.9 C 430.6,363.5 431.4,363.4 432.2,362.7 C 433.0,361.9 433.8,360.2 434.5,359.4 C 435.3,358.6 436.1,358.3 436.9,357.8 C 437.7,357.3 438.4,357.2 439.2,356.5 C 440.0,355.8 440.8,354.2 441.6,353.5 C 442.3,352.8 443.1,352.7 443.9,352.3 C 444.7,351.9 445.5,351.5 446.2,351.1 C 447.0,3Line truncated
|
||||
<path d="M 90.0,462.2 C 90.4,462.1 92.0,461.8 92.3,461.7 C 93.1,461.6 93.9,461.4 94.7,461.2 C 95.5,461.1 96.2,460.8 97.0,460.8 C 97.8,460.7 98.6,460.7 99.4,460.7 C 100.2,460.7 100.9,460.6 101.7,460.6 C 102.5,460.5 103.3,460.5 104.1,460.4 C 104.8,460.4 105.2,460.3 106.4,460.3 C 107.6,460.2 109.9,460.1 111.1,460.1 C 112.3,460.1 112.7,460.0 113.4,460.0 C 114.2,460.0 115.0,460.0 115.8,459.9 C 116.6,459.9 117.3,459.9 118.1,459.9 C 118.9,459.9 119.7,459.9 120.5,459.8 C 121.2,459.8 122.0,459.7 122.8,459.7 C 123.6,459.6 124.4,459.6 125.2,459.4 C 125.9,459.3 126.7,459.0 127.5,458.7 C 128.3,458.3 129.1,457.6 129.8,457.3 C 130.6,457.1 131.4,457.2 132.2,457.1 C 133.0,457.1 133.8,457.1 134.5,457.0 C 135.3,457.0 135.7,457.0 136.9,456.9 C 138.0,456.9 140.0,456.9 141.6,456.9 C 143.1,456.8 145.1,456.8 146.2,456.8 C 147.4,456.7 147.8,456.7 148.6,456.7 C 149.4,456.6 150.2,456.6 150.9,456.5 C 151.7,456.5 152.5,456.5 153.3,456.4 C 154.1,456.4 154.8,456.4 155.6,456.4 C 156.4,456.4 156.8,456.4 158.0,456.3 C 159.1,456.3 160.7,456.2 162.7,456.2 C 164.6,456.2 167.7,456.2 169.7,456.2 C 171.6,456.1 172.0,456.2 174.4,456.1 C 176.7,456.1 181.8,455.9 183.8,455.9 C 185.7,455.8 184.9,455.9 186.1,455.8 C 187.3,455.8 188.8,455.8 190.8,455.8 C 192.7,455.8 193.9,455.7 197.8,455.7 C 201.7,455.6 211.1,455.5 214.2,455.5 C 217.3,455.5 215.8,455.5 216.6,455.4 C 217.3,455.4 217.7,456.1 218.9,455.4 C 220.1,454.7 222.4,454.1 223.6,451.3 C 224.8,448.4 225.2,440.7 225.9,438.3 C 226.7,435.9 227.5,437.3 228.3,436.9 C 229.1,436.5 229.8,436.3 230.6,436.0 C 231.4,435.8 232.2,435.6 233.0,435.4 C 233.8,435.3 234.5,435.3 235.3,435.2 C 236.1,435.1 236.9,434.9 237.7,434.8 C 238.4,434.8 239.2,434.8 240.0,434.8 C 240.8,434.7 241.6,434.5 242.3,434.4 C 243.1,434.3 243.9,434.3 244.7,434.2 C 245.5,434.1 246.2,433.7 247.0,433.6 C 247.8,433.5 248.6,433.5 249.4,433.5 C 250.2,433.4 250.5,433.2 251.7,433.1 C 252.9,433.0 255.2,433.0 256.4,432.9 C 257.6,432.9 258.0,432.8 258.8,432.7 C 259.5,432.7 260.3,432.7 261.1,432.6 C 261.9,432.5 262.7,432.2 263.4,432.1 C 264.2,431.9 265.0,432.6 265.8,431.7 C 266.6,430.8 267.3,427.6 268.1,426.6 C 268.9,425.5 269.7,425.7 270.5,425.5 C 271.2,425.3 272.0,425.5 272.8,425.4 C 273.6,425.3 274.4,424.9 275.2,424.6 C 275.9,424.4 276.7,424.2 277.5,423.7 C 278.3,423.2 279.1,422.2 279.8,421.6 C 280.6,421.0 281.4,420.4 282.2,420.0 C 283.0,419.5 283.8,419.2 284.5,419.0 C 285.3,418.7 286.1,418.6 286.9,418.5 C 287.7,418.3 288.4,418.4 289.2,418.2 C 290.0,418.1 290.8,417.9 291.6,417.8 C 292.3,417.7 293.1,417.8 293.9,417.7 C 294.7,417.6 295.5,417.6 296.2,417.5 C 297.0,417.4 297.8,417.4 298.6,417.0 C 299.4,416.6 300.2,415.5 300.9,415.0 C 301.7,414.5 302.5,414.3 303.3,414.0 C 304.1,413.6 304.8,414.3 305.6,412.8 C 306.4,411.2 307.2,406.4 308.0,404.6 C 308.8,402.7 309.5,402.4 310.3,401.7 C 311.1,400.9 311.9,400.4 312.7,399.9 C 313.4,399.3 314.2,398.8 315.0,398.4 C 315.8,398.0 316.6,397.6 317.3,397.3 C 318.1,397.1 318.9,397.0 319.7,396.8 C 320.5,396.6 321.2,396.3 322.0,396.0 C 322.8,395.7 323.6,395.2 324.4,395.0 C 325.2,394.7 325.9,394.8 326.7,394.5 C 327.5,394.2 328.3,393.8 329.1,393.5 C 329.8,393.1 330.6,392.9 331.4,392.6 C 332.2,392.4 333.0,392.2 333.8,392.0 C 334.5,391.9 335.3,391.8 336.1,391.7 C 336.9,391.6 337.7,391.4 338.4,391.2 C 339.2,391.0 340.0,390.9 340.8,390.7 C 341.6,390.5 342.3,390.3 343.1,390.0 C 343.9,389.6 344.7,389.0 345.5,388.8 C 346.2,388.5 347.0,388.4 347.8,388.2 C 348.6,388.1 349.4,387.9 350.2,387.8 C 350.9,387.7 351.7,387.6 352.5,387.5 C 353.3,387.4 354.1,387.2 354.8,387.2 C 355.6,387.1 356.4,387.1 357.2,387.0 C 358.0,386.9 358.8,386.7 359.5,386.4 C 360.3,386.1 361.1,385.6 361.9,385.3 C 362.7,385.0 363.4,384.6 364.2,384.4 C 365.0,384.2 365.8,384.1 366.6,384.0 C 367.3,383.9 368.1,383.8 368.9,383.6 C 369.7,383.3 370.5,382.8 371.2,382.6 C 372.0,382.4 372.8,382.4 373.6,382.3 C 374.4,382.2 375.2,382.0 375.9,381.8 C 376.7,381.6 377.5,381.4 378.3,381.3 C 379.1,381.1 379.8,380.8 380.6,380.7 C 381.4,380.5 382.2,380.4 383.0,380.2 C 383.8,379.9 384.5,379.6 385.3,379.3 C 386.1,379.1 386.9,379.0 387.7,378.7 C 388.4,378.5 389.2,378.2 390.0,377.9 C 390.8,377.6 391.6,377.4 392.3,377.1 C 393.1,376.8 393.9,376.8 394.7,376.4 C 395.5,376.0 396.2,375.1 397.0,374.7 C 397.8,374.3 398.6,374.3 399.4,374.1 C 400.2,374.0 400.9,374.0 401.7,373.9 C 402.5,373.8 403.3,373.7 404.1,373.5 C 404.8,373.4 405.6,373.6 406.4,373.3 C 407.2,372.9 408.0,371.8 408.8,371.4 C 409.5,371.0 410.3,371.1 411.1,370.8 C 411.9,370.6 412.7,370.1 413.4,369.9 C 414.2,369.7 415.0,369.8 415.8,369.6 C 416.6,369.4 417.3,368.9 418.1,368.7 C 418.9,368.5 419.7,368.5 420.5,368.4 C 421.2,368.3 422.0,368.3 422.8,368.1 C 423.6,367.9 424.4,367.6 425.2,367.1 C 425.9,366.6 426.7,365.5 427.5,365.0 C 428.3,364.5 429.1,364.2 429.8,363.9 C 430.6,363.5 431.4,363.4 432.2,362.7 C 433.0,361.9 433.8,360.2 434.5,359.4 C 435.3,358.6 436.1,358.3 436.9,357.8 C 437.7,357.3 438.4,357.2 439.2,356.5 C 440.0,355.8 440.8,354.2 441.6,353.5 C 442.3,352.8 443.1,352.7 443.9,352.3 C 444.7,351.9 445.5,351.5 446.2,351.1 C 447.0,3Line truncated
|
||||
<circle cx="765.0" cy="99.0" r="5" fill="#dd4528" stroke="#fff" stroke-width="2" filter="url(#xkcdify)"/>
|
||||
<text x="753.0" y="77.0" fill="#dd4528" font-size="18" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">6652</text>
|
||||
<path d="M 90.0,462.2 C 90.4,462.1 91.9,461.8 92.3,461.7 C 93.1,461.6 93.8,461.4 94.6,461.2 C 95.3,461.1 96.1,460.8 96.9,460.8 C 97.6,460.7 98.4,460.7 99.2,460.7 C 99.9,460.7 100.7,460.6 101.4,460.6 C 102.2,460.5 103.0,460.5 103.7,460.4 C 104.5,460.4 104.9,460.3 106.0,460.3 C 107.2,460.2 109.4,460.1 110.6,460.1 C 111.7,460.1 112.1,460.0 112.9,460.0 C 113.6,460.0 114.4,460.0 115.2,459.9 C 115.9,459.9 116.7,459.9 117.5,459.9 C 118.2,459.9 119.0,459.9 119.7,459.8 C 120.5,459.8 121.3,459.7 122.0,459.7 C 122.8,459.6 123.6,459.6 124.3,459.4 C 125.1,459.3 125.8,459.0 126.6,458.7 C 127.4,458.3 128.1,457.6 128.9,457.3 C 129.7,457.1 130.4,457.2 131.2,457.1 C 131.9,457.1 132.7,457.1 133.5,457.0 C 134.2,457.0 134.6,457.0 135.8,456.9 C 136.9,456.9 138.8,456.9 140.3,456.9 C 141.9,456.8 143.8,456.8 144.9,456.8 C 146.1,456.7 146.4,456.7 147.2,456.7 C 148.0,456.6 148.7,456.6 149.5,456.5 C 150.3,456.5 151.0,456.5 151.8,456.4 C 152.5,456.4 153.3,456.4 154.1,456.4 C 154.8,456.4 155.2,456.4 156.4,456.3 C 157.5,456.3 159.0,456.2 160.9,456.2 C 162.8,456.2 165.9,456.2 167.8,456.2 C 169.7,456.1 170.1,456.2 172.4,456.1 C 174.7,456.1 179.6,455.9 181.5,455.9 C 183.4,455.8 182.7,455.9 183.8,455.8 C 185.0,455.8 186.5,455.8 188.4,455.8 C 190.3,455.8 191.4,455.7 195.3,455.7 C 199.1,455.6 208.2,455.5 211.3,455.5 C 214.3,455.5 212.8,455.5 213.6,455.4 C 214.3,455.4 214.7,456.1 215.8,455.4 C 217.0,454.7 219.3,454.1 220.4,451.3 C 221.6,448.4 221.9,440.7 222.7,438.3 C 223.5,435.9 224.2,437.3 225.0,436.9 C 225.8,436.5 226.5,436.3 227.3,436.0 C 228.1,435.8 228.8,435.6 229.6,435.4 C 230.3,435.3 231.1,435.3 231.9,435.2 C 232.6,435.1 233.4,434.9 234.2,434.8 C 234.9,434.8 235.7,434.8 236.4,434.8 C 237.2,434.7 238.0,434.5 238.7,434.4 C 239.5,434.3 240.3,434.3 241.0,434.2 C 241.8,434.1 242.5,433.7 243.3,433.6 C 244.1,433.5 244.8,433.5 245.6,433.5 C 246.4,433.4 246.7,433.2 247.9,433.1 C 249.0,433.0 251.3,433.0 252.5,432.9 C 253.6,432.9 254.0,432.8 254.7,432.7 C 255.5,432.7 256.3,432.7 257.0,432.6 C 257.8,432.5 258.6,432.2 259.3,432.1 C 260.1,431.9 260.8,432.6 261.6,431.7 C 262.4,430.8 263.1,427.6 263.9,426.6 C 264.7,425.5 265.4,425.7 266.2,425.5 C 266.9,425.3 267.7,425.5 268.5,425.4 C 269.2,425.3 270.0,424.9 270.8,424.6 C 271.5,424.4 272.3,424.2 273.1,423.7 C 273.8,423.2 274.6,422.2 275.3,421.6 C 276.1,421.0 276.9,420.5 277.6,420.0 C 278.4,419.6 279.2,419.3 279.9,419.0 C 280.7,418.8 281.4,418.6 282.2,418.5 C 283.0,418.4 283.7,418.4 284.5,418.3 C 285.3,418.2 286.0,418.0 286.8,417.9 C 287.5,417.8 288.3,417.8 289.1,417.8 C 289.8,417.7 290.6,417.7 291.4,417.5 C 292.1,417.4 292.9,417.5 293.6,417.0 C 294.4,416.6 295.2,415.6 295.9,415.1 C 296.7,414.6 297.5,414.4 298.2,414.0 C 299.0,413.7 299.7,414.4 300.5,412.8 C 301.3,411.3 302.0,406.5 302.8,404.6 C 303.6,402.8 304.3,402.5 305.1,401.7 C 305.8,400.9 306.6,400.4 307.4,399.9 C 308.1,399.3 308.9,398.8 309.7,398.4 C 310.4,398.0 311.2,397.6 311.9,397.3 C 312.7,397.1 313.5,397.0 314.2,396.8 C 315.0,396.6 315.8,396.3 316.5,396.0 C 317.3,395.7 318.1,395.2 318.8,395.0 C 319.6,394.7 320.3,394.8 321.1,394.5 C 321.9,394.2 322.6,393.8 323.4,393.5 C 324.2,393.1 324.9,392.9 325.7,392.6 C 326.4,392.4 327.2,392.2 328.0,392.0 C 328.7,391.9 329.5,391.8 330.3,391.7 C 331.0,391.6 331.8,391.4 332.5,391.2 C 333.3,391.0 334.1,390.9 334.8,390.7 C 335.6,390.5 336.4,390.3 337.1,390.0 C 337.9,389.6 338.6,389.0 339.4,388.8 C 340.2,388.5 340.9,388.4 341.7,388.2 C 342.5,388.1 343.2,387.9 344.0,387.8 C 344.7,387.7 345.5,387.6 346.3,387.5 C 347.0,387.4 347.8,387.2 348.6,387.2 C 349.3,387.1 350.1,387.1 350.8,387.0 C 351.6,386.9 352.4,386.7 353.1,386.4 C 353.9,386.1 354.7,385.6 355.4,385.3 C 356.2,385.0 356.9,384.6 357.7,384.4 C 358.5,384.2 359.2,384.1 360.0,384.0 C 360.8,383.9 361.5,383.8 362.3,383.6 C 363.1,383.3 363.8,382.8 364.6,382.6 C 365.3,382.4 366.1,382.4 366.9,382.3 C 367.6,382.2 368.4,382.0 369.2,381.8 C 369.9,381.6 370.7,381.4 371.4,381.3 C 372.2,381.1 373.0,380.8 373.7,380.7 C 374.5,380.5 375.3,380.4 376.0,380.2 C 376.8,379.9 377.5,379.6 378.3,379.3 C 379.1,379.1 379.8,379.0 380.6,378.7 C 381.4,378.5 382.1,378.2 382.9,377.9 C 383.6,377.6 384.4,377.4 385.2,377.1 C 385.9,376.8 386.7,376.8 387.5,376.4 C 388.2,376.0 389.0,375.1 389.7,374.7 C 390.5,374.3 391.3,374.3 392.0,374.1 C 392.8,374.0 393.6,374.0 394.3,373.9 C 395.1,373.8 395.8,373.7 396.6,373.5 C 397.4,373.4 398.1,373.6 398.9,373.3 C 399.7,372.9 400.4,371.8 401.2,371.4 C 401.9,371.0 402.7,371.1 403.5,370.8 C 404.2,370.6 405.0,370.1 405.8,369.9 C 406.5,369.7 407.3,369.8 408.1,369.6 C 408.8,369.4 409.6,368.9 410.3,368.7 C 411.1,368.5 411.9,368.5 412.6,368.4 C 413.4,368.3 414.2,368.3 414.9,368.1 C 415.7,367.9 416.4,367.6 417.2,367.1 C 418.0,366.6 418.7,365.5 419.5,365.0 C 420.3,364.5 421.0,364.2 421.8,363.9 C 422.5,363.5 423.3,363.4 424.1,362.7 C 424.8,361.9 425.6,360.2 426.4,359.4 C 427.1,358.6 427.9,358.3 428.6,357.8 C 429.4,357.4 430.2,357.3 430.9,356.6 C 431.7,355.9 432.5,354.2 433.2,353.5 C 434.0,352.8 434.7,352.7 435.5,352.3 C 436.3,351.9 437.0,351.6 437.8,351.1 C 438.6,35Line truncated
|
||||
<path d="M 90.0,462.2 C 90.4,462.1 91.9,461.8 92.3,461.7 C 93.1,461.6 93.8,461.4 94.6,461.2 C 95.3,461.1 96.1,460.8 96.9,460.8 C 97.6,460.7 98.4,460.7 99.2,460.7 C 99.9,460.7 100.7,460.6 101.4,460.6 C 102.2,460.5 103.0,460.5 103.7,460.4 C 104.5,460.4 104.9,460.3 106.0,460.3 C 107.2,460.2 109.4,460.1 110.6,460.1 C 111.7,460.1 112.1,460.0 112.9,460.0 C 113.6,460.0 114.4,460.0 115.2,459.9 C 115.9,459.9 116.7,459.9 117.5,459.9 C 118.2,459.9 119.0,459.9 119.7,459.8 C 120.5,459.8 121.3,459.7 122.0,459.7 C 122.8,459.6 123.6,459.6 124.3,459.4 C 125.1,459.3 125.8,459.0 126.6,458.7 C 127.4,458.3 128.1,457.6 128.9,457.3 C 129.7,457.1 130.4,457.2 131.2,457.1 C 131.9,457.1 132.7,457.1 133.5,457.0 C 134.2,457.0 134.6,457.0 135.8,456.9 C 136.9,456.9 138.8,456.9 140.3,456.9 C 141.9,456.8 143.8,456.8 144.9,456.8 C 146.1,456.7 146.4,456.7 147.2,456.7 C 148.0,456.6 148.7,456.6 149.5,456.5 C 150.3,456.5 151.0,456.5 151.8,456.4 C 152.5,456.4 153.3,456.4 154.1,456.4 C 154.8,456.4 155.2,456.4 156.4,456.3 C 157.5,456.3 159.0,456.2 160.9,456.2 C 162.8,456.2 165.9,456.2 167.8,456.2 C 169.7,456.1 170.1,456.2 172.4,456.1 C 174.7,456.1 179.6,455.9 181.5,455.9 C 183.4,455.8 182.7,455.9 183.8,455.8 C 185.0,455.8 186.5,455.8 188.4,455.8 C 190.3,455.8 191.4,455.7 195.3,455.7 C 199.1,455.6 208.2,455.5 211.3,455.5 C 214.3,455.5 212.8,455.5 213.6,455.4 C 214.3,455.4 214.7,456.1 215.8,455.4 C 217.0,454.7 219.3,454.1 220.4,451.3 C 221.6,448.4 221.9,440.7 222.7,438.3 C 223.5,435.9 224.2,437.3 225.0,436.9 C 225.8,436.5 226.5,436.3 227.3,436.0 C 228.1,435.8 228.8,435.6 229.6,435.4 C 230.3,435.3 231.1,435.3 231.9,435.2 C 232.6,435.1 233.4,434.9 234.2,434.8 C 234.9,434.8 235.7,434.8 236.4,434.8 C 237.2,434.7 238.0,434.5 238.7,434.4 C 239.5,434.3 240.3,434.3 241.0,434.2 C 241.8,434.1 242.5,433.7 243.3,433.6 C 244.1,433.5 244.8,433.5 245.6,433.5 C 246.4,433.4 246.7,433.2 247.9,433.1 C 249.0,433.0 251.3,433.0 252.5,432.9 C 253.6,432.9 254.0,432.8 254.7,432.7 C 255.5,432.7 256.3,432.7 257.0,432.6 C 257.8,432.5 258.6,432.2 259.3,432.1 C 260.1,431.9 260.8,432.6 261.6,431.7 C 262.4,430.8 263.1,427.6 263.9,426.6 C 264.7,425.5 265.4,425.7 266.2,425.5 C 266.9,425.3 267.7,425.5 268.5,425.4 C 269.2,425.3 270.0,424.9 270.8,424.6 C 271.5,424.4 272.3,424.2 273.1,423.7 C 273.8,423.2 274.6,422.2 275.3,421.6 C 276.1,421.0 276.9,420.5 277.6,420.0 C 278.4,419.6 279.2,419.3 279.9,419.0 C 280.7,418.8 281.4,418.6 282.2,418.5 C 283.0,418.4 283.7,418.4 284.5,418.3 C 285.3,418.2 286.0,418.0 286.8,417.9 C 287.5,417.8 288.3,417.8 289.1,417.8 C 289.8,417.7 290.6,417.7 291.4,417.5 C 292.1,417.4 292.9,417.5 293.6,417.0 C 294.4,416.6 295.2,415.6 295.9,415.1 C 296.7,414.6 297.5,414.4 298.2,414.0 C 299.0,413.7 299.7,414.4 300.5,412.8 C 301.3,411.3 302.0,406.5 302.8,404.6 C 303.6,402.8 304.3,402.5 305.1,401.7 C 305.8,400.9 306.6,400.4 307.4,399.9 C 308.1,399.3 308.9,398.8 309.7,398.4 C 310.4,398.0 311.2,397.6 311.9,397.3 C 312.7,397.1 313.5,397.0 314.2,396.8 C 315.0,396.6 315.8,396.3 316.5,396.0 C 317.3,395.7 318.1,395.2 318.8,395.0 C 319.6,394.7 320.3,394.8 321.1,394.5 C 321.9,394.2 322.6,393.8 323.4,393.5 C 324.2,393.1 324.9,392.9 325.7,392.6 C 326.4,392.4 327.2,392.2 328.0,392.0 C 328.7,391.9 329.5,391.8 330.3,391.7 C 331.0,391.6 331.8,391.4 332.5,391.2 C 333.3,391.0 334.1,390.9 334.8,390.7 C 335.6,390.5 336.4,390.3 337.1,390.0 C 337.9,389.6 338.6,389.0 339.4,388.8 C 340.2,388.5 340.9,388.4 341.7,388.2 C 342.5,388.1 343.2,387.9 344.0,387.8 C 344.7,387.7 345.5,387.6 346.3,387.5 C 347.0,387.4 347.8,387.2 348.6,387.2 C 349.3,387.1 350.1,387.1 350.8,387.0 C 351.6,386.9 352.4,386.7 353.1,386.4 C 353.9,386.1 354.7,385.6 355.4,385.3 C 356.2,385.0 356.9,384.6 357.7,384.4 C 358.5,384.2 359.2,384.1 360.0,384.0 C 360.8,383.9 361.5,383.8 362.3,383.6 C 363.1,383.3 363.8,382.8 364.6,382.6 C 365.3,382.4 366.1,382.4 366.9,382.3 C 367.6,382.2 368.4,382.0 369.2,381.8 C 369.9,381.6 370.7,381.4 371.4,381.3 C 372.2,381.1 373.0,380.8 373.7,380.7 C 374.5,380.5 375.3,380.4 376.0,380.2 C 376.8,379.9 377.5,379.6 378.3,379.3 C 379.1,379.1 379.8,379.0 380.6,378.7 C 381.4,378.5 382.1,378.2 382.9,377.9 C 383.6,377.6 384.4,377.4 385.2,377.1 C 385.9,376.8 386.7,376.8 387.5,376.4 C 388.2,376.0 389.0,375.1 389.7,374.7 C 390.5,374.3 391.3,374.3 392.0,374.1 C 392.8,374.0 393.6,374.0 394.3,373.9 C 395.1,373.8 395.8,373.7 396.6,373.5 C 397.4,373.4 398.1,373.6 398.9,373.3 C 399.7,372.9 400.4,371.8 401.2,371.4 C 401.9,371.0 402.7,371.1 403.5,370.8 C 404.2,370.6 405.0,370.1 405.8,369.9 C 406.5,369.7 407.3,369.8 408.1,369.6 C 408.8,369.4 409.6,368.9 410.3,368.7 C 411.1,368.5 411.9,368.5 412.6,368.4 C 413.4,368.3 414.2,368.3 414.9,368.1 C 415.7,367.9 416.4,367.6 417.2,367.1 C 418.0,366.6 418.7,365.5 419.5,365.0 C 420.3,364.5 421.0,364.2 421.8,363.9 C 422.5,363.5 423.3,363.4 424.1,362.7 C 424.8,361.9 425.6,360.2 426.4,359.4 C 427.1,358.6 427.9,358.3 428.6,357.8 C 429.4,357.4 430.2,357.3 430.9,356.6 C 431.7,355.9 432.5,354.2 433.2,353.5 C 434.0,352.8 434.7,352.7 435.5,352.3 C 436.3,351.9 437.0,351.6 437.8,351.1 C 438.6,35Line truncated
|
||||
<circle cx="765.0" cy="90.8" r="5" fill="#dd4528" stroke="#fff" stroke-width="2" filter="url(#xkcdify)"/>
|
||||
<text x="753.0" y="77.0" fill="#dd4528" font-size="18" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">6802</text>
|
||||
<a href="https://github.com/carsteneu/mystarhistory" target="_blank" rel="noopener"><text x="763" y="527" font-size="13" font-family="Handlee, cursive" fill="#000" text-anchor="end">Made with carsteneu/mystarhistory</text></a>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 36 KiB |
@@ -47,6 +47,16 @@ test("effectiveDefaultAlgorithms (modern) includes chacha20-poly1305", () => {
|
||||
assert.ok(result.cipher.includes("chacha20-poly1305@openssh.com"));
|
||||
});
|
||||
|
||||
test("effectiveDefaultAlgorithms offers OpenSSH host certificates before plain host keys", () => {
|
||||
const cert = "ssh-ed25519-cert-v01@openssh.com";
|
||||
const modern = effectiveDefaultAlgorithms(false);
|
||||
assert.ok(modern.serverHostKey.includes(cert));
|
||||
assert.ok(modern.serverHostKey.indexOf(cert) < modern.serverHostKey.indexOf("ssh-ed25519"));
|
||||
const legacy = effectiveDefaultAlgorithms(true);
|
||||
assert.ok(legacy.serverHostKey.includes(cert));
|
||||
assert.ok(legacy.serverHostKey.includes("ssh-dss"));
|
||||
});
|
||||
|
||||
test("effectiveDefaultAlgorithms (legacy) appends sha1 KEX, CBC, and ssh-dss", () => {
|
||||
const modern = effectiveDefaultAlgorithms(false);
|
||||
const legacy = effectiveDefaultAlgorithms(true);
|
||||
|
||||
@@ -77,6 +77,13 @@ export const SUPPORTED_HMAC_ALGORITHMS: readonly string[] = [
|
||||
];
|
||||
|
||||
export const SUPPORTED_SERVER_HOST_KEY_ALGORITHMS: readonly string[] = [
|
||||
"ssh-ed25519-cert-v01@openssh.com",
|
||||
"ecdsa-sha2-nistp256-cert-v01@openssh.com",
|
||||
"ecdsa-sha2-nistp384-cert-v01@openssh.com",
|
||||
"ecdsa-sha2-nistp521-cert-v01@openssh.com",
|
||||
"rsa-sha2-512-cert-v01@openssh.com",
|
||||
"rsa-sha2-256-cert-v01@openssh.com",
|
||||
"ssh-rsa-cert-v01@openssh.com",
|
||||
"ssh-ed25519",
|
||||
"ecdsa-sha2-nistp256",
|
||||
"ecdsa-sha2-nistp384",
|
||||
@@ -148,6 +155,13 @@ const MODERN_DEFAULT_ALGORITHMS: Readonly<Record<SSHAlgorithmCategory, readonly
|
||||
"hmac-sha1",
|
||||
],
|
||||
serverHostKey: [
|
||||
"ssh-ed25519-cert-v01@openssh.com",
|
||||
"ecdsa-sha2-nistp256-cert-v01@openssh.com",
|
||||
"ecdsa-sha2-nistp384-cert-v01@openssh.com",
|
||||
"ecdsa-sha2-nistp521-cert-v01@openssh.com",
|
||||
"rsa-sha2-512-cert-v01@openssh.com",
|
||||
"rsa-sha2-256-cert-v01@openssh.com",
|
||||
"ssh-rsa-cert-v01@openssh.com",
|
||||
"ssh-ed25519",
|
||||
"ecdsa-sha2-nistp256",
|
||||
"ecdsa-sha2-nistp384",
|
||||
|
||||
@@ -195,6 +195,9 @@ module.exports = {
|
||||
'node_modules/@cursor/sdk-*/**/*',
|
||||
'node_modules/sqlite3/**/*',
|
||||
'node_modules/@modelcontextprotocol/sdk/**/*',
|
||||
// External MCP scripts run from asarUnpack via ELECTRON_RUN_AS_NODE and
|
||||
// must resolve re2js from a real directory (issue #3910).
|
||||
'node_modules/re2js/**/*',
|
||||
'lib/**/*.cjs',
|
||||
'lib/**/*.json',
|
||||
'node_modules/zod/**/*',
|
||||
|
||||
@@ -2,6 +2,18 @@ const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { EventEmitter } = require('node:events');
|
||||
const { startPtyJob } = require('./ptyExec.cjs');
|
||||
const { getFreshIdlePrompt, getVisibleIdlePrompt, trackSessionIdlePrompt } = require('./shellUtils.cjs');
|
||||
const { terminalCursorLine } = require('../terminalLogSanitizer.cjs');
|
||||
|
||||
function readyPrompt(session) {
|
||||
return getFreshIdlePrompt(session) || getVisibleIdlePrompt(session);
|
||||
}
|
||||
|
||||
// PSReadLine echoes the PowerShell clear-prefix keys, then erases them with
|
||||
// cursor-column + ED without rewriting the prompt into the byte stream.
|
||||
function powershellEraseRedraw(prompt) {
|
||||
return `${prompt}ggd2147483647d\x1b[20G\x1b[0J`;
|
||||
}
|
||||
|
||||
class TestPty extends EventEmitter {
|
||||
writes = [];
|
||||
@@ -158,3 +170,116 @@ test('wall deadline during authorization reports authorization failure rather th
|
||||
assert.match(result.error, /authorization timed out/);
|
||||
assert.deepEqual(pty.writes, []);
|
||||
});
|
||||
|
||||
test('idle PowerShell whose raw tail was CSI-erased still receives the command', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['Date', 'setTimeout'] });
|
||||
const pty = new TestPty();
|
||||
const session = { _promptTrackTail: '', lastIdlePrompt: '' };
|
||||
const prompt = 'PS C:\\Users\\alice> ';
|
||||
trackSessionIdlePrompt(session, `Windows PowerShell\r\n${powershellEraseRedraw(prompt)}`);
|
||||
assert.equal(getFreshIdlePrompt(session), '');
|
||||
const job = startPtyJob(pty, 'Write-Output ok', {
|
||||
shellKind: 'powershell',
|
||||
getReadyPrompt: () => readyPrompt(session),
|
||||
promptWaitTimeoutMs: 200,
|
||||
timeoutMs: 1000,
|
||||
typedInput: true,
|
||||
});
|
||||
t.mock.timers.tick(200);
|
||||
const result = await job.resultPromise;
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.stdout, 'OK');
|
||||
assert.equal(pty.writes.length, 1);
|
||||
assert.match(pty.writes[0], /\$__NCMCP_/);
|
||||
});
|
||||
|
||||
test('the next PowerShell exec runs after the clear-prefix echo is erased on screen', async () => {
|
||||
const prompt = 'PS C:\\Users\\alice> ';
|
||||
const session = { _promptTrackTail: '', lastIdlePrompt: '' };
|
||||
trackSessionIdlePrompt(session, prompt);
|
||||
const pty = new EventEmitter();
|
||||
pty.writes = [];
|
||||
pty.write = (data) => {
|
||||
const text = String(data);
|
||||
pty.writes.push(text);
|
||||
const marker = text.match(/(__NCMCP_[0-9a-z]+_[0-9a-f]+__)/i)?.[1];
|
||||
if (!marker) return;
|
||||
queueMicrotask(() => {
|
||||
const chunk = `${marker}_S\r\nok\r\n${marker}_E:0\r\n${powershellEraseRedraw(prompt)}`;
|
||||
trackSessionIdlePrompt(session, chunk);
|
||||
pty.emit('data', chunk);
|
||||
});
|
||||
};
|
||||
|
||||
const first = startPtyJob(pty, 'Write-Output ok', {
|
||||
shellKind: 'powershell',
|
||||
getReadyPrompt: () => readyPrompt(session),
|
||||
timeoutMs: 1000,
|
||||
typedInput: true,
|
||||
});
|
||||
const firstResult = await first.resultPromise;
|
||||
assert.equal(firstResult.ok, true);
|
||||
assert.equal(firstResult.stdout, 'ok');
|
||||
assert.equal(getFreshIdlePrompt(session), '');
|
||||
assert.equal(getVisibleIdlePrompt(session), prompt);
|
||||
|
||||
const second = startPtyJob(pty, 'Write-Output next', {
|
||||
shellKind: 'powershell',
|
||||
getReadyPrompt: () => readyPrompt(session),
|
||||
promptWaitTimeoutMs: 200,
|
||||
timeoutMs: 1000,
|
||||
typedInput: true,
|
||||
});
|
||||
const secondResult = await second.resultPromise;
|
||||
assert.equal(secondResult.ok, true);
|
||||
assert.equal(secondResult.stdout, 'ok');
|
||||
assert.equal(pty.writes.length, 2);
|
||||
});
|
||||
|
||||
test('alt-screen paint is not ready after the entry sequence leaves the prompt tail', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['Date', 'setTimeout'] });
|
||||
const pty = new TestPty();
|
||||
const session = { _promptTrackTail: '', lastIdlePrompt: '' };
|
||||
const prompt = 'PS C:\\Users\\alice> ';
|
||||
// A full-screen app can emit more than the 4096-byte prompt tail. Once the
|
||||
// alternate-screen entry scrolls off, a painted idle prompt must not unlock
|
||||
// ptyExec and type into the still-running program.
|
||||
trackSessionIdlePrompt(session, prompt);
|
||||
trackSessionIdlePrompt(session, `\x1b[?1049h${'x'.repeat(4200)}`);
|
||||
trackSessionIdlePrompt(session, `\r\n${prompt}\r`);
|
||||
assert.equal(session._promptTrackTail.includes('\x1b[?1049h'), false);
|
||||
assert.equal(terminalCursorLine(session._promptTrackTail).trimEnd(), prompt.trimEnd());
|
||||
assert.equal(getFreshIdlePrompt(session), '');
|
||||
assert.equal(getVisibleIdlePrompt(session), '');
|
||||
assert.equal(readyPrompt(session), '');
|
||||
|
||||
const job = startPtyJob(pty, 'Write-Output ok', {
|
||||
shellKind: 'powershell',
|
||||
getReadyPrompt: () => readyPrompt(session),
|
||||
promptWaitTimeoutMs: 50,
|
||||
timeoutMs: 1000,
|
||||
});
|
||||
t.mock.timers.tick(50);
|
||||
const result = await job.resultPromise;
|
||||
assert.equal(result.code, 'SHELL_NOT_READY');
|
||||
assert.match(result.error, /no command was sent/);
|
||||
assert.deepEqual(pty.writes, []);
|
||||
});
|
||||
|
||||
test('real pending PowerShell input is still not sent', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['Date', 'setTimeout'] });
|
||||
const pty = new TestPty();
|
||||
const session = { _promptTrackTail: '', lastIdlePrompt: '' };
|
||||
trackSessionIdlePrompt(session, 'PS C:\\Users\\alice> Write-Output ok');
|
||||
const job = startPtyJob(pty, 'echo OK', {
|
||||
shellKind: 'powershell',
|
||||
getReadyPrompt: () => readyPrompt(session),
|
||||
promptWaitTimeoutMs: 50,
|
||||
timeoutMs: 1000,
|
||||
});
|
||||
t.mock.timers.tick(50);
|
||||
const result = await job.resultPromise;
|
||||
assert.equal(result.code, 'SHELL_NOT_READY');
|
||||
assert.match(result.error, /no command was sent/);
|
||||
assert.deepEqual(pty.writes, []);
|
||||
});
|
||||
@@ -68,7 +68,8 @@ const PROMPT_SUFFIX_WINDOW_PER_PROMPT_CHAR = 8;
|
||||
|
||||
function hasExpectedPromptSuffix(text, expectedPrompt) {
|
||||
if (!expectedPrompt) return false;
|
||||
const normalizedPrompt = stripAnsi(String(expectedPrompt || "")).replace(/\r/g, "");
|
||||
const ansiFreePrompt = stripAnsi(String(expectedPrompt || ""));
|
||||
const normalizedPrompt = ansiFreePrompt.replace(/\r/g, "");
|
||||
if (!normalizedPrompt) return false;
|
||||
const fullText = String(text || "");
|
||||
const windowChars = Math.max(
|
||||
@@ -76,7 +77,17 @@ function hasExpectedPromptSuffix(text, expectedPrompt) {
|
||||
String(expectedPrompt).length * PROMPT_SUFFIX_WINDOW_PER_PROMPT_CHAR,
|
||||
);
|
||||
const tail = fullText.length > windowChars ? fullText.slice(-windowChars) : fullText;
|
||||
return stripAnsi(tail).replace(/\r/g, "").endsWith(normalizedPrompt);
|
||||
const strippedTail = stripAnsi(tail);
|
||||
// Drop CR so a redraw inside one line (`root@host:~\r# `) still matches
|
||||
// the cached prompt. That collapses a two-line PS1's `\r\r\n` to a single
|
||||
// LF, which no longer ends with the cache (`user@host: /root\n\n\n$ `).
|
||||
if (strippedTail.replace(/\r/g, "").endsWith(normalizedPrompt)) return true;
|
||||
// The idle-prompt cache turns those CRs into LFs and keeps the blank
|
||||
// lines. Compare that form too, only when the cache itself spans lines,
|
||||
// so a bare `$`/`#` is unchanged.
|
||||
if (!ansiFreePrompt.includes("\n")) return false;
|
||||
const crAsLfPrompt = ansiFreePrompt.replace(/\r/g, "\n");
|
||||
return strippedTail.replace(/\r/g, "\n").endsWith(crAsLfPrompt);
|
||||
}
|
||||
|
||||
function escapePosixSingleQuoted(text) {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { hasExpectedPromptSuffix } = require("./ptyExecHelpers.cjs");
|
||||
const { stripAnsi } = require("./shellUtils.cjs");
|
||||
const { extractTrailingIdlePrompt, stripAnsi } = require("./shellUtils.cjs");
|
||||
|
||||
// The behaviour before the tail window was introduced: normalise the whole
|
||||
// text, then compare its end.
|
||||
@@ -41,6 +41,38 @@ test("prompt suffix is found after a very large flood of CRLF output", () => {
|
||||
assert.equal(hasExpectedPromptSuffix(`${flood}${COLORED_PROMPT}`, "root@3160T:~# "), true);
|
||||
});
|
||||
|
||||
test("live PTY CR bytes match a cached two-line idle prompt", () => {
|
||||
// Bash writes the PS1 newline as CR and ONLCR adds another, so the bytes
|
||||
// are `\r\r\n`. The idle-prompt cache keeps those as blank lines. Suffix
|
||||
// matching has to accept that form; stripping CR down to one LF does not.
|
||||
const live = "user@host: /workspace\r\r\n$ ";
|
||||
const cached = extractTrailingIdlePrompt(`welcome\r\n${live}`);
|
||||
assert.equal(cached, "user@host: /workspace\n\n\n$ ");
|
||||
const matchLive = hasExpectedPromptSuffix(live, cached);
|
||||
assert.equal(matchLive, true);
|
||||
assert.equal(hasExpectedPromptSuffix(`hostname\r\nbox\r\n${live}`, cached), true);
|
||||
assert.equal(
|
||||
hasExpectedPromptSuffix("\x1b[?2004huser@host: /workspace\r\r\n$ ", cached),
|
||||
true,
|
||||
);
|
||||
|
||||
const rootLive = "user@host: /root\r\r\n# ";
|
||||
const rootCached = extractTrailingIdlePrompt(rootLive);
|
||||
assert.equal(rootCached, "user@host: /root\n\n\n# ");
|
||||
assert.equal(hasExpectedPromptSuffix(rootLive, rootCached), true);
|
||||
|
||||
// Readiness for a real LF two-line prompt still matches, and a bare
|
||||
// `$`/`#` is neither this prompt nor a POSIX prompt on its own.
|
||||
const lfCached = extractTrailingIdlePrompt("welcome\r\nuser@host: /workspace\n$ ");
|
||||
assert.equal(lfCached, "user@host: /workspace\n$ ");
|
||||
assert.equal(hasExpectedPromptSuffix("user@host: /workspace\n$ ", lfCached), true);
|
||||
assert.equal(hasExpectedPromptSuffix("output\r\n$ ", cached), false);
|
||||
assert.equal(hasExpectedPromptSuffix("$ ", cached), false);
|
||||
assert.equal(hasExpectedPromptSuffix("# ", rootCached), false);
|
||||
assert.equal(extractTrailingIdlePrompt("$ "), "");
|
||||
assert.equal(extractTrailingIdlePrompt("# "), "");
|
||||
});
|
||||
|
||||
test("prompt suffix check does not scan the whole buffer on every call", () => {
|
||||
// 2M characters of CRLF output used to cost seconds per call because every
|
||||
// call stripped ANSI and carriage returns over the entire text.
|
||||
|
||||
@@ -61,10 +61,23 @@ const POWERSHELL_PROMPT_PATTERN = /^PS(?:\s+\S.*)?>$/;
|
||||
// PowerShell's `PS C:\...>` (handled by POWERSHELL_PROMPT_PATTERN first).
|
||||
const CMD_PROMPT_PATTERN = /^[A-Za-z]:(?:\\[^<>"|]*)?>$/;
|
||||
|
||||
// Classic `user@host:...$` / `user@host:...#` login prompt (bash/zsh in WSL,
|
||||
// remote Linux, etc.). Intentionally narrow so custom / fish prompts do not
|
||||
// flip a Windows DefaultShell soft hint.
|
||||
const POSIX_PROMPT_PATTERN = /^[^\s@]+@[^\s:]+(?::[^\n\r]*)?[#$]$/;
|
||||
// Default login prompts, kept narrow so custom / fish / starship prompts do
|
||||
// not flip a Windows DefaultShell soft hint:
|
||||
// - bash/zsh `user@host[:path][#$]` (colon before the path; `root@host ~#` stays rejected)
|
||||
// - RHEL-family `[\u@\h \W]\$` -> `[user@host path][#$]`, or `[user@host][#$]`
|
||||
// with no path. `\W` may contain whitespace or `]` (`[user@host My [Project]]$`);
|
||||
// the final `]` before `$`/`#` is the delimiter.
|
||||
const POSIX_PROMPT_PATTERN = /^(?:[^\s@]+@[^\s:]+(?::[^\n\r]*)?|\[[^\s@\]]+@[^\s\]:]+(?:\s+[^\n\r]+)?\])[#$]$/;
|
||||
|
||||
// Host line of a two-line bash PS1 (`\u@\h: \w` then a following `$` or `#`).
|
||||
// Same user@host[:path] class as POSIX_PROMPT_PATTERN's first alternative,
|
||||
// without a prompt marker on this line. A bare `$`/`#` alone is not a prompt.
|
||||
const POSIX_PROMPT_HOST_LINE_PATTERN = /^[^\s@]+@[^\s:]+(?::[^\n\r]*)?$/;
|
||||
|
||||
// Alpine's default ash PS1 omits the username: `\h:\w\$ ` -> `localhost:~# `.
|
||||
// Require a hostname and a home/absolute path, excluding prompt delimiters
|
||||
// within the path so partially entered commands do not count as idle prompts.
|
||||
const ALPINE_PROMPT_PATTERN = /^[A-Za-z0-9_][A-Za-z0-9_.-]*:(?:~(?:\/[^#$\n\r]*)?|\/[^#$\n\r]*)[#$]$/;
|
||||
|
||||
function isDefaultPowerShellPromptLine(line) {
|
||||
return POWERSHELL_PROMPT_PATTERN.test(String(line || ""));
|
||||
@@ -75,7 +88,15 @@ function isDefaultCmdPromptLine(line) {
|
||||
}
|
||||
|
||||
function isDefaultPosixPromptLine(line) {
|
||||
return POSIX_PROMPT_PATTERN.test(String(line || "").replace(/\s+$/, ""));
|
||||
const normalized = String(line || "").replace(/\s+$/, "");
|
||||
// Optional virtualenv / conda / BT-panel style env name prefix: "(name) "
|
||||
// before the shell prompt. Recognition only; callers still return the full
|
||||
// prompt line so suffix matching sees the visible prefix (#3947).
|
||||
const withoutEnvPrefix = normalized.replace(/^\([^()\n\r]+\)\s+/, "");
|
||||
return (
|
||||
POSIX_PROMPT_PATTERN.test(withoutEnvPrefix) ||
|
||||
ALPINE_PROMPT_PATTERN.test(withoutEnvPrefix)
|
||||
);
|
||||
}
|
||||
|
||||
function extractTrailingIdlePrompt(output) {
|
||||
@@ -86,7 +107,8 @@ function extractTrailingIdlePrompt(output) {
|
||||
const normalized = stripAnsi(output).replace(/\r/g, "\n");
|
||||
if (!normalized || normalized.endsWith("\n")) return "";
|
||||
|
||||
const lastLine = normalized.split("\n").pop() || "";
|
||||
const lines = normalized.split("\n");
|
||||
const lastLine = lines.pop() || "";
|
||||
const rightTrimmed = lastLine.replace(/\s+$/, "");
|
||||
if (!rightTrimmed) return "";
|
||||
|
||||
@@ -102,6 +124,24 @@ function extractTrailingIdlePrompt(output) {
|
||||
return lastLine;
|
||||
}
|
||||
|
||||
// Two-line PS1 (`export PS1="\u@\h: \w\n$ "`). Bash emits the newline as
|
||||
// CR and the PTY's ONLCR adds another, so the bytes are `\r\r\n`. The
|
||||
// CR-to-LF normalize above turns that into blank lines between the host
|
||||
// line and `$`/`#`. Keep those blanks in the cache: getFreshIdlePrompt
|
||||
// uses the same normalize, and hasExpectedPromptSuffix compares this
|
||||
// CR-as-LF form against live PTY bytes. A lone `$` must not satisfy endsWith.
|
||||
if (rightTrimmed === "$" || rightTrimmed === "#") {
|
||||
let hostIndex = lines.length - 1;
|
||||
while (hostIndex >= 0 && lines[hostIndex].replace(/\s+$/, "") === "") {
|
||||
hostIndex -= 1;
|
||||
}
|
||||
const previousLine = hostIndex >= 0 ? lines[hostIndex] : "";
|
||||
const previousRightTrimmed = previousLine.replace(/\s+$/, "");
|
||||
if (POSIX_PROMPT_HOST_LINE_PATTERN.test(previousRightTrimmed)) {
|
||||
return `${lines.slice(hostIndex).join("\n")}\n${lastLine}`;
|
||||
}
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
@@ -134,7 +174,20 @@ function looksLikeIdleAutoLogout(outputTail) {
|
||||
function trackSessionIdlePrompt(session, chunk) {
|
||||
if (!session || typeof chunk !== "string" || !chunk) return "";
|
||||
|
||||
const nextTail = `${session._promptTrackTail || ""}${chunk}`.slice(-MAX_PROMPT_TRACK_TAIL);
|
||||
const previousTail = session._promptTrackTail || "";
|
||||
const combined = `${previousTail}${chunk}`;
|
||||
const overflow = combined.length - MAX_PROMPT_TRACK_TAIL;
|
||||
if (overflow > 0) {
|
||||
// The kept tail no longer starts where the previous tail started. Replay
|
||||
// the dropped prefix so an alternate-screen enter or leave that scrolls
|
||||
// off is still the mode at the first kept byte.
|
||||
const { alternateScreenActiveAfter } = require("../terminalLogSanitizer.cjs");
|
||||
session._promptTrackAltScreenAtTailStart = alternateScreenActiveAfter(
|
||||
combined.slice(0, overflow),
|
||||
session._promptTrackAltScreenAtTailStart === true,
|
||||
);
|
||||
}
|
||||
const nextTail = overflow > 0 ? combined.slice(overflow) : combined;
|
||||
session._promptTrackTail = nextTail;
|
||||
|
||||
const prompt = extractTrailingIdlePrompt(nextTail);
|
||||
@@ -149,8 +202,10 @@ function trackSessionIdlePrompt(session, chunk) {
|
||||
// Return `session.lastIdlePrompt` only if the PTY's recent rolling tail
|
||||
// still ends with it. The cached prompt is updated only when
|
||||
// extractTrailingIdlePrompt recognizes a known shape (PowerShell, cmd.exe,
|
||||
// or `user@host[:path][#$]`); a remote shell switch into another shell, an
|
||||
// oh-my-posh / starship / custom PS1, or any unrecognized prompt would
|
||||
// `user@host[:path][#$]`, two-line `user@host[:path]` + `$`/`#`,
|
||||
// RHEL `[user@host path][#$]`, or Alpine `host:path[#$]`);
|
||||
// a remote shell switch into another shell, an oh-my-posh / starship / custom
|
||||
// PS1, or any unrecognized prompt would
|
||||
// otherwise leave a stale value behind, which `resolveEffectiveShellKind`
|
||||
// would then keep using to coerce future commands into a PowerShell
|
||||
// wrapper. By re-checking the live tail we self-correct: if the visible
|
||||
@@ -172,6 +227,34 @@ function getFreshIdlePrompt(session) {
|
||||
return normalizedTail.endsWith(normalizedCached) ? cached : "";
|
||||
}
|
||||
|
||||
// Readiness companion to getFreshIdlePrompt. PSReadLine echoes keys (including
|
||||
// the pending-input clear prefix) and then erases them with cursor-column +
|
||||
// ED/EL without appending a fresh copy of the prompt. stripAnsi removes the
|
||||
// erase, so the raw tail no longer ends with the cached prompt while the
|
||||
// screen is still an idle default prompt (#3927). Collapse those cursor and
|
||||
// erase operations, then accept only the line the cursor is actually on.
|
||||
function getVisibleIdlePrompt(session) {
|
||||
const tail = session?._promptTrackTail;
|
||||
if (typeof tail !== "string" || !tail) return "";
|
||||
|
||||
const { terminalCursorLine } = require("../terminalLogSanitizer.cjs");
|
||||
const cursorLine = terminalCursorLine(tail, {
|
||||
alternateScreenActive: session._promptTrackAltScreenAtTailStart === true,
|
||||
});
|
||||
const rightTrimmed = cursorLine.replace(/\s+$/, "");
|
||||
if (!rightTrimmed) return "";
|
||||
if (
|
||||
!isDefaultPowerShellPromptLine(rightTrimmed)
|
||||
&& !isDefaultPosixPromptLine(rightTrimmed)
|
||||
&& !isDefaultCmdPromptLine(rightTrimmed)
|
||||
) {
|
||||
return "";
|
||||
}
|
||||
// Keep one trailing space when the visible prompt has one. Erase-to-end
|
||||
// padding must not become part of the expected prompt.
|
||||
return /\s$/.test(cursorLine) ? `${rightTrimmed} ` : rightTrimmed;
|
||||
}
|
||||
|
||||
// ── URL helpers ──
|
||||
|
||||
function isLocalhostHostname(hostname) {
|
||||
@@ -1112,6 +1195,7 @@ module.exports = {
|
||||
formatSyntheticEcho,
|
||||
extractTrailingIdlePrompt,
|
||||
getFreshIdlePrompt,
|
||||
getVisibleIdlePrompt,
|
||||
isDefaultPowerShellPromptLine,
|
||||
isDefaultCmdPromptLine,
|
||||
isDefaultPosixPromptLine,
|
||||
|
||||
@@ -8,6 +8,7 @@ const {
|
||||
extractTrailingIdlePrompt,
|
||||
formatSyntheticEcho,
|
||||
getFreshIdlePrompt,
|
||||
getVisibleIdlePrompt,
|
||||
isDefaultPowerShellPromptLine,
|
||||
isDefaultCmdPromptLine,
|
||||
isDefaultPosixPromptLine,
|
||||
@@ -133,14 +134,182 @@ test("isDefaultCmdPromptLine matches drive-letter cmd prompts only", () => {
|
||||
|
||||
test("isDefaultPosixPromptLine matches classic user@host prompts", () => {
|
||||
assert.equal(isDefaultPosixPromptLine("alice@host:~$"), true);
|
||||
assert.equal(isDefaultPosixPromptLine("user@host:~$"), true);
|
||||
assert.equal(isDefaultPosixPromptLine("alice@wsl:/mnt/c$"), true);
|
||||
assert.equal(isDefaultPosixPromptLine("root@box:/#"), true);
|
||||
assert.equal(isDefaultPosixPromptLine("[opc@instance-20260905-1821 ~]$"), true);
|
||||
assert.equal(isDefaultPosixPromptLine("[opc@instance-20260905-1821 ~]$ "), true);
|
||||
assert.equal(isDefaultPosixPromptLine("[root@host ~]#"), true);
|
||||
assert.equal(isDefaultPosixPromptLine("[user@host]$"), true);
|
||||
assert.equal(isDefaultPosixPromptLine("[user@host My Project]$"), true);
|
||||
assert.equal(isDefaultPosixPromptLine("[user@host My Project]$ "), true);
|
||||
assert.equal(isDefaultPosixPromptLine("[user@host My [Project]]$"), true);
|
||||
assert.equal(isDefaultPosixPromptLine("[user@host My [Project]]$ "), true);
|
||||
assert.equal(isDefaultPosixPromptLine("root@host ~#"), false);
|
||||
assert.equal(isDefaultPosixPromptLine("PS C:\\Users\\alice>"), false);
|
||||
assert.equal(isDefaultPosixPromptLine("C:\\Users\\alice>"), false);
|
||||
assert.equal(isDefaultPosixPromptLine(""), false);
|
||||
});
|
||||
|
||||
test("isDefaultPosixPromptLine matches Alpine host:path prompts without a username", () => {
|
||||
for (const prompt of [
|
||||
"localhost:~#",
|
||||
"localhost:~# ",
|
||||
"alpine:~$ ",
|
||||
"alpine-host:~/projects$",
|
||||
"alpine-host:/# ",
|
||||
"alpine.example:/srv/My Project$ ",
|
||||
]) {
|
||||
assert.equal(isDefaultPosixPromptLine(prompt), true, prompt);
|
||||
}
|
||||
});
|
||||
|
||||
test("Alpine prompt recognition rejects output and partially entered commands", () => {
|
||||
for (const line of [
|
||||
"#",
|
||||
"$",
|
||||
"status: ready#",
|
||||
"progress:100%$",
|
||||
":~#",
|
||||
"alpine:#",
|
||||
"alpine:relative$",
|
||||
"alpine:~user$",
|
||||
"localhost:~# printf busy",
|
||||
"localhost:/tmp# printf busy#",
|
||||
"alpine:/tmp$ printf busy$",
|
||||
]) {
|
||||
assert.equal(isDefaultPosixPromptLine(line), false, line);
|
||||
assert.equal(extractTrailingIdlePrompt(`welcome\r\n${line}`), "", line);
|
||||
}
|
||||
});
|
||||
|
||||
test("extracts an Alpine idle prompt while preserving its trailing whitespace", () => {
|
||||
assert.equal(
|
||||
extractTrailingIdlePrompt("Welcome to Alpine!\r\n\x1b[32mlocalhost:~# \x1b[0m"),
|
||||
"localhost:~# ",
|
||||
);
|
||||
assert.equal(extractTrailingIdlePrompt("alpine:/srv$ "), "alpine:/srv$ ");
|
||||
assert.equal(extractTrailingIdlePrompt("localhost:~# \r\n"), "");
|
||||
});
|
||||
|
||||
test("extracts a trailing RHEL-family bracketed idle prompt", () => {
|
||||
// Oracle Linux / RHEL / CentOS / Fedora default PS1 is `[\u@\h \W]\$ `.
|
||||
// Trailing space must round-trip, same as the PowerShell prompt capture.
|
||||
assert.equal(
|
||||
extractTrailingIdlePrompt("[opc@instance-20260905-1821 ~]$ "),
|
||||
"[opc@instance-20260905-1821 ~]$ ",
|
||||
);
|
||||
assert.equal(
|
||||
extractTrailingIdlePrompt("Last login: Fri Oct 24\r\n[root@host ~]#"),
|
||||
"[root@host ~]#",
|
||||
);
|
||||
assert.equal(
|
||||
extractTrailingIdlePrompt("welcome\r\nuser@host:~$ "),
|
||||
"user@host:~$ ",
|
||||
);
|
||||
assert.equal(
|
||||
extractTrailingIdlePrompt("welcome\r\n[user@host My Project]$ "),
|
||||
"[user@host My Project]$ ",
|
||||
);
|
||||
assert.equal(
|
||||
extractTrailingIdlePrompt("welcome\r\n[user@host My [Project]]$ "),
|
||||
"[user@host My [Project]]$ ",
|
||||
);
|
||||
// Unbracketed space form is still not a recognized default prompt.
|
||||
assert.equal(extractTrailingIdlePrompt("welcome\r\nroot@host ~#"), "");
|
||||
});
|
||||
|
||||
test("extractTrailingIdlePrompt recognizes a two-line user@host then bare $/# PS1", () => {
|
||||
// export PS1="\u@\h: \w\n$ " paints the host line, then a lone "$ ".
|
||||
// The cached prompt must keep both lines so a lone "$" cannot satisfy endsWith.
|
||||
assert.equal(
|
||||
extractTrailingIdlePrompt("welcome\r\nuser@host: /home/foo\n$ "),
|
||||
"user@host: /home/foo\n$ ",
|
||||
);
|
||||
assert.equal(
|
||||
extractTrailingIdlePrompt("welcome\r\nroot@host: /root\n# "),
|
||||
"root@host: /root\n# ",
|
||||
);
|
||||
// Live bash PTY: prompt CR plus ONLCR is `\r\r\n`, which this normalizer
|
||||
// stores as blank lines. The cache must keep them so endsWith still matches.
|
||||
assert.equal(
|
||||
extractTrailingIdlePrompt("\x1b[?2004huser@host: /home/foo\r\r\n$ "),
|
||||
"user@host: /home/foo\n\n\n$ ",
|
||||
);
|
||||
assert.equal(
|
||||
extractTrailingIdlePrompt("\x1b[?2004hroot@host: /root\r\r\n# "),
|
||||
"root@host: /root\n\n\n# ",
|
||||
);
|
||||
assert.equal(extractTrailingIdlePrompt("$ "), "");
|
||||
assert.equal(extractTrailingIdlePrompt("# "), "");
|
||||
assert.equal(extractTrailingIdlePrompt("welcome\r\n$ "), "");
|
||||
assert.equal(extractTrailingIdlePrompt("not a host line\n$ "), "");
|
||||
assert.equal(extractTrailingIdlePrompt("copied output\n# "), "");
|
||||
assert.equal(isDefaultPosixPromptLine("$"), false);
|
||||
assert.equal(isDefaultPosixPromptLine("#"), false);
|
||||
assert.equal(isDefaultPosixPromptLine("$ "), false);
|
||||
assert.equal(isDefaultPosixPromptLine("# "), false);
|
||||
});
|
||||
|
||||
test("trackSessionIdlePrompt and getFreshIdlePrompt accept a two-line user@host PS1", () => {
|
||||
const session = {};
|
||||
const prompt = "user@host: /home/foo\n$ ";
|
||||
trackSessionIdlePrompt(session, `welcome\r\n${prompt}`);
|
||||
assert.equal(getFreshIdlePrompt(session), prompt);
|
||||
|
||||
const ptySession = {};
|
||||
trackSessionIdlePrompt(ptySession, "\x1b[?2004huser@host: /home/foo\r\r\n$ ");
|
||||
assert.equal(getFreshIdlePrompt(ptySession), "user@host: /home/foo\n\n\n$ ");
|
||||
});
|
||||
|
||||
test("extractTrailingIdlePrompt still recognizes a classic single-line POSIX prompt", () => {
|
||||
assert.equal(extractTrailingIdlePrompt("welcome\r\nuser@host:~$ "), "user@host:~$ ");
|
||||
});
|
||||
|
||||
test("isDefaultPosixPromptLine accepts virtualenv, conda, and BT-panel env prefixes", () => {
|
||||
// Python venv / conda / BT-panel prepend "(name) " before the real prompt.
|
||||
// Recognition strips that prefix; a missing space after ")" is already
|
||||
// accepted because the username class absorbs the parens (#3947).
|
||||
for (const prompt of [
|
||||
"(3.12.12) root@example:~# ",
|
||||
"(3.12.12) root@example:~#",
|
||||
"(venv) alice@host:~$ ",
|
||||
"(base) [opc@host ~]$ ",
|
||||
"(my-env) localhost:~# ",
|
||||
"(3.12.12)root@example:~# ",
|
||||
]) {
|
||||
assert.equal(isDefaultPosixPromptLine(prompt), true, prompt);
|
||||
}
|
||||
});
|
||||
|
||||
test("extractTrailingIdlePrompt keeps the full env-prefixed POSIX prompt", () => {
|
||||
// Suffix matching compares the visible PTY line, so the captured prompt
|
||||
// must still include the "(name) " prefix rather than the stripped form.
|
||||
for (const prompt of [
|
||||
"(3.12.12) root@example:~# ",
|
||||
"(3.12.12) root@example:~#",
|
||||
"(venv) alice@host:~$ ",
|
||||
"(base) [opc@host ~]$ ",
|
||||
"(my-env) localhost:~# ",
|
||||
]) {
|
||||
assert.equal(extractTrailingIdlePrompt(`Last login\r\n${prompt}`), prompt, prompt);
|
||||
}
|
||||
});
|
||||
|
||||
test("env-prefixed POSIX recognition rejects incomplete prefixes and non-prompts", () => {
|
||||
for (const line of [
|
||||
"(3.12.12 root@example:~# ",
|
||||
"(3.12.12) root@example:~# printf busy",
|
||||
"(venv) not a prompt",
|
||||
"root@host ~#",
|
||||
"(base) [opc@host ~]$ printf busy",
|
||||
"(my-env) localhost:~# printf busy",
|
||||
]) {
|
||||
assert.equal(isDefaultPosixPromptLine(line), false, line);
|
||||
assert.equal(extractTrailingIdlePrompt(`welcome\r\n${line}`), "", line);
|
||||
}
|
||||
});
|
||||
|
||||
test("isPlausibleCliVersionOutput rejects stack traces and file URLs", () => {
|
||||
assert.equal(isPlausibleCliVersionOutput("2.1.123 (Claude Code)"), true);
|
||||
assert.equal(isPlausibleCliVersionOutput("codex-cli 0.125.0"), true);
|
||||
@@ -674,6 +843,95 @@ test("getFreshIdlePrompt returns empty string when the session has no cached pro
|
||||
);
|
||||
});
|
||||
|
||||
test("tracks split Alpine prompts and expires them while the shell is busy", () => {
|
||||
const session = {};
|
||||
assert.equal(trackSessionIdlePrompt(session, "Welcome to Alpine!\r\nlocalhost:"), "");
|
||||
assert.equal(trackSessionIdlePrompt(session, "~# "), "localhost:~# ");
|
||||
assert.equal(getFreshIdlePrompt(session), "localhost:~# ");
|
||||
|
||||
assert.equal(trackSessionIdlePrompt(session, "printf busy"), "");
|
||||
assert.equal(getFreshIdlePrompt(session), "");
|
||||
|
||||
trackSessionIdlePrompt(session, "\r\nbusy\r\nlocalhost:/srv# ");
|
||||
assert.equal(getFreshIdlePrompt(session), "localhost:/srv# ");
|
||||
trackSessionIdlePrompt(session, "printf working\r\nworking...");
|
||||
assert.equal(getFreshIdlePrompt(session), "");
|
||||
});
|
||||
|
||||
test("trackSessionIdlePrompt and getFreshIdlePrompt accept an Oracle/RHEL bracketed bash prompt", () => {
|
||||
const session = {};
|
||||
trackSessionIdlePrompt(session, "Last login...\r\n[opc@instance-20260905-1821 ~]$ ");
|
||||
assert.equal(session.lastIdlePrompt, "[opc@instance-20260905-1821 ~]$ ");
|
||||
assert.equal(getFreshIdlePrompt(session), "[opc@instance-20260905-1821 ~]$ ");
|
||||
|
||||
const rootSession = {};
|
||||
trackSessionIdlePrompt(rootSession, "[root@host ~]#");
|
||||
assert.equal(getFreshIdlePrompt(rootSession), "[root@host ~]#");
|
||||
|
||||
const classic = {};
|
||||
trackSessionIdlePrompt(classic, "user@host:~$ ");
|
||||
assert.equal(getFreshIdlePrompt(classic), "user@host:~$ ");
|
||||
|
||||
const unbracketed = {};
|
||||
assert.equal(trackSessionIdlePrompt(unbracketed, "root@host ~#"), "");
|
||||
assert.equal(getFreshIdlePrompt(unbracketed), "");
|
||||
|
||||
const spacedPath = {};
|
||||
trackSessionIdlePrompt(spacedPath, "Last login...\r\n[user@host My Project]$ ");
|
||||
assert.equal(spacedPath.lastIdlePrompt, "[user@host My Project]$ ");
|
||||
assert.equal(getFreshIdlePrompt(spacedPath), "[user@host My Project]$ ");
|
||||
|
||||
const bracketedPath = {};
|
||||
trackSessionIdlePrompt(bracketedPath, "Last login...\r\n[user@host My [Project]]$ ");
|
||||
assert.equal(bracketedPath.lastIdlePrompt, "[user@host My [Project]]$ ");
|
||||
assert.equal(getFreshIdlePrompt(bracketedPath), "[user@host My [Project]]$ ");
|
||||
});
|
||||
|
||||
test("visible idle prompt survives a PSReadLine erase that leaves the raw tail dirty", () => {
|
||||
// PSReadLine echoes the pending-input clear prefix (`ggd2147483647d` / `i`),
|
||||
// then homes the cursor and erases those keys with CSI. stripAnsi drops the
|
||||
// erase but keeps the keys, so the cached prompt no longer matches the tail.
|
||||
// The screen is still an idle default PowerShell prompt (#3927).
|
||||
const session = {};
|
||||
const prompt = "PS C:\\Users\\alice> ";
|
||||
trackSessionIdlePrompt(session, `Windows PowerShell\r\n${prompt}ggd2147483647d\x1b[20G\x1b[0J`);
|
||||
assert.equal(getFreshIdlePrompt(session), "");
|
||||
assert.equal(extractTrailingIdlePrompt(session._promptTrackTail), "");
|
||||
assert.equal(getVisibleIdlePrompt(session), prompt);
|
||||
|
||||
const backspaced = {};
|
||||
trackSessionIdlePrompt(backspaced, `${prompt}\x08`);
|
||||
assert.equal(getFreshIdlePrompt(backspaced), "");
|
||||
assert.equal(getVisibleIdlePrompt(backspaced), prompt);
|
||||
|
||||
const crHomed = {};
|
||||
trackSessionIdlePrompt(crHomed, `${prompt}\r`);
|
||||
assert.equal(getFreshIdlePrompt(crHomed), "");
|
||||
assert.equal(getVisibleIdlePrompt(crHomed), prompt);
|
||||
});
|
||||
|
||||
test("visible idle prompt stays empty for pending input, a following line, and an unrecognized prompt", () => {
|
||||
const pending = {};
|
||||
trackSessionIdlePrompt(pending, "PS C:\\Users\\alice> Write-Output ok");
|
||||
assert.equal(getFreshIdlePrompt(pending), "");
|
||||
assert.equal(getVisibleIdlePrompt(pending), "");
|
||||
|
||||
const movedOn = {};
|
||||
trackSessionIdlePrompt(movedOn, "PS C:\\Users\\alice> \r\n");
|
||||
assert.equal(getVisibleIdlePrompt(movedOn), "");
|
||||
|
||||
const custom = {};
|
||||
trackSessionIdlePrompt(custom, "PS C:\\Users\\alice>\r\nexit\r\n❯ ");
|
||||
assert.equal(getVisibleIdlePrompt(custom), "");
|
||||
|
||||
const fullscreen = {};
|
||||
trackSessionIdlePrompt(fullscreen, "PS C:\\Users\\alice> \x1b[?1049h\x1b[Hvim");
|
||||
assert.equal(getVisibleIdlePrompt(fullscreen), "");
|
||||
|
||||
assert.equal(getVisibleIdlePrompt({ _promptTrackTail: "" }), "");
|
||||
assert.equal(getVisibleIdlePrompt(null), "");
|
||||
});
|
||||
|
||||
test("getFreshIdlePrompt and trackSessionIdlePrompt round-trip through a real PTY-like flow", () => {
|
||||
// (1) Remote PowerShell prompt arrives — lastIdlePrompt is captured.
|
||||
const session = {};
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// runs under `with (ctx)` where bare `require` resolves to ctx.require
|
||||
// (based in electron/bridges/). Requiring here keeps the path unambiguous.
|
||||
const { emitTerminalSessionData } = require("../emitTerminalSessionData.cjs");
|
||||
const { formatSyntheticEcho } = require("../ai/shellUtils.cjs");
|
||||
const { formatSyntheticEcho, getVisibleIdlePrompt } = require("../ai/shellUtils.cjs");
|
||||
const { clearSessionFlowState } = require("../terminalFlowAck.cjs");
|
||||
const { remoteDisallowsExecChannelProbe, sessionDisallowsExtraSshChannel, ensureSessionShellKindForExec } = require("../ai/sessionShellKind.cjs");
|
||||
const defaultAiPrivacyService = require("../../shared/aiPrivacyService.cjs");
|
||||
@@ -217,7 +217,7 @@ function registerCattyExecHandlers(ctx) {
|
||||
chatSessionId,
|
||||
expectedPrompt: getFreshIdlePrompt(session),
|
||||
getReadyPrompt: typeof session._promptTrackTail === "string"
|
||||
? () => getFreshIdlePrompt(session) : undefined,
|
||||
? () => getFreshIdlePrompt(session) || getVisibleIdlePrompt(session) : undefined,
|
||||
typedInput: true,
|
||||
echoCommand: (rawCommand) => {
|
||||
const contents = electronModule?.webContents?.fromId?.(session.webContentsId);
|
||||
|
||||
@@ -329,6 +329,43 @@ test("expired local and unknown jobs do not claim a lost worker route", (t) => {
|
||||
});
|
||||
|
||||
|
||||
test("MCP exec sends on an idle PowerShell prompt hidden by a CSI erase", async () => {
|
||||
const pty = new FakePty();
|
||||
pty.write = (data) => {
|
||||
pty.writes.push(String(data));
|
||||
const marker = String(data).match(/(__NCMCP_[0-9a-z]+_[0-9a-f]+__)/i)?.[1];
|
||||
if (!marker) return;
|
||||
queueMicrotask(() => pty.emit("data", `${marker}_S\r\nok\r\n${marker}_E:0\r\n`));
|
||||
};
|
||||
const { trackSessionIdlePrompt } = require("./ai/shellUtils.cjs");
|
||||
const session = {
|
||||
protocol: "ssh",
|
||||
pty,
|
||||
shellKind: "powershell",
|
||||
_promptTrackTail: "",
|
||||
lastIdlePrompt: "",
|
||||
};
|
||||
trackSessionIdlePrompt(session, "PS C:\\Users\\alice> ggd2147483647d\x1b[20G\x1b[0J");
|
||||
const ctx = createExecHandlerTestContext({
|
||||
sessions: new Map([["ps", session]]),
|
||||
backgroundJobs: new Map(),
|
||||
});
|
||||
ctx.commandTimeoutMs = 200;
|
||||
ctx.startPtyJob = require("./ai/ptyExec.cjs").startPtyJob;
|
||||
ctx.execViaPty = require("./ai/ptyExec.cjs").execViaPty;
|
||||
ctx.getFreshIdlePrompt = require("./ai/shellUtils.cjs").getFreshIdlePrompt;
|
||||
const api = createExecHandlerApi(ctx);
|
||||
const result = await api.handleExec({
|
||||
sessionId: "ps",
|
||||
chatSessionId: "ps-chat",
|
||||
command: "Write-Output ok",
|
||||
});
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.stdout, "ok");
|
||||
assert.equal(pty.writes.length, 1);
|
||||
assert.match(pty.writes[0], /\$__NCMCP_/);
|
||||
});
|
||||
|
||||
test("MCP background readiness timeout survives job polling without PTY writes", async (t) => {
|
||||
t.mock.timers.enable({ apis: ["Date", "setTimeout"] });
|
||||
const pty = new FakePty();
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// Module-level require: code inside createExecHandlerApi runs under `with (ctx)`
|
||||
// where bare `require` resolves to ctx.require (based in electron/bridges/).
|
||||
const { clearSessionFlowState } = require("../terminalFlowAck.cjs");
|
||||
const { getVisibleIdlePrompt } = require("../ai/shellUtils.cjs");
|
||||
const { emitJobDiag } = require("../ai/jobStartupDiagnostics.cjs");
|
||||
const {
|
||||
ensureSessionShellKind,
|
||||
@@ -159,7 +160,7 @@ function createExecHandlerApi(ctx) {
|
||||
onEchoSuppressionPrime: (marker) => echoCommandToSession(session, sessionId, `${marker}_I`, { syntheticEcho: false }),
|
||||
expectedPrompt: getFreshIdlePrompt(session),
|
||||
getReadyPrompt: typeof session._promptTrackTail === "string"
|
||||
? () => getFreshIdlePrompt(session) : undefined,
|
||||
? () => getFreshIdlePrompt(session) || getVisibleIdlePrompt(session) : undefined,
|
||||
typedInput: true,
|
||||
echoCommand: (rawCommand) => echoCommandToSession(session, sessionId, rawCommand),
|
||||
chatSessionId,
|
||||
@@ -341,7 +342,7 @@ function createExecHandlerApi(ctx) {
|
||||
chatSessionId,
|
||||
expectedPrompt: getFreshIdlePrompt(session),
|
||||
getReadyPrompt: typeof session._promptTrackTail === "string"
|
||||
? () => getFreshIdlePrompt(session) : undefined,
|
||||
? () => getFreshIdlePrompt(session) || getVisibleIdlePrompt(session) : undefined,
|
||||
typedInput: true,
|
||||
echoCommand: (rawCommand) => echoCommandToSession(session, sessionId, rawCommand),
|
||||
maxBufferedChars: MAX_BACKGROUND_JOB_OUTPUT_CHARS,
|
||||
|
||||
@@ -836,6 +836,105 @@ test("sudo SFTP-page transfer reuses the elevated channel instead of dialing aga
|
||||
assert.equal(sftpClients.has("browse-sudo"), true);
|
||||
});
|
||||
|
||||
for (const [ident, sequential] of [
|
||||
["OpenSSH_9.6", false],
|
||||
["CLOUDBILITY-4.14", true],
|
||||
["TERM-SSHD", true],
|
||||
]) {
|
||||
test(`sudo transfer aliases preserve ${ident} upload concurrency`, { timeout: 15000 }, async (t) => {
|
||||
const server = await startOtpOnlyServer({ ident });
|
||||
pool.resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 });
|
||||
const sftpClients = new Map();
|
||||
sftpBridge.init({ sftpClients, sessions: new Map(), electronModule: {} });
|
||||
transferBridge.init({ sftpClients });
|
||||
t.after(async () => {
|
||||
for (const sftpId of [...sftpClients.keys()].reverse()) {
|
||||
await sftpBridge.closeSftp(null, { sftpId }).catch(() => {});
|
||||
}
|
||||
pool.discardAllTransports("test-end");
|
||||
pool.resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 });
|
||||
await server.close();
|
||||
});
|
||||
|
||||
const options = { ...BASE_OPTIONS(server.port), reuseTransport: false };
|
||||
const sender = createSender();
|
||||
const opening = sftpBridge.openSftp({ sender }, { ...options, sessionId: "sudo-owner" });
|
||||
opening.catch(() => {});
|
||||
await waitFor(() => answerOtpPrompts(sender));
|
||||
const opened = await opening;
|
||||
const owner = sftpClients.get(opened.sftpId);
|
||||
// Model an already-elevated channel. The real SSH/SFTP peer remains the
|
||||
// ordinary server or bastion above; no second sudo/authentication is needed.
|
||||
const sudoOptions = { ...BASE_OPTIONS(server.port), sudo: true };
|
||||
const endpoint = pool.buildConnectionReuseEndpoint(sudoOptions, { sftpSudo: true });
|
||||
owner.__netcattySudoMode = true;
|
||||
owner.__netcattyEndpointKey = pool.buildEndpointKey(endpoint);
|
||||
owner.__netcattyChannelAdoptionKey = pool.buildChannelAdoptionKey(endpoint);
|
||||
const firstAlias = await sftpBridge.openSftp({ sender: createSender() }, sudoOptions);
|
||||
const secondAlias = await sftpBridge.openSftp({ sender: createSender() }, sudoOptions);
|
||||
for (const alias of [firstAlias, secondAlias]) {
|
||||
const client = sftpClients.get(alias.sftpId);
|
||||
assert.equal(client.__netcattySharedChannelAlias, true);
|
||||
assert.equal(client.__netcattySudoMode, true);
|
||||
assert.equal(client.sftp, owner.sftp, "both uploads must retain the elevated channel");
|
||||
}
|
||||
|
||||
const root = fs.mkdtempSync(`${tempDirBridge.getTempFilePath("sudo-concurrency")}-`);
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const source = path.join(root, "source.bin");
|
||||
const payload = Buffer.alloc(3 * 32768 + 17, 0x5a);
|
||||
fs.writeFileSync(source, payload);
|
||||
const firstPath = "/home/otpuser/first.bin";
|
||||
const secondPath = "/home/otpuser/second.bin";
|
||||
server.files.set(firstPath, Buffer.from("original file"));
|
||||
const channel = owner.sftp;
|
||||
const originalLstat = channel.lstat.bind(channel);
|
||||
let firstTargetChecks = 0;
|
||||
let promotionStarted;
|
||||
const promotionStartedPromise = new Promise((resolve) => { promotionStarted = resolve; });
|
||||
let releasePromotion;
|
||||
const promotionGate = new Promise((resolve) => { releasePromotion = resolve; });
|
||||
channel.lstat = (remotePath, ...args) => {
|
||||
if (String(remotePath) === firstPath && ++firstTargetChecks === 2) {
|
||||
promotionStarted();
|
||||
void promotionGate.then(() => originalLstat(remotePath, ...args));
|
||||
return;
|
||||
}
|
||||
originalLstat(remotePath, ...args);
|
||||
};
|
||||
const upload = (alias, targetPath) => transferBridge.startTransfer({ sender: createSender() }, {
|
||||
transferId: `sudo-concurrency-${ident}-${path.basename(targetPath)}`,
|
||||
sourcePath: source, targetPath,
|
||||
sourceType: "local", targetType: "sftp", targetSftpId: alias.sftpId,
|
||||
totalBytes: payload.length, resumable: true,
|
||||
});
|
||||
let firstFinished = false;
|
||||
let secondFinished = false;
|
||||
const first = upload(firstAlias, firstPath).then((result) => { firstFinished = true; return result; });
|
||||
let second;
|
||||
let results;
|
||||
try {
|
||||
await promotionStartedPromise;
|
||||
second = upload(secondAlias, secondPath).then((result) => { secondFinished = true; return result; });
|
||||
await waitFor(() => secondFinished
|
||||
|| transferBridge._getSessionFastDownloadWaiterCountForTests(channel) > 0);
|
||||
assert.equal(firstFinished, false, "the first upload must still be held before publication");
|
||||
assert.equal(secondFinished, !sequential,
|
||||
sequential ? "a bastion must wait for the first promotion" : "ordinary sudo uploads must remain concurrent");
|
||||
assert.equal(transferBridge._getSessionFastDownloadWaiterCountForTests(channel), sequential ? 1 : 0);
|
||||
} finally {
|
||||
releasePromotion();
|
||||
results = await Promise.all(second ? [first, second] : [first]);
|
||||
channel.lstat = originalLstat;
|
||||
}
|
||||
for (const result of results) assert.equal(result.error, undefined, result.error);
|
||||
assert.deepEqual(server.files.get(firstPath), payload);
|
||||
assert.deepEqual(server.files.get(secondPath), payload);
|
||||
assert.equal(server.getConnectionCount(), 1, "sudo uploads must not open an unelevated connection");
|
||||
assert.equal(server.getKeyboardInteractiveRounds(), 1, "uploads must not ask for another OTP");
|
||||
});
|
||||
}
|
||||
|
||||
test("reconnecting the soft-closed owner id does not replace it with a self-alias (#3310)", async (t) => {
|
||||
const { sftpTransferSessionLeaseStore, channelAliasLeaseId } = require("./sftpTransferSessionLease.cjs");
|
||||
pool.resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 });
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { execFileSync } = require("node:child_process");
|
||||
const {
|
||||
DEFAULT_SERVER_HOST_KEY,
|
||||
SUPPORTED_SERVER_HOST_KEY,
|
||||
} = require("ssh2/lib/protocol/constants.js");
|
||||
const {
|
||||
parseDERKey,
|
||||
parseKey,
|
||||
} = require("ssh2/lib/protocol/keyParser.js");
|
||||
const {
|
||||
hostKeyBlobTypeAccepted,
|
||||
hostKeySignatureTypeAccepted,
|
||||
} = require("ssh2/lib/protocol/kex.js");
|
||||
|
||||
// Manual live check (optional; needs network and real Instabox credentials):
|
||||
// ssh -vv -p 2222 USER@ssh.instabox.sh
|
||||
// Instabox advertises only ssh-ed25519-cert-v01@openssh.com and signs the
|
||||
// exchange hash as ssh-ed25519. After this patch, host-key negotiation should
|
||||
// succeed; authentication still depends on the real certificate credentials.
|
||||
|
||||
const HOST_CERT = "ssh-ed25519-cert-v01@openssh.com";
|
||||
const HOST_CERT_ALGORITHMS = [
|
||||
HOST_CERT,
|
||||
"ecdsa-sha2-nistp256-cert-v01@openssh.com",
|
||||
"ecdsa-sha2-nistp384-cert-v01@openssh.com",
|
||||
"ecdsa-sha2-nistp521-cert-v01@openssh.com",
|
||||
"rsa-sha2-512-cert-v01@openssh.com",
|
||||
"rsa-sha2-256-cert-v01@openssh.com",
|
||||
"ssh-rsa-cert-v01@openssh.com",
|
||||
];
|
||||
|
||||
function sshKeygenSkipReason(error) {
|
||||
if (error && error.code === "ENOENT")
|
||||
return "ssh-keygen is not installed";
|
||||
return false;
|
||||
}
|
||||
|
||||
function detectSshKeygenSkip() {
|
||||
try {
|
||||
execFileSync("ssh-keygen", ["-h"], { stdio: "ignore" });
|
||||
return false;
|
||||
} catch (error) {
|
||||
return sshKeygenSkipReason(error);
|
||||
}
|
||||
}
|
||||
|
||||
const sshKeygenSkip = detectSshKeygenSkip();
|
||||
|
||||
function mintEd25519HostCert(dir) {
|
||||
const ca = path.join(dir, "ca");
|
||||
const host = path.join(dir, "host");
|
||||
execFileSync("ssh-keygen", ["-q", "-t", "ed25519", "-N", "", "-f", ca]);
|
||||
execFileSync("ssh-keygen", ["-q", "-t", "ed25519", "-N", "", "-f", host]);
|
||||
execFileSync("ssh-keygen", [
|
||||
"-q",
|
||||
"-s",
|
||||
ca,
|
||||
"-I",
|
||||
"netcatty-hostcert",
|
||||
"-h",
|
||||
"-n",
|
||||
"127.0.0.1",
|
||||
"-V",
|
||||
"+52w",
|
||||
`${host}.pub`,
|
||||
]);
|
||||
const certLine = fs.readFileSync(`${host}-cert.pub`, "utf8").trim();
|
||||
const [type, b64] = certLine.split(/\s+/);
|
||||
const privateKey = parseKey(fs.readFileSync(host));
|
||||
return {
|
||||
type,
|
||||
certBlob: Buffer.from(b64, "base64"),
|
||||
privateKeyPem: fs.readFileSync(host),
|
||||
privateKey,
|
||||
};
|
||||
}
|
||||
|
||||
test("ssh2 default and supported host key lists offer OpenSSH host certificates", () => {
|
||||
for (const algo of HOST_CERT_ALGORITHMS) {
|
||||
assert.equal(DEFAULT_SERVER_HOST_KEY.includes(algo), true, `${algo} missing from defaults`);
|
||||
assert.equal(SUPPORTED_SERVER_HOST_KEY.includes(algo), true, `${algo} missing from supported`);
|
||||
}
|
||||
assert.ok(
|
||||
DEFAULT_SERVER_HOST_KEY.indexOf(HOST_CERT) < DEFAULT_SERVER_HOST_KEY.indexOf("ssh-ed25519"),
|
||||
"host certificates should be offered before the matching plain key",
|
||||
);
|
||||
assert.equal(DEFAULT_SERVER_HOST_KEY.includes("ssh-ed25519"), true);
|
||||
assert.equal(DEFAULT_SERVER_HOST_KEY.includes("ssh-rsa"), true);
|
||||
assert.equal(
|
||||
SUPPORTED_SERVER_HOST_KEY.includes("sk-ssh-ed25519-cert-v01@openssh.com"),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
SUPPORTED_SERVER_HOST_KEY.includes("sk-ecdsa-sha2-nistp256-cert-v01@openssh.com"),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("missing ssh-keygen skips host-certificate minting instead of failing", () => {
|
||||
const missing = Object.assign(new Error("spawn ssh-keygen ENOENT"), { code: "ENOENT" });
|
||||
assert.equal(sshKeygenSkipReason(missing), "ssh-keygen is not installed");
|
||||
const present = Object.assign(new Error("usage"), { status: 1 });
|
||||
assert.equal(sshKeygenSkipReason(present), false);
|
||||
assert.equal(sshKeygenSkipReason(null), false);
|
||||
});
|
||||
|
||||
test("parseDERKey reads an OpenSSH ed25519 host certificate the way KEX does", { skip: sshKeygenSkip }, () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-hostcert-"));
|
||||
try {
|
||||
const minted = mintEd25519HostCert(dir);
|
||||
assert.equal(minted.type, HOST_CERT);
|
||||
assert.equal(minted.privateKey instanceof Error, false, minted.privateKey.message);
|
||||
const typeLen = minted.certBlob.readUInt32BE(0);
|
||||
const type = minted.certBlob.subarray(4, 4 + typeLen).toString();
|
||||
const keyMaterial = minted.certBlob.subarray(4 + typeLen);
|
||||
const parsed = parseDERKey(keyMaterial, type);
|
||||
assert.equal(parsed instanceof Error, false, parsed instanceof Error ? parsed.message : "");
|
||||
assert.equal(parsed.type, HOST_CERT);
|
||||
const message = Buffer.from("kex-exchange-hash");
|
||||
const signature = minted.privateKey.sign(message);
|
||||
assert.equal(signature instanceof Error, false, signature instanceof Error ? signature.message : "");
|
||||
assert.equal(parsed.verify(message, signature), true);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("host key signature type accepts the underlying algorithm for host certificates", () => {
|
||||
assert.equal(typeof hostKeySignatureTypeAccepted, "function");
|
||||
assert.equal(hostKeySignatureTypeAccepted(HOST_CERT, "ssh-ed25519"), true);
|
||||
assert.equal(hostKeySignatureTypeAccepted(HOST_CERT, HOST_CERT), true);
|
||||
assert.equal(hostKeySignatureTypeAccepted(HOST_CERT, "ssh-rsa"), false);
|
||||
assert.equal(
|
||||
hostKeySignatureTypeAccepted("ecdsa-sha2-nistp256-cert-v01@openssh.com", "ecdsa-sha2-nistp256"),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
hostKeySignatureTypeAccepted("rsa-sha2-256-cert-v01@openssh.com", "rsa-sha2-256"),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
hostKeySignatureTypeAccepted("rsa-sha2-256-cert-v01@openssh.com", "rsa-sha2-512"),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
hostKeySignatureTypeAccepted("rsa-sha2-256-cert-v01@openssh.com", "ssh-rsa"),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
hostKeySignatureTypeAccepted("rsa-sha2-256-cert-v00@openssh.com", "ssh-rsa"),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
hostKeySignatureTypeAccepted("rsa-sha2-512-cert-v01@openssh.com", "rsa-sha2-512"),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
hostKeySignatureTypeAccepted("rsa-sha2-512-cert-v01@openssh.com", "rsa-sha2-256"),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
hostKeySignatureTypeAccepted("rsa-sha2-512-cert-v01@openssh.com", "ssh-rsa"),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
hostKeySignatureTypeAccepted("ssh-rsa-cert-v01@openssh.com", "rsa-sha2-256"),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
hostKeySignatureTypeAccepted("ssh-rsa-cert-v01@openssh.com", "rsa-sha2-512"),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
hostKeySignatureTypeAccepted("ssh-rsa-cert-v01@openssh.com", "ssh-rsa"),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
hostKeySignatureTypeAccepted("ssh-rsa-cert-v00@openssh.com", "ssh-rsa"),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
hostKeyBlobTypeAccepted("rsa-sha2-256-cert-v01@openssh.com", "ssh-rsa-cert-v01@openssh.com"),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
hostKeyBlobTypeAccepted("rsa-sha2-512-cert-v01@openssh.com", "ssh-rsa-cert-v01@openssh.com"),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
hostKeyBlobTypeAccepted("rsa-sha2-256-cert-v01@openssh.com", "ssh-rsa"),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
hostKeyBlobTypeAccepted("rsa-sha2-512-cert-v01@openssh.com", "ssh-rsa"),
|
||||
false,
|
||||
);
|
||||
assert.equal(hostKeyBlobTypeAccepted("rsa-sha2-256", "ssh-rsa"), true);
|
||||
assert.equal(hostKeyBlobTypeAccepted("rsa-sha2-512", "ssh-rsa"), true);
|
||||
assert.equal(hostKeySignatureTypeAccepted("ssh-ed25519", "ssh-ed25519"), true);
|
||||
assert.equal(hostKeySignatureTypeAccepted("ssh-ed25519", HOST_CERT), false);
|
||||
assert.equal(hostKeySignatureTypeAccepted("rsa-sha2-256", "ssh-rsa"), false);
|
||||
assert.equal(hostKeySignatureTypeAccepted("rsa-sha2-512", "rsa-sha2-256"), false);
|
||||
});
|
||||
|
||||
function loadSsh2WithCertHostKey(pem, certBlob) {
|
||||
// ssh2.Server only accepts PEM text and then advertises privateKey.type.
|
||||
// Reload it after wrapping parseKey so this one key is presented as an
|
||||
// OpenSSH host certificate while signing still uses the embedded key.
|
||||
const keyParser = require("ssh2/lib/protocol/keyParser.js");
|
||||
const originalParseKey = keyParser.parseKey;
|
||||
keyParser.parseKey = function parseCertHostKey(data, passphrase) {
|
||||
const parsed = originalParseKey(data, passphrase);
|
||||
if (!(parsed instanceof Error) && data === pem) {
|
||||
parsed.type = HOST_CERT;
|
||||
parsed.getPublicSSH = () => certBlob;
|
||||
}
|
||||
return parsed;
|
||||
};
|
||||
const reloadIds = ["ssh2/lib/server.js", "ssh2/lib/index.js", "ssh2"];
|
||||
const resolved = reloadIds.map((id) => require.resolve(id));
|
||||
const previous = resolved.map((filename) => require.cache[filename]);
|
||||
for (const filename of resolved) delete require.cache[filename];
|
||||
const ssh2 = require("ssh2");
|
||||
for (let i = 0; i < resolved.length; i++) {
|
||||
if (previous[i]) require.cache[resolved[i]] = previous[i];
|
||||
else delete require.cache[resolved[i]];
|
||||
}
|
||||
keyParser.parseKey = originalParseKey;
|
||||
return ssh2;
|
||||
}
|
||||
|
||||
test("ssh2 client completes KEX against a cert-only ed25519 host key", { skip: sshKeygenSkip }, async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-hostcert-kex-"));
|
||||
const minted = mintEd25519HostCert(dir);
|
||||
assert.equal(minted.privateKey instanceof Error, false, minted.privateKey.message);
|
||||
const { Client, Server } = loadSsh2WithCertHostKey(minted.privateKeyPem, minted.certBlob);
|
||||
|
||||
const server = new Server({ hostKeys: [minted.privateKeyPem] }, (client) => {
|
||||
client.on("error", () => {});
|
||||
client.on("authentication", (ctx) => ctx.reject());
|
||||
});
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", resolve);
|
||||
});
|
||||
|
||||
try {
|
||||
const authError = await new Promise((resolve, reject) => {
|
||||
const client = new Client();
|
||||
const timer = setTimeout(() => {
|
||||
client.end();
|
||||
reject(new Error("timed out waiting for KEX/auth"));
|
||||
}, 10000);
|
||||
client.on("ready", () => {
|
||||
clearTimeout(timer);
|
||||
client.end();
|
||||
reject(new Error("authentication unexpectedly succeeded"));
|
||||
});
|
||||
client.on("error", (err) => {
|
||||
clearTimeout(timer);
|
||||
client.end();
|
||||
resolve(err);
|
||||
});
|
||||
client.connect({
|
||||
host: "127.0.0.1",
|
||||
port: server.address().port,
|
||||
username: "netcatty",
|
||||
password: "not-the-password",
|
||||
readyTimeout: 8000,
|
||||
});
|
||||
});
|
||||
assert.match(authError.message, /authentication/i);
|
||||
assert.doesNotMatch(
|
||||
authError.message,
|
||||
/Handshake failed|no matching host key|Wrong signature type|Unsupported OpenSSH|signature verification failed|Host key does not match/,
|
||||
);
|
||||
} finally {
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -2,10 +2,12 @@
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { createHash, generateKeyPairSync, sign } = require("node:crypto");
|
||||
const {
|
||||
parseKey,
|
||||
getOpenSSHCertSignatureAlgorithm,
|
||||
} = require("ssh2/lib/protocol/keyParser.js");
|
||||
const { convertSignature } = require("ssh2/lib/protocol/utils.js");
|
||||
|
||||
function buildSkEd25519PublicLine() {
|
||||
const type = Buffer.from("sk-ssh-ed25519@openssh.com");
|
||||
@@ -146,3 +148,89 @@ test("ssh2 agent protocol preserves SK signature framing, flags and counter", as
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function sshString(value) {
|
||||
const body = Buffer.isBuffer(value) ? value : Buffer.from(value);
|
||||
const out = Buffer.alloc(4 + body.length);
|
||||
out.writeUInt32BE(body.length, 0);
|
||||
body.copy(out, 4);
|
||||
return out;
|
||||
}
|
||||
|
||||
function opensshPublicLine(typeName, fields) {
|
||||
const blob = Buffer.concat([
|
||||
sshString(typeName),
|
||||
...fields.map((field) => sshString(field)),
|
||||
]);
|
||||
return `${typeName} ${blob.toString("base64")} netcatty@sk`;
|
||||
}
|
||||
|
||||
function skEcdsaSignature(rawSig, flags, counter) {
|
||||
const out = Buffer.alloc(4 + rawSig.length + 5);
|
||||
out.writeUInt32BE(rawSig.length, 0);
|
||||
rawSig.copy(out, 4);
|
||||
const pos = 4 + rawSig.length;
|
||||
out[pos] = flags;
|
||||
out.writeUInt32BE(counter, pos + 1);
|
||||
return out;
|
||||
}
|
||||
|
||||
test("ssh2 verifies an SK ECDSA certificate with the same P-256 signature as the plain key", () => {
|
||||
const { publicKey, privateKey } = generateKeyPairSync("ec", {
|
||||
namedCurve: "prime256v1",
|
||||
});
|
||||
const jwk = publicKey.export({ format: "jwk" });
|
||||
const point = Buffer.concat([
|
||||
Buffer.from([0x04]),
|
||||
Buffer.from(jwk.x, "base64url"),
|
||||
Buffer.from(jwk.y, "base64url"),
|
||||
]);
|
||||
const application = "ssh:";
|
||||
const plain = parseKey(opensshPublicLine("sk-ecdsa-sha2-nistp256@openssh.com", [
|
||||
"nistp256",
|
||||
point,
|
||||
application,
|
||||
]));
|
||||
const cert = parseKey(opensshPublicLine("sk-ecdsa-sha2-nistp256-cert-v01@openssh.com", [
|
||||
Buffer.alloc(16, 3),
|
||||
"nistp256",
|
||||
point,
|
||||
application,
|
||||
]));
|
||||
assert.equal(plain instanceof Error, false, plain instanceof Error ? plain.message : "");
|
||||
assert.equal(cert instanceof Error, false, cert instanceof Error ? cert.message : "");
|
||||
assert.equal(cert.type, "sk-ecdsa-sha2-nistp256-cert-v01@openssh.com");
|
||||
assert.equal(cert.getApplication(), application);
|
||||
|
||||
const message = Buffer.from("kex-exchange-hash");
|
||||
const flags = 0x01;
|
||||
const counter = 0x12345678;
|
||||
const appHash = createHash("sha256").update(application).digest();
|
||||
const dataHash = createHash("sha256").update(message).digest();
|
||||
const signedData = Buffer.alloc(appHash.length + 1 + 4 + dataHash.length);
|
||||
let offset = 0;
|
||||
appHash.copy(signedData, offset);
|
||||
offset += appHash.length;
|
||||
signedData[offset++] = flags;
|
||||
signedData.writeUInt32BE(counter, offset);
|
||||
offset += 4;
|
||||
dataHash.copy(signedData, offset);
|
||||
|
||||
const der = sign("sha256", signedData, privateKey);
|
||||
const rawSig = convertSignature(der, "ecdsa-sha2-nistp256");
|
||||
assert.ok(Buffer.isBuffer(rawSig));
|
||||
const signature = skEcdsaSignature(rawSig, flags, counter);
|
||||
|
||||
const plainResult = plain.verify(message, signature);
|
||||
assert.equal(
|
||||
plainResult,
|
||||
true,
|
||||
plainResult instanceof Error ? plainResult.message : String(plainResult),
|
||||
);
|
||||
const certResult = cert.verify(message, signature);
|
||||
assert.equal(
|
||||
certResult,
|
||||
true,
|
||||
certResult instanceof Error ? certResult.message : String(certResult),
|
||||
);
|
||||
});
|
||||
@@ -114,6 +114,13 @@ function applyLegacyAlgorithms(algorithms) {
|
||||
]),
|
||||
);
|
||||
algorithms.serverHostKey = [
|
||||
"ssh-ed25519-cert-v01@openssh.com",
|
||||
"ecdsa-sha2-nistp256-cert-v01@openssh.com",
|
||||
"ecdsa-sha2-nistp384-cert-v01@openssh.com",
|
||||
"ecdsa-sha2-nistp521-cert-v01@openssh.com",
|
||||
"rsa-sha2-512-cert-v01@openssh.com",
|
||||
"rsa-sha2-256-cert-v01@openssh.com",
|
||||
"ssh-rsa-cert-v01@openssh.com",
|
||||
"ssh-ed25519", "ecdsa-sha2-nistp256", "ecdsa-sha2-nistp384", "ecdsa-sha2-nistp521",
|
||||
"rsa-sha2-512", "rsa-sha2-256",
|
||||
"ssh-rsa", "ssh-dss",
|
||||
@@ -150,10 +157,16 @@ function applyLegacyHmacAlgorithms(algorithms) {
|
||||
}
|
||||
|
||||
// Mirror of ssh2's DEFAULT_SERVER_HOST_KEY (see ssh2/lib/protocol/constants.js)
|
||||
// minus every ecdsa-sha2-*. Used when skipEcdsaHostKey is on but legacy mode
|
||||
// is off, so the field would otherwise be undefined and ssh2 would fall back
|
||||
// to its built-in defaults (which still include ecdsa-sha2-*).
|
||||
// minus every ecdsa-sha2-* (plain keys and host certificates). Used when
|
||||
// skipEcdsaHostKey is on but legacy mode is off, so the field would otherwise
|
||||
// be undefined and ssh2 would fall back to its built-in defaults (which still
|
||||
// include ecdsa-sha2-*). Non-ECDSA host certificates stay so a cert-only
|
||||
// bastion can still be reached.
|
||||
const MODERN_SERVER_HOST_KEY_WITHOUT_ECDSA = Object.freeze([
|
||||
"ssh-ed25519-cert-v01@openssh.com",
|
||||
"rsa-sha2-512-cert-v01@openssh.com",
|
||||
"rsa-sha2-256-cert-v01@openssh.com",
|
||||
"ssh-rsa-cert-v01@openssh.com",
|
||||
"ssh-ed25519",
|
||||
"rsa-sha2-512",
|
||||
"rsa-sha2-256",
|
||||
|
||||
@@ -269,6 +269,19 @@ for (const [label, buildAlgorithms] of [
|
||||
["SSH", sshBridge.buildAlgorithms],
|
||||
["SFTP", sftpBridge.buildSftpAlgorithms],
|
||||
]) {
|
||||
test(`${label} legacy serverHostKey offers OpenSSH host certificates before plain keys`, () => {
|
||||
withAlgorithmRuntime({}, () => {
|
||||
const keys = buildAlgorithms(true).serverHostKey;
|
||||
assert.ok(keys.includes("ssh-ed25519-cert-v01@openssh.com"));
|
||||
assert.ok(keys.indexOf("ssh-ed25519-cert-v01@openssh.com") < keys.indexOf("ssh-ed25519"));
|
||||
assert.ok(keys.includes("ecdsa-sha2-nistp256-cert-v01@openssh.com"));
|
||||
assert.ok(keys.includes("rsa-sha2-512-cert-v01@openssh.com"));
|
||||
assert.ok(keys.includes("rsa-sha2-256-cert-v01@openssh.com"));
|
||||
assert.ok(keys.includes("ssh-rsa-cert-v01@openssh.com"));
|
||||
assert.ok(keys.includes("ssh-dss"));
|
||||
});
|
||||
});
|
||||
|
||||
test(`${label} skipEcdsaHostKey removes every ecdsa-sha2-* from serverHostKey (legacy on)`, () => {
|
||||
withAlgorithmRuntime({}, () => {
|
||||
const algorithms = buildAlgorithms(true, { skipEcdsaHostKey: true });
|
||||
@@ -280,6 +293,9 @@ for (const [label, buildAlgorithms] of [
|
||||
assert.ok(algorithms.serverHostKey.includes("ssh-rsa"));
|
||||
assert.ok(algorithms.serverHostKey.includes("ssh-dss"));
|
||||
assert.ok(algorithms.serverHostKey.includes("ssh-ed25519"));
|
||||
assert.ok(algorithms.serverHostKey.includes("ssh-ed25519-cert-v01@openssh.com"));
|
||||
assert.ok(algorithms.serverHostKey.includes("rsa-sha2-512-cert-v01@openssh.com"));
|
||||
assert.ok(algorithms.serverHostKey.includes("ssh-rsa-cert-v01@openssh.com"));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -293,6 +309,10 @@ for (const [label, buildAlgorithms] of [
|
||||
for (const algo of algorithms.serverHostKey) {
|
||||
assert.ok(!algo.startsWith("ecdsa-sha2-"), `${algo} should be filtered out`);
|
||||
}
|
||||
assert.ok(algorithms.serverHostKey.includes("ssh-ed25519-cert-v01@openssh.com"));
|
||||
assert.ok(algorithms.serverHostKey.includes("rsa-sha2-512-cert-v01@openssh.com"));
|
||||
assert.ok(algorithms.serverHostKey.includes("rsa-sha2-256-cert-v01@openssh.com"));
|
||||
assert.ok(algorithms.serverHostKey.includes("ssh-rsa-cert-v01@openssh.com"));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -73,6 +73,11 @@ function normalizePassword(password) {
|
||||
return typeof password === "string" ? password : "";
|
||||
}
|
||||
|
||||
// Bare CR/LF used to wake a silent serial console before "login:" appears.
|
||||
function isAutoLoginWakeInput(data) {
|
||||
return typeof data === "string" && /^[\r\n]+$/.test(data);
|
||||
}
|
||||
|
||||
function createTelnetAutoLogin(options = {}) {
|
||||
const username = normalizeUsername(options.username);
|
||||
const password = normalizePassword(options.password);
|
||||
@@ -207,7 +212,26 @@ function createTelnetAutoLogin(options = {}) {
|
||||
notifyIncomplete();
|
||||
}
|
||||
},
|
||||
handleUserInput() {
|
||||
// Silent serial consoles need a bare CR/LF to wake getty before "login:"
|
||||
// appears. While the detector is still active that wake is not a
|
||||
// takeover: keep autofill armed, leave the caller's write on the port,
|
||||
// and do not consume the one-shot continue-prompt response. A later
|
||||
// "Press RETURN to continue" must still be answered. Once the detector
|
||||
// is disabled (expiry, completion, or a prior cancel), the same Enter
|
||||
// notifies like any other user input so callers can drop a deferred
|
||||
// startup command. Any other user input still cancels.
|
||||
handleUserInput(data) {
|
||||
const wakeOnly = isAutoLoginWakeInput(data);
|
||||
if (
|
||||
wakeOnly
|
||||
&& !disabled
|
||||
&& !sentUsername
|
||||
&& !sentPassword
|
||||
&& !isUsernamePrompt(tail)
|
||||
&& !isPasswordPrompt(tail)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
disabled = true;
|
||||
clearExpiryTimer();
|
||||
notifyUserInput();
|
||||
@@ -224,6 +248,7 @@ function createTelnetAutoLogin(options = {}) {
|
||||
|
||||
module.exports = {
|
||||
createTelnetAutoLogin,
|
||||
isAutoLoginWakeInput,
|
||||
isCommandPrompt,
|
||||
isContinuePrompt,
|
||||
isPasswordPrompt,
|
||||
|
||||
@@ -224,6 +224,155 @@ test("telnet auto-login stops when the user starts typing manually", () => {
|
||||
assert.equal(cancelCount, 1);
|
||||
});
|
||||
|
||||
test("telnet auto-login stays armed when a wake-only enter arrives before any login prompt", () => {
|
||||
const writes = [];
|
||||
let cancelCount = 0;
|
||||
const autoLogin = createTelnetAutoLogin({
|
||||
username: "admin",
|
||||
password: "secret",
|
||||
write: (data) => writes.push(data),
|
||||
onUserInput: () => { cancelCount += 1; },
|
||||
});
|
||||
|
||||
// Silent serial consoles need a bare CR to wake getty before "login:" appears.
|
||||
autoLogin.handleUserInput("\r");
|
||||
autoLogin.handleText("login: ");
|
||||
autoLogin.handleText("\r\nPassword: ");
|
||||
|
||||
assert.equal(cancelCount, 0);
|
||||
assert.deepEqual(writes, ["admin\r", "secret\r"]);
|
||||
});
|
||||
|
||||
test("telnet auto-login treats a bare newline as a wake and still autofills", () => {
|
||||
const writes = [];
|
||||
let cancelCount = 0;
|
||||
const autoLogin = createTelnetAutoLogin({
|
||||
username: "admin",
|
||||
password: "secret",
|
||||
write: (data) => writes.push(data),
|
||||
onUserInput: () => { cancelCount += 1; },
|
||||
});
|
||||
|
||||
autoLogin.handleUserInput("\n");
|
||||
autoLogin.handleUserInput("\r\n");
|
||||
autoLogin.handleText("login: ");
|
||||
autoLogin.handleText("\r\nPassword: ");
|
||||
|
||||
assert.equal(cancelCount, 0);
|
||||
assert.deepEqual(writes, ["admin\r", "secret\r"]);
|
||||
});
|
||||
|
||||
test("telnet auto-login notifies when a wake enter arrives after expiry", () => {
|
||||
let cancelCount = 0;
|
||||
let timerFn = null;
|
||||
const writes = [];
|
||||
const autoLogin = createTelnetAutoLogin({
|
||||
username: "admin",
|
||||
password: "secret",
|
||||
write: (data) => writes.push(data),
|
||||
timeoutMs: 60_000,
|
||||
onUserInput: () => { cancelCount += 1; },
|
||||
setTimeout: (fn) => {
|
||||
timerFn = fn;
|
||||
return { unref() {} };
|
||||
},
|
||||
clearTimeout: () => {},
|
||||
});
|
||||
|
||||
// Quiet device: the 60s window elapses with no prompt, which disables the
|
||||
// detector without a user-input notification. Serial startup still has its
|
||||
// 65s fallback armed until this cancellation arrives.
|
||||
timerFn();
|
||||
assert.equal(cancelCount, 0);
|
||||
autoLogin.handleUserInput("\r");
|
||||
|
||||
assert.equal(cancelCount, 1);
|
||||
autoLogin.handleText("Press RETURN to continue");
|
||||
autoLogin.handleText("login: ");
|
||||
autoLogin.handleText("\r\nPassword: ");
|
||||
assert.equal(cancelCount, 1);
|
||||
assert.deepEqual(writes, []);
|
||||
});
|
||||
|
||||
test("telnet auto-login still answers a continue prompt after a blind wake enter", () => {
|
||||
const writes = [];
|
||||
let cancelCount = 0;
|
||||
const autoLogin = createTelnetAutoLogin({
|
||||
username: "admin",
|
||||
password: "secret",
|
||||
write: (data) => writes.push(data),
|
||||
onUserInput: () => { cancelCount += 1; },
|
||||
});
|
||||
|
||||
// The user's Enter woke a silent console. It must not consume the
|
||||
// detector's one-shot response when the device then asks to continue.
|
||||
autoLogin.handleUserInput("\r");
|
||||
autoLogin.handleText("Press RETURN to continue");
|
||||
autoLogin.handleText("\r\nlogin: ");
|
||||
autoLogin.handleText("\r\nPassword: ");
|
||||
|
||||
assert.equal(cancelCount, 0);
|
||||
assert.deepEqual(writes, ["\r", "admin\r", "secret\r"]);
|
||||
});
|
||||
|
||||
test("telnet auto-login still cancels when a normal character arrives before login", () => {
|
||||
const writes = [];
|
||||
let cancelCount = 0;
|
||||
const autoLogin = createTelnetAutoLogin({
|
||||
username: "admin",
|
||||
password: "secret",
|
||||
write: (data) => writes.push(data),
|
||||
onUserInput: () => { cancelCount += 1; },
|
||||
});
|
||||
|
||||
autoLogin.handleUserInput("x");
|
||||
autoLogin.handleText("login: ");
|
||||
autoLogin.handleText("\r\nPassword: ");
|
||||
|
||||
assert.equal(cancelCount, 1);
|
||||
assert.deepEqual(writes, []);
|
||||
});
|
||||
|
||||
test("telnet auto-login still cancels an enter after credentials have started", () => {
|
||||
const writes = [];
|
||||
let cancelCount = 0;
|
||||
const autoLogin = createTelnetAutoLogin({
|
||||
username: "admin",
|
||||
password: "secret",
|
||||
write: (data) => writes.push(data),
|
||||
onUserInput: () => { cancelCount += 1; },
|
||||
});
|
||||
|
||||
autoLogin.handleText("login: ");
|
||||
autoLogin.handleUserInput("\r");
|
||||
autoLogin.handleText("\r\nPassword: ");
|
||||
|
||||
assert.equal(cancelCount, 1);
|
||||
assert.deepEqual(writes, ["admin\r"]);
|
||||
});
|
||||
|
||||
test("telnet auto-login still cancels an enter when a login prompt is already in the tail", () => {
|
||||
let clock = 0;
|
||||
const writes = [];
|
||||
let cancelCount = 0;
|
||||
const autoLogin = createTelnetAutoLogin({
|
||||
username: "admin",
|
||||
password: "secret",
|
||||
write: (data) => writes.push(data),
|
||||
now: () => clock,
|
||||
timeoutMs: 60_000,
|
||||
onUserInput: () => { cancelCount += 1; },
|
||||
});
|
||||
|
||||
// After the window, the prompt is recorded but credentials are not sent.
|
||||
clock = 60_001;
|
||||
autoLogin.handleText("login: ");
|
||||
autoLogin.handleUserInput("\r");
|
||||
|
||||
assert.equal(cancelCount, 1);
|
||||
assert.deepEqual(writes, []);
|
||||
});
|
||||
|
||||
test("telnet auto-login avoids common non-prompt login text", () => {
|
||||
const writes = [];
|
||||
const autoLogin = createTelnetAutoLogin({
|
||||
|
||||
@@ -63,7 +63,7 @@ const { discoverShells } = require("./shellDiscovery.cjs");
|
||||
const { isWindowsAppExecutionAliasPath } = require("../../lib/localShell.cjs");
|
||||
const moshHandshake = require("./moshHandshake.cjs");
|
||||
const tempDirBridge = require("./tempDirBridge.cjs");
|
||||
const { createTelnetAutoLogin } = require("./telnetAutoLogin.cjs");
|
||||
const { createTelnetAutoLogin, isAutoLoginWakeInput } = require("./telnetAutoLogin.cjs");
|
||||
const telnetProtocol = require("./telnetProtocol.cjs");
|
||||
const { createPtyOutputBuffer } = require("./ptyOutputBuffer.cjs");
|
||||
const { enableTcpNoDelay } = require("./tcpNoDelay.cjs");
|
||||
@@ -1713,8 +1713,9 @@ function writeToSessionNow(payload, data, logRewrite = payload.logRewrite) {
|
||||
(session.type === 'telnet-native' || session.type === 'serial')
|
||||
&& !payload.automated
|
||||
&& !isTerminalReportSequence(data)
|
||||
&& !payload.autoLoginInputHandled
|
||||
) {
|
||||
session.autoLogin?.handleUserInput();
|
||||
session.autoLogin?.handleUserInput(data);
|
||||
}
|
||||
|
||||
// Encode keystrokes with the SAME charset the output path decodes with so
|
||||
@@ -1805,6 +1806,8 @@ function writeToSessionWithInterception(
|
||||
paste = null,
|
||||
index = 0,
|
||||
) {
|
||||
// Stays the original payload until ingress classification marks this write.
|
||||
let sessionWritePayload = payload;
|
||||
const writeWithReceipt = (nextData) => {
|
||||
if (paste && !paste.active) return;
|
||||
const current = sessions.get(payload.sessionId);
|
||||
@@ -1817,7 +1820,7 @@ function writeToSessionWithInterception(
|
||||
paste.finish(index, true);
|
||||
return;
|
||||
}
|
||||
const written = writeToSessionNow(payload, nextData, logRewrite);
|
||||
const written = writeToSessionNow(sessionWritePayload, nextData, logRewrite);
|
||||
if (paste) paste.finish(written ? index : undefined);
|
||||
};
|
||||
const bypass = payload?.sensitive === true || isTerminalReportSequence(data);
|
||||
@@ -1830,20 +1833,24 @@ function writeToSessionWithInterception(
|
||||
writeWithReceipt(data);
|
||||
return;
|
||||
}
|
||||
// Cancel the auto-login detector at input ingress, before the asynchronous
|
||||
// Classify auto-login at input ingress, before the asynchronous
|
||||
// interception pipeline: a slow interceptor would otherwise leave the
|
||||
// detector armed while the user's keystrokes are queued, letting a login
|
||||
// prompt that arrives during that wait trigger a saved-credential
|
||||
// transmission after the user has already taken over. Same guard
|
||||
// conditions as writeToSessionNow; handleUserInput is idempotent, so the
|
||||
// later call there stays a no-op.
|
||||
// conditions as writeToSessionNow. Wake-only CR/LF is not idempotent once
|
||||
// credentials start, so mark this write and do not classify those bytes
|
||||
// again when they reach writeToSessionNow. That preserved classification
|
||||
// describes the queued bytes only while they stay wake-only; writeIfCurrent
|
||||
// classifies an interceptor rewrite that is no longer wake-only.
|
||||
if (
|
||||
expectedSession
|
||||
&& (expectedSession.type === 'telnet-native' || expectedSession.type === 'serial')
|
||||
&& !payload.automated
|
||||
&& !isTerminalReportSequence(data)
|
||||
) {
|
||||
expectedSession.autoLogin?.handleUserInput();
|
||||
expectedSession.autoLogin?.handleUserInput(data);
|
||||
sessionWritePayload = { ...payload, autoLoginInputHandled: true };
|
||||
}
|
||||
const writeIfCurrent = (nextData) => {
|
||||
const current = sessions.get(payload.sessionId);
|
||||
@@ -1851,6 +1858,19 @@ function writeToSessionWithInterception(
|
||||
paste?.finish();
|
||||
return;
|
||||
}
|
||||
// An Enter-to-command macro can turn the wake CR/LF classified above
|
||||
// into ordinary text. Classify that transformed value before it is
|
||||
// written. autoLoginInputHandled stays set so writeToSessionNow does not
|
||||
// classify the same write again. A rewrite that is still only CR/LF keeps
|
||||
// the ingress classification: those bytes are not idempotent once
|
||||
// credentials have started.
|
||||
if (
|
||||
sessionWritePayload.autoLoginInputHandled
|
||||
&& !isAutoLoginWakeInput(nextData)
|
||||
&& !isTerminalReportSequence(nextData)
|
||||
) {
|
||||
current.autoLogin?.handleUserInput(nextData);
|
||||
}
|
||||
writeWithReceipt(nextData);
|
||||
};
|
||||
const write = async () => {
|
||||
|
||||
@@ -1,6 +1,32 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const terminalBridge = require("./terminalBridge.cjs");
|
||||
const { createTelnetAutoLogin } = require("./telnetAutoLogin.cjs");
|
||||
|
||||
function createSerialAutoLoginSession() {
|
||||
const writes = [];
|
||||
const events = [];
|
||||
const session = {
|
||||
type: "serial",
|
||||
protocol: "serial",
|
||||
encoding: "utf-8",
|
||||
serialPort: {
|
||||
write: (data) => {
|
||||
writes.push(typeof data === "string" ? data : Buffer.from(data).toString());
|
||||
return true;
|
||||
},
|
||||
},
|
||||
};
|
||||
session.autoLogin = createTelnetAutoLogin({
|
||||
username: "admin",
|
||||
password: "secret",
|
||||
write: (data) => {
|
||||
session.serialPort.write(data);
|
||||
},
|
||||
onUserInput: () => { events.push("cancelled"); },
|
||||
});
|
||||
return { session, writes, events };
|
||||
}
|
||||
|
||||
test("serial auto-login cancels on interactive user input but not automated writes", () => {
|
||||
let userInputs = 0;
|
||||
@@ -61,6 +87,221 @@ test("serial line-mode buffered input notification cancels auto-login", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("serial wake-only enter keeps auto-login armed and still reaches the port", () => {
|
||||
const { session, writes, events } = createSerialAutoLoginSession();
|
||||
terminalBridge.init({ sessions: new Map([["s", session]]), electronModule: {} });
|
||||
try {
|
||||
terminalBridge.writeToSession({}, { sessionId: "s", data: "\r" });
|
||||
assert.deepEqual(events, []);
|
||||
assert.deepEqual(writes, ["\r"]);
|
||||
|
||||
session.autoLogin.handleText("login: ");
|
||||
session.autoLogin.handleText("\r\nPassword: ");
|
||||
|
||||
assert.deepEqual(events, []);
|
||||
assert.deepEqual(writes, ["\r", "admin\r", "secret\r"]);
|
||||
} finally {
|
||||
terminalBridge.cleanupAllSessions();
|
||||
}
|
||||
});
|
||||
|
||||
test("serial character input before login still cancels auto-login", () => {
|
||||
const { session, writes, events } = createSerialAutoLoginSession();
|
||||
terminalBridge.init({ sessions: new Map([["s", session]]), electronModule: {} });
|
||||
try {
|
||||
terminalBridge.writeToSession({}, { sessionId: "s", data: "x" });
|
||||
session.autoLogin.handleText("login: ");
|
||||
session.autoLogin.handleText("\r\nPassword: ");
|
||||
|
||||
assert.deepEqual(events, ["cancelled"]);
|
||||
assert.deepEqual(writes, ["x"]);
|
||||
} finally {
|
||||
terminalBridge.cleanupAllSessions();
|
||||
}
|
||||
});
|
||||
|
||||
test("serial wake-only enter stays armed when an input interceptor is pending", async () => {
|
||||
const { session, writes, events } = createSerialAutoLoginSession();
|
||||
let interceptorResolved = false;
|
||||
terminalBridge.init({
|
||||
sessions: new Map([["s", session]]),
|
||||
electronModule: {},
|
||||
terminalDataPipeline: {
|
||||
has: () => true,
|
||||
interceptInput: async (sessionId, data) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
interceptorResolved = true;
|
||||
return data;
|
||||
},
|
||||
},
|
||||
});
|
||||
try {
|
||||
terminalBridge.writeToSession({}, { sessionId: "s", data: "\n" });
|
||||
assert.deepEqual(events, []);
|
||||
assert.equal(interceptorResolved, false);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 40));
|
||||
assert.equal(interceptorResolved, true);
|
||||
assert.deepEqual(writes, ["\n"]);
|
||||
|
||||
session.autoLogin.handleText("login: ");
|
||||
session.autoLogin.handleText("\r\nPassword: ");
|
||||
assert.deepEqual(events, []);
|
||||
assert.deepEqual(writes, ["\n", "admin\r", "secret\r"]);
|
||||
} finally {
|
||||
terminalBridge.cleanupAllSessions();
|
||||
}
|
||||
});
|
||||
|
||||
test("serial wake enter is not cancelled when login arrives during interceptor delay", async () => {
|
||||
const { session, writes, events } = createSerialAutoLoginSession();
|
||||
let interceptorStarted = false;
|
||||
let releaseInterceptor = () => {};
|
||||
const interceptorGate = new Promise((resolve) => {
|
||||
releaseInterceptor = resolve;
|
||||
});
|
||||
terminalBridge.init({
|
||||
sessions: new Map([["s", session]]),
|
||||
electronModule: {},
|
||||
terminalDataPipeline: {
|
||||
has: () => true,
|
||||
interceptInput: async (_sessionId, data) => {
|
||||
interceptorStarted = true;
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
await interceptorGate;
|
||||
return data;
|
||||
},
|
||||
},
|
||||
});
|
||||
try {
|
||||
terminalBridge.writeToSession({}, { sessionId: "s", data: "\r" });
|
||||
await Promise.resolve();
|
||||
assert.equal(interceptorStarted, true);
|
||||
|
||||
// Prompt arrives while the wake byte is still queued in the interceptor.
|
||||
session.autoLogin.handleText("login: ");
|
||||
assert.deepEqual(events, []);
|
||||
assert.deepEqual(writes, ["admin\r"]);
|
||||
|
||||
releaseInterceptor();
|
||||
const deadline = Date.now() + 500;
|
||||
while (writes.length < 2 && Date.now() < deadline) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
|
||||
assert.deepEqual(events, []);
|
||||
assert.deepEqual(writes, ["admin\r", "\r"]);
|
||||
|
||||
session.autoLogin.handleText("\r\nPassword: ");
|
||||
assert.deepEqual(events, []);
|
||||
assert.deepEqual(writes, ["admin\r", "\r", "secret\r"]);
|
||||
} finally {
|
||||
releaseInterceptor();
|
||||
terminalBridge.cleanupAllSessions();
|
||||
}
|
||||
});
|
||||
|
||||
test("serial interceptor that rewrites a wake enter into text cancels auto-login", async () => {
|
||||
const { session, writes, events } = createSerialAutoLoginSession();
|
||||
let interceptorStarted = false;
|
||||
terminalBridge.init({
|
||||
sessions: new Map([["s", session]]),
|
||||
electronModule: {},
|
||||
terminalDataPipeline: {
|
||||
has: () => true,
|
||||
interceptInput: async (_sessionId, data) => {
|
||||
interceptorStarted = true;
|
||||
assert.equal(data, "\r");
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
// Enter-to-command macro: the bare wake CR is no longer what is written.
|
||||
return "uptime\r";
|
||||
},
|
||||
},
|
||||
});
|
||||
try {
|
||||
terminalBridge.writeToSession({}, { sessionId: "s", data: "\r" });
|
||||
await Promise.resolve();
|
||||
assert.equal(interceptorStarted, true);
|
||||
// Ingress still sees a wake, so autofill stays armed until the rewrite lands.
|
||||
assert.deepEqual(events, []);
|
||||
assert.deepEqual(writes, []);
|
||||
|
||||
const deadline = Date.now() + 500;
|
||||
while (writes.length < 1 && Date.now() < deadline) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
|
||||
assert.deepEqual(events, ["cancelled"]);
|
||||
assert.deepEqual(writes, ["uptime\r"]);
|
||||
|
||||
session.autoLogin.handleText("login: ");
|
||||
session.autoLogin.handleText("\r\nPassword: ");
|
||||
assert.deepEqual(events, ["cancelled"]);
|
||||
assert.deepEqual(writes, ["uptime\r"]);
|
||||
} finally {
|
||||
terminalBridge.cleanupAllSessions();
|
||||
}
|
||||
});
|
||||
|
||||
test("serial interceptor that rewrites a wake enter into another wake still autofills", async () => {
|
||||
const { session, writes, events } = createSerialAutoLoginSession();
|
||||
let releaseInterceptor = () => {};
|
||||
const interceptorGate = new Promise((resolve) => {
|
||||
releaseInterceptor = resolve;
|
||||
});
|
||||
terminalBridge.init({
|
||||
sessions: new Map([["s", session]]),
|
||||
electronModule: {},
|
||||
terminalDataPipeline: {
|
||||
has: () => true,
|
||||
interceptInput: async (_sessionId, data) => {
|
||||
assert.equal(data, "\r");
|
||||
await interceptorGate;
|
||||
return "\r\n";
|
||||
},
|
||||
},
|
||||
});
|
||||
try {
|
||||
terminalBridge.writeToSession({}, { sessionId: "s", data: "\r" });
|
||||
await Promise.resolve();
|
||||
|
||||
session.autoLogin.handleText("login: ");
|
||||
assert.deepEqual(events, []);
|
||||
assert.deepEqual(writes, ["admin\r"]);
|
||||
|
||||
releaseInterceptor();
|
||||
const deadline = Date.now() + 500;
|
||||
while (writes.length < 2 && Date.now() < deadline) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
|
||||
assert.deepEqual(events, []);
|
||||
assert.deepEqual(writes, ["admin\r", "\r\n"]);
|
||||
|
||||
session.autoLogin.handleText("\r\nPassword: ");
|
||||
assert.deepEqual(events, []);
|
||||
assert.deepEqual(writes, ["admin\r", "\r\n", "secret\r"]);
|
||||
} finally {
|
||||
releaseInterceptor();
|
||||
terminalBridge.cleanupAllSessions();
|
||||
}
|
||||
});
|
||||
|
||||
test("serial line-mode notification still cancels auto-login without keystroke data", () => {
|
||||
const { session, writes, events } = createSerialAutoLoginSession();
|
||||
terminalBridge.init({ sessions: new Map([["s", session]]), electronModule: {} });
|
||||
try {
|
||||
terminalBridge.notifySessionUserInput({}, { sessionId: "s" });
|
||||
session.autoLogin.handleText("login: ");
|
||||
session.autoLogin.handleText("\r\nPassword: ");
|
||||
|
||||
assert.deepEqual(events, ["cancelled"]);
|
||||
assert.deepEqual(writes, []);
|
||||
} finally {
|
||||
terminalBridge.cleanupAllSessions();
|
||||
}
|
||||
});
|
||||
|
||||
test("serial sessions without auto-login credentials accept input normally", () => {
|
||||
const writes = [];
|
||||
const session = {
|
||||
|
||||
@@ -76,6 +76,11 @@ class TerminalTextRenderer {
|
||||
return this.toString();
|
||||
}
|
||||
|
||||
getCursorLine() {
|
||||
const line = this.lines[this.row] || [];
|
||||
return line.map((cell) => cell?.ch || " ").join("");
|
||||
}
|
||||
|
||||
toString({ includePendingClearedScreen = false, formatLine = (line) => line } = {}) {
|
||||
const lines = includePendingClearedScreen ? this.#linesWithPendingClearedScreen() : this.lines;
|
||||
return lines
|
||||
@@ -515,6 +520,89 @@ function terminalDataToPlainText(terminalData) {
|
||||
return renderer.finish();
|
||||
}
|
||||
|
||||
function terminalCursorLine(terminalData, options) {
|
||||
// Prompt tracking keeps a rolling tail, so the alternate-screen entry may
|
||||
// already have scrolled off. Seed the mode that was active at the first
|
||||
// byte still in that tail; a fresh renderer would otherwise treat TUI paint
|
||||
// as the main screen.
|
||||
const renderer = new TerminalTextRenderer({
|
||||
alternateScreenActive: options?.alternateScreenActive === true,
|
||||
});
|
||||
renderer.feed(terminalData || "");
|
||||
renderer.finish();
|
||||
// A full-screen program owns the display. The main buffer may still hold
|
||||
// the shell prompt from before the alternate screen was entered.
|
||||
if (renderer.alternateScreenActive) return "";
|
||||
return renderer.getCursorLine();
|
||||
}
|
||||
|
||||
// Alternate-screen transitions from TerminalTextRenderer, without a text
|
||||
// buffer. Callers replay the bytes a rolling tail dropped so truncation
|
||||
// cannot clear the guard.
|
||||
function alternateScreenActiveAfter(text, alternateScreenActive) {
|
||||
let active = alternateScreenActive === true;
|
||||
if (!text) return active;
|
||||
let state = "normal";
|
||||
let escapeBuffer = "";
|
||||
|
||||
for (const ch of text) {
|
||||
if (state === "esc") {
|
||||
if (ch === "[") {
|
||||
state = "csi";
|
||||
escapeBuffer = "";
|
||||
continue;
|
||||
}
|
||||
if (ch === "]") {
|
||||
state = "osc";
|
||||
continue;
|
||||
}
|
||||
if (ch === "c") active = false;
|
||||
state = "normal";
|
||||
continue;
|
||||
}
|
||||
if (state === "csi") {
|
||||
escapeBuffer += ch;
|
||||
if (CSI_FINAL_RE.test(ch)) {
|
||||
active = alternateScreenActiveFromCsi(escapeBuffer, active);
|
||||
state = "normal";
|
||||
escapeBuffer = "";
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (state === "osc") {
|
||||
if (ch === "\x07") state = "normal";
|
||||
else if (ch === "\x1b") state = "oscEsc";
|
||||
continue;
|
||||
}
|
||||
if (state === "oscEsc") {
|
||||
state = ch === "\\" ? "normal" : "osc";
|
||||
continue;
|
||||
}
|
||||
if (ch === "\x1b") {
|
||||
state = "esc";
|
||||
continue;
|
||||
}
|
||||
if (ch === "\x9b") {
|
||||
state = "csi";
|
||||
escapeBuffer = "";
|
||||
}
|
||||
}
|
||||
return active;
|
||||
}
|
||||
|
||||
function alternateScreenActiveFromCsi(sequence, active) {
|
||||
const final = sequence.at(-1);
|
||||
if (final !== "h" && final !== "l") return active;
|
||||
const params = sequence.slice(0, -1);
|
||||
if (!params.includes("?")) return active;
|
||||
for (const part of params.replace(/[?><=]/g, "").split(";")) {
|
||||
if (part === "") continue;
|
||||
const mode = Number.parseInt(part, 10);
|
||||
if (mode === 47 || mode === 1047 || mode === 1049) return final === "h";
|
||||
}
|
||||
return active;
|
||||
}
|
||||
|
||||
function terminalDataToHtmlContent(terminalData) {
|
||||
const renderer = new TerminalTextRenderer();
|
||||
renderer.feed(terminalData || "");
|
||||
@@ -531,6 +619,8 @@ module.exports = {
|
||||
createTerminalTextRenderer,
|
||||
terminalDataToHtmlContent,
|
||||
terminalDataToPlainText,
|
||||
terminalCursorLine,
|
||||
alternateScreenActiveAfter,
|
||||
};
|
||||
|
||||
function createDefaultStyle() {
|
||||
|
||||
@@ -2474,16 +2474,68 @@ async function acquireSessionFastDownloadSlot(client, transfer) {
|
||||
}
|
||||
}
|
||||
|
||||
// TERM-SSHD compatibility requires one file handle per channel. Per-file request
|
||||
// concurrency is not enough: pooled SFTP aliases share that physical channel,
|
||||
// and a second body or verification OPEN would fail even for tiny files.
|
||||
// Reuse the cancelable one-slot queue, keyed by the stream rather than its
|
||||
// wrapper client, across both directions and the complete verification phase.
|
||||
function isSingleChannelSftpClient(client) {
|
||||
return Boolean(
|
||||
client?.__netcattySingleChannelSsh
|
||||
|| client?.client?.__netcattySingleChannelSsh,
|
||||
);
|
||||
}
|
||||
|
||||
function requiresExclusiveSftpTransferChannel(client, channel) {
|
||||
// Aliases stay on one authenticated/elevated channel, even on ordinary SSH
|
||||
// servers. Only the owning peer's restriction requires serial transfers.
|
||||
const peer = client?.__netcattySharedChannelAlias
|
||||
? sftpClients?.get(client.__netcattySharedChannelOwnerId) || client
|
||||
: client;
|
||||
return isSingleChannelSftpClient(peer)
|
||||
|| getSftpTransferConcurrency(channel, DOWNLOAD_TRANSFER_CONCURRENCY) === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Some single-channel gateways cannot safely interleave SFTP metadata and
|
||||
* write requests from different files. Serialize the complete upload
|
||||
* transaction per raw SFTP stream while leaving independent connections free.
|
||||
*/
|
||||
async function acquireExclusiveSftpTransferTransaction(client, transfer) {
|
||||
if (isScpModeClient(client)) return null;
|
||||
// Preserve the normal lazy SFTP-open path. Only known single-channel peers
|
||||
// acquire the transaction slot before the upload itself opens a handle.
|
||||
const knownChannel = client?.sftp || { _client: client?.client };
|
||||
if (!requiresExclusiveSftpTransferChannel(client, knownChannel)) return null;
|
||||
await requireSftpChannel(client);
|
||||
const channel = client.sftp;
|
||||
if (transfer?._exclusiveSftpTransactionChannel === channel) return null;
|
||||
|
||||
await acquireSessionFastDownloadSlot(channel, transfer);
|
||||
transfer._exclusiveSftpTransactionChannel = channel;
|
||||
let released = false;
|
||||
return {
|
||||
client,
|
||||
channel,
|
||||
release() {
|
||||
if (released) return;
|
||||
released = true;
|
||||
if (transfer._exclusiveSftpTransactionChannel === channel) {
|
||||
transfer._exclusiveSftpTransactionChannel = null;
|
||||
}
|
||||
releaseSessionFastDownloadSlot(channel);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Single-channel compatibility permits only one transfer operation per raw
|
||||
// SFTP stream. A per-file request limit is insufficient because pooled aliases
|
||||
// share the physical channel, and even a tiny file's second OPEN or verification
|
||||
// read can fail. Reuse the cancelable one-slot queue keyed by stream rather
|
||||
// than wrapper client across both directions and complete verification.
|
||||
async function withSequentialSftpTransferSlot(client, transfer, operation) {
|
||||
if (isScpModeClient(client)) return operation();
|
||||
await requireSftpChannel(client);
|
||||
const channel = client.sftp;
|
||||
if (getSftpTransferConcurrency(channel, DOWNLOAD_TRANSFER_CONCURRENCY) !== 1) {
|
||||
if (transfer?._exclusiveSftpTransactionChannel === channel) {
|
||||
return operation();
|
||||
}
|
||||
if (!requiresExclusiveSftpTransferChannel(client, channel)) {
|
||||
return operation();
|
||||
}
|
||||
await acquireSessionFastDownloadSlot(channel, transfer);
|
||||
@@ -6625,6 +6677,7 @@ async function startTransferNow(event, payload, onProgress) {
|
||||
});
|
||||
};
|
||||
|
||||
let exclusiveSftpUploadTransaction = null;
|
||||
try {
|
||||
// Explicit 0 is a valid empty-snapshot plan (e.g. download of an empty log
|
||||
// that may grow later). Do not treat it as "size unknown" and re-stat into
|
||||
@@ -6752,6 +6805,16 @@ async function startTransferNow(event, payload, onProgress) {
|
||||
else await verify();
|
||||
}
|
||||
|
||||
// A single-channel upload holds the raw SFTP stream from directory setup
|
||||
// through stage verification, destination safety checks, promotion, and
|
||||
// timestamp restoration so directory children cannot interleave requests.
|
||||
if (sourceType === "local" && targetType === "sftp" && targetClient) {
|
||||
exclusiveSftpUploadTransaction = await acquireExclusiveSftpTransferTransaction(
|
||||
targetClient,
|
||||
transfer,
|
||||
);
|
||||
}
|
||||
|
||||
sendProgress(transfer.checkpointBytes, fileSize, { force: true });
|
||||
|
||||
if (sourceType === 'local' && targetType === 'sftp') {
|
||||
@@ -7380,6 +7443,13 @@ async function startTransferNow(event, payload, onProgress) {
|
||||
|
||||
return { transferId, totalBytes: fileSize, ...(transfer.warning ? { warning: transfer.warning } : {}) };
|
||||
} catch (err) {
|
||||
if (
|
||||
exclusiveSftpUploadTransaction
|
||||
&& err?.uncleanSftpChannel
|
||||
&& exclusiveSftpUploadTransaction.client.sftp === exclusiveSftpUploadTransaction.channel
|
||||
) {
|
||||
abandonWedgedVerificationSftpChannel(exclusiveSftpUploadTransaction.client);
|
||||
}
|
||||
const failureMessage = err?.message || String(err);
|
||||
if (
|
||||
sourceType === "local"
|
||||
@@ -7465,6 +7535,8 @@ async function startTransferNow(event, payload, onProgress) {
|
||||
sendError(err);
|
||||
}
|
||||
return { transferId, error: err.message };
|
||||
} finally {
|
||||
exclusiveSftpUploadTransaction?.release();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -233,6 +233,7 @@ async function createSequentialTransferFixture(t) {
|
||||
return {
|
||||
payload, files, sftp, handles, clients,
|
||||
rejectedOpens: () => rejectedOpens,
|
||||
openCount: () => opens,
|
||||
async start(direction, sftpId = "first", options = {}) {
|
||||
const transferId = options.transferId || crypto.randomUUID();
|
||||
const localPath = tempDirBridge.getTempFilePath(`term-concurrent-${transferId}.bin`);
|
||||
@@ -263,6 +264,61 @@ async function createSequentialTransferFixture(t) {
|
||||
};
|
||||
}
|
||||
|
||||
test("single-channel upload waits for the prior promotion before starting the next file", { timeout: 5_000 }, async (t) => {
|
||||
const fixture = await createSequentialTransferFixture(t);
|
||||
const originalLstat = fixture.sftp.lstat;
|
||||
let lstatCalls = 0;
|
||||
let promotionStarted;
|
||||
const promotionStartedPromise = new Promise((resolve) => { promotionStarted = resolve; });
|
||||
let releasePromotion;
|
||||
const promotionGate = new Promise((resolve) => { releasePromotion = resolve; });
|
||||
|
||||
fixture.sftp.lstat = (remotePath, callback) => {
|
||||
lstatCalls += 1;
|
||||
// The first two calls inspect the recovery backup and initial destination.
|
||||
// The third is the final safety check after the .part write; hold it to
|
||||
// prove that the next file cannot acquire the channel yet.
|
||||
if (lstatCalls === 3) {
|
||||
promotionStarted();
|
||||
void promotionGate.then(() => originalLstat(remotePath, callback));
|
||||
return;
|
||||
}
|
||||
originalLstat(remotePath, callback);
|
||||
};
|
||||
|
||||
const first = fixture.start("upload", "first");
|
||||
await promotionStartedPromise;
|
||||
const openCountAtPromotion = fixture.openCount();
|
||||
const second = fixture.start("upload", "alias");
|
||||
let firstResult;
|
||||
let secondResult;
|
||||
try {
|
||||
// start() first writes its local source. Wait until the second upload
|
||||
// actually queues, or incorrectly starts remote work, before asserting.
|
||||
const deadline = Date.now() + 1_000;
|
||||
while (
|
||||
transferBridge._getSessionFastDownloadWaiterCountForTests(fixture.sftp) === 0
|
||||
&& lstatCalls === 3
|
||||
&& fixture.openCount() === openCountAtPromotion
|
||||
&& Date.now() < deadline
|
||||
) {
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
assert.equal(lstatCalls, 3, "the next file must not inspect its target during prior promotion");
|
||||
assert.equal(fixture.openCount(), openCountAtPromotion, "the next file must not open a remote handle during prior promotion");
|
||||
assert.equal(transferBridge._getSessionFastDownloadWaiterCountForTests(fixture.sftp), 1,
|
||||
"the second upload must be waiting for the shared channel");
|
||||
} finally {
|
||||
releasePromotion();
|
||||
[firstResult, secondResult] = await Promise.all([first, second]);
|
||||
}
|
||||
assert.equal(firstResult.result.error, undefined);
|
||||
assert.equal(secondResult.result.error, undefined);
|
||||
assert.deepEqual(fixture.files.get(firstResult.targetPath), fixture.payload);
|
||||
assert.deepEqual(fixture.files.get(secondResult.targetPath), fixture.payload);
|
||||
assert.equal(fixture.rejectedOpens(), 0);
|
||||
});
|
||||
|
||||
for (const secondId of ["first", "alias"]) {
|
||||
test(`TERM-SSHD small downloads serialize through body and verification (${secondId})`, async (t) => {
|
||||
const fixture = await createSequentialTransferFixture(t);
|
||||
|
||||
@@ -8,7 +8,7 @@ const {
|
||||
execViaChannel,
|
||||
execViaRawPty,
|
||||
} = require("../bridges/ai/ptyExec.cjs");
|
||||
const { getFreshIdlePrompt, formatSyntheticEcho } = require("../bridges/ai/shellUtils.cjs");
|
||||
const { getFreshIdlePrompt, getVisibleIdlePrompt, formatSyntheticEcho } = require("../bridges/ai/shellUtils.cjs");
|
||||
const {
|
||||
ensureSessionShellKind,
|
||||
remoteDisallowsExecChannelProbe, sessionDisallowsExtraSshChannel, ensureSessionShellKindForExec,
|
||||
@@ -338,7 +338,7 @@ function createWorkerAiExecHandler({
|
||||
chatSessionId,
|
||||
expectedPrompt: getFreshIdlePrompt(session),
|
||||
getReadyPrompt: typeof session._promptTrackTail === "string"
|
||||
? () => getFreshIdlePrompt(session) : undefined,
|
||||
? () => getFreshIdlePrompt(session) || getVisibleIdlePrompt(session) : undefined,
|
||||
typedInput: true,
|
||||
echoCommand: (rawCommand) => {
|
||||
event?.sender?.send?.("netcatty:data", {
|
||||
@@ -567,7 +567,7 @@ function createWorkerAiJobStartHandler({
|
||||
chatSessionId,
|
||||
expectedPrompt: getFreshIdlePrompt(session),
|
||||
getReadyPrompt: typeof session._promptTrackTail === "string"
|
||||
? () => getFreshIdlePrompt(session) : undefined,
|
||||
? () => getFreshIdlePrompt(session) || getVisibleIdlePrompt(session) : undefined,
|
||||
typedInput: true,
|
||||
echoCommand: (rawCommand) => {
|
||||
event?.sender?.send?.("netcatty:data", {
|
||||
|
||||
@@ -80,6 +80,22 @@ describe('queryLocalFonts deduplication', () => {
|
||||
assert.ok(!names.includes('Microsoft YaHei UI'));
|
||||
});
|
||||
|
||||
it('recognizes SFMono Nerd Font as a monospace terminal font (#3929)', async () => {
|
||||
installMockWindow(async () => [
|
||||
{ family: 'SFMono Nerd Font' },
|
||||
{ family: 'SF Mono' },
|
||||
{ family: 'Segoe UI' },
|
||||
]);
|
||||
|
||||
const monoFonts = await getMonospaceFonts();
|
||||
const names = monoFonts.map((f) => f.name);
|
||||
|
||||
// Glued typographic family (name ID 16) and the spaced family both belong.
|
||||
assert.ok(names.includes('SFMono Nerd Font'));
|
||||
assert.ok(names.includes('SF Mono'));
|
||||
assert.ok(!names.includes('Segoe UI'));
|
||||
});
|
||||
|
||||
it('a second sequential call also reuses the resolved promise (no second API call)', async () => {
|
||||
let callCount = 0;
|
||||
installMockWindow(async () => {
|
||||
|
||||
@@ -37,6 +37,9 @@ const KNOWN_MONOSPACE_FONTS = new Set([
|
||||
'victor mono',
|
||||
'ibm plex mono',
|
||||
'sf mono',
|
||||
// Nerd Font / patched builds often use the glued typographic family
|
||||
// "SFMono Nerd Font" (name ID 16), which does not match "sf mono" (#3929).
|
||||
'sfmono',
|
||||
'operator mono',
|
||||
'input mono',
|
||||
'pragmata pro',
|
||||
|
||||
+3
-3
@@ -1,14 +1,14 @@
|
||||
{
|
||||
version = "1.1.85";
|
||||
version = "1.1.90";
|
||||
|
||||
sources = {
|
||||
x86_64-linux = {
|
||||
appImageArch = "x86_64";
|
||||
hash = "sha256-tiFQ/IGprMO9rc6nB6JWCxetdgBmGD+Zn6wKHPR8v6I=";
|
||||
hash = "sha256-oa/T3Zey0FHsH+SOt4C1H4CT6aZRJta+x4x/11EAOJE=";
|
||||
};
|
||||
aarch64-linux = {
|
||||
appImageArch = "arm64";
|
||||
hash = "sha256-LK/evHTu4wEu9TR/ULN1YDCEYlaoqVNIZ0gEhYDAL/s=";
|
||||
hash = "sha256-r5hr3+gpIGrv9XJ7PQkHCdhOd2COkhELMHlnr3xIG7A=";
|
||||
};
|
||||
};
|
||||
}
|
||||
+308
-84
@@ -1,7 +1,8 @@
|
||||
diff --git a/node_modules/ssh2/lib/agent.js b/node_modules/ssh2/lib/agent.js
|
||||
index bb495d1..32c48a0 100644
|
||||
--- a/node_modules/ssh2/lib/agent.js
|
||||
+++ b/node_modules/ssh2/lib/agent.js
|
||||
@@ -6,7 +6,7 @@
|
||||
@@ -6,7 +6,7 @@ const { resolve } = require('path');
|
||||
const { readFile } = require('fs');
|
||||
const { execFile, spawn } = require('child_process');
|
||||
|
||||
@@ -10,7 +11,7 @@ diff --git a/node_modules/ssh2/lib/agent.js b/node_modules/ssh2/lib/agent.js
|
||||
|
||||
const {
|
||||
makeBufferParser,
|
||||
@@ -655,8 +655,12 @@
|
||||
@@ -655,8 +655,12 @@ const AgentProtocol = (() => {
|
||||
// TODO: verify signature type based on key and options used
|
||||
// during initial sign request
|
||||
binaryParser.init(signature, 0);
|
||||
@@ -25,7 +26,7 @@ diff --git a/node_modules/ssh2/lib/agent.js b/node_modules/ssh2/lib/agent.js
|
||||
binaryParser.clear();
|
||||
|
||||
if (signature === undefined)
|
||||
@@ -711,7 +715,10 @@
|
||||
@@ -711,7 +715,10 @@ const AgentProtocol = (() => {
|
||||
hash: undefined,
|
||||
};
|
||||
let ctx;
|
||||
@@ -37,7 +38,7 @@ diff --git a/node_modules/ssh2/lib/agent.js b/node_modules/ssh2/lib/agent.js
|
||||
if (flagsVal & SSH_AGENT_RSA_SHA2_256) {
|
||||
ctx = 'rsa-sha2-256';
|
||||
flags.hash = 'sha256';
|
||||
@@ -721,7 +728,7 @@
|
||||
@@ -721,7 +728,7 @@ const AgentProtocol = (() => {
|
||||
}
|
||||
}
|
||||
if (ctx === undefined)
|
||||
@@ -46,7 +47,7 @@ diff --git a/node_modules/ssh2/lib/agent.js b/node_modules/ssh2/lib/agent.js
|
||||
|
||||
const req = new AgentInboundRequest(msgType, ctx);
|
||||
this[SYM_REQS].push(req);
|
||||
@@ -732,6 +739,9 @@
|
||||
@@ -732,6 +739,9 @@ const AgentProtocol = (() => {
|
||||
default: {
|
||||
const req = new AgentInboundRequest(msgType);
|
||||
this[SYM_REQS].push(req);
|
||||
@@ -56,7 +57,7 @@ diff --git a/node_modules/ssh2/lib/agent.js b/node_modules/ssh2/lib/agent.js
|
||||
this.failureReply(req);
|
||||
}
|
||||
}
|
||||
@@ -781,7 +791,11 @@
|
||||
@@ -781,7 +791,11 @@ const AgentProtocol = (() => {
|
||||
if (pubKey instanceof Error)
|
||||
throw new Error('Invalid public key argument');
|
||||
|
||||
@@ -69,7 +70,7 @@ diff --git a/node_modules/ssh2/lib/agent.js b/node_modules/ssh2/lib/agent.js
|
||||
switch (options.hash) {
|
||||
case 'sha256':
|
||||
flags = SSH_AGENT_RSA_SHA2_256;
|
||||
@@ -992,19 +1006,24 @@
|
||||
@@ -992,19 +1006,24 @@ const AgentProtocol = (() => {
|
||||
let p = 0;
|
||||
const sigFormat = req.getContext();
|
||||
const sigFormatLen = Buffer.byteLength(sigFormat);
|
||||
@@ -99,9 +100,10 @@ diff --git a/node_modules/ssh2/lib/agent.js b/node_modules/ssh2/lib/agent.js
|
||||
return respond(this, req, buf);
|
||||
}
|
||||
diff --git a/node_modules/ssh2/lib/client.js b/node_modules/ssh2/lib/client.js
|
||||
index 7291c2c..8943c9a 100644
|
||||
--- a/node_modules/ssh2/lib/client.js
|
||||
+++ b/node_modules/ssh2/lib/client.js
|
||||
@@ -2170,6 +2170,29 @@
|
||||
@@ -2170,6 +2170,29 @@ function getKeyAlgos(client, key, serverSigAlgs) {
|
||||
return [];
|
||||
}
|
||||
return [['ssh-rsa', 'sha1']];
|
||||
@@ -132,9 +134,10 @@ diff --git a/node_modules/ssh2/lib/client.js b/node_modules/ssh2/lib/client.js
|
||||
}
|
||||
|
||||
diff --git a/node_modules/ssh2/lib/protocol/Protocol.js b/node_modules/ssh2/lib/protocol/Protocol.js
|
||||
index 7302488..df0c9b3 100644
|
||||
--- a/node_modules/ssh2/lib/protocol/Protocol.js
|
||||
+++ b/node_modules/ssh2/lib/protocol/Protocol.js
|
||||
@@ -58,6 +58,7 @@
|
||||
@@ -58,6 +58,7 @@ const {
|
||||
} = require('./kex.js');
|
||||
const {
|
||||
parseKey,
|
||||
@@ -142,7 +145,7 @@ diff --git a/node_modules/ssh2/lib/protocol/Protocol.js b/node_modules/ssh2/lib/
|
||||
} = require('./keyParser.js');
|
||||
const MESSAGE_HANDLERS = require('./handlers.js');
|
||||
const {
|
||||
@@ -701,11 +702,19 @@
|
||||
@@ -701,11 +702,19 @@ class Protocol {
|
||||
if (signature === false)
|
||||
throw new Error('Error while converting handshake signature');
|
||||
|
||||
@@ -163,7 +166,7 @@ diff --git a/node_modules/ssh2/lib/protocol/Protocol.js b/node_modules/ssh2/lib/
|
||||
);
|
||||
|
||||
// TODO: simply copy from original "packet" to new `packet` to avoid
|
||||
@@ -729,13 +738,17 @@
|
||||
@@ -729,13 +738,17 @@ class Protocol {
|
||||
writeUInt32BE(packet, pubKeyLen, p += algoLen);
|
||||
packet.set(pubKey, p += 4);
|
||||
|
||||
@@ -186,7 +189,7 @@ diff --git a/node_modules/ssh2/lib/protocol/Protocol.js b/node_modules/ssh2/lib/
|
||||
|
||||
// Servers shouldn't send packet type 60 in response to signed publickey
|
||||
// attempts, but if they do, interpret as type 60.
|
||||
@@ -810,20 +823,32 @@
|
||||
@@ -810,20 +823,32 @@ class Protocol {
|
||||
if (!signature)
|
||||
throw new Error('Error while converting handshake signature');
|
||||
|
||||
@@ -225,7 +228,7 @@ diff --git a/node_modules/ssh2/lib/protocol/Protocol.js b/node_modules/ssh2/lib/
|
||||
|
||||
this._authsQueue.push('hostbased');
|
||||
|
||||
@@ -1916,7 +1941,10 @@
|
||||
@@ -1916,7 +1941,10 @@ class Protocol {
|
||||
}
|
||||
|
||||
// SSH-protoversion-softwareversion (SP comments) CR LF
|
||||
@@ -238,9 +241,10 @@ diff --git a/node_modules/ssh2/lib/protocol/Protocol.js b/node_modules/ssh2/lib/
|
||||
// TODO: optimize this by starting n bytes from the end of this._buffer instead
|
||||
// of the beginning
|
||||
diff --git a/node_modules/ssh2/lib/protocol/SFTP.js b/node_modules/ssh2/lib/protocol/SFTP.js
|
||||
index 9f33c02..9751164 100644
|
||||
--- a/node_modules/ssh2/lib/protocol/SFTP.js
|
||||
+++ b/node_modules/ssh2/lib/protocol/SFTP.js
|
||||
@@ -117,6 +117,20 @@
|
||||
@@ -117,6 +117,20 @@ const OPENSSH_MAX_PKT_LEN = 256 * 1024;
|
||||
|
||||
const bufferParser = makeBufferParser();
|
||||
|
||||
@@ -261,7 +265,7 @@ diff --git a/node_modules/ssh2/lib/protocol/SFTP.js b/node_modules/ssh2/lib/prot
|
||||
const fakeStderr = {
|
||||
readable: false,
|
||||
writable: false,
|
||||
@@ -155,6 +169,8 @@
|
||||
@@ -155,6 +169,8 @@ class SFTP extends EventEmitter {
|
||||
this._writeReqid = -1;
|
||||
this._requests = {};
|
||||
this._maxInPktLen = OPENSSH_MAX_PKT_LEN;
|
||||
@@ -270,7 +274,7 @@ diff --git a/node_modules/ssh2/lib/protocol/SFTP.js b/node_modules/ssh2/lib/prot
|
||||
this._maxOutPktLen = 34000;
|
||||
this._maxReadLen =
|
||||
(this._isOpenSSH ? OPENSSH_MAX_PKT_LEN : 34000) - PKT_RW_OVERHEAD;
|
||||
@@ -196,6 +212,53 @@
|
||||
@@ -196,6 +212,53 @@ class SFTP extends EventEmitter {
|
||||
this.emit('end');
|
||||
return;
|
||||
}
|
||||
@@ -324,7 +328,7 @@ diff --git a/node_modules/ssh2/lib/protocol/SFTP.js b/node_modules/ssh2/lib/prot
|
||||
/*
|
||||
uint32 length
|
||||
byte type
|
||||
@@ -339,7 +402,7 @@
|
||||
@@ -339,7 +402,7 @@ class SFTP extends EventEmitter {
|
||||
uint32 pflags
|
||||
ATTRS attrs
|
||||
*/
|
||||
@@ -333,7 +337,7 @@ diff --git a/node_modules/ssh2/lib/protocol/SFTP.js b/node_modules/ssh2/lib/prot
|
||||
let p = 9;
|
||||
const buf = Buffer.allocUnsafe(4 + 1 + 4 + 4 + pathLen + 4 + 4 + attrsLen);
|
||||
|
||||
@@ -349,7 +412,7 @@
|
||||
@@ -349,7 +412,7 @@ class SFTP extends EventEmitter {
|
||||
writeUInt32BE(buf, reqid, 5);
|
||||
|
||||
writeUInt32BE(buf, pathLen, p);
|
||||
@@ -342,7 +346,7 @@ diff --git a/node_modules/ssh2/lib/protocol/SFTP.js b/node_modules/ssh2/lib/prot
|
||||
writeUInt32BE(buf, flags, p += pathLen);
|
||||
writeUInt32BE(buf, attrsFlags, p += 4);
|
||||
if (attrsLen) {
|
||||
@@ -734,7 +797,7 @@
|
||||
@@ -734,7 +797,7 @@ class SFTP extends EventEmitter {
|
||||
uint32 id
|
||||
string filename
|
||||
*/
|
||||
@@ -351,7 +355,7 @@ diff --git a/node_modules/ssh2/lib/protocol/SFTP.js b/node_modules/ssh2/lib/prot
|
||||
let p = 9;
|
||||
const buf = Buffer.allocUnsafe(4 + 1 + 4 + 4 + fnameLen);
|
||||
|
||||
@@ -744,7 +807,7 @@
|
||||
@@ -744,7 +807,7 @@ class SFTP extends EventEmitter {
|
||||
writeUInt32BE(buf, reqid, 5);
|
||||
|
||||
writeUInt32BE(buf, fnameLen, p);
|
||||
@@ -360,7 +364,7 @@ diff --git a/node_modules/ssh2/lib/protocol/SFTP.js b/node_modules/ssh2/lib/prot
|
||||
|
||||
this._requests[reqid] = { cb };
|
||||
|
||||
@@ -762,8 +825,8 @@
|
||||
@@ -762,8 +825,8 @@ class SFTP extends EventEmitter {
|
||||
string oldpath
|
||||
string newpath
|
||||
*/
|
||||
@@ -371,7 +375,7 @@ diff --git a/node_modules/ssh2/lib/protocol/SFTP.js b/node_modules/ssh2/lib/prot
|
||||
let p = 9;
|
||||
const buf = Buffer.allocUnsafe(4 + 1 + 4 + 4 + oldLen + 4 + newLen);
|
||||
|
||||
@@ -773,9 +836,9 @@
|
||||
@@ -773,9 +836,9 @@ class SFTP extends EventEmitter {
|
||||
writeUInt32BE(buf, reqid, 5);
|
||||
|
||||
writeUInt32BE(buf, oldLen, p);
|
||||
@@ -383,7 +387,7 @@ diff --git a/node_modules/ssh2/lib/protocol/SFTP.js b/node_modules/ssh2/lib/prot
|
||||
|
||||
this._requests[reqid] = { cb };
|
||||
|
||||
@@ -806,7 +869,7 @@
|
||||
@@ -806,7 +869,7 @@ class SFTP extends EventEmitter {
|
||||
string path
|
||||
ATTRS attrs
|
||||
*/
|
||||
@@ -392,7 +396,7 @@ diff --git a/node_modules/ssh2/lib/protocol/SFTP.js b/node_modules/ssh2/lib/prot
|
||||
let p = 9;
|
||||
const buf = Buffer.allocUnsafe(4 + 1 + 4 + 4 + pathLen + 4 + attrsLen);
|
||||
|
||||
@@ -816,7 +879,7 @@
|
||||
@@ -816,7 +879,7 @@ class SFTP extends EventEmitter {
|
||||
writeUInt32BE(buf, reqid, 5);
|
||||
|
||||
writeUInt32BE(buf, pathLen, p);
|
||||
@@ -401,7 +405,7 @@ diff --git a/node_modules/ssh2/lib/protocol/SFTP.js b/node_modules/ssh2/lib/prot
|
||||
writeUInt32BE(buf, flags, p += pathLen);
|
||||
if (attrsLen) {
|
||||
p += 4;
|
||||
@@ -844,7 +907,7 @@
|
||||
@@ -844,7 +907,7 @@ class SFTP extends EventEmitter {
|
||||
uint32 id
|
||||
string path
|
||||
*/
|
||||
@@ -410,7 +414,7 @@ diff --git a/node_modules/ssh2/lib/protocol/SFTP.js b/node_modules/ssh2/lib/prot
|
||||
let p = 9;
|
||||
const buf = Buffer.allocUnsafe(4 + 1 + 4 + 4 + pathLen);
|
||||
|
||||
@@ -854,7 +917,7 @@
|
||||
@@ -854,7 +917,7 @@ class SFTP extends EventEmitter {
|
||||
writeUInt32BE(buf, reqid, 5);
|
||||
|
||||
writeUInt32BE(buf, pathLen, p);
|
||||
@@ -419,7 +423,7 @@ diff --git a/node_modules/ssh2/lib/protocol/SFTP.js b/node_modules/ssh2/lib/prot
|
||||
|
||||
this._requests[reqid] = { cb };
|
||||
|
||||
@@ -987,7 +1050,7 @@
|
||||
@@ -987,7 +1050,7 @@ class SFTP extends EventEmitter {
|
||||
uint32 id
|
||||
string path
|
||||
*/
|
||||
@@ -428,7 +432,7 @@ diff --git a/node_modules/ssh2/lib/protocol/SFTP.js b/node_modules/ssh2/lib/prot
|
||||
let p = 9;
|
||||
const buf = Buffer.allocUnsafe(4 + 1 + 4 + 4 + pathLen);
|
||||
|
||||
@@ -997,7 +1060,7 @@
|
||||
@@ -997,7 +1060,7 @@ class SFTP extends EventEmitter {
|
||||
writeUInt32BE(buf, reqid, 5);
|
||||
|
||||
writeUInt32BE(buf, pathLen, p);
|
||||
@@ -437,7 +441,7 @@ diff --git a/node_modules/ssh2/lib/protocol/SFTP.js b/node_modules/ssh2/lib/prot
|
||||
|
||||
this._requests[reqid] = { cb };
|
||||
|
||||
@@ -1014,7 +1077,7 @@
|
||||
@@ -1014,7 +1077,7 @@ class SFTP extends EventEmitter {
|
||||
uint32 id
|
||||
string path
|
||||
*/
|
||||
@@ -446,7 +450,7 @@ diff --git a/node_modules/ssh2/lib/protocol/SFTP.js b/node_modules/ssh2/lib/prot
|
||||
let p = 9;
|
||||
const buf = Buffer.allocUnsafe(4 + 1 + 4 + 4 + pathLen);
|
||||
|
||||
@@ -1024,7 +1087,7 @@
|
||||
@@ -1024,7 +1087,7 @@ class SFTP extends EventEmitter {
|
||||
writeUInt32BE(buf, reqid, 5);
|
||||
|
||||
writeUInt32BE(buf, pathLen, p);
|
||||
@@ -455,7 +459,7 @@ diff --git a/node_modules/ssh2/lib/protocol/SFTP.js b/node_modules/ssh2/lib/prot
|
||||
|
||||
this._requests[reqid] = { cb };
|
||||
|
||||
@@ -1041,7 +1104,7 @@
|
||||
@@ -1041,7 +1104,7 @@ class SFTP extends EventEmitter {
|
||||
uint32 id
|
||||
string path
|
||||
*/
|
||||
@@ -464,7 +468,7 @@ diff --git a/node_modules/ssh2/lib/protocol/SFTP.js b/node_modules/ssh2/lib/prot
|
||||
let p = 9;
|
||||
const buf = Buffer.allocUnsafe(4 + 1 + 4 + 4 + pathLen);
|
||||
|
||||
@@ -1051,7 +1114,7 @@
|
||||
@@ -1051,7 +1114,7 @@ class SFTP extends EventEmitter {
|
||||
writeUInt32BE(buf, reqid, 5);
|
||||
|
||||
writeUInt32BE(buf, pathLen, p);
|
||||
@@ -473,7 +477,7 @@ diff --git a/node_modules/ssh2/lib/protocol/SFTP.js b/node_modules/ssh2/lib/prot
|
||||
|
||||
this._requests[reqid] = { cb };
|
||||
|
||||
@@ -1080,7 +1143,7 @@
|
||||
@@ -1080,7 +1143,7 @@ class SFTP extends EventEmitter {
|
||||
string path
|
||||
ATTRS attrs
|
||||
*/
|
||||
@@ -482,7 +486,7 @@ diff --git a/node_modules/ssh2/lib/protocol/SFTP.js b/node_modules/ssh2/lib/prot
|
||||
let p = 9;
|
||||
const buf = Buffer.allocUnsafe(4 + 1 + 4 + 4 + pathLen + 4 + attrsLen);
|
||||
|
||||
@@ -1090,7 +1153,7 @@
|
||||
@@ -1090,7 +1153,7 @@ class SFTP extends EventEmitter {
|
||||
writeUInt32BE(buf, reqid, 5);
|
||||
|
||||
writeUInt32BE(buf, pathLen, p);
|
||||
@@ -491,7 +495,7 @@ diff --git a/node_modules/ssh2/lib/protocol/SFTP.js b/node_modules/ssh2/lib/prot
|
||||
writeUInt32BE(buf, flags, p += pathLen);
|
||||
if (attrsLen) {
|
||||
p += 4;
|
||||
@@ -1205,7 +1268,7 @@
|
||||
@@ -1205,7 +1268,7 @@ class SFTP extends EventEmitter {
|
||||
uint32 id
|
||||
string path
|
||||
*/
|
||||
@@ -500,7 +504,7 @@ diff --git a/node_modules/ssh2/lib/protocol/SFTP.js b/node_modules/ssh2/lib/prot
|
||||
let p = 9;
|
||||
const buf = Buffer.allocUnsafe(4 + 1 + 4 + 4 + pathLen);
|
||||
|
||||
@@ -1215,7 +1278,7 @@
|
||||
@@ -1215,7 +1278,7 @@ class SFTP extends EventEmitter {
|
||||
writeUInt32BE(buf, reqid, 5);
|
||||
|
||||
writeUInt32BE(buf, pathLen, p);
|
||||
@@ -509,7 +513,7 @@ diff --git a/node_modules/ssh2/lib/protocol/SFTP.js b/node_modules/ssh2/lib/prot
|
||||
|
||||
this._requests[reqid] = {
|
||||
cb: (err, names) => {
|
||||
@@ -1243,8 +1306,8 @@
|
||||
@@ -1243,8 +1306,8 @@ class SFTP extends EventEmitter {
|
||||
string linkpath
|
||||
string targetpath
|
||||
*/
|
||||
@@ -520,7 +524,7 @@ diff --git a/node_modules/ssh2/lib/protocol/SFTP.js b/node_modules/ssh2/lib/prot
|
||||
let p = 9;
|
||||
const buf = Buffer.allocUnsafe(4 + 1 + 4 + 4 + linkLen + 4 + targetLen);
|
||||
|
||||
@@ -1256,14 +1319,14 @@
|
||||
@@ -1256,14 +1319,14 @@ class SFTP extends EventEmitter {
|
||||
if (this._isOpenSSH) {
|
||||
// OpenSSH has linkpath and targetpath positions switched
|
||||
writeUInt32BE(buf, targetLen, p);
|
||||
@@ -539,7 +543,7 @@ diff --git a/node_modules/ssh2/lib/protocol/SFTP.js b/node_modules/ssh2/lib/prot
|
||||
}
|
||||
|
||||
this._requests[reqid] = { cb };
|
||||
@@ -1281,7 +1344,7 @@
|
||||
@@ -1281,7 +1344,7 @@ class SFTP extends EventEmitter {
|
||||
uint32 id
|
||||
string path
|
||||
*/
|
||||
@@ -548,7 +552,7 @@ diff --git a/node_modules/ssh2/lib/protocol/SFTP.js b/node_modules/ssh2/lib/prot
|
||||
let p = 9;
|
||||
const buf = Buffer.allocUnsafe(4 + 1 + 4 + 4 + pathLen);
|
||||
|
||||
@@ -1291,7 +1354,7 @@
|
||||
@@ -1291,7 +1354,7 @@ class SFTP extends EventEmitter {
|
||||
writeUInt32BE(buf, reqid, 5);
|
||||
|
||||
writeUInt32BE(buf, pathLen, p);
|
||||
@@ -557,7 +561,7 @@ diff --git a/node_modules/ssh2/lib/protocol/SFTP.js b/node_modules/ssh2/lib/prot
|
||||
|
||||
this._requests[reqid] = {
|
||||
cb: (err, names) => {
|
||||
@@ -1325,8 +1388,8 @@
|
||||
@@ -1325,8 +1388,8 @@ class SFTP extends EventEmitter {
|
||||
string oldpath
|
||||
string newpath
|
||||
*/
|
||||
@@ -568,7 +572,7 @@ diff --git a/node_modules/ssh2/lib/protocol/SFTP.js b/node_modules/ssh2/lib/prot
|
||||
let p = 9;
|
||||
const buf =
|
||||
Buffer.allocUnsafe(4 + 1 + 4 + 4 + 24 + 4 + oldLen + 4 + newLen);
|
||||
@@ -1337,11 +1400,11 @@
|
||||
@@ -1337,11 +1400,11 @@ class SFTP extends EventEmitter {
|
||||
writeUInt32BE(buf, reqid, 5);
|
||||
|
||||
writeUInt32BE(buf, 24, p);
|
||||
@@ -583,7 +587,7 @@ diff --git a/node_modules/ssh2/lib/protocol/SFTP.js b/node_modules/ssh2/lib/prot
|
||||
|
||||
this._requests[reqid] = { cb };
|
||||
|
||||
@@ -1364,7 +1427,7 @@
|
||||
@@ -1364,7 +1427,7 @@ class SFTP extends EventEmitter {
|
||||
string "statvfs@openssh.com"
|
||||
string path
|
||||
*/
|
||||
@@ -592,7 +596,7 @@ diff --git a/node_modules/ssh2/lib/protocol/SFTP.js b/node_modules/ssh2/lib/prot
|
||||
let p = 9;
|
||||
const buf = Buffer.allocUnsafe(4 + 1 + 4 + 4 + 19 + 4 + pathLen);
|
||||
|
||||
@@ -1374,9 +1437,9 @@
|
||||
@@ -1374,9 +1437,9 @@ class SFTP extends EventEmitter {
|
||||
writeUInt32BE(buf, reqid, 5);
|
||||
|
||||
writeUInt32BE(buf, 19, p);
|
||||
@@ -604,7 +608,7 @@ diff --git a/node_modules/ssh2/lib/protocol/SFTP.js b/node_modules/ssh2/lib/prot
|
||||
|
||||
this._requests[reqid] = { extended: 'statvfs@openssh.com', cb };
|
||||
|
||||
@@ -1411,7 +1474,7 @@
|
||||
@@ -1411,7 +1474,7 @@ class SFTP extends EventEmitter {
|
||||
writeUInt32BE(buf, reqid, 5);
|
||||
|
||||
writeUInt32BE(buf, 20, p);
|
||||
@@ -613,7 +617,7 @@ diff --git a/node_modules/ssh2/lib/protocol/SFTP.js b/node_modules/ssh2/lib/prot
|
||||
writeUInt32BE(buf, handleLen, p += 20);
|
||||
buf.set(handle, p += 4);
|
||||
|
||||
@@ -1437,8 +1500,8 @@
|
||||
@@ -1437,8 +1500,8 @@ class SFTP extends EventEmitter {
|
||||
string oldpath
|
||||
string newpath
|
||||
*/
|
||||
@@ -624,7 +628,7 @@ diff --git a/node_modules/ssh2/lib/protocol/SFTP.js b/node_modules/ssh2/lib/prot
|
||||
let p = 9;
|
||||
const buf =
|
||||
Buffer.allocUnsafe(4 + 1 + 4 + 4 + 20 + 4 + oldLen + 4 + newLen);
|
||||
@@ -1449,11 +1512,11 @@
|
||||
@@ -1449,11 +1512,11 @@ class SFTP extends EventEmitter {
|
||||
writeUInt32BE(buf, reqid, 5);
|
||||
|
||||
writeUInt32BE(buf, 20, p);
|
||||
@@ -639,7 +643,7 @@ diff --git a/node_modules/ssh2/lib/protocol/SFTP.js b/node_modules/ssh2/lib/prot
|
||||
|
||||
this._requests[reqid] = { cb };
|
||||
|
||||
@@ -1488,7 +1551,7 @@
|
||||
@@ -1488,7 +1551,7 @@ class SFTP extends EventEmitter {
|
||||
writeUInt32BE(buf, reqid, 5);
|
||||
|
||||
writeUInt32BE(buf, 17, p);
|
||||
@@ -648,7 +652,7 @@ diff --git a/node_modules/ssh2/lib/protocol/SFTP.js b/node_modules/ssh2/lib/prot
|
||||
writeUInt32BE(buf, handleLen, p += 17);
|
||||
buf.set(handle, p += 4);
|
||||
|
||||
@@ -1524,7 +1587,7 @@
|
||||
@@ -1524,7 +1587,7 @@ class SFTP extends EventEmitter {
|
||||
string path
|
||||
ATTRS attrs
|
||||
*/
|
||||
@@ -657,7 +661,7 @@ diff --git a/node_modules/ssh2/lib/protocol/SFTP.js b/node_modules/ssh2/lib/prot
|
||||
let p = 9;
|
||||
const buf =
|
||||
Buffer.allocUnsafe(4 + 1 + 4 + 4 + 20 + 4 + pathLen + 4 + attrsLen);
|
||||
@@ -1535,10 +1598,10 @@
|
||||
@@ -1535,10 +1598,10 @@ class SFTP extends EventEmitter {
|
||||
writeUInt32BE(buf, reqid, 5);
|
||||
|
||||
writeUInt32BE(buf, 20, p);
|
||||
@@ -670,7 +674,7 @@ diff --git a/node_modules/ssh2/lib/protocol/SFTP.js b/node_modules/ssh2/lib/prot
|
||||
|
||||
writeUInt32BE(buf, flags, p += pathLen);
|
||||
if (attrsLen) {
|
||||
@@ -1573,7 +1636,7 @@
|
||||
@@ -1573,7 +1636,7 @@ class SFTP extends EventEmitter {
|
||||
string "expand-path@openssh.com"
|
||||
string path
|
||||
*/
|
||||
@@ -679,7 +683,7 @@ diff --git a/node_modules/ssh2/lib/protocol/SFTP.js b/node_modules/ssh2/lib/prot
|
||||
let p = 9;
|
||||
const buf = Buffer.allocUnsafe(4 + 1 + 4 + 4 + 23 + 4 + pathLen);
|
||||
|
||||
@@ -1583,10 +1646,10 @@
|
||||
@@ -1583,10 +1646,10 @@ class SFTP extends EventEmitter {
|
||||
writeUInt32BE(buf, reqid, 5);
|
||||
|
||||
writeUInt32BE(buf, 23, p);
|
||||
@@ -692,7 +696,7 @@ diff --git a/node_modules/ssh2/lib/protocol/SFTP.js b/node_modules/ssh2/lib/prot
|
||||
|
||||
this._requests[reqid] = {
|
||||
cb: (err, names) => {
|
||||
@@ -1653,7 +1716,7 @@
|
||||
@@ -1653,7 +1716,7 @@ class SFTP extends EventEmitter {
|
||||
|
||||
writeUInt32BE(buf, 9, p);
|
||||
p += 4;
|
||||
@@ -701,7 +705,7 @@ diff --git a/node_modules/ssh2/lib/protocol/SFTP.js b/node_modules/ssh2/lib/prot
|
||||
p += 9;
|
||||
|
||||
writeUInt32BE(buf, srcHandle.length, p);
|
||||
@@ -1708,7 +1771,7 @@
|
||||
@@ -1708,7 +1771,7 @@ class SFTP extends EventEmitter {
|
||||
string username
|
||||
*/
|
||||
let p = 0;
|
||||
@@ -710,7 +714,7 @@ diff --git a/node_modules/ssh2/lib/protocol/SFTP.js b/node_modules/ssh2/lib/prot
|
||||
const buf = Buffer.allocUnsafe(
|
||||
4 + 1
|
||||
+ 4
|
||||
@@ -1728,12 +1791,12 @@
|
||||
@@ -1728,12 +1791,12 @@ class SFTP extends EventEmitter {
|
||||
|
||||
writeUInt32BE(buf, 14, p);
|
||||
p += 4;
|
||||
@@ -725,7 +729,7 @@ diff --git a/node_modules/ssh2/lib/protocol/SFTP.js b/node_modules/ssh2/lib/prot
|
||||
p += usernameLen;
|
||||
|
||||
this._requests[reqid] = {
|
||||
@@ -1806,7 +1869,7 @@
|
||||
@@ -1806,7 +1869,7 @@ class SFTP extends EventEmitter {
|
||||
|
||||
writeUInt32BE(buf, 30, p);
|
||||
p += 4;
|
||||
@@ -734,7 +738,7 @@ diff --git a/node_modules/ssh2/lib/protocol/SFTP.js b/node_modules/ssh2/lib/prot
|
||||
p += 30;
|
||||
|
||||
writeUInt32BE(buf, 4 * uids.length, p);
|
||||
@@ -1871,7 +1934,7 @@
|
||||
@@ -1871,7 +1934,7 @@ class SFTP extends EventEmitter {
|
||||
|
||||
message || (message = '');
|
||||
|
||||
@@ -743,7 +747,7 @@ diff --git a/node_modules/ssh2/lib/protocol/SFTP.js b/node_modules/ssh2/lib/prot
|
||||
let p = 9;
|
||||
const buf = Buffer.allocUnsafe(4 + 1 + 4 + 4 + 4 + msgLen + 4);
|
||||
|
||||
@@ -1884,7 +1947,7 @@
|
||||
@@ -1884,7 +1947,7 @@ class SFTP extends EventEmitter {
|
||||
writeUInt32BE(buf, msgLen, p += 4);
|
||||
p += 4;
|
||||
if (msgLen) {
|
||||
@@ -752,7 +756,7 @@ diff --git a/node_modules/ssh2/lib/protocol/SFTP.js b/node_modules/ssh2/lib/prot
|
||||
p += msgLen;
|
||||
}
|
||||
|
||||
@@ -1913,7 +1976,7 @@
|
||||
@@ -1913,7 +1976,7 @@ class SFTP extends EventEmitter {
|
||||
const dataLen = (
|
||||
isBuffer
|
||||
? data.length
|
||||
@@ -761,7 +765,7 @@ diff --git a/node_modules/ssh2/lib/protocol/SFTP.js b/node_modules/ssh2/lib/prot
|
||||
);
|
||||
let p = 9;
|
||||
const buf = Buffer.allocUnsafe(4 + 1 + 4 + 4 + dataLen);
|
||||
@@ -1927,7 +1990,7 @@
|
||||
@@ -1927,7 +1990,7 @@ class SFTP extends EventEmitter {
|
||||
if (isBuffer)
|
||||
buf.set(data, p += 4);
|
||||
else if (isUTF8)
|
||||
@@ -770,7 +774,7 @@ diff --git a/node_modules/ssh2/lib/protocol/SFTP.js b/node_modules/ssh2/lib/prot
|
||||
else
|
||||
buf.write(data, p += 4, dataLen, encoding);
|
||||
}
|
||||
@@ -1959,13 +2022,13 @@
|
||||
@@ -1959,13 +2022,13 @@ class SFTP extends EventEmitter {
|
||||
? ''
|
||||
: name.filename
|
||||
);
|
||||
@@ -786,7 +790,7 @@ diff --git a/node_modules/ssh2/lib/protocol/SFTP.js b/node_modules/ssh2/lib/prot
|
||||
|
||||
if (typeof name.attrs === 'object' && name.attrs !== null) {
|
||||
nameAttrs = attrsToBytes(name.attrs);
|
||||
@@ -2011,11 +2074,11 @@
|
||||
@@ -2011,11 +2074,11 @@ class SFTP extends EventEmitter {
|
||||
? ''
|
||||
: name.filename
|
||||
);
|
||||
@@ -800,7 +804,7 @@ diff --git a/node_modules/ssh2/lib/protocol/SFTP.js b/node_modules/ssh2/lib/prot
|
||||
p += len;
|
||||
}
|
||||
}
|
||||
@@ -2026,11 +2089,11 @@
|
||||
@@ -2026,11 +2089,11 @@ class SFTP extends EventEmitter {
|
||||
? ''
|
||||
: name.longname
|
||||
);
|
||||
@@ -814,7 +818,7 @@ diff --git a/node_modules/ssh2/lib/protocol/SFTP.js b/node_modules/ssh2/lib/prot
|
||||
p += len;
|
||||
}
|
||||
}
|
||||
@@ -2749,7 +2812,7 @@
|
||||
@@ -2749,7 +2812,7 @@ function requestLimits(sftp, cb) {
|
||||
writeUInt32BE(buf, reqid, 5);
|
||||
|
||||
writeUInt32BE(buf, 18, p);
|
||||
@@ -823,7 +827,7 @@ diff --git a/node_modules/ssh2/lib/protocol/SFTP.js b/node_modules/ssh2/lib/prot
|
||||
|
||||
sftp._requests[reqid] = { extended: 'limits@openssh.com', cb };
|
||||
|
||||
@@ -2953,18 +3016,28 @@
|
||||
@@ -2953,18 +3016,28 @@ const CLIENT_HANDLERS = {
|
||||
// spec not specifying an encoding because the specs for newer
|
||||
// versions of the protocol all explicitly specify UTF-8 for
|
||||
// filenames
|
||||
@@ -856,21 +860,41 @@ diff --git a/node_modules/ssh2/lib/protocol/SFTP.js b/node_modules/ssh2/lib/prot
|
||||
if (names !== undefined) {
|
||||
sftp._debug && sftp._debug(
|
||||
diff --git a/node_modules/ssh2/lib/protocol/constants.js b/node_modules/ssh2/lib/protocol/constants.js
|
||||
index ad77592..a249e00 100644
|
||||
--- a/node_modules/ssh2/lib/protocol/constants.js
|
||||
+++ b/node_modules/ssh2/lib/protocol/constants.js
|
||||
@@ -73,7 +73,11 @@
|
||||
@@ -71,9 +71,30 @@ const DEFAULT_SERVER_HOST_KEY = [
|
||||
];
|
||||
if (eddsaSupported)
|
||||
DEFAULT_SERVER_HOST_KEY.unshift('ssh-ed25519');
|
||||
+// OpenSSH host certificates, offered before the matching plain key so a
|
||||
+// cert-only server (for example ssh-ed25519-cert-v01@openssh.com) can
|
||||
+// negotiate. Plain keys stay as fallbacks.
|
||||
+const OPENSSH_HOST_CERT_ALGS = [
|
||||
+ 'ecdsa-sha2-nistp256-cert-v01@openssh.com',
|
||||
+ 'ecdsa-sha2-nistp384-cert-v01@openssh.com',
|
||||
+ 'ecdsa-sha2-nistp521-cert-v01@openssh.com',
|
||||
+ 'rsa-sha2-512-cert-v01@openssh.com',
|
||||
+ 'rsa-sha2-256-cert-v01@openssh.com',
|
||||
+ 'ssh-rsa-cert-v01@openssh.com',
|
||||
+];
|
||||
+if (eddsaSupported)
|
||||
+ OPENSSH_HOST_CERT_ALGS.unshift('ssh-ed25519-cert-v01@openssh.com');
|
||||
+DEFAULT_SERVER_HOST_KEY.unshift(...OPENSSH_HOST_CERT_ALGS);
|
||||
const SUPPORTED_SERVER_HOST_KEY = DEFAULT_SERVER_HOST_KEY.concat([
|
||||
'ssh-dss',
|
||||
+ // FIDO/U2F security keys (client publickey auth / rare host keys).
|
||||
+ 'sk-ecdsa-sha2-nistp256@openssh.com',
|
||||
+ 'sk-ecdsa-sha2-nistp256-cert-v01@openssh.com',
|
||||
]);
|
||||
+if (eddsaSupported)
|
||||
+if (eddsaSupported) {
|
||||
+ SUPPORTED_SERVER_HOST_KEY.push('sk-ssh-ed25519@openssh.com');
|
||||
+ SUPPORTED_SERVER_HOST_KEY.push('sk-ssh-ed25519-cert-v01@openssh.com');
|
||||
+}
|
||||
|
||||
|
||||
const canUseCipher = (() => {
|
||||
@@ -160,6 +164,7 @@
|
||||
@@ -160,6 +181,7 @@ const COMPAT = {
|
||||
DYN_RPORT_BUG: 1 << 2,
|
||||
BUG_DHGEX_LARGE: 1 << 3,
|
||||
IMPLY_RSA_SHA2_SIGALGS: 1 << 4,
|
||||
@@ -878,7 +902,7 @@ diff --git a/node_modules/ssh2/lib/protocol/constants.js b/node_modules/ssh2/lib
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
@@ -330,6 +335,7 @@
|
||||
@@ -330,6 +352,7 @@ module.exports = {
|
||||
COMPAT_CHECKS: [
|
||||
[ 'Cisco-1.25', COMPAT.BAD_DHGEX ],
|
||||
[ /^Cisco-1[.]/, COMPAT.BUG_DHGEX_LARGE ],
|
||||
@@ -887,9 +911,198 @@ diff --git a/node_modules/ssh2/lib/protocol/constants.js b/node_modules/ssh2/lib
|
||||
[ /^OpenSSH_5[.][0-9]+/, COMPAT.DYN_RPORT_BUG ],
|
||||
[ /^OpenSSH_7[.]4/, COMPAT.IMPLY_RSA_SHA2_SIGALGS ],
|
||||
diff --git a/node_modules/ssh2/lib/protocol/kex.js b/node_modules/ssh2/lib/protocol/kex.js
|
||||
index 811e631..422a198 100644
|
||||
--- a/node_modules/ssh2/lib/protocol/kex.js
|
||||
+++ b/node_modules/ssh2/lib/protocol/kex.js
|
||||
@@ -1377,8 +1377,13 @@
|
||||
@@ -30,7 +30,10 @@ const {
|
||||
createDecipher,
|
||||
MAC_INFO,
|
||||
} = require('./crypto.js');
|
||||
-const { parseDERKey } = require('./keyParser.js');
|
||||
+const {
|
||||
+ parseDERKey,
|
||||
+ getOpenSSHCertSignatureAlgorithm,
|
||||
+} = require('./keyParser.js');
|
||||
const {
|
||||
bufferFill,
|
||||
bufferParser,
|
||||
@@ -49,6 +52,68 @@ const {
|
||||
|
||||
let MESSAGE_HANDLERS;
|
||||
|
||||
+const LEGACY_RSA_HOST_CERT = /^ssh-rsa-cert-v0[01]@openssh\.com$/;
|
||||
+const RSA_SHA2_256_HOST_CERT = /^rsa-sha2-256-cert-v0[01]@openssh\.com$/;
|
||||
+const RSA_SHA2_512_HOST_CERT = /^rsa-sha2-512-cert-v0[01]@openssh\.com$/;
|
||||
+const LEGACY_RSA_HOST_SIGNATURES = new Set([
|
||||
+ 'rsa-sha2-256',
|
||||
+ 'rsa-sha2-512',
|
||||
+ 'ssh-rsa',
|
||||
+]);
|
||||
+
|
||||
+// Client KEX accepts the negotiated host-key name or, for OpenSSH host
|
||||
+// certificates, the underlying key algorithm. Real servers sign with the
|
||||
+// embedded key (ssh-ed25519, ecdsa-sha2-*, rsa-sha2-*) rather than the
|
||||
+// certificate algorithm name. OpenSSH allows any RSA signature only for the
|
||||
+// legacy ssh-rsa certificate. rsa-sha2-256/512 certificates must use the
|
||||
+// hash that was negotiated.
|
||||
+function hostKeySignatureTypeAccepted(negotiatedServerHostKey, sigType) {
|
||||
+ if (RSA_SHA2_256_HOST_CERT.test(negotiatedServerHostKey))
|
||||
+ return sigType === 'rsa-sha2-256';
|
||||
+ if (RSA_SHA2_512_HOST_CERT.test(negotiatedServerHostKey))
|
||||
+ return sigType === 'rsa-sha2-512';
|
||||
+ if (sigType === negotiatedServerHostKey)
|
||||
+ return true;
|
||||
+ if (sigType === getOpenSSHCertSignatureAlgorithm(negotiatedServerHostKey))
|
||||
+ return true;
|
||||
+ return LEGACY_RSA_HOST_CERT.test(negotiatedServerHostKey)
|
||||
+ && LEGACY_RSA_HOST_SIGNATURES.has(sigType);
|
||||
+}
|
||||
+
|
||||
+// Plain rsa-sha2-* keeps an ssh-rsa key blob. An RSA certificate negotiation
|
||||
+// is KEY_RSA_CERT: the blob must be ssh-rsa-cert-v01@openssh.com, never a
|
||||
+// plain ssh-rsa key.
|
||||
+function hostKeyBlobTypeAccepted(negotiatedServerHostKey, hostPubKeyType) {
|
||||
+ if (hostPubKeyType === negotiatedServerHostKey)
|
||||
+ return true;
|
||||
+ switch (negotiatedServerHostKey) {
|
||||
+ case 'rsa-sha2-256':
|
||||
+ case 'rsa-sha2-512':
|
||||
+ return hostPubKeyType === 'ssh-rsa';
|
||||
+ case 'rsa-sha2-256-cert-v01@openssh.com':
|
||||
+ case 'rsa-sha2-512-cert-v01@openssh.com':
|
||||
+ return hostPubKeyType === 'ssh-rsa-cert-v01@openssh.com';
|
||||
+ default:
|
||||
+ return false;
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
+function hashAlgoForHostKey(negotiatedServerHostKey, sigType) {
|
||||
+ // Negotiated RSA-SHA2 wins over the signature label so a mismatched
|
||||
+ // signature cannot select a weaker hash.
|
||||
+ if (negotiatedServerHostKey === 'rsa-sha2-256'
|
||||
+ || RSA_SHA2_256_HOST_CERT.test(negotiatedServerHostKey))
|
||||
+ return 'sha256';
|
||||
+ if (negotiatedServerHostKey === 'rsa-sha2-512'
|
||||
+ || RSA_SHA2_512_HOST_CERT.test(negotiatedServerHostKey))
|
||||
+ return 'sha512';
|
||||
+ if (sigType === 'rsa-sha2-256')
|
||||
+ return 'sha256';
|
||||
+ if (sigType === 'rsa-sha2-512')
|
||||
+ return 'sha512';
|
||||
+ return undefined;
|
||||
+}
|
||||
+
|
||||
const GEX_MIN_BITS = 2048; // RFC 8270
|
||||
const GEX_MAX_BITS = 8192; // RFC 8270
|
||||
|
||||
@@ -635,7 +700,7 @@ const createKeyExchange = (() => {
|
||||
);
|
||||
}
|
||||
|
||||
- if (sigType !== negotiated.serverHostKey) {
|
||||
+ if (!hostKeySignatureTypeAccepted(negotiated.serverHostKey, sigType)) {
|
||||
return doFatalError(
|
||||
this._protocol,
|
||||
`Wrong signature type: ${sigType}, `
|
||||
@@ -659,7 +724,10 @@ const createKeyExchange = (() => {
|
||||
);
|
||||
}
|
||||
|
||||
- if (!(sigValue = sigSSHToASN1(sigValue, sigType))) {
|
||||
+ if (!(sigValue = sigSSHToASN1(
|
||||
+ sigValue,
|
||||
+ getOpenSSHCertSignatureAlgorithm(sigType)
|
||||
+ ))) {
|
||||
return doFatalError(
|
||||
this._protocol,
|
||||
'Malformed signature',
|
||||
@@ -685,12 +753,10 @@ const createKeyExchange = (() => {
|
||||
}
|
||||
}
|
||||
|
||||
- let hashAlgo;
|
||||
- // Check if we need to override the default hash algorithm
|
||||
- switch (this.negotiated.serverHostKey) {
|
||||
- case 'rsa-sha2-256': hashAlgo = 'sha256'; break;
|
||||
- case 'rsa-sha2-512': hashAlgo = 'sha512'; break;
|
||||
- }
|
||||
+ const hashAlgo = hashAlgoForHostKey(
|
||||
+ this.negotiated.serverHostKey,
|
||||
+ sigType
|
||||
+ );
|
||||
|
||||
this._protocol._debug
|
||||
&& this._protocol._debug('Verifying signature ...');
|
||||
@@ -717,12 +783,7 @@ const createKeyExchange = (() => {
|
||||
} else {
|
||||
// Server
|
||||
|
||||
- let hashAlgo;
|
||||
- // Check if we need to override the default hash algorithm
|
||||
- switch (this.negotiated.serverHostKey) {
|
||||
- case 'rsa-sha2-256': hashAlgo = 'sha256'; break;
|
||||
- case 'rsa-sha2-512': hashAlgo = 'sha512'; break;
|
||||
- }
|
||||
+ const hashAlgo = hashAlgoForHostKey(this.negotiated.serverHostKey);
|
||||
|
||||
this._protocol._debug && this._protocol._debug(
|
||||
'Generating signature ...'
|
||||
@@ -739,7 +800,12 @@ const createKeyExchange = (() => {
|
||||
);
|
||||
}
|
||||
|
||||
- signature = convertSignature(signature, this._hostKey.type);
|
||||
+ // OpenSSH signs a host certificate with the embedded key algorithm,
|
||||
+ // not the certificate algorithm name.
|
||||
+ const sigType = getOpenSSHCertSignatureAlgorithm(
|
||||
+ this.negotiated.serverHostKey
|
||||
+ );
|
||||
+ signature = convertSignature(signature, sigType);
|
||||
if (signature === false) {
|
||||
return doFatalError(
|
||||
this._protocol,
|
||||
@@ -759,7 +825,6 @@ const createKeyExchange = (() => {
|
||||
string <method-specific data>
|
||||
string signature of H
|
||||
*/
|
||||
- const sigType = this.negotiated.serverHostKey;
|
||||
const sigTypeLen = Buffer.byteLength(sigType);
|
||||
const sigLen = 4 + sigTypeLen + 4 + signature.length;
|
||||
let p = this._protocol._packetRW.write.allocStartKEX;
|
||||
@@ -1167,22 +1232,16 @@ const createKeyExchange = (() => {
|
||||
DISCONNECT_REASON.KEY_EXCHANGE_FAILED
|
||||
);
|
||||
}
|
||||
- if (hostPubKeyType !== this.negotiated.serverHostKey) {
|
||||
- // Check if we need to make an exception
|
||||
- switch (this.negotiated.serverHostKey) {
|
||||
- case 'rsa-sha2-256':
|
||||
- case 'rsa-sha2-512':
|
||||
- if (hostPubKeyType === 'ssh-rsa')
|
||||
- break;
|
||||
- // FALLTHROUGH
|
||||
- default:
|
||||
- return doFatalError(
|
||||
- this._protocol,
|
||||
- 'Host key does not match negotiated type',
|
||||
- 'handshake',
|
||||
- DISCONNECT_REASON.KEY_EXCHANGE_FAILED
|
||||
- );
|
||||
- }
|
||||
+ if (!hostKeyBlobTypeAccepted(
|
||||
+ this.negotiated.serverHostKey,
|
||||
+ hostPubKeyType
|
||||
+ )) {
|
||||
+ return doFatalError(
|
||||
+ this._protocol,
|
||||
+ 'Host key does not match negotiated type',
|
||||
+ 'handshake',
|
||||
+ DISCONNECT_REASON.KEY_EXCHANGE_FAILED
|
||||
+ );
|
||||
}
|
||||
|
||||
this._hostKey = hostPubKey;
|
||||
@@ -1377,8 +1436,13 @@ const createKeyExchange = (() => {
|
||||
this._generator = null;
|
||||
this._minBits = GEX_MIN_BITS;
|
||||
this._prefBits = dhEstimate(this.negotiated);
|
||||
@@ -904,10 +1117,20 @@ diff --git a/node_modules/ssh2/lib/protocol/kex.js b/node_modules/ssh2/lib/proto
|
||||
this._maxBits = GEX_MAX_BITS;
|
||||
}
|
||||
start() {
|
||||
@@ -1868,6 +1932,8 @@ function trySendNEWKEYS(kex) {
|
||||
|
||||
module.exports = {
|
||||
KexInit,
|
||||
+ hostKeyBlobTypeAccepted,
|
||||
+ hostKeySignatureTypeAccepted,
|
||||
kexinit,
|
||||
onKEXPayload,
|
||||
DEFAULT_KEXINIT_CLIENT: new KexInit({
|
||||
diff --git a/node_modules/ssh2/lib/protocol/keyParser.js b/node_modules/ssh2/lib/protocol/keyParser.js
|
||||
index a276c1a..c8ff9d1 100644
|
||||
--- a/node_modules/ssh2/lib/protocol/keyParser.js
|
||||
+++ b/node_modules/ssh2/lib/protocol/keyParser.js
|
||||
@@ -35,6 +35,9 @@
|
||||
@@ -35,6 +35,9 @@ const SYM_PRIV_PEM = Symbol('Private key PEM');
|
||||
const SYM_PUB_PEM = Symbol('Public key PEM');
|
||||
const SYM_PUB_SSH = Symbol('Public key SSH');
|
||||
const SYM_DECRYPTED = Symbol('Decrypted Key');
|
||||
@@ -917,7 +1140,7 @@ diff --git a/node_modules/ssh2/lib/protocol/keyParser.js b/node_modules/ssh2/lib
|
||||
|
||||
// Create OpenSSL cipher name -> SSH cipher name conversion table
|
||||
const CIPHER_INFO_OPENSSL = Object.create(null);
|
||||
@@ -258,6 +261,55 @@
|
||||
@@ -258,6 +261,55 @@ function genOpenSSHEdPub(pub) {
|
||||
return publicKey;
|
||||
}
|
||||
|
||||
@@ -973,7 +1196,7 @@ diff --git a/node_modules/ssh2/lib/protocol/keyParser.js b/node_modules/ssh2/lib
|
||||
function genOpenSSLEdPriv(priv) {
|
||||
const asnWriter = new Ber.Writer();
|
||||
asnWriter.startSequence();
|
||||
@@ -451,6 +503,82 @@
|
||||
@@ -451,6 +503,82 @@ const BaseKey = {
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1035,7 +1258,7 @@ diff --git a/node_modules/ssh2/lib/protocol/keyParser.js b/node_modules/ssh2/lib
|
||||
+ signedData.set(dataHash, p);
|
||||
+
|
||||
+ let toVerify = rawSig;
|
||||
+ if (this.type === 'sk-ecdsa-sha2-nistp256@openssh.com') {
|
||||
+ if (getOpenSSHCertBaseType(this.type) === 'sk-ecdsa-sha2-nistp256@openssh.com') {
|
||||
+ // Convert SSH ECDSA r,s blob to ASN.1 for OpenSSL
|
||||
+ const { sigSSHToASN1 } = require('./utils.js');
|
||||
+ toVerify = sigSSHToASN1(rawSig, 'ecdsa-sha2-nistp256');
|
||||
@@ -1056,7 +1279,7 @@ diff --git a/node_modules/ssh2/lib/protocol/keyParser.js b/node_modules/ssh2/lib
|
||||
|
||||
function OpenSSH_Private(type, comment, privPEM, pubPEM, pubSSH, algo,
|
||||
decrypted) {
|
||||
@@ -463,6 +591,26 @@
|
||||
@@ -463,6 +591,26 @@ function OpenSSH_Private(type, comment, privPEM, pubPEM, pubSSH, algo,
|
||||
this[SYM_DECRYPTED] = decrypted;
|
||||
}
|
||||
OpenSSH_Private.prototype = BaseKey;
|
||||
@@ -1083,7 +1306,7 @@ diff --git a/node_modules/ssh2/lib/protocol/keyParser.js b/node_modules/ssh2/lib
|
||||
{
|
||||
const regexp = /^-----BEGIN OPENSSH PRIVATE KEY-----(?:\r\n|\n)([\s\S]+)(?:\r\n|\n)-----END OPENSSH PRIVATE KEY-----$/;
|
||||
OpenSSH_Private.parse = (str, passphrase) => {
|
||||
@@ -771,6 +919,87 @@
|
||||
@@ -771,6 +919,87 @@ OpenSSH_Private.prototype = BaseKey;
|
||||
privPEM = genOpenSSLECDSAPriv(oid, ecpub, ecpriv);
|
||||
break;
|
||||
}
|
||||
@@ -1171,7 +1394,7 @@ diff --git a/node_modules/ssh2/lib/protocol/keyParser.js b/node_modules/ssh2/lib
|
||||
default:
|
||||
return new Error(`Unsupported OpenSSH private key type: ${type}`);
|
||||
}
|
||||
@@ -1186,31 +1415,97 @@
|
||||
@@ -1186,31 +1415,97 @@ function OpenSSH_Public(type, comment, pubPEM, pubSSH, algo) {
|
||||
this[SYM_DECRYPTED] = false;
|
||||
}
|
||||
OpenSSH_Public.prototype = BaseKey;
|
||||
@@ -1281,7 +1504,7 @@ diff --git a/node_modules/ssh2/lib/protocol/keyParser.js b/node_modules/ssh2/lib
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1309,7 +1604,7 @@
|
||||
@@ -1309,7 +1604,7 @@ RFC4716_Public.prototype = BaseKey;
|
||||
}
|
||||
|
||||
|
||||
@@ -1290,7 +1513,7 @@ diff --git a/node_modules/ssh2/lib/protocol/keyParser.js b/node_modules/ssh2/lib
|
||||
if (!isSupportedKeyType(baseType))
|
||||
return new Error(`Unsupported OpenSSH public key type: ${baseType}`);
|
||||
|
||||
@@ -1317,6 +1612,10 @@
|
||||
@@ -1317,6 +1612,10 @@ function parseDER(data, baseType, comment, fullType) {
|
||||
let oid;
|
||||
let pubPEM = null;
|
||||
let pubSSH = null;
|
||||
@@ -1301,7 +1524,7 @@ diff --git a/node_modules/ssh2/lib/protocol/keyParser.js b/node_modules/ssh2/lib
|
||||
|
||||
switch (baseType) {
|
||||
case 'ssh-rsa': {
|
||||
@@ -1383,11 +1682,66 @@
|
||||
@@ -1383,11 +1682,66 @@ function parseDER(data, baseType, comment, fullType) {
|
||||
pubSSH = genOpenSSHECDSAPub(oid, ecpub);
|
||||
break;
|
||||
}
|
||||
@@ -1369,7 +1592,7 @@ diff --git a/node_modules/ssh2/lib/protocol/keyParser.js b/node_modules/ssh2/lib
|
||||
}
|
||||
|
||||
function isSupportedKeyType(type) {
|
||||
@@ -1397,8 +1751,10 @@
|
||||
@@ -1397,8 +1751,10 @@ function isSupportedKeyType(type) {
|
||||
case 'ecdsa-sha2-nistp256':
|
||||
case 'ecdsa-sha2-nistp384':
|
||||
case 'ecdsa-sha2-nistp521':
|
||||
@@ -1380,7 +1603,7 @@ diff --git a/node_modules/ssh2/lib/protocol/keyParser.js b/node_modules/ssh2/lib
|
||||
if (eddsaSupported)
|
||||
return true;
|
||||
// FALLTHROUGH
|
||||
@@ -1460,7 +1816,13 @@
|
||||
@@ -1460,7 +1816,13 @@ function parseKey(data, passphrase) {
|
||||
if (type !== undefined) {
|
||||
data = binaryKeyParser.readRaw();
|
||||
if (data !== undefined) {
|
||||
@@ -1395,11 +1618,12 @@ diff --git a/node_modules/ssh2/lib/protocol/keyParser.js b/node_modules/ssh2/lib
|
||||
// Ignore potentially useless errors in case the data was not actually
|
||||
// in the binary format
|
||||
if (ret instanceof Error)
|
||||
@@ -1479,6 +1841,7 @@
|
||||
@@ -1479,6 +1841,7 @@ function parseKey(data, passphrase) {
|
||||
module.exports = {
|
||||
isParsedKey,
|
||||
isSupportedKeyType,
|
||||
- parseDERKey: (data, type) => parseDER(data, type, '', type),
|
||||
+ getOpenSSHCertSignatureAlgorithm,
|
||||
parseDERKey: (data, type) => parseDER(data, type, '', type),
|
||||
+ parseDERKey: (data, type) => parseDER(data, getOpenSSHCertBaseType(type), '', type),
|
||||
parseKey,
|
||||
};
|
||||
@@ -128,6 +128,13 @@ test("asarUnpack no longer references removed legacy agent packages", () => {
|
||||
test("asarUnpack keeps MCP server runtime deps unpacked", () => {
|
||||
// @modelcontextprotocol/sdk is now a direct dep and the MCP server hard-requires it.
|
||||
assert.ok(config.asarUnpack.includes("node_modules/@modelcontextprotocol/sdk/**/*"));
|
||||
// externalMcpRedaction.cjs requires re2js. The launcher runs from
|
||||
// app.asar.unpacked with ELECTRON_RUN_AS_NODE, which cannot see modules
|
||||
// that exist only inside app.asar (issue #3910).
|
||||
assert.ok(
|
||||
config.asarUnpack.includes("node_modules/re2js/**/*"),
|
||||
"external MCP bridge must unpack re2js so the stdio launcher can resolve it",
|
||||
);
|
||||
});
|
||||
|
||||
test("asarUnpack keeps Cursor SDK runtime deps unpacked", () => {
|
||||
@@ -338,6 +345,26 @@ test("windows packaging includes a zip archive target", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("windows installer kills leftover Netcatty.exe before file replacement", () => {
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const installerScript = fs.readFileSync(
|
||||
path.join(__dirname, "..", config.nsis.include),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
assert.match(installerScript, /!macro customInit\b/);
|
||||
assert.match(installerScript, /#3946/);
|
||||
assert.match(installerScript, /ELECTRON_RUN_AS_NODE/);
|
||||
assert.match(
|
||||
installerScript,
|
||||
/nsExec::ExecToLog "taskkill \/F \/IM Netcatty\.exe"/,
|
||||
);
|
||||
assert.match(installerScript, /Pop \$0/);
|
||||
// /T would kill this installer while it is still a child of the UI Netcatty.exe.
|
||||
assert.doesNotMatch(installerScript, /taskkill [^\r\n]*\/T/);
|
||||
});
|
||||
|
||||
test("windows installer registers and removes Explorer folder context menu entries", () => {
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
Reference in new issue
Block a user