Skip to content

Commit 1f08cf9

Browse files
committed
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
1 parent 02b9e5f commit 1f08cf9

File tree

3 files changed

+297
-0
lines changed

3 files changed

+297
-0
lines changed

pkg/remote/conncontroller/conncontroller.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ type SSHConn struct {
8080
Status string
8181
ConnHealthStatus string
8282
WshEnabled *atomic.Bool
83+
WshEnsuring *atomic.Bool
8384
Opts *remote.SSHOpts
8485
Client *ssh.Client
8586
DomainSockName string // if "", then no domain socket
@@ -270,12 +271,23 @@ func (conn *SSHConn) GetName() string {
270271
func (conn *SSHConn) OpenDomainSocketListener(ctx context.Context) error {
271272
conn.Infof(ctx, "running OpenDomainSocketListener...\n")
272273
allowed := WithLockRtn(conn, func() bool {
274+
// If it's already set up, allow callers to reuse it even if the conn is already connected.
275+
if conn.DomainSockListener != nil && conn.DomainSockName != "" {
276+
return true
277+
}
273278
return conn.Status == Status_Connecting
274279
})
275280
if !allowed {
276281
return fmt.Errorf("cannot open domain socket for %q when status is %q", conn.GetName(), conn.GetStatus())
277282
}
283+
if conn.DomainSockListener != nil && conn.DomainSockName != "" {
284+
conn.Infof(ctx, "domain socket already active (%s)\n", conn.DomainSockName)
285+
return nil
286+
}
278287
client := conn.GetClient()
288+
if client == nil {
289+
return fmt.Errorf("cannot open domain socket for %q: ssh client is not connected", conn.GetName())
290+
}
279291
randStr, err := utilfn.RandomHexString(16) // 64-bits of randomness
280292
if err != nil {
281293
return fmt.Errorf("error generating random string: %w", err)
@@ -1075,6 +1087,7 @@ func getConnInternal(opts *remote.SSHOpts, createIfNotExists bool) *SSHConn {
10751087
Status: Status_Init,
10761088
ConnHealthStatus: ConnHealthStatus_Good,
10771089
WshEnabled: &atomic.Bool{},
1090+
WshEnsuring: &atomic.Bool{},
10781091
Opts: opts,
10791092
}
10801093
clientControllerMap[*opts] = rtn
@@ -1125,6 +1138,37 @@ func EnsureConnection(ctx context.Context, connName string) error {
11251138
connStatus := conn.DeriveConnStatus()
11261139
switch connStatus.Status {
11271140
case Status_Connected:
1141+
// If wsh is enabled for this connection, ensure the connserver route exists.
1142+
// This prevents "no route for \"conn:...\"" errors when using remote file browsing after a
1143+
// connserver restart/termination.
1144+
enableWsh, _ := conn.getConnWshSettings()
1145+
if enableWsh {
1146+
routeId := wshutil.MakeConnectionRouteId(conn.GetName())
1147+
fastCtx, cancelFn := context.WithTimeout(ctx, 75*time.Millisecond)
1148+
fastErr := wshutil.DefaultRouter.WaitForRegister(fastCtx, routeId)
1149+
cancelFn()
1150+
if fastErr != nil {
1151+
// Avoid a thundering herd when multiple blocks ensure concurrently.
1152+
if conn.WshEnsuring != nil && !conn.WshEnsuring.CompareAndSwap(false, true) {
1153+
waitCtx, cancelWait := context.WithTimeout(ctx, 5*time.Second)
1154+
defer cancelWait()
1155+
return wshutil.DefaultRouter.WaitForRegister(waitCtx, routeId)
1156+
}
1157+
if conn.WshEnsuring != nil {
1158+
defer conn.WshEnsuring.Store(false)
1159+
}
1160+
wshResult := conn.tryEnableWsh(ctx, conn.GetName())
1161+
conn.persistWshInstalled(ctx, wshResult)
1162+
if !wshResult.WshEnabled {
1163+
if wshResult.WshError != nil {
1164+
return wshResult.WshError
1165+
}
1166+
if wshResult.NoWshReason != "" {
1167+
return fmt.Errorf("wsh unavailable for %q: %s", conn.GetName(), wshResult.NoWshReason)
1168+
}
1169+
}
1170+
}
1171+
}
11281172
return nil
11291173
case Status_Connecting:
11301174
return conn.WaitForConnect(ctx)

pkg/shellexec/shellexec.go

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import (
2626
"github.com/wavetermdev/waveterm/pkg/remote/conncontroller"
2727
"github.com/wavetermdev/waveterm/pkg/util/pamparse"
2828
"github.com/wavetermdev/waveterm/pkg/util/shellutil"
29+
"github.com/wavetermdev/waveterm/pkg/util/utilfn"
2930
"github.com/wavetermdev/waveterm/pkg/wavebase"
3031
"github.com/wavetermdev/waveterm/pkg/waveobj"
3132
"github.com/wavetermdev/waveterm/pkg/wshrpc"
@@ -107,6 +108,110 @@ func ExitCodeFromWaitErr(err error) int {
107108

108109
}
109110

111+
// escapeForPosixDoubleQuotes escapes special characters for use inside POSIX double quotes.
112+
// It escapes: \\, ", $, and ` to be safe inside "$HOME<rest>" where <rest> should be treated literally.
113+
func escapeForPosixDoubleQuotes(s string) string {
114+
// Conservative escaping for the subset of chars that are special inside double quotes.
115+
// This is used for "$HOME<rest>" where <rest> should be treated literally.
116+
var b strings.Builder
117+
b.Grow(len(s))
118+
for i := 0; i < len(s); i++ {
119+
switch s[i] {
120+
case '\\', '"', '$', '`':
121+
b.WriteByte('\\')
122+
b.WriteByte(s[i])
123+
default:
124+
b.WriteByte(s[i])
125+
}
126+
}
127+
return b.String()
128+
}
129+
130+
// posixCwdExpr returns a POSIX shell expression for the given current working directory.
131+
// It handles tilde (~) expansion by using $HOME for paths starting with ~/, and quotes other paths appropriately.
132+
func posixCwdExpr(cwd string) string {
133+
cwd = strings.TrimSpace(cwd)
134+
if cwd == "" {
135+
return ""
136+
}
137+
if cwd == "~" {
138+
return "~"
139+
}
140+
if strings.HasPrefix(cwd, "~/") {
141+
// "~" must be expanded on the target machine. Use $HOME so we can still quote paths with spaces safely.
142+
rest := cwd[1:] // includes leading "/"
143+
return fmt.Sprintf("\"$HOME%s\"", escapeForPosixDoubleQuotes(rest))
144+
}
145+
return utilfn.ShellQuote(cwd, false, -1)
146+
}
147+
148+
// posixCwdExprNoWshRemote returns a POSIX shell expression for the given current working directory on a remote SSH connection.
149+
// It uses ~user syntax for tilde paths when an SSH username is provided, avoiding dependency on $HOME on the remote shell.
150+
func posixCwdExprNoWshRemote(cwd string, sshUser string) string {
151+
cwd = strings.TrimSpace(cwd)
152+
if cwd == "" {
153+
return ""
154+
}
155+
sshUser = strings.TrimSpace(sshUser)
156+
if sshUser == "" {
157+
return posixCwdExpr(cwd)
158+
}
159+
if cwd == "~" {
160+
// Prefer ~user so we don't depend on $HOME being correct on the remote shell.
161+
return "~" + sshUser
162+
}
163+
if cwd == "~/" {
164+
return "~" + sshUser + "/"
165+
}
166+
if strings.HasPrefix(cwd, "~/") {
167+
// Prefer ~user so we don't depend on $HOME being correct on the remote shell.
168+
rest := cwd[1:] // includes leading "/"
169+
return "~" + sshUser + rest
170+
}
171+
return posixCwdExpr(cwd)
172+
}
173+
174+
// fishCwdExpr returns a Fish shell expression for the given current working directory.
175+
// Fish requires $HOME for tilde paths in double-quoted strings to handle spaces safely.
176+
func fishCwdExpr(cwd string) string {
177+
cwd = strings.TrimSpace(cwd)
178+
if cwd == "" {
179+
return ""
180+
}
181+
if cwd == "~" {
182+
return "~"
183+
}
184+
if strings.HasPrefix(cwd, "~/") {
185+
// Fish does not expand ~ inside double quotes, use $HOME instead
186+
rest := cwd[1:] // includes leading "/"
187+
return fmt.Sprintf("\"$HOME%s\"", escapeForPosixDoubleQuotes(rest))
188+
}
189+
return utilfn.ShellQuote(cwd, false, -1)
190+
}
191+
192+
// pwshCwdExpr returns a PowerShell expression for the given current working directory.
193+
// PowerShell uses ~ correctly by default; paths with spaces or special characters are wrapped in quotes.
194+
func pwshCwdExpr(cwd string) string {
195+
cwd = strings.TrimSpace(cwd)
196+
if cwd == "" {
197+
return ""
198+
}
199+
if cwd == "~" {
200+
return "~"
201+
}
202+
if strings.HasPrefix(cwd, "~/") {
203+
rest := cwd[1:]
204+
if strings.ContainsAny(rest, " \"'`$()[]{}") {
205+
return "~\"" + rest + "\""
206+
}
207+
return cwd
208+
}
209+
if strings.ContainsAny(cwd, " \"'`$()[]{}") {
210+
return "'" + strings.ReplaceAll(cwd, "'", "''") + "'"
211+
}
212+
return cwd
213+
}
214+
110215
func checkCwd(cwd string) error {
111216
if cwd == "" {
112217
return fmt.Errorf("cwd is empty")

pkg/shellexec/shellexec_test.go

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
// Copyright 2026, Command Line Inc.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package shellexec
5+
6+
import "testing"
7+
8+
func TestFishCwdExpr(t *testing.T) {
9+
tests := []struct {
10+
name string
11+
cwd string
12+
want string
13+
}{
14+
{
15+
name: "tilde-alone",
16+
cwd: "~",
17+
want: "~",
18+
},
19+
{
20+
name: "tilde-dir",
21+
cwd: "~/.ssh",
22+
want: "\"$HOME/.ssh\"",
23+
},
24+
{
25+
name: "tilde-with-spaces",
26+
cwd: "~/Documents/My Files",
27+
want: "\"$HOME/Documents/My Files\"",
28+
},
29+
{
30+
name: "absolute-path",
31+
cwd: "/var/log",
32+
want: "/var/log",
33+
},
34+
{
35+
name: "path-with-spaces-quoted",
36+
cwd: "/path with spaces",
37+
want: "'/path with spaces'",
38+
},
39+
}
40+
for _, tt := range tests {
41+
t.Run(tt.name, func(t *testing.T) {
42+
got := fishCwdExpr(tt.cwd)
43+
if got != tt.want {
44+
t.Fatalf("fishCwdExpr(%q)=%q, want %q", tt.cwd, got, tt.want)
45+
}
46+
})
47+
}
48+
}
49+
50+
func TestPwshCwdExpr(t *testing.T) {
51+
tests := []struct {
52+
name string
53+
cwd string
54+
want string
55+
}{
56+
{
57+
name: "tilde-alone",
58+
cwd: "~",
59+
want: "~",
60+
},
61+
{
62+
name: "tilde-dir",
63+
cwd: "~/.ssh",
64+
want: "~/.ssh",
65+
},
66+
{
67+
name: "tilde-with-spaces",
68+
cwd: "~/Documents/My Files",
69+
want: "~\"/Documents/My Files\"",
70+
},
71+
{
72+
name: "absolute-path",
73+
cwd: "/var/log",
74+
want: "/var/log",
75+
},
76+
{
77+
name: "path-with-spaces-quoted",
78+
cwd: "/path with spaces",
79+
want: "'/path with spaces'",
80+
},
81+
{
82+
name: "path-with-dollars",
83+
cwd: "/path$with$dollars",
84+
want: "'/path$with$dollars'",
85+
},
86+
{
87+
name: "path-with-parens",
88+
cwd: "/path(with)parens",
89+
want: "'/path(with)parens'",
90+
},
91+
}
92+
for _, tt := range tests {
93+
t.Run(tt.name, func(t *testing.T) {
94+
got := pwshCwdExpr(tt.cwd)
95+
if got != tt.want {
96+
t.Fatalf("pwshCwdExpr(%q)=%q, want %q", tt.cwd, got, tt.want)
97+
}
98+
})
99+
}
100+
}
101+
102+
func TestPosixCwdExprNoWshRemote(t *testing.T) {
103+
tests := []struct {
104+
name string
105+
cwd string
106+
sshUser string
107+
want string
108+
}{
109+
{
110+
name: "tilde-dir-uses-username-home",
111+
cwd: "~/.ssh",
112+
sshUser: "root",
113+
want: "~root/.ssh",
114+
},
115+
{
116+
name: "tilde-root-uses-username-home",
117+
cwd: "~",
118+
sshUser: "root",
119+
want: "~root",
120+
},
121+
{
122+
name: "tilde-slash-uses-username-home",
123+
cwd: "~/",
124+
sshUser: "root",
125+
want: "~root/",
126+
},
127+
{
128+
name: "non-tilde-falls-back",
129+
cwd: "/var/log",
130+
sshUser: "root",
131+
want: "/var/log",
132+
},
133+
{
134+
name: "missing-user-falls-back-to-home-var",
135+
cwd: "~/.ssh",
136+
sshUser: "",
137+
want: "\"$HOME/.ssh\"",
138+
},
139+
}
140+
for _, tt := range tests {
141+
t.Run(tt.name, func(t *testing.T) {
142+
got := posixCwdExprNoWshRemote(tt.cwd, tt.sshUser)
143+
if got != tt.want {
144+
t.Fatalf("posixCwdExprNoWshRemote(%q, %q)=%q, want %q", tt.cwd, tt.sshUser, got, tt.want)
145+
}
146+
})
147+
}
148+
}

0 commit comments

Comments
 (0)