Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/SUMMARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
146 changes: 146 additions & 0 deletions docs/bashrc.md
Original file line number Diff line number Diff line change
@@ -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)"
Comment on lines +55 to +56
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

direnv hook bash sets PROMPT_COMMAND which I believe means that direnv will not be activated until bash prompt is shown, which is never when running commands with bash -c

Assuming bashrc is sourced from the project directory with .envrc file then I guess running direnv export bash instead might work better (it's what direnv hook seems to run.)

```

### 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
35 changes: 35 additions & 0 deletions src/common/constants/paths.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
27 changes: 27 additions & 0 deletions src/common/constants/paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`;
}
13 changes: 10 additions & 3 deletions src/node/runtime/LocalBaseRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -67,15 +68,19 @@ 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";
const bashPath = getBashPath();
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,
Expand Down Expand Up @@ -366,7 +371,9 @@ export abstract class LocalBaseRuntime implements Runtime {

return new Promise<void>((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: {
Expand Down
5 changes: 5 additions & 0 deletions src/node/runtime/SSHRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)) {
Expand Down
82 changes: 81 additions & 1 deletion src/node/runtime/WorktreeRuntime.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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");
});
});