Skip to content

Commit a53d405

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 96c2526 commit a53d405

File tree

3 files changed

+186
-0
lines changed

3 files changed

+186
-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: 88 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,93 @@ func ExitCodeFromWaitErr(err error) int {
107108

108109
}
109110

111+
func escapeForPosixDoubleQuotes(s string) string {
112+
// Conservative escaping for the subset of chars that are special inside double quotes.
113+
// This is used for "$HOME<rest>" where <rest> should be treated literally.
114+
var b strings.Builder
115+
b.Grow(len(s))
116+
for i := 0; i < len(s); i++ {
117+
switch s[i] {
118+
case '\\', '"', '$', '`':
119+
b.WriteByte('\\')
120+
b.WriteByte(s[i])
121+
default:
122+
b.WriteByte(s[i])
123+
}
124+
}
125+
return b.String()
126+
}
127+
128+
func posixCwdExpr(cwd string) string {
129+
cwd = strings.TrimSpace(cwd)
130+
if cwd == "" {
131+
return ""
132+
}
133+
if cwd == "~" {
134+
return "~"
135+
}
136+
if strings.HasPrefix(cwd, "~/") {
137+
// "~" must be expanded on the target machine. Use $HOME so we can still quote paths with spaces safely.
138+
rest := cwd[1:] // includes leading "/"
139+
return fmt.Sprintf("\"$HOME%s\"", escapeForPosixDoubleQuotes(rest))
140+
}
141+
return utilfn.ShellQuote(cwd, false, -1)
142+
}
143+
144+
func posixCwdExprNoWshRemote(cwd string, sshUser string) string {
145+
cwd = strings.TrimSpace(cwd)
146+
if cwd == "" {
147+
return ""
148+
}
149+
sshUser = strings.TrimSpace(sshUser)
150+
if sshUser == "" {
151+
return posixCwdExpr(cwd)
152+
}
153+
if cwd == "~" {
154+
// Prefer ~user so we don't depend on $HOME being correct on the remote shell.
155+
return "~" + sshUser
156+
}
157+
if cwd == "~/" {
158+
return "~" + sshUser + "/"
159+
}
160+
if strings.HasPrefix(cwd, "~/") {
161+
// Prefer ~user so we don't depend on $HOME being correct on the remote shell.
162+
rest := cwd[1:] // includes leading "/"
163+
return "~" + sshUser + rest
164+
}
165+
return posixCwdExpr(cwd)
166+
}
167+
168+
func fishCwdExpr(cwd string) string {
169+
cwd = strings.TrimSpace(cwd)
170+
if cwd == "" {
171+
return ""
172+
}
173+
if cwd == "~" {
174+
return "~"
175+
}
176+
if strings.HasPrefix(cwd, "~/") {
177+
return cwd // fish auto-expands ~ correctly
178+
}
179+
return utilfn.ShellQuote(cwd, false, -1)
180+
}
181+
182+
func pwshCwdExpr(cwd string) string {
183+
cwd = strings.TrimSpace(cwd)
184+
if cwd == "" {
185+
return ""
186+
}
187+
// PowerShell uses ~ correctly by default
188+
if cwd == "~" || strings.HasPrefix(cwd, "~/") {
189+
return cwd
190+
}
191+
// PowerShell paths should be wrapped in single quotes to handle special characters
192+
if strings.ContainsAny(cwd, " \"'`$") {
193+
return "'" + strings.ReplaceAll(cwd, "'", "''") + "'"
194+
}
195+
return cwd
196+
}
197+
110198
func checkCwd(cwd string) error {
111199
if cwd == "" {
112200
return fmt.Errorf("cwd is empty")

pkg/shellexec/shellexec_test.go

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
// Copyright 2026, Command Line Inc.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package shellexec
5+
6+
import "testing"
7+
8+
func TestPosixCwdExprNoWshRemote(t *testing.T) {
9+
tests := []struct {
10+
name string
11+
cwd string
12+
sshUser string
13+
want string
14+
}{
15+
{
16+
name: "tilde-dir-uses-username-home",
17+
cwd: "~/.ssh",
18+
sshUser: "root",
19+
want: "~root/.ssh",
20+
},
21+
{
22+
name: "tilde-root-uses-username-home",
23+
cwd: "~",
24+
sshUser: "root",
25+
want: "~root",
26+
},
27+
{
28+
name: "tilde-slash-uses-username-home",
29+
cwd: "~/",
30+
sshUser: "root",
31+
want: "~root/",
32+
},
33+
{
34+
name: "non-tilde-falls-back",
35+
cwd: "/var/log",
36+
sshUser: "root",
37+
want: "/var/log",
38+
},
39+
{
40+
name: "missing-user-falls-back-to-home-var",
41+
cwd: "~/.ssh",
42+
sshUser: "",
43+
want: "\"$HOME/.ssh\"",
44+
},
45+
}
46+
for _, tt := range tests {
47+
t.Run(tt.name, func(t *testing.T) {
48+
got := posixCwdExprNoWshRemote(tt.cwd, tt.sshUser)
49+
if got != tt.want {
50+
t.Fatalf("posixCwdExprNoWshRemote(%q, %q)=%q, want %q", tt.cwd, tt.sshUser, got, tt.want)
51+
}
52+
})
53+
}
54+
}

0 commit comments

Comments
 (0)