Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
ed01a2b
test resume approval batching behavior
azooz2003-bit Jul 28, 2026
1da86c5
add generalized resume approval state
azooz2003-bit Jul 28, 2026
445ce5a
batch surface resume approval prompts
azooz2003-bit Jul 28, 2026
2305960
Merge remote-tracking branch 'origin/main' into feat-resume-approval-…
azooz2003-bit Jul 30, 2026
6726e8f
Add resume approval batching regressions
azooz2003-bit Jul 30, 2026
f5e54d5
Fix resume approval batch review findings
azooz2003-bit Jul 30, 2026
1b07f3a
Add round-two resume approval regressions
azooz2003-bit Jul 30, 2026
4ba1109
Fix round-two resume approval findings
azooz2003-bit Jul 30, 2026
3ecc8cf
Add round-three resume approval regressions
azooz2003-bit Jul 30, 2026
be72423
Fix round-three resume approval findings
azooz2003-bit Jul 30, 2026
8f451c7
test: cover unsafe resume command expansions
azooz2003-bit Jul 30, 2026
489668b
fix: harden resume approval persistence
azooz2003-bit Jul 30, 2026
0d28fee
test: cover resume approval authorization gaps
azooz2003-bit Jul 30, 2026
6ac09f4
fix: scope resume approvals to safe local execution
azooz2003-bit Jul 30, 2026
62b7551
test: cover remaining resume approval gaps
azooz2003-bit Jul 30, 2026
2e2d335
fix: close remaining resume approval gaps
azooz2003-bit Jul 30, 2026
76c30c4
test: cover env-flag and trailing-arg approval generalization
azooz2003-bit Jul 30, 2026
54869b4
fix: fail closed on env flags and trailing resume arguments
azooz2003-bit Jul 30, 2026
2bb2115
Merge remote-tracking branch 'origin/main' into feat-resume-approval-…
azooz2003-bit Jul 31, 2026
8d84339
Merge remote-tracking branch 'origin/main' into feat-resume-approval-…
azooz2003-bit Jul 31, 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
68 changes: 68 additions & 0 deletions Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -229977,6 +229977,23 @@
}
}
},
"surfaceResumeApproval.proposal.applyToPrefix": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Apply to all commands starting with “%@” in this folder"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "このフォルダで「%@」から始まるすべてのコマンドに適用"
}
}
}
},
"surfaceResumeApproval.proposal.ask": {
"extractionState": "manual",
"localizations": {
Expand Down Expand Up @@ -230062,6 +230079,23 @@
}
}
},
"surfaceResumeApproval.runPrompt.dontAskAgain": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Don’t ask again for this command"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "このコマンドについて今後確認しない"
}
}
}
},
"surfaceResumeApproval.runPrompt.message": {
"extractionState": "manual",
"localizations": {
Expand Down Expand Up @@ -230096,6 +230130,23 @@
}
}
},
"surfaceResumeApproval.runPrompt.runAll": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Run All"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "すべて実行"
}
}
}
},
"surfaceResumeApproval.runPrompt.skip": {
"extractionState": "manual",
"localizations": {
Expand All @@ -230113,6 +230164,23 @@
}
}
},
"surfaceResumeApproval.runPrompt.skipAll": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Skip All"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "すべてスキップ"
}
}
}
},
"surfaceResumeApproval.runPrompt.title": {
"extractionState": "manual",
"localizations": {
Expand Down
36 changes: 29 additions & 7 deletions Sources/ControlSurfaceResumeTarget.swift
Original file line number Diff line number Diff line change
Expand Up @@ -249,8 +249,12 @@ extension TerminalController {
) else {
return .resolved(effectiveBinding)
}
let policy = surfacePromptForResumeApproval(binding: effectiveBinding)
guard let record = SurfaceResumeApprovalStore.approve(binding: binding, policy: policy) else {
let approval = surfacePromptForResumeApproval(binding: effectiveBinding)
guard let record = SurfaceResumeApprovalStore.approve(
binding: binding,
policy: approval.policy,
commandPrefix: approval.commandPrefix
) else {
return .resolved(effectiveBinding)
}
effectiveBinding.approvalPolicy = record.policy
Expand All @@ -268,7 +272,7 @@ extension TerminalController {

private func surfacePromptForResumeApproval(
binding: SurfaceResumeBindingSnapshot
) -> SurfaceResumeApprovalPolicy {
) -> (policy: SurfaceResumeApprovalPolicy, commandPrefix: [String]?) {
let alert = NSAlert()
alert.alertStyle = .informational
alert.messageText = String(
Expand All @@ -287,19 +291,37 @@ extension TerminalController {
alert.addButton(withTitle: String(localized: "surfaceResumeApproval.proposal.auto", defaultValue: "Auto-Restore"))
alert.addButton(withTitle: String(localized: "surfaceResumeApproval.proposal.ask", defaultValue: "Ask Each Time"))
alert.addButton(withTitle: String(localized: "surfaceResumeApproval.proposal.manual", defaultValue: "Keep Manual"))
let generalizedPrefix = SurfaceResumeCommandCanonicalizer.generalizedApprovalPrefix(
forCommand: binding.command
)
if let generalizedPrefix {
let renderedPrefix = generalizedPrefix
.map(SurfaceResumeCommandCanonicalizer.shellQuoted)
.joined(separator: " ")
alert.showsSuppressionButton = true
alert.suppressionButton?.title = String(
format: String(
localized: "surfaceResumeApproval.proposal.applyToPrefix",
defaultValue: "Apply to all commands starting with “%@” in this folder"
),
renderedPrefix
)
}
let content = CmuxAlertContent(
flattenedText: informativeText,
separatingScrollableDetails: binding.command
)
content.apply(to: alert, presentingWindow: nil)

switch alert.runModal() {
let response = alert.runModal()
let commandPrefix = alert.suppressionButton?.state == .on ? generalizedPrefix : nil
switch response {
case .alertFirstButtonReturn:
return .auto
return (.auto, commandPrefix)
case .alertSecondButtonReturn:
return .prompt
return (.prompt, commandPrefix)
default:
return .manual
return (.manual, commandPrefix)
}
}

Expand Down
70 changes: 70 additions & 0 deletions Sources/SessionPersistence.swift
Original file line number Diff line number Diff line change
Expand Up @@ -652,6 +652,54 @@ enum SurfaceResumeCommandCanonicalizer {
return tokens.isEmpty ? nil : tokens
}

static func generalizedApprovalPrefix(forCommand command: String) -> [String]? {
guard let tokens = tokens(from: command) else {
return nil
}

var prefix: [String] = []
var index = tokens.startIndex

if tokens[index] == "{" || tokens[index] == "cd" {
guard let guardEndIndex = tokens[index...].firstIndex(of: "&&") else {
return nil
}
prefix.append(contentsOf: tokens[index...guardEndIndex])
index = tokens.index(after: guardEndIndex)
}

while index < tokens.endIndex, isEnvironmentAssignment(tokens[index]) {
prefix.append(tokens[index])
index = tokens.index(after: index)
}

if index < tokens.endIndex, tokens[index] == "env" || tokens[index] == "/usr/bin/env" {
prefix.append(tokens[index])
index = tokens.index(after: index)
while index < tokens.endIndex, isEnvironmentAssignment(tokens[index]) {
prefix.append(tokens[index])
index = tokens.index(after: index)
}
}

guard index < tokens.endIndex else {
return nil
}
prefix.append(tokens[index])
index = tokens.index(after: index)

while index < tokens.endIndex {
let token = tokens[index]
guard token.hasPrefix("-") || token == "resume" else {
break
}
prefix.append(token)
index = tokens.index(after: index)
}

return prefix.count == tokens.count ? nil : prefix
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}

static func normalizedCWD(_ rawValue: String?) -> String? {
guard let rawValue = rawValue?.trimmingCharacters(in: .whitespacesAndNewlines),
!rawValue.isEmpty else {
Expand All @@ -669,6 +717,28 @@ enum SurfaceResumeCommandCanonicalizer {
return "'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'"
}

private static func isEnvironmentAssignment(_ token: String) -> Bool {
guard let equalsIndex = token.firstIndex(of: "=") else {
return false
}
let nameScalars = token[..<equalsIndex].unicodeScalars
guard let firstScalar = nameScalars.first,
isEnvironmentNameStart(firstScalar) else {
return false
}
return nameScalars.dropFirst().allSatisfy(isEnvironmentNameContinuation)
}

private static func isEnvironmentNameStart(_ scalar: UnicodeScalar) -> Bool {
scalar == "_" ||
(scalar.value >= 65 && scalar.value <= 90) ||
(scalar.value >= 97 && scalar.value <= 122)
}

private static func isEnvironmentNameContinuation(_ scalar: UnicodeScalar) -> Bool {
isEnvironmentNameStart(scalar) ||
(scalar.value >= 48 && scalar.value <= 57)
}
}

enum SurfaceResumeApprovalSignature {
Expand Down
19 changes: 19 additions & 0 deletions Sources/SurfaceResumeRunPromptBatch.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import Foundation

@MainActor
final class SurfaceResumeRunPromptBatch {
static let shared = SurfaceResumeRunPromptBatch()

enum StickyDecision {
case runAll
case skipAll
}

var stickyDecision: StickyDecision?

private init() {}

func reset() {
stickyDecision = nil
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}
}
40 changes: 39 additions & 1 deletion Sources/Workspace.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2654,6 +2654,16 @@ final class Workspace: Identifiable, ObservableObject {

@MainActor
private static func shouldRunPromptedSurfaceResumeOnMain(_ binding: SurfaceResumeBindingSnapshot) -> Bool {
let promptBatch = SurfaceResumeRunPromptBatch.shared
switch promptBatch.stickyDecision {
case .runAll:
return true
case .skipAll:
return false
case nil:
break
}

let alert = NSAlert()
alert.alertStyle = .informational
alert.messageText = String(
Expand All @@ -2669,12 +2679,40 @@ final class Workspace: Identifiable, ObservableObject {
binding.command
)
alert.addButton(withTitle: String(localized: "surfaceResumeApproval.runPrompt.run", defaultValue: "Run"))
alert.addButton(withTitle: String(localized: "surfaceResumeApproval.runPrompt.runAll", defaultValue: "Run All"))
alert.addButton(withTitle: String(localized: "surfaceResumeApproval.runPrompt.skip", defaultValue: "Skip"))
alert.addButton(withTitle: String(localized: "surfaceResumeApproval.runPrompt.skipAll", defaultValue: "Skip All"))
alert.showsSuppressionButton = true
alert.suppressionButton?.title = String(
localized: "surfaceResumeApproval.runPrompt.dontAskAgain",
defaultValue: "Don’t ask again for this command"
)
let content = CmuxAlertContent(
flattenedText: informativeText,
separatingScrollableDetails: binding.command
)
return alert.runCmuxModal(content: content) == .alertFirstButtonReturn
let response = alert.runCmuxModal(content: content)
let shouldRun: Bool
if response == .alertFirstButtonReturn {
shouldRun = true
} else if response == .alertSecondButtonReturn {
promptBatch.stickyDecision = .runAll
shouldRun = true
} else if response == .alertThirdButtonReturn {
shouldRun = false
} else if response.rawValue == NSApplication.ModalResponse.alertFirstButtonReturn.rawValue + 3 {
promptBatch.stickyDecision = .skipAll
shouldRun = false
} else {
shouldRun = false
}

if shouldRun,
alert.suppressionButton?.state == .on,
let approvalRecordId = binding.approvalRecordId {
SurfaceResumeApprovalStore.update(recordId: approvalRecordId, policy: .auto)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
return shouldRun
}

// MARK: - Initialization
Expand Down
4 changes: 4 additions & 0 deletions cmux.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -1868,6 +1868,7 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources
842300000000000000000001 /* SurfaceResumeExitedAgentLivenessTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 842300000000000000000002 /* SurfaceResumeExitedAgentLivenessTests.swift */; };
7989B0027989B0027989B002 /* SurfaceResumeLaunchFlavor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7989B1027989B1027989B102 /* SurfaceResumeLaunchFlavor.swift */; };
7989B0037989B0037989B003 /* SurfaceResumeRemoteContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7989B1037989B1037989B103 /* SurfaceResumeRemoteContext.swift */; };
F27B00000000000000000001 /* SurfaceResumeRunPromptBatch.swift in Sources */ = {isa = PBXBuildFile; fileRef = F27B00000000000000000002 /* SurfaceResumeRunPromptBatch.swift */; };
A5001303 /* SurfaceSearchOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001301 /* SurfaceSearchOverlay.swift */; };
C51A73B40000000000000002 /* SurfaceTabBarButtonConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = C51A73B40000000000000001 /* SurfaceTabBarButtonConfiguration.swift */; };
A7206A010000000000000001 /* SystemAppearanceObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7206A020000000000000001 /* SystemAppearanceObserver.swift */; };
Expand Down Expand Up @@ -4159,6 +4160,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa =
842300000000000000000002 /* SurfaceResumeExitedAgentLivenessTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SurfaceResumeExitedAgentLivenessTests.swift; sourceTree = "<group>"; };
7989B1027989B1027989B102 /* SurfaceResumeLaunchFlavor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SurfaceResumeLaunchFlavor.swift; sourceTree = "<group>"; };
7989B1037989B1037989B103 /* SurfaceResumeRemoteContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SurfaceResumeRemoteContext.swift; sourceTree = "<group>"; };
F27B00000000000000000002 /* SurfaceResumeRunPromptBatch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SurfaceResumeRunPromptBatch.swift; sourceTree = "<group>"; };
A5001301 /* SurfaceSearchOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Find/SurfaceSearchOverlay.swift; sourceTree = "<group>"; };
C51A73B40000000000000001 /* SurfaceTabBarButtonConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SurfaceTabBarButtonConfiguration.swift; sourceTree = "<group>"; };
A7206A020000000000000001 /* SystemAppearanceObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemAppearanceObserver.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -6056,6 +6058,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa =
883700000000000000000004 /* SurfaceResumeApprovalLookup.swift */,
875900000000000000000006 /* SurfaceResumeApprovalSigningSecretCache.swift */,
883700000000000000000002 /* SurfaceResumeApprovalSigningSecretResolution.swift */,
F27B00000000000000000002 /* SurfaceResumeRunPromptBatch.swift */,
769F7D1FD3622B7D06998B0F /* SessionPersistence+Todos.swift */,
C54860090000000000000002 /* SessionRestoreIdentityExclusions.swift */,
A7477001000000000000000A /* SessionPersistencePolicy+ConfigFrames.swift */,
Expand Down Expand Up @@ -8641,6 +8644,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa =
F6572000A1B2C3D4E5F60718 /* SurfaceResumeCommandCanonicalizer+PortableAgentExecutable.swift in Sources */,
7989B0027989B0027989B002 /* SurfaceResumeLaunchFlavor.swift in Sources */,
7989B0037989B0037989B003 /* SurfaceResumeRemoteContext.swift in Sources */,
F27B00000000000000000001 /* SurfaceResumeRunPromptBatch.swift in Sources */,
A5001303 /* SurfaceSearchOverlay.swift in Sources */,
C51A73B40000000000000002 /* SurfaceTabBarButtonConfiguration.swift in Sources */,
A7206A010000000000000001 /* SystemAppearanceObserver.swift in Sources */,
Expand Down
Loading