Skip to content
Closed
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
40c4b51
Add regression tests for fish Claude restore
Jun 26, 2026
852a660
Fix shell executable agent restore fallback
Jun 26, 2026
ad0e617
Merge remote-tracking branch 'origin/main' into issue-5796-fish-no-sh…
Jun 26, 2026
61a818d
Persist fresh agent snapshots over stale bindings
Jun 26, 2026
5f34133
Merge remote-tracking branch 'origin/main' into issue-5796-fish-no-sh…
Jun 26, 2026
f3fce2c
Limit shell wrapper detection to option prefix
Jun 26, 2026
02f53c4
Keep cross-kind bindings during snapshot preference
Jun 26, 2026
7e49c95
Preserve shell-named agent wrappers
Jun 26, 2026
422e994
Use instance launch capture trust policy
Jun 26, 2026
55d0d1a
Update CLI launch capture trust usage
Jun 26, 2026
1cb4ad2
Merge origin/main into fish restore fix
Jun 27, 2026
4f4e86c
Preserve launch env for shell argv captures
Jun 27, 2026
307222b
Handle shell option operands in launch trust
Jun 27, 2026
b7b9761
Require explicit kind for launch snapshot replacement
Jun 27, 2026
6a0c9f7
Simplify shell wrapper argv scan
Jun 27, 2026
8a4e823
Treat legacy resume bindings as stale
Jun 27, 2026
5bac04b
Narrow stale binding replacement to shell poison
Jun 27, 2026
fd8f2cf
Add Codex poisoned resume binding regression
Jun 27, 2026
f8d95df
Handle Codex shell resume poison
Jun 27, 2026
2f19e80
Cover synthesized Codex poison timestamps
Jun 27, 2026
1421d1e
Ignore poisoned binding timestamps
Jun 27, 2026
d628e70
Merge remote-tracking branch 'origin/main' into issue-5796-fish-no-sh…
Jun 29, 2026
fcd7ef3
Retrigger Vercel checks
Jun 29, 2026
66bf9ef
Merge remote-tracking branch 'origin/main' into issue-5796-fish-no-sh…
Jun 29, 2026
a7fc814
Retrigger CI and Vercel checks
Jun 29, 2026
ca455bc
Stabilize Codex hook regression parsing
Jun 29, 2026
6770509
Stabilize display resolution UI harness
Jun 29, 2026
af00bbb
Align generic hook resume guard expectation
Jun 29, 2026
9d42c11
Gate display churn on XCTest baseline marker
Jun 29, 2026
ea1e2c1
Satisfy test import policy for generic hook coverage
Jun 29, 2026
51d28b0
Pass display baseline marker through prelaunch manifest
Jun 29, 2026
1da3f68
Isolate app-host test home in CI
Jun 29, 2026
10c9c78
Handle split shell startup flags in launch capture
Jun 29, 2026
b86e665
Preserve Rust toolchain home for app-host tests
Jun 29, 2026
0eda7bb
Allow console user to write app-host test home
Jun 29, 2026
4eafbc5
Guard poisoned binding preference by freshness
Jun 29, 2026
95f2330
Merge origin/main into issue-5796 fish restore fix
austinywang Jun 29, 2026
1d2c58e
Retrigger Vercel checks
austinywang Jun 30, 2026
8b5ac39
Merge remote-tracking branch 'origin/main' into issue-5796-fish-no-sh…
austinywang Jul 3, 2026
9d6491d
Fix sessions list launch trust caller
austinywang Jul 3, 2026
eb86952
Add shell wrapper fork environment regression
austinywang Jul 3, 2026
d6232b9
Preserve sanitized fork launch environment
austinywang Jul 3, 2026
88775c4
Update Swift file length budget
austinywang Jul 3, 2026
7752730
Merge remote-tracking branch 'origin/main' into issue-5796-fish-no-sh…
austinywang Jul 4, 2026
b69993b
Merge remote-tracking branch 'origin/main' into issue-5796-fish-no-sh…
austinywang Jul 4, 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
8 changes: 4 additions & 4 deletions .github/swift-file-length-budget.tsv
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
# 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.
34643 CLI/cmux.swift
34644 CLI/cmux.swift
17854 Sources/AppDelegate.swift
16476 Sources/ContentView.swift
13913 Sources/TerminalController.swift
12886 Sources/Workspace.swift
12926 Sources/Workspace.swift
12348 cmuxTests/AppDelegateShortcutRoutingTests.swift
12237 Sources/GhosttyTerminalView.swift
11567 Sources/Panels/BrowserPanel.swift
Expand Down Expand Up @@ -46,7 +46,7 @@
2126 cmuxTests/CmuxConfigTests.swift
2091 cmuxTests/ShortcutAndCommandPaletteTests.swift
2078 Sources/SessionPersistence.swift
1960 Sources/RestorableAgentSession.swift
1964 Sources/RestorableAgentSession.swift
1952 Sources/KeyboardShortcutSettingsFileStore.swift
1900 cmuxTests/NotificationAndMenuBarTests.swift
1866 Sources/Panels/BrowserWebAuthnSupport.swift
Expand Down Expand Up @@ -123,6 +123,7 @@
768 cmuxUITests/BrowserFixtureInteractionUITests.swift
762 Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxNetworkByteTransport.swift
761 Sources/Mobile/AgentChat/AgentChatSessionRegistry.swift
761 cmuxTests/AgentSessionAutoResumeSwiftTests.swift
760 Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentLaunchSanitizerTests.swift
756 Sources/Panels/AgentSessionWebRendererCoordinator.swift
754 Sources/TerminalController+ControlWorkspaceContext.swift
Expand Down Expand Up @@ -160,7 +161,6 @@
642 cmuxTests/RemoteTmuxControlParserTests.swift
641 cmuxTests/CommandPaletteNucleoFFITests.swift
636 Sources/TerminalController+ControlPaneContext.swift
632 cmuxTests/AgentSessionAutoResumeSwiftTests.swift
630 Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutWhenClause.swift
627 Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/KeyboardShortcutsSection.swift
621 cmuxUITests/RightSidebarChromeHeightUITests.swift
Expand Down
9 changes: 5 additions & 4 deletions CLI/cmux.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26508,7 +26508,7 @@ struct CMUXCLI {
}

private func nativeProcessDescribesKnownAgent(for pid: pid_t) -> Bool {
AgentLaunchCaptureTrust.nativeProcessDescribesKnownAgent(
AgentLaunchCaptureTrust().nativeProcessDescribesKnownAgent(
processName: processName(for: pid),
arguments: processArguments(for: pid) ?? []
)
Expand Down Expand Up @@ -26669,7 +26669,8 @@ struct CMUXCLI {
// capture only when its launcher describes this hook's agent kind; otherwise
// fall back to the agent's own process argv and kind.
let envLauncher = normalizedHookValue(env["CMUX_AGENT_LAUNCH_KIND"])
let envCaptureIsTrusted = AgentLaunchCaptureTrust.launcherDescribesKind(
let launchCaptureTrust = AgentLaunchCaptureTrust()
let envCaptureIsTrusted = launchCaptureTrust.launcherDescribesKind(
envLauncher,
kind: fallbackKind
)
Expand All @@ -26679,15 +26680,15 @@ struct CMUXCLI {
var processArguments = fallbackPID.flatMap { fallbackPID -> [String]? in
let pid = pid_t(fallbackPID)
let candidate = self.processArguments(for: pid)
guard AgentLaunchCaptureTrust.nativeProcessDescribesKind(
guard launchCaptureTrust.nativeProcessDescribesKind(
processName: processName(for: pid),
arguments: candidate,
kind: fallbackKind
) else { return nil }
return candidate
}
if let candidate = processArguments,
AgentLaunchCaptureTrust.argvLooksLikeShellWrapper(candidate) {
launchCaptureTrust.argvLooksLikeShellWrapper(candidate) {
// The PID fallback resolved to a shell dispatcher (e.g. the hook's own
// `sh -c …` wrapper), not the agent. That argv is not a launch.
processArguments = nil
Expand Down
Original file line number Diff line number Diff line change
@@ -1,44 +1,67 @@
import Foundation

/// Decides whether a captured agent launch command can be trusted for the agent
/// kind it is stored under.
/// Decides whether captured agent launch metadata can be trusted for a stored agent kind.
///
/// `CMUX_AGENT_LAUNCH_*` is exported into the agent's process environment by the
/// cmux launch wrappers and therefore leaks to every descendant process: an agent
/// started from inside another agent's session (codex under claude, claude under
/// codex, …) inherits the ANCESTOR's launch capture. A hook that stores that
/// capture verbatim poisons resume/fork for the session — the rendered command
/// runs the wrong binary with the wrong flags.
public enum AgentLaunchCaptureTrust {
public struct AgentLaunchCaptureTrust: Sendable, Equatable {
/// Wrapper launchers that legitimately differ from the hook kind they launch.
private static let wrapperLaunchersByKind: [String: Set<String>] = [
"claude": ["claudeteams"],
"codex": ["codexteams"],
"opencode": ["omo", "omx", "omc"],
"pi": ["omp"],
]
private let wrapperLaunchersByKind: [String: Set<String>]
private let nativeProcessAliasesByKind: [String: Set<String>]
private let shellExecutableBasenames: Set<String>

private static let nativeProcessAliasesByKind: [String: Set<String>] = [
"antigravity": ["agy"],
"claude": ["claude"],
"codex": ["codex"],
"codebuddy": ["codebuddy"],
"copilot": ["copilot"],
"cursor": ["cursor-agent", "cursor"],
"factory": ["droid", "factory"],
"gemini": ["gemini"],
"grok": ["grok", "grok-macos-aarch64", "grok-macos-aarch"],
"kiro": ["kiro", "kiro-cli"],
"omp": ["omp"],
"opencode": ["opencode", "omo", "omx", "omc"],
"pi": ["pi", "omp"],
"qoder": ["qodercli", "qoder"],
"rovodev": ["rovodev", "rovo", "rovo-dev"],
]
/// Creates a launch-capture trust policy with cmux's built-in agent and shell aliases.
public init() {
wrapperLaunchersByKind = [
"claude": ["claudeteams"],
"codex": ["codexteams"],
"opencode": ["omo", "omx", "omc"],
"pi": ["omp"],
]

nativeProcessAliasesByKind = [
"antigravity": ["agy"],
"claude": ["claude"],
"codex": ["codex"],
"codebuddy": ["codebuddy"],
"copilot": ["copilot"],
"cursor": ["cursor-agent", "cursor"],
"factory": ["droid", "factory"],
"gemini": ["gemini"],
"grok": ["grok", "grok-macos-aarch64", "grok-macos-aarch"],
"kiro": ["kiro", "kiro-cli"],
"omp": ["omp"],
"opencode": ["opencode", "omo", "omx", "omc"],
"pi": ["pi", "omp"],
"qoder": ["qodercli", "qoder"],
"rovodev": ["rovodev", "rovo", "rovo-dev"],
]

shellExecutableBasenames = [
"bash",
"csh",
"dash",
"fish",
"ksh",
"login",
"sh",
"tcsh",
"zsh",
]
}

/// True when `launcher` plausibly describes a launch of agent `kind`.
///
/// A nil/empty launcher is trusted: hooks fall back to their own kind.
public static func launcherDescribesKind(_ launcher: String?, kind: String) -> Bool {
/// - Parameters:
/// - launcher: The captured launcher label, if any.
/// - kind: The agent kind that owns the captured session.
/// - Returns: Whether the launcher belongs to the supplied kind.
public func launcherDescribesKind(_ launcher: String?, kind: String) -> Bool {
guard let launcher = launcher?.trimmingCharacters(in: .whitespacesAndNewlines),
!launcher.isEmpty else {
return true
Expand All @@ -51,35 +74,73 @@ public enum AgentLaunchCaptureTrust {
return wrapperLaunchersByKind[normalizedKind]?.contains(normalizedLauncher) == true
}

/// True when `executable` is a known shell or login executable, not an agent binary.
/// - Parameter executable: The captured executable path or argv token.
/// - Returns: Whether the executable basename matches a known shell.
public func executableLooksLikeShell(_ executable: String?) -> Bool {
guard let executable = executable?.trimmingCharacters(in: .whitespacesAndNewlines),
!executable.isEmpty else {
return false
}
let basename = (executable as NSString).lastPathComponent
.trimmingCharacters(in: CharacterSet(charactersIn: "-"))
.lowercased()
return shellExecutableBasenames.contains(basename)
}

/// True when a captured argv describes a shell dispatcher (`sh -c …`,
/// `zsh -lc …`) rather than an agent launch. This happens when the
/// launch-capture PID fallback resolves to the hook's own dispatch shell
/// instead of the agent process. Requires both a shell argv[0] basename and
/// a command-string flag, so an agent that merely shares a shell's name
/// (e.g. a wrapper script named `fish`) is not misclassified.
public static func argvLooksLikeShellWrapper(_ arguments: [String]) -> Bool {
guard let argv0 = arguments.first?.trimmingCharacters(in: .whitespacesAndNewlines),
!argv0.isEmpty else {
/// shell dispatcher/profile-startup flags, so an agent that merely shares a
/// shell's name (e.g. a wrapper script named `fish`) is not misclassified.
/// - Parameter arguments: The captured process argv, including argv[0].
/// - Returns: Whether argv is a shell command-string dispatcher.
public func argvLooksLikeShellWrapper(_ arguments: [String]) -> Bool {
guard executableLooksLikeShell(arguments.first) else { return false }
var sawShellStartupOnlyFlag = false
for argument in arguments.dropFirst() {
if shellCommandStringFlag(argument) {
return true
}
if shellStartupOnlyFlag(argument) {
sawShellStartupOnlyFlag = true
continue
}
if argument == "--" || !argument.hasPrefix("-") {
return false
}
return false
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}
let name = (argv0 as NSString).lastPathComponent.lowercased()
let shells: Set<String> = ["sh", "bash", "zsh", "dash", "fish", "csh", "tcsh", "ksh"]
guard shells.contains(name) else { return false }
guard arguments.count >= 2 else { return false }
// A combined short option whose letters are shell mode flags and that
// includes `c` (-c, -lc, -ic, -lic, …): the command-string form.
let flag = arguments[1]
return sawShellStartupOnlyFlag
}

private func shellCommandStringFlag(_ flag: String) -> Bool {
guard flag.hasPrefix("-"), !flag.hasPrefix("--") else { return false }
let letters = flag.dropFirst()
return !letters.isEmpty
&& letters.contains("c")
&& letters.allSatisfy { "cilms".contains($0) }
}

private func shellStartupOnlyFlag(_ flag: String) -> Bool {
switch flag {
case "--no-config", "--no-rcs", "--noprofile", "--norc":
return true
default:
return false
}
}

/// True when PID-derived process metadata describes the same native agent as
/// the hook kind. This keeps unrelated parents, including Xcode test hosts
/// and the cmux app executable, from becoming persisted resume commands.
public static func nativeProcessDescribesKind(
/// - Parameters:
/// - processName: The process name reported by the system, if any.
/// - arguments: The captured process argv.
/// - kind: The hook kind that owns the session.
/// - Returns: Whether the process metadata describes the hook kind.
public func nativeProcessDescribesKind(
processName: String?,
arguments: [String]?,
kind: String
Expand All @@ -95,7 +156,12 @@ public enum AgentLaunchCaptureTrust {
}
}

public static func nativeProcessDescribesKnownAgent(
/// True when PID-derived process metadata describes any built-in agent.
/// - Parameters:
/// - processName: The process name reported by the system, if any.
/// - arguments: The captured process argv.
/// - Returns: Whether the metadata matches a known agent alias.
public func nativeProcessDescribesKnownAgent(
processName: String?,
arguments: [String]
) -> Bool {
Expand All @@ -105,7 +171,7 @@ public enum AgentLaunchCaptureTrust {
}
}

private static func nativeProcessDescriptors(
private func nativeProcessDescriptors(
processName: String?,
arguments: [String]
) -> Set<String> {
Expand Down Expand Up @@ -140,15 +206,15 @@ public enum AgentLaunchCaptureTrust {
return descriptors
}

private static func normalizedAgentName(_ value: String?) -> String? {
private func normalizedAgentName(_ value: String?) -> String? {
guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines),
!value.isEmpty else {
return nil
}
return value.lowercased()
}

private static func processBasename(_ value: String?) -> String? {
private func processBasename(_ value: String?) -> String? {
guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines),
!value.isEmpty else {
return nil
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,13 @@ import Foundation
/// per-kind verb (``builtInKind(kind:sessionId:executablePath:arguments:)``). Callers that also
/// support custom Vault agents slot that resolution between the two.
public struct AgentResumeArgv: Sendable, Equatable {
/// Creates a resume-argv builder. The type holds no state.
public init() {}
private let launchCaptureTrust: AgentLaunchCaptureTrust

/// Creates a resume-argv builder.
/// - Parameter launchCaptureTrust: The trust policy used to reject inherited shell dispatch captures.
public init(launchCaptureTrust: AgentLaunchCaptureTrust = AgentLaunchCaptureTrust()) {
self.launchCaptureTrust = launchCaptureTrust
}

/// The shell token that resolves cmux's `claude` wrapper at exec time.
///
Expand Down Expand Up @@ -367,7 +372,12 @@ public struct AgentResumeArgv: Sendable, Equatable {
arguments: [String],
fallbackExecutable: String
) -> (executable: String, tail: [String]) {
let executable = normalized(executablePath) ?? normalized(arguments.first) ?? fallbackExecutable
guard let executable = normalized(executablePath) ?? normalized(arguments.first) else {
return (fallbackExecutable, [])
}
if launchCaptureTrust.argvLooksLikeShellWrapper(arguments) {
return (fallbackExecutable, [])
}
let tail = arguments.isEmpty ? [] : Array(arguments.dropFirst())
return (executable, tail)
}
Expand Down
Loading
Loading