mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-30 10:22:33 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d746530079 |
No files matched your search
@@ -964,52 +964,8 @@ Highlight the focused split pane:
|
||||
'settings.shortcuts.category.terminal': 'Terminal',
|
||||
'settings.shortcuts.category.navigation': 'Navigation',
|
||||
'settings.shortcuts.category.app': 'App',
|
||||
'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',
|
||||
'settings.shortcuts.category.sftp': 'SFTP',
|
||||
|
||||
// Context menus / common actions
|
||||
'action.newHost': 'New Host',
|
||||
@@ -1235,24 +1191,6 @@ Highlight the focused split pane:
|
||||
'deepLink.jms.invalid': 'Invalid JumpServer link',
|
||||
'deepLink.jms.unsupported': 'Unsupported JumpServer protocol: {{protocol}}',
|
||||
'settings.tab.plugins': 'Plugins',
|
||||
"settings.plugins.manager.installed": "Installed plugins",
|
||||
"settings.plugins.manager.import": "Import .ncpkg",
|
||||
"settings.plugins.manager.preview": "Developer preview. Imported packages remain disabled until you review and enable them.",
|
||||
"settings.plugins.manager.empty": "Import a local plugin package to get started.",
|
||||
"settings.plugins.manager.enabled": "Enabled",
|
||||
"settings.plugins.manager.disabled": "Disabled",
|
||||
"settings.plugins.manager.enable": "Enable",
|
||||
"settings.plugins.manager.disable": "Disable",
|
||||
"settings.plugins.manager.remove": "Uninstall",
|
||||
"settings.plugins.manager.removeNote": "Uninstall removes package code and local KV data. User-owned settings, secrets and sync sidecars are retained.",
|
||||
"settings.plugins.manager.permissions": "Declared permissions",
|
||||
"settings.plugins.manager.permissionNote": "Read from the installed manifest. Declarations are not grants; sensitive operations still require host approval.",
|
||||
"settings.plugins.manager.noPermissions": "No privileged permissions declared.",
|
||||
"settings.plugins.manager.required": "Required",
|
||||
"settings.plugins.manager.optional": "Optional",
|
||||
"settings.plugins.manager.browser": "Browser runtime: privileged access goes through host permission brokers.",
|
||||
"settings.plugins.manager.advanced": "Advanced runtime: full Node filesystem and network authority. Broker permissions cannot restrict this access. Development preview only.",
|
||||
"settings.plugins.manager.data": "Local KV stays on this device. Only non-secret settings explicitly marked for sync participate in encrypted cloud sidecars. Packages and permission grants do not sync.",
|
||||
'settings.plugins.title': 'Plugin settings',
|
||||
'settings.plugins.description': 'Native, validated settings contributed by development plugins.',
|
||||
'settings.plugins.loading': 'Loading plugin settings…',
|
||||
|
||||
@@ -90,15 +90,6 @@ 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',
|
||||
@@ -289,14 +280,6 @@ 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',
|
||||
@@ -711,9 +694,6 @@ 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,32 +84,6 @@ 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',
|
||||
@@ -145,65 +119,6 @@ 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.',
|
||||
@@ -1235,24 +1150,6 @@ Resalta el panel dividido enfocado:
|
||||
'deepLink.jms.invalid': 'Enlace JumpServer inválido',
|
||||
'deepLink.jms.unsupported': 'Protocolo JumpServer no compatible: {{protocol}}',
|
||||
'settings.tab.plugins': 'Plugins',
|
||||
"settings.plugins.manager.installed": "Plugins instalados",
|
||||
"settings.plugins.manager.import": "Importar .ncpkg",
|
||||
"settings.plugins.manager.preview": "Vista previa para desarrolladores. Los paquetes importados permanecen desactivados hasta que los revises y actives.",
|
||||
"settings.plugins.manager.empty": "Importa un paquete local para empezar.",
|
||||
"settings.plugins.manager.enabled": "Activado",
|
||||
"settings.plugins.manager.disabled": "Desactivado",
|
||||
"settings.plugins.manager.enable": "Activar",
|
||||
"settings.plugins.manager.disable": "Desactivar",
|
||||
"settings.plugins.manager.remove": "Desinstalar",
|
||||
"settings.plugins.manager.removeNote": "La desinstalación elimina el código y los datos KV locales. Se conservan los ajustes del usuario, los secretos y los datos auxiliares de sincronización.",
|
||||
"settings.plugins.manager.permissions": "Permisos declarados",
|
||||
"settings.plugins.manager.permissionNote": "Obtenidos del manifiesto instalado. Las declaraciones no son autorizaciones; las operaciones sensibles requieren aprobación del anfitrión.",
|
||||
"settings.plugins.manager.noPermissions": "No se han declarado permisos privilegiados.",
|
||||
"settings.plugins.manager.required": "Obligatorio",
|
||||
"settings.plugins.manager.optional": "Opcional",
|
||||
"settings.plugins.manager.browser": "Entorno de navegador: el acceso privilegiado pasa por los controles de permisos del anfitrión.",
|
||||
"settings.plugins.manager.advanced": "Entorno avanzado: acceso completo de Node al sistema de archivos y a la red. Los permisos del intermediario no restringen este acceso. Solo para desarrollo.",
|
||||
"settings.plugins.manager.data": "Los datos KV permanecen en este dispositivo. Solo los ajustes no secretos marcados para sincronización participan en los datos auxiliares cifrados. Los paquetes y las autorizaciones no se sincronizan.",
|
||||
'settings.plugins.title': 'Configuración de plugins',
|
||||
'settings.plugins.description': 'Ajustes nativos y validados que aportan los plugins de desarrollo.',
|
||||
'settings.plugins.loading': 'Cargando ajustes del plugin…',
|
||||
|
||||
@@ -33,9 +33,6 @@ 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.',
|
||||
@@ -215,20 +212,6 @@ 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,7 +219,6 @@ 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',
|
||||
@@ -676,45 +675,6 @@ 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,18 +1044,6 @@ 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',
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
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`,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -1130,24 +1130,6 @@ export const ruCoreMessages: Messages = {
|
||||
'deepLink.jms.unsupported': 'Неподдерживаемый протокол JumpServer: {{protocol}}',
|
||||
|
||||
'settings.tab.plugins': 'Плагины',
|
||||
"settings.plugins.manager.installed": "Установленные плагины",
|
||||
"settings.plugins.manager.import": "Импорт .ncpkg",
|
||||
"settings.plugins.manager.preview": "Предварительная версия для разработчиков. Импортированные пакеты отключены до проверки и включения.",
|
||||
"settings.plugins.manager.empty": "Импортируйте локальный пакет плагина.",
|
||||
"settings.plugins.manager.enabled": "Включён",
|
||||
"settings.plugins.manager.disabled": "Отключён",
|
||||
"settings.plugins.manager.enable": "Включить",
|
||||
"settings.plugins.manager.disable": "Отключить",
|
||||
"settings.plugins.manager.remove": "Удалить",
|
||||
"settings.plugins.manager.removeNote": "Удаление очищает код пакета и локальные данные KV. Настройки пользователя, секреты и данные синхронизации сохраняются.",
|
||||
"settings.plugins.manager.permissions": "Заявленные разрешения",
|
||||
"settings.plugins.manager.permissionNote": "Из установленного манифеста. Объявление не является разрешением; чувствительные операции требуют одобрения приложения.",
|
||||
"settings.plugins.manager.noPermissions": "Привилегированные разрешения не заявлены.",
|
||||
"settings.plugins.manager.required": "Обязательно",
|
||||
"settings.plugins.manager.optional": "Необязательно",
|
||||
"settings.plugins.manager.browser": "Браузерная среда: привилегированный доступ проходит через проверку разрешений приложения.",
|
||||
"settings.plugins.manager.advanced": "Расширенная среда: полный доступ Node к файловой системе и сети. Разрешения посредника не ограничивают этот доступ. Только для разработки.",
|
||||
"settings.plugins.manager.data": "Данные KV остаются на устройстве. Синхронизируются только явно отмеченные несекретные настройки в зашифрованных данных. Пакеты и разрешения не синхронизируются.",
|
||||
'settings.plugins.title': 'Настройки плагинов',
|
||||
'settings.plugins.description': 'Проверенные нативные настройки от плагинов разработки.',
|
||||
'settings.plugins.loading': 'Загрузка настроек плагинов…',
|
||||
|
||||
@@ -18,19 +18,12 @@ const LOCALIZED_SETTINGS_LOCALES = [
|
||||
|
||||
const APP_LOCK_LOCALES = [
|
||||
{ name: "en", messages: en },
|
||||
{ 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,
|
||||
{ name: "ru", messages: ru },
|
||||
];
|
||||
|
||||
test("localized settings include names for every default shortcut", () => {
|
||||
for (const locale of ALL_SETTINGS_LOCALES) {
|
||||
for (const locale of LOCALIZED_SETTINGS_LOCALES) {
|
||||
const missing = DEFAULT_KEY_BINDINGS
|
||||
.map((binding) => `settings.shortcuts.binding.${binding.id}`)
|
||||
.filter((key) => !locale.messages[key]);
|
||||
|
||||
@@ -1150,24 +1150,6 @@ Vaults 标签保持扁平,不使用这块背景。
|
||||
'qs.localShells': '本地 Shell',
|
||||
'qs.default': '默认',
|
||||
'settings.tab.plugins': '插件',
|
||||
"settings.plugins.manager.installed": "已安装插件",
|
||||
"settings.plugins.manager.import": "导入 .ncpkg",
|
||||
"settings.plugins.manager.preview": "开发者预览。导入的插件保持停用,请检查后再启用。",
|
||||
"settings.plugins.manager.empty": "导入本地插件包以开始使用。",
|
||||
"settings.plugins.manager.enabled": "已启用",
|
||||
"settings.plugins.manager.disabled": "已停用",
|
||||
"settings.plugins.manager.enable": "启用",
|
||||
"settings.plugins.manager.disable": "停用",
|
||||
"settings.plugins.manager.remove": "卸载",
|
||||
"settings.plugins.manager.removeNote": "卸载会移除插件代码和本地 KV 数据,保留用户设置、秘密存储和同步 sidecar。",
|
||||
"settings.plugins.manager.permissions": "声明的权限",
|
||||
"settings.plugins.manager.permissionNote": "来自已安装的 manifest。声明不等于授权,敏感操作仍须通过宿主批准。",
|
||||
"settings.plugins.manager.noPermissions": "未声明特权权限。",
|
||||
"settings.plugins.manager.required": "必需",
|
||||
"settings.plugins.manager.optional": "可选",
|
||||
"settings.plugins.manager.browser": "浏览器运行时:特权访问通过宿主权限代理。",
|
||||
"settings.plugins.manager.advanced": "高级运行时:拥有完整 Node 文件系统和网络能力,代理权限无法限制这些访问。仅供开发预览。",
|
||||
"settings.plugins.manager.data": "本地 KV 仅保留在此设备。只有明确标记为同步的非秘密设置进入加密云 sidecar。插件包和权限授权不参与同步。",
|
||||
'settings.plugins.title': '插件设置',
|
||||
'settings.plugins.description': '由开发插件提供、经 Netcatty 原生组件验证的设置。',
|
||||
'settings.plugins.loading': '正在加载插件设置…',
|
||||
|
||||
@@ -1153,24 +1153,6 @@ Vaults 分頁維持扁平,不使用這塊背景。
|
||||
'qs.default': '預設',
|
||||
|
||||
'settings.tab.plugins': '外掛程式',
|
||||
"settings.plugins.manager.installed": "已安裝插件",
|
||||
"settings.plugins.manager.import": "導入 .ncpkg",
|
||||
"settings.plugins.manager.preview": "開發者預覽。導入的插件保持停用,請檢查後再啟用。",
|
||||
"settings.plugins.manager.empty": "導入本地插件包以開始使用。",
|
||||
"settings.plugins.manager.enabled": "已啟用",
|
||||
"settings.plugins.manager.disabled": "已停用",
|
||||
"settings.plugins.manager.enable": "啟用",
|
||||
"settings.plugins.manager.disable": "停用",
|
||||
"settings.plugins.manager.remove": "卸載",
|
||||
"settings.plugins.manager.removeNote": "卸載會移除插件代碼和本地 KV 數據,保留用戶設置、秘密存儲和同步 sidecar。",
|
||||
"settings.plugins.manager.permissions": "聲明的權限",
|
||||
"settings.plugins.manager.permissionNote": "來自已安裝的 manifest。聲明不等於授權,敏感操作仍須通過宿主批准。",
|
||||
"settings.plugins.manager.noPermissions": "未聲明特權權限。",
|
||||
"settings.plugins.manager.required": "必需",
|
||||
"settings.plugins.manager.optional": "可選",
|
||||
"settings.plugins.manager.browser": "瀏覽器運行時:特權訪問通過宿主權限代理。",
|
||||
"settings.plugins.manager.advanced": "高級運行時:擁有完整 Node 文件系統和網絡能力,代理權限無法限制這些訪問。僅供開發預覽。",
|
||||
"settings.plugins.manager.data": "本地 KV 僅保留在此設備。只有明確標記為同步的非秘密設置進入加密雲 sidecar。插件包和權限授權不參與同步。",
|
||||
'settings.plugins.title': '外掛程式設定',
|
||||
'settings.plugins.description': '由開發外掛程式提供、經 Netcatty 原生元件驗證的設定。',
|
||||
'settings.plugins.loading': '正在載入外掛程式設定…',
|
||||
|
||||
@@ -80,76 +80,6 @@ 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,14 +11,7 @@ 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;
|
||||
@@ -38,10 +31,9 @@ export async function retainLeavingTabAfterEviction(input: {
|
||||
}
|
||||
}
|
||||
if (!input.stillWantsRetention()) return;
|
||||
const keepCount = input.resolveKeepCount?.() ?? input.keepCount;
|
||||
if (
|
||||
completed
|
||||
&& terminalHiddenRendererStore.leavingSlotStillFits(input.sessionId, keepCount)
|
||||
&& terminalHiddenRendererStore.leavingSlotStillFits(input.sessionId, input.keepCount)
|
||||
) {
|
||||
input.softHide();
|
||||
return;
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
|
||||
export function usePluginManager() {
|
||||
const [plugins, setPlugins] = useState<NetcattyInstalledPlugin[]>([]);
|
||||
const [available, setAvailable] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const generation = useRef(0);
|
||||
const mutation = useRef(false);
|
||||
const refresh = useCallback(async () => {
|
||||
const current = ++generation.current;
|
||||
const bridge = netcattyBridge.get();
|
||||
const status = await bridge?.getPluginRuntimeStatus?.();
|
||||
const list = status?.available ? await bridge?.listPlugins?.() ?? [] : [];
|
||||
if (current !== generation.current) return;
|
||||
setAvailable(status?.available === true);
|
||||
setPlugins(list);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const update = () => { void refresh().catch((cause) => setError(String(cause))); };
|
||||
update();
|
||||
const unsubscribe = netcattyBridge.get()?.onPluginContributionsChanged?.(update);
|
||||
const refreshGeneration = generation;
|
||||
return () => { refreshGeneration.current++; unsubscribe?.(); };
|
||||
}, [refresh]);
|
||||
const run = useCallback(async (action: (bridge: NetcattyBridge) => Promise<unknown>) => {
|
||||
if (mutation.current) return;
|
||||
mutation.current = true;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try { await action(netcattyBridge.require()); }
|
||||
catch (cause) { setError(cause instanceof Error ? cause.message : String(cause)); }
|
||||
finally {
|
||||
try { await refresh(); } catch (cause) { setError(String(cause)); }
|
||||
mutation.current = false;
|
||||
setBusy(false);
|
||||
}
|
||||
}, [refresh]);
|
||||
return {
|
||||
plugins, available, busy, error,
|
||||
importPackage: () => run(async (bridge) => {
|
||||
if (!bridge.selectFile || !bridge.installPluginPackage) throw new Error('Plugin import unavailable');
|
||||
const path = await bridge.selectFile('Import plugin package', undefined, [{ name: 'Netcatty plugin', extensions: ['ncpkg'] }]);
|
||||
if (path) await bridge.installPluginPackage(path, { enable: false });
|
||||
}),
|
||||
setEnabled: (id: string, enabled: boolean) => run(async (bridge) => {
|
||||
if (!bridge.setPluginEnabled) throw new Error('Plugin management unavailable');
|
||||
return bridge.setPluginEnabled(id, enabled);
|
||||
}),
|
||||
uninstall: (id: string) => run(async (bridge) => {
|
||||
if (!bridge.uninstallPlugin) throw new Error('Plugin management unavailable');
|
||||
return bridge.uninstallPlugin(id);
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -523,8 +523,7 @@ const SettingsPageContent: React.FC<{ settings: SettingsState; appLock?: AppLock
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
{/* 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">
|
||||
<div className="flex-1 h-full flex flex-col min-h-0 bg-muted/10">
|
||||
{mountedTabs.has("application") && (
|
||||
<SettingsLazyTab value="application">
|
||||
<LazySettingsApplicationTab
|
||||
|
||||
+1
-15
@@ -2355,14 +2355,8 @@ 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) {
|
||||
const retainedKeepCount = statusRef.current === "connected"
|
||||
? resolveHibernateKeepRendererCount(terminalSettingsRef.current)
|
||||
: 0;
|
||||
if (statusRef.current === "disconnected" || !(retainedKeepCount > 0)) {
|
||||
if (statusRef.current === "disconnected") {
|
||||
upgradeSoftHiddenRuntimeToHibernate();
|
||||
}
|
||||
return;
|
||||
@@ -2393,13 +2387,6 @@ 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
|
||||
@@ -4769,7 +4756,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
isSearchOpen,
|
||||
hibernateEnabled: hibernateEnabled,
|
||||
hibernateDelayMs: resolveTerminalHibernateDelayMs(terminalSettings),
|
||||
hibernateKeepRendererCount: resolveHibernateKeepRendererCount(terminalSettings),
|
||||
fileTransferActive: hibernateFileTransferActive,
|
||||
hibernatedRef,
|
||||
softHiddenRef,
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import React from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { I18nProvider } from '../../../application/i18n/I18nProvider';
|
||||
import { PluginPackageDetails } from './PluginPackageManager';
|
||||
|
||||
test('package detail renders manifest resources as text and explains advanced authority', () => {
|
||||
const plugin = {
|
||||
id: 'com.example.synthetic', enabled: false, activeVersion: '1.0.0',
|
||||
archiveSha256: 'a'.repeat(64),
|
||||
manifest: {
|
||||
manifestVersion: 1, id: 'com.example.synthetic', name: 'synthetic', version: '1.0.0', publisher: 'example',
|
||||
engines: { netcatty: '>=1.0.0 <2.0.0', api: '0.1.0-internal' },
|
||||
main: { node: 'index.js' },
|
||||
permissions: { required: [{ permission: 'network', resources: ['https://example.com'], reason: '<script>attack()</script>' }] },
|
||||
},
|
||||
runtime: { status: 'stopped', kind: null, lastError: null, quarantinedAt: null },
|
||||
} as NetcattyInstalledPlugin;
|
||||
const html = renderToStaticMarkup(<I18nProvider locale="en"><PluginPackageDetails plugin={plugin} /></I18nProvider>);
|
||||
assert.match(html, /full Node filesystem and network authority/);
|
||||
assert.match(html, /https:\/\/example.com/);
|
||||
assert.match(html, /<script>/);
|
||||
assert.doesNotMatch(html, /<script>/);
|
||||
assert.match(html, /Declarations are not grants/);
|
||||
assert.match(html, /aaaaaaaaaaaaaaaa/);
|
||||
});
|
||||
@@ -1,72 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Package, Upload } from 'lucide-react';
|
||||
import { usePluginManager } from '../../../application/state/usePluginManager';
|
||||
import { useI18n } from '../../../application/i18n/I18nProvider';
|
||||
import { Button } from '../../ui/button';
|
||||
|
||||
export function PluginPackageDetails({ plugin }: { plugin: NetcattyInstalledPlugin }) {
|
||||
const { t } = useI18n();
|
||||
const manifest = plugin.manifest;
|
||||
if (!manifest) return null;
|
||||
const permissions = [
|
||||
...(manifest.permissions?.required ?? []).map((value) => ({ value, required: true })),
|
||||
...(manifest.permissions?.optional ?? []).map((value) => ({ value, required: false })),
|
||||
];
|
||||
const advanced = !manifest.main.browser || Boolean(manifest.companionExecutables?.length)
|
||||
|| manifest.contributes?.providers?.some((p) => p.kind === 'terminal.interceptor.input' || p.kind === 'terminal.interceptor.output');
|
||||
return <div className="space-y-4 border-t border-border/60 pt-4 text-sm">
|
||||
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-2 text-xs">
|
||||
<dt className="text-muted-foreground">ID</dt><dd className="break-all font-mono">{plugin.id}</dd>
|
||||
<dt className="text-muted-foreground">Netcatty</dt><dd>{manifest.engines.netcatty}</dd>
|
||||
<dt className="text-muted-foreground">API</dt><dd>{manifest.engines.api}</dd>
|
||||
<dt className="text-muted-foreground">SHA-256</dt><dd className="break-all font-mono">{plugin.archiveSha256 ?? '-'}</dd>
|
||||
</dl>
|
||||
<p className={advanced ? 'text-destructive' : 'text-muted-foreground'}>{t(advanced ? 'settings.plugins.manager.advanced' : 'settings.plugins.manager.browser')}</p>
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-medium">{t('settings.plugins.manager.permissions')}</h4>
|
||||
<p className="text-xs text-muted-foreground">{t('settings.plugins.manager.permissionNote')}</p>
|
||||
{permissions.length === 0 && <p className="text-xs text-muted-foreground">{t('settings.plugins.manager.noPermissions')}</p>}
|
||||
{permissions.map(({ value, required }, index) => <div key={index} className="rounded-md bg-muted/40 p-3">
|
||||
<div className="flex justify-between gap-3"><code className="break-all text-xs">{typeof value === 'string' ? value : value.permission}</code><span className="text-xs text-muted-foreground">{t(required ? 'settings.plugins.manager.required' : 'settings.plugins.manager.optional')}</span></div>
|
||||
{typeof value !== 'string' && <><p className="mt-1 whitespace-pre-wrap break-all text-xs">{value.resources.join('\n')}</p>{value.reason && <p className="mt-1 text-xs text-muted-foreground">{value.reason}</p>}</>}
|
||||
</div>)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t('settings.plugins.manager.data')}</p>
|
||||
</div>;
|
||||
}
|
||||
|
||||
export function PluginPackageManager() {
|
||||
const { t, resolvedLocale } = useI18n();
|
||||
const manager = usePluginManager();
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
const [removing, setRemoving] = useState<string | null>(null);
|
||||
if (!manager.available) return null;
|
||||
return <section className="space-y-4" aria-label={t('settings.plugins.manager.installed')}>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h3 className="font-medium">{t('settings.plugins.manager.installed')} <span className="text-muted-foreground">{manager.plugins.length}</span></h3>
|
||||
<Button variant="outline" size="sm" disabled={manager.busy} onClick={() => void manager.importPackage()}><Upload size={14} className="mr-2" />{t('settings.plugins.manager.import')}</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t('settings.plugins.manager.preview')}</p>
|
||||
{manager.error && <p role="alert" className="text-sm text-destructive">{manager.error}</p>}
|
||||
{!manager.plugins.length && <div className="rounded-xl border border-dashed border-border p-8 text-center text-sm text-muted-foreground">{t('settings.plugins.manager.empty')}</div>}
|
||||
{manager.plugins.map((plugin) => <article key={plugin.id} className="space-y-4 rounded-xl border border-border/70 bg-background p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="rounded-lg bg-muted p-2"><Package size={20} /></div>
|
||||
<button type="button" className="min-w-0 flex-1 text-left" aria-expanded={selected === plugin.id} onClick={() => setSelected(selected === plugin.id ? null : plugin.id)}>
|
||||
<div className="truncate text-sm font-medium">{typeof plugin.manifest?.displayName === 'string' ? plugin.manifest.displayName : plugin.manifest?.displayName?.[resolvedLocale] ?? plugin.manifest?.displayName?.en ?? plugin.manifest?.name ?? plugin.id}</div>
|
||||
<div className="text-xs text-muted-foreground">{plugin.activeVersion} · {t(plugin.enabled ? 'settings.plugins.manager.enabled' : 'settings.plugins.manager.disabled')}</div>
|
||||
</button>
|
||||
<Button variant="outline" size="sm" disabled={manager.busy} onClick={() => void manager.setEnabled(plugin.id, !plugin.enabled)}>{t(plugin.enabled ? 'settings.plugins.manager.disable' : 'settings.plugins.manager.enable')}</Button>
|
||||
</div>
|
||||
{plugin.runtime.lastError && <p role="status" className="break-words text-xs text-destructive">{plugin.runtime.lastError}</p>}
|
||||
{selected === plugin.id && <>
|
||||
<PluginPackageDetails plugin={plugin} />
|
||||
{removing === plugin.id ? <div className="space-y-2 rounded-lg border border-destructive/30 p-3">
|
||||
<p className="text-xs">{t('settings.plugins.manager.removeNote')}</p>
|
||||
<Button variant="destructive" size="sm" disabled={manager.busy} onClick={() => { void manager.uninstall(plugin.id); setRemoving(null); }}>{t('settings.plugins.manager.remove')}</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => setRemoving(null)}>{t('common.cancel')}</Button>
|
||||
</div> : <Button variant="ghost" size="sm" disabled={manager.busy} onClick={() => setRemoving(plugin.id)}>{t('settings.plugins.manager.remove')}</Button>}
|
||||
</>}
|
||||
</article>)}
|
||||
</section>;
|
||||
}
|
||||
@@ -8,40 +8,3 @@ 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,7 +9,6 @@ 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,
|
||||
@@ -53,7 +52,6 @@ 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';
|
||||
@@ -62,14 +60,10 @@ export default function SettingsFileAssociationsTab() {
|
||||
}, [defaultOpener]);
|
||||
|
||||
const handleRemove = useCallback((extension: string) => {
|
||||
setRemoveConfirmExt(extension);
|
||||
}, []);
|
||||
|
||||
const handleConfirmRemove = useCallback(() => {
|
||||
if (!removeConfirmExt) return;
|
||||
removeAssociation(removeConfirmExt);
|
||||
setRemoveConfirmExt(null);
|
||||
}, [removeAssociation, removeConfirmExt]);
|
||||
if (confirm(t('settings.sftpFileAssociations.removeConfirm', { ext: extension === 'file' ? t('sftp.opener.noExtension') : extension }))) {
|
||||
removeAssociation(extension);
|
||||
}
|
||||
}, [removeAssociation, t]);
|
||||
|
||||
const handleSelectDefaultSystemApp = useCallback(async () => {
|
||||
setIsSelectingDefaultApp(true);
|
||||
@@ -378,16 +372,6 @@ 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>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import { PluginPackageManager } from './PluginPackageManager';
|
||||
import { FolderOpen } from 'lucide-react';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
@@ -308,7 +307,6 @@ export default function SettingsPluginsTab() {
|
||||
<h2 className="text-xl font-semibold">{t('settings.plugins.title')}</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{t('settings.plugins.description')}</p>
|
||||
</div>
|
||||
<PluginPackageManager />
|
||||
{contextualScopes.size > 0 && (
|
||||
<section className="grid gap-3 rounded-lg border border-border/70 bg-muted/10 p-4 sm:grid-cols-2" aria-label={t('settings.plugins.scopeTargets')}>
|
||||
{[...contextualScopes].map((kind) => (
|
||||
|
||||
@@ -77,13 +77,13 @@ export const ClaudeCodeCard: React.FC<{
|
||||
</div>
|
||||
|
||||
{found && (
|
||||
<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>
|
||||
<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>
|
||||
{pathInfo.version && (
|
||||
<>
|
||||
<span className="text-muted-foreground shrink-0">|</span>
|
||||
<span className="text-muted-foreground shrink-0">{pathInfo.version}</span>
|
||||
<span className="text-muted-foreground">|</span>
|
||||
<span className="text-muted-foreground">{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 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"
|
||||
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"
|
||||
/>
|
||||
<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 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>
|
||||
<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>
|
||||
{pathInfo.version && (
|
||||
<>
|
||||
<span className="text-muted-foreground shrink-0">|</span>
|
||||
<span className="text-muted-foreground shrink-0">{pathInfo.version}</span>
|
||||
<span className="text-muted-foreground">|</span>
|
||||
<span className="text-muted-foreground">{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 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"
|
||||
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"
|
||||
/>
|
||||
<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 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>
|
||||
<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>
|
||||
{pathInfo.version && (
|
||||
<>
|
||||
<span className="text-muted-foreground shrink-0">|</span>
|
||||
<span className="text-muted-foreground shrink-0">{pathInfo.version}</span>
|
||||
<span className="text-muted-foreground">|</span>
|
||||
<span className="text-muted-foreground">{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 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"
|
||||
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"
|
||||
/>
|
||||
<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 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>
|
||||
<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>
|
||||
{pathInfo.version && (
|
||||
<>
|
||||
<span className="text-muted-foreground shrink-0">|</span>
|
||||
<span className="text-muted-foreground shrink-0">{pathInfo.version}</span>
|
||||
<span className="text-muted-foreground">|</span>
|
||||
<span className="text-muted-foreground">{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 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"
|
||||
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"
|
||||
/>
|
||||
)}
|
||||
<Button variant="outline" size="sm" onClick={onRecheckPath} disabled={!allowEmptyCheck && !customPath.trim()}>
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
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);
|
||||
});
|
||||
@@ -348,62 +348,6 @@ 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,207 +3653,6 @@ 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,56 +3,6 @@ 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;
|
||||
|
||||
@@ -88,7 +38,7 @@ export const resolveStartupCommand = (
|
||||
ctx: TerminalSessionStartersContext,
|
||||
options?: { consumeSuppressHostStartupCommand?: boolean },
|
||||
): string | undefined => {
|
||||
const command = ctx.startupCommand || (ctx.suppressHostStartupCommandRef?.current ? undefined : resolveStartupHost(ctx).startupCommand);
|
||||
const command = ctx.startupCommand || (ctx.suppressHostStartupCommandRef?.current ? undefined : ctx.host.startupCommand);
|
||||
if (options?.consumeSuppressHostStartupCommand && ctx.suppressHostStartupCommandRef) {
|
||||
ctx.suppressHostStartupCommandRef.current = false;
|
||||
}
|
||||
@@ -146,7 +96,7 @@ export const scheduleStartupCommand = (
|
||||
|
||||
const runMode = ctx.startupCommand
|
||||
? (ctx.multiLineRunMode ?? "paste")
|
||||
: (resolveStartupHost(ctx).startupCommandRunMode ?? "paste");
|
||||
: (ctx.host.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*const retainedKeepCount = statusRef\.current === "connected"\s*\? resolveHibernateKeepRendererCount\(terminalSettingsRef\.current\)\s*: 0;\s*if \(statusRef\.current === "disconnected" \|\| !\(retainedKeepCount > 0\)\) \{\s*upgradeSoftHiddenRuntimeToHibernate\(\);\s*\}\s*return;/,
|
||||
/if \(softHiddenRef\.current\) \{\s*if \(statusRef\.current === "disconnected"\) \{\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*const retainedKeepCount = statusRef\.current === "connected"\s*\? resolveHibernateKeepRendererCount\(terminalSettingsRef\.current\)\s*: 0;\s*if \(statusRef\.current === "disconnected" \|\| !\(retainedKeepCount > 0\)\) \{\s*upgradeSoftHiddenRuntimeToHibernate\(\);/,
|
||||
/if \(softHiddenRef\.current\) \{\s*if \(statusRef\.current === "disconnected"\) \{\s*upgradeSoftHiddenRuntimeToHibernate\(\);/,
|
||||
);
|
||||
const wakeIndex = upgradeBody.indexOf("wakeSoftHiddenRuntimeRef.current?.()");
|
||||
const upgradeIndex = upgradeBody.indexOf("fullHibernateRuntime().then(");
|
||||
@@ -311,15 +311,10 @@ 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*const retainedKeepCount = statusRef\.current === "connected"\s*\? resolveHibernateKeepRendererCount\(terminalSettingsRef\.current\)\s*: 0;\s*if \(statusRef\.current === "disconnected" \|\| !\(retainedKeepCount > 0\)\) \{\s*upgradeSoftHiddenRuntimeToHibernate\(\);\s*\}\s*return;\s*\}/,
|
||||
/if \(softHiddenRef\.current\) \{\s*if \(statusRef\.current === "disconnected"\) \{\s*upgradeSoftHiddenRuntimeToHibernate\(\);\s*\}\s*return;\s*\}/,
|
||||
);
|
||||
});
|
||||
@@ -20,8 +20,6 @@ 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>;
|
||||
@@ -51,7 +49,6 @@ export function useTerminalHibernateEffect({
|
||||
isSearchOpen,
|
||||
hibernateEnabled,
|
||||
hibernateDelayMs,
|
||||
hibernateKeepRendererCount,
|
||||
fileTransferActive,
|
||||
hibernatedRef,
|
||||
softHiddenRef,
|
||||
@@ -213,27 +210,4 @@ 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,90 +58,6 @@ 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,17 +31,20 @@
|
||||
<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="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="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="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 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>
|
||||
<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>
|
||||
<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,17 +31,20 @@
|
||||
<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="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="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="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 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>
|
||||
<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>
|
||||
<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 |
@@ -1,67 +0,0 @@
|
||||
# Plugin platform implementation review
|
||||
|
||||
Baseline: main `0256e7471d66bbbca077c361ef379d7b11ce8465` (2026-10-26).
|
||||
|
||||
## Existing capabilities and gaps
|
||||
|
||||
| Area | Implemented source | Remaining boundary |
|
||||
| --- | --- | --- |
|
||||
| Contract, SDK, package CLI | `packages/plugin-contract/schema`, `packages/plugin-sdk`, `packages/plugin-cli` | Internal API `0.1.0-internal`; no public API stability promise |
|
||||
| Runtime | `electron/plugins/browserPluginRuntime.cjs`, `utilityPluginRuntime.cjs`, `hostBootstrap.cjs` | Entire host remains development opt-in; advanced utility is ambient Node authority, not a fine-grained sandbox |
|
||||
| Permissions | `permissionEngine.cjs`, `hostRpcRegistry.cjs`, `secureCapabilities.cjs` | Broker checks at privileged boundary; unsigned principal bound to package digest; verified publisher/distribution not implemented |
|
||||
| UI contributions | `contributionService.cjs`, `components/plugins`, `SettingsPluginsTab.tsx` | Commands, menus, keybindings, settings and views exist; current Settings page is contributed settings, not a package manager/marketplace |
|
||||
| Terminal | `terminalProviderService.cjs`, `terminalDataPipelineService.cjs` | Completion/link/visual providers and lifecycle; interceptors require privileged utility placement; not arbitrary terminal object access |
|
||||
| Connection, authentication, importer | `extensionProviderService.cjs`, `application/state/pluginExtensionBridge.ts` | Existing operation-bound provider integration; not general vault/SFTP/clipboard CRUD |
|
||||
| Sync | `pluginSyncSidecarService.cjs`, `application/pluginSyncSidecarBridge.ts` | Opt-in non-secret settings and account/CRDT sidecars; ordinary KV is local, package code/permissions/secrets are not cloud authority |
|
||||
| Compatibility | `plugin-cli/src/compatibility.ts`, `packageStore.cjs`, `runtimeSupervisor.cjs` | Install/start checks existed; startup reconciliation and lazy re-enable checks missing |
|
||||
| Local package | `packageStore.cjs`, CLI `pack`, first-party `examples/plugins/hello-netcatty` | Immutable snapshot, hash/integrity validation and safe extraction exist; no ordinary import management UI |
|
||||
| Distribution | No public catalog implementation | Version/hash/pinned README, review workflow, manual update UI still needed |
|
||||
|
||||
Historical merged steps: #2276 contract/SDK; #2303 runtime; #2315 permission/security;
|
||||
#2324 UI contributions; #2338 terminal providers; #2402 privileged terminal pipeline;
|
||||
#2413 connection/auth/importers; #2713 sync; #2748 and #2752 sync follow-up fixes.
|
||||
The repository AGENTS.md runtime section describes an earlier phase and must not
|
||||
be mistaken for a complete current capability inventory. No repository `.agents/skills`
|
||||
directory was present at this baseline.
|
||||
|
||||
## Small implementation slices
|
||||
|
||||
1. Reconcile host/API/features compatibility before contribution enumeration;
|
||||
disable incompatible plugins and retain package/data, record the reason;
|
||||
block lazy re-enable. Do not auto-enable after a host downgrade.
|
||||
2. Reuse the existing Settings Plugins tab and management IPC for installed
|
||||
packages, manifest-derived permissions, version/hash and lifecycle actions;
|
||||
local import starts disabled for review. Keep native permission mediation.
|
||||
3. Add a lightweight first-party catalog and pinned safe README detail renderer,
|
||||
shared package verification, digest-bound manual update flow and author docs.
|
||||
Do not introduce remote accounts, reviews, payments or a new repository.
|
||||
4. Reproduce the actual Electron flow with synthetic data and the first-party
|
||||
sample; transfer source/build instructions if GUI is unavailable here.
|
||||
|
||||
## Decisions and explicit release gates
|
||||
|
||||
User approved public source plus a clear license for catalog entries, and manual
|
||||
confirmation of plugin updates. Private local packages remain separate.
|
||||
|
||||
Recommended public v1 eligibility: browser runtime only. Utility Node, native
|
||||
companions and raw terminal interceptors retain the development preview path.
|
||||
This excludes advanced provider implementations requiring Node/native binaries;
|
||||
ordinary browser contributions, broker-backed networking and providers remain
|
||||
possible. This policy needs product confirmation; no existing advanced path is
|
||||
removed by this review.
|
||||
|
||||
Keep `NETCATTY_PLUGIN_DEV=1` until distribution trust, permission UI, compatibility
|
||||
migration and native acceptance are reviewed. Passing tests is not proof of
|
||||
security isolation. Do not describe utility processes as broker-constrained.
|
||||
|
||||
A future KV sync feature needs an explicit manifest opt-in schema, size limits,
|
||||
namespace and schema-version migrations, deterministic conflict/deletion rules,
|
||||
account switching semantics and secret exclusion. It is deliberately not silently
|
||||
enabled in this iteration. Reuse existing settings sidecars for small preferences.
|
||||
|
||||
Catalog entries should bind ID, repository, license, author, version, commit,
|
||||
artifact SHA-256 and URL, platform/engine ranges, permissions and README revision.
|
||||
A hash mismatch must fail rather than fall back to latest. README must not execute
|
||||
HTML or automatically fetch remote media. Directory PR checks are a bounded
|
||||
review process, not a security certification or a guarantee that maintainers will
|
||||
repair third-party plugins.
|
||||
@@ -47,16 +47,6 @@ 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,13 +77,6 @@ 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",
|
||||
@@ -155,13 +148,6 @@ 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,9 +195,6 @@ 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,18 +2,6 @@ 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 = [];
|
||||
@@ -170,116 +158,3 @@ 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, []);
|
||||
});
|
||||
@@ -61,18 +61,10 @@ const POWERSHELL_PROMPT_PATTERN = /^PS(?:\s+\S.*)?>$/;
|
||||
// PowerShell's `PS C:\...>` (handled by POWERSHELL_PROMPT_PATTERN first).
|
||||
const CMD_PROMPT_PATTERN = /^[A-Za-z]:(?:\\[^<>"|]*)?>$/;
|
||||
|
||||
// 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]+)?\])[#$]$/;
|
||||
|
||||
// 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]*)[#$]$/;
|
||||
// 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]*)?[#$]$/;
|
||||
|
||||
function isDefaultPowerShellPromptLine(line) {
|
||||
return POWERSHELL_PROMPT_PATTERN.test(String(line || ""));
|
||||
@@ -83,8 +75,7 @@ function isDefaultCmdPromptLine(line) {
|
||||
}
|
||||
|
||||
function isDefaultPosixPromptLine(line) {
|
||||
const normalized = String(line || "").replace(/\s+$/, "");
|
||||
return POSIX_PROMPT_PATTERN.test(normalized) || ALPINE_PROMPT_PATTERN.test(normalized);
|
||||
return POSIX_PROMPT_PATTERN.test(String(line || "").replace(/\s+$/, ""));
|
||||
}
|
||||
|
||||
function extractTrailingIdlePrompt(output) {
|
||||
@@ -143,20 +134,7 @@ function looksLikeIdleAutoLogout(outputTail) {
|
||||
function trackSessionIdlePrompt(session, chunk) {
|
||||
if (!session || typeof chunk !== "string" || !chunk) return "";
|
||||
|
||||
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;
|
||||
const nextTail = `${session._promptTrackTail || ""}${chunk}`.slice(-MAX_PROMPT_TRACK_TAIL);
|
||||
session._promptTrackTail = nextTail;
|
||||
|
||||
const prompt = extractTrailingIdlePrompt(nextTail);
|
||||
@@ -171,9 +149,8 @@ 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,
|
||||
// `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
|
||||
// or `user@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
|
||||
@@ -195,34 +172,6 @@ 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) {
|
||||
@@ -1163,7 +1112,6 @@ module.exports = {
|
||||
formatSyntheticEcho,
|
||||
extractTrailingIdlePrompt,
|
||||
getFreshIdlePrompt,
|
||||
getVisibleIdlePrompt,
|
||||
isDefaultPowerShellPromptLine,
|
||||
isDefaultCmdPromptLine,
|
||||
isDefaultPosixPromptLine,
|
||||
|
||||
@@ -8,7 +8,6 @@ const {
|
||||
extractTrailingIdlePrompt,
|
||||
formatSyntheticEcho,
|
||||
getFreshIdlePrompt,
|
||||
getVisibleIdlePrompt,
|
||||
isDefaultPowerShellPromptLine,
|
||||
isDefaultCmdPromptLine,
|
||||
isDefaultPosixPromptLine,
|
||||
@@ -134,91 +133,14 @@ 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("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);
|
||||
@@ -752,95 +674,6 @@ 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, getVisibleIdlePrompt } = require("../ai/shellUtils.cjs");
|
||||
const { formatSyntheticEcho } = 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) || getVisibleIdlePrompt(session) : undefined,
|
||||
? () => getFreshIdlePrompt(session) : undefined,
|
||||
typedInput: true,
|
||||
echoCommand: (rawCommand) => {
|
||||
const contents = electronModule?.webContents?.fromId?.(session.webContentsId);
|
||||
|
||||
@@ -329,43 +329,6 @@ 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,7 +2,6 @@
|
||||
// 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,
|
||||
@@ -160,7 +159,7 @@ function createExecHandlerApi(ctx) {
|
||||
onEchoSuppressionPrime: (marker) => echoCommandToSession(session, sessionId, `${marker}_I`, { syntheticEcho: false }),
|
||||
expectedPrompt: getFreshIdlePrompt(session),
|
||||
getReadyPrompt: typeof session._promptTrackTail === "string"
|
||||
? () => getFreshIdlePrompt(session) || getVisibleIdlePrompt(session) : undefined,
|
||||
? () => getFreshIdlePrompt(session) : undefined,
|
||||
typedInput: true,
|
||||
echoCommand: (rawCommand) => echoCommandToSession(session, sessionId, rawCommand),
|
||||
chatSessionId,
|
||||
@@ -342,7 +341,7 @@ function createExecHandlerApi(ctx) {
|
||||
chatSessionId,
|
||||
expectedPrompt: getFreshIdlePrompt(session),
|
||||
getReadyPrompt: typeof session._promptTrackTail === "string"
|
||||
? () => getFreshIdlePrompt(session) || getVisibleIdlePrompt(session) : undefined,
|
||||
? () => getFreshIdlePrompt(session) : undefined,
|
||||
typedInput: true,
|
||||
echoCommand: (rawCommand) => echoCommandToSession(session, sessionId, rawCommand),
|
||||
maxBufferedChars: MAX_BACKGROUND_JOB_OUTPUT_CHARS,
|
||||
|
||||
@@ -1,289 +0,0 @@
|
||||
"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,12 +2,10 @@
|
||||
|
||||
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");
|
||||
@@ -148,89 +146,3 @@ 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,13 +114,6 @@ 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",
|
||||
@@ -157,16 +150,10 @@ function applyLegacyHmacAlgorithms(algorithms) {
|
||||
}
|
||||
|
||||
// Mirror of ssh2's DEFAULT_SERVER_HOST_KEY (see ssh2/lib/protocol/constants.js)
|
||||
// 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.
|
||||
// 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-*).
|
||||
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,19 +269,6 @@ 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 });
|
||||
@@ -293,9 +280,6 @@ 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"));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -309,10 +293,6 @@ 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,11 +73,6 @@ 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);
|
||||
@@ -212,26 +207,7 @@ function createTelnetAutoLogin(options = {}) {
|
||||
notifyIncomplete();
|
||||
}
|
||||
},
|
||||
// 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;
|
||||
}
|
||||
handleUserInput() {
|
||||
disabled = true;
|
||||
clearExpiryTimer();
|
||||
notifyUserInput();
|
||||
@@ -248,7 +224,6 @@ function createTelnetAutoLogin(options = {}) {
|
||||
|
||||
module.exports = {
|
||||
createTelnetAutoLogin,
|
||||
isAutoLoginWakeInput,
|
||||
isCommandPrompt,
|
||||
isContinuePrompt,
|
||||
isPasswordPrompt,
|
||||
|
||||
@@ -224,155 +224,6 @@ 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, isAutoLoginWakeInput } = require("./telnetAutoLogin.cjs");
|
||||
const { createTelnetAutoLogin } = require("./telnetAutoLogin.cjs");
|
||||
const telnetProtocol = require("./telnetProtocol.cjs");
|
||||
const { createPtyOutputBuffer } = require("./ptyOutputBuffer.cjs");
|
||||
const { enableTcpNoDelay } = require("./tcpNoDelay.cjs");
|
||||
@@ -1713,9 +1713,8 @@ function writeToSessionNow(payload, data, logRewrite = payload.logRewrite) {
|
||||
(session.type === 'telnet-native' || session.type === 'serial')
|
||||
&& !payload.automated
|
||||
&& !isTerminalReportSequence(data)
|
||||
&& !payload.autoLoginInputHandled
|
||||
) {
|
||||
session.autoLogin?.handleUserInput(data);
|
||||
session.autoLogin?.handleUserInput();
|
||||
}
|
||||
|
||||
// Encode keystrokes with the SAME charset the output path decodes with so
|
||||
@@ -1806,8 +1805,6 @@ 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);
|
||||
@@ -1820,7 +1817,7 @@ function writeToSessionWithInterception(
|
||||
paste.finish(index, true);
|
||||
return;
|
||||
}
|
||||
const written = writeToSessionNow(sessionWritePayload, nextData, logRewrite);
|
||||
const written = writeToSessionNow(payload, nextData, logRewrite);
|
||||
if (paste) paste.finish(written ? index : undefined);
|
||||
};
|
||||
const bypass = payload?.sensitive === true || isTerminalReportSequence(data);
|
||||
@@ -1833,24 +1830,20 @@ function writeToSessionWithInterception(
|
||||
writeWithReceipt(data);
|
||||
return;
|
||||
}
|
||||
// Classify auto-login at input ingress, before the asynchronous
|
||||
// Cancel the auto-login detector 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. 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.
|
||||
// conditions as writeToSessionNow; handleUserInput is idempotent, so the
|
||||
// later call there stays a no-op.
|
||||
if (
|
||||
expectedSession
|
||||
&& (expectedSession.type === 'telnet-native' || expectedSession.type === 'serial')
|
||||
&& !payload.automated
|
||||
&& !isTerminalReportSequence(data)
|
||||
) {
|
||||
expectedSession.autoLogin?.handleUserInput(data);
|
||||
sessionWritePayload = { ...payload, autoLoginInputHandled: true };
|
||||
expectedSession.autoLogin?.handleUserInput();
|
||||
}
|
||||
const writeIfCurrent = (nextData) => {
|
||||
const current = sessions.get(payload.sessionId);
|
||||
@@ -1858,19 +1851,6 @@ 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,32 +1,6 @@
|
||||
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;
|
||||
@@ -87,221 +61,6 @@ 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,11 +76,6 @@ 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
|
||||
@@ -520,89 +515,6 @@ 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 || "");
|
||||
@@ -619,8 +531,6 @@ module.exports = {
|
||||
createTerminalTextRenderer,
|
||||
terminalDataToHtmlContent,
|
||||
terminalDataToPlainText,
|
||||
terminalCursorLine,
|
||||
alternateScreenActiveAfter,
|
||||
};
|
||||
|
||||
function createDefaultStyle() {
|
||||
|
||||
@@ -151,35 +151,6 @@ class PackageStore {
|
||||
.filter((value) => typeof value === "string" && value !== this.paths.database)
|
||||
.map((directory) => mkdir(directory, { recursive: true, mode: 0o700 })));
|
||||
await this.recover();
|
||||
// Reconcile every installed manifest before lazy contributions are exposed.
|
||||
// An application/API upgrade must not leave incompatible providers enabled.
|
||||
for (const plugin of this.database.listPlugins()) {
|
||||
if (!plugin.enabled) continue;
|
||||
await this.assertCompatible(plugin).catch((error) => {
|
||||
if (error.code !== "PLUGIN_INCOMPATIBLE") throw error;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async assertCompatible(plugin) {
|
||||
const pluginCli = await loadPluginCli();
|
||||
const result = pluginCli.checkPluginCompatibility(plugin.manifest, {
|
||||
netcattyVersion: this.netcattyVersion,
|
||||
apiVersion: this.apiVersion,
|
||||
features: this.supportedFeatures,
|
||||
});
|
||||
if (result.compatible) return result;
|
||||
const error = new Error(`Plugin is incompatible: ${result.errors.join("; ")}`);
|
||||
error.code = "PLUGIN_INCOMPATIBLE";
|
||||
const current = this.database.getActivePlugin(plugin.id);
|
||||
if (current?.activeVersion === plugin.activeVersion) {
|
||||
this.database.setEnabled(plugin.id, false);
|
||||
this.database.setRuntimeState(plugin.id, "error", {
|
||||
pluginVersion: plugin.activeVersion,
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
async install(archivePath, options = {}) {
|
||||
@@ -216,9 +187,7 @@ class PackageStore {
|
||||
const version = assertPluginStorageSegment(manifest.version, "version");
|
||||
const targetDirectory = resolveInstalledVersionDirectory(this.paths, pluginId, version);
|
||||
const previousPlugin = this.database.getActivePlugin(pluginId);
|
||||
const enableAfterActivation = options.enable === false
|
||||
? false
|
||||
: options.enable === true || previousPlugin?.enabled === true;
|
||||
const enableAfterActivation = options.enable === true || previousPlugin?.enabled === true;
|
||||
let activationPrepared = false;
|
||||
const prepareActivation = async (reason) => {
|
||||
if (activationPrepared) return;
|
||||
@@ -245,7 +214,7 @@ class PackageStore {
|
||||
await rm(targetDirectory, { recursive: true, force: true });
|
||||
}
|
||||
if (existingValidation) {
|
||||
if (previousPlugin?.activeVersion !== version || options.enable === false) await prepareActivation("switch-active-version");
|
||||
if (previousPlugin?.activeVersion !== version) await prepareActivation("switch-active-version");
|
||||
const existingPackage = path.join(targetDirectory, PACKAGE_DIRECTORY);
|
||||
this.database.installVersion({
|
||||
pluginId,
|
||||
@@ -253,7 +222,7 @@ class PackageStore {
|
||||
manifest: existingValidation.manifest,
|
||||
archiveSha256: existing.archiveSha256,
|
||||
packageRelativePath: path.relative(this.paths.packages, existingPackage),
|
||||
}, { enable: enableAfterActivation, forceDisabled: options.enable === false });
|
||||
}, { enable: enableAfterActivation });
|
||||
return this.database.getActivePlugin(pluginId);
|
||||
}
|
||||
}
|
||||
@@ -272,7 +241,7 @@ class PackageStore {
|
||||
await writeDurableMetadata(path.join(stagingDirectory, INSTALL_METADATA_FILE), metadata);
|
||||
await this.syncDirectory(stagingDirectory);
|
||||
await mkdir(path.dirname(targetDirectory), { recursive: true, mode: 0o700 });
|
||||
if (previousPlugin?.activeVersion !== version || options.enable === false) await prepareActivation("switch-active-version");
|
||||
if (previousPlugin?.activeVersion !== version) await prepareActivation("switch-active-version");
|
||||
await rename(stagingDirectory, targetDirectory);
|
||||
await this.syncDirectory(path.dirname(targetDirectory));
|
||||
const packageRelativePath = path.relative(
|
||||
@@ -286,7 +255,7 @@ class PackageStore {
|
||||
manifest,
|
||||
archiveSha256: snapshot.sha256,
|
||||
packageRelativePath,
|
||||
}, { enable: enableAfterActivation, forceDisabled: options.enable === false });
|
||||
}, { enable: enableAfterActivation });
|
||||
} catch (error) {
|
||||
this.verifiedArchives.delete(this.#versionKey(pluginId, version));
|
||||
await rm(targetDirectory, { recursive: true, force: true });
|
||||
|
||||
@@ -370,54 +370,3 @@ test("failed package validation removes every extracted staging file", async (co
|
||||
assert.deepEqual(await fs.promises.readdir(fixture.paths.staging), []);
|
||||
assert.deepEqual(fixture.database.listPlugins(), []);
|
||||
});
|
||||
|
||||
test("host upgrade disables incompatible lazy plugins before contributions and retains data", async (context) => {
|
||||
const fixture = createStore(context);
|
||||
await fixture.store.initialize();
|
||||
const archive = await createPackage(fixture.root, {
|
||||
manifest: { engines: { netcatty: "<1.0.0", api: ">=0.1.0-internal <0.2.0" } },
|
||||
});
|
||||
const installed = await fixture.store.install(archive.archive, { enable: true });
|
||||
fixture.database.setValue(installed.id, "synthetic", { retained: true });
|
||||
fixture.store.netcattyVersion = "1.0.0";
|
||||
await fixture.store.initialize();
|
||||
const disabled = fixture.database.getActivePlugin(installed.id);
|
||||
assert.equal(disabled.enabled, false);
|
||||
assert.match(disabled.runtime.lastError, /incompatible.*Netcatty/);
|
||||
assert.equal(disabled.activeVersion, installed.activeVersion);
|
||||
assert.deepEqual(fixture.database.getValue(installed.id, "synthetic"), { retained: true });
|
||||
assert.equal(fs.existsSync(fixture.store.resolvePackageRoot(disabled)), true);
|
||||
await assert.rejects(fixture.store.assertCompatible(disabled), { code: "PLUGIN_INCOMPATIBLE" });
|
||||
fixture.store.netcattyVersion = "0.0.0";
|
||||
await fixture.store.initialize();
|
||||
assert.equal(fixture.database.getActivePlugin(installed.id).enabled, false,
|
||||
"returning to a compatible host must not silently re-enable code");
|
||||
});
|
||||
|
||||
for (const target of [
|
||||
{ apiVersion: "0.2.0" },
|
||||
{ supportedFeatures: [] },
|
||||
]) {
|
||||
test(`startup checks API and required feature compatibility: ${JSON.stringify(target)}`, async (context) => {
|
||||
const fixture = createStore(context, { supportedFeatures: ["example.feature"] });
|
||||
await fixture.store.initialize();
|
||||
const archive = await createPackage(fixture.root, {
|
||||
manifest: { features: { required: ["example.feature"] } },
|
||||
});
|
||||
const installed = await fixture.store.install(archive.archive, { enable: true });
|
||||
Object.assign(fixture.store, target);
|
||||
await fixture.store.initialize();
|
||||
assert.equal(fixture.database.getActivePlugin(installed.id).enabled, false);
|
||||
});
|
||||
}
|
||||
|
||||
test("explicit disabled import never auto-enables an update of an enabled plugin", async (context) => {
|
||||
const fixture = createStore(context);
|
||||
await fixture.store.initialize();
|
||||
const first = await createPackage(fixture.root);
|
||||
await fixture.store.install(first.archive, { enable: true });
|
||||
const update = await createPackage(fixture.root, { manifest: { version: "1.1.0" } });
|
||||
const installed = await fixture.store.install(update.archive, { enable: false });
|
||||
assert.equal(installed.activeVersion, "1.1.0");
|
||||
assert.equal(installed.enabled, false);
|
||||
});
|
||||
@@ -646,7 +646,7 @@ function registerPluginBridge(ipcMain, options) {
|
||||
handle(CHANNELS.list, async (activeManager) => activeManager.list());
|
||||
handle(CHANNELS.install, async (activeManager, payload) => activeManager.install(
|
||||
payload?.archivePath,
|
||||
{ enable: typeof payload?.enable === "boolean" ? payload.enable : undefined },
|
||||
{ enable: payload?.enable === true },
|
||||
));
|
||||
handle(CHANNELS.setEnabled, async (activeManager, payload) => activeManager.setEnabled(
|
||||
payload?.pluginId,
|
||||
|
||||
@@ -52,7 +52,7 @@ class PluginManager {
|
||||
let plugin;
|
||||
try {
|
||||
plugin = await this.packageStore.install(archivePath, {
|
||||
enable: options?.enable,
|
||||
enable: options?.enable === true,
|
||||
beforeActivate: async ({ pluginId, previousPlugin }) => {
|
||||
if (!previousPlugin?.enabled) return;
|
||||
this.database.setEnabled(pluginId, false);
|
||||
@@ -107,7 +107,6 @@ class PluginManager {
|
||||
await this.#ready();
|
||||
if (enabled) {
|
||||
const plugin = this.database.getActivePlugin(pluginId);
|
||||
if (plugin) await this.packageStore.assertCompatible?.(plugin);
|
||||
if (plugin?.runtime?.quarantinedAt != null) {
|
||||
this.database.clearQuarantine(pluginId, plugin.activeVersion);
|
||||
}
|
||||
|
||||
@@ -488,26 +488,3 @@ test("shutdown cancels an in-flight runtime mutation before waiting for its queu
|
||||
"database:close",
|
||||
]);
|
||||
});
|
||||
|
||||
test("explicit enable checks compatibility before clearing quarantine or exposing lazy contributions", async () => {
|
||||
const calls = [];
|
||||
const plugin = { id: "com.example.incompatible", enabled: false, runtime: { quarantinedAt: 1 } };
|
||||
const manager = new PluginManager({
|
||||
database: {
|
||||
getActivePlugin: () => plugin,
|
||||
clearQuarantine: () => calls.push("clear"),
|
||||
setEnabled: () => calls.push("enable"),
|
||||
},
|
||||
packageStore: {
|
||||
async initialize() {},
|
||||
async assertCompatible() { throw new Error("Plugin is incompatible"); },
|
||||
},
|
||||
runtimeSupervisor: {},
|
||||
contributionService: {
|
||||
async initialize() {},
|
||||
onPluginEnabled: () => calls.push("contribute"),
|
||||
},
|
||||
});
|
||||
await assert.rejects(manager.setEnabled(plugin.id, true), /incompatible/);
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
@@ -8,7 +8,7 @@ const {
|
||||
execViaChannel,
|
||||
execViaRawPty,
|
||||
} = require("../bridges/ai/ptyExec.cjs");
|
||||
const { getFreshIdlePrompt, getVisibleIdlePrompt, formatSyntheticEcho } = require("../bridges/ai/shellUtils.cjs");
|
||||
const { getFreshIdlePrompt, 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) || getVisibleIdlePrompt(session) : undefined,
|
||||
? () => getFreshIdlePrompt(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) || getVisibleIdlePrompt(session) : undefined,
|
||||
? () => getFreshIdlePrompt(session) : undefined,
|
||||
typedInput: true,
|
||||
echoCommand: (rawCommand) => {
|
||||
event?.sender?.send?.("netcatty:data", {
|
||||
|
||||
@@ -9,9 +9,9 @@ From the repository root:
|
||||
|
||||
```bash
|
||||
npm run build:plugin-packages
|
||||
node packages/plugin-cli/dist/cli.js validate examples/plugins/hello-netcatty
|
||||
node packages/plugin-cli/dist/cli.js compatibility examples/plugins/hello-netcatty --netcatty 0.0.0
|
||||
node packages/plugin-cli/dist/cli.js pack examples/plugins/hello-netcatty --out /tmp/hello-netcatty.ncpkg
|
||||
npm exec -- netcatty-plugin validate examples/plugins/hello-netcatty
|
||||
npm exec -- netcatty-plugin compatibility examples/plugins/hello-netcatty --netcatty 0.0.0
|
||||
npm exec -- netcatty-plugin pack examples/plugins/hello-netcatty --out /tmp/hello-netcatty.ncpkg
|
||||
```
|
||||
|
||||
After installation, change **Greeting** under **Settings → Plugins**, then run
|
||||
@@ -21,29 +21,3 @@ sandboxed plugin runtime. Type `netc` at a shell prompt to exercise the bounded
|
||||
completion Provider, and print `Hello from Netcatty` to exercise the declarative
|
||||
decoration Provider. Neither Provider receives raw xterm objects or sensitive
|
||||
terminal input/output streams.
|
||||
|
||||
## Import and inspect
|
||||
|
||||
Launch a development build with `NETCATTY_PLUGIN_DEV=1`, using a fresh Electron
|
||||
user-data directory for demonstrations. In **Settings > Plugins**, choose
|
||||
**Import .ncpkg**, select the generated archive, and open its details. Review the
|
||||
manifest permissions, engine ranges and SHA-256, then choose **Enable**. Native
|
||||
permission prompts still mediate activation and sensitive operations.
|
||||
|
||||
Importing an update is a manual action and leaves it disabled, including when
|
||||
the previous version was enabled. Review the new package before enabling it.
|
||||
Changing unsigned package contents changes its security principal; previous
|
||||
persistent grants are not transferred to different bytes.
|
||||
|
||||
An incompatible application/API upgrade disables installed plugins before
|
||||
publishing their contributions. The reason remains in the installed list, and
|
||||
package files and local data remain available. Returning to a compatible host
|
||||
requires explicit re-enable. Uninstall is different: package code and local KV
|
||||
are deleted; user-owned settings, secret storage and sync sidecars are retained.
|
||||
|
||||
For a synthetic demonstration, use only this first-party example, change the
|
||||
Greeting, invoke **Examples: Say Hello**, disable/re-enable, restart the app and
|
||||
inspect the package again. Do not connect real hosts or a cloud account. The
|
||||
existing greeting setting is local; declaring `sync: true` on a non-secret
|
||||
setting opts it into the existing encrypted settings sidecar. Generic SDK KV
|
||||
storage does not sync.
|
||||
Vendored
+1
-2
@@ -337,8 +337,7 @@ declare global {
|
||||
id: string;
|
||||
enabled: boolean;
|
||||
activeVersion: string | null;
|
||||
manifest: import("@netcatty/plugin-contract").PluginManifest | null;
|
||||
archiveSha256?: string | null;
|
||||
manifest: unknown;
|
||||
runtime: {
|
||||
status: string;
|
||||
kind: 'browser' | 'utility' | null;
|
||||
|
||||
@@ -80,22 +80,6 @@ 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,9 +37,6 @@ 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.89";
|
||||
version = "1.1.85";
|
||||
|
||||
sources = {
|
||||
x86_64-linux = {
|
||||
appImageArch = "x86_64";
|
||||
hash = "sha256-Ne5TalZ3G9cHPl0xnQpPq/0GNRe1ErUg1DzE+/nnseM=";
|
||||
hash = "sha256-tiFQ/IGprMO9rc6nB6JWCxetdgBmGD+Zn6wKHPR8v6I=";
|
||||
};
|
||||
aarch64-linux = {
|
||||
appImageArch = "arm64";
|
||||
hash = "sha256-L1aiJ/CroFwLSLH5aDnakogxjP8k5gLt8NJC+IGfXIw=";
|
||||
hash = "sha256-LK/evHTu4wEu9TR/ULN1YDCEYlaoqVNIZ0gEhYDAL/s=";
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -581,11 +581,11 @@ test("example README commands use repository-root CLI paths", async () => {
|
||||
assert.match(readme, /npm run build:plugin-packages/);
|
||||
assert.match(
|
||||
readme,
|
||||
/node packages\/plugin-cli\/dist\/cli\.js validate examples\/plugins\/hello-netcatty/,
|
||||
/npm exec -- netcatty-plugin validate examples\/plugins\/hello-netcatty/,
|
||||
);
|
||||
assert.match(
|
||||
readme,
|
||||
/node packages\/plugin-cli\/dist\/cli\.js compatibility examples\/plugins\/hello-netcatty --netcatty 0\.0\.0/,
|
||||
/npm exec -- netcatty-plugin compatibility examples\/plugins\/hello-netcatty --netcatty 0\.0\.0/,
|
||||
);
|
||||
assert.doesNotMatch(readme, /npm exec --workspace @netcatty\/plugin-cli/);
|
||||
});
|
||||
|
||||
+84
-308
@@ -1,8 +1,7 @@
|
||||
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 @@ const { resolve } = require('path');
|
||||
@@ -6,7 +6,7 @@
|
||||
const { readFile } = require('fs');
|
||||
const { execFile, spawn } = require('child_process');
|
||||
|
||||
@@ -11,7 +10,7 @@ index bb495d1..32c48a0 100644
|
||||
|
||||
const {
|
||||
makeBufferParser,
|
||||
@@ -655,8 +655,12 @@ const AgentProtocol = (() => {
|
||||
@@ -655,8 +655,12 @@
|
||||
// TODO: verify signature type based on key and options used
|
||||
// during initial sign request
|
||||
binaryParser.init(signature, 0);
|
||||
@@ -26,7 +25,7 @@ index bb495d1..32c48a0 100644
|
||||
binaryParser.clear();
|
||||
|
||||
if (signature === undefined)
|
||||
@@ -711,7 +715,10 @@ const AgentProtocol = (() => {
|
||||
@@ -711,7 +715,10 @@
|
||||
hash: undefined,
|
||||
};
|
||||
let ctx;
|
||||
@@ -38,7 +37,7 @@ index bb495d1..32c48a0 100644
|
||||
if (flagsVal & SSH_AGENT_RSA_SHA2_256) {
|
||||
ctx = 'rsa-sha2-256';
|
||||
flags.hash = 'sha256';
|
||||
@@ -721,7 +728,7 @@ const AgentProtocol = (() => {
|
||||
@@ -721,7 +728,7 @@
|
||||
}
|
||||
}
|
||||
if (ctx === undefined)
|
||||
@@ -47,7 +46,7 @@ index bb495d1..32c48a0 100644
|
||||
|
||||
const req = new AgentInboundRequest(msgType, ctx);
|
||||
this[SYM_REQS].push(req);
|
||||
@@ -732,6 +739,9 @@ const AgentProtocol = (() => {
|
||||
@@ -732,6 +739,9 @@
|
||||
default: {
|
||||
const req = new AgentInboundRequest(msgType);
|
||||
this[SYM_REQS].push(req);
|
||||
@@ -57,7 +56,7 @@ index bb495d1..32c48a0 100644
|
||||
this.failureReply(req);
|
||||
}
|
||||
}
|
||||
@@ -781,7 +791,11 @@ const AgentProtocol = (() => {
|
||||
@@ -781,7 +791,11 @@
|
||||
if (pubKey instanceof Error)
|
||||
throw new Error('Invalid public key argument');
|
||||
|
||||
@@ -70,7 +69,7 @@ index bb495d1..32c48a0 100644
|
||||
switch (options.hash) {
|
||||
case 'sha256':
|
||||
flags = SSH_AGENT_RSA_SHA2_256;
|
||||
@@ -992,19 +1006,24 @@ const AgentProtocol = (() => {
|
||||
@@ -992,19 +1006,24 @@
|
||||
let p = 0;
|
||||
const sigFormat = req.getContext();
|
||||
const sigFormatLen = Buffer.byteLength(sigFormat);
|
||||
@@ -100,10 +99,9 @@ index bb495d1..32c48a0 100644
|
||||
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 @@ function getKeyAlgos(client, key, serverSigAlgs) {
|
||||
@@ -2170,6 +2170,29 @@
|
||||
return [];
|
||||
}
|
||||
return [['ssh-rsa', 'sha1']];
|
||||
@@ -134,10 +132,9 @@ index 7291c2c..8943c9a 100644
|
||||
}
|
||||
|
||||
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 @@ const {
|
||||
@@ -58,6 +58,7 @@
|
||||
} = require('./kex.js');
|
||||
const {
|
||||
parseKey,
|
||||
@@ -145,7 +142,7 @@ index 7302488..df0c9b3 100644
|
||||
} = require('./keyParser.js');
|
||||
const MESSAGE_HANDLERS = require('./handlers.js');
|
||||
const {
|
||||
@@ -701,11 +702,19 @@ class Protocol {
|
||||
@@ -701,11 +702,19 @@
|
||||
if (signature === false)
|
||||
throw new Error('Error while converting handshake signature');
|
||||
|
||||
@@ -166,7 +163,7 @@ index 7302488..df0c9b3 100644
|
||||
);
|
||||
|
||||
// TODO: simply copy from original "packet" to new `packet` to avoid
|
||||
@@ -729,13 +738,17 @@ class Protocol {
|
||||
@@ -729,13 +738,17 @@
|
||||
writeUInt32BE(packet, pubKeyLen, p += algoLen);
|
||||
packet.set(pubKey, p += 4);
|
||||
|
||||
@@ -189,7 +186,7 @@ index 7302488..df0c9b3 100644
|
||||
|
||||
// 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 @@ class Protocol {
|
||||
@@ -810,20 +823,32 @@
|
||||
if (!signature)
|
||||
throw new Error('Error while converting handshake signature');
|
||||
|
||||
@@ -228,7 +225,7 @@ index 7302488..df0c9b3 100644
|
||||
|
||||
this._authsQueue.push('hostbased');
|
||||
|
||||
@@ -1916,7 +1941,10 @@ class Protocol {
|
||||
@@ -1916,7 +1941,10 @@
|
||||
}
|
||||
|
||||
// SSH-protoversion-softwareversion (SP comments) CR LF
|
||||
@@ -241,10 +238,9 @@ index 7302488..df0c9b3 100644
|
||||
// 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 @@ const OPENSSH_MAX_PKT_LEN = 256 * 1024;
|
||||
@@ -117,6 +117,20 @@
|
||||
|
||||
const bufferParser = makeBufferParser();
|
||||
|
||||
@@ -265,7 +261,7 @@ index 9f33c02..9751164 100644
|
||||
const fakeStderr = {
|
||||
readable: false,
|
||||
writable: false,
|
||||
@@ -155,6 +169,8 @@ class SFTP extends EventEmitter {
|
||||
@@ -155,6 +169,8 @@
|
||||
this._writeReqid = -1;
|
||||
this._requests = {};
|
||||
this._maxInPktLen = OPENSSH_MAX_PKT_LEN;
|
||||
@@ -274,7 +270,7 @@ index 9f33c02..9751164 100644
|
||||
this._maxOutPktLen = 34000;
|
||||
this._maxReadLen =
|
||||
(this._isOpenSSH ? OPENSSH_MAX_PKT_LEN : 34000) - PKT_RW_OVERHEAD;
|
||||
@@ -196,6 +212,53 @@ class SFTP extends EventEmitter {
|
||||
@@ -196,6 +212,53 @@
|
||||
this.emit('end');
|
||||
return;
|
||||
}
|
||||
@@ -328,7 +324,7 @@ index 9f33c02..9751164 100644
|
||||
/*
|
||||
uint32 length
|
||||
byte type
|
||||
@@ -339,7 +402,7 @@ class SFTP extends EventEmitter {
|
||||
@@ -339,7 +402,7 @@
|
||||
uint32 pflags
|
||||
ATTRS attrs
|
||||
*/
|
||||
@@ -337,7 +333,7 @@ index 9f33c02..9751164 100644
|
||||
let p = 9;
|
||||
const buf = Buffer.allocUnsafe(4 + 1 + 4 + 4 + pathLen + 4 + 4 + attrsLen);
|
||||
|
||||
@@ -349,7 +412,7 @@ class SFTP extends EventEmitter {
|
||||
@@ -349,7 +412,7 @@
|
||||
writeUInt32BE(buf, reqid, 5);
|
||||
|
||||
writeUInt32BE(buf, pathLen, p);
|
||||
@@ -346,7 +342,7 @@ index 9f33c02..9751164 100644
|
||||
writeUInt32BE(buf, flags, p += pathLen);
|
||||
writeUInt32BE(buf, attrsFlags, p += 4);
|
||||
if (attrsLen) {
|
||||
@@ -734,7 +797,7 @@ class SFTP extends EventEmitter {
|
||||
@@ -734,7 +797,7 @@
|
||||
uint32 id
|
||||
string filename
|
||||
*/
|
||||
@@ -355,7 +351,7 @@ index 9f33c02..9751164 100644
|
||||
let p = 9;
|
||||
const buf = Buffer.allocUnsafe(4 + 1 + 4 + 4 + fnameLen);
|
||||
|
||||
@@ -744,7 +807,7 @@ class SFTP extends EventEmitter {
|
||||
@@ -744,7 +807,7 @@
|
||||
writeUInt32BE(buf, reqid, 5);
|
||||
|
||||
writeUInt32BE(buf, fnameLen, p);
|
||||
@@ -364,7 +360,7 @@ index 9f33c02..9751164 100644
|
||||
|
||||
this._requests[reqid] = { cb };
|
||||
|
||||
@@ -762,8 +825,8 @@ class SFTP extends EventEmitter {
|
||||
@@ -762,8 +825,8 @@
|
||||
string oldpath
|
||||
string newpath
|
||||
*/
|
||||
@@ -375,7 +371,7 @@ index 9f33c02..9751164 100644
|
||||
let p = 9;
|
||||
const buf = Buffer.allocUnsafe(4 + 1 + 4 + 4 + oldLen + 4 + newLen);
|
||||
|
||||
@@ -773,9 +836,9 @@ class SFTP extends EventEmitter {
|
||||
@@ -773,9 +836,9 @@
|
||||
writeUInt32BE(buf, reqid, 5);
|
||||
|
||||
writeUInt32BE(buf, oldLen, p);
|
||||
@@ -387,7 +383,7 @@ index 9f33c02..9751164 100644
|
||||
|
||||
this._requests[reqid] = { cb };
|
||||
|
||||
@@ -806,7 +869,7 @@ class SFTP extends EventEmitter {
|
||||
@@ -806,7 +869,7 @@
|
||||
string path
|
||||
ATTRS attrs
|
||||
*/
|
||||
@@ -396,7 +392,7 @@ index 9f33c02..9751164 100644
|
||||
let p = 9;
|
||||
const buf = Buffer.allocUnsafe(4 + 1 + 4 + 4 + pathLen + 4 + attrsLen);
|
||||
|
||||
@@ -816,7 +879,7 @@ class SFTP extends EventEmitter {
|
||||
@@ -816,7 +879,7 @@
|
||||
writeUInt32BE(buf, reqid, 5);
|
||||
|
||||
writeUInt32BE(buf, pathLen, p);
|
||||
@@ -405,7 +401,7 @@ index 9f33c02..9751164 100644
|
||||
writeUInt32BE(buf, flags, p += pathLen);
|
||||
if (attrsLen) {
|
||||
p += 4;
|
||||
@@ -844,7 +907,7 @@ class SFTP extends EventEmitter {
|
||||
@@ -844,7 +907,7 @@
|
||||
uint32 id
|
||||
string path
|
||||
*/
|
||||
@@ -414,7 +410,7 @@ index 9f33c02..9751164 100644
|
||||
let p = 9;
|
||||
const buf = Buffer.allocUnsafe(4 + 1 + 4 + 4 + pathLen);
|
||||
|
||||
@@ -854,7 +917,7 @@ class SFTP extends EventEmitter {
|
||||
@@ -854,7 +917,7 @@
|
||||
writeUInt32BE(buf, reqid, 5);
|
||||
|
||||
writeUInt32BE(buf, pathLen, p);
|
||||
@@ -423,7 +419,7 @@ index 9f33c02..9751164 100644
|
||||
|
||||
this._requests[reqid] = { cb };
|
||||
|
||||
@@ -987,7 +1050,7 @@ class SFTP extends EventEmitter {
|
||||
@@ -987,7 +1050,7 @@
|
||||
uint32 id
|
||||
string path
|
||||
*/
|
||||
@@ -432,7 +428,7 @@ index 9f33c02..9751164 100644
|
||||
let p = 9;
|
||||
const buf = Buffer.allocUnsafe(4 + 1 + 4 + 4 + pathLen);
|
||||
|
||||
@@ -997,7 +1060,7 @@ class SFTP extends EventEmitter {
|
||||
@@ -997,7 +1060,7 @@
|
||||
writeUInt32BE(buf, reqid, 5);
|
||||
|
||||
writeUInt32BE(buf, pathLen, p);
|
||||
@@ -441,7 +437,7 @@ index 9f33c02..9751164 100644
|
||||
|
||||
this._requests[reqid] = { cb };
|
||||
|
||||
@@ -1014,7 +1077,7 @@ class SFTP extends EventEmitter {
|
||||
@@ -1014,7 +1077,7 @@
|
||||
uint32 id
|
||||
string path
|
||||
*/
|
||||
@@ -450,7 +446,7 @@ index 9f33c02..9751164 100644
|
||||
let p = 9;
|
||||
const buf = Buffer.allocUnsafe(4 + 1 + 4 + 4 + pathLen);
|
||||
|
||||
@@ -1024,7 +1087,7 @@ class SFTP extends EventEmitter {
|
||||
@@ -1024,7 +1087,7 @@
|
||||
writeUInt32BE(buf, reqid, 5);
|
||||
|
||||
writeUInt32BE(buf, pathLen, p);
|
||||
@@ -459,7 +455,7 @@ index 9f33c02..9751164 100644
|
||||
|
||||
this._requests[reqid] = { cb };
|
||||
|
||||
@@ -1041,7 +1104,7 @@ class SFTP extends EventEmitter {
|
||||
@@ -1041,7 +1104,7 @@
|
||||
uint32 id
|
||||
string path
|
||||
*/
|
||||
@@ -468,7 +464,7 @@ index 9f33c02..9751164 100644
|
||||
let p = 9;
|
||||
const buf = Buffer.allocUnsafe(4 + 1 + 4 + 4 + pathLen);
|
||||
|
||||
@@ -1051,7 +1114,7 @@ class SFTP extends EventEmitter {
|
||||
@@ -1051,7 +1114,7 @@
|
||||
writeUInt32BE(buf, reqid, 5);
|
||||
|
||||
writeUInt32BE(buf, pathLen, p);
|
||||
@@ -477,7 +473,7 @@ index 9f33c02..9751164 100644
|
||||
|
||||
this._requests[reqid] = { cb };
|
||||
|
||||
@@ -1080,7 +1143,7 @@ class SFTP extends EventEmitter {
|
||||
@@ -1080,7 +1143,7 @@
|
||||
string path
|
||||
ATTRS attrs
|
||||
*/
|
||||
@@ -486,7 +482,7 @@ index 9f33c02..9751164 100644
|
||||
let p = 9;
|
||||
const buf = Buffer.allocUnsafe(4 + 1 + 4 + 4 + pathLen + 4 + attrsLen);
|
||||
|
||||
@@ -1090,7 +1153,7 @@ class SFTP extends EventEmitter {
|
||||
@@ -1090,7 +1153,7 @@
|
||||
writeUInt32BE(buf, reqid, 5);
|
||||
|
||||
writeUInt32BE(buf, pathLen, p);
|
||||
@@ -495,7 +491,7 @@ index 9f33c02..9751164 100644
|
||||
writeUInt32BE(buf, flags, p += pathLen);
|
||||
if (attrsLen) {
|
||||
p += 4;
|
||||
@@ -1205,7 +1268,7 @@ class SFTP extends EventEmitter {
|
||||
@@ -1205,7 +1268,7 @@
|
||||
uint32 id
|
||||
string path
|
||||
*/
|
||||
@@ -504,7 +500,7 @@ index 9f33c02..9751164 100644
|
||||
let p = 9;
|
||||
const buf = Buffer.allocUnsafe(4 + 1 + 4 + 4 + pathLen);
|
||||
|
||||
@@ -1215,7 +1278,7 @@ class SFTP extends EventEmitter {
|
||||
@@ -1215,7 +1278,7 @@
|
||||
writeUInt32BE(buf, reqid, 5);
|
||||
|
||||
writeUInt32BE(buf, pathLen, p);
|
||||
@@ -513,7 +509,7 @@ index 9f33c02..9751164 100644
|
||||
|
||||
this._requests[reqid] = {
|
||||
cb: (err, names) => {
|
||||
@@ -1243,8 +1306,8 @@ class SFTP extends EventEmitter {
|
||||
@@ -1243,8 +1306,8 @@
|
||||
string linkpath
|
||||
string targetpath
|
||||
*/
|
||||
@@ -524,7 +520,7 @@ index 9f33c02..9751164 100644
|
||||
let p = 9;
|
||||
const buf = Buffer.allocUnsafe(4 + 1 + 4 + 4 + linkLen + 4 + targetLen);
|
||||
|
||||
@@ -1256,14 +1319,14 @@ class SFTP extends EventEmitter {
|
||||
@@ -1256,14 +1319,14 @@
|
||||
if (this._isOpenSSH) {
|
||||
// OpenSSH has linkpath and targetpath positions switched
|
||||
writeUInt32BE(buf, targetLen, p);
|
||||
@@ -543,7 +539,7 @@ index 9f33c02..9751164 100644
|
||||
}
|
||||
|
||||
this._requests[reqid] = { cb };
|
||||
@@ -1281,7 +1344,7 @@ class SFTP extends EventEmitter {
|
||||
@@ -1281,7 +1344,7 @@
|
||||
uint32 id
|
||||
string path
|
||||
*/
|
||||
@@ -552,7 +548,7 @@ index 9f33c02..9751164 100644
|
||||
let p = 9;
|
||||
const buf = Buffer.allocUnsafe(4 + 1 + 4 + 4 + pathLen);
|
||||
|
||||
@@ -1291,7 +1354,7 @@ class SFTP extends EventEmitter {
|
||||
@@ -1291,7 +1354,7 @@
|
||||
writeUInt32BE(buf, reqid, 5);
|
||||
|
||||
writeUInt32BE(buf, pathLen, p);
|
||||
@@ -561,7 +557,7 @@ index 9f33c02..9751164 100644
|
||||
|
||||
this._requests[reqid] = {
|
||||
cb: (err, names) => {
|
||||
@@ -1325,8 +1388,8 @@ class SFTP extends EventEmitter {
|
||||
@@ -1325,8 +1388,8 @@
|
||||
string oldpath
|
||||
string newpath
|
||||
*/
|
||||
@@ -572,7 +568,7 @@ index 9f33c02..9751164 100644
|
||||
let p = 9;
|
||||
const buf =
|
||||
Buffer.allocUnsafe(4 + 1 + 4 + 4 + 24 + 4 + oldLen + 4 + newLen);
|
||||
@@ -1337,11 +1400,11 @@ class SFTP extends EventEmitter {
|
||||
@@ -1337,11 +1400,11 @@
|
||||
writeUInt32BE(buf, reqid, 5);
|
||||
|
||||
writeUInt32BE(buf, 24, p);
|
||||
@@ -587,7 +583,7 @@ index 9f33c02..9751164 100644
|
||||
|
||||
this._requests[reqid] = { cb };
|
||||
|
||||
@@ -1364,7 +1427,7 @@ class SFTP extends EventEmitter {
|
||||
@@ -1364,7 +1427,7 @@
|
||||
string "statvfs@openssh.com"
|
||||
string path
|
||||
*/
|
||||
@@ -596,7 +592,7 @@ index 9f33c02..9751164 100644
|
||||
let p = 9;
|
||||
const buf = Buffer.allocUnsafe(4 + 1 + 4 + 4 + 19 + 4 + pathLen);
|
||||
|
||||
@@ -1374,9 +1437,9 @@ class SFTP extends EventEmitter {
|
||||
@@ -1374,9 +1437,9 @@
|
||||
writeUInt32BE(buf, reqid, 5);
|
||||
|
||||
writeUInt32BE(buf, 19, p);
|
||||
@@ -608,7 +604,7 @@ index 9f33c02..9751164 100644
|
||||
|
||||
this._requests[reqid] = { extended: 'statvfs@openssh.com', cb };
|
||||
|
||||
@@ -1411,7 +1474,7 @@ class SFTP extends EventEmitter {
|
||||
@@ -1411,7 +1474,7 @@
|
||||
writeUInt32BE(buf, reqid, 5);
|
||||
|
||||
writeUInt32BE(buf, 20, p);
|
||||
@@ -617,7 +613,7 @@ index 9f33c02..9751164 100644
|
||||
writeUInt32BE(buf, handleLen, p += 20);
|
||||
buf.set(handle, p += 4);
|
||||
|
||||
@@ -1437,8 +1500,8 @@ class SFTP extends EventEmitter {
|
||||
@@ -1437,8 +1500,8 @@
|
||||
string oldpath
|
||||
string newpath
|
||||
*/
|
||||
@@ -628,7 +624,7 @@ index 9f33c02..9751164 100644
|
||||
let p = 9;
|
||||
const buf =
|
||||
Buffer.allocUnsafe(4 + 1 + 4 + 4 + 20 + 4 + oldLen + 4 + newLen);
|
||||
@@ -1449,11 +1512,11 @@ class SFTP extends EventEmitter {
|
||||
@@ -1449,11 +1512,11 @@
|
||||
writeUInt32BE(buf, reqid, 5);
|
||||
|
||||
writeUInt32BE(buf, 20, p);
|
||||
@@ -643,7 +639,7 @@ index 9f33c02..9751164 100644
|
||||
|
||||
this._requests[reqid] = { cb };
|
||||
|
||||
@@ -1488,7 +1551,7 @@ class SFTP extends EventEmitter {
|
||||
@@ -1488,7 +1551,7 @@
|
||||
writeUInt32BE(buf, reqid, 5);
|
||||
|
||||
writeUInt32BE(buf, 17, p);
|
||||
@@ -652,7 +648,7 @@ index 9f33c02..9751164 100644
|
||||
writeUInt32BE(buf, handleLen, p += 17);
|
||||
buf.set(handle, p += 4);
|
||||
|
||||
@@ -1524,7 +1587,7 @@ class SFTP extends EventEmitter {
|
||||
@@ -1524,7 +1587,7 @@
|
||||
string path
|
||||
ATTRS attrs
|
||||
*/
|
||||
@@ -661,7 +657,7 @@ index 9f33c02..9751164 100644
|
||||
let p = 9;
|
||||
const buf =
|
||||
Buffer.allocUnsafe(4 + 1 + 4 + 4 + 20 + 4 + pathLen + 4 + attrsLen);
|
||||
@@ -1535,10 +1598,10 @@ class SFTP extends EventEmitter {
|
||||
@@ -1535,10 +1598,10 @@
|
||||
writeUInt32BE(buf, reqid, 5);
|
||||
|
||||
writeUInt32BE(buf, 20, p);
|
||||
@@ -674,7 +670,7 @@ index 9f33c02..9751164 100644
|
||||
|
||||
writeUInt32BE(buf, flags, p += pathLen);
|
||||
if (attrsLen) {
|
||||
@@ -1573,7 +1636,7 @@ class SFTP extends EventEmitter {
|
||||
@@ -1573,7 +1636,7 @@
|
||||
string "expand-path@openssh.com"
|
||||
string path
|
||||
*/
|
||||
@@ -683,7 +679,7 @@ index 9f33c02..9751164 100644
|
||||
let p = 9;
|
||||
const buf = Buffer.allocUnsafe(4 + 1 + 4 + 4 + 23 + 4 + pathLen);
|
||||
|
||||
@@ -1583,10 +1646,10 @@ class SFTP extends EventEmitter {
|
||||
@@ -1583,10 +1646,10 @@
|
||||
writeUInt32BE(buf, reqid, 5);
|
||||
|
||||
writeUInt32BE(buf, 23, p);
|
||||
@@ -696,7 +692,7 @@ index 9f33c02..9751164 100644
|
||||
|
||||
this._requests[reqid] = {
|
||||
cb: (err, names) => {
|
||||
@@ -1653,7 +1716,7 @@ class SFTP extends EventEmitter {
|
||||
@@ -1653,7 +1716,7 @@
|
||||
|
||||
writeUInt32BE(buf, 9, p);
|
||||
p += 4;
|
||||
@@ -705,7 +701,7 @@ index 9f33c02..9751164 100644
|
||||
p += 9;
|
||||
|
||||
writeUInt32BE(buf, srcHandle.length, p);
|
||||
@@ -1708,7 +1771,7 @@ class SFTP extends EventEmitter {
|
||||
@@ -1708,7 +1771,7 @@
|
||||
string username
|
||||
*/
|
||||
let p = 0;
|
||||
@@ -714,7 +710,7 @@ index 9f33c02..9751164 100644
|
||||
const buf = Buffer.allocUnsafe(
|
||||
4 + 1
|
||||
+ 4
|
||||
@@ -1728,12 +1791,12 @@ class SFTP extends EventEmitter {
|
||||
@@ -1728,12 +1791,12 @@
|
||||
|
||||
writeUInt32BE(buf, 14, p);
|
||||
p += 4;
|
||||
@@ -729,7 +725,7 @@ index 9f33c02..9751164 100644
|
||||
p += usernameLen;
|
||||
|
||||
this._requests[reqid] = {
|
||||
@@ -1806,7 +1869,7 @@ class SFTP extends EventEmitter {
|
||||
@@ -1806,7 +1869,7 @@
|
||||
|
||||
writeUInt32BE(buf, 30, p);
|
||||
p += 4;
|
||||
@@ -738,7 +734,7 @@ index 9f33c02..9751164 100644
|
||||
p += 30;
|
||||
|
||||
writeUInt32BE(buf, 4 * uids.length, p);
|
||||
@@ -1871,7 +1934,7 @@ class SFTP extends EventEmitter {
|
||||
@@ -1871,7 +1934,7 @@
|
||||
|
||||
message || (message = '');
|
||||
|
||||
@@ -747,7 +743,7 @@ index 9f33c02..9751164 100644
|
||||
let p = 9;
|
||||
const buf = Buffer.allocUnsafe(4 + 1 + 4 + 4 + 4 + msgLen + 4);
|
||||
|
||||
@@ -1884,7 +1947,7 @@ class SFTP extends EventEmitter {
|
||||
@@ -1884,7 +1947,7 @@
|
||||
writeUInt32BE(buf, msgLen, p += 4);
|
||||
p += 4;
|
||||
if (msgLen) {
|
||||
@@ -756,7 +752,7 @@ index 9f33c02..9751164 100644
|
||||
p += msgLen;
|
||||
}
|
||||
|
||||
@@ -1913,7 +1976,7 @@ class SFTP extends EventEmitter {
|
||||
@@ -1913,7 +1976,7 @@
|
||||
const dataLen = (
|
||||
isBuffer
|
||||
? data.length
|
||||
@@ -765,7 +761,7 @@ index 9f33c02..9751164 100644
|
||||
);
|
||||
let p = 9;
|
||||
const buf = Buffer.allocUnsafe(4 + 1 + 4 + 4 + dataLen);
|
||||
@@ -1927,7 +1990,7 @@ class SFTP extends EventEmitter {
|
||||
@@ -1927,7 +1990,7 @@
|
||||
if (isBuffer)
|
||||
buf.set(data, p += 4);
|
||||
else if (isUTF8)
|
||||
@@ -774,7 +770,7 @@ index 9f33c02..9751164 100644
|
||||
else
|
||||
buf.write(data, p += 4, dataLen, encoding);
|
||||
}
|
||||
@@ -1959,13 +2022,13 @@ class SFTP extends EventEmitter {
|
||||
@@ -1959,13 +2022,13 @@
|
||||
? ''
|
||||
: name.filename
|
||||
);
|
||||
@@ -790,7 +786,7 @@ index 9f33c02..9751164 100644
|
||||
|
||||
if (typeof name.attrs === 'object' && name.attrs !== null) {
|
||||
nameAttrs = attrsToBytes(name.attrs);
|
||||
@@ -2011,11 +2074,11 @@ class SFTP extends EventEmitter {
|
||||
@@ -2011,11 +2074,11 @@
|
||||
? ''
|
||||
: name.filename
|
||||
);
|
||||
@@ -804,7 +800,7 @@ index 9f33c02..9751164 100644
|
||||
p += len;
|
||||
}
|
||||
}
|
||||
@@ -2026,11 +2089,11 @@ class SFTP extends EventEmitter {
|
||||
@@ -2026,11 +2089,11 @@
|
||||
? ''
|
||||
: name.longname
|
||||
);
|
||||
@@ -818,7 +814,7 @@ index 9f33c02..9751164 100644
|
||||
p += len;
|
||||
}
|
||||
}
|
||||
@@ -2749,7 +2812,7 @@ function requestLimits(sftp, cb) {
|
||||
@@ -2749,7 +2812,7 @@
|
||||
writeUInt32BE(buf, reqid, 5);
|
||||
|
||||
writeUInt32BE(buf, 18, p);
|
||||
@@ -827,7 +823,7 @@ index 9f33c02..9751164 100644
|
||||
|
||||
sftp._requests[reqid] = { extended: 'limits@openssh.com', cb };
|
||||
|
||||
@@ -2953,18 +3016,28 @@ const CLIENT_HANDLERS = {
|
||||
@@ -2953,18 +3016,28 @@
|
||||
// spec not specifying an encoding because the specs for newer
|
||||
// versions of the protocol all explicitly specify UTF-8 for
|
||||
// filenames
|
||||
@@ -860,41 +856,21 @@ index 9f33c02..9751164 100644
|
||||
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
|
||||
@@ -71,9 +71,30 @@ const DEFAULT_SERVER_HOST_KEY = [
|
||||
];
|
||||
if (eddsaSupported)
|
||||
@@ -73,7 +73,11 @@
|
||||
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 +181,7 @@ const COMPAT = {
|
||||
@@ -160,6 +164,7 @@
|
||||
DYN_RPORT_BUG: 1 << 2,
|
||||
BUG_DHGEX_LARGE: 1 << 3,
|
||||
IMPLY_RSA_SHA2_SIGALGS: 1 << 4,
|
||||
@@ -902,7 +878,7 @@ index ad77592..a249e00 100644
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
@@ -330,6 +352,7 @@ module.exports = {
|
||||
@@ -330,6 +335,7 @@
|
||||
COMPAT_CHECKS: [
|
||||
[ 'Cisco-1.25', COMPAT.BAD_DHGEX ],
|
||||
[ /^Cisco-1[.]/, COMPAT.BUG_DHGEX_LARGE ],
|
||||
@@ -911,198 +887,9 @@ index ad77592..a249e00 100644
|
||||
[ /^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
|
||||
@@ -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 = (() => {
|
||||
@@ -1377,8 +1377,13 @@
|
||||
this._generator = null;
|
||||
this._minBits = GEX_MIN_BITS;
|
||||
this._prefBits = dhEstimate(this.negotiated);
|
||||
@@ -1117,20 +904,10 @@ index 811e631..422a198 100644
|
||||
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 @@ const SYM_PRIV_PEM = Symbol('Private key PEM');
|
||||
@@ -35,6 +35,9 @@
|
||||
const SYM_PUB_PEM = Symbol('Public key PEM');
|
||||
const SYM_PUB_SSH = Symbol('Public key SSH');
|
||||
const SYM_DECRYPTED = Symbol('Decrypted Key');
|
||||
@@ -1140,7 +917,7 @@ index a276c1a..c8ff9d1 100644
|
||||
|
||||
// Create OpenSSL cipher name -> SSH cipher name conversion table
|
||||
const CIPHER_INFO_OPENSSL = Object.create(null);
|
||||
@@ -258,6 +261,55 @@ function genOpenSSHEdPub(pub) {
|
||||
@@ -258,6 +261,55 @@
|
||||
return publicKey;
|
||||
}
|
||||
|
||||
@@ -1196,7 +973,7 @@ index a276c1a..c8ff9d1 100644
|
||||
function genOpenSSLEdPriv(priv) {
|
||||
const asnWriter = new Ber.Writer();
|
||||
asnWriter.startSequence();
|
||||
@@ -451,6 +503,82 @@ const BaseKey = {
|
||||
@@ -451,6 +503,82 @@
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1258,7 +1035,7 @@ index a276c1a..c8ff9d1 100644
|
||||
+ signedData.set(dataHash, p);
|
||||
+
|
||||
+ let toVerify = rawSig;
|
||||
+ if (getOpenSSHCertBaseType(this.type) === 'sk-ecdsa-sha2-nistp256@openssh.com') {
|
||||
+ if (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');
|
||||
@@ -1279,7 +1056,7 @@ index a276c1a..c8ff9d1 100644
|
||||
|
||||
function OpenSSH_Private(type, comment, privPEM, pubPEM, pubSSH, algo,
|
||||
decrypted) {
|
||||
@@ -463,6 +591,26 @@ function OpenSSH_Private(type, comment, privPEM, pubPEM, pubSSH, algo,
|
||||
@@ -463,6 +591,26 @@
|
||||
this[SYM_DECRYPTED] = decrypted;
|
||||
}
|
||||
OpenSSH_Private.prototype = BaseKey;
|
||||
@@ -1306,7 +1083,7 @@ index a276c1a..c8ff9d1 100644
|
||||
{
|
||||
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 @@ OpenSSH_Private.prototype = BaseKey;
|
||||
@@ -771,6 +919,87 @@
|
||||
privPEM = genOpenSSLECDSAPriv(oid, ecpub, ecpriv);
|
||||
break;
|
||||
}
|
||||
@@ -1394,7 +1171,7 @@ index a276c1a..c8ff9d1 100644
|
||||
default:
|
||||
return new Error(`Unsupported OpenSSH private key type: ${type}`);
|
||||
}
|
||||
@@ -1186,31 +1415,97 @@ function OpenSSH_Public(type, comment, pubPEM, pubSSH, algo) {
|
||||
@@ -1186,31 +1415,97 @@
|
||||
this[SYM_DECRYPTED] = false;
|
||||
}
|
||||
OpenSSH_Public.prototype = BaseKey;
|
||||
@@ -1504,7 +1281,7 @@ index a276c1a..c8ff9d1 100644
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1309,7 +1604,7 @@ RFC4716_Public.prototype = BaseKey;
|
||||
@@ -1309,7 +1604,7 @@
|
||||
}
|
||||
|
||||
|
||||
@@ -1513,7 +1290,7 @@ index a276c1a..c8ff9d1 100644
|
||||
if (!isSupportedKeyType(baseType))
|
||||
return new Error(`Unsupported OpenSSH public key type: ${baseType}`);
|
||||
|
||||
@@ -1317,6 +1612,10 @@ function parseDER(data, baseType, comment, fullType) {
|
||||
@@ -1317,6 +1612,10 @@
|
||||
let oid;
|
||||
let pubPEM = null;
|
||||
let pubSSH = null;
|
||||
@@ -1524,7 +1301,7 @@ index a276c1a..c8ff9d1 100644
|
||||
|
||||
switch (baseType) {
|
||||
case 'ssh-rsa': {
|
||||
@@ -1383,11 +1682,66 @@ function parseDER(data, baseType, comment, fullType) {
|
||||
@@ -1383,11 +1682,66 @@
|
||||
pubSSH = genOpenSSHECDSAPub(oid, ecpub);
|
||||
break;
|
||||
}
|
||||
@@ -1592,7 +1369,7 @@ index a276c1a..c8ff9d1 100644
|
||||
}
|
||||
|
||||
function isSupportedKeyType(type) {
|
||||
@@ -1397,8 +1751,10 @@ function isSupportedKeyType(type) {
|
||||
@@ -1397,8 +1751,10 @@
|
||||
case 'ecdsa-sha2-nistp256':
|
||||
case 'ecdsa-sha2-nistp384':
|
||||
case 'ecdsa-sha2-nistp521':
|
||||
@@ -1603,7 +1380,7 @@ index a276c1a..c8ff9d1 100644
|
||||
if (eddsaSupported)
|
||||
return true;
|
||||
// FALLTHROUGH
|
||||
@@ -1460,7 +1816,13 @@ function parseKey(data, passphrase) {
|
||||
@@ -1460,7 +1816,13 @@
|
||||
if (type !== undefined) {
|
||||
data = binaryKeyParser.readRaw();
|
||||
if (data !== undefined) {
|
||||
@@ -1618,12 +1395,11 @@ index a276c1a..c8ff9d1 100644
|
||||
// Ignore potentially useless errors in case the data was not actually
|
||||
// in the binary format
|
||||
if (ret instanceof Error)
|
||||
@@ -1479,6 +1841,7 @@ function parseKey(data, passphrase) {
|
||||
@@ -1479,6 +1841,7 @@
|
||||
module.exports = {
|
||||
isParsedKey,
|
||||
isSupportedKeyType,
|
||||
- parseDERKey: (data, type) => parseDER(data, type, '', type),
|
||||
+ getOpenSSHCertSignatureAlgorithm,
|
||||
+ parseDERKey: (data, type) => parseDER(data, getOpenSSHCertBaseType(type), '', type),
|
||||
parseDERKey: (data, type) => parseDER(data, type, '', type),
|
||||
parseKey,
|
||||
};
|
||||
@@ -128,13 +128,6 @@ 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", () => {
|
||||
|
||||
Reference in new issue
Block a user