Skip to content

Commit 9cd3d82

Browse files
committed
fix: bound restored agent startup input
1 parent b9efe4c commit 9cd3d82

3 files changed

Lines changed: 194 additions & 7 deletions

File tree

Sources/RestorableAgentSession.swift

Lines changed: 88 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,10 @@ struct AgentLaunchCommandSnapshot: Codable, Equatable, Sendable {
5050
var source: String?
5151
}
5252

53+
fileprivate func shellSingleQuoted(_ value: String) -> String {
54+
"'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'"
55+
}
56+
5357
private enum AgentResumeCommandBuilder {
5458
private static let claudeValueOptions: Set<String> = [
5559
"--add-dir",
@@ -647,13 +651,11 @@ private enum AgentResumeCommandBuilder {
647651
}
648652
return trimmed
649653
}
650-
651-
private static func shellSingleQuoted(_ value: String) -> String {
652-
"'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'"
653-
}
654654
}
655655

656656
struct SessionRestorableAgentSnapshot: Codable, Sendable {
657+
static let maxInlineStartupInputBytes = 900
658+
657659
var kind: RestorableAgentKind
658660
var sessionId: String
659661
var workingDirectory: String?
@@ -666,6 +668,88 @@ struct SessionRestorableAgentSnapshot: Codable, Sendable {
666668
workingDirectory: workingDirectory
667669
)
668670
}
671+
672+
func resumeStartupInput(
673+
fileManager: FileManager = .default,
674+
temporaryDirectory: URL = FileManager.default.temporaryDirectory
675+
) -> String? {
676+
guard let command = resumeCommand else { return nil }
677+
678+
let inlineInput = command + "\n"
679+
guard inlineInput.utf8.count > Self.maxInlineStartupInputBytes else {
680+
return inlineInput
681+
}
682+
guard let scriptURL = AgentResumeScriptStore.writeLauncherScript(
683+
command: command,
684+
kind: kind,
685+
sessionId: sessionId,
686+
fileManager: fileManager,
687+
temporaryDirectory: temporaryDirectory
688+
) else {
689+
return nil
690+
}
691+
692+
let scriptInput = "/bin/zsh \(shellSingleQuoted(scriptURL.path))\n"
693+
return scriptInput.utf8.count <= Self.maxInlineStartupInputBytes ? scriptInput : nil
694+
}
695+
}
696+
697+
private enum AgentResumeScriptStore {
698+
private static let directoryName = "cmux-agent-resume"
699+
private static let scriptTTL: TimeInterval = 24 * 60 * 60
700+
701+
static func writeLauncherScript(
702+
command: String,
703+
kind: RestorableAgentKind,
704+
sessionId: String,
705+
fileManager: FileManager,
706+
temporaryDirectory: URL
707+
) -> URL? {
708+
let directoryURL = temporaryDirectory.appendingPathComponent(directoryName, isDirectory: true)
709+
do {
710+
try fileManager.createDirectory(at: directoryURL, withIntermediateDirectories: true)
711+
try? fileManager.setAttributes([.posixPermissions: 0o700], ofItemAtPath: directoryURL.path)
712+
pruneOldScripts(in: directoryURL, fileManager: fileManager)
713+
714+
let safeSessionPrefix = sessionId
715+
.prefix(12)
716+
.map { character -> Character in
717+
character.isLetter || character.isNumber || character == "-" ? character : "_"
718+
}
719+
let scriptURL = directoryURL.appendingPathComponent(
720+
"\(kind.rawValue)-\(String(safeSessionPrefix))-\(UUID().uuidString).zsh",
721+
isDirectory: false
722+
)
723+
let contents = """
724+
#!/bin/zsh
725+
rm -f -- "$0" 2>/dev/null || true
726+
\(command)
727+
"""
728+
try contents.write(to: scriptURL, atomically: true, encoding: .utf8)
729+
try? fileManager.setAttributes([.posixPermissions: 0o600], ofItemAtPath: scriptURL.path)
730+
return scriptURL
731+
} catch {
732+
return nil
733+
}
734+
}
735+
736+
private static func pruneOldScripts(in directoryURL: URL, fileManager: FileManager) {
737+
guard let scriptURLs = try? fileManager.contentsOfDirectory(
738+
at: directoryURL,
739+
includingPropertiesForKeys: [.contentModificationDateKey],
740+
options: [.skipsHiddenFiles]
741+
) else {
742+
return
743+
}
744+
745+
let cutoff = Date().addingTimeInterval(-scriptTTL)
746+
for scriptURL in scriptURLs where scriptURL.pathExtension == "zsh" {
747+
let values = try? scriptURL.resourceValues(forKeys: [.contentModificationDateKey])
748+
if let modified = values?.contentModificationDate, modified < cutoff {
749+
try? fileManager.removeItem(at: scriptURL)
750+
}
751+
}
752+
}
669753
}
670754

671755
private struct RestorableAgentHookSessionRecord: Codable, Sendable {

Sources/Workspace.swift

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -701,8 +701,7 @@ extension Workspace {
701701
let shouldReplayScrollback = Self.shouldReplaySessionScrollback(
702702
restorableAgent: restorableAgent
703703
)
704-
let restoredAgentResumeCommand = restorableAgent?.resumeCommand
705-
let restoredAgentResumeInput = restoredAgentResumeCommand.map { $0 + "\n" }
704+
let restoredAgentResumeInput = restorableAgent?.resumeStartupInput()
706705
#if DEBUG
707706
if let restorableAgent {
708707
let sessionPreview = String(restorableAgent.sessionId.prefix(8))
@@ -711,7 +710,7 @@ extension Workspace {
711710
"session.restore.agent panel=\(snapshot.id.uuidString.prefix(5)) " +
712711
"kind=\(restorableAgent.kind.rawValue) session=\(sessionPreview) " +
713712
"hasLaunch=\(restorableAgent.launchCommand == nil ? 0 : 1) " +
714-
"launchArgc=\(launchArgc) hasResume=\(restoredAgentResumeCommand == nil ? 0 : 1) " +
713+
"launchArgc=\(launchArgc) hasResume=\(restoredAgentResumeInput == nil ? 0 : 1) " +
715714
"replayScrollback=\(shouldReplayScrollback ? 1 : 0)"
716715
)
717716
}

cmuxTests/SessionPersistenceTests.swift

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1472,6 +1472,110 @@ final class SocketListenerAcceptPolicyTests: XCTestCase {
14721472
)
14731473
}
14741474

1475+
func testRestorableAgentStartupInputUsesInlineCommandWhenShort() {
1476+
let snapshot = SessionRestorableAgentSnapshot(
1477+
kind: .claude,
1478+
sessionId: "claude-session-123",
1479+
workingDirectory: "/tmp/cmux project",
1480+
launchCommand: AgentLaunchCommandSnapshot(
1481+
launcher: "claude",
1482+
executablePath: "/opt/Claude Code/bin/claude",
1483+
arguments: [
1484+
"/opt/Claude Code/bin/claude",
1485+
"--model",
1486+
"sonnet"
1487+
],
1488+
workingDirectory: "/tmp/cmux project",
1489+
environment: nil,
1490+
capturedAt: 123,
1491+
source: "environment"
1492+
)
1493+
)
1494+
1495+
XCTAssertEqual(snapshot.resumeStartupInput(), snapshot.resumeCommand.map { $0 + "\n" })
1496+
}
1497+
1498+
func testRestorableAgentStartupInputUsesLauncherScriptWhenCommandExceedsTerminalInputBudget() throws {
1499+
let tempDir = FileManager.default.temporaryDirectory
1500+
.appendingPathComponent("cmux-agent-resume-test-\(UUID().uuidString)", isDirectory: true)
1501+
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
1502+
defer { try? FileManager.default.removeItem(at: tempDir) }
1503+
1504+
let longPath = "/tmp/" + String(repeating: "nested-path-", count: 120)
1505+
let snapshot = SessionRestorableAgentSnapshot(
1506+
kind: .codex,
1507+
sessionId: "019dad34-d218-7943-b81a-eddac5c87951",
1508+
workingDirectory: "/tmp/repo",
1509+
launchCommand: AgentLaunchCommandSnapshot(
1510+
launcher: "codex",
1511+
executablePath: "/Users/example/.bun/bin/codex",
1512+
arguments: [
1513+
"/Users/example/.bun/bin/codex",
1514+
"--model",
1515+
"gpt-5.4",
1516+
"--add-dir",
1517+
longPath,
1518+
"initial prompt should not replay"
1519+
],
1520+
workingDirectory: "/tmp/repo",
1521+
environment: ["CODEX_HOME": "/tmp/codex"],
1522+
capturedAt: 123,
1523+
source: "environment"
1524+
)
1525+
)
1526+
1527+
let input = try XCTUnwrap(snapshot.resumeStartupInput(temporaryDirectory: tempDir))
1528+
XCTAssertLessThanOrEqual(input.utf8.count, SessionRestorableAgentSnapshot.maxInlineStartupInputBytes)
1529+
XCTAssertTrue(input.hasPrefix("/bin/zsh '"))
1530+
XCTAssertFalse(input.contains(longPath))
1531+
1532+
let trimmedInput = input.trimmingCharacters(in: .whitespacesAndNewlines)
1533+
let prefix = "/bin/zsh '"
1534+
let scriptPath = String(trimmedInput.dropFirst(prefix.count).dropLast())
1535+
let scriptContents = try String(contentsOfFile: scriptPath, encoding: .utf8)
1536+
XCTAssertTrue(scriptContents.contains(longPath))
1537+
XCTAssertTrue(scriptContents.contains("'resume'"))
1538+
XCTAssertTrue(scriptContents.contains("'019dad34-d218-7943-b81a-eddac5c87951'"))
1539+
1540+
let attributes = try FileManager.default.attributesOfItem(atPath: scriptPath)
1541+
let permissions = try XCTUnwrap(attributes[.posixPermissions] as? NSNumber).intValue & 0o777
1542+
XCTAssertEqual(permissions, 0o600)
1543+
}
1544+
1545+
func testRestorableAgentStartupInputSkipsOversizedCommandWhenScriptCannotBeWritten() throws {
1546+
let tempDir = FileManager.default.temporaryDirectory
1547+
.appendingPathComponent("cmux-agent-resume-test-\(UUID().uuidString)", isDirectory: true)
1548+
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
1549+
defer { try? FileManager.default.removeItem(at: tempDir) }
1550+
1551+
let blockedDirectory = tempDir.appendingPathComponent("not-a-directory", isDirectory: false)
1552+
try "occupied".write(to: blockedDirectory, atomically: true, encoding: .utf8)
1553+
let longPath = "/tmp/" + String(repeating: "nested-path-", count: 120)
1554+
let snapshot = SessionRestorableAgentSnapshot(
1555+
kind: .codex,
1556+
sessionId: "019dad34-d218-7943-b81a-eddac5c87951",
1557+
workingDirectory: "/tmp/repo",
1558+
launchCommand: AgentLaunchCommandSnapshot(
1559+
launcher: "codex",
1560+
executablePath: "/Users/example/.bun/bin/codex",
1561+
arguments: [
1562+
"/Users/example/.bun/bin/codex",
1563+
"--model",
1564+
"gpt-5.4",
1565+
"--add-dir",
1566+
longPath,
1567+
"initial prompt should not replay"
1568+
],
1569+
workingDirectory: "/tmp/repo",
1570+
environment: ["CODEX_HOME": "/tmp/codex"],
1571+
capturedAt: 123,
1572+
source: "environment"
1573+
)
1574+
)
1575+
1576+
XCTAssertNil(snapshot.resumeStartupInput(temporaryDirectory: blockedDirectory))
1577+
}
1578+
14751579
func testClaudeResumeCommandPreservesDangerouslySkipPermissionsAndObservedEnvironment() {
14761580
let snapshot = SessionRestorableAgentSnapshot(
14771581
kind: .claude,

0 commit comments

Comments
 (0)