diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 3bc592f487de..439fc327d557 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 +34334 CLI/cmux.swift 17706 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 @@ -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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7fad5f9f7aa5..4c1f57f05db7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/CLI/CMUXCLI+ExecutableResolution.swift b/CLI/CMUXCLI+ExecutableResolution.swift index a0b753b503d3..ccb87856c300 100644 --- a/CLI/CMUXCLI+ExecutableResolution.swift +++ b/CLI/CMUXCLI+ExecutableResolution.swift @@ -1,3 +1,4 @@ +import CMUXAgentLaunch import Foundation extension CMUXCLI { @@ -131,6 +132,45 @@ 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 @@ -138,6 +178,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 905e16faa471..dfbffe69773d 100644 --- a/CLI/CMUXCLI+TmuxCompatSupport.swift +++ b/CLI/CMUXCLI+TmuxCompatSupport.swift @@ -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 ` + /// (see ghostty/src/termio/Exec.zig), which only works when `` is a + /// single executable. tmux shell-commands are arbitrary shell expressions — + /// 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). + /// + /// 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. + /// + /// 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")] + } + func tmuxShellWords(_ commandText: String) -> [String] { var words: [String] = [] var current = "" diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 15f2cb2726d1..30af4a35bfae 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,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") @@ -19262,7 +19261,7 @@ struct CMUXCLI { executablePath: executablePath, socketPath: socketPath, explicitPassword: explicitPassword, - focusedContext: focusedContext + focusedContext: focusedContext, commandArgs: commandArgs ) let launchPath = claudeExecutablePath @@ -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) @@ -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), @@ -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) diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizer.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizer.swift index c9e19688ae2d..3356774f36d7 100644 --- a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizer.swift +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizer.swift @@ -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 ` + /// 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) { 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 new file mode 100644 index 000000000000..c55a365b916f --- /dev/null +++ b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/ClaudeTeamsLaunchOptionTests.swift @@ -0,0 +1,59 @@ +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 a real --tmux prompt payload as an opt-in") + func ignoresAfterTmuxBoundary() { + #expect(!hasDangerousSkip(["--tmux", "explain --dangerously-skip-permissions and continue"])) + } + + @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") + 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"])) + // 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") + func absentFlag() { + #expect(!hasDangerousSkip(["--model", "sonnet", "make a demo team"])) + #expect(!hasDangerousSkip([])) + } +} diff --git a/cmuxTests/CLITmuxCompatRemoteSplitTests.swift b/cmuxTests/CLITmuxCompatRemoteSplitTests.swift index 0d29bc711c38..ec33972f3228 100644 --- a/cmuxTests/CLITmuxCompatRemoteSplitTests.swift +++ b/cmuxTests/CLITmuxCompatRemoteSplitTests.swift @@ -73,6 +73,225 @@ import Testing #expect(!result.stderr.contains("already applied"), Comment(rawValue: result.stderr)) } + 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, + extraEnvironment: [String: 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) + 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 captured = CapturedRespawn() + let state = ServerState() + 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: reqLine) + } + 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] ?? [:] + captured.record( + command: params["command"] as? String, + startCommand: params["tmux_start_command"] as? String + ) + return Self.v2Response(id: id, ok: true, result: [:]) + default: + return Self.v2Response(id: id, ok: false, error: ["code": "unsupported", "message": method]) + } + } + + 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: environment, + 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)) + 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 every tmux respawn shell-command + /// 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 = "/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 /bin/sh -c, 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 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 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( + assignmentPrefix.command.hasPrefix(shellPrefix), + "assignment-prefixed command must be wrapped, got: \(assignmentPrefix.command)" + ) + + // 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( + shellForm.command.hasPrefix(shellPrefix), + "shell-invocation command must be wrapped, got: \(shellForm.command)" + ) + #expect( + shellForm.command.contains("/bin/sh -c \"opencode attach; sleep 1\""), + "wrapper must carry the original command verbatim, got: \(shellForm.command)" + ) + } + + /// 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. Because the gate is a real safety boundary, cmux waives it + /// 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 { + // 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" + + 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="), + "an opted-in claude-teams respawn must export CLAUDE_CODE_SANDBOXED, 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)" + ) + + // 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( + !substringOnly.command.contains("CLAUDE_CODE_SANDBOXED"), + "a command substring must not grant the trust bypass without the opt-in marker, got: \(substringOnly.command)" + ) + + // 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"), + "without the opt-in marker, respawn must not inject CLAUDE_CODE_SANDBOXED, got: \(outsideTeams.command)" + ) + } + + private enum CLITmuxCompatRespawnTestError: Error { + case noRespawnCommand + } + private final class CLITmuxCompatRemoteSplitBundleToken {} private final class ServerState: @unchecked Sendable { diff --git a/tests/test_cli_claude_teams_env.py b/tests/test_cli_claude_teams_env.py index ba0b2b9f9193..05d827663397 100644 --- a/tests/test_cli_claude_teams_env.py +++ b/tests/test_cli_claude_teams_env.py @@ -36,6 +36,8 @@ def run_claude_teams( real_bin.mkdir(parents=True, exist_ok=True) 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" @@ -56,6 +58,8 @@ 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" +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" @@ -108,6 +112,8 @@ 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_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) @@ -139,6 +145,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, @@ -156,6 +166,22 @@ 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) + + # 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") @@ -188,6 +214,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) 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()) diff --git a/tests/test_cli_omo_tmux_respawn_pane.py b/tests/test_cli_omo_tmux_respawn_pane.py index 302204403951..233c26c744b3 100644 --- a/tests/test_cli_omo_tmux_respawn_pane.py +++ b/tests/test_cli_omo_tmux_respawn_pane.py @@ -33,6 +33,15 @@ ) +def shell_wrapped(command: str) -> str: + """Mirror CMUXCLI.tmuxShellInvokedStartCommand: respawn shell-commands are run + 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 "/bin/sh -c " + 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") != 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") != 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,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") != "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") != 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}")