Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
16 changes: 8 additions & 8 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.
33857 CLI/cmux.swift
34136 CLI/cmux.swift
17914 Sources/AppDelegate.swift
16709 Sources/ContentView.swift
14612 Sources/TerminalController.swift
14682 Sources/TerminalController.swift
13595 Sources/Panels/BrowserPanel.swift
12093 Sources/Workspace.swift
12088 Sources/GhosttyTerminalView.swift
12046 cmuxTests/AppDelegateShortcutRoutingTests.swift
11992 Sources/Workspace.swift
9331 cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift
7911 Sources/Panels/BrowserPanelView.swift
7350 cmuxTests/WorkspaceUnitTests.swift
7354 cmuxTests/WorkspaceUnitTests.swift
6944 cmuxTests/WorkspaceRemoteConnectionTests.swift
6363 cmuxTests/GhosttyConfigTests.swift
6317 cmuxTests/SessionPersistenceTests.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 @@ -98,8 +98,8 @@
905 Sources/CmuxSSHURLRequest.swift
901 Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/AppSection.swift
893 Sources/WorkspaceContentView.swift
876 Sources/Panels/TerminalPanel.swift
868 Sources/Panels/BrowserScreenshotSnapshotter.swift
866 Sources/Panels/TerminalPanel.swift
852 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Workspace/ControlCommandCoordinator+Workspace.swift
847 cmuxTests/AgentSessionAutoResumeSettingsTests.swift
845 cmuxTests/SSHStartupSignalLifecycleTests.swift
Expand Down Expand Up @@ -192,10 +192,10 @@
520 CLI/CMUXCLI+AmpExtension.swift
520 cmuxTests/MainWindowVisibilityControllerTests.swift
519 Packages/CmuxSwiftRender/Tests/CmuxSwiftRenderTests/Corpus/stress-two-column-cockpit-sidebar.swift
519 Sources/CmuxConfigExecutor.swift
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
514 Packages/CmuxSwiftRender/Sources/CmuxSwiftRender/ExpressionEvaluator.swift
514 cmuxUITests/UpdatePillUITests.swift
510 Sources/TerminalImageTransfer.swift
Expand Down
293 changes: 286 additions & 7 deletions CLI/cmux.swift

Large diffs are not rendered by default.

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
256 changes: 244 additions & 12 deletions Resources/Localizable.xcstrings

Large diffs are not rendered by default.

5 changes: 4 additions & 1 deletion Sources/CmuxConfigExecutor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -498,7 +498,10 @@ struct CmuxConfigExecutor {
}

let resolvedCwd = CmuxConfigStore.resolveCwd(wsDef.cwd, relativeTo: baseCwd)
let newWorkspace = tabManager.addWorkspace(workingDirectory: resolvedCwd)
let newWorkspace = tabManager.addWorkspace(
workingDirectory: resolvedCwd,
workspaceEnvironment: wsDef.env ?? [:]
)
newWorkspace.setCustomTitle(workspaceName)
if let color = wsDef.color {
newWorkspace.setCustomColor(color)
Expand Down
13 changes: 12 additions & 1 deletion Sources/CmuxWorkspaceDefinition.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,30 @@ struct CmuxWorkspaceDefinition: Codable, Sendable {
var name: String?
var cwd: String?
var color: String?
/// User-defined environment variables inherited by every shell spawned in the
/// workspace (issue #5995). Managed `CMUX_*` variables always win.
var env: [String: String]?
var layout: CmuxLayoutNode?

init(name: String? = nil, cwd: String? = nil, color: String? = nil, layout: CmuxLayoutNode? = nil) {
init(
name: String? = nil,
cwd: String? = nil,
color: String? = nil,
env: [String: String]? = nil,
layout: CmuxLayoutNode? = nil
) {
self.name = name
self.cwd = cwd
self.color = color
self.env = env
self.layout = layout
}

init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
name = try container.decodeIfPresent(String.self, forKey: .name)
cwd = try container.decodeIfPresent(String.self, forKey: .cwd)
env = try container.decodeIfPresent([String: String].self, forKey: .env)
layout = try container.decodeIfPresent(CmuxLayoutNode.self, forKey: .layout)

if let rawColor = try container.decodeIfPresent(String.self, forKey: .color) {
Expand Down
10 changes: 10 additions & 0 deletions Sources/Panels/TerminalPanel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,16 @@ final class TerminalPanel: Panel, ObservableObject {
/// The workspace ID this panel belongs to
private(set) var workspaceId: UUID

/// The workspace-env key/value pairs this panel inherited from its workspace's
/// `workspaceEnvironment` at creation. The same panel travels when a surface is
/// moved between workspaces, so a respawn uses these to drop the (possibly
/// previous) workspace's variables and re-apply the current workspace's. The
/// value (not just the key) is tracked so an explicit per-surface override that
/// happens to share a workspace key (e.g. a layout `env` AWS_PROFILE=staging in
/// a workspace with AWS_PROFILE=prod) is preserved on respawn rather than being
/// stripped and replaced by the workspace value (issue #5995).
var seededWorkspaceEnvironment: [String: String] = [:]

/// Published title from the terminal process
@Published private(set) var title: String = "Terminal"

Expand Down
3 changes: 3 additions & 0 deletions Sources/SessionPersistence.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1837,6 +1837,9 @@ struct SessionWorkspaceSnapshot: Codable, Sendable {
var progress: SessionProgressSnapshot?
var gitBranch: SessionGitBranchSnapshot?
var remote: SessionRemoteWorkspaceSnapshot?
/// User-defined per-workspace environment variables (issue #5995). Optional
/// with a `nil` default so manifests written before this field decode cleanly.
var environment: [String: String]? = nil
}

struct SessionWorkspaceGroupSnapshot: Codable, Sendable, Equatable {
Expand Down
10 changes: 7 additions & 3 deletions Sources/TabManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -935,7 +935,8 @@ class TabManager: ObservableObject {
initialSurface: NewWorkspaceInitialSurface = .terminal,
initialTerminalCommand: String?,
initialTerminalInput: String? = nil,
initialTerminalEnvironment: [String: String]
initialTerminalEnvironment: [String: String],
workspaceEnvironment: [String: String] = [:]
) -> Workspace {
Workspace(
title: title,
Expand All @@ -945,7 +946,8 @@ class TabManager: ObservableObject {
initialSurface: initialSurface,
initialTerminalCommand: initialTerminalCommand,
initialTerminalInput: initialTerminalInput,
initialTerminalEnvironment: initialTerminalEnvironment
initialTerminalEnvironment: initialTerminalEnvironment,
workspaceEnvironment: workspaceEnvironment
)
}

Expand Down Expand Up @@ -1022,6 +1024,7 @@ class TabManager: ObservableObject {
initialTerminalCommand: String? = nil,
initialTerminalInput: String? = nil,
initialTerminalEnvironment: [String: String] = [:],
workspaceEnvironment: [String: String] = [:],
inheritWorkingDirectory: Bool = true,
select: Bool = true,
eagerLoadTerminal: Bool = false,
Expand Down Expand Up @@ -1085,7 +1088,8 @@ class TabManager: ObservableObject {
initialSurface: initialSurface,
initialTerminalCommand: initialTerminalCommand,
initialTerminalInput: initialTerminalInput,
initialTerminalEnvironment: initialTerminalEnvironment
initialTerminalEnvironment: initialTerminalEnvironment,
workspaceEnvironment: workspaceEnvironment
)
applyCreationChromeInheritance(
to: newWorkspace,
Expand Down
70 changes: 70 additions & 0 deletions Sources/TerminalController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1084,6 +1084,8 @@ class TerminalController {
return v2Result(id: request.id, v2SystemTop(params: request.params))
case "system.memory":
return v2Result(id: request.id, v2SystemMemory(params: request.params))
case "workspace.env":
return v2Result(id: request.id, v2WorkspaceEnv(params: request.params))
case "workspace.remote.pty_sessions":
return v2Result(id: request.id, v2WorkspaceRemotePTYSessions(params: request.params))
case "workspace.remote.pty_close":
Expand Down Expand Up @@ -2023,6 +2025,7 @@ class TerminalController {
"window.display",
"workspace.list",
"workspace.create",
"workspace.env",
"workspace.select",
"workspace.current",
"workspace.close",
Expand Down Expand Up @@ -3675,6 +3678,63 @@ class TerminalController {
]
}

/// `workspace.env` — read a workspace's user-defined environment (issue #5995).
/// Resolves the workspace by `workspace_id` / surface / pane, falling back to the
/// selected workspace only when no explicit target is supplied, and returns the
/// raw configured set. An explicit-but-unresolvable target errors. Secret masking is a
/// CLI presentation concern (`cmux workspace env --mask`): the local control
/// socket already exposes the surrounding workspace state, so values are returned
/// verbatim and the env set is deliberately kept out of `workspace.list` so a
/// plain listing never echoes secrets.
private nonisolated func v2WorkspaceEnv(params: [String: Any]) -> V2CallResult {
// Validate any explicit target before resolving. This endpoint can print
// secrets, so a malformed or stale explicit target must error rather than
// silently fall back to the selected workspace (unlike the generic
// v2ResolveWorkspace, which falls through to the selection).
for key in ["workspace_id", "surface_id", "terminal_id", "tab_id", "pane_id"] {
if v2HasNonNullParam(params, key), v2UUID(params, key) == nil {
return .err(code: "invalid_params", message: "Missing or invalid \(key)", data: nil)
}
}
return v2MainSync { () -> V2CallResult in
v2RefreshKnownRefs()
guard let tabManager = v2ResolveTabManager(params: params) else {
return .err(code: "unavailable", message: "TabManager not available", data: nil)
}
// Resolve strictly for explicit targets; only fall back to the selected
// workspace when no explicit target was supplied.
let resolved: Workspace?
if let wsId = v2UUID(params, "workspace_id") {
resolved = tabManager.tabs.first(where: { $0.id == wsId })
} else if let surfaceId = v2UUID(params, "surface_id") ?? v2UUID(params, "terminal_id") ?? v2UUID(params, "tab_id") {
resolved = tabManager.tabs.first(where: { $0.panels[surfaceId] != nil })
} else if let paneId = v2UUID(params, "pane_id") {
if let located = v2LocatePane(paneId), located.tabManager === tabManager {
resolved = located.workspace
} else {
resolved = nil
}
} else if let selectedId = tabManager.selectedTabId {
resolved = tabManager.tabs.first(where: { $0.id == selectedId })
} else {
resolved = nil
}
guard let workspace = resolved else {
return .err(code: "not_found", message: "Workspace not found", data: nil)
}
let windowId = v2ResolveWindowId(tabManager: tabManager)
let env = workspace.workspaceEnvironment
return .ok([
"window_id": v2OrNull(windowId?.uuidString),
"window_ref": v2Ref(kind: .window, uuid: windowId),
"workspace_id": workspace.id.uuidString,
"workspace_ref": v2Ref(kind: .workspace, uuid: workspace.id),
"env": env,
"count": env.count,
])
}
}

private nonisolated func v2WorkspaceRemotePTYSessions(params: [String: Any]) -> V2CallResult {
if v2HasNonNullParam(params, "all_workspaces"), v2Bool(params, "all_workspaces") == nil {
return .err(code: "invalid_params", message: "Missing or invalid all_workspaces", data: nil)
Expand Down Expand Up @@ -13896,6 +13956,15 @@ class TerminalController {
guard !key.isEmpty else { return }
result[key] = pair.value
}
// Persistent per-workspace environment (issue #5995): applied to the initial
// shell AND every later pane/surface/split, and round-tripped through session
// restore. Socket callers must use `workspace_env`; bare `env` remains
// layout/config spelling elsewhere and is not silently reinterpreted here.
// Unlike `initial_env`, this is NOT gated on the presence of a layout — the
// workspace set must apply to layout-defined surfaces too.
let workspaceEnv = Workspace.sanitizedWorkspaceEnvironment(
v2StringMap(params, "workspace_env") ?? [:]
)
let cwd: String?
if let workingDirectory {
cwd = workingDirectory
Expand Down Expand Up @@ -13938,6 +14007,7 @@ class TerminalController {
workingDirectory: cwd,
initialTerminalCommand: layoutNode == nil ? initialCommand : nil,
initialTerminalEnvironment: layoutNode == nil ? initialEnv : [:],
workspaceEnvironment: workspaceEnv,
select: shouldFocus,
eagerLoadTerminal: shouldEagerLoadTerminal,
autoRefreshMetadata: shouldAutoRefreshMetadata
Expand Down
Loading
Loading