From d1953b28f5f3d9dfaa78c93e78814084b2fe8d69 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sat, 20 Jun 2026 02:28:56 -0700 Subject: [PATCH 01/19] Add failing test: respawn-pane must run command through a login shell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Regression coverage for #6447. Claude Code 2.1.183 launches agent-team teammates with `respawn-pane -k -- "cd && env … …"`. cmux forwards that command to the surface as the pane's process command, which on macOS Ghostty execs via `exec -l ` — that only works for a single executable, so a `cd …&&… claude` shell expression makes Ghostty try to exec the `cd` builtin as a binary, the pane dies immediately, and the teammate never gets a visible pane. This test asserts respawn-pane forwards a login-shell-invoked command while keeping tmux_start_command raw. It fails until the fix lands. Co-Authored-By: Claude Opus 4.8 --- cmuxTests/CLITmuxCompatRemoteSplitTests.swift | 81 +++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/cmuxTests/CLITmuxCompatRemoteSplitTests.swift b/cmuxTests/CLITmuxCompatRemoteSplitTests.swift index 0d29bc711c38..5d0af154c651 100644 --- a/cmuxTests/CLITmuxCompatRemoteSplitTests.swift +++ b/cmuxTests/CLITmuxCompatRemoteSplitTests.swift @@ -73,6 +73,87 @@ import Testing #expect(!result.stderr.contains("already applied"), Comment(rawValue: result.stderr)) } + /// Regression for #6447: Claude Code 2.1.183 launches agent-team teammates by + /// creating a placeholder pane (`split-window -- cat`) and then running the real + /// teammate command via `respawn-pane -k -- "cd && env … …"`. cmux + /// forwards that command to the surface as the pane's process command. On macOS, + /// Ghostty execs the surface command via `exec -l ` (ghostty + /// src/termio/Exec.zig), which only works for a single executable — a bare shell + /// expression like `cd … && … claude` makes it try to exec the `cd` builtin as a + /// binary, the pane exits immediately, and the teammate never gets a visible pane + /// (it falls back to in-process). The fix runs the command through a login shell so + /// Ghostty execs the shell, not the expression. `tmux_start_command` must stay the + /// raw command so `#{pane_start_command}` / OMX-HUD detection keep reporting it. + @Test func respawnPaneRunsShellCommandThroughLoginShell() throws { + let cliPath = try BundledCLITestSupport.bundledCLIPath(for: CLITmuxCompatRemoteSplitBundleToken.self) + let tmpDir = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-tmux-compat-respawn-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: tmpDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tmpDir) } + + let socketPath = Self.makeSocketPath("tmuxresp") + let listenerFD = try Self.bindUnixSocket(at: socketPath) + defer { + Darwin.close(listenerFD) + unlink(socketPath) + } + + let workspaceId = "11111111-1111-1111-1111-111111111111" + let surfaceId = "22222222-2222-2222-2222-222222222222" + let teammateCommand = "cd /tmp/work && env CLAUDECODE=1 /opt/claude --agent-id alice@team --agent-name alice" + let state = ServerState() + let handled = Self.startMockServer(listenerFD: listenerFD, state: state) { line in + guard let payload = Self.jsonObject(line), + let id = payload["id"] as? String, + let method = payload["method"] as? String else { + return Self.malformedRequestResponse(raw: line) + } + switch method { + case "surface.list": + return Self.v2Response(id: id, ok: true, result: [ + "surfaces": [["id": surfaceId, "ref": "surface:1", "index": 0, "focused": true]], + ]) + case "surface.respawn": + let params = payload["params"] as? [String: Any] ?? [:] + let command = params["command"] as? String ?? "" + let startCommand = params["tmux_start_command"] as? String ?? "" + state.expect( + command.hasPrefix("/bin/zsh -lc "), + "surface.respawn command must run through a login shell so Ghostty `exec -l` execs the shell, got: \(command)" + ) + state.expect( + command.contains(teammateCommand), + "shell-invoked command must carry the original teammate command verbatim, got: \(command)" + ) + state.expect( + startCommand == teammateCommand, + "tmux_start_command must stay the raw command for display/OMX detection, got: \(startCommand)" + ) + return Self.v2Response(id: id, ok: true, result: [:]) + default: + return Self.v2Response(id: id, ok: false, error: ["code": "unsupported", "message": method]) + } + } + + let result = Self.runProcess( + executablePath: cliPath, + arguments: ["__tmux-compat", "respawn-pane", "-k", "--", teammateCommand], + environment: [ + "CMUX_SOCKET_PATH": socketPath, + "CMUX_WORKSPACE_ID": workspaceId, + "CMUX_SURFACE_ID": surfaceId, + "HOME": tmpDir.path, + "PATH": ProcessInfo.processInfo.environment["PATH"] ?? "/usr/bin:/bin", + "CMUX_CLI_SENTRY_DISABLED": "1", + ], + timeout: 30 + ) + #expect(handled.wait(timeout: .now() + 30) == .success) + #expect(state.errorSnapshot() == []) + #expect(!result.timedOut, Comment(rawValue: result.stderr)) + #expect(result.status == 0, Comment(rawValue: result.stderr)) + } + private final class CLITmuxCompatRemoteSplitBundleToken {} private final class ServerState: @unchecked Sendable { From 44360303d7e26a70cb654ff43bcdb6a2fe439417 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sat, 20 Jun 2026 02:35:23 -0700 Subject: [PATCH 02/19] Fix #6447: run tmux respawn-pane shell-commands through a login shell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude Code 2.1.183 changed how agent-team teammates open panes: instead of `split-window` + `send-keys` (which typed the command into a live shell), it now creates a placeholder pane (`split-window -- cat`) and runs the real teammate command via `respawn-pane -k -- "cd && env … …"`. cmux forwarded that command to the surface as the pane's process command. On macOS, Ghostty execs the surface command via `exec -l ` (ghostty/src/termio/Exec.zig), which only works for a single executable. The teammate command is a shell expression, so `exec -l cd && …` tried to exec the `cd` builtin as a binary, failed, and the pane exited before Claude ever started — so teammates silently fell back to in-process with no visible pane. Claude Code 2.1.181 was unaffected because it typed the command into a running shell. respawn-pane now rewrites shell-expression commands to run through `/bin/zsh -lc ''` (matching real tmux's `$SHELL -c` semantics) so Ghostty execs the shell. Commands that are already a single executable — including the `/bin/sh -c "…"` form OMO uses — are left unchanged so they are not double-wrapped. `tmux_start_command` stays the raw command so `#{pane_start_command}` / OMX-HUD detection are unaffected. Co-Authored-By: Claude Opus 4.8 --- CLI/CMUXCLI+TmuxCompatSupport.swift | 47 +++++++++++++++++++++++++++++ CLI/cmux.swift | 10 +++++- 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/CLI/CMUXCLI+TmuxCompatSupport.swift b/CLI/CMUXCLI+TmuxCompatSupport.swift index 905e16faa471..7d2f9f274824 100644 --- a/CLI/CMUXCLI+TmuxCompatSupport.swift +++ b/CLI/CMUXCLI+TmuxCompatSupport.swift @@ -65,6 +65,53 @@ extension CMUXCLI { return commandText.isEmpty ? nil : commandText } + /// Shell builtins that cannot be exec'd as a standalone binary, so a command + /// starting with one must run through a shell. + static let tmuxShellExecBuiltins: Set = [ + "cd", "exec", "eval", "source", ".", "export", "set", "unset", + "alias", "unalias", "pushd", "popd", "umask", "ulimit", ":", + "local", "readonly", "let", "trap", + ] + + /// Top-level shell control/redirection operators. Their presence (as a + /// whitespace-separated token) means the command is a shell expression rather + /// than a single program invocation. + static let tmuxShellControlOperators: Set = [ + "&&", "||", ";", ";;", "|", "|&", "&", ">", ">>", "<", + "<<", "<<<", "2>", "2>>", "2>&1", ">&", "<&", "&>", "&>>", + ] + + /// Whether a tmux shell-command must run through a shell rather than being + /// exec'd directly. True when it starts with a shell builtin (e.g. `cd`) or + /// contains a top-level control operator (e.g. `&&`). + func tmuxCommandRequiresLoginShell(_ command: String) -> Bool { + let words = tmuxShellWords(command) + guard let first = words.first else { return false } + if Self.tmuxShellExecBuiltins.contains(first) { return true } + return words.contains { Self.tmuxShellControlOperators.contains($0) } + } + + /// Returns a pane start-command that the surface can exec correctly. + /// + /// cmux hands a respawn/start command to the surface as the pane's process + /// command. On macOS, Ghostty execs that command via `exec -l ` + /// (see ghostty/src/termio/Exec.zig), which only works when `` is a + /// single executable (optionally with arguments). A bare shell expression — + /// Claude Code agent-team teammates respawn with `cd && env … …` + /// — makes `exec -l cd …` try to exec the `cd` builtin as a binary; it fails + /// and the pane exits before the real command runs, so Claude Code 2.1.183 + /// teammates never opened a split pane (issue #6447). Such commands are + /// rewritten to run through `/bin/zsh -lc`, matching real tmux's `$SHELL -c` + /// semantics, so Ghostty execs the shell rather than a builtin/expression. + /// Commands that are already a single executable (including the + /// `/bin/sh -c "…"` form OMO uses) are returned unchanged so they are not + /// needlessly double-wrapped. + func tmuxShellInvokedStartCommand(_ command: String) -> String { + let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, tmuxCommandRequiresLoginShell(trimmed) else { return command } + return "/bin/zsh -lc \(tmuxShellQuote(trimmed))" + } + func tmuxShellWords(_ commandText: String) -> [String] { var words: [String] = [] var current = "" diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 15f2cb2726d1..2f76989a5bb1 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -21904,10 +21904,18 @@ struct CMUXCLI { client: client ) ?? "exec ${SHELL:-/bin/sh} -l" } + // Run the respawn command through a login shell. The surface execs + // its process command via Ghostty's macOS `exec -l `, which + // only works for a single executable; tmux respawn shell-commands are + // arbitrary shell expressions (Claude Code teammates use + // `cd && env … …`), so they must go through a shell or + // the pane dies before the command runs (#6447). Keep + // `tmux_start_command` raw so `#{pane_start_command}` / OMX-HUD + // detection still see the original command. var params: [String: Any] = [ "workspace_id": target.workspaceId, "surface_id": target.surfaceId, - "command": commandText, + "command": tmuxShellInvokedStartCommand(commandText), "tmux_start_command": commandText ] if let cwd = parsed.value("-c")?.trimmingCharacters(in: .whitespacesAndNewlines), From 46c406860cf17e1c93dfbd683c41142abe5ad5d3 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sat, 20 Jun 2026 02:51:34 -0700 Subject: [PATCH 03/19] Address autoreview: char-level operator detection + use $SHELL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two follow-ups from review of the #6447 fix: - Detect shell operators at the character level (respecting quotes) instead of only matching whitespace-separated `tmuxShellWords` tokens, so commands with operators that lack surrounding spaces (`echo a;echo b`, `a&&b`) are still recognized as shell expressions and wrapped. - Run the wrapped command through `${SHELL:-/bin/zsh}` (resolved by Ghostty's shell wrapper at exec time) instead of hardcoding zsh, matching tmux's configured-shell semantics. Single-executable commands (including OMO's `/bin/sh -c "…"`) still match no builtin/operator and are forwarded unchanged. Expanded the regression test to cover the spaced operator, non-spaced operator, plain-executable, and already-shell-invoked cases. Co-Authored-By: Claude Opus 4.8 --- CLI/CMUXCLI+TmuxCompatSupport.swift | 53 +++++-- cmuxTests/CLITmuxCompatRemoteSplitTests.swift | 131 ++++++++++++++---- 2 files changed, 140 insertions(+), 44 deletions(-) diff --git a/CLI/CMUXCLI+TmuxCompatSupport.swift b/CLI/CMUXCLI+TmuxCompatSupport.swift index 7d2f9f274824..6f7f5093e760 100644 --- a/CLI/CMUXCLI+TmuxCompatSupport.swift +++ b/CLI/CMUXCLI+TmuxCompatSupport.swift @@ -73,22 +73,46 @@ extension CMUXCLI { "local", "readonly", "let", "trap", ] - /// Top-level shell control/redirection operators. Their presence (as a - /// whitespace-separated token) means the command is a shell expression rather - /// than a single program invocation. - static let tmuxShellControlOperators: Set = [ - "&&", "||", ";", ";;", "|", "|&", "&", ">", ">>", "<", - "<<", "<<<", "2>", "2>>", "2>&1", ">&", "<&", "&>", "&>>", + /// Unquoted shell metacharacters that make a command a shell expression + /// (control operators, pipes, redirections, substitutions, multi-line) + /// rather than a single program invocation. Detected at the character level + /// so operators without surrounding whitespace (`a&&b`, `a;b`) are caught. + static let tmuxShellMetacharacters: Set = [ + "&", "|", ";", "<", ">", "$", "`", "\n", ] /// Whether a tmux shell-command must run through a shell rather than being /// exec'd directly. True when it starts with a shell builtin (e.g. `cd`) or - /// contains a top-level control operator (e.g. `&&`). + /// contains an unquoted shell metacharacter (e.g. `&&`, `;`, `$`). func tmuxCommandRequiresLoginShell(_ command: String) -> Bool { - let words = tmuxShellWords(command) - guard let first = words.first else { return false } - if Self.tmuxShellExecBuiltins.contains(first) { return true } - return words.contains { Self.tmuxShellControlOperators.contains($0) } + if let first = tmuxShellWords(command).first, Self.tmuxShellExecBuiltins.contains(first) { + return true + } + var inSingleQuote = false + var inDoubleQuote = false + var escaping = false + for character in command { + if escaping { + escaping = false + continue + } + if character == "\\" && !inSingleQuote { + escaping = true + continue + } + if character == "'" && !inDoubleQuote { + inSingleQuote.toggle() + continue + } + if character == "\"" && !inSingleQuote { + inDoubleQuote.toggle() + continue + } + if !inSingleQuote, !inDoubleQuote, Self.tmuxShellMetacharacters.contains(character) { + return true + } + } + return false } /// Returns a pane start-command that the surface can exec correctly. @@ -101,15 +125,16 @@ extension CMUXCLI { /// — makes `exec -l cd …` try to exec the `cd` builtin as a binary; it fails /// and the pane exits before the real command runs, so Claude Code 2.1.183 /// teammates never opened a split pane (issue #6447). Such commands are - /// rewritten to run through `/bin/zsh -lc`, matching real tmux's `$SHELL -c` - /// semantics, so Ghostty execs the shell rather than a builtin/expression. + /// rewritten to run through `${SHELL:-/bin/zsh} -lc` (the user's shell, + /// resolved when Ghostty execs the wrapper), matching real tmux's `$SHELL -c` + /// semantics so Ghostty execs the shell rather than a builtin/expression. /// Commands that are already a single executable (including the /// `/bin/sh -c "…"` form OMO uses) are returned unchanged so they are not /// needlessly double-wrapped. func tmuxShellInvokedStartCommand(_ command: String) -> String { let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty, tmuxCommandRequiresLoginShell(trimmed) else { return command } - return "/bin/zsh -lc \(tmuxShellQuote(trimmed))" + return "${SHELL:-/bin/zsh} -lc \(tmuxShellQuote(trimmed))" } func tmuxShellWords(_ commandText: String) -> [String] { diff --git a/cmuxTests/CLITmuxCompatRemoteSplitTests.swift b/cmuxTests/CLITmuxCompatRemoteSplitTests.swift index 5d0af154c651..fb8eace5f16f 100644 --- a/cmuxTests/CLITmuxCompatRemoteSplitTests.swift +++ b/cmuxTests/CLITmuxCompatRemoteSplitTests.swift @@ -73,18 +73,35 @@ import Testing #expect(!result.stderr.contains("already applied"), Comment(rawValue: result.stderr)) } - /// Regression for #6447: Claude Code 2.1.183 launches agent-team teammates by - /// creating a placeholder pane (`split-window -- cat`) and then running the real - /// teammate command via `respawn-pane -k -- "cd && env … …"`. cmux - /// forwards that command to the surface as the pane's process command. On macOS, - /// Ghostty execs the surface command via `exec -l ` (ghostty - /// src/termio/Exec.zig), which only works for a single executable — a bare shell - /// expression like `cd … && … claude` makes it try to exec the `cd` builtin as a - /// binary, the pane exits immediately, and the teammate never gets a visible pane - /// (it falls back to in-process). The fix runs the command through a login shell so - /// Ghostty execs the shell, not the expression. `tmux_start_command` must stay the - /// raw command so `#{pane_start_command}` / OMX-HUD detection keep reporting it. - @Test func respawnPaneRunsShellCommandThroughLoginShell() throws { + private final class CapturedRespawn: @unchecked Sendable { + private let lock = NSLock() + private var commandValue: String? + private var startCommandValue: String? + + func record(command: String?, startCommand: String?) { + lock.lock() + commandValue = command + startCommandValue = startCommand + lock.unlock() + } + + var command: String? { + lock.lock(); defer { lock.unlock() } + return commandValue + } + + var startCommand: String? { + lock.lock(); defer { lock.unlock() } + return startCommandValue + } + } + + /// Drives `__tmux-compat respawn-pane -k -- ` against a mock socket + /// and returns the `command` / `tmux_start_command` forwarded to + /// `surface.respawn`. + private func respawnPaneForwardedCommand( + _ command: String + ) throws -> (command: String, startCommand: String) { let cliPath = try BundledCLITestSupport.bundledCLIPath(for: CLITmuxCompatRemoteSplitBundleToken.self) let tmpDir = FileManager.default.temporaryDirectory .appendingPathComponent("cmux-tmux-compat-respawn-\(UUID().uuidString)", isDirectory: true) @@ -100,13 +117,13 @@ import Testing let workspaceId = "11111111-1111-1111-1111-111111111111" let surfaceId = "22222222-2222-2222-2222-222222222222" - let teammateCommand = "cd /tmp/work && env CLAUDECODE=1 /opt/claude --agent-id alice@team --agent-name alice" + let captured = CapturedRespawn() let state = ServerState() - let handled = Self.startMockServer(listenerFD: listenerFD, state: state) { line in - guard let payload = Self.jsonObject(line), + let handled = Self.startMockServer(listenerFD: listenerFD, state: state) { reqLine in + guard let payload = Self.jsonObject(reqLine), let id = payload["id"] as? String, let method = payload["method"] as? String else { - return Self.malformedRequestResponse(raw: line) + return Self.malformedRequestResponse(raw: reqLine) } switch method { case "surface.list": @@ -115,19 +132,9 @@ import Testing ]) case "surface.respawn": let params = payload["params"] as? [String: Any] ?? [:] - let command = params["command"] as? String ?? "" - let startCommand = params["tmux_start_command"] as? String ?? "" - state.expect( - command.hasPrefix("/bin/zsh -lc "), - "surface.respawn command must run through a login shell so Ghostty `exec -l` execs the shell, got: \(command)" - ) - state.expect( - command.contains(teammateCommand), - "shell-invoked command must carry the original teammate command verbatim, got: \(command)" - ) - state.expect( - startCommand == teammateCommand, - "tmux_start_command must stay the raw command for display/OMX detection, got: \(startCommand)" + captured.record( + command: params["command"] as? String, + startCommand: params["tmux_start_command"] as? String ) return Self.v2Response(id: id, ok: true, result: [:]) default: @@ -137,7 +144,7 @@ import Testing let result = Self.runProcess( executablePath: cliPath, - arguments: ["__tmux-compat", "respawn-pane", "-k", "--", teammateCommand], + arguments: ["__tmux-compat", "respawn-pane", "-k", "--", command], environment: [ "CMUX_SOCKET_PATH": socketPath, "CMUX_WORKSPACE_ID": workspaceId, @@ -152,6 +159,70 @@ import Testing #expect(state.errorSnapshot() == []) #expect(!result.timedOut, Comment(rawValue: result.stderr)) #expect(result.status == 0, Comment(rawValue: result.stderr)) + guard let forwarded = captured.command, let startCommand = captured.startCommand else { + throw CLITmuxCompatRespawnTestError.noRespawnCommand + } + return (forwarded, startCommand) + } + + /// Regression for #6447: Claude Code 2.1.183 launches agent-team teammates by + /// creating a placeholder pane (`split-window -- cat`) and then running the real + /// teammate command via `respawn-pane -k -- "cd && env … …"`. cmux + /// forwards that command to the surface as the pane's process command. On macOS, + /// Ghostty execs the surface command via `exec -l ` (ghostty + /// src/termio/Exec.zig), which only works for a single executable — a bare shell + /// expression like `cd … && … claude` makes it try to exec the `cd` builtin as a + /// binary, the pane exits immediately, and the teammate never gets a visible pane + /// (it falls back to in-process). The fix runs shell-expression commands through a + /// login shell so Ghostty execs the shell, not the expression, while leaving + /// single-executable commands untouched. `tmux_start_command` stays the raw + /// command so `#{pane_start_command}` / OMX-HUD detection keep reporting it. + @Test func respawnPaneRunsShellExpressionsThroughLoginShell() throws { + let shellPrefix = "${SHELL:-/bin/zsh} -lc " + + // Claude Code teammate command: spaced `cd … && … claude` shell expression. + let teammate = "cd /tmp/work && env CLAUDECODE=1 /opt/claude --agent-id alice@team --agent-name alice" + let teammateResult = try respawnPaneForwardedCommand(teammate) + #expect( + teammateResult.command.hasPrefix(shellPrefix), + "teammate command must run through a login shell, got: \(teammateResult.command)" + ) + #expect( + teammateResult.command.contains(teammate), + "shell-invoked command must carry the original command verbatim, got: \(teammateResult.command)" + ) + #expect( + teammateResult.startCommand == teammate, + "tmux_start_command must stay the raw command for display/OMX detection, got: \(teammateResult.startCommand)" + ) + + // Operator without surrounding whitespace must still be detected as a shell + // expression and wrapped. + let noSpaceOperator = try respawnPaneForwardedCommand("echo one;echo two") + #expect( + noSpaceOperator.command.hasPrefix(shellPrefix), + "non-whitespace-separated operator must still be wrapped, got: \(noSpaceOperator.command)" + ) + + // A single executable (with args) execs directly, so it is forwarded + // unchanged rather than needlessly wrapped. + let plainExecutable = try respawnPaneForwardedCommand("/opt/claude --resume abc --model sonnet") + #expect( + plainExecutable.command == "/opt/claude --resume abc --model sonnet", + "single executable must be forwarded unchanged, got: \(plainExecutable.command)" + ) + + // The `/bin/sh -c "…"` form (used by OMO) is already a single executable, so + // its inner operators stay quoted and the command is forwarded unchanged. + let alreadyShellInvoked = try respawnPaneForwardedCommand("/bin/sh -c \"opencode attach; sleep 1\"") + #expect( + alreadyShellInvoked.command == "/bin/sh -c \"opencode attach; sleep 1\"", + "already-shell-invoked command must not be double-wrapped, got: \(alreadyShellInvoked.command)" + ) + } + + private enum CLITmuxCompatRespawnTestError: Error { + case noRespawnCommand } private final class CLITmuxCompatRemoteSplitBundleToken {} From d1369ec83c2dbb7b883eee17c989ad81f01155f8 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sat, 20 Jun 2026 03:00:39 -0700 Subject: [PATCH 04/19] Address autoreview: always shell-wrap respawn commands, skip only shell invocations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "detect what needs a shell" classifier was whack-a-mole — review surfaced non-spaced operators, then assignment-prefix commands (`FOO=bar claude`), and more forms (tilde, globs) would follow. Invert it: run every tmux respawn shell-command through `${SHELL:-/bin/zsh} -lc` (matching tmux's `$SHELL -c` semantics), and skip wrapping only for a command that is already a clean shell invocation — a known shell (`sh`/`bash`/`zsh`/…) with a `-c`-style flag, e.g. OMO's `/bin/sh -c "…"` — to avoid redundant double-wrapping. The skip is conservative: an unrecognized form is harmlessly wrapped, never wrongly left unwrapped. Updated the regression test to cover the assignment-prefix case and the already-shell-invoked passthrough. Co-Authored-By: Claude Opus 4.8 --- CLI/CMUXCLI+TmuxCompatSupport.swift | 83 +++++++------------ cmuxTests/CLITmuxCompatRemoteSplitTests.swift | 27 +++--- 2 files changed, 42 insertions(+), 68 deletions(-) diff --git a/CLI/CMUXCLI+TmuxCompatSupport.swift b/CLI/CMUXCLI+TmuxCompatSupport.swift index 6f7f5093e760..afbe41c7b8a1 100644 --- a/CLI/CMUXCLI+TmuxCompatSupport.swift +++ b/CLI/CMUXCLI+TmuxCompatSupport.swift @@ -65,54 +65,27 @@ extension CMUXCLI { return commandText.isEmpty ? nil : commandText } - /// Shell builtins that cannot be exec'd as a standalone binary, so a command - /// starting with one must run through a shell. - static let tmuxShellExecBuiltins: Set = [ - "cd", "exec", "eval", "source", ".", "export", "set", "unset", - "alias", "unalias", "pushd", "popd", "umask", "ulimit", ":", - "local", "readonly", "let", "trap", + /// Shell program names whose ` … -c ` invocation already runs + /// `` through a shell, so cmux must not wrap it a second time. + static let tmuxKnownShellNames: Set = [ + "sh", "bash", "zsh", "dash", "fish", "ksh", "ash", "mksh", "csh", "tcsh", ] - /// Unquoted shell metacharacters that make a command a shell expression - /// (control operators, pipes, redirections, substitutions, multi-line) - /// rather than a single program invocation. Detected at the character level - /// so operators without surrounding whitespace (`a&&b`, `a;b`) are caught. - static let tmuxShellMetacharacters: Set = [ - "&", "|", ";", "<", ">", "$", "`", "\n", - ] - - /// Whether a tmux shell-command must run through a shell rather than being - /// exec'd directly. True when it starts with a shell builtin (e.g. `cd`) or - /// contains an unquoted shell metacharacter (e.g. `&&`, `;`, `$`). - func tmuxCommandRequiresLoginShell(_ command: String) -> Bool { - if let first = tmuxShellWords(command).first, Self.tmuxShellExecBuiltins.contains(first) { - return true + /// Whether `command` is already a clean shell invocation — a known shell + /// followed by a `-c`-style command flag (e.g. OMO's `/bin/sh -c "…"`). In + /// that case the shell already interprets the inner command, so cmux must not + /// wrap it again. Conservative on purpose: an unrecognized form just gets + /// (harmlessly) wrapped, whereas a wrong "skip" could leave a shell + /// expression unwrapped, so only a known shell with a c-flag qualifies. + func tmuxCommandIsShellInvocation(_ command: String) -> Bool { + let words = tmuxShellWords(command) + guard let program = words.first.map({ ($0 as NSString).lastPathComponent }), + Self.tmuxKnownShellNames.contains(program) else { + return false } - var inSingleQuote = false - var inDoubleQuote = false - var escaping = false - for character in command { - if escaping { - escaping = false - continue - } - if character == "\\" && !inSingleQuote { - escaping = true - continue - } - if character == "'" && !inDoubleQuote { - inSingleQuote.toggle() - continue - } - if character == "\"" && !inSingleQuote { - inDoubleQuote.toggle() - continue - } - if !inSingleQuote, !inDoubleQuote, Self.tmuxShellMetacharacters.contains(character) { - return true - } + return words.dropFirst().contains { word in + word.hasPrefix("-") && word.contains("c") } - return false } /// Returns a pane start-command that the surface can exec correctly. @@ -120,20 +93,20 @@ extension CMUXCLI { /// cmux hands a respawn/start command to the surface as the pane's process /// command. On macOS, Ghostty execs that command via `exec -l ` /// (see ghostty/src/termio/Exec.zig), which only works when `` is a - /// single executable (optionally with arguments). A bare shell expression — + /// single executable. tmux shell-commands are arbitrary shell expressions — /// Claude Code agent-team teammates respawn with `cd && env … …` - /// — makes `exec -l cd …` try to exec the `cd` builtin as a binary; it fails - /// and the pane exits before the real command runs, so Claude Code 2.1.183 - /// teammates never opened a split pane (issue #6447). Such commands are - /// rewritten to run through `${SHELL:-/bin/zsh} -lc` (the user's shell, - /// resolved when Ghostty execs the wrapper), matching real tmux's `$SHELL -c` - /// semantics so Ghostty execs the shell rather than a builtin/expression. - /// Commands that are already a single executable (including the - /// `/bin/sh -c "…"` form OMO uses) are returned unchanged so they are not - /// needlessly double-wrapped. + /// — so `exec -l cd …` tries to exec the `cd` builtin as a binary, fails, and + /// the pane exits before the real command runs; that is why Claude Code + /// 2.1.183 teammates never opened a split pane (issue #6447). Commands are run + /// through `${SHELL:-/bin/zsh} -lc` (the user's shell, resolved when Ghostty + /// execs the wrapper), matching real tmux's `$SHELL -c` semantics, so Ghostty + /// execs the shell rather than a builtin/expression/assignment-prefix. The + /// only commands left unwrapped are ones that are already a clean shell + /// invocation (e.g. OMO's `/bin/sh -c "…"`), which would otherwise be + /// redundantly double-wrapped. func tmuxShellInvokedStartCommand(_ command: String) -> String { let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty, tmuxCommandRequiresLoginShell(trimmed) else { return command } + guard !trimmed.isEmpty, !tmuxCommandIsShellInvocation(trimmed) else { return command } return "${SHELL:-/bin/zsh} -lc \(tmuxShellQuote(trimmed))" } diff --git a/cmuxTests/CLITmuxCompatRemoteSplitTests.swift b/cmuxTests/CLITmuxCompatRemoteSplitTests.swift index fb8eace5f16f..f6d229aff64c 100644 --- a/cmuxTests/CLITmuxCompatRemoteSplitTests.swift +++ b/cmuxTests/CLITmuxCompatRemoteSplitTests.swift @@ -173,10 +173,11 @@ import Testing /// src/termio/Exec.zig), which only works for a single executable — a bare shell /// expression like `cd … && … claude` makes it try to exec the `cd` builtin as a /// binary, the pane exits immediately, and the teammate never gets a visible pane - /// (it falls back to in-process). The fix runs shell-expression commands through a - /// login shell so Ghostty execs the shell, not the expression, while leaving - /// single-executable commands untouched. `tmux_start_command` stays the raw - /// command so `#{pane_start_command}` / OMX-HUD detection keep reporting it. + /// (it falls back to in-process). The fix runs tmux shell-commands through the + /// user's login shell so Ghostty execs the shell, not the expression, while + /// leaving commands that are already a clean shell invocation untouched. + /// `tmux_start_command` stays the raw command so `#{pane_start_command}` / OMX-HUD + /// detection keep reporting it. @Test func respawnPaneRunsShellExpressionsThroughLoginShell() throws { let shellPrefix = "${SHELL:-/bin/zsh} -lc " @@ -196,24 +197,24 @@ import Testing "tmux_start_command must stay the raw command for display/OMX detection, got: \(teammateResult.startCommand)" ) - // Operator without surrounding whitespace must still be detected as a shell - // expression and wrapped. + // Operator without surrounding whitespace must still be wrapped. let noSpaceOperator = try respawnPaneForwardedCommand("echo one;echo two") #expect( noSpaceOperator.command.hasPrefix(shellPrefix), "non-whitespace-separated operator must still be wrapped, got: \(noSpaceOperator.command)" ) - // A single executable (with args) execs directly, so it is forwarded - // unchanged rather than needlessly wrapped. - let plainExecutable = try respawnPaneForwardedCommand("/opt/claude --resume abc --model sonnet") + // A leading `NAME=value` assignment is shell syntax (`exec` cannot run a + // program literally named `FOO=bar`), so it must be wrapped even with no + // operators present. + let assignmentPrefix = try respawnPaneForwardedCommand("FOO=bar /opt/claude --resume x") #expect( - plainExecutable.command == "/opt/claude --resume abc --model sonnet", - "single executable must be forwarded unchanged, got: \(plainExecutable.command)" + assignmentPrefix.command.hasPrefix(shellPrefix), + "assignment-prefixed command must be wrapped, got: \(assignmentPrefix.command)" ) - // The `/bin/sh -c "…"` form (used by OMO) is already a single executable, so - // its inner operators stay quoted and the command is forwarded unchanged. + // The `/bin/sh -c "…"` form (used by OMO) is already a clean shell + // invocation, so it is forwarded unchanged rather than double-wrapped. let alreadyShellInvoked = try respawnPaneForwardedCommand("/bin/sh -c \"opencode attach; sleep 1\"") #expect( alreadyShellInvoked.command == "/bin/sh -c \"opencode attach; sleep 1\"", From 4a0492747561205eccc832bea54f49f56f82f8f0 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sat, 20 Jun 2026 03:09:18 -0700 Subject: [PATCH 05/19] Address autoreview: shell-invocation skip requires the -c arg to be the last token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A shell invocation with a trailing operator (`/bin/sh -c "setup" && claude`) is still a shell expression — the `&& claude` runs after the inner shell — so it cannot be forwarded raw onto Ghostty's `exec -l` path. Tighten the skip: only a genuine complete ` … -c ` where the argument is the final token qualifies; anything with tokens after the `-c` argument is wrapped like every other shell expression. Added a regression case. Co-Authored-By: Claude Opus 4.8 --- CLI/CMUXCLI+TmuxCompatSupport.swift | 34 ++++++++++++++----- cmuxTests/CLITmuxCompatRemoteSplitTests.swift | 9 +++++ 2 files changed, 35 insertions(+), 8 deletions(-) diff --git a/CLI/CMUXCLI+TmuxCompatSupport.swift b/CLI/CMUXCLI+TmuxCompatSupport.swift index afbe41c7b8a1..3993d192e255 100644 --- a/CLI/CMUXCLI+TmuxCompatSupport.swift +++ b/CLI/CMUXCLI+TmuxCompatSupport.swift @@ -71,21 +71,39 @@ extension CMUXCLI { "sh", "bash", "zsh", "dash", "fish", "ksh", "ash", "mksh", "csh", "tcsh", ] - /// Whether `command` is already a clean shell invocation — a known shell - /// followed by a `-c`-style command flag (e.g. OMO's `/bin/sh -c "…"`). In - /// that case the shell already interprets the inner command, so cmux must not - /// wrap it again. Conservative on purpose: an unrecognized form just gets - /// (harmlessly) wrapped, whereas a wrong "skip" could leave a shell - /// expression unwrapped, so only a known shell with a c-flag qualifies. + /// Whether `command` is *exactly* a clean shell invocation — a known shell, + /// optional flags, a `-c`-style command flag, and a single command argument + /// that is the **last** token (e.g. OMO's `/bin/sh -c "…"`). Such a command + /// already runs its argument through a shell, so cmux must not wrap it again. + /// + /// The `-c` argument must be the final token: a trailing shell operator + /// (`… -c "x" && y`, `… -c "x"; y`) makes the whole thing a shell expression + /// that still needs the outer wrapper, so those do not qualify. Conservative + /// on purpose — an unrecognized form is harmlessly wrapped; only a genuine + /// complete ` … -c ` is skipped. func tmuxCommandIsShellInvocation(_ command: String) -> Bool { let words = tmuxShellWords(command) guard let program = words.first.map({ ($0 as NSString).lastPathComponent }), Self.tmuxKnownShellNames.contains(program) else { return false } - return words.dropFirst().contains { word in - word.hasPrefix("-") && word.contains("c") + var index = 1 + while index < words.count { + let word = words[index] + // A non-flag token before the `-c` flag means this is not a bare + // ` … -c ` form (e.g. `sh foo`), so it is not a clean + // invocation. + guard word.hasPrefix("-") else { return false } + // The command flag is a short cluster ending in `c` (`-c`, `-lc`, …); + // long flags like `--norc` are skipped over, not treated as `-c`. + if !word.hasPrefix("--"), word.hasSuffix("c") { + // Exactly one argument may follow, and it must be the last token — + // nothing (such as a trailing `&&` / `;`) after it. + return index == words.count - 2 + } + index += 1 } + return false } /// Returns a pane start-command that the surface can exec correctly. diff --git a/cmuxTests/CLITmuxCompatRemoteSplitTests.swift b/cmuxTests/CLITmuxCompatRemoteSplitTests.swift index f6d229aff64c..27d10e35f07f 100644 --- a/cmuxTests/CLITmuxCompatRemoteSplitTests.swift +++ b/cmuxTests/CLITmuxCompatRemoteSplitTests.swift @@ -220,6 +220,15 @@ import Testing alreadyShellInvoked.command == "/bin/sh -c \"opencode attach; sleep 1\"", "already-shell-invoked command must not be double-wrapped, got: \(alreadyShellInvoked.command)" ) + + // A shell invocation with a trailing operator is still a shell expression + // (the `&& …` runs after the inner shell), so it must be wrapped — the + // `-c` argument is not the last token. + let shellInvocationWithTrailer = try respawnPaneForwardedCommand("/bin/sh -c \"setup\" && echo done") + #expect( + shellInvocationWithTrailer.command.hasPrefix(shellPrefix), + "shell invocation with a trailing operator must be wrapped, got: \(shellInvocationWithTrailer.command)" + ) } private enum CLITmuxCompatRespawnTestError: Error { From 8b25e71896cf5516de4c50388e6779813c0898f0 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sat, 20 Jun 2026 03:18:43 -0700 Subject: [PATCH 06/19] Address autoreview: always wrap respawn commands (drop the unreliable skip) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `tmuxShellWords` is a whitespace tokenizer, not a shell parser, so any "is this already a clean shell invocation" check is fooled by operators with no surrounding whitespace (`/bin/sh -c "x";y` tokenizes as three words). Rather than keep refining a classifier that cannot be made reliable, run every tmux respawn shell-command through `${SHELL:-/bin/zsh} -lc ''`. Single-quoting the whole original makes it round-trip verbatim, so it runs correctly regardless of operators or quoting, and there is no longer any classification to get wrong. Commands that are themselves a shell invocation (e.g. OMO's `/bin/sh -c "…"`) are run through one more shell that execs straight into them — harmless and matching tmux's `$SHELL -c` semantics. Updated the OMO respawn test accordingly; `tmux_start_command` still carries the raw command. Co-Authored-By: Claude Opus 4.8 --- CLI/CMUXCLI+TmuxCompatSupport.swift | 61 ++++--------------- cmuxTests/CLITmuxCompatRemoteSplitTests.swift | 27 ++++---- tests/test_cli_omo_tmux_respawn_pane.py | 21 +++++-- 3 files changed, 39 insertions(+), 70 deletions(-) diff --git a/CLI/CMUXCLI+TmuxCompatSupport.swift b/CLI/CMUXCLI+TmuxCompatSupport.swift index 3993d192e255..190443ff980d 100644 --- a/CLI/CMUXCLI+TmuxCompatSupport.swift +++ b/CLI/CMUXCLI+TmuxCompatSupport.swift @@ -65,47 +65,6 @@ extension CMUXCLI { return commandText.isEmpty ? nil : commandText } - /// Shell program names whose ` … -c ` invocation already runs - /// `` through a shell, so cmux must not wrap it a second time. - static let tmuxKnownShellNames: Set = [ - "sh", "bash", "zsh", "dash", "fish", "ksh", "ash", "mksh", "csh", "tcsh", - ] - - /// Whether `command` is *exactly* a clean shell invocation — a known shell, - /// optional flags, a `-c`-style command flag, and a single command argument - /// that is the **last** token (e.g. OMO's `/bin/sh -c "…"`). Such a command - /// already runs its argument through a shell, so cmux must not wrap it again. - /// - /// The `-c` argument must be the final token: a trailing shell operator - /// (`… -c "x" && y`, `… -c "x"; y`) makes the whole thing a shell expression - /// that still needs the outer wrapper, so those do not qualify. Conservative - /// on purpose — an unrecognized form is harmlessly wrapped; only a genuine - /// complete ` … -c ` is skipped. - func tmuxCommandIsShellInvocation(_ command: String) -> Bool { - let words = tmuxShellWords(command) - guard let program = words.first.map({ ($0 as NSString).lastPathComponent }), - Self.tmuxKnownShellNames.contains(program) else { - return false - } - var index = 1 - while index < words.count { - let word = words[index] - // A non-flag token before the `-c` flag means this is not a bare - // ` … -c ` form (e.g. `sh foo`), so it is not a clean - // invocation. - guard word.hasPrefix("-") else { return false } - // The command flag is a short cluster ending in `c` (`-c`, `-lc`, …); - // long flags like `--norc` are skipped over, not treated as `-c`. - if !word.hasPrefix("--"), word.hasSuffix("c") { - // Exactly one argument may follow, and it must be the last token — - // nothing (such as a trailing `&&` / `;`) after it. - return index == words.count - 2 - } - index += 1 - } - return false - } - /// Returns a pane start-command that the surface can exec correctly. /// /// cmux hands a respawn/start command to the surface as the pane's process @@ -115,16 +74,20 @@ extension CMUXCLI { /// Claude Code agent-team teammates respawn with `cd && env … …` /// — so `exec -l cd …` tries to exec the `cd` builtin as a binary, fails, and /// the pane exits before the real command runs; that is why Claude Code - /// 2.1.183 teammates never opened a split pane (issue #6447). Commands are run - /// through `${SHELL:-/bin/zsh} -lc` (the user's shell, resolved when Ghostty - /// execs the wrapper), matching real tmux's `$SHELL -c` semantics, so Ghostty - /// execs the shell rather than a builtin/expression/assignment-prefix. The - /// only commands left unwrapped are ones that are already a clean shell - /// invocation (e.g. OMO's `/bin/sh -c "…"`), which would otherwise be - /// redundantly double-wrapped. + /// 2.1.183 teammates never opened a split pane (issue #6447). + /// + /// Every command is run through `${SHELL:-/bin/zsh} -lc ''` (the + /// user's shell, resolved when Ghostty execs the wrapper), matching real + /// tmux's `$SHELL -c` semantics, so Ghostty execs the shell rather than a + /// builtin/expression/assignment-prefix. The whole command is single-quoted, + /// so it round-trips verbatim regardless of operators or quoting — there is no + /// attempt to classify which commands "need" a shell, which was unreliable + /// (tmux shell-commands can hide operators with no surrounding whitespace). + /// Commands that are already a shell invocation (e.g. OMO's `/bin/sh -c "…"`) + /// are simply run through one more shell, which execs straight into them. func tmuxShellInvokedStartCommand(_ command: String) -> String { let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty, !tmuxCommandIsShellInvocation(trimmed) else { return command } + guard !trimmed.isEmpty else { return command } return "${SHELL:-/bin/zsh} -lc \(tmuxShellQuote(trimmed))" } diff --git a/cmuxTests/CLITmuxCompatRemoteSplitTests.swift b/cmuxTests/CLITmuxCompatRemoteSplitTests.swift index 27d10e35f07f..7a9f6bc4c4c1 100644 --- a/cmuxTests/CLITmuxCompatRemoteSplitTests.swift +++ b/cmuxTests/CLITmuxCompatRemoteSplitTests.swift @@ -173,9 +173,8 @@ import Testing /// src/termio/Exec.zig), which only works for a single executable — a bare shell /// expression like `cd … && … claude` makes it try to exec the `cd` builtin as a /// binary, the pane exits immediately, and the teammate never gets a visible pane - /// (it falls back to in-process). The fix runs tmux shell-commands through the - /// user's login shell so Ghostty execs the shell, not the expression, while - /// leaving commands that are already a clean shell invocation untouched. + /// (it falls back to in-process). The fix runs every tmux respawn shell-command + /// through the user's login shell so Ghostty execs the shell, not the expression. /// `tmux_start_command` stays the raw command so `#{pane_start_command}` / OMX-HUD /// detection keep reporting it. @Test func respawnPaneRunsShellExpressionsThroughLoginShell() throws { @@ -213,21 +212,19 @@ import Testing "assignment-prefixed command must be wrapped, got: \(assignmentPrefix.command)" ) - // The `/bin/sh -c "…"` form (used by OMO) is already a clean shell - // invocation, so it is forwarded unchanged rather than double-wrapped. - let alreadyShellInvoked = try respawnPaneForwardedCommand("/bin/sh -c \"opencode attach; sleep 1\"") + // A ` -c "…"` form (e.g. OMO) is wrapped too: it is run through one + // more shell that execs straight into it, so there is no fragile attempt + // to tell "already shelled" commands apart from shell expressions that + // hide trailing operators with no whitespace (`-c "x";y`). The original is + // carried verbatim inside the wrapper. + let shellForm = try respawnPaneForwardedCommand("/bin/sh -c \"opencode attach; sleep 1\"") #expect( - alreadyShellInvoked.command == "/bin/sh -c \"opencode attach; sleep 1\"", - "already-shell-invoked command must not be double-wrapped, got: \(alreadyShellInvoked.command)" + shellForm.command.hasPrefix(shellPrefix), + "shell-invocation command must be wrapped, got: \(shellForm.command)" ) - - // A shell invocation with a trailing operator is still a shell expression - // (the `&& …` runs after the inner shell), so it must be wrapped — the - // `-c` argument is not the last token. - let shellInvocationWithTrailer = try respawnPaneForwardedCommand("/bin/sh -c \"setup\" && echo done") #expect( - shellInvocationWithTrailer.command.hasPrefix(shellPrefix), - "shell invocation with a trailing operator must be wrapped, got: \(shellInvocationWithTrailer.command)" + shellForm.command.contains("/bin/sh -c \"opencode attach; sleep 1\""), + "wrapper must carry the original command verbatim, got: \(shellForm.command)" ) } diff --git a/tests/test_cli_omo_tmux_respawn_pane.py b/tests/test_cli_omo_tmux_respawn_pane.py index 302204403951..d69e6c9e06a2 100644 --- a/tests/test_cli_omo_tmux_respawn_pane.py +++ b/tests/test_cli_omo_tmux_respawn_pane.py @@ -33,6 +33,15 @@ ) +def login_shell_wrapped(command: str) -> str: + """Mirror CMUXCLI.tmuxShellInvokedStartCommand: respawn shell-commands are run + through the user's login shell so Ghostty's macOS `exec -l ` execs a + shell rather than the raw expression (issue #6447). Quoting mirrors + tmuxShellQuote (single-quote, with embedded single quotes escaped).""" + quoted = "'" + command.replace("'", "'\"'\"'") + "'" + return "${SHELL:-/bin/zsh} -lc " + quoted + + class FakeCmuxState: def __init__(self) -> None: self.split_created = False @@ -336,8 +345,8 @@ def assert_omo_split_is_listed_and_respawned( empty_respawn_params = state.respawn_params[0] if empty_respawn_params.get("surface_id") != SUBAGENT_SURFACE_ID: raise AssertionError(f"empty respawn targeted wrong surface: {empty_respawn_params!r}") - if empty_respawn_params.get("command") != PLACEHOLDER_COMMAND: - raise AssertionError(f"empty respawn did not reuse stored command: {empty_respawn_params!r}") + if empty_respawn_params.get("command") != login_shell_wrapped(PLACEHOLDER_COMMAND): + raise AssertionError(f"empty respawn did not reuse stored command (login-shell-wrapped): {empty_respawn_params!r}") if empty_respawn_params.get("tmux_start_command") != PLACEHOLDER_COMMAND: raise AssertionError(f"empty respawn did not preserve tmux start metadata: {empty_respawn_params!r}") if state.sent_text: @@ -366,8 +375,8 @@ def assert_omo_split_is_listed_and_respawned( raise AssertionError(f"respawn targeted wrong workspace: {respawn_params!r}") if respawn_params.get("surface_id") != SUBAGENT_SURFACE_ID: raise AssertionError(f"respawn targeted wrong surface: {respawn_params!r}") - if respawn_params.get("command") != ATTACH_COMMAND: - raise AssertionError(f"respawn carried wrong command: {respawn_params!r}") + if respawn_params.get("command") != login_shell_wrapped(ATTACH_COMMAND): + raise AssertionError(f"respawn carried wrong command (login-shell-wrapped): {respawn_params!r}") if respawn_params.get("tmux_start_command") != ATTACH_COMMAND: raise AssertionError(f"respawn did not update tmux start metadata: {respawn_params!r}") if state.sent_text: @@ -434,8 +443,8 @@ def assert_public_respawn_uses_same_surface_lifecycle( respawn_params = state.respawn_params[0] if respawn_params.get("surface_id") != SUBAGENT_SURFACE_ID: raise AssertionError(f"public respawn targeted wrong surface: {respawn_params!r}") - if respawn_params.get("command") != "echo TEST_PUBLIC": - raise AssertionError(f"public respawn carried wrong command: {respawn_params!r}") + if respawn_params.get("command") != login_shell_wrapped("echo TEST_PUBLIC"): + raise AssertionError(f"public respawn carried wrong command (login-shell-wrapped): {respawn_params!r}") if state.sent_text: raise AssertionError(f"public respawn must not send text: {state.sent_text!r}") From bf3685bc346c627a102b9cbb7b0035be822dfe02 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sat, 20 Jun 2026 03:22:11 -0700 Subject: [PATCH 07/19] Fix OMO test: keep public respawn-pane assertion raw MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The public `cmux respawn-pane --command …` CLI is a separate handler from `__tmux-compat respawn-pane` and is not part of the #6447 shell-wrapping change, so its forwarded command stays raw. Only the `__tmux-compat respawn-pane` assertions expect the login-shell wrapper. Co-Authored-By: Claude Opus 4.8 --- tests/test_cli_omo_tmux_respawn_pane.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/test_cli_omo_tmux_respawn_pane.py b/tests/test_cli_omo_tmux_respawn_pane.py index d69e6c9e06a2..a1d80547efde 100644 --- a/tests/test_cli_omo_tmux_respawn_pane.py +++ b/tests/test_cli_omo_tmux_respawn_pane.py @@ -443,8 +443,11 @@ def assert_public_respawn_uses_same_surface_lifecycle( respawn_params = state.respawn_params[0] if respawn_params.get("surface_id") != SUBAGENT_SURFACE_ID: raise AssertionError(f"public respawn targeted wrong surface: {respawn_params!r}") - if respawn_params.get("command") != login_shell_wrapped("echo TEST_PUBLIC"): - raise AssertionError(f"public respawn carried wrong command (login-shell-wrapped): {respawn_params!r}") + # The public `respawn-pane` CLI is a separate handler from + # `__tmux-compat respawn-pane` and does not shell-wrap, so it forwards the + # command unchanged. + if respawn_params.get("command") != "echo TEST_PUBLIC": + raise AssertionError(f"public respawn carried wrong command: {respawn_params!r}") if state.sent_text: raise AssertionError(f"public respawn must not send text: {state.sent_text!r}") From 7053531aacf2408254ac2da5d0f2f2893408f3d5 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sat, 20 Jun 2026 03:28:21 -0700 Subject: [PATCH 08/19] Address autoreview: wrap with -c (not -lc) for csh/tcsh compatibility `/bin/csh` and `/bin/tcsh` are valid macOS login shells that reject `-l`, so a combined `-lc` would make respawn fail outright for those users. Every shell accepts `-c`, and on macOS Ghostty already execs the wrapper with a login-style argv0 (`exec -l`), so the inner shell is still a login shell. Switch the wrapper to `${SHELL:-/bin/zsh} -c`. Co-Authored-By: Claude Opus 4.8 --- CLI/CMUXCLI+TmuxCompatSupport.swift | 9 +++++++-- cmuxTests/CLITmuxCompatRemoteSplitTests.swift | 2 +- tests/test_cli_omo_tmux_respawn_pane.py | 2 +- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/CLI/CMUXCLI+TmuxCompatSupport.swift b/CLI/CMUXCLI+TmuxCompatSupport.swift index 190443ff980d..914d6476d1d2 100644 --- a/CLI/CMUXCLI+TmuxCompatSupport.swift +++ b/CLI/CMUXCLI+TmuxCompatSupport.swift @@ -76,7 +76,7 @@ extension CMUXCLI { /// the pane exits before the real command runs; that is why Claude Code /// 2.1.183 teammates never opened a split pane (issue #6447). /// - /// Every command is run through `${SHELL:-/bin/zsh} -lc ''` (the + /// Every command is run through `${SHELL:-/bin/zsh} -c ''` (the /// user's shell, resolved when Ghostty execs the wrapper), matching real /// tmux's `$SHELL -c` semantics, so Ghostty execs the shell rather than a /// builtin/expression/assignment-prefix. The whole command is single-quoted, @@ -85,10 +85,15 @@ extension CMUXCLI { /// (tmux shell-commands can hide operators with no surrounding whitespace). /// Commands that are already a shell invocation (e.g. OMO's `/bin/sh -c "…"`) /// are simply run through one more shell, which execs straight into them. + /// + /// Only `-c` is passed (not `-lc`): every POSIX-ish and csh-family shell + /// accepts `-c`, whereas `-l`/`-lc` is rejected by `/bin/csh` and `/bin/tcsh`. + /// On macOS Ghostty already execs the wrapper with a login-style argv0 + /// (`exec -l`), so the shell is still a login shell. func tmuxShellInvokedStartCommand(_ command: String) -> String { let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return command } - return "${SHELL:-/bin/zsh} -lc \(tmuxShellQuote(trimmed))" + return "${SHELL:-/bin/zsh} -c \(tmuxShellQuote(trimmed))" } func tmuxShellWords(_ commandText: String) -> [String] { diff --git a/cmuxTests/CLITmuxCompatRemoteSplitTests.swift b/cmuxTests/CLITmuxCompatRemoteSplitTests.swift index 7a9f6bc4c4c1..5620fa4d0f9c 100644 --- a/cmuxTests/CLITmuxCompatRemoteSplitTests.swift +++ b/cmuxTests/CLITmuxCompatRemoteSplitTests.swift @@ -178,7 +178,7 @@ import Testing /// `tmux_start_command` stays the raw command so `#{pane_start_command}` / OMX-HUD /// detection keep reporting it. @Test func respawnPaneRunsShellExpressionsThroughLoginShell() throws { - let shellPrefix = "${SHELL:-/bin/zsh} -lc " + let shellPrefix = "${SHELL:-/bin/zsh} -c " // Claude Code teammate command: spaced `cd … && … claude` shell expression. let teammate = "cd /tmp/work && env CLAUDECODE=1 /opt/claude --agent-id alice@team --agent-name alice" diff --git a/tests/test_cli_omo_tmux_respawn_pane.py b/tests/test_cli_omo_tmux_respawn_pane.py index a1d80547efde..a1f810e9dbc2 100644 --- a/tests/test_cli_omo_tmux_respawn_pane.py +++ b/tests/test_cli_omo_tmux_respawn_pane.py @@ -39,7 +39,7 @@ def login_shell_wrapped(command: str) -> str: shell rather than the raw expression (issue #6447). Quoting mirrors tmuxShellQuote (single-quote, with embedded single quotes escaped).""" quoted = "'" + command.replace("'", "'\"'\"'") + "'" - return "${SHELL:-/bin/zsh} -lc " + quoted + return "${SHELL:-/bin/zsh} -c " + quoted class FakeCmuxState: From 1357f13e8be03a02701b8bf2b35178a3df1e5183 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sat, 20 Jun 2026 03:33:51 -0700 Subject: [PATCH 09/19] Address autoreview: shell-wrap the public respawn-pane command too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The public `cmux respawn-pane --command …` handler reaches the same surface.respawn / Ghostty `exec -l ` path as `__tmux-compat respawn-pane`, so a shell-expression command (`cd /tmp && env FOO=bar tool`) hit the same failure. Wrap its command through `tmuxShellInvokedStartCommand` too, keeping tmux_start_command raw. Updated the public-respawn OMO assertion to expect the wrapper. Co-Authored-By: Claude Opus 4.8 --- CLI/cmux.swift | 5 ++++- tests/test_cli_omo_tmux_respawn_pane.py | 10 +++++----- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 2f76989a5bb1..0909e2ea8391 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -22857,8 +22857,11 @@ struct CMUXCLI { let workspaceArg = workspaceOpt ?? (effectiveWindowRaw == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil) let commandText = (commandOpt ?? respawnRem3.dropFirst(respawnRem3.first == "--" ? 1 : 0).joined(separator: " ")).trimmingCharacters(in: .whitespacesAndNewlines) let finalCommand = commandText.isEmpty ? "exec ${SHELL:-/bin/zsh} -l" : commandText + // Run the command through a shell so Ghostty's macOS `exec -l ` + // execs a shell rather than a bare builtin/expression (#6447); keep + // tmux_start_command raw for `#{pane_start_command}` / OMX detection. var params: [String: Any] = [ - "command": finalCommand, + "command": tmuxShellInvokedStartCommand(finalCommand), "tmux_start_command": finalCommand ] let winId = try normalizeWindowHandle(effectiveWindowRaw, client: client) diff --git a/tests/test_cli_omo_tmux_respawn_pane.py b/tests/test_cli_omo_tmux_respawn_pane.py index a1f810e9dbc2..37ef16879299 100644 --- a/tests/test_cli_omo_tmux_respawn_pane.py +++ b/tests/test_cli_omo_tmux_respawn_pane.py @@ -443,11 +443,11 @@ def assert_public_respawn_uses_same_surface_lifecycle( respawn_params = state.respawn_params[0] if respawn_params.get("surface_id") != SUBAGENT_SURFACE_ID: raise AssertionError(f"public respawn targeted wrong surface: {respawn_params!r}") - # The public `respawn-pane` CLI is a separate handler from - # `__tmux-compat respawn-pane` and does not shell-wrap, so it forwards the - # command unchanged. - if respawn_params.get("command") != "echo TEST_PUBLIC": - raise AssertionError(f"public respawn carried wrong command: {respawn_params!r}") + # The public `respawn-pane` CLI reaches the same surface.respawn / Ghostty + # `exec -l` path, so it shell-wraps its command just like the + # `__tmux-compat respawn-pane` path. + if respawn_params.get("command") != login_shell_wrapped("echo TEST_PUBLIC"): + raise AssertionError(f"public respawn carried wrong command (login-shell-wrapped): {respawn_params!r}") if state.sent_text: raise AssertionError(f"public respawn must not send text: {state.sent_text!r}") From f906f536310badceac2c62deec2cbb17baa806fc Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sat, 20 Jun 2026 03:43:10 -0700 Subject: [PATCH 10/19] Trim call-site comments to keep CLI/cmux.swift within length budget The shell-wrap call sites are self-documenting via tmuxShellInvokedStartCommand and its doc comment, so drop the redundant inline comments to keep cmux.swift at net-zero growth (workflow-guard-tests swift-file-length-budget). Co-Authored-By: Claude Opus 4.8 --- CLI/cmux.swift | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 0909e2ea8391..0abb3137c3fe 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -21904,14 +21904,6 @@ struct CMUXCLI { client: client ) ?? "exec ${SHELL:-/bin/sh} -l" } - // Run the respawn command through a login shell. The surface execs - // its process command via Ghostty's macOS `exec -l `, which - // only works for a single executable; tmux respawn shell-commands are - // arbitrary shell expressions (Claude Code teammates use - // `cd && env … …`), so they must go through a shell or - // the pane dies before the command runs (#6447). Keep - // `tmux_start_command` raw so `#{pane_start_command}` / OMX-HUD - // detection still see the original command. var params: [String: Any] = [ "workspace_id": target.workspaceId, "surface_id": target.surfaceId, @@ -22857,9 +22849,6 @@ struct CMUXCLI { let workspaceArg = workspaceOpt ?? (effectiveWindowRaw == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil) let commandText = (commandOpt ?? respawnRem3.dropFirst(respawnRem3.first == "--" ? 1 : 0).joined(separator: " ")).trimmingCharacters(in: .whitespacesAndNewlines) let finalCommand = commandText.isEmpty ? "exec ${SHELL:-/bin/zsh} -l" : commandText - // Run the command through a shell so Ghostty's macOS `exec -l ` - // execs a shell rather than a bare builtin/expression (#6447); keep - // tmux_start_command raw for `#{pane_start_command}` / OMX detection. var params: [String: Any] = [ "command": tmuxShellInvokedStartCommand(finalCommand), "tmux_start_command": finalCommand From b4db66ec1ec5a56dc6507f39e78d2a922bf7cccf Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sat, 20 Jun 2026 03:58:32 -0700 Subject: [PATCH 11/19] Address autoreview: wrap with POSIX /bin/sh, not the user's $SHELL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wrapped commands are POSIX `sh` syntax — Claude Code's `cd … && env …` and the no-command fallback `exec ${SHELL:-/bin/sh} -l` — so they must run under a POSIX shell. `csh`/`tcsh` login shells cannot parse `${VAR:-default}` parameter expansion or `NAME=value` command prefixes, so routing these through the user's `$SHELL` would still exit immediately for those users. Use `/bin/sh -c` (always present, POSIX, and the body interpreter every command was written for); Ghostty still supplies a login-style argv0 on macOS. Updated tests to expect the /bin/sh prefix. Co-Authored-By: Claude Opus 4.8 --- CLI/CMUXCLI+TmuxCompatSupport.swift | 30 ++++++++++--------- cmuxTests/CLITmuxCompatRemoteSplitTests.swift | 6 ++-- tests/test_cli_omo_tmux_respawn_pane.py | 14 ++++----- 3 files changed, 26 insertions(+), 24 deletions(-) diff --git a/CLI/CMUXCLI+TmuxCompatSupport.swift b/CLI/CMUXCLI+TmuxCompatSupport.swift index 914d6476d1d2..0a1023ec9ae0 100644 --- a/CLI/CMUXCLI+TmuxCompatSupport.swift +++ b/CLI/CMUXCLI+TmuxCompatSupport.swift @@ -76,24 +76,26 @@ extension CMUXCLI { /// the pane exits before the real command runs; that is why Claude Code /// 2.1.183 teammates never opened a split pane (issue #6447). /// - /// Every command is run through `${SHELL:-/bin/zsh} -c ''` (the - /// user's shell, resolved when Ghostty execs the wrapper), matching real - /// tmux's `$SHELL -c` semantics, so Ghostty execs the shell rather than a - /// builtin/expression/assignment-prefix. The whole command is single-quoted, - /// so it round-trips verbatim regardless of operators or quoting — there is no - /// attempt to classify which commands "need" a shell, which was unreliable - /// (tmux shell-commands can hide operators with no surrounding whitespace). - /// Commands that are already a shell invocation (e.g. OMO's `/bin/sh -c "…"`) - /// are simply run through one more shell, which execs straight into them. + /// Every command is run through `/bin/sh -c ''`, so Ghostty execs a + /// shell rather than a builtin/expression/assignment-prefix. The whole command + /// is single-quoted, so it round-trips verbatim regardless of operators or + /// quoting — there is no attempt to classify which commands "need" a shell, + /// which was unreliable (tmux shell-commands can hide operators with no + /// surrounding whitespace). Commands that are already a shell invocation (e.g. + /// OMO's `/bin/sh -c "…"`) are simply run through one more shell, which execs + /// straight into them. /// - /// Only `-c` is passed (not `-lc`): every POSIX-ish and csh-family shell - /// accepts `-c`, whereas `-l`/`-lc` is rejected by `/bin/csh` and `/bin/tcsh`. - /// On macOS Ghostty already execs the wrapper with a login-style argv0 - /// (`exec -l`), so the shell is still a login shell. + /// A POSIX shell (`/bin/sh`) is used deliberately rather than the user's + /// `$SHELL`: the commands being wrapped are POSIX `sh` syntax (Claude Code's + /// `cd … && env …`, and the no-command fallback `exec ${SHELL:-/bin/sh} -l`), + /// and `csh`/`tcsh` login shells cannot parse `${VAR:-default}` parameter + /// expansion or `NAME=value` command prefixes. `/bin/sh` is always present and + /// runs the bodies correctly for every user. `-l` is not passed (`/bin/sh` + /// does not take it); on macOS Ghostty already supplies a login-style argv0. func tmuxShellInvokedStartCommand(_ command: String) -> String { let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return command } - return "${SHELL:-/bin/zsh} -c \(tmuxShellQuote(trimmed))" + return "/bin/sh -c \(tmuxShellQuote(trimmed))" } func tmuxShellWords(_ commandText: String) -> [String] { diff --git a/cmuxTests/CLITmuxCompatRemoteSplitTests.swift b/cmuxTests/CLITmuxCompatRemoteSplitTests.swift index 5620fa4d0f9c..e37550f7f42d 100644 --- a/cmuxTests/CLITmuxCompatRemoteSplitTests.swift +++ b/cmuxTests/CLITmuxCompatRemoteSplitTests.swift @@ -174,18 +174,18 @@ import Testing /// expression like `cd … && … claude` makes it try to exec the `cd` builtin as a /// binary, the pane exits immediately, and the teammate never gets a visible pane /// (it falls back to in-process). The fix runs every tmux respawn shell-command - /// through the user's login shell so Ghostty execs the shell, not the expression. + /// through a POSIX shell (/bin/sh) so Ghostty execs the shell, not the expression. /// `tmux_start_command` stays the raw command so `#{pane_start_command}` / OMX-HUD /// detection keep reporting it. @Test func respawnPaneRunsShellExpressionsThroughLoginShell() throws { - let shellPrefix = "${SHELL:-/bin/zsh} -c " + let shellPrefix = "/bin/sh -c " // Claude Code teammate command: spaced `cd … && … claude` shell expression. let teammate = "cd /tmp/work && env CLAUDECODE=1 /opt/claude --agent-id alice@team --agent-name alice" let teammateResult = try respawnPaneForwardedCommand(teammate) #expect( teammateResult.command.hasPrefix(shellPrefix), - "teammate command must run through a login shell, got: \(teammateResult.command)" + "teammate command must run through /bin/sh -c, got: \(teammateResult.command)" ) #expect( teammateResult.command.contains(teammate), diff --git a/tests/test_cli_omo_tmux_respawn_pane.py b/tests/test_cli_omo_tmux_respawn_pane.py index 37ef16879299..233c26c744b3 100644 --- a/tests/test_cli_omo_tmux_respawn_pane.py +++ b/tests/test_cli_omo_tmux_respawn_pane.py @@ -33,13 +33,13 @@ ) -def login_shell_wrapped(command: str) -> str: +def shell_wrapped(command: str) -> str: """Mirror CMUXCLI.tmuxShellInvokedStartCommand: respawn shell-commands are run - through the user's login shell so Ghostty's macOS `exec -l ` execs a - shell rather than the raw expression (issue #6447). Quoting mirrors + through a POSIX shell (`/bin/sh -c`) so Ghostty's macOS `exec -l ` + execs a shell rather than the raw expression (issue #6447). Quoting mirrors tmuxShellQuote (single-quote, with embedded single quotes escaped).""" quoted = "'" + command.replace("'", "'\"'\"'") + "'" - return "${SHELL:-/bin/zsh} -c " + quoted + return "/bin/sh -c " + quoted class FakeCmuxState: @@ -345,7 +345,7 @@ def assert_omo_split_is_listed_and_respawned( empty_respawn_params = state.respawn_params[0] if empty_respawn_params.get("surface_id") != SUBAGENT_SURFACE_ID: raise AssertionError(f"empty respawn targeted wrong surface: {empty_respawn_params!r}") - if empty_respawn_params.get("command") != login_shell_wrapped(PLACEHOLDER_COMMAND): + if empty_respawn_params.get("command") != shell_wrapped(PLACEHOLDER_COMMAND): raise AssertionError(f"empty respawn did not reuse stored command (login-shell-wrapped): {empty_respawn_params!r}") if empty_respawn_params.get("tmux_start_command") != PLACEHOLDER_COMMAND: raise AssertionError(f"empty respawn did not preserve tmux start metadata: {empty_respawn_params!r}") @@ -375,7 +375,7 @@ def assert_omo_split_is_listed_and_respawned( raise AssertionError(f"respawn targeted wrong workspace: {respawn_params!r}") if respawn_params.get("surface_id") != SUBAGENT_SURFACE_ID: raise AssertionError(f"respawn targeted wrong surface: {respawn_params!r}") - if respawn_params.get("command") != login_shell_wrapped(ATTACH_COMMAND): + if respawn_params.get("command") != shell_wrapped(ATTACH_COMMAND): raise AssertionError(f"respawn carried wrong command (login-shell-wrapped): {respawn_params!r}") if respawn_params.get("tmux_start_command") != ATTACH_COMMAND: raise AssertionError(f"respawn did not update tmux start metadata: {respawn_params!r}") @@ -446,7 +446,7 @@ def assert_public_respawn_uses_same_surface_lifecycle( # The public `respawn-pane` CLI reaches the same surface.respawn / Ghostty # `exec -l` path, so it shell-wraps its command just like the # `__tmux-compat respawn-pane` path. - if respawn_params.get("command") != login_shell_wrapped("echo TEST_PUBLIC"): + if respawn_params.get("command") != shell_wrapped("echo TEST_PUBLIC"): raise AssertionError(f"public respawn carried wrong command (login-shell-wrapped): {respawn_params!r}") if state.sent_text: raise AssertionError(f"public respawn must not send text: {state.sent_text!r}") From f8c1c26d767c09276f5708d873d0f059139b82d1 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sat, 20 Jun 2026 14:36:38 -0700 Subject: [PATCH 12/19] Add failing tests: claude-teams must skip trust gate and spawn named panes (#6447) Regression coverage beyond the respawn shell-wrap, for the two startup gaps that still left `cmux claude-teams` non-functional: - The lead and every respawned teammate must start with CLAUDE_CODE_SANDBOXED so Claude Code's interactive "Do you trust this folder?" gate (which --dangerously-skip-permissions does not cover) cannot deadlock the unattended panes. cmuxTests asserts the teammate respawn command exports it; the env test asserts the lead receives it. - `cmux claude-teams` must append a system-prompt nudge so a plain "make a demo team with 5 subagents" spawns NAMED split-pane teammates instead of nameless in-process subagents (or a "demo what?" question). These fail without the follow-up fix. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/ci.yml | 1 + cmuxTests/CLITmuxCompatRemoteSplitTests.swift | 65 ++++++++++++++++--- tests/test_cli_claude_teams_env.py | 23 +++++++ 3 files changed, 80 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4827af8b6104..7de4b580f174 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -650,6 +650,7 @@ jobs: python3 tests/test_claude_wrapper_hooks.py python3 tests/test_claude_wrapper_user_binary_resolution.py CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_cli_claude_teams_fallback_path.py + CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_cli_claude_teams_env.py CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_cli_omo_fallback_path.py CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_cli_omx_fallback_path.py CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_cli_omc_fallback_path.py diff --git a/cmuxTests/CLITmuxCompatRemoteSplitTests.swift b/cmuxTests/CLITmuxCompatRemoteSplitTests.swift index e37550f7f42d..f5e4df243158 100644 --- a/cmuxTests/CLITmuxCompatRemoteSplitTests.swift +++ b/cmuxTests/CLITmuxCompatRemoteSplitTests.swift @@ -100,7 +100,8 @@ import Testing /// and returns the `command` / `tmux_start_command` forwarded to /// `surface.respawn`. private func respawnPaneForwardedCommand( - _ command: String + _ command: String, + extraEnvironment: [String: String] = [:] ) throws -> (command: String, startCommand: String) { let cliPath = try BundledCLITestSupport.bundledCLIPath(for: CLITmuxCompatRemoteSplitBundleToken.self) let tmpDir = FileManager.default.temporaryDirectory @@ -142,17 +143,21 @@ import Testing } } + var environment = [ + "CMUX_SOCKET_PATH": socketPath, + "CMUX_WORKSPACE_ID": workspaceId, + "CMUX_SURFACE_ID": surfaceId, + "HOME": tmpDir.path, + "PATH": ProcessInfo.processInfo.environment["PATH"] ?? "/usr/bin:/bin", + "CMUX_CLI_SENTRY_DISABLED": "1", + ] + for (key, value) in extraEnvironment { + environment[key] = value + } let result = Self.runProcess( executablePath: cliPath, arguments: ["__tmux-compat", "respawn-pane", "-k", "--", command], - environment: [ - "CMUX_SOCKET_PATH": socketPath, - "CMUX_WORKSPACE_ID": workspaceId, - "CMUX_SURFACE_ID": surfaceId, - "HOME": tmpDir.path, - "PATH": ProcessInfo.processInfo.environment["PATH"] ?? "/usr/bin:/bin", - "CMUX_CLI_SENTRY_DISABLED": "1", - ], + environment: environment, timeout: 30 ) #expect(handled.wait(timeout: .now() + 30) == .success) @@ -228,6 +233,48 @@ import Testing ) } + /// Regression for #6447: even after the teammate command runs through a shell, + /// each spawned `claude` opened its pane but then blocked forever on Claude + /// Code's interactive "Do you trust this folder?" gate and never checked in — + /// which looked like the teammate "failing to start". Claude Code short-circuits + /// that gate on `CLAUDE_CODE_SANDBOXED`. Teammate panes are respawned by cmux + /// (not by `cmux claude-teams`), so they do not inherit the launcher env and must + /// be re-supplied it. cmux injects it ONLY in a claude-teams context (detected via + /// `CMUX_CLAUDE_TEAMS_CMUX_BIN`); OMO and the public `respawn-pane` are unchanged. + @Test func respawnPaneInjectsClaudeTeamsTrustBypass() throws { + let teammate = "cd /tmp/work && env CLAUDECODE=1 /opt/claude --agent-id alice@team --agent-name alice" + + // claude-teams context: the trust-bypass var is exported ahead of the + // original command, still inside the /bin/sh -c wrapper. + let inTeams = try respawnPaneForwardedCommand( + teammate, + extraEnvironment: ["CMUX_CLAUDE_TEAMS_CMUX_BIN": "/opt/cmux/bin/cmux"] + ) + #expect( + inTeams.command.hasPrefix("/bin/sh -c "), + "claude-teams respawn must still run through /bin/sh -c, got: \(inTeams.command)" + ) + #expect( + inTeams.command.contains("export CLAUDE_CODE_SANDBOXED="), + "claude-teams respawn must export CLAUDE_CODE_SANDBOXED so the teammate skips the trust gate, got: \(inTeams.command)" + ) + #expect( + inTeams.command.contains(teammate), + "the original teammate command must still be carried verbatim, got: \(inTeams.command)" + ) + #expect( + inTeams.startCommand == teammate, + "tmux_start_command must stay the raw command (no injected env), got: \(inTeams.startCommand)" + ) + + // No claude-teams context (OMO / public respawn-pane): no injection. + let outsideTeams = try respawnPaneForwardedCommand(teammate) + #expect( + !outsideTeams.command.contains("CLAUDE_CODE_SANDBOXED"), + "non-claude-teams respawn must not inject CLAUDE_CODE_SANDBOXED, got: \(outsideTeams.command)" + ) + } + private enum CLITmuxCompatRespawnTestError: Error { case noRespawnCommand } diff --git a/tests/test_cli_claude_teams_env.py b/tests/test_cli_claude_teams_env.py index ba0b2b9f9193..70541ceeae7b 100644 --- a/tests/test_cli_claude_teams_env.py +++ b/tests/test_cli_claude_teams_env.py @@ -36,6 +36,7 @@ def run_claude_teams( real_bin.mkdir(parents=True, exist_ok=True) env_log = tmp / "agent-teams.log" + sandboxed_log = tmp / "sandboxed.log" tmux_log = tmp / "tmux-path.log" cmux_bin_log = tmp / "cmux-bin.log" argv_log = tmp / "argv.log" @@ -56,6 +57,7 @@ def run_claude_teams( """#!/usr/bin/env bash set -euo pipefail printf '%s\\n' "${CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS-__UNSET__}" > "$FAKE_AGENT_TEAMS_LOG" +printf '%s\\n' "${CLAUDE_CODE_SANDBOXED-__UNSET__}" > "$FAKE_SANDBOXED_LOG" command -v tmux > "$FAKE_TMUX_PATH_LOG" printf '%s\\n' "${CMUX_CLAUDE_TEAMS_CMUX_BIN-__UNSET__}" > "$FAKE_CMUX_BIN_LOG" printf '%s\\n' "$@" > "$FAKE_ARGV_LOG" @@ -108,6 +110,7 @@ def run_claude_teams( env["HOME"] = str(fake_home) env["PATH"] = f"{real_bin}:{base_env.get('PATH', '/usr/bin:/bin')}" env["FAKE_AGENT_TEAMS_LOG"] = str(env_log) + env["FAKE_SANDBOXED_LOG"] = str(sandboxed_log) env["FAKE_TMUX_PATH_LOG"] = str(tmux_log) env["FAKE_CMUX_BIN_LOG"] = str(cmux_bin_log) env["FAKE_ARGV_LOG"] = str(argv_log) @@ -156,6 +159,14 @@ def run_claude_teams( print(f"FAIL: expected CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1, got {agent_teams_value!r}") raise SystemExit(1) + # #6447: the lead must skip Claude Code's interactive "trust this folder?" + # gate (which otherwise deadlocks the unattended session) via + # CLAUDE_CODE_SANDBOXED. + sandboxed_value = read_text(sandboxed_log) + if sandboxed_value != "1": + print(f"FAIL: expected CLAUDE_CODE_SANDBOXED=1 to skip the trust gate, got {sandboxed_value!r}") + raise SystemExit(1) + tmux_path = read_text(tmux_log) if not tmux_path: print("FAIL: fake claude did not observe a tmux binary in PATH") @@ -188,6 +199,18 @@ def run_claude_teams( print(f"FAIL: expected launcher to prepend --teammate-mode auto, got {argv_lines!r}") raise SystemExit(1) + # #6447: so a plain `cmux claude-teams "make a demo team"` actually opens + # split panes, the lead is nudged (via an appended system prompt) toward + # named split-pane teammates instead of nameless in-process subagents. + if "--append-system-prompt" not in argv_lines: + print(f"FAIL: expected claude-teams to append a team-spawn system prompt, got {argv_lines!r}") + raise SystemExit(1) + nudge_index = argv_lines.index("--append-system-prompt") + nudge_text = argv_lines[nudge_index + 1] if nudge_index + 1 < len(argv_lines) else "" + if "teammate" not in nudge_text.lower() or "pane" not in nudge_text.lower(): + print(f"FAIL: team-spawn nudge must steer toward split-pane teammates, got {nudge_text!r}") + raise SystemExit(1) + if "--version" not in argv_lines: print(f"FAIL: expected launcher to preserve user args, got {argv_lines!r}") raise SystemExit(1) From 5d3302ba91fe6c6784193ddd9934bb5e537abe8e Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sat, 20 Jun 2026 14:37:21 -0700 Subject: [PATCH 13/19] Fix #6447: claude-teams skips the trust gate and spawns named split panes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two startup gaps remained after the respawn shell-wrap, both of which made the agent-team teammates look broken: 1. Trust gate deadlock. The lead and every teammate `claude` blocked forever on Claude Code's interactive "Do you trust this folder?" dialog (which `--dangerously-skip-permissions` does NOT cover), so teammate panes opened but never checked in. Set CLAUDE_CODE_SANDBOXED=1 — the first short-circuit in Claude Code's trust check — for the lead (extraEnvVars) and inject it into each teammate respawn command via tmuxRespawnStartCommand / tmuxClaudeTeamsRespawnEnvironment (teammates are respawned by cmux, so they do not inherit the launcher env). Scoped to claude-teams; OMO and the public respawn-pane are unchanged. 2. Hard-to-start teams. A plain `cmux claude-teams "make a demo team with 5 subagents"` tended to run nameless in-process subagents (no panes) or stop to ask "demo what?". Append a small system-prompt nudge (claudeTeamsExec Arguments / claudeTeamsTeamSpawnGuidance) steering the lead to named, split-pane teammates for team/parallel requests, so no elaborate prompt is needed. Kept out of the exported restore command (which re-invokes claude-teams and re-applies the nudge); skipped if the user passes their own system prompt. Verified on a dev build: `cmux claude-teams --dangerously-skip-permissions "make a demo of this feature with 5 subagents"` opens 5 named teammate panes (@Researcher/@Architect/@Builder/@Tester/@Scribe) that check in and complete, with no trust prompts and no manual steps. Co-Authored-By: Claude Opus 4.8 --- CLI/CMUXCLI+ExecutableResolution.swift | 51 ++++++++++++++++++++++++++ CLI/CMUXCLI+TmuxCompatSupport.swift | 42 +++++++++++++++++++++ CLI/cmux.swift | 6 +-- 3 files changed, 96 insertions(+), 3 deletions(-) diff --git a/CLI/CMUXCLI+ExecutableResolution.swift b/CLI/CMUXCLI+ExecutableResolution.swift index a0b753b503d3..95f1c747fec5 100644 --- a/CLI/CMUXCLI+ExecutableResolution.swift +++ b/CLI/CMUXCLI+ExecutableResolution.swift @@ -138,6 +138,57 @@ extension CMUXCLI { return ["--teammate-mode", "auto"] + commandArgs } + func claudeTeamsHasExplicitSystemPrompt(commandArgs: [String]) -> Bool { + commandArgs.contains { arg in + arg == "--system-prompt" || arg.hasPrefix("--system-prompt=") + || arg == "--system-prompt-file" || arg.hasPrefix("--system-prompt-file=") + || arg == "--append-system-prompt" || arg.hasPrefix("--append-system-prompt=") + || arg == "--append-system-prompt-file" || arg.hasPrefix("--append-system-prompt-file=") + } + } + + /// The whole point of `cmux claude-teams` is "just start a team." Claude Code's + /// Task tool only opens a teammate in its own split pane when it is called with + /// a `name`; without a name it runs an in-process subagent (no pane). Left to a + /// bare prompt the lead tends to use the nameless form — or stops to ask "demo + /// *what*?" — so a plain `cmux claude-teams "make a demo team with 5 subagents"` + /// produced no panes. Append a small system-prompt nudge that steers the lead to + /// named, split-pane teammates for team/parallel requests so no elaborate prompt + /// is needed. Kept out of `claudeTeamsLaunchArguments` (and thus the exported + /// restore command) so that stays canonical; restore re-invokes `cmux + /// claude-teams`, which re-applies the nudge. Skipped when the user supplies + /// their own system prompt. + var claudeTeamsTeamSpawnGuidance: String { + """ + You are Claude Code running inside cmux, started with `cmux claude-teams`. \ + Agent teams are enabled and every NAMED teammate opens in its own split \ + pane. When the user asks you to start a team, demo teams, or run several \ + subagents/teammates in parallel, spawn them as named teammates: make one \ + Task tool call per teammate, each with a distinct `name` (a short role), all \ + in a single message so they run concurrently in their own split panes. \ + Prefer named teammates over in-process subagents for any team or \ + parallel-agent request. If the user asks for an open-ended demo such as \ + "make a demo team with 5 subagents" without naming a topic, do not ask which \ + feature — pick that many sensible roles and spawn them right away. + """ + } + + /// The live `execv` argv for the lead: the canonical launch arguments plus the + /// split-pane-teammate system-prompt nudge (see `claudeTeamsTeamSpawnGuidance`). + /// The nudge is inserted right after a leading `--teammate-mode ` pair so + /// callers/tests that expect that pair first keep working. + func claudeTeamsExecArguments(commandArgs: [String]) -> [String] { + let base = claudeTeamsLaunchArguments(commandArgs: commandArgs) + guard !claudeTeamsHasExplicitSystemPrompt(commandArgs: commandArgs) else { + return base + } + let nudge = ["--append-system-prompt", claudeTeamsTeamSpawnGuidance] + if base.count >= 2, base[0] == "--teammate-mode" { + return Array(base[0..<2]) + nudge + Array(base[2...]) + } + return nudge + base + } + private func providerExecutableSearchDirectories(searchPath: String?) -> [String] { var directories = searchPath?.split(separator: ":").map(String.init) ?? [] let environment = ProcessInfo.processInfo.environment diff --git a/CLI/CMUXCLI+TmuxCompatSupport.swift b/CLI/CMUXCLI+TmuxCompatSupport.swift index 0a1023ec9ae0..ee70ee3ca4c8 100644 --- a/CLI/CMUXCLI+TmuxCompatSupport.swift +++ b/CLI/CMUXCLI+TmuxCompatSupport.swift @@ -98,6 +98,48 @@ extension CMUXCLI { return "/bin/sh -c \(tmuxShellQuote(trimmed))" } + /// Like `tmuxShellInvokedStartCommand`, but first exports `prependEnv` inside + /// the wrapping shell so the respawned process — and any `env …`/`exec` it + /// chains into — inherits those variables. Used to re-supply claude-teams + /// teammate panes the environment they need (see + /// `tmuxClaudeTeamsRespawnEnvironment`); with an empty `prependEnv` it is + /// byte-for-byte identical to `tmuxShellInvokedStartCommand`, so OMO and the + /// public `respawn-pane` command are unchanged. + func tmuxRespawnStartCommand( + _ command: String, + prependEnv: [(key: String, value: String)] + ) -> String { + let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return command } + guard !prependEnv.isEmpty else { return tmuxShellInvokedStartCommand(trimmed) } + let exports = prependEnv + .map { "export \($0.key)=\(tmuxShellQuote($0.value))" } + .joined(separator: "; ") + return tmuxShellInvokedStartCommand("\(exports); \(trimmed)") + } + + /// Environment that claude-teams teammate panes must start with. + /// + /// Teammate panes are respawned by cmux's surface layer, not by `cmux + /// claude-teams`, so they do NOT inherit the launcher environment the lead + /// got from `configureClaudeTeamsEnvironment`. The one variable that matters + /// for startup is `CLAUDE_CODE_SANDBOXED`: Claude Code short-circuits its + /// interactive "Do you trust this folder?" gate on it, and a teammate that + /// hits that gate hangs forever (its pane opens but it never checks in — + /// issue #6447). Re-supply it on every claude-teams respawn so teammates + /// start the same way the lead does. + /// + /// Scoped to claude-teams via `CMUX_CLAUDE_TEAMS_CMUX_BIN`, which the launcher + /// exports and which the tmux shim propagates to this `__tmux-compat` + /// process. OMO (`CMUX_OMO_CMUX_BIN`) and the public `respawn-pane` command + /// run without it and are unaffected. + func tmuxClaudeTeamsRespawnEnvironment() -> [(key: String, value: String)] { + guard ProcessInfo.processInfo.environment["CMUX_CLAUDE_TEAMS_CMUX_BIN"] != nil else { + return [] + } + return [(key: "CLAUDE_CODE_SANDBOXED", value: "1")] + } + func tmuxShellWords(_ commandText: String) -> [String] { var words: [String] = [] var current = "" diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 0abb3137c3fe..c441f5f06f16 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -19152,7 +19152,7 @@ struct CMUXCLI { cmuxBinEnvVar: "CMUX_CLAUDE_TEAMS_CMUX_BIN", termOverrideEnvVar: "CMUX_CLAUDE_TEAMS_TERM", extraEnvVars: [ - (key: "CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS", value: "1"), + (key: "CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS", value: "1"), (key: "CLAUDE_CODE_SANDBOXED", value: "1"), // CLAUDE_CODE_SANDBOXED skips CC's "trust folder?" gate that deadlocks unattended panes (#6447); teammates re-supplied via tmuxClaudeTeamsRespawnEnvironment() ] ) guard let restoreModuleURL = try? createClaudeNodeOptionsRestoreModule() else { @@ -19273,7 +19273,7 @@ struct CMUXCLI { arguments: [executablePath, "claude-teams"] + launchArguments, workingDirectory: launcherEnvironment["PWD"] ) - var argv = ([launchPath] + launchArguments).map { strdup($0) } + var argv = ([launchPath] + claudeTeamsExecArguments(commandArgs: commandArgs)).map { strdup($0) } defer { for item in argv { free(item) @@ -21907,7 +21907,7 @@ struct CMUXCLI { var params: [String: Any] = [ "workspace_id": target.workspaceId, "surface_id": target.surfaceId, - "command": tmuxShellInvokedStartCommand(commandText), + "command": tmuxRespawnStartCommand(commandText, prependEnv: tmuxClaudeTeamsRespawnEnvironment()), "tmux_start_command": commandText ] if let cwd = parsed.value("-c")?.trimmingCharacters(in: .whitespacesAndNewlines), From e65d789945374d154ae1682e38c8ba83e697b6d2 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sat, 20 Jun 2026 14:52:48 -0700 Subject: [PATCH 14/19] Address autoreview: gate the trust-prompt bypass behind --dangerously-skip-permissions Autoreview (P1) correctly flagged that unconditionally exporting CLAUDE_CODE_SANDBOXED removed Claude Code's "Do you trust this folder?" safety check for every `cmux claude-teams` launch, including untrusted/cloned checkouts. Only waive the trust gate when the user has already opted into skipping safety prompts with --dangerously-skip-permissions: - lead: claudeTeamsExtraEnvVars adds CLAUDE_CODE_SANDBOXED only when the claude-teams args carry the flag; - teammates: tmuxClaudeTeamsRespawnEnvironment(forCommand:) injects it only when the respawn command carries the flag (Claude Code adds --dangerously-skip-permissions to the teammate command only in bypass mode). Without the flag the trust prompt is left in place and the user vets the directory normally. Tests cover both the bypass and gated-off paths. Co-Authored-By: Claude Opus 4.8 --- CLI/CMUXCLI+ExecutableResolution.swift | 26 ++++++++++++++ CLI/CMUXCLI+TmuxCompatSupport.swift | 16 ++++++--- CLI/cmux.swift | 10 +++--- cmuxTests/CLITmuxCompatRemoteSplitTests.swift | 35 ++++++++++++------- tests/test_cli_claude_teams_env.py | 4 +++ 5 files changed, 68 insertions(+), 23 deletions(-) diff --git a/CLI/CMUXCLI+ExecutableResolution.swift b/CLI/CMUXCLI+ExecutableResolution.swift index 95f1c747fec5..43f3d39936d7 100644 --- a/CLI/CMUXCLI+ExecutableResolution.swift +++ b/CLI/CMUXCLI+ExecutableResolution.swift @@ -131,6 +131,32 @@ extension CMUXCLI { } } + func claudeTeamsHasDangerousSkipPermissions(commandArgs: [String]) -> Bool { + commandArgs.contains { arg in + arg == "--dangerously-skip-permissions" + || arg.hasPrefix("--dangerously-skip-permissions=") + } + } + + /// Environment the lead `claude` is launched with. CLAUDE_CODE_SANDBOXED skips + /// Claude Code's interactive "Do you trust this folder?" gate so the unattended + /// lead/teammate panes don't deadlock on it (#6447). That gate is a real safety + /// boundary — running `claude` in an untrusted checkout — so it is only waived + /// when the user has already opted into skipping safety prompts with + /// `--dangerously-skip-permissions`. Without that flag the trust prompt is left + /// in place and the user vets the directory normally. Teammate panes are + /// re-supplied the same value, under the same condition, by + /// `tmuxClaudeTeamsRespawnEnvironment(forCommand:)`. + func claudeTeamsExtraEnvVars(commandArgs: [String]) -> [(key: String, value: String)] { + var vars: [(key: String, value: String)] = [ + (key: "CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS", value: "1"), + ] + if claudeTeamsHasDangerousSkipPermissions(commandArgs: commandArgs) { + vars.append((key: "CLAUDE_CODE_SANDBOXED", value: "1")) + } + return vars + } + func claudeTeamsLaunchArguments(commandArgs: [String]) -> [String] { guard !claudeTeamsHasExplicitTeammateMode(commandArgs: commandArgs) else { return commandArgs diff --git a/CLI/CMUXCLI+TmuxCompatSupport.swift b/CLI/CMUXCLI+TmuxCompatSupport.swift index ee70ee3ca4c8..f8a64cd3895b 100644 --- a/CLI/CMUXCLI+TmuxCompatSupport.swift +++ b/CLI/CMUXCLI+TmuxCompatSupport.swift @@ -118,7 +118,7 @@ extension CMUXCLI { return tmuxShellInvokedStartCommand("\(exports); \(trimmed)") } - /// Environment that claude-teams teammate panes must start with. + /// Environment that a claude-teams teammate pane must start with. /// /// Teammate panes are respawned by cmux's surface layer, not by `cmux /// claude-teams`, so they do NOT inherit the launcher environment the lead @@ -126,15 +126,21 @@ extension CMUXCLI { /// for startup is `CLAUDE_CODE_SANDBOXED`: Claude Code short-circuits its /// interactive "Do you trust this folder?" gate on it, and a teammate that /// hits that gate hangs forever (its pane opens but it never checks in — - /// issue #6447). Re-supply it on every claude-teams respawn so teammates - /// start the same way the lead does. + /// issue #6447). Re-supply it so teammates start the same way the lead does. + /// + /// That trust gate is a real safety boundary, so — exactly as the lead does + /// in `claudeTeamsExtraEnvVars` — it is only waived when the user already + /// opted into skipping safety prompts: the teammate command Claude Code + /// issues carries `--dangerously-skip-permissions` only when the lead is in + /// bypass mode. Without it the teammate keeps its trust prompt. /// /// Scoped to claude-teams via `CMUX_CLAUDE_TEAMS_CMUX_BIN`, which the launcher /// exports and which the tmux shim propagates to this `__tmux-compat` /// process. OMO (`CMUX_OMO_CMUX_BIN`) and the public `respawn-pane` command /// run without it and are unaffected. - func tmuxClaudeTeamsRespawnEnvironment() -> [(key: String, value: String)] { - guard ProcessInfo.processInfo.environment["CMUX_CLAUDE_TEAMS_CMUX_BIN"] != nil else { + func tmuxClaudeTeamsRespawnEnvironment(forCommand command: String) -> [(key: String, value: String)] { + guard ProcessInfo.processInfo.environment["CMUX_CLAUDE_TEAMS_CMUX_BIN"] != nil, + command.contains("--dangerously-skip-permissions") else { return [] } return [(key: "CLAUDE_CODE_SANDBOXED", value: "1")] diff --git a/CLI/cmux.swift b/CLI/cmux.swift index c441f5f06f16..0eb4b1526781 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -19139,7 +19139,7 @@ struct CMUXCLI { executablePath: String, socketPath: String, explicitPassword: String?, - focusedContext: TmuxCompatFocusedContext? + focusedContext: TmuxCompatFocusedContext?, commandArgs: [String] ) { configureTmuxCompatEnvironment( processEnvironment: processEnvironment, @@ -19151,9 +19151,7 @@ struct CMUXCLI { tmuxPathPrefix: "cmux-claude-teams", cmuxBinEnvVar: "CMUX_CLAUDE_TEAMS_CMUX_BIN", termOverrideEnvVar: "CMUX_CLAUDE_TEAMS_TERM", - extraEnvVars: [ - (key: "CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS", value: "1"), (key: "CLAUDE_CODE_SANDBOXED", value: "1"), // CLAUDE_CODE_SANDBOXED skips CC's "trust folder?" gate that deadlocks unattended panes (#6447); teammates re-supplied via tmuxClaudeTeamsRespawnEnvironment() - ] + extraEnvVars: claudeTeamsExtraEnvVars(commandArgs: commandArgs) ) guard let restoreModuleURL = try? createClaudeNodeOptionsRestoreModule() else { unsetenv("CMUX_ORIGINAL_NODE_OPTIONS_PRESENT") @@ -19262,7 +19260,7 @@ struct CMUXCLI { executablePath: executablePath, socketPath: socketPath, explicitPassword: explicitPassword, - focusedContext: focusedContext + focusedContext: focusedContext, commandArgs: commandArgs ) let launchPath = claudeExecutablePath @@ -21907,7 +21905,7 @@ struct CMUXCLI { var params: [String: Any] = [ "workspace_id": target.workspaceId, "surface_id": target.surfaceId, - "command": tmuxRespawnStartCommand(commandText, prependEnv: tmuxClaudeTeamsRespawnEnvironment()), + "command": tmuxRespawnStartCommand(commandText, prependEnv: tmuxClaudeTeamsRespawnEnvironment(forCommand: commandText)), "tmux_start_command": commandText ] if let cwd = parsed.value("-c")?.trimmingCharacters(in: .whitespacesAndNewlines), diff --git a/cmuxTests/CLITmuxCompatRemoteSplitTests.swift b/cmuxTests/CLITmuxCompatRemoteSplitTests.swift index f5e4df243158..272b503ed502 100644 --- a/cmuxTests/CLITmuxCompatRemoteSplitTests.swift +++ b/cmuxTests/CLITmuxCompatRemoteSplitTests.swift @@ -239,17 +239,19 @@ import Testing /// which looked like the teammate "failing to start". Claude Code short-circuits /// that gate on `CLAUDE_CODE_SANDBOXED`. Teammate panes are respawned by cmux /// (not by `cmux claude-teams`), so they do not inherit the launcher env and must - /// be re-supplied it. cmux injects it ONLY in a claude-teams context (detected via - /// `CMUX_CLAUDE_TEAMS_CMUX_BIN`); OMO and the public `respawn-pane` are unchanged. + /// be re-supplied it. Because the gate is a real safety boundary, cmux waives it + /// only when the user already opted into skipping prompts: the teammate command + /// carries `--dangerously-skip-permissions` (Claude Code adds it only in bypass + /// mode). It is scoped to claude-teams (`CMUX_CLAUDE_TEAMS_CMUX_BIN`); OMO and the + /// public `respawn-pane` are unchanged. @Test func respawnPaneInjectsClaudeTeamsTrustBypass() throws { - let teammate = "cd /tmp/work && env CLAUDECODE=1 /opt/claude --agent-id alice@team --agent-name alice" + let teamsEnv = ["CMUX_CLAUDE_TEAMS_CMUX_BIN": "/opt/cmux/bin/cmux"] + let bypassTeammate = "cd /tmp/work && env CLAUDECODE=1 /opt/claude --agent-id alice@team --agent-name alice --dangerously-skip-permissions" - // claude-teams context: the trust-bypass var is exported ahead of the - // original command, still inside the /bin/sh -c wrapper. - let inTeams = try respawnPaneForwardedCommand( - teammate, - extraEnvironment: ["CMUX_CLAUDE_TEAMS_CMUX_BIN": "/opt/cmux/bin/cmux"] - ) + // claude-teams context + the user's --dangerously-skip-permissions opt-in: + // the trust-bypass var is exported ahead of the original command, still + // inside the /bin/sh -c wrapper. + let inTeams = try respawnPaneForwardedCommand(bypassTeammate, extraEnvironment: teamsEnv) #expect( inTeams.command.hasPrefix("/bin/sh -c "), "claude-teams respawn must still run through /bin/sh -c, got: \(inTeams.command)" @@ -259,16 +261,25 @@ import Testing "claude-teams respawn must export CLAUDE_CODE_SANDBOXED so the teammate skips the trust gate, got: \(inTeams.command)" ) #expect( - inTeams.command.contains(teammate), + inTeams.command.contains(bypassTeammate), "the original teammate command must still be carried verbatim, got: \(inTeams.command)" ) #expect( - inTeams.startCommand == teammate, + inTeams.startCommand == bypassTeammate, "tmux_start_command must stay the raw command (no injected env), got: \(inTeams.startCommand)" ) + // claude-teams context but NO --dangerously-skip-permissions: the trust gate + // stays in place, so the bypass var must NOT be injected. + let safeTeammate = "cd /tmp/work && env CLAUDECODE=1 /opt/claude --agent-id alice@team --agent-name alice --permission-mode acceptEdits" + let gated = try respawnPaneForwardedCommand(safeTeammate, extraEnvironment: teamsEnv) + #expect( + !gated.command.contains("CLAUDE_CODE_SANDBOXED"), + "without --dangerously-skip-permissions the trust bypass must not be injected, got: \(gated.command)" + ) + // No claude-teams context (OMO / public respawn-pane): no injection. - let outsideTeams = try respawnPaneForwardedCommand(teammate) + let outsideTeams = try respawnPaneForwardedCommand(bypassTeammate) #expect( !outsideTeams.command.contains("CLAUDE_CODE_SANDBOXED"), "non-claude-teams respawn must not inject CLAUDE_CODE_SANDBOXED, got: \(outsideTeams.command)" diff --git a/tests/test_cli_claude_teams_env.py b/tests/test_cli_claude_teams_env.py index 70541ceeae7b..d1205eff780b 100644 --- a/tests/test_cli_claude_teams_env.py +++ b/tests/test_cli_claude_teams_env.py @@ -142,6 +142,10 @@ def run_claude_teams( "--password", explicit_socket_password, "claude-teams", + # The trust-gate bypass (CLAUDE_CODE_SANDBOXED) is only granted + # once the user opts into skipping safety prompts, so exercise the + # bypass path with --dangerously-skip-permissions. + "--dangerously-skip-permissions", "--version", ], capture_output=True, From 73289583b406f0a287ed3a3213afb0a7ed344570 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sat, 20 Jun 2026 15:05:41 -0700 Subject: [PATCH 15/19] Address autoreview: gate teammate trust bypass on a launcher marker, not command text Autoreview (P1) flagged that inferring the --dangerously-skip-permissions opt-in from the teammate respawn command text is unsafe: that substring can appear in a cwd, quoted value, or other non-flag position, granting the trust bypass even when the real Claude argv is not in dangerous-skip mode. Decide once, from the launcher's own argv, and propagate explicitly: - the lead records the opt-in in CMUX_CLAUDE_TEAMS_SANDBOXED (claudeTeamsExtraEnvVars), which the tmux shim already propagates to the __tmux-compat process; - tmuxClaudeTeamsRespawnEnvironment() injects CLAUDE_CODE_SANDBOXED only when that marker is set, with no command-text matching. Tests: the teammate inject case is driven purely by the marker, and a new case proves a bare --dangerously-skip-permissions substring in the command does NOT grant the bypass. Co-Authored-By: Claude Opus 4.8 --- CLI/CMUXCLI+ExecutableResolution.swift | 11 +++-- CLI/CMUXCLI+TmuxCompatSupport.swift | 24 +++++----- CLI/cmux.swift | 2 +- cmuxTests/CLITmuxCompatRemoteSplitTests.swift | 46 ++++++++++--------- tests/test_cli_claude_teams_env.py | 11 +++++ 5 files changed, 55 insertions(+), 39 deletions(-) diff --git a/CLI/CMUXCLI+ExecutableResolution.swift b/CLI/CMUXCLI+ExecutableResolution.swift index 43f3d39936d7..41f12d4f136b 100644 --- a/CLI/CMUXCLI+ExecutableResolution.swift +++ b/CLI/CMUXCLI+ExecutableResolution.swift @@ -144,15 +144,20 @@ extension CMUXCLI { /// boundary — running `claude` in an untrusted checkout — so it is only waived /// when the user has already opted into skipping safety prompts with /// `--dangerously-skip-permissions`. Without that flag the trust prompt is left - /// in place and the user vets the directory normally. Teammate panes are - /// re-supplied the same value, under the same condition, by - /// `tmuxClaudeTeamsRespawnEnvironment(forCommand:)`. + /// in place and the user vets the directory normally. + /// + /// The opt-in decision is made here, once, by an exact argv check, and recorded + /// in `CMUX_CLAUDE_TEAMS_SANDBOXED` so teammate respawns (which run as a separate + /// `cmux __tmux-compat` process and cannot see this argv) re-apply the same + /// decision without re-deriving it from untrusted command text — see + /// `tmuxClaudeTeamsRespawnEnvironment()`. func claudeTeamsExtraEnvVars(commandArgs: [String]) -> [(key: String, value: String)] { var vars: [(key: String, value: String)] = [ (key: "CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS", value: "1"), ] if claudeTeamsHasDangerousSkipPermissions(commandArgs: commandArgs) { vars.append((key: "CLAUDE_CODE_SANDBOXED", value: "1")) + vars.append((key: "CMUX_CLAUDE_TEAMS_SANDBOXED", value: "1")) } return vars } diff --git a/CLI/CMUXCLI+TmuxCompatSupport.swift b/CLI/CMUXCLI+TmuxCompatSupport.swift index f8a64cd3895b..2e34db52dbe0 100644 --- a/CLI/CMUXCLI+TmuxCompatSupport.swift +++ b/CLI/CMUXCLI+TmuxCompatSupport.swift @@ -128,19 +128,17 @@ extension CMUXCLI { /// hits that gate hangs forever (its pane opens but it never checks in — /// issue #6447). Re-supply it so teammates start the same way the lead does. /// - /// That trust gate is a real safety boundary, so — exactly as the lead does - /// in `claudeTeamsExtraEnvVars` — it is only waived when the user already - /// opted into skipping safety prompts: the teammate command Claude Code - /// issues carries `--dangerously-skip-permissions` only when the lead is in - /// bypass mode. Without it the teammate keeps its trust prompt. - /// - /// Scoped to claude-teams via `CMUX_CLAUDE_TEAMS_CMUX_BIN`, which the launcher - /// exports and which the tmux shim propagates to this `__tmux-compat` - /// process. OMO (`CMUX_OMO_CMUX_BIN`) and the public `respawn-pane` command - /// run without it and are unaffected. - func tmuxClaudeTeamsRespawnEnvironment(forCommand command: String) -> [(key: String, value: String)] { - guard ProcessInfo.processInfo.environment["CMUX_CLAUDE_TEAMS_CMUX_BIN"] != nil, - command.contains("--dangerously-skip-permissions") else { + /// That trust gate is a real safety boundary, so it is only waived when the + /// user already opted into skipping safety prompts. The opt-in is NOT inferred + /// from the respawn command text (a `--dangerously-skip-permissions` substring + /// can appear in a cwd, quoted value, or other non-flag position): the `cmux + /// claude-teams` launcher makes that decision once from its own argv and records + /// it in `CMUX_CLAUDE_TEAMS_SANDBOXED` (see `claudeTeamsExtraEnvVars`). That + /// launcher env is propagated by the tmux shim to this `__tmux-compat` process, + /// and is set only inside an opted-in claude-teams session, so OMO and the + /// public `respawn-pane` command never see it and are unaffected. + func tmuxClaudeTeamsRespawnEnvironment() -> [(key: String, value: String)] { + guard ProcessInfo.processInfo.environment["CMUX_CLAUDE_TEAMS_SANDBOXED"] == "1" else { return [] } return [(key: "CLAUDE_CODE_SANDBOXED", value: "1")] diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 0eb4b1526781..386fb8b42a05 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -21905,7 +21905,7 @@ struct CMUXCLI { var params: [String: Any] = [ "workspace_id": target.workspaceId, "surface_id": target.surfaceId, - "command": tmuxRespawnStartCommand(commandText, prependEnv: tmuxClaudeTeamsRespawnEnvironment(forCommand: commandText)), + "command": tmuxRespawnStartCommand(commandText, prependEnv: tmuxClaudeTeamsRespawnEnvironment()), "tmux_start_command": commandText ] if let cwd = parsed.value("-c")?.trimmingCharacters(in: .whitespacesAndNewlines), diff --git a/cmuxTests/CLITmuxCompatRemoteSplitTests.swift b/cmuxTests/CLITmuxCompatRemoteSplitTests.swift index 272b503ed502..ec33972f3228 100644 --- a/cmuxTests/CLITmuxCompatRemoteSplitTests.swift +++ b/cmuxTests/CLITmuxCompatRemoteSplitTests.swift @@ -240,49 +240,51 @@ import Testing /// that gate on `CLAUDE_CODE_SANDBOXED`. Teammate panes are respawned by cmux /// (not by `cmux claude-teams`), so they do not inherit the launcher env and must /// be re-supplied it. Because the gate is a real safety boundary, cmux waives it - /// only when the user already opted into skipping prompts: the teammate command - /// carries `--dangerously-skip-permissions` (Claude Code adds it only in bypass - /// mode). It is scoped to claude-teams (`CMUX_CLAUDE_TEAMS_CMUX_BIN`); OMO and the - /// public `respawn-pane` are unchanged. + /// only when the launcher recorded the user's `--dangerously-skip-permissions` + /// opt-in in `CMUX_CLAUDE_TEAMS_SANDBOXED` — the decision is NOT re-derived from + /// the respawn command text. OMO and the public `respawn-pane` never see that env + /// and are unaffected. @Test func respawnPaneInjectsClaudeTeamsTrustBypass() throws { - let teamsEnv = ["CMUX_CLAUDE_TEAMS_CMUX_BIN": "/opt/cmux/bin/cmux"] - let bypassTeammate = "cd /tmp/work && env CLAUDECODE=1 /opt/claude --agent-id alice@team --agent-name alice --dangerously-skip-permissions" + // No --dangerously-skip-permissions substring in the command: the bypass must + // come purely from the launcher's recorded opt-in marker. + let teammate = "cd /tmp/work && env CLAUDECODE=1 /opt/claude --agent-id alice@team --agent-name alice" - // claude-teams context + the user's --dangerously-skip-permissions opt-in: - // the trust-bypass var is exported ahead of the original command, still - // inside the /bin/sh -c wrapper. - let inTeams = try respawnPaneForwardedCommand(bypassTeammate, extraEnvironment: teamsEnv) + let inTeams = try respawnPaneForwardedCommand( + teammate, + extraEnvironment: ["CMUX_CLAUDE_TEAMS_SANDBOXED": "1"] + ) #expect( inTeams.command.hasPrefix("/bin/sh -c "), "claude-teams respawn must still run through /bin/sh -c, got: \(inTeams.command)" ) #expect( inTeams.command.contains("export CLAUDE_CODE_SANDBOXED="), - "claude-teams respawn must export CLAUDE_CODE_SANDBOXED so the teammate skips the trust gate, got: \(inTeams.command)" + "an opted-in claude-teams respawn must export CLAUDE_CODE_SANDBOXED, got: \(inTeams.command)" ) #expect( - inTeams.command.contains(bypassTeammate), + inTeams.command.contains(teammate), "the original teammate command must still be carried verbatim, got: \(inTeams.command)" ) #expect( - inTeams.startCommand == bypassTeammate, + inTeams.startCommand == teammate, "tmux_start_command must stay the raw command (no injected env), got: \(inTeams.startCommand)" ) - // claude-teams context but NO --dangerously-skip-permissions: the trust gate - // stays in place, so the bypass var must NOT be injected. - let safeTeammate = "cd /tmp/work && env CLAUDECODE=1 /opt/claude --agent-id alice@team --agent-name alice --permission-mode acceptEdits" - let gated = try respawnPaneForwardedCommand(safeTeammate, extraEnvironment: teamsEnv) + // A bare `--dangerously-skip-permissions` substring in the command must NOT + // grant the bypass on its own — only the recorded opt-in marker does. + let substringOnly = try respawnPaneForwardedCommand( + "cd '/tmp/--dangerously-skip-permissions' && /opt/claude --agent-id a@t --agent-name a" + ) #expect( - !gated.command.contains("CLAUDE_CODE_SANDBOXED"), - "without --dangerously-skip-permissions the trust bypass must not be injected, got: \(gated.command)" + !substringOnly.command.contains("CLAUDE_CODE_SANDBOXED"), + "a command substring must not grant the trust bypass without the opt-in marker, got: \(substringOnly.command)" ) - // No claude-teams context (OMO / public respawn-pane): no injection. - let outsideTeams = try respawnPaneForwardedCommand(bypassTeammate) + // No marker (OMO / public respawn-pane / non-opted-in claude-teams): no injection. + let outsideTeams = try respawnPaneForwardedCommand(teammate) #expect( !outsideTeams.command.contains("CLAUDE_CODE_SANDBOXED"), - "non-claude-teams respawn must not inject CLAUDE_CODE_SANDBOXED, got: \(outsideTeams.command)" + "without the opt-in marker, respawn must not inject CLAUDE_CODE_SANDBOXED, got: \(outsideTeams.command)" ) } diff --git a/tests/test_cli_claude_teams_env.py b/tests/test_cli_claude_teams_env.py index d1205eff780b..05d827663397 100644 --- a/tests/test_cli_claude_teams_env.py +++ b/tests/test_cli_claude_teams_env.py @@ -37,6 +37,7 @@ def run_claude_teams( env_log = tmp / "agent-teams.log" sandboxed_log = tmp / "sandboxed.log" + marker_log = tmp / "sandboxed-marker.log" tmux_log = tmp / "tmux-path.log" cmux_bin_log = tmp / "cmux-bin.log" argv_log = tmp / "argv.log" @@ -58,6 +59,7 @@ def run_claude_teams( set -euo pipefail printf '%s\\n' "${CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS-__UNSET__}" > "$FAKE_AGENT_TEAMS_LOG" printf '%s\\n' "${CLAUDE_CODE_SANDBOXED-__UNSET__}" > "$FAKE_SANDBOXED_LOG" +printf '%s\\n' "${CMUX_CLAUDE_TEAMS_SANDBOXED-__UNSET__}" > "$FAKE_SANDBOXED_MARKER_LOG" command -v tmux > "$FAKE_TMUX_PATH_LOG" printf '%s\\n' "${CMUX_CLAUDE_TEAMS_CMUX_BIN-__UNSET__}" > "$FAKE_CMUX_BIN_LOG" printf '%s\\n' "$@" > "$FAKE_ARGV_LOG" @@ -111,6 +113,7 @@ def run_claude_teams( env["PATH"] = f"{real_bin}:{base_env.get('PATH', '/usr/bin:/bin')}" env["FAKE_AGENT_TEAMS_LOG"] = str(env_log) env["FAKE_SANDBOXED_LOG"] = str(sandboxed_log) + env["FAKE_SANDBOXED_MARKER_LOG"] = str(marker_log) env["FAKE_TMUX_PATH_LOG"] = str(tmux_log) env["FAKE_CMUX_BIN_LOG"] = str(cmux_bin_log) env["FAKE_ARGV_LOG"] = str(argv_log) @@ -171,6 +174,14 @@ def run_claude_teams( print(f"FAIL: expected CLAUDE_CODE_SANDBOXED=1 to skip the trust gate, got {sandboxed_value!r}") raise SystemExit(1) + # The launcher records the opt-in in CMUX_CLAUDE_TEAMS_SANDBOXED so teammate + # respawns re-apply the same trust decision without re-deriving it from + # untrusted command text. + marker_value = read_text(marker_log) + if marker_value != "1": + print(f"FAIL: expected CMUX_CLAUDE_TEAMS_SANDBOXED=1 opt-in marker, got {marker_value!r}") + raise SystemExit(1) + tmux_path = read_text(tmux_log) if not tmux_path: print("FAIL: fake claude did not observe a tmux binary in PATH") From 0781629fdeb2c66363e60b3cac96f5ce93d65486 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sat, 20 Jun 2026 15:27:16 -0700 Subject: [PATCH 16/19] Address autoreview: detect the dangerous-skip opt-in with Claude option parsing, not a raw arg scan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Autoreview (P1) flagged that scanning every forwarded Claude argument for --dangerously-skip-permissions can promote prompt text into a safety opt-in: in claude-teams, tokens after a prompt-boundary option (--tmux), after --, or consumed as another option's value are prompt/value text, not options. Add AgentLaunchSanitizer.claudeTeamsLaunchHasOption, which walks the args with the Teams policy's option/value/prompt-boundary rules and matches how Claude itself treats positions — crucially it honors an option that follows the positional prompt (verified on a dev build: `claude "do x" --dangerously-skip-permissions` enables bypass mode), while ignoring tokens after --tmux/-- or in a value slot. claudeTeamsHasDangerousSkipPermissions now defers to it. Adds CMUXAgentLaunch unit coverage for leading/after-prompt/=value detection and the --tmux / -- / value-slot negatives. Budget bumped for the new helper. Co-Authored-By: Claude Opus 4.8 --- .github/swift-file-length-budget.tsv | 4 +- CLI/CMUXCLI+ExecutableResolution.swift | 17 +++++-- .../AgentLaunchSanitizer.swift | 26 ++++++++++ .../ClaudeTeamsLaunchOptionTests.swift | 50 +++++++++++++++++++ 4 files changed, 91 insertions(+), 6 deletions(-) create mode 100644 Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/ClaudeTeamsLaunchOptionTests.swift diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 0124f0e5e563..6684136c8e35 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_linesrelative path # Reduce counts as files shrink. CI fails if tracked files exceed this budget. -34335 CLI/cmux.swift +34333 CLI/cmux.swift 17710 Sources/AppDelegate.swift 16052 Sources/ContentView.swift 13992 Sources/TerminalController.swift @@ -181,7 +181,7 @@ 571 Sources/Feed/FeedTextEditorDebugWindowController.swift 568 Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/MobileTerminalRenderGrid.swift 567 Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/ConfigDiscovery/GhosttyConfigDiscovery.swift -566 Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizer.swift +592 Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizer.swift 562 Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTranscriptTableView.swift 562 cmuxTests/AgentExecutableResolverTests.swift 561 cmuxTests/GhosttyConfigPathResolverTests.swift diff --git a/CLI/CMUXCLI+ExecutableResolution.swift b/CLI/CMUXCLI+ExecutableResolution.swift index 41f12d4f136b..ccb87856c300 100644 --- a/CLI/CMUXCLI+ExecutableResolution.swift +++ b/CLI/CMUXCLI+ExecutableResolution.swift @@ -1,3 +1,4 @@ +import CMUXAgentLaunch import Foundation extension CMUXCLI { @@ -131,11 +132,19 @@ extension CMUXCLI { } } + /// Whether the user passed `--dangerously-skip-permissions` as a real Claude + /// *option* (not as prompt text). This gates a trust-boundary decision, so it + /// must not treat a token that lands in the prompt as an opt-in: a claude-teams + /// prompt can legitimately contain `--dangerously-skip-permissions` after a + /// prompt-boundary option (`--tmux`), after `--`, or as another option's value. + /// Defer to the claude-teams launch parser's option/prompt-boundary rules, which + /// match how Claude itself treats those positions (including options that follow + /// the prompt positional). func claudeTeamsHasDangerousSkipPermissions(commandArgs: [String]) -> Bool { - commandArgs.contains { arg in - arg == "--dangerously-skip-permissions" - || arg.hasPrefix("--dangerously-skip-permissions=") - } + AgentLaunchSanitizer.claudeTeamsLaunchHasOption( + "--dangerously-skip-permissions", + args: commandArgs + ) } /// Environment the lead `claude` is launched with. CLAUDE_CODE_SANDBOXED skips diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizer.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizer.swift index c9e19688ae2d..741bf30ff0ba 100644 --- a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizer.swift +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizer.swift @@ -168,6 +168,32 @@ public enum AgentLaunchSanitizer { /// Preserves restorable `claude-teams` `args` with the Teams policy, keeping routing flags while dropping `--tmux` prompt payloads; returns `nil` for unsafe replay shapes. public static func preservedClaudeTeamsLaunchArguments(args: [String]) -> [String]? { preserveOptions(args, policy: claudeTeamsPolicy) } + + /// Whether `option` appears as a real Claude *option* in claude-teams launch + /// `args`. Unlike restore preservation, this does NOT stop at the first + /// positional — Claude honors options that follow a positional prompt (e.g. + /// `claude "do x" --dangerously-skip-permissions` enables bypass mode) — but it + /// still treats tokens after `--`, after a prompt-boundary option (`--tmux`), or + /// consumed as another option's value as non-options. Use this for trust-boundary + /// opt-in decisions so a flag-shaped token inside the prompt is not promoted to + /// an option (it would be after `--tmux`/`--` or a value slot). + public static func claudeTeamsLaunchHasOption(_ option: String, args: [String]) -> Bool { + let policy = claudeTeamsPolicy + var index = 0 + while index < args.count { + let arg = args[index] + if arg == "--" { return false } + if !arg.hasPrefix("-") || arg == "-" { + index += 1 + continue + } + let name = arg.firstIndex(of: "=").map { String(arg[..<$0]) } ?? arg + if policy.promptBoundaryOptions.contains(name) { return false } + if arg == option || arg.hasPrefix(option + "=") { return true } + index += max(optionWidth(args, index: index, policy: policy), 1) + } + return false + } public static func preservedCodexForkArguments(args: [String]) -> [String]? { var tail = args if let forkCommand = codexForkCommand(in: tail) { diff --git a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/ClaudeTeamsLaunchOptionTests.swift b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/ClaudeTeamsLaunchOptionTests.swift new file mode 100644 index 000000000000..204e38ddcfbc --- /dev/null +++ b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/ClaudeTeamsLaunchOptionTests.swift @@ -0,0 +1,50 @@ +import CMUXAgentLaunch +import Testing + +/// `claudeTeamsLaunchHasOption` gates the claude-teams trust-gate bypass (#6447), +/// so it must match how Claude itself treats `--dangerously-skip-permissions`: +/// honored even after a positional prompt, but NOT when the token lands in the +/// prompt (after `--`, after the `--tmux` prompt boundary, or in a value slot). +@Suite("Claude Teams launch option detection") +struct ClaudeTeamsLaunchOptionTests { + private func hasDangerousSkip(_ args: [String]) -> Bool { + AgentLaunchSanitizer.claudeTeamsLaunchHasOption("--dangerously-skip-permissions", args: args) + } + + @Test("Detects the flag as a leading option") + func detectsLeadingOption() { + #expect(hasDangerousSkip(["--dangerously-skip-permissions", "make a demo team"])) + } + + @Test("Detects the flag after the positional prompt (Claude honors it)") + func detectsFlagAfterPrompt() { + #expect(hasDangerousSkip(["make a demo team", "--dangerously-skip-permissions"])) + } + + @Test("Detects the --flag=value form") + func detectsEqualsForm() { + #expect(hasDangerousSkip(["--dangerously-skip-permissions=true", "do it"])) + } + + @Test("Does NOT treat a prompt token after --tmux as an opt-in") + func ignoresAfterTmuxBoundary() { + #expect(!hasDangerousSkip(["--tmux", "explain --dangerously-skip-permissions and continue"])) + #expect(!hasDangerousSkip(["--tmux", "p", "--dangerously-skip-permissions"])) + } + + @Test("Does NOT treat a token after -- as an opt-in") + func ignoresAfterDoubleDash() { + #expect(!hasDangerousSkip(["--", "--dangerously-skip-permissions"])) + } + + @Test("Does NOT treat the flag consumed as another option's value as an opt-in") + func ignoresValueSlot() { + #expect(!hasDangerousSkip(["--model", "--dangerously-skip-permissions", "prompt"])) + } + + @Test("Returns false when the flag is absent") + func absentFlag() { + #expect(!hasDangerousSkip(["--model", "sonnet", "make a demo team"])) + #expect(!hasDangerousSkip([])) + } +} From 89cbec3f4344dd12a14c6ea06c5ec41736db33a8 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sat, 20 Jun 2026 15:34:43 -0700 Subject: [PATCH 17/19] Address autoreview: treat --tmux classic as a launch mode, not a prompt boundary Autoreview (P1) noted that claudeTeamsLaunchHasOption stopped at any `--tmux` token, so `cmux claude-teams --tmux classic --dangerously-skip-permissions ...` lost the trust-gate opt-in and its teammates could still hang on the trust prompt. The existing claude-teams sanitizer treats `--tmux classic` / `--tmux=classic` as a launch mode and keeps scanning later flags. Reuse that exact handling: claudeTeamsLaunchHasOption now defers to consumePromptBoundaryOption, so a `--tmux` launch mode is skipped and scanning continues, while only a real `--tmux ` payload (or `--`, or a value slot) ends the options. Unit tests cover `--tmux classic` / `--tmux=classic` detection plus the real-payload negative. Co-Authored-By: Claude Opus 4.8 --- .github/swift-file-length-budget.tsv | 2 +- .../AgentLaunchSanitizer.swift | 23 ++++++++++++------- .../ClaudeTeamsLaunchOptionTests.swift | 10 ++++++-- 3 files changed, 24 insertions(+), 11 deletions(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 6684136c8e35..c8254c8280f6 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -181,7 +181,7 @@ 571 Sources/Feed/FeedTextEditorDebugWindowController.swift 568 Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/MobileTerminalRenderGrid.swift 567 Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/ConfigDiscovery/GhosttyConfigDiscovery.swift -592 Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizer.swift +599 Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizer.swift 562 Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTranscriptTableView.swift 562 cmuxTests/AgentExecutableResolverTests.swift 561 cmuxTests/GhosttyConfigPathResolverTests.swift diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizer.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizer.swift index 741bf30ff0ba..3356774f36d7 100644 --- a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizer.swift +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizer.swift @@ -172,14 +172,16 @@ public enum AgentLaunchSanitizer { /// Whether `option` appears as a real Claude *option* in claude-teams launch /// `args`. Unlike restore preservation, this does NOT stop at the first /// positional — Claude honors options that follow a positional prompt (e.g. - /// `claude "do x" --dangerously-skip-permissions` enables bypass mode) — but it - /// still treats tokens after `--`, after a prompt-boundary option (`--tmux`), or - /// consumed as another option's value as non-options. Use this for trust-boundary - /// opt-in decisions so a flag-shaped token inside the prompt is not promoted to - /// an option (it would be after `--tmux`/`--` or a value slot). + /// `claude "do x" --dangerously-skip-permissions` enables bypass mode). It reuses + /// the launch parser's prompt-boundary handling, so `--tmux classic` (a launch + /// mode) is skipped and scanning continues, while a real `--tmux ` + /// payload, a trailing `--`, or a value slot are NOT treated as options. Use this + /// for trust-boundary opt-in decisions so a flag-shaped token inside the prompt + /// is never promoted to an option. public static func claudeTeamsLaunchHasOption(_ option: String, args: [String]) -> Bool { let policy = claudeTeamsPolicy var index = 0 + var sink: [String] = [] while index < args.count { let arg = args[index] if arg == "--" { return false } @@ -187,10 +189,15 @@ public enum AgentLaunchSanitizer { index += 1 continue } - let name = arg.firstIndex(of: "=").map { String(arg[..<$0]) } ?? arg - if policy.promptBoundaryOptions.contains(name) { return false } + let width = optionWidth(args, index: index, policy: policy) + guard let consumedBoundary = consumePromptBoundaryOption( + arg, args: args, index: &index, width: width, policy: policy, result: &sink + ) else { + return false + } + if consumedBoundary { continue } if arg == option || arg.hasPrefix(option + "=") { return true } - index += max(optionWidth(args, index: index, policy: policy), 1) + index += max(width, 1) } return false } diff --git a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/ClaudeTeamsLaunchOptionTests.swift b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/ClaudeTeamsLaunchOptionTests.swift index 204e38ddcfbc..a0428bfa7a24 100644 --- a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/ClaudeTeamsLaunchOptionTests.swift +++ b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/ClaudeTeamsLaunchOptionTests.swift @@ -26,10 +26,16 @@ struct ClaudeTeamsLaunchOptionTests { #expect(hasDangerousSkip(["--dangerously-skip-permissions=true", "do it"])) } - @Test("Does NOT treat a prompt token after --tmux as an opt-in") + @Test("Does NOT treat a prompt token after a real --tmux prompt payload as an opt-in") func ignoresAfterTmuxBoundary() { #expect(!hasDangerousSkip(["--tmux", "explain --dangerously-skip-permissions and continue"])) - #expect(!hasDangerousSkip(["--tmux", "p", "--dangerously-skip-permissions"])) + } + + @Test("Keeps scanning past the --tmux launch mode (classic), so a later flag is detected") + func detectsAfterTmuxMode() { + #expect(hasDangerousSkip(["--tmux", "classic", "--dangerously-skip-permissions"])) + #expect(hasDangerousSkip(["--tmux=classic", "--dangerously-skip-permissions"])) + #expect(hasDangerousSkip(["--tmux", "classic", "make a demo", "--dangerously-skip-permissions"])) } @Test("Does NOT treat a token after -- as an opt-in") From 799d311e06aca5eb08ad32f44db52ba44cf45e57 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sat, 20 Jun 2026 15:46:24 -0700 Subject: [PATCH 18/19] Address autoreview: clear ambient sandbox markers + treat file-option values as values MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two trust-boundary fixes flagged by autoreview: 1. Ambient marker leak. The launcher only added CMUX_CLAUDE_TEAMS_SANDBOXED / CLAUDE_CODE_SANDBOXED when --dangerously-skip-permissions was present, never clearing one inherited from a parent opted-in session — so a nested, non-opted `cmux claude-teams` could bypass the trust gate. configureClaudeTeamsEnvironment now unsets both markers whenever this invocation did not opt in, so the bypass reflects only the current command. 2. File-option values misread as flags. claudeTeamsLaunchHasOption relies on the policy's option widths to skip value slots, but --append-system-prompt-file / --system-prompt-file were not in the claude policy's valueOptions, so `--append-system-prompt-file --dangerously-skip-permissions` treated the path value as a real opt-in. Add both to valueOptions. Tests: new CLI test asserts the bypass markers are set only on opt-in and cleared when an ambient marker is inherited without opt-in; unit tests add the file-option value-slot negatives. Co-Authored-By: Claude Opus 4.8 --- .github/swift-file-length-budget.tsv | 4 +- .github/workflows/ci.yml | 1 + CLI/cmux.swift | 1 + .../AgentLaunchSanitizerPrimaryPolicies.swift | 2 + .../ClaudeTeamsLaunchOptionTests.swift | 3 + tests/test_cli_claude_teams_trust_optin.py | 80 +++++++++++++++++++ 6 files changed, 89 insertions(+), 2 deletions(-) create mode 100644 tests/test_cli_claude_teams_trust_optin.py diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index c8254c8280f6..38351f6945b6 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_linesrelative path # Reduce counts as files shrink. CI fails if tracked files exceed this budget. -34333 CLI/cmux.swift +34334 CLI/cmux.swift 17710 Sources/AppDelegate.swift 16052 Sources/ContentView.swift 13992 Sources/TerminalController.swift @@ -164,7 +164,7 @@ 608 Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Coordinators/WorkspaceGroupCoordinator.swift 606 Sources/SettingsNavigation.swift 604 Packages/macOS/CmuxCommandPalette/Tests/CmuxCommandPaletteTests/CommandPaletteNucleoFFITests.swift -599 Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizerPrimaryPolicies.swift +601 Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizerPrimaryPolicies.swift 596 cmuxTests/CmuxEventBusTests.swift 594 Sources/SessionIndexModels.swift 594 cmuxTests/PortalTabDragRoutingTests.swift diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7de4b580f174..df9e71dbf89d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -651,6 +651,7 @@ jobs: python3 tests/test_claude_wrapper_user_binary_resolution.py CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_cli_claude_teams_fallback_path.py CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_cli_claude_teams_env.py + CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_cli_claude_teams_trust_optin.py CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_cli_omo_fallback_path.py CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_cli_omx_fallback_path.py CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_cli_omc_fallback_path.py diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 386fb8b42a05..30af4a35bfae 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -19153,6 +19153,7 @@ struct CMUXCLI { termOverrideEnvVar: "CMUX_CLAUDE_TEAMS_TERM", extraEnvVars: claudeTeamsExtraEnvVars(commandArgs: commandArgs) ) + if !claudeTeamsHasDangerousSkipPermissions(commandArgs: commandArgs) { unsetenv("CMUX_CLAUDE_TEAMS_SANDBOXED"); unsetenv("CLAUDE_CODE_SANDBOXED") } // clear ambient markers inherited from a parent opted-in session so the trust bypass never leaks across invocations (#6447) guard let restoreModuleURL = try? createClaudeNodeOptionsRestoreModule() else { unsetenv("CMUX_ORIGINAL_NODE_OPTIONS_PRESENT") unsetenv("CMUX_ORIGINAL_NODE_OPTIONS") diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizerPrimaryPolicies.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizerPrimaryPolicies.swift index ba903f9e4b04..f9d5746c695c 100644 --- a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizerPrimaryPolicies.swift +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizerPrimaryPolicies.swift @@ -9,6 +9,7 @@ extension AgentLaunchSanitizer { "--allowedTools", "--allowed-tools", "--append-system-prompt", + "--append-system-prompt-file", "--betas", "--dangerously-load-development-channels", "--debug-file", @@ -35,6 +36,7 @@ extension AgentLaunchSanitizer { "--setting-sources", "--settings", "--system-prompt", + "--system-prompt-file", "--teammate-mode", "--tmux", "--tools", diff --git a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/ClaudeTeamsLaunchOptionTests.swift b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/ClaudeTeamsLaunchOptionTests.swift index a0428bfa7a24..c55a365b916f 100644 --- a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/ClaudeTeamsLaunchOptionTests.swift +++ b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/ClaudeTeamsLaunchOptionTests.swift @@ -46,6 +46,9 @@ struct ClaudeTeamsLaunchOptionTests { @Test("Does NOT treat the flag consumed as another option's value as an opt-in") func ignoresValueSlot() { #expect(!hasDangerousSkip(["--model", "--dangerously-skip-permissions", "prompt"])) + // File-option values (paths) are not options either. + #expect(!hasDangerousSkip(["--append-system-prompt-file", "--dangerously-skip-permissions"])) + #expect(!hasDangerousSkip(["--system-prompt-file", "--dangerously-skip-permissions", "prompt"])) } @Test("Returns false when the flag is absent") diff --git a/tests/test_cli_claude_teams_trust_optin.py b/tests/test_cli_claude_teams_trust_optin.py new file mode 100644 index 000000000000..7a274894d8d3 --- /dev/null +++ b/tests/test_cli_claude_teams_trust_optin.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python3 +""" +Regression test (#6447): `cmux claude-teams` grants the Claude trust-gate bypass +(CLAUDE_CODE_SANDBOXED) only when THIS invocation opts in with +--dangerously-skip-permissions, and clears any ambient opt-in marker inherited +from a parent session when it does not, so the bypass never leaks across launches. +""" + +from __future__ import annotations + +import os +import subprocess +import tempfile +from pathlib import Path + +from claude_teams_test_utils import resolve_cmux_cli + + +def run(cli_path: str, args: list[str], extra_env: dict[str, str]) -> tuple[str, str]: + with tempfile.TemporaryDirectory(prefix="cmux-teams-trust-") as td: + tmp = Path(td) + real_bin = tmp / "bin" + real_bin.mkdir() + sandboxed_log = tmp / "sandboxed.log" + marker_log = tmp / "marker.log" + fake = real_bin / "claude" + fake.write_text( + "#!/usr/bin/env bash\n" + 'printf "%s" "${CLAUDE_CODE_SANDBOXED-__UNSET__}" > "$L1"\n' + 'printf "%s" "${CMUX_CLAUDE_TEAMS_SANDBOXED-__UNSET__}" > "$L2"\n', + encoding="utf-8", + ) + fake.chmod(0o755) + env = dict(os.environ) + env.update(extra_env) + env["PATH"] = f"{real_bin}:{env.get('PATH', '/usr/bin:/bin')}" + env["HOME"] = str(tmp / "home") + Path(env["HOME"]).mkdir(parents=True, exist_ok=True) + env["L1"] = str(sandboxed_log) + env["L2"] = str(marker_log) + subprocess.run( + [cli_path, "--socket", str(tmp / "s.sock"), "claude-teams"] + args, + env=env, + capture_output=True, + text=True, + timeout=30, + check=False, + ) + + def read(p: Path) -> str: + return p.read_text(encoding="utf-8") if p.exists() else "__NOLOG__" + + return read(sandboxed_log), read(marker_log) + + +def main() -> int: + cli = resolve_cmux_cli() + + # Opted in: both the Claude env var and the cmux propagation marker are set. + sandboxed, marker = run(cli, ["--dangerously-skip-permissions", "--version"], {}) + if sandboxed != "1" or marker != "1": + print(f"FAIL: opted-in launch must set markers, got sandboxed={sandboxed!r} marker={marker!r}") + return 1 + + # Not opted in, but a parent set the markers: they must be CLEARED, not inherited. + sandboxed, marker = run( + cli, + ["--version"], + {"CLAUDE_CODE_SANDBOXED": "1", "CMUX_CLAUDE_TEAMS_SANDBOXED": "1"}, + ) + if sandboxed != "__UNSET__" or marker != "__UNSET__": + print(f"FAIL: non-opted launch must clear ambient markers, got sandboxed={sandboxed!r} marker={marker!r}") + return 1 + + print("PASS: cmux claude-teams gates the trust bypass on the current opt-in and clears ambient markers") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 606a06070ac5ab302d3c9c6c4494c5f897053d14 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sat, 20 Jun 2026 15:59:48 -0700 Subject: [PATCH 19/19] Document that the claude-teams trust bypass is intentionally per-launch (not restored) Autoreview noted a restored teammate pane relaunches from the raw tmux_start_command without CLAUDE_CODE_SANDBOXED and re-shows the trust prompt. That is intentional, not a regression: persisting the bypass into restore would re-introduce the trust-boundary leak the earlier fixes closed (a restored pane is not a fresh --dangerously-skip-permissions opt-in, and after an app restart the agent team/parent session is gone, so the pane is an orphan). Falling back to the trust prompt is the safe behavior; record the invariant where the env is supplied. Co-Authored-By: Claude Opus 4.8 --- CLI/CMUXCLI+TmuxCompatSupport.swift | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CLI/CMUXCLI+TmuxCompatSupport.swift b/CLI/CMUXCLI+TmuxCompatSupport.swift index 2e34db52dbe0..dfbffe69773d 100644 --- a/CLI/CMUXCLI+TmuxCompatSupport.swift +++ b/CLI/CMUXCLI+TmuxCompatSupport.swift @@ -137,6 +137,14 @@ extension CMUXCLI { /// launcher env is propagated by the tmux shim to this `__tmux-compat` process, /// and is set only inside an opted-in claude-teams session, so OMO and the /// public `respawn-pane` command never see it and are unaffected. + /// + /// The bypass is deliberately per-launch and is NOT baked into the pane's + /// `tmux_start_command` (kept raw for display / OMX-HUD / `#{pane_start_command}`), + /// so it is not carried into session persistence/restore. That is intentional: + /// a restored teammate pane is an orphan (its team/parent session is gone after + /// an app restart) and is not a fresh `--dangerously-skip-permissions` opt-in, so + /// it correctly falls back to Claude's trust prompt rather than silently bypassing + /// the trust boundary outside an explicit opt-in. func tmuxClaudeTeamsRespawnEnvironment() -> [(key: String, value: String)] { guard ProcessInfo.processInfo.environment["CMUX_CLAUDE_TEAMS_SANDBOXED"] == "1" else { return []