From 7e379f22d8a935727022368683500cd509d258f3 Mon Sep 17 00:00:00 2001 From: Ammar Date: Tue, 2 Dec 2025 00:29:28 -0600 Subject: [PATCH] =?UTF-8?q?=F0=9F=A4=96=20feat:=20add=20~/.mux/bashrc=20fo?= =?UTF-8?q?r=20shell=20environment=20customization?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When mux launches from Applications (not terminal), agent bash commands get a minimal PATH missing user tools. The standard ~/.bashrc isn't sourced because: 1. `bash -c` creates a non-interactive shell 2. Most bashrc files have interactivity guards that skip content This adds a dedicated ~/.mux/bashrc file that is always sourced before every bash command, allowing users to set up: - PATH modifications (Homebrew, ~/bin, etc.) - Nix profile sourcing - direnv hooks (auto-loads .envrc per directory) - pyenv/rbenv/nvm/asdf initialization Implementation: - LocalBaseRuntime.exec() prepends bashrc sourcing to all commands - SSHRuntime.exec() prepends bashrc sourcing (uses remote ~/.mux/bashrc) - Init hooks also source bashrc for consistency The snippet uses `[ -f $HOME/.mux/bashrc ] && . $HOME/.mux/bashrc || true` to ensure commands don't fail when the file doesn't exist. Fixes #797 _Generated with mux_ --- docs/SUMMARY.md | 1 + docs/bashrc.md | 146 +++++++++++++++++++++++ src/common/constants/paths.test.ts | 35 ++++++ src/common/constants/paths.ts | 27 +++++ src/node/runtime/LocalBaseRuntime.ts | 13 +- src/node/runtime/SSHRuntime.ts | 5 + src/node/runtime/WorktreeRuntime.test.ts | 82 ++++++++++++- 7 files changed, 305 insertions(+), 4 deletions(-) create mode 100644 docs/bashrc.md create mode 100644 src/common/constants/paths.test.ts diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index e7166f0ea..d7e87df7a 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -23,6 +23,7 @@ - [Instruction Files](./instruction-files.md) - [Project Secrets](./project-secrets.md) - [Agentic Git Identity](./agentic-git-identity.md) +- [Shell Environment](./bashrc.md) # Advanced diff --git a/docs/bashrc.md b/docs/bashrc.md new file mode 100644 index 000000000..adf38c2c4 --- /dev/null +++ b/docs/bashrc.md @@ -0,0 +1,146 @@ +# Shell Environment (`~/.mux/bashrc`) + +Customize the shell environment for agent bash commands by creating a `~/.mux/bashrc` file. + +## Why This Exists + +When mux runs bash commands (via `bash -c "command"`), the shell is **non-interactive** and **doesn't source `~/.bashrc`**. Most users have interactivity guards in their bashrc that skip content in non-interactive shells: + +```bash +# Common pattern in ~/.bashrc that skips setup for non-interactive shells +[[ $- != *i* ]] && return +``` + +This means: + +- **Launching from Applications** — PATH is minimal (`/usr/bin:/bin:/usr/sbin:/sbin`) +- **Nix/direnv users** — Shell customizations aren't applied +- **Homebrew/pyenv/rbenv** — Tools not in PATH + +The `~/.mux/bashrc` file is **always sourced** before every bash command, giving you a place to set up the environment reliably. + +## Setup + +Create `~/.mux/bashrc`: + +```bash +mkdir -p ~/.mux +touch ~/.mux/bashrc +``` + +Add your shell customizations: + +```bash +# ~/.mux/bashrc + +# Add Homebrew to PATH (macOS) +eval "$(/opt/homebrew/bin/brew shellenv)" + +# Add ~/bin to PATH +export PATH="$HOME/bin:$PATH" +``` + +## Examples + +### Nix Users + +```bash +# ~/.mux/bashrc + +# Source nix profile +if [ -e "$HOME/.nix-profile/etc/profile.d/nix.sh" ]; then + . "$HOME/.nix-profile/etc/profile.d/nix.sh" +fi + +# Enable direnv (auto-loads .envrc per directory) +eval "$(direnv hook bash)" +``` + +### Python (pyenv) + +```bash +# ~/.mux/bashrc +export PYENV_ROOT="$HOME/.pyenv" +export PATH="$PYENV_ROOT/bin:$PATH" +eval "$(pyenv init -)" +``` + +### Node.js (nvm) + +```bash +# ~/.mux/bashrc +export NVM_DIR="$HOME/.nvm" +[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh" +``` + +### Ruby (rbenv) + +```bash +# ~/.mux/bashrc +eval "$(rbenv init -)" +``` + +### Multiple Tools (asdf) + +```bash +# ~/.mux/bashrc +. "$HOME/.asdf/asdf.sh" +``` + +## Behavior + +- **Sourced before every bash command** — including init hooks +- **Silently skipped if missing** — no bashrc file = no effect +- **Errors propagate** — if your bashrc has errors, they appear in command output +- **SSH workspaces** — the remote `~/.mux/bashrc` is sourced (you manage it on the remote host) + +## Comparison with Init Hooks + +| Feature | `~/.mux/bashrc` | `.mux/init` | +| ------- | --------------------- | -------------------------- | +| When | Every bash command | Once at workspace creation | +| Scope | Global (all projects) | Per-project | +| Purpose | Shell environment | Project build/install | +| Errors | Command fails | Logged, non-blocking | + +Use **bashrc** for environment (PATH, tools, direnv hooks). +Use **init hooks** for project setup (install dependencies, build). + +## Troubleshooting + +### Commands Not Finding Tools + +If tools aren't found, check that bashrc is being sourced: + +```bash +# In mux, run: +echo "bashrc: $MUX_BASHRC_SOURCED" +``` + +If empty, your bashrc might not exist. If you want to confirm it's being sourced, add to your bashrc: + +```bash +# ~/.mux/bashrc +export MUX_BASHRC_SOURCED=1 +``` + +### Bashrc Errors + +Errors in your bashrc will cause commands to fail. Test your bashrc: + +```bash +bash -c '[ -f "$HOME/.mux/bashrc" ] && . "$HOME/.mux/bashrc" && echo ok' +``` + +### Performance + +The bashrc runs before every command. Keep it fast: + +- Avoid expensive operations (network calls, slow init scripts) +- Use lazy loading where possible +- Profile with `time bash -c '. ~/.mux/bashrc'` + +## Related + +- [Init Hooks](./init-hooks.md) — Per-project initialization scripts +- [Project Secrets](./project-secrets.md) — Environment variables for API keys diff --git a/src/common/constants/paths.test.ts b/src/common/constants/paths.test.ts new file mode 100644 index 000000000..ecbe23c8d --- /dev/null +++ b/src/common/constants/paths.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "bun:test"; +import { getMuxBashrcSourceSnippet } from "./paths"; + +describe("getMuxBashrcSourceSnippet", () => { + it("should return a bash snippet that sources ~/.mux/bashrc if it exists", () => { + const snippet = getMuxBashrcSourceSnippet(); + + // Should check for file existence with -f + expect(snippet).toContain("[ -f"); + // Should reference $HOME/.mux/bashrc + expect(snippet).toContain('$HOME/.mux/bashrc"'); + // Should source the file with . (dot command) + expect(snippet).toContain(". "); + }); + + it("should use $HOME for portability across local and SSH runtimes", () => { + const snippet = getMuxBashrcSourceSnippet(); + + // Should not use ~ (tilde) which doesn't expand in all contexts + expect(snippet).not.toContain("~/"); + // Should use $HOME which expands reliably + expect(snippet).toContain("$HOME/"); + }); + + it("should silently skip if file doesn't exist and return success", () => { + const snippet = getMuxBashrcSourceSnippet(); + + // Should use the pattern: [ -f file ] && . file || true + // - If file doesn't exist, [ -f file ] returns 1, && short-circuits, || true returns 0 + // - If file exists, [ -f file ] returns 0, && sources file, || is skipped + // The || true is critical for SSH runtime where commands are joined with && + expect(snippet).toContain("] && ."); + expect(snippet).toContain("|| true"); + }); +}); diff --git a/src/common/constants/paths.ts b/src/common/constants/paths.ts index f4ca660b5..f16016299 100644 --- a/src/common/constants/paths.ts +++ b/src/common/constants/paths.ts @@ -116,3 +116,30 @@ export function getMuxExtensionMetadataPath(rootDir?: string): string { const root = rootDir ?? getMuxHome(); return join(root, "extensionMetadata.json"); } + +/** + * Filename for the user's mux bashrc file. + * This is sourced before every bash command to set up the shell environment. + * + * Unlike ~/.bashrc, this file is always sourced (even in non-interactive shells) + * because `bash -c` doesn't source ~/.bashrc by default, and most users have + * interactivity guards in their bashrc that skip content for non-interactive shells. + * + * Users can put PATH modifications, nix profile sourcing, direnv hooks, etc. here. + */ +export const MUX_BASHRC_FILENAME = "bashrc"; + +/** + * Get the bash snippet to source ~/.mux/bashrc if it exists. + * Uses $HOME to work correctly on both local and SSH runtimes. + * + * @returns Bash snippet to prepend to commands + */ +export function getMuxBashrcSourceSnippet(): string { + // Use $HOME/.mux/bashrc to work on both local and SSH runtimes + // The pattern `[ -f file ] && . file || true` ensures: + // 1. If file exists: source it, return its exit status (typically 0) + // 2. If file doesn't exist: [ -f ] returns 1, && short-circuits, || true returns 0 + // This is critical for SSH runtime where commands are joined with && + return `[ -f "$HOME/.mux/bashrc" ] && . "$HOME/.mux/bashrc" || true`; +} diff --git a/src/node/runtime/LocalBaseRuntime.ts b/src/node/runtime/LocalBaseRuntime.ts index bc3dce26c..6b553b605 100644 --- a/src/node/runtime/LocalBaseRuntime.ts +++ b/src/node/runtime/LocalBaseRuntime.ts @@ -28,6 +28,7 @@ import { createLineBufferedLoggers, getInitHookEnv, } from "./initHook"; +import { getMuxBashrcSourceSnippet } from "@/common/constants/paths"; /** * Abstract base class for local runtimes (both WorktreeRuntime and LocalRuntime). @@ -67,6 +68,10 @@ export abstract class LocalBaseRuntime implements Runtime { ); } + // Prepend bashrc sourcing to set up environment (PATH, nix, direnv, etc.) + // This is necessary because `bash -c` doesn't source ~/.bashrc + const commandWithBashrc = `${getMuxBashrcSourceSnippet()}\n${command}`; + // If niceness is specified on Unix/Linux, spawn nice directly to avoid escaping issues // Windows doesn't have nice command, so just spawn bash directly const isWindows = process.platform === "win32"; @@ -74,8 +79,8 @@ export abstract class LocalBaseRuntime implements Runtime { const spawnCommand = options.niceness !== undefined && !isWindows ? "nice" : bashPath; const spawnArgs = options.niceness !== undefined && !isWindows - ? ["-n", options.niceness.toString(), bashPath, "-c", command] - : ["-c", command]; + ? ["-n", options.niceness.toString(), bashPath, "-c", commandWithBashrc] + : ["-c", commandWithBashrc]; const childProcess = spawn(spawnCommand, spawnArgs, { cwd, @@ -366,7 +371,9 @@ export abstract class LocalBaseRuntime implements Runtime { return new Promise((resolve) => { const bashPath = getBashPath(); - const proc = spawn(bashPath, ["-c", `"${hookPath}"`], { + // Source bashrc before running init hook so it has access to user's environment + const commandWithBashrc = `${getMuxBashrcSourceSnippet()}\n"${hookPath}"`; + const proc = spawn(bashPath, ["-c", commandWithBashrc], { cwd: workspacePath, stdio: ["ignore", "pipe", "pipe"], env: { diff --git a/src/node/runtime/SSHRuntime.ts b/src/node/runtime/SSHRuntime.ts index c78e3a7e3..1501fae42 100644 --- a/src/node/runtime/SSHRuntime.ts +++ b/src/node/runtime/SSHRuntime.ts @@ -26,6 +26,7 @@ import { getErrorMessage } from "@/common/utils/errors"; import { execAsync, DisposableProcess } from "@/node/utils/disposableExec"; import { getControlPath } from "./sshConnectionPool"; import { getBashPath } from "@/node/utils/main/bashPath"; +import { getMuxBashrcSourceSnippet } from "@/common/constants/paths"; /** * Shell-escape helper for remote bash. @@ -107,6 +108,10 @@ export class SSHRuntime implements Runtime { // Add cd command if cwd is specified parts.push(cdCommandForSSH(options.cwd)); + // Source ~/.mux/bashrc if it exists (sets up PATH, nix, direnv, etc.) + // This is necessary because `bash -c` doesn't source ~/.bashrc + parts.push(getMuxBashrcSourceSnippet()); + // Add environment variable exports (user env first, then non-interactive overrides) const envVars = { ...options.env, ...NON_INTERACTIVE_ENV_VARS }; for (const [key, value] of Object.entries(envVars)) { diff --git a/src/node/runtime/WorktreeRuntime.test.ts b/src/node/runtime/WorktreeRuntime.test.ts index 949b309d5..c7f2f894e 100644 --- a/src/node/runtime/WorktreeRuntime.test.ts +++ b/src/node/runtime/WorktreeRuntime.test.ts @@ -1,6 +1,7 @@ -import { describe, expect, it } from "bun:test"; +import { describe, expect, it, beforeEach, afterEach } from "bun:test"; import * as os from "os"; import * as path from "path"; +import * as fs from "fs/promises"; import { WorktreeRuntime } from "./WorktreeRuntime"; describe("WorktreeRuntime constructor", () => { @@ -65,3 +66,82 @@ describe("WorktreeRuntime.resolvePath", () => { expect(path.isAbsolute(resolved)).toBe(true); }); }); + +describe("WorktreeRuntime.exec bashrc sourcing", () => { + // Use a temp directory as fake HOME with .mux/bashrc inside + const testHome = path.join(os.tmpdir(), `mux-bashrc-test-${Date.now()}`); + const testMuxDir = path.join(testHome, ".mux"); + const testBashrcPath = path.join(testMuxDir, "bashrc"); + const testWorkDir = path.join(os.tmpdir(), "mux-bashrc-workdir"); + + beforeEach(async () => { + // Create test directories + await fs.mkdir(testMuxDir, { recursive: true }); + await fs.mkdir(testWorkDir, { recursive: true }); + }); + + afterEach(async () => { + // Clean up test files + await fs.rm(testHome, { recursive: true, force: true }); + await fs.rm(testWorkDir, { recursive: true, force: true }); + }); + + it("should source ~/.mux/bashrc and set environment variables", async () => { + // Create a test bashrc that sets a unique environment variable + const testEnvValue = `test_value_${Date.now()}`; + await fs.writeFile(testBashrcPath, `export MUX_TEST_BASHRC_VAR="${testEnvValue}"\n`); + + const runtime = new WorktreeRuntime("/tmp"); + // Pass HOME as environment variable so the child process uses our test home + const stream = await runtime.exec("echo $MUX_TEST_BASHRC_VAR", { + cwd: testWorkDir, + timeout: 5, + env: { HOME: testHome }, + }); + + // Read stdout + const reader = stream.stdout.getReader(); + const decoder = new TextDecoder(); + let output = ""; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + output += decoder.decode(value, { stream: true }); + } + output += decoder.decode(); + + await stream.exitCode; + + // Should have the value set by our bashrc + expect(output.trim()).toBe(testEnvValue); + }); + + it("should work silently when ~/.mux/bashrc doesn't exist", async () => { + // Don't create bashrc - just have empty .mux dir + await fs.rm(testBashrcPath, { force: true }); + + const runtime = new WorktreeRuntime("/tmp"); + const stream = await runtime.exec("echo hello", { + cwd: testWorkDir, + timeout: 5, + env: { HOME: testHome }, + }); + + // Read stdout + const reader = stream.stdout.getReader(); + const decoder = new TextDecoder(); + let output = ""; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + output += decoder.decode(value, { stream: true }); + } + output += decoder.decode(); + + const exitCode = await stream.exitCode; + + // Command should succeed with expected output + expect(exitCode).toBe(0); + expect(output.trim()).toBe("hello"); + }); +});