From 7e71520d928284afcbc972088e6fc8807d74f097 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Sat, 11 Apr 2026 09:47:49 -0500 Subject: [PATCH 1/3] fix: sanitize AppImage environment before opening URLs (#127) When running inside a Linux AppImage, the AppRun script prepends bundled library paths to LD_LIBRARY_PATH. Child processes like xdg-open and gio then load these bundled libraries instead of the system ones, causing symbol lookup errors (e.g. "undefined symbol: g_unix_mount_entry_get_options"). This centralizes all URL-opening through pkg/open.Run(), which detects the AppImage environment and strips injected variables (LD_LIBRARY_PATH, LD_PRELOAD, APPDIR, etc.) before spawning xdg-open. It also uses /usr/bin/xdg-open by absolute path to bypass Tauri's bundled wrapper. References: - https://github.com/AppImage/AppImageKit/issues/396 - https://github.com/AppImage/AppImageKit/issues/616 - https://github.com/tauri-apps/tauri/issues/10617 --- cmd/pro/start.go | 6 +- .../daemonclient/client.go | 4 +- pkg/ide/jetbrains/generic.go | 4 +- pkg/ide/opener/opener.go | 3 +- pkg/ide/vscode/open.go | 4 +- pkg/ide/zed/zed.go | 4 +- pkg/ide/zed/zed_linux.go | 5 +- pkg/open/appimage_linux.go | 92 +++++++++++++++++++ pkg/open/appimage_other.go | 11 +++ pkg/open/open.go | 12 ++- pkg/platform/client/client.go | 4 +- 11 files changed, 130 insertions(+), 19 deletions(-) create mode 100644 pkg/open/appimage_linux.go create mode 100644 pkg/open/appimage_other.go diff --git a/cmd/pro/start.go b/cmd/pro/start.go index f08c87ece0..4fd17ae589 100644 --- a/cmd/pro/start.go +++ b/cmd/pro/start.go @@ -27,6 +27,7 @@ import ( proflags "github.com/skevetter/devpod/cmd/pro/flags" "github.com/skevetter/devpod/pkg/config" "github.com/skevetter/devpod/pkg/machineid" + devpodopen "github.com/skevetter/devpod/pkg/open" "github.com/skevetter/devpod/pkg/platform" "github.com/skevetter/devpod/pkg/platform/client" "github.com/skevetter/devpod/pkg/util" @@ -34,7 +35,6 @@ import ( "github.com/skevetter/log/hash" "github.com/skevetter/log/scanner" "github.com/skevetter/log/survey" - "github.com/skratchdot/open-golang/open" "github.com/spf13/cobra" corev1 "k8s.io/api/core/v1" kerrors "k8s.io/apimachinery/pkg/api/errors" @@ -1272,7 +1272,7 @@ func (cmd *StartCmd) login(url string) error { // check if we are already logged in if cmd.isLoggedIn(url) { // still open the UI - err := open.Run(url) + err := devpodopen.Run(url) if err != nil { return fmt.Errorf("couldn't open the login page in a browser: %w", err) } @@ -1357,7 +1357,7 @@ func (cmd *StartCmd) loginUI(url string) error { ) loginURL := fmt.Sprintf("%s/login#%s", url, queryString) - err := open.Run(loginURL) + err := devpodopen.Run(loginURL) if err != nil { return fmt.Errorf("couldn't open the login page in a browser: %w", err) } diff --git a/pkg/client/clientimplementation/daemonclient/client.go b/pkg/client/clientimplementation/daemonclient/client.go index 27ba183a7a..6af1ae307c 100644 --- a/pkg/client/clientimplementation/daemonclient/client.go +++ b/pkg/client/clientimplementation/daemonclient/client.go @@ -16,6 +16,7 @@ import ( clientpkg "github.com/skevetter/devpod/pkg/client" "github.com/skevetter/devpod/pkg/config" daemon "github.com/skevetter/devpod/pkg/daemon/platform" + devpodopen "github.com/skevetter/devpod/pkg/open" "github.com/skevetter/devpod/pkg/options" "github.com/skevetter/devpod/pkg/options/resolver" "github.com/skevetter/devpod/pkg/platform" @@ -24,7 +25,6 @@ import ( sshServer "github.com/skevetter/devpod/pkg/ssh/server" "github.com/skevetter/devpod/pkg/ts" "github.com/skevetter/log" - "github.com/skratchdot/open-golang/open" "golang.org/x/crypto/ssh" "tailscale.com/client/local" "tailscale.com/tailcfg" @@ -147,7 +147,7 @@ func (c *client) CheckWorkspaceReachable(ctx context.Context) error { c.workspace.Source.String(), c.workspace.IDE.Name, ) - openErr := open.Run(deeplink) + openErr := devpodopen.Run(deeplink) if openErr != nil { return getWorkspaceErr // inform user about daemon state } diff --git a/pkg/ide/jetbrains/generic.go b/pkg/ide/jetbrains/generic.go index 13c436e829..56b68f7dc4 100644 --- a/pkg/ide/jetbrains/generic.go +++ b/pkg/ide/jetbrains/generic.go @@ -20,9 +20,9 @@ import ( "github.com/skevetter/devpod/pkg/extract" devpodhttp "github.com/skevetter/devpod/pkg/http" "github.com/skevetter/devpod/pkg/ide" + devpodopen "github.com/skevetter/devpod/pkg/open" "github.com/skevetter/devpod/pkg/util" "github.com/skevetter/log" - "github.com/skratchdot/open-golang/open" ) const ( @@ -89,7 +89,7 @@ type GenericJetBrainsServer struct { func (o *GenericJetBrainsServer) OpenGateway(workspaceFolder, workspaceID string) error { o.log.Infof("Starting %s through JetBrains Gateway...", o.options.DisplayName) - err := open.Run( + err := devpodopen.Run( `jetbrains-gateway://connect#idePath=` + url.QueryEscape( o.getDirectory(path.Join("/", "home", o.userName)), ) + `&projectPath=` + url.QueryEscape( diff --git a/pkg/ide/opener/opener.go b/pkg/ide/opener/opener.go index 72c4056b9e..0871ac112a 100644 --- a/pkg/ide/opener/opener.go +++ b/pkg/ide/opener/opener.go @@ -26,7 +26,6 @@ import ( "github.com/skevetter/devpod/pkg/port" "github.com/skevetter/devpod/pkg/tunnel" "github.com/skevetter/log" - "github.com/skratchdot/open-golang/open" ) // Params holds the parameters needed to open an IDE. @@ -435,5 +434,5 @@ func startFleet(ctx context.Context, client client2.BaseWorkspaceClient, logger ) logger.Infof("Starting Fleet at %s ...", url) - return open.Run(url) + return open2.Run(url) } diff --git a/pkg/ide/vscode/open.go b/pkg/ide/vscode/open.go index 89b79d511a..bfb2298c08 100644 --- a/pkg/ide/vscode/open.go +++ b/pkg/ide/vscode/open.go @@ -11,8 +11,8 @@ import ( "github.com/skevetter/devpod/pkg/command" pkgconfig "github.com/skevetter/devpod/pkg/config" + devpodopen "github.com/skevetter/devpod/pkg/open" "github.com/skevetter/log" - "github.com/skratchdot/open-golang/open" ) const containersExtension = "ms-vscode-remote.remote-containers" @@ -124,7 +124,7 @@ func openViaBrowser(params OpenParams) error { openURL := u.String() params.Log.Debugf("opening URL %s", openURL) - err := open.Run(openURL) + err := devpodopen.Run(openURL) if err != nil { params.Log.Errorf( "flavor %s is not installed on host device: %v", diff --git a/pkg/ide/zed/zed.go b/pkg/ide/zed/zed.go index 5afe39186a..56abc35e20 100644 --- a/pkg/ide/zed/zed.go +++ b/pkg/ide/zed/zed.go @@ -7,8 +7,8 @@ import ( "fmt" "github.com/skevetter/devpod/pkg/config" + devpodopen "github.com/skevetter/devpod/pkg/open" "github.com/skevetter/log" - "github.com/skratchdot/open-golang/open" ) // Open first finds the zed binary for the local platform and then opens the zed editor with the given workspace folder. @@ -26,7 +26,7 @@ func Open( sshHost := workspaceID + config.SSHHostSuffix + workspaceFolder openURL := fmt.Sprintf("zed://ssh/%s", sshHost) - err := open.Run(openURL) + err := devpodopen.Run(openURL) if err != nil { log.Debugf("Starting Zed caused error: %v", err) log.Errorf("Seems like you don't have Zed installed on your computer locally") diff --git a/pkg/ide/zed/zed_linux.go b/pkg/ide/zed/zed_linux.go index 24e9ff396f..a808b666b5 100644 --- a/pkg/ide/zed/zed_linux.go +++ b/pkg/ide/zed/zed_linux.go @@ -5,9 +5,9 @@ package zed import ( "context" "fmt" - "os/exec" "github.com/skevetter/devpod/pkg/config" + devpodopen "github.com/skevetter/devpod/pkg/open" "github.com/skevetter/log" ) @@ -26,10 +26,9 @@ func Open( sshHost := workspaceID + config.SSHHostSuffix + workspaceFolder openURL := fmt.Sprintf("zed://ssh/%s", sshHost) - out, err := exec.Command("xdg-open", openURL).CombinedOutput() + err := devpodopen.Run(openURL) if err != nil { log.Debugf("Starting Zed caused error: %v", err) - log.Debugf("xdg-open %s output: %s", err, openURL, string(out)) log.Errorf("Seems like you don't have Zed installed on your computer locally") return err } diff --git a/pkg/open/appimage_linux.go b/pkg/open/appimage_linux.go new file mode 100644 index 0000000000..7029cbfe27 --- /dev/null +++ b/pkg/open/appimage_linux.go @@ -0,0 +1,92 @@ +package open + +import ( + "os" + "os/exec" + "strings" +) + +// isAppImage reports whether the current process is running inside an AppImage. +func isAppImage() bool { + return os.Getenv("APPIMAGE") != "" +} + +// openURLSanitized opens a URL using xdg-open with a sanitized environment +// to work around AppImage library conflicts. +// +// When running inside an AppImage, the AppRun script prepends the image's +// bundled library paths to LD_LIBRARY_PATH. Child processes like xdg-open +// and gio then load these bundled libraries instead of the system ones, +// causing symbol lookup errors (e.g. "undefined symbol: g_unix_mount_entry_get_options"). +// +// This is a well-known AppImage limitation: +// - https://github.com/AppImage/AppImageKit/issues/396 +// - https://github.com/AppImage/AppImageKit/issues/616 +// +// Tauri's AppImage builds also bundle their own xdg-open wrapper +// (APPIMAGE_BUNDLE_XDG_OPEN=1), which compounds the issue: +// - https://github.com/tauri-apps/tauri/issues/10617 +// - https://github.com/tauri-apps/plugins-workspace/pull/2103 +// +// The fix is to strip AppImage-injected environment variables before spawning +// xdg-open, and to use the system xdg-open by absolute path to bypass any +// bundled wrapper. +func openURLSanitized(url string) error { + // Use the system xdg-open to avoid Tauri's bundled wrapper. + xdgOpen := "/usr/bin/xdg-open" + if _, err := os.Stat(xdgOpen); err != nil { + xdgOpen = "xdg-open" + } + + //nolint:gosec // xdgOpen is either "/usr/bin/xdg-open" or "xdg-open" + cmd := exec.Command(xdgOpen, url) + cmd.Env = sanitizedEnv() + return cmd.Run() +} + +// sanitizedEnv returns a copy of the current environment with AppImage-injected +// variables removed or restored to their pre-AppImage values. +func sanitizedEnv() []string { + // Variables injected by AppImage's AppRun that cause library conflicts + // when inherited by system binaries. + strip := map[string]bool{ + "APPDIR": true, + "APPIMAGE": true, + "ARGV0": true, + "OWD": true, + "APPIMAGE_BUNDLE_XDG_OPEN": true, + } + + var env []string + for _, kv := range os.Environ() { + key, _, _ := strings.Cut(kv, "=") + if strip[key] { + continue + } + env = append(env, kv) + } + + // Restore LD_LIBRARY_PATH to its pre-AppImage value if saved, + // otherwise remove it entirely so system binaries use system libs. + env = removeEnvKey(env, "LD_LIBRARY_PATH") + if orig := os.Getenv("ORIG_LD_LIBRARY_PATH"); orig != "" { + env = append(env, "LD_LIBRARY_PATH="+orig) + } + + // LD_PRELOAD may be set to an exec interception library (exec.so); + // remove it so system binaries aren't affected. + env = removeEnvKey(env, "LD_PRELOAD") + + return env +} + +func removeEnvKey(env []string, key string) []string { + prefix := key + "=" + result := env[:0:0] + for _, kv := range env { + if !strings.HasPrefix(kv, prefix) { + result = append(result, kv) + } + } + return result +} diff --git a/pkg/open/appimage_other.go b/pkg/open/appimage_other.go new file mode 100644 index 0000000000..8ddd44ef27 --- /dev/null +++ b/pkg/open/appimage_other.go @@ -0,0 +1,11 @@ +//go:build !linux + +package open + +func isAppImage() bool { + return false +} + +func openURLSanitized(_ string) error { + panic("openURLSanitized is only available on Linux") +} diff --git a/pkg/open/open.go b/pkg/open/open.go index 5150389595..f04650ac2a 100644 --- a/pkg/open/open.go +++ b/pkg/open/open.go @@ -13,6 +13,16 @@ import ( "github.com/skratchdot/open-golang/open" ) +// Run opens the given URL in the default application. +// When running inside a Linux AppImage, it sanitizes the environment +// to avoid library conflicts before spawning xdg-open. +func Run(url string) error { + if isAppImage() { + return openURLSanitized(url) + } + return open.Run(url) +} + // Open opens the given url in the default application, retrying every second until the context is done. func Open(ctx context.Context, url string, log log.Logger) error { for { @@ -20,7 +30,7 @@ func Open(ctx context.Context, url string, log log.Logger) error { case <-ctx.Done(): return nil case <-time.After(time.Second): - err := tryOpen(ctx, url, open.Start, log) + err := tryOpen(ctx, url, Run, log) if err == nil { return nil } diff --git a/pkg/platform/client/client.go b/pkg/platform/client/client.go index b0c8efcfbd..0b72da5ccd 100644 --- a/pkg/platform/client/client.go +++ b/pkg/platform/client/client.go @@ -20,12 +20,12 @@ import ( storagev1 "github.com/loft-sh/api/v4/pkg/apis/storage/v1" "github.com/loft-sh/api/v4/pkg/auth" pkgconfig "github.com/skevetter/devpod/pkg/config" + devpodopen "github.com/skevetter/devpod/pkg/open" "github.com/skevetter/devpod/pkg/platform/kube" "github.com/skevetter/devpod/pkg/platform/project" "github.com/skevetter/devpod/pkg/util" "github.com/skevetter/devpod/pkg/version" "github.com/skevetter/log" - "github.com/skratchdot/open-golang/open" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/rest" "k8s.io/client-go/tools/clientcmd" @@ -314,7 +314,7 @@ func (c *client) Login(host string, insecure bool, log log.Logger) error { } server := startServer(fmt.Sprintf(RedirectPath, host), keyChannel, log) - err = open.Run(fmt.Sprintf(LoginPath, host)) + err = devpodopen.Run(fmt.Sprintf(LoginPath, host)) if err != nil { return fmt.Errorf( "couldn't open the login page in a browser: %w. Please use the --access-key flag for the login command. "+ From f6b51e8f0a58470a4b521a1db87b4d7fa13ae0ac Mon Sep 17 00:00:00 2001 From: Samuel K Date: Sat, 11 Apr 2026 12:04:29 -0500 Subject: [PATCH 2/3] fix: propagate opener errors in tryOpen instead of silently dropping them --- pkg/open/open.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pkg/open/open.go b/pkg/open/open.go index f04650ac2a..c53c641ce9 100644 --- a/pkg/open/open.go +++ b/pkg/open/open.go @@ -81,7 +81,9 @@ func tryOpen(ctx context.Context, url string, fn func(string) error, log log.Log return nil case <-time.After(time.Second): } - _ = fn(url) + if err := fn(url); err != nil { + return fmt.Errorf("open url: %w", err) + } log.WithFields(logrus.Fields{ "url": url, }).Done("opened url") From 820648d433132d2b08a8b7e296d9eb3ef805d615 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Sat, 11 Apr 2026 12:07:05 -0500 Subject: [PATCH 3/3] fix: return error instead of panicking in non-Linux openURLSanitized stub --- pkg/open/appimage_other.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pkg/open/appimage_other.go b/pkg/open/appimage_other.go index 8ddd44ef27..7e7a6b28d5 100644 --- a/pkg/open/appimage_other.go +++ b/pkg/open/appimage_other.go @@ -2,10 +2,12 @@ package open +import "errors" + func isAppImage() bool { return false } func openURLSanitized(_ string) error { - panic("openURLSanitized is only available on Linux") + return errors.New("openURLSanitized is only available on Linux") }