From 514b48a59687f45ea89aad776ac7b67e9c60355d Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sun, 14 Jun 2026 01:31:06 -0700 Subject: [PATCH 01/13] Add per-workspace environment variables inherited by every shell (#5995) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Give a workspace a set of user-defined env vars that every shell spawned in it inherits — the initial terminal plus every later pane/surface/split, and every surface recreated on session restore — without editing global shell init or re-exporting per pane. Model & injection - Workspace carries a persistent `workspaceEnvironment` dictionary, folded into the startup environment at the two terminal-creation choke points (`init` for the initial shell, `newTerminalSurface`/`newTerminalSplit` for every later surface and for session-restore, which routes through `newTerminalSurface`). It flows through the existing `additionalEnvironment` / `initialEnvironmentOverrides` channels, so the managed `CMUX_*` and terminal-identity vars (protected keys in `mergedStartupEnvironment`) always win and can never be clobbered. Explicit per-surface env (layout `env`, scrollback replay, SSH startup) overlays the workspace set. Persistence - Stored on `SessionWorkspaceSnapshot.environment` (optional, so older manifests decode cleanly) and restored before surfaces are rebuilt. Entry points - CLI: repeatable `--env KEY=VALUE` and `--env-file ` on `new-workspace` / `workspace create` (file values overridden by `--env`). - cmux.json: an `env` object on a workspace definition. - Socket: `workspace_env` (alias `env`) on `workspace.create`. Inspect - `cmux workspace env [] [--mask] [--json]` via a new worker-lane `workspace.env` control method. `--mask` redacts values; the env set is kept out of `workspace list` so a plain listing never leaks secrets. Tests, docs, localization - WorkspaceEnvironmentTests covers the acceptance paths (initial shell, later pane, explicit-override precedence, restore round-trip, CMUX_* protection, Codable back-compat, config decode), wired into the pbxproj. - docs/cli-contract.md documents the flags, the inspect command, and the precedence vs shell init files and protected CMUX_* vars. - Localizable.xcstrings updated (en/ja/ko/uk) for the workspace usage/error strings and the new empty-output string. Closes #5995 Co-Authored-By: Claude Opus 4.8 --- CLI/cmux.swift | 219 +++++++++++++++++- .../Wire/ControlCommandExecutionPolicy.swift | 4 + .../ControlCommandExecutionPolicyTests.swift | 2 +- Resources/Localizable.xcstrings | 53 ++++- Sources/CmuxConfigExecutor.swift | 5 +- Sources/CmuxWorkspaceDefinition.swift | 13 +- Sources/SessionPersistence.swift | 3 + Sources/TabManager.swift | 10 +- Sources/TerminalController.swift | 44 ++++ Sources/Workspace.swift | 73 +++++- cmux.xcodeproj/project.pbxproj | 4 + cmuxTests/WorkspaceEnvironmentTests.swift | 174 ++++++++++++++ docs/cli-contract.md | 50 +++- 13 files changed, 622 insertions(+), 32 deletions(-) create mode 100644 cmuxTests/WorkspaceEnvironmentTests.swift diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 0b99b1502626..076668f90cbc 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -7086,9 +7086,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 , --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: ¶ms, client: client, windowRaw: windowOpt ?? windowOverride) @@ -7097,6 +7098,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 { @@ -7119,6 +7124,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] = [:] + 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 + } + + /// 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], @@ -7396,7 +7579,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()) @@ -7427,6 +7610,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", @@ -7481,7 +7672,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 @@ -13957,7 +14148,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. @@ -13966,6 +14157,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). @@ -13979,6 +14176,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": @@ -14004,7 +14203,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 @@ -14014,13 +14217,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 diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Wire/ControlCommandExecutionPolicy.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Wire/ControlCommandExecutionPolicy.swift index e29118c59607..4bce4585e86a 100644 --- a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Wire/ControlCommandExecutionPolicy.swift +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Wire/ControlCommandExecutionPolicy.swift @@ -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", diff --git a/Packages/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandExecutionPolicyTests.swift b/Packages/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandExecutionPolicyTests.swift index 42bbb111d367..5c193c65ff6e 100644 --- a/Packages/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandExecutionPolicyTests.swift +++ b/Packages/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandExecutionPolicyTests.swift @@ -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). diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index c6b05c69f192..09470b232fe6 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -194462,25 +194462,25 @@ "en": { "stringUnit": { "state": "translated", - "value": "Usage: cmux workspace <subcommand> [flags]\n\nCanonical noun for workspace operations. Legacy verbs\n(new-workspace, list-workspaces, close-workspace,\nrename-workspace, select-workspace) keep working and print a\none-time deprecation hint pointing here.\n\nSubcommands:\n list List workspaces in a window\n create [flags] Create a workspace (same flags as new-workspace)\n close <workspace> Close a workspace\n rename <workspace> --title <new>\n select <workspace> Make a workspace active\n reconnect [workspace] Reconnect a remote (SSH) workspace, including one\n whose automatic reconnect paused because the host\n was unreachable\n disconnect [workspace] Stop a remote (SSH) workspace's connection\n group <subcommand> Workspace group operations (see cmux workspace-group --help)\n\nreconnect/disconnect accept a positional handle or --workspace\n<id|ref|index>, defaulting to the caller's workspace, then the\nselected one (of --window's window when given).\n\nExamples:\n cmux workspace list --json\n cmux workspace create --name Build --cwd ~/projects/myapp\n cmux workspace close workspace:3\n cmux workspace reconnect\n cmux workspace disconnect --workspace workspace:3" + "value": "Usage: cmux workspace <subcommand> [flags]\n\nCanonical noun for workspace operations. Legacy verbs\n(new-workspace, list-workspaces, close-workspace,\nrename-workspace, select-workspace) keep working and print a\none-time deprecation hint pointing here.\n\nSubcommands:\n list List workspaces in a window\n create [flags] Create a workspace (same flags as new-workspace,\n including --env KEY=VALUE and --env-file <path>)\n env [workspace] [--mask]\n Print a workspace's configured environment\n variables (--mask redacts the values)\n close <workspace> Close a workspace\n rename <workspace> --title <new>\n select <workspace> Make a workspace active\n reconnect [workspace] Reconnect a remote (SSH) workspace, including one\n whose automatic reconnect paused because the host\n was unreachable\n disconnect [workspace] Stop a remote (SSH) workspace's connection\n group <subcommand> Workspace group operations (see cmux workspace-group --help)\n\nenv/reconnect/disconnect accept a positional handle or --workspace\n<id|ref|index>, defaulting to the caller's workspace, then the\nselected one (of --window's window when given).\n\nExamples:\n cmux workspace list --json\n cmux workspace create --name Build --cwd ~/projects/myapp\n cmux workspace create --cwd . --env AWS_PROFILE=prod --env-file ./.cmux.env\n cmux workspace env workspace:3 --mask\n cmux workspace close workspace:3\n cmux workspace reconnect\n cmux workspace disconnect --workspace workspace:3" } }, "ja": { "stringUnit": { "state": "translated", - "value": "使い方: cmux workspace <サブコマンド> [フラグ]\n\nワークスペース操作の正規の名前空間です。従来の動詞コマンド\n(new-workspace、list-workspaces、close-workspace、\nrename-workspace、select-workspace) は引き続き動作し、ここを指す\n非推奨ヒントを一度だけ表示します。\n\nサブコマンド:\n list ウィンドウ内のワークスペースを一覧表示\n create [flags] ワークスペースを作成 (new-workspace と同じフラグ)\n close <workspace> ワークスペースを閉じる\n rename <workspace> --title <new>\n select <workspace> ワークスペースをアクティブにする\n reconnect [workspace] リモート (SSH) ワークスペースを再接続。ホストに\n 到達できず自動再接続が一時停止したものも含む\n disconnect [workspace] リモート (SSH) ワークスペースの接続を停止\n group <subcommand> ワークスペースグループ操作 (cmux workspace-group --help を参照)\n\nreconnect/disconnect は位置引数のハンドルまたは --workspace\n<id|ref|index> を受け付け、省略時は呼び出し元のワークスペース、\n次に選択中のワークスペース (--window 指定時はそのウィンドウ) を\n対象にします。\n\n例:\n cmux workspace list --json\n cmux workspace create --name Build --cwd ~/projects/myapp\n cmux workspace close workspace:3\n cmux workspace reconnect\n cmux workspace disconnect --workspace workspace:3" + "value": "使い方: cmux workspace <サブコマンド> [フラグ]\n\nワークスペース操作の正規の名前空間です。従来の動詞コマンド\n(new-workspace、list-workspaces、close-workspace、\nrename-workspace、select-workspace) は引き続き動作し、ここを指す\n非推奨ヒントを一度だけ表示します。\n\nサブコマンド:\n list ウィンドウ内のワークスペースを一覧表示\n create [flags] ワークスペースを作成 (new-workspace と同じフラグ。\n --env KEY=VALUE と --env-file <path> を含む)\n env [workspace] [--mask]\n ワークスペースに設定された環境変数を表示\n (--mask は値を伏せ字にします)\n close <workspace> ワークスペースを閉じる\n rename <workspace> --title <new>\n select <workspace> ワークスペースをアクティブにする\n reconnect [workspace] リモート (SSH) ワークスペースを再接続。ホストに\n 到達できず自動再接続が一時停止したものも含む\n disconnect [workspace] リモート (SSH) ワークスペースの接続を停止\n group <subcommand> ワークスペースグループ操作 (cmux workspace-group --help を参照)\n\nenv/reconnect/disconnect は位置引数のハンドルまたは --workspace\n<id|ref|index> を受け付け、省略時は呼び出し元のワークスペース、\n次に選択中のワークスペース (--window 指定時はそのウィンドウ) を\n対象にします。\n\n例:\n cmux workspace list --json\n cmux workspace create --name Build --cwd ~/projects/myapp\n cmux workspace create --cwd . --env AWS_PROFILE=prod --env-file ./.cmux.env\n cmux workspace env workspace:3 --mask\n cmux workspace close workspace:3\n cmux workspace reconnect\n cmux workspace disconnect --workspace workspace:3" } }, "ko": { "stringUnit": { "state": "translated", - "value": "사용법: cmux workspace <하위 명령> [플래그]\n\n워크스페이스 작업의 표준 네임스페이스입니다. 기존 동사형 명령\n(new-workspace, list-workspaces, close-workspace,\nrename-workspace, select-workspace)은 계속 작동하며 이곳을\n가리키는 사용 중단 안내를 한 번 표시합니다.\n\n하위 명령:\n list 창의 워크스페이스 목록 표시\n create [flags] 워크스페이스 생성 (new-workspace와 동일한 플래그)\n close <workspace> 워크스페이스 닫기\n rename <workspace> --title <new>\n select <workspace> 워크스페이스를 활성화\n reconnect [workspace] 원격 (SSH) 워크스페이스 재연결. 호스트에 연결할\n 수 없어 자동 재연결이 일시 중지된 경우 포함\n disconnect [workspace] 원격 (SSH) 워크스페이스 연결 중지\n group <subcommand> 워크스페이스 그룹 작업 (cmux workspace-group --help 참고)\n\nreconnect/disconnect는 위치 핸들 또는 --workspace\n<id|ref|index>를 받으며, 기본값은 호출자의 워크스페이스, 그다음\n선택된 워크스페이스입니다 (--window 지정 시 해당 창 기준).\n\n예시:\n cmux workspace list --json\n cmux workspace create --name Build --cwd ~/projects/myapp\n cmux workspace close workspace:3\n cmux workspace reconnect\n cmux workspace disconnect --workspace workspace:3" + "value": "사용법: cmux workspace <하위 명령> [플래그]\n\n워크스페이스 작업의 표준 네임스페이스입니다. 기존 동사형 명령\n(new-workspace, list-workspaces, close-workspace,\nrename-workspace, select-workspace)은 계속 작동하며 이곳을\n가리키는 사용 중단 안내를 한 번 표시합니다.\n\n하위 명령:\n list 창의 워크스페이스 목록 표시\n create [flags] 워크스페이스 생성 (new-workspace와 동일한 플래그.\n --env KEY=VALUE 및 --env-file <path> 포함)\n env [workspace] [--mask]\n 워크스페이스에 설정된 환경 변수 표시\n (--mask는 값을 가립니다)\n close <workspace> 워크스페이스 닫기\n rename <workspace> --title <new>\n select <workspace> 워크스페이스를 활성화\n reconnect [workspace] 원격 (SSH) 워크스페이스 재연결. 호스트에 연결할\n 수 없어 자동 재연결이 일시 중지된 경우 포함\n disconnect [workspace] 원격 (SSH) 워크스페이스 연결 중지\n group <subcommand> 워크스페이스 그룹 작업 (cmux workspace-group --help 참고)\n\nenv/reconnect/disconnect는 위치 핸들 또는 --workspace\n<id|ref|index>를 받으며, 기본값은 호출자의 워크스페이스, 그다음\n선택된 워크스페이스입니다 (--window 지정 시 해당 창 기준).\n\n예시:\n cmux workspace list --json\n cmux workspace create --name Build --cwd ~/projects/myapp\n cmux workspace create --cwd . --env AWS_PROFILE=prod --env-file ./.cmux.env\n cmux workspace env workspace:3 --mask\n cmux workspace close workspace:3\n cmux workspace reconnect\n cmux workspace disconnect --workspace workspace:3" } }, "uk": { "stringUnit": { "state": "translated", - "value": "Використання: cmux workspace <підкоманда> [прапорці]\n\nКанонічний простір імен для операцій з робочими просторами.\nЗастарілі дієслівні команди (new-workspace, list-workspaces,\nclose-workspace, rename-workspace, select-workspace) продовжують\nпрацювати й одноразово показують підказку про застарілість, що\nвказує сюди.\n\nПідкоманди:\n list Список робочих просторів у вікні\n create [flags] Створити робочий простір (ті самі прапорці, що й new-workspace)\n close <workspace> Закрити робочий простір\n rename <workspace> --title <new>\n select <workspace> Зробити робочий простір активним\n reconnect [workspace] Перепідключити віддалений (SSH) робочий простір,\n зокрема той, де автоматичне перепідключення\n призупинено через недосяжний хост\n disconnect [workspace] Зупинити з'єднання віддаленого (SSH) робочого простору\n group <subcommand> Операції з групами робочих просторів (див. cmux workspace-group --help)\n\nreconnect/disconnect приймають позиційний дескриптор або --workspace\n<id|ref|index>; типово використовується робочий простір викликача,\nдалі — вибраний (у вікні з --window, якщо задано).\n\nПриклади:\n cmux workspace list --json\n cmux workspace create --name Build --cwd ~/projects/myapp\n cmux workspace close workspace:3\n cmux workspace reconnect\n cmux workspace disconnect --workspace workspace:3" + "value": "Використання: cmux workspace <підкоманда> [прапорці]\n\nКанонічний простір імен для операцій з робочими просторами.\nЗастарілі дієслівні команди (new-workspace, list-workspaces,\nclose-workspace, rename-workspace, select-workspace) продовжують\nпрацювати й одноразово показують підказку про застарілість, що\nвказує сюди.\n\nПідкоманди:\n list Список робочих просторів у вікні\n create [flags] Створити робочий простір (ті самі прапорці, що й\n new-workspace, зокрема --env KEY=VALUE та --env-file <path>)\n env [workspace] [--mask]\n Показати налаштовані змінні середовища робочого\n простору (--mask приховує значення)\n close <workspace> Закрити робочий простір\n rename <workspace> --title <new>\n select <workspace> Зробити робочий простір активним\n reconnect [workspace] Перепідключити віддалений (SSH) робочий простір,\n зокрема той, де автоматичне перепідключення\n призупинено через недосяжний хост\n disconnect [workspace] Зупинити з'єднання віддаленого (SSH) робочого простору\n group <subcommand> Операції з групами робочих просторів (див. cmux workspace-group --help)\n\nenv/reconnect/disconnect приймають позиційний дескриптор або --workspace\n<id|ref|index>; типово використовується робочий простір викликача,\nдалі — вибраний (у вікні з --window, якщо задано).\n\nПриклади:\n cmux workspace list --json\n cmux workspace create --name Build --cwd ~/projects/myapp\n cmux workspace create --cwd . --env AWS_PROFILE=prod --env-file ./.cmux.env\n cmux workspace env workspace:3 --mask\n cmux workspace close workspace:3\n cmux workspace reconnect\n cmux workspace disconnect --workspace workspace:3" } } } @@ -194491,25 +194491,25 @@ "en": { "stringUnit": { "state": "translated", - "value": "workspace requires a subcommand. Try: list, create, close, rename, select, reconnect, disconnect, group" + "value": "workspace requires a subcommand. Try: list, create, env, close, rename, select, reconnect, disconnect, group" } }, "ja": { "stringUnit": { "state": "translated", - "value": "workspace にはサブコマンドが必要です。利用可能: list, create, close, rename, select, reconnect, disconnect, group" + "value": "workspace にはサブコマンドが必要です。利用可能: list, create, env, close, rename, select, reconnect, disconnect, group" } }, "ko": { "stringUnit": { "state": "translated", - "value": "workspace에는 하위 명령이 필요합니다. 사용 가능: list, create, close, rename, select, reconnect, disconnect, group" + "value": "workspace에는 하위 명령이 필요합니다. 사용 가능: list, create, env, close, rename, select, reconnect, disconnect, group" } }, "uk": { "stringUnit": { "state": "translated", - "value": "workspace потребує підкоманди. Доступні: list, create, close, rename, select, reconnect, disconnect, group" + "value": "workspace потребує підкоманди. Доступні: list, create, env, close, rename, select, reconnect, disconnect, group" } } } @@ -194520,25 +194520,25 @@ "en": { "stringUnit": { "state": "translated", - "value": "Unknown workspace subcommand: %@. Try: list, create, close, rename, select, reconnect, disconnect, group" + "value": "Unknown workspace subcommand: %@. Try: list, create, env, close, rename, select, reconnect, disconnect, group" } }, "ja": { "stringUnit": { "state": "translated", - "value": "不明な workspace サブコマンド: %@。利用可能: list, create, close, rename, select, reconnect, disconnect, group" + "value": "不明な workspace サブコマンド: %@。利用可能: list, create, env, close, rename, select, reconnect, disconnect, group" } }, "ko": { "stringUnit": { "state": "translated", - "value": "알 수 없는 workspace 하위 명령: %@. 사용 가능: list, create, close, rename, select, reconnect, disconnect, group" + "value": "알 수 없는 workspace 하위 명령: %@. 사용 가능: list, create, env, close, rename, select, reconnect, disconnect, group" } }, "uk": { "stringUnit": { "state": "translated", - "value": "Невідома підкоманда workspace: %@. Доступні: list, create, close, rename, select, reconnect, disconnect, group" + "value": "Невідома підкоманда workspace: %@. Доступні: list, create, env, close, rename, select, reconnect, disconnect, group" } } } @@ -194745,6 +194745,35 @@ } } } + }, + "cli.workspace.env.empty": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No environment variables" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "環境変数はありません" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "환경 변수가 없습니다" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Немає змінних середовища" + } + } + } } } } diff --git a/Sources/CmuxConfigExecutor.swift b/Sources/CmuxConfigExecutor.swift index d2c13e9727f4..de34b48dbfcd 100644 --- a/Sources/CmuxConfigExecutor.swift +++ b/Sources/CmuxConfigExecutor.swift @@ -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) diff --git a/Sources/CmuxWorkspaceDefinition.swift b/Sources/CmuxWorkspaceDefinition.swift index 9ec0c810715b..ab9bad8dd517 100644 --- a/Sources/CmuxWorkspaceDefinition.swift +++ b/Sources/CmuxWorkspaceDefinition.swift @@ -4,12 +4,22 @@ 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 } @@ -17,6 +27,7 @@ struct CmuxWorkspaceDefinition: Codable, Sendable { 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) { diff --git a/Sources/SessionPersistence.swift b/Sources/SessionPersistence.swift index 3717255e54b9..b09805114bda 100644 --- a/Sources/SessionPersistence.swift +++ b/Sources/SessionPersistence.swift @@ -1813,6 +1813,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 { diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index ee2832225bff..f2f5a64821a6 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -2535,7 +2535,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, @@ -2545,7 +2546,8 @@ class TabManager: ObservableObject { initialSurface: initialSurface, initialTerminalCommand: initialTerminalCommand, initialTerminalInput: initialTerminalInput, - initialTerminalEnvironment: initialTerminalEnvironment + initialTerminalEnvironment: initialTerminalEnvironment, + workspaceEnvironment: workspaceEnvironment ) } @@ -2622,6 +2624,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, @@ -2685,7 +2688,8 @@ class TabManager: ObservableObject { initialSurface: initialSurface, initialTerminalCommand: initialTerminalCommand, initialTerminalInput: initialTerminalInput, - initialTerminalEnvironment: initialTerminalEnvironment + initialTerminalEnvironment: initialTerminalEnvironment, + workspaceEnvironment: workspaceEnvironment ) applyCreationChromeInheritance( to: newWorkspace, diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 2156cd48abae..28b0958fed44 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -1063,6 +1063,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": @@ -2002,6 +2004,7 @@ class TerminalController { "window.display", "workspace.list", "workspace.create", + "workspace.env", "workspace.select", "workspace.current", "workspace.close", @@ -3654,6 +3657,38 @@ 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, and returns the raw configured set. 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 { + if v2HasNonNullParam(params, "workspace_id"), v2UUID(params, "workspace_id") == nil { + return .err(code: "invalid_params", message: "Missing or invalid workspace_id", data: nil) + } + return v2MainSync { () -> V2CallResult in + v2RefreshKnownRefs() + guard let tabManager = v2ResolveTabManager(params: params) else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + guard let workspace = v2ResolveWorkspace(params: params, tabManager: tabManager) 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) @@ -14060,6 +14095,14 @@ 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. Accept `workspace_env` (preferred) or `env` (layout-JSON spelling). + // 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") ?? v2StringMap(params, "env") ?? [:] + ) let cwd: String? if let workingDirectory { cwd = workingDirectory @@ -14102,6 +14145,7 @@ class TerminalController { workingDirectory: cwd, initialTerminalCommand: layoutNode == nil ? initialCommand : nil, initialTerminalEnvironment: layoutNode == nil ? initialEnv : [:], + workspaceEnvironment: workspaceEnv, select: shouldFocus, eagerLoadTerminal: shouldEagerLoadTerminal, autoRefreshMetadata: shouldAutoRefreshMetadata diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 501993a58dba..ed9d0564c151 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -253,7 +253,8 @@ extension Workspace { logEntries: logSnapshots, progress: progressSnapshot, gitBranch: gitBranchSnapshot, - remote: remoteConfiguration?.sessionSnapshot() + remote: remoteConfiguration?.sessionSnapshot(), + environment: workspaceEnvironment.isEmpty ? nil : workspaceEnvironment ) } @@ -295,6 +296,11 @@ extension Workspace { currentDirectory = normalizedCurrentDirectory } + // Restore the per-workspace environment before any surface is rebuilt so + // every restored terminal (all of which spawn fresh shells — PTYs do not + // survive an app restart) inherits it through `newTerminalSurface`. + workspaceEnvironment = Self.sanitizedWorkspaceEnvironment(snapshot.environment ?? [:]) + let panelSnapshotsById = Dictionary(uniqueKeysWithValues: snapshot.panels.map { ($0.id, $0) }) let leafEntries: [SessionPaneRestoreEntry] = { let previousValue = suppressRemoteTerminalStartupForSessionRestoreScaffold @@ -10583,6 +10589,16 @@ final class Workspace: Identifiable, ObservableObject { /// The group entity itself lives in `TabManager.workspaceGroups`. @Published var groupId: UUID? @Published var customColor: String? // hex string, e.g. "#C0392B" + /// User-defined environment variables applied to every shell spawned in this + /// workspace: the initial terminal, every later pane/surface/split, and every + /// surface recreated on session restore. Managed `CMUX_*` and terminal-identity + /// variables always win — this dictionary is merged through the + /// `additionalEnvironment` / `initialEnvironmentOverrides` channels, both of + /// which skip `protectedStartupEnvironmentKeys` in + /// `mergedStartupEnvironment(...)`, so a workspace env entry can never clobber + /// the variables the daemon relies on (CMUX_WORKSPACE_ID, CMUX_SOCKET_PATH, …). + /// Persisted in the session manifest and restored before surfaces are rebuilt. + @Published var workspaceEnvironment: [String: String] = [:] // Legacy in-memory state for old helpers/tests. Product UI, rendering, and // session persistence no longer honor per-workspace scrollbar overrides. @Published private(set) var terminalScrollBarHidden: Bool = false @@ -11258,9 +11274,13 @@ final class Workspace: Identifiable, ObservableObject { initialSurface: NewWorkspaceInitialSurface = .terminal, initialTerminalCommand: String? = nil, initialTerminalInput: String? = nil, - initialTerminalEnvironment: [String: String] = [:], initialDetachedSurface: DetachedSurfaceTransfer? = nil + initialTerminalEnvironment: [String: String] = [:], + workspaceEnvironment: [String: String] = [:], + initialDetachedSurface: DetachedSurfaceTransfer? = nil ) { self.id = UUID() + let sanitizedWorkspaceEnvironment = Self.sanitizedWorkspaceEnvironment(workspaceEnvironment) + self.workspaceEnvironment = sanitizedWorkspaceEnvironment self.portOrdinal = portOrdinal self.processTitle = title self.title = title @@ -11362,7 +11382,10 @@ final class Workspace: Identifiable, ObservableObject { portOrdinal: portOrdinal, initialCommand: initialTerminalCommand, initialInput: initialTerminalInput, - initialEnvironmentOverrides: initialTerminalEnvironment + initialEnvironmentOverrides: Self.startupEnvironment( + workspaceEnvironment: sanitizedWorkspaceEnvironment, + overlaying: initialTerminalEnvironment + ) ) configureNewTerminalPanel(terminalPanel) panels[terminalPanel.id] = terminalPanel @@ -13702,6 +13725,46 @@ final class Workspace: Identifiable, ObservableObject { maybeDemoteRemoteWorkspaceAfterSSHSessionEnded() } + /// Normalizes a user-supplied workspace environment: trims keys and drops any + /// entry with a blank key or blank value. Dropping blank values keeps behavior + /// identical across the `additionalEnvironment` channel (which already skips + /// empty values) and the `initialEnvironmentOverrides` channel (which would + /// otherwise export a blank value on the initial shell only). Reserved `CMUX_*` + /// variables are intentionally *not* stripped here — they are protected at + /// spawn time by `mergedStartupEnvironment(protectedKeys:)`, which is the single + /// authority on which keys are managed. + static func sanitizedWorkspaceEnvironment(_ environment: [String: String]) -> [String: String] { + environment.reduce(into: [String: String]()) { result, pair in + let key = pair.key.trimmingCharacters(in: .whitespacesAndNewlines) + guard !key.isEmpty, !pair.value.isEmpty else { return } + result[key] = pair.value + } + } + + /// Pure merge core: overlays `explicit` on top of `workspaceEnvironment`. + /// Managed `CMUX_*` / terminal-identity keys are protected downstream by + /// `mergedStartupEnvironment(protectedKeys:)`; this only decides precedence + /// among user-supplied values — explicit per-surface entries (layout `env`, + /// scrollback replay, SSH startup) win over the workspace set. Static so the + /// `init` path can call it before `self` is fully initialized. + static func startupEnvironment( + workspaceEnvironment: [String: String], + overlaying explicit: [String: String] + ) -> [String: String] { + guard !workspaceEnvironment.isEmpty else { return explicit } + var merged = workspaceEnvironment + for (key, value) in explicit { + merged[key] = value + } + return merged + } + + /// Instance convenience over ``startupEnvironment(workspaceEnvironment:overlaying:)`` + /// for the post-init surface-creation paths. + func startupEnvironmentMergingWorkspaceEnvironment(_ explicit: [String: String]) -> [String: String] { + Self.startupEnvironment(workspaceEnvironment: workspaceEnvironment, overlaying: explicit) + } + private func terminalStartupEnvironment( base: [String: String], remoteStartupCommand: String? @@ -14699,7 +14762,7 @@ final class Workspace: Identifiable, ObservableObject { let startupCommand = explicitInitialCommand ?? remoteTerminalStartupCommand let remoteStartupCommandForEnvironment = explicitInitialCommand == nil ? remoteTerminalStartupCommand : nil let effectiveStartupEnvironment = terminalStartupEnvironment( - base: startupEnvironment, + base: startupEnvironmentMergingWorkspaceEnvironment(startupEnvironment), remoteStartupCommand: remoteStartupCommandForEnvironment ) // Hold the pane open after the remote session ends so the user can read the @@ -14884,7 +14947,7 @@ final class Workspace: Identifiable, ObservableObject { let startupCommand = explicitInitialCommand ?? remoteTerminalStartupCommand let remoteStartupCommandForEnvironment = explicitInitialCommand == nil ? remoteTerminalStartupCommand : nil let effectiveStartupEnvironment = terminalStartupEnvironment( - base: startupEnvironment, + base: startupEnvironmentMergingWorkspaceEnvironment(startupEnvironment), remoteStartupCommand: remoteStartupCommandForEnvironment ) // See the comment at the other call site: hold the PTY open after the remote diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 60b5053b2332..76a691e5bc69 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -758,6 +758,7 @@ F7000000A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */; }; 281F3AEA52379AF45C5C1189 /* WorkspaceCustomSidebarPullRequestContextTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE74DFAC5946F9C2EEDBE577 /* WorkspaceCustomSidebarPullRequestContextTests.swift */; }; E6FA9084A1B2C3D4E5F60718 /* WorkspaceDescriptionUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6FA9085A1B2C3D4E5F60718 /* WorkspaceDescriptionUITests.swift */; }; + 72DB1838A0F1B710BFB45DC1 /* WorkspaceEnvironmentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B561A95C6DC21BA9C4B2BCDD /* WorkspaceEnvironmentTests.swift */; }; C0DE36230000000000000001 /* WorkspaceFinderDirectoryResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE36230000000000000002 /* WorkspaceFinderDirectoryResolver.swift */; }; C9A57203C9A57203C9A57203 /* WorkspaceGroupMenuSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9A57204C9A57204C9A57204 /* WorkspaceGroupMenuSnapshot.swift */; }; C9A57001C9A57001C9A57001 /* WorkspaceGroupTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9A57002C9A57002C9A57002 /* WorkspaceGroupTests.swift */; }; @@ -1557,6 +1558,7 @@ F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceContentViewVisibilityTests.swift; sourceTree = "<group>"; }; CE74DFAC5946F9C2EEDBE577 /* WorkspaceCustomSidebarPullRequestContextTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceCustomSidebarPullRequestContextTests.swift; sourceTree = "<group>"; }; E6FA9085A1B2C3D4E5F60718 /* WorkspaceDescriptionUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceDescriptionUITests.swift; sourceTree = "<group>"; }; + B561A95C6DC21BA9C4B2BCDD /* WorkspaceEnvironmentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceEnvironmentTests.swift; sourceTree = "<group>"; }; C0DE36230000000000000002 /* WorkspaceFinderDirectoryResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceFinderDirectoryResolver.swift; sourceTree = "<group>"; }; C9A57204C9A57204C9A57204 /* WorkspaceGroupMenuSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceGroupMenuSnapshot.swift; sourceTree = "<group>"; }; C9A57002C9A57002C9A57002 /* WorkspaceGroupTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceGroupTests.swift; sourceTree = "<group>"; }; @@ -2419,6 +2421,7 @@ D2C075029771815DD5DA1332 /* NotificationAndMenuBarTests.swift */, 4E5F60720000000000000002 /* NotificationSoundSettingsTests.swift */, 42092CDB2109E250F7F2A76E /* TabManagerUnitTests.swift */, + B561A95C6DC21BA9C4B2BCDD /* WorkspaceEnvironmentTests.swift */, D7C0DE00000000000000A104 /* CmuxWebViewContextMenuLinkCaptureTests.swift */, D1F0A00300000000000000C2 /* PhonePushPresenceGateTests.swift */, B79482F1ECA54E98BE5C8953 /* WindowKeyDownReplayGuardTests.swift */, @@ -3613,6 +3616,7 @@ D7AB3605C10DEF0000000001 /* WorkspaceCloseTabsContextMenuTests.swift in Sources */, F7000000A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift in Sources */, 281F3AEA52379AF45C5C1189 /* WorkspaceCustomSidebarPullRequestContextTests.swift in Sources */, + 72DB1838A0F1B710BFB45DC1 /* WorkspaceEnvironmentTests.swift in Sources */, C9A57001C9A57001C9A57001 /* WorkspaceGroupTests.swift in Sources */, 0F2C25F9170130F8DC09DD1B /* WorkspaceManualUnreadTests.swift in Sources */, 1A1B2C3D4E5F607180000003 /* WorkspacePromptSubmitTests.swift in Sources */, diff --git a/cmuxTests/WorkspaceEnvironmentTests.swift b/cmuxTests/WorkspaceEnvironmentTests.swift new file mode 100644 index 000000000000..4b3d295af8f7 --- /dev/null +++ b/cmuxTests/WorkspaceEnvironmentTests.swift @@ -0,0 +1,174 @@ +import XCTest + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +/// Behavior coverage for per-workspace user-defined environment variables +/// (issue #5995): the initial shell inherits them, every later pane/split +/// inherits them, they survive session restore, explicit per-surface env wins, +/// and the managed `CMUX_*` variables can never be clobbered. +@MainActor +final class WorkspaceEnvironmentTests: XCTestCase { + + // MARK: - Sanitization + + func testSanitizedWorkspaceEnvironmentTrimsKeysAndDropsBlanks() { + let result = Workspace.sanitizedWorkspaceEnvironment([ + " FOO ": "bar", // key is trimmed + "": "ignored", // blank key is dropped + "EMPTY": "", // blank value is dropped (matches additionalEnvironment) + "OK": "value", + ]) + XCTAssertEqual(result, ["FOO": "bar", "OK": "value"]) + } + + // MARK: - Acceptance: initial shell inherits the workspace environment + + func testInitialShellInheritsWorkspaceEnvironment() throws { + let workspace = Workspace(workspaceEnvironment: ["AWS_PROFILE": "prod", "API_BASE": "https://api.example.com"]) + let panelId = try XCTUnwrap(workspace.focusedPanelId) + let panel = try XCTUnwrap(workspace.terminalPanel(for: panelId)) + let env = panel.surface.respawnInitialEnvironmentOverrides + XCTAssertEqual(env["AWS_PROFILE"], "prod") + XCTAssertEqual(env["API_BASE"], "https://api.example.com") + } + + // MARK: - Acceptance: later panes/splits inherit it, with no per-pane re-export + + func testLaterSurfaceInheritsWorkspaceEnvironment() throws { + let workspace = Workspace(workspaceEnvironment: ["AWS_PROFILE": "prod"]) + let firstPanelId = try XCTUnwrap(workspace.focusedPanelId) + let paneId = try XCTUnwrap(workspace.paneId(forPanelId: firstPanelId)) + let second = try XCTUnwrap(workspace.newTerminalSurface(inPane: paneId, focus: false)) + XCTAssertEqual(second.surface.respawnAdditionalEnvironment["AWS_PROFILE"], "prod") + } + + /// An explicit per-surface environment (layout `env`, scrollback replay, SSH + /// startup) overlays the workspace set rather than being discarded. + func testExplicitSurfaceEnvironmentOverridesWorkspaceEnvironment() throws { + let workspace = Workspace(workspaceEnvironment: ["AWS_PROFILE": "prod", "SHARED": "workspace"]) + let firstPanelId = try XCTUnwrap(workspace.focusedPanelId) + let paneId = try XCTUnwrap(workspace.paneId(forPanelId: firstPanelId)) + let second = try XCTUnwrap(workspace.newTerminalSurface( + inPane: paneId, + focus: false, + startupEnvironment: ["SHARED": "surface", "EXTRA": "x"] + )) + let env = second.surface.respawnAdditionalEnvironment + XCTAssertEqual(env["SHARED"], "surface") // explicit wins + XCTAssertEqual(env["AWS_PROFILE"], "prod") // workspace value preserved + XCTAssertEqual(env["EXTRA"], "x") + } + + func testEmptyWorkspaceEnvironmentLeavesSurfaceEnvironmentUntouched() throws { + let workspace = Workspace() + let firstPanelId = try XCTUnwrap(workspace.focusedPanelId) + let paneId = try XCTUnwrap(workspace.paneId(forPanelId: firstPanelId)) + let second = try XCTUnwrap(workspace.newTerminalSurface( + inPane: paneId, + focus: false, + startupEnvironment: ["ONLY": "surface"] + )) + XCTAssertEqual(second.surface.respawnAdditionalEnvironment, ["ONLY": "surface"]) + } + + // MARK: - Acceptance: persistence across session restore + + func testWorkspaceEnvironmentSurvivesSessionRestore() throws { + let source = Workspace(workspaceEnvironment: ["AWS_PROFILE": "prod", "API_BASE": "https://api.example.com"]) + let snapshot = source.sessionSnapshot(includeScrollback: false) + XCTAssertEqual(snapshot.environment, ["AWS_PROFILE": "prod", "API_BASE": "https://api.example.com"]) + + let restored = Workspace() + restored.restoreSessionSnapshot(snapshot) + XCTAssertEqual(restored.workspaceEnvironment, ["AWS_PROFILE": "prod", "API_BASE": "https://api.example.com"]) + + let restoredPanelId = try XCTUnwrap(restored.focusedPanelId) + let restoredPanel = try XCTUnwrap(restored.terminalPanel(for: restoredPanelId)) + // Restored terminals spawn fresh shells through newTerminalSurface, which + // threads the workspace environment via additionalEnvironment. + XCTAssertEqual(restoredPanel.surface.respawnAdditionalEnvironment["AWS_PROFILE"], "prod") + } + + func testEmptyWorkspaceEnvironmentIsNotPersisted() { + let workspace = Workspace() + XCTAssertNil(workspace.sessionSnapshot(includeScrollback: false).environment) + } + + // MARK: - Acceptance: managed CMUX_* variables cannot be clobbered + + /// Workspace env reaches a spawned shell through `additionalEnvironment` / + /// `initialEnvironmentOverrides`, both of which `mergedStartupEnvironment` + /// applies only for keys absent from `protectedKeys`. This proves a workspace + /// env entry can never overwrite the variables the daemon relies on. + func testWorkspaceEnvironmentCannotClobberProtectedCmuxVariables() { + let merged = TerminalSurface.mergedStartupEnvironment( + base: ["CMUX_WORKSPACE_ID": "real-id", "TERM": "xterm-ghostty"], + protectedKeys: ["CMUX_WORKSPACE_ID", "TERM"], + additionalEnvironment: [ + "CMUX_WORKSPACE_ID": "spoofed", // must be ignored + "TERM": "dumb", // must be ignored + "AWS_PROFILE": "prod", // must be applied + ], + initialEnvironmentOverrides: ["CMUX_WORKSPACE_ID": "also-spoofed"], + ambientEnvironment: [:] + ) + XCTAssertEqual(merged["CMUX_WORKSPACE_ID"], "real-id") + XCTAssertEqual(merged["TERM"], "xterm-ghostty") + XCTAssertEqual(merged["AWS_PROFILE"], "prod") + } + + // MARK: - Persistence schema (Codable) + + func testSessionWorkspaceSnapshotEnvironmentRoundTrips() throws { + let snapshot = SessionWorkspaceSnapshot( + processTitle: "Terminal", + isPinned: false, + currentDirectory: "/tmp", + layout: .pane(SessionPaneLayoutSnapshot(panelIds: [], selectedPanelId: nil)), + panels: [], + statusEntries: [], + logEntries: [], + environment: ["AWS_PROFILE": "prod"] + ) + let data = try JSONEncoder().encode(snapshot) + let decoded = try JSONDecoder().decode(SessionWorkspaceSnapshot.self, from: data) + XCTAssertEqual(decoded.environment, ["AWS_PROFILE": "prod"]) + } + + /// A manifest written before this feature has no `environment` key; it must + /// decode cleanly with a nil environment (and a nil environment must not bloat + /// new manifests). + func testSessionWorkspaceSnapshotOmitsAndToleratesAbsentEnvironment() throws { + let snapshot = SessionWorkspaceSnapshot( + processTitle: "Terminal", + isPinned: false, + currentDirectory: "/tmp", + layout: .pane(SessionPaneLayoutSnapshot(panelIds: [], selectedPanelId: nil)), + panels: [], + statusEntries: [], + logEntries: [] + ) + let data = try JSONEncoder().encode(snapshot) + let object = try XCTUnwrap(try JSONSerialization.jsonObject(with: data) as? [String: Any]) + XCTAssertNil(object["environment"], "nil environment should be omitted from the manifest") + let decoded = try JSONDecoder().decode(SessionWorkspaceSnapshot.self, from: data) + XCTAssertNil(decoded.environment) + } + + // MARK: - Config entry point (cmux.json) + + func testCmuxWorkspaceDefinitionDecodesEnv() throws { + let json = #"{"name":"Build","env":{"AWS_PROFILE":"prod","API_BASE":"https://api.example.com"}}"# + let definition = try JSONDecoder().decode(CmuxWorkspaceDefinition.self, from: Data(json.utf8)) + XCTAssertEqual(definition.env, ["AWS_PROFILE": "prod", "API_BASE": "https://api.example.com"]) + } + + func testCmuxWorkspaceDefinitionEnvIsOptional() throws { + let definition = try JSONDecoder().decode(CmuxWorkspaceDefinition.self, from: Data(#"{"name":"Build"}"#.utf8)) + XCTAssertNil(definition.env) + } +} diff --git a/docs/cli-contract.md b/docs/cli-contract.md index 3c0ab018a332..0525c861ca41 100644 --- a/docs/cli-contract.md +++ b/docs/cli-contract.md @@ -98,10 +98,10 @@ Environment: | `reorder-workspace` | Reorder a workspace inside a window. | | `reorder-workspaces` | Atomically reorder workspaces inside pinned and unpinned groups. | | `workspace-action` | Run workspace context-menu actions from the CLI. | -| `workspace` | Namespace for workspace verbs: `list`, `create`, `close`, `rename`, `select`, `reconnect`, `disconnect`, `group`. `workspace reconnect` manually reconnects a remote (SSH) workspace — including one whose automatic reconnect suspended because the host was unreachable — and `workspace disconnect` stops its remote connection. Both accept a positional workspace handle or `--workspace <id\|ref\|index>`, defaulting to the caller's workspace, then the selected one. | +| `workspace` | Namespace for workspace verbs: `list`, `create`, `env`, `close`, `rename`, `select`, `reconnect`, `disconnect`, `group`. `workspace env` prints a workspace's configured environment variables (see [Workspace environment variables](#workspace-environment-variables)); pass `--mask` to redact the values. `workspace reconnect` manually reconnects a remote (SSH) workspace — including one whose automatic reconnect suspended because the host was unreachable — and `workspace disconnect` stops its remote connection. `env`, `reconnect`, and `disconnect` accept a positional workspace handle or `--workspace <id\|ref\|index>`, defaulting to the caller's workspace, then the selected one. | | `move-tab-to-new-workspace` | Move a tab or surface into a newly created workspace. | | `list-workspaces` | List workspaces. | -| `new-workspace` | Create a workspace, optionally with cwd, command, description, and layout. | +| `new-workspace` | Create a workspace, optionally with cwd, command, description, layout, and per-workspace environment variables (`--env KEY=VALUE` repeatable, `--env-file <path>`). See [Workspace environment variables](#workspace-environment-variables). | | `ssh` | Open an SSH-backed workspace. Preserves the caller's live `SSH_AUTH_SOCK` for app-launched OpenSSH processes so `ForwardAgent yes` from ssh_config works normally. Supports `-A` / `--forward-agent` to request forwarding and `-a` / `--no-forward-agent` to disable forwarding for a workspace. Agent forwarding remains opt-in because forwarded agents can be used by processes on the remote host while the SSH session is active. | | `remote-daemon-status` | Print bundled remote daemon version, asset, checksum, and cache status. | | `ssh-session-list` | List persisted SSH PTY sessions for one remote workspace or all remote workspaces. Supports `--json`. | @@ -216,6 +216,52 @@ Workspace and tab action names: | `workspace-action` | `pin`, `unpin`, `rename`, `clear-name`, `set-description`, `clear-description`, `move-up`, `move-down`, `move-top`, `close-others`, `close-above`, `close-below`, `mark-read`, `mark-unread`, `set-color`, `clear-color` | | `tab-action` | `rename`, `clear-name`, `close-left`, `close-right`, `close-others`, `new-terminal-right`, `new-browser-right`, `reload`, `duplicate`, `pin`, `unpin`, `mark-unread` | +### Workspace environment variables + +A workspace can carry a set of user-defined environment variables that every +shell spawned in it inherits. + +Setting them: + +- CLI: `cmux new-workspace --env KEY=VALUE [--env ...] [--env-file <path>]` + (and the same flags on `cmux workspace create`). `--env` is repeatable; + `--env-file` reads `KEY=VALUE` lines (blank lines and `#` comments ignored, an + optional leading `export ` stripped). When both are given, `--env` overrides a + value from a file. +- Project config (`cmux.json`): an `env` object on a workspace definition, e.g. + `{ "name": "Build", "cwd": ".", "env": { "AWS_PROFILE": "prod" } }`. +- Socket: the `workspace_env` (alias `env`) param on `workspace.create`. + +Inspecting them: `cmux workspace env [<handle>] [--mask] [--json]` prints the +configured set. `--mask` redacts the values so secrets are not echoed in full. +The env set is intentionally omitted from `workspace list` output so a plain +listing never leaks secrets. + +Semantics: + +- **Inheritance.** The variables apply to the workspace's initial shell and to + every pane, surface, and split created later in that workspace — no per-pane + re-export. They are also re-applied to every shell recreated on session + restore. +- **Persistence.** They are stored on the workspace in the session manifest, so + they survive app restart, daemon restart, and session restore. +- **Precedence.** Workspace env overlays the inherited process environment. It is + applied as the shell's startup environment, so it is visible to login-shell + init files (`~/.zprofile`, `~/.zshrc`) as they run, but any `export` those + files perform for the same key wins for the interactive session (they run after + the variable is seeded). An explicit per-surface environment (a layout + `surfaces[].env`, SSH startup env) overrides the workspace value for that + surface. +- **Protected `CMUX_*` variables.** Workspace env can never override the managed + variables cmux injects (e.g. `CMUX_WORKSPACE_ID`, `CMUX_SURFACE_ID`, + `CMUX_SOCKET_PATH`, `CMUX_SOCKET_PASSWORD`) or the terminal identity variables + (`TERM`, `COLORTERM`, `TERM_PROGRAM`); those keys are protected at spawn time + and silently win. +- **Secrets.** Values may be secrets. They are never logged, are masked by + `--mask`, and are kept out of `workspace list`. Prefer `--env-file` so secrets + do not land in shell history. Note that values stored in the session manifest + live on disk in plaintext. + tmux compatibility commands: | Command | Contract | From ac9f218ab44f65d527d39fbeb4c2198c62e085e2 Mon Sep 17 00:00:00 2001 From: austinpower1258 <austinwang115@gmail.com> Date: Sun, 14 Jun 2026 01:40:33 -0700 Subject: [PATCH 02/13] ci: refresh Swift file-length budget for workspace env changes CLI/cmux.swift, TerminalController.swift, Workspace.swift, TabManager.swift, SessionPersistence.swift, and CmuxConfigExecutor.swift grew with the per-workspace environment feature (#5995). Accept the new line counts. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- .github/swift-file-length-budget.tsv | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 726ee888cde6..b014dec2740c 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -1,14 +1,14 @@ # 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 @@ -16,7 +16,7 @@ 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 @@ -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 @@ -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 From 03775dc6afa76ddc2bf260de1adb3b5d03800a91 Mon Sep 17 00:00:00 2001 From: austinpower1258 <austinwang115@gmail.com> Date: Sun, 14 Jun 2026 01:54:30 -0700 Subject: [PATCH 03/13] Fix workspace-env test override signatures, NUL key bypass, and test framework MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - WorkspaceUnitTests: update the two `makeWorkspaceForCreation` test overrides to include the new `workspaceEnvironment` parameter (CI `tests` compile fix). - Workspace.sanitizedWorkspaceEnvironment: reject keys containing NUL or `=` and values containing NUL. A key like `CMUX_SOCKET_PATH\0x` would pass the exact-match protected-key check but truncate to `CMUX_SOCKET_PATH` at the Swift→C boundary (strdup/Ghostty) and clobber the managed variable. The sanitizer is the single choke point for every entry point, so the guard cannot be bypassed (autoreview P1). - WorkspaceEnvironmentTests: convert to Swift Testing per repo policy for new non-UI tests, and add regression coverage for the NUL/`=` rejection. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- Sources/Workspace.swift | 22 +++- cmuxTests/WorkspaceEnvironmentTests.swift | 130 +++++++++++++--------- cmuxTests/WorkspaceUnitTests.swift | 12 +- 3 files changed, 105 insertions(+), 59 deletions(-) diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 42bcdc6aa627..738b78473b3a 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -5685,14 +5685,26 @@ final class Workspace: Identifiable, ObservableObject { /// entry with a blank key or blank value. Dropping blank values keeps behavior /// identical across the `additionalEnvironment` channel (which already skips /// empty values) and the `initialEnvironmentOverrides` channel (which would - /// otherwise export a blank value on the initial shell only). Reserved `CMUX_*` - /// variables are intentionally *not* stripped here — they are protected at - /// spawn time by `mergedStartupEnvironment(protectedKeys:)`, which is the single - /// authority on which keys are managed. + /// otherwise export a blank value on the initial shell only). + /// + /// Reserved `CMUX_*` variables are intentionally *not* stripped by name — they + /// are protected at spawn time by `mergedStartupEnvironment(protectedKeys:)`, + /// the single authority on which keys are managed. That protection is an exact + /// Swift-string match, but the env eventually crosses the Swift→C boundary + /// (`strdup` / Ghostty), where a key is truncated at its first NUL. A key like + /// `"CMUX_SOCKET_PATH\0x"` would dodge the exact-match check yet collapse to + /// `CMUX_SOCKET_PATH` in the spawned shell, so reject any key containing a NUL + /// (and `=`, which is never a valid env var name) and any value containing a + /// NUL. This is the single choke point for every entry point (CLI, cmux.json, + /// session restore), so the guard cannot be bypassed. static func sanitizedWorkspaceEnvironment(_ environment: [String: String]) -> [String: String] { environment.reduce(into: [String: String]()) { result, pair in let key = pair.key.trimmingCharacters(in: .whitespacesAndNewlines) - guard !key.isEmpty, !pair.value.isEmpty else { return } + guard !key.isEmpty, + !pair.value.isEmpty, + !key.contains("\0"), + !key.contains("="), + !pair.value.contains("\0") else { return } result[key] = pair.value } } diff --git a/cmuxTests/WorkspaceEnvironmentTests.swift b/cmuxTests/WorkspaceEnvironmentTests.swift index 4b3d295af8f7..93afdb2815c5 100644 --- a/cmuxTests/WorkspaceEnvironmentTests.swift +++ b/cmuxTests/WorkspaceEnvironmentTests.swift @@ -1,101 +1,125 @@ -import XCTest +import Foundation +import Testing #if canImport(cmux_DEV) -@testable import cmux_DEV + @testable import cmux_DEV #elseif canImport(cmux) -@testable import cmux + @testable import cmux #endif /// Behavior coverage for per-workspace user-defined environment variables /// (issue #5995): the initial shell inherits them, every later pane/split /// inherits them, they survive session restore, explicit per-surface env wins, /// and the managed `CMUX_*` variables can never be clobbered. +@Suite(.serialized) @MainActor -final class WorkspaceEnvironmentTests: XCTestCase { +struct WorkspaceEnvironmentTests { // MARK: - Sanitization - func testSanitizedWorkspaceEnvironmentTrimsKeysAndDropsBlanks() { + @Test + func sanitizedWorkspaceEnvironmentTrimsKeysAndDropsBlanks() { let result = Workspace.sanitizedWorkspaceEnvironment([ " FOO ": "bar", // key is trimmed "": "ignored", // blank key is dropped "EMPTY": "", // blank value is dropped (matches additionalEnvironment) "OK": "value", ]) - XCTAssertEqual(result, ["FOO": "bar", "OK": "value"]) + #expect(result == ["FOO": "bar", "OK": "value"]) + } + + /// Regression for the Swift→C truncation bypass: a NUL in a key collapses it + /// at `strdup`/Ghostty, so `"CMUX_SOCKET_PATH\0x"` would dodge the exact-match + /// protection and overwrite the managed variable. NUL/`=` keys and NUL values + /// must be rejected at the sanitizer (the single choke point). + @Test + func sanitizedWorkspaceEnvironmentRejectsKeysThatTruncateAtTheCBoundary() { + let result = Workspace.sanitizedWorkspaceEnvironment([ + "CMUX_SOCKET_PATH\u{0}x": "spoofed", // NUL would truncate to CMUX_SOCKET_PATH + "BAD=KEY": "v", // '=' is never a valid env var name + "NUL_VALUE": "a\u{0}b", // NUL in the value + "GOOD": "value", + ]) + #expect(result == ["GOOD": "value"]) } // MARK: - Acceptance: initial shell inherits the workspace environment - func testInitialShellInheritsWorkspaceEnvironment() throws { + @Test + func initialShellInheritsWorkspaceEnvironment() throws { let workspace = Workspace(workspaceEnvironment: ["AWS_PROFILE": "prod", "API_BASE": "https://api.example.com"]) - let panelId = try XCTUnwrap(workspace.focusedPanelId) - let panel = try XCTUnwrap(workspace.terminalPanel(for: panelId)) + let panelId = try #require(workspace.focusedPanelId) + let panel = try #require(workspace.terminalPanel(for: panelId)) let env = panel.surface.respawnInitialEnvironmentOverrides - XCTAssertEqual(env["AWS_PROFILE"], "prod") - XCTAssertEqual(env["API_BASE"], "https://api.example.com") + #expect(env["AWS_PROFILE"] == "prod") + #expect(env["API_BASE"] == "https://api.example.com") } // MARK: - Acceptance: later panes/splits inherit it, with no per-pane re-export - func testLaterSurfaceInheritsWorkspaceEnvironment() throws { + @Test + func laterSurfaceInheritsWorkspaceEnvironment() throws { let workspace = Workspace(workspaceEnvironment: ["AWS_PROFILE": "prod"]) - let firstPanelId = try XCTUnwrap(workspace.focusedPanelId) - let paneId = try XCTUnwrap(workspace.paneId(forPanelId: firstPanelId)) - let second = try XCTUnwrap(workspace.newTerminalSurface(inPane: paneId, focus: false)) - XCTAssertEqual(second.surface.respawnAdditionalEnvironment["AWS_PROFILE"], "prod") + let firstPanelId = try #require(workspace.focusedPanelId) + let paneId = try #require(workspace.paneId(forPanelId: firstPanelId)) + let second = try #require(workspace.newTerminalSurface(inPane: paneId, focus: false)) + #expect(second.surface.respawnAdditionalEnvironment["AWS_PROFILE"] == "prod") } /// An explicit per-surface environment (layout `env`, scrollback replay, SSH /// startup) overlays the workspace set rather than being discarded. - func testExplicitSurfaceEnvironmentOverridesWorkspaceEnvironment() throws { + @Test + func explicitSurfaceEnvironmentOverridesWorkspaceEnvironment() throws { let workspace = Workspace(workspaceEnvironment: ["AWS_PROFILE": "prod", "SHARED": "workspace"]) - let firstPanelId = try XCTUnwrap(workspace.focusedPanelId) - let paneId = try XCTUnwrap(workspace.paneId(forPanelId: firstPanelId)) - let second = try XCTUnwrap(workspace.newTerminalSurface( + let firstPanelId = try #require(workspace.focusedPanelId) + let paneId = try #require(workspace.paneId(forPanelId: firstPanelId)) + let second = try #require(workspace.newTerminalSurface( inPane: paneId, focus: false, startupEnvironment: ["SHARED": "surface", "EXTRA": "x"] )) let env = second.surface.respawnAdditionalEnvironment - XCTAssertEqual(env["SHARED"], "surface") // explicit wins - XCTAssertEqual(env["AWS_PROFILE"], "prod") // workspace value preserved - XCTAssertEqual(env["EXTRA"], "x") + #expect(env["SHARED"] == "surface") // explicit wins + #expect(env["AWS_PROFILE"] == "prod") // workspace value preserved + #expect(env["EXTRA"] == "x") } - func testEmptyWorkspaceEnvironmentLeavesSurfaceEnvironmentUntouched() throws { + @Test + func emptyWorkspaceEnvironmentLeavesSurfaceEnvironmentUntouched() throws { let workspace = Workspace() - let firstPanelId = try XCTUnwrap(workspace.focusedPanelId) - let paneId = try XCTUnwrap(workspace.paneId(forPanelId: firstPanelId)) - let second = try XCTUnwrap(workspace.newTerminalSurface( + let firstPanelId = try #require(workspace.focusedPanelId) + let paneId = try #require(workspace.paneId(forPanelId: firstPanelId)) + let second = try #require(workspace.newTerminalSurface( inPane: paneId, focus: false, startupEnvironment: ["ONLY": "surface"] )) - XCTAssertEqual(second.surface.respawnAdditionalEnvironment, ["ONLY": "surface"]) + #expect(second.surface.respawnAdditionalEnvironment == ["ONLY": "surface"]) } // MARK: - Acceptance: persistence across session restore - func testWorkspaceEnvironmentSurvivesSessionRestore() throws { + @Test + func workspaceEnvironmentSurvivesSessionRestore() throws { let source = Workspace(workspaceEnvironment: ["AWS_PROFILE": "prod", "API_BASE": "https://api.example.com"]) let snapshot = source.sessionSnapshot(includeScrollback: false) - XCTAssertEqual(snapshot.environment, ["AWS_PROFILE": "prod", "API_BASE": "https://api.example.com"]) + #expect(snapshot.environment == ["AWS_PROFILE": "prod", "API_BASE": "https://api.example.com"]) let restored = Workspace() restored.restoreSessionSnapshot(snapshot) - XCTAssertEqual(restored.workspaceEnvironment, ["AWS_PROFILE": "prod", "API_BASE": "https://api.example.com"]) + #expect(restored.workspaceEnvironment == ["AWS_PROFILE": "prod", "API_BASE": "https://api.example.com"]) - let restoredPanelId = try XCTUnwrap(restored.focusedPanelId) - let restoredPanel = try XCTUnwrap(restored.terminalPanel(for: restoredPanelId)) + let restoredPanelId = try #require(restored.focusedPanelId) + let restoredPanel = try #require(restored.terminalPanel(for: restoredPanelId)) // Restored terminals spawn fresh shells through newTerminalSurface, which // threads the workspace environment via additionalEnvironment. - XCTAssertEqual(restoredPanel.surface.respawnAdditionalEnvironment["AWS_PROFILE"], "prod") + #expect(restoredPanel.surface.respawnAdditionalEnvironment["AWS_PROFILE"] == "prod") } - func testEmptyWorkspaceEnvironmentIsNotPersisted() { + @Test + func emptyWorkspaceEnvironmentIsNotPersisted() { let workspace = Workspace() - XCTAssertNil(workspace.sessionSnapshot(includeScrollback: false).environment) + #expect(workspace.sessionSnapshot(includeScrollback: false).environment == nil) } // MARK: - Acceptance: managed CMUX_* variables cannot be clobbered @@ -104,7 +128,8 @@ final class WorkspaceEnvironmentTests: XCTestCase { /// `initialEnvironmentOverrides`, both of which `mergedStartupEnvironment` /// applies only for keys absent from `protectedKeys`. This proves a workspace /// env entry can never overwrite the variables the daemon relies on. - func testWorkspaceEnvironmentCannotClobberProtectedCmuxVariables() { + @Test + func workspaceEnvironmentCannotClobberProtectedCmuxVariables() { let merged = TerminalSurface.mergedStartupEnvironment( base: ["CMUX_WORKSPACE_ID": "real-id", "TERM": "xterm-ghostty"], protectedKeys: ["CMUX_WORKSPACE_ID", "TERM"], @@ -116,14 +141,15 @@ final class WorkspaceEnvironmentTests: XCTestCase { initialEnvironmentOverrides: ["CMUX_WORKSPACE_ID": "also-spoofed"], ambientEnvironment: [:] ) - XCTAssertEqual(merged["CMUX_WORKSPACE_ID"], "real-id") - XCTAssertEqual(merged["TERM"], "xterm-ghostty") - XCTAssertEqual(merged["AWS_PROFILE"], "prod") + #expect(merged["CMUX_WORKSPACE_ID"] == "real-id") + #expect(merged["TERM"] == "xterm-ghostty") + #expect(merged["AWS_PROFILE"] == "prod") } // MARK: - Persistence schema (Codable) - func testSessionWorkspaceSnapshotEnvironmentRoundTrips() throws { + @Test + func sessionWorkspaceSnapshotEnvironmentRoundTrips() throws { let snapshot = SessionWorkspaceSnapshot( processTitle: "Terminal", isPinned: false, @@ -136,13 +162,14 @@ final class WorkspaceEnvironmentTests: XCTestCase { ) let data = try JSONEncoder().encode(snapshot) let decoded = try JSONDecoder().decode(SessionWorkspaceSnapshot.self, from: data) - XCTAssertEqual(decoded.environment, ["AWS_PROFILE": "prod"]) + #expect(decoded.environment == ["AWS_PROFILE": "prod"]) } /// A manifest written before this feature has no `environment` key; it must /// decode cleanly with a nil environment (and a nil environment must not bloat /// new manifests). - func testSessionWorkspaceSnapshotOmitsAndToleratesAbsentEnvironment() throws { + @Test + func sessionWorkspaceSnapshotOmitsAndToleratesAbsentEnvironment() throws { let snapshot = SessionWorkspaceSnapshot( processTitle: "Terminal", isPinned: false, @@ -153,22 +180,25 @@ final class WorkspaceEnvironmentTests: XCTestCase { logEntries: [] ) let data = try JSONEncoder().encode(snapshot) - let object = try XCTUnwrap(try JSONSerialization.jsonObject(with: data) as? [String: Any]) - XCTAssertNil(object["environment"], "nil environment should be omitted from the manifest") + let raw = try JSONSerialization.jsonObject(with: data) + let object = try #require(raw as? [String: Any]) + #expect(object["environment"] == nil, "nil environment should be omitted from the manifest") let decoded = try JSONDecoder().decode(SessionWorkspaceSnapshot.self, from: data) - XCTAssertNil(decoded.environment) + #expect(decoded.environment == nil) } // MARK: - Config entry point (cmux.json) - func testCmuxWorkspaceDefinitionDecodesEnv() throws { + @Test + func cmuxWorkspaceDefinitionDecodesEnv() throws { let json = #"{"name":"Build","env":{"AWS_PROFILE":"prod","API_BASE":"https://api.example.com"}}"# let definition = try JSONDecoder().decode(CmuxWorkspaceDefinition.self, from: Data(json.utf8)) - XCTAssertEqual(definition.env, ["AWS_PROFILE": "prod", "API_BASE": "https://api.example.com"]) + #expect(definition.env == ["AWS_PROFILE": "prod", "API_BASE": "https://api.example.com"]) } - func testCmuxWorkspaceDefinitionEnvIsOptional() throws { + @Test + func cmuxWorkspaceDefinitionEnvIsOptional() throws { let definition = try JSONDecoder().decode(CmuxWorkspaceDefinition.self, from: Data(#"{"name":"Build"}"#.utf8)) - XCTAssertNil(definition.env) + #expect(definition.env == nil) } } diff --git a/cmuxTests/WorkspaceUnitTests.swift b/cmuxTests/WorkspaceUnitTests.swift index ae68a047664e..b078892aee29 100644 --- a/cmuxTests/WorkspaceUnitTests.swift +++ b/cmuxTests/WorkspaceUnitTests.swift @@ -3222,7 +3222,8 @@ final class WorkspaceCreationPlacementTests: XCTestCase { initialSurface: NewWorkspaceInitialSurface, initialTerminalCommand: String?, initialTerminalInput: String?, - initialTerminalEnvironment: [String: String] + initialTerminalEnvironment: [String: String], + workspaceEnvironment: [String: String] ) -> Workspace { beforeCreateWorkspace?() return super.makeWorkspaceForCreation( @@ -3233,7 +3234,8 @@ final class WorkspaceCreationPlacementTests: XCTestCase { initialSurface: initialSurface, initialTerminalCommand: initialTerminalCommand, initialTerminalInput: initialTerminalInput, - initialTerminalEnvironment: initialTerminalEnvironment + initialTerminalEnvironment: initialTerminalEnvironment, + workspaceEnvironment: workspaceEnvironment ) } } @@ -3523,7 +3525,8 @@ final class WorkspaceCreationConfigSanitizationTests: XCTestCase { initialSurface: NewWorkspaceInitialSurface, initialTerminalCommand: String?, initialTerminalInput: String?, - initialTerminalEnvironment: [String: String] + initialTerminalEnvironment: [String: String], + workspaceEnvironment: [String: String] ) -> Workspace { capturedConfigTemplate = configTemplate return super.makeWorkspaceForCreation( @@ -3534,7 +3537,8 @@ final class WorkspaceCreationConfigSanitizationTests: XCTestCase { initialSurface: initialSurface, initialTerminalCommand: initialTerminalCommand, initialTerminalInput: initialTerminalInput, - initialTerminalEnvironment: initialTerminalEnvironment + initialTerminalEnvironment: initialTerminalEnvironment, + workspaceEnvironment: workspaceEnvironment ) } } From 525074d878e0dff67fa9278d4fcd4a7df0b9b65e Mon Sep 17 00:00:00 2001 From: austinpower1258 <austinwang115@gmail.com> Date: Sun, 14 Jun 2026 01:57:04 -0700 Subject: [PATCH 04/13] Address review: newTerminalSplit env test + CLI error formatting - Add a Swift Testing case covering workspace-env inheritance through newTerminalSplit (the second later-surface choke point), matching the newTerminalSurface coverage (CodeRabbit). - Use String(describing:) instead of error.localizedDescription for the --env-file read failure, per CLI error-formatting convention (CodeRabbit). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- CLI/cmux.swift | 2 +- cmuxTests/WorkspaceEnvironmentTests.swift | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 1073ec2de261..8b98ddfedeba 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -7198,7 +7198,7 @@ struct CMUXCLI { do { contents = try String(contentsOfFile: resolved, encoding: .utf8) } catch { - throw CLIError(message: "\(commandName): could not read --env-file '\(path)': \(error.localizedDescription)") + throw CLIError(message: "\(commandName): could not read --env-file '\(path)': \(String(describing: error))") } for rawLine in contents.split(omittingEmptySubsequences: false, whereSeparator: { $0.isNewline }) { var line = String(rawLine).trimmingCharacters(in: .whitespaces) diff --git a/cmuxTests/WorkspaceEnvironmentTests.swift b/cmuxTests/WorkspaceEnvironmentTests.swift index 93afdb2815c5..7c5ca1ae6954 100644 --- a/cmuxTests/WorkspaceEnvironmentTests.swift +++ b/cmuxTests/WorkspaceEnvironmentTests.swift @@ -66,6 +66,20 @@ struct WorkspaceEnvironmentTests { #expect(second.surface.respawnAdditionalEnvironment["AWS_PROFILE"] == "prod") } + /// `newTerminalSplit` is the other later-surface choke point (splitting a + /// surface into a new pane); it must fold in the workspace environment too. + @Test + func splitSurfaceInheritsWorkspaceEnvironment() throws { + let workspace = Workspace(workspaceEnvironment: ["AWS_PROFILE": "prod"]) + let firstPanelId = try #require(workspace.focusedPanelId) + let split = try #require(workspace.newTerminalSplit( + from: firstPanelId, + orientation: .horizontal, + focus: false + )) + #expect(split.surface.respawnAdditionalEnvironment["AWS_PROFILE"] == "prod") + } + /// An explicit per-surface environment (layout `env`, scrollback replay, SSH /// startup) overlays the workspace set rather than being discarded. @Test From f3cd42b21b3bddad664a780558a12f55c5dbae58 Mon Sep 17 00:00:00 2001 From: austinpower1258 <austinwang115@gmail.com> Date: Sun, 14 Jun 2026 01:59:14 -0700 Subject: [PATCH 05/13] ci: bump file-length budget for sanitizer guard and test overrides Workspace.swift (NUL/= key guard) and WorkspaceUnitTests.swift (the two makeWorkspaceForCreation override updates) grew past the budget. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- .github/swift-file-length-budget.tsv | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index b014dec2740c..63647d55ab09 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -8,10 +8,10 @@ 13595 Sources/Panels/BrowserPanel.swift 12088 Sources/GhosttyTerminalView.swift 12046 cmuxTests/AppDelegateShortcutRoutingTests.swift -12055 Sources/Workspace.swift +12067 Sources/Workspace.swift 9345 cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift 7911 Sources/Panels/BrowserPanelView.swift -7350 cmuxTests/WorkspaceUnitTests.swift +7354 cmuxTests/WorkspaceUnitTests.swift 6944 cmuxTests/WorkspaceRemoteConnectionTests.swift 6317 cmuxTests/SessionPersistenceTests.swift 6299 cmuxTests/GhosttyConfigTests.swift From a849109a0d6e727a9177a84e4feef9dee58d97af Mon Sep 17 00:00:00 2001 From: austinpower1258 <austinwang115@gmail.com> Date: Sun, 14 Jun 2026 02:10:56 -0700 Subject: [PATCH 06/13] Address autoreview: cover all shell-spawn paths, fix env CLI, drop ghostty bump - Workspace env now folds into every terminal-creation path, not just init/newTerminalSurface/newTerminalSplit: splitPaneWithNewTerminal (session- index drop), createReplacementTerminalPanel (last-panel replacement), and the drag-to-split placeholder/auto-create panels all merge startupEnvironmentMergingWorkspaceEnvironment([:]). Adds tests for the two publicly-callable paths. - `cmux workspace env` now defaults to the caller's workspace ($CMUX_WORKSPACE_ID) before the selected one, matching its help text and the reconnect/disconnect commands. - `cmux workspace env --json` no longer runs the user env map through formatIDs (which strips id/ref and *_id/*_ref keys); the envelope is formatted and the env map reinserted verbatim, so user variables named id/project_id survive. - Drop the unintended ghostty submodule bump: reset the pointer to origin/main (05c3e29). The bump (5697db8) was pulled in by an earlier main merge and main has since reverted it; the feature does not depend on ghostty. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- .github/swift-file-length-budget.tsv | 4 +-- CLI/cmux.swift | 36 +++++++++++++++-------- Sources/Workspace.swift | 11 ++++--- cmuxTests/WorkspaceEnvironmentTests.swift | 26 ++++++++++++++++ ghostty | 2 +- 5 files changed, 60 insertions(+), 19 deletions(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 63647d55ab09..ecbc3cb5b789 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -1,14 +1,14 @@ # 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. -33659 CLI/cmux.swift +33671 CLI/cmux.swift 17914 Sources/AppDelegate.swift 16827 Sources/ContentView.swift 14656 Sources/TerminalController.swift 13595 Sources/Panels/BrowserPanel.swift 12088 Sources/GhosttyTerminalView.swift 12046 cmuxTests/AppDelegateShortcutRoutingTests.swift -12067 Sources/Workspace.swift +12070 Sources/Workspace.swift 9345 cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift 7911 Sources/Panels/BrowserPanelView.swift 7354 cmuxTests/WorkspaceUnitTests.swift diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 8b98ddfedeba..98ef78990990 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -7277,10 +7277,17 @@ struct CMUXCLI { 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("--") }) + let positional = rem1.first(where: { !$0.hasPrefix("--") }) + let windowRaw = windowFromArgsOrOverride(commandArgs, windowOverride: windowOverride) + // Match reconnect/disconnect: default to the caller's workspace + // ($CMUX_WORKSPACE_ID) before the selected one, but only when no explicit + // --window is given (the caller's workspace may live in another window). + let target = workspaceArg + ?? positional + ?? (windowRaw == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil) var params: [String: Any] = [:] - let winId = try normalizeWindowHandle(windowFromArgsOrOverride(commandArgs, windowOverride: windowOverride), client: client) + let winId = try normalizeWindowHandle(windowRaw, 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 } @@ -7290,22 +7297,27 @@ struct CMUXCLI { let envStrings: [String: String] = rawEnv.reduce(into: [:]) { result, pair in if let value = pair.value as? String { result[pair.key] = value } } + let displayedEnv: [String: String] = mask + ? envStrings.reduce(into: [String: String]()) { result, pair in + result[pair.key] = Self.maskedEnvValue(pair.value) + } + : envStrings 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))) + // Format only the envelope's id/ref metadata. The env map is arbitrary + // user data, so running it through formatIDs (which strips `id` when + // `ref` is present and `*_id` when a matching `*_ref` exists) could + // silently drop a user variable; reinsert it verbatim after formatting. + var envelope = payload + envelope.removeValue(forKey: "env") + var formatted = (formatIDs(envelope, mode: idFormat) as? [String: Any]) ?? envelope + formatted["env"] = displayedEnv + print(jsonString(formatted)) } 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)") + print("\(key)=\(displayedEnv[key] ?? "")") } } } diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 738b78473b3a..31707f66a14a 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -9242,7 +9242,8 @@ final class Workspace: Identifiable, ObservableObject { context: GHOSTTY_SURFACE_CONTEXT_TAB, configTemplate: inheritedConfig, portOrdinal: portOrdinal, - initialCommand: replacementInitialCommand + initialCommand: replacementInitialCommand, + additionalEnvironment: startupEnvironmentMergingWorkspaceEnvironment([:]) ) configureNewTerminalPanel(newPanel) panels[newPanel.id] = newPanel @@ -10294,7 +10295,7 @@ final class Workspace: Identifiable, ObservableObject { let requestedRemoteStartupCommand = remoteStartupCommand?.trimmingCharacters(in: .whitespacesAndNewlines) let startupCommand = requestedRemoteStartupCommand?.isEmpty == false ? requestedRemoteStartupCommand : nil let effectiveStartupEnvironment = terminalStartupEnvironment( - base: [:], + base: startupEnvironmentMergingWorkspaceEnvironment([:]), remoteStartupCommand: startupCommand ) if startupCommand != nil { @@ -11636,7 +11637,8 @@ extension Workspace: BonsplitDelegate { workspaceId: id, context: GHOSTTY_SURFACE_CONTEXT_SPLIT, configTemplate: inheritedConfig, - portOrdinal: portOrdinal + portOrdinal: portOrdinal, + additionalEnvironment: startupEnvironmentMergingWorkspaceEnvironment([:]) ) configureNewTerminalPanel(replacementPanel) panels[replacementPanel.id] = replacementPanel @@ -11704,7 +11706,8 @@ extension Workspace: BonsplitDelegate { workspaceId: id, context: GHOSTTY_SURFACE_CONTEXT_SPLIT, configTemplate: inheritedConfig, - portOrdinal: portOrdinal + portOrdinal: portOrdinal, + additionalEnvironment: startupEnvironmentMergingWorkspaceEnvironment([:]) ) configureNewTerminalPanel(newPanel) panels[newPanel.id] = newPanel diff --git a/cmuxTests/WorkspaceEnvironmentTests.swift b/cmuxTests/WorkspaceEnvironmentTests.swift index 7c5ca1ae6954..bb469c4555b4 100644 --- a/cmuxTests/WorkspaceEnvironmentTests.swift +++ b/cmuxTests/WorkspaceEnvironmentTests.swift @@ -80,6 +80,32 @@ struct WorkspaceEnvironmentTests { #expect(split.surface.respawnAdditionalEnvironment["AWS_PROFILE"] == "prod") } + /// The session-index drop path creates a terminal in a freshly split pane via + /// `splitPaneWithNewTerminal`; it must inherit the workspace environment too. + @Test + func splitPaneWithNewTerminalInheritsWorkspaceEnvironment() throws { + let workspace = Workspace(workspaceEnvironment: ["AWS_PROFILE": "prod"]) + let firstPanelId = try #require(workspace.focusedPanelId) + let paneId = try #require(workspace.paneId(forPanelId: firstPanelId)) + let panel = try #require(workspace.splitPaneWithNewTerminal( + targetPane: paneId, + orientation: .horizontal, + insertFirst: false, + workingDirectory: nil, + initialInput: nil + )) + #expect(panel.surface.respawnAdditionalEnvironment["AWS_PROFILE"] == "prod") + } + + /// When the last surface exits it is replaced by a fresh local shell via + /// `createReplacementTerminalPanel`; that shell must inherit the workspace env. + @Test + func replacementTerminalInheritsWorkspaceEnvironment() { + let workspace = Workspace(workspaceEnvironment: ["AWS_PROFILE": "prod"]) + let replacement = workspace.createReplacementTerminalPanel() + #expect(replacement.surface.respawnAdditionalEnvironment["AWS_PROFILE"] == "prod") + } + /// An explicit per-surface environment (layout `env`, scrollback replay, SSH /// startup) overlays the workspace set rather than being discarded. @Test diff --git a/ghostty b/ghostty index 5697db813b1b..05c3e2908f95 160000 --- a/ghostty +++ b/ghostty @@ -1 +1 @@ -Subproject commit 5697db813b1b0fe14873093e9028f36513ddc187 +Subproject commit 05c3e2908f95892b180c3e5d770abe7a88b42c42 From ccf3cb8af8a4451b4e15948aa0e9610898caacc6 Mon Sep 17 00:00:00 2001 From: austinpower1258 <austinwang115@gmail.com> Date: Sun, 14 Jun 2026 02:29:13 -0700 Subject: [PATCH 07/13] Address autoreview: import CmuxTerminal in test; fix cross-workspace env leak - WorkspaceEnvironmentTests imports CmuxTerminal so TerminalSurface resolves in the wired cmuxTests target (P1, was a compile failure). - Fix workspace env becoming sticky across surface moves (P2). The same TerminalPanel travels when a surface is moved to another workspace, so the workspace env baked into its respawn state would re-seed the source workspace's variables on respawn. TerminalPanel now records the env keys it inherited from the workspace (set only at creation via configureNewTerminalPanel, so it survives the move); respawnTerminalSurface strips those keys from the replayed env and re-folds the current workspace's env. Adds coverage for the seeded-keys tracking. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- .github/swift-file-length-budget.tsv | 4 ++-- Sources/Panels/TerminalPanel.swift | 7 +++++++ Sources/Workspace.swift | 19 ++++++++++++++++++- cmuxTests/WorkspaceEnvironmentTests.swift | 12 ++++++++++++ 4 files changed, 39 insertions(+), 3 deletions(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index ecbc3cb5b789..8fc0c13717a4 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -8,7 +8,7 @@ 13595 Sources/Panels/BrowserPanel.swift 12088 Sources/GhosttyTerminalView.swift 12046 cmuxTests/AppDelegateShortcutRoutingTests.swift -12070 Sources/Workspace.swift +12087 Sources/Workspace.swift 9345 cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift 7911 Sources/Panels/BrowserPanelView.swift 7354 cmuxTests/WorkspaceUnitTests.swift @@ -99,7 +99,7 @@ 901 Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/AppSection.swift 893 Sources/WorkspaceContentView.swift 868 Sources/Panels/BrowserScreenshotSnapshotter.swift -866 Sources/Panels/TerminalPanel.swift +873 Sources/Panels/TerminalPanel.swift 852 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Workspace/ControlCommandCoordinator+Workspace.swift 847 cmuxTests/AgentSessionAutoResumeSettingsTests.swift 845 cmuxTests/SSHStartupSignalLifecycleTests.swift diff --git a/Sources/Panels/TerminalPanel.swift b/Sources/Panels/TerminalPanel.swift index a72e38db946f..606a71b9aeb6 100644 --- a/Sources/Panels/TerminalPanel.swift +++ b/Sources/Panels/TerminalPanel.swift @@ -49,6 +49,13 @@ final class TerminalPanel: Panel, ObservableObject { /// The workspace ID this panel belongs to private(set) var workspaceId: UUID + /// Keys 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, rather than + /// re-seeding the source workspace's env (issue #5995). + var seededWorkspaceEnvironmentKeys: Set<String> = [] + /// Published title from the terminal process @Published private(set) var title: String = "Terminal" diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 31707f66a14a..abebea9375bf 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -3672,6 +3672,14 @@ final class Workspace: Identifiable, ObservableObject { } private func configureNewTerminalPanel(_ terminalPanel: TerminalPanel) { + // Record which env keys this freshly-created panel inherited from the + // workspace, so a later respawn (which reuses this panel even after a + // move to another workspace) can drop them and re-apply the current + // workspace's env instead of leaking the source workspace's (#5995). + // Only creation runs through here — attach uses configureTerminalPanel — + // so the keys keep reflecting the workspace the surface's env was built + // from until the panel is respawned. + terminalPanel.seededWorkspaceEnvironmentKeys = Set(workspaceEnvironment.keys) if TerminalTextBoxInputSettings.focusOnNewTerminals() { terminalPanel.preferTextBoxInputWhenActivated() } else if TerminalTextBoxInputSettings.showOnNewTerminals() { @@ -7047,8 +7055,17 @@ final class Workspace: Identifiable, ObservableObject { let replacementTmuxStartCommand = (startCommand?.isEmpty == false) ? startCommand : trimmedCommand let focusPlacement = oldPanel.surface.focusPlacement let launchContext = oldPanel.surface.launchContext + // Drop env this surface inherited from its (possibly previous) workspace, + // then re-fold the current workspace's env below, so a terminal moved + // between workspaces respawns with the destination's variables rather than + // the source's (#5995). configureNewTerminalPanel re-records the keys for + // the replacement panel against the current workspace. + let oldSeededWorkspaceKeys = oldPanel.seededWorkspaceEnvironmentKeys let initialEnvironmentOverrides = oldPanel.surface.respawnInitialEnvironmentOverrides - let additionalEnvironment = oldPanel.surface.respawnAdditionalEnvironment + .filter { !oldSeededWorkspaceKeys.contains($0.key) } + let additionalEnvironment = startupEnvironmentMergingWorkspaceEnvironment( + oldPanel.surface.respawnAdditionalEnvironment.filter { !oldSeededWorkspaceKeys.contains($0.key) } + ) oldPanel.unfocus() oldPanel.hostedView.setVisibleInUI(false) diff --git a/cmuxTests/WorkspaceEnvironmentTests.swift b/cmuxTests/WorkspaceEnvironmentTests.swift index bb469c4555b4..3cf5414d30ec 100644 --- a/cmuxTests/WorkspaceEnvironmentTests.swift +++ b/cmuxTests/WorkspaceEnvironmentTests.swift @@ -1,3 +1,4 @@ +import CmuxTerminal import Foundation import Testing @@ -106,6 +107,17 @@ struct WorkspaceEnvironmentTests { #expect(replacement.surface.respawnAdditionalEnvironment["AWS_PROFILE"] == "prod") } + /// A new terminal panel records the workspace env keys it was seeded with, so a + /// later respawn can drop a previous workspace's env when the surface has been + /// moved (the same panel travels with the move). + @Test + func newPanelRecordsSeededWorkspaceEnvironmentKeys() throws { + let workspace = Workspace(workspaceEnvironment: ["AWS_PROFILE": "prod", "API_BASE": "https://x"]) + let panelId = try #require(workspace.focusedPanelId) + let panel = try #require(workspace.terminalPanel(for: panelId)) + #expect(panel.seededWorkspaceEnvironmentKeys == ["AWS_PROFILE", "API_BASE"]) + } + /// An explicit per-surface environment (layout `env`, scrollback replay, SSH /// startup) overlays the workspace set rather than being discarded. @Test From 9af03f678279df70035c7fd72dcb7feef60b8529 Mon Sep 17 00:00:00 2001 From: austinpower1258 <austinwang115@gmail.com> Date: Sun, 14 Jun 2026 02:41:58 -0700 Subject: [PATCH 08/13] Address autoreview: preserve per-surface env on respawn; strict workspace.env target - Respawn previously stripped any key in the seeded workspace key set, which discarded an explicit per-surface override sharing a workspace key (e.g. a layout env AWS_PROFILE=staging in a workspace with AWS_PROFILE=prod would respawn as prod). TerminalPanel now records the seeded workspace key/value pairs and respawn only drops entries whose value still equals the seeded workspace value, preserving per-surface overrides (autoreview P2). - `workspace.env` validated only workspace_id, so a malformed surface_id/ terminal_id/tab_id or a stale pane_id fell through to the selected workspace and could print the wrong workspace's secrets. It now validates every target param and resolves explicit targets strictly, only falling back to the selected workspace when no explicit target is supplied (autoreview P2). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- .github/swift-file-length-budget.tsv | 6 ++--- Sources/Panels/TerminalPanel.swift | 15 ++++++----- Sources/TerminalController.swift | 33 ++++++++++++++++++++--- Sources/Workspace.swift | 28 ++++++++++--------- cmuxTests/WorkspaceEnvironmentTests.swift | 11 ++++---- 5 files changed, 62 insertions(+), 31 deletions(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 8fc0c13717a4..9bf2bc88da49 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -4,11 +4,11 @@ 33671 CLI/cmux.swift 17914 Sources/AppDelegate.swift 16827 Sources/ContentView.swift -14656 Sources/TerminalController.swift +14681 Sources/TerminalController.swift 13595 Sources/Panels/BrowserPanel.swift 12088 Sources/GhosttyTerminalView.swift 12046 cmuxTests/AppDelegateShortcutRoutingTests.swift -12087 Sources/Workspace.swift +12089 Sources/Workspace.swift 9345 cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift 7911 Sources/Panels/BrowserPanelView.swift 7354 cmuxTests/WorkspaceUnitTests.swift @@ -99,7 +99,7 @@ 901 Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/AppSection.swift 893 Sources/WorkspaceContentView.swift 868 Sources/Panels/BrowserScreenshotSnapshotter.swift -873 Sources/Panels/TerminalPanel.swift +876 Sources/Panels/TerminalPanel.swift 852 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Workspace/ControlCommandCoordinator+Workspace.swift 847 cmuxTests/AgentSessionAutoResumeSettingsTests.swift 845 cmuxTests/SSHStartupSignalLifecycleTests.swift diff --git a/Sources/Panels/TerminalPanel.swift b/Sources/Panels/TerminalPanel.swift index 606a71b9aeb6..50444ce0cf5d 100644 --- a/Sources/Panels/TerminalPanel.swift +++ b/Sources/Panels/TerminalPanel.swift @@ -49,12 +49,15 @@ final class TerminalPanel: Panel, ObservableObject { /// The workspace ID this panel belongs to private(set) var workspaceId: UUID - /// Keys 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, rather than - /// re-seeding the source workspace's env (issue #5995). - var seededWorkspaceEnvironmentKeys: Set<String> = [] + /// 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" diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 56460979e53d..7ef794754d61 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -3680,21 +3680,46 @@ 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, and returns the raw configured set. Secret masking is a + /// 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 { - if v2HasNonNullParam(params, "workspace_id"), v2UUID(params, "workspace_id") == nil { - return .err(code: "invalid_params", message: "Missing or invalid workspace_id", data: nil) + // 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) } - guard let workspace = v2ResolveWorkspace(params: params, tabManager: tabManager) else { + // 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) diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index abebea9375bf..f6d6ac705183 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -3672,14 +3672,13 @@ final class Workspace: Identifiable, ObservableObject { } private func configureNewTerminalPanel(_ terminalPanel: TerminalPanel) { - // Record which env keys this freshly-created panel inherited from the - // workspace, so a later respawn (which reuses this panel even after a - // move to another workspace) can drop them and re-apply the current - // workspace's env instead of leaking the source workspace's (#5995). - // Only creation runs through here — attach uses configureTerminalPanel — - // so the keys keep reflecting the workspace the surface's env was built - // from until the panel is respawned. - terminalPanel.seededWorkspaceEnvironmentKeys = Set(workspaceEnvironment.keys) + // Record the workspace env this freshly-created panel inherited, so a later + // respawn (which reuses this panel even after a move to another workspace) + // can drop it and re-apply the current workspace's env instead of leaking + // the source workspace's (#5995). Only creation runs through here — attach + // uses configureTerminalPanel — so it keeps reflecting the workspace the + // surface's env was built from until the panel is respawned. + terminalPanel.seededWorkspaceEnvironment = workspaceEnvironment if TerminalTextBoxInputSettings.focusOnNewTerminals() { terminalPanel.preferTextBoxInputWhenActivated() } else if TerminalTextBoxInputSettings.showOnNewTerminals() { @@ -7058,13 +7057,16 @@ final class Workspace: Identifiable, ObservableObject { // Drop env this surface inherited from its (possibly previous) workspace, // then re-fold the current workspace's env below, so a terminal moved // between workspaces respawns with the destination's variables rather than - // the source's (#5995). configureNewTerminalPanel re-records the keys for - // the replacement panel against the current workspace. - let oldSeededWorkspaceKeys = oldPanel.seededWorkspaceEnvironmentKeys + // the source's (#5995). Only entries whose value still equals the seeded + // workspace value are dropped, so an explicit per-surface override that + // shares a workspace key keeps its value. configureNewTerminalPanel + // re-records the seeded env for the replacement panel against the current + // workspace. + let oldSeededWorkspaceEnvironment = oldPanel.seededWorkspaceEnvironment let initialEnvironmentOverrides = oldPanel.surface.respawnInitialEnvironmentOverrides - .filter { !oldSeededWorkspaceKeys.contains($0.key) } + .filter { oldSeededWorkspaceEnvironment[$0.key] != $0.value } let additionalEnvironment = startupEnvironmentMergingWorkspaceEnvironment( - oldPanel.surface.respawnAdditionalEnvironment.filter { !oldSeededWorkspaceKeys.contains($0.key) } + oldPanel.surface.respawnAdditionalEnvironment.filter { oldSeededWorkspaceEnvironment[$0.key] != $0.value } ) oldPanel.unfocus() diff --git a/cmuxTests/WorkspaceEnvironmentTests.swift b/cmuxTests/WorkspaceEnvironmentTests.swift index 3cf5414d30ec..dc29af1d84b0 100644 --- a/cmuxTests/WorkspaceEnvironmentTests.swift +++ b/cmuxTests/WorkspaceEnvironmentTests.swift @@ -107,15 +107,16 @@ struct WorkspaceEnvironmentTests { #expect(replacement.surface.respawnAdditionalEnvironment["AWS_PROFILE"] == "prod") } - /// A new terminal panel records the workspace env keys it was seeded with, so a - /// later respawn can drop a previous workspace's env when the surface has been - /// moved (the same panel travels with the move). + /// A new terminal panel records the workspace env (key and value) it was seeded + /// with, so a later respawn can drop a previous workspace's env when the surface + /// has been moved (the same panel travels with the move) while preserving an + /// explicit per-surface override that shares a workspace key. @Test - func newPanelRecordsSeededWorkspaceEnvironmentKeys() throws { + func newPanelRecordsSeededWorkspaceEnvironment() throws { let workspace = Workspace(workspaceEnvironment: ["AWS_PROFILE": "prod", "API_BASE": "https://x"]) let panelId = try #require(workspace.focusedPanelId) let panel = try #require(workspace.terminalPanel(for: panelId)) - #expect(panel.seededWorkspaceEnvironmentKeys == ["AWS_PROFILE", "API_BASE"]) + #expect(panel.seededWorkspaceEnvironment == ["AWS_PROFILE": "prod", "API_BASE": "https://x"]) } /// An explicit per-surface environment (layout `env`, scrollback replay, SSH From 8436094d6e9858a6047df7b4b52fdfc11217c85e Mon Sep 17 00:00:00 2001 From: austinpower1258 <austinwang115@gmail.com> Date: Sun, 14 Jun 2026 11:47:29 -0700 Subject: [PATCH 09/13] Mark workspace env sanitizer nonisolated for the socket create path Workspace is @MainActor, so its static sanitizedWorkspaceEnvironment was main-actor-isolated. The nonisolated socket workspace-create parsing path (v2WorkspaceCreate) calls it synchronously, so mark the pure helper `nonisolated` to keep it safe under stricter Swift concurrency checking (autoreview P1). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- .github/swift-file-length-budget.tsv | 2 +- Sources/Workspace.swift | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 9bf2bc88da49..b4c35a1284c3 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -8,7 +8,7 @@ 13595 Sources/Panels/BrowserPanel.swift 12088 Sources/GhosttyTerminalView.swift 12046 cmuxTests/AppDelegateShortcutRoutingTests.swift -12089 Sources/Workspace.swift +12093 Sources/Workspace.swift 9345 cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift 7911 Sources/Panels/BrowserPanelView.swift 7354 cmuxTests/WorkspaceUnitTests.swift diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index f6d6ac705183..368f05f945b0 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -5704,7 +5704,11 @@ final class Workspace: Identifiable, ObservableObject { /// (and `=`, which is never a valid env var name) and any value containing a /// NUL. This is the single choke point for every entry point (CLI, cmux.json, /// session restore), so the guard cannot be bypassed. - static func sanitizedWorkspaceEnvironment(_ environment: [String: String]) -> [String: String] { + // `nonisolated` so the nonisolated socket workspace-create parsing path + // (`v2WorkspaceCreate`) can call this pure helper without hopping to the main + // actor; `Workspace` is `@MainActor`, so its statics are main-actor-isolated by + // default. + nonisolated static func sanitizedWorkspaceEnvironment(_ environment: [String: String]) -> [String: String] { environment.reduce(into: [String: String]()) { result, pair in let key = pair.key.trimmingCharacters(in: .whitespacesAndNewlines) guard !key.isEmpty, From 777efde616f936b682b4794cd3010fdde129f0c4 Mon Sep 17 00:00:00 2001 From: austinpower1258 <austinwang115@gmail.com> Date: Sun, 14 Jun 2026 14:36:37 -0700 Subject: [PATCH 10/13] fix: address workspace env review feedback --- .github/swift-file-length-budget.tsv | 2 +- CLI/cmux.swift | 54 +++++++- Resources/Localizable.xcstrings | 145 ++++++++++++++++++++++ cmuxTests/WorkspaceEnvironmentTests.swift | 27 ++++ 4 files changed, 221 insertions(+), 7 deletions(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index b2b9d18d7ac7..2ac7448558e3 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -1,7 +1,7 @@ # 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. -34074 CLI/cmux.swift +34116 CLI/cmux.swift 17914 Sources/AppDelegate.swift 16709 Sources/ContentView.swift 14681 Sources/TerminalController.swift diff --git a/CLI/cmux.swift b/CLI/cmux.swift index c2fa370348d1..1e5d5c341c5a 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -7255,7 +7255,15 @@ struct CMUXCLI { 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>, --env KEY=VALUE, --env-file <path>, --layout <json>, --window <id|ref|index>, --focus <true|false>") + throw CLIError(message: String( + format: String( + localized: "cli.workspace.create.error.unknownFlag", + defaultValue: "%@: unknown flag '%@'. 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>" + ), + locale: .current, + commandName, + unknown + )) } var params: [String: Any] = [:] try applyWindowOrCallerContext(to: ¶ms, client: client, windowRaw: windowOpt ?? windowOverride) @@ -7356,7 +7364,16 @@ struct CMUXCLI { do { contents = try String(contentsOfFile: resolved, encoding: .utf8) } catch { - throw CLIError(message: "\(commandName): could not read --env-file '\(path)': \(String(describing: error))") + throw CLIError(message: String( + format: String( + localized: "cli.workspace.envFile.error.readFailed", + defaultValue: "%@: could not read --env-file '%@': %@" + ), + locale: .current, + commandName, + path, + String(describing: error) + )) } for rawLine in contents.split(omittingEmptySubsequences: false, whereSeparator: { $0.isNewline }) { var line = String(rawLine).trimmingCharacters(in: .whitespaces) @@ -7384,12 +7401,30 @@ struct CMUXCLI { 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") + throw CLIError(message: String( + format: String( + localized: "cli.workspace.env.error.invalidAssignment", + defaultValue: "%@: %@ entry '%@' must be in KEY=VALUE form" + ), + locale: .current, + commandName, + source, + raw + )) } 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") + throw CLIError(message: String( + format: String( + localized: "cli.workspace.env.error.emptyKey", + defaultValue: "%@: %@ entry '%@' has an empty key" + ), + locale: .current, + commandName, + source, + raw + )) } return (key, value) } @@ -7403,7 +7438,7 @@ struct CMUXCLI { (trimmed.hasPrefix("'") && trimmed.hasSuffix("'")) { return String(trimmed.dropFirst().dropLast()) } - return value + return trimmed } /// Masks a secret env value for display. Short values are fully masked so a @@ -7433,7 +7468,14 @@ struct CMUXCLI { 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") + throw CLIError(message: String( + format: String( + localized: "cli.workspace.env.error.unknownFlag", + defaultValue: "workspace env: unknown flag '%@'. Known flags: --workspace <id|ref|index>, --window <id|ref|index>, --mask" + ), + locale: .current, + unknown + )) } let positional = rem1.first(where: { !$0.hasPrefix("--") }) let windowRaw = windowFromArgsOrOverride(commandArgs, windowOverride: windowOverride) diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index b9389d7748a9..be6b8503e90c 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -194572,6 +194572,151 @@ } } }, + "cli.workspace.create.error.unknownFlag": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%@: unknown flag '%@'. 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>" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@: 不明なフラグ '%@'。利用可能なフラグ: --name <title>, --description <text>, --command <text>, --cwd <path>, --env KEY=VALUE, --env-file <path>, --layout <json>, --window <id|ref|index>, --focus <true|false>" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@: 알 수 없는 플래그 '%@'. 사용 가능한 플래그: --name <title>, --description <text>, --command <text>, --cwd <path>, --env KEY=VALUE, --env-file <path>, --layout <json>, --window <id|ref|index>, --focus <true|false>" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "%@: невідомий прапорець '%@'. Доступні прапорці: --name <title>, --description <text>, --command <text>, --cwd <path>, --env KEY=VALUE, --env-file <path>, --layout <json>, --window <id|ref|index>, --focus <true|false>" + } + } + } + }, + "cli.workspace.env.error.emptyKey": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%@: %@ entry '%@' has an empty key" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@: %@ のエントリ '%@' のキーが空です" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@: %@ 항목 '%@'의 키가 비어 있습니다" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "%@: запис %@ '%@' має порожній ключ" + } + } + } + }, + "cli.workspace.env.error.invalidAssignment": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%@: %@ entry '%@' must be in KEY=VALUE form" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@: %@ のエントリ '%@' は KEY=VALUE 形式である必要があります" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@: %@ 항목 '%@'은(는) KEY=VALUE 형식이어야 합니다" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "%@: запис %@ '%@' має бути у форматі KEY=VALUE" + } + } + } + }, + "cli.workspace.env.error.unknownFlag": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "workspace env: unknown flag '%@'. Known flags: --workspace <id|ref|index>, --window <id|ref|index>, --mask" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "workspace env: 不明なフラグ '%@'。利用可能なフラグ: --workspace <id|ref|index>, --window <id|ref|index>, --mask" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "workspace env: 알 수 없는 플래그 '%@'. 사용 가능한 플래그: --workspace <id|ref|index>, --window <id|ref|index>, --mask" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "workspace env: невідомий прапорець '%@'. Доступні прапорці: --workspace <id|ref|index>, --window <id|ref|index>, --mask" + } + } + } + }, + "cli.workspace.envFile.error.readFailed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%@: could not read --env-file '%@': %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@: --env-file '%@' を読み込めませんでした: %@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@: --env-file '%@'을(를) 읽을 수 없습니다: %@" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "%@: не вдалося прочитати --env-file '%@': %@" + } + } + } + }, "debug.menu.devWindowDisplay": { "extractionState": "manual", "localizations": { diff --git a/cmuxTests/WorkspaceEnvironmentTests.swift b/cmuxTests/WorkspaceEnvironmentTests.swift index dc29af1d84b0..e3651395450a 100644 --- a/cmuxTests/WorkspaceEnvironmentTests.swift +++ b/cmuxTests/WorkspaceEnvironmentTests.swift @@ -44,6 +44,33 @@ struct WorkspaceEnvironmentTests { #expect(result == ["GOOD": "value"]) } + // MARK: - CLI env-file parsing + + @Test + func envFileValuesTrimUnquotedWhitespace() throws { + let directoryURL = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-workspace-env-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directoryURL, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directoryURL) } + + let envFileURL = directoryURL.appendingPathComponent(".cmux.env", isDirectory: false) + try """ + SPACED= bar + TRAILING=baz + QUOTED=" keep " + """.write(to: envFileURL, atomically: true, encoding: .utf8) + + let env = try CMUXCLI(args: []).buildWorkspaceEnvironment( + envFiles: [envFileURL.path], + envPairs: [], + commandName: "new-workspace" + ) + + #expect(env["SPACED"] == "bar") + #expect(env["TRAILING"] == "baz") + #expect(env["QUOTED"] == " keep ") + } + // MARK: - Acceptance: initial shell inherits the workspace environment @Test From 67593ea378902aca724e829186590879cdb16b9f Mon Sep 17 00:00:00 2001 From: austinpower1258 <austinwang115@gmail.com> Date: Sun, 14 Jun 2026 14:45:31 -0700 Subject: [PATCH 11/13] fix: keep workspace env tests in target scope --- cmuxTests/WorkspaceEnvironmentTests.swift | 27 ----------------------- 1 file changed, 27 deletions(-) diff --git a/cmuxTests/WorkspaceEnvironmentTests.swift b/cmuxTests/WorkspaceEnvironmentTests.swift index e3651395450a..dc29af1d84b0 100644 --- a/cmuxTests/WorkspaceEnvironmentTests.swift +++ b/cmuxTests/WorkspaceEnvironmentTests.swift @@ -44,33 +44,6 @@ struct WorkspaceEnvironmentTests { #expect(result == ["GOOD": "value"]) } - // MARK: - CLI env-file parsing - - @Test - func envFileValuesTrimUnquotedWhitespace() throws { - let directoryURL = FileManager.default.temporaryDirectory - .appendingPathComponent("cmux-workspace-env-\(UUID().uuidString)", isDirectory: true) - try FileManager.default.createDirectory(at: directoryURL, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: directoryURL) } - - let envFileURL = directoryURL.appendingPathComponent(".cmux.env", isDirectory: false) - try """ - SPACED= bar - TRAILING=baz - QUOTED=" keep " - """.write(to: envFileURL, atomically: true, encoding: .utf8) - - let env = try CMUXCLI(args: []).buildWorkspaceEnvironment( - envFiles: [envFileURL.path], - envPairs: [], - commandName: "new-workspace" - ) - - #expect(env["SPACED"] == "bar") - #expect(env["TRAILING"] == "baz") - #expect(env["QUOTED"] == " keep ") - } - // MARK: - Acceptance: initial shell inherits the workspace environment @Test From 039f7badddfd50e172a79e1fbed84a5a47224e00 Mon Sep 17 00:00:00 2001 From: austinpower1258 <austinwang115@gmail.com> Date: Sun, 14 Jun 2026 14:52:54 -0700 Subject: [PATCH 12/13] fix: clarify workspace env missing values --- .github/swift-file-length-budget.tsv | 2 +- CLI/cmux.swift | 20 ++++++++++ Resources/Localizable.xcstrings | 58 ++++++++++++++++++++++++++++ 3 files changed, 79 insertions(+), 1 deletion(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 2ac7448558e3..b366a62538f9 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -1,7 +1,7 @@ # 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. -34116 CLI/cmux.swift +34136 CLI/cmux.swift 17914 Sources/AppDelegate.swift 16709 Sources/ContentView.swift 14681 Sources/TerminalController.swift diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 1e5d5c341c5a..5734068c17de 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -7254,6 +7254,26 @@ struct CMUXCLI { let (windowOpt, rem5) = parseOption(rem4, name: "--window") let (focusOpt, rem6) = parseOption(rem5, name: "--focus") let (envFiles, envPairs, remaining) = parseWorkspaceEnvOptions(rem6) + if remaining.last == "--env" { + throw CLIError(message: String( + format: String( + localized: "cli.workspace.create.error.envRequiresValue", + defaultValue: "%@: --env requires KEY=VALUE" + ), + locale: .current, + commandName + )) + } + if remaining.last == "--env-file" { + throw CLIError(message: String( + format: String( + localized: "cli.workspace.create.error.envFileRequiresValue", + defaultValue: "%@: --env-file requires <path>" + ), + locale: .current, + commandName + )) + } if let unknown = remaining.first(where: { $0.hasPrefix("--") }) { throw CLIError(message: String( format: String( diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index be6b8503e90c..ed52887b3dff 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -194601,6 +194601,64 @@ } } }, + "cli.workspace.create.error.envFileRequiresValue": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%@: --env-file requires <path>" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@: --env-file には <path> が必要です" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@: --env-file에는 <path>가 필요합니다" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "%@: --env-file потребує <path>" + } + } + } + }, + "cli.workspace.create.error.envRequiresValue": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%@: --env requires KEY=VALUE" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@: --env には KEY=VALUE が必要です" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@: --env에는 KEY=VALUE가 필요합니다" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "%@: --env потребує KEY=VALUE" + } + } + } + }, "cli.workspace.env.error.emptyKey": { "extractionState": "manual", "localizations": { From bde45c64158bd8c02c4bfed61972fd262e27fa85 Mon Sep 17 00:00:00 2001 From: austinpower1258 <austinwang115@gmail.com> Date: Sun, 14 Jun 2026 14:57:27 -0700 Subject: [PATCH 13/13] fix: require workspace_env socket param --- .github/swift-file-length-budget.tsv | 2 +- Sources/TerminalController.swift | 5 +++-- docs/cli-contract.md | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index b366a62538f9..609439928e59 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -4,7 +4,7 @@ 34136 CLI/cmux.swift 17914 Sources/AppDelegate.swift 16709 Sources/ContentView.swift -14681 Sources/TerminalController.swift +14682 Sources/TerminalController.swift 13595 Sources/Panels/BrowserPanel.swift 12093 Sources/Workspace.swift 12088 Sources/GhosttyTerminalView.swift diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 7ef794754d61..7fc5a5c93edf 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -13958,11 +13958,12 @@ class TerminalController { } // Persistent per-workspace environment (issue #5995): applied to the initial // shell AND every later pane/surface/split, and round-tripped through session - // restore. Accept `workspace_env` (preferred) or `env` (layout-JSON spelling). + // 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") ?? v2StringMap(params, "env") ?? [:] + v2StringMap(params, "workspace_env") ?? [:] ) let cwd: String? if let workingDirectory { diff --git a/docs/cli-contract.md b/docs/cli-contract.md index 0525c861ca41..d7139159fc3f 100644 --- a/docs/cli-contract.md +++ b/docs/cli-contract.md @@ -230,7 +230,7 @@ Setting them: value from a file. - Project config (`cmux.json`): an `env` object on a workspace definition, e.g. `{ "name": "Build", "cwd": ".", "env": { "AWS_PROFILE": "prod" } }`. -- Socket: the `workspace_env` (alias `env`) param on `workspace.create`. +- Socket: the `workspace_env` param on `workspace.create`. Inspecting them: `cmux workspace env [<handle>] [--mask] [--json]` prints the configured set. `--mask` redacts the values so secrets are not echoed in full.