From a53d40502a87848819c08f93c127900a9a8bfe98 Mon Sep 17 00:00:00 2001 From: re2zero Date: Mon, 30 Mar 2026 16:02:06 +0800 Subject: [PATCH] fix: prevent "no route for conn:..." errors and add shell path expansion - Auto-recover wsh routes after connserver restart - Allow domain socket listener reuse when already established - Add WshEnsuring flag to prevent thundering herd - Add shell-specific path expansion (posix, fish, pwsh) with tilde (~) support - Add escapeForPosixDoubleQuotes for safe path quoting - Add unit tests for path expansion logic --- pkg/remote/conncontroller/conncontroller.go | 44 +++++++++++ pkg/shellexec/shellexec.go | 88 +++++++++++++++++++++ pkg/shellexec/shellexec_test.go | 54 +++++++++++++ 3 files changed, 186 insertions(+) create mode 100644 pkg/shellexec/shellexec_test.go diff --git a/pkg/remote/conncontroller/conncontroller.go b/pkg/remote/conncontroller/conncontroller.go index a24a789009..64f7806333 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 @@ -270,12 +271,23 @@ func (conn *SSHConn) GetName() string { func (conn *SSHConn) OpenDomainSocketListener(ctx context.Context) error { conn.Infof(ctx, "running OpenDomainSocketListener...\n") 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 != "" { + 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 conn.DomainSockListener != nil && conn.DomainSockName != "" { + conn.Infof(ctx, "domain socket already active (%s)\n", conn.DomainSockName) + 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 +1087,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 +1138,37 @@ 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() + return wshutil.DefaultRouter.WaitForRegister(waitCtx, routeId) + } + 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..9c21f4621d 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,93 @@ func ExitCodeFromWaitErr(err error) int { } +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() +} + +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) +} + +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 "/" + return "~" + sshUser + rest + } + return posixCwdExpr(cwd) +} + +func fishCwdExpr(cwd string) string { + cwd = strings.TrimSpace(cwd) + if cwd == "" { + return "" + } + if cwd == "~" { + return "~" + } + if strings.HasPrefix(cwd, "~/") { + return cwd // fish auto-expands ~ correctly + } + return utilfn.ShellQuote(cwd, false, -1) +} + +func pwshCwdExpr(cwd string) string { + cwd = strings.TrimSpace(cwd) + if cwd == "" { + return "" + } + // PowerShell uses ~ correctly by default + if cwd == "~" || strings.HasPrefix(cwd, "~/") { + return cwd + } + // PowerShell paths should be wrapped in single quotes to handle special characters + 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..64d05602bb --- /dev/null +++ b/pkg/shellexec/shellexec_test.go @@ -0,0 +1,54 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package shellexec + +import "testing" + +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\"", + }, + } + 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) + } + }) + } +}