Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 28 additions & 1 deletion Sources/RestorableAgentSession.swift
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down
8 changes: 2 additions & 6 deletions Sources/SessionIndexModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
109 changes: 109 additions & 0 deletions cmuxTests/SessionPersistenceTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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[..<separator.lowerBound])
}

private func assertZshCommandChangesDirectory(
_ cdCommand: String,
expectedPath: String,
file: StaticString = #filePath,
line: UInt = #line
) throws {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/bin/zsh")
process.arguments = ["-fc", "\(cdCommand) && pwd"]

let output = Pipe()
let error = Pipe()
process.standardOutput = output
process.standardError = error

try process.run()
process.waitUntilExit()

let outputData = output.fileHandleForReading.readDataToEndOfFile()
let errorData = error.fileHandleForReading.readDataToEndOfFile()
let stdout = String(data: outputData, encoding: .utf8)?
.trimmingCharacters(in: .newlines)
let stderr = String(data: errorData, encoding: .utf8)?
.trimmingCharacters(in: .whitespacesAndNewlines)

XCTAssertEqual(process.terminationStatus, 0, stderr ?? "", file: file, line: line)
XCTAssertEqual(stdout, expectedPath, file: file, line: line)
}

func testRestorableAgentStartupInputUsesInlineCommandWhenShort() {
let snapshot = SessionRestorableAgentSnapshot(
kind: .claude,
Expand Down
Loading