Skip to content

Commit a7e2919

Browse files
committed
🤖 feat: add ~/.mux/bashrc for shell environment customization
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: - LocalRuntime.exec() prepends bashrc sourcing to all commands - SSHRuntime.exec() prepends bashrc sourcing (uses remote ~/.mux/bashrc) - Init hooks also source bashrc for consistency Fixes #797 _Generated with mux_
1 parent 284dbc7 commit a7e2919

File tree

7 files changed

+301
-4
lines changed

7 files changed

+301
-4
lines changed

docs/SUMMARY.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
- [Instruction Files](./instruction-files.md)
2222
- [Project Secrets](./project-secrets.md)
2323
- [Agentic Git Identity](./agentic-git-identity.md)
24+
- [Shell Environment](./bashrc.md)
2425

2526
# Advanced
2627

docs/bashrc.md

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
# Shell Environment (`~/.mux/bashrc`)
2+
3+
Customize the shell environment for agent bash commands by creating a `~/.mux/bashrc` file.
4+
5+
## Why This Exists
6+
7+
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:
8+
9+
```bash
10+
# Common pattern in ~/.bashrc that skips setup for non-interactive shells
11+
[[ $- != *i* ]] && return
12+
```
13+
14+
This means:
15+
16+
- **Launching from Applications** — PATH is minimal (`/usr/bin:/bin:/usr/sbin:/sbin`)
17+
- **Nix/direnv users** — Shell customizations aren't applied
18+
- **Homebrew/pyenv/rbenv** — Tools not in PATH
19+
20+
The `~/.mux/bashrc` file is **always sourced** before every bash command, giving you a place to set up the environment reliably.
21+
22+
## Setup
23+
24+
Create `~/.mux/bashrc`:
25+
26+
```bash
27+
mkdir -p ~/.mux
28+
touch ~/.mux/bashrc
29+
```
30+
31+
Add your shell customizations:
32+
33+
```bash
34+
# ~/.mux/bashrc
35+
36+
# Add Homebrew to PATH (macOS)
37+
eval "$(/opt/homebrew/bin/brew shellenv)"
38+
39+
# Add ~/bin to PATH
40+
export PATH="$HOME/bin:$PATH"
41+
```
42+
43+
## Examples
44+
45+
### Nix Users
46+
47+
```bash
48+
# ~/.mux/bashrc
49+
50+
# Source nix profile
51+
if [ -e "$HOME/.nix-profile/etc/profile.d/nix.sh" ]; then
52+
. "$HOME/.nix-profile/etc/profile.d/nix.sh"
53+
fi
54+
55+
# Enable direnv (auto-loads .envrc per directory)
56+
eval "$(direnv hook bash)"
57+
```
58+
59+
### Python (pyenv)
60+
61+
```bash
62+
# ~/.mux/bashrc
63+
export PYENV_ROOT="$HOME/.pyenv"
64+
export PATH="$PYENV_ROOT/bin:$PATH"
65+
eval "$(pyenv init -)"
66+
```
67+
68+
### Node.js (nvm)
69+
70+
```bash
71+
# ~/.mux/bashrc
72+
export NVM_DIR="$HOME/.nvm"
73+
[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"
74+
```
75+
76+
### Ruby (rbenv)
77+
78+
```bash
79+
# ~/.mux/bashrc
80+
eval "$(rbenv init -)"
81+
```
82+
83+
### Multiple Tools (asdf)
84+
85+
```bash
86+
# ~/.mux/bashrc
87+
. "$HOME/.asdf/asdf.sh"
88+
```
89+
90+
## Behavior
91+
92+
- **Sourced before every bash command** — including init hooks
93+
- **Silently skipped if missing** — no bashrc file = no effect
94+
- **Errors propagate** — if your bashrc has errors, they appear in command output
95+
- **SSH workspaces** — the remote `~/.mux/bashrc` is sourced (you manage it on the remote host)
96+
97+
## Comparison with Init Hooks
98+
99+
| Feature | `~/.mux/bashrc` | `.mux/init` |
100+
| ------- | ----------------------- | -------------------------- |
101+
| When | Every bash command | Once at workspace creation |
102+
| Scope | Global (all projects) | Per-project |
103+
| Purpose | Shell environment setup | Project build/install |
104+
| Errors | Command fails | Logged, non-blocking |
105+
106+
Use **bashrc** for environment (PATH, tools, direnv hooks).
107+
Use **init hooks** for project setup (install dependencies, build).
108+
109+
## Troubleshooting
110+
111+
### Commands Not Finding Tools
112+
113+
If tools aren't found, check that bashrc is being sourced:
114+
115+
```bash
116+
# In mux, run:
117+
echo "bashrc: $MUX_BASHRC_SOURCED"
118+
```
119+
120+
If empty, your bashrc might not exist. If you want to confirm it's being sourced, add to your bashrc:
121+
122+
```bash
123+
# ~/.mux/bashrc
124+
export MUX_BASHRC_SOURCED=1
125+
```
126+
127+
### Bashrc Errors
128+
129+
Errors in your bashrc will cause commands to fail. Test your bashrc:
130+
131+
```bash
132+
bash -c '[ -f "$HOME/.mux/bashrc" ] && . "$HOME/.mux/bashrc" && echo ok'
133+
```
134+
135+
### Performance
136+
137+
The bashrc runs before every command. Keep it fast:
138+
139+
- Avoid expensive operations (network calls, slow init scripts)
140+
- Use lazy loading where possible
141+
- Profile with `time bash -c '. ~/.mux/bashrc'`
142+
143+
## Related
144+
145+
- [Init Hooks](./init-hooks.md) — Per-project initialization scripts
146+
- [Project Secrets](./project-secrets.md) — Environment variables for API keys

src/common/constants/paths.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { describe, expect, it } from "bun:test";
2+
import { getMuxBashrcSourceSnippet } from "./paths";
3+
4+
describe("getMuxBashrcSourceSnippet", () => {
5+
it("should return a bash snippet that sources ~/.mux/bashrc if it exists", () => {
6+
const snippet = getMuxBashrcSourceSnippet();
7+
8+
// Should check for file existence with -f
9+
expect(snippet).toContain("[ -f");
10+
// Should reference $HOME/.mux/bashrc
11+
expect(snippet).toContain('$HOME/.mux/bashrc"');
12+
// Should source the file with . (dot command)
13+
expect(snippet).toContain(". ");
14+
});
15+
16+
it("should use $HOME for portability across local and SSH runtimes", () => {
17+
const snippet = getMuxBashrcSourceSnippet();
18+
19+
// Should not use ~ (tilde) which doesn't expand in all contexts
20+
expect(snippet).not.toContain("~/");
21+
// Should use $HOME which expands reliably
22+
expect(snippet).toContain("$HOME/");
23+
});
24+
25+
it("should silently skip if file doesn't exist", () => {
26+
const snippet = getMuxBashrcSourceSnippet();
27+
28+
// Should use && to only source if test succeeds (file exists)
29+
// This pattern: [ -f file ] && . file
30+
// - If file doesn't exist, [ -f file ] returns 1, && short-circuits, no error
31+
// - If file exists, [ -f file ] returns 0, && continues to source file
32+
expect(snippet).toContain("] && .");
33+
});
34+
});

src/common/constants/paths.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,3 +116,27 @@ export function getMuxExtensionMetadataPath(rootDir?: string): string {
116116
const root = rootDir ?? getMuxHome();
117117
return join(root, "extensionMetadata.json");
118118
}
119+
120+
/**
121+
* Filename for the user's mux bashrc file.
122+
* This is sourced before every bash command to set up the shell environment.
123+
*
124+
* Unlike ~/.bashrc, this file is always sourced (even in non-interactive shells)
125+
* because `bash -c` doesn't source ~/.bashrc by default, and most users have
126+
* interactivity guards in their bashrc that skip content for non-interactive shells.
127+
*
128+
* Users can put PATH modifications, nix profile sourcing, direnv hooks, etc. here.
129+
*/
130+
export const MUX_BASHRC_FILENAME = "bashrc";
131+
132+
/**
133+
* Get the bash snippet to source ~/.mux/bashrc if it exists.
134+
* Uses $HOME to work correctly on both local and SSH runtimes.
135+
*
136+
* @returns Bash snippet to prepend to commands
137+
*/
138+
export function getMuxBashrcSourceSnippet(): string {
139+
// Use $HOME/.mux/bashrc to work on both local and SSH runtimes
140+
// The -f test and source are combined to silently skip if file doesn't exist
141+
return `[ -f "$HOME/.mux/bashrc" ] && . "$HOME/.mux/bashrc"`;
142+
}

src/node/runtime/LocalRuntime.test.ts

Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
import { describe, expect, it } from "bun:test";
1+
import { describe, expect, it, beforeEach, afterEach } from "bun:test";
22
import * as os from "os";
33
import * as path from "path";
4+
import * as fs from "fs/promises";
45
import { LocalRuntime } from "./LocalRuntime";
56

67
describe("LocalRuntime constructor", () => {
@@ -30,6 +31,85 @@ describe("LocalRuntime constructor", () => {
3031
});
3132
});
3233

34+
describe("LocalRuntime.exec bashrc sourcing", () => {
35+
// Use a temp directory as fake HOME with .mux/bashrc inside
36+
const testHome = path.join(os.tmpdir(), `mux-bashrc-test-${Date.now()}`);
37+
const testMuxDir = path.join(testHome, ".mux");
38+
const testBashrcPath = path.join(testMuxDir, "bashrc");
39+
const testWorkDir = path.join(os.tmpdir(), "mux-bashrc-workdir");
40+
41+
beforeEach(async () => {
42+
// Create test directories
43+
await fs.mkdir(testMuxDir, { recursive: true });
44+
await fs.mkdir(testWorkDir, { recursive: true });
45+
});
46+
47+
afterEach(async () => {
48+
// Clean up test files
49+
await fs.rm(testHome, { recursive: true, force: true });
50+
await fs.rm(testWorkDir, { recursive: true, force: true });
51+
});
52+
53+
it("should source ~/.mux/bashrc and set environment variables", async () => {
54+
// Create a test bashrc that sets a unique environment variable
55+
const testEnvValue = `test_value_${Date.now()}`;
56+
await fs.writeFile(testBashrcPath, `export MUX_TEST_BASHRC_VAR="${testEnvValue}"\n`);
57+
58+
const runtime = new LocalRuntime("/tmp");
59+
// Pass HOME as environment variable so the child process uses our test home
60+
const stream = await runtime.exec("echo $MUX_TEST_BASHRC_VAR", {
61+
cwd: testWorkDir,
62+
timeout: 5,
63+
env: { HOME: testHome },
64+
});
65+
66+
// Read stdout
67+
const reader = stream.stdout.getReader();
68+
const decoder = new TextDecoder();
69+
let output = "";
70+
while (true) {
71+
const { done, value } = await reader.read();
72+
if (done) break;
73+
output += decoder.decode(value, { stream: true });
74+
}
75+
output += decoder.decode();
76+
77+
await stream.exitCode;
78+
79+
// Should have the value set by our bashrc
80+
expect(output.trim()).toBe(testEnvValue);
81+
});
82+
83+
it("should work silently when ~/.mux/bashrc doesn't exist", async () => {
84+
// Don't create bashrc - just have empty .mux dir
85+
await fs.rm(testBashrcPath, { force: true });
86+
87+
const runtime = new LocalRuntime("/tmp");
88+
const stream = await runtime.exec("echo hello", {
89+
cwd: testWorkDir,
90+
timeout: 5,
91+
env: { HOME: testHome },
92+
});
93+
94+
// Read stdout
95+
const reader = stream.stdout.getReader();
96+
const decoder = new TextDecoder();
97+
let output = "";
98+
while (true) {
99+
const { done, value } = await reader.read();
100+
if (done) break;
101+
output += decoder.decode(value, { stream: true });
102+
}
103+
output += decoder.decode();
104+
105+
const exitCode = await stream.exitCode;
106+
107+
// Command should succeed with expected output
108+
expect(exitCode).toBe(0);
109+
expect(output.trim()).toBe("hello");
110+
});
111+
});
112+
33113
describe("LocalRuntime.resolvePath", () => {
34114
it("should expand tilde to home directory", async () => {
35115
const runtime = new LocalRuntime("/tmp");

src/node/runtime/LocalRuntime.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import { execAsync, DisposableProcess } from "@/node/utils/disposableExec";
3131
import { getProjectName } from "@/node/utils/runtime/helpers";
3232
import { getErrorMessage } from "@/common/utils/errors";
3333
import { expandTilde } from "./tildeExpansion";
34+
import { getMuxBashrcSourceSnippet } from "@/common/constants/paths";
3435

3536
/**
3637
* Local runtime implementation that executes commands and file operations
@@ -62,15 +63,19 @@ export class LocalRuntime implements Runtime {
6263
);
6364
}
6465

66+
// Prepend bashrc sourcing to set up environment (PATH, nix, direnv, etc.)
67+
// This is necessary because `bash -c` doesn't source ~/.bashrc
68+
const commandWithBashrc = `${getMuxBashrcSourceSnippet()}\n${command}`;
69+
6570
// If niceness is specified on Unix/Linux, spawn nice directly to avoid escaping issues
6671
// Windows doesn't have nice command, so just spawn bash directly
6772
const isWindows = process.platform === "win32";
6873
const bashPath = getBashPath();
6974
const spawnCommand = options.niceness !== undefined && !isWindows ? "nice" : bashPath;
7075
const spawnArgs =
7176
options.niceness !== undefined && !isWindows
72-
? ["-n", options.niceness.toString(), bashPath, "-c", command]
73-
: ["-c", command];
77+
? ["-n", options.niceness.toString(), bashPath, "-c", commandWithBashrc]
78+
: ["-c", commandWithBashrc];
7479

7580
const childProcess = spawn(spawnCommand, spawnArgs, {
7681
cwd,
@@ -421,7 +426,9 @@ export class LocalRuntime implements Runtime {
421426

422427
return new Promise<void>((resolve) => {
423428
const bashPath = getBashPath();
424-
const proc = spawn(bashPath, ["-c", `"${hookPath}"`], {
429+
// Source bashrc before running init hook so it has access to user's environment
430+
const commandWithBashrc = `${getMuxBashrcSourceSnippet()}\n"${hookPath}"`;
431+
const proc = spawn(bashPath, ["-c", commandWithBashrc], {
425432
cwd: workspacePath,
426433
stdio: ["ignore", "pipe", "pipe"],
427434
env: {

src/node/runtime/SSHRuntime.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { getErrorMessage } from "@/common/utils/errors";
2525
import { execAsync, DisposableProcess } from "@/node/utils/disposableExec";
2626
import { getControlPath } from "./sshConnectionPool";
2727
import { getBashPath } from "@/node/utils/main/bashPath";
28+
import { getMuxBashrcSourceSnippet } from "@/common/constants/paths";
2829

2930
/**
3031
* Shell-escape helper for remote bash.
@@ -106,6 +107,10 @@ export class SSHRuntime implements Runtime {
106107
// Add cd command if cwd is specified
107108
parts.push(cdCommandForSSH(options.cwd));
108109

110+
// Source ~/.mux/bashrc if it exists (sets up PATH, nix, direnv, etc.)
111+
// This is necessary because `bash -c` doesn't source ~/.bashrc
112+
parts.push(getMuxBashrcSourceSnippet());
113+
109114
// Add environment variable exports
110115
if (options.env) {
111116
for (const [key, value] of Object.entries(options.env)) {

0 commit comments

Comments
 (0)