From 9b02a59c4e5dbf98ebc28092b3cfc368fb93f21b Mon Sep 17 00:00:00 2001 From: zhaolei Date: Tue, 14 Apr 2026 11:11:51 +0800 Subject: [PATCH 1/2] Add support for RemoteCommand option in kitten ssh --- kittens/ssh/main.go | 35 ++++++++++++++++++++++++++---- kittens/ssh/utils.go | 35 ++++++++++++++++++++++++++++++ shell-integration/ssh/bootstrap.py | 5 +++++ shell-integration/ssh/bootstrap.sh | 2 ++ 4 files changed, 73 insertions(+), 4 deletions(-) diff --git a/kittens/ssh/main.go b/kittens/ssh/main.go index 4178b2a3f..729ebdd10 100644 --- a/kittens/ssh/main.go +++ b/kittens/ssh/main.go @@ -10,7 +10,6 @@ import ( "encoding/json" "errors" "fmt" - "github.com/kovidgoyal/kitty" "io" "io/fs" "maps" @@ -28,6 +27,8 @@ import ( "syscall" "time" + "github.com/kovidgoyal/kitty" + "github.com/kovidgoyal/go-shm" "github.com/kovidgoyal/kitty/tools/cli" "github.com/kovidgoyal/kitty/tools/themes" @@ -177,6 +178,7 @@ func set_askpass(hostname_for_match, uname string, overrides []string) (need_to_ type connection_data struct { remote_args []string host_opts *Config + ssh_config *SSHConfig hostname_for_match string username string echo_on bool @@ -408,6 +410,18 @@ func prepare_exec_cmd(cd *connection_data) string { return "unset KITTY_SHELL_INTEGRATION; exec \"$login_shell\" -c '" + strings.Join(args, " ") + "'" } +func prepare_remote_cmd(cd *connection_data) string { + if cd.ssh_config == nil || cd.ssh_config.RemoteCommand == "" { + return "" + } + + remote_command := cd.ssh_config.RemoteCommand + if cd.script_type == "py" { + return base64.RawStdEncoding.EncodeToString(utils.UnsafeStringToBytes(remote_command)) + } + return remote_command +} + var data_shm shm.MMap func prepare_script(script string, replacements map[string]string) string { @@ -417,6 +431,9 @@ func prepare_script(script string, replacements map[string]string) string { if _, found := replacements["EXPORT_HOME_CMD"]; !found { replacements["EXPORT_HOME_CMD"] = "" } + if _, found := replacements["REMOTE_CMD"]; !found { + replacements["REMOTE_CMD"] = "" + } keys := utils.Keys(replacements) for i, key := range keys { keys[i] = "\\b" + key + "\\b" @@ -434,6 +451,8 @@ func bootstrap_script(cd *connection_data) (err error) { if len(cd.remote_args) > 0 { exec_cmd = prepare_exec_cmd(cd) } + remote_cmd := prepare_remote_cmd(cd) + pw, err := secrets.TokenHex() if err != nil { return err @@ -467,6 +486,7 @@ func bootstrap_script(cd *connection_data) (err error) { replacements := map[string]string{ "EXPORT_HOME_CMD": export_home_cmd, "EXEC_CMD": exec_cmd, + "REMOTE_CMD": remote_cmd, "TEST_SCRIPT": cd.test_script, } add_bool := func(ok bool, key string) { @@ -600,7 +620,7 @@ func change_colors(color_scheme string) (ans string, err error) { return } -func run_ssh(ssh_args, server_args, found_extra_args []string) (rc int, err error) { +func run_ssh(ssh_args, server_args, found_extra_args []string, ssh_config *SSHConfig) (rc int, err error) { go shell_integration.Data() go RelevantKittyOpts() defer func() { @@ -610,11 +630,14 @@ func run_ssh(ssh_args, server_args, found_extra_args []string) (rc int, err erro } }() cmd := append([]string{SSHExe()}, ssh_args...) - cd := connection_data{remote_args: server_args[1:]} + cd := connection_data{remote_args: server_args[1:], ssh_config: ssh_config} hostname := server_args[0] if len(cd.remote_args) == 0 { cmd = append(cmd, "-t") } + if cd.ssh_config != nil && cd.ssh_config.RemoteCommand != "" { + cmd = append(cmd, "-o", "RemoteCommand=none") + } insertion_point := len(cmd) cmd = append(cmd, "--", hostname) uname, hostname_for_match := get_destination(hostname) @@ -833,13 +856,17 @@ func main(cmd *cli.Command, o *Options, args []string) (rc int, err error) { if passthrough { return 1, unix.Exec(SSHExe(), utils.Concat([]string{"ssh"}, ssh_args, server_args), os.Environ()) } + ssh_config, err := LoadSSHConfig(server_args[0]) + if err != nil { + return 1, err + } if os.Getenv("KITTY_WINDOW_ID") == "" || os.Getenv("KITTY_PID") == "" { return 1, fmt.Errorf("The SSH kitten is meant to run inside a kitty window") } if !tty.IsTerminal(os.Stdin.Fd()) { return 1, fmt.Errorf("The SSH kitten is meant for interactive use only, STDIN must be a terminal") } - return run_ssh(ssh_args, server_args, found_extra_args) + return run_ssh(ssh_args, server_args, found_extra_args, ssh_config) } func EntryPoint(parent *cli.Command) { diff --git a/kittens/ssh/utils.go b/kittens/ssh/utils.go index 442537a18..f5c7a89f7 100644 --- a/kittens/ssh/utils.go +++ b/kittens/ssh/utils.go @@ -3,6 +3,7 @@ package ssh import ( + "bufio" "bytes" "fmt" "os/exec" @@ -194,6 +195,40 @@ func ParseSSHArgs(args []string, extra_args ...string) (ssh_args []string, serve return } +type SSHConfig struct { + RemoteCommand string +} + +func LoadSSHConfig(hostname string) (config *SSHConfig, err error) { + cmd_args := []string{SSHExe(), hostname, "-G"} + cmd := exec.Command(cmd_args[0], cmd_args[1:]...) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + _ = cmd.Run() + + text := stdout.String() + scanner := bufio.NewScanner(strings.NewReader(text)) + + config = &SSHConfig{} + for scanner.Scan() { + line := scanner.Text() + i := strings.IndexByte(line, ' ') + if i <= 0 { + continue + } + + key, val := line[:i], line[i+1:] + switch key { + case "remotecommand": + if val != "none" { + config.RemoteCommand = val + } + } + } + return config, nil +} + type SSHVersion struct{ Major, Minor int } func (self SSHVersion) SupportsAskpassRequire() bool { diff --git a/shell-integration/ssh/bootstrap.py b/shell-integration/ssh/bootstrap.py index f21119d67..618f7fdc9 100644 --- a/shell-integration/ssh/bootstrap.py +++ b/shell-integration/ssh/bootstrap.py @@ -321,6 +321,11 @@ def main(): os.environ.pop('KITTY_SHELL_INTEGRATION', None) cmd = base64.standard_b64decode(exec_cmd).decode('utf-8') exec_with_better_error(login_shell, os.path.basename(login_shell), '-c', cmd) + remote_cmd = b'REMOTE_CMD' + if remote_cmd: + os.environ.pop('KITTY_SHELL_INTEGRATION', None) + cmd = base64.standard_b64decode(remote_cmd).decode('utf-8') + exec_with_better_error(login_shell, os.path.basename(login_shell), '-c', cmd) TEST_SCRIPT # noqa if ksi and 'no-rc' not in ksi: exec_with_shell_integration() diff --git a/shell-integration/ssh/bootstrap.sh b/shell-integration/ssh/bootstrap.sh index 1a272020a..c907caf37 100644 --- a/shell-integration/ssh/bootstrap.sh +++ b/shell-integration/ssh/bootstrap.sh @@ -158,6 +158,8 @@ prepare_for_exec # If a command was passed to SSH execute it here EXEC_CMD +REMOTE_CMD + # Used in the tests TEST_SCRIPT From b86367483f09546700a0ef94b689f682e116ecc9 Mon Sep 17 00:00:00 2001 From: zhaolei Date: Tue, 14 Apr 2026 14:29:18 +0800 Subject: [PATCH 2/2] Asynchronously read ssh configuration --- kittens/ssh/main.go | 25 ++++++++++------- kittens/ssh/utils.go | 64 +++++++++++++++++++++++++++++--------------- 2 files changed, 57 insertions(+), 32 deletions(-) diff --git a/kittens/ssh/main.go b/kittens/ssh/main.go index 729ebdd10..f1d9c4892 100644 --- a/kittens/ssh/main.go +++ b/kittens/ssh/main.go @@ -6,6 +6,7 @@ import ( "archive/tar" "bytes" "compress/gzip" + "context" "encoding/base64" "encoding/json" "errors" @@ -620,7 +621,7 @@ func change_colors(color_scheme string) (ans string, err error) { return } -func run_ssh(ssh_args, server_args, found_extra_args []string, ssh_config *SSHConfig) (rc int, err error) { +func run_ssh(ssh_args, server_args, found_extra_args []string, ssh_config_channel <-chan *SSHConfig) (rc int, err error) { go shell_integration.Data() go RelevantKittyOpts() defer func() { @@ -630,14 +631,11 @@ func run_ssh(ssh_args, server_args, found_extra_args []string, ssh_config *SSHCo } }() cmd := append([]string{SSHExe()}, ssh_args...) - cd := connection_data{remote_args: server_args[1:], ssh_config: ssh_config} + cd := connection_data{remote_args: server_args[1:]} hostname := server_args[0] if len(cd.remote_args) == 0 { cmd = append(cmd, "-t") } - if cd.ssh_config != nil && cd.ssh_config.RemoteCommand != "" { - cmd = append(cmd, "-o", "RemoteCommand=none") - } insertion_point := len(cmd) cmd = append(cmd, "--", hostname) uname, hostname_for_match := get_destination(hostname) @@ -780,6 +778,12 @@ func run_ssh(ssh_args, server_args, found_extra_args []string, ssh_config *SSHCo } } defer cleanup() + // Receive ssh config + ssh_config := <-ssh_config_channel + if ssh_config != nil && ssh_config.RemoteCommand != "" { + cmd = slices.Insert(cmd, insertion_point, "-o", "RemoteCommand=none") + } + cd.ssh_config = ssh_config err = get_remote_command(&cd) if err != nil { return 1, err @@ -856,17 +860,18 @@ func main(cmd *cli.Command, o *Options, args []string) (rc int, err error) { if passthrough { return 1, unix.Exec(SSHExe(), utils.Concat([]string{"ssh"}, ssh_args, server_args), os.Environ()) } - ssh_config, err := LoadSSHConfig(server_args[0]) - if err != nil { - return 1, err - } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + ssh_config_channel := ReadSSHConfig(ctx, server_args[0]) + if os.Getenv("KITTY_WINDOW_ID") == "" || os.Getenv("KITTY_PID") == "" { return 1, fmt.Errorf("The SSH kitten is meant to run inside a kitty window") } if !tty.IsTerminal(os.Stdin.Fd()) { return 1, fmt.Errorf("The SSH kitten is meant for interactive use only, STDIN must be a terminal") } - return run_ssh(ssh_args, server_args, found_extra_args, ssh_config) + return run_ssh(ssh_args, server_args, found_extra_args, ssh_config_channel) } func EntryPoint(parent *cli.Command) { diff --git a/kittens/ssh/utils.go b/kittens/ssh/utils.go index f5c7a89f7..f7bf674af 100644 --- a/kittens/ssh/utils.go +++ b/kittens/ssh/utils.go @@ -5,6 +5,7 @@ package ssh import ( "bufio" "bytes" + "context" "fmt" "os/exec" "regexp" @@ -199,34 +200,53 @@ type SSHConfig struct { RemoteCommand string } -func LoadSSHConfig(hostname string) (config *SSHConfig, err error) { - cmd_args := []string{SSHExe(), hostname, "-G"} - cmd := exec.Command(cmd_args[0], cmd_args[1:]...) - var stdout, stderr bytes.Buffer - cmd.Stdout = &stdout - cmd.Stderr = &stderr - _ = cmd.Run() +// ReadSSHConfig Asynchronously read ssh configuration +func ReadSSHConfig(ctx context.Context, hostname string) <-chan *SSHConfig { + ch := make(chan *SSHConfig, 1) - text := stdout.String() - scanner := bufio.NewScanner(strings.NewReader(text)) + go func() { + defer close(ch) - config = &SSHConfig{} - for scanner.Scan() { - line := scanner.Text() - i := strings.IndexByte(line, ' ') - if i <= 0 { - continue + cmd_args := []string{SSHExe(), hostname, "-G"} + cmd := exec.CommandContext(ctx, cmd_args[0], cmd_args[1:]...) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + return } - key, val := line[:i], line[i+1:] - switch key { - case "remotecommand": - if val != "none" { - config.RemoteCommand = val + text := stdout.String() + scanner := bufio.NewScanner(strings.NewReader(text)) + + config := &SSHConfig{} + for scanner.Scan() { + select { + case <-ctx.Done(): + return + default: + } + line := scanner.Text() + i := strings.IndexByte(line, ' ') + if i <= 0 { + continue + } + + key, val := line[:i], line[i+1:] + switch key { + case "remotecommand": + if val != "none" { + config.RemoteCommand = val + } } } - } - return config, nil + select { + case <-ctx.Done(): + return + case ch <- config: + } + }() + return ch } type SSHVersion struct{ Major, Minor int }