Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
d1953b2
Add failing test: respawn-pane must run command through a login shell
austinywang Jun 20, 2026
4436030
Fix #6447: run tmux respawn-pane shell-commands through a login shell
austinywang Jun 20, 2026
6390836
Merge remote-tracking branch 'origin/main' into issue-6447-claude-tea…
austinywang Jun 20, 2026
46c4068
Address autoreview: char-level operator detection + use $SHELL
austinywang Jun 20, 2026
d1369ec
Address autoreview: always shell-wrap respawn commands, skip only she…
austinywang Jun 20, 2026
4a04927
Address autoreview: shell-invocation skip requires the -c arg to be t…
austinywang Jun 20, 2026
8b25e71
Address autoreview: always wrap respawn commands (drop the unreliable…
austinywang Jun 20, 2026
bf3685b
Fix OMO test: keep public respawn-pane assertion raw
austinywang Jun 20, 2026
7053531
Address autoreview: wrap with -c (not -lc) for csh/tcsh compatibility
austinywang Jun 20, 2026
1357f13
Address autoreview: shell-wrap the public respawn-pane command too
austinywang Jun 20, 2026
f906f53
Trim call-site comments to keep CLI/cmux.swift within length budget
austinywang Jun 20, 2026
b4db66e
Address autoreview: wrap with POSIX /bin/sh, not the user's $SHELL
austinywang Jun 20, 2026
f8c1c26
Add failing tests: claude-teams must skip trust gate and spawn named …
austinywang Jun 20, 2026
5d3302b
Fix #6447: claude-teams skips the trust gate and spawns named split p…
austinywang Jun 20, 2026
e65d789
Address autoreview: gate the trust-prompt bypass behind --dangerously…
austinywang Jun 20, 2026
7328958
Address autoreview: gate teammate trust bypass on a launcher marker, …
austinywang Jun 20, 2026
0781629
Address autoreview: detect the dangerous-skip opt-in with Claude opti…
austinywang Jun 20, 2026
89cbec3
Address autoreview: treat --tmux classic as a launch mode, not a prom…
austinywang Jun 20, 2026
799d311
Address autoreview: clear ambient sandbox markers + treat file-option…
austinywang Jun 20, 2026
7a108b9
Merge remote-tracking branch 'origin/main' into issue-6447-claude-tea…
austinywang Jun 20, 2026
606a060
Document that the claude-teams trust bypass is intentionally per-laun…
austinywang Jun 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/swift-file-length-budget.tsv
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# cmux-owned Swift file length budget.
# Format: max_lines<TAB>relative path
# Reduce counts as files shrink. CI fails if tracked files exceed this budget.
34335 CLI/cmux.swift
34334 CLI/cmux.swift
17706 Sources/AppDelegate.swift
16052 Sources/ContentView.swift
13992 Sources/TerminalController.swift
Expand Down Expand Up @@ -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
Expand All @@ -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
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
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -652,6 +652,8 @@ 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_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
Expand Down
91 changes: 91 additions & 0 deletions CLI/CMUXCLI+ExecutableResolution.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import CMUXAgentLaunch
import Foundation

extension CMUXCLI {
Expand Down Expand Up @@ -131,13 +132,103 @@ 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 {
AgentLaunchSanitizer.claudeTeamsLaunchHasOption(
"--dangerously-skip-permissions",
args: commandArgs
)
}

/// 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.
///
/// 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
}

func claudeTeamsLaunchArguments(commandArgs: [String]) -> [String] {
guard !claudeTeamsHasExplicitTeammateMode(commandArgs: commandArgs) else {
return commandArgs
}
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 <value>` 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
Expand Down
87 changes: 87 additions & 0 deletions CLI/CMUXCLI+TmuxCompatSupport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,93 @@ extension CMUXCLI {
return commandText.isEmpty ? nil : commandText
}

/// 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 <command>`
/// (see ghostty/src/termio/Exec.zig), which only works when `<command>` is a
/// single executable. tmux shell-commands are arbitrary shell expressions —
/// Claude Code agent-team teammates respawn with `cd <dir> && env … <claude> …`
/// — 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).
///
/// Every command is run through `/bin/sh -c '<command>'`, 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.
///
/// 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 "/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 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
/// 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 so teammates start the same way the lead does.
///
/// 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.
///
/// 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 []
}
return [(key: "CLAUDE_CODE_SANDBOXED", value: "1")]
}
Comment on lines +95 to +99

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Hardcoded /bin/zsh diverges from tmuxStartupScript's ${SHELL:-/bin/sh} and from the doc comment's own claim of "matching real tmux's $SHELL -c semantics." tmuxStartupScript (line 256 in this same file) writes exec "${SHELL:-/bin/sh}" -lc … so the two code paths pick different shells for the same purpose. The fallback commandText of "exec ${SHELL:-/bin/sh} -l" also starts with exec — a member of tmuxShellExecBuiltins — so it gets re-wrapped as /bin/zsh -lc 'exec ${SHELL:-/bin/sh} -l', meaning every pane that respawns without an explicit command goes through an extra zsh process before exec-ing the user's configured shell.


func tmuxShellWords(_ commandText: String) -> [String] {
var words: [String] = []
var current = ""
Expand Down
15 changes: 7 additions & 8 deletions CLI/cmux.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19139,7 +19139,7 @@ struct CMUXCLI {
executablePath: String,
socketPath: String,
explicitPassword: String?,
focusedContext: TmuxCompatFocusedContext?
focusedContext: TmuxCompatFocusedContext?, commandArgs: [String]
) {
configureTmuxCompatEnvironment(
processEnvironment: processEnvironment,
Expand All @@ -19151,10 +19151,9 @@ 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"),
]
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")
Expand Down Expand Up @@ -19262,7 +19261,7 @@ struct CMUXCLI {
executablePath: executablePath,
socketPath: socketPath,
explicitPassword: explicitPassword,
focusedContext: focusedContext
focusedContext: focusedContext, commandArgs: commandArgs
)

let launchPath = claudeExecutablePath
Expand All @@ -19273,7 +19272,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)
Expand Down Expand Up @@ -21907,7 +21906,7 @@ struct CMUXCLI {
var params: [String: Any] = [
"workspace_id": target.workspaceId,
"surface_id": target.surfaceId,
"command": commandText,
"command": tmuxRespawnStartCommand(commandText, prependEnv: tmuxClaudeTeamsRespawnEnvironment()),
"tmux_start_command": commandText
]
if let cwd = parsed.value("-c")?.trimmingCharacters(in: .whitespacesAndNewlines),
Expand Down Expand Up @@ -22850,7 +22849,7 @@ struct CMUXCLI {
let commandText = (commandOpt ?? respawnRem3.dropFirst(respawnRem3.first == "--" ? 1 : 0).joined(separator: " ")).trimmingCharacters(in: .whitespacesAndNewlines)
let finalCommand = commandText.isEmpty ? "exec ${SHELL:-/bin/zsh} -l" : commandText
var params: [String: Any] = [
"command": finalCommand,
"command": tmuxShellInvokedStartCommand(finalCommand),
"tmux_start_command": finalCommand
]
let winId = try normalizeWindowHandle(effectiveWindowRaw, client: client)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,39 @@ 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). It reuses
/// the launch parser's prompt-boundary handling, so `--tmux classic` (a launch
/// mode) is skipped and scanning continues, while a real `--tmux <prompt>`
/// 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 }
if !arg.hasPrefix("-") || arg == "-" {
index += 1
continue
}
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(width, 1)
}
return false
}
public static func preservedCodexForkArguments(args: [String]) -> [String]? {
var tail = args
if let forkCommand = codexForkCommand(in: tail) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ extension AgentLaunchSanitizer {
"--allowedTools",
"--allowed-tools",
"--append-system-prompt",
"--append-system-prompt-file",
"--betas",
"--dangerously-load-development-channels",
"--debug-file",
Expand All @@ -35,6 +36,7 @@ extension AgentLaunchSanitizer {
"--setting-sources",
"--settings",
"--system-prompt",
"--system-prompt-file",
"--teammate-mode",
"--tmux",
"--tools",
Expand Down
Loading
Loading