Description
wavesrv writes empty strings for XDG_CONFIG_HOME, XDG_DATA_HOME, and
XDG_CACHE_HOME into the environment of every shell it spawns, even when none
of these variables were set in wavesrv's own environment. Tools that path-join
these vars without spec-compliant fallback (e.g. mise) then write their cache,
config, and data into the user's current working directory as relative
dirs (./mise/, ./claude/versions/, ./applications/…).
Originally I assumed this was a Wave-snap-only issue (similar to #3316), but
it reproduces on the dev build running from electron-vite preview — see
root cause below.
Root cause
pkg/shellexec/shellexec.go:646-666:
/*
For Snap installations, we need to correct the XDG environment variables as Snap
overrides them to point to snap directories. We will get the correct values, if
set, from the PAM environment. If the XDG variables are set in profile or in an
RC file, it will be overridden when the shell initializes.
*/
if os.Getenv("SNAP") != "" {
log.Printf("Detected Snap installation, correcting XDG environment variables")
varsToReplace := map[string]string{
"XDG_CONFIG_HOME": "", "XDG_DATA_HOME": "", "XDG_CACHE_HOME": "",
"XDG_RUNTIME_DIR": "", "XDG_CONFIG_DIRS": "", "XDG_DATA_DIRS": "",
}
pamEnvs := tryGetPamEnvVars()
if len(pamEnvs) > 0 {
for k := range pamEnvs {
if _, ok := varsToReplace[k]; ok {
varsToReplace[k] = pamEnvs[k]
}
}
}
log.Printf("Setting XDG environment variables to: %v", varsToReplace)
shellutil.UpdateCmdEnv(ecmd, varsToReplace)
}
Two compounding bugs:
Bug 1: Snap detection is too coarse
os.Getenv("SNAP") != "" returns true whenever wavesrv is launched from any
snap process, not just when Wave itself is installed as a snap. Lots of CLI
tools ship as snaps (mine: task, see env dump below), and snap classic
confinement propagates $SNAP, $SNAP_NAME, $SNAP_DATA, etc. all the way
down the process tree.
A more accurate detection would be:
if os.Getenv("SNAP_NAME") == "waveterm" {
Bug 2: Empty-string defaults are written verbatim when PAM env lacks XDG vars
tryGetPamEnvVars() parses /etc/environment, /etc/security/pam_env.conf,
and ~/.pam_environment. On most modern systems (especially Ubuntu / Debian)
none of these set the XDG base-directory vars — XDG defaults are handled at
the freedesktop spec level, not via PAM. So pamEnvs either returns an empty
map or a map that doesn't contain any of the six XDG keys.
When that happens, the varsToReplace map keeps its empty-string defaults, and
shellutil.UpdateCmdEnv(ecmd, varsToReplace) writes them verbatim into the
spawned shell's environment.
Tools then path-join these empty values and write to relative paths in CWD.
Reproduction (dev build)
Wave dev build, launched as electron-vite preview from a shell that has $SNAP
set due to a launching snap tool (here: a snap named task).
# Process tree
$ pstree -ps $(pgrep wavesrv.x64)
... electron-vite preview ─ electron ─ wavesrv.x64 ─ bash
# wavesrv's own environ — $SNAP is set (inherited from launching snap), but
# the three XDG_*_HOME vars themselves are NOT present
$ tr '\0' '\n' < /proc/$(pgrep wavesrv.x64)/environ | grep -E '^(SNAP=|XDG_)' | sort
SNAP=/snap/task/283
XDG_CONFIG_DIRS=/etc/xdg/xdg-ubuntu:/etc/xdg
XDG_CURRENT_DESKTOP=ubuntu:GNOME
XDG_DATA_DIRS=/usr/share/ubuntu:/usr/share/gnome:/home/USER/.local/share/flatpak/...
XDG_MENU_PREFIX=gnome-
XDG_RUNTIME_DIR=/run/user/1000
XDG_SESSION_CLASS=user
XDG_SESSION_DESKTOP=ubuntu
XDG_SESSION_TYPE=wayland
# bash spawned by wavesrv — the three *_HOME vars are now set-but-empty,
# *_DIRS got truncated, RUNTIME_DIR preserved
$ tr '\0' '\n' < /proc/<spawned-bash-pid>/environ | grep '^XDG_' | sort
XDG_CACHE_HOME=
XDG_CONFIG_DIRS=/etc/xdg
XDG_CONFIG_HOME=
XDG_CURRENT_DESKTOP=ubuntu:GNOME
XDG_DATA_DIRS=/usr/local/share:/usr/share
XDG_DATA_HOME=
XDG_MENU_PREFIX=gnome-
XDG_RUNTIME_DIR=/run/user/1000
XDG_SESSION_CLASS=user
XDG_SESSION_DESKTOP=ubuntu
XDG_SESSION_TYPE=wayland
From within any Wave shell:
$ printf 'XDG_CACHE_HOME=[%s] set?=[%s]\n' "$XDG_CACHE_HOME" "${XDG_CACHE_HOME-UNSET}"
XDG_CACHE_HOME=[] set?=[]
If unset, set? would print UNSET; it prints empty, proving the var is
explicitly set to an empty string.
Impact (real-world)
I traced this after mise outdated started writing its cache into project
directories. Auditing ~/Projects/ turned up 686 MB of leaked tool data
across 7 project repos:
| Tool |
Leaked dir |
Cause |
mise outdated |
./mise/ (× 7) |
mise path-joins XDG_CACHE_HOME + "mise" |
| Claude Code (autoupdater) |
./claude/versions/ (× 3, ~229 MB each) |
path-joins XDG_DATA_HOME + "claude" |
| Claude Code (URL handler) |
./applications/claude-code-url-handler.desktop (× 6) |
writes desktop entry to $XDG_DATA_HOME/applications |
| Android SDK Manager |
./.android/cache/ |
metadata XMLs duplicated into CWD |
Every cd <project> followed by any of these tools running silently created
a fresh cache copy in the project. Three of them were untracked-but-not-
gitignored and would have been committed by an unwary git add ..
Suggested fix
Two independent patches that should both land:
-
Tighten snap detection — gate on the Wave snap specifically, not any
ancestor snap:
if os.Getenv("SNAP_NAME") == "waveterm" {
-
Don't write empty-string defaults — when pamEnvs lacks a given XDG
key, either leave the var alone (so the spec default applies) or set it to
the spec default explicitly:
xdgDefaults := map[string]string{
"XDG_CONFIG_HOME": filepath.Join(home, ".config"),
"XDG_DATA_HOME": filepath.Join(home, ".local/share"),
"XDG_CACHE_HOME": filepath.Join(home, ".cache"),
}
for k, def := range xdgDefaults {
if v, ok := pamEnvs[k]; ok && v != "" {
varsToReplace[k] = v
} else {
varsToReplace[k] = def
}
}
(Or just delete(varsToReplace, k) for keys with no PAM value, so the var
stays unset and the child shell falls back via the spec.)
Workaround
Add to ~/.bashrc (after the interactive-shell guard):
[ -z "${XDG_CACHE_HOME:-}" ] && export XDG_CACHE_HOME="$HOME/.cache"
[ -z "${XDG_CONFIG_HOME:-}" ] && export XDG_CONFIG_HOME="$HOME/.config"
[ -z "${XDG_DATA_HOME:-}" ] && export XDG_DATA_HOME="$HOME/.local/share"
This protects interactive shells. Non-interactive subprocesses launched from a
Wave shell (cron, systemd user units, CLI tools that fork before sourcing rc
files) still need per-script guarding.
Environment
- WaveTerm: dev build,
electron-vite preview from main (commit current as of
2026-05-27). Binary at ~/Projects/waveterm/dist/bin/wavesrv.x64.
- The wavesrv process had inherited
SNAP=/snap/task/283 and friends from a
CLI snap (task) that was active in the launching shell, despite Wave itself
not being installed as a snap.
- Distro: Ubuntu 26.04, Wayland / GNOME
- Shell: bash 5.x (system)
Related
Description
wavesrvwrites empty strings forXDG_CONFIG_HOME,XDG_DATA_HOME, andXDG_CACHE_HOMEinto the environment of every shell it spawns, even when noneof these variables were set in
wavesrv's own environment. Tools that path-jointhese vars without spec-compliant fallback (e.g.
mise) then write their cache,config, and data into the user's current working directory as relative
dirs (
./mise/,./claude/versions/,./applications/…).Originally I assumed this was a Wave-snap-only issue (similar to #3316), but
it reproduces on the dev build running from
electron-vite preview— seeroot cause below.
Root cause
pkg/shellexec/shellexec.go:646-666:Two compounding bugs:
Bug 1: Snap detection is too coarse
os.Getenv("SNAP") != ""returns true wheneverwavesrvis launched from anysnap process, not just when Wave itself is installed as a snap. Lots of CLI
tools ship as snaps (mine:
task, see env dump below), and snap classicconfinement propagates
$SNAP,$SNAP_NAME,$SNAP_DATA, etc. all the waydown the process tree.
A more accurate detection would be:
Bug 2: Empty-string defaults are written verbatim when PAM env lacks XDG vars
tryGetPamEnvVars()parses/etc/environment,/etc/security/pam_env.conf,and
~/.pam_environment. On most modern systems (especially Ubuntu / Debian)none of these set the XDG base-directory vars — XDG defaults are handled at
the freedesktop spec level, not via PAM. So
pamEnvseither returns an emptymap or a map that doesn't contain any of the six XDG keys.
When that happens, the
varsToReplacemap keeps its empty-string defaults, andshellutil.UpdateCmdEnv(ecmd, varsToReplace)writes them verbatim into thespawned shell's environment.
Tools then path-join these empty values and write to relative paths in CWD.
Reproduction (dev build)
Wave dev build, launched as
electron-vite previewfrom a shell that has$SNAPset due to a launching snap tool (here: a snap named
task).From within any Wave shell:
If unset,
set?would printUNSET; it prints empty, proving the var isexplicitly set to an empty string.
Impact (real-world)
I traced this after
mise outdatedstarted writing its cache into projectdirectories. Auditing
~/Projects/turned up 686 MB of leaked tool dataacross 7 project repos:
mise outdated./mise/(× 7)misepath-joinsXDG_CACHE_HOME + "mise"./claude/versions/(× 3, ~229 MB each)XDG_DATA_HOME + "claude"./applications/claude-code-url-handler.desktop(× 6)$XDG_DATA_HOME/applications./.android/cache/Every
cd <project>followed by any of these tools running silently createda fresh cache copy in the project. Three of them were untracked-but-not-
gitignored and would have been committed by an unwary
git add ..Suggested fix
Two independent patches that should both land:
Tighten snap detection — gate on the Wave snap specifically, not any
ancestor snap:
Don't write empty-string defaults — when
pamEnvslacks a given XDGkey, either leave the var alone (so the spec default applies) or set it to
the spec default explicitly:
(Or just
delete(varsToReplace, k)for keys with no PAM value, so the varstays unset and the child shell falls back via the spec.)
Workaround
Add to
~/.bashrc(after the interactive-shell guard):This protects interactive shells. Non-interactive subprocesses launched from a
Wave shell (cron, systemd user units, CLI tools that fork before sourcing rc
files) still need per-script guarding.
Environment
electron-vite previewfrom main (commit current as of2026-05-27). Binary at
~/Projects/waveterm/dist/bin/wavesrv.x64.SNAP=/snap/task/283and friends from aCLI snap (
task) that was active in the launching shell, despite Wave itselfnot being installed as a snap.
Related
desktop-common.shdeletes XDG user dirs / self-referentialsymlinks under classic confinement (a different XDG mishandling path)