From 8369e3c2ee434c702e33e81d76899a2c3adfee6f Mon Sep 17 00:00:00 2001 From: Steve Lum Date: Sat, 18 Apr 2026 20:06:49 -0700 Subject: [PATCH 1/5] test: assert claude-teams preserves TERM_PROGRAM and uses xterm-ghostty MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pins the new contract for cmux claude-teams's tmux-compat env: TERM defaults to xterm-ghostty (was screen-256color) and TERM_PROGRAM is preserved (was unconditionally unset). Without the matching Swift CLI fix, this test fails — exposing the Claude Code v2.1.112 crash from issue #2947 during permission escalation. Co-Authored-By: Claude Opus 4.7 --- tests/test_cli_claude_teams_env.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/tests/test_cli_claude_teams_env.py b/tests/test_cli_claude_teams_env.py index f577364fc3ae..193970771c40 100644 --- a/tests/test_cli_claude_teams_env.py +++ b/tests/test_cli_claude_teams_env.py @@ -203,13 +203,16 @@ def run_claude_teams( raise SystemExit(1) term_value = read_text(term_log) - if term_value != "screen-256color": - print(f"FAIL: expected TERM=screen-256color, got {term_value!r}") + if term_value != "xterm-ghostty": + print(f"FAIL: expected TERM=xterm-ghostty, got {term_value!r}") raise SystemExit(1) term_program_value = read_text(term_program_log) - if term_program_value != "__UNSET__": - print(f"FAIL: expected TERM_PROGRAM to be unset, got {term_program_value!r}") + if term_program_value != "__HOST_TERM_PROGRAM__": + print( + "FAIL: expected TERM_PROGRAM to be preserved for claude-teams " + f"(Claude Code v2.1.112+ crashes if missing, see #2947), got {term_program_value!r}" + ) raise SystemExit(1) socket_path_value = read_text(socket_path_log) From 3b2b37b243f4d515805b5a4a5a92b15d048c731e Mon Sep 17 00:00:00 2001 From: Steve Lum Date: Sat, 18 Apr 2026 20:07:25 -0700 Subject: [PATCH 2/5] fix: preserve TERM_PROGRAM in Swift CLI for claude-teams (#2947) Mirrors PR #2960's Go-daemon fix into the local-Mac Swift CLI path, which is what `cmux claude-teams` actually goes through on the user's machine. Without this, the Go-only fix doesn't reach the reporter's crash. - Add `unsetTermProgram: Bool` to configureTmuxCompatEnvironment. - claude-teams: pass false (Claude Code v2.1.112+ needs TERM_PROGRAM set during permission escalation; missing it triggers the cli.js crash in #2947). - omo / omx / omc: pass true (opencode flips to a light theme on TERM_PROGRAM=ghostty; preserves prior behavior). - Default TERM is now xterm-ghostty instead of screen-256color so agents render with full Ghostty fidelity, and COLORTERM=truecolor is set when the parent env didn't already supply one. Co-Authored-By: Claude Opus 4.7 --- CLI/cmux.swift | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 3215c4b536ec..9e82e42b3e04 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -10376,6 +10376,7 @@ struct CMUXCLI { tmuxPathPrefix: String, cmuxBinEnvVar: String, termOverrideEnvVar: String, + unsetTermProgram: Bool, extraEnvVars: [(key: String, value: String)] = [] ) { let updatedPath = prependPathEntries( @@ -10392,20 +10393,29 @@ struct CMUXCLI { let fakeTmuxPane = focusedContext.map { "%\($0.paneHandle)" } ?? processEnvironment["TMUX_PANE"] ?? "%1" - let fakeTerm = processEnvironment[termOverrideEnvVar] ?? "screen-256color" + let fakeTerm = processEnvironment[termOverrideEnvVar] ?? "xterm-ghostty" setenv(cmuxBinEnvVar, executablePath, 1) setenv("PATH", updatedPath, 1) setenv("TMUX", fakeTmuxValue, 1) setenv("TMUX_PANE", fakeTmuxPane, 1) setenv("TERM", fakeTerm, 1) + if (processEnvironment["COLORTERM"] ?? "").isEmpty { + setenv("COLORTERM", "truecolor", 1) + } setenv("CMUX_SOCKET_PATH", socketPath, 1) setenv("CMUX_SOCKET", socketPath, 1) if let explicitPassword, !explicitPassword.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { setenv("CMUX_SOCKET_PASSWORD", explicitPassword, 1) } - unsetenv("TERM_PROGRAM") + // Only unset TERM_PROGRAM for opencode-family (omo/omx/omc) — they switch + // to a light theme when they detect TERM_PROGRAM=ghostty. claude-teams + // must preserve it; Claude Code v2.1.112+ crashes during permission + // escalation when TERM_PROGRAM is missing (see issue #2947). + if unsetTermProgram { + unsetenv("TERM_PROGRAM") + } for envVar in extraEnvVars { setenv(envVar.key, envVar.value, 1) } @@ -10446,6 +10456,7 @@ struct CMUXCLI { tmuxPathPrefix: "cmux-claude-teams", cmuxBinEnvVar: "CMUX_CLAUDE_TEAMS_CMUX_BIN", termOverrideEnvVar: "CMUX_CLAUDE_TEAMS_TERM", + unsetTermProgram: false, extraEnvVars: [ (key: "CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS", value: "1"), ] @@ -11024,6 +11035,7 @@ struct CMUXCLI { tmuxPathPrefix: "cmux-omo", cmuxBinEnvVar: "CMUX_OMO_CMUX_BIN", termOverrideEnvVar: "CMUX_OMO_TERM", + unsetTermProgram: true, extraEnvVars: [(key: "OPENCODE_PORT", value: openCodePort)] ) } @@ -11145,7 +11157,8 @@ struct CMUXCLI { focusedContext: focusedContext, tmuxPathPrefix: "cmux-omx", cmuxBinEnvVar: "CMUX_OMX_CMUX_BIN", - termOverrideEnvVar: "CMUX_OMX_TERM" + termOverrideEnvVar: "CMUX_OMX_TERM", + unsetTermProgram: true ) } @@ -11248,7 +11261,8 @@ struct CMUXCLI { focusedContext: focusedContext, tmuxPathPrefix: "cmux-omc", cmuxBinEnvVar: "CMUX_OMC_CMUX_BIN", - termOverrideEnvVar: "CMUX_OMC_TERM" + termOverrideEnvVar: "CMUX_OMC_TERM", + unsetTermProgram: true ) // omc wraps Claude Code, so it needs the same NODE_OPTIONS restore module guard let restoreModuleURL = try? createClaudeNodeOptionsRestoreModule() else { From 395271160a39d7f5c2e689a0ed02cfc9b64d92e1 Mon Sep 17 00:00:00 2001 From: bobo-xxx <111567133+bobo-xxx@users.noreply.github.com> Date: Fri, 17 Apr 2026 18:05:41 +0000 Subject: [PATCH 3/5] fix: preserve TERM_PROGRAM for claude-teams to prevent crash Fixes cmux crash when using claude-teams with teamCreate (issue #2947). Root cause: Claude Code v2.1.112 changed how it handles permission escalation based on terminal environment variables. The previous code unconditionally unset TERM_PROGRAM which caused Claude Code's cli.js to crash during permissionExplainerEnabled checks. Changes: - Add unsetTermProgram flag to agentConfig to control TERM_PROGRAM handling per agent type - claude-teams: preserve TERM_PROGRAM (needed for Ghostty detection) - omo/omx/omc: unset TERM_PROGRAM (opencode switches to light theme) - Change default TERM from screen-256color to xterm-ghostty - Add COLORTERM=truecolor for proper truecolor support (cherry picked from commit 4c6f9b290eb7009d2f03f28460151e0845eafd51) --- .../remote/cmd/cmuxd-remote/agent_launch.go | 87 ++++++++++--------- 1 file changed, 47 insertions(+), 40 deletions(-) diff --git a/daemon/remote/cmd/cmuxd-remote/agent_launch.go b/daemon/remote/cmd/cmuxd-remote/agent_launch.go index cf3e7378f081..282496e8a73d 100644 --- a/daemon/remote/cmd/cmuxd-remote/agent_launch.go +++ b/daemon/remote/cmd/cmuxd-remote/agent_launch.go @@ -41,12 +41,13 @@ func runClaudeTeamsRelay(socketPath string, args []string, refreshAddr func() st focused := getFocusedContext(rc) configureAgentEnvironment(agentConfig{ - shimDir: shimDir, - socketPath: socketPath, - focused: focused, - tmuxPathPrefix: "cmux-claude-teams", - cmuxBinEnvVar: "CMUX_CLAUDE_TEAMS_CMUX_BIN", - termEnvVar: "CMUX_CLAUDE_TEAMS_TERM", + shimDir: shimDir, + socketPath: socketPath, + focused: focused, + tmuxPathPrefix: "cmux-claude-teams", + cmuxBinEnvVar: "CMUX_CLAUDE_TEAMS_CMUX_BIN", + termEnvVar: "CMUX_CLAUDE_TEAMS_TERM", + unsetTermProgram: false, extraEnv: map[string]string{ "CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS": "1", }, @@ -95,13 +96,14 @@ func runOMORelay(socketPath string, args []string, refreshAddr func() string) in focused := getFocusedContext(rc) configureAgentEnvironment(agentConfig{ - shimDir: shimDir, - socketPath: socketPath, - focused: focused, - tmuxPathPrefix: "cmux-omo", - cmuxBinEnvVar: "CMUX_OMO_CMUX_BIN", - termEnvVar: "CMUX_OMO_TERM", - extraEnv: map[string]string{}, + shimDir: shimDir, + socketPath: socketPath, + focused: focused, + tmuxPathPrefix: "cmux-omo", + cmuxBinEnvVar: "CMUX_OMO_CMUX_BIN", + termEnvVar: "CMUX_OMO_TERM", + unsetTermProgram: true, + extraEnv: map[string]string{}, }) // Set OPENCODE_PORT if not already set @@ -153,13 +155,14 @@ func runOMXRelay(socketPath string, args []string, refreshAddr func() string) in focused := getFocusedContext(rc) configureAgentEnvironment(agentConfig{ - shimDir: shimDir, - socketPath: socketPath, - focused: focused, - tmuxPathPrefix: "cmux-omx", - cmuxBinEnvVar: "CMUX_OMX_CMUX_BIN", - termEnvVar: "CMUX_OMX_TERM", - extraEnv: map[string]string{}, + shimDir: shimDir, + socketPath: socketPath, + focused: focused, + tmuxPathPrefix: "cmux-omx", + cmuxBinEnvVar: "CMUX_OMX_CMUX_BIN", + termEnvVar: "CMUX_OMX_TERM", + unsetTermProgram: true, + extraEnv: map[string]string{}, }) launchPath, launchArgv := resolveNodeScriptExec(omxPath, args, originalPath, shimDir) @@ -189,13 +192,14 @@ func runOMCRelay(socketPath string, args []string, refreshAddr func() string) in focused := getFocusedContext(rc) configureAgentEnvironment(agentConfig{ - shimDir: shimDir, - socketPath: socketPath, - focused: focused, - tmuxPathPrefix: "cmux-omc", - cmuxBinEnvVar: "CMUX_OMC_CMUX_BIN", - termEnvVar: "CMUX_OMC_TERM", - extraEnv: map[string]string{}, + shimDir: shimDir, + socketPath: socketPath, + focused: focused, + tmuxPathPrefix: "cmux-omc", + cmuxBinEnvVar: "CMUX_OMC_CMUX_BIN", + termEnvVar: "CMUX_OMC_TERM", + unsetTermProgram: true, + extraEnv: map[string]string{}, }) // omc wraps Claude Code, so configure NODE_OPTIONS restore module @@ -431,13 +435,14 @@ func stringFromAny(values ...any) string { // --- Environment configuration --- type agentConfig struct { - shimDir string - socketPath string - focused *focusedContext - tmuxPathPrefix string - cmuxBinEnvVar string - termEnvVar string - extraEnv map[string]string + shimDir string + socketPath string + focused *focusedContext + tmuxPathPrefix string + cmuxBinEnvVar string + termEnvVar string + extraEnv map[string]string + unsetTermProgram bool } func configureAgentEnvironment(cfg agentConfig) { @@ -470,9 +475,12 @@ func configureAgentEnvironment(cfg agentConfig) { // Terminal settings fakeTerm := os.Getenv(cfg.termEnvVar) if fakeTerm == "" { - fakeTerm = "screen-256color" + fakeTerm = "xterm-ghostty" } os.Setenv("TERM", fakeTerm) + if os.Getenv("COLORTERM") == "" { + os.Setenv("COLORTERM", "truecolor") + } // Socket path os.Setenv("CMUX_SOCKET_PATH", cfg.socketPath) @@ -481,11 +489,10 @@ func configureAgentEnvironment(cfg agentConfig) { // Unset TERM_PROGRAM so apps don't detect the host terminal and // override tmux-compatible behavior (e.g. opencode switches to // light theme when it sees TERM_PROGRAM=ghostty). - os.Unsetenv("TERM_PROGRAM") - - // Preserve COLORTERM for truecolor support in subagent panes. - if os.Getenv("COLORTERM") == "" { - os.Setenv("COLORTERM", "truecolor") + // Only unset for OMO/OMX/OMC; claude-teams must preserve it + // so Claude Code can detect Ghostty for proper color support. + if cfg.unsetTermProgram { + os.Unsetenv("TERM_PROGRAM") } // Set workspace/surface IDs from focused context From 3bcfcdbcc97095517cfc0bf07fb39210f7dcad18 Mon Sep 17 00:00:00 2001 From: Steve Lum Date: Sat, 18 Apr 2026 21:14:41 -0700 Subject: [PATCH 4/5] refactor: invert flag, probe terminfo, align COLORTERM across Go and Swift MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Polish on top of the cherry-picked Go fix (#2960) and our matching Swift fix, addressing CodeRabbit + adversarial-review feedback. Changes: - Rename agentConfig.unsetTermProgram (Go) and the matching Swift parameter to preserveTermProgram. The zero/default value now matches the historically common case (TERM_PROGRAM unset, the legacy behavior), so any future agent type defaults to the safe choice and only claude-teams opts in to preservation. CodeRabbit flagged the original naming as zero-value-unsafe; both adversarial reviewers concurred. - Add a resolveDefaultTerm() helper in both surfaces that probes `infocmp xterm-ghostty` and falls back to `xterm-256color` when the terminfo entry is missing. cmux's terminfo overlay installs asynchronously during shell bootstrap on remote SSH hosts (see tests_v2/test_ssh_remote_shell_integration.py for the documented race), so a child process exec'd before the install completes would otherwise inherit TERM=xterm-ghostty without the matching terminfo entry — landing it at a dumb terminal. xterm-256color is universally available. - Read COLORTERM from the live process env (getenv) instead of the Swift-side processEnvironment snapshot, mirroring the Go daemon's behavior in agent_launch.go. Functionally equivalent today (nothing mutates COLORTERM between snapshot and check) but more robust to future re-orderings and keeps the two surfaces aligned. - Tighten the asymmetry comment in both files. opencode reacts to any non-empty TERM_PROGRAM (treats it as an outer-host marker), not specifically TERM_PROGRAM=ghostty as the previous comment implied. Co-Authored-By: Claude Opus 4.7 --- CLI/cmux.swift | 56 ++++++--- .../remote/cmd/cmuxd-remote/agent_launch.go | 108 ++++++++++-------- 2 files changed, 104 insertions(+), 60 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 9e82e42b3e04..d60117c26917 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -10366,6 +10366,29 @@ struct CMUXCLI { return ["--teammate-mode", "auto"] + commandArgs } + /// Returns "xterm-ghostty" if its terminfo entry is available, otherwise + /// "xterm-256color" — which is universally supported. This avoids a race + /// where `cmux claude-teams` (etc.) could exec a child process that + /// inherits TERM=xterm-ghostty before cmux's terminfo overlay has + /// finished installing (matters for SSH'd agents on fresh remote hosts; + /// also belt-and-suspenders for local Macs without Ghostty installed + /// system-wide). Mirrors the Go-side `resolveDefaultTerm` in + /// daemon/remote/cmd/cmuxd-remote/agent_launch.go. + private static func resolveDefaultTerm() -> String { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/env") + process.arguments = ["infocmp", "xterm-ghostty"] + process.standardOutput = Pipe() + process.standardError = Pipe() + do { + try process.run() + process.waitUntilExit() + return process.terminationStatus == 0 ? "xterm-ghostty" : "xterm-256color" + } catch { + return "xterm-256color" + } + } + private func configureTmuxCompatEnvironment( processEnvironment: [String: String], shimDirectory: URL, @@ -10376,7 +10399,7 @@ struct CMUXCLI { tmuxPathPrefix: String, cmuxBinEnvVar: String, termOverrideEnvVar: String, - unsetTermProgram: Bool, + preserveTermProgram: Bool = false, extraEnvVars: [(key: String, value: String)] = [] ) { let updatedPath = prependPathEntries( @@ -10393,14 +10416,19 @@ struct CMUXCLI { let fakeTmuxPane = focusedContext.map { "%\($0.paneHandle)" } ?? processEnvironment["TMUX_PANE"] ?? "%1" - let fakeTerm = processEnvironment[termOverrideEnvVar] ?? "xterm-ghostty" + let fakeTerm = processEnvironment[termOverrideEnvVar] ?? Self.resolveDefaultTerm() setenv(cmuxBinEnvVar, executablePath, 1) setenv("PATH", updatedPath, 1) setenv("TMUX", fakeTmuxValue, 1) setenv("TMUX_PANE", fakeTmuxPane, 1) setenv("TERM", fakeTerm, 1) - if (processEnvironment["COLORTERM"] ?? "").isEmpty { + // Read COLORTERM from the live env (not the snapshot) so the fallback + // is robust against any future re-ordering that mutates COLORTERM + // earlier — and so it stays in sync with the Go daemon's behavior in + // daemon/remote/cmd/cmuxd-remote/agent_launch.go. + let liveColorTerm = getenv("COLORTERM").map { String(cString: $0) } ?? "" + if liveColorTerm.isEmpty { setenv("COLORTERM", "truecolor", 1) } setenv("CMUX_SOCKET_PATH", socketPath, 1) @@ -10409,11 +10437,14 @@ struct CMUXCLI { !explicitPassword.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { setenv("CMUX_SOCKET_PASSWORD", explicitPassword, 1) } - // Only unset TERM_PROGRAM for opencode-family (omo/omx/omc) — they switch - // to a light theme when they detect TERM_PROGRAM=ghostty. claude-teams - // must preserve it; Claude Code v2.1.112+ crashes during permission - // escalation when TERM_PROGRAM is missing (see issue #2947). - if unsetTermProgram { + // opencode-family agents (omo/omx/omc) react to any non-empty + // TERM_PROGRAM by switching to a light theme — they treat the variable + // as an outer-host marker regardless of value (regression #2516). + // claude-teams must preserve TERM_PROGRAM since Claude Code v2.1.112+ + // crashes during permission escalation when it's missing (issue #2947). + // The default (false) keeps the legacy "unset" behavior, so any new + // agent type defaults to the historically common case. + if !preserveTermProgram { unsetenv("TERM_PROGRAM") } for envVar in extraEnvVars { @@ -10456,7 +10487,7 @@ struct CMUXCLI { tmuxPathPrefix: "cmux-claude-teams", cmuxBinEnvVar: "CMUX_CLAUDE_TEAMS_CMUX_BIN", termOverrideEnvVar: "CMUX_CLAUDE_TEAMS_TERM", - unsetTermProgram: false, + preserveTermProgram: true, extraEnvVars: [ (key: "CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS", value: "1"), ] @@ -11035,7 +11066,6 @@ struct CMUXCLI { tmuxPathPrefix: "cmux-omo", cmuxBinEnvVar: "CMUX_OMO_CMUX_BIN", termOverrideEnvVar: "CMUX_OMO_TERM", - unsetTermProgram: true, extraEnvVars: [(key: "OPENCODE_PORT", value: openCodePort)] ) } @@ -11157,8 +11187,7 @@ struct CMUXCLI { focusedContext: focusedContext, tmuxPathPrefix: "cmux-omx", cmuxBinEnvVar: "CMUX_OMX_CMUX_BIN", - termOverrideEnvVar: "CMUX_OMX_TERM", - unsetTermProgram: true + termOverrideEnvVar: "CMUX_OMX_TERM" ) } @@ -11261,8 +11290,7 @@ struct CMUXCLI { focusedContext: focusedContext, tmuxPathPrefix: "cmux-omc", cmuxBinEnvVar: "CMUX_OMC_CMUX_BIN", - termOverrideEnvVar: "CMUX_OMC_TERM", - unsetTermProgram: true + termOverrideEnvVar: "CMUX_OMC_TERM" ) // omc wraps Claude Code, so it needs the same NODE_OPTIONS restore module guard let restoreModuleURL = try? createClaudeNodeOptionsRestoreModule() else { diff --git a/daemon/remote/cmd/cmuxd-remote/agent_launch.go b/daemon/remote/cmd/cmuxd-remote/agent_launch.go index 282496e8a73d..be565c36ce33 100644 --- a/daemon/remote/cmd/cmuxd-remote/agent_launch.go +++ b/daemon/remote/cmd/cmuxd-remote/agent_launch.go @@ -41,13 +41,13 @@ func runClaudeTeamsRelay(socketPath string, args []string, refreshAddr func() st focused := getFocusedContext(rc) configureAgentEnvironment(agentConfig{ - shimDir: shimDir, - socketPath: socketPath, - focused: focused, - tmuxPathPrefix: "cmux-claude-teams", - cmuxBinEnvVar: "CMUX_CLAUDE_TEAMS_CMUX_BIN", - termEnvVar: "CMUX_CLAUDE_TEAMS_TERM", - unsetTermProgram: false, + shimDir: shimDir, + socketPath: socketPath, + focused: focused, + tmuxPathPrefix: "cmux-claude-teams", + cmuxBinEnvVar: "CMUX_CLAUDE_TEAMS_CMUX_BIN", + termEnvVar: "CMUX_CLAUDE_TEAMS_TERM", + preserveTermProgram: true, extraEnv: map[string]string{ "CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS": "1", }, @@ -96,14 +96,13 @@ func runOMORelay(socketPath string, args []string, refreshAddr func() string) in focused := getFocusedContext(rc) configureAgentEnvironment(agentConfig{ - shimDir: shimDir, - socketPath: socketPath, - focused: focused, - tmuxPathPrefix: "cmux-omo", - cmuxBinEnvVar: "CMUX_OMO_CMUX_BIN", - termEnvVar: "CMUX_OMO_TERM", - unsetTermProgram: true, - extraEnv: map[string]string{}, + shimDir: shimDir, + socketPath: socketPath, + focused: focused, + tmuxPathPrefix: "cmux-omo", + cmuxBinEnvVar: "CMUX_OMO_CMUX_BIN", + termEnvVar: "CMUX_OMO_TERM", + extraEnv: map[string]string{}, }) // Set OPENCODE_PORT if not already set @@ -155,14 +154,13 @@ func runOMXRelay(socketPath string, args []string, refreshAddr func() string) in focused := getFocusedContext(rc) configureAgentEnvironment(agentConfig{ - shimDir: shimDir, - socketPath: socketPath, - focused: focused, - tmuxPathPrefix: "cmux-omx", - cmuxBinEnvVar: "CMUX_OMX_CMUX_BIN", - termEnvVar: "CMUX_OMX_TERM", - unsetTermProgram: true, - extraEnv: map[string]string{}, + shimDir: shimDir, + socketPath: socketPath, + focused: focused, + tmuxPathPrefix: "cmux-omx", + cmuxBinEnvVar: "CMUX_OMX_CMUX_BIN", + termEnvVar: "CMUX_OMX_TERM", + extraEnv: map[string]string{}, }) launchPath, launchArgv := resolveNodeScriptExec(omxPath, args, originalPath, shimDir) @@ -192,14 +190,13 @@ func runOMCRelay(socketPath string, args []string, refreshAddr func() string) in focused := getFocusedContext(rc) configureAgentEnvironment(agentConfig{ - shimDir: shimDir, - socketPath: socketPath, - focused: focused, - tmuxPathPrefix: "cmux-omc", - cmuxBinEnvVar: "CMUX_OMC_CMUX_BIN", - termEnvVar: "CMUX_OMC_TERM", - unsetTermProgram: true, - extraEnv: map[string]string{}, + shimDir: shimDir, + socketPath: socketPath, + focused: focused, + tmuxPathPrefix: "cmux-omc", + cmuxBinEnvVar: "CMUX_OMC_CMUX_BIN", + termEnvVar: "CMUX_OMC_TERM", + extraEnv: map[string]string{}, }) // omc wraps Claude Code, so configure NODE_OPTIONS restore module @@ -434,15 +431,32 @@ func stringFromAny(values ...any) string { // --- Environment configuration --- +// resolveDefaultTerm returns "xterm-ghostty" if its terminfo entry is +// available, otherwise "xterm-256color" — which is universally supported. +// This avoids a race on fresh remote SSH hosts where cmux's terminfo overlay +// installs asynchronously during shell bootstrap (see CLI/cmux.swift around +// the interactiveRemoteTerminalSetupLines block, and the existing test +// tests_v2/test_ssh_remote_shell_integration.py which documents that fresh +// containers don't have the entry preinstalled). +func resolveDefaultTerm() string { + cmd := exec.Command("infocmp", "xterm-ghostty") + cmd.Stdout = nil + cmd.Stderr = nil + if err := cmd.Run(); err == nil { + return "xterm-ghostty" + } + return "xterm-256color" +} + type agentConfig struct { - shimDir string - socketPath string - focused *focusedContext - tmuxPathPrefix string - cmuxBinEnvVar string - termEnvVar string - extraEnv map[string]string - unsetTermProgram bool + shimDir string + socketPath string + focused *focusedContext + tmuxPathPrefix string + cmuxBinEnvVar string + termEnvVar string + extraEnv map[string]string + preserveTermProgram bool } func configureAgentEnvironment(cfg agentConfig) { @@ -475,7 +489,7 @@ func configureAgentEnvironment(cfg agentConfig) { // Terminal settings fakeTerm := os.Getenv(cfg.termEnvVar) if fakeTerm == "" { - fakeTerm = "xterm-ghostty" + fakeTerm = resolveDefaultTerm() } os.Setenv("TERM", fakeTerm) if os.Getenv("COLORTERM") == "" { @@ -486,12 +500,14 @@ func configureAgentEnvironment(cfg agentConfig) { os.Setenv("CMUX_SOCKET_PATH", cfg.socketPath) os.Setenv("CMUX_SOCKET", cfg.socketPath) - // Unset TERM_PROGRAM so apps don't detect the host terminal and - // override tmux-compatible behavior (e.g. opencode switches to - // light theme when it sees TERM_PROGRAM=ghostty). - // Only unset for OMO/OMX/OMC; claude-teams must preserve it - // so Claude Code can detect Ghostty for proper color support. - if cfg.unsetTermProgram { + // opencode-family agents (omo/omx/omc) react to any non-empty TERM_PROGRAM + // by switching to a light theme — they treat the variable as an outer-host + // marker regardless of value. claude-teams must preserve TERM_PROGRAM since + // Claude Code v2.1.112+ crashes during permission escalation when it's + // missing (issue #2947). The zero value (false) keeps the legacy behavior + // of unsetting, so any new agent type defaults to the historically common + // case. + if !cfg.preserveTermProgram { os.Unsetenv("TERM_PROGRAM") } From 4f0242155794422bd7367a445e977cc418945a00 Mon Sep 17 00:00:00 2001 From: Steve Lum Date: Sat, 18 Apr 2026 21:15:31 -0700 Subject: [PATCH 5/5] test: cover claude-teams + opencode env contract and terminfo race MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add comprehensive coverage for configureAgentEnvironment's behavior matrix in cmuxd-remote, plus a sibling Python test for the local Swift CLI's `cmux omo` path. Go (daemon/remote/cmd/cmuxd-remote/agent_launch_test.go): - TestConfigureAgentEnvironment_PreservesTermProgramWhenFlagSet — locks in claude-teams's preserve contract that prevents the Claude Code v2.1.112+ permission-escalation crash from #2947. - TestConfigureAgentEnvironment_OpencodeFamilyUnsetsTermProgram — guards against silent regression of #2516 (opencode flips to a light theme on any non-empty TERM_PROGRAM); without this assertion a future PR could remove the unset side of the asymmetry undetected. - TestConfigureAgentEnvironment_TermEnvVarOverride — asserts the per-agent TERM override env var (e.g. CMUX_CLAUDE_TEAMS_TERM) wins over resolveDefaultTerm's fallback. - TestConfigureAgentEnvironment_COLORTERMPreservedWhenSet — asserts the truecolor fallback never downgrades a caller-provided COLORTERM. - TestConfigureAgentEnvironment_AppliesExtraEnv — sanity check that the extraEnv map applies (regression guard for the field). - TestResolveDefaultTerm_FallsBackWhenInfocmpAbsent — simulates the remote-SSH-bootstrap race (PATH stripped of infocmp) and asserts resolveDefaultTerm returns "xterm-256color" instead of leaving a child process with an unresolvable TERM. - TestResolveDefaultTerm_PrefersXtermGhosttyWhenInfocmpSucceeds — happy-path check, gracefully skips on hosts without the terminfo entry installed. The existing TestConfigureAgentEnvironment in tmux_compat_test.go now also asserts TERM_PROGRAM is unset by default (zero-value preserveTermProgram = legacy behavior), filling a previously-empty assertion gap in the existing test. Python (tests/test_cli_omo_env.py): - New file mirroring tests/test_cli_claude_teams_env.py's fake-shim pattern but for `cmux omo`. Asserts TERM_PROGRAM is unset (the #2516 guard), TERM defaults to xterm-ghostty, and COLORTERM=truecolor is set when the parent had no COLORTERM. Uses the cmux DEV CLI from /tmp/cmux-last-cli-path so it runs against the user's most recent reload.sh build. Co-Authored-By: Claude Opus 4.7 --- .../cmd/cmuxd-remote/agent_launch_test.go | 236 ++++++++++++++++++ .../cmd/cmuxd-remote/tmux_compat_test.go | 6 + tests/test_cli_omo_env.py | 140 +++++++++++ 3 files changed, 382 insertions(+) create mode 100644 daemon/remote/cmd/cmuxd-remote/agent_launch_test.go create mode 100644 tests/test_cli_omo_env.py diff --git a/daemon/remote/cmd/cmuxd-remote/agent_launch_test.go b/daemon/remote/cmd/cmuxd-remote/agent_launch_test.go new file mode 100644 index 000000000000..2a8035d87892 --- /dev/null +++ b/daemon/remote/cmd/cmuxd-remote/agent_launch_test.go @@ -0,0 +1,236 @@ +package main + +import ( + "os" + "os/exec" + "strings" + "testing" +) + +// envSnapshot captures the current values of the named env vars and returns +// a cleanup that restores them. Use `t.Cleanup(envSnapshot(t, ...))` so each +// test's mutations don't leak into siblings. +func envSnapshot(t *testing.T, keys ...string) func() { + t.Helper() + saved := make(map[string]struct { + val string + ok bool + }, len(keys)) + for _, k := range keys { + v, ok := os.LookupEnv(k) + saved[k] = struct { + val string + ok bool + }{v, ok} + } + return func() { + for k, s := range saved { + if s.ok { + _ = os.Setenv(k, s.val) + } else { + _ = os.Unsetenv(k) + } + } + } +} + +// claudeTeamsConfig returns an agentConfig matching the real claude-teams +// relay (see runClaudeTeamsRelay). +func claudeTeamsConfig() agentConfig { + return agentConfig{ + shimDir: "/tmp/test-claude-teams-shim", + socketPath: "127.0.0.1:54321", + focused: nil, + tmuxPathPrefix: "cmux-claude-teams", + cmuxBinEnvVar: "CMUX_CLAUDE_TEAMS_CMUX_BIN", + termEnvVar: "CMUX_CLAUDE_TEAMS_TERM", + preserveTermProgram: true, + extraEnv: map[string]string{ + "CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS": "1", + }, + } +} + +// opencodeFamilyConfig returns an agentConfig matching the real omo/omx/omc +// relays — the preserveTermProgram flag is left at the zero value (false), +// which yields the legacy "unset TERM_PROGRAM" behavior. opencode-family +// agents react to any non-empty TERM_PROGRAM by switching to a light theme +// (regression #2516), so the unset is required. +func opencodeFamilyConfig() agentConfig { + return agentConfig{ + shimDir: "/tmp/test-omo-shim", + socketPath: "127.0.0.1:54321", + focused: nil, + tmuxPathPrefix: "cmux-omo", + cmuxBinEnvVar: "CMUX_OMO_CMUX_BIN", + termEnvVar: "CMUX_OMO_TERM", + extraEnv: map[string]string{}, + } +} + +// TestConfigureAgentEnvironment_PreservesTermProgramWhenFlagSet exercises the +// claude-teams contract: TERM_PROGRAM must survive configureAgentEnvironment +// because Claude Code v2.1.112+ crashes during permission escalation when +// the variable is missing (issue #2947). +func TestConfigureAgentEnvironment_PreservesTermProgramWhenFlagSet(t *testing.T) { + t.Cleanup(envSnapshot(t, + "TERM_PROGRAM", "TERM", "COLORTERM", "PATH", "TMUX", "TMUX_PANE", + "CMUX_CLAUDE_TEAMS_CMUX_BIN", "CMUX_SOCKET_PATH", "CMUX_SOCKET", + "CMUX_CLAUDE_TEAMS_TERM", "CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS", + )) + + if err := os.Setenv("TERM_PROGRAM", "ghostty"); err != nil { + t.Fatalf("setenv TERM_PROGRAM: %v", err) + } + + configureAgentEnvironment(claudeTeamsConfig()) + + got, ok := os.LookupEnv("TERM_PROGRAM") + if !ok { + t.Fatal("TERM_PROGRAM was unset; claude-teams must preserve it (#2947)") + } + if got != "ghostty" { + t.Errorf("TERM_PROGRAM = %q, want %q (preserved unmodified)", got, "ghostty") + } +} + +// TestConfigureAgentEnvironment_OpencodeFamilyUnsetsTermProgram exercises the +// opencode-family contract: TERM_PROGRAM must NOT leak through, otherwise +// opencode flips to a light theme (regression #2516). +func TestConfigureAgentEnvironment_OpencodeFamilyUnsetsTermProgram(t *testing.T) { + t.Cleanup(envSnapshot(t, + "TERM_PROGRAM", "TERM", "COLORTERM", "PATH", "TMUX", "TMUX_PANE", + "CMUX_OMO_CMUX_BIN", "CMUX_SOCKET_PATH", "CMUX_SOCKET", + "CMUX_OMO_TERM", + )) + + if err := os.Setenv("TERM_PROGRAM", "ghostty"); err != nil { + t.Fatalf("setenv TERM_PROGRAM: %v", err) + } + + configureAgentEnvironment(opencodeFamilyConfig()) + + if v, ok := os.LookupEnv("TERM_PROGRAM"); ok { + t.Errorf("TERM_PROGRAM = %q, should be unset (prevents #2516 light-theme regression)", v) + } +} + +// TestConfigureAgentEnvironment_TermEnvVarOverride asserts that the +// agent-specific override env var (e.g. CMUX_CLAUDE_TEAMS_TERM) wins over +// the built-in default. +func TestConfigureAgentEnvironment_TermEnvVarOverride(t *testing.T) { + t.Cleanup(envSnapshot(t, + "TERM", "CMUX_CLAUDE_TEAMS_TERM", "COLORTERM", "PATH", "TMUX", + "TMUX_PANE", "CMUX_CLAUDE_TEAMS_CMUX_BIN", "CMUX_SOCKET_PATH", + "CMUX_SOCKET", "TERM_PROGRAM", + )) + + if err := os.Setenv("CMUX_CLAUDE_TEAMS_TERM", "screen-256color"); err != nil { + t.Fatalf("setenv CMUX_CLAUDE_TEAMS_TERM: %v", err) + } + + configureAgentEnvironment(claudeTeamsConfig()) + + if got := os.Getenv("TERM"); got != "screen-256color" { + t.Errorf("TERM = %q, want %q (override should win over default)", got, "screen-256color") + } +} + +// TestConfigureAgentEnvironment_COLORTERMPreservedWhenSet asserts the +// truecolor fallback only runs when COLORTERM is empty — we should never +// downgrade an explicitly-set value. +func TestConfigureAgentEnvironment_COLORTERMPreservedWhenSet(t *testing.T) { + t.Cleanup(envSnapshot(t, + "COLORTERM", "TERM", "PATH", "TMUX", "TMUX_PANE", + "CMUX_CLAUDE_TEAMS_CMUX_BIN", "CMUX_SOCKET_PATH", "CMUX_SOCKET", + "CMUX_CLAUDE_TEAMS_TERM", "TERM_PROGRAM", + )) + + if err := os.Setenv("COLORTERM", "256color"); err != nil { + t.Fatalf("setenv COLORTERM: %v", err) + } + + configureAgentEnvironment(claudeTeamsConfig()) + + if got := os.Getenv("COLORTERM"); got != "256color" { + t.Errorf("COLORTERM = %q, want %q (must not overwrite caller-provided value)", got, "256color") + } +} + +// TestConfigureAgentEnvironment_AppliesExtraEnv asserts the extraEnv map is +// applied LAST (after all other env mutations) so callers can override +// anything the function sets — including, in principle, TERM_PROGRAM itself +// for opt-in test/debug scenarios. +func TestConfigureAgentEnvironment_AppliesExtraEnv(t *testing.T) { + t.Cleanup(envSnapshot(t, + "COLORTERM", "TERM", "PATH", "TMUX", "TMUX_PANE", + "CMUX_CLAUDE_TEAMS_CMUX_BIN", "CMUX_SOCKET_PATH", "CMUX_SOCKET", + "CMUX_CLAUDE_TEAMS_TERM", "TERM_PROGRAM", + "CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS", + )) + + configureAgentEnvironment(claudeTeamsConfig()) + + if got := os.Getenv("CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS"); got != "1" { + t.Errorf("CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS = %q, want 1", got) + } +} + +// hasInfocmp reports whether the test host has the `infocmp` binary +// available — needed to guard tests that depend on it being present. +func hasInfocmp() bool { + for _, dir := range strings.Split(os.Getenv("PATH"), string(os.PathListSeparator)) { + if dir == "" { + continue + } + if info, err := os.Stat(dir + "/infocmp"); err == nil && !info.IsDir() { + return true + } + } + return false +} + +// TestResolveDefaultTerm_FallsBackWhenInfocmpAbsent simulates the case where +// the xterm-ghostty terminfo entry isn't installed (or `infocmp` itself is +// missing). The fallback to xterm-256color avoids ncurses errors on fresh +// remote SSH hosts where cmux's terminfo overlay installs asynchronously +// during shell bootstrap. +func TestResolveDefaultTerm_FallsBackWhenInfocmpAbsent(t *testing.T) { + t.Cleanup(envSnapshot(t, "PATH")) + + // Empty PATH ⇒ exec.Command("infocmp") cannot find the binary, which + // returns the same error class as a real "infocmp xterm-ghostty" + // failure. Either way, resolveDefaultTerm must fall back. + if err := os.Setenv("PATH", ""); err != nil { + t.Fatalf("setenv PATH: %v", err) + } + + got := resolveDefaultTerm() + if got != "xterm-256color" { + t.Errorf("resolveDefaultTerm() = %q, want %q (fallback when infocmp can't resolve xterm-ghostty)", got, "xterm-256color") + } +} + +// TestResolveDefaultTerm_PrefersXtermGhosttyWhenInfocmpSucceeds asserts the +// happy-path return value when the terminfo entry IS installed. Skipped on +// hosts that don't have either `infocmp` or the xterm-ghostty entry. +func TestResolveDefaultTerm_PrefersXtermGhosttyWhenInfocmpSucceeds(t *testing.T) { + if !hasInfocmp() { + t.Skip("infocmp not in PATH — fallback path is covered by TestResolveDefaultTerm_FallsBackWhenInfocmpAbsent") + } + + // Probe: does this host have xterm-ghostty terminfo? Skip if not — the + // happy path requires a real terminfo entry and we don't want to depend + // on test-host configuration. + probe := exec.Command("infocmp", "xterm-ghostty") + probe.Stdout = nil + probe.Stderr = nil + if err := probe.Run(); err != nil { + t.Skip("xterm-ghostty terminfo not installed on this host — fallback path is covered separately") + } + + got := resolveDefaultTerm() + if got != "xterm-ghostty" { + t.Errorf("resolveDefaultTerm() = %q, want %q (terminfo present)", got, "xterm-ghostty") + } +} diff --git a/daemon/remote/cmd/cmuxd-remote/tmux_compat_test.go b/daemon/remote/cmd/cmuxd-remote/tmux_compat_test.go index 850f0f7fdecc..b71741bc5c66 100644 --- a/daemon/remote/cmd/cmuxd-remote/tmux_compat_test.go +++ b/daemon/remote/cmd/cmuxd-remote/tmux_compat_test.go @@ -364,6 +364,12 @@ func TestConfigureAgentEnvironment(t *testing.T) { if os.Getenv("COLORTERM") != "truecolor" { t.Errorf("COLORTERM = %q, want truecolor", os.Getenv("COLORTERM")) } + // Verify TERM_PROGRAM was unset (zero-value preserveTermProgram = legacy + // "unset" behavior; the explicit preserve case is covered in + // agent_launch_test.go). + if v, ok := os.LookupEnv("TERM_PROGRAM"); ok { + t.Errorf("TERM_PROGRAM should be unset, got %q", v) + } // Verify workspace/surface IDs if os.Getenv("CMUX_WORKSPACE_ID") != "ws-abc" { t.Errorf("CMUX_WORKSPACE_ID = %q", os.Getenv("CMUX_WORKSPACE_ID")) diff --git a/tests/test_cli_omo_env.py b/tests/test_cli_omo_env.py new file mode 100644 index 000000000000..ce037bcee5fb --- /dev/null +++ b/tests/test_cli_omo_env.py @@ -0,0 +1,140 @@ +#!/usr/bin/env python3 +""" +Regression test: `cmux omo` injects the tmux-style auto-mode env *and* unsets +TERM_PROGRAM (otherwise opencode flips to a light theme — regression #2516). + +This is the sibling of test_cli_claude_teams_env.py for the opencode-family +agent path. Locks in the asymmetric TERM_PROGRAM contract: claude-teams +preserves it (issue #2947), opencode-family agents must NOT have it leak +through. +""" + +from __future__ import annotations + +import os +import subprocess +import tempfile +from pathlib import Path + +from claude_teams_test_utils import resolve_cmux_cli + + +def make_executable(path: Path, content: str) -> None: + path.write_text(content, encoding="utf-8") + path.chmod(0o755) + + +def read_text(path: Path) -> str: + if not path.exists(): + return "" + return path.read_text(encoding="utf-8").strip() + + +def run_omo(cli_path: str, base_env: dict[str, str]) -> subprocess.CompletedProcess[str]: + with tempfile.TemporaryDirectory(prefix="cmux-omo-env-") as td: + tmp = Path(td) + real_bin = tmp / "real-bin" + real_bin.mkdir(parents=True, exist_ok=True) + + term_log = tmp / "term.log" + term_program_log = tmp / "term-program.log" + colorterm_log = tmp / "colorterm.log" + cmux_bin_log = tmp / "cmux-bin.log" + argv_log = tmp / "argv.log" + fake_home = tmp / "home" + fake_home.mkdir(parents=True, exist_ok=True) + + # Fake opencode shim — logs env on every invocation (the final `--version` + # call wins, overwriting earlier writes from any plugin-install probes). + make_executable( + real_bin / "opencode", + """#!/usr/bin/env bash +set -euo pipefail +printf '%s\\n' "${TERM-__UNSET__}" > "$FAKE_TERM_LOG" +printf '%s\\n' "${TERM_PROGRAM-__UNSET__}" > "$FAKE_TERM_PROGRAM_LOG" +printf '%s\\n' "${COLORTERM-__UNSET__}" > "$FAKE_COLORTERM_LOG" +printf '%s\\n' "${CMUX_OMO_CMUX_BIN-__UNSET__}" > "$FAKE_CMUX_BIN_LOG" +printf '%s\\n' "$@" > "$FAKE_ARGV_LOG" +exit 0 +""", + ) + + env = base_env.copy() + env["HOME"] = str(fake_home) + env["PATH"] = f"{real_bin}:{base_env.get('PATH', '/usr/bin:/bin')}" + env["FAKE_TERM_LOG"] = str(term_log) + env["FAKE_TERM_PROGRAM_LOG"] = str(term_program_log) + env["FAKE_COLORTERM_LOG"] = str(colorterm_log) + env["FAKE_CMUX_BIN_LOG"] = str(cmux_bin_log) + env["FAKE_ARGV_LOG"] = str(argv_log) + # Parent-side TERM_PROGRAM and TERM are intentionally non-empty to + # exercise the "must be unset" / "must not leak through" path. + env["TERM_PROGRAM"] = "ghostty" + env["TERM"] = "xterm-256color" + # Ensure COLORTERM is unset so we exercise the truecolor fallback. + env.pop("COLORTERM", None) + + proc = subprocess.run( + [cli_path, "omo", "--version"], + capture_output=True, + text=True, + check=False, + env=env, + timeout=30, + ) + + if proc.returncode != 0: + print(f"FAIL: `cmux omo --version` exited non-zero: {proc.returncode}") + print(f"stdout={proc.stdout.strip()}") + print(f"stderr={proc.stderr.strip()}") + raise SystemExit(1) + + cmux_bin_value = read_text(cmux_bin_log) + if not cmux_bin_value or cmux_bin_value == "__UNSET__": + print("FAIL: missing CMUX_OMO_CMUX_BIN — the omo env shim did not run") + print(f"stdout={proc.stdout.strip()}") + print(f"stderr={proc.stderr.strip()}") + raise SystemExit(1) + + term_value = read_text(term_log) + if term_value != "xterm-ghostty": + print( + f"FAIL: expected TERM=xterm-ghostty for cmux omo, got {term_value!r}" + ) + raise SystemExit(1) + + term_program_value = read_text(term_program_log) + if term_program_value != "__UNSET__": + print( + "FAIL: expected TERM_PROGRAM to be unset for cmux omo " + "(prevents opencode light-theme regression of #2516), " + f"got {term_program_value!r}" + ) + raise SystemExit(1) + + colorterm_value = read_text(colorterm_log) + if colorterm_value != "truecolor": + print( + f"FAIL: expected COLORTERM=truecolor fallback when parent had none, " + f"got {colorterm_value!r}" + ) + raise SystemExit(1) + + return proc + + +def main() -> int: + try: + cli_path = resolve_cmux_cli() + except Exception as exc: + print(f"FAIL: {exc}") + return 1 + + base_env = os.environ.copy() + run_omo(cli_path, base_env) + print("OK: cmux omo unsets TERM_PROGRAM, defaults TERM=xterm-ghostty, sets COLORTERM=truecolor") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())