Compare commits

..
Author SHA1 Message Date
陈奇 eccec31d71 docs: record final native input compatibility acceptance 2026-10-26 12:49:07 +00:00
陈奇 38c5f4744c docs: distinguish input timing and native SSH evidence 2026-10-26 12:02:15 +00:00
陈奇 bd5f27ff42 fix: retain connection input mode across renderer hibernation 2026-10-26 11:21:47 +00:00
陈奇 d2b5fde081 fix: default new SSH link and importer drafts to standard input 2026-10-26 11:16:52 +00:00
陈奇 c4ea28d3fe fix: keep Mosh and Eternal Terminal input policy unchanged 2026-10-26 11:10:42 +00:00
陈奇 b8b527abc6 fix: preserve non-SSH input compatibility behavior 2026-10-26 11:08:34 +00:00
陈奇 6788994995 test: cover legacy cloud host migration after import 2026-10-26 11:06:45 +00:00
陈奇 d106c28e8b test: provide required sync vault fields 2026-10-26 11:05:15 +00:00
陈奇 3be237e27a test: update extracted input harnesses for host mode 2026-10-26 11:03:21 +00:00
陈奇 4fdbfc5ac9 feat: save SSH input compatibility per host 2026-10-26 11:02:03 +00:00
陈大猫 392a094297 fix: collapse timestamp gutter on alternate screen (#3975)
Alternate-screen programs still skip line timestamps; collapse the empty left gutter while alt buffer is active and restore on normal screen.

Fixes #3972
2026-10-26 18:13:38 +08:00
陈大猫 f300d583b2 fix(ssh): treat russh banners as single-channel (skip env) (#3974)
Fixes #3973

russh CHANNEL_FAILURE on wantReply=false env desyncs ssh2 FIFO;
add russh to SINGLE_CHANNEL_SOFTWARE_TOKENS so startSession skips env.
OpenSSH path unchanged.
2026-10-26 18:08:34 +08:00
陈大猫 70d58a0cad Merge pull request #3968 from binaricat/codex/fix-sudo-transfer-concurrency
fix(sftp): preserve ordinary sudo transfer concurrency
2026-10-26 17:45:30 +08:00
bincxz b92177ec42 fix(sftp): preserve ordinary sudo transfer concurrency 2026-10-26 17:28:34 +08:00
陈大猫 54481b6306 Merge pull request #3944 from lazzman/fix/single-channel-sftp-folder-upload
fix(sftp): 串行化单通道目录上传事务
2026-10-26 17:04:45 +08:00
bincxz 7e81bece9e test(sftp): wait for the second upload to reach the shared channel 2026-10-26 16:39:44 +08:00
陈大猫 04ee9afa7f fix(mcp): match two-line idle prompts against live PTY CR bytes (#3963)
Related #3958 / follow-up to #3961.

hasExpectedPromptSuffix must match live PTY \r\r\n against a cached two-line idle prompt (CR→LF form) so cancel/completion matching works after readiness.

No release / no tag.
2026-10-26 16:39:38 +08:00
陈大猫 efe5d4530d fix(win): kill leftover Netcatty.exe before NSIS install (#3946) (#3962)
NSIS customInit taskkill /F /IM Netcatty.exe (no /T) so orphaned external-MCP Netcatty.exe cannot lock $INSTDIR during in-app update.

Fixes #3946
2026-10-26 16:30:56 +08:00
陈大猫 32e75b6ace fix(mcp): recognize two-line user@host idle prompts (#3961)
Fixes #3958. extractTrailingIdlePrompt now accepts two-line user@host[:path] + bare $/ # idle prompts (including CR-normalized live PTY form) so MCP terminal_execute getReadyPrompt no longer times out.

No release/tag.
2026-10-26 16:26:29 +08:00
陈大猫 4a831f41c2 Merge pull request #3950 from binaricat/automation/nix-release-v1.1.90
Update Nix release metadata for v1.1.90
2026-10-26 15:09:55 +08:00
github-actions[bot] 141148ae0d Update Nix release metadata for v1.1.90 2026-10-26 05:54:33 +00:00
陈大猫 8d15a29bc6 fix(mcp): recognize Python env-prefixed idle POSIX prompts (#3948)
Idle SSH Bash prompts that start with a Python virtualenv, conda, or BT-panel name such as `(3.12.12) ` were not recognized. Recognition now strips one optional leading `(name) ` prefix, then tests the existing POSIX and Alpine prompt patterns.

Fixes #3947
2026-10-26 13:33:40 +08:00
lazzman fcb6723c3d fix(sftp): 串行化单通道目录上传事务 2026-10-26 12:02:48 +08:00
陈大猫 519c24cc4c fix(terminal): skip keyword highlight on ANSI-colored cells (#3941)
Keyword highlighting no longer overwrites cells whose PTY foreground is already 16/256/truecolor (Starship directory segments). Default-foreground matches still get the unix-path purple.

Fixes #3940
2026-10-26 11:45:28 +08:00
Netcatty-Botandgithub-actions[bot] 0256e7471d Update Nix release metadata for v1.1.89 (#3931)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-10-26 09:45:44 +08:00
github-actions[bot]andgithub-actions[bot] a4b1009c2a chore: update star history charts [skip ci] (#3932)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-10-26 00:10:10 +00:00
陈大猫 54b6f85b09 fix(fonts): include SFMono Nerd Font in the terminal font picker (#3930)
Add `sfmono` to KNOWN_MONOSPACE_FONTS so glued typographic family names like "SFMono Nerd Font" appear in the Settings → Terminal font picker. Matching logic unchanged.

Fixes #3929
2026-10-25 22:46:09 +08:00
陈大猫 62798393c3 fix(mcp): send commands when a PowerShell prompt is only visible (#3928)
Fixes #3927

Gate: Codex thrash (mid-CSI truncate edge) after real P2 alt-screen fix; CI lint-and-test red only on unrelated TERM-SSHD flake; smoke PASS /workspace/netcatty-smoke-shots/pr-3928-20261025-223624.
2026-10-25 22:42:09 +08:00
FtLOi d382f7bd4f fix(ai): recognize Alpine default shell prompts (#3925)
Recognize Alpine ash default host:path prompts (e.g. localhost:~# ) so AI/pty exec readiness accepts idle Alpine sessions without changing PS1.

CI lint-and-test red on tip was unrelated: TERM-SSHD failed transfer releases the physical channel (transferBridge.termSshd.test.cjs). Local shellUtils + readiness suite and synthetic Alpine prompt smoke PASS.
2026-10-25 18:16:06 +08:00
陈大猫 ff36dc7bce fix(settings): truncate long Agent CLI paths in AI cards (#3924)
Fixes #3923

Settings content column and Agent CLI path rows use min-w-0 so long paths ellipsize inside the pane instead of overflowing the window.
2026-10-25 14:43:26 +08:00
陈大猫 d45fb75370 fix(settings): use in-app confirm for SFTP file association delete (#3921)
Replace native confirm() with ConfirmDialog when deleting an SFTP file association so Windows Settings inputs keep focus.

Fixes #3920
2026-10-25 13:12:04 +08:00
陈大猫 5a1b50a407 Merge pull request #3916 from binaricat/automation/nix-release-v1.1.88 2026-10-25 11:23:44 +08:00
copilot-swe-agent[bot]andbinaricat 0e7ba69edb Merge remote-tracking branch 'origin/main' into automation/nix-release-v1.1.88
# Conflicts:
#	nix/release.nix

Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-10-25 03:22:33 +00:00
陈大猫 babf97e2c1 Merge pull request #3917 from difagume/fix/es-locale-parity 2026-10-25 11:21:59 +08:00
陈大猫 622ffec2cd Merge pull request #3913 from binaricat/automation/nix-release-v1.1.87 2026-10-25 11:21:09 +08:00
陈大猫 598de9cda0 fix(ssh): negotiate OpenSSH host certificates (Fixes #3918)
Offer OpenSSH host-certificate algorithms in ssh2 KEX so cert-only bastions (e.g. Instabox ssh-ed25519-cert-v01@openssh.com) can negotiate. Accept underlying key signature types, keep certs in legacy/skip-ECDSA lists, reject RSA cert signature downgrades, and normalize SK cert types before ECDSA verify.

Fixes #3918
2026-10-25 10:03:10 +08:00
Diego Fabricio 3c1afa9b7d fix(i18n): restore Spanish locale parity with English
The Spanish locale had drifted 163 keys behind English, so those strings fell back to English through the default-locale resolution in I18nProvider. English was also missing 42 of the 43 shortcut binding labels that DEFAULT_KEY_BINDINGS declares.

Add the missing Spanish messages for App Lock, FIDO2 security keys, Kubernetes statuses, host connection test, AI provider headers, OpenCode 2, keychain generation, launch at login, terminal layout and progress, and system manager runtime errors. Add the missing English shortcut labels using the canonical label field in DEFAULT_KEY_BINDINGS.

Harden the locale tests so this cannot drift silently again: include es and zh-TW in the App Lock coverage check, include en in the shortcut label check, and add a parity test that pins es to en for keys and interpolation placeholders while keeping the remaining gaps for ru, zh-CN and zh-TW tracked in an explicit pending list.
2026-10-24 13:20:16 -05:00
github-actions[bot] 36d9a7bd7f Update Nix release metadata for v1.1.88 2026-10-24 18:17:59 +00:00
陈大猫 c44633231a fix(mcp): recognize RHEL-family bash prompts as idle (#3915)
Extend POSIX_PROMPT_PATTERN for Oracle/RHEL/CentOS/Fedora [user@host path][#$] (incl. whitespace and ] in \W). Fixes #3914.
2026-10-25 02:01:23 +08:00
github-actions[bot] b1a13011a4 Update Nix release metadata for v1.1.87 2026-10-24 17:12:48 +00:00
陈大猫 631ce6c1d5 fix(serial): keep auto-login armed across wake Enter (#3908)
Wake-only CR/LF no longer permanently disables serial/telnet auto-login while the detector is active.

Fixes #3906
2026-10-25 00:54:09 +08:00
陈大猫 3e9ac6a36b fix(mcp): unpack re2js for external MCP bridge (#3911)
Unpack node_modules/re2js so external MCP bridge scripts under app.asar.unpacked can require it.

Fixes #3910
2026-10-25 00:43:20 +08:00
陈大猫 cbb1262955 fix(terminal): run host startup command after restored SSH reconnect (#3907)
Restored SSH tabs reconnect after app restart but skipped the host startup command because the boot effect closed over a pre-vault host snapshot. Prefer hostRef for SSH when the dial target matches.

Fixes #3905
2026-10-25 00:38:58 +08:00
陈大猫 7dcb52062f Merge pull request #3904 from binaricat/automation/nix-release-v1.1.86
Update Nix release metadata for v1.1.86
2026-10-24 20:50:07 +08:00
github-actions[bot] 2f1cc57c54 Update Nix release metadata for v1.1.86 2026-10-24 12:23:42 +00:00
c54df5e7d5 fix(terminal): resync side-panel refs after queued external writes (#3903)
A pending tool switch stored an intermediate focused-tool map in the ref.
An urgent open on another tab then updated only state, and the identity
guard never copied that tab back, so a later click on the old tool was
ignored. Committed entries now catch up without dropping a write that is
still ahead of the last render.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-24 19:57:00 +08:00
bb1d1ce8f7 fix(terminal): re-read hibernate keep count after eviction (#3902)
A keep-count change during victim drain was still applied from the count
captured when the leave started, so a zero setting could soft-hide the
leaving tab with no later timer to correct it. Retention now uses the live
count, and an already soft-hidden tab re-enters hibernate when the limit hits
zero.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-24 19:56:53 +08:00
96 changed files with 4135 additions and 217 deletions

No files matched your search

+45 -1
View File
@@ -964,9 +964,53 @@ Highlight the focused split pane:
'settings.shortcuts.category.terminal': 'Terminal',
'settings.shortcuts.category.navigation': 'Navigation',
'settings.shortcuts.category.app': 'App',
'settings.shortcuts.binding.toggle-host-tree': 'Toggle Host List',
'settings.shortcuts.category.sftp': 'SFTP',
// Settings > Shortcuts -> key bindings
'settings.shortcuts.binding.switch-tab-1-9': 'Switch to Tab [1...9]',
'settings.shortcuts.binding.next-tab': 'Next Tab',
'settings.shortcuts.binding.prev-tab': 'Previous Tab',
'settings.shortcuts.binding.close-tab': 'Close Tab',
'settings.shortcuts.binding.close-session': 'Close Session Pane',
'settings.shortcuts.binding.new-tab': 'New Local Tab',
'settings.shortcuts.binding.copy': 'Copy from Terminal',
'settings.shortcuts.binding.paste': 'Paste to Terminal',
'settings.shortcuts.binding.paste-selection': 'Paste Selection to Terminal',
'settings.shortcuts.binding.select-all': 'Select All in Terminal',
'settings.shortcuts.binding.clear-buffer': 'Clear Terminal Buffer',
'settings.shortcuts.binding.search-terminal': 'Open Terminal Search',
'settings.shortcuts.binding.increase-terminal-font-size': 'Increase Terminal Font Size',
'settings.shortcuts.binding.decrease-terminal-font-size': 'Decrease Terminal Font Size',
'settings.shortcuts.binding.reset-terminal-font-size': 'Reset Terminal Font Size',
'settings.shortcuts.binding.move-focus': 'Move focus between Split View panes',
'settings.shortcuts.binding.split-horizontal': 'Split Horizontal',
'settings.shortcuts.binding.split-vertical': 'Split Vertical',
'settings.shortcuts.binding.toggle-pane-zoom': 'Toggle Pane Zoom',
'settings.shortcuts.binding.open-hosts': 'Open Hosts Page',
'settings.shortcuts.binding.open-local': 'Open Local Terminal',
'settings.shortcuts.binding.open-sftp': 'Open SFTP',
'settings.shortcuts.binding.port-forwarding': 'Open Port Forwarding',
'settings.shortcuts.binding.command-palette': 'Open Command Palette',
'settings.shortcuts.binding.quick-switch': 'Quick Switch',
'settings.shortcuts.binding.new-workspace': 'New Workspace',
'settings.shortcuts.binding.snippets': 'Open Snippets',
'settings.shortcuts.binding.broadcast': 'Switch the Broadcast Mode',
'settings.shortcuts.binding.toggle-side-panel': 'Toggle Side Panel',
'settings.shortcuts.binding.toggle-host-tree': 'Toggle Host List',
'settings.shortcuts.binding.open-settings': 'Open Settings',
'settings.shortcuts.binding.toggle-opacity': 'Toggle Window Transparency',
'settings.shortcuts.binding.sftp-copy': 'Copy Files',
'settings.shortcuts.binding.sftp-cut': 'Cut Files',
'settings.shortcuts.binding.sftp-paste': 'Paste Files',
'settings.shortcuts.binding.sftp-select-all': 'Select All Files',
'settings.shortcuts.binding.sftp-rename': 'Rename File',
'settings.shortcuts.binding.sftp-delete': 'Delete Files',
'settings.shortcuts.binding.sftp-refresh': 'Refresh',
'settings.shortcuts.binding.sftp-new-folder': 'New Folder',
'settings.shortcuts.binding.sftp-open': 'Open File / Enter Directory',
'settings.shortcuts.binding.sftp-go-parent': 'Go to Parent Directory',
'settings.shortcuts.binding.sftp-navigate-to': 'Navigate to Selected Directory',
// Context menus / common actions
'action.newHost': 'New Host',
'action.newSubfolder': 'New Subfolder',
+3
View File
@@ -975,6 +975,9 @@ export const enVaultMessages: Messages = {
'hostDetails.sshTimeouts.tcpConnect.desc': 'Seconds to wait for the network connection to this host.',
'hostDetails.sshTimeouts.authReady': 'SSH auth timeout',
'hostDetails.sshTimeouts.authReady.desc': 'Seconds to wait after connecting for SSH negotiation and authentication to finish.',
'hostDetails.sshInputCompatibility': 'Input compatibility: send character by character',
'hostDetails.sshInputCompatibility.desc': 'Try enabling this if Chinese input or short pastes lose characters.',
'hostDetails.sshInputCompatibility.reconnect': 'Saved changes take effect after reconnecting.',
'hostDetails.backspaceBehavior': 'Backspace Behavior',
'hostDetails.backspaceBehavior.default': 'Default',
'hostDetails.jumpHosts': 'Proxy via Hosts',
+20
View File
@@ -90,6 +90,15 @@ export const esAiMessages: Messages = {
'ai.providers.advancedParams.hint': 'Déjalo en blanco para usar los valores predeterminados del proveedor.',
'ai.providers.advancedParams.maxTokens.placeholder': 'p. ej. 4096',
'ai.providers.advancedParams.default': 'Predeterminado del proveedor',
'ai.providers.headers': 'Cabeceras HTTP personalizadas',
'ai.providers.headers.help': 'Cabeceras opcionales para este proveedor. Los valores se ocultan y se cifran al guardarlos.',
'ai.providers.headers.name': 'Nombre de la cabecera',
'ai.providers.headers.value': 'Valor de la cabecera',
'ai.providers.headers.add': 'Agregar cabecera',
'ai.providers.headers.remove': 'Quitar cabecera',
'ai.providers.headers.invalid': 'Usa cabeceras HTTP válidas y únicas. Content-Length y Transfer-Encoding se gestionan automáticamente.',
'ai.providers.headers.loadError': 'No se pudieron desbloquear las cabeceras guardadas. Cancela y vuelve a intentarlo después de desbloquear el llavero del sistema.',
'ai.providers.headers.saveError': 'No se pudo guardar de forma segura. Comprueba que el llavero del sistema esté disponible y vuelve a intentarlo.',
// AI Codex
'ai.codex': 'Codex',
@@ -280,6 +289,14 @@ export const esAiMessages: Messages = {
'ai.opencode.customPathPlaceholder': 'p. ej. /usr/local/bin/opencode',
'ai.opencode.check': 'Verificar',
'ai.opencode.resetPath': 'Restablecer',
'ai.opencode.v2.startupTimeout': 'OpenCode 2 no estuvo listo a tiempo. Confirma que `opencode serve` funciona en una terminal y vuelve a intentarlo.',
'ai.opencode.v2.exited': 'OpenCode 2 se detuvo antes de que el chat pudiera iniciarse.',
'ai.opencode.v2.unauthorized': 'OpenCode 2 rechazó la conexión. Vuelve a intentar el chat.',
'ai.opencode.v2.unavailable': 'No se pudo contactar con OpenCode 2. Comprueba que esté instalado y vuelve a intentarlo.',
'ai.opencode.v2.sessionFailed': 'OpenCode 2 no pudo completar este turno.',
'ai.opencode.v2.empty': 'OpenCode 2 devolvió una respuesta vacía. Ejecuta `opencode` en una terminal para configurar la autenticación y los modelos.',
'ai.opencode.v2.contextSkipped': 'OpenCode 2 no pudo aplicar el contexto de la conversación de Netcatty.',
'ai.opencode.v2.mcpSkipped': 'OpenCode 2 no pudo adjuntar una herramienta MCP. El chat continuará sin ella.',
// AI MiMo Code (CLI de Xiaomi MiMo Code — fork de OpenCode)
'ai.mimo.title': 'MiMo Code',
@@ -694,6 +711,9 @@ export const esAiMessages: Messages = {
'terminal.layer.splitHorizontal': 'Dividir arriba y abajo',
'terminal.layer.splitVertical': 'Dividir izquierda y derecha',
'terminal.layer.openInNewSplit': 'Abrir en una división nueva',
'terminal.layer.saveLayoutAsDefault': 'Guardar diseño como predeterminado',
'terminal.layer.layoutSavedAsDefault': 'Diseño guardado como predeterminado',
'terminal.layer.layoutSaveFailed': 'No se pudo guardar el diseño',
'terminal.layer.hostTree.search': 'Buscar hosts...',
'terminal.layer.hostTree.searchButton': 'Buscar',
'terminal.layer.hostTree.tagsButton': 'Filtrar por etiquetas',
+85
View File
@@ -84,6 +84,32 @@ export const esCoreMessages: Messages = {
'credentials.protectionUnavailable.message': 'Las contraseñas y claves guardadas no se pueden descifrar automáticamente en este dispositivo. Vuelve a ingresar las credenciales antes de conectarte.',
'credentials.protectionUnavailable.action': 'Abrir configuración',
// App Lock
'appLock.title': 'Netcatty está bloqueado',
'appLock.reason.default': 'Ingresa tu contraseña de bloqueo para continuar.',
'appLock.reason.startup': 'Netcatty se bloquea cada vez que se abre esta ventana.',
'appLock.reason.idle': 'Netcatty se bloqueó tras el tiempo de inactividad.',
'appLock.reason.manual': 'Netcatty se bloqueó manualmente.',
'appLock.passwordLabel': 'Contraseña de bloqueo',
'appLock.passwordPlaceholder': 'Ingresa la contraseña para desbloquear',
'appLock.unlock': 'Desbloquear',
'appLock.unlocking': 'Desbloqueando...',
'appLock.usePassword': 'Usar contraseña',
'appLock.error.emptyPassword': 'Ingresa la contraseña de bloqueo.',
'appLock.error.incorrectPassword': 'La contraseña de bloqueo es incorrecta.',
'appLock.systemUnlock.unlockWith': 'Desbloquear con {label}',
'appLock.systemUnlock.preparing': 'Preparando {label}...',
'appLock.systemUnlock.verifying': 'Esperando a {label}...',
'appLock.systemUnlock.error': 'No se completó el desbloqueo del sistema. Usa la contraseña de bloqueo para continuar.',
'appLock.logoLabel': 'Netcatty',
'appLock.reset.title': 'Restablecer el bloqueo de la aplicación',
'appLock.reset.description': 'Esto elimina la contraseña de bloqueo actual y desactiva el bloqueo de la aplicación. Crea una nueva contraseña desde Configuración cuando la necesites.',
'appLock.reset.cancel': 'Cancelar',
'appLock.reset.confirm': 'Restablecer el bloqueo de la aplicación',
'appLock.reset.resetting': 'Restableciendo...',
'appLock.reset.error': 'No se pudo restablecer el bloqueo de la aplicación. Inténtalo de nuevo.',
'topTabs.lockApp': 'Bloquear Netcatty',
// Settings shell
'settings.title': 'Configuración',
'settings.tab.application': 'Aplicación',
@@ -119,6 +145,65 @@ export const esCoreMessages: Messages = {
'settings.system.credentials.unavailableHint': 'Las credenciales cifradas en otro perfil de usuario o máquina no se pueden descifrar aquí. Vuelve a ingresar y guarda las credenciales en este dispositivo.',
'settings.system.credentials.portabilityHint': 'La sincronización en la nube es portátil porque usa tu cifrado de clave maestra. El cifrado local de safeStorage está limitado al dispositivo y al usuario.',
// Settings > System > App Lock
'settings.appLock.title': 'Bloqueo de la aplicación',
'settings.appLock.description': 'Solicita una contraseña de bloqueo local cuando Netcatty se abre, tras un periodo de inactividad o cuando lo bloqueas manualmente.',
'settings.appLock.setupTitle': 'Define una contraseña de bloqueo',
'settings.appLock.setupDescription': 'Crea la contraseña a continuación. El bloqueo de la aplicación se activa en cuanto se guarda.',
'settings.appLock.manageTitle': 'El bloqueo de la aplicación está listo',
'settings.appLock.enabledStatus': 'Activado. Netcatty se bloquea al iniciar, tras un periodo de inactividad y cuando lo bloqueas manualmente.',
'settings.appLock.disabledStatus': 'Contraseña guardada, pero el bloqueo de la aplicación está desactivado.',
'settings.appLock.disableTitle': 'Desactivar el bloqueo de la aplicación',
'settings.appLock.disableDescription': 'Ingresa la contraseña de bloqueo actual para dejar de bloquear Netcatty. La contraseña guardada se eliminará; para volver a activar el bloqueo tendrás que crear una nueva.',
'settings.appLock.disable': 'Desactivar el bloqueo de la aplicación',
'settings.appLock.disabling': 'Desactivando...',
'settings.appLock.setupPasswordTitle': 'Crear contraseña de bloqueo',
'settings.appLock.setupPasswordDescription': 'Esta contraseña es necesaria para desbloquear Netcatty y para cambiar o desactivar el bloqueo de la aplicación más adelante.',
'settings.appLock.changePasswordTitle': 'Cambiar la contraseña de bloqueo',
'settings.appLock.changePasswordDescription': 'Ingresa la contraseña de bloqueo actual y luego elige una nueva.',
'settings.appLock.enable': 'Activar el bloqueo de la aplicación',
'settings.appLock.enableDesc': 'Bloquea al iniciar y tras el tiempo de inactividad seleccionado.',
'settings.appLock.timeout': 'Tiempo de inactividad',
'settings.appLock.timeoutDesc': 'Bloquea tras un periodo de inactividad o al cerrar la ventana en la bandeja. Nunca omite ambos casos.',
'settings.appLock.timeout.0': 'Nunca bloquear automáticamente',
'settings.appLock.timeout.1': '1 minuto',
'settings.appLock.timeout.5': '5 minutos',
'settings.appLock.timeout.15': '15 minutos',
'settings.appLock.timeout.30': '30 minutos',
'settings.appLock.timeout.60': '60 minutos',
'settings.appLock.systemUnlock.label': 'Desbloqueo con {label}',
'settings.appLock.systemUnlock.desc': 'Usa {label} como forma alternativa de desbloquear Netcatty en este dispositivo.',
'settings.appLock.systemUnlock.unavailableDesc': '{label} no está disponible en este dispositivo ahora mismo.',
'settings.appLock.systemUnlock.unavailable': 'El desbloqueo del sistema no está disponible en este dispositivo.',
'settings.appLock.systemUnlock.locked': 'Desbloquea Netcatty antes de cambiar el desbloqueo del sistema.',
'settings.appLock.systemUnlock.autoPrompt.label': 'Abrir {label} automáticamente',
'settings.appLock.systemUnlock.autoPrompt.desc': 'Muestra el aviso de {label} automáticamente cuando Netcatty está esperando para desbloquearse.',
'settings.appLock.currentPassword': 'Contraseña actual',
'settings.appLock.currentPasswordPlaceholder': 'Necesaria para desactivar o reemplazar',
'settings.appLock.currentPasswordForDisablePlaceholder': 'Contraseña actual para desactivar el bloqueo de la aplicación',
'settings.appLock.currentPasswordForChangePlaceholder': 'Contraseña actual para cambiarla',
'settings.appLock.newPassword': 'Nueva contraseña',
'settings.appLock.newPasswordPlaceholder': 'Define la contraseña de bloqueo',
'settings.appLock.confirmPassword': 'Confirmar contraseña',
'settings.appLock.confirmPasswordPlaceholder': 'Repite la contraseña',
'settings.appLock.savePassword': 'Definir y activar',
'settings.appLock.savingPassword': 'Guardando...',
'settings.appLock.passwordSet': 'Contraseña configurada',
'settings.appLock.replacePassword': 'Reemplazar contraseña',
'settings.appLock.enableAfterPassword': 'Define una contraseña de bloqueo antes de activar el bloqueo de la aplicación.',
'settings.appLock.localOnlyHint': 'Bloqueo de privacidad local del dispositivo. No se sincroniza y no es una barrera de seguridad del sistema operativo.',
'settings.appLock.validation.currentRequired': 'Ingresa la contraseña de bloqueo actual.',
'settings.appLock.validation.newRequired': 'Ingresa una nueva contraseña de bloqueo.',
'settings.appLock.validation.confirmRequired': 'Confirma la nueva contraseña de bloqueo.',
'settings.appLock.validation.mismatch': 'Las contraseñas nuevas no coinciden.',
'settings.appLock.validation.incorrect': 'La contraseña de bloqueo actual es incorrecta.',
// Settings > System > Auto Launch
'settings.autoLaunch.title': 'Abrir al iniciar sesión',
'settings.autoLaunch.enabled': 'Iniciar al arrancar el sistema',
'settings.autoLaunch.enabledDesc': 'Inicia Netcatty automáticamente al iniciar sesión, oculto en la bandeja del sistema.',
'settings.autoLaunch.unsupportedDesc': 'No disponible en esta plataforma ni en una compilación de desarrollo.',
// Settings > System > Network Proxy
'settings.system.networkProxy.title': 'Proxy de red',
'settings.system.networkProxy.description': 'Proxy HTTP(S) para la sincronización en la nube y los proveedores de IA. Esto es independiente de los perfiles de ProxyJump / ProxyCommand de SSH.',
@@ -33,6 +33,9 @@ export const esSystemManagerMessages: Messages = {
'systemManager.errors.loadPorts': 'No se pudieron cargar los puertos en escucha',
'systemManager.errors.loadServices': 'No se pudieron cargar los servicios de systemd',
'systemManager.errors.sshChannelUnavailable': 'El servidor se negó a abrir un nuevo canal de ejecución. Inténtalo de nuevo más tarde o vuelve a conectar este host.',
'systemManager.errors.nerdctlUnreachable': 'nerdctl no puede conectarse a containerd',
'systemManager.errors.clusterUnreachable': 'No se puede conectar al clúster para el contexto {{context}}',
'systemManager.errors.clusterUnreachableGeneric': 'No se puede conectar al clúster',
'systemManager.unsupportedRemote': 'Este host no permite canales SSH de ejecución adicionales, por lo que el administrador del sistema no está disponible. Abrir este panel ya no desconectará la terminal.',
'systemManager.overview.empty': 'Aún no hay datos de resumen del sistema.',
@@ -212,6 +215,20 @@ export const esSystemManagerMessages: Messages = {
'systemManager.k8s.restarts': '{{count}} reinicios',
'systemManager.k8s.chooseContainer': 'Elegir un contenedor',
'systemManager.k8s.noContext': 'No hay un contexto de Kubernetes en este host',
'systemManager.k8s.status.Running': 'En ejecución',
'systemManager.k8s.status.Pending': 'Pendiente',
'systemManager.k8s.status.Completed': 'Completado',
'systemManager.k8s.status.Succeeded': 'Con éxito',
'systemManager.k8s.status.Failed': 'Fallido',
'systemManager.k8s.status.Unknown': 'Desconocido',
'systemManager.k8s.status.CrashLoopBackOff': 'Bucle de fallos',
'systemManager.k8s.status.ContainerCreating': 'Creando contenedor',
'systemManager.k8s.status.Terminating': 'Terminando',
'systemManager.k8s.status.ImagePullBackOff': 'Reintentando descarga de imagen',
'systemManager.k8s.status.ErrImagePull': 'Error al descargar la imagen',
'systemManager.k8s.status.Error': 'Error',
'systemManager.k8s.status.Evicted': 'Desalojado',
'systemManager.k8s.status.OOMKilled': 'Sin memoria',
'systemManager.ports.unavailable': 'No se detectaron herramientas de puertos en escucha (ss / netstat) en este host.',
'systemManager.ports.loading': 'Cargando puertos en escucha…',
+40
View File
@@ -219,6 +219,7 @@ export const esTerminalMessages: Messages = {
'terminal.progress.timeoutIn': 'Tiempo de espera en {seconds}s',
'terminal.progress.waitingForUserInput': 'Esperando entrada del usuario',
'terminal.progress.disconnected': 'Desconectado',
'terminal.progress.connected': 'Conectado',
'terminal.progress.cancelling': 'Cancelando...',
'terminal.progress.startOver': 'Empezar de nuevo',
'terminal.progress.enterReconnectHint': 'Presiona Enter para reconectar',
@@ -675,6 +676,45 @@ export const esTerminalMessages: Messages = {
'keychain.keyDialog.editTitle': 'Editar clave',
'keychain.keyDialog.editDesc': 'Actualizar esta clave SSH',
'keychain.keyDialog.updateKey': 'Actualizar clave',
'keychain.generate.standardKeys': 'Claves estándar',
'keychain.generate.securityKeys': 'Llaves de seguridad · FIDO2',
'keychain.generate.residentDesc': 'Permite recuperar las credenciales desde esta llave de seguridad.',
'keychain.generate.verifyRequiredDesc': 'Solicita el PIN del dispositivo antes de firmar.',
'keychain.generate.generating': 'Generando…',
'keychain.generate.fidoHint': 'Conecta una llave de seguridad FIDO2. Es posible que se te solicite su PIN o un toque.',
'keychain.generate.resident': 'Guardar las credenciales en la llave',
'keychain.generate.verifyRequired': 'Verificar el PIN cada vez',
'keychain.action.loadResident': 'Cargar claves residentes desde la llave de seguridad',
// FIDO2 security key prompts and recovery states
'fido.prompt.pinTouchDesc': 'Ingresa tu PIN y luego toca la llave para confirmar.',
'fido.prompt.details': 'Detalles de la solicitud',
'fido.prompt.showPin': 'Mostrar PIN',
'fido.prompt.hidePin': 'Ocultar PIN',
'fido.prompt.verifying': 'Verificando…',
'fido.prompt.pinTitle': 'PIN de la llave de seguridad',
'fido.prompt.touchTitle': 'Toca tu llave de seguridad',
'fido.prompt.pinDesc': 'Ingresa el PIN de tu llave de seguridad para continuar.',
'fido.prompt.touchDesc': 'Toca tu llave de seguridad para confirmar tu identidad.',
'fido.prompt.pinLabel': 'PIN',
'fido.prompt.touchWaiting': 'Esperando tu llave…',
'fido.prompt.touchAutomatic': 'Continúa automáticamente después de que la toques',
'fido.prompt.cancelled': 'Se canceló la autenticación con llave de seguridad.',
'fido.prompt.touchDone': 'Ya la toqué',
'fido.prompt.timeout': 'El aviso de FIDO agotó el tiempo de espera. Intenta conectarte de nuevo.',
'fido.recovery.device-unavailable.title': 'Conecta la llave de seguridad correspondiente',
'fido.recovery.device-unavailable.desc': 'Conecta el dispositivo que contiene la credencial de esta llave y vuelve a intentarlo.',
'fido.recovery.timeout.title': 'Se agotó el tiempo de confirmación de la llave de seguridad',
'fido.recovery.timeout.desc': 'La operación del dispositivo se detuvo. Comprueba que tu llave esté conectada, vuelve a intentarlo y sigue el aviso de toque o verificación.',
'fido.recovery.pin-failed.title': 'No se aceptó el PIN de la llave de seguridad',
'fido.recovery.pin-failed.desc': 'Verifica el PIN FIDO2 de este dispositivo antes de reintentar. Varios intentos incorrectos pueden bloquear el dispositivo.',
'fido.recovery.failed.title': 'No se completó la verificación de la llave de seguridad',
'fido.recovery.failed.desc': 'Comprueba que el dispositivo correspondiente esté conectado y admita esta llave. Reintenta o cancela para revisar el dispositivo y la configuración de OpenSSH.',
'fido.recovery.retry': 'Reintentar verificación',
'fido.recovery.usePassword': 'Usar la contraseña configurada',
'fido.recovery.passwordFallback': 'Probando la contraseña configurada para este host',
'fido.error.opensshMissing': 'Se requiere OpenSSH con FIDO/libfido2. En macOS: brew install openssh libfido2.',
'fido.error.agentUnavailable': 'No se pudo iniciar un agente SSH compatible con FIDO.',
// Tabs
'tabs.closeSessionAria': 'Cerrar sesión',
+15
View File
@@ -965,6 +965,9 @@ export const esVaultMessages: Messages = {
'hostDetails.sshTimeouts.tcpConnect.desc': 'Segundos de espera para la conexión de red a este host.',
'hostDetails.sshTimeouts.authReady': 'Tiempo de espera de autenticación SSH',
'hostDetails.sshTimeouts.authReady.desc': 'Segundos de espera después de conectar para que terminen la negociación y la autenticación SSH.',
'hostDetails.sshInputCompatibility': 'Compatibilidad de entrada: enviar carácter por carácter',
'hostDetails.sshInputCompatibility.desc': 'Actívalo si se pierden caracteres al escribir chino o pegar textos cortos.',
'hostDetails.sshInputCompatibility.reconnect': 'Los cambios guardados se aplican al volver a conectar.',
'hostDetails.backspaceBehavior': 'Comportamiento de la tecla Retroceso',
'hostDetails.backspaceBehavior.default': 'Predeterminado',
'hostDetails.jumpHosts': 'Proxy mediante hosts',
@@ -1044,6 +1047,18 @@ export const esVaultMessages: Messages = {
'hostDetails.addTag': 'Agregar una etiqueta...',
'hostDetails.createTag': 'Crear etiqueta',
'hostDetails.createGroup': 'Crear grupo',
// Host connection test
'hostConnectionTest.title': 'Probar conexión',
'hostConnectionTest.test': 'Probar',
'hostConnectionTest.chainOf': 'Cadena {current} de {total}',
'hostConnectionTest.success': 'Conexión correcta',
'hostConnectionTest.successHint': 'El host aceptó tus credenciales. Puedes cerrar esta ventana.',
'hostConnectionTest.connecting': 'Conectando… tiempo de espera en {seconds}s',
'hostConnectionTest.failed': 'No se pudo conectar.',
'hostConnectionTest.retry': 'Reintentar',
'hostConnectionTest.credentialsUnavailable': 'Las credenciales guardadas no se pueden descifrar en este dispositivo. Vuelve a ingresarlas abajo.',
// Entradas de shell local (CMD) en la bóveda
'localShell.subtitle': 'Shell local',
'localShell.panel.title': 'Nueva shell local',
@@ -0,0 +1,120 @@
import assert from "node:assert/strict";
import test from "node:test";
import { DEFAULT_KEY_BINDINGS } from "../../../domain/models/keyBindings.ts";
import en from "./en.ts";
import es from "./es.ts";
import ru from "./ru.ts";
import zhCN from "./zh-CN.ts";
import zhTW from "./zh-TW.ts";
// Locales that are not at full key parity with `en` yet. They are tracked here
// so the remaining debt stays visible instead of silently rendering in English
// through the `DEFAULT_UI_LOCALE` fallback in `I18nProvider.resolveMessage`.
// Move a locale out of this list once it covers every `en` key.
const LOCALES_PENDING_PARITY = ["ru", "zh-CN", "zh-TW"] as const;
const hasKey = (messages: Record<string, string>, key: string) =>
Object.hasOwn(messages, key);
const interpolationTokens = (value: string) =>
[...value.matchAll(/\{\{\s*\w+\s*\}\}|\{\s*\w+\s*\}/g)]
.map((match) => match[0].replace(/[{}]/g, "").trim())
.sort();
test("es defines every message key that en defines", () => {
const missing = Object.keys(en).filter((key) => !hasKey(es, key));
assert.deepEqual(
missing,
[],
`es is missing ${missing.length} message keys`,
);
});
test("es defines no message keys that en does not define", () => {
const extra = Object.keys(es).filter((key) => !hasKey(en, key));
assert.deepEqual(
extra,
[],
`es defines ${extra.length} keys that no longer exist in en`,
);
});
test("es keeps the same interpolation placeholders as en", () => {
const mismatched: string[] = [];
for (const key of Object.keys(es)) {
if (!hasKey(en, key)) continue;
const expected = interpolationTokens(en[key] ?? "").join(",");
const actual = interpolationTokens(es[key] ?? "").join(",");
if (expected !== actual) mismatched.push(key);
}
assert.deepEqual(
mismatched,
[],
`es changes interpolation placeholders for ${mismatched.length} keys`,
);
});
test("every default shortcut has a label in en and es", () => {
const locales = [
{ name: "en", messages: en },
{ name: "es", messages: es },
];
for (const locale of locales) {
const missing = DEFAULT_KEY_BINDINGS.map(
(binding) => `settings.shortcuts.binding.${binding.id}`,
).filter((key) => !hasKey(locale.messages, key));
assert.deepEqual(
missing,
[],
`${locale.name} is missing ${missing.length} shortcut labels`,
);
}
});
test("en shortcut labels stay identical to DEFAULT_KEY_BINDINGS", () => {
// SettingsShortcutsTab falls back to `binding.label` only while the locale
// resolves the key to itself. Once `en` defines the key, the locale becomes
// the rendered source, so the two must not drift apart.
for (const binding of DEFAULT_KEY_BINDINGS) {
const key = `settings.shortcuts.binding.${binding.id}`;
assert.equal(
en[key],
binding.label,
`en label drifted from DEFAULT_KEY_BINDINGS for ${binding.id}`,
);
}
});
test("every locale outside the pending list is at full parity with en", () => {
const locales = [
{ name: "es", messages: es },
{ name: "ru", messages: ru },
{ name: "zh-CN", messages: zhCN },
{ name: "zh-TW", messages: zhTW },
];
for (const locale of locales) {
if ((LOCALES_PENDING_PARITY as readonly string[]).includes(locale.name)) {
continue;
}
const missing = Object.keys(en).filter(
(key) => !hasKey(locale.messages, key),
);
assert.deepEqual(
missing,
[],
`${locale.name} is missing ${missing.length} message keys`,
);
}
});
+3
View File
@@ -1014,6 +1014,9 @@ export const ruVaultMessages: Messages = {
'hostDetails.sshTimeouts.tcpConnect.desc': 'Сколько секунд ждать сетевого подключения к этому хосту.',
'hostDetails.sshTimeouts.authReady': 'Тайм-аут SSH-входа',
'hostDetails.sshTimeouts.authReady.desc': 'Сколько секунд ждать согласования SSH и завершения аутентификации после подключения.',
'hostDetails.sshInputCompatibility': 'Совместимость ввода: отправка по символам',
'hostDetails.sshInputCompatibility.desc': 'Включите, если при вводе китайского текста или короткой вставке теряются символы.',
'hostDetails.sshInputCompatibility.reconnect': 'Сохранённые изменения применяются после повторного подключения.',
'hostDetails.backspaceBehavior': 'Поведение Backspace',
'hostDetails.backspaceBehavior.default': 'По умолчанию',
'hostDetails.jumpHosts': 'Прокси через хосты',
@@ -18,12 +18,19 @@ const LOCALIZED_SETTINGS_LOCALES = [
const APP_LOCK_LOCALES = [
{ name: "en", messages: en },
{ name: "zh-CN", messages: zhCN },
{ name: "es", messages: es },
{ name: "ru", messages: ru },
{ name: "zh-CN", messages: zhCN },
{ name: "zh-TW", messages: zhTW },
];
const ALL_SETTINGS_LOCALES = [
{ name: "en", messages: en },
...LOCALIZED_SETTINGS_LOCALES,
];
test("localized settings include names for every default shortcut", () => {
for (const locale of LOCALIZED_SETTINGS_LOCALES) {
for (const locale of ALL_SETTINGS_LOCALES) {
const missing = DEFAULT_KEY_BINDINGS
.map((binding) => `settings.shortcuts.binding.${binding.id}`)
.filter((key) => !locale.messages[key]);
+3
View File
@@ -384,6 +384,9 @@ export const zhCNVaultMessages: Messages = {
'hostDetails.sshTimeouts.tcpConnect.desc': '等待与该主机建立网络连接的最长时间。',
'hostDetails.sshTimeouts.authReady': '认证超时(秒)',
'hostDetails.sshTimeouts.authReady.desc': '网络连接建立后,等待 SSH 协商和认证完成的最长时间。',
'hostDetails.sshInputCompatibility': '输入兼容模式:逐字发送',
'hostDetails.sshInputCompatibility.desc': '中文输入或短粘贴出现丢字时尝试开启。',
'hostDetails.sshInputCompatibility.reconnect': '保存后重新连接生效。',
'hostDetails.backspaceBehavior': 'Backspace 行为',
'hostDetails.backspaceBehavior.default': '默认',
'hostDetails.jumpHosts': '通过主机代理',
+3
View File
@@ -384,6 +384,9 @@ export const zhTWVaultMessages: Messages = {
'hostDetails.sshTimeouts.tcpConnect.desc': '等待與該主機建立網路連線的最長時間。',
'hostDetails.sshTimeouts.authReady': '驗證逾時(秒)',
'hostDetails.sshTimeouts.authReady.desc': '網路連線建立後,等待 SSH 交握和驗證完成的最長時間。',
'hostDetails.sshInputCompatibility': '輸入相容模式:逐字傳送',
'hostDetails.sshInputCompatibility.desc': '中文輸入或短貼上出現漏字時嘗試開啟。',
'hostDetails.sshInputCompatibility.reconnect': '儲存後重新連線生效。',
'hostDetails.backspaceBehavior': 'Backspace 行為',
'hostDetails.backspaceBehavior.default': '預設',
'hostDetails.jumpHosts': '透過主機代理',
@@ -80,6 +80,76 @@ test("hibernate failure after the soft-hidden marker drops does not retain the l
assert.deepEqual(recoveredOutcomes, ["soft-hide"]);
});
test("a live keep count that still fits after eviction soft-hides the leaving tab", async () => {
let keepCount = 0;
const outcomes: string[] = [];
await retainLeavingTabAfterEviction({
sessionId: "leaving",
candidates: ["victim"],
keepCount,
resolveKeepCount: () => keepCount,
stillWantsRetention: () => true,
softHide: () => outcomes.push("soft-hide"),
fullHibernate: () => outcomes.push("full-hibernate"),
evict: async () => {
keepCount = 2;
await Promise.resolve();
return true;
},
});
assert.deepEqual(outcomes, ["soft-hide"]);
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 0);
});
test("a keep count dropped to zero during eviction full-hibernates the leaving tab", async () => {
let keepCount = 2;
const outcomes: string[] = [];
await retainLeavingTabAfterEviction({
sessionId: "leaving",
candidates: ["victim"],
keepCount,
resolveKeepCount: () => keepCount,
stillWantsRetention: () => true,
softHide: () => outcomes.push("soft-hide"),
fullHibernate: () => outcomes.push("full-hibernate"),
evict: async () => {
keepCount = 0;
await Promise.resolve();
return true;
},
});
assert.deepEqual(outcomes, ["full-hibernate"]);
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 0);
});
test("a keep count that no longer fits after eviction full-hibernates the leaving tab", async () => {
const sessions = ["victim", "other", "leaving"];
for (const sessionId of sessions) terminalHiddenRendererStore.clearSoftHidden(sessionId);
terminalHiddenRendererStore.markSoftHidden("other");
let keepCount = 2;
const outcomes: string[] = [];
try {
await retainLeavingTabAfterEviction({
sessionId: "leaving",
candidates: ["victim"],
keepCount,
resolveKeepCount: () => keepCount,
stillWantsRetention: () => true,
softHide: () => outcomes.push("soft-hide"),
fullHibernate: () => outcomes.push("full-hibernate"),
evict: async () => {
keepCount = 1;
await Promise.resolve();
return true;
},
});
assert.deepEqual(outcomes, ["full-hibernate"]);
assert.equal(terminalHiddenRendererStore.isSoftHidden("leaving"), false);
} finally {
for (const sessionId of sessions) terminalHiddenRendererStore.clearSoftHidden(sessionId);
}
});
test("a leaving tab that no longer wants retention is not soft-hidden or hibernated", async () => {
const outcomes: string[] = [];
await retainLeavingTabAfterEviction({
+9 -1
View File
@@ -11,7 +11,14 @@ import { terminalHiddenRendererStore } from "./terminalHiddenRendererStore.ts";
export async function retainLeavingTabAfterEviction(input: {
sessionId: string;
candidates: readonly string[];
/** Count captured when this leave started. Used only if resolveKeepCount is absent. */
keepCount: number;
/**
* Live keep-count read after eviction settles. Settings can change while the
* victim drains or serializes; retaining against the captured count would
* soft-hide a tab the user no longer wants kept.
*/
resolveKeepCount?: () => number;
stillWantsRetention: () => boolean;
softHide: () => void;
fullHibernate: () => void;
@@ -31,9 +38,10 @@ export async function retainLeavingTabAfterEviction(input: {
}
}
if (!input.stillWantsRetention()) return;
const keepCount = input.resolveKeepCount?.() ?? input.keepCount;
if (
completed
&& terminalHiddenRendererStore.leavingSlotStillFits(input.sessionId, input.keepCount)
&& terminalHiddenRendererStore.leavingSlotStillFits(input.sessionId, keepCount)
) {
input.softHide();
return;
@@ -0,0 +1,108 @@
import assert from "node:assert/strict";
import test from "node:test";
import React from "react";
import { act, create, type ReactTestRenderer } from "react-test-renderer";
import { JSDOM } from "jsdom";
import { installBrowserLockFixture } from "../testSupport/browserLockFixture";
import type { Host } from "../../domain/models";
import { STORAGE_KEY_HOSTS } from "../../infrastructure/config/storageKeys";
// Exercise actual vault save/load/JSON import and sync boundaries, including
// the asynchronous encrypted-field adapter (without a native safeStorage key).
test("host input choices survive save, restart, duplicate, backup and cloud sync", async () => {
const dom = new JSDOM("", { url: "http://localhost" });
const globals = ["window", "localStorage", "CustomEvent", "IS_REACT_ACT_ENVIRONMENT", "navigator"];
const previous = new Map(globals.map(key => [key, Object.getOwnPropertyDescriptor(globalThis, key)]));
Object.defineProperties(globalThis, {
window: { value: dom.window, configurable: true },
localStorage: { value: dom.window.localStorage, configurable: true },
CustomEvent: { value: dom.window.CustomEvent, configurable: true },
IS_REACT_ACT_ENVIRONMENT: { value: true, configurable: true },
});
installBrowserLockFixture();
const { useVaultState } = await import("./useVaultState");
const { buildSyncPayload, applySyncPayload, buildLocalVaultPayload, applyLocalVaultPayload } = await import("../syncPayload");
const { buildVaultHostFromDraft } = await import("../../domain/vaultHostCreate");
const legacy: Host = { id: "legacy", label: "Legacy", hostname: "localhost", username: "agent", protocol: "ssh", tags: [], os: "linux" };
dom.window.localStorage.setItem(STORAGE_KEY_HOSTS, JSON.stringify([legacy]));
let state: ReturnType<typeof useVaultState>;
let renderer: ReactTestRenderer | undefined;
const Probe = () => { state = useVaultState({ syncConnectionLogTerminalData: false }); return null; };
const mount = async () => {
await act(async () => { renderer = create(React.createElement(Probe)); });
for (let attempt = 0; !state!.isInitialized && attempt < 50; attempt++) {
await act(async () => { await new Promise(resolve => setTimeout(resolve, 10)); });
}
assert.equal(state!.isInitialized, true);
await act(async () => { await state!.readPersistedHosts(); });
};
const choices = () => state!.hosts.map(host => [host.id, host.sshInputCompatibility]);
try {
await mount();
assert.deepEqual(choices(), [["legacy", true]]);
const created = buildVaultHostFromDraft({ hostname: "new.example.test", username: "agent" });
if (created.ok === false) throw Error(created.error);
assert.equal(created.ok, true);
const standard = { ...created.host, id: "standard" };
await act(async () => { await state!.updateHosts([...state!.hosts, standard]); });
assert.deepEqual(choices(), [["legacy", true], ["standard", false]]);
const duplicate = { ...state!.hosts[0], id: "copy", label: "Legacy copy" };
await act(async () => { await state!.updateHosts([...state!.hosts, duplicate]); });
await act(async () => { renderer!.unmount(); });
await mount();
assert.deepEqual(choices(), [["legacy", true], ["standard", false], ["copy", true]]);
// A later deliberate false must survive migration, including another boot.
await act(async () => { await state!.updateHosts(state!.hosts.map(host => host.id === "legacy" ? { ...host, sshInputCompatibility: false } : host)); });
const expected = [["legacy", false], ["standard", false], ["copy", true]];
const exported = JSON.stringify(state!.exportData());
await act(async () => { await state!.updateHosts([]); await state!.importDataFromString(exported); });
assert.deepEqual(choices(), expected);
// Use the real sync payload builder and apply importer, not JSON alone.
const cloud = JSON.parse(JSON.stringify(buildSyncPayload({ ...state!.exportData(), identities: state!.identities, knownHosts: state!.knownHosts })));
await act(async () => { await state!.updateHosts([]); await applySyncPayload(cloud, { importVaultData: state!.importDataFromString }); });
assert.deepEqual(choices(), expected);
const backup = JSON.parse(JSON.stringify(buildLocalVaultPayload({ ...state!.exportData(), identities: state!.identities, knownHosts: state!.knownHosts })));
await act(async () => { await state!.updateHosts([]); await applyLocalVaultPayload(backup, { importVaultData: state!.importDataFromString }); });
assert.deepEqual(choices(), expected);
await act(async () => { renderer!.unmount(); });
await mount();
assert.deepEqual(choices(), expected);
assert.deepEqual((await state!.readPersistedHosts()).map(host => [host.id, host.sshInputCompatibility]), expected);
// Older cloud snapshots enter through the same per-record migration.
cloud.hosts = [{ ...legacy, id: "cloud-legacy" }];
await act(async () => { await applySyncPayload(cloud, { importVaultData: state!.importDataFromString }); });
assert.deepEqual(choices(), [["cloud-legacy", true]]);
await act(async () => { await state!.updateHosts(state!.hosts.map(host => ({ ...host, sshInputCompatibility: false }))); });
await act(async () => { renderer!.unmount(); });
await mount();
assert.deepEqual(choices(), [["cloud-legacy", false]]);
// These drafts enter HostDetailsPanel as initialData, then the real vault
// save/normalization path. They must not be mistaken for legacy records.
const { buildSshDeepLinkHostDraft, parseSshDeepLink } = await import("../../domain/sshDeepLink");
const { normalizePluginImporterRecords } = await import("../../domain/pluginImporter");
const linkDraft = buildSshDeepLinkHostDraft(parseSshDeepLink("ssh://agent@new.test")!, { id: "new-link", now: 1 });
const imported = normalizePluginImporterRecords([undefined, false, true].map(choice => ({
type: "draft" as const, draft: { kind: "host" as const, value: {
hostname: `import-${choice}.test`, protocol: "ssh",
...(choice === undefined ? {} : { sshInputCompatibility: choice }),
} },
})));
assert.deepEqual(imported.errors, []);
const explicitCopy = { ...imported.hosts[2], id: "copy-explicit-input" };
const drafts = [linkDraft, ...imported.hosts, explicitCopy];
const draftChoices = drafts.map(host => [host.id, host.sshInputCompatibility]);
assert.deepEqual(drafts.map(host => host.sshInputCompatibility), [false, false, false, true, true]);
await act(async () => { await state!.updateHosts([...state!.hosts, ...drafts]); });
await act(async () => { renderer!.unmount(); });
await mount();
assert.deepEqual(choices(), [["cloud-legacy", false], ...draftChoices]);
} finally {
await act(async () => { renderer?.unmount(); });
dom.window.close();
for (const key of globals) {
const descriptor = previous.get(key);
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else Reflect.deleteProperty(globalThis, key);
}
}
});
+1
View File
@@ -1570,6 +1570,7 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
group: "",
tags: [],
protocol: "ssh",
sshInputCompatibility: false,
order: getNextVaultOrder(hosts),
};
+11
View File
@@ -1,3 +1,14 @@
!macro customInit
; #3946: external MCP hosts launch Netcatty.exe with ELECTRON_RUN_AS_NODE=1
; via netcatty-external-mcp.cmd. Those processes can outlive the host UI as
; orphans and keep $INSTDIR locked, so the in-app updater hangs (AppHangB1).
; Force-kill leftover Netcatty.exe before files are replaced. Do not pass /T:
; electron-updater spawns this installer as a child of the UI Netcatty.exe,
; and /T would terminate the installer itself. Ignore a missing process.
nsExec::ExecToLog "taskkill /F /IM Netcatty.exe"
Pop $0
!macroend
!macro customInstall
; Open a selected folder in a new Netcatty local terminal.
; Args go after `--` so Electron/Chromium does not swallow them. Use
@@ -503,6 +503,19 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
enabled={!!form.showLineTimestamps}
onToggle={() => update("showLineTimestamps", !form.showLineTimestamps)}
/>
{(form.protocol === undefined || form.protocol === "ssh") && !form.moshEnabled && !form.etEnabled && (
<>
<ToggleRow
label={t("hostDetails.sshInputCompatibility")}
hint={t("hostDetails.sshInputCompatibility.desc")}
enabled={form.sshInputCompatibility ?? true}
onToggle={() => update("sshInputCompatibility", !(form.sshInputCompatibility ?? true))}
/>
<p className="text-xs text-muted-foreground">
{t("hostDetails.sshInputCompatibility.reconnect")}
</p>
</>
)}
<HostDetailsSettingRow label={t("hostDetails.backspaceBehavior")}>
<Select
value={form.backspaceBehavior ?? "default"}
+1
View File
@@ -182,6 +182,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
os: "linux",
authMethod: undefined,
authPolicyVersion: 1,
sshInputCompatibility: false,
charset: groupDefaults?.charset ? undefined : "UTF-8",
distroMode: "auto",
createdAt: Date.now(),
+2 -1
View File
@@ -523,7 +523,8 @@ const SettingsPageContent: React.FC<{ settings: SettingsState; appLock?: AppLock
</TabsList>
</div>
<div className="flex-1 h-full flex flex-col min-h-0 bg-muted/10">
{/* min-w-0: row flex item must shrink so long Agent paths truncate inside the pane. */}
<div className="flex-1 h-full flex flex-col min-h-0 min-w-0 bg-muted/10">
{mountedTabs.has("application") && (
<SettingsLazyTab value="application">
<LazySettingsApplicationTab
+16 -2
View File
@@ -2355,8 +2355,14 @@ const TerminalComponent: React.FC<TerminalProps> = ({
return;
}
// Disconnected sessions never stay soft-hidden. A connected session whose
// keep count dropped to zero takes the same upgrade. A positive count stays
// retained until a later leave evicts overflow.
if (softHiddenRef.current) {
if (statusRef.current === "disconnected") {
const retainedKeepCount = statusRef.current === "connected"
? resolveHibernateKeepRendererCount(terminalSettingsRef.current)
: 0;
if (statusRef.current === "disconnected" || !(retainedKeepCount > 0)) {
upgradeSoftHiddenRuntimeToHibernate();
}
return;
@@ -2387,6 +2393,13 @@ const TerminalComponent: React.FC<TerminalProps> = ({
sessionId,
candidates: keepDecision.candidates,
keepCount,
// Re-read after the victim drain. The count above was captured when
// this timer fired; a zero or tighter setting must full-hibernate.
resolveKeepCount: () => (
statusRef.current === "connected"
? resolveHibernateKeepRendererCount(terminalSettingsRef.current)
: 0
),
stillWantsRetention: () => (
hibernateEvictionAttemptRef.current === attempt
&& !isVisibleRef.current
@@ -4050,7 +4063,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
return;
}
finishReconnectPreparation();
xtermRuntimeRef.current?.resetKittyConnectionInputState();
xtermRuntimeRef.current?.resetConnectionInputState(host);
resetKittyKeyboardModeStateForSession(terminalSettingsRef);
if (effectiveTerminalProtocol.startsWith("plugin:")) {
sessionStarters.startPluginConnection(term);
@@ -4756,6 +4769,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
isSearchOpen,
hibernateEnabled: hibernateEnabled,
hibernateDelayMs: resolveTerminalHibernateDelayMs(terminalSettings),
hibernateKeepRendererCount: resolveHibernateKeepRendererCount(terminalSettings),
fileTransferActive: hibernateFileTransferActive,
hibernatedRef,
softHiddenRef,
@@ -8,3 +8,40 @@ test("SFTP settings imports the shared Toggle used by its option rows", () => {
assert.match(source, /<Toggle\b/);
assert.match(source, /Toggle,\s*\n\s*Select,/);
});
function functionBody(functionName: string): string {
const start = source.indexOf(`const ${functionName} = useCallback`);
assert.notEqual(start, -1, `${functionName} should exist`);
const next = source.indexOf("\n const ", start + 1);
return source.slice(start, next === -1 ? source.length : next);
}
test("removing an SFTP file association confirms with the in-app dialog", () => {
assert.match(source, /import \{ ConfirmDialog \} from "\.\.\/\.\.\/ui\/confirm-dialog";/);
assert.doesNotMatch(source, /window\.confirm|globalThis\.confirm|\bconfirm\(/);
assert.match(
source,
/const \[removeConfirmExt, setRemoveConfirmExt\] = useState<string \| null>\(null\);/,
);
const handleRemove = functionBody("handleRemove");
assert.match(handleRemove, /setRemoveConfirmExt\(extension\)/);
assert.doesNotMatch(handleRemove, /removeAssociation\(/);
const handleConfirm = functionBody("handleConfirmRemove");
assert.match(handleConfirm, /if \(!removeConfirmExt\) return;/);
assert.match(handleConfirm, /removeAssociation\(removeConfirmExt\);\s*setRemoveConfirmExt\(null\);/);
const dialogStart = source.indexOf("<ConfirmDialog");
assert.notEqual(dialogStart, -1, "ConfirmDialog should be rendered");
const dialog = source.slice(dialogStart);
assert.match(dialog, /open=\{removeConfirmExt !== null\}/);
assert.match(dialog, /settings\.sftpFileAssociations\.removeConfirm/);
assert.match(dialog, /confirmLabel=\{t\('action\.remove'\)\}/);
assert.match(dialog, /\bdestructive\b/);
assert.match(dialog, /onConfirm=\{handleConfirmRemove\}/);
const onOpenChange = dialog.slice(dialog.indexOf("onOpenChange"), dialog.indexOf("onConfirm"));
assert.match(onOpenChange, /if \(!open\) setRemoveConfirmExt\(null\);/);
assert.doesNotMatch(onOpenChange, /removeAssociation\(/);
});
@@ -9,6 +9,7 @@ import { useSettingsState } from "../../../application/state/useSettingsState";
import type { FileOpenerType, SystemAppInfo } from "../../../lib/sftpFileUtils";
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
import { Button } from "../../ui/button";
import { ConfirmDialog } from "../../ui/confirm-dialog";
import { Tooltip, TooltipContent, TooltipTrigger } from "../../ui/tooltip";
import {
SectionHeader,
@@ -52,6 +53,7 @@ export default function SettingsFileAssociationsTab() {
const defaultOpener = getDefaultOpener();
const [editingExtension, setEditingExtension] = useState<string | null>(null);
const [isSelectingDefaultApp, setIsSelectingDefaultApp] = useState(false);
const [removeConfirmExt, setRemoveConfirmExt] = useState<string | null>(null);
const defaultOpenerValue = useMemo(() => {
if (!defaultOpener) return 'ask';
@@ -60,10 +62,14 @@ export default function SettingsFileAssociationsTab() {
}, [defaultOpener]);
const handleRemove = useCallback((extension: string) => {
if (confirm(t('settings.sftpFileAssociations.removeConfirm', { ext: extension === 'file' ? t('sftp.opener.noExtension') : extension }))) {
removeAssociation(extension);
}
}, [removeAssociation, t]);
setRemoveConfirmExt(extension);
}, []);
const handleConfirmRemove = useCallback(() => {
if (!removeConfirmExt) return;
removeAssociation(removeConfirmExt);
setRemoveConfirmExt(null);
}, [removeAssociation, removeConfirmExt]);
const handleSelectDefaultSystemApp = useCallback(async () => {
setIsSelectingDefaultApp(true);
@@ -372,6 +378,16 @@ export default function SettingsFileAssociationsTab() {
</table>
</div>
)}
<ConfirmDialog
open={removeConfirmExt !== null}
title={removeConfirmExt ? t('settings.sftpFileAssociations.removeConfirm', { ext: removeConfirmExt === 'file' ? t('sftp.opener.noExtension') : removeConfirmExt }) : ''}
confirmLabel={t('action.remove')}
destructive
onOpenChange={(open) => {
if (!open) setRemoveConfirmExt(null);
}}
onConfirm={handleConfirmRemove}
/>
</SettingsTabContent>
);
}
@@ -77,13 +77,13 @@ export const ClaudeCodeCard: React.FC<{
</div>
{found && (
<div className="flex items-center gap-2 text-xs">
<span className="text-muted-foreground">{t('ai.claude.path')}</span>
<span className="font-mono text-foreground truncate">{pathInfo.path}</span>
<div className="flex items-center gap-2 text-xs min-w-0">
<span className="text-muted-foreground shrink-0">{t('ai.claude.path')}</span>
<span className="font-mono text-foreground min-w-0 flex-1 truncate" title={pathInfo.path ?? undefined}>{pathInfo.path}</span>
{pathInfo.version && (
<>
<span className="text-muted-foreground">|</span>
<span className="text-muted-foreground">{pathInfo.version}</span>
<span className="text-muted-foreground shrink-0">|</span>
<span className="text-muted-foreground shrink-0">{pathInfo.version}</span>
</>
)}
</div>
@@ -102,7 +102,7 @@ export const ClaudeCodeCard: React.FC<{
value={customPath}
onChange={(e) => onCustomPathChange(e.target.value)}
placeholder={t('ai.claude.customPathPlaceholder')}
className="flex-1 h-8 rounded-md border border-input bg-background px-3 text-sm font-mono placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
className="flex-1 min-w-0 h-8 rounded-md border border-input bg-background px-3 text-sm font-mono placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
<Button variant="outline" size="sm" onClick={onRecheckPath} disabled={!customPath.trim()}>
<RefreshCw size={14} className="mr-1.5" />
@@ -117,13 +117,13 @@ export const CodebuddyCard: React.FC<{
</div>
{found && (
<div className="flex items-center gap-2 text-xs">
<span className="text-muted-foreground">{t('ai.codebuddy.path')}</span>
<span className="font-mono text-foreground truncate">{pathInfo.path}</span>
<div className="flex items-center gap-2 text-xs min-w-0">
<span className="text-muted-foreground shrink-0">{t('ai.codebuddy.path')}</span>
<span className="font-mono text-foreground min-w-0 flex-1 truncate" title={pathInfo.path ?? undefined}>{pathInfo.path}</span>
{pathInfo.version && (
<>
<span className="text-muted-foreground">|</span>
<span className="text-muted-foreground">{pathInfo.version}</span>
<span className="text-muted-foreground shrink-0">|</span>
<span className="text-muted-foreground shrink-0">{pathInfo.version}</span>
</>
)}
</div>
@@ -142,7 +142,7 @@ export const CodebuddyCard: React.FC<{
value={customPath}
onChange={(e) => onCustomPathChange(e.target.value)}
placeholder={t('ai.codebuddy.customPathPlaceholder')}
className="flex-1 h-8 rounded-md border border-input bg-background px-3 text-sm font-mono placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
className="flex-1 min-w-0 h-8 rounded-md border border-input bg-background px-3 text-sm font-mono placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
<Button variant="outline" size="sm" onClick={onRecheckPath} disabled={!customPath.trim()}>
<RefreshCw size={14} className="mr-1.5" />
@@ -108,13 +108,13 @@ export const CodexConnectionCard: React.FC<{
</div>
{found && (
<div className="flex items-center gap-2 text-xs">
<span className="text-muted-foreground">{t('ai.codex.path')}</span>
<span className="font-mono text-foreground truncate">{pathInfo.path}</span>
<div className="flex items-center gap-2 text-xs min-w-0">
<span className="text-muted-foreground shrink-0">{t('ai.codex.path')}</span>
<span className="font-mono text-foreground min-w-0 flex-1 truncate" title={pathInfo.path ?? undefined}>{pathInfo.path}</span>
{pathInfo.version && (
<>
<span className="text-muted-foreground">|</span>
<span className="text-muted-foreground">{pathInfo.version}</span>
<span className="text-muted-foreground shrink-0">|</span>
<span className="text-muted-foreground shrink-0">{pathInfo.version}</span>
</>
)}
</div>
@@ -133,7 +133,7 @@ export const CodexConnectionCard: React.FC<{
value={customPath}
onChange={(e) => onCustomPathChange(e.target.value)}
placeholder={t('ai.codex.customPathPlaceholder')}
className="flex-1 h-8 rounded-md border border-input bg-background px-3 text-sm font-mono placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
className="flex-1 min-w-0 h-8 rounded-md border border-input bg-background px-3 text-sm font-mono placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
<Button variant="outline" size="sm" onClick={onRecheckPath} disabled={!customPath.trim()}>
<RefreshCw size={14} className="mr-1.5" />
@@ -61,13 +61,13 @@ export const CopilotCliCard: React.FC<{
</div>
{found && (
<div className="flex items-center gap-2 text-xs">
<span className="text-muted-foreground">{t(`${i18nPrefix}.path`)}</span>
<span className="font-mono text-foreground truncate">{pathInfo.path}</span>
<div className="flex items-center gap-2 text-xs min-w-0">
<span className="text-muted-foreground shrink-0">{t(`${i18nPrefix}.path`)}</span>
<span className="font-mono text-foreground min-w-0 flex-1 truncate" title={pathInfo.path ?? undefined}>{pathInfo.path}</span>
{pathInfo.version && (
<>
<span className="text-muted-foreground">|</span>
<span className="text-muted-foreground">{pathInfo.version}</span>
<span className="text-muted-foreground shrink-0">|</span>
<span className="text-muted-foreground shrink-0">{pathInfo.version}</span>
</>
)}
</div>
@@ -87,7 +87,7 @@ export const CopilotCliCard: React.FC<{
value={customPath}
onChange={(e) => onCustomPathChange(e.target.value)}
placeholder={t(`${i18nPrefix}.customPathPlaceholder`)}
className="flex-1 h-8 rounded-md border border-input bg-background px-3 text-sm font-mono placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
className="flex-1 min-w-0 h-8 rounded-md border border-input bg-background px-3 text-sm font-mono placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
)}
<Button variant="outline" size="sm" onClick={onRecheckPath} disabled={!allowEmptyCheck && !customPath.trim()}>
@@ -0,0 +1,132 @@
import test from "node:test";
import assert from "node:assert/strict";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { ClaudeCodeCard } from "./ClaudeCodeCard";
import { CodebuddyCard } from "./CodebuddyCard";
import { CodexConnectionCard } from "./CodexConnectionCard";
import { CopilotCliCard } from "./CopilotCliCard";
const LONG_PATH = "C:\\Users\\70541\\AppData\\Local\\Microsoft\\WinGet\\Packages\\Anthropic.ClaudeCode_Microsoft.Winget.Source_8wekyb3d8bbwe\\claude.exe";
const VERSION = "9.9.9-test";
function classList(tag: string): Set<string> {
const match = tag.match(/class="([^"]*)"/);
assert.ok(match, `missing class on ${tag}`);
return new Set(match[1].split(/\s+/));
}
function assertClasses(tag: string, expected: string[]) {
const have = classList(tag);
for (const name of expected) {
assert.ok(have.has(name), `expected ${name} on ${tag}`);
}
}
function openingTagContaining(markup: string, text: string): string {
const idx = markup.indexOf(`>${text}<`);
assert.ok(idx >= 0, `missing text node ${text}`);
const start = markup.lastIndexOf("<", idx);
return markup.slice(start, idx + 1);
}
function pathRowClass(markup: string): string {
const idx = markup.indexOf(LONG_PATH);
assert.ok(idx >= 0, "missing path text");
const matches = [...markup.slice(0, idx).matchAll(/<div\b[^>]*class="([^"]*)"/g)];
assert.ok(matches.length > 0, "missing path row");
return matches[matches.length - 1][1];
}
function assertLongPathTruncates(markup: string) {
const pathTag = openingTagContaining(markup, LONG_PATH);
assertClasses(pathTag, ["truncate", "min-w-0", "flex-1"]);
assert.match(pathTag, new RegExp(`title="${LONG_PATH.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}"`));
assert.ok(pathRowClass(markup).split(/\s+/).includes("min-w-0"), "path row missing min-w-0");
const versionTag = openingTagContaining(markup, VERSION);
assertClasses(versionTag, ["shrink-0"]);
const input = markup.match(/<input\b[^>]*>/);
assert.ok(input, "missing custom path input");
assertClasses(input[0], ["flex-1", "min-w-0"]);
assert.match(input[0], new RegExp(`value="${LONG_PATH.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}"`));
}
test("Claude Code card truncates a long CLI path inside the card", () => {
const markup = renderToStaticMarkup(
<ClaudeCodeCard
pathInfo={{ path: LONG_PATH, version: VERSION, available: true }}
isResolvingPath={false}
customPath={LONG_PATH}
onCustomPathChange={() => {}}
onRecheckPath={() => {}}
onResetPath={() => {}}
configDir=""
onConfigDirChange={() => {}}
settingsPath=""
onSettingsPathChange={() => {}}
envText=""
onEnvTextChange={() => {}}
/>,
);
assertLongPathTruncates(markup);
});
test("Codex card truncates a long CLI path inside the card", () => {
const markup = renderToStaticMarkup(
<CodexConnectionCard
pathInfo={{ path: LONG_PATH, version: VERSION, available: true }}
isResolvingPath={false}
customPath={LONG_PATH}
onCustomPathChange={() => {}}
onRecheckPath={() => {}}
onResetPath={() => {}}
integration={null}
loginSession={null}
isLoading={false}
error={null}
onRefresh={() => {}}
onConnect={() => {}}
onCancel={() => {}}
onOpenUrl={() => {}}
onLogout={() => {}}
appServerRuntime="sdk"
appServerStatus={null}
onAppServerRuntimeChange={() => {}}
/>,
);
assertLongPathTruncates(markup);
});
test("Codebuddy card truncates a long CLI path inside the card", () => {
const markup = renderToStaticMarkup(
<CodebuddyCard
pathInfo={{ path: LONG_PATH, version: VERSION, available: true }}
isResolvingPath={false}
customPath={LONG_PATH}
onCustomPathChange={() => {}}
onRecheckPath={() => {}}
onResetPath={() => {}}
internetEnv=""
onInternetEnvChange={() => {}}
envText=""
onEnvTextChange={() => {}}
/>,
);
assertLongPathTruncates(markup);
});
test("Copilot CLI card truncates a long CLI path inside the card", () => {
const markup = renderToStaticMarkup(
<CopilotCliCard
pathInfo={{ path: LONG_PATH, version: VERSION, available: true }}
isResolvingPath={false}
customPath={LONG_PATH}
onCustomPathChange={() => {}}
onRecheckPath={() => {}}
onResetPath={() => {}}
/>,
);
assertLongPathTruncates(markup);
});
+123
View File
@@ -10,9 +10,11 @@ import {
formatTerminalTitleConnectionAddress,
focusTerminalFromDisconnectedNotice,
getLineTimestampToggleHostUpdate,
resolveLineTimestampGutterLayout,
resolveNetworkDeviceTipRightInset,
resolveTerminalRightInset,
resolveTerminalTopOffsets,
subscribeTerminalAlternateScreenChanges,
shouldBlockTerminalReconnectForTarget,
shouldEnableStatusBarDisconnect,
shouldEnableStatusBarReconnect,
@@ -182,6 +184,127 @@ test("line timestamp toolbar toggle is hidden when timestamps are unavailable",
assert.equal(shouldShowLineTimestampToolbarToggle(true, undefined), false);
});
test("line timestamp gutter reserves no space while the alternate screen is active", () => {
assert.deepEqual(resolveLineTimestampGutterLayout({
showLineTimestampGutter: true,
alternateScreenActive: true,
gutterWidth: 72,
}), { enabled: false, width: 0 });
assert.deepEqual(resolveLineTimestampGutterLayout({
showLineTimestampGutter: true,
alternateScreenActive: false,
gutterWidth: 72,
}), { enabled: true, width: 72 });
assert.deepEqual(resolveLineTimestampGutterLayout({
showLineTimestampGutter: false,
alternateScreenActive: false,
gutterWidth: 72,
}), { enabled: false, width: 0 });
const source = readFileSync(new URL("./TerminalView.tsx", import.meta.url), "utf8");
assert.match(source, /const activeLineTimestampGutterWidth = lineTimestampGutterLayout\.width/);
assert.match(source, /enabled=\{lineTimestampGutterLayout\.enabled\}/);
assert.doesNotMatch(source, /enabled=\{showLineTimestampGutter\}/);
assert.match(source, /aria-pressed=\{showLineTimestampGutter\}/);
assert.doesNotMatch(source, /aria-pressed=\{[^}]*alternateScreenActive/);
});
type AlternateScreenWatchTerm = {
buffer: { active: { type: string } };
onRender: (callback: () => void) => { dispose: () => void };
};
function createAlternateScreenWatchTerm(type: "normal" | "alternate") {
const renders: Array<() => void> = [];
let disposed = 0;
const term: AlternateScreenWatchTerm = {
buffer: { active: { type } },
onRender(callback) {
renders.push(callback);
return { dispose: () => { disposed += 1; } };
},
};
return {
term,
renders,
disposeCount: () => disposed,
};
}
test("alternate screen enter and leave drive the timestamp gutter", (t) => {
const watched = createAlternateScreenWatchTerm("normal");
const seen: boolean[] = [];
const stop = subscribeTerminalAlternateScreenChanges(
() => watched.term,
(active) => { seen.push(active); },
);
t.after(stop);
assert.deepEqual(seen, [false]);
watched.term.buffer.active.type = "alternate";
watched.renders[0]();
watched.renders[0]();
watched.term.buffer.active.type = "normal";
watched.renders[0]();
assert.deepEqual(seen, [false, true, false]);
stop();
assert.equal(watched.disposeCount(), 1);
watched.term.buffer.active.type = "alternate";
watched.renders[0]();
assert.deepEqual(seen, [false, true, false]);
});
test("alternate screen watch attaches after the terminal runtime exists", (t) => {
t.mock.timers.enable({ apis: ["setTimeout"] });
const watched = createAlternateScreenWatchTerm("alternate");
let current: AlternateScreenWatchTerm | null = null;
const seen: boolean[] = [];
const stop = subscribeTerminalAlternateScreenChanges(
() => current,
(active) => { seen.push(active); },
);
t.after(() => {
stop();
t.mock.timers.reset();
});
assert.deepEqual(seen, [false]);
current = watched.term;
t.mock.timers.tick(50);
assert.deepEqual(seen, [false, true]);
assert.equal(watched.renders.length, 1);
t.mock.timers.tick(500);
assert.equal(watched.renders.length, 1);
assert.deepEqual(seen, [false, true]);
});
test("alternate screen watch follows a replacement terminal runtime", (t) => {
t.mock.timers.enable({ apis: ["setTimeout"] });
const first = createAlternateScreenWatchTerm("alternate");
const second = createAlternateScreenWatchTerm("normal");
let current: AlternateScreenWatchTerm = first.term;
const seen: boolean[] = [];
const stop = subscribeTerminalAlternateScreenChanges(
() => current,
(active) => { seen.push(active); },
);
t.after(() => {
stop();
t.mock.timers.reset();
});
assert.deepEqual(seen, [true]);
current = second.term;
t.mock.timers.tick(250);
assert.deepEqual(seen, [true, false]);
assert.equal(first.disposeCount(), 1);
assert.equal(second.renders.length, 1);
second.term.buffer.active.type = "alternate";
second.renders[0]();
assert.deepEqual(seen, [true, false, true]);
});
test("selection AI overlay honors the visibility preference", () => {
const overlayPosition = { left: 120, top: 80 };
const addSelection = () => {};
+116 -2
View File
@@ -1,5 +1,6 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import React, { memo, useCallback, useEffect, useRef, useState } from 'react';
import type { Terminal as XTerm } from '@xterm/xterm';
import { ChevronsLeft, GripVertical, Minimize2, Network, PanelLeft, X as XIcon } from 'lucide-react';
import { isTerminalSensitiveInputActive } from './runtime/terminalSensitiveInputRegistry';
import { isSessionReconnectDisabled } from '../top-tabs/SessionTabContextMenuContent';
@@ -18,6 +19,7 @@ import {
resolveTerminalTimestampGutterColor,
resolveTerminalTimestampGutterWidth,
} from './TerminalTimestampGutter';
import { isTerminalAlternateScreenActive } from './terminalHibernateRuntime';
import {
Dialog,
DialogContent,
@@ -122,6 +124,108 @@ export function shouldShowLineTimestampToolbarToggle(
return lineTimestampsAvailable !== false && Boolean(onUpdateHost);
}
export function resolveLineTimestampGutterLayout({
showLineTimestampGutter,
alternateScreenActive,
gutterWidth,
}: {
showLineTimestampGutter: boolean;
alternateScreenActive: boolean;
gutterWidth: number;
}): { enabled: boolean; width: number } {
const enabled = showLineTimestampGutter && !alternateScreenActive;
return { enabled, width: enabled ? gutterWidth : 0 };
}
const ALTERNATE_SCREEN_ATTACH_RETRY_MS = 50;
const ALTERNATE_SCREEN_RUNTIME_WATCH_MS = 250;
type TerminalAlternateScreenDisposable = { dispose: () => void };
type TerminalAlternateScreenWatchTarget = {
buffer?: { active?: { type?: string } };
onRender?: (callback: () => void) => TerminalAlternateScreenDisposable;
};
const readAlternateScreenActive = (
term: TerminalAlternateScreenWatchTarget | null | undefined,
): boolean => {
try {
if (!term?.buffer?.active) return false;
return isTerminalAlternateScreenActive(term as XTerm);
} catch {
return false;
}
};
/**
* Alternate-screen programs intentionally skip timestamp recording. onRender
* collapses and restores the gutter immediately. The timer attaches once xterm
* exists and follows a replacement runtime after hibernate or reconnect.
*/
export function subscribeTerminalAlternateScreenChanges(
getTerm: () => TerminalAlternateScreenWatchTarget | null | undefined,
onChange: (alternateScreenActive: boolean) => void,
): () => void {
let disposed = false;
let timer: ReturnType<typeof setTimeout> | null = null;
let renderDisposable: TerminalAlternateScreenDisposable | null = null;
let attachedTerm: TerminalAlternateScreenWatchTarget | null = null;
let lastActive: boolean | null = null;
const publish = (active: boolean) => {
if (disposed || lastActive === active) return;
lastActive = active;
onChange(active);
};
const detachRender = () => {
renderDisposable?.dispose();
renderDisposable = null;
};
const watch = () => {
if (disposed) return;
if (timer !== null) {
clearTimeout(timer);
timer = null;
}
const term = getTerm() ?? null;
if (term !== attachedTerm) {
detachRender();
attachedTerm = term;
if (term?.onRender) {
renderDisposable = term.onRender(() => {
if (disposed) return;
const current = getTerm() ?? null;
if (current !== term) {
watch();
return;
}
publish(readAlternateScreenActive(current));
});
}
}
publish(readAlternateScreenActive(term));
if (disposed) return;
const delay = term?.onRender
? ALTERNATE_SCREEN_RUNTIME_WATCH_MS
: ALTERNATE_SCREEN_ATTACH_RETRY_MS;
timer = setTimeout(watch, delay);
};
watch();
return () => {
disposed = true;
if (timer !== null) clearTimeout(timer);
detachRender();
attachedTerm = null;
};
}
/** Keep the tab/pane; only tear down the live transport. */
export function shouldEnableStatusBarDisconnect(
status: 'connecting' | 'connected' | 'disconnected' | undefined,
@@ -486,6 +590,11 @@ function TerminalViewInner({ ctx, isPaneMagnified = false }: { ctx: TerminalView
}, [hostTimestampsEnabled, timestampOverride]);
const showLineTimestampGutter = lineTimestampsAvailable !== false
&& (timestampOverride ?? hostTimestampsEnabled);
const [alternateScreenActive, setAlternateScreenActive] = useState(false);
useEffect(() => subscribeTerminalAlternateScreenChanges(
() => termRef.current,
setAlternateScreenActive,
), [sessionId, status, termRef]);
const lineTimestampColor = resolveTerminalTimestampGutterColor(effectiveTheme.colors);
const [lineTimestampGutterWidth, setLineTimestampGutterWidth] = useState(() => (
resolveTerminalTimestampGutterWidth({ fontSize: effectiveFontSize })
@@ -497,7 +606,12 @@ function TerminalViewInner({ ctx, isPaneMagnified = false }: { ctx: TerminalView
const handleLineTimestampGutterWidthChange = useCallback((width: number) => {
setLineTimestampGutterWidth((current) => (current === width ? current : width));
}, []);
const activeLineTimestampGutterWidth = showLineTimestampGutter ? lineTimestampGutterWidth : 0;
const lineTimestampGutterLayout = resolveLineTimestampGutterLayout({
showLineTimestampGutter,
alternateScreenActive,
gutterWidth: lineTimestampGutterWidth,
});
const activeLineTimestampGutterWidth = lineTimestampGutterLayout.width;
const lineTimestampToggleLabel = showLineTimestampGutter
? t("terminal.toolbar.timestampsDisable")
: t("terminal.toolbar.timestampsEnable");
@@ -1086,7 +1200,7 @@ function TerminalViewInner({ ctx, isPaneMagnified = false }: { ctx: TerminalView
<TerminalTimestampGutter
termRef={termRef}
containerRef={containerRef}
enabled={showLineTimestampGutter}
enabled={lineTimestampGutterLayout.enabled}
top={terminalContentTop}
left={terminalBodyInset}
bottom={terminalBottomInset}
@@ -5,6 +5,7 @@ import { createRequire } from "node:module";
import type { SerializeAddon as SerializeAddonType } from "@xterm/addon-serialize";
import type { Terminal as XTermType } from "@xterm/xterm";
import { DEFAULT_KEYWORD_HIGHLIGHT_RULES } from "../../domain/models/terminal.ts";
import { writeTerminalDataWithLineTimestamps } from "./runtime/terminalLineTimestamps.ts";
import {
noteTerminalOutputPressureData,
@@ -81,6 +82,76 @@ test("new output is colored on the written cells without decorations", async ()
term.dispose();
});
test("keyword highlight skips cells whose PTY foreground is already colored", async () => {
// Starship directory segments set a truecolor foreground (#3940). Those
// cells must keep the PTY color; only default-foreground matches are recolored.
const unixPath = DEFAULT_KEYWORD_HIGHLIGHT_RULES.find((entry) => entry.id === "unix-path");
assert.ok(unixPath);
const keywordRgb = Number.parseInt(unixPath.color.slice(1), 16);
const cream = 0xfbf1c7;
const yellow = 0xfabd2f;
const term = new XTerm({ allowProposedApi: true, cols: 80, rows: 5, scrollback: 20 });
const highlighter = new KeywordHighlighter(term);
highlighter.setRules([unixPath], true);
await write(
term,
"\x1b[38;2;251;241;199m\x1b[48;2;250;189;47m/opt\x1b[0m"
+ " \x1b[38;5;220m/mnt\x1b[0m"
+ " \x1b[31m/bin\x1b[0m"
+ " /var",
);
const tokenCells = (text: string) => {
const line = term.buffer.active.getLine(0);
const index = (line?.translateToString(true) ?? "").indexOf(text);
assert.ok(index >= 0, text);
return Array.from({ length: text.length }, (_, offset) => {
const cell = line?.getCell(index + offset);
assert.ok(cell, `${text}[${offset}]`);
return cell;
});
};
for (const cell of tokenCells("/opt")) {
assert.equal(cell.isFgRGB(), true);
assert.equal(cell.getFgColorMode(), 0x3000000);
assert.equal(cell.getFgColor(), cream);
assert.equal(cell.isBgRGB(), true);
assert.equal(cell.getBgColor(), yellow);
}
for (const cell of tokenCells("/mnt")) {
assert.equal(cell.isFgPalette(), true);
assert.equal(cell.getFgColorMode(), 0x2000000);
assert.equal(cell.getFgColor(), 220);
}
for (const cell of tokenCells("/bin")) {
assert.equal(cell.isFgPalette(), true);
assert.equal(cell.getFgColorMode(), 0x1000000);
assert.equal(cell.getFgColor(), 1);
}
for (const cell of tokenCells("/var")) {
assert.equal(cell.isFgRGB(), true);
assert.equal(cell.getFgColor(), keywordRgb);
}
highlighter.setRules([{ ...unixPath, color: "#60A5FA" }], true);
await highlighter.whenSettled();
assert.equal(tokenCells("/opt")[0]?.getFgColor(), cream);
assert.equal(tokenCells("/mnt")[0]?.getFgColor(), 220);
assert.equal(tokenCells("/bin")[0]?.getFgColor(), 1);
assert.equal(tokenCells("/var")[0]?.getFgColor(), BLUE);
highlighter.setRules([{ ...unixPath, color: "#60A5FA" }], false);
await highlighter.whenSettled();
assert.equal(tokenCells("/opt")[0]?.getFgColor(), cream);
assert.equal(tokenCells("/mnt")[0]?.getFgColor(), 220);
assert.equal(tokenCells("/bin")[0]?.getFgColor(), 1);
assert.equal(tokenCells("/var")[0]?.getFgColor(), -1);
highlighter.dispose();
term.dispose();
});
test("ordinary Enter output does not revisit existing history", async () => {
const term = new XTerm({ allowProposedApi: true, cols: 80, rows: 5, scrollback: 20 });
const highlighter = new KeywordHighlighter(term);
+8 -1
View File
@@ -67,6 +67,7 @@ const CELL_INDICES = 3;
const CELL_CONTENT = 0;
const CELL_FG = 1;
const STYLE_MASK = 0xfc000000;
const CM_MASK = 0x3000000;
const CM_RGB = 0x3000000;
const MAX_PLUGIN_HIGHLIGHT_SCAN_CHARS = 4_096;
const MAX_PLUGIN_HIGHLIGHT_MATCHES_PER_WRITE = 256;
@@ -1061,7 +1062,13 @@ export class KeywordHighlighter implements IDisposable {
const dataIndex = x * CELL_INDICES;
const content = internal._data[dataIndex + CELL_CONTENT];
const currentFg = internal._data[dataIndex + CELL_FG];
if (!originals.mask[x] || originals.content[x] !== content) {
// A cell already carrying a PTY palette/truecolor foreground (starship
// directory segments, ANSI logs) must keep that color. Keyword RGB is
// only for the default foreground.
const maskedForContent = originals.mask[x] === 1 && originals.content[x] === content;
const ptyFg = maskedForContent ? originals.fg[x] : currentFg;
if ((ptyFg & CM_MASK) !== 0) continue;
if (!maskedForContent) {
originals.fg[x] = currentFg;
originals.content[x] = content;
originals.mask[x] = 1;
@@ -34,7 +34,7 @@ test("local terminals stay out of hibernate and hidden-renderer paths", () => {
test("reconnect clears keyboard pairing before resetting protocol state", () => {
assert.match(
terminalSource,
/startNewSession = \(\) => \{[\s\S]*resetKittyConnectionInputState\(\);[\s\S]*resetKittyKeyboardModeStateForSession/,
/startNewSession = \(\) => \{[\s\S]*resetConnectionInputState\(host\);[\s\S]*resetKittyKeyboardModeStateForSession/,
);
assert.match(
runtimeSource,
@@ -42,7 +42,7 @@ test("reconnect clears keyboard pairing before resetting protocol state", () =>
);
assert.match(
runtimeSource,
/resetKittyConnectionInputState: clearKittyConnectionInputState/,
/resetConnectionInputState: \(host\) => \{[\s\S]*clearKittyConnectionInputState\(\)/,
);
assert.match(
runtimeSource,
@@ -348,6 +348,62 @@ test("startSerial does not arm the fallback when auto-login is cancelled before
assert.deepEqual(writtenCommands, []);
});
test("restored serial attach ignores a startup command hydrated while the port open is pending", async () => {
const writtenCommands: string[] = [];
let autoLoginComplete: ((evt: { sessionId: string }) => void) | null = null;
let releaseOpen: (() => void) | undefined;
const openPending = new Promise<void>((resolve) => {
releaseOpen = resolve;
});
const bootHost = {
id: "serial-1",
hostname: "/dev/ttyUSB0",
protocol: "serial" as const,
username: "admin",
password: "secret",
};
const hostRef = { current: bootHost };
const backend = buildBackend({
startSerialSession: async () => {
await openPending;
return "serial-session";
},
onTelnetAutoLoginComplete: (
_sessionId: string,
cb: (evt: { sessionId: string }) => void,
) => {
autoLoginComplete = cb;
return noop;
},
onTelnetAutoLoginCancelled: () => noop,
writeToSession: (_sessionId: string, data: string) => {
writtenCommands.push(data);
},
});
const ctx = buildCtx(backend, {
host: bootHost,
hostRef,
terminalSettings: { startupCommandDelayMs: 0 },
});
const started = createTerminalSessionStarters(ctx as never).startSerial(term as never);
// Vault hydration can replace the fallback host after preflight has already
// decided there is no command to hold for auto-login.
hostRef.current = { ...bootHost, startupCommand: "show version" };
releaseOpen?.();
await started;
await new Promise((resolve) => setTimeout(resolve, 0));
autoLoginComplete?.({ sessionId: "session-1" });
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(autoLoginComplete, null);
assert.equal(ctx.hasRunStartupCommandRef.current, false);
assert.deepEqual(writtenCommands, []);
});
for (const runMode of ['paste', 'lineDelay', 'noAutoRun']) {
test(`serial reconnect discards the previous delayed startup command (${runMode})`, async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
@@ -3653,6 +3653,207 @@ test("restored local reconnect runs the host startup command while automatic ret
assert.deepEqual(sessionWrites, []);
});
test("restored SSH attach runs the live host startup command when the boot snapshot predates vault hydration", async () => {
const sessionWrites: Array<{ id: string; data: string; automated?: boolean }> = [];
// Session restore mounts the pane before the vault host (and group defaults)
// replace the fallback snapshot. The dial can still succeed from that
// snapshot; the startup command is only known on the effective host by the
// time the shell is attached.
const bootHost = {
id: "host-1",
label: "Target",
hostname: "target.example.test",
username: "alice",
port: 22,
protocol: "ssh" as const,
password: "secret",
};
const effectiveHost = {
...bootHost,
startupCommand: "echo restored-startup",
startupCommandRunMode: "paste" as const,
};
const hostRef = { current: bootHost };
let releaseDial: (() => void) | undefined;
const dialStarted = new Promise<void>((resolve) => {
releaseDial = resolve;
});
const terminalBackend = {
backendAvailable: () => true,
startSSHSession: async () => {
await dialStarted;
return "ssh-session";
},
onSessionData: () => noop,
onSessionExit: () => noop,
onChainProgress: () => noop,
writeToSession: (id: string, data: string, options?: { automated?: boolean }) => {
sessionWrites.push({ id, data, automated: options?.automated });
},
resizeSession: noop,
closeSession: noop,
};
const ctx = createStarterContext({
host: bootHost,
hostRef,
terminalSettings: { startupCommandDelayMs: 0 },
terminalBackend,
startupCommand: undefined,
suppressHostStartupCommandRef: {
current: shouldSuppressHostStartupCommandOnReconnect("restored"),
},
promptLineBreakStateRef: undefined,
});
const started = createTerminalSessionStarters(ctx as never).startSSH(createTermStub() as never);
hostRef.current = effectiveHost;
releaseDial?.();
await started;
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(sessionWrites, [
{ id: "ssh-session", data: "echo restored-startup\r", automated: true },
]);
sessionWrites.length = 0;
hostRef.current = effectiveHost;
const automaticCtx = createStarterContext({
...ctx,
sessionRef: { current: null },
hasRunStartupCommandRef: { current: false },
suppressHostStartupCommandRef: {
current: shouldSuppressHostStartupCommandOnReconnect("automatic"),
},
});
await createTerminalSessionStarters(automaticCtx as never).startSSH(createTermStub() as never);
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(sessionWrites, []);
});
test("restored SSH attach ignores a live startup command for a different dial target", async () => {
const bootHost = {
id: "host-1",
label: "Target",
hostname: "target.example.test",
username: "alice",
port: 22,
protocol: "ssh" as const,
password: "secret",
};
const mismatches = [
{ label: "hostname", patch: { hostname: "moved.example.test" } },
{ label: "port", patch: { port: 2222 } },
{ label: "username", patch: { username: "bob" } },
{ label: "protocol", patch: { protocol: "telnet" as const } },
];
for (const { label, patch } of mismatches) {
const sessionWrites: Array<{ id: string; data: string; automated?: boolean }> = [];
const hostRef = { current: bootHost };
let releaseDial: (() => void) | undefined;
const dialStarted = new Promise<void>((resolve) => {
releaseDial = resolve;
});
const terminalBackend = {
backendAvailable: () => true,
startSSHSession: async () => {
await dialStarted;
return "ssh-session";
},
onSessionData: () => noop,
onSessionExit: () => noop,
onChainProgress: () => noop,
writeToSession: (id: string, data: string, options?: { automated?: boolean }) => {
sessionWrites.push({ id, data, automated: options?.automated });
},
resizeSession: noop,
closeSession: noop,
};
const ctx = createStarterContext({
host: bootHost,
hostRef,
terminalSettings: { startupCommandDelayMs: 0 },
terminalBackend,
startupCommand: undefined,
suppressHostStartupCommandRef: {
current: shouldSuppressHostStartupCommandOnReconnect("restored"),
},
promptLineBreakStateRef: undefined,
});
const started = createTerminalSessionStarters(ctx as never).startSSH(createTermStub() as never);
hostRef.current = {
...bootHost,
...patch,
startupCommand: "echo moved-startup",
};
releaseDial?.();
await started;
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(ctx.hasRunStartupCommandRef.current, false, label);
assert.deepEqual(sessionWrites, [], label);
}
});
test("restored SSH attach still runs a hydrated startup command when only the default port is materialized", async () => {
const sessionWrites: Array<{ id: string; data: string; automated?: boolean }> = [];
const bootHost = {
id: "host-1",
label: "Target",
hostname: "target.example.test",
username: "alice",
port: 22,
protocol: "ssh" as const,
password: "secret",
};
const hostRef = { current: bootHost };
let releaseDial: (() => void) | undefined;
const dialStarted = new Promise<void>((resolve) => {
releaseDial = resolve;
});
const terminalBackend = {
backendAvailable: () => true,
startSSHSession: async () => {
await dialStarted;
return "ssh-session";
},
onSessionData: () => noop,
onSessionExit: () => noop,
onChainProgress: () => noop,
writeToSession: (id: string, data: string, options?: { automated?: boolean }) => {
sessionWrites.push({ id, data, automated: options?.automated });
},
resizeSession: noop,
closeSession: noop,
};
const ctx = createStarterContext({
host: bootHost,
hostRef,
terminalSettings: { startupCommandDelayMs: 0 },
terminalBackend,
startupCommand: undefined,
suppressHostStartupCommandRef: {
current: shouldSuppressHostStartupCommandOnReconnect("restored"),
},
promptLineBreakStateRef: undefined,
});
const started = createTerminalSessionStarters(ctx as never).startSSH(createTermStub() as never);
hostRef.current = {
...bootHost,
port: undefined,
startupCommand: "echo restored-startup",
};
releaseDial?.();
await started;
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(sessionWrites, [
{ id: "ssh-session", data: "echo restored-startup\r", automated: true },
]);
});
for (const { name, hostDirectory, globalDirectory, expectedDirectory } of [
{ name: "uses saved directory before global default", hostDirectory: "/Users/alice/project", globalDirectory: "/Users/alice/default", expectedDirectory: "/Users/alice/project" },
{ name: "inherits global directory when unset", hostDirectory: undefined, globalDirectory: "/Users/alice/default", expectedDirectory: "/Users/alice/default" },
@@ -985,13 +985,12 @@ test("multi-character plain text goes out as per-character writes (#3077)", asyn
const { readFileSync } = await import("node:fs");
const source = readFileSync(new URL("./createXTermRuntime.ts", import.meta.url), "utf8");
// Strict bastion prompts (QAX) treat one channel write as one keystroke and
// drop multi-character chunks, so IME commits and short raw pastes must be
// sent one character per write instead of one write per commit.
// The legacy workaround selected IME commits and short raw pastes (#3077).
// Host settings now gate that selection at the shared send boundary.
assert.match(source, /perCharacterWrites\?: boolean/);
assert.match(
source,
/getTextInputWireChunks\(outData, options\?\.perCharacterWrites === true\)/,
/getTextInputWireChunks\(outData, usePerCharacterInput && options\?\.perCharacterWrites === true, \(ctx\.host\.protocol === undefined \|\| ctx\.host\.protocol === "ssh"\) && !ctx\.host\.moshEnabled && !ctx\.host\.etEnabled\)/,
);
// Every chunk carries the same write options as the original single write.
const writeLoopIdx = source.indexOf("for (const chunk of getTextInputWireChunks(outData");
@@ -252,6 +252,9 @@ type TerminalBackendApi = {
// renderer hibernation. Weak ownership keeps negotiated keyboard state alive
// for that session without introducing a process-wide session registry.
const kittyKeyboardStates = createKittyKeyboardSessionStateStore();
// The same session-owned ref also keeps the connection's input choice alive
// when hibernation destroys xterm. Only a real reconnect refreshes this choice.
const inputCompatibilityStates = new WeakMap<object, boolean>();
const resolveKittyKeyboardModeState = (
ctx: Pick<
@@ -307,8 +310,8 @@ export type XTermRuntime = {
* terminal reset or FIFO eviction.
*/
hasInlineImages: () => boolean;
/** Clear local/per-target keyboard state before reusing this runtime. */
resetKittyConnectionInputState: () => void;
/** Reset keyboard state and snapshot endpoint settings for a fresh connection. */
resetConnectionInputState: (host: Host) => void;
/** Emit any owed releases before detaching or closing this renderer. */
flushKittyKeyboardReleases: () => void;
/** Transfer negotiated keyboard state across renderer attach handoffs. */
@@ -515,6 +518,13 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
const rendererType = settings?.rendererType ?? "auto";
const bridge = netcattyBridge.get();
const isLocalTerminalHost = ctx.host.protocol === "local";
// Capture the terminal endpoint's choice for this connection, never a chain hop.
const usesPerCharacterInput = (host: Host) =>
(host.protocol !== undefined && host.protocol !== "ssh") || host.moshEnabled === true
|| host.etEnabled === true || host.sshInputCompatibility === true;
let usePerCharacterInput = inputCompatibilityStates.get(ctx.terminalSettingsRef)
?? usesPerCharacterInput(ctx.host);
inputCompatibilityStates.set(ctx.terminalSettingsRef, usePerCharacterInput);
const windowsPty =
platform === "win32" && isLocalTerminalHost
? bridge?.getWindowsPtyInfo?.() ?? { backend: "conpty" as const }
@@ -1325,10 +1335,9 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
lineDelayMs?: number;
pasteRequestId?: string;
/**
* Send plain text as one write per character. Strict bastion prompts
* (QAX) treat one channel write as a keystroke and drop multi-character
* chunks, so IME commits and short raw pastes must go out per
* character (#3077). Bookkeeping still sees the whole payload once.
* Request the legacy IME/short-paste workaround reported in #3077.
* The SSH host's compatibility choice gates the final writes below.
* Bookkeeping still sees the whole payload once.
*/
perCharacterWrites?: boolean;
},
@@ -1550,7 +1559,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
}
ctx.onOutputTriggerUserInputRef?.current?.(outData);
for (const chunk of getTextInputWireChunks(outData, options?.perCharacterWrites === true)) {
for (const chunk of getTextInputWireChunks(outData, usePerCharacterInput && options?.perCharacterWrites === true, (ctx.host.protocol === undefined || ctx.host.protocol === "ssh") && !ctx.host.moshEnabled && !ctx.host.etEnabled)) {
ctx.terminalBackend.writeToSession(id, chunk, {
sensitive,
serialEraseChar,
@@ -3140,7 +3149,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
broadcastLegacyDataPending = null;
}
// Raw multi-character onData is an unbracketed paste (or a committed
// composition): short plain text must reach strict bastions per character.
// composition): selected short plain text can use compatibility writes.
// Decide on the sanitized payload so removed zero-width characters cannot
// keep a qualifying paste above the split cap (#3138).
const sanitizedRawData = sanitizeTerminalInput(data);
@@ -3479,7 +3488,11 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
ligatures.apply(enabled);
},
hasInlineImages,
resetKittyConnectionInputState: clearKittyConnectionInputState,
resetConnectionInputState: (host) => {
usePerCharacterInput = usesPerCharacterInput(host);
inputCompatibilityStates.set(ctx.terminalSettingsRef, usePerCharacterInput);
clearKittyConnectionInputState();
},
flushKittyKeyboardReleases: clearKittyTransientInputState,
getKittyKeyboardModeState: () => snapshotKittyKeyboardModeState(kittyKeyboardMode),
restoreKittyKeyboardModeState: (state) => restoreKittyKeyboardModeState(
@@ -27,7 +27,7 @@ for (const prefix of ['fresh','empty-arrow','submit','paste-submit','interrupt',
const session={encoding, serialPort:{write(data: string | Buffer){for(const byte of Buffer.from(data)) { if(byte===127||byte===8) wire=wire.subarray(0,Math.max(0,wire.length-1)); else wire=Buffer.concat([wire,Buffer.from([byte])]);}}}};
bridge.init({sessions:new Map([['s',session]]),electronModule:{}});
const ctx={host:{protocol:'serial',id:'h',label:'h'},sessionId:'s',sessionRef:{current:'s'},statusRef:{current:'connected'},commandBufferRef:{current:''},onBroadcastInputRef:{current:null},isBroadcastEnabledRef:{current:false},terminalBackend:{writeToSession(id:string,data:string,opts: {serialEraseChar?: string; sensitive?: boolean}){bridge.writeToSession({}, {sessionId:id,data,...opts})}},serialByteOrientedBackspace:true,serialLocalEcho:false};
const env={...Object.assign({},...modules),api: undefined as unknown as {handleTerminalInputData: (data: string) => void; recordSerialSnippetInput: (data: string) => void},ctx,term:{buffer:{active:{cursorX:0,cursorY:0,baseY:0,getLine:()=>undefined}},cols:80},suppressNextTerminalDataBroadcast:false,handlingKittyBroadcast:false,shouldBroadcastTerminalUserInput:()=>false,prioritizeTerminalInput:()=>{},getFlowControllerForTerm:()=>null,shouldSuppressTerminalInputScrollForUserPaste:()=>true,shouldOverrideTerminalUserPasteSensitivity:()=>false,scrollToBottomAfterInput:()=>{},writeLocalTerminalData:()=>{}};
const env={usePerCharacterInput:true,...Object.assign({},...modules),api: undefined as unknown as {handleTerminalInputData: (data: string) => void; recordSerialSnippetInput: (data: string) => void},ctx,term:{buffer:{active:{cursorX:0,cursorY:0,baseY:0,getLine:()=>undefined}},cols:80},suppressNextTerminalDataBroadcast:false,handlingKittyBroadcast:false,shouldBroadcastTerminalUserInput:()=>false,prioritizeTerminalInput:()=>{},getFlowControllerForTerm:()=>null,shouldSuppressTerminalInputScrollForUserPaste:()=>true,shouldOverrideTerminalUserPasteSensitivity:()=>false,scrollToBottomAfterInput:()=>{},writeLocalTerminalData:()=>{}};
vm.runInNewContext(code,env); const {handleTerminalInputData:input,recordSerialSnippetInput:snippet}=env.api;
if(prefix==='empty-arrow') input('\x1b[D');
if(prefix==='submit') {input('旧');input('\x1b[D');input('\r');}
@@ -0,0 +1,171 @@
import assert from "node:assert/strict";
import path from "node:path";
import test from "node:test";
import { fileURLToPath } from "node:url";
import { buildSync } from "esbuild";
import { JSDOM } from "jsdom";
// Real xterm + runtime input callbacks; canvas measurement is stubbed because
// these assertions concern writes, not rendering or a native OS input method.
test("SSH host mode gates every interactive text entry and stays fixed until reconnect", async () => {
const root = fileURLToPath(new URL("../../..", import.meta.url));
const bundle = buildSync({
stdin: {
contents: 'export {createXTermRuntime} from "./components/terminal/runtime/createXTermRuntime"; export {DEFAULT_TERMINAL_SETTINGS} from "./domain/models/terminal"; export {dispatchTerminalLinePaste} from "./components/terminal/runtime/terminalUserPaste"; export {wakeTerminalFromHibernate} from "./components/terminal/terminalRuntimeMount";',
loader: "ts", resolveDir: root,
},
bundle: true, format: "iife", globalName: "runtimeTest", platform: "browser", write: false,
alias: {
"node:diagnostics_channel": path.join(root, "infrastructure/shims/nodeDiagnosticsChannel.ts"),
"diagnostics_channel": path.join(root, "infrastructure/shims/nodeDiagnosticsChannel.ts"),
},
define: { "import.meta.env.DEV": "false", "import.meta.env.PROD": "true", "import.meta": "{}" },
}).outputFiles[0].text;
const dom = new JSDOM("<body/>", { url: "http://localhost", runScripts: "outside-only", pretendToBeVisual: true });
Object.assign(dom.window, { structuredClone });
try {
dom.window.eval(`
window.matchMedia = () => ({matches:false,addListener(){},removeListener(){},addEventListener(){},removeEventListener(){}});
window.ResizeObserver = window.IntersectionObserver = class {observe(){} unobserve(){} disconnect(){}};
HTMLCanvasElement.prototype.getContext = function(type) {
if (type !== '2d') return null;
return new Proxy({canvas:this,measureText:()=>({width:8,fontBoundingBoxAscent:12,fontBoundingBoxDescent:3}),
getImageData:()=>({data:new Uint8ClampedArray(4)}),createLinearGradient:()=>({addColorStop(){}})},
{get:(target,key)=>key in target ? target[key] : ()=>{}});
};
`);
dom.window.eval(bundle);
const result = await dom.window.eval(`(async () => {
const {createXTermRuntime, DEFAULT_TERMINAL_SETTINGS, dispatchTerminalLinePaste, wakeTerminalFromHibernate} = runtimeTest;
const ref = current => ({current});
const panes = [];
const make = host => {
const el = document.createElement('div'); document.body.appendChild(el);
const writes = [], logical = [], broadcasts = [];
const context = {
container:el, host, getHost:()=>host,
fontFamilyId:'jetbrains-mono', resolvedFontFamily:'monospace', fontSize:14,
terminalTheme:{colors:{background:'#111111',foreground:'#eeeeee',cursor:'#ffffff',selection:'#444444'}},
terminalSettingsRef:ref({...DEFAULT_TERMINAL_SETTINGS,rendererType:'dom',fontLigatures:false,cursorBlink:false}),
terminalBackend:{writeToSession:(_,data)=>writes.push(data),resizeSession(){},openExternalAvailable:false},
sessionRef:ref(host.id),hotkeySchemeRef:ref('disabled'),disableTerminalFontZoomRef:ref(false),
keyBindingsRef:ref([]),onHotkeyActionRef:ref(undefined),isBroadcastEnabledRef:ref(true),
onBroadcastInputRef:ref(data=>{broadcasts.push(data);return [];}),
onOutputTriggerUserInputRef:ref(data=>logical.push(data)),
sessionId:host.id,statusRef:ref('connected'),commandBufferRef:ref(''),requestSearchFocus(){},
kittyKeyboardProtocolEnabled:true,
};
const runtime = createXTermRuntime(context);
const pane={...runtime,writes,logical,broadcasts,context,el}; panes.push(pane); return pane;
};
const equal = (actual, expected, label) => {
if (JSON.stringify(actual) !== JSON.stringify(expected)) throw Error(label+': '+JSON.stringify({actual,expected}));
};
const wait = () => new Promise(resolve=>setTimeout(resolve,20));
const check = (pane, text, split, label) => {
equal(pane.writes.splice(0),split ? Array.from(text) : [text],label);
equal(pane.logical.splice(0),[text],label+' logical payload once');
pane.broadcasts.length=0;
};
const hosts=[false,true].map((mode,index)=>({id:'mode-'+index,label:'Mode',hostname:'localhost',protocol:'ssh',sshInputCompatibility:mode}));
const metrics=[];
for (const host of hosts) {
const pane=make(host), term=pane.term, ta=term.textarea, mode=host.sshInputCompatibility;
for (const text of ['中文','👩🏽‍💻é','x'.repeat(32),'x'.repeat(33)]) {
term.paste(text); check(pane,text,mode && Array.from(text).length<=32,'raw paste '+mode);
}
ta.dispatchEvent(new InputEvent('input',{bubbles:true,inputType:'insertText',data:'中国'}));
check(pane,'中国',mode,'insertText');
const longIme='中'.repeat(40);
ta.dispatchEvent(new InputEvent('input',{bubbles:true,inputType:'insertText',data:longIme}));
check(pane,longIme,mode,'IME keeps its uncapped scope');
ta.dispatchEvent(new KeyboardEvent('keydown',{bubbles:true,keyCode:229,key:'Process'}));
ta.dispatchEvent(new InputEvent('input',{bubbles:true,inputType:'insertText',data:'中文'}));
check(pane,'中文',mode,'IME 229 insertText');
ta.dispatchEvent(new CompositionEvent('compositionstart',{bubbles:true,data:''}));
ta.value='中文';
ta.dispatchEvent(new CompositionEvent('compositionupdate',{bubbles:true,data:'中文'}));
ta.dispatchEvent(new CompositionEvent('compositionend',{bubbles:true,data:'中文'}));
await wait(); check(pane,'中文',mode,'compositionend');
// xterm's composition fallback reaches onData independently of insertText.
ta.dispatchEvent(new CompositionEvent('compositionstart',{bubbles:true,data:''}));
term.input('中国',true); check(pane,'中国',mode,'composition onData fallback');
ta.dispatchEvent(new CompositionEvent('compositionend',{bubbles:true,data:''})); await wait();
pane.writes.length=0; pane.logical.length=0;
for (const data of ['\\x1b[A','\\x03','\\x03\\x04','\\x1b[0;;200u']) {
term.input(data,true); check(pane,data,false,'terminal control');
}
await new Promise(resolve=>term.write('\\x1b[?2004h',resolve));
term.paste('中文'); check(pane,'\\x1b[200~中文\\x1b[201~',false,'bracketed paste');
await new Promise(resolve=>term.write('\\x1b[?2004l',resolve));
dispatchTerminalLinePaste(term,'中文\\r',{lineDelayMs:1,sensitive:false});
equal(pane.writes.splice(0),['中文\\r'],'existing line-paste path stays intact');
pane.logical.length=0;
await new Promise(resolve=>term.write('\\x1b[>25u',resolve));
ta.dispatchEvent(new InputEvent('input',{bubbles:true,inputType:'insertText',data:'中文'}));
const kittyWrites=pane.writes.splice(0);
equal(kittyWrites,['\\x1b[0;;20013:25991u'],'Kitty associated text stays in one transport write');
pane.logical.length=0;
await new Promise(resolve=>term.write('\\x1b[<u',resolve));
host.sshInputCompatibility=!mode;
term.paste('中文'); check(pane,'中文',mode,'save does not change active runtime');
// The app reconnects with the existing runtime, so exercise that reset too.
pane.resetConnectionInputState(host);
term.paste('中文'); check(pane,'中文',!mode,'existing runtime reconnect');
pane.dispose();
const reconnected=make(host);
reconnected.term.paste('中文'); check(reconnected,'中文',!mode,'reconnect reads saved mode');
metrics.push({mode,entryPoints:5,reconnectedMode:!mode});
}
// Exercise the actual wake entry that recreates xterm but only reattaches
// the existing backend. Its context contains the newly saved host record.
for (const mode of [false,true]) {
const connectedHost={id:'hibernate-'+mode,label:'Hibernate',hostname:'127.0.0.1',protocol:'ssh',sshInputCompatibility:mode};
const savedHost={...connectedHost,sshInputCompatibility:!mode};
let pane=make(connectedHost), reattached=0;
const wake = async old => {
old.dispose();
const refs={xtermRuntimeRef:ref(null),termRef:ref(null),fitAddonRef:ref(null),
serializeAddonRef:ref(null),searchAddonRef:ref(null),hasRuntimeRef:ref(false)};
const context={...old.context,host:savedHost};
const success=await wakeTerminalFromHibernate({
refs,runtimeContext:context,container:old.el,
getPayload:()=>({snapshot:'',alternateScreen:false,pendingBuffer:''}),
prepareWakeFlow:async()=>true,takePendingBuffer:()=>'',
stopHibernateDataListener(){},stopHibernateListeners(){},
reattachSession(){reattached++;},safeFit(){},resizeSession(){},forceSyncRenderAfterResize(){},
lastFittedSizeRef:ref(null),isBootActiveRef:ref(false),sessionId:connectedHost.id,updateStatus(){},
});
equal(success,true,'hibernate wake succeeds');
equal(context.sessionRef.current,connectedHost.id,'hibernate retains backend session');
const next={...refs.xtermRuntimeRef.current,context,el:old.el,
writes:old.writes,logical:old.logical,broadcasts:old.broadcasts};
panes.push(next); next.writes.length=0; next.logical.length=0; return next;
};
pane=await wake(pane);
pane.term.paste('中文');check(pane,'中文',mode,'wake retains connection-time mode');
pane.resetConnectionInputState(savedHost);
pane.term.paste('中文');check(pane,'中文',!mode,'real reconnect refreshes mode after wake');
pane=await wake(pane);
pane.term.paste('中文');check(pane,'中文',!mode,'next wake retains refreshed connection mode');
equal(reattached,2,'both wakes reattach the existing backend');
}
// The SSH-only field cannot change other transports' existing policy.
for (const protocol of ['local','telnet','serial','mosh','et','ssh-mosh','ssh-et']) {
const pane=make({id:'other-'+protocol,label:protocol,hostname:'127.0.0.1',
protocol:protocol.startsWith('ssh-')?'ssh':protocol,
moshEnabled:protocol==='ssh-mosh',etEnabled:protocol==='ssh-et',sshInputCompatibility:false});
pane.term.paste('中文'); check(pane,'中文',true,protocol+' retains legacy short paste');
pane.term.input('a\\rb',true); check(pane,'a\\rb',true,protocol+' retains legacy control-containing text');
}
for (const pane of panes) { try {pane.dispose();} catch {} }
return JSON.stringify(metrics);
})()`);
assert.deepEqual(JSON.parse(result), [
{ mode: false, entryPoints: 5, reconnectedMode: true },
{ mode: true, entryPoints: 5, reconnectedMode: false },
]);
} finally {
dom.window.close();
}
});
@@ -42,7 +42,7 @@ test("password-prompt input is classified before prompt state reset and cannot b
);
assert.match(
runtimeSource,
/for \(const chunk of getTextInputWireChunks\(outData, options\?\.perCharacterWrites === true\)\) \{\s*ctx\.terminalBackend\.writeToSession\(id, chunk, \{\s*sensitive,\s*serialEraseChar,[\s\S]*?\}\);\s*\}/u,
/for \(const chunk of getTextInputWireChunks\(outData, usePerCharacterInput && options\?\.perCharacterWrites === true, \(ctx\.host\.protocol === undefined \|\| ctx\.host\.protocol === "ssh"\) && !ctx\.host\.moshEnabled && !ctx\.host\.etEnabled\)\) \{\s*ctx\.terminalBackend\.writeToSession\(id, chunk, \{\s*sensitive,\s*serialEraseChar,[\s\S]*?\}\);\s*\}/u,
);
assert.match(
runtimeSource,
@@ -127,6 +127,7 @@ for (const [protocol, lineMode, sensitive] of [
? { isWrapped: false, translateToString: () => liveLine } : undefined } },
};
const env = {
usePerCharacterInput: true,
...Object.assign({}, ...helpers), ...userPaste, ctx, term, crypto, isPluginHostProtocol: () => false, logger: { warn() {} },
netcattyBridge: { get: () => ({ onTerminalPasteWrite: (listener: typeof receiptListener) => { receiptListener = listener; return () => { receiptListener = undefined; }; } }) },
suppressNextTerminalDataBroadcast: false, handlingKittyBroadcast: false,
@@ -341,6 +342,7 @@ test("bypassed sensitive line paste keeps the paced fan-out with sourceSensitive
buffer: { active: { cursorX: 0, cursorY: 0, baseY: 0, getLine: () => undefined } },
};
const env = {
usePerCharacterInput: true,
...Object.assign({}, ...helpers), ...userPaste, ctx, term, crypto, isPluginHostProtocol: () => false, logger: { warn() {} },
netcattyBridge: { get: () => ({ onTerminalPasteWrite: (listener: typeof receiptListener) => { receiptListener = listener; return () => { receiptListener = undefined; }; } }) },
suppressNextTerminalDataBroadcast: false, handlingKittyBroadcast: false,
@@ -66,4 +66,20 @@ test("wire chunking honors the per-character request only for plain text", () =>
assert.deepEqual(getTextInputWireChunks("\x1b[200~ab\x1b[201~", true), [
"\x1b[200~ab\x1b[201~",
]);
});
});
test("compatibility preserves exact Unicode byte order and keeps terminal controls intact", () => {
for (const text of ["中文", "👩🏽‍💻", "e\u0301", "क्‍ष"]) {
assert.deepEqual(Buffer.from(getTextInputWireChunks(text, true).join("")), Buffer.from(text));
}
for (const data of ["\x03", "\x1b[A", "\x1b[200~中文\x1b[201~", "\x1b[0;;200u", "\x03\x04", "a\r\nb"]) {
assert.deepEqual(getTextInputWireChunks(data, true, true), [data]);
}
});
test("non-SSH callers retain legacy control-containing text chunking", () => {
assert.equal(shouldSplitRawPasteInputForWire("a\rb"), true);
assert.deepEqual(getTextInputWireChunks("a\rb", true), ["a", "\r", "b"]);
assert.deepEqual(getTextInputWireChunks("a\rb", true, true), ["a\rb"]);
});
@@ -1,13 +1,16 @@
/**
* Strict bastion prompts (e.g. QAX/奇安信) treat one SSH channel write as a
* single keystroke and silently drop every multi-character chunk (#3077). IME
* commits and short raw pastes therefore have to leave the renderer as
* per-character writes, which is what typing produces and what Xshell does.
* Legacy workaround introduced by #3168 after #3077 reported that individual
* hanzi worked where multi-character input did not. The remote parser and
* other terminals' default send behavior were not verified. SSH sessions now
* select this workaround in their host settings.
*/
/** Raw pastes longer than this keep the single-write behavior. */
export const MAX_RAW_PASTE_PER_CHARACTER_LENGTH = 32;
// Terminal control bytes are intentional: never fragment shortcuts or reports.
// eslint-disable-next-line no-control-regex
const TERMINAL_CONTROL = /[\x00-\x1f\x7f-\x9f]/;
const ESC = "\x1b";
/** True while the payload only carries plain text and no escape sequence. */
@@ -16,7 +19,7 @@ export const isPlainTerminalInputText = (data: string): boolean => !data.include
/** One chunk per Unicode code point so surrogate pairs stay intact. */
export const splitTextIntoCodePointWrites = (data: string): string[] => Array.from(data);
/** IME commits: any plain text with more than one character splits per glyph. */
/** IME commits: any plain text with more than one character splits per code point. */
export const shouldSplitImeTextInputForWire = (text: string): boolean =>
Array.from(text).length > 1 && isPlainTerminalInputText(text);
@@ -35,11 +38,13 @@ export const shouldSplitRawPasteInputForWire = (data: string): boolean => {
return true;
};
/** Chunks to write for one input payload; escape sequences are never split. */
/** Escape sequences never split. SSH callers also preserve terminal controls. */
export const getTextInputWireChunks = (
data: string,
perCharacterWrites: boolean,
preserveTerminalControls = false,
): string[] =>
perCharacterWrites && isPlainTerminalInputText(data)
&& !(preserveTerminalControls && TERMINAL_CONTROL.test(data))
? splitTextIntoCodePointWrites(data)
: [data];
@@ -3,6 +3,56 @@ import { normalizeLineEndings, wrapBracketedPaste } from "../../../lib/utils";
import { markPromptLineBreakCommandPending } from "./promptLineBreak";
import type { TerminalSessionStartersContext } from "./createTerminalSessionStarters.types";
/**
* Host startup is scheduled only after the shell attaches. Restored SSH panes
* boot from the session fallback first; vault hydration (including group
* defaults) replaces `hostRef` while that dial is still in flight. `startSSH`
* keeps dialing the boot snapshot (`ctx.host`). The startup command may come
* from the live host only when that object is still this dial: same id,
* hostname, username, and dial port (`port || 22`), and the same protocol
* when the live host sets one. A missing protocol is a legacy SSH host.
* A saved host edited onto another endpoint since the workspace was saved
* keeps the snapshot, so its command is not typed into the old session.
*
* Serial preflight uses this same resolver to decide whether to wait for
* auto-login, then schedules again after the port opens. Non-SSH transports
* stay on the snapshot, so a command that appears on `hostRef` during the
* open is not typed into a saved username/password exchange.
* A ref that only carries a write-path toggle (no id) is not a host snapshot.
* A different host id is a later pane, not this dial, so keep the snapshot.
*/
const prefersLiveStartupHost = (protocol: string | undefined): boolean =>
protocol === "ssh" || protocol === undefined;
const sshDialPort = (port: number | undefined): number => port || 22;
const sameSshDialTarget = (
dial: TerminalSessionStartersContext["host"],
live: TerminalSessionStartersContext["host"],
): boolean => {
if (live.hostname !== dial.hostname) return false;
if ((live.username ?? "") !== (dial.username ?? "")) return false;
if (sshDialPort(live.port) !== sshDialPort(dial.port)) return false;
// A live protocol, when set, must still be the protocol this dial used.
if (live.protocol !== undefined && live.protocol !== (dial.protocol || "ssh")) return false;
return true;
};
const resolveStartupHost = (
ctx: TerminalSessionStartersContext,
): TerminalSessionStartersContext["host"] => {
const live = ctx.hostRef?.current;
if (
prefersLiveStartupHost(ctx.host.protocol)
&& live
&& live.id === ctx.host.id
&& sameSshDialTarget(ctx.host, live)
) {
return live;
}
return ctx.host;
};
const STARTUP_COMMAND_DEFAULT_DELAY_MS = 600;
const STARTUP_COMMAND_MAX_DELAY_MS = 10000;
@@ -38,7 +88,7 @@ export const resolveStartupCommand = (
ctx: TerminalSessionStartersContext,
options?: { consumeSuppressHostStartupCommand?: boolean },
): string | undefined => {
const command = ctx.startupCommand || (ctx.suppressHostStartupCommandRef?.current ? undefined : ctx.host.startupCommand);
const command = ctx.startupCommand || (ctx.suppressHostStartupCommandRef?.current ? undefined : resolveStartupHost(ctx).startupCommand);
if (options?.consumeSuppressHostStartupCommand && ctx.suppressHostStartupCommandRef) {
ctx.suppressHostStartupCommandRef.current = false;
}
@@ -96,7 +146,7 @@ export const scheduleStartupCommand = (
const runMode = ctx.startupCommand
? (ctx.multiLineRunMode ?? "paste")
: (ctx.host.startupCommandRunMode ?? "paste");
: (resolveStartupHost(ctx).startupCommandRunMode ?? "paste");
if (runMode === "paste") {
timeoutId = setTimeout(() => {
if (cancelled) return;
@@ -211,7 +211,7 @@ test("ended soft-hidden and alternate-screen sessions can fully hibernate", () =
);
assert.match(
hibernateBody,
/if \(softHiddenRef\.current\) \{\s*if \(statusRef\.current === "disconnected"\) \{\s*upgradeSoftHiddenRuntimeToHibernate\(\);\s*\}\s*return;/,
/if \(softHiddenRef\.current\) \{\s*const retainedKeepCount = statusRef\.current === "connected"\s*\? resolveHibernateKeepRendererCount\(terminalSettingsRef\.current\)\s*: 0;\s*if \(statusRef\.current === "disconnected" \|\| !\(retainedKeepCount > 0\)\) \{\s*upgradeSoftHiddenRuntimeToHibernate\(\);\s*\}\s*return;/,
);
assert.match(
alternateSkipBody,
@@ -227,7 +227,7 @@ test("a cancelled soft-hidden upgrade resumes its renderer", () => {
assert.match(
hibernateBody,
/if \(softHiddenRef\.current\) \{\s*if \(statusRef\.current === "disconnected"\) \{\s*upgradeSoftHiddenRuntimeToHibernate\(\);/,
/if \(softHiddenRef\.current\) \{\s*const retainedKeepCount = statusRef\.current === "connected"\s*\? resolveHibernateKeepRendererCount\(terminalSettingsRef\.current\)\s*: 0;\s*if \(statusRef\.current === "disconnected" \|\| !\(retainedKeepCount > 0\)\) \{\s*upgradeSoftHiddenRuntimeToHibernate\(\);/,
);
const wakeIndex = upgradeBody.indexOf("wakeSoftHiddenRuntimeRef.current?.()");
const upgradeIndex = upgradeBody.indexOf("fullHibernateRuntime().then(");
@@ -311,10 +311,15 @@ test("a full keep count soft-hides the leaving session instead of snapshotting i
assert.match(body, /getSoftHiddenOccupancy\(\)/);
assert.match(body, /stillWantsRetention:/);
assert.match(body, /softHide: \(\) => hideRuntimeOnly\(\)/);
assert.match(
body,
/resolveKeepCount: \(\) => \(\s*statusRef\.current === "connected"\s*\? resolveHibernateKeepRendererCount\(terminalSettingsRef\.current\)\s*: 0\s*\)/,
"retention after eviction must read the live keep count, not the captured one",
);
assert.doesNotMatch(body, /resolveHiddenTabEviction/);
assert.doesNotMatch(body, /requestEviction\(keepDecision/);
assert.match(
body,
/if \(softHiddenRef\.current\) \{\s*if \(statusRef\.current === "disconnected"\) \{\s*upgradeSoftHiddenRuntimeToHibernate\(\);\s*\}\s*return;\s*\}/,
/if \(softHiddenRef\.current\) \{\s*const retainedKeepCount = statusRef\.current === "connected"\s*\? resolveHibernateKeepRendererCount\(terminalSettingsRef\.current\)\s*: 0;\s*if \(statusRef\.current === "disconnected" \|\| !\(retainedKeepCount > 0\)\) \{\s*upgradeSoftHiddenRuntimeToHibernate\(\);\s*\}\s*return;\s*\}/,
);
});
@@ -20,6 +20,8 @@ type UseTerminalHibernateEffectOptions = {
isSearchOpen: boolean;
hibernateEnabled: boolean;
hibernateDelayMs: number;
/** Current retained-renderer limit. Zero rechecks soft-hidden tabs immediately. */
hibernateKeepRendererCount?: number;
fileTransferActive: boolean;
hibernatedRef: React.MutableRefObject<boolean>;
softHiddenRef: React.MutableRefObject<boolean>;
@@ -49,6 +51,7 @@ export function useTerminalHibernateEffect({
isSearchOpen,
hibernateEnabled,
hibernateDelayMs,
hibernateKeepRendererCount,
fileTransferActive,
hibernatedRef,
softHiddenRef,
@@ -210,4 +213,27 @@ export function useTerminalHibernateEffect({
softHiddenRef,
status,
]);
// Separate from the visibility timer so a keep-count edit does not clear an
// in-flight leave. Already soft-hidden tabs have no timer left to notice a
// later zero; this one re-enters hibernate without stacking a second snapshot
// when StrictMode runs the effect twice.
useEffect(() => {
if (!hibernateEnabled || hibernateKeepRendererCount !== 0) return;
if (!softHiddenRef.current) return;
if (resolvePaneVisible(sessionId, isVisibleRef.current)) return;
const timer = window.setTimeout(() => {
if (resolvePaneVisible(sessionId, isVisibleRef.current)) return;
if (!softHiddenRef.current) return;
onHibernateRef.current();
}, 0);
return () => window.clearTimeout(timer);
}, [
hibernateEnabled,
hibernateKeepRendererCount,
isVisible,
isVisibleRef,
sessionId,
softHiddenRef,
]);
}
@@ -58,6 +58,90 @@ test("soft-hidden wake keeps its marker until the runtime has resumed", () => {
assert.doesNotMatch(softWakeBranch, /softHiddenRef\.current\s*=\s*false/);
});
test("a keep-count of zero rechecks a soft-hidden tab while hibernate stays enabled", async (t) => {
const sessionId = "keep-count-zero-recheck";
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
const previousActEnvironment = Object.getOwnPropertyDescriptor(
globalThis,
"IS_REACT_ACT_ENVIRONMENT",
);
Object.defineProperty(globalThis, "window", {
configurable: true,
value: { setTimeout, clearTimeout },
});
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", {
configurable: true,
value: true,
});
t.after(() => {
removePaneVisible(sessionId);
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow);
else Reflect.deleteProperty(globalThis, "window");
if (previousActEnvironment) {
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", previousActEnvironment);
} else {
Reflect.deleteProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT");
}
});
let hibernateCalls = 0;
let renderer: ReactTestRenderer | null = null;
const softHiddenRef = { current: true };
const ref = <T>(current: T) => ({ current });
function Probe({ keepCount }: { keepCount: number }) {
useTerminalHibernateEffect({
sessionId,
isVisible: false,
isVisibleRef: ref(false),
getSessionConnectedRef: ref(() => true),
status: "connected",
isSearchOpen: false,
hibernateEnabled: true,
hibernateDelayMs: 60_000,
hibernateKeepRendererCount: keepCount,
fileTransferActive: false,
hibernatedRef: ref(false),
softHiddenRef,
hibernatePendingBufferRef: ref(""),
hibernateSnapshotRef: ref(""),
hibernateViewportSnapshotRef: ref(""),
hibernateScrollbackSnapshotRef: ref(""),
hibernateContextSnapshotRef: ref(""),
hibernateContextViewportSnapshotRef: ref(""),
hibernateContextScrollbackSnapshotRef: ref(""),
hibernateAlternateScreenRef: ref(false),
hasRuntimeRef: ref(true),
onHibernate: () => { hibernateCalls += 1; },
onSoftHideWake: () => {},
onWake: () => true,
});
return null;
}
const element = (keepCount: number) => React.createElement(
React.StrictMode,
null,
React.createElement(Probe, { keepCount }),
);
await act(async () => { renderer = create(element(2)); });
assert.equal(hibernateCalls, 0, "a positive keep count must leave the retained runtime alone");
await act(async () => { renderer!.update(element(1)); });
assert.equal(hibernateCalls, 0, "a still-positive keep count must not drop every retained runtime");
await act(async () => {
renderer!.update(element(0));
});
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
assert.equal(hibernateCalls, 1, "zero retention must re-enter hibernate for a soft-hidden tab");
await act(async () => renderer!.unmount());
});
test("hidden disconnected remote terminals release their retained runtime", async (t) => {
const sessionId = "disconnected-hidden-hibernate-test";
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
+9 -12
View File
@@ -31,20 +31,17 @@
<text x="85" y="85.0" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">7K</text>
<text x="43" y="271.5" fill="#e6edf3" font-size="17" font-family="Handlee, cursive" font-weight="bold" transform="rotate(-90, 43, 271.5)">GitHub Stars</text>
<text x="90.0" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jan 2026</text>
<text x="223.6" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Mar 2026</text>
<text x="296.2" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Apr 2026</text>
<text x="366.6" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">May 2026</text>
<text x="439.2" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jun 2026</text>
<text x="509.5" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jul 2026</text>
<text x="582.2" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Aug 2026</text>
<text x="654.8" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Sep 2026</text>
<text x="725.2" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Oct 2026</text>
<text x="220.4" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Mar 2026</text>
<text x="291.4" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Apr 2026</text>
<text x="430.9" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jun 2026</text>
<text x="570.5" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Aug 2026</text>
<text x="710.1" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Oct 2026</text>
<text x="50%" y="525" fill="#e6edf3" font-size="17" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Date</text>
<line x1="90" y1="80" x2="90" y2="463" stroke="#30363d" stroke-width="2.5" filter="url(#xkcdify)"/>
<line x1="90" y1="463" x2="765" y2="463" stroke="#30363d" stroke-width="2.5" filter="url(#xkcdify)"/>
<path d="M 90.0,462.2 C 90.4,462.1 92.0,461.8 92.3,461.7 C 93.1,461.6 93.9,461.4 94.7,461.2 C 95.5,461.1 96.2,460.8 97.0,460.8 C 97.8,460.7 98.6,460.7 99.4,460.7 C 100.2,460.7 100.9,460.6 101.7,460.6 C 102.5,460.5 103.3,460.5 104.1,460.4 C 104.8,460.4 105.2,460.3 106.4,460.3 C 107.6,460.2 109.9,460.1 111.1,460.1 C 112.3,460.1 112.7,460.0 113.4,460.0 C 114.2,460.0 115.0,460.0 115.8,459.9 C 116.6,459.9 117.3,459.9 118.1,459.9 C 118.9,459.9 119.7,459.9 120.5,459.8 C 121.2,459.8 122.0,459.7 122.8,459.7 C 123.6,459.6 124.4,459.6 125.2,459.4 C 125.9,459.3 126.7,459.0 127.5,458.7 C 128.3,458.3 129.1,457.6 129.8,457.3 C 130.6,457.1 131.4,457.2 132.2,457.1 C 133.0,457.1 133.8,457.1 134.5,457.0 C 135.3,457.0 135.7,457.0 136.9,456.9 C 138.0,456.9 140.0,456.9 141.6,456.9 C 143.1,456.8 145.1,456.8 146.2,456.8 C 147.4,456.7 147.8,456.7 148.6,456.7 C 149.4,456.6 150.2,456.6 150.9,456.5 C 151.7,456.5 152.5,456.5 153.3,456.4 C 154.1,456.4 154.8,456.4 155.6,456.4 C 156.4,456.4 156.8,456.4 158.0,456.3 C 159.1,456.3 160.7,456.2 162.7,456.2 C 164.6,456.2 167.7,456.2 169.7,456.2 C 171.6,456.1 172.0,456.2 174.4,456.1 C 176.7,456.1 181.8,455.9 183.8,455.9 C 185.7,455.8 184.9,455.9 186.1,455.8 C 187.3,455.8 188.8,455.8 190.8,455.8 C 192.7,455.8 193.9,455.7 197.8,455.7 C 201.7,455.6 211.1,455.5 214.2,455.5 C 217.3,455.5 215.8,455.5 216.6,455.4 C 217.3,455.4 217.7,456.1 218.9,455.4 C 220.1,454.7 222.4,454.1 223.6,451.3 C 224.8,448.4 225.2,440.7 225.9,438.3 C 226.7,435.9 227.5,437.3 228.3,436.9 C 229.1,436.5 229.8,436.3 230.6,436.0 C 231.4,435.8 232.2,435.6 233.0,435.4 C 233.8,435.3 234.5,435.3 235.3,435.2 C 236.1,435.1 236.9,434.9 237.7,434.8 C 238.4,434.8 239.2,434.8 240.0,434.8 C 240.8,434.7 241.6,434.5 242.3,434.4 C 243.1,434.3 243.9,434.3 244.7,434.2 C 245.5,434.1 246.2,433.7 247.0,433.6 C 247.8,433.5 248.6,433.5 249.4,433.5 C 250.2,433.4 250.5,433.2 251.7,433.1 C 252.9,433.0 255.2,433.0 256.4,432.9 C 257.6,432.9 258.0,432.8 258.8,432.7 C 259.5,432.7 260.3,432.7 261.1,432.6 C 261.9,432.5 262.7,432.2 263.4,432.1 C 264.2,431.9 265.0,432.6 265.8,431.7 C 266.6,430.8 267.3,427.6 268.1,426.6 C 268.9,425.5 269.7,425.7 270.5,425.5 C 271.2,425.3 272.0,425.5 272.8,425.4 C 273.6,425.3 274.4,424.9 275.2,424.6 C 275.9,424.4 276.7,424.2 277.5,423.7 C 278.3,423.2 279.1,422.2 279.8,421.6 C 280.6,421.0 281.4,420.4 282.2,420.0 C 283.0,419.5 283.8,419.2 284.5,419.0 C 285.3,418.7 286.1,418.6 286.9,418.5 C 287.7,418.3 288.4,418.4 289.2,418.2 C 290.0,418.1 290.8,417.9 291.6,417.8 C 292.3,417.7 293.1,417.8 293.9,417.7 C 294.7,417.6 295.5,417.6 296.2,417.5 C 297.0,417.4 297.8,417.4 298.6,417.0 C 299.4,416.6 300.2,415.5 300.9,415.0 C 301.7,414.5 302.5,414.3 303.3,414.0 C 304.1,413.6 304.8,414.3 305.6,412.8 C 306.4,411.2 307.2,406.4 308.0,404.6 C 308.8,402.7 309.5,402.4 310.3,401.7 C 311.1,400.9 311.9,400.4 312.7,399.9 C 313.4,399.3 314.2,398.8 315.0,398.4 C 315.8,398.0 316.6,397.6 317.3,397.3 C 318.1,397.1 318.9,397.0 319.7,396.8 C 320.5,396.6 321.2,396.3 322.0,396.0 C 322.8,395.7 323.6,395.2 324.4,395.0 C 325.2,394.7 325.9,394.8 326.7,394.5 C 327.5,394.2 328.3,393.8 329.1,393.5 C 329.8,393.1 330.6,392.9 331.4,392.6 C 332.2,392.4 333.0,392.2 333.8,392.0 C 334.5,391.9 335.3,391.8 336.1,391.7 C 336.9,391.6 337.7,391.4 338.4,391.2 C 339.2,391.0 340.0,390.9 340.8,390.7 C 341.6,390.5 342.3,390.3 343.1,390.0 C 343.9,389.6 344.7,389.0 345.5,388.8 C 346.2,388.5 347.0,388.4 347.8,388.2 C 348.6,388.1 349.4,387.9 350.2,387.8 C 350.9,387.7 351.7,387.6 352.5,387.5 C 353.3,387.4 354.1,387.2 354.8,387.2 C 355.6,387.1 356.4,387.1 357.2,387.0 C 358.0,386.9 358.8,386.7 359.5,386.4 C 360.3,386.1 361.1,385.6 361.9,385.3 C 362.7,385.0 363.4,384.6 364.2,384.4 C 365.0,384.2 365.8,384.1 366.6,384.0 C 367.3,383.9 368.1,383.8 368.9,383.6 C 369.7,383.3 370.5,382.8 371.2,382.6 C 372.0,382.4 372.8,382.4 373.6,382.3 C 374.4,382.2 375.2,382.0 375.9,381.8 C 376.7,381.6 377.5,381.4 378.3,381.3 C 379.1,381.1 379.8,380.8 380.6,380.7 C 381.4,380.5 382.2,380.4 383.0,380.2 C 383.8,379.9 384.5,379.6 385.3,379.3 C 386.1,379.1 386.9,379.0 387.7,378.7 C 388.4,378.5 389.2,378.2 390.0,377.9 C 390.8,377.6 391.6,377.4 392.3,377.1 C 393.1,376.8 393.9,376.8 394.7,376.4 C 395.5,376.0 396.2,375.1 397.0,374.7 C 397.8,374.3 398.6,374.3 399.4,374.1 C 400.2,374.0 400.9,374.0 401.7,373.9 C 402.5,373.8 403.3,373.7 404.1,373.5 C 404.8,373.4 405.6,373.6 406.4,373.3 C 407.2,372.9 408.0,371.8 408.8,371.4 C 409.5,371.0 410.3,371.1 411.1,370.8 C 411.9,370.6 412.7,370.1 413.4,369.9 C 414.2,369.7 415.0,369.8 415.8,369.6 C 416.6,369.4 417.3,368.9 418.1,368.7 C 418.9,368.5 419.7,368.5 420.5,368.4 C 421.2,368.3 422.0,368.3 422.8,368.1 C 423.6,367.9 424.4,367.6 425.2,367.1 C 425.9,366.6 426.7,365.5 427.5,365.0 C 428.3,364.5 429.1,364.2 429.8,363.9 C 430.6,363.5 431.4,363.4 432.2,362.7 C 433.0,361.9 433.8,360.2 434.5,359.4 C 435.3,358.6 436.1,358.3 436.9,357.8 C 437.7,357.3 438.4,357.2 439.2,356.5 C 440.0,355.8 440.8,354.2 441.6,353.5 C 442.3,352.8 443.1,352.7 443.9,352.3 C 444.7,351.9 445.5,351.5 446.2,351.1 C 447.0,3Line truncated
<path d="M 90.0,462.2 C 90.4,462.1 92.0,461.8 92.3,461.7 C 93.1,461.6 93.9,461.4 94.7,461.2 C 95.5,461.1 96.2,460.8 97.0,460.8 C 97.8,460.7 98.6,460.7 99.4,460.7 C 100.2,460.7 100.9,460.6 101.7,460.6 C 102.5,460.5 103.3,460.5 104.1,460.4 C 104.8,460.4 105.2,460.3 106.4,460.3 C 107.6,460.2 109.9,460.1 111.1,460.1 C 112.3,460.1 112.7,460.0 113.4,460.0 C 114.2,460.0 115.0,460.0 115.8,459.9 C 116.6,459.9 117.3,459.9 118.1,459.9 C 118.9,459.9 119.7,459.9 120.5,459.8 C 121.2,459.8 122.0,459.7 122.8,459.7 C 123.6,459.6 124.4,459.6 125.2,459.4 C 125.9,459.3 126.7,459.0 127.5,458.7 C 128.3,458.3 129.1,457.6 129.8,457.3 C 130.6,457.1 131.4,457.2 132.2,457.1 C 133.0,457.1 133.8,457.1 134.5,457.0 C 135.3,457.0 135.7,457.0 136.9,456.9 C 138.0,456.9 140.0,456.9 141.6,456.9 C 143.1,456.8 145.1,456.8 146.2,456.8 C 147.4,456.7 147.8,456.7 148.6,456.7 C 149.4,456.6 150.2,456.6 150.9,456.5 C 151.7,456.5 152.5,456.5 153.3,456.4 C 154.1,456.4 154.8,456.4 155.6,456.4 C 156.4,456.4 156.8,456.4 158.0,456.3 C 159.1,456.3 160.7,456.2 162.7,456.2 C 164.6,456.2 167.7,456.2 169.7,456.2 C 171.6,456.1 172.0,456.2 174.4,456.1 C 176.7,456.1 181.8,455.9 183.8,455.9 C 185.7,455.8 184.9,455.9 186.1,455.8 C 187.3,455.8 188.8,455.8 190.8,455.8 C 192.7,455.8 193.9,455.7 197.8,455.7 C 201.7,455.6 211.1,455.5 214.2,455.5 C 217.3,455.5 215.8,455.5 216.6,455.4 C 217.3,455.4 217.7,456.1 218.9,455.4 C 220.1,454.7 222.4,454.1 223.6,451.3 C 224.8,448.4 225.2,440.7 225.9,438.3 C 226.7,435.9 227.5,437.3 228.3,436.9 C 229.1,436.5 229.8,436.3 230.6,436.0 C 231.4,435.8 232.2,435.6 233.0,435.4 C 233.8,435.3 234.5,435.3 235.3,435.2 C 236.1,435.1 236.9,434.9 237.7,434.8 C 238.4,434.8 239.2,434.8 240.0,434.8 C 240.8,434.7 241.6,434.5 242.3,434.4 C 243.1,434.3 243.9,434.3 244.7,434.2 C 245.5,434.1 246.2,433.7 247.0,433.6 C 247.8,433.5 248.6,433.5 249.4,433.5 C 250.2,433.4 250.5,433.2 251.7,433.1 C 252.9,433.0 255.2,433.0 256.4,432.9 C 257.6,432.9 258.0,432.8 258.8,432.7 C 259.5,432.7 260.3,432.7 261.1,432.6 C 261.9,432.5 262.7,432.2 263.4,432.1 C 264.2,431.9 265.0,432.6 265.8,431.7 C 266.6,430.8 267.3,427.6 268.1,426.6 C 268.9,425.5 269.7,425.7 270.5,425.5 C 271.2,425.3 272.0,425.5 272.8,425.4 C 273.6,425.3 274.4,424.9 275.2,424.6 C 275.9,424.4 276.7,424.2 277.5,423.7 C 278.3,423.2 279.1,422.2 279.8,421.6 C 280.6,421.0 281.4,420.4 282.2,420.0 C 283.0,419.5 283.8,419.2 284.5,419.0 C 285.3,418.7 286.1,418.6 286.9,418.5 C 287.7,418.3 288.4,418.4 289.2,418.2 C 290.0,418.1 290.8,417.9 291.6,417.8 C 292.3,417.7 293.1,417.8 293.9,417.7 C 294.7,417.6 295.5,417.6 296.2,417.5 C 297.0,417.4 297.8,417.4 298.6,417.0 C 299.4,416.6 300.2,415.5 300.9,415.0 C 301.7,414.5 302.5,414.3 303.3,414.0 C 304.1,413.6 304.8,414.3 305.6,412.8 C 306.4,411.2 307.2,406.4 308.0,404.6 C 308.8,402.7 309.5,402.4 310.3,401.7 C 311.1,400.9 311.9,400.4 312.7,399.9 C 313.4,399.3 314.2,398.8 315.0,398.4 C 315.8,398.0 316.6,397.6 317.3,397.3 C 318.1,397.1 318.9,397.0 319.7,396.8 C 320.5,396.6 321.2,396.3 322.0,396.0 C 322.8,395.7 323.6,395.2 324.4,395.0 C 325.2,394.7 325.9,394.8 326.7,394.5 C 327.5,394.2 328.3,393.8 329.1,393.5 C 329.8,393.1 330.6,392.9 331.4,392.6 C 332.2,392.4 333.0,392.2 333.8,392.0 C 334.5,391.9 335.3,391.8 336.1,391.7 C 336.9,391.6 337.7,391.4 338.4,391.2 C 339.2,391.0 340.0,390.9 340.8,390.7 C 341.6,390.5 342.3,390.3 343.1,390.0 C 343.9,389.6 344.7,389.0 345.5,388.8 C 346.2,388.5 347.0,388.4 347.8,388.2 C 348.6,388.1 349.4,387.9 350.2,387.8 C 350.9,387.7 351.7,387.6 352.5,387.5 C 353.3,387.4 354.1,387.2 354.8,387.2 C 355.6,387.1 356.4,387.1 357.2,387.0 C 358.0,386.9 358.8,386.7 359.5,386.4 C 360.3,386.1 361.1,385.6 361.9,385.3 C 362.7,385.0 363.4,384.6 364.2,384.4 C 365.0,384.2 365.8,384.1 366.6,384.0 C 367.3,383.9 368.1,383.8 368.9,383.6 C 369.7,383.3 370.5,382.8 371.2,382.6 C 372.0,382.4 372.8,382.4 373.6,382.3 C 374.4,382.2 375.2,382.0 375.9,381.8 C 376.7,381.6 377.5,381.4 378.3,381.3 C 379.1,381.1 379.8,380.8 380.6,380.7 C 381.4,380.5 382.2,380.4 383.0,380.2 C 383.8,379.9 384.5,379.6 385.3,379.3 C 386.1,379.1 386.9,379.0 387.7,378.7 C 388.4,378.5 389.2,378.2 390.0,377.9 C 390.8,377.6 391.6,377.4 392.3,377.1 C 393.1,376.8 393.9,376.8 394.7,376.4 C 395.5,376.0 396.2,375.1 397.0,374.7 C 397.8,374.3 398.6,374.3 399.4,374.1 C 400.2,374.0 400.9,374.0 401.7,373.9 C 402.5,373.8 403.3,373.7 404.1,373.5 C 404.8,373.4 405.6,373.6 406.4,373.3 C 407.2,372.9 408.0,371.8 408.8,371.4 C 409.5,371.0 410.3,371.1 411.1,370.8 C 411.9,370.6 412.7,370.1 413.4,369.9 C 414.2,369.7 415.0,369.8 415.8,369.6 C 416.6,369.4 417.3,368.9 418.1,368.7 C 418.9,368.5 419.7,368.5 420.5,368.4 C 421.2,368.3 422.0,368.3 422.8,368.1 C 423.6,367.9 424.4,367.6 425.2,367.1 C 425.9,366.6 426.7,365.5 427.5,365.0 C 428.3,364.5 429.1,364.2 429.8,363.9 C 430.6,363.5 431.4,363.4 432.2,362.7 C 433.0,361.9 433.8,360.2 434.5,359.4 C 435.3,358.6 436.1,358.3 436.9,357.8 C 437.7,357.3 438.4,357.2 439.2,356.5 C 440.0,355.8 440.8,354.2 441.6,353.5 C 442.3,352.8 443.1,352.7 443.9,352.3 C 444.7,351.9 445.5,351.5 446.2,351.1 C 447.0,3Line truncated
<circle cx="765.0" cy="99.0" r="5" fill="#dd4528" stroke="#0d1117" stroke-width="2" filter="url(#xkcdify)"/>
<text x="753.0" y="77.0" fill="#dd4528" font-size="18" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">6652</text>
<path d="M 90.0,462.2 C 90.4,462.1 91.9,461.8 92.3,461.7 C 93.1,461.6 93.8,461.4 94.6,461.2 C 95.3,461.1 96.1,460.8 96.9,460.8 C 97.6,460.7 98.4,460.7 99.2,460.7 C 99.9,460.7 100.7,460.6 101.4,460.6 C 102.2,460.5 103.0,460.5 103.7,460.4 C 104.5,460.4 104.9,460.3 106.0,460.3 C 107.2,460.2 109.4,460.1 110.6,460.1 C 111.7,460.1 112.1,460.0 112.9,460.0 C 113.6,460.0 114.4,460.0 115.2,459.9 C 115.9,459.9 116.7,459.9 117.5,459.9 C 118.2,459.9 119.0,459.9 119.7,459.8 C 120.5,459.8 121.3,459.7 122.0,459.7 C 122.8,459.6 123.6,459.6 124.3,459.4 C 125.1,459.3 125.8,459.0 126.6,458.7 C 127.4,458.3 128.1,457.6 128.9,457.3 C 129.7,457.1 130.4,457.2 131.2,457.1 C 131.9,457.1 132.7,457.1 133.5,457.0 C 134.2,457.0 134.6,457.0 135.8,456.9 C 136.9,456.9 138.8,456.9 140.3,456.9 C 141.9,456.8 143.8,456.8 144.9,456.8 C 146.1,456.7 146.4,456.7 147.2,456.7 C 148.0,456.6 148.7,456.6 149.5,456.5 C 150.3,456.5 151.0,456.5 151.8,456.4 C 152.5,456.4 153.3,456.4 154.1,456.4 C 154.8,456.4 155.2,456.4 156.4,456.3 C 157.5,456.3 159.0,456.2 160.9,456.2 C 162.8,456.2 165.9,456.2 167.8,456.2 C 169.7,456.1 170.1,456.2 172.4,456.1 C 174.7,456.1 179.6,455.9 181.5,455.9 C 183.4,455.8 182.7,455.9 183.8,455.8 C 185.0,455.8 186.5,455.8 188.4,455.8 C 190.3,455.8 191.4,455.7 195.3,455.7 C 199.1,455.6 208.2,455.5 211.3,455.5 C 214.3,455.5 212.8,455.5 213.6,455.4 C 214.3,455.4 214.7,456.1 215.8,455.4 C 217.0,454.7 219.3,454.1 220.4,451.3 C 221.6,448.4 221.9,440.7 222.7,438.3 C 223.5,435.9 224.2,437.3 225.0,436.9 C 225.8,436.5 226.5,436.3 227.3,436.0 C 228.1,435.8 228.8,435.6 229.6,435.4 C 230.3,435.3 231.1,435.3 231.9,435.2 C 232.6,435.1 233.4,434.9 234.2,434.8 C 234.9,434.8 235.7,434.8 236.4,434.8 C 237.2,434.7 238.0,434.5 238.7,434.4 C 239.5,434.3 240.3,434.3 241.0,434.2 C 241.8,434.1 242.5,433.7 243.3,433.6 C 244.1,433.5 244.8,433.5 245.6,433.5 C 246.4,433.4 246.7,433.2 247.9,433.1 C 249.0,433.0 251.3,433.0 252.5,432.9 C 253.6,432.9 254.0,432.8 254.7,432.7 C 255.5,432.7 256.3,432.7 257.0,432.6 C 257.8,432.5 258.6,432.2 259.3,432.1 C 260.1,431.9 260.8,432.6 261.6,431.7 C 262.4,430.8 263.1,427.6 263.9,426.6 C 264.7,425.5 265.4,425.7 266.2,425.5 C 266.9,425.3 267.7,425.5 268.5,425.4 C 269.2,425.3 270.0,424.9 270.8,424.6 C 271.5,424.4 272.3,424.2 273.1,423.7 C 273.8,423.2 274.6,422.2 275.3,421.6 C 276.1,421.0 276.9,420.5 277.6,420.0 C 278.4,419.6 279.2,419.3 279.9,419.0 C 280.7,418.8 281.4,418.6 282.2,418.5 C 283.0,418.4 283.7,418.4 284.5,418.3 C 285.3,418.2 286.0,418.0 286.8,417.9 C 287.5,417.8 288.3,417.8 289.1,417.8 C 289.8,417.7 290.6,417.7 291.4,417.5 C 292.1,417.4 292.9,417.5 293.6,417.0 C 294.4,416.6 295.2,415.6 295.9,415.1 C 296.7,414.6 297.5,414.4 298.2,414.0 C 299.0,413.7 299.7,414.4 300.5,412.8 C 301.3,411.3 302.0,406.5 302.8,404.6 C 303.6,402.8 304.3,402.5 305.1,401.7 C 305.8,400.9 306.6,400.4 307.4,399.9 C 308.1,399.3 308.9,398.8 309.7,398.4 C 310.4,398.0 311.2,397.6 311.9,397.3 C 312.7,397.1 313.5,397.0 314.2,396.8 C 315.0,396.6 315.8,396.3 316.5,396.0 C 317.3,395.7 318.1,395.2 318.8,395.0 C 319.6,394.7 320.3,394.8 321.1,394.5 C 321.9,394.2 322.6,393.8 323.4,393.5 C 324.2,393.1 324.9,392.9 325.7,392.6 C 326.4,392.4 327.2,392.2 328.0,392.0 C 328.7,391.9 329.5,391.8 330.3,391.7 C 331.0,391.6 331.8,391.4 332.5,391.2 C 333.3,391.0 334.1,390.9 334.8,390.7 C 335.6,390.5 336.4,390.3 337.1,390.0 C 337.9,389.6 338.6,389.0 339.4,388.8 C 340.2,388.5 340.9,388.4 341.7,388.2 C 342.5,388.1 343.2,387.9 344.0,387.8 C 344.7,387.7 345.5,387.6 346.3,387.5 C 347.0,387.4 347.8,387.2 348.6,387.2 C 349.3,387.1 350.1,387.1 350.8,387.0 C 351.6,386.9 352.4,386.7 353.1,386.4 C 353.9,386.1 354.7,385.6 355.4,385.3 C 356.2,385.0 356.9,384.6 357.7,384.4 C 358.5,384.2 359.2,384.1 360.0,384.0 C 360.8,383.9 361.5,383.8 362.3,383.6 C 363.1,383.3 363.8,382.8 364.6,382.6 C 365.3,382.4 366.1,382.4 366.9,382.3 C 367.6,382.2 368.4,382.0 369.2,381.8 C 369.9,381.6 370.7,381.4 371.4,381.3 C 372.2,381.1 373.0,380.8 373.7,380.7 C 374.5,380.5 375.3,380.4 376.0,380.2 C 376.8,379.9 377.5,379.6 378.3,379.3 C 379.1,379.1 379.8,379.0 380.6,378.7 C 381.4,378.5 382.1,378.2 382.9,377.9 C 383.6,377.6 384.4,377.4 385.2,377.1 C 385.9,376.8 386.7,376.8 387.5,376.4 C 388.2,376.0 389.0,375.1 389.7,374.7 C 390.5,374.3 391.3,374.3 392.0,374.1 C 392.8,374.0 393.6,374.0 394.3,373.9 C 395.1,373.8 395.8,373.7 396.6,373.5 C 397.4,373.4 398.1,373.6 398.9,373.3 C 399.7,372.9 400.4,371.8 401.2,371.4 C 401.9,371.0 402.7,371.1 403.5,370.8 C 404.2,370.6 405.0,370.1 405.8,369.9 C 406.5,369.7 407.3,369.8 408.1,369.6 C 408.8,369.4 409.6,368.9 410.3,368.7 C 411.1,368.5 411.9,368.5 412.6,368.4 C 413.4,368.3 414.2,368.3 414.9,368.1 C 415.7,367.9 416.4,367.6 417.2,367.1 C 418.0,366.6 418.7,365.5 419.5,365.0 C 420.3,364.5 421.0,364.2 421.8,363.9 C 422.5,363.5 423.3,363.4 424.1,362.7 C 424.8,361.9 425.6,360.2 426.4,359.4 C 427.1,358.6 427.9,358.3 428.6,357.8 C 429.4,357.4 430.2,357.3 430.9,356.6 C 431.7,355.9 432.5,354.2 433.2,353.5 C 434.0,352.8 434.7,352.7 435.5,352.3 C 436.3,351.9 437.0,351.6 437.8,351.1 C 438.6,35Line truncated
<path d="M 90.0,462.2 C 90.4,462.1 91.9,461.8 92.3,461.7 C 93.1,461.6 93.8,461.4 94.6,461.2 C 95.3,461.1 96.1,460.8 96.9,460.8 C 97.6,460.7 98.4,460.7 99.2,460.7 C 99.9,460.7 100.7,460.6 101.4,460.6 C 102.2,460.5 103.0,460.5 103.7,460.4 C 104.5,460.4 104.9,460.3 106.0,460.3 C 107.2,460.2 109.4,460.1 110.6,460.1 C 111.7,460.1 112.1,460.0 112.9,460.0 C 113.6,460.0 114.4,460.0 115.2,459.9 C 115.9,459.9 116.7,459.9 117.5,459.9 C 118.2,459.9 119.0,459.9 119.7,459.8 C 120.5,459.8 121.3,459.7 122.0,459.7 C 122.8,459.6 123.6,459.6 124.3,459.4 C 125.1,459.3 125.8,459.0 126.6,458.7 C 127.4,458.3 128.1,457.6 128.9,457.3 C 129.7,457.1 130.4,457.2 131.2,457.1 C 131.9,457.1 132.7,457.1 133.5,457.0 C 134.2,457.0 134.6,457.0 135.8,456.9 C 136.9,456.9 138.8,456.9 140.3,456.9 C 141.9,456.8 143.8,456.8 144.9,456.8 C 146.1,456.7 146.4,456.7 147.2,456.7 C 148.0,456.6 148.7,456.6 149.5,456.5 C 150.3,456.5 151.0,456.5 151.8,456.4 C 152.5,456.4 153.3,456.4 154.1,456.4 C 154.8,456.4 155.2,456.4 156.4,456.3 C 157.5,456.3 159.0,456.2 160.9,456.2 C 162.8,456.2 165.9,456.2 167.8,456.2 C 169.7,456.1 170.1,456.2 172.4,456.1 C 174.7,456.1 179.6,455.9 181.5,455.9 C 183.4,455.8 182.7,455.9 183.8,455.8 C 185.0,455.8 186.5,455.8 188.4,455.8 C 190.3,455.8 191.4,455.7 195.3,455.7 C 199.1,455.6 208.2,455.5 211.3,455.5 C 214.3,455.5 212.8,455.5 213.6,455.4 C 214.3,455.4 214.7,456.1 215.8,455.4 C 217.0,454.7 219.3,454.1 220.4,451.3 C 221.6,448.4 221.9,440.7 222.7,438.3 C 223.5,435.9 224.2,437.3 225.0,436.9 C 225.8,436.5 226.5,436.3 227.3,436.0 C 228.1,435.8 228.8,435.6 229.6,435.4 C 230.3,435.3 231.1,435.3 231.9,435.2 C 232.6,435.1 233.4,434.9 234.2,434.8 C 234.9,434.8 235.7,434.8 236.4,434.8 C 237.2,434.7 238.0,434.5 238.7,434.4 C 239.5,434.3 240.3,434.3 241.0,434.2 C 241.8,434.1 242.5,433.7 243.3,433.6 C 244.1,433.5 244.8,433.5 245.6,433.5 C 246.4,433.4 246.7,433.2 247.9,433.1 C 249.0,433.0 251.3,433.0 252.5,432.9 C 253.6,432.9 254.0,432.8 254.7,432.7 C 255.5,432.7 256.3,432.7 257.0,432.6 C 257.8,432.5 258.6,432.2 259.3,432.1 C 260.1,431.9 260.8,432.6 261.6,431.7 C 262.4,430.8 263.1,427.6 263.9,426.6 C 264.7,425.5 265.4,425.7 266.2,425.5 C 266.9,425.3 267.7,425.5 268.5,425.4 C 269.2,425.3 270.0,424.9 270.8,424.6 C 271.5,424.4 272.3,424.2 273.1,423.7 C 273.8,423.2 274.6,422.2 275.3,421.6 C 276.1,421.0 276.9,420.5 277.6,420.0 C 278.4,419.6 279.2,419.3 279.9,419.0 C 280.7,418.8 281.4,418.6 282.2,418.5 C 283.0,418.4 283.7,418.4 284.5,418.3 C 285.3,418.2 286.0,418.0 286.8,417.9 C 287.5,417.8 288.3,417.8 289.1,417.8 C 289.8,417.7 290.6,417.7 291.4,417.5 C 292.1,417.4 292.9,417.5 293.6,417.0 C 294.4,416.6 295.2,415.6 295.9,415.1 C 296.7,414.6 297.5,414.4 298.2,414.0 C 299.0,413.7 299.7,414.4 300.5,412.8 C 301.3,411.3 302.0,406.5 302.8,404.6 C 303.6,402.8 304.3,402.5 305.1,401.7 C 305.8,400.9 306.6,400.4 307.4,399.9 C 308.1,399.3 308.9,398.8 309.7,398.4 C 310.4,398.0 311.2,397.6 311.9,397.3 C 312.7,397.1 313.5,397.0 314.2,396.8 C 315.0,396.6 315.8,396.3 316.5,396.0 C 317.3,395.7 318.1,395.2 318.8,395.0 C 319.6,394.7 320.3,394.8 321.1,394.5 C 321.9,394.2 322.6,393.8 323.4,393.5 C 324.2,393.1 324.9,392.9 325.7,392.6 C 326.4,392.4 327.2,392.2 328.0,392.0 C 328.7,391.9 329.5,391.8 330.3,391.7 C 331.0,391.6 331.8,391.4 332.5,391.2 C 333.3,391.0 334.1,390.9 334.8,390.7 C 335.6,390.5 336.4,390.3 337.1,390.0 C 337.9,389.6 338.6,389.0 339.4,388.8 C 340.2,388.5 340.9,388.4 341.7,388.2 C 342.5,388.1 343.2,387.9 344.0,387.8 C 344.7,387.7 345.5,387.6 346.3,387.5 C 347.0,387.4 347.8,387.2 348.6,387.2 C 349.3,387.1 350.1,387.1 350.8,387.0 C 351.6,386.9 352.4,386.7 353.1,386.4 C 353.9,386.1 354.7,385.6 355.4,385.3 C 356.2,385.0 356.9,384.6 357.7,384.4 C 358.5,384.2 359.2,384.1 360.0,384.0 C 360.8,383.9 361.5,383.8 362.3,383.6 C 363.1,383.3 363.8,382.8 364.6,382.6 C 365.3,382.4 366.1,382.4 366.9,382.3 C 367.6,382.2 368.4,382.0 369.2,381.8 C 369.9,381.6 370.7,381.4 371.4,381.3 C 372.2,381.1 373.0,380.8 373.7,380.7 C 374.5,380.5 375.3,380.4 376.0,380.2 C 376.8,379.9 377.5,379.6 378.3,379.3 C 379.1,379.1 379.8,379.0 380.6,378.7 C 381.4,378.5 382.1,378.2 382.9,377.9 C 383.6,377.6 384.4,377.4 385.2,377.1 C 385.9,376.8 386.7,376.8 387.5,376.4 C 388.2,376.0 389.0,375.1 389.7,374.7 C 390.5,374.3 391.3,374.3 392.0,374.1 C 392.8,374.0 393.6,374.0 394.3,373.9 C 395.1,373.8 395.8,373.7 396.6,373.5 C 397.4,373.4 398.1,373.6 398.9,373.3 C 399.7,372.9 400.4,371.8 401.2,371.4 C 401.9,371.0 402.7,371.1 403.5,370.8 C 404.2,370.6 405.0,370.1 405.8,369.9 C 406.5,369.7 407.3,369.8 408.1,369.6 C 408.8,369.4 409.6,368.9 410.3,368.7 C 411.1,368.5 411.9,368.5 412.6,368.4 C 413.4,368.3 414.2,368.3 414.9,368.1 C 415.7,367.9 416.4,367.6 417.2,367.1 C 418.0,366.6 418.7,365.5 419.5,365.0 C 420.3,364.5 421.0,364.2 421.8,363.9 C 422.5,363.5 423.3,363.4 424.1,362.7 C 424.8,361.9 425.6,360.2 426.4,359.4 C 427.1,358.6 427.9,358.3 428.6,357.8 C 429.4,357.4 430.2,357.3 430.9,356.6 C 431.7,355.9 432.5,354.2 433.2,353.5 C 434.0,352.8 434.7,352.7 435.5,352.3 C 436.3,351.9 437.0,351.6 437.8,351.1 C 438.6,35Line truncated
<circle cx="765.0" cy="90.8" r="5" fill="#dd4528" stroke="#0d1117" stroke-width="2" filter="url(#xkcdify)"/>
<text x="753.0" y="77.0" fill="#dd4528" font-size="18" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">6802</text>
<a href="https://github.com/carsteneu/mystarhistory" target="_blank" rel="noopener"><text x="763" y="527" font-size="13" font-family="Handlee, cursive" fill="#fff" text-anchor="end">Made with carsteneu/mystarhistory</text></a>
</svg>

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 36 KiB

@@ -31,20 +31,17 @@
<text x="85" y="85.0" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">7K</text>
<text x="43" y="271.5" fill="#000" font-size="17" font-family="Handlee, cursive" font-weight="bold" transform="rotate(-90, 43, 271.5)">GitHub Stars</text>
<text x="90.0" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jan 2026</text>
<text x="223.6" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Mar 2026</text>
<text x="296.2" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Apr 2026</text>
<text x="366.6" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">May 2026</text>
<text x="439.2" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jun 2026</text>
<text x="509.5" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jul 2026</text>
<text x="582.2" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Aug 2026</text>
<text x="654.8" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Sep 2026</text>
<text x="725.2" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Oct 2026</text>
<text x="220.4" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Mar 2026</text>
<text x="291.4" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Apr 2026</text>
<text x="430.9" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jun 2026</text>
<text x="570.5" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Aug 2026</text>
<text x="710.1" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Oct 2026</text>
<text x="50%" y="525" fill="#000" font-size="17" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Date</text>
<line x1="90" y1="80" x2="90" y2="463" stroke="#222" stroke-width="2.5" filter="url(#xkcdify)"/>
<line x1="90" y1="463" x2="765" y2="463" stroke="#222" stroke-width="2.5" filter="url(#xkcdify)"/>
<path d="M 90.0,462.2 C 90.4,462.1 92.0,461.8 92.3,461.7 C 93.1,461.6 93.9,461.4 94.7,461.2 C 95.5,461.1 96.2,460.8 97.0,460.8 C 97.8,460.7 98.6,460.7 99.4,460.7 C 100.2,460.7 100.9,460.6 101.7,460.6 C 102.5,460.5 103.3,460.5 104.1,460.4 C 104.8,460.4 105.2,460.3 106.4,460.3 C 107.6,460.2 109.9,460.1 111.1,460.1 C 112.3,460.1 112.7,460.0 113.4,460.0 C 114.2,460.0 115.0,460.0 115.8,459.9 C 116.6,459.9 117.3,459.9 118.1,459.9 C 118.9,459.9 119.7,459.9 120.5,459.8 C 121.2,459.8 122.0,459.7 122.8,459.7 C 123.6,459.6 124.4,459.6 125.2,459.4 C 125.9,459.3 126.7,459.0 127.5,458.7 C 128.3,458.3 129.1,457.6 129.8,457.3 C 130.6,457.1 131.4,457.2 132.2,457.1 C 133.0,457.1 133.8,457.1 134.5,457.0 C 135.3,457.0 135.7,457.0 136.9,456.9 C 138.0,456.9 140.0,456.9 141.6,456.9 C 143.1,456.8 145.1,456.8 146.2,456.8 C 147.4,456.7 147.8,456.7 148.6,456.7 C 149.4,456.6 150.2,456.6 150.9,456.5 C 151.7,456.5 152.5,456.5 153.3,456.4 C 154.1,456.4 154.8,456.4 155.6,456.4 C 156.4,456.4 156.8,456.4 158.0,456.3 C 159.1,456.3 160.7,456.2 162.7,456.2 C 164.6,456.2 167.7,456.2 169.7,456.2 C 171.6,456.1 172.0,456.2 174.4,456.1 C 176.7,456.1 181.8,455.9 183.8,455.9 C 185.7,455.8 184.9,455.9 186.1,455.8 C 187.3,455.8 188.8,455.8 190.8,455.8 C 192.7,455.8 193.9,455.7 197.8,455.7 C 201.7,455.6 211.1,455.5 214.2,455.5 C 217.3,455.5 215.8,455.5 216.6,455.4 C 217.3,455.4 217.7,456.1 218.9,455.4 C 220.1,454.7 222.4,454.1 223.6,451.3 C 224.8,448.4 225.2,440.7 225.9,438.3 C 226.7,435.9 227.5,437.3 228.3,436.9 C 229.1,436.5 229.8,436.3 230.6,436.0 C 231.4,435.8 232.2,435.6 233.0,435.4 C 233.8,435.3 234.5,435.3 235.3,435.2 C 236.1,435.1 236.9,434.9 237.7,434.8 C 238.4,434.8 239.2,434.8 240.0,434.8 C 240.8,434.7 241.6,434.5 242.3,434.4 C 243.1,434.3 243.9,434.3 244.7,434.2 C 245.5,434.1 246.2,433.7 247.0,433.6 C 247.8,433.5 248.6,433.5 249.4,433.5 C 250.2,433.4 250.5,433.2 251.7,433.1 C 252.9,433.0 255.2,433.0 256.4,432.9 C 257.6,432.9 258.0,432.8 258.8,432.7 C 259.5,432.7 260.3,432.7 261.1,432.6 C 261.9,432.5 262.7,432.2 263.4,432.1 C 264.2,431.9 265.0,432.6 265.8,431.7 C 266.6,430.8 267.3,427.6 268.1,426.6 C 268.9,425.5 269.7,425.7 270.5,425.5 C 271.2,425.3 272.0,425.5 272.8,425.4 C 273.6,425.3 274.4,424.9 275.2,424.6 C 275.9,424.4 276.7,424.2 277.5,423.7 C 278.3,423.2 279.1,422.2 279.8,421.6 C 280.6,421.0 281.4,420.4 282.2,420.0 C 283.0,419.5 283.8,419.2 284.5,419.0 C 285.3,418.7 286.1,418.6 286.9,418.5 C 287.7,418.3 288.4,418.4 289.2,418.2 C 290.0,418.1 290.8,417.9 291.6,417.8 C 292.3,417.7 293.1,417.8 293.9,417.7 C 294.7,417.6 295.5,417.6 296.2,417.5 C 297.0,417.4 297.8,417.4 298.6,417.0 C 299.4,416.6 300.2,415.5 300.9,415.0 C 301.7,414.5 302.5,414.3 303.3,414.0 C 304.1,413.6 304.8,414.3 305.6,412.8 C 306.4,411.2 307.2,406.4 308.0,404.6 C 308.8,402.7 309.5,402.4 310.3,401.7 C 311.1,400.9 311.9,400.4 312.7,399.9 C 313.4,399.3 314.2,398.8 315.0,398.4 C 315.8,398.0 316.6,397.6 317.3,397.3 C 318.1,397.1 318.9,397.0 319.7,396.8 C 320.5,396.6 321.2,396.3 322.0,396.0 C 322.8,395.7 323.6,395.2 324.4,395.0 C 325.2,394.7 325.9,394.8 326.7,394.5 C 327.5,394.2 328.3,393.8 329.1,393.5 C 329.8,393.1 330.6,392.9 331.4,392.6 C 332.2,392.4 333.0,392.2 333.8,392.0 C 334.5,391.9 335.3,391.8 336.1,391.7 C 336.9,391.6 337.7,391.4 338.4,391.2 C 339.2,391.0 340.0,390.9 340.8,390.7 C 341.6,390.5 342.3,390.3 343.1,390.0 C 343.9,389.6 344.7,389.0 345.5,388.8 C 346.2,388.5 347.0,388.4 347.8,388.2 C 348.6,388.1 349.4,387.9 350.2,387.8 C 350.9,387.7 351.7,387.6 352.5,387.5 C 353.3,387.4 354.1,387.2 354.8,387.2 C 355.6,387.1 356.4,387.1 357.2,387.0 C 358.0,386.9 358.8,386.7 359.5,386.4 C 360.3,386.1 361.1,385.6 361.9,385.3 C 362.7,385.0 363.4,384.6 364.2,384.4 C 365.0,384.2 365.8,384.1 366.6,384.0 C 367.3,383.9 368.1,383.8 368.9,383.6 C 369.7,383.3 370.5,382.8 371.2,382.6 C 372.0,382.4 372.8,382.4 373.6,382.3 C 374.4,382.2 375.2,382.0 375.9,381.8 C 376.7,381.6 377.5,381.4 378.3,381.3 C 379.1,381.1 379.8,380.8 380.6,380.7 C 381.4,380.5 382.2,380.4 383.0,380.2 C 383.8,379.9 384.5,379.6 385.3,379.3 C 386.1,379.1 386.9,379.0 387.7,378.7 C 388.4,378.5 389.2,378.2 390.0,377.9 C 390.8,377.6 391.6,377.4 392.3,377.1 C 393.1,376.8 393.9,376.8 394.7,376.4 C 395.5,376.0 396.2,375.1 397.0,374.7 C 397.8,374.3 398.6,374.3 399.4,374.1 C 400.2,374.0 400.9,374.0 401.7,373.9 C 402.5,373.8 403.3,373.7 404.1,373.5 C 404.8,373.4 405.6,373.6 406.4,373.3 C 407.2,372.9 408.0,371.8 408.8,371.4 C 409.5,371.0 410.3,371.1 411.1,370.8 C 411.9,370.6 412.7,370.1 413.4,369.9 C 414.2,369.7 415.0,369.8 415.8,369.6 C 416.6,369.4 417.3,368.9 418.1,368.7 C 418.9,368.5 419.7,368.5 420.5,368.4 C 421.2,368.3 422.0,368.3 422.8,368.1 C 423.6,367.9 424.4,367.6 425.2,367.1 C 425.9,366.6 426.7,365.5 427.5,365.0 C 428.3,364.5 429.1,364.2 429.8,363.9 C 430.6,363.5 431.4,363.4 432.2,362.7 C 433.0,361.9 433.8,360.2 434.5,359.4 C 435.3,358.6 436.1,358.3 436.9,357.8 C 437.7,357.3 438.4,357.2 439.2,356.5 C 440.0,355.8 440.8,354.2 441.6,353.5 C 442.3,352.8 443.1,352.7 443.9,352.3 C 444.7,351.9 445.5,351.5 446.2,351.1 C 447.0,3Line truncated
<path d="M 90.0,462.2 C 90.4,462.1 92.0,461.8 92.3,461.7 C 93.1,461.6 93.9,461.4 94.7,461.2 C 95.5,461.1 96.2,460.8 97.0,460.8 C 97.8,460.7 98.6,460.7 99.4,460.7 C 100.2,460.7 100.9,460.6 101.7,460.6 C 102.5,460.5 103.3,460.5 104.1,460.4 C 104.8,460.4 105.2,460.3 106.4,460.3 C 107.6,460.2 109.9,460.1 111.1,460.1 C 112.3,460.1 112.7,460.0 113.4,460.0 C 114.2,460.0 115.0,460.0 115.8,459.9 C 116.6,459.9 117.3,459.9 118.1,459.9 C 118.9,459.9 119.7,459.9 120.5,459.8 C 121.2,459.8 122.0,459.7 122.8,459.7 C 123.6,459.6 124.4,459.6 125.2,459.4 C 125.9,459.3 126.7,459.0 127.5,458.7 C 128.3,458.3 129.1,457.6 129.8,457.3 C 130.6,457.1 131.4,457.2 132.2,457.1 C 133.0,457.1 133.8,457.1 134.5,457.0 C 135.3,457.0 135.7,457.0 136.9,456.9 C 138.0,456.9 140.0,456.9 141.6,456.9 C 143.1,456.8 145.1,456.8 146.2,456.8 C 147.4,456.7 147.8,456.7 148.6,456.7 C 149.4,456.6 150.2,456.6 150.9,456.5 C 151.7,456.5 152.5,456.5 153.3,456.4 C 154.1,456.4 154.8,456.4 155.6,456.4 C 156.4,456.4 156.8,456.4 158.0,456.3 C 159.1,456.3 160.7,456.2 162.7,456.2 C 164.6,456.2 167.7,456.2 169.7,456.2 C 171.6,456.1 172.0,456.2 174.4,456.1 C 176.7,456.1 181.8,455.9 183.8,455.9 C 185.7,455.8 184.9,455.9 186.1,455.8 C 187.3,455.8 188.8,455.8 190.8,455.8 C 192.7,455.8 193.9,455.7 197.8,455.7 C 201.7,455.6 211.1,455.5 214.2,455.5 C 217.3,455.5 215.8,455.5 216.6,455.4 C 217.3,455.4 217.7,456.1 218.9,455.4 C 220.1,454.7 222.4,454.1 223.6,451.3 C 224.8,448.4 225.2,440.7 225.9,438.3 C 226.7,435.9 227.5,437.3 228.3,436.9 C 229.1,436.5 229.8,436.3 230.6,436.0 C 231.4,435.8 232.2,435.6 233.0,435.4 C 233.8,435.3 234.5,435.3 235.3,435.2 C 236.1,435.1 236.9,434.9 237.7,434.8 C 238.4,434.8 239.2,434.8 240.0,434.8 C 240.8,434.7 241.6,434.5 242.3,434.4 C 243.1,434.3 243.9,434.3 244.7,434.2 C 245.5,434.1 246.2,433.7 247.0,433.6 C 247.8,433.5 248.6,433.5 249.4,433.5 C 250.2,433.4 250.5,433.2 251.7,433.1 C 252.9,433.0 255.2,433.0 256.4,432.9 C 257.6,432.9 258.0,432.8 258.8,432.7 C 259.5,432.7 260.3,432.7 261.1,432.6 C 261.9,432.5 262.7,432.2 263.4,432.1 C 264.2,431.9 265.0,432.6 265.8,431.7 C 266.6,430.8 267.3,427.6 268.1,426.6 C 268.9,425.5 269.7,425.7 270.5,425.5 C 271.2,425.3 272.0,425.5 272.8,425.4 C 273.6,425.3 274.4,424.9 275.2,424.6 C 275.9,424.4 276.7,424.2 277.5,423.7 C 278.3,423.2 279.1,422.2 279.8,421.6 C 280.6,421.0 281.4,420.4 282.2,420.0 C 283.0,419.5 283.8,419.2 284.5,419.0 C 285.3,418.7 286.1,418.6 286.9,418.5 C 287.7,418.3 288.4,418.4 289.2,418.2 C 290.0,418.1 290.8,417.9 291.6,417.8 C 292.3,417.7 293.1,417.8 293.9,417.7 C 294.7,417.6 295.5,417.6 296.2,417.5 C 297.0,417.4 297.8,417.4 298.6,417.0 C 299.4,416.6 300.2,415.5 300.9,415.0 C 301.7,414.5 302.5,414.3 303.3,414.0 C 304.1,413.6 304.8,414.3 305.6,412.8 C 306.4,411.2 307.2,406.4 308.0,404.6 C 308.8,402.7 309.5,402.4 310.3,401.7 C 311.1,400.9 311.9,400.4 312.7,399.9 C 313.4,399.3 314.2,398.8 315.0,398.4 C 315.8,398.0 316.6,397.6 317.3,397.3 C 318.1,397.1 318.9,397.0 319.7,396.8 C 320.5,396.6 321.2,396.3 322.0,396.0 C 322.8,395.7 323.6,395.2 324.4,395.0 C 325.2,394.7 325.9,394.8 326.7,394.5 C 327.5,394.2 328.3,393.8 329.1,393.5 C 329.8,393.1 330.6,392.9 331.4,392.6 C 332.2,392.4 333.0,392.2 333.8,392.0 C 334.5,391.9 335.3,391.8 336.1,391.7 C 336.9,391.6 337.7,391.4 338.4,391.2 C 339.2,391.0 340.0,390.9 340.8,390.7 C 341.6,390.5 342.3,390.3 343.1,390.0 C 343.9,389.6 344.7,389.0 345.5,388.8 C 346.2,388.5 347.0,388.4 347.8,388.2 C 348.6,388.1 349.4,387.9 350.2,387.8 C 350.9,387.7 351.7,387.6 352.5,387.5 C 353.3,387.4 354.1,387.2 354.8,387.2 C 355.6,387.1 356.4,387.1 357.2,387.0 C 358.0,386.9 358.8,386.7 359.5,386.4 C 360.3,386.1 361.1,385.6 361.9,385.3 C 362.7,385.0 363.4,384.6 364.2,384.4 C 365.0,384.2 365.8,384.1 366.6,384.0 C 367.3,383.9 368.1,383.8 368.9,383.6 C 369.7,383.3 370.5,382.8 371.2,382.6 C 372.0,382.4 372.8,382.4 373.6,382.3 C 374.4,382.2 375.2,382.0 375.9,381.8 C 376.7,381.6 377.5,381.4 378.3,381.3 C 379.1,381.1 379.8,380.8 380.6,380.7 C 381.4,380.5 382.2,380.4 383.0,380.2 C 383.8,379.9 384.5,379.6 385.3,379.3 C 386.1,379.1 386.9,379.0 387.7,378.7 C 388.4,378.5 389.2,378.2 390.0,377.9 C 390.8,377.6 391.6,377.4 392.3,377.1 C 393.1,376.8 393.9,376.8 394.7,376.4 C 395.5,376.0 396.2,375.1 397.0,374.7 C 397.8,374.3 398.6,374.3 399.4,374.1 C 400.2,374.0 400.9,374.0 401.7,373.9 C 402.5,373.8 403.3,373.7 404.1,373.5 C 404.8,373.4 405.6,373.6 406.4,373.3 C 407.2,372.9 408.0,371.8 408.8,371.4 C 409.5,371.0 410.3,371.1 411.1,370.8 C 411.9,370.6 412.7,370.1 413.4,369.9 C 414.2,369.7 415.0,369.8 415.8,369.6 C 416.6,369.4 417.3,368.9 418.1,368.7 C 418.9,368.5 419.7,368.5 420.5,368.4 C 421.2,368.3 422.0,368.3 422.8,368.1 C 423.6,367.9 424.4,367.6 425.2,367.1 C 425.9,366.6 426.7,365.5 427.5,365.0 C 428.3,364.5 429.1,364.2 429.8,363.9 C 430.6,363.5 431.4,363.4 432.2,362.7 C 433.0,361.9 433.8,360.2 434.5,359.4 C 435.3,358.6 436.1,358.3 436.9,357.8 C 437.7,357.3 438.4,357.2 439.2,356.5 C 440.0,355.8 440.8,354.2 441.6,353.5 C 442.3,352.8 443.1,352.7 443.9,352.3 C 444.7,351.9 445.5,351.5 446.2,351.1 C 447.0,3Line truncated
<circle cx="765.0" cy="99.0" r="5" fill="#dd4528" stroke="#fff" stroke-width="2" filter="url(#xkcdify)"/>
<text x="753.0" y="77.0" fill="#dd4528" font-size="18" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">6652</text>
<path d="M 90.0,462.2 C 90.4,462.1 91.9,461.8 92.3,461.7 C 93.1,461.6 93.8,461.4 94.6,461.2 C 95.3,461.1 96.1,460.8 96.9,460.8 C 97.6,460.7 98.4,460.7 99.2,460.7 C 99.9,460.7 100.7,460.6 101.4,460.6 C 102.2,460.5 103.0,460.5 103.7,460.4 C 104.5,460.4 104.9,460.3 106.0,460.3 C 107.2,460.2 109.4,460.1 110.6,460.1 C 111.7,460.1 112.1,460.0 112.9,460.0 C 113.6,460.0 114.4,460.0 115.2,459.9 C 115.9,459.9 116.7,459.9 117.5,459.9 C 118.2,459.9 119.0,459.9 119.7,459.8 C 120.5,459.8 121.3,459.7 122.0,459.7 C 122.8,459.6 123.6,459.6 124.3,459.4 C 125.1,459.3 125.8,459.0 126.6,458.7 C 127.4,458.3 128.1,457.6 128.9,457.3 C 129.7,457.1 130.4,457.2 131.2,457.1 C 131.9,457.1 132.7,457.1 133.5,457.0 C 134.2,457.0 134.6,457.0 135.8,456.9 C 136.9,456.9 138.8,456.9 140.3,456.9 C 141.9,456.8 143.8,456.8 144.9,456.8 C 146.1,456.7 146.4,456.7 147.2,456.7 C 148.0,456.6 148.7,456.6 149.5,456.5 C 150.3,456.5 151.0,456.5 151.8,456.4 C 152.5,456.4 153.3,456.4 154.1,456.4 C 154.8,456.4 155.2,456.4 156.4,456.3 C 157.5,456.3 159.0,456.2 160.9,456.2 C 162.8,456.2 165.9,456.2 167.8,456.2 C 169.7,456.1 170.1,456.2 172.4,456.1 C 174.7,456.1 179.6,455.9 181.5,455.9 C 183.4,455.8 182.7,455.9 183.8,455.8 C 185.0,455.8 186.5,455.8 188.4,455.8 C 190.3,455.8 191.4,455.7 195.3,455.7 C 199.1,455.6 208.2,455.5 211.3,455.5 C 214.3,455.5 212.8,455.5 213.6,455.4 C 214.3,455.4 214.7,456.1 215.8,455.4 C 217.0,454.7 219.3,454.1 220.4,451.3 C 221.6,448.4 221.9,440.7 222.7,438.3 C 223.5,435.9 224.2,437.3 225.0,436.9 C 225.8,436.5 226.5,436.3 227.3,436.0 C 228.1,435.8 228.8,435.6 229.6,435.4 C 230.3,435.3 231.1,435.3 231.9,435.2 C 232.6,435.1 233.4,434.9 234.2,434.8 C 234.9,434.8 235.7,434.8 236.4,434.8 C 237.2,434.7 238.0,434.5 238.7,434.4 C 239.5,434.3 240.3,434.3 241.0,434.2 C 241.8,434.1 242.5,433.7 243.3,433.6 C 244.1,433.5 244.8,433.5 245.6,433.5 C 246.4,433.4 246.7,433.2 247.9,433.1 C 249.0,433.0 251.3,433.0 252.5,432.9 C 253.6,432.9 254.0,432.8 254.7,432.7 C 255.5,432.7 256.3,432.7 257.0,432.6 C 257.8,432.5 258.6,432.2 259.3,432.1 C 260.1,431.9 260.8,432.6 261.6,431.7 C 262.4,430.8 263.1,427.6 263.9,426.6 C 264.7,425.5 265.4,425.7 266.2,425.5 C 266.9,425.3 267.7,425.5 268.5,425.4 C 269.2,425.3 270.0,424.9 270.8,424.6 C 271.5,424.4 272.3,424.2 273.1,423.7 C 273.8,423.2 274.6,422.2 275.3,421.6 C 276.1,421.0 276.9,420.5 277.6,420.0 C 278.4,419.6 279.2,419.3 279.9,419.0 C 280.7,418.8 281.4,418.6 282.2,418.5 C 283.0,418.4 283.7,418.4 284.5,418.3 C 285.3,418.2 286.0,418.0 286.8,417.9 C 287.5,417.8 288.3,417.8 289.1,417.8 C 289.8,417.7 290.6,417.7 291.4,417.5 C 292.1,417.4 292.9,417.5 293.6,417.0 C 294.4,416.6 295.2,415.6 295.9,415.1 C 296.7,414.6 297.5,414.4 298.2,414.0 C 299.0,413.7 299.7,414.4 300.5,412.8 C 301.3,411.3 302.0,406.5 302.8,404.6 C 303.6,402.8 304.3,402.5 305.1,401.7 C 305.8,400.9 306.6,400.4 307.4,399.9 C 308.1,399.3 308.9,398.8 309.7,398.4 C 310.4,398.0 311.2,397.6 311.9,397.3 C 312.7,397.1 313.5,397.0 314.2,396.8 C 315.0,396.6 315.8,396.3 316.5,396.0 C 317.3,395.7 318.1,395.2 318.8,395.0 C 319.6,394.7 320.3,394.8 321.1,394.5 C 321.9,394.2 322.6,393.8 323.4,393.5 C 324.2,393.1 324.9,392.9 325.7,392.6 C 326.4,392.4 327.2,392.2 328.0,392.0 C 328.7,391.9 329.5,391.8 330.3,391.7 C 331.0,391.6 331.8,391.4 332.5,391.2 C 333.3,391.0 334.1,390.9 334.8,390.7 C 335.6,390.5 336.4,390.3 337.1,390.0 C 337.9,389.6 338.6,389.0 339.4,388.8 C 340.2,388.5 340.9,388.4 341.7,388.2 C 342.5,388.1 343.2,387.9 344.0,387.8 C 344.7,387.7 345.5,387.6 346.3,387.5 C 347.0,387.4 347.8,387.2 348.6,387.2 C 349.3,387.1 350.1,387.1 350.8,387.0 C 351.6,386.9 352.4,386.7 353.1,386.4 C 353.9,386.1 354.7,385.6 355.4,385.3 C 356.2,385.0 356.9,384.6 357.7,384.4 C 358.5,384.2 359.2,384.1 360.0,384.0 C 360.8,383.9 361.5,383.8 362.3,383.6 C 363.1,383.3 363.8,382.8 364.6,382.6 C 365.3,382.4 366.1,382.4 366.9,382.3 C 367.6,382.2 368.4,382.0 369.2,381.8 C 369.9,381.6 370.7,381.4 371.4,381.3 C 372.2,381.1 373.0,380.8 373.7,380.7 C 374.5,380.5 375.3,380.4 376.0,380.2 C 376.8,379.9 377.5,379.6 378.3,379.3 C 379.1,379.1 379.8,379.0 380.6,378.7 C 381.4,378.5 382.1,378.2 382.9,377.9 C 383.6,377.6 384.4,377.4 385.2,377.1 C 385.9,376.8 386.7,376.8 387.5,376.4 C 388.2,376.0 389.0,375.1 389.7,374.7 C 390.5,374.3 391.3,374.3 392.0,374.1 C 392.8,374.0 393.6,374.0 394.3,373.9 C 395.1,373.8 395.8,373.7 396.6,373.5 C 397.4,373.4 398.1,373.6 398.9,373.3 C 399.7,372.9 400.4,371.8 401.2,371.4 C 401.9,371.0 402.7,371.1 403.5,370.8 C 404.2,370.6 405.0,370.1 405.8,369.9 C 406.5,369.7 407.3,369.8 408.1,369.6 C 408.8,369.4 409.6,368.9 410.3,368.7 C 411.1,368.5 411.9,368.5 412.6,368.4 C 413.4,368.3 414.2,368.3 414.9,368.1 C 415.7,367.9 416.4,367.6 417.2,367.1 C 418.0,366.6 418.7,365.5 419.5,365.0 C 420.3,364.5 421.0,364.2 421.8,363.9 C 422.5,363.5 423.3,363.4 424.1,362.7 C 424.8,361.9 425.6,360.2 426.4,359.4 C 427.1,358.6 427.9,358.3 428.6,357.8 C 429.4,357.4 430.2,357.3 430.9,356.6 C 431.7,355.9 432.5,354.2 433.2,353.5 C 434.0,352.8 434.7,352.7 435.5,352.3 C 436.3,351.9 437.0,351.6 437.8,351.1 C 438.6,35Line truncated
<path d="M 90.0,462.2 C 90.4,462.1 91.9,461.8 92.3,461.7 C 93.1,461.6 93.8,461.4 94.6,461.2 C 95.3,461.1 96.1,460.8 96.9,460.8 C 97.6,460.7 98.4,460.7 99.2,460.7 C 99.9,460.7 100.7,460.6 101.4,460.6 C 102.2,460.5 103.0,460.5 103.7,460.4 C 104.5,460.4 104.9,460.3 106.0,460.3 C 107.2,460.2 109.4,460.1 110.6,460.1 C 111.7,460.1 112.1,460.0 112.9,460.0 C 113.6,460.0 114.4,460.0 115.2,459.9 C 115.9,459.9 116.7,459.9 117.5,459.9 C 118.2,459.9 119.0,459.9 119.7,459.8 C 120.5,459.8 121.3,459.7 122.0,459.7 C 122.8,459.6 123.6,459.6 124.3,459.4 C 125.1,459.3 125.8,459.0 126.6,458.7 C 127.4,458.3 128.1,457.6 128.9,457.3 C 129.7,457.1 130.4,457.2 131.2,457.1 C 131.9,457.1 132.7,457.1 133.5,457.0 C 134.2,457.0 134.6,457.0 135.8,456.9 C 136.9,456.9 138.8,456.9 140.3,456.9 C 141.9,456.8 143.8,456.8 144.9,456.8 C 146.1,456.7 146.4,456.7 147.2,456.7 C 148.0,456.6 148.7,456.6 149.5,456.5 C 150.3,456.5 151.0,456.5 151.8,456.4 C 152.5,456.4 153.3,456.4 154.1,456.4 C 154.8,456.4 155.2,456.4 156.4,456.3 C 157.5,456.3 159.0,456.2 160.9,456.2 C 162.8,456.2 165.9,456.2 167.8,456.2 C 169.7,456.1 170.1,456.2 172.4,456.1 C 174.7,456.1 179.6,455.9 181.5,455.9 C 183.4,455.8 182.7,455.9 183.8,455.8 C 185.0,455.8 186.5,455.8 188.4,455.8 C 190.3,455.8 191.4,455.7 195.3,455.7 C 199.1,455.6 208.2,455.5 211.3,455.5 C 214.3,455.5 212.8,455.5 213.6,455.4 C 214.3,455.4 214.7,456.1 215.8,455.4 C 217.0,454.7 219.3,454.1 220.4,451.3 C 221.6,448.4 221.9,440.7 222.7,438.3 C 223.5,435.9 224.2,437.3 225.0,436.9 C 225.8,436.5 226.5,436.3 227.3,436.0 C 228.1,435.8 228.8,435.6 229.6,435.4 C 230.3,435.3 231.1,435.3 231.9,435.2 C 232.6,435.1 233.4,434.9 234.2,434.8 C 234.9,434.8 235.7,434.8 236.4,434.8 C 237.2,434.7 238.0,434.5 238.7,434.4 C 239.5,434.3 240.3,434.3 241.0,434.2 C 241.8,434.1 242.5,433.7 243.3,433.6 C 244.1,433.5 244.8,433.5 245.6,433.5 C 246.4,433.4 246.7,433.2 247.9,433.1 C 249.0,433.0 251.3,433.0 252.5,432.9 C 253.6,432.9 254.0,432.8 254.7,432.7 C 255.5,432.7 256.3,432.7 257.0,432.6 C 257.8,432.5 258.6,432.2 259.3,432.1 C 260.1,431.9 260.8,432.6 261.6,431.7 C 262.4,430.8 263.1,427.6 263.9,426.6 C 264.7,425.5 265.4,425.7 266.2,425.5 C 266.9,425.3 267.7,425.5 268.5,425.4 C 269.2,425.3 270.0,424.9 270.8,424.6 C 271.5,424.4 272.3,424.2 273.1,423.7 C 273.8,423.2 274.6,422.2 275.3,421.6 C 276.1,421.0 276.9,420.5 277.6,420.0 C 278.4,419.6 279.2,419.3 279.9,419.0 C 280.7,418.8 281.4,418.6 282.2,418.5 C 283.0,418.4 283.7,418.4 284.5,418.3 C 285.3,418.2 286.0,418.0 286.8,417.9 C 287.5,417.8 288.3,417.8 289.1,417.8 C 289.8,417.7 290.6,417.7 291.4,417.5 C 292.1,417.4 292.9,417.5 293.6,417.0 C 294.4,416.6 295.2,415.6 295.9,415.1 C 296.7,414.6 297.5,414.4 298.2,414.0 C 299.0,413.7 299.7,414.4 300.5,412.8 C 301.3,411.3 302.0,406.5 302.8,404.6 C 303.6,402.8 304.3,402.5 305.1,401.7 C 305.8,400.9 306.6,400.4 307.4,399.9 C 308.1,399.3 308.9,398.8 309.7,398.4 C 310.4,398.0 311.2,397.6 311.9,397.3 C 312.7,397.1 313.5,397.0 314.2,396.8 C 315.0,396.6 315.8,396.3 316.5,396.0 C 317.3,395.7 318.1,395.2 318.8,395.0 C 319.6,394.7 320.3,394.8 321.1,394.5 C 321.9,394.2 322.6,393.8 323.4,393.5 C 324.2,393.1 324.9,392.9 325.7,392.6 C 326.4,392.4 327.2,392.2 328.0,392.0 C 328.7,391.9 329.5,391.8 330.3,391.7 C 331.0,391.6 331.8,391.4 332.5,391.2 C 333.3,391.0 334.1,390.9 334.8,390.7 C 335.6,390.5 336.4,390.3 337.1,390.0 C 337.9,389.6 338.6,389.0 339.4,388.8 C 340.2,388.5 340.9,388.4 341.7,388.2 C 342.5,388.1 343.2,387.9 344.0,387.8 C 344.7,387.7 345.5,387.6 346.3,387.5 C 347.0,387.4 347.8,387.2 348.6,387.2 C 349.3,387.1 350.1,387.1 350.8,387.0 C 351.6,386.9 352.4,386.7 353.1,386.4 C 353.9,386.1 354.7,385.6 355.4,385.3 C 356.2,385.0 356.9,384.6 357.7,384.4 C 358.5,384.2 359.2,384.1 360.0,384.0 C 360.8,383.9 361.5,383.8 362.3,383.6 C 363.1,383.3 363.8,382.8 364.6,382.6 C 365.3,382.4 366.1,382.4 366.9,382.3 C 367.6,382.2 368.4,382.0 369.2,381.8 C 369.9,381.6 370.7,381.4 371.4,381.3 C 372.2,381.1 373.0,380.8 373.7,380.7 C 374.5,380.5 375.3,380.4 376.0,380.2 C 376.8,379.9 377.5,379.6 378.3,379.3 C 379.1,379.1 379.8,379.0 380.6,378.7 C 381.4,378.5 382.1,378.2 382.9,377.9 C 383.6,377.6 384.4,377.4 385.2,377.1 C 385.9,376.8 386.7,376.8 387.5,376.4 C 388.2,376.0 389.0,375.1 389.7,374.7 C 390.5,374.3 391.3,374.3 392.0,374.1 C 392.8,374.0 393.6,374.0 394.3,373.9 C 395.1,373.8 395.8,373.7 396.6,373.5 C 397.4,373.4 398.1,373.6 398.9,373.3 C 399.7,372.9 400.4,371.8 401.2,371.4 C 401.9,371.0 402.7,371.1 403.5,370.8 C 404.2,370.6 405.0,370.1 405.8,369.9 C 406.5,369.7 407.3,369.8 408.1,369.6 C 408.8,369.4 409.6,368.9 410.3,368.7 C 411.1,368.5 411.9,368.5 412.6,368.4 C 413.4,368.3 414.2,368.3 414.9,368.1 C 415.7,367.9 416.4,367.6 417.2,367.1 C 418.0,366.6 418.7,365.5 419.5,365.0 C 420.3,364.5 421.0,364.2 421.8,363.9 C 422.5,363.5 423.3,363.4 424.1,362.7 C 424.8,361.9 425.6,360.2 426.4,359.4 C 427.1,358.6 427.9,358.3 428.6,357.8 C 429.4,357.4 430.2,357.3 430.9,356.6 C 431.7,355.9 432.5,354.2 433.2,353.5 C 434.0,352.8 434.7,352.7 435.5,352.3 C 436.3,351.9 437.0,351.6 437.8,351.1 C 438.6,35Line truncated
<circle cx="765.0" cy="90.8" r="5" fill="#dd4528" stroke="#fff" stroke-width="2" filter="url(#xkcdify)"/>
<text x="753.0" y="77.0" fill="#dd4528" font-size="18" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">6802</text>
<a href="https://github.com/carsteneu/mystarhistory" target="_blank" rel="noopener"><text x="763" y="527" font-size="13" font-family="Handlee, cursive" fill="#000" text-anchor="end">Made with carsteneu/mystarhistory</text></a>
</svg>

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 36 KiB

+79
View File
@@ -0,0 +1,79 @@
# SSH input compatibility
The host advanced settings include **输入兼容模式:逐字发送** (input compatibility: send character by character). The help text is **中文输入或短粘贴出现丢字时尝试开启。** Saved changes take effect after reconnecting.
New SSH hosts use standard mode. Compatibility mode retains the interactive text workaround introduced by [PR #3168](https://github.com/binaricat/Netcatty/pull/3168):
- Literal IME commits with multiple Unicode code points use separate writes.
- Plain, unbracketed pastes of at most 32 Unicode code points use separate writes.
- Longer raw pastes, bracketed pastes, Kitty associated text, and the existing confirmed line-paste path retain their current transport behavior.
- Terminal controls stay together. Unicode code points retain their byte order, including combining marks and emoji sequences.
The setting belongs to the terminal endpoint host. A host used only as an SSH network jump does not supply the target's input setting. Other transports retain their existing input behavior. Automated commands, connect scripts, and SFTP do not use this setting.
## Stored records
`Host.sshInputCompatibility` stores the choice as a boolean. `sanitizeHost` migrates an SSH record with a missing field to `true`, including records whose omitted protocol means SSH. It preserves explicit `false` and `true` values on every subsequent load, import, and save. The existing vault normalization and persistence paths save the migrated value. No global migration marker or new storage key is required.
New host creation paths save `false`. Duplicates copy the source value. JSON vault backups and cloud sync retain the field. CSV exports include `SshInputCompatibility`; imports accept `true` and `false`. External imports without that column create standard-mode hosts.
The connection captures the host's setting under its session-owned terminal settings ref. This snapshot survives xterm disposal and the actual hibernate wake/reattach path. Reconnect refreshes the setting through `resetConnectionInputState(host)`, including reconnects that reuse the existing runtime.
## Historical evidence
[Issue #3077](https://github.com/binaricat/Netcatty/issues/3077#issuecomment-5433744875) reports that individual hanzi worked in a Qianxin search prompt where multi-character input failed. The report also says Windows with Xshell worked. Those observations do not establish the remote parser's SSH-write semantics or Xshell's default implementation.
The [Xshell 7 manual](https://cdn.netsarang.net/docs/Xshell7_manual.pdf), section 5.2.21, printed page 27, describes session paste character delay, line delay, and prompt waiting. [SecureCRT's echo flow control tip](https://www.vandyke.com/support/tips/echoflowctrl.html) describes a session line delay and an optional script that waits for each line's echo. These references support configurable send compatibility. They do not establish default per-character IME sends or equivalence to a Qianxin-specific fix. This feature adds no delays or echo waiting.
## Verification boundaries
`sshInputCompatibility.integration.test.ts` runs real xterm and `createXTermRuntime` callbacks under JSDOM. It covers raw paste, literal `insertText`, key code 229, composition events, composition fallback, long IME text, terminal controls, bracketed paste, Kitty text, reconnect with a reused runtime, and the actual `wakeTerminalFromHibernate` entry after a saved setting changes. Local, Telnet, serial, Mosh, and Eternal Terminal cases verify that the SSH field leaves their legacy text and control-containing payload behavior unchanged. Canvas measurement is stubbed. These tests do not exercise native input methods or application windows.
`useVaultState.inputCompatibility.test.ts` exercises the actual vault hook's asynchronous save, reload, duplicate, JSON backup, local backup payload, and cloud payload apply paths. It uses test localStorage and no native safeStorage key. CSV and endpoint selection have separate round-trip and jump-host tests.
## Isolated OpenSSH timing fixture
The implementation-side fixture connected to an owned OpenSSH server on `127.0.0.1:22379` using public-key authentication. It executed `cat` without a PTY because the unprivileged daemon could not allocate one. Production IME/raw-paste selectors and the code-point chunker supplied an `ssh2` client's `channel.write` calls. This fixture did not include native GUI events, renderer IPC, clipboard handling, or an OS input method.
Each sample used 15 consecutive repetitions per mode on the same ready SSH channel. Standard mode ran before compatibility mode for each sample; the modes were not randomized or interleaved. Authentication, command startup, selector evaluation, and chunk-array construction finished before timing. Each repetition started immediately before its first `channel.write` call and ended after the awaited receive-length condition resolved: the client's echo buffer contained at least the expected UTF-8 byte length. The complete received buffer was then compared byte-for-byte with the input, outside the timer. No artificial send delay was added.
The table reports milliseconds. Median is the eighth sorted observation. P95 uses nearest rank, which is the largest of 15 observations here. Every repetition in all six tested sample pairs preserved the exact input bytes.
| Sample | UTF-8 bytes | Client writes, standard / compatibility | Echo median, standard / compatibility | Echo P95, standard / compatibility |
|---|---:|---:|---:|---:|
| `中文输入` (IME selector) | 12 | 1 / 4 | 47.439 / 44.572 | 57.971 / 48.418 |
| `中文👩🏽‍💻é-hostname-123` (raw paste) | 37 | 1 / 21 | 46.628 / 47.957 | 51.753 / 55.012 |
| `中` repeated 32 times (raw paste) | 96 | 1 / 32 | 44.122 / 47.536 | 49.040 / 54.734 |
| `中文abc👩🏽‍💻é` repeated 4,096 times (raw paste) | 110,592 | 1 / 1 | 5.433 / 6.240 | 26.105 / 54.073 |
| Arrow sequence `ESC [ A` | 3 | 1 / 1 | 44.320 / 47.657 | 55.726 / 52.414 |
| Bracketed paste containing `中文` | 18 | 1 / 1 | 44.151 / 44.995 | 48.959 / 55.769 |
These are local end-to-end SSH echo completion times for this fixture. They are not native app paste latency, remote processing time, a throughput benchmark, or evidence that the previous behavior caused lag. Client write-call counts do not establish SSH packet boundaries, server read boundaries, or SSH channel-data block counts.
## Native desktop results
The final native report tested production code commit `bd5f27ff42d29b18c0e9cb3513415da3243dd636` against two owned loopback SSH endpoints in an isolated profile. The following checks passed:
- New hosts defaulted to standard mode. Duplicate, save, and application restart preserved each host's choice.
- Saving during a connection did not change its input mode. A real reconnect adopted the saved mode in both directions.
- Full hibernation used a 5-second hidden-tab delay and zero retained renderers, with xterm absence confirmed. Wake/reattach retained the old connection choice in both directions; a subsequent reconnect adopted the saved choice.
- A missing-field legacy record was seeded into the isolated host store. Real application startup migrated it to `true`. After the user disabled compatibility, saved, and restarted, the value remained `false`.
- The Chinese setting label, help text, and reconnect notice matched the required text and were captured in screenshots. Arrow and Ctrl+C bytes remained intact.
| Native sample | Received-content result | Observed SSH channel-data blocks, standard / compatibility |
|---|---|---|
| `中文测试🙂é输入兼容模式` — 37 UTF-8 bytes, 13 code points | Hashes matched between modes | 1 / 13 |
| Long sample — 131,072 bytes | Hashes matched between modes | Four 32,768-byte frames / four 32,768-byte frames |
| Negotiated bracketed paste — 49 bytes | Preserved | 1 / 1 |
The native short sample differs from the implementation fixture's 37-byte sample above. Its 1 / 13 SSH channel-data block observation is not the implementation fixture's 1 / 21 client write-call count. The native 131,072-byte long sample is also distinct from the 110,592-byte timing sample.
The native timing observation was one PTY first-to-last receive span per mode: standard **3.408724 ms**, compatibility **1.600044 ms**. It starts at the first received PTY payload and ends at the last received payload. It excludes the input-to-first-receive interval and is not total GUI paste latency. One observation per mode does not provide a median, P95, or causal performance comparison. These spans cannot be substituted for the 15-repetition client echo timings above. Neither run establishes throughput superiority or that the previous workaround caused lag.
Actual OS IME input and Qianxin hardware were not tested. Native networked cloud sync, import, and jump-chain behavior were also not tested; those data flows have automated coverage rather than claimed native validation. Synthetic DOM composition events, native GUI/paste coverage, client write calls, SSH data blocks, and receive timing remain separate evidence categories.
Independent review confirmed the three reported P2 findings resolved, with no new blocking findings. The native application, SSH and clipboard fixture processes, isolated profile, and test keys were cleaned up; the source tree was clean after that run.
## CI result
The `test` workflow completed successfully for documentation commit `38c5f4744cb5fc3ecdd4e624b323587a471e4333`: [run 40319492139](https://github.com/binaricat/Netcatty/actions/runs/40319492139). That workflow includes `npm run lint`, `npm test`, generated-spec checks, and `npm run build`. The production code is unchanged from the native-tested `bd5f27ff` commit. Subsequent acceptance-text edits change documentation only. The PR remains draft and unmerged.
+12
View File
@@ -793,3 +793,15 @@ test("sanitizeHost preserves named-pipe spaces and restores the authoritative se
assert.equal(sanitizeHost(host).serialConfig?.path, path);
assert.equal(sanitizeHost(makeHost({ hostname: 'example.com extra' })).hostname, 'example.com');
});
test("SSH input compatibility migrates legacy hosts once and preserves later choices", () => {
const legacy = { id: "legacy", label: "Legacy", hostname: "localhost", username: "user", tags: [], os: "linux" } as Host;
for (const protocol of [undefined, "ssh"] as const) {
const migrated = sanitizeHost({ ...legacy, protocol });
assert.equal(migrated.sshInputCompatibility, true);
assert.equal(sanitizeHost(migrated).sshInputCompatibility, true);
assert.equal(sanitizeHost({ ...migrated, sshInputCompatibility: false }).sshInputCompatibility, false);
}
assert.equal(sanitizeHost({ ...legacy, protocol: "telnet" }).sshInputCompatibility, undefined);
});
+5
View File
@@ -466,6 +466,11 @@ export const sanitizeHost = (host: Host, snippets: Snippet[] = []): Host => {
? undefined
: migrated.authMethod ?? inferredLegacyAuthMethod,
authPolicyVersion: 1,
// Legacy SSH records used per-character IME/short-paste writes globally.
// Persist the choice on normalization; explicit false survives every reload.
sshInputCompatibility: host.protocol === undefined || host.protocol === 'ssh'
? host.sshInputCompatibility ?? true
: host.sshInputCompatibility,
hostname: cleanHostname,
osOverride: osSelection,
os: osSelection === 'linux' || osSelection === 'windows' || osSelection === 'macos' ? osSelection : host.os,
+3
View File
@@ -189,6 +189,9 @@ export interface Host {
identityFileId?: string; // Reference to SSHKey
protocol?: HostProtocol; // Default/primary protocol, including namespaced plugin protocols
pluginConnection?: PluginConnectionConfig;
// SSH interactive IME/short-paste compatibility; applied on reconnect.
// Missing on legacy records; sanitizeHost preserves their previous behavior.
sshInputCompatibility?: boolean;
// Runtime marker for in-memory-only hosts (e.g. password deep links).
// Ephemeral hosts are never persisted to the vault or session restore.
ephemeral?: boolean;
+26
View File
@@ -820,3 +820,29 @@ test('plugin importer preview is bounded and never exposes secret or command pay
assert.equal(JSON.stringify(preview).includes('private-key-material'), false);
assert.equal(JSON.stringify(preview).includes('contains-sensitive-command'), false);
});
test('new imported SSH drafts default to standard input and preserve explicit boolean choices', () => {
for (const protocol of [undefined, 'ssh'] as const) {
for (const choice of [undefined, false, true]) {
const result = normalizePluginImporterRecords([{ type: 'draft', draft: {
kind: 'host', value: {
hostname: 'new.test', ...(protocol ? { protocol } : {}),
...(choice === undefined ? {} : { sshInputCompatibility: choice }),
},
} }]);
assert.deepEqual(result.errors, []);
assert.equal(result.hosts[0].sshInputCompatibility, choice ?? false);
}
}
});
test('imported host drafts reject non-boolean input compatibility choices', () => {
for (const choice of ['false', 'true', 0, null]) {
const result = normalizePluginImporterRecords([{ type: 'draft', draft: {
kind: 'host', value: { hostname: 'new.test', protocol: 'ssh', sshInputCompatibility: choice },
} }]);
assert.deepEqual(result.hosts, []);
assert.deepEqual(result.errors, ['Importer returned an invalid host draft.']);
}
});
+2
View File
@@ -204,6 +204,7 @@ const normalizeHost = (value: JsonValue): Host | null => {
};
if (Object.values(optionalStrings).some((item) => item === INVALID_FIELD)) return null;
const optionalBooleans = {
sshInputCompatibility: optionalBooleanValue(object, 'sshInputCompatibility'),
moshEnabled: optionalBooleanValue(object, 'moshEnabled'),
etEnabled: optionalBooleanValue(object, 'etEnabled'),
telnetEnabled: optionalBooleanValue(object, 'telnetEnabled'),
@@ -242,6 +243,7 @@ const normalizeHost = (value: JsonValue): Host | null => {
createdAt: Date.now(),
ephemeral: false,
managedSourceId: undefined,
sshInputCompatibility: false,
...(protocol ? { protocol } : {}),
...(pluginConnection ? { pluginConnection: structuredClone(pluginConnection) } : {}),
...(deviceType ? { deviceType } : {}),
+1
View File
@@ -68,6 +68,7 @@ export const buildQuickConnectHost = ({
os: "linux",
protocol: protocol === "mosh" || protocol === "et" ? "ssh" : protocol,
authMethod,
sshInputCompatibility: false,
identityId: applicableIdentityId,
password: !applicableIdentityId && authMethod === "password" ? password : undefined,
identityFileId:
+4
View File
@@ -16,6 +16,9 @@
// CloudLinker — issue #3592: the vendor identifies a single-channel gateway
// and proposes this distinctive banner. Older "Go" banners cannot identify it
// safely; this compatibility entry requires the vendor banner change.
// russh — issue #3973: russh returns CHANNEL_FAILURE for wantReply=false env
// requests, desynchronizing ssh2's callback FIFO so pty-req/shell appear to
// fail. Skip env via the single-channel path. No chunked-write evidence.
// JumpServer KoKo advertises Version "JumpServer" and multiplexes. Omit it.
const SINGLE_CHANNEL_SOFTWARE_TOKENS = Object.freeze([
@@ -23,6 +26,7 @@ const SINGLE_CHANNEL_SOFTWARE_TOKENS = Object.freeze([
"TERM-SSHD",
"CLOUDBILITY",
"CLOUDLINKER",
"russh",
]);
// #3146 also drops multi-character writes. That evidence is only for these
+4
View File
@@ -16,6 +16,9 @@
// CloudLinker — issue #3592: the vendor identifies a single-channel gateway
// and proposes this distinctive banner. Older "Go" banners cannot identify it
// safely; this compatibility entry requires the vendor banner change.
// russh — issue #3973: russh returns CHANNEL_FAILURE for wantReply=false env
// requests, desynchronizing ssh2's callback FIFO so pty-req/shell appear to
// fail. Skip env via the single-channel path. No chunked-write evidence.
// JumpServer KoKo advertises Version "JumpServer" and multiplexes. Omit it.
const SINGLE_CHANNEL_SOFTWARE_TOKENS = Object.freeze([
@@ -23,6 +26,7 @@ const SINGLE_CHANNEL_SOFTWARE_TOKENS = Object.freeze([
"TERM-SSHD",
"CLOUDBILITY",
"CLOUDLINKER",
"russh",
]);
// #3146 also drops multi-character writes. That evidence is only for these
+13 -1
View File
@@ -20,6 +20,10 @@ test("single-channel banners match only the supported software tokens", () => {
"CloudLinker",
"SSH-2.0-CloudLinker",
"cloudlinker-1.0",
"russh",
"SSH-2.0-russh",
"SSH-2.0-russh_0.45.0",
"RuSSH",
]) {
assert.equal(remoteSoftwareRequiresSingleChannel(version), true, version);
}
@@ -49,6 +53,12 @@ test("single-channel banner match ignores OpenSSH, JumpServer, and lookalike tok
"SSH-2.0-Go",
"notcloudlinker",
"CloudLinkerProxy",
"russhd",
"myrussh",
"prerussh",
"russhserver",
"SSH-2.0-russhd",
"notRussh",
]) {
assert.equal(remoteSoftwareRequiresSingleChannel(version), false, String(version));
}
@@ -59,6 +69,8 @@ test("chunked-write limit stays on BHostSSH and TERM-SSHD", () => {
assert.equal(remoteDisallowsChunkedChannelWrite("SSH-2.0-TERM-SSHD"), true);
assert.equal(remoteDisallowsChunkedChannelWrite("CLOUDBILITY-4.14"), false);
assert.equal(remoteDisallowsChunkedChannelWrite("SSH-2.0-CloudLinker"), false);
assert.equal(remoteDisallowsChunkedChannelWrite("SSH-2.0-russh"), false);
assert.equal(remoteDisallowsChunkedChannelWrite("SSH-2.0-russh_0.45.0"), false);
assert.equal(remoteDisallowsChunkedChannelWrite("OpenSSH_9.6"), false);
assert.equal(remoteDisallowsChunkedChannelWrite("superterm-sshd"), false);
});
@@ -82,7 +94,7 @@ test("CommonJS and renderer modules agree on SSH banner behavior", async () => {
const browserModule = await import("./singleChannelSshBanner.shared.mjs");
const nodeModule = require("./singleChannelSshBanner.shared.cjs");
for (const version of [
"BHostSSH_7.0", "TERM-SSHD", "CLOUDBILITY-4.14", "CloudLinker", "SSH-2.0-Go", "OpenSSH_9.6", "JumpServer", "",
"BHostSSH_7.0", "TERM-SSHD", "CLOUDBILITY-4.14", "CloudLinker", "SSH-2.0-russh_0.45.0", "SSH-2.0-Go", "OpenSSH_9.6", "JumpServer", "",
]) {
assert.equal(browserModule.remoteSoftwareRequiresSingleChannel(version), nodeModule.remoteSoftwareRequiresSingleChannel(version));
assert.equal(browserModule.remoteDisallowsChunkedChannelWrite(version), nodeModule.remoteDisallowsChunkedChannelWrite(version));
+10
View File
@@ -47,6 +47,16 @@ test("effectiveDefaultAlgorithms (modern) includes chacha20-poly1305", () => {
assert.ok(result.cipher.includes("chacha20-poly1305@openssh.com"));
});
test("effectiveDefaultAlgorithms offers OpenSSH host certificates before plain host keys", () => {
const cert = "ssh-ed25519-cert-v01@openssh.com";
const modern = effectiveDefaultAlgorithms(false);
assert.ok(modern.serverHostKey.includes(cert));
assert.ok(modern.serverHostKey.indexOf(cert) < modern.serverHostKey.indexOf("ssh-ed25519"));
const legacy = effectiveDefaultAlgorithms(true);
assert.ok(legacy.serverHostKey.includes(cert));
assert.ok(legacy.serverHostKey.includes("ssh-dss"));
});
test("effectiveDefaultAlgorithms (legacy) appends sha1 KEX, CBC, and ssh-dss", () => {
const modern = effectiveDefaultAlgorithms(false);
const legacy = effectiveDefaultAlgorithms(true);
+14
View File
@@ -77,6 +77,13 @@ export const SUPPORTED_HMAC_ALGORITHMS: readonly string[] = [
];
export const SUPPORTED_SERVER_HOST_KEY_ALGORITHMS: readonly string[] = [
"ssh-ed25519-cert-v01@openssh.com",
"ecdsa-sha2-nistp256-cert-v01@openssh.com",
"ecdsa-sha2-nistp384-cert-v01@openssh.com",
"ecdsa-sha2-nistp521-cert-v01@openssh.com",
"rsa-sha2-512-cert-v01@openssh.com",
"rsa-sha2-256-cert-v01@openssh.com",
"ssh-rsa-cert-v01@openssh.com",
"ssh-ed25519",
"ecdsa-sha2-nistp256",
"ecdsa-sha2-nistp384",
@@ -148,6 +155,13 @@ const MODERN_DEFAULT_ALGORITHMS: Readonly<Record<SSHAlgorithmCategory, readonly
"hmac-sha1",
],
serverHostKey: [
"ssh-ed25519-cert-v01@openssh.com",
"ecdsa-sha2-nistp256-cert-v01@openssh.com",
"ecdsa-sha2-nistp384-cert-v01@openssh.com",
"ecdsa-sha2-nistp521-cert-v01@openssh.com",
"rsa-sha2-512-cert-v01@openssh.com",
"rsa-sha2-256-cert-v01@openssh.com",
"ssh-rsa-cert-v01@openssh.com",
"ssh-ed25519",
"ecdsa-sha2-nistp256",
"ecdsa-sha2-nistp384",
+13
View File
@@ -2,6 +2,7 @@ import test from "node:test";
import assert from "node:assert/strict";
import type { Host } from "./models";
import { resolveHostAutofillPassword } from "./sshAuth";
import { sanitizeHost } from "./host";
import {
buildSshDeepLinkConnectionHost,
buildSshDeepLinkEphemeralHost,
@@ -242,8 +243,10 @@ test("buildSshDeepLinkHostDraft prepares an editable new ssh host", () => {
tags: [],
os: "linux",
protocol: "ssh",
sshInputCompatibility: false,
createdAt: 123,
});
assert.equal(sanitizeHost(draft).sshInputCompatibility, false);
});
test("buildSshDeepLinkOpenHost falls back to a draft host when no saved host matches", () => {
@@ -331,3 +334,13 @@ test("buildSshNoteLinkOpenHost ignores unrelated external links", () => {
assert.equal(openHost, null);
});
test("deep links derived from saved hosts preserve explicit input choices", () => {
const target = parseSshDeepLink("ssh://alice:secret@example.com")!;
for (const sshInputCompatibility of [false, true]) {
const saved = host({ sshInputCompatibility });
assert.equal(buildSshDeepLinkConnectionHost(saved).sshInputCompatibility, sshInputCompatibility);
assert.equal(buildSshDeepLinkEphemeralHostFromSaved(saved, target, { id: "copy", now: 1 }).sshInputCompatibility, sshInputCompatibility);
}
});
+1
View File
@@ -165,6 +165,7 @@ export const buildSshDeepLinkHostDraft = (
tags: [],
os: "linux",
protocol: "ssh",
sshInputCompatibility: false,
createdAt: options.now,
});
+13
View File
@@ -675,3 +675,16 @@ test("resolveTerminalChainHosts skips non-SSH jump candidates", () => {
assert.deepEqual(resolved.map((host) => host.id), ["jump-ssh", "jump-mosh"]);
});
test("input compatibility belongs to the terminal endpoint, independently of SSH jump hops", () => {
const target: Host = { id: "target", label: "Target", hostname: "target.example.test", username: "alice",
protocol: "ssh", tags: [], os: "linux", sshInputCompatibility: false, hostChain: { hostIds: ["bastion"] } };
const bastion: Host = { ...target, id: "bastion", hostChain: undefined, sshInputCompatibility: true };
const resolve = (hostId: string) => resolveTerminalSessionHost({
session: { ...baseSession, hostId }, hosts: [target, bastion], groupConfigs: [], proxyProfiles: [], localOs: "linux",
});
assert.equal(resolve("target").sshInputCompatibility, false);
assert.equal(resolve("bastion").sshInputCompatibility, true);
assert.equal(resolveTerminalChainHosts({ host: target, hosts: [target, bastion], groupConfigs: [], proxyProfiles: [] })[0].sshInputCompatibility, true);
});
+7
View File
@@ -1956,3 +1956,10 @@ test('applyVaultHostDelete keeps jump hosts referenced by empty group defaults',
assert.equal(result.ok, false);
if (!result.ok) assert.match(result.error, /still used as a group jump host/i);
});
test('new SSH hosts explicitly use standard interactive input', () => {
const created = buildVaultHostFromDraft({ hostname: 'localhost', username: 'user' });
assert.equal(created.ok, true);
if (created.ok) assert.equal(created.host.sshInputCompatibility, false);
});
+1
View File
@@ -329,6 +329,7 @@ export function buildVaultHostFromDraft(
os,
osOverride: parsedOs ?? 'auto',
protocol,
sshInputCompatibility: false,
createdAt: now,
...(keyPath
? {
+5
View File
@@ -665,6 +665,7 @@ const importFromCsv = (
const proxyIdx = findExactHeaderIndex(header, ["proxy", "proxyserver", "proxy server"]);
const proxyJumpIdx = findExactHeaderIndex(header, ["proxyjump"]);
const localShellIdx = findExactHeaderIndex(header, ["localshell"]);
const inputCompatibilityIdx = findExactHeaderIndex(header, ["sshinputcompatibility"]);
const passphraseIdx = keyPathIdx >= 0 ? explicitPassphraseIdx : -1;
const exactPasswordIdx = findExactHeaderIndex(header, ["password", "pass", "passwd"]);
const fuzzyNamedPasswordIdx = findHeaderIndex(header, ["password", "passwd"]);
@@ -795,6 +796,10 @@ const importFromCsv = (
tags,
notes,
});
if (inputCompatibilityIdx >= 0 && (protocol === undefined || protocol === "ssh")) {
const choice = row[inputCompatibilityIdx]?.trim().toLowerCase();
if (choice === "true" || choice === "false") host.sshInputCompatibility = choice === "true";
}
if (proxyConfig) host.proxyConfig = proxyConfig;
if (directProxy) host.directProxy = true;
if (localShellSpec && protocol === "local") {
+13
View File
@@ -842,3 +842,16 @@ test("CSV import warns and ignores a malformed LocalShell value", () => {
assert.equal(imported.hosts[0]?.localShell, undefined);
assert.ok(imported.issues.some((issue) => issue.message.includes("row 2") && issue.message.includes("LocalShell")));
});
test("CSV export/import preserves independent SSH input compatibility choices", () => {
const hosts: Host[] = [false, true].map((choice, index) => ({
...hostDefaults, id: `mode-${index}`, label: `Mode ${index}`,
hostname: `mode-${index}.example.test`, protocol: "ssh", sshInputCompatibility: choice,
}));
const { csv } = exportHostsToCsvWithStats(hosts);
const imported = importVaultHostsFromText("csv", csv);
assert.deepEqual(imported.hosts.map(host => host.sshInputCompatibility), [false, true]);
const second = importVaultHostsFromText("csv", exportHostsToCsvWithStats(imported.hosts).csv);
assert.deepEqual(second.hosts.map(host => host.sshInputCompatibility), [false, true]);
});
+3 -2
View File
@@ -37,7 +37,7 @@ export const getVaultCsvTemplate = (
opts: VaultCsvTemplateOptions = {},
): string => {
const includeExampleRows = opts.includeExampleRows !== false;
const header = ["Groups", "Label", "Tags", "Notes", "Hostname/IP", "Protocol", "Port", "Username", "Password", "KeyPath", "Passphrase", "Proxy", "ProxyJump", "LocalShell"];
const header = ["Groups", "Label", "Tags", "Notes", "Hostname/IP", "Protocol", "Port", "Username", "Password", "KeyPath", "Passphrase", "Proxy", "ProxyJump", "LocalShell", "SshInputCompatibility"];
const rows: string[][] = [header];
if (includeExampleRows) {
rows.push(["Project/Dev", "Web Server (dev)", "dev,web", "Dev web tier", "192.168.1.10", "ssh", "22", "root", "", "~/.ssh/id_ed25519", "", "socks5://127.0.0.1:1080"]);
@@ -115,7 +115,7 @@ const formatCsvProxyJump = (host: Host, allHosts: readonly Host[]): string => {
};
const exportHostsToCsv = (hosts: Host[], options: VaultCsvExportOptions) => {
const header = ["Groups", "Label", "Tags", "Notes", "Hostname/IP", "Protocol", "Port", "Username", "Password", "KeyPath", "Passphrase", "Proxy", "ProxyJump", "LocalShell"];
const header = ["Groups", "Label", "Tags", "Notes", "Hostname/IP", "Protocol", "Port", "Username", "Password", "KeyPath", "Passphrase", "Proxy", "ProxyJump", "LocalShell", "SshInputCompatibility"];
const rows: string[][] = [header];
let unreadableProxyCredentialCount = 0;
@@ -217,6 +217,7 @@ const exportHostsToCsv = (hosts: Host[], options: VaultCsvExportOptions) => {
encodeCsvProxy(proxyValue),
encodeCsvProxyJump(proxyJumpValue),
localShellSpec ? encodeCsvLocalShell(localShellSpec) : "",
host.protocol === undefined || host.protocol === "ssh" ? String(host.sshInputCompatibility ?? true) : "",
]);
}
+3
View File
@@ -195,6 +195,9 @@ module.exports = {
'node_modules/@cursor/sdk-*/**/*',
'node_modules/sqlite3/**/*',
'node_modules/@modelcontextprotocol/sdk/**/*',
// External MCP scripts run from asarUnpack via ELECTRON_RUN_AS_NODE and
// must resolve re2js from a real directory (issue #3910).
'node_modules/re2js/**/*',
'lib/**/*.cjs',
'lib/**/*.json',
'node_modules/zod/**/*',
@@ -2,6 +2,18 @@ const test = require('node:test');
const assert = require('node:assert/strict');
const { EventEmitter } = require('node:events');
const { startPtyJob } = require('./ptyExec.cjs');
const { getFreshIdlePrompt, getVisibleIdlePrompt, trackSessionIdlePrompt } = require('./shellUtils.cjs');
const { terminalCursorLine } = require('../terminalLogSanitizer.cjs');
function readyPrompt(session) {
return getFreshIdlePrompt(session) || getVisibleIdlePrompt(session);
}
// PSReadLine echoes the PowerShell clear-prefix keys, then erases them with
// cursor-column + ED without rewriting the prompt into the byte stream.
function powershellEraseRedraw(prompt) {
return `${prompt}ggd2147483647d\x1b[20G\x1b[0J`;
}
class TestPty extends EventEmitter {
writes = [];
@@ -158,3 +170,116 @@ test('wall deadline during authorization reports authorization failure rather th
assert.match(result.error, /authorization timed out/);
assert.deepEqual(pty.writes, []);
});
test('idle PowerShell whose raw tail was CSI-erased still receives the command', async (t) => {
t.mock.timers.enable({ apis: ['Date', 'setTimeout'] });
const pty = new TestPty();
const session = { _promptTrackTail: '', lastIdlePrompt: '' };
const prompt = 'PS C:\\Users\\alice> ';
trackSessionIdlePrompt(session, `Windows PowerShell\r\n${powershellEraseRedraw(prompt)}`);
assert.equal(getFreshIdlePrompt(session), '');
const job = startPtyJob(pty, 'Write-Output ok', {
shellKind: 'powershell',
getReadyPrompt: () => readyPrompt(session),
promptWaitTimeoutMs: 200,
timeoutMs: 1000,
typedInput: true,
});
t.mock.timers.tick(200);
const result = await job.resultPromise;
assert.equal(result.ok, true);
assert.equal(result.stdout, 'OK');
assert.equal(pty.writes.length, 1);
assert.match(pty.writes[0], /\$__NCMCP_/);
});
test('the next PowerShell exec runs after the clear-prefix echo is erased on screen', async () => {
const prompt = 'PS C:\\Users\\alice> ';
const session = { _promptTrackTail: '', lastIdlePrompt: '' };
trackSessionIdlePrompt(session, prompt);
const pty = new EventEmitter();
pty.writes = [];
pty.write = (data) => {
const text = String(data);
pty.writes.push(text);
const marker = text.match(/(__NCMCP_[0-9a-z]+_[0-9a-f]+__)/i)?.[1];
if (!marker) return;
queueMicrotask(() => {
const chunk = `${marker}_S\r\nok\r\n${marker}_E:0\r\n${powershellEraseRedraw(prompt)}`;
trackSessionIdlePrompt(session, chunk);
pty.emit('data', chunk);
});
};
const first = startPtyJob(pty, 'Write-Output ok', {
shellKind: 'powershell',
getReadyPrompt: () => readyPrompt(session),
timeoutMs: 1000,
typedInput: true,
});
const firstResult = await first.resultPromise;
assert.equal(firstResult.ok, true);
assert.equal(firstResult.stdout, 'ok');
assert.equal(getFreshIdlePrompt(session), '');
assert.equal(getVisibleIdlePrompt(session), prompt);
const second = startPtyJob(pty, 'Write-Output next', {
shellKind: 'powershell',
getReadyPrompt: () => readyPrompt(session),
promptWaitTimeoutMs: 200,
timeoutMs: 1000,
typedInput: true,
});
const secondResult = await second.resultPromise;
assert.equal(secondResult.ok, true);
assert.equal(secondResult.stdout, 'ok');
assert.equal(pty.writes.length, 2);
});
test('alt-screen paint is not ready after the entry sequence leaves the prompt tail', async (t) => {
t.mock.timers.enable({ apis: ['Date', 'setTimeout'] });
const pty = new TestPty();
const session = { _promptTrackTail: '', lastIdlePrompt: '' };
const prompt = 'PS C:\\Users\\alice> ';
// A full-screen app can emit more than the 4096-byte prompt tail. Once the
// alternate-screen entry scrolls off, a painted idle prompt must not unlock
// ptyExec and type into the still-running program.
trackSessionIdlePrompt(session, prompt);
trackSessionIdlePrompt(session, `\x1b[?1049h${'x'.repeat(4200)}`);
trackSessionIdlePrompt(session, `\r\n${prompt}\r`);
assert.equal(session._promptTrackTail.includes('\x1b[?1049h'), false);
assert.equal(terminalCursorLine(session._promptTrackTail).trimEnd(), prompt.trimEnd());
assert.equal(getFreshIdlePrompt(session), '');
assert.equal(getVisibleIdlePrompt(session), '');
assert.equal(readyPrompt(session), '');
const job = startPtyJob(pty, 'Write-Output ok', {
shellKind: 'powershell',
getReadyPrompt: () => readyPrompt(session),
promptWaitTimeoutMs: 50,
timeoutMs: 1000,
});
t.mock.timers.tick(50);
const result = await job.resultPromise;
assert.equal(result.code, 'SHELL_NOT_READY');
assert.match(result.error, /no command was sent/);
assert.deepEqual(pty.writes, []);
});
test('real pending PowerShell input is still not sent', async (t) => {
t.mock.timers.enable({ apis: ['Date', 'setTimeout'] });
const pty = new TestPty();
const session = { _promptTrackTail: '', lastIdlePrompt: '' };
trackSessionIdlePrompt(session, 'PS C:\\Users\\alice> Write-Output ok');
const job = startPtyJob(pty, 'echo OK', {
shellKind: 'powershell',
getReadyPrompt: () => readyPrompt(session),
promptWaitTimeoutMs: 50,
timeoutMs: 1000,
});
t.mock.timers.tick(50);
const result = await job.resultPromise;
assert.equal(result.code, 'SHELL_NOT_READY');
assert.match(result.error, /no command was sent/);
assert.deepEqual(pty.writes, []);
});
+13 -2
View File
@@ -68,7 +68,8 @@ const PROMPT_SUFFIX_WINDOW_PER_PROMPT_CHAR = 8;
function hasExpectedPromptSuffix(text, expectedPrompt) {
if (!expectedPrompt) return false;
const normalizedPrompt = stripAnsi(String(expectedPrompt || "")).replace(/\r/g, "");
const ansiFreePrompt = stripAnsi(String(expectedPrompt || ""));
const normalizedPrompt = ansiFreePrompt.replace(/\r/g, "");
if (!normalizedPrompt) return false;
const fullText = String(text || "");
const windowChars = Math.max(
@@ -76,7 +77,17 @@ function hasExpectedPromptSuffix(text, expectedPrompt) {
String(expectedPrompt).length * PROMPT_SUFFIX_WINDOW_PER_PROMPT_CHAR,
);
const tail = fullText.length > windowChars ? fullText.slice(-windowChars) : fullText;
return stripAnsi(tail).replace(/\r/g, "").endsWith(normalizedPrompt);
const strippedTail = stripAnsi(tail);
// Drop CR so a redraw inside one line (`root@host:~\r# `) still matches
// the cached prompt. That collapses a two-line PS1's `\r\r\n` to a single
// LF, which no longer ends with the cache (`user@host: /root\n\n\n$ `).
if (strippedTail.replace(/\r/g, "").endsWith(normalizedPrompt)) return true;
// The idle-prompt cache turns those CRs into LFs and keeps the blank
// lines. Compare that form too, only when the cache itself spans lines,
// so a bare `$`/`#` is unchanged.
if (!ansiFreePrompt.includes("\n")) return false;
const crAsLfPrompt = ansiFreePrompt.replace(/\r/g, "\n");
return strippedTail.replace(/\r/g, "\n").endsWith(crAsLfPrompt);
}
function escapePosixSingleQuoted(text) {
@@ -3,7 +3,7 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const { hasExpectedPromptSuffix } = require("./ptyExecHelpers.cjs");
const { stripAnsi } = require("./shellUtils.cjs");
const { extractTrailingIdlePrompt, stripAnsi } = require("./shellUtils.cjs");
// The behaviour before the tail window was introduced: normalise the whole
// text, then compare its end.
@@ -41,6 +41,38 @@ test("prompt suffix is found after a very large flood of CRLF output", () => {
assert.equal(hasExpectedPromptSuffix(`${flood}${COLORED_PROMPT}`, "root@3160T:~# "), true);
});
test("live PTY CR bytes match a cached two-line idle prompt", () => {
// Bash writes the PS1 newline as CR and ONLCR adds another, so the bytes
// are `\r\r\n`. The idle-prompt cache keeps those as blank lines. Suffix
// matching has to accept that form; stripping CR down to one LF does not.
const live = "user@host: /workspace\r\r\n$ ";
const cached = extractTrailingIdlePrompt(`welcome\r\n${live}`);
assert.equal(cached, "user@host: /workspace\n\n\n$ ");
const matchLive = hasExpectedPromptSuffix(live, cached);
assert.equal(matchLive, true);
assert.equal(hasExpectedPromptSuffix(`hostname\r\nbox\r\n${live}`, cached), true);
assert.equal(
hasExpectedPromptSuffix("\x1b[?2004huser@host: /workspace\r\r\n$ ", cached),
true,
);
const rootLive = "user@host: /root\r\r\n# ";
const rootCached = extractTrailingIdlePrompt(rootLive);
assert.equal(rootCached, "user@host: /root\n\n\n# ");
assert.equal(hasExpectedPromptSuffix(rootLive, rootCached), true);
// Readiness for a real LF two-line prompt still matches, and a bare
// `$`/`#` is neither this prompt nor a POSIX prompt on its own.
const lfCached = extractTrailingIdlePrompt("welcome\r\nuser@host: /workspace\n$ ");
assert.equal(lfCached, "user@host: /workspace\n$ ");
assert.equal(hasExpectedPromptSuffix("user@host: /workspace\n$ ", lfCached), true);
assert.equal(hasExpectedPromptSuffix("output\r\n$ ", cached), false);
assert.equal(hasExpectedPromptSuffix("$ ", cached), false);
assert.equal(hasExpectedPromptSuffix("# ", rootCached), false);
assert.equal(extractTrailingIdlePrompt("$ "), "");
assert.equal(extractTrailingIdlePrompt("# "), "");
});
test("prompt suffix check does not scan the whole buffer on every call", () => {
// 2M characters of CRLF output used to cost seconds per call because every
// call stripped ANSI and carriage returns over the entire text.
+93 -9
View File
@@ -61,10 +61,23 @@ const POWERSHELL_PROMPT_PATTERN = /^PS(?:\s+\S.*)?>$/;
// PowerShell's `PS C:\...>` (handled by POWERSHELL_PROMPT_PATTERN first).
const CMD_PROMPT_PATTERN = /^[A-Za-z]:(?:\\[^<>"|]*)?>$/;
// Classic `user@host:...$` / `user@host:...#` login prompt (bash/zsh in WSL,
// remote Linux, etc.). Intentionally narrow so custom / fish prompts do not
// flip a Windows DefaultShell soft hint.
const POSIX_PROMPT_PATTERN = /^[^\s@]+@[^\s:]+(?::[^\n\r]*)?[#$]$/;
// Default login prompts, kept narrow so custom / fish / starship prompts do
// not flip a Windows DefaultShell soft hint:
// - bash/zsh `user@host[:path][#$]` (colon before the path; `root@host ~#` stays rejected)
// - RHEL-family `[\u@\h \W]\$` -> `[user@host path][#$]`, or `[user@host][#$]`
// with no path. `\W` may contain whitespace or `]` (`[user@host My [Project]]$`);
// the final `]` before `$`/`#` is the delimiter.
const POSIX_PROMPT_PATTERN = /^(?:[^\s@]+@[^\s:]+(?::[^\n\r]*)?|\[[^\s@\]]+@[^\s\]:]+(?:\s+[^\n\r]+)?\])[#$]$/;
// Host line of a two-line bash PS1 (`\u@\h: \w` then a following `$` or `#`).
// Same user@host[:path] class as POSIX_PROMPT_PATTERN's first alternative,
// without a prompt marker on this line. A bare `$`/`#` alone is not a prompt.
const POSIX_PROMPT_HOST_LINE_PATTERN = /^[^\s@]+@[^\s:]+(?::[^\n\r]*)?$/;
// Alpine's default ash PS1 omits the username: `\h:\w\$ ` -> `localhost:~# `.
// Require a hostname and a home/absolute path, excluding prompt delimiters
// within the path so partially entered commands do not count as idle prompts.
const ALPINE_PROMPT_PATTERN = /^[A-Za-z0-9_][A-Za-z0-9_.-]*:(?:~(?:\/[^#$\n\r]*)?|\/[^#$\n\r]*)[#$]$/;
function isDefaultPowerShellPromptLine(line) {
return POWERSHELL_PROMPT_PATTERN.test(String(line || ""));
@@ -75,7 +88,15 @@ function isDefaultCmdPromptLine(line) {
}
function isDefaultPosixPromptLine(line) {
return POSIX_PROMPT_PATTERN.test(String(line || "").replace(/\s+$/, ""));
const normalized = String(line || "").replace(/\s+$/, "");
// Optional virtualenv / conda / BT-panel style env name prefix: "(name) "
// before the shell prompt. Recognition only; callers still return the full
// prompt line so suffix matching sees the visible prefix (#3947).
const withoutEnvPrefix = normalized.replace(/^\([^()\n\r]+\)\s+/, "");
return (
POSIX_PROMPT_PATTERN.test(withoutEnvPrefix) ||
ALPINE_PROMPT_PATTERN.test(withoutEnvPrefix)
);
}
function extractTrailingIdlePrompt(output) {
@@ -86,7 +107,8 @@ function extractTrailingIdlePrompt(output) {
const normalized = stripAnsi(output).replace(/\r/g, "\n");
if (!normalized || normalized.endsWith("\n")) return "";
const lastLine = normalized.split("\n").pop() || "";
const lines = normalized.split("\n");
const lastLine = lines.pop() || "";
const rightTrimmed = lastLine.replace(/\s+$/, "");
if (!rightTrimmed) return "";
@@ -102,6 +124,24 @@ function extractTrailingIdlePrompt(output) {
return lastLine;
}
// Two-line PS1 (`export PS1="\u@\h: \w\n$ "`). Bash emits the newline as
// CR and the PTY's ONLCR adds another, so the bytes are `\r\r\n`. The
// CR-to-LF normalize above turns that into blank lines between the host
// line and `$`/`#`. Keep those blanks in the cache: getFreshIdlePrompt
// uses the same normalize, and hasExpectedPromptSuffix compares this
// CR-as-LF form against live PTY bytes. A lone `$` must not satisfy endsWith.
if (rightTrimmed === "$" || rightTrimmed === "#") {
let hostIndex = lines.length - 1;
while (hostIndex >= 0 && lines[hostIndex].replace(/\s+$/, "") === "") {
hostIndex -= 1;
}
const previousLine = hostIndex >= 0 ? lines[hostIndex] : "";
const previousRightTrimmed = previousLine.replace(/\s+$/, "");
if (POSIX_PROMPT_HOST_LINE_PATTERN.test(previousRightTrimmed)) {
return `${lines.slice(hostIndex).join("\n")}\n${lastLine}`;
}
}
return "";
}
@@ -134,7 +174,20 @@ function looksLikeIdleAutoLogout(outputTail) {
function trackSessionIdlePrompt(session, chunk) {
if (!session || typeof chunk !== "string" || !chunk) return "";
const nextTail = `${session._promptTrackTail || ""}${chunk}`.slice(-MAX_PROMPT_TRACK_TAIL);
const previousTail = session._promptTrackTail || "";
const combined = `${previousTail}${chunk}`;
const overflow = combined.length - MAX_PROMPT_TRACK_TAIL;
if (overflow > 0) {
// The kept tail no longer starts where the previous tail started. Replay
// the dropped prefix so an alternate-screen enter or leave that scrolls
// off is still the mode at the first kept byte.
const { alternateScreenActiveAfter } = require("../terminalLogSanitizer.cjs");
session._promptTrackAltScreenAtTailStart = alternateScreenActiveAfter(
combined.slice(0, overflow),
session._promptTrackAltScreenAtTailStart === true,
);
}
const nextTail = overflow > 0 ? combined.slice(overflow) : combined;
session._promptTrackTail = nextTail;
const prompt = extractTrailingIdlePrompt(nextTail);
@@ -149,8 +202,10 @@ function trackSessionIdlePrompt(session, chunk) {
// Return `session.lastIdlePrompt` only if the PTY's recent rolling tail
// still ends with it. The cached prompt is updated only when
// extractTrailingIdlePrompt recognizes a known shape (PowerShell, cmd.exe,
// or `user@host[:path][#$]`); a remote shell switch into another shell, an
// oh-my-posh / starship / custom PS1, or any unrecognized prompt would
// `user@host[:path][#$]`, two-line `user@host[:path]` + `$`/`#`,
// RHEL `[user@host path][#$]`, or Alpine `host:path[#$]`);
// a remote shell switch into another shell, an oh-my-posh / starship / custom
// PS1, or any unrecognized prompt would
// otherwise leave a stale value behind, which `resolveEffectiveShellKind`
// would then keep using to coerce future commands into a PowerShell
// wrapper. By re-checking the live tail we self-correct: if the visible
@@ -172,6 +227,34 @@ function getFreshIdlePrompt(session) {
return normalizedTail.endsWith(normalizedCached) ? cached : "";
}
// Readiness companion to getFreshIdlePrompt. PSReadLine echoes keys (including
// the pending-input clear prefix) and then erases them with cursor-column +
// ED/EL without appending a fresh copy of the prompt. stripAnsi removes the
// erase, so the raw tail no longer ends with the cached prompt while the
// screen is still an idle default prompt (#3927). Collapse those cursor and
// erase operations, then accept only the line the cursor is actually on.
function getVisibleIdlePrompt(session) {
const tail = session?._promptTrackTail;
if (typeof tail !== "string" || !tail) return "";
const { terminalCursorLine } = require("../terminalLogSanitizer.cjs");
const cursorLine = terminalCursorLine(tail, {
alternateScreenActive: session._promptTrackAltScreenAtTailStart === true,
});
const rightTrimmed = cursorLine.replace(/\s+$/, "");
if (!rightTrimmed) return "";
if (
!isDefaultPowerShellPromptLine(rightTrimmed)
&& !isDefaultPosixPromptLine(rightTrimmed)
&& !isDefaultCmdPromptLine(rightTrimmed)
) {
return "";
}
// Keep one trailing space when the visible prompt has one. Erase-to-end
// padding must not become part of the expected prompt.
return /\s$/.test(cursorLine) ? `${rightTrimmed} ` : rightTrimmed;
}
// ── URL helpers ──
function isLocalhostHostname(hostname) {
@@ -1112,6 +1195,7 @@ module.exports = {
formatSyntheticEcho,
extractTrailingIdlePrompt,
getFreshIdlePrompt,
getVisibleIdlePrompt,
isDefaultPowerShellPromptLine,
isDefaultCmdPromptLine,
isDefaultPosixPromptLine,
+258
View File
@@ -8,6 +8,7 @@ const {
extractTrailingIdlePrompt,
formatSyntheticEcho,
getFreshIdlePrompt,
getVisibleIdlePrompt,
isDefaultPowerShellPromptLine,
isDefaultCmdPromptLine,
isDefaultPosixPromptLine,
@@ -133,14 +134,182 @@ test("isDefaultCmdPromptLine matches drive-letter cmd prompts only", () => {
test("isDefaultPosixPromptLine matches classic user@host prompts", () => {
assert.equal(isDefaultPosixPromptLine("alice@host:~$"), true);
assert.equal(isDefaultPosixPromptLine("user@host:~$"), true);
assert.equal(isDefaultPosixPromptLine("alice@wsl:/mnt/c$"), true);
assert.equal(isDefaultPosixPromptLine("root@box:/#"), true);
assert.equal(isDefaultPosixPromptLine("[opc@instance-20260905-1821 ~]$"), true);
assert.equal(isDefaultPosixPromptLine("[opc@instance-20260905-1821 ~]$ "), true);
assert.equal(isDefaultPosixPromptLine("[root@host ~]#"), true);
assert.equal(isDefaultPosixPromptLine("[user@host]$"), true);
assert.equal(isDefaultPosixPromptLine("[user@host My Project]$"), true);
assert.equal(isDefaultPosixPromptLine("[user@host My Project]$ "), true);
assert.equal(isDefaultPosixPromptLine("[user@host My [Project]]$"), true);
assert.equal(isDefaultPosixPromptLine("[user@host My [Project]]$ "), true);
assert.equal(isDefaultPosixPromptLine("root@host ~#"), false);
assert.equal(isDefaultPosixPromptLine("PS C:\\Users\\alice>"), false);
assert.equal(isDefaultPosixPromptLine("C:\\Users\\alice>"), false);
assert.equal(isDefaultPosixPromptLine(""), false);
});
test("isDefaultPosixPromptLine matches Alpine host:path prompts without a username", () => {
for (const prompt of [
"localhost:~#",
"localhost:~# ",
"alpine:~$ ",
"alpine-host:~/projects$",
"alpine-host:/# ",
"alpine.example:/srv/My Project$ ",
]) {
assert.equal(isDefaultPosixPromptLine(prompt), true, prompt);
}
});
test("Alpine prompt recognition rejects output and partially entered commands", () => {
for (const line of [
"#",
"$",
"status: ready#",
"progress:100%$",
":~#",
"alpine:#",
"alpine:relative$",
"alpine:~user$",
"localhost:~# printf busy",
"localhost:/tmp# printf busy#",
"alpine:/tmp$ printf busy$",
]) {
assert.equal(isDefaultPosixPromptLine(line), false, line);
assert.equal(extractTrailingIdlePrompt(`welcome\r\n${line}`), "", line);
}
});
test("extracts an Alpine idle prompt while preserving its trailing whitespace", () => {
assert.equal(
extractTrailingIdlePrompt("Welcome to Alpine!\r\n\x1b[32mlocalhost:~# \x1b[0m"),
"localhost:~# ",
);
assert.equal(extractTrailingIdlePrompt("alpine:/srv$ "), "alpine:/srv$ ");
assert.equal(extractTrailingIdlePrompt("localhost:~# \r\n"), "");
});
test("extracts a trailing RHEL-family bracketed idle prompt", () => {
// Oracle Linux / RHEL / CentOS / Fedora default PS1 is `[\u@\h \W]\$ `.
// Trailing space must round-trip, same as the PowerShell prompt capture.
assert.equal(
extractTrailingIdlePrompt("[opc@instance-20260905-1821 ~]$ "),
"[opc@instance-20260905-1821 ~]$ ",
);
assert.equal(
extractTrailingIdlePrompt("Last login: Fri Oct 24\r\n[root@host ~]#"),
"[root@host ~]#",
);
assert.equal(
extractTrailingIdlePrompt("welcome\r\nuser@host:~$ "),
"user@host:~$ ",
);
assert.equal(
extractTrailingIdlePrompt("welcome\r\n[user@host My Project]$ "),
"[user@host My Project]$ ",
);
assert.equal(
extractTrailingIdlePrompt("welcome\r\n[user@host My [Project]]$ "),
"[user@host My [Project]]$ ",
);
// Unbracketed space form is still not a recognized default prompt.
assert.equal(extractTrailingIdlePrompt("welcome\r\nroot@host ~#"), "");
});
test("extractTrailingIdlePrompt recognizes a two-line user@host then bare $/# PS1", () => {
// export PS1="\u@\h: \w\n$ " paints the host line, then a lone "$ ".
// The cached prompt must keep both lines so a lone "$" cannot satisfy endsWith.
assert.equal(
extractTrailingIdlePrompt("welcome\r\nuser@host: /home/foo\n$ "),
"user@host: /home/foo\n$ ",
);
assert.equal(
extractTrailingIdlePrompt("welcome\r\nroot@host: /root\n# "),
"root@host: /root\n# ",
);
// Live bash PTY: prompt CR plus ONLCR is `\r\r\n`, which this normalizer
// stores as blank lines. The cache must keep them so endsWith still matches.
assert.equal(
extractTrailingIdlePrompt("\x1b[?2004huser@host: /home/foo\r\r\n$ "),
"user@host: /home/foo\n\n\n$ ",
);
assert.equal(
extractTrailingIdlePrompt("\x1b[?2004hroot@host: /root\r\r\n# "),
"root@host: /root\n\n\n# ",
);
assert.equal(extractTrailingIdlePrompt("$ "), "");
assert.equal(extractTrailingIdlePrompt("# "), "");
assert.equal(extractTrailingIdlePrompt("welcome\r\n$ "), "");
assert.equal(extractTrailingIdlePrompt("not a host line\n$ "), "");
assert.equal(extractTrailingIdlePrompt("copied output\n# "), "");
assert.equal(isDefaultPosixPromptLine("$"), false);
assert.equal(isDefaultPosixPromptLine("#"), false);
assert.equal(isDefaultPosixPromptLine("$ "), false);
assert.equal(isDefaultPosixPromptLine("# "), false);
});
test("trackSessionIdlePrompt and getFreshIdlePrompt accept a two-line user@host PS1", () => {
const session = {};
const prompt = "user@host: /home/foo\n$ ";
trackSessionIdlePrompt(session, `welcome\r\n${prompt}`);
assert.equal(getFreshIdlePrompt(session), prompt);
const ptySession = {};
trackSessionIdlePrompt(ptySession, "\x1b[?2004huser@host: /home/foo\r\r\n$ ");
assert.equal(getFreshIdlePrompt(ptySession), "user@host: /home/foo\n\n\n$ ");
});
test("extractTrailingIdlePrompt still recognizes a classic single-line POSIX prompt", () => {
assert.equal(extractTrailingIdlePrompt("welcome\r\nuser@host:~$ "), "user@host:~$ ");
});
test("isDefaultPosixPromptLine accepts virtualenv, conda, and BT-panel env prefixes", () => {
// Python venv / conda / BT-panel prepend "(name) " before the real prompt.
// Recognition strips that prefix; a missing space after ")" is already
// accepted because the username class absorbs the parens (#3947).
for (const prompt of [
"(3.12.12) root@example:~# ",
"(3.12.12) root@example:~#",
"(venv) alice@host:~$ ",
"(base) [opc@host ~]$ ",
"(my-env) localhost:~# ",
"(3.12.12)root@example:~# ",
]) {
assert.equal(isDefaultPosixPromptLine(prompt), true, prompt);
}
});
test("extractTrailingIdlePrompt keeps the full env-prefixed POSIX prompt", () => {
// Suffix matching compares the visible PTY line, so the captured prompt
// must still include the "(name) " prefix rather than the stripped form.
for (const prompt of [
"(3.12.12) root@example:~# ",
"(3.12.12) root@example:~#",
"(venv) alice@host:~$ ",
"(base) [opc@host ~]$ ",
"(my-env) localhost:~# ",
]) {
assert.equal(extractTrailingIdlePrompt(`Last login\r\n${prompt}`), prompt, prompt);
}
});
test("env-prefixed POSIX recognition rejects incomplete prefixes and non-prompts", () => {
for (const line of [
"(3.12.12 root@example:~# ",
"(3.12.12) root@example:~# printf busy",
"(venv) not a prompt",
"root@host ~#",
"(base) [opc@host ~]$ printf busy",
"(my-env) localhost:~# printf busy",
]) {
assert.equal(isDefaultPosixPromptLine(line), false, line);
assert.equal(extractTrailingIdlePrompt(`welcome\r\n${line}`), "", line);
}
});
test("isPlausibleCliVersionOutput rejects stack traces and file URLs", () => {
assert.equal(isPlausibleCliVersionOutput("2.1.123 (Claude Code)"), true);
assert.equal(isPlausibleCliVersionOutput("codex-cli 0.125.0"), true);
@@ -674,6 +843,95 @@ test("getFreshIdlePrompt returns empty string when the session has no cached pro
);
});
test("tracks split Alpine prompts and expires them while the shell is busy", () => {
const session = {};
assert.equal(trackSessionIdlePrompt(session, "Welcome to Alpine!\r\nlocalhost:"), "");
assert.equal(trackSessionIdlePrompt(session, "~# "), "localhost:~# ");
assert.equal(getFreshIdlePrompt(session), "localhost:~# ");
assert.equal(trackSessionIdlePrompt(session, "printf busy"), "");
assert.equal(getFreshIdlePrompt(session), "");
trackSessionIdlePrompt(session, "\r\nbusy\r\nlocalhost:/srv# ");
assert.equal(getFreshIdlePrompt(session), "localhost:/srv# ");
trackSessionIdlePrompt(session, "printf working\r\nworking...");
assert.equal(getFreshIdlePrompt(session), "");
});
test("trackSessionIdlePrompt and getFreshIdlePrompt accept an Oracle/RHEL bracketed bash prompt", () => {
const session = {};
trackSessionIdlePrompt(session, "Last login...\r\n[opc@instance-20260905-1821 ~]$ ");
assert.equal(session.lastIdlePrompt, "[opc@instance-20260905-1821 ~]$ ");
assert.equal(getFreshIdlePrompt(session), "[opc@instance-20260905-1821 ~]$ ");
const rootSession = {};
trackSessionIdlePrompt(rootSession, "[root@host ~]#");
assert.equal(getFreshIdlePrompt(rootSession), "[root@host ~]#");
const classic = {};
trackSessionIdlePrompt(classic, "user@host:~$ ");
assert.equal(getFreshIdlePrompt(classic), "user@host:~$ ");
const unbracketed = {};
assert.equal(trackSessionIdlePrompt(unbracketed, "root@host ~#"), "");
assert.equal(getFreshIdlePrompt(unbracketed), "");
const spacedPath = {};
trackSessionIdlePrompt(spacedPath, "Last login...\r\n[user@host My Project]$ ");
assert.equal(spacedPath.lastIdlePrompt, "[user@host My Project]$ ");
assert.equal(getFreshIdlePrompt(spacedPath), "[user@host My Project]$ ");
const bracketedPath = {};
trackSessionIdlePrompt(bracketedPath, "Last login...\r\n[user@host My [Project]]$ ");
assert.equal(bracketedPath.lastIdlePrompt, "[user@host My [Project]]$ ");
assert.equal(getFreshIdlePrompt(bracketedPath), "[user@host My [Project]]$ ");
});
test("visible idle prompt survives a PSReadLine erase that leaves the raw tail dirty", () => {
// PSReadLine echoes the pending-input clear prefix (`ggd2147483647d` / `i`),
// then homes the cursor and erases those keys with CSI. stripAnsi drops the
// erase but keeps the keys, so the cached prompt no longer matches the tail.
// The screen is still an idle default PowerShell prompt (#3927).
const session = {};
const prompt = "PS C:\\Users\\alice> ";
trackSessionIdlePrompt(session, `Windows PowerShell\r\n${prompt}ggd2147483647d\x1b[20G\x1b[0J`);
assert.equal(getFreshIdlePrompt(session), "");
assert.equal(extractTrailingIdlePrompt(session._promptTrackTail), "");
assert.equal(getVisibleIdlePrompt(session), prompt);
const backspaced = {};
trackSessionIdlePrompt(backspaced, `${prompt}\x08`);
assert.equal(getFreshIdlePrompt(backspaced), "");
assert.equal(getVisibleIdlePrompt(backspaced), prompt);
const crHomed = {};
trackSessionIdlePrompt(crHomed, `${prompt}\r`);
assert.equal(getFreshIdlePrompt(crHomed), "");
assert.equal(getVisibleIdlePrompt(crHomed), prompt);
});
test("visible idle prompt stays empty for pending input, a following line, and an unrecognized prompt", () => {
const pending = {};
trackSessionIdlePrompt(pending, "PS C:\\Users\\alice> Write-Output ok");
assert.equal(getFreshIdlePrompt(pending), "");
assert.equal(getVisibleIdlePrompt(pending), "");
const movedOn = {};
trackSessionIdlePrompt(movedOn, "PS C:\\Users\\alice> \r\n");
assert.equal(getVisibleIdlePrompt(movedOn), "");
const custom = {};
trackSessionIdlePrompt(custom, "PS C:\\Users\\alice>\r\nexit\r\n❯ ");
assert.equal(getVisibleIdlePrompt(custom), "");
const fullscreen = {};
trackSessionIdlePrompt(fullscreen, "PS C:\\Users\\alice> \x1b[?1049h\x1b[Hvim");
assert.equal(getVisibleIdlePrompt(fullscreen), "");
assert.equal(getVisibleIdlePrompt({ _promptTrackTail: "" }), "");
assert.equal(getVisibleIdlePrompt(null), "");
});
test("getFreshIdlePrompt and trackSessionIdlePrompt round-trip through a real PTY-like flow", () => {
// (1) Remote PowerShell prompt arrives — lastIdlePrompt is captured.
const session = {};
@@ -3,7 +3,7 @@
// runs under `with (ctx)` where bare `require` resolves to ctx.require
// (based in electron/bridges/). Requiring here keeps the path unambiguous.
const { emitTerminalSessionData } = require("../emitTerminalSessionData.cjs");
const { formatSyntheticEcho } = require("../ai/shellUtils.cjs");
const { formatSyntheticEcho, getVisibleIdlePrompt } = require("../ai/shellUtils.cjs");
const { clearSessionFlowState } = require("../terminalFlowAck.cjs");
const { remoteDisallowsExecChannelProbe, sessionDisallowsExtraSshChannel, ensureSessionShellKindForExec } = require("../ai/sessionShellKind.cjs");
const defaultAiPrivacyService = require("../../shared/aiPrivacyService.cjs");
@@ -217,7 +217,7 @@ function registerCattyExecHandlers(ctx) {
chatSessionId,
expectedPrompt: getFreshIdlePrompt(session),
getReadyPrompt: typeof session._promptTrackTail === "string"
? () => getFreshIdlePrompt(session) : undefined,
? () => getFreshIdlePrompt(session) || getVisibleIdlePrompt(session) : undefined,
typedInput: true,
echoCommand: (rawCommand) => {
const contents = electronModule?.webContents?.fromId?.(session.webContentsId);
@@ -329,6 +329,43 @@ test("expired local and unknown jobs do not claim a lost worker route", (t) => {
});
test("MCP exec sends on an idle PowerShell prompt hidden by a CSI erase", async () => {
const pty = new FakePty();
pty.write = (data) => {
pty.writes.push(String(data));
const marker = String(data).match(/(__NCMCP_[0-9a-z]+_[0-9a-f]+__)/i)?.[1];
if (!marker) return;
queueMicrotask(() => pty.emit("data", `${marker}_S\r\nok\r\n${marker}_E:0\r\n`));
};
const { trackSessionIdlePrompt } = require("./ai/shellUtils.cjs");
const session = {
protocol: "ssh",
pty,
shellKind: "powershell",
_promptTrackTail: "",
lastIdlePrompt: "",
};
trackSessionIdlePrompt(session, "PS C:\\Users\\alice> ggd2147483647d\x1b[20G\x1b[0J");
const ctx = createExecHandlerTestContext({
sessions: new Map([["ps", session]]),
backgroundJobs: new Map(),
});
ctx.commandTimeoutMs = 200;
ctx.startPtyJob = require("./ai/ptyExec.cjs").startPtyJob;
ctx.execViaPty = require("./ai/ptyExec.cjs").execViaPty;
ctx.getFreshIdlePrompt = require("./ai/shellUtils.cjs").getFreshIdlePrompt;
const api = createExecHandlerApi(ctx);
const result = await api.handleExec({
sessionId: "ps",
chatSessionId: "ps-chat",
command: "Write-Output ok",
});
assert.equal(result.ok, true);
assert.equal(result.stdout, "ok");
assert.equal(pty.writes.length, 1);
assert.match(pty.writes[0], /\$__NCMCP_/);
});
test("MCP background readiness timeout survives job polling without PTY writes", async (t) => {
t.mock.timers.enable({ apis: ["Date", "setTimeout"] });
const pty = new FakePty();
@@ -2,6 +2,7 @@
// Module-level require: code inside createExecHandlerApi runs under `with (ctx)`
// where bare `require` resolves to ctx.require (based in electron/bridges/).
const { clearSessionFlowState } = require("../terminalFlowAck.cjs");
const { getVisibleIdlePrompt } = require("../ai/shellUtils.cjs");
const { emitJobDiag } = require("../ai/jobStartupDiagnostics.cjs");
const {
ensureSessionShellKind,
@@ -159,7 +160,7 @@ function createExecHandlerApi(ctx) {
onEchoSuppressionPrime: (marker) => echoCommandToSession(session, sessionId, `${marker}_I`, { syntheticEcho: false }),
expectedPrompt: getFreshIdlePrompt(session),
getReadyPrompt: typeof session._promptTrackTail === "string"
? () => getFreshIdlePrompt(session) : undefined,
? () => getFreshIdlePrompt(session) || getVisibleIdlePrompt(session) : undefined,
typedInput: true,
echoCommand: (rawCommand) => echoCommandToSession(session, sessionId, rawCommand),
chatSessionId,
@@ -341,7 +342,7 @@ function createExecHandlerApi(ctx) {
chatSessionId,
expectedPrompt: getFreshIdlePrompt(session),
getReadyPrompt: typeof session._promptTrackTail === "string"
? () => getFreshIdlePrompt(session) : undefined,
? () => getFreshIdlePrompt(session) || getVisibleIdlePrompt(session) : undefined,
typedInput: true,
echoCommand: (rawCommand) => echoCommandToSession(session, sessionId, rawCommand),
maxBufferedChars: MAX_BACKGROUND_JOB_OUTPUT_CHARS,
@@ -836,6 +836,105 @@ test("sudo SFTP-page transfer reuses the elevated channel instead of dialing aga
assert.equal(sftpClients.has("browse-sudo"), true);
});
for (const [ident, sequential] of [
["OpenSSH_9.6", false],
["CLOUDBILITY-4.14", true],
["TERM-SSHD", true],
]) {
test(`sudo transfer aliases preserve ${ident} upload concurrency`, { timeout: 15000 }, async (t) => {
const server = await startOtpOnlyServer({ ident });
pool.resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 });
const sftpClients = new Map();
sftpBridge.init({ sftpClients, sessions: new Map(), electronModule: {} });
transferBridge.init({ sftpClients });
t.after(async () => {
for (const sftpId of [...sftpClients.keys()].reverse()) {
await sftpBridge.closeSftp(null, { sftpId }).catch(() => {});
}
pool.discardAllTransports("test-end");
pool.resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 });
await server.close();
});
const options = { ...BASE_OPTIONS(server.port), reuseTransport: false };
const sender = createSender();
const opening = sftpBridge.openSftp({ sender }, { ...options, sessionId: "sudo-owner" });
opening.catch(() => {});
await waitFor(() => answerOtpPrompts(sender));
const opened = await opening;
const owner = sftpClients.get(opened.sftpId);
// Model an already-elevated channel. The real SSH/SFTP peer remains the
// ordinary server or bastion above; no second sudo/authentication is needed.
const sudoOptions = { ...BASE_OPTIONS(server.port), sudo: true };
const endpoint = pool.buildConnectionReuseEndpoint(sudoOptions, { sftpSudo: true });
owner.__netcattySudoMode = true;
owner.__netcattyEndpointKey = pool.buildEndpointKey(endpoint);
owner.__netcattyChannelAdoptionKey = pool.buildChannelAdoptionKey(endpoint);
const firstAlias = await sftpBridge.openSftp({ sender: createSender() }, sudoOptions);
const secondAlias = await sftpBridge.openSftp({ sender: createSender() }, sudoOptions);
for (const alias of [firstAlias, secondAlias]) {
const client = sftpClients.get(alias.sftpId);
assert.equal(client.__netcattySharedChannelAlias, true);
assert.equal(client.__netcattySudoMode, true);
assert.equal(client.sftp, owner.sftp, "both uploads must retain the elevated channel");
}
const root = fs.mkdtempSync(`${tempDirBridge.getTempFilePath("sudo-concurrency")}-`);
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const source = path.join(root, "source.bin");
const payload = Buffer.alloc(3 * 32768 + 17, 0x5a);
fs.writeFileSync(source, payload);
const firstPath = "/home/otpuser/first.bin";
const secondPath = "/home/otpuser/second.bin";
server.files.set(firstPath, Buffer.from("original file"));
const channel = owner.sftp;
const originalLstat = channel.lstat.bind(channel);
let firstTargetChecks = 0;
let promotionStarted;
const promotionStartedPromise = new Promise((resolve) => { promotionStarted = resolve; });
let releasePromotion;
const promotionGate = new Promise((resolve) => { releasePromotion = resolve; });
channel.lstat = (remotePath, ...args) => {
if (String(remotePath) === firstPath && ++firstTargetChecks === 2) {
promotionStarted();
void promotionGate.then(() => originalLstat(remotePath, ...args));
return;
}
originalLstat(remotePath, ...args);
};
const upload = (alias, targetPath) => transferBridge.startTransfer({ sender: createSender() }, {
transferId: `sudo-concurrency-${ident}-${path.basename(targetPath)}`,
sourcePath: source, targetPath,
sourceType: "local", targetType: "sftp", targetSftpId: alias.sftpId,
totalBytes: payload.length, resumable: true,
});
let firstFinished = false;
let secondFinished = false;
const first = upload(firstAlias, firstPath).then((result) => { firstFinished = true; return result; });
let second;
let results;
try {
await promotionStartedPromise;
second = upload(secondAlias, secondPath).then((result) => { secondFinished = true; return result; });
await waitFor(() => secondFinished
|| transferBridge._getSessionFastDownloadWaiterCountForTests(channel) > 0);
assert.equal(firstFinished, false, "the first upload must still be held before publication");
assert.equal(secondFinished, !sequential,
sequential ? "a bastion must wait for the first promotion" : "ordinary sudo uploads must remain concurrent");
assert.equal(transferBridge._getSessionFastDownloadWaiterCountForTests(channel), sequential ? 1 : 0);
} finally {
releasePromotion();
results = await Promise.all(second ? [first, second] : [first]);
channel.lstat = originalLstat;
}
for (const result of results) assert.equal(result.error, undefined, result.error);
assert.deepEqual(server.files.get(firstPath), payload);
assert.deepEqual(server.files.get(secondPath), payload);
assert.equal(server.getConnectionCount(), 1, "sudo uploads must not open an unelevated connection");
assert.equal(server.getKeyboardInteractiveRounds(), 1, "uploads must not ask for another OTP");
});
}
test("reconnecting the soft-closed owner id does not replace it with a self-alias (#3310)", async (t) => {
const { sftpTransferSessionLeaseStore, channelAliasLeaseId } = require("./sftpTransferSessionLease.cjs");
pool.resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 });
@@ -0,0 +1,289 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { execFileSync } = require("node:child_process");
const {
DEFAULT_SERVER_HOST_KEY,
SUPPORTED_SERVER_HOST_KEY,
} = require("ssh2/lib/protocol/constants.js");
const {
parseDERKey,
parseKey,
} = require("ssh2/lib/protocol/keyParser.js");
const {
hostKeyBlobTypeAccepted,
hostKeySignatureTypeAccepted,
} = require("ssh2/lib/protocol/kex.js");
// Manual live check (optional; needs network and real Instabox credentials):
// ssh -vv -p 2222 USER@ssh.instabox.sh
// Instabox advertises only ssh-ed25519-cert-v01@openssh.com and signs the
// exchange hash as ssh-ed25519. After this patch, host-key negotiation should
// succeed; authentication still depends on the real certificate credentials.
const HOST_CERT = "ssh-ed25519-cert-v01@openssh.com";
const HOST_CERT_ALGORITHMS = [
HOST_CERT,
"ecdsa-sha2-nistp256-cert-v01@openssh.com",
"ecdsa-sha2-nistp384-cert-v01@openssh.com",
"ecdsa-sha2-nistp521-cert-v01@openssh.com",
"rsa-sha2-512-cert-v01@openssh.com",
"rsa-sha2-256-cert-v01@openssh.com",
"ssh-rsa-cert-v01@openssh.com",
];
function sshKeygenSkipReason(error) {
if (error && error.code === "ENOENT")
return "ssh-keygen is not installed";
return false;
}
function detectSshKeygenSkip() {
try {
execFileSync("ssh-keygen", ["-h"], { stdio: "ignore" });
return false;
} catch (error) {
return sshKeygenSkipReason(error);
}
}
const sshKeygenSkip = detectSshKeygenSkip();
function mintEd25519HostCert(dir) {
const ca = path.join(dir, "ca");
const host = path.join(dir, "host");
execFileSync("ssh-keygen", ["-q", "-t", "ed25519", "-N", "", "-f", ca]);
execFileSync("ssh-keygen", ["-q", "-t", "ed25519", "-N", "", "-f", host]);
execFileSync("ssh-keygen", [
"-q",
"-s",
ca,
"-I",
"netcatty-hostcert",
"-h",
"-n",
"127.0.0.1",
"-V",
"+52w",
`${host}.pub`,
]);
const certLine = fs.readFileSync(`${host}-cert.pub`, "utf8").trim();
const [type, b64] = certLine.split(/\s+/);
const privateKey = parseKey(fs.readFileSync(host));
return {
type,
certBlob: Buffer.from(b64, "base64"),
privateKeyPem: fs.readFileSync(host),
privateKey,
};
}
test("ssh2 default and supported host key lists offer OpenSSH host certificates", () => {
for (const algo of HOST_CERT_ALGORITHMS) {
assert.equal(DEFAULT_SERVER_HOST_KEY.includes(algo), true, `${algo} missing from defaults`);
assert.equal(SUPPORTED_SERVER_HOST_KEY.includes(algo), true, `${algo} missing from supported`);
}
assert.ok(
DEFAULT_SERVER_HOST_KEY.indexOf(HOST_CERT) < DEFAULT_SERVER_HOST_KEY.indexOf("ssh-ed25519"),
"host certificates should be offered before the matching plain key",
);
assert.equal(DEFAULT_SERVER_HOST_KEY.includes("ssh-ed25519"), true);
assert.equal(DEFAULT_SERVER_HOST_KEY.includes("ssh-rsa"), true);
assert.equal(
SUPPORTED_SERVER_HOST_KEY.includes("sk-ssh-ed25519-cert-v01@openssh.com"),
true,
);
assert.equal(
SUPPORTED_SERVER_HOST_KEY.includes("sk-ecdsa-sha2-nistp256-cert-v01@openssh.com"),
true,
);
});
test("missing ssh-keygen skips host-certificate minting instead of failing", () => {
const missing = Object.assign(new Error("spawn ssh-keygen ENOENT"), { code: "ENOENT" });
assert.equal(sshKeygenSkipReason(missing), "ssh-keygen is not installed");
const present = Object.assign(new Error("usage"), { status: 1 });
assert.equal(sshKeygenSkipReason(present), false);
assert.equal(sshKeygenSkipReason(null), false);
});
test("parseDERKey reads an OpenSSH ed25519 host certificate the way KEX does", { skip: sshKeygenSkip }, () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-hostcert-"));
try {
const minted = mintEd25519HostCert(dir);
assert.equal(minted.type, HOST_CERT);
assert.equal(minted.privateKey instanceof Error, false, minted.privateKey.message);
const typeLen = minted.certBlob.readUInt32BE(0);
const type = minted.certBlob.subarray(4, 4 + typeLen).toString();
const keyMaterial = minted.certBlob.subarray(4 + typeLen);
const parsed = parseDERKey(keyMaterial, type);
assert.equal(parsed instanceof Error, false, parsed instanceof Error ? parsed.message : "");
assert.equal(parsed.type, HOST_CERT);
const message = Buffer.from("kex-exchange-hash");
const signature = minted.privateKey.sign(message);
assert.equal(signature instanceof Error, false, signature instanceof Error ? signature.message : "");
assert.equal(parsed.verify(message, signature), true);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test("host key signature type accepts the underlying algorithm for host certificates", () => {
assert.equal(typeof hostKeySignatureTypeAccepted, "function");
assert.equal(hostKeySignatureTypeAccepted(HOST_CERT, "ssh-ed25519"), true);
assert.equal(hostKeySignatureTypeAccepted(HOST_CERT, HOST_CERT), true);
assert.equal(hostKeySignatureTypeAccepted(HOST_CERT, "ssh-rsa"), false);
assert.equal(
hostKeySignatureTypeAccepted("ecdsa-sha2-nistp256-cert-v01@openssh.com", "ecdsa-sha2-nistp256"),
true,
);
assert.equal(
hostKeySignatureTypeAccepted("rsa-sha2-256-cert-v01@openssh.com", "rsa-sha2-256"),
true,
);
assert.equal(
hostKeySignatureTypeAccepted("rsa-sha2-256-cert-v01@openssh.com", "rsa-sha2-512"),
false,
);
assert.equal(
hostKeySignatureTypeAccepted("rsa-sha2-256-cert-v01@openssh.com", "ssh-rsa"),
false,
);
assert.equal(
hostKeySignatureTypeAccepted("rsa-sha2-256-cert-v00@openssh.com", "ssh-rsa"),
false,
);
assert.equal(
hostKeySignatureTypeAccepted("rsa-sha2-512-cert-v01@openssh.com", "rsa-sha2-512"),
true,
);
assert.equal(
hostKeySignatureTypeAccepted("rsa-sha2-512-cert-v01@openssh.com", "rsa-sha2-256"),
false,
);
assert.equal(
hostKeySignatureTypeAccepted("rsa-sha2-512-cert-v01@openssh.com", "ssh-rsa"),
false,
);
assert.equal(
hostKeySignatureTypeAccepted("ssh-rsa-cert-v01@openssh.com", "rsa-sha2-256"),
true,
);
assert.equal(
hostKeySignatureTypeAccepted("ssh-rsa-cert-v01@openssh.com", "rsa-sha2-512"),
true,
);
assert.equal(
hostKeySignatureTypeAccepted("ssh-rsa-cert-v01@openssh.com", "ssh-rsa"),
true,
);
assert.equal(
hostKeySignatureTypeAccepted("ssh-rsa-cert-v00@openssh.com", "ssh-rsa"),
true,
);
assert.equal(
hostKeyBlobTypeAccepted("rsa-sha2-256-cert-v01@openssh.com", "ssh-rsa-cert-v01@openssh.com"),
true,
);
assert.equal(
hostKeyBlobTypeAccepted("rsa-sha2-512-cert-v01@openssh.com", "ssh-rsa-cert-v01@openssh.com"),
true,
);
assert.equal(
hostKeyBlobTypeAccepted("rsa-sha2-256-cert-v01@openssh.com", "ssh-rsa"),
false,
);
assert.equal(
hostKeyBlobTypeAccepted("rsa-sha2-512-cert-v01@openssh.com", "ssh-rsa"),
false,
);
assert.equal(hostKeyBlobTypeAccepted("rsa-sha2-256", "ssh-rsa"), true);
assert.equal(hostKeyBlobTypeAccepted("rsa-sha2-512", "ssh-rsa"), true);
assert.equal(hostKeySignatureTypeAccepted("ssh-ed25519", "ssh-ed25519"), true);
assert.equal(hostKeySignatureTypeAccepted("ssh-ed25519", HOST_CERT), false);
assert.equal(hostKeySignatureTypeAccepted("rsa-sha2-256", "ssh-rsa"), false);
assert.equal(hostKeySignatureTypeAccepted("rsa-sha2-512", "rsa-sha2-256"), false);
});
function loadSsh2WithCertHostKey(pem, certBlob) {
// ssh2.Server only accepts PEM text and then advertises privateKey.type.
// Reload it after wrapping parseKey so this one key is presented as an
// OpenSSH host certificate while signing still uses the embedded key.
const keyParser = require("ssh2/lib/protocol/keyParser.js");
const originalParseKey = keyParser.parseKey;
keyParser.parseKey = function parseCertHostKey(data, passphrase) {
const parsed = originalParseKey(data, passphrase);
if (!(parsed instanceof Error) && data === pem) {
parsed.type = HOST_CERT;
parsed.getPublicSSH = () => certBlob;
}
return parsed;
};
const reloadIds = ["ssh2/lib/server.js", "ssh2/lib/index.js", "ssh2"];
const resolved = reloadIds.map((id) => require.resolve(id));
const previous = resolved.map((filename) => require.cache[filename]);
for (const filename of resolved) delete require.cache[filename];
const ssh2 = require("ssh2");
for (let i = 0; i < resolved.length; i++) {
if (previous[i]) require.cache[resolved[i]] = previous[i];
else delete require.cache[resolved[i]];
}
keyParser.parseKey = originalParseKey;
return ssh2;
}
test("ssh2 client completes KEX against a cert-only ed25519 host key", { skip: sshKeygenSkip }, async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-hostcert-kex-"));
const minted = mintEd25519HostCert(dir);
assert.equal(minted.privateKey instanceof Error, false, minted.privateKey.message);
const { Client, Server } = loadSsh2WithCertHostKey(minted.privateKeyPem, minted.certBlob);
const server = new Server({ hostKeys: [minted.privateKeyPem] }, (client) => {
client.on("error", () => {});
client.on("authentication", (ctx) => ctx.reject());
});
await new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", resolve);
});
try {
const authError = await new Promise((resolve, reject) => {
const client = new Client();
const timer = setTimeout(() => {
client.end();
reject(new Error("timed out waiting for KEX/auth"));
}, 10000);
client.on("ready", () => {
clearTimeout(timer);
client.end();
reject(new Error("authentication unexpectedly succeeded"));
});
client.on("error", (err) => {
clearTimeout(timer);
client.end();
resolve(err);
});
client.connect({
host: "127.0.0.1",
port: server.address().port,
username: "netcatty",
password: "not-the-password",
readyTimeout: 8000,
});
});
assert.match(authError.message, /authentication/i);
assert.doesNotMatch(
authError.message,
/Handshake failed|no matching host key|Wrong signature type|Unsupported OpenSSH|signature verification failed|Host key does not match/,
);
} finally {
await new Promise((resolve) => server.close(resolve));
fs.rmSync(dir, { recursive: true, force: true });
}
});
+88
View File
@@ -2,10 +2,12 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const { createHash, generateKeyPairSync, sign } = require("node:crypto");
const {
parseKey,
getOpenSSHCertSignatureAlgorithm,
} = require("ssh2/lib/protocol/keyParser.js");
const { convertSignature } = require("ssh2/lib/protocol/utils.js");
function buildSkEd25519PublicLine() {
const type = Buffer.from("sk-ssh-ed25519@openssh.com");
@@ -146,3 +148,89 @@ test("ssh2 agent protocol preserves SK signature framing, flags and counter", as
}
}
});
function sshString(value) {
const body = Buffer.isBuffer(value) ? value : Buffer.from(value);
const out = Buffer.alloc(4 + body.length);
out.writeUInt32BE(body.length, 0);
body.copy(out, 4);
return out;
}
function opensshPublicLine(typeName, fields) {
const blob = Buffer.concat([
sshString(typeName),
...fields.map((field) => sshString(field)),
]);
return `${typeName} ${blob.toString("base64")} netcatty@sk`;
}
function skEcdsaSignature(rawSig, flags, counter) {
const out = Buffer.alloc(4 + rawSig.length + 5);
out.writeUInt32BE(rawSig.length, 0);
rawSig.copy(out, 4);
const pos = 4 + rawSig.length;
out[pos] = flags;
out.writeUInt32BE(counter, pos + 1);
return out;
}
test("ssh2 verifies an SK ECDSA certificate with the same P-256 signature as the plain key", () => {
const { publicKey, privateKey } = generateKeyPairSync("ec", {
namedCurve: "prime256v1",
});
const jwk = publicKey.export({ format: "jwk" });
const point = Buffer.concat([
Buffer.from([0x04]),
Buffer.from(jwk.x, "base64url"),
Buffer.from(jwk.y, "base64url"),
]);
const application = "ssh:";
const plain = parseKey(opensshPublicLine("sk-ecdsa-sha2-nistp256@openssh.com", [
"nistp256",
point,
application,
]));
const cert = parseKey(opensshPublicLine("sk-ecdsa-sha2-nistp256-cert-v01@openssh.com", [
Buffer.alloc(16, 3),
"nistp256",
point,
application,
]));
assert.equal(plain instanceof Error, false, plain instanceof Error ? plain.message : "");
assert.equal(cert instanceof Error, false, cert instanceof Error ? cert.message : "");
assert.equal(cert.type, "sk-ecdsa-sha2-nistp256-cert-v01@openssh.com");
assert.equal(cert.getApplication(), application);
const message = Buffer.from("kex-exchange-hash");
const flags = 0x01;
const counter = 0x12345678;
const appHash = createHash("sha256").update(application).digest();
const dataHash = createHash("sha256").update(message).digest();
const signedData = Buffer.alloc(appHash.length + 1 + 4 + dataHash.length);
let offset = 0;
appHash.copy(signedData, offset);
offset += appHash.length;
signedData[offset++] = flags;
signedData.writeUInt32BE(counter, offset);
offset += 4;
dataHash.copy(signedData, offset);
const der = sign("sha256", signedData, privateKey);
const rawSig = convertSignature(der, "ecdsa-sha2-nistp256");
assert.ok(Buffer.isBuffer(rawSig));
const signature = skEcdsaSignature(rawSig, flags, counter);
const plainResult = plain.verify(message, signature);
assert.equal(
plainResult,
true,
plainResult instanceof Error ? plainResult.message : String(plainResult),
);
const certResult = cert.verify(message, signature);
assert.equal(
certResult,
true,
certResult instanceof Error ? certResult.message : String(certResult),
);
});
+16 -3
View File
@@ -114,6 +114,13 @@ function applyLegacyAlgorithms(algorithms) {
]),
);
algorithms.serverHostKey = [
"ssh-ed25519-cert-v01@openssh.com",
"ecdsa-sha2-nistp256-cert-v01@openssh.com",
"ecdsa-sha2-nistp384-cert-v01@openssh.com",
"ecdsa-sha2-nistp521-cert-v01@openssh.com",
"rsa-sha2-512-cert-v01@openssh.com",
"rsa-sha2-256-cert-v01@openssh.com",
"ssh-rsa-cert-v01@openssh.com",
"ssh-ed25519", "ecdsa-sha2-nistp256", "ecdsa-sha2-nistp384", "ecdsa-sha2-nistp521",
"rsa-sha2-512", "rsa-sha2-256",
"ssh-rsa", "ssh-dss",
@@ -150,10 +157,16 @@ function applyLegacyHmacAlgorithms(algorithms) {
}
// Mirror of ssh2's DEFAULT_SERVER_HOST_KEY (see ssh2/lib/protocol/constants.js)
// minus every ecdsa-sha2-*. Used when skipEcdsaHostKey is on but legacy mode
// is off, so the field would otherwise be undefined and ssh2 would fall back
// to its built-in defaults (which still include ecdsa-sha2-*).
// minus every ecdsa-sha2-* (plain keys and host certificates). Used when
// skipEcdsaHostKey is on but legacy mode is off, so the field would otherwise
// be undefined and ssh2 would fall back to its built-in defaults (which still
// include ecdsa-sha2-*). Non-ECDSA host certificates stay so a cert-only
// bastion can still be reached.
const MODERN_SERVER_HOST_KEY_WITHOUT_ECDSA = Object.freeze([
"ssh-ed25519-cert-v01@openssh.com",
"rsa-sha2-512-cert-v01@openssh.com",
"rsa-sha2-256-cert-v01@openssh.com",
"ssh-rsa-cert-v01@openssh.com",
"ssh-ed25519",
"rsa-sha2-512",
"rsa-sha2-256",
+20
View File
@@ -269,6 +269,19 @@ for (const [label, buildAlgorithms] of [
["SSH", sshBridge.buildAlgorithms],
["SFTP", sftpBridge.buildSftpAlgorithms],
]) {
test(`${label} legacy serverHostKey offers OpenSSH host certificates before plain keys`, () => {
withAlgorithmRuntime({}, () => {
const keys = buildAlgorithms(true).serverHostKey;
assert.ok(keys.includes("ssh-ed25519-cert-v01@openssh.com"));
assert.ok(keys.indexOf("ssh-ed25519-cert-v01@openssh.com") < keys.indexOf("ssh-ed25519"));
assert.ok(keys.includes("ecdsa-sha2-nistp256-cert-v01@openssh.com"));
assert.ok(keys.includes("rsa-sha2-512-cert-v01@openssh.com"));
assert.ok(keys.includes("rsa-sha2-256-cert-v01@openssh.com"));
assert.ok(keys.includes("ssh-rsa-cert-v01@openssh.com"));
assert.ok(keys.includes("ssh-dss"));
});
});
test(`${label} skipEcdsaHostKey removes every ecdsa-sha2-* from serverHostKey (legacy on)`, () => {
withAlgorithmRuntime({}, () => {
const algorithms = buildAlgorithms(true, { skipEcdsaHostKey: true });
@@ -280,6 +293,9 @@ for (const [label, buildAlgorithms] of [
assert.ok(algorithms.serverHostKey.includes("ssh-rsa"));
assert.ok(algorithms.serverHostKey.includes("ssh-dss"));
assert.ok(algorithms.serverHostKey.includes("ssh-ed25519"));
assert.ok(algorithms.serverHostKey.includes("ssh-ed25519-cert-v01@openssh.com"));
assert.ok(algorithms.serverHostKey.includes("rsa-sha2-512-cert-v01@openssh.com"));
assert.ok(algorithms.serverHostKey.includes("ssh-rsa-cert-v01@openssh.com"));
});
});
@@ -293,6 +309,10 @@ for (const [label, buildAlgorithms] of [
for (const algo of algorithms.serverHostKey) {
assert.ok(!algo.startsWith("ecdsa-sha2-"), `${algo} should be filtered out`);
}
assert.ok(algorithms.serverHostKey.includes("ssh-ed25519-cert-v01@openssh.com"));
assert.ok(algorithms.serverHostKey.includes("rsa-sha2-512-cert-v01@openssh.com"));
assert.ok(algorithms.serverHostKey.includes("rsa-sha2-256-cert-v01@openssh.com"));
assert.ok(algorithms.serverHostKey.includes("ssh-rsa-cert-v01@openssh.com"));
});
});
+26 -1
View File
@@ -73,6 +73,11 @@ function normalizePassword(password) {
return typeof password === "string" ? password : "";
}
// Bare CR/LF used to wake a silent serial console before "login:" appears.
function isAutoLoginWakeInput(data) {
return typeof data === "string" && /^[\r\n]+$/.test(data);
}
function createTelnetAutoLogin(options = {}) {
const username = normalizeUsername(options.username);
const password = normalizePassword(options.password);
@@ -207,7 +212,26 @@ function createTelnetAutoLogin(options = {}) {
notifyIncomplete();
}
},
handleUserInput() {
// Silent serial consoles need a bare CR/LF to wake getty before "login:"
// appears. While the detector is still active that wake is not a
// takeover: keep autofill armed, leave the caller's write on the port,
// and do not consume the one-shot continue-prompt response. A later
// "Press RETURN to continue" must still be answered. Once the detector
// is disabled (expiry, completion, or a prior cancel), the same Enter
// notifies like any other user input so callers can drop a deferred
// startup command. Any other user input still cancels.
handleUserInput(data) {
const wakeOnly = isAutoLoginWakeInput(data);
if (
wakeOnly
&& !disabled
&& !sentUsername
&& !sentPassword
&& !isUsernamePrompt(tail)
&& !isPasswordPrompt(tail)
) {
return;
}
disabled = true;
clearExpiryTimer();
notifyUserInput();
@@ -224,6 +248,7 @@ function createTelnetAutoLogin(options = {}) {
module.exports = {
createTelnetAutoLogin,
isAutoLoginWakeInput,
isCommandPrompt,
isContinuePrompt,
isPasswordPrompt,
+149
View File
@@ -224,6 +224,155 @@ test("telnet auto-login stops when the user starts typing manually", () => {
assert.equal(cancelCount, 1);
});
test("telnet auto-login stays armed when a wake-only enter arrives before any login prompt", () => {
const writes = [];
let cancelCount = 0;
const autoLogin = createTelnetAutoLogin({
username: "admin",
password: "secret",
write: (data) => writes.push(data),
onUserInput: () => { cancelCount += 1; },
});
// Silent serial consoles need a bare CR to wake getty before "login:" appears.
autoLogin.handleUserInput("\r");
autoLogin.handleText("login: ");
autoLogin.handleText("\r\nPassword: ");
assert.equal(cancelCount, 0);
assert.deepEqual(writes, ["admin\r", "secret\r"]);
});
test("telnet auto-login treats a bare newline as a wake and still autofills", () => {
const writes = [];
let cancelCount = 0;
const autoLogin = createTelnetAutoLogin({
username: "admin",
password: "secret",
write: (data) => writes.push(data),
onUserInput: () => { cancelCount += 1; },
});
autoLogin.handleUserInput("\n");
autoLogin.handleUserInput("\r\n");
autoLogin.handleText("login: ");
autoLogin.handleText("\r\nPassword: ");
assert.equal(cancelCount, 0);
assert.deepEqual(writes, ["admin\r", "secret\r"]);
});
test("telnet auto-login notifies when a wake enter arrives after expiry", () => {
let cancelCount = 0;
let timerFn = null;
const writes = [];
const autoLogin = createTelnetAutoLogin({
username: "admin",
password: "secret",
write: (data) => writes.push(data),
timeoutMs: 60_000,
onUserInput: () => { cancelCount += 1; },
setTimeout: (fn) => {
timerFn = fn;
return { unref() {} };
},
clearTimeout: () => {},
});
// Quiet device: the 60s window elapses with no prompt, which disables the
// detector without a user-input notification. Serial startup still has its
// 65s fallback armed until this cancellation arrives.
timerFn();
assert.equal(cancelCount, 0);
autoLogin.handleUserInput("\r");
assert.equal(cancelCount, 1);
autoLogin.handleText("Press RETURN to continue");
autoLogin.handleText("login: ");
autoLogin.handleText("\r\nPassword: ");
assert.equal(cancelCount, 1);
assert.deepEqual(writes, []);
});
test("telnet auto-login still answers a continue prompt after a blind wake enter", () => {
const writes = [];
let cancelCount = 0;
const autoLogin = createTelnetAutoLogin({
username: "admin",
password: "secret",
write: (data) => writes.push(data),
onUserInput: () => { cancelCount += 1; },
});
// The user's Enter woke a silent console. It must not consume the
// detector's one-shot response when the device then asks to continue.
autoLogin.handleUserInput("\r");
autoLogin.handleText("Press RETURN to continue");
autoLogin.handleText("\r\nlogin: ");
autoLogin.handleText("\r\nPassword: ");
assert.equal(cancelCount, 0);
assert.deepEqual(writes, ["\r", "admin\r", "secret\r"]);
});
test("telnet auto-login still cancels when a normal character arrives before login", () => {
const writes = [];
let cancelCount = 0;
const autoLogin = createTelnetAutoLogin({
username: "admin",
password: "secret",
write: (data) => writes.push(data),
onUserInput: () => { cancelCount += 1; },
});
autoLogin.handleUserInput("x");
autoLogin.handleText("login: ");
autoLogin.handleText("\r\nPassword: ");
assert.equal(cancelCount, 1);
assert.deepEqual(writes, []);
});
test("telnet auto-login still cancels an enter after credentials have started", () => {
const writes = [];
let cancelCount = 0;
const autoLogin = createTelnetAutoLogin({
username: "admin",
password: "secret",
write: (data) => writes.push(data),
onUserInput: () => { cancelCount += 1; },
});
autoLogin.handleText("login: ");
autoLogin.handleUserInput("\r");
autoLogin.handleText("\r\nPassword: ");
assert.equal(cancelCount, 1);
assert.deepEqual(writes, ["admin\r"]);
});
test("telnet auto-login still cancels an enter when a login prompt is already in the tail", () => {
let clock = 0;
const writes = [];
let cancelCount = 0;
const autoLogin = createTelnetAutoLogin({
username: "admin",
password: "secret",
write: (data) => writes.push(data),
now: () => clock,
timeoutMs: 60_000,
onUserInput: () => { cancelCount += 1; },
});
// After the window, the prompt is recorded but credentials are not sent.
clock = 60_001;
autoLogin.handleText("login: ");
autoLogin.handleUserInput("\r");
assert.equal(cancelCount, 1);
assert.deepEqual(writes, []);
});
test("telnet auto-login avoids common non-prompt login text", () => {
const writes = [];
const autoLogin = createTelnetAutoLogin({
+27 -7
View File
@@ -63,7 +63,7 @@ const { discoverShells } = require("./shellDiscovery.cjs");
const { isWindowsAppExecutionAliasPath } = require("../../lib/localShell.cjs");
const moshHandshake = require("./moshHandshake.cjs");
const tempDirBridge = require("./tempDirBridge.cjs");
const { createTelnetAutoLogin } = require("./telnetAutoLogin.cjs");
const { createTelnetAutoLogin, isAutoLoginWakeInput } = require("./telnetAutoLogin.cjs");
const telnetProtocol = require("./telnetProtocol.cjs");
const { createPtyOutputBuffer } = require("./ptyOutputBuffer.cjs");
const { enableTcpNoDelay } = require("./tcpNoDelay.cjs");
@@ -1713,8 +1713,9 @@ function writeToSessionNow(payload, data, logRewrite = payload.logRewrite) {
(session.type === 'telnet-native' || session.type === 'serial')
&& !payload.automated
&& !isTerminalReportSequence(data)
&& !payload.autoLoginInputHandled
) {
session.autoLogin?.handleUserInput();
session.autoLogin?.handleUserInput(data);
}
// Encode keystrokes with the SAME charset the output path decodes with so
@@ -1805,6 +1806,8 @@ function writeToSessionWithInterception(
paste = null,
index = 0,
) {
// Stays the original payload until ingress classification marks this write.
let sessionWritePayload = payload;
const writeWithReceipt = (nextData) => {
if (paste && !paste.active) return;
const current = sessions.get(payload.sessionId);
@@ -1817,7 +1820,7 @@ function writeToSessionWithInterception(
paste.finish(index, true);
return;
}
const written = writeToSessionNow(payload, nextData, logRewrite);
const written = writeToSessionNow(sessionWritePayload, nextData, logRewrite);
if (paste) paste.finish(written ? index : undefined);
};
const bypass = payload?.sensitive === true || isTerminalReportSequence(data);
@@ -1830,20 +1833,24 @@ function writeToSessionWithInterception(
writeWithReceipt(data);
return;
}
// Cancel the auto-login detector at input ingress, before the asynchronous
// Classify auto-login at input ingress, before the asynchronous
// interception pipeline: a slow interceptor would otherwise leave the
// detector armed while the user's keystrokes are queued, letting a login
// prompt that arrives during that wait trigger a saved-credential
// transmission after the user has already taken over. Same guard
// conditions as writeToSessionNow; handleUserInput is idempotent, so the
// later call there stays a no-op.
// conditions as writeToSessionNow. Wake-only CR/LF is not idempotent once
// credentials start, so mark this write and do not classify those bytes
// again when they reach writeToSessionNow. That preserved classification
// describes the queued bytes only while they stay wake-only; writeIfCurrent
// classifies an interceptor rewrite that is no longer wake-only.
if (
expectedSession
&& (expectedSession.type === 'telnet-native' || expectedSession.type === 'serial')
&& !payload.automated
&& !isTerminalReportSequence(data)
) {
expectedSession.autoLogin?.handleUserInput();
expectedSession.autoLogin?.handleUserInput(data);
sessionWritePayload = { ...payload, autoLoginInputHandled: true };
}
const writeIfCurrent = (nextData) => {
const current = sessions.get(payload.sessionId);
@@ -1851,6 +1858,19 @@ function writeToSessionWithInterception(
paste?.finish();
return;
}
// An Enter-to-command macro can turn the wake CR/LF classified above
// into ordinary text. Classify that transformed value before it is
// written. autoLoginInputHandled stays set so writeToSessionNow does not
// classify the same write again. A rewrite that is still only CR/LF keeps
// the ingress classification: those bytes are not idempotent once
// credentials have started.
if (
sessionWritePayload.autoLoginInputHandled
&& !isAutoLoginWakeInput(nextData)
&& !isTerminalReportSequence(nextData)
) {
current.autoLogin?.handleUserInput(nextData);
}
writeWithReceipt(nextData);
};
const write = async () => {
@@ -1,6 +1,32 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const terminalBridge = require("./terminalBridge.cjs");
const { createTelnetAutoLogin } = require("./telnetAutoLogin.cjs");
function createSerialAutoLoginSession() {
const writes = [];
const events = [];
const session = {
type: "serial",
protocol: "serial",
encoding: "utf-8",
serialPort: {
write: (data) => {
writes.push(typeof data === "string" ? data : Buffer.from(data).toString());
return true;
},
},
};
session.autoLogin = createTelnetAutoLogin({
username: "admin",
password: "secret",
write: (data) => {
session.serialPort.write(data);
},
onUserInput: () => { events.push("cancelled"); },
});
return { session, writes, events };
}
test("serial auto-login cancels on interactive user input but not automated writes", () => {
let userInputs = 0;
@@ -61,6 +87,221 @@ test("serial line-mode buffered input notification cancels auto-login", () => {
}
});
test("serial wake-only enter keeps auto-login armed and still reaches the port", () => {
const { session, writes, events } = createSerialAutoLoginSession();
terminalBridge.init({ sessions: new Map([["s", session]]), electronModule: {} });
try {
terminalBridge.writeToSession({}, { sessionId: "s", data: "\r" });
assert.deepEqual(events, []);
assert.deepEqual(writes, ["\r"]);
session.autoLogin.handleText("login: ");
session.autoLogin.handleText("\r\nPassword: ");
assert.deepEqual(events, []);
assert.deepEqual(writes, ["\r", "admin\r", "secret\r"]);
} finally {
terminalBridge.cleanupAllSessions();
}
});
test("serial character input before login still cancels auto-login", () => {
const { session, writes, events } = createSerialAutoLoginSession();
terminalBridge.init({ sessions: new Map([["s", session]]), electronModule: {} });
try {
terminalBridge.writeToSession({}, { sessionId: "s", data: "x" });
session.autoLogin.handleText("login: ");
session.autoLogin.handleText("\r\nPassword: ");
assert.deepEqual(events, ["cancelled"]);
assert.deepEqual(writes, ["x"]);
} finally {
terminalBridge.cleanupAllSessions();
}
});
test("serial wake-only enter stays armed when an input interceptor is pending", async () => {
const { session, writes, events } = createSerialAutoLoginSession();
let interceptorResolved = false;
terminalBridge.init({
sessions: new Map([["s", session]]),
electronModule: {},
terminalDataPipeline: {
has: () => true,
interceptInput: async (sessionId, data) => {
await new Promise((resolve) => setTimeout(resolve, 20));
interceptorResolved = true;
return data;
},
},
});
try {
terminalBridge.writeToSession({}, { sessionId: "s", data: "\n" });
assert.deepEqual(events, []);
assert.equal(interceptorResolved, false);
await new Promise((resolve) => setTimeout(resolve, 40));
assert.equal(interceptorResolved, true);
assert.deepEqual(writes, ["\n"]);
session.autoLogin.handleText("login: ");
session.autoLogin.handleText("\r\nPassword: ");
assert.deepEqual(events, []);
assert.deepEqual(writes, ["\n", "admin\r", "secret\r"]);
} finally {
terminalBridge.cleanupAllSessions();
}
});
test("serial wake enter is not cancelled when login arrives during interceptor delay", async () => {
const { session, writes, events } = createSerialAutoLoginSession();
let interceptorStarted = false;
let releaseInterceptor = () => {};
const interceptorGate = new Promise((resolve) => {
releaseInterceptor = resolve;
});
terminalBridge.init({
sessions: new Map([["s", session]]),
electronModule: {},
terminalDataPipeline: {
has: () => true,
interceptInput: async (_sessionId, data) => {
interceptorStarted = true;
await new Promise((resolve) => setTimeout(resolve, 30));
await interceptorGate;
return data;
},
},
});
try {
terminalBridge.writeToSession({}, { sessionId: "s", data: "\r" });
await Promise.resolve();
assert.equal(interceptorStarted, true);
// Prompt arrives while the wake byte is still queued in the interceptor.
session.autoLogin.handleText("login: ");
assert.deepEqual(events, []);
assert.deepEqual(writes, ["admin\r"]);
releaseInterceptor();
const deadline = Date.now() + 500;
while (writes.length < 2 && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 10));
}
assert.deepEqual(events, []);
assert.deepEqual(writes, ["admin\r", "\r"]);
session.autoLogin.handleText("\r\nPassword: ");
assert.deepEqual(events, []);
assert.deepEqual(writes, ["admin\r", "\r", "secret\r"]);
} finally {
releaseInterceptor();
terminalBridge.cleanupAllSessions();
}
});
test("serial interceptor that rewrites a wake enter into text cancels auto-login", async () => {
const { session, writes, events } = createSerialAutoLoginSession();
let interceptorStarted = false;
terminalBridge.init({
sessions: new Map([["s", session]]),
electronModule: {},
terminalDataPipeline: {
has: () => true,
interceptInput: async (_sessionId, data) => {
interceptorStarted = true;
assert.equal(data, "\r");
await new Promise((resolve) => setTimeout(resolve, 20));
// Enter-to-command macro: the bare wake CR is no longer what is written.
return "uptime\r";
},
},
});
try {
terminalBridge.writeToSession({}, { sessionId: "s", data: "\r" });
await Promise.resolve();
assert.equal(interceptorStarted, true);
// Ingress still sees a wake, so autofill stays armed until the rewrite lands.
assert.deepEqual(events, []);
assert.deepEqual(writes, []);
const deadline = Date.now() + 500;
while (writes.length < 1 && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 10));
}
assert.deepEqual(events, ["cancelled"]);
assert.deepEqual(writes, ["uptime\r"]);
session.autoLogin.handleText("login: ");
session.autoLogin.handleText("\r\nPassword: ");
assert.deepEqual(events, ["cancelled"]);
assert.deepEqual(writes, ["uptime\r"]);
} finally {
terminalBridge.cleanupAllSessions();
}
});
test("serial interceptor that rewrites a wake enter into another wake still autofills", async () => {
const { session, writes, events } = createSerialAutoLoginSession();
let releaseInterceptor = () => {};
const interceptorGate = new Promise((resolve) => {
releaseInterceptor = resolve;
});
terminalBridge.init({
sessions: new Map([["s", session]]),
electronModule: {},
terminalDataPipeline: {
has: () => true,
interceptInput: async (_sessionId, data) => {
assert.equal(data, "\r");
await interceptorGate;
return "\r\n";
},
},
});
try {
terminalBridge.writeToSession({}, { sessionId: "s", data: "\r" });
await Promise.resolve();
session.autoLogin.handleText("login: ");
assert.deepEqual(events, []);
assert.deepEqual(writes, ["admin\r"]);
releaseInterceptor();
const deadline = Date.now() + 500;
while (writes.length < 2 && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 10));
}
assert.deepEqual(events, []);
assert.deepEqual(writes, ["admin\r", "\r\n"]);
session.autoLogin.handleText("\r\nPassword: ");
assert.deepEqual(events, []);
assert.deepEqual(writes, ["admin\r", "\r\n", "secret\r"]);
} finally {
releaseInterceptor();
terminalBridge.cleanupAllSessions();
}
});
test("serial line-mode notification still cancels auto-login without keystroke data", () => {
const { session, writes, events } = createSerialAutoLoginSession();
terminalBridge.init({ sessions: new Map([["s", session]]), electronModule: {} });
try {
terminalBridge.notifySessionUserInput({}, { sessionId: "s" });
session.autoLogin.handleText("login: ");
session.autoLogin.handleText("\r\nPassword: ");
assert.deepEqual(events, ["cancelled"]);
assert.deepEqual(writes, []);
} finally {
terminalBridge.cleanupAllSessions();
}
});
test("serial sessions without auto-login credentials accept input normally", () => {
const writes = [];
const session = {
+90
View File
@@ -76,6 +76,11 @@ class TerminalTextRenderer {
return this.toString();
}
getCursorLine() {
const line = this.lines[this.row] || [];
return line.map((cell) => cell?.ch || " ").join("");
}
toString({ includePendingClearedScreen = false, formatLine = (line) => line } = {}) {
const lines = includePendingClearedScreen ? this.#linesWithPendingClearedScreen() : this.lines;
return lines
@@ -515,6 +520,89 @@ function terminalDataToPlainText(terminalData) {
return renderer.finish();
}
function terminalCursorLine(terminalData, options) {
// Prompt tracking keeps a rolling tail, so the alternate-screen entry may
// already have scrolled off. Seed the mode that was active at the first
// byte still in that tail; a fresh renderer would otherwise treat TUI paint
// as the main screen.
const renderer = new TerminalTextRenderer({
alternateScreenActive: options?.alternateScreenActive === true,
});
renderer.feed(terminalData || "");
renderer.finish();
// A full-screen program owns the display. The main buffer may still hold
// the shell prompt from before the alternate screen was entered.
if (renderer.alternateScreenActive) return "";
return renderer.getCursorLine();
}
// Alternate-screen transitions from TerminalTextRenderer, without a text
// buffer. Callers replay the bytes a rolling tail dropped so truncation
// cannot clear the guard.
function alternateScreenActiveAfter(text, alternateScreenActive) {
let active = alternateScreenActive === true;
if (!text) return active;
let state = "normal";
let escapeBuffer = "";
for (const ch of text) {
if (state === "esc") {
if (ch === "[") {
state = "csi";
escapeBuffer = "";
continue;
}
if (ch === "]") {
state = "osc";
continue;
}
if (ch === "c") active = false;
state = "normal";
continue;
}
if (state === "csi") {
escapeBuffer += ch;
if (CSI_FINAL_RE.test(ch)) {
active = alternateScreenActiveFromCsi(escapeBuffer, active);
state = "normal";
escapeBuffer = "";
}
continue;
}
if (state === "osc") {
if (ch === "\x07") state = "normal";
else if (ch === "\x1b") state = "oscEsc";
continue;
}
if (state === "oscEsc") {
state = ch === "\\" ? "normal" : "osc";
continue;
}
if (ch === "\x1b") {
state = "esc";
continue;
}
if (ch === "\x9b") {
state = "csi";
escapeBuffer = "";
}
}
return active;
}
function alternateScreenActiveFromCsi(sequence, active) {
const final = sequence.at(-1);
if (final !== "h" && final !== "l") return active;
const params = sequence.slice(0, -1);
if (!params.includes("?")) return active;
for (const part of params.replace(/[?><=]/g, "").split(";")) {
if (part === "") continue;
const mode = Number.parseInt(part, 10);
if (mode === 47 || mode === 1047 || mode === 1049) return final === "h";
}
return active;
}
function terminalDataToHtmlContent(terminalData) {
const renderer = new TerminalTextRenderer();
renderer.feed(terminalData || "");
@@ -531,6 +619,8 @@ module.exports = {
createTerminalTextRenderer,
terminalDataToHtmlContent,
terminalDataToPlainText,
terminalCursorLine,
alternateScreenActiveAfter,
};
function createDefaultStyle() {
+78 -6
View File
@@ -2474,16 +2474,68 @@ async function acquireSessionFastDownloadSlot(client, transfer) {
}
}
// TERM-SSHD compatibility requires one file handle per channel. Per-file request
// concurrency is not enough: pooled SFTP aliases share that physical channel,
// and a second body or verification OPEN would fail even for tiny files.
// Reuse the cancelable one-slot queue, keyed by the stream rather than its
// wrapper client, across both directions and the complete verification phase.
function isSingleChannelSftpClient(client) {
return Boolean(
client?.__netcattySingleChannelSsh
|| client?.client?.__netcattySingleChannelSsh,
);
}
function requiresExclusiveSftpTransferChannel(client, channel) {
// Aliases stay on one authenticated/elevated channel, even on ordinary SSH
// servers. Only the owning peer's restriction requires serial transfers.
const peer = client?.__netcattySharedChannelAlias
? sftpClients?.get(client.__netcattySharedChannelOwnerId) || client
: client;
return isSingleChannelSftpClient(peer)
|| getSftpTransferConcurrency(channel, DOWNLOAD_TRANSFER_CONCURRENCY) === 1;
}
/**
* Some single-channel gateways cannot safely interleave SFTP metadata and
* write requests from different files. Serialize the complete upload
* transaction per raw SFTP stream while leaving independent connections free.
*/
async function acquireExclusiveSftpTransferTransaction(client, transfer) {
if (isScpModeClient(client)) return null;
// Preserve the normal lazy SFTP-open path. Only known single-channel peers
// acquire the transaction slot before the upload itself opens a handle.
const knownChannel = client?.sftp || { _client: client?.client };
if (!requiresExclusiveSftpTransferChannel(client, knownChannel)) return null;
await requireSftpChannel(client);
const channel = client.sftp;
if (transfer?._exclusiveSftpTransactionChannel === channel) return null;
await acquireSessionFastDownloadSlot(channel, transfer);
transfer._exclusiveSftpTransactionChannel = channel;
let released = false;
return {
client,
channel,
release() {
if (released) return;
released = true;
if (transfer._exclusiveSftpTransactionChannel === channel) {
transfer._exclusiveSftpTransactionChannel = null;
}
releaseSessionFastDownloadSlot(channel);
},
};
}
// Single-channel compatibility permits only one transfer operation per raw
// SFTP stream. A per-file request limit is insufficient because pooled aliases
// share the physical channel, and even a tiny file's second OPEN or verification
// read can fail. Reuse the cancelable one-slot queue keyed by stream rather
// than wrapper client across both directions and complete verification.
async function withSequentialSftpTransferSlot(client, transfer, operation) {
if (isScpModeClient(client)) return operation();
await requireSftpChannel(client);
const channel = client.sftp;
if (getSftpTransferConcurrency(channel, DOWNLOAD_TRANSFER_CONCURRENCY) !== 1) {
if (transfer?._exclusiveSftpTransactionChannel === channel) {
return operation();
}
if (!requiresExclusiveSftpTransferChannel(client, channel)) {
return operation();
}
await acquireSessionFastDownloadSlot(channel, transfer);
@@ -6625,6 +6677,7 @@ async function startTransferNow(event, payload, onProgress) {
});
};
let exclusiveSftpUploadTransaction = null;
try {
// Explicit 0 is a valid empty-snapshot plan (e.g. download of an empty log
// that may grow later). Do not treat it as "size unknown" and re-stat into
@@ -6752,6 +6805,16 @@ async function startTransferNow(event, payload, onProgress) {
else await verify();
}
// A single-channel upload holds the raw SFTP stream from directory setup
// through stage verification, destination safety checks, promotion, and
// timestamp restoration so directory children cannot interleave requests.
if (sourceType === "local" && targetType === "sftp" && targetClient) {
exclusiveSftpUploadTransaction = await acquireExclusiveSftpTransferTransaction(
targetClient,
transfer,
);
}
sendProgress(transfer.checkpointBytes, fileSize, { force: true });
if (sourceType === 'local' && targetType === 'sftp') {
@@ -7380,6 +7443,13 @@ async function startTransferNow(event, payload, onProgress) {
return { transferId, totalBytes: fileSize, ...(transfer.warning ? { warning: transfer.warning } : {}) };
} catch (err) {
if (
exclusiveSftpUploadTransaction
&& err?.uncleanSftpChannel
&& exclusiveSftpUploadTransaction.client.sftp === exclusiveSftpUploadTransaction.channel
) {
abandonWedgedVerificationSftpChannel(exclusiveSftpUploadTransaction.client);
}
const failureMessage = err?.message || String(err);
if (
sourceType === "local"
@@ -7465,6 +7535,8 @@ async function startTransferNow(event, payload, onProgress) {
sendError(err);
}
return { transferId, error: err.message };
} finally {
exclusiveSftpUploadTransaction?.release();
}
}
@@ -233,6 +233,7 @@ async function createSequentialTransferFixture(t) {
return {
payload, files, sftp, handles, clients,
rejectedOpens: () => rejectedOpens,
openCount: () => opens,
async start(direction, sftpId = "first", options = {}) {
const transferId = options.transferId || crypto.randomUUID();
const localPath = tempDirBridge.getTempFilePath(`term-concurrent-${transferId}.bin`);
@@ -263,6 +264,61 @@ async function createSequentialTransferFixture(t) {
};
}
test("single-channel upload waits for the prior promotion before starting the next file", { timeout: 5_000 }, async (t) => {
const fixture = await createSequentialTransferFixture(t);
const originalLstat = fixture.sftp.lstat;
let lstatCalls = 0;
let promotionStarted;
const promotionStartedPromise = new Promise((resolve) => { promotionStarted = resolve; });
let releasePromotion;
const promotionGate = new Promise((resolve) => { releasePromotion = resolve; });
fixture.sftp.lstat = (remotePath, callback) => {
lstatCalls += 1;
// The first two calls inspect the recovery backup and initial destination.
// The third is the final safety check after the .part write; hold it to
// prove that the next file cannot acquire the channel yet.
if (lstatCalls === 3) {
promotionStarted();
void promotionGate.then(() => originalLstat(remotePath, callback));
return;
}
originalLstat(remotePath, callback);
};
const first = fixture.start("upload", "first");
await promotionStartedPromise;
const openCountAtPromotion = fixture.openCount();
const second = fixture.start("upload", "alias");
let firstResult;
let secondResult;
try {
// start() first writes its local source. Wait until the second upload
// actually queues, or incorrectly starts remote work, before asserting.
const deadline = Date.now() + 1_000;
while (
transferBridge._getSessionFastDownloadWaiterCountForTests(fixture.sftp) === 0
&& lstatCalls === 3
&& fixture.openCount() === openCountAtPromotion
&& Date.now() < deadline
) {
await new Promise((resolve) => setImmediate(resolve));
}
assert.equal(lstatCalls, 3, "the next file must not inspect its target during prior promotion");
assert.equal(fixture.openCount(), openCountAtPromotion, "the next file must not open a remote handle during prior promotion");
assert.equal(transferBridge._getSessionFastDownloadWaiterCountForTests(fixture.sftp), 1,
"the second upload must be waiting for the shared channel");
} finally {
releasePromotion();
[firstResult, secondResult] = await Promise.all([first, second]);
}
assert.equal(firstResult.result.error, undefined);
assert.equal(secondResult.result.error, undefined);
assert.deepEqual(fixture.files.get(firstResult.targetPath), fixture.payload);
assert.deepEqual(fixture.files.get(secondResult.targetPath), fixture.payload);
assert.equal(fixture.rejectedOpens(), 0);
});
for (const secondId of ["first", "alias"]) {
test(`TERM-SSHD small downloads serialize through body and verification (${secondId})`, async (t) => {
const fixture = await createSequentialTransferFixture(t);
+3 -3
View File
@@ -8,7 +8,7 @@ const {
execViaChannel,
execViaRawPty,
} = require("../bridges/ai/ptyExec.cjs");
const { getFreshIdlePrompt, formatSyntheticEcho } = require("../bridges/ai/shellUtils.cjs");
const { getFreshIdlePrompt, getVisibleIdlePrompt, formatSyntheticEcho } = require("../bridges/ai/shellUtils.cjs");
const {
ensureSessionShellKind,
remoteDisallowsExecChannelProbe, sessionDisallowsExtraSshChannel, ensureSessionShellKindForExec,
@@ -338,7 +338,7 @@ function createWorkerAiExecHandler({
chatSessionId,
expectedPrompt: getFreshIdlePrompt(session),
getReadyPrompt: typeof session._promptTrackTail === "string"
? () => getFreshIdlePrompt(session) : undefined,
? () => getFreshIdlePrompt(session) || getVisibleIdlePrompt(session) : undefined,
typedInput: true,
echoCommand: (rawCommand) => {
event?.sender?.send?.("netcatty:data", {
@@ -567,7 +567,7 @@ function createWorkerAiJobStartHandler({
chatSessionId,
expectedPrompt: getFreshIdlePrompt(session),
getReadyPrompt: typeof session._promptTrackTail === "string"
? () => getFreshIdlePrompt(session) : undefined,
? () => getFreshIdlePrompt(session) || getVisibleIdlePrompt(session) : undefined,
typedInput: true,
echoCommand: (rawCommand) => {
event?.sender?.send?.("netcatty:data", {
+16
View File
@@ -80,6 +80,22 @@ describe('queryLocalFonts deduplication', () => {
assert.ok(!names.includes('Microsoft YaHei UI'));
});
it('recognizes SFMono Nerd Font as a monospace terminal font (#3929)', async () => {
installMockWindow(async () => [
{ family: 'SFMono Nerd Font' },
{ family: 'SF Mono' },
{ family: 'Segoe UI' },
]);
const monoFonts = await getMonospaceFonts();
const names = monoFonts.map((f) => f.name);
// Glued typographic family (name ID 16) and the spaced family both belong.
assert.ok(names.includes('SFMono Nerd Font'));
assert.ok(names.includes('SF Mono'));
assert.ok(!names.includes('Segoe UI'));
});
it('a second sequential call also reuses the resolved promise (no second API call)', async () => {
let callCount = 0;
installMockWindow(async () => {
+3
View File
@@ -37,6 +37,9 @@ const KNOWN_MONOSPACE_FONTS = new Set([
'victor mono',
'ibm plex mono',
'sf mono',
// Nerd Font / patched builds often use the glued typographic family
// "SFMono Nerd Font" (name ID 16), which does not match "sf mono" (#3929).
'sfmono',
'operator mono',
'input mono',
'pragmata pro',
+3 -3
View File
@@ -1,14 +1,14 @@
{
version = "1.1.85";
version = "1.1.90";
sources = {
x86_64-linux = {
appImageArch = "x86_64";
hash = "sha256-tiFQ/IGprMO9rc6nB6JWCxetdgBmGD+Zn6wKHPR8v6I=";
hash = "sha256-oa/T3Zey0FHsH+SOt4C1H4CT6aZRJta+x4x/11EAOJE=";
};
aarch64-linux = {
appImageArch = "arm64";
hash = "sha256-LK/evHTu4wEu9TR/ULN1YDCEYlaoqVNIZ0gEhYDAL/s=";
hash = "sha256-r5hr3+gpIGrv9XJ7PQkHCdhOd2COkhELMHlnr3xIG7A=";
};
};
}
+308 -84
View File
@@ -1,7 +1,8 @@
diff --git a/node_modules/ssh2/lib/agent.js b/node_modules/ssh2/lib/agent.js
index bb495d1..32c48a0 100644
--- a/node_modules/ssh2/lib/agent.js
+++ b/node_modules/ssh2/lib/agent.js
@@ -6,7 +6,7 @@
@@ -6,7 +6,7 @@ const { resolve } = require('path');
const { readFile } = require('fs');
const { execFile, spawn } = require('child_process');
@@ -10,7 +11,7 @@ diff --git a/node_modules/ssh2/lib/agent.js b/node_modules/ssh2/lib/agent.js
const {
makeBufferParser,
@@ -655,8 +655,12 @@
@@ -655,8 +655,12 @@ const AgentProtocol = (() => {
// TODO: verify signature type based on key and options used
// during initial sign request
binaryParser.init(signature, 0);
@@ -25,7 +26,7 @@ diff --git a/node_modules/ssh2/lib/agent.js b/node_modules/ssh2/lib/agent.js
binaryParser.clear();
if (signature === undefined)
@@ -711,7 +715,10 @@
@@ -711,7 +715,10 @@ const AgentProtocol = (() => {
hash: undefined,
};
let ctx;
@@ -37,7 +38,7 @@ diff --git a/node_modules/ssh2/lib/agent.js b/node_modules/ssh2/lib/agent.js
if (flagsVal & SSH_AGENT_RSA_SHA2_256) {
ctx = 'rsa-sha2-256';
flags.hash = 'sha256';
@@ -721,7 +728,7 @@
@@ -721,7 +728,7 @@ const AgentProtocol = (() => {
}
}
if (ctx === undefined)
@@ -46,7 +47,7 @@ diff --git a/node_modules/ssh2/lib/agent.js b/node_modules/ssh2/lib/agent.js
const req = new AgentInboundRequest(msgType, ctx);
this[SYM_REQS].push(req);
@@ -732,6 +739,9 @@
@@ -732,6 +739,9 @@ const AgentProtocol = (() => {
default: {
const req = new AgentInboundRequest(msgType);
this[SYM_REQS].push(req);
@@ -56,7 +57,7 @@ diff --git a/node_modules/ssh2/lib/agent.js b/node_modules/ssh2/lib/agent.js
this.failureReply(req);
}
}
@@ -781,7 +791,11 @@
@@ -781,7 +791,11 @@ const AgentProtocol = (() => {
if (pubKey instanceof Error)
throw new Error('Invalid public key argument');
@@ -69,7 +70,7 @@ diff --git a/node_modules/ssh2/lib/agent.js b/node_modules/ssh2/lib/agent.js
switch (options.hash) {
case 'sha256':
flags = SSH_AGENT_RSA_SHA2_256;
@@ -992,19 +1006,24 @@
@@ -992,19 +1006,24 @@ const AgentProtocol = (() => {
let p = 0;
const sigFormat = req.getContext();
const sigFormatLen = Buffer.byteLength(sigFormat);
@@ -99,9 +100,10 @@ diff --git a/node_modules/ssh2/lib/agent.js b/node_modules/ssh2/lib/agent.js
return respond(this, req, buf);
}
diff --git a/node_modules/ssh2/lib/client.js b/node_modules/ssh2/lib/client.js
index 7291c2c..8943c9a 100644
--- a/node_modules/ssh2/lib/client.js
+++ b/node_modules/ssh2/lib/client.js
@@ -2170,6 +2170,29 @@
@@ -2170,6 +2170,29 @@ function getKeyAlgos(client, key, serverSigAlgs) {
return [];
}
return [['ssh-rsa', 'sha1']];
@@ -132,9 +134,10 @@ diff --git a/node_modules/ssh2/lib/client.js b/node_modules/ssh2/lib/client.js
}
diff --git a/node_modules/ssh2/lib/protocol/Protocol.js b/node_modules/ssh2/lib/protocol/Protocol.js
index 7302488..df0c9b3 100644
--- a/node_modules/ssh2/lib/protocol/Protocol.js
+++ b/node_modules/ssh2/lib/protocol/Protocol.js
@@ -58,6 +58,7 @@
@@ -58,6 +58,7 @@ const {
} = require('./kex.js');
const {
parseKey,
@@ -142,7 +145,7 @@ diff --git a/node_modules/ssh2/lib/protocol/Protocol.js b/node_modules/ssh2/lib/
} = require('./keyParser.js');
const MESSAGE_HANDLERS = require('./handlers.js');
const {
@@ -701,11 +702,19 @@
@@ -701,11 +702,19 @@ class Protocol {
if (signature === false)
throw new Error('Error while converting handshake signature');
@@ -163,7 +166,7 @@ diff --git a/node_modules/ssh2/lib/protocol/Protocol.js b/node_modules/ssh2/lib/
);
// TODO: simply copy from original "packet" to new `packet` to avoid
@@ -729,13 +738,17 @@
@@ -729,13 +738,17 @@ class Protocol {
writeUInt32BE(packet, pubKeyLen, p += algoLen);
packet.set(pubKey, p += 4);
@@ -186,7 +189,7 @@ diff --git a/node_modules/ssh2/lib/protocol/Protocol.js b/node_modules/ssh2/lib/
// Servers shouldn't send packet type 60 in response to signed publickey
// attempts, but if they do, interpret as type 60.
@@ -810,20 +823,32 @@
@@ -810,20 +823,32 @@ class Protocol {
if (!signature)
throw new Error('Error while converting handshake signature');
@@ -225,7 +228,7 @@ diff --git a/node_modules/ssh2/lib/protocol/Protocol.js b/node_modules/ssh2/lib/
this._authsQueue.push('hostbased');
@@ -1916,7 +1941,10 @@
@@ -1916,7 +1941,10 @@ class Protocol {
}
// SSH-protoversion-softwareversion (SP comments) CR LF
@@ -238,9 +241,10 @@ diff --git a/node_modules/ssh2/lib/protocol/Protocol.js b/node_modules/ssh2/lib/
// TODO: optimize this by starting n bytes from the end of this._buffer instead
// of the beginning
diff --git a/node_modules/ssh2/lib/protocol/SFTP.js b/node_modules/ssh2/lib/protocol/SFTP.js
index 9f33c02..9751164 100644
--- a/node_modules/ssh2/lib/protocol/SFTP.js
+++ b/node_modules/ssh2/lib/protocol/SFTP.js
@@ -117,6 +117,20 @@
@@ -117,6 +117,20 @@ const OPENSSH_MAX_PKT_LEN = 256 * 1024;
const bufferParser = makeBufferParser();
@@ -261,7 +265,7 @@ diff --git a/node_modules/ssh2/lib/protocol/SFTP.js b/node_modules/ssh2/lib/prot
const fakeStderr = {
readable: false,
writable: false,
@@ -155,6 +169,8 @@
@@ -155,6 +169,8 @@ class SFTP extends EventEmitter {
this._writeReqid = -1;
this._requests = {};
this._maxInPktLen = OPENSSH_MAX_PKT_LEN;
@@ -270,7 +274,7 @@ diff --git a/node_modules/ssh2/lib/protocol/SFTP.js b/node_modules/ssh2/lib/prot
this._maxOutPktLen = 34000;
this._maxReadLen =
(this._isOpenSSH ? OPENSSH_MAX_PKT_LEN : 34000) - PKT_RW_OVERHEAD;
@@ -196,6 +212,53 @@
@@ -196,6 +212,53 @@ class SFTP extends EventEmitter {
this.emit('end');
return;
}
@@ -324,7 +328,7 @@ diff --git a/node_modules/ssh2/lib/protocol/SFTP.js b/node_modules/ssh2/lib/prot
/*
uint32 length
byte type
@@ -339,7 +402,7 @@
@@ -339,7 +402,7 @@ class SFTP extends EventEmitter {
uint32 pflags
ATTRS attrs
*/
@@ -333,7 +337,7 @@ diff --git a/node_modules/ssh2/lib/protocol/SFTP.js b/node_modules/ssh2/lib/prot
let p = 9;
const buf = Buffer.allocUnsafe(4 + 1 + 4 + 4 + pathLen + 4 + 4 + attrsLen);
@@ -349,7 +412,7 @@
@@ -349,7 +412,7 @@ class SFTP extends EventEmitter {
writeUInt32BE(buf, reqid, 5);
writeUInt32BE(buf, pathLen, p);
@@ -342,7 +346,7 @@ diff --git a/node_modules/ssh2/lib/protocol/SFTP.js b/node_modules/ssh2/lib/prot
writeUInt32BE(buf, flags, p += pathLen);
writeUInt32BE(buf, attrsFlags, p += 4);
if (attrsLen) {
@@ -734,7 +797,7 @@
@@ -734,7 +797,7 @@ class SFTP extends EventEmitter {
uint32 id
string filename
*/
@@ -351,7 +355,7 @@ diff --git a/node_modules/ssh2/lib/protocol/SFTP.js b/node_modules/ssh2/lib/prot
let p = 9;
const buf = Buffer.allocUnsafe(4 + 1 + 4 + 4 + fnameLen);
@@ -744,7 +807,7 @@
@@ -744,7 +807,7 @@ class SFTP extends EventEmitter {
writeUInt32BE(buf, reqid, 5);
writeUInt32BE(buf, fnameLen, p);
@@ -360,7 +364,7 @@ diff --git a/node_modules/ssh2/lib/protocol/SFTP.js b/node_modules/ssh2/lib/prot
this._requests[reqid] = { cb };
@@ -762,8 +825,8 @@
@@ -762,8 +825,8 @@ class SFTP extends EventEmitter {
string oldpath
string newpath
*/
@@ -371,7 +375,7 @@ diff --git a/node_modules/ssh2/lib/protocol/SFTP.js b/node_modules/ssh2/lib/prot
let p = 9;
const buf = Buffer.allocUnsafe(4 + 1 + 4 + 4 + oldLen + 4 + newLen);
@@ -773,9 +836,9 @@
@@ -773,9 +836,9 @@ class SFTP extends EventEmitter {
writeUInt32BE(buf, reqid, 5);
writeUInt32BE(buf, oldLen, p);
@@ -383,7 +387,7 @@ diff --git a/node_modules/ssh2/lib/protocol/SFTP.js b/node_modules/ssh2/lib/prot
this._requests[reqid] = { cb };
@@ -806,7 +869,7 @@
@@ -806,7 +869,7 @@ class SFTP extends EventEmitter {
string path
ATTRS attrs
*/
@@ -392,7 +396,7 @@ diff --git a/node_modules/ssh2/lib/protocol/SFTP.js b/node_modules/ssh2/lib/prot
let p = 9;
const buf = Buffer.allocUnsafe(4 + 1 + 4 + 4 + pathLen + 4 + attrsLen);
@@ -816,7 +879,7 @@
@@ -816,7 +879,7 @@ class SFTP extends EventEmitter {
writeUInt32BE(buf, reqid, 5);
writeUInt32BE(buf, pathLen, p);
@@ -401,7 +405,7 @@ diff --git a/node_modules/ssh2/lib/protocol/SFTP.js b/node_modules/ssh2/lib/prot
writeUInt32BE(buf, flags, p += pathLen);
if (attrsLen) {
p += 4;
@@ -844,7 +907,7 @@
@@ -844,7 +907,7 @@ class SFTP extends EventEmitter {
uint32 id
string path
*/
@@ -410,7 +414,7 @@ diff --git a/node_modules/ssh2/lib/protocol/SFTP.js b/node_modules/ssh2/lib/prot
let p = 9;
const buf = Buffer.allocUnsafe(4 + 1 + 4 + 4 + pathLen);
@@ -854,7 +917,7 @@
@@ -854,7 +917,7 @@ class SFTP extends EventEmitter {
writeUInt32BE(buf, reqid, 5);
writeUInt32BE(buf, pathLen, p);
@@ -419,7 +423,7 @@ diff --git a/node_modules/ssh2/lib/protocol/SFTP.js b/node_modules/ssh2/lib/prot
this._requests[reqid] = { cb };
@@ -987,7 +1050,7 @@
@@ -987,7 +1050,7 @@ class SFTP extends EventEmitter {
uint32 id
string path
*/
@@ -428,7 +432,7 @@ diff --git a/node_modules/ssh2/lib/protocol/SFTP.js b/node_modules/ssh2/lib/prot
let p = 9;
const buf = Buffer.allocUnsafe(4 + 1 + 4 + 4 + pathLen);
@@ -997,7 +1060,7 @@
@@ -997,7 +1060,7 @@ class SFTP extends EventEmitter {
writeUInt32BE(buf, reqid, 5);
writeUInt32BE(buf, pathLen, p);
@@ -437,7 +441,7 @@ diff --git a/node_modules/ssh2/lib/protocol/SFTP.js b/node_modules/ssh2/lib/prot
this._requests[reqid] = { cb };
@@ -1014,7 +1077,7 @@
@@ -1014,7 +1077,7 @@ class SFTP extends EventEmitter {
uint32 id
string path
*/
@@ -446,7 +450,7 @@ diff --git a/node_modules/ssh2/lib/protocol/SFTP.js b/node_modules/ssh2/lib/prot
let p = 9;
const buf = Buffer.allocUnsafe(4 + 1 + 4 + 4 + pathLen);
@@ -1024,7 +1087,7 @@
@@ -1024,7 +1087,7 @@ class SFTP extends EventEmitter {
writeUInt32BE(buf, reqid, 5);
writeUInt32BE(buf, pathLen, p);
@@ -455,7 +459,7 @@ diff --git a/node_modules/ssh2/lib/protocol/SFTP.js b/node_modules/ssh2/lib/prot
this._requests[reqid] = { cb };
@@ -1041,7 +1104,7 @@
@@ -1041,7 +1104,7 @@ class SFTP extends EventEmitter {
uint32 id
string path
*/
@@ -464,7 +468,7 @@ diff --git a/node_modules/ssh2/lib/protocol/SFTP.js b/node_modules/ssh2/lib/prot
let p = 9;
const buf = Buffer.allocUnsafe(4 + 1 + 4 + 4 + pathLen);
@@ -1051,7 +1114,7 @@
@@ -1051,7 +1114,7 @@ class SFTP extends EventEmitter {
writeUInt32BE(buf, reqid, 5);
writeUInt32BE(buf, pathLen, p);
@@ -473,7 +477,7 @@ diff --git a/node_modules/ssh2/lib/protocol/SFTP.js b/node_modules/ssh2/lib/prot
this._requests[reqid] = { cb };
@@ -1080,7 +1143,7 @@
@@ -1080,7 +1143,7 @@ class SFTP extends EventEmitter {
string path
ATTRS attrs
*/
@@ -482,7 +486,7 @@ diff --git a/node_modules/ssh2/lib/protocol/SFTP.js b/node_modules/ssh2/lib/prot
let p = 9;
const buf = Buffer.allocUnsafe(4 + 1 + 4 + 4 + pathLen + 4 + attrsLen);
@@ -1090,7 +1153,7 @@
@@ -1090,7 +1153,7 @@ class SFTP extends EventEmitter {
writeUInt32BE(buf, reqid, 5);
writeUInt32BE(buf, pathLen, p);
@@ -491,7 +495,7 @@ diff --git a/node_modules/ssh2/lib/protocol/SFTP.js b/node_modules/ssh2/lib/prot
writeUInt32BE(buf, flags, p += pathLen);
if (attrsLen) {
p += 4;
@@ -1205,7 +1268,7 @@
@@ -1205,7 +1268,7 @@ class SFTP extends EventEmitter {
uint32 id
string path
*/
@@ -500,7 +504,7 @@ diff --git a/node_modules/ssh2/lib/protocol/SFTP.js b/node_modules/ssh2/lib/prot
let p = 9;
const buf = Buffer.allocUnsafe(4 + 1 + 4 + 4 + pathLen);
@@ -1215,7 +1278,7 @@
@@ -1215,7 +1278,7 @@ class SFTP extends EventEmitter {
writeUInt32BE(buf, reqid, 5);
writeUInt32BE(buf, pathLen, p);
@@ -509,7 +513,7 @@ diff --git a/node_modules/ssh2/lib/protocol/SFTP.js b/node_modules/ssh2/lib/prot
this._requests[reqid] = {
cb: (err, names) => {
@@ -1243,8 +1306,8 @@
@@ -1243,8 +1306,8 @@ class SFTP extends EventEmitter {
string linkpath
string targetpath
*/
@@ -520,7 +524,7 @@ diff --git a/node_modules/ssh2/lib/protocol/SFTP.js b/node_modules/ssh2/lib/prot
let p = 9;
const buf = Buffer.allocUnsafe(4 + 1 + 4 + 4 + linkLen + 4 + targetLen);
@@ -1256,14 +1319,14 @@
@@ -1256,14 +1319,14 @@ class SFTP extends EventEmitter {
if (this._isOpenSSH) {
// OpenSSH has linkpath and targetpath positions switched
writeUInt32BE(buf, targetLen, p);
@@ -539,7 +543,7 @@ diff --git a/node_modules/ssh2/lib/protocol/SFTP.js b/node_modules/ssh2/lib/prot
}
this._requests[reqid] = { cb };
@@ -1281,7 +1344,7 @@
@@ -1281,7 +1344,7 @@ class SFTP extends EventEmitter {
uint32 id
string path
*/
@@ -548,7 +552,7 @@ diff --git a/node_modules/ssh2/lib/protocol/SFTP.js b/node_modules/ssh2/lib/prot
let p = 9;
const buf = Buffer.allocUnsafe(4 + 1 + 4 + 4 + pathLen);
@@ -1291,7 +1354,7 @@
@@ -1291,7 +1354,7 @@ class SFTP extends EventEmitter {
writeUInt32BE(buf, reqid, 5);
writeUInt32BE(buf, pathLen, p);
@@ -557,7 +561,7 @@ diff --git a/node_modules/ssh2/lib/protocol/SFTP.js b/node_modules/ssh2/lib/prot
this._requests[reqid] = {
cb: (err, names) => {
@@ -1325,8 +1388,8 @@
@@ -1325,8 +1388,8 @@ class SFTP extends EventEmitter {
string oldpath
string newpath
*/
@@ -568,7 +572,7 @@ diff --git a/node_modules/ssh2/lib/protocol/SFTP.js b/node_modules/ssh2/lib/prot
let p = 9;
const buf =
Buffer.allocUnsafe(4 + 1 + 4 + 4 + 24 + 4 + oldLen + 4 + newLen);
@@ -1337,11 +1400,11 @@
@@ -1337,11 +1400,11 @@ class SFTP extends EventEmitter {
writeUInt32BE(buf, reqid, 5);
writeUInt32BE(buf, 24, p);
@@ -583,7 +587,7 @@ diff --git a/node_modules/ssh2/lib/protocol/SFTP.js b/node_modules/ssh2/lib/prot
this._requests[reqid] = { cb };
@@ -1364,7 +1427,7 @@
@@ -1364,7 +1427,7 @@ class SFTP extends EventEmitter {
string "statvfs@openssh.com"
string path
*/
@@ -592,7 +596,7 @@ diff --git a/node_modules/ssh2/lib/protocol/SFTP.js b/node_modules/ssh2/lib/prot
let p = 9;
const buf = Buffer.allocUnsafe(4 + 1 + 4 + 4 + 19 + 4 + pathLen);
@@ -1374,9 +1437,9 @@
@@ -1374,9 +1437,9 @@ class SFTP extends EventEmitter {
writeUInt32BE(buf, reqid, 5);
writeUInt32BE(buf, 19, p);
@@ -604,7 +608,7 @@ diff --git a/node_modules/ssh2/lib/protocol/SFTP.js b/node_modules/ssh2/lib/prot
this._requests[reqid] = { extended: 'statvfs@openssh.com', cb };
@@ -1411,7 +1474,7 @@
@@ -1411,7 +1474,7 @@ class SFTP extends EventEmitter {
writeUInt32BE(buf, reqid, 5);
writeUInt32BE(buf, 20, p);
@@ -613,7 +617,7 @@ diff --git a/node_modules/ssh2/lib/protocol/SFTP.js b/node_modules/ssh2/lib/prot
writeUInt32BE(buf, handleLen, p += 20);
buf.set(handle, p += 4);
@@ -1437,8 +1500,8 @@
@@ -1437,8 +1500,8 @@ class SFTP extends EventEmitter {
string oldpath
string newpath
*/
@@ -624,7 +628,7 @@ diff --git a/node_modules/ssh2/lib/protocol/SFTP.js b/node_modules/ssh2/lib/prot
let p = 9;
const buf =
Buffer.allocUnsafe(4 + 1 + 4 + 4 + 20 + 4 + oldLen + 4 + newLen);
@@ -1449,11 +1512,11 @@
@@ -1449,11 +1512,11 @@ class SFTP extends EventEmitter {
writeUInt32BE(buf, reqid, 5);
writeUInt32BE(buf, 20, p);
@@ -639,7 +643,7 @@ diff --git a/node_modules/ssh2/lib/protocol/SFTP.js b/node_modules/ssh2/lib/prot
this._requests[reqid] = { cb };
@@ -1488,7 +1551,7 @@
@@ -1488,7 +1551,7 @@ class SFTP extends EventEmitter {
writeUInt32BE(buf, reqid, 5);
writeUInt32BE(buf, 17, p);
@@ -648,7 +652,7 @@ diff --git a/node_modules/ssh2/lib/protocol/SFTP.js b/node_modules/ssh2/lib/prot
writeUInt32BE(buf, handleLen, p += 17);
buf.set(handle, p += 4);
@@ -1524,7 +1587,7 @@
@@ -1524,7 +1587,7 @@ class SFTP extends EventEmitter {
string path
ATTRS attrs
*/
@@ -657,7 +661,7 @@ diff --git a/node_modules/ssh2/lib/protocol/SFTP.js b/node_modules/ssh2/lib/prot
let p = 9;
const buf =
Buffer.allocUnsafe(4 + 1 + 4 + 4 + 20 + 4 + pathLen + 4 + attrsLen);
@@ -1535,10 +1598,10 @@
@@ -1535,10 +1598,10 @@ class SFTP extends EventEmitter {
writeUInt32BE(buf, reqid, 5);
writeUInt32BE(buf, 20, p);
@@ -670,7 +674,7 @@ diff --git a/node_modules/ssh2/lib/protocol/SFTP.js b/node_modules/ssh2/lib/prot
writeUInt32BE(buf, flags, p += pathLen);
if (attrsLen) {
@@ -1573,7 +1636,7 @@
@@ -1573,7 +1636,7 @@ class SFTP extends EventEmitter {
string "expand-path@openssh.com"
string path
*/
@@ -679,7 +683,7 @@ diff --git a/node_modules/ssh2/lib/protocol/SFTP.js b/node_modules/ssh2/lib/prot
let p = 9;
const buf = Buffer.allocUnsafe(4 + 1 + 4 + 4 + 23 + 4 + pathLen);
@@ -1583,10 +1646,10 @@
@@ -1583,10 +1646,10 @@ class SFTP extends EventEmitter {
writeUInt32BE(buf, reqid, 5);
writeUInt32BE(buf, 23, p);
@@ -692,7 +696,7 @@ diff --git a/node_modules/ssh2/lib/protocol/SFTP.js b/node_modules/ssh2/lib/prot
this._requests[reqid] = {
cb: (err, names) => {
@@ -1653,7 +1716,7 @@
@@ -1653,7 +1716,7 @@ class SFTP extends EventEmitter {
writeUInt32BE(buf, 9, p);
p += 4;
@@ -701,7 +705,7 @@ diff --git a/node_modules/ssh2/lib/protocol/SFTP.js b/node_modules/ssh2/lib/prot
p += 9;
writeUInt32BE(buf, srcHandle.length, p);
@@ -1708,7 +1771,7 @@
@@ -1708,7 +1771,7 @@ class SFTP extends EventEmitter {
string username
*/
let p = 0;
@@ -710,7 +714,7 @@ diff --git a/node_modules/ssh2/lib/protocol/SFTP.js b/node_modules/ssh2/lib/prot
const buf = Buffer.allocUnsafe(
4 + 1
+ 4
@@ -1728,12 +1791,12 @@
@@ -1728,12 +1791,12 @@ class SFTP extends EventEmitter {
writeUInt32BE(buf, 14, p);
p += 4;
@@ -725,7 +729,7 @@ diff --git a/node_modules/ssh2/lib/protocol/SFTP.js b/node_modules/ssh2/lib/prot
p += usernameLen;
this._requests[reqid] = {
@@ -1806,7 +1869,7 @@
@@ -1806,7 +1869,7 @@ class SFTP extends EventEmitter {
writeUInt32BE(buf, 30, p);
p += 4;
@@ -734,7 +738,7 @@ diff --git a/node_modules/ssh2/lib/protocol/SFTP.js b/node_modules/ssh2/lib/prot
p += 30;
writeUInt32BE(buf, 4 * uids.length, p);
@@ -1871,7 +1934,7 @@
@@ -1871,7 +1934,7 @@ class SFTP extends EventEmitter {
message || (message = '');
@@ -743,7 +747,7 @@ diff --git a/node_modules/ssh2/lib/protocol/SFTP.js b/node_modules/ssh2/lib/prot
let p = 9;
const buf = Buffer.allocUnsafe(4 + 1 + 4 + 4 + 4 + msgLen + 4);
@@ -1884,7 +1947,7 @@
@@ -1884,7 +1947,7 @@ class SFTP extends EventEmitter {
writeUInt32BE(buf, msgLen, p += 4);
p += 4;
if (msgLen) {
@@ -752,7 +756,7 @@ diff --git a/node_modules/ssh2/lib/protocol/SFTP.js b/node_modules/ssh2/lib/prot
p += msgLen;
}
@@ -1913,7 +1976,7 @@
@@ -1913,7 +1976,7 @@ class SFTP extends EventEmitter {
const dataLen = (
isBuffer
? data.length
@@ -761,7 +765,7 @@ diff --git a/node_modules/ssh2/lib/protocol/SFTP.js b/node_modules/ssh2/lib/prot
);
let p = 9;
const buf = Buffer.allocUnsafe(4 + 1 + 4 + 4 + dataLen);
@@ -1927,7 +1990,7 @@
@@ -1927,7 +1990,7 @@ class SFTP extends EventEmitter {
if (isBuffer)
buf.set(data, p += 4);
else if (isUTF8)
@@ -770,7 +774,7 @@ diff --git a/node_modules/ssh2/lib/protocol/SFTP.js b/node_modules/ssh2/lib/prot
else
buf.write(data, p += 4, dataLen, encoding);
}
@@ -1959,13 +2022,13 @@
@@ -1959,13 +2022,13 @@ class SFTP extends EventEmitter {
? ''
: name.filename
);
@@ -786,7 +790,7 @@ diff --git a/node_modules/ssh2/lib/protocol/SFTP.js b/node_modules/ssh2/lib/prot
if (typeof name.attrs === 'object' && name.attrs !== null) {
nameAttrs = attrsToBytes(name.attrs);
@@ -2011,11 +2074,11 @@
@@ -2011,11 +2074,11 @@ class SFTP extends EventEmitter {
? ''
: name.filename
);
@@ -800,7 +804,7 @@ diff --git a/node_modules/ssh2/lib/protocol/SFTP.js b/node_modules/ssh2/lib/prot
p += len;
}
}
@@ -2026,11 +2089,11 @@
@@ -2026,11 +2089,11 @@ class SFTP extends EventEmitter {
? ''
: name.longname
);
@@ -814,7 +818,7 @@ diff --git a/node_modules/ssh2/lib/protocol/SFTP.js b/node_modules/ssh2/lib/prot
p += len;
}
}
@@ -2749,7 +2812,7 @@
@@ -2749,7 +2812,7 @@ function requestLimits(sftp, cb) {
writeUInt32BE(buf, reqid, 5);
writeUInt32BE(buf, 18, p);
@@ -823,7 +827,7 @@ diff --git a/node_modules/ssh2/lib/protocol/SFTP.js b/node_modules/ssh2/lib/prot
sftp._requests[reqid] = { extended: 'limits@openssh.com', cb };
@@ -2953,18 +3016,28 @@
@@ -2953,18 +3016,28 @@ const CLIENT_HANDLERS = {
// spec not specifying an encoding because the specs for newer
// versions of the protocol all explicitly specify UTF-8 for
// filenames
@@ -856,21 +860,41 @@ diff --git a/node_modules/ssh2/lib/protocol/SFTP.js b/node_modules/ssh2/lib/prot
if (names !== undefined) {
sftp._debug && sftp._debug(
diff --git a/node_modules/ssh2/lib/protocol/constants.js b/node_modules/ssh2/lib/protocol/constants.js
index ad77592..a249e00 100644
--- a/node_modules/ssh2/lib/protocol/constants.js
+++ b/node_modules/ssh2/lib/protocol/constants.js
@@ -73,7 +73,11 @@
@@ -71,9 +71,30 @@ const DEFAULT_SERVER_HOST_KEY = [
];
if (eddsaSupported)
DEFAULT_SERVER_HOST_KEY.unshift('ssh-ed25519');
+// OpenSSH host certificates, offered before the matching plain key so a
+// cert-only server (for example ssh-ed25519-cert-v01@openssh.com) can
+// negotiate. Plain keys stay as fallbacks.
+const OPENSSH_HOST_CERT_ALGS = [
+ 'ecdsa-sha2-nistp256-cert-v01@openssh.com',
+ 'ecdsa-sha2-nistp384-cert-v01@openssh.com',
+ 'ecdsa-sha2-nistp521-cert-v01@openssh.com',
+ 'rsa-sha2-512-cert-v01@openssh.com',
+ 'rsa-sha2-256-cert-v01@openssh.com',
+ 'ssh-rsa-cert-v01@openssh.com',
+];
+if (eddsaSupported)
+ OPENSSH_HOST_CERT_ALGS.unshift('ssh-ed25519-cert-v01@openssh.com');
+DEFAULT_SERVER_HOST_KEY.unshift(...OPENSSH_HOST_CERT_ALGS);
const SUPPORTED_SERVER_HOST_KEY = DEFAULT_SERVER_HOST_KEY.concat([
'ssh-dss',
+ // FIDO/U2F security keys (client publickey auth / rare host keys).
+ 'sk-ecdsa-sha2-nistp256@openssh.com',
+ 'sk-ecdsa-sha2-nistp256-cert-v01@openssh.com',
]);
+if (eddsaSupported)
+if (eddsaSupported) {
+ SUPPORTED_SERVER_HOST_KEY.push('sk-ssh-ed25519@openssh.com');
+ SUPPORTED_SERVER_HOST_KEY.push('sk-ssh-ed25519-cert-v01@openssh.com');
+}
const canUseCipher = (() => {
@@ -160,6 +164,7 @@
@@ -160,6 +181,7 @@ const COMPAT = {
DYN_RPORT_BUG: 1 << 2,
BUG_DHGEX_LARGE: 1 << 3,
IMPLY_RSA_SHA2_SIGALGS: 1 << 4,
@@ -878,7 +902,7 @@ diff --git a/node_modules/ssh2/lib/protocol/constants.js b/node_modules/ssh2/lib
};
module.exports = {
@@ -330,6 +335,7 @@
@@ -330,6 +352,7 @@ module.exports = {
COMPAT_CHECKS: [
[ 'Cisco-1.25', COMPAT.BAD_DHGEX ],
[ /^Cisco-1[.]/, COMPAT.BUG_DHGEX_LARGE ],
@@ -887,9 +911,198 @@ diff --git a/node_modules/ssh2/lib/protocol/constants.js b/node_modules/ssh2/lib
[ /^OpenSSH_5[.][0-9]+/, COMPAT.DYN_RPORT_BUG ],
[ /^OpenSSH_7[.]4/, COMPAT.IMPLY_RSA_SHA2_SIGALGS ],
diff --git a/node_modules/ssh2/lib/protocol/kex.js b/node_modules/ssh2/lib/protocol/kex.js
index 811e631..422a198 100644
--- a/node_modules/ssh2/lib/protocol/kex.js
+++ b/node_modules/ssh2/lib/protocol/kex.js
@@ -1377,8 +1377,13 @@
@@ -30,7 +30,10 @@ const {
createDecipher,
MAC_INFO,
} = require('./crypto.js');
-const { parseDERKey } = require('./keyParser.js');
+const {
+ parseDERKey,
+ getOpenSSHCertSignatureAlgorithm,
+} = require('./keyParser.js');
const {
bufferFill,
bufferParser,
@@ -49,6 +52,68 @@ const {
let MESSAGE_HANDLERS;
+const LEGACY_RSA_HOST_CERT = /^ssh-rsa-cert-v0[01]@openssh\.com$/;
+const RSA_SHA2_256_HOST_CERT = /^rsa-sha2-256-cert-v0[01]@openssh\.com$/;
+const RSA_SHA2_512_HOST_CERT = /^rsa-sha2-512-cert-v0[01]@openssh\.com$/;
+const LEGACY_RSA_HOST_SIGNATURES = new Set([
+ 'rsa-sha2-256',
+ 'rsa-sha2-512',
+ 'ssh-rsa',
+]);
+
+// Client KEX accepts the negotiated host-key name or, for OpenSSH host
+// certificates, the underlying key algorithm. Real servers sign with the
+// embedded key (ssh-ed25519, ecdsa-sha2-*, rsa-sha2-*) rather than the
+// certificate algorithm name. OpenSSH allows any RSA signature only for the
+// legacy ssh-rsa certificate. rsa-sha2-256/512 certificates must use the
+// hash that was negotiated.
+function hostKeySignatureTypeAccepted(negotiatedServerHostKey, sigType) {
+ if (RSA_SHA2_256_HOST_CERT.test(negotiatedServerHostKey))
+ return sigType === 'rsa-sha2-256';
+ if (RSA_SHA2_512_HOST_CERT.test(negotiatedServerHostKey))
+ return sigType === 'rsa-sha2-512';
+ if (sigType === negotiatedServerHostKey)
+ return true;
+ if (sigType === getOpenSSHCertSignatureAlgorithm(negotiatedServerHostKey))
+ return true;
+ return LEGACY_RSA_HOST_CERT.test(negotiatedServerHostKey)
+ && LEGACY_RSA_HOST_SIGNATURES.has(sigType);
+}
+
+// Plain rsa-sha2-* keeps an ssh-rsa key blob. An RSA certificate negotiation
+// is KEY_RSA_CERT: the blob must be ssh-rsa-cert-v01@openssh.com, never a
+// plain ssh-rsa key.
+function hostKeyBlobTypeAccepted(negotiatedServerHostKey, hostPubKeyType) {
+ if (hostPubKeyType === negotiatedServerHostKey)
+ return true;
+ switch (negotiatedServerHostKey) {
+ case 'rsa-sha2-256':
+ case 'rsa-sha2-512':
+ return hostPubKeyType === 'ssh-rsa';
+ case 'rsa-sha2-256-cert-v01@openssh.com':
+ case 'rsa-sha2-512-cert-v01@openssh.com':
+ return hostPubKeyType === 'ssh-rsa-cert-v01@openssh.com';
+ default:
+ return false;
+ }
+}
+
+function hashAlgoForHostKey(negotiatedServerHostKey, sigType) {
+ // Negotiated RSA-SHA2 wins over the signature label so a mismatched
+ // signature cannot select a weaker hash.
+ if (negotiatedServerHostKey === 'rsa-sha2-256'
+ || RSA_SHA2_256_HOST_CERT.test(negotiatedServerHostKey))
+ return 'sha256';
+ if (negotiatedServerHostKey === 'rsa-sha2-512'
+ || RSA_SHA2_512_HOST_CERT.test(negotiatedServerHostKey))
+ return 'sha512';
+ if (sigType === 'rsa-sha2-256')
+ return 'sha256';
+ if (sigType === 'rsa-sha2-512')
+ return 'sha512';
+ return undefined;
+}
+
const GEX_MIN_BITS = 2048; // RFC 8270
const GEX_MAX_BITS = 8192; // RFC 8270
@@ -635,7 +700,7 @@ const createKeyExchange = (() => {
);
}
- if (sigType !== negotiated.serverHostKey) {
+ if (!hostKeySignatureTypeAccepted(negotiated.serverHostKey, sigType)) {
return doFatalError(
this._protocol,
`Wrong signature type: ${sigType}, `
@@ -659,7 +724,10 @@ const createKeyExchange = (() => {
);
}
- if (!(sigValue = sigSSHToASN1(sigValue, sigType))) {
+ if (!(sigValue = sigSSHToASN1(
+ sigValue,
+ getOpenSSHCertSignatureAlgorithm(sigType)
+ ))) {
return doFatalError(
this._protocol,
'Malformed signature',
@@ -685,12 +753,10 @@ const createKeyExchange = (() => {
}
}
- let hashAlgo;
- // Check if we need to override the default hash algorithm
- switch (this.negotiated.serverHostKey) {
- case 'rsa-sha2-256': hashAlgo = 'sha256'; break;
- case 'rsa-sha2-512': hashAlgo = 'sha512'; break;
- }
+ const hashAlgo = hashAlgoForHostKey(
+ this.negotiated.serverHostKey,
+ sigType
+ );
this._protocol._debug
&& this._protocol._debug('Verifying signature ...');
@@ -717,12 +783,7 @@ const createKeyExchange = (() => {
} else {
// Server
- let hashAlgo;
- // Check if we need to override the default hash algorithm
- switch (this.negotiated.serverHostKey) {
- case 'rsa-sha2-256': hashAlgo = 'sha256'; break;
- case 'rsa-sha2-512': hashAlgo = 'sha512'; break;
- }
+ const hashAlgo = hashAlgoForHostKey(this.negotiated.serverHostKey);
this._protocol._debug && this._protocol._debug(
'Generating signature ...'
@@ -739,7 +800,12 @@ const createKeyExchange = (() => {
);
}
- signature = convertSignature(signature, this._hostKey.type);
+ // OpenSSH signs a host certificate with the embedded key algorithm,
+ // not the certificate algorithm name.
+ const sigType = getOpenSSHCertSignatureAlgorithm(
+ this.negotiated.serverHostKey
+ );
+ signature = convertSignature(signature, sigType);
if (signature === false) {
return doFatalError(
this._protocol,
@@ -759,7 +825,6 @@ const createKeyExchange = (() => {
string <method-specific data>
string signature of H
*/
- const sigType = this.negotiated.serverHostKey;
const sigTypeLen = Buffer.byteLength(sigType);
const sigLen = 4 + sigTypeLen + 4 + signature.length;
let p = this._protocol._packetRW.write.allocStartKEX;
@@ -1167,22 +1232,16 @@ const createKeyExchange = (() => {
DISCONNECT_REASON.KEY_EXCHANGE_FAILED
);
}
- if (hostPubKeyType !== this.negotiated.serverHostKey) {
- // Check if we need to make an exception
- switch (this.negotiated.serverHostKey) {
- case 'rsa-sha2-256':
- case 'rsa-sha2-512':
- if (hostPubKeyType === 'ssh-rsa')
- break;
- // FALLTHROUGH
- default:
- return doFatalError(
- this._protocol,
- 'Host key does not match negotiated type',
- 'handshake',
- DISCONNECT_REASON.KEY_EXCHANGE_FAILED
- );
- }
+ if (!hostKeyBlobTypeAccepted(
+ this.negotiated.serverHostKey,
+ hostPubKeyType
+ )) {
+ return doFatalError(
+ this._protocol,
+ 'Host key does not match negotiated type',
+ 'handshake',
+ DISCONNECT_REASON.KEY_EXCHANGE_FAILED
+ );
}
this._hostKey = hostPubKey;
@@ -1377,8 +1436,13 @@ const createKeyExchange = (() => {
this._generator = null;
this._minBits = GEX_MIN_BITS;
this._prefBits = dhEstimate(this.negotiated);
@@ -904,10 +1117,20 @@ diff --git a/node_modules/ssh2/lib/protocol/kex.js b/node_modules/ssh2/lib/proto
this._maxBits = GEX_MAX_BITS;
}
start() {
@@ -1868,6 +1932,8 @@ function trySendNEWKEYS(kex) {
module.exports = {
KexInit,
+ hostKeyBlobTypeAccepted,
+ hostKeySignatureTypeAccepted,
kexinit,
onKEXPayload,
DEFAULT_KEXINIT_CLIENT: new KexInit({
diff --git a/node_modules/ssh2/lib/protocol/keyParser.js b/node_modules/ssh2/lib/protocol/keyParser.js
index a276c1a..c8ff9d1 100644
--- a/node_modules/ssh2/lib/protocol/keyParser.js
+++ b/node_modules/ssh2/lib/protocol/keyParser.js
@@ -35,6 +35,9 @@
@@ -35,6 +35,9 @@ const SYM_PRIV_PEM = Symbol('Private key PEM');
const SYM_PUB_PEM = Symbol('Public key PEM');
const SYM_PUB_SSH = Symbol('Public key SSH');
const SYM_DECRYPTED = Symbol('Decrypted Key');
@@ -917,7 +1140,7 @@ diff --git a/node_modules/ssh2/lib/protocol/keyParser.js b/node_modules/ssh2/lib
// Create OpenSSL cipher name -> SSH cipher name conversion table
const CIPHER_INFO_OPENSSL = Object.create(null);
@@ -258,6 +261,55 @@
@@ -258,6 +261,55 @@ function genOpenSSHEdPub(pub) {
return publicKey;
}
@@ -973,7 +1196,7 @@ diff --git a/node_modules/ssh2/lib/protocol/keyParser.js b/node_modules/ssh2/lib
function genOpenSSLEdPriv(priv) {
const asnWriter = new Ber.Writer();
asnWriter.startSequence();
@@ -451,6 +503,82 @@
@@ -451,6 +503,82 @@ const BaseKey = {
},
};
@@ -1035,7 +1258,7 @@ diff --git a/node_modules/ssh2/lib/protocol/keyParser.js b/node_modules/ssh2/lib
+ signedData.set(dataHash, p);
+
+ let toVerify = rawSig;
+ if (this.type === 'sk-ecdsa-sha2-nistp256@openssh.com') {
+ if (getOpenSSHCertBaseType(this.type) === 'sk-ecdsa-sha2-nistp256@openssh.com') {
+ // Convert SSH ECDSA r,s blob to ASN.1 for OpenSSL
+ const { sigSSHToASN1 } = require('./utils.js');
+ toVerify = sigSSHToASN1(rawSig, 'ecdsa-sha2-nistp256');
@@ -1056,7 +1279,7 @@ diff --git a/node_modules/ssh2/lib/protocol/keyParser.js b/node_modules/ssh2/lib
function OpenSSH_Private(type, comment, privPEM, pubPEM, pubSSH, algo,
decrypted) {
@@ -463,6 +591,26 @@
@@ -463,6 +591,26 @@ function OpenSSH_Private(type, comment, privPEM, pubPEM, pubSSH, algo,
this[SYM_DECRYPTED] = decrypted;
}
OpenSSH_Private.prototype = BaseKey;
@@ -1083,7 +1306,7 @@ diff --git a/node_modules/ssh2/lib/protocol/keyParser.js b/node_modules/ssh2/lib
{
const regexp = /^-----BEGIN OPENSSH PRIVATE KEY-----(?:\r\n|\n)([\s\S]+)(?:\r\n|\n)-----END OPENSSH PRIVATE KEY-----$/;
OpenSSH_Private.parse = (str, passphrase) => {
@@ -771,6 +919,87 @@
@@ -771,6 +919,87 @@ OpenSSH_Private.prototype = BaseKey;
privPEM = genOpenSSLECDSAPriv(oid, ecpub, ecpriv);
break;
}
@@ -1171,7 +1394,7 @@ diff --git a/node_modules/ssh2/lib/protocol/keyParser.js b/node_modules/ssh2/lib
default:
return new Error(`Unsupported OpenSSH private key type: ${type}`);
}
@@ -1186,31 +1415,97 @@
@@ -1186,31 +1415,97 @@ function OpenSSH_Public(type, comment, pubPEM, pubSSH, algo) {
this[SYM_DECRYPTED] = false;
}
OpenSSH_Public.prototype = BaseKey;
@@ -1281,7 +1504,7 @@ diff --git a/node_modules/ssh2/lib/protocol/keyParser.js b/node_modules/ssh2/lib
};
}
@@ -1309,7 +1604,7 @@
@@ -1309,7 +1604,7 @@ RFC4716_Public.prototype = BaseKey;
}
@@ -1290,7 +1513,7 @@ diff --git a/node_modules/ssh2/lib/protocol/keyParser.js b/node_modules/ssh2/lib
if (!isSupportedKeyType(baseType))
return new Error(`Unsupported OpenSSH public key type: ${baseType}`);
@@ -1317,6 +1612,10 @@
@@ -1317,6 +1612,10 @@ function parseDER(data, baseType, comment, fullType) {
let oid;
let pubPEM = null;
let pubSSH = null;
@@ -1301,7 +1524,7 @@ diff --git a/node_modules/ssh2/lib/protocol/keyParser.js b/node_modules/ssh2/lib
switch (baseType) {
case 'ssh-rsa': {
@@ -1383,11 +1682,66 @@
@@ -1383,11 +1682,66 @@ function parseDER(data, baseType, comment, fullType) {
pubSSH = genOpenSSHECDSAPub(oid, ecpub);
break;
}
@@ -1369,7 +1592,7 @@ diff --git a/node_modules/ssh2/lib/protocol/keyParser.js b/node_modules/ssh2/lib
}
function isSupportedKeyType(type) {
@@ -1397,8 +1751,10 @@
@@ -1397,8 +1751,10 @@ function isSupportedKeyType(type) {
case 'ecdsa-sha2-nistp256':
case 'ecdsa-sha2-nistp384':
case 'ecdsa-sha2-nistp521':
@@ -1380,7 +1603,7 @@ diff --git a/node_modules/ssh2/lib/protocol/keyParser.js b/node_modules/ssh2/lib
if (eddsaSupported)
return true;
// FALLTHROUGH
@@ -1460,7 +1816,13 @@
@@ -1460,7 +1816,13 @@ function parseKey(data, passphrase) {
if (type !== undefined) {
data = binaryKeyParser.readRaw();
if (data !== undefined) {
@@ -1395,11 +1618,12 @@ diff --git a/node_modules/ssh2/lib/protocol/keyParser.js b/node_modules/ssh2/lib
// Ignore potentially useless errors in case the data was not actually
// in the binary format
if (ret instanceof Error)
@@ -1479,6 +1841,7 @@
@@ -1479,6 +1841,7 @@ function parseKey(data, passphrase) {
module.exports = {
isParsedKey,
isSupportedKeyType,
- parseDERKey: (data, type) => parseDER(data, type, '', type),
+ getOpenSSHCertSignatureAlgorithm,
parseDERKey: (data, type) => parseDER(data, type, '', type),
+ parseDERKey: (data, type) => parseDER(data, getOpenSSHCertBaseType(type), '', type),
parseKey,
};
+27
View File
@@ -128,6 +128,13 @@ test("asarUnpack no longer references removed legacy agent packages", () => {
test("asarUnpack keeps MCP server runtime deps unpacked", () => {
// @modelcontextprotocol/sdk is now a direct dep and the MCP server hard-requires it.
assert.ok(config.asarUnpack.includes("node_modules/@modelcontextprotocol/sdk/**/*"));
// externalMcpRedaction.cjs requires re2js. The launcher runs from
// app.asar.unpacked with ELECTRON_RUN_AS_NODE, which cannot see modules
// that exist only inside app.asar (issue #3910).
assert.ok(
config.asarUnpack.includes("node_modules/re2js/**/*"),
"external MCP bridge must unpack re2js so the stdio launcher can resolve it",
);
});
test("asarUnpack keeps Cursor SDK runtime deps unpacked", () => {
@@ -338,6 +345,26 @@ test("windows packaging includes a zip archive target", () => {
);
});
test("windows installer kills leftover Netcatty.exe before file replacement", () => {
const fs = require("node:fs");
const path = require("node:path");
const installerScript = fs.readFileSync(
path.join(__dirname, "..", config.nsis.include),
"utf8",
);
assert.match(installerScript, /!macro customInit\b/);
assert.match(installerScript, /#3946/);
assert.match(installerScript, /ELECTRON_RUN_AS_NODE/);
assert.match(
installerScript,
/nsExec::ExecToLog "taskkill \/F \/IM Netcatty\.exe"/,
);
assert.match(installerScript, /Pop \$0/);
// /T would kill this installer while it is still a child of the UI Netcatty.exe.
assert.doesNotMatch(installerScript, /taskkill [^\r\n]*\/T/);
});
test("windows installer registers and removes Explorer folder context menu entries", () => {
const fs = require("node:fs");
const path = require("node:path");