Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
32 changes: 32 additions & 0 deletions docs/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
25 changes: 25 additions & 0 deletions docs/zh/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
68 changes: 68 additions & 0 deletions internal/sshclient/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"errors"
"os"

"golang.org/x/crypto/ssh"
"golang.org/x/term"
)

Expand Down Expand Up @@ -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
}
66 changes: 66 additions & 0 deletions internal/sshclient/login_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
24 changes: 8 additions & 16 deletions internal/sshclient/login_unix.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
Loading