Goose Desktop Environment Fix
Fix the Goose Desktop shell tool so that shell commands run with the
full user environment — including devbox/nix-installed tools, crc/oc,
cargo, sdkman, and custom exports.
Problem
Goose Desktop’s shell tool runs non-interactive, non-login bash
shells (bash -c "<command>"). Bash only sources ~/.bashrc for
interactive shells, so all user environment setup is missing:
| Shell Type | Files Sourced |
|---|---|
| Login + Interactive | ~/.bash_profile → ~/.bashrc |
| Interactive (non-login) | ~/.bashrc |
| Non-interactive, non-login | Only $BASH_ENV (if set) |
Goose Desktop falls into the third category, resulting in:
- Bare
PATH(missing devbox/nix, cargo, crc/oc, sdkman paths) - Missing environment variables
- Tools like
gh,oc,hey,javanot found
Additionally, Goose Desktop on Fedora/GNOME may have a user-level
.desktop file override at ~/.local/share/applications/Goose.desktop
that hardcodes PATH via Exec=env PATH=..., preventing
environment.d variables from reaching the Goose process.
Root Causes
- GPU process crash on Wayland — Chromium’s GPU sandbox fails
with
error_code=1002on certain hardware (e.g., ThinkPad P1 Gen 7) when using--ozone-platform=waylandwith Vulkan. The GPU process crashes repeatedly (6-9 attempts) then fatally exits withGPU process isn't usable. Goodbye.On restart, Electron re-probes GPU capabilities, adding ~20s to startup. This has never worked on affected hardware — the failure is present since the earliest logs. Fix: Add--ozone-platform=x11to the Exec line in the.desktopfile. Electron falls back to XWayland for rendering, which is stable and performant for a text-based app. - Bash sourcing rules — non-interactive shells don’t source
~/.bashrc - Nix double-source guard —
nix-daemon.shsets__ETC_PROFILE_NIX_SOURCED=1and skips if already set. The GNOME session sets this during login, but the Goose.desktopfile overridesPATHwithout nix paths, so nix is “sourced” but its paths are missing. - Desktop file override — the user-level
.desktopfile’sExec=env PATH=...line overrides session environment variables set via~/.config/environment.d/ - Devbox
shellenvrecursive fork bomb —devbox global shellenvspawns a bash subprocess to compute the environment. That subprocess sources~/.bashrc(viaBASH_ENVor thegoose-shellwrapper), which callsdevbox global shellenvagain, creating an infinite recursion. This rapidly spawns thousands of processes (observed: 5,763+), consuming CPU and risking system instability.
Devbox Fork Bomb — Detailed Analysis
Trigger Chain
Goose Desktop
→ goose-shell (sources ~/.bashrc)
→ .bashrc: eval "$(devbox global shellenv)"
→ devbox spawns bash subprocess
→ bash sources ~/.bashrc (via goose-shell or BASH_ENV)
→ .bashrc: eval "$(devbox global shellenv)" ← RECURSION
→ devbox spawns bash subprocess
→ ... (infinite loop, 5,763+ processes observed)
Symptoms
- Thousands of
devbox global shellenvprocesses visible inps aux - Each process is a child of the previous, forming a deep nesting chain
- CPU load rises (each process uses a small amount, but thousands add up)
pstreeshows a deeply nested chain:systemd → bash → devbox → devbox → devbox → ... (thousands deep)
Emergency Cleanup
If the fork bomb is already running:
pkill -f 'devbox global shellenv'
Prevention: Shell-Local Guard
Add a recursion guard in ~/.bashrc around the devbox global shellenv
call. This is the critical fix — without it, the fork bomb will
recur on every Goose Desktop restart.
v1.2 pattern — uses a shell-local (non-exported) variable:
# Shell-local guard: prevents re-eval within the same shell process, but does NOT
# propagate to child shells (e.g. devbox shell), so they get a fresh eval and the
# refresh-global alias is properly created. Safe against fork bombs because
# devbox global shellenv just prints text — it doesn't spawn a shell.
if [ -z "$__devbox_shellenv_done" ]; then
__devbox_shellenv_done=1
eval "$(devbox global shellenv 2>/dev/null)"
fi
Why shell-local, not exported?
- An exported guard (like the old
export __DEVBOX_SHELLENV_LOADED=1) is inherited by child shell processes such asdevbox shell. - When
devbox shellstarts a new bash, that bash sources~/.bashrc, sees the inherited guard, and skipsdevbox global shellenventirely. - This means the
refresh-globalalias is never created indevbox shell. - A shell-local variable (
__devbox_shellenv_done=1withoutexport) prevents re-eval within the same process but does NOT propagate to child processes, sodevbox shellgets a fresh eval and the alias works. - Fork bomb safety is maintained because
devbox global shellenvjust prints text to stdout — it does not spawn a shell. Theevalevaluates that text in the current shell. Additionally, devbox’s own output includesexport __DEVBOX_SHELLENV_LOADED=1, providing a secondary layer of protection.
Why __GOOSE_SHELL_SOURCED alone is not enough: The goose-shell
wrapper’s own guard (__GOOSE_SHELL_SOURCED) prevents re-sourcing
~/.bashrc from the wrapper level, but it does NOT prevent the
internal recursion caused by devbox global shellenv spawning child
bash processes that re-enter .bashrc through a different code path.
The shell-local guard catches this inner recursion.
Solution (3 files)
The fix has three components that work together:
File 1: ~/.local/bin/goose-shell (wrapper script)
A bash wrapper that sources ~/.bashrc before executing the command.
Goose calls $GOOSE_SHELL -c "<command>", so this wrapper runs first.
#!/bin/bash
# Goose Desktop shell wrapper — sources ~/.bashrc for full environment
# before executing the command passed via -c.
# Guard against recursive sourcing when exec'd bash also triggers this wrapper.
if [ -z "$__GOOSE_SHELL_SOURCED" ]; then
export __GOOSE_SHELL_SOURCED=1
# The Goose .desktop file sets a bare PATH, so nix/profile.d paths
# are missing. Clear the nix "already sourced" guard so nix-daemon.sh
# re-adds its paths.
unset __ETC_PROFILE_NIX_SOURCED
unset BASHRCSOURCED
if [ -f "$HOME/.bashrc" ]; then
. "$HOME/.bashrc"
fi
fi
exec /bin/bash "$@"
Key details:
__GOOSE_SHELL_SOURCEDguard prevents infinite recursionunset __ETC_PROFILE_NIX_SOURCEDforcesnix-daemon.shto re-add nix paths (they were lost when the.desktopfile overrodePATH)unset BASHRCSOURCEDlets/etc/bashrcre-run to source/etc/profile.d/*.shscriptsexec /bin/bash "$@"replaces the wrapper with real bash to run the actual command
File 2: ~/.local/share/applications/Goose.desktop (desktop entry)
The user-level .desktop file must pass GOOSE_SHELL to the Goose
process. Add it to the existing Exec=env ... line:
[Desktop Entry]
Name=Goose
Exec=env GOOSE_SHELL=$HOME/.local/bin/goose-shell PATH=$HOME/.local/bin:/usr/local/bin:/usr/bin:/bin:/usr/local/sbin:/usr/sbin /usr/lib/Goose/Goose --ozone-platform=x11 %U
Icon=/usr/share/pixmaps/Goose.png
Terminal=false
Type=Application
Categories=Development;
MimeType=x-scheme-handler/goose;
Note on --ozone-platform=x11: Required on systems where
Chromium’s GPU process fails under Wayland (see Root Cause 1). This
flag forces Electron to use XWayland. Omit only if GPU process
launches succeed in your environment (check journalctl --user -t
Goose.desktop | grep GPU).
Note: Replace $HOME with the actual home directory path (e.g.,
/home/username) since .desktop files don’t expand shell variables.
If there is no user-level .desktop override, you can either:
- Create one from the system file at
/usr/share/applications/Goose.desktop - Or rely on
environment.dalone (File 3)
File 3: ~/.config/environment.d/60-goose-shell.conf
Sets GOOSE_SHELL in the systemd user environment as a fallback for
any launch method that doesn’t use the .desktop file:
# Tell Goose Desktop to use our wrapper shell that sources ~/.bashrc
GOOSE_SHELL=$HOME/.local/bin/goose-shell
Note: Replace $HOME with the actual home directory path.
Prerequisite: ~/.bashrc restructuring
The ~/.bashrc must be restructured with an interactive guard so
that environment setup runs for all shells, but interactive-only setup
(completions, prompt integration) is skipped for non-interactive shells:
# .bashrc
# Source global definitions
if [ -f /etc/bashrc ]; then
. /etc/bashrc
fi
# ============================================================
# Environment setup (runs for ALL shells)
# ============================================================
# PATH additions
if ! [[ "$PATH" =~ "$HOME/.local/bin:$HOME/bin:" ]]; then
PATH="$HOME/.local/bin:$HOME/bin:$PATH"
fi
export PATH=$PATH:$HOME/.cargo/bin
# User-specific aliases and functions
if [ -d ~/.bashrc.d ]; then
for rc in ~/.bashrc.d/*; do
[ -f "$rc" ] && . "$rc"
done
fi
unset rc
# Devbox environment
# Shell-local guard: prevents re-eval within same process, but does NOT
# propagate to child shells (e.g. devbox shell), so they get a fresh
# eval and the refresh-global alias is properly created.
if [ -z "$__devbox_shellenv_done" ]; then
__devbox_shellenv_done=1
eval "$(devbox global shellenv 2>/dev/null)"
fi
# Other environment setup (crc/oc, exports, sdkman, etc.)
# ... add your environment exports here ...
# SDKMAN — must be near the end of the environment section
export SDKMAN_DIR="$HOME/.sdkman"
[[ -s "$HOME/.sdkman/bin/sdkman-init.sh" ]] && source "$HOME/.sdkman/bin/sdkman-init.sh"
# ============================================================
# Interactive-only setup (completions, prompt integration)
# Skipped for non-interactive shells (Goose Desktop, scripts, cron)
# ============================================================
[[ $- != *i* ]] && return
# Goose terminal integration
eval "$(goose term init bash)"
# Completions (devbox, hermes, gcloud, etc.)
. <(devbox completion bash 2>/dev/null)
eval "$(hermes completion bash 2>/dev/null)"
Key principle: Everything above [[ $- != *i* ]] && return runs
for ALL shells. Everything below runs only in interactive terminals.
Caution with crc oc-env: The output of crc oc-env contains
a comment line with an unmatched single quote (# eval $(crc oc-env)).
Using eval "$(crc oc-env)" in non-interactive shells causes a
parsing error. Replace it with a direct export PATH= statement:
# Instead of: eval "$(crc oc-env 2>/dev/null)"
export PATH="$HOME/.crc/bin/oc:$PATH"
Guard Variables — Summary
| Variable | Scope | Set By | Purpose |
|---|---|---|---|
__GOOSE_SHELL_SOURCED |
Exported | goose-shell wrapper |
Prevents ~/.bashrc from being re-sourced at the wrapper level |
__devbox_shellenv_done |
Shell-local (NOT exported) | ~/.bashrc |
Prevents devbox shellenv re-eval within same process; does NOT propagate to child shells so devbox shell gets refresh-global |
__DEVBOX_SHELLENV_LOADED |
Exported | devbox’s own output | Secondary safety net — devbox’s built-in recursion guard |
Steps to Apply
Step 1: Create the goose-shell wrapper
cat > ~/.local/bin/goose-shell << 'EOF'
#!/bin/bash
if [ -z "$__GOOSE_SHELL_SOURCED" ]; then
export __GOOSE_SHELL_SOURCED=1
unset __ETC_PROFILE_NIX_SOURCED
unset BASHRCSOURCED
if [ -f "$HOME/.bashrc" ]; then
. "$HOME/.bashrc"
fi
fi
exec /bin/bash "$@"
EOF
chmod +x ~/.local/bin/goose-shell
Step 2: Restructure ~/.bashrc
Move all environment setup (PATH, exports, devbox shellenv, sdkman)
above the interactive guard. Move completions and prompt
integration below it. Add [[ $- != *i* ]] && return as the
separator. Use the shell-local __devbox_shellenv_done guard
(not exported) for the devbox shellenv block.
Step 3: Set GOOSE_SHELL in desktop entry
Check if a user-level .desktop override exists:
ls ~/.local/share/applications/Goose.desktop 2>/dev/null
If it exists, add GOOSE_SHELL=/home/<user>/.local/bin/goose-shell
to the Exec=env ... line. If not, create one from the system file:
cp /usr/share/applications/Goose.desktop ~/.local/share/applications/Goose.desktop
Then edit the Exec line to include GOOSE_SHELL.
Step 4: Set GOOSE_SHELL in environment.d
mkdir -p ~/.config/environment.d
cat > ~/.config/environment.d/60-goose-shell.conf << EOF
GOOSE_SHELL=/home/$(whoami)/.local/bin/goose-shell
EOF
Step 5: Apply and verify
# Reload systemd user environment
systemctl --user daemon-reload
# Update desktop database
update-desktop-database ~/.local/share/applications/ 2>/dev/null
# Restart Goose Desktop, then test:
# In Goose Desktop, run: which gh && gh --version
Verification Checklist
GOOSE_SHELLis set in the Goose process environment (cat /proc/$(pgrep -f '/usr/lib/Goose/Goose' | head -1)/environ | tr '\0' '\n' | grep GOOSE_SHELL)- Shell commands see the full PATH (devbox/nix, cargo, crc/oc, sdkman)
which ghfinds gh at the devbox nix pathwhich ocfinds ocdevbox global shellenvruns without errors- Environment variables are set (
CLOUD_ML_REGION,SDKMAN_DIR, etc.) - Interactive bash sessions still work normally (completions, prompt)
- No
evalerrors in shell output - No runaway
devbox global shellenvprocesses after Goose Desktop restart (ps aux | grep 'devbox global shellenv' | grep -v grep | wc -lshould be 0) - No GPU process crash on startup
(
journalctl --user -t Goose.desktop --since "-5min" | grep -c "GPU process launch failed"should be 0) - Goose Desktop starts in under 5 seconds (no 20s GPU re-probe delay)
refresh-globalalias is available insidedevbox shellsessions (rundevbox shellthentype refresh-global)
Gotchas
-
Goose’s hermit
nodewrapper breaks non-Goose Node.js workflows. Goose ships/usr/lib/Goose/resources/bin/containing wrapper scripts fornode,npx,uvx, andjbang. These wrappers exist so Goose’s MCP server extensions can find a Node.js runtime even when the user hasn’t installed one. Each wrapper sourcesnode-setup-common.sh, whichcds into~/.config/goose/mcp-hermit/and prepends~/.config/goose/mcp-hermit/bin/to PATH before running the real command. This CWD change breaks any npm/pnpm post-install script that uses relative paths (e.g.,node scripts/prebuild.jsresolves to~/.config/goose/mcp-hermit/scripts/prebuild.jsinstead of the package directory).Affected binaries:
node,npx,uvx,jbangonly. Commands likebash,python3,curl,gitare NOT wrapped.Why
goose-shellcan’t fix this: The Goose Electron process itself injects/usr/lib/Goose/resources/bin/onto PATH before thegoose-shellwrapper runs. The wrapper sources~/.bashrcto restore user tools, but Goose’s bin dir still wins because it’s earlier on PATH.Per-skill workaround pattern (recommended for any skill that runs Node.js commands through Goose’s shell tool):
# Use system node directly NODE="/usr/bin/node" # Strip hermit from PATH for pnpm/npm build scripts CLEAN_PATH=$(echo "$PATH" | tr ':' '\n' \ | grep -v 'goose.*hermit' \ | grep -v 'Goose.*bin' \ | tr '\n' ':') CLEAN_PATH="${CLEAN_PATH%:}" PATH="$CLEAN_PATH" $NODE some-commandDo NOT fix globally by reordering PATH to put
/usr/binbefore Goose’s bin dir — this could break MCP server extensions that depend on hermit’s isolated Node.js runtime (which may be a different version than the system Node.js).Skills using this pattern:
dsh-setup(pnpm builds for DSH).
Affected Files
| File | Purpose |
|---|---|
~/.local/bin/goose-shell |
Wrapper: sources ~/.bashrc for non-interactive shells |
~/.local/share/applications/Goose.desktop |
Desktop entry: passes GOOSE_SHELL to Goose process |
~/.config/environment.d/60-goose-shell.conf |
Systemd user env: sets GOOSE_SHELL as fallback |
~/.bashrc |
Restructured: env setup for all shells, interactive guard, shell-local devbox guard |
Platform Notes
- Tested on Fedora with GNOME/Wayland desktop
- Goose Desktop is an Electron app installed at
/usr/lib/Goose/ - The Goose
shelltool honors theGOOSE_SHELLenvironment variable to select the shell binary (defaults to/bin/bash) - The
environment.dmechanism works for apps launched via systemd user session (GNOME on Fedora). Other desktop environments may need different approaches. - If nix is not installed, skip the
unset __ETC_PROFILE_NIX_SOURCEDline in the wrapper
Changelog
See CHANGELOG.md for version history.