Skip to content

Commit 44580b3

Browse files
authored
🤖 feat: add MUX_ env vars to all bash spawns (#935)
Set MUX_PROJECT_PATH, MUX_RUNTIME, and MUX_WORKSPACE_NAME environment variables in both init hooks and bash tool executions for parity. ## Changes - Add `getMuxEnv()` function to centralize env var creation - Add `muxEnv` field to `ToolConfiguration`, used by bash tool - Refactor `runInitHook` to receive muxEnv from caller (no duplication) - Add tests for muxEnv injection in bash tool ## Environment Variables | Variable | Description | |----------|-------------| | `MUX_PROJECT_PATH` | Path to the original project/git repo | | `MUX_RUNTIME` | Runtime type: `local`, `worktree`, or `ssh` | | `MUX_WORKSPACE_NAME` | Name of the workspace (branch name) | _Generated with `mux`_
1 parent 1f7a544 commit 44580b3

File tree

11 files changed

+116
-40
lines changed

11 files changed

+116
-40
lines changed

docs/init-hooks.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,13 +32,14 @@ The init script runs in the workspace directory with the workspace's environment
3232

3333
## Environment Variables
3434

35-
Init hooks receive the following environment variables:
35+
Init hooks receive the following environment variables (also available in all agent bash tool executions):
3636

3737
- `MUX_PROJECT_PATH` - Absolute path to the project root on the **local machine**
3838
- Always refers to your local project path, even on SSH workspaces
3939
- Useful for logging, debugging, or runtime-specific logic
40-
- `MUX_RUNTIME` - Runtime type: `"local"` or `"ssh"`
40+
- `MUX_RUNTIME` - Runtime type: `"local"`, `"worktree"`, or `"ssh"`
4141
- Use this to detect whether the hook is running locally or remotely
42+
- `MUX_WORKSPACE_NAME` - Name of the workspace (typically the branch name)
4243

4344
**Note for SSH workspaces:** Since the project is synced to the remote machine, files exist in both locations. The init hook runs in the workspace directory (`$PWD`), so use relative paths to reference project files:
4445

docs/runtime.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ When creating a workspace, select the runtime from the dropdown in the workspace
1717

1818
## Init Hooks
1919

20-
[Init hooks](/init-hooks) can detect the runtime type via the `MUX_RUNTIME` environment variable:
20+
[Init hooks](/init-hooks) and agent bash tool executions can detect the runtime type via the `MUX_RUNTIME` environment variable:
2121

2222
- `local` — Local runtime
2323
- `worktree` — Worktree runtime

src/common/utils/tools/tools.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ export interface ToolConfiguration {
2323
runtime: Runtime;
2424
/** Environment secrets to inject (optional) */
2525
secrets?: Record<string, string>;
26+
/** MUX_ environment variables (MUX_PROJECT_PATH, MUX_RUNTIME) - set from init hook env */
27+
muxEnv?: Record<string, string>;
2628
/** Process niceness level (optional, -20 to 19, lower = higher priority) */
2729
niceness?: number;
2830
/** Temporary directory for tool outputs in runtime's context (local or remote) */

src/node/runtime/LocalBaseRuntime.ts

Lines changed: 9 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,7 @@ import { getBashPath } from "@/node/utils/main/bashPath";
2222
import { EXIT_CODE_ABORTED, EXIT_CODE_TIMEOUT } from "@/common/constants/exitCodes";
2323
import { DisposableProcess } from "@/node/utils/disposableExec";
2424
import { expandTilde } from "./tildeExpansion";
25-
import {
26-
checkInitHookExists,
27-
getInitHookPath,
28-
createLineBufferedLoggers,
29-
getInitHookEnv,
30-
} from "./initHook";
25+
import { getInitHookPath, createLineBufferedLoggers } from "./initHook";
3126

3227
/**
3328
* Abstract base class for local runtimes (both WorktreeRuntime and LocalRuntime).
@@ -347,19 +342,17 @@ export abstract class LocalBaseRuntime implements Runtime {
347342
/**
348343
* Helper to run .mux/init hook if it exists and is executable.
349344
* Shared between WorktreeRuntime and LocalRuntime.
345+
* @param workspacePath - Path to the workspace directory
346+
* @param muxEnv - MUX_ environment variables (from getMuxEnv)
347+
* @param initLogger - Logger for streaming output
350348
*/
351349
protected async runInitHook(
352-
projectPath: string,
353350
workspacePath: string,
354-
initLogger: InitLogger,
355-
runtimeType: "local" | "worktree"
351+
muxEnv: Record<string, string>,
352+
initLogger: InitLogger
356353
): Promise<void> {
357-
// Check if hook exists and is executable
358-
const hookExists = await checkInitHookExists(projectPath);
359-
if (!hookExists) {
360-
return;
361-
}
362-
354+
// Hook path is derived from MUX_PROJECT_PATH in muxEnv
355+
const projectPath = muxEnv.MUX_PROJECT_PATH;
363356
const hookPath = getInitHookPath(projectPath);
364357
initLogger.logStep(`Running init hook: ${hookPath}`);
365358

@@ -373,7 +366,7 @@ export abstract class LocalBaseRuntime implements Runtime {
373366
stdio: ["ignore", "pipe", "pipe"],
374367
env: {
375368
...process.env,
376-
...getInitHookEnv(projectPath, runtimeType),
369+
...muxEnv,
377370
},
378371
// Prevent console window from appearing on Windows
379372
windowsHide: true,

src/node/runtime/LocalRuntime.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import type {
66
WorkspaceForkParams,
77
WorkspaceForkResult,
88
} from "./Runtime";
9-
import { checkInitHookExists } from "./initHook";
9+
import { checkInitHookExists, getMuxEnv } from "./initHook";
1010
import { getErrorMessage } from "@/common/utils/errors";
1111
import { LocalBaseRuntime } from "./LocalBaseRuntime";
1212

@@ -70,13 +70,14 @@ export class LocalRuntime extends LocalBaseRuntime {
7070
}
7171

7272
async initWorkspace(params: WorkspaceInitParams): Promise<WorkspaceInitResult> {
73-
const { projectPath, workspacePath, initLogger } = params;
73+
const { projectPath, branchName, workspacePath, initLogger } = params;
7474

7575
try {
7676
// Run .mux/init hook if it exists
7777
const hookExists = await checkInitHookExists(projectPath);
7878
if (hookExists) {
79-
await this.runInitHook(projectPath, workspacePath, initLogger, "local");
79+
const muxEnv = getMuxEnv(projectPath, "local", branchName);
80+
await this.runInitHook(workspacePath, muxEnv, initLogger);
8081
} else {
8182
// No hook - signal completion immediately
8283
initLogger.logComplete(0);

src/node/runtime/SSHRuntime.ts

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import type {
1717
import { RuntimeError as RuntimeErrorClass } from "./Runtime";
1818
import { EXIT_CODE_ABORTED, EXIT_CODE_TIMEOUT } from "@/common/constants/exitCodes";
1919
import { log } from "@/node/services/log";
20-
import { checkInitHookExists, createLineBufferedLoggers, getInitHookEnv } from "./initHook";
20+
import { checkInitHookExists, createLineBufferedLoggers, getMuxEnv } from "./initHook";
2121
import { NON_INTERACTIVE_ENV_VARS } from "@/common/constants/env";
2222
import { streamProcessToLogger } from "./streamProcess";
2323
import { expandTildeForSSH, cdCommandForSSH } from "./tildeExpansion";
@@ -732,19 +732,17 @@ export class SSHRuntime implements Runtime {
732732

733733
/**
734734
* Run .mux/init hook on remote machine if it exists
735+
* @param workspacePath - Path to the workspace directory on remote
736+
* @param muxEnv - MUX_ environment variables (from getMuxEnv)
737+
* @param initLogger - Logger for streaming output
738+
* @param abortSignal - Optional abort signal
735739
*/
736740
private async runInitHook(
737-
projectPath: string,
738741
workspacePath: string,
742+
muxEnv: Record<string, string>,
739743
initLogger: InitLogger,
740744
abortSignal?: AbortSignal
741745
): Promise<void> {
742-
// Check if hook exists locally (we synced the project, so local check is sufficient)
743-
const hookExists = await checkInitHookExists(projectPath);
744-
if (!hookExists) {
745-
return;
746-
}
747-
748746
// Construct hook path - expand tilde if present
749747
const remoteHookPath = `${workspacePath}/.mux/init`;
750748
initLogger.logStep(`Running init hook: ${remoteHookPath}`);
@@ -759,7 +757,7 @@ export class SSHRuntime implements Runtime {
759757
cwd: workspacePath, // Run in the workspace directory
760758
timeout: 3600, // 1 hour - generous timeout for init hooks
761759
abortSignal,
762-
env: getInitHookEnv(projectPath, "ssh"),
760+
env: muxEnv,
763761
});
764762

765763
// Create line-buffered loggers
@@ -921,7 +919,8 @@ export class SSHRuntime implements Runtime {
921919
// Note: runInitHook calls logComplete() internally if hook exists
922920
const hookExists = await checkInitHookExists(projectPath);
923921
if (hookExists) {
924-
await this.runInitHook(projectPath, workspacePath, initLogger, abortSignal);
922+
const muxEnv = getMuxEnv(projectPath, "ssh", branchName);
923+
await this.runInitHook(workspacePath, muxEnv, initLogger, abortSignal);
925924
} else {
926925
// No hook - signal completion immediately
927926
initLogger.logComplete(0);

src/node/runtime/WorktreeRuntime.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import type {
1010
InitLogger,
1111
} from "./Runtime";
1212
import { listLocalBranches } from "@/node/git";
13-
import { checkInitHookExists } from "./initHook";
13+
import { checkInitHookExists, getMuxEnv } from "./initHook";
1414
import { execAsync } from "@/node/utils/disposableExec";
1515
import { getProjectName } from "@/node/utils/runtime/helpers";
1616
import { getErrorMessage } from "@/common/utils/errors";
@@ -139,14 +139,15 @@ export class WorktreeRuntime extends LocalBaseRuntime {
139139
}
140140

141141
async initWorkspace(params: WorkspaceInitParams): Promise<WorkspaceInitResult> {
142-
const { projectPath, workspacePath, initLogger } = params;
142+
const { projectPath, branchName, workspacePath, initLogger } = params;
143143

144144
try {
145145
// Run .mux/init hook if it exists
146146
// Note: runInitHook calls logComplete() internally if hook exists
147147
const hookExists = await checkInitHookExists(projectPath);
148148
if (hookExists) {
149-
await this.runInitHook(projectPath, workspacePath, initLogger, "worktree");
149+
const muxEnv = getMuxEnv(projectPath, "worktree", branchName);
150+
await this.runInitHook(workspacePath, muxEnv, initLogger);
150151
} else {
151152
// No hook - signal completion immediately
152153
initLogger.logComplete(0);

src/node/runtime/initHook.ts

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import * as fs from "fs";
22
import * as fsPromises from "fs/promises";
33
import * as path from "path";
44
import type { InitLogger } from "./Runtime";
5+
import type { RuntimeConfig } from "@/common/types/runtime";
6+
import { isWorktreeRuntime, isSSHRuntime } from "@/common/types/runtime";
57

68
/**
79
* Check if .mux/init hook exists and is executable
@@ -27,21 +29,35 @@ export function getInitHookPath(projectPath: string): string {
2729
}
2830

2931
/**
30-
* Get environment variables for init hook execution
31-
* Centralizes env var injection to avoid duplication across runtimes
32+
* Get MUX_ environment variables for bash execution.
33+
* Used by both init hook and regular bash tool calls.
3234
* @param projectPath - Path to project root (local path for LocalRuntime, remote path for SSHRuntime)
3335
* @param runtime - Runtime type: "local", "worktree", or "ssh"
36+
* @param workspaceName - Name of the workspace (branch name or custom name)
3437
*/
35-
export function getInitHookEnv(
38+
export function getMuxEnv(
3639
projectPath: string,
37-
runtime: "local" | "worktree" | "ssh"
40+
runtime: "local" | "worktree" | "ssh",
41+
workspaceName: string
3842
): Record<string, string> {
3943
return {
4044
MUX_PROJECT_PATH: projectPath,
4145
MUX_RUNTIME: runtime,
46+
MUX_WORKSPACE_NAME: workspaceName,
4247
};
4348
}
4449

50+
/**
51+
* Get the effective runtime type from a RuntimeConfig.
52+
* Handles legacy "local" with srcBaseDir → "worktree" mapping.
53+
*/
54+
export function getRuntimeType(config: RuntimeConfig | undefined): "local" | "worktree" | "ssh" {
55+
if (!config) return "worktree"; // Default to worktree for undefined config
56+
if (isSSHRuntime(config)) return "ssh";
57+
if (isWorktreeRuntime(config)) return "worktree";
58+
return "local";
59+
}
60+
4561
/**
4662
* Line-buffered logger that splits stream output into lines and logs them
4763
* Handles incomplete lines by buffering until a newline is received

src/node/services/aiService.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import type { InitStateManager } from "./initStateManager";
1919
import type { SendMessageError } from "@/common/types/errors";
2020
import { getToolsForModel } from "@/common/utils/tools/tools";
2121
import { createRuntime } from "@/node/runtime/runtimeFactory";
22+
import { getMuxEnv, getRuntimeType } from "@/node/runtime/initHook";
2223
import { secretsToRecord } from "@/common/types/secrets";
2324
import type { MuxProviderOptions } from "@/common/types/providerOptions";
2425
import { log } from "./log";
@@ -1005,6 +1006,11 @@ export class AIService extends EventEmitter {
10051006
cwd: workspacePath,
10061007
runtime,
10071008
secrets: secretsToRecord(projectSecrets),
1009+
muxEnv: getMuxEnv(
1010+
metadata.projectPath,
1011+
getRuntimeType(metadata.runtimeConfig),
1012+
metadata.name
1013+
),
10081014
runtimeTempDir,
10091015
},
10101016
workspaceId,

src/node/services/tools/bash.test.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1230,6 +1230,63 @@ fi
12301230
});
12311231
});
12321232

1233+
describe("muxEnv environment variables", () => {
1234+
it("should inject MUX_ environment variables when muxEnv is provided", async () => {
1235+
using tempDir = new TestTempDir("test-mux-env");
1236+
const config = createTestToolConfig(process.cwd());
1237+
config.runtimeTempDir = tempDir.path;
1238+
config.muxEnv = {
1239+
MUX_PROJECT_PATH: "/test/project/path",
1240+
MUX_RUNTIME: "worktree",
1241+
MUX_WORKSPACE_NAME: "feature-branch",
1242+
};
1243+
const tool = createBashTool(config);
1244+
1245+
const args: BashToolArgs = {
1246+
script: 'echo "PROJECT:$MUX_PROJECT_PATH RUNTIME:$MUX_RUNTIME WORKSPACE:$MUX_WORKSPACE_NAME"',
1247+
timeout_secs: 5,
1248+
};
1249+
1250+
const result = (await tool.execute!(args, mockToolCallOptions)) as BashToolResult;
1251+
1252+
expect(result.success).toBe(true);
1253+
if (result.success) {
1254+
expect(result.output).toContain("PROJECT:/test/project/path");
1255+
expect(result.output).toContain("RUNTIME:worktree");
1256+
expect(result.output).toContain("WORKSPACE:feature-branch");
1257+
}
1258+
});
1259+
1260+
it("should allow secrets to override muxEnv", async () => {
1261+
using tempDir = new TestTempDir("test-mux-env-override");
1262+
const config = createTestToolConfig(process.cwd());
1263+
config.runtimeTempDir = tempDir.path;
1264+
config.muxEnv = {
1265+
MUX_PROJECT_PATH: "/mux/path",
1266+
CUSTOM_VAR: "from-mux",
1267+
};
1268+
config.secrets = {
1269+
CUSTOM_VAR: "from-secrets",
1270+
};
1271+
const tool = createBashTool(config);
1272+
1273+
const args: BashToolArgs = {
1274+
script: 'echo "MUX:$MUX_PROJECT_PATH CUSTOM:$CUSTOM_VAR"',
1275+
timeout_secs: 5,
1276+
};
1277+
1278+
const result = (await tool.execute!(args, mockToolCallOptions)) as BashToolResult;
1279+
1280+
expect(result.success).toBe(true);
1281+
if (result.success) {
1282+
// MUX_PROJECT_PATH from muxEnv should be present
1283+
expect(result.output).toContain("MUX:/mux/path");
1284+
// Secrets should override muxEnv when there's a conflict
1285+
expect(result.output).toContain("CUSTOM:from-secrets");
1286+
}
1287+
});
1288+
});
1289+
12331290
describe("SSH runtime redundant cd detection", () => {
12341291
// Helper to create bash tool with SSH runtime configuration
12351292
// Note: These tests check redundant cd detection logic only - they don't actually execute via SSH

0 commit comments

Comments
 (0)