diff --git a/CLI/CMUXCLI+CommandSuggestions.swift b/CLI/CMUXCLI+CommandSuggestions.swift index 06081b463afa..d3b3dfbed022 100644 --- a/CLI/CMUXCLI+CommandSuggestions.swift +++ b/CLI/CMUXCLI+CommandSuggestions.swift @@ -166,6 +166,7 @@ extension CMUXCLI { "resize-pane", "respawn-pane", "restore-session", + "restore", "right-sidebar", "rpc", "select-workspace", diff --git a/CLI/CMUXCLI+Restore.swift b/CLI/CMUXCLI+Restore.swift new file mode 100644 index 000000000000..affe89e57e37 --- /dev/null +++ b/CLI/CMUXCLI+Restore.swift @@ -0,0 +1,296 @@ +import CMUXAgentLaunch +import Foundation + +extension CMUXCLI { + func controlAgentLaunchCommandPayload( + _ command: AgentLaunchCommand + ) -> [String: Any] { + var payload: [String: Any] = ["arguments": command.arguments] + if let launcher = command.launcher { + payload["launcher"] = launcher + } + if let executablePath = command.executablePath { + payload["executable_path"] = executablePath + } + if let workingDirectory = command.workingDirectory { + payload["working_directory"] = workingDirectory + } + if let environment = command.environment { + payload["environment"] = environment + } + if let capturedAt = command.capturedAt { + payload["captured_at"] = capturedAt + } + if let source = command.source { + payload["source"] = source + } + return payload + } + + func runRestoreCommand( + commandArgs: [String], + client: SocketClient, + processEnvironment: [String: String] + ) throws { + let selector = try restoreSelector(commandArgs) + var params: [String: Any] = [:] + if let surface = selector.surface { + let surfaceID = try normalizeSurfaceHandle( + surface, + client: client, + workspaceHandle: nil, + windowHandle: nil + ) + guard let surfaceID else { + throw loggedRestoreError( + stage: "surface.lookup", + detail: surface, + message: String( + localized: "cli.restore.error.surfaceNotFound", + defaultValue: "restore: the requested surface was not found. Check the surface reference, then retry." + ) + ) + } + params["surface_id"] = surfaceID + } else if selector.usesCurrentSurface, + let surfaceID = processEnvironment["CMUX_SURFACE_ID"], + !surfaceID.isEmpty { + params["surface_id"] = surfaceID + } else if selector.usesCurrentSurface, + let ttyName = resolveCallerTTYName(), + let caller = resolveTerminalBinding( + ttyName: ttyName, + client: client + ) { + params["surface_id"] = caller.surfaceId + } else { + throw CLIError( + message: String( + localized: "cli.restore.error.currentSurfaceUnknown", + defaultValue: "restore: the current cmux surface could not be identified. Retry from this terminal or pass --surface ." + ) + ) + } + + let payload = try client.sendV2(method: "surface.resume.get", params: params) + guard let rawRecord = payload["restore_record"] as? [String: Any] else { + throw loggedRestoreError( + stage: "record.missing", + message: String( + localized: "cli.restore.error.noRecord", + defaultValue: "restore: this session has nothing to restore. Start the agent again in this terminal." + ) + ) + } + let record = try restoreRecord(from: rawRecord) + if let expectedKind = selector.kind, expectedKind != record.kind { + throw loggedRestoreError( + stage: "record.kind-mismatch", + detail: "expected=\(expectedKind) actual=\(record.kind)", + message: String( + localized: "cli.restore.error.kindMismatch", + defaultValue: "restore: this command no longer matches the session. Run 'cmux restore --surface' to use the current record." + ) + ) + } + if let expectedCheckpointID = selector.checkpointID, + expectedCheckpointID != record.checkpointID { + throw loggedRestoreError( + stage: "record.checkpoint-mismatch", + detail: "expected=\(expectedCheckpointID) actual=\(record.checkpointID ?? "none")", + message: String( + localized: "cli.restore.error.checkpointMismatch", + defaultValue: "restore: this command no longer matches the session. Run 'cmux restore --surface' to use the current record." + ) + ) + } + + let environment = processEnvironment.merging(record.environment) { _, restored in + restored + } + if record.launchCommand == nil, + record.preparedArguments == nil, + let legacyCommand = record.legacyCommand { + try execLegacyRestoreRecord( + legacyCommand, + record: record, + environment: environment, + client: client + ) + } + + guard let mode = AgentRestoreRequestMode(rawValue: record.mode) else { + throw loggedRestoreError( + stage: "record.mode", + detail: record.mode, + message: String( + localized: "cli.restore.error.unsupportedMode", + defaultValue: "restore: this session's saved restore data is not compatible. Start the agent again in this terminal." + ) + ) + } + let requestedWorkingDirectory = requestedRestoreWorkingDirectory(for: record) + let appliedWorkingDirectory = try applyRestoreWorkingDirectory( + requestedWorkingDirectory + ) + let effectiveWorkingDirectory: String? = + if requestedWorkingDirectory?.isEmpty == false { + appliedWorkingDirectory ?? FileManager.default.currentDirectoryPath + } else { + nil + } + let request = AgentRestoreRequest( + mode: mode, + kind: record.kind, + checkpointID: record.checkpointID, + source: record.source, + workingDirectory: effectiveWorkingDirectory, + environment: record.environment, + launchCommand: record.launchCommand, + preparedArguments: record.preparedArguments, + preparedArgumentsWorkingDirectory: normalizedRestoreWorkingDirectory( + record.preparedArgumentsWorkingDirectory + ), + observedPermissionMode: record.permissionMode + ) + guard let invocation = AgentRestorePlanner( + executableFileResolver: AgentRestoreExecutableFileResolver() + ).invocation( + for: request, + ambientEnvironment: processEnvironment + ) else { + if let legacyCommand = record.legacyCommand { + try execLegacyRestoreRecord( + legacyCommand, + record: record, + environment: environment, + client: client + ) + } + throw loggedRestoreError( + stage: "record.incomplete", + detail: "mode=\(record.mode) kind=\(record.kind)", + message: String( + localized: "cli.restore.error.incompleteData", + defaultValue: "restore: this session's saved restore data is not compatible. Start the agent again in this terminal." + ) + ) + } + + for preflight in invocation.preflightInvocations { + try runRestorePreflight( + preflight, + appliedWorkingDirectory: effectiveWorkingDirectory + ) + } + client.close() + try execRestoreInvocation( + invocation, + appliedWorkingDirectory: effectiveWorkingDirectory + ) + } + + private func restoreSelector(_ arguments: [String]) throws -> RestoreSelector { + if arguments.first == "--surface" { + if arguments.count == 1 { + return RestoreSelector( + surface: nil, + usesCurrentSurface: true, + kind: nil, + checkpointID: nil + ) + } + guard arguments.count == 2, !arguments[1].isEmpty else { + throw CLIError(message: String( + localized: "cli.restore.usage.surface", + defaultValue: "Usage: cmux restore --surface [id|ref]" + )) + } + return RestoreSelector( + surface: arguments[1], + usesCurrentSurface: false, + kind: nil, + checkpointID: nil + ) + } + guard arguments.count == 2, + !arguments[0].trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + !arguments[1].trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw CLIError(message: String( + localized: "cli.restore.usage.positional", + defaultValue: "Usage: cmux restore " + )) + } + return RestoreSelector( + surface: nil, + usesCurrentSurface: true, + kind: arguments[0], + checkpointID: arguments[1] + ) + } + + private func restoreRecord(from object: [String: Any]) throws -> RestoreRecord { + guard let mode = object["mode"] as? String, + let kind = object["kind"] as? String else { + throw loggedRestoreError( + stage: "record.decode", + detail: "keys=\(object.keys.sorted().joined(separator: ","))", + message: String( + localized: "cli.restore.error.malformedRecord", + defaultValue: "restore: this session's saved restore data is not compatible. Start the agent again in this terminal." + ) + ) + } + let legacyCommand = object["legacy_command"] as? String + let launchCommand: AgentLaunchCommand? + do { + launchCommand = try restoreLaunchCommand(from: object["launch_command"]) + } catch { + guard legacyCommand != nil else { + throw loggedRestoreError( + stage: "record.launch-command", + detail: String(reflecting: type(of: error)), + message: String( + localized: "cli.restore.error.malformedArguments", + defaultValue: "restore: this session's saved restore data is not compatible. Start the agent again in this terminal." + ) + ) + } + launchCommand = nil + } + return RestoreRecord( + mode: mode, + kind: kind, + checkpointID: object["checkpoint_id"] as? String, + source: object["source"] as? String, + workingDirectory: object["working_directory"] as? String, + environment: object["environment"] as? [String: String] ?? [:], + launchCommand: launchCommand, + preparedArguments: object["prepared_arguments"] as? [String], + preparedArgumentsWorkingDirectory: + object["prepared_arguments_working_directory"] as? String, + permissionMode: object["permission_mode"] as? String, + legacyCommand: legacyCommand + ) + } + + private func restoreLaunchCommand(from value: Any?) throws -> AgentLaunchCommand? { + guard let object = value as? [String: Any] else { return nil } + guard let arguments = object["arguments"] as? [String], !arguments.isEmpty else { + throw CLIError(message: String( + localized: "cli.restore.error.malformedArguments", + defaultValue: "restore: this session's saved restore data is not compatible. Start the agent again in this terminal." + )) + } + return AgentLaunchCommand( + launcher: object["launcher"] as? String, + executablePath: object["executable_path"] as? String, + arguments: arguments, + workingDirectory: object["working_directory"] as? String, + environment: object["environment"] as? [String: String], + capturedAt: (object["captured_at"] as? NSNumber)?.doubleValue, + source: object["source"] as? String + ) + } + +} diff --git a/CLI/CMUXCLI+RestoreExecution.swift b/CLI/CMUXCLI+RestoreExecution.swift new file mode 100644 index 000000000000..9527e3cf66e6 --- /dev/null +++ b/CLI/CMUXCLI+RestoreExecution.swift @@ -0,0 +1,202 @@ +import CMUXAgentLaunch +import Darwin +import Foundation + +extension CMUXCLI { + @discardableResult + func applyRestoreWorkingDirectory(_ path: String?) throws -> String? { + guard let path = path?.trimmingCharacters(in: .whitespacesAndNewlines), + !path.isEmpty else { + return nil + } + if chdir(path) == 0 { + return path + } + let changeDirectoryError = errno + // Preserve the old guarded `cd`: a directory removed since capture + // falls back to the shell's current directory, while an existing but + // inaccessible path still blocks restore. + if changeDirectoryError == ENOENT || changeDirectoryError == ENOTDIR { + return nil + } + throw loggedRestoreError( + stage: "working-directory.change", + detail: path, + errorCode: changeDirectoryError, + message: String( + localized: "cli.restore.error.workingDirectoryFailed", + defaultValue: "restore: the saved working directory is inaccessible. Restore access to it, then retry." + ) + ) + } + + func requestedRestoreWorkingDirectory(for record: RestoreRecord) -> String? { + normalizedRestoreWorkingDirectory(record.workingDirectory) + ?? normalizedRestoreWorkingDirectory(record.launchCommand?.workingDirectory) + } + + func normalizedRestoreWorkingDirectory(_ path: String?) -> String? { + let trimmed = path?.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed?.isEmpty == false ? trimmed : nil + } + + func execRestoreInvocation( + _ invocation: AgentRestoreInvocation, + appliedWorkingDirectory: String? + ) throws { + var invocationEnvironment = invocation.environment + if let appliedWorkingDirectory { + invocationEnvironment["PWD"] = appliedWorkingDirectory + } + guard let first = invocation.arguments.first, + let executable = resolveRestoreExecutable( + first, + environment: invocationEnvironment + ) else { + throw loggedRestoreError( + stage: "executable.resolve", + detail: invocation.arguments.first ?? "none", + message: String( + localized: "cli.restore.error.executableNotFound", + defaultValue: "restore: the saved agent command is unavailable. Make sure the agent is installed, then retry." + ) + ) + } + let executionError = withCStringArray(invocation.arguments) { argv in + withEnvironmentCStringArray(invocationEnvironment) { environment in + executable.withCString { + _ = execve($0, argv, environment) + return errno + } + } + } + throw loggedRestoreError( + stage: "executable.exec", + detail: executable, + errorCode: executionError, + message: String( + localized: "cli.restore.error.execveFailed", + defaultValue: "restore: the saved process could not be started. Retry the visible restore command." + ) + ) + } + + func execLegacyRestoreRecord( + _ command: String, + record: RestoreRecord, + environment: [String: String], + client: SocketClient + ) throws { + let appliedWorkingDirectory = try applyRestoreWorkingDirectory( + requestedRestoreWorkingDirectory(for: record) + ) + var legacyEnvironment = environment + if let appliedWorkingDirectory { + legacyEnvironment["PWD"] = appliedWorkingDirectory + } + client.close() + try execLegacyRestoreCommand(command, environment: legacyEnvironment) + } + + private func execLegacyRestoreCommand( + _ command: String, + environment: [String: String] + ) throws { + let shell = restoreCompatibilityShell(environment: environment) + let arguments = [shell, "-lc", command] + let executionError = withCStringArray(arguments) { argv in + withEnvironmentCStringArray(environment) { childEnvironment in + shell.withCString { + _ = execve($0, argv, childEnvironment) + return errno + } + } + } + throw loggedRestoreError( + stage: "legacy-shell.exec", + detail: shell, + errorCode: executionError, + message: String( + localized: "cli.restore.error.compatibilityShellFailed", + defaultValue: "restore: the saved process could not be started. Retry the visible restore command." + ) + ) + } + + private func restoreCompatibilityShell(environment: [String: String]) -> String { + if let shell = environment["SHELL"], + shell.hasPrefix("/"), + isExecutableRegularFile(atPath: shell) { + return shell + } + if let record = getpwuid(getuid()), + let shellPointer = record.pointee.pw_shell { + let shell = String(cString: shellPointer) + if isExecutableRegularFile(atPath: shell) { + return shell + } + } + return "/bin/sh" + } + + func resolveRestoreExecutable( + _ executable: String, + environment: [String: String] + ) -> String? { + if executable.contains("/") { + return isExecutableRegularFile(atPath: executable) + ? executable + : nil + } + let path = environment["PATH"] ?? "/usr/bin:/bin:/usr/sbin:/sbin" + // Shells treat an empty PATH component as the current directory. Restore + // may already be inside an untrusted project, so fail closed instead. + for directory in path.split(separator: ":") { + let root = String(directory) + let candidate = URL(fileURLWithPath: root, isDirectory: true) + .appendingPathComponent(executable, isDirectory: false) + .path + if isExecutableRegularFile(atPath: candidate) { + return candidate + } + } + return nil + } + + private func isExecutableRegularFile(atPath path: String) -> Bool { + var isDirectory: ObjCBool = false + guard FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory), + !isDirectory.boolValue else { + return false + } + return FileManager.default.isExecutableFile(atPath: path) + } + + func withCStringArray( + _ strings: [String], + body: (UnsafeMutablePointer?>?) -> Result + ) -> Result { + var pointers = strings.map { strdup($0) } + pointers.append(nil) + defer { + for pointer in pointers where pointer != nil { + free(pointer) + } + } + return pointers.withUnsafeMutableBufferPointer { + body($0.baseAddress) + } + } + + func withEnvironmentCStringArray( + _ environment: [String: String], + body: (UnsafeMutablePointer?>?) -> Result + ) -> Result { + withCStringArray( + environment.keys.sorted().compactMap { key in + environment[key].map { "\(key)=\($0)" } + }, + body: body + ) + } +} diff --git a/CLI/CMUXCLI+RestoreFailureReporting.swift b/CLI/CMUXCLI+RestoreFailureReporting.swift new file mode 100644 index 000000000000..536979dd3e4d --- /dev/null +++ b/CLI/CMUXCLI+RestoreFailureReporting.swift @@ -0,0 +1,23 @@ +import Foundation +import OSLog + +nonisolated private let restoreFailureLogger = Logger( + subsystem: "com.cmuxterm.cli", + category: "Restore" +) + +extension CMUXCLI { + /// Records private restore diagnostics while returning a product-level error. + func loggedRestoreError( + stage: String, + detail: String = "none", + errorCode: Int32? = nil, + message: String + ) -> CLIError { + let loggedErrorCode = errorCode.map { String($0) } ?? "none" + restoreFailureLogger.error( + "Restore failed stage=\(stage, privacy: .public) detail=\(detail, privacy: .private(mask: .hash)) errorCode=\(loggedErrorCode, privacy: .private(mask: .hash))" + ) + return CLIError(message: message) + } +} diff --git a/CLI/CMUXCLI+RestorePreflight.swift b/CLI/CMUXCLI+RestorePreflight.swift new file mode 100644 index 000000000000..b0cab0f95386 --- /dev/null +++ b/CLI/CMUXCLI+RestorePreflight.swift @@ -0,0 +1,278 @@ +import CMUXAgentLaunch +import Darwin +import Foundation + +extension CMUXCLI { + func runRestorePreflight( + _ invocation: AgentRestorePreflightInvocation, + appliedWorkingDirectory: String? + ) throws { + var invocationEnvironment = invocation.environment + if let appliedWorkingDirectory { + invocationEnvironment["PWD"] = appliedWorkingDirectory + } + guard let executable = resolveRestoreExecutable( + invocation.executable, + environment: invocationEnvironment + ) else { + throw loggedRestoreError( + stage: "provider.resolve", + detail: invocation.executable, + message: String( + localized: "cli.restore.error.providerSetupUnavailable", + defaultValue: "restore: provider setup is unavailable. Check the agent's provider settings, then retry." + ) + ) + } + var fileActions: posix_spawn_file_actions_t? + let actionsStatus = posix_spawn_file_actions_init(&fileActions) + guard actionsStatus == 0 else { + throw loggedRestoreError( + stage: "provider.file-actions", + errorCode: actionsStatus, + message: String( + localized: "cli.restore.error.providerSetupConfigurationFailed", + defaultValue: "restore: provider setup could not start. Check the agent's provider settings, then retry." + ) + ) + } + defer { posix_spawn_file_actions_destroy(&fileActions) } + + var redirectStatus = "/dev/null".withCString { + posix_spawn_file_actions_addopen( + &fileActions, + STDIN_FILENO, + $0, + O_RDONLY, + 0 + ) + } + if redirectStatus == 0 { + redirectStatus = "/dev/null".withCString { + posix_spawn_file_actions_addopen( + &fileActions, + STDOUT_FILENO, + $0, + O_WRONLY, + 0 + ) + } + } + if redirectStatus == 0 { + redirectStatus = "/dev/null".withCString { + posix_spawn_file_actions_addopen( + &fileActions, + STDERR_FILENO, + $0, + O_WRONLY, + 0 + ) + } + } + guard redirectStatus == 0 else { + throw loggedRestoreError( + stage: "provider.redirect", + errorCode: redirectStatus, + message: String( + localized: "cli.restore.error.providerSetupConfigurationFailed", + defaultValue: "restore: provider setup could not start. Check the agent's provider settings, then retry." + ) + ) + } + + var processID: pid_t = 0 + let status = withCStringArray(invocation.arguments) { argv in + withEnvironmentCStringArray(invocationEnvironment) { environment in + executable.withCString { + posix_spawn( + &processID, + $0, + &fileActions, + nil, + argv, + environment + ) + } + } + } + guard status == 0 else { + throw loggedRestoreError( + stage: "provider.spawn", + detail: executable, + errorCode: status, + message: String( + localized: "cli.restore.error.providerSetupStartFailed", + defaultValue: "restore: provider setup could not start. Check the agent's provider settings, then retry." + ) + ) + } + try waitForRestorePreflight(processID) + } + + private func waitForRestorePreflight(_ processID: pid_t) throws { + // This synchronous CLI is about to call `execve`; EVFILT_PROC provides + // signal-driven completion with a kernel-enforced deadline and no poll. + let exitQueue = try restorePreflightExitQueue(processID) + defer { close(exitQueue) } + + guard try waitForRestorePreflightExit( + exitQueue, + timeout: 10 + ) else { + terminateRestorePreflight(processID, exitQueue: exitQueue) + throw loggedRestoreError( + stage: "provider.timeout", + detail: "pid=\(processID)", + message: String( + localized: "cli.restore.error.providerSetupTimedOut", + defaultValue: "restore: provider setup took too long. Check the provider connection, then retry." + ) + ) + } + + let waitStatus = try reapRestorePreflight(processID) + let exitedNormally = waitStatus & 0x7f == 0 + let exitStatus = (waitStatus >> 8) & 0xff + if exitedNormally { + guard exitStatus == 0 else { + throw loggedRestoreError( + stage: "provider.exit", + errorCode: exitStatus, + message: String( + localized: "cli.restore.error.providerSetupExited", + defaultValue: "restore: provider setup failed. Check the agent's provider settings, then retry." + ) + ) + } + return + } + let terminationSignal = waitStatus & 0x7f + throw loggedRestoreError( + stage: "provider.signal", + errorCode: terminationSignal, + message: String( + localized: "cli.restore.error.providerSetupSignaled", + defaultValue: "restore: provider setup failed. Check the agent's provider settings, then retry." + ) + ) + } + + private func restorePreflightExitQueue(_ processID: pid_t) throws -> Int32 { + let queue = kqueue() + guard queue >= 0 else { + throw restorePreflightWaitError( + stage: "provider.wait-queue", + errorCode: errno + ) + } + + var event = kevent( + ident: UInt(processID), + filter: Int16(EVFILT_PROC), + flags: UInt16(EV_ADD | EV_ENABLE | EV_ONESHOT), + fflags: UInt32(NOTE_EXIT), + data: 0, + udata: nil + ) + while kevent(queue, &event, 1, nil, 0, nil) != 0 { + if errno == EINTR { + continue + } + close(queue) + throw restorePreflightWaitError( + stage: "provider.wait-register", + errorCode: errno + ) + } + return queue + } + + private func waitForRestorePreflightExit( + _ queue: Int32, + timeout: TimeInterval + ) throws -> Bool { + let deadline = ProcessInfo.processInfo.systemUptime + timeout + while true { + let remaining = deadline - ProcessInfo.processInfo.systemUptime + guard remaining > 0 else { return false } + var timeoutSpec = timespec( + tv_sec: Int(remaining), + tv_nsec: Int((remaining - floor(remaining)) * 1_000_000_000) + ) + var triggeredEvent = kevent() + let result = kevent(queue, nil, 0, &triggeredEvent, 1, &timeoutSpec) + if result > 0 { + return true + } + if result == 0 { + return false + } + if errno != EINTR { + throw restorePreflightWaitError( + stage: "provider.wait-event", + errorCode: errno + ) + } + } + } + + private func terminateRestorePreflight( + _ processID: pid_t, + exitQueue: Int32 + ) { + _ = kill(processID, SIGTERM) + var observedExit = (try? waitForRestorePreflightExit( + exitQueue, + timeout: 0.25 + )) == true + if !observedExit { + _ = kill(processID, SIGKILL) + observedExit = (try? waitForRestorePreflightExit( + exitQueue, + timeout: 1 + )) == true + } + if observedExit { + _ = try? reapRestorePreflight(processID) + } else { + _ = try? reapRestorePreflight(processID, options: WNOHANG) + } + } + + private func reapRestorePreflight( + _ processID: pid_t, + options: Int32 = 0 + ) throws -> Int32 { + var waitStatus: Int32 = 0 + while true { + let waitResult = waitpid(processID, &waitStatus, options) + if waitResult == processID { + return waitStatus + } + if waitResult == 0, options & WNOHANG != 0 { + return waitStatus + } + if waitResult == -1 && errno == EINTR { + continue + } + throw restorePreflightWaitError( + stage: "provider.wait-reap", + errorCode: errno + ) + } + } + + private func restorePreflightWaitError( + stage: String, + errorCode: Int32 + ) -> CLIError { + loggedRestoreError( + stage: stage, + errorCode: errorCode, + message: String( + localized: "cli.restore.error.providerSetupWaitFailed", + defaultValue: "restore: provider setup could not complete. Retry the visible restore command." + ) + ) + } +} diff --git a/CLI/CMUXCLI+RestoreRecord.swift b/CLI/CMUXCLI+RestoreRecord.swift new file mode 100644 index 000000000000..6da096cd5e40 --- /dev/null +++ b/CLI/CMUXCLI+RestoreRecord.swift @@ -0,0 +1,18 @@ +import CMUXAgentLaunch + +extension CMUXCLI { + /// The socket restore payload after validation and typed decoding. + struct RestoreRecord { + let mode: String + let kind: String + let checkpointID: String? + let source: String? + let workingDirectory: String? + let environment: [String: String] + let launchCommand: AgentLaunchCommand? + let preparedArguments: [String]? + let preparedArgumentsWorkingDirectory: String? + let permissionMode: String? + let legacyCommand: String? + } +} diff --git a/CLI/CMUXCLI+RestoreSelector.swift b/CLI/CMUXCLI+RestoreSelector.swift new file mode 100644 index 000000000000..6b1e93e12c33 --- /dev/null +++ b/CLI/CMUXCLI+RestoreSelector.swift @@ -0,0 +1,9 @@ +extension CMUXCLI { + /// The surface identity constraints parsed from `cmux restore` arguments. + struct RestoreSelector { + let surface: String? + let usesCurrentSurface: Bool + let kind: String? + let checkpointID: String? + } +} diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 33ef5ca163d6..b61e0a56ba2f 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -185,15 +185,7 @@ struct ClaudeHookActiveSessionRecord: Codable { var updatedAt: TimeInterval } -struct AgentHookLaunchCommandRecord: Codable { - var launcher: String? - var executablePath: String? - var arguments: [String] - var workingDirectory: String? - var environment: [String: String]? - var capturedAt: TimeInterval? - var source: String? -} +typealias AgentHookLaunchCommandRecord = AgentLaunchCommand private struct CodexMonitorLeaseRecord: Codable { var leaseId: String @@ -3357,6 +3349,9 @@ struct CMUXCLI { if normalizedCommand == "surface-resume" { return false } + if normalizedCommand == "restore" { + return false + } if normalizedCommand == "surface", commandArgs.first?.lowercased() == "resume" { return false } @@ -3793,6 +3788,16 @@ struct CMUXCLI { } catch { cliTelemetry.breadcrumb("socket.connect.failure", data: ["path": resolvedSocketPath]) cliTelemetry.captureError(stage: "socket_connect", error: error) + if command == "restore", explicitSocketPath == nil { + throw loggedRestoreError( + stage: "socket.startup", + detail: String(reflecting: error), + message: String( + localized: "cli.restore.error.socketNotReady", + defaultValue: "restore: cmux is still opening. Retry the visible restore command in a moment." + ) + ) + } throw error } defer { client.close() } @@ -4922,6 +4927,13 @@ struct CMUXCLI { windowOverride: windowId ) + case "restore": + try runRestoreCommand( + commandArgs: commandArgs, + client: client, + processEnvironment: processEnv + ) + case "surface-resume": try runSurfaceResumeCommand( commandArgs: commandArgs, @@ -6942,6 +6954,14 @@ struct CMUXCLI { throw CLIError(message: "surface resume set requires --shell or -- ") } commandText = argv.map(cliShellQuote).joined(separator: " ") + params["launch_command"] = controlAgentLaunchCommandPayload( + AgentLaunchCommand( + executablePath: argv[0], + arguments: argv, + workingDirectory: params["cwd"] as? String, + source: "cli" + ) + ) } guard !commandText.isEmpty else { throw CLIError(message: "surface resume set requires a non-empty command") @@ -15674,6 +15694,16 @@ struct CMUXCLI { If the app is already running, this restores the last saved session into the current app. If the app is not running, this launches cmux and lets startup restore reopen the saved session. """ + case "restore": + return String(localized: "cli.restore.help", defaultValue: """ + Usage: cmux restore + cmux restore --surface [id|ref] + + Replace this CLI process with the persisted surface process. New + records preserve argv, environment, and cwd as structured values; + command-only records from older builds use a compatibility shell. + With no id or ref, --surface uses the calling cmux surface. + """) case "sessions", "session-debug": return sessionsUsage() case "feedback": return """ @@ -26072,7 +26102,7 @@ struct CMUXCLI { return false } - private func resolveTerminalBinding(ttyName: String, client: SocketClient) -> CallerTerminalBinding? { + func resolveTerminalBinding(ttyName: String, client: SocketClient) -> CallerTerminalBinding? { guard let payload = try? client.sendV2(method: "debug.terminals") else { return nil } @@ -28294,6 +28324,12 @@ struct CMUXCLI { if let resumeEnvironment, !resumeEnvironment.isEmpty { params["environment"] = resumeEnvironment } + if let launchCommand { + params["launch_command"] = controlAgentLaunchCommandPayload(launchCommand) + } + if let observedPermissionMode { + params["permission_mode"] = observedPermissionMode + } _ = try? client.sendV2(method: "surface.resume.set", params: params) } @@ -35857,6 +35893,7 @@ export default CMUXSessionRestore; shortcuts disable-browser | enable-browser | browser-status agent-hibernation + restore | restore --surface [id|ref] restore-session open ... [--workspace ] [--surface ] [--pane ] [--window ] [--focus ] [--no-focus] diff [patch-file|-] [--source ] [--unstaged|--staged|--branch|--last-turn] [--workspace ] [--surface ] [--window ] [--cwd ] [--base ] [--focus ] [--no-focus] [--title ] [--layout ] [--font-size ] diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchCommand.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchCommand.swift new file mode 100644 index 000000000000..0d74662a607c --- /dev/null +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchCommand.swift @@ -0,0 +1,38 @@ +import Foundation + +/// A captured agent launch kept as structured values for later resume planning. +public struct AgentLaunchCommand: Codable, Equatable, Sendable { + /// The cmux launcher classification, when one was captured. + public var launcher: String? + /// The captured executable path. + public var executablePath: String? + /// The captured process arguments, including `argv[0]`. + public var arguments: [String] + /// The working directory at initial launch. + public var workingDirectory: String? + /// Replay-safe environment captured with the launch. + public var environment: [String: String]? + /// The capture timestamp. + public var capturedAt: TimeInterval? + /// The capture source. + public var source: String? + + /// Creates a structured captured launch. + public init( + launcher: String? = nil, + executablePath: String? = nil, + arguments: [String], + workingDirectory: String? = nil, + environment: [String: String]? = nil, + capturedAt: TimeInterval? = nil, + source: String? = nil + ) { + self.launcher = launcher + self.executablePath = executablePath + self.arguments = arguments + self.workingDirectory = workingDirectory + self.environment = environment + self.capturedAt = capturedAt + self.source = source + } +} diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchEnvironmentPolicy.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchEnvironmentPolicy.swift index c48ac446385a..fd9f9abbdfbc 100644 --- a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchEnvironmentPolicy.swift +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchEnvironmentPolicy.swift @@ -122,6 +122,9 @@ public struct AgentLaunchEnvironmentPolicy: Sendable { /// The optional `kind` applies agent-specific exclusions for values that are safe for one /// agent but managed or incorrect for another. public func selectedEnvironment(from env: [String: String], kind: String? = nil) -> [String: String] { + let normalizedKind = kind? + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() var result: [String: String] = [:] for key in Self.sortedSafeEnvironmentKeys where key != "NODE_OPTIONS" { guard let value = sanitizedValue(key: key, value: env[key]) else { continue } @@ -130,12 +133,12 @@ public struct AgentLaunchEnvironmentPolicy: Sendable { if let nodeOptions = selectedNodeOptions(from: env) { result["NODE_OPTIONS"] = nodeOptions } - if kind != "hermes-agent" { + if normalizedKind != "hermes-agent" { for key in Self.hermesAgentEnvironmentKeys { result.removeValue(forKey: key) } } - if kind == "campfire" { + if normalizedKind == "campfire" { for key in Self.campfireManagedEnvironmentKeys { result.removeValue(forKey: key) } @@ -143,6 +146,30 @@ public struct AgentLaunchEnvironmentPolicy: Sendable { return result } + /// Returns the captured environment that may cross the restore transport boundary. + /// + /// Pi-family agents also retain their captured `PATH` because Nix and other + /// custom installations rely on executable locations outside the login shell. + /// + /// - Parameters: + /// - env: The captured process environment. + /// - kind: The restored agent kind. + /// - Returns: The non-secret environment values safe to transport and replay. + public func selectedRestoreEnvironment( + from env: [String: String], + kind: String? + ) -> [String: String] { + let normalizedKind = kind? + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + var selected = selectedEnvironment(from: env, kind: kind) + if normalizedKind == "pi" || normalizedKind == "omp", + let path = normalizedValue(env["PATH"]) { + selected["PATH"] = path + } + return selected + } + /// Returns a replay-safe value for a single environment variable, or `nil` when it should drop. public func sanitizedValue(key: String, value: String?) -> String? { guard Self.safeEnvironmentKeys.contains(key) else { return nil } diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentRestoreCLIArgument.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentRestoreCLIArgument.swift new file mode 100644 index 000000000000..fa17ead453df --- /dev/null +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentRestoreCLIArgument.swift @@ -0,0 +1,24 @@ +import Foundation + +/// One validated argument that can be typed in a readable `cmux restore` command. +public struct AgentRestoreCLIArgument: RawRepresentable, Equatable, Sendable { + /// The validated shell-token-safe argument. + public let rawValue: String + + /// Validates a restore kind or checkpoint identifier for unquoted shell transport. + /// + /// Leading hyphens and characters requiring shell quoting are rejected so + /// startup input cannot be reinterpreted as options or multiple tokens. + /// + /// - Parameter rawValue: An already normalized binding kind or checkpoint identifier. + /// - Returns: `nil` when the value is empty or unsafe for unquoted shell transport. + public init?(rawValue: String) { + guard rawValue.range( + of: "^[A-Za-z0-9._:+][A-Za-z0-9._:+-]*$", + options: .regularExpression + ) != nil else { + return nil + } + self.rawValue = rawValue + } +} diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentRestoreExecutableFileResolver.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentRestoreExecutableFileResolver.swift new file mode 100644 index 000000000000..cb65a2a8f977 --- /dev/null +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentRestoreExecutableFileResolver.swift @@ -0,0 +1,34 @@ +import Darwin + +/// Resolves executable regular files for shell-free restore planning. +public struct AgentRestoreExecutableFileResolver: Sendable { + private let predicate: @Sendable (String) -> Bool + + /// Creates the live POSIX executable-file resolver. + public init() { + self.init(isExecutableFile: { path in + var metadata = stat() + let status = stat(path, &metadata) + guard status == 0, + metadata.st_mode & mode_t(S_IFMT) == mode_t(S_IFREG) else { + return false + } + return access(path, X_OK) == 0 + }) + } + + /// Creates a resolver with an injected executable-file predicate. + /// + /// - Parameter isExecutableFile: The deterministic filesystem lookup. + public init(isExecutableFile: @escaping @Sendable (String) -> Bool) { + predicate = isExecutableFile + } + + /// Returns whether the path resolves to an executable regular file. + /// + /// - Parameter path: The absolute or relative filesystem path to inspect. + /// - Returns: `true` only for an executable regular file. + public func isExecutableFile(atPath path: String) -> Bool { + predicate(path) + } +} diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentRestoreInvocation.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentRestoreInvocation.swift new file mode 100644 index 000000000000..2935c0d60f6f --- /dev/null +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentRestoreInvocation.swift @@ -0,0 +1,24 @@ +/// The fully planned, shell-free invocation used by `cmux restore`. +public struct AgentRestoreInvocation: Equatable, Sendable { + /// Process arguments, including `argv[0]`. + public let arguments: [String] + /// The working directory applied before process replacement. + public let workingDirectory: String? + /// The complete child environment. + public let environment: [String: String] + /// Typed subprocesses that must succeed before the final process replacement. + public let preflightInvocations: [AgentRestorePreflightInvocation] + + /// Creates a planned restore invocation. + public init( + arguments: [String], + workingDirectory: String?, + environment: [String: String], + preflightInvocations: [AgentRestorePreflightInvocation] = [] + ) { + self.arguments = arguments + self.workingDirectory = workingDirectory + self.environment = environment + self.preflightInvocations = preflightInvocations + } +} diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentRestoreLaunch.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentRestoreLaunch.swift index 01b8b467d6c0..d3ac196907f1 100644 --- a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentRestoreLaunch.swift +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentRestoreLaunch.swift @@ -12,6 +12,13 @@ import Foundation /// let startupInput = launch?.authorizing(leadingShell: "", routedCommand: resumeCommand) /// ``` public struct AgentRestoreLaunch: Sendable { + /// The readable restore executable typed into cmux-owned terminals. + /// + /// ``TerminalSurface`` prepends the owning app's bundled `Resources/bin` + /// directory to the terminal environment before the login shell starts, so + /// this resolves to the same build while keeping restored scrollback useful. + public static let cliStartupExecutableToken = "cmux" + private enum Provider: String, Sendable { case claude case codex @@ -57,6 +64,11 @@ public struct AgentRestoreLaunch: Sendable { } } + /// The provider- and session-bound authorization value passed to the wrapper. + public var authorizationEnvironmentValue: String { + "\(provider.rawValue):\(sessionID)" + } + /// Wraps a provider-specific wrapper command so every supported login shell can dispatch it. /// /// - Parameter posixCommand: The command containing ``wrapperShellExecutableToken``. @@ -80,7 +92,7 @@ public struct AgentRestoreLaunch: Sendable { /// - routedCommand: The command beginning at its executable after wrapper routing. /// - Returns: Startup input carrying provider- and session-bound authorization. public func authorizing(leadingShell: String, routedCommand: String) -> String { - let assignment = "CMUX_AGENT_RESTORE_LAUNCH=\(provider.rawValue):\(sessionID)" + let assignment = "CMUX_AGENT_RESTORE_LAUNCH=\(authorizationEnvironmentValue)" return leadingShell + "/usr/bin/env '\(assignment)' " + routedCommand } } diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentRestorePlanner.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentRestorePlanner.swift new file mode 100644 index 000000000000..2550ddb7cd32 --- /dev/null +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentRestorePlanner.swift @@ -0,0 +1,299 @@ +import Foundation + +/// Builds shell-free restore invocations from structured persisted records. +public struct AgentRestorePlanner: Sendable { + private static let claudeAuthSelectionEnvironmentKeys: Set = [ + "ANTHROPIC_API_KEY", + "ANTHROPIC_AUTH_TOKEN", + "ANTHROPIC_BASE_URL", + "ANTHROPIC_MODEL", + "ANTHROPIC_SMALL_FAST_MODEL", + "CLAUDE_CODE_USE_BEDROCK", + "CLAUDE_CODE_USE_VERTEX", + "CLAUDE_CONFIG_DIR", + ] + + private let isExecutableFile: @Sendable (String) -> Bool + + /// Creates a restore planner. + /// + /// - Parameter isExecutableFile: Executable-path lookup used for optional wrapper shims. + public init(isExecutableFile: @escaping @Sendable (String) -> Bool) { + self.isExecutableFile = isExecutableFile + } + + /// Creates a restore planner backed by an injected executable-file resolver. + /// + /// - Parameter executableFileResolver: The filesystem dependency used to resolve wrapper shims. + public init(executableFileResolver: AgentRestoreExecutableFileResolver) { + self.init(isExecutableFile: executableFileResolver.isExecutableFile(atPath:)) + } + + /// Produces the final direct process invocation for a persisted restore request. + /// + /// - Parameters: + /// - request: Structured restore data. + /// - ambientEnvironment: The current CLI environment inherited by the child. + /// - Returns: A direct invocation, or `nil` when the record cannot be restored safely. + public func invocation( + for request: AgentRestoreRequest, + ambientEnvironment: [String: String] + ) -> AgentRestoreInvocation? { + let kind = normalizedKind(request.kind) + guard let plannedArguments = plannedArguments(for: request, kind: kind), + !plannedArguments.values.isEmpty else { + return nil + } + + let workingDirectory = normalized( + request.workingDirectory ?? request.launchCommand?.workingDirectory + ) + let sanitizedArguments: [String] + if plannedArguments.removesCapturedWorkingDirectoryOptions { + let workingDirectories = [ + workingDirectory, + normalized(request.launchCommand?.workingDirectory), + ].compactMap { $0 } + sanitizedArguments = workingDirectories.reduce(plannedArguments.values) { + AgentLaunchSanitizer.removingSavedWorkingDirectoryOptions( + from: $0, + workingDirectory: $1 + ) + } + } else { + sanitizedArguments = retargetPreparedWorkingDirectory( + in: plannedArguments.values, + request: request, + workingDirectory: workingDirectory + ) + } + guard !sanitizedArguments.isEmpty else { return nil } + + var environment = ambientEnvironment + let restoredEnvironment = restoredEnvironment(for: request, kind: kind) + environment.merge(restoredEnvironment) { _, restored in restored } + + var routedArguments = sanitizedArguments + if request.mode != .direct { + routedArguments = routeManagedWrapper( + arguments: routedArguments, + request: request, + kind: kind, + environment: &environment + ) + } + guard !routedArguments.isEmpty else { return nil } + + let preflights = hermesPreflights( + arguments: &routedArguments, + kind: kind, + environment: environment, + ambientEnvironment: ambientEnvironment + ) + return AgentRestoreInvocation( + arguments: routedArguments, + workingDirectory: workingDirectory, + environment: environment, + preflightInvocations: preflights + ) + } + + private func plannedArguments( + for request: AgentRestoreRequest, + kind: String + ) -> (values: [String], removesCapturedWorkingDirectoryOptions: Bool)? { + let preparedArguments = request.preparedArguments.flatMap { + $0.isEmpty ? nil : $0 + } + switch request.mode { + case .direct: + return (preparedArguments ?? request.launchCommand?.arguments).map { + ($0, false) + } + case .relaunchAgent: + if let preparedArguments { + return (preparedArguments, false) + } + guard let launchCommand = request.launchCommand else { return nil } + return AgentResumeArgv().builtInRelaunchKind( + kind: kind, + executablePath: launchCommand.executablePath, + arguments: launchCommand.arguments + ).map { ($0, true) } + case .resumeAgent: + guard let checkpointID = normalized(request.checkpointID) else { return nil } + let launch = request.launchCommand + switch AgentResumeArgv().launcherResolution( + launcher: launch?.launcher, + sessionId: checkpointID, + executablePath: launch?.executablePath, + arguments: launch?.arguments ?? [] + ) { + case .resolved(let arguments): + if let arguments { + return (arguments, true) + } + return preparedArguments.map { ($0, false) } + case .passthrough: + if let arguments = AgentResumeArgv().builtInKind( + kind: kind, + sessionId: checkpointID, + executablePath: launch?.executablePath, + arguments: launch?.arguments ?? [], + observedPermissionMode: request.observedPermissionMode + ) { + return (arguments, true) + } + return preparedArguments.map { ($0, false) } + } + } + } + + private func restoredEnvironment( + for request: AgentRestoreRequest, + kind: String + ) -> [String: String] { + var captured = request.launchCommand?.environment ?? [:] + captured.merge(request.environment) { _, binding in binding } + if request.mode == .direct { + return captured + } + var selected = AgentLaunchEnvironmentPolicy().selectedRestoreEnvironment( + from: captured, + kind: kind + ) + if kind == "claude" { + let keys = selected.keys.sorted().filter { + Self.claudeAuthSelectionEnvironmentKeys.contains($0) + } + if !keys.isEmpty { + selected["CMUX_PRESERVE_CLAUDE_AUTH_SELECTION_ENV"] = "1" + selected["CMUX_PRESERVE_CLAUDE_AUTH_SELECTION_ENV_KEYS"] = keys.joined(separator: ",") + } + } + return selected + } + + private func retargetPreparedWorkingDirectory( + in arguments: [String], + request: AgentRestoreRequest, + workingDirectory: String? + ) -> [String] { + guard request.mode != .direct, + let capturedWorkingDirectory = normalized( + request.preparedArgumentsWorkingDirectory + ?? request.launchCommand?.workingDirectory + ), + let workingDirectory, + capturedWorkingDirectory != workingDirectory else { + return arguments + } + return arguments.map { argument in + if argument == capturedWorkingDirectory { + return workingDirectory + } + let assignmentSuffix = "=\(capturedWorkingDirectory)" + guard argument.hasSuffix(assignmentSuffix) else { + return argument + } + return String(argument.dropLast(assignmentSuffix.count)) + + "=\(workingDirectory)" + } + } + + private func routeManagedWrapper( + arguments: [String], + request: AgentRestoreRequest, + kind: String, + environment: inout [String: String] + ) -> [String] { + guard let first = arguments.first, + let restoreLaunch = AgentRestoreLaunch( + kind: kind, + sessionID: request.checkpointID + ), + (first as NSString).lastPathComponent == restoreLaunch.executableName else { + return arguments + } + + if first != restoreLaunch.executableName { + environment[restoreLaunch.customExecutablePathEnvironmentKey] = first + } + environment["CMUX_AGENT_RESTORE_LAUNCH"] = restoreLaunch.authorizationEnvironmentValue + let shimKey = kind == "claude" + ? "CMUX_CLAUDE_WRAPPER_SHIM" + : "CMUX_CODEX_WRAPPER_SHIM" + let routedExecutable = + normalized(environment[shimKey]) + .flatMap { isExecutableFile($0) ? $0 : nil } + ?? (first.contains("/") && isExecutableFile(first) ? first : nil) + ?? restoreLaunch.executableName + return [routedExecutable] + Array(arguments.dropFirst()) + } + + private func hermesPreflights( + arguments: inout [String], + kind: String, + environment: [String: String], + ambientEnvironment: [String: String] + ) -> [AgentRestorePreflightInvocation] { + guard kind == "hermes-agent" else { return [] } + arguments = HermesAgentCodexEnvironment.argumentsByReplacingOpenAICodexProvider(arguments) + guard !arguments.contains(where: { $0.contains("model.api_mode") }), + hermesProvider(in: arguments).map({ + $0 == HermesAgentCodexEnvironment.defaultProvider || $0 == "openai-codex" + }) ?? true else { + return [] + } + let resolvedEnvironment = HermesAgentCodexEnvironment.applyingDefaultCodexBaseURL( + to: environment, + ambientEnvironment: ambientEnvironment + ) + guard let baseURL = normalized( + resolvedEnvironment[HermesAgentCodexEnvironment.customBaseURLEnvironmentKey] + ), let executable = arguments.first else { + return [] + } + var settings = [ + ("model.provider", HermesAgentCodexEnvironment.defaultProvider), + ("model.base_url", baseURL), + ("model.api_mode", HermesAgentCodexEnvironment.codexResponsesAPIMode), + ] + if let model = HermesAgentCodexEnvironment.defaultCodexModel( + environment: resolvedEnvironment, + ambientEnvironment: ambientEnvironment + ) { + settings.append(("model.default", model)) + } + return settings.compactMap { key, value in + AgentRestorePreflightInvocation( + arguments: [executable, "config", "set", key, value], + environment: resolvedEnvironment + ) + } + } + + private func hermesProvider(in arguments: [String]) -> String? { + var index = arguments.startIndex + while index < arguments.endIndex { + let argument = arguments[index] + if argument == "--provider", arguments.indices.contains(index + 1) { + return arguments[index + 1] + } + if argument.hasPrefix("--provider=") { + return String(argument.dropFirst("--provider=".count)) + } + index += 1 + } + return nil + } + + private func normalized(_ value: String?) -> String? { + let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed?.isEmpty == false ? trimmed : nil + } + + private func normalizedKind(_ value: String) -> String { + value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + } +} diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentRestorePreflightInvocation.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentRestorePreflightInvocation.swift new file mode 100644 index 000000000000..33379ff9d8be --- /dev/null +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentRestorePreflightInvocation.swift @@ -0,0 +1,22 @@ +/// A shell-free subprocess invocation run before the restored process. +public struct AgentRestorePreflightInvocation: Equatable, Sendable { + /// The executable token from ``arguments``. + public let executable: String + /// Process arguments, including `argv[0]`. + public let arguments: [String] + /// Environment passed to the preflight process. + public let environment: [String: String] + + /// Creates a preflight invocation when `arguments` contains `argv[0]`. + /// + /// - Parameters: + /// - arguments: Process arguments beginning with the executable token. + /// - environment: The complete environment for the preflight process. + /// - Returns: `nil` when `arguments` is empty. + public init?(arguments: [String], environment: [String: String]) { + guard let executable = arguments.first else { return nil } + self.executable = executable + self.arguments = arguments + self.environment = environment + } +} diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentRestoreRequest.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentRestoreRequest.swift new file mode 100644 index 000000000000..07896307187a --- /dev/null +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentRestoreRequest.swift @@ -0,0 +1,48 @@ +/// Structured input for planning one restored process. +public struct AgentRestoreRequest: Equatable, Sendable { + /// The restore construction mode. + public let mode: AgentRestoreRequestMode + /// The agent or binding kind. + public let kind: String + /// The persisted checkpoint/session identifier. + public let checkpointID: String? + /// The binding source. + public let source: String? + /// The restore working directory. + public let workingDirectory: String? + /// Environment values persisted on the binding. + public let environment: [String: String] + /// The structured launch capture, when available. + public let launchCommand: AgentLaunchCommand? + /// A typed argv fallback for registry-owned/custom agents. + public let preparedArguments: [String]? + /// The working directory substituted into ``preparedArguments`` when they were built. + public let preparedArgumentsWorkingDirectory: String? + /// The last observed Claude permission mode. + public let observedPermissionMode: String? + + /// Creates a structured restore request. + public init( + mode: AgentRestoreRequestMode, + kind: String, + checkpointID: String?, + source: String?, + workingDirectory: String?, + environment: [String: String], + launchCommand: AgentLaunchCommand?, + preparedArguments: [String]?, + preparedArgumentsWorkingDirectory: String? = nil, + observedPermissionMode: String? + ) { + self.mode = mode + self.kind = kind + self.checkpointID = checkpointID + self.source = source + self.workingDirectory = workingDirectory + self.environment = environment + self.launchCommand = launchCommand + self.preparedArguments = preparedArguments + self.preparedArgumentsWorkingDirectory = preparedArgumentsWorkingDirectory + self.observedPermissionMode = observedPermissionMode + } +} diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentRestoreRequestMode.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentRestoreRequestMode.swift new file mode 100644 index 000000000000..72f944199ff1 --- /dev/null +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentRestoreRequestMode.swift @@ -0,0 +1,9 @@ +/// How a structured restore record constructs its final process invocation. +public enum AgentRestoreRequestMode: String, Codable, Sendable { + /// Rebuild an agent's resume argv from its captured launch. + case resumeAgent + /// Rebuild a relaunch-only agent invocation. + case relaunchAgent + /// Execute the recorded argv exactly. + case direct +} diff --git a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentLaunchEnvironmentPolicyTests.swift b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentLaunchEnvironmentPolicyTests.swift index 4087d6f5e52f..2d345bdfd667 100644 --- a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentLaunchEnvironmentPolicyTests.swift +++ b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentLaunchEnvironmentPolicyTests.swift @@ -20,6 +20,23 @@ struct AgentLaunchEnvironmentPolicyTests { ]) } + @Test("Restore transport keeps Pi PATH without crossing secrets") + func restoreTransportKeepsPiPathWithoutSecrets() { + let selected = AgentLaunchEnvironmentPolicy().selectedRestoreEnvironment( + from: [ + "PATH": "/nix/store/pi/bin:/usr/bin", + "PI_CONFIG_DIR": ".custom-pi", + "OPENAI_API_KEY": "secret-should-not-cross-socket", + ], + kind: "pi" + ) + + #expect(selected == [ + "PATH": "/nix/store/pi/bin:/usr/bin", + "PI_CONFIG_DIR": ".custom-pi", + ]) + } + @Test("Preserves Campfire config roots and drops Pi-managed env") func preservesCampfireConfigRootsAndDropsManagedPackageDir() { let selected = AgentLaunchEnvironmentPolicy().selectedEnvironment( diff --git a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentRestoreLaunchTests.swift b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentRestoreLaunchTests.swift index ca643a6ccc74..c2fd975f02df 100644 --- a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentRestoreLaunchTests.swift +++ b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentRestoreLaunchTests.swift @@ -1,3 +1,4 @@ +import Foundation import Testing @testable import CMUXAgentLaunch @@ -21,6 +22,20 @@ import Testing #expect(AgentRestoreLaunch(kind: "claude", sessionID: nil) == nil) } + @Test func preflightInvocationRequiresExecutableArgument() throws { + #expect( + AgentRestorePreflightInvocation( + arguments: [], + environment: [:] + ) == nil + ) + let invocation = try #require(AgentRestorePreflightInvocation( + arguments: ["/usr/bin/true", "config"], + environment: ["PATH": "/usr/bin:/bin"] + )) + #expect(invocation.executable == "/usr/bin/true") + } + @Test func authorizationUsesShellPortableEnvironmentTransport() throws { let launch = try #require(AgentRestoreLaunch(kind: "codex", sessionID: sessionID)) @@ -31,4 +46,343 @@ import Testing ) == "cd -- '/repo' && /usr/bin/env 'CMUX_AGENT_RESTORE_LAUNCH=codex:\(sessionID)' /bin/sh -c 'wrapper resume'" ) } + + @Test func startupTokenIsTheReadableManagedPATHCommand() { + #expect(AgentRestoreLaunch.cliStartupExecutableToken == "cmux") + } + + @Test func structuredCodexRestorePlansDirectArgvEnvironmentAndCwd() throws { + let workingDirectory = "/tmp/项目 with 'quotes'" + let capturedWorkingDirectory = "/tmp/old project" + let launch = AgentLaunchCommand( + launcher: nil, + executablePath: "/opt/company bin/codex", + arguments: [ + "/opt/company bin/codex", + "--model", + "gpt-5.6-sol", + "-c", + #"model_provider="subrouter""#, + "--cd", + capturedWorkingDirectory, + ], + workingDirectory: capturedWorkingDirectory, + environment: ["CODEX_HOME": "/tmp/配置"], + capturedAt: 1, + source: "test" + ) + let request = AgentRestoreRequest( + mode: .resumeAgent, + kind: "codex", + checkpointID: sessionID, + source: "agent-hook", + workingDirectory: workingDirectory, + environment: [:], + launchCommand: launch, + preparedArguments: nil, + observedPermissionMode: nil + ) + let planner = AgentRestorePlanner(isExecutableFile: { $0 == "/shim/codex" }) + let invocation = try #require(planner.invocation( + for: request, + ambientEnvironment: [ + "PATH": "/usr/bin:/bin", + "CMUX_CODEX_WRAPPER_SHIM": "/shim/codex", + ] + )) + + #expect(invocation.workingDirectory == workingDirectory) + #expect(invocation.arguments.first == "/shim/codex") + #expect(invocation.arguments.dropFirst().starts(with: ["resume", sessionID])) + #expect(invocation.arguments.contains("check_for_update_on_startup=false")) + #expect(invocation.arguments.contains(#"model_provider="subrouter""#)) + #expect(invocation.arguments.contains(capturedWorkingDirectory) == false) + #expect(invocation.environment["CODEX_HOME"] == "/tmp/配置") + #expect(invocation.environment["CMUX_CUSTOM_CODEX_PATH"] == "/opt/company bin/codex") + #expect( + invocation.environment["CMUX_AGENT_RESTORE_LAUNCH"] + == "codex:\(sessionID)" + ) + #expect(invocation.arguments.contains("/bin/sh") == false) + #expect(invocation.arguments.contains("-lc") == false) + } + + @Test func structuredClaudeRestoreAppliesObservedPermissionModeWithoutParsingShell() throws { + let request = AgentRestoreRequest( + mode: .resumeAgent, + kind: " CLAUDE ", + checkpointID: sessionID, + source: "agent-hook", + workingDirectory: "/tmp/work", + environment: [:], + launchCommand: AgentLaunchCommand( + launcher: nil, + executablePath: "/opt/claude", + arguments: ["/opt/claude", "--model", "sonnet"], + workingDirectory: "/tmp/work", + environment: nil, + capturedAt: nil, + source: nil + ), + preparedArguments: nil, + observedPermissionMode: "bypassPermissions" + ) + let invocation = try #require(AgentRestorePlanner( + isExecutableFile: { $0 == "/shim/claude" } + ).invocation( + for: request, + ambientEnvironment: ["CMUX_CLAUDE_WRAPPER_SHIM": "/shim/claude"] + )) + + #expect(invocation.arguments.first == "/shim/claude") + #expect(invocation.arguments.contains("--resume")) + #expect(invocation.arguments.contains(sessionID)) + #expect(invocation.arguments.contains("--permission-mode")) + #expect(invocation.arguments.contains("bypassPermissions")) + #expect( + invocation.environment["CMUX_AGENT_RESTORE_LAUNCH"] + == "claude:\(sessionID)" + ) + } + + @Test func managedRestoreUsesCapturedExecutableWhenWrapperShimIsUnavailable() throws { + let executable = "/opt/custom tools/codex" + let request = AgentRestoreRequest( + mode: .resumeAgent, + kind: "codex", + checkpointID: sessionID, + source: "agent-hook", + workingDirectory: "/tmp/work", + environment: [:], + launchCommand: AgentLaunchCommand( + executablePath: executable, + arguments: [executable, "--model", "gpt-5.6-sol"] + ), + preparedArguments: nil, + observedPermissionMode: nil + ) + let invocation = try #require(AgentRestorePlanner( + isExecutableFile: { $0 == executable } + ).invocation( + for: request, + ambientEnvironment: ["PATH": "/usr/bin:/bin"] + )) + + #expect(invocation.arguments.first == executable) + #expect(invocation.arguments.dropFirst().starts(with: ["resume", sessionID])) + #expect(invocation.environment["CMUX_CUSTOM_CODEX_PATH"] == executable) + } + + @Test func directBindingPreservesStructuredArgumentsBeyondFormerInlineBudget() throws { + let hazards = [ + "space value", + "quote'\"", + "日本語", + "--cwd", + "/tmp/空 白", + String(repeating: "x", count: 4_000), + ] + let request = AgentRestoreRequest( + mode: .direct, + kind: "custom", + checkpointID: nil, + source: "cli", + workingDirectory: "/tmp/空 白", + environment: [ + "K": "値 with spaces", + "OVERRIDE": "binding", + ], + launchCommand: AgentLaunchCommand( + arguments: ["/usr/bin/printf"] + hazards, + environment: [ + "CAPTURED": "launch", + "OVERRIDE": "captured", + ] + ), + preparedArguments: ["/usr/bin/printf"] + hazards, + observedPermissionMode: nil + ) + let invocation = try #require(AgentRestorePlanner( + executableFileResolver: AgentRestoreExecutableFileResolver() + ).invocation( + for: request, + ambientEnvironment: ["PATH": "/usr/bin:/bin"] + )) + + #expect(invocation.arguments == ["/usr/bin/printf"] + hazards) + #expect(invocation.environment["K"] == "値 with spaces") + #expect(invocation.environment["CAPTURED"] == "launch") + #expect(invocation.environment["OVERRIDE"] == "binding") + #expect(invocation.workingDirectory == "/tmp/空 白") + } + + @Test func preparedRestoreArgumentsRetargetTheirPersistedWorkingDirectory() throws { + let persistedWorkingDirectory = "/tmp/deleted 项目" + let effectiveWorkingDirectory = "/tmp/fallback project" + let request = AgentRestoreRequest( + mode: .resumeAgent, + kind: "cwd-agent", + checkpointID: "session-123", + source: "session-snapshot", + workingDirectory: effectiveWorkingDirectory, + environment: [:], + launchCommand: AgentLaunchCommand( + executablePath: "/opt/cwd-agent", + arguments: ["/opt/cwd-agent"], + workingDirectory: "/tmp/older captured cwd" + ), + preparedArguments: [ + "/opt/cwd-agent", + "--cwd", + persistedWorkingDirectory, + "--workspace=\(persistedWorkingDirectory)", + "--session", + "session-123", + ], + preparedArgumentsWorkingDirectory: persistedWorkingDirectory, + observedPermissionMode: nil + ) + let invocation = try #require(AgentRestorePlanner( + executableFileResolver: AgentRestoreExecutableFileResolver() + ).invocation( + for: request, + ambientEnvironment: ["PATH": "/usr/bin:/bin"] + )) + + #expect(invocation.workingDirectory == effectiveWorkingDirectory) + #expect(invocation.arguments.contains(effectiveWorkingDirectory)) + #expect(invocation.arguments.contains("--workspace=\(effectiveWorkingDirectory)")) + #expect(invocation.arguments.contains(where: { $0.contains(persistedWorkingDirectory) }) == false) + } + + @Test func structuredHermesRestoreUsesTypedPreflightsAndDirectArgv() throws { + let executable = "/opt/Hermes Tools/hermes" + let request = AgentRestoreRequest( + mode: .resumeAgent, + kind: "hermes-agent", + checkpointID: "hermes-session-123", + source: "agent-hook", + workingDirectory: "/tmp/Hermes 项目", + environment: [:], + launchCommand: AgentLaunchCommand( + launcher: "hermes-agent", + executablePath: executable, + arguments: [ + executable, + "--provider", + "openai-codex", + "--model", + "gpt-5.5", + ], + workingDirectory: "/tmp/Hermes 项目", + environment: [ + HermesAgentCodexEnvironment.customBaseURLEnvironmentKey: + "http://subrouter-team:31415/v1", + ] + ), + preparedArguments: nil, + observedPermissionMode: nil + ) + let invocation = try #require(AgentRestorePlanner( + executableFileResolver: AgentRestoreExecutableFileResolver() + ).invocation( + for: request, + ambientEnvironment: ["PATH": "/usr/bin:/bin"] + )) + + #expect(invocation.arguments.first == executable) + #expect(invocation.arguments.contains("--resume")) + #expect(invocation.arguments.contains("hermes-session-123")) + #expect(invocation.arguments.contains("openai-codex") == false) + #expect(invocation.arguments.contains(HermesAgentCodexEnvironment.defaultProvider)) + #expect(invocation.preflightInvocations.count >= 3) + #expect(invocation.preflightInvocations.allSatisfy { + $0.arguments.first == executable && + Array($0.arguments.dropFirst().prefix(2)) == ["config", "set"] + }) + #expect(invocation.preflightInvocations.flatMap(\.arguments).contains("model.provider")) + #expect(invocation.preflightInvocations.flatMap(\.arguments).contains("model.base_url")) + #expect(invocation.preflightInvocations.flatMap(\.arguments).contains("model.api_mode")) + #expect(invocation.preflightInvocations.flatMap(\.arguments).contains("/bin/sh") == false) + } + + @Test func structuredHermesRestoreUsesDefaultCodexBaseURLForPreflights() throws { + let codexHome = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-hermes-codex-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: codexHome, withIntermediateDirectories: true) + try """ + openai_base_url = "http://default-subrouter:31415/v1" + model = "gpt-5.6-sol" + """.write( + to: codexHome.appendingPathComponent("config.toml", isDirectory: false), + atomically: true, + encoding: .utf8 + ) + defer { try? FileManager.default.removeItem(at: codexHome) } + + let request = AgentRestoreRequest( + mode: .resumeAgent, + kind: " HERMES-Agent ", + checkpointID: "hermes-default-session", + source: "agent-hook", + workingDirectory: "/tmp/hermes", + environment: [:], + launchCommand: AgentLaunchCommand( + arguments: ["hermes", "--provider", "openai-codex"] + ), + preparedArguments: nil, + observedPermissionMode: nil + ) + let invocation = try #require(AgentRestorePlanner( + executableFileResolver: AgentRestoreExecutableFileResolver() + ).invocation( + for: request, + ambientEnvironment: [ + "CODEX_HOME": codexHome.path, + "PATH": "/usr/bin:/bin", + ] + )) + let baseURLPreflight = try #require( + invocation.preflightInvocations.first { + $0.arguments.dropFirst(3).first == "model.base_url" + } + ) + + #expect(baseURLPreflight.arguments.last == "http://default-subrouter:31415/v1") + #expect( + baseURLPreflight.environment[ + HermesAgentCodexEnvironment.customBaseURLEnvironmentKey + ] == "http://default-subrouter:31415/v1" + ) + #expect(baseURLPreflight.environment["CODEX_HOME"] == codexHome.path) + } + + @Test func structuredHermesExplicitProviderSkipsCodexPreflights() throws { + let request = AgentRestoreRequest( + mode: .resumeAgent, + kind: "hermes-agent", + checkpointID: "hermes-session-123", + source: "agent-hook", + workingDirectory: "/tmp/hermes", + environment: [:], + launchCommand: AgentLaunchCommand( + arguments: ["hermes", "--provider", "anthropic"], + environment: [ + HermesAgentCodexEnvironment.customBaseURLEnvironmentKey: + "http://subrouter-team:31415/v1", + ] + ), + preparedArguments: nil, + observedPermissionMode: nil + ) + let invocation = try #require(AgentRestorePlanner( + executableFileResolver: AgentRestoreExecutableFileResolver() + ).invocation( + for: request, + ambientEnvironment: ["PATH": "/usr/bin:/bin"] + )) + + #expect(invocation.arguments.contains("anthropic")) + #expect(invocation.preflightInvocations.isEmpty) + } } diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlAgentLaunchCommand.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlAgentLaunchCommand.swift new file mode 100644 index 000000000000..5face62bd3ab --- /dev/null +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlAgentLaunchCommand.swift @@ -0,0 +1,45 @@ +/// A structured launch capture transported by the surface-resume socket API. +public struct ControlAgentLaunchCommand: Sendable, Equatable { + /// The registry-owned launcher identifier, when one created the command. + public let launcher: String? + /// The captured absolute executable path, when available. + public let executablePath: String? + /// Process arguments including `argv[0]`. + public let arguments: [String] + /// The working directory captured with the launch. + public let workingDirectory: String? + /// Replay-safe environment values captured with the launch. + public let environment: [String: String]? + /// The Unix timestamp at which the launch was captured. + public let capturedAt: Double? + /// The subsystem that captured the launch. + public let source: String? + + /// Creates a structured launch capture for socket transport. + /// + /// - Parameters: + /// - launcher: The registry-owned launcher identifier. + /// - executablePath: The captured absolute executable path. + /// - arguments: Process arguments including `argv[0]`. + /// - workingDirectory: The captured working directory. + /// - environment: Replay-safe captured environment values. + /// - capturedAt: The capture time as a Unix timestamp. + /// - source: The subsystem that captured the launch. + public init( + launcher: String?, + executablePath: String?, + arguments: [String], + workingDirectory: String?, + environment: [String: String]?, + capturedAt: Double?, + source: String? + ) { + self.launcher = launcher + self.executablePath = executablePath + self.arguments = arguments + self.workingDirectory = workingDirectory + self.environment = environment + self.capturedAt = capturedAt + self.source = source + } +} diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlCommandCoordinator+Surface3.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlCommandCoordinator+Surface3.swift index 8bbfed6f360b..6beda80c9574 100644 --- a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlCommandCoordinator+Surface3.swift +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlCommandCoordinator+Surface3.swift @@ -57,6 +57,20 @@ extension ControlCommandCoordinator { data: nil ) } + let launchCommand: ControlAgentLaunchCommand? + switch params["launch_command"] { + case nil, .null: + launchCommand = nil + case let value?: + guard let parsed = controlAgentLaunchCommand(value) else { + return .err( + code: "invalid_params", + message: surfaceResumeStrings().launchCommandMustBeValid, + data: nil + ) + } + launchCommand = parsed + } let inputs = ControlSurfaceResumeSetInputs( name: optionalTrimmedRawString(params, "name"), kind: optionalTrimmedRawString(params, "kind"), @@ -66,6 +80,8 @@ extension ControlCommandCoordinator { ?? optionalTrimmedRawString(params, "checkpointId"), source: source, environment: stringMap(params, "environment"), + launchCommand: launchCommand, + permissionMode: optionalTrimmedRawString(params, "permission_mode"), autoResume: source == "agent-hook" ? (bool(params, "auto_resume") ?? false) : false, remoteWorkspaceID: remoteWorkspaceID, remoteRelayParameters: remoteWorkspaceID == nil ? nil : params @@ -141,7 +157,8 @@ extension ControlCommandCoordinator { /// The localized surface-resume strings supplied by the app bundle. private func surfaceResumeStrings() -> ControlSurfaceResumeStrings { context?.controlSurfaceResumeStrings() ?? ControlSurfaceResumeStrings( - agentSessionEndedMustBeBoolean: "" + agentSessionEndedMustBeBoolean: "", + launchCommandMustBeValid: "" ) } @@ -176,6 +193,7 @@ extension ControlCommandCoordinator { "surface_ref": ref(.surface, snapshot.surfaceID), "cleared": .bool(snapshot.cleared), "resume_binding": surfaceResumeBindingPayload(snapshot.binding), + "restore_record": surfaceRestoreRecordPayload(snapshot.restoreRecord), ])) } } @@ -197,6 +215,8 @@ extension ControlCommandCoordinator { "checkpoint_id": orNull(binding.checkpointID), "source": orNull(binding.source), "environment": environment, + "launch_command": controlAgentLaunchCommandPayload(binding.launchCommand), + "permission_mode": orNull(binding.permissionMode), "auto_resume": .bool(binding.autoResume), "approval_policy": orNull(binding.approvalPolicyRawValue), "approval_record_id": orNull(binding.approvalRecordID), @@ -208,6 +228,103 @@ extension ControlCommandCoordinator { ]) } + private func controlAgentLaunchCommand(_ value: JSONValue?) -> ControlAgentLaunchCommand? { + guard case .object(let object)? = value, + case .array(let rawArguments)? = object["arguments"] else { + return nil + } + for key in ["launcher", "executable_path", "working_directory", "source"] { + switch object[key] { + case nil, .null, .string: + break + default: + return nil + } + } + switch object["environment"] { + case nil, .null: + break + case .object(let environment): + guard environment.values.allSatisfy({ + if case .string = $0 { return true } + return false + }) else { + return nil + } + default: + return nil + } + switch object["captured_at"] { + case nil, .null, .double, .int: + break + default: + return nil + } + let arguments = rawArguments.compactMap { value -> String? in + guard case .string(let argument) = value else { return nil } + return argument + } + guard arguments.count == rawArguments.count, !arguments.isEmpty else { return nil } + return ControlAgentLaunchCommand( + launcher: rawString(object, "launcher"), + executablePath: rawString(object, "executable_path"), + arguments: arguments, + workingDirectory: rawString(object, "working_directory"), + environment: stringMap(object, "environment"), + capturedAt: doubleValue(object["captured_at"]), + source: rawString(object, "source") + ) + } + + private nonisolated func controlAgentLaunchCommandPayload( + _ command: ControlAgentLaunchCommand? + ) -> JSONValue { + guard let command else { return .null } + let environment = command.environment.map { + JSONValue.object($0.mapValues(JSONValue.string)) + } ?? .null + return .object([ + "launcher": orNull(command.launcher), + "executable_path": orNull(command.executablePath), + "arguments": .array(command.arguments.map(JSONValue.string)), + "working_directory": orNull(command.workingDirectory), + "environment": environment, + "captured_at": command.capturedAt.map(JSONValue.double) ?? .null, + "source": orNull(command.source), + ]) + } + + private nonisolated func surfaceRestoreRecordPayload( + _ record: ControlSurfaceRestoreRecord? + ) -> JSONValue { + guard let record else { return .null } + return .object([ + "mode": .string(record.modeRawValue), + "kind": .string(record.kind), + "checkpoint_id": orNull(record.checkpointID), + "source": orNull(record.source), + "working_directory": orNull(record.workingDirectory), + "environment": .object(record.environment.mapValues(JSONValue.string)), + "launch_command": controlAgentLaunchCommandPayload(record.launchCommand), + "prepared_arguments": record.preparedArguments.map { + .array($0.map(JSONValue.string)) + } ?? .null, + "prepared_arguments_working_directory": orNull( + record.preparedArgumentsWorkingDirectory + ), + "permission_mode": orNull(record.permissionMode), + "legacy_command": orNull(record.legacyCommand), + ]) + } + + private func doubleValue(_ value: JSONValue?) -> Double? { + switch value { + case .double(let value): value + case .int(let value): Double(value) + default: nil + } + } + // MARK: - report_tty /// `surface.report_tty` — record a reported TTY name. diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlSurfaceRestoreRecord.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlSurfaceRestoreRecord.swift new file mode 100644 index 000000000000..dc8a313e9e88 --- /dev/null +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlSurfaceRestoreRecord.swift @@ -0,0 +1,69 @@ +/// Structured data consumed by `cmux restore`. +/// +/// `legacyCommand` is populated only for command-only records written by older +/// builds. New records keep argv and environment structured through process +/// replacement. +public struct ControlSurfaceRestoreRecord: Sendable, Equatable { + /// The raw `AgentRestoreRequestMode` value consumed by the CLI package. + public let modeRawValue: String + /// The persisted agent or binding kind. + public let kind: String + /// The persisted session or checkpoint identifier. + public let checkpointID: String? + /// The subsystem that created the binding. + public let source: String? + /// The directory the restored process should enter before execution. + public let workingDirectory: String? + /// Replay-safe environment values persisted on the binding. + public let environment: [String: String] + /// The structured launch capture, when available. + public let launchCommand: ControlAgentLaunchCommand? + /// Registry-built process arguments used when no captured launch can rebuild them. + public let preparedArguments: [String]? + /// The cwd against which `preparedArguments` was captured and may be retargeted. + public let preparedArgumentsWorkingDirectory: String? + /// The last observed provider permission mode. + public let permissionMode: String? + /// Compatibility shell input retained for records persisted by older builds. + public let legacyCommand: String? + + /// Creates the structured restore record transported to `cmux restore`. + /// + /// - Parameters: + /// - modeRawValue: The raw restore construction mode. + /// - kind: The persisted agent or binding kind. + /// - checkpointID: The persisted session or checkpoint identifier. + /// - source: The subsystem that created the binding. + /// - workingDirectory: The target restore working directory. + /// - environment: Replay-safe persisted environment values. + /// - launchCommand: The structured launch capture. + /// - preparedArguments: Registry-built fallback process arguments. + /// - preparedArgumentsWorkingDirectory: The cwd embedded in prepared arguments. + /// - permissionMode: The last observed provider permission mode. + /// - legacyCommand: Compatibility input for records written by older builds. + public init( + modeRawValue: String, + kind: String, + checkpointID: String?, + source: String?, + workingDirectory: String?, + environment: [String: String], + launchCommand: ControlAgentLaunchCommand?, + preparedArguments: [String]?, + preparedArgumentsWorkingDirectory: String?, + permissionMode: String?, + legacyCommand: String? + ) { + self.modeRawValue = modeRawValue + self.kind = kind + self.checkpointID = checkpointID + self.source = source + self.workingDirectory = workingDirectory + self.environment = environment + self.launchCommand = launchCommand + self.preparedArguments = preparedArguments + self.preparedArgumentsWorkingDirectory = preparedArgumentsWorkingDirectory + self.permissionMode = permissionMode + self.legacyCommand = legacyCommand + } +} diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlSurfaceResumeBinding.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlSurfaceResumeBinding.swift index 59470e7d564f..a8e7b5f02c75 100644 --- a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlSurfaceResumeBinding.swift +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlSurfaceResumeBinding.swift @@ -24,6 +24,10 @@ public struct ControlSurfaceResumeBinding: Sendable, Equatable { /// The environment overrides, if any (the legacy payload wrote the whole map /// or `null`). public let environment: [String: String]? + /// Structured launch data persisted additively with the legacy command. + public let launchCommand: ControlAgentLaunchCommand? + /// Last provider permission mode captured by an agent hook. + public let permissionMode: String? /// Whether the binding allows automatic resume /// (`effectiveBinding.allowsAutomaticResume`). public let autoResume: Bool @@ -68,6 +72,8 @@ public struct ControlSurfaceResumeBinding: Sendable, Equatable { checkpointID: String?, source: String?, environment: [String: String]?, + launchCommand: ControlAgentLaunchCommand?, + permissionMode: String?, autoResume: Bool, approvalPolicyRawValue: String?, approvalRecordID: String?, @@ -84,6 +90,8 @@ public struct ControlSurfaceResumeBinding: Sendable, Equatable { self.checkpointID = checkpointID self.source = source self.environment = environment + self.launchCommand = launchCommand + self.permissionMode = permissionMode self.autoResume = autoResume self.approvalPolicyRawValue = approvalPolicyRawValue self.approvalRecordID = approvalRecordID diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlSurfaceResumeSetInputs.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlSurfaceResumeSetInputs.swift index 864e130b4851..a22c42b71bff 100644 --- a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlSurfaceResumeSetInputs.swift +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlSurfaceResumeSetInputs.swift @@ -22,6 +22,10 @@ public struct ControlSurfaceResumeSetInputs: Sendable, Equatable { public let source: String? /// The environment overrides (the legacy `v2StringMap`, or `nil`). public let environment: [String: String]? + /// Structured launch data supplied alongside the compatibility command. + public let launchCommand: ControlAgentLaunchCommand? + /// Last provider permission mode captured by an agent hook. + public let permissionMode: String? /// Whether automatic resume is requested (already gated: `true` only for the /// `agent-hook` source with `auto_resume == true`). public let autoResume: Bool @@ -51,6 +55,8 @@ public struct ControlSurfaceResumeSetInputs: Sendable, Equatable { checkpointID: String?, source: String?, environment: [String: String]?, + launchCommand: ControlAgentLaunchCommand?, + permissionMode: String?, autoResume: Bool, remoteWorkspaceID: UUID?, remoteRelayParameters: [String: JSONValue]? @@ -62,6 +68,8 @@ public struct ControlSurfaceResumeSetInputs: Sendable, Equatable { self.checkpointID = checkpointID self.source = source self.environment = environment + self.launchCommand = launchCommand + self.permissionMode = permissionMode self.autoResume = autoResume self.remoteWorkspaceID = remoteWorkspaceID self.remoteRelayParameters = remoteRelayParameters diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlSurfaceResumeSnapshot.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlSurfaceResumeSnapshot.swift index c14a1bb03a2b..eb2ff669fe4d 100644 --- a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlSurfaceResumeSnapshot.swift +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlSurfaceResumeSnapshot.swift @@ -20,6 +20,8 @@ public struct ControlSurfaceResumeSnapshot: Sendable, Equatable { public let cleared: Bool /// The resulting resume binding, or `nil`. public let binding: ControlSurfaceResumeBinding? + /// Structured process data used by `cmux restore`. + public let restoreRecord: ControlSurfaceRestoreRecord? /// Creates a resume snapshot. /// @@ -36,7 +38,8 @@ public struct ControlSurfaceResumeSnapshot: Sendable, Equatable { paneID: UUID?, surfaceID: UUID, cleared: Bool, - binding: ControlSurfaceResumeBinding? + binding: ControlSurfaceResumeBinding?, + restoreRecord: ControlSurfaceRestoreRecord? ) { self.windowID = windowID self.workspaceID = workspaceID @@ -44,5 +47,6 @@ public struct ControlSurfaceResumeSnapshot: Sendable, Equatable { self.surfaceID = surfaceID self.cleared = cleared self.binding = binding + self.restoreRecord = restoreRecord } } diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlSurfaceResumeStrings.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlSurfaceResumeStrings.swift index a40786757485..9b31ea8539ab 100644 --- a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlSurfaceResumeStrings.swift +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlSurfaceResumeStrings.swift @@ -5,12 +5,20 @@ public struct ControlSurfaceResumeStrings: Sendable, Equatable { /// The message returned when `agent_session_ended` is not a JSON boolean. public let agentSessionEndedMustBeBoolean: String + /// The message returned when `launch_command` is present but malformed. + public let launchCommandMustBeValid: String /// Creates the localized surface-resume message bundle. /// - /// - Parameter agentSessionEndedMustBeBoolean: The malformed - /// `agent_session_ended` message. - public init(agentSessionEndedMustBeBoolean: String) { + /// - Parameters: + /// - agentSessionEndedMustBeBoolean: The malformed + /// `agent_session_ended` message. + /// - launchCommandMustBeValid: The malformed `launch_command` message. + public init( + agentSessionEndedMustBeBoolean: String, + launchCommandMustBeValid: String + ) { self.agentSessionEndedMustBeBoolean = agentSessionEndedMustBeBoolean + self.launchCommandMustBeValid = launchCommandMustBeValid } } diff --git a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandContextTestStubs.swift b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandContextTestStubs.swift index cac4ddc05b1c..3f2b980db477 100644 --- a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandContextTestStubs.swift +++ b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandContextTestStubs.swift @@ -470,7 +470,10 @@ extension ControlSurfaceContext { } func controlSurfaceResumeStrings() -> ControlSurfaceResumeStrings { - ControlSurfaceResumeStrings(agentSessionEndedMustBeBoolean: "") + ControlSurfaceResumeStrings( + agentSessionEndedMustBeBoolean: "", + launchCommandMustBeValid: "" + ) } func controlSurfaceSendText( diff --git a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandCoordinatorSurfaceTests.swift b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandCoordinatorSurfaceTests.swift index 283b8fd21c96..7d25604a3ac6 100644 --- a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandCoordinatorSurfaceTests.swift +++ b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandCoordinatorSurfaceTests.swift @@ -211,6 +211,146 @@ struct ControlCommandCoordinatorSurfaceTests { )) } + @Test func surfaceResumeSetKeepsStructuredLaunchDataStructured() throws { + let context = FakeSurfaceControlCommandContext() + context.resumeResolution = .setFailed + let coordinator = ControlCommandCoordinator(context: context) + + _ = coordinator.handle(ControlRequest( + id: .int(1), + method: "surface.resume.set", + params: [ + "command": .string("codex resume legacy"), + "kind": .string("codex"), + "permission_mode": .string("never"), + "launch_command": .object([ + "launcher": .string("codex"), + "executable_path": .string("/opt/Codex Tools/codex"), + "arguments": .array([ + .string("/opt/Codex Tools/codex"), + .string("space value"), + .string("引用"), + ]), + "working_directory": .string("/tmp/项目"), + "environment": .object(["CODEX_HOME": .string("/tmp/配置")]), + "captured_at": .double(42.5), + "source": .string("test"), + ]), + ] + )) + + let inputs = try #require(context.resumeSetInputs) + #expect(inputs.permissionMode == "never") + #expect(inputs.launchCommand == ControlAgentLaunchCommand( + launcher: "codex", + executablePath: "/opt/Codex Tools/codex", + arguments: ["/opt/Codex Tools/codex", "space value", "引用"], + workingDirectory: "/tmp/项目", + environment: ["CODEX_HOME": "/tmp/配置"], + capturedAt: 42.5, + source: "test" + )) + } + + @Test( + "surface resume set rejects malformed structured launch data", + arguments: [ + JSONValue.string("codex"), + .object([:]), + .object(["arguments": .array([])]), + .object(["arguments": .array([.string("codex"), .int(1)])]), + .object([ + "arguments": .array([.string("codex")]), + "environment": .object(["CODEX_HOME": .int(1)]), + ]), + .object([ + "arguments": .array([.string("codex")]), + "captured_at": .string("now"), + ]), + ] + ) + func surfaceResumeSetRejectsMalformedStructuredLaunchData( + launchCommand: JSONValue + ) { + let context = FakeSurfaceControlCommandContext() + context.resumeStrings = ControlSurfaceResumeStrings( + agentSessionEndedMustBeBoolean: "localized boolean validation", + launchCommandMustBeValid: "localized launch-command validation" + ) + let coordinator = ControlCommandCoordinator(context: context) + + let result = coordinator.handle(ControlRequest( + id: .int(1), + method: "surface.resume.set", + params: [ + "command": .string("codex resume legacy"), + "launch_command": launchCommand, + ] + )) + + #expect(result == .err( + code: "invalid_params", + message: "localized launch-command validation", + data: nil + )) + #expect(context.resumeSetInputs == nil) + } + + @Test func surfaceResumeGetEmitsStructuredRestoreRecord() throws { + let context = FakeSurfaceControlCommandContext() + let surfaceID = UUID() + let command = ControlAgentLaunchCommand( + launcher: nil, + executablePath: "/usr/bin/printf", + arguments: ["/usr/bin/printf", "%s", "quoted ' value"], + workingDirectory: "/tmp/日本語", + environment: ["RESTORE_VALUE": "space value"], + capturedAt: 21, + source: "test" + ) + context.resumeResolution = .result(ControlSurfaceResumeSnapshot( + windowID: nil, + workspaceID: UUID(), + paneID: nil, + surfaceID: surfaceID, + cleared: false, + binding: nil, + restoreRecord: ControlSurfaceRestoreRecord( + modeRawValue: "direct", + kind: "custom", + checkpointID: "checkpoint", + source: "test", + workingDirectory: "/tmp/日本語", + environment: ["RESTORE_VALUE": "space value"], + launchCommand: command, + preparedArguments: command.arguments, + preparedArgumentsWorkingDirectory: "/tmp/日本語", + permissionMode: nil, + legacyCommand: nil + ) + )) + let coordinator = ControlCommandCoordinator(context: context) + + let result = coordinator.handle(ControlRequest( + id: .int(1), + method: "surface.resume.get", + params: [:] + )) + guard case .ok(.object(let payload)) = result, + case .object(let record)? = payload["restore_record"], + case .object(let launch)? = record["launch_command"] else { + Issue.record("expected structured restore record") + return + } + + #expect(record["mode"] == .string("direct")) + #expect(record["working_directory"] == .string("/tmp/日本語")) + #expect(record["environment"] == .object(["RESTORE_VALUE": .string("space value")])) + #expect(record["prepared_arguments_working_directory"] == .string("/tmp/日本語")) + #expect(launch["arguments"] == .array(command.arguments.map(JSONValue.string))) + #expect(record["legacy_command"] == .null) + } + @Test func surfaceResumeClearForwardsManagedSessionEndProvenance() { let context = FakeSurfaceControlCommandContext() let coordinator = ControlCommandCoordinator(context: context) @@ -237,7 +377,8 @@ struct ControlCommandCoordinatorSurfaceTests { func surfaceResumeClearRejectsMalformedSessionEndProvenance(value: JSONValue) { let context = FakeSurfaceControlCommandContext() context.resumeStrings = ControlSurfaceResumeStrings( - agentSessionEndedMustBeBoolean: "localized boolean validation" + agentSessionEndedMustBeBoolean: "localized boolean validation", + launchCommandMustBeValid: "localized launch-command validation" ) let coordinator = ControlCommandCoordinator(context: context) diff --git a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/FakeSurfaceControlCommandContext.swift b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/FakeSurfaceControlCommandContext.swift index d3139051e033..144e15d716f0 100644 --- a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/FakeSurfaceControlCommandContext.swift +++ b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/FakeSurfaceControlCommandContext.swift @@ -7,9 +7,11 @@ final class FakeSurfaceControlCommandContext: ControlCommandContext { var createResolution: ControlSurfaceCreateResolution = .tabManagerUnavailable var surfaceListSnapshot: ControlSurfaceListSnapshot? var resumeResolution: ControlSurfaceResumeResolution = .surfaceNotFound + var resumeSetInputs: ControlSurfaceResumeSetInputs? var resumeClearAgentSessionEnded: Bool? var resumeStrings = ControlSurfaceResumeStrings( - agentSessionEndedMustBeBoolean: "agent_session_ended must be a boolean" + agentSessionEndedMustBeBoolean: "agent_session_ended must be a boolean", + launchCommandMustBeValid: "launch_command must be valid" ) var reportPWDResolution: ControlSurfaceReportPWDResolution = .recorded(surfaceID: UUID()) var reportedPWD: (workspaceID: UUID, requestedSurfaceID: UUID?, path: String)? @@ -54,13 +56,22 @@ final class FakeSurfaceControlCommandContext: ControlCommandContext { hasResolvedWindowID: Bool, inputs: ControlSurfaceResumeSetInputs ) -> ControlSurfaceResumeResolution { - resumeResolution + resumeSetInputs = inputs + return resumeResolution } func controlSurfaceResumeStrings() -> ControlSurfaceResumeStrings { resumeStrings } + func controlSurfaceResumeGet( + routing: ControlRoutingSelectors, + explicitTargetID: UUID?, + hasResolvedWindowID: Bool + ) -> ControlSurfaceResumeResolution { + resumeResolution + } + func controlSurfaceResumeClear( routing: ControlRoutingSelectors, explicitTargetID: UUID?, diff --git a/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Session/WorkspaceSessionRestorePolicyService.swift b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Session/WorkspaceSessionRestorePolicyService.swift index 110f8cc6311d..6c946765fe3a 100644 --- a/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Session/WorkspaceSessionRestorePolicyService.swift +++ b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Session/WorkspaceSessionRestorePolicyService.swift @@ -12,7 +12,6 @@ public struct WorkspaceSessionRestorePolicyService Bool private let truncateScrollback: @Sendable (String?) -> String? private let hermesCodexEnvironment: WorkspaceHermesCodexEnvironment - private let temporaryDirectory: URL /// Creates a restore policy service. public init( @@ -20,15 +19,13 @@ public struct WorkspaceSessionRestorePolicyService Bool, isRunningUnderAutomatedTests: @escaping @Sendable () -> Bool, truncateScrollback: @escaping @Sendable (String?) -> String?, - hermesCodexEnvironment: WorkspaceHermesCodexEnvironment, - temporaryDirectory: URL + hermesCodexEnvironment: WorkspaceHermesCodexEnvironment ) { self.applyStoredApproval = applyStoredApproval self.shouldRunPromptedSurfaceResume = shouldRunPromptedSurfaceResume self.isRunningUnderAutomatedTests = isRunningUnderAutomatedTests self.truncateScrollback = truncateScrollback self.hermesCodexEnvironment = hermesCodexEnvironment - self.temporaryDirectory = temporaryDirectory } /// Resolves the scrollback text persisted for a terminal snapshot. @@ -80,11 +77,9 @@ public struct WorkspaceSessionRestorePolicyService String? { guard let effectiveBinding = approvedSurfaceResumeBinding( resumeBinding, @@ -95,24 +90,16 @@ public struct WorkspaceSessionRestorePolicyService WorkspaceSurfaceResumeStartupLaunch? { guard let effectiveBinding = approvedSurfaceResumeBinding( resumeBinding, @@ -124,31 +111,30 @@ public struct WorkspaceSessionRestorePolicyService WorkspaceSurfaceResumeStartupLaunch? { - guard let input = effectiveBinding.startupInputWithLauncherScript( - fileManager: fileManager, - temporaryDirectory: temporaryDirectory, - allowLauncherScript: allowLauncherScript, - restoringWorkingDirectory: restoringWorkingDirectory - ) else { + guard let input = effectiveBinding.restoreStartupInput() else { return nil } return .input(input) } + /// Prepares a binding used only when a legacy shell command must be restored. + /// + /// - Parameter binding: The persisted binding whose structured fields remain authoritative. + /// - Returns: A copy with compatibility-only provider setup applied to its shell command. + public func bindingForCompatibilityShellRestore(_ binding: Binding) -> Binding { + WorkspaceHermesAgentCommandBootstrapper( + hermesCodexEnvironment: hermesCodexEnvironment + ).bindingForStartup(binding) + } + /// Applies stored approval state and returns the binding allowed to run. public func approvedSurfaceResumeBinding( _ resumeBinding: Binding?, @@ -165,9 +151,9 @@ public struct WorkspaceSessionRestorePolicyService String? + /// Returns the startup input used to restore this binding. + /// + /// Local bindings return a short `cmux restore` verb. Remote bindings may + /// retain their compatibility command because the local CLI cannot replace + /// a process on the remote host. + func restoreStartupInput() -> String? } diff --git a/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/Session/WorkspaceSessionRestorePolicyServiceTests.swift b/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/Session/WorkspaceSessionRestorePolicyServiceTests.swift index c410acb1cd6e..f25a3eaad107 100644 --- a/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/Session/WorkspaceSessionRestorePolicyServiceTests.swift +++ b/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/Session/WorkspaceSessionRestorePolicyServiceTests.swift @@ -21,6 +21,7 @@ struct WorkspaceSessionRestorePolicyServiceTests { var allowsAutomaticResume: Bool var requiresPromptApproval: Bool var autoResume: Bool? + var usesLocalRestoreVerb: Bool var startupInputPrefix = "input" init( @@ -33,7 +34,8 @@ struct WorkspaceSessionRestorePolicyServiceTests { isAgentHookBinding: Bool = false, allowsAutomaticResume: Bool = true, requiresPromptApproval: Bool = false, - autoResume: Bool? = nil + autoResume: Bool? = nil, + usesLocalRestoreVerb: Bool = true ) { self.source = source self.kind = kind @@ -45,15 +47,11 @@ struct WorkspaceSessionRestorePolicyServiceTests { self.allowsAutomaticResume = allowsAutomaticResume self.requiresPromptApproval = requiresPromptApproval self.autoResume = autoResume + self.usesLocalRestoreVerb = usesLocalRestoreVerb } - func startupInputWithLauncherScript( - fileManager: FileManager, - temporaryDirectory: URL, - allowLauncherScript: Bool, - restoringWorkingDirectory: String? - ) -> String? { - "\(startupInputPrefix):\(command):launcher=\(allowLauncherScript):cwd=\(restoringWorkingDirectory ?? "")" + func restoreStartupInput() -> String? { + "\(startupInputPrefix):\(command)" } } @@ -89,8 +87,7 @@ struct WorkspaceSessionRestorePolicyServiceTests { codexResponsesAPIMode: "responses", applyingDefaultCodexBaseURL: applyingDefaultCodexBaseURL, resolvingDefaultCodexModel: resolvingDefaultCodexModel - ), - temporaryDirectory: URL(fileURLWithPath: "/tmp", isDirectory: true) + ) ) } @@ -115,7 +112,7 @@ struct WorkspaceSessionRestorePolicyServiceTests { approvalSigningSecret: Data("secret".utf8) ) - #expect(result == "input:echo ok:launcher=false:cwd=") + #expect(result == "input:echo ok") #expect(observation.url == approvalURL) #expect(observation.secret == Data("secret".utf8)) } @@ -151,7 +148,7 @@ struct WorkspaceSessionRestorePolicyServiceTests { binding, autoResumeAgentSessions: true, approvalStoreURL: approvalURL - ) == "input:echo ok:launcher=false:cwd=") + ) == "input:echo ok") #expect(approved.surfaceResumeStartupInput( binding, autoResumeAgentSessions: true, @@ -180,21 +177,17 @@ struct WorkspaceSessionRestorePolicyServiceTests { binding, autoResumeAgentSessions: true, approvalStoreURL: approvalURL - ) == "input:claude --resume:launcher=false:cwd=") + ) == "input:claude --resume") } - @Test("post-start launch forwards the resolved resume working directory") - func postStartLaunchForwardsWorkingDirectory() throws { + @Test("post-start launch uses the binding restore input") + func postStartLaunchUsesBindingRestoreInput() throws { let service = makeService() let launch = try #require(service.surfaceResumeStartupLaunch( - forApprovedBinding: FakeBinding(), - restoringWorkingDirectory: "/tmp/restored project" + forApprovedBinding: FakeBinding() )) - #expect( - launch.initialInput == - "input:echo ok:launcher=true:cwd=/tmp/restored project" - ) + #expect(launch.initialInput == "input:echo ok") } @Test("Hermes agent bindings receive Codex bootstrap and provider rewrite") @@ -212,7 +205,8 @@ struct WorkspaceSessionRestorePolicyServiceTests { kind: "hermes-agent", command: "cd /repo && hermes --provider openai-codex run", isAgentHookBinding: true, - allowsAutomaticResume: true + allowsAutomaticResume: true, + usesLocalRestoreVerb: false ) let launch = try #require(service.surfaceResumeStartupLaunch( @@ -230,6 +224,65 @@ struct WorkspaceSessionRestorePolicyServiceTests { #expect(input.contains("hermes --provider 'codex' run")) } + @Test("local Hermes bindings stay untouched behind the restore verb") + func localHermesBindingsSkipShellBootstrap() throws { + let service = makeService() + let command = "cd /repo && hermes --provider openai-codex run" + let binding = FakeBinding( + source: "agent-hook", + kind: "hermes-agent", + command: command, + isAgentHookBinding: true, + allowsAutomaticResume: true, + usesLocalRestoreVerb: true + ) + + let launch = try #require(service.surfaceResumeStartupLaunch( + binding, + autoResumeAgentSessions: true, + approvalStoreURL: URL(fileURLWithPath: "/tmp/cmux-approvals.json") + )) + + #expect(launch.initialInput == "input:\(command)") + #expect(launch.initialInput.contains("config set") == false) + } + + @Test("compatibility-shell preparation refreshes local legacy Hermes bindings") + func compatibilityShellPreparationRefreshesLocalLegacyHermesBindings() { + let service = makeService( + applyingDefaultCodexBaseURL: { environment in + var copy = environment + copy["OPENAI_BASE_URL"] = "https://codex.example.test" + return copy + }, + resolvingDefaultCodexModel: { _ in "gpt-5" } + ) + let binding = FakeBinding( + source: "agent-hook", + kind: "hermes-agent", + command: "cd /repo && hermes --provider openai-codex run", + isAgentHookBinding: true, + allowsAutomaticResume: true, + usesLocalRestoreVerb: true + ) + + let compatibilityBinding = service.bindingForCompatibilityShellRestore(binding) + + #expect(compatibilityBinding.command.contains( + "'hermes' config set model.provider 'codex' >/dev/null" + )) + #expect(compatibilityBinding.command.contains( + "'hermes' config set model.base_url 'https://codex.example.test' >/dev/null" + )) + #expect(compatibilityBinding.command.contains( + "'hermes' config set model.api_mode 'responses' >/dev/null" + )) + #expect(compatibilityBinding.command.contains( + "'hermes' config set model.default 'gpt-5' >/dev/null" + )) + #expect(compatibilityBinding.command.contains("hermes --provider 'codex' run")) + } + @Test("remote reconnect waits when restored terminals can authenticate") func remoteReconnectWaitsWhenTerminalsAuthenticate() { let service = makeService() diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 2c44d85a2e55..a47156acd108 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -47666,6 +47666,174 @@ } } }, + "cli.restore.error.checkpointMismatch": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "restore: this command no longer matches the session. Run 'cmux restore --surface' to use the current record." } }, + "ja": { "stringUnit": { "state": "translated", "value": "restore: このコマンドは現在のセッションと一致しません。現在の状態を使用するには 'cmux restore --surface' を実行してください。" } } + } + }, + "cli.restore.error.compatibilityShellFailed": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "restore: the saved process could not be started. Retry the visible restore command." } }, + "ja": { "stringUnit": { "state": "translated", "value": "restore: 保存されたプロセスを開始できませんでした。表示されている復元コマンドを再実行してください。" } } + } + }, + "cli.restore.error.currentSurfaceUnknown": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "restore: the current cmux surface could not be identified. Retry from this terminal or pass --surface ." } }, + "ja": { "stringUnit": { "state": "translated", "value": "restore: 現在の cmux サーフェスを特定できませんでした。このターミナルから再試行するか、--surface を指定してください。" } } + } + }, + "cli.restore.error.executableNotFound": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "restore: the saved agent command is unavailable. Make sure the agent is installed, then retry." } }, + "ja": { "stringUnit": { "state": "translated", "value": "restore: 保存されたエージェントコマンドを利用できません。エージェントがインストールされていることを確認してから、再試行してください。" } } + } + }, + "cli.restore.error.execveFailed": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "restore: the saved process could not be started. Retry the visible restore command." } }, + "ja": { "stringUnit": { "state": "translated", "value": "restore: 保存されたプロセスを開始できませんでした。表示されている復元コマンドを再実行してください。" } } + } + }, + "cli.restore.error.incompleteData": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "restore: this session's saved restore data is not compatible. Start the agent again in this terminal." } }, + "ja": { "stringUnit": { "state": "translated", "value": "restore: このセッションの保存済み復元データには互換性がありません。このターミナルでエージェントをもう一度起動してください。" } } + } + }, + "cli.restore.error.kindMismatch": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "restore: this command no longer matches the session. Run 'cmux restore --surface' to use the current record." } }, + "ja": { "stringUnit": { "state": "translated", "value": "restore: このコマンドは現在のセッションと一致しません。現在の状態を使用するには 'cmux restore --surface' を実行してください。" } } + } + }, + "cli.restore.error.malformedArguments": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "restore: this session's saved restore data is not compatible. Start the agent again in this terminal." } }, + "ja": { "stringUnit": { "state": "translated", "value": "restore: このセッションの保存済み復元データには互換性がありません。このターミナルでエージェントをもう一度起動してください。" } } + } + }, + "cli.restore.error.malformedRecord": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "restore: this session's saved restore data is not compatible. Start the agent again in this terminal." } }, + "ja": { "stringUnit": { "state": "translated", "value": "restore: このセッションの保存済み復元データには互換性がありません。このターミナルでエージェントをもう一度起動してください。" } } + } + }, + "cli.restore.error.noRecord": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "restore: this session has nothing to restore. Start the agent again in this terminal." } }, + "ja": { "stringUnit": { "state": "translated", "value": "restore: このセッションには復元できるものがありません。このターミナルでエージェントをもう一度起動してください。" } } + } + }, + "cli.restore.error.providerSetupConfigurationFailed": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "restore: provider setup could not start. Check the agent's provider settings, then retry." } }, + "ja": { "stringUnit": { "state": "translated", "value": "restore: プロバイダー設定を開始できませんでした。エージェントのプロバイダー設定を確認してから、再試行してください。" } } + } + }, + "cli.restore.error.providerSetupExited": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "restore: provider setup failed. Check the agent's provider settings, then retry." } }, + "ja": { "stringUnit": { "state": "translated", "value": "restore: プロバイダー設定に失敗しました。エージェントのプロバイダー設定を確認してから、再試行してください。" } } + } + }, + "cli.restore.error.providerSetupSignaled": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "restore: provider setup failed. Check the agent's provider settings, then retry." } }, + "ja": { "stringUnit": { "state": "translated", "value": "restore: プロバイダー設定に失敗しました。エージェントのプロバイダー設定を確認してから、再試行してください。" } } + } + }, + "cli.restore.error.providerSetupStartFailed": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "restore: provider setup could not start. Check the agent's provider settings, then retry." } }, + "ja": { "stringUnit": { "state": "translated", "value": "restore: プロバイダー設定を開始できませんでした。エージェントのプロバイダー設定を確認してから、再試行してください。" } } + } + }, + "cli.restore.error.providerSetupTimedOut": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "restore: provider setup took too long. Check the provider connection, then retry." } }, + "ja": { "stringUnit": { "state": "translated", "value": "restore: プロバイダー設定に時間がかかりすぎました。プロバイダー接続を確認してから、再試行してください。" } } + } + }, + "cli.restore.error.providerSetupUnavailable": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "restore: provider setup is unavailable. Check the agent's provider settings, then retry." } }, + "ja": { "stringUnit": { "state": "translated", "value": "restore: プロバイダー設定を利用できません。エージェントのプロバイダー設定を確認してから、再試行してください。" } } + } + }, + "cli.restore.error.providerSetupWaitFailed": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "restore: provider setup could not complete. Retry the visible restore command." } }, + "ja": { "stringUnit": { "state": "translated", "value": "restore: プロバイダー設定を完了できませんでした。表示されている復元コマンドを再実行してください。" } } + } + }, + "cli.restore.error.socketNotReady": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "restore: cmux is still opening. Retry the visible restore command in a moment." } }, + "ja": { "stringUnit": { "state": "translated", "value": "restore: cmux はまだ起動中です。しばらくしてから、表示されている復元コマンドを再実行してください。" } } + } + }, + "cli.restore.error.surfaceNotFound": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "restore: the requested surface was not found. Check the surface reference, then retry." } }, + "ja": { "stringUnit": { "state": "translated", "value": "restore: 指定されたサーフェスが見つかりません。サーフェスの参照を確認してから、再試行してください。" } } + } + }, + "cli.restore.error.unsupportedMode": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "restore: this session's saved restore data is not compatible. Start the agent again in this terminal." } }, + "ja": { "stringUnit": { "state": "translated", "value": "restore: このセッションの保存済み復元データには互換性がありません。このターミナルでエージェントをもう一度起動してください。" } } + } + }, + "cli.restore.error.workingDirectoryFailed": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "restore: the saved working directory is inaccessible. Restore access to it, then retry." } }, + "ja": { "stringUnit": { "state": "translated", "value": "restore: 保存された作業ディレクトリにアクセスできません。アクセスを復元してから、再試行してください。" } } + } + }, + "cli.restore.help": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Usage: cmux restore \n cmux restore --surface [id|ref]\n\nReplace this CLI process with the persisted surface process. New\nrecords preserve argv, environment, and cwd as structured values;\ncommand-only records from older builds use a compatibility shell.\nWith no id or ref, --surface uses the calling cmux surface." } }, + "ja": { "stringUnit": { "state": "translated", "value": "使用法: cmux restore \n cmux restore --surface [id|ref]\n\nこの CLI プロセスを保存されたサーフェスプロセスで置き換えます。新しい\nレコードでは argv、環境、cwd が構造化された値として保持されます。\n旧ビルドのコマンドのみのレコードでは互換シェルを使用します。\nid または ref を省略すると、--surface は呼び出し元の cmux サーフェスを使用します。" } } + } + }, + "cli.restore.usage.positional": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Usage: cmux restore " } }, + "ja": { "stringUnit": { "state": "translated", "value": "使用法: cmux restore " } } + } + }, + "cli.restore.usage.surface": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Usage: cmux restore --surface [id|ref]" } }, + "ja": { "stringUnit": { "state": "translated", "value": "使用法: cmux restore --surface [id|ref]" } } + } + }, "cli.rightSidebar.error.invalidWindow": { "extractionState": "manual", "localizations": { @@ -228699,6 +228867,23 @@ } } }, + "socket.surface.resume.launchCommandMustBeValid": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "launch_command.arguments must be a non-empty array of strings" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "launch_command.arguments は空でない文字列の配列である必要があります" + } + } + } + }, "socket.surfaceSplitOff.error.appDelegateUnavailable": { "extractionState": "manual", "localizations": { diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index be1cdd6d1f9e..a26f071e9dd9 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -7323,6 +7323,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent suppressWelcome: Bool = false ) -> UUID { reserveInitialSocketPathIfNeeded() + // Restored terminals can execute their short `cmux restore` input as + // soon as their PTY comes up. Bind the transport before constructing + // those surfaces; main-actor command routing naturally waits until the + // restore pass has registered their windows and bindings. + reconcileSocketListenerConfiguration(source: "bootstrapInitialMainWindow.preRestore") let windowId = ensureInitialMainWindowIfNeeded( shouldActivate: shouldActivate, suppressWelcome: suppressWelcome diff --git a/Sources/ControlSurfaceResumeTarget.swift b/Sources/ControlSurfaceResumeTarget.swift index 7ee468ac63ef..7db9b750525f 100644 --- a/Sources/ControlSurfaceResumeTarget.swift +++ b/Sources/ControlSurfaceResumeTarget.swift @@ -1,4 +1,5 @@ import AppKit +import CMUXAgentLaunch import CmuxControlSocket import Foundation @@ -44,6 +45,24 @@ enum ControlSurfaceResumeTarget { } } + var restorableAgent: SessionRestorableAgentSnapshot? { + switch self { + case .workspace(_, let workspace, let surfaceID): + workspace.restoredAgentSnapshotsByPanelId[surfaceID] + case .dock(_, let dock, let surfaceID): + dock.restoredAgentLifecycle.snapshotsByPanelId[surfaceID] + } + } + + var restoredResumeWorkingDirectory: String? { + switch self { + case .workspace(_, let workspace, let surfaceID): + workspace.restoredResumeSessionWorkingDirectoriesByPanelId[surfaceID] + case .dock(_, let dock, let surfaceID): + dock.restoredResumeSessionWorkingDirectoriesByPanelId[surfaceID] + } + } + @discardableResult func setBinding(_ binding: SurfaceResumeBindingSnapshot) -> Bool { switch self { @@ -239,7 +258,123 @@ extension TerminalController { paneID: target.paneID, surfaceID: target.surfaceID, cleared: cleared, - binding: controlResumeBinding(from: binding) + binding: controlResumeBinding(from: binding), + restoreRecord: cleared + ? nil + : controlSurfaceRestoreRecord(target: target, binding: binding) + ) + } + + private func controlSurfaceRestoreRecord( + target: ControlSurfaceResumeTarget, + binding: SurfaceResumeBindingSnapshot? + ) -> ControlSurfaceRestoreRecord? { + // Structured fields remain untouched; only the explicit legacy fallback + // receives restore-time provider refreshes that older records depended on. + let compatibilityBinding = binding.map { + Workspace.makeSessionRestorePolicyService() + .bindingForCompatibilityShellRestore($0) + } + // A hook can replace the live binding after this surface was restored, + // while the restore-time agent snapshot still names the previous + // conversation. Reuse the session-restore identity gate so the record + // returned to the CLI always agrees with the binding that generated its + // typed `cmux restore ` selector. + let compatibleAgent: SessionRestorableAgentSnapshot? = + if binding == nil || binding?.isAgentHookBinding == true { + Workspace.restorableAgentForSessionRestore( + target.restorableAgent, + resumeBinding: binding + ) + } else { + nil + } + if let agent = compatibleAgent { + let launchCommand = binding?.launchCommand ?? agent.launchCommand + let workingDirectory = target.restoredResumeWorkingDirectory + ?? binding?.cwd + ?? agent.workingDirectory + ?? launchCommand?.workingDirectory + let permissionMode = binding?.permissionMode ?? agent.permissionMode + let mode: AgentRestoreRequestMode = agent.kind.restoreMode == .relaunchCommand + ? .relaunchAgent + : .resumeAgent + let preparedArguments = agent.kind.restoreMode == .resumeSession + ? agent.preparedResumeArguments( + launchCommand: launchCommand, + workingDirectory: workingDirectory, + observedPermissionMode: permissionMode + ) + : nil + return ControlSurfaceRestoreRecord( + modeRawValue: mode.rawValue, + kind: agent.kind.rawValue, + checkpointID: agent.sessionId, + source: "session-snapshot", + workingDirectory: workingDirectory, + environment: binding?.environment ?? [:], + launchCommand: launchCommand.map { + controlAgentLaunchCommand( + $0, + replaySafeEnvironmentFor: agent.kind.rawValue + ) + }, + preparedArguments: preparedArguments, + preparedArgumentsWorkingDirectory: preparedArguments == nil + ? nil + : workingDirectory, + permissionMode: permissionMode, + legacyCommand: compatibilityBinding?.inlineStartupInput + ) + } + guard let binding else { return nil } + let trimmedKind = binding.kind?.trimmingCharacters(in: .whitespacesAndNewlines) + let normalizedKind = trimmedKind.flatMap { $0.isEmpty ? nil : $0 } ?? "command" + let mode: AgentRestoreRequestMode = binding.isAgentHookBinding + ? .resumeAgent + : .direct + return ControlSurfaceRestoreRecord( + modeRawValue: mode.rawValue, + kind: normalizedKind, + checkpointID: binding.checkpointId, + source: binding.source, + workingDirectory: target.restoredResumeWorkingDirectory + ?? binding.cwd + ?? binding.launchCommand?.workingDirectory, + environment: binding.environment ?? [:], + launchCommand: binding.launchCommand.map { + controlAgentLaunchCommand( + $0, + replaySafeEnvironmentFor: normalizedKind + ) + }, + preparedArguments: mode == .direct ? binding.launchCommand?.arguments : nil, + preparedArgumentsWorkingDirectory: nil, + permissionMode: binding.permissionMode, + legacyCommand: compatibilityBinding?.inlineStartupInput + ) + } + + func controlAgentLaunchCommand( + _ command: AgentLaunchCommandSnapshot, + replaySafeEnvironmentFor kind: String? = nil + ) -> ControlAgentLaunchCommand { + let environment = kind.flatMap { kind in + command.environment.map { + AgentLaunchEnvironmentPolicy().selectedRestoreEnvironment( + from: $0, + kind: kind + ) + } + } ?? command.environment + return ControlAgentLaunchCommand( + launcher: command.launcher, + executablePath: command.executablePath, + arguments: command.arguments, + workingDirectory: command.workingDirectory, + environment: environment, + capturedAt: command.capturedAt, + source: command.source ) } @@ -367,6 +502,18 @@ extension TerminalController { checkpointId: inputs.checkpointID, source: inputs.source, environment: inputs.environment, + launchCommand: inputs.launchCommand.map { + AgentLaunchCommandSnapshot( + launcher: $0.launcher, + executablePath: $0.executablePath, + arguments: $0.arguments, + workingDirectory: $0.workingDirectory, + environment: $0.environment, + capturedAt: $0.capturedAt, + source: $0.source + ) + }, + permissionMode: inputs.permissionMode, autoResume: inputs.autoResume, updatedAt: Date.now.timeIntervalSince1970 ) diff --git a/Sources/DockSplitStore+SessionRestore.swift b/Sources/DockSplitStore+SessionRestore.swift index d122325094db..c3e4e0b85ac6 100644 --- a/Sources/DockSplitStore+SessionRestore.swift +++ b/Sources/DockSplitStore+SessionRestore.swift @@ -178,12 +178,9 @@ extension DockSplitStore { ?? restorableAgent?.workingDirectory ?? snapshot.directory let workingDirectory = savedWorkingDirectory ?? FileManager.default.homeDirectoryForCurrentUser.path - let candidateBindingWorkingDirectory = approvedResumeBinding?.cwd ?? workingDirectory let unresolvedBindingLaunch = approvedResumeBinding.flatMap { policy.surfaceResumeStartupLaunch( - forApprovedBinding: $0, - allowLauncherScript: true, - restoringWorkingDirectory: candidateBindingWorkingDirectory + forApprovedBinding: $0 ) } let resumeSessionWorkingDirectory: String? = { diff --git a/Sources/RestorableAgentSession.swift b/Sources/RestorableAgentSession.swift index 7e1cabfddb77..ce8a69094ffc 100644 --- a/Sources/RestorableAgentSession.swift +++ b/Sources/RestorableAgentSession.swift @@ -509,7 +509,7 @@ enum AgentResumeCommandBuilder { return environmentParts } - private static func resumeArguments( + fileprivate static func resumeArguments( kind: RestorableAgentKind, sessionId: String, launchCommand: AgentLaunchCommandSnapshot?, @@ -773,7 +773,7 @@ enum AgentResumeCommandBuilder { } struct SessionRestorableAgentSnapshot: Codable, Sendable { - static let maxInlineStartupInputBytes = 900 + private static let maxInlineForkInputBytes = 900 var kind: RestorableAgentKind var sessionId: String @@ -784,32 +784,44 @@ struct SessionRestorableAgentSnapshot: Codable, Sendable { /// user-owned claude resume/fork when no explicit launch flag covers it. var permissionMode: String? = nil + func preparedResumeArguments( + launchCommand: AgentLaunchCommandSnapshot?, + workingDirectory: String?, + observedPermissionMode: String? + ) -> [String]? { + AgentResumeCommandBuilder.resumeArguments( + kind: kind, + sessionId: sessionId, + launchCommand: launchCommand, + workingDirectory: workingDirectory, + customRegistration: registration, + observedPermissionMode: observedPermissionMode + ) + } + func resumeStartupInput( - fileManager: FileManager = .default, - temporaryDirectory: URL = FileManager.default.temporaryDirectory, - allowLauncherScript: Bool = true, - allowOversizedInlineInput: Bool = false, + useLocalRestoreVerb: Bool = true, restoringWorkingDirectory: String? = nil ) -> String? { + if useLocalRestoreVerb { + let executable = AgentRestoreLaunch.cliStartupExecutableToken + guard AgentRestoreCLIArgument(rawValue: kind.rawValue) != nil, + AgentRestoreCLIArgument(rawValue: sessionId) != nil else { + return " \(executable) restore --surface\n" + } + return " \(executable) restore \(kind.rawValue) \(sessionId)\n" + } let effectiveWorkingDirectory = resumeWorkingDirectory( preferred: restoringWorkingDirectory ) let restoreCommand = resumeCommand( - includeWorkingDirectoryPrefix: !allowLauncherScript, + includeWorkingDirectoryPrefix: true, restoringWorkingDirectory: effectiveWorkingDirectory ).map { command in AgentRestoreLaunch(kind: kind.rawValue, sessionID: sessionId)? .applying(toStoredCommand: command) ?? command } - return startupInput( - command: restoreCommand, - workingDirectory: allowLauncherScript ? effectiveWorkingDirectory : nil, - fileManager: fileManager, - temporaryDirectory: temporaryDirectory, - allowLauncherScript: allowLauncherScript, - allowOversizedInlineInput: allowOversizedInlineInput, - alwaysUseLauncherScript: allowLauncherScript - ) + return restoreCommand.map { $0 + "\n" } } func forkStartupInput( @@ -831,16 +843,11 @@ struct SessionRestorableAgentSnapshot: Codable, Sendable { workingDirectory: String?, fileManager: FileManager, temporaryDirectory: URL, - allowLauncherScript: Bool = true, - allowOversizedInlineInput: Bool = false, - alwaysUseLauncherScript: Bool = false + allowLauncherScript: Bool = true ) -> String? { guard let command else { return nil } let inlineInput = command + "\n" - guard alwaysUseLauncherScript || inlineInput.utf8.count > Self.maxInlineStartupInputBytes else { - return inlineInput - } - guard alwaysUseLauncherScript || !allowOversizedInlineInput else { + guard inlineInput.utf8.count > Self.maxInlineForkInputBytes else { return inlineInput } guard allowLauncherScript else { return nil } @@ -853,7 +860,7 @@ struct SessionRestorableAgentSnapshot: Codable, Sendable { ) else { return nil } - return scriptInput.utf8.count <= Self.maxInlineStartupInputBytes ? scriptInput : nil + return scriptInput.utf8.count <= Self.maxInlineForkInputBytes ? scriptInput : nil } private func resumeWorkingDirectory(preferred: String?) -> String? { diff --git a/Sources/RestorableAgentTypes.swift b/Sources/RestorableAgentTypes.swift index 208763bf2cae..d7e46374defa 100644 --- a/Sources/RestorableAgentTypes.swift +++ b/Sources/RestorableAgentTypes.swift @@ -198,12 +198,4 @@ enum RestorableAgentKind: Codable, Hashable, Sendable { } } -struct AgentLaunchCommandSnapshot: Codable, Equatable, Sendable { - var launcher: String? - var executablePath: String? - var arguments: [String] - var workingDirectory: String? - var environment: [String: String]? - var capturedAt: TimeInterval? - var source: String? -} +typealias AgentLaunchCommandSnapshot = AgentLaunchCommand diff --git a/Sources/SessionPersistence.swift b/Sources/SessionPersistence.swift index abcd44657b1c..43077ac23488 100644 --- a/Sources/SessionPersistence.swift +++ b/Sources/SessionPersistence.swift @@ -261,7 +261,7 @@ struct SurfaceResumeBindingSnapshot: Codable, Equatable, Sendable { private enum CodingKeys: String, CodingKey { case name, kind, command, cwd, checkpointId, source case environment, autoResume, approvalPolicy, approvalRecordId - case launchFlavor, updatedAt + case launchCommand, permissionMode, launchFlavor, updatedAt } var name: String? @@ -271,6 +271,8 @@ struct SurfaceResumeBindingSnapshot: Codable, Equatable, Sendable { var checkpointId: String? var source: String? var environment: [String: String]? + var launchCommand: AgentLaunchCommandSnapshot? + var permissionMode: String? var autoResume: Bool? var approvalPolicy: SurfaceResumeApprovalPolicy? var approvalRecordId: String? @@ -287,6 +289,8 @@ struct SurfaceResumeBindingSnapshot: Codable, Equatable, Sendable { checkpointId: String? = nil, source: String? = nil, environment: [String: String]? = nil, + launchCommand: AgentLaunchCommandSnapshot? = nil, + permissionMode: String? = nil, autoResume: Bool? = nil, approvalPolicy: SurfaceResumeApprovalPolicy? = nil, approvalRecordId: String? = nil, @@ -307,6 +311,8 @@ struct SurfaceResumeBindingSnapshot: Codable, Equatable, Sendable { self.checkpointId = Self.normalized(checkpointId) self.source = normalizedSource self.environment = Self.normalizedEnvironment(environment) + self.launchCommand = Self.normalizedLaunchCommand(launchCommand) + self.permissionMode = Self.normalized(permissionMode) self.autoResume = autoResume self.approvalPolicy = approvalPolicy self.approvalRecordId = Self.normalized(approvalRecordId) @@ -325,6 +331,11 @@ struct SurfaceResumeBindingSnapshot: Codable, Equatable, Sendable { 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), + launchCommand: try container.decodeIfPresent( + AgentLaunchCommandSnapshot.self, + forKey: .launchCommand + ), + permissionMode: try container.decodeIfPresent(String.self, forKey: .permissionMode), autoResume: try container.decodeIfPresent(Bool.self, forKey: .autoResume), approvalPolicy: try container.decodeIfPresent(SurfaceResumeApprovalPolicy.self, forKey: .approvalPolicy), approvalRecordId: try container.decodeIfPresent(String.self, forKey: .approvalRecordId), @@ -351,6 +362,10 @@ struct SurfaceResumeBindingSnapshot: Codable, Equatable, Sendable { autoResume == true } + var usesLocalRestoreVerb: Bool { + launchFlavor == .local + } + func shouldYieldToDetectedSurfaceResumeBinding(_ detectedBinding: SurfaceResumeBindingSnapshot) -> Bool { detectedBinding.isProcessDetected && (isProcessDetected || isAgentHookBinding) } @@ -358,28 +373,19 @@ struct SurfaceResumeBindingSnapshot: Codable, Equatable, Sendable { func retargetingWorkingDirectory(_ workingDirectory: String?) -> SurfaceResumeBindingSnapshot { guard isAgentHookBinding else { return self } let normalizedCwd = Self.normalized(workingDirectory) - let retargetedCommand = TerminalStartupWorkingDirectoryPrefix.replacingRequiredChangeDirectoryPrefix( + var retargeted = self + retargeted.command = TerminalStartupWorkingDirectoryPrefix.replacingRequiredChangeDirectoryPrefix( in: command, previousWorkingDirectory: cwd, workingDirectory: normalizedCwd ) - return SurfaceResumeBindingSnapshot( - name: name, - kind: kind, - command: retargetedCommand, - cwd: normalizedCwd, - checkpointId: checkpointId, - source: source, - environment: environment, - autoResume: autoResume, - approvalPolicy: approvalPolicy, - approvalRecordId: approvalRecordId, - launchFlavor: launchFlavor, - updatedAt: updatedAt - ) + retargeted.cwd = normalizedCwd + if var launchCommand = retargeted.launchCommand { + launchCommand.workingDirectory = normalizedCwd + retargeted.launchCommand = launchCommand + } + return retargeted } - static let maxInlineStartupInputBytes = SessionRestorableAgentSnapshot.maxInlineStartupInputBytes - var startupInput: String? { inlineStartupInput } @@ -388,19 +394,8 @@ struct SurfaceResumeBindingSnapshot: Codable, Equatable, Sendable { inlineStartupInput(repairPortableAgentExecutable: true) } - func startupInputWithLauncherScript( - fileManager: FileManager = .default, - temporaryDirectory: URL = FileManager.default.temporaryDirectory, - allowLauncherScript: Bool = true, - restoringWorkingDirectory: String? = nil - ) -> String? { - startupInputWithLauncherScript( - fileManager: fileManager, - temporaryDirectory: temporaryDirectory, - allowLauncherScript: allowLauncherScript, - restoringWorkingDirectory: restoringWorkingDirectory, - repairPortableAgentExecutable: true - ) + func restoreStartupInput() -> String? { + restoreStartupInput(repairPortableAgentExecutable: true) } private static func normalized(_ rawValue: String?) -> String? { @@ -422,6 +417,15 @@ struct SurfaceResumeBindingSnapshot: Codable, Equatable, Sendable { return normalized.isEmpty ? nil : normalized } + private static func normalizedLaunchCommand( + _ launchCommand: AgentLaunchCommandSnapshot? + ) -> AgentLaunchCommandSnapshot? { + guard var launchCommand else { return nil } + launchCommand.workingDirectory = normalized(launchCommand.workingDirectory) + launchCommand.environment = normalizedEnvironment(launchCommand.environment) + return launchCommand + } + private static func isSafeEnvironmentValue(_ value: String) -> Bool { !value.unicodeScalars.contains { $0.value < 0x20 || $0.value == 0x7F } } diff --git a/Sources/SurfaceResumeBindingSnapshot+Remote.swift b/Sources/SurfaceResumeBindingSnapshot+Remote.swift index 8f7bc04d6198..8b2de9058b69 100644 --- a/Sources/SurfaceResumeBindingSnapshot+Remote.swift +++ b/Sources/SurfaceResumeBindingSnapshot+Remote.swift @@ -37,19 +37,8 @@ extension SurfaceResumeBindingSnapshot { private func replacingLaunchFlavor( _ launchFlavor: SurfaceResumeLaunchFlavor ) -> SurfaceResumeBindingSnapshot { - SurfaceResumeBindingSnapshot( - name: name, - kind: kind, - command: command, - cwd: cwd, - checkpointId: checkpointId, - source: source, - environment: environment, - autoResume: autoResume, - approvalPolicy: approvalPolicy, - approvalRecordId: approvalRecordId, - launchFlavor: launchFlavor, - updatedAt: updatedAt - ) + var replaced = self + replaced.launchFlavor = launchFlavor + return replaced } } diff --git a/Sources/SurfaceResumeCommandCanonicalizer+PortableAgentExecutable.swift b/Sources/SurfaceResumeCommandCanonicalizer+PortableAgentExecutable.swift index 7a1087e1eb9a..b839d2bca528 100644 --- a/Sources/SurfaceResumeCommandCanonicalizer+PortableAgentExecutable.swift +++ b/Sources/SurfaceResumeCommandCanonicalizer+PortableAgentExecutable.swift @@ -46,40 +46,36 @@ extension SurfaceResumeBindingSnapshot { return argv.map(Self.shellSingleQuoted).joined(separator: " ") + "\n" } - func startupInputWithLauncherScript( - fileManager: FileManager = .default, - temporaryDirectory: URL = FileManager.default.temporaryDirectory, - allowLauncherScript: Bool = true, - restoringWorkingDirectory: String? = nil, + func restoreStartupInput( repairPortableAgentExecutable: Bool ) -> String? { - if !allowLauncherScript { - return inlineStartupInput( - repairPortableAgentExecutable: repairPortableAgentExecutable - ) - } - guard let inlineInput = inlineStartupInput( - repairPortableAgentExecutable: repairPortableAgentExecutable, - includeWorkingDirectoryPrefix: false - ) else { return nil } - guard let scriptInput = OneShotTerminalLauncherStore( - fileManager: fileManager, - temporaryDirectory: temporaryDirectory - ).writeInvocationInput( - command: inlineInput, - workingDirectory: restoringWorkingDirectory ?? cwd - ) else { - return nil + if usesLocalRestoreVerb { + return localRestoreCLIInput } + return inlineStartupInput( + repairPortableAgentExecutable: repairPortableAgentExecutable + ) + } - return scriptInput.utf8.count <= Self.maxInlineStartupInputBytes ? scriptInput : nil + func remoteStartupInput() -> String? { + inlineStartupInput(repairPortableAgentExecutable: false) } - func remoteStartupInputWithLauncherScript(allowLauncherScript: Bool = false) -> String? { - startupInputWithLauncherScript( - allowLauncherScript: allowLauncherScript, - repairPortableAgentExecutable: false - ) + private var localRestoreCLIInput: String { + let executable = AgentRestoreLaunch.cliStartupExecutableToken + if let kind = Self.restoreCLIArgument(kind), + let checkpointId = Self.restoreCLIArgument(checkpointId) { + return " \(executable) restore \(kind) \(checkpointId)\n" + } + return " \(executable) restore --surface\n" + } + + private static func restoreCLIArgument(_ value: String?) -> String? { + guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines), + !value.isEmpty else { + return nil + } + AgentRestoreCLIArgument(rawValue: value)?.rawValue } private func resolvedStartupCommand(repairPortableAgentExecutable: Bool) -> String { diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 7051b348a5d9..07242d906158 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -5908,6 +5908,8 @@ extension TabManager { hashOptionalString(snapshot.checkpointId, into: &hasher) hashOptionalString(snapshot.source, into: &hasher) hashStringMap(snapshot.environment, into: &hasher) + hashAgentLaunchCommand(snapshot.launchCommand, into: &hasher) + hashOptionalString(snapshot.permissionMode, into: &hasher) hasher.combine(snapshot.allowsAutomaticResume) hasher.combine(snapshot.launchFlavor) if snapshot.isProcessDetected { diff --git a/Sources/TerminalController+ControlSurfaceContext.swift b/Sources/TerminalController+ControlSurfaceContext.swift index 7f4e5bccf0ed..a19588992a83 100644 --- a/Sources/TerminalController+ControlSurfaceContext.swift +++ b/Sources/TerminalController+ControlSurfaceContext.swift @@ -95,6 +95,13 @@ extension TerminalController: ControlSurfaceContext { checkpointID: effective.checkpointId, source: effective.source, environment: effective.environment, + launchCommand: effective.launchCommand.map { + controlAgentLaunchCommand( + $0, + replaySafeEnvironmentFor: effective.kind + ) + }, + permissionMode: effective.permissionMode, autoResume: effective.allowsAutomaticResume, approvalPolicyRawValue: effective.approvalPolicy?.rawValue, approvalRecordID: effective.approvalRecordId, diff --git a/Sources/TerminalController+ControlSurfaceContext3.swift b/Sources/TerminalController+ControlSurfaceContext3.swift index f8223b95eb2b..23a05f36ca25 100644 --- a/Sources/TerminalController+ControlSurfaceContext3.swift +++ b/Sources/TerminalController+ControlSurfaceContext3.swift @@ -13,6 +13,10 @@ extension TerminalController { agentSessionEndedMustBeBoolean: String( localized: "socket.surface.resume.agentSessionEndedMustBeBoolean", defaultValue: "agent_session_ended must be a boolean" + ), + launchCommandMustBeValid: String( + localized: "socket.surface.resume.launchCommandMustBeValid", + defaultValue: "launch_command.arguments must be a non-empty array of strings" ) ) } diff --git a/Sources/TmuxResumeParser.swift b/Sources/TmuxResumeParser.swift index 0a7f2e3a94eb..fb26bc17083f 100644 --- a/Sources/TmuxResumeParser.swift +++ b/Sources/TmuxResumeParser.swift @@ -26,6 +26,15 @@ enum TmuxResumeParser { checkpointId: invocation.sessionName, source: "process-detected", environment: resumeEnvironment, + launchCommand: AgentLaunchCommandSnapshot( + launcher: nil, + executablePath: invocation.argv.first, + arguments: invocation.argv, + workingDirectory: cwd, + environment: resumeEnvironment, + capturedAt: capturedAt, + source: "process-detected" + ), autoResume: true, updatedAt: capturedAt ) diff --git a/Sources/Workspace+RemoteSurfaceResumeBinding.swift b/Sources/Workspace+RemoteSurfaceResumeBinding.swift index ae64ee39b1e7..7eb72437a643 100644 --- a/Sources/Workspace+RemoteSurfaceResumeBinding.swift +++ b/Sources/Workspace+RemoteSurfaceResumeBinding.swift @@ -63,7 +63,7 @@ extension Workspace { !configuration.skipDaemonBootstrap, configuration.persistentDaemonSlot != nil, let relayPort = configuration.relayPort, - let startupInput = binding.remoteStartupInputWithLauncherScript(allowLauncherScript: false) else { + let startupInput = binding.remoteStartupInput() else { return nil } return SSHPTYAttachStartupCommandBuilder.restoredRemoteShellCommand( diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index b3acbacd3840..ebad645bee38 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -943,7 +943,6 @@ extension Workspace { nonisolated static func surfaceResumeStartupInput( _ resumeBinding: SurfaceResumeBindingSnapshot?, autoResumeAgentSessions: Bool, - allowLauncherScript: Bool = false, promptForApproval: Bool = true, approvalStoreURL: URL = SurfaceResumeApprovalStore.defaultURL(), approvalSigningSecret: Data? = nil @@ -951,7 +950,6 @@ extension Workspace { makeSessionRestorePolicyService().surfaceResumeStartupInput( resumeBinding, autoResumeAgentSessions: autoResumeAgentSessions, - allowLauncherScript: allowLauncherScript, promptForApproval: promptForApproval, approvalStoreURL: approvalStoreURL, approvalSigningSecret: approvalSigningSecret @@ -961,23 +959,16 @@ extension Workspace { nonisolated static func surfaceResumeStartupLaunch( _ resumeBinding: SurfaceResumeBindingSnapshot?, autoResumeAgentSessions: Bool, - allowLauncherScript: Bool = true, promptForApproval: Bool = true, approvalStoreURL: URL = SurfaceResumeApprovalStore.defaultURL(), - approvalSigningSecret: Data? = nil, - fileManager: FileManager = .default, - temporaryDirectory: URL = FileManager.default.temporaryDirectory + approvalSigningSecret: Data? = nil ) -> SurfaceResumeStartupLaunch? { - makeSessionRestorePolicyService( - temporaryDirectory: temporaryDirectory - ).surfaceResumeStartupLaunch( + makeSessionRestorePolicyService().surfaceResumeStartupLaunch( resumeBinding, autoResumeAgentSessions: autoResumeAgentSessions, - allowLauncherScript: allowLauncherScript, promptForApproval: promptForApproval, approvalStoreURL: approvalStoreURL, - approvalSigningSecret: approvalSigningSecret, - fileManager: fileManager + approvalSigningSecret: approvalSigningSecret ) } @@ -1366,20 +1357,10 @@ extension Workspace { let canAttemptLocalBindingResume = effectiveResumeBindingForStartup?.launchFlavor == .local && !restoresRemoteWorkspaceTerminalSnapshot - let candidateSavedWorkingDirectory = (canAttemptLocalBindingResume ? resumeBinding?.cwd : nil) - ?? (restoresUntrustedSavedDirectory ? nil : snapshot.terminal?.workingDirectory) - ?? (restoresUntrustedSavedDirectory ? nil : restorableAgent?.workingDirectory) - ?? (restoresUntrustedSavedDirectory ? nil : snapshot.directory) - let candidateWorkingDirectory = candidateSavedWorkingDirectory - ?? currentDirectory - let candidateBindingWorkingDirectory = effectiveResumeBindingForStartup?.cwd - ?? candidateWorkingDirectory let unresolvedBindingLaunch: SurfaceResumeStartupLaunch? = if canAttemptLocalBindingResume, let effectiveResumeBindingForStartup { sessionRestorePolicy.surfaceResumeStartupLaunch( - forApprovedBinding: effectiveResumeBindingForStartup, - allowLauncherScript: true, - restoringWorkingDirectory: candidateBindingWorkingDirectory + forApprovedBinding: effectiveResumeBindingForStartup ) } else { nil @@ -1456,8 +1437,7 @@ extension Workspace { && !agentSessionAlreadyActive { if restoresRemoteWorkspaceTerminalSnapshot { restorableAgent?.resumeStartupInput( - allowLauncherScript: false, - allowOversizedInlineInput: true, + useLocalRestoreVerb: false, restoringWorkingDirectory: resumeSessionWorkingDirectory ) .map(SurfaceResumeStartupLaunch.input) @@ -2755,9 +2735,8 @@ final class Workspace: Identifiable, ObservableObject { } } - nonisolated static func makeSessionRestorePolicyService( - temporaryDirectory: URL = FileManager.default.temporaryDirectory - ) -> WorkspaceSessionRestorePolicyService { + nonisolated static func makeSessionRestorePolicyService() + -> WorkspaceSessionRestorePolicyService { WorkspaceSessionRestorePolicyService( applyStoredApproval: { binding, fileURL, signingSecret in switch SurfaceResumeApprovalStore.applyingStoredApprovalLookup( @@ -2790,8 +2769,7 @@ final class Workspace: Identifiable, ObservableObject { resolvingDefaultCodexModel: { environment in HermesAgentCodexEnvironment.defaultCodexModel(environment: environment) } - ), - temporaryDirectory: temporaryDirectory + ) ) } @@ -5084,6 +5062,18 @@ final class Workspace: Identifiable, ObservableObject { !startupInput.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return false } + // This transient cwd belongs to the binding restored at launch. Let a + // same-session hook refresh keep its cwd rescue, but never let it + // override a replacement session's structured restore record. + if let previous = surfaceResumeBindingsByPanelId[panelId], + previous.kind != binding.kind + || previous.checkpointId != binding.checkpointId + || previous.cwd != binding.cwd + || previous.launchCommand?.workingDirectory != binding.launchCommand?.workingDirectory + || (previous.launchCommand == nil && binding.launchCommand == nil + && previous.command != binding.command) { + restoredResumeSessionWorkingDirectoriesByPanelId.removeValue(forKey: panelId) + } surfaceResumeBindingsByPanelId[panelId] = binding return true } diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index cc24bcdfbb3b..bf7c612fa9e7 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -650,6 +650,12 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources B9000054A1B2C3D4E5F60719 /* CMUXCLI+Process.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000055A1B2C3D4E5F60719 /* CMUXCLI+Process.swift */; }; C73660010000000000000001 /* CMUXCLI+RemotePTYErrors.swift in Sources */ = {isa = PBXBuildFile; fileRef = C73660010000000000000002 /* CMUXCLI+RemotePTYErrors.swift */; }; REE0CA0000000000000000C2 /* CMUXCLI+Remotes.swift in Sources */ = {isa = PBXBuildFile; fileRef = REE0CA0000000000000000C1 /* CMUXCLI+Remotes.swift */; }; + 925800000000000000000002 /* CMUXCLI+Restore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 925800000000000000000001 /* CMUXCLI+Restore.swift */; }; + 92580000000000000000000B /* CMUXCLI+RestoreExecution.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92580000000000000000000C /* CMUXCLI+RestoreExecution.swift */; }; + 925800000000000000000009 /* CMUXCLI+RestoreFailureReporting.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92580000000000000000000A /* CMUXCLI+RestoreFailureReporting.swift */; }; + 925800000000000000000003 /* CMUXCLI+RestorePreflight.swift in Sources */ = {isa = PBXBuildFile; fileRef = 925800000000000000000004 /* CMUXCLI+RestorePreflight.swift */; }; + 925800000000000000000007 /* CMUXCLI+RestoreRecord.swift in Sources */ = {isa = PBXBuildFile; fileRef = 925800000000000000000008 /* CMUXCLI+RestoreRecord.swift */; }; + 925800000000000000000005 /* CMUXCLI+RestoreSelector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 925800000000000000000006 /* CMUXCLI+RestoreSelector.swift */; }; 888222D96880953F33F554F1 /* CMUXCLI+RovoDevHooks.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF43B7BD28A755A6280428D6 /* CMUXCLI+RovoDevHooks.swift */; }; C0DE64950000000000000001 /* CMUXCLI+SessionsList.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE64950000000000000002 /* CMUXCLI+SessionsList.swift */; }; C0DE64960000000000000005 /* CMUXCLI+SessionsListClaudeWorkflow.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE64960000000000000006 /* CMUXCLI+SessionsListClaudeWorkflow.swift */; }; @@ -3287,6 +3293,12 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = B9000055A1B2C3D4E5F60719 /* CMUXCLI+Process.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+Process.swift"; sourceTree = ""; }; C73660010000000000000002 /* CMUXCLI+RemotePTYErrors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+RemotePTYErrors.swift"; sourceTree = ""; }; REE0CA0000000000000000C1 /* CMUXCLI+Remotes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+Remotes.swift"; sourceTree = ""; }; + 925800000000000000000001 /* CMUXCLI+Restore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+Restore.swift"; sourceTree = ""; }; + 92580000000000000000000C /* CMUXCLI+RestoreExecution.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+RestoreExecution.swift"; sourceTree = ""; }; + 92580000000000000000000A /* CMUXCLI+RestoreFailureReporting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+RestoreFailureReporting.swift"; sourceTree = ""; }; + 925800000000000000000004 /* CMUXCLI+RestorePreflight.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+RestorePreflight.swift"; sourceTree = ""; }; + 925800000000000000000008 /* CMUXCLI+RestoreRecord.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+RestoreRecord.swift"; sourceTree = ""; }; + 925800000000000000000006 /* CMUXCLI+RestoreSelector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+RestoreSelector.swift"; sourceTree = ""; }; DF43B7BD28A755A6280428D6 /* CMUXCLI+RovoDevHooks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+RovoDevHooks.swift"; sourceTree = ""; }; C0DE64950000000000000002 /* CMUXCLI+SessionsList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+SessionsList.swift"; sourceTree = ""; }; C0DE64960000000000000006 /* CMUXCLI+SessionsListClaudeWorkflow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+SessionsListClaudeWorkflow.swift"; sourceTree = ""; }; @@ -7176,6 +7188,12 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = B9000031A1B2C3D4E5F60719 /* CMUXCLI+DocsSettings.swift */, B900002DA1B2C3D4E5F60719 /* CMUXCLI+ThemeSupport.swift */, B90000D1A1B2C3D4E5F60719 /* CMUXCLI+CommandSuggestions.swift */, + 925800000000000000000001 /* CMUXCLI+Restore.swift */, + 925800000000000000000004 /* CMUXCLI+RestorePreflight.swift */, + 92580000000000000000000A /* CMUXCLI+RestoreFailureReporting.swift */, + 92580000000000000000000C /* CMUXCLI+RestoreExecution.swift */, + 925800000000000000000008 /* CMUXCLI+RestoreRecord.swift */, + 925800000000000000000006 /* CMUXCLI+RestoreSelector.swift */, C73660010000000000000002 /* CMUXCLI+RemotePTYErrors.swift */, C77070000000000000000009 /* CMUXCLI+SSHPTYAttachBridge.swift */, B900002CA1B2C3D4E5F60719 /* CMUXCLI+Themes.swift */, @@ -10060,6 +10078,12 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = B9000054A1B2C3D4E5F60719 /* CMUXCLI+Process.swift in Sources */, C73660010000000000000001 /* CMUXCLI+RemotePTYErrors.swift in Sources */, REE0CA0000000000000000C2 /* CMUXCLI+Remotes.swift in Sources */, + 925800000000000000000002 /* CMUXCLI+Restore.swift in Sources */, + 92580000000000000000000B /* CMUXCLI+RestoreExecution.swift in Sources */, + 925800000000000000000009 /* CMUXCLI+RestoreFailureReporting.swift in Sources */, + 925800000000000000000003 /* CMUXCLI+RestorePreflight.swift in Sources */, + 925800000000000000000007 /* CMUXCLI+RestoreRecord.swift in Sources */, + 925800000000000000000005 /* CMUXCLI+RestoreSelector.swift in Sources */, 888222D96880953F33F554F1 /* CMUXCLI+RovoDevHooks.swift in Sources */, C0DE64950000000000000001 /* CMUXCLI+SessionsList.swift in Sources */, C0DE64960000000000000005 /* CMUXCLI+SessionsListClaudeWorkflow.swift in Sources */, diff --git a/cmuxTests/AgentResumeReturnShellStartupTests.swift b/cmuxTests/AgentResumeReturnShellStartupTests.swift index 5c7375cc4e2f..e5ccdc1054c9 100644 --- a/cmuxTests/AgentResumeReturnShellStartupTests.swift +++ b/cmuxTests/AgentResumeReturnShellStartupTests.swift @@ -1,4 +1,4 @@ -import Darwin +import CMUXAgentLaunch import Foundation import Testing @@ -10,75 +10,87 @@ import Testing @Suite("Agent resume return shell startup") struct AgentResumeReturnShellStartupTests { - @Test("local resume input is one history-hidden wrapper invocation") - func localResumeInputUsesOneWrapperInvocation() throws { - let fileManager = FileManager.default - let root = fileManager.temporaryDirectory - .appendingPathComponent("cmux-9200-input-\(UUID().uuidString)", isDirectory: true) - try fileManager.createDirectory(at: root, withIntermediateDirectories: true) - defer { try? fileManager.removeItem(at: root) } + @Test("local resume input is one short readable CLI command") + func localResumeInputUsesRestoreVerb() { + let sessionID = "019dad34-d218-7943-b81a-eddac5c87951" + let agentBinding = SurfaceResumeBindingSnapshot( + kind: "codex", + command: "codex resume \(sessionID) \(String(repeating: "--config x=y ", count: 200))", + checkpointId: sessionID, + source: "agent-hook", + autoResume: true + ) + let manualBinding = SurfaceResumeBindingSnapshot( + name: "CLI binding", + command: "printf done >/dev/null # \(String(repeating: "x", count: 4_000))", + source: "cli", + autoResume: true + ) + let snapshot = SessionRestorableAgentSnapshot( + kind: .codex, + sessionId: sessionID, + workingDirectory: "/tmp/项目 with spaces", + launchCommand: AgentLaunchCommandSnapshot( + launcher: "codex", + executablePath: "/Users/example/.bun/bin/codex", + arguments: [ + "/Users/example/.bun/bin/codex", + "--add-dir", + "quote' and 日本語", + String(repeating: "nested-path-", count: 200), + ], + workingDirectory: "/tmp/项目 with spaces" + ) + ) - let bindings = [ - SurfaceResumeBindingSnapshot( - kind: "codex", - command: ":", - source: "agent-hook", - autoResume: true + #expect( + agentBinding.restoreStartupInput( + repairPortableAgentExecutable: true + ) + == " cmux restore codex \(sessionID)\n" + ) + #expect( + manualBinding.restoreStartupInput( + repairPortableAgentExecutable: true + ) + == " cmux restore --surface\n" + ) + #expect( + snapshot.resumeStartupInput() + == " cmux restore codex \(sessionID)\n" + ) + } + + @Test("unsafe identifiers use the ASCII-only surface selector") + func unsafeIdentifiersUseSurfaceSelector() { + let snapshots = [ + SessionRestorableAgentSnapshot( + kind: .custom("代理 agent"), + sessionId: "会話 'one'" ), - SurfaceResumeBindingSnapshot( - name: "Short CLI binding", - command: ":", - source: "cli", - autoResume: true + SessionRestorableAgentSnapshot( + kind: .custom("-beta"), + sessionId: "checkpoint" ), - SurfaceResumeBindingSnapshot( - name: "Long CLI binding", - command: "printf done >/dev/null # \(String(repeating: "x", count: 1_200))", - source: "cli", - autoResume: true + SessionRestorableAgentSnapshot( + kind: .custom("agent"), + sessionId: "--checkpoint" ), ] - for binding in bindings { - let input = try #require(binding.startupInputWithLauncherScript( - fileManager: fileManager, - temporaryDirectory: root - )) - try expectLauncherInvocation(input) - } - - for extraArgument in ["short", String(repeating: "nested-path-", count: 120)] { - let snapshot = SessionRestorableAgentSnapshot( - kind: .codex, - sessionId: "019dad34-d218-7943-b81a-eddac5c87951", - workingDirectory: root.path, - launchCommand: AgentLaunchCommandSnapshot( - launcher: "codex", - executablePath: "/Users/example/.bun/bin/codex", - arguments: [ - "/Users/example/.bun/bin/codex", - "--add-dir", - extraArgument, - ], - workingDirectory: root.path, - environment: nil, - capturedAt: 123, - source: "environment" - ) + for snapshot in snapshots { + #expect( + snapshot.resumeStartupInput() + == " \(AgentRestoreLaunch.cliStartupExecutableToken) restore --surface\n" ) - let input = try #require(snapshot.resumeStartupInput( - fileManager: fileManager, - temporaryDirectory: root - )) - try expectLauncherInvocation(input) } } - @Test("one-shot launcher is private, self-deleting, and TTL-pruned") - func oneShotLauncherStoragePolicy() throws { + @Test("non-restore one-shot launchers retain their storage policy") + func nonRestoreOneShotLauncherStoragePolicy() throws { let fileManager = FileManager.default let root = fileManager.temporaryDirectory - .appendingPathComponent("cmux-9200-store-\(UUID().uuidString)", isDirectory: true) + .appendingPathComponent("cmux-9258-store-\(UUID().uuidString)", isDirectory: true) let launcherDirectory = root.appendingPathComponent("cmux-r", isDirectory: true) let staleLauncher = launcherDirectory.appendingPathComponent("stale.zsh", isDirectory: false) let currentLauncher = launcherDirectory.appendingPathComponent("current.zsh", isDirectory: false) @@ -115,302 +127,5 @@ struct AgentResumeReturnShellStartupTests { ).intValue & 0o777 #expect(directoryMode == 0o700) #expect(launcherMode == 0o600) - - let process = Process() - process.executableURL = URL(fileURLWithPath: "/bin/zsh") - process.arguments = [launcher.path] - try process.run() - process.waitUntilExit() - #expect(process.terminationStatus == 0) - #expect(!fileManager.fileExists(atPath: launcher.path)) - } - - @Test("launcher cwd guard preserves present, missing, and inaccessible outcomes") - func launcherWorkingDirectoryOutcomes() throws { - let fileManager = FileManager.default - let root = fileManager.temporaryDirectory - .appendingPathComponent("cmux-9200-cwd-\(UUID().uuidString)", isDirectory: true) - let presentDirectory = root.appendingPathComponent("present", isDirectory: true) - let missingDirectory = root.appendingPathComponent("missing", isDirectory: true) - let inaccessibleDirectory = root.appendingPathComponent("inaccessible", isDirectory: true) - let nestedInaccessibleDirectory = inaccessibleDirectory.appendingPathComponent("child", isDirectory: true) - try fileManager.createDirectory(at: presentDirectory, withIntermediateDirectories: true) - try fileManager.createDirectory(at: nestedInaccessibleDirectory, withIntermediateDirectories: true) - try fileManager.setAttributes([.posixPermissions: 0o000], ofItemAtPath: inaccessibleDirectory.path) - defer { - try? fileManager.setAttributes([.posixPermissions: 0o700], ofItemAtPath: inaccessibleDirectory.path) - try? fileManager.removeItem(at: root) - } - - let presentOutput = root.appendingPathComponent("present.txt", isDirectory: false) - let presentInput = try launcherInput( - command: "pwd > \(TerminalStartupShellQuoting.singleQuoted(presentOutput.path))", - workingDirectory: presentDirectory.path, - root: root - ) - try expectLauncherInvocation(presentInput) - let presentResult = try runShellInput(presentInput, currentDirectory: root) - #expect(presentResult.status == 0, Comment(rawValue: presentResult.stderr)) - let presentCwd = try #require( - String(bytes: Data(contentsOf: presentOutput), encoding: .utf8) - ) - #expect( - presentCwd.trimmingCharacters(in: .whitespacesAndNewlines) == presentDirectory.path - ) - - let missingOutput = root.appendingPathComponent("missing.txt", isDirectory: false) - let missingInput = try launcherInput( - command: "pwd > \(TerminalStartupShellQuoting.singleQuoted(missingOutput.path))", - workingDirectory: missingDirectory.path, - root: root - ) - try expectLauncherInvocation(missingInput) - let missingResult = try runShellInput(missingInput, currentDirectory: root) - #expect(missingResult.status == 0, Comment(rawValue: missingResult.stderr)) - let missingCwd = try #require( - String(bytes: Data(contentsOf: missingOutput), encoding: .utf8) - ) - #expect( - missingCwd.trimmingCharacters(in: .whitespacesAndNewlines) == - root.resolvingSymlinksInPath().path - ) - - if getuid() != 0 { - let inaccessibleOutput = root.appendingPathComponent("inaccessible.txt", isDirectory: false) - let inaccessibleInput = try launcherInput( - command: "print ran > \(TerminalStartupShellQuoting.singleQuoted(inaccessibleOutput.path))", - workingDirectory: inaccessibleDirectory.path, - root: root - ) - try expectLauncherInvocation(inaccessibleInput) - let inaccessibleResult = try runShellInput(inaccessibleInput, currentDirectory: root) - #expect(inaccessibleResult.status != 0) - #expect(!fileManager.fileExists(atPath: inaccessibleOutput.path)) - - let nestedInaccessibleOutput = root.appendingPathComponent("nested-inaccessible.txt", isDirectory: false) - let nestedInaccessibleInput = try launcherInput( - command: "print ran > \(TerminalStartupShellQuoting.singleQuoted(nestedInaccessibleOutput.path))", - workingDirectory: nestedInaccessibleDirectory.path, - root: root - ) - try expectLauncherInvocation(nestedInaccessibleInput) - let nestedInaccessibleResult = try runShellInput(nestedInaccessibleInput, currentDirectory: root) - #expect(nestedInaccessibleResult.status != 0) - #expect(!fileManager.fileExists(atPath: nestedInaccessibleOutput.path)) - } - } - - @Test("pre-change hook and CLI bindings with an outside cwd prefix still resume") - func legacyOutsideScriptWorkingDirectoryPrefixStillResumes() throws { - let fileManager = FileManager.default - let root = fileManager.temporaryDirectory - .appendingPathComponent("cmux-9200-legacy-\(UUID().uuidString)", isDirectory: true) - let workingDirectory = root.appendingPathComponent("legacy project", isDirectory: true) - try fileManager.createDirectory(at: workingDirectory, withIntermediateDirectories: true) - defer { try? fileManager.removeItem(at: root) } - - let quotedDirectory = TerminalStartupShellQuoting.singleQuoted(workingDirectory.path) - for source in ["agent-hook", "cli"] { - let output = root.appendingPathComponent("\(source).txt", isDirectory: false) - let quotedOutput = TerminalStartupShellQuoting.singleQuoted(output.path) - let encoded = try JSONSerialization.data(withJSONObject: [ - "kind": "codex", - "command": "cd -- \(quotedDirectory) 2>/dev/null || [ ! -d \(quotedDirectory) ] && pwd > \(quotedOutput)", - "cwd": workingDirectory.path, - "source": source, - "autoResume": true, - ]) - let binding = try JSONDecoder().decode(SurfaceResumeBindingSnapshot.self, from: encoded) - let input = try #require(binding.startupInputWithLauncherScript( - fileManager: fileManager, - temporaryDirectory: root - )) - - try expectLauncherInvocation(input) - let result = try runShellInput(input, currentDirectory: root) - #expect(result.status == 0, Comment(rawValue: "\(source): \(result.stderr)")) - let restoredCwd = try #require( - String(bytes: Data(contentsOf: output), encoding: .utf8) - ) - #expect( - restoredCwd.trimmingCharacters(in: .whitespacesAndNewlines) == workingDirectory.path - ) - } - } - - @Test("auto-resume returns to the normally initialized login shell") - func autoResumeReturnsToNormallyInitializedLoginShell() throws { - let fileManager = FileManager.default - let root = fileManager.temporaryDirectory - .appendingPathComponent("cmux-8837-return-shell-\(UUID().uuidString)", isDirectory: true) - let home = root.appendingPathComponent("home", isDirectory: true) - let workingDirectory = root.appendingPathComponent("project", isDirectory: true) - let historyURL = home.appendingPathComponent(".zsh_history", isDirectory: false) - try fileManager.createDirectory(at: home, withIntermediateDirectories: true) - try fileManager.createDirectory(at: workingDirectory, withIntermediateDirectories: true) - defer { try? fileManager.removeItem(at: root) } - - try """ - export CMUX_8837_ZPROFILE_COUNT=$(( ${CMUX_8837_ZPROFILE_COUNT:-0} + 1 )) - export CMUX_8837_ZPROFILE=loaded - - """ - .write(to: home.appendingPathComponent(".zprofile"), atomically: true, encoding: .utf8) - try """ - export CMUX_8837_ZSHRC_COUNT=$(( ${CMUX_8837_ZSHRC_COUNT:-0} + 1 )) - alias cmux_8837_alias='print alias-loaded' - HISTFILE=\(TerminalStartupShellQuoting.singleQuoted(historyURL.path)) - HISTSIZE=100 - SAVEHIST=100 - setopt HIST_IGNORE_SPACE - - """ - .write(to: home.appendingPathComponent(".zshrc"), atomically: true, encoding: .utf8) - try """ - export CMUX_8837_ZLOGIN_COUNT=$(( ${CMUX_8837_ZLOGIN_COUNT:-0} + 1 )) - - """ - .write(to: home.appendingPathComponent(".zlogin"), atomically: true, encoding: .utf8) - - let binding = SurfaceResumeBindingSnapshot( - kind: "codex", - command: ":", - cwd: workingDirectory.path, - source: "agent-hook", - autoResume: true - ) - let launch = try #require(Workspace.surfaceResumeStartupLaunch( - binding, - autoResumeAgentSessions: true, - approvalStoreURL: root.appendingPathComponent("approvals.json"), - fileManager: fileManager, - temporaryDirectory: root - )) - - let process = Process() - process.executableURL = URL(fileURLWithPath: "/bin/zsh") - process.arguments = ["-li"] - process.currentDirectoryURL = workingDirectory - let startupInput = launch.initialInput - try expectLauncherInvocation(startupInput) - process.environment = [ - "HOME": home.path, - "LOGNAME": NSUserName(), - "PATH": "/usr/bin:/bin", - "SHELL": "/bin/zsh", - "TERM": "dumb", - "USER": NSUserName(), - "ZDOTDIR": home.path, - ] - - let input = Pipe() - let output = Pipe() - let error = Pipe() - process.standardInput = input - process.standardOutput = output - process.standardError = error - - try process.run() - input.fileHandleForWriting.write(Data((startupInput + """ - if [[ -o interactive ]]; then print -r -- interactive=yes; else print -r -- interactive=no; fi - if [[ -o login ]]; then print -r -- login=yes; else print -r -- login=no; fi - print -r -- "profile=${CMUX_8837_ZPROFILE:-missing}" - print -r -- "zprofile_count=${CMUX_8837_ZPROFILE_COUNT:-0}" - print -r -- "zshrc_count=${CMUX_8837_ZSHRC_COUNT:-0}" - print -r -- "zlogin_count=${CMUX_8837_ZLOGIN_COUNT:-0}" - print -r -- "cwd=$PWD" - if (( $+aliases[cmux_8837_alias] )); then print -r -- alias=present; else print -r -- alias=missing; fi - print -r -- history-control >/dev/null - fc -W "$HISTFILE" - exit - - """).utf8)) - try input.fileHandleForWriting.close() - process.waitUntilExit() - - let stdout = String( - data: output.fileHandleForReading.readDataToEndOfFile(), - encoding: .utf8 - ) ?? "" - let stderr = String( - data: error.fileHandleForReading.readDataToEndOfFile(), - encoding: .utf8 - ) ?? "" - let diagnostic = "stdout=\(stdout) stderr=\(stderr)" - - #expect(process.terminationStatus == 0, Comment(rawValue: diagnostic)) - #expect(stdout.contains("interactive=yes"), Comment(rawValue: diagnostic)) - #expect(stdout.contains("login=yes"), Comment(rawValue: diagnostic)) - #expect(stdout.contains("profile=loaded"), Comment(rawValue: diagnostic)) - #expect(stdout.contains("zprofile_count=1"), Comment(rawValue: diagnostic)) - #expect(stdout.contains("zshrc_count=1"), Comment(rawValue: diagnostic)) - #expect(stdout.contains("zlogin_count=1"), Comment(rawValue: diagnostic)) - #expect( - stdout.contains("cwd=\(workingDirectory.resolvingSymlinksInPath().path)"), - Comment(rawValue: diagnostic) - ) - #expect(stdout.contains("alias=present"), Comment(rawValue: diagnostic)) - let history = try #require( - String(bytes: Data(contentsOf: historyURL), encoding: .utf8) - ) - #expect(history.contains("history-control"), Comment(rawValue: history)) - #expect(!history.contains(root.path), Comment(rawValue: history)) - } - - private func launcherInput(command: String, workingDirectory: String, root: URL) throws -> String { - let binding = SurfaceResumeBindingSnapshot( - kind: "codex", - command: command, - cwd: workingDirectory, - source: "agent-hook", - autoResume: true - ) - return try #require(binding.startupInputWithLauncherScript( - fileManager: .default, - temporaryDirectory: root - )) - } - - private func expectLauncherInvocation( - _ input: String, - sourceLocation: SourceLocation = #_sourceLocation - ) throws { - #expect(input.first == " ", "Injected resume input must opt into HIST_IGNORE_SPACE", sourceLocation: sourceLocation) - let commandLine = input.trimmingCharacters(in: .whitespacesAndNewlines) - let words = TerminalStartupWorkingDirectoryPrefix.shellWordRanges(commandLine).map(\.value) - #expect(words.count == 2, "Expected one interpreter + launcher invocation, saw: \(input)", sourceLocation: sourceLocation) - #expect(words.first == "/bin/zsh", "Expected the established zsh wrapper, saw: \(input)", sourceLocation: sourceLocation) - let path = try #require(words.dropFirst().first, sourceLocation: sourceLocation) - #expect(path.hasPrefix("/"), "Expected an absolute launcher path, saw: \(input)", sourceLocation: sourceLocation) - for shellOperator in ["cd ", "&&", "||", "{", "}", ";"] { - #expect( - !commandLine.contains(shellOperator), - "Launcher input contains shell syntax '\(shellOperator)': \(input)", - sourceLocation: sourceLocation - ) - } - } - - private func runShellInput( - _ input: String, - currentDirectory: URL - ) throws -> (status: Int32, stderr: String) { - let process = Process() - process.executableURL = URL(fileURLWithPath: "/bin/zsh") - process.arguments = ["-fc", input] - process.currentDirectoryURL = currentDirectory - let error = Pipe() - process.standardOutput = FileHandle.nullDevice - process.standardError = error - - try process.run() - process.waitUntilExit() - return ( - process.terminationStatus, - String( - data: error.fileHandleForReading.readDataToEndOfFile(), - encoding: .utf8 - ) ?? "" - ) } } diff --git a/cmuxTests/AgentSessionAutoResumeSettingsTests.swift b/cmuxTests/AgentSessionAutoResumeSettingsTests.swift index 3add3387ff50..a7a86630def1 100644 --- a/cmuxTests/AgentSessionAutoResumeSettingsTests.swift +++ b/cmuxTests/AgentSessionAutoResumeSettingsTests.swift @@ -316,7 +316,7 @@ final class AgentSessionAutoResumeSettingsTests: XCTestCase { let restoredInput = try XCTUnwrap(restoredPanel.surface.initialInput) XCTAssertEqual(restoredPanel.surface.debugInitialCommand(), restored.remoteConfiguration?.terminalStartupCommand) - XCTAssertGreaterThan(restoredInput.utf8.count, SessionRestorableAgentSnapshot.maxInlineStartupInputBytes) + XCTAssertGreaterThan(restoredInput.utf8.count, 900) XCTAssertTrue(restoredInput.contains("'resume'"), restoredInput) XCTAssertTrue(restoredInput.contains("codex-remote-long-running-session"), restoredInput) XCTAssertTrue(restoredInput.contains(longPath), restoredInput) diff --git a/cmuxTests/AppDelegateIssue2907RoutingTests.swift b/cmuxTests/AppDelegateIssue2907RoutingTests.swift index 05e80ca75a4a..cdec793fc8d3 100644 --- a/cmuxTests/AppDelegateIssue2907RoutingTests.swift +++ b/cmuxTests/AppDelegateIssue2907RoutingTests.swift @@ -764,6 +764,18 @@ final class AppDelegateIssue2907RoutingTests: XCTestCase { XCTAssertEqual(setEnvironment["SPACED"] as? String, " keep exact ") XCTAssertNil(setEnvironment["ANTHROPIC_API_KEY"]) XCTAssertEqual(setBinding["auto_resume"] as? Bool, false) + workspace.restoredAgentSnapshotsByPanelId[panelId] = + SessionRestorableAgentSnapshot( + kind: .codex, + sessionId: UUID().uuidString.lowercased(), + workingDirectory: "/tmp/stale-agent", + launchCommand: AgentLaunchCommandSnapshot( + launcher: "codex", + executablePath: "/opt/stale/codex", + arguments: ["/opt/stale/codex"], + workingDirectory: "/tmp/stale-agent" + ) + ) let getResult = try v2Result( method: "surface.resume.get", @@ -779,6 +791,387 @@ final class AppDelegateIssue2907RoutingTests: XCTestCase { XCTAssertEqual(getEnvironment["SPACED"] as? String, " keep exact ") XCTAssertNil(getEnvironment["ANTHROPIC_API_KEY"]) XCTAssertEqual(getBinding["auto_resume"] as? Bool, false) + let restoreRecord = try XCTUnwrap(getResult["restore_record"] as? [String: Any]) + XCTAssertEqual(restoreRecord["mode"] as? String, "direct") + XCTAssertEqual(restoreRecord["kind"] as? String, "command") + XCTAssertNil(restoreRecord["launch_command"] as? [String: Any]) + let legacyCommand = try XCTUnwrap(restoreRecord["legacy_command"] as? String) + XCTAssertTrue(legacyCommand.contains("tmux attach -t dogfood"), legacyCommand) + XCTAssertTrue(legacyCommand.contains("SPACED= keep exact "), legacyCommand) + } + + func testSurfaceRestoreRecordBootstrapsCommandOnlyLocalHermesBinding() throws { + _ = NSApplication.shared + let previousAppDelegate = AppDelegate.shared + let app = AppDelegate() + defer { + AppDelegate.shared = previousAppDelegate + } + + let windowId = UUID() + let window = makeMainWindow(id: windowId) + defer { + TerminalController.shared.setActiveTabManager(nil) + app.unregisterMainWindowContextForTesting(windowId: windowId) + window.orderOut(nil) + } + + let manager = TabManager(autoWelcomeIfNeeded: false) + app.registerMainWindow( + window, + windowId: windowId, + tabManager: manager, + sidebarState: SidebarState(), + sidebarSelectionState: SidebarSelectionState(), + fileExplorerState: FileExplorerState() + ) + TerminalController.shared.setActiveTabManager(manager) + + let workspace = try XCTUnwrap(manager.selectedWorkspace) + let panelId = try XCTUnwrap(workspace.focusedPanelId) + let checkpointID = UUID().uuidString.lowercased() + XCTAssertTrue(workspace.setSurfaceResumeBinding( + SurfaceResumeBindingSnapshot( + kind: "hermes-agent", + command: "hermes --provider openai-codex --resume \(checkpointID)", + cwd: "/tmp/hermes-legacy", + checkpointId: checkpointID, + source: "agent-hook", + environment: ["CUSTOM_BASE_URL": "https://codex.example.test/v1"], + autoResume: true + ), + panelId: panelId + )) + + let getResult = try v2Result( + method: "surface.resume.get", + params: [ + "window_id": windowId.uuidString, + "workspace_id": workspace.id.uuidString, + "surface_id": panelId.uuidString, + ] + ) + let restoreRecord = try XCTUnwrap(getResult["restore_record"] as? [String: Any]) + XCTAssertEqual(restoreRecord["kind"] as? String, "hermes-agent") + XCTAssertEqual(restoreRecord["checkpoint_id"] as? String, checkpointID) + XCTAssertNil(restoreRecord["launch_command"] as? [String: Any]) + XCTAssertNil(restoreRecord["prepared_arguments"] as? [String]) + let legacyCommand = try XCTUnwrap(restoreRecord["legacy_command"] as? String) + XCTAssertTrue( + legacyCommand.contains("config set model.provider"), + legacyCommand + ) + XCTAssertTrue( + legacyCommand.contains("config set model.base_url") + && legacyCommand.contains("https://codex.example.test/v1"), + legacyCommand + ) + XCTAssertTrue( + legacyCommand.contains("config set model.api_mode"), + legacyCommand + ) + XCTAssertTrue( + legacyCommand.contains("--provider") + && legacyCommand.contains("custom") + && legacyCommand.contains(checkpointID), + legacyCommand + ) + } + + func testSurfaceRestoreRecordPrefersNewerBindingOverStaleRestoredAgent() throws { + _ = NSApplication.shared + let previousAppDelegate = AppDelegate.shared + let app = AppDelegate() + defer { + AppDelegate.shared = previousAppDelegate + } + + let windowId = UUID() + let window = makeMainWindow(id: windowId) + defer { + TerminalController.shared.setActiveTabManager(nil) + app.unregisterMainWindowContextForTesting(windowId: windowId) + window.orderOut(nil) + } + + let manager = TabManager(autoWelcomeIfNeeded: false) + app.registerMainWindow( + window, + windowId: windowId, + tabManager: manager, + sidebarState: SidebarState(), + sidebarSelectionState: SidebarSelectionState(), + fileExplorerState: FileExplorerState() + ) + TerminalController.shared.setActiveTabManager(manager) + + let workspace = try XCTUnwrap(manager.selectedWorkspace) + let panelId = try XCTUnwrap(workspace.focusedPanelId) + let currentSessionID = UUID().uuidString.lowercased() + let currentLaunch = AgentLaunchCommandSnapshot( + launcher: "codex", + executablePath: "/opt/current/codex", + arguments: ["/opt/current/codex", "--model", "gpt-current"], + workingDirectory: "/tmp/current", + environment: [ + "CODEX_HOME": "/tmp/current-codex-home", + "OPENAI_API_KEY": "must-not-cross-socket", + ] + ) + XCTAssertTrue(workspace.setSurfaceResumeBinding( + SurfaceResumeBindingSnapshot( + kind: "codex", + command: "codex resume \(currentSessionID)", + cwd: "/tmp/current", + checkpointId: currentSessionID, + source: "agent-hook", + launchCommand: currentLaunch, + autoResume: true + ), + panelId: panelId + )) + + let staleSessionID = UUID().uuidString.lowercased() + workspace.restoredAgentSnapshotsByPanelId[panelId] = SessionRestorableAgentSnapshot( + kind: .codex, + sessionId: staleSessionID, + workingDirectory: "/tmp/stale", + launchCommand: AgentLaunchCommandSnapshot( + launcher: "codex", + executablePath: "/opt/stale/codex", + arguments: ["/opt/stale/codex", "--model", "gpt-stale"], + workingDirectory: "/tmp/stale", + environment: [ + "CODEX_HOME": "/tmp/stale-codex-home", + "OPENAI_API_KEY": "must-not-cross-socket", + ] + ) + ) + + let getResult = try v2Result( + method: "surface.resume.get", + params: [ + "window_id": windowId.uuidString, + "workspace_id": workspace.id.uuidString, + "surface_id": panelId.uuidString, + ] + ) + let restoreRecord = try XCTUnwrap(getResult["restore_record"] as? [String: Any]) + XCTAssertEqual(restoreRecord["kind"] as? String, "codex") + XCTAssertEqual(restoreRecord["checkpoint_id"] as? String, currentSessionID) + XCTAssertEqual(restoreRecord["source"] as? String, "agent-hook") + XCTAssertEqual(restoreRecord["working_directory"] as? String, "/tmp/current") + XCTAssertEqual( + restoreRecord["prepared_arguments_working_directory"] as? String, + "/tmp/current" + ) + let launch = try XCTUnwrap(restoreRecord["launch_command"] as? [String: Any]) + XCTAssertEqual(launch["arguments"] as? [String], currentLaunch.arguments) + let launchEnvironment = try XCTUnwrap(launch["environment"] as? [String: Any]) + XCTAssertEqual( + launchEnvironment["CODEX_HOME"] as? String, + "/tmp/current-codex-home" + ) + XCTAssertNil(launchEnvironment["OPENAI_API_KEY"]) + let resumeBinding = try XCTUnwrap(getResult["resume_binding"] as? [String: Any]) + let resumeLaunch = try XCTUnwrap(resumeBinding["launch_command"] as? [String: Any]) + let resumeLaunchEnvironment = try XCTUnwrap( + resumeLaunch["environment"] as? [String: Any] + ) + XCTAssertEqual( + resumeLaunchEnvironment["CODEX_HOME"] as? String, + "/tmp/current-codex-home" + ) + XCTAssertNil(resumeLaunchEnvironment["OPENAI_API_KEY"]) + let legacyCommand = try XCTUnwrap(restoreRecord["legacy_command"] as? String) + XCTAssertTrue(legacyCommand.contains("codex resume \(currentSessionID)")) + + let ompSessionID = UUID().uuidString.lowercased() + XCTAssertTrue(workspace.setSurfaceResumeBinding( + SurfaceResumeBindingSnapshot( + kind: "OMP", + command: "omp --session \(ompSessionID)", + checkpointId: ompSessionID, + source: "agent-hook", + launchCommand: AgentLaunchCommandSnapshot( + launcher: "omp", + executablePath: "/opt/current/omp", + arguments: ["/opt/current/omp", "--session", ompSessionID], + environment: [ + "PATH": "/opt/omp/bin:/usr/bin:/bin", + "OPENAI_API_KEY": "must-not-cross-socket", + ] + ), + autoResume: true + ), + panelId: panelId + )) + let ompResult = try v2Result( + method: "surface.resume.get", + params: [ + "window_id": windowId.uuidString, + "workspace_id": workspace.id.uuidString, + "surface_id": panelId.uuidString, + ] + ) + let ompRecord = try XCTUnwrap(ompResult["restore_record"] as? [String: Any]) + let ompLaunch = try XCTUnwrap(ompRecord["launch_command"] as? [String: Any]) + let ompEnvironment = try XCTUnwrap(ompLaunch["environment"] as? [String: Any]) + XCTAssertEqual(ompEnvironment["PATH"] as? String, "/opt/omp/bin:/usr/bin:/bin") + XCTAssertNil(ompEnvironment["OPENAI_API_KEY"]) + + XCTAssertTrue(workspace.clearSurfaceResumeBinding(panelId: panelId)) + let snapshotResult = try v2Result( + method: "surface.resume.get", + params: [ + "window_id": windowId.uuidString, + "workspace_id": workspace.id.uuidString, + "surface_id": panelId.uuidString, + ] + ) + let snapshotRecord = try XCTUnwrap( + snapshotResult["restore_record"] as? [String: Any] + ) + let snapshotLaunch = try XCTUnwrap( + snapshotRecord["launch_command"] as? [String: Any] + ) + let snapshotEnvironment = try XCTUnwrap( + snapshotLaunch["environment"] as? [String: Any] + ) + XCTAssertEqual( + snapshotEnvironment["CODEX_HOME"] as? String, + "/tmp/stale-codex-home" + ) + XCTAssertNil(snapshotEnvironment["OPENAI_API_KEY"]) + } + + func testSurfaceRestoreRecordAppliesBindingEnvironmentAndRestoreTimeCwd() throws { + _ = NSApplication.shared + let previousAppDelegate = AppDelegate.shared + let app = AppDelegate() + defer { + AppDelegate.shared = previousAppDelegate + } + + let windowId = UUID() + let window = makeMainWindow(id: windowId) + defer { + TerminalController.shared.setActiveTabManager(nil) + app.unregisterMainWindowContextForTesting(windowId: windowId) + window.orderOut(nil) + } + + let manager = TabManager(autoWelcomeIfNeeded: false) + app.registerMainWindow( + window, + windowId: windowId, + tabManager: manager, + sidebarState: SidebarState(), + sidebarSelectionState: SidebarSelectionState(), + fileExplorerState: FileExplorerState() + ) + TerminalController.shared.setActiveTabManager(manager) + + let workspace = try XCTUnwrap(manager.selectedWorkspace) + let panelId = try XCTUnwrap(workspace.focusedPanelId) + let sessionID = "cwd-session" + let savedDirectory = "/tmp/saved-project" + let restoredDirectory = "/tmp/restored-project" + let launch = AgentLaunchCommandSnapshot( + launcher: "cwd-agent", + executablePath: "/opt/cwd-agent", + arguments: ["/opt/cwd-agent"], + workingDirectory: savedDirectory, + environment: ["RESTORE_OVERRIDE": "captured"] + ) + XCTAssertTrue(workspace.setSurfaceResumeBinding( + SurfaceResumeBindingSnapshot( + kind: "cwd-agent", + command: "/opt/cwd-agent --cwd \(savedDirectory) --session \(sessionID)", + cwd: savedDirectory, + checkpointId: sessionID, + source: "agent-hook", + environment: ["RESTORE_OVERRIDE": "binding"], + launchCommand: launch, + autoResume: true + ), + panelId: panelId + )) + workspace.restoredAgentSnapshotsByPanelId[panelId] = SessionRestorableAgentSnapshot( + kind: .custom("cwd-agent"), + sessionId: sessionID, + workingDirectory: savedDirectory, + launchCommand: launch, + registration: CmuxVaultAgentRegistration( + id: "cwd-agent", + name: "CWD Agent", + detect: CmuxVaultAgentDetectRule(processName: "cwd-agent"), + sessionIdSource: .argvOption("--session"), + resumeCommand: "{{executable}} --cwd {{cwd}} --session {{sessionId}}", + cwd: .preserve + ) + ) + workspace.restoredResumeSessionWorkingDirectoriesByPanelId[panelId] = + restoredDirectory + + let getResult = try v2Result( + method: "surface.resume.get", + params: [ + "window_id": windowId.uuidString, + "workspace_id": workspace.id.uuidString, + "surface_id": panelId.uuidString, + ] + ) + let restoreRecord = try XCTUnwrap(getResult["restore_record"] as? [String: Any]) + XCTAssertEqual(restoreRecord["working_directory"] as? String, restoredDirectory) + let environment = try XCTUnwrap(restoreRecord["environment"] as? [String: Any]) + XCTAssertEqual(environment["RESTORE_OVERRIDE"] as? String, "binding") + let preparedArguments = try XCTUnwrap( + restoreRecord["prepared_arguments"] as? [String] + ) + XCTAssertTrue(preparedArguments.contains(restoredDirectory), "\(preparedArguments)") + XCTAssertFalse(preparedArguments.contains(savedDirectory), "\(preparedArguments)") + + let replacementSessionID = "replacement-cwd-session" + let replacementDirectory = "/tmp/replacement-project" + let replacementLaunch = AgentLaunchCommandSnapshot( + launcher: "cwd-agent", + executablePath: "/opt/cwd-agent", + arguments: ["/opt/cwd-agent"], + workingDirectory: replacementDirectory + ) + XCTAssertTrue(workspace.setSurfaceResumeBinding( + SurfaceResumeBindingSnapshot( + kind: "cwd-agent", + command: "/opt/cwd-agent --cwd \(replacementDirectory) --session \(replacementSessionID)", + cwd: replacementDirectory, + checkpointId: replacementSessionID, + source: "agent-hook", + launchCommand: replacementLaunch, + autoResume: true + ), + panelId: panelId + )) + + let replacementResult = try v2Result( + method: "surface.resume.get", + params: [ + "window_id": windowId.uuidString, + "workspace_id": workspace.id.uuidString, + "surface_id": panelId.uuidString, + ] + ) + let replacementRecord = try XCTUnwrap( + replacementResult["restore_record"] as? [String: Any] + ) + XCTAssertEqual( + replacementRecord["working_directory"] as? String, + replacementDirectory + ) + XCTAssertNotEqual( + replacementRecord["working_directory"] as? String, + restoredDirectory + ) } func testSurfaceResumeSetCannotEnableAutoResumeFromSocket() throws { diff --git a/cmuxTests/CMUXCLIErrorOutputRegressionTests.swift b/cmuxTests/CMUXCLIErrorOutputRegressionTests.swift index 186f10e38a90..42d218d362ee 100644 --- a/cmuxTests/CMUXCLIErrorOutputRegressionTests.swift +++ b/cmuxTests/CMUXCLIErrorOutputRegressionTests.swift @@ -90,6 +90,548 @@ import Testing #expect(params["surface_id"] == nil) } + @Test func testRestoreExecutesStructuredArgvEnvironmentAndCwdDirectly() throws { + let cliPath = try bundledCLIPath() + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux restore 项目 'space' \(UUID().uuidString)", isDirectory: true) + let workingDirectory = root.appendingPathComponent("工作 dir", isDirectory: true) + let executable = root.appendingPathComponent("fake agent", isDirectory: false) + try FileManager.default.createDirectory(at: workingDirectory, withIntermediateDirectories: true) + try """ + #!/bin/sh + printf 'pwd=%s\\n' "$PWD" + printf 'env=%s\\n' "$RESTORE_VALUE" + for argument in "$@"; do + printf 'arg=%s\\n' "$argument" + done + """.write(to: executable, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: executable.path) + defer { try? FileManager.default.removeItem(at: root) } + + let checkpointID = "checkpoint-\(UUID().uuidString)" + let arguments = [executable.path, "space value", "quote'\"", "日本語", String(repeating: "x", count: 4_000)] + let surfaceID = UUID().uuidString.lowercased() + let workspaceID = UUID().uuidString.lowercased() + let response = try jsonResponse(result: [ + "terminals": [[ + "tty": "ttys9258", + "workspace_id": workspaceID, + "surface_id": surfaceID, + ]], + "restore_record": [ + "mode": "direct", + "kind": "custom", + "checkpoint_id": checkpointID, + "source": "test", + "working_directory": workingDirectory.path, + "environment": ["RESTORE_VALUE": "値 with spaces"], + "launch_command": [ + "arguments": arguments, + "executable_path": executable.path, + "working_directory": workingDirectory.path, + "environment": ["RESTORE_VALUE": "値 with spaces"], + ], + "prepared_arguments": arguments, + ], + ]) + let socketPath = "/tmp/cmux-restore-\(UUID().uuidString.prefix(8)).sock" + let responder = try UnixSocketResponder(path: socketPath, response: response) + defer { responder.stop() } + var environment = ProcessInfo.processInfo.environment + for key in Array(environment.keys) where key.hasPrefix("CMUX_") { + environment.removeValue(forKey: key) + } + environment["CMUX_CLI_SENTRY_DISABLED"] = "1" + environment["CMUX_SOCKET_PATH"] = socketPath + environment["TTY"] = "/dev/ttys9258" + + let result = runProcess( + executablePath: cliPath, + arguments: ["restore", "--surface"], + environment: environment, + timeout: 5 + ) + + XCTAssertFalse(result.timedOut, result.stdout) + XCTAssertEqual(result.status, 0, result.stdout) + XCTAssertTrue(result.stdout.contains("pwd=\(workingDirectory.path)\n"), result.stdout) + XCTAssertTrue(result.stdout.contains("env=値 with spaces\n"), result.stdout) + for argument in arguments.dropFirst() { + XCTAssertTrue(result.stdout.contains("arg=\(argument)\n"), result.stdout) + } + let methods = try responder.receivedRequests.map { request in + let data = try XCTUnwrap(request.data(using: .utf8)) + let object = try XCTUnwrap( + JSONSerialization.jsonObject(with: data) as? [String: Any] + ) + return try XCTUnwrap(object["method"] as? String) + } + XCTAssertEqual(methods, ["debug.terminals", "surface.resume.get"]) + } + + @Test func testRestoreDoesNotResolveBareExecutableFromEmptyPATHComponent() throws { + let cliPath = try bundledCLIPath() + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux restore untrusted cwd \(UUID().uuidString)", isDirectory: true) + let executableName = "restore-agent" + let executable = root.appendingPathComponent(executableName, isDirectory: false) + let marker = root.appendingPathComponent("executed", isDirectory: false) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + try """ + #!/bin/sh + touch \(shellSingleQuote(marker.path)) + """.write(to: executable, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes( + [.posixPermissions: 0o700], + ofItemAtPath: executable.path + ) + defer { try? FileManager.default.removeItem(at: root) } + + let checkpointID = "path-\(UUID().uuidString)" + let response = try jsonResponse(result: [ + "restore_record": [ + "mode": "direct", + "kind": "custom", + "checkpoint_id": checkpointID, + "working_directory": root.path, + "environment": ["PATH": "/usr/bin:"], + "launch_command": [ + "arguments": [executableName], + "executable_path": executableName, + "working_directory": root.path, + "environment": ["PATH": "/usr/bin:"], + ], + "prepared_arguments": [executableName], + ], + ]) + let socketPath = "/tmp/cmux-restore-path-\(UUID().uuidString.prefix(8)).sock" + let responder = try UnixSocketResponder(path: socketPath, response: response) + defer { responder.stop() } + var environment = ProcessInfo.processInfo.environment + for key in Array(environment.keys) where key.hasPrefix("CMUX_") { + environment.removeValue(forKey: key) + } + environment["CMUX_CLI_SENTRY_DISABLED"] = "1" + environment["CMUX_SOCKET_PATH"] = socketPath + environment["CMUX_SURFACE_ID"] = UUID().uuidString + + let result = runProcess( + executablePath: cliPath, + arguments: ["restore", "custom", checkpointID], + environment: environment, + timeout: 5 + ) + + XCTAssertFalse(result.timedOut, result.stdout) + XCTAssertEqual(result.status, 1, result.stdout) + XCTAssertTrue( + result.stdout.contains( + "restore: the saved agent command is unavailable. " + + "Make sure the agent is installed, then retry." + ), + result.stdout + ) + XCTAssertFalse(result.stdout.contains(executableName), result.stdout) + XCTAssertFalse(result.stdout.contains(root.path), result.stdout) + XCTAssertFalse(FileManager.default.fileExists(atPath: marker.path)) + } + + @Test func testRestorePreflightIsQuietAndTimesOut() throws { + let cliPath = try bundledCLIPath() + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux restore preflight \(UUID().uuidString)", isDirectory: true) + let executable = root.appendingPathComponent("fake hermes", isDirectory: false) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + try """ + #!/bin/sh + if [ "$1" = "config" ]; then + printf 'preflight stdout chatter\\n' + printf 'preflight stderr chatter\\n' >&2 + exec /bin/sleep 60 + fi + printf 'unexpected agent launch\\n' + """.write(to: executable, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes( + [.posixPermissions: 0o700], + ofItemAtPath: executable.path + ) + defer { try? FileManager.default.removeItem(at: root) } + + let checkpointID = "preflight-\(UUID().uuidString)" + let launchEnvironment = ["CUSTOM_BASE_URL": "https://codex.example.test/v1"] + let response = try jsonResponse(result: [ + "restore_record": [ + "mode": "resumeAgent", + "kind": "hermes-agent", + "checkpoint_id": checkpointID, + "working_directory": root.path, + "environment": launchEnvironment, + "launch_command": [ + "launcher": "hermes-agent", + "arguments": [ + executable.path, + "--provider", + "openai-codex", + ], + "executable_path": executable.path, + "working_directory": root.path, + "environment": launchEnvironment, + ], + ], + ]) + let socketPath = "/tmp/cmux-restore-preflight-\(UUID().uuidString.prefix(8)).sock" + let responder = try UnixSocketResponder(path: socketPath, response: response) + defer { responder.stop() } + var environment = ProcessInfo.processInfo.environment + for key in Array(environment.keys) where key.hasPrefix("CMUX_") { + environment.removeValue(forKey: key) + } + environment["CMUX_CLI_SENTRY_DISABLED"] = "1" + environment["CMUX_SOCKET_PATH"] = socketPath + environment["CMUX_SURFACE_ID"] = UUID().uuidString + + let result = runProcess( + executablePath: cliPath, + arguments: ["restore", "hermes-agent", checkpointID], + environment: environment, + timeout: 15 + ) + + XCTAssertFalse(result.timedOut, result.stdout) + XCTAssertEqual(result.status, 1, result.stdout) + XCTAssertTrue( + result.stdout.contains( + "restore: provider setup took too long. " + + "Check the provider connection, then retry." + ), + result.stdout + ) + XCTAssertFalse(result.stdout.contains("fake hermes"), result.stdout) + XCTAssertFalse(result.stdout.contains("model.provider"), result.stdout) + XCTAssertFalse(result.stdout.contains("preflight stdout chatter"), result.stdout) + XCTAssertFalse(result.stdout.contains("preflight stderr chatter"), result.stdout) + XCTAssertFalse(result.stdout.contains("unexpected agent launch"), result.stdout) + } + + @Test func testRestoreRetargetsPreparedCwdWhenPersistedDirectoryIsMissing() throws { + let cliPath = try bundledCLIPath() + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux missing cwd \(UUID().uuidString)", isDirectory: true) + let executable = root.appendingPathComponent("fake cwd agent", isDirectory: false) + let missingDirectory = root.appendingPathComponent("deleted", isDirectory: true) + let capturedDirectory = root.appendingPathComponent("captured", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + try """ + #!/bin/sh + printf 'pwd=%s\\n' "$PWD" + for argument in "$@"; do + printf 'arg=%s\\n' "$argument" + done + """.write(to: executable, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes( + [.posixPermissions: 0o700], + ofItemAtPath: executable.path + ) + defer { try? FileManager.default.removeItem(at: root) } + + let checkpointID = "cwd-\(UUID().uuidString)" + let preparedArguments = [ + executable.path, + "--cwd", + capturedDirectory.path, + "--session", + checkpointID, + ] + let response = try jsonResponse(result: [ + "restore_record": [ + "mode": "resumeAgent", + "kind": "cwd-agent", + "checkpoint_id": checkpointID, + "working_directory": missingDirectory.path, + "environment": [:], + "launch_command": [ + "arguments": [executable.path], + "executable_path": executable.path, + "working_directory": capturedDirectory.path, + ], + "prepared_arguments": preparedArguments, + "prepared_arguments_working_directory": capturedDirectory.path, + ], + ]) + let socketPath = "/tmp/cmux-missing-cwd-\(UUID().uuidString.prefix(8)).sock" + let responder = try UnixSocketResponder(path: socketPath, response: response) + defer { responder.stop() } + var environment = ProcessInfo.processInfo.environment + for key in Array(environment.keys) where key.hasPrefix("CMUX_") { + environment.removeValue(forKey: key) + } + environment["CMUX_CLI_SENTRY_DISABLED"] = "1" + environment["CMUX_SOCKET_PATH"] = socketPath + environment["CMUX_SURFACE_ID"] = UUID().uuidString + + let result = runProcess( + executablePath: cliPath, + arguments: ["restore", "cwd-agent", checkpointID], + environment: environment, + currentDirectoryURL: root, + timeout: 5 + ) + + XCTAssertFalse(result.timedOut, result.stdout) + XCTAssertEqual(result.status, 0, result.stdout) + XCTAssertTrue(result.stdout.contains("pwd=\(root.path)\n"), result.stdout) + XCTAssertTrue(result.stdout.contains("arg=\(root.path)\n"), result.stdout) + XCTAssertFalse(result.stdout.contains(missingDirectory.path), result.stdout) + } + + @Test func testRestoreRunsCommandOnlyLegacyRecordThroughCompatibilityShell() throws { + let cliPath = try bundledCLIPath() + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux legacy restore \(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let checkpointID = "legacy-\(UUID().uuidString)" + let response = try jsonResponse(result: [ + "restore_record": [ + "mode": "resumeAgent", + "kind": "codex", + "checkpoint_id": checkpointID, + "working_directory": root.path, + "environment": ["LEGACY_RESTORE_VALUE": "kept"], + "legacy_command": #"printf 'legacy=%s|%s\n' "$PWD" "$LEGACY_RESTORE_VALUE""#, + ], + ]) + let socketPath = "/tmp/cmux-legacy-\(UUID().uuidString.prefix(8)).sock" + let responder = try UnixSocketResponder(path: socketPath, response: response) + defer { responder.stop() } + var environment = ProcessInfo.processInfo.environment + for key in Array(environment.keys) where key.hasPrefix("CMUX_") { + environment.removeValue(forKey: key) + } + environment["CMUX_CLI_SENTRY_DISABLED"] = "1" + environment["CMUX_SOCKET_PATH"] = socketPath + environment["CMUX_SURFACE_ID"] = UUID().uuidString + environment["SHELL"] = "/bin/sh" + + let result = runProcess( + executablePath: cliPath, + arguments: ["restore", "codex", checkpointID], + environment: environment, + timeout: 5 + ) + + XCTAssertFalse(result.timedOut, result.stdout) + XCTAssertEqual(result.status, 0, result.stdout) + XCTAssertTrue(result.stdout.contains("legacy=\(root.path)|kept"), result.stdout) + } + + @Test func testRestoreFallsBackWhenStructuredPlannerCannotBuildInvocation() throws { + let cliPath = try bundledCLIPath() + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux structured fallback \(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let checkpointID = "fallback-\(UUID().uuidString)" + let response = try jsonResponse(result: [ + "restore_record": [ + "mode": "relaunchAgent", + "kind": "custom-relaunch", + "checkpoint_id": checkpointID, + "working_directory": root.path, + "environment": ["FALLBACK_VALUE": "structured"], + "launch_command": [ + "arguments": ["/missing/custom-relaunch"], + "executable_path": "/missing/custom-relaunch", + ], + "legacy_command": #"printf 'fallback=%s|%s\n' "$PWD" "$FALLBACK_VALUE""#, + ], + ]) + let socketPath = "/tmp/cmux-fallback-\(UUID().uuidString.prefix(8)).sock" + let responder = try UnixSocketResponder(path: socketPath, response: response) + defer { responder.stop() } + var environment = ProcessInfo.processInfo.environment + for key in Array(environment.keys) where key.hasPrefix("CMUX_") { + environment.removeValue(forKey: key) + } + environment["CMUX_CLI_SENTRY_DISABLED"] = "1" + environment["CMUX_SOCKET_PATH"] = socketPath + environment["CMUX_SURFACE_ID"] = UUID().uuidString + environment["SHELL"] = "/bin/sh" + + let result = runProcess( + executablePath: cliPath, + arguments: ["restore", "custom-relaunch", checkpointID], + environment: environment, + timeout: 5 + ) + + XCTAssertFalse(result.timedOut, result.stdout) + XCTAssertEqual(result.status, 0, result.stdout) + XCTAssertTrue( + result.stdout.contains("fallback=\(root.path)|structured"), + result.stdout + ) + } + + @Test func testRestorePositionalFormRequiresSurfaceContext() throws { + let cliPath = try bundledCLIPath() + var environment = ProcessInfo.processInfo.environment + for key in Array(environment.keys) where key.hasPrefix("CMUX_") { + environment.removeValue(forKey: key) + } + environment["CMUX_CLI_SENTRY_DISABLED"] = "1" + + let result = runProcess( + executablePath: cliPath, + arguments: ["restore", "codex", UUID().uuidString.lowercased()], + environment: environment, + timeout: 5 + ) + + XCTAssertFalse(result.timedOut, result.stdout) + XCTAssertEqual(result.status, 1, result.stdout) + XCTAssertTrue( + result.stdout.contains( + "restore: the current cmux surface could not be identified. " + + "Retry from this terminal or pass --surface ." + ), + result.stdout + ) + } + + @Test func testRestorePositionalFormFailsClosedWhenBindingIdentityDrifts() throws { + let cliPath = try bundledCLIPath() + let currentCheckpointID = UUID().uuidString.lowercased() + let response = try jsonResponse(result: [ + "restore_record": [ + "mode": "direct", + "kind": "codex", + "checkpoint_id": currentCheckpointID, + "environment": [:], + "launch_command": [ + "arguments": ["/usr/bin/true"], + "executable_path": "/usr/bin/true", + ], + "prepared_arguments": ["/usr/bin/true"], + ], + ]) + let socketPath = "/tmp/cmux-restore-drift-\(UUID().uuidString.prefix(8)).sock" + let responder = try UnixSocketResponder(path: socketPath, response: response) + defer { responder.stop() } + var environment = ProcessInfo.processInfo.environment + for key in Array(environment.keys) where key.hasPrefix("CMUX_") { + environment.removeValue(forKey: key) + } + environment["CMUX_CLI_SENTRY_DISABLED"] = "1" + environment["CMUX_SOCKET_PATH"] = socketPath + environment["CMUX_SURFACE_ID"] = UUID().uuidString + + for arguments in [ + ["restore", "claude", currentCheckpointID], + ["restore", "codex", UUID().uuidString.lowercased()], + ] { + let result = runProcess( + executablePath: cliPath, + arguments: arguments, + environment: environment, + timeout: 5 + ) + + XCTAssertFalse(result.timedOut, result.stdout) + XCTAssertEqual(result.status, 1, result.stdout) + XCTAssertTrue( + result.stdout.contains("Run 'cmux restore --surface'"), + result.stdout + ) + } + } + + @Test func testRestoreExplicitSocketFailureReportsTheSocketError() throws { + let cliPath = try bundledCLIPath() + let socketPath = "/tmp/cmux-restore-offline-\(UUID().uuidString.prefix(8)).sock" + unlink(socketPath) + var environment = ProcessInfo.processInfo.environment + for key in Array(environment.keys) where key.hasPrefix("CMUX_") { + environment.removeValue(forKey: key) + } + environment["CMUX_CLI_SENTRY_DISABLED"] = "1" + + let result = runProcess( + executablePath: cliPath, + arguments: [ + "--socket", + socketPath, + "restore", + "codex", + UUID().uuidString.lowercased(), + ], + environment: environment, + timeout: 5 + ) + + XCTAssertFalse(result.timedOut, result.stdout) + XCTAssertEqual(result.status, 1, result.stdout) + XCTAssertTrue( + result.stdout.contains("Socket not found at \(socketPath)"), + result.stdout + ) + XCTAssertFalse( + result.stdout.contains("Retry the visible restore command after cmux finishes opening."), + result.stdout + ) + } + + @Test func testRestoreWaitsForControlSocketDuringAppStartup() throws { + let cliPath = try bundledCLIPath() + let checkpointID = UUID().uuidString.lowercased() + let response = try jsonResponse(result: [ + "restore_record": [ + "mode": "direct", + "kind": "custom", + "checkpoint_id": checkpointID, + "environment": [:], + "launch_command": [ + "arguments": ["/usr/bin/true"], + "executable_path": "/usr/bin/true", + ], + "prepared_arguments": ["/usr/bin/true"], + ], + ]) + let socketPath = "/tmp/cmux-restore-startup-\(UUID().uuidString.prefix(8)).sock" + unlink(socketPath) + var responder: UnixSocketResponder? + defer { responder?.stop() } + var environment = ProcessInfo.processInfo.environment + for key in Array(environment.keys) where key.hasPrefix("CMUX_") { + environment.removeValue(forKey: key) + } + environment["CMUX_CLI_SENTRY_DISABLED"] = "1" + environment["CMUX_SOCKET_PATH"] = socketPath + environment["CMUX_SURFACE_ID"] = UUID().uuidString + + let result = runProcess( + executablePath: cliPath, + arguments: [ + "restore", + "custom", + checkpointID, + ], + environment: environment, + timeout: 5, + afterLaunch: { + usleep(100_000) + responder = try? UnixSocketResponder(path: socketPath, response: response) + } + ) + + let requiredResponder = try #require(responder) + XCTAssertFalse(result.timedOut, result.stdout) + XCTAssertEqual(result.status, 0, result.stdout) + XCTAssertEqual(requiredResponder.receivedRequests.count, 1) + } + @Test func testBundledCLIInTaggedDebugAppPrefersItsOwnSocketWithoutEnvironmentOverride() throws { let cliPath = try bundledCLIPath() let tagSlug = "cli-socket-\(UUID().uuidString.lowercased())" @@ -1321,6 +1863,14 @@ import Testing return home } + private func jsonResponse(result: [String: Any]) throws -> String { + let data = try JSONSerialization.data( + withJSONObject: ["ok": true, "result": result], + options: [] + ) + return try XCTUnwrap(String(data: data, encoding: .utf8)) + } + /// The stable control-socket path under an injected (temp) home, resolved via /// the canonical ``CmuxStateDirectory`` so the test exercises the real layout. private func stableSocketURL(home: URL) throws -> URL { @@ -1465,7 +2015,8 @@ import Testing arguments: [String], environment: [String: String], currentDirectoryURL: URL? = nil, - timeout: TimeInterval + timeout: TimeInterval, + afterLaunch: (() -> Void)? = nil ) -> ProcessRunResult { let process = Process() let outputPipe = Pipe() @@ -1482,6 +2033,7 @@ import Testing } catch { return ProcessRunResult(status: -1, stdout: String(describing: error), timedOut: false) } + afterLaunch?() let exitSignal = DispatchSemaphore(value: 0) DispatchQueue.global(qos: .userInitiated).async { @@ -1650,33 +2202,32 @@ final class UnixSocketResponder { private func handle(clientFD: Int32) { defer { close(clientFD) } - var request = Data() while true { - var byte: UInt8 = 0 - let count = read(clientFD, &byte, 1) - if count <= 0 { - return + var request = Data() + while true { + var byte: UInt8 = 0 + let count = read(clientFD, &byte, 1) + if count <= 0 { + return + } + request.append(byte) + if byte == 0x0A { + break + } } - request.append(byte) - if byte == 0x0A { - break + if let line = String(data: request, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) { + lock.lock() + requests.append(line) + lock.unlock() + } + if responseDelay > 0 { + Thread.sleep(forTimeInterval: responseDelay) + } + let payload = response + "\n" + payload.withCString { pointer in + _ = write(clientFD, pointer, strlen(pointer)) } - } - guard !request.isEmpty else { - return - } - if let line = String(data: request, encoding: .utf8)? - .trimmingCharacters(in: .whitespacesAndNewlines) { - lock.lock() - requests.append(line) - lock.unlock() - } - if responseDelay > 0 { - Thread.sleep(forTimeInterval: responseDelay) - } - let payload = response + "\n" - payload.withCString { pointer in - _ = write(clientFD, pointer, strlen(pointer)) } } diff --git a/cmuxTests/ForkParentFallbackResidualTests.swift b/cmuxTests/ForkParentFallbackResidualTests.swift index 87db7aad8563..ec83420113b4 100644 --- a/cmuxTests/ForkParentFallbackResidualTests.swift +++ b/cmuxTests/ForkParentFallbackResidualTests.swift @@ -1,3 +1,4 @@ +import CMUXAgentLaunch import Darwin import Foundation import SQLite3 @@ -157,16 +158,10 @@ struct ForkParentFallbackResidualTests { .snapshot(workspaceId: fixture.workspaceId, panelId: fixture.panelId) ) #expect(snapshot.workingDirectory == fixture.cwd.path) - let resumeInput = try #require(snapshot.resumeStartupInput( - fileManager: fixture.fileManager, - temporaryDirectory: fixture.root - )) - let resumeWords = TerminalStartupWorkingDirectoryPrefix.shellWordRanges(resumeInput).map(\.value) - #expect(resumeWords.first == "/bin/zsh") - let resumeScriptPath = try #require(resumeWords.dropFirst().first) - let resumeScript = try String(contentsOfFile: resumeScriptPath, encoding: .utf8) + let resumeInput = try #require(snapshot.resumeStartupInput()) #expect( - resumeScript.contains("/usr/bin/env 'CMUX_AGENT_RESTORE_LAUNCH=codex:\(sessionId)'") + resumeInput + == " \(AgentRestoreLaunch.cliStartupExecutableToken) restore codex \(sessionId)\n" ) #expect(snapshot.resumeCommand?.contains("cd -- '\(fixture.cwd.path)'") == true) #expect(snapshot.forkStartupInput()?.contains("cd -- '\(fixture.cwd.path)'") == true) diff --git a/cmuxTests/ResumeLauncherCwdConsistencyTests.swift b/cmuxTests/ResumeLauncherCwdConsistencyTests.swift index bd24718ce9d4..bcc1ba186539 100644 --- a/cmuxTests/ResumeLauncherCwdConsistencyTests.swift +++ b/cmuxTests/ResumeLauncherCwdConsistencyTests.swift @@ -1,3 +1,4 @@ +import CMUXAgentLaunch import Foundation import Testing @@ -9,8 +10,8 @@ import Testing @Suite("Resume launcher cwd consistency") struct ResumeLauncherCwdConsistencyTests { - @Test("launcher cwd and cwd-sensitive resume argv use the same restored directory") - func launcherAndResumeCommandShareRestoredWorkingDirectory() throws { + @Test("local restore keeps cwd-sensitive argv structured behind the short verb") + func localRestoreUsesShortVerbForCwdSensitiveAgent() throws { let fileManager = FileManager.default let root = fileManager.temporaryDirectory .appendingPathComponent("cmux-9200-retarget-\(UUID().uuidString)", isDirectory: true) @@ -43,18 +44,45 @@ struct ResumeLauncherCwdConsistencyTests { ) let input = try #require(snapshot.resumeStartupInput( - fileManager: fileManager, - temporaryDirectory: root, restoringWorkingDirectory: restoredDirectory.path )) - let words = TerminalStartupWorkingDirectoryPrefix.shellWordRanges( - input.trimmingCharacters(in: .whitespacesAndNewlines) - ).map(\.value) - let launcherPath = try #require(words.last) - let script = try String(contentsOfFile: launcherPath, encoding: .utf8) - #expect(script.contains("cd -- \(TerminalStartupShellQuoting.singleQuoted(restoredDirectory.path))")) - #expect(script.contains("'--cwd' '\(restoredDirectory.path)'")) - #expect(!script.contains(savedDirectory.path)) + #expect( + input + == " \(AgentRestoreLaunch.cliStartupExecutableToken) restore cwd-agent session-9200\n" + ) + #expect( + try fileManager.contentsOfDirectory( + at: root, + includingPropertiesForKeys: nil + ).map(\.lastPathComponent) == ["restored"] + ) + + let preparedArguments = try #require( + snapshot.preparedResumeArguments( + launchCommand: snapshot.launchCommand, + workingDirectory: restoredDirectory.path, + observedPermissionMode: nil + ) + ) + let invocation = try #require(AgentRestorePlanner( + executableFileResolver: AgentRestoreExecutableFileResolver() + ).invocation( + for: AgentRestoreRequest( + mode: .resumeAgent, + kind: snapshot.kind.rawValue, + checkpointID: snapshot.sessionId, + source: "session-snapshot", + workingDirectory: restoredDirectory.path, + environment: [:], + launchCommand: snapshot.launchCommand, + preparedArguments: preparedArguments, + observedPermissionMode: nil + ), + ambientEnvironment: ["PATH": "/usr/bin:/bin"] + )) + #expect(invocation.workingDirectory == restoredDirectory.path) + #expect(invocation.arguments.contains(restoredDirectory.path)) + #expect(!invocation.arguments.contains(savedDirectory.path)) } @Test("restored logical cwd survives physical-path shell reports") @@ -111,8 +139,7 @@ struct ResumeLauncherCwdConsistencyTests { ) let input = try #require(snapshot.resumeStartupInput( - allowLauncherScript: false, - allowOversizedInlineInput: true, + useLocalRestoreVerb: false, restoringWorkingDirectory: restoredDirectory )) #expect(input.contains("cd -- '\(restoredDirectory)'")) diff --git a/cmuxTests/SessionPersistenceResumeBindingTests.swift b/cmuxTests/SessionPersistenceResumeBindingTests.swift index b4fa1d60896e..72630d6ca6b5 100644 --- a/cmuxTests/SessionPersistenceResumeBindingTests.swift +++ b/cmuxTests/SessionPersistenceResumeBindingTests.swift @@ -1,3 +1,4 @@ +import CMUXAgentLaunch import Foundation import CmuxCore import Testing @@ -9,6 +10,92 @@ import Testing #endif @Suite struct SessionPersistenceResumeBindingTests { + @Test func structuredLaunchCaptureRoundTripsAdditively() throws { + let binding = SurfaceResumeBindingSnapshot( + name: "Codex", + kind: "codex", + command: "codex resume legacy-display-command", + cwd: "/tmp/项目 with 'quotes'", + checkpointId: "a22293b7-bcef-4707-8439-2f538c8517a4", + source: "agent-hook", + environment: ["CODEX_HOME": "/tmp/配置"], + launchCommand: AgentLaunchCommandSnapshot( + launcher: "codex", + executablePath: "/opt/company bin/codex", + arguments: [ + "/opt/company bin/codex", + "--model", + "gpt-5.6-sol", + "日本語", + ], + workingDirectory: "/tmp/项目 with 'quotes'", + environment: ["CODEX_HOME": "/tmp/配置"], + capturedAt: 123, + source: "process" + ), + autoResume: true + ) + + let decoded = try JSONDecoder().decode( + SurfaceResumeBindingSnapshot.self, + from: JSONEncoder().encode(binding) + ) + + #expect(decoded == binding) + #expect(decoded.launchCommand?.arguments == binding.launchCommand?.arguments) + #expect(decoded.command.contains("codex resume legacy-display-command")) + } + + @Test func v06420CommandOnlyBindingStillProducesRestoreVerb() throws { + let json = """ + { + "name": "Legacy custom agent", + "kind": "custom-agent", + "command": "legacy-agent --resume 'old checkpoint'", + "cwd": "/tmp/legacy", + "checkpointId": "old checkpoint", + "source": "agent-hook", + "environment": {"LEGACY_VALUE": "preserved"}, + "autoResume": true, + "approvalPolicy": "auto", + "approvalRecordId": "legacy-approval", + "updatedAt": 1 + } + """ + let binding = try JSONDecoder().decode( + SurfaceResumeBindingSnapshot.self, + from: Data(json.utf8) + ) + + #expect(binding.launchCommand == nil) + #expect(binding.permissionMode == nil) + #expect(binding.launchFlavor == .local) + #expect(binding.wasDecodedWithoutLaunchFlavor) + #expect(binding.environment == ["LEGACY_VALUE": "preserved"]) + #expect( + binding.restoreStartupInput() + == " \(AgentRestoreLaunch.cliStartupExecutableToken) restore --surface\n" + ) + } + + @Test func localRestoreUsesOneShortCLICommandRegardlessOfBindingSize() throws { + let sessionId = "a22293b7-bcef-4707-8439-2f538c8517a4" + let binding = SurfaceResumeBindingSnapshot( + kind: "codex", + command: "codex resume \(sessionId) " + String(repeating: "--config model_provider=subrouter ", count: 80), + checkpointId: sessionId, + source: "agent-hook", + autoResume: true + ) + + let startupInput = try #require(binding.restoreStartupInput()) + + #expect( + startupInput + == " \(AgentRestoreLaunch.cliStartupExecutableToken) restore codex \(sessionId)\n" + ) + } + @Test(arguments: ["codex", "claude"]) func agentHookRestoreBindingCarriesProviderAndSessionBoundAuthorization(kind: String) throws { let sessionId = "a22293b7-bcef-4707-8439-2f538c8517a4" @@ -314,8 +401,7 @@ import Testing autoResume: true ) - let startupInput = try #require(binding.startupInputWithLauncherScript( - allowLauncherScript: false, + let startupInput = try #require(binding.inlineStartupInput( repairPortableAgentExecutable: false )) #expect( @@ -325,24 +411,14 @@ import Testing } } - @Test @MainActor func remoteWorkspaceLocalTerminalResumeBindingUsesLocalRepair() throws { + @Test @MainActor func remoteWorkspaceLocalTerminalResumeBindingUsesShortLocalRestoreVerb() throws { let fileManager = FileManager.default let root = fileManager.temporaryDirectory .appendingPathComponent("cmux-local-resume-binding-\(UUID().uuidString)", isDirectory: true) let localDirectoryURL = root.appendingPathComponent("local repo", isDirectory: true) - let binURL = root.appendingPathComponent("bin", isDirectory: true) - let codexOutputURL = root.appendingPathComponent("codex-output.txt", isDirectory: false) try fileManager.createDirectory(at: localDirectoryURL, withIntermediateDirectories: true) - try fileManager.createDirectory(at: binURL, withIntermediateDirectories: true) defer { try? fileManager.removeItem(at: root) } - let fakeCodexURL = binURL.appendingPathComponent("codex", isDirectory: false) - try """ - #!/bin/sh - printf '%s|%s\\n' "$PWD" "$*" > "$CMUX_FAKE_CODEX_OUTPUT" - """.write(to: fakeCodexURL, atomically: true, encoding: .utf8) - try fileManager.setAttributes([.posixPermissions: 0o700], ofItemAtPath: fakeCodexURL.path) - let suiteName = "cmux-session-resume-binding-\(UUID().uuidString)" let defaults = try #require(UserDefaults(suiteName: suiteName)) defer { defaults.removePersistentDomain(forName: suiteName) } @@ -387,7 +463,7 @@ import Testing ) let oversizedArgument = String( repeating: "x", - count: SurfaceResumeBindingSnapshot.maxInlineStartupInputBytes + 1 + count: 901 ) let quotedDirectory = "'\(localDirectory)'" #expect(remoteWorkspace.setSurfaceResumeBinding( @@ -422,22 +498,12 @@ import Testing #expect(restoredPanel.surface.debugInitialCommand() == nil) let restoredInput = try #require(restoredPanel.surface.debugInitialInputForTesting()) #expect(restoredPanel.requestedWorkingDirectory == localDirectory) - let launcherScriptPath = try launcherScriptPath(from: restoredInput) - let launcherEnvironment = try makeOhMyZshLauncherEnvironment( - root: root, - integrationDir: shellIntegrationDirectory(), - pathPrefix: binURL.path, - codexShimURL: fakeCodexURL, - codexOutputURL: codexOutputURL - ) - try runLauncherUntilOutput( - scriptPath: launcherScriptPath, - environment: launcherEnvironment, - outputURL: codexOutputURL + #expect( + restoredInput + == " \(AgentRestoreLaunch.cliStartupExecutableToken) restore codex session-local-resume\n" ) - let codexOutput = try String(contentsOf: codexOutputURL, encoding: .utf8) - #expect(codexOutput.contains("\(localDirectory)|resume session-local-resume"), "\(codexOutput)") - #expect(!codexOutput.contains(staleExecutablePath), "\(codexOutput)") + #expect(!restoredInput.contains(staleExecutablePath)) + #expect(!restoredInput.contains(oversizedArgument)) } @Test func agentHookSurfaceResumeStartupInputPreservesExistingPATHManagedAgentExecutable() throws { @@ -552,159 +618,6 @@ import Testing } } - private func runLauncherUntilOutput( - scriptPath: String, - environment: [String: String], - outputURL: URL, - timeout: TimeInterval = 10 - ) throws { - let process = Process() - process.executableURL = URL(fileURLWithPath: "/bin/zsh") - process.arguments = [scriptPath] - process.environment = environment - let stderr = Pipe() - process.standardOutput = FileHandle.nullDevice - process.standardError = stderr - - try process.run() - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if let output = try? String(contentsOf: outputURL, encoding: .utf8), - !output.isEmpty { - if process.isRunning { - process.terminate() - process.waitUntilExit() - } - return - } - _ = RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(0.02)) - } - - if process.isRunning { - process.terminate() - process.waitUntilExit() - } - let errorText = String( - data: stderr.fileHandleForReading.readDataToEndOfFile(), - encoding: .utf8 - ) ?? "" - Issue.record("Launcher did not produce Codex output within \(Int(timeout))s. stderr: \(errorText)") - throw ResumeShellTimeout(shellDescription: "/bin/zsh \(scriptPath)", timeout: timeout) - } - - private func launcherScriptPath(from input: String) throws -> String { - let words = TerminalStartupWorkingDirectoryPrefix.shellWordRanges(input).map(\.value) - let launcherIndex = try #require( - words.lastIndex(of: "/bin/zsh"), - "Expected /bin/zsh launcher script input, saw: \(input)" - ) - return try #require( - words.dropFirst(launcherIndex + 1).first, - "Expected launcher script path after /bin/zsh, saw: \(input)" - ) - } - - private func shellIntegrationDirectory() -> URL { - URL(fileURLWithPath: #filePath) - .deletingLastPathComponent() - .deletingLastPathComponent() - .appendingPathComponent("Resources/shell-integration", isDirectory: true) - } - - private func makeOhMyZshLauncherEnvironment( - root: URL, - integrationDir: URL, - pathPrefix: String, - codexShimURL: URL, - codexOutputURL: URL - ) throws -> [String: String] { - let homeURL = root.appendingPathComponent("home", isDirectory: true) - let userZdotdirURL = root.appendingPathComponent("zdotdir", isDirectory: true) - let ohMyZshURL = root.appendingPathComponent("oh-my-zsh", isDirectory: true) - try FileManager.default.createDirectory(at: homeURL, withIntermediateDirectories: true) - try FileManager.default.createDirectory(at: userZdotdirURL, withIntermediateDirectories: true) - try writeOhMyZshFixture(at: ohMyZshURL) - try "\n".write( - to: userZdotdirURL.appendingPathComponent(".zshenv", isDirectory: false), - atomically: true, - encoding: .utf8 - ) - try """ - export ZSH="\(ohMyZshURL.path)" - export ZSH_DISABLE_COMPFIX=true - export DISABLE_AUTO_UPDATE=true - ZSH_THEME="" - plugins=(git zsh-autosuggestions zsh-syntax-highlighting) - source "$ZSH/oh-my-zsh.sh" - """.write( - to: userZdotdirURL.appendingPathComponent(".zshrc", isDirectory: false), - atomically: true, - encoding: .utf8 - ) - - return [ - "HOME": homeURL.path, - "TERM": "xterm-256color", - "SHELL": "/bin/zsh", - "USER": NSUserName(), - "PATH": "\(pathPrefix):/usr/bin:/bin", - "ZDOTDIR": integrationDir.path, - "CMUX_ZSH_ZDOTDIR": userZdotdirURL.path, - "CMUX_SHELL_INTEGRATION": "1", - "CMUX_SHELL_INTEGRATION_DIR": integrationDir.path, - "CMUX_ZSH_RESTORE_TERM": "xterm-256color", - "CMUX_CODEX_WRAPPER_SHIM": codexShimURL.path, - "CMUX_FAKE_CODEX_OUTPUT": codexOutputURL.path, - "ZSH_DISABLE_COMPFIX": "true", - "DISABLE_AUTO_UPDATE": "true", - ] - } - - private func writeOhMyZshFixture(at root: URL) throws { - let customPluginRoot = root.appendingPathComponent("custom/plugins", isDirectory: true) - let autosuggestionsURL = customPluginRoot - .appendingPathComponent("zsh-autosuggestions", isDirectory: true) - let syntaxHighlightingURL = customPluginRoot - .appendingPathComponent("zsh-syntax-highlighting", isDirectory: true) - try FileManager.default.createDirectory(at: autosuggestionsURL, withIntermediateDirectories: true) - try FileManager.default.createDirectory(at: syntaxHighlightingURL, withIntermediateDirectories: true) - - try """ - autoload -Uz add-zsh-hook - for plugin in $plugins; do - plugin_file="$ZSH/custom/plugins/$plugin/$plugin.plugin.zsh" - [[ -r "$plugin_file" ]] && source "$plugin_file" - done - """.write( - to: root.appendingPathComponent("oh-my-zsh.sh", isDirectory: false), - atomically: true, - encoding: .utf8 - ) - try """ - autoload -Uz add-zsh-hook - _cmux_test_autosuggest_precmd() { :; } - _cmux_test_autosuggest_preexec() { :; } - add-zsh-hook precmd _cmux_test_autosuggest_precmd - add-zsh-hook preexec _cmux_test_autosuggest_preexec - _cmux_test_autosuggest_self_insert() { zle .self-insert } - zle -N self-insert _cmux_test_autosuggest_self_insert - """.write( - to: autosuggestionsURL.appendingPathComponent("zsh-autosuggestions.plugin.zsh", isDirectory: false), - atomically: true, - encoding: .utf8 - ) - try """ - _cmux_test_syntax_highlighting_line_init() { :; } - _cmux_test_syntax_highlighting_line_finish() { :; } - zle -N zle-line-init _cmux_test_syntax_highlighting_line_init - zle -N zle-line-finish _cmux_test_syntax_highlighting_line_finish - """.write( - to: syntaxHighlightingURL.appendingPathComponent("zsh-syntax-highlighting.plugin.zsh", isDirectory: false), - atomically: true, - encoding: .utf8 - ) - } - private static func homeManagedExecutablePath(executableName: String, _ components: String...) -> String { localManagedExecutablePath(root: FileManager.default.homeDirectoryForCurrentUser, executableName: executableName, components) } diff --git a/cmuxTests/SessionPersistenceTests.swift b/cmuxTests/SessionPersistenceTests.swift index 2ef66614cba2..a5d68c82c72d 100644 --- a/cmuxTests/SessionPersistenceTests.swift +++ b/cmuxTests/SessionPersistenceTests.swift @@ -2122,7 +2122,7 @@ final class SocketListenerAcceptPolicyTests: XCTestCase { workingDirectory: nil, sessionId: "a22293b7-bcef-4707-8439-2f538c8517a4" ) - let resumeCommand = try XCTUnwrap(snapshot.resumeStartupInput(allowLauncherScript: false)) + let resumeCommand = try XCTUnwrap(snapshot.resumeStartupInput(useLocalRestoreVerb: false)) let recorded = try runClaudeResumeCommand( resumeCommand, @@ -2397,21 +2397,22 @@ final class SocketListenerAcceptPolicyTests: XCTestCase { ) ) - let startupInput = try XCTUnwrap(snapshot.resumeStartupInput(temporaryDirectory: root)) + 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." + "The short restore verb must stay ASCII-only; structured cwd never crosses the shell parser." ) - - // Local resume always uses the one-shot `/bin/zsh '