Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
514b48a
Add per-workspace environment variables inherited by every shell (#5995)
austinywang Jun 14, 2026
5a845a3
Merge remote-tracking branch 'origin/main' into issue-5995-workspace-…
austinywang Jun 14, 2026
ac9f218
ci: refresh Swift file-length budget for workspace env changes
austinywang Jun 14, 2026
03775dc
Fix workspace-env test override signatures, NUL key bypass, and test …
austinywang Jun 14, 2026
525074d
Address review: newTerminalSplit env test + CLI error formatting
austinywang Jun 14, 2026
f3cd42b
ci: bump file-length budget for sanitizer guard and test overrides
austinywang Jun 14, 2026
a849109
Address autoreview: cover all shell-spawn paths, fix env CLI, drop gh…
austinywang Jun 14, 2026
ccf3cb8
Address autoreview: import CmuxTerminal in test; fix cross-workspace …
austinywang Jun 14, 2026
9af03f6
Address autoreview: preserve per-surface env on respawn; strict works…
austinywang Jun 14, 2026
8436094
Mark workspace env sanitizer nonisolated for the socket create path
austinywang Jun 14, 2026
7f28a2f
Merge origin/main into issue-5995-workspace-env-vars
austinywang Jun 14, 2026
777efde
fix: address workspace env review feedback
austinywang Jun 14, 2026
67593ea
fix: keep workspace env tests in target scope
austinywang Jun 14, 2026
039f7ba
fix: clarify workspace env missing values
austinywang Jun 14, 2026
bde45c6
fix: require workspace_env socket param
austinywang Jun 14, 2026
ef24d8c
Merge origin/main into issue-5995-workspace-env-vars
austinywang Jun 14, 2026
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
12 changes: 6 additions & 6 deletions .github/swift-file-length-budget.tsv
Original file line number Diff line number Diff line change
@@ -1,22 +1,22 @@
# cmux-owned Swift file length budget.
# Format: max_lines<TAB>relative path
# Reduce counts as files shrink. CI fails if tracked files exceed this budget.
33454 CLI/cmux.swift
33659 CLI/cmux.swift
17914 Sources/AppDelegate.swift
16827 Sources/ContentView.swift
14612 Sources/TerminalController.swift
14656 Sources/TerminalController.swift
13595 Sources/Panels/BrowserPanel.swift
12088 Sources/GhosttyTerminalView.swift
12046 cmuxTests/AppDelegateShortcutRoutingTests.swift
11992 Sources/Workspace.swift
12055 Sources/Workspace.swift
9345 cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift
7911 Sources/Panels/BrowserPanelView.swift
7350 cmuxTests/WorkspaceUnitTests.swift
6944 cmuxTests/WorkspaceRemoteConnectionTests.swift
6317 cmuxTests/SessionPersistenceTests.swift
6299 cmuxTests/GhosttyConfigTests.swift
6153 CLI/cmux_open.swift
6074 Sources/TabManager.swift
6078 Sources/TabManager.swift
6074 Sources/TextBoxInput.swift
5925 cmuxTests/TerminalAndGhosttyTests.swift
5522 cmuxTests/BrowserConfigTests.swift
Expand Down Expand Up @@ -46,7 +46,7 @@
2236 Sources/TerminalNotificationStore.swift
2117 cmuxTests/CmuxConfigTests.swift
2095 cmuxTests/ShortcutAndCommandPaletteTests.swift
2059 Sources/SessionPersistence.swift
2062 Sources/SessionPersistence.swift
2036 Sources/KeyboardShortcutSettingsFileStore.swift
1949 Sources/Panels/BrowserWebAuthnSupport.swift
1860 cmuxTests/NotificationAndMenuBarTests.swift
Expand Down Expand Up @@ -194,7 +194,7 @@
518 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlCommandCoordinator+Surface.swift
518 Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift
518 Packages/CmuxSwiftRender/Tests/CmuxSwiftRenderTests/Corpus/stress-git-review-queue-command-deck.swift
516 Sources/CmuxConfigExecutor.swift
519 Sources/CmuxConfigExecutor.swift
514 Packages/CmuxSwiftRender/Sources/CmuxSwiftRender/ExpressionEvaluator.swift
514 cmuxUITests/UpdatePillUITests.swift
510 Sources/TerminalImageTransfer.swift
Expand Down
219 changes: 212 additions & 7 deletions CLI/cmux.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7094,9 +7094,10 @@ struct CMUXCLI {
let (descriptionOpt, rem3) = parseOption(rem2, name: "--description")
let (layoutOpt, rem4) = parseOption(rem3, name: "--layout")
let (windowOpt, rem5) = parseOption(rem4, name: "--window")
let (focusOpt, remaining) = parseOption(rem5, name: "--focus")
let (focusOpt, rem6) = parseOption(rem5, name: "--focus")
let (envFiles, envPairs, remaining) = parseWorkspaceEnvOptions(rem6)
if let unknown = remaining.first(where: { $0.hasPrefix("--") }) {
throw CLIError(message: "\(commandName): unknown flag '\(unknown)'. Known flags: --name <title>, --description <text>, --command <text>, --cwd <path>, --layout <json>, --window <id|ref|index>, --focus <true|false>")
throw CLIError(message: "\(commandName): unknown flag '\(unknown)'. Known flags: --name <title>, --description <text>, --command <text>, --cwd <path>, --env KEY=VALUE, --env-file <path>, --layout <json>, --window <id|ref|index>, --focus <true|false>")
}
var params: [String: Any] = [:]
try applyWindowOrCallerContext(to: &params, client: client, windowRaw: windowOpt ?? windowOverride)
Expand All @@ -7105,6 +7106,10 @@ struct CMUXCLI {
}
if let nameOpt { params["title"] = nameOpt }
if let descriptionOpt { params["description"] = descriptionOpt }
let workspaceEnv = try buildWorkspaceEnvironment(envFiles: envFiles, envPairs: envPairs, commandName: commandName)
if !workspaceEnv.isEmpty {
params["workspace_env"] = workspaceEnv
}
if let layoutOpt {
guard let layoutData = layoutOpt.data(using: .utf8),
let layoutObj = try? JSONSerialization.jsonObject(with: layoutData) as? [String: Any] else {
Expand All @@ -7127,6 +7132,184 @@ struct CMUXCLI {
}
}

/// Parses repeatable `--env KEY=VALUE` / `--env=KEY=VALUE` and
/// `--env-file PATH` / `--env-file=PATH` flags out of `args`, returning the
/// ordered env-file paths, the ordered `KEY=VALUE` pairs, and the remaining
/// unparsed args. Files are applied before pairs by the builder so an explicit
/// `--env` overrides a value from a file.
func parseWorkspaceEnvOptions(
_ args: [String]
) -> (envFiles: [String], envPairs: [String], remaining: [String]) {
var envFiles: [String] = []
var envPairs: [String] = []
var remaining: [String] = []
var skipNext = false
var pastTerminator = false
for (idx, arg) in args.enumerated() {
if skipNext {
skipNext = false
continue
}
if arg == "--" {
pastTerminator = true
remaining.append(arg)
continue
}
if !pastTerminator {
if arg == "--env", idx + 1 < args.count {
envPairs.append(args[idx + 1])
skipNext = true
continue
}
if arg.hasPrefix("--env=") {
envPairs.append(String(arg.dropFirst("--env=".count)))
continue
}
if arg == "--env-file", idx + 1 < args.count {
envFiles.append(args[idx + 1])
skipNext = true
continue
}
if arg.hasPrefix("--env-file=") {
envFiles.append(String(arg.dropFirst("--env-file=".count)))
continue
}
}
remaining.append(arg)
}
return (envFiles, envPairs, remaining)
}

/// Builds the workspace environment dict from `--env-file` paths (applied
/// first, in order) and `--env KEY=VALUE` pairs (applied after, so they
/// override files). Env files use `KEY=VALUE` lines; blank lines and lines
/// starting with `#` are ignored, an optional leading `export ` is stripped,
/// and matching surrounding quotes on a file value are removed. Command-line
/// `--env` values are taken verbatim (the shell already handled quoting).
func buildWorkspaceEnvironment(
envFiles: [String],
envPairs: [String],
commandName: String
) throws -> [String: String] {
var env: [String: String] = [:]
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
for path in envFiles {
let resolved = resolvePath(path)
let contents: String
do {
contents = try String(contentsOfFile: resolved, encoding: .utf8)
} catch {
throw CLIError(message: "\(commandName): could not read --env-file '\(path)': \(error.localizedDescription)")
}
for rawLine in contents.split(omittingEmptySubsequences: false, whereSeparator: { $0.isNewline }) {
var line = String(rawLine).trimmingCharacters(in: .whitespaces)
if line.isEmpty || line.hasPrefix("#") { continue }
if line.hasPrefix("export ") {
line = String(line.dropFirst("export ".count)).trimmingCharacters(in: .whitespaces)
}
let (key, value) = try parseEnvAssignment(line, source: "--env-file '\(path)'", commandName: commandName)
env[key] = unquoteEnvValue(value)
}
}
for pair in envPairs {
let (key, value) = try parseEnvAssignment(pair, source: "--env", commandName: commandName)
env[key] = value
}
return env
}

/// Splits a `KEY=VALUE` assignment on the first `=`. The key is trimmed and
/// must be non-empty; the value is returned unmodified (callers decide whether
/// to unquote).
func parseEnvAssignment(
_ raw: String,
source: String,
commandName: String
) throws -> (String, String) {
guard let eq = raw.firstIndex(of: "=") else {
throw CLIError(message: "\(commandName): \(source) entry '\(raw)' must be in KEY=VALUE form")
}
let key = String(raw[..<eq]).trimmingCharacters(in: .whitespaces)
let value = String(raw[raw.index(after: eq)...])
guard !key.isEmpty else {
throw CLIError(message: "\(commandName): \(source) entry '\(raw)' has an empty key")
}
return (key, value)
}

/// Removes a single matching pair of surrounding single or double quotes from
/// an env-file value.
func unquoteEnvValue(_ value: String) -> String {
let trimmed = value.trimmingCharacters(in: .whitespaces)
if trimmed.count >= 2,
(trimmed.hasPrefix("\"") && trimmed.hasSuffix("\"")) ||
(trimmed.hasPrefix("'") && trimmed.hasSuffix("'")) {
return String(trimmed.dropFirst().dropLast())
}
return value
}
Comment thread
austinywang marked this conversation as resolved.
Resolved

/// Masks a secret env value for display. Short values are fully masked so a
/// brief secret isn't mostly revealed; longer ones keep a 2-character hint.
/// The mask is fixed-width so the value's length isn't leaked.
static func maskedEnvValue(_ value: String) -> String {
if value.isEmpty { return "" }
guard value.count > 6 else { return "••••" }
return "\(value.prefix(2))••••"
}

/// `cmux workspace env [<handle>] [--mask]` — print a workspace's configured
/// environment variables (issue #5995). Resolves the positional/`--workspace`
/// handle, falling back to the selected workspace. `--mask` redacts values so
/// secrets aren't echoed in full.
private func runWorkspaceEnvCommand(
commandArgs: [String],
client: SocketClient,
jsonOutput: Bool,
idFormat: CLIIDFormat,
windowOverride: String?
) throws {
var rest = commandArgs
let mask = rest.contains("--mask")
rest.removeAll { $0 == "--mask" }

let (workspaceArg, rem0) = parseOption(rest, name: "--workspace")
let (_, rem1) = parseOption(rem0, name: "--window")
if let unknown = rem1.first(where: { $0.hasPrefix("--") }) {
throw CLIError(message: "workspace env: unknown flag '\(unknown)'. Known flags: --workspace <id|ref|index>, --window <id|ref|index>, --mask")
}
let target = workspaceArg ?? rem1.first(where: { !$0.hasPrefix("--") })

var params: [String: Any] = [:]
let winId = try normalizeWindowHandle(windowFromArgsOrOverride(commandArgs, windowOverride: windowOverride), client: client)
if let winId { params["window_id"] = winId }
let wsId = try normalizeWorkspaceHandle(target, client: client, windowHandle: winId)
if let wsId { params["workspace_id"] = wsId }

let payload = try client.sendV2(method: "workspace.env", params: params)
let rawEnv = (payload["env"] as? [String: Any]) ?? [:]
let envStrings: [String: String] = rawEnv.reduce(into: [:]) { result, pair in
if let value = pair.value as? String { result[pair.key] = value }
}

if jsonOutput {
var out = payload
if mask {
out["env"] = envStrings.reduce(into: [String: String]()) { result, pair in
result[pair.key] = Self.maskedEnvValue(pair.value)
}
}
print(jsonString(formatIDs(out, mode: idFormat)))
} else if envStrings.isEmpty {
print(String(localized: "cli.workspace.env.empty", defaultValue: "No environment variables"))
} else {
for key in envStrings.keys.sorted() {
let value = envStrings[key] ?? ""
let shown = mask ? Self.maskedEnvValue(value) : value
print("\(key)=\(shown)")
}
}
}

private func runWorkspaceCloseCommand(
commandName: String,
commandArgs: [String],
Expand Down Expand Up @@ -7532,7 +7715,7 @@ struct CMUXCLI {
guard let sub = commandArgs.first?.lowercased() else {
throw CLIError(message: String(
localized: "cli.error.workspaceSubcommandRequired",
defaultValue: "workspace requires a subcommand. Try: list, create, close, rename, select, reconnect, disconnect, group"
defaultValue: "workspace requires a subcommand. Try: list, create, env, close, rename, select, reconnect, disconnect, group"
))
}
let rest = Array(commandArgs.dropFirst())
Expand Down Expand Up @@ -7563,6 +7746,14 @@ struct CMUXCLI {
windowOverride: windowOverride,
honorJSONOutput: true
)
case "env":
try runWorkspaceEnvCommand(
commandArgs: rest,
client: client,
jsonOutput: jsonOutput,
idFormat: idFormat,
windowOverride: windowOverride
)
case "close":
try runWorkspaceCloseCommand(
commandName: "workspace close",
Expand Down Expand Up @@ -7617,7 +7808,7 @@ struct CMUXCLI {
throw CLIError(message: String(
format: String(
localized: "cli.error.workspaceSubcommandUnknown",
defaultValue: "Unknown workspace subcommand: %@. Try: list, create, close, rename, select, reconnect, disconnect, group"
defaultValue: "Unknown workspace subcommand: %@. Try: list, create, env, close, rename, select, reconnect, disconnect, group"
),
locale: .current,
sub
Expand Down Expand Up @@ -14126,7 +14317,7 @@ struct CMUXCLI {
"""
case "new-workspace":
return """
Usage: cmux new-workspace [--name <title>] [--description <text>] [--cwd <path>] [--command <text>] [--layout <json>] [--window <id|ref|index>] [--focus <true|false>]
Usage: cmux new-workspace [--name <title>] [--description <text>] [--cwd <path>] [--command <text>] [--env KEY=VALUE]... [--env-file <path>]... [--layout <json>] [--window <id|ref|index>] [--focus <true|false>]

Create a new workspace in the caller's window.

Expand All @@ -14135,6 +14326,12 @@ struct CMUXCLI {
--description <text> Set a custom description for the new workspace
--cwd <path> Set the working directory for the new workspace
--command <text> Send text+Enter to the new workspace after creation
--env KEY=VALUE Set an environment variable for every shell in the
workspace (initial shell plus every later pane/split,
and across session restore). Repeatable. Reserved
CMUX_* variables cannot be overridden.
--env-file <path> Load KEY=VALUE lines from a file into the workspace
environment. Repeatable; --env overrides file values.
--layout <json> Create workspace with a predefined split layout (inline JSON).
Uses the same schema as cmux.json layout definitions.
When provided, --command is ignored (layout surfaces define their own commands).
Expand All @@ -14148,6 +14345,8 @@ struct CMUXCLI {
cmux new-workspace --name "Launch" --description "Ship checklist"
cmux new-workspace --cwd ~/projects/myapp
cmux new-workspace --cwd . --command "npm test"
cmux new-workspace --cwd . --env AWS_PROFILE=prod --env API_BASE=https://api.example.com
cmux new-workspace --cwd . --env-file ./.cmux.env
cmux new-workspace --name "Dev" --layout '{"direction":"horizontal","split":0.5,"children":[{"pane":{"surfaces":[{"type":"terminal","command":"vim"}]}},{"pane":{"surfaces":[{"type":"terminal","command":"npm run start"}]}}]}'
"""
case "list-workspaces":
Expand All @@ -14173,7 +14372,11 @@ struct CMUXCLI {

Subcommands:
list List workspaces in a window
create [flags] Create a workspace (same flags as new-workspace)
create [flags] Create a workspace (same flags as new-workspace,
including --env KEY=VALUE and --env-file <path>)
env [workspace] [--mask]
Print a workspace's configured environment
variables (--mask redacts the values)
close <workspace> Close a workspace
rename <workspace> --title <new>
select <workspace> Make a workspace active
Expand All @@ -14183,13 +14386,15 @@ struct CMUXCLI {
disconnect [workspace] Stop a remote (SSH) workspace's connection
group <subcommand> Workspace group operations (see cmux workspace-group --help)

reconnect/disconnect accept a positional handle or --workspace
env/reconnect/disconnect accept a positional handle or --workspace
<id|ref|index>, defaulting to the caller's workspace, then the
selected one (of --window's window when given).

Examples:
cmux workspace list --json
cmux workspace create --name Build --cwd ~/projects/myapp
cmux workspace create --cwd . --env AWS_PROFILE=prod --env-file ./.cmux.env
cmux workspace env workspace:3 --mask
cmux workspace close workspace:3
cmux workspace reconnect
cmux workspace disconnect --workspace workspace:3
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,10 @@ public enum ControlCommandExecutionPolicy: Sendable, Equatable {
"mobile.attach_ticket.create",
"system.top",
"system.memory",
// `workspace.env` is a read that resolves a workspace and copies its
// env dictionary behind a `v2MainSync` hop, so it runs on the worker
// lane like the other workspace reads below.
"workspace.env",
"workspace.remote.pty_sessions",
"workspace.remote.pty_close",
"workspace.remote.pty_detach",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ struct ControlCommandExecutionPolicyTests {
for method in [
"system.ping", "system.capabilities", "auth.status", "auth.sign_in_url",
"feed.push", "browser.download.wait", "system.top", "system.memory",
"workspace.remote.pty_bridge", "sidebar.custom.reload",
"workspace.remote.pty_bridge", "workspace.env", "sidebar.custom.reload",
"debug.sidebar.simulate_drag", "mobile.attach_ticket.create",
// JavaScript-evaluating browser methods block on page JS and must
// not hold the main actor (see socketWorkerMethods rationale).
Expand Down
Loading
Loading