diff --git a/Sources/RestorableAgentSession.swift b/Sources/RestorableAgentSession.swift index 68e6bf8f27ce..5b901d9ce1e6 100644 --- a/Sources/RestorableAgentSession.swift +++ b/Sources/RestorableAgentSession.swift @@ -1,8 +1,35 @@ import Foundation import CMUXAgentLaunch +nonisolated enum TerminalStartupShellQuoting { + static func singleQuoted(_ value: String) -> String { + if value.utf8.contains(where: { $0 >= 0x80 }) { + return asciiPrintfCommandSubstitution(for: value) + } + return "'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'" + } + + static func shellToken(_ value: String, allowingBareASCII: Bool) -> String { + if value.utf8.contains(where: { $0 >= 0x80 }) { + return asciiPrintfCommandSubstitution(for: value) + } + if allowingBareASCII, + value.range(of: "[^A-Za-z0-9_./:=+-]", options: .regularExpression) == nil { + return value + } + return singleQuoted(value) + } + + private static func asciiPrintfCommandSubstitution(for value: String) -> String { + let octalBytes = value.utf8 + .map { String(format: #"\%03o"#, Int($0)) } + .joined() + return #""$(printf '"# + octalBytes + #"')""# + } +} + fileprivate func shellSingleQuoted(_ value: String) -> String { - "'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'" + TerminalStartupShellQuoting.singleQuoted(value) } enum AgentResumeCommandBuilder { diff --git a/Sources/SessionIndexModels.swift b/Sources/SessionIndexModels.swift index baaddd5a897d..7ea3d9920751 100644 --- a/Sources/SessionIndexModels.swift +++ b/Sources/SessionIndexModels.swift @@ -432,16 +432,12 @@ struct SessionEntry: Identifiable, Hashable { } private static func shellSingleQuote(_ value: String) -> String { - "'\(value.replacingOccurrences(of: "'", with: #"'\''"#))'" + TerminalStartupShellQuoting.singleQuoted(value) } /// Single-quote a value for safe shell injection. Escapes embedded single quotes. static func shellQuote(_ value: String) -> String { - if value.range(of: "[^A-Za-z0-9_./:=+-]", options: .regularExpression) == nil { - return value - } - let escaped = value.replacingOccurrences(of: "'", with: #"'\''"#) - return "'\(escaped)'" + TerminalStartupShellQuoting.shellToken(value, allowingBareASCII: true) } var displayTitle: String { diff --git a/cmuxTests/SessionPersistenceTests.swift b/cmuxTests/SessionPersistenceTests.swift index 3e5750ad76ca..05e95522b590 100644 --- a/cmuxTests/SessionPersistenceTests.swift +++ b/cmuxTests/SessionPersistenceTests.swift @@ -1948,6 +1948,45 @@ final class SocketListenerAcceptPolicyTests: XCTestCase { ) } + func testRestorableAgentResumeStartupInputEscapesNonAsciiWorkingDirectoryAsAsciiShellInput() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-agent-resume-\(UUID().uuidString)", isDirectory: true) + let cwdURL = root + .appendingPathComponent("中文路径", isDirectory: true) + .appendingPathComponent("uam-service", isDirectory: true) + try FileManager.default.createDirectory(at: cwdURL, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let snapshot = SessionRestorableAgentSnapshot( + kind: .claude, + sessionId: "claude-session-123", + workingDirectory: cwdURL.path, + launchCommand: AgentLaunchCommandSnapshot( + launcher: "claude", + executablePath: "/opt/Claude Code/bin/claude", + arguments: [ + "/opt/Claude Code/bin/claude", + "--model", + "sonnet" + ], + workingDirectory: cwdURL.path, + environment: ["CLAUDE_CONFIG_DIR": cwdURL.path], + capturedAt: 123, + source: "environment" + ) + ) + + let startupInput = try XCTUnwrap(snapshot.resumeStartupInput()) + XCTAssertTrue( + startupInput.utf8.allSatisfy { $0 < 0x80 }, + "Terminal startup input must stay ASCII-only so UTF-8 paths are reconstructed by the shell instead of being mojibaked before execution." + ) + + let command = startupInput.trimmingCharacters(in: .newlines) + let cdCommand = try leadingCdCommand(from: command) + try assertZshCommandChangesDirectory(cdCommand, expectedPath: cwdURL.path) + } + func testSessionEntryClaudeResumeCommandChangesToSessionCwdBeforeResume() throws { let root = FileManager.default.temporaryDirectory .appendingPathComponent("cmux-claude-resume-\(UUID().uuidString)", isDirectory: true) @@ -1986,6 +2025,76 @@ final class SocketListenerAcceptPolicyTests: XCTestCase { ) } + func testSessionEntryClaudeResumeCommandEscapesNonAsciiCwdAsAsciiShellInput() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-claude-resume-\(UUID().uuidString)", isDirectory: true) + let cwdURL = root + .appendingPathComponent("中文路径", isDirectory: true) + .appendingPathComponent("uam-service", isDirectory: true) + try FileManager.default.createDirectory(at: cwdURL, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let entry = SessionEntry( + id: "claude:4d8cfb79-ef17-41a7-a0ac-2f0c25ac1519", + agent: .claude, + sessionId: "4d8cfb79-ef17-41a7-a0ac-2f0c25ac1519", + title: "resume me", + cwd: cwdURL.path, + gitBranch: nil, + pullRequest: nil, + modified: Date(timeIntervalSince1970: 0), + fileURL: nil, + specifics: .claude( + model: "gpt-5.5", + permissionMode: "bypassPermissions", + configDirectoryForResume: nil + ) + ) + + let command = try XCTUnwrap(entry.resumeCommand) + XCTAssertTrue( + command.utf8.allSatisfy { $0 < 0x80 }, + "Terminal startup input must stay ASCII-only so UTF-8 paths are reconstructed by the shell instead of being mojibaked before execution." + ) + + let cdCommand = try leadingCdCommand(from: command) + try assertZshCommandChangesDirectory(cdCommand, expectedPath: cwdURL.path) + } + + private func leadingCdCommand(from command: String) throws -> String { + let separator = try XCTUnwrap(command.range(of: " && ")) + return String(command[..