From f2f1302ba70b45480a9caa1b716a90c278742e5c Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Tue, 26 May 2026 23:11:36 -0700 Subject: [PATCH 1/9] test: cover agent resume with deleted cwd --- cmuxTests/SessionPersistenceTests.swift | 55 +++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/cmuxTests/SessionPersistenceTests.swift b/cmuxTests/SessionPersistenceTests.swift index 7302879b7ed1..57b4aad48a46 100644 --- a/cmuxTests/SessionPersistenceTests.swift +++ b/cmuxTests/SessionPersistenceTests.swift @@ -3802,6 +3802,61 @@ extension SessionPersistenceTests { ) } + func testAgentHookSurfaceResumeStartupInputRunsWhenSavedWorkingDirectoryWasDeleted() throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent("cmux-surface-resume-missing-cwd-\(UUID().uuidString)", isDirectory: true) + let bin = root.appendingPathComponent("bin", isDirectory: true) + let deletedCwd = root.appendingPathComponent("deleted", isDirectory: true) + .appendingPathComponent("repo", isDirectory: true) + let outputURL = root.appendingPathComponent("codex-output.txt", isDirectory: false) + try fileManager.createDirectory(at: bin, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: root) } + + let fakeCodex = bin.appendingPathComponent("codex", isDirectory: false) + try """ + #!/bin/zsh + print -r -- "$PWD|$*" > "$CMUX_FAKE_CODEX_OUTPUT" + """.write(to: fakeCodex, atomically: true, encoding: .utf8) + try fileManager.setAttributes([.posixPermissions: 0o700], ofItemAtPath: fakeCodex.path) + + let binding = SurfaceResumeBindingSnapshot( + kind: "codex", + command: "cd '\(deletedCwd.path)' && codex resume session-duplicate-turn --yolo", + cwd: deletedCwd.path, + checkpointId: "session-duplicate-turn", + source: "agent-hook", + environment: [ + "CLAUDE_CONFIG_DIR": root.appendingPathComponent("claude-profile", isDirectory: true).path + ], + autoResume: true + ) + + let startupInput = try XCTUnwrap(binding.startupInput) + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/zsh") + process.arguments = ["-lc", startupInput] + var environment = ProcessInfo.processInfo.environment + environment["PATH"] = "\(bin.path):\(environment["PATH"] ?? "/usr/bin:/bin")" + environment["CMUX_FAKE_CODEX_OUTPUT"] = outputURL.path + process.environment = environment + let stderr = Pipe() + process.standardError = stderr + + try process.run() + process.waitUntilExit() + + let errorText = String( + data: stderr.fileHandleForReading.readDataToEndOfFile(), + encoding: .utf8 + ) ?? "" + XCTAssertEqual(process.terminationStatus, 0, errorText) + + let output = try String(contentsOf: outputURL, encoding: .utf8) + XCTAssertTrue(output.contains("resume session-duplicate-turn --yolo"), output) + XCTAssertFalse(output.hasPrefix("\(deletedCwd.path)|"), output) + } + func testSurfaceResumeBindingStartupInputUsesLauncherScriptWhenLong() throws { let tempDir = FileManager.default.temporaryDirectory .appendingPathComponent("cmux-surface-resume-test-\(UUID().uuidString)", isDirectory: true) From 739e349afb928a0abd48f5f5f0a4ead94bb47898 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Tue, 26 May 2026 23:22:22 -0700 Subject: [PATCH 2/9] fix: resume agents when saved cwd is gone --- CLI/CMUXCLI+TmuxCompatSupport.swift | 6 +- CLI/cmux.swift | 8 ++- Sources/RestorableAgentSession.swift | 69 +++++++++++++++++-- Sources/SessionPersistence.swift | 11 ++- .../SessionRestoredTerminalCommandStore.swift | 3 +- cmuxTests/AgentHibernationTests.swift | 2 +- ...ifyProcessIntegrationRegressionTests.swift | 2 +- cmuxTests/PiVaultAgentPersistenceTests.swift | 16 ++--- ...storableAgentHookProviderHermesTests.swift | 2 +- ...storableAgentHookProviderResumeTests.swift | 24 +++---- cmuxTests/RovoDevSessionIndexTests.swift | 2 +- cmuxTests/SessionPersistenceTests.swift | 46 ++++++------- cmuxTests/WorkspaceUnitTests.swift | 8 +-- 13 files changed, 136 insertions(+), 63 deletions(-) diff --git a/CLI/CMUXCLI+TmuxCompatSupport.swift b/CLI/CMUXCLI+TmuxCompatSupport.swift index 2c901c635082..8bdbf3ac1f65 100644 --- a/CLI/CMUXCLI+TmuxCompatSupport.swift +++ b/CLI/CMUXCLI+TmuxCompatSupport.swift @@ -47,7 +47,8 @@ extension CMUXCLI { var pieces: [String] = [] if let trimmedCwd, !trimmedCwd.isEmpty { - pieces.append("cd -- \(tmuxShellQuote(resolvePath(trimmedCwd)))") + let quotedCwd = tmuxShellQuote(resolvePath(trimmedCwd)) + pieces.append("{ [ ! -d \(quotedCwd) ] || cd -- \(quotedCwd); }") } if !commandText.isEmpty { pieces.append(commandText) @@ -202,7 +203,8 @@ extension CMUXCLI { "rm -f -- \"$0\" 2>/dev/null || true" ] if let cwd = cwd?.trimmingCharacters(in: .whitespacesAndNewlines), !cwd.isEmpty { - lines.append("cd -- \(tmuxShellQuote(resolvePath(cwd))) || exit $?") + let quotedCwd = tmuxShellQuote(resolvePath(cwd)) + lines.append("{ [ ! -d \(quotedCwd) ] || cd -- \(quotedCwd); } || exit $?") } lines.append("exec \"${SHELL:-/bin/sh}\" -lc \(tmuxShellQuote(commandText))") do { diff --git a/CLI/cmux.swift b/CLI/cmux.swift index eae7247309e2..adb29e7aae0f 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -16935,7 +16935,8 @@ struct CMUXCLI { "rm -f -- \"$0\" 2>/dev/null || true" ] if let cwd = cwd?.trimmingCharacters(in: .whitespacesAndNewlines), !cwd.isEmpty { - lines.append("cd -- \(codexTeamsShellQuote(cwd)) || exit $?") + let quotedCwd = codexTeamsShellQuote(cwd) + lines.append("{ [ ! -d \(quotedCwd) ] || cd -- \(quotedCwd); } || exit $?") } lines.append("exec \"${SHELL:-/bin/sh}\" -lc \(codexTeamsShellQuote(commandText))") do { @@ -23279,9 +23280,10 @@ struct CMUXCLI { var commandParts: [String] = [] commandParts.append(contentsOf: argv) - var command = commandParts.map(cliShellQuote).joined(separator: " ") + let command = commandParts.map(cliShellQuote).joined(separator: " ") if let cwd = normalizedHookValue(workingDirectory) { - command = "cd \(cliShellQuote(cwd)) && \(command)" + let quotedCwd = cliShellQuote(cwd) + return "{ [ ! -d \(quotedCwd) ] || cd -- \(quotedCwd); } && \(command)" } return command } diff --git a/Sources/RestorableAgentSession.swift b/Sources/RestorableAgentSession.swift index 36feebe00308..261287bf67db 100644 --- a/Sources/RestorableAgentSession.swift +++ b/Sources/RestorableAgentSession.swift @@ -32,6 +32,68 @@ fileprivate func shellSingleQuoted(_ value: String) -> String { TerminalStartupShellQuoting.singleQuoted(value) } +nonisolated enum TerminalStartupWorkingDirectoryPrefix { + static func optionalChangeDirectoryPrefix(for workingDirectory: String?) -> String? { + guard let workingDirectory = normalized(workingDirectory) else { return nil } + let quoted = TerminalStartupShellQuoting.singleQuoted(workingDirectory) + return "{ [ ! -d \(quoted) ] || cd -- \(quoted); } && " + } + + static func prefix(_ command: String, workingDirectory: String?) -> String { + guard let prefix = optionalChangeDirectoryPrefix(for: workingDirectory) else { + return command + } + return prefix + command + } + + static func replacingRequiredChangeDirectoryPrefix( + in command: String, + workingDirectory: String? + ) -> String { + let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines) + guard let workingDirectory = normalized(workingDirectory) else { return trimmed } + if let existingPrefix = optionalChangeDirectoryPrefix(for: workingDirectory), + trimmed.hasPrefix(existingPrefix) { + return trimmed + } + let stripped = strippedRequiredChangeDirectoryPrefix( + from: trimmed, + workingDirectory: workingDirectory + ) + return prefix(stripped, workingDirectory: workingDirectory) + } + + private static func strippedRequiredChangeDirectoryPrefix( + from command: String, + workingDirectory: String + ) -> String { + let quotedCandidates = [ + TerminalStartupShellQuoting.singleQuoted(workingDirectory), + legacySingleQuoted(workingDirectory) + ] + var seen = Set() + for quoted in quotedCandidates where seen.insert(quoted).inserted { + let prefix = "cd \(quoted) && " + if command.hasPrefix(prefix) { + return String(command.dropFirst(prefix.count)) + } + } + return command + } + + private static func normalized(_ value: String?) -> String? { + guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines), + !trimmed.isEmpty else { + return nil + } + return trimmed + } + + private static func legacySingleQuoted(_ value: String) -> String { + "'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'" + } +} + enum AgentResumeCommandBuilder { private static let claudeAuthSelectionEnvironmentKeys: Set = [ "ANTHROPIC_API_KEY", @@ -119,14 +181,11 @@ enum AgentResumeCommandBuilder { } commandParts.append(contentsOf: argv) - var shellCommand = commandParts.map(shellSingleQuoted).joined(separator: " ") + let shellCommand = commandParts.map(shellSingleQuoted).joined(separator: " ") let cwd = !includeWorkingDirectoryPrefix || customRegistration?.cwd == .ignore ? nil : normalized(workingDirectory ?? launchCommand?.workingDirectory) - if let cwd { - shellCommand = "cd \(shellSingleQuoted(cwd)) && \(shellCommand)" - } - return shellCommand + return TerminalStartupWorkingDirectoryPrefix.prefix(shellCommand, workingDirectory: cwd) } static func openCodeVersionProbe( diff --git a/Sources/SessionPersistence.swift b/Sources/SessionPersistence.swift index 6fdb5377d292..e1ba441743d2 100644 --- a/Sources/SessionPersistence.swift +++ b/Sources/SessionPersistence.swift @@ -325,7 +325,7 @@ nonisolated struct SurfaceResumeBindingSnapshot: Codable, Equatable, Sendable { } var inlineStartupInput: String? { - let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines) + let trimmed = startupCommand.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return nil } guard let environment, !environment.isEmpty else { return trimmed + "\n" @@ -338,6 +338,15 @@ nonisolated struct SurfaceResumeBindingSnapshot: Codable, Equatable, Sendable { return argv.map(Self.shellSingleQuoted).joined(separator: " ") + "\n" } + private var startupCommand: String { + let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines) + guard isAgentHookBinding else { return trimmed } + return TerminalStartupWorkingDirectoryPrefix.replacingRequiredChangeDirectoryPrefix( + in: trimmed, + workingDirectory: cwd + ) + } + func startupInputWithLauncherScript( fileManager: FileManager = .default, temporaryDirectory: URL = FileManager.default.temporaryDirectory, diff --git a/Sources/SessionRestoredTerminalCommandStore.swift b/Sources/SessionRestoredTerminalCommandStore.swift index b79567a305a9..0a5d485bde06 100644 --- a/Sources/SessionRestoredTerminalCommandStore.swift +++ b/Sources/SessionRestoredTerminalCommandStore.swift @@ -26,7 +26,8 @@ enum SessionRestoredTerminalCommandStore { "rm -f -- \"$0\" 2>/dev/null || true" ] if let workingDirectory = normalized(workingDirectory) { - lines.append("cd -- \(shellSingleQuoted(workingDirectory)) || exit $?") + let quotedDirectory = shellSingleQuoted(workingDirectory) + lines.append("{ [ ! -d \(quotedDirectory) ] || cd -- \(quotedDirectory); } || exit $?") } lines.append("exec \"${SHELL:-/bin/zsh}\" -lc \(shellSingleQuoted(trimmedCommand))") diff --git a/cmuxTests/AgentHibernationTests.swift b/cmuxTests/AgentHibernationTests.swift index bdd2293d2ae9..bb9971810c1a 100644 --- a/cmuxTests/AgentHibernationTests.swift +++ b/cmuxTests/AgentHibernationTests.swift @@ -690,7 +690,7 @@ final class AgentHibernationTests: XCTestCase { ) XCTAssertEqual(snapshot.agentDisplayName, "Local Agent") - XCTAssertEqual(snapshot.resumeCommand, "cd '/tmp/custom-agent' && '/usr/local/bin/local-agent' 'resume' 'custom-session'") + XCTAssertEqual(snapshot.resumeCommand, "{ [ ! -d '/tmp/custom-agent' ] || cd -- '/tmp/custom-agent'; } && '/usr/local/bin/local-agent' 'resume' 'custom-session'") } @MainActor diff --git a/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift b/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift index 190c6237287f..af4713d94694 100644 --- a/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift +++ b/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift @@ -6484,7 +6484,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { XCTAssertEqual(request["auto_resume"] as? Bool, true) XCTAssertEqual( request["command"] as? String, - "cd '\(root.path)' && '/usr/local/bin/cmux' 'codex-teams' 'resume' '\(sessionId)' '--model' 'gpt-5.4' '--sandbox' 'danger-full-access'" + "{ [ ! -d '\(root.path)' ] || cd -- '\(root.path)'; } && '/usr/local/bin/cmux' 'codex-teams' 'resume' '\(sessionId)' '--model' 'gpt-5.4' '--sandbox' 'danger-full-access'" ) } diff --git a/cmuxTests/PiVaultAgentPersistenceTests.swift b/cmuxTests/PiVaultAgentPersistenceTests.swift index 3672003751ba..d07b72112041 100644 --- a/cmuxTests/PiVaultAgentPersistenceTests.swift +++ b/cmuxTests/PiVaultAgentPersistenceTests.swift @@ -104,7 +104,7 @@ final class PiVaultAgentPersistenceTests: XCTestCase { XCTAssertEqual(entry.cwd, "/tmp/antigravity repo") XCTAssertEqual( entry.resumeCommand, - "cd '/tmp/antigravity repo' && 'agy' '--conversation' 'antigravity-conversation-123'" + "{ [ ! -d '/tmp/antigravity repo' ] || cd -- '/tmp/antigravity repo'; } && 'agy' '--conversation' 'antigravity-conversation-123'" ) } @@ -146,7 +146,7 @@ final class PiVaultAgentPersistenceTests: XCTestCase { XCTAssertEqual(filtered.map(\.sessionId), ["conversation-b"]) XCTAssertEqual( filtered.first?.resumeCommand, - "cd '/tmp/antigravity repo' && 'agy' '--conversation' 'conversation-b'" + "{ [ ! -d '/tmp/antigravity repo' ] || cd -- '/tmp/antigravity repo'; } && 'agy' '--conversation' 'conversation-b'" ) } @@ -254,7 +254,7 @@ final class PiVaultAgentPersistenceTests: XCTestCase { let entry = try XCTUnwrap(entries.first) XCTAssertEqual(entry.sessionId, "native-session-123") - XCTAssertEqual(entry.resumeCommand, "cd '/tmp/acme' && 'acme-agent' '--session' 'native-session-123'") + XCTAssertEqual(entry.resumeCommand, "{ [ ! -d '/tmp/acme' ] || cd -- '/tmp/acme'; } && 'acme-agent' '--session' 'native-session-123'") } func testBuiltInGrokRegistrationUsesNativeSessionDirectory() { @@ -649,7 +649,7 @@ final class PiVaultAgentPersistenceTests: XCTestCase { XCTAssertEqual(entry.fileURL, historyURL) XCTAssertEqual( entry.resumeCommand, - "cd '/tmp/grok repo' && 'env' 'GROK_HOME=\(grokHome.path)' 'grok' '-r' 'grok-session-123' '-m' 'grok-4' '--permission-mode' 'auto' '--sandbox' 'danger-full-access'" + "{ [ ! -d '/tmp/grok repo' ] || cd -- '/tmp/grok repo'; } && 'env' 'GROK_HOME=\(grokHome.path)' 'grok' '-r' 'grok-session-123' '-m' 'grok-4' '--permission-mode' 'auto' '--sandbox' 'danger-full-access'" ) } @@ -801,7 +801,7 @@ final class PiVaultAgentPersistenceTests: XCTestCase { XCTAssertEqual(entry.cwd, cwd) XCTAssertEqual( entry.resumeCommand, - "cd '/tmp/grok observed home' && 'env' 'GROK_HOME=\(grokHome.path)' 'grok' '-r' '\(sessionId)' '-m' 'grok-4' '--permission-mode' 'auto' '--sandbox' 'danger-full-access'" + "{ [ ! -d '/tmp/grok observed home' ] || cd -- '/tmp/grok observed home'; } && 'env' 'GROK_HOME=\(grokHome.path)' 'grok' '-r' '\(sessionId)' '-m' 'grok-4' '--permission-mode' 'auto' '--sandbox' 'danger-full-access'" ) } @@ -862,7 +862,7 @@ final class PiVaultAgentPersistenceTests: XCTestCase { XCTAssertEqual(entry.title, "Find sessions under custom hook state") XCTAssertEqual( entry.resumeCommand, - "cd '/tmp/grok custom state' && 'env' 'GROK_HOME=\(grokHome.path)' 'grok' '-r' '\(sessionId)' '-m' 'grok-4'" + "{ [ ! -d '/tmp/grok custom state' ] || cd -- '/tmp/grok custom state'; } && 'env' 'GROK_HOME=\(grokHome.path)' 'grok' '-r' '\(sessionId)' '-m' 'grok-4'" ) } @@ -913,7 +913,7 @@ final class PiVaultAgentPersistenceTests: XCTestCase { XCTAssertEqual(entry.gitBranch, "issue-4394-grok-vault-resume") XCTAssertEqual( entry.resumeCommand, - "cd '/tmp/custom grok repo' && 'env' 'GROK_HOME=\(tempDir.path)' 'custom-grok' '-r' '\(sessionId)'" + "{ [ ! -d '/tmp/custom grok repo' ] || cd -- '/tmp/custom grok repo'; } && 'env' 'GROK_HOME=\(tempDir.path)' 'custom-grok' '-r' '\(sessionId)'" ) } @@ -1071,7 +1071,7 @@ final class PiVaultAgentPersistenceTests: XCTestCase { XCTAssertEqual(loadedAgent.sessionId, sessionPath) XCTAssertEqual( loadedAgent.resumeCommand, - "cd '/tmp/pi repo' && '/opt/homebrew/bin/pi' '--session' '\(sessionPath)'" + "{ [ ! -d '/tmp/pi repo' ] || cd -- '/tmp/pi repo'; } && '/opt/homebrew/bin/pi' '--session' '\(sessionPath)'" ) } diff --git a/cmuxTests/RestorableAgentHookProviderHermesTests.swift b/cmuxTests/RestorableAgentHookProviderHermesTests.swift index a04b6c4d8bd1..2b3bbccd28a4 100644 --- a/cmuxTests/RestorableAgentHookProviderHermesTests.swift +++ b/cmuxTests/RestorableAgentHookProviderHermesTests.swift @@ -39,7 +39,7 @@ extension SocketListenerAcceptPolicyTests { XCTAssertEqual( snapshot.resumeCommand, - "cd '/tmp/hermes repo' && 'env' 'HERMES_HOME=/tmp/hermes home' '/opt/homebrew/bin/hermes' '--tui' '--model' 'anthropic/claude-sonnet-4.6' '--resume' 'hermes-session-123'" + "{ [ ! -d '/tmp/hermes repo' ] || cd -- '/tmp/hermes repo'; } && 'env' 'HERMES_HOME=/tmp/hermes home' '/opt/homebrew/bin/hermes' '--tui' '--model' 'anthropic/claude-sonnet-4.6' '--resume' 'hermes-session-123'" ) } diff --git a/cmuxTests/RestorableAgentHookProviderResumeTests.swift b/cmuxTests/RestorableAgentHookProviderResumeTests.swift index 164d347aa0a9..57050f55d564 100644 --- a/cmuxTests/RestorableAgentHookProviderResumeTests.swift +++ b/cmuxTests/RestorableAgentHookProviderResumeTests.swift @@ -40,7 +40,7 @@ extension SocketListenerAcceptPolicyTests { XCTAssertEqual( snapshot.resumeCommand, - "cd '/tmp/gemini repo' && 'env' 'GEMINI_CLI_HOME=/tmp/gemini home' '/Users/example/.bun/bin/gemini' '--resume' '5839bed1-0a60-4c05-b6d1-2410d7a3741e' '--model' 'gemini-2.5-pro' '--sandbox' 'danger-full-access' '--approval-mode' 'yolo'" + "{ [ ! -d '/tmp/gemini repo' ] || cd -- '/tmp/gemini repo'; } && 'env' 'GEMINI_CLI_HOME=/tmp/gemini home' '/Users/example/.bun/bin/gemini' '--resume' '5839bed1-0a60-4c05-b6d1-2410d7a3741e' '--model' 'gemini-2.5-pro' '--sandbox' 'danger-full-access' '--approval-mode' 'yolo'" ) } @@ -74,7 +74,7 @@ extension SocketListenerAcceptPolicyTests { XCTAssertEqual( snapshot.resumeCommand, - "cd '/tmp/antigravity repo' && 'env' 'GEMINI_CLI_HOME=/tmp/gemini home' '/Users/example/.local/bin/agy' '--conversation' 'antigravity-conversation-123' '--sandbox' 'danger-full-access' '--add-dir' '/tmp/extra repo'" + "{ [ ! -d '/tmp/antigravity repo' ] || cd -- '/tmp/antigravity repo'; } && 'env' 'GEMINI_CLI_HOME=/tmp/gemini home' '/Users/example/.local/bin/agy' '--conversation' 'antigravity-conversation-123' '--sandbox' 'danger-full-access' '--add-dir' '/tmp/extra repo'" ) } @@ -107,7 +107,7 @@ extension SocketListenerAcceptPolicyTests { XCTAssertEqual( snapshot.resumeCommand, - "cd '/tmp/rovo repo' && 'env' 'CMUX_ROVODEV_SESSIONS_DIR=/tmp/rovo sessions' '/opt/homebrew/bin/acli' 'rovodev' 'run' '--restore' 'session with space' '--yolo'" + "{ [ ! -d '/tmp/rovo repo' ] || cd -- '/tmp/rovo repo'; } && 'env' 'CMUX_ROVODEV_SESSIONS_DIR=/tmp/rovo sessions' '/opt/homebrew/bin/acli' 'rovodev' 'run' '--restore' 'session with space' '--yolo'" ) } @@ -137,7 +137,7 @@ extension SocketListenerAcceptPolicyTests { XCTAssertEqual( snapshot.resumeCommand, - "cd '~/.cursor' && '/usr/local/bin/agent' '--resume' '019dad34-d218-7943-b81a-eddac5c87951' '--model' 'gpt-5.4'" + "{ [ ! -d '~/.cursor' ] || cd -- '~/.cursor'; } && '/usr/local/bin/agent' '--resume' '019dad34-d218-7943-b81a-eddac5c87951' '--model' 'gpt-5.4'" ) } @@ -339,32 +339,32 @@ extension SocketListenerAcceptPolicyTests { XCTAssertEqual( cursor.resumeCommand, - "cd '/tmp/cursor repo' && '/Users/example/.local/bin/cursor-agent' '--resume' 'cursor-chat-123' '--model' 'gpt-5.4' '--sandbox' 'enabled'" + "{ [ ! -d '/tmp/cursor repo' ] || cd -- '/tmp/cursor repo'; } && '/Users/example/.local/bin/cursor-agent' '--resume' 'cursor-chat-123' '--model' 'gpt-5.4' '--sandbox' 'enabled'" ) XCTAssertEqual( copilot.resumeCommand, - "cd '/tmp/copilot repo' && 'env' 'COPILOT_HOME=/tmp/copilot home' '/tmp/cmux-agent-upstreams/copilot-install/bin/copilot' '--resume' 'copilot-session-123' '--model' 'gpt-5.4' '--allow-all-tools'" + "{ [ ! -d '/tmp/copilot repo' ] || cd -- '/tmp/copilot repo'; } && 'env' 'COPILOT_HOME=/tmp/copilot home' '/tmp/cmux-agent-upstreams/copilot-install/bin/copilot' '--resume' 'copilot-session-123' '--model' 'gpt-5.4' '--allow-all-tools'" ) XCTAssertEqual( codeBuddy.resumeCommand, - "cd '/tmp/codebuddy repo' && 'env' 'CODEBUDDY_CONFIG_DIR=/tmp/codebuddy config' '/Users/example/.npm/bin/codebuddy' '--resume' 'codebuddy-session-123' '--model' 'gpt-5.4' '--permission-mode' 'plan'" + "{ [ ! -d '/tmp/codebuddy repo' ] || cd -- '/tmp/codebuddy repo'; } && 'env' 'CODEBUDDY_CONFIG_DIR=/tmp/codebuddy config' '/Users/example/.npm/bin/codebuddy' '--resume' 'codebuddy-session-123' '--model' 'gpt-5.4' '--permission-mode' 'plan'" ) XCTAssertEqual( factory.resumeCommand, - "cd '/tmp/factory repo' && '/Users/example/.npm/bin/droid' '--resume' 'factory-session-123' '--cwd' '/tmp/factory repo' '--append-system-prompt' 'be terse'" + "{ [ ! -d '/tmp/factory repo' ] || cd -- '/tmp/factory repo'; } && '/Users/example/.npm/bin/droid' '--resume' 'factory-session-123' '--cwd' '/tmp/factory repo' '--append-system-prompt' 'be terse'" ) XCTAssertEqual( qoder.resumeCommand, - "cd '/tmp/qoder repo' && 'env' 'QODER_CONFIG_DIR=/tmp/qoder config' '/Users/example/.npm/bin/qodercli' '--resume' 'qoder-session-123' '--model' 'gemini-2.5-pro' '--permission-mode' 'plan' '--workspace' '/tmp/qoder repo'" + "{ [ ! -d '/tmp/qoder repo' ] || cd -- '/tmp/qoder repo'; } && 'env' 'QODER_CONFIG_DIR=/tmp/qoder config' '/Users/example/.npm/bin/qodercli' '--resume' 'qoder-session-123' '--model' 'gemini-2.5-pro' '--permission-mode' 'plan' '--workspace' '/tmp/qoder repo'" ) XCTAssertEqual( grok.resumeCommand, - "cd '/tmp/grok repo' && 'env' 'GROK_HOME=/tmp/grok home' '/Users/example/.grok/bin/grok' '-r' 'grok-session-123' '--model' 'grok-4' '--permission-mode' 'auto' '--cwd' '/tmp/grok repo'" + "{ [ ! -d '/tmp/grok repo' ] || cd -- '/tmp/grok repo'; } && 'env' 'GROK_HOME=/tmp/grok home' '/Users/example/.grok/bin/grok' '-r' 'grok-session-123' '--model' 'grok-4' '--permission-mode' 'auto' '--cwd' '/tmp/grok repo'" ) - XCTAssertEqual(pi.resumeCommand, "cd '/tmp/pi repo' && 'env' 'PI_CODING_AGENT_DIR=/tmp/pi home' '/Users/example/.bun/bin/pi' '--session' 'pi-session-123' '--model' 'anthropic/claude-sonnet-4-5' '--thinking' 'high'") + XCTAssertEqual(pi.resumeCommand, "{ [ ! -d '/tmp/pi repo' ] || cd -- '/tmp/pi repo'; } && 'env' 'PI_CODING_AGENT_DIR=/tmp/pi home' '/Users/example/.bun/bin/pi' '--session' 'pi-session-123' '--model' 'anthropic/claude-sonnet-4-5' '--thinking' 'high'") XCTAssertEqual( amp.resumeCommand, - "cd '/tmp/amp repo' && 'env' 'AMP_SETTINGS_FILE=/tmp/amp-settings.json' '/Users/example/.local/bin/amp' 'threads' 'continue' '--mode' 'smart' '--effort' 'high' 'T-019e032c-c31a-77a9-ad87-8298ec47029f'" + "{ [ ! -d '/tmp/amp repo' ] || cd -- '/tmp/amp repo'; } && 'env' 'AMP_SETTINGS_FILE=/tmp/amp-settings.json' '/Users/example/.local/bin/amp' 'threads' 'continue' '--mode' 'smart' '--effort' 'high' 'T-019e032c-c31a-77a9-ad87-8298ec47029f'" ) } diff --git a/cmuxTests/RovoDevSessionIndexTests.swift b/cmuxTests/RovoDevSessionIndexTests.swift index 3da201a1d712..2ea63b3e6211 100644 --- a/cmuxTests/RovoDevSessionIndexTests.swift +++ b/cmuxTests/RovoDevSessionIndexTests.swift @@ -117,7 +117,7 @@ final class RovoDevSessionIndexTests: XCTestCase { XCTAssertEqual(entry.fileURL?.lastPathComponent, "session_context.json") XCTAssertEqual( entry.resumeCommand, - "cd '/tmp/rovo repo' && acli rovodev run --restore 'session with space'" + "{ [ ! -d '/tmp/rovo repo' ] || cd -- '/tmp/rovo repo'; } && acli rovodev run --restore 'session with space'" ) } diff --git a/cmuxTests/SessionPersistenceTests.swift b/cmuxTests/SessionPersistenceTests.swift index 57b4aad48a46..842fd25bc302 100644 --- a/cmuxTests/SessionPersistenceTests.swift +++ b/cmuxTests/SessionPersistenceTests.swift @@ -1181,7 +1181,7 @@ final class SessionPersistenceTests: XCTestCase { XCTAssertEqual(agent.sessionId, "antigravity-conversation-123") XCTAssertEqual( agent.resumeCommand, - "cd '/tmp/repo' && '/usr/local/bin/agy' '--conversation' 'antigravity-conversation-123' '--sandbox' 'danger-full-access'" + "{ [ ! -d '/tmp/repo' ] || cd -- '/tmp/repo'; } && '/usr/local/bin/agy' '--conversation' 'antigravity-conversation-123' '--sandbox' 'danger-full-access'" ) } @@ -1953,7 +1953,7 @@ final class SocketListenerAcceptPolicyTests: XCTestCase { XCTAssertEqual( snapshot.resumeCommand, - "cd '/tmp/cmux project' && 'env' 'CLAUDE_CONFIG_DIR=/tmp/claude config' 'CMUX_PRESERVE_CLAUDE_AUTH_SELECTION_ENV=1' 'CMUX_PRESERVE_CLAUDE_AUTH_SELECTION_ENV_KEYS=CLAUDE_CONFIG_DIR' '/opt/Claude Code/bin/claude' '--resume' 'claude-session-123' '--model' 'sonnet' '--permission-mode' 'auto'" + "{ [ ! -d '/tmp/cmux project' ] || cd -- '/tmp/cmux project'; } && 'env' 'CLAUDE_CONFIG_DIR=/tmp/claude config' 'CMUX_PRESERVE_CLAUDE_AUTH_SELECTION_ENV=1' 'CMUX_PRESERVE_CLAUDE_AUTH_SELECTION_ENV_KEYS=CLAUDE_CONFIG_DIR' '/opt/Claude Code/bin/claude' '--resume' 'claude-session-123' '--model' 'sonnet' '--permission-mode' 'auto'" ) } @@ -2235,7 +2235,7 @@ final class SocketListenerAcceptPolicyTests: XCTestCase { XCTAssertEqual( snapshot.resumeCommand, - "cd '/Users/lawrence/fun' && 'env' 'CLAUDE_CONFIG_DIR=/Users/lawrence/.codex-accounts/claude/_p1775010019397' 'CMUX_PRESERVE_CLAUDE_AUTH_SELECTION_ENV=1' 'CMUX_PRESERVE_CLAUDE_AUTH_SELECTION_ENV_KEYS=CLAUDE_CONFIG_DIR' '/Users/lawrence/.local/bin/claude' '--resume' '24ec0052-450c-4914-b1dd-2ee80d4bc84b' '--dangerously-load-development-channels' 'server:custom-dev-channel' '--dangerously-skip-permissions'" + "{ [ ! -d '/Users/lawrence/fun' ] || cd -- '/Users/lawrence/fun'; } && 'env' 'CLAUDE_CONFIG_DIR=/Users/lawrence/.codex-accounts/claude/_p1775010019397' 'CMUX_PRESERVE_CLAUDE_AUTH_SELECTION_ENV=1' 'CMUX_PRESERVE_CLAUDE_AUTH_SELECTION_ENV_KEYS=CLAUDE_CONFIG_DIR' '/Users/lawrence/.local/bin/claude' '--resume' '24ec0052-450c-4914-b1dd-2ee80d4bc84b' '--dangerously-load-development-channels' 'server:custom-dev-channel' '--dangerously-skip-permissions'" ) } @@ -2269,7 +2269,7 @@ final class SocketListenerAcceptPolicyTests: XCTestCase { XCTAssertEqual( snapshot.resumeCommand, - "cd '/Users/example/repo' && 'env' 'CODEX_HOME=/tmp/codex home' '/Users/example/.bun/bin/codex' 'resume' '019dad34-d218-7943-b81a-eddac5c87951' '--model' 'gpt-5.4' '--sandbox' 'danger-full-access' '--ask-for-approval' 'never' '--search' '--cd' '/Users/example/repo'" + "{ [ ! -d '/Users/example/repo' ] || cd -- '/Users/example/repo'; } && 'env' 'CODEX_HOME=/tmp/codex home' '/Users/example/.bun/bin/codex' 'resume' '019dad34-d218-7943-b81a-eddac5c87951' '--model' 'gpt-5.4' '--sandbox' 'danger-full-access' '--ask-for-approval' 'never' '--search' '--cd' '/Users/example/repo'" ) } @@ -2300,7 +2300,7 @@ final class SocketListenerAcceptPolicyTests: XCTestCase { XCTAssertEqual( snapshot.resumeCommand, - "cd '/Users/lawrence/fun/cmuxterm-hq' && '/Users/lawrence/.bun/bin/codex' 'resume' '019e2bb9-5544-7201-a517-d77bb00d724f' '--yolo' '--model' 'gpt-5.4'" + "{ [ ! -d '/Users/lawrence/fun/cmuxterm-hq' ] || cd -- '/Users/lawrence/fun/cmuxterm-hq'; } && '/Users/lawrence/.bun/bin/codex' 'resume' '019e2bb9-5544-7201-a517-d77bb00d724f' '--yolo' '--model' 'gpt-5.4'" ) } @@ -2332,7 +2332,7 @@ final class SocketListenerAcceptPolicyTests: XCTestCase { XCTAssertEqual( snapshot.resumeCommand, - "cd '/Users/example/repo' && 'env' 'CODEX_HOME=/tmp/codex home' '/usr/local/bin/cmux' 'codex-teams' 'resume' '019dad34-d218-7943-b81a-eddac5c87951' '--model' 'gpt-5.4' '--sandbox' 'danger-full-access'" + "{ [ ! -d '/Users/example/repo' ] || cd -- '/Users/example/repo'; } && 'env' 'CODEX_HOME=/tmp/codex home' '/usr/local/bin/cmux' 'codex-teams' 'resume' '019dad34-d218-7943-b81a-eddac5c87951' '--model' 'gpt-5.4' '--sandbox' 'danger-full-access'" ) } @@ -2364,7 +2364,7 @@ final class SocketListenerAcceptPolicyTests: XCTestCase { XCTAssertEqual( snapshot.resumeCommand, - "cd '/Users/example/repo' && 'env' 'CODEX_HOME=/tmp/codex home' '/usr/local/bin/cmux' 'codex-teams' 'resume' '019dad34-d218-7943-b81a-eddac5c87952' '--model' 'gpt-5.4' '--sandbox' 'danger-full-access'" + "{ [ ! -d '/Users/example/repo' ] || cd -- '/Users/example/repo'; } && 'env' 'CODEX_HOME=/tmp/codex home' '/usr/local/bin/cmux' 'codex-teams' 'resume' '019dad34-d218-7943-b81a-eddac5c87952' '--model' 'gpt-5.4' '--sandbox' 'danger-full-access'" ) } @@ -2622,43 +2622,43 @@ final class SocketListenerAcceptPolicyTests: XCTestCase { XCTAssertEqual( claude.forkCommand, - "cd '/Users/lawrence/fun' && 'env' 'CLAUDE_CONFIG_DIR=/Users/lawrence/.codex-accounts/claude/_p1775010019397' 'CMUX_PRESERVE_CLAUDE_AUTH_SELECTION_ENV=1' 'CMUX_PRESERVE_CLAUDE_AUTH_SELECTION_ENV_KEYS=CLAUDE_CONFIG_DIR' '/Users/lawrence/.local/bin/claude' '--resume' '24ec0052-450c-4914-b1dd-2ee80d4bc84b' '--fork-session' '--dangerously-load-development-channels' 'server:custom-dev-channel' '--dangerously-skip-permissions'" + "{ [ ! -d '/Users/lawrence/fun' ] || cd -- '/Users/lawrence/fun'; } && 'env' 'CLAUDE_CONFIG_DIR=/Users/lawrence/.codex-accounts/claude/_p1775010019397' 'CMUX_PRESERVE_CLAUDE_AUTH_SELECTION_ENV=1' 'CMUX_PRESERVE_CLAUDE_AUTH_SELECTION_ENV_KEYS=CLAUDE_CONFIG_DIR' '/Users/lawrence/.local/bin/claude' '--resume' '24ec0052-450c-4914-b1dd-2ee80d4bc84b' '--fork-session' '--dangerously-load-development-channels' 'server:custom-dev-channel' '--dangerously-skip-permissions'" ) XCTAssertEqual( claudeFork.forkCommand, - "cd '/Users/lawrence/fun' && 'env' 'CLAUDE_CONFIG_DIR=/Users/lawrence/.codex-accounts/claude/_p1775010019397' 'CMUX_PRESERVE_CLAUDE_AUTH_SELECTION_ENV=1' 'CMUX_PRESERVE_CLAUDE_AUTH_SELECTION_ENV_KEYS=CLAUDE_CONFIG_DIR' '/Users/lawrence/.local/bin/claude' '--resume' 'claude-fork-child' '--fork-session' '--model' 'sonnet' '--dangerously-skip-permissions'" + "{ [ ! -d '/Users/lawrence/fun' ] || cd -- '/Users/lawrence/fun'; } && 'env' 'CLAUDE_CONFIG_DIR=/Users/lawrence/.codex-accounts/claude/_p1775010019397' 'CMUX_PRESERVE_CLAUDE_AUTH_SELECTION_ENV=1' 'CMUX_PRESERVE_CLAUDE_AUTH_SELECTION_ENV_KEYS=CLAUDE_CONFIG_DIR' '/Users/lawrence/.local/bin/claude' '--resume' 'claude-fork-child' '--fork-session' '--model' 'sonnet' '--dangerously-skip-permissions'" ) XCTAssertEqual( codex.forkCommand, - "cd '/Users/example/repo' && 'env' 'CODEX_HOME=/tmp/codex home' '/Users/example/.bun/bin/codex' 'fork' '019dad34-d218-7943-b81a-eddac5c87951' '--model' 'gpt-5.4' '--sandbox' 'danger-full-access' '--ask-for-approval' 'never' '--search' '--cd' '/Users/example/repo'" + "{ [ ! -d '/Users/example/repo' ] || cd -- '/Users/example/repo'; } && 'env' 'CODEX_HOME=/tmp/codex home' '/Users/example/.bun/bin/codex' 'fork' '019dad34-d218-7943-b81a-eddac5c87951' '--model' 'gpt-5.4' '--sandbox' 'danger-full-access' '--ask-for-approval' 'never' '--search' '--cd' '/Users/example/repo'" ) XCTAssertEqual( codexWithImage.forkCommand, - "cd '/Users/example/repo' && 'env' 'CODEX_HOME=/tmp/codex home' '/Users/example/.bun/bin/codex' 'fork' '019image-session' '--model' 'gpt-5.4'" + "{ [ ! -d '/Users/example/repo' ] || cd -- '/Users/example/repo'; } && 'env' 'CODEX_HOME=/tmp/codex home' '/Users/example/.bun/bin/codex' 'fork' '019image-session' '--model' 'gpt-5.4'" ) XCTAssertEqual( codexFork.forkCommand, - "cd '/Users/example/repo' && 'env' 'CODEX_HOME=/tmp/codex home' '/Users/example/.bun/bin/codex' 'fork' '019e1eca-ee32-7001-ab30-edcae57430bb' '--model' 'gpt-5.4' '--sandbox' 'danger-full-access' '--search'" + "{ [ ! -d '/Users/example/repo' ] || cd -- '/Users/example/repo'; } && 'env' 'CODEX_HOME=/tmp/codex home' '/Users/example/.bun/bin/codex' 'fork' '019e1eca-ee32-7001-ab30-edcae57430bb' '--model' 'gpt-5.4' '--sandbox' 'danger-full-access' '--search'" ) XCTAssertEqual( codexTeams.forkCommand, - "cd '/Users/example/repo' && 'env' 'CODEX_HOME=/tmp/codex home' '/usr/local/bin/cmux' 'codex-teams' 'fork' 'codex-teams-session' '--model' 'gpt-5.4' '--sandbox' 'danger-full-access'" + "{ [ ! -d '/Users/example/repo' ] || cd -- '/Users/example/repo'; } && 'env' 'CODEX_HOME=/tmp/codex home' '/usr/local/bin/cmux' 'codex-teams' 'fork' 'codex-teams-session' '--model' 'gpt-5.4' '--sandbox' 'danger-full-access'" ) XCTAssertEqual( directOpenCode.forkCommand, - "cd '/tmp/direct opencode repo' && 'env' 'OPENCODE_CONFIG_DIR=/tmp/opencode config' '/opt/homebrew/bin/opencode' '--session' 'direct-opencode-session-456' '--fork' '--model' 'anthropic/claude-sonnet-4-6' '--port' '4096' '/tmp/direct opencode repo'" + "{ [ ! -d '/tmp/direct opencode repo' ] || cd -- '/tmp/direct opencode repo'; } && 'env' 'OPENCODE_CONFIG_DIR=/tmp/opencode config' '/opt/homebrew/bin/opencode' '--session' 'direct-opencode-session-456' '--fork' '--model' 'anthropic/claude-sonnet-4-6' '--port' '4096' '/tmp/direct opencode repo'" ) XCTAssertEqual( directOpenCodeFork.forkCommand, - "cd '/tmp/direct opencode repo' && 'env' 'OPENCODE_CONFIG_DIR=/tmp/opencode config' '/opt/homebrew/bin/opencode' '--session' 'direct-opencode-child-session' '--fork' '--model' 'anthropic/claude-sonnet-4-6' '--port' '4096' '/tmp/direct opencode repo'" + "{ [ ! -d '/tmp/direct opencode repo' ] || cd -- '/tmp/direct opencode repo'; } && 'env' 'OPENCODE_CONFIG_DIR=/tmp/opencode config' '/opt/homebrew/bin/opencode' '--session' 'direct-opencode-child-session' '--fork' '--model' 'anthropic/claude-sonnet-4-6' '--port' '4096' '/tmp/direct opencode repo'" ) XCTAssertEqual( omoOpenCode.forkCommand, - "cd '/tmp/opencode repo' && 'env' 'OPENCODE_CONFIG_DIR=/tmp/opencode config' '/usr/local/bin/cmux' 'omo' '--session' 'opencode-session-123' '--fork' '--model' 'anthropic/claude-sonnet-4-6' '/tmp/opencode repo'" + "{ [ ! -d '/tmp/opencode repo' ] || cd -- '/tmp/opencode repo'; } && 'env' 'OPENCODE_CONFIG_DIR=/tmp/opencode config' '/usr/local/bin/cmux' 'omo' '--session' 'opencode-session-123' '--fork' '--model' 'anthropic/claude-sonnet-4-6' '/tmp/opencode repo'" ) XCTAssertEqual( omoOpenCodeFork.forkCommand, - "cd '/tmp/opencode repo' && 'env' 'OPENCODE_CONFIG_DIR=/tmp/opencode config' '/usr/local/bin/cmux' 'omo' '--session' 'opencode-child-session' '--fork' '--model' 'anthropic/claude-sonnet-4-6' '/tmp/opencode repo'" + "{ [ ! -d '/tmp/opencode repo' ] || cd -- '/tmp/opencode repo'; } && 'env' 'OPENCODE_CONFIG_DIR=/tmp/opencode config' '/usr/local/bin/cmux' 'omo' '--session' 'opencode-child-session' '--fork' '--model' 'anthropic/claude-sonnet-4-6' '/tmp/opencode repo'" ) XCTAssertNil(unsupported.forkCommand) } @@ -3126,7 +3126,7 @@ final class SocketListenerAcceptPolicyTests: XCTestCase { XCTAssertEqual( snapshot.forkCommand, - "cd '/tmp/opencode repo' && '\(executable.path)' '--session' 'opencode-session-123' '--fork' '--model' 'anthropic/claude-sonnet-4-6' '--agent' 'build' '--port' '4096' '/tmp/opencode repo'" + "{ [ ! -d '/tmp/opencode repo' ] || cd -- '/tmp/opencode repo'; } && '\(executable.path)' '--session' 'opencode-session-123' '--fork' '--model' 'anthropic/claude-sonnet-4-6' '--agent' 'build' '--port' '4096' '/tmp/opencode repo'" ) } @@ -3257,7 +3257,7 @@ final class SocketListenerAcceptPolicyTests: XCTestCase { XCTAssertEqual( snapshot.resumeCommand, - "cd '/tmp/team repo' && 'env' 'CMUX_CUSTOM_CLAUDE_PATH=/opt/Claude Code/bin/claude' '/Applications/cmux.app/Contents/Resources/bin/cmux' 'claude-teams' '--resume' 'claude-team-session' '--teammate-mode' 'auto' '--model' 'sonnet' '--remote-control-session-name-prefix' 'cmux-team' '--permission-mode' 'auto'" + "{ [ ! -d '/tmp/team repo' ] || cd -- '/tmp/team repo'; } && 'env' 'CMUX_CUSTOM_CLAUDE_PATH=/opt/Claude Code/bin/claude' '/Applications/cmux.app/Contents/Resources/bin/cmux' 'claude-teams' '--resume' 'claude-team-session' '--teammate-mode' 'auto' '--model' 'sonnet' '--remote-control-session-name-prefix' 'cmux-team' '--permission-mode' 'auto'" ) } @@ -3469,15 +3469,15 @@ final class SocketListenerAcceptPolicyTests: XCTestCase { XCTAssertEqual( direct.resumeCommand, - "cd '/tmp/direct opencode repo' && 'env' 'OPENCODE_CONFIG_DIR=/tmp/opencode config' '/opt/homebrew/bin/opencode' '--session' 'direct-opencode-session-456' '--model' 'anthropic/claude-sonnet-4-6' '--port' '4096' '/tmp/direct opencode repo'" + "{ [ ! -d '/tmp/direct opencode repo' ] || cd -- '/tmp/direct opencode repo'; } && 'env' 'OPENCODE_CONFIG_DIR=/tmp/opencode config' '/opt/homebrew/bin/opencode' '--session' 'direct-opencode-session-456' '--model' 'anthropic/claude-sonnet-4-6' '--port' '4096' '/tmp/direct opencode repo'" ) XCTAssertEqual( omo.resumeCommand, - "cd '/tmp/opencode repo' && 'env' 'OPENCODE_CONFIG_DIR=/tmp/opencode config' '/usr/local/bin/cmux' 'omo' '--session' 'opencode-session-123' '--model' 'anthropic/claude-sonnet-4-6' '/tmp/opencode repo'" + "{ [ ! -d '/tmp/opencode repo' ] || cd -- '/tmp/opencode repo'; } && 'env' 'OPENCODE_CONFIG_DIR=/tmp/opencode config' '/usr/local/bin/cmux' 'omo' '--session' 'opencode-session-123' '--model' 'anthropic/claude-sonnet-4-6' '/tmp/opencode repo'" ) XCTAssertEqual( staleBunWorker.resumeCommand, - "cd '/Users/lawrence/fun' && '/Users/lawrence/.bun/bin/opencode' '--session' 'ses_24b0be92affeVRRBplLmUzbXQl'" + "{ [ ! -d '/Users/lawrence/fun' ] || cd -- '/Users/lawrence/fun'; } && '/Users/lawrence/.bun/bin/opencode' '--session' 'ses_24b0be92affeVRRBplLmUzbXQl'" ) XCTAssertNil(omx.resumeCommand) XCTAssertNil(omc.resumeCommand) @@ -3532,7 +3532,7 @@ final class SocketListenerAcceptPolicyTests: XCTestCase { XCTAssertEqual(snapshot.launchCommand?.arguments.first, "/usr/local/bin/codex") XCTAssertEqual( snapshot.resumeCommand, - "cd '/tmp/repo' && 'env' 'CODEX_HOME=/tmp/codex' '/usr/local/bin/codex' 'resume' 'codex-session-123' '--model' 'gpt-5.4' '--search'" + "{ [ ! -d '/tmp/repo' ] || cd -- '/tmp/repo'; } && 'env' 'CODEX_HOME=/tmp/codex' '/usr/local/bin/codex' 'resume' 'codex-session-123' '--model' 'gpt-5.4' '--search'" ) } diff --git a/cmuxTests/WorkspaceUnitTests.swift b/cmuxTests/WorkspaceUnitTests.swift index 26286ed0f434..0b2284472038 100644 --- a/cmuxTests/WorkspaceUnitTests.swift +++ b/cmuxTests/WorkspaceUnitTests.swift @@ -5347,7 +5347,7 @@ final class WorkspacePanelGitBranchTests: XCTestCase { XCTAssertEqual(forkPanel.requestedWorkingDirectory, "/tmp/workspace fork repo") XCTAssertEqual( forkPanel.surface.initialInput, - "cd '/tmp/workspace fork repo' && '/Users/example/.bun/bin/codex' 'fork' '019dad34-d218-7943-b81a-eddac5c87951'\n" + "{ [ ! -d '/tmp/workspace fork repo' ] || cd -- '/tmp/workspace fork repo'; } && '/Users/example/.bun/bin/codex' 'fork' '019dad34-d218-7943-b81a-eddac5c87951'\n" ) } @@ -5448,7 +5448,7 @@ final class WorkspacePanelGitBranchTests: XCTestCase { XCTAssertEqual(workspace.panelDirectories[forkPanel.id], "/Users/cmux/fallback repo") XCTAssertEqual( forkPanel.surface.initialInput, - "cd '/Users/cmux/fallback repo' && '/Users/example/.bun/bin/codex' 'fork' '019dad34-d218-7943-b81a-eddac5c87951'\n" + "{ [ ! -d '/Users/cmux/fallback repo' ] || cd -- '/Users/cmux/fallback repo'; } && '/Users/example/.bun/bin/codex' 'fork' '019dad34-d218-7943-b81a-eddac5c87951'\n" ) } @@ -5606,7 +5606,7 @@ final class WorkspacePanelGitBranchTests: XCTestCase { XCTAssertEqual(launch.initialTerminalCommand, "ssh -tt cmux-macmini") XCTAssertEqual( launch.initialTerminalInput, - "cd '/Users/cmux/fallback repo' && '/Users/example/.bun/bin/codex' 'fork' '019dad34-d218-7943-b81a-eddac5c87951'\n" + "{ [ ! -d '/Users/cmux/fallback repo' ] || cd -- '/Users/cmux/fallback repo'; } && '/Users/example/.bun/bin/codex' 'fork' '019dad34-d218-7943-b81a-eddac5c87951'\n" ) } @@ -5643,7 +5643,7 @@ final class WorkspacePanelGitBranchTests: XCTestCase { XCTAssertNil(launch.remoteConfiguration) XCTAssertEqual( launch.initialTerminalInput, - "cd '/tmp/local fork repo' && '/Users/example/.bun/bin/codex' 'fork' '019dad34-d218-7943-b81a-eddac5c87951'\n" + "{ [ ! -d '/tmp/local fork repo' ] || cd -- '/tmp/local fork repo'; } && '/Users/example/.bun/bin/codex' 'fork' '019dad34-d218-7943-b81a-eddac5c87951'\n" ) } From 05146ab257dea07950b0ea31c1c196ea7efd6add Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Tue, 26 May 2026 23:45:58 -0700 Subject: [PATCH 3/9] fix: harden restored cwd guard --- CLI/CMUXCLI+TmuxCompatSupport.swift | 4 +- CLI/cmux.swift | 4 +- Sources/RestorableAgentSession.swift | 15 +-- Sources/SessionPersistence.swift | 54 ++++++++- .../SessionRestoredTerminalCommandStore.swift | 2 +- cmuxTests/AgentHibernationTests.swift | 2 +- ...ifyProcessIntegrationRegressionTests.swift | 2 +- cmuxTests/PiVaultAgentPersistenceTests.swift | 16 +-- ...storableAgentHookProviderHermesTests.swift | 2 +- ...storableAgentHookProviderResumeTests.swift | 24 ++-- cmuxTests/RovoDevSessionIndexTests.swift | 2 +- cmuxTests/SessionPersistenceTests.swift | 105 ++++++++++++++---- cmuxTests/WorkspaceUnitTests.swift | 8 +- 13 files changed, 173 insertions(+), 67 deletions(-) diff --git a/CLI/CMUXCLI+TmuxCompatSupport.swift b/CLI/CMUXCLI+TmuxCompatSupport.swift index 8bdbf3ac1f65..7d228de06031 100644 --- a/CLI/CMUXCLI+TmuxCompatSupport.swift +++ b/CLI/CMUXCLI+TmuxCompatSupport.swift @@ -48,7 +48,7 @@ extension CMUXCLI { var pieces: [String] = [] if let trimmedCwd, !trimmedCwd.isEmpty { let quotedCwd = tmuxShellQuote(resolvePath(trimmedCwd)) - pieces.append("{ [ ! -d \(quotedCwd) ] || cd -- \(quotedCwd); }") + pieces.append("{ cd -- \(quotedCwd) 2>/dev/null || [ ! -d \(quotedCwd) ]; }") } if !commandText.isEmpty { pieces.append(commandText) @@ -204,7 +204,7 @@ extension CMUXCLI { ] if let cwd = cwd?.trimmingCharacters(in: .whitespacesAndNewlines), !cwd.isEmpty { let quotedCwd = tmuxShellQuote(resolvePath(cwd)) - lines.append("{ [ ! -d \(quotedCwd) ] || cd -- \(quotedCwd); } || exit $?") + lines.append("{ cd -- \(quotedCwd) 2>/dev/null || [ ! -d \(quotedCwd) ]; } || exit $?") } lines.append("exec \"${SHELL:-/bin/sh}\" -lc \(tmuxShellQuote(commandText))") do { diff --git a/CLI/cmux.swift b/CLI/cmux.swift index adb29e7aae0f..65cb8e753198 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -16936,7 +16936,7 @@ struct CMUXCLI { ] if let cwd = cwd?.trimmingCharacters(in: .whitespacesAndNewlines), !cwd.isEmpty { let quotedCwd = codexTeamsShellQuote(cwd) - lines.append("{ [ ! -d \(quotedCwd) ] || cd -- \(quotedCwd); } || exit $?") + lines.append("{ cd -- \(quotedCwd) 2>/dev/null || [ ! -d \(quotedCwd) ]; } || exit $?") } lines.append("exec \"${SHELL:-/bin/sh}\" -lc \(codexTeamsShellQuote(commandText))") do { @@ -23283,7 +23283,7 @@ struct CMUXCLI { let command = commandParts.map(cliShellQuote).joined(separator: " ") if let cwd = normalizedHookValue(workingDirectory) { let quotedCwd = cliShellQuote(cwd) - return "{ [ ! -d \(quotedCwd) ] || cd -- \(quotedCwd); } && \(command)" + return "{ cd -- \(quotedCwd) 2>/dev/null || [ ! -d \(quotedCwd) ]; } && \(command)" } return command } diff --git a/Sources/RestorableAgentSession.swift b/Sources/RestorableAgentSession.swift index 261287bf67db..6315d47c87d7 100644 --- a/Sources/RestorableAgentSession.swift +++ b/Sources/RestorableAgentSession.swift @@ -36,7 +36,7 @@ nonisolated enum TerminalStartupWorkingDirectoryPrefix { static func optionalChangeDirectoryPrefix(for workingDirectory: String?) -> String? { guard let workingDirectory = normalized(workingDirectory) else { return nil } let quoted = TerminalStartupShellQuoting.singleQuoted(workingDirectory) - return "{ [ ! -d \(quoted) ] || cd -- \(quoted); } && " + return "{ cd -- \(quoted) 2>/dev/null || [ ! -d \(quoted) ]; } && " } static func prefix(_ command: String, workingDirectory: String?) -> String { @@ -52,10 +52,6 @@ nonisolated enum TerminalStartupWorkingDirectoryPrefix { ) -> String { let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines) guard let workingDirectory = normalized(workingDirectory) else { return trimmed } - if let existingPrefix = optionalChangeDirectoryPrefix(for: workingDirectory), - trimmed.hasPrefix(existingPrefix) { - return trimmed - } let stripped = strippedRequiredChangeDirectoryPrefix( from: trimmed, workingDirectory: workingDirectory @@ -73,8 +69,13 @@ nonisolated enum TerminalStartupWorkingDirectoryPrefix { ] var seen = Set() for quoted in quotedCandidates where seen.insert(quoted).inserted { - let prefix = "cd \(quoted) && " - if command.hasPrefix(prefix) { + let prefixes = [ + "{ cd -- \(quoted) 2>/dev/null || [ ! -d \(quoted) ]; } && ", + "{ [ ! -d \(quoted) ] || cd -- \(quoted); } && ", + "cd -- \(quoted) && ", + "cd \(quoted) && " + ] + for prefix in prefixes where command.hasPrefix(prefix) { return String(command.dropFirst(prefix.count)) } } diff --git a/Sources/SessionPersistence.swift b/Sources/SessionPersistence.swift index e1ba441743d2..b9e2fdf42c6b 100644 --- a/Sources/SessionPersistence.swift +++ b/Sources/SessionPersistence.swift @@ -260,6 +260,20 @@ enum SurfaceResumeApprovalPolicy: String, Codable, CaseIterable, Sendable { } nonisolated struct SurfaceResumeBindingSnapshot: Codable, Equatable, Sendable { + private enum CodingKeys: String, CodingKey { + case name + case kind + case command + case cwd + case checkpointId + case source + case environment + case autoResume + case approvalPolicy + case approvalRecordId + case updatedAt + } + var name: String? var kind: String? var command: String @@ -285,12 +299,18 @@ nonisolated struct SurfaceResumeBindingSnapshot: Codable, Equatable, Sendable { approvalRecordId: String? = nil, updatedAt: TimeInterval = Date().timeIntervalSince1970 ) { + let normalizedCwd = Self.normalized(cwd) + let normalizedSource = Self.normalized(source) self.name = Self.normalized(name) self.kind = Self.normalized(kind) - self.command = command.trimmingCharacters(in: .whitespacesAndNewlines) - self.cwd = Self.normalized(cwd) + self.command = Self.sanitizedStartupCommand( + command, + cwd: normalizedCwd, + source: normalizedSource + ) + self.cwd = normalizedCwd self.checkpointId = Self.normalized(checkpointId) - self.source = Self.normalized(source) + self.source = normalizedSource self.environment = Self.normalizedEnvironment(environment) self.autoResume = autoResume self.approvalPolicy = approvalPolicy @@ -298,6 +318,24 @@ nonisolated struct SurfaceResumeBindingSnapshot: Codable, Equatable, Sendable { self.updatedAt = updatedAt } + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.init( + name: try container.decodeIfPresent(String.self, forKey: .name), + kind: try container.decodeIfPresent(String.self, forKey: .kind), + command: try container.decode(String.self, forKey: .command), + cwd: try container.decodeIfPresent(String.self, forKey: .cwd), + checkpointId: try container.decodeIfPresent(String.self, forKey: .checkpointId), + source: try container.decodeIfPresent(String.self, forKey: .source), + environment: try container.decodeIfPresent([String: String].self, forKey: .environment), + autoResume: try container.decodeIfPresent(Bool.self, forKey: .autoResume), + approvalPolicy: try container.decodeIfPresent(SurfaceResumeApprovalPolicy.self, forKey: .approvalPolicy), + approvalRecordId: try container.decodeIfPresent(String.self, forKey: .approvalRecordId), + updatedAt: try container.decodeIfPresent(TimeInterval.self, forKey: .updatedAt) + ?? Date().timeIntervalSince1970 + ) + } + var isProcessDetected: Bool { source == "process-detected" } @@ -339,8 +377,16 @@ nonisolated struct SurfaceResumeBindingSnapshot: Codable, Equatable, Sendable { } private var startupCommand: String { + Self.sanitizedStartupCommand(command, cwd: cwd, source: source) + } + + private static func sanitizedStartupCommand( + _ command: String, + cwd: String?, + source: String? + ) -> String { let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines) - guard isAgentHookBinding else { return trimmed } + guard source == "agent-hook" else { return trimmed } return TerminalStartupWorkingDirectoryPrefix.replacingRequiredChangeDirectoryPrefix( in: trimmed, workingDirectory: cwd diff --git a/Sources/SessionRestoredTerminalCommandStore.swift b/Sources/SessionRestoredTerminalCommandStore.swift index 0a5d485bde06..8a860817e1c5 100644 --- a/Sources/SessionRestoredTerminalCommandStore.swift +++ b/Sources/SessionRestoredTerminalCommandStore.swift @@ -27,7 +27,7 @@ enum SessionRestoredTerminalCommandStore { ] if let workingDirectory = normalized(workingDirectory) { let quotedDirectory = shellSingleQuoted(workingDirectory) - lines.append("{ [ ! -d \(quotedDirectory) ] || cd -- \(quotedDirectory); } || exit $?") + lines.append("{ cd -- \(quotedDirectory) 2>/dev/null || [ ! -d \(quotedDirectory) ]; } || exit $?") } lines.append("exec \"${SHELL:-/bin/zsh}\" -lc \(shellSingleQuoted(trimmedCommand))") diff --git a/cmuxTests/AgentHibernationTests.swift b/cmuxTests/AgentHibernationTests.swift index bb9971810c1a..2fba6ae86540 100644 --- a/cmuxTests/AgentHibernationTests.swift +++ b/cmuxTests/AgentHibernationTests.swift @@ -690,7 +690,7 @@ final class AgentHibernationTests: XCTestCase { ) XCTAssertEqual(snapshot.agentDisplayName, "Local Agent") - XCTAssertEqual(snapshot.resumeCommand, "{ [ ! -d '/tmp/custom-agent' ] || cd -- '/tmp/custom-agent'; } && '/usr/local/bin/local-agent' 'resume' 'custom-session'") + XCTAssertEqual(snapshot.resumeCommand, "{ cd -- '/tmp/custom-agent' 2>/dev/null || [ ! -d '/tmp/custom-agent' ]; } && '/usr/local/bin/local-agent' 'resume' 'custom-session'") } @MainActor diff --git a/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift b/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift index af4713d94694..6617f1af7105 100644 --- a/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift +++ b/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift @@ -6484,7 +6484,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { XCTAssertEqual(request["auto_resume"] as? Bool, true) XCTAssertEqual( request["command"] as? String, - "{ [ ! -d '\(root.path)' ] || cd -- '\(root.path)'; } && '/usr/local/bin/cmux' 'codex-teams' 'resume' '\(sessionId)' '--model' 'gpt-5.4' '--sandbox' 'danger-full-access'" + "{ cd -- '\(root.path)' 2>/dev/null || [ ! -d '\(root.path)' ]; } && '/usr/local/bin/cmux' 'codex-teams' 'resume' '\(sessionId)' '--model' 'gpt-5.4' '--sandbox' 'danger-full-access'" ) } diff --git a/cmuxTests/PiVaultAgentPersistenceTests.swift b/cmuxTests/PiVaultAgentPersistenceTests.swift index d07b72112041..37cce86c5c29 100644 --- a/cmuxTests/PiVaultAgentPersistenceTests.swift +++ b/cmuxTests/PiVaultAgentPersistenceTests.swift @@ -104,7 +104,7 @@ final class PiVaultAgentPersistenceTests: XCTestCase { XCTAssertEqual(entry.cwd, "/tmp/antigravity repo") XCTAssertEqual( entry.resumeCommand, - "{ [ ! -d '/tmp/antigravity repo' ] || cd -- '/tmp/antigravity repo'; } && 'agy' '--conversation' 'antigravity-conversation-123'" + "{ cd -- '/tmp/antigravity repo' 2>/dev/null || [ ! -d '/tmp/antigravity repo' ]; } && 'agy' '--conversation' 'antigravity-conversation-123'" ) } @@ -146,7 +146,7 @@ final class PiVaultAgentPersistenceTests: XCTestCase { XCTAssertEqual(filtered.map(\.sessionId), ["conversation-b"]) XCTAssertEqual( filtered.first?.resumeCommand, - "{ [ ! -d '/tmp/antigravity repo' ] || cd -- '/tmp/antigravity repo'; } && 'agy' '--conversation' 'conversation-b'" + "{ cd -- '/tmp/antigravity repo' 2>/dev/null || [ ! -d '/tmp/antigravity repo' ]; } && 'agy' '--conversation' 'conversation-b'" ) } @@ -254,7 +254,7 @@ final class PiVaultAgentPersistenceTests: XCTestCase { let entry = try XCTUnwrap(entries.first) XCTAssertEqual(entry.sessionId, "native-session-123") - XCTAssertEqual(entry.resumeCommand, "{ [ ! -d '/tmp/acme' ] || cd -- '/tmp/acme'; } && 'acme-agent' '--session' 'native-session-123'") + XCTAssertEqual(entry.resumeCommand, "{ cd -- '/tmp/acme' 2>/dev/null || [ ! -d '/tmp/acme' ]; } && 'acme-agent' '--session' 'native-session-123'") } func testBuiltInGrokRegistrationUsesNativeSessionDirectory() { @@ -649,7 +649,7 @@ final class PiVaultAgentPersistenceTests: XCTestCase { XCTAssertEqual(entry.fileURL, historyURL) XCTAssertEqual( entry.resumeCommand, - "{ [ ! -d '/tmp/grok repo' ] || cd -- '/tmp/grok repo'; } && 'env' 'GROK_HOME=\(grokHome.path)' 'grok' '-r' 'grok-session-123' '-m' 'grok-4' '--permission-mode' 'auto' '--sandbox' 'danger-full-access'" + "{ cd -- '/tmp/grok repo' 2>/dev/null || [ ! -d '/tmp/grok repo' ]; } && 'env' 'GROK_HOME=\(grokHome.path)' 'grok' '-r' 'grok-session-123' '-m' 'grok-4' '--permission-mode' 'auto' '--sandbox' 'danger-full-access'" ) } @@ -801,7 +801,7 @@ final class PiVaultAgentPersistenceTests: XCTestCase { XCTAssertEqual(entry.cwd, cwd) XCTAssertEqual( entry.resumeCommand, - "{ [ ! -d '/tmp/grok observed home' ] || cd -- '/tmp/grok observed home'; } && 'env' 'GROK_HOME=\(grokHome.path)' 'grok' '-r' '\(sessionId)' '-m' 'grok-4' '--permission-mode' 'auto' '--sandbox' 'danger-full-access'" + "{ cd -- '/tmp/grok observed home' 2>/dev/null || [ ! -d '/tmp/grok observed home' ]; } && 'env' 'GROK_HOME=\(grokHome.path)' 'grok' '-r' '\(sessionId)' '-m' 'grok-4' '--permission-mode' 'auto' '--sandbox' 'danger-full-access'" ) } @@ -862,7 +862,7 @@ final class PiVaultAgentPersistenceTests: XCTestCase { XCTAssertEqual(entry.title, "Find sessions under custom hook state") XCTAssertEqual( entry.resumeCommand, - "{ [ ! -d '/tmp/grok custom state' ] || cd -- '/tmp/grok custom state'; } && 'env' 'GROK_HOME=\(grokHome.path)' 'grok' '-r' '\(sessionId)' '-m' 'grok-4'" + "{ cd -- '/tmp/grok custom state' 2>/dev/null || [ ! -d '/tmp/grok custom state' ]; } && 'env' 'GROK_HOME=\(grokHome.path)' 'grok' '-r' '\(sessionId)' '-m' 'grok-4'" ) } @@ -913,7 +913,7 @@ final class PiVaultAgentPersistenceTests: XCTestCase { XCTAssertEqual(entry.gitBranch, "issue-4394-grok-vault-resume") XCTAssertEqual( entry.resumeCommand, - "{ [ ! -d '/tmp/custom grok repo' ] || cd -- '/tmp/custom grok repo'; } && 'env' 'GROK_HOME=\(tempDir.path)' 'custom-grok' '-r' '\(sessionId)'" + "{ cd -- '/tmp/custom grok repo' 2>/dev/null || [ ! -d '/tmp/custom grok repo' ]; } && 'env' 'GROK_HOME=\(tempDir.path)' 'custom-grok' '-r' '\(sessionId)'" ) } @@ -1071,7 +1071,7 @@ final class PiVaultAgentPersistenceTests: XCTestCase { XCTAssertEqual(loadedAgent.sessionId, sessionPath) XCTAssertEqual( loadedAgent.resumeCommand, - "{ [ ! -d '/tmp/pi repo' ] || cd -- '/tmp/pi repo'; } && '/opt/homebrew/bin/pi' '--session' '\(sessionPath)'" + "{ cd -- '/tmp/pi repo' 2>/dev/null || [ ! -d '/tmp/pi repo' ]; } && '/opt/homebrew/bin/pi' '--session' '\(sessionPath)'" ) } diff --git a/cmuxTests/RestorableAgentHookProviderHermesTests.swift b/cmuxTests/RestorableAgentHookProviderHermesTests.swift index 2b3bbccd28a4..af5176836f7f 100644 --- a/cmuxTests/RestorableAgentHookProviderHermesTests.swift +++ b/cmuxTests/RestorableAgentHookProviderHermesTests.swift @@ -39,7 +39,7 @@ extension SocketListenerAcceptPolicyTests { XCTAssertEqual( snapshot.resumeCommand, - "{ [ ! -d '/tmp/hermes repo' ] || cd -- '/tmp/hermes repo'; } && 'env' 'HERMES_HOME=/tmp/hermes home' '/opt/homebrew/bin/hermes' '--tui' '--model' 'anthropic/claude-sonnet-4.6' '--resume' 'hermes-session-123'" + "{ cd -- '/tmp/hermes repo' 2>/dev/null || [ ! -d '/tmp/hermes repo' ]; } && 'env' 'HERMES_HOME=/tmp/hermes home' '/opt/homebrew/bin/hermes' '--tui' '--model' 'anthropic/claude-sonnet-4.6' '--resume' 'hermes-session-123'" ) } diff --git a/cmuxTests/RestorableAgentHookProviderResumeTests.swift b/cmuxTests/RestorableAgentHookProviderResumeTests.swift index 57050f55d564..6b1521da0b84 100644 --- a/cmuxTests/RestorableAgentHookProviderResumeTests.swift +++ b/cmuxTests/RestorableAgentHookProviderResumeTests.swift @@ -40,7 +40,7 @@ extension SocketListenerAcceptPolicyTests { XCTAssertEqual( snapshot.resumeCommand, - "{ [ ! -d '/tmp/gemini repo' ] || cd -- '/tmp/gemini repo'; } && 'env' 'GEMINI_CLI_HOME=/tmp/gemini home' '/Users/example/.bun/bin/gemini' '--resume' '5839bed1-0a60-4c05-b6d1-2410d7a3741e' '--model' 'gemini-2.5-pro' '--sandbox' 'danger-full-access' '--approval-mode' 'yolo'" + "{ cd -- '/tmp/gemini repo' 2>/dev/null || [ ! -d '/tmp/gemini repo' ]; } && 'env' 'GEMINI_CLI_HOME=/tmp/gemini home' '/Users/example/.bun/bin/gemini' '--resume' '5839bed1-0a60-4c05-b6d1-2410d7a3741e' '--model' 'gemini-2.5-pro' '--sandbox' 'danger-full-access' '--approval-mode' 'yolo'" ) } @@ -74,7 +74,7 @@ extension SocketListenerAcceptPolicyTests { XCTAssertEqual( snapshot.resumeCommand, - "{ [ ! -d '/tmp/antigravity repo' ] || cd -- '/tmp/antigravity repo'; } && 'env' 'GEMINI_CLI_HOME=/tmp/gemini home' '/Users/example/.local/bin/agy' '--conversation' 'antigravity-conversation-123' '--sandbox' 'danger-full-access' '--add-dir' '/tmp/extra repo'" + "{ cd -- '/tmp/antigravity repo' 2>/dev/null || [ ! -d '/tmp/antigravity repo' ]; } && 'env' 'GEMINI_CLI_HOME=/tmp/gemini home' '/Users/example/.local/bin/agy' '--conversation' 'antigravity-conversation-123' '--sandbox' 'danger-full-access' '--add-dir' '/tmp/extra repo'" ) } @@ -107,7 +107,7 @@ extension SocketListenerAcceptPolicyTests { XCTAssertEqual( snapshot.resumeCommand, - "{ [ ! -d '/tmp/rovo repo' ] || cd -- '/tmp/rovo repo'; } && 'env' 'CMUX_ROVODEV_SESSIONS_DIR=/tmp/rovo sessions' '/opt/homebrew/bin/acli' 'rovodev' 'run' '--restore' 'session with space' '--yolo'" + "{ cd -- '/tmp/rovo repo' 2>/dev/null || [ ! -d '/tmp/rovo repo' ]; } && 'env' 'CMUX_ROVODEV_SESSIONS_DIR=/tmp/rovo sessions' '/opt/homebrew/bin/acli' 'rovodev' 'run' '--restore' 'session with space' '--yolo'" ) } @@ -137,7 +137,7 @@ extension SocketListenerAcceptPolicyTests { XCTAssertEqual( snapshot.resumeCommand, - "{ [ ! -d '~/.cursor' ] || cd -- '~/.cursor'; } && '/usr/local/bin/agent' '--resume' '019dad34-d218-7943-b81a-eddac5c87951' '--model' 'gpt-5.4'" + "{ cd -- '~/.cursor' 2>/dev/null || [ ! -d '~/.cursor' ]; } && '/usr/local/bin/agent' '--resume' '019dad34-d218-7943-b81a-eddac5c87951' '--model' 'gpt-5.4'" ) } @@ -339,32 +339,32 @@ extension SocketListenerAcceptPolicyTests { XCTAssertEqual( cursor.resumeCommand, - "{ [ ! -d '/tmp/cursor repo' ] || cd -- '/tmp/cursor repo'; } && '/Users/example/.local/bin/cursor-agent' '--resume' 'cursor-chat-123' '--model' 'gpt-5.4' '--sandbox' 'enabled'" + "{ cd -- '/tmp/cursor repo' 2>/dev/null || [ ! -d '/tmp/cursor repo' ]; } && '/Users/example/.local/bin/cursor-agent' '--resume' 'cursor-chat-123' '--model' 'gpt-5.4' '--sandbox' 'enabled'" ) XCTAssertEqual( copilot.resumeCommand, - "{ [ ! -d '/tmp/copilot repo' ] || cd -- '/tmp/copilot repo'; } && 'env' 'COPILOT_HOME=/tmp/copilot home' '/tmp/cmux-agent-upstreams/copilot-install/bin/copilot' '--resume' 'copilot-session-123' '--model' 'gpt-5.4' '--allow-all-tools'" + "{ cd -- '/tmp/copilot repo' 2>/dev/null || [ ! -d '/tmp/copilot repo' ]; } && 'env' 'COPILOT_HOME=/tmp/copilot home' '/tmp/cmux-agent-upstreams/copilot-install/bin/copilot' '--resume' 'copilot-session-123' '--model' 'gpt-5.4' '--allow-all-tools'" ) XCTAssertEqual( codeBuddy.resumeCommand, - "{ [ ! -d '/tmp/codebuddy repo' ] || cd -- '/tmp/codebuddy repo'; } && 'env' 'CODEBUDDY_CONFIG_DIR=/tmp/codebuddy config' '/Users/example/.npm/bin/codebuddy' '--resume' 'codebuddy-session-123' '--model' 'gpt-5.4' '--permission-mode' 'plan'" + "{ cd -- '/tmp/codebuddy repo' 2>/dev/null || [ ! -d '/tmp/codebuddy repo' ]; } && 'env' 'CODEBUDDY_CONFIG_DIR=/tmp/codebuddy config' '/Users/example/.npm/bin/codebuddy' '--resume' 'codebuddy-session-123' '--model' 'gpt-5.4' '--permission-mode' 'plan'" ) XCTAssertEqual( factory.resumeCommand, - "{ [ ! -d '/tmp/factory repo' ] || cd -- '/tmp/factory repo'; } && '/Users/example/.npm/bin/droid' '--resume' 'factory-session-123' '--cwd' '/tmp/factory repo' '--append-system-prompt' 'be terse'" + "{ cd -- '/tmp/factory repo' 2>/dev/null || [ ! -d '/tmp/factory repo' ]; } && '/Users/example/.npm/bin/droid' '--resume' 'factory-session-123' '--cwd' '/tmp/factory repo' '--append-system-prompt' 'be terse'" ) XCTAssertEqual( qoder.resumeCommand, - "{ [ ! -d '/tmp/qoder repo' ] || cd -- '/tmp/qoder repo'; } && 'env' 'QODER_CONFIG_DIR=/tmp/qoder config' '/Users/example/.npm/bin/qodercli' '--resume' 'qoder-session-123' '--model' 'gemini-2.5-pro' '--permission-mode' 'plan' '--workspace' '/tmp/qoder repo'" + "{ cd -- '/tmp/qoder repo' 2>/dev/null || [ ! -d '/tmp/qoder repo' ]; } && 'env' 'QODER_CONFIG_DIR=/tmp/qoder config' '/Users/example/.npm/bin/qodercli' '--resume' 'qoder-session-123' '--model' 'gemini-2.5-pro' '--permission-mode' 'plan' '--workspace' '/tmp/qoder repo'" ) XCTAssertEqual( grok.resumeCommand, - "{ [ ! -d '/tmp/grok repo' ] || cd -- '/tmp/grok repo'; } && 'env' 'GROK_HOME=/tmp/grok home' '/Users/example/.grok/bin/grok' '-r' 'grok-session-123' '--model' 'grok-4' '--permission-mode' 'auto' '--cwd' '/tmp/grok repo'" + "{ cd -- '/tmp/grok repo' 2>/dev/null || [ ! -d '/tmp/grok repo' ]; } && 'env' 'GROK_HOME=/tmp/grok home' '/Users/example/.grok/bin/grok' '-r' 'grok-session-123' '--model' 'grok-4' '--permission-mode' 'auto' '--cwd' '/tmp/grok repo'" ) - XCTAssertEqual(pi.resumeCommand, "{ [ ! -d '/tmp/pi repo' ] || cd -- '/tmp/pi repo'; } && 'env' 'PI_CODING_AGENT_DIR=/tmp/pi home' '/Users/example/.bun/bin/pi' '--session' 'pi-session-123' '--model' 'anthropic/claude-sonnet-4-5' '--thinking' 'high'") + XCTAssertEqual(pi.resumeCommand, "{ cd -- '/tmp/pi repo' 2>/dev/null || [ ! -d '/tmp/pi repo' ]; } && 'env' 'PI_CODING_AGENT_DIR=/tmp/pi home' '/Users/example/.bun/bin/pi' '--session' 'pi-session-123' '--model' 'anthropic/claude-sonnet-4-5' '--thinking' 'high'") XCTAssertEqual( amp.resumeCommand, - "{ [ ! -d '/tmp/amp repo' ] || cd -- '/tmp/amp repo'; } && 'env' 'AMP_SETTINGS_FILE=/tmp/amp-settings.json' '/Users/example/.local/bin/amp' 'threads' 'continue' '--mode' 'smart' '--effort' 'high' 'T-019e032c-c31a-77a9-ad87-8298ec47029f'" + "{ cd -- '/tmp/amp repo' 2>/dev/null || [ ! -d '/tmp/amp repo' ]; } && 'env' 'AMP_SETTINGS_FILE=/tmp/amp-settings.json' '/Users/example/.local/bin/amp' 'threads' 'continue' '--mode' 'smart' '--effort' 'high' 'T-019e032c-c31a-77a9-ad87-8298ec47029f'" ) } diff --git a/cmuxTests/RovoDevSessionIndexTests.swift b/cmuxTests/RovoDevSessionIndexTests.swift index 2ea63b3e6211..bc54c1d8c6c3 100644 --- a/cmuxTests/RovoDevSessionIndexTests.swift +++ b/cmuxTests/RovoDevSessionIndexTests.swift @@ -117,7 +117,7 @@ final class RovoDevSessionIndexTests: XCTestCase { XCTAssertEqual(entry.fileURL?.lastPathComponent, "session_context.json") XCTAssertEqual( entry.resumeCommand, - "{ [ ! -d '/tmp/rovo repo' ] || cd -- '/tmp/rovo repo'; } && acli rovodev run --restore 'session with space'" + "{ cd -- '/tmp/rovo repo' 2>/dev/null || [ ! -d '/tmp/rovo repo' ]; } && acli rovodev run --restore 'session with space'" ) } diff --git a/cmuxTests/SessionPersistenceTests.swift b/cmuxTests/SessionPersistenceTests.swift index 842fd25bc302..b49de92da72b 100644 --- a/cmuxTests/SessionPersistenceTests.swift +++ b/cmuxTests/SessionPersistenceTests.swift @@ -1181,7 +1181,7 @@ final class SessionPersistenceTests: XCTestCase { XCTAssertEqual(agent.sessionId, "antigravity-conversation-123") XCTAssertEqual( agent.resumeCommand, - "{ [ ! -d '/tmp/repo' ] || cd -- '/tmp/repo'; } && '/usr/local/bin/agy' '--conversation' 'antigravity-conversation-123' '--sandbox' 'danger-full-access'" + "{ cd -- '/tmp/repo' 2>/dev/null || [ ! -d '/tmp/repo' ]; } && '/usr/local/bin/agy' '--conversation' 'antigravity-conversation-123' '--sandbox' 'danger-full-access'" ) } @@ -1953,7 +1953,7 @@ final class SocketListenerAcceptPolicyTests: XCTestCase { XCTAssertEqual( snapshot.resumeCommand, - "{ [ ! -d '/tmp/cmux project' ] || cd -- '/tmp/cmux project'; } && 'env' 'CLAUDE_CONFIG_DIR=/tmp/claude config' 'CMUX_PRESERVE_CLAUDE_AUTH_SELECTION_ENV=1' 'CMUX_PRESERVE_CLAUDE_AUTH_SELECTION_ENV_KEYS=CLAUDE_CONFIG_DIR' '/opt/Claude Code/bin/claude' '--resume' 'claude-session-123' '--model' 'sonnet' '--permission-mode' 'auto'" + "{ cd -- '/tmp/cmux project' 2>/dev/null || [ ! -d '/tmp/cmux project' ]; } && 'env' 'CLAUDE_CONFIG_DIR=/tmp/claude config' 'CMUX_PRESERVE_CLAUDE_AUTH_SELECTION_ENV=1' 'CMUX_PRESERVE_CLAUDE_AUTH_SELECTION_ENV_KEYS=CLAUDE_CONFIG_DIR' '/opt/Claude Code/bin/claude' '--resume' 'claude-session-123' '--model' 'sonnet' '--permission-mode' 'auto'" ) } @@ -2235,7 +2235,7 @@ final class SocketListenerAcceptPolicyTests: XCTestCase { XCTAssertEqual( snapshot.resumeCommand, - "{ [ ! -d '/Users/lawrence/fun' ] || cd -- '/Users/lawrence/fun'; } && 'env' 'CLAUDE_CONFIG_DIR=/Users/lawrence/.codex-accounts/claude/_p1775010019397' 'CMUX_PRESERVE_CLAUDE_AUTH_SELECTION_ENV=1' 'CMUX_PRESERVE_CLAUDE_AUTH_SELECTION_ENV_KEYS=CLAUDE_CONFIG_DIR' '/Users/lawrence/.local/bin/claude' '--resume' '24ec0052-450c-4914-b1dd-2ee80d4bc84b' '--dangerously-load-development-channels' 'server:custom-dev-channel' '--dangerously-skip-permissions'" + "{ cd -- '/Users/lawrence/fun' 2>/dev/null || [ ! -d '/Users/lawrence/fun' ]; } && 'env' 'CLAUDE_CONFIG_DIR=/Users/lawrence/.codex-accounts/claude/_p1775010019397' 'CMUX_PRESERVE_CLAUDE_AUTH_SELECTION_ENV=1' 'CMUX_PRESERVE_CLAUDE_AUTH_SELECTION_ENV_KEYS=CLAUDE_CONFIG_DIR' '/Users/lawrence/.local/bin/claude' '--resume' '24ec0052-450c-4914-b1dd-2ee80d4bc84b' '--dangerously-load-development-channels' 'server:custom-dev-channel' '--dangerously-skip-permissions'" ) } @@ -2269,7 +2269,7 @@ final class SocketListenerAcceptPolicyTests: XCTestCase { XCTAssertEqual( snapshot.resumeCommand, - "{ [ ! -d '/Users/example/repo' ] || cd -- '/Users/example/repo'; } && 'env' 'CODEX_HOME=/tmp/codex home' '/Users/example/.bun/bin/codex' 'resume' '019dad34-d218-7943-b81a-eddac5c87951' '--model' 'gpt-5.4' '--sandbox' 'danger-full-access' '--ask-for-approval' 'never' '--search' '--cd' '/Users/example/repo'" + "{ cd -- '/Users/example/repo' 2>/dev/null || [ ! -d '/Users/example/repo' ]; } && 'env' 'CODEX_HOME=/tmp/codex home' '/Users/example/.bun/bin/codex' 'resume' '019dad34-d218-7943-b81a-eddac5c87951' '--model' 'gpt-5.4' '--sandbox' 'danger-full-access' '--ask-for-approval' 'never' '--search' '--cd' '/Users/example/repo'" ) } @@ -2300,7 +2300,7 @@ final class SocketListenerAcceptPolicyTests: XCTestCase { XCTAssertEqual( snapshot.resumeCommand, - "{ [ ! -d '/Users/lawrence/fun/cmuxterm-hq' ] || cd -- '/Users/lawrence/fun/cmuxterm-hq'; } && '/Users/lawrence/.bun/bin/codex' 'resume' '019e2bb9-5544-7201-a517-d77bb00d724f' '--yolo' '--model' 'gpt-5.4'" + "{ cd -- '/Users/lawrence/fun/cmuxterm-hq' 2>/dev/null || [ ! -d '/Users/lawrence/fun/cmuxterm-hq' ]; } && '/Users/lawrence/.bun/bin/codex' 'resume' '019e2bb9-5544-7201-a517-d77bb00d724f' '--yolo' '--model' 'gpt-5.4'" ) } @@ -2332,7 +2332,7 @@ final class SocketListenerAcceptPolicyTests: XCTestCase { XCTAssertEqual( snapshot.resumeCommand, - "{ [ ! -d '/Users/example/repo' ] || cd -- '/Users/example/repo'; } && 'env' 'CODEX_HOME=/tmp/codex home' '/usr/local/bin/cmux' 'codex-teams' 'resume' '019dad34-d218-7943-b81a-eddac5c87951' '--model' 'gpt-5.4' '--sandbox' 'danger-full-access'" + "{ cd -- '/Users/example/repo' 2>/dev/null || [ ! -d '/Users/example/repo' ]; } && 'env' 'CODEX_HOME=/tmp/codex home' '/usr/local/bin/cmux' 'codex-teams' 'resume' '019dad34-d218-7943-b81a-eddac5c87951' '--model' 'gpt-5.4' '--sandbox' 'danger-full-access'" ) } @@ -2364,7 +2364,7 @@ final class SocketListenerAcceptPolicyTests: XCTestCase { XCTAssertEqual( snapshot.resumeCommand, - "{ [ ! -d '/Users/example/repo' ] || cd -- '/Users/example/repo'; } && 'env' 'CODEX_HOME=/tmp/codex home' '/usr/local/bin/cmux' 'codex-teams' 'resume' '019dad34-d218-7943-b81a-eddac5c87952' '--model' 'gpt-5.4' '--sandbox' 'danger-full-access'" + "{ cd -- '/Users/example/repo' 2>/dev/null || [ ! -d '/Users/example/repo' ]; } && 'env' 'CODEX_HOME=/tmp/codex home' '/usr/local/bin/cmux' 'codex-teams' 'resume' '019dad34-d218-7943-b81a-eddac5c87952' '--model' 'gpt-5.4' '--sandbox' 'danger-full-access'" ) } @@ -2622,43 +2622,43 @@ final class SocketListenerAcceptPolicyTests: XCTestCase { XCTAssertEqual( claude.forkCommand, - "{ [ ! -d '/Users/lawrence/fun' ] || cd -- '/Users/lawrence/fun'; } && 'env' 'CLAUDE_CONFIG_DIR=/Users/lawrence/.codex-accounts/claude/_p1775010019397' 'CMUX_PRESERVE_CLAUDE_AUTH_SELECTION_ENV=1' 'CMUX_PRESERVE_CLAUDE_AUTH_SELECTION_ENV_KEYS=CLAUDE_CONFIG_DIR' '/Users/lawrence/.local/bin/claude' '--resume' '24ec0052-450c-4914-b1dd-2ee80d4bc84b' '--fork-session' '--dangerously-load-development-channels' 'server:custom-dev-channel' '--dangerously-skip-permissions'" + "{ cd -- '/Users/lawrence/fun' 2>/dev/null || [ ! -d '/Users/lawrence/fun' ]; } && 'env' 'CLAUDE_CONFIG_DIR=/Users/lawrence/.codex-accounts/claude/_p1775010019397' 'CMUX_PRESERVE_CLAUDE_AUTH_SELECTION_ENV=1' 'CMUX_PRESERVE_CLAUDE_AUTH_SELECTION_ENV_KEYS=CLAUDE_CONFIG_DIR' '/Users/lawrence/.local/bin/claude' '--resume' '24ec0052-450c-4914-b1dd-2ee80d4bc84b' '--fork-session' '--dangerously-load-development-channels' 'server:custom-dev-channel' '--dangerously-skip-permissions'" ) XCTAssertEqual( claudeFork.forkCommand, - "{ [ ! -d '/Users/lawrence/fun' ] || cd -- '/Users/lawrence/fun'; } && 'env' 'CLAUDE_CONFIG_DIR=/Users/lawrence/.codex-accounts/claude/_p1775010019397' 'CMUX_PRESERVE_CLAUDE_AUTH_SELECTION_ENV=1' 'CMUX_PRESERVE_CLAUDE_AUTH_SELECTION_ENV_KEYS=CLAUDE_CONFIG_DIR' '/Users/lawrence/.local/bin/claude' '--resume' 'claude-fork-child' '--fork-session' '--model' 'sonnet' '--dangerously-skip-permissions'" + "{ cd -- '/Users/lawrence/fun' 2>/dev/null || [ ! -d '/Users/lawrence/fun' ]; } && 'env' 'CLAUDE_CONFIG_DIR=/Users/lawrence/.codex-accounts/claude/_p1775010019397' 'CMUX_PRESERVE_CLAUDE_AUTH_SELECTION_ENV=1' 'CMUX_PRESERVE_CLAUDE_AUTH_SELECTION_ENV_KEYS=CLAUDE_CONFIG_DIR' '/Users/lawrence/.local/bin/claude' '--resume' 'claude-fork-child' '--fork-session' '--model' 'sonnet' '--dangerously-skip-permissions'" ) XCTAssertEqual( codex.forkCommand, - "{ [ ! -d '/Users/example/repo' ] || cd -- '/Users/example/repo'; } && 'env' 'CODEX_HOME=/tmp/codex home' '/Users/example/.bun/bin/codex' 'fork' '019dad34-d218-7943-b81a-eddac5c87951' '--model' 'gpt-5.4' '--sandbox' 'danger-full-access' '--ask-for-approval' 'never' '--search' '--cd' '/Users/example/repo'" + "{ cd -- '/Users/example/repo' 2>/dev/null || [ ! -d '/Users/example/repo' ]; } && 'env' 'CODEX_HOME=/tmp/codex home' '/Users/example/.bun/bin/codex' 'fork' '019dad34-d218-7943-b81a-eddac5c87951' '--model' 'gpt-5.4' '--sandbox' 'danger-full-access' '--ask-for-approval' 'never' '--search' '--cd' '/Users/example/repo'" ) XCTAssertEqual( codexWithImage.forkCommand, - "{ [ ! -d '/Users/example/repo' ] || cd -- '/Users/example/repo'; } && 'env' 'CODEX_HOME=/tmp/codex home' '/Users/example/.bun/bin/codex' 'fork' '019image-session' '--model' 'gpt-5.4'" + "{ cd -- '/Users/example/repo' 2>/dev/null || [ ! -d '/Users/example/repo' ]; } && 'env' 'CODEX_HOME=/tmp/codex home' '/Users/example/.bun/bin/codex' 'fork' '019image-session' '--model' 'gpt-5.4'" ) XCTAssertEqual( codexFork.forkCommand, - "{ [ ! -d '/Users/example/repo' ] || cd -- '/Users/example/repo'; } && 'env' 'CODEX_HOME=/tmp/codex home' '/Users/example/.bun/bin/codex' 'fork' '019e1eca-ee32-7001-ab30-edcae57430bb' '--model' 'gpt-5.4' '--sandbox' 'danger-full-access' '--search'" + "{ cd -- '/Users/example/repo' 2>/dev/null || [ ! -d '/Users/example/repo' ]; } && 'env' 'CODEX_HOME=/tmp/codex home' '/Users/example/.bun/bin/codex' 'fork' '019e1eca-ee32-7001-ab30-edcae57430bb' '--model' 'gpt-5.4' '--sandbox' 'danger-full-access' '--search'" ) XCTAssertEqual( codexTeams.forkCommand, - "{ [ ! -d '/Users/example/repo' ] || cd -- '/Users/example/repo'; } && 'env' 'CODEX_HOME=/tmp/codex home' '/usr/local/bin/cmux' 'codex-teams' 'fork' 'codex-teams-session' '--model' 'gpt-5.4' '--sandbox' 'danger-full-access'" + "{ cd -- '/Users/example/repo' 2>/dev/null || [ ! -d '/Users/example/repo' ]; } && 'env' 'CODEX_HOME=/tmp/codex home' '/usr/local/bin/cmux' 'codex-teams' 'fork' 'codex-teams-session' '--model' 'gpt-5.4' '--sandbox' 'danger-full-access'" ) XCTAssertEqual( directOpenCode.forkCommand, - "{ [ ! -d '/tmp/direct opencode repo' ] || cd -- '/tmp/direct opencode repo'; } && 'env' 'OPENCODE_CONFIG_DIR=/tmp/opencode config' '/opt/homebrew/bin/opencode' '--session' 'direct-opencode-session-456' '--fork' '--model' 'anthropic/claude-sonnet-4-6' '--port' '4096' '/tmp/direct opencode repo'" + "{ cd -- '/tmp/direct opencode repo' 2>/dev/null || [ ! -d '/tmp/direct opencode repo' ]; } && 'env' 'OPENCODE_CONFIG_DIR=/tmp/opencode config' '/opt/homebrew/bin/opencode' '--session' 'direct-opencode-session-456' '--fork' '--model' 'anthropic/claude-sonnet-4-6' '--port' '4096' '/tmp/direct opencode repo'" ) XCTAssertEqual( directOpenCodeFork.forkCommand, - "{ [ ! -d '/tmp/direct opencode repo' ] || cd -- '/tmp/direct opencode repo'; } && 'env' 'OPENCODE_CONFIG_DIR=/tmp/opencode config' '/opt/homebrew/bin/opencode' '--session' 'direct-opencode-child-session' '--fork' '--model' 'anthropic/claude-sonnet-4-6' '--port' '4096' '/tmp/direct opencode repo'" + "{ cd -- '/tmp/direct opencode repo' 2>/dev/null || [ ! -d '/tmp/direct opencode repo' ]; } && 'env' 'OPENCODE_CONFIG_DIR=/tmp/opencode config' '/opt/homebrew/bin/opencode' '--session' 'direct-opencode-child-session' '--fork' '--model' 'anthropic/claude-sonnet-4-6' '--port' '4096' '/tmp/direct opencode repo'" ) XCTAssertEqual( omoOpenCode.forkCommand, - "{ [ ! -d '/tmp/opencode repo' ] || cd -- '/tmp/opencode repo'; } && 'env' 'OPENCODE_CONFIG_DIR=/tmp/opencode config' '/usr/local/bin/cmux' 'omo' '--session' 'opencode-session-123' '--fork' '--model' 'anthropic/claude-sonnet-4-6' '/tmp/opencode repo'" + "{ cd -- '/tmp/opencode repo' 2>/dev/null || [ ! -d '/tmp/opencode repo' ]; } && 'env' 'OPENCODE_CONFIG_DIR=/tmp/opencode config' '/usr/local/bin/cmux' 'omo' '--session' 'opencode-session-123' '--fork' '--model' 'anthropic/claude-sonnet-4-6' '/tmp/opencode repo'" ) XCTAssertEqual( omoOpenCodeFork.forkCommand, - "{ [ ! -d '/tmp/opencode repo' ] || cd -- '/tmp/opencode repo'; } && 'env' 'OPENCODE_CONFIG_DIR=/tmp/opencode config' '/usr/local/bin/cmux' 'omo' '--session' 'opencode-child-session' '--fork' '--model' 'anthropic/claude-sonnet-4-6' '/tmp/opencode repo'" + "{ cd -- '/tmp/opencode repo' 2>/dev/null || [ ! -d '/tmp/opencode repo' ]; } && 'env' 'OPENCODE_CONFIG_DIR=/tmp/opencode config' '/usr/local/bin/cmux' 'omo' '--session' 'opencode-child-session' '--fork' '--model' 'anthropic/claude-sonnet-4-6' '/tmp/opencode repo'" ) XCTAssertNil(unsupported.forkCommand) } @@ -3126,7 +3126,7 @@ final class SocketListenerAcceptPolicyTests: XCTestCase { XCTAssertEqual( snapshot.forkCommand, - "{ [ ! -d '/tmp/opencode repo' ] || cd -- '/tmp/opencode repo'; } && '\(executable.path)' '--session' 'opencode-session-123' '--fork' '--model' 'anthropic/claude-sonnet-4-6' '--agent' 'build' '--port' '4096' '/tmp/opencode repo'" + "{ cd -- '/tmp/opencode repo' 2>/dev/null || [ ! -d '/tmp/opencode repo' ]; } && '\(executable.path)' '--session' 'opencode-session-123' '--fork' '--model' 'anthropic/claude-sonnet-4-6' '--agent' 'build' '--port' '4096' '/tmp/opencode repo'" ) } @@ -3257,7 +3257,7 @@ final class SocketListenerAcceptPolicyTests: XCTestCase { XCTAssertEqual( snapshot.resumeCommand, - "{ [ ! -d '/tmp/team repo' ] || cd -- '/tmp/team repo'; } && 'env' 'CMUX_CUSTOM_CLAUDE_PATH=/opt/Claude Code/bin/claude' '/Applications/cmux.app/Contents/Resources/bin/cmux' 'claude-teams' '--resume' 'claude-team-session' '--teammate-mode' 'auto' '--model' 'sonnet' '--remote-control-session-name-prefix' 'cmux-team' '--permission-mode' 'auto'" + "{ cd -- '/tmp/team repo' 2>/dev/null || [ ! -d '/tmp/team repo' ]; } && 'env' 'CMUX_CUSTOM_CLAUDE_PATH=/opt/Claude Code/bin/claude' '/Applications/cmux.app/Contents/Resources/bin/cmux' 'claude-teams' '--resume' 'claude-team-session' '--teammate-mode' 'auto' '--model' 'sonnet' '--remote-control-session-name-prefix' 'cmux-team' '--permission-mode' 'auto'" ) } @@ -3469,15 +3469,15 @@ final class SocketListenerAcceptPolicyTests: XCTestCase { XCTAssertEqual( direct.resumeCommand, - "{ [ ! -d '/tmp/direct opencode repo' ] || cd -- '/tmp/direct opencode repo'; } && 'env' 'OPENCODE_CONFIG_DIR=/tmp/opencode config' '/opt/homebrew/bin/opencode' '--session' 'direct-opencode-session-456' '--model' 'anthropic/claude-sonnet-4-6' '--port' '4096' '/tmp/direct opencode repo'" + "{ cd -- '/tmp/direct opencode repo' 2>/dev/null || [ ! -d '/tmp/direct opencode repo' ]; } && 'env' 'OPENCODE_CONFIG_DIR=/tmp/opencode config' '/opt/homebrew/bin/opencode' '--session' 'direct-opencode-session-456' '--model' 'anthropic/claude-sonnet-4-6' '--port' '4096' '/tmp/direct opencode repo'" ) XCTAssertEqual( omo.resumeCommand, - "{ [ ! -d '/tmp/opencode repo' ] || cd -- '/tmp/opencode repo'; } && 'env' 'OPENCODE_CONFIG_DIR=/tmp/opencode config' '/usr/local/bin/cmux' 'omo' '--session' 'opencode-session-123' '--model' 'anthropic/claude-sonnet-4-6' '/tmp/opencode repo'" + "{ cd -- '/tmp/opencode repo' 2>/dev/null || [ ! -d '/tmp/opencode repo' ]; } && 'env' 'OPENCODE_CONFIG_DIR=/tmp/opencode config' '/usr/local/bin/cmux' 'omo' '--session' 'opencode-session-123' '--model' 'anthropic/claude-sonnet-4-6' '/tmp/opencode repo'" ) XCTAssertEqual( staleBunWorker.resumeCommand, - "{ [ ! -d '/Users/lawrence/fun' ] || cd -- '/Users/lawrence/fun'; } && '/Users/lawrence/.bun/bin/opencode' '--session' 'ses_24b0be92affeVRRBplLmUzbXQl'" + "{ cd -- '/Users/lawrence/fun' 2>/dev/null || [ ! -d '/Users/lawrence/fun' ]; } && '/Users/lawrence/.bun/bin/opencode' '--session' 'ses_24b0be92affeVRRBplLmUzbXQl'" ) XCTAssertNil(omx.resumeCommand) XCTAssertNil(omc.resumeCommand) @@ -3532,7 +3532,7 @@ final class SocketListenerAcceptPolicyTests: XCTestCase { XCTAssertEqual(snapshot.launchCommand?.arguments.first, "/usr/local/bin/codex") XCTAssertEqual( snapshot.resumeCommand, - "{ [ ! -d '/tmp/repo' ] || cd -- '/tmp/repo'; } && 'env' 'CODEX_HOME=/tmp/codex' '/usr/local/bin/codex' 'resume' 'codex-session-123' '--model' 'gpt-5.4' '--search'" + "{ cd -- '/tmp/repo' 2>/dev/null || [ ! -d '/tmp/repo' ]; } && 'env' 'CODEX_HOME=/tmp/codex' '/usr/local/bin/codex' 'resume' 'codex-session-123' '--model' 'gpt-5.4' '--search'" ) } @@ -3802,6 +3802,65 @@ extension SessionPersistenceTests { ) } + func testAgentHookSurfaceResumeBindingStoresSanitizedCommand() throws { + let binding = SurfaceResumeBindingSnapshot( + command: "cd '/tmp/project' && codex resume session", + cwd: "/tmp/project", + source: "agent-hook", + updatedAt: 1 + ) + + XCTAssertEqual( + binding.command, + TerminalStartupWorkingDirectoryPrefix.prefix( + "codex resume session", + workingDirectory: "/tmp/project" + ) + ) + + let decoded = try JSONDecoder().decode( + SurfaceResumeBindingSnapshot.self, + from: Data( + """ + { + "command": "cd '/tmp/project' && codex resume session", + "cwd": "/tmp/project", + "source": "agent-hook", + "updatedAt": 1 + } + """.utf8 + ) + ) + + XCTAssertEqual( + decoded.command, + TerminalStartupWorkingDirectoryPrefix.prefix( + "codex resume session", + workingDirectory: "/tmp/project" + ) + ) + } + + func testAgentHookSurfaceResumeBindingCanonicalizesLegacyGuardForNonASCIIWorkingDirectory() { + let cwd = "/tmp/\u{4E2D}\u{6587}\u{8DEF}\u{5F84}" + let legacyQuotedCwd = "'\(cwd)'" + let binding = SurfaceResumeBindingSnapshot( + command: "{ cd -- \(legacyQuotedCwd) 2>/dev/null || [ ! -d \(legacyQuotedCwd) ]; } && codex resume session", + cwd: cwd, + source: "agent-hook", + updatedAt: 1 + ) + + XCTAssertEqual( + binding.command, + TerminalStartupWorkingDirectoryPrefix.prefix( + "codex resume session", + workingDirectory: cwd + ) + ) + XCTAssertFalse(binding.command.contains(legacyQuotedCwd), binding.command) + } + func testAgentHookSurfaceResumeStartupInputRunsWhenSavedWorkingDirectoryWasDeleted() throws { let fileManager = FileManager.default let root = fileManager.temporaryDirectory diff --git a/cmuxTests/WorkspaceUnitTests.swift b/cmuxTests/WorkspaceUnitTests.swift index 0b2284472038..36e9611a6d13 100644 --- a/cmuxTests/WorkspaceUnitTests.swift +++ b/cmuxTests/WorkspaceUnitTests.swift @@ -5347,7 +5347,7 @@ final class WorkspacePanelGitBranchTests: XCTestCase { XCTAssertEqual(forkPanel.requestedWorkingDirectory, "/tmp/workspace fork repo") XCTAssertEqual( forkPanel.surface.initialInput, - "{ [ ! -d '/tmp/workspace fork repo' ] || cd -- '/tmp/workspace fork repo'; } && '/Users/example/.bun/bin/codex' 'fork' '019dad34-d218-7943-b81a-eddac5c87951'\n" + "{ cd -- '/tmp/workspace fork repo' 2>/dev/null || [ ! -d '/tmp/workspace fork repo' ]; } && '/Users/example/.bun/bin/codex' 'fork' '019dad34-d218-7943-b81a-eddac5c87951'\n" ) } @@ -5448,7 +5448,7 @@ final class WorkspacePanelGitBranchTests: XCTestCase { XCTAssertEqual(workspace.panelDirectories[forkPanel.id], "/Users/cmux/fallback repo") XCTAssertEqual( forkPanel.surface.initialInput, - "{ [ ! -d '/Users/cmux/fallback repo' ] || cd -- '/Users/cmux/fallback repo'; } && '/Users/example/.bun/bin/codex' 'fork' '019dad34-d218-7943-b81a-eddac5c87951'\n" + "{ cd -- '/Users/cmux/fallback repo' 2>/dev/null || [ ! -d '/Users/cmux/fallback repo' ]; } && '/Users/example/.bun/bin/codex' 'fork' '019dad34-d218-7943-b81a-eddac5c87951'\n" ) } @@ -5606,7 +5606,7 @@ final class WorkspacePanelGitBranchTests: XCTestCase { XCTAssertEqual(launch.initialTerminalCommand, "ssh -tt cmux-macmini") XCTAssertEqual( launch.initialTerminalInput, - "{ [ ! -d '/Users/cmux/fallback repo' ] || cd -- '/Users/cmux/fallback repo'; } && '/Users/example/.bun/bin/codex' 'fork' '019dad34-d218-7943-b81a-eddac5c87951'\n" + "{ cd -- '/Users/cmux/fallback repo' 2>/dev/null || [ ! -d '/Users/cmux/fallback repo' ]; } && '/Users/example/.bun/bin/codex' 'fork' '019dad34-d218-7943-b81a-eddac5c87951'\n" ) } @@ -5643,7 +5643,7 @@ final class WorkspacePanelGitBranchTests: XCTestCase { XCTAssertNil(launch.remoteConfiguration) XCTAssertEqual( launch.initialTerminalInput, - "{ [ ! -d '/tmp/local fork repo' ] || cd -- '/tmp/local fork repo'; } && '/Users/example/.bun/bin/codex' 'fork' '019dad34-d218-7943-b81a-eddac5c87951'\n" + "{ cd -- '/tmp/local fork repo' 2>/dev/null || [ ! -d '/tmp/local fork repo' ]; } && '/Users/example/.bun/bin/codex' 'fork' '019dad34-d218-7943-b81a-eddac5c87951'\n" ) } From b45f0ff9c02f0b716fc7a0c7a3ef3dd3e581b16f Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Wed, 27 May 2026 04:06:28 -0700 Subject: [PATCH 4/9] fix: skip ghostty cwd for guarded restore commands --- Sources/Workspace.swift | 10 +++++- cmuxTests/SessionPersistenceTests.swift | 48 +++++++++++++++++++++++-- 2 files changed, 55 insertions(+), 3 deletions(-) diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 0ab689fa888b..9c40ec1dacd1 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -1206,7 +1206,6 @@ extension Workspace { ?? restorableAgent?.workingDirectory ?? snapshot.directory ?? currentDirectory - let localWorkingDirectory = remoteStartupCommand == nil ? workingDirectory : nil let restorableTmuxStartCommand = restorableAgent == nil && restoredBindingLaunch == nil ? Self.restorableTmuxStartCommand(snapshot.terminal?.tmuxStartCommand) : nil @@ -1250,6 +1249,15 @@ extension Workspace { let restoredStartupInput = restoredRemotePTYAttachCommand == nil ? (restoredBindingLaunch?.initialInput ?? restoredAgentResumeLaunch?.initialInput) : nil + let startupHandlesWorkingDirectory = + restoredTmuxStartupScript != nil || + restoredAgentResumeLaunch != nil || + (restoredBindingLaunch != nil && resumeBinding?.isAgentHookBinding == true) + // Guarded startup commands cd themselves and tolerate deleted saved directories. + // Passing the same cwd to Ghostty can fail before the guarded command runs. + let localWorkingDirectory = remoteStartupCommand == nil && !startupHandlesWorkingDirectory + ? workingDirectory + : nil let restoredAgentWillRunStartupCommand = restorableAgent != nil && ( restoredAgentResumeLaunch?.initialCommand != nil || (restoredBindingLaunch?.initialCommand != nil && resumeBinding?.isAgentHookBinding == true) diff --git a/cmuxTests/SessionPersistenceTests.swift b/cmuxTests/SessionPersistenceTests.swift index b49de92da72b..64033f1af33f 100644 --- a/cmuxTests/SessionPersistenceTests.swift +++ b/cmuxTests/SessionPersistenceTests.swift @@ -4861,12 +4861,18 @@ extension SessionPersistenceTests { let source = Workspace() let sourcePanelId = try XCTUnwrap(source.focusedPanelId) source.panelDirectories[sourcePanelId] = "/tmp/old" + let bindingCwd = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-surface-binding-cwd-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: bindingCwd, withIntermediateDirectories: true) + addTeardownBlock { + try? FileManager.default.removeItem(at: bindingCwd) + } let bindingIndex = SurfaceResumeBindingIndex(bindingsByPanel: [ SurfaceResumeBindingIndex.PanelKey(workspaceId: source.id, panelId: sourcePanelId): SurfaceResumeBindingSnapshot( name: "script", kind: "custom", command: "./resume.sh", - cwd: "/tmp/new", + cwd: bindingCwd.path, checkpointId: "script", source: "process-detected", autoResume: true, @@ -4883,10 +4889,48 @@ extension SessionPersistenceTests { let restoredPanelId = try XCTUnwrap(restored.focusedPanelId) let restoredPanel = try XCTUnwrap(restored.terminalPanel(for: restoredPanelId)) - XCTAssertEqual(restoredPanel.requestedWorkingDirectory, "/tmp/new") + XCTAssertEqual(restoredPanel.requestedWorkingDirectory, bindingCwd.path) XCTAssertTrue(restoredPanel.surface.debugInitialInputMetadata().hasInitialInput) } + @MainActor + func testRestoreDoesNotPassDeletedAgentHookCwdToTerminalRuntime() throws { + let source = Workspace() + let sourcePanelId = try XCTUnwrap(source.focusedPanelId) + let missingCwd = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-deleted-agent-hook-cwd-\(UUID().uuidString)", isDirectory: true) + .appendingPathComponent("repo", isDirectory: true) + let bindingIndex = SurfaceResumeBindingIndex(bindingsByPanel: [ + SurfaceResumeBindingIndex.PanelKey(workspaceId: source.id, panelId: sourcePanelId): SurfaceResumeBindingSnapshot( + name: "Codex", + kind: "codex", + command: "cd '\(missingCwd.path)' && codex resume session-duplicate-turn --yolo", + cwd: missingCwd.path, + checkpointId: "session-duplicate-turn", + source: "agent-hook", + environment: [ + "CLAUDE_CONFIG_DIR": "/tmp/claude-profile" + ], + autoResume: true, + updatedAt: 10 + ), + ]) + let snapshot = source.sessionSnapshot( + includeScrollback: false, + surfaceResumeBindingIndex: bindingIndex + ) + + let restored = Workspace() + restored.restoreSessionSnapshot(snapshot) + let restoredPanelId = try XCTUnwrap(restored.focusedPanelId) + let restoredPanel = try XCTUnwrap(restored.terminalPanel(for: restoredPanelId)) + let input = try XCTUnwrap(restoredPanel.surface.debugInitialInputForTesting()) + + XCTAssertNil(restoredPanel.requestedWorkingDirectory) + XCTAssertTrue(input.contains("codex resume session-duplicate-turn --yolo"), input) + XCTAssertTrue(input.contains("{ cd -- '\(missingCwd.path)' 2>/dev/null || [ ! -d '\(missingCwd.path)' ]; } &&"), input) + } + @MainActor func testRestoreDoesNotRunResumeBindingForHibernatedSnapshot() throws { let source = Workspace() From 24721ce3d4f2f42806a838f7994174ed0480a4a2 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Wed, 27 May 2026 05:28:24 -0700 Subject: [PATCH 5/9] fix: drop duplicate cwd args on restore --- CLI/CMUXCLI+TmuxCompatSupport.swift | 4 +- CLI/cmux.swift | 9 ++- .../AgentLaunchSanitizer.swift | 52 ++++++++++++++ .../AgentLaunchSanitizerTests.swift | 22 ++++++ Sources/RestorableAgentSession.swift | 72 ++++++++++++++++++- ...storableAgentHookProviderResumeTests.swift | 6 +- cmuxTests/SessionPersistenceTests.swift | 21 +++++- 7 files changed, 175 insertions(+), 11 deletions(-) diff --git a/CLI/CMUXCLI+TmuxCompatSupport.swift b/CLI/CMUXCLI+TmuxCompatSupport.swift index 7d228de06031..8d06732f8f90 100644 --- a/CLI/CMUXCLI+TmuxCompatSupport.swift +++ b/CLI/CMUXCLI+TmuxCompatSupport.swift @@ -48,7 +48,7 @@ extension CMUXCLI { var pieces: [String] = [] if let trimmedCwd, !trimmedCwd.isEmpty { let quotedCwd = tmuxShellQuote(resolvePath(trimmedCwd)) - pieces.append("{ cd -- \(quotedCwd) 2>/dev/null || [ ! -d \(quotedCwd) ]; }") + pieces.append("cd -- \(quotedCwd)") } if !commandText.isEmpty { pieces.append(commandText) @@ -204,7 +204,7 @@ extension CMUXCLI { ] if let cwd = cwd?.trimmingCharacters(in: .whitespacesAndNewlines), !cwd.isEmpty { let quotedCwd = tmuxShellQuote(resolvePath(cwd)) - lines.append("{ cd -- \(quotedCwd) 2>/dev/null || [ ! -d \(quotedCwd) ]; } || exit $?") + lines.append("cd -- \(quotedCwd) || exit $?") } lines.append("exec \"${SHELL:-/bin/sh}\" -lc \(tmuxShellQuote(commandText))") do { diff --git a/CLI/cmux.swift b/CLI/cmux.swift index b64edc50df44..42c0048e5ca5 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -23573,8 +23573,13 @@ struct CMUXCLI { var commandParts: [String] = [] commandParts.append(contentsOf: argv) - let command = commandParts.map(cliShellQuote).joined(separator: " ") - if let cwd = normalizedHookValue(workingDirectory) { + let cwd = normalizedHookValue(workingDirectory) + let sanitizedCommandParts = AgentLaunchSanitizer.removingSavedWorkingDirectoryOptions( + from: commandParts, + workingDirectory: cwd + ) + let command = sanitizedCommandParts.map(cliShellQuote).joined(separator: " ") + if let cwd { let quotedCwd = cliShellQuote(cwd) return "{ cd -- \(quotedCwd) 2>/dev/null || [ ! -d \(quotedCwd) ]; } && \(command)" } diff --git a/Packages/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizer.swift b/Packages/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizer.swift index 6e73408d6c0a..d209a4103848 100644 --- a/Packages/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizer.swift +++ b/Packages/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizer.swift @@ -151,6 +151,43 @@ public enum AgentLaunchSanitizer { return preserveOptions(tail, policy: codexPolicy) } + public static func removingSavedWorkingDirectoryOptions( + from args: [String], + workingDirectory: String? + ) -> [String] { + guard let workingDirectory = normalizedWorkingDirectory(workingDirectory) else { + return args + } + + let valueOptions: Set = ["--cd", "-C", "--cwd", "--workspace"] + let optionPrefixes = valueOptions.map { "\($0)=" } + var result: [String] = [] + var index = 0 + while index < args.count { + let arg = args[index] + if arg == "--" { + result.append(contentsOf: args[index...]) + break + } + if valueOptions.contains(arg), + index + 1 < args.count, + workingDirectoryValue(args[index + 1], matches: workingDirectory) { + index += 2 + continue + } + if let prefix = optionPrefixes.first(where: { arg.hasPrefix($0) }) { + let value = String(arg.dropFirst(prefix.count)) + if workingDirectoryValue(value, matches: workingDirectory) { + index += 1 + continue + } + } + result.append(arg) + index += 1 + } + return result + } + private static func preservedCodexLaunchArguments(args: [String]) -> [String]? { if codexForkCommand(in: args) != nil { return preservedCodexForkArguments(args: args) @@ -350,6 +387,21 @@ public enum AgentLaunchSanitizer { return droppedOptions.contains(String(arg[.. String? { + guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines), + !trimmed.isEmpty else { + return nil + } + return trimmed + } + + private static func workingDirectoryValue(_ value: String, matches workingDirectory: String) -> Bool { + guard value == workingDirectory else { + return (value as NSString).expandingTildeInPath == (workingDirectory as NSString).expandingTildeInPath + } + return true + } + private static func runtimeOnlyOptionWidth(_ arg: String) -> Int? { if let width = runtimeOnlyOptionWidths[arg] { return width diff --git a/Packages/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentLaunchSanitizerTests.swift b/Packages/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentLaunchSanitizerTests.swift index 7bc0851006a8..9f54027f8bde 100644 --- a/Packages/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentLaunchSanitizerTests.swift +++ b/Packages/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentLaunchSanitizerTests.swift @@ -650,4 +650,26 @@ struct AgentLaunchSanitizerTests { ) == ["amp", "--mode", "geppetto"] ) } + + @Test("Removes cwd options that duplicate the saved working directory") + func removesSavedWorkingDirectoryOptions() { + #expect( + AgentLaunchSanitizer.removingSavedWorkingDirectoryOptions( + from: ["codex", "resume", "session", "--cd", "/tmp/project", "--model", "gpt-5.4"], + workingDirectory: "/tmp/project" + ) == ["codex", "resume", "session", "--model", "gpt-5.4"] + ) + #expect( + AgentLaunchSanitizer.removingSavedWorkingDirectoryOptions( + from: ["grok", "-r", "session", "--cwd=/tmp/project", "--model", "grok-4"], + workingDirectory: "/tmp/project" + ) == ["grok", "-r", "session", "--model", "grok-4"] + ) + #expect( + AgentLaunchSanitizer.removingSavedWorkingDirectoryOptions( + from: ["qoder", "--workspace", "/tmp/other", "--cwd", "/tmp/project"], + workingDirectory: "/tmp/project" + ) == ["qoder", "--workspace", "/tmp/other"] + ) + } } diff --git a/Sources/RestorableAgentSession.swift b/Sources/RestorableAgentSession.swift index 6315d47c87d7..9f3c17635f72 100644 --- a/Sources/RestorableAgentSession.swift +++ b/Sources/RestorableAgentSession.swift @@ -56,7 +56,11 @@ nonisolated enum TerminalStartupWorkingDirectoryPrefix { from: trimmed, workingDirectory: workingDirectory ) - return prefix(stripped, workingDirectory: workingDirectory) + let command = strippedSavedWorkingDirectoryOptions( + from: stripped, + workingDirectory: workingDirectory + ) + return prefix(command, workingDirectory: workingDirectory) } private static func strippedRequiredChangeDirectoryPrefix( @@ -82,6 +86,19 @@ nonisolated enum TerminalStartupWorkingDirectoryPrefix { return command } + private static func strippedSavedWorkingDirectoryOptions( + from command: String, + workingDirectory: String + ) -> String { + let words = shellWords(command) + let stripped = AgentLaunchSanitizer.removingSavedWorkingDirectoryOptions( + from: words, + workingDirectory: workingDirectory + ) + guard stripped != words else { return command } + return stripped.map(TerminalStartupShellQuoting.singleQuoted).joined(separator: " ") + } + private static func normalized(_ value: String?) -> String? { guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines), !trimmed.isEmpty else { @@ -93,6 +110,53 @@ nonisolated enum TerminalStartupWorkingDirectoryPrefix { private static func legacySingleQuoted(_ value: String) -> String { "'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'" } + + private static func shellWords(_ command: String) -> [String] { + enum Quote { + case single + case double + } + + var words: [String] = [] + var current = "" + var quote: Quote? + var escaping = false + + func finishWord() { + guard !current.isEmpty else { return } + words.append(current) + current = "" + } + + for character in command { + if escaping { + current.append(character) + escaping = false + continue + } + if character == "\\" { + escaping = true + continue + } + switch (quote, character) { + case (.single, "'"), (.double, "\""): + quote = nil + case (nil, "'"): + quote = .single + case (nil, "\""): + quote = .double + case (nil, " "), (nil, "\t"), (nil, "\n"): + finishWord() + default: + current.append(character) + } + } + if escaping { + current.append("\\") + } + finishWord() + return words + } } enum AgentResumeCommandBuilder { @@ -182,10 +246,14 @@ enum AgentResumeCommandBuilder { } commandParts.append(contentsOf: argv) - let shellCommand = commandParts.map(shellSingleQuoted).joined(separator: " ") let cwd = !includeWorkingDirectoryPrefix || customRegistration?.cwd == .ignore ? nil : normalized(workingDirectory ?? launchCommand?.workingDirectory) + let sanitizedCommandParts = AgentLaunchSanitizer.removingSavedWorkingDirectoryOptions( + from: commandParts, + workingDirectory: cwd + ) + let shellCommand = sanitizedCommandParts.map(shellSingleQuoted).joined(separator: " ") return TerminalStartupWorkingDirectoryPrefix.prefix(shellCommand, workingDirectory: cwd) } diff --git a/cmuxTests/RestorableAgentHookProviderResumeTests.swift b/cmuxTests/RestorableAgentHookProviderResumeTests.swift index 6b1521da0b84..1bf75daedca7 100644 --- a/cmuxTests/RestorableAgentHookProviderResumeTests.swift +++ b/cmuxTests/RestorableAgentHookProviderResumeTests.swift @@ -351,15 +351,15 @@ extension SocketListenerAcceptPolicyTests { ) XCTAssertEqual( factory.resumeCommand, - "{ cd -- '/tmp/factory repo' 2>/dev/null || [ ! -d '/tmp/factory repo' ]; } && '/Users/example/.npm/bin/droid' '--resume' 'factory-session-123' '--cwd' '/tmp/factory repo' '--append-system-prompt' 'be terse'" + "{ cd -- '/tmp/factory repo' 2>/dev/null || [ ! -d '/tmp/factory repo' ]; } && '/Users/example/.npm/bin/droid' '--resume' 'factory-session-123' '--append-system-prompt' 'be terse'" ) XCTAssertEqual( qoder.resumeCommand, - "{ cd -- '/tmp/qoder repo' 2>/dev/null || [ ! -d '/tmp/qoder repo' ]; } && 'env' 'QODER_CONFIG_DIR=/tmp/qoder config' '/Users/example/.npm/bin/qodercli' '--resume' 'qoder-session-123' '--model' 'gemini-2.5-pro' '--permission-mode' 'plan' '--workspace' '/tmp/qoder repo'" + "{ cd -- '/tmp/qoder repo' 2>/dev/null || [ ! -d '/tmp/qoder repo' ]; } && 'env' 'QODER_CONFIG_DIR=/tmp/qoder config' '/Users/example/.npm/bin/qodercli' '--resume' 'qoder-session-123' '--model' 'gemini-2.5-pro' '--permission-mode' 'plan'" ) XCTAssertEqual( grok.resumeCommand, - "{ cd -- '/tmp/grok repo' 2>/dev/null || [ ! -d '/tmp/grok repo' ]; } && 'env' 'GROK_HOME=/tmp/grok home' '/Users/example/.grok/bin/grok' '-r' 'grok-session-123' '--model' 'grok-4' '--permission-mode' 'auto' '--cwd' '/tmp/grok repo'" + "{ cd -- '/tmp/grok repo' 2>/dev/null || [ ! -d '/tmp/grok repo' ]; } && 'env' 'GROK_HOME=/tmp/grok home' '/Users/example/.grok/bin/grok' '-r' 'grok-session-123' '--model' 'grok-4' '--permission-mode' 'auto'" ) XCTAssertEqual(pi.resumeCommand, "{ cd -- '/tmp/pi repo' 2>/dev/null || [ ! -d '/tmp/pi repo' ]; } && 'env' 'PI_CODING_AGENT_DIR=/tmp/pi home' '/Users/example/.bun/bin/pi' '--session' 'pi-session-123' '--model' 'anthropic/claude-sonnet-4-5' '--thinking' 'high'") XCTAssertEqual( diff --git a/cmuxTests/SessionPersistenceTests.swift b/cmuxTests/SessionPersistenceTests.swift index 64033f1af33f..d095c95f7940 100644 --- a/cmuxTests/SessionPersistenceTests.swift +++ b/cmuxTests/SessionPersistenceTests.swift @@ -2269,7 +2269,7 @@ final class SocketListenerAcceptPolicyTests: XCTestCase { XCTAssertEqual( snapshot.resumeCommand, - "{ cd -- '/Users/example/repo' 2>/dev/null || [ ! -d '/Users/example/repo' ]; } && 'env' 'CODEX_HOME=/tmp/codex home' '/Users/example/.bun/bin/codex' 'resume' '019dad34-d218-7943-b81a-eddac5c87951' '--model' 'gpt-5.4' '--sandbox' 'danger-full-access' '--ask-for-approval' 'never' '--search' '--cd' '/Users/example/repo'" + "{ cd -- '/Users/example/repo' 2>/dev/null || [ ! -d '/Users/example/repo' ]; } && 'env' 'CODEX_HOME=/tmp/codex home' '/Users/example/.bun/bin/codex' 'resume' '019dad34-d218-7943-b81a-eddac5c87951' '--model' 'gpt-5.4' '--sandbox' 'danger-full-access' '--ask-for-approval' 'never' '--search'" ) } @@ -2630,7 +2630,7 @@ final class SocketListenerAcceptPolicyTests: XCTestCase { ) XCTAssertEqual( codex.forkCommand, - "{ cd -- '/Users/example/repo' 2>/dev/null || [ ! -d '/Users/example/repo' ]; } && 'env' 'CODEX_HOME=/tmp/codex home' '/Users/example/.bun/bin/codex' 'fork' '019dad34-d218-7943-b81a-eddac5c87951' '--model' 'gpt-5.4' '--sandbox' 'danger-full-access' '--ask-for-approval' 'never' '--search' '--cd' '/Users/example/repo'" + "{ cd -- '/Users/example/repo' 2>/dev/null || [ ! -d '/Users/example/repo' ]; } && 'env' 'CODEX_HOME=/tmp/codex home' '/Users/example/.bun/bin/codex' 'fork' '019dad34-d218-7943-b81a-eddac5c87951' '--model' 'gpt-5.4' '--sandbox' 'danger-full-access' '--ask-for-approval' 'never' '--search'" ) XCTAssertEqual( codexWithImage.forkCommand, @@ -3841,6 +3841,23 @@ extension SessionPersistenceTests { ) } + func testAgentHookSurfaceResumeBindingDropsDuplicateWorkingDirectoryOption() { + let binding = SurfaceResumeBindingSnapshot( + command: "cd '/tmp/project' && codex resume session --cd '/tmp/project' --model gpt-5.4", + cwd: "/tmp/project", + source: "agent-hook", + updatedAt: 1 + ) + + XCTAssertEqual( + binding.command, + TerminalStartupWorkingDirectoryPrefix.prefix( + "'codex' 'resume' 'session' '--model' 'gpt-5.4'", + workingDirectory: "/tmp/project" + ) + ) + } + func testAgentHookSurfaceResumeBindingCanonicalizesLegacyGuardForNonASCIIWorkingDirectory() { let cwd = "/tmp/\u{4E2D}\u{6587}\u{8DEF}\u{5F84}" let legacyQuotedCwd = "'\(cwd)'" From d67a2f0b44dfbff67d7468701c45fe80f6e0631c Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Wed, 27 May 2026 06:19:44 -0700 Subject: [PATCH 6/9] fix: preserve shell args during cwd cleanup --- .../AgentLaunchSanitizer.swift | 2 +- .../AgentLaunchSanitizerTests.swift | 6 +++ Sources/RestorableAgentSession.swift | 46 +++++++++++++------ cmuxTests/SessionPersistenceTests.swift | 4 +- 4 files changed, 40 insertions(+), 18 deletions(-) diff --git a/Packages/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizer.swift b/Packages/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizer.swift index d209a4103848..15804913a8a8 100644 --- a/Packages/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizer.swift +++ b/Packages/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizer.swift @@ -159,7 +159,7 @@ public enum AgentLaunchSanitizer { return args } - let valueOptions: Set = ["--cd", "-C", "--cwd", "--workspace"] + let valueOptions: Set = ["--cd", "-C", "--cwd", "--workspace", "-w"] let optionPrefixes = valueOptions.map { "\($0)=" } var result: [String] = [] var index = 0 diff --git a/Packages/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentLaunchSanitizerTests.swift b/Packages/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentLaunchSanitizerTests.swift index 9f54027f8bde..58ee5d5c91bd 100644 --- a/Packages/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentLaunchSanitizerTests.swift +++ b/Packages/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentLaunchSanitizerTests.swift @@ -671,5 +671,11 @@ struct AgentLaunchSanitizerTests { workingDirectory: "/tmp/project" ) == ["qoder", "--workspace", "/tmp/other"] ) + #expect( + AgentLaunchSanitizer.removingSavedWorkingDirectoryOptions( + from: ["qoder", "-w", "/tmp/project", "--model", "best"], + workingDirectory: "/tmp/project" + ) == ["qoder", "--model", "best"] + ) } } diff --git a/Sources/RestorableAgentSession.swift b/Sources/RestorableAgentSession.swift index 9f3c17635f72..5ba1f0afaca7 100644 --- a/Sources/RestorableAgentSession.swift +++ b/Sources/RestorableAgentSession.swift @@ -120,39 +120,55 @@ nonisolated enum TerminalStartupWorkingDirectoryPrefix { var words: [String] = [] var current = "" var quote: Quote? - var escaping = false + var hasCurrentWord = false + let characters = Array(command) + let doubleQuoteEscapable: Set = ["$", "`", "\"", "\\", "\n"] func finishWord() { - guard !current.isEmpty else { return } + guard hasCurrentWord else { return } words.append(current) current = "" + hasCurrentWord = false } - for character in command { - if escaping { - current.append(character) - escaping = false - continue - } - if character == "\\" { - escaping = true - continue - } + var index = 0 + while index < characters.count { + let character = characters[index] switch (quote, character) { case (.single, "'"), (.double, "\""): quote = nil case (nil, "'"): quote = .single + hasCurrentWord = true case (nil, "\""): quote = .double + hasCurrentWord = true + case (.double, "\\"): + if index + 1 < characters.count, + doubleQuoteEscapable.contains(characters[index + 1]) { + current.append(characters[index + 1]) + hasCurrentWord = true + index += 2 + continue + } + current.append(character) + hasCurrentWord = true + case (nil, "\\"): + if index + 1 < characters.count { + current.append(characters[index + 1]) + hasCurrentWord = true + index += 2 + continue + } + current.append(character) + hasCurrentWord = true case (nil, " "), (nil, "\t"), (nil, "\n"): finishWord() default: current.append(character) + hasCurrentWord = true } - } - if escaping { - current.append("\\") + index += 1 } finishWord() return words diff --git a/cmuxTests/SessionPersistenceTests.swift b/cmuxTests/SessionPersistenceTests.swift index d095c95f7940..edcc0a7f2751 100644 --- a/cmuxTests/SessionPersistenceTests.swift +++ b/cmuxTests/SessionPersistenceTests.swift @@ -3843,7 +3843,7 @@ extension SessionPersistenceTests { func testAgentHookSurfaceResumeBindingDropsDuplicateWorkingDirectoryOption() { let binding = SurfaceResumeBindingSnapshot( - command: "cd '/tmp/project' && codex resume session --cd '/tmp/project' --model gpt-5.4", + command: "cd '/tmp/project' && codex resume session --append-system-prompt 'use C:\\tmp' --cd '/tmp/project' --model gpt-5.4", cwd: "/tmp/project", source: "agent-hook", updatedAt: 1 @@ -3852,7 +3852,7 @@ extension SessionPersistenceTests { XCTAssertEqual( binding.command, TerminalStartupWorkingDirectoryPrefix.prefix( - "'codex' 'resume' 'session' '--model' 'gpt-5.4'", + "'codex' 'resume' 'session' '--append-system-prompt' 'use C:\\tmp' '--model' 'gpt-5.4'", workingDirectory: "/tmp/project" ) ) From cffd2140f70fa582343d3ac2e969033c47395ce9 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Wed, 27 May 2026 06:57:59 -0700 Subject: [PATCH 7/9] fix: preserve custom resume cwd arguments --- Sources/RestorableAgentSession.swift | 10 +++--- cmuxTests/PiVaultAgentPersistenceTests.swift | 32 ++++++++++++++++++++ 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/Sources/RestorableAgentSession.swift b/Sources/RestorableAgentSession.swift index 5ba1f0afaca7..6ff78680d1aa 100644 --- a/Sources/RestorableAgentSession.swift +++ b/Sources/RestorableAgentSession.swift @@ -265,10 +265,12 @@ enum AgentResumeCommandBuilder { let cwd = !includeWorkingDirectoryPrefix || customRegistration?.cwd == .ignore ? nil : normalized(workingDirectory ?? launchCommand?.workingDirectory) - let sanitizedCommandParts = AgentLaunchSanitizer.removingSavedWorkingDirectoryOptions( - from: commandParts, - workingDirectory: cwd - ) + let sanitizedCommandParts = customRegistration == nil + ? AgentLaunchSanitizer.removingSavedWorkingDirectoryOptions( + from: commandParts, + workingDirectory: cwd + ) + : commandParts let shellCommand = sanitizedCommandParts.map(shellSingleQuoted).joined(separator: " ") return TerminalStartupWorkingDirectoryPrefix.prefix(shellCommand, workingDirectory: cwd) } diff --git a/cmuxTests/PiVaultAgentPersistenceTests.swift b/cmuxTests/PiVaultAgentPersistenceTests.swift index 37cce86c5c29..f7a2f64b84b6 100644 --- a/cmuxTests/PiVaultAgentPersistenceTests.swift +++ b/cmuxTests/PiVaultAgentPersistenceTests.swift @@ -327,6 +327,38 @@ final class PiVaultAgentPersistenceTests: XCTestCase { XCTAssertEqual(command, "'acme-agent' '--cwd' '/tmp/acme' '--session' 'session-123'") } + func testRegisteredAgentTemplatePreservesCWDArgumentWithWorkingDirectoryPrefix() { + let registration = CmuxVaultAgentRegistration( + id: "acme-agent", + name: "Acme Agent", + detect: CmuxVaultAgentDetectRule(processName: "acme-agent"), + sessionIdSource: .argvOption("--session"), + resumeCommand: "acme-agent --cwd {{cwd}} --session {{sessionId}}", + cwd: .preserve + ) + + let command = AgentResumeCommandBuilder.resumeShellCommand( + kind: .custom("acme-agent"), + sessionId: "session-123", + launchCommand: AgentLaunchCommandSnapshot( + launcher: "acme-agent", + executablePath: nil, + arguments: ["acme-agent"], + workingDirectory: nil, + environment: nil, + capturedAt: nil, + source: "test" + ), + workingDirectory: "/tmp/acme", + registrationOverride: registration + ) + + XCTAssertEqual( + command, + "{ cd -- '/tmp/acme' 2>/dev/null || [ ! -d '/tmp/acme' ]; } && 'acme-agent' '--cwd' '/tmp/acme' '--session' 'session-123'" + ) + } + func testRegisteredAgentTemplateDoesNotExpandPlaceholdersInsideReplacementValues() { let registration = CmuxVaultAgentRegistration( id: "acme-agent", From da06cd886358009aa0c3cdf1b84c12efd16a42a4 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Wed, 27 May 2026 07:31:03 -0700 Subject: [PATCH 8/9] fix: preserve shell syntax during cwd cleanup --- Sources/RestorableAgentSession.swift | 160 +++++++++++++++++++----- cmuxTests/SessionPersistenceTests.swift | 20 ++- 2 files changed, 150 insertions(+), 30 deletions(-) diff --git a/Sources/RestorableAgentSession.swift b/Sources/RestorableAgentSession.swift index 6ff78680d1aa..ae7c0825a1c5 100644 --- a/Sources/RestorableAgentSession.swift +++ b/Sources/RestorableAgentSession.swift @@ -90,13 +90,13 @@ nonisolated enum TerminalStartupWorkingDirectoryPrefix { from command: String, workingDirectory: String ) -> String { - let words = shellWords(command) - let stripped = AgentLaunchSanitizer.removingSavedWorkingDirectoryOptions( - from: words, + let words = shellWordRanges(command) + let ranges = savedWorkingDirectoryOptionRanges( + in: words, workingDirectory: workingDirectory ) - guard stripped != words else { return command } - return stripped.map(TerminalStartupShellQuoting.singleQuoted).joined(separator: " ") + guard !ranges.isEmpty else { return command } + return removingRanges(removing: ranges, from: command) } private static func normalized(_ value: String?) -> String? { @@ -111,68 +111,170 @@ nonisolated enum TerminalStartupWorkingDirectoryPrefix { "'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'" } + private struct ShellWordRange { + var value: String + var range: Range + } + private static func shellWords(_ command: String) -> [String] { + shellWordRanges(command).map(\.value) + } + + private static func shellWordRanges(_ command: String) -> [ShellWordRange] { enum Quote { case single case double } - var words: [String] = [] + var words: [ShellWordRange] = [] var current = "" + var wordStart: String.Index? var quote: Quote? var hasCurrentWord = false - let characters = Array(command) let doubleQuoteEscapable: Set = ["$", "`", "\"", "\\", "\n"] - func finishWord() { + func markWordStart(_ index: String.Index) { + if wordStart == nil { + wordStart = index + } + hasCurrentWord = true + } + + func finishWord(at end: String.Index) { guard hasCurrentWord else { return } - words.append(current) + words.append(ShellWordRange(value: current, range: (wordStart ?? end).. [Range] { + let valueOptions: Set = ["--cd", "-C", "--cwd", "--workspace", "-w"] + let optionPrefixes = valueOptions.map { "\($0)=" } + var ranges: [Range] = [] + var index = 0 + while index < words.count { + let arg = words[index].value + if arg == "--" { + break + } + if valueOptions.contains(arg), + index + 1 < words.count, + workingDirectoryValue(words[index + 1].value, matches: workingDirectory) { + ranges.append(words[index].range.lowerBound..], + from command: String + ) -> String { + let expanded = ranges.map { range -> Range in + var lower = range.lowerBound + var upper = range.upperBound + if lower == command.startIndex { + while upper < command.endIndex, command[upper].isWhitespace { + upper = command.index(after: upper) + } + } else { + while lower > command.startIndex { + let before = command.index(before: lower) + guard command[before].isWhitespace else { break } + lower = before + } + } + return lower..] = [] + for range in expanded { + guard let last = merged.last else { + merged.append(range) + continue + } + if range.lowerBound <= last.upperBound { + let upper = last.upperBound < range.upperBound ? range.upperBound : last.upperBound + merged[merged.count - 1] = last.lowerBound.. Bool { + guard value == workingDirectory else { + return (value as NSString).expandingTildeInPath == (workingDirectory as NSString).expandingTildeInPath + } + return true + } } enum AgentResumeCommandBuilder { diff --git a/cmuxTests/SessionPersistenceTests.swift b/cmuxTests/SessionPersistenceTests.swift index edcc0a7f2751..edf2de27b5c3 100644 --- a/cmuxTests/SessionPersistenceTests.swift +++ b/cmuxTests/SessionPersistenceTests.swift @@ -3852,12 +3852,30 @@ extension SessionPersistenceTests { XCTAssertEqual( binding.command, TerminalStartupWorkingDirectoryPrefix.prefix( - "'codex' 'resume' 'session' '--append-system-prompt' 'use C:\\tmp' '--model' 'gpt-5.4'", + "codex resume session --append-system-prompt 'use C:\\tmp' --model gpt-5.4", workingDirectory: "/tmp/project" ) ) } + func testAgentHookSurfaceResumeBindingPreservesShellOperatorsWhenDroppingDuplicateWorkingDirectoryOption() { + let binding = SurfaceResumeBindingSnapshot( + command: "cd '/tmp/project' && codex resume session --cd '/tmp/project' && echo done", + cwd: "/tmp/project", + source: "agent-hook", + updatedAt: 1 + ) + + XCTAssertEqual( + binding.command, + TerminalStartupWorkingDirectoryPrefix.prefix( + "codex resume session && echo done", + workingDirectory: "/tmp/project" + ) + ) + XCTAssertFalse(binding.command.contains("'&&'"), binding.command) + } + func testAgentHookSurfaceResumeBindingCanonicalizesLegacyGuardForNonASCIIWorkingDirectory() { let cwd = "/tmp/\u{4E2D}\u{6587}\u{8DEF}\u{5F84}" let legacyQuotedCwd = "'\(cwd)'" From bb5c757e6665dedf1eb1918c987e927e845841f5 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Wed, 27 May 2026 08:19:48 -0700 Subject: [PATCH 9/9] chore: remove unused shell word helper --- Sources/RestorableAgentSession.swift | 4 ---- 1 file changed, 4 deletions(-) diff --git a/Sources/RestorableAgentSession.swift b/Sources/RestorableAgentSession.swift index ae7c0825a1c5..16f3520e33b2 100644 --- a/Sources/RestorableAgentSession.swift +++ b/Sources/RestorableAgentSession.swift @@ -116,10 +116,6 @@ nonisolated enum TerminalStartupWorkingDirectoryPrefix { var range: Range } - private static func shellWords(_ command: String) -> [String] { - shellWordRanges(command).map(\.value) - } - private static func shellWordRanges(_ command: String) -> [ShellWordRange] { enum Quote { case single