Skip to content

[Bug]: shellexec writes empty XDG_*_HOME when $SNAP is inherited from any snap (not just Wave-as-snap) #3336

@jphein

Description

@jphein

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:

  1. Tighten snap detection — gate on the Wave snap specifically, not any
    ancestor snap:

    if os.Getenv("SNAP_NAME") == "waveterm" {
  2. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions