Skip to content
Open
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
44 changes: 44 additions & 0 deletions pkg/remote/conncontroller/conncontroller.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
88 changes: 88 additions & 0 deletions pkg/shellexec/shellexec.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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<rest>" where <rest> 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
Comment on lines +176 to +177
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

For fish shell and PowerShell, what is the recommended way to represent ~/path with spaces as a single safe argument while preserving home expansion?

💡 Result:

For fish shell, the recommended way is to use double quotes around the path: "/path with spaces". This preserves tilde () expansion to the home directory while treating the entire path, including spaces, as a single argument. Tilde expansion occurs for unquoted ~ at the start of a word or inside double quotes when followed by /. Examples: ls "/My Documents" cd "/My Documents" Single quotes '~/path' prevent tilde expansion, passing ~ literally. For PowerShell, ~ expansion is not automatic for paths (especially external commands), so use the $HOME variable inside double quotes: "$HOME/path with spaces". This expands to the full home path and handles spaces as a single argument. To expand ~ explicitly if needed: Resolve-Path ~/path then quote, but using $HOME is simpler and more reliable. Examples: ls "$HOME/My Documents" & "$HOME/My Documents/myapp.exe"

Citations:


🏁 Script executed:

cat -n pkg/shellexec/shellexec.go | sed -n '170,200p'

Repository: wavetermdev/waveterm

Length of output: 966


🏁 Script executed:

rg -n "fishCwdExpr|pwshCwdExpr" pkg/shellexec/shellexec.go -A 3 -B 1

Repository: wavetermdev/waveterm

Length of output: 300


🏁 Script executed:

rg -n "fishCwdExpr|pwshCwdExpr" pkg/shellexec/shellexec.go | grep -v "^[0-9]*:func"

Repository: wavetermdev/waveterm

Length of output: 46


🏁 Script executed:

rg "fishCwdExpr|pwshCwdExpr" --type go

Repository: wavetermdev/waveterm

Length of output: 193


🏁 Script executed:

rg -n "Cwd|cwd" pkg/shellexec/shellexec.go | head -30

Repository: wavetermdev/waveterm

Length of output: 1153


🏁 Script executed:

rg "fishCwdExpr|pwshCwdExpr" --type go -B 2 -A 5 | grep -v "^func"

Repository: wavetermdev/waveterm

Length of output: 762


🏁 Script executed:

rg -n "fishCwdExpr\(|pwshCwdExpr\(" pkg/shellexec/shellexec.go

Repository: wavetermdev/waveterm

Length of output: 147


🏁 Script executed:

rg "fishCwdExpr\(|pwshCwdExpr\(" --type go -n

Repository: wavetermdev/waveterm

Length of output: 201


🏁 Script executed:

rg "fishCwdExpr\(|pwshCwdExpr\(" --type go

Repository: wavetermdev/waveterm

Length of output: 193


🏁 Script executed:

rg "fishCwdExpr|pwshCwdExpr" --type go -l

Repository: wavetermdev/waveterm

Length of output: 90


🏁 Script executed:

cat -n pkg/shellexec/shellexec.go | grep -E "fishCwdExpr|pwshCwdExpr" | grep -v "^[0-9]*:func"

Repository: wavetermdev/waveterm

Length of output: 153


🏁 Script executed:

rg "fishCwdExpr|pwshCwdExpr" -t go --no-filename

Repository: wavetermdev/waveterm

Length of output: 139


🏁 Script executed:

cat -n pkg/shellexec/shellexec.go | tail -100

Repository: wavetermdev/waveterm

Length of output: 4267


🏁 Script executed:

wc -l pkg/shellexec/shellexec.go

Repository: wavetermdev/waveterm

Length of output: 94


🏁 Script executed:

grep -r "fishCwdExpr\|pwshCwdExpr" --include="*.go" .

Repository: wavetermdev/waveterm

Length of output: 197


🏁 Script executed:

cd pkg/shellexec && grep -r "fishCwd\|pwshCwd\|CwdExpr" --include="*.go" .

Repository: wavetermdev/waveterm

Length of output: 644


~/... paths in fish and pwsh need proper quoting to handle spaces safely.

The code returns tilde-prefixed paths raw without escaping (lines 176-177 for fishCwdExpr and 188-189 for pwshCwdExpr). For paths with spaces, this creates unsafe behavior:

  • Fish: Raw ~/path with spaces gets split on whitespace. Use double quotes: "~/path with spaces" to preserve tilde expansion while handling spaces safely.
  • PowerShell: Tilde (~) is not automatically expanded in path contexts. Use $HOME instead: "$HOME/path with spaces" for reliable expansion and safe handling of special characters.

Same issue applies to lines 192-193 (special char quoting is skipped for tilde paths).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/shellexec/shellexec.go` around lines 176 - 177, When cwd starts with
"~/", the current fishCwdExpr and pwshCwdExpr return the raw tilde path which
breaks on spaces and skips special-char quoting; change fishCwdExpr to return a
double-quoted tilde path (e.g., use strconv.Quote or wrap with quotes) so
"~/path with spaces" becomes "\"~/path with spaces\"" and change pwshCwdExpr to
expand "~" to "$HOME" and return a double-quoted string (e.g., "$HOME/path with
spaces" quoted) so PowerShell expands safely; update any logic that skipped
special-char quoting for tilde paths to use these quoted/expanded forms instead
(refer to fishCwdExpr and pwshCwdExpr to locate edits).

}
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")
Expand Down
54 changes: 54 additions & 0 deletions pkg/shellexec/shellexec_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}