Fix Fleet Desktop not launching on OpenSUSE 16 (#44482)

This pull request addresses a startup issue with Fleet Desktop on
openSUSE Leap 16 and similar Linux distributions. The main change is to
adjust how Fleet Desktop and key-escrow dialogs are launched to avoid
environment variable loss caused by login shell profile scripts. The fix
is scoped specifically to openSUSE Leap 16+ to avoid impacting other
distributions.

**Distribution-specific sudo invocation changes:**

* The `-i` (login shell) flag is now omitted from the `sudo` command
when launching Fleet Desktop and key-escrow dialogs on openSUSE Leap 16
and newer, preventing environment variables from being lost due to
profile script interference.
[[1]](diffhunk://#diff-633ab361af6795ef458233819e2806dfba4ca56f684866d956321825b8fd2e91R1)
[[2]](diffhunk://#diff-3e8315d9f12512bce490457c5d20bd7c5aebaa2a8e18b1abf50e504815dd7a9dR178-R193)
* For all other supported distributions, the previous behavior (using
`-i`) is preserved to maintain compatibility and avoid unnecessary
re-testing.

**Detection logic:**

* Introduced a new helper function `isOpenSUSELeap16Plus` in
`execuser_linux.go` to detect if the host is running openSUSE Leap 16 or
newer by parsing `/etc/os-release`. This ensures the workaround is only
applied where necessary.

---


**Related issue:** N/A — surfaced via field investigation on openSUSE
Leap 16 (arm64).

This PR addresses two distinct issues that together prevent Fleet
Desktop from working on openSUSE Leap 16, both validated end-to-end on a
real Leap 16 (arm64) host.

## 1. Launch reliability — drop `sudo -i`

`orbit/pkg/execuser/execuser_linux.go`

On Linux, Orbit launches Fleet Desktop with:

```
sudo -n -i -u <user> -H env WAYLAND_DISPLAY=… … FLEET_DESKTOP_DEVICE_IDENTIFIER_PATH=/opt/orbit/identifier … /…/fleet-desktop
```

The `-i` flag makes sudo "simulate initial login" — it runs the target
user's shell as a login shell and wraps the rest of the command in `bash
--login -c '<escaped>'`. That sources `/etc/profile` and every script in
`/etc/profile.d/*` before our `env KEY=val … fleet-desktop` line runs,
and shell metacharacters (`=`, `:`, `/`, `.`) get backslash-escaped
through the shell layer.

On **openSUSE Leap 16 (arm64)**, that indirection causes the inline
env-var assignments to not reach `fleet-desktop`, which exits
immediately with:

```
FTL missing URL environment FLEET_DESKTOP_DEVICE_IDENTIFIER_PATH
```

Orbit then respawns it every ~15 s in a tight kill-and-respawn loop, so
the tray icon never appears.

**Fix:** drop `-i` from the sudo invocation. We don't need a login
shell:
- `-H` already sets `HOME` to the target user.
- sudo's default `env_reset` sets `USER` / `LOGNAME` / `SHELL` / `MAIL`
and `PATH` to `secure_path`.
- All session vars (`WAYLAND_DISPLAY`, `DISPLAY`,
`DBUS_SESSION_BUS_ADDRESS`, `LD_LIBRARY_PATH`) and every
`FLEET_DESKTOP_*` var are already passed explicitly via `env KEY=val …`.

After the change, sudo `execve()`s `env` directly with no shell layer in
between, so `/etc/profile.d` sourcing and shell-escaping are out of the
picture.

The `runuser -l` /proc/keys-leak regression from PR #32309 does not
apply — that was specific to `runuser -l` creating session keyrings;
sudo without `-i` doesn't.

# Checklist for submitter

- [x] Changes file added:
`orbit/changes/fleet-desktop-linux-no-login-shell`
- [x] Input data is properly validated; untrusted data interpolated into
shell scripts/commands is validated against shell metacharacters.
- [x] Timeouts are implemented and retries are limited to avoid infinite
loops (script's wait loop now bounded at 90s).
- [x] If paths of existing endpoints are modified without backwards
compatibility, checked the frontend/CLI for any necessary changes — N/A.

## Testing

Manual QA needed before merge:

- [x] **openSUSE Leap 16 (arm64)** — Fleet Desktop process starts, stays
running, env vars present, no FTL respawn loop. Done via `sudo` shim.
- [x] **openSUSE Leap 16 (arm64) — extension fallback** — manual tarball
install + schema compilation produces a working tray icon (matching what
the script automates).
- [ ] **Ubuntu 22.04 / 24.04** — regression check: Fleet Desktop tray
icon still appears, key-escrow zenity dialog still renders, AppIndicator
script still installs via the official path.
- [ ] **Fedora (recent)** — regression check: same as above.
- [ ] **Debian** — regression check: same as above.
- [ ] **openSUSE Tumbleweed** — confirm `InstallRemoteExtension` path
still works (no fallback path triggered).

## fleetd/orbit/Fleet Desktop

- [x] Verified compatibility with the latest released version of Fleet —
pure launch-flag change plus a script update; no protocol or schema
impact.
- [x] If the change applies to only one platform, confirmed that
`runtime.GOOS` is used as needed to isolate changes — Go change is in
`execuser_linux.go`, only built on Linux. The script is Linux-only by
construction.
- [ ] Verified that fleetd runs on macOS, Linux and Windows — Linux
re-verification pending QA above; macOS/Windows code paths unchanged.
- [ ] Verified auto-update works from the released version of component
to the new version.

## Notes for reviewers

- The tray-icon visibility issue is an OS-side prerequisite (GNOME 3.26+
has no native tray), so the AppIndicator extension is required
regardless. Even after installing it, Wayland requires a logout/login to
pick up new extensions — this is documented behavior and not specific to
the fallback path.
This commit is contained in:
Allen Houchins
2026-05-01 23:26:56 -05:00
committed by GitHub
parent 23d26db33a
commit fa38063590
3 changed files with 201 additions and 23 deletions
@@ -1,7 +1,8 @@
#!/bin/bash
# Script assumes one user is using the desktop environment (no multi-session).
# It was tested on Fedora 38, 39, Debian 12, and OpenSUSE Leap/Tumbleweed.
# It was tested on Fedora 38, 39, Debian 12, openSUSE Leap 15/Tumbleweed, and
# openSUSE Leap 16.
set -x
@@ -29,11 +30,42 @@ if [ -f /etc/os-release ]; then
. /etc/os-release
distro_id="$ID"
distro_name="$NAME"
distro_version_id="$VERSION_ID"
else
distro_id="unknown"
distro_name="unknown"
distro_version_id=""
fi
# Detect openSUSE Leap 16+. On Leap 16 the GNOME extension install path differs
# from other distros: extensions.gnome.org doesn't list a compatible build of
# this extension, and `sudo -i` exec's are denied, so we install the extension
# directly from the upstream tarball and use a `sudo -u ... env` invocation
# instead of `sudo -i`. Other distros keep the previously QA'd behavior.
is_opensuse_leap_16_plus=false
if [ "$distro_id" = "opensuse-leap" ]; then
major_version="${distro_version_id%%.*}"
if [ -n "$major_version" ] && [ "$major_version" -ge 16 ] 2>/dev/null; then
is_opensuse_leap_16_plus=true
fi
fi
# run_as_user runs a command as the GUI user with the session DBus address set.
# On openSUSE Leap 16+ we drop sudo's -i flag because, in that environment,
# `sudo -i` wraps the command in `bash --login -c '<escaped>'` and the exec of
# /bin/bash is denied (the same root cause that breaks fleet-desktop launch).
# Other distros keep the previous "-i + DBUS=val command" form so we don't
# change behavior on already-QA'd platforms.
run_as_user() {
if [ "$is_opensuse_leap_16_plus" = true ]; then
sudo -u "$username" -H \
env DBUS_SESSION_BUS_ADDRESS="unix:path=/run/user/$uid/bus" "$@"
else
sudo -i -u "$username" -H \
DBUS_SESSION_BUS_ADDRESS="unix:path=/run/user/$uid/bus" "$@"
fi
}
# Determine extension name and installation method based on distribution
case "$distro_id" in
"opensuse-leap"|"opensuse-tumbleweed"|"opensuse")
@@ -51,19 +83,24 @@ case "$distro_id" in
;;
esac
# Check if the AppIndicator extension is already installed
# Check if the AppIndicator extension is already installed. We look for
# metadata.json (not just the directory) so that a half-baked stub left over
# from an earlier failed install — e.g. one where gnome-shell created the
# directory but bailed before writing metadata.json — is treated as
# "not installed" and gets re-installed properly. Without this, downstream
# `gnome-extensions enable` would fail with "Extension does not exist".
extension_path="/home/$username/.local/share/gnome-shell/extensions/$extension_name"
extension_metadata="$extension_path/metadata.json"
extension_installed=false
if [ -d "$extension_path" ]; then
if [ -f "$extension_metadata" ]; then
extension_installed=true
fi
# If no extension is installed, install the appropriate one
if [ "$extension_installed" = false ]; then
# Show notification to user before the prompt.
sudo -i -u $username -H DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/$uid/bus \
gdbus call --session \
run_as_user gdbus call --session \
--dest org.freedesktop.Notifications \
--object-path /org/freedesktop/Notifications \
--method org.freedesktop.Notifications.Notify \
@@ -72,25 +109,114 @@ if [ "$extension_installed" = false ]; then
# Give some time to user to see notification.
sleep 10
# Use GNOME Extensions for all distributions (including OpenSUSE)
sudo -i -u $username -H DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/$uid/bus \
gdbus call --session \
--dest org.gnome.Shell.Extensions \
--object-path /org/gnome/Shell/Extensions \
--method org.gnome.Shell.Extensions.InstallRemoteExtension \
"$extension_name"
if [ "$is_opensuse_leap_16_plus" = true ]; then
# On openSUSE Leap 16+, skip the dbus InstallRemoteExtension call and go
# straight to the upstream tarball: extensions.gnome.org doesn't list a
# compatible build of this extension for Leap 16's GNOME, so the dbus
# call returns "Remote peer disconnected" immediately and at best leaves
# a half-baked stub directory behind. Skipping it avoids ~90s of
# dead-end waiting and a useless install prompt the user can't complete.
#
# Clear any stub directory left from a previous run so our copy below
# isn't laying files on top of a half-baked install.
if [ -d "$extension_path" ] && [ ! -f "$extension_metadata" ]; then
sudo rm -rf "$extension_path"
fi
# Wait until the extension is accepted by the user ("gdbus call" command above is asynchronous).
while [ ! -d "/home/$username/.local/share/gnome-shell/extensions/$extension_name" ]; do
sleep 1
done
extensions_dir="/home/$username/.local/share/gnome-shell/extensions"
tarball_url="https://github.com/ubuntu/gnome-shell-extension-appindicator/archive/refs/heads/master.tar.gz"
tmp_dir=$(mktemp -d /tmp/fleet-appindicator.XXXXXX)
tarball="$tmp_dir/extension.tar.gz"
# Sleep to give some time for files to be downloaded.
sleep 15
# Download tarball — curl preferred, wget as fallback. Bail out cleanly
# if neither can fetch it (e.g. no network) so we don't leave a
# half-installed extension.
fetched=false
if command -v curl >/dev/null 2>&1; then
if curl -fsSL --max-time 60 -o "$tarball" "$tarball_url"; then
fetched=true
fi
elif command -v wget >/dev/null 2>&1; then
if wget -q --timeout=60 -O "$tarball" "$tarball_url"; then
fetched=true
fi
fi
if [ "$fetched" = true ] && [ -s "$tarball" ]; then
# tar isn't part of a minimal openSUSE Leap 16 install, so pull it
# in via zypper if it's missing. We're already running as root here
# (this whole branch runs from the root-detached script invocation
# at the top of this file).
if ! command -v tar >/dev/null 2>&1; then
zypper --non-interactive install --no-recommends tar \
>/dev/null 2>&1 || true
fi
if command -v tar >/dev/null 2>&1; then
sudo -u $username -H mkdir -p "$extensions_dir"
# Extract into a staging dir under our root-owned tmp_dir, then
# copy the contents into the user's UUID-named extension path
# and hand ownership to the user. We do the copy as root rather
# than `sudo -u $username` because mktemp's tmp_dir is mode 700
# owned by root, which the user can't traverse.
staging="$tmp_dir/staging"
mkdir -p "$staging"
if tar -xzf "$tarball" -C "$staging" --strip-components=1; then
mkdir -p "$extension_path"
cp -r "$staging/." "$extension_path/"
chown -R "$username":"$username" "$extension_path"
if [ -d "$extension_path/schemas" ] && command -v glib-compile-schemas >/dev/null 2>&1; then
sudo -u $username -H glib-compile-schemas "$extension_path/schemas/"
fi
fi
fi
fi
rm -rf "$tmp_dir"
else
# Other distributions: prompt the user via gnome-shell's
# InstallRemoteExtension and wait indefinitely for them to accept.
# This is the previously QA'd behavior on Fedora / Debian / Ubuntu /
# openSUSE Tumbleweed.
run_as_user gdbus call --session \
--dest org.gnome.Shell.Extensions \
--object-path /org/gnome/Shell/Extensions \
--method org.gnome.Shell.Extensions.InstallRemoteExtension \
"$extension_name"
while [ ! -f "$extension_metadata" ]; do
sleep 1
done
# Give gnome-shell a moment to finish writing extension files after
# the directory shows up. Not needed on the Leap 16 path above, where
# we already wrote everything synchronously via curl + tar.
sleep 15
fi
fi
# Enable the extension
if [ -d "$extension_path" ]; then
sudo -i -u $username -H DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/$uid/bus \
gnome-extensions enable "$extension_name"
# Enable the extension.
if [ -f "$extension_metadata" ]; then
if [ "$is_opensuse_leap_16_plus" = true ]; then
# gnome-shell on Leap 16 (Wayland) doesn't rescan
# ~/.local/share/gnome-shell/extensions while running, so
# `gnome-extensions enable` (which talks to the live gnome-shell)
# reports the extension as missing. Pre-seed the dconf list directly;
# gnome-shell will pick it up and enable it on the user's next login.
current_extensions=$(run_as_user gsettings get org.gnome.shell enabled-extensions)
case "$current_extensions" in
*"'$extension_name'"*)
;;
"@as []"|"[]")
run_as_user gsettings set org.gnome.shell enabled-extensions \
"['$extension_name']"
;;
*)
run_as_user gsettings set org.gnome.shell enabled-extensions \
"${current_extensions%]}, '$extension_name']"
;;
esac
else
run_as_user gnome-extensions enable "$extension_name"
fi
fi
@@ -0,0 +1 @@
* Fixed Fleet Desktop failing to start on openSUSE Leap 16, by dropping the `-i` (login shell) flag from the sudo invocation used to launch Fleet Desktop and key-escrow dialogs as the logged-in user.
+52 -1
View File
@@ -175,7 +175,22 @@ func getConfigForCommand(user string, path string) (args []string, env []string,
Str("session_type", userDisplaySession.Type.String()).
Msg("running sudo")
args = []string{"-n", "-i", "-u", user, "-H"}
// On openSUSE Leap 16+ we drop -i (login shell). With -i, sudo runs the target
// user's shell as a login shell and passes the rest of the command via
// `bash --login -c`, which sources /etc/profile and /etc/profile.d/* and
// shell-escapes the inline command. On Leap 16 that environment indirection
// causes our `env KEY=val ... fleet-desktop` invocation to lose env vars, so
// fleet-desktop exits with "missing URL environment ..." and Orbit respawns it
// in a tight loop. -H sets HOME to the target user; sudo's default env_reset
// already sets USER/LOGNAME/SHELL.
//
// We keep -i on every other supported distribution to preserve the previously
// QA'd behavior.
if isOpenSUSELeap16Plus() {
args = []string{"-n", "-u", user, "-H"}
} else {
args = []string{"-n", "-i", "-u", user, "-H"}
}
env = make([]string, 0)
if userDisplaySession.Type == userpkg.GuiSessionTypeWayland {
@@ -200,6 +215,42 @@ func getConfigForCommand(user string, path string) (args []string, env []string,
return args, env, nil
}
// isOpenSUSELeap16Plus reports whether the host is running openSUSE Leap 16 or
// newer. We scope the no-login-shell sudo workaround to that distribution since
// it is the one observed to break under sudo -i; other distributions retain the
// previous (login-shell) launch path so we don't have to re-QA them.
func isOpenSUSELeap16Plus() bool {
data, err := os.ReadFile("/etc/os-release")
if err != nil {
return false
}
var id, versionID string
for line := range strings.SplitSeq(string(data), "\n") {
key, value, ok := strings.Cut(line, "=")
if !ok {
continue
}
// /etc/os-release values may be quoted.
value = strings.Trim(value, `"'`)
switch key {
case "ID":
id = value
case "VERSION_ID":
versionID = value
}
}
if id != "opensuse-leap" {
return false
}
// VERSION_ID is typically "16" or "16.0"; compare the major component.
major, _, _ := strings.Cut(versionID, ".")
n, err := strconv.Atoi(major)
if err != nil {
return false
}
return n >= 16
}
// getUserWaylandDisplay returns the value to set on WAYLAND_DISPLAY for the given user.
func getUserWaylandDisplay(uid string) (string, error) {
matches, err := filepath.Glob("/run/user/" + uid + "/wayland-*")