diff --git a/CHANGELOG.md b/CHANGELOG.md index e9f608b..47ebbfd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.12.1] - 2026-08-30 + +### Fixed + +- `sshx login` requested a PTY with only `ECHO`/`ECHOCTL` set, so remote zsh + (and plugins such as zsh-autosuggestions) could mis-measure UTF-8 prompt + width and reprint each typed character. Login now sends OpenSSH-like cooked + UTF-8 tty modes and forwards `LANG`/`LC_*`/`COLORTERM` when the server + allows it. + ## [0.12.0] - 2026-08-28 ### Changed diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 8d1952b..9e5b658 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -98,6 +98,38 @@ sshx -h=prod-web --dry-run --json "sudo rm -rf /" If a privileged or destructive command is genuinely intended, review it, record the reason, and use `--force` only for that invocation. +## Login Types Extra Characters + +Symptoms: after `sshx login`, each keystroke appears more than once. Extra +copies are often dim, gray, or pink. Tab completion or zsh-autosuggestions +look garbled. + +Update sshx first. Older builds sent a sparse PTY mode list (echo only), which +can make remote zsh reprint typed characters. + +If it still happens in the Cursor or VS Code integrated terminal, that +emulator's **local echo** (type-ahead) draws predicted keystrokes in a dim +color. It turns itself off for `vim`/`tmux`, but not for `sshx`, and it +fights zsh-autosuggestions: + +```json +{ + "terminal.integrated.localEchoExcludePrograms": [ + "vim", + "vi", + "nano", + "tmux", + "ssh", + "sshx" + ] +} +``` + +To disable the feature entirely: `"terminal.integrated.localEchoEnabled": false`. + +Compare with `ssh` in the **same** terminal. If OpenSSH glitches too, this is +the emulator, not sshx. + ## Script Hangs Set a timeout: diff --git a/docs/zh/troubleshooting.md b/docs/zh/troubleshooting.md index f71a5a5..88ef828 100644 --- a/docs/zh/troubleshooting.md +++ b/docs/zh/troubleshooting.md @@ -96,6 +96,31 @@ sshx -h=prod-web --dry-run --json "sudo rm -rf /" 如果特权或破坏性命令确实是预期操作,先审阅、记录原因,再只对这一次使用 `--force`。 +## Login 后输入一个字母出现好几个 + +症状:`sshx login` 之后每按一个键,屏幕上会出现多个相同字符;多出来的往往是暗色、灰色或粉色。Tab 补全或 zsh-autosuggestions 也会错乱。 + +先升级 sshx。旧版本申请 PTY 时只设置了 echo,远端 zsh 可能把提示符宽度算错,从而把刚输入的字符又画一遍。 + +若升级后在 Cursor / VS Code 集成终端里仍复现,多半是终端的 **local echo**(预显示)。它会给 `vim`/`tmux` 自动关掉,但不会给 `sshx` 关,于是和 zsh-autosuggestions 叠在一起: + +```json +{ + "terminal.integrated.localEchoExcludePrograms": [ + "vim", + "vi", + "nano", + "tmux", + "ssh", + "sshx" + ] +} +``` + +彻底关掉该功能:`"terminal.integrated.localEchoEnabled": false`。 + +在**同一个**终端里对比 `ssh`:如果 OpenSSH 也花屏,问题在终端模拟器,不在 sshx。 + ## 脚本卡住 设置 timeout: diff --git a/internal/sshclient/login.go b/internal/sshclient/login.go index 15da48e..202c9c5 100644 --- a/internal/sshclient/login.go +++ b/internal/sshclient/login.go @@ -4,6 +4,7 @@ import ( "errors" "os" + "golang.org/x/crypto/ssh" "golang.org/x/term" ) @@ -33,3 +34,70 @@ func (c *SSHClient) Login() error { } return c.loginSession() } + +func loginTermName() string { + if name := os.Getenv("TERM"); name != "" { + return name + } + return "xterm-256color" +} + +// loginPtyModes is the cooked UTF-8 tty state OpenSSH typically sends with +// pty-req. A sparse ECHO-only list leaves IUTF8/ONLCR/ICRNL at the PTY +// allocator default, which on some hosts makes zsh/zle and autosuggestions +// reprint typed characters. +func loginPtyModes(sudo bool) ssh.TerminalModes { + echo := uint32(1) + echoCtl := uint32(1) + if sudo { + echo = 0 + echoCtl = 0 + } + return ssh.TerminalModes{ + ssh.VINTR: 3, + ssh.VQUIT: 28, + ssh.VERASE: 127, + ssh.VKILL: 21, + ssh.VEOF: 4, + ssh.VSTART: 17, + ssh.VSTOP: 19, + ssh.VSUSP: 26, + ssh.IGNPAR: 0, + ssh.INLCR: 0, + ssh.IGNCR: 0, + ssh.ICRNL: 1, + ssh.IUCLC: 0, + ssh.IXON: 1, + ssh.IXANY: 1, + ssh.IXOFF: 0, + ssh.IMAXBEL: 1, + ssh.IUTF8: 1, + ssh.ISIG: 1, + ssh.ICANON: 1, + ssh.ECHO: echo, + ssh.ECHOE: 1, + ssh.ECHOK: 1, + ssh.ECHONL: 0, + ssh.IEXTEN: 1, + ssh.ECHOCTL: echoCtl, + ssh.ECHOKE: 1, + ssh.OPOST: 1, + ssh.ONLCR: 1, + ssh.OCRNL: 0, + ssh.CS8: 1, + ssh.PARENB: 0, + ssh.TTY_OP_ISPEED: 14400, + ssh.TTY_OP_OSPEED: 14400, + } +} + +func loginEnvVars() [][2]string { + keys := []string{"LANG", "LC_ALL", "LC_CTYPE", "COLORTERM"} + out := make([][2]string, 0, len(keys)) + for _, key := range keys { + if val := os.Getenv(key); val != "" { + out = append(out, [2]string{key, val}) + } + } + return out +} diff --git a/internal/sshclient/login_test.go b/internal/sshclient/login_test.go index c6a10c6..92113ff 100644 --- a/internal/sshclient/login_test.go +++ b/internal/sshclient/login_test.go @@ -32,6 +32,72 @@ func TestLoginRequiresConnection(t *testing.T) { } } +func TestLoginPtyModesInteractiveLooksLikeOpenSSH(t *testing.T) { + modes := loginPtyModes(false) + want := map[uint8]uint32{ + ssh.ECHO: 1, + ssh.ECHOCTL: 1, + ssh.IUTF8: 1, + ssh.ICRNL: 1, + ssh.ONLCR: 1, + ssh.ICANON: 1, + ssh.ISIG: 1, + ssh.OPOST: 1, + ssh.CS8: 1, + ssh.VERASE: 127, + ssh.VINTR: 3, + } + for key, val := range want { + if modes[key] != val { + t.Fatalf("loginPtyModes(false)[%d] = %d, want %d", key, modes[key], val) + } + } +} + +func TestLoginPtyModesSudoDisablesEchoKeepsUTF8(t *testing.T) { + modes := loginPtyModes(true) + if modes[ssh.ECHO] != 0 || modes[ssh.ECHOCTL] != 0 { + t.Fatalf("sudo login PTY must start with echo off, got ECHO=%d ECHOCTL=%d", + modes[ssh.ECHO], modes[ssh.ECHOCTL]) + } + if modes[ssh.IUTF8] != 1 || modes[ssh.ONLCR] != 1 { + t.Fatalf("sudo login PTY must still be a UTF-8 cooked terminal, got IUTF8=%d ONLCR=%d", + modes[ssh.IUTF8], modes[ssh.ONLCR]) + } +} + +func TestLoginTermNameUsesTERM(t *testing.T) { + t.Setenv("TERM", "xterm-ghostty") + if got := loginTermName(); got != "xterm-ghostty" { + t.Fatalf("loginTermName() = %q, want xterm-ghostty", got) + } + t.Setenv("TERM", "") + if got := loginTermName(); got != "xterm-256color" { + t.Fatalf("loginTermName() = %q, want xterm-256color", got) + } +} + +func TestLoginEnvVarsForwardsLocale(t *testing.T) { + t.Setenv("LANG", "en_US.UTF-8") + t.Setenv("LC_ALL", "") + t.Setenv("LC_CTYPE", "C.UTF-8") + t.Setenv("COLORTERM", "truecolor") + got := loginEnvVars() + want := [][2]string{ + {"LANG", "en_US.UTF-8"}, + {"LC_CTYPE", "C.UTF-8"}, + {"COLORTERM", "truecolor"}, + } + if len(got) != len(want) { + t.Fatalf("loginEnvVars() = %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("loginEnvVars()[%d] = %v, want %v", i, got[i], want[i]) + } + } +} + func TestLoginWithoutTTY(t *testing.T) { if !InteractiveLoginSupported() { t.Skip("login session is not implemented on this platform") diff --git a/internal/sshclient/login_unix.go b/internal/sshclient/login_unix.go index 88e647c..012b84d 100644 --- a/internal/sshclient/login_unix.go +++ b/internal/sshclient/login_unix.go @@ -28,24 +28,10 @@ func (c *SSHClient) loginSession() error { fd := int(os.Stdin.Fd()) width, height := terminalSize(fd) - termName := os.Getenv("TERM") - if termName == "" { - termName = "xterm-256color" - } - - echo := uint32(1) - if c.config.LoginUseSudo { - echo = 0 - } - modes := ssh.TerminalModes{ - ssh.ECHO: echo, - ssh.ECHOCTL: 0, - ssh.TTY_OP_ISPEED: 14400, - ssh.TTY_OP_OSPEED: 14400, - } - if ptyErr := session.RequestPty(termName, height, width, modes); ptyErr != nil { + if ptyErr := session.RequestPty(loginTermName(), height, width, loginPtyModes(c.config.LoginUseSudo)); ptyErr != nil { return fmt.Errorf("failed to request login PTY: %w", ptyErr) } + forwardLoginEnv(session) stdin, err := session.StdinPipe() if err != nil { @@ -86,6 +72,12 @@ func (c *SSHClient) loginSession() error { return waitErr } +func forwardLoginEnv(session *ssh.Session) { + for _, kv := range loginEnvVars() { + _ = session.Setenv(kv[0], kv[1]) //nolint:errcheck // sshd may reject env; the session must still start + } +} + func terminalSize(fd int) (width, height int) { width, height, err := term.GetSize(fd) if err != nil || width <= 0 || height <= 0 {