From d7a42836d70e7a1ba52a56592e0da0927222f09d Mon Sep 17 00:00:00 2001 From: Bartek Mucha Date: Fri, 1 Aug 2025 19:41:27 +0100 Subject: [PATCH 1/4] fix: properly support multi-word SSH commands Signed-off-by: Bartek Mucha --- cmd/limactl/copy.go | 6 ++--- cmd/limactl/shell.go | 9 ++++---- cmd/limactl/show-ssh.go | 2 +- cmd/limactl/tunnel.go | 7 +++--- pkg/hostagent/hostagent.go | 2 +- pkg/sshutil/sshutil.go | 46 +++++++++++++++++++++++--------------- 6 files changed, 42 insertions(+), 30 deletions(-) diff --git a/cmd/limactl/copy.go b/cmd/limactl/copy.go index 6204d158969..d4476899777 100644 --- a/cmd/limactl/copy.go +++ b/cmd/limactl/copy.go @@ -84,7 +84,7 @@ func copyAction(cmd *cobra.Command, args []string) error { scpFlags = append(scpFlags, "-r") } // this assumes that ssh and scp come from the same place, but scp has no -V - legacySSH := sshutil.DetectOpenSSHVersion("ssh").LessThan(*semver.New("8.0.0")) + legacySSH := sshutil.DetectOpenSSHVersion(sshutil.SSHExe{Executable: "ssh"}).LessThan(*semver.New("8.0.0")) for _, arg := range args { if runtime.GOOS == "windows" { if filepath.IsAbs(arg) { @@ -135,14 +135,14 @@ func copyAction(cmd *cobra.Command, args []string) error { // arguments such as ControlPath. This is preferred as we can multiplex // sessions without re-authenticating (MaxSessions permitting). for _, inst := range instances { - sshOpts, err = sshutil.SSHOpts("ssh", inst.Dir, *inst.Config.User.Name, false, false, false, false) + sshOpts, err = sshutil.SSHOpts(sshutil.SSHExe{Executable: "ssh"}, inst.Dir, *inst.Config.User.Name, false, false, false, false) if err != nil { return err } } } else { // Copying among multiple hosts; we can't pass in host-specific options. - sshOpts, err = sshutil.CommonOpts("ssh", false) + sshOpts, err = sshutil.CommonOpts(sshutil.SSHExe{Executable: "ssh"}, false) if err != nil { return err } diff --git a/cmd/limactl/shell.go b/cmd/limactl/shell.go index a49fe072aa9..671cf5d08e3 100644 --- a/cmd/limactl/shell.go +++ b/cmd/limactl/shell.go @@ -196,13 +196,13 @@ func shellAction(cmd *cobra.Command, args []string) error { ) } - arg0, arg0Args, err := sshutil.SSHArguments() + sshExe, err := sshutil.SSHArguments() if err != nil { return err } sshOpts, err := sshutil.SSHOpts( - arg0, + sshExe, inst.Dir, *inst.Config.User.Name, *inst.Config.SSH.LoadDotSSHPubKeys, @@ -224,7 +224,7 @@ func shellAction(cmd *cobra.Command, args []string) error { logLevel := "ERROR" // For versions older than OpenSSH 8.9p, LogLevel=QUIET was needed to // avoid the "Shared connection to 127.0.0.1 closed." message with -t. - olderSSH := sshutil.DetectOpenSSHVersion(arg0).LessThan(*semver.New("8.9.0")) + olderSSH := sshutil.DetectOpenSSHVersion(sshExe).LessThan(*semver.New("8.9.0")) if olderSSH { logLevel = "QUIET" } @@ -235,7 +235,8 @@ func shellAction(cmd *cobra.Command, args []string) error { "--", script, }...) - sshCmd := exec.Command(arg0, append(arg0Args, sshArgs...)...) + allArgs := append(sshExe.Args, sshArgs...) + sshCmd := exec.Command(sshExe.Executable, allArgs...) sshCmd.Stdin = os.Stdin sshCmd.Stdout = os.Stdout sshCmd.Stderr = os.Stderr diff --git a/cmd/limactl/show-ssh.go b/cmd/limactl/show-ssh.go index eac462ae1f7..58ca6fd74d6 100644 --- a/cmd/limactl/show-ssh.go +++ b/cmd/limactl/show-ssh.go @@ -93,7 +93,7 @@ func showSSHAction(cmd *cobra.Command, args []string) error { logrus.Warnf("`limactl show-ssh` is deprecated. Instead, use `ssh -F %s %s`.", filepath.Join(inst.Dir, filenames.SSHConfig), inst.Hostname) opts, err := sshutil.SSHOpts( - "ssh", + sshutil.SSHExe{Executable: "ssh"}, inst.Dir, *inst.Config.User.Name, *inst.Config.SSH.LoadDotSSHPubKeys, diff --git a/cmd/limactl/tunnel.go b/cmd/limactl/tunnel.go index 189e8f61b8f..129afab7725 100644 --- a/cmd/limactl/tunnel.go +++ b/cmd/limactl/tunnel.go @@ -82,13 +82,13 @@ func tunnelAction(cmd *cobra.Command, args []string) error { } } - arg0, arg0Args, err := sshutil.SSHArguments() + sshExe, err := sshutil.SSHArguments() if err != nil { return err } sshOpts, err := sshutil.SSHOpts( - arg0, + sshExe, inst.Dir, *inst.Config.User.Name, *inst.Config.SSH.LoadDotSSHPubKeys, @@ -107,7 +107,8 @@ func tunnelAction(cmd *cobra.Command, args []string) error { "-p", strconv.Itoa(inst.SSHLocalPort), inst.SSHAddress, }...) - sshCmd := exec.Command(arg0, append(arg0Args, sshArgs...)...) + allArgs := append(sshExe.Args, sshArgs...) + sshCmd := exec.Command(sshExe.Executable, allArgs...) sshCmd.Stdout = stderr sshCmd.Stderr = stderr logrus.Debugf("executing ssh (may take a long)): %+v", sshCmd.Args) diff --git a/pkg/hostagent/hostagent.go b/pkg/hostagent/hostagent.go index 98d0922a0bd..7982abe7e02 100644 --- a/pkg/hostagent/hostagent.go +++ b/pkg/hostagent/hostagent.go @@ -147,7 +147,7 @@ func New(instName string, stdout io.Writer, signalCh chan os.Signal, opts ...Opt } sshOpts, err := sshutil.SSHOpts( - "ssh", + sshutil.SSHExe{Executable: "ssh"}, inst.Dir, *inst.Config.User.Name, *inst.Config.SSH.LoadDotSSHPubKeys, diff --git a/pkg/sshutil/sshutil.go b/pkg/sshutil/sshutil.go index 302c9a5035f..67b1e59de4e 100644 --- a/pkg/sshutil/sshutil.go +++ b/pkg/sshutil/sshutil.go @@ -35,7 +35,14 @@ import ( // in place of the 'ssh' executable. const EnvShellSSH = "SSH" -func SSHArguments() (arg0 string, arg0Args []string, err error) { +type SSHExe struct { + Executable string + Args []string +} + +func SSHArguments() (SSHExe, error) { + var sshExe SSHExe + if sshShell := os.Getenv(EnvShellSSH); sshShell != "" { sshShellFields, err := shellwords.Parse(sshShell) switch { @@ -43,21 +50,21 @@ func SSHArguments() (arg0 string, arg0Args []string, err error) { logrus.WithError(err).Warnf("Failed to split %s variable into shell tokens. "+ "Falling back to 'ssh' command", EnvShellSSH) case len(sshShellFields) > 0: - arg0 = sshShellFields[0] + sshExe.Executable = sshShellFields[0] if len(sshShellFields) > 1 { - arg0Args = sshShellFields[1:] + sshExe.Args = sshShellFields[1:] } + return sshExe, nil } } - if arg0 == "" { - arg0, err = exec.LookPath("ssh") - if err != nil { - return "", []string{""}, err - } + executable, err := exec.LookPath("ssh") + if err != nil { + return SSHExe{}, err } + sshExe.Executable = executable - return arg0, arg0Args, nil + return sshExe, nil } type PubKey struct { @@ -177,7 +184,7 @@ var sshInfo struct { // // The result always contains the IdentityFile option. // The result never contains the Port option. -func CommonOpts(sshPath string, useDotSSH bool) ([]string, error) { +func CommonOpts(sshExe SSHExe, useDotSSH bool) ([]string, error) { configDir, err := dirnames.LimaConfigDir() if err != nil { return nil, err @@ -243,7 +250,7 @@ func CommonOpts(sshPath string, useDotSSH bool) ([]string, error) { sshInfo.Do(func() { sshInfo.aesAccelerated = detectAESAcceleration() - sshInfo.openSSH = detectOpenSSHInfo(sshPath) + sshInfo.openSSH = detectOpenSSHInfo(sshExe) }) if sshInfo.openSSH.GSSAPISupported { @@ -287,12 +294,12 @@ func identityFileEntry(privateKeyPath string) (string, error) { } // SSHOpts adds the following options to CommonOptions: User, ControlMaster, ControlPath, ControlPersist. -func SSHOpts(sshPath, instDir, username string, useDotSSH, forwardAgent, forwardX11, forwardX11Trusted bool) ([]string, error) { +func SSHOpts(sshExe SSHExe, instDir, username string, useDotSSH, forwardAgent, forwardX11, forwardX11Trusted bool) ([]string, error) { controlSock := filepath.Join(instDir, filenames.SSHSock) if len(controlSock) >= osutil.UnixPathMax { return nil, fmt.Errorf("socket path %q is too long: >= UNIX_PATH_MAX=%d", controlSock, osutil.UnixPathMax) } - opts, err := CommonOpts(sshPath, useDotSSH) + opts, err := CommonOpts(sshExe, useDotSSH) if err != nil { return nil, err } @@ -361,13 +368,15 @@ var ( openSSHInfosRW sync.RWMutex ) -func detectOpenSSHInfo(ssh string) openSSHInfo { +func detectOpenSSHInfo(sshExe SSHExe) openSSHInfo { var ( info openSSHInfo exe sshExecutable stderr bytes.Buffer ) - path, err := exec.LookPath(ssh) + // TODO: Fix this function to properly handle complex SSH commands like "kitten ssh" + // The current LookPath, os.Stat, and caching logic doesn't work well for multi-word commands + path, err := exec.LookPath(sshExe.Executable) if err != nil { logrus.Warnf("failed to find ssh executable: %v", err) } else { @@ -381,7 +390,8 @@ func detectOpenSSHInfo(ssh string) openSSHInfo { } } // -V should be last - cmd := exec.Command(path, "-o", "GSSAPIAuthentication=no", "-V") + allArgs := append(sshExe.Args, "-o", "GSSAPIAuthentication=no", "-V") + cmd := exec.Command(sshExe.Executable, allArgs...) cmd.Stderr = &stderr if err := cmd.Run(); err != nil { logrus.Warnf("failed to run %v: stderr=%q", cmd.Args, stderr.String()) @@ -398,8 +408,8 @@ func detectOpenSSHInfo(ssh string) openSSHInfo { return info } -func DetectOpenSSHVersion(ssh string) semver.Version { - return detectOpenSSHInfo(ssh).Version +func DetectOpenSSHVersion(sshExe SSHExe) semver.Version { + return detectOpenSSHInfo(sshExe).Version } // detectValidPublicKey returns whether content represent a public key. From a0e38e6f71595b3b58c18bc4bdad62ead918dcf8 Mon Sep 17 00:00:00 2001 From: Bartek Mucha Date: Fri, 1 Aug 2025 20:13:49 +0100 Subject: [PATCH 2/4] refactor: centralize SSH executable creation Signed-off-by: Bartek Mucha --- cmd/limactl/copy.go | 18 +++++++++++++++--- cmd/limactl/shell.go | 4 ++-- cmd/limactl/show-ssh.go | 6 +++++- cmd/limactl/tunnel.go | 4 ++-- pkg/hostagent/hostagent.go | 6 +++++- pkg/sshutil/sshutil.go | 14 +++++++------- 6 files changed, 36 insertions(+), 16 deletions(-) diff --git a/cmd/limactl/copy.go b/cmd/limactl/copy.go index d4476899777..337a10d092a 100644 --- a/cmd/limactl/copy.go +++ b/cmd/limactl/copy.go @@ -84,7 +84,11 @@ func copyAction(cmd *cobra.Command, args []string) error { scpFlags = append(scpFlags, "-r") } // this assumes that ssh and scp come from the same place, but scp has no -V - legacySSH := sshutil.DetectOpenSSHVersion(sshutil.SSHExe{Executable: "ssh"}).LessThan(*semver.New("8.0.0")) + sshExe, err := sshutil.NewSSHExe() + if err != nil { + return err + } + legacySSH := sshutil.DetectOpenSSHVersion(sshExe).LessThan(*semver.New("8.0.0")) for _, arg := range args { if runtime.GOOS == "windows" { if filepath.IsAbs(arg) { @@ -135,14 +139,22 @@ func copyAction(cmd *cobra.Command, args []string) error { // arguments such as ControlPath. This is preferred as we can multiplex // sessions without re-authenticating (MaxSessions permitting). for _, inst := range instances { - sshOpts, err = sshutil.SSHOpts(sshutil.SSHExe{Executable: "ssh"}, inst.Dir, *inst.Config.User.Name, false, false, false, false) + sshExe, err := sshutil.NewSSHExe() + if err != nil { + return err + } + sshOpts, err = sshutil.SSHOpts(sshExe, inst.Dir, *inst.Config.User.Name, false, false, false, false) if err != nil { return err } } } else { // Copying among multiple hosts; we can't pass in host-specific options. - sshOpts, err = sshutil.CommonOpts(sshutil.SSHExe{Executable: "ssh"}, false) + sshExe, err := sshutil.NewSSHExe() + if err != nil { + return err + } + sshOpts, err = sshutil.CommonOpts(sshExe, false) if err != nil { return err } diff --git a/cmd/limactl/shell.go b/cmd/limactl/shell.go index 671cf5d08e3..ef34261d78a 100644 --- a/cmd/limactl/shell.go +++ b/cmd/limactl/shell.go @@ -196,7 +196,7 @@ func shellAction(cmd *cobra.Command, args []string) error { ) } - sshExe, err := sshutil.SSHArguments() + sshExe, err := sshutil.NewSSHExe() if err != nil { return err } @@ -236,7 +236,7 @@ func shellAction(cmd *cobra.Command, args []string) error { script, }...) allArgs := append(sshExe.Args, sshArgs...) - sshCmd := exec.Command(sshExe.Executable, allArgs...) + sshCmd := exec.Command(sshExe.Exe, allArgs...) sshCmd.Stdin = os.Stdin sshCmd.Stdout = os.Stdout sshCmd.Stderr = os.Stderr diff --git a/cmd/limactl/show-ssh.go b/cmd/limactl/show-ssh.go index 58ca6fd74d6..4fcda4beb6f 100644 --- a/cmd/limactl/show-ssh.go +++ b/cmd/limactl/show-ssh.go @@ -92,8 +92,12 @@ func showSSHAction(cmd *cobra.Command, args []string) error { } logrus.Warnf("`limactl show-ssh` is deprecated. Instead, use `ssh -F %s %s`.", filepath.Join(inst.Dir, filenames.SSHConfig), inst.Hostname) + sshExe, err := sshutil.NewSSHExe() + if err != nil { + return err + } opts, err := sshutil.SSHOpts( - sshutil.SSHExe{Executable: "ssh"}, + sshExe, inst.Dir, *inst.Config.User.Name, *inst.Config.SSH.LoadDotSSHPubKeys, diff --git a/cmd/limactl/tunnel.go b/cmd/limactl/tunnel.go index 129afab7725..54286a2cb79 100644 --- a/cmd/limactl/tunnel.go +++ b/cmd/limactl/tunnel.go @@ -82,7 +82,7 @@ func tunnelAction(cmd *cobra.Command, args []string) error { } } - sshExe, err := sshutil.SSHArguments() + sshExe, err := sshutil.NewSSHExe() if err != nil { return err } @@ -108,7 +108,7 @@ func tunnelAction(cmd *cobra.Command, args []string) error { inst.SSHAddress, }...) allArgs := append(sshExe.Args, sshArgs...) - sshCmd := exec.Command(sshExe.Executable, allArgs...) + sshCmd := exec.Command(sshExe.Exe, allArgs...) sshCmd.Stdout = stderr sshCmd.Stderr = stderr logrus.Debugf("executing ssh (may take a long)): %+v", sshCmd.Args) diff --git a/pkg/hostagent/hostagent.go b/pkg/hostagent/hostagent.go index 7982abe7e02..0dfe9f0abe4 100644 --- a/pkg/hostagent/hostagent.go +++ b/pkg/hostagent/hostagent.go @@ -146,8 +146,12 @@ func New(instName string, stdout io.Writer, signalCh chan os.Signal, opts ...Opt return nil, err } + sshExe, err := sshutil.NewSSHExe() + if err != nil { + return nil, err + } sshOpts, err := sshutil.SSHOpts( - sshutil.SSHExe{Executable: "ssh"}, + sshExe, inst.Dir, *inst.Config.User.Name, *inst.Config.SSH.LoadDotSSHPubKeys, diff --git a/pkg/sshutil/sshutil.go b/pkg/sshutil/sshutil.go index 67b1e59de4e..cf219c7241b 100644 --- a/pkg/sshutil/sshutil.go +++ b/pkg/sshutil/sshutil.go @@ -36,11 +36,11 @@ import ( const EnvShellSSH = "SSH" type SSHExe struct { - Executable string - Args []string + Exe string + Args []string } -func SSHArguments() (SSHExe, error) { +func NewSSHExe() (SSHExe, error) { var sshExe SSHExe if sshShell := os.Getenv(EnvShellSSH); sshShell != "" { @@ -50,7 +50,7 @@ func SSHArguments() (SSHExe, error) { logrus.WithError(err).Warnf("Failed to split %s variable into shell tokens. "+ "Falling back to 'ssh' command", EnvShellSSH) case len(sshShellFields) > 0: - sshExe.Executable = sshShellFields[0] + sshExe.Exe = sshShellFields[0] if len(sshShellFields) > 1 { sshExe.Args = sshShellFields[1:] } @@ -62,7 +62,7 @@ func SSHArguments() (SSHExe, error) { if err != nil { return SSHExe{}, err } - sshExe.Executable = executable + sshExe.Exe = executable return sshExe, nil } @@ -376,7 +376,7 @@ func detectOpenSSHInfo(sshExe SSHExe) openSSHInfo { ) // TODO: Fix this function to properly handle complex SSH commands like "kitten ssh" // The current LookPath, os.Stat, and caching logic doesn't work well for multi-word commands - path, err := exec.LookPath(sshExe.Executable) + path, err := exec.LookPath(sshExe.Exe) if err != nil { logrus.Warnf("failed to find ssh executable: %v", err) } else { @@ -391,7 +391,7 @@ func detectOpenSSHInfo(sshExe SSHExe) openSSHInfo { } // -V should be last allArgs := append(sshExe.Args, "-o", "GSSAPIAuthentication=no", "-V") - cmd := exec.Command(sshExe.Executable, allArgs...) + cmd := exec.Command(sshExe.Exe, allArgs...) cmd.Stderr = &stderr if err := cmd.Run(); err != nil { logrus.Warnf("failed to run %v: stderr=%q", cmd.Args, stderr.String()) From 5c3d4670fc383705f5cd6d4ac27aab970aac3437 Mon Sep 17 00:00:00 2001 From: Bartek Mucha Date: Fri, 1 Aug 2025 20:50:20 +0100 Subject: [PATCH 3/4] fix: remove redundant LookPath call in detectOpenSSHInfo Signed-off-by: Bartek Mucha --- pkg/sshutil/sshutil.go | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/pkg/sshutil/sshutil.go b/pkg/sshutil/sshutil.go index cf219c7241b..dacaceaa273 100644 --- a/pkg/sshutil/sshutil.go +++ b/pkg/sshutil/sshutil.go @@ -374,14 +374,12 @@ func detectOpenSSHInfo(sshExe SSHExe) openSSHInfo { exe sshExecutable stderr bytes.Buffer ) - // TODO: Fix this function to properly handle complex SSH commands like "kitten ssh" - // The current LookPath, os.Stat, and caching logic doesn't work well for multi-word commands - path, err := exec.LookPath(sshExe.Exe) - if err != nil { - logrus.Warnf("failed to find ssh executable: %v", err) - } else { - st, _ := os.Stat(path) - exe = sshExecutable{Path: path, Size: st.Size(), ModTime: st.ModTime()} + // Note: For SSH wrappers like "kitten ssh", os.Stat will check the wrapper + // executable (kitten) instead of the underlying ssh binary. This means + // cache invalidation won't work properly - ssh upgrades won't be detected + // since kitten's size/mtime won't change. This is probably acceptable. + if st, err := os.Stat(sshExe.Exe); err == nil { + exe = sshExecutable{Path: sshExe.Exe, Size: st.Size(), ModTime: st.ModTime()} openSSHInfosRW.RLock() info := openSSHInfos[exe] openSSHInfosRW.RUnlock() From 18cb183198866d961c211f9c7a0f0c1c439b48f7 Mon Sep 17 00:00:00 2001 From: Bartek Mucha Date: Fri, 1 Aug 2025 22:53:04 +0100 Subject: [PATCH 4/4] fix: appease linter, ensure no mutation Signed-off-by: Bartek Mucha --- cmd/limactl/shell.go | 6 +++--- cmd/limactl/tunnel.go | 6 +++--- pkg/sshutil/sshutil.go | 5 +++-- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/cmd/limactl/shell.go b/cmd/limactl/shell.go index ef34261d78a..470d3de051e 100644 --- a/cmd/limactl/shell.go +++ b/cmd/limactl/shell.go @@ -212,7 +212,8 @@ func shellAction(cmd *cobra.Command, args []string) error { if err != nil { return err } - sshArgs := sshutil.SSHArgsFromOpts(sshOpts) + sshArgs := append([]string{}, sshExe.Args...) + sshArgs = append(sshArgs, sshutil.SSHArgsFromOpts(sshOpts)...) if isatty.IsTerminal(os.Stdout.Fd()) || isatty.IsCygwinTerminal(os.Stdout.Fd()) { // required for showing the shell prompt: https://stackoverflow.com/a/626574 sshArgs = append(sshArgs, "-t") @@ -235,8 +236,7 @@ func shellAction(cmd *cobra.Command, args []string) error { "--", script, }...) - allArgs := append(sshExe.Args, sshArgs...) - sshCmd := exec.Command(sshExe.Exe, allArgs...) + sshCmd := exec.Command(sshExe.Exe, sshArgs...) sshCmd.Stdin = os.Stdin sshCmd.Stdout = os.Stdout sshCmd.Stderr = os.Stderr diff --git a/cmd/limactl/tunnel.go b/cmd/limactl/tunnel.go index 54286a2cb79..977f33674fc 100644 --- a/cmd/limactl/tunnel.go +++ b/cmd/limactl/tunnel.go @@ -98,7 +98,8 @@ func tunnelAction(cmd *cobra.Command, args []string) error { if err != nil { return err } - sshArgs := sshutil.SSHArgsFromOpts(sshOpts) + sshArgs := append([]string{}, sshExe.Args...) + sshArgs = append(sshArgs, sshutil.SSHArgsFromOpts(sshOpts)...) sshArgs = append(sshArgs, []string{ "-q", // quiet "-f", // background @@ -107,8 +108,7 @@ func tunnelAction(cmd *cobra.Command, args []string) error { "-p", strconv.Itoa(inst.SSHLocalPort), inst.SSHAddress, }...) - allArgs := append(sshExe.Args, sshArgs...) - sshCmd := exec.Command(sshExe.Exe, allArgs...) + sshCmd := exec.Command(sshExe.Exe, sshArgs...) sshCmd.Stdout = stderr sshCmd.Stderr = stderr logrus.Debugf("executing ssh (may take a long)): %+v", sshCmd.Args) diff --git a/pkg/sshutil/sshutil.go b/pkg/sshutil/sshutil.go index dacaceaa273..61bca136ce5 100644 --- a/pkg/sshutil/sshutil.go +++ b/pkg/sshutil/sshutil.go @@ -387,9 +387,10 @@ func detectOpenSSHInfo(sshExe SSHExe) openSSHInfo { return *info } } + sshArgs := append([]string{}, sshExe.Args...) // -V should be last - allArgs := append(sshExe.Args, "-o", "GSSAPIAuthentication=no", "-V") - cmd := exec.Command(sshExe.Exe, allArgs...) + sshArgs = append(sshArgs, "-o", "GSSAPIAuthentication=no", "-V") + cmd := exec.Command(sshExe.Exe, sshArgs...) cmd.Stderr = &stderr if err := cmd.Run(); err != nil { logrus.Warnf("failed to run %v: stderr=%q", cmd.Args, stderr.String())