diff --git a/pkg/remote/conncontroller/conncontroller.go b/pkg/remote/conncontroller/conncontroller.go index a24a789009..93b331a239 100644 --- a/pkg/remote/conncontroller/conncontroller.go +++ b/pkg/remote/conncontroller/conncontroller.go @@ -80,6 +80,7 @@ type SSHConn struct { Status string ConnHealthStatus string WshEnabled *atomic.Bool + WshEnsuring *atomic.Bool Opts *remote.SSHOpts Client *ssh.Client DomainSockName string // if "", then no domain socket @@ -269,13 +270,28 @@ func (conn *SSHConn) GetName() string { func (conn *SSHConn) OpenDomainSocketListener(ctx context.Context) error { conn.Infof(ctx, "running OpenDomainSocketListener...\n") + var existingListener net.Listener + var existingSockName string allowed := WithLockRtn(conn, func() bool { + // If it's already set up, allow callers to reuse it even if the conn is already connected. + if conn.DomainSockListener != nil && conn.DomainSockName != "" { + existingListener = conn.DomainSockListener + existingSockName = conn.DomainSockName + return true + } return conn.Status == Status_Connecting }) if !allowed { return fmt.Errorf("cannot open domain socket for %q when status is %q", conn.GetName(), conn.GetStatus()) } + if existingListener != nil && existingSockName != "" { + conn.Infof(ctx, "domain socket already active (%s)\n", existingSockName) + return nil + } client := conn.GetClient() + if client == nil { + return fmt.Errorf("cannot open domain socket for %q: ssh client is not connected", conn.GetName()) + } randStr, err := utilfn.RandomHexString(16) // 64-bits of randomness if err != nil { return fmt.Errorf("error generating random string: %w", err) @@ -1075,6 +1091,7 @@ func getConnInternal(opts *remote.SSHOpts, createIfNotExists bool) *SSHConn { Status: Status_Init, ConnHealthStatus: ConnHealthStatus_Good, WshEnabled: &atomic.Bool{}, + WshEnsuring: &atomic.Bool{}, Opts: opts, } clientControllerMap[*opts] = rtn @@ -1125,6 +1142,40 @@ func EnsureConnection(ctx context.Context, connName string) error { connStatus := conn.DeriveConnStatus() switch connStatus.Status { case Status_Connected: + // If wsh is enabled for this connection, ensure the connserver route exists. + // This prevents "no route for \"conn:...\"" errors when using remote file browsing after a + // connserver restart/termination. + enableWsh, _ := conn.getConnWshSettings() + if enableWsh { + routeId := wshutil.MakeConnectionRouteId(conn.GetName()) + fastCtx, cancelFn := context.WithTimeout(ctx, 75*time.Millisecond) + fastErr := wshutil.DefaultRouter.WaitForRegister(fastCtx, routeId) + cancelFn() + if fastErr != nil { + // Avoid a thundering herd when multiple blocks ensure concurrently. + if conn.WshEnsuring != nil && !conn.WshEnsuring.CompareAndSwap(false, true) { + waitCtx, cancelWait := context.WithTimeout(ctx, 5*time.Second) + defer cancelWait() + if err := wshutil.DefaultRouter.WaitForRegister(waitCtx, routeId); err != nil { + return fmt.Errorf("waiting for concurrent wsh setup for %q: %w", conn.GetName(), err) + } + return nil + } + if conn.WshEnsuring != nil { + defer conn.WshEnsuring.Store(false) + } + wshResult := conn.tryEnableWsh(ctx, conn.GetName()) + conn.persistWshInstalled(ctx, wshResult) + if !wshResult.WshEnabled { + if wshResult.WshError != nil { + return wshResult.WshError + } + if wshResult.NoWshReason != "" { + return fmt.Errorf("wsh unavailable for %q: %s", conn.GetName(), wshResult.NoWshReason) + } + } + } + } return nil case Status_Connecting: return conn.WaitForConnect(ctx) diff --git a/pkg/shellexec/shellexec.go b/pkg/shellexec/shellexec.go index 35af5446a3..a19274cca4 100644 --- a/pkg/shellexec/shellexec.go +++ b/pkg/shellexec/shellexec.go @@ -26,6 +26,7 @@ import ( "github.com/wavetermdev/waveterm/pkg/remote/conncontroller" "github.com/wavetermdev/waveterm/pkg/util/pamparse" "github.com/wavetermdev/waveterm/pkg/util/shellutil" + "github.com/wavetermdev/waveterm/pkg/util/utilfn" "github.com/wavetermdev/waveterm/pkg/wavebase" "github.com/wavetermdev/waveterm/pkg/waveobj" "github.com/wavetermdev/waveterm/pkg/wshrpc" @@ -107,6 +108,115 @@ func ExitCodeFromWaitErr(err error) int { } +// escapeForPosixDoubleQuotes escapes special characters for use inside POSIX double quotes. +// It escapes: \\, ", $, and ` to be safe inside "$HOME" where should be treated literally. +func escapeForPosixDoubleQuotes(s string) string { + // Conservative escaping for the subset of chars that are special inside double quotes. + // This is used for "$HOME" where should be treated literally. + var b strings.Builder + b.Grow(len(s)) + for i := 0; i < len(s); i++ { + switch s[i] { + case '\\', '"', '$', '`': + b.WriteByte('\\') + b.WriteByte(s[i]) + default: + b.WriteByte(s[i]) + } + } + return b.String() +} + +// posixCwdExpr returns a POSIX shell expression for the given current working directory. +// It handles tilde (~) expansion by using $HOME for paths starting with ~/, and quotes other paths appropriately. +func posixCwdExpr(cwd string) string { + cwd = strings.TrimSpace(cwd) + if cwd == "" { + return "" + } + if cwd == "~" { + return "~" + } + if strings.HasPrefix(cwd, "~/") { + // "~" must be expanded on the target machine. Use $HOME so we can still quote paths with spaces safely. + rest := cwd[1:] // includes leading "/" + return fmt.Sprintf("\"$HOME%s\"", escapeForPosixDoubleQuotes(rest)) + } + return utilfn.ShellQuote(cwd, false, -1) +} + +// posixCwdExprNoWshRemote returns a POSIX shell expression for the given current working directory on a remote SSH connection. +// It uses ~user syntax for tilde paths when an SSH username is provided, avoiding dependency on $HOME on the remote shell. +func posixCwdExprNoWshRemote(cwd string, sshUser string) string { + cwd = strings.TrimSpace(cwd) + if cwd == "" { + return "" + } + sshUser = strings.TrimSpace(sshUser) + if sshUser == "" { + return posixCwdExpr(cwd) + } + if cwd == "~" { + // Prefer ~user so we don't depend on $HOME being correct on the remote shell. + return "~" + sshUser + } + if cwd == "~/" { + return "~" + sshUser + "/" + } + if strings.HasPrefix(cwd, "~/") { + // Prefer ~user so we don't depend on $HOME being correct on the remote shell. + rest := cwd[1:] // includes leading "/" + if strings.ContainsAny(rest, " \t\n\r'\"`$&|;<>()\\*[]?!") { + // Quote the rest to handle spaces and special characters + return "~" + sshUser + "'" + strings.ReplaceAll(rest, "'", "''") + "'" + } + return "~" + sshUser + rest + } + return posixCwdExpr(cwd) +} + +// fishCwdExpr returns a Fish shell expression for the given current working directory. +// Fish requires $HOME for tilde paths in double-quoted strings to handle spaces safely. +func fishCwdExpr(cwd string) string { + cwd = strings.TrimSpace(cwd) + if cwd == "" { + return "" + } + if cwd == "~" { + return "~" + } + if strings.HasPrefix(cwd, "~/") { + // Fish does not expand ~ inside double quotes, use $HOME instead + rest := cwd[1:] // includes leading "/" + return fmt.Sprintf("\"$HOME%s\"", escapeForPosixDoubleQuotes(rest)) + } + return utilfn.ShellQuote(cwd, false, -1) +} + +// pwshCwdExpr returns a PowerShell expression for the given current working directory. +// PowerShell uses ~ correctly by default; paths with spaces or special characters are wrapped in quotes. +func pwshCwdExpr(cwd string) string { + cwd = strings.TrimSpace(cwd) + if cwd == "" { + return "" + } + if cwd == "~" { + return "~" + } + if strings.HasPrefix(cwd, "~/") { + rest := cwd[1:] + if strings.ContainsAny(rest, " \"'`$()[]{}") { + // Use single quotes for the path portion to escape special characters + return "~'" + strings.ReplaceAll(rest, "'", "''") + "'" + } + return cwd + } + if strings.ContainsAny(cwd, " \"'`$()[]{}") { + return "'" + strings.ReplaceAll(cwd, "'", "''") + "'" + } + return cwd +} + func checkCwd(cwd string) error { if cwd == "" { return fmt.Errorf("cwd is empty") diff --git a/pkg/shellexec/shellexec_test.go b/pkg/shellexec/shellexec_test.go new file mode 100644 index 0000000000..f15b193df4 --- /dev/null +++ b/pkg/shellexec/shellexec_test.go @@ -0,0 +1,193 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package shellexec + +import "testing" + +func TestFishCwdExpr(t *testing.T) { + tests := []struct { + name string + cwd string + want string + }{ + { + name: "tilde-alone", + cwd: "~", + want: "~", + }, + { + name: "tilde-dir", + cwd: "~/.ssh", + want: "\"$HOME/.ssh\"", + }, + { + name: "tilde-with-spaces", + cwd: "~/Documents/My Files", + want: "\"$HOME/Documents/My Files\"", + }, + { + name: "absolute-path", + cwd: "/var/log", + want: "/var/log", + }, + { + name: "path-with-spaces-quoted", + cwd: "/path with spaces", + want: "'/path with spaces'", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := fishCwdExpr(tt.cwd) + if got != tt.want { + t.Fatalf("fishCwdExpr(%q)=%q, want %q", tt.cwd, got, tt.want) + } + }) + } +} + +func TestPwshCwdExpr(t *testing.T) { + tests := []struct { + name string + cwd string + want string + }{ + { + name: "tilde-alone", + cwd: "~", + want: "~", + }, + { + name: "tilde-dir", + cwd: "~/.ssh", + want: "~/.ssh", + }, + { + name: "tilde-with-spaces", + cwd: "~/Documents/My Files", + want: "~'/Documents/My Files'", + }, + { + name: "tilde-with-dollars", + cwd: "~/path$with$dollars", + want: "~'/path$with$dollars'", + }, + { + name: "tilde-with-backticks", + cwd: "~/path`with`backticks", + want: "~'/path`with`backticks'", + }, + { + name: "tilde-with-single-quotes", + cwd: "~/path'with'quotes", + want: "~'/path''with''quotes'", + }, + { + name: "absolute-path", + cwd: "/var/log", + want: "/var/log", + }, + { + name: "path-with-spaces-quoted", + cwd: "/path with spaces", + want: "'/path with spaces'", + }, + { + name: "path-with-dollars", + cwd: "/path$with$dollars", + want: "'/path$with$dollars'", + }, + { + name: "path-with-parens", + cwd: "/path(with)parens", + want: "'/path(with)parens'", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := pwshCwdExpr(tt.cwd) + if got != tt.want { + t.Fatalf("pwshCwdExpr(%q)=%q, want %q", tt.cwd, got, tt.want) + } + }) + } +} + +func TestPosixCwdExprNoWshRemote(t *testing.T) { + tests := []struct { + name string + cwd string + sshUser string + want string + }{ + { + name: "tilde-dir-uses-username-home", + cwd: "~/.ssh", + sshUser: "root", + want: "~root/.ssh", + }, + { + name: "tilde-root-uses-username-home", + cwd: "~", + sshUser: "root", + want: "~root", + }, + { + name: "tilde-slash-uses-username-home", + cwd: "~/", + sshUser: "root", + want: "~root/", + }, + { + name: "non-tilde-falls-back", + cwd: "/var/log", + sshUser: "root", + want: "/var/log", + }, + { + name: "missing-user-falls-back-to-home-var", + cwd: "~/.ssh", + sshUser: "", + want: "\"$HOME/.ssh\"", + }, + { + name: "tilde-with-spaces-and-user", + cwd: "~/My Documents", + sshUser: "root", + want: "~root'/My Documents'", + }, + { + name: "tilde-with-special-chars-and-user", + cwd: "~/a;echo pwn", + sshUser: "root", + want: "~root'/a;echo pwn'", + }, + { + name: "tilde-with-quoted-path", + cwd: `~/"quoted"`, + sshUser: "root", + want: `~root'/"quoted"'`, + }, + { + name: "tilde-with-spaces-no-user", + cwd: "~/My Docs", + sshUser: "", + want: "\"$HOME/My Docs\"", + }, + { + name: "tilde-with-special-chars-no-user", + cwd: "~/a;echo pwn", + sshUser: "", + want: "\"$HOME/a;echo pwn\"", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := posixCwdExprNoWshRemote(tt.cwd, tt.sshUser) + if got != tt.want { + t.Fatalf("posixCwdExprNoWshRemote(%q, %q)=%q, want %q", tt.cwd, tt.sshUser, got, tt.want) + } + }) + } +}